From 1e79c8c25854778fb71d1b326675c2e688ed9c08 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 9 Mar 2026 13:04:04 -0500 Subject: [PATCH 001/198] Create README.md for anyplotlib project Add initial README.md with project overview and usage examples. --- README.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 00000000..e6b28801 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ + + +Welcome to **anyplotlib** – a lightweight, interactive viewer for 1-D signals and +2-D images, backed by [anywidget](https://anywidget.dev/) and a pure-JavaScript +canvas renderer. The goal is to duplicate and extend the interactive plotting capabilities of Matplotlib, +although the scope is intentionally limited in the following ways: + +1. This uses the object-oriented API of Matplotlib, not the stateful pyplot interface. This means there is +no `plt.imshow` or `plt.plot` – instead, you create a viewer object and call methods on it to add data +and customize the plot. This is a deliberate choice to avoid the pitfalls of the stateful API. + +python +``` +import anyplotlib as apl +import matplotlib.pyplot as plt + +# matplotlib: +fig, axs = plt.subplots(1,1) +axs.imshow(...) + +# anyplotlib equivalent: +fig, axs = apl.subplots(1,1) +axs.imshow(...) +``` + +2. In matplotlib they use vector graphics (SVG) to render the plot, which is great for static images. It's especially +great for making publication-quality figures. (If you haven't try inkscape + matplotlib SVG output, +it's pretty amazing.) For interactivity, it can be slow. Anyplotlib uses a pure-JavaScript canvas renderer which is +much faster for interactive applications, but the quality of the output is not as good as vector graphics. This is a +trade-off that we are willing to make for the sake of interactivity. + +3. Matplotlib supports a wide range of marker styles, line styles, and other plot elements. Anyplotlib focuses on a +core set of features that are most commonly used in scientific plotting. This means that some of the more +esoteric features of Matplotlib may not be available in Anyplotlib. In general we try to match the lower level +`collections` API of Matplotlib. + +4. Each collection, plot, image is rendered as a single object on the canvas. This is highly performant and more +importantly allows for blitting. This is one of the main reasons why the `ipympl` backend of Matplotlib is so slow. + +5. Finally `anyplotlib` uses `AnyWidget` as the underlying widget framework. This means that it can be used in any +environment that supports `AnyWidget`, including Jupyter notebooks, JupyterLab, and PyCharm notebook preview. Under +the hood, `AnyWidget` uses a pure-JavaScript implementation of the widget protocol, which allows for fast rendering +and interactivity. + +**Disclaimer**: This project is in the early stages of development. Additionally many of the +javascript code was optimized using LLM's. That being said, the javascript/python code is fairly minimal, +and not too difficult to understand. + + +**Disclaimer #2**: Mostly this project is to see __if__ something like this is possible, it remains to be +seen if this can be developed into a full-fledged plotting library. The hope is that this can be. From 3816d0fd0955b4760fdb3ed84e2babe8e9947e06 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 9 Mar 2026 13:04:24 -0500 Subject: [PATCH 002/198] Add project name 'Anyplotlib' to README --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e6b28801..6d0be162 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ - +Anyplotlib +---------- Welcome to **anyplotlib** – a lightweight, interactive viewer for 1-D signals and 2-D images, backed by [anywidget](https://anywidget.dev/) and a pure-JavaScript From 166d58a3db43c1f0e72d04795a421f11f0d5cf7b Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Tue, 10 Mar 2026 10:33:49 -0500 Subject: [PATCH 003/198] Update README to refer to Figure object instead of viewer --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6d0be162..ba9ec48e 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ canvas renderer. The goal is to duplicate and extend the interactive plotting c although the scope is intentionally limited in the following ways: 1. This uses the object-oriented API of Matplotlib, not the stateful pyplot interface. This means there is -no `plt.imshow` or `plt.plot` – instead, you create a viewer object and call methods on it to add data +no `plt.imshow` or `plt.plot` – instead, you create a Figure object and call methods on it to add data and customize the plot. This is a deliberate choice to avoid the pitfalls of the stateful API. python From d727dd93dbf2072ce3d8fc42305cab3610f6eba7 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 9 Mar 2026 13:07:52 -0500 Subject: [PATCH 004/198] New Feature: Add example for 3D plotting support with surface, scatter, and line geometries --- Examples/plot_3d.py | 59 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 Examples/plot_3d.py diff --git a/Examples/plot_3d.py b/Examples/plot_3d.py new file mode 100644 index 00000000..b63706ad --- /dev/null +++ b/Examples/plot_3d.py @@ -0,0 +1,59 @@ +""" +3D Plotting +=========== +Demonstrate the three 3-D geometry types supported by +:meth:`~anyplotlib.figure_plots.Axes.plot_surface`, +:meth:`~anyplotlib.figure_plots.Axes.scatter3d`, and +:meth:`~anyplotlib.figure_plots.Axes.plot3d`. +Drag to rotate, scroll to zoom, press **R** to reset the view. +""" +import numpy as np +import anyplotlib as vw +# ── Surface ─────────────────────────────────────────────────────────────────── +x = np.linspace(-3, 3, 60) +y = np.linspace(-3, 3, 60) +XX, YY = np.meshgrid(x, y) +ZZ = np.sin(np.sqrt(XX**2 + YY**2)) +fig, ax = vw.subplots(1, 1, figsize=(520, 480)) +surf = ax.plot_surface(XX, YY, ZZ, + colormap="viridis", + x_label="x", y_label="y", z_label="sin(r)") +fig +# %% +# Scatter plot +# ------------ +rng = np.random.default_rng(1) +n = 300 +theta = rng.uniform(0, 2 * np.pi, n) +phi = rng.uniform(0, np.pi, n) +r = rng.uniform(0.6, 1.0, n) +xs = r * np.sin(phi) * np.cos(theta) +ys = r * np.sin(phi) * np.sin(theta) +zs = r * np.cos(phi) +fig2, ax2 = vw.subplots(1, 1, figsize=(480, 480)) +sc = ax2.scatter3d(xs, ys, zs, + color="#4fc3f7", point_size=3, + x_label="x", y_label="y", z_label="z") +fig2 +# %% +# 3-D line — parametric helix +# ---------------------------- +t = np.linspace(0, 4 * np.pi, 300) +hx = np.cos(t) +hy = np.sin(t) +hz = t / (4 * np.pi) +fig3, ax3 = vw.subplots(1, 1, figsize=(480, 480)) +ln = ax3.plot3d(hx, hy, hz, + color="#ff7043", linewidth=2, + x_label="cos t", y_label="sin t", z_label="t") +fig3 +# %% +# Update the surface data live +# ---------------------------- +# Call :meth:`~anyplotlib.figure_plots.Plot3D.update` to replace the geometry +# without recreating the panel. +ZZ2 = np.cos(np.sqrt(XX**2 + YY**2)) +surf.update(XX, YY, ZZ2) +surf.set_colormap("plasma") +surf.set_view(azimuth=30, elevation=40) +fig From 0b5b3d72f5c84079898666ef5c674ad54b84afd4 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 15 Mar 2026 20:09:26 -0500 Subject: [PATCH 005/198] New Feature: Implement two-tier event and callback system for interactive plots --- anyplotlib/callbacks.py | 222 +++++++++ anyplotlib/figure.py | 24 + anyplotlib/figure_esm.js | 54 ++- anyplotlib/figure_plots.py | 97 ++++ tests/test_events.py | 908 +++++++++++++++++++++++++++++++++++++ 5 files changed, 1299 insertions(+), 6 deletions(-) create mode 100644 anyplotlib/callbacks.py create mode 100644 tests/test_events.py diff --git a/anyplotlib/callbacks.py b/anyplotlib/callbacks.py new file mode 100644 index 00000000..6065a792 --- /dev/null +++ b/anyplotlib/callbacks.py @@ -0,0 +1,222 @@ +""" +callbacks.py +============ + +Two-tier event / callback system for anyplotlib plot objects. + +The two tiers map directly to what the JS already provides: + +``'change'`` — fires on every animation frame while a drag is in + progress (~16 ms cadence, coalesced by + ``requestAnimationFrame``). Keep callbacks here + **fast**: update a text readout, move a linked cursor. + +``'release'`` — fires exactly **once** when the interaction settles + (mouseup, scroll end, key press). Safe for expensive + work: re-fit a spectrum, recompute an integral, + re-render a dependent plot. + +Both tiers receive the same :class:`Event` object; the ``settled`` +boolean tells you which tier fired. + +Usage +----- + +.. code-block:: python + + import anyplotlib as apl + import numpy as np + + fig, ax = apl.subplots(1, 1) + v = ax.imshow(data, axes=[x, y], units="nm") + + # ── specific widget, two tiers ───────────────────────────────── + wid = v.add_widget("crosshair", cx=64, cy=64) + + @v.on_change(wid) # fast — every drag frame + def live(event): + readout.value = f"({event.cx:.1f}, {event.cy:.1f})" + + @v.on_release(wid) # slow work — only when drag settles + def settled(event): + recompute(event.cx, event.cy) + + # ── any widget on this panel, on release ─────────────────────── + @v.on_release() + def any_settled(event): + print(event) + + # ── 1-D vline ────────────────────────────────────────────────── + v1 = ax1.plot(spectrum, axes=[energy], units="eV") + wid2 = v1.add_vline_widget(x=284.8) + + @v1.on_release(wid2) + def peak_selected(event): + idx = np.searchsorted(energy, event.x) + print(f"{event.x:.2f} eV → I={spectrum[idx]:.4f}") + + # ── disconnect by CID (matplotlib-style) ─────────────────────── + cid = v.on_release(wid)(my_fn) + v.disconnect(cid) + + # ── single-fire pattern ──────────────────────────────────────── + @v.on_release(wid) + def once(event): + do_work(event) + v.disconnect(once._cid) +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Callable + + +# --------------------------------------------------------------------------- +# Event +# --------------------------------------------------------------------------- + +@dataclass +class Event: + """A single interactive event from the JS frontend. + + Attributes + ---------- + name : str + Event category: + + ``'widget_change'`` – 2-D overlay widget dragged / resized. + ``'vline_change'`` – 1-D vline widget moved. + ``'hline_change'`` – 1-D hline widget moved. + ``'range_change'`` – 1-D range widget moved / resized. + ``'zoom_change'`` – 2-D panel panned / zoomed. + ``'view_change'`` – 1-D view window panned / zoomed. + ``'rotate_change'`` – 3-D camera rotated / zoomed. + + panel_id : str + Internal panel identifier. + widget_id : str or None + The ``wid`` from ``add_widget`` / ``add_vline_widget`` etc., + or ``None`` for view / zoom / rotate events. + settled : bool + ``True`` when the interaction has finished (mouseup / scroll + end / key press). ``False`` while a drag is still live. + + Use this to gate expensive work:: + + def cb(event): + update_readout(event.cx) # always cheap + if event.settled: + recompute(event.cx) # only when done + + data : dict + Full updated widget or view-state dict. All keys are also + forwarded as top-level attributes:: + + event.cx # same as event.data['cx'] + event.x0 # same as event.data['x0'] + event.zoom # same as event.data['zoom'] + """ + + name: str + panel_id: str + widget_id: str | None + settled: bool + data: dict = field(default_factory=dict) + + def __getattr__(self, key: str) -> Any: + try: + return self.data[key] + except KeyError: + raise AttributeError( + f"Event has no attribute {key!r}. " + f"Available data keys: {list(self.data)}" + ) from None + + def __repr__(self) -> str: + parts = [ + f"name={self.name!r}", + f"panel_id={self.panel_id!r}", + f"settled={self.settled}", + ] + if self.widget_id is not None: + parts.append(f"widget_id={self.widget_id!r}") + _skip = {"id", "type", "color", "colormap_data", + "image_b64", "histogram_data", "colormap_name"} + shown = 0 + for k, v in self.data.items(): + if k in _skip or shown >= 6: + continue + parts.append( + f"{k}={v:.4g}" if isinstance(v, float) else f"{k}={v!r}" + ) + shown += 1 + return f"Event({', '.join(parts)})" + + +# --------------------------------------------------------------------------- +# CallbackRegistry +# --------------------------------------------------------------------------- + +class CallbackRegistry: + """Per-plot callback registry with change / release tiers and CID management. + + Instantiated once per plot object. Users interact through the + convenience methods on plot objects: + + * ``plot.on_change(wid)`` – decorator; fires every drag frame. + * ``plot.on_release(wid)`` – decorator; fires once on settle. + * ``plot.disconnect(cid)`` – remove a handler by integer CID. + + Matching rules + -------------- + An entry fires when **all** conditions match: + + * *tier* matches ``event.settled`` + (``'change'`` → ``not settled``, ``'release'`` → ``settled``). + * *name* is ``None`` (wildcard) **or** equals ``event.name``. + * *widget_id* is ``None`` (wildcard) **or** equals ``event.widget_id``. + """ + + def __init__(self) -> None: + self._next_cid: int = 1 + # {cid: (tier, name_or_None, widget_id_or_None, fn)} + self._entries: dict[int, tuple[str, str | None, str | None, Callable]] = {} + + # ------------------------------------------------------------------ + def connect(self, tier: str, name: str | None, + widget_id: str | None, fn: Callable) -> int: + """Register *fn* and return an integer CID. + + Parameters + ---------- + tier : ``'change'`` or ``'release'`` + name : event name to match, or ``None`` for any. + widget_id : widget id to match, or ``None`` for any. + fn : callable ``(event: Event) -> None``. + """ + if tier not in ("change", "release"): + raise ValueError("tier must be 'change' or 'release'") + cid = self._next_cid + self._next_cid += 1 + self._entries[cid] = (tier, name, widget_id, fn) + return cid + + def disconnect(self, cid: int) -> None: + """Remove the handler registered under *cid*. Silent if not found.""" + self._entries.pop(cid, None) + + def fire(self, event: "Event") -> None: + """Dispatch *event* to all matching handlers.""" + tier = "release" if event.settled else "change" + for _cid, (t, n, wid, fn) in list(self._entries.items()): + if t != tier: + continue + if n is not None and n != event.name: + continue + if wid is not None and wid != event.widget_id: + continue + fn(event) + + def __bool__(self) -> bool: + return bool(self._entries) + diff --git a/anyplotlib/figure.py b/anyplotlib/figure.py index 73689f1b..5d0aeae5 100644 --- a/anyplotlib/figure.py +++ b/anyplotlib/figure.py @@ -2,6 +2,7 @@ import json, pathlib import anywidget, numpy as np, traitlets from anyplotlib.figure_plots import GridSpec, SubplotSpec, Axes, Plot2D, PlotMesh, Plot3D +from anyplotlib.callbacks import Event __all__ = ["Figure", "GridSpec", "SubplotSpec", "subplots"] @@ -22,6 +23,7 @@ class Figure(anywidget.AnyWidget): layout_json = traitlets.Unicode("{}").tag(sync=True) fig_width = traitlets.Int(640).tag(sync=True) fig_height = traitlets.Int(480).tag(sync=True) + event_json = traitlets.Unicode("{}").tag(sync=True) _esm = _ESM_SOURCE def __init__(self, nrows=1, ncols=1, figsize=(640, 480), @@ -178,6 +180,28 @@ def _on_resize(self, change) -> None: for pid in self._plots_map: self._push(pid) + @traitlets.observe("event_json") + def _on_event(self, change) -> None: + """Dispatch a JS interaction event to the relevant plot's CallbackRegistry.""" + raw = change["new"] + if not raw or raw == "{}": + return + try: + msg = json.loads(raw) + except Exception: + return + panel_id = msg.get("panel_id", "") + name = msg.get("name", "unknown") + widget_id = msg.get("widget_id") + settled = bool(msg.get("settled", False)) + data = {k: v for k, v in msg.items() + if k not in ("panel_id", "name", "widget_id", "settled")} + event = Event(name=name, panel_id=panel_id, + widget_id=widget_id, settled=settled, data=data) + plot = self._plots_map.get(panel_id) + if plot is not None and hasattr(plot, "callbacks"): + plot.callbacks.fire(event) + # ── helpers ─────────────────────────────────────────────────────────────── def get_axes(self) -> list: return sorted(self._axes_map.values(), diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index fa9ca7c8..fac6386a 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -1183,6 +1183,16 @@ function render({ model, el }) { }); } + // ── event emission helper ─────────────────────────────────────────── + function _emitEvent(panelId, name, widgetId, settled, extraData) { + const payload = Object.assign( + { panel_id: panelId, name: name, widget_id: widgetId || null, settled: !!settled }, + extraData || {} + ); + model.set('event_json', JSON.stringify(payload)); + model.save_changes(); + } + overlayCanvas.addEventListener('mousedown', (e) => { if (e.button !== 0) return; dragStart = { mx: e.clientX, my: e.clientY, @@ -1198,6 +1208,8 @@ function render({ model, el }) { p.state.elevation = Math.max(-89, Math.min(89, dragStart.el - dy * 0.5)); draw3d(p); model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); + _emitEvent(p.id, 'rotate_change', null, false, + { azimuth: p.state.azimuth, elevation: p.state.elevation, zoom: p.state.zoom }); e.preventDefault(); }); document.addEventListener('mouseup', () => { @@ -1205,6 +1217,8 @@ function render({ model, el }) { dragStart = null; overlayCanvas.style.cursor = 'grab'; model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); + _emitEvent(p.id, 'rotate_change', null, true, + { azimuth: p.state.azimuth, elevation: p.state.elevation, zoom: p.state.zoom }); _scheduleCommit(); }); @@ -1213,6 +1227,8 @@ function render({ model, el }) { p.state.zoom = Math.max(0.1, Math.min(10, p.state.zoom * (e.deltaY > 0 ? 0.9 : 1.1))); draw3d(p); model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); + _emitEvent(p.id, 'zoom_change', null, false, + { azimuth: p.state.azimuth, elevation: p.state.elevation, zoom: p.state.zoom }); _scheduleCommit(); }, { passive: false }); @@ -1666,7 +1682,12 @@ function render({ model, el }) { p.isPanning=true; overlayCanvas.style.cursor='grabbing'; e.preventDefault(); }); document.addEventListener('mousemove',(e)=>{ - if(p.ovDrag2d){_doDrag2d(e,p);return;} + if(p.ovDrag2d){ + _doDrag2d(e,p); + const _dw=(p.state.overlay_widgets||[]).find(w=>w.id===p.ovDrag2d.id)||{}; + _emitEvent(p.id,(_dw.type||'widget')+'_change',p.ovDrag2d.id,false,_dw); + return; + } if(!p.isPanning) return; const st=p.state; if(!st) return; const rect=overlayCanvas.getBoundingClientRect(); @@ -1680,7 +1701,13 @@ function render({ model, el }) { _scheduleCommit(); e.preventDefault(); }); document.addEventListener('mouseup',(e)=>{ - if(p.ovDrag2d){p.ovDrag2d=null;overlayCanvas.style.cursor='default';return;} + if(p.ovDrag2d){ + const _did=p.ovDrag2d.id; + const _dw=(p.state.overlay_widgets||[]).find(w=>w.id===_did)||{}; + p.ovDrag2d=null; overlayCanvas.style.cursor='default'; + _emitEvent(p.id,(_dw.type||'widget')+'_change',_did,true,_dw); + return; + } if(!p.isPanning) return; p.isPanning=false; overlayCanvas.style.cursor='default'; const st=p.state; if(!st) return; @@ -1688,6 +1715,7 @@ function render({ model, el }) { st.center_x=Math.max(0,Math.min(1,panStart.cx-(e.clientX-panStart.mx)/rect.width/st.zoom)); st.center_y=Math.max(0,Math.min(1,panStart.cy-(e.clientY-panStart.my)/rect.height/st.zoom)); model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); + _emitEvent(p.id,'zoom_change',null,true,{center_x:st.center_x,center_y:st.center_y,zoom:st.zoom}); model.save_changes(); }); @@ -1843,7 +1871,12 @@ function render({ model, el }) { p.isPanning=true;overlayCanvas.style.cursor='grabbing';e.preventDefault(); }); document.addEventListener('mousemove',(e)=>{ - if(p.ovDrag){_doDrag1d(e,p);return;} + if(p.ovDrag){ + _doDrag1d(e,p); + const _dw=(p.state.overlay_widgets||[]).find(w=>w.id===p.ovDrag.id)||{}; + _emitEvent(p.id,(_dw.type||'widget')+'_change',p.ovDrag.id,false,_dw); + return; + } if(!p.isPanning) return; const st=p.state; if(!st) return; const r=_plotRect1d(p.pw,p.ph); @@ -1855,9 +1888,18 @@ function render({ model, el }) { draw1d(p);_propagateView1d(p); model.set(`panel_${p.id}_json`,JSON.stringify(st));_scheduleCommit();e.preventDefault(); }); - document.addEventListener('mouseup',()=>{ - if(p.ovDrag){p.ovDrag=null;overlayCanvas.style.cursor='crosshair';} - if(p.isPanning){p.isPanning=false;overlayCanvas.style.cursor='crosshair';} + document.addEventListener('mouseup',(e)=>{ + if(p.ovDrag){ + const _did=p.ovDrag.id; + const _dw=(p.state.overlay_widgets||[]).find(w=>w.id===_did)||{}; + p.ovDrag=null; overlayCanvas.style.cursor='crosshair'; + _emitEvent(p.id,(_dw.type||'widget')+'_change',_did,true,_dw); + } + if(p.isPanning){ + p.isPanning=false; overlayCanvas.style.cursor='crosshair'; + const st=p.state; + if(st) _emitEvent(p.id,'view_change',null,true,{view_x0:st.view_x0,view_x1:st.view_x1}); + } }); overlayCanvas.addEventListener('keydown',(e)=>{ diff --git a/anyplotlib/figure_plots.py b/anyplotlib/figure_plots.py index f1fee092..813b816d 100644 --- a/anyplotlib/figure_plots.py +++ b/anyplotlib/figure_plots.py @@ -22,6 +22,7 @@ import numpy as np from anyplotlib.markers import MarkerRegistry +from anyplotlib.callbacks import CallbackRegistry __all__ = ["GridSpec", "SubplotSpec", "Axes", "Plot1D", "Plot2D", "PlotMesh", "Plot3D"] @@ -430,6 +431,7 @@ def __init__(self, data: np.ndarray, self.markers = MarkerRegistry(self._push_markers, allowed=MarkerRegistry._KNOWN_2D) + self.callbacks = CallbackRegistry() @staticmethod def _encode_bytes(arr: np.ndarray) -> str: @@ -616,6 +618,29 @@ def add_crosshair_widget(self, cx=None, cy=None, color="#00e5ff") -> str: return self.add_widget("crosshair", color=color, cx=cx or iw/2, cy=cy or ih/2) + # ------------------------------------------------------------------ + # Callback API + # ------------------------------------------------------------------ + def on_change(self, widget_id=None): + """Fires on every drag/scroll frame. Keep callbacks fast.""" + def decorator(fn): + cid = self.callbacks.connect("change", None, widget_id, fn) + fn._cid = cid + return fn + return decorator + + def on_release(self, widget_id=None): + """Fires once when drag settles. Safe for expensive work.""" + def decorator(fn): + cid = self.callbacks.connect("release", None, widget_id, fn) + fn._cid = cid + return fn + return decorator + + def disconnect(self, cid: int) -> None: + """Remove the callback registered under integer *cid*.""" + self.callbacks.disconnect(cid) + # ------------------------------------------------------------------ # Marker API (matplotlib-style kwargs → MarkerRegistry) # ------------------------------------------------------------------ @@ -847,6 +872,7 @@ def __init__(self, data: np.ndarray, self.markers = MarkerRegistry(self._push_markers, allowed=MarkerRegistry._KNOWN_MESH) + self.callbacks = CallbackRegistry() def _push(self) -> None: if self._fig is None: @@ -945,6 +971,29 @@ def colormap_name(self) -> str: def colormap_name(self, name: str) -> None: self.set_colormap(name) + # ------------------------------------------------------------------ + # Callback API (PlotMesh) + # ------------------------------------------------------------------ + def on_change(self, widget_id=None): + """Fires on every drag/zoom frame. Keep callbacks fast.""" + def decorator(fn): + cid = self.callbacks.connect("change", None, widget_id, fn) + fn._cid = cid + return fn + return decorator + + def on_release(self, widget_id=None): + """Fires once when drag/zoom settles.""" + def decorator(fn): + cid = self.callbacks.connect("release", None, widget_id, fn) + fn._cid = cid + return fn + return decorator + + def disconnect(self, cid: int) -> None: + """Remove the callback registered under integer *cid*.""" + self.callbacks.disconnect(cid) + # ------------------------------------------------------------------ # Marker API (circles and lines only) # ------------------------------------------------------------------ @@ -1102,6 +1151,7 @@ def __init__(self, geom_type: str, "zoom": float(zoom), "data_bounds": data_bounds, } + self.callbacks = CallbackRegistry() # ------------------------------------------------------------------ def _push(self) -> None: @@ -1112,6 +1162,29 @@ def _push(self) -> None: def to_state_dict(self) -> dict: return dict(self._state) + # ------------------------------------------------------------------ + # Callback API (Plot3D) + # ------------------------------------------------------------------ + def on_change(self, widget_id=None): + """Fires on every rotation/zoom frame. Keep callbacks fast.""" + def decorator(fn): + cid = self.callbacks.connect("change", None, widget_id, fn) + fn._cid = cid + return fn + return decorator + + def on_release(self, widget_id=None): + """Fires once when rotation/zoom settles.""" + def decorator(fn): + cid = self.callbacks.connect("release", None, widget_id, fn) + fn._cid = cid + return fn + return decorator + + def disconnect(self, cid: int) -> None: + """Remove the callback registered under integer *cid*.""" + self.callbacks.disconnect(cid) + # ------------------------------------------------------------------ # Display settings # ------------------------------------------------------------------ @@ -1234,6 +1307,7 @@ def __init__(self, data: np.ndarray, self.markers = MarkerRegistry(self._push_markers, allowed=MarkerRegistry._KNOWN_1D) + self.callbacks = CallbackRegistry() def _push(self) -> None: if self._fig is None: @@ -1378,6 +1452,29 @@ def clear_widgets(self) -> None: self._state["overlay_widgets"] = [] self._push() + # ------------------------------------------------------------------ + # Callback API (Plot1D) + # ------------------------------------------------------------------ + def on_change(self, widget_id=None): + """Fires on every drag frame. Keep callbacks fast.""" + def decorator(fn): + cid = self.callbacks.connect("change", None, widget_id, fn) + fn._cid = cid + return fn + return decorator + + def on_release(self, widget_id=None): + """Fires once when drag settles. Safe for expensive work.""" + def decorator(fn): + cid = self.callbacks.connect("release", None, widget_id, fn) + fn._cid = cid + return fn + return decorator + + def disconnect(self, cid: int) -> None: + """Remove the callback registered under integer *cid*.""" + self.callbacks.disconnect(cid) + # ------------------------------------------------------------------ # View control # ------------------------------------------------------------------ diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 00000000..80605a3e --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,908 @@ +""" +tests/test_events.py +==================== + +Tests for the callback / event system: + + * CallbackRegistry – unit tests for connect / disconnect / fire + * Event – attribute forwarding, repr + * Plot2D callbacks – on_change / on_release / disconnect / single-fire + * Plot1D callbacks – same API, different event names + * PlotMesh callbacks + * Plot3D callbacks + * Figure._on_event – JSON dispatch from model to plot registry + * Filtering – tier, name, widget_id wildcards and exact matches + * Practical patterns +""" + +from __future__ import annotations + +import json +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.callbacks import CallbackRegistry, Event +from anyplotlib.figure_plots import Plot1D, Plot2D, PlotMesh, Plot3D + + +# ───────────────────────────────────────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────────────────────────────────────── + +def _event(name="widget_change", panel_id="p1", widget_id="w1", + settled=True, **data): + return Event(name=name, panel_id=panel_id, widget_id=widget_id, + settled=settled, data=data) + + +def _change_event(**kw): + return _event(settled=False, **kw) + + +def _release_event(**kw): + return _event(settled=True, **kw) + + +def _plot2d(): + fig, ax = apl.subplots(1, 1) + return ax.imshow(np.zeros((32, 32))) + + +def _plot1d(): + fig, ax = apl.subplots(1, 1) + return ax.plot(np.zeros(64)) + + +def _plotmesh(): + fig, ax = apl.subplots(1, 1) + return ax.pcolormesh(np.zeros((8, 8))) + + +def _plot3d(): + fig, ax = apl.subplots(1, 1) + x = np.linspace(-1, 1, 10) + y = np.linspace(-1, 1, 10) + X, Y = np.meshgrid(x, y) + Z = X ** 2 + Y ** 2 + return ax.plot_surface(X, Y, Z) + + +# ───────────────────────────────────────────────────────────────────────────── +# 1. Event dataclass +# ───────────────────────────────────────────────────────────────────────────── + +class TestEvent: + def test_basic_fields(self): + ev = Event(name="zoom_change", panel_id="abc", widget_id=None, + settled=True, data={"zoom": 2.5, "center_x": 0.4}) + assert ev.name == "zoom_change" + assert ev.panel_id == "abc" + assert ev.widget_id is None + assert ev.settled is True + + def test_data_attribute_forwarding(self): + ev = _event(cx=12.5, cy=8.0) + assert ev.cx == pytest.approx(12.5) + assert ev.cy == pytest.approx(8.0) + + def test_unknown_attribute_raises(self): + ev = _event(cx=1.0) + with pytest.raises(AttributeError, match="Event has no attribute 'nonexistent'"): + _ = ev.nonexistent + + def test_repr_contains_name_and_settled(self): + ev = _event(name="rotate_change", settled=False, azimuth=45.0) + r = repr(ev) + assert "rotate_change" in r + assert "settled=False" in r + + def test_repr_shows_widget_id_when_set(self): + ev = _event(widget_id="mywidget") + assert "mywidget" in repr(ev) + + def test_repr_omits_widget_id_when_none(self): + ev = _event(name="zoom_change", widget_id=None) + assert "widget_id" not in repr(ev) + + def test_data_key_forwarding_various_types(self): + ev = _event(x=1.1, text="hello", flag=True, n=7) + assert ev.x == pytest.approx(1.1) + assert ev.text == "hello" + assert ev.flag is True + assert ev.n == 7 + + def test_empty_data(self): + ev = Event(name="view_change", panel_id="p", widget_id=None, + settled=True, data={}) + with pytest.raises(AttributeError): + _ = ev.anything + + +# ───────────────────────────────────────────────────────────────────────────── +# 2. CallbackRegistry – unit tests +# ───────────────────────────────────────────────────────────────────────────── + +class TestCallbackRegistry: + + # ── connect / disconnect ───────────────────────────────────────────────── + + def test_connect_returns_incrementing_cids(self): + reg = CallbackRegistry() + cid1 = reg.connect("change", None, None, lambda e: None) + cid2 = reg.connect("release", None, None, lambda e: None) + assert isinstance(cid1, int) + assert isinstance(cid2, int) + assert cid2 > cid1 + + def test_disconnect_removes_handler(self): + reg = CallbackRegistry() + fired = [] + cid = reg.connect("release", None, None, lambda e: fired.append(e)) + reg.disconnect(cid) + reg.fire(_release_event()) + assert fired == [] + + def test_disconnect_unknown_cid_is_silent(self): + reg = CallbackRegistry() + reg.disconnect(9999) # should not raise + + def test_disconnect_twice_is_silent(self): + reg = CallbackRegistry() + cid = reg.connect("release", None, None, lambda e: None) + reg.disconnect(cid) + reg.disconnect(cid) # should not raise + + def test_bool_false_when_empty(self): + assert not CallbackRegistry() + + def test_bool_true_when_connected(self): + reg = CallbackRegistry() + reg.connect("change", None, None, lambda e: None) + assert reg + + def test_bool_false_after_all_disconnected(self): + reg = CallbackRegistry() + cid = reg.connect("change", None, None, lambda e: None) + reg.disconnect(cid) + assert not reg + + def test_invalid_tier_raises(self): + reg = CallbackRegistry() + with pytest.raises(ValueError, match="tier must be"): + reg.connect("invalid", None, None, lambda e: None) + + # ── tier dispatch ──────────────────────────────────────────────────────── + + def test_change_tier_fires_on_not_settled(self): + reg = CallbackRegistry() + fired = [] + reg.connect("change", None, None, lambda e: fired.append(e)) + reg.fire(_change_event()) + assert len(fired) == 1 + assert not fired[0].settled + + def test_change_tier_does_not_fire_on_settled(self): + reg = CallbackRegistry() + fired = [] + reg.connect("change", None, None, lambda e: fired.append(e)) + reg.fire(_release_event()) + assert fired == [] + + def test_release_tier_fires_on_settled(self): + reg = CallbackRegistry() + fired = [] + reg.connect("release", None, None, lambda e: fired.append(e)) + reg.fire(_release_event()) + assert len(fired) == 1 + assert fired[0].settled + + def test_release_tier_does_not_fire_on_not_settled(self): + reg = CallbackRegistry() + fired = [] + reg.connect("release", None, None, lambda e: fired.append(e)) + reg.fire(_change_event()) + assert fired == [] + + def test_both_tiers_independent(self): + reg = CallbackRegistry() + change_fired, release_fired = [], [] + reg.connect("change", None, None, lambda e: change_fired.append(e)) + reg.connect("release", None, None, lambda e: release_fired.append(e)) + reg.fire(_change_event()) + reg.fire(_release_event()) + assert len(change_fired) == 1 + assert len(release_fired) == 1 + + # ── name filtering ─────────────────────────────────────────────────────── + + def test_name_wildcard_matches_any(self): + reg = CallbackRegistry() + fired = [] + reg.connect("release", None, None, lambda e: fired.append(e)) + reg.fire(_release_event(name="zoom_change")) + reg.fire(_release_event(name="view_change")) + assert len(fired) == 2 + + def test_name_exact_match_fires(self): + reg = CallbackRegistry() + fired = [] + reg.connect("release", "zoom_change", None, lambda e: fired.append(e)) + reg.fire(_release_event(name="zoom_change")) + assert len(fired) == 1 + + def test_name_exact_match_does_not_fire_other_name(self): + reg = CallbackRegistry() + fired = [] + reg.connect("release", "zoom_change", None, lambda e: fired.append(e)) + reg.fire(_release_event(name="view_change")) + assert fired == [] + + # ── widget_id filtering ────────────────────────────────────────────────── + + def test_widget_id_wildcard_matches_any(self): + reg = CallbackRegistry() + fired = [] + reg.connect("release", None, None, lambda e: fired.append(e)) + reg.fire(_release_event(widget_id="abc")) + reg.fire(_release_event(widget_id="xyz")) + assert len(fired) == 2 + + def test_widget_id_exact_match_fires(self): + reg = CallbackRegistry() + fired = [] + reg.connect("release", None, "abc", lambda e: fired.append(e)) + reg.fire(_release_event(widget_id="abc")) + assert len(fired) == 1 + + def test_widget_id_exact_match_does_not_fire_other_widget(self): + reg = CallbackRegistry() + fired = [] + reg.connect("release", None, "abc", lambda e: fired.append(e)) + reg.fire(_release_event(widget_id="xyz")) + assert fired == [] + + def test_widget_id_exact_does_not_match_none_widget(self): + reg = CallbackRegistry() + fired = [] + reg.connect("release", None, "abc", lambda e: fired.append(e)) + reg.fire(_release_event(widget_id=None)) + assert fired == [] + + def test_widget_id_wildcard_matches_none_widget(self): + """Wildcard (None) fires even for zoom/view events where widget_id=None.""" + reg = CallbackRegistry() + fired = [] + reg.connect("release", None, None, lambda e: fired.append(e)) + reg.fire(_release_event(name="zoom_change", widget_id=None)) + assert len(fired) == 1 + + # ── combined filtering ─────────────────────────────────────────────────── + + def test_all_conditions_must_match(self): + reg = CallbackRegistry() + fired = [] + reg.connect("release", "widget_change", "w1", lambda e: fired.append(e)) + reg.fire(_change_event( name="widget_change", widget_id="w1")) # wrong tier + reg.fire(_release_event( name="zoom_change", widget_id="w1")) # wrong name + reg.fire(_release_event( name="widget_change", widget_id="w2")) # wrong widget + assert fired == [] + reg.fire(_release_event( name="widget_change", widget_id="w1")) # all match + assert len(fired) == 1 + + # ── multiple callbacks ─────────────────────────────────────────────────── + + def test_multiple_handlers_all_called(self): + reg = CallbackRegistry() + log = [] + reg.connect("release", None, None, lambda e: log.append("a")) + reg.connect("release", None, None, lambda e: log.append("b")) + reg.connect("release", None, None, lambda e: log.append("c")) + reg.fire(_release_event()) + assert sorted(log) == ["a", "b", "c"] + + def test_disconnect_only_removes_one(self): + reg = CallbackRegistry() + log = [] + cid1 = reg.connect("release", None, None, lambda e: log.append("a")) + reg.connect( "release", None, None, lambda e: log.append("b")) + reg.disconnect(cid1) + reg.fire(_release_event()) + assert log == ["b"] + + def test_disconnect_inside_callback_is_safe(self): + """Disconnecting from within a callback should not crash.""" + reg = CallbackRegistry() + fired = [] + + def self_disconnect(event): + fired.append(event) + reg.disconnect(self_disconnect._cid) + + self_disconnect._cid = reg.connect("release", None, None, self_disconnect) + reg.fire(_release_event()) + reg.fire(_release_event()) # handler already removed + assert len(fired) == 1 + + def test_no_handlers_fire_is_noop(self): + CallbackRegistry().fire(_release_event()) # should not raise + + +# ───────────────────────────────────────────────────────────────────────────── +# 3. Plot2D callback API +# ───────────────────────────────────────────────────────────────────────────── + +class TestPlot2DCallbacks: + + def test_has_callbacks_registry(self): + assert isinstance(_plot2d().callbacks, CallbackRegistry) + + def test_on_change_decorator_fires_on_change(self): + v = _plot2d() + fired = [] + + @v.on_change() + def cb(event): fired.append(event) + + v.callbacks.fire(_change_event()) + assert len(fired) == 1 + + def test_on_change_does_not_fire_on_release(self): + v = _plot2d() + fired = [] + + @v.on_change() + def cb(event): fired.append(event) + + v.callbacks.fire(_release_event()) + assert fired == [] + + def test_on_release_decorator_fires_on_release(self): + v = _plot2d() + fired = [] + + @v.on_release() + def cb(event): fired.append(event) + + v.callbacks.fire(_release_event()) + assert len(fired) == 1 + + def test_on_release_does_not_fire_on_change(self): + v = _plot2d() + fired = [] + + @v.on_release() + def cb(event): fired.append(event) + + v.callbacks.fire(_change_event()) + assert fired == [] + + def test_decorator_assigns_cid(self): + v = _plot2d() + + @v.on_release() + def cb(event): pass + + assert hasattr(cb, "_cid") and isinstance(cb._cid, int) + + def test_disconnect(self): + v = _plot2d() + fired = [] + + @v.on_release() + def cb(event): fired.append(event) + + v.disconnect(cb._cid) + v.callbacks.fire(_release_event()) + assert fired == [] + + def test_widget_id_filter(self): + v = _plot2d() + wid = v.add_widget("crosshair") + fired = [] + + @v.on_release(wid) + def cb(event): fired.append(event) + + v.callbacks.fire(_release_event(widget_id="other")) + assert fired == [] + v.callbacks.fire(_release_event(widget_id=wid)) + assert len(fired) == 1 + + def test_wildcard_fires_for_any_widget(self): + v = _plot2d() + fired = [] + + @v.on_release() + def cb(event): fired.append(event) + + v.callbacks.fire(_release_event(widget_id="any1")) + v.callbacks.fire(_release_event(widget_id="any2")) + assert len(fired) == 2 + + def test_single_fire_pattern(self): + v = _plot2d() + fired = [] + + @v.on_release() + def once(event): + fired.append(event) + v.disconnect(once._cid) + + v.callbacks.fire(_release_event()) + v.callbacks.fire(_release_event()) + assert len(fired) == 1 + + def test_zoom_event_no_widget_id(self): + v = _plot2d() + fired = [] + + @v.on_release() + def cb(event): fired.append(event) + + v.callbacks.fire(_release_event(name="zoom_change", widget_id=None, + center_x=0.6, center_y=0.4, zoom=3.0)) + assert len(fired) == 1 + assert fired[0].zoom == pytest.approx(3.0) + + +# ───────────────────────────────────────────────────────────────────────────── +# 4. Plot1D callback API +# ───────────────────────────────────────────────────────────────────────────── + +class TestPlot1DCallbacks: + + def test_has_callbacks_registry(self): + assert isinstance(_plot1d().callbacks, CallbackRegistry) + + def test_on_change_and_on_release(self): + v = _plot1d() + change_fired, release_fired = [], [] + + @v.on_change() + def lv(event): change_fired.append(event) + + @v.on_release() + def done(event): release_fired.append(event) + + v.callbacks.fire(_change_event(name="vline_change")) + v.callbacks.fire(_release_event(name="vline_change")) + assert len(change_fired) == 1 + assert len(release_fired) == 1 + + def test_vline_widget_filter(self): + v = _plot1d() + wid = v.add_vline_widget(x=10.0) + fired = [] + + @v.on_release(wid) + def cb(event): fired.append(event) + + v.callbacks.fire(_release_event(name="vline_change", widget_id="other")) + assert fired == [] + v.callbacks.fire(_release_event(name="vline_change", widget_id=wid)) + assert len(fired) == 1 + + def test_range_widget_filter(self): + v = _plot1d() + wid = v.add_range_widget(x0=5.0, x1=15.0) + fired = [] + + @v.on_release(wid) + def cb(event): fired.append(event) + + v.callbacks.fire(_release_event(name="range_change", widget_id=wid, + x0=5.0, x1=15.0)) + assert len(fired) == 1 + + def test_view_change_event_data(self): + v = _plot1d() + fired = [] + + @v.on_release() + def cb(event): fired.append(event) + + v.callbacks.fire(_release_event(name="view_change", widget_id=None, + view_x0=0.2, view_x1=0.8)) + assert len(fired) == 1 + assert fired[0].view_x0 == pytest.approx(0.2) + assert fired[0].view_x1 == pytest.approx(0.8) + + def test_disconnect(self): + v = _plot1d() + fired = [] + + @v.on_change() + def cb(event): fired.append(event) + + v.disconnect(cb._cid) + v.callbacks.fire(_change_event()) + assert fired == [] + + def test_hline_widget_filter(self): + v = _plot1d() + wid = v.add_hline_widget(y=0.5) + fired = [] + + @v.on_release(wid) + def cb(event): fired.append(event) + + v.callbacks.fire(_release_event(name="hline_change", widget_id=wid, y=0.5)) + assert len(fired) == 1 + assert fired[0].y == pytest.approx(0.5) + + +# ───────────────────────────────────────────────────────────────────────────── +# 5. PlotMesh callback API +# ───────────────────────────────────────────────────────────────────────────── + +class TestPlotMeshCallbacks: + + def test_has_callbacks_registry(self): + assert isinstance(_plotmesh().callbacks, CallbackRegistry) + + def test_on_change_and_on_release(self): + v = _plotmesh() + change_fired, release_fired = [], [] + + @v.on_change() + def lv(event): change_fired.append(event) + + @v.on_release() + def done(event): release_fired.append(event) + + v.callbacks.fire(_change_event()) + v.callbacks.fire(_release_event()) + assert len(change_fired) == 1 + assert len(release_fired) == 1 + + def test_disconnect(self): + v = _plotmesh() + fired = [] + + @v.on_release() + def cb(event): fired.append(event) + + v.disconnect(cb._cid) + v.callbacks.fire(_release_event()) + assert fired == [] + + def test_zoom_event(self): + v = _plotmesh() + fired = [] + + @v.on_release() + def cb(event): fired.append(event) + + v.callbacks.fire(_release_event(name="zoom_change", widget_id=None, + center_x=0.5, center_y=0.5, zoom=2.0)) + assert fired[0].zoom == pytest.approx(2.0) + + +# ───────────────────────────────────────────────────────────────────────────── +# 6. Plot3D callback API +# ───────────────────────────────────────────────────────────────────────────── + +class TestPlot3DCallbacks: + + def test_has_callbacks_registry(self): + assert isinstance(_plot3d().callbacks, CallbackRegistry) + + def test_on_change_rotation(self): + v = _plot3d() + fired = [] + + @v.on_change() + def cb(event): fired.append(event) + + v.callbacks.fire(_change_event(name="rotate_change", widget_id=None, + azimuth=45.0, elevation=30.0, zoom=1.0)) + assert len(fired) == 1 + assert fired[0].azimuth == pytest.approx(45.0) + + def test_on_release_rotation_data(self): + v = _plot3d() + fired = [] + + @v.on_release() + def cb(event): fired.append(event) + + v.callbacks.fire(_release_event(name="rotate_change", widget_id=None, + azimuth=-60.0, elevation=20.0, zoom=2.5)) + assert fired[0].zoom == pytest.approx(2.5) + assert fired[0].elevation == pytest.approx(20.0) + + def test_on_release_zoom(self): + v = _plot3d() + fired = [] + + @v.on_release() + def cb(event): fired.append(event) + + v.callbacks.fire(_release_event(name="zoom_change", widget_id=None, + zoom=1.5, azimuth=0.0, elevation=30.0)) + assert fired[0].zoom == pytest.approx(1.5) + + def test_disconnect(self): + v = _plot3d() + fired = [] + + @v.on_release() + def cb(event): fired.append(event) + + v.disconnect(cb._cid) + v.callbacks.fire(_release_event()) + assert fired == [] + + +# ───────────────────────────────────────────────────────────────────────────── +# 7. Figure._on_event — JSON dispatch from model traitlet +# ───────────────────────────────────────────────────────────────────────────── + +class TestFigureOnEvent: + + def _dispatch(self, fig, plot, name, widget_id, settled, **data): + """Simulate JS sending event_json.""" + payload = dict(panel_id=plot._id, name=name, + widget_id=widget_id, settled=settled, **data) + fig._on_event({"new": json.dumps(payload)}) + + def test_dispatch_reaches_plot(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + fired = [] + + @v.on_release() + def cb(event): fired.append(event) + + self._dispatch(fig, v, "widget_change", "w1", True, cx=10.0, cy=20.0) + assert len(fired) == 1 + assert fired[0].cx == pytest.approx(10.0) + + def test_dispatch_wrong_panel_id_ignored(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + fired = [] + + @v.on_release() + def cb(event): fired.append(event) + + payload = dict(panel_id="nonexistent", name="widget_change", + widget_id=None, settled=True) + fig._on_event({"new": json.dumps(payload)}) + assert fired == [] + + def test_dispatch_empty_json_ignored(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + fig._on_event({"new": "{}"}) # should not raise + + def test_dispatch_invalid_json_ignored(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + fig._on_event({"new": "not-json"}) # should not raise + + def test_dispatch_settled_false_calls_on_change(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + fired = [] + + @v.on_change() + def cb(event): fired.append(event) + + self._dispatch(fig, v, "widget_change", "w1", False, cx=5.0) + assert len(fired) == 1 + assert not fired[0].settled + + def test_dispatch_settled_true_calls_on_release(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + fired = [] + + @v.on_release() + def cb(event): fired.append(event) + + self._dispatch(fig, v, "zoom_change", None, True, zoom=2.0) + assert len(fired) == 1 + assert fired[0].zoom == pytest.approx(2.0) + + def test_dispatch_to_1d_plot(self): + fig, ax = apl.subplots(1, 1) + v = ax.plot(np.zeros(64)) + fired = [] + + @v.on_release() + def cb(event): fired.append(event) + + self._dispatch(fig, v, "vline_change", "vl1", True, x=42.0) + assert fired[0].x == pytest.approx(42.0) + + def test_dispatch_multi_panel_correct_routing(self): + fig, (ax1, ax2) = apl.subplots(1, 2) + v1 = ax1.imshow(np.zeros((16, 16))) + v2 = ax2.plot(np.zeros(32)) + fired1, fired2 = [], [] + + @v1.on_release() + def cb1(event): fired1.append(event) + + @v2.on_release() + def cb2(event): fired2.append(event) + + self._dispatch(fig, v1, "zoom_change", None, True, zoom=1.5) + assert len(fired1) == 1 and fired2 == [] + + self._dispatch(fig, v2, "view_change", None, True, view_x0=0.1, view_x1=0.9) + assert len(fired2) == 1 and len(fired1) == 1 # v1 still only 1 + + def test_extra_keys_stripped_from_event_data(self): + """panel_id / name / widget_id / settled must not appear in event.data.""" + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((16, 16))) + fired = [] + + @v.on_release() + def cb(event): fired.append(event) + + self._dispatch(fig, v, "zoom_change", None, True, zoom=2.0) + ev = fired[0] + assert "panel_id" not in ev.data + assert "name" not in ev.data + assert "settled" not in ev.data + assert ev.zoom == pytest.approx(2.0) + + +# ───────────────────────────────────────────────────────────────────────────── +# 8. Filtering edge cases +# ───────────────────────────────────────────────────────────────────────────── + +class TestFilteringEdgeCases: + + def test_repeated_change_then_single_release(self): + reg = CallbackRegistry() + change_log, release_log = [], [] + reg.connect("change", None, None, lambda e: change_log.append(1)) + reg.connect("release", None, None, lambda e: release_log.append(1)) + + for _ in range(5): + reg.fire(_change_event()) + for _ in range(3): + reg.fire(_release_event()) + + assert len(change_log) == 5 + assert len(release_log) == 3 + + def test_both_wildcards_matches_everything(self): + reg = CallbackRegistry() + fired = [] + reg.connect("release", None, None, lambda e: fired.append(e)) + for ev in [ + _release_event(name="zoom_change", widget_id=None), + _release_event(name="widget_change", widget_id="w1"), + _release_event(name="rotate_change", widget_id=None), + ]: + reg.fire(ev) + assert len(fired) == 3 + + def test_exact_name_wildcard_widget(self): + reg = CallbackRegistry() + fired = [] + reg.connect("release", "zoom_change", None, lambda e: fired.append(e)) + reg.fire(_release_event(name="zoom_change", widget_id="w1")) + reg.fire(_release_event(name="zoom_change", widget_id="w2")) + reg.fire(_release_event(name="other", widget_id="w1")) + assert len(fired) == 2 + + def test_wildcard_name_exact_widget(self): + reg = CallbackRegistry() + fired = [] + reg.connect("release", None, "w1", lambda e: fired.append(e)) + reg.fire(_release_event(name="zoom_change", widget_id="w1")) + reg.fire(_release_event(name="widget_change", widget_id="w1")) + reg.fire(_release_event(name="zoom_change", widget_id="w2")) + assert len(fired) == 2 + + +# ───────────────────────────────────────────────────────────────────────────── +# 9. Practical usage patterns +# ───────────────────────────────────────────────────────────────────────────── + +class TestPracticalPatterns: + + def test_readout_update_on_drag(self): + v = _plot2d() + wid = v.add_widget("crosshair") + readout = {"value": ""} + + @v.on_change(wid) + def live(event): + readout["value"] = f"({event.cx:.1f}, {event.cy:.1f})" + + v.callbacks.fire(_change_event(name="crosshair_change", + widget_id=wid, cx=12.5, cy=7.3)) + assert readout["value"] == "(12.5, 7.3)" + + def test_expensive_work_gated_on_release(self): + v = _plot1d() + wid = v.add_vline_widget(x=284.0) + calls = {"cheap": 0, "expensive": 0} + + @v.on_change(wid) + def live(event): calls["cheap"] += 1 + + @v.on_release(wid) + def done(event): calls["expensive"] += 1 + + for _ in range(10): + v.callbacks.fire(_change_event(name="vline_change", widget_id=wid, x=285.0)) + v.callbacks.fire(_release_event(name="vline_change", widget_id=wid, x=285.0)) + + assert calls["cheap"] == 10 + assert calls["expensive"] == 1 + + def test_multiple_widgets_separate_callbacks(self): + v = _plot2d() + w1 = v.add_widget("circle") + w2 = v.add_widget("crosshair") + log = {w1: [], w2: []} + + @v.on_release(w1) + def cb1(event): log[w1].append(event) + + @v.on_release(w2) + def cb2(event): log[w2].append(event) + + v.callbacks.fire(_release_event(widget_id=w1)) + assert len(log[w1]) == 1 and len(log[w2]) == 0 + + v.callbacks.fire(_release_event(widget_id=w2)) + assert len(log[w1]) == 1 and len(log[w2]) == 1 + + def test_3d_rotate_many_frames_one_release(self): + v = _plot3d() + frames, final = [], {} + + @v.on_change() + def live(event): frames.append(event.azimuth) + + @v.on_release() + def done(event): final["az"] = event.azimuth + + for az in range(0, 50, 5): + v.callbacks.fire(_change_event(name="rotate_change", widget_id=None, + azimuth=float(az), elevation=30.0, zoom=1.0)) + v.callbacks.fire(_release_event(name="rotate_change", widget_id=None, + azimuth=45.0, elevation=30.0, zoom=1.0)) + + assert len(frames) == 10 + assert final["az"] == pytest.approx(45.0) + + def test_cid_returned_from_direct_connect(self): + reg = CallbackRegistry() + fired = [] + cid = reg.connect("release", None, None, lambda e: fired.append(e)) + reg.fire(_release_event()) + assert len(fired) == 1 + reg.disconnect(cid) + reg.fire(_release_event()) + assert len(fired) == 1 # handler removed + + def test_on_change_and_on_release_same_widget(self): + """Same widget can have both tiers active simultaneously.""" + v = _plot2d() + wid = v.add_widget("circle") + fast, slow = [], [] + + @v.on_change(wid) + def live(event): fast.append(event) + + @v.on_release(wid) + def done(event): slow.append(event) + + for _ in range(5): + v.callbacks.fire(_change_event(widget_id=wid)) + v.callbacks.fire(_release_event(widget_id=wid)) + + assert len(fast) == 5 + assert len(slow) == 1 + From 09123849632150df437e39336305dc1b7c400065 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 15 Mar 2026 20:10:15 -0500 Subject: [PATCH 006/198] New Feature: Add support for drawing circles, horizontal lines, line segments, points, polygons, rectangles, and vertical lines with live updates in anyplotlib --- Examples/Markers/plot_circles.py | 27 ++++++++++++++++ Examples/Markers/plot_horizontal_lines.py | 22 +++++++++++++ Examples/Markers/plot_line_segments.py | 39 +++++++++++++++++++++++ Examples/Markers/plot_points.py | 25 +++++++++++++++ Examples/Markers/plot_polygons.py | 38 ++++++++++++++++++++++ Examples/Markers/plot_rectangles.py | 26 +++++++++++++++ Examples/Markers/plot_vertical_lines.py | 22 +++++++++++++ 7 files changed, 199 insertions(+) create mode 100644 Examples/Markers/plot_circles.py create mode 100644 Examples/Markers/plot_horizontal_lines.py create mode 100644 Examples/Markers/plot_line_segments.py create mode 100644 Examples/Markers/plot_points.py create mode 100644 Examples/Markers/plot_polygons.py create mode 100644 Examples/Markers/plot_rectangles.py create mode 100644 Examples/Markers/plot_vertical_lines.py diff --git a/Examples/Markers/plot_circles.py b/Examples/Markers/plot_circles.py new file mode 100644 index 00000000..29413f6c --- /dev/null +++ b/Examples/Markers/plot_circles.py @@ -0,0 +1,27 @@ +""" +Circles +======= +Mark circular features on a 2-D image with +:meth:`~anyplotlib.figure_plots.Plot2D.add_circles`. +Use ``markers["circles"]["name"].set(...)`` to update them live. +""" +import numpy as np +import anyplotlib as vw +rng = np.random.default_rng(0) +data = rng.standard_normal((128, 128)).cumsum(0).cumsum(1) +data = (data - data.min()) / (data.max() - data.min()) +xy = np.linspace(0, 10, 128) +fig, ax = vw.subplots(1, 1, figsize=(460, 460)) +v = ax.imshow(data, axes=[xy, xy], units="nm") +centres = rng.uniform(15, 113, (8, 2)) +v.add_circles(centres, name="spots", radius=10, + edgecolors="#ff1744", facecolors="#ff174433", + labels=[f"#{i}" for i in range(8)]) +fig +# %% +# Live update +# ----------- +# Call ``.set()`` on the marker group to push any change immediately. +v.markers["circles"]["spots"].set(radius=16, edgecolors="#ffcc00", + facecolors="#ffcc0033") +fig diff --git a/Examples/Markers/plot_horizontal_lines.py b/Examples/Markers/plot_horizontal_lines.py new file mode 100644 index 00000000..995cd1c9 --- /dev/null +++ b/Examples/Markers/plot_horizontal_lines.py @@ -0,0 +1,22 @@ +""" +Horizontal Lines +================ +Draw read-only horizontal lines on a 1-D plot with +:meth:`~anyplotlib.figure_plots.Plot1D.add_hlines`. +Use ``markers["hlines"]["name"].set(...)`` to update them live. +""" +import numpy as np +import anyplotlib as vw +x = np.linspace(0, 4 * np.pi, 512) +signal = np.sin(x) +fig, ax = vw.subplots(1, 1, figsize=(560, 300)) +v = ax.plot(signal, axes=[x], units="rad") +v.add_hlines([0.5, 0.0, -0.5], name="thresholds", + color="#69f0ae", linewidths=1.5, + label="thresholds", labels=["+0.5", "zero", "-0.5"]) +fig +# %% +# Live update +# ----------- +v.markers["hlines"]["thresholds"].set(color="#ff1744", linewidths=2.0) +fig diff --git a/Examples/Markers/plot_line_segments.py b/Examples/Markers/plot_line_segments.py new file mode 100644 index 00000000..49e8901f --- /dev/null +++ b/Examples/Markers/plot_line_segments.py @@ -0,0 +1,39 @@ +""" +Line Segments +============= + +Draw line segments on a 2-D image with +:meth:`~anyplotlib.figure_plots.Plot2D.add_lines`. +Use ``markers["lines"]["name"].set(...)`` to update them live. +""" +import numpy as np +import anyplotlib as vw + +rng = np.random.default_rng(4) +data = rng.standard_normal((128, 128)).cumsum(0).cumsum(1) +data = (data - data.min()) / (data.max() - data.min()) +xy = np.linspace(0, 10, 128) + +fig, ax = vw.subplots(1, 1, figsize=(460, 460)) +v = ax.imshow(data, axes=[xy, xy], units="nm") + +segments = np.array([ + [[ 10.0, 10.0], [118.0, 10.0]], + [[118.0, 10.0], [118.0, 118.0]], + [[118.0, 118.0], [ 10.0, 118.0]], + [[ 10.0, 118.0], [ 10.0, 10.0]], + [[ 10.0, 10.0], [118.0, 118.0]], +]) +v.add_lines(segments, name="frame", + edgecolors="#00e5ff", linewidths=1.5, + label="frame", + labels=["top", "right", "bottom", "left", "diagonal"]) +fig + +# %% +# Live update +# ----------- +# Update stroke colour and width for all segments at once. + +v.markers["lines"]["frame"].set(edgecolors="#ff9100", linewidths=2.5) +fig diff --git a/Examples/Markers/plot_points.py b/Examples/Markers/plot_points.py new file mode 100644 index 00000000..f626bfbc --- /dev/null +++ b/Examples/Markers/plot_points.py @@ -0,0 +1,25 @@ +""" +Points +====== +Draw point markers on a 1-D plot with +:meth:`~anyplotlib.figure_plots.Plot1D.add_points`. +Use ``markers["points"]["name"].set(...)`` to update them live. +""" +import numpy as np +import anyplotlib as vw +x = np.linspace(0, 4 * np.pi, 512) +signal = np.sin(x) +fig, ax = vw.subplots(1, 1, figsize=(560, 300)) +v = ax.plot(signal, axes=[x], units="rad") +peak_x = np.array([np.pi / 2, 5 * np.pi / 2, 9 * np.pi / 2]) +offsets = np.column_stack([peak_x, np.sin(peak_x)]) +v.add_points(offsets, name="peaks", + edgecolors="#ff1744", facecolors="#ff174433", sizes=8, + label="peaks", labels=["P1", "P2", "P3"]) +fig +# %% +# Live update +# ----------- +v.markers["points"]["peaks"].set(sizes=12, edgecolors="#ffcc00", + facecolors="#ffcc0033") +fig diff --git a/Examples/Markers/plot_polygons.py b/Examples/Markers/plot_polygons.py new file mode 100644 index 00000000..e3f9adaf --- /dev/null +++ b/Examples/Markers/plot_polygons.py @@ -0,0 +1,38 @@ +""" +Polygons +======== + +Draw closed polygons on a 2-D image with +:meth:`~anyplotlib.figure_plots.Plot2D.add_polygons`. +Use ``markers["polygons"]["name"].set(...)`` to update them live. +""" +import numpy as np +import anyplotlib as vw + +rng = np.random.default_rng(5) +data = rng.standard_normal((128, 128)).cumsum(0).cumsum(1) +data = (data - data.min()) / (data.max() - data.min()) +xy = np.linspace(0, 10, 128) + +fig, ax = vw.subplots(1, 1, figsize=(460, 460)) +v = ax.imshow(data, axes=[xy, xy], units="nm") + +triangle = [[64.0, 10.0], [100.0, 60.0], [28.0, 60.0]] +hexagon = [[64.0 + 28 * np.cos(np.radians(60 * k)), + 95.0 + 28 * np.sin(np.radians(60 * k))] + for k in range(6)] + +v.add_polygons([triangle, hexagon], name="shapes", + edgecolors="#69f0ae", facecolors="#69f0ae22", + linewidths=2.0, + label="shapes", labels=["triangle", "hexagon"]) +fig + +# %% +# Live update +# ----------- +# Change the stroke and fill colour of every polygon at once. + +v.markers["polygons"]["shapes"].set(edgecolors="#e040fb", + facecolors="#e040fb33") +fig diff --git a/Examples/Markers/plot_rectangles.py b/Examples/Markers/plot_rectangles.py new file mode 100644 index 00000000..60d23266 --- /dev/null +++ b/Examples/Markers/plot_rectangles.py @@ -0,0 +1,26 @@ +""" +Rectangles +========== +Draw bounding boxes on a 2-D image with +:meth:`~anyplotlib.figure_plots.Plot2D.add_rectangles`. +""" +import numpy as np +import anyplotlib as vw +rng = np.random.default_rng(1) +data = rng.standard_normal((128, 128)).cumsum(0).cumsum(1) +data = (data - data.min()) / (data.max() - data.min()) +xy = np.linspace(0, 10, 128) +fig, ax = vw.subplots(1, 1, figsize=(460, 460)) +v = ax.imshow(data, axes=[xy, xy], units="nm") +centres = rng.uniform(20, 108, (5, 2)) +v.add_rectangles(centres, widths=22, heights=14, name="boxes", + edgecolors="#00e5ff", facecolors="#00e5ff22", + labels=[f"R{i}" for i in range(5)]) +fig +# %% +# Live update +# ----------- +v.markers["rectangles"]["boxes"].set(widths=30, heights=20, + edgecolors="#ff9100", + facecolors="#ff910033") +fig diff --git a/Examples/Markers/plot_vertical_lines.py b/Examples/Markers/plot_vertical_lines.py new file mode 100644 index 00000000..875bd4de --- /dev/null +++ b/Examples/Markers/plot_vertical_lines.py @@ -0,0 +1,22 @@ +""" +Vertical Lines +============== +Draw read-only vertical lines on a 1-D plot with +:meth:`~anyplotlib.figure_plots.Plot1D.add_vlines`. +Use ``markers["vlines"]["name"].set(...)`` to update them live. +""" +import numpy as np +import anyplotlib as vw +x = np.linspace(0, 4 * np.pi, 512) +signal = np.sin(x) +fig, ax = vw.subplots(1, 1, figsize=(560, 300)) +v = ax.plot(signal, axes=[x], units="rad") +v.add_vlines([np.pi, 2 * np.pi, 3 * np.pi], name="pi_mult", + color="#00e5ff", linewidths=1.5, + label="pi multiples", labels=["pi", "2pi", "3pi"]) +fig +# %% +# Live update +# ----------- +v.markers["vlines"]["pi_mult"].set(color="#ff9100", linewidths=2.0) +fig From 4dfee0efee4c1ba63c55dbadd77553329af21226 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 16 Mar 2026 11:29:51 -0500 Subject: [PATCH 007/198] New Feature: Add interactive widget support with callbacks for real-time updates in Anyplotlib --- Examples/Interactive/README.rst | 6 + Examples/Interactive/plot_interactive_fft.py | 184 ++++ anyplotlib/__init__.py | 13 +- anyplotlib/callbacks.py | 201 +---- anyplotlib/figure.py | 44 +- anyplotlib/figure_esm.js | 76 +- anyplotlib/figure_plots.py | 802 ++++++++--------- anyplotlib/widgets.py | 221 +++++ pyproject.toml | 10 +- tests/test_events.py | 851 ++++++++----------- tests/test_widgets.py | 648 ++++++++++++++ 11 files changed, 1890 insertions(+), 1166 deletions(-) create mode 100644 Examples/Interactive/README.rst create mode 100644 Examples/Interactive/plot_interactive_fft.py create mode 100644 anyplotlib/widgets.py create mode 100644 tests/test_widgets.py diff --git a/Examples/Interactive/README.rst b/Examples/Interactive/README.rst new file mode 100644 index 00000000..3379ea8c --- /dev/null +++ b/Examples/Interactive/README.rst @@ -0,0 +1,6 @@ +Interactive Examples +==================== + +Examples that use the callback / event system to connect widget +interactions to live Python computations. + diff --git a/Examples/Interactive/plot_interactive_fft.py b/Examples/Interactive/plot_interactive_fft.py new file mode 100644 index 00000000..2d46f562 --- /dev/null +++ b/Examples/Interactive/plot_interactive_fft.py @@ -0,0 +1,184 @@ +""" +Interactive FFT ROI +=================== + +A draggable rectangle widget on a real-space image drives a live 2-D FFT +of the selected region, displayed in a side-by-side panel. + +**How it works** + +* The left panel shows a synthetic real-space image (a periodic lattice with + noise, similar to an atomic-resolution STEM image). +* A yellow rectangle widget marks the region-of-interest (ROI). +* Whenever the ROI is moved or resized the :meth:`~anyplotlib.figure_plots.Plot2D.on_release` + callback re-computes ``numpy.fft.fft2`` on the cropped pixels, applies a + Hann window to reduce edge ringing, takes the log-magnitude, and pushes the + result into the right panel with + :meth:`~anyplotlib.figure_plots.Plot2D.update`. +* A second :meth:`~anyplotlib.figure_plots.Plot2D.on_change` callback updates + a lightweight text readout (ROI size in pixels) on every drag frame without + re-running the FFT. + +**Interaction** + +* Drag the rectangle body to move the ROI. +* Drag any corner handle to resize it. +* The FFT panel refreshes automatically on mouse-release. + +.. note:: + The ``on_release`` / ``on_change`` callbacks are pure Python — no kernel + restart is needed after editing them. +""" + +import numpy as np +import anyplotlib as vw + +# ── Synthetic real-space image ──────────────────────────────────────────────── +# Periodic lattice (two overlapping sinusoidal gratings) + Gaussian envelope +# + shot noise. Mimics a crystalline region in an electron-microscopy image. + +N = 256 # image size (pixels) +rng = np.random.default_rng(42) + +x = np.arange(N) +XX, YY = np.meshgrid(x, x) + +# Two lattice periodicities (pixels) +a1, a2 = 22, 14 +theta = np.deg2rad(30) + +lattice = ( + np.cos(2 * np.pi * (XX * np.cos(theta) + YY * np.sin(theta)) / a1) + + 0.6 * np.cos(2 * np.pi * (XX * np.cos(theta + np.pi / 3) + + YY * np.sin(theta + np.pi / 3)) / a2) +) + +# Gaussian envelope (brighter in centre) +cx, cy = N // 2, N // 2 +gauss = np.exp(-((XX - cx) ** 2 + (YY - cy) ** 2) / (2 * (N * 0.35) ** 2)) + +image = gauss * lattice + rng.normal(scale=0.08, size=(N, N)) + +# Normalise to [0, 1] +image = (image - image.min()) / (image.max() - image.min()) + +# Physical axis: 0.1 Å / pixel +scale = 0.1 # Å per pixel +xy_px = np.arange(N) * scale # physical axis in Å + +# ── Figure layout: real-space (left) | FFT (right) ─────────────────────────── +fig, (ax_real, ax_fft) = vw.subplots( + 1, 2, + figsize=(900, 460), + sharex=False, + sharey=False, +) + +# ── Left panel: real-space image ────────────────────────────────────────────── +v_real = ax_real.imshow(image, axes=[xy_px, xy_px], units="Å") +v_real.set_colormap("gray") + +# Initial ROI: centred, 64 × 64 px +ROI_W, ROI_H = 64, 64 +roi_x0 = (N - ROI_W) // 2 # pixel coords (top-left corner) +roi_y0 = (N - ROI_H) // 2 + +wid = v_real.add_widget( + "rectangle", + color="#ffeb3b", + x=float(roi_x0), + y=float(roi_y0), + w=float(ROI_W), + h=float(ROI_H), +) + +# ── Right panel: FFT magnitude ──────────────────────────────────────────────── +def _compute_fft(img_full, x0, y0, w, h): + """Crop, window and FFT a region of *img_full*. + + Parameters + ---------- + img_full : ndarray, shape (N, N) – full real-space image (float) + x0, y0 : float – top-left corner of rectangle in pixel coords + w, h : float – width and height in pixels + + Returns + ------- + log_mag : ndarray – log10(1 + |FFT|), shifted so DC is at centre + freq_x : ndarray – spatial-frequency axis (1/Å), shape (w_int,) + freq_y : ndarray – spatial-frequency axis (1/Å), shape (h_int,) + """ + ih, iw = img_full.shape + + # Clamp ROI to image bounds + x0i = max(0, int(round(x0))) + y0i = max(0, int(round(y0))) + x1i = min(iw, x0i + max(1, int(round(w)))) + y1i = min(ih, y0i + max(1, int(round(h)))) + + crop = img_full[y0i:y1i, x0i:x1i].copy() + ch, cw = crop.shape + if ch < 2 or cw < 2: + # ROI too small — return a blank placeholder + blank = np.zeros((4, 4)) + f = np.fft.fftfreq(4, d=scale) + return blank, f, f + + # Hann window to suppress edge ringing + win_y = np.hanning(ch) + win_x = np.hanning(cw) + crop *= win_y[:, None] * win_x[None, :] + + # 2-D FFT → log magnitude, DC centred + fft2 = np.fft.fftshift(np.fft.fft2(crop)) + log_mag = np.log1p(np.abs(fft2)) + + # Spatial-frequency axes (cycles per Å) + freq_x = np.fft.fftshift(np.fft.fftfreq(cw, d=scale)) + freq_y = np.fft.fftshift(np.fft.fftfreq(ch, d=scale)) + + return log_mag, freq_x, freq_y + + +# Compute initial FFT and display it +_fft_init, _fx_init, _fy_init = _compute_fft(image, roi_x0, roi_y0, ROI_W, ROI_H) +v_fft = ax_fft.imshow(_fft_init, axes=[_fx_init, _fy_init], units="1/Å") +v_fft.set_colormap("inferno") + +# ── Callbacks ───────────────────────────────────────────────────────────────── + +@v_real.on_change(wid) +def _roi_dragging(event): + """Fires on every drag frame — highlight rectangle while dragging.""" + # Cheaply pulse the widget colour to give live drag feedback. + for w in v_real._state["overlay_widgets"]: + if w["id"] == wid: + w["color"] = "#ff9800" # orange while dragging + break + v_real._push() + + +@v_real.on_release(wid) +def _roi_released(event): + """Fires once on mouse-up — recompute and push the full FFT.""" + x0 = event.data.get("x", roi_x0) + y0 = event.data.get("y", roi_y0) + w = event.data.get("w", ROI_W) + h = event.data.get("h", ROI_H) + + # Restore widget colour to yellow + for widget in v_real._state["overlay_widgets"]: + if widget["id"] == wid: + widget["color"] = "#ffeb3b" + break + + log_mag, freq_x, freq_y = _compute_fft(image, x0, y0, w, h) + + # Push updated FFT into the right panel + v_fft.update(log_mag, x_axis=freq_x, y_axis=freq_y, units="1/Å") + + +fig + + + diff --git a/anyplotlib/__init__.py b/anyplotlib/__init__.py index f0985027..486549e7 100644 --- a/anyplotlib/__init__.py +++ b/anyplotlib/__init__.py @@ -1,4 +1,15 @@ from anyplotlib.figure import Figure, GridSpec, SubplotSpec, subplots from anyplotlib.figure_plots import PlotMesh, Plot3D +from anyplotlib.widgets import ( + Widget, RectangleWidget, CircleWidget, AnnularWidget, + CrosshairWidget, PolygonWidget, LabelWidget, + VLineWidget, HLineWidget, RangeWidget, +) -__all__ = ["Figure", "GridSpec", "SubplotSpec", "subplots", "PlotMesh", "Plot3D"] +__all__ = [ + "Figure", "GridSpec", "SubplotSpec", "subplots", + "PlotMesh", "Plot3D", + "Widget", "RectangleWidget", "CircleWidget", "AnnularWidget", + "CrosshairWidget", "PolygonWidget", "LabelWidget", + "VLineWidget", "HLineWidget", "RangeWidget", +] diff --git a/anyplotlib/callbacks.py b/anyplotlib/callbacks.py index 6065a792..f9432156 100644 --- a/anyplotlib/callbacks.py +++ b/anyplotlib/callbacks.py @@ -1,127 +1,20 @@ -""" -callbacks.py -============ - -Two-tier event / callback system for anyplotlib plot objects. - -The two tiers map directly to what the JS already provides: - -``'change'`` — fires on every animation frame while a drag is in - progress (~16 ms cadence, coalesced by - ``requestAnimationFrame``). Keep callbacks here - **fast**: update a text readout, move a linked cursor. - -``'release'`` — fires exactly **once** when the interaction settles - (mouseup, scroll end, key press). Safe for expensive - work: re-fit a spectrum, recompute an integral, - re-render a dependent plot. - -Both tiers receive the same :class:`Event` object; the ``settled`` -boolean tells you which tier fired. - -Usage ------ - -.. code-block:: python - - import anyplotlib as apl - import numpy as np - - fig, ax = apl.subplots(1, 1) - v = ax.imshow(data, axes=[x, y], units="nm") - - # ── specific widget, two tiers ───────────────────────────────── - wid = v.add_widget("crosshair", cx=64, cy=64) - - @v.on_change(wid) # fast — every drag frame - def live(event): - readout.value = f"({event.cx:.1f}, {event.cy:.1f})" - - @v.on_release(wid) # slow work — only when drag settles - def settled(event): - recompute(event.cx, event.cy) - - # ── any widget on this panel, on release ─────────────────────── - @v.on_release() - def any_settled(event): - print(event) - - # ── 1-D vline ────────────────────────────────────────────────── - v1 = ax1.plot(spectrum, axes=[energy], units="eV") - wid2 = v1.add_vline_widget(x=284.8) - - @v1.on_release(wid2) - def peak_selected(event): - idx = np.searchsorted(energy, event.x) - print(f"{event.x:.2f} eV → I={spectrum[idx]:.4f}") - - # ── disconnect by CID (matplotlib-style) ─────────────────────── - cid = v.on_release(wid)(my_fn) - v.disconnect(cid) - - # ── single-fire pattern ──────────────────────────────────────── - @v.on_release(wid) - def once(event): - do_work(event) - v.disconnect(once._cid) -""" from __future__ import annotations - from dataclasses import dataclass, field from typing import Any, Callable +_VALID_EVENT_TYPES = ("on_click", "on_changed", "on_release") -# --------------------------------------------------------------------------- -# Event -# --------------------------------------------------------------------------- @dataclass class Event: - """A single interactive event from the JS frontend. - - Attributes - ---------- - name : str - Event category: - - ``'widget_change'`` – 2-D overlay widget dragged / resized. - ``'vline_change'`` – 1-D vline widget moved. - ``'hline_change'`` – 1-D hline widget moved. - ``'range_change'`` – 1-D range widget moved / resized. - ``'zoom_change'`` – 2-D panel panned / zoomed. - ``'view_change'`` – 1-D view window panned / zoomed. - ``'rotate_change'`` – 3-D camera rotated / zoomed. - - panel_id : str - Internal panel identifier. - widget_id : str or None - The ``wid`` from ``add_widget`` / ``add_vline_widget`` etc., - or ``None`` for view / zoom / rotate events. - settled : bool - ``True`` when the interaction has finished (mouseup / scroll - end / key press). ``False`` while a drag is still live. - - Use this to gate expensive work:: - - def cb(event): - update_readout(event.cx) # always cheap - if event.settled: - recompute(event.cx) # only when done - - data : dict - Full updated widget or view-state dict. All keys are also - forwarded as top-level attributes:: - - event.cx # same as event.data['cx'] - event.x0 # same as event.data['x0'] - event.zoom # same as event.data['zoom'] + """A single interactive event. + event_type: one of on_click / on_changed / on_release + source: the originating Python object (Widget, Plot, or None) + data: full state dict; all keys also accessible as event.x """ - - name: str - panel_id: str - widget_id: str | None - settled: bool - data: dict = field(default_factory=dict) + event_type: str + source: Any + data: dict = field(default_factory=dict) def __getattr__(self, key: str) -> Any: try: @@ -133,13 +26,8 @@ def __getattr__(self, key: str) -> Any: ) from None def __repr__(self) -> str: - parts = [ - f"name={self.name!r}", - f"panel_id={self.panel_id!r}", - f"settled={self.settled}", - ] - if self.widget_id is not None: - parts.append(f"widget_id={self.widget_id!r}") + src = type(self.source).__name__ if self.source is not None else "None" + parts = [f"event_type={self.event_type!r}", f"source={src}"] _skip = {"id", "type", "color", "colormap_data", "image_b64", "histogram_data", "colormap_name"} shown = 0 @@ -150,73 +38,36 @@ def __repr__(self) -> str: f"{k}={v:.4g}" if isinstance(v, float) else f"{k}={v!r}" ) shown += 1 - return f"Event({', '.join(parts)})" - + return "Event(" + ", ".join(parts) + ")" -# --------------------------------------------------------------------------- -# CallbackRegistry -# --------------------------------------------------------------------------- class CallbackRegistry: - """Per-plot callback registry with change / release tiers and CID management. - - Instantiated once per plot object. Users interact through the - convenience methods on plot objects: - - * ``plot.on_change(wid)`` – decorator; fires every drag frame. - * ``plot.on_release(wid)`` – decorator; fires once on settle. - * ``plot.disconnect(cid)`` – remove a handler by integer CID. - - Matching rules - -------------- - An entry fires when **all** conditions match: - - * *tier* matches ``event.settled`` - (``'change'`` → ``not settled``, ``'release'`` → ``settled``). - * *name* is ``None`` (wildcard) **or** equals ``event.name``. - * *widget_id* is ``None`` (wildcard) **or** equals ``event.widget_id``. - """ + """Per-object registry for on_click / on_changed / on_release callbacks.""" def __init__(self) -> None: self._next_cid: int = 1 - # {cid: (tier, name_or_None, widget_id_or_None, fn)} - self._entries: dict[int, tuple[str, str | None, str | None, Callable]] = {} + self._entries: dict[int, tuple[str, Callable]] = {} - # ------------------------------------------------------------------ - def connect(self, tier: str, name: str | None, - widget_id: str | None, fn: Callable) -> int: - """Register *fn* and return an integer CID. - - Parameters - ---------- - tier : ``'change'`` or ``'release'`` - name : event name to match, or ``None`` for any. - widget_id : widget id to match, or ``None`` for any. - fn : callable ``(event: Event) -> None``. - """ - if tier not in ("change", "release"): - raise ValueError("tier must be 'change' or 'release'") + def connect(self, event_type: str, fn: Callable) -> int: + """Register fn for event_type. Returns integer CID.""" + if event_type not in _VALID_EVENT_TYPES: + raise ValueError( + f"event_type must be one of {_VALID_EVENT_TYPES}, got {event_type!r}" + ) cid = self._next_cid self._next_cid += 1 - self._entries[cid] = (tier, name, widget_id, fn) + self._entries[cid] = (event_type, fn) return cid def disconnect(self, cid: int) -> None: - """Remove the handler registered under *cid*. Silent if not found.""" + """Remove handler for cid. Silent if not found.""" self._entries.pop(cid, None) - def fire(self, event: "Event") -> None: - """Dispatch *event* to all matching handlers.""" - tier = "release" if event.settled else "change" - for _cid, (t, n, wid, fn) in list(self._entries.items()): - if t != tier: - continue - if n is not None and n != event.name: - continue - if wid is not None and wid != event.widget_id: - continue - fn(event) + def fire(self, event) -> None: + """Dispatch event to all handlers matching event.event_type.""" + for _cid, (et, fn) in list(self._entries.items()): + if et == event.event_type: + fn(event) def __bool__(self) -> bool: return bool(self._entries) - diff --git a/anyplotlib/figure.py b/anyplotlib/figure.py index 5d0aeae5..93419c79 100644 --- a/anyplotlib/figure.py +++ b/anyplotlib/figure.py @@ -23,6 +23,8 @@ class Figure(anywidget.AnyWidget): layout_json = traitlets.Unicode("{}").tag(sync=True) fig_width = traitlets.Int(640).tag(sync=True) fig_height = traitlets.Int(480).tag(sync=True) + # Bidirectional js. Events have an object id and some + # data that event_json = traitlets.Unicode("{}").tag(sync=True) _esm = _ESM_SOURCE @@ -182,7 +184,8 @@ def _on_resize(self, change) -> None: @traitlets.observe("event_json") def _on_event(self, change) -> None: - """Dispatch a JS interaction event to the relevant plot's CallbackRegistry.""" + """Dispatch a JS interaction event to the relevant plot and widget callbacks.""" + print("_on_event:", change["new"]) raw = change["new"] if not raw or raw == "{}": return @@ -190,18 +193,39 @@ def _on_event(self, change) -> None: msg = json.loads(raw) except Exception: return - panel_id = msg.get("panel_id", "") - name = msg.get("name", "unknown") - widget_id = msg.get("widget_id") - settled = bool(msg.get("settled", False)) - data = {k: v for k, v in msg.items() - if k not in ("panel_id", "name", "widget_id", "settled")} - event = Event(name=name, panel_id=panel_id, - widget_id=widget_id, settled=settled, data=data) + + # Echo guard — Python-originated pushes must not loop back + if msg.get("source") == "python": + return + + panel_id = msg.get("panel_id", "") + event_type = msg.get("event_type", "on_changed") + widget_id = msg.get("widget_id") + data = {k: v for k, v in msg.items() + if k not in ("source", "panel_id", "event_type", "widget_id")} + plot = self._plots_map.get(panel_id) - if plot is not None and hasattr(plot, "callbacks"): + if plot is None: + return + + source = None + if widget_id and hasattr(plot, "_widgets"): + widget = plot._widgets.get(widget_id) + if widget is not None: + widget._update_from_js(data, event_type) + source = widget + + if hasattr(plot, "callbacks"): + event = Event(event_type=event_type, source=source, data=data) plot.callbacks.fire(event) + def _push_widget(self, panel_id: str, widget_id: str, fields: dict) -> None: + """Send a targeted widget-position update to JS (no image data).""" + payload = {"source": "python", "panel_id": panel_id, + "widget_id": widget_id} + payload.update(fields) + self.event_json = json.dumps(payload) + # ── helpers ─────────────────────────────────────────────────────────────── def get_axes(self) -> list: return sorted(self._axes_map.values(), diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index fac6386a..f48f435c 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -1171,6 +1171,18 @@ function render({ model, el }) { } } + // ── event emission helper (module-scope: accessible to all attach fns) ── + // eventType: 'on_changed' | 'on_release' | 'on_click' + function _emitEvent(panelId, eventType, widgetId, extraData) { + const payload = Object.assign( + { source: 'js', panel_id: panelId, event_type: eventType, + widget_id: widgetId || null }, + extraData || {} + ); + model.set('event_json', JSON.stringify(payload)); + model.save_changes(); + } + function _attachEvents3d(p) { const { overlayCanvas } = p; let dragStart = null; @@ -1183,15 +1195,6 @@ function render({ model, el }) { }); } - // ── event emission helper ─────────────────────────────────────────── - function _emitEvent(panelId, name, widgetId, settled, extraData) { - const payload = Object.assign( - { panel_id: panelId, name: name, widget_id: widgetId || null, settled: !!settled }, - extraData || {} - ); - model.set('event_json', JSON.stringify(payload)); - model.save_changes(); - } overlayCanvas.addEventListener('mousedown', (e) => { if (e.button !== 0) return; @@ -1208,7 +1211,7 @@ function render({ model, el }) { p.state.elevation = Math.max(-89, Math.min(89, dragStart.el - dy * 0.5)); draw3d(p); model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); - _emitEvent(p.id, 'rotate_change', null, false, + _emitEvent(p.id, 'on_changed', null, { azimuth: p.state.azimuth, elevation: p.state.elevation, zoom: p.state.zoom }); e.preventDefault(); }); @@ -1217,7 +1220,7 @@ function render({ model, el }) { dragStart = null; overlayCanvas.style.cursor = 'grab'; model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); - _emitEvent(p.id, 'rotate_change', null, true, + _emitEvent(p.id, 'on_release', null, { azimuth: p.state.azimuth, elevation: p.state.elevation, zoom: p.state.zoom }); _scheduleCommit(); }); @@ -1227,7 +1230,7 @@ function render({ model, el }) { p.state.zoom = Math.max(0.1, Math.min(10, p.state.zoom * (e.deltaY > 0 ? 0.9 : 1.1))); draw3d(p); model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); - _emitEvent(p.id, 'zoom_change', null, false, + _emitEvent(p.id, 'on_changed', null, { azimuth: p.state.azimuth, elevation: p.state.elevation, zoom: p.state.zoom }); _scheduleCommit(); }, { passive: false }); @@ -1684,8 +1687,8 @@ function render({ model, el }) { document.addEventListener('mousemove',(e)=>{ if(p.ovDrag2d){ _doDrag2d(e,p); - const _dw=(p.state.overlay_widgets||[]).find(w=>w.id===p.ovDrag2d.id)||{}; - _emitEvent(p.id,(_dw.type||'widget')+'_change',p.ovDrag2d.id,false,_dw); + const _dw=(p.state.overlay_widgets||[])[p.ovDrag2d.idx]||{}; + _emitEvent(p.id,'on_changed',_dw.id||null,_dw); return; } if(!p.isPanning) return; @@ -1702,10 +1705,12 @@ function render({ model, el }) { }); document.addEventListener('mouseup',(e)=>{ if(p.ovDrag2d){ - const _did=p.ovDrag2d.id; - const _dw=(p.state.overlay_widgets||[]).find(w=>w.id===_did)||{}; + const _idx=p.ovDrag2d.idx; + const _dw=(p.state.overlay_widgets||[])[_idx]||{}; + const _did=_dw.id||null; p.ovDrag2d=null; overlayCanvas.style.cursor='default'; - _emitEvent(p.id,(_dw.type||'widget')+'_change',_did,true,_dw); + model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); + _emitEvent(p.id,'on_release',_did,_dw); return; } if(!p.isPanning) return; @@ -1715,7 +1720,7 @@ function render({ model, el }) { st.center_x=Math.max(0,Math.min(1,panStart.cx-(e.clientX-panStart.mx)/rect.width/st.zoom)); st.center_y=Math.max(0,Math.min(1,panStart.cy-(e.clientY-panStart.my)/rect.height/st.zoom)); model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); - _emitEvent(p.id,'zoom_change',null,true,{center_x:st.center_x,center_y:st.center_y,zoom:st.zoom}); + _emitEvent(p.id,'on_release',null,{center_x:st.center_x,center_y:st.center_y,zoom:st.zoom}); model.save_changes(); }); @@ -1873,8 +1878,8 @@ function render({ model, el }) { document.addEventListener('mousemove',(e)=>{ if(p.ovDrag){ _doDrag1d(e,p); - const _dw=(p.state.overlay_widgets||[]).find(w=>w.id===p.ovDrag.id)||{}; - _emitEvent(p.id,(_dw.type||'widget')+'_change',p.ovDrag.id,false,_dw); + const _dw=(p.state.overlay_widgets||[])[p.ovDrag.idx]||{}; + _emitEvent(p.id,'on_changed',_dw.id||null,_dw); return; } if(!p.isPanning) return; @@ -1890,15 +1895,17 @@ function render({ model, el }) { }); document.addEventListener('mouseup',(e)=>{ if(p.ovDrag){ - const _did=p.ovDrag.id; - const _dw=(p.state.overlay_widgets||[]).find(w=>w.id===_did)||{}; + const _idx=p.ovDrag.idx; + const _dw=(p.state.overlay_widgets||[])[_idx]||{}; + const _did=_dw.id||null; p.ovDrag=null; overlayCanvas.style.cursor='crosshair'; - _emitEvent(p.id,(_dw.type||'widget')+'_change',_did,true,_dw); + model.set(`panel_${p.id}_json`,JSON.stringify(p.state)); + _emitEvent(p.id,'on_release',_did,_dw); } if(p.isPanning){ p.isPanning=false; overlayCanvas.style.cursor='crosshair'; const st=p.state; - if(st) _emitEvent(p.id,'view_change',null,true,{view_x0:st.view_x0,view_x1:st.view_x1}); + if(st) _emitEvent(p.id,'on_release',null,{view_x0:st.view_x0,view_x1:st.view_x1}); } }); @@ -2471,6 +2478,27 @@ function render({ model, el }) { model.on('change:layout_json', () => { applyLayout(); redrawAll(); }); model.on('change:fig_width change:fig_height', () => { applyLayout(); redrawAll(); }); + // Python→JS targeted widget update (source:"python" in event_json). + // Applies changed fields directly to the widget in overlay_widgets and + // redraws the panel — no image re-decode, no Python echo. + model.on('change:event_json', () => { + try { + const msg = JSON.parse(model.get('event_json') || '{}'); + if (!msg || msg.source !== 'python') return; + const p = panels.get(msg.panel_id); + if (!p || !p.state) return; + const ws = p.state.overlay_widgets || []; + const wi = ws.findIndex(w => w.id === msg.widget_id); + if (wi < 0) return; + // Apply every field from the message except protocol keys + const skip = new Set(['source', 'panel_id', 'widget_id']); + for (const [k, v] of Object.entries(msg)) { + if (!skip.has(k)) ws[wi][k] = v; + } + _redrawPanel(p); + } catch(_) {} + }); + // ── initial render ──────────────────────────────────────────────────────── applyLayout(); } diff --git a/anyplotlib/figure_plots.py b/anyplotlib/figure_plots.py index 813b816d..042fabf1 100644 --- a/anyplotlib/figure_plots.py +++ b/anyplotlib/figure_plots.py @@ -20,11 +20,21 @@ import uuid as _uuid import numpy as np +from typing import Callable from anyplotlib.markers import MarkerRegistry from anyplotlib.callbacks import CallbackRegistry +from anyplotlib.widgets import ( + Widget, + RectangleWidget, CircleWidget, AnnularWidget, + CrosshairWidget, PolygonWidget, LabelWidget, + VLineWidget as _VLineWidget, + HLineWidget as _HLineWidget, + RangeWidget as _RangeWidget, +) -__all__ = ["GridSpec", "SubplotSpec", "Axes", "Plot1D", "Plot2D", "PlotMesh", "Plot3D"] +__all__ = ["GridSpec", "SubplotSpec", "Axes", "Plot1D", "Plot2D", "PlotMesh", "Plot3D", + "_resample_mesh"] # --------------------------------------------------------------------------- @@ -352,6 +362,44 @@ def _build_colormap_lut(name: str) -> list: return [[i, i, i] for i in range(256)] +def _resample_mesh(data: np.ndarray, x_edges, y_edges) -> np.ndarray: + """Resample a mesh to a regular pixel grid via nearest-neighbour lookup. + + For uniform edges this is an identity operation. For non-uniform edges + (e.g. log-spaced) it maps each uniform output pixel to the nearest input + cell, producing a visually correct linear-axis image. + + Parameters + ---------- + data : ndarray, shape (M, N) — one value per mesh cell. + x_edges : array-like, length N+1 — column edge coordinates. + y_edges : array-like, length M+1 — row edge coordinates. + + Returns + ------- + ndarray, shape (M, N) + """ + rows, cols = data.shape + x_edges = np.asarray(x_edges, dtype=float) + y_edges = np.asarray(y_edges, dtype=float) + + # Cell centres + x_c = (x_edges[:-1] + x_edges[1:]) / 2.0 + y_c = (y_edges[:-1] + y_edges[1:]) / 2.0 + + # Uniform sample points (same count as original cells) + x_samp = np.linspace(x_c[0], x_c[-1], cols) + y_samp = np.linspace(y_c[0], y_c[-1], rows) + + # Nearest-neighbour cell lookup via edge-sorted searchsorted + xi = np.searchsorted(x_edges, x_samp) - 1 + xi = np.clip(xi, 0, cols - 1) + yi = np.searchsorted(y_edges, y_samp) - 1 + yi = np.clip(yi, 0, rows - 1) + + return data[np.ix_(yi, xi)] + + # --------------------------------------------------------------------------- # Plot2D # --------------------------------------------------------------------------- @@ -432,6 +480,7 @@ def __init__(self, data: np.ndarray, self.markers = MarkerRegistry(self._push_markers, allowed=MarkerRegistry._KNOWN_2D) self.callbacks = CallbackRegistry() + self._widgets: dict[str, Widget] = {} @staticmethod def _encode_bytes(arr: np.ndarray) -> str: @@ -442,6 +491,7 @@ def _push(self) -> None: """Serialise _state + markers and write to Figure trait.""" if self._fig is None: return + self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] self._fig._push(self._id) def _push_markers(self) -> None: @@ -452,6 +502,7 @@ def _push_markers(self) -> None: def to_state_dict(self) -> dict: """Return a JSON-serialisable copy of the current state.""" d = dict(self._state) + d["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] d["markers"] = self.markers.to_wire_list() return d @@ -536,113 +587,112 @@ def colormap_name(self, name: str) -> None: # ------------------------------------------------------------------ # Overlay Widgets # ------------------------------------------------------------------ - def add_widget(self, kind: str, color: str = "#00e5ff", **kwargs) -> str: + def add_widget(self, kind: str, color: str = "#00e5ff", **kwargs) -> Widget: kind = kind.lower() valid = ("circle", "rectangle", "annular", "polygon", "label", "crosshair") if kind not in valid: raise ValueError(f"kind must be one of {valid}") - wid = str(_uuid.uuid4())[:8] iw, ih = self._state["image_width"], self._state["image_height"] def _f(k, default): return float(kwargs.get(k, default)) def _i(k, default): return int(kwargs.get(k, default)) if kind == "circle": - entry = {"id": wid, "type": "circle", - "cx": _f("cx", iw/2), "cy": _f("cy", ih/2), - "r": _f("r", iw*0.1), "color": color} + widget = CircleWidget(lambda: None, + cx=_f("cx", iw / 2), cy=_f("cy", ih / 2), + r=_f("r", iw * 0.1), color=color) elif kind == "rectangle": - entry = {"id": wid, "type": "rectangle", - "x": _f("x", iw*0.25), "y": _f("y", ih*0.25), - "w": _f("w", iw*0.5), "h": _f("h", ih*0.5), "color": color} + widget = RectangleWidget(lambda: None, + x=_f("x", iw * 0.25), y=_f("y", ih * 0.25), + w=_f("w", iw * 0.5), h=_f("h", ih * 0.5), + color=color) elif kind == "annular": - r_outer = _f("r_outer", iw*0.2) - r_inner = _f("r_inner", iw*0.1) - if r_inner >= r_outer: - raise ValueError("r_inner must be < r_outer") - entry = {"id": wid, "type": "annular", - "cx": _f("cx", iw/2), "cy": _f("cy", ih/2), - "r_outer": r_outer, "r_inner": r_inner, "color": color} + r_outer = _f("r_outer", iw * 0.2) + r_inner = _f("r_inner", iw * 0.1) + widget = AnnularWidget(lambda: None, + cx=_f("cx", iw / 2), cy=_f("cy", ih / 2), + r_outer=r_outer, r_inner=r_inner, color=color) elif kind == "polygon": - raw = kwargs.get("vertices", [[iw*.25,ih*.25],[iw*.75,ih*.25], - [iw*.75,ih*.75],[iw*.25,ih*.75]]) - verts = [[float(x), float(y)] for x, y in raw] - if len(verts) < 3: - raise ValueError("polygon needs >= 3 vertices") - entry = {"id": wid, "type": "polygon", "vertices": verts, "color": color} + raw = kwargs.get("vertices", [[iw * .25, ih * .25], [iw * .75, ih * .25], + [iw * .75, ih * .75], [iw * .25, ih * .75]]) + widget = PolygonWidget(lambda: None, vertices=raw, color=color) elif kind == "crosshair": - entry = {"id": wid, "type": "crosshair", - "cx": _f("cx", iw/2), "cy": _f("cy", ih/2), "color": color} + widget = CrosshairWidget(lambda: None, + cx=_f("cx", iw / 2), cy=_f("cy", ih / 2), + color=color) else: # label - entry = {"id": wid, "type": "label", - "x": _f("x", iw*0.1), "y": _f("y", ih*0.1), - "text": str(kwargs.get("text", "Label")), - "fontsize": _i("fontsize", 14), "color": color} + widget = LabelWidget(lambda: None, + x=_f("x", iw * 0.1), y=_f("y", ih * 0.1), + text=str(kwargs.get("text", "Label")), + fontsize=_i("fontsize", 14), color=color) + + # Replace the temporary push_fn with a targeted one now that + # we have both the widget's _id and the plot's _id. + plot_ref = self + wid_id = widget._id + def _targeted_push(): + if plot_ref._fig is not None: + fields = {k: v for k, v in widget._data.items() + if k not in ("id", "type")} + plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) + widget._push_fn = _targeted_push + + self._widgets[widget.id] = widget + self._push() # full panel push once so JS knows about the widget + return widget + + def get_widget(self, wid) -> Widget: + """Return the Widget object by ID string or Widget instance.""" + if isinstance(wid, Widget): + wid = wid.id + try: + return self._widgets[wid] + except KeyError: + raise KeyError(wid) - self._state["overlay_widgets"].append(entry) - self._push() - return wid - - def get_widget(self, wid: str) -> dict: - for w in self._state["overlay_widgets"]: - if w["id"] == wid: - return dict(w) - raise KeyError(wid) - - def remove_widget(self, wid: str) -> None: - before = len(self._state["overlay_widgets"]) - self._state["overlay_widgets"] = [ - w for w in self._state["overlay_widgets"] if w["id"] != wid] - if len(self._state["overlay_widgets"]) == before: + def remove_widget(self, wid) -> None: + """Remove a widget by ID string or Widget instance.""" + if isinstance(wid, Widget): + wid = wid.id + if wid not in self._widgets: raise KeyError(wid) + del self._widgets[wid] self._push() def list_widgets(self) -> list: - return [dict(w) for w in self._state["overlay_widgets"]] + return list(self._widgets.values()) def clear_widgets(self) -> None: - self._state["overlay_widgets"] = [] + self._widgets.clear() self._push() - # convenience widget helpers - def add_annular_widget(self, cx=None, cy=None, r_outer=None, r_inner=None, - color="#00e5ff") -> str: - iw, ih = self._state["image_width"], self._state["image_height"] - return self.add_widget("annular", color=color, - cx=cx or iw/2, cy=cy or ih/2, - r_outer=r_outer or iw*0.2, - r_inner=r_inner or iw*0.1) - - def add_crosshair_widget(self, cx=None, cy=None, color="#00e5ff") -> str: - iw, ih = self._state["image_width"], self._state["image_height"] - return self.add_widget("crosshair", color=color, - cx=cx or iw/2, cy=cy or ih/2) - # ------------------------------------------------------------------ - # Callback API + # Callback API (Plot2D) # ------------------------------------------------------------------ - def on_change(self, widget_id=None): - """Fires on every drag/scroll frame. Keep callbacks fast.""" - def decorator(fn): - cid = self.callbacks.connect("change", None, widget_id, fn) - fn._cid = cid - return fn - return decorator - - def on_release(self, widget_id=None): - """Fires once when drag settles. Safe for expensive work.""" - def decorator(fn): - cid = self.callbacks.connect("release", None, widget_id, fn) - fn._cid = cid - return fn - return decorator + def on_changed(self, fn: Callable) -> Callable: + """Decorator: fires on every pan/zoom/drag frame on this panel.""" + cid = self.callbacks.connect("on_changed", fn) + fn._cid = cid + return fn + + def on_release(self, fn: Callable) -> Callable: + """Decorator: fires once when pan/zoom/drag settles on this panel.""" + cid = self.callbacks.connect("on_release", fn) + fn._cid = cid + return fn + + def on_click(self, fn: Callable) -> Callable: + """Decorator: fires on click on this panel.""" + cid = self.callbacks.connect("on_click", fn) + fn._cid = cid + return fn def disconnect(self, cid: int) -> None: - """Remove the callback registered under integer *cid*.""" + """Remove the callback registered under integer cid.""" self.callbacks.disconnect(cid) # ------------------------------------------------------------------ - # Marker API (matplotlib-style kwargs → MarkerRegistry) + # Marker API (circles and lines only) # ------------------------------------------------------------------ def _add_marker(self, mtype: str, name: str | None, **kwargs) -> "MarkerGroup": # noqa: F821 return self.markers.add(mtype, name, **kwargs) @@ -652,6 +702,7 @@ def add_circles(self, offsets, name=None, *, radius=5, linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, labels=None, label=None) -> "MarkerGroup": # noqa: F821 + """Add point markers in physical (data) coordinates.""" return self._add_marker("circles", name, offsets=offsets, radius=radius, facecolors=facecolors, edgecolors=edgecolors, linewidths=linewidths, alpha=alpha, @@ -659,84 +710,16 @@ def add_circles(self, offsets, name=None, *, radius=5, hover_facecolors=hover_facecolors, labels=labels, label=label) - def add_arrows(self, offsets, U, V, name=None, *, - edgecolors="#ff0000", linewidths=1.5, - hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - return self._add_marker("arrows", name, offsets=offsets, U=U, V=V, - edgecolors=edgecolors, linewidths=linewidths, - hover_edgecolors=hover_edgecolors, - labels=labels, label=label) - - def add_ellipses(self, offsets, widths, heights, name=None, *, - angles=0, facecolors=None, edgecolors="#ff0000", - linewidths=1.5, alpha=0.3, - hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - return self._add_marker("ellipses", name, offsets=offsets, - widths=widths, heights=heights, angles=angles, - facecolors=facecolors, edgecolors=edgecolors, - linewidths=linewidths, alpha=alpha, - hover_edgecolors=hover_edgecolors, - hover_facecolors=hover_facecolors, - labels=labels, label=label) - def add_lines(self, segments, name=None, *, edgecolors="#ff0000", linewidths=1.5, hover_edgecolors=None, labels=None, label=None) -> "MarkerGroup": # noqa: F821 + """Add line-segment markers in physical (data) coordinates.""" return self._add_marker("lines", name, segments=segments, edgecolors=edgecolors, linewidths=linewidths, hover_edgecolors=hover_edgecolors, labels=labels, label=label) - def add_rectangles(self, offsets, widths, heights, name=None, *, - angles=0, facecolors=None, edgecolors="#ff0000", - linewidths=1.5, alpha=0.3, - hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - return self._add_marker("rectangles", name, offsets=offsets, - widths=widths, heights=heights, angles=angles, - facecolors=facecolors, edgecolors=edgecolors, - linewidths=linewidths, alpha=alpha, - hover_edgecolors=hover_edgecolors, - hover_facecolors=hover_facecolors, - labels=labels, label=label) - - def add_squares(self, offsets, widths, name=None, *, - angles=0, facecolors=None, edgecolors="#ff0000", - linewidths=1.5, alpha=0.3, - hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - return self._add_marker("squares", name, offsets=offsets, - widths=widths, angles=angles, - facecolors=facecolors, edgecolors=edgecolors, - linewidths=linewidths, alpha=alpha, - hover_edgecolors=hover_edgecolors, - hover_facecolors=hover_facecolors, - labels=labels, label=label) - - def add_polygons(self, vertices_list, name=None, *, - facecolors=None, edgecolors="#ff0000", - linewidths=1.5, alpha=0.3, - hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - return self._add_marker("polygons", name, vertices_list=vertices_list, - facecolors=facecolors, edgecolors=edgecolors, - linewidths=linewidths, alpha=alpha, - hover_edgecolors=hover_edgecolors, - hover_facecolors=hover_facecolors, - labels=labels, label=label) - - def add_texts(self, offsets, texts, name=None, *, - color="#ff0000", fontsize=12, - hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - return self._add_marker("texts", name, offsets=offsets, texts=texts, - color=color, fontsize=fontsize, - hover_edgecolors=hover_edgecolors, - labels=labels, label=label) - def remove_marker(self, marker_type: str, name: str) -> None: self.markers.remove(marker_type, name) @@ -752,179 +735,97 @@ def list_markers(self) -> list: # --------------------------------------------------------------------------- -# PlotMesh helpers -# --------------------------------------------------------------------------- - -def _resample_mesh(data: np.ndarray, - x_edges: np.ndarray, - y_edges: np.ndarray) -> np.ndarray: - """Resample an (M, N) mesh to a regular (M, N) pixel grid. - - The non-uniform cell *edges* define where each column/row boundary sits - in physical space. We map a uniformly-spaced output grid back to input - cell indices via ``np.searchsorted`` on the edges, giving a simple - nearest-cell lookup (no interpolation). - - Parameters - ---------- - data : shape (M, N) - x_edges : length N+1 – column edge coordinates - y_edges : length M+1 – row edge coordinates - - Returns - ------- - resampled : shape (M, N) float64, ready for ``_normalize_image`` - """ - m, n = data.shape - # Uniform sample points at cell centres of the *output* (M, N) grid - # expressed in the same physical space as the edges. - x_out = np.linspace(x_edges[0], x_edges[-1], n, endpoint=False) \ - + (x_edges[-1] - x_edges[0]) / (2 * n) - y_out = np.linspace(y_edges[0], y_edges[-1], m, endpoint=False) \ - + (y_edges[-1] - y_edges[0]) / (2 * m) - - # Map each output sample to the source cell index (clipped to valid range) - xi = np.clip(np.searchsorted(x_edges, x_out, side='right') - 1, 0, n - 1) - yi = np.clip(np.searchsorted(y_edges, y_out, side='right') - 1, 0, m - 1) - - return data[np.ix_(yi, xi)].astype(np.float64) - - -# --------------------------------------------------------------------------- -# PlotMesh +# PlotMesh (pcolormesh-style 2-D panel) # --------------------------------------------------------------------------- -class PlotMesh: - """2-D mesh plot panel using pcolormesh-style edge coordinates. - - Accepts cell *edge* arrays (length N+1 / M+1) exactly like - ``matplotlib.axes.Axes.pcolormesh``. The mesh is resampled to a regular - pixel grid internally; the original edge arrays are sent to JS for - accurate non-linear axis ticks. +class PlotMesh(Plot2D): + """2-D mesh plot panel created by :meth:`Axes.pcolormesh`. - Marker support is limited to circles (points) and line segments. - Overlay widgets are not supported. + Accepts cell *edge* arrays (length N+1 / M+1) rather than centre arrays, + matches matplotlib's ``pcolormesh`` convention. Only ``'circles'`` and + ``'lines'`` markers are supported. """ def __init__(self, data: np.ndarray, x_edges=None, y_edges=None, units: str = ""): - self._id: str = "" # assigned by Axes._attach - self._fig: object = None # assigned by Axes._attach - - data = np.asarray(data, dtype=float) + data = np.asarray(data) if data.ndim != 2: raise ValueError(f"data must be 2-D (M x N), got {data.shape}") - - m, n = data.shape + rows, cols = data.shape if x_edges is None: - x_edges = np.arange(n + 1, dtype=float) + x_edges = np.arange(cols + 1, dtype=float) if y_edges is None: - y_edges = np.arange(m + 1, dtype=float) - + y_edges = np.arange(rows + 1, dtype=float) x_edges = np.asarray(x_edges, dtype=float) y_edges = np.asarray(y_edges, dtype=float) - if x_edges.ndim != 1 or len(x_edges) != n + 1: + if len(x_edges) != cols + 1: raise ValueError( - f"x_edges must be 1-D with length N+1={n+1}, got {x_edges.shape}") - if y_edges.ndim != 1 or len(y_edges) != m + 1: + f"x_edges must have length {cols + 1} for {cols} columns, " + f"got {len(x_edges)}") + if len(y_edges) != rows + 1: raise ValueError( - f"y_edges must be 1-D with length M+1={m+1}, got {y_edges.shape}") - - self._x_edges = x_edges - self._y_edges = y_edges + f"y_edges must have length {rows + 1} for {rows} rows, " + f"got {len(y_edges)}") + # Resample to a regular pixel grid for display resampled = _resample_mesh(data, x_edges, y_edges) - img_u8, vmin, vmax = _normalize_image(resampled) - self._raw_u8 = img_u8 - self._raw_vmin = vmin - self._raw_vmax = vmax - - cmap_lut = _build_colormap_lut("gray") - - self._state: dict = { - "kind": "2d", - "is_mesh": True, - "image_b64": Plot2D._encode_bytes(img_u8), - "image_width": n, - "image_height": m, - "x_axis": x_edges.tolist(), - "y_axis": y_edges.tolist(), - "units": units, - "hist_min": vmin, - "hist_max": vmax, - "display_min": vmin, - "display_max": vmax, - "histogram_data": _compute_histogram(img_u8, vmin, vmax), - "histogram_visible": False, - "show_colorbar": False, - "log_scale": False, - "scale_mode": "linear", - "colormap_name": "gray", - "colormap_data": cmap_lut, - "zoom": 1.0, - "center_x": 0.5, - "center_y": 0.5, - "overlay_widgets": [], - "markers": [], - } + # Use cell centres to initialise the parent (axes will be replaced) + x_c = (x_edges[:-1] + x_edges[1:]) / 2.0 + y_c = (y_edges[:-1] + y_edges[1:]) / 2.0 + super().__init__(resampled, x_axis=x_c, y_axis=y_c, units=units) + + # Override mesh-specific state + self._state["is_mesh"] = True + self._state["has_axes"] = True + # Store edges (not centres) so the JS renderer can place grid lines + self._state["x_axis"] = x_edges.tolist() + self._state["y_axis"] = y_edges.tolist() + # Mesh panels have no fixed pixel scale + self._state.pop("scale_x", None) + self._state.pop("scale_y", None) + + # Restrict markers to circles + lines only self.markers = MarkerRegistry(self._push_markers, allowed=MarkerRegistry._KNOWN_MESH) - self.callbacks = CallbackRegistry() - - def _push(self) -> None: - if self._fig is None: - return - self._fig._push(self._id) - - def _push_markers(self) -> None: - self._state["markers"] = self.markers.to_wire_list() - self._push() - - def to_state_dict(self) -> dict: - d = dict(self._state) - d["markers"] = self.markers.to_wire_list() - return d # ------------------------------------------------------------------ # Data update # ------------------------------------------------------------------ def update(self, data: np.ndarray, - x_edges=None, y_edges=None, - units: str | None = None) -> None: - """Replace the mesh data.""" - data = np.asarray(data, dtype=float) + x_edges=None, y_edges=None, units: str | None = None) -> None: + """Replace the mesh data (and optionally the edge arrays).""" + data = np.asarray(data) if data.ndim != 2: raise ValueError(f"data must be 2-D, got {data.shape}") - m, n = data.shape - - if x_edges is not None: - x_edges = np.asarray(x_edges, dtype=float) - if len(x_edges) != n + 1: - raise ValueError(f"x_edges must have length N+1={n+1}") - self._x_edges = x_edges - if y_edges is not None: - y_edges = np.asarray(y_edges, dtype=float) - if len(y_edges) != m + 1: - raise ValueError(f"y_edges must have length M+1={m+1}") - self._y_edges = y_edges - - resampled = _resample_mesh(data, self._x_edges, self._y_edges) + rows, cols = data.shape + + cur_xe = np.asarray(self._state["x_axis"], dtype=float) + cur_ye = np.asarray(self._state["y_axis"], dtype=float) + xe = np.asarray(x_edges, dtype=float) if x_edges is not None else cur_xe + ye = np.asarray(y_edges, dtype=float) if y_edges is not None else cur_ye + + if len(xe) != cols + 1: + raise ValueError(f"x_edges must have length {cols + 1}") + if len(ye) != rows + 1: + raise ValueError(f"y_edges must have length {rows + 1}") + + resampled = _resample_mesh(data, xe, ye) img_u8, vmin, vmax = _normalize_image(resampled) self._raw_u8, self._raw_vmin, self._raw_vmax = img_u8, vmin, vmax self._state.update({ - "image_b64": Plot2D._encode_bytes(img_u8), - "image_width": n, - "image_height": m, - "x_axis": self._x_edges.tolist(), - "y_axis": self._y_edges.tolist(), - "hist_min": vmin, - "hist_max": vmax, - "display_min": vmin, - "display_max": vmax, + "image_b64": self._encode_bytes(img_u8), + "image_width": cols, + "image_height": rows, + "x_axis": xe.tolist(), + "y_axis": ye.tolist(), + "hist_min": vmin, + "hist_max": vmax, + "display_min": vmin, + "display_max": vmax, "histogram_data": _compute_histogram(img_u8, vmin, vmax), "colormap_data": _build_colormap_lut(self._state["colormap_name"]), }) @@ -932,113 +833,9 @@ def update(self, data: np.ndarray, self._state["units"] = units self._push() - # ------------------------------------------------------------------ - # Display settings (same API as Plot2D) - # ------------------------------------------------------------------ - def set_colormap(self, name: str) -> None: - self._state["colormap_name"] = name - self._state["colormap_data"] = _build_colormap_lut(name) - self._push() - - def set_clim(self, vmin=None, vmax=None) -> None: - if vmin is not None: - self._state["display_min"] = float(vmin) - if vmax is not None: - self._state["display_max"] = float(vmax) - self._push() - - def set_scale_mode(self, mode: str) -> None: - valid = ("linear", "log", "symlog") - if mode not in valid: - raise ValueError(f"mode must be one of {valid}") - self._state["scale_mode"] = mode - self._push() - - @property - def histogram_visible(self) -> bool: - return self._state["histogram_visible"] - - @histogram_visible.setter - def histogram_visible(self, val: bool) -> None: - self._state["histogram_visible"] = bool(val) - self._push() - - @property - def colormap_name(self) -> str: - return self._state["colormap_name"] - - @colormap_name.setter - def colormap_name(self, name: str) -> None: - self.set_colormap(name) - - # ------------------------------------------------------------------ - # Callback API (PlotMesh) - # ------------------------------------------------------------------ - def on_change(self, widget_id=None): - """Fires on every drag/zoom frame. Keep callbacks fast.""" - def decorator(fn): - cid = self.callbacks.connect("change", None, widget_id, fn) - fn._cid = cid - return fn - return decorator - - def on_release(self, widget_id=None): - """Fires once when drag/zoom settles.""" - def decorator(fn): - cid = self.callbacks.connect("release", None, widget_id, fn) - fn._cid = cid - return fn - return decorator - - def disconnect(self, cid: int) -> None: - """Remove the callback registered under integer *cid*.""" - self.callbacks.disconnect(cid) - - # ------------------------------------------------------------------ - # Marker API (circles and lines only) - # ------------------------------------------------------------------ - def _add_marker(self, mtype: str, name: str | None, **kwargs) -> "MarkerGroup": # noqa: F821 - return self.markers.add(mtype, name, **kwargs) - - def add_circles(self, offsets, name=None, *, radius=5, - facecolors=None, edgecolors="#ff0000", - linewidths=1.5, alpha=0.3, - hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - """Add point markers in physical (data) coordinates.""" - return self._add_marker("circles", name, offsets=offsets, radius=radius, - facecolors=facecolors, edgecolors=edgecolors, - linewidths=linewidths, alpha=alpha, - hover_edgecolors=hover_edgecolors, - hover_facecolors=hover_facecolors, - labels=labels, label=label) - - def add_lines(self, segments, name=None, *, - edgecolors="#ff0000", linewidths=1.5, - hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - """Add line-segment markers in physical (data) coordinates.""" - return self._add_marker("lines", name, segments=segments, - edgecolors=edgecolors, linewidths=linewidths, - hover_edgecolors=hover_edgecolors, - labels=labels, label=label) - - def remove_marker(self, marker_type: str, name: str) -> None: - self.markers.remove(marker_type, name) - - def clear_markers(self) -> None: - self.markers.clear() - - def list_markers(self) -> list: - out = [] - for mtype, td in self.markers._types.items(): - for name, g in td.items(): - out.append({"type": mtype, "name": name, "n": g._count()}) - return out - # --------------------------------------------------------------------------- -# Plot3D +# _triangulate_grid helper + Plot3D # --------------------------------------------------------------------------- def _triangulate_grid(rows: int, cols: int) -> list: @@ -1165,21 +962,23 @@ def to_state_dict(self) -> dict: # ------------------------------------------------------------------ # Callback API (Plot3D) # ------------------------------------------------------------------ - def on_change(self, widget_id=None): - """Fires on every rotation/zoom frame. Keep callbacks fast.""" - def decorator(fn): - cid = self.callbacks.connect("change", None, widget_id, fn) - fn._cid = cid - return fn - return decorator - - def on_release(self, widget_id=None): - """Fires once when rotation/zoom settles.""" - def decorator(fn): - cid = self.callbacks.connect("release", None, widget_id, fn) - fn._cid = cid - return fn - return decorator + def on_changed(self, fn: Callable) -> Callable: + """Decorator: fires on every rotation/zoom frame.""" + cid = self.callbacks.connect("on_changed", fn) + fn._cid = cid + return fn + + def on_release(self, fn: Callable) -> Callable: + """Decorator: fires once when rotation/zoom settles.""" + cid = self.callbacks.connect("on_release", fn) + fn._cid = cid + return fn + + def on_click(self, fn: Callable) -> Callable: + """Decorator: fires on click on this panel.""" + cid = self.callbacks.connect("on_click", fn) + fn._cid = cid + return fn def disconnect(self, cid: int) -> None: """Remove the callback registered under integer *cid*.""" @@ -1308,10 +1107,12 @@ def __init__(self, data: np.ndarray, self.markers = MarkerRegistry(self._push_markers, allowed=MarkerRegistry._KNOWN_1D) self.callbacks = CallbackRegistry() + self._widgets: dict[str, Widget] = {} def _push(self) -> None: if self._fig is None: return + self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] self._fig._push(self._id) def _push_markers(self) -> None: @@ -1320,6 +1121,7 @@ def _push_markers(self) -> None: def to_state_dict(self) -> dict: d = dict(self._state) + d["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] d["markers"] = self.markers.to_wire_list() return d @@ -1408,68 +1210,88 @@ def clear_spans(self) -> None: # ------------------------------------------------------------------ # Overlay Widgets # ------------------------------------------------------------------ - def add_vline_widget(self, x: float, color: str = "#00e5ff") -> str: - wid = str(_uuid.uuid4())[:8] - self._state["overlay_widgets"].append( - {"id": wid, "type": "vline", "x": float(x), "color": color}) + def add_vline_widget(self, x: float, color: str = "#00e5ff") -> _VLineWidget: + widget = _VLineWidget(lambda: None, x=float(x), color=color) + plot_ref, wid_id = self, widget._id + def _tp(): + if plot_ref._fig is not None: + fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} + plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) + widget._push_fn = _tp + self._widgets[widget.id] = widget self._push() - return wid - - def add_hline_widget(self, y: float, color: str = "#00e5ff") -> str: - wid = str(_uuid.uuid4())[:8] - self._state["overlay_widgets"].append( - {"id": wid, "type": "hline", "y": float(y), "color": color}) + return widget + + def add_hline_widget(self, y: float, color: str = "#00e5ff") -> _HLineWidget: + widget = _HLineWidget(lambda: None, y=float(y), color=color) + plot_ref, wid_id = self, widget._id + def _tp(): + if plot_ref._fig is not None: + fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} + plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) + widget._push_fn = _tp + self._widgets[widget.id] = widget self._push() - return wid + return widget def add_range_widget(self, x0: float, x1: float, - color: str = "#00e5ff") -> str: - wid = str(_uuid.uuid4())[:8] - self._state["overlay_widgets"].append( - {"id": wid, "type": "range", - "x0": float(x0), "x1": float(x1), "color": color}) + color: str = "#00e5ff") -> _RangeWidget: + widget = _RangeWidget(lambda: None, x0=float(x0), x1=float(x1), color=color) + plot_ref, wid_id = self, widget._id + def _tp(): + if plot_ref._fig is not None: + fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} + plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) + widget._push_fn = _tp + self._widgets[widget.id] = widget self._push() - return wid - - def get_widget(self, wid: str) -> dict: - for w in self._state["overlay_widgets"]: - if w["id"] == wid: - return dict(w) - raise KeyError(wid) - - def remove_widget(self, wid: str) -> None: - before = len(self._state["overlay_widgets"]) - self._state["overlay_widgets"] = [ - w for w in self._state["overlay_widgets"] if w["id"] != wid] - if len(self._state["overlay_widgets"]) == before: + return widget + + def get_widget(self, wid) -> Widget: + """Return the Widget object by ID string or Widget instance.""" + if isinstance(wid, Widget): + wid = wid.id + try: + return self._widgets[wid] + except KeyError: raise KeyError(wid) + + def remove_widget(self, wid) -> None: + """Remove a widget by ID string or Widget instance.""" + if isinstance(wid, Widget): + wid = wid.id + if wid not in self._widgets: + raise KeyError(wid) + del self._widgets[wid] self._push() def list_widgets(self) -> list: - return [dict(w) for w in self._state["overlay_widgets"]] + return list(self._widgets.values()) def clear_widgets(self) -> None: - self._state["overlay_widgets"] = [] + self._widgets.clear() self._push() # ------------------------------------------------------------------ # Callback API (Plot1D) # ------------------------------------------------------------------ - def on_change(self, widget_id=None): - """Fires on every drag frame. Keep callbacks fast.""" - def decorator(fn): - cid = self.callbacks.connect("change", None, widget_id, fn) - fn._cid = cid - return fn - return decorator - - def on_release(self, widget_id=None): - """Fires once when drag settles. Safe for expensive work.""" - def decorator(fn): - cid = self.callbacks.connect("release", None, widget_id, fn) - fn._cid = cid - return fn - return decorator + def on_changed(self, fn: Callable) -> Callable: + """Decorator: fires on every drag/zoom frame on this panel.""" + cid = self.callbacks.connect("on_changed", fn) + fn._cid = cid + return fn + + def on_release(self, fn: Callable) -> Callable: + """Decorator: fires once when drag/zoom settles on this panel.""" + cid = self.callbacks.connect("on_release", fn) + fn._cid = cid + return fn + + def on_click(self, fn: Callable) -> Callable: + """Decorator: fires on click on this panel.""" + cid = self.callbacks.connect("on_click", fn) + fn._cid = cid + return fn def disconnect(self, cid: int) -> None: """Remove the callback registered under integer *cid*.""" @@ -1496,39 +1318,43 @@ def reset_view(self) -> None: self._push() # ------------------------------------------------------------------ - # Marker API (matplotlib-style) + # Marker API (matplotlib-style kwargs → MarkerRegistry) # ------------------------------------------------------------------ def _add_marker(self, mtype: str, name: str | None, **kwargs) -> "MarkerGroup": # noqa: F821 return self.markers.add(mtype, name, **kwargs) - def add_points(self, offsets, name=None, *, sizes=5, - facecolors=None, edgecolors="#ff0000", - linewidths=1.5, alpha=0.3, - hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - return self._add_marker("points", name, offsets=offsets, sizes=sizes, + def add_circles(self, offsets, name=None, *, radius=5, + facecolors=None, edgecolors="#ff0000", + linewidths=1.5, alpha=0.3, + hover_edgecolors=None, hover_facecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + return self._add_marker("circles", name, offsets=offsets, radius=radius, facecolors=facecolors, edgecolors=edgecolors, linewidths=linewidths, alpha=alpha, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, labels=labels, label=label) - def add_vlines(self, offsets, name=None, *, - color="#ff0000", linewidths=1.5, + def add_arrows(self, offsets, U, V, name=None, *, + edgecolors="#ff0000", linewidths=1.5, hover_edgecolors=None, labels=None, label=None) -> "MarkerGroup": # noqa: F821 - return self._add_marker("vlines", name, offsets=offsets, - color=color, linewidths=linewidths, + return self._add_marker("arrows", name, offsets=offsets, U=U, V=V, + edgecolors=edgecolors, linewidths=linewidths, hover_edgecolors=hover_edgecolors, labels=labels, label=label) - def add_hlines(self, offsets, name=None, *, - color="#ff0000", linewidths=1.5, - hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - return self._add_marker("hlines", name, offsets=offsets, - color=color, linewidths=linewidths, + def add_ellipses(self, offsets, widths, heights, name=None, *, + angles=0, facecolors=None, edgecolors="#ff0000", + linewidths=1.5, alpha=0.3, + hover_edgecolors=None, hover_facecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + return self._add_marker("ellipses", name, offsets=offsets, + widths=widths, heights=heights, angles=angles, + facecolors=facecolors, edgecolors=edgecolors, + linewidths=linewidths, alpha=alpha, hover_edgecolors=hover_edgecolors, + hover_facecolors=hover_facecolors, labels=labels, label=label) def add_lines(self, segments, name=None, *, @@ -1540,6 +1366,44 @@ def add_lines(self, segments, name=None, *, hover_edgecolors=hover_edgecolors, labels=labels, label=label) + def add_rectangles(self, offsets, widths, heights, name=None, *, + angles=0, facecolors=None, edgecolors="#ff0000", + linewidths=1.5, alpha=0.3, + hover_edgecolors=None, hover_facecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + return self._add_marker("rectangles", name, offsets=offsets, + widths=widths, heights=heights, angles=angles, + facecolors=facecolors, edgecolors=edgecolors, + linewidths=linewidths, alpha=alpha, + hover_edgecolors=hover_edgecolors, + hover_facecolors=hover_facecolors, + labels=labels, label=label) + + def add_squares(self, offsets, widths, name=None, *, + angles=0, facecolors=None, edgecolors="#ff0000", + linewidths=1.5, alpha=0.3, + hover_edgecolors=None, hover_facecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + return self._add_marker("squares", name, offsets=offsets, + widths=widths, angles=angles, + facecolors=facecolors, edgecolors=edgecolors, + linewidths=linewidths, alpha=alpha, + hover_edgecolors=hover_edgecolors, + hover_facecolors=hover_facecolors, + labels=labels, label=label) + + def add_polygons(self, vertices_list, name=None, *, + facecolors=None, edgecolors="#ff0000", + linewidths=1.5, alpha=0.3, + hover_edgecolors=None, hover_facecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + return self._add_marker("polygons", name, vertices_list=vertices_list, + facecolors=facecolors, edgecolors=edgecolors, + linewidths=linewidths, alpha=alpha, + hover_edgecolors=hover_edgecolors, + hover_facecolors=hover_facecolors, + labels=labels, label=label) + def add_texts(self, offsets, texts, name=None, *, color="#ff0000", fontsize=12, hover_edgecolors=None, diff --git a/anyplotlib/widgets.py b/anyplotlib/widgets.py new file mode 100644 index 00000000..9f6cf9db --- /dev/null +++ b/anyplotlib/widgets.py @@ -0,0 +1,221 @@ +""" +widgets.py — Interactive overlay widget classes. + +Each widget has a .callbacks (CallbackRegistry). Register handlers via:: + + @rect.on_changed # every drag frame + def live(event): ... + + @rect.on_release # once on mouseup + def done(event): ... + + @rect.on_click + def clicked(event): ... + + rect.x = 40 # moves widget, sends targeted update to JS + rect.x # always reflects current JS position +""" +from __future__ import annotations + +import uuid as _uuid +from typing import Callable + +from anyplotlib.callbacks import CallbackRegistry, Event + +__all__ = [ + "Widget", + "RectangleWidget", "CircleWidget", "AnnularWidget", + "CrosshairWidget", "PolygonWidget", "LabelWidget", + "VLineWidget", "HLineWidget", "RangeWidget", +] + + +class Widget: + """Base class for all overlay widgets.""" + + def __init__(self, wtype: str, push_fn: Callable, **kwargs): + self._id: str = str(_uuid.uuid4())[:8] + self._type: str = wtype + self._data: dict = dict(kwargs) + self._data["id"] = self._id + self._data["type"] = wtype + self._push_fn: Callable = push_fn + self.callbacks: CallbackRegistry = CallbackRegistry() + + # ── attribute read ──────────────────────────────────────────────── + + def __getattr__(self, key: str): + if key.startswith("_"): + raise AttributeError(key) + try: + return self._data[key] + except KeyError: + raise AttributeError( + f"{type(self).__name__} has no attribute {key!r}. " + f"Available: {list(self._data)}" + ) from None + + # ── attribute write — routes public assignments through set() ──── + + def __setattr__(self, key: str, value) -> None: + # Private attrs and 'callbacks' bypass set() + if key.startswith("_") or key == "callbacks": + super().__setattr__(key, value) + return + # During __init__ _data may not exist yet + try: + object.__getattribute__(self, "_data") + except AttributeError: + super().__setattr__(key, value) + return + self.set(**{key: value}) + + # ── set / get ───────────────────────────────────────────────────── + + def set(self, _push: bool = True, **kwargs) -> None: + """Update properties. Sends a targeted event_json update to JS + (not a full panel push). Fires on_changed callbacks. + + Use _push=False internally (e.g. _update_from_js) to avoid echo. + """ + self._data.update(kwargs) + if _push: + self._push_fn() + self.callbacks.fire(Event("on_changed", source=self, data=dict(self._data))) + + def get(self, key: str, default=None): + return self._data.get(key, default) + + def to_dict(self) -> dict: + return dict(self._data) + + # ── callback decorator methods ──────────────────────────────────── + + def on_changed(self, fn: Callable) -> Callable: + """Decorator: register fn to fire on every drag frame.""" + cid = self.callbacks.connect("on_changed", fn) + fn._cid = cid + return fn + + def on_release(self, fn: Callable) -> Callable: + """Decorator: register fn to fire once when drag settles.""" + cid = self.callbacks.connect("on_release", fn) + fn._cid = cid + return fn + + def on_click(self, fn: Callable) -> Callable: + """Decorator: register fn to fire on click.""" + cid = self.callbacks.connect("on_click", fn) + fn._cid = cid + return fn + + def disconnect(self, cid) -> None: + """Remove the callback registered under *cid*. + + Accepts either the integer CID returned by ``callbacks.connect()``, + or the decorated function itself (which carries a ``._cid`` attribute). + """ + if callable(cid) and hasattr(cid, "_cid"): + cid = cid._cid + self.callbacks.disconnect(cid) + + # ── JS → Python sync ────────────────────────────────────────────── + + def _update_from_js(self, new_data: dict, event_type: str = "on_changed") -> bool: + """Apply incoming JS state without pushing back (avoids echo). + Fires self.callbacks with event_type. Returns True if data changed. + Always fires for on_release / on_click even when nothing changed. + """ + changed = False + for k, v in new_data.items(): + if k in ("id", "type"): + continue + if self._data.get(k) != v: + self._data[k] = v + changed = True + # Always fire for settle / click; only fire on_changed when something moved + if changed or event_type in ("on_release", "on_click"): + self.callbacks.fire(Event(event_type, source=self, data=dict(self._data))) + return changed + + # ── repr ────────────────────────────────────────────────────────── + + def __repr__(self) -> str: + props = ", ".join( + f"{k}={v:.4g}" if isinstance(v, float) else f"{k}={v!r}" + for k, v in self._data.items() + if k not in ("id", "type", "color") + ) + return f"{type(self).__name__}({props})" + + @property + def id(self) -> str: + return self._id + + +# --------------------------------------------------------------------------- +# 2-D widgets +# --------------------------------------------------------------------------- + +class RectangleWidget(Widget): + def __init__(self, push_fn, *, x, y, w, h, color="#00e5ff"): + super().__init__("rectangle", push_fn, + x=float(x), y=float(y), + w=float(w), h=float(h), color=color) + + +class CircleWidget(Widget): + def __init__(self, push_fn, *, cx, cy, r, color="#00e5ff"): + super().__init__("circle", push_fn, + cx=float(cx), cy=float(cy), r=float(r), color=color) + + +class AnnularWidget(Widget): + def __init__(self, push_fn, *, cx, cy, r_outer, r_inner, color="#00e5ff"): + if r_inner >= r_outer: + raise ValueError("r_inner must be < r_outer") + super().__init__("annular", push_fn, + cx=float(cx), cy=float(cy), + r_outer=float(r_outer), r_inner=float(r_inner), + color=color) + + +class CrosshairWidget(Widget): + def __init__(self, push_fn, *, cx, cy, color="#00e5ff"): + super().__init__("crosshair", push_fn, + cx=float(cx), cy=float(cy), color=color) + + +class PolygonWidget(Widget): + def __init__(self, push_fn, *, vertices, color="#00e5ff"): + verts = [[float(x), float(y)] for x, y in vertices] + if len(verts) < 3: + raise ValueError("polygon needs >= 3 vertices") + super().__init__("polygon", push_fn, vertices=verts, color=color) + + +class LabelWidget(Widget): + def __init__(self, push_fn, *, x, y, text="Label", fontsize=14, + color="#00e5ff"): + super().__init__("label", push_fn, + x=float(x), y=float(y), + text=str(text), fontsize=int(fontsize), color=color) + + +# --------------------------------------------------------------------------- +# 1-D widgets +# --------------------------------------------------------------------------- + +class VLineWidget(Widget): + def __init__(self, push_fn, *, x, color="#00e5ff"): + super().__init__("vline", push_fn, x=float(x), color=color) + + +class HLineWidget(Widget): + def __init__(self, push_fn, *, y, color="#00e5ff"): + super().__init__("hline", push_fn, y=float(y), color=color) + + +class RangeWidget(Widget): + def __init__(self, push_fn, *, x0, x1, color="#00e5ff"): + super().__init__("range", push_fn, x0=float(x0), x1=float(x1), color=color) diff --git a/pyproject.toml b/pyproject.toml index c3fb4f9e..a7d1ef03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,12 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["anyplotlib"] + [project] -name = "viewer" +name = "anyplotlib" version = "0.1.0" description = "Add your description here" requires-python = ">=3.12" @@ -20,3 +27,4 @@ docs = [ "pillow>=10.0", ] + diff --git a/tests/test_events.py b/tests/test_events.py index 80605a3e..fd4fcece 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -2,17 +2,15 @@ tests/test_events.py ==================== -Tests for the callback / event system: - - * CallbackRegistry – unit tests for connect / disconnect / fire - * Event – attribute forwarding, repr - * Plot2D callbacks – on_change / on_release / disconnect / single-fire - * Plot1D callbacks – same API, different event names - * PlotMesh callbacks - * Plot3D callbacks - * Figure._on_event – JSON dispatch from model to plot registry - * Filtering – tier, name, widget_id wildcards and exact matches +Tests for the unified object-level callback system. + + * Event dataclass – event_type / source / data / attribute forwarding + * CallbackRegistry – connect / disconnect / fire (event_type dispatch only) + * Plot2D / Plot1D / PlotMesh / Plot3D – on_changed / on_release / on_click + * Widget-level – @wid.on_changed / @wid.on_release / @wid.on_click + * Figure._on_event – JSON routing to widget + plot callbacks * Practical patterns + * Interactive FFT example – unit tests (pure Python, no browser) """ from __future__ import annotations @@ -30,18 +28,13 @@ # Helpers # ───────────────────────────────────────────────────────────────────────────── -def _event(name="widget_change", panel_id="p1", widget_id="w1", - settled=True, **data): - return Event(name=name, panel_id=panel_id, widget_id=widget_id, - settled=settled, data=data) - - -def _change_event(**kw): - return _event(settled=False, **kw) - - -def _release_event(**kw): - return _event(settled=True, **kw) +def _simulate_js_event(fig, plot, event_type: str, *, widget_id=None, **fields): + """Simulate JS sending an interaction event via event_json.""" + payload = {"source": "js", "panel_id": plot._id, "event_type": event_type} + if widget_id is not None: + payload["widget_id"] = widget_id if isinstance(widget_id, str) else widget_id._id + payload.update(fields) + fig._on_event({"new": json.dumps(payload)}) def _plot2d(): @@ -73,245 +66,152 @@ def _plot3d(): # ───────────────────────────────────────────────────────────────────────────── class TestEvent: - def test_basic_fields(self): - ev = Event(name="zoom_change", panel_id="abc", widget_id=None, - settled=True, data={"zoom": 2.5, "center_x": 0.4}) - assert ev.name == "zoom_change" - assert ev.panel_id == "abc" - assert ev.widget_id is None - assert ev.settled is True + def test_event_type_field(self): + ev = Event(event_type="on_release", source=None, data={"x": 1.0}) + assert ev.event_type == "on_release" + + def test_source_field(self): + obj = object() + ev = Event(event_type="on_changed", source=obj, data={}) + assert ev.source is obj def test_data_attribute_forwarding(self): - ev = _event(cx=12.5, cy=8.0) + ev = Event(event_type="on_changed", source=None, data={"cx": 12.5, "cy": 8.0}) assert ev.cx == pytest.approx(12.5) assert ev.cy == pytest.approx(8.0) def test_unknown_attribute_raises(self): - ev = _event(cx=1.0) + ev = Event(event_type="on_changed", source=None, data={"x": 1.0}) with pytest.raises(AttributeError, match="Event has no attribute 'nonexistent'"): _ = ev.nonexistent - def test_repr_contains_name_and_settled(self): - ev = _event(name="rotate_change", settled=False, azimuth=45.0) - r = repr(ev) - assert "rotate_change" in r - assert "settled=False" in r - - def test_repr_shows_widget_id_when_set(self): - ev = _event(widget_id="mywidget") - assert "mywidget" in repr(ev) - - def test_repr_omits_widget_id_when_none(self): - ev = _event(name="zoom_change", widget_id=None) - assert "widget_id" not in repr(ev) - - def test_data_key_forwarding_various_types(self): - ev = _event(x=1.1, text="hello", flag=True, n=7) + def test_data_key_various_types(self): + ev = Event(event_type="on_click", source=None, + data={"x": 1.1, "text": "hello", "flag": True, "n": 7}) assert ev.x == pytest.approx(1.1) assert ev.text == "hello" assert ev.flag is True assert ev.n == 7 - def test_empty_data(self): - ev = Event(name="view_change", panel_id="p", widget_id=None, - settled=True, data={}) + def test_empty_data_raises_on_access(self): + ev = Event(event_type="on_release", source=None, data={}) with pytest.raises(AttributeError): _ = ev.anything + def test_repr_contains_event_type(self): + ev = Event(event_type="on_release", source=None, data={"zoom": 2.5}) + assert "on_release" in repr(ev) + + def test_repr_shows_source_type(self): + from anyplotlib.widgets import CircleWidget + w = CircleWidget(lambda: None, cx=0, cy=0, r=5) + ev = Event(event_type="on_changed", source=w, data={}) + assert "CircleWidget" in repr(ev) + # ───────────────────────────────────────────────────────────────────────────── -# 2. CallbackRegistry – unit tests +# 2. CallbackRegistry # ───────────────────────────────────────────────────────────────────────────── class TestCallbackRegistry: - # ── connect / disconnect ───────────────────────────────────────────────── - - def test_connect_returns_incrementing_cids(self): - reg = CallbackRegistry() - cid1 = reg.connect("change", None, None, lambda e: None) - cid2 = reg.connect("release", None, None, lambda e: None) - assert isinstance(cid1, int) - assert isinstance(cid2, int) - assert cid2 > cid1 - - def test_disconnect_removes_handler(self): - reg = CallbackRegistry() - fired = [] - cid = reg.connect("release", None, None, lambda e: fired.append(e)) - reg.disconnect(cid) - reg.fire(_release_event()) - assert fired == [] - - def test_disconnect_unknown_cid_is_silent(self): - reg = CallbackRegistry() - reg.disconnect(9999) # should not raise - - def test_disconnect_twice_is_silent(self): - reg = CallbackRegistry() - cid = reg.connect("release", None, None, lambda e: None) - reg.disconnect(cid) - reg.disconnect(cid) # should not raise - - def test_bool_false_when_empty(self): - assert not CallbackRegistry() - - def test_bool_true_when_connected(self): + def test_connect_returns_int_cid(self): reg = CallbackRegistry() - reg.connect("change", None, None, lambda e: None) - assert reg + cid = reg.connect("on_changed", lambda e: None) + assert isinstance(cid, int) - def test_bool_false_after_all_disconnected(self): + def test_connect_cids_increment(self): reg = CallbackRegistry() - cid = reg.connect("change", None, None, lambda e: None) - reg.disconnect(cid) - assert not reg + c1 = reg.connect("on_changed", lambda e: None) + c2 = reg.connect("on_release", lambda e: None) + assert c2 > c1 - def test_invalid_tier_raises(self): + def test_invalid_event_type_raises(self): reg = CallbackRegistry() - with pytest.raises(ValueError, match="tier must be"): - reg.connect("invalid", None, None, lambda e: None) - - # ── tier dispatch ──────────────────────────────────────────────────────── + with pytest.raises(ValueError, match="event_type must be one of"): + reg.connect("change", lambda e: None) # old name - def test_change_tier_fires_on_not_settled(self): + def test_fire_on_changed(self): reg = CallbackRegistry() fired = [] - reg.connect("change", None, None, lambda e: fired.append(e)) - reg.fire(_change_event()) + reg.connect("on_changed", lambda e: fired.append(e)) + reg.fire(Event("on_changed", None, {})) assert len(fired) == 1 - assert not fired[0].settled - def test_change_tier_does_not_fire_on_settled(self): + def test_fire_does_not_cross_types(self): reg = CallbackRegistry() fired = [] - reg.connect("change", None, None, lambda e: fired.append(e)) - reg.fire(_release_event()) + reg.connect("on_release", lambda e: fired.append(e)) + reg.fire(Event("on_changed", None, {})) assert fired == [] - def test_release_tier_fires_on_settled(self): + def test_fire_on_release(self): reg = CallbackRegistry() fired = [] - reg.connect("release", None, None, lambda e: fired.append(e)) - reg.fire(_release_event()) + reg.connect("on_release", lambda e: fired.append(e)) + reg.fire(Event("on_release", None, {})) assert len(fired) == 1 - assert fired[0].settled - - def test_release_tier_does_not_fire_on_not_settled(self): - reg = CallbackRegistry() - fired = [] - reg.connect("release", None, None, lambda e: fired.append(e)) - reg.fire(_change_event()) - assert fired == [] - def test_both_tiers_independent(self): - reg = CallbackRegistry() - change_fired, release_fired = [], [] - reg.connect("change", None, None, lambda e: change_fired.append(e)) - reg.connect("release", None, None, lambda e: release_fired.append(e)) - reg.fire(_change_event()) - reg.fire(_release_event()) - assert len(change_fired) == 1 - assert len(release_fired) == 1 - - # ── name filtering ─────────────────────────────────────────────────────── - - def test_name_wildcard_matches_any(self): + def test_fire_on_click(self): reg = CallbackRegistry() fired = [] - reg.connect("release", None, None, lambda e: fired.append(e)) - reg.fire(_release_event(name="zoom_change")) - reg.fire(_release_event(name="view_change")) - assert len(fired) == 2 - - def test_name_exact_match_fires(self): - reg = CallbackRegistry() - fired = [] - reg.connect("release", "zoom_change", None, lambda e: fired.append(e)) - reg.fire(_release_event(name="zoom_change")) + reg.connect("on_click", lambda e: fired.append(e)) + reg.fire(Event("on_click", None, {})) assert len(fired) == 1 - def test_name_exact_match_does_not_fire_other_name(self): - reg = CallbackRegistry() - fired = [] - reg.connect("release", "zoom_change", None, lambda e: fired.append(e)) - reg.fire(_release_event(name="view_change")) - assert fired == [] - - # ── widget_id filtering ────────────────────────────────────────────────── - - def test_widget_id_wildcard_matches_any(self): + def test_three_types_independent(self): reg = CallbackRegistry() - fired = [] - reg.connect("release", None, None, lambda e: fired.append(e)) - reg.fire(_release_event(widget_id="abc")) - reg.fire(_release_event(widget_id="xyz")) - assert len(fired) == 2 - - def test_widget_id_exact_match_fires(self): - reg = CallbackRegistry() - fired = [] - reg.connect("release", None, "abc", lambda e: fired.append(e)) - reg.fire(_release_event(widget_id="abc")) - assert len(fired) == 1 + c_log, r_log, k_log = [], [], [] + reg.connect("on_changed", lambda e: c_log.append(1)) + reg.connect("on_release", lambda e: r_log.append(1)) + reg.connect("on_click", lambda e: k_log.append(1)) + reg.fire(Event("on_changed", None, {})) + reg.fire(Event("on_release", None, {})) + reg.fire(Event("on_click", None, {})) + assert len(c_log) == 1 and len(r_log) == 1 and len(k_log) == 1 - def test_widget_id_exact_match_does_not_fire_other_widget(self): + def test_disconnect_removes_handler(self): reg = CallbackRegistry() fired = [] - reg.connect("release", None, "abc", lambda e: fired.append(e)) - reg.fire(_release_event(widget_id="xyz")) + cid = reg.connect("on_release", lambda e: fired.append(e)) + reg.disconnect(cid) + reg.fire(Event("on_release", None, {})) assert fired == [] - def test_widget_id_exact_does_not_match_none_widget(self): + def test_disconnect_unknown_cid_is_silent(self): reg = CallbackRegistry() - fired = [] - reg.connect("release", None, "abc", lambda e: fired.append(e)) - reg.fire(_release_event(widget_id=None)) - assert fired == [] + reg.disconnect(9999) - def test_widget_id_wildcard_matches_none_widget(self): - """Wildcard (None) fires even for zoom/view events where widget_id=None.""" + def test_disconnect_twice_is_silent(self): reg = CallbackRegistry() - fired = [] - reg.connect("release", None, None, lambda e: fired.append(e)) - reg.fire(_release_event(name="zoom_change", widget_id=None)) - assert len(fired) == 1 + cid = reg.connect("on_release", lambda e: None) + reg.disconnect(cid) + reg.disconnect(cid) - # ── combined filtering ─────────────────────────────────────────────────── + def test_bool_false_when_empty(self): + assert not CallbackRegistry() - def test_all_conditions_must_match(self): + def test_bool_true_when_connected(self): reg = CallbackRegistry() - fired = [] - reg.connect("release", "widget_change", "w1", lambda e: fired.append(e)) - reg.fire(_change_event( name="widget_change", widget_id="w1")) # wrong tier - reg.fire(_release_event( name="zoom_change", widget_id="w1")) # wrong name - reg.fire(_release_event( name="widget_change", widget_id="w2")) # wrong widget - assert fired == [] - reg.fire(_release_event( name="widget_change", widget_id="w1")) # all match - assert len(fired) == 1 + reg.connect("on_changed", lambda e: None) + assert reg - # ── multiple callbacks ─────────────────────────────────────────────────── + def test_bool_false_after_all_disconnected(self): + reg = CallbackRegistry() + cid = reg.connect("on_changed", lambda e: None) + reg.disconnect(cid) + assert not reg def test_multiple_handlers_all_called(self): reg = CallbackRegistry() log = [] - reg.connect("release", None, None, lambda e: log.append("a")) - reg.connect("release", None, None, lambda e: log.append("b")) - reg.connect("release", None, None, lambda e: log.append("c")) - reg.fire(_release_event()) + reg.connect("on_release", lambda e: log.append("a")) + reg.connect("on_release", lambda e: log.append("b")) + reg.connect("on_release", lambda e: log.append("c")) + reg.fire(Event("on_release", None, {})) assert sorted(log) == ["a", "b", "c"] - def test_disconnect_only_removes_one(self): - reg = CallbackRegistry() - log = [] - cid1 = reg.connect("release", None, None, lambda e: log.append("a")) - reg.connect( "release", None, None, lambda e: log.append("b")) - reg.disconnect(cid1) - reg.fire(_release_event()) - assert log == ["b"] - def test_disconnect_inside_callback_is_safe(self): - """Disconnecting from within a callback should not crash.""" reg = CallbackRegistry() fired = [] @@ -319,13 +219,13 @@ def self_disconnect(event): fired.append(event) reg.disconnect(self_disconnect._cid) - self_disconnect._cid = reg.connect("release", None, None, self_disconnect) - reg.fire(_release_event()) - reg.fire(_release_event()) # handler already removed + self_disconnect._cid = reg.connect("on_release", self_disconnect) + reg.fire(Event("on_release", None, {})) + reg.fire(Event("on_release", None, {})) assert len(fired) == 1 def test_no_handlers_fire_is_noop(self): - CallbackRegistry().fire(_release_event()) # should not raise + CallbackRegistry().fire(Event("on_release", None, {})) # ───────────────────────────────────────────────────────────────────────────── @@ -337,50 +237,51 @@ class TestPlot2DCallbacks: def test_has_callbacks_registry(self): assert isinstance(_plot2d().callbacks, CallbackRegistry) - def test_on_change_decorator_fires_on_change(self): + def test_on_changed_decorator(self): v = _plot2d() fired = [] - @v.on_change() + @v.on_changed def cb(event): fired.append(event) - v.callbacks.fire(_change_event()) + v.callbacks.fire(Event("on_changed", None, {})) assert len(fired) == 1 - def test_on_change_does_not_fire_on_release(self): + def test_on_changed_not_fired_for_release(self): v = _plot2d() fired = [] - @v.on_change() + @v.on_changed def cb(event): fired.append(event) - v.callbacks.fire(_release_event()) + v.callbacks.fire(Event("on_release", None, {})) assert fired == [] - def test_on_release_decorator_fires_on_release(self): + def test_on_release_decorator(self): v = _plot2d() fired = [] - @v.on_release() + @v.on_release def cb(event): fired.append(event) - v.callbacks.fire(_release_event()) + v.callbacks.fire(Event("on_release", None, {})) assert len(fired) == 1 - def test_on_release_does_not_fire_on_change(self): + def test_on_click_decorator(self): v = _plot2d() fired = [] - @v.on_release() + @v.on_click def cb(event): fired.append(event) - v.callbacks.fire(_change_event()) - assert fired == [] + v.callbacks.fire(Event("on_click", None, {"x": 5.0, "y": 10.0})) + assert len(fired) == 1 + assert fired[0].x == pytest.approx(5.0) - def test_decorator_assigns_cid(self): + def test_decorator_stamps_cid(self): v = _plot2d() - @v.on_release() + @v.on_release def cb(event): pass assert hasattr(cb, "_cid") and isinstance(cb._cid, int) @@ -389,60 +290,35 @@ def test_disconnect(self): v = _plot2d() fired = [] - @v.on_release() + @v.on_release def cb(event): fired.append(event) v.disconnect(cb._cid) - v.callbacks.fire(_release_event()) - assert fired == [] - - def test_widget_id_filter(self): - v = _plot2d() - wid = v.add_widget("crosshair") - fired = [] - - @v.on_release(wid) - def cb(event): fired.append(event) - - v.callbacks.fire(_release_event(widget_id="other")) + v.callbacks.fire(Event("on_release", None, {})) assert fired == [] - v.callbacks.fire(_release_event(widget_id=wid)) - assert len(fired) == 1 - - def test_wildcard_fires_for_any_widget(self): - v = _plot2d() - fired = [] - - @v.on_release() - def cb(event): fired.append(event) - - v.callbacks.fire(_release_event(widget_id="any1")) - v.callbacks.fire(_release_event(widget_id="any2")) - assert len(fired) == 2 def test_single_fire_pattern(self): v = _plot2d() fired = [] - @v.on_release() + @v.on_release def once(event): fired.append(event) v.disconnect(once._cid) - v.callbacks.fire(_release_event()) - v.callbacks.fire(_release_event()) + v.callbacks.fire(Event("on_release", None, {})) + v.callbacks.fire(Event("on_release", None, {})) assert len(fired) == 1 - def test_zoom_event_no_widget_id(self): + def test_zoom_event_data(self): v = _plot2d() fired = [] - @v.on_release() + @v.on_release def cb(event): fired.append(event) - v.callbacks.fire(_release_event(name="zoom_change", widget_id=None, - center_x=0.6, center_y=0.4, zoom=3.0)) - assert len(fired) == 1 + v.callbacks.fire(Event("on_release", None, + {"center_x": 0.6, "center_y": 0.4, "zoom": 3.0})) assert fired[0].zoom == pytest.approx(3.0) @@ -455,56 +331,28 @@ class TestPlot1DCallbacks: def test_has_callbacks_registry(self): assert isinstance(_plot1d().callbacks, CallbackRegistry) - def test_on_change_and_on_release(self): + def test_on_changed_and_on_release(self): v = _plot1d() change_fired, release_fired = [], [] - @v.on_change() + @v.on_changed def lv(event): change_fired.append(event) - @v.on_release() + @v.on_release def done(event): release_fired.append(event) - v.callbacks.fire(_change_event(name="vline_change")) - v.callbacks.fire(_release_event(name="vline_change")) - assert len(change_fired) == 1 - assert len(release_fired) == 1 - - def test_vline_widget_filter(self): - v = _plot1d() - wid = v.add_vline_widget(x=10.0) - fired = [] - - @v.on_release(wid) - def cb(event): fired.append(event) - - v.callbacks.fire(_release_event(name="vline_change", widget_id="other")) - assert fired == [] - v.callbacks.fire(_release_event(name="vline_change", widget_id=wid)) - assert len(fired) == 1 - - def test_range_widget_filter(self): - v = _plot1d() - wid = v.add_range_widget(x0=5.0, x1=15.0) - fired = [] - - @v.on_release(wid) - def cb(event): fired.append(event) - - v.callbacks.fire(_release_event(name="range_change", widget_id=wid, - x0=5.0, x1=15.0)) - assert len(fired) == 1 + v.callbacks.fire(Event("on_changed", None, {})) + v.callbacks.fire(Event("on_release", None, {})) + assert len(change_fired) == 1 and len(release_fired) == 1 def test_view_change_event_data(self): v = _plot1d() fired = [] - @v.on_release() + @v.on_release def cb(event): fired.append(event) - v.callbacks.fire(_release_event(name="view_change", widget_id=None, - view_x0=0.2, view_x1=0.8)) - assert len(fired) == 1 + v.callbacks.fire(Event("on_release", None, {"view_x0": 0.2, "view_x1": 0.8})) assert fired[0].view_x0 == pytest.approx(0.2) assert fired[0].view_x1 == pytest.approx(0.8) @@ -512,25 +360,13 @@ def test_disconnect(self): v = _plot1d() fired = [] - @v.on_change() + @v.on_changed def cb(event): fired.append(event) v.disconnect(cb._cid) - v.callbacks.fire(_change_event()) + v.callbacks.fire(Event("on_changed", None, {})) assert fired == [] - def test_hline_widget_filter(self): - v = _plot1d() - wid = v.add_hline_widget(y=0.5) - fired = [] - - @v.on_release(wid) - def cb(event): fired.append(event) - - v.callbacks.fire(_release_event(name="hline_change", widget_id=wid, y=0.5)) - assert len(fired) == 1 - assert fired[0].y == pytest.approx(0.5) - # ───────────────────────────────────────────────────────────────────────────── # 5. PlotMesh callback API @@ -541,43 +377,31 @@ class TestPlotMeshCallbacks: def test_has_callbacks_registry(self): assert isinstance(_plotmesh().callbacks, CallbackRegistry) - def test_on_change_and_on_release(self): + def test_on_changed_and_on_release(self): v = _plotmesh() change_fired, release_fired = [], [] - @v.on_change() + @v.on_changed def lv(event): change_fired.append(event) - @v.on_release() + @v.on_release def done(event): release_fired.append(event) - v.callbacks.fire(_change_event()) - v.callbacks.fire(_release_event()) - assert len(change_fired) == 1 - assert len(release_fired) == 1 + v.callbacks.fire(Event("on_changed", None, {})) + v.callbacks.fire(Event("on_release", None, {})) + assert len(change_fired) == 1 and len(release_fired) == 1 def test_disconnect(self): v = _plotmesh() fired = [] - @v.on_release() + @v.on_release def cb(event): fired.append(event) v.disconnect(cb._cid) - v.callbacks.fire(_release_event()) + v.callbacks.fire(Event("on_release", None, {})) assert fired == [] - def test_zoom_event(self): - v = _plotmesh() - fired = [] - - @v.on_release() - def cb(event): fired.append(event) - - v.callbacks.fire(_release_event(name="zoom_change", widget_id=None, - center_x=0.5, center_y=0.5, zoom=2.0)) - assert fired[0].zoom == pytest.approx(2.0) - # ───────────────────────────────────────────────────────────────────────────── # 6. Plot3D callback API @@ -588,321 +412,376 @@ class TestPlot3DCallbacks: def test_has_callbacks_registry(self): assert isinstance(_plot3d().callbacks, CallbackRegistry) - def test_on_change_rotation(self): + def test_on_changed_rotation(self): v = _plot3d() fired = [] - @v.on_change() + @v.on_changed def cb(event): fired.append(event) - v.callbacks.fire(_change_event(name="rotate_change", widget_id=None, - azimuth=45.0, elevation=30.0, zoom=1.0)) - assert len(fired) == 1 + v.callbacks.fire(Event("on_changed", None, + {"azimuth": 45.0, "elevation": 30.0, "zoom": 1.0})) assert fired[0].azimuth == pytest.approx(45.0) - def test_on_release_rotation_data(self): + def test_on_release_data(self): v = _plot3d() fired = [] - @v.on_release() + @v.on_release def cb(event): fired.append(event) - v.callbacks.fire(_release_event(name="rotate_change", widget_id=None, - azimuth=-60.0, elevation=20.0, zoom=2.5)) + v.callbacks.fire(Event("on_release", None, + {"azimuth": -60.0, "elevation": 20.0, "zoom": 2.5})) assert fired[0].zoom == pytest.approx(2.5) - assert fired[0].elevation == pytest.approx(20.0) - def test_on_release_zoom(self): + def test_on_click(self): v = _plot3d() fired = [] - @v.on_release() + @v.on_click def cb(event): fired.append(event) - v.callbacks.fire(_release_event(name="zoom_change", widget_id=None, - zoom=1.5, azimuth=0.0, elevation=30.0)) - assert fired[0].zoom == pytest.approx(1.5) + v.callbacks.fire(Event("on_click", None, {"x": 1.0})) + assert len(fired) == 1 def test_disconnect(self): v = _plot3d() fired = [] - @v.on_release() + @v.on_release def cb(event): fired.append(event) v.disconnect(cb._cid) - v.callbacks.fire(_release_event()) + v.callbacks.fire(Event("on_release", None, {})) assert fired == [] # ───────────────────────────────────────────────────────────────────────────── -# 7. Figure._on_event — JSON dispatch from model traitlet +# 7. Widget-level callbacks (@wid.on_changed / on_release / on_click) # ───────────────────────────────────────────────────────────────────────────── -class TestFigureOnEvent: - - def _dispatch(self, fig, plot, name, widget_id, settled, **data): - """Simulate JS sending event_json.""" - payload = dict(panel_id=plot._id, name=name, - widget_id=widget_id, settled=settled, **data) - fig._on_event({"new": json.dumps(payload)}) +class TestWidgetLevelCallbacks: - def test_dispatch_reaches_plot(self): + def test_on_changed_fires_on_drag_frame(self): fig, ax = apl.subplots(1, 1) v = ax.imshow(np.zeros((32, 32))) + wid = v.add_widget("circle") fired = [] - @v.on_release() + @wid.on_changed def cb(event): fired.append(event) - self._dispatch(fig, v, "widget_change", "w1", True, cx=10.0, cy=20.0) + _simulate_js_event(fig, v, "on_changed", widget_id=wid, cx=10.0, cy=20.0) assert len(fired) == 1 assert fired[0].cx == pytest.approx(10.0) + assert fired[0].source is wid - def test_dispatch_wrong_panel_id_ignored(self): + def test_on_release_fires_on_mouseup(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + wid = v.add_widget("rectangle") + fired = [] + + @wid.on_release + def cb(event): fired.append(event) + + _simulate_js_event(fig, v, "on_release", widget_id=wid, + x=5.0, y=5.0, w=20.0, h=20.0) + assert len(fired) == 1 + assert fired[0].event_type == "on_release" + + def test_on_click_fires_without_state_change(self): + """on_click must fire even when no field values changed.""" + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + wid = v.add_widget("crosshair", cx=16.0, cy=16.0) + fired = [] + + @wid.on_click + def cb(event): fired.append(event) + + _simulate_js_event(fig, v, "on_click", widget_id=wid, cx=16.0, cy=16.0) + assert len(fired) == 1 + + def test_on_changed_not_fire_for_release(self): fig, ax = apl.subplots(1, 1) v = ax.imshow(np.zeros((32, 32))) + wid = v.add_widget("circle") fired = [] - @v.on_release() + @wid.on_changed def cb(event): fired.append(event) - payload = dict(panel_id="nonexistent", name="widget_change", - widget_id=None, settled=True) - fig._on_event({"new": json.dumps(payload)}) + _simulate_js_event(fig, v, "on_release", widget_id=wid, cx=5.0, cy=5.0) assert fired == [] - def test_dispatch_empty_json_ignored(self): + def test_widget_and_plot_both_fire(self): fig, ax = apl.subplots(1, 1) v = ax.imshow(np.zeros((32, 32))) - fig._on_event({"new": "{}"}) # should not raise + wid = v.add_widget("circle") + w_fired, p_fired = [], [] - def test_dispatch_invalid_json_ignored(self): + @wid.on_release + def wc(event): w_fired.append(event) + + @v.on_release + def pc(event): p_fired.append(event) + + _simulate_js_event(fig, v, "on_release", widget_id=wid, cx=5.0, cy=5.0) + assert len(w_fired) == 1 and len(p_fired) == 1 + assert w_fired[0].source is wid + assert p_fired[0].source is wid + + def test_widget_state_updated_after_js_event(self): fig, ax = apl.subplots(1, 1) v = ax.imshow(np.zeros((32, 32))) - fig._on_event({"new": "not-json"}) # should not raise + wid = v.add_widget("rectangle", x=0.0, y=0.0, w=10.0, h=10.0) - def test_dispatch_settled_false_calls_on_change(self): + _simulate_js_event(fig, v, "on_changed", widget_id=wid, + x=50.0, y=60.0, w=20.0, h=20.0) + assert wid.x == pytest.approx(50.0) + assert wid.y == pytest.approx(60.0) + + def test_no_echo_from_python_push(self): fig, ax = apl.subplots(1, 1) v = ax.imshow(np.zeros((32, 32))) + wid = v.add_widget("circle") + fired = [] + + @wid.on_changed + def cb(event): fired.append(event) + + fig._on_event({"new": json.dumps({ + "source": "python", "panel_id": v._id, + "widget_id": wid._id, "cx": 99.0 + })}) + assert fired == [] + + def test_1d_vline_widget_event(self): + fig, ax = apl.subplots(1, 1) + v = ax.plot(np.zeros(64)) + wid = v.add_vline_widget(x=10.0) + fired = [] + + @wid.on_changed + def cb(event): fired.append(event) + + _simulate_js_event(fig, v, "on_changed", widget_id=wid, x=30.0) + assert len(fired) == 1 + assert fired[0].x == pytest.approx(30.0) + + def test_1d_range_widget_event(self): + fig, ax = apl.subplots(1, 1) + v = ax.plot(np.zeros(64)) + wid = v.add_range_widget(x0=5.0, x1=15.0) fired = [] - @v.on_change() + @wid.on_release def cb(event): fired.append(event) - self._dispatch(fig, v, "widget_change", "w1", False, cx=5.0) + _simulate_js_event(fig, v, "on_release", widget_id=wid, x0=8.0, x1=20.0) assert len(fired) == 1 - assert not fired[0].settled + assert fired[0].x0 == pytest.approx(8.0) + + +# ───────────────────────────────────────────────────────────────────────────── +# 8. Figure._on_event routing +# ───────────────────────────────────────────────────────────────────────────── - def test_dispatch_settled_true_calls_on_release(self): +class TestFigureOnEvent: + + def test_dispatch_reaches_plot_callbacks(self): fig, ax = apl.subplots(1, 1) v = ax.imshow(np.zeros((32, 32))) fired = [] - @v.on_release() + @v.on_release def cb(event): fired.append(event) - self._dispatch(fig, v, "zoom_change", None, True, zoom=2.0) + _simulate_js_event(fig, v, "on_release", cx=10.0, cy=20.0) assert len(fired) == 1 - assert fired[0].zoom == pytest.approx(2.0) + assert fired[0].cx == pytest.approx(10.0) - def test_dispatch_to_1d_plot(self): + def test_dispatch_with_widget_id_updates_widget(self): fig, ax = apl.subplots(1, 1) - v = ax.plot(np.zeros(64)) + v = ax.imshow(np.zeros((32, 32))) + wid = v.add_widget("circle", cx=0.0, cy=0.0) + + _simulate_js_event(fig, v, "on_changed", widget_id=wid, cx=5.0) + assert wid.cx == pytest.approx(5.0) + + def test_dispatch_wrong_panel_id_ignored(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + fired = [] + + @v.on_release + def cb(event): fired.append(event) + + fig._on_event({"new": json.dumps({"source": "js", "panel_id": "nonexistent", + "event_type": "on_release"})}) + assert fired == [] + + def test_dispatch_empty_json_ignored(self): + fig, ax = apl.subplots(1, 1) + fig._on_event({"new": "{}"}) + + def test_dispatch_invalid_json_ignored(self): + fig, ax = apl.subplots(1, 1) + fig._on_event({"new": "not-json"}) + + def test_source_python_not_dispatched(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) fired = [] - @v.on_release() + @v.on_changed def cb(event): fired.append(event) - self._dispatch(fig, v, "vline_change", "vl1", True, x=42.0) - assert fired[0].x == pytest.approx(42.0) + fig._on_event({"new": json.dumps( + {"source": "python", "panel_id": v._id, + "event_type": "on_changed", "cx": 5.0})}) + assert fired == [] - def test_dispatch_multi_panel_correct_routing(self): + def test_multi_panel_correct_routing(self): fig, (ax1, ax2) = apl.subplots(1, 2) v1 = ax1.imshow(np.zeros((16, 16))) v2 = ax2.plot(np.zeros(32)) fired1, fired2 = [], [] - @v1.on_release() + @v1.on_release def cb1(event): fired1.append(event) - @v2.on_release() + @v2.on_release def cb2(event): fired2.append(event) - self._dispatch(fig, v1, "zoom_change", None, True, zoom=1.5) + _simulate_js_event(fig, v1, "on_release", zoom=1.5) assert len(fired1) == 1 and fired2 == [] - self._dispatch(fig, v2, "view_change", None, True, view_x0=0.1, view_x1=0.9) - assert len(fired2) == 1 and len(fired1) == 1 # v1 still only 1 + _simulate_js_event(fig, v2, "on_release", view_x0=0.1, view_x1=0.9) + assert len(fired2) == 1 and len(fired1) == 1 - def test_extra_keys_stripped_from_event_data(self): - """panel_id / name / widget_id / settled must not appear in event.data.""" + def test_protocol_keys_stripped_from_event_data(self): fig, ax = apl.subplots(1, 1) v = ax.imshow(np.zeros((16, 16))) fired = [] - @v.on_release() + @v.on_release def cb(event): fired.append(event) - self._dispatch(fig, v, "zoom_change", None, True, zoom=2.0) + _simulate_js_event(fig, v, "on_release", zoom=2.0) ev = fired[0] - assert "panel_id" not in ev.data - assert "name" not in ev.data - assert "settled" not in ev.data + assert "panel_id" not in ev.data + assert "event_type" not in ev.data + assert "source" not in ev.data assert ev.zoom == pytest.approx(2.0) - -# ───────────────────────────────────────────────────────────────────────────── -# 8. Filtering edge cases -# ───────────────────────────────────────────────────────────────────────────── - -class TestFilteringEdgeCases: - - def test_repeated_change_then_single_release(self): - reg = CallbackRegistry() - change_log, release_log = [], [] - reg.connect("change", None, None, lambda e: change_log.append(1)) - reg.connect("release", None, None, lambda e: release_log.append(1)) - - for _ in range(5): - reg.fire(_change_event()) - for _ in range(3): - reg.fire(_release_event()) - - assert len(change_log) == 5 - assert len(release_log) == 3 - - def test_both_wildcards_matches_everything(self): - reg = CallbackRegistry() + def test_default_event_type_is_on_changed(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((16, 16))) fired = [] - reg.connect("release", None, None, lambda e: fired.append(e)) - for ev in [ - _release_event(name="zoom_change", widget_id=None), - _release_event(name="widget_change", widget_id="w1"), - _release_event(name="rotate_change", widget_id=None), - ]: - reg.fire(ev) - assert len(fired) == 3 - def test_exact_name_wildcard_widget(self): - reg = CallbackRegistry() - fired = [] - reg.connect("release", "zoom_change", None, lambda e: fired.append(e)) - reg.fire(_release_event(name="zoom_change", widget_id="w1")) - reg.fire(_release_event(name="zoom_change", widget_id="w2")) - reg.fire(_release_event(name="other", widget_id="w1")) - assert len(fired) == 2 + @v.on_changed + def cb(event): fired.append(event) - def test_wildcard_name_exact_widget(self): - reg = CallbackRegistry() - fired = [] - reg.connect("release", None, "w1", lambda e: fired.append(e)) - reg.fire(_release_event(name="zoom_change", widget_id="w1")) - reg.fire(_release_event(name="widget_change", widget_id="w1")) - reg.fire(_release_event(name="zoom_change", widget_id="w2")) - assert len(fired) == 2 + fig._on_event({"new": json.dumps({"source": "js", + "panel_id": v._id, "cx": 1.0})}) + assert len(fired) == 1 # ───────────────────────────────────────────────────────────────────────────── -# 9. Practical usage patterns +# 9. Practical patterns # ───────────────────────────────────────────────────────────────────────────── class TestPracticalPatterns: def test_readout_update_on_drag(self): - v = _plot2d() + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((64, 64))) wid = v.add_widget("crosshair") readout = {"value": ""} - @v.on_change(wid) + @wid.on_changed def live(event): readout["value"] = f"({event.cx:.1f}, {event.cy:.1f})" - v.callbacks.fire(_change_event(name="crosshair_change", - widget_id=wid, cx=12.5, cy=7.3)) + _simulate_js_event(fig, v, "on_changed", widget_id=wid, cx=12.5, cy=7.3) assert readout["value"] == "(12.5, 7.3)" def test_expensive_work_gated_on_release(self): - v = _plot1d() + fig, ax = apl.subplots(1, 1) + v = ax.plot(np.zeros(64)) wid = v.add_vline_widget(x=284.0) calls = {"cheap": 0, "expensive": 0} - @v.on_change(wid) + @wid.on_changed def live(event): calls["cheap"] += 1 - @v.on_release(wid) + @wid.on_release def done(event): calls["expensive"] += 1 - for _ in range(10): - v.callbacks.fire(_change_event(name="vline_change", widget_id=wid, x=285.0)) - v.callbacks.fire(_release_event(name="vline_change", widget_id=wid, x=285.0)) + for i in range(10): + _simulate_js_event(fig, v, "on_changed", widget_id=wid, x=285.0 + i) + _simulate_js_event(fig, v, "on_release", widget_id=wid, x=285.0) assert calls["cheap"] == 10 assert calls["expensive"] == 1 def test_multiple_widgets_separate_callbacks(self): - v = _plot2d() + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) w1 = v.add_widget("circle") w2 = v.add_widget("crosshair") - log = {w1: [], w2: []} + log = {w1._id: [], w2._id: []} + + @w1.on_release + def cb1(event): log[w1._id].append(event) - @v.on_release(w1) - def cb1(event): log[w1].append(event) + @w2.on_release + def cb2(event): log[w2._id].append(event) - @v.on_release(w2) - def cb2(event): log[w2].append(event) + _simulate_js_event(fig, v, "on_release", widget_id=w1, cx=5.0, cy=5.0) + assert len(log[w1._id]) == 1 and len(log[w2._id]) == 0 - v.callbacks.fire(_release_event(widget_id=w1)) - assert len(log[w1]) == 1 and len(log[w2]) == 0 + _simulate_js_event(fig, v, "on_release", widget_id=w2, cx=8.0, cy=8.0) + assert len(log[w1._id]) == 1 and len(log[w2._id]) == 1 - v.callbacks.fire(_release_event(widget_id=w2)) - assert len(log[w1]) == 1 and len(log[w2]) == 1 + def test_widget_attribute_assignment(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + wid = v.add_widget("rectangle", x=0.0, y=0.0, w=10.0, h=10.0) + wid.x = 40.0 + assert wid.x == pytest.approx(40.0) + assert v.to_state_dict()["overlay_widgets"][0]["x"] == pytest.approx(40.0) + + def test_widget_x_readback_after_js_event(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + wid = v.add_widget("rectangle", x=0.0, y=0.0, w=10.0, h=10.0) + _simulate_js_event(fig, v, "on_changed", widget_id=wid, + x=77.0, y=88.0, w=33.0, h=44.0) + assert wid.x == pytest.approx(77.0) + assert wid.y == pytest.approx(88.0) def test_3d_rotate_many_frames_one_release(self): - v = _plot3d() + x = y = np.linspace(-1, 1, 5) + X, Y = np.meshgrid(x, y) + fig, ax = apl.subplots(1, 1) + v = ax.plot_surface(X, Y, np.zeros((5, 5))) frames, final = [], {} - @v.on_change() + @v.on_changed def live(event): frames.append(event.azimuth) - @v.on_release() + @v.on_release def done(event): final["az"] = event.azimuth for az in range(0, 50, 5): - v.callbacks.fire(_change_event(name="rotate_change", widget_id=None, - azimuth=float(az), elevation=30.0, zoom=1.0)) - v.callbacks.fire(_release_event(name="rotate_change", widget_id=None, - azimuth=45.0, elevation=30.0, zoom=1.0)) + _simulate_js_event(fig, v, "on_changed", + azimuth=float(az), elevation=30.0, zoom=1.0) + _simulate_js_event(fig, v, "on_release", + azimuth=45.0, elevation=30.0, zoom=1.0) assert len(frames) == 10 assert final["az"] == pytest.approx(45.0) - def test_cid_returned_from_direct_connect(self): - reg = CallbackRegistry() - fired = [] - cid = reg.connect("release", None, None, lambda e: fired.append(e)) - reg.fire(_release_event()) - assert len(fired) == 1 - reg.disconnect(cid) - reg.fire(_release_event()) - assert len(fired) == 1 # handler removed - - def test_on_change_and_on_release_same_widget(self): - """Same widget can have both tiers active simultaneously.""" - v = _plot2d() - wid = v.add_widget("circle") - fast, slow = [], [] - - @v.on_change(wid) - def live(event): fast.append(event) - - @v.on_release(wid) - def done(event): slow.append(event) - - for _ in range(5): - v.callbacks.fire(_change_event(widget_id=wid)) - v.callbacks.fire(_release_event(widget_id=wid)) - - assert len(fast) == 5 - assert len(slow) == 1 - diff --git a/tests/test_widgets.py b/tests/test_widgets.py new file mode 100644 index 00000000..e34946ba --- /dev/null +++ b/tests/test_widgets.py @@ -0,0 +1,648 @@ +""" +tests/test_widgets.py +===================== + +Tests for the Widget class system and the event_json dispatch pipeline. + +Covers: + * Widget creation, attribute access, set(), to_dict(), __setattr__ + * on_changed / on_release / on_click decorator + disconnect + * _update_from_js — always fires for on_release/on_click + * Plot2D / Plot1D widget integration + * Figure event_json dispatch (JS→Python path via _simulate_js_event) + * widget.x = 40 attribute assignment + * widget.x read-back after JS event + * End-to-end FFT example with simulated JS drag +""" + +from __future__ import annotations + +import json +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.callbacks import Event +from anyplotlib.widgets import ( + Widget, RectangleWidget, CircleWidget, AnnularWidget, + CrosshairWidget, PolygonWidget, LabelWidget, + VLineWidget, HLineWidget, RangeWidget, +) + + +# ───────────────────────────────────────────────────────────────────────────── +# Helper: simulate JS sending an interaction event +# ───────────────────────────────────────────────────────────────────────────── + +def _simulate_js_event(fig, plot, event_type: str, *, widget_id=None, **fields): + """Simulate what JS does when the user interacts with a widget. + + JS writes to event_json: + { source:"js", panel_id, event_type, widget_id?, ...fields } + """ + payload = {"source": "js", "panel_id": plot._id, "event_type": event_type} + if widget_id is not None: + payload["widget_id"] = widget_id if isinstance(widget_id, str) else widget_id._id + payload.update(fields) + fig._on_event({"new": json.dumps(payload)}) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 1. Widget class unit tests +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestWidgetBase: + def test_rectangle_attributes(self): + w = RectangleWidget(lambda: None, x=10, y=20, w=30, h=40) + assert w.x == 10.0 and w.y == 20.0 and w.w == 30.0 and w.h == 40.0 + assert w._type == "rectangle" + + def test_circle_attributes(self): + w = CircleWidget(lambda: None, cx=5, cy=6, r=7) + assert w.cx == 5.0 and w.r == 7.0 + + def test_annular_validates(self): + with pytest.raises(ValueError, match="r_inner"): + AnnularWidget(lambda: None, cx=0, cy=0, r_outer=5, r_inner=10) + + def test_polygon_validates(self): + with pytest.raises(ValueError, match="3 vertices"): + PolygonWidget(lambda: None, vertices=[[0, 0], [1, 1]]) + + def test_set_updates_and_pushes(self): + pushed = [] + w = RectangleWidget(lambda: pushed.append(1), x=0, y=0, w=10, h=10) + w.set(x=50) + assert w.x == 50.0 + assert len(pushed) == 1 + + def test_set_no_push(self): + pushed = [] + w = RectangleWidget(lambda: pushed.append(1), x=0, y=0, w=10, h=10) + w.set(_push=False, x=50) + assert w.x == 50.0 + assert len(pushed) == 0 + + def test_to_dict(self): + w = CircleWidget(lambda: None, cx=1, cy=2, r=3) + d = w.to_dict() + assert d["cx"] == 1.0 and d["type"] == "circle" and "id" in d + + def test_get(self): + w = RectangleWidget(lambda: None, x=10, y=20, w=30, h=40) + assert w.get("x") == 10.0 + assert w.get("missing", 99) == 99 + + def test_unknown_attr_raises(self): + w = RectangleWidget(lambda: None, x=0, y=0, w=1, h=1) + with pytest.raises(AttributeError, match="no_such"): + _ = w.no_such + + def test_repr(self): + w = RectangleWidget(lambda: None, x=1, y=2, w=3, h=4) + assert "RectangleWidget" in repr(w) and "1" in repr(w) + + def test_id_property(self): + w = RectangleWidget(lambda: None, x=0, y=0, w=1, h=1) + assert isinstance(w.id, str) and len(w.id) == 8 + + def test_setattr_routes_through_set(self): + """Public attribute assignment should call set() and push.""" + pushed = [] + w = RectangleWidget(lambda: pushed.append(1), x=0, y=0, w=10, h=10) + w.x = 40.0 + assert w.x == pytest.approx(40.0) + assert len(pushed) == 1 # set() triggered the push + + def test_setattr_private_bypasses_set(self): + """Private attributes must not go through set().""" + pushed = [] + w = RectangleWidget(lambda: pushed.append(1), x=0, y=0, w=10, h=10) + pushed.clear() + w._custom = "private" + assert len(pushed) == 0 + + def test_setattr_callbacks_bypasses_set(self): + """'callbacks' attribute assignment must never go through set().""" + from anyplotlib.callbacks import CallbackRegistry + pushed = [] + w = RectangleWidget(lambda: pushed.append(1), x=0, y=0, w=10, h=10) + pushed.clear() + w.callbacks = CallbackRegistry() # must not crash or push + assert len(pushed) == 0 + + +class TestWidgetCallbacks: + def test_on_changed_fires(self): + w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10) + results = [] + w.on_changed(lambda event: results.append(event.x)) + w.set(x=42) + assert results == [42.0] + + def test_on_changed_event_source_is_widget(self): + w = CircleWidget(lambda: None, cx=0, cy=0, r=5) + received = [] + w.on_changed(lambda event: received.append(event.source)) + w.set(cx=10) + assert received[0] is w + + def test_multiple_callbacks(self): + w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10) + a, b = [], [] + w.on_changed(lambda event: a.append(1)) + w.on_changed(lambda event: b.append(1)) + w.set(x=1) + assert len(a) == 1 and len(b) == 1 + + def test_disconnect_by_fn(self): + """Disconnecting using the function object (which has ._cid) should work.""" + w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10) + results = [] + fn = w.on_changed(lambda event: results.append(1)) + w.set(x=1); assert len(results) == 1 + w.disconnect(fn) # fn._cid is used + w.set(x=2); assert len(results) == 1 + + def test_disconnect_by_cid(self): + """Disconnecting using the integer CID should also work.""" + w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10) + results = [] + fn = w.on_changed(lambda event: results.append(1)) + w.disconnect(fn._cid) + w.set(x=2) + assert results == [] + + def test_disconnect_nonexistent_silent(self): + w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10) + w.disconnect(9999) + + def test_on_release_decorator(self): + w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10) + results = [] + w.on_release(lambda event: results.append(event.event_type)) + w.callbacks.fire(Event("on_release", w, {"x": 5.0})) + assert results == ["on_release"] + + def test_on_click_decorator(self): + w = CircleWidget(lambda: None, cx=0, cy=0, r=5) + results = [] + w.on_click(lambda event: results.append(event.event_type)) + w.callbacks.fire(Event("on_click", w, {})) + assert results == ["on_click"] + + +class TestWidgetUpdateFromJs: + def test_update_returns_true_on_change(self): + w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10) + assert w._update_from_js({"x": 5.0}) + + def test_update_returns_false_on_no_change(self): + w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10, color="#00e5ff") + assert not w._update_from_js( + {"id": w.id, "type": "rectangle", + "x": 0.0, "y": 0.0, "w": 10.0, "h": 10.0, "color": "#00e5ff"}) + + def test_update_fires_on_changed_when_changed(self): + w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10) + results = [] + w.on_changed(lambda event: results.append(event.x)) + w._update_from_js({"x": 99.0}) + assert results == [99.0] + + def test_update_does_not_fire_on_changed_if_unchanged(self): + w = RectangleWidget(lambda: None, x=5, y=5, w=10, h=10, color="#abc") + results = [] + w.on_changed(lambda event: results.append(1)) + w._update_from_js({"x": 5.0, "y": 5.0, "w": 10.0, "h": 10.0, "color": "#abc"}) + assert results == [] + + def test_update_always_fires_on_release(self): + """on_release fires even when nothing changed (drag ended in place).""" + w = RectangleWidget(lambda: None, x=5, y=5, w=10, h=10) + results = [] + w.on_release(lambda event: results.append(1)) + w._update_from_js({"x": 5.0, "y": 5.0, "w": 10.0, "h": 10.0}, + event_type="on_release") + assert results == [1] + + def test_update_always_fires_on_click(self): + """on_click fires even when nothing changed.""" + w = CrosshairWidget(lambda: None, cx=16.0, cy=16.0) + results = [] + w.on_click(lambda event: results.append(1)) + w._update_from_js({"cx": 16.0, "cy": 16.0}, event_type="on_click") + assert results == [1] + + def test_id_and_type_ignored(self): + w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10) + old_id = w.id + w._update_from_js({"id": "FAKE", "type": "FAKE", "x": 1.0}) + assert w.id == old_id and w._type == "rectangle" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 2. Plot2D widget integration +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestPlot2DWidgets: + def test_add_widget_returns_widget_object(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + w = v.add_widget("rectangle", x=10, y=10, w=20, h=20) + assert isinstance(w, RectangleWidget) and w.x == 10.0 + + def test_add_circle(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + w = v.add_widget("circle", cx=16, cy=16, r=5) + assert isinstance(w, CircleWidget) and w.cx == 16.0 + + def test_add_crosshair(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + assert isinstance(v.add_widget("crosshair"), CrosshairWidget) + + def test_add_annular(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + assert isinstance(v.add_widget("annular", r_outer=10, r_inner=5), AnnularWidget) + + def test_add_polygon(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + w = v.add_widget("polygon", vertices=[[0,0],[10,0],[10,10],[0,10]]) + assert isinstance(w, PolygonWidget) + + def test_add_label(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + w = v.add_widget("label", x=5, y=5, text="hello") + assert isinstance(w, LabelWidget) and w.text == "hello" + + def test_invalid_kind_raises(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + with pytest.raises(ValueError): + v.add_widget("nonexistent") + + def test_get_widget_by_id(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + w = v.add_widget("rectangle", x=1, y=2, w=3, h=4) + assert v.get_widget(w.id) is w + + def test_get_widget_by_object(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + w = v.add_widget("circle") + assert v.get_widget(w) is w + + def test_remove_widget(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + w = v.add_widget("rectangle") + v.remove_widget(w) + assert len(v.list_widgets()) == 0 + + def test_list_widgets(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + v.add_widget("circle"); v.add_widget("rectangle") + assert len(v.list_widgets()) == 2 + + def test_clear_widgets(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + v.add_widget("circle"); v.add_widget("rectangle") + v.clear_widgets() + assert v.list_widgets() == [] + + def test_widget_set_updates_state_dict(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + w = v.add_widget("rectangle", x=0, y=0, w=10, h=10) + w.set(x=99) + found = [wd for wd in v.to_state_dict()["overlay_widgets"] if wd["id"] == w.id] + assert found[0]["x"] == 99.0 + + def test_to_state_dict_includes_widgets(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + v.add_widget("circle", cx=1, cy=2, r=3) + d = v.to_state_dict() + assert len(d["overlay_widgets"]) == 1 + assert d["overlay_widgets"][0]["cx"] == 1.0 + + def test_setattr_moves_widget(self): + """widget.x = 40 triggers push and updates _data.""" + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + w = v.add_widget("rectangle", x=0.0, y=0.0, w=10.0, h=10.0) + w.x = 40.0 + assert w.x == pytest.approx(40.0) + d = v.to_state_dict()["overlay_widgets"] + assert d[0]["x"] == pytest.approx(40.0) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 3. Plot1D widget integration +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestPlot1DWidgets: + def test_add_vline_returns_widget(self): + fig, ax = apl.subplots(1, 1) + v = ax.plot(np.zeros(64)) + w = v.add_vline_widget(x=10.0) + assert isinstance(w, VLineWidget) and w.x == 10.0 + + def test_add_hline_returns_widget(self): + fig, ax = apl.subplots(1, 1) + v = ax.plot(np.zeros(64)) + w = v.add_hline_widget(y=0.5) + assert isinstance(w, HLineWidget) and w.y == 0.5 + + def test_add_range_returns_widget(self): + fig, ax = apl.subplots(1, 1) + v = ax.plot(np.zeros(64)) + w = v.add_range_widget(x0=10, x1=20) + assert isinstance(w, RangeWidget) and w.x0 == 10.0 + + def test_remove_widget(self): + fig, ax = apl.subplots(1, 1) + v = ax.plot(np.zeros(64)) + w = v.add_vline_widget(x=5) + v.remove_widget(w) + assert len(v.list_widgets()) == 0 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 4. Figure event_json dispatch (the JS→Python path) +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestEventJsonDispatch: + """Simulate what JS does: write event_json with source:"js". + Verify that Widget callbacks fire correctly.""" + + def test_rectangle_drag_fires_on_changed(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + w = v.add_widget("rectangle", x=10, y=10, w=20, h=20) + results = [] + w.on_changed(lambda event: results.append((event.x, event.y))) + + _simulate_js_event(fig, v, "on_changed", widget_id=w, x=50.0, y=60.0) + + assert len(results) == 1 + assert results[0] == (50.0, 60.0) + assert w.x == 50.0 and w.y == 60.0 + + def test_no_change_no_on_changed_callback(self): + """on_changed must NOT fire when nothing actually changed.""" + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + w = v.add_widget("rectangle", x=10, y=10, w=20, h=20) + results = [] + w.on_changed(lambda event: results.append(1)) + + _simulate_js_event(fig, v, "on_changed", widget_id=w, + x=10.0, y=10.0, w=20.0, h=20.0) + assert results == [] + + def test_on_release_always_fires(self): + """on_release fires even when position didn't change.""" + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + w = v.add_widget("rectangle", x=10, y=10, w=20, h=20) + results = [] + w.on_release(lambda event: results.append(1)) + + _simulate_js_event(fig, v, "on_release", widget_id=w, + x=10.0, y=10.0, w=20.0, h=20.0) + assert len(results) == 1 + + def test_on_click_fires(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + w = v.add_widget("crosshair", cx=16.0, cy=16.0) + results = [] + w.on_click(lambda event: results.append(event.cx)) + + _simulate_js_event(fig, v, "on_click", widget_id=w, cx=16.0, cy=16.0) + assert len(results) == 1 + assert results[0] == pytest.approx(16.0) + + def test_circle_drag(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + w = v.add_widget("circle", cx=16, cy=16, r=5) + results = [] + w.on_changed(lambda event: results.append(event.cx)) + + _simulate_js_event(fig, v, "on_changed", widget_id=w, cx=25.0) + assert results == [25.0] + + def test_python_set_does_not_echo(self): + """Python widget.set() triggers on_changed once (from set itself), + but the subsequent event_json push must NOT re-fire callbacks.""" + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + w = v.add_widget("rectangle", x=10, y=10, w=20, h=20) + results = [] + w.on_changed(lambda event: results.append("cb")) + + w.set(x=99) + assert results == ["cb"] # one fire from set() + results.clear() + + # The push to event_json has source:"python" — must be ignored + assert results == [] + + def test_multi_widget_only_changed_fires(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + w1 = v.add_widget("circle", cx=10, cy=10, r=5) + w2 = v.add_widget("rectangle", x=0, y=0, w=10, h=10) + r1, r2 = [], [] + w1.on_changed(lambda e: r1.append(1)) + w2.on_changed(lambda e: r2.append(1)) + + _simulate_js_event(fig, v, "on_changed", widget_id=w2, x=50.0, y=50.0) + assert r1 == [] + assert len(r2) == 1 + + def test_multi_panel_routing(self): + fig, (ax1, ax2) = apl.subplots(1, 2) + v1 = ax1.imshow(np.zeros((16, 16))) + v2 = ax2.imshow(np.zeros((16, 16))) + w1 = v1.add_widget("circle", cx=8, cy=8, r=3) + w2 = v2.add_widget("circle", cx=8, cy=8, r=3) + r1, r2 = [], [] + w1.on_changed(lambda e: r1.append(1)) + w2.on_changed(lambda e: r2.append(1)) + + _simulate_js_event(fig, v1, "on_changed", widget_id=w1, cx=12.0) + assert len(r1) == 1 and r2 == [] + + def test_1d_vline_drag(self): + fig, ax = apl.subplots(1, 1) + v = ax.plot(np.zeros(64)) + w = v.add_vline_widget(x=10.0) + results = [] + w.on_changed(lambda event: results.append(event.x)) + + _simulate_js_event(fig, v, "on_changed", widget_id=w, x=30.0) + assert results == [30.0] + + def test_1d_range_drag(self): + fig, ax = apl.subplots(1, 1) + v = ax.plot(np.zeros(64)) + w = v.add_range_widget(x0=10, x1=20) + results = [] + w.on_changed(lambda event: results.append((event.x0, event.x1))) + + _simulate_js_event(fig, v, "on_changed", widget_id=w, x0=15.0, x1=25.0) + assert results == [(15.0, 25.0)] + + def test_disconnect_prevents_callback(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + w = v.add_widget("rectangle", x=0, y=0, w=10, h=10) + results = [] + fn = w.on_changed(lambda event: results.append(1)) + w.disconnect(fn) + + _simulate_js_event(fig, v, "on_changed", widget_id=w, x=50.0) + assert results == [] + + def test_widget_state_synced_after_js_event(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + w = v.add_widget("rectangle", x=0, y=0, w=10, h=10) + + _simulate_js_event(fig, v, "on_changed", widget_id=w, + x=77.0, y=88.0, w=33.0, h=44.0) + assert w.x == 77.0 and w.y == 88.0 and w.w == 33.0 and w.h == 44.0 + + def test_widget_x_readback_after_js_event(self): + """After a JS event, reading widget.x returns the updated value.""" + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + w = v.add_widget("circle", cx=0.0, cy=0.0, r=5.0) + + _simulate_js_event(fig, v, "on_release", widget_id=w, cx=20.0, cy=30.0) + assert w.cx == pytest.approx(20.0) + assert w.cy == pytest.approx(30.0) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 5. End-to-end FFT example +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestInteractiveFft: + """End-to-end: two panels, rectangle widget, simulate JS events, + verify callback fires and updates the FFT panel.""" + + @staticmethod + def _compute_fft(img, x0, y0, w, h, scale=0.1): + ih, iw = img.shape + x0i = max(0, int(round(x0))); y0i = max(0, int(round(y0))) + x1i = min(iw, x0i + max(1, int(round(w)))) + y1i = min(ih, y0i + max(1, int(round(h)))) + crop = img[y0i:y1i, x0i:x1i].copy() + ch, cw = crop.shape + if ch < 2 or cw < 2: + f = np.fft.fftfreq(4, d=scale) + return np.zeros((4, 4)), f, f + crop *= np.hanning(ch)[:, None] * np.hanning(cw)[None, :] + fft2 = np.fft.fftshift(np.fft.fft2(crop)) + log_mag = np.log1p(np.abs(fft2)) + return (log_mag, + np.fft.fftshift(np.fft.fftfreq(cw, d=scale)), + np.fft.fftshift(np.fft.fftfreq(ch, d=scale))) + + def test_drag_rectangle_updates_fft(self): + N = 64 + rng = np.random.default_rng(0) + img = rng.standard_normal((N, N)).cumsum(0).cumsum(1) + img = (img - img.min()) / (img.max() - img.min()) + scale = 0.1 + xy = np.arange(N) * scale + + fig, (ax_real, ax_fft) = apl.subplots(1, 2, figsize=(600, 300)) + v_real = ax_real.imshow(img, axes=[xy, xy], units="Å") + + ROI_W, ROI_H = 32, 32 + roi_x0, roi_y0 = 16, 16 + rect = v_real.add_widget("rectangle", + x=float(roi_x0), y=float(roi_y0), + w=float(ROI_W), h=float(ROI_H)) + + fft0, fx0, fy0 = self._compute_fft(img, roi_x0, roi_y0, ROI_W, ROI_H) + v_fft = ax_fft.imshow(fft0, axes=[fx0, fy0], units="1/Å") + + initial_b64 = v_fft._state["image_b64"] + updates = [] + + @rect.on_changed + def on_rect_changed(event): + log_mag, freq_x, freq_y = self._compute_fft( + img, event.x, event.y, event.w, event.h) + v_fft.update(log_mag, x_axis=freq_x, y_axis=freq_y, units="1/Å") + updates.append({"x": event.x, "y": event.y, + "w": event.w, "h": event.h}) + + _simulate_js_event(fig, v_real, "on_changed", widget_id=rect, + x=0.0, y=0.0, w=48.0, h=48.0) + + assert len(updates) == 1 + assert updates[0]["x"] == 0.0 and updates[0]["w"] == 48.0 + assert v_fft._state["image_b64"] != initial_b64 + + def test_multiple_drags_fire_multiple_callbacks(self): + N = 32 + img = np.random.default_rng(1).random((N, N)) + fig, ax = apl.subplots(1, 1) + v = ax.imshow(img) + rect = v.add_widget("rectangle", x=0, y=0, w=16, h=16) + count = [0] + rect.on_changed(lambda e: count.__setitem__(0, count[0] + 1)) + + for i in range(5): + _simulate_js_event(fig, v, "on_changed", widget_id=rect, x=float(i)) + + # Only fires when something actually changed — first fire is from x=0 + # (which equals the initial value, no change), then 1,2,3,4 = 4 fires + assert count[0] == 4 + + def test_drag_then_disconnect(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((32, 32))) + rect = v.add_widget("rectangle", x=0, y=0, w=10, h=10) + results = [] + fn = rect.on_changed(lambda e: results.append(1)) + + _simulate_js_event(fig, v, "on_changed", widget_id=rect, x=5.0) + assert len(results) == 1 + + rect.disconnect(fn) + _simulate_js_event(fig, v, "on_changed", widget_id=rect, x=10.0) + assert len(results) == 1 + + def test_on_release_after_drags(self): + N = 32 + img = np.random.default_rng(2).random((N, N)) + fig, ax = apl.subplots(1, 1) + v = ax.imshow(img) + rect = v.add_widget("rectangle", x=0, y=0, w=16, h=16) + drag_count = [0]; release_count = [0] + + rect.on_changed(lambda e: drag_count.__setitem__(0, drag_count[0] + 1)) + rect.on_release(lambda e: release_count.__setitem__(0, release_count[0] + 1)) + + for i in range(1, 6): + _simulate_js_event(fig, v, "on_changed", widget_id=rect, x=float(i)) + _simulate_js_event(fig, v, "on_release", widget_id=rect, x=5.0) + + assert drag_count[0] == 5 + assert release_count[0] == 1 From 39402faa15d2c0a1c07720fa7552a9f188ab89c3 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 16 Mar 2026 11:30:04 -0500 Subject: [PATCH 008/198] New Documentation: Add AGENTS.md for architecture overview and developer workflows in Anyplotlib --- AGENTS.md | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..450882ba --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,88 @@ +# AGENTS.md — anyplotlib Codebase Guide + +## Architecture Overview + +`anyplotlib` is a Jupyter-compatible interactive plotting library. The key architectural split: + +- **`Figure`** (`figure.py`) — the only `anywidget.AnyWidget` subclass. Owns all traitlets and is the Python↔JS bridge. +- **Plot objects** (`figure_plots.py`) — `Plot2D`, `Plot1D`, `PlotMesh`, `Plot3D` are **plain Python classes**, not widgets. They hold state in `_state` dicts and push to the Figure. +- **`figure_esm.js`** — pure-JS canvas renderer; all rendering logic lives here. +- **`markers.py`** — static visual overlays (circles, arrows, lines, etc.) with a two-level dict registry: `plot.markers[type][name]`. +- **`widgets.py`** — interactive draggable overlays (`RectangleWidget`, `CrosshairWidget`, etc.) that receive JS position updates. +- **`callbacks.py`** — two-tier event system (`on_change` for live drag frames, `on_release` for settled state). + +## Python ↔ JS Data Flow + +**Python → JS (push):** Every plot state mutation calls `plot._push()` → `figure._push(panel_id)` → serialises `_state` to JSON → writes to the dynamic traitlet `panel_{id}_json` (tagged `sync=True`) → JS observes and re-renders. + +**JS → Python (events/widgets):** JS writes back to `panel_{id}_json` after a drag → Python observer calls `Widget._update_from_js()` and fires callbacks. Interaction events (zoom, rotate) come through the separate `event_json` traitlet → dispatched by `Figure._on_event()` → `plot.callbacks.fire(event)`. + +**Adding state fields:** Add to `_state` in the constructor, include in `to_state_dict()`, and handle in `figure_esm.js`. + +## Key Patterns + +**`_push()` contract:** Any mutation to a plot's `_state` must end with `self._push()`. Forgetting this means changes won't appear in JS. + +**Marker kwargs use matplotlib names** — translated to wire format in `MarkerGroup.to_wire()`: +```python +plot.add_circles(offsets, name="g1", facecolors="#f00", edgecolors="#fff", radius=5) +plot.markers["circles"]["g1"].set(radius=8) # live update +``` + +**Widget (interactive overlay) pattern:** +```python +wid = plot.add_widget("crosshair", cx=64, cy=64) + +@plot.on_change(wid) # fires every drag frame — keep fast +def live(event): readout.value = f"({event.cx:.1f}, {event.cy:.1f})" + +@plot.on_release(wid) # fires once on settle — safe for expensive work +def done(event): recompute(event.cx, event.cy) +``` + +**`subplots` squeeze behaviour** mirrors matplotlib: `(1,1)` → scalar `Axes`; `(1,N)` → 1-D array `(N,)`; `(M,N)` → 2-D array `(M,N)`. + +**`GridSpec` indexing** mirrors matplotlib exactly, including negative indices, slices, and multi-cell spans — see `tests/test_gridspec.py`. + +## Developer Workflows + +```bash +# Install (uses uv) +uv sync + +# Run the full test suite +uv run pytest tests/ + +# Smoke tests (no pytest needed) +uv run python test_figure.py +uv run python test_pcolormesh.py + +# Build docs (Sphinx Gallery, outputs to build/html/) +make html +make clean # wipe build artefacts +``` + +## Key Files + +| File | Purpose | +|------|---------| +| `anyplotlib/figure.py` | `Figure` widget; layout engine; JS↔Python dispatch | +| `anyplotlib/figure_plots.py` | All plot classes, `Axes`, `GridSpec`, `subplots()` | +| `anyplotlib/figure_esm.js` | All JS canvas rendering | +| `anyplotlib/markers.py` | Static marker collections; `to_wire()` translation | +| `anyplotlib/widgets.py` | Interactive overlay widgets | +| `anyplotlib/callbacks.py` | `CallbackRegistry`, `Event` dataclass | +| `anyplotlib/_repr_utils.py` | Self-contained iframe HTML for Sphinx Gallery / non-kernel use | +| `tests/test_events.py` | Callback system tests (good reference for event API) | +| `tests/test_gridspec.py` | Layout / sizing pipeline tests | +| `Examples/` | Gallery examples (files must be named `plot_*.py`) | + +## Important Constraints + +- The **OO API only** — no `plt.plot()` style. Always create a `Figure` and call methods on `Axes`. +- Plot objects (`Plot2D` etc.) store all display state in `self._state` (plain dict). Never add traitlets to them. +- `Figure` adds per-panel traits **dynamically** (`add_traits(panel_{id}_json=...)`); check `has_trait()` before accessing. +- `_pushing` set on `Figure` prevents echo loops: when Python pushes a trait change the JS observer is skipped. +- Colormap LUTs are built from matplotlib (`_build_colormap_lut`) and serialised as `[[r,g,b], ...]` in `_state["colormap_data"]`. +- Docs examples in `Examples/` must have a module-level docstring (first lines) for Sphinx Gallery to pick them up. +- When possible stop and ask questions. If you're unsure about how something works. From 132ce84d2c0bd142e5d486c406ba53499c1be4cf Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 16 Mar 2026 11:30:07 -0500 Subject: [PATCH 009/198] New Documentation: Add pcolormesh examples --- Examples/plot_pcolormesh.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Examples/plot_pcolormesh.py b/Examples/plot_pcolormesh.py index a9d298ba..41147bdc 100644 --- a/Examples/plot_pcolormesh.py +++ b/Examples/plot_pcolormesh.py @@ -8,7 +8,7 @@ The key difference from :meth:`~anyplotlib.figure_plots.Axes.imshow` is that ``pcolormesh`` takes **edge** arrays (length N+1 and M+1 for an (M, N) data -array) rather than centre arrays. This enables fully non-linear axes where +array) rather than center arrays. This enables fully non-linear axes where each cell can have a different width/height in data coordinates. """ import numpy as np From 55714431070e6ad3b961a8f379be65b0ab19a1bb Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 16 Mar 2026 11:55:48 -0500 Subject: [PATCH 010/198] Fix y-axis label positioning and add units label to the y-axis gutter --- anyplotlib/figure_esm.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index f48f435c..cd7d34ee 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -801,18 +801,22 @@ function render({ model, el }) { const yTicks=[]; for(let v=Math.ceil(yVMin/step)*step; v<=yVMax+step*0.01; v+=step) yTicks.push(v); const minLabelGapY=14; // px - let lastPy=Infinity; + let lastPy=-Infinity; for(let ti=0;tiimgH) continue; p.yCtx.beginPath(); p.yCtx.moveTo(aw,py2); p.yCtx.lineTo(aw-TICK,py2); p.yCtx.stroke(); - if(lastPy-py2>=minLabelGapY){ + if(py2-lastPy>=minLabelGapY){ p.yCtx.fillText(fmtVal(v), aw-TICK-2, py2); lastPy=py2; } } + // Units label: top-left corner of y-axis gutter + p.yCtx.textAlign='left'; p.yCtx.textBaseline='top'; + p.yCtx.fillStyle=theme.unitText; p.yCtx.font='9px sans-serif'; + p.yCtx.fillText(units, 2, 1); } } From 1eb0be359e72c9c80a2e7497e58ec8f450f285c2 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 16 Mar 2026 14:38:56 -0500 Subject: [PATCH 011/198] New Feature: Add baseline tests for comparison with pngs created using playwright --- pyproject.toml | 5 + tests/_png_utils.py | 289 ++++++++++++++++++++++++ tests/baselines/imshow_checkerboard.png | Bin 0 -> 8046 bytes tests/baselines/imshow_gradient.png | Bin 0 -> 5420 bytes tests/baselines/imshow_viridis.png | Bin 0 -> 12048 bytes tests/baselines/pcolormesh_uniform.png | Bin 0 -> 12299 bytes tests/baselines/plot1d_multi.png | Bin 0 -> 14576 bytes tests/baselines/plot1d_sine.png | Bin 0 -> 10866 bytes tests/baselines/plot3d_surface.png | Bin 0 -> 49457 bytes tests/baselines/subplots_2x1.png | Bin 0 -> 11984 bytes tests/conftest.py | 135 +++++++++++ tests/test_visual.py | 159 +++++++++++++ 12 files changed, 588 insertions(+) create mode 100644 tests/_png_utils.py create mode 100644 tests/baselines/imshow_checkerboard.png create mode 100644 tests/baselines/imshow_gradient.png create mode 100644 tests/baselines/imshow_viridis.png create mode 100644 tests/baselines/pcolormesh_uniform.png create mode 100644 tests/baselines/plot1d_multi.png create mode 100644 tests/baselines/plot1d_sine.png create mode 100644 tests/baselines/plot3d_surface.png create mode 100644 tests/baselines/subplots_2x1.png create mode 100644 tests/conftest.py create mode 100644 tests/test_visual.py diff --git a/pyproject.toml b/pyproject.toml index a7d1ef03..cf522207 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,4 +27,9 @@ docs = [ "pillow>=10.0", ] +[dependency-groups] +dev = [ + "playwright>=1.58.0", +] + diff --git a/tests/_png_utils.py b/tests/_png_utils.py new file mode 100644 index 00000000..cfb2ae98 --- /dev/null +++ b/tests/_png_utils.py @@ -0,0 +1,289 @@ +""" +tests/_png_utils.py +=================== + +Minimal PNG encode / decode and pixel-level comparison utilities built on +top of numpy + Python stdlib only (struct, zlib). No PIL, no matplotlib. + +Public API +---------- +decode_png(data) bytes → H×W×C uint8 ndarray (RGB or RGBA) +encode_png(arr) H×W×C uint8 ndarray → bytes (filter-0 PNG) +compare_arrays(a, b, ...) two uint8 arrays → (ok: bool, message: str) +""" +from __future__ import annotations + +import struct +import zlib + +import numpy as np + +# --------------------------------------------------------------------------- +# PNG magic / chunk helpers +# --------------------------------------------------------------------------- + +_PNG_SIG = b"\x89PNG\r\n\x1a\n" + +# color-type → channel count (8-bit only) +_CT_CHANNELS = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4} + + +def _iter_chunks(data: bytes): + """Yield (chunk_type: bytes, chunk_data: bytes) for every PNG chunk.""" + pos = 8 # skip signature + while pos + 12 <= len(data): + (length,) = struct.unpack(">I", data[pos : pos + 4]) + ctype = data[pos + 4 : pos + 8] + cdata = data[pos + 8 : pos + 8 + length] + pos += 12 + length + yield ctype, cdata + if ctype == b"IEND": + break + + +def _make_chunk(ctype: bytes, cdata: bytes) -> bytes: + length = struct.pack(">I", len(cdata)) + body = ctype + cdata + crc = struct.pack(">I", zlib.crc32(body) & 0xFFFF_FFFF) + return length + body + crc + + +# --------------------------------------------------------------------------- +# Reconstruction filters +# --------------------------------------------------------------------------- + +def _recon_none(row: np.ndarray, _prev: np.ndarray, _bpp: int) -> np.ndarray: + return row + + +def _recon_sub(row: np.ndarray, _prev: np.ndarray, bpp: int) -> np.ndarray: + """Sub: Recon[x] = Filt[x] + Recon[x-bpp] (mod 256). + + Equivalent to a cumulative-sum along pixel axis, mod 256 — fully vectorised + for bpp-aligned rows (the common case for 8-bit RGB / RGBA). + """ + n = len(row) + if n % bpp == 0: + # Reshape to (npixels, bpp), cumsum along pixels, mask to 8-bit + arr = row.reshape(-1, bpp) + return (np.cumsum(arr, axis=0) & 0xFF).ravel().astype(np.uint8) + # Fallback (edge case: row not bpp-aligned) + recon = row.copy() + for i in range(bpp, n): + recon[i] = (recon[i] + recon[i - bpp]) & 0xFF + return recon.astype(np.uint8) + + +def _recon_up(row: np.ndarray, prev: np.ndarray, _bpp: int) -> np.ndarray: + """Up: Recon[x] = Filt[x] + Prior[x] (mod 256).""" + return ((row + prev) & 0xFF).astype(np.uint8) + + +def _recon_avg(row: np.ndarray, prev: np.ndarray, bpp: int) -> np.ndarray: + """Average: Recon[x] = Filt[x] + floor((Recon[x-bpp] + Prior[x]) / 2).""" + recon = row.copy() + n = len(recon) + for i in range(n): + a = int(recon[i - bpp]) if i >= bpp else 0 + b = int(prev[i]) + recon[i] = (int(row[i]) + (a + b) // 2) & 0xFF + return recon.astype(np.uint8) + + +def _paeth(a: int, b: int, c: int) -> int: + p = a + b - c + pa, pb, pc = abs(p - a), abs(p - b), abs(p - c) + if pa <= pb and pa <= pc: + return a + if pb <= pc: + return b + return c + + +def _recon_paeth(row: np.ndarray, prev: np.ndarray, bpp: int) -> np.ndarray: + """Paeth: Recon[x] = Filt[x] + PaethPredictor(Recon[x-bpp], Prior[x], Prior[x-bpp]).""" + recon = row.copy() + n = len(recon) + for i in range(n): + a = int(recon[i - bpp]) if i >= bpp else 0 + b = int(prev[i]) + c = int(prev[i - bpp]) if i >= bpp else 0 + recon[i] = (int(row[i]) + _paeth(a, b, c)) & 0xFF + return recon.astype(np.uint8) + + +_RECON = [_recon_none, _recon_sub, _recon_up, _recon_avg, _recon_paeth] + + +# --------------------------------------------------------------------------- +# Decoder +# --------------------------------------------------------------------------- + +def decode_png(data: bytes) -> np.ndarray: + """Decode a PNG file to a ``(H, W, C)`` uint8 numpy array. + + Supports 8-bit colour types 0 (grayscale), 2 (RGB), 4 (grayscale+alpha), + and 6 (RGBA) — which cover all screenshots Playwright can produce. + + Parameters + ---------- + data : bytes + Raw PNG file contents. + + Returns + ------- + np.ndarray + Shape ``(height, width, channels)``, dtype ``uint8``. + """ + if data[:8] != _PNG_SIG: + raise ValueError("Not a valid PNG file (bad signature)") + + ihdr: tuple | None = None + idat_parts: list[bytes] = [] + + for ctype, cdata in _iter_chunks(data): + if ctype == b"IHDR": + w, h, bd, ct = struct.unpack(">IIBB", cdata[:10]) + ihdr = (w, h, bd, ct) + elif ctype == b"IDAT": + idat_parts.append(cdata) + elif ctype == b"IEND": + break + + if ihdr is None: + raise ValueError("PNG has no IHDR chunk") + + w, h, bd, ct = ihdr + if bd != 8: + raise ValueError(f"Only 8-bit depth supported, got {bd}") + + channels = _CT_CHANNELS.get(ct) + if channels is None: + raise ValueError(f"Unsupported PNG colour type: {ct}") + + raw = zlib.decompress(b"".join(idat_parts)) + bpp = channels # bytes per pixel for 8-bit + stride = w * channels # bytes per row (without filter byte) + + result = np.empty((h, w, channels), dtype=np.uint8) + prev = np.zeros(stride, dtype=np.int32) + + for y in range(h): + base = y * (stride + 1) + filt = raw[base] + row = np.frombuffer(raw[base + 1 : base + 1 + stride], dtype=np.uint8).astype(np.int32) + + if filt > 4: + raise ValueError(f"Unknown PNG filter type {filt} at row {y}") + + recon = _RECON[filt](row, prev, bpp) + prev = recon.astype(np.int32) + result[y] = recon.reshape(w, channels) + + return result + + +# --------------------------------------------------------------------------- +# Encoder (filter 0 — no filtering; only used for writing baseline PNGs) +# --------------------------------------------------------------------------- + +def encode_png(arr: np.ndarray) -> bytes: + """Encode a ``(H, W, C)`` or ``(H, W)`` uint8 array as a PNG file. + + Uses filter type 0 (None) on every row — correct but not maximally + compressed. Intended for writing golden baselines, not production use. + + Parameters + ---------- + arr : np.ndarray + Shape ``(H, W, C)`` or ``(H, W)``, dtype ``uint8``. + + Returns + ------- + bytes + Raw PNG file contents. + """ + if arr.dtype != np.uint8: + arr = np.clip(arr, 0, 255).astype(np.uint8) + if arr.ndim == 2: + arr = arr[:, :, np.newaxis] + h, w, channels = arr.shape + + ct_map = {1: 0, 2: 0, 3: 2, 4: 6} # channel count → PNG colour type + # Override: 1 ch → grayscale (0), 2 ch → grayscale+alpha (4) + ct_map = {1: 0, 2: 4, 3: 2, 4: 6} + ct = ct_map.get(channels) + if ct is None: + raise ValueError(f"Unsupported channel count: {channels}") + + # Build raw scanlines: prepend a 0x00 filter byte to each row + rows_bytes = bytearray() + for y in range(h): + rows_bytes.append(0) # filter = None + rows_bytes += arr[y].tobytes() + + compressed = zlib.compress(bytes(rows_bytes), level=1) + + sig = _PNG_SIG + ihdr_data = struct.pack(">IIBBBBB", w, h, 8, ct, 0, 0, 0) + return ( + sig + + _make_chunk(b"IHDR", ihdr_data) + + _make_chunk(b"IDAT", compressed) + + _make_chunk(b"IEND", b"") + ) + + +# --------------------------------------------------------------------------- +# Comparison +# --------------------------------------------------------------------------- + +def compare_arrays( + actual: np.ndarray, + expected: np.ndarray, + *, + tol: int = 8, + max_diff_frac: float = 0.02, +) -> tuple[bool, str]: + """Compare two uint8 image arrays pixel-by-pixel. + + A pixel is considered *different* when any of its channel values differ by + more than *tol*. The comparison passes as long as the fraction of + different pixels is at most *max_diff_frac*. + + Parameters + ---------- + actual, expected : np.ndarray + ``(H, W, C)`` uint8 arrays. They must have the same shape. + tol : int + Per-channel absolute tolerance (default 8, i.e. ~3 % of 255). + max_diff_frac : float + Maximum fraction of pixels allowed to differ (default 0.02 = 2 %). + + Returns + ------- + (ok, message) : (bool, str) + """ + if actual.shape != expected.shape: + return False, ( + f"shape mismatch: actual {actual.shape} vs expected {expected.shape}" + ) + + # Work in int32 to avoid uint8 wrap-around + diff = np.abs(actual.astype(np.int32) - expected.astype(np.int32)) + + # A pixel fails if ANY channel exceeds tol + bad_pixels = (diff > tol).any(axis=-1) # (H, W) bool + n_bad = int(bad_pixels.sum()) + n_total = bad_pixels.size + frac = n_bad / n_total + + if frac > max_diff_frac: + max_diff = int(diff.max()) + return False, ( + f"{n_bad}/{n_total} pixels ({frac:.1%}) differ by >{tol}; " + f"max channel diff = {max_diff}" + ) + + return True, f"ok — {n_bad}/{n_total} pixels ({frac:.2%}) differ by >{tol}" + diff --git a/tests/baselines/imshow_checkerboard.png b/tests/baselines/imshow_checkerboard.png new file mode 100644 index 0000000000000000000000000000000000000000..1d31a4b50a14d96c96658a0031fa7a904231a63a GIT binary patch literal 8046 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 MZlG0o)h7IZ0E7bzF#rGn literal 0 HcmV?d00001 diff --git a/tests/baselines/pcolormesh_uniform.png b/tests/baselines/pcolormesh_uniform.png new file mode 100644 index 0000000000000000000000000000000000000000..eeec631e4a06f2fe827abd8ef747af0dbab5e501 GIT binary patch literal 12299 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 literal 0 HcmV?d00001 diff --git a/tests/baselines/plot1d_multi.png b/tests/baselines/plot1d_multi.png new file mode 100644 index 0000000000000000000000000000000000000000..e85e196497e311014f51cf6bc0d985dc8445643d GIT binary patch literal 14576 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 literal 0 HcmV?d00001 diff --git a/tests/baselines/plot3d_surface.png b/tests/baselines/plot3d_surface.png new file mode 100644 index 0000000000000000000000000000000000000000..3d0e7a92288c4f1e990fa69fef81e7dcda49d903 GIT binary patch literal 49457 zcmd>FWmg+*v_(tt;uLLhch}-x+_gx7qQ%|arMLts#jUspcXta8#U)q>a`Udc?ytBX z@*!E1=gb^G`|OEU`znWtMuG+h2ZyO3FRcLw2e0*cqtz+KO-OFTw&rAw6$>gwvrpbmMQWYGA%iM8)#&?mk#^Gjn3)rB$z1%t6doWXN#$Z}7JOFp(co4O|V-uoMpXD(fOqYQmaFtrwd9|^N7N-b1 zQGGO$GLD&}4JHU+bP#?-sB5MLJ26pF?AgpHc=SI(`%LU2Ev*Q}|DC9HvxBmSrpyKt zM6c_;(>6#zoCZ6wI0ajr`2X9j_b7Uc`nbJ8DLr3ap_jET903?LzyX-9-(uF@uI9V# z_%#9{cXP}XiIfq{95^to_Ozdv`mwgT?B%I;pZQ&Fcj$_v2hh|21^)A`6LTr~z`fX# zW%0KjX8z8e*Qv*Kr$mz%qsQb{guSBwEN&U_cy-^TT8ao>7K3it0R}*w{?DVS^uCdZtC#>qAwO@3=n}`q7J(R!|mm@w0e6-{qURb z!Mh!BZFnNZo6x(`A=O}Jjz9ldKREZpP~bfQIU1u`J!<;^-Xnn4tA7+9FC&0CZBtla{`TvOQ>{PAJq!4#hL*gO z>iO<ey+ka9@Zuy-6Pur=C#9+#e_d;H@-%caDNo&Juy0o0HPn!}c#L31_ zcaHi9ycA&mW~xya`1GW^l@0e;33%OtoJJe6Av-}=D4QlTj_&+FbnFMF6B}q?%I4&lk#MQXgnc8w^;MfG6`p;T+;Y z4e(tau7>kiPAg|-r@L&=4{Lj3%^;GLPvmq8Wb^yr|EM|3{?I*KPdP6MROQovJx_8> z+|H5kL!h2FF9q`YlgYrJ2XKoqAi=x`q5=QtA$RzB)6@InLa7UzVw198WV^JFz!~O3 zAociq)xhVy&o)`Zz>99;h8M-zZC+o`&7tu?zHLn2z2MZ#04GMUeftdy(z?rzkedX{=0rrStd#}qhPuxWwUbXOxk$s?F3bF5}z}GZ@^8})(z{^ z2Q_aiPvb9!wW6_)$gjNrDOFDW&Fg3=rm~(oGcvLH;ZL=Qeu21gg3Co~8oc`BDYlhOmUS8+z;qs)dx9@ot%;a$`r#&efP!n44CjM-)&tkaGVK|N4b`}v{ zvTHm8xQ?J_*$Wcc;6?f>khZk$`;qMNCzD}W{AHH}axDlfv46r0Nsm>sDJ3cjqyJNJ{0(t-l_+}d?H(6SA@egAks+B0YP8vYuJu zw?yr;?S(cONm`pI`(CaqWiS26I zr8tEZ)>c$MKm>kDyxQ(fwkGtvwsfi!r!BiN3#QSLB3Fzv&a>H=u6-?)(__h-ESEV> zE@!O9;&jgfx>GZ`=Zp6A;*G)KA8KcX@U<5V^U09gQ0?;5vXE3eM^F!Ua*s1h&C4T{ z63)Hu5~I0ZV5{ee45ktN=MO7jHZ6-Xi|F(Q{a<(`3l5XCP!i6Cdg;Y)2G8A3kD-_! zeGPIFI9;&V;m^>qwKFKs2Qq7g$D-Gk(t9)ooKRf1`>9jg&EK~7RMob7TJFDE+IA5v z4+R6Z%?A*C|Cu=nrj82f4*IqkF(-|?3s(@~^bIcCl?ZY(Vp z(Z0bwD|b`k_M$mV&NrXV-=F?{J{0=cT0$(cN-kc}CJ3EtEOOXD{2`z^n27 z?F>Y@An;{}j>zk^wY=Yw^y|yTW`-dA>t+uFtn?EJ((U(G`?icm7D$@J{=CR66COdT z+w)k~qBLU{1SN2qdKG+zs?iUL(BMAsG*ayGlAAA69)>!}1xGq;@VPuYVsV(ZaSyt% z*epUK3_aOj1>>T6*5)tNhSh1$>ga3w(gH<``o7Iki0ayD<&{uzPt`f36Mvb%}vd^5XlkPg89 zoBLkKqrc>BQ{P%#{ATEWWvjyl)BJJW7du{3_jxAyzwEDNStJ(d2A+=#8NVn%!|{xe~2vjBVuE&~13Lng~!?sC{K$e(`=uXV2<;yFq?d ztu271V&w(V&-!-lSvGyd*N3jwXbBM|8W zh`H0>al(n0?2$9$c2U*qa`t78njDr5M>b41J>oQ-EkBy|Jev8h_38}6QK-L?A%skD{vIR@p+`Cm&Y);^JKUz>Vn!3m~KW(V^Ug;DFQe>l_FKIh9(chzg5Y+^VHxoMm{ymXeIH5l84}R%> z8tV_)Fn*pxUm^gCGfH)NuPV_H11?4svYltu;Y{emAy43`* z!>O4D(Ai9DBhc&*Q`vbA#|8@36nN3(r*dtUAwqj9k){{39Lz0 zHXTAn;(s9cW}Fe7v`v=1S|_gVEtu{pr0+WB_R#Cq^KcS*z%1@S zCHi`N#9w0uiow_c_Cyc9ulD4a<-ObfyMQ>Zh@EzsbKma8Jt zXx`(@Oz+2yZYAa#88b8NVmg>3Uf^gJaUTFakd!^sIK; z04M>wS->*EB=^uNxs@49tlfwb6GdWt?fV%&RK9+f> z!YE22#o?I@Rf`)7h&p~-!JsE zoD0J0m;D}IS9H$+8$E%pU=O{eLlvg577lh;`gSb5c-~hYX#V<-8_*PP^jDXpp;D%s+Cf`3JsF&G+uPezttpfW54tZ|ZYe?eF z7Y1lqp7+=_&Fk^%vn%+<JQ3-1KC(qS#B@KCpV4aJ#X>IIeF?`H7|G?Bl+`4Ke6blQ zD(b0rI@|AIIEvzWZdF>vFQmVsVCP;~iLbAWf-KBhZ{5a&Vkn&(H)sgqS zO$IV+y>esWm=;MJ5h9QiORoF(Mg3%H5a}|ggX*W?v7Ysqh5j6czSf;kznd5>j^2rRVe=iNaOmo{8OG%Z6APrq?Xg;^FK#S0 zyrlRl-!tLfHd7f3XrA5X#NR==y{6V34}y&Fvd%pmx{=9Cf?^S;yy*{{tFn+Wz3y+1 zrJw4(RiF(qi7bknS_l(F6iisB)wvDT$-Z@OvkV~LyZxPh6v|Cv&4a5{QB&A#cyxMm zc-D3MLXQ(l{@>W7R?oi4o(MXe@!{vO2L&SX2B@WQmqnY>1D078H*G#uZ3`h_A0yi} zv2FU9QLy-g+xPm!8;V;`8I%m8iE)_L7o!;*uVK@@XP-cvI>Vfw&SF8M_Am6>1;5m*!P$(2@w{$rmp z#OgBL(n%j-V%Vu7v%dZ3Yw%F(raQWi4N>>&?chOwU}%tn?;d-{tT31G;nD zn-HHx^v*ndZrCI)Ol)^d28%XN`pC!P;^h_D#Yqg-A|vJnnt@V2ha_2Yi8HGciXUep zYtqdod>^i6fE4}tHhU#C>ciDqPgx%*xiy{nurf<2OhxEDK`;nL6M2Jrz#AK zAmfl*OuE_t-&69HzTnbe(Fp%eeZC&KBl{1NCi=CVBBYttxkf-S0_+Lh^HF(_fsS)o ztL1Xh6yh;0qQamY+W7>w&Mz`U9w%1^=|E8q%7(Vy%{AP=>3u@2J3Xq+gOQVoZ}Aa` z;;-2~V+7NI;OH$hpF^GMX#+wuNR-2qPM#9`a!W?WUv=@KQgV-o+Fus(a<_`9kxcj0 z2)>V!Ck73%@cJtbnHKYt|7HoW3E{5HHav-D5{9M717`0#7tXC_bXp!)b_YX|1x3qg z=?US%S%w+-=cZ&G)og!z;4`sJH@7zzaayBEHk4*_*IqU#9L+qb-e7S zu>Eg73S$iLPGlkW{gQ}30(6u2gGgT+WwBHoVJ({WUu4oIN9MN77FE58x+^M_q!Q6c?Ti;VwlGxlE1unRw4V;=rTupHy(uF?b)ZrTgz^> zY(cM`ajXzpx$}uq2UgYfzh3tjLz&v~;Cp@~7AG`=ub69+)MYuP51)>@{k{%TQ1XfM z@ehyZBBQ)oEo9GJ)^yz03>%~$!R553O@O}~Ia2oE8>~hYdbNLd( zg}n%Ugcp4$lKqAGe$?(P*#Cx!T2Z(ilWP>=7EL#vMtV`dM^s9Pr^v*Qt>$G$@}xej z2vO#v@HqNC>$^jA>@?ZTbW8hkCPAItmTL2hlbbeZj>R(1clF z&xol%eW$){w5ay{BJ6diU&*V$j9EhxT#FUWTe0V(GKIBL94rcVSwZ**Gra_xFzwuA zKqQ}`i=)HVQ)AXlY|O9sRYQJ)ybSHwE^c)ex2MK&r(!NMxWq$Td`86AKlb5=i#+9` zC-SPM6m6B+o7_yH)tq=QEYT=J5_>sepyj9C*1X*g;TD7?7g!%k0QDxdbmboxMbZ26 z+6jN~etU;n1o?Yy%Cs?SLkr0L8h450==MjRmdHdUy?d6iWlpppGkgx`eAhEp=!V39Z7+!y-zop z#t2VGLWwn`@FGt*O!psic)y)`p&Bky=XTn$FS`p42bS1Op?O!xP*|h~KMa-0;?-F< z4@#>kh!X5Bm~g8*Idvf_T{N|p4FLVlDkA>w#r|zh`vNr@Ei7tx5TvzBLB}FM07IbT ze?16}&e`+23qSuZ#mPiL=UX1Ga?I%}<@C@lc2ec$xZGMkb}Gy+|2>X*{@FYtHR@!j z@Q3F)3Ag(dURMyTsbl{%mvfsoF7Agadd09m6Txq9mq{T-xG+7oQI@^V{_}h%tU86J zYQ6+B6IbV0ibaDSb6SBOF2_o%9tr}f*oxrK|J+ON{?A^V0 zhf+qa-mmU*{7e+XMp5x<=F0uBE)v-AE@CIAaAG!i>%dw#o&)#zvBMzxgihSh`Yhs4 zT|UWWH{TX~g{o&HAR(8rxTEseQL(u)Ny0Ko)r6w7$qn?|Jh!eB24rR4)_Qy+wl!?2 zrg!L~^H1gTPi1omWpnQ$JZzz>UZ$&>4`S6T&Dx^XpQ+9&+JytL(OHH~9?u2OC(MlF zMJ5dedCo;^WElQeCyT!9Rz=w4(2X!W%&s9q8%-1-*L^Xms#)wpS{%d8Jg&nUX5IhP zybqh_1S``gSLO-%_K!*(Y~AOZ@O&86yU2HjNB~VVJ@7xw_(xXc3J)obLxWMhKu*LRrW`cw=i{k{{Gx90ko3T-Yw+$up4eSZqdwx0On*i2=)#%^bLxaFd7%wT;rUXBUz9F z8>)C{HsQ3lQII_0hwac6b<~e6zU$v-j#kLDfbITtZ9Xg#VcZwlcNlsx+F^8$<6yY+ z>?}=U^C_usQT$M+scjTE4bQAZq3AZ)eJ#+-!O&bjImhbX?PXwT5| zVNrMp3@=)=?@qTYUfNnbv5qn%{CKZF?#?M{kVz1F5!YM>5Q2O@%hTRvDE=Vj)@^ataP#9M=w!-{7c5WNWPK0j=h>VR;`AX!`w#QRox@bj9ud4nUV+&vw-8 zV35(%!p7Q?VpY&{RA@2?(aXiC7JRS%U)OF~y&B32f9OvWXR|)H!K%dkb*)}?(+vB} zx~js}QmnHlyud#3f`0JKKC8KNaC#2DAO1zfEfca=RUoKf>v0(FMugL^qWmO|v#L2j zGb2B0KlNzyc-e`D^=!%dfTP|;4O5FMImgpI)x0iasg2h(7q)Lv`yx2~6wfCPD2P$2 zbkR0WE1M&18uIOOD1*k1)q{RIO~$QB_HKFDp^S0O7|YHK z-@beVq|fCa+l41qWnIP`;91#IPD}I|qCd)cY8X0!nQu&ECI;-gGU|0+7CMuskB?BEwiMf=aO1w2E^t+=w7TqX5_PgUVMIha?Pwfmz)$(KuLf>G zOiMJuW@zq-j7;VuX9`jEWL0+wnb!kQLop^9_M8N=0oVdgJ_o~hg_`=d85 z(vFW@yxf-aX%tjUbphT@Kr{cn8c%q-*?VHHuMW^U>uGBBdCJr96W+ZEWgF*^cyvK8 zP6@BJyaUJ{0~sEs9ba^Wz0kQ2qWP^Dt)LZm%pK;+F4&k`hRpZepCn{$GxdcQsQ}-# zW!Le!+ZP(DZoEl;W)FV&^UL>>q;4|MF>1t7V?c#1{2ZZ4tbc)kMcWJp++)1dano~2 zAN$x{0dT|w0vARj|1rAWFR7X?+5nguy7>H!wTH4(GSe0rz~GD)Q%A>jSzGE`HBl9} z#3nLx{zXGAl+7ED!+0KkSlFU;4Y z3|-imN67=d23-!#_k^%+`Zd_^sf^C@+b9!5fmpu68;VLv+B@db`F0mRKjEm9xkyWo zo4!0Y@J-tU6^P2SsPY;_&CHA8PZt}qcN4>!M?U*d$c?>g2E8dlwP(Ex`PsD@b$w3G z?me$Gn-kpO`HEAOu?<@yXaR15^uPhI&~)HngU-={qsy^@sbvQ8Czy|)-(4OI$?jy2 z>r&@5!Vn3`K4(2>2e;O61*2r`^b!d2HY4lmyh*KY2L^cmw}_T+P@&vFS+jfRwCVdS zxkU+Jul9dq5lWg;f~ANbk-q8P)}aDcZ65_5KA_K65mJWRBZ0Ti_Zs{Co$8tVJ3;#w zA3&k055#EOoM+aG3)ezm$tHM%%Rf}vSuOz^ARoCy>q`fWB`sCcdG*deY1a1Wm$nJ3 zpxtkuJ07@1C!_+pVr)d+Oa@qTSZKT1q$_b66r~w>CgVSg2tbI-35&^9zBu`%tYubkP3+qG zGu$v3l2%#pI|W);s=m|#wpQhT@kV3{|8Z)3m{7g6i~Z+BdpjTO?ZjNwHTs#s=JNeN z%|?4lf>5BX2N!q))2}F(Q)c207u>Z%MwNOr-+S|;s2b#N*17+zYlH%GnHb2}Lt?ON zT{rqe`}CIZJ%r<`N6haATdw<6yg$3{Ghes`wFpL`Qpo39K3)M24Bx1eF4}$|;y&D) zeHY)74KT&unMhoui|7SI@X)}LLI7Y1B~IXq33qTlOiCzi;}el$ehc45@A2_fBoeIL(o(5g2u zWl)1xxwzU#e}b$jfKMMC^l9H`PLBek#tB`tHTVWk%pu_Hf)*CaGcM!Wz8s1A{d~|0 z`{lr=Y8UWiyzYf~_KXD845{f~?=vvD{L8}Z&dMafkNPm% z#ec6D4TCsdJmfJ9+!K6Mav!Mcg}uLs>U zH_|4L*4E#xR>L=YU{f#pWA=CCh!iK{n^%GBQi8n`4={?-zMh#2RSIZIE1OZ|YdUH% zsrS0;{seuSGqN&QrHK6UUBdoyQ9fsD^fgkl@9nt{wtMNR)50=+KG;dUaP{0_ocX)A zRB}o8*MMC-U|WBCR+J+w;2U66pB_oK$2t6C>s;P`s{VH2wOA^e8_<#(-> zl)1fy9`r%(|b_&P5^mkcD z4O~l$c4%T_hvo1r%%xog&w`rIY5MPg6^x59ai{?ztM3 zDF-qQcVrQm%wJ#akS>@DdGJrQ=A%AbSx`U7Ni{uL89F-lyqdvU4|8kC?h(KU-`u*5 zq4!73jO}~05Heao2S24U<19@-f~0K z$+yGJ#YRS>_R5rVryyYdVN6n^|!5mzpMJ~>=I;`+-Wt3Y2W54hx1IsfQu|NB zIm+WkYzplEf}Y{YP!}NHstaIe8n)FGK~t+=F?nZspX_C5`qLjPAcW zkHPHISA zg`Db{far1)n)UBU{8Zu$i!mfzJn7TQ{MCLthgUk z`wc_WCI#9q3n%`p&NT9``CUaC@%=|aW*7)~b)aZ=Rn_w);Txc&MeZ))P$^}<%%RYp zz>Jce@3C3j{f79t{rkI498ol~0}y>uk1g?|~4NiglQ z1U8nD+H|2SRHBauTKXykx&Gl!JETi}s)t+{75bvA$I{X8{e4*v8*s_C7Ay&&^V05p zJs*ax6hXq0i0Lvf-hY&jz>k(Mp{<}WjjwI=~vo{8tv6#h3_eK zN?j>qPUOtQ3Dhk=c8EkU-Ro&xj)?rGCcd#(;co=7<7cZ+`3qZqvy`DTA1BaPG)SJZ zTHyIhmuH1qm4+<;5l$p?3oT|#kxRgvLzQK# zm~vUeBoHDN_GICu0N3s3?UX)BRytp*$8v1Ev_Nqh!iLTwcVug72zveGoDy5m;h3=c zp(;hHWApvmENs^Tpj~EL=*5Ho2`eh~51(SK!CDY5O|s!6_S3Hi#E5n>ue|bRp=3=6 z`MhP4xr02@pT-fER`NPGr`hSD_v@$NtDBE{|AzDjyoQ0-ni2rAO(_XF`7`SC+$KLy zWbF1`<17w`h*P?>wkxE8gelJzdtKDs$!%O=ukJi5gy=oZ{u}#Ui0+KFmfGbOWzWzo zC(GYxQ34ntWnzkZ4E8|hh$QjI&=X-{UKK6i@4u$E#r7@ zZeEH zHxUmftuGQr!wb{IzI=|HS$33^$R6BfS8t8An&#E*7Wn#GF(b=wl%w&xjTw)~N^rRGXI}Z>I^jn3`ksWpoe2qCm!|Zr+wSmJ zX7Gzt*b>%JX(X-52oor^1&}J5;8c3xm=0fe?8=l#W@X6N0zN@0WDu3d^84HaBX*>d zEAtjCERM?|yf*fdCy8S6&?60{dvh2IvW>z$w?H<)nU=XM?C<&ZHEc<><%&!8h{|wU z8vr3kKa*Q^e;D8F;E=TIrTesUn`d{g#*@2^K**Us!m_`A*^uBTgC9>2#;rOjR#R7<-|9Qe`a%Ju_Qt4EZ zvdv|DhVwn}y}UjQfH8o4tq%}om5PywHcy@%P4?{Bnk;SlHjU-_l8BTcoPSaRmQ-(s)Y;Jpp+14U=-L0{xiZClgYrPUEy;7t2xaaA*}+))fuVwYmP z588g#SpE785i!w3hWkwEt^D;bYmZ6R*fwQO+g`K4^Z923oHwhuZL}e(nCHQRzMg1A z@!kjEc9Utk{gcP&5T%DvVBKrylvmjEY5zKVerf1(Egnr&apdqEo4I<-DhZRWAQ!g( zCBs#}Aqhh0Nnd3923ePX!bXh5#X8N{hs-06JJkmanY*1Pu5aYN0pz$^^w{NT`0wp^ zRM3E8p|)ryboIgz#psR*8NFT)U9R^pex7*T6Y^kg4=*Mm;OKhRGB}DOsat8^e!rK& z2O;!y?BJ>I(OYaj3kmPg($Q}qzSp%Fy;CU6!~tQXL053o?&ErGFmhplT5Ob_B&yy* z_cHWnE;RTF-k$#!@~F-N3GCLs#%*{3!bOZgY6UefDo+^ zPHa>sR0J?c^wsT-1O9>?i0utkOQ%{EOTV`b1JpFem$gIrqro)I<(337q9lumCkcF& zD#!%BJxslQUq#XUcs{I8f4==|eBfqAF(IXdt?nU!Dhz9NLekM2cF)tivDV}1X>O2a zST3g`i1UTQhu{vsjgk}5!FVUPwgkTJFidSW;Ii+J%P#)u9Mpu#SHM@~ zOH~*n9RGwj;QXY;8tyKh=(Y|yl}zXn#J|-!atC{>LxZ<&%Ctg$@#ALs8ArW{iM2An zAy?HBQiGsI@#iR+f0~~Rsuei0KliCdB3s+eS!YFx)=Rnb+5$h}Tz%o=`dN=n;H)Fa;TBa_^nZkuN{0sC<+vEBo)5 zqb;TfV`=Q_eJ{dh6y3TQAzJn#;#YFA$LJoEfWVuagfzh{d9lqJ+=qp^>u6W1Nv74ZuIP9T#~gLQ`PAC7otmdup@y56;D=3%x3N;(WpC zXH~eLA^du_l2x~sUU@po;S%D+x`i)ChDnW*Tx3hsf&Jm?OJ|6% z%=k3{*vzF{IP{X~1049Oj7ld3QQpt<^X@oq8!zXMI>7=HRZ4OJN&cHgjZ^Uef8wrY z-^~;~WWV{|X%o72(D#P+-vo=((%Y=GDfJcfI<^l@q;z`P)-^M6ew;cIH+^G=th*l| z@+Tu9Q#2=iZE%xO`ZY~%fGE6!Q>RujDtQZDIq@p|EMq>U;dbyvwWD2FZZWOA@ee~4 zn06@3$SPtXX<#wjeWbX&La*IXY&~;?yA+LO8{{f&Y@N+uO~MG_krA8ET&amK`+%H* z7OFH@NWiraGCqh*Gd|(6YTnM;XarY=&Al~v&w~Z^_SD;m8FJaF_um(>CY{!t%t0V{ zEgZda#P5!snd%gd-qWAFzWDH{32}6=Z;_=F$qX?-604>HC^~-0o2d<iTQPt=K3Ykry z?ZnYi*;suKMYT+1$ zn35IWBy=}YNCMQ-7>blzqZWCaG!hCEXm85@M9HL-56*=dxJM-+@_PUBE=oJEXyCs5 zVbKvKfJCycAN>K@-lofyfXfUG^C<#D6^)1q>_NXZa9?p0?DTMo@1>QBI^!dk0aFRTU2v8Gq5NL5YeVIUSC zfIk?jb%|s-Dt}w?-K;P7#g%RvqwsK%S z4`j^2ycT)|MNbTTTmR^ecxha@b&rPxc_F&|wz_!@f_^lx$R2G6S_Wr#=6v{xaTy%Q zU(W-w%%Yt)Tfy^A(MtP>2t&tcY|+g1f&6{S#P%no*&}Hy!6>1#H*J1~_QNA|Ld0I0 zBPVEvREX$|IMylg=mu`YU>mJO1WFSckL16(UOeI-$E)oaI{7(_F<3&<&M~e#4bEh( z3LE@$!~GJM_~`#7Q5qO5H>4uIV8Q1})_%o7A_sQr#*UO`Vo;y^$x}_!y7kc6dc}V- z&-zsKi}G(KY4&)gu6#fJv^oE111j00HLo|`18~JxkGuAvudDZ=1M~)Cnh#?i^{!Kg zrL>XslSQw^k0lZ^Yq}jOr?ReE#%lafdkyyXOs45yBVeeLdxR6gX#R;rp(5p}`)7eK zzQ)5+Ow$}4ZdhzU#J>NeguuvEIuyb6E{VZRUvj*J0etTus4-9{%SxJXRbK|1<$m87 zHTn^uk_zp>`97F5w}hYDe`|OBxV^tqy~yTC{ zx5#w=c|Gfa(Hf$2gJAdg%7IY(Yj=wUy?pL z?9^kUc)2$_N)wBEYxCu!UGbUa2lg_oX{FLCe;PJHm$G~x+Bz3bf_H@-m|Zd(DZj5^ zfT)#O>iTOMA@Ks0P6-`gR&2`pQb~lLEpd2E{@xmF*WL4gIBYxEhS| zx4yY8U!Nn|?;Fw65@mYMfxEtqDpHK5*?Phj93_&T-CbgrR0KG}g8A`ARND9}qjzT4 z8zjVaCxjf78%#7BI9rO2@@XSIgx5|f5UZ5Fac_8Sqb(7i72Ka{e5_oN^{oDg`wf$!gQq>rdeA}p&>1Z!zNZf*W zL#wW8h#-71A%?Wv{}hP@0W>@%Q4V12MqizEi zvpjrWo6pmG`lbA%+>G~SVvR<)1`$EwzhW%xevLW|*~idkShCn_9`JvpYCuk2<&aN^ zO~~~maLwiM5Xa_2CTyf``HJJBekl5ziYPeG+gvyLD>JRpXaEa6`9PCtv^Xs z13yK@n|x^D-R1_Gw;n}fdT2Binsl35Jm z^C!NfZ@kPzCB${~F-TLA6cX>1Hm z6r{f?lJ89C^3S+_J}oI6M|xe{O)_`1K^6Zkji2-dZMq0b+Z_4Z{pJ_**2dr5g4`XT zYSI;Z;OeF08<(ctz@Kj9ajn%uHm5&fBPqBa|c&xG*dnIpV^WY0BrVO(u_?X z-^D`J!o*E&LvVN#fW^lG`LK2vxmFATYM;znrh(yV_FYTCoOB=gtR@LN_$zpiMnnm~uC${eJgA!B z6_c;0&~o^Y^eG((Qv~}R!bGUW1M~O3{f!xt8dd78j$aqc4473i&rIH$8COy+Z4w84 z1sFQggzPM#1nCPn?RD3cP61xtC0mEoFb7H#0mZX5#2E#sZ|z|5VNZY{j7epndIrf;7$yzs=Q#CZmm;VbC+ zZQxwpj3`?N|2&$v#uHLsw>3BQ9bQ5Y+sMj!rl^O z6pcB3_5y$Z(?ptH3l#8F29mWRzXRK6VP(oyd$6LnU)TtzTi;=S^VlIed(5%EvZO|)I^2=z0!*vzl1L}aZeIgN7j;VzG zVmUuh+3^d3#p`9}zr*bF)TyNEBV{{m_04}l>Zo?`2>@_X|H4HmcV^o8b>M?1!^%K_ z!4_;nOY}_z7$q6w`>dBvOZ%)00~kKu$oWQ$qo`To#Po+X$Wi9@iq`K6{7svxM?}_H z5VtLgGI=^tzDRYFP?~HYG|&ovt;Zp97ua)5>jSB*P4)Gen@H!Wh6gQCRC42n zr{E~9u0V)B+0Kx%YZXqYW_;$&`6?9#SbdQAFqcP0+w0=)FDkW61YL4CrI=W{0^#corrs$xnt<|$?oy7_o&sPux`MX4N zioCG{i&v%%nV9JeO9`nOlzz|um1ej~&XOd`0_r=M<}HEn+4m);b5oN~hrYL7w;}}- zXGRXE3QGlXshY(;GnEC9Q`!8y4ZDvLO2F>i#f{kbLfjLF0)+B^>Ms9XXC9yzzfv1T z(Hcc$z=Snn9a&Oe^jDC3P7mnk@%cf8eedT4CDyY*%u7l}i0t?IdJB`ftMMdcM(ty| zSmF57e%xuzWA3;m%T!+F8W%iZ!P>W@OZH^z%rgRCeCX;LX1HCCLFenRKp{^Y6Z~QC zZfgX74QeK~O^U_ga?s;qv|j}>CwYuz4QG7GXmR;I&1y5D2i<0hGT)TLVqeVv0Bb;$ zzqGJk9js#m3)%!OMP@e0a_QDtwaV~(fun~<(-B3IumWLPy(YCzm9kVlEmM?GrYfvA z28qNj285s#5;$C79$u2;%j3F>V4fVqmCi+H1Q1DlH<$wtPF^VWs$zMTGea8%UR04i zq;KK*F5%c?kYytOI+5MU7qUXKIf09X;fvV;neEV4Kea>{p39C%<3de-+I;Ht_>OZI zuD+!9Z|&D{;NQ@q{p#k;(p9Tb)E&1mI^9C8$(<$k;K^X?&Jae56-hcXIRCjilNT&g zEkOQKvF9kWbQ^^`XsZ2`A`^l#?+YDlh;0^G<<}s^n0HJ>v?+tj1W7HbG#DOVEDkLe zg28GQVa0-3mC}U`s`Pek4%wJZ(&cn07PadacUp?UI?ToG=0$Dh!d6pZv$3GXkk_bB zTP826mZla;q8D;w68Zl1TmT--Rm67|4G*oGBIbF~XfAbaDDbcipukEPcILfW`LJXq zEPCP7APj2adBX6!YNmICy=Nh~g74Ra_|=F5i#TCL_HH?!T$n#Yg<(IDMZEBI_KX~U zR5BwbftHcWcteYM+_QxPKkkzM(*@z}QEN}0j8Liwh{O?fEDie;%+^@B7MRD(og)GM zz375Cxei+9Qw>(=x|=D2S43NUT98Plq-hlk7SgKRbYmKC6^eaOr=mdK6V@zoL>*1% z@NF>r1rQ1<5f)G_^Xj$XWwPhm{CZ7j3Vm^_DQ>YMI!E%XMP-T08_lt)f+Y>+*{SgC z;5q9=DD&_tcqgd{{5)#J4n{yL$4xLi!{57!V|Vgg#2ePAZ4&ta+wfYuT7FO+%d>=n zHZ+7U<-=I|&|+>#0rbfUAj84Qw6J7aWFa>&g%Xh~n4ZCmT1bzc-@bqU>302wpu=j724YYE&Rczn~wTLC+#jLLBOBK&DpG~qvW{alfN#_(H zW$n-)FMgK!B~6x@$zrgSB6(h&)=AArfrqUd9VW~TGfxl*iFFLGR*o-82-ALG@}FlD z2cr6r7CsF7^;^n?>H5IDcRS*@nCw+)U**aV*32*Agjb90%Wq_{g3Ber#iFnU)T!B` z@N{ZWd}maiD0pt?%tU&6_S@N9|M0UfzBl)LEmAn%$B(}H<_j9{r`7Ah9)0=slLrqz zC$YY__I}nz$ANbgYQoa+0+|EXujQ=lbCpB=R*IURMv-N+d9VaTj%bcXpJBEpS*!sH zr6c_CEP!C%n~h+oV+nYZ%xsPZs1BnjVRCz5l`yP9oXFA_8uk^h{HUp4RNc!CCW|4w zhwmf^Een}?sL2EN`9s6z1JYowM8svRzIbKXTkt7Ig=DAb(9xW_ym;i5| zy)Ox><^%IEpuoR~8I;EkEa62g<-w4`?>w-PpotsTwB0rdqoqRO&drF;=bv;JtXVWQhr*9ZUGONz+8u zII0$y2aBOdYS!(A2tR+tjcN2g|60*3l6nzSv#*g#=~U)W&9I=&%vxx@6Ni)|Fx@p| zi3G(ys8mouv&^a00QQS077TUA#Pob|S-WLsp1oFISf`(nBpe1lN$5f2!*aTAG#-J$ z^W*WIv?6DXh%mGUMgY7hmMimMB3^ZN=D|GL1pW;S_p(j^9?ZK{>{lZI<^z`S14?

1xuq>%2H!+`8Xl!$_gcvCajkx3k=!$&M&%!+46FW^MQw1h@iuU)r$q6>fQ zum3FYzumh1`<-9mN;kLe_-W0CUk)6Z0O0T6zkB|w{?Pf{W0$keezfH3m;6t^>-+xJ z(O>U>`7e120Ow!c`gz6qcb9Bl<7<>+ZCBvs2zqg9pVe0+nyJ^N+N|+blb1p|0)9`G zETg;2myck~V+j=g2t*Ep>N3^ZOu5;=9mds2G1MIs0qxQPsV$<}&OBHwe^tTC&j9)2%tp zx~Lp+R1Pw1xk%KoNuqKk@kH2{xVOajldlMhcZBD^=wKHXj7#o%~Bi@lsr zU~q_J-tbgEirBM-H_SX8@u{Hr)^O1|yn(RfUcJD#MdDw^3t21(DiZ+nK}*G986A*S zU_LyL8=S$Ink$M)=R{-)XT~$46L^u++GA$YXUEk3{`=eJY#f1qa{s{}zyE$~$6jc$ z1K@w&wC&dRJTH1ljz6Y;49r@7Z$aSC7 zH{vgTy43M0aQpUchy@@9o4nxwgsaBOE>R44468Y= z&6al0u^Lw??~P2`h9 z39sb)!V*_dnP0~DY8D0+vx2K-Avx@zr4nF1xQG`ruOlXp8&-w{r_mx(InZ<$oz9(= z%AGNn9y5htON`t=ZavAL1E)(U)xT{p%kA;SkJ!C6q%{58fUg0be7k zFIggp^T!)_$P-}j^Dmdqe4Kgsn)}HQ6OLV7c<_qbu^Y}OK6M2<{)x-ck6r9ffvYoC zpIo})eCwLaojY&HkAAWI!nfN#`svhncRs%N@D70Vrd18#cfsT7*I$3Vd+*+vFF!S( zJzBMMU|PT0+5B``sHaVo)^D1om8bNY6INKf3{q#keISaXMR#lCO$LI{P68Of?~6zi z?C*ZJ%{1v;p&v_*%(WV!CXZI^G2oN*)<~wxmn;YKA<1TObZJ5hOf~VV6@@h+Fq|eA zv1K7$vsftqGI#_m;29E`2DD2Q%!Q;6Fe@g%R+m<)j!8$JYx$Mh*m&-;I&*vq5<6e$SI@=jEFSF~^k>?CDI374yuv{=P>QSFGWI=Y2oRd z$SiJD9w%@?JG9$HXA7sLbE4xIGvZlu=2B@s0A>mFa?*O=X*AdbAz9XH zC9hP!J~Kn z?FYa3?FWDO;~&t6!WR`L0M97W(A5ZjGE77M?Ze-`_Va(e`p)ZzUVGki?~?rH$c%G@+5Tc7+P+@`^ zA`3O5%b7M+DSc>0EkR^wYW)PR^~jj`)ln`(9O&Nn2qOH3h1YY({3!MXY+IWol+|I_ zgnftLNnuPLOV>RWq|_Nd&V2WXk5sHBm*~oC^gRRK#9rt~vAz=l%M(#EGVC6=L4@Bs zmolS?maiA~Cb5ype2m1D)T2y-K6ZT?XkV8$s7W5x#J39*yOiMMQ~C^-%oE^xbM6yVJ*ienNR)Xnv^mz2eyUS}1sD z-g|5R!h<7wUq7?-#<|09T|D;ol~dopzVd^c7v8yZ`Q7KQ|LDc@Z`?Zk;M$?rZ|!~U zdg$Rr*Znhw=Z`Dy9OB>HH+g->;N?))1#ib;7j2(8d|m`{>xiwXFQdBaaD-PXk9Y|| zbH!eN@h*3?U-NPDK?;Jt*Ms-lVRagW*~3hl3~>9~gXOz}@jf#OKTPbIyBAW-7L13D z1Asrr?x^&6%k-uSy(^w;0K75OHx82KO!cPu^eHojJvKo3C(K%AgeLeia!uaJibSo46}4Tl0pw<{Bdi{r;n)NvXlC> zNdsD7K4Dm&-YJW(od)2+5OJ#xHRJ-qLS1%=IJeo9(`3xZpDrkq{`hk+|33^odd0u~@CWyQ&W%W&cGLV`YYRcDQhL{E1y0FT6NTzc|*js%kxEsyS&)S>oZ22vUxVHd*qr zp#GGtaKD9d#9MgKzHK{nR#I?Y% zh>(V{KbYt=qZ`o({1#n=Nka>H7`sEH9iA|kHr8uoEC!=>hK;nt7@Q&5?PYov;>9Mi z&f8%0SF61Z=6TSoO9p{Do)Mz@jo9O54k(pN9M@yCZyfBJ)7&1Oqm4UQGoF+>S<#TC zp6i_nnB12Iri;e@9cR}RtYF{E2+MV)qmGO=V?nPK&(drH9!?>`XAZjR`rQPE9>uclYv zTyt^n(p(}RcK&lA{VsooSgs}z zc@&b6k~m{^_qS-+r46b9fYcI9_{e z+pRZCZoFD{@#Uy%Z-qhdfBnt!!2N35D|GA2G~0vX?T-c*Uh1{pquO3x_xTX>haPot zZx40dY#+NdEWIt_-I?gU*|ZV+{yQzgJ3{8lO#5ZU;AL&?IWB!=r0CMbMk~EI*?U1p zI4rAQaWGF>(hdqfPJY%gN&N{&)S?u(S6994tv=#O*r|xvwcb_yJ z?Lum=jtKe|eP-BuWAX!4<1SQEKEF>lvTGS4{Pw*+Y)~I>5eC~O^9_7|hsa;Yac9?P zpehD27F3~w88H5HYgAoRfubgJUbU`g$cK4&Qn4m(N(1QwQ5;32REra;BXl~zew$Ps zq0j;IapQ8hsYISJVG5kQC2>-l29mYY%IFD2{ICWt8#Jwwo24l5P_7G!v}r79Zo4XZ zz?cC$a}04#iz&NCm0hRKuG8jH1bIbrB;kk1BNs_YMPfWbP=KGb8g_j?V7~6(^;H2L z{T%=F(eJZMn%KQ-0cMNCvK0*?g(dZk8UKNeW#0mOImzxl5&0m$REC} zZ@eJfM1Jk+T-SM1`~iN(Q7P-Jt^TAX`;ZWKXl|p!r=8T4AF^%TArIfBjM}LxKI{VW zGxzE@l21NtqwRKrPAe+#0_ES)enZLLK;>S4#=L2ZPw{L}U9j6l3c4V?--JEt_P3g~ zB%h<&YcI7s3XQI4feu6cse#B;@7SI%w4&`Fq2+VJq#vmH|C=OC&F=7}r_@AbqoDS;%+FW2V7R($$2g(Awz8l{k> zW#lPXi21tH`#G-vs|7rI10VkWXZOE9arO1=E3a>Z<$4J9!M6kX$nmYLTW{80emQUT zQTow`Erxq!6nm6>*F(nQO9O$IT3~q(X84|m4Eqb5&g-p!Jxc!M?J+5ke`l=!b_?qB zC+`k%Zpg^z#%ZflKz`$@c<8dO`Jxyj9|HciOA^)@UBa?3@u(l((jj}zvX8jm9I-=!PV6g|Jz4W+lzHq3ZlAEz$=Dw% z-0j}xl~wHVrhBX#ncr$R#yCu6iy=s`!6}UqA`Qb9sMdKJ?F*IGV6w!DHhA8MeTvjt zXPr-*Hlf&KM`-*Ci}qBpv(6N%gHDnfPl?bO8Wbr9W$TQdX=L=fTG?(wg{Gj&fEl4e2d^MjrXo}*Ygnq*7Bh(`A(Nye zqBu&TK?{MR#F_{weTNJ}wJ}zpj2Ee+CnT{GiWn$*7hAv*dllHMBzjhtST~cxUZ0Yr z4I7f6rQDb)txg83iRrZpSXn^U7nnj^2^Vql&4#Q}F^Q=nlqrd@N?)NRWY59{6y)&9 zIa4_)gU60s`W(P~-M{6l2t4|kzWl>md!FO%dairl3k@e8Hm<%}ckPX`8*k*oQ{eJz zVK-hcxb|}8`TJ$TI}FnUDuz8m{zIDQQT^geL!SHf#(Si|qZZMfk*?eADE8fVI>6_1 zZcoUc6Qkr0+-;q^%jvx$2If%%^6OSbKz`e*6eYj#!dU+mZOKVx(lPP69T8QYu{55x zla9zx^3#rr>Q6i4cPM~)%)squEtwcceWiz;F*{^2yA?G@d@(*920S(Z@?!(WlD+pP zh}F$}LgVN7DPz6b*EOkw<)4KqO`CL~4wC1_ok&vM1q-LGh21w?eX2l zLAyeEVRzrr_gJcv1eOVy&#Tq9js=)q4&WSP9vif?eI%+9th>iu%P>($P!FfaD*s|Q zplr3CJ_x&v2|QVJ&nyN!%u|vFo-mJVmVzR6ZkHMd#r()*UIjZ2SEUg9V@W3G}q`gN!Gb6o#d7kIcC z*t)y_-l@R-=IsxA1NS=hcgf(=jrYj&FEI{2YCQdN^U4F}$_uPLtM!JfH1kVT^8+f- z4naQ3Ka2w}H!r?8=z4+Sd9H8lMi0vTCh}!>Mbo!OdapMLZwpzgTnu~E=d&&fhpy<_ zSLKwI;i3!UeHZmP%aSM99}_1Y5tf}cH=S|NkEwA7xP;^Knq#)G#q~z$8_7@DqbWb) zt~l(*@77i9b8LC6(~L3?z=wG?=?m7fJ%P#vAKhWAvABx#&IE~m6YbbGHO1m7)BB*A z1h_${A&Z;Q&{gg_YpBMykfpH43QYh^i75b@^AOu_#6C~tWXb%gea4kx!Am3DM!v6n z!kN*kPjA)2OrFX0wn~EV2&mx)2y7c9)uwhEYWaR_V&ALU(YG6s(`i}!KDBoNm|rt} z&s4Ca!HA&_gZxT;?}(2=*P=!%QnmJ)!j)PK_z0sZdP0T-_Kz)kT$0)+iQ_(Ao|prP z8zt)JajlY+E^Sh~GO1OW-K$C*HzwDMQ=1j(oqAwCy+XXceNZmVAq#TLWd*hJta3>f zg$HwZ8YI{j$@1|+N`W{(b&8xmM@<=i@4fdv2Qy#mZ~00CkAAYh|I=@-yc4j$ROEhC zx#Q75;C{RQd9vlvx>tvR={{BWe39eUh-`ISbz{tYXWV^n!gqhf|5BgtVXNa|6};d3 zVypj7um19I`}wAg*uxn0`O|l%WOs#A*V(*lVwl)(#2zKT=7OOAlD1=2Ua`XIzF@)~ z5^lsE#z212Nj;EXbW~NktflTV!o(f}9veWwAF)G0KVWCHUTl**s;ymemRA=!j*s8$p5& z>da|_*19=(1cVxd{#K!f#c|>pD)8#`pHBb@{w}xw% zrHoi|yR?u<3)yw4EwYS0RT4{>+NK8Jp^T4EuL9;X>(n`AvivdytQQi>q}h~7m{}l+ zy)UoOlXmUSuQk1&DR1L$d8nPq`8W9;mpj_7wIOz;t@CR8#EpK*?NQ~85$Ot>vog(Do#WmR z&fO96?#@l$nVz^aHhg=i_hwhe)mF~+3E>s~$XP-D*hIwLE%kSa6Bh)t<-Yn6STUyjlqh6?coi^E=7evl0ilLns$jxSW5aiVws(Kt9qdu6!H}`uXn8$!8 z(=>^r>TN1RI7gNMm9Qc$czE#kQF84z!P)|z)*R24!<8p>$za7HWmuIorhzymWkemv zgX%pcj%@eA{3xN1HU)q}O^(pZX&HUqv2Vt83zwFdSD0e)h^Y(cS- zRI19&o~Du%q&z+;b(WGo%cRUg6!|%@`TBm#*9Lg>BYyiAcelUN1m9PGe(A2GH+ z81mhJvU&&Gb-_nHqFW=qH+sc)#k?E5_RH-ZH#)lRbYa$cyL0v?zkfy9aYj|NJe{{Z znty6C@5FfCvGIaqVYGm+o6j0s&l=lSOzr1P zZKw1tM^(%nQr000bJ<>d)KPoXRddu?y^L7Z5l8tESMdQWb%&vPr?b#yiPS5eh2E)7 zq`Ar_i*^|+=6%T;L%BD|@CEZMcEr!?)`eeC_){(RYIiVNq+QP~7n`!wj#}4ZtuvIT zvqAUBD7g`DbTRFr*xAP`+Z(d(AiKWNypS`hE10r3NqmeMXPZ0(&7MnU-D!OWjDNr$ z#zh=gqaX-)Z=@Z@#C}`tu#3Pl<}nShR@XA*MpnPewUina+q(lI{LZEK8YQRN*zWF$ zK-Yw?f6Cj#wzsx98=CDkOiOu{kyfgTl<7cnGm5X-1{?1(eG(6N=-QX9gWrp;}0xi({wO8Q0 zG$twsSKpx}^c!ktj>lXRXwo+n5fs!!?#(Z9{rD4niv}2aX)OSn-Dx1t` z-D^pYr@`?6u&2 zk5uVmMkSFNQ$n8e)M2My%VX-NFAHoZ?4-!O+8Tmv4Qe485D#RBuG1E%zH5oFh8 z*C+^eiVVpAEf;4M3t@_ZuhJ08<&giAm&YgP38*wp9)7MMn*$4nR6LKII!8$vbLw}0 z#ohIv-v8j^Np(;j1F{w%T!mizrqfTkqpu(E=*|54_aB`7zRC9BsRUaH_yfrL{DAAl zdLZBapw#=~RR6VZ@a-u1;PbiHxviI4F`v)AJv@0uS#W9!*lt->1NjxFg`3Eyo#l3% zv&8Kc5tj{hC)_2+EYTk$Kj(<1_J}*oE2HjnGY$sx_SiO&AGW9=?{kqt&MhVlKH#p| zxll6iiLz?L>`1d@)S$}}Wx+>uxIpOzk#o}+5(fx9rmRj=^OP4uedB=KXo9_WQiWz6d-lEo1tP7%4~p5m05AqtTBc?X z_-G^rNYh}7`k=HMJ0{2Ud7v;B$B{>|h4Dj3NRL`luX5vZV|<$+tx1A3t6saE+*T!8 zCkHh%PzIY*qXfwvJVTCWO4Ddue2o^ECs!yTuqP0OWRipgjvbiCP0%vAlsrBGH(8WD zQ%B={m7{v7a)ko8UmiQXk>P*8d>xA6e(GO*SD=8E?MA-2M>-vTISd52v9=E!*!8x$ZNq_o~b-BI=L!odFoz$upoTyo&5h*Ju5GZJOScEX?Pq!{Pu#v!&)7Mi zYOnzFm;wCR76$|AB;!xe8sfBuI{!kF*z_^xO*ol_VGGsj{rDMEt2)#y^+NB*0#Kyx z*P-!n$A}iT;v~aLz=puFwj+i=ZFbI@4oxht)&668mMgD1U%@~{o&mo z0Rm7J3xiFkABTM7D_?Km;Z7hY_v)(`EY}3an-kVMBfb|$f)D!qkD8nhN-Qst%rBCH z4_darH0XJ*OLB9x2Kz0%r#qn&?8R(*zth4er^>w>Ir)xbI@ zL&=9%nlET*%i7pOaxfr2dyV{xo`Js_z)?oQufM9bsCg6hNtW5trM^nfoW zq}j5d2KZyP>uQ&RvGylPb)fD<2)KcBYRD4-0l!_F;IUNA2ci^OjCpi`NVRKCCEg%2 z7%Fvni_FeQz6OfPU_GzKK3{7HrcIhQX3o+?ZkF7i+^s2`b~Y&%n&iRYq~g{v4~va~ zoCDMXeTmtqvXm zaD?6XidG9S4@=_R_d<&?A2Xwg z?+~T;%9F;l>wX?2D<3nT+9FTs1)&Qm#Pj%OC5Yr__8D-{MiP;!g-jbGMDnQWPENf( zJ#&mwB7vwruUef$mk5_oA`>oE*p}ot^GJXUSMXn14*ZH0_Y8_Jp(Mgafx<5_3?Jd{AC{ z)Eya6KdFihX`({flKo!B!9dy$WU8OM%T%$)w<(1l$WL%v%Xfq-7K6zy6W(Jf@%x~~ z;>WRvseYW=Q0nk9_4YcAn=oa7HWE<%h9;kGJVNq0sM(eBT9voKW9q6gYh%dOM5a{?7D=7MSi7@<-}sMO%gRfTni z+8#&akh^}!UD0KklP$Ck*oY)KnW8MN_cps0-k~ohlp_WN=N&GxKA2th$C}OxUYQ`Krb(WSYq{WZW zVkRk(J-_@pqUHS6{I?nS|NKwTvG~umykG$SxAz}xI{oy!_&Wg}y~$tv?t|bf?!Ft% zg6F5Cw*~Ag1Aslc>kgTA9oJjiR-2noH_Tj|8#yB%yC|Q!s+hYep93dq3Gk;a zDe{+fRfpVIk<7Z!4{0Fa&)@9;@(H`Ggq_yn9llRYp$8RL^1QEld$2a-OIK+is*l&2 zXii_PcfQ6Mr0Sp~R)@;D#`8^(En6bAH)t2?)j_Jj0V!yk;h!`$=@#;lVsd0d3pH5r z${aK?gn>5IB1Ha_X=}a6liCJpEv7a;=ubbP9mAg3VuEaWVwD!fe$x>i0aYz#X1%Vb z+sUX8?jJDgK|v-z9=CieY@FQyyq@jNXw#)+L%Yc)v#8!eD zSJI>bH@H?OGpNZLQbXk*zz@Ku)QQ(O5gB4M21)BOA`yPs40Mk~qIyW7uT{WI9#P#Q zi>QT?f?^F=K3SNZH4dACWU7jo!zZT9P;p#phM+iUoF2z1!wvrSx4->#PW}hKx)J#Q zSi8>mtRB#x{m+j+dRMIe)BC^Jbo%L+`ga99daG~$>P^jy5-5oS6TdJmzb)KIKFa^_ zZT8q@S@Vj#cU50Rnw?ARe0^S~U;- zVOHOHS&$@gsV{bJzSRpy=FM3_A8TzREZr@P_{vV zNAiDbCD^FED_%6QD5vL#`A&{KrpG3jWgZ?+|#n zRhZb{dha^#xiKh!hawpuU-ZH>`24{ueVEVhzTGi%XSVHvsNgiG<)Ws0)lhtjhpyXU zj=kXQblVwy)m}s4enVt{7rR}MxnD^-tSLIGuR3O6o-o%PHPj#0RxijIOIF5VXYI1P z_L!&Um>aR{)S7}>BFo3&C#gKpSR!fI zfEJJf{Mj5EQxzIkc;6nKT^tcYRe6!b!<70VU+rny4RxXbljDW(80N#xGr@{3Ye}1} zZ9LdBJKwjfy(L*8^rb~_9ac0`X(#Tjv0lj>h5vI4@#n7 z?_F! zfgl$*O3#uMXA29HCrc8iXfZ>J_CtS(kgw+JfBwTC(3Udj0^fS;y!dR^FLES6E5J(mnV?p!= z)pF*8Of?eNV?q%C|M5}bqfst-!o*Mp!Sh#2y$q>`K8Zy583U$P*+S)*6P*d8u`wL+ zEyix%Jgw1yrp>~NH=$Nn-Ro+fLLLEa6TYfGJD#QL;CRRkC8gHT-0r|oM+e~Nllzr% zld|M#*mc*V*r(ffSNQc1;R{bl@~!LbZX;(@DQ(>N>Gke+;O7&@bjd?HjQK>i4p%yh zZ&IW|G4EQMU?yYT$wO5fGHEZ7Aj|Sqih?RxW+6X2e-3~zDAUnsVg&d+AvsGx&K1)M z%7Ro5C1o0zCnb#0Vmb6U_GrUbrNb<&y8q_xJqXu71=|14m;RoCM?aSjK78Nv$coj& zuET$)O?rS;(3~#9<1@3=c=Svtvrs?ZXiYOH)P+wNft=Izvx6Y2C&QP;qb4VYFidN{_8$ zHh^cSQS3J!;So?!XJ8I`+Q!_S1Llr)b4#aJy zlX+_Bb&F!pw97Q-^bq7X@4XtYaw6>_*-BieEQ77X1a=`+9@}w9ZIVG!FtR>ZFVAUJ zLuwErn9r2J;$i`GiES|EmdbLgRB(Avx0_cg$|g(z?FjQFDgs$TB#J0_2?@sm;Hh|7 zURYmI$}}})iX1auoH$;aGyYZBjfWf?2-?AZd1~dqL-nV8@83W0=*_?K!OO~Pin$xp zQ+L??H(NmT4&r<$j2*b$Dt>`0zBSi-xqIT;MAyaj-S|%=AD#v6s|L~`P2_%A)RH`E zpOk*o#ysvSIHLQT(*(-f>^B%s^9QC998>+l%38kF2xh<{o3L^UcB28VKl^= zO=Y2AO(>XcvxMt3PsleJYUUTxl;&8uB?c1dB}TH*!*b78ID!dsBMLv7LZ3coMzP1> zhu%^(#`y+Ih^lg>gP@(pmprP=8()`&0?`PXz|*W=sG5b&uv$n*B@NkHAxWmg(839J zaf3}9AGOPbbaB|gf90^dY0Qmgvpmhbp=H!x$uxl+Z9}WQrO8YtNFm3DRc)dUA^qM_ zOA_kP8A`&eGHy-<%k`03WAcQmYw0{h_-zM&k}q2Vua0Y#C5;)<`&76wJ>ukNO}JJh zu!oHXSdE8r-Rx!+RP{mHZ*GGut5ciNs7z^5WtE7~HN_mN0KGQItIf=qB$EV0x*TCX ze~wI(k@F-F(v$IG0G_ywU6jb9#Lben4HPB9LVfQCAAImBocwLR^v9q46b1f+wMH>t z#((;c0z7+Ql@({op*C(m(< zmM5@j{Kv^Bp5k<0w59HqVZI&6Cmu1?9(B?Vo3`y1!|U)0;J=!gPExaD#(=v7ka8~m+vk5H1peK4gHK7p2;|kZRQMaE8MY6uN6neP@$VcFp znv&#}a(l4WHBZplfubzEqs}oOKcnA>{W|=!CcN0eGA}mj{3UZH8sFKh43^KVbG}}1 zvQ~DI(Pb*1bT@K*kh_9(lBn00_Bq;TLS1vArYRq#(+K=$HfmvFU)f_vr7r-j0+yj` z)K@@L0_~`g3e`RRURI4UlO;#G);|UwnnT7w*H@((Y@0-%Jf(;i>tf{k_&Hs&Hpukf zqg#*2j*CjPnq*LslT%R}y%GQSD7A0Drp8;jCYxR8TP3k;|@XNUn z(ys&0P~$5VaA6R7;PJftOkP2Om_`%k7s~T;xe(9O2#VrdJ~dB9$8pHp#*32#1<&+Q z!`b=Iv|YV;>&pP_zxp#FVHO(2AY$@g&cA;k;L#88!$16P`>Q9a&&~8*QxB|aN>6i0 zr$;Df$0#e~O{;Sw*CaDnB-7`_?fbZlQ=;M(e*Wq8m3d@weuc9>&tElGpAt46Gsf(B zDx?SZo^wdYJnpGD>WJGbiQg+@E&HMZx{ba)I-oDv>#NuoND3Gr;7<&h%D4N&by^gA zgnXkR)ox=f&X)yzv3exnr}}+`7EiRqfHJ>M{+in-8o=!@RN4bnle19iK>Mo#?Fn4y zJYytCoXuMQcD}Az<|B-1YlOZwfv<%hsG0F*b?Bh+`$p!G%9Ac*!(6C(#I><$6z~su zEad~v_SyMPZm4eD(+-n%mWt8mtnaX6sAB_QKBYyBo0dbS00uk^q9i(~nZafyQ9^AB zM-ek9ik?=-jcJG~gXXjdCicTiXGu~w+8zVfr^y^p;hIEAt#Y(|H6q0WouNVN;&B;o zGKkm}&q0m=$~>uDhJ^I6+>kGzP-H|hl1@v`7ZUTP3o`ihd?h_kOv)Bg;yILvvBHEo zN?1QNY?Si7c9&xD%R%g`^;7@hfJZ<6pZ?~3?Y+gq3*$u>Cwi_a2QO(V&rAdMSQ#t~ z>MluIj~c7?>5C4i%Z_Rr&Y0WJSv$|0J1!WTE@&BN#4Tr49mkdQUD7Q(_<%YRps_ro~j@@uVqC8<|HOK4333!LuMitS>WpSz1S_+)=0YGo{`F=p@yzwGWH`Wn{Jz z8Y-{JIj6IYkZ+UUWytO{)pGppqWM-~sASXuu{|myfjk1b3~izyt;@0zdu$B+(>u&< zBlg~5cWV)^SLT+;+#ye`nc1krlf{r=z-Lv2jbdu0}_sh)IQ-{7f#odIC-yxv`<#C*HE|HR;Km(z_~cA(msl-U0h_A!s0z0~u*!I;b#9pS3iq11yoZSsCi$+20H z!rmecX0>S%xrlaCW}AWBZ*AlS+C`x{u0Id@#dd_Ib1Tisgf`sE2X3mh)xcN~-#mS_kzg9t^b2F{S7d{iw9FMgN<- z#*!5BK#(Zrf-uz-EiaK_Lh=odcgE@((!L}RX`(^0t1J^E395rNtd<}a& z@g@_rcX&Z$GCyw0Y|^Fo7&EFQ8TG2XdIjYC1NIp;5?H#+Y&YarDWH0XSS&%afbb=9 zQi(V#Z;D8j1MuMFX+$9aPs$ch(&q|O_(d5~auSai*Hf6pFTwGM&-NFGj}?XYjWWgG z0RIV(gTKoU1)bPeuFil)Ek-^FSGHY|g3oU_Cjjy*S9u-hOlXk|6vkyA(J)UrE00;@ z_lUvaqnUOY2URtP-C-_y#&#Pd*wA;m!(9mW*c=`P@gZa7c3+0YQXLGI1%fdKW4K{m z>zlDkj~i8r-HMJPRoOwq}mIuFu#r z=7N3_^dxl{1NO7gL7h0Tlp`A278a_$kBne14xTvl$W6%&Jr3 z8)UF2mtG-)Ch+)LEdY;H?@&Zo+Fw>KhnO=On=#DJSE&BN?)B*2t$EGZpW5 z0_PC4CxlFu`-9MP3bJu9^3mORn6z)z>Eldhz#iaFvslA*Pvl&oRRh)IqZ-{HM4JJX zaE^JVV?IZ2j}aI!>=PvBdiz4wv2eUB`sKuv$wg z*EDoEdPe-sqb~f64(1w2sjtlt$(3&tt8fCOu>|JlV<2aC&B6uI1H14ey#I^+O7sGO0xZ$u`g&wpg_S%y%68dC}Z5s*?}G_}Mj*9Huy(JcAYL z0`OR>Z(i06A%#Q96Oc$^B3(_(7Z8&n(?&{5nWLl$0eDjQaADL$VbnBjTMuo^5cQd! z7X0M@?4RG?=70YN_@_Q+$jN=~-PhaCI_r*`DvxN=_Hs*)E1OsJkbc*HS>JY1)_z{v zdrsMNQbpM(*(iMzdq|piKw5RuUU$NUKcI=)E37>3%-dl>eLD~y?$^@x_@MJdX3)IF zt%TR%aZt9$pJBIwR|jdg2)%*o^D{#svdg_yqg{9VdIKX6N|##!a`5W0LL<%KW7_7+ zjQ)7OAx4D2pQUs**%ng9jetFBDIAklXc8)1o8%KpwvY`?`KG%6{+Kd zDsb5=_aOH14&78D zLP&XjXW`YXou}5Z{ieVyQ9kU$*Y>X39s?ef8vD#&!Xc4Qe#ZaJ7mOSHNJ?d7S;E3S zHX^BXDx+~p`(32bs7LdR4vjC8Tb(_GxOgYO*J-*wgBGD>{D|QAt(Z}iS4N-J&_3^u zvvD)uWL}V%#*!mAOp>eP4*|Orc_my1Yb14qie^Wp|pSnB-~ zK5f2!DZeD4)>y%?k1t_yg2c^4j&z;@kGvAay+N&tK-bo5@{&MyCLx4_XNZwv7ZkJaPTj%7K zBf6rnx}qSv%i1kp+x~eXU{(m{VBcA>#Z6ctuSdFRMY*jOmedHxI8&j>rz|5!sqT zCc6m1_yr!D%%R1#Ts4mc#Rlg750RWFn<2g(|}`3khpw**=);-mpYjqb+KsBiE|y#g!*gL*r$#zxQ}L?rlDK zRL%nM%?YmuJEFnO1{SoJP<3+p^xbd9FDH`SI9Qb|#^&)=pOGf+kckZQw(=*eSX!>$ z(HQnMtZR?BRuK_~N!x{KQj{i!JVL4BC;-T8C+dVOwlL7Svhi4AG5_|cqUPHdWLa3u zlZhv3qmLBHM`emuhG!mBdKXvIS#EGXF?*VrdDOcZTO591xqg`%W-sf4b&v7$}4{R*T_Q!OyMqylcx^1_7Ca=blTWJYs z$Z%GP$2RU-E3M6RT*tRBufGK`PGeisigZ!i#Ig}UWv-+^_JV$cN ze#Jec!GbS2s)f)CQl8*ZuM(bnj%*YeK=0}m4&u^olRzx+OIaRu@|uIn-}oj+6&((* z#6}_WL(7R?>hffnn^a()t*BfDI%}cd{N5*rL2>*se$*DPyJLY-z_k7+N`;$Jz0t0r~DScO-WR$Rx+ zuX4nAOO~D4e$EIwBNxUFm3sY-UiB@=73v3?e`+v&#G^~5C>8tW(RkMKg1(ArvjxKL zJ8&`5npy!%ju|o1B1I6jLUj^}o0b(jc~D0LzIA)TnJv1AQ-cXVF^-eWL0aeDK#IcT zL~@8DDNBB7*0@}5opc78A-M$Ou#*P1VMl(eC+2r=$PyjcsDTdH-(9^oWgzjdB5%uC z;mvc{SCruQhbEwNTBlh~I_;e?#8nRB~$7YxvsC<#3hMnZe&yl%r z&GV1v=_}Os^~<|`*tzTJbT>1cN>$5*85W~^$+Uj4TQQs~p0Y{pgv7tK2rug4ox%)$ z_Tb>s7S8lHS1XN%g;cg0g~0oEBcehNme9-sN7)=96I)d%O%eMj233|z`ST^gaaHe) zpS9upc34oXn)oLZs{F?EDZB^^FxkRb$lZ3wYC1TJXxPu#H59^STYIxcO@pLURZ*z4 z|Kw*JqLnMqO|6O(x9y^HlkK1crAwoTQYA&2Hv-s0Y9fOLm*N7$?C{_rrER#-9&qN2 zw0iUix9Oy8)oXyQqFn3haxeSD%JtUqgUJ@^QTe#YRQN-jD9skc1~vWj!w0;4bY*E_ z$I5@GXrbV_JZpFSY?rY;X1GOm{?i<>HSdBz{jOG0$fhrGkLB1f^y&P}Zp7#8+4_B2$}`pjF5 z2l{j*ygKWS7r)OF7HdmZb z*`y_f6u7=v>~WtRA*I(>=u#;%o{;k=A^~G0?vF@wjNU~x{4WZF&xO^ekC~S;P)L`3 z68omf4_>%UvY*~FN6L*g;d48$qFECFtH?#ttGbnx!H#eI% zX}!HyXG{XotM5M8`8{8Jr^yAK%4^PdzNZ|(yXTYwl{y}vJJ5$5N7n5xo3oMUg%94b zgvRo$JFL3fL!Q87kN&o|WAW=roY(Y2W9(h(B|$zApX`Uc>IJ%T^qW`;snm~x_4{UG z$%x@}($t|yvukD7Y%v1*xl|o~XkF2%a5_tAy^4CMkO_nsK`9D(bQuRaJoz8}r$bOt zgw#1v%~B>1iRF5GC%)(uDh}|{FJ>&The~AEjrvm2a>R#x{c;)@Qn@_Bsd?ou=K z={HH{f9|19`0N$4v`ZlvXPq)=Nn?lsCz_`3yE-=1k>;@7VmwXhdwv1RqMQ(hlhG}S zA!iGzlIbQTP?~`6EKtv~<0&GEP^NAt&~#3*@wr@SID8^<7!aU4Q*cVh)Yz<|uZ3l2 zlqMlRpKF%%;cl&@NwS-FmijiD`x1YBB)B{wz)z zSQ)5F9$x-+bPVei+fC-L$>Y1p>vHsP0&Voz(8~RoPOhQ%UIv`rs9XOHsc}X()1*dB z%*fMU+rz5r>xtS}DHEKI)`D_qcU(%6?uKXaY39;m9+Bl5ndXY$aKYwmI3CrA#s|e+ z$L$q{3%6~JE&}7_QfTE5?Up5`0D> zp|Q4+Ez_iN4Lu9bl<=!iz<}o+&^!N^$rtkNNa3K@<>RBy|EPlv&jSEBN31gqBR1_c=+m)R zxmzxum=xRO2zs@XKWS(G>-h&bJau}zIG0A(*By0r4{^r)6G{H~iZxW8OK!u|aO$zz zWx)U_9%wN7A!~n%-syBtdx03&;H=;P_LS4HszGgG*;&TwK4qSB@MWL3CViz%ob_>m z*do8;j7hqWEqC014`MH_SI=p}-c8L>XsYSx;zvI-$1#56JhsWKQMSArvY9IWy>2p{ zcQ7aA!s0{Z;v2JvH%Fy*&BnX1Nd2>}Tnl4_9}~2ff9M>{T%@18R52VN2@@A6*`Q#% zc)%=9`@Bzcc|MgCLa;u2jM@fRfM?Z;Wi?1=vbyetNAv66^J0-wkUJ2m__i=IyDu3& zb4S)EsemaSt=PM$^OTcY2y8)7wD^$eePlZO>n|0BY<@xT85QSuE%TGe`*2>Nq1AV9 z=gW*=n>^QbKbtDNVS>UAn8OtAB9nsPZfCu@Amd&h zEJP6bJ1fR8J)**wG+7C#rcRB2~u>u}j#+6e_-= zUB+!|cW9b=u{kF{3k2LNWumP&@KZ~#4IJ8WAnTmo;4E-f&!7g=5ffRl9pj6 z5=m5zs>$L<36sU~JaG^T;L9bU#QV5n0X0+kL!pQIpxmN$XR{D~(7lRvwcc zhTX>!Fu?e6d7|ZfN;|o|s%fkepK4+Y?NPA(TBF_+Tqkd-KdpOjX6~=z{GO{G9|_-D z7pwCBytMoE;eh16qLI)(;UIESYK{6Egtp|XLxU$0HOR|)MG~L*!A{+=b{OWgV74@Y za@M5*9&^2_hW*N=_U8PnsXjCo$i0?4UNrv3t%Q@0j>pc zuI*(XR!qjrsMc*;qa6!sp1=9y(o% zNe8w^B2|ep*r}V4j^y-FpKhEfWmgL({Op=YSmeh9 zFX~!JkNARizEw45lR1lNFES|Kl42ibXOQM+kd+jWv55)j_<2QySY_9Q#MZdPR{6xV zFDBg|DB4HQFWHJoae9;DwmH(;eK~E7sB9gne2;BLknJSmlMeU9G}~qwm4YM_FA{cj z=eVGY^j9Xtf-Xx)(f$P5Ie8rv5f8hVgp}ByaB>7G?(M5+!@FV99$5+ za!6SYa3I43X_J9$)`&Xrwf@CXBxx2Dlwi#Q+SnqQlT^yZ>$wih@gnnRH{4?3Q=K|A zGr?w!JjfVIUUtirAeR$J9l9}d%h{l}E@fIUl*_yV9t!10{gMHU;~NC5XV33VpZx4G zMF;f6WlK`OXl=Y$mA^$XEDLXX|0!R5P_DX7nd28f&Bw)t;GeSRK?4s>Ee*wF~Hc)|KT`k zkok*mWKw6ZhnvS+kv*%8L6*O(ANdAjnPHBXU>92~V_4^?M5lGK^-48J{6gIX+YVJ| ze+iI<$?{U>wl;g&3KxQYf9VjNR9RFYGGe1Us^5FHt8}!BOE$l6zA!7I_g4O0;&%{3 z-;kE?OE|BYA{H#tmc`E^I3_^w6@xSTfmSN;*W3Z1ssr(Akxn$z8O2%c8Njyz1j#Bt(_#9eVr@tKm(whzKMdM2A^jdk6)GfiPh!KaA?2tUjYmJ4vjMyjdQA6w$fU)`#dZB?!gP5 zWi8h~u%bi+%ylTLd4z?;xO5%N9SNQTf!9rkt5fqnc?pP@l`>_#R+BbHN zPsmaSf@(vITG2Y_r>Q2{MZpdM227HxFr&mJN%m~z|5Wba;5iApU({phjO5WuzSwuk z4e^=OO};j{(q@s$_yqtePet5iM%fFskr;A;UFOM)uPoKo+j7cwg`~xTU;2m_j*z^X zC6Y=C=WL~1v~2X0cpQH&SkWr9!D|eaE{x(;7)JwU$fH1>L2rbN0tUZIztA0Bb<&hv zNq{E@Ha@M?88HcJX9aP;^aOq%0K6a2)^p@b09<@ik zU|y3oGNj;jJo5w-#2U+b2Teq4U*`b`Fg&7817sU9IOQ2H)X>DJ0uT7;s!=n zc`km4JG>f%`0Bw{%bPPyJfyV?P8Ibc&QA55$vKS5V$YO69AtWsxl|$|UMKeAd&l!B zLn4$FKc&fNPoYKVn*7Tu%RRt3q7>wE=qD1D_evFa0-u8{UJzT6FoR@m?_7+YP~UaB zhqo_(ycy&ye5T#MHNP6XF@4a#VSKG{na{(wqd|kLTCEX#uKcON?{8xgw=h64SU>KJ~sJqRF~Gb4+9vY!=sXsvENjXzx_i-NhKtp;n|YBiQRepqkV8qOo8}jC6e6itaJv3aW1;U5#@Tw<4dr0eBsFOEcgHK- z7oYaP&Z5GOHTT}ntC!8rD?NALNBZ%yUwDSU2mg83jcgS*j0Bal(+=Kk(LON3MgEYu zh>(b3k%;-h5?u@BQPt9iNYF~j%4Z>G`L9C&;6==&%_ zsZxaBCOfEFUNPQ<%h?Ops9{()&qM1fZ=4NTW^9VUuQJSN$!ZZh9bzzs$;3`!K=_ay z<-8xc>ddj_%t7pl_+*+~mQ@KOgl2&SpCTI)sYEY)G#l4G^77Yp^&6Ce;0m!x9*n{Y z#cMS{7!)Ie_`Zz+)0^`Vt0%qii7&*ox`E8*`_Wrdg$%dbDN>%+)2rmo_Qbcm0ihYnxF?6v`&J?MQog8ni?sDQ%pb+oWUFvcA!sEyEHO-_$taONZblewW*tS(FsayRkhSZT zKWmab&}C>*iM&kvos)Wki`X0u&FdXAr(ffwSaF$BDU6OMO6nwg%;IzqOW01Xa7r`F zP3NMjjKysgnazU_$T27HlaLP#JaZW_Nu6VE=rlWjS5~c_g3!G#O1%qNNEGx1r*Do& z0=CX7*TGBAQ0Nb7Qodax0UpCL{{E(v^LSx1EN9?$^7AMLn0v$S4)Vc~5Fn*-%eU=! znHtDv=dJvF>d*DOmd|bjch1iUt7W{ZSpa>q(R%hxg?~O@?f5HH)KpxS>^5hZ!|nd% zRn61;uv(bjcsDAeW7*A3@pt zz1@`gv0t&scXV45WEs_F$2UyeSuz5zp(}*R!*tl&EWOhuat0=yM#DRY56y6~8Ao3yI7~AdYA^~TA z<=7YXeiRk9%@Z$7F!N1_)8Z*%RTR1G8xOPl*j7{91dn4%1$+wab=I+%T4|`d>9hU# z?P`*}X48B&PR+sYrSIIFPiqXn$p9Ny)MXmtC07Mu>+kprdQ4fUElv2vO~lDn96a*| zr+<|q#o$rNc2@WK-@D)ceBVFFU*e`=zvFR;b;`k+4pYYNIq2#FgI&f4cC+wkZ$E2m z2q#q4<63!UUJ`o_6Ss>KTPjXgXeAw1E9rhVpmWBCXEyi68w#ECsY4IU(>fGGz^c9| zEI=crz{V|!Q|>)!@aP(lhn^Yl7poqeddOi;`^6=RQ6kw0aUXL_)xvGfD=2MbVse=|NUj0K!f7fdea?5;n znP5~(ErRBP>p#DLdl02f!I$jE6$%M!D#gxon1K)6%R~hB{-j-|1&#Ipe#o4r)EZC6 z|0%6nQAzP*W8Z3Q*5`)89@QwrdF_!TBE<+#$4=r1*ARCWbjTDm3|ji>Vd}HJ2!(?7 zEa|{pHLrigzB@<`Amz^)FiByl&l{EL%)_;jjJ(GgZSc6X!5QW*mh8zvKTl8E$xc1U zCc1r?YV$9pc=wjy5HcV6G*Lc+D=?GTMDU^nv>uSEE^g%1tL^Hra+&VG1^mzw?raPe zoJM>G{E6o%-r0QaIefMwaVy_$cx=%e4bjkb(&{-jCvIflc6H6wne~B{dk7E#H_6Cu zmpIfebYW29PM##9!$iiC4vSGWu8!wpNJeH*>BOnJP{-~n_Il-y+v~&f^3Iu4tHEn( z`lnLhI+Rd%8P>6%#QWOt{v-*DwKr0Q`#nY`a|7$z*)xPeK2V`0HmBe^yT(O9t)m~- zF0s!$GMqzYvmmkz>jn?8+Z?BU8|dI-p)e2VD(mY<-|+!?X9h%gf%qMah5Iz+3=3&! zlnCQ#;3HCoC?_i7R|XN{2f}`n7gjV<#(y_XLF;)ox_E;l(RTtvQ7tp~An_yANuN5R z>^P?AxJA!qrOEP4dn*TL`~E3N`9Fgzb%yOVk5+7E@edh8KK7X5bdTy7-Rs7Pj#a5EpelENYtW@PVNEz@wDz;7Fd zqEwZ>p^y_T+%pCsaiUQvI_zCKP}T4sO@R+oR+Aql1t8(UaZe!)p9xvt7^Vn(V{e zb1gmh)g*NEqR1<2!@jxMQ_JSO+a{5+r`Ou_Yc5H(!&9&8g?YiF zOZSYoRa^fHa#}scjY!y}A%VR^=wOw%SYlx?=(7LpyW^NxG;?we4jyTt?Wr+&GB-;k zm5)z6!vr}~_O)M-vs)q`2|&R`Ucqe|?;<_-DNnWf0ONy&{2>p+ThMG|)V*6g?=`Hid>G)3P;diL}z)M^uyVIb*)1kl9r4&9` z6IzSeR=3;s1LvFO1WA)JuR{fQ!`?3k%l38Y=cmhFfc?g4z5Y>~(r$;mkneGDV9A8A zS>~hr5_43fqK&~IPQfpxBfO|4MyJ(25-Q_Jm5 zjXB6FhZ9T>Q_4!6SGp6xln`834-MFv^(2iWh`K4`xF(SYRcPaYCjP?ZkztP^_c6$MLfX^xUmzn6HRw|}6 zTjuSb-xn_SL#?$gv)*cIR80x0p1*W2v%rIFY}mS&vX;q-=5W0ZtF0Q#GgVq((yaD; z*g;n)ZFH0DsNE)wjY}$hQVjAswMS~d!*jftXfdDN#5X~&V!^0n#eGp*_6U;FbDDK` zVPd_1lAhYOcUQUE1k<|gs%=oW#c2mUX47f2&rF-TJF-BLG&ZDD)0Rn1lq~GMMTkN! zp;Hq$G9PeT?Nh_n3KmF{5wsUO#AJRGwF@>mt>`_a)Gn9QU~vvIy%-5 z_(4`;$D7YI%tS*j zzF9`LRYuAepo&E2-=3$%IHrFb^Sv>>`ZelUb2)18DL)=b(ku3BJIDY)1Tq<)xW}nD zCO;z8S|ie%4bF^t^N4wg0$mx~;TYW^cRfl6&FNNyVRx;5cdb$f2BkmCwYa^PH~(VF z@S;*4CfSLtWvJBE2W^n$?dRP#jF@Oe6SfRtcZqPYR64JqrTJnuhHf@J*UC-k=3W50kt)muV$#GwBWU+w8K4O=Zmb470zB^=wVQ^iT2QJgt`5 znm53XcTzb zgzy-KMp4`4*ka=6qe~<}6Tj@6rDc`C!=M6RPcSA|V}WA@=3rbVpdAm(+$4}9nI8t6 zN!hh8tj9oecZ<}L3>WBh!OyPF*R7j6@=jK>$QFN+|e3Na$-Dzpgln*|e;SL7AJ0I7jI?VN7ia`9Cte<+&&6`0v1R zpqItfhC`pR=*m(kX=Atsw1Os7YHK=for^5oCjy1KTS4i^Z2TXyXf>w_>77n7cEgG> zqlhWbi0lScyH-v7dOJjJ{)U^8gUiKf`ZQ?UmFKO8O7KYzDT3Z1*RBT1>Mb@!MtM4( z*k*x9BXK^wXXR4W%TTthWG`w8Te1`v@n7HYko}bRxGfYnMav(j)jE&dv$0IVfBY4p zc>8v8jM(o9oD$jA!p}UrqqZ>@IL2z&)_|2AMb;JW6D!$n68WTkA;q1GR(Xy2$&Oe7 zRE%&Z+!jh8394@-Z(<_aNyJ2RM5xb^FSw3e(UcN{pM} zN*VG54P2^_6PAwhn6?1>%nT_>ZkHA+c6khZoYwXRp9~*I^Lh1m90LHuAuMn|;p_tj zD%oCpo+ru_1%Hm4orq6Eqo)~Hs1&Re{5&Ye;5h?KegFFt=~F1^+0FQawBesPo!Qvo z^jBqCd4MQAde>?W4uh*!(~we;$l9>hY*@0Mr$F@ag%$6&rsI96@C~?#Q`u*f>RTq@;aKZU;fDbSi#z?2O`w zUy9-*W8cXEXazwIpBIMqcKqL`V5Rm4RU-=iaC6F91Cr0HH1xA2&9i0f3h8BktB78f zlA>Nli*2E7>$cKuf1IH7BkA$zUOYS*)N_P*CEKZIh|Jo=?- znwH^+nvFx%&x+H?gNE}dwV|?I7RF(uDB)Dt6l7zu@u&R_5ou~m^zGq|leA}7-qE|F%6S0+M3 zW~p)vs6Njqddh3y=`XK3M|NABbZ@7q=-ACimZv>bgcQ_Q5!4l2SW#g~M5{r^Ex16H z;}@Yw;FloDVuw&81_yEPk`072)0~+qInrgLV?|+Xu8YczhR_$i8R-qen@mNx{f)Ri zp!UAr(`!uH0Xxsc1}3tuG3kwJ-4BX8TOY|vRM~y*(j`=W3@4ydZEH&1wK6t08QLJ+n z6BMrfyL)T7zAC1eSvH#?BQC5%8+xJYGk z-k-?OZFla;?$>B{FuPhsC%iqG{$YA}uGP*v!%$*Tnw?T>S~4jxXIkrQD$fSEQW3vm zJ0#{VvPy-q;+erjLbME>JN9jVq+oGo*tT{N^ydB|GB{#G&UKK^yQbrXj!p`TO@Kkh zX$Tu1kf0Ar93pZ&4JBvH`Od0@otq!|YYsrpuGhrQ9ZiCrjj@}DcRCq|fGG|gwHM^! z_^|;+V)s2;UrVb&|CEKeab#qQ2{Tn)3w9=E6d8td`F5z;`$riqd+HIAr^pi*mNd&aobdC`&4RMnY5O3icMF^j9SA{1^;nH{q<2ofqBU<0Sh`Gm3t2pl=RxW ztjfD=ipSj25}Tv)K_i6KdDlP;VULhc3f~p)v-*acpM*GU+F0BYIPBU4?AkH54*>;p z@uqf$6kHViLJ(y=5|M)r9nQ(jtU)+f(}+mu5`bTY1H%czs|EUa1&q#vn{$aiY+G_W zPRfz=TqIqt=c&(eQ77;qPQ^6TtxDL6Q!gy6k*>54kZ3?n&)V8rWo0FK*$m@W2fe%2 zDTU{(VJw3w^0J9j=%cEg_nxQxZbK1X;&-v*b9ih`Pj?l>pCOKH5za=+NqQap?6A~^d9o^(?N zZ2c!20L01s>j(|47utib%^j%k>Uuw;QA_xIQ-#ziG+VQlsnXM_2;f4TG3)%{iE zlZEHPDC2|tHdx{qHtr}S`Y<%+P%?U7GI}>8ZW@Isfqrzc+V@o3NNY6e3DoL1}K|CetZg$Fn;K_}*fh%PlsGo|=jZVIVe3A`42F`Wzxz zSIRd)1+F4QhW?*Y)-Kl;36K@gdLK3d3`VngU`TfCGPJ@KghpqqH9rDl2m=C zv-P-DRZ=vZ=_}mVir&O+;b>jB6k2fV9XX>QhgcPa_#zAz3O#^_gV>Y^KI zP0peJoZ`ih^yHR<^NJ2D^3)^N8fGi>etL6HNEU7DOL9b5K2K5?hDtRoh%<@6`@6C-6!N0`UI!GBj{_57$Hdg$>gW6ymhViE zCn$>Q`jVFkAOAG!8<~$m{)@$$jkRqz*)n;vfcJyN6>!pfVup+GCB*4=}|B_I>GGA-BEL&G+#w0x^!P1`G={SEbFZc_;K>8SE*-JMaw zuHn9bpG)4}TXHi!p$xeTw6D21>8+`G9+5j(&&+IC;3D&xZ$2LZaQ*K)Je=Q28V>!C zb?pv_yq=m$_VMaN{pU7dL}s>BqNch-mw3EM7PuDiBA+bVZciTbfIcxUHAm=n-b-sv zhgYq?YHZaX2W~VZO0SX>8qy^ZAGvIcL=2i*<{U4ss%gt^mz~9DvUEOBx2GvKa-^>7 ze$B^t!|!&2E0bt6I5m3mcOQ(Ae?i=;ObKNfvH!C-2H}lk_uV;+gZI)E?4RJyx+PsN zrRrgnp@&VKBA>1IYe@M>#q72^RT1D1CHrFV8rFGlv zwzklh2EN~Ej+w^^YP0^MAHUnGmcOv28`*LO-^b^QfX3rYA?JM6)>oHc*M(KEQf5`} zb@LME{7K$>!>cx(s;gk>Y(MC)1>%w~E#KXKYUSD_fxJ7E-$2S5wL8+H4duK~5k6n( zLg{JqwypTl@`M!Jr?%#l3))w!n1adC%Ij9^RLnhBUr`|q zw;rS6jM2q7eMV^mjfVZqK+wN&4gqiNZOzWx^_0u!k!I?l8SN|?`Ckt2O8dccNq^+m z8rJG|W}*J_&o!>a#=uu$H9PlzZQMq~eAa(J{q)c#@PD3uma{-~n5hM-Yh=%+<|*g2 zyMad6k7GZ&h*U>yJ};5zjF;z+Mz^>5U5l=8I$V>CTRSLHJ}|#9$RD9)tL^k2KY#1@ zs`Oew?NxtL=cxZ`@S|t=!oNGXBz{)88GPZ7&Fz~0CX|mX%o?$$4>}mKE;ETGtG`E` zfPF_5qSW(WV=h{F_Q1UNuADE4-#l_D*uQ43b$00AlCx5Yi*h8MB0|CKzCtWAt~HrA zJ~N8>f4ptn50WyYv+Ajr(*N^z3Y{=``|}El(@`2yfT8kCDlEVszk~kzfxdxOIJVlo zt3K2y4xiV5V=P>Lq2OP+DF1z;#~9eJ+4|}6Lb=`c+lPGok@xHJFU43!(r#b%Sw{Ic zhz~Phl2%*NjMu?yGh2joa@mq&;Ip^vn>BrQ`d!s`%upteb27x2F_Ma|dpfFI;e%KB z3yLuu#x%Nv0|G(J13X^s=R;aCVTW21eul_jM>$vSDDLK!p>(Tab zfo^(CKNhyvE`i-%hfK59@GJ1OMdO-#>8>|WVvY`iw?H#L^*@T zRS0Y3R(+2b7Dc}dgW40>25u1MKV*l{Q5JY>Xn#%asm&iX0Vp=_6^)@*OBrN<)@jEE zwTc}w_mk1Tc?MhHuuqJ6yTNtXL5m(GOnfB)l1Qpv=e-p(tNseNJck}(!w}z|@^`bk zUmp%%fBXZLTy{F%tZ^h23V%w{zja(RNn)Yq+~}`TVGLvenysE**4Hn?;7Ii;b550T z%xh)g?9Kb6+?mkYoF4fu22D<5DFtfh=(;2A&!6+M^P9a4K3--O+1J{6+(sN3yh|SS?E$Dg%Vd;DJ!pXne&zXUUU9jC9x9Zm#JTOv15A8sU|%E$ z^CYW5zdgfiL-2@)=hgm>40AQl>nzg_wBF(&Z7E{%cfUnRY0|$zZ7}Etp=zl9e#v4I zoqtRP>t07D^8xNjX`g=!oYz1J0D#}4G~_Ye@__EFp!q;HjtOvgU^ zZb&R_(M*{KNE1H$U@M>w+E-SZ^W9A;q?t}DJrb$qzt13;8 z-r%0`QeZd-s;2hq*`p-jB&` zK?xXa;ju1len-dGL4`OX9u0Yo^ByoxjU?2qqtNPYgs%H4XuUI#0lyDHp&6Ol;)hN3 zIvZ49=;RM7XWTQgRP5-_mTdZ<1eB|8MPGCn&m0^Bmsz1tN&ql#-zrMse(|0hlsCAf zmo^|8hLS=&XaCsCMb$+H#yPOR=L&XdC@th+sdC6Q`3)fXnuF7Mu~|z0CD=O<+OAy6 zv)m7bf(E|pN&WKk0AzOmypKEZ)JH?XND_Yo{qVRFF^D5dHtNv zpt;>O3=80KXzw@n+sE@Awv>CeJnSdoDuBZw@9c zhXq~=)bx$oY#g%fH50z@2eSgP286;Owz)x}QVo+2wjB^;U2-Vf{^Yw%o4n3DVMs29 z*~9`ZEW4+DI#hvNvXKH-8%J~)1M3^{Xo23LU~mdmCoEMa839=_koFrjJw7i3jMZ@8 z9J$Y~KK!n$x?QY%CB*l;UT)Kl6*M>(T+6|{mbz0GsQ|5iz{&Q~p}~1UBO%V-6R~h8 zejO{^$fG!UX*RqRTi)eh6{q9-Ne0O&gjhCw#WVtR`Oo&);IEY!UmmGQYLm4*)DD>_ zYy(i~t3ITqYp)&-z|~_tL-x*P;kMCzjp6IKX%Vztr-;UAbRoMb<$HuqcUN1c}GOaN$!hf$1-TPT&`< zkDi0tlNLbnSCbwh=DxYAcPAKR;MX#(ZN{Fb3jiGrX5N|7LJ==+biJ>Kw6vrpT>hYp z7=HcwQ~L=O6&w!YNHhR-%SSUQ7Y+hj@@u#!`V)#2E*<4-i+F1!=k*;iGqF60qqhH5WpXM+r#~mfdc>1sI{Oj0jjqwJJMx%2q^G%@g4gXdftR= z(x0d&v|m1>fOzx*HQWmG*N~InL(C=Ur^4490BMbj1%>-U(VldCeY=sO6vU|EP@y_v zz{WmWkB*M)mZ`D+^E3hpFw;*0PFNjn?Vs;qC?sp3BGlQh6M0OQH@x+2A=cefHxoZa42b_vQyp0#_}w z*kY5MjNEiSPQSgKaN|rcl9HMM6T%>H`H?_q1f?pDDSXVl`39bVQw+zdghZ(yg`={DY#teD;iDe{79Rd9xUhLucf28E_T?Q6J})M@-=6W z?Vx18;I%$$Y22YM@sd4woltiLg&R1pKF2_ZB?wD(*jar-WAL9$;|6%yalqIqmDFkj zx7bvRepb7P2W8{?`d#f>5Nf9r7{&r z$P!i%ec_0}fJHcMoo{~^*56H;4i9_8 z^Z^>V+#_Nsv<8)<%$@)&kUadI!h%7_oW8?xr*}5Lm7T~&1^d)B6~Gz(Z*RKvK202@ z#!DASjHr$lOv^FbR|k>&zN!bdoayuBD3r_pPt=%ld`U8y&`5C)#PI}%<_Zv>K9f&7 zy0L5Prq2gZI7Wj6$0&eH0$#wGFw)qBZ5+9tj}~RY%HvkL%M>-%!7QScQ=QW~wR=}Y zaggKox~bUCo3yzwu^mP=c~lRlEI=%BYk%5A+BWo=uAo1Hr|@zABn%+3k0FV2`N~dO=EIw)cBMGSz=o zs!<~)!-L`iz80ZXvF`4Rp?>Ns*s z<`A5p;4{k_~v($f@iKKWYZCyL_Zd@zM{cigT;OzW6O40Cc~5y94|Zb)4n;;31L ze)5n_?{K*ovm?!V&NB@lyhZm7Fu)TSkBc!MlHh`zMYwEc#C*e^s^T!CON~4skNcrg zEtDh+2ldG-8OoX!8rv8v4xygh$8&e$eAo&N;(WN8u+#^6IClKYDkKJ5YFL#!tpcUs z{!KXXlmtxpwoOIJHD61*)Y``YOR$hWhMe9MPfFG8V8o1!P5p)Cd4XA_LEkDBpC?wNIV!4g~4TFWj=vud%;rNlm`#m1D+Mb)zR{} zlX8|S))=M{I-YwMCD4ddPdi-Kl;Keywn`D|xr82MYjvoZU5WGIY;wsKRapqf27|h@ zkEZ=-Gob|7$R!oXRFu9r5NS#c5TXoa!i)&KG-4e{@7_>5Fp-b$ zPfv+_agp@UW;a!@2$8!N0lz< z!++0H$Lf{zQ7&hXBHCJXhWJ>m=|+|Gd^6yw_V;%wIz*#pvx65G_%PS=3E_f?UK8*&fHoRN`g)g%zw4xw0at$o^>d4m6qBc zgU^J?sb=klYIw*Z;{U=7o)J&$qZfPC>+K63AX9&3otd6RsJqN227xHW-(?T6!1_S~ z6AzI78O#6NiL0Jl;MAJA1W+xBDYh8XkrVn6?ZRQ|&xyIMKLW=!-@Z9FxhfdYX8 zeHZf4i<<&1Ncq&oyO;RtZ!OPJ!tA&#^gaf4jk3Gi<;frVF$DgKf-|VxPzJq}n%TRp z$^LVL0R!%0UPp_j298`ehr@zL@KQLU8`6&3O9@ zdx&{%+f)T8A-6S*cwsGt0+)p&3-+If*8)8COU`f_n#2CE_TbdoPQs6(Qc7mh9-1D9 zbH9s4te~EF^ZM9~V5={Iq?Z3dxTo)SAH`sqn|xF^C#Tl_*hR^C@N7q+4~Z~mL%nf( z{WaSQcl#k}q$op&`6{Dys|Mcb2JjBt693Chx(9D+}@5D*^#(fP2UG)x{<5Z z|IeHPR*&QC1MSsbuM1iknh*9l1UpIdpj7wAnF%4(p~v}qk}TPfP~4trz*_ZZV`vaV zRK|~lmi6sI`K$=sh4+eIykQmtmhB#tub8S5;ZmWS7pZ7!vv zVIH9j1Em}+lHTd&?P`3b!M(OvEPr!_yT;{&Rba0Z0B92l2mU$2j za2R@kDphahg9F+h7J9r2fq3CFqNppND4DKyTaJ_q zX`{?|QWxrOA_<~+fE^bXNZ5ZI4#Ftxab#&e5sR5d9Ybb}P@^8z)ZNUddJ~rvIH&&F zjkZwiyX{;cTid^)r9{2?_V&JoGA(k-msLO7K1jcO?_Ogk4-g*a-j9ExqVtj^l7F+V zck=`9o+?aHnzB<{_`bb-Cm1^dgKDYAKF_1`@L~jbqnwuZlfd?5A(hpy{G^6iq!o); zIciX$ob&cf_|s8^dbGINcHW(BN8&cT9{aI(?`wV*bF!&zKZN=>n1`|d8!-J{|(bF3NNry$#{d(Qrivras!N!S!3V-(eDx z2&eRY_NTfdaFw>*5H1A%{{#Ow;m|=1-W!z`R9E|@ZM)46l|HOAA-kz3%6b)b+Kl&M z@enr(S6mxa!Ho=`ee;$LMUcfu35TlPp(BXiHICubdT=9b_$(s-sMrm-(rd{t(LrKg zPss<-e$fAzR$MGORxBB*k$7QAy~W~kMu7HktIU=h;*`crrxzcHoLP;D5i*o;pC6*6 zIqD(sGeGg_(Y6#hD8|sCbSl}aC+}y}eE}p^sCMiIHC{iXjp@4WMWCIjE;TG}pp}5h zdg~H>jl5GsBeJ@-W-M5v)F5IntsdJ89`s9(w#xQZ`E{9y#Z^&u`>hbhl9VR z$u^_#e_nm(xX63q%s85$0BL7wRE~w7wrWonN_vz1rxo_SHSXt!T$9RM;=xUinz0(x zlHwfTMW2Xqzw{Tsdm~%lYdd$Bs(xJ4xODdWvGVXZcX0|t+-gAE4Sj}5fh`2qAS+U6 z1>$j6s~^+et!{Rr1#HL4&s8$7uO^Gi<~XueZYC#NU2y5z%s7TLkZN>%v#zyj`;+T=YDd0Ghx|_~tr4L=m z)bfj{TT1z^6YIP+>>Rs09oSO-%_u_OX8yENC9KO=ZE*=99j=}jJTd-DFQt5&jmi8j z%1_k9Z@q((RKx8R>4iX=&Gr;MlYWqp&Pb5ZqtaE_Nc*wl9)|9+*PAFaD2nue3-|BR zx)YKIDz;Prwx;``(w2R-=2n<_UwForbT}eR5sJ7Q4je zB&dn$g?2G6V-hO-fIj(>SJm-dZwKcgl5|?!b0qEs31`Ir}`GZw2zBQOsiwv#fI7DgDoN>7}z z_$a&)WwC>9;1U+X5CS-S46Yr#%=?9%knh7(&+@M`CzD6{Zud`2Mi?1FBYNCpr;*q{ zQgdPdfK}YgoSm3#dNI3F!@gy%tYF#3S%g(SPn{7+hP}cV#|D8 zW5>ru^6in>`@ui6HHP@6QD$9D_rfGob^3&urEVBU{P3x$WQ2D;X!}A2d6GcIPv^CPcESWry8~K1`j$_HCvSf9hS4)n zvem8dyX+!r^hk=IlQvmfpmP0=FVh6;?X8t%M$pczOY_%-%pA!?JEiePnRZ=tYs;D9 z1dD2?xz{%^gsI~>ln2tAkeTCn?5%0HoF`w=@};<~nW{3EOSar7Vq5&*SA=|dIXMl< z1Qk?na+fFqH!Zs+spQI5o^Q}t>#MqKmavqhsBUCkeZNS z?F!Sp;0DH@OG+W+HgD-V?!~cnqSwz=vkc0)g@Cw{(}@lHzWksiY>#g!__{;2L5^b{ zf0#QdWg4jQPuvej>_^IiEf}wa1n%8)cL?*9A`w^KS(e^0Tw(5%=T78qt|ji2ZsZj<(fr)1b#FE5s$1BDh>PgznL7K3;a<=v|JJW{v`{2S{*>al*wCs8a&+wC zJ`OG-V7c$H!pULzQ!EWxx2(2aPLey$Uk&wSdugkE;aWCrv0c}XK&1-Td-8!h>KjCE z!${8GxijzDei)?*N=XKtt+ROJ(x!V7g#WxW^093FdH>Ujo#Qe6vNB)Ob!!8LBM$X9 za|QsL^jFskC_vcfK-<#^J#Hx%`LB2U+Gz>kMKmfEP22Y(!10fs$t1#O+Mtor;EU+Z z2LFMphaR48DjoRwXcp0>UHm1+4iV1csOq})hguovd%l(0ig4IIE@Iupf+6_Zb+_E6 zAsP=hSmr|vw#u^9F#?oT6>m1JorC=__=htR-y@j8a#w=-)G6&_oahU03JwwK%fDs> zDpn@Os&<#rg7|(h(ONJ4V)4O#XCYQLHl6A~wfOWW#deg$4O&UZ;_^1Pm!TJKOQZoU zZs!b{Gk04x3OC>V0iyBk#(cG2`Ih(k-e2dUjTKixAWHTowr3^f@J(aixd(MX?-ebiyf+s=-f6ICM+aTaNZy#gpr;^o<9k-?ion&g0Y~EPYqSGuuShQ- z<6Eb41^IJdc30GG7o30xlU7vNV67>;rsL?i;7JjgAXn^Yf6U?x-%~aRDfet2zq>gV zta_(2*%wVFj;T0OIevAaP-Sp{GAZ!m-t+zjqZ3iMn`|deMCdE`o;Pad;T_Vb#VSAF zU2%HB4SoO0Z-U{WY%v{->vPq^@Ps}~Z2uugQ<0cw`N|`n+jo0)x+PXb?*PXKRQO@v zNb{5yHr*Y>1;2Mv!7hp%7?+cPt9f#Fe(r1q+RC4Uoqf2}IcxWi-u;p&l~6yL0=q7X z#s*V{o7R#uey!6TyZmzChU%q$CaQ6v&SnEiBo=F#oWlni?OhRO8zgae@Fjk1A^`KQ z@L8%d7?ULg3nkqH=~NyDj9}8vp|N9X$@k1|Kb{V^R{GFK)=BVrP0k5Zct5BlTc{`; zLr_=c&+00uBy;p>9RtVnCZPB4lHLvMbfSn?syzA>K(c+C=B>RcRdX;NedMy<3`b^0 zs{jhgJai%6^n8J`6eBgrwS6j7N3l5WpGlyN-9`{u#b2OCJ>DFr^topmol61F`rb?L zmjILaO2A-<6aygX+`MujulKLv+dwPNuel|9_@lteBU<3;b5q*?j8$%XBkSK$9|P>9 z!DoI-&6`_#Pu5+4GC6~J<*`Y=!sK3y>rhHC9x z-8jL~<@&-I5Z;;Rkgc)H-4%ks`$p@}4|G+3Qvmm64IMcd!1gFlTOG> zd1}Jv;Fm~q&_hmXelEm3LV*Dcohrq;->z5c@MrGv9`p)Cgj*~1mO*b^A~A$A79LG_ z$N%(C@dI^>J51!4=#{I^xSfZ6`hpP=e$h$f?QTjDJy2-xv!nox3AGITI>LKmDEyHV zddtOc{#rzUp7AW^hkFzCqI|N1R$Fe5ma9mUcMQVVfAz~Rn26u)QB8uwlgje`NCaJy z8hEexjA@bLeg!k6GeMpL?17-ML9B~fS≧r4TN~WesE3C{nASh<03#O(0-nyJs?f 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 literal 0 HcmV?d00001 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..be741945 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,135 @@ +""" +tests/conftest.py +================= +Shared pytest fixtures and command-line options for the anyplotlib test suite. +Visual regression +----------------- +* ``--update-baselines`` regenerate the golden PNG files in ``tests/baselines/`` +* ``take_screenshot`` session-scoped fixture: callable(widget) -> H×W×C uint8 ndarray + +The screenshot helper: + 1. Calls ``build_standalone_html(widget, resizable=False)`` to get a fully + self-contained HTML page (ESM + initial model state inlined). + 2. Injects a ``window._aplReady`` sentinel that fires after the widget's + ``render()`` function has run and all canvases have been painted. + 3. Opens the HTML in headless Chromium (Playwright) and waits for the sentinel. + 4. Grabs a full-page screenshot and decodes it to a numpy array via our + pure-stdlib PNG decoder (no PIL / matplotlib required). +""" +from __future__ import annotations +import pathlib +import tempfile +import pytest + + +# --------------------------------------------------------------------------- +# CLI option +# --------------------------------------------------------------------------- + +def pytest_addoption(parser): + parser.addoption( + "--update-baselines", + action="store_true", + default=False, + help="Regenerate golden PNG baselines in tests/baselines/", + ) + + +@pytest.fixture(scope="session") +def update_baselines(request): + return request.config.getoption("--update-baselines") + + +# --------------------------------------------------------------------------- +# Playwright browser (one Chromium instance for the whole test session) +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="session") +def _pw_browser(): + """Yield a headless Chromium browser for the whole test session.""" + from playwright.sync_api import sync_playwright + + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + yield browser + browser.close() + + +# --------------------------------------------------------------------------- +# HTML builder with readiness sentinel +# --------------------------------------------------------------------------- + +def _build_ready_html(widget): + """Return build_standalone_html() output with a window._aplReady sentinel. + + The sentinel ``window._aplReady = true`` is injected immediately after + the synchronous ``renderFn({ model, el })`` call so Playwright's + ``wait_for_function`` can poll for render completion without relying on + arbitrary sleep durations. + """ + from anyplotlib._repr_utils import build_standalone_html + + html = build_standalone_html(widget, resizable=False) + # The template always produces this exact substring after .format() + html = html.replace( + "renderFn({ model, el });", + "renderFn({ model, el }); window._aplReady = true;", + ) + return html + + +# --------------------------------------------------------------------------- +# Core screenshot helper (not a fixture — called by the fixture below) +# --------------------------------------------------------------------------- + +def _screenshot_widget(browser, widget): + """Render *widget* in headless Chromium; return an H×W×C uint8 ndarray.""" + from tests._png_utils import decode_png + + html = _build_ready_html(widget) + + # Write to a temp file so the browser can load it via file:// + # Blob-URL imports work in Chromium from file:// origins. + with tempfile.NamedTemporaryFile( + suffix=".html", mode="w", encoding="utf-8", delete=False + ) as fh: + fh.write(html) + tmp_path = pathlib.Path(fh.name) + + page = browser.new_page() + try: + page.goto(tmp_path.as_uri()) + # Wait until render() has fully executed (sentinel set synchronously + # inside the import().then() microtask). + page.wait_for_function("() => window._aplReady === true", timeout=15_000) + # Two rAFs: first lets the compositor transfer canvas pixels to the + # frame buffer; second ensures the element bounding-box is stable. + page.evaluate( + "() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))" + ) + # Screenshot only the widget root — gives exact widget pixels, + # independent of the browser's default 1280×720 viewport size. + png_bytes = page.locator("#widget-root").screenshot() + finally: + page.close() + tmp_path.unlink(missing_ok=True) + + return decode_png(png_bytes) + + +# --------------------------------------------------------------------------- +# Public fixture +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="session") +def take_screenshot(_pw_browser): + """Return a callable ``(widget) -> H×W×C uint8 ndarray``. + + Renders the widget's ``build_standalone_html()`` output in headless + Chromium and decodes the resulting PNG with the pure-stdlib decoder in + ``tests/_png_utils``. The browser instance is shared across the whole + test session for speed. + """ + def _take(widget): + return _screenshot_widget(_pw_browser, widget) + return _take diff --git a/tests/test_visual.py b/tests/test_visual.py new file mode 100644 index 00000000..23397c54 --- /dev/null +++ b/tests/test_visual.py @@ -0,0 +1,159 @@ +""" +tests/test_visual.py +==================== + +Pixel-level visual regression tests. + +Each test: + 1. Builds a deterministic Figure using the OO API. + 2. Renders it in headless Chromium via the ``take_screenshot`` fixture + (see conftest.py) — the *exact* JS renderer the user sees in a notebook. + 3. Compares the result against a golden PNG in ``tests/baselines/``. + +Workflow +-------- +Generate / refresh baselines (first run or after intentional visual change):: + + uv run pytest tests/test_visual.py --update-baselines -v + +Normal CI run (fails on regression):: + + uv run pytest tests/test_visual.py -v + +Comparison tolerance +-------------------- +* Per-pixel tolerance: 8 DN (≈3 % of 255) on any channel. +* Maximum bad-pixel fraction: 2 % of all pixels. + +These values absorb sub-pixel anti-aliasing differences between Chromium +versions while still catching genuine rendering regressions. +""" +from __future__ import annotations + +import pathlib + +import numpy as np +import pytest + +import anyplotlib as apl +from tests._png_utils import decode_png, encode_png, compare_arrays + +BASELINES = pathlib.Path(__file__).parent / "baselines" + + +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- + +def _check(name: str, arr: np.ndarray, update: bool) -> None: + """Assert *arr* matches the baseline named *name*, or write it if *update*.""" + path = BASELINES / f"{name}.png" + + if update: + BASELINES.mkdir(exist_ok=True) + path.write_bytes(encode_png(arr)) + pytest.skip(f"Baseline updated: {path.name}") + + if not path.exists(): + pytest.skip( + f"No baseline for {name!r} — run with --update-baselines to create it" + ) + + expected = decode_png(path.read_bytes()) + ok, msg = compare_arrays(arr, expected) + assert ok, f"Visual regression [{name}]: {msg}" + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +class TestVisual: + """Pixel-accurate rendering checks for each plot kind.""" + + # ── 2-D image ────────────────────────────────────────────────────────── + + def test_imshow_gradient(self, take_screenshot, update_baselines): + """Grayscale linear gradient — exercises the 2-D colormap + LUT path.""" + fig, ax = apl.subplots(1, 1, figsize=(320, 320)) + data = np.linspace(0.0, 1.0, 64 * 64, dtype=np.float32).reshape(64, 64) + ax.imshow(data) + arr = take_screenshot(fig) + _check("imshow_gradient", arr, update_baselines) + + def test_imshow_checkerboard(self, take_screenshot, update_baselines): + """High-contrast checkerboard — exercises sharp edge rendering.""" + fig, ax = apl.subplots(1, 1, figsize=(256, 256)) + board = np.indices((32, 32)).sum(axis=0) % 2 # 0/1 alternating + ax.imshow(board.astype(np.float32)) + arr = take_screenshot(fig) + _check("imshow_checkerboard", arr, update_baselines) + + def test_imshow_viridis(self, take_screenshot, update_baselines): + """2-D image with viridis colormap — exercises non-gray LUT path.""" + fig, ax = apl.subplots(1, 1, figsize=(320, 256)) + rng = np.random.default_rng(0) + data = rng.uniform(0.0, 1.0, (48, 64)).astype(np.float32) + plot = ax.imshow(data) + plot.set_colormap("viridis") + arr = take_screenshot(fig) + _check("imshow_viridis", arr, update_baselines) + + # ── 1-D line ─────────────────────────────────────────────────────────── + + def test_plot1d_sine(self, take_screenshot, update_baselines): + """Single sine wave — exercises the 1-D line renderer.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 240)) + t = np.linspace(0.0, 2.0 * np.pi, 256) + ax.plot(np.sin(t)) + arr = take_screenshot(fig) + _check("plot1d_sine", arr, update_baselines) + + def test_plot1d_multi(self, take_screenshot, update_baselines): + """Multiple overlaid 1-D lines — exercises add_line() layering.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 240)) + t = np.linspace(0.0, 2.0 * np.pi, 256) + plot = ax.plot(np.sin(t), color="#4fc3f7") + plot.add_line(np.cos(t), color="#ff7043") + arr = take_screenshot(fig) + _check("plot1d_multi", arr, update_baselines) + + # ── pcolormesh ───────────────────────────────────────────────────────── + + def test_pcolormesh_uniform(self, take_screenshot, update_baselines): + """Uniform-grid pcolormesh with sine × cosine pattern.""" + x = np.linspace(0.0, 2.0 * np.pi, 33) # 32 cells → 33 edges + y = np.linspace(0.0, 2.0 * np.pi, 33) + Xc = (x[:-1] + x[1:]) / 2 + Yc = (y[:-1] + y[1:]) / 2 + Z = np.sin(Xc[np.newaxis, :]) * np.cos(Yc[:, np.newaxis]) + fig, ax = apl.subplots(1, 1, figsize=(320, 320)) + ax.pcolormesh(Z, x_edges=x, y_edges=y) + arr = take_screenshot(fig) + _check("pcolormesh_uniform", arr, update_baselines) + + # ── 3-D surface ──────────────────────────────────────────────────────── + + def test_plot3d_surface(self, take_screenshot, update_baselines): + """3-D paraboloid surface — exercises the software 3-D renderer.""" + x = np.linspace(-1.5, 1.5, 24) + y = np.linspace(-1.5, 1.5, 24) + X, Y = np.meshgrid(x, y) + Z = X ** 2 + Y ** 2 + fig, ax = apl.subplots(1, 1, figsize=(320, 320)) + ax.plot_surface(X, Y, Z, colormap="viridis") + arr = take_screenshot(fig) + _check("plot3d_surface", arr, update_baselines) + + # ── multi-panel layout ───────────────────────────────────────────────── + + def test_subplots_2x1(self, take_screenshot, update_baselines): + """Two-row figure: image on top, 1-D line below.""" + fig, axs = apl.subplots(2, 1, figsize=(320, 480)) + data = np.linspace(0.0, 1.0, 32 * 32).reshape(32, 32).astype(np.float32) + axs[0].imshow(data) + t = np.linspace(0.0, 2.0 * np.pi, 128) + axs[1].plot(np.sin(t)) + arr = take_screenshot(fig) + _check("subplots_2x1", arr, update_baselines) + From 9f99a73daf5cf40d2133d39d89fb6787c6672a00 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 16 Mar 2026 14:56:31 -0500 Subject: [PATCH 012/198] New Feature: Implement colormap aliasing with colorcet for improved compatibility --- anyplotlib/figure_plots.py | 51 +++++++++++++++++++----- pyproject.toml | 4 +- tests/baselines/imshow_checkerboard.png | Bin 8046 -> 6354 bytes tests/baselines/imshow_gradient.png | Bin 5420 -> 5614 bytes tests/baselines/imshow_viridis.png | Bin 12048 -> 12932 bytes tests/baselines/pcolormesh_uniform.png | Bin 12299 -> 12288 bytes tests/baselines/plot3d_surface.png | Bin 49457 -> 50850 bytes tests/baselines/subplots_2x1.png | Bin 11984 -> 12058 bytes 8 files changed, 44 insertions(+), 11 deletions(-) diff --git a/anyplotlib/figure_plots.py b/anyplotlib/figure_plots.py index 042fabf1..bf1f85c1 100644 --- a/anyplotlib/figure_plots.py +++ b/anyplotlib/figure_plots.py @@ -351,15 +351,49 @@ def _compute_histogram(img_u8: np.ndarray, vmin: float, vmax: float) -> dict: return {"bins": bin_centers.tolist(), "counts": counts.tolist()} +# Mapping from common matplotlib colormap names to their nearest colorcet +# equivalents so callers can keep using familiar names without any matplotlib +# dependency. +_CMAP_ALIASES: dict[str, str] = { + "viridis": "bmy", # blue→magenta→yellow, perceptually uniform + "plasma": "fire", # warm sequential (dark→bright) + "inferno": "kb", # dark→blue→white + "magma": "kbc", # dark→blue→cyan sequential + "cividis": "dimgray", # accessible, low-chroma sequential + "hot": "fire", + "afmhot": "fire", + "jet": "rainbow4", + "hsv": "rainbow4", + "nipy_spectral": "rainbow4", + "RdBu": "coolwarm", + "bwr": "cwr", # blue→white→red diverging + "seismic": "coolwarm", +} + + def _build_colormap_lut(name: str) -> list: - """Return a 256-entry [[r,g,b], ...] LUT for the named colormap.""" - try: - import matplotlib.cm as cm - cmap = cm.get_cmap(name, 256) - return [[int(r * 255), int(g * 255), int(b * 255)] - for r, g, b, _ in (cmap(i / 255) for i in range(256))] - except Exception: - return [[i, i, i] for i in range(256)] + """Return a 256-entry ``[[r, g, b], ...]`` LUT for the named colormap. + + Uses **colorcet** exclusively. Common matplotlib colormap names are + transparently remapped via :data:`_CMAP_ALIASES` so callers can keep + using names like ``"viridis"`` or ``"hot"`` without any matplotlib + dependency. Falls back to a plain gray ramp for unknown names. + """ + import colorcet as cc + + resolved = _CMAP_ALIASES.get(name, name) + palette = cc.palette.get(resolved) + + if palette is None: + # Unknown name → linear gray ramp + return [[v, v, v] for v in range(256)] + + n = len(palette) + lut: list = [] + for i in range(256): + h = palette[int(round(i * (n - 1) / 255))].lstrip("#") + lut.append([int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)]) + return lut def _resample_mesh(data: np.ndarray, x_edges, y_edges) -> np.ndarray: @@ -1426,4 +1460,3 @@ def list_markers(self) -> list: out.append({"type": mtype, "name": name, "n": g._count()}) return out - diff --git a/pyproject.toml b/pyproject.toml index cf522207..87bf2609 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,12 +8,12 @@ packages = ["anyplotlib"] [project] name = "anyplotlib" version = "0.1.0" -description = "Add your description here" +description = "A plotting library using python, javascript and anywidget for performant in browser plotting." requires-python = ">=3.12" dependencies = [ "anywidget>=0.9.21", + "colorcet>=3.0", "jupyterlab>=4.5.5", - "matplotlib>=3.10.8", "numpy>=2.4.2", "pytest>=9.0.2", "traitlets>=5.14.3", diff --git a/tests/baselines/imshow_checkerboard.png b/tests/baselines/imshow_checkerboard.png index 1d31a4b50a14d96c96658a0031fa7a904231a63a..91a6bdc319da935b362b1e1aa859e5b5bed6eaa4 100644 GIT binary patch literal 6354 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 literal 8046 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 Xa3(Ky9u5TGaUu7;9(yWwok;#49s(hl literal 5420 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 E0mtlIssI20 literal 12048 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 MZlG0o)h7IZ0E7bzF#rGn diff --git a/tests/baselines/pcolormesh_uniform.png b/tests/baselines/pcolormesh_uniform.png index eeec631e4a06f2fe827abd8ef747af0dbab5e501..da83450fd5284fbe2315b0b08aa33d18ce55be27 100644 GIT binary patch literal 12288 zcmd6tcT|(>w(jXDRX}=EL8bR1NRtjxs-O_53Q~5@B7GBoC5i)tABHtxM9gX)xV(Mdb^{WYTno1*^~LRWDc z9>Fznr`A)3xis*& zEGI-I{9n;9H63BXe&4*f7{e6^e#^wt1RmIeh++%tm%sLoruhhyCq<;iZK5~A1SI}j z3Wh#k!Q-j3V51c_>|O;Ezl~x?V1{<#m@UtzLevV_2`H>#wV9B*WRBl4)9GD1tDZvO z^3nM2Oy8r^if45&yQF@;B-i~3T(qYvS9e0MzeHIf1p;T<= zZnsiOJB`)Ltj?7OY&&Y#8J{klaIK+ZZKHjzQ9^*SSVNRC%-u220J#ARa$5ZRs_hhLkFLBE=dFFAnYsbIti`V!OvuwYc{$fb^k3U%9LN?)6v6VW$ zHS;cpOL;pl$(;5pe9XIet-D)c)9IVd%jzI$bUOh_BV||ti9aI$(!A!C*Bst+Cz*8> zetZ>#f6hMMlfRxK^QM{AlW;t?%8+muI~84A4sviO{A~Ki7dNBJm_G;XZVw-;X0s%r z5=WDaHN%M(`RKu)*ljcI#)De?oU&6;el%CDdtH;N5m( zoG^_zQ5!*;g*@KuxKO4TLM_%zQ9lIU7`|3XzNI0Tn;36%H(pr?wbcY=eGu$@FYob2 z8{t;@&i(g(fzU$77U`O#6CG+|I)^zgf2BZuo||1iQbOzQ)0gZ58Ld8$aj`oTDPsZE|?)PzH|Pnu_wQ8N1JG8Yc5(bC_7^#ZyH%Lx4vfo3ExKgxV6FfeMgHzIwlwC z&N~|OF)T0eENj=t)wTNjmkn4^I$YLj_?Wb>l^P)z?( zO6@rAg1#eqFL~-o#>ily8ITedZ1RZ`wA#ETg_fL_lU7qe2x@^hpNthunWl#z(h6IHhWwdIT zHslEtXjp50LArR>KDy}B@2Uc+vltdcEDbknJbTYUH{nLNP`O3he%-{2=ArvB`z%$Z z@t`c$j{HUNl#jdgA}0_LCU_hncH3f+r7TU9)2w=B@7nY9gu;}=1DDh;cr;xaDWYRJ zKI18hb+u;*eGn&iYG;p6)u2|e9r1lN z^NZFYS5K`yFtR@XTjnx^Mzla^Rbvz8S;J3;pA8p${+Lfj(%`HF&!yNjZ7wD7PN`ZF z=o?Es_U|4z?&NTgM7X>M#U|uN9J6=%;6`-TXHpwyW6ami%46afHTK$BBKdcc`+w@j zUM81>+KrO-Zhw!|Ydm_|@9DQ`Jxy+8ge>M8skf~2u+Ed7ObIPCa#@1lSSd`|6~=b2 zSXAHBG^R2bF|Fft4ARE&{BiK5Kf_#k%AUBOP9&QgEz)oap&Fgv|+O7yG5OpX4Bii+Mn}rVCq7}A5?Vb>=4oupI3|4=MUmiLXi#j+z?X0~D^~rVb->IsLu2owz`Y{K z4#$a7v@YozesK}=6cU9?L?r%yIf<#DRuj#YE%Z6~=%=%+K|)h}msxCO@+3sLU=y$Y zU~AMg>uGAI@HR=ACaSG&VfL`uU{jC}$wQdQ%I72Oha(GqKQLIfMw{x5c~EEfiYGJp z+Nj!m1zCy~qMc;Myx#Cu*ew z7|G;g&WzUemA^uama`zAOGRY%EXg|GS70~{Q7C!#=I8}WR=Uu`Uap>1Pz_Fa6*9%0SUCrH96bqWz{)v_#XtWy= zgZjB8gAjpCdf%DMv$dYpGalDB?$b9qguDR3Xp~Ir8N2fKyuG_usBmZCf@{;RJP&3) z7qg!3y6)!PLyMfW<{9z;7g_&)(NOFnMugD9SmF5v0UDn+2gb20diym1m%5;{OI&(n zoKubAzEBz5dP?}r{$)>tbXOzy8{*Z)Q|KYX!?Ix+ib~X@s5hX-HH6dM#1YF}(}u$? zePlXG=MQgkPY`1Kk1y@amDbMj^k9`Ps!EyeA1F8Dl>x&O0n|g7R8)+eLB>h-0IP4{lvoh0AA;aQ8YV^tu10TaTN|{>T*{yLyOHAi}V0`=P*Vo+pf?+RZ z_q8$Tjru?m;6DBi7HWp)`g28sKvJ+b`P_GJ%~2R8+y5kSsRJ_qpndG6j^SXYfZ=Sm zL5=_et(hpVqJcsw&62VBueSAP(*vgkm8dw(>G$BU^x+P1VeI(wd!LvIQ=fS=QJhRA(p$&qa^5t>pW0>c@S7FGT*VRW+vrkx_;eG z0zr}5qR-kl`>01@pkWQ)WyKyL{-lDpgPv?9XAqbRUjyD?!3Wg=2FR9YQgcnTP^K@V6edu7389i&+ zS%6@EO5aqv}3kz3q>gRG5PrazvjqVC#-J(xxM^0sAGQVLsuC zd5t~d%;J`hksmE3`qlcb^eVLM*%5wmf#LRxhsP2e{8|0OQ>^!z=kn9!u=aTB?4m?T zDAXpBG-6-SRLL*O#vUjUbX~IBU)rVNv&VBLrzIXu;_bHcmp_f8^$8v%RtE zAm@#lYY8Q>nQ#QiWNE$Zwx&AcESRenYo^iH7Vtzdz zZNW=u1Hzx+E$cbG<6=Y;n`cGyY=+9rYGcPGw09M; z6CofkLF3yBKM!exE53CE4V&I5^e8OH^UnKLA1KK=6cV9aVhpvUlydo^aUys*;N>n_dSh{^7_gz6V1%eQUqLq?1F-SNhI-G|}`|aXgF@tq*tC zA8|US+8|bn_EHu_Ys<$c@I)lYF_y&AI7TeM>E0q_ID`d3Gobm?0*2g{JVU0-A8UO3=Tq0A{i%Lx63}Rb|Fg%G zL<7IOb<297`7M!EsIURf@!`a=Q|O^|7{u8hAqgCt^KAM8Rkyf z0T()9T70s$&`Dm$tCQCUnJS$39g6a~ zGh_ks@yl#Q!;h2bz5UORnm_j6RuAw`MdTeQzIFnUaw>zO3$EoB_%@XBlqOj>n|eoW zk;N62Zo3tbL(D~NNvP^bsp_EysYbL?_?rAUpkly3sQipeNwB9g&im&_Pf9pIbondH z;du-UUZOvbz*87^7I#ELx2X&0_W0xk?p`1#^Syv9qF$%ci5vp_r&P#n@pC1bDNkO8Qy|$HbOE8 zO-o6?T4smgTz_rHsnG<{hET3iRLm_#4e5-m)PbJR^*_x&{PYOYilp%v3dNfsQZ6FC?;-(3bz441Dhaye?}Z*&R-9+<|RI3tksEgDrAZ(B5>w?Oc-{Y zeF|53P7~lV>hFekVf48^arJaA+k}e|B6!4tFDJ-D47rg{N)r@LD+ta$JH#d zkv_HB@T|zpt>h^xw7m;MV8Kb#;uE!s%tY0HX92_M?(AB>8&$|3b;|JBPw2MCN`{)D zAmVl5`}l)m>x7Bz<8ho{8z=j}g#nFrN#+&V3s6`2M}P>*7~MT2BMIuwjEknW#pP5l zKvDr0o5D;Nx+@hAQ>r<|54t-O8Sm~7y}7J-`}3GP3Y26n^-R9C66DSF0DLI9HuaO@_6xJcN^sz?Uw3ftm)Qe>|}9xOQve$t@^T6+55A)vjy z;LD07>LsI!s82pPqI;kNM_cwdCjd!F+z+_<_hB|I)LQcQcY*^1>_m$Ez9y)HyLmtM zYyLdoxWadsJ{5t0wfsGM4q2@A#K>Qs53fhD$yD6zPTvWk;*cv;jFVcd80fpaNeNzs z(pQnuzC)yY@=DpY8dqfBxzYxo^{3PMskdrp9y_Lc%`i9Q29!6fnN}6bf1r}x%S=ug z*>HJ@3F%v(KenFJS(?1hIS7yH&qnDBTzzJ_yR*Hq-W){eP=jjR94v)0Qr~jI58#T2 zDf8;;lMPPm&DApciAUecy+B=jai8uTRWRe&5;~*0ka}#CHGo2m)7W&-cAA&RH7m8T zP2D5RKj)zQVKJ8(0;}vx`j7N(-+8gD+sZ_R27jgOI5bm7AA25E?*c5r-Kjs!fH@My z>@B(4)67rKQ*Su~N?7^ox7S9X)lMk{TNO4P@nSv=*Sw|^Ql*?@ zXE0A|Ljnlvm(>J>>(K;S$Fz)Tsw`ueXd!=t1QQ;j`=;~|8ivb*#;YN4KNG#toI5sT z=J6yk=}{f3u`Z?Ov83Z*xkdSWZ;l3|(6vHGH(4cza)zpP^H@JeGgk*C++i}AdUfg_ zR`39cvtQvsL9hqs!bLAm*qhe5FUtC2Z?8PXSXji_J>ABcJCeqXh)SE|LAqzX zW7Tn4wWWm@F3gO*`~G1WtQYxQ1VU~Gg9vx&bGgMv6{>@I4Gv=p5BGWn(Q2Fe_6pg? zF<0cF$AhvR{q3~^7#NKGZNf6*jBIOUjGYs1e(E2r7s~T@EE-TrFqM3vS0r~dL4sgE z54xf=KNFwcX1DX61dL8t3oyFV+L6edp@Bk}d!_JCJgii?6g;ZH9Ij*0j* zXNeIiB$(J0mnz?McANAw-ySq5{+$Biy22kg>L1k)hv`-brxKW%$}bXToFO1o)Raoe zTK9$sXO;6%Cp=u%6GfBwhhfv!a6N5Z#urlJOJ$KOG-qK$(ooWQm%t1mkd#(yVJj{u3XI5km>0c|Y&dh&g($tLnYFrEE;MCRq$ zqXkcuI3w(OydCn?f$Z61Qa<9xy`Op2dgP5i%&W7I>5t5UDVpR=o-GJ#3=}ubvCio+gyIfd0qyF%8wg5i zfw9BSi4M+7;YxjNp?ZnwBp2Kmd+IiKn3|Ad1ZWL4253X0pKuYd6abGLv3!1=EGpF? z*Fuy7bhQIeTI;rX_fOMtL}XnX2UIu0et()6*h1X9J@ymzHJBi+-v1lar+5T*JsbT8 z69;AH7nZ}({Jw{|^l3teG+(H`JoERF5RmIwRtmZ24inddpS3U+afxy#YW}~P`TWxa zaNqzPkYQ`0`02_jSr?~tIBER~E05UqJ{lj3-BHjD&&f?ZqVtiP@Gocb<|7DDpB3LA4qZEVfK|}D)4g%5y&p#8mk+EFy!hw!G)sXZfun{QEEIR8!rI26Z@ zOC`q8HC*!ZB&W6Bhea6Kek?P#t;IJdpBClORq>)~4-*0?gX)bh z%M6wYTPgJi5n~q81ckVeUVwI#KeO}xUFLW%oK0hXB*Qu5Ni0JTXxITi``TB=?&>e=aya_HnsxH2XBoVfxNHy$m14o}+4Oza{;MlWqD2`2_yM-y zy?5c|3J$&tZhYc}mu*OJIJuZ9RGS{57mmZ(z9vh?*k(@~5->(NU|!_7+%S5iocGYa z(Dm?BQBw0Nyc~aD`|7;}W(Mc~vDZ1EP0uChR|+!gkJyOE4`tQ&f|^s_nw)ACB^<1) zyFWlXq)i=J*T|-6c^EI$Jc9%&2nCA@2g#U|DVU}I8_*WyfF5vH@`BBFVVbCG<10xeTawTBF+Ys~V^KZQqJExw4A1Lj$=YG< zfbZG`uNYNo@Y;Xqbu9qgvSQUqZW+ZG2j)ogClXjf53Jg6=paDpr-J^&B9|f_mJB%; zh5nk>5@zgAW}j<(`K&h4opV^NGwr;cO(s|FS^vW&o5rlx3ReAGa$&(fh=gPIro^%I zW6g&9fD}ih_Up%9{w{bInM~S4ttRSh)9lZULA_x};#@e!`D#TAqu)wmC}xi|k&5RXkS3R` zH1g>*aJ`pW`Vzp+;p`gNzve~9T7 z!L0+OUjFtFohcqhe2V5J-?7Ge9tF^(yVTRbtAB*hLrK&{7&9(t#JWY-kO8H59;(Ml15G+(7zAv z!QWaf4=|e-oVuyF`3VI&wevFEc_iQD*5{Y{kA+oa%N2jT>!Gg`gjUZZeRL*&0F z?j09Hi4E5Kc=cz#FLwwp$ksm_;xG?L8)@{b!(3Z5eF;ev=(ROBl6N!`)W)wc!o+<` z?_b{pV?Z$Onmy&rOoe{@a4xT@6*XM70H#tOT){JrI~p!Z@A0SYYq^3dgbGhSASKB> zx3v@nYkB@+*cQxe>RA~%Os@6Q@w;i~dUrVw4vzo&KrC1svUeKrl#j+_%O$ z>YUif2l*TBiv|+2VFx3EZ0PtkFCeQA_@rBB`bQ<--&=7Ey}}@Z21DW?K>Dsu-aIlR zg&Y0vXUNIr#Kv!S_Un)@T6K=Ykv@<^KN0uax)7Evy@JGsd4JVyHo_f8dYzG+o)bYB zZL!jciZ>xPp#`_|p065t+XSVMnp>E?7quIbJwvuT2!-W!BFLzc84=BfFDbSj@YLG{ zELIH-XFcJRq-yRE-}YCRRXnNI&9v2Fu_Jb}OJVsPm&d5v(7ki~(y*H8#G4s@YhQO} zc;JrgbQ7yCkKK2oTJ@#JJg45n@Fl2pcDhNoXRvQ%BKs{`<%)pQij{M+;~4MtZ^c|IT?==mK@_Lw z0h}^-x&1bpkE1q5Ga`i+V8w=8^IpBECsF;>()q97isGAl)Yr|X`{eWUAZS0~p9i4e zitAhR(T@xF6}PgMRt7Lc*)+iwwFaeb|7$nhb2q=9m73X4}>V2sQkA`H9kI#%|<9LB!& zg|TT)Fly|xE9-9lu#3q$!GIaw>QjHc%*}wPDDo$^%S&~rht4)|UKz)S9@|SEEHe+k+f{l=z2D0%L4`M7^PdgFFJ76d z;-y1xe4A@s43L0)o0a$ma;yT~MyHz=w#&82P)aB>z+WM$ySU`PUN`=}M|k&jz4?5N zfR1b6!>=(rkEAPZ>7;xFkj)3WG4qLgd+gGk0B!0Q*?j#~5uosKrns*@7WaF(9c|0p z^GX!oKdrLi!`Y`zgQ`iZvusZYBnpC^y_416Vtk}uZ<~CDq&=;0LDf1r#gC#!<27Df zlxZ|{yBLW2`hwb>=!_KDIkr~xKCciCj#3=9Xk<1#GB7CPWAs>*t~J5CjIDp)Y=Ga zs8!Q7)pV2sB3;|3LYhAvUPR)5T+yxD-kK|OWE45r3YLKt-Nc*d`HknKs-hKluwYr( z)ezxn_yQ3X9i+I}^CL2<8Ka@pybzYy;J=C&ygufmOPy#)=DG7@w!lV&P*Kpz^(?qV zHM0O+8=bcV<24$xHP3&A&UdE$!TLOOd; z3~t-JCc2I8E>YJt5b>0VQXh9Bbs_M``S%QP@cUijSi){F~3&6s=%$+Z7j26BpF^bz5gt&ApcxkpE kD>WrM^4nHIA9tzE`e{BvZ&*@-&3-&BH67J5Wy|3I0;|)lF8}}l literal 12299 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 diff --git a/tests/baselines/plot3d_surface.png b/tests/baselines/plot3d_surface.png index 3d0e7a92288c4f1e990fa69fef81e7dcda49d903..c0f9f0cfe231483432d16064afcccee61cbd5623 100644 GIT binary patch literal 50850 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℘=9@zs?Tx4_wX4SeD31Y`lx^}tQGtOl=_B1h$}oaed(*v^g|`U z(6)mJKeTwF*7`!=(v#yrc<_+#cLfgn@Vr5CIIYdqEw4QD!CjxX*jJas^xYd_t?>0f zPe7|->h_;EiwOVEF+?p46oKv+U0{k2wz1>|6-`}PPq`33?~PZtAY*1gaA>f1?yPy| zy5kM;M(PKM@FR8PMuFUIoiAXa4(oOZcl+k;@Wsq{vjXwSTLWOj3WBi4hj&6L2(CV0 z8bCVy@D=kDx1S5OzU$`rLiCRPdDo5qz0VQh{~m7Wsv$rC;@d);Odx#J`!!l7olh8^td~ z_)$E#BjDCt-yP(2K1}Le|huW5G@N*aos~+-5wbL z8(eN-G6zFFh@}O_f4i^vfsqefd#mIE807U`eA&h5{EG-bI>&V+-qqEjGvGf5TX+2m z4?28M=|Os2eTSFuWB_akj|-0j&<;%LC+{VvUJoM)5AF9|?6>z3T>%k(L=PVlo?n^I z?Pvv!U3NhlsOtb7(EOc#uoss1zH>>bSR2C~b+YSP6z@mV!IJzN2 zgdg4GJF4yuZ06s3KW|m$!=68U4*v7tUDFe0r;F24MO+RF{1Q$f6v5LDB5?2(-`d5f z`4AC))C}b)zY*}d1(hDG&-=YkSki+&4`y)E`w@MEFrP zoTK$-!1kU{;KOX(?>)j}Iq369W!@Wve*p@7-oP7t#eUl#(G?KkNA&Rh4+(#-Ecf`` zokU=qLESbl;fcUoe1SLkii2u=#8pOwA8|v51Rfj$KzKq$53@Qf6X0Urz#Ek%k1pLh z1S&tT{)6) z#_r_lVBRPWix_1uG%&mRyU#-C+9%f};jKusdUTY1Fg5#dMNkRgHj z5dgJ3Y_P*}eIQnPT^-!EW)8Onx5IPumeS-z0q5liQtK zd`l0ebXfC)3tb1dm+&{Q9O}1y5wCyb--L-+lObchBPuN*%Vxn^gBUq)46112znVW3PNW;leQG^M^V9 zr}+5)%vqJR$zl<(S*Tp~W5Ne!rw3lo)=fVC*HUz`&8)Ky8-VQ(4zCsGINTPj`JlGu zs-mp@GxMhWwe&J2Qa1rdwq2{1Xi1DP+J8(17TIQSS{Dh?+0 zRylES$O4%Q`5A>66csFh?HSf~(^s5|3MD{hs~w96LrcR0VADuk=GqGNmWB(0K<22y zLN8>_gh7;BqhqiCss(6n7uE2t60!&O!3>n-{WKAC4FJ=5C zqu3xIv6CYKxJg3A(uqOQAOaW%?A;Qp`UNBo0(;y24-PsnQf6W6W>ET*An5#6kV#pB zam~h!g)H6uF76x(ENndt6J)dV^W_U_Lj*yF9pqrRX?R2C&5IWdudZLe-v1!l5omw_ l(|ZSo6}&9$vzTxGm*?!7c=yvGqYMTh@O1TaS?83{1OTZ0&v*a; literal 49457 zcmd>FWmg+*v_(tt;uLLhch}-x+_gx7qQ%|arMLts#jUspcXta8#U)q>a`Udc?ytBX z@*!E1=gb^G`|OEU`znWtMuG+h2ZyO3FRcLw2e0*cqtz+KO-OFTw&rAw6$>gwvrpbmMQWYGA%iM8)#&?mk#^Gjn3)rB$z1%t6doWXN#$Z}7JOFp(co4O|V-uoMpXD(fOqYQmaFtrwd9|^N7N-b1 zQGGO$GLD&}4JHU+bP#?-sB5MLJ26pF?AgpHc=SI(`%LU2Ev*Q}|DC9HvxBmSrpyKt zM6c_;(>6#zoCZ6wI0ajr`2X9j_b7Uc`nbJ8DLr3ap_jET903?LzyX-9-(uF@uI9V# z_%#9{cXP}XiIfq{95^to_Ozdv`mwgT?B%I;pZQ&Fcj$_v2hh|21^)A`6LTr~z`fX# zW%0KjX8z8e*Qv*Kr$mz%qsQb{guSBwEN&U_cy-^TT8ao>7K3it0R}*w{?DVS^uCdZtC#>qAwO@3=n}`q7J(R!|mm@w0e6-{qURb z!Mh!BZFnNZo6x(`A=O}Jjz9ldKREZpP~bfQIU1u`J!<;^-Xnn4tA7+9FC&0CZBtla{`TvOQ>{PAJq!4#hL*gO z>iO<ey+ka9@Zuy-6Pur=C#9+#e_d;H@-%caDNo&Juy0o0HPn!}c#L31_ zcaHi9ycA&mW~xya`1GW^l@0e;33%OtoJJe6Av-}=D4QlTj_&+FbnFMF6B}q?%I4&lk#MQXgnc8w^;MfG6`p;T+;Y z4e(tau7>kiPAg|-r@L&=4{Lj3%^;GLPvmq8Wb^yr|EM|3{?I*KPdP6MROQovJx_8> z+|H5kL!h2FF9q`YlgYrJ2XKoqAi=x`q5=QtA$RzB)6@InLa7UzVw198WV^JFz!~O3 zAociq)xhVy&o)`Zz>99;h8M-zZC+o`&7tu?zHLn2z2MZ#04GMUeftdy(z?rzkedX{=0rrStd#}qhPuxWwUbXOxk$s?F3bF5}z}GZ@^8})(z{^ z2Q_aiPvb9!wW6_)$gjNrDOFDW&Fg3=rm~(oGcvLH;ZL=Qeu21gg3Co~8oc`BDYlhOmUS8+z;qs)dx9@ot%;a$`r#&efP!n44CjM-)&tkaGVK|N4b`}v{ zvTHm8xQ?J_*$Wcc;6?f>khZk$`;qMNCzD}W{AHH}axDlfv46r0Nsm>sDJ3cjqyJNJ{0(t-l_+}d?H(6SA@egAks+B0YP8vYuJu zw?yr;?S(cONm`pI`(CaqWiS26I zr8tEZ)>c$MKm>kDyxQ(fwkGtvwsfi!r!BiN3#QSLB3Fzv&a>H=u6-?)(__h-ESEV> zE@!O9;&jgfx>GZ`=Zp6A;*G)KA8KcX@U<5V^U09gQ0?;5vXE3eM^F!Ua*s1h&C4T{ z63)Hu5~I0ZV5{ee45ktN=MO7jHZ6-Xi|F(Q{a<(`3l5XCP!i6Cdg;Y)2G8A3kD-_! zeGPIFI9;&V;m^>qwKFKs2Qq7g$D-Gk(t9)ooKRf1`>9jg&EK~7RMob7TJFDE+IA5v z4+R6Z%?A*C|Cu=nrj82f4*IqkF(-|?3s(@~^bIcCl?ZY(Vp z(Z0bwD|b`k_M$mV&NrXV-=F?{J{0=cT0$(cN-kc}CJ3EtEOOXD{2`z^n27 z?F>Y@An;{}j>zk^wY=Yw^y|yTW`-dA>t+uFtn?EJ((U(G`?icm7D$@J{=CR66COdT z+w)k~qBLU{1SN2qdKG+zs?iUL(BMAsG*ayGlAAA69)>!}1xGq;@VPuYVsV(ZaSyt% z*epUK3_aOj1>>T6*5)tNhSh1$>ga3w(gH<``o7Iki0ayD<&{uzPt`f36Mvb%}vd^5XlkPg89 zoBLkKqrc>BQ{P%#{ATEWWvjyl)BJJW7du{3_jxAyzwEDNStJ(d2A+=#8NVn%!|{xe~2vjBVuE&~13Lng~!?sC{K$e(`=uXV2<;yFq?d ztu271V&w(V&-!-lSvGyd*N3jwXbBM|8W zh`H0>al(n0?2$9$c2U*qa`t78njDr5M>b41J>oQ-EkBy|Jev8h_38}6QK-L?A%skD{vIR@p+`Cm&Y);^JKUz>Vn!3m~KW(V^Ug;DFQe>l_FKIh9(chzg5Y+^VHxoMm{ymXeIH5l84}R%> z8tV_)Fn*pxUm^gCGfH)NuPV_H11?4svYltu;Y{emAy43`* z!>O4D(Ai9DBhc&*Q`vbA#|8@36nN3(r*dtUAwqj9k){{39Lz0 zHXTAn;(s9cW}Fe7v`v=1S|_gVEtu{pr0+WB_R#Cq^KcS*z%1@S zCHi`N#9w0uiow_c_Cyc9ulD4a<-ObfyMQ>Zh@EzsbKma8Jt zXx`(@Oz+2yZYAa#88b8NVmg>3Uf^gJaUTFakd!^sIK; z04M>wS->*EB=^uNxs@49tlfwb6GdWt?fV%&RK9+f> z!YE22#o?I@Rf`)7h&p~-!JsE zoD0J0m;D}IS9H$+8$E%pU=O{eLlvg577lh;`gSb5c-~hYX#V<-8_*PP^jDXpp;D%s+Cf`3JsF&G+uPezttpfW54tZ|ZYe?eF z7Y1lqp7+=_&Fk^%vn%+<JQ3-1KC(qS#B@KCpV4aJ#X>IIeF?`H7|G?Bl+`4Ke6blQ zD(b0rI@|AIIEvzWZdF>vFQmVsVCP;~iLbAWf-KBhZ{5a&Vkn&(H)sgqS zO$IV+y>esWm=;MJ5h9QiORoF(Mg3%H5a}|ggX*W?v7Ysqh5j6czSf;kznd5>j^2rRVe=iNaOmo{8OG%Z6APrq?Xg;^FK#S0 zyrlRl-!tLfHd7f3XrA5X#NR==y{6V34}y&Fvd%pmx{=9Cf?^S;yy*{{tFn+Wz3y+1 zrJw4(RiF(qi7bknS_l(F6iisB)wvDT$-Z@OvkV~LyZxPh6v|Cv&4a5{QB&A#cyxMm zc-D3MLXQ(l{@>W7R?oi4o(MXe@!{vO2L&SX2B@WQmqnY>1D078H*G#uZ3`h_A0yi} zv2FU9QLy-g+xPm!8;V;`8I%m8iE)_L7o!;*uVK@@XP-cvI>Vfw&SF8M_Am6>1;5m*!P$(2@w{$rmp z#OgBL(n%j-V%Vu7v%dZ3Yw%F(raQWi4N>>&?chOwU}%tn?;d-{tT31G;nD zn-HHx^v*ndZrCI)Ol)^d28%XN`pC!P;^h_D#Yqg-A|vJnnt@V2ha_2Yi8HGciXUep zYtqdod>^i6fE4}tHhU#C>ciDqPgx%*xiy{nurf<2OhxEDK`;nL6M2Jrz#AK zAmfl*OuE_t-&69HzTnbe(Fp%eeZC&KBl{1NCi=CVBBYttxkf-S0_+Lh^HF(_fsS)o ztL1Xh6yh;0qQamY+W7>w&Mz`U9w%1^=|E8q%7(Vy%{AP=>3u@2J3Xq+gOQVoZ}Aa` z;;-2~V+7NI;OH$hpF^GMX#+wuNR-2qPM#9`a!W?WUv=@KQgV-o+Fus(a<_`9kxcj0 z2)>V!Ck73%@cJtbnHKYt|7HoW3E{5HHav-D5{9M717`0#7tXC_bXp!)b_YX|1x3qg z=?US%S%w+-=cZ&G)og!z;4`sJH@7zzaayBEHk4*_*IqU#9L+qb-e7S zu>Eg73S$iLPGlkW{gQ}30(6u2gGgT+WwBHoVJ({WUu4oIN9MN77FE58x+^M_q!Q6c?Ti;VwlGxlE1unRw4V;=rTupHy(uF?b)ZrTgz^> zY(cM`ajXzpx$}uq2UgYfzh3tjLz&v~;Cp@~7AG`=ub69+)MYuP51)>@{k{%TQ1XfM z@ehyZBBQ)oEo9GJ)^yz03>%~$!R553O@O}~Ia2oE8>~hYdbNLd( zg}n%Ugcp4$lKqAGe$?(P*#Cx!T2Z(ilWP>=7EL#vMtV`dM^s9Pr^v*Qt>$G$@}xej z2vO#v@HqNC>$^jA>@?ZTbW8hkCPAItmTL2hlbbeZj>R(1clF z&xol%eW$){w5ay{BJ6diU&*V$j9EhxT#FUWTe0V(GKIBL94rcVSwZ**Gra_xFzwuA zKqQ}`i=)HVQ)AXlY|O9sRYQJ)ybSHwE^c)ex2MK&r(!NMxWq$Td`86AKlb5=i#+9` zC-SPM6m6B+o7_yH)tq=QEYT=J5_>sepyj9C*1X*g;TD7?7g!%k0QDxdbmboxMbZ26 z+6jN~etU;n1o?Yy%Cs?SLkr0L8h450==MjRmdHdUy?d6iWlpppGkgx`eAhEp=!V39Z7+!y-zop z#t2VGLWwn`@FGt*O!psic)y)`p&Bky=XTn$FS`p42bS1Op?O!xP*|h~KMa-0;?-F< z4@#>kh!X5Bm~g8*Idvf_T{N|p4FLVlDkA>w#r|zh`vNr@Ei7tx5TvzBLB}FM07IbT ze?16}&e`+23qSuZ#mPiL=UX1Ga?I%}<@C@lc2ec$xZGMkb}Gy+|2>X*{@FYtHR@!j z@Q3F)3Ag(dURMyTsbl{%mvfsoF7Agadd09m6Txq9mq{T-xG+7oQI@^V{_}h%tU86J zYQ6+B6IbV0ibaDSb6SBOF2_o%9tr}f*oxrK|J+ON{?A^V0 zhf+qa-mmU*{7e+XMp5x<=F0uBE)v-AE@CIAaAG!i>%dw#o&)#zvBMzxgihSh`Yhs4 zT|UWWH{TX~g{o&HAR(8rxTEseQL(u)Ny0Ko)r6w7$qn?|Jh!eB24rR4)_Qy+wl!?2 zrg!L~^H1gTPi1omWpnQ$JZzz>UZ$&>4`S6T&Dx^XpQ+9&+JytL(OHH~9?u2OC(MlF zMJ5dedCo;^WElQeCyT!9Rz=w4(2X!W%&s9q8%-1-*L^Xms#)wpS{%d8Jg&nUX5IhP zybqh_1S``gSLO-%_K!*(Y~AOZ@O&86yU2HjNB~VVJ@7xw_(xXc3J)obLxWMhKu*LRrW`cw=i{k{{Gx90ko3T-Yw+$up4eSZqdwx0On*i2=)#%^bLxaFd7%wT;rUXBUz9F z8>)C{HsQ3lQII_0hwac6b<~e6zU$v-j#kLDfbITtZ9Xg#VcZwlcNlsx+F^8$<6yY+ z>?}=U^C_usQT$M+scjTE4bQAZq3AZ)eJ#+-!O&bjImhbX?PXwT5| zVNrMp3@=)=?@qTYUfNnbv5qn%{CKZF?#?M{kVz1F5!YM>5Q2O@%hTRvDE=Vj)@^ataP#9M=w!-{7c5WNWPK0j=h>VR;`AX!`w#QRox@bj9ud4nUV+&vw-8 zV35(%!p7Q?VpY&{RA@2?(aXiC7JRS%U)OF~y&B32f9OvWXR|)H!K%dkb*)}?(+vB} zx~js}QmnHlyud#3f`0JKKC8KNaC#2DAO1zfEfca=RUoKf>v0(FMugL^qWmO|v#L2j zGb2B0KlNzyc-e`D^=!%dfTP|;4O5FMImgpI)x0iasg2h(7q)Lv`yx2~6wfCPD2P$2 zbkR0WE1M&18uIOOD1*k1)q{RIO~$QB_HKFDp^S0O7|YHK z-@beVq|fCa+l41qWnIP`;91#IPD}I|qCd)cY8X0!nQu&ECI;-gGU|0+7CMuskB?BEwiMf=aO1w2E^t+=w7TqX5_PgUVMIha?Pwfmz)$(KuLf>G zOiMJuW@zq-j7;VuX9`jEWL0+wnb!kQLop^9_M8N=0oVdgJ_o~hg_`=d85 z(vFW@yxf-aX%tjUbphT@Kr{cn8c%q-*?VHHuMW^U>uGBBdCJr96W+ZEWgF*^cyvK8 zP6@BJyaUJ{0~sEs9ba^Wz0kQ2qWP^Dt)LZm%pK;+F4&k`hRpZepCn{$GxdcQsQ}-# zW!Le!+ZP(DZoEl;W)FV&^UL>>q;4|MF>1t7V?c#1{2ZZ4tbc)kMcWJp++)1dano~2 zAN$x{0dT|w0vARj|1rAWFR7X?+5nguy7>H!wTH4(GSe0rz~GD)Q%A>jSzGE`HBl9} z#3nLx{zXGAl+7ED!+0KkSlFU;4Y z3|-imN67=d23-!#_k^%+`Zd_^sf^C@+b9!5fmpu68;VLv+B@db`F0mRKjEm9xkyWo zo4!0Y@J-tU6^P2SsPY;_&CHA8PZt}qcN4>!M?U*d$c?>g2E8dlwP(Ex`PsD@b$w3G z?me$Gn-kpO`HEAOu?<@yXaR15^uPhI&~)HngU-={qsy^@sbvQ8Czy|)-(4OI$?jy2 z>r&@5!Vn3`K4(2>2e;O61*2r`^b!d2HY4lmyh*KY2L^cmw}_T+P@&vFS+jfRwCVdS zxkU+Jul9dq5lWg;f~ANbk-q8P)}aDcZ65_5KA_K65mJWRBZ0Ti_Zs{Co$8tVJ3;#w zA3&k055#EOoM+aG3)ezm$tHM%%Rf}vSuOz^ARoCy>q`fWB`sCcdG*deY1a1Wm$nJ3 zpxtkuJ07@1C!_+pVr)d+Oa@qTSZKT1q$_b66r~w>CgVSg2tbI-35&^9zBu`%tYubkP3+qG zGu$v3l2%#pI|W);s=m|#wpQhT@kV3{|8Z)3m{7g6i~Z+BdpjTO?ZjNwHTs#s=JNeN z%|?4lf>5BX2N!q))2}F(Q)c207u>Z%MwNOr-+S|;s2b#N*17+zYlH%GnHb2}Lt?ON zT{rqe`}CIZJ%r<`N6haATdw<6yg$3{Ghes`wFpL`Qpo39K3)M24Bx1eF4}$|;y&D) zeHY)74KT&unMhoui|7SI@X)}LLI7Y1B~IXq33qTlOiCzi;}el$ehc45@A2_fBoeIL(o(5g2u zWl)1xxwzU#e}b$jfKMMC^l9H`PLBek#tB`tHTVWk%pu_Hf)*CaGcM!Wz8s1A{d~|0 z`{lr=Y8UWiyzYf~_KXD845{f~?=vvD{L8}Z&dMafkNPm% z#ec6D4TCsdJmfJ9+!K6Mav!Mcg}uLs>U zH_|4L*4E#xR>L=YU{f#pWA=CCh!iK{n^%GBQi8n`4={?-zMh#2RSIZIE1OZ|YdUH% zsrS0;{seuSGqN&QrHK6UUBdoyQ9fsD^fgkl@9nt{wtMNR)50=+KG;dUaP{0_ocX)A zRB}o8*MMC-U|WBCR+J+w;2U66pB_oK$2t6C>s;P`s{VH2wOA^e8_<#(-> zl)1fy9`r%(|b_&P5^mkcD z4O~l$c4%T_hvo1r%%xog&w`rIY5MPg6^x59ai{?ztM3 zDF-qQcVrQm%wJ#akS>@DdGJrQ=A%AbSx`U7Ni{uL89F-lyqdvU4|8kC?h(KU-`u*5 zq4!73jO}~05Heao2S24U<19@-f~0K z$+yGJ#YRS>_R5rVryyYdVN6n^|!5mzpMJ~>=I;`+-Wt3Y2W54hx1IsfQu|NB zIm+WkYzplEf}Y{YP!}NHstaIe8n)FGK~t+=F?nZspX_C5`qLjPAcW zkHPHISA zg`Db{far1)n)UBU{8Zu$i!mfzJn7TQ{MCLthgUk z`wc_WCI#9q3n%`p&NT9``CUaC@%=|aW*7)~b)aZ=Rn_w);Txc&MeZ))P$^}<%%RYp zz>Jce@3C3j{f79t{rkI498ol~0}y>uk1g?|~4NiglQ z1U8nD+H|2SRHBauTKXykx&Gl!JETi}s)t+{75bvA$I{X8{e4*v8*s_C7Ay&&^V05p zJs*ax6hXq0i0Lvf-hY&jz>k(Mp{<}WjjwI=~vo{8tv6#h3_eK zN?j>qPUOtQ3Dhk=c8EkU-Ro&xj)?rGCcd#(;co=7<7cZ+`3qZqvy`DTA1BaPG)SJZ zTHyIhmuH1qm4+<;5l$p?3oT|#kxRgvLzQK# zm~vUeBoHDN_GICu0N3s3?UX)BRytp*$8v1Ev_Nqh!iLTwcVug72zveGoDy5m;h3=c zp(;hHWApvmENs^Tpj~EL=*5Ho2`eh~51(SK!CDY5O|s!6_S3Hi#E5n>ue|bRp=3=6 z`MhP4xr02@pT-fER`NPGr`hSD_v@$NtDBE{|AzDjyoQ0-ni2rAO(_XF`7`SC+$KLy zWbF1`<17w`h*P?>wkxE8gelJzdtKDs$!%O=ukJi5gy=oZ{u}#Ui0+KFmfGbOWzWzo zC(GYxQ34ntWnzkZ4E8|hh$QjI&=X-{UKK6i@4u$E#r7@ zZeEH zHxUmftuGQr!wb{IzI=|HS$33^$R6BfS8t8An&#E*7Wn#GF(b=wl%w&xjTw)~N^rRGXI}Z>I^jn3`ksWpoe2qCm!|Zr+wSmJ zX7Gzt*b>%JX(X-52oor^1&}J5;8c3xm=0fe?8=l#W@X6N0zN@0WDu3d^84HaBX*>d zEAtjCERM?|yf*fdCy8S6&?60{dvh2IvW>z$w?H<)nU=XM?C<&ZHEc<><%&!8h{|wU z8vr3kKa*Q^e;D8F;E=TIrTesUn`d{g#*@2^K**Us!m_`A*^uBTgC9>2#;rOjR#R7<-|9Qe`a%Ju_Qt4EZ zvdv|DhVwn}y}UjQfH8o4tq%}om5PywHcy@%P4?{Bnk;SlHjU-_l8BTcoPSaRmQ-(s)Y;Jpp+14U=-L0{xiZClgYrPUEy;7t2xaaA*}+))fuVwYmP z588g#SpE785i!w3hWkwEt^D;bYmZ6R*fwQO+g`K4^Z923oHwhuZL}e(nCHQRzMg1A z@!kjEc9Utk{gcP&5T%DvVBKrylvmjEY5zKVerf1(Egnr&apdqEo4I<-DhZRWAQ!g( zCBs#}Aqhh0Nnd3923ePX!bXh5#X8N{hs-06JJkmanY*1Pu5aYN0pz$^^w{NT`0wp^ zRM3E8p|)ryboIgz#psR*8NFT)U9R^pex7*T6Y^kg4=*Mm;OKhRGB}DOsat8^e!rK& z2O;!y?BJ>I(OYaj3kmPg($Q}qzSp%Fy;CU6!~tQXL053o?&ErGFmhplT5Ob_B&yy* z_cHWnE;RTF-k$#!@~F-N3GCLs#%*{3!bOZgY6UefDo+^ zPHa>sR0J?c^wsT-1O9>?i0utkOQ%{EOTV`b1JpFem$gIrqro)I<(337q9lumCkcF& zD#!%BJxslQUq#XUcs{I8f4==|eBfqAF(IXdt?nU!Dhz9NLekM2cF)tivDV}1X>O2a zST3g`i1UTQhu{vsjgk}5!FVUPwgkTJFidSW;Ii+J%P#)u9Mpu#SHM@~ zOH~*n9RGwj;QXY;8tyKh=(Y|yl}zXn#J|-!atC{>LxZ<&%Ctg$@#ALs8ArW{iM2An zAy?HBQiGsI@#iR+f0~~Rsuei0KliCdB3s+eS!YFx)=Rnb+5$h}Tz%o=`dN=n;H)Fa;TBa_^nZkuN{0sC<+vEBo)5 zqb;TfV`=Q_eJ{dh6y3TQAzJn#;#YFA$LJoEfWVuagfzh{d9lqJ+=qp^>u6W1Nv74ZuIP9T#~gLQ`PAC7otmdup@y56;D=3%x3N;(WpC zXH~eLA^du_l2x~sUU@po;S%D+x`i)ChDnW*Tx3hsf&Jm?OJ|6% z%=k3{*vzF{IP{X~1049Oj7ld3QQpt<^X@oq8!zXMI>7=HRZ4OJN&cHgjZ^Uef8wrY z-^~;~WWV{|X%o72(D#P+-vo=((%Y=GDfJcfI<^l@q;z`P)-^M6ew;cIH+^G=th*l| z@+Tu9Q#2=iZE%xO`ZY~%fGE6!Q>RujDtQZDIq@p|EMq>U;dbyvwWD2FZZWOA@ee~4 zn06@3$SPtXX<#wjeWbX&La*IXY&~;?yA+LO8{{f&Y@N+uO~MG_krA8ET&amK`+%H* z7OFH@NWiraGCqh*Gd|(6YTnM;XarY=&Al~v&w~Z^_SD;m8FJaF_um(>CY{!t%t0V{ zEgZda#P5!snd%gd-qWAFzWDH{32}6=Z;_=F$qX?-604>HC^~-0o2d<iTQPt=K3Ykry z?ZnYi*;suKMYT+1$ zn35IWBy=}YNCMQ-7>blzqZWCaG!hCEXm85@M9HL-56*=dxJM-+@_PUBE=oJEXyCs5 zVbKvKfJCycAN>K@-lofyfXfUG^C<#D6^)1q>_NXZa9?p0?DTMo@1>QBI^!dk0aFRTU2v8Gq5NL5YeVIUSC zfIk?jb%|s-Dt}w?-K;P7#g%RvqwsK%S z4`j^2ycT)|MNbTTTmR^ecxha@b&rPxc_F&|wz_!@f_^lx$R2G6S_Wr#=6v{xaTy%Q zU(W-w%%Yt)Tfy^A(MtP>2t&tcY|+g1f&6{S#P%no*&}Hy!6>1#H*J1~_QNA|Ld0I0 zBPVEvREX$|IMylg=mu`YU>mJO1WFSckL16(UOeI-$E)oaI{7(_F<3&<&M~e#4bEh( z3LE@$!~GJM_~`#7Q5qO5H>4uIV8Q1})_%o7A_sQr#*UO`Vo;y^$x}_!y7kc6dc}V- z&-zsKi}G(KY4&)gu6#fJv^oE111j00HLo|`18~JxkGuAvudDZ=1M~)Cnh#?i^{!Kg zrL>XslSQw^k0lZ^Yq}jOr?ReE#%lafdkyyXOs45yBVeeLdxR6gX#R;rp(5p}`)7eK zzQ)5+Ow$}4ZdhzU#J>NeguuvEIuyb6E{VZRUvj*J0etTus4-9{%SxJXRbK|1<$m87 zHTn^uk_zp>`97F5w}hYDe`|OBxV^tqy~yTC{ zx5#w=c|Gfa(Hf$2gJAdg%7IY(Yj=wUy?pL z?9^kUc)2$_N)wBEYxCu!UGbUa2lg_oX{FLCe;PJHm$G~x+Bz3bf_H@-m|Zd(DZj5^ zfT)#O>iTOMA@Ks0P6-`gR&2`pQb~lLEpd2E{@xmF*WL4gIBYxEhS| zx4yY8U!Nn|?;Fw65@mYMfxEtqDpHK5*?Phj93_&T-CbgrR0KG}g8A`ARND9}qjzT4 z8zjVaCxjf78%#7BI9rO2@@XSIgx5|f5UZ5Fac_8Sqb(7i72Ka{e5_oN^{oDg`wf$!gQq>rdeA}p&>1Z!zNZf*W zL#wW8h#-71A%?Wv{}hP@0W>@%Q4V12MqizEi zvpjrWo6pmG`lbA%+>G~SVvR<)1`$EwzhW%xevLW|*~idkShCn_9`JvpYCuk2<&aN^ zO~~~maLwiM5Xa_2CTyf``HJJBekl5ziYPeG+gvyLD>JRpXaEa6`9PCtv^Xs z13yK@n|x^D-R1_Gw;n}fdT2Binsl35Jm z^C!NfZ@kPzCB${~F-TLA6cX>1Hm z6r{f?lJ89C^3S+_J}oI6M|xe{O)_`1K^6Zkji2-dZMq0b+Z_4Z{pJ_**2dr5g4`XT zYSI;Z;OeF08<(ctz@Kj9ajn%uHm5&fBPqBa|c&xG*dnIpV^WY0BrVO(u_?X z-^D`J!o*E&LvVN#fW^lG`LK2vxmFATYM;znrh(yV_FYTCoOB=gtR@LN_$zpiMnnm~uC${eJgA!B z6_c;0&~o^Y^eG((Qv~}R!bGUW1M~O3{f!xt8dd78j$aqc4473i&rIH$8COy+Z4w84 z1sFQggzPM#1nCPn?RD3cP61xtC0mEoFb7H#0mZX5#2E#sZ|z|5VNZY{j7epndIrf;7$yzs=Q#CZmm;VbC+ zZQxwpj3`?N|2&$v#uHLsw>3BQ9bQ5Y+sMj!rl^O z6pcB3_5y$Z(?ptH3l#8F29mWRzXRK6VP(oyd$6LnU)TtzTi;=S^VlIed(5%EvZO|)I^2=z0!*vzl1L}aZeIgN7j;VzG zVmUuh+3^d3#p`9}zr*bF)TyNEBV{{m_04}l>Zo?`2>@_X|H4HmcV^o8b>M?1!^%K_ z!4_;nOY}_z7$q6w`>dBvOZ%)00~kKu$oWQ$qo`To#Po+X$Wi9@iq`K6{7svxM?}_H z5VtLgGI=^tzDRYFP?~HYG|&ovt;Zp97ua)5>jSB*P4)Gen@H!Wh6gQCRC42n zr{E~9u0V)B+0Kx%YZXqYW_;$&`6?9#SbdQAFqcP0+w0=)FDkW61YL4CrI=W{0^#corrs$xnt<|$?oy7_o&sPux`MX4N zioCG{i&v%%nV9JeO9`nOlzz|um1ej~&XOd`0_r=M<}HEn+4m);b5oN~hrYL7w;}}- zXGRXE3QGlXshY(;GnEC9Q`!8y4ZDvLO2F>i#f{kbLfjLF0)+B^>Ms9XXC9yzzfv1T z(Hcc$z=Snn9a&Oe^jDC3P7mnk@%cf8eedT4CDyY*%u7l}i0t?IdJB`ftMMdcM(ty| zSmF57e%xuzWA3;m%T!+F8W%iZ!P>W@OZH^z%rgRCeCX;LX1HCCLFenRKp{^Y6Z~QC zZfgX74QeK~O^U_ga?s;qv|j}>CwYuz4QG7GXmR;I&1y5D2i<0hGT)TLVqeVv0Bb;$ zzqGJk9js#m3)%!OMP@e0a_QDtwaV~(fun~<(-B3IumWLPy(YCzm9kVlEmM?GrYfvA z28qNj285s#5;$C79$u2;%j3F>V4fVqmCi+H1Q1DlH<$wtPF^VWs$zMTGea8%UR04i zq;KK*F5%c?kYytOI+5MU7qUXKIf09X;fvV;neEV4Kea>{p39C%<3de-+I;Ht_>OZI zuD+!9Z|&D{;NQ@q{p#k;(p9Tb)E&1mI^9C8$(<$k;K^X?&Jae56-hcXIRCjilNT&g zEkOQKvF9kWbQ^^`XsZ2`A`^l#?+YDlh;0^G<<}s^n0HJ>v?+tj1W7HbG#DOVEDkLe zg28GQVa0-3mC}U`s`Pek4%wJZ(&cn07PadacUp?UI?ToG=0$Dh!d6pZv$3GXkk_bB zTP826mZla;q8D;w68Zl1TmT--Rm67|4G*oGBIbF~XfAbaDDbcipukEPcILfW`LJXq zEPCP7APj2adBX6!YNmICy=Nh~g74Ra_|=F5i#TCL_HH?!T$n#Yg<(IDMZEBI_KX~U zR5BwbftHcWcteYM+_QxPKkkzM(*@z}QEN}0j8Liwh{O?fEDie;%+^@B7MRD(og)GM zz375Cxei+9Qw>(=x|=D2S43NUT98Plq-hlk7SgKRbYmKC6^eaOr=mdK6V@zoL>*1% z@NF>r1rQ1<5f)G_^Xj$XWwPhm{CZ7j3Vm^_DQ>YMI!E%XMP-T08_lt)f+Y>+*{SgC z;5q9=DD&_tcqgd{{5)#J4n{yL$4xLi!{57!V|Vgg#2ePAZ4&ta+wfYuT7FO+%d>=n zHZ+7U<-=I|&|+>#0rbfUAj84Qw6J7aWFa>&g%Xh~n4ZCmT1bzc-@bqU>302wpu=j724YYE&Rczn~wTLC+#jLLBOBK&DpG~qvW{alfN#_(H zW$n-)FMgK!B~6x@$zrgSB6(h&)=AArfrqUd9VW~TGfxl*iFFLGR*o-82-ALG@}FlD z2cr6r7CsF7^;^n?>H5IDcRS*@nCw+)U**aV*32*Agjb90%Wq_{g3Ber#iFnU)T!B` z@N{ZWd}maiD0pt?%tU&6_S@N9|M0UfzBl)LEmAn%$B(}H<_j9{r`7Ah9)0=slLrqz zC$YY__I}nz$ANbgYQoa+0+|EXujQ=lbCpB=R*IURMv-N+d9VaTj%bcXpJBEpS*!sH zr6c_CEP!C%n~h+oV+nYZ%xsPZs1BnjVRCz5l`yP9oXFA_8uk^h{HUp4RNc!CCW|4w zhwmf^Een}?sL2EN`9s6z1JYowM8svRzIbKXTkt7Ig=DAb(9xW_ym;i5| zy)Ox><^%IEpuoR~8I;EkEa62g<-w4`?>w-PpotsTwB0rdqoqRO&drF;=bv;JtXVWQhr*9ZUGONz+8u zII0$y2aBOdYS!(A2tR+tjcN2g|60*3l6nzSv#*g#=~U)W&9I=&%vxx@6Ni)|Fx@p| zi3G(ys8mouv&^a00QQS077TUA#Pob|S-WLsp1oFISf`(nBpe1lN$5f2!*aTAG#-J$ z^W*WIv?6DXh%mGUMgY7hmMimMB3^ZN=D|GL1pW;S_p(j^9?ZK{>{lZI<^z`S14?

1xuq>%2H!+`8Xl!$_gcvCajkx3k=!$&M&%!+46FW^MQw1h@iuU)r$q6>fQ zum3FYzumh1`<-9mN;kLe_-W0CUk)6Z0O0T6zkB|w{?Pf{W0$keezfH3m;6t^>-+xJ z(O>U>`7e120Ow!c`gz6qcb9Bl<7<>+ZCBvs2zqg9pVe0+nyJ^N+N|+blb1p|0)9`G zETg;2myck~V+j=g2t*Ep>N3^ZOu5;=9mds2G1MIs0qxQPsV$<}&OBHwe^tTC&j9)2%tp zx~Lp+R1Pw1xk%KoNuqKk@kH2{xVOajldlMhcZBD^=wKHXj7#o%~Bi@lsr zU~q_J-tbgEirBM-H_SX8@u{Hr)^O1|yn(RfUcJD#MdDw^3t21(DiZ+nK}*G986A*S zU_LyL8=S$Ink$M)=R{-)XT~$46L^u++GA$YXUEk3{`=eJY#f1qa{s{}zyE$~$6jc$ z1K@w&wC&dRJTH1ljz6Y;49r@7Z$aSC7 zH{vgTy43M0aQpUchy@@9o4nxwgsaBOE>R44468Y= z&6al0u^Lw??~P2`h9 z39sb)!V*_dnP0~DY8D0+vx2K-Avx@zr4nF1xQG`ruOlXp8&-w{r_mx(InZ<$oz9(= z%AGNn9y5htON`t=ZavAL1E)(U)xT{p%kA;SkJ!C6q%{58fUg0be7k zFIggp^T!)_$P-}j^Dmdqe4Kgsn)}HQ6OLV7c<_qbu^Y}OK6M2<{)x-ck6r9ffvYoC zpIo})eCwLaojY&HkAAWI!nfN#`svhncRs%N@D70Vrd18#cfsT7*I$3Vd+*+vFF!S( zJzBMMU|PT0+5B``sHaVo)^D1om8bNY6INKf3{q#keISaXMR#lCO$LI{P68Of?~6zi z?C*ZJ%{1v;p&v_*%(WV!CXZI^G2oN*)<~wxmn;YKA<1TObZJ5hOf~VV6@@h+Fq|eA zv1K7$vsftqGI#_m;29E`2DD2Q%!Q;6Fe@g%R+m<)j!8$JYx$Mh*m&-;I&*vq5<6e$SI@=jEFSF~^k>?CDI374yuv{=P>QSFGWI=Y2oRd z$SiJD9w%@?JG9$HXA7sLbE4xIGvZlu=2B@s0A>mFa?*O=X*AdbAz9XH zC9hP!J~Kn z?FYa3?FWDO;~&t6!WR`L0M97W(A5ZjGE77M?Ze-`_Va(e`p)ZzUVGki?~?rH$c%G@+5Tc7+P+@`^ zA`3O5%b7M+DSc>0EkR^wYW)PR^~jj`)ln`(9O&Nn2qOH3h1YY({3!MXY+IWol+|I_ zgnftLNnuPLOV>RWq|_Nd&V2WXk5sHBm*~oC^gRRK#9rt~vAz=l%M(#EGVC6=L4@Bs zmolS?maiA~Cb5ype2m1D)T2y-K6ZT?XkV8$s7W5x#J39*yOiMMQ~C^-%oE^xbM6yVJ*ienNR)Xnv^mz2eyUS}1sD z-g|5R!h<7wUq7?-#<|09T|D;ol~dopzVd^c7v8yZ`Q7KQ|LDc@Z`?Zk;M$?rZ|!~U zdg$Rr*Znhw=Z`Dy9OB>HH+g->;N?))1#ib;7j2(8d|m`{>xiwXFQdBaaD-PXk9Y|| zbH!eN@h*3?U-NPDK?;Jt*Ms-lVRagW*~3hl3~>9~gXOz}@jf#OKTPbIyBAW-7L13D z1Asrr?x^&6%k-uSy(^w;0K75OHx82KO!cPu^eHojJvKo3C(K%AgeLeia!uaJibSo46}4Tl0pw<{Bdi{r;n)NvXlC> zNdsD7K4Dm&-YJW(od)2+5OJ#xHRJ-qLS1%=IJeo9(`3xZpDrkq{`hk+|33^odd0u~@CWyQ&W%W&cGLV`YYRcDQhL{E1y0FT6NTzc|*js%kxEsyS&)S>oZ22vUxVHd*qr zp#GGtaKD9d#9MgKzHK{nR#I?Y% zh>(V{KbYt=qZ`o({1#n=Nka>H7`sEH9iA|kHr8uoEC!=>hK;nt7@Q&5?PYov;>9Mi z&f8%0SF61Z=6TSoO9p{Do)Mz@jo9O54k(pN9M@yCZyfBJ)7&1Oqm4UQGoF+>S<#TC zp6i_nnB12Iri;e@9cR}RtYF{E2+MV)qmGO=V?nPK&(drH9!?>`XAZjR`rQPE9>uclYv zTyt^n(p(}RcK&lA{VsooSgs}z zc@&b6k~m{^_qS-+r46b9fYcI9_{e z+pRZCZoFD{@#Uy%Z-qhdfBnt!!2N35D|GA2G~0vX?T-c*Uh1{pquO3x_xTX>haPot zZx40dY#+NdEWIt_-I?gU*|ZV+{yQzgJ3{8lO#5ZU;AL&?IWB!=r0CMbMk~EI*?U1p zI4rAQaWGF>(hdqfPJY%gN&N{&)S?u(S6994tv=#O*r|xvwcb_yJ z?Lum=jtKe|eP-BuWAX!4<1SQEKEF>lvTGS4{Pw*+Y)~I>5eC~O^9_7|hsa;Yac9?P zpehD27F3~w88H5HYgAoRfubgJUbU`g$cK4&Qn4m(N(1QwQ5;32REra;BXl~zew$Ps zq0j;IapQ8hsYISJVG5kQC2>-l29mYY%IFD2{ICWt8#Jwwo24l5P_7G!v}r79Zo4XZ zz?cC$a}04#iz&NCm0hRKuG8jH1bIbrB;kk1BNs_YMPfWbP=KGb8g_j?V7~6(^;H2L z{T%=F(eJZMn%KQ-0cMNCvK0*?g(dZk8UKNeW#0mOImzxl5&0m$REC} zZ@eJfM1Jk+T-SM1`~iN(Q7P-Jt^TAX`;ZWKXl|p!r=8T4AF^%TArIfBjM}LxKI{VW zGxzE@l21NtqwRKrPAe+#0_ES)enZLLK;>S4#=L2ZPw{L}U9j6l3c4V?--JEt_P3g~ zB%h<&YcI7s3XQI4feu6cse#B;@7SI%w4&`Fq2+VJq#vmH|C=OC&F=7}r_@AbqoDS;%+FW2V7R($$2g(Awz8l{k> zW#lPXi21tH`#G-vs|7rI10VkWXZOE9arO1=E3a>Z<$4J9!M6kX$nmYLTW{80emQUT zQTow`Erxq!6nm6>*F(nQO9O$IT3~q(X84|m4Eqb5&g-p!Jxc!M?J+5ke`l=!b_?qB zC+`k%Zpg^z#%ZflKz`$@c<8dO`Jxyj9|HciOA^)@UBa?3@u(l((jj}zvX8jm9I-=!PV6g|Jz4W+lzHq3ZlAEz$=Dw% z-0j}xl~wHVrhBX#ncr$R#yCu6iy=s`!6}UqA`Qb9sMdKJ?F*IGV6w!DHhA8MeTvjt zXPr-*Hlf&KM`-*Ci}qBpv(6N%gHDnfPl?bO8Wbr9W$TQdX=L=fTG?(wg{Gj&fEl4e2d^MjrXo}*Ygnq*7Bh(`A(Nye zqBu&TK?{MR#F_{weTNJ}wJ}zpj2Ee+CnT{GiWn$*7hAv*dllHMBzjhtST~cxUZ0Yr z4I7f6rQDb)txg83iRrZpSXn^U7nnj^2^Vql&4#Q}F^Q=nlqrd@N?)NRWY59{6y)&9 zIa4_)gU60s`W(P~-M{6l2t4|kzWl>md!FO%dairl3k@e8Hm<%}ckPX`8*k*oQ{eJz zVK-hcxb|}8`TJ$TI}FnUDuz8m{zIDQQT^geL!SHf#(Si|qZZMfk*?eADE8fVI>6_1 zZcoUc6Qkr0+-;q^%jvx$2If%%^6OSbKz`e*6eYj#!dU+mZOKVx(lPP69T8QYu{55x zla9zx^3#rr>Q6i4cPM~)%)squEtwcceWiz;F*{^2yA?G@d@(*920S(Z@?!(WlD+pP zh}F$}LgVN7DPz6b*EOkw<)4KqO`CL~4wC1_ok&vM1q-LGh21w?eX2l zLAyeEVRzrr_gJcv1eOVy&#Tq9js=)q4&WSP9vif?eI%+9th>iu%P>($P!FfaD*s|Q zplr3CJ_x&v2|QVJ&nyN!%u|vFo-mJVmVzR6ZkHMd#r()*UIjZ2SEUg9V@W3G}q`gN!Gb6o#d7kIcC z*t)y_-l@R-=IsxA1NS=hcgf(=jrYj&FEI{2YCQdN^U4F}$_uPLtM!JfH1kVT^8+f- z4naQ3Ka2w}H!r?8=z4+Sd9H8lMi0vTCh}!>Mbo!OdapMLZwpzgTnu~E=d&&fhpy<_ zSLKwI;i3!UeHZmP%aSM99}_1Y5tf}cH=S|NkEwA7xP;^Knq#)G#q~z$8_7@DqbWb) zt~l(*@77i9b8LC6(~L3?z=wG?=?m7fJ%P#vAKhWAvABx#&IE~m6YbbGHO1m7)BB*A z1h_${A&Z;Q&{gg_YpBMykfpH43QYh^i75b@^AOu_#6C~tWXb%gea4kx!Am3DM!v6n z!kN*kPjA)2OrFX0wn~EV2&mx)2y7c9)uwhEYWaR_V&ALU(YG6s(`i}!KDBoNm|rt} z&s4Ca!HA&_gZxT;?}(2=*P=!%QnmJ)!j)PK_z0sZdP0T-_Kz)kT$0)+iQ_(Ao|prP z8zt)JajlY+E^Sh~GO1OW-K$C*HzwDMQ=1j(oqAwCy+XXceNZmVAq#TLWd*hJta3>f zg$HwZ8YI{j$@1|+N`W{(b&8xmM@<=i@4fdv2Qy#mZ~00CkAAYh|I=@-yc4j$ROEhC zx#Q75;C{RQd9vlvx>tvR={{BWe39eUh-`ISbz{tYXWV^n!gqhf|5BgtVXNa|6};d3 zVypj7um19I`}wAg*uxn0`O|l%WOs#A*V(*lVwl)(#2zKT=7OOAlD1=2Ua`XIzF@)~ z5^lsE#z212Nj;EXbW~NktflTV!o(f}9veWwAF)G0KVWCHUTl**s;ymemRA=!j*s8$p5& z>da|_*19=(1cVxd{#K!f#c|>pD)8#`pHBb@{w}xw% zrHoi|yR?u<3)yw4EwYS0RT4{>+NK8Jp^T4EuL9;X>(n`AvivdytQQi>q}h~7m{}l+ zy)UoOlXmUSuQk1&DR1L$d8nPq`8W9;mpj_7wIOz;t@CR8#EpK*?NQ~85$Ot>vog(Do#WmR z&fO96?#@l$nVz^aHhg=i_hwhe)mF~+3E>s~$XP-D*hIwLE%kSa6Bh)t<-Yn6STUyjlqh6?coi^E=7evl0ilLns$jxSW5aiVws(Kt9qdu6!H}`uXn8$!8 z(=>^r>TN1RI7gNMm9Qc$czE#kQF84z!P)|z)*R24!<8p>$za7HWmuIorhzymWkemv zgX%pcj%@eA{3xN1HU)q}O^(pZX&HUqv2Vt83zwFdSD0e)h^Y(cS- zRI19&o~Du%q&z+;b(WGo%cRUg6!|%@`TBm#*9Lg>BYyiAcelUN1m9PGe(A2GH+ z81mhJvU&&Gb-_nHqFW=qH+sc)#k?E5_RH-ZH#)lRbYa$cyL0v?zkfy9aYj|NJe{{Z znty6C@5FfCvGIaqVYGm+o6j0s&l=lSOzr1P zZKw1tM^(%nQr000bJ<>d)KPoXRddu?y^L7Z5l8tESMdQWb%&vPr?b#yiPS5eh2E)7 zq`Ar_i*^|+=6%T;L%BD|@CEZMcEr!?)`eeC_){(RYIiVNq+QP~7n`!wj#}4ZtuvIT zvqAUBD7g`DbTRFr*xAP`+Z(d(AiKWNypS`hE10r3NqmeMXPZ0(&7MnU-D!OWjDNr$ z#zh=gqaX-)Z=@Z@#C}`tu#3Pl<}nShR@XA*MpnPewUina+q(lI{LZEK8YQRN*zWF$ zK-Yw?f6Cj#wzsx98=CDkOiOu{kyfgTl<7cnGm5X-1{?1(eG(6N=-QX9gWrp;}0xi({wO8Q0 zG$twsSKpx}^c!ktj>lXRXwo+n5fs!!?#(Z9{rD4niv}2aX)OSn-Dx1t` z-D^pYr@`?6u&2 zk5uVmMkSFNQ$n8e)M2My%VX-NFAHoZ?4-!O+8Tmv4Qe485D#RBuG1E%zH5oFh8 z*C+^eiVVpAEf;4M3t@_ZuhJ08<&giAm&YgP38*wp9)7MMn*$4nR6LKII!8$vbLw}0 z#ohIv-v8j^Np(;j1F{w%T!mizrqfTkqpu(E=*|54_aB`7zRC9BsRUaH_yfrL{DAAl zdLZBapw#=~RR6VZ@a-u1;PbiHxviI4F`v)AJv@0uS#W9!*lt->1NjxFg`3Eyo#l3% zv&8Kc5tj{hC)_2+EYTk$Kj(<1_J}*oE2HjnGY$sx_SiO&AGW9=?{kqt&MhVlKH#p| zxll6iiLz?L>`1d@)S$}}Wx+>uxIpOzk#o}+5(fx9rmRj=^OP4uedB=KXo9_WQiWz6d-lEo1tP7%4~p5m05AqtTBc?X z_-G^rNYh}7`k=HMJ0{2Ud7v;B$B{>|h4Dj3NRL`luX5vZV|<$+tx1A3t6saE+*T!8 zCkHh%PzIY*qXfwvJVTCWO4Ddue2o^ECs!yTuqP0OWRipgjvbiCP0%vAlsrBGH(8WD zQ%B={m7{v7a)ko8UmiQXk>P*8d>xA6e(GO*SD=8E?MA-2M>-vTISd52v9=E!*!8x$ZNq_o~b-BI=L!odFoz$upoTyo&5h*Ju5GZJOScEX?Pq!{Pu#v!&)7Mi zYOnzFm;wCR76$|AB;!xe8sfBuI{!kF*z_^xO*ol_VGGsj{rDMEt2)#y^+NB*0#Kyx z*P-!n$A}iT;v~aLz=puFwj+i=ZFbI@4oxht)&668mMgD1U%@~{o&mo z0Rm7J3xiFkABTM7D_?Km;Z7hY_v)(`EY}3an-kVMBfb|$f)D!qkD8nhN-Qst%rBCH z4_darH0XJ*OLB9x2Kz0%r#qn&?8R(*zth4er^>w>Ir)xbI@ zL&=9%nlET*%i7pOaxfr2dyV{xo`Js_z)?oQufM9bsCg6hNtW5trM^nfoW zq}j5d2KZyP>uQ&RvGylPb)fD<2)KcBYRD4-0l!_F;IUNA2ci^OjCpi`NVRKCCEg%2 z7%Fvni_FeQz6OfPU_GzKK3{7HrcIhQX3o+?ZkF7i+^s2`b~Y&%n&iRYq~g{v4~va~ zoCDMXeTmtqvXm zaD?6XidG9S4@=_R_d<&?A2Xwg z?+~T;%9F;l>wX?2D<3nT+9FTs1)&Qm#Pj%OC5Yr__8D-{MiP;!g-jbGMDnQWPENf( zJ#&mwB7vwruUef$mk5_oA`>oE*p}ot^GJXUSMXn14*ZH0_Y8_Jp(Mgafx<5_3?Jd{AC{ z)Eya6KdFihX`({flKo!B!9dy$WU8OM%T%$)w<(1l$WL%v%Xfq-7K6zy6W(Jf@%x~~ z;>WRvseYW=Q0nk9_4YcAn=oa7HWE<%h9;kGJVNq0sM(eBT9voKW9q6gYh%dOM5a{?7D=7MSi7@<-}sMO%gRfTni z+8#&akh^}!UD0KklP$Ck*oY)KnW8MN_cps0-k~ohlp_WN=N&GxKA2th$C}OxUYQ`Krb(WSYq{WZW zVkRk(J-_@pqUHS6{I?nS|NKwTvG~umykG$SxAz}xI{oy!_&Wg}y~$tv?t|bf?!Ft% zg6F5Cw*~Ag1Aslc>kgTA9oJjiR-2noH_Tj|8#yB%yC|Q!s+hYep93dq3Gk;a zDe{+fRfpVIk<7Z!4{0Fa&)@9;@(H`Ggq_yn9llRYp$8RL^1QEld$2a-OIK+is*l&2 zXii_PcfQ6Mr0Sp~R)@;D#`8^(En6bAH)t2?)j_Jj0V!yk;h!`$=@#;lVsd0d3pH5r z${aK?gn>5IB1Ha_X=}a6liCJpEv7a;=ubbP9mAg3VuEaWVwD!fe$x>i0aYz#X1%Vb z+sUX8?jJDgK|v-z9=CieY@FQyyq@jNXw#)+L%Yc)v#8!eD zSJI>bH@H?OGpNZLQbXk*zz@Ku)QQ(O5gB4M21)BOA`yPs40Mk~qIyW7uT{WI9#P#Q zi>QT?f?^F=K3SNZH4dACWU7jo!zZT9P;p#phM+iUoF2z1!wvrSx4->#PW}hKx)J#Q zSi8>mtRB#x{m+j+dRMIe)BC^Jbo%L+`ga99daG~$>P^jy5-5oS6TdJmzb)KIKFa^_ zZT8q@S@Vj#cU50Rnw?ARe0^S~U;- zVOHOHS&$@gsV{bJzSRpy=FM3_A8TzREZr@P_{vV zNAiDbCD^FED_%6QD5vL#`A&{KrpG3jWgZ?+|#n zRhZb{dha^#xiKh!hawpuU-ZH>`24{ueVEVhzTGi%XSVHvsNgiG<)Ws0)lhtjhpyXU zj=kXQblVwy)m}s4enVt{7rR}MxnD^-tSLIGuR3O6o-o%PHPj#0RxijIOIF5VXYI1P z_L!&Um>aR{)S7}>BFo3&C#gKpSR!fI zfEJJf{Mj5EQxzIkc;6nKT^tcYRe6!b!<70VU+rny4RxXbljDW(80N#xGr@{3Ye}1} zZ9LdBJKwjfy(L*8^rb~_9ac0`X(#Tjv0lj>h5vI4@#n7 z?_F! zfgl$*O3#uMXA29HCrc8iXfZ>J_CtS(kgw+JfBwTC(3Udj0^fS;y!dR^FLES6E5J(mnV?p!= z)pF*8Of?eNV?q%C|M5}bqfst-!o*Mp!Sh#2y$q>`K8Zy583U$P*+S)*6P*d8u`wL+ zEyix%Jgw1yrp>~NH=$Nn-Ro+fLLLEa6TYfGJD#QL;CRRkC8gHT-0r|oM+e~Nllzr% zld|M#*mc*V*r(ffSNQc1;R{bl@~!LbZX;(@DQ(>N>Gke+;O7&@bjd?HjQK>i4p%yh zZ&IW|G4EQMU?yYT$wO5fGHEZ7Aj|Sqih?RxW+6X2e-3~zDAUnsVg&d+AvsGx&K1)M z%7Ro5C1o0zCnb#0Vmb6U_GrUbrNb<&y8q_xJqXu71=|14m;RoCM?aSjK78Nv$coj& zuET$)O?rS;(3~#9<1@3=c=Svtvrs?ZXiYOH)P+wNft=Izvx6Y2C&QP;qb4VYFidN{_8$ zHh^cSQS3J!;So?!XJ8I`+Q!_S1Llr)b4#aJy zlX+_Bb&F!pw97Q-^bq7X@4XtYaw6>_*-BieEQ77X1a=`+9@}w9ZIVG!FtR>ZFVAUJ zLuwErn9r2J;$i`GiES|EmdbLgRB(Avx0_cg$|g(z?FjQFDgs$TB#J0_2?@sm;Hh|7 zURYmI$}}})iX1auoH$;aGyYZBjfWf?2-?AZd1~dqL-nV8@83W0=*_?K!OO~Pin$xp zQ+L??H(NmT4&r<$j2*b$Dt>`0zBSi-xqIT;MAyaj-S|%=AD#v6s|L~`P2_%A)RH`E zpOk*o#ysvSIHLQT(*(-f>^B%s^9QC998>+l%38kF2xh<{o3L^UcB28VKl^= zO=Y2AO(>XcvxMt3PsleJYUUTxl;&8uB?c1dB}TH*!*b78ID!dsBMLv7LZ3coMzP1> zhu%^(#`y+Ih^lg>gP@(pmprP=8()`&0?`PXz|*W=sG5b&uv$n*B@NkHAxWmg(839J zaf3}9AGOPbbaB|gf90^dY0Qmgvpmhbp=H!x$uxl+Z9}WQrO8YtNFm3DRc)dUA^qM_ zOA_kP8A`&eGHy-<%k`03WAcQmYw0{h_-zM&k}q2Vua0Y#C5;)<`&76wJ>ukNO}JJh zu!oHXSdE8r-Rx!+RP{mHZ*GGut5ciNs7z^5WtE7~HN_mN0KGQItIf=qB$EV0x*TCX ze~wI(k@F-F(v$IG0G_ywU6jb9#Lben4HPB9LVfQCAAImBocwLR^v9q46b1f+wMH>t z#((;c0z7+Ql@({op*C(m(< zmM5@j{Kv^Bp5k<0w59HqVZI&6Cmu1?9(B?Vo3`y1!|U)0;J=!gPExaD#(=v7ka8~m+vk5H1peK4gHK7p2;|kZRQMaE8MY6uN6neP@$VcFp znv&#}a(l4WHBZplfubzEqs}oOKcnA>{W|=!CcN0eGA}mj{3UZH8sFKh43^KVbG}}1 zvQ~DI(Pb*1bT@K*kh_9(lBn00_Bq;TLS1vArYRq#(+K=$HfmvFU)f_vr7r-j0+yj` z)K@@L0_~`g3e`RRURI4UlO;#G);|UwnnT7w*H@((Y@0-%Jf(;i>tf{k_&Hs&Hpukf zqg#*2j*CjPnq*LslT%R}y%GQSD7A0Drp8;jCYxR8TP3k;|@XNUn z(ys&0P~$5VaA6R7;PJftOkP2Om_`%k7s~T;xe(9O2#VrdJ~dB9$8pHp#*32#1<&+Q z!`b=Iv|YV;>&pP_zxp#FVHO(2AY$@g&cA;k;L#88!$16P`>Q9a&&~8*QxB|aN>6i0 zr$;Df$0#e~O{;Sw*CaDnB-7`_?fbZlQ=;M(e*Wq8m3d@weuc9>&tElGpAt46Gsf(B zDx?SZo^wdYJnpGD>WJGbiQg+@E&HMZx{ba)I-oDv>#NuoND3Gr;7<&h%D4N&by^gA zgnXkR)ox=f&X)yzv3exnr}}+`7EiRqfHJ>M{+in-8o=!@RN4bnle19iK>Mo#?Fn4y zJYytCoXuMQcD}Az<|B-1YlOZwfv<%hsG0F*b?Bh+`$p!G%9Ac*!(6C(#I><$6z~su zEad~v_SyMPZm4eD(+-n%mWt8mtnaX6sAB_QKBYyBo0dbS00uk^q9i(~nZafyQ9^AB zM-ek9ik?=-jcJG~gXXjdCicTiXGu~w+8zVfr^y^p;hIEAt#Y(|H6q0WouNVN;&B;o zGKkm}&q0m=$~>uDhJ^I6+>kGzP-H|hl1@v`7ZUTP3o`ihd?h_kOv)Bg;yILvvBHEo zN?1QNY?Si7c9&xD%R%g`^;7@hfJZ<6pZ?~3?Y+gq3*$u>Cwi_a2QO(V&rAdMSQ#t~ z>MluIj~c7?>5C4i%Z_Rr&Y0WJSv$|0J1!WTE@&BN#4Tr49mkdQUD7Q(_<%YRps_ro~j@@uVqC8<|HOK4333!LuMitS>WpSz1S_+)=0YGo{`F=p@yzwGWH`Wn{Jz z8Y-{JIj6IYkZ+UUWytO{)pGppqWM-~sASXuu{|myfjk1b3~izyt;@0zdu$B+(>u&< zBlg~5cWV)^SLT+;+#ye`nc1krlf{r=z-Lv2jbdu0}_sh)IQ-{7f#odIC-yxv`<#C*HE|HR;Km(z_~cA(msl-U0h_A!s0z0~u*!I;b#9pS3iq11yoZSsCi$+20H z!rmecX0>S%xrlaCW}AWBZ*AlS+C`x{u0Id@#dd_Ib1Tisgf`sE2X3mh)xcN~-#mS_kzg9t^b2F{S7d{iw9FMgN<- z#*!5BK#(Zrf-uz-EiaK_Lh=odcgE@((!L}RX`(^0t1J^E395rNtd<}a& z@g@_rcX&Z$GCyw0Y|^Fo7&EFQ8TG2XdIjYC1NIp;5?H#+Y&YarDWH0XSS&%afbb=9 zQi(V#Z;D8j1MuMFX+$9aPs$ch(&q|O_(d5~auSai*Hf6pFTwGM&-NFGj}?XYjWWgG z0RIV(gTKoU1)bPeuFil)Ek-^FSGHY|g3oU_Cjjy*S9u-hOlXk|6vkyA(J)UrE00;@ z_lUvaqnUOY2URtP-C-_y#&#Pd*wA;m!(9mW*c=`P@gZa7c3+0YQXLGI1%fdKW4K{m z>zlDkj~i8r-HMJPRoOwq}mIuFu#r z=7N3_^dxl{1NO7gL7h0Tlp`A278a_$kBne14xTvl$W6%&Jr3 z8)UF2mtG-)Ch+)LEdY;H?@&Zo+Fw>KhnO=On=#DJSE&BN?)B*2t$EGZpW5 z0_PC4CxlFu`-9MP3bJu9^3mORn6z)z>Eldhz#iaFvslA*Pvl&oRRh)IqZ-{HM4JJX zaE^JVV?IZ2j}aI!>=PvBdiz4wv2eUB`sKuv$wg z*EDoEdPe-sqb~f64(1w2sjtlt$(3&tt8fCOu>|JlV<2aC&B6uI1H14ey#I^+O7sGO0xZ$u`g&wpg_S%y%68dC}Z5s*?}G_}Mj*9Huy(JcAYL z0`OR>Z(i06A%#Q96Oc$^B3(_(7Z8&n(?&{5nWLl$0eDjQaADL$VbnBjTMuo^5cQd! z7X0M@?4RG?=70YN_@_Q+$jN=~-PhaCI_r*`DvxN=_Hs*)E1OsJkbc*HS>JY1)_z{v zdrsMNQbpM(*(iMzdq|piKw5RuUU$NUKcI=)E37>3%-dl>eLD~y?$^@x_@MJdX3)IF zt%TR%aZt9$pJBIwR|jdg2)%*o^D{#svdg_yqg{9VdIKX6N|##!a`5W0LL<%KW7_7+ zjQ)7OAx4D2pQUs**%ng9jetFBDIAklXc8)1o8%KpwvY`?`KG%6{+Kd zDsb5=_aOH14&78D zLP&XjXW`YXou}5Z{ieVyQ9kU$*Y>X39s?ef8vD#&!Xc4Qe#ZaJ7mOSHNJ?d7S;E3S zHX^BXDx+~p`(32bs7LdR4vjC8Tb(_GxOgYO*J-*wgBGD>{D|QAt(Z}iS4N-J&_3^u zvvD)uWL}V%#*!mAOp>eP4*|Orc_my1Yb14qie^Wp|pSnB-~ zK5f2!DZeD4)>y%?k1t_yg2c^4j&z;@kGvAay+N&tK-bo5@{&MyCLx4_XNZwv7ZkJaPTj%7K zBf6rnx}qSv%i1kp+x~eXU{(m{VBcA>#Z6ctuSdFRMY*jOmedHxI8&j>rz|5!sqT zCc6m1_yr!D%%R1#Ts4mc#Rlg750RWFn<2g(|}`3khpw**=);-mpYjqb+KsBiE|y#g!*gL*r$#zxQ}L?rlDK zRL%nM%?YmuJEFnO1{SoJP<3+p^xbd9FDH`SI9Qb|#^&)=pOGf+kckZQw(=*eSX!>$ z(HQnMtZR?BRuK_~N!x{KQj{i!JVL4BC;-T8C+dVOwlL7Svhi4AG5_|cqUPHdWLa3u zlZhv3qmLBHM`emuhG!mBdKXvIS#EGXF?*VrdDOcZTO591xqg`%W-sf4b&v7$}4{R*T_Q!OyMqylcx^1_7Ca=blTWJYs z$Z%GP$2RU-E3M6RT*tRBufGK`PGeisigZ!i#Ig}UWv-+^_JV$cN ze#Jec!GbS2s)f)CQl8*ZuM(bnj%*YeK=0}m4&u^olRzx+OIaRu@|uIn-}oj+6&((* z#6}_WL(7R?>hffnn^a()t*BfDI%}cd{N5*rL2>*se$*DPyJLY-z_k7+N`;$Jz0t0r~DScO-WR$Rx+ zuX4nAOO~D4e$EIwBNxUFm3sY-UiB@=73v3?e`+v&#G^~5C>8tW(RkMKg1(ArvjxKL zJ8&`5npy!%ju|o1B1I6jLUj^}o0b(jc~D0LzIA)TnJv1AQ-cXVF^-eWL0aeDK#IcT zL~@8DDNBB7*0@}5opc78A-M$Ou#*P1VMl(eC+2r=$PyjcsDTdH-(9^oWgzjdB5%uC z;mvc{SCruQhbEwNTBlh~I_;e?#8nRB~$7YxvsC<#3hMnZe&yl%r z&GV1v=_}Os^~<|`*tzTJbT>1cN>$5*85W~^$+Uj4TQQs~p0Y{pgv7tK2rug4ox%)$ z_Tb>s7S8lHS1XN%g;cg0g~0oEBcehNme9-sN7)=96I)d%O%eMj233|z`ST^gaaHe) zpS9upc34oXn)oLZs{F?EDZB^^FxkRb$lZ3wYC1TJXxPu#H59^STYIxcO@pLURZ*z4 z|Kw*JqLnMqO|6O(x9y^HlkK1crAwoTQYA&2Hv-s0Y9fOLm*N7$?C{_rrER#-9&qN2 zw0iUix9Oy8)oXyQqFn3haxeSD%JtUqgUJ@^QTe#YRQN-jD9skc1~vWj!w0;4bY*E_ z$I5@GXrbV_JZpFSY?rY;X1GOm{?i<>HSdBz{jOG0$fhrGkLB1f^y&P}Zp7#8+4_B2$}`pjF5 z2l{j*ygKWS7r)OF7HdmZb z*`y_f6u7=v>~WtRA*I(>=u#;%o{;k=A^~G0?vF@wjNU~x{4WZF&xO^ekC~S;P)L`3 z68omf4_>%UvY*~FN6L*g;d48$qFECFtH?#ttGbnx!H#eI% zX}!HyXG{XotM5M8`8{8Jr^yAK%4^PdzNZ|(yXTYwl{y}vJJ5$5N7n5xo3oMUg%94b zgvRo$JFL3fL!Q87kN&o|WAW=roY(Y2W9(h(B|$zApX`Uc>IJ%T^qW`;snm~x_4{UG z$%x@}($t|yvukD7Y%v1*xl|o~XkF2%a5_tAy^4CMkO_nsK`9D(bQuRaJoz8}r$bOt zgw#1v%~B>1iRF5GC%)(uDh}|{FJ>&The~AEjrvm2a>R#x{c;)@Qn@_Bsd?ou=K z={HH{f9|19`0N$4v`ZlvXPq)=Nn?lsCz_`3yE-=1k>;@7VmwXhdwv1RqMQ(hlhG}S zA!iGzlIbQTP?~`6EKtv~<0&GEP^NAt&~#3*@wr@SID8^<7!aU4Q*cVh)Yz<|uZ3l2 zlqMlRpKF%%;cl&@NwS-FmijiD`x1YBB)B{wz)z zSQ)5F9$x-+bPVei+fC-L$>Y1p>vHsP0&Voz(8~RoPOhQ%UIv`rs9XOHsc}X()1*dB z%*fMU+rz5r>xtS}DHEKI)`D_qcU(%6?uKXaY39;m9+Bl5ndXY$aKYwmI3CrA#s|e+ z$L$q{3%6~JE&}7_QfTE5?Up5`0D> zp|Q4+Ez_iN4Lu9bl<=!iz<}o+&^!N^$rtkNNa3K@<>RBy|EPlv&jSEBN31gqBR1_c=+m)R zxmzxum=xRO2zs@XKWS(G>-h&bJau}zIG0A(*By0r4{^r)6G{H~iZxW8OK!u|aO$zz zWx)U_9%wN7A!~n%-syBtdx03&;H=;P_LS4HszGgG*;&TwK4qSB@MWL3CViz%ob_>m z*do8;j7hqWEqC014`MH_SI=p}-c8L>XsYSx;zvI-$1#56JhsWKQMSArvY9IWy>2p{ zcQ7aA!s0{Z;v2JvH%Fy*&BnX1Nd2>}Tnl4_9}~2ff9M>{T%@18R52VN2@@A6*`Q#% zc)%=9`@Bzcc|MgCLa;u2jM@fRfM?Z;Wi?1=vbyetNAv66^J0-wkUJ2m__i=IyDu3& zb4S)EsemaSt=PM$^OTcY2y8)7wD^$eePlZO>n|0BY<@xT85QSuE%TGe`*2>Nq1AV9 z=gW*=n>^QbKbtDNVS>UAn8OtAB9nsPZfCu@Amd&h zEJP6bJ1fR8J)**wG+7C#rcRB2~u>u}j#+6e_-= zUB+!|cW9b=u{kF{3k2LNWumP&@KZ~#4IJ8WAnTmo;4E-f&!7g=5ffRl9pj6 z5=m5zs>$L<36sU~JaG^T;L9bU#QV5n0X0+kL!pQIpxmN$XR{D~(7lRvwcc zhTX>!Fu?e6d7|ZfN;|o|s%fkepK4+Y?NPA(TBF_+Tqkd-KdpOjX6~=z{GO{G9|_-D z7pwCBytMoE;eh16qLI)(;UIESYK{6Egtp|XLxU$0HOR|)MG~L*!A{+=b{OWgV74@Y za@M5*9&^2_hW*N=_U8PnsXjCo$i0?4UNrv3t%Q@0j>pc zuI*(XR!qjrsMc*;qa6!sp1=9y(o% zNe8w^B2|ep*r}V4j^y-FpKhEfWmgL({Op=YSmeh9 zFX~!JkNARizEw45lR1lNFES|Kl42ibXOQM+kd+jWv55)j_<2QySY_9Q#MZdPR{6xV zFDBg|DB4HQFWHJoae9;DwmH(;eK~E7sB9gne2;BLknJSmlMeU9G}~qwm4YM_FA{cj z=eVGY^j9Xtf-Xx)(f$P5Ie8rv5f8hVgp}ByaB>7G?(M5+!@FV99$5+ za!6SYa3I43X_J9$)`&Xrwf@CXBxx2Dlwi#Q+SnqQlT^yZ>$wih@gnnRH{4?3Q=K|A zGr?w!JjfVIUUtirAeR$J9l9}d%h{l}E@fIUl*_yV9t!10{gMHU;~NC5XV33VpZx4G zMF;f6WlK`OXl=Y$mA^$XEDLXX|0!R5P_DX7nd28f&Bw)t;GeSRK?4s>Ee*wF~Hc)|KT`k zkok*mWKw6ZhnvS+kv*%8L6*O(ANdAjnPHBXU>92~V_4^?M5lGK^-48J{6gIX+YVJ| ze+iI<$?{U>wl;g&3KxQYf9VjNR9RFYGGe1Us^5FHt8}!BOE$l6zA!7I_g4O0;&%{3 z-;kE?OE|BYA{H#tmc`E^I3_^w6@xSTfmSN;*W3Z1ssr(Akxn$z8O2%c8Njyz1j#Bt(_#9eVr@tKm(whzKMdM2A^jdk6)GfiPh!KaA?2tUjYmJ4vjMyjdQA6w$fU)`#dZB?!gP5 zWi8h~u%bi+%ylTLd4z?;xO5%N9SNQTf!9rkt5fqnc?pP@l`>_#R+BbHN zPsmaSf@(vITG2Y_r>Q2{MZpdM227HxFr&mJN%m~z|5Wba;5iApU({phjO5WuzSwuk z4e^=OO};j{(q@s$_yqtePet5iM%fFskr;A;UFOM)uPoKo+j7cwg`~xTU;2m_j*z^X zC6Y=C=WL~1v~2X0cpQH&SkWr9!D|eaE{x(;7)JwU$fH1>L2rbN0tUZIztA0Bb<&hv zNq{E@Ha@M?88HcJX9aP;^aOq%0K6a2)^p@b09<@ik zU|y3oGNj;jJo5w-#2U+b2Teq4U*`b`Fg&7817sU9IOQ2H)X>DJ0uT7;s!=n zc`km4JG>f%`0Bw{%bPPyJfyV?P8Ibc&QA55$vKS5V$YO69AtWsxl|$|UMKeAd&l!B zLn4$FKc&fNPoYKVn*7Tu%RRt3q7>wE=qD1D_evFa0-u8{UJzT6FoR@m?_7+YP~UaB zhqo_(ycy&ye5T#MHNP6XF@4a#VSKG{na{(wqd|kLTCEX#uKcON?{8xgw=h64SU>KJ~sJqRF~Gb4+9vY!=sXsvENjXzx_i-NhKtp;n|YBiQRepqkV8qOo8}jC6e6itaJv3aW1;U5#@Tw<4dr0eBsFOEcgHK- z7oYaP&Z5GOHTT}ntC!8rD?NALNBZ%yUwDSU2mg83jcgS*j0Bal(+=Kk(LON3MgEYu zh>(b3k%;-h5?u@BQPt9iNYF~j%4Z>G`L9C&;6==&%_ zsZxaBCOfEFUNPQ<%h?Ops9{()&qM1fZ=4NTW^9VUuQJSN$!ZZh9bzzs$;3`!K=_ay z<-8xc>ddj_%t7pl_+*+~mQ@KOgl2&SpCTI)sYEY)G#l4G^77Yp^&6Ce;0m!x9*n{Y z#cMS{7!)Ie_`Zz+)0^`Vt0%qii7&*ox`E8*`_Wrdg$%dbDN>%+)2rmo_Qbcm0ihYnxF?6v`&J?MQog8ni?sDQ%pb+oWUFvcA!sEyEHO-_$taONZblewW*tS(FsayRkhSZT zKWmab&}C>*iM&kvos)Wki`X0u&FdXAr(ffwSaF$BDU6OMO6nwg%;IzqOW01Xa7r`F zP3NMjjKysgnazU_$T27HlaLP#JaZW_Nu6VE=rlWjS5~c_g3!G#O1%qNNEGx1r*Do& z0=CX7*TGBAQ0Nb7Qodax0UpCL{{E(v^LSx1EN9?$^7AMLn0v$S4)Vc~5Fn*-%eU=! znHtDv=dJvF>d*DOmd|bjch1iUt7W{ZSpa>q(R%hxg?~O@?f5HH)KpxS>^5hZ!|nd% zRn61;uv(bjcsDAeW7*A3@pt zz1@`gv0t&scXV45WEs_F$2UyeSuz5zp(}*R!*tl&EWOhuat0=yM#DRY56y6~8Ao3yI7~AdYA^~TA z<=7YXeiRk9%@Z$7F!N1_)8Z*%RTR1G8xOPl*j7{91dn4%1$+wab=I+%T4|`d>9hU# z?P`*}X48B&PR+sYrSIIFPiqXn$p9Ny)MXmtC07Mu>+kprdQ4fUElv2vO~lDn96a*| zr+<|q#o$rNc2@WK-@D)ceBVFFU*e`=zvFR;b;`k+4pYYNIq2#FgI&f4cC+wkZ$E2m z2q#q4<63!UUJ`o_6Ss>KTPjXgXeAw1E9rhVpmWBCXEyi68w#ECsY4IU(>fGGz^c9| zEI=crz{V|!Q|>)!@aP(lhn^Yl7poqeddOi;`^6=RQ6kw0aUXL_)xvGfD=2MbVse=|NUj0K!f7fdea?5;n znP5~(ErRBP>p#DLdl02f!I$jE6$%M!D#gxon1K)6%R~hB{-j-|1&#Ipe#o4r)EZC6 z|0%6nQAzP*W8Z3Q*5`)89@QwrdF_!TBE<+#$4=r1*ARCWbjTDm3|ji>Vd}HJ2!(?7 zEa|{pHLrigzB@<`Amz^)FiByl&l{EL%)_;jjJ(GgZSc6X!5QW*mh8zvKTl8E$xc1U zCc1r?YV$9pc=wjy5HcV6G*Lc+D=?GTMDU^nv>uSEE^g%1tL^Hra+&VG1^mzw?raPe zoJM>G{E6o%-r0QaIefMwaVy_$cx=%e4bjkb(&{-jCvIflc6H6wne~B{dk7E#H_6Cu zmpIfebYW29PM##9!$iiC4vSGWu8!wpNJeH*>BOnJP{-~n_Il-y+v~&f^3Iu4tHEn( z`lnLhI+Rd%8P>6%#QWOt{v-*DwKr0Q`#nY`a|7$z*)xPeK2V`0HmBe^yT(O9t)m~- zF0s!$GMqzYvmmkz>jn?8+Z?BU8|dI-p)e2VD(mY<-|+!?X9h%gf%qMah5Iz+3=3&! zlnCQ#;3HCoC?_i7R|XN{2f}`n7gjV<#(y_XLF;)ox_E;l(RTtvQ7tp~An_yANuN5R z>^P?AxJA!qrOEP4dn*TL`~E3N`9Fgzb%yOVk5+7E@edh8KK7X5bdTy7-Rs7Pj#a5EpelENYtW@PVNEz@wDz;7Fd zqEwZ>p^y_T+%pCsaiUQvI_zCKP}T4sO@R+oR+Aql1t8(UaZe!)p9xvt7^Vn(V{e zb1gmh)g*NEqR1<2!@jxMQ_JSO+a{5+r`Ou_Yc5H(!&9&8g?YiF zOZSYoRa^fHa#}scjY!y}A%VR^=wOw%SYlx?=(7LpyW^NxG;?we4jyTt?Wr+&GB-;k zm5)z6!vr}~_O)M-vs)q`2|&R`Ucqe|?;<_-DNnWf0ONy&{2>p+ThMG|)V*6g?=`Hid>G)3P;diL}z)M^uyVIb*)1kl9r4&9` z6IzSeR=3;s1LvFO1WA)JuR{fQ!`?3k%l38Y=cmhFfc?g4z5Y>~(r$;mkneGDV9A8A zS>~hr5_43fqK&~IPQfpxBfO|4MyJ(25-Q_Jm5 zjXB6FhZ9T>Q_4!6SGp6xln`834-MFv^(2iWh`K4`xF(SYRcPaYCjP?ZkztP^_c6$MLfX^xUmzn6HRw|}6 zTjuSb-xn_SL#?$gv)*cIR80x0p1*W2v%rIFY}mS&vX;q-=5W0ZtF0Q#GgVq((yaD; z*g;n)ZFH0DsNE)wjY}$hQVjAswMS~d!*jftXfdDN#5X~&V!^0n#eGp*_6U;FbDDK` zVPd_1lAhYOcUQUE1k<|gs%=oW#c2mUX47f2&rF-TJF-BLG&ZDD)0Rn1lq~GMMTkN! zp;Hq$G9PeT?Nh_n3KmF{5wsUO#AJRGwF@>mt>`_a)Gn9QU~vvIy%-5 z_(4`;$D7YI%tS*j zzF9`LRYuAepo&E2-=3$%IHrFb^Sv>>`ZelUb2)18DL)=b(ku3BJIDY)1Tq<)xW}nD zCO;z8S|ie%4bF^t^N4wg0$mx~;TYW^cRfl6&FNNyVRx;5cdb$f2BkmCwYa^PH~(VF z@S;*4CfSLtWvJBE2W^n$?dRP#jF@Oe6SfRtcZqPYR64JqrTJnuhHf@J*UC-k=3W50kt)muV$#GwBWU+w8K4O=Zmb470zB^=wVQ^iT2QJgt`5 znm53XcTzb zgzy-KMp4`4*ka=6qe~<}6Tj@6rDc`C!=M6RPcSA|V}WA@=3rbVpdAm(+$4}9nI8t6 zN!hh8tj9oecZ<}L3>WBh!OyPF*R7j6@=jK>$QFN+|e3Na$-Dzpgln*|e;SL7AJ0I7jI?VN7ia`9Cte<+&&6`0v1R zpqItfhC`pR=*m(kX=Atsw1Os7YHK=for^5oCjy1KTS4i^Z2TXyXf>w_>77n7cEgG> zqlhWbi0lScyH-v7dOJjJ{)U^8gUiKf`ZQ?UmFKO8O7KYzDT3Z1*RBT1>Mb@!MtM4( z*k*x9BXK^wXXR4W%TTthWG`w8Te1`v@n7HYko}bRxGfYnMav(j)jE&dv$0IVfBY4p zc>8v8jM(o9oD$jA!p}UrqqZ>@IL2z&)_|2AMb;JW6D!$n68WTkA;q1GR(Xy2$&Oe7 zRE%&Z+!jh8394@-Z(<_aNyJ2RM5xb^FSw3e(UcN{pM} zN*VG54P2^_6PAwhn6?1>%nT_>ZkHA+c6khZoYwXRp9~*I^Lh1m90LHuAuMn|;p_tj zD%oCpo+ru_1%Hm4orq6Eqo)~Hs1&Re{5&Ye;5h?KegFFt=~F1^+0FQawBesPo!Qvo z^jBqCd4MQAde>?W4uh*!(~we;$l9>hY*@0Mr$F@ag%$6&rsI96@C~?#Q`u*f>RTq@;aKZU;fDbSi#z?2O`w zUy9-*W8cXEXazwIpBIMqcKqL`V5Rm4RU-=iaC6F91Cr0HH1xA2&9i0f3h8BktB78f zlA>Nli*2E7>$cKuf1IH7BkA$zUOYS*)N_P*CEKZIh|Jo=?- znwH^+nvFx%&x+H?gNE}dwV|?I7RF(uDB)Dt6l7zu@u&R_5ou~m^zGq|leA}7-qE|F%6S0+M3 zW~p)vs6Njqddh3y=`XK3M|NABbZ@7q=-ACimZv>bgcQ_Q5!4l2SW#g~M5{r^Ex16H z;}@Yw;FloDVuw&81_yEPk`072)0~+qInrgLV?|+Xu8YczhR_$i8R-qen@mNx{f)Ri zp!UAr(`!uH0Xxsc1}3tuG3kwJ-4BX8TOY|vRM~y*(j`=W3@4ydZEH&1wK6t08QLJ+n z6BMrfyL)T7zAC1eSvH#?BQC5%8+xJYGk z-k-?OZFla;?$>B{FuPhsC%iqG{$YA}uGP*v!%$*Tnw?T>S~4jxXIkrQD$fSEQW3vm zJ0#{VvPy-q;+erjLbME>JN9jVq+oGo*tT{N^ydB|GB{#G&UKK^yQbrXj!p`TO@Kkh zX$Tu1kf0Ar93pZ&4JBvH`Od0@otq!|YYsrpuGhrQ9ZiCrjj@}DcRCq|fGG|gwHM^! z_^|;+V)s2;UrVb&|CEKeab#qQ2{Tn)3w9=E6d8td`F5z;`$riqd+HIAr^pi*mNd&aobdC`&4RMnY5O3icMF^j9SA{1^;nH{q<2ofqBU<0Sh`Gm3t2pl=RxW ztjfD=ipSj25}Tv)K_i6KdDlP;VULhc3f~p)v-*acpM*GU+F0BYIPBU4?AkH54*>;p z@uqf$6kHViLJ(y=5|M)r9nQ(jtU)+f(}+mu5`bTY1H%czs|EUa1&q#vn{$aiY+G_W zPRfz=TqIqt=c&(eQ77;qPQ^6TtxDL6Q!gy6k*>54kZ3?n&)V8rWo0FK*$m@W2fe%2 zDTU{(VJw3w^0J9j=%cEg_nxQxZbK1X;&-v*b9ih`Pj?l>pCOKH5za=+NqQap?6A~^d9o^(?N zZ2c!20L01s>j(|47utib%^j%k>Uuw;QA_xIQ-#ziG+VQlsnXM_2;f4TG3)%{iE zlZEHPDC2|tHdx{qHtr}S`Y<%+P%?U7GI}>8ZW@Isfqrzc+V@o3NNY6e3DoL1}K|CetZg$Fn;K_}*fh%PlsGo|=jZVIVe3A`42F`Wzxz zSIRd)1+F4QhW?*Y)-Kl;36K@gdLK3d3`VngU`TfCGPJ@KghpqqH9rDl2m=C zv-P-DRZ=vZ=_}mVir&O+;b>jB6k2fV9XX>QhgcPa_#zAz3O#^_gV>Y^KI zP0peJoZ`ih^yHR<^NJ2D^3)^N8fGi>etL6HNEU7DOL9b5K2K5?hDtRoh%<@6`@6C-6!N0`UI!GBj{_57$Hdg$>gW6ymhViE zCn$>Q`jVFkAOAG!8<~$m{)@$$jkRqz*)n;vfcJyN6>!pfVup+GCB*4=}|B_I>GGA-BEL&G+#w0x^!P1`G={SEbFZc_;K>8SE*-JMaw zuHn9bpG)4}TXHi!p$xeTw6D21>8+`G9+5j(&&+IC;3D&xZ$2LZaQ*K)Je=Q28V>!C zb?pv_yq=m$_VMaN{pU7dL}s>BqNch-mw3EM7PuDiBA+bVZciTbfIcxUHAm=n-b-sv zhgYq?YHZaX2W~VZO0SX>8qy^ZAGvIcL=2i*<{U4ss%gt^mz~9DvUEOBx2GvKa-^>7 ze$B^t!|!&2E0bt6I5m3mcOQ(Ae?i=;ObKNfvH!C-2H}lk_uV;+gZI)E?4RJyx+PsN zrRrgnp@&VKBA>1IYe@M>#q72^RT1D1CHrFV8rFGlv zwzklh2EN~Ej+w^^YP0^MAHUnGmcOv28`*LO-^b^QfX3rYA?JM6)>oHc*M(KEQf5`} zb@LME{7K$>!>cx(s;gk>Y(MC)1>%w~E#KXKYUSD_fxJ7E-$2S5wL8+H4duK~5k6n( zLg{JqwypTl@`M!Jr?%#l3))w!n1adC%Ij9^RLnhBUr`|q zw;rS6jM2q7eMV^mjfVZqK+wN&4gqiNZOzWx^_0u!k!I?l8SN|?`Ckt2O8dccNq^+m z8rJG|W}*J_&o!>a#=uu$H9PlzZQMq~eAa(J{q)c#@PD3uma{-~n5hM-Yh=%+<|*g2 zyMad6k7GZ&h*U>yJ};5zjF;z+Mz^>5U5l=8I$V>CTRSLHJ}|#9$RD9)tL^k2KY#1@ zs`Oew?NxtL=cxZ`@S|t=!oNGXBz{)88GPZ7&Fz~0CX|mX%o?$$4>}mKE;ETGtG`E` zfPF_5qSW(WV=h{F_Q1UNuADE4-#l_D*uQ43b$00AlCx5Yi*h8MB0|CKzCtWAt~HrA zJ~N8>f4ptn50WyYv+Ajr(*N^z3Y{=``|}El(@`2yfT8kCDlEVszk~kzfxdxOIJVlo zt3K2y4xiV5V=P>Lq2OP+DF1z;#~9eJ+4|}6Lb=`c+lPGok@xHJFU43!(r#b%Sw{Ic zhz~Phl2%*NjMu?yGh2joa@mq&;Ip^vn>BrQ`d!s`%upteb27x2F_Ma|dpfFI;e%KB z3yLuu#x%Nv0|G(J13X^s=R;aCVTW21eul_jM>$vSDDLK!p>(Tab zfo^(CKNhyvE`i-%hfK59@GJ1OMdO-#>8>|WVvY`iw?H#L^*@T zRS0Y3R(+2b7Dc}dgW40>25u1MKV*l{Q5JY>Xn#%asm&iX0Vp=_6^)@*OBrN<)@jEE zwTc}w_mk1Tc?MhHuuqJ6yTNtXL5m(GOnfB)l1Qpv=e-p(tNseNJck}(!w}z|@^`bk zUmp%%fBXZLTy{F%tZ^h23V%w{zja(RNn)Yq+~}`TVGLvenysE**4Hn?;7Ii;b550T z%xh)g?9Kb6+?mkYoF4fu22D<5DFtfh=(;2A&!6+M^P9a4K3--O+1J{6+(sN3yh|SS?E$Dg%Vd;DJ!pXne&zXUUU9jC9x9Zm#JTOv15A8sU|%E$ z^CYW5zdgfiL-2@)=hgm>40AQl>nzg_wBF(&Z7E{%cfUnRY0|$zZ7}Etp=zl9e#v4I zoqtRP>t07D^8xNjX`g=!oYz1J0D#}4G~_Ye@__EFp!q;HjtOvgU^ zZb&R_(M*{KNE1H$U@M>w+E-SZ^W9A;q?t}DJrb$qzt13;8 z-r%0`QeZd-s;2hq*`p-jB&` zK?xXa;ju1len-dGL4`OX9u0Yo^ByoxjU?2qqtNPYgs%H4XuUI#0lyDHp&6Ol;)hN3 zIvZ49=;RM7XWTQgRP5-_mTdZ<1eB|8MPGCn&m0^Bmsz1tN&ql#-zrMse(|0hlsCAf zmo^|8hLS=&XaCsCMb$+H#yPOR=L&XdC@th+sdC6Q`3)fXnuF7Mu~|z0CD=O<+OAy6 zv)m7bf(E|pN&WKk0AzOmypKEZ)JH?XND_Yo{qVRFF^D5dHtNv zpt;>O3=80KXzw@n+sE@Awv>CeJnSdoDuBZw@9c zhXq~=)bx$oY#g%fH50z@2eSgP286;Owz)x}QVo+2wjB^;U2-Vf{^Yw%o4n3DVMs29 z*~9`ZEW4+DI#hvNvXKH-8%J~)1M3^{Xo23LU~mdmCoEMa839=_koFrjJw7i3jMZ@8 z9J$Y~KK!n$x?QY%CB*l;UT)Kl6*M>(T+6|{mbz0GsQ|5iz{&Q~p}~1UBO%V-6R~h8 zejO{^$fG!UX*RqRTi)eh6{q9-Ne0O&gjhCw#WVtR`Oo&);IEY!UmmGQYLm4*)DD>_ zYy(i~t3ITqYp)&-z|~_tL-x*P;kMCzjp6IKX%Vztr-;UAbRoMb<$HuqcUN1c}GOaN$!hf$1-TPT&`< zkDi0tlNLbnSCbwh=DxYAcPAKR;MX#(ZN{Fb3jiGrX5N|7LJ==+biJ>Kw6vrpT>hYp z7=HcwQ~L=O6&w!YNHhR-%SSUQ7Y+hj@@u#!`V)#2E*<4-i+F1!=k*;iGqF60qqhH5WpXM+r#~mfdc>1sI{Oj0jjqwJJMx%2q^G%@g4gXdftR= z(x0d&v|m1>fOzx*HQWmG*N~InL(C=Ur^4490BMbj1%>-U(VldCeY=sO6vU|EP@y_v zz{WmWkB*M)mZ`D+^E3hpFw;*0PFNjn?Vs;qC?sp3BGlQh6MxiKNK+8Z&Ek@uFHQ3J65^9% zrlkEf$s1eAQ6+ebkYbT9f?}~JN<_UVxl0(L&RL#vDxN-Z|GuC`$i zxrAuS<#5IOw|#2BR?=v1WowTuBZMWTw!isoz=I!S^6b$EQA6;Mp2%EB;0v4PGBK{y4n`5OXmk zowhgpu|Rn*ERW?U0{M-D>}6*Z5g@{er*6wOlc2JcPG3o5T1Fsgme0NQ3MV5+KfPct zDLzDkVjmLLT&AI>!Jd}>=44w2zbP|M8RCH1vAAauI_#;-D8Q+6LIKMU%>C8zJ<&ZxM-WXZ+vDY@qW)C&it8^22KPD6JHg2m%*}qhMzwL1j|&IyuEQ+E*%KvB!U^#+xU2n7~C6l zS=vowWXy~Aot1Rq=OI2PMbmc&!W}`R6`7xxX4fS=(nCEezDfYZDi_HjWG8X}Io0n% z)y3~SZAVW${v}akU3R*E*1&xa-e92Prg%wYg+j#RM*+X4lO6IM_*y5fLikgVbDF?RoPg)DL3K8U3HYOto_hKHBTJM8g>9E&ela7d3P{*hr z@xUz@mNtp`0}n>n1;Qu7IAWx)=d^yMX&t|%d~rxD0wi4TpGhRY_vnDZ3HLlz)8z3W z)Uf3JtYZB0(w1Q7J-*3T&U8pxpeUxY2zkQ_E6h2>2n#;=a|TH>YHY!Rcjd~(@Gl-Q zLBNqzBB{dz$W{K6&Vucw5=&6*EXHZBVX(V+ly4td06kGM4>c0^QVtNdm~aByS!YZX zA=Buk`L~L86#L7wu*Rb2A!?YP<@a5POB2F45>W}V_`!ohZI@Fvuqw*Z-o9GE9%LsFqO&KcLi zYuQupNw&%0NA-vi?0Dfx<-P(4wU6``)E1|+?;o>zERU^@VnnKJom@f;lBC?$59n%M z%*K3PdLA}}YasG=@LCXUJA`z^lrhddBs5r`8zKidie3n%3At+bK-2$`0~kNw2)luH zjXGEPwP*?9=|cmMF`8sb>@12l>UQL3#;Lg<$VrkRE=GQPw5e97?RUzSUbGfjTG=Q` zQCew9^y=W{OVvKhJAdSD$82;OHy}xvjZP0_P5;m0It_SJh*$)?RxJ3lYdv4e!nTG@UR1U#O-lzUS{h=?VgIft^N@KcFy(MOGaGXF zFM5LWBO&$X^fE64`kc?g`$tYzRmZ}V%&C}nL2u^a#TC{2zw4lOKDg||*7Qp->} zpAtw@u_&@fV>@u4ILY0c*U0@z>pDA!cFEUHA#Zk{z$l@ee(?URk%3O^fw;A7otgX0cEk&tuaUfl( zj-@1XXaE;p<={5bUWqHj&B~a{ZCF{3k#<8zu^RzQv%PInuzXE zI#pVF$=X7}1QzutYQc2>4%h z1-Xuy`xT&681Hq?*1UPirQ>(?*4VaUx`1ZSapgQ>hC9^@ofAD6XDJHW=~JGokwf9% zJ%EenjuBZgyV(dY4h8@g+@7Yqf2*74c1}_k&Bb~)IEdoUE3uEN<9O2L;V@#ko};Ey zr(Hu`8kQ2s?_A3U*L9OnLbB!F&4Pc$juCC}JcQ7MA}tOu)dr9`$A1?)VGgQj^O89K ztK$A3ngNLh@7*lOr`B#Kop^$t>IblsyoJa_4-`?0P(jKe4vt$(O(+__Yd(?>sj_&koaF#Q zf;1o$ft;939=bk(%B)&=?PtyeGuG>M5nw(d5u(!}6bT1-_Uqvvl62p^f96Ln1lk-q zs#a~D)5V7d301p{w$b;A%>$VL%4l9pK$bllv+)ejD z??NvW4{rrl1HTcZQ@H@8p)5qQR+Y%BN!>?WZe=0J0%T3Rcp5K3MjN0thrCJ0N#YP0%{B3W6Qn45tjfCq&AKTP2{ZM4eq164|@yGL+OYegC$MT`&|1&%O#yj!Z)>`;el#Zr+iXyNM5`PcR zLjRfZ3Vs7e-wz3CLc*52VzY!z=X3xTWMOu;BZyr!_!!W-k2bs#JQv@RCp)+rx-ju^`{4SegA7tE|iNc4S_!}8k zZ`w)!a|GNCSIIf@*#E;^lp4MpT442dykdLd~|Xx8$;e6_$li2`qd z9*-!1dm}7hZ?(~rytNigiNb=592M&1SLEa_#Q5wBAncie(7n3wSGR{zAhz}_+~dgu zn$7l2Mg&?8U&68+3sIdUF3hlv4GUMX?j${;TAOkxNq zCJXOTadTKg(C~g}7efhXFMRjs1^t{iPQtw@6GQS%z_}4Hfe+5nNG5JTY+y2#0NgB%lmbsEbgU`_5A!CY1j2i+OF$@Fi)L`>sTG3)NVZBPv@W6 zZbRIY+l@K!1c5SAg9lh9O6n=+tpUvxkxBbr>ub)OuU_{UNN%o_b}Q7{cD{^M8O-}0 z$*hwx{%xQ6{Tm6I(I1Wyo|kwoVrDv+sx#9}R1&yH4m&p`?d8Dh8OG5|bagj^zS>ER zdd8y+`8>)(T67dd9`5E#G$ns{=ZkS&$TzJeE;R;=uNR~(kK>?k|0ucGXc#z-*9ShO zX6yQJGeh74!%YMtm!KL2^83ViI0jtHy;|cQiF&? zKJ8aN1e;{7Oa)UJRf7R|Vx*la_SwYrqg9wSP5g;Oj#qRp@L1~tYqgKo9~9IlKWjy; z`m3eZOY-WD8YXZ%z-nxb5@j)RPEzA&V^d^k>vZiaolyM9<+G7GYmh*@G$?l6{yAkk z*z3`;@xFfgM_8q&RO3JNUxYu?0A@n;EY)0tW4Q%*zmVVlN&@iRXn9-6lv3JtDAj!R9{hgwTI~^!r{gKJ-wold{-C7;6MTk7~Y?@}V z6S^sQ#UuWfFd9&PI^Sx`>1oP}qt(X6Vgp;$K&H$?vx;JSZ+G}xQRzc* zxACRYk({ zFJhYdv*XqSUG@|gm`z)y=<#hvGH4^|%k4}TGfFv(a;+9p;W0hJ*?BPiMqAVW5Z2IVTXst8au0{`N%O zv7Mi(wwIjNFTRu&DEik=C3RYt-$4`4KS;E5EGDpip;uV}HbQ`H`cB z%Rr*dQ)YkTbAC22L6z^1V?dqtd3L=%x2|I(E}`>T9XB5)*qI)SRiZR}^ixuu5B$FMKl}D>XH{;Kkuw6vmvrQu=_JPHce40w6I+Xn62%UFRCge^gK|UQN2~q zk)$ZBh*-U6S^n2j_Emn%5jT`y)ctXKgs{r8ekO&13noa?~2gY2vH;Kt9Ezm zV+;IRx|{Eek%^mE+bYYjmD9p|(EPgxBgo>{1~e7yv~sX`dU;?=N+y`9Q=GPG(#M9>7fcN*wSy6Z=I=LiS3On*i)#nt)0({;{>zfd=EB-VaM?7X7GZ7M` zKueZrxfYxTt>K-e9i;CcUES|enUco0>^iRS&wb#Yb$oiNgiK9ml+>i;1lp{Qzi+yT zj~!t_2>vpqQ8RB>_ln1y9gvo22}fQA-cBru4Tl=_4Og-BH=35A7bc?G1OUSlW8!&+O?NW^t;MUr+N=dnxneom=47rT^#O-7KR7RB z6Gt~zau$rs4OWf*mK;_Q_!&+JN1~s1B>pbW7M1P%nfrnSZ>`{+O1V=1_R*t9{IpLs zQnRt(-d1DlhM^+*>(4>1VSUibFh$R_bAyba|ItSFG#Cx|9rUp+G)64_k-2$hG9%39 z=h-cW4u7z@{zvNME|KR}0xMX!#N>NT=A)XlxF-XcWEzuq*ga?4ks|8-d&Hsl?Pa54 zbWc-`rd}PD!b0h=ORe+H`gAU((^&tP=R}egsuG6S>4}CA)fX3Hh28q!2Zv2mfpxOqS$o#}7E;3LhW zc9x8kl$$~C5mmC7Iz?r#2uhC&s*Cy6s#viWaSo~8us$I9_EGv-WCSPItjAV2Qh4@3 zDzDj0c&421dK`~NLio`>ULj&C$E^ov#h&&Bfi6=gN>0=7WJne=Y0SG`*|i|S{Q)#J z`)0NKrrMPL`7lF?dUz=Q(7?orgBGMFXlv)#`0pvc|3?IfnYA<5I@;wEgRUToV}vs# zXtB93|HLOj)q0G;*GB1E;_g!ievgv~yn*w_FlC@HWKONqqIVq;hKRW-X?uCDU{KWx zg}+|!)9(Yce=x6ghw$v}iqhfK04rgbQA&Hp@VTH~mogGtFl2RV4an6Lab$($1cB9O zQCx_tcU_gQnUCJiS%l{s9OiNlbP51gPX|8fNl2{x*D zb*+aoh>>HbpPL2572Lf=bG+7o46mW|yj^vNw8X%6+B6b@mT&Xfagz9bf)?ECV=(!K zcnJOWfNEZQ)yP!pSb>;3zPln-4($70`M0b;v-&-)@U$}*y$wTQ0W)O2?M9@`b# zEnZeeW_`KiQ==zL+f0)6`-B1KNh@lL%U0QFZa@dAbv(@H+=45ZI4gK0JQqBAmVBNZ zytZ2`i3P3uwSCVpbV}@JPZ(HD=^~F8F@i@>uKRtB&szYARrc)pkm#W=+!v9Dp*UV; z38qqSmH&n=j&dVmW?y^daYHw&G{Jk7s59gwtHGOGnS(|j4Z~GhB7hoe^*Q}Ro#DUy z5MZj)GJ;;<9K?vsB7J^oXZSk`f45mJ7y!rh-jnjsx)ozQE84c4wr-jWQKV0C?%&o~ z8xhtugB)|T>49~KSe5J7r(Hb9czPRgbtnpxzkcWesFlFfFUEOPc24lmX=5G`p7Ei5 z7oj_j`Dzzqa776asilP6S;q~>E`|f6NJjn%vWcd^P3n4G;u^;D;dvzc=V?j9R9phz z@k&3SKexA~M%sbU?X7EbYMH)rtQ;&E_T}L_x}r#xlLHsP*%R`&`gP?0>V*}ouRd6K zng2!cai}ziGrl1su-qy1cNNlNF5nmG&ZpWoe+Ne2{w=<{2==0|oBr)PRUv@guR^E* z7`eY&dp(%cd-j6Xz5)4rFc|dKO<3*jf#W>gfzGjVbwS6Fer8EcRIhp3sos--uq1nD zpiSEA3Ie_1WO@h8-5tGDgh>iGj8vv`Ld4A1LmvvF#O-Oo16r;VC~S2%@upnyvZpWG z3*LNlu|Z_5Nyhh7^mg1+vW?vXS-Eb$2a>H6=3@Pngq`xm9exjAz^o?hF@D?!AOOT zXHPzz7BZ0;xUcxbkQdAP{!aTCNy?QcKn|0=#`+^Nx6NJnA{(g4aA{WTW>IkEq=T`z zZU(+#SH0M9vvjuQ<|eP20tj%Meb>RM5D=t!48te#-ff?#jI=OVNt5gwH+bpFv5uHX zFL;m{@NsRk@-O0DtJ!jd#}g#M>T>KMjs7N=9&(TY$xO#)MGb1HG17r!hE=rrW${&ry6l3F(u zePvJC4-m4?)6ZK_xIDF`LR;)A9V0&MjVKlF5OBfa#j~w)`OlcM=@LN9IICH4Z0p-! zyr*)}v{Z)%5Eh=AF~-I!Nu+fPOFsbIJ$q`<_8dmWEiIcKyKfy^#diVw*`GQ0#{5aT ztwe_>nh4#d@jcZiSB`;at3HJna|=TE=f!~44NXMMBp$VX{}&5z4e=rlXCYdx?e!@_ zkrC2GwkPqbdyBztw;tJf$HV6n(hs;?ynU$IO$0-Wglj*l*+Az8~uw^~!k4@h(`pAmEeH5e0PDx-&`} z((3-D6#<3fl`;_1X=ZGHCV>`+sjmC`ak)JF+nOLb1>Gf707Lx`A5Pw)a0V-5kY(m= zS|-Z~sge8z^CxeqZD`*{p%viKEw^p>*zGTQ#(;gRJX^&*`qDk`W8tmbN5i*c#W(-!HH8{!RBNdyKQgNx6OD^HY8ObGVNUwxP6%7D+x-8TcNn#T>;Ntzm$WCEgC zZk*|g9>Ss}^~*t~E6cN5=#5<7-)1@QBX(m6yfbBtC+N?*bkFZ$^+C*zFbeyR^+EE4 zvu^76pNO|#AD@;@{r!9X?dH~2f!w4XFaPB7AN692_b`_KWM!~?uX%BUH)nZ$0<>D| zxUS@l)_k6$H8Y}{;h*0Ce`Y$<^WK`88vP6y3?{4+DX8#H3161m}qIj~0F$4XQRbeVH0z z@TtF+uHX-&U_U>ChPM@lDDa3uqwKe*7 zJE7_NNwEE>=*$JQ6Rb9Of>8;!RfDp`u4N&?-_C58GvlK}3|hJK41 zC#HA4k}j=uD7EceCkATt))^#vSXiK~I1-OL5a(|u?BEGdll1){_LKsJ25&zwSJw;w z-t0#E%kx|nG$Ns?rk*Dgdhl|AMxT|o=H7OOoUh&;@8uy0x1Y`Py}3H@-(TZ+1eB^0 z)NfWHVVoVFVjxkG5Wq(hPXCfvvtX6^u`-ar9p9AO-^@{xQoNJ*B+d>Ju9l1v*(x-At05=Py}njzc&#@bky4!}H2!c%@~J#ttonq_VmaMRcP?4XlY$lQ z`wHS@>Ay&^ntMFCnAFKHUMukAe9&RW=eDEwN=eeoNtY22Qwfb1zn&4PrbocaY`f6S zd%IP0sjzSP@$%ZwH4PWgT1%Ztu=3t1$iD_(dZ=UP3NjrI7?aF$C=>?u%9Sru5IG01{bH32TeVsN2%9=rwdY;#i1hpfWa zQp{e6;_|ysNB7_Ujd~BR%JlfCZfZ|>XenP0pu4~oU59B~mc|Zfoxp#p zJs%?Vo7wAW3|Kvnk1yMN9F^kkTRGZp7qKToh7SCo_#pm!C9dWGK4i`6z2WXR{`E`2 zwXSnnv9d?6_kMb9^$ttG>d9cd$1p{b;Qhg^?gUe{q6e_XNo`!q#pD6_Wvt844)fNHjTt~zA%a@pq;B!k5 z6J7L9MpO}l`owaE8tY{KB;U;K%xI*c8xjrVM6*tx?A2MTzZ==#I``J~S01KFeXi4-L4w?E->YI&(~7aq`?M8PM9M!{DPF2*G?*(lga zYvv9aFao9|mb$`l2h`uiP+sLu9WnM(zjr7S9@ZGT+wmt31aUGe!G4@AqNkRyGp$lf zS8w9nPL1`OW!jmNavOm6!~LvTz6t00{uId_k_bQ0dT{7nSZE{9M+H;;y{j$APmP+Y zz06c${?|Vrw_wV!$JWd;io9$q z&)#Z%NeHrNG5*TG;@_5{*s4v{{)EReBW}D39eGYTNu7PRBbENPbk`Y0vA7|P#wQD+ z-IDH>PQQt_ZdFA6;=fH$VMw@kx=*6%l`|I;l|0LsYt(BxOdB=Gd-f{-7eXYHe{5WY z2rRX%lR0BQMde>&geO7al#ZNSFW)KdyfqtJOYp27$2pA?{N&^9%wm_4&)b*qzS`qZi=S2cNAXU?d{=6-ok zKTR4dbI@Mqb*MvLH=Jc`h;|;owq+Kp3Bq4#nRzjhvWuk2**z2fBY~}c2@fx+g`vmF zkeG2@2cPht&js_prVSyd^^&&KpA=YWTQhB3hf}inJfh>$KaI+m!()z^AmbU8V!p6A z!D2FUwJXC0NL{*7|2}p=fMMbzk7{Q#{8|0x{>iVr zzJRl6K>GgSseCdQY8f}i3&M-E?wYL2!G$)NHESyIO5T`ww;X&=FaA~7EC&clzUX#IhT(M$v_V7K{7Yh4M z0n><>A|x`CHhDFx&7|<}q|l%{duUQ_GNF;V_Q60QUuJ;2g$2GKB7llZN4zc?%`Fd~ z;0Oi$vkpQPxr9b?Cr@goicKF64D-yj@>#mz^1X6tHbHi!&I85^>na(Q=Ek2E3u_Cj zXU=#4ziQ-pA@$y?%3(4=>`E^+ zzQTF9u=cl1Ug8FL7W(ie)522UUm=M@cQQ^$`)b%s-MnSRVNlTOn7bb4dH8`y!p>H! zogrten>;|z7PM`d2cMHc=f5m{Jl4$%2WJk&uYq(sjCbop8EjesL;mU5KJDpK^K!#J z``d029#gWe4q5l83QWfXw?=!ut3SJPFFDSR=hv2jFUaf4};U Rw-LZU>MGjGh1V?}|2HB6=|KPh literal 11984 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≧r4TN~WesE3C{nASh<03#O(0-nyJs?f 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 From ccb0f922e6dd6bdfdcc3b7d316c60a6e65af22c8 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 16 Mar 2026 15:26:11 -0500 Subject: [PATCH 013/198] New Feature: Add PlotBar class for bar chart functionality with support for vertical and horizontal orientations --- Examples/plot_bar.py | 118 +++++++++ anyplotlib/__init__.py | 4 +- anyplotlib/figure.py | 7 +- anyplotlib/figure_esm.js | 312 ++++++++++++++++++++++- anyplotlib/figure_plots.py | 204 ++++++++++++++- tests/test_bar.py | 508 +++++++++++++++++++++++++++++++++++++ 6 files changed, 1144 insertions(+), 9 deletions(-) create mode 100644 Examples/plot_bar.py create mode 100644 tests/test_bar.py diff --git a/Examples/plot_bar.py b/Examples/plot_bar.py new file mode 100644 index 00000000..6433f00b --- /dev/null +++ b/Examples/plot_bar.py @@ -0,0 +1,118 @@ +""" +Bar Chart +========= + +Demonstrate :meth:`~anyplotlib.figure_plots.Axes.bar` with vertical and +horizontal orientations, per-bar colours, category labels, and live data +updates via :meth:`~anyplotlib.figure_plots.PlotBar.update`. + +Three separate figures are shown: + +1. **Vertical bar chart** – monthly sales data with a uniform colour. +2. **Horizontal bar chart** – ranked items with per-bar colours and value + labels. +3. **Side-by-side comparison** – two panels sharing the same figure; one + panel updates its data to show a different quarter. +""" +import numpy as np +import anyplotlib as vw + +rng = np.random.default_rng(7) + +# ── 1. Vertical bar chart — monthly sales ──────────────────────────────────── +months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] +sales = np.array([42, 55, 48, 63, 71, 68, 74, 81, 66, 59, 52, 78], + dtype=float) + +fig1, ax1 = vw.subplots(1, 1, figsize=(640, 340)) +bar1 = ax1.bar( + sales, + x_labels=months, + color="#4fc3f7", + bar_width=0.6, + show_values=True, + units="Month", + y_units="Units sold", +) +fig1 + +# %% +# Horizontal bar chart — ranked items +# ------------------------------------- +# Set ``orient="h"`` for a horizontal layout. Pass a list of CSS colours to +# ``colors`` to give each bar its own colour, and use ``show_values=True`` to +# annotate each bar with its numeric value. + +categories = ["NumPy", "SciPy", "Matplotlib", "Pandas", "Scikit-learn", + "PyTorch", "TensorFlow", "JAX", "Polars", "Dask"] +scores = np.array([95, 88, 91, 87, 83, 79, 76, 72, 68, 65], dtype=float) + +palette = [ + "#ef5350", "#ec407a", "#ab47bc", "#7e57c2", "#42a5f5", + "#26c6da", "#26a69a", "#66bb6a", "#d4e157", "#ffa726", +] + +fig2, ax2 = vw.subplots(1, 1, figsize=(540, 400)) +bar2 = ax2.bar( + scores, + x_labels=categories, + orient="h", + colors=palette, + bar_width=0.65, + show_values=True, + y_units="Popularity score", +) +fig2 + +# %% +# Side-by-side comparison — update data live +# ------------------------------------------- +# Place two :class:`~anyplotlib.figure_plots.PlotBar` panels in one +# :func:`~anyplotlib.figure_plots.subplots` figure. Call +# :meth:`~anyplotlib.figure_plots.PlotBar.update` to swap in Q2 data for the +# right panel, demonstrating how the axis range re-calculates automatically. + +quarters = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + +q1 = np.array([42, 55, 48, 63, 71, 68, 74, 81, 66, 59, 52, 78], dtype=float) +q2 = np.array([58, 61, 70, 75, 69, 83, 90, 88, 77, 64, 71, 95], dtype=float) + +fig3, (ax_left, ax_right) = vw.subplots(1, 2, figsize=(820, 320)) + +bar_left = ax_left.bar( + q1, + x_labels=quarters, + color="#4fc3f7", + bar_width=0.6, + show_values=False, + y_units="Q1 sales", +) + +bar_right = ax_right.bar( + q1, # start with Q1 … + x_labels=quarters, + color="#ff7043", + bar_width=0.6, + show_values=False, + y_units="Q2 sales", +) + +# Swap in Q2 data — range is recalculated automatically +bar_right.update(q2) + +fig3 + +# %% +# Mutate colours and annotations at runtime +# ------------------------------------------ +# :meth:`~anyplotlib.figure_plots.PlotBar.set_color` repaints all bars with a +# single CSS colour. +# :meth:`~anyplotlib.figure_plots.PlotBar.set_show_values` toggles the +# in-bar value annotations. + +bar1.set_color("#ff7043") +bar1.set_show_values(False) +fig1 + diff --git a/anyplotlib/__init__.py b/anyplotlib/__init__.py index 486549e7..a3d1c013 100644 --- a/anyplotlib/__init__.py +++ b/anyplotlib/__init__.py @@ -1,5 +1,5 @@ from anyplotlib.figure import Figure, GridSpec, SubplotSpec, subplots -from anyplotlib.figure_plots import PlotMesh, Plot3D +from anyplotlib.figure_plots import PlotMesh, Plot3D, PlotBar from anyplotlib.widgets import ( Widget, RectangleWidget, CircleWidget, AnnularWidget, CrosshairWidget, PolygonWidget, LabelWidget, @@ -8,7 +8,7 @@ __all__ = [ "Figure", "GridSpec", "SubplotSpec", "subplots", - "PlotMesh", "Plot3D", + "PlotMesh", "Plot3D", "PlotBar", "Widget", "RectangleWidget", "CircleWidget", "AnnularWidget", "CrosshairWidget", "PolygonWidget", "LabelWidget", "VLineWidget", "HLineWidget", "RangeWidget", diff --git a/anyplotlib/figure.py b/anyplotlib/figure.py index 93419c79..478962b0 100644 --- a/anyplotlib/figure.py +++ b/anyplotlib/figure.py @@ -1,7 +1,7 @@ from __future__ import annotations import json, pathlib import anywidget, numpy as np, traitlets -from anyplotlib.figure_plots import GridSpec, SubplotSpec, Axes, Plot2D, PlotMesh, Plot3D +from anyplotlib.figure_plots import GridSpec, SubplotSpec, Axes, Plot2D, PlotMesh, Plot3D, PlotBar from anyplotlib.callbacks import Event __all__ = ["Figure", "GridSpec", "SubplotSpec", "subplots"] @@ -156,7 +156,10 @@ def _mg(flag, key): plot = self._plots_map.get(pid) panel_specs.append({ "id": pid, - "kind": "3d" if isinstance(plot, Plot3D) else ("2d" if isinstance(plot, (Plot2D, PlotMesh)) else "1d"), + "kind": ("3d" if isinstance(plot, Plot3D) + else "2d" if isinstance(plot, (Plot2D, PlotMesh)) + else "bar" if isinstance(plot, PlotBar) + else "1d"), "row_start": s.row_start, "row_stop": s.row_stop, "col_start": s.col_start, diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index cd7d34ee..8ffe22eb 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -312,6 +312,7 @@ function render({ model, el }) { isPanning: false, panStart: {}, state: null, _hoverSi: -1, _hoverI: -1, // index of hovered marker group / marker (-1 = none) + _hovBar: -1, // index of hovered bar (-1 = none) // 2D extras (null for 1D panels) histCanvas: _p2d ? _p2d.histCanvas : null, histCtx: _p2d ? _p2d.histCtx : null, @@ -1635,9 +1636,10 @@ function render({ model, el }) { // ── panel-level event handlers ─────────────────────────────────────────── function _attachPanelEvents(p) { - if (p.kind === '2d') _attachEvents2d(p); - else if (p.kind === '3d') _attachEvents3d(p); - else _attachEvents1d(p); + if (p.kind === '2d') _attachEvents2d(p); + else if (p.kind === '3d') _attachEvents3d(p); + else if (p.kind === 'bar') _attachEventsBar(p); + else _attachEvents1d(p); } function _canvasToImg2d(px,py,st,pw,ph){ @@ -2400,11 +2402,315 @@ function render({ model, el }) { }); + // ── bar chart ───────────────────────────────────────────────────────────── + // Shared geometry helper used by both drawBar and _attachEventsBar. + // Returns the per-slot pixel width, per-bar pixel width, and coordinate + // mappers for the current panel state. + function _barGeom(st, r) { + const values = st.values || []; + const n = values.length || 1; + const orient = st.orient || 'v'; + const bwFrac = st.bar_width !== undefined ? st.bar_width : 0.7; + const baseline = st.baseline !== undefined ? st.baseline : 0; + const dMin = st.data_min, dMax = st.data_max; + + if (orient === 'h') { + // Horizontal: categories on Y, values on X + const slotPx = r.h / n; + const barPx = slotPx * bwFrac; + // xToPx maps a value to an x pixel (value axis = horizontal) + function xToPx(v) { return r.x + ((v - dMin) / ((dMax - dMin) || 1)) * r.w; } + // yToPx maps a bar index to the centre of its slot (category axis = vertical) + function yToPx(i) { return r.y + (i + 0.5) * slotPx; } + const basePx = Math.max(r.x, Math.min(r.x + r.w, xToPx(baseline))); + return { n, orient, slotPx, barPx, dMin, dMax, baseline, basePx, xToPx, yToPx }; + } else { + // Vertical: categories on X, values on Y + const slotPx = r.w / n; + const barPx = slotPx * bwFrac; + function xToPx(i) { return r.x + (i + 0.5) * slotPx; } + function yToPx(v) { return r.y + r.h - ((v - dMin) / ((dMax - dMin) || 1)) * r.h; } + const basePx = Math.max(r.y, Math.min(r.y + r.h, yToPx(baseline))); + return { n, orient, slotPx, barPx, dMin, dMax, baseline, basePx, xToPx, yToPx }; + } + } + + function drawBar(p) { + const st = p.state; if (!st) return; + const { pw, ph, plotCtx: ctx } = p; + const r = _plotRect1d(pw, ph); + + ctx.clearRect(0, 0, pw, ph); + ctx.fillStyle = theme.bg; ctx.fillRect(0, 0, pw, ph); + ctx.fillStyle = theme.bgPlot; ctx.fillRect(r.x, r.y, r.w, r.h); + + const values = st.values || []; + const xCenters = st.x_centers || values.map((_, i) => i); + const xLabels = st.x_labels || []; + const barColor = st.bar_color || '#4fc3f7'; + const barColors = st.bar_colors || []; + const orient = st.orient || 'v'; + const dMin = st.data_min, dMax = st.data_max; + + if (!values.length) return; + + const g = _barGeom(st, r); + + // ── grid lines (along value axis) ───────────────────────────────────── + ctx.strokeStyle = theme.gridStroke; ctx.lineWidth = 1; + const valRange = (dMax - dMin) || 1; + const valStep = findNice(valRange / Math.max(2, Math.floor((orient==='h' ? r.w : r.h) / 40))); + + if (orient === 'h') { + for (let v = Math.ceil(dMin/valStep)*valStep; v <= dMax+valStep*0.01; v += valStep) { + const px = g.xToPx(v); + if (px < r.x || px > r.x + r.w) continue; + ctx.beginPath(); ctx.moveTo(px, r.y); ctx.lineTo(px, r.y + r.h); ctx.stroke(); + } + } else { + for (let v = Math.ceil(dMin/valStep)*valStep; v <= dMax+valStep*0.01; v += valStep) { + const py = g.yToPx(v); + if (py < r.y || py > r.y + r.h) continue; + ctx.beginPath(); ctx.moveTo(r.x, py); ctx.lineTo(r.x + r.w, py); ctx.stroke(); + } + } + + // ── bars ────────────────────────────────────────────────────────────── + ctx.save(); ctx.beginPath(); ctx.rect(r.x, r.y, r.w, r.h); ctx.clip(); + + for (let i = 0; i < g.n; i++) { + const color = barColors[i] || barColor; + const isHov = (p._hovBar === i); + + if (orient === 'h') { + const cy = g.yToPx(i); + const valPx = g.xToPx(values[i]); + const barLeft = Math.min(valPx, g.basePx); + const barW = Math.max(1, Math.abs(valPx - g.basePx)); + ctx.fillStyle = color; + ctx.fillRect(barLeft, cy - g.barPx / 2, barW, g.barPx); + if (isHov) { + ctx.save(); ctx.fillStyle = 'rgba(255,255,255,0.22)'; + ctx.fillRect(barLeft, cy - g.barPx / 2, barW, g.barPx); + ctx.restore(); + } + ctx.strokeStyle = theme.dark ? 'rgba(0,0,0,0.25)' : 'rgba(0,0,0,0.09)'; + ctx.lineWidth = 0.5; + ctx.strokeRect(barLeft, cy - g.barPx / 2, barW, g.barPx); + } else { + const cx = g.xToPx(i); + const valPy = g.yToPx(values[i]); + const barTop = Math.min(valPy, g.basePx); + const barH = Math.max(1, Math.abs(valPy - g.basePx)); + ctx.fillStyle = color; + ctx.fillRect(cx - g.barPx / 2, barTop, g.barPx, barH); + if (isHov) { + ctx.save(); ctx.fillStyle = 'rgba(255,255,255,0.22)'; + ctx.fillRect(cx - g.barPx / 2, barTop, g.barPx, barH); + ctx.restore(); + } + ctx.strokeStyle = theme.dark ? 'rgba(0,0,0,0.25)' : 'rgba(0,0,0,0.09)'; + ctx.lineWidth = 0.5; + ctx.strokeRect(cx - g.barPx / 2, barTop, g.barPx, barH); + } + } + ctx.restore(); + + // ── value annotations ───────────────────────────────────────────────── + if (st.show_values) { + ctx.font = '9px monospace'; ctx.fillStyle = theme.tickText; + for (let i = 0; i < g.n; i++) { + if (orient === 'h') { + const cy = g.yToPx(i); + const valPx = g.xToPx(values[i]); + const above = values[i] >= g.baseline; + ctx.textAlign = above ? 'left' : 'right'; + ctx.textBaseline = 'middle'; + ctx.fillText(fmtVal(values[i]), valPx + (above ? 3 : -3), cy); + } else { + const cx = g.xToPx(i); + const valPy = g.yToPx(values[i]); + const above = values[i] >= g.baseline; + ctx.textAlign = 'center'; + ctx.textBaseline = above ? 'bottom' : 'top'; + ctx.fillText(fmtVal(values[i]), cx, valPy + (above ? -2 : 2)); + } + } + } + + // ── axis borders ────────────────────────────────────────────────────── + ctx.strokeStyle = theme.axisStroke; ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(r.x, r.y + r.h); ctx.lineTo(r.x + r.w, r.y + r.h); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(r.x, r.y); ctx.lineTo(r.x, r.y + r.h); ctx.stroke(); + + // Explicit baseline when it isn't at the plot edge + if (orient === 'h') { + if (g.basePx > r.x && g.basePx < r.x + r.w) { + ctx.strokeStyle = theme.axisStroke; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(g.basePx, r.y); ctx.lineTo(g.basePx, r.y + r.h); ctx.stroke(); + } + } else { + if (g.basePx > r.y && g.basePx < r.y + r.h) { + ctx.strokeStyle = theme.axisStroke; ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(r.x, g.basePx); ctx.lineTo(r.x + r.w, g.basePx); ctx.stroke(); + } + } + + // ── tick labels ─────────────────────────────────────────────────────── + ctx.font = '10px monospace'; ctx.fillStyle = theme.tickText; + + if (orient === 'h') { + // Value axis → X ticks at bottom + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + for (let v = Math.ceil(dMin/valStep)*valStep; v <= dMax+valStep*0.01; v += valStep) { + const px = g.xToPx(v); + if (px < r.x || px > r.x + r.w) continue; + ctx.strokeStyle = theme.axisStroke; + ctx.beginPath(); ctx.moveTo(px, r.y + r.h); ctx.lineTo(px, r.y + r.h + 4); ctx.stroke(); + ctx.fillStyle = theme.tickText; + ctx.fillText(fmtVal(v), px, r.y + r.h + 7); + } + // Category axis → Y labels on left + ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; + const maxCatLabels = Math.max(1, Math.floor(r.h / 14)); + const catStep = Math.max(1, Math.ceil(g.n / maxCatLabels)); + for (let i = 0; i < g.n; i += catStep) { + const cy = g.yToPx(i); + const label = xLabels[i] !== undefined ? String(xLabels[i]) : fmtVal(xCenters[i]); + ctx.strokeStyle = theme.axisStroke; + ctx.beginPath(); ctx.moveTo(r.x, cy); ctx.lineTo(r.x - 4, cy); ctx.stroke(); + ctx.fillStyle = theme.tickText; + ctx.fillText(label, r.x - 7, cy); + } + // Units + if (st.y_units) { + ctx.textAlign='right'; ctx.textBaseline='top'; ctx.font='9px monospace'; + ctx.fillStyle=theme.unitText; + ctx.fillText(st.y_units, r.x + r.w, r.y + r.h + 24); + ctx.font='10px monospace'; + } + if (st.units) { + ctx.save(); + ctx.translate(Math.round(PAD_L * 0.28), r.y + r.h / 2); ctx.rotate(-Math.PI/2); + ctx.textAlign='center'; ctx.textBaseline='middle'; + ctx.fillStyle=theme.unitText; ctx.font='9px monospace'; + ctx.fillText(st.units, 0, 0); + ctx.restore(); + } + } else { + // Category axis → X ticks at bottom + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + const maxCatLabels = Math.max(1, Math.floor(r.w / 42)); + const catStep = Math.max(1, Math.ceil(g.n / maxCatLabels)); + for (let i = 0; i < g.n; i += catStep) { + const cx = g.xToPx(i); + const label = xLabels[i] !== undefined ? String(xLabels[i]) : fmtVal(xCenters[i]); + ctx.strokeStyle = theme.axisStroke; + ctx.beginPath(); ctx.moveTo(cx, r.y + r.h); ctx.lineTo(cx, r.y + r.h + 4); ctx.stroke(); + ctx.fillStyle = theme.tickText; + ctx.fillText(label, cx, r.y + r.h + 7); + } + if (st.units && st.units !== 'px') { + ctx.textAlign='right'; ctx.textBaseline='top'; ctx.font='9px monospace'; + ctx.fillStyle=theme.unitText; + ctx.fillText(st.units, r.x + r.w, r.y + r.h + 24); + ctx.font='10px monospace'; + } + // Value axis → Y ticks on left + ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; + for (let v = Math.ceil(dMin/valStep)*valStep; v <= dMax+valStep*0.01; v += valStep) { + const py = g.yToPx(v); + if (py < r.y || py > r.y + r.h) continue; + ctx.strokeStyle = theme.axisStroke; + ctx.beginPath(); ctx.moveTo(r.x, py); ctx.lineTo(r.x - 5, py); ctx.stroke(); + ctx.fillStyle = theme.tickText; + ctx.fillText(fmtVal(v), r.x - 8, py); + } + if (st.y_units) { + ctx.save(); + ctx.translate(Math.round(PAD_L * 0.28), r.y + r.h / 2); ctx.rotate(-Math.PI/2); + ctx.textAlign='center'; ctx.textBaseline='middle'; + ctx.fillStyle=theme.unitText; ctx.font='9px monospace'; + ctx.fillText(st.y_units, 0, 0); + ctx.restore(); + } + } + } + + function _attachEventsBar(p) { + const { overlayCanvas } = p; + + // Return the bar index at canvas position (mx, my), or -1 if none. + function _barHit(mx, my) { + const st = p.state; if (!st || !st.values.length) return -1; + const r = _plotRect1d(p.pw, p.ph); + if (mx < r.x || mx > r.x + r.w || my < r.y || my > r.y + r.h) return -1; + const g = _barGeom(st, r); + for (let i = 0; i < g.n; i++) { + if (g.orient === 'h') { + const cy = g.yToPx(i); + const valPx = g.xToPx(st.values[i]); + const left = Math.min(valPx, g.basePx); + const barW = Math.max(1, Math.abs(valPx - g.basePx)); + if (Math.abs(my - cy) <= g.barPx / 2 && mx >= left && mx <= left + barW) return i; + } else { + const cx = g.xToPx(i); + const valPy = g.yToPx(st.values[i]); + const top = Math.min(valPy, g.basePx); + const barH = Math.max(1, Math.abs(valPy - g.basePx)); + if (Math.abs(mx - cx) <= g.barPx / 2 && my >= top && my <= top + barH) return i; + } + } + return -1; + } + + overlayCanvas.addEventListener('mousemove', (e) => { + const st = p.state; if (!st) return; + const rect = overlayCanvas.getBoundingClientRect(); + const mx = e.clientX - rect.left, my = e.clientY - rect.top; + const idx = _barHit(mx, my); + if (idx !== p._hovBar) { + p._hovBar = idx; + drawBar(p); + } + if (idx >= 0) { + const label = (st.x_labels||[])[idx] !== undefined + ? String(st.x_labels[idx]) + : fmtVal((st.x_centers||[])[idx] ?? idx); + _showTooltip(`${label}: ${fmtVal(st.values[idx])}`, e.clientX, e.clientY); + overlayCanvas.style.cursor = 'pointer'; + } else { + tooltip.style.display = 'none'; + overlayCanvas.style.cursor = 'default'; + } + }); + + overlayCanvas.addEventListener('mouseleave', () => { + if (p._hovBar !== -1) { p._hovBar = -1; drawBar(p); } + tooltip.style.display = 'none'; + }); + + overlayCanvas.addEventListener('click', (e) => { + const st = p.state; if (!st) return; + const rect = overlayCanvas.getBoundingClientRect(); + const idx = _barHit(e.clientX - rect.left, e.clientY - rect.top); + if (idx < 0) return; + _emitEvent(p.id, 'on_click', null, { + bar_index: idx, + value: st.values[idx], + x_center: (st.x_centers||[])[idx] ?? idx, + x_label: (st.x_labels||[])[idx] !== undefined + ? String(st.x_labels[idx]) : null, + }); + }); + } + // ── generic redraw ──────────────────────────────────────────────────────── function _redrawPanel(p) { if(!p.state) return; if(p.kind==='2d') draw2d(p); else if(p.kind==='3d') draw3d(p); + else if(p.kind==='bar') drawBar(p); else draw1d(p); } diff --git a/anyplotlib/figure_plots.py b/anyplotlib/figure_plots.py index bf1f85c1..62d96ddd 100644 --- a/anyplotlib/figure_plots.py +++ b/anyplotlib/figure_plots.py @@ -34,7 +34,7 @@ ) __all__ = ["GridSpec", "SubplotSpec", "Axes", "Plot1D", "Plot2D", "PlotMesh", "Plot3D", - "_resample_mesh"] + "PlotBar", "_resample_mesh"] # --------------------------------------------------------------------------- @@ -308,7 +308,57 @@ def plot(self, data: np.ndarray, self._attach(plot) return plot - def _attach(self, plot: "Plot1D | Plot2D | PlotMesh | Plot3D") -> None: + def bar(self, values, + x_labels=None, + x_centers=None, + color: str = "#4fc3f7", + colors=None, + bar_width: float = 0.7, + orient: str = "v", + baseline: float = 0.0, + show_values: bool = False, + units: str = "", + y_units: str = "") -> "PlotBar": + """Attach a bar chart to this axes cell. + + Parameters + ---------- + values : array-like, shape (N,) + Bar heights (vertical) or widths (horizontal). + x_labels : list of str, optional + Category labels for each bar. Shown on the categorical axis + instead of numeric tick values. + x_centers : array-like, optional + Numeric positions of bar centres. Defaults to ``0, 1, … N-1``. + color : str, optional + Single CSS colour applied to every bar. Default ``"#4fc3f7"``. + colors : list of str, optional + Per-bar colour list; overrides *color* where provided. + bar_width : float, optional + Bar width as a fraction of the slot width (0–1). Default ``0.7``. + orient : ``"v"`` | ``"h"``, optional + Vertical (default) or horizontal orientation. + baseline : float, optional + Value at which bars are rooted. Default ``0``. + show_values : bool, optional + Draw the numeric value above / beside each bar. + units : str, optional + Label for the categorical axis. + y_units : str, optional + Label for the value axis. + + Returns + ------- + PlotBar + """ + plot = PlotBar(values, x_labels=x_labels, x_centers=x_centers, + color=color, colors=colors, bar_width=bar_width, + orient=orient, baseline=baseline, show_values=show_values, + units=units, y_units=y_units) + self._attach(plot) + return plot + + def _attach(self, plot: "Plot1D | Plot2D | PlotMesh | Plot3D | PlotBar") -> None: """Register a plot on this axes (replace any previous plot).""" # Allocate a panel id if needed; reuse if replacing if self._plot is not None: @@ -1460,3 +1510,153 @@ def list_markers(self) -> list: out.append({"type": mtype, "name": name, "n": g._count()}) return out + +# --------------------------------------------------------------------------- +# PlotBar +# --------------------------------------------------------------------------- + +class PlotBar: + """Bar-chart plot panel. + + Not an anywidget. Holds state in ``_state`` dict; every mutation calls + ``_push()`` which writes to the parent Figure's panel trait. + + Created by :meth:`Axes.bar`. + """ + + def __init__(self, values, + x_labels=None, + x_centers=None, + color: str = "#4fc3f7", + colors=None, + bar_width: float = 0.7, + orient: str = "v", + baseline: float = 0.0, + show_values: bool = False, + units: str = "", + y_units: str = ""): + self._id: str = "" + self._fig: object = None + + values = np.asarray(values, dtype=float) + n = len(values) + if values.ndim != 1: + raise ValueError(f"values must be 1-D, got shape {values.shape}") + if orient not in ("v", "h"): + raise ValueError("orient must be 'v' or 'h'") + + if x_centers is None: + x_centers = np.arange(n, dtype=float) + x_centers = np.asarray(x_centers, dtype=float) + if len(x_centers) != n: + raise ValueError("x_centers length must match values length") + + val_min = float(np.nanmin(values)) if n else 0.0 + val_max = float(np.nanmax(values)) if n else 1.0 + dmin = min(float(baseline), val_min) + dmax = max(float(baseline), val_max) + pad = (dmax - dmin) * 0.07 if dmax > dmin else 0.5 + dmax += pad + if dmin < float(baseline): + dmin -= pad + + self._state: dict = { + "kind": "bar", + "values": values.tolist(), + "x_centers": x_centers.tolist(), + "x_labels": list(x_labels) if x_labels is not None else [], + "bar_color": color, + "bar_colors": list(colors) if colors is not None else [], + "bar_width": float(bar_width), + "orient": orient, + "baseline": float(baseline), + "show_values": bool(show_values), + "data_min": dmin, + "data_max": dmax, + "units": units, + "y_units": y_units, + } + self.callbacks = CallbackRegistry() + + # ------------------------------------------------------------------ + def _push(self) -> None: + if self._fig is None: + return + self._fig._push(self._id) + + def to_state_dict(self) -> dict: + return dict(self._state) + + # ------------------------------------------------------------------ + # Data update + # ------------------------------------------------------------------ + def update(self, values, x_centers=None, x_labels=None) -> None: + """Replace bar values; recalculates the value-axis range automatically.""" + values = np.asarray(values, dtype=float) + if values.ndim != 1: + raise ValueError(f"values must be 1-D, got shape {values.shape}") + + baseline = self._state["baseline"] + dmin = min(float(baseline), float(np.nanmin(values))) + dmax = max(float(baseline), float(np.nanmax(values))) + pad = (dmax - dmin) * 0.07 if dmax > dmin else 0.5 + dmax += pad + if dmin < baseline: + dmin -= pad + + self._state["values"] = values.tolist() + self._state["data_min"] = dmin + self._state["data_max"] = dmax + if x_centers is not None: + self._state["x_centers"] = np.asarray(x_centers, dtype=float).tolist() + if x_labels is not None: + self._state["x_labels"] = list(x_labels) + self._push() + + # ------------------------------------------------------------------ + # Display settings + # ------------------------------------------------------------------ + def set_color(self, color: str) -> None: + """Set a single colour for all bars.""" + self._state["bar_color"] = color + self._push() + + def set_colors(self, colors) -> None: + """Set per-bar colours (list of CSS colour strings, length N).""" + self._state["bar_colors"] = list(colors) + self._push() + + def set_show_values(self, show: bool) -> None: + """Show or hide in-bar value annotations.""" + self._state["show_values"] = bool(show) + self._push() + + # ------------------------------------------------------------------ + # Callbacks + # ------------------------------------------------------------------ + def on_click(self, fn: Callable) -> Callable: + """Decorator: fires when the user clicks a bar. + + The :class:`~anyplotlib.callbacks.Event` has ``bar_index``, + ``value``, ``x_center``, and ``x_label``. + """ + cid = self.callbacks.connect("on_click", fn) + fn._cid = cid + return fn + + def on_changed(self, fn: Callable) -> Callable: + """Decorator: fires on hover-enter for a bar.""" + cid = self.callbacks.connect("on_changed", fn) + fn._cid = cid + return fn + + def disconnect(self, cid: int) -> None: + self.callbacks.disconnect(cid) + + def __repr__(self) -> str: + n = len(self._state.get("values", [])) + orient = self._state.get("orient", "v") + return f"PlotBar(n={n}, orient={orient!r})" + + + diff --git a/tests/test_bar.py b/tests/test_bar.py new file mode 100644 index 00000000..5ff3396b --- /dev/null +++ b/tests/test_bar.py @@ -0,0 +1,508 @@ +""" +tests/test_bar.py +================= + +Tests for the bar chart (PlotBar) functionality. + +Covers: + * Construction – default and explicit arguments + * State dict contents and data integrity + * Orientation (vertical / horizontal) + * Colour options: single colour and per-bar colours + * Bar-width, baseline, and show_values flags + * x_labels and x_centers + * Range / padding calculations + * update() – value replacement and axis recalculation + * Display-setting mutations: set_color, set_colors, set_show_values + * _push() contract – state is propagated to the Figure + * Layout JSON reflects "bar" kind for PlotBar panels + * Callback API: on_click, on_changed, disconnect + * Edge cases: single bar, negative values, all-equal values, large N + * Validation errors for bad inputs + * repr() +""" + +from __future__ import annotations + +import json +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.callbacks import CallbackRegistry, Event +from anyplotlib.figure_plots import PlotBar + + +# ───────────────────────────────────────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────────────────────────────────────── + +def _make_bar(values=None, **kwargs) -> PlotBar: + """Create a PlotBar attached to a one-panel Figure.""" + if values is None: + values = [1, 2, 3, 4, 5] + fig, ax = apl.subplots(1, 1) + return ax.bar(values, **kwargs) + + +def _state(plot: PlotBar) -> dict: + return plot.to_state_dict() + + +# ───────────────────────────────────────────────────────────────────────────── +# 1. Construction – defaults +# ───────────────────────────────────────────────────────────────────────────── + +class TestPlotBarConstruction: + + def test_kind_is_bar(self): + p = _make_bar() + assert _state(p)["kind"] == "bar" + + def test_values_stored_as_list(self): + values = [10, 20, 30] + p = _make_bar(values) + assert _state(p)["values"] == pytest.approx([10, 20, 30]) + + def test_numpy_array_accepted(self): + arr = np.array([1.0, 2.0, 3.0]) + p = _make_bar(arr) + assert _state(p)["values"] == pytest.approx([1.0, 2.0, 3.0]) + + def test_default_x_centers(self): + p = _make_bar([5, 6, 7]) + assert _state(p)["x_centers"] == pytest.approx([0.0, 1.0, 2.0]) + + def test_default_orient_is_vertical(self): + p = _make_bar() + assert _state(p)["orient"] == "v" + + def test_default_baseline_is_zero(self): + p = _make_bar() + assert _state(p)["baseline"] == pytest.approx(0.0) + + def test_default_bar_width(self): + p = _make_bar() + assert _state(p)["bar_width"] == pytest.approx(0.7) + + def test_default_show_values_false(self): + p = _make_bar() + assert _state(p)["show_values"] is False + + def test_default_color(self): + p = _make_bar() + assert _state(p)["bar_color"] == "#4fc3f7" + + def test_default_bar_colors_empty(self): + p = _make_bar() + assert _state(p)["bar_colors"] == [] + + def test_default_x_labels_empty(self): + p = _make_bar() + assert _state(p)["x_labels"] == [] + + def test_default_units_empty(self): + p = _make_bar() + assert _state(p)["units"] == "" + assert _state(p)["y_units"] == "" + + +# ───────────────────────────────────────────────────────────────────────────── +# 2. Construction – explicit arguments +# ───────────────────────────────────────────────────────────────────────────── + +class TestPlotBarExplicitArgs: + + def test_custom_x_centers(self): + p = _make_bar([1, 2, 3], x_centers=[10, 20, 30]) + assert _state(p)["x_centers"] == pytest.approx([10.0, 20.0, 30.0]) + + def test_custom_x_labels(self): + p = _make_bar([1, 2, 3], x_labels=["A", "B", "C"]) + assert _state(p)["x_labels"] == ["A", "B", "C"] + + def test_custom_color(self): + p = _make_bar(color="#ff0000") + assert _state(p)["bar_color"] == "#ff0000" + + def test_custom_colors_list(self): + colors = ["#f00", "#0f0", "#00f"] + p = _make_bar([1, 2, 3], colors=colors) + assert _state(p)["bar_colors"] == colors + + def test_custom_bar_width(self): + p = _make_bar(bar_width=0.5) + assert _state(p)["bar_width"] == pytest.approx(0.5) + + def test_horizontal_orient(self): + p = _make_bar(orient="h") + assert _state(p)["orient"] == "h" + + def test_custom_baseline(self): + p = _make_bar(baseline=5.0) + assert _state(p)["baseline"] == pytest.approx(5.0) + + def test_show_values_true(self): + p = _make_bar(show_values=True) + assert _state(p)["show_values"] is True + + def test_units_and_y_units(self): + p = _make_bar(units="category", y_units="count") + assert _state(p)["units"] == "category" + assert _state(p)["y_units"] == "count" + + +# ───────────────────────────────────────────────────────────────────────────── +# 3. Range / padding calculations +# ───────────────────────────────────────────────────────────────────────────── + +class TestPlotBarRange: + + def test_data_max_exceeds_max_value(self): + """data_max must include padding above the largest value.""" + p = _make_bar([1, 2, 3, 4, 5]) + assert _state(p)["data_max"] > 5.0 + + def test_data_min_at_baseline_for_positive_values(self): + """With all positive values and baseline=0, data_min <= 0.""" + p = _make_bar([1, 2, 3, 4, 5], baseline=0.0) + assert _state(p)["data_min"] <= 0.0 + + def test_negative_values_extend_data_min(self): + p = _make_bar([-3, -1, 0, 2]) + assert _state(p)["data_min"] < -3.0 + + def test_data_max_gt_data_min(self): + p = _make_bar([1, 2, 3]) + st = _state(p) + assert st["data_max"] > st["data_min"] + + def test_all_equal_values_padded(self): + """Equal values should still get a non-zero range via the fallback pad.""" + p = _make_bar([5, 5, 5]) + st = _state(p) + assert st["data_max"] > st["data_min"] + + def test_baseline_above_all_values(self): + """When baseline > max(values), data_max should be >= baseline.""" + p = _make_bar([1, 2, 3], baseline=10.0) + assert _state(p)["data_max"] >= 10.0 + + def test_baseline_below_all_values(self): + """When baseline < min(values), data_min should be <= baseline.""" + p = _make_bar([5, 6, 7], baseline=-5.0) + assert _state(p)["data_min"] <= -5.0 + + +# ───────────────────────────────────────────────────────────────────────────── +# 4. update() — value replacement +# ───────────────────────────────────────────────────────────────────────────── + +class TestPlotBarUpdate: + + def test_update_replaces_values(self): + p = _make_bar([1, 2, 3]) + p.update([10, 20, 30]) + assert _state(p)["values"] == pytest.approx([10.0, 20.0, 30.0]) + + def test_update_recalculates_data_max(self): + p = _make_bar([1, 2, 3]) + p.update([100, 200, 300]) + assert _state(p)["data_max"] > 300.0 + + def test_update_recalculates_data_min(self): + p = _make_bar([1, 2, 3]) + p.update([-50, -20, -10]) + assert _state(p)["data_min"] < -50.0 + + def test_update_with_new_x_centers(self): + p = _make_bar([1, 2, 3]) + p.update([4, 5, 6], x_centers=[0.5, 1.5, 2.5]) + assert _state(p)["x_centers"] == pytest.approx([0.5, 1.5, 2.5]) + + def test_update_with_new_x_labels(self): + p = _make_bar([1, 2, 3], x_labels=["a", "b", "c"]) + p.update([4, 5, 6], x_labels=["x", "y", "z"]) + assert _state(p)["x_labels"] == ["x", "y", "z"] + + def test_update_preserves_orient(self): + p = _make_bar([1, 2, 3], orient="h") + p.update([4, 5, 6]) + assert _state(p)["orient"] == "h" + + def test_update_preserves_baseline(self): + p = _make_bar([1, 2, 3], baseline=2.0) + p.update([10, 20, 30]) + assert _state(p)["baseline"] == pytest.approx(2.0) + + def test_update_2d_raises(self): + p = _make_bar([1, 2, 3]) + with pytest.raises(ValueError, match="1-D"): + p.update(np.array([[1, 2], [3, 4]])) + + +# ───────────────────────────────────────────────────────────────────────────── +# 5. Display-setting mutations +# ───────────────────────────────────────────────────────────────────────────── + +class TestPlotBarDisplayMutations: + + def test_set_color(self): + p = _make_bar() + p.set_color("#abcdef") + assert _state(p)["bar_color"] == "#abcdef" + + def test_set_colors(self): + p = _make_bar([1, 2, 3]) + p.set_colors(["red", "green", "blue"]) + assert _state(p)["bar_colors"] == ["red", "green", "blue"] + + def test_set_show_values_true(self): + p = _make_bar(show_values=False) + p.set_show_values(True) + assert _state(p)["show_values"] is True + + def test_set_show_values_false(self): + p = _make_bar(show_values=True) + p.set_show_values(False) + assert _state(p)["show_values"] is False + + +# ───────────────────────────────────────────────────────────────────────────── +# 6. _push() / Figure integration +# ───────────────────────────────────────────────────────────────────────────── + +class TestPlotBarPush: + + def test_panel_trait_exists_after_attach(self): + """After ax.bar(), the Figure should have a panel trait for this plot.""" + fig, ax = apl.subplots(1, 1) + p = ax.bar([1, 2, 3]) + trait_name = f"panel_{p._id}_json" + assert fig.has_trait(trait_name), f"Missing trait {trait_name!r}" + + def test_panel_json_contains_kind_bar(self): + fig, ax = apl.subplots(1, 1) + p = ax.bar([1, 2, 3]) + trait_name = f"panel_{p._id}_json" + data = json.loads(getattr(fig, trait_name)) + assert data["kind"] == "bar" + + def test_panel_json_values_after_update(self): + fig, ax = apl.subplots(1, 1) + p = ax.bar([1, 2, 3]) + p.update([7, 8, 9]) + trait_name = f"panel_{p._id}_json" + data = json.loads(getattr(fig, trait_name)) + assert data["values"] == pytest.approx([7.0, 8.0, 9.0]) + + def test_panel_json_color_after_set_color(self): + fig, ax = apl.subplots(1, 1) + p = ax.bar([1, 2, 3]) + p.set_color("#112233") + trait_name = f"panel_{p._id}_json" + data = json.loads(getattr(fig, trait_name)) + assert data["bar_color"] == "#112233" + + def test_push_without_figure_is_noop(self): + """PlotBar._push() before attachment must not raise.""" + p = PlotBar([1, 2, 3]) + p._push() # should be a no-op (fig is None) + + def test_layout_json_kind_bar(self): + """layout_json panel_specs should tag bar plots as 'bar'.""" + fig, ax = apl.subplots(1, 1) + p = ax.bar([10, 20, 30]) + layout = json.loads(fig.layout_json) + panel_spec = next(s for s in layout["panel_specs"] if s["id"] == p._id) + assert panel_spec["kind"] == "bar" + + +# ───────────────────────────────────────────────────────────────────────────── +# 7. Callback API +# ───────────────────────────────────────────────────────────────────────────── + +class TestPlotBarCallbacks: + + def test_has_callback_registry(self): + p = _make_bar() + assert isinstance(p.callbacks, CallbackRegistry) + + def test_on_click_decorator_returns_fn(self): + p = _make_bar() + fn = lambda e: None + returned = p.on_click(fn) + assert returned is fn + + def test_on_click_stamps_cid(self): + p = _make_bar() + + @p.on_click + def cb(event): pass + + assert hasattr(cb, "_cid") and isinstance(cb._cid, int) + + def test_on_click_fires(self): + p = _make_bar() + fired = [] + + @p.on_click + def cb(event): fired.append(event) + + p.callbacks.fire(Event("on_click", p, {"bar_index": 2, "value": 3.0})) + assert len(fired) == 1 + + def test_on_click_event_data(self): + p = _make_bar([10, 20, 30]) + fired = [] + + @p.on_click + def cb(event): fired.append(event) + + p.callbacks.fire(Event("on_click", p, + {"bar_index": 1, "value": 20.0, + "x_center": 1.0, "x_label": "B"})) + assert fired[0].bar_index == 1 + assert fired[0].value == pytest.approx(20.0) + assert fired[0].x_center == pytest.approx(1.0) + assert fired[0].x_label == "B" + + def test_on_changed_fires(self): + p = _make_bar() + fired = [] + + @p.on_changed + def cb(event): fired.append(event) + + p.callbacks.fire(Event("on_changed", p, {})) + assert len(fired) == 1 + + def test_on_click_not_fired_by_on_changed(self): + p = _make_bar() + fired = [] + + @p.on_click + def cb(event): fired.append(event) + + p.callbacks.fire(Event("on_changed", p, {})) + assert fired == [] + + def test_disconnect(self): + p = _make_bar() + fired = [] + + @p.on_click + def cb(event): fired.append(event) + + p.disconnect(cb._cid) + p.callbacks.fire(Event("on_click", p, {})) + assert fired == [] + + def test_multiple_on_click_handlers(self): + p = _make_bar() + log = [] + + @p.on_click + def cb1(event): log.append("a") + + @p.on_click + def cb2(event): log.append("b") + + p.callbacks.fire(Event("on_click", p, {})) + assert sorted(log) == ["a", "b"] + + +# ───────────────────────────────────────────────────────────────────────────── +# 8. Edge cases +# ───────────────────────────────────────────────────────────────────────────── + +class TestPlotBarEdgeCases: + + def test_single_bar(self): + p = _make_bar([42]) + st = _state(p) + assert len(st["values"]) == 1 + assert st["data_max"] > st["data_min"] + + def test_large_n(self): + values = list(range(200)) + p = _make_bar(values) + assert len(_state(p)["values"]) == 200 + assert len(_state(p)["x_centers"]) == 200 + + def test_all_negative_values(self): + p = _make_bar([-5, -3, -1]) + st = _state(p) + assert st["data_min"] < -5.0 + assert st["data_max"] >= 0.0 # baseline is 0 + + def test_mixed_positive_negative(self): + p = _make_bar([-10, 0, 10]) + st = _state(p) + assert st["data_min"] < -10.0 + assert st["data_max"] > 10.0 + + def test_float_values(self): + p = _make_bar([1.1, 2.2, 3.3]) + assert _state(p)["values"] == pytest.approx([1.1, 2.2, 3.3]) + + def test_x_centers_float(self): + p = _make_bar([1, 2, 3], x_centers=[0.5, 1.5, 2.5]) + assert _state(p)["x_centers"] == pytest.approx([0.5, 1.5, 2.5]) + + def test_bar_width_zero_boundary(self): + """bar_width of 0 should be stored without error.""" + p = _make_bar(bar_width=0.0) + assert _state(p)["bar_width"] == pytest.approx(0.0) + + def test_bar_width_one_boundary(self): + p = _make_bar(bar_width=1.0) + assert _state(p)["bar_width"] == pytest.approx(1.0) + + +# ───────────────────────────────────────────────────────────────────────────── +# 9. Validation errors +# ───────────────────────────────────────────────────────────────────────────── + +class TestPlotBarValidation: + + def test_2d_values_raises(self): + with pytest.raises(ValueError, match="1-D"): + PlotBar(np.array([[1, 2], [3, 4]])) + + def test_invalid_orient_raises(self): + with pytest.raises(ValueError, match="orient"): + PlotBar([1, 2, 3], orient="diagonal") + + def test_x_centers_length_mismatch_raises(self): + with pytest.raises(ValueError, match="x_centers length"): + PlotBar([1, 2, 3], x_centers=[0, 1]) + + def test_axes_bar_returns_plotbar_instance(self): + fig, ax = apl.subplots(1, 1) + p = ax.bar([1, 2, 3]) + assert isinstance(p, PlotBar) + + +# ───────────────────────────────────────────────────────────────────────────── +# 10. repr +# ───────────────────────────────────────────────────────────────────────────── + +class TestPlotBarRepr: + + def test_repr_contains_n(self): + p = _make_bar([1, 2, 3, 4]) + assert "n=4" in repr(p) + + def test_repr_contains_orient_v(self): + p = _make_bar([1, 2, 3]) + assert "orient='v'" in repr(p) + + def test_repr_contains_orient_h(self): + p = _make_bar([1, 2, 3], orient="h") + assert "orient='h'" in repr(p) + + def test_repr_is_string(self): + p = _make_bar() + assert isinstance(repr(p), str) + From a296e58b8ae88a1468918f355056285fc069829a Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 16 Mar 2026 15:36:11 -0500 Subject: [PATCH 014/198] New Feature: Add GitHub Actions workflows for documentation and testing --- .github/workflows/docs.yml | 82 +++++++++++++++++++++++++++++++++++++ .github/workflows/tests.yml | 58 ++++++++++++++++++++++++++ pyproject.toml | 3 +- 3 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/docs.yml create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..7743dcb3 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,82 @@ +name: Docs + +on: + push: + # Publish to dev/ on every push to main … + branches: [main] + # … and to a versioned directory on every release tag. + tags: ["v*.*.*"] + # Allow manual re-builds from the Actions tab. + workflow_dispatch: + +# Only one docs deployment should run at a time to avoid race conditions on +# the gh-pages branch. +concurrency: + group: docs-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write # needed to push to gh-pages + +jobs: + build-and-deploy: + name: Build & deploy docs + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + # Full history lets sphinx-gallery / git tools see tags correctly. + fetch-depth: 0 + + # ── uv + Python ──────────────────────────────────────────────────────── + - name: Set up uv + uses: astral-sh/setup-uv@v5 + with: + python-version: "3.13" + enable-cache: true + + # ── Dependencies ─────────────────────────────────────────────────────── + # Install the package itself plus the [docs] optional-dependency group + # (sphinx, pydata-sphinx-theme, sphinx-gallery, pillow). + # sphinx-gallery transitively brings in matplotlib, which the custom + # scraper (docs/_sg_html_scraper.py) needs to render thumbnails. + - name: Install dependencies (with docs extras) + run: uv sync --extra docs + + # ── Sphinx build ─────────────────────────────────────────────────────── + # -W turns warnings into errors so broken cross-references are caught. + # Remove -W if the gallery examples produce unavoidable warnings. + - name: Build HTML documentation + run: | + uv run sphinx-build -b html docs build/html -W --keep-going + + # ── Determine deployment target ───────────────────────────────────────── + # Release tag (refs/tags/v1.2.3) → destination = "v1.2.3" + # Everything else (push to main, manual dispatch) → destination = "dev" + - name: Determine deployment directory + id: target + shell: bash + run: | + if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + echo "dest_dir=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + else + echo "dest_dir=dev" >> "$GITHUB_OUTPUT" + fi + + # ── Deploy to gh-pages ──────────────────────────────────────────────── + # keep_files: true preserves all existing directories on the branch so + # versioned releases accumulate rather than overwriting each other. + # Only the target destination_dir is replaced on each run. + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./build/html + destination_dir: ${{ steps.target.outputs.dest_dir }} + keep_files: true + # Commit message makes the deployment easy to identify in the branch log. + commit_message: | + docs: deploy ${{ steps.target.outputs.dest_dir }} @ ${{ github.sha }} + diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..fb119425 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,58 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + +# Cancel in-flight runs for the same branch so a new push doesn't queue +# behind an older one. +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Python ${{ matrix.python-version }} / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.12", "3.13"] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # ── uv + Python ──────────────────────────────────────────────────────── + - name: Set up uv + uses: astral-sh/setup-uv@v5 + with: + python-version: ${{ matrix.python-version }} + # Cache the uv download / build cache between runs. + enable-cache: true + + # ── Dependencies ─────────────────────────────────────────────────────── + # uv sync installs the project + all dependency groups (including dev, + # which pulls in playwright). + - name: Install dependencies + run: uv sync + + # ── Playwright browsers ──────────────────────────────────────────────── + # Visual regression tests (test_visual.py) use headless Chromium. + # They skip gracefully when no baselines exist, but the browser must be + # present so the playwright fixture doesn't error on setup. + - name: Install Playwright browsers (Linux) + if: runner.os == 'Linux' + run: uv run playwright install chromium --with-deps + + - name: Install Playwright browsers (macOS / Windows) + if: runner.os != 'Linux' + run: uv run playwright install chromium + + # ── Test suite ───────────────────────────────────────────────────────── + - name: Run tests + run: uv run pytest tests/ -v --tb=short + diff --git a/pyproject.toml b/pyproject.toml index 87bf2609..871108c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ packages = ["anyplotlib"] name = "anyplotlib" version = "0.1.0" description = "A plotting library using python, javascript and anywidget for performant in browser plotting." -requires-python = ">=3.12" +requires-python = ">=3.10" dependencies = [ "anywidget>=0.9.21", "colorcet>=3.0", @@ -25,6 +25,7 @@ docs = [ "pydata-sphinx-theme>=0.16", "sphinx-gallery>=0.18", "pillow>=10.0", + "matplotlib>=3.9", ] [dependency-groups] From 0eb47bd4b00b1129d08e4864add0e1fce4186be8 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 16 Mar 2026 15:46:34 -0500 Subject: [PATCH 015/198] Update dependencies in pyproject.toml and enhance GitHub Actions workflow for testing --- .github/workflows/tests.yml | 48 +++++++++++++++++++++++++++---------- pyproject.toml | 8 +++---- 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fb119425..5e02c213 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,8 +5,6 @@ on: branches: [main] pull_request: -# Cancel in-flight runs for the same branch so a new push doesn't queue -# behind an older one. concurrency: group: tests-${{ github.ref }} cancel-in-progress: true @@ -20,30 +18,30 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13"] + exclude: + - os: macos-latest + python-version: "3.10" + - os: macos-latest + python-version: "3.11" + - os: windows-latest + python-version: "3.10" + - os: windows-latest + python-version: "3.11" steps: - name: Checkout repository uses: actions/checkout@v4 - # ── uv + Python ──────────────────────────────────────────────────────── - name: Set up uv uses: astral-sh/setup-uv@v5 with: python-version: ${{ matrix.python-version }} - # Cache the uv download / build cache between runs. enable-cache: true - # ── Dependencies ─────────────────────────────────────────────────────── - # uv sync installs the project + all dependency groups (including dev, - # which pulls in playwright). - name: Install dependencies run: uv sync - # ── Playwright browsers ──────────────────────────────────────────────── - # Visual regression tests (test_visual.py) use headless Chromium. - # They skip gracefully when no baselines exist, but the browser must be - # present so the playwright fixture doesn't error on setup. - name: Install Playwright browsers (Linux) if: runner.os == 'Linux' run: uv run playwright install chromium --with-deps @@ -52,7 +50,31 @@ jobs: if: runner.os != 'Linux' run: uv run playwright install chromium - # ── Test suite ───────────────────────────────────────────────────────── - name: Run tests run: uv run pytest tests/ -v --tb=short + minimum-deps: + name: Minimum deps (Python 3.10 / ubuntu) + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@v5 + with: + python-version: "3.10" + enable-cache: true + + - name: Install dependencies at minimum versions + run: uv sync --resolution lowest-direct + + - name: Show installed versions + run: uv run pip list --format=columns + + - name: Install Playwright browsers + run: uv run playwright install chromium --with-deps + + - name: Run tests + run: uv run pytest tests/ -v --tb=short diff --git a/pyproject.toml b/pyproject.toml index 871108c9..73e1ecc1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,12 +11,12 @@ version = "0.1.0" description = "A plotting library using python, javascript and anywidget for performant in browser plotting." requires-python = ">=3.10" dependencies = [ - "anywidget>=0.9.21", + "anywidget>=0.9.0", "colorcet>=3.0", "jupyterlab>=4.5.5", - "numpy>=2.4.2", + "numpy>=2.0.0", "pytest>=9.0.2", - "traitlets>=5.14.3", + "traitlets>=5.0.0", ] [project.optional-dependencies] @@ -25,7 +25,7 @@ docs = [ "pydata-sphinx-theme>=0.16", "sphinx-gallery>=0.18", "pillow>=10.0", - "matplotlib>=3.9", + "matplotlib>=3.7", ] [dependency-groups] From f7e5667b942b6dd3dccee29c9cc4dd03d0081006 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 16 Mar 2026 16:25:05 -0500 Subject: [PATCH 016/198] New Feature: Add various marker types including arrows, ellipses, rectangles, squares, polygons, texts, points, horizontal lines, and vertical lines for enhanced plotting capabilities --- Examples/Interactive/plot_interactive_fft.py | 10 +- Examples/Markers/plot_arrows.py | 2 +- Examples/Markers/plot_circles.py | 7 ++ Examples/Markers/plot_ellipses.py | 2 +- Examples/Markers/plot_horizontal_lines.py | 9 +- Examples/Markers/plot_line_segments.py | 1 + Examples/Markers/plot_points.py | 13 ++- Examples/Markers/plot_polygons.py | 2 +- Examples/Markers/plot_rectangles.py | 8 ++ Examples/Markers/plot_squares.py | 1 + Examples/Markers/plot_texts.py | 2 +- Examples/Markers/plot_vertical_lines.py | 13 ++- Examples/Widgets/plot_widget1d_hline.py | 2 +- Examples/Widgets/plot_widget1d_range.py | 2 +- Examples/Widgets/plot_widget1d_vline.py | 1 + Examples/Widgets/plot_widget2d_annular.py | 2 +- Examples/Widgets/plot_widget2d_circle.py | 5 +- Examples/Widgets/plot_widget2d_crosshair.py | 2 +- Examples/Widgets/plot_widget2d_label.py | 1 + Examples/Widgets/plot_widget2d_polygon.py | 2 +- Examples/Widgets/plot_widget2d_rectangle.py | 2 +- Examples/plot_3d.py | 19 +++- Examples/plot_image2d.py | 27 ++++- Examples/plot_spectra1d.py | 10 +- anyplotlib/figure_plots.py | 111 ++++++++++++++++++- 25 files changed, 219 insertions(+), 37 deletions(-) diff --git a/Examples/Interactive/plot_interactive_fft.py b/Examples/Interactive/plot_interactive_fft.py index 2d46f562..5030de42 100644 --- a/Examples/Interactive/plot_interactive_fft.py +++ b/Examples/Interactive/plot_interactive_fft.py @@ -147,18 +147,18 @@ def _compute_fft(img_full, x0, y0, w, h): # ── Callbacks ───────────────────────────────────────────────────────────────── -@v_real.on_change(wid) +@wid.on_changed def _roi_dragging(event): """Fires on every drag frame — highlight rectangle while dragging.""" # Cheaply pulse the widget colour to give live drag feedback. for w in v_real._state["overlay_widgets"]: - if w["id"] == wid: + if w["id"] == wid._id: w["color"] = "#ff9800" # orange while dragging break v_real._push() -@v_real.on_release(wid) +@wid.on_release def _roi_released(event): """Fires once on mouse-up — recompute and push the full FFT.""" x0 = event.data.get("x", roi_x0) @@ -168,14 +168,14 @@ def _roi_released(event): # Restore widget colour to yellow for widget in v_real._state["overlay_widgets"]: - if widget["id"] == wid: + if widget["id"] == wid._id: widget["color"] = "#ffeb3b" break log_mag, freq_x, freq_y = _compute_fft(image, x0, y0, w, h) # Push updated FFT into the right panel - v_fft.update(log_mag, x_axis=freq_x, y_axis=freq_y, units="1/Å") + v_fft.update(log_mag, x_axis=freq_x, y_axis=freq_y, units="1/\u00c5") fig diff --git a/Examples/Markers/plot_arrows.py b/Examples/Markers/plot_arrows.py index cb4c7f91..44369458 100644 --- a/Examples/Markers/plot_arrows.py +++ b/Examples/Markers/plot_arrows.py @@ -20,10 +20,10 @@ tails = rng.uniform(15, 100, (8, 2)) U = rng.uniform(-18, 18, 8) V = rng.uniform(-18, 18, 8) - v.add_arrows(tails, U, V, name="flow", edgecolors="#76ff03", linewidths=2.0, label="flow vectors") + fig # %% diff --git a/Examples/Markers/plot_circles.py b/Examples/Markers/plot_circles.py index 29413f6c..a7d3325d 100644 --- a/Examples/Markers/plot_circles.py +++ b/Examples/Markers/plot_circles.py @@ -1,27 +1,34 @@ """ Circles ======= + Mark circular features on a 2-D image with :meth:`~anyplotlib.figure_plots.Plot2D.add_circles`. Use ``markers["circles"]["name"].set(...)`` to update them live. """ import numpy as np import anyplotlib as vw + rng = np.random.default_rng(0) data = rng.standard_normal((128, 128)).cumsum(0).cumsum(1) data = (data - data.min()) / (data.max() - data.min()) xy = np.linspace(0, 10, 128) + fig, ax = vw.subplots(1, 1, figsize=(460, 460)) v = ax.imshow(data, axes=[xy, xy], units="nm") + centres = rng.uniform(15, 113, (8, 2)) v.add_circles(centres, name="spots", radius=10, edgecolors="#ff1744", facecolors="#ff174433", labels=[f"#{i}" for i in range(8)]) + fig + # %% # Live update # ----------- # Call ``.set()`` on the marker group to push any change immediately. + v.markers["circles"]["spots"].set(radius=16, edgecolors="#ffcc00", facecolors="#ffcc0033") fig diff --git a/Examples/Markers/plot_ellipses.py b/Examples/Markers/plot_ellipses.py index 6f2be5de..44d248af 100644 --- a/Examples/Markers/plot_ellipses.py +++ b/Examples/Markers/plot_ellipses.py @@ -23,6 +23,7 @@ name="grains", edgecolors="#ff9100", facecolors="#ff910033", label="grains", labels=["A", "B", "C"]) + fig # %% @@ -34,4 +35,3 @@ edgecolors="#69f0ae", facecolors="#69f0ae33") fig - diff --git a/Examples/Markers/plot_horizontal_lines.py b/Examples/Markers/plot_horizontal_lines.py index 995cd1c9..42119015 100644 --- a/Examples/Markers/plot_horizontal_lines.py +++ b/Examples/Markers/plot_horizontal_lines.py @@ -1,22 +1,29 @@ """ Horizontal Lines ================ -Draw read-only horizontal lines on a 1-D plot with + +Draw static horizontal threshold lines on a 1-D plot with :meth:`~anyplotlib.figure_plots.Plot1D.add_hlines`. Use ``markers["hlines"]["name"].set(...)`` to update them live. """ import numpy as np import anyplotlib as vw + x = np.linspace(0, 4 * np.pi, 512) signal = np.sin(x) + fig, ax = vw.subplots(1, 1, figsize=(560, 300)) v = ax.plot(signal, axes=[x], units="rad") + v.add_hlines([0.5, 0.0, -0.5], name="thresholds", color="#69f0ae", linewidths=1.5, label="thresholds", labels=["+0.5", "zero", "-0.5"]) + fig + # %% # Live update # ----------- + v.markers["hlines"]["thresholds"].set(color="#ff1744", linewidths=2.0) fig diff --git a/Examples/Markers/plot_line_segments.py b/Examples/Markers/plot_line_segments.py index 49e8901f..88dc0720 100644 --- a/Examples/Markers/plot_line_segments.py +++ b/Examples/Markers/plot_line_segments.py @@ -28,6 +28,7 @@ edgecolors="#00e5ff", linewidths=1.5, label="frame", labels=["top", "right", "bottom", "left", "diagonal"]) + fig # %% diff --git a/Examples/Markers/plot_points.py b/Examples/Markers/plot_points.py index f626bfbc..76da84dd 100644 --- a/Examples/Markers/plot_points.py +++ b/Examples/Markers/plot_points.py @@ -1,25 +1,32 @@ """ Points ====== -Draw point markers on a 1-D plot with + +Mark specific (x, y) positions on a 1-D plot with :meth:`~anyplotlib.figure_plots.Plot1D.add_points`. Use ``markers["points"]["name"].set(...)`` to update them live. """ import numpy as np import anyplotlib as vw + x = np.linspace(0, 4 * np.pi, 512) signal = np.sin(x) + fig, ax = vw.subplots(1, 1, figsize=(560, 300)) v = ax.plot(signal, axes=[x], units="rad") + peak_x = np.array([np.pi / 2, 5 * np.pi / 2, 9 * np.pi / 2]) offsets = np.column_stack([peak_x, np.sin(peak_x)]) v.add_points(offsets, name="peaks", - edgecolors="#ff1744", facecolors="#ff174433", sizes=8, + sizes=8, color="#ff1744", facecolors="#ff174433", label="peaks", labels=["P1", "P2", "P3"]) + fig + # %% # Live update # ----------- -v.markers["points"]["peaks"].set(sizes=12, edgecolors="#ffcc00", + +v.markers["points"]["peaks"].set(sizes=12, color="#ffcc00", facecolors="#ffcc0033") fig diff --git a/Examples/Markers/plot_polygons.py b/Examples/Markers/plot_polygons.py index e3f9adaf..49540817 100644 --- a/Examples/Markers/plot_polygons.py +++ b/Examples/Markers/plot_polygons.py @@ -21,11 +21,11 @@ hexagon = [[64.0 + 28 * np.cos(np.radians(60 * k)), 95.0 + 28 * np.sin(np.radians(60 * k))] for k in range(6)] - v.add_polygons([triangle, hexagon], name="shapes", edgecolors="#69f0ae", facecolors="#69f0ae22", linewidths=2.0, label="shapes", labels=["triangle", "hexagon"]) + fig # %% diff --git a/Examples/Markers/plot_rectangles.py b/Examples/Markers/plot_rectangles.py index 60d23266..3124acd2 100644 --- a/Examples/Markers/plot_rectangles.py +++ b/Examples/Markers/plot_rectangles.py @@ -1,25 +1,33 @@ """ Rectangles ========== + Draw bounding boxes on a 2-D image with :meth:`~anyplotlib.figure_plots.Plot2D.add_rectangles`. +Use ``markers["rectangles"]["name"].set(...)`` to update them live. """ import numpy as np import anyplotlib as vw + rng = np.random.default_rng(1) data = rng.standard_normal((128, 128)).cumsum(0).cumsum(1) data = (data - data.min()) / (data.max() - data.min()) xy = np.linspace(0, 10, 128) + fig, ax = vw.subplots(1, 1, figsize=(460, 460)) v = ax.imshow(data, axes=[xy, xy], units="nm") + centres = rng.uniform(20, 108, (5, 2)) v.add_rectangles(centres, widths=22, heights=14, name="boxes", edgecolors="#00e5ff", facecolors="#00e5ff22", labels=[f"R{i}" for i in range(5)]) + fig + # %% # Live update # ----------- + v.markers["rectangles"]["boxes"].set(widths=30, heights=20, edgecolors="#ff9100", facecolors="#ff910033") diff --git a/Examples/Markers/plot_squares.py b/Examples/Markers/plot_squares.py index 125dfe23..1b3146b3 100644 --- a/Examples/Markers/plot_squares.py +++ b/Examples/Markers/plot_squares.py @@ -24,6 +24,7 @@ name="tiles", edgecolors="#00e5ff", facecolors="#00e5ff22", label="tiles", labels=[f"T{i}" for i in range(5)]) + fig # %% diff --git a/Examples/Markers/plot_texts.py b/Examples/Markers/plot_texts.py index d0898ffe..5922eee8 100644 --- a/Examples/Markers/plot_texts.py +++ b/Examples/Markers/plot_texts.py @@ -22,6 +22,7 @@ name="corners", color="#ffeb3b", fontsize=12, label="corners") + fig # %% @@ -31,4 +32,3 @@ v.markers["texts"]["corners"].set(color="#e040fb", fontsize=14) fig - diff --git a/Examples/Markers/plot_vertical_lines.py b/Examples/Markers/plot_vertical_lines.py index 875bd4de..be320544 100644 --- a/Examples/Markers/plot_vertical_lines.py +++ b/Examples/Markers/plot_vertical_lines.py @@ -1,22 +1,29 @@ """ Vertical Lines ============== -Draw read-only vertical lines on a 1-D plot with + +Draw static vertical marker lines on a 1-D plot with :meth:`~anyplotlib.figure_plots.Plot1D.add_vlines`. Use ``markers["vlines"]["name"].set(...)`` to update them live. """ import numpy as np import anyplotlib as vw -x = np.linspace(0, 4 * np.pi, 512) + +x = np.linspace(0, 4 * np.pi, 512) signal = np.sin(x) + fig, ax = vw.subplots(1, 1, figsize=(560, 300)) v = ax.plot(signal, axes=[x], units="rad") + v.add_vlines([np.pi, 2 * np.pi, 3 * np.pi], name="pi_mult", color="#00e5ff", linewidths=1.5, - label="pi multiples", labels=["pi", "2pi", "3pi"]) + label="pi multiples", labels=["\u03c0", "2\u03c0", "3\u03c0"]) + fig + # %% # Live update # ----------- + v.markers["vlines"]["pi_mult"].set(color="#ff9100", linewidths=2.0) fig diff --git a/Examples/Widgets/plot_widget1d_hline.py b/Examples/Widgets/plot_widget1d_hline.py index 8cfb70e1..f3f143e6 100644 --- a/Examples/Widgets/plot_widget1d_hline.py +++ b/Examples/Widgets/plot_widget1d_hline.py @@ -16,5 +16,5 @@ v = ax.plot(signal, axes=[x], units="rad") v.add_hline_widget(y=0.5, color="#69f0ae") -fig +fig diff --git a/Examples/Widgets/plot_widget1d_range.py b/Examples/Widgets/plot_widget1d_range.py index 7eecdf3c..32e62f6c 100644 --- a/Examples/Widgets/plot_widget1d_range.py +++ b/Examples/Widgets/plot_widget1d_range.py @@ -17,5 +17,5 @@ v = ax.plot(signal, axes=[x], units="rad") v.add_range_widget(x0=np.pi, x1=2 * np.pi, color="#ffeb3b") -fig +fig diff --git a/Examples/Widgets/plot_widget1d_vline.py b/Examples/Widgets/plot_widget1d_vline.py index 88debdb9..8041eccc 100644 --- a/Examples/Widgets/plot_widget1d_vline.py +++ b/Examples/Widgets/plot_widget1d_vline.py @@ -16,4 +16,5 @@ v = ax.plot(signal, axes=[x], units="rad") v.add_vline_widget(x=np.pi, color="#e040fb") + fig diff --git a/Examples/Widgets/plot_widget2d_annular.py b/Examples/Widgets/plot_widget2d_annular.py index b79faa6e..ab8b3258 100644 --- a/Examples/Widgets/plot_widget2d_annular.py +++ b/Examples/Widgets/plot_widget2d_annular.py @@ -17,5 +17,5 @@ v = ax.imshow(data, axes=[xy, xy], units="nm") v.add_widget("annular", color="#00e5ff", cx=64, cy=64, r_outer=40, r_inner=20) -fig +fig diff --git a/Examples/Widgets/plot_widget2d_circle.py b/Examples/Widgets/plot_widget2d_circle.py index afb0d62a..8aea954a 100644 --- a/Examples/Widgets/plot_widget2d_circle.py +++ b/Examples/Widgets/plot_widget2d_circle.py @@ -4,8 +4,7 @@ A draggable, resizable circle overlay on a 2-D image panel. Add it with :meth:`~anyplotlib.figure_plots.Plot2D.add_widget` using -``kind="circle"``, or via the convenience wrapper -``add_widget("circle", ...)``. +``kind="circle"``. """ import numpy as np import anyplotlib as vw @@ -19,5 +18,5 @@ v = ax.imshow(data, axes=[xy, xy], units="nm") v.add_widget("circle", color="#e040fb", cx=64, cy=64, r=20) -fig +fig diff --git a/Examples/Widgets/plot_widget2d_crosshair.py b/Examples/Widgets/plot_widget2d_crosshair.py index 3b29b0a6..f61e2d73 100644 --- a/Examples/Widgets/plot_widget2d_crosshair.py +++ b/Examples/Widgets/plot_widget2d_crosshair.py @@ -17,5 +17,5 @@ v = ax.imshow(data, axes=[xy, xy], units="nm") v.add_widget("crosshair", color="#69f0ae", cx=64, cy=64) -fig +fig diff --git a/Examples/Widgets/plot_widget2d_label.py b/Examples/Widgets/plot_widget2d_label.py index 30055587..f19fe7be 100644 --- a/Examples/Widgets/plot_widget2d_label.py +++ b/Examples/Widgets/plot_widget2d_label.py @@ -19,4 +19,5 @@ v.add_widget("label", color="#ff1744", x=10, y=10, text="Region A", fontsize=14) + fig diff --git a/Examples/Widgets/plot_widget2d_polygon.py b/Examples/Widgets/plot_widget2d_polygon.py index 2a1ab9e7..0be6721d 100644 --- a/Examples/Widgets/plot_widget2d_polygon.py +++ b/Examples/Widgets/plot_widget2d_polygon.py @@ -19,5 +19,5 @@ v.add_widget("polygon", color="#ff9100", vertices=[[32, 16], [96, 16], [112, 80], [64, 112], [16, 80]]) -fig +fig diff --git a/Examples/Widgets/plot_widget2d_rectangle.py b/Examples/Widgets/plot_widget2d_rectangle.py index afa2a904..e5b848c1 100644 --- a/Examples/Widgets/plot_widget2d_rectangle.py +++ b/Examples/Widgets/plot_widget2d_rectangle.py @@ -18,5 +18,5 @@ v = ax.imshow(data, axes=[xy, xy], units="nm") v.add_widget("rectangle", color="#ffeb3b", x=24, y=24, w=80, h=60) -fig +fig diff --git a/Examples/plot_3d.py b/Examples/plot_3d.py index b63706ad..642bbf10 100644 --- a/Examples/plot_3d.py +++ b/Examples/plot_3d.py @@ -1,6 +1,7 @@ """ 3D Plotting =========== + Demonstrate the three 3-D geometry types supported by :meth:`~anyplotlib.figure_plots.Axes.plot_surface`, :meth:`~anyplotlib.figure_plots.Axes.scatter3d`, and @@ -9,19 +10,24 @@ """ import numpy as np import anyplotlib as vw + # ── Surface ─────────────────────────────────────────────────────────────────── x = np.linspace(-3, 3, 60) y = np.linspace(-3, 3, 60) XX, YY = np.meshgrid(x, y) -ZZ = np.sin(np.sqrt(XX**2 + YY**2)) +ZZ = np.sin(np.sqrt(XX ** 2 + YY ** 2)) + fig, ax = vw.subplots(1, 1, figsize=(520, 480)) surf = ax.plot_surface(XX, YY, ZZ, colormap="viridis", x_label="x", y_label="y", z_label="sin(r)") + fig + # %% # Scatter plot # ------------ + rng = np.random.default_rng(1) n = 300 theta = rng.uniform(0, 2 * np.pi, n) @@ -30,30 +36,39 @@ xs = r * np.sin(phi) * np.cos(theta) ys = r * np.sin(phi) * np.sin(theta) zs = r * np.cos(phi) + fig2, ax2 = vw.subplots(1, 1, figsize=(480, 480)) sc = ax2.scatter3d(xs, ys, zs, color="#4fc3f7", point_size=3, x_label="x", y_label="y", z_label="z") + fig2 + # %% # 3-D line — parametric helix # ---------------------------- + t = np.linspace(0, 4 * np.pi, 300) hx = np.cos(t) hy = np.sin(t) hz = t / (4 * np.pi) + fig3, ax3 = vw.subplots(1, 1, figsize=(480, 480)) ln = ax3.plot3d(hx, hy, hz, color="#ff7043", linewidth=2, x_label="cos t", y_label="sin t", z_label="t") + fig3 + # %% # Update the surface data live # ---------------------------- # Call :meth:`~anyplotlib.figure_plots.Plot3D.update` to replace the geometry # without recreating the panel. -ZZ2 = np.cos(np.sqrt(XX**2 + YY**2)) + +ZZ2 = np.cos(np.sqrt(XX ** 2 + YY ** 2)) surf.update(XX, YY, ZZ2) surf.set_colormap("plasma") surf.set_view(azimuth=30, elevation=40) + fig diff --git a/Examples/plot_image2d.py b/Examples/plot_image2d.py index 758e37cf..22047da0 100644 --- a/Examples/plot_image2d.py +++ b/Examples/plot_image2d.py @@ -1,6 +1,7 @@ """ 2D Image ======== + Display a 2-D image with physical axes using :meth:`~anyplotlib.figure_plots.Axes.imshow`. The image is a synthetic STEM-like diffraction pattern with a physical @@ -11,30 +12,43 @@ """ import numpy as np import anyplotlib as vw + + rng = np.random.default_rng(1) + # ── Synthetic diffraction pattern ───────────────────────────────────────────── N = 256 x = np.linspace(-5, 5, N) # physical axis in nm y = np.linspace(-5, 5, N) XX, YY = np.meshgrid(x, y) R = np.sqrt(XX ** 2 + YY ** 2) + + def _ring(r, r0, width, amp): return amp * np.exp(-0.5 * ((r - r0) / width) ** 2) + + image = ( _ring(R, 0.0, 0.30, 1.00) # central spot + _ring(R, 2.1, 0.15, 0.55) # first-order ring + _ring(R, 4.2, 0.15, 0.25) # second-order ring + rng.normal(scale=0.04, size=(N, N)) ) -# ── Plot ─────────────────────────────────────────────────────────────────────── + +# ── Plot ────────────────────────────────────────────────────────────────────── fig, ax = vw.subplots(1, 1, figsize=(500, 500)) v = ax.imshow(image, axes=[x, y], units="nm") v.set_colormap("inferno") -# ── First-order spot markers ─────────────────────────────────────────────────── + +# ── First-order spot markers ────────────────────────────────────────────────── # imshow axes are centre arrays: pixel = (phys - x[0]) / (x[1] - x[0]) dx = x[1] - x[0] + + def phys_to_px(val): return (np.asarray(val) - x[0]) / dx + + spot_nm = np.array([[ 2.1, 0.0], [-2.1, 0.0], [ 0.0, 2.1], [ 0.0, -2.1]]) spot_px = np.column_stack([phys_to_px(spot_nm[:, 0]), @@ -42,18 +56,23 @@ def phys_to_px(val): v.add_circles(spot_px, name="spots", radius=7, edgecolors="#00e5ff", facecolors="#00e5ff22", labels=["g1", "g1_bar", "g2", "g2_bar"]) -# ── Annular integration widget ───────────────────────────────────────────────── + +# ── Annular integration widget ──────────────────────────────────────────────── cx = cy = float(phys_to_px(0.0)) v.add_widget("annular", color="#ffcc00", cx=cx, cy=cy, r_outer=float(phys_to_px(2.8) - phys_to_px(0.0)), r_inner=float(phys_to_px(1.2) - phys_to_px(0.0))) + fig + # %% # Adjust display range and colour map -# ------------------------------------- +# ------------------------------------ # :meth:`~anyplotlib.figure_plots.Plot2D.set_clim` clips the colour scale; # :meth:`~anyplotlib.figure_plots.Plot2D.set_colormap` switches the palette. + v.set_clim(vmin=0.0, vmax=0.8) v.set_colormap("viridis") + fig diff --git a/Examples/plot_spectra1d.py b/Examples/plot_spectra1d.py index eac54435..7d8a8913 100644 --- a/Examples/plot_spectra1d.py +++ b/Examples/plot_spectra1d.py @@ -6,7 +6,7 @@ :meth:`~anyplotlib.figure_plots.Axes.plot`. The spectrum contains a broad background and three Gaussian peaks. -Vertical-line markers highlight the peak positions, and a range widget +Circle markers highlight the peak positions, and a range widget selects a region of interest. Pan and zoom with the mouse; press **R** to reset the view. """ @@ -15,7 +15,7 @@ rng = np.random.default_rng(0) -# ── Synthetic XPS-style spectrum ───────────────────────────────────────────── +# ── Synthetic XPS-style spectrum ────────────────────────────────────────────── energy = np.linspace(280, 295, 512) # binding energy axis (eV) def gaussian(x, mu, sigma, amp): @@ -41,8 +41,8 @@ def gaussian(x, mu, sigma, amp): np.interp(peak_energies, energy, spectrum), ]) v.add_points(peak_offsets, name="peaks", - edgecolors="#ff1744", facecolors="#ff174433", sizes=7, - labels=["C–C", "C–O", "C=O"]) + sizes=7, color="#ff1744", facecolors="#ff174433", + labels=["C\u2013C", "C\u2013O", "C=O"]) # ── Region-of-interest widget ───────────────────────────────────────────────── v.add_range_widget(x0=285.8, x1=288.8, color="#00e5ff") @@ -62,5 +62,5 @@ def gaussian(x, mu, sigma, amp): + gaussian(energy, 288.0, 0.4, 0.18) ) v.add_line(fit, x_axis=energy, color="#ffcc00", linewidth=1.5, label="fit") -fig +fig diff --git a/anyplotlib/figure_plots.py b/anyplotlib/figure_plots.py index 62d96ddd..9bd4a73d 100644 --- a/anyplotlib/figure_plots.py +++ b/anyplotlib/figure_plots.py @@ -804,6 +804,81 @@ def add_lines(self, segments, name=None, *, hover_edgecolors=hover_edgecolors, labels=labels, label=label) + def add_arrows(self, offsets, U, V, name=None, *, + edgecolors="#ff0000", linewidths=1.5, + hover_edgecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + """Add vector-arrow markers in physical (data) coordinates.""" + return self._add_marker("arrows", name, offsets=offsets, U=U, V=V, + edgecolors=edgecolors, linewidths=linewidths, + hover_edgecolors=hover_edgecolors, + labels=labels, label=label) + + def add_ellipses(self, offsets, widths, heights, name=None, *, + angles=0, facecolors=None, edgecolors="#ff0000", + linewidths=1.5, alpha=0.3, + hover_edgecolors=None, hover_facecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + """Add ellipse markers in physical (data) coordinates.""" + return self._add_marker("ellipses", name, offsets=offsets, + widths=widths, heights=heights, angles=angles, + facecolors=facecolors, edgecolors=edgecolors, + linewidths=linewidths, alpha=alpha, + hover_edgecolors=hover_edgecolors, + hover_facecolors=hover_facecolors, + labels=labels, label=label) + + def add_rectangles(self, offsets, widths, heights, name=None, *, + angles=0, facecolors=None, edgecolors="#ff0000", + linewidths=1.5, alpha=0.3, + hover_edgecolors=None, hover_facecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + """Add rectangle markers in physical (data) coordinates.""" + return self._add_marker("rectangles", name, offsets=offsets, + widths=widths, heights=heights, angles=angles, + facecolors=facecolors, edgecolors=edgecolors, + linewidths=linewidths, alpha=alpha, + hover_edgecolors=hover_edgecolors, + hover_facecolors=hover_facecolors, + labels=labels, label=label) + + def add_squares(self, offsets, widths, name=None, *, + angles=0, facecolors=None, edgecolors="#ff0000", + linewidths=1.5, alpha=0.3, + hover_edgecolors=None, hover_facecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + """Add square markers in physical (data) coordinates.""" + return self._add_marker("squares", name, offsets=offsets, + widths=widths, angles=angles, + facecolors=facecolors, edgecolors=edgecolors, + linewidths=linewidths, alpha=alpha, + hover_edgecolors=hover_edgecolors, + hover_facecolors=hover_facecolors, + labels=labels, label=label) + + def add_polygons(self, vertices_list, name=None, *, + facecolors=None, edgecolors="#ff0000", + linewidths=1.5, alpha=0.3, + hover_edgecolors=None, hover_facecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + """Add closed polygon markers in physical (data) coordinates.""" + return self._add_marker("polygons", name, vertices_list=vertices_list, + facecolors=facecolors, edgecolors=edgecolors, + linewidths=linewidths, alpha=alpha, + hover_edgecolors=hover_edgecolors, + hover_facecolors=hover_facecolors, + labels=labels, label=label) + + def add_texts(self, offsets, texts, name=None, *, + color="#ff0000", fontsize=12, + hover_edgecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + """Add text annotation markers in physical (data) coordinates.""" + return self._add_marker("texts", name, offsets=offsets, texts=texts, + color=color, fontsize=fontsize, + hover_edgecolors=hover_edgecolors, + labels=labels, label=label) + def remove_marker(self, marker_type: str, name: str) -> None: self.markers.remove(marker_type, name) @@ -1412,13 +1487,47 @@ def add_circles(self, offsets, name=None, *, radius=5, linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, labels=None, label=None) -> "MarkerGroup": # noqa: F821 - return self._add_marker("circles", name, offsets=offsets, radius=radius, + # On 1-D panels the native type is "points" (radius maps to sizes). + return self._add_marker("points", name, offsets=offsets, sizes=radius, facecolors=facecolors, edgecolors=edgecolors, linewidths=linewidths, alpha=alpha, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, labels=labels, label=label) + def add_points(self, offsets, name=None, *, sizes=5, + color="#ff0000", facecolors=None, + linewidths=1.5, alpha=0.3, + hover_edgecolors=None, hover_facecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + """Add point markers at (x, y) positions in data coordinates.""" + return self._add_marker("points", name, offsets=offsets, sizes=sizes, + edgecolors=color, facecolors=facecolors, + linewidths=linewidths, alpha=alpha, + hover_edgecolors=hover_edgecolors, + hover_facecolors=hover_facecolors, + labels=labels, label=label) + + def add_hlines(self, y_values, name=None, *, + color="#ff0000", linewidths=1.5, + hover_edgecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + """Add static horizontal lines at the given y positions.""" + return self._add_marker("hlines", name, offsets=y_values, + color=color, linewidths=linewidths, + hover_edgecolors=hover_edgecolors, + labels=labels, label=label) + + def add_vlines(self, x_values, name=None, *, + color="#ff0000", linewidths=1.5, + hover_edgecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + """Add static vertical lines at the given x positions.""" + return self._add_marker("vlines", name, offsets=x_values, + color=color, linewidths=linewidths, + hover_edgecolors=hover_edgecolors, + labels=labels, label=label) + def add_arrows(self, offsets, U, V, name=None, *, edgecolors="#ff0000", linewidths=1.5, hover_edgecolors=None, From 7854b202ae7ad86973bbcd2de07613c3f9460f4c Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 16 Mar 2026 16:34:19 -0500 Subject: [PATCH 017/198] Update documentation to reflect repository name change and enhance usage examples for plotting functionality --- docs/api/figure.rst | 4 +-- docs/getting_started.rst | 39 +++++++++++++++++++--------- docs/index.rst | 56 +++++++++++++++++++--------------------- 3 files changed, 56 insertions(+), 43 deletions(-) diff --git a/docs/api/figure.rst b/docs/api/figure.rst index aa217fa4..58b5c822 100644 --- a/docs/api/figure.rst +++ b/docs/api/figure.rst @@ -1,7 +1,7 @@ Figure -======== +====== -.. automodule:: viewer.figure +.. automodule:: anyplotlib.figure :members: :undoc-members: False :show-inheritance: diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 82becd09..533d1090 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -6,40 +6,55 @@ Installation Clone the repository and install with ``uv`` (or pip):: - git clone https://github.com/your-org/viewer.git - cd viewer + git clone https://github.com/your-org/anyplotlib.git + cd anyplotlib uv sync # installs the project + all dependencies Quick start ----------- -1-D viewer -~~~~~~~~~~ +1-D plot +~~~~~~~~ .. code-block:: python import numpy as np - from viewer import Viewer1D + import anyplotlib as vw x = np.linspace(0, 4 * np.pi, 512) signal = np.sin(x) - v = Viewer1D(signal, x_axis=x, units="rad") + fig, ax = vw.subplots(1, 1, figsize=(620, 320)) + v = ax.plot(signal, axes=[x], units="rad") v # display in a Jupyter cell -2-D viewer -~~~~~~~~~~ +2-D image +~~~~~~~~~ .. code-block:: python import numpy as np - from viewer import Viewer2D + import anyplotlib as vw data = np.random.default_rng(0).standard_normal((256, 256)) - v = Viewer2D(data, units="px") + fig, ax = vw.subplots(1, 1, figsize=(500, 500)) + v = ax.imshow(data, units="px") v # display in a Jupyter cell -For more elaborate usage, see the :doc:`auto_examples/index` gallery or -the :doc:`api/index`. +Bar chart +~~~~~~~~~ +.. code-block:: python + + import numpy as np + import anyplotlib as vw + values = np.array([42, 55, 48, 63, 71, 68], dtype=float) + months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"] + + fig, ax = vw.subplots(1, 1, figsize=(560, 320)) + bar = ax.bar(values, x_labels=months, color="#4fc3f7", show_values=True) + bar # display in a Jupyter cell + +For more elaborate usage, see the :doc:`auto_examples/index` gallery or +the :doc:`api/index`. diff --git a/docs/index.rst b/docs/index.rst index 5a69a7de..2a7c79c0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -15,49 +15,47 @@ canvas renderer. The goal is to duplicate and extend the interactive plotting c although the scope is intentionally limited in the following ways: 1. This uses the object-oriented API of Matplotlib, not the stateful pyplot interface. This means there is -no `plt.imshow` or `plt.plot` – instead, you create a viewer object and call methods on it to add data -and customize the plot. This is a deliberate choice to avoid the pitfalls of the stateful API. + no ``plt.imshow`` or ``plt.plot`` — instead, you create a figure object and call methods on axes to add + data and customize the plot. This is a deliberate choice to avoid the pitfalls of the stateful API. -python -``` -import anyplotlib as apl -import matplotlib.pyplot as plt + .. code-block:: python -# matplotlib: -fig, axs = plt.subplots(1,1) -axs.imshow(...) + import anyplotlib as apl + import matplotlib.pyplot as plt -# anyplotlib equivalent: -fig, axs = apl.subplots(1,1) -axs.imshow(...) -``` + # matplotlib: + fig, axs = plt.subplots(1, 1) + axs.imshow(...) + + # anyplotlib equivalent: + fig, axs = apl.subplots(1, 1) + axs.imshow(...) 2. In matplotlib they use vector graphics (SVG) to render the plot, which is great for static images. It's especially -great for making publication-quality figures. (If you haven't try inkscape + matplotlib SVG output, -it's pretty amazing.) For interactivity, it can be slow. Anyplotlib uses a pure-JavaScript canvas renderer which is -much faster for interactive applications, but the quality of the output is not as good as vector graphics. This is a -trade-off that we are willing to make for the sake of interactivity. + great for making publication-quality figures. (If you haven't tried inkscape + matplotlib SVG output, + it's pretty amazing.) For interactivity, it can be slow. Anyplotlib uses a pure-JavaScript canvas renderer which is + much faster for interactive applications, but the quality of the output is not as good as vector graphics. This is a + trade-off that we are willing to make for the sake of interactivity. 3. Matplotlib supports a wide range of marker styles, line styles, and other plot elements. Anyplotlib focuses on a -core set of features that are most commonly used in scientific plotting. This means that some of the more -esoteric features of Matplotlib may not be available in Anyplotlib. In general we try to match the lower level -`collections` API of Matplotlib. + core set of features that are most commonly used in scientific plotting. This means that some of the more + esoteric features of Matplotlib may not be available in Anyplotlib. In general we try to match the lower level + ``collections`` API of Matplotlib. 4. Each collection, plot, image is rendered as a single object on the canvas. This is highly performant and more -importantly allows for blitting. This is one of the main reasons why the `ipympl` backend of Matplotlib is so slow. + importantly allows for blitting. This is one of the main reasons why the ``ipympl`` backend of Matplotlib is so slow. -5. Finally `anyplotlib` uses `AnyWidget` as the underlying widget framework. This means that it can be used in any -environment that supports `AnyWidget`, including Jupyter notebooks, JupyterLab, and PyCharm notebook preview. Under -the hood, `AnyWidget` uses a pure-JavaScript implementation of the widget protocol, which allows for fast rendering -and interactivity. +5. Finally ``anyplotlib`` uses ``AnyWidget`` as the underlying widget framework. This means that it can be used in any + environment that supports ``AnyWidget``, including Jupyter notebooks, JupyterLab, and PyCharm notebook preview. Under + the hood, ``AnyWidget`` uses a pure-JavaScript implementation of the widget protocol, which allows for fast rendering + and interactivity. **Disclaimer**: This project is in the early stages of development. Additionally many of the -javascript code was optimized using LLM's. That being said, the javascript/python code is fairly minimal, +JavaScript code was optimised using LLMs. That being said, the JavaScript/Python code is fairly minimal and not too difficult to understand. - -**Disclaimer #2**: Mostly this project is to see __if__ something like this is possible, it remains to be -seen if this can be developed into a full-fledged plotting library. The hope is that this can be. +**Disclaimer #2**: Mostly this project is to see *if* something like this is possible; it remains to be +seen if this can be developed into a full-fledged plotting library. The hope is that it can. * :ref:`genindex` * :ref:`modindex` From b69ffdf9a333a15ba747a6bae5b40460b734c8c0 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 16 Mar 2026 16:44:48 -0500 Subject: [PATCH 018/198] Add SVG logo for anyplotlib and update documentation configuration --- docs/_static/anyplotlib.svg | 48 +++++++++++++++++++++++++++++++++++++ docs/conf.py | 2 ++ 2 files changed, 50 insertions(+) create mode 100644 docs/_static/anyplotlib.svg diff --git a/docs/_static/anyplotlib.svg b/docs/_static/anyplotlib.svg new file mode 100644 index 00000000..8101881a --- /dev/null +++ b/docs/_static/anyplotlib.svg @@ -0,0 +1,48 @@ + + + + + + + + + + diff --git a/docs/conf.py b/docs/conf.py index b763c8bc..110464cc 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -78,6 +78,8 @@ html_theme_options = { "github_url": "https://github.com/", "logo": { + "image_light": "_static/anyplotlib.svg", + "image_dark": "_static/anyplotlib.svg", "text": "anyplotlib", }, "navbar_end": ["navbar-icon-links"], From 079ce2846ffbf7ff7e11874b434e67cbc9fe55ab Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 16 Mar 2026 18:05:12 -0500 Subject: [PATCH 019/198] Enhance documentation build process and add version switcher for improved navigation --- .github/workflows/docs.yml | 29 ++++++++++++++++++++++------- docs/_root/index.html | 16 ++++++++++++++++ docs/_root/switcher.json | 8 ++++++++ docs/conf.py | 14 ++++++++++++-- 4 files changed, 58 insertions(+), 9 deletions(-) create mode 100644 docs/_root/index.html create mode 100644 docs/_root/switcher.json diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 7743dcb3..fd6f7280 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -45,13 +45,6 @@ jobs: - name: Install dependencies (with docs extras) run: uv sync --extra docs - # ── Sphinx build ─────────────────────────────────────────────────────── - # -W turns warnings into errors so broken cross-references are caught. - # Remove -W if the gallery examples produce unavoidable warnings. - - name: Build HTML documentation - run: | - uv run sphinx-build -b html docs build/html -W --keep-going - # ── Determine deployment target ───────────────────────────────────────── # Release tag (refs/tags/v1.2.3) → destination = "v1.2.3" # Everything else (push to main, manual dispatch) → destination = "dev" @@ -65,6 +58,15 @@ jobs: echo "dest_dir=dev" >> "$GITHUB_OUTPUT" fi + # ── Sphinx build ─────────────────────────────────────────────────────── + # -W turns warnings into errors so broken cross-references are caught. + # Remove -W if the gallery examples produce unavoidable warnings. + - name: Build HTML documentation + env: + DOCS_VERSION: ${{ steps.target.outputs.dest_dir }} + run: | + uv run sphinx-build -b html docs build/html -W --keep-going + # ── Deploy to gh-pages ──────────────────────────────────────────────── # keep_files: true preserves all existing directories on the branch so # versioned releases accumulate rather than overwriting each other. @@ -80,3 +82,16 @@ jobs: commit_message: | docs: deploy ${{ steps.target.outputs.dest_dir }} @ ${{ github.sha }} + # ── Deploy root files (redirect + switcher) ──────────────────────────── + # Places index.html and switcher.json at the root of gh-pages so the + # bare URL redirects to dev/ and the version switcher is always reachable. + - name: Deploy root redirect and switcher + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./docs/_root + destination_dir: . + keep_files: true + commit_message: | + docs: update root redirect and switcher.json @ ${{ github.sha }} + diff --git a/docs/_root/index.html b/docs/_root/index.html new file mode 100644 index 00000000..c6277376 --- /dev/null +++ b/docs/_root/index.html @@ -0,0 +1,16 @@ + + + + + anyplotlib – redirecting… + + + + + +

+ Redirecting to dev documentation… +

+ + + diff --git a/docs/_root/switcher.json b/docs/_root/switcher.json new file mode 100644 index 00000000..9fb40e0d --- /dev/null +++ b/docs/_root/switcher.json @@ -0,0 +1,8 @@ +[ + { + "name": "dev (latest)", + "version": "dev", + "url": "https://cssfrancis.github.io/anyplotlib/dev/" + }, +] + diff --git a/docs/conf.py b/docs/conf.py index 110464cc..acd99ed3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -19,6 +19,12 @@ author = "anyplotlib contributors" release = "0.1.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. +_docs_version = os.environ.get("DOCS_VERSION", "dev") +_base = "https://cssfrancis.github.io/anyplotlib/" +html_baseurl = f"{_base}{_docs_version}/" + # -- General configuration --------------------------------------------------- extensions = [ "sphinx.ext.autodoc", @@ -76,13 +82,17 @@ html_static_path = ["_static"] html_theme_options = { - "github_url": "https://github.com/", + "github_url": "https://github.com/CSSFrancis/anyplotlib", "logo": { "image_light": "_static/anyplotlib.svg", "image_dark": "_static/anyplotlib.svg", "text": "anyplotlib", }, - "navbar_end": ["navbar-icon-links"], + "switcher": { + "json_url": f"{_base}switcher.json", + "version_match": _docs_version, + }, + "navbar_end": ["version-switcher", "navbar-icon-links"], "show_toc_level": 2, } From 1eabcbbb46d93cde3c83452213e60d8c43250c4c Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Tue, 17 Mar 2026 11:01:02 -0500 Subject: [PATCH 020/198] Refactor layout handling for 2D panels to ensure canvas size matches grid cell allocation, removing aspect-locking logic. Update tests to verify correct canvas dimensions for various image aspect ratios and configurations. Add documentation for new layout behavior and introduce a navigator for `figure_esm.js`. --- AGENTS.md | 3 +- Examples/plot_image2d.py | 85 +++-- anyplotlib/FIGURE_ESM.md | 368 ++++++++++++++++++++++ anyplotlib/figure.py | 33 +- anyplotlib/figure_esm.js | 392 ++++++++++++------------ anyplotlib/figure_plots.py | 142 +++++++-- tests/baselines/imshow_checkerboard.png | Bin 6354 -> 6291 bytes tests/baselines/imshow_gradient.png | Bin 5614 -> 5613 bytes tests/baselines/imshow_viridis.png | Bin 12932 -> 13135 bytes tests/baselines/pcolormesh_uniform.png | Bin 12288 -> 12490 bytes tests/baselines/subplots_2x1.png | Bin 12058 -> 13852 bytes tests/test_gridspec.py | 126 ++++---- tests/test_panel_alignment.py | 25 +- 13 files changed, 812 insertions(+), 362 deletions(-) create mode 100644 anyplotlib/FIGURE_ESM.md diff --git a/AGENTS.md b/AGENTS.md index 450882ba..489e2a68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,7 +68,8 @@ make clean # wipe build artefacts |------|---------| | `anyplotlib/figure.py` | `Figure` widget; layout engine; JS↔Python dispatch | | `anyplotlib/figure_plots.py` | All plot classes, `Axes`, `GridSpec`, `subplots()` | -| `anyplotlib/figure_esm.js` | All JS canvas rendering | +| `anyplotlib/figure_esm.js` | All JS canvas rendering (2 805 lines — see `anyplotlib/FIGURE_ESM.md` for a full section map) | +| `anyplotlib/FIGURE_ESM.md` | Line-numbered navigator for `figure_esm.js` — read this before editing the JS | | `anyplotlib/markers.py` | Static marker collections; `to_wire()` translation | | `anyplotlib/widgets.py` | Interactive overlay widgets | | `anyplotlib/callbacks.py` | `CallbackRegistry`, `Event` dataclass | diff --git a/Examples/plot_image2d.py b/Examples/plot_image2d.py index 22047da0..46d06a99 100644 --- a/Examples/plot_image2d.py +++ b/Examples/plot_image2d.py @@ -1,17 +1,23 @@ """ -2D Image -======== - -Display a 2-D image with physical axes using -:meth:`~anyplotlib.figure_plots.Axes.imshow`. -The image is a synthetic STEM-like diffraction pattern with a physical -length scale in nanometres. Circle markers highlight the first-order -diffraction spots, and an annular integration widget is placed over the -central beam. Pan and zoom with the mouse; press **R** to reset the view, -**H** to toggle the histogram, **L** / **S** to cycle colour-scale modes. +2D Image with Histogram +======================= + +Display a 2-D image with physical axes, a colourmap, and an interactive +histogram below — all wired together with draggable threshold widgets. + +Layout +------ +A :class:`~anyplotlib.figure_plots.GridSpec` with two rows puts the image +on top and a bar-chart histogram below. Two +:class:`~anyplotlib.widgets.VLineWidget` handles on the histogram mark the +``display_min`` / ``display_max`` thresholds; dragging them updates the +image colour scale in real time. + +Key bindings on the image panel: **R** reset view · **C** toggle colorbar · +**L** / **S** cycle colour-scale modes. """ import numpy as np -import anyplotlib as vw +import anyplotlib as apl rng = np.random.default_rng(1) @@ -35,13 +41,21 @@ def _ring(r, r0, width, amp): + rng.normal(scale=0.04, size=(N, N)) ) -# ── Plot ────────────────────────────────────────────────────────────────────── -fig, ax = vw.subplots(1, 1, figsize=(500, 500)) -v = ax.imshow(image, axes=[x, y], units="nm") +# ── Layout: image (top, 3×) + histogram bar chart (bottom, 1×) ──────────────── +gs = apl.GridSpec(2, 1, height_ratios=[3, 1]) +fig = apl.Figure(figsize=(500, 640)) +ax_img = fig.add_subplot(gs[0, 0]) +ax_hist = fig.add_subplot(gs[1, 0]) + +# ── Image panel ─────────────────────────────────────────────────────────────── +v = ax_img.imshow(image, axes=[x, y], units="nm") v.set_colormap("inferno") -# ── First-order spot markers ────────────────────────────────────────────────── -# imshow axes are centre arrays: pixel = (phys - x[0]) / (x[1] - x[0]) +vmin_init = float(image.min()) +vmax_init = float(image.max()) +v.set_clim(vmin=vmin_init, vmax=vmax_init) + +# First-order spot markers dx = x[1] - x[0] @@ -57,22 +71,39 @@ def phys_to_px(val): edgecolors="#00e5ff", facecolors="#00e5ff22", labels=["g1", "g1_bar", "g2", "g2_bar"]) -# ── Annular integration widget ──────────────────────────────────────────────── -cx = cy = float(phys_to_px(0.0)) -v.add_widget("annular", color="#ffcc00", - cx=cx, cy=cy, - r_outer=float(phys_to_px(2.8) - phys_to_px(0.0)), - r_inner=float(phys_to_px(1.2) - phys_to_px(0.0))) +# ── Histogram bar chart ──────────────────────────────────────────────────────── +counts, edges = np.histogram(image.ravel(), bins=64) +bin_centers = 0.5 * (edges[:-1] + edges[1:]) + +h = ax_hist.bar(counts, x_centers=bin_centers, orient="v", + color="#4fc3f7", y_units="count") + +# ── Draggable threshold handles on the histogram ────────────────────────────── +wlo = h.add_vline_widget(vmin_init, color="#ff6e40") # low-threshold handle +whi = h.add_vline_widget(vmax_init, color="#ffffff") # high-threshold handle + + +@wlo.on_release +def _apply_low(event): + """Update image display_min when the low handle is released.""" + v.set_clim(vmin=event.x) + + +@whi.on_release +def _apply_high(event): + """Update image display_max when the high handle is released.""" + v.set_clim(vmax=event.x) + fig # %% -# Adjust display range and colour map -# ------------------------------------ -# :meth:`~anyplotlib.figure_plots.Plot2D.set_clim` clips the colour scale; -# :meth:`~anyplotlib.figure_plots.Plot2D.set_colormap` switches the palette. +# Adjust colour map +# ------------------ +# :meth:`~anyplotlib.figure_plots.Plot2D.set_colormap` switches the palette; +# :meth:`~anyplotlib.figure_plots.Plot2D.set_clim` adjusts the display range. -v.set_clim(vmin=0.0, vmax=0.8) v.set_colormap("viridis") +v.set_clim(vmin=0.0, vmax=0.8) fig diff --git a/anyplotlib/FIGURE_ESM.md b/anyplotlib/FIGURE_ESM.md new file mode 100644 index 00000000..61aec775 --- /dev/null +++ b/anyplotlib/FIGURE_ESM.md @@ -0,0 +1,368 @@ +# FIGURE_ESM.md — Navigator for `figure_esm.js` + +`figure_esm.js` is **~2 810 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 line-numbered map +so you can jump straight to the relevant section without reading the whole file. + +--- + +## Sizing contract + +``` +Rule 1 – Grid tracks are always pure ratio math. + col_px[i] = fig_width × width_ratios[i] / Σ width_ratios + row_px[r] = fig_height × height_ratios[r] / Σ height_ratios + No exceptions. No 2-D special-casing. Both Python + (_compute_cell_sizes) and JS (_applyFigResizeDOM) follow this rule. + +Rule 2 – All panels in the same grid column have the same canvas width. + All panels in the same grid row have the same canvas height. + (Follows automatically from Rule 1.) + +Rule 3 – Images are displayed "contain" (letterbox / pillarbox). + _imgFitRect(iw, ih, cw, ch) → largest rect of aspect iw:ih + that fits inside cw×ch, centred. + A 256×256 image in an 800×333 canvas occupies a 333×333 fit-rect + starting at x=233.5; bgCanvas colour shows on either side. + +Rule 4 – Zoom is relative to the fit-rect. + zoom=1 → fit-rect exactly filled by the whole image. + zoom=Z → a 1/Z portion of the image fills the fit-rect. + The fit-rect position never changes; only which part of the + image is drawn inside it changes with zoom/pan. +``` + +--- + +## Quick-reference: line ranges + +| Section | Lines | +|---------|-------| +| Shared constants (`PAD_*`) | 9–14 | +| Theme (dark/light detection) | 15–51 | +| Shared math helpers | 53–84 | +| Outer DOM setup + tooltip | 85–131 | +| Per-panel state map + guards | 128–131 | +| **Layout engine** | 132–458 | +| **2D drawing** | 459–956 | +| **3D drawing** | 957–1159 | +| Event-emission helper | 1160–1171 | +| 3D event handlers | 1172–1238 | +| **1D drawing** | 1239–1497 | +| Marker hit-test helpers | 1498–1617 | +| Panel-level event dispatch | 1618–1635 | +| `_canvasToImg2d` | 1626–1635 | +| 2D event handlers | 1636–1805 | +| 1D event handlers | 1806–1909 | +| 2D overlay widget hit-test & drag | 1910–2077 | +| 1D overlay widget drag | 2078–2131 | +| Shared-axis propagation | 2132–2172 | +| Figure-level resize | 2173–2342 | +| **Bar chart drawing + events** | 2343–2697 | +| Generic redraw + RedrawAll | 2698–2710 | +| ResizeObserver (cell-fit) | 2711–2801 | +| Model listeners + initial render | 2802–2812 | + +--- + +## Section-by-section detail + +### Shared closure constants (lines 9–14) +``` +PAD_L=58 PAD_R=12 PAD_T=12 PAD_B=42 +``` +All panel kinds use the **same** padding so axes align across rows/columns. +The inner plot rectangle is `[PAD_L, PAD_T] → [pw-PAD_R, ph-PAD_B]`. + +--- + +### Theme (lines 15–51) +- **`_isDarkBg(node)`** — walks the DOM tree to detect a dark background. +- **`_makeTheme(dark)`** — returns a theme object with keys: + `bg`, `bgPlot`, `bgCanvas`, `border`, `axisBg`, `axisStroke`, + `gridStroke`, `tickStroke`, `tickText`, `unitText`, `dark`. +- `theme` is a module-level `let`; refreshed on OS media-query changes and + JupyterLab / VS Code theme mutations via `MutationObserver`. + +--- + +### Shared math helpers (lines 53–84) +| Function | Purpose | +|----------|---------| +| `findNice(t)` | Round a range to a clean tick interval (1/2/2.5/5/10 × 10ⁿ) | +| `fmtVal(v)` | Format an axis number (0, exponential, fixed, etc.) | +| `_axisValToFrac(arr, val)` | Data value → [0,1] fraction along an axis array (binary-search) | +| `_axisFracToVal(arr, frac)` | Inverse of the above | + +--- + +### Outer DOM (lines 85–131) +``` +outerDiv position:relative wrapper around everything + gridDiv CSS grid; columns/rows set by applyLayout() + resizeHandle bottom-right drag handle (figure-level resize) + tooltip div shared hover tooltip (created lazily by _showTooltip) +``` +- **`_showTooltip(text, cx, cy)`** — shows/hides a fixed tooltip. +- **`panels`** — `Map` storing every live panel object. +- **`_suppressLayoutUpdate`** — boolean guard preventing re-entry during resize. + +--- + +### Layout engine (lines 132–458) + +#### `applyLayout()` (line 133) +Reads `layout_json`. Builds CSS grid tracks from `panel_specs[].panel_width/height`. +Creates panels that don't exist yet, resizes existing ones, removes stale ones. + +#### `_createPanelDOM(id, kind, pw, ph, spec)` (line 171) +Builds all canvas/DOM elements for one panel, stores the **`p` object** in +`panels`, subscribes to `change:panel_{id}_json`, runs the initial draw. + +**DOM structure by kind:** +| kind | elements | +|------|----------| +| `'2d'` | `plotWrap > plotCanvas + overlayCanvas + markersCanvas + yAxisCanvas + xAxisCanvas + cbCanvas + scaleBar + statusBar` | +| `'3d'` | `wrap3 > plotCanvas + overlayCanvas + markersCanvas + statusBar` | +| `'1d'` / `'bar'` | `wrap > plotCanvas + overlayCanvas + markersCanvas + statusBar` | + +#### `_resizePanelDOM(id, pw, ph)` (line 342) +Updates `canvas.width / canvas.height` (DPR-scaled) for every canvas in the panel. + +#### The `p` (panel) object — all fields +```js +p.id, p.kind, p.pw, p.ph +p.state // parsed JSON from panel_{id}_json (full plot state dict) +p.plotCanvas, p.plotCtx // main image / plot canvas +p.overlayCanvas, p.ovCtx // interactive overlay widgets +p.markersCanvas, p.mkCtx // static markers (pointer-events:none) +p.xAxisCanvas, p.yAxisCanvas, p.xCtx, p.yCtx // 2D only +p.cbCanvas, p.cbCtx // 2D only — colorbar +p.scaleBar // 2D only — scale-bar canvas +p.statusBar // coordinate readout div +p.plotWrap // 2D only — positioned wrapper div +p.blitCache // { bitmap, bytesKey, lutKey, w, h } — ImageBitmap cache +p.ovDrag // active 1D/bar widget drag state or null +p.ovDrag2d // active 2D widget drag state or null +p.isPanning // bool +p._hoverSi, p._hoverI, p._hovBar +``` + +--- + +### 2D drawing (lines 459–956) + +All 2D state lives in `p.state`. Key fields: +``` +st.image_b64 base-64-encoded Uint8 raw pixel bytes +st.image_width/height +st.zoom 1.0 = fit-rect filled; Z > 1 = zoomed in Z× +st.center_x/y normalised (0–1) image centre of the viewed region +st.display_min/max clamp range for LUT +st.raw_min/max histogram-stretch range +st.scale_mode 'linear' | 'log' | 'symlog' +st.colormap_data [[r,g,b], ...] × 256 +st.show_axes, st.x_axis, st.y_axis +st.scale_bar, st.colorbar +st.markers, st.overlay_widgets +st.share_axes +``` + +| Function | Lines | Purpose | +|----------|-------|---------| +| **`_imgFitRect(iw,ih,cw,ch)`** | **465** | **Returns `{x,y,w,h,s}` — the largest rect of aspect `iw:ih` centred in `cw×ch`. `s` = canvas-px per image-px. All 2-D coordinate functions derive from this.** | +| `_buildLut32(st)` | 471 | Build 256-entry `Uint32Array` LUT from colormap + scale mode | +| `_lutKey(st)` | 499 | String cache key (invalidates when colormap/range changes) | +| `_imgToCanvas2d(ix,iy,st,pw,ph)` | 503 | Image pixel → canvas pixel using `_imgFitRect` + zoom/pan | +| `_imgScale2d(st,pw,ph)` | 514 | Returns `_imgFitRect(…).s * zoom` — canvas-px per image-px at current zoom | +| `_blit2d(bitmap,st,pw,ph,ctx)` | 518 | **Contain render**: clears canvas to `bgCanvas`, draws image inside fit-rect. zoom≥1 → crops + fills fit-rect; zoom<1 → shrinks fit-rect proportionally | +| `draw2d(p)` | 540 | Main 2D render: decode bytes → LUT → ImageBitmap → `_blit2d`; then axes, scale bar, colorbar, overlay, markers | +| `drawScaleBar2d(p)` | 587 | Physical scale bar using `fr.w` (fit-rect width) for pixel sizing | +| `drawColorbar2d(p)` | 663 | Colorbar strip with gradient + tick labels | +| `_drawAxes2d(p)` | 705 | Tick marks + labels on `xAxisCanvas` / `yAxisCanvas` | +| `drawOverlay2d(p)` | 805 | Draw `overlay_widgets` onto `overlayCanvas` | +| `_drawHandle2d(ctx,x,y,color)` | 855 | Single drag-handle square | +| `drawMarkers2d(p,hoverState)` | 860 | Render all marker groups onto `markersCanvas` | + +#### Zoom model (Rule 4 in detail) +`_imgFitRect(iw, ih, cw, ch)` is computed once per draw and shared by all +geometry functions. At `zoom=1` the entire image fills the fit-rect exactly. +At `zoom=Z>1` a `iw/Z × ih/Z` pixel region of the image fills the fit-rect. + +**`_blit2d`** (line 518): +``` +zoom ≥ 1 → ctx.drawImage(bitmap, srcX, srcY, visW, visH, fr.x, fr.y, fr.w, fr.h) +zoom < 1 → ctx.drawImage(bitmap, 0, 0, iw, ih, centredShrink...) +``` +Background (`bgCanvas` colour) is always visible outside the fit-rect, +so letterboxing/pillarboxing is correct at all zoom levels. + +--- + +### 3D drawing (lines 957–1159) +Orthographic projection. State fields: `st.vertices`, `st.faces`, +`st.face_values`, `st.colormap_data`, `st.azimuth`, `st.elevation`, `st.scale`. + +| Function | Lines | Purpose | +|----------|-------|---------| +| `_rot3(az,el)` | 959 | 3×3 rotation matrix | +| `_applyRot(R,v)` | 972 | Rotate a 3-vector | +| `_project3(rv,cx,cy,scale)` | 980 | Orthographic 3D→2D | +| `_colourFromLut(lut,t)` | 985 | `t∈[0,1]` → `'#rrggbb'` | +| `draw3d(p)` | 993 | Sort triangles, fill + stroke, draw axis labels | + +--- + +### Event emission helper (lines 1160–1171) +```js +_emitEvent(panelId, eventType, widgetId, extraData) +``` +Writes to `model.event_json` + `save_changes()`. +`eventType`: `'on_changed'` | `'on_release'` | `'on_click'`. + +--- + +### 3D event handlers (lines 1172–1238) +- **`_attachEvents3d(p)`** — drag → azimuth/elevation; scroll → zoom. + +--- + +### 1D drawing (lines 1239–1497) + +| Function | Lines | Purpose | +|----------|-------|---------| +| `_plotRect1d(pw,ph)` | 1241 | `{x,y,w,h}` of the plot rectangle | +| `_xToFrac1d` / `_fracToX1d` | 1243/1252 | x data ↔ [0,1] fraction | +| `_fracToPx1d`, `_valToPy1d` | 1257/1258 | fraction/value → canvas px | +| `draw1d(p)` | 1260 | Main 1D render: series, axes, ticks, grid | +| `drawOverlay1d(p)` | 1383 | Overlay widgets on `overlayCanvas` | +| `drawMarkers1d(p,hoverState)` | 1424 | Marker groups for 1D panels | + +--- + +### Marker hit-test helpers (lines 1498–1617) +| Function | Lines | Purpose | +|----------|-------|---------| +| `_markerHitTest2d(mx,my,st,pw,ph)` | 1503 | → `{si,i}` hit marker for 2D; uses `_imgFitRect` via `_imgToCanvas2d` | +| `_markerHitTest1d(mx,my,p)` | 1571 | → `{si,i}` hit marker for 1D | + +--- + +### Panel-level event handlers (lines 1618–1805) +- **`_attachPanelEvents(p)`** (line 1619) — dispatches to kind-specific attach fn. +- **`_canvasToImg2d(px,py,st,pw,ph)`** (line 1626) — inverts `_imgToCanvas2d`; + derives fit-rect geometry from `_imgFitRect` so both directions are consistent. +- **`_attachEvents2d(p)`** (line 1636) — 2D mouse events: + - **Wheel zoom** — calls `_canvasToImg2d` to find the image-space anchor + before zoom; recomputes `center_x/y` via `_imgFitRect` so the same image + point stays under the cursor after the zoom change. + - **Pan** — drag delta divided by `fr.w/fr.h` (fit-rect dimensions, not + canvas dimensions) so pan speed is proportional to the visible image region. + - Widget drag, marker hover, status bar, keyboard shortcuts (r/c/l/s). +- **`_attachEvents1d(p)`** (line 1806) — scroll zoom on `view_x0/x1`, pan, + widget drag, marker hover, status bar. + +--- + +### 2D / 1D Overlay Widget Drag (lines 1910–2131) +| Function | Lines | Purpose | +|----------|-------|---------| +| `_ovHitTest2d(mx,my,p)` | 1921 | Find 2D widget under cursor; scale via `_imgScale2d(st,imgW,imgH)` | +| `_pointInPolygon2d(…)` | 1992 | Polygon hit-test in image space | +| `_doDrag2d(e,p)` | 2002 | Drag 2D widgets; uses `_canvasToImg2d` for coordinates | +| `_canvasXToFrac1d(px,x0,x1,r)` | 2078 | Canvas x → axis fraction | +| `_ovHitTest1d(mx,my,p)` | 2080 | Find 1D widget under cursor | +| `_doDrag1d(e,p)` | 2107 | Drag 1D widgets | + +--- + +### Shared-axis propagation (lines 2132–2172) +- **`_getShareGroups()`** — groups panels by `st.share_axes`. +- **`_propagateZoom2d(srcPanel)`** — copies `zoom/center_x/center_y` to share group. +- **`_propagateView1d(srcPanel)`** — copies `x0/x1/y_min/y_max` to share group. + +--- + +### Figure-level resize (lines 2173–2342) +- **`_applyFigResizeDOM(nfw,nfh)`** (line 2193) — pure proportional scaling of + all panel tracks (`col_px = nfw * ratio / sum`); **no aspect-lock loop**. + CSS-only resize (no canvas buffer clear) for smooth live drag. +- **`_resizePanelCSS(id,pw,ph)`** (line 2235) — updates CSS + element positions. +- **`_applyFigResize(nfw,nfh)`** (line 2286) — full resize: DOM + pixel buffers + redraw. + +--- + +### Bar chart (lines 2343–2697) + +State fields: +``` +st.values, st.x_centers, st.x_labels +st.bar_color, st.bar_colors, st.bar_width +st.orient 'v' (default) | 'h' +st.baseline value axis zero line +st.data_min/max current visible value-axis range — modified by zoom/pan +st.x_axis, st.view_x0/x1 widget coordinate system (category axis) +st.overlay_widgets +``` + +| Function | Lines | Purpose | +|----------|-------|---------| +| `_barGeom(st,r)` | 2347 | Per-bar geometry: slot/bar px, `xToPx`/`yToPx`, baseline px | +| `drawBar(p)` | 2376 | **Main bar render**: grid, bars (clipped), value labels, axis, ticks; calls `drawOverlay1d` | +| `_attachEventsBar(p)` | 2581 | **Full interaction**: wheel zoom on `data_min/max`, left-drag pan on value axis, widget drag via `_ovHitTest1d`/`_doDrag1d`, 'r' reset, per-widget cursors, status bar, bar hover + tooltip, `on_click` | + +#### Bar zoom/pan model +Unlike 1D (which zooms `view_x0/x1`), bar zooms and pans the **value axis** by +modifying `st.data_min`/`st.data_max` directly. `view_x0/x1` stays fixed at +0/1 so overlay widgets (vlines, hlines) keep correct positions throughout. +`origDataMin/Max` are captured on first interaction (JS closure) for 'r' reset. + +--- + +### Generic redraw (lines 2698–2710) +```js +_redrawPanel(p) → draw2d / draw3d / drawBar / draw1d based on p.kind +redrawAll() → _redrawPanel for every panel +``` + +--- + +### ResizeObserver — cell-fit (lines 2711–2801) +Watches `el`. If cell is narrower than the widget, scales figure down, +persists into `layout_json`. 150 ms debounce; `_roActive` re-entry guard. + +--- + +### Model listeners + initial render (lines 2802–2812) +```js +model.on('change:layout_json') → applyLayout() + redrawAll() +model.on('change:fig_width/height') → applyLayout() + redrawAll() +model.on('change:event_json') → Python→JS widget position push + (only when msg.source === 'python') +applyLayout() ← initial call +``` + +--- + +## Key data flows + +``` +Python push: + plot._push() → figure._push(id) → panel_{id}_json trait changes + → model.on('change:panel_{id}_json') → p.state = JSON.parse(...) + → _redrawPanel(p) + +JS → Python (widget drag): + _doDrag2d / _doDrag1d → updates p.state.overlay_widgets in-place + → _emitEvent(id, 'on_changed', widgetId, {…}) + → model.set('event_json', …) + save_changes() + → Python Figure._on_event() → CallbackRegistry.fire() + +JS → Python (3D rotate / zoom): + _attachEvents3d → model.set('panel_{id}_json', …) + save_changes() + +Python → JS (set widget position from Python): + figure.move_widget(…) → event_json with source:'python' + → model.on('change:event_json') patches overlay_widgets + redraws +``` diff --git a/anyplotlib/figure.py b/anyplotlib/figure.py index 478962b0..eebc588a 100644 --- a/anyplotlib/figure.py +++ b/anyplotlib/figure.py @@ -95,38 +95,13 @@ def _compute_cell_sizes(self) -> dict: wr, hr = self._width_ratios, self._height_ratios wsum, hsum = sum(wr), sum(hr) - # Step 1: raw pixel size per grid track (float precision) + # Grid tracks are pure ratio math — no aspect-locking. + # Rule: col_px[i] = fw * width_ratios[i] / Σ width_ratios (and analogous + # for rows). Every panel gets exactly the canvas size its cell specifies; + # images are rendered "contain" (letterboxed) in JS if needed. col_px = [fw * w / wsum for w in wr] row_px = [fh * h / hsum for h in hr] - # Step 2: aspect-lock every 2D panel by shrinking its track(s). - # Multiple passes let interactions between panels converge. - for _ in range(4): - for pid, ax in self._axes_map.items(): - plot = self._plots_map.get(pid) - if not isinstance(plot, (Plot2D, PlotMesh)): - continue - s = ax._spec - cw = sum(col_px[s.col_start:s.col_stop]) - ch = sum(row_px[s.row_start:s.row_stop]) - iw = plot._state.get("image_width", 1) - ih = plot._state.get("image_height", 1) - if iw <= 0 or ih <= 0 or ch == 0: - continue - ar = iw / ih - if cw / ch > ar: # wider than image -> shrink cols - new_cw = ch * ar - span = max(1, s.col_stop - s.col_start) - for c in range(s.col_start, s.col_stop): - col_px[c] = new_cw / span - else: # taller than image -> shrink rows - new_ch = cw / ar - span = max(1, s.row_stop - s.row_start) - for r in range(s.row_start, s.row_stop): - row_px[r] = new_ch / span - - # Step 3: every panel gets the pixel size of its track span. - # All panels sharing a row/col automatically have identical dimensions. sizes: dict = {} for pid, ax in self._axes_map.items(): s = ax._spec diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 8ffe22eb..e0e4a563 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -226,22 +226,21 @@ function render({ model, el }) { xAxisCanvas = document.createElement('canvas'); xAxisCanvas.style.cssText = `position:absolute;display:none;background:${theme.axisBg};`; - // Histogram canvas: to the right of the image area, same height - const histCanvas = document.createElement('canvas'); - histCanvas.style.cssText = 'position:absolute;display:none;cursor:ns-resize;'; - const histWidth = 80; + // Colorbar canvas: narrow strip (16 px) to the right of the image area + const cbCanvas = document.createElement('canvas'); + cbCanvas.style.cssText = 'position:absolute;display:none;pointer-events:none;border-radius:0 2px 2px 0;'; plotWrap.appendChild(plotCanvas); plotWrap.appendChild(overlayCanvas); plotWrap.appendChild(markersCanvas); plotWrap.appendChild(yAxisCanvas); plotWrap.appendChild(xAxisCanvas); - plotWrap.appendChild(histCanvas); + plotWrap.appendChild(cbCanvas); plotWrap.appendChild(statusBar); cell.appendChild(plotWrap); - const histCtx = histCanvas.getContext('2d'); - _p2d = { histCanvas, histCtx, histWidth, sbLine, sbLabel, plotWrap }; + const cbCtx = cbCanvas.getContext('2d'); + _p2d = { cbCanvas, cbCtx, plotWrap }; } else if (kind === '3d') { // ── 3D branch: one full-panel plotCanvas + overlayCanvas on top ─────── @@ -313,12 +312,11 @@ function render({ model, el }) { state: null, _hoverSi: -1, _hoverI: -1, // index of hovered marker group / marker (-1 = none) _hovBar: -1, // index of hovered bar (-1 = none) - // 2D extras (null for 1D panels) - histCanvas: _p2d ? _p2d.histCanvas : null, - histCtx: _p2d ? _p2d.histCtx : null, - histWidth: _p2d ? _p2d.histWidth : 0, - sbLine: _p2d ? _p2d.sbLine : null, - sbLabel: _p2d ? _p2d.sbLabel : null, + // 2D extras (null for non-2D panels) + cbCanvas: _p2d ? _p2d.cbCanvas : null, + cbCtx: _p2d ? _p2d.cbCtx : null, + sbLine: null, + sbLabel: null, plotWrap: _p2d ? _p2d.plotWrap : null, }; panels.set(id, p); @@ -432,17 +430,17 @@ function render({ model, el }) { } } - // Histogram: right of image area - if (p.histCanvas && p.histCtx) { - const hw = p.histWidth || 80; - const vis = st && st.histogram_visible; + // Colorbar: narrow strip to the right of the image area + if (p.cbCanvas && p.cbCtx) { + const cbW = 16; + const vis = st && st.show_colorbar; if (vis) { - p.histCanvas.style.display = 'block'; - p.histCanvas.style.left = (imgX + imgW) + 'px'; - p.histCanvas.style.top = imgY + 'px'; - _sz(p.histCanvas, p.histCtx, hw, imgH); + p.cbCanvas.style.display = 'block'; + p.cbCanvas.style.left = (imgX + imgW + 2) + 'px'; + p.cbCanvas.style.top = imgY + 'px'; + _sz(p.cbCanvas, p.cbCtx, cbW, imgH); } else { - p.histCanvas.style.display = 'none'; + p.cbCanvas.style.display = 'none'; } } @@ -459,9 +457,21 @@ function render({ model, el }) { } // ── 2D drawing ─────────────────────────────────────────────────────────── + + // Largest rect with the image's natural aspect that fits inside cw×ch, + // centred. All 2-D coordinate functions derive from this single rect so + // draw, hit-test, and coordinate conversion are always consistent. + // s = uniform scale factor (canvas px per image px). + function _imgFitRect(iw, ih, cw, ch) { + const s = Math.min(cw / iw, ch / ih); + const fw = iw * s, fh = ih * s; + return { x: (cw - fw) / 2, y: (ch - fh) / 2, w: fw, h: fh, s }; + } + function _buildLut32(st) { const dMin=st.display_min, dMax=st.display_max; - const hMin=st.hist_min, hMax=st.hist_max; + const hMin=st.raw_min!=null?st.raw_min:dMin; + const hMax=st.raw_max!=null?st.raw_max:dMax; const mode=st.scale_mode||'linear'; const range=hMax-hMin||1; const cmapData=st.colormap_data||[]; @@ -487,26 +497,44 @@ function render({ model, el }) { } function _lutKey(st) { - return [st.display_min,st.display_max,st.hist_min,st.hist_max,st.scale_mode,st.colormap_name].join('|'); + return [st.display_min,st.display_max,st.raw_min,st.raw_max,st.scale_mode,st.colormap_name].join('|'); } function _imgToCanvas2d(ix, iy, st, pw, ph) { - const zoom=st.zoom, cx=st.center_x, cy=st.center_y; - const iw=st.image_width, ih=st.image_height; - if(zoom>=1.0){ - const visW=iw/zoom, visH=ih/zoom; - const srcX=Math.max(0,Math.min(iw-visW,cx*iw-visW/2)); - const srcY=Math.max(0,Math.min(ih-visH,cy*ih-visH/2)); - return [(ix-srcX)/visW*pw, (iy-srcY)/visH*ph]; - } else { - const dstW=pw*zoom, dstH=ph*zoom; - return [(pw-dstW)/2+(ix/iw)*dstW, (ph-dstH)/2+(iy/ih)*dstH]; - } + const { x, y, w, h } = _imgFitRect(st.image_width, st.image_height, pw, ph); + const zoom = st.zoom, cx = st.center_x, cy = st.center_y; + const iw = st.image_width, ih = st.image_height; + const visW = iw / zoom, visH = ih / zoom; + const srcX = Math.max(0, Math.min(iw - visW, cx * iw - visW / 2)); + const srcY = Math.max(0, Math.min(ih - visH, cy * ih - visH / 2)); + return [x + (ix - srcX) / visW * w, y + (iy - srcY) / visH * h]; } - function _imgScale2d(st, pw) { - const zoom=st.zoom, iw=st.image_width; - return zoom>=1.0 ? pw/(iw/zoom) : pw*zoom/iw; + // Returns canvas-px per image-px at the current zoom (uniform in x and y). + function _imgScale2d(st, pw, ph) { + return _imgFitRect(st.image_width, st.image_height, pw, ph).s * st.zoom; + } + + function _blit2d(bitmap, st, pw, ph, ctx) { + const { x, y, w, h } = _imgFitRect(st.image_width, st.image_height, pw, ph); + const zoom = st.zoom, cx = st.center_x, cy = st.center_y; + const iw = st.image_width, ih = st.image_height; + ctx.clearRect(0, 0, pw, ph); + ctx.fillStyle = theme.bgCanvas; + ctx.fillRect(0, 0, pw, ph); + ctx.imageSmoothingEnabled = false; + if (zoom >= 1.0) { + // Zoomed in: show a portion of the image filling the fit-rect. + const visW = iw / zoom, visH = ih / zoom; + const srcX = Math.max(0, Math.min(iw - visW, cx * iw - visW / 2)); + const srcY = Math.max(0, Math.min(ih - visH, cy * ih - visH / 2)); + ctx.drawImage(bitmap, srcX, srcY, visW, visH, x, y, w, h); + } else { + // Zoomed out: shrink the fit-rect proportionally, keep it centred. + const dstW = w * zoom, dstH = h * zoom; + ctx.drawImage(bitmap, 0, 0, iw, ih, + x + (w - dstW) / 2, y + (h - dstH) / 2, dstW, dstH); + } } function draw2d(p) { @@ -548,10 +576,10 @@ function render({ model, el }) { blitCache.w=iw; blitCache.h=ih; _blit2d(oc, st, imgW, imgH, ctx); } - // Axes / scalebar + // Axes / scalebar / colorbar _drawAxes2d(p); drawScaleBar2d(p); - drawHistogram2d(p); + drawColorbar2d(p); drawOverlay2d(p); drawMarkers2d(p); } @@ -565,14 +593,16 @@ function render({ model, el }) { if(!scaleX||units==='px'){p.scaleBar.style.display='none';return;} const imgW=Math.max(1,p.pw-PAD_L-PAD_R); + const imgH=Math.max(1,p.ph-PAD_T-PAD_B); - // Compute bar width accounting for current zoom + // Compute bar width in the fit-rect pixel space const zoom=st.zoom||1; const iw=st.image_width||imgW; + const fr=_imgFitRect(iw, st.image_height||imgH, imgW, imgH); const visDataW=(zoom>=1?iw/zoom:iw)*scaleX; const targetDataWidth=visDataW*0.2; const niceWidth=findNice(targetDataWidth); - const barPx=Math.round((niceWidth/visDataW)*imgW); + const barPx=Math.round((niceWidth/visDataW)*fr.w); // use fit-rect width if(barPx<4){p.scaleBar.style.display='none';return;} // Layout constants (CSS pixels) @@ -630,97 +660,48 @@ function render({ model, el }) { sb.style.display='block'; } - function drawHistogram2d(p) { - const st=p.state; if(!st||!p.histCanvas||!p.histCtx) return; - const vis=st.histogram_visible||false; - p.histCanvas.style.display = vis ? 'block' : 'none'; + function drawColorbar2d(p) { + const st=p.state; if(!st||!p.cbCanvas||!p.cbCtx) return; + const vis=st.show_colorbar||false; + p.cbCanvas.style.display = vis ? 'block' : 'none'; if(!vis) return; - const hw=p.histWidth||80; - const ph=Math.max(1,p.ph-PAD_T-PAD_B); // histogram height = image area height - const hctx=p.histCtx; - hctx.clearRect(0,0,hw,ph); - hctx.fillStyle=theme.axisBg; hctx.fillRect(0,0,hw,ph); - - const histData=st.histogram_data||{bins:[],counts:[]}; - const bins=histData.bins||[], counts=histData.counts||[]; - if(!bins.length||!counts.length){return;} - - const hMin=st.hist_min||0, hMax=st.hist_max||1; - const maxC=Math.max(...counts)||1; - - // Draw histogram bars - const barAreaX=18, barAreaW=hw-barAreaX-2; - const dMin=st.display_min, dMax=st.display_max; - - function _dataToY(v){return ph-2-((v-hMin)/(hMax-hMin||1))*(ph-4);} - - // Bars - hctx.fillStyle=theme.dark?'rgba(150,150,200,0.55)':'rgba(80,80,160,0.55)'; - for(let i=0;i=1.0){ - const visW=iw/zoom, visH=ih/zoom; - const srcX=Math.max(0,Math.min(iw-visW,cx*iw-visW/2)); - const srcY=Math.max(0,Math.min(ih-visH,cy*ih-visH/2)); - ctx.drawImage(bitmap,srcX,srcY,visW,visH,0,0,pw,ph); - } else { - const dstW=pw*zoom, dstH=ph*zoom; - ctx.fillStyle=theme.bgCanvas; ctx.fillRect(0,0,pw,ph); - ctx.drawImage(bitmap,0,0,iw,ih,(pw-dstW)/2,(ph-dstH)/2,dstW,dstH); - } + // display_min / display_max tick marks + const dMin=st.display_min, dMax=st.display_max; + const hMin=st.raw_min!=null?st.raw_min:dMin; + const hMax=st.raw_max!=null?st.raw_max:dMax; + const vRange=(hMax-hMin)||1; + function _vToY(v){return imgH-1-((v-hMin)/vRange)*(imgH-1);} + ctx.strokeStyle='rgba(255,255,255,0.85)'; ctx.lineWidth=1.5; + ctx.beginPath();ctx.moveTo(0,_vToY(dMax));ctx.lineTo(cbW,_vToY(dMax));ctx.stroke(); + ctx.beginPath();ctx.moveTo(0,_vToY(dMin));ctx.lineTo(cbW,_vToY(dMin));ctx.stroke(); } + function _drawAxes2d(p) { const st=p.state; if(!st) return; const {pw,ph} = p; @@ -827,7 +808,7 @@ function render({ model, el }) { const imgW=Math.max(1,pw-PAD_L-PAD_R), imgH=Math.max(1,ph-PAD_T-PAD_B); ovCtx.clearRect(0,0,imgW,imgH); const widgets=st.overlay_widgets||[]; - const scale=_imgScale2d(st,imgW); + const scale=_imgScale2d(st,imgW,imgH); for(const w of widgets){ ovCtx.save(); ovCtx.strokeStyle=w.color||'#00e5ff'; ovCtx.lineWidth=2; if(w.type==='circle'){ @@ -883,7 +864,7 @@ function render({ model, el }) { mkCtx.clearRect(0,0,imgW,imgH); const sets=st.markers||[]; if(!sets.length) return; - const scale=_imgScale2d(st,imgW); + const scale=_imgScale2d(st,imgW,imgH); const hsi = hoverState ? hoverState.si : -1; for(let si=0;si= 0; si--) { const ms = sets[si]; const type = ms.type || 'circles'; @@ -1642,10 +1623,14 @@ function render({ model, el }) { else _attachEvents1d(p); } - function _canvasToImg2d(px,py,st,pw,ph){ - const zoom=st.zoom,cx=st.center_x,cy=st.center_y,iw=st.image_width,ih=st.image_height; - if(zoom>=1.0){const visW=iw/zoom,visH=ih/zoom;const srcX=Math.max(0,Math.min(iw-visW,cx*iw-visW/2));const srcY=Math.max(0,Math.min(ih-visH,cy*ih-visH/2));return[srcX+(px/pw)*visW,srcY+(py/ph)*visH];} - const dstW=pw*zoom,dstH=ph*zoom;return[((px-(pw-dstW)/2)/dstW)*iw,((py-(ph-dstH)/2)/dstH)*ih]; + function _canvasToImg2d(px, py, st, pw, ph) { + const { x, y, w, h } = _imgFitRect(st.image_width, st.image_height, pw, ph); + const zoom = st.zoom, cx = st.center_x, cy = st.center_y; + const iw = st.image_width, ih = st.image_height; + const visW = iw / zoom, visH = ih / zoom; + const srcX = Math.max(0, Math.min(iw - visW, cx * iw - visW / 2)); + const srcY = Math.max(0, Math.min(ih - visH, cy * ih - visH / 2)); + return [srcX + (px - x) / w * visW, srcY + (py - y) / h * visH]; } function _attachEvents2d(p) { @@ -1656,17 +1641,25 @@ function render({ model, el }) { requestAnimationFrame(()=>{commitPending=false;localOnly=true;model.save_changes();setTimeout(()=>{localOnly=false;},200);}); } - // Wheel zoom + // Wheel zoom — anchored on the image point under the cursor overlayCanvas.addEventListener('wheel',(e)=>{ e.preventDefault(); const st=p.state; if(!st) return; const rect=overlayCanvas.getBoundingClientRect(); - const mx=(e.clientX-rect.left)/rect.width, my=(e.clientY-rect.top)/rect.height; + const imgW=Math.max(1,p.pw-PAD_L-PAD_R), imgH=Math.max(1,p.ph-PAD_T-PAD_B); + const mx=e.clientX-rect.left, my=e.clientY-rect.top; + // Image point under cursor before zoom change + const [anchorX,anchorY]=_canvasToImg2d(mx,my,st,imgW,imgH); const curZ=st.zoom, newZ=Math.max(0.75,Math.min(100,curZ*(e.deltaY>0?0.9:1.1))); - const ratio=curZ/newZ; st.zoom=newZ; - st.center_x=Math.max(0,Math.min(1,st.center_x+(mx-0.5)*(1-ratio)/newZ)); - st.center_y=Math.max(0,Math.min(1,st.center_y+(my-0.5)*(1-ratio)/newZ)); + // Reposition center so the same image point stays under the cursor + const iw=st.image_width, ih=st.image_height; + const fr=_imgFitRect(iw,ih,imgW,imgH); + const newVisW=iw/newZ, newVisH=ih/newZ; + const newSrcX=anchorX-(mx-fr.x)/fr.w*newVisW; + const newSrcY=anchorY-(my-fr.y)/fr.h*newVisH; + st.center_x=Math.max(0,Math.min(1,(newSrcX+newVisW/2)/iw)); + st.center_y=Math.max(0,Math.min(1,(newSrcY+newVisH/2)/ih)); draw2d(p); _propagateZoom2d(p); model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); @@ -1699,11 +1692,12 @@ function render({ model, el }) { } if(!p.isPanning) return; const st=p.state; if(!st) return; - const rect=overlayCanvas.getBoundingClientRect(); + const imgW=Math.max(1,p.pw-PAD_L-PAD_R), imgH=Math.max(1,p.ph-PAD_T-PAD_B); + const fr=_imgFitRect(st.image_width,st.image_height,imgW,imgH); const z=st.zoom; localOnly=true; - st.center_x=Math.max(0,Math.min(1,panStart.cx-(e.clientX-panStart.mx)/rect.width/z)); - st.center_y=Math.max(0,Math.min(1,panStart.cy-(e.clientY-panStart.my)/rect.height/z)); + st.center_x=Math.max(0,Math.min(1,panStart.cx-(e.clientX-panStart.mx)/fr.w/z)); + st.center_y=Math.max(0,Math.min(1,panStart.cy-(e.clientY-panStart.my)/fr.h/z)); draw2d(p); _propagateZoom2d(p); model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); @@ -1722,9 +1716,10 @@ function render({ model, el }) { if(!p.isPanning) return; p.isPanning=false; overlayCanvas.style.cursor='default'; const st=p.state; if(!st) return; - const rect=overlayCanvas.getBoundingClientRect(); - st.center_x=Math.max(0,Math.min(1,panStart.cx-(e.clientX-panStart.mx)/rect.width/st.zoom)); - st.center_y=Math.max(0,Math.min(1,panStart.cy-(e.clientY-panStart.my)/rect.height/st.zoom)); + const imgW=Math.max(1,p.pw-PAD_L-PAD_R), imgH=Math.max(1,p.ph-PAD_T-PAD_B); + const fr=_imgFitRect(st.image_width,st.image_height,imgW,imgH); + st.center_x=Math.max(0,Math.min(1,panStart.cx-(e.clientX-panStart.mx)/fr.w/st.zoom)); + st.center_y=Math.max(0,Math.min(1,panStart.cy-(e.clientY-panStart.my)/fr.h/st.zoom)); model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); _emitEvent(p.id,'on_release',null,{center_x:st.center_x,center_y:st.center_y,zoom:st.zoom}); model.save_changes(); @@ -1790,11 +1785,6 @@ function render({ model, el }) { st.zoom=1; st.center_x=0.5; st.center_y=0.5; draw2d(p); model.set(`panel_${p.id}_json`,JSON.stringify(st)); model.save_changes(); e.preventDefault(); - } else if(key==='h'){ - st.histogram_visible=!st.histogram_visible; - draw2d(p); - model.set(`panel_${p.id}_json`,JSON.stringify(st)); model.save_changes(); - e.preventDefault(); } else if(key==='c'){ st.show_colorbar=!st.show_colorbar; draw2d(p); @@ -1811,37 +1801,6 @@ function render({ model, el }) { } }); overlayCanvas.addEventListener('mouseenter',()=>overlayCanvas.focus()); - - // Histogram drag (display_min / display_max lines) - if(p.histCanvas){ - const HTOL=6; - let histDrag=null; - p.histCanvas.addEventListener('mousedown',(e)=>{ - const st2=p.state; if(!st2) return; - const rect=p.histCanvas.getBoundingClientRect(); - const my2=e.clientY-rect.top, ph2=rect.height; - const yMax=ph2-2-((st2.display_max-st2.hist_min)/((st2.hist_max-st2.hist_min)||1))*(ph2-4); - const yMin=ph2-2-((st2.display_min-st2.hist_min)/((st2.hist_max-st2.hist_min)||1))*(ph2-4); - if(Math.abs(my2-yMax)<=HTOL){histDrag='max'; e.preventDefault();} - else if(Math.abs(my2-yMin)<=HTOL){histDrag='min'; e.preventDefault();} - }); - document.addEventListener('mousemove',(e)=>{ - if(!histDrag) return; - const st2=p.state; if(!st2) return; - const rect=p.histCanvas.getBoundingClientRect(); - const my2=Math.max(0,Math.min(rect.height,e.clientY-rect.top)); - const frac=1-(my2-2)/(rect.height-4); - let val=st2.hist_min+frac*(st2.hist_max-st2.hist_min); - val=Math.max(st2.hist_min,Math.min(st2.hist_max,val)); - if(histDrag==='max'&&val>st2.display_min){st2.display_max=val;} - else if(histDrag==='min'&&val{ histDrag=null; }); - } } function _attachEvents1d(p) { @@ -1964,7 +1923,7 @@ function render({ model, el }) { const imgW = Math.max(1, p.pw - PAD_L - PAD_R); const imgH = Math.max(1, p.ph - PAD_T - PAD_B); const widgets = st.overlay_widgets || []; - const scale = _imgScale2d(st, imgW); + const scale = _imgScale2d(st, imgW, imgH); const HR = 9; // handle grab radius (px) // iterate top-to-bottom (last drawn = topmost) @@ -2050,7 +2009,7 @@ function render({ model, el }) { const d = p.ovDrag2d; const s = d.snapW; const w = st.overlay_widgets[d.idx]; - const scale = _imgScale2d(st, imgW); + const scale = _imgScale2d(st, imgW, imgH); // Convert current mouse to image coords const [imgMX, imgMY] = _canvasToImg2d(mx, my, st, imgW, imgH); @@ -2241,27 +2200,6 @@ function render({ model, el }) { const col_px = width_ratios.map(w => nfw * w / wsum); const row_px = height_ratios.map(h => nfh * h / hsum); - // Aspect-lock 2D panels - for (let pass = 0; pass < 4; pass++) { - for (const spec of panel_specs) { - const p = panels.get(spec.id); - if (!p || p.kind !== '2d' || !p.state) continue; - const iw = p.state.image_width||1, ih = p.state.image_height||1; - if (iw<=0||ih<=0) continue; - const ar = iw/ih; - let cw=0, ch=0; - for(let c=spec.col_start;c ar) { - const new_cw=ch*ar, span=Math.max(1,spec.col_stop-spec.col_start); - for(let c=spec.col_start;c { commitPending = false; model.save_changes(); }); + } + + overlayCanvas.addEventListener('mousedown', (e) => { + if (e.button !== 0) return; + const rect = overlayCanvas.getBoundingClientRect(); + const hit = _ovHitTest1d(e.clientX - rect.left, e.clientY - rect.top, p); + if (hit) { + p.ovDrag = hit; + overlayCanvas.style.cursor = 'ew-resize'; + e.preventDefault(); + } + }); + + document.addEventListener('mousemove', (e) => { + if (!p.ovDrag) return; + _doDrag1d(e, p); + const _dw = (p.state.overlay_widgets || [])[p.ovDrag.idx] || {}; + _emitEvent(p.id, 'on_changed', _dw.id || null, _dw); + }); + + document.addEventListener('mouseup', (e) => { + if (!p.ovDrag) return; + const _idx = p.ovDrag.idx; + const _dw = (p.state.overlay_widgets || [])[_idx] || {}; + const _did = _dw.id || null; + p.ovDrag = null; + overlayCanvas.style.cursor = 'default'; + model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); + _emitEvent(p.id, 'on_release', _did, _dw); + _scheduleCommit(); + }); + overlayCanvas.addEventListener('mousemove', (e) => { + if (p.ovDrag) return; // handled by document mousemove during drag const st = p.state; if (!st) return; const rect = overlayCanvas.getBoundingClientRect(); const mx = e.clientX - rect.left, my = e.clientY - rect.top; + + // Overlay widget cursor hint + const whit = _ovHitTest1d(mx, my, p); + if (whit) { + overlayCanvas.style.cursor = 'ew-resize'; + tooltip.style.display = 'none'; + if (p._hovBar !== -1) { p._hovBar = -1; drawBar(p); } + return; + } + const idx = _barHit(mx, my); if (idx !== p._hovBar) { p._hovBar = idx; @@ -2691,6 +2680,7 @@ function render({ model, el }) { }); overlayCanvas.addEventListener('click', (e) => { + if (p.ovDrag) return; const st = p.state; if (!st) return; const rect = overlayCanvas.getBoundingClientRect(); const idx = _barHit(e.clientX - rect.left, e.clientY - rect.top); diff --git a/anyplotlib/figure_plots.py b/anyplotlib/figure_plots.py index 9bd4a73d..4036354e 100644 --- a/anyplotlib/figure_plots.py +++ b/anyplotlib/figure_plots.py @@ -395,11 +395,6 @@ def _normalize_image(data: np.ndarray): return img_u8, vmin, vmax -def _compute_histogram(img_u8: np.ndarray, vmin: float, vmax: float) -> dict: - counts, edges = np.histogram(img_u8.ravel(), bins=256, range=(0, 255)) - bin_centers = vmin + (edges[:-1] + edges[1:]) / 2 / 255.0 * (vmax - vmin) - return {"bins": bin_centers.tolist(), "counts": counts.tolist()} - # Mapping from common matplotlib colormap names to their nearest colorcet # equivalents so callers can keep using familiar names without any matplotlib @@ -543,12 +538,10 @@ def __init__(self, data: np.ndarray, "units": units, "scale_x": scale_x, "scale_y": scale_y, - "hist_min": vmin, - "hist_max": vmax, "display_min": vmin, "display_max": vmax, - "histogram_data": _compute_histogram(img_u8, vmin, vmax), - "histogram_visible": False, + "raw_min": vmin, + "raw_max": vmax, "show_colorbar": False, "log_scale": False, "scale_mode": "linear", @@ -620,11 +613,10 @@ def update(self, data: np.ndarray, "image_b64": self._encode_bytes(img_u8), "image_width": w, "image_height": h, - "hist_min": vmin, - "hist_max": vmax, "display_min": vmin, "display_max": vmax, - "histogram_data": _compute_histogram(img_u8, vmin, vmax), + "raw_min": vmin, + "raw_max": vmax, "colormap_data": _build_colormap_lut(self._state["colormap_name"]), }) self._push() @@ -651,15 +643,6 @@ def set_scale_mode(self, mode: str) -> None: self._state["scale_mode"] = mode self._push() - @property - def histogram_visible(self) -> bool: - return self._state["histogram_visible"] - - @histogram_visible.setter - def histogram_visible(self, val: bool) -> None: - self._state["histogram_visible"] = bool(val) - self._push() - @property def colormap_name(self) -> str: return self._state["colormap_name"] @@ -981,11 +964,10 @@ def update(self, data: np.ndarray, "image_height": rows, "x_axis": xe.tolist(), "y_axis": ye.tolist(), - "hist_min": vmin, - "hist_max": vmax, "display_min": vmin, "display_max": vmax, - "histogram_data": _compute_histogram(img_u8, vmin, vmax), + "raw_min": vmin, + "raw_max": vmax, "colormap_data": _build_colormap_lut(self._state["colormap_name"]), }) if units is not None: @@ -1620,6 +1602,27 @@ def list_markers(self) -> list: return out +# --------------------------------------------------------------------------- +# _bar_x_axis helper +# --------------------------------------------------------------------------- + +def _bar_x_axis(x_centers: np.ndarray) -> list: + """Return a 2-element [x_left_edge, x_right_edge] list for a bar chart. + + The edges are half a slot-width outside the first/last bar centre so that + a vline_widget at ``x_centers[i]`` renders at exactly the bar's centre + pixel when used with ``_xToFrac1d`` / ``_fracToPx1d`` in the JS renderer. + """ + n = len(x_centers) + if n == 0: + return [0.0, 1.0] + if n == 1: + return [float(x_centers[0]) - 0.5, float(x_centers[0]) + 0.5] + slot = (float(x_centers[-1]) - float(x_centers[0])) / (n - 1) + half = slot / 2.0 + return [float(x_centers[0]) - half, float(x_centers[-1]) + half] + + # --------------------------------------------------------------------------- # PlotBar # --------------------------------------------------------------------------- @@ -1630,6 +1633,10 @@ class PlotBar: Not an anywidget. Holds state in ``_state`` dict; every mutation calls ``_push()`` which writes to the parent Figure's panel trait. + Supports draggable :class:`~anyplotlib.widgets.VLineWidget` and + :class:`~anyplotlib.widgets.HLineWidget` overlays via + :meth:`add_vline_widget` / :meth:`add_hline_widget`. + Created by :meth:`Axes.bar`. """ @@ -1669,6 +1676,10 @@ def __init__(self, values, if dmin < float(baseline): dmin -= pad + # Compute physical x-axis extent (left/right edges of the bar chart) + # so that vline_widgets map to the correct pixel positions. + x_axis = _bar_x_axis(x_centers) + self._state: dict = { "kind": "bar", "values": values.tolist(), @@ -1684,17 +1695,26 @@ def __init__(self, values, "data_max": dmax, "units": units, "y_units": y_units, + # overlay-widget coordinate system (mirrors Plot1D) + "x_axis": x_axis, + "view_x0": 0.0, + "view_x1": 1.0, + "overlay_widgets": [], } self.callbacks = CallbackRegistry() + self._widgets: dict[str, Widget] = {} # ------------------------------------------------------------------ def _push(self) -> None: if self._fig is None: return + self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] self._fig._push(self._id) def to_state_dict(self) -> dict: - return dict(self._state) + d = dict(self._state) + d["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] + return d # ------------------------------------------------------------------ # Data update @@ -1717,7 +1737,9 @@ def update(self, values, x_centers=None, x_labels=None) -> None: self._state["data_min"] = dmin self._state["data_max"] = dmax if x_centers is not None: - self._state["x_centers"] = np.asarray(x_centers, dtype=float).tolist() + xc = np.asarray(x_centers, dtype=float) + self._state["x_centers"] = xc.tolist() + self._state["x_axis"] = _bar_x_axis(xc) if x_labels is not None: self._state["x_labels"] = list(x_labels) self._push() @@ -1740,6 +1762,60 @@ def set_show_values(self, show: bool) -> None: self._state["show_values"] = bool(show) self._push() + # ------------------------------------------------------------------ + # Overlay Widgets + # ------------------------------------------------------------------ + def add_vline_widget(self, x: float, color: str = "#00e5ff") -> _VLineWidget: + """Add a draggable vertical line at data position *x*.""" + widget = _VLineWidget(lambda: None, x=float(x), color=color) + plot_ref, wid_id = self, widget._id + def _tp(): + if plot_ref._fig is not None: + fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} + plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) + widget._push_fn = _tp + self._widgets[widget.id] = widget + self._push() + return widget + + def add_hline_widget(self, y: float, color: str = "#00e5ff") -> _HLineWidget: + """Add a draggable horizontal line at value-axis position *y*.""" + widget = _HLineWidget(lambda: None, y=float(y), color=color) + plot_ref, wid_id = self, widget._id + def _tp(): + if plot_ref._fig is not None: + fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} + plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) + widget._push_fn = _tp + self._widgets[widget.id] = widget + self._push() + return widget + + def get_widget(self, wid) -> Widget: + """Return the Widget object by ID string or Widget instance.""" + if isinstance(wid, Widget): + wid = wid.id + try: + return self._widgets[wid] + except KeyError: + raise KeyError(wid) + + def remove_widget(self, wid) -> None: + """Remove a widget by ID string or Widget instance.""" + if isinstance(wid, Widget): + wid = wid.id + if wid not in self._widgets: + raise KeyError(wid) + del self._widgets[wid] + self._push() + + def list_widgets(self) -> list: + return list(self._widgets.values()) + + def clear_widgets(self) -> None: + self._widgets.clear() + self._push() + # ------------------------------------------------------------------ # Callbacks # ------------------------------------------------------------------ @@ -1754,11 +1830,17 @@ def on_click(self, fn: Callable) -> Callable: return fn def on_changed(self, fn: Callable) -> Callable: - """Decorator: fires on hover-enter for a bar.""" + """Decorator: fires on every drag frame (widget drag or hover).""" cid = self.callbacks.connect("on_changed", fn) fn._cid = cid return fn + def on_release(self, fn: Callable) -> Callable: + """Decorator: fires once when a widget drag settles.""" + cid = self.callbacks.connect("on_release", fn) + fn._cid = cid + return fn + def disconnect(self, cid: int) -> None: self.callbacks.disconnect(cid) @@ -1769,3 +1851,9 @@ def __repr__(self) -> str: + + + + + + diff --git a/tests/baselines/imshow_checkerboard.png b/tests/baselines/imshow_checkerboard.png index 91a6bdc319da935b362b1e1aa859e5b5bed6eaa4..8682d061c379da0b4e6c7d225ccec64d9bba5c61 100644 GIT binary patch literal 6291 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 IHoi&!0uONp-2eap literal 6354 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 diff --git a/tests/baselines/imshow_gradient.png b/tests/baselines/imshow_gradient.png index ff6c6ebe717c8b2fdd4b18b2b62718ef198bcf0d..5ef71c9106c12fa96e07b7015ea3e433eda1b770 100644 GIT binary patch literal 5613 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 literal 5614 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 Xa3(Ky9u5TGaUu7;9(yWwok;#49s(hl diff --git a/tests/baselines/imshow_viridis.png b/tests/baselines/imshow_viridis.png index 92f70e1b4d828c53a12a192ca9e04eb277a070c8..fb8fa1252fa21e3c63bd1353478d942f8dba8641 100644 GIT binary patch literal 13135 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 E0mtlIssI20 diff --git a/tests/baselines/pcolormesh_uniform.png b/tests/baselines/pcolormesh_uniform.png index da83450fd5284fbe2315b0b08aa33d18ce55be27..b6dc2187811d744c94ab4163172a8041464d4bb3 100644 GIT binary patch literal 12490 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 literal 12288 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 diff --git a/tests/baselines/subplots_2x1.png b/tests/baselines/subplots_2x1.png index c468052a69f3b7a4dad88894f669e7f1c10a1e8b..08f31828a49b9dd2116507470594d95876ab251d 100644 GIT binary patch literal 13852 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 literal 12058 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 Rw-LZU>MGjGh1V?}|2HB6=|KPh diff --git a/tests/test_gridspec.py b/tests/test_gridspec.py index aa968f86..b38f8f9a 100644 --- a/tests/test_gridspec.py +++ b/tests/test_gridspec.py @@ -9,13 +9,16 @@ The sizing contract (all measured at the *canvas* level, before PAD margins): - All panels in the same grid column have the same canvas width (pw). - All panels in the same grid row have the same canvas height (ph). - - For a 2-D panel with an image of aspect ratio ar = iw/ih: - canvas_pw / canvas_ph == ar (within 1 px rounding). + - Grid tracks are pure ratio math — no aspect-locking. + col_px[i] = fig_width * width_ratios[i] / sum(width_ratios) + row_px[r] = fig_height * height_ratios[r] / sum(height_ratios) - For N equal-ratio columns inside figsize (fw, fh): each column width == fw / N (within 1 px rounding). - width_ratios / height_ratios scale the tracks proportionally. - The total figure area is not exceeded: sum(col tracks) <= fw, sum(row tracks) <= fh. + - Images are rendered "contain" (letterboxed) in JS — the Python layout + engine never modifies tracks because of image content. """ from __future__ import annotations @@ -384,49 +387,44 @@ def test_panel_row_col_indices_in_layout_json(self): # ───────────────────────────────────────────────────────────────────────────── -# Part 5 – _compute_cell_sizes: 2D aspect-locking +# Part 5 – _compute_cell_sizes: 2D panels obey pure ratio math (no aspect-lock) # ───────────────────────────────────────────────────────────────────────────── -class TestAspectLocking: - """2D images must produce square-pixel canvases (pw/ph == iw/ih).""" +class Test2DPanelLayout: + """2D panels must receive exactly the canvas size their grid cell dictates. - def _assert_aspect(self, pw, ph, iw, ih, tol=1): - expected_ph = round(pw * ih / iw) - assert approx(ph, expected_ph, tol=tol), \ - f"aspect wrong: image {iw}×{ih}, canvas {pw}×{ph}, " \ - f"expected ph≈{expected_ph}" + Images are rendered "contain" (letterboxed) by the JS renderer, so the + Python layout engine never shrinks tracks to match image aspect ratios. + """ - def test_square_image_square_canvas(self): - fig, ax = vw.subplots(1, 1, figsize=(400, 400)) + def test_2d_panel_gets_full_cell_width(self): + """A 2D panel's canvas width equals the grid-ratio column width.""" + fig, ax = vw.subplots(1, 1, figsize=(400, 300)) v = ax.imshow(np.zeros((128, 128))) pw, ph = _sizes(fig)[v._id] - assert approx(pw, ph, tol=1), f"square image → square canvas: {pw}×{ph}" + assert pw == 400, f"expected pw=400, got {pw}" + assert ph == 300, f"expected ph=300, got {ph}" - def test_2to1_wide_image(self): - """256×128 image (2:1) in square cell → pw/ph ≈ 2.""" - fig, ax = vw.subplots(1, 1, figsize=(400, 400)) - v = ax.imshow(np.zeros((128, 256))) # H=128, W=256 + def test_2d_nonsquare_canvas_from_nonsquare_figsize(self): + """Non-square figsize → non-square canvas even for a square image.""" + fig, ax = vw.subplots(1, 1, figsize=(600, 200)) + v = ax.imshow(np.zeros((128, 128))) pw, ph = _sizes(fig)[v._id] - ratio = pw / ph - assert approx(round(ratio * 100), 200, tol=3), \ - f"2:1 image should give pw/ph≈2, got {ratio:.3f}" + assert pw == 600 and ph == 200, f"expected 600×200, got {pw}×{ph}" - def test_tall_image_portrait(self): - """128×64 image (1:2 — tall) in square cell → ph/pw ≈ 2.""" + def test_wide_image_does_not_shrink_canvas(self): + """Wide image (2:1) in a square cell — canvas stays square.""" fig, ax = vw.subplots(1, 1, figsize=(400, 400)) - v = ax.imshow(np.zeros((256, 128))) # H=256 (tall), W=128 + v = ax.imshow(np.zeros((128, 256))) # H=128, W=256 pw, ph = _sizes(fig)[v._id] - ratio = ph / pw - assert approx(round(ratio * 100), 200, tol=3), \ - f"tall image should give ph/pw≈2, got {ratio:.3f}" - - def test_aspect_does_not_exceed_cell(self): - """Canvas must not exceed allocated cell size.""" - fig, ax = vw.subplots(1, 1, figsize=(300, 500)) - v = ax.imshow(np.zeros((64, 128))) # wide image, tall cell + assert pw == 400 and ph == 400, f"expected 400×400, got {pw}×{ph}" + + def test_tall_image_does_not_shrink_canvas(self): + """Tall image (1:2) in a square cell — canvas stays square.""" + fig, ax = vw.subplots(1, 1, figsize=(400, 400)) + v = ax.imshow(np.zeros((256, 128))) # H=256, W=128 pw, ph = _sizes(fig)[v._id] - assert pw <= 300 + 1, f"pw={pw} exceeds cell width 300" - assert ph <= 500 + 1, f"ph={ph} exceeds cell height 500" + assert pw == 400 and ph == 400, f"expected 400×400, got {pw}×{ph}" def test_2d_and_1d_same_row_same_height(self): """2D and 1D panels in the same row must have the same canvas height.""" @@ -450,41 +448,38 @@ def test_2d_and_1d_same_col_same_width(self): assert pw2d == pw1d, \ f"same-col panels must have equal width: 2D={pw2d}, 1D={pw1d}" - def test_wide_2d_shrinks_col_not_row(self): - """ - Wide image (W > H) in a taller-than-wide cell: - the aspect lock should shrink the column width, leaving row height intact. - The 1D panel in the same column must match the shrunken width. + def test_image_does_not_affect_sibling_panel_size(self): + """Adding an image to one panel must NOT change a sibling panel's dimensions. + + This is the key regression test for the old aspect-lock bug: + a square image in row-0 of a height_ratios=[2,1] layout used to + shrink the shared column from 800 px to 333 px. """ - # figsize 400×600, square image 128×128: - # cell is 400×600 → taller than wide → shrink row: ph = pw = 400 - fig, axs = vw.subplots(2, 1, figsize=(400, 600)) - v2d = axs[0].imshow(np.zeros((128, 128))) - v1d = axs[1].plot(np.zeros(256)) + fig, axs = vw.subplots(2, 1, figsize=(800, 600), + height_ratios=[2, 1]) + v2d = axs[0].imshow(np.zeros((256, 256))) + v1d = axs[1].plot(np.zeros(10)) s = _sizes(fig) pw2d, ph2d = s[v2d._id] pw1d, ph1d = s[v1d._id] - assert pw2d == pw1d, f"column widths must match: {pw2d} vs {pw1d}" - assert approx(pw2d, ph2d, tol=1), \ - f"square image canvas must be square: {pw2d}×{ph2d}" - - def test_aspect_locks_converge_multiple_2d(self): - """ - Two 2D panels in the same column with different aspect ratios. - The more-constrained one determines the final column width. - Both panels must get the same pw. - """ - # Col width = 400. Image A: 128×128 (ar=1) → ph=pw=400 (no shrink needed for height). - # Image B: 64×128 (ar=0.5, tall) → ph = pw / 0.5 = 800, too tall. - # The taller cell gets shrunk: row_px[1] = pw_col / (1/0.5) = pw_col * 0.5 … - # The column serves both; they must end up the same pw. + # Both panels must share the full figure width + assert pw2d == 800, f"2D panel width should be 800, got {pw2d}" + assert pw1d == 800, f"1D panel width should be 800, got {pw1d}" + # Heights follow height_ratios=[2,1] → 400 and 200 + assert approx(ph2d, 400, tol=2), f"2D panel height should be ~400, got {ph2d}" + assert approx(ph1d, 200, tol=2), f"1D panel height should be ~200, got {ph1d}" + + def test_two_2d_panels_same_col_same_width(self): + """Two 2D panels with different aspect ratios in the same column + must both get the column width — no convergence loop needed.""" fig, axs = vw.subplots(2, 1, figsize=(400, 800)) vA = axs[0].imshow(np.zeros((128, 128))) # square - vB = axs[1].imshow(np.zeros((128, 64))) # wide (W=64 is wrong, fix: W>H) + vB = axs[1].imshow(np.zeros((128, 64))) # wide s = _sizes(fig) - pwA = s[vA._id][0] - pwB = s[vB._id][0] - assert pwA == pwB, f"Two 2D panels in same col must have same pw: {pwA} vs {pwB}" + pwA, phA = s[vA._id] + pwB, phB = s[vB._id] + assert pwA == pwB == 400, \ + f"Both 2D panels in same col must have pw=400: {pwA}, {pwB}" def test_minimum_canvas_size_floor(self): """Even a tiny figsize must produce canvas size ≥ 64 px.""" @@ -660,13 +655,14 @@ def test_replacing_plot_preserves_panel_id(self): pid2 = v2._id assert pid1 == pid2, "replacing plot must reuse the same panel id" - def test_2d_in_nonsquare_cell_aspect_preserved(self): - """Non-square figsize with a square image → canvas must still be square.""" + def test_2d_canvas_equals_cell_allocation(self): + """Non-square figsize with a square image → canvas equals the full cell + (no aspect-lock shrinking). The image is letterboxed by the JS renderer.""" fig, ax = vw.subplots(1, 1, figsize=(600, 300)) v = ax.imshow(np.zeros((128, 128))) pw, ph = _sizes(fig)[v._id] - assert approx(pw, ph, tol=1), \ - f"square image in non-square cell should produce square canvas: {pw}×{ph}" + assert pw == 600 and ph == 300, \ + f"canvas should equal full figsize 600×300, got {pw}×{ph}" def test_layout_json_is_valid_json(self): fig, axs = vw.subplots(2, 2, figsize=(400, 400)) diff --git a/tests/test_panel_alignment.py b/tests/test_panel_alignment.py index 182db7a4..2d4c2609 100644 --- a/tests/test_panel_alignment.py +++ b/tests/test_panel_alignment.py @@ -132,28 +132,30 @@ def test_1row_2col_plot_area_top_bottom_aligned(): assert h2d == h1d, f"Plot area heights: 2D={h2d}, 1D={h1d}" -# ── test 3: 2D aspect ratio preserves square pixels ────────────────────────── +# ── test 3: 2D panel canvas equals its grid cell ───────────────────────────── def test_square_image_gets_square_canvas(): - """A 128×128 image must produce a square canvas (pw == ph).""" + """A 128×128 image in a 500×500 figsize → canvas is 500×500 (pw == ph). + This still holds: the grid cell is square so the canvas is square too. + Images are letterboxed in JS; the Python layout never changes the cell size.""" fig, axs = vw.subplots(1, 1, figsize=(500, 500)) v2d = axs.imshow(np.random.rand(128, 128)) sizes = _panel_sizes(fig) pw, ph = sizes[v2d._id] - assert pw == ph, f"Square image must have pw==ph: pw={pw}, ph={ph}" + assert pw == ph, f"Square figsize must give pw==ph: pw={pw}, ph={ph}" -def test_wide_image_correct_aspect(): - """A 256×128 (2:1) image in a square cell must halve the height.""" +def test_wide_image_canvas_equals_cell(): + """A 2:1 image in a square cell gets a square canvas — no aspect-lock. + The image is letterboxed (pillarboxed) by the JS renderer.""" fig, axs = vw.subplots(1, 1, figsize=(512, 512)) v2d = axs.imshow(np.random.rand(128, 256)) # w=256, h=128 sizes = _panel_sizes(fig) pw, ph = sizes[v2d._id] - # aspect ratio should be 2:1 - assert abs(pw / ph - 2.0) < 0.05, ( - f"2:1 image should have pw/ph≈2, got {pw}/{ph}={pw/ph:.3f}" + assert pw == 512 and ph == 512, ( + f"Canvas should equal full figsize 512×512, got {pw}×{ph}" ) @@ -161,8 +163,8 @@ def test_wide_image_correct_aspect(): def test_nonsquare_2d_and_1d_same_column(): """ - A tall non-square image (h > w) in a 2-row column: after aspect-locking, - both panels must still have the same canvas width. + A tall non-square image in a 2-row, 1-col layout: both panels must have + the same canvas width (dictated by the column track, not the image aspect). """ fig, axs = vw.subplots(2, 1, figsize=(600, 800)) v2d = axs[0].imshow(np.random.rand(256, 128)) # tall image @@ -173,8 +175,7 @@ def test_nonsquare_2d_and_1d_same_column(): pw1d, ph1d = sizes[v1d._id] assert pw2d == pw1d, ( - f"Same-column panels must have equal width after aspect lock: " - f"2D={pw2d}, 1D={pw1d}" + f"Same-column panels must have equal width: 2D={pw2d}, 1D={pw1d}" ) From 617135aa0b7ab905ace5a57faafe16bfa124553b Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Tue, 17 Mar 2026 15:28:58 -0500 Subject: [PATCH 021/198] Add key-press handling for overlay widget placement and interaction - Introduce `on_key` callback API for registering key-press handlers. - Implement functionality to add various shapes (rectangles, circles, annuli) at cursor position. - Allow deletion of the last clicked widget using the Delete key. - Update documentation to reflect new key binding features and usage examples. --- Examples/Interactive/plot_key_bindings.py | 114 ++++++++++ anyplotlib/callbacks.py | 2 +- anyplotlib/figure_esm.js | 92 +++++++- anyplotlib/figure_plots.py | 256 ++++++++++++++++++++-- 4 files changed, 436 insertions(+), 28 deletions(-) create mode 100644 Examples/Interactive/plot_key_bindings.py diff --git a/Examples/Interactive/plot_key_bindings.py b/Examples/Interactive/plot_key_bindings.py new file mode 100644 index 00000000..1c114387 --- /dev/null +++ b/Examples/Interactive/plot_key_bindings.py @@ -0,0 +1,114 @@ +""" +Key-Press Widget Placement +========================== + +Demonstrates the ``on_key`` callback API: press a key while the plot is +focused to add an overlay widget centred on the current cursor position, +or press **Delete** to remove the last widget you clicked. + +**Key bindings** + ++-------+---------------------------+ +| Key | Action | ++=======+===========================+ +| ``q`` | Add a rectangle | ++-------+---------------------------+ +| ``w`` | Add a circle | ++-------+---------------------------+ +| ``e`` | Add an annulus | ++-------+---------------------------+ +| ``Delete`` | Remove last-clicked | ++-------+---------------------------+ + +**Built-in 2-D shortcuts** (not overridden in this example): + ++-------+---------------------------+ +| Key | Action | ++=======+===========================+ +| ``r`` | Reset zoom / pan | ++-------+---------------------------+ +| ``c`` | Toggle colorbar | ++-------+---------------------------+ +| ``l`` | Toggle log scale | ++-------+---------------------------+ +| ``s`` | Toggle symlog scale | ++-------+---------------------------+ + +The cursor coordinates reported in the event (``event.img_x``, +``event.img_y``) are in image-pixel space, so widgets are centred exactly +where the cursor was when the key was pressed. + +.. note:: + Move the mouse over the image first so the plot panel receives focus, + then press a key. +""" + +import numpy as np +import anyplotlib as vw + +# ── Synthetic test image ────────────────────────────────────────────────────── +rng = np.random.default_rng(0) +N = 256 +x = np.linspace(0, 4 * np.pi, N) +XX, YY = np.meshgrid(x, x) +data = np.sin(XX) * np.cos(YY) + 0.15 * rng.standard_normal((N, N)) + +# ── Figure ──────────────────────────────────────────────────────────────────── +fig, ax = vw.subplots(figsize=(520, 520)) +plot = ax.imshow(data) + +# ── Key handlers ───────────────────────────────────────────────────────────── + +@plot.on_key('q') +def add_rectangle(event): + """Press 'q' — add a rectangle centred on the cursor.""" + cx, cy = event.img_x, event.img_y + half_w, half_h = N * 0.08, N * 0.08 + plot.add_widget( + "rectangle", + x=cx - half_w, y=cy - half_h, + w=half_w * 2, h=half_h * 2, + color="#ffd54f", + ) + + +@plot.on_key('w') +def add_circle(event): + """Press 'w' — add a circle centred on the cursor.""" + plot.add_widget( + "circle", + cx=event.img_x, cy=event.img_y, + r=N * 0.07, + color="#80cbc4", + ) + + +@plot.on_key('e') +def add_annulus(event): + """Press 'e' — add an annulus centred on the cursor.""" + plot.add_widget( + "annular", + cx=event.img_x, cy=event.img_y, + r_outer=N * 0.12, + r_inner=N * 0.06, + color="#ce93d8", + ) + + +@plot.on_key('Delete') +def delete_last(event): + """Press Delete — remove the last widget that was clicked / dragged.""" + wid = event.last_widget_id + if wid and wid in {w.id for w in plot.list_widgets()}: + plot.remove_widget(wid) + + +# ── Catch-all handler (optional) — print every registered key press ────────── + +@plot.on_key +def log_key(event): + print(f"[on_key] key={event.key!r} img=({event.img_x:.1f}, {event.img_y:.1f})" + f" last_widget={event.last_widget_id!r}") + +fig + diff --git a/anyplotlib/callbacks.py b/anyplotlib/callbacks.py index f9432156..4dee08a2 100644 --- a/anyplotlib/callbacks.py +++ b/anyplotlib/callbacks.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from typing import Any, Callable -_VALID_EVENT_TYPES = ("on_click", "on_changed", "on_release") +_VALID_EVENT_TYPES = ("on_click", "on_changed", "on_release", "on_key") @dataclass diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index e0e4a563..99daa6a9 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -312,6 +312,8 @@ function render({ model, el }) { state: null, _hoverSi: -1, _hoverI: -1, // index of hovered marker group / marker (-1 = none) _hovBar: -1, // index of hovered bar (-1 = none) + lastWidgetId: null, // id of the last clicked/dragged widget (for on_key Delete etc.) + mouseX: 0, mouseY: 0, // last known canvas-relative cursor position // 2D extras (null for non-2D panels) cbCanvas: _p2d ? _p2d.cbCanvas : null, cbCtx: _p2d ? _p2d.cbCtx : null, @@ -1221,7 +1223,25 @@ function render({ model, el }) { _scheduleCommit(); }, { passive: false }); + overlayCanvas.addEventListener('mousemove', (e) => { + const rect = overlayCanvas.getBoundingClientRect(); + p.mouseX = e.clientX - rect.left; + p.mouseY = e.clientY - rect.top; + }); + + // Keyboard shortcuts + // Built-in: r=reset view. Registered keys are forwarded to Python first. overlayCanvas.addEventListener('keydown', (e) => { + const st = p.state; if (!st) return; + const regKeys = st.registered_keys || []; + if (regKeys.includes(e.key) || regKeys.includes('*')) { + _emitEvent(p.id, 'on_key', null, { + key: e.key, + last_widget_id: p.lastWidgetId || null, + mouse_x: p.mouseX, mouse_y: p.mouseY, + }); + e.preventDefault(); return; + } if (e.key.toLowerCase() === 'r') { p.state.azimuth = -60; p.state.elevation = 30; p.state.zoom = 1; draw3d(p); @@ -1677,6 +1697,7 @@ function render({ model, el }) { const hit=_ovHitTest2d(mx, my, p); if(hit){ p.ovDrag2d=hit; + p.lastWidgetId=(st.overlay_widgets||[])[hit.idx]?.id||null; overlayCanvas.style.cursor='move'; e.preventDefault(); return; } @@ -1727,10 +1748,11 @@ function render({ model, el }) { // Status bar + tooltip + widget hover cursor overlayCanvas.addEventListener('mousemove',(e)=>{ - if(p.ovDrag2d) return; // handled by document mousemove - const st=p.state; if(!st) return; const rect=overlayCanvas.getBoundingClientRect(); const mx=e.clientX-rect.left, my=e.clientY-rect.top; + p.mouseX=mx; p.mouseY=my; + if(p.ovDrag2d) return; // handled by document mousemove + const st=p.state; if(!st) return; // Update cursor based on widget hit const whit=_ovHitTest2d(mx, my, p); @@ -1778,8 +1800,28 @@ function render({ model, el }) { }); // Keyboard shortcuts + // Built-ins: r=reset zoom, c=colorbar toggle, l=log scale, s=symlog scale. + // Any key listed in st.registered_keys (or '*' for all keys) is forwarded + // to Python via on_key and suppresses the matching built-in. overlayCanvas.addEventListener('keydown',(e)=>{ const st=p.state; if(!st) return; + const regKeys=st.registered_keys||[]; + if(regKeys.includes(e.key)||regKeys.includes('*')){ + const imgW=Math.max(1,p.pw-PAD_L-PAD_R), imgH=Math.max(1,p.ph-PAD_T-PAD_B); + const [imgX,imgY]=_canvasToImg2d(p.mouseX,p.mouseY,st,imgW,imgH); + const xArr=st.x_axis||[], yArr=st.y_axis||[]; + const iw=st.image_width||1, ih=st.image_height||1; + const physX=xArr.length>=2?_axisFracToVal(xArr,imgX/iw):imgX; + const physY=yArr.length>=2?_axisFracToVal(yArr,imgY/ih):imgY; + _emitEvent(p.id,'on_key',null,{ + key:e.key, + last_widget_id:p.lastWidgetId||null, + mouse_x:p.mouseX, mouse_y:p.mouseY, + img_x:imgX, img_y:imgY, + phys_x:physX, phys_y:physY, + }); + e.preventDefault(); return; + } const key=e.key.toLowerCase(); if(key==='r'){ st.zoom=1; st.center_x=0.5; st.center_y=0.5; @@ -1836,7 +1878,7 @@ function render({ model, el }) { if(e.button!==0) return; const st=p.state; if(!st) return; const hit=_ovHitTest1d(e.clientX-overlayCanvas.getBoundingClientRect().left, e.clientY-overlayCanvas.getBoundingClientRect().top, p); - if(hit){p.ovDrag=hit;overlayCanvas.style.cursor=(hit.mode==='edge0'||hit.mode==='edge1')?'ew-resize':'move';e.preventDefault();return;} + if(hit){p.ovDrag=hit;p.lastWidgetId=(p.state.overlay_widgets||[])[hit.idx]?.id||null;overlayCanvas.style.cursor=(hit.mode==='edge0'||hit.mode==='edge1')?'ew-resize':'move';e.preventDefault();return;} panStart={mx:e.clientX,x0:st.view_x0,x1:st.view_x1}; p.isPanning=true;overlayCanvas.style.cursor='grabbing';e.preventDefault(); }); @@ -1874,8 +1916,26 @@ function render({ model, el }) { } }); + // Keyboard shortcuts + // Built-in: r=reset view. Any key in st.registered_keys (or '*') is + // forwarded to Python via on_key and suppresses the matching built-in. overlayCanvas.addEventListener('keydown',(e)=>{ - if(e.key.toLowerCase()==='r'){const st=p.state;if(!st)return;st.view_x0=0;st.view_x1=1;draw1d(p);model.set(`panel_${p.id}_json`,JSON.stringify(st));model.save_changes();e.preventDefault();} + const st=p.state; if(!st) return; + const regKeys=st.registered_keys||[]; + if(regKeys.includes(e.key)||regKeys.includes('*')){ + const r=_plotRect1d(p.pw,p.ph); + const xArr=st.x_axis||[]; + const frac=_canvasXToFrac1d(p.mouseX,st.view_x0,st.view_x1,r); + const physX=xArr.length>=2?_fracToX1d(xArr,frac):frac; + _emitEvent(p.id,'on_key',null,{ + key:e.key, + last_widget_id:p.lastWidgetId||null, + mouse_x:p.mouseX, mouse_y:p.mouseY, + phys_x:physX, + }); + e.preventDefault(); return; + } + if(e.key.toLowerCase()==='r'){st.view_x0=0;st.view_x1=1;draw1d(p);model.set(`panel_${p.id}_json`,JSON.stringify(st));model.save_changes();e.preventDefault();} }); overlayCanvas.tabIndex=0;overlayCanvas.style.outline='none'; overlayCanvas.addEventListener('mouseenter',()=>overlayCanvas.focus()); @@ -1883,6 +1943,7 @@ function render({ model, el }) { const st=p.state;if(!st)return; const rect=overlayCanvas.getBoundingClientRect(); const mx=e.clientX-rect.left,my=e.clientY-rect.top; + p.mouseX=mx; p.mouseY=my; const r=_plotRect1d(p.pw,p.ph); if(mxr.x+r.w||myr.y+r.h){ p.statusBar.style.display='none';tooltip.style.display='none'; @@ -2618,6 +2679,7 @@ function render({ model, el }) { const hit = _ovHitTest1d(e.clientX - rect.left, e.clientY - rect.top, p); if (hit) { p.ovDrag = hit; + p.lastWidgetId = (p.state.overlay_widgets || [])[hit.idx]?.id || null; overlayCanvas.style.cursor = 'ew-resize'; e.preventDefault(); } @@ -2643,10 +2705,11 @@ function render({ model, el }) { }); overlayCanvas.addEventListener('mousemove', (e) => { - if (p.ovDrag) return; // handled by document mousemove during drag - const st = p.state; if (!st) return; const rect = overlayCanvas.getBoundingClientRect(); const mx = e.clientX - rect.left, my = e.clientY - rect.top; + p.mouseX = mx; p.mouseY = my; + if (p.ovDrag) return; // handled by document mousemove during drag + const st = p.state; if (!st) return; // Overlay widget cursor hint const whit = _ovHitTest1d(mx, my, p); @@ -2693,6 +2756,23 @@ function render({ model, el }) { ? String(st.x_labels[idx]) : null, }); }); + + // Keyboard: registered_keys forwarded to Python; no built-in bar shortcuts. + overlayCanvas.addEventListener('keydown', (e) => { + const st = p.state; if (!st) return; + const regKeys = st.registered_keys || []; + if (regKeys.includes(e.key) || regKeys.includes('*')) { + _emitEvent(p.id, 'on_key', null, { + key: e.key, + last_widget_id: p.lastWidgetId || null, + mouse_x: p.mouseX, mouse_y: p.mouseY, + }); + e.preventDefault(); + } + }); + overlayCanvas.tabIndex = 0; + overlayCanvas.style.outline = 'none'; + overlayCanvas.addEventListener('mouseenter', () => overlayCanvas.focus()); } // ── generic redraw ──────────────────────────────────────────────────────── diff --git a/anyplotlib/figure_plots.py b/anyplotlib/figure_plots.py index 4036354e..05a0846e 100644 --- a/anyplotlib/figure_plots.py +++ b/anyplotlib/figure_plots.py @@ -552,6 +552,7 @@ def __init__(self, data: np.ndarray, "center_y": 0.5, "overlay_widgets": [], "markers": [], + "registered_keys": [], } self.markers = MarkerRegistry(self._push_markers, @@ -754,12 +755,75 @@ def on_click(self, fn: Callable) -> Callable: fn._cid = cid return fn + def on_key(self, key_or_fn=None) -> Callable: + """Register a key-press handler for this panel. + + Two call forms are supported:: + + @plot.on_key('q') # fires only when 'q' is pressed + def handler(event): ... + + @plot.on_key # fires for every registered key + def handler(event): ... + + The event carries: ``key``, ``mouse_x``, ``mouse_y``, ``phys_x``, + and ``last_widget_id``. + + .. note:: + Registered keys take priority over the built-in **r** (reset view) + shortcut. + """ + if callable(key_or_fn): + return self._connect_on_key(None, key_or_fn) + key = key_or_fn + def _decorator(fn): + return self._connect_on_key(key, fn) + return _decorator + + def _connect_on_key(self, key, fn) -> Callable: + if key is None: + if '*' not in self._state['registered_keys']: + self._state['registered_keys'].append('*') + self._push() + cid = self.callbacks.connect("on_key", fn) + else: + if key not in self._state['registered_keys']: + self._state['registered_keys'].append(key) + self._push() + def _wrapped(event): + if event.data.get('key') == key: + fn(event) + cid = self.callbacks.connect("on_key", _wrapped) + _wrapped._cid = cid + fn._cid = cid + return fn + def disconnect(self, cid: int) -> None: - """Remove the callback registered under integer cid.""" + """Remove the callback registered under integer *cid*.""" self.callbacks.disconnect(cid) # ------------------------------------------------------------------ - # Marker API (circles and lines only) + # View control + # ------------------------------------------------------------------ + def set_view(self, x0: float | None = None, x1: float | None = None) -> None: + xarr = np.asarray(self._state["x_axis"]) + if len(xarr) < 2: + return + xmin, xmax = float(xarr[0]), float(xarr[-1]) + span = xmax - xmin or 1.0 + f0 = 0.0 if x0 is None else max(0.0, min(1.0, (float(x0)-xmin)/span)) + f1 = 1.0 if x1 is None else max(0.0, min(1.0, (float(x1)-xmin)/span)) + self._state["view_x0"] = f0 + self._state["view_x1"] = f1 + self._push() + + def reset_view(self) -> None: + self._state["view_x0"] = 0.0 + self._state["view_x1"] = 1.0 + self._push() + + # ------------------------------------------------------------------ + # Marker API (matplotlib-style kwargs → MarkerRegistry) # ------------------------------------------------------------------ def _add_marker(self, mtype: str, name: str | None, **kwargs) -> "MarkerGroup": # noqa: F821 return self.markers.add(mtype, name, **kwargs) @@ -769,21 +833,44 @@ def add_circles(self, offsets, name=None, *, radius=5, linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, labels=None, label=None) -> "MarkerGroup": # noqa: F821 - """Add point markers in physical (data) coordinates.""" - return self._add_marker("circles", name, offsets=offsets, radius=radius, + # On 1-D panels the native type is "points" (radius maps to sizes). + return self._add_marker("points", name, offsets=offsets, sizes=radius, facecolors=facecolors, edgecolors=edgecolors, linewidths=linewidths, alpha=alpha, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, labels=labels, label=label) - def add_lines(self, segments, name=None, *, - edgecolors="#ff0000", linewidths=1.5, - hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - """Add line-segment markers in physical (data) coordinates.""" - return self._add_marker("lines", name, segments=segments, - edgecolors=edgecolors, linewidths=linewidths, + def add_points(self, offsets, name=None, *, sizes=5, + color="#ff0000", facecolors=None, + linewidths=1.5, alpha=0.3, + hover_edgecolors=None, hover_facecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + """Add point markers at (x, y) positions in data coordinates.""" + return self._add_marker("points", name, offsets=offsets, sizes=sizes, + edgecolors=color, facecolors=facecolors, + linewidths=linewidths, alpha=alpha, + hover_edgecolors=hover_edgecolors, + hover_facecolors=hover_facecolors, + labels=labels, label=label) + + def add_hlines(self, y_values, name=None, *, + color="#ff0000", linewidths=1.5, + hover_edgecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + """Add static horizontal lines at the given y positions.""" + return self._add_marker("hlines", name, offsets=y_values, + color=color, linewidths=linewidths, + hover_edgecolors=hover_edgecolors, + labels=labels, label=label) + + def add_vlines(self, x_values, name=None, *, + color="#ff0000", linewidths=1.5, + hover_edgecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + """Add static vertical lines at the given x positions.""" + return self._add_marker("vlines", name, offsets=x_values, + color=color, linewidths=linewidths, hover_edgecolors=hover_edgecolors, labels=labels, label=label) @@ -791,7 +878,6 @@ def add_arrows(self, offsets, U, V, name=None, *, edgecolors="#ff0000", linewidths=1.5, hover_edgecolors=None, labels=None, label=None) -> "MarkerGroup": # noqa: F821 - """Add vector-arrow markers in physical (data) coordinates.""" return self._add_marker("arrows", name, offsets=offsets, U=U, V=V, edgecolors=edgecolors, linewidths=linewidths, hover_edgecolors=hover_edgecolors, @@ -802,7 +888,6 @@ def add_ellipses(self, offsets, widths, heights, name=None, *, linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, labels=None, label=None) -> "MarkerGroup": # noqa: F821 - """Add ellipse markers in physical (data) coordinates.""" return self._add_marker("ellipses", name, offsets=offsets, widths=widths, heights=heights, angles=angles, facecolors=facecolors, edgecolors=edgecolors, @@ -811,12 +896,20 @@ def add_ellipses(self, offsets, widths, heights, name=None, *, hover_facecolors=hover_facecolors, labels=labels, label=label) + def add_lines(self, segments, name=None, *, + edgecolors="#ff0000", linewidths=1.5, + hover_edgecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + return self._add_marker("lines", name, segments=segments, + edgecolors=edgecolors, linewidths=linewidths, + hover_edgecolors=hover_edgecolors, + labels=labels, label=label) + def add_rectangles(self, offsets, widths, heights, name=None, *, angles=0, facecolors=None, edgecolors="#ff0000", linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, labels=None, label=None) -> "MarkerGroup": # noqa: F821 - """Add rectangle markers in physical (data) coordinates.""" return self._add_marker("rectangles", name, offsets=offsets, widths=widths, heights=heights, angles=angles, facecolors=facecolors, edgecolors=edgecolors, @@ -830,7 +923,6 @@ def add_squares(self, offsets, widths, name=None, *, linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, labels=None, label=None) -> "MarkerGroup": # noqa: F821 - """Add square markers in physical (data) coordinates.""" return self._add_marker("squares", name, offsets=offsets, widths=widths, angles=angles, facecolors=facecolors, edgecolors=edgecolors, @@ -844,7 +936,6 @@ def add_polygons(self, vertices_list, name=None, *, linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, labels=None, label=None) -> "MarkerGroup": # noqa: F821 - """Add closed polygon markers in physical (data) coordinates.""" return self._add_marker("polygons", name, vertices_list=vertices_list, facecolors=facecolors, edgecolors=edgecolors, linewidths=linewidths, alpha=alpha, @@ -856,7 +947,6 @@ def add_texts(self, offsets, texts, name=None, *, color="#ff0000", fontsize=12, hover_edgecolors=None, labels=None, label=None) -> "MarkerGroup": # noqa: F821 - """Add text annotation markers in physical (data) coordinates.""" return self._add_marker("texts", name, offsets=offsets, texts=texts, color=color, fontsize=fontsize, hover_edgecolors=hover_edgecolors, @@ -1088,6 +1178,7 @@ def __init__(self, geom_type: str, "elevation": float(elevation), "zoom": float(zoom), "data_bounds": data_bounds, + "registered_keys": [], } self.callbacks = CallbackRegistry() @@ -1121,6 +1212,49 @@ def on_click(self, fn: Callable) -> Callable: fn._cid = cid return fn + def on_key(self, key_or_fn=None) -> Callable: + """Register a key-press handler for this panel. + + Two call forms are supported:: + + @plot.on_key('q') # fires only when 'q' is pressed + def handler(event): ... + + @plot.on_key # fires for every registered key + def handler(event): ... + + The event carries: ``key``, ``mouse_x``, ``mouse_y``, and + ``last_widget_id``. + + .. note:: + Registered keys take priority over the built-in **r** (reset view) + shortcut. + """ + if callable(key_or_fn): + return self._connect_on_key(None, key_or_fn) + key = key_or_fn + def _decorator(fn): + return self._connect_on_key(key, fn) + return _decorator + + def _connect_on_key(self, key, fn) -> Callable: + if key is None: + if '*' not in self._state['registered_keys']: + self._state['registered_keys'].append('*') + self._push() + cid = self.callbacks.connect("on_key", fn) + else: + if key not in self._state['registered_keys']: + self._state['registered_keys'].append(key) + self._push() + def _wrapped(event): + if event.data.get('key') == key: + fn(event) + cid = self.callbacks.connect("on_key", _wrapped) + _wrapped._cid = cid + fn._cid = cid + return fn + def disconnect(self, cid: int) -> None: """Remove the callback registered under integer *cid*.""" self.callbacks.disconnect(cid) @@ -1243,6 +1377,7 @@ def __init__(self, data: np.ndarray, "spans": [], "overlay_widgets": [], "markers": [], + "registered_keys": [], } self.markers = MarkerRegistry(self._push_markers, @@ -1434,6 +1569,49 @@ def on_click(self, fn: Callable) -> Callable: fn._cid = cid return fn + def on_key(self, key_or_fn=None) -> Callable: + """Register a key-press handler for this panel. + + Two call forms are supported:: + + @plot.on_key('q') # fires only when 'q' is pressed + def handler(event): ... + + @plot.on_key # fires for every registered key + def handler(event): ... + + The event carries: ``key``, ``mouse_x``, ``mouse_y``, ``phys_x``, + and ``last_widget_id``. + + .. note:: + Registered keys take priority over the built-in **r** (reset view) + shortcut. + """ + if callable(key_or_fn): + return self._connect_on_key(None, key_or_fn) + key = key_or_fn + def _decorator(fn): + return self._connect_on_key(key, fn) + return _decorator + + def _connect_on_key(self, key, fn) -> Callable: + if key is None: + if '*' not in self._state['registered_keys']: + self._state['registered_keys'].append('*') + self._push() + cid = self.callbacks.connect("on_key", fn) + else: + if key not in self._state['registered_keys']: + self._state['registered_keys'].append(key) + self._push() + def _wrapped(event): + if event.data.get('key') == key: + fn(event) + cid = self.callbacks.connect("on_key", _wrapped) + _wrapped._cid = cid + fn._cid = cid + return fn + def disconnect(self, cid: int) -> None: """Remove the callback registered under integer *cid*.""" self.callbacks.disconnect(cid) @@ -1700,6 +1878,7 @@ def __init__(self, values, "view_x0": 0.0, "view_x1": 1.0, "overlay_widgets": [], + "registered_keys": [], } self.callbacks = CallbackRegistry() self._widgets: dict[str, Widget] = {} @@ -1841,6 +2020,45 @@ def on_release(self, fn: Callable) -> Callable: fn._cid = cid return fn + def on_key(self, key_or_fn=None) -> Callable: + """Register a key-press handler for this panel. + + Two call forms are supported:: + + @plot.on_key('q') # fires only when 'q' is pressed + def handler(event): ... + + @plot.on_key # fires for every registered key + def handler(event): ... + + The event carries: ``key``, ``mouse_x``, ``mouse_y``, and + ``last_widget_id``. + """ + if callable(key_or_fn): + return self._connect_on_key(None, key_or_fn) + key = key_or_fn + def _decorator(fn): + return self._connect_on_key(key, fn) + return _decorator + + def _connect_on_key(self, key, fn) -> Callable: + if key is None: + if '*' not in self._state['registered_keys']: + self._state['registered_keys'].append('*') + self._push() + cid = self.callbacks.connect("on_key", fn) + else: + if key not in self._state['registered_keys']: + self._state['registered_keys'].append(key) + self._push() + def _wrapped(event): + if event.data.get('key') == key: + fn(event) + cid = self.callbacks.connect("on_key", _wrapped) + _wrapped._cid = cid + fn._cid = cid + return fn + def disconnect(self, cid: int) -> None: self.callbacks.disconnect(cid) @@ -1853,7 +2071,3 @@ def __repr__(self) -> str: - - - - From dbbb83e6fd88261dbc305f6b60ac9c548836ff5a Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 18 Mar 2026 12:01:47 -0500 Subject: [PATCH 022/198] Clean up syntax and remove unused code. Add regression tests for Plot2D methods and ensure top-level imports are accessible. --- Examples/Interactive/plot_key_bindings.py | 40 ++-- anyplotlib/__init__.py | 6 +- anyplotlib/_repr_utils.py | 2 +- anyplotlib/figure.py | 1 - anyplotlib/figure_esm.js | 26 +-- anyplotlib/figure_plots.py | 79 +++++-- pyproject.toml | 6 +- tests/test_plot2d_polish.py | 273 ++++++++++++++++++++++ 8 files changed, 385 insertions(+), 48 deletions(-) create mode 100644 tests/test_plot2d_polish.py diff --git a/Examples/Interactive/plot_key_bindings.py b/Examples/Interactive/plot_key_bindings.py index 1c114387..d3c1f222 100644 --- a/Examples/Interactive/plot_key_bindings.py +++ b/Examples/Interactive/plot_key_bindings.py @@ -4,21 +4,22 @@ Demonstrates the ``on_key`` callback API: press a key while the plot is focused to add an overlay widget centred on the current cursor position, -or press **Delete** to remove the last widget you clicked. +or press **Backspace / Delete** to remove the last widget you clicked. **Key bindings** -+-------+---------------------------+ -| Key | Action | -+=======+===========================+ -| ``q`` | Add a rectangle | -+-------+---------------------------+ -| ``w`` | Add a circle | -+-------+---------------------------+ -| ``e`` | Add an annulus | -+-------+---------------------------+ -| ``Delete`` | Remove last-clicked | -+-------+---------------------------+ ++-------------------------------+---------------------------+ +| Key | Action | ++===============================+===========================+ +| ``q`` | Add a rectangle | ++-------------------------------+---------------------------+ +| ``w`` | Add a circle | ++-------------------------------+---------------------------+ +| ``e`` | Add an annulus | ++-------------------------------+---------------------------+ +| ``Backspace`` (macOS ⌫) | Remove last-clicked | +| ``Delete`` (Windows / Linux) | | ++-------------------------------+---------------------------+ **Built-in 2-D shortcuts** (not overridden in this example): @@ -40,7 +41,8 @@ .. note:: Move the mouse over the image first so the plot panel receives focus, - then press a key. + then press a key. On macOS the backspace key (⌫) is used for deletion; + on Windows / Linux use the **Delete** key. """ import numpy as np @@ -95,19 +97,25 @@ def add_annulus(event): ) +# macOS sends 'Backspace' for the ⌫ key; Windows/Linux send 'Delete'. +# Register both so the example works cross-platform. +@plot.on_key('Backspace') @plot.on_key('Delete') def delete_last(event): - """Press Delete — remove the last widget that was clicked / dragged.""" + """Press Backspace/Delete — remove the last widget that was clicked.""" wid = event.last_widget_id if wid and wid in {w.id for w in plot.list_widgets()}: plot.remove_widget(wid) -# ── Catch-all handler (optional) — print every registered key press ────────── +# ── Catch-all handler (optional) — log every registered key press ───────────── @plot.on_key def log_key(event): - print(f"[on_key] key={event.key!r} img=({event.img_x:.1f}, {event.img_y:.1f})" + img_x = getattr(event, 'img_x', None) + img_y = getattr(event, 'img_y', None) + pos = f"({img_x:.1f}, {img_y:.1f})" if img_x is not None else "n/a" + print(f"[on_key] key={event.key!r} img={pos}" f" last_widget={event.last_widget_id!r}") fig diff --git a/anyplotlib/__init__.py b/anyplotlib/__init__.py index a3d1c013..93abdc00 100644 --- a/anyplotlib/__init__.py +++ b/anyplotlib/__init__.py @@ -1,5 +1,6 @@ from anyplotlib.figure import Figure, GridSpec, SubplotSpec, subplots -from anyplotlib.figure_plots import PlotMesh, Plot3D, PlotBar +from anyplotlib.figure_plots import Axes, Plot1D, Plot2D, PlotMesh, Plot3D, PlotBar +from anyplotlib.callbacks import CallbackRegistry, Event from anyplotlib.widgets import ( Widget, RectangleWidget, CircleWidget, AnnularWidget, CrosshairWidget, PolygonWidget, LabelWidget, @@ -8,7 +9,8 @@ __all__ = [ "Figure", "GridSpec", "SubplotSpec", "subplots", - "PlotMesh", "Plot3D", "PlotBar", + "Axes", "Plot1D", "Plot2D", "PlotMesh", "Plot3D", "PlotBar", + "CallbackRegistry", "Event", "Widget", "RectangleWidget", "CircleWidget", "AnnularWidget", "CrosshairWidget", "PolygonWidget", "LabelWidget", "VLineWidget", "HLineWidget", "RangeWidget", diff --git a/anyplotlib/_repr_utils.py b/anyplotlib/_repr_utils.py index 42be9d61..e79f4899 100644 --- a/anyplotlib/_repr_utils.py +++ b/anyplotlib/_repr_utils.py @@ -7,7 +7,7 @@ Strategy -------- - and 1. Serialise every synced traitlet value to a plain JSON dict. +1. Serialise every synced traitlet value to a plain JSON dict. 2. Embed that dict and the widget's ``_esm`` source directly in the page. 3. Provide a minimal model shim (get/set/on/save_changes) so the ESM's render() function works without any Jupyter comm infrastructure. diff --git a/anyplotlib/figure.py b/anyplotlib/figure.py index eebc588a..50c05509 100644 --- a/anyplotlib/figure.py +++ b/anyplotlib/figure.py @@ -163,7 +163,6 @@ def _on_resize(self, change) -> None: @traitlets.observe("event_json") def _on_event(self, change) -> None: """Dispatch a JS interaction event to the relevant plot and widget callbacks.""" - print("_on_event:", change["new"]) raw = change["new"] if not raw or raw == "{}": return diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 99daa6a9..477a99dc 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -84,7 +84,7 @@ function render({ model, el }) { // ── outer DOM ──────────────────────────────────────────────────────────── const outerDiv = document.createElement('div'); - outerDiv.style.cssText = 'position:relative;display:inline-block;user-select:none;'; + outerDiv.style.cssText = 'position:relative;display:inline-block;user-select:none;z-index:1;isolation:isolate;'; el.appendChild(outerDiv); const gridDiv = document.createElement('div'); @@ -95,7 +95,7 @@ function render({ model, el }) { const resizeHandle = document.createElement('div'); resizeHandle.style.cssText = 'position:absolute;bottom:2px;right:2px;width:16px;height:16px;cursor:nwse-resize;' + - 'background:linear-gradient(135deg,transparent 50%,#888 50%);border-radius:0 0 4px 0;z-index:20;'; + 'background:linear-gradient(135deg,transparent 50%,#888 50%);border-radius:0 0 4px 0;z-index:100;'; resizeHandle.title = 'Drag to resize figure'; outerDiv.appendChild(resizeHandle); @@ -170,7 +170,7 @@ function render({ model, el }) { function _createPanelDOM(id, kind, pw, ph, spec) { const cell = document.createElement('div'); - cell.style.cssText = 'position:relative;overflow:visible;line-height:0;'; + cell.style.cssText = 'position:relative;overflow:visible;line-height:0;display:flex;justify-content:center;align-items:flex-start;'; cell.style.gridRow = `${spec.row_start+1} / ${spec.row_stop+1}`; cell.style.gridColumn = `${spec.col_start+1} / ${spec.col_stop+1}`; gridDiv.appendChild(cell); @@ -1240,14 +1240,14 @@ function render({ model, el }) { last_widget_id: p.lastWidgetId || null, mouse_x: p.mouseX, mouse_y: p.mouseY, }); - e.preventDefault(); return; + e.stopPropagation(); e.preventDefault(); return; } if (e.key.toLowerCase() === 'r') { p.state.azimuth = -60; p.state.elevation = 30; p.state.zoom = 1; draw3d(p); model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); model.save_changes(); - e.preventDefault(); + e.stopPropagation(); e.preventDefault(); } }); overlayCanvas.tabIndex = 0; @@ -1820,26 +1820,26 @@ function render({ model, el }) { img_x:imgX, img_y:imgY, phys_x:physX, phys_y:physY, }); - e.preventDefault(); return; + e.stopPropagation(); e.preventDefault(); return; } const key=e.key.toLowerCase(); if(key==='r'){ st.zoom=1; st.center_x=0.5; st.center_y=0.5; draw2d(p); model.set(`panel_${p.id}_json`,JSON.stringify(st)); model.save_changes(); - e.preventDefault(); + e.stopPropagation(); e.preventDefault(); } else if(key==='c'){ st.show_colorbar=!st.show_colorbar; draw2d(p); model.set(`panel_${p.id}_json`,JSON.stringify(st)); model.save_changes(); - e.preventDefault(); + e.stopPropagation(); e.preventDefault(); } else if(key==='l'){ st.scale_mode=st.scale_mode==='log'?'linear':'log'; draw2d(p); model.set(`panel_${p.id}_json`,JSON.stringify(st)); model.save_changes(); - e.preventDefault(); + e.stopPropagation(); e.preventDefault(); } else if(key==='s'){ st.scale_mode=st.scale_mode==='symlog'?'linear':'symlog'; draw2d(p); model.set(`panel_${p.id}_json`,JSON.stringify(st)); model.save_changes(); - e.preventDefault(); + e.stopPropagation(); e.preventDefault(); } }); overlayCanvas.addEventListener('mouseenter',()=>overlayCanvas.focus()); @@ -1933,9 +1933,9 @@ function render({ model, el }) { mouse_x:p.mouseX, mouse_y:p.mouseY, phys_x:physX, }); - e.preventDefault(); return; + e.stopPropagation(); e.preventDefault(); return; } - if(e.key.toLowerCase()==='r'){st.view_x0=0;st.view_x1=1;draw1d(p);model.set(`panel_${p.id}_json`,JSON.stringify(st));model.save_changes();e.preventDefault();} + if(e.key.toLowerCase()==='r'){st.view_x0=0;st.view_x1=1;draw1d(p);model.set(`panel_${p.id}_json`,JSON.stringify(st));model.save_changes();e.stopPropagation();e.preventDefault();} }); overlayCanvas.tabIndex=0;overlayCanvas.style.outline='none'; overlayCanvas.addEventListener('mouseenter',()=>overlayCanvas.focus()); @@ -2767,7 +2767,7 @@ function render({ model, el }) { last_widget_id: p.lastWidgetId || null, mouse_x: p.mouseX, mouse_y: p.mouseY, }); - e.preventDefault(); + e.stopPropagation(); e.preventDefault(); } }); overlayCanvas.tabIndex = 0; diff --git a/anyplotlib/figure_plots.py b/anyplotlib/figure_plots.py index 05a0846e..eb2d1c31 100644 --- a/anyplotlib/figure_plots.py +++ b/anyplotlib/figure_plots.py @@ -404,7 +404,7 @@ def _normalize_image(data: np.ndarray): "plasma": "fire", # warm sequential (dark→bright) "inferno": "kb", # dark→blue→white "magma": "kbc", # dark→blue→cyan sequential - "cividis": "dimgray", # accessible, low-chroma sequential + "cividis": "bgy", # accessible, blue→green→yellow sequential "hot": "fire", "afmhot": "fire", "jet": "rainbow4", @@ -805,21 +805,58 @@ def disconnect(self, cid: int) -> None: # ------------------------------------------------------------------ # View control # ------------------------------------------------------------------ - def set_view(self, x0: float | None = None, x1: float | None = None) -> None: + def set_view(self, + x0: float | None = None, x1: float | None = None, + y0: float | None = None, y1: float | None = None) -> None: + """Set the viewport to a data-space rectangle. + + Parameters + ---------- + x0, x1 : float, optional + Horizontal data-space range to show. If omitted the full + x-extent is used for zoom calculation. + y0, y1 : float, optional + Vertical data-space range to show. If omitted the full + y-extent is used for zoom calculation. + + Translates the requested rectangle into the ``zoom`` / ``center_x`` + / ``center_y`` state values used by the 2-D JS renderer. + """ xarr = np.asarray(self._state["x_axis"]) - if len(xarr) < 2: + yarr = np.asarray(self._state["y_axis"]) + if len(xarr) < 2 or len(yarr) < 2: return + xmin, xmax = float(xarr[0]), float(xarr[-1]) - span = xmax - xmin or 1.0 - f0 = 0.0 if x0 is None else max(0.0, min(1.0, (float(x0)-xmin)/span)) - f1 = 1.0 if x1 is None else max(0.0, min(1.0, (float(x1)-xmin)/span)) - self._state["view_x0"] = f0 - self._state["view_x1"] = f1 + ymin, ymax = float(yarr[0]), float(yarr[-1]) + x_span = xmax - xmin or 1.0 + y_span = ymax - ymin or 1.0 + + zoom_candidates = [] + + if x0 is not None and x1 is not None: + fx0 = max(0.0, min(1.0, (float(x0) - xmin) / x_span)) + fx1 = max(0.0, min(1.0, (float(x1) - xmin) / x_span)) + if fx1 > fx0: + self._state["center_x"] = (fx0 + fx1) / 2.0 + zoom_candidates.append(1.0 / (fx1 - fx0)) + + if y0 is not None and y1 is not None: + fy0 = max(0.0, min(1.0, (float(y0) - ymin) / y_span)) + fy1 = max(0.0, min(1.0, (float(y1) - ymin) / y_span)) + if fy1 > fy0: + self._state["center_y"] = (fy0 + fy1) / 2.0 + zoom_candidates.append(1.0 / (fy1 - fy0)) + + if zoom_candidates: + self._state["zoom"] = min(zoom_candidates) self._push() def reset_view(self) -> None: - self._state["view_x0"] = 0.0 - self._state["view_x1"] = 1.0 + """Reset pan and zoom to show the full image.""" + self._state["zoom"] = 1.0 + self._state["center_x"] = 0.5 + self._state["center_y"] = 0.5 self._push() # ------------------------------------------------------------------ @@ -833,8 +870,8 @@ def add_circles(self, offsets, name=None, *, radius=5, linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, labels=None, label=None) -> "MarkerGroup": # noqa: F821 - # On 1-D panels the native type is "points" (radius maps to sizes). - return self._add_marker("points", name, offsets=offsets, sizes=radius, + """Add circle markers at (x, y) positions in data coordinates.""" + return self._add_marker("circles", name, offsets=offsets, radius=radius, facecolors=facecolors, edgecolors=edgecolors, linewidths=linewidths, alpha=alpha, hover_edgecolors=hover_edgecolors, @@ -847,7 +884,7 @@ def add_points(self, offsets, name=None, *, sizes=5, hover_edgecolors=None, hover_facecolors=None, labels=None, label=None) -> "MarkerGroup": # noqa: F821 """Add point markers at (x, y) positions in data coordinates.""" - return self._add_marker("points", name, offsets=offsets, sizes=sizes, + return self._add_marker("circles", name, offsets=offsets, radius=sizes, edgecolors=color, facecolors=facecolors, linewidths=linewidths, alpha=alpha, hover_edgecolors=hover_edgecolors, @@ -965,6 +1002,12 @@ def list_markers(self) -> list: out.append({"type": mtype, "name": name, "n": g._count()}) return out + def __repr__(self) -> str: + w = self._state.get("image_width", "?") + h = self._state.get("image_height", "?") + cmap = self._state.get("colormap_name", "?") + return f"Plot2D({w}\u00d7{h}, cmap={cmap!r})" + # --------------------------------------------------------------------------- # PlotMesh (pcolormesh-style 2-D panel) @@ -1323,6 +1366,11 @@ def update(self, x, y, z) -> None: }) self._push() + def __repr__(self) -> str: + geom = self._state.get("geom_type", "?") + n = len(self._state.get("vertices", [])) + return f"Plot3D(geom={geom!r}, n_vertices={n})" + # --------------------------------------------------------------------------- # Plot1D @@ -1779,6 +1827,11 @@ def list_markers(self) -> list: out.append({"type": mtype, "name": name, "n": g._count()}) return out + def __repr__(self) -> str: + n = len(self._state.get("data", [])) + color = self._state.get("line_color", "?") + return f"Plot1D(n={n}, color={color!r})" + # --------------------------------------------------------------------------- # _bar_x_axis helper diff --git a/pyproject.toml b/pyproject.toml index 73e1ecc1..d702e14d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,9 +13,7 @@ requires-python = ">=3.10" dependencies = [ "anywidget>=0.9.0", "colorcet>=3.0", - "jupyterlab>=4.5.5", "numpy>=2.0.0", - "pytest>=9.0.2", "traitlets>=5.0.0", ] @@ -27,10 +25,14 @@ docs = [ "pillow>=10.0", "matplotlib>=3.7", ] +jupyter = [ + "jupyterlab>=4.5.5", +] [dependency-groups] dev = [ "playwright>=1.58.0", + "pytest>=9.0.2", ] diff --git a/tests/test_plot2d_polish.py b/tests/test_plot2d_polish.py new file mode 100644 index 00000000..7a264342 --- /dev/null +++ b/tests/test_plot2d_polish.py @@ -0,0 +1,273 @@ +""" +tests/test_plot2d_polish.py +=========================== + +Regression tests for the 0.1.0 pre-release bug-fix sweep: + + * Plot2D.add_circles / add_points use the correct "circles" marker type + * Plot2D.set_view writes zoom/center_x/center_y (not the non-existent view_x0) + * Plot2D.reset_view restores zoom=1, center_x=0.5, center_y=0.5 + * Plot2D.__repr__ returns a useful string + * Plot1D.__repr__ returns a useful string + * Plot3D.__repr__ returns a useful string + * cividis colormap alias resolves to a valid colorcet palette (not 'dimgray') + * Top-level imports: Plot1D, Plot2D, Axes, CallbackRegistry, Event + * No debug print in Figure._on_event +""" + +from __future__ import annotations + +import io +import sys +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.figure_plots import Plot1D, Plot2D, Plot3D, PlotBar +from anyplotlib.callbacks import CallbackRegistry, Event + + +# ───────────────────────────────────────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────────────────────────────────────── + +def _make_plot2d(shape=(32, 32)) -> Plot2D: + fig, ax = apl.subplots(1, 1) + return ax.imshow(np.zeros(shape)) + + +def _make_plot1d(n=64) -> Plot1D: + fig, ax = apl.subplots(1, 1) + return ax.plot(np.zeros(n)) + + +def _make_plot3d() -> Plot3D: + fig, ax = apl.subplots(1, 1) + x = np.linspace(0, 1, 4) + y = np.linspace(0, 1, 4) + X, Y = np.meshgrid(x, y) + Z = X + Y + return ax.plot_surface(X, Y, Z) + + +# ───────────────────────────────────────────────────────────────────────────── +# 1. add_circles on Plot2D — must use "circles" type, not "points" +# ───────────────────────────────────────────────────────────────────────────── + +def test_plot2d_add_circles_does_not_crash(): + """add_circles on a Plot2D must not raise ValueError ('points' absent from _KNOWN_2D).""" + plot = _make_plot2d() + offsets = np.array([[8.0, 8.0], [16.0, 16.0]]) + mg = plot.add_circles(offsets, name="g1", radius=3) + assert mg is not None + wire = plot.markers.to_wire_list() + assert len(wire) == 1 + assert wire[0]["type"] == "circles" + + +def test_plot2d_add_circles_radius_kwarg(): + """add_circles must pass radius, not sizes, to the wire format.""" + plot = _make_plot2d() + offsets = np.array([[4.0, 4.0]]) + mg = plot.add_circles(offsets, name="c1", radius=7) + wire = plot.markers.to_wire_list() + assert wire[0]["type"] == "circles" + # radius is embedded in the wire as 'sizes' by MarkerGroup.to_wire() + sizes = wire[0].get("sizes") + assert sizes is not None and all(s == 7.0 for s in sizes) + + +# ───────────────────────────────────────────────────────────────────────────── +# 2. add_points on Plot2D — must use "circles" type +# ───────────────────────────────────────────────────────────────────────────── + +def test_plot2d_add_points_does_not_crash(): + """add_points on a Plot2D must not raise ValueError.""" + plot = _make_plot2d() + offsets = np.array([[8.0, 8.0]]) + mg = plot.add_points(offsets, name="p1", sizes=5) + assert mg is not None + wire = plot.markers.to_wire_list() + assert wire[0]["type"] == "circles" + + +# ───────────────────────────────────────────────────────────────────────────── +# 3. Plot1D.add_circles still uses "points" (regression guard) +# ───────────────────────────────────────────────────────────────────────────── + +def test_plot1d_add_circles_still_uses_points(): + """Plot1D.add_circles should continue to use the 'points' type.""" + plot = _make_plot1d() + offsets = np.array([10.0, 20.0, 30.0]) + mg = plot.add_circles(offsets, name="ev") + wire = plot.markers.to_wire_list() + assert wire[0]["type"] == "points" + + +# ───────────────────────────────────────────────────────────────────────────── +# 4. Plot2D.set_view writes correct state keys +# ───────────────────────────────────────────────────────────────────────────── + +def test_plot2d_set_view_x_only(): + """set_view(x0, x1) must update center_x and zoom, not view_x0/view_x1.""" + data = np.zeros((32, 32)) + x_axis = np.linspace(0.0, 32.0, 32) + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(data, axes=[x_axis, None]) + + plot.set_view(x0=8.0, x1=24.0) + + # center_x should be midpoint fraction: (8+24)/2 / 32 = 0.5 + assert abs(plot._state["center_x"] - 0.5) < 1e-6 + # zoom_x = 32 / (24-8) = 2.0 + assert abs(plot._state["zoom"] - 2.0) < 1e-6 + # The wrong keys must NOT exist + assert "view_x0" not in plot._state + assert "view_x1" not in plot._state + + +def test_plot2d_set_view_y_only(): + """set_view(y0=..., y1=...) must update center_y and zoom.""" + data = np.zeros((32, 32)) + y_axis = np.linspace(0.0, 32.0, 32) + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(data, axes=[None, y_axis]) + + plot.set_view(y0=8.0, y1=24.0) + + assert abs(plot._state["center_y"] - 0.5) < 1e-6 + assert abs(plot._state["zoom"] - 2.0) < 1e-6 + + +def test_plot2d_set_view_xy(): + """set_view(x0, x1, y0, y1) uses minimum zoom when both axes given.""" + data = np.zeros((32, 64)) + x_axis = np.linspace(0.0, 64.0, 64) + y_axis = np.linspace(0.0, 32.0, 32) + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(data, axes=[x_axis, y_axis]) + + # Zoom half of x (zoom=2) and a quarter of y (zoom=4): min = 2 + plot.set_view(x0=0, x1=32, y0=0, y1=16) + + zoom_x = 64.0 / 32.0 # = 2.0 + zoom_y = 32.0 / 16.0 # = 2.0 + expected_zoom = min(zoom_x, zoom_y) + assert abs(plot._state["zoom"] - expected_zoom) < 1e-6 + + +# ───────────────────────────────────────────────────────────────────────────── +# 5. Plot2D.reset_view restores defaults +# ───────────────────────────────────────────────────────────────────────────── + +def test_plot2d_reset_view(): + """reset_view must restore zoom=1, center_x=0.5, center_y=0.5.""" + plot = _make_plot2d() + plot.set_view(x0=4, x1=28) # changes zoom & center_x + plot.reset_view() + + assert plot._state["zoom"] == 1.0 + assert plot._state["center_x"] == 0.5 + assert plot._state["center_y"] == 0.5 + assert "view_x0" not in plot._state + assert "view_x1" not in plot._state + + +# ───────────────────────────────────────────────────────────────────────────── +# 6. __repr__ methods +# ───────────────────────────────────────────────────────────────────────────── + +def test_plot2d_repr(): + plot = _make_plot2d((128, 256)) + r = repr(plot) + assert "Plot2D" in r + assert "256" in r # width + assert "128" in r # height + assert "gray" in r # default colormap + + +def test_plot1d_repr(): + plot = _make_plot1d(100) + r = repr(plot) + assert "Plot1D" in r + assert "100" in r + + +def test_plot3d_repr(): + plot = _make_plot3d() + r = repr(plot) + assert "Plot3D" in r + assert "surface" in r + + +def test_plotbar_repr(): + """PlotBar already had __repr__; make sure it still works.""" + fig, ax = apl.subplots(1, 1) + plot = ax.bar([1, 2, 3]) + r = repr(plot) + assert "PlotBar" in r + assert "3" in r + + +# ───────────────────────────────────────────────────────────────────────────── +# 7. cividis colormap alias resolves to a valid colorcet palette +# ───────────────────────────────────────────────────────────────────────────── + +def test_cividis_alias_resolves(): + """'cividis' must map to a real colorcet palette (not 'dimgray').""" + from anyplotlib.figure_plots import _build_colormap_lut, _CMAP_ALIASES + alias = _CMAP_ALIASES.get("cividis", "cividis") + assert alias != "dimgray", "cividis alias must not be a CSS colour name" + import colorcet as cc + assert alias in cc.palette, f"cividis alias '{alias}' not found in colorcet" + lut = _build_colormap_lut("cividis") + assert len(lut) == 256 + # Must not be a gray ramp (first and last entries must differ) + assert lut[0] != lut[-1], "cividis LUT should not be a flat gray ramp" + + +# ───────────────────────────────────────────────────────────────────────────── +# 8. Top-level public API imports +# ───────────────────────────────────────────────────────────────────────────── + +def test_top_level_imports(): + """Plot1D, Plot2D, Axes, CallbackRegistry, Event must all be importable.""" + from anyplotlib import Plot1D, Plot2D, Axes, CallbackRegistry, Event # noqa: F401 + assert Plot1D is not None + assert Plot2D is not None + assert Axes is not None + assert CallbackRegistry is not None + assert Event is not None + + +def test_top_level_all(): + """All names in __all__ must actually exist on the module.""" + import anyplotlib + for name in anyplotlib.__all__: + assert hasattr(anyplotlib, name), f"anyplotlib.{name} not found" + + +# ───────────────────────────────────────────────────────────────────────────── +# 9. No debug print in Figure._on_event +# ───────────────────────────────────────────────────────────────────────────── + +def test_no_debug_print_in_on_event(capsys): + """Figure._on_event must not print to stdout.""" + import json + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(16)) + + # Simulate a JS event (zoom change) + payload = { + "source": "js", + "panel_id": plot._id, + "event_type": "on_changed", + "zoom": 1.5, + "center_x": 0.5, + "center_y": 0.5, + } + fig._on_event({"new": json.dumps(payload)}) + + captured = capsys.readouterr() + assert captured.out == "", f"Unexpected stdout: {captured.out!r}" + From 1329e97c9d9be31b5548da800f30482a391d0be7 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 18 Mar 2026 12:55:06 -0500 Subject: [PATCH 023/198] Add detailed docstrings and examples for callbacks and figure modules; create new RST files for callbacks, figure plots, markers, and widgets --- README.md | 14 +++++++------- anyplotlib/callbacks.py | 29 +++++++++++++++++++++++++++++ anyplotlib/figure.py | 24 ++++++++++++++++++++++++ docs/api/callbacks.rst | 9 +++++++++ docs/api/figure_plots.rst | 9 +++++++++ docs/api/index.rst | 7 +++++-- docs/api/markers.rst | 9 +++++++++ docs/api/widgets.rst | 9 +++++++++ docs/conf.py | 10 +++------- docs/getting_started.rst | 10 +++++++--- docs/index.rst | 10 +++++----- 11 files changed, 116 insertions(+), 24 deletions(-) create mode 100644 docs/api/callbacks.rst create mode 100644 docs/api/figure_plots.rst create mode 100644 docs/api/markers.rst create mode 100644 docs/api/widgets.rst diff --git a/README.md b/README.md index ba9ec48e..22237ad0 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,7 @@ although the scope is intentionally limited in the following ways: no `plt.imshow` or `plt.plot` – instead, you create a Figure object and call methods on it to add data and customize the plot. This is a deliberate choice to avoid the pitfalls of the stateful API. -python -``` +```python import anyplotlib as apl import matplotlib.pyplot as plt @@ -43,10 +42,11 @@ environment that supports `AnyWidget`, including Jupyter notebooks, JupyterLab, the hood, `AnyWidget` uses a pure-JavaScript implementation of the widget protocol, which allows for fast rendering and interactivity. -**Disclaimer**: This project is in the early stages of development. Additionally many of the -javascript code was optimized using LLM's. That being said, the javascript/python code is fairly minimal, -and not too difficult to understand. +## Getting Started +Install from PyPI: +```bash +pip install anyplotlib +``` -**Disclaimer #2**: Mostly this project is to see __if__ something like this is possible, it remains to be -seen if this can be developed into a full-fledged plotting library. The hope is that this can be. +See the [documentation](https://cssfrancis.github.io/anyplotlib/) for more information. diff --git a/anyplotlib/callbacks.py b/anyplotlib/callbacks.py index 4dee08a2..78d5d0a7 100644 --- a/anyplotlib/callbacks.py +++ b/anyplotlib/callbacks.py @@ -1,3 +1,32 @@ +""" +callbacks.py +============ + +Lightweight two-class event system used by every plot object and widget. + +:class:`CallbackRegistry` + Per-object store of named callbacks. Every plot object and widget + exposes ``on_changed``, ``on_release``, ``on_click``, and ``on_key`` + decorator methods that connect handlers through this registry. + +:class:`Event` + Immutable data-carrier passed to every callback. All keys in the + raw JS payload are accessible as attributes (``event.zoom``, + ``event.cx``, etc.) in addition to the typed ``event_type``, + ``source``, and ``data`` fields. + +Example +------- +.. code-block:: python + + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(data) + + @plot.on_release + def on_settle(event): + print(f"zoom={event.zoom:.2f} center=({event.center_x:.3f}, {event.center_y:.3f})") +""" + from __future__ import annotations from dataclasses import dataclass, field from typing import Any, Callable diff --git a/anyplotlib/figure.py b/anyplotlib/figure.py index 50c05509..1bfc1c51 100644 --- a/anyplotlib/figure.py +++ b/anyplotlib/figure.py @@ -1,3 +1,27 @@ +""" +figure.py +========= + +Top-level :class:`Figure` widget and the :func:`subplots` factory. + +``Figure`` is the only ``anywidget.AnyWidget`` subclass in anyplotlib. +It owns all traitlets and acts as the Python ↔ JavaScript bridge. +Use :func:`subplots` (the recommended entry-point) or construct a +``Figure`` directly and call :meth:`Figure.add_subplot` to attach data. + +Example +------- +.. code-block:: python + + import numpy as np + import anyplotlib as apl + + fig, axs = apl.subplots(1, 2, figsize=(800, 400)) + axs[0].imshow(np.random.standard_normal((128, 128))) + axs[1].plot(np.sin(np.linspace(0, 6.28, 256))) + fig +""" + from __future__ import annotations import json, pathlib import anywidget, numpy as np, traitlets diff --git a/docs/api/callbacks.rst b/docs/api/callbacks.rst new file mode 100644 index 00000000..ce16027a --- /dev/null +++ b/docs/api/callbacks.rst @@ -0,0 +1,9 @@ +Callbacks +========= + +.. automodule:: anyplotlib.callbacks + :members: + :undoc-members: False + :show-inheritance: + :member-order: bysource + diff --git a/docs/api/figure_plots.rst b/docs/api/figure_plots.rst new file mode 100644 index 00000000..417224b1 --- /dev/null +++ b/docs/api/figure_plots.rst @@ -0,0 +1,9 @@ +Figure Plots +============ + +.. automodule:: anyplotlib.figure_plots + :members: + :undoc-members: False + :show-inheritance: + :member-order: bysource + diff --git a/docs/api/index.rst b/docs/api/index.rst index 6b26f6b4..39cf4122 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -2,7 +2,10 @@ API Reference ============= .. toctree:: - :maxdepth: 1 + :maxdepth: 2 figure - + figure_plots + markers + widgets + callbacks diff --git a/docs/api/markers.rst b/docs/api/markers.rst new file mode 100644 index 00000000..ae74c990 --- /dev/null +++ b/docs/api/markers.rst @@ -0,0 +1,9 @@ +Markers +======= + +.. automodule:: anyplotlib.markers + :members: + :undoc-members: False + :show-inheritance: + :member-order: bysource + diff --git a/docs/api/widgets.rst b/docs/api/widgets.rst new file mode 100644 index 00000000..2b3c7982 --- /dev/null +++ b/docs/api/widgets.rst @@ -0,0 +1,9 @@ +Widgets +======= + +.. automodule:: anyplotlib.widgets + :members: + :undoc-members: False + :show-inheritance: + :member-order: bysource + diff --git a/docs/conf.py b/docs/conf.py index acd99ed3..5e4b232b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -86,14 +86,10 @@ "logo": { "image_light": "_static/anyplotlib.svg", "image_dark": "_static/anyplotlib.svg", - "text": "anyplotlib", + "text": "anyplotlib" }, - "switcher": { - "json_url": f"{_base}switcher.json", - "version_match": _docs_version, - }, - "navbar_end": ["version-switcher", "navbar-icon-links"], - "show_toc_level": 2, + "navbar_end": ["navbar-icon-links"], + "show_toc_level": 2 } # -- autodoc options --------------------------------------------------------- diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 533d1090..7284dbf1 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -4,11 +4,15 @@ Getting Started Installation ------------ -Clone the repository and install with ``uv`` (or pip):: +Install via pip from PyPI (recommended):: - git clone https://github.com/your-org/anyplotlib.git + pip install anyplotlib + +Or clone the repository and install from source:: + + git clone https://github.com/CSSFrancis/anyplotlib.git cd anyplotlib - uv sync # installs the project + all dependencies + uv sync # or `pip install -e .` Quick start ----------- diff --git a/docs/index.rst b/docs/index.rst index 2a7c79c0..a638a683 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -50,12 +50,12 @@ although the scope is intentionally limited in the following ways: the hood, ``AnyWidget`` uses a pure-JavaScript implementation of the widget protocol, which allows for fast rendering and interactivity. -**Disclaimer**: This project is in the early stages of development. Additionally many of the -JavaScript code was optimised using LLMs. That being said, the JavaScript/Python code is fairly minimal -and not too difficult to understand. +**Status**: anyplotlib v0.1.0 provides a lightweight, interactive alternative to matplotlib's +pyplot interface for Jupyter notebooks and compatible environments. Performance is optimized for +real-time interactivity with large datasets on canvas-based rendering. -**Disclaimer #2**: Mostly this project is to see *if* something like this is possible; it remains to be -seen if this can be developed into a full-fledged plotting library. The hope is that it can. +.. toctree:: + :hidden: * :ref:`genindex` * :ref:`modindex` From 0df9e8c2a6dfb3d6874b0fb45439c4ebb5e8b4d0 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 18 Mar 2026 13:14:42 -0500 Subject: [PATCH 024/198] Add doc strings for figure_plots.py --- anyplotlib/figure_plots.py | 1203 ++++++++++++------------------------ 1 file changed, 408 insertions(+), 795 deletions(-) diff --git a/anyplotlib/figure_plots.py b/anyplotlib/figure_plots.py index eb2d1c31..8c267675 100644 --- a/anyplotlib/figure_plots.py +++ b/anyplotlib/figure_plots.py @@ -42,7 +42,21 @@ # --------------------------------------------------------------------------- class SubplotSpec: - """Describes which grid cells a subplot occupies.""" + """Describes which grid cells a subplot occupies. + + Parameters + ---------- + gs : GridSpec + Parent GridSpec instance. + row_start : int + Starting row index (0-based). + row_stop : int + Ending row index (exclusive). + col_start : int + Starting column index (0-based). + col_stop : int + Ending column index (exclusive). + """ def __init__(self, gs: "GridSpec", row_start: int, row_stop: int, col_start: int, col_stop: int): @@ -140,9 +154,23 @@ def __repr__(self) -> str: class Axes: """A single grid cell in a Figure. - Returned by Figure.add_subplot() and Figure.subplots(). - Call .imshow() or .plot() to attach a data plot and get back - a Plot2D or Plot1D object. + Represents a single subplot cell within a grid layout. Use plotting methods + like :meth:`imshow`, :meth:`plot`, :meth:`bar`, etc. to attach visualization + to this axes. + + Returned by :func:`Figure.add_subplot` and :func:`Figure.subplots`. + + Parameters + ---------- + fig : Figure + Parent Figure instance. + spec : SubplotSpec + Layout specification (row/column spans). + + Notes + ----- + Each Axes can hold at most one plot object at a time. Calling another plot + method replaces the previous one. """ def __init__(self, fig: "Figure", spec: SubplotSpec): # noqa: F821 @@ -486,16 +514,31 @@ def _resample_mesh(data: np.ndarray, x_edges, y_edges) -> np.ndarray: class Plot2D: """2-D image plot panel. - Not an anywidget. Holds state in ``_state`` dict; every mutation calls + Not an anywidget. Holds state in ``_state`` dict; every mutation calls ``_push()`` which writes to the parent Figure's panel trait. - The marker API follows matplotlib conventions: + The marker API follows matplotlib conventions:: + plot.add_circles(offsets, name="g1", facecolors="#f00", radius=5) - plot.markers["circles"]["g1"].set(radius=8) + plot.markers["circles"]["g1"].set(radius=8) # live update + + Supports interactive 2-D draggable overlays (widgets) via :meth:`add_widget`. + + Parameters + ---------- + data : ndarray, shape (H, W) or (H, W, C) + Image data. If 3-D, only the first channel is used. + x_axis : array-like, optional + X-axis physical coordinates. Length must equal W (width). + y_axis : array-like, optional + Y-axis physical coordinates. Length must equal H (height). + units : str, optional + Label for the axes. Default ``"px"``. """ def __init__(self, data: np.ndarray, x_axis=None, y_axis=None, units: str = "px"): + #...existing code... self._id: str = "" # assigned by Axes._attach self._fig: object = None # assigned by Axes._attach @@ -589,7 +632,24 @@ def to_state_dict(self) -> dict: # ------------------------------------------------------------------ def update(self, data: np.ndarray, x_axis=None, y_axis=None, units: str | None = None) -> None: - """Replace the image data.""" + """Replace the image data. + + Parameters + ---------- + data : ndarray, shape (H, W) or (H, W, C) + New image data. 3-D arrays use only the first channel. + x_axis : array-like, optional + New X-axis coordinates. Must match the new image width. + y_axis : array-like, optional + New Y-axis coordinates. Must match the new image height. + units : str, optional + Update the axes label. If not provided, keeps the current value. + + Raises + ------ + ValueError + If data is not 2-D. + """ data = np.asarray(data) if data.ndim == 3: data = data[:, :, 0] @@ -626,11 +686,44 @@ def update(self, data: np.ndarray, # Display settings # ------------------------------------------------------------------ def set_colormap(self, name: str) -> None: + """Set or update the colormap. + + Parameters + ---------- + name : str + Matplotlib-style colormap name. Common names include: + "viridis", "plasma", "inferno", "magma", "cividis", + "hot", "jet", "RdBu", "coolwarm", etc. + Aliases to colorcet palettes are used internally for + colormap independence. + + Notes + ----- + The colormap is applied to surface Z-values (in Plot3D) or + to intensity values (in Plot2D when used with set_clim). + """ self._state["colormap_name"] = name self._state["colormap_data"] = _build_colormap_lut(name) self._push() def set_clim(self, vmin=None, vmax=None) -> None: + """Set the data range for display normalization. + + Parameters + ---------- + vmin : float, optional + Minimum data value to map to the colormap. If not provided, + uses the current minimum. + vmax : float, optional + Maximum data value to map to the colormap. If not provided, + uses the current maximum. + + Notes + ----- + This controls the color range display without modifying the + underlying data. Useful for emphasizing features in a specific + intensity range. + """ if vmin is not None: self._state["display_min"] = float(vmin) if vmax is not None: @@ -638,6 +731,21 @@ def set_clim(self, vmin=None, vmax=None) -> None: self._push() def set_scale_mode(self, mode: str) -> None: + """Set the axis scale mode (linear, logarithmic, or symmetric log). + + Parameters + ---------- + mode : str + One of ``"linear"``, ``"log"``, or ``"symlog"``. + - ``"linear"``: standard linear scale. + - ``"log"``: logarithmic scale (data must be positive). + - ``"symlog"``: symmetric logarithmic scale (allows negative values). + + Raises + ------ + ValueError + If *mode* is not one of the valid options. + """ valid = ("linear", "log", "symlog") if mode not in valid: raise ValueError(f"mode must be one of {valid}") @@ -656,6 +764,40 @@ def colormap_name(self, name: str) -> None: # Overlay Widgets # ------------------------------------------------------------------ def add_widget(self, kind: str, color: str = "#00e5ff", **kwargs) -> Widget: + """Add an interactive overlay widget to this plot. + + Parameters + ---------- + kind : str + Widget type: ``"circle"``, ``"rectangle"``, ``"annular"``, + ``"polygon"``, ``"label"``, or ``"crosshair"``. + color : str, optional + CSS colour for the widget outline/fill. Default ``"#00e5ff"``. + **kwargs : dict + Type-specific parameters: + - circle: cx, cy, r (center x, y and radius) + - rectangle: x, y, w, h (top-left corner and dimensions) + - annular: cx, cy, r_outer, r_inner (center and radii) + - polygon: vertices (list of [x, y] coordinates) + - crosshair: cx, cy (center position) + - label: x, y, text, fontsize (position, text, font size in pts) + + Returns + ------- + Widget + The created widget object. Register callbacks via + ``@widget.on_changed`` or ``@widget.on_release``. + + Raises + ------ + ValueError + If *kind* is not recognized. + + Examples + -------- + >>> plot.add_widget("circle", cx=100, cy=100, r=50, color="#ff0000") + >>> plot.add_widget("crosshair", cx=64, cy=64) + """ kind = kind.lower() valid = ("circle", "rectangle", "annular", "polygon", "label", "crosshair") if kind not in valid: @@ -738,13 +880,46 @@ def clear_widgets(self) -> None: # Callback API (Plot2D) # ------------------------------------------------------------------ def on_changed(self, fn: Callable) -> Callable: - """Decorator: fires on every pan/zoom/drag frame on this panel.""" + """Decorator: fires on every pan/zoom/drag frame on this panel. + + Use this for high-frequency updates (e.g., live readout). Keep the + handler fast to avoid blocking the UI. + + Parameters + ---------- + fn : Callable + Handler function receiving an Event with zoom, center_x, center_y. + + Returns + ------- + Callable + The decorated function. + + Examples + -------- + >>> @plot.on_changed + ... def update_readout(event): + ... print(f"zoom={event.zoom:.2f}") + """ cid = self.callbacks.connect("on_changed", fn) fn._cid = cid return fn def on_release(self, fn: Callable) -> Callable: - """Decorator: fires once when pan/zoom/drag settles on this panel.""" + """Decorator: fires once when pan/zoom/drag settles on this panel. + + Use this for expensive operations (e.g., recomputation). + + Parameters + ---------- + fn : Callable + Handler function receiving an Event with final zoom/position. + + Returns + ------- + Callable + The decorated function. + """ cid = self.callbacks.connect("on_release", fn) fn._cid = cid return fn @@ -1003,550 +1178,218 @@ def list_markers(self) -> list: return out def __repr__(self) -> str: - w = self._state.get("image_width", "?") - h = self._state.get("image_height", "?") - cmap = self._state.get("colormap_name", "?") - return f"Plot2D({w}\u00d7{h}, cmap={cmap!r})" + n = len(self._state.get("data", [])) + color = self._state.get("line_color", "?") + return f"Plot1D(n={n}, color={color!r})" # --------------------------------------------------------------------------- -# PlotMesh (pcolormesh-style 2-D panel) +# _bar_x_axis helper # --------------------------------------------------------------------------- -class PlotMesh(Plot2D): - """2-D mesh plot panel created by :meth:`Axes.pcolormesh`. +def _bar_x_axis(x_centers: np.ndarray) -> list: + """Return a 2-element [x_left_edge, x_right_edge] list for a bar chart. - Accepts cell *edge* arrays (length N+1 / M+1) rather than centre arrays, - matches matplotlib's ``pcolormesh`` convention. Only ``'circles'`` and - ``'lines'`` markers are supported. + The edges are half a slot-width outside the first/last bar centre so that + a vline_widget at ``x_centers[i]`` renders at exactly the bar's centre + pixel when used with ``_xToFrac1d`` / ``_fracToPx1d`` in the JS renderer. """ - - def __init__(self, data: np.ndarray, - x_edges=None, y_edges=None, units: str = ""): - data = np.asarray(data) - if data.ndim != 2: - raise ValueError(f"data must be 2-D (M x N), got {data.shape}") - rows, cols = data.shape - - if x_edges is None: - x_edges = np.arange(cols + 1, dtype=float) - if y_edges is None: - y_edges = np.arange(rows + 1, dtype=float) - x_edges = np.asarray(x_edges, dtype=float) - y_edges = np.asarray(y_edges, dtype=float) - - if len(x_edges) != cols + 1: - raise ValueError( - f"x_edges must have length {cols + 1} for {cols} columns, " - f"got {len(x_edges)}") - if len(y_edges) != rows + 1: - raise ValueError( - f"y_edges must have length {rows + 1} for {rows} rows, " - f"got {len(y_edges)}") - - # Resample to a regular pixel grid for display - resampled = _resample_mesh(data, x_edges, y_edges) - - # Use cell centres to initialise the parent (axes will be replaced) - x_c = (x_edges[:-1] + x_edges[1:]) / 2.0 - y_c = (y_edges[:-1] + y_edges[1:]) / 2.0 - super().__init__(resampled, x_axis=x_c, y_axis=y_c, units=units) - - # Override mesh-specific state - self._state["is_mesh"] = True - self._state["has_axes"] = True - # Store edges (not centres) so the JS renderer can place grid lines - self._state["x_axis"] = x_edges.tolist() - self._state["y_axis"] = y_edges.tolist() - # Mesh panels have no fixed pixel scale - self._state.pop("scale_x", None) - self._state.pop("scale_y", None) - - # Restrict markers to circles + lines only - self.markers = MarkerRegistry(self._push_markers, - allowed=MarkerRegistry._KNOWN_MESH) - - # ------------------------------------------------------------------ - # Data update - # ------------------------------------------------------------------ - def update(self, data: np.ndarray, - x_edges=None, y_edges=None, units: str | None = None) -> None: - """Replace the mesh data (and optionally the edge arrays).""" - data = np.asarray(data) - if data.ndim != 2: - raise ValueError(f"data must be 2-D, got {data.shape}") - rows, cols = data.shape - - cur_xe = np.asarray(self._state["x_axis"], dtype=float) - cur_ye = np.asarray(self._state["y_axis"], dtype=float) - xe = np.asarray(x_edges, dtype=float) if x_edges is not None else cur_xe - ye = np.asarray(y_edges, dtype=float) if y_edges is not None else cur_ye - - if len(xe) != cols + 1: - raise ValueError(f"x_edges must have length {cols + 1}") - if len(ye) != rows + 1: - raise ValueError(f"y_edges must have length {rows + 1}") - - resampled = _resample_mesh(data, xe, ye) - img_u8, vmin, vmax = _normalize_image(resampled) - self._raw_u8, self._raw_vmin, self._raw_vmax = img_u8, vmin, vmax - - self._state.update({ - "image_b64": self._encode_bytes(img_u8), - "image_width": cols, - "image_height": rows, - "x_axis": xe.tolist(), - "y_axis": ye.tolist(), - "display_min": vmin, - "display_max": vmax, - "raw_min": vmin, - "raw_max": vmax, - "colormap_data": _build_colormap_lut(self._state["colormap_name"]), - }) - if units is not None: - self._state["units"] = units - self._push() + n = len(x_centers) + if n == 0: + return [0.0, 1.0] + if n == 1: + return [float(x_centers[0]) - 0.5, float(x_centers[0]) + 0.5] + slot = (float(x_centers[-1]) - float(x_centers[0])) / (n - 1) + half = slot / 2.0 + return [float(x_centers[0]) - half, float(x_centers[-1]) + half] # --------------------------------------------------------------------------- -# _triangulate_grid helper + Plot3D +# PlotBar # --------------------------------------------------------------------------- -def _triangulate_grid(rows: int, cols: int) -> list: - """Return a flat list of [i0, i1, i2] triangle indices for an (rows×cols) grid.""" - faces = [] - for r in range(rows - 1): - for c in range(cols - 1): - i = r * cols + c - faces.append([i, i + 1, i + cols]) - faces.append([i + 1, i + cols + 1, i + cols]) - return faces - - -class Plot3D: - """3-D plot panel. - - Supports three geometry types matching matplotlib's 3-D Axes API: +class PlotBar: + """Bar-chart plot panel. - * ``'surface'`` – triangulated surface, Z-coloured via colormap. - * ``'scatter'`` – point cloud, single colour. - * ``'line'`` – connected line through 3-D points. + Not an anywidget. Holds state in ``_state`` dict; every mutation calls + ``_push()`` which writes to the parent Figure's panel trait. - Created by :meth:`Axes.plot_surface`, :meth:`Axes.scatter3d`, - and :meth:`Axes.plot3d`. + Supports draggable :class:`~anyplotlib.widgets.VLineWidget` and + :class:`~anyplotlib.widgets.HLineWidget` overlays via + :meth:`add_vline_widget` / :meth:`add_hline_widget`. - Not an anywidget. Holds state in ``_state`` dict; every mutation - calls ``_push()`` which writes to the parent Figure's panel trait. + Created by :meth:`Axes.bar`. """ - def __init__(self, geom_type: str, - x, y, z, *, - colormap: str = "viridis", + def __init__(self, values, + x_labels=None, + x_centers=None, color: str = "#4fc3f7", - point_size: float = 4.0, - linewidth: float = 1.5, - x_label: str = "x", - y_label: str = "y", - z_label: str = "z", - azimuth: float = -60.0, - elevation: float = 30.0, - zoom: float = 1.0): + colors=None, + bar_width: float = 0.7, + orient: str = "v", + baseline: float = 0.0, + show_values: bool = False, + units: str = "", + y_units: str = ""): self._id: str = "" self._fig: object = None - geom_type = geom_type.lower() - if geom_type not in ("surface", "scatter", "line"): - raise ValueError("geom_type must be 'surface', 'scatter', or 'line'") - - x = np.asarray(x, dtype=float) - y = np.asarray(y, dtype=float) - z = np.asarray(z, dtype=float) - - if geom_type == "surface": - # Accept 2-D grid arrays (meshgrid style) or 1-D flat arrays - if x.ndim == 2 and y.ndim == 2 and z.ndim == 2: - rows, cols = z.shape - xf, yf, zf = x.ravel(), y.ravel(), z.ravel() - elif x.ndim == 1 and y.ndim == 1 and z.ndim == 2: - rows, cols = z.shape - if len(x) != cols or len(y) != rows: - raise ValueError( - "For surface with 1-D x/y: x must have length ncols " - "and y must have length nrows") - XX, YY = np.meshgrid(x, y) - xf, yf, zf = XX.ravel(), YY.ravel(), z.ravel() - else: - raise ValueError( - "Surface x/y/z must be 2-D grids of the same shape, " - "or 1-D x/y centre arrays with 2-D z.") - faces = _triangulate_grid(rows, cols) - vertices = np.column_stack([xf, yf, zf]).tolist() - z_values = zf.tolist() - else: - if x.ndim != 1 or y.ndim != 1 or z.ndim != 1: - raise ValueError("scatter/line x, y, z must be 1-D arrays") - if not (len(x) == len(y) == len(z)): - raise ValueError("x, y, z must have the same length") - vertices = np.column_stack([x, y, z]).tolist() - faces = [] - z_values = z.tolist() - - # Normalised data bounds for the JS renderer - all_x = np.asarray([v[0] for v in vertices]) - all_y = np.asarray([v[1] for v in vertices]) - all_z = np.asarray([v[2] for v in vertices]) - data_bounds = { - "xmin": float(all_x.min()), "xmax": float(all_x.max()), - "ymin": float(all_y.min()), "ymax": float(all_y.max()), - "zmin": float(all_z.min()), "zmax": float(all_z.max()), - } + values = np.asarray(values, dtype=float) + n = len(values) + if values.ndim != 1: + raise ValueError(f"values must be 1-D, got shape {values.shape}") + if orient not in ("v", "h"): + raise ValueError("orient must be 'v' or 'h'") + + if x_centers is None: + x_centers = np.arange(n, dtype=float) + x_centers = np.asarray(x_centers, dtype=float) + if len(x_centers) != n: + raise ValueError("x_centers length must match values length") + + val_min = float(np.nanmin(values)) if n else 0.0 + val_max = float(np.nanmax(values)) if n else 1.0 + dmin = min(float(baseline), val_min) + dmax = max(float(baseline), val_max) + pad = (dmax - dmin) * 0.07 if dmax > dmin else 0.5 + dmax += pad + if dmin < float(baseline): + dmin -= pad - cmap_lut = _build_colormap_lut(colormap) + # Compute physical x-axis extent (left/right edges of the bar chart) + # so that vline_widgets map to the correct pixel positions. + x_axis = _bar_x_axis(x_centers) self._state: dict = { - "kind": "3d", - "geom_type": geom_type, - "vertices": vertices, - "faces": faces, - "z_values": z_values, - "colormap_name": colormap, - "colormap_data": cmap_lut, - "color": color, - "point_size": float(point_size), - "linewidth": float(linewidth), - "x_label": x_label, - "y_label": y_label, - "z_label": z_label, - "azimuth": float(azimuth), - "elevation": float(elevation), - "zoom": float(zoom), - "data_bounds": data_bounds, + "kind": "bar", + "values": values.tolist(), + "x_centers": x_centers.tolist(), + "x_labels": list(x_labels) if x_labels is not None else [], + "bar_color": color, + "bar_colors": list(colors) if colors is not None else [], + "bar_width": float(bar_width), + "orient": orient, + "baseline": float(baseline), + "show_values": bool(show_values), + "data_min": dmin, + "data_max": dmax, + "units": units, + "y_units": y_units, + # overlay-widget coordinate system (mirrors Plot1D) + "x_axis": x_axis, + "view_x0": 0.0, + "view_x1": 1.0, + "overlay_widgets": [], "registered_keys": [], } self.callbacks = CallbackRegistry() + self._widgets: dict[str, Widget] = {} # ------------------------------------------------------------------ def _push(self) -> None: if self._fig is None: return + self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] self._fig._push(self._id) def to_state_dict(self) -> dict: - return dict(self._state) + d = dict(self._state) + d["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] + return d # ------------------------------------------------------------------ - # Callback API (Plot3D) + # Data update # ------------------------------------------------------------------ - def on_changed(self, fn: Callable) -> Callable: - """Decorator: fires on every rotation/zoom frame.""" - cid = self.callbacks.connect("on_changed", fn) - fn._cid = cid - return fn + def update(self, values, x_centers=None, x_labels=None) -> None: + """Replace bar values; recalculates the value-axis range automatically.""" + values = np.asarray(values, dtype=float) + if values.ndim != 1: + raise ValueError(f"values must be 1-D, got shape {values.shape}") - def on_release(self, fn: Callable) -> Callable: - """Decorator: fires once when rotation/zoom settles.""" - cid = self.callbacks.connect("on_release", fn) - fn._cid = cid - return fn + baseline = self._state["baseline"] + dmin = min(float(baseline), float(np.nanmin(values))) + dmax = max(float(baseline), float(np.nanmax(values))) + pad = (dmax - dmin) * 0.07 if dmax > dmin else 0.5 + dmax += pad + if dmin < baseline: + dmin -= pad - def on_click(self, fn: Callable) -> Callable: - """Decorator: fires on click on this panel.""" - cid = self.callbacks.connect("on_click", fn) - fn._cid = cid - return fn + self._state["values"] = values.tolist() + self._state["data_min"] = dmin + self._state["data_max"] = dmax + if x_centers is not None: + xc = np.asarray(x_centers, dtype=float) + self._state["x_centers"] = xc.tolist() + self._state["x_axis"] = _bar_x_axis(xc) + if x_labels is not None: + self._state["x_labels"] = list(x_labels) + self._push() - def on_key(self, key_or_fn=None) -> Callable: - """Register a key-press handler for this panel. + # ------------------------------------------------------------------ + # Display settings + # ------------------------------------------------------------------ + def set_color(self, color: str) -> None: + """Set a single colour for all bars.""" + self._state["bar_color"] = color + self._push() - Two call forms are supported:: + def set_colors(self, colors) -> None: + """Set per-bar colours (list of CSS colour strings, length N).""" + self._state["bar_colors"] = list(colors) + self._push() - @plot.on_key('q') # fires only when 'q' is pressed - def handler(event): ... + def set_show_values(self, show: bool) -> None: + """Show or hide in-bar value annotations.""" + self._state["show_values"] = bool(show) + self._push() - @plot.on_key # fires for every registered key - def handler(event): ... + # ------------------------------------------------------------------ + # Overlay Widgets + # ------------------------------------------------------------------ + def add_vline_widget(self, x: float, color: str = "#00e5ff") -> _VLineWidget: + """Add a draggable vertical line widget at x position. - The event carries: ``key``, ``mouse_x``, ``mouse_y``, and - ``last_widget_id``. + Parameters + ---------- + x : float + Initial x-coordinate (in data units). + color : str, optional + CSS colour for the line. Default ``"#00e5ff"``. - .. note:: - Registered keys take priority over the built-in **r** (reset view) - shortcut. + Returns + ------- + VLineWidget + The widget. Register callbacks via ``@widget.on_changed`` + or ``@widget.on_release``. """ - if callable(key_or_fn): - return self._connect_on_key(None, key_or_fn) - key = key_or_fn - def _decorator(fn): - return self._connect_on_key(key, fn) - return _decorator + widget = _VLineWidget(lambda: None, x=float(x), color=color) + plot_ref, wid_id = self, widget._id + def _tp(): + if plot_ref._fig is not None: + fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} + plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) + widget._push_fn = _tp + self._widgets[widget.id] = widget + self._push() + return widget - def _connect_on_key(self, key, fn) -> Callable: - if key is None: - if '*' not in self._state['registered_keys']: - self._state['registered_keys'].append('*') - self._push() - cid = self.callbacks.connect("on_key", fn) - else: - if key not in self._state['registered_keys']: - self._state['registered_keys'].append(key) - self._push() - def _wrapped(event): - if event.data.get('key') == key: - fn(event) - cid = self.callbacks.connect("on_key", _wrapped) - _wrapped._cid = cid - fn._cid = cid - return fn - - def disconnect(self, cid: int) -> None: - """Remove the callback registered under integer *cid*.""" - self.callbacks.disconnect(cid) - - # ------------------------------------------------------------------ - # Display settings - # ------------------------------------------------------------------ - def set_colormap(self, name: str) -> None: - """Set the surface colormap (ignored for scatter/line).""" - self._state["colormap_name"] = name - self._state["colormap_data"] = _build_colormap_lut(name) - self._push() - - def set_view(self, azimuth: float | None = None, - elevation: float | None = None) -> None: - """Set the camera azimuth (°) and/or elevation (°).""" - if azimuth is not None: self._state["azimuth"] = float(azimuth) - if elevation is not None: self._state["elevation"] = float(elevation) - self._push() - - def set_zoom(self, zoom: float) -> None: - self._state["zoom"] = float(zoom) - self._push() - - def update(self, x, y, z) -> None: - """Replace the geometry data.""" - # Re-run the same logic as __init__ for the stored geom_type - geom_type = self._state["geom_type"] - x = np.asarray(x, dtype=float) - y = np.asarray(y, dtype=float) - z = np.asarray(z, dtype=float) - - if geom_type == "surface": - if x.ndim == 2 and y.ndim == 2 and z.ndim == 2: - rows, cols = z.shape - xf, yf, zf = x.ravel(), y.ravel(), z.ravel() - elif x.ndim == 1 and y.ndim == 1 and z.ndim == 2: - rows, cols = z.shape - XX, YY = np.meshgrid(x, y) - xf, yf, zf = XX.ravel(), YY.ravel(), z.ravel() - else: - raise ValueError("Surface x/y/z must be 2-D grids or 1-D+2-D.") - faces = _triangulate_grid(rows, cols) - vertices = np.column_stack([xf, yf, zf]).tolist() - z_values = zf.tolist() - else: - vertices = np.column_stack([x.ravel(), y.ravel(), z.ravel()]).tolist() - faces = [] - z_values = z.ravel().tolist() - - all_x = np.asarray([v[0] for v in vertices]) - all_y = np.asarray([v[1] for v in vertices]) - all_z = np.asarray([v[2] for v in vertices]) - data_bounds = { - "xmin": float(all_x.min()), "xmax": float(all_x.max()), - "ymin": float(all_y.min()), "ymax": float(all_y.max()), - "zmin": float(all_z.min()), "zmax": float(all_z.max()), - } - - self._state.update({ - "vertices": vertices, - "faces": faces, - "z_values": z_values, - "data_bounds": data_bounds, - "colormap_data": _build_colormap_lut(self._state["colormap_name"]), - }) - self._push() - - def __repr__(self) -> str: - geom = self._state.get("geom_type", "?") - n = len(self._state.get("vertices", [])) - return f"Plot3D(geom={geom!r}, n_vertices={n})" - - -# --------------------------------------------------------------------------- -# Plot1D -# --------------------------------------------------------------------------- - -class Plot1D: - """1-D line plot panel. - - Holds state in ``_state`` dict; every mutation pushes to Figure trait. - Exposes the full Viewer1D-compatible API plus the new marker API. - """ - - def __init__(self, data: np.ndarray, - x_axis=None, - units: str = "px", - y_units: str = "", - color: str = "#4fc3f7", - linewidth: float = 1.5, - label: str = ""): - self._id: str = "" - self._fig: object = None - - data = np.asarray(data, dtype=float) - if data.ndim != 1: - raise ValueError(f"data must be 1-D, got {data.shape}") - n = len(data) - if x_axis is None: - x_axis = np.arange(n, dtype=float) - x_axis = np.asarray(x_axis, dtype=float) - if len(x_axis) != n: - raise ValueError("x_axis length must match data length") - - dmin = float(np.nanmin(data)) - dmax = float(np.nanmax(data)) - pad = (dmax - dmin) * 0.05 if dmax > dmin else 0.5 - dmin -= pad; dmax += pad - - self._state: dict = { - "kind": "1d", - "data": data.tolist(), - "x_axis": x_axis.tolist(), - "units": units, - "y_units": y_units, - "data_min": dmin, - "data_max": dmax, - "view_x0": 0.0, - "view_x1": 1.0, - "line_color": color, - "line_linewidth": float(linewidth), - "line_label": label, - "extra_lines": [], - "spans": [], - "overlay_widgets": [], - "markers": [], - "registered_keys": [], - } - - self.markers = MarkerRegistry(self._push_markers, - allowed=MarkerRegistry._KNOWN_1D) - self.callbacks = CallbackRegistry() - self._widgets: dict[str, Widget] = {} - - def _push(self) -> None: - if self._fig is None: - return - self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] - self._fig._push(self._id) - - def _push_markers(self) -> None: - self._state["markers"] = self.markers.to_wire_list() - self._push() - - def to_state_dict(self) -> dict: - d = dict(self._state) - d["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] - d["markers"] = self.markers.to_wire_list() - return d - - # ------------------------------------------------------------------ - # Data update - # ------------------------------------------------------------------ - def update(self, data: np.ndarray, x_axis=None, - units: str | None = None, y_units: str | None = None) -> None: - data = np.asarray(data, dtype=float) - if data.ndim != 1: - raise ValueError(f"data must be 1-D, got {data.shape}") - n = len(data) - if x_axis is None: - prev = np.asarray(self._state["x_axis"]) - x_axis = prev if len(prev) == n else np.arange(n, dtype=float) - x_axis = np.asarray(x_axis, dtype=float) - - dmin = float(np.nanmin(data)) - dmax = float(np.nanmax(data)) - pad = (dmax - dmin) * 0.05 if dmax > dmin else 0.5 - - self._state["data"] = data.tolist() - self._state["x_axis"] = x_axis.tolist() - self._state["data_min"] = dmin - pad - self._state["data_max"] = dmax + pad - if units is not None: self._state["units"] = units - if y_units is not None: self._state["y_units"] = y_units - self._push() - - # ------------------------------------------------------------------ - # Extra lines - # ------------------------------------------------------------------ - def add_line(self, data: np.ndarray, x_axis=None, - color: str = "#ffffff", linewidth: float = 1.5, - label: str = "") -> str: - data = np.asarray(data, dtype=float) - if data.ndim != 1: - raise ValueError("data must be 1-D") - xa = (np.asarray(x_axis, float).tolist() if x_axis is not None - else self._state["x_axis"]) - lid = str(_uuid.uuid4())[:8] - self._state["extra_lines"].append({ - "id": lid, "data": data.tolist(), "x_axis": xa, - "color": color, "linewidth": float(linewidth), "label": label, - }) - self._push() - return lid - - def remove_line(self, lid: str) -> None: - before = len(self._state["extra_lines"]) - self._state["extra_lines"] = [ - e for e in self._state["extra_lines"] if e["id"] != lid] - if len(self._state["extra_lines"]) == before: - raise KeyError(lid) - self._push() - - def clear_lines(self) -> None: - self._state["extra_lines"] = [] - self._push() - - # ------------------------------------------------------------------ - # Spans - # ------------------------------------------------------------------ - def add_span(self, v0: float, v1: float, - axis: str = "x", color: str | None = None) -> str: - sid = str(_uuid.uuid4())[:8] - self._state["spans"].append({ - "id": sid, "v0": float(v0), "v1": float(v1), - "axis": axis, "color": color, - }) - self._push() - return sid - - def remove_span(self, sid: str) -> None: - before = len(self._state["spans"]) - self._state["spans"] = [ - s for s in self._state["spans"] if s["id"] != sid] - if len(self._state["spans"]) == before: - raise KeyError(sid) - self._push() - - def clear_spans(self) -> None: - self._state["spans"] = [] - self._push() + def add_hline_widget(self, y: float, color: str = "#00e5ff") -> _HLineWidget: + """Add a draggable horizontal line widget at y position. - # ------------------------------------------------------------------ - # Overlay Widgets - # ------------------------------------------------------------------ - def add_vline_widget(self, x: float, color: str = "#00e5ff") -> _VLineWidget: - widget = _VLineWidget(lambda: None, x=float(x), color=color) - plot_ref, wid_id = self, widget._id - def _tp(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _tp - self._widgets[widget.id] = widget - self._push() - return widget + Parameters + ---------- + y : float + Initial y-coordinate (in data units). + color : str, optional + CSS colour for the line. Default ``"#00e5ff"``. - def add_hline_widget(self, y: float, color: str = "#00e5ff") -> _HLineWidget: + Returns + ------- + HLineWidget + The widget. Register callbacks via ``@widget.on_changed`` + or ``@widget.on_release``. + """ widget = _HLineWidget(lambda: None, y=float(y), color=color) plot_ref, wid_id = self, widget._id def _tp(): @@ -1560,6 +1403,25 @@ def _tp(): def add_range_widget(self, x0: float, x1: float, color: str = "#00e5ff") -> _RangeWidget: + """Add a draggable range (two connected vertical lines) widget. + + Parameters + ---------- + x0, x1 : float + Initial left and right x-coordinates (in data units). + color : str, optional + CSS colour for the lines. Default ``"#00e5ff"``. + + Returns + ------- + RangeWidget + The widget. Register callbacks via ``@widget.on_changed`` + or ``@widget.on_release``. + + Notes + ----- + Dragging either line updates both x0 and x1 in the widget. + """ widget = _RangeWidget(lambda: None, x0=float(x0), x1=float(x1), color=color) plot_ref, wid_id = self, widget._id def _tp(): @@ -1600,19 +1462,56 @@ def clear_widgets(self) -> None: # Callback API (Plot1D) # ------------------------------------------------------------------ def on_changed(self, fn: Callable) -> Callable: - """Decorator: fires on every drag/zoom frame on this panel.""" + """Decorator: fires on every drag/zoom frame on this panel. + + Use this for high-frequency updates (keep the handler fast). + + Parameters + ---------- + fn : Callable + Handler function receiving an Event. + + Returns + ------- + Callable + The decorated function. + """ cid = self.callbacks.connect("on_changed", fn) fn._cid = cid return fn def on_release(self, fn: Callable) -> Callable: - """Decorator: fires once when drag/zoom settles on this panel.""" + """Decorator: fires once when drag/zoom settles on this panel. + + Use this for expensive operations. + + Parameters + ---------- + fn : Callable + Handler function receiving an Event. + + Returns + ------- + Callable + The decorated function. + """ cid = self.callbacks.connect("on_release", fn) fn._cid = cid return fn def on_click(self, fn: Callable) -> Callable: - """Decorator: fires on click on this panel.""" + """Decorator: fires on click on this panel. + + Parameters + ---------- + fn : Callable + Handler function receiving an Event. + + Returns + ------- + Callable + The decorated function. + """ cid = self.callbacks.connect("on_click", fn) fn._cid = cid return fn @@ -1827,294 +1726,6 @@ def list_markers(self) -> list: out.append({"type": mtype, "name": name, "n": g._count()}) return out - def __repr__(self) -> str: - n = len(self._state.get("data", [])) - color = self._state.get("line_color", "?") - return f"Plot1D(n={n}, color={color!r})" - - -# --------------------------------------------------------------------------- -# _bar_x_axis helper -# --------------------------------------------------------------------------- - -def _bar_x_axis(x_centers: np.ndarray) -> list: - """Return a 2-element [x_left_edge, x_right_edge] list for a bar chart. - - The edges are half a slot-width outside the first/last bar centre so that - a vline_widget at ``x_centers[i]`` renders at exactly the bar's centre - pixel when used with ``_xToFrac1d`` / ``_fracToPx1d`` in the JS renderer. - """ - n = len(x_centers) - if n == 0: - return [0.0, 1.0] - if n == 1: - return [float(x_centers[0]) - 0.5, float(x_centers[0]) + 0.5] - slot = (float(x_centers[-1]) - float(x_centers[0])) / (n - 1) - half = slot / 2.0 - return [float(x_centers[0]) - half, float(x_centers[-1]) + half] - - -# --------------------------------------------------------------------------- -# PlotBar -# --------------------------------------------------------------------------- - -class PlotBar: - """Bar-chart plot panel. - - Not an anywidget. Holds state in ``_state`` dict; every mutation calls - ``_push()`` which writes to the parent Figure's panel trait. - - Supports draggable :class:`~anyplotlib.widgets.VLineWidget` and - :class:`~anyplotlib.widgets.HLineWidget` overlays via - :meth:`add_vline_widget` / :meth:`add_hline_widget`. - - Created by :meth:`Axes.bar`. - """ - - def __init__(self, values, - x_labels=None, - x_centers=None, - color: str = "#4fc3f7", - colors=None, - bar_width: float = 0.7, - orient: str = "v", - baseline: float = 0.0, - show_values: bool = False, - units: str = "", - y_units: str = ""): - self._id: str = "" - self._fig: object = None - - values = np.asarray(values, dtype=float) - n = len(values) - if values.ndim != 1: - raise ValueError(f"values must be 1-D, got shape {values.shape}") - if orient not in ("v", "h"): - raise ValueError("orient must be 'v' or 'h'") - - if x_centers is None: - x_centers = np.arange(n, dtype=float) - x_centers = np.asarray(x_centers, dtype=float) - if len(x_centers) != n: - raise ValueError("x_centers length must match values length") - - val_min = float(np.nanmin(values)) if n else 0.0 - val_max = float(np.nanmax(values)) if n else 1.0 - dmin = min(float(baseline), val_min) - dmax = max(float(baseline), val_max) - pad = (dmax - dmin) * 0.07 if dmax > dmin else 0.5 - dmax += pad - if dmin < float(baseline): - dmin -= pad - - # Compute physical x-axis extent (left/right edges of the bar chart) - # so that vline_widgets map to the correct pixel positions. - x_axis = _bar_x_axis(x_centers) - - self._state: dict = { - "kind": "bar", - "values": values.tolist(), - "x_centers": x_centers.tolist(), - "x_labels": list(x_labels) if x_labels is not None else [], - "bar_color": color, - "bar_colors": list(colors) if colors is not None else [], - "bar_width": float(bar_width), - "orient": orient, - "baseline": float(baseline), - "show_values": bool(show_values), - "data_min": dmin, - "data_max": dmax, - "units": units, - "y_units": y_units, - # overlay-widget coordinate system (mirrors Plot1D) - "x_axis": x_axis, - "view_x0": 0.0, - "view_x1": 1.0, - "overlay_widgets": [], - "registered_keys": [], - } - self.callbacks = CallbackRegistry() - self._widgets: dict[str, Widget] = {} - - # ------------------------------------------------------------------ - def _push(self) -> None: - if self._fig is None: - return - self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] - self._fig._push(self._id) - - def to_state_dict(self) -> dict: - d = dict(self._state) - d["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] - return d - - # ------------------------------------------------------------------ - # Data update - # ------------------------------------------------------------------ - def update(self, values, x_centers=None, x_labels=None) -> None: - """Replace bar values; recalculates the value-axis range automatically.""" - values = np.asarray(values, dtype=float) - if values.ndim != 1: - raise ValueError(f"values must be 1-D, got shape {values.shape}") - - baseline = self._state["baseline"] - dmin = min(float(baseline), float(np.nanmin(values))) - dmax = max(float(baseline), float(np.nanmax(values))) - pad = (dmax - dmin) * 0.07 if dmax > dmin else 0.5 - dmax += pad - if dmin < baseline: - dmin -= pad - - self._state["values"] = values.tolist() - self._state["data_min"] = dmin - self._state["data_max"] = dmax - if x_centers is not None: - xc = np.asarray(x_centers, dtype=float) - self._state["x_centers"] = xc.tolist() - self._state["x_axis"] = _bar_x_axis(xc) - if x_labels is not None: - self._state["x_labels"] = list(x_labels) - self._push() - - # ------------------------------------------------------------------ - # Display settings - # ------------------------------------------------------------------ - def set_color(self, color: str) -> None: - """Set a single colour for all bars.""" - self._state["bar_color"] = color - self._push() - - def set_colors(self, colors) -> None: - """Set per-bar colours (list of CSS colour strings, length N).""" - self._state["bar_colors"] = list(colors) - self._push() - - def set_show_values(self, show: bool) -> None: - """Show or hide in-bar value annotations.""" - self._state["show_values"] = bool(show) - self._push() - - # ------------------------------------------------------------------ - # Overlay Widgets - # ------------------------------------------------------------------ - def add_vline_widget(self, x: float, color: str = "#00e5ff") -> _VLineWidget: - """Add a draggable vertical line at data position *x*.""" - widget = _VLineWidget(lambda: None, x=float(x), color=color) - plot_ref, wid_id = self, widget._id - def _tp(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _tp - self._widgets[widget.id] = widget - self._push() - return widget - - def add_hline_widget(self, y: float, color: str = "#00e5ff") -> _HLineWidget: - """Add a draggable horizontal line at value-axis position *y*.""" - widget = _HLineWidget(lambda: None, y=float(y), color=color) - plot_ref, wid_id = self, widget._id - def _tp(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _tp - self._widgets[widget.id] = widget - self._push() - return widget - - def get_widget(self, wid) -> Widget: - """Return the Widget object by ID string or Widget instance.""" - if isinstance(wid, Widget): - wid = wid.id - try: - return self._widgets[wid] - except KeyError: - raise KeyError(wid) - - def remove_widget(self, wid) -> None: - """Remove a widget by ID string or Widget instance.""" - if isinstance(wid, Widget): - wid = wid.id - if wid not in self._widgets: - raise KeyError(wid) - del self._widgets[wid] - self._push() - - def list_widgets(self) -> list: - return list(self._widgets.values()) - - def clear_widgets(self) -> None: - self._widgets.clear() - self._push() - - # ------------------------------------------------------------------ - # Callbacks - # ------------------------------------------------------------------ - def on_click(self, fn: Callable) -> Callable: - """Decorator: fires when the user clicks a bar. - - The :class:`~anyplotlib.callbacks.Event` has ``bar_index``, - ``value``, ``x_center``, and ``x_label``. - """ - cid = self.callbacks.connect("on_click", fn) - fn._cid = cid - return fn - - def on_changed(self, fn: Callable) -> Callable: - """Decorator: fires on every drag frame (widget drag or hover).""" - cid = self.callbacks.connect("on_changed", fn) - fn._cid = cid - return fn - - def on_release(self, fn: Callable) -> Callable: - """Decorator: fires once when a widget drag settles.""" - cid = self.callbacks.connect("on_release", fn) - fn._cid = cid - return fn - - def on_key(self, key_or_fn=None) -> Callable: - """Register a key-press handler for this panel. - - Two call forms are supported:: - - @plot.on_key('q') # fires only when 'q' is pressed - def handler(event): ... - - @plot.on_key # fires for every registered key - def handler(event): ... - - The event carries: ``key``, ``mouse_x``, ``mouse_y``, and - ``last_widget_id``. - """ - if callable(key_or_fn): - return self._connect_on_key(None, key_or_fn) - key = key_or_fn - def _decorator(fn): - return self._connect_on_key(key, fn) - return _decorator - - def _connect_on_key(self, key, fn) -> Callable: - if key is None: - if '*' not in self._state['registered_keys']: - self._state['registered_keys'].append('*') - self._push() - cid = self.callbacks.connect("on_key", fn) - else: - if key not in self._state['registered_keys']: - self._state['registered_keys'].append(key) - self._push() - def _wrapped(event): - if event.data.get('key') == key: - fn(event) - cid = self.callbacks.connect("on_key", _wrapped) - _wrapped._cid = cid - fn._cid = cid - return fn - - def disconnect(self, cid: int) -> None: - self.callbacks.disconnect(cid) - def __repr__(self) -> str: n = len(self._state.get("values", [])) orient = self._state.get("orient", "v") @@ -2124,3 +1735,5 @@ def __repr__(self) -> str: + + From 6f24865abbc5fad51a37621bab012dd659baa206 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 18 Mar 2026 13:22:35 -0500 Subject: [PATCH 025/198] Enhance documentation for figure, markers, and widget classes with detailed parameter descriptions, examples, and return values --- anyplotlib/figure.py | 68 ++++++++++- anyplotlib/markers.py | 168 ++++++++++++++++++++++++-- anyplotlib/widgets.py | 265 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 476 insertions(+), 25 deletions(-) diff --git a/anyplotlib/figure.py b/anyplotlib/figure.py index 1bfc1c51..f63712ca 100644 --- a/anyplotlib/figure.py +++ b/anyplotlib/figure.py @@ -37,11 +37,44 @@ class Figure(anywidget.AnyWidget): """Multi-panel interactive figure widget. - Create via :func:`subplots` or directly:: + The top-level container for all plots and the only ``anywidget.AnyWidget`` + subclass in anyplotlib. It owns all traitlets and acts as the Python ↔ + JavaScript bridge via the ``figure_esm.js`` canvas renderer. + + Create via :func:`subplots` (recommended) or directly:: fig = Figure(2, 2, figsize=(800, 600)) ax = fig.add_subplot((0, 0)) v2d = ax.imshow(data) + + Parameters + ---------- + nrows, ncols : int, optional + Grid dimensions. Default 1 row, 1 column. + figsize : (width, height), optional + Figure size in CSS pixels. Default ``(640, 480)``. + width_ratios : list of float, optional + Relative column widths. Length must equal *ncols*. + height_ratios : list of float, optional + Relative row heights. Length must equal *nrows*. + sharex, sharey : bool, optional + Link pan/zoom across all panels on the respective axis. + Default False (independent pan/zoom per panel). + + Attributes + ---------- + fig_width : int + Current figure width in pixels (synced with JS). + fig_height : int + Current figure height in pixels (synced with JS). + layout_json : str + JSON serialization of the grid layout (synced with JS). + event_json : str + JSON serialization of interaction events from JS. + + See Also + -------- + subplots : Recommended factory for creating Figure and Axes grid. """ layout_json = traitlets.Unicode("{}").tag(sync=True) @@ -75,11 +108,29 @@ def add_subplot(self, spec) -> Axes: Parameters ---------- - spec : SubplotSpec | int | (row, col) tuple + spec : SubplotSpec or int or (row, col) tuple + Specifies which grid cell(s) to occupy: - ``SubplotSpec``: used directly (e.g. from ``GridSpec[r, c]``). - ``int``: converted to ``(row, col)`` via ``divmod(spec, ncols)``, matching ``matplotlib.Figure.add_subplot(num)`` numbering. - ``(row, col)`` tuple: selects a single cell. + + Returns + ------- + Axes + The subplot axes object. Call plotting methods like ``.imshow()``, + ``.plot()``, ``.bar()`` to attach data. + + Raises + ------ + TypeError + If *spec* is not a SubplotSpec, int, or tuple. + + Examples + -------- + >>> fig = Figure(2, 2) + >>> ax1 = fig.add_subplot(0) # top-left (via numbering) + >>> ax2 = fig.add_subplot((0, 1)) # top-right (via tuple) """ if isinstance(spec, SubplotSpec): pass # use as-is @@ -229,6 +280,14 @@ def _push_widget(self, panel_id: str, widget_id: str, fields: dict) -> None: # ── helpers ─────────────────────────────────────────────────────────────── def get_axes(self) -> list: + """Return a list of all Axes, sorted by grid position. + + Returns + ------- + list of Axes + Axes sorted by (row_start, col_start) to match typical left-to-right, + top-to-bottom iteration order. + """ return sorted(self._axes_map.values(), key=lambda a: (a._spec.row_start, a._spec.col_start)) @@ -238,6 +297,11 @@ def _repr_html_(self) -> str: Used by Sphinx Gallery (via :class:`~docs._sg_html_scraper.ViewerScraper`) and by any HTML-capable notebook frontend that falls back to ``_repr_html_`` instead of the full ipywidgets protocol. + + Returns + ------- + str + HTML string containing an embedded iframe with srcdoc attribute. """ from anyplotlib._repr_utils import repr_html_iframe return repr_html_iframe(self) diff --git a/anyplotlib/markers.py b/anyplotlib/markers.py index 3da1767d..dd0e31c4 100644 --- a/anyplotlib/markers.py +++ b/anyplotlib/markers.py @@ -99,7 +99,14 @@ def __init__(self, marker_type: str, name: str, kwargs: dict, push_fn): # ------------------------------------------------------------------ def set(self, **kwargs) -> None: - """Update one or more properties and push the change to the plot.""" + """Update one or more properties and push the change to the plot. + + Parameters + ---------- + **kwargs : dict + Properties to update (e.g., offsets, radius, facecolors). + Matplotlib-style names are translated to wire format. + """ self._data.update(kwargs) self._push_fn() @@ -107,6 +114,7 @@ def __repr__(self) -> str: # pragma: no cover return f"MarkerGroup(type={self._type!r}, name={self._name!r}, n={self._count()})" def _count(self) -> int: + """Return the number of markers in this group.""" offs = self._data.get("offsets") if offs is None: return 0 @@ -119,7 +127,19 @@ def _count(self) -> int: # Wire-format serialisation # ------------------------------------------------------------------ def to_wire(self, group_id: str) -> dict: - """Return a dict in the JS wire format for this marker group.""" + """Return a dict in the JS wire format for this marker group. + + Parameters + ---------- + group_id : str + Unique identifier for this marker group (usually UUID). + + Returns + ------- + dict + Wire-format dict with type-specific structure, ready for JSON + serialization and transmission to the JavaScript renderer. + """ d = self._data t = self._type @@ -333,6 +353,13 @@ class MarkerTypeDict: Any modification (``__setitem__``, ``__delitem__``) automatically triggers the ``_push_fn`` callback so the plot re-renders. + + Parameters + ---------- + marker_type : str + Type of markers (e.g., 'circles', 'arrows', 'lines'). + push_fn : callable + Zero-arg callback to trigger re-render on mutations. """ def __init__(self, marker_type: str, push_fn): @@ -343,38 +370,112 @@ def __init__(self, marker_type: str, push_fn): # ------------------------------------------------------------------ # dict-like interface def __getitem__(self, name: str) -> MarkerGroup: + """Return a MarkerGroup by name. + + Parameters + ---------- + name : str + Name of the marker group. + + Returns + ------- + MarkerGroup + The requested marker group. + + Raises + ------ + KeyError + If the name is not found. + """ return self._groups[name] def __setitem__(self, name: str, group: MarkerGroup) -> None: + """Register a MarkerGroup and trigger re-render. + + Parameters + ---------- + name : str + Name to register the group under. + group : MarkerGroup + The marker group object. + """ self._groups[name] = group self._push_fn() def __delitem__(self, name: str) -> None: + """Remove a MarkerGroup by name and trigger re-render. + + Parameters + ---------- + name : str + Name of the group to remove. + + Raises + ------ + KeyError + If the name is not found. + """ del self._groups[name] self._push_fn() def __contains__(self, name: object) -> bool: + """Check if a named group exists. + + Parameters + ---------- + name : object + Name to check. + + Returns + ------- + bool + True if the group exists. + """ return name in self._groups def __iter__(self): + """Iterate over group names.""" return iter(self._groups) def __len__(self) -> int: + """Return the number of groups.""" return len(self._groups) def __repr__(self) -> str: # pragma: no cover return f"MarkerTypeDict(type={self._type!r}, groups={list(self._groups)})" def keys(self): + """Return group names.""" return self._groups.keys() def values(self): + """Return MarkerGroup objects.""" return self._groups.values() def items(self): + """Return (name, MarkerGroup) pairs.""" return self._groups.items() def pop(self, name: str, *args): + """Remove and return a MarkerGroup by name. + + Parameters + ---------- + name : str + Name of the group to remove. + *args : optional + Default value if name is not found. + + Returns + ------- + MarkerGroup + The removed group, or default value if provided. + + Raises + ------ + KeyError + If name is not found and no default is provided. + """ result = self._groups.pop(name, *args) self._push_fn() return result @@ -437,6 +538,23 @@ def __init__(self, push_fn, allowed: frozenset | None = None): # ------------------------------------------------------------------ def __getitem__(self, marker_type: str) -> MarkerTypeDict: + """Return the MarkerTypeDict for a type, auto-creating if needed. + + Parameters + ---------- + marker_type : str + Type name (e.g., 'circles', 'lines', 'arrows'). + + Returns + ------- + MarkerTypeDict + The dict of named groups for this type. + + Raises + ------ + ValueError + If the type is not in the allowed set (if one was provided). + """ if self._allowed is not None and marker_type not in self._allowed: raise ValueError( f"Marker type '{marker_type}' is not allowed on this panel. " @@ -447,9 +565,11 @@ def __getitem__(self, marker_type: str) -> MarkerTypeDict: return self._types[marker_type] def __contains__(self, marker_type: object) -> bool: + """Check if a marker type has any registered groups.""" return marker_type in self._types def __iter__(self): + """Iterate over marker types that have registered groups.""" return iter(self._types) def __repr__(self) -> str: # pragma: no cover @@ -482,16 +602,23 @@ def add(self, marker_type: str, name: str | None = None, **kwargs) -> MarkerGrou Parameters ---------- - marker_type : - Type string, e.g. ``'circles'``. - name : - Group name. Auto-generated (``'circles_1'`` etc.) if ``None``. - **kwargs : - Matplotlib-style kwargs for the group. + marker_type : str + Type string, e.g. ``'circles'``, ``'lines'``, ``'arrows'``. + name : str, optional + Group name. Auto-generated (``'circles_1'`` etc.) if ``None``. + **kwargs : dict + Matplotlib-style kwargs for the group (offsets, radius, colors, etc.). Returns ------- MarkerGroup + The created marker group. Call ``.set()`` to update, or access + properties as attributes. + + Examples + -------- + >>> group = registry.add("circles", name="my_circles", offsets=[[10, 20]], radius=5) + >>> group.set(radius=8) """ if name is None: name = self._auto_name(marker_type) @@ -501,16 +628,35 @@ def add(self, marker_type: str, name: str | None = None, **kwargs) -> MarkerGrou return g def remove(self, marker_type: str, name: str) -> None: - """Remove a named group (triggers a push).""" + """Remove a named marker group and trigger re-render. + + Parameters + ---------- + marker_type : str + Type of the group. + name : str + Name of the group to remove. + + Raises + ------ + KeyError + If the type or name is not found. + """ del self[marker_type][name] # MarkerTypeDict.__delitem__ pushes def clear(self) -> None: - """Remove all markers of all types.""" + """Remove all markers of all types and trigger re-render.""" self._types.clear() self._push_fn() def to_wire_list(self) -> list: - """Flatten the full registry to a list of wire-format dicts.""" + """Flatten the full registry to a list of wire-format dicts. + + Returns + ------- + list of dict + Wire-format dicts ready for JSON serialization and JS rendering. + """ out = [] for td in self._types.values(): out.extend(td.to_wire_list()) diff --git a/anyplotlib/widgets.py b/anyplotlib/widgets.py index 9f6cf9db..9a1f2f2a 100644 --- a/anyplotlib/widgets.py +++ b/anyplotlib/widgets.py @@ -31,7 +31,28 @@ def clicked(event): ... class Widget: - """Base class for all overlay widgets.""" + """Base class for all overlay widgets. + + Provides attribute-based state access, callbacks for interaction events, + and automatic synchronization with the JavaScript renderer. + + Parameters + ---------- + wtype : str + Widget type (e.g., 'rectangle', 'circle', 'crosshair'). + push_fn : Callable + Zero-arg callback to send position updates to the JavaScript renderer. + **kwargs : dict + Initial widget state (position, size, color, etc.). + + Attributes + ---------- + callbacks : CallbackRegistry + Event callback registry. Register handlers via: + - ``@widget.on_changed`` — fires on every drag frame + - ``@widget.on_release`` — fires once when drag settles + - ``@widget.on_click`` — fires on click event + """ def __init__(self, wtype: str, push_fn: Callable, **kwargs): self._id: str = str(_uuid.uuid4())[:8] @@ -45,6 +66,7 @@ def __init__(self, wtype: str, push_fn: Callable, **kwargs): # ── attribute read ──────────────────────────────────────────────── def __getattr__(self, key: str): + """Access widget properties as attributes (read-only).""" if key.startswith("_"): raise AttributeError(key) try: @@ -58,6 +80,7 @@ def __getattr__(self, key: str): # ── attribute write — routes public assignments through set() ──── def __setattr__(self, key: str, value) -> None: + """Update widget properties via attribute assignment.""" # Private attrs and 'callbacks' bypass set() if key.startswith("_") or key == "callbacks": super().__setattr__(key, value) @@ -73,10 +96,20 @@ def __setattr__(self, key: str, value) -> None: # ── set / get ───────────────────────────────────────────────────── def set(self, _push: bool = True, **kwargs) -> None: - """Update properties. Sends a targeted event_json update to JS - (not a full panel push). Fires on_changed callbacks. - - Use _push=False internally (e.g. _update_from_js) to avoid echo. + """Update properties and send targeted update to JavaScript. + + Parameters + ---------- + _push : bool, optional + Whether to push update to renderer. Default True. + Set to False internally to avoid echo loops. + **kwargs : dict + Properties to update (e.g., x=100, y=50, radius=20). + + Notes + ----- + Updates are sent as targeted widget updates, not full panel re-renders. + This is more efficient for frequent updates during dragging. """ self._data.update(kwargs) if _push: @@ -84,27 +117,85 @@ def set(self, _push: bool = True, **kwargs) -> None: self.callbacks.fire(Event("on_changed", source=self, data=dict(self._data))) def get(self, key: str, default=None): + """Get a widget property by name. + + Parameters + ---------- + key : str + Property name. + default : optional + Default value if property not found. + + Returns + ------- + object + The property value. + """ return self._data.get(key, default) def to_dict(self) -> dict: + """Return a dict copy of the widget state. + + Returns + ------- + dict + All widget properties including id and type. + """ return dict(self._data) # ── callback decorator methods ──────────────────────────────────── def on_changed(self, fn: Callable) -> Callable: - """Decorator: register fn to fire on every drag frame.""" + """Decorator: register fn to fire on every drag frame. + + Use this for high-frequency updates (keep handler fast). + + Parameters + ---------- + fn : Callable + Handler function receiving an Event. + + Returns + ------- + Callable + The decorated function. + """ cid = self.callbacks.connect("on_changed", fn) fn._cid = cid return fn def on_release(self, fn: Callable) -> Callable: - """Decorator: register fn to fire once when drag settles.""" + """Decorator: register fn to fire once when drag settles. + + Use this for expensive operations triggered after user stops dragging. + + Parameters + ---------- + fn : Callable + Handler function receiving an Event. + + Returns + ------- + Callable + The decorated function. + """ cid = self.callbacks.connect("on_release", fn) fn._cid = cid return fn def on_click(self, fn: Callable) -> Callable: - """Decorator: register fn to fire on click.""" + """Decorator: register fn to fire on widget click. + + Parameters + ---------- + fn : Callable + Handler function receiving an Event. + + Returns + ------- + Callable + The decorated function. + """ cid = self.callbacks.connect("on_click", fn) fn._cid = cid return fn @@ -112,8 +203,11 @@ def on_click(self, fn: Callable) -> Callable: def disconnect(self, cid) -> None: """Remove the callback registered under *cid*. - Accepts either the integer CID returned by ``callbacks.connect()``, - or the decorated function itself (which carries a ``._cid`` attribute). + Parameters + ---------- + cid : int or Callable + Either the integer CID returned by ``callbacks.connect()``, + or the decorated function itself (carries a ``._cid`` attribute). """ if callable(cid) and hasattr(cid, "_cid"): cid = cid._cid @@ -123,8 +217,23 @@ def disconnect(self, cid) -> None: def _update_from_js(self, new_data: dict, event_type: str = "on_changed") -> bool: """Apply incoming JS state without pushing back (avoids echo). - Fires self.callbacks with event_type. Returns True if data changed. - Always fires for on_release / on_click even when nothing changed. + + Parameters + ---------- + new_data : dict + Updated widget properties from JavaScript. + event_type : str, optional + Type of event that triggered the update. + + Returns + ------- + bool + True if any state changed. + + Notes + ----- + Always fires on_release / on_click callbacks even if nothing changed. + Only fires on_changed if state actually changed. """ changed = False for k, v in new_data.items(): @@ -150,6 +259,7 @@ def __repr__(self) -> str: @property def id(self) -> str: + """Return the widget's unique identifier.""" return self._id @@ -158,6 +268,19 @@ def id(self) -> str: # --------------------------------------------------------------------------- class RectangleWidget(Widget): + """Draggable rectangle overlay widget for 2-D plots. + + Parameters + ---------- + push_fn : Callable + Update callback. + x, y : float + Top-left corner position in pixel/data coordinates. + w, h : float + Width and height in pixel/data coordinates. + color : str, optional + CSS colour for the rectangle outline. Default ``"#00e5ff"``. + """ def __init__(self, push_fn, *, x, y, w, h, color="#00e5ff"): super().__init__("rectangle", push_fn, x=float(x), y=float(y), @@ -165,12 +288,44 @@ def __init__(self, push_fn, *, x, y, w, h, color="#00e5ff"): class CircleWidget(Widget): + """Draggable circle overlay widget for 2-D plots. + + Parameters + ---------- + push_fn : Callable + Update callback. + cx, cy : float + Center position in pixel/data coordinates. + r : float + Radius in pixel/data coordinates. + color : str, optional + CSS colour for the circle outline. Default ``"#00e5ff"``. + """ def __init__(self, push_fn, *, cx, cy, r, color="#00e5ff"): super().__init__("circle", push_fn, cx=float(cx), cy=float(cy), r=float(r), color=color) class AnnularWidget(Widget): + """Draggable annular (ring) overlay widget for 2-D plots. + + Parameters + ---------- + push_fn : Callable + Update callback. + cx, cy : float + Center position in pixel/data coordinates. + r_outer, r_inner : float + Outer and inner radii in pixel/data coordinates. + Inner radius must be less than outer radius. + color : str, optional + CSS colour for the ring outline. Default ``"#00e5ff"``. + + Raises + ------ + ValueError + If r_inner >= r_outer. + """ def __init__(self, push_fn, *, cx, cy, r_outer, r_inner, color="#00e5ff"): if r_inner >= r_outer: raise ValueError("r_inner must be < r_outer") @@ -181,12 +336,40 @@ def __init__(self, push_fn, *, cx, cy, r_outer, r_inner, color="#00e5ff"): class CrosshairWidget(Widget): + """Draggable crosshair overlay widget for 2-D plots. + + Parameters + ---------- + push_fn : Callable + Update callback. + cx, cy : float + Center position in pixel/data coordinates. + color : str, optional + CSS colour for the crosshair. Default ``"#00e5ff"``. + """ def __init__(self, push_fn, *, cx, cy, color="#00e5ff"): super().__init__("crosshair", push_fn, cx=float(cx), cy=float(cy), color=color) class PolygonWidget(Widget): + """Draggable polygon overlay widget for 2-D plots. + + Parameters + ---------- + push_fn : Callable + Update callback. + vertices : list of (x, y) tuples + Polygon vertices in pixel/data coordinates. + Must have at least 3 vertices. + color : str, optional + CSS colour for the polygon outline. Default ``"#00e5ff"``. + + Raises + ------ + ValueError + If fewer than 3 vertices provided. + """ def __init__(self, push_fn, *, vertices, color="#00e5ff"): verts = [[float(x), float(y)] for x, y in vertices] if len(verts) < 3: @@ -195,6 +378,21 @@ def __init__(self, push_fn, *, vertices, color="#00e5ff"): class LabelWidget(Widget): + """Text label overlay widget for 2-D plots. + + Parameters + ---------- + push_fn : Callable + Update callback. + x, y : float + Label position in pixel/data coordinates. + text : str, optional + Label text. Default ``"Label"``. + fontsize : int, optional + Font size in points. Default 14. + color : str, optional + CSS colour for the text. Default ``"#00e5ff"``. + """ def __init__(self, push_fn, *, x, y, text="Label", fontsize=14, color="#00e5ff"): super().__init__("label", push_fn, @@ -207,15 +405,58 @@ def __init__(self, push_fn, *, x, y, text="Label", fontsize=14, # --------------------------------------------------------------------------- class VLineWidget(Widget): + """Draggable vertical line overlay widget for 1-D plots. + + Allows interactive selection of a single x-axis value. The line can be + dragged left/right to change the selected position. + + Parameters + ---------- + push_fn : Callable + Update callback. + x : float + Initial x-position in data coordinates. + color : str, optional + CSS colour for the line. Default ``"#00e5ff"``. + """ def __init__(self, push_fn, *, x, color="#00e5ff"): super().__init__("vline", push_fn, x=float(x), color=color) class HLineWidget(Widget): + """Draggable horizontal line overlay widget for bar charts. + + Allows interactive selection of a single y-axis value. The line can be + dragged up/down to change the selected value. + + Parameters + ---------- + push_fn : Callable + Update callback. + y : float + Initial y-position in data coordinates. + color : str, optional + CSS colour for the line. Default ``"#00e5ff"``. + """ def __init__(self, push_fn, *, y, color="#00e5ff"): super().__init__("hline", push_fn, y=float(y), color=color) class RangeWidget(Widget): + """Draggable range selection widget (two connected vertical lines). + + Allows interactive selection of a range on the x-axis. Both lines + move together when dragging, maintaining the range width. Either end + can be dragged independently to resize the range. + + Parameters + ---------- + push_fn : Callable + Update callback. + x0, x1 : float + Initial left and right positions in data coordinates. + color : str, optional + CSS colour for both lines. Default ``"#00e5ff"``. + """ def __init__(self, push_fn, *, x0, x1, color="#00e5ff"): super().__init__("range", push_fn, x0=float(x0), x1=float(x1), color=color) From 55bc5bd51c91376df541dbbdd4124911163997ab Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 18 Mar 2026 15:32:34 -0500 Subject: [PATCH 026/198] Revert "Add doc strings for figure_plots.py" This reverts commit a77ee38a077969cf443d548276d100dc4de5ea30. --- anyplotlib/figure_plots.py | 1203 ++++++++++++++++++++++++------------ 1 file changed, 795 insertions(+), 408 deletions(-) diff --git a/anyplotlib/figure_plots.py b/anyplotlib/figure_plots.py index 8c267675..eb2d1c31 100644 --- a/anyplotlib/figure_plots.py +++ b/anyplotlib/figure_plots.py @@ -42,21 +42,7 @@ # --------------------------------------------------------------------------- class SubplotSpec: - """Describes which grid cells a subplot occupies. - - Parameters - ---------- - gs : GridSpec - Parent GridSpec instance. - row_start : int - Starting row index (0-based). - row_stop : int - Ending row index (exclusive). - col_start : int - Starting column index (0-based). - col_stop : int - Ending column index (exclusive). - """ + """Describes which grid cells a subplot occupies.""" def __init__(self, gs: "GridSpec", row_start: int, row_stop: int, col_start: int, col_stop: int): @@ -154,23 +140,9 @@ def __repr__(self) -> str: class Axes: """A single grid cell in a Figure. - Represents a single subplot cell within a grid layout. Use plotting methods - like :meth:`imshow`, :meth:`plot`, :meth:`bar`, etc. to attach visualization - to this axes. - - Returned by :func:`Figure.add_subplot` and :func:`Figure.subplots`. - - Parameters - ---------- - fig : Figure - Parent Figure instance. - spec : SubplotSpec - Layout specification (row/column spans). - - Notes - ----- - Each Axes can hold at most one plot object at a time. Calling another plot - method replaces the previous one. + Returned by Figure.add_subplot() and Figure.subplots(). + Call .imshow() or .plot() to attach a data plot and get back + a Plot2D or Plot1D object. """ def __init__(self, fig: "Figure", spec: SubplotSpec): # noqa: F821 @@ -514,31 +486,16 @@ def _resample_mesh(data: np.ndarray, x_edges, y_edges) -> np.ndarray: class Plot2D: """2-D image plot panel. - Not an anywidget. Holds state in ``_state`` dict; every mutation calls + Not an anywidget. Holds state in ``_state`` dict; every mutation calls ``_push()`` which writes to the parent Figure's panel trait. - The marker API follows matplotlib conventions:: - + The marker API follows matplotlib conventions: plot.add_circles(offsets, name="g1", facecolors="#f00", radius=5) - plot.markers["circles"]["g1"].set(radius=8) # live update - - Supports interactive 2-D draggable overlays (widgets) via :meth:`add_widget`. - - Parameters - ---------- - data : ndarray, shape (H, W) or (H, W, C) - Image data. If 3-D, only the first channel is used. - x_axis : array-like, optional - X-axis physical coordinates. Length must equal W (width). - y_axis : array-like, optional - Y-axis physical coordinates. Length must equal H (height). - units : str, optional - Label for the axes. Default ``"px"``. + plot.markers["circles"]["g1"].set(radius=8) """ def __init__(self, data: np.ndarray, x_axis=None, y_axis=None, units: str = "px"): - #...existing code... self._id: str = "" # assigned by Axes._attach self._fig: object = None # assigned by Axes._attach @@ -632,24 +589,7 @@ def to_state_dict(self) -> dict: # ------------------------------------------------------------------ def update(self, data: np.ndarray, x_axis=None, y_axis=None, units: str | None = None) -> None: - """Replace the image data. - - Parameters - ---------- - data : ndarray, shape (H, W) or (H, W, C) - New image data. 3-D arrays use only the first channel. - x_axis : array-like, optional - New X-axis coordinates. Must match the new image width. - y_axis : array-like, optional - New Y-axis coordinates. Must match the new image height. - units : str, optional - Update the axes label. If not provided, keeps the current value. - - Raises - ------ - ValueError - If data is not 2-D. - """ + """Replace the image data.""" data = np.asarray(data) if data.ndim == 3: data = data[:, :, 0] @@ -686,44 +626,11 @@ def update(self, data: np.ndarray, # Display settings # ------------------------------------------------------------------ def set_colormap(self, name: str) -> None: - """Set or update the colormap. - - Parameters - ---------- - name : str - Matplotlib-style colormap name. Common names include: - "viridis", "plasma", "inferno", "magma", "cividis", - "hot", "jet", "RdBu", "coolwarm", etc. - Aliases to colorcet palettes are used internally for - colormap independence. - - Notes - ----- - The colormap is applied to surface Z-values (in Plot3D) or - to intensity values (in Plot2D when used with set_clim). - """ self._state["colormap_name"] = name self._state["colormap_data"] = _build_colormap_lut(name) self._push() def set_clim(self, vmin=None, vmax=None) -> None: - """Set the data range for display normalization. - - Parameters - ---------- - vmin : float, optional - Minimum data value to map to the colormap. If not provided, - uses the current minimum. - vmax : float, optional - Maximum data value to map to the colormap. If not provided, - uses the current maximum. - - Notes - ----- - This controls the color range display without modifying the - underlying data. Useful for emphasizing features in a specific - intensity range. - """ if vmin is not None: self._state["display_min"] = float(vmin) if vmax is not None: @@ -731,21 +638,6 @@ def set_clim(self, vmin=None, vmax=None) -> None: self._push() def set_scale_mode(self, mode: str) -> None: - """Set the axis scale mode (linear, logarithmic, or symmetric log). - - Parameters - ---------- - mode : str - One of ``"linear"``, ``"log"``, or ``"symlog"``. - - ``"linear"``: standard linear scale. - - ``"log"``: logarithmic scale (data must be positive). - - ``"symlog"``: symmetric logarithmic scale (allows negative values). - - Raises - ------ - ValueError - If *mode* is not one of the valid options. - """ valid = ("linear", "log", "symlog") if mode not in valid: raise ValueError(f"mode must be one of {valid}") @@ -764,40 +656,6 @@ def colormap_name(self, name: str) -> None: # Overlay Widgets # ------------------------------------------------------------------ def add_widget(self, kind: str, color: str = "#00e5ff", **kwargs) -> Widget: - """Add an interactive overlay widget to this plot. - - Parameters - ---------- - kind : str - Widget type: ``"circle"``, ``"rectangle"``, ``"annular"``, - ``"polygon"``, ``"label"``, or ``"crosshair"``. - color : str, optional - CSS colour for the widget outline/fill. Default ``"#00e5ff"``. - **kwargs : dict - Type-specific parameters: - - circle: cx, cy, r (center x, y and radius) - - rectangle: x, y, w, h (top-left corner and dimensions) - - annular: cx, cy, r_outer, r_inner (center and radii) - - polygon: vertices (list of [x, y] coordinates) - - crosshair: cx, cy (center position) - - label: x, y, text, fontsize (position, text, font size in pts) - - Returns - ------- - Widget - The created widget object. Register callbacks via - ``@widget.on_changed`` or ``@widget.on_release``. - - Raises - ------ - ValueError - If *kind* is not recognized. - - Examples - -------- - >>> plot.add_widget("circle", cx=100, cy=100, r=50, color="#ff0000") - >>> plot.add_widget("crosshair", cx=64, cy=64) - """ kind = kind.lower() valid = ("circle", "rectangle", "annular", "polygon", "label", "crosshair") if kind not in valid: @@ -880,46 +738,13 @@ def clear_widgets(self) -> None: # Callback API (Plot2D) # ------------------------------------------------------------------ def on_changed(self, fn: Callable) -> Callable: - """Decorator: fires on every pan/zoom/drag frame on this panel. - - Use this for high-frequency updates (e.g., live readout). Keep the - handler fast to avoid blocking the UI. - - Parameters - ---------- - fn : Callable - Handler function receiving an Event with zoom, center_x, center_y. - - Returns - ------- - Callable - The decorated function. - - Examples - -------- - >>> @plot.on_changed - ... def update_readout(event): - ... print(f"zoom={event.zoom:.2f}") - """ + """Decorator: fires on every pan/zoom/drag frame on this panel.""" cid = self.callbacks.connect("on_changed", fn) fn._cid = cid return fn def on_release(self, fn: Callable) -> Callable: - """Decorator: fires once when pan/zoom/drag settles on this panel. - - Use this for expensive operations (e.g., recomputation). - - Parameters - ---------- - fn : Callable - Handler function receiving an Event with final zoom/position. - - Returns - ------- - Callable - The decorated function. - """ + """Decorator: fires once when pan/zoom/drag settles on this panel.""" cid = self.callbacks.connect("on_release", fn) fn._cid = cid return fn @@ -1178,218 +1003,550 @@ def list_markers(self) -> list: return out def __repr__(self) -> str: - n = len(self._state.get("data", [])) - color = self._state.get("line_color", "?") - return f"Plot1D(n={n}, color={color!r})" + w = self._state.get("image_width", "?") + h = self._state.get("image_height", "?") + cmap = self._state.get("colormap_name", "?") + return f"Plot2D({w}\u00d7{h}, cmap={cmap!r})" # --------------------------------------------------------------------------- -# _bar_x_axis helper +# PlotMesh (pcolormesh-style 2-D panel) # --------------------------------------------------------------------------- -def _bar_x_axis(x_centers: np.ndarray) -> list: - """Return a 2-element [x_left_edge, x_right_edge] list for a bar chart. +class PlotMesh(Plot2D): + """2-D mesh plot panel created by :meth:`Axes.pcolormesh`. - The edges are half a slot-width outside the first/last bar centre so that - a vline_widget at ``x_centers[i]`` renders at exactly the bar's centre - pixel when used with ``_xToFrac1d`` / ``_fracToPx1d`` in the JS renderer. + Accepts cell *edge* arrays (length N+1 / M+1) rather than centre arrays, + matches matplotlib's ``pcolormesh`` convention. Only ``'circles'`` and + ``'lines'`` markers are supported. """ - n = len(x_centers) - if n == 0: - return [0.0, 1.0] - if n == 1: - return [float(x_centers[0]) - 0.5, float(x_centers[0]) + 0.5] - slot = (float(x_centers[-1]) - float(x_centers[0])) / (n - 1) - half = slot / 2.0 - return [float(x_centers[0]) - half, float(x_centers[-1]) + half] + + def __init__(self, data: np.ndarray, + x_edges=None, y_edges=None, units: str = ""): + data = np.asarray(data) + if data.ndim != 2: + raise ValueError(f"data must be 2-D (M x N), got {data.shape}") + rows, cols = data.shape + + if x_edges is None: + x_edges = np.arange(cols + 1, dtype=float) + if y_edges is None: + y_edges = np.arange(rows + 1, dtype=float) + x_edges = np.asarray(x_edges, dtype=float) + y_edges = np.asarray(y_edges, dtype=float) + + if len(x_edges) != cols + 1: + raise ValueError( + f"x_edges must have length {cols + 1} for {cols} columns, " + f"got {len(x_edges)}") + if len(y_edges) != rows + 1: + raise ValueError( + f"y_edges must have length {rows + 1} for {rows} rows, " + f"got {len(y_edges)}") + + # Resample to a regular pixel grid for display + resampled = _resample_mesh(data, x_edges, y_edges) + + # Use cell centres to initialise the parent (axes will be replaced) + x_c = (x_edges[:-1] + x_edges[1:]) / 2.0 + y_c = (y_edges[:-1] + y_edges[1:]) / 2.0 + super().__init__(resampled, x_axis=x_c, y_axis=y_c, units=units) + + # Override mesh-specific state + self._state["is_mesh"] = True + self._state["has_axes"] = True + # Store edges (not centres) so the JS renderer can place grid lines + self._state["x_axis"] = x_edges.tolist() + self._state["y_axis"] = y_edges.tolist() + # Mesh panels have no fixed pixel scale + self._state.pop("scale_x", None) + self._state.pop("scale_y", None) + + # Restrict markers to circles + lines only + self.markers = MarkerRegistry(self._push_markers, + allowed=MarkerRegistry._KNOWN_MESH) + + # ------------------------------------------------------------------ + # Data update + # ------------------------------------------------------------------ + def update(self, data: np.ndarray, + x_edges=None, y_edges=None, units: str | None = None) -> None: + """Replace the mesh data (and optionally the edge arrays).""" + data = np.asarray(data) + if data.ndim != 2: + raise ValueError(f"data must be 2-D, got {data.shape}") + rows, cols = data.shape + + cur_xe = np.asarray(self._state["x_axis"], dtype=float) + cur_ye = np.asarray(self._state["y_axis"], dtype=float) + xe = np.asarray(x_edges, dtype=float) if x_edges is not None else cur_xe + ye = np.asarray(y_edges, dtype=float) if y_edges is not None else cur_ye + + if len(xe) != cols + 1: + raise ValueError(f"x_edges must have length {cols + 1}") + if len(ye) != rows + 1: + raise ValueError(f"y_edges must have length {rows + 1}") + + resampled = _resample_mesh(data, xe, ye) + img_u8, vmin, vmax = _normalize_image(resampled) + self._raw_u8, self._raw_vmin, self._raw_vmax = img_u8, vmin, vmax + + self._state.update({ + "image_b64": self._encode_bytes(img_u8), + "image_width": cols, + "image_height": rows, + "x_axis": xe.tolist(), + "y_axis": ye.tolist(), + "display_min": vmin, + "display_max": vmax, + "raw_min": vmin, + "raw_max": vmax, + "colormap_data": _build_colormap_lut(self._state["colormap_name"]), + }) + if units is not None: + self._state["units"] = units + self._push() # --------------------------------------------------------------------------- -# PlotBar +# _triangulate_grid helper + Plot3D # --------------------------------------------------------------------------- -class PlotBar: - """Bar-chart plot panel. +def _triangulate_grid(rows: int, cols: int) -> list: + """Return a flat list of [i0, i1, i2] triangle indices for an (rows×cols) grid.""" + faces = [] + for r in range(rows - 1): + for c in range(cols - 1): + i = r * cols + c + faces.append([i, i + 1, i + cols]) + faces.append([i + 1, i + cols + 1, i + cols]) + return faces - Not an anywidget. Holds state in ``_state`` dict; every mutation calls - ``_push()`` which writes to the parent Figure's panel trait. - Supports draggable :class:`~anyplotlib.widgets.VLineWidget` and - :class:`~anyplotlib.widgets.HLineWidget` overlays via - :meth:`add_vline_widget` / :meth:`add_hline_widget`. +class Plot3D: + """3-D plot panel. - Created by :meth:`Axes.bar`. + Supports three geometry types matching matplotlib's 3-D Axes API: + + * ``'surface'`` – triangulated surface, Z-coloured via colormap. + * ``'scatter'`` – point cloud, single colour. + * ``'line'`` – connected line through 3-D points. + + Created by :meth:`Axes.plot_surface`, :meth:`Axes.scatter3d`, + and :meth:`Axes.plot3d`. + + Not an anywidget. Holds state in ``_state`` dict; every mutation + calls ``_push()`` which writes to the parent Figure's panel trait. """ - def __init__(self, values, - x_labels=None, - x_centers=None, + def __init__(self, geom_type: str, + x, y, z, *, + colormap: str = "viridis", color: str = "#4fc3f7", - colors=None, - bar_width: float = 0.7, - orient: str = "v", - baseline: float = 0.0, - show_values: bool = False, - units: str = "", - y_units: str = ""): + point_size: float = 4.0, + linewidth: float = 1.5, + x_label: str = "x", + y_label: str = "y", + z_label: str = "z", + azimuth: float = -60.0, + elevation: float = 30.0, + zoom: float = 1.0): self._id: str = "" self._fig: object = None - values = np.asarray(values, dtype=float) - n = len(values) - if values.ndim != 1: - raise ValueError(f"values must be 1-D, got shape {values.shape}") - if orient not in ("v", "h"): - raise ValueError("orient must be 'v' or 'h'") - - if x_centers is None: - x_centers = np.arange(n, dtype=float) - x_centers = np.asarray(x_centers, dtype=float) - if len(x_centers) != n: - raise ValueError("x_centers length must match values length") - - val_min = float(np.nanmin(values)) if n else 0.0 - val_max = float(np.nanmax(values)) if n else 1.0 - dmin = min(float(baseline), val_min) - dmax = max(float(baseline), val_max) - pad = (dmax - dmin) * 0.07 if dmax > dmin else 0.5 - dmax += pad - if dmin < float(baseline): - dmin -= pad + geom_type = geom_type.lower() + if geom_type not in ("surface", "scatter", "line"): + raise ValueError("geom_type must be 'surface', 'scatter', or 'line'") + + x = np.asarray(x, dtype=float) + y = np.asarray(y, dtype=float) + z = np.asarray(z, dtype=float) + + if geom_type == "surface": + # Accept 2-D grid arrays (meshgrid style) or 1-D flat arrays + if x.ndim == 2 and y.ndim == 2 and z.ndim == 2: + rows, cols = z.shape + xf, yf, zf = x.ravel(), y.ravel(), z.ravel() + elif x.ndim == 1 and y.ndim == 1 and z.ndim == 2: + rows, cols = z.shape + if len(x) != cols or len(y) != rows: + raise ValueError( + "For surface with 1-D x/y: x must have length ncols " + "and y must have length nrows") + XX, YY = np.meshgrid(x, y) + xf, yf, zf = XX.ravel(), YY.ravel(), z.ravel() + else: + raise ValueError( + "Surface x/y/z must be 2-D grids of the same shape, " + "or 1-D x/y centre arrays with 2-D z.") + faces = _triangulate_grid(rows, cols) + vertices = np.column_stack([xf, yf, zf]).tolist() + z_values = zf.tolist() + else: + if x.ndim != 1 or y.ndim != 1 or z.ndim != 1: + raise ValueError("scatter/line x, y, z must be 1-D arrays") + if not (len(x) == len(y) == len(z)): + raise ValueError("x, y, z must have the same length") + vertices = np.column_stack([x, y, z]).tolist() + faces = [] + z_values = z.tolist() + + # Normalised data bounds for the JS renderer + all_x = np.asarray([v[0] for v in vertices]) + all_y = np.asarray([v[1] for v in vertices]) + all_z = np.asarray([v[2] for v in vertices]) + data_bounds = { + "xmin": float(all_x.min()), "xmax": float(all_x.max()), + "ymin": float(all_y.min()), "ymax": float(all_y.max()), + "zmin": float(all_z.min()), "zmax": float(all_z.max()), + } - # Compute physical x-axis extent (left/right edges of the bar chart) - # so that vline_widgets map to the correct pixel positions. - x_axis = _bar_x_axis(x_centers) + cmap_lut = _build_colormap_lut(colormap) self._state: dict = { - "kind": "bar", - "values": values.tolist(), - "x_centers": x_centers.tolist(), - "x_labels": list(x_labels) if x_labels is not None else [], - "bar_color": color, - "bar_colors": list(colors) if colors is not None else [], - "bar_width": float(bar_width), - "orient": orient, - "baseline": float(baseline), - "show_values": bool(show_values), - "data_min": dmin, - "data_max": dmax, - "units": units, - "y_units": y_units, - # overlay-widget coordinate system (mirrors Plot1D) - "x_axis": x_axis, - "view_x0": 0.0, - "view_x1": 1.0, - "overlay_widgets": [], + "kind": "3d", + "geom_type": geom_type, + "vertices": vertices, + "faces": faces, + "z_values": z_values, + "colormap_name": colormap, + "colormap_data": cmap_lut, + "color": color, + "point_size": float(point_size), + "linewidth": float(linewidth), + "x_label": x_label, + "y_label": y_label, + "z_label": z_label, + "azimuth": float(azimuth), + "elevation": float(elevation), + "zoom": float(zoom), + "data_bounds": data_bounds, "registered_keys": [], } self.callbacks = CallbackRegistry() - self._widgets: dict[str, Widget] = {} # ------------------------------------------------------------------ def _push(self) -> None: if self._fig is None: return - self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] self._fig._push(self._id) def to_state_dict(self) -> dict: - d = dict(self._state) - d["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] - return d + return dict(self._state) # ------------------------------------------------------------------ - # Data update + # Callback API (Plot3D) # ------------------------------------------------------------------ - def update(self, values, x_centers=None, x_labels=None) -> None: - """Replace bar values; recalculates the value-axis range automatically.""" - values = np.asarray(values, dtype=float) - if values.ndim != 1: - raise ValueError(f"values must be 1-D, got shape {values.shape}") + def on_changed(self, fn: Callable) -> Callable: + """Decorator: fires on every rotation/zoom frame.""" + cid = self.callbacks.connect("on_changed", fn) + fn._cid = cid + return fn - baseline = self._state["baseline"] - dmin = min(float(baseline), float(np.nanmin(values))) - dmax = max(float(baseline), float(np.nanmax(values))) - pad = (dmax - dmin) * 0.07 if dmax > dmin else 0.5 - dmax += pad - if dmin < baseline: - dmin -= pad + def on_release(self, fn: Callable) -> Callable: + """Decorator: fires once when rotation/zoom settles.""" + cid = self.callbacks.connect("on_release", fn) + fn._cid = cid + return fn - self._state["values"] = values.tolist() - self._state["data_min"] = dmin - self._state["data_max"] = dmax - if x_centers is not None: - xc = np.asarray(x_centers, dtype=float) - self._state["x_centers"] = xc.tolist() - self._state["x_axis"] = _bar_x_axis(xc) - if x_labels is not None: - self._state["x_labels"] = list(x_labels) - self._push() + def on_click(self, fn: Callable) -> Callable: + """Decorator: fires on click on this panel.""" + cid = self.callbacks.connect("on_click", fn) + fn._cid = cid + return fn - # ------------------------------------------------------------------ - # Display settings - # ------------------------------------------------------------------ - def set_color(self, color: str) -> None: - """Set a single colour for all bars.""" - self._state["bar_color"] = color - self._push() + def on_key(self, key_or_fn=None) -> Callable: + """Register a key-press handler for this panel. - def set_colors(self, colors) -> None: - """Set per-bar colours (list of CSS colour strings, length N).""" - self._state["bar_colors"] = list(colors) - self._push() + Two call forms are supported:: - def set_show_values(self, show: bool) -> None: - """Show or hide in-bar value annotations.""" - self._state["show_values"] = bool(show) - self._push() + @plot.on_key('q') # fires only when 'q' is pressed + def handler(event): ... - # ------------------------------------------------------------------ - # Overlay Widgets - # ------------------------------------------------------------------ - def add_vline_widget(self, x: float, color: str = "#00e5ff") -> _VLineWidget: - """Add a draggable vertical line widget at x position. + @plot.on_key # fires for every registered key + def handler(event): ... - Parameters - ---------- - x : float - Initial x-coordinate (in data units). - color : str, optional - CSS colour for the line. Default ``"#00e5ff"``. + The event carries: ``key``, ``mouse_x``, ``mouse_y``, and + ``last_widget_id``. - Returns - ------- - VLineWidget - The widget. Register callbacks via ``@widget.on_changed`` - or ``@widget.on_release``. + .. note:: + Registered keys take priority over the built-in **r** (reset view) + shortcut. """ - widget = _VLineWidget(lambda: None, x=float(x), color=color) - plot_ref, wid_id = self, widget._id - def _tp(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _tp - self._widgets[widget.id] = widget - self._push() - return widget + if callable(key_or_fn): + return self._connect_on_key(None, key_or_fn) + key = key_or_fn + def _decorator(fn): + return self._connect_on_key(key, fn) + return _decorator - def add_hline_widget(self, y: float, color: str = "#00e5ff") -> _HLineWidget: - """Add a draggable horizontal line widget at y position. + def _connect_on_key(self, key, fn) -> Callable: + if key is None: + if '*' not in self._state['registered_keys']: + self._state['registered_keys'].append('*') + self._push() + cid = self.callbacks.connect("on_key", fn) + else: + if key not in self._state['registered_keys']: + self._state['registered_keys'].append(key) + self._push() + def _wrapped(event): + if event.data.get('key') == key: + fn(event) + cid = self.callbacks.connect("on_key", _wrapped) + _wrapped._cid = cid + fn._cid = cid + return fn - Parameters - ---------- - y : float - Initial y-coordinate (in data units). - color : str, optional - CSS colour for the line. Default ``"#00e5ff"``. + def disconnect(self, cid: int) -> None: + """Remove the callback registered under integer *cid*.""" + self.callbacks.disconnect(cid) - Returns - ------- - HLineWidget - The widget. Register callbacks via ``@widget.on_changed`` - or ``@widget.on_release``. - """ + # ------------------------------------------------------------------ + # Display settings + # ------------------------------------------------------------------ + def set_colormap(self, name: str) -> None: + """Set the surface colormap (ignored for scatter/line).""" + self._state["colormap_name"] = name + self._state["colormap_data"] = _build_colormap_lut(name) + self._push() + + def set_view(self, azimuth: float | None = None, + elevation: float | None = None) -> None: + """Set the camera azimuth (°) and/or elevation (°).""" + if azimuth is not None: self._state["azimuth"] = float(azimuth) + if elevation is not None: self._state["elevation"] = float(elevation) + self._push() + + def set_zoom(self, zoom: float) -> None: + self._state["zoom"] = float(zoom) + self._push() + + def update(self, x, y, z) -> None: + """Replace the geometry data.""" + # Re-run the same logic as __init__ for the stored geom_type + geom_type = self._state["geom_type"] + x = np.asarray(x, dtype=float) + y = np.asarray(y, dtype=float) + z = np.asarray(z, dtype=float) + + if geom_type == "surface": + if x.ndim == 2 and y.ndim == 2 and z.ndim == 2: + rows, cols = z.shape + xf, yf, zf = x.ravel(), y.ravel(), z.ravel() + elif x.ndim == 1 and y.ndim == 1 and z.ndim == 2: + rows, cols = z.shape + XX, YY = np.meshgrid(x, y) + xf, yf, zf = XX.ravel(), YY.ravel(), z.ravel() + else: + raise ValueError("Surface x/y/z must be 2-D grids or 1-D+2-D.") + faces = _triangulate_grid(rows, cols) + vertices = np.column_stack([xf, yf, zf]).tolist() + z_values = zf.tolist() + else: + vertices = np.column_stack([x.ravel(), y.ravel(), z.ravel()]).tolist() + faces = [] + z_values = z.ravel().tolist() + + all_x = np.asarray([v[0] for v in vertices]) + all_y = np.asarray([v[1] for v in vertices]) + all_z = np.asarray([v[2] for v in vertices]) + data_bounds = { + "xmin": float(all_x.min()), "xmax": float(all_x.max()), + "ymin": float(all_y.min()), "ymax": float(all_y.max()), + "zmin": float(all_z.min()), "zmax": float(all_z.max()), + } + + self._state.update({ + "vertices": vertices, + "faces": faces, + "z_values": z_values, + "data_bounds": data_bounds, + "colormap_data": _build_colormap_lut(self._state["colormap_name"]), + }) + self._push() + + def __repr__(self) -> str: + geom = self._state.get("geom_type", "?") + n = len(self._state.get("vertices", [])) + return f"Plot3D(geom={geom!r}, n_vertices={n})" + + +# --------------------------------------------------------------------------- +# Plot1D +# --------------------------------------------------------------------------- + +class Plot1D: + """1-D line plot panel. + + Holds state in ``_state`` dict; every mutation pushes to Figure trait. + Exposes the full Viewer1D-compatible API plus the new marker API. + """ + + def __init__(self, data: np.ndarray, + x_axis=None, + units: str = "px", + y_units: str = "", + color: str = "#4fc3f7", + linewidth: float = 1.5, + label: str = ""): + self._id: str = "" + self._fig: object = None + + data = np.asarray(data, dtype=float) + if data.ndim != 1: + raise ValueError(f"data must be 1-D, got {data.shape}") + n = len(data) + if x_axis is None: + x_axis = np.arange(n, dtype=float) + x_axis = np.asarray(x_axis, dtype=float) + if len(x_axis) != n: + raise ValueError("x_axis length must match data length") + + dmin = float(np.nanmin(data)) + dmax = float(np.nanmax(data)) + pad = (dmax - dmin) * 0.05 if dmax > dmin else 0.5 + dmin -= pad; dmax += pad + + self._state: dict = { + "kind": "1d", + "data": data.tolist(), + "x_axis": x_axis.tolist(), + "units": units, + "y_units": y_units, + "data_min": dmin, + "data_max": dmax, + "view_x0": 0.0, + "view_x1": 1.0, + "line_color": color, + "line_linewidth": float(linewidth), + "line_label": label, + "extra_lines": [], + "spans": [], + "overlay_widgets": [], + "markers": [], + "registered_keys": [], + } + + self.markers = MarkerRegistry(self._push_markers, + allowed=MarkerRegistry._KNOWN_1D) + self.callbacks = CallbackRegistry() + self._widgets: dict[str, Widget] = {} + + def _push(self) -> None: + if self._fig is None: + return + self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] + self._fig._push(self._id) + + def _push_markers(self) -> None: + self._state["markers"] = self.markers.to_wire_list() + self._push() + + def to_state_dict(self) -> dict: + d = dict(self._state) + d["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] + d["markers"] = self.markers.to_wire_list() + return d + + # ------------------------------------------------------------------ + # Data update + # ------------------------------------------------------------------ + def update(self, data: np.ndarray, x_axis=None, + units: str | None = None, y_units: str | None = None) -> None: + data = np.asarray(data, dtype=float) + if data.ndim != 1: + raise ValueError(f"data must be 1-D, got {data.shape}") + n = len(data) + if x_axis is None: + prev = np.asarray(self._state["x_axis"]) + x_axis = prev if len(prev) == n else np.arange(n, dtype=float) + x_axis = np.asarray(x_axis, dtype=float) + + dmin = float(np.nanmin(data)) + dmax = float(np.nanmax(data)) + pad = (dmax - dmin) * 0.05 if dmax > dmin else 0.5 + + self._state["data"] = data.tolist() + self._state["x_axis"] = x_axis.tolist() + self._state["data_min"] = dmin - pad + self._state["data_max"] = dmax + pad + if units is not None: self._state["units"] = units + if y_units is not None: self._state["y_units"] = y_units + self._push() + + # ------------------------------------------------------------------ + # Extra lines + # ------------------------------------------------------------------ + def add_line(self, data: np.ndarray, x_axis=None, + color: str = "#ffffff", linewidth: float = 1.5, + label: str = "") -> str: + data = np.asarray(data, dtype=float) + if data.ndim != 1: + raise ValueError("data must be 1-D") + xa = (np.asarray(x_axis, float).tolist() if x_axis is not None + else self._state["x_axis"]) + lid = str(_uuid.uuid4())[:8] + self._state["extra_lines"].append({ + "id": lid, "data": data.tolist(), "x_axis": xa, + "color": color, "linewidth": float(linewidth), "label": label, + }) + self._push() + return lid + + def remove_line(self, lid: str) -> None: + before = len(self._state["extra_lines"]) + self._state["extra_lines"] = [ + e for e in self._state["extra_lines"] if e["id"] != lid] + if len(self._state["extra_lines"]) == before: + raise KeyError(lid) + self._push() + + def clear_lines(self) -> None: + self._state["extra_lines"] = [] + self._push() + + # ------------------------------------------------------------------ + # Spans + # ------------------------------------------------------------------ + def add_span(self, v0: float, v1: float, + axis: str = "x", color: str | None = None) -> str: + sid = str(_uuid.uuid4())[:8] + self._state["spans"].append({ + "id": sid, "v0": float(v0), "v1": float(v1), + "axis": axis, "color": color, + }) + self._push() + return sid + + def remove_span(self, sid: str) -> None: + before = len(self._state["spans"]) + self._state["spans"] = [ + s for s in self._state["spans"] if s["id"] != sid] + if len(self._state["spans"]) == before: + raise KeyError(sid) + self._push() + + def clear_spans(self) -> None: + self._state["spans"] = [] + self._push() + + # ------------------------------------------------------------------ + # Overlay Widgets + # ------------------------------------------------------------------ + def add_vline_widget(self, x: float, color: str = "#00e5ff") -> _VLineWidget: + widget = _VLineWidget(lambda: None, x=float(x), color=color) + plot_ref, wid_id = self, widget._id + def _tp(): + if plot_ref._fig is not None: + fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} + plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) + widget._push_fn = _tp + self._widgets[widget.id] = widget + self._push() + return widget + + def add_hline_widget(self, y: float, color: str = "#00e5ff") -> _HLineWidget: widget = _HLineWidget(lambda: None, y=float(y), color=color) plot_ref, wid_id = self, widget._id def _tp(): @@ -1403,25 +1560,6 @@ def _tp(): def add_range_widget(self, x0: float, x1: float, color: str = "#00e5ff") -> _RangeWidget: - """Add a draggable range (two connected vertical lines) widget. - - Parameters - ---------- - x0, x1 : float - Initial left and right x-coordinates (in data units). - color : str, optional - CSS colour for the lines. Default ``"#00e5ff"``. - - Returns - ------- - RangeWidget - The widget. Register callbacks via ``@widget.on_changed`` - or ``@widget.on_release``. - - Notes - ----- - Dragging either line updates both x0 and x1 in the widget. - """ widget = _RangeWidget(lambda: None, x0=float(x0), x1=float(x1), color=color) plot_ref, wid_id = self, widget._id def _tp(): @@ -1462,56 +1600,19 @@ def clear_widgets(self) -> None: # Callback API (Plot1D) # ------------------------------------------------------------------ def on_changed(self, fn: Callable) -> Callable: - """Decorator: fires on every drag/zoom frame on this panel. - - Use this for high-frequency updates (keep the handler fast). - - Parameters - ---------- - fn : Callable - Handler function receiving an Event. - - Returns - ------- - Callable - The decorated function. - """ + """Decorator: fires on every drag/zoom frame on this panel.""" cid = self.callbacks.connect("on_changed", fn) fn._cid = cid return fn def on_release(self, fn: Callable) -> Callable: - """Decorator: fires once when drag/zoom settles on this panel. - - Use this for expensive operations. - - Parameters - ---------- - fn : Callable - Handler function receiving an Event. - - Returns - ------- - Callable - The decorated function. - """ + """Decorator: fires once when drag/zoom settles on this panel.""" cid = self.callbacks.connect("on_release", fn) fn._cid = cid return fn def on_click(self, fn: Callable) -> Callable: - """Decorator: fires on click on this panel. - - Parameters - ---------- - fn : Callable - Handler function receiving an Event. - - Returns - ------- - Callable - The decorated function. - """ + """Decorator: fires on click on this panel.""" cid = self.callbacks.connect("on_click", fn) fn._cid = cid return fn @@ -1726,6 +1827,294 @@ def list_markers(self) -> list: out.append({"type": mtype, "name": name, "n": g._count()}) return out + def __repr__(self) -> str: + n = len(self._state.get("data", [])) + color = self._state.get("line_color", "?") + return f"Plot1D(n={n}, color={color!r})" + + +# --------------------------------------------------------------------------- +# _bar_x_axis helper +# --------------------------------------------------------------------------- + +def _bar_x_axis(x_centers: np.ndarray) -> list: + """Return a 2-element [x_left_edge, x_right_edge] list for a bar chart. + + The edges are half a slot-width outside the first/last bar centre so that + a vline_widget at ``x_centers[i]`` renders at exactly the bar's centre + pixel when used with ``_xToFrac1d`` / ``_fracToPx1d`` in the JS renderer. + """ + n = len(x_centers) + if n == 0: + return [0.0, 1.0] + if n == 1: + return [float(x_centers[0]) - 0.5, float(x_centers[0]) + 0.5] + slot = (float(x_centers[-1]) - float(x_centers[0])) / (n - 1) + half = slot / 2.0 + return [float(x_centers[0]) - half, float(x_centers[-1]) + half] + + +# --------------------------------------------------------------------------- +# PlotBar +# --------------------------------------------------------------------------- + +class PlotBar: + """Bar-chart plot panel. + + Not an anywidget. Holds state in ``_state`` dict; every mutation calls + ``_push()`` which writes to the parent Figure's panel trait. + + Supports draggable :class:`~anyplotlib.widgets.VLineWidget` and + :class:`~anyplotlib.widgets.HLineWidget` overlays via + :meth:`add_vline_widget` / :meth:`add_hline_widget`. + + Created by :meth:`Axes.bar`. + """ + + def __init__(self, values, + x_labels=None, + x_centers=None, + color: str = "#4fc3f7", + colors=None, + bar_width: float = 0.7, + orient: str = "v", + baseline: float = 0.0, + show_values: bool = False, + units: str = "", + y_units: str = ""): + self._id: str = "" + self._fig: object = None + + values = np.asarray(values, dtype=float) + n = len(values) + if values.ndim != 1: + raise ValueError(f"values must be 1-D, got shape {values.shape}") + if orient not in ("v", "h"): + raise ValueError("orient must be 'v' or 'h'") + + if x_centers is None: + x_centers = np.arange(n, dtype=float) + x_centers = np.asarray(x_centers, dtype=float) + if len(x_centers) != n: + raise ValueError("x_centers length must match values length") + + val_min = float(np.nanmin(values)) if n else 0.0 + val_max = float(np.nanmax(values)) if n else 1.0 + dmin = min(float(baseline), val_min) + dmax = max(float(baseline), val_max) + pad = (dmax - dmin) * 0.07 if dmax > dmin else 0.5 + dmax += pad + if dmin < float(baseline): + dmin -= pad + + # Compute physical x-axis extent (left/right edges of the bar chart) + # so that vline_widgets map to the correct pixel positions. + x_axis = _bar_x_axis(x_centers) + + self._state: dict = { + "kind": "bar", + "values": values.tolist(), + "x_centers": x_centers.tolist(), + "x_labels": list(x_labels) if x_labels is not None else [], + "bar_color": color, + "bar_colors": list(colors) if colors is not None else [], + "bar_width": float(bar_width), + "orient": orient, + "baseline": float(baseline), + "show_values": bool(show_values), + "data_min": dmin, + "data_max": dmax, + "units": units, + "y_units": y_units, + # overlay-widget coordinate system (mirrors Plot1D) + "x_axis": x_axis, + "view_x0": 0.0, + "view_x1": 1.0, + "overlay_widgets": [], + "registered_keys": [], + } + self.callbacks = CallbackRegistry() + self._widgets: dict[str, Widget] = {} + + # ------------------------------------------------------------------ + def _push(self) -> None: + if self._fig is None: + return + self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] + self._fig._push(self._id) + + def to_state_dict(self) -> dict: + d = dict(self._state) + d["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] + return d + + # ------------------------------------------------------------------ + # Data update + # ------------------------------------------------------------------ + def update(self, values, x_centers=None, x_labels=None) -> None: + """Replace bar values; recalculates the value-axis range automatically.""" + values = np.asarray(values, dtype=float) + if values.ndim != 1: + raise ValueError(f"values must be 1-D, got shape {values.shape}") + + baseline = self._state["baseline"] + dmin = min(float(baseline), float(np.nanmin(values))) + dmax = max(float(baseline), float(np.nanmax(values))) + pad = (dmax - dmin) * 0.07 if dmax > dmin else 0.5 + dmax += pad + if dmin < baseline: + dmin -= pad + + self._state["values"] = values.tolist() + self._state["data_min"] = dmin + self._state["data_max"] = dmax + if x_centers is not None: + xc = np.asarray(x_centers, dtype=float) + self._state["x_centers"] = xc.tolist() + self._state["x_axis"] = _bar_x_axis(xc) + if x_labels is not None: + self._state["x_labels"] = list(x_labels) + self._push() + + # ------------------------------------------------------------------ + # Display settings + # ------------------------------------------------------------------ + def set_color(self, color: str) -> None: + """Set a single colour for all bars.""" + self._state["bar_color"] = color + self._push() + + def set_colors(self, colors) -> None: + """Set per-bar colours (list of CSS colour strings, length N).""" + self._state["bar_colors"] = list(colors) + self._push() + + def set_show_values(self, show: bool) -> None: + """Show or hide in-bar value annotations.""" + self._state["show_values"] = bool(show) + self._push() + + # ------------------------------------------------------------------ + # Overlay Widgets + # ------------------------------------------------------------------ + def add_vline_widget(self, x: float, color: str = "#00e5ff") -> _VLineWidget: + """Add a draggable vertical line at data position *x*.""" + widget = _VLineWidget(lambda: None, x=float(x), color=color) + plot_ref, wid_id = self, widget._id + def _tp(): + if plot_ref._fig is not None: + fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} + plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) + widget._push_fn = _tp + self._widgets[widget.id] = widget + self._push() + return widget + + def add_hline_widget(self, y: float, color: str = "#00e5ff") -> _HLineWidget: + """Add a draggable horizontal line at value-axis position *y*.""" + widget = _HLineWidget(lambda: None, y=float(y), color=color) + plot_ref, wid_id = self, widget._id + def _tp(): + if plot_ref._fig is not None: + fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} + plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) + widget._push_fn = _tp + self._widgets[widget.id] = widget + self._push() + return widget + + def get_widget(self, wid) -> Widget: + """Return the Widget object by ID string or Widget instance.""" + if isinstance(wid, Widget): + wid = wid.id + try: + return self._widgets[wid] + except KeyError: + raise KeyError(wid) + + def remove_widget(self, wid) -> None: + """Remove a widget by ID string or Widget instance.""" + if isinstance(wid, Widget): + wid = wid.id + if wid not in self._widgets: + raise KeyError(wid) + del self._widgets[wid] + self._push() + + def list_widgets(self) -> list: + return list(self._widgets.values()) + + def clear_widgets(self) -> None: + self._widgets.clear() + self._push() + + # ------------------------------------------------------------------ + # Callbacks + # ------------------------------------------------------------------ + def on_click(self, fn: Callable) -> Callable: + """Decorator: fires when the user clicks a bar. + + The :class:`~anyplotlib.callbacks.Event` has ``bar_index``, + ``value``, ``x_center``, and ``x_label``. + """ + cid = self.callbacks.connect("on_click", fn) + fn._cid = cid + return fn + + def on_changed(self, fn: Callable) -> Callable: + """Decorator: fires on every drag frame (widget drag or hover).""" + cid = self.callbacks.connect("on_changed", fn) + fn._cid = cid + return fn + + def on_release(self, fn: Callable) -> Callable: + """Decorator: fires once when a widget drag settles.""" + cid = self.callbacks.connect("on_release", fn) + fn._cid = cid + return fn + + def on_key(self, key_or_fn=None) -> Callable: + """Register a key-press handler for this panel. + + Two call forms are supported:: + + @plot.on_key('q') # fires only when 'q' is pressed + def handler(event): ... + + @plot.on_key # fires for every registered key + def handler(event): ... + + The event carries: ``key``, ``mouse_x``, ``mouse_y``, and + ``last_widget_id``. + """ + if callable(key_or_fn): + return self._connect_on_key(None, key_or_fn) + key = key_or_fn + def _decorator(fn): + return self._connect_on_key(key, fn) + return _decorator + + def _connect_on_key(self, key, fn) -> Callable: + if key is None: + if '*' not in self._state['registered_keys']: + self._state['registered_keys'].append('*') + self._push() + cid = self.callbacks.connect("on_key", fn) + else: + if key not in self._state['registered_keys']: + self._state['registered_keys'].append(key) + self._push() + def _wrapped(event): + if event.data.get('key') == key: + fn(event) + cid = self.callbacks.connect("on_key", _wrapped) + _wrapped._cid = cid + fn._cid = cid + return fn + + def disconnect(self, cid: int) -> None: + self.callbacks.disconnect(cid) + def __repr__(self) -> str: n = len(self._state.get("values", [])) orient = self._state.get("orient", "v") @@ -1735,5 +2124,3 @@ def __repr__(self) -> str: - - From 9f4025108c41b28083442ba1e2e1c8892a10f363 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Thu, 19 Mar 2026 14:13:07 -0500 Subject: [PATCH 027/198] Implement zoom-out handling in _imgToCanvas2d and update documentation for consistency --- anyplotlib/FIGURE_ESM.md | 9 +++++---- anyplotlib/figure_esm.js | 13 +++++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/anyplotlib/FIGURE_ESM.md b/anyplotlib/FIGURE_ESM.md index 61aec775..fc16f999 100644 --- a/anyplotlib/FIGURE_ESM.md +++ b/anyplotlib/FIGURE_ESM.md @@ -174,9 +174,9 @@ st.share_axes | **`_imgFitRect(iw,ih,cw,ch)`** | **465** | **Returns `{x,y,w,h,s}` — the largest rect of aspect `iw:ih` centred in `cw×ch`. `s` = canvas-px per image-px. All 2-D coordinate functions derive from this.** | | `_buildLut32(st)` | 471 | Build 256-entry `Uint32Array` LUT from colormap + scale mode | | `_lutKey(st)` | 499 | String cache key (invalidates when colormap/range changes) | -| `_imgToCanvas2d(ix,iy,st,pw,ph)` | 503 | Image pixel → canvas pixel using `_imgFitRect` + zoom/pan | +| `_imgToCanvas2d(ix,iy,st,pw,ph)` | 503 | Image pixel → canvas pixel. **zoom≥1**: uses pan/crop formula. **zoom<1**: maps through the centred-shrink geometry (`dstX = x+(w-w·zoom)/2`) — must match `_blit2d` exactly. | | `_imgScale2d(st,pw,ph)` | 514 | Returns `_imgFitRect(…).s * zoom` — canvas-px per image-px at current zoom | -| `_blit2d(bitmap,st,pw,ph,ctx)` | 518 | **Contain render**: clears canvas to `bgCanvas`, draws image inside fit-rect. zoom≥1 → crops + fills fit-rect; zoom<1 → shrinks fit-rect proportionally | +| `_blit2d(bitmap,st,pw,ph,ctx)` | 518 | **Contain render**: clears canvas to `bgCanvas`, draws image inside fit-rect. zoom≥1 → crops + fills fit-rect; zoom<1 → shrinks fit-rect proportionally (centred) | | `draw2d(p)` | 540 | Main 2D render: decode bytes → LUT → ImageBitmap → `_blit2d`; then axes, scale bar, colorbar, overlay, markers | | `drawScaleBar2d(p)` | 587 | Physical scale bar using `fr.w` (fit-rect width) for pixel sizing | | `drawColorbar2d(p)` | 663 | Colorbar strip with gradient + tick labels | @@ -251,8 +251,9 @@ Writes to `model.event_json` + `save_changes()`. ### Panel-level event handlers (lines 1618–1805) - **`_attachPanelEvents(p)`** (line 1619) — dispatches to kind-specific attach fn. -- **`_canvasToImg2d(px,py,st,pw,ph)`** (line 1626) — inverts `_imgToCanvas2d`; - derives fit-rect geometry from `_imgFitRect` so both directions are consistent. +- **`_canvasToImg2d(px,py,st,pw,ph)`** (line 1626) — inverts `_imgToCanvas2d` exactly; + uses the zoom≥1 pan/crop formula or the zoom<1 centred-shrink inverse so both + directions are always consistent with `_blit2d`. - **`_attachEvents2d(p)`** (line 1636) — 2D mouse events: - **Wheel zoom** — calls `_canvasToImg2d` to find the image-space anchor before zoom; recomputes `center_x/y` via `_imgFitRect` so the same image diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 477a99dc..fd23d628 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -506,6 +506,13 @@ function render({ model, el }) { const { x, y, w, h } = _imgFitRect(st.image_width, st.image_height, pw, ph); const zoom = st.zoom, cx = st.center_x, cy = st.center_y; const iw = st.image_width, ih = st.image_height; + if (zoom < 1.0) { + // Zoom-out path: full image drawn centred inside a scaled-down fit-rect + // (mirrors the zoom<1 branch in _blit2d exactly). + const dstW = w * zoom, dstH = h * zoom; + const dstX = x + (w - dstW) / 2, dstY = y + (h - dstH) / 2; + return [dstX + (ix / iw) * dstW, dstY + (iy / ih) * dstH]; + } const visW = iw / zoom, visH = ih / zoom; const srcX = Math.max(0, Math.min(iw - visW, cx * iw - visW / 2)); const srcY = Math.max(0, Math.min(ih - visH, cy * ih - visH / 2)); @@ -1647,6 +1654,12 @@ function render({ model, el }) { const { x, y, w, h } = _imgFitRect(st.image_width, st.image_height, pw, ph); const zoom = st.zoom, cx = st.center_x, cy = st.center_y; const iw = st.image_width, ih = st.image_height; + if (zoom < 1.0) { + // Zoom-out path: inverse of the centred-shrink in _blit2d. + const dstW = w * zoom, dstH = h * zoom; + const dstX = x + (w - dstW) / 2, dstY = y + (h - dstH) / 2; + return [(px - dstX) / dstW * iw, (py - dstY) / dstH * ih]; + } const visW = iw / zoom, visH = ih / zoom; const srcX = Math.max(0, Math.min(iw - visW, cx * iw - visW / 2)); const srcY = Math.max(0, Math.min(ih - visH, cy * ih - visH / 2)); From d7a3a1563e41e2c01a0140ed94c8194e0aa74838 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Thu, 19 Mar 2026 14:36:17 -0500 Subject: [PATCH 028/198] Add responsive iframe embedding for interactive widgets in HTML output --- docs/_sg_html_scraper.py | 85 +++++++++++++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 19 deletions(-) diff --git a/docs/_sg_html_scraper.py b/docs/_sg_html_scraper.py index 8edf07ce..74f1b4cc 100644 --- a/docs/_sg_html_scraper.py +++ b/docs/_sg_html_scraper.py @@ -17,6 +17,13 @@ import io from pathlib import Path +from uuid import uuid4 + +# Maximum iframe width (px) that fits comfortably inside the pydata-sphinx-theme +# content column on a desktop browser. Figures wider than this are scaled down +# proportionally via CSS transform; a JS resize listener makes the embed fully +# responsive so it also looks correct on tablets and phones. +MAX_DOC_WIDTH = 684 # --------------------------------------------------------------------------- @@ -99,6 +106,63 @@ def _make_thumbnail_png(widget) -> bytes: return buf.read() +def _iframe_html(src: str, w: int, h: int) -> str: + """Return a single-line HTML snippet that embeds *src* responsively. + + The iframe is always rendered at its native resolution (``w × h`` px) so + the interactive widget is pixel-perfect on wide screens. On narrower + viewports (docs sidebar layout, tablet, phone) a CSS ``transform:scale()`` + shrinks the whole iframe proportionally — CSS transforms correctly + translate pointer events, so dragging and scrolling continue to work. + + A tiny inline script re-runs the scale calculation on every ``resize`` + event so the embed reflows without a page reload. + """ + uid = f"f{uuid4().hex[:8]}" + + # Static initial scale so the page renders correctly before JS runs + init_scale = min(1.0, MAX_DOC_WIDTH / w) + init_w = round(w * init_scale) + init_h = round(h * init_scale) + scale_css = f"{init_scale:.6f}".rstrip("0").rstrip(".") + + # Inline JS: re-scale whenever the window is resized. + # Uses the wrapper's parent width as the available space so the figure + # always fills (but never overflows) the content column. + js = ( + f"(function(){{" + f"var wrap=document.getElementById('{uid}')," + f"ifr=wrap.querySelector('iframe')," + f"nw={w},nh={h};" + f"function r(){{" + f"var avail=wrap.parentElement?wrap.parentElement.offsetWidth:nw;" + f"var s=Math.min(1,avail/nw);" + f"wrap.style.width=Math.round(nw*s)+'px';" + f"wrap.style.height=Math.round(nh*s)+'px';" + f"ifr.style.transform='scale('+s+')';" + f"}}" + f"r();window.addEventListener('resize',r);" + f"}})()" + ) + + # The wrapper is sized to the *scaled* dimensions and clips overflow. + # The iframe is absolutely positioned at (0,0) at its full native size; + # CSS transform scales it to fit exactly inside the wrapper. + return ( + f'
' + f'
' + f'' + f'
' + f'' + f'
' + ) + + # --------------------------------------------------------------------------- # Scraper # --------------------------------------------------------------------------- @@ -155,35 +219,18 @@ def __call__(self, block, block_vars, gallery_conf): # ── 3. Return rST ────────────────────────────────────────────────── if interactive: - # Compute the relative path from the *built* HTML page back up to - # _static/viewer_widgets/. - # - # The PNG (and its sibling HTML) sits at e.g.: - # /auto_examples/Markers/images/sphx_glr_plot_circles_001.png - # The built page for this example is at: - # /auto_examples/Markers/plot_circles.html - # _static/viewer_widgets/ lives at: - # /_static/viewer_widgets/ - # - # We derive depth by counting the parts of the gallery output path - # relative to the Sphinx source dir (which mirrors the build root). try: src_dir = Path(gallery_conf["src_dir"]) - # png_path is inside the gallery output images/ subdir. - # The page itself is one directory above images/. page_dir = png_path.parent.parent # strip /images rel_parts = page_dir.relative_to(src_dir).parts - depth = len(rel_parts) # e.g. 2 for auto_examples/Markers + depth = len(rel_parts) except Exception: depth = 1 prefix = "../" * depth src = f"{prefix}_static/viewer_widgets/{html_name}" return ( "\n\n.. raw:: html\n\n" - f'
' - f'
\n\n' + " " + _iframe_html(src, w, h) + "\n\n" ) else: rel_png = png_path.name From 2fd8318ce75b21ee8e71d6b4cb471aee4544ded4 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Thu, 19 Mar 2026 14:51:12 -0500 Subject: [PATCH 029/198] Refactor figure.py: streamline attribute documentation and enhance parameter descriptions for subplot specification --- anyplotlib/figure.py | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/anyplotlib/figure.py b/anyplotlib/figure.py index f63712ca..d6356b4b 100644 --- a/anyplotlib/figure.py +++ b/anyplotlib/figure.py @@ -61,16 +61,6 @@ class Figure(anywidget.AnyWidget): Link pan/zoom across all panels on the respective axis. Default False (independent pan/zoom per panel). - Attributes - ---------- - fig_width : int - Current figure width in pixels (synced with JS). - fig_height : int - Current figure height in pixels (synced with JS). - layout_json : str - JSON serialization of the grid layout (synced with JS). - event_json : str - JSON serialization of interaction events from JS. See Also -------- @@ -80,8 +70,7 @@ class Figure(anywidget.AnyWidget): layout_json = traitlets.Unicode("{}").tag(sync=True) fig_width = traitlets.Int(640).tag(sync=True) fig_height = traitlets.Int(480).tag(sync=True) - # Bidirectional js. Events have an object id and some - # data that + # Bidirectional JS event bus: JS writes interaction events here, Python reads them. event_json = traitlets.Unicode("{}").tag(sync=True) _esm = _ESM_SOURCE @@ -108,12 +97,12 @@ def add_subplot(self, spec) -> Axes: Parameters ---------- - spec : SubplotSpec or int or (row, col) tuple - Specifies which grid cell(s) to occupy: - - ``SubplotSpec``: used directly (e.g. from ``GridSpec[r, c]``). - - ``int``: converted to ``(row, col)`` via ``divmod(spec, ncols)``, - matching ``matplotlib.Figure.add_subplot(num)`` numbering. - - ``(row, col)`` tuple: selects a single cell. + spec : SubplotSpec or int or tuple of (row, col) + Which grid cell(s) to occupy. A :class:`SubplotSpec` is used + directly (e.g. from ``GridSpec[r, c]``). An :class:`int` is + converted via ``divmod(spec, ncols)``, matching + ``matplotlib.Figure.add_subplot`` numbering. A ``(row, col)`` + tuple selects a single cell. Returns ------- From a79cbd561422ff344fb2b1de7b11b8f539d22cda Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Thu, 19 Mar 2026 15:51:29 -0500 Subject: [PATCH 030/198] Enhance responsive iframe embedding: add max width for notebook display and improve scaling logic for interactive widgets --- anyplotlib/_repr_utils.py | 67 ++++++++++++++++++---- anyplotlib/figure.py | 25 +++++++++ anyplotlib/figure_esm.js | 113 +++++++++++++++++--------------------- docs/_sg_html_scraper.py | 11 +++- 4 files changed, 139 insertions(+), 77 deletions(-) diff --git a/anyplotlib/_repr_utils.py b/anyplotlib/_repr_utils.py index e79f4899..555222c6 100644 --- a/anyplotlib/_repr_utils.py +++ b/anyplotlib/_repr_utils.py @@ -24,6 +24,11 @@ from html import escape from uuid import uuid4 +# Maximum display width (px) for the non-resizable notebook embed. +# Figures wider than this are scaled down proportionally via CSS transform. +# 860 px fits comfortably in a standard JupyterLab / VS Code notebook cell. +MAX_NOTEBOOK_WIDTH = 860 + # --------------------------------------------------------------------------- # Trait serialisation @@ -204,8 +209,9 @@ def build_standalone_html(widget, *, resizable: bool = True) -> str: def repr_html_iframe(widget, *, resizable: bool = False, + max_width: int = MAX_NOTEBOOK_WIDTH, max_height: int = 800) -> str: - """Return a centred ``' f'' + f'' + f'' ) else: - # Resizable — fill container width, auto-resize height after render. + # ── Resizable embed (fills cell width, auto-sizes height) ────────── return ( f'' + f'' + f'' + f'' + ) + else: + return ( + f'' + ) + diff --git a/anyplotlib/sphinx_anywidget/_scraper.py b/anyplotlib/sphinx_anywidget/_scraper.py new file mode 100644 index 00000000..18e2b5e6 --- /dev/null +++ b/anyplotlib/sphinx_anywidget/_scraper.py @@ -0,0 +1,327 @@ +""" +sphinx_anywidget/_scraper.py +============================= + +Generic Sphinx Gallery image scraper for any ``anywidget.AnyWidget`` subclass. + +Drop-in replacement for the anyplotlib-specific ``_sg_html_scraper.ViewerScraper``. +Works with **any** library built on anywidget — just add the scraper to your +``sphinx_gallery_conf["image_scrapers"]``. + +Interactive tagging +------------------- +If a code block's last expression line contains a ``# Interactive`` comment +(case-insensitive), the scraper: + +* embeds the full example Python source in a ``' + f'' + ) + + +# --------------------------------------------------------------------------- +# Scraper +# --------------------------------------------------------------------------- + +class AnywidgetScraper: + """Sphinx Gallery image scraper for any ``anywidget.AnyWidget`` subclass. + + Parameters + ---------- + static_icon : str + Unicode character shown as the "static snapshot" badge. + Default ``"📷"``. Override via the ``anywidget_static_icon`` Sphinx + config value when using the ``sphinx_anywidget`` extension. + """ + + def __init__(self, static_icon: str = "\U0001f4f7"): + self.static_icon = static_icon + # Maps src_file → list of fig_ids emitted so far (creation order). + self._example_figs: dict = {} + + def __repr__(self) -> str: + return "AnywidgetScraper()" + + def __call__(self, block, block_vars, gallery_conf): + globals_dict = block_vars.get("example_globals", {}) + widget = _find_widget(globals_dict) + if widget is None: + return "" + + src_file = str(block_vars.get("src_file", "")) + + # ── detect # Interactive tag ────────────────────────────────────── + block_source = block[1] if isinstance(block, (list, tuple)) else "" + is_interactive = bool(_INTERACTIVE_RE.search(block_source)) + + # ── assign a stable fig_id and fig_index ───────────────────────── + if src_file not in self._example_figs: + self._example_figs[src_file] = [] + fig_index = len(self._example_figs[src_file]) + + # ── 1. Write the thumbnail PNG ──────────────────────────────────── + image_path_iterator = block_vars["image_path_iterator"] + png_path = Path(next(image_path_iterator)) + png_path.parent.mkdir(parents=True, exist_ok=True) + png_path.write_bytes(_make_thumbnail_png(widget)) + + fig_id = png_path.stem # stable, unique stem from Sphinx Gallery + self._example_figs[src_file].append(fig_id) + + # ── 2. Write the standalone HTML ────────────────────────────────── + try: + from anyplotlib.sphinx_anywidget._repr_utils import ( + build_standalone_html, _widget_px, + ) + docs_dir = Path(gallery_conf["src_dir"]) + widgets_dir = docs_dir / "_static" / "viewer_widgets" + widgets_dir.mkdir(parents=True, exist_ok=True) + + html_name = png_path.stem + ".html" + html_path = widgets_dir / html_name + + inner_html = build_standalone_html(widget, resizable=False, fig_id=fig_id) + html_path.write_text(inner_html, encoding="utf-8") + w, h = _widget_px(widget) + have_html = True + except Exception as exc: + print(f"[sphinx_anywidget] WARNING: could not write iframe HTML: {exc}") + have_html = False + + # ── 3. Return rST ───────────────────────────────────────────────── + if have_html: + try: + src_dir = Path(gallery_conf["src_dir"]) + page_dir = png_path.parent.parent # strip /images + rel_parts = page_dir.relative_to(src_dir).parts + depth = len(rel_parts) + except Exception: + depth = 1 + prefix = "../" * depth + src = f"{prefix}_static/viewer_widgets/{html_name}" + + iframe_block = _iframe_html( + src, w, h, + fig_id=fig_id, + interactive=is_interactive, + static_icon=self.static_icon, + ) + + rst = "\n\n.. raw:: html\n\n " + iframe_block + "\n\n" + + if is_interactive: + # Embed the example Python source so the Pyodide bridge can + # re-execute it and wire live callbacks. + python_src = "" + try: + python_src = Path(src_file).read_text(encoding="utf-8") + except Exception: + pass + + if python_src: + data_src = _html_escape(_json.dumps(python_src), quote=True) + python_block = ( + f'' + ) + rst += "\n\n.. raw:: html\n\n " + python_block + "\n\n" + + return rst + else: + return ( + f"\n\n.. image:: {png_path.name}\n" + f" :width: 100%\n\n" + ) + + +# Back-compat alias used by the existing anyplotlib docs. +ViewerScraper = AnywidgetScraper + diff --git a/anyplotlib/sphinx_anywidget/_wheel_builder.py b/anyplotlib/sphinx_anywidget/_wheel_builder.py new file mode 100644 index 00000000..f3847a21 --- /dev/null +++ b/anyplotlib/sphinx_anywidget/_wheel_builder.py @@ -0,0 +1,74 @@ +""" +sphinx_anywidget/_wheel_builder.py +==================================== + +Builds a project wheel at docs-build time so the Pyodide bridge can install +the exact library version that generated the docs — no PyPI release required. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + + +def build_wheel( + static_dir: Path, + package_name: str, + project_root: Path, +) -> "Path | None": + """Build a pure-Python wheel into *static_dir/wheels/*. + + The wheel is renamed to ``{package_name}-0.0.0-py3-none-any.whl``. + ``0.0.0`` is a valid PEP 440 sentinel micropip accepts for URL installs. + + Parameters + ---------- + static_dir : + The docs ``_static`` directory; a ``wheels/`` sub-dir is created. + package_name : + PyPI / importable name (e.g. ``"anyplotlib"``). + project_root : + Directory containing ``pyproject.toml`` / ``setup.py``. + + Returns + ------- + Path or None + Path to the written wheel, or *None* on failure. + """ + wheels_dir = static_dir / "wheels" + wheels_dir.mkdir(parents=True, exist_ok=True) + + for old in wheels_dir.glob(f"{package_name}*.whl"): + old.unlink(missing_ok=True) + + result = subprocess.run( + [ + sys.executable, "-m", "pip", "wheel", + "--no-deps", "--quiet", + "--wheel-dir", str(wheels_dir), + str(project_root), + ], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + print( + f"\n[sphinx_anywidget] WARNING: wheel build failed " + f"for {package_name!r}:\n{result.stderr}" + ) + return None + + wheels = sorted(wheels_dir.glob(f"{package_name}*.whl")) + if not wheels: + print(f"\n[sphinx_anywidget] WARNING: no wheel found for {package_name!r}") + return None + + stable = wheels_dir / f"{package_name}-0.0.0-py3-none-any.whl" + stable.unlink(missing_ok=True) + wheels[-1].rename(stable) + print(f"[sphinx_anywidget] wheel → {stable}") + return stable + diff --git a/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js b/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js new file mode 100644 index 00000000..49d6dfc1 --- /dev/null +++ b/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js @@ -0,0 +1,411 @@ +/** + * anywidget_bridge.js + * + * Generic Pyodide bridge for anywidget-based interactive documentation. + * + * Architecture + * ──────────── + * Parent page (this script) + * ├─ Per-figure ⚡ badge (in .awi-badge div, rendered by _scraper.py) + * ├─ Pyodide WASM runtime (loaded once from CDN on first ⚡ click) + * ├─ Package wheel at _static/wheels/{pkg}-0.0.0-py3-none-any.whl + * ├─ ' - f'' - ) - - -# --------------------------------------------------------------------------- -# Scraper -# --------------------------------------------------------------------------- - -class ViewerScraper: - """Sphinx Gallery image scraper that embeds anyplotlib Widgets as live iframes.""" - - def __init__(self): - # Maps src_file path → list of fig_ids emitted so far for that example. - # Used to assign a stable fig_index (creation order) so pyodide_bridge.js - # can run the example source once and tag figures in the right order. - self._example_figs: dict = {} - - def __repr__(self) -> str: - return "ViewerScraper()" - - def __call__(self, block, block_vars, gallery_conf): - globals_dict = block_vars.get("example_globals", {}) - widget = _find_viewer(globals_dict) - if widget is None: - return "" - - src_file = str(block_vars.get("src_file", "")) - - # ── assign a stable fig_id and fig_index for this widget ────────── - if src_file not in self._example_figs: - self._example_figs[src_file] = [] - fig_index = len(self._example_figs[src_file]) - - # ── 1. Write the thumbnail PNG (Sphinx Gallery requires this) ────── - image_path_iterator = block_vars["image_path_iterator"] - png_path = Path(next(image_path_iterator)) - png_path.parent.mkdir(parents=True, exist_ok=True) - png_path.write_bytes(_make_thumbnail_png(widget)) - - # fig_id is derived from the PNG stem so it is stable across rebuilds - # and unique within the built docs (Sphinx Gallery guarantees unique stems). - fig_id = png_path.stem # e.g. "sphx_glr_plot_image2d_001" - self._example_figs[src_file].append(fig_id) - - # ── 2. Write the standalone HTML into docs/_static/viewer_widgets/ ─ - try: - from anyplotlib._repr_utils import build_standalone_html, _widget_px - docs_dir = Path(gallery_conf["src_dir"]) - widgets_dir = docs_dir / "_static" / "viewer_widgets" - widgets_dir.mkdir(parents=True, exist_ok=True) - - html_name = png_path.stem + ".html" # sphx_glr_plot_..._001.html - html_path = widgets_dir / html_name - - inner_html = build_standalone_html(widget, resizable=False, fig_id=fig_id) - html_path.write_text(inner_html, encoding="utf-8") - w, h = _widget_px(widget) - interactive = True - except Exception: - interactive = False - - # ── 3. Return rST ────────────────────────────────────────────────── - if interactive: - try: - src_dir = Path(gallery_conf["src_dir"]) - page_dir = png_path.parent.parent # strip /images - rel_parts = page_dir.relative_to(src_dir).parts - depth = len(rel_parts) - except Exception: - depth = 1 - prefix = "../" * depth - src = f"{prefix}_static/viewer_widgets/{html_name}" - - iframe_block = _iframe_html(src, w, h, fig_id=fig_id) - - # Embed the full example Python source alongside the iframe so - # pyodide_bridge.js can run it in Pyodide and wire live callbacks. - python_src = "" - try: - python_src = Path(src_file).read_text(encoding="utf-8") - except Exception: - pass - - if python_src: - # The Python source is JSON-encoded and HTML-escaped into a - # data-src attribute so the ' - ) - else: - python_block = "" +continues to work without changes. +""" - rst = "\n\n.. raw:: html\n\n " + iframe_block + "\n\n" - if python_block: - rst += "\n\n.. raw:: html\n\n " + python_block + "\n\n" - return rst - else: - rel_png = png_path.name - return ( - f"\n\n.. image:: {rel_png}\n" - f" :width: 100%\n\n" - ) +from anyplotlib.sphinx_anywidget._scraper import ( # noqa: F401 + AnywidgetScraper, + AnywidgetScraper as ViewerScraper, +) diff --git a/test_sphinx_anywidget.py b/test_sphinx_anywidget.py new file mode 100644 index 00000000..3e1efed2 --- /dev/null +++ b/test_sphinx_anywidget.py @@ -0,0 +1,44 @@ +"""Quick smoke test for sphinx_anywidget extension.""" +from anyplotlib.sphinx_anywidget import AnywidgetScraper, ViewerScraper, setup +from anyplotlib.sphinx_anywidget._scraper import _find_widget, _iframe_html +from anyplotlib.sphinx_anywidget._repr_utils import build_standalone_html, _widget_px +from anyplotlib.sphinx_anywidget._wheel_builder import build_wheel +from anyplotlib.sphinx_anywidget._directive import AnywidgetFigureDirective +print('imports OK') + +import numpy as np +import anyplotlib as apl + +fig, ax = apl.subplots(1, 1, figsize=(400, 300)) +ax.plot(np.sin(np.linspace(0, 6.28, 64))) + +html = build_standalone_html(fig, resizable=False, fig_id='tf') +assert 'awi_state' in html, 'Missing awi_state listener' +assert '"tf"' in html, 'Missing fig_id in HTML' + +w, h = _widget_px(fig) +assert w == 416, f'Expected 416 got {w}' + +b = _iframe_html('t.html', 400, 300, fig_id='a', interactive=True, static_icon='P') +assert 'awi-activate-btn' in b, 'Missing activate button' +assert 'awi-static-icon' in b, 'Missing static icon' + +s = _iframe_html('t.html', 400, 300, fig_id='a', interactive=False) +assert 'awi-activate-btn' not in s, 'Should not have activate btn on static' + +import anyplotlib.figure as _af +assert not hasattr(_af, '_pyodide_push_hook'), '_pyodide_push_hook should be gone' + +# Test _find_widget +found = _find_widget({'fig': fig, 'x': 42}) +assert found is fig, 'Should find Figure' +assert _find_widget({'x': 42}) is None + +# Test # Interactive detection +from anyplotlib.sphinx_anywidget._scraper import _INTERACTIVE_RE +assert _INTERACTIVE_RE.search('fig # Interactive\n'), 'Should match' +assert _INTERACTIVE_RE.search('fig # interactive'), 'Should match lowercase' +assert not _INTERACTIVE_RE.search('fig # not a match'), 'Should not match' + +print('ALL SMOKE TESTS PASSED') + diff --git a/tests/test_pyodide_e2e.py b/tests/test_pyodide_e2e.py new file mode 100644 index 00000000..4bb519bd --- /dev/null +++ b/tests/test_pyodide_e2e.py @@ -0,0 +1,946 @@ +""" +tests/test_pyodide_e2e.py +========================= + +End-to-end Playwright tests for the Pyodide live documentation bridge. + +Three test tiers, in increasing scope: + +1. **Python push-hook unit tests** — verify ``_pyodide_push_hook`` intercepts + ``_push()`` / ``_push_layout()`` correctly, and that panel IDs are + deterministic (no-browser, fast). + +2. **iframe postMessage tests** — reuse the existing ``interact_page`` fixture + to open a standalone figure in headless Chromium, fire ``awi_state`` + messages directly, and assert the model updates correctly (no Pyodide, no + HTTP server). + +3. **Full bridge mock-boot tests** — build a ``parent.html`` page that + includes the real ``anywidget_bridge.js`` but defines ``window.loadPyodide`` + as a lightweight mock *before* the bridge evaluates it. The mock exercises + the complete JS boot sequence — button click → all ``runPythonAsync`` / + ``loadPackage`` calls → push-hook installation → state push into the iframe + → awi_event forwarding — without downloading the ~10 MB Pyodide WASM + runtime. Pages are served over a local stdlib HTTP server so the + ``file://`` guard in ``anywidget_bridge.js`` is bypassed. + +Run:: + + uv run pytest tests/test_pyodide_e2e.py -v +""" +from __future__ import annotations + +import json +import pathlib +import socket +import tempfile +import threading +from http.server import HTTPServer, SimpleHTTPRequestHandler +from html import escape as _html_escape +from typing import Generator + +import numpy as np +import pytest + +import anyplotlib as apl +import anyplotlib.figure as _af +from anyplotlib._repr_utils import build_standalone_html + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- + +_BRIDGE_JS = ( + pathlib.Path(__file__).parent.parent + / "anyplotlib" / "sphinx_anywidget" / "static" / "anywidget_bridge.js" +) + + +# --------------------------------------------------------------------------- +# Helpers used by multiple tiers +# --------------------------------------------------------------------------- + +def _capture_fig_state(fig) -> dict[str, str]: + """Return ``{trait_name: json_string}`` for layout + every panel trait. + + Reads traitlet values directly after calling the push methods. This + works even when the value hasn't changed (traitlets suppresses duplicate + change events, so an observe-based approach would return nothing on a + second call with the same state). + """ + # Ensure state is up to date + fig._push_layout() + for pid in list(fig._plots_map): + fig._push(pid) + + captured: dict[str, str] = {} + captured["layout_json"] = fig.layout_json + for tname in fig.trait_names(): + if tname.startswith("panel_") and tname.endswith("_json"): + captured[tname] = getattr(fig, tname) + return captured + + +def _patched_iframe_html(fig, fig_id: str) -> str: + """Return standalone figure HTML instrumented for Playwright. + + Patches applied on top of ``build_standalone_html``: + * ``window._aplModel = model`` — exposes the model to parent-frame JS. + * ``window._aplReady = true`` — sentinel polled by ``wait_for_function``. + """ + html = build_standalone_html(fig, resizable=False, fig_id=fig_id) + html = html.replace( + "const model = makeModel(STATE);", + "const model = makeModel(STATE);\nwindow._aplModel = model;", + ) + html = html.replace( + "renderFn({ model, el });", + "renderFn({ model, el }); window._aplReady = true;", + ) + return html + + +# --------------------------------------------------------------------------- +# HTTP-server fixture (module-scoped — one server per test module) +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def http_server(tmp_path_factory) -> Generator[tuple[str, pathlib.Path], None, None]: + """Serve a temp directory over HTTP; yield ``(base_url, base_dir)``. + + Uses a randomly-chosen free port so tests run safely alongside other + sessions. The server is shut down after the last test in the module. + """ + base_dir = tmp_path_factory.mktemp("bridge_server") + + class _SilentHandler(SimpleHTTPRequestHandler): + def __init__(self, *a, **kw): + super().__init__(*a, directory=str(base_dir), **kw) + + def log_message(self, *_): + pass # suppress request noise in test output + + # Pick a free port + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + + srv = HTTPServer(("127.0.0.1", port), _SilentHandler) + t = threading.Thread(target=srv.serve_forever, daemon=True) + t.start() + + yield f"http://127.0.0.1:{port}", base_dir + + srv.shutdown() + + +# --------------------------------------------------------------------------- +# Parent-page builder +# --------------------------------------------------------------------------- + +def _build_parent_page( + fig, + fig_id: str, + *, + base_dir: pathlib.Path, + python_src: str = "", +) -> pathlib.Path: + """Write a complete mock-Pyodide parent page to *base_dir*. + + Files written + ------------- + ``{fig_id}.html`` — standalone figure iframe + ``anywidget_bridge.js`` — the real bridge script (copied from docs/) + ``{fig_id}_parent.html`` — parent page with mock loadPyodide + + The mock ``window.loadPyodide`` is defined **before** the bridge script + so the bridge's ``typeof loadPyodide !== 'undefined'`` guard skips the CDN + download entirely. Each ``runPythonAsync`` call is dispatched by string + pattern to simulate the five significant Pyodide boot steps: + + 1. ``micropip.install`` — no-op. + 2. ``sys.modules['anywidget']`` stub — no-op. + 3. ``_pyodide_push_hook`` install — sets real ``window._anywidgetPush``. + 4. ``_fig_ids`` example-run — calls ``window._anywidgetPush`` with captured state. + + Pre-collected figure state (``layout_json`` + ``panel_*_json``) is baked + into the page as ``window._MOCK_LAYOUT`` / ``window._MOCK_PANELS`` so the + mock can push real data without running any Python. + """ + # ── 1. Iframe HTML (with Playwright instrumentation patches) ───────── + iframe_html = _patched_iframe_html(fig, fig_id) + (base_dir / f"{fig_id}.html").write_text(iframe_html, encoding="utf-8") + + # ── 2. Real bridge script ───────────────────────────────────────────── + (base_dir / "anywidget_bridge.js").write_text( + _BRIDGE_JS.read_text(encoding="utf-8"), encoding="utf-8" + ) + + # ── 3. Capture real figure state via the push-hook ──────────────────── + fig_state = _capture_fig_state(fig) + layout_value = fig_state.get("layout_json", "{}") + panel_entries = [ + {"key": k, "value": v} + for k, v in fig_state.items() + if k.startswith("panel_") + ] + + fig_w, fig_h = int(fig.fig_width), int(fig.fig_height) + + # ── 4. Python source block (or a minimal comment stub) ──────────────── + if not python_src: + python_src = "# mock example — state injected by test harness\n" + data_src_attr = _html_escape(json.dumps(python_src), quote=True) + + # ── 5. Mock loadPyodide script ──────────────────────────────────────── + # + # Intercepts every runPythonAsync call by pattern so the full JS boot + # path (button → loading → active) is exercised in milliseconds. + # + # Step (3): install push-hook → sets window._anywidgetPush which delivers + # postMessage awi_state updates into the correct iframe. + # Step (4): run example → calls window._anywidgetPush with pre-baked + # state so the iframe model receives real figure data. + mock_js = f"""""" + + # ── 6. Assemble the parent HTML ─────────────────────────────────────── + parent_html = f""" + + + +anywidget bridge test — {fig_id} +{mock_js} + + + +
+
+ +
+ 📷 + +
+
+
+ + +""" + + parent_path = base_dir / f"{fig_id}_parent.html" + parent_path.write_text(parent_html, encoding="utf-8") + return parent_path + + +# --------------------------------------------------------------------------- +# Browser helpers +# --------------------------------------------------------------------------- + +def _rafter(page) -> None: + page.evaluate("() => new Promise(r => requestAnimationFrame(r))") + + +def _open_page(browser, url: str, timeout: int = 15_000): + page = browser.new_page() + page.goto(url, wait_until="domcontentloaded", timeout=timeout) + return page + + +def _click_and_wait_boot(page, timeout: int = 15_000) -> None: + """Click the ⚡ badge button and wait until it reaches the 'active' state.""" + page.wait_for_function( + "() => !!document.querySelector('button.awi-activate-btn')", + timeout=timeout, + ) + page.click("button.awi-activate-btn") + page.wait_for_function( + """() => { + const btn = document.querySelector('button.awi-activate-btn'); + return btn && btn.dataset.state === 'active'; + }""", + timeout=timeout, + ) + + +def _wait_for_iframe_model(page, fig_id: str, panel_id: str, + timeout: int = 10_000) -> None: + """Block until the iframe's model has a non-empty panel JSON.""" + page.wait_for_function( + f"""() => {{ + const iframe = document.querySelector('iframe[data-awi-fig="{fig_id}"]'); + if (!iframe || !iframe.contentWindow) return false; + const mdl = iframe.contentWindow._aplModel; + if (!mdl) return false; + const raw = mdl.get('panel_{panel_id}_json'); + return typeof raw === 'string' && raw.length > 10; + }}""", + timeout=timeout, + ) + + +# ============================================================================= +# Tier 1 — Traitlet push unit tests (no browser required) +# ============================================================================= + +class TestPushHook: + """Verify _push() / _push_layout() write to sync=True traitlets. + + The old tests checked ``_pyodide_push_hook``; now we observe the traitlets + directly — the same path that the generic anywidget monkey-patch uses in + Pyodide. + """ + + def test_push_does_not_crash(self): + """Normal mode: _push() succeeds without error.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.zeros(16)) # must not raise + + def test_layout_json_written_on_create(self): + """layout_json traitlet is set when a figure is created.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + import json + parsed = json.loads(fig.layout_json) + assert "panel_specs" in parsed, ( + f"layout_json missing 'panel_specs': {list(parsed.keys())}" + ) + + def test_panel_json_written_after_plot(self): + """panel_*_json traitlet is set when a plot is added.""" + import json + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.sin(np.linspace(0, 2 * np.pi, 64))) + + panel_keys = [k for k in fig.trait_names() if k.startswith("panel_") and k.endswith("_json")] + assert len(panel_keys) >= 1, "Expected at least one panel_*_json trait" + for k in panel_keys: + parsed = json.loads(getattr(fig, k)) + assert "kind" in parsed, f"panel JSON missing 'kind': {list(parsed.keys())}" + + def test_observe_fires_on_push(self): + """traitlets.observe() fires when _push() writes a panel trait.""" + seen: list[str] = [] + + def _watch(change): + seen.append(change["name"]) + + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + fig.observe(_watch) + ax.plot(np.zeros(8)) + fig.unobserve(_watch) + + assert any(k.startswith("panel_") for k in seen), ( + f"Expected a panel_* trait change; got: {seen}" + ) + + def test_panel_id_deterministic(self): + """Panel IDs derived from SubplotSpec must be identical across rebuilds.""" + ids: list[str] = [] + for _ in range(3): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.zeros(8)) + ids.append(list(fig._plots_map.keys())[0]) + assert ids[0] == ids[1] == ids[2], ( + f"Panel ID must be deterministic; got {ids}" + ) + + def test_panel_ids_unique_in_multiplot(self): + """Each panel in a multi-panel figure has a unique ID.""" + fig, axes = apl.subplots(1, 3, figsize=(900, 300)) + for ax in axes: + ax.plot(np.zeros(8)) + ids = list(fig._plots_map.keys()) + assert len(ids) == len(set(ids)), f"Panel IDs not unique: {ids}" + + def test_panel_id_matches_grid_position(self): + """Panel IDs encode the SubplotSpec row/col bounds.""" + fig, axes = apl.subplots(2, 2, figsize=(600, 400)) + for ax in np.asarray(axes).flat: + ax.plot(np.zeros(4)) + ids = set(fig._plots_map.keys()) + for pid in ids: + assert pid.startswith("p"), f"Unexpected panel ID format: {pid!r}" + + def test_dispatch_event_callable_without_kernel(self): + """_dispatch_event() can be called directly as the Pyodide bridge does.""" + import json + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.zeros(16)) + raw = json.dumps({ + "event_type": "on_zoom", + "panel_id": list(fig._plots_map.keys())[0], + "source": "js", + }) + fig._dispatch_event(raw) # must not raise + + def test_capture_fig_state_helper(self): + """_capture_fig_state returns both layout_json and panel JSON(s).""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.zeros(32)) + state = _capture_fig_state(fig) + assert "layout_json" in state, f"Expected layout_json; got {list(state.keys())}" + panel_keys = [k for k in state if k.startswith("panel_")] + assert len(panel_keys) >= 1, "Expected at least one panel_ key" + + def test_no_pyodide_push_hook_attribute(self): + """figure module no longer exposes _pyodide_push_hook.""" + assert not hasattr(_af, "_pyodide_push_hook"), ( + "_pyodide_push_hook should not exist on figure module in this branch" + ) + + +# ============================================================================= +# Tier 2 — iframe postMessage tests (browser, no Pyodide, no HTTP server) +# ============================================================================= + +class TestIframeMessaging: + """Test the awi_state postMessage protocol via the standalone iframe. + + The ``interact_page`` fixture opens the figure HTML as a top-level page + (not as an iframe), so ``window.parent === window`` and the outbound + awi_event forwarding is naturally disabled. These tests focus on the + *inbound* direction: an ``awi_state`` message updates the model. + """ + + def _open_fig(self, interact_page): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.sin(np.linspace(0, 2 * np.pi, 64)), color="#4fc3f7") + plot = list(fig._plots_map.values())[0] + panel_id = list(fig._plots_map.keys())[0] + page = interact_page(fig) + return fig, plot, panel_id, page + + def test_awi_state_message_updates_model_key(self, interact_page): + """Posting {type:'awi_state', key, value} into the page updates the model.""" + fig, plot, panel_id, page = self._open_fig(interact_page) + + # Read the current panel JSON and add a sentinel key + raw = page.evaluate(f"() => window._aplModel.get('panel_{panel_id}_json')") + assert raw is not None, "Model should have an initial panel JSON" + curr = json.loads(raw) + curr["__apl_e2e_sentinel__"] = "hello_from_postMessage" + new_json = json.dumps(curr) + + page.evaluate(f"""() => {{ + window.postMessage({{ + type: 'awi_state', + key: 'panel_{panel_id}_json', + value: {json.dumps(new_json)} + }}, '*'); + }}""") + _rafter(page) + + updated = json.loads( + page.evaluate(f"() => window._aplModel.get('panel_{panel_id}_json')") + ) + assert updated.get("__apl_e2e_sentinel__") == "hello_from_postMessage", ( + "awi_state postMessage did not update the model key" + ) + + def test_awi_state_message_sets_from_parent_flag(self, interact_page): + """_fromParent is True while the awi_state handler runs. + + We can't read the flag mid-handler, but we can verify that a + save_changes() triggered by awi_state does NOT set _eventJsonDirty + (since event_json was not written in that transaction). A by-product + check: calling model.set on a non-event_json key never marks the + dirty flag. + """ + fig, plot, panel_id, page = self._open_fig(interact_page) + + raw = json.loads( + page.evaluate(f"() => window._aplModel.get('panel_{panel_id}_json')") + ) + raw["__flag_test__"] = 42 + new_json = json.dumps(raw) + + # Expose _eventJsonDirty so we can read it after the handler runs. + # We monkey-patch model.save_changes to record whether _eventJsonDirty + # was True at the time of the call triggered by the awi_state message. + page.evaluate("""() => { + window._dirtyAtSaveChanges = null; + // We can't access module-scoped _eventJsonDirty from outside, but + // we can observe whether an awi_event postMessage is fired: it only + // fires when (!_fromParent && FIG_ID && parent!==window && dirty). + // Since FIG_ID is null (standalone page), no awi_event fires in any + // case. So we check absence of awi_event messages instead. + window._aplEventsSeen = 0; + window.addEventListener('message', (e) => { + if (e.data && e.data.type === 'awi_event') window._aplEventsSeen++; + }); + }""") + + page.evaluate(f"""() => {{ + window.postMessage({{ + type: 'awi_state', + key: 'panel_{panel_id}_json', + value: {json.dumps(new_json)} + }}, '*'); + }}""") + _rafter(page) + + # In standalone mode FIG_ID is null → no awi_event is ever forwarded + events_seen = page.evaluate("() => window._aplEventsSeen") + assert events_seen == 0, ( + "_fromParent guard or FIG_ID=null should prevent awi_event echo; " + f"got {events_seen} awi_event(s)" + ) + + def test_awi_state_fires_change_listeners(self, interact_page): + """Posting awi_state triggers on('change:…') listeners in the model.""" + fig, plot, panel_id, page = self._open_fig(interact_page) + + page.evaluate(f"""() => {{ + window._aplChangeCount = 0; + window._aplModel.on('change:panel_{panel_id}_json', () => {{ + window._aplChangeCount++; + }}); + }}""") + + raw = json.loads( + page.evaluate(f"() => window._aplModel.get('panel_{panel_id}_json')") + ) + raw["__change_test__"] = 1 + new_json = json.dumps(raw) + + page.evaluate(f"""() => {{ + window.postMessage({{ + type: 'awi_state', + key: 'panel_{panel_id}_json', + value: {json.dumps(new_json)} + }}, '*'); + }}""") + _rafter(page) + + count = page.evaluate("() => window._aplChangeCount") + assert count >= 1, ( + "awi_state postMessage should fire change listeners; " + f"got {count} invocations" + ) + + def test_layout_json_push_updates_model(self, interact_page): + """layout_json can be updated via awi_state, not only panel_*_json.""" + fig, plot, panel_id, page = self._open_fig(interact_page) + + layout = json.loads( + page.evaluate("() => window._aplModel.get('layout_json') || '{}'") + ) + layout["__layout_sentinel__"] = "bridge_test" + new_json = json.dumps(layout) + + page.evaluate(f"""() => {{ + window.postMessage({{ + type: 'awi_state', + key: 'layout_json', + value: {json.dumps(new_json)} + }}, '*'); + }}""") + _rafter(page) + + updated = json.loads( + page.evaluate("() => window._aplModel.get('layout_json') || '{}'") + ) + assert updated.get("__layout_sentinel__") == "bridge_test", ( + "layout_json postMessage did not update the model" + ) + + +# ============================================================================= +# Tier 3 — Full bridge mock-boot tests (HTTP server + mock Pyodide) +# ============================================================================= + +class TestFullBridgeBoot: + """Boot anywidget_bridge.js end-to-end via a mock loadPyodide. + + Each test builds a parent HTML page using ``_build_parent_page`` and + serves it from the shared ``http_server`` fixture. All Pyodide network + I/O is replaced by the JS mock so tests run in milliseconds. + """ + + # ------------------------------------------------------------------ + # helpers + + def _open(self, browser, base_url: str, parent_path: pathlib.Path, + timeout: int = 15_000): + url = f"{base_url}/{parent_path.name}" + page = browser.new_page() + page.goto(url, wait_until="domcontentloaded", timeout=timeout) + return page + + def _basic_fig(self) -> tuple: + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.sin(np.linspace(0, 2 * np.pi, 64)), color="#50fa7b") + panel_id = list(fig._plots_map.keys())[0] + return fig, panel_id + + # ------------------------------------------------------------------ + # tests + + def test_button_appears_when_iframe_present( + self, http_server, _pw_browser + ): + """The ⚡ button is injected on any page that has a data-awi-fig iframe.""" + base_url, base_dir = http_server + fig, panel_id = self._basic_fig() + parent = _build_parent_page(fig, "btn_test_001", base_dir=base_dir) + page = self._open(_pw_browser, base_url, parent) + + page.wait_for_function( + "() => !!document.querySelector('button.awi-activate-btn')", + timeout=5_000, + ) + tooltip = page.evaluate( + "() => document.querySelector('button.awi-activate-btn').title" + ) + assert "interactive" in tooltip.lower(), ( + f"Button tooltip should mention 'interactive'; got {tooltip!r}" + ) + page.close() + + def test_boot_completes_all_mock_steps( + self, http_server, _pw_browser + ): + """Clicking ⚡ runs through all expected mock Pyodide boot steps.""" + base_url, base_dir = http_server + fig, panel_id = self._basic_fig() + parent = _build_parent_page(fig, "boot_test_001", base_dir=base_dir) + page = self._open(_pw_browser, base_url, parent) + + _click_and_wait_boot(page) + + steps = page.evaluate("() => window._APL_BOOT_STEPS") + + assert "loadPyodide" in steps, ( + f"loadPyodide() was never called; steps={steps}" + ) + assert "micropip_install" in steps, ( + f"micropip install step missing; steps={steps}" + ) + assert "stub_anywidget" in steps, ( + f"anywidget stub step missing; steps={steps}" + ) + assert "install_monkey_patch" in steps, ( + f"monkey-patch install step missing; steps={steps!r}\n" + "This means anywidget_bridge.js never called runPythonAsync with " + "the _patched_init monkey-patch source — the JS↔Python bridge is broken." + ) + assert "run_example" in steps, ( + f"Example-run step missing; steps={steps!r}\n" + "This means anywidget_bridge.js never called runPythonAsync with " + "the _fig_ids / _push_layout block that seeds the iframes." + ) + page.close() + + def test_anywidgetPush_is_function_after_boot( + self, http_server, _pw_browser + ): + """window._anywidgetPush must be a function after the push-hook step runs.""" + base_url, base_dir = http_server + fig, panel_id = self._basic_fig() + parent = _build_parent_page(fig, "apush_test_001", base_dir=base_dir) + page = self._open(_pw_browser, base_url, parent) + + _click_and_wait_boot(page) + + is_fn = page.evaluate("() => typeof window._anywidgetPush === 'function'") + assert is_fn, ( + "window._anywidgetPush should be a function after the push-hook step; " + "if it is missing the hook was never installed by anywidget_bridge.js" + ) + page.close() + + def test_state_pushed_into_iframe_model( + self, http_server, _pw_browser + ): + """After boot the iframe's model contains the figure's panel JSON. + + This is the core Pyodide bridge assertion: Python figure state must + reach the iframe model via _anywidgetPush → postMessage → awi_state listener + → model.set(key, value). + """ + base_url, base_dir = http_server + fig, panel_id = self._basic_fig() + expected = fig._plots_map[panel_id].to_state_dict() + + parent = _build_parent_page(fig, "state_push_001", base_dir=base_dir) + page = self._open(_pw_browser, base_url, parent) + + _click_and_wait_boot(page) + _wait_for_iframe_model(page, "state_push_001", panel_id) + + raw = page.evaluate(f"""() => {{ + const iframe = document.querySelector('iframe[data-awi-fig="state_push_001"]'); + return iframe && iframe.contentWindow + ? iframe.contentWindow._aplModel.get('panel_{panel_id}_json') + : null; + }}""") + + assert raw is not None, ( + "panel JSON was never delivered to the iframe model after boot.\n" + "Check: (a) _anywidgetPush was installed, (b) postMessage reached the " + "iframe's awi_state listener, (c) model.set() was called." + ) + state = json.loads(raw) + assert state.get("kind") == expected.get("kind"), ( + f"kind mismatch: iframe has {state.get('kind')!r}, " + f"Python produced {expected.get('kind')!r}" + ) + page.close() + + def test_layout_json_pushed_into_iframe( + self, http_server, _pw_browser + ): + """layout_json (panel geometry) is delivered to the iframe model.""" + base_url, base_dir = http_server + fig, panel_id = self._basic_fig() + parent = _build_parent_page(fig, "layout_push_001", base_dir=base_dir) + page = self._open(_pw_browser, base_url, parent) + + _click_and_wait_boot(page) + + # Wait for layout_json to propagate + page.wait_for_function( + """() => { + const iframe = document.querySelector('iframe[data-awi-fig="layout_push_001"]'); + if (!iframe || !iframe.contentWindow) return false; + const mdl = iframe.contentWindow._aplModel; + if (!mdl) return false; + const raw = mdl.get('layout_json'); + return typeof raw === 'string' && raw.length > 10; + }""", + timeout=8_000, + ) + + raw = page.evaluate("""() => { + const iframe = document.querySelector('iframe[data-awi-fig="layout_push_001"]'); + return iframe.contentWindow._aplModel.get('layout_json'); + }""") + assert raw is not None, "layout_json was not delivered to the iframe" + layout = json.loads(raw) + assert "panel_specs" in layout, ( + f"layout_json is missing 'panel_specs'; got keys: {list(layout.keys())}" + ) + page.close() + + def test_event_message_forwarded_to_parent( + self, http_server, _pw_browser + ): + """awi_event messages sent from the iframe arrive at the parent window. + + This tests the reverse direction of the bridge: user interaction in + the iframe → awi_event postMessage → parent window.message listener + → _fig._dispatch_event(). Here we only test the JS forwarding step; + the Python dispatch is covered by TestPushHook.test_dispatch_event_*. + """ + base_url, base_dir = http_server + fig, panel_id = self._basic_fig() + parent = _build_parent_page(fig, "event_fwd_001", base_dir=base_dir) + page = self._open(_pw_browser, base_url, parent) + + _click_and_wait_boot(page) + + # Install a parent-side listener that records received awi_events + page.evaluate("""() => { + window._aplReceivedEvents = []; + window.addEventListener('message', (e) => { + if (e.data && e.data.type === 'awi_event') { + window._aplReceivedEvents.push(e.data); + } + }); + }""") + + # Synthesise an awi_event from the iframe (mirrors what the iframe + # does when a widget drag ends: window.parent.postMessage({...}, '*')) + fake_event = json.dumps({ + "event_type": "on_release", + "panel_id": panel_id, + "widget_id": "w_e2e_fake", + "x": 42.0, + }) + page.evaluate(f"""() => {{ + // Simulate the iframe posting the event to its parent. + // In the actual docs the iframe does: + // window.parent.postMessage({{type:'awi_event', figId, data}}, '*') + // Here the iframe IS the top-level page so we post to window itself. + window.postMessage({{ + type: 'awi_event', + figId: 'event_fwd_001', + data: {json.dumps(fake_event)} + }}, '*'); + }}""") + _rafter(page) + + events = page.evaluate("() => window._aplReceivedEvents") + assert len(events) >= 1, ( + "No awi_event reached the parent message bus.\n" + "The parent window.message listener in anywidget_bridge.js " + "may not be installed, or the figId routing is broken." + ) + assert events[0]["figId"] == "event_fwd_001", ( + f"figId mismatch: {events[0]['figId']!r} vs 'event_fwd_001'" + ) + page.close() + + def test_multiple_panels_all_receive_state( + self, http_server, _pw_browser + ): + """All panels in a multi-panel figure have their state pushed.""" + base_url, base_dir = http_server + + fig, axes = apl.subplots(1, 2, figsize=(700, 300)) + axes[0].plot(np.zeros(32)) + axes[1].plot(np.ones(32) * 0.5) + panel_ids = list(fig._plots_map.keys()) + assert len(panel_ids) == 2, "Expected exactly 2 panels" + + parent = _build_parent_page(fig, "multi_panel_001", base_dir=base_dir) + page = self._open(_pw_browser, base_url, parent) + _click_and_wait_boot(page) + + # Wait for both panels to arrive + for pid in panel_ids: + _wait_for_iframe_model(page, "multi_panel_001", pid) + + for pid in panel_ids: + raw = page.evaluate(f"""() => {{ + const iframe = document.querySelector( + 'iframe[data-awi-fig="multi_panel_001"]'); + return iframe && iframe.contentWindow + ? iframe.contentWindow._aplModel.get('panel_{pid}_json') + : null; + }}""") + assert raw is not None, ( + f"Panel {pid!r} state was not pushed into the iframe model.\n" + "If only the first panel arrives, _anywidgetPush may be iterating " + "panels incorrectly in the mock (or in the real bridge)." + ) + page.close() + + def test_button_shows_error_on_boot_failure( + self, http_server, _pw_browser + ): + """If Pyodide boot fails the button switches to the error state (❌).""" + base_url, base_dir = http_server + fig, panel_id = self._basic_fig() + + # Build the parent page, then patch the mock to throw on loadPyodide + parent = _build_parent_page(fig, "error_test_001", base_dir=base_dir) + html = (base_dir / "error_test_001_parent.html").read_text(encoding="utf-8") + # Inject a rejection AFTER the mock definition so it overrides it + html = html.replace( + "window.loadPyodide = async function({indexURL}) {", + "window.loadPyodide = async function({indexURL}) { throw new Error('mock boot failure'); //", + ) + (base_dir / "error_test_001_parent.html").write_text(html, encoding="utf-8") + + page = self._open(_pw_browser, base_url, parent) + page.wait_for_function( + "() => !!document.querySelector('button.awi-activate-btn')", + timeout=5_000 + ) + page.click("button.awi-activate-btn") + + # Wait for button to enter error state + page.wait_for_function( + """() => { + const btn = document.querySelector('button.awi-activate-btn'); + return btn && btn.dataset.state === 'error'; + }""", + timeout=10_000, + ) + label = page.evaluate( + "() => document.querySelector('button.awi-activate-btn').title" + ) + assert "mock boot failure" in label, ( + f"Error button title should contain the exception message; got {label!r}" + ) + page.close() + + + From d2948d3eeb7411f7e66bb4e34deff762991f1622 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 3 May 2026 13:40:46 -0500 Subject: [PATCH 071/198] refactor: remove static icon support and update badge behavior for interactive figures --- Examples/Interactive/plot_interactive_fft.py | 5 +- Examples/Interactive/plot_key_bindings.py | 3 +- Examples/Interactive/plot_point_widget.py | 2 +- anyplotlib/figure_plots.py | 54 +++++++----- anyplotlib/sphinx_anywidget/__init__.py | 21 +++-- anyplotlib/sphinx_anywidget/_directive.py | 9 -- anyplotlib/sphinx_anywidget/_scraper.py | 36 +++----- .../static/anywidget_bridge.js | 3 +- .../static/anywidget_overlay.css | 39 ++++----- docs/_static/pyodide_bridge.js | 82 +++++++++++-------- test_sphinx_anywidget.py | 3 +- tests/test_pyodide_e2e.py | 1 - 12 files changed, 130 insertions(+), 128 deletions(-) diff --git a/Examples/Interactive/plot_interactive_fft.py b/Examples/Interactive/plot_interactive_fft.py index 20e27736..f274cc47 100644 --- a/Examples/Interactive/plot_interactive_fft.py +++ b/Examples/Interactive/plot_interactive_fft.py @@ -178,7 +178,4 @@ def _roi_released(event): v_fft.set_data(log_mag, x_axis=freq_x, y_axis=freq_y, units="1/\u00c5") -fig - - - +fig # Interactive diff --git a/Examples/Interactive/plot_key_bindings.py b/Examples/Interactive/plot_key_bindings.py index d3c1f222..3ec8505c 100644 --- a/Examples/Interactive/plot_key_bindings.py +++ b/Examples/Interactive/plot_key_bindings.py @@ -118,5 +118,4 @@ def log_key(event): print(f"[on_key] key={event.key!r} img={pos}" f" last_widget={event.last_widget_id!r}") -fig - +fig # Interactive diff --git a/Examples/Interactive/plot_point_widget.py b/Examples/Interactive/plot_point_widget.py index 7f86f8ac..c614b90c 100644 --- a/Examples/Interactive/plot_point_widget.py +++ b/Examples/Interactive/plot_point_widget.py @@ -105,5 +105,5 @@ def _settled(event): _draw_tangent(event.x) -fig +fig # Interactive diff --git a/anyplotlib/figure_plots.py b/anyplotlib/figure_plots.py index d3a67fa8..da4e2698 100644 --- a/anyplotlib/figure_plots.py +++ b/anyplotlib/figure_plots.py @@ -592,26 +592,42 @@ def _normalize_image(data: np.ndarray): def _build_colormap_lut(name: str) -> list: """Return a 256-entry ``[[r, g, b], ...]`` LUT for the named colormap. - Uses **colorcet** exclusively. Common matplotlib colormap names are - transparently remapped via :data:`_CMAP_ALIASES` so callers can keep - using names like ``"viridis"`` or ``"hot"`` without any matplotlib - dependency. Falls back to a plain gray ramp for unknown names. - """ - import colorcet as cc - - resolved = _CMAP_ALIASES.get(name, name) - palette = cc.palette.get(resolved) + Priority order: - if palette is None: - # Unknown name → linear gray ramp - return [[v, v, v] for v in range(256)] - - n = len(palette) - lut: list = [] - for i in range(256): - h = palette[int(round(i * (n - 1) / 255))].lstrip("#") - lut.append([int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)]) - return lut + 1. **colorcet** — preferred; common matplotlib names are remapped via + :data:`_CMAP_ALIASES` so callers can use ``"viridis"`` etc. + 2. **matplotlib** — fallback when colorcet is not installed (e.g. in + Pyodide before micropip finishes, or in minimal test environments). + 3. **Built-in gray ramp** — final fallback for unknown names. + """ + # ── 1. Try colorcet ─────────────────────────────────────────────────── + try: + import colorcet as cc + resolved = _CMAP_ALIASES.get(name, name) + palette = cc.palette.get(resolved) + if palette is not None: + n = len(palette) + lut: list = [] + for i in range(256): + h = palette[int(round(i * (n - 1) / 255))].lstrip("#") + lut.append([int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)]) + return lut + except Exception: + pass + + # ── 2. Try matplotlib ───────────────────────────────────────────────── + try: + import matplotlib.cm as _cm + import numpy as _np + cmap = _cm.get_cmap(name, 256) + rgba = cmap(_np.linspace(0, 1, 256)) + return [[int(r * 255), int(g * 255), int(b * 255)] + for r, g, b, _ in rgba] + except Exception: + pass + + # ── 3. Gray ramp fallback ───────────────────────────────────────────── + return [[v, v, v] for v in range(256)] def _resample_mesh(data: np.ndarray, x_edges, y_edges) -> np.ndarray: diff --git a/anyplotlib/sphinx_anywidget/__init__.py b/anyplotlib/sphinx_anywidget/__init__.py index 75b8ea8b..7f3cd0c4 100644 --- a/anyplotlib/sphinx_anywidget/__init__.py +++ b/anyplotlib/sphinx_anywidget/__init__.py @@ -18,8 +18,6 @@ # Package whose wheel is built and served to Pyodide at runtime. anywidget_pyodide_package = "mypackage" - # Optional icon on static (non-interactive) figure snapshots. - anywidget_static_icon = "📷" # default The extension: * builds a pure-Python wheel at docs-build time; @@ -48,8 +46,7 @@ def setup(app): """Register sphinx_anywidget with Sphinx.""" - app.add_config_value("anywidget_pyodide_package", default=None, rebuild="html") - app.add_config_value("anywidget_static_icon", default="\U0001f4f7", rebuild="html") + app.add_config_value("anywidget_pyodide_package", default=None, rebuild="html") from anyplotlib.sphinx_anywidget._directive import AnywidgetFigureDirective app.add_directive("anywidget-figure", AnywidgetFigureDirective) @@ -57,7 +54,10 @@ def setup(app): app.connect("builder-inited", _copy_static_assets) app.connect("builder-inited", _build_pyodide_wheel) - app.add_js_file("anywidget_bridge.js", loading_method="defer") + # anywidget_config.js is written dynamically by _build_pyodide_wheel; + # it must load BEFORE anywidget_bridge.js so _inferPackageName sees the name. + app.add_js_file("anywidget_config.js", loading_method="defer", priority=490) + app.add_js_file("anywidget_bridge.js", loading_method="defer", priority=500) app.add_css_file("anywidget_overlay.css") return { @@ -87,11 +87,18 @@ def _build_pyodide_wheel(app): ) return - from anyplotlib.sphinx_anywidget._wheel_builder import build_wheel conf_dir = Path(app.confdir) - project_root = conf_dir.parent static_dir = conf_dir / "_static" static_dir.mkdir(parents=True, exist_ok=True) + + # Write a tiny config script so anywidget_bridge.js can find the package + # name without fragile heuristics. Loaded before anywidget_bridge.js. + import json as _json + config_js = f"window._anywidgetPackage = {_json.dumps(pkg)};\n" + (static_dir / "anywidget_config.js").write_text(config_js, encoding="utf-8") + + from anyplotlib.sphinx_anywidget._wheel_builder import build_wheel + project_root = conf_dir.parent build_wheel(static_dir, pkg, project_root) diff --git a/anyplotlib/sphinx_anywidget/_directive.py b/anyplotlib/sphinx_anywidget/_directive.py index 4be69c43..2be636ce 100644 --- a/anyplotlib/sphinx_anywidget/_directive.py +++ b/anyplotlib/sphinx_anywidget/_directive.py @@ -24,9 +24,6 @@ source is embedded for live re-execution by the Pyodide bridge. ``:width:`` (int, default 684) Maximum display width in pixels. -``:static-icon:`` (str, default "📷") - Unicode character for the static snapshot badge. Falls back to the - ``anywidget_static_icon`` Sphinx config value, then ``"📷"``. """ from __future__ import annotations @@ -50,7 +47,6 @@ class AnywidgetFigureDirective(Directive): option_spec = { "interactive": directives.flag, "width": directives.nonnegative_int, - "static-icon": directives.unchanged, } def run(self): @@ -73,10 +69,6 @@ def run(self): # ── options ────────────────────────────────────────────────────── is_interactive = "interactive" in self.options max_width = self.options.get("width", 684) - static_icon = self.options.get( - "static-icon", - getattr(config, "anywidget_static_icon", "\U0001f4f7"), - ) # ── execute the script to get the widget ───────────────────────── try: @@ -133,7 +125,6 @@ def run(self): src_url, w, h, fig_id=fig_id, interactive=is_interactive, - static_icon=static_icon, ) raw_html = "\n" + iframe_block + "\n" diff --git a/anyplotlib/sphinx_anywidget/_scraper.py b/anyplotlib/sphinx_anywidget/_scraper.py index 18e2b5e6..807be2a3 100644 --- a/anyplotlib/sphinx_anywidget/_scraper.py +++ b/anyplotlib/sphinx_anywidget/_scraper.py @@ -24,7 +24,7 @@ fig # Interactive Without the comment the figure is rendered as a plain static iframe with -only the snapshot (📷) badge — no Pyodide wiring at all. +no Pyodide wiring. Usage in ``conf.py``:: @@ -128,7 +128,6 @@ def _iframe_html( h: int, fig_id: str | None = None, interactive: bool = False, - static_icon: str = "\U0001f4f7", # 📷 ) -> str: """Return a single-line HTML snippet embedding *src* responsively. @@ -142,9 +141,6 @@ def _iframe_html( Stable identifier; used as the ``data-awi-fig`` attribute. interactive : bool When True, renders the ⚡ activation badge. - static_icon : str - Unicode character (or HTML entity) displayed as the "static snapshot" - badge icon. Configurable via ``anywidget_static_icon`` in ``conf.py``. """ uid = fig_id or f"f{uuid4().hex[:8]}" @@ -171,21 +167,22 @@ def _iframe_html( f"}})()" ) - # Badge HTML — always includes static icon; ⚡ only when interactive. - badge_parts = [ - f'{static_icon}' - ] + # Badge HTML — only the ⚡ button when interactive; nothing otherwise. + badge_parts = [] if interactive: badge_parts.append( f'' ) - badge_html = ( - f'
' - + "".join(badge_parts) - + "
" - ) + if not badge_parts: + badge_html = "" + else: + badge_html = ( + f'
' + + "".join(badge_parts) + + "
" + ) return ( f'
' @@ -210,17 +207,9 @@ def _iframe_html( class AnywidgetScraper: """Sphinx Gallery image scraper for any ``anywidget.AnyWidget`` subclass. - - Parameters - ---------- - static_icon : str - Unicode character shown as the "static snapshot" badge. - Default ``"📷"``. Override via the ``anywidget_static_icon`` Sphinx - config value when using the ``sphinx_anywidget`` extension. """ - def __init__(self, static_icon: str = "\U0001f4f7"): - self.static_icon = static_icon + def __init__(self): # Maps src_file → list of fig_ids emitted so far (creation order). self._example_figs: dict = {} @@ -289,7 +278,6 @@ def __call__(self, block, block_vars, gallery_conf): src, w, h, fig_id=fig_id, interactive=is_interactive, - static_icon=self.static_icon, ) rst = "\n\n.. raw:: html\n\n " + iframe_block + "\n\n" diff --git a/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js b/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js index 49d6dfc1..110e16c7 100644 --- a/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js +++ b/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js @@ -89,7 +89,6 @@ btn.textContent = '✓'; btn.dataset.state = 'active'; btn.title = 'Python active'; - // Mark the wrapper so CSS can hide the static icon const wrap = btn.closest('.awi-fig-wrap'); if (wrap) wrap.dataset.awiLive = 'true'; } @@ -353,6 +352,8 @@ elif _widget is None: // ── package name inference ──────────────────────────────────────────────── function _inferPackageName() { + // 0. Authoritative: set by anywidget_config.js (written at build time) + if (window._anywidgetPackage) return window._anywidgetPackage; // 1. Check for a tag (set by the extension) const meta = document.querySelector('meta[name="anywidget:package"]'); if (meta) return meta.getAttribute('content'); diff --git a/anyplotlib/sphinx_anywidget/static/anywidget_overlay.css b/anyplotlib/sphinx_anywidget/static/anywidget_overlay.css index cbed923d..87f96a13 100644 --- a/anyplotlib/sphinx_anywidget/static/anywidget_overlay.css +++ b/anyplotlib/sphinx_anywidget/static/anywidget_overlay.css @@ -9,7 +9,6 @@ *
* *
- * 📷 * *
*
@@ -23,38 +22,34 @@ /* ── badge container ─────────────────────────────────────────────────────── */ .awi-badge { position: absolute; - top: 6px; - right: 6px; + top: 8px; + right: 8px; display: flex; align-items: center; - gap: 4px; + gap: 8px; z-index: 10; - pointer-events: none; /* icons are pointer-none by default; button overrides */ + pointer-events: none; } +/* ── after activation: no change needed — stays top-right ───────────────── */ + /* ── shared icon style ───────────────────────────────────────────────────── */ .awi-badge-icon { display: flex; align-items: center; justify-content: center; - width: 28px; - height: 28px; + width: 38px; + height: 38px; border-radius: 50%; - font-size: 14px; + font-size: 18px; line-height: 1; - background: rgba(30, 30, 46, 0.72); - backdrop-filter: blur(4px); - border: 1px solid rgba(255, 255, 255, 0.18); + background: rgba(30, 30, 46, 0.82); + backdrop-filter: blur(6px); + border: 1px solid rgba(255, 255, 255, 0.25); color: #fff; - box-shadow: 0 1px 4px rgba(0,0,0,0.4); + box-shadow: 0 2px 8px rgba(0,0,0,0.5); transition: opacity 0.25s, background 0.2s, transform 0.15s; - opacity: 0.80; -} - -/* ── static snapshot icon (📷 or custom) ────────────────────────────────── */ -.awi-static-icon { - opacity: 0.55; - cursor: default; + opacity: 0.90; } /* ── ⚡ activation button ────────────────────────────────────────────────── */ @@ -101,9 +96,5 @@ pointer-events: none; } -/* ── hide static icon once active (cleans up the badge) ─────────────────── */ -.awi-fig-wrap[data-awi-live="true"] .awi-static-icon { - opacity: 0; - pointer-events: none; -} + diff --git a/docs/_static/pyodide_bridge.js b/docs/_static/pyodide_bridge.js index 49b16f11..d0ea5b6c 100644 --- a/docs/_static/pyodide_bridge.js +++ b/docs/_static/pyodide_bridge.js @@ -202,40 +202,27 @@ document.head.appendChild(s); })); } + console.info('[anyplotlib] pyodide.js ready'); // 2. Initialise Pyodide const pyodide = await _step('init pyodide', loadPyodide({ indexURL: PYODIDE_CDN })); + console.info('[anyplotlib] Pyodide initialised'); // 3. Install packages - // - // numpy — pre-built binary in Pyodide's package index. - // traitlets, colorcet — pure-Python; fetched from PyPI via micropip. - // anyplotlib — wheel from _static/wheels/ (same source tree as the docs). - // - // anyplotlib declares anywidget as a dep; anywidget → psygnal may have - // no Pyodide-compatible wheel. Safe strategy: - // a) load numpy + micropip from Pyodide's bundled index (fast), - // b) install traitlets + colorcet via micropip in Python (avoids - // JS-Array → Python-list coercion when calling the PyProxy), - // c) pre-populate sys.modules['anywidget'] with a HasTraits stub so - // micropip never tries to fetch it, - // d) install the anyplotlib wheel with deps=False. - - // a) Pyodide-bundled packages await _step('load numpy', pyodide.loadPackage(['micropip', 'numpy'])); + console.info('[anyplotlib] numpy + micropip loaded'); - // b) Pure-Python deps — run as Python to avoid JS array coercion issues + // b) Pure-Python deps await _step('install traitlets/colorcet', pyodide.runPythonAsync(` import micropip await micropip.install(['traitlets', 'colorcet']) `)); + console.info('[anyplotlib] traitlets + colorcet installed'); - // 4. Stub anywidget BEFORE installing the anyplotlib wheel. - // anyplotlib/figure.py imports anywidget inside a try/except and will - // pick up this stub as _AnyWidgetBase automatically. + // 4. Stub anywidget await _step('stub anywidget', pyodide.runPythonAsync(` import sys, traitlets as _tr @@ -252,23 +239,19 @@ class _AnyWidgetMod: sys.modules['anywidget'] = _AnyWidgetMod() _APL_REGISTRY = {} `)); + console.info('[anyplotlib] anywidget stub installed'); - // c) Install anyplotlib wheel directly via pyodide.loadPackage(url). - // loadPackage accepts wheel URLs and installs them without dependency - // resolution, which sidesteps the micropip "Attempted to install wheel - // before downloading it" bug that is triggered by deps=False on URL - // installs in Pyodide 0.27.x. anywidget is already in sys.modules so - // importing anyplotlib will use our stub. + // c) Install anyplotlib wheel const wheelUrl = _DOCS_ROOT + '_static/wheels/anyplotlib-0.0.0-py3-none-any.whl'; + console.info('[anyplotlib] installing anyplotlib wheel from', wheelUrl); await _step('install anyplotlib wheel', pyodide.loadPackage(wheelUrl)); + console.info('[anyplotlib] anyplotlib installed'); - // 5. Expose window._aplPush so Python can push state into iframes + // 5. Expose window._aplPush window._aplPush = (figId, key, value) => _postToIframe(String(figId), String(key), String(value)); - // 6. Install the push hook — from now on every Figure._push() / - // _push_layout() / _push_widget() call routes through _aplPush - // instead of writing to a Jupyter traitlet. + // 6. Install the push hook await _step('install push hook', pyodide.runPythonAsync(` import anyplotlib.figure as _af @@ -281,6 +264,7 @@ def _push_hook(fig, key, value_str): _af._pyodide_push_hook = _push_hook `)); + console.info('[anyplotlib] push hook installed'); // 7. Collect text/x-python script blocks, group by src-file so each // example source runs exactly once even if it creates multiple figures. @@ -305,12 +289,18 @@ _af._pyodide_push_hook = _push_hook // 8. Run each example source once, tag created figures in creation order, // then push the current Python state into the matching iframes. + const _execErrors = []; for (const [srcFile, { src, pairs }] of srcGroups) { const figIdList = JSON.stringify(pairs.map(p => p.figId)); + console.info(`[anyplotlib] running example: ${srcFile} (${pairs.length} figure(s))`); + // Pre-assign the srcFile name to a Python variable to avoid embedding + // JSON.stringify output (which uses double quotes) inside Python f-strings. + const _srcFileRepr = JSON.stringify(srcFile); try { await pyodide.runPythonAsync(` import anyplotlib.figure as _af +_SRC_FILE = ${_srcFileRepr} _CREATED_FIGS = [] _SEEN_IDS = set() _orig_init = _af.Figure.__init__ @@ -322,14 +312,18 @@ def _tracked_init(self, *a, **kw): _CREATED_FIGS.append(self) _af.Figure.__init__ = _tracked_init +_exec_error = None try: - exec(${JSON.stringify(src)}, {}) + exec(${JSON.stringify(src)}, {"__name__": "__main__"}) except Exception as _e: - print(f"[anyplotlib] exec error in ${JSON.stringify(srcFile)}: {_e}") + import traceback as _tb + _exec_error = _tb.format_exc() + print("[anyplotlib] exec error in " + _SRC_FILE + ":\\n" + _exec_error) finally: _af.Figure.__init__ = _orig_init _fig_ids = ${figIdList} +_wired = 0 for _i, _fid in enumerate(_fig_ids): if _i < len(_CREATED_FIGS): _fig = _CREATED_FIGS[_i] @@ -338,21 +332,41 @@ for _i, _fid in enumerate(_fig_ids): _fig._push_layout() for _pid in list(_fig._plots_map): _fig._push(_pid) + _wired += 1 + +print("[anyplotlib] wired " + str(_wired) + "/" + str(len(_fig_ids)) + " figures for " + _SRC_FILE) +if _exec_error: + raise RuntimeError("exec failed: " + _exec_error) `); } catch (err) { - console.warn(`[anyplotlib] Pyodide failed for ${srcFile}:`, err); + const msg = String(err.message || err); + console.warn(`[anyplotlib] Pyodide failed for ${srcFile}:`, msg); + _execErrors.push(`${srcFile}: ${msg.split('\n').filter(Boolean).pop()}`); } } + // Surface exec errors in the button tooltip so the user can open DevTools + if (_execErrors.length > 0) { + btn.title = 'Python active (some examples failed — open DevTools console for details)\n\n' + + _execErrors.join('\n'); + } + // 9. Route interaction events from iframes → Pyodide callbacks window.addEventListener('message', async (e) => { if (!e.data || e.data.type !== 'apl_event') return; const { figId, data } = e.data; + console.debug('[anyplotlib] apl_event received', figId, + JSON.parse(data || '{}').event_type); + const _figIdRepr = JSON.stringify(figId); + const _dataRepr = JSON.stringify(data); try { await pyodide.runPythonAsync(` -_fig = _APL_REGISTRY.get(${JSON.stringify(figId)}) +_FIG_ID = ${_figIdRepr} +_fig = _APL_REGISTRY.get(_FIG_ID) if _fig is not None: - _fig._dispatch_event(${JSON.stringify(data)}) + _fig._dispatch_event(${_dataRepr}) +else: + print("[anyplotlib] no figure registered for figId=" + repr(_FIG_ID) + "; registry keys: " + str(list(_APL_REGISTRY.keys()))) `); } catch (err) { console.warn('[anyplotlib] event dispatch error:', err); diff --git a/test_sphinx_anywidget.py b/test_sphinx_anywidget.py index 3e1efed2..4e4e8697 100644 --- a/test_sphinx_anywidget.py +++ b/test_sphinx_anywidget.py @@ -19,9 +19,8 @@ w, h = _widget_px(fig) assert w == 416, f'Expected 416 got {w}' -b = _iframe_html('t.html', 400, 300, fig_id='a', interactive=True, static_icon='P') +b = _iframe_html('t.html', 400, 300, fig_id='a', interactive=True) assert 'awi-activate-btn' in b, 'Missing activate button' -assert 'awi-static-icon' in b, 'Missing static icon' s = _iframe_html('t.html', 400, 300, fig_id='a', interactive=False) assert 'awi-activate-btn' not in s, 'Should not have activate btn on static' diff --git a/tests/test_pyodide_e2e.py b/tests/test_pyodide_e2e.py index 4bb519bd..2378ac11 100644 --- a/tests/test_pyodide_e2e.py +++ b/tests/test_pyodide_e2e.py @@ -298,7 +298,6 @@ def _build_parent_page( overflow:hidden;display:block;">
- 📷 From bdfee953a400a1f1e5d1229f6bea7cfd6d31bc17 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 3 May 2026 14:20:23 -0500 Subject: [PATCH 072/198] feat: enhance Pyodide bridge with event handling and deterministic panel IDs --- .../Interactive/plot_interactive_fitting.py | 2 +- .../Interactive/plot_segment_by_contrast.py | 2 +- anyplotlib/_repr_utils.py | 56 ++++++++++++++++--- anyplotlib/figure.py | 17 +++++- anyplotlib/figure_plots.py | 18 +++++- tests/test_pyodide_e2e.py | 34 +++++------ 6 files changed, 102 insertions(+), 27 deletions(-) diff --git a/Examples/Interactive/plot_interactive_fitting.py b/Examples/Interactive/plot_interactive_fitting.py index 06edc414..ce1988e0 100644 --- a/Examples/Interactive/plot_interactive_fitting.py +++ b/Examples/Interactive/plot_interactive_fitting.py @@ -289,4 +289,4 @@ def _on_fit(event): def _clicked(event, c=comp): c.toggle() -fig \ No newline at end of file +fig # Interactive \ No newline at end of file diff --git a/Examples/Interactive/plot_segment_by_contrast.py b/Examples/Interactive/plot_segment_by_contrast.py index fe559c31..e195dccc 100644 --- a/Examples/Interactive/plot_segment_by_contrast.py +++ b/Examples/Interactive/plot_segment_by_contrast.py @@ -232,6 +232,6 @@ def _delete_nearest(event): _refresh() -fig +fig # Interactive diff --git a/anyplotlib/_repr_utils.py b/anyplotlib/_repr_utils.py index c65063b4..f8e57622 100644 --- a/anyplotlib/_repr_utils.py +++ b/anyplotlib/_repr_utils.py @@ -129,23 +129,51 @@ def _widget_px(widget) -> tuple[int, int]:
""" -def build_standalone_html(widget, *, resizable: bool = True) -> str: +def build_standalone_html(widget, *, resizable: bool = True, + fig_id: str | None = None) -> str: """Return a self-contained HTML page that renders *widget* interactively. Parameters @@ -193,6 +231,9 @@ def build_standalone_html(widget, *, resizable: bool = True) -> str: When ``True`` (default) the widget's built-in resize handle is preserved. When ``False`` the handle is hidden via CSS and the page is sized exactly to the widget's natural dimensions. + fig_id : str or None + When provided, embedded as ``FIG_ID`` so the parent-page bridge + can route ``postMessage`` state updates to this iframe. """ state = _widget_state(widget) @@ -210,6 +251,7 @@ def build_standalone_html(widget, *, resizable: bool = True) -> str: extra_css=extra_css, state_json=json.dumps(state, default=str), esm_json=json.dumps(esm), + fig_id_json=json.dumps(fig_id), ) diff --git a/anyplotlib/figure.py b/anyplotlib/figure.py index b044d3bd..7161721e 100644 --- a/anyplotlib/figure.py +++ b/anyplotlib/figure.py @@ -351,7 +351,22 @@ def _on_resize(self, change) -> None: @traitlets.observe("event_json") def _on_event(self, change) -> None: """Dispatch a JS interaction event to the relevant plot and widget callbacks.""" - raw = change["new"] + self._dispatch_event(change["new"]) + + def _dispatch_event(self, raw: str) -> None: + """Process a raw JSON event string from the JS side. + + Called by ``_on_event`` (traitlets observer) and also directly by the + Pyodide bridge (``anywidget_bridge.js``) when forwarding user interaction + events from the iframe back to Python callbacks. + + Parameters + ---------- + raw : str + JSON-encoded event message. Expected keys: ``event_type``, + ``panel_id``, and optionally ``source``, ``widget_id``, plus + any event-specific payload fields. + """ if not raw or raw == "{}": return try: diff --git a/anyplotlib/figure_plots.py b/anyplotlib/figure_plots.py index da4e2698..f0539a70 100644 --- a/anyplotlib/figure_plots.py +++ b/anyplotlib/figure_plots.py @@ -531,13 +531,29 @@ def bar(self, x, height=None, width: float = 0.8, bottom: float = 0.0, *, self._attach(plot) return plot + def _panel_id_from_spec(self) -> str: + """Derive a deterministic, position-based panel ID from the SubplotSpec. + + The ID is ``"p"`` followed by the first 7 hex characters of a SHA-256 + hash of the row/col bounds, e.g. ``"p6a2f3b1"``. This is: + + * **Deterministic** – the same SubplotSpec always produces the same ID + across Python processes and after code edits. + * **Starts with "p"** – satisfies the JS naming convention and makes it + easy to grep for panel traits (``panel_{id}_json``). + * **Short** – 8 characters total; safe to embed in CSS selectors. + """ + import hashlib as _hl + key = f"{self._spec.row_start},{self._spec.row_stop},{self._spec.col_start},{self._spec.col_stop}" + return "p" + _hl.sha256(key.encode()).hexdigest()[:7] + def _attach(self, plot: "Plot1D | Plot2D | PlotMesh | Plot3D | PlotBar") -> None: """Register a plot on this axes (replace any previous plot).""" # Allocate a panel id if needed; reuse if replacing if self._plot is not None: panel_id = self._plot._id else: - panel_id = str(_uuid.uuid4())[:8] + panel_id = self._panel_id_from_spec() plot._id = panel_id plot._fig = self._fig self._plot = plot diff --git a/tests/test_pyodide_e2e.py b/tests/test_pyodide_e2e.py index 2378ac11..17d1078b 100644 --- a/tests/test_pyodide_e2e.py +++ b/tests/test_pyodide_e2e.py @@ -236,26 +236,12 @@ def _build_parent_page( return; }} - // ── install generic anywidget monkey-patch ───────────────────── - // Identified by the '_patched_init' marker in the monkey-patch code. - // Installs window._anywidgetPush so postMessage reaches the iframe. - if (src.includes('_patched_init') || src.includes('_anywidget_fig_id')) {{ - window._APL_BOOT_STEPS.push('install_monkey_patch'); - // Install the real _anywidgetPush — delivers awi_state postMessages. - window._anywidgetPush = function(figId, key, value) {{ - const iframe = document.querySelector('iframe[data-awi-fig="' + figId + '"]'); - if (iframe && iframe.contentWindow) {{ - iframe.contentWindow.postMessage( - {{type: 'awi_state', key: key, value: value}}, '*'); - }} - }}; - return; - }} - // ── exec(example_src) + _push_layout() + _push(panel_id) ──────── // Triggered by the `_fig_ids = …` line that anywidget_bridge.js // wraps around every example exec call. We skip the actual Python // exec and instead push pre-collected state directly. + // NOTE: checked BEFORE the monkey-patch pattern because step 9 in + // anywidget_bridge.js contains both _fig_ids and _anywidget_fig_id. if (src.includes('_fig_ids')) {{ window._APL_BOOT_STEPS.push('run_example'); const fid = window._MOCK_FIG_ID; @@ -269,6 +255,22 @@ def _build_parent_page( return; }} + // ── install generic anywidget monkey-patch ───────────────────── + // Identified by the '_patched_init' marker in the monkey-patch code. + // Installs window._anywidgetPush so postMessage reaches the iframe. + if (src.includes('_patched_init') || src.includes('_anywidget_fig_id')) {{ + window._APL_BOOT_STEPS.push('install_monkey_patch'); + // Install the real _anywidgetPush — delivers awi_state postMessages. + window._anywidgetPush = function(figId, key, value) {{ + const iframe = document.querySelector('iframe[data-awi-fig="' + figId + '"]'); + if (iframe && iframe.contentWindow) {{ + iframe.contentWindow.postMessage( + {{type: 'awi_state', key: key, value: value}}, '*'); + }} + }}; + return; + }} + // Catch-all for any other runPythonAsync call window._APL_BOOT_STEPS.push('runPythonAsync:other'); }} From 593e189fae40ea7a88040170e9c1bbffe9f85bcf Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 3 May 2026 14:20:34 -0500 Subject: [PATCH 073/198] refactor: remove stale pyodide_bridge.js reference and add anywidget_bridge.js for anyplotlib extension --- docs/conf.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 6cbf57ce..71279cc2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -34,6 +34,7 @@ "sphinx.ext.intersphinx", "sphinx_gallery.gen_gallery", "sphinx_design", + "anyplotlib.sphinx_anywidget", ] autosummary_generate = True @@ -82,10 +83,8 @@ html_theme = "pydata_sphinx_theme" html_static_path = ["_static"] html_css_files = ["custom.css"] - -# pyodide_bridge.js adds the "⚡" activation button to gallery pages and boots -# a single shared Pyodide instance for the whole page on click. -html_js_files = ["pyodide_bridge.js"] +# anywidget_bridge.js is injected by the anyplotlib.sphinx_anywidget extension; +# the stale pyodide_bridge.js reference has been removed. html_theme_options = { "github_url": "https://github.com/CSSFrancis/anyplotlib", From c6a9532e5df0c283444be08ea111c365b2996352 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 3 May 2026 14:35:18 -0500 Subject: [PATCH 074/198] feat: add support for extracting and loading Pyodide packages in interactive examples --- .../Interactive/plot_interactive_fitting.py | 3 +++ anyplotlib/sphinx_anywidget/_scraper.py | 22 +++++++++++++++++ .../static/anywidget_bridge.js | 24 +++++++++++++++---- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/Examples/Interactive/plot_interactive_fitting.py b/Examples/Interactive/plot_interactive_fitting.py index ce1988e0..8acd7b69 100644 --- a/Examples/Interactive/plot_interactive_fitting.py +++ b/Examples/Interactive/plot_interactive_fitting.py @@ -19,6 +19,9 @@ and the sum curve will jump to the optimal fit. Click a component line again to hide its widgets. """ +# Packages required when running interactively in Pyodide (docs live mode). +_PYODIDE_PACKAGES = ["scipy"] + import numpy as np from scipy.optimize import curve_fit import anyplotlib as apl diff --git a/anyplotlib/sphinx_anywidget/_scraper.py b/anyplotlib/sphinx_anywidget/_scraper.py index 807be2a3..71dbf21f 100644 --- a/anyplotlib/sphinx_anywidget/_scraper.py +++ b/anyplotlib/sphinx_anywidget/_scraper.py @@ -51,6 +51,11 @@ # Sentinel that marks a code block as interactive. _INTERACTIVE_RE = re.compile(r"#\s*interactive\s*$", re.IGNORECASE | re.MULTILINE) +# Pattern that extracts _PYODIDE_PACKAGES = [...] declarations from source. +_PYODIDE_PACKAGES_RE = re.compile( + r"^_PYODIDE_PACKAGES\s*=\s*(\[[^\]]*\])", re.MULTILINE +) + # --------------------------------------------------------------------------- # Helpers @@ -293,11 +298,28 @@ def __call__(self, block, block_vars, gallery_conf): if python_src: data_src = _html_escape(_json.dumps(python_src), quote=True) + + # Detect _PYODIDE_PACKAGES = [...] in the source. + _pkg_attr = "" + m = _PYODIDE_PACKAGES_RE.search(python_src) + if m: + try: + import ast as _ast + pkgs = _ast.literal_eval(m.group(1)) + if pkgs: + _pkg_attr = ( + f' data-pyodide-packages=' + f'"{_html_escape(_json.dumps(pkgs), quote=True)}"' + ) + except Exception: + pass + python_block = ( f'' ) rst += "\n\n.. raw:: html\n\n " + python_block + "\n\n" diff --git a/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js b/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js index 110e16c7..51305fea 100644 --- a/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js +++ b/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js @@ -237,7 +237,7 @@ print('[sphinx_anywidget] anywidget monkey-patch installed') // 8. Collect text/x-python script blocks, group by src-file so each // example source runs exactly once even with multiple figures. - const srcGroups = new Map(); // srcFile → { src, pairs: [{figId, figIndex}] } + const srcGroups = new Map(); // srcFile → { src, pairs: [{figId, figIndex}], packages: [] } for (const script of document.querySelectorAll( 'script[type="text/x-python"][data-fig-id]')) { @@ -246,9 +246,14 @@ print('[sphinx_anywidget] anywidget monkey-patch installed') const figIndex = parseInt(script.dataset.figIndex || '0', 10); let src = ''; try { src = JSON.parse(script.dataset.src || 'null') || ''; } catch (_) {} - - if (!srcGroups.has(srcFile)) srcGroups.set(srcFile, { src, pairs: [] }); - srcGroups.get(srcFile).pairs.push({ figId, figIndex }); + let packages = []; + try { packages = JSON.parse(script.dataset.pyodidePackages || 'null') || []; } catch (_) {} + + if (!srcGroups.has(srcFile)) srcGroups.set(srcFile, { src, pairs: [], packages }); + const grp = srcGroups.get(srcFile); + grp.pairs.push({ figId, figIndex }); + // Merge any packages declared by any script tag for this source file. + for (const p of packages) if (!grp.packages.includes(p)) grp.packages.push(p); } for (const g of srcGroups.values()) @@ -257,10 +262,19 @@ print('[sphinx_anywidget] anywidget monkey-patch installed') // 9. Run each example source once, assign _anywidget_fig_id in creation // order, then push current state into the matching iframes. const _execErrors = []; - for (const [srcFile, { src, pairs }] of srcGroups) { + for (const [srcFile, { src, pairs, packages }] of srcGroups) { const figIdList = JSON.stringify(pairs.map(p => p.figId)); console.info(`[sphinx_anywidget] running: ${srcFile} (${pairs.length} figure(s))`); const _srcFileRepr = JSON.stringify(srcFile); + + // Load any extra packages declared by this example (e.g. scipy). + if (packages.length > 0) { + console.info(`[sphinx_anywidget] loading extra packages for ${srcFile}:`, packages); + await _step(`load packages for ${srcFile}`, + pyodide.loadPackage(packages)); + console.info(`[sphinx_anywidget] extra packages loaded for ${srcFile}`); + } + try { await pyodide.runPythonAsync(` import anywidget as _aw From a4a7d10e974dcb9749d727de43d895e1260e9aef Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 3 May 2026 21:02:07 -0500 Subject: [PATCH 075/198] feat: add .gitignore and update conf.py for wheel build optimization --- .github/workflows/docs.yml | 24 ++++++++++++++++++++ .gitignore | 45 ++++++++++++++++++++++++++++++++++++++ docs/conf.py | 26 ++++++++++++++++------ 3 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 .gitignore diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 1442bb87..17a97dab 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -53,6 +53,20 @@ jobs: - name: Install Playwright browser run: uv run playwright install chromium --with-deps + # ── Build Pyodide wheel ─────────────────────────────────────────────── + # Produces docs/_static/wheels/anyplotlib-0.0.0-py3-none-any.whl so the + # in-browser Pyodide bridge can install the exact source tree that built + # these docs — no PyPI release required. + - name: Build Pyodide wheel + run: | + mkdir -p docs/_static/wheels + uv build --wheel --out-dir docs/_static/wheels/ + # Rename to the stable sentinel name micropip expects for URL installs. + cd docs/_static/wheels + for f in anyplotlib-*.whl; do + [ "$f" != "anyplotlib-0.0.0-py3-none-any.whl" ] && mv "$f" anyplotlib-0.0.0-py3-none-any.whl + done + # ── Sphinx build ───────────────────────────────────────────────────── # -W turns warnings into errors; --keep-going collects all of them. - name: Build HTML documentation @@ -110,6 +124,16 @@ jobs: echo "dest_dir=dev" >> "$GITHUB_OUTPUT" fi + # ── Build Pyodide wheel ─────────────────────────────────────────────── + - name: Build Pyodide wheel + run: | + mkdir -p docs/_static/wheels + uv build --wheel --out-dir docs/_static/wheels/ + cd docs/_static/wheels + for f in anyplotlib-*.whl; do + [ "$f" != "anyplotlib-0.0.0-py3-none-any.whl" ] && mv "$f" anyplotlib-0.0.0-py3-none-any.whl + done + # ── Sphinx build ───────────────────────────────────────────────────── - name: Build HTML documentation env: diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..4c0a1cd3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Python bytecode / caches +__pycache__/ +*.py[cod] +*$py.class +*.pyo + +# Distribution / packaging +dist/ +build/ +*.egg-info/ +*.egg +.eggs/ + +# Virtual environments +.venv/ +venv/ +env/ + +# Test / coverage artefacts +.pytest_cache/ +.coverage +coverage.xml +htmlcov/ + +# Jupyter notebooks checkpoints +.ipynb_checkpoints/ + +# Sphinx build output +docs/_build/ +build/html/ +build/doctrees/ + +# Generated Pyodide wheel (built by workflow / make html — never commit) +docs/_static/wheels/ + +# Editor / IDE +.idea/ +.vscode/ +*.swp +*.swo + +# macOS +.DS_Store + + diff --git a/docs/conf.py b/docs/conf.py index 71279cc2..cbfa2bb3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -123,7 +123,13 @@ # pyodide_bridge.js derives the wheel URL from its own script src, which # already carries the version prefix. def setup(app): - """Build the anyplotlib wheel for the in-browser Pyodide bridge.""" + """Build the anyplotlib wheel for the in-browser Pyodide bridge. + + In CI the workflow pre-builds the wheel before sphinx-build runs, so we + skip the build step if ``anyplotlib-0.0.0-py3-none-any.whl`` is already + present. This avoids a redundant ``pip wheel`` invocation and keeps the + local ``make html`` path working without needing ``uv build``. + """ import subprocess import sys from pathlib import Path @@ -131,6 +137,14 @@ def setup(app): wheels_dir = Path(__file__).parent / "_static" / "wheels" wheels_dir.mkdir(parents=True, exist_ok=True) + stable = wheels_dir / "anyplotlib-0.0.0-py3-none-any.whl" + + # If the wheel was already built by the workflow step (or a previous local + # build), reuse it rather than rebuilding. + if stable.exists(): + print(f"[pyodide_bridge] wheel already present → {stable}") + return + # Remove stale wheels from previous builds. for old in wheels_dir.glob("anyplotlib*.whl"): old.unlink(missing_ok=True) @@ -153,13 +167,11 @@ def setup(app): print(f"\n[pyodide_bridge] WARNING: wheel build failed:\n{result.stderr}") return - # Rename to a stable, version-agnostic filename so pyodide_bridge.js can - # reference it without knowing the current version string. - # NOTE: "latest" is NOT a valid PEP 440 version; micropip rejects it. - # "0.0.0" is the simplest valid sentinel that micropip accepts when the - # wheel is installed via URL (no PyPI version-check happens for URL installs). + # Rename to the stable sentinel name micropip accepts for URL installs. + # "0.0.0" is a valid PEP 440 version; micropip skips PyPI checks for + # wheels installed by URL so the actual version number doesn't matter. wheels = sorted(wheels_dir.glob("anyplotlib*.whl")) if wheels: - stable = wheels_dir / "anyplotlib-0.0.0-py3-none-any.whl" stable.unlink(missing_ok=True) wheels[-1].rename(stable) + print(f"[pyodide_bridge] wheel → {stable}") From beb25efc154426c8b305d64a9d09f4299d45970e Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 3 May 2026 21:35:32 -0500 Subject: [PATCH 076/198] chore: remove committed __pycache__ .pyc files and add them to .gitignore --- .../__pycache__/__init__.cpython-313.pyc | Bin 5337 -> 0 bytes .../__pycache__/_directive.cpython-313.pyc | Bin 6660 -> 0 bytes .../__pycache__/_repr_utils.cpython-313.pyc | Bin 10925 -> 0 bytes .../__pycache__/_scraper.cpython-313.pyc | Bin 12628 -> 0 bytes .../__pycache__/_wheel_builder.cpython-313.pyc | Bin 2659 -> 0 bytes 5 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 anyplotlib/sphinx_anywidget/__pycache__/__init__.cpython-313.pyc delete mode 100644 anyplotlib/sphinx_anywidget/__pycache__/_directive.cpython-313.pyc delete mode 100644 anyplotlib/sphinx_anywidget/__pycache__/_repr_utils.cpython-313.pyc delete mode 100644 anyplotlib/sphinx_anywidget/__pycache__/_scraper.cpython-313.pyc delete mode 100644 anyplotlib/sphinx_anywidget/__pycache__/_wheel_builder.cpython-313.pyc diff --git a/anyplotlib/sphinx_anywidget/__pycache__/__init__.cpython-313.pyc b/anyplotlib/sphinx_anywidget/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index a0cb230ef18f6d8a88ac5895a954a132dcf31ed6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5337 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 DR*7&C diff --git a/anyplotlib/sphinx_anywidget/__pycache__/_directive.cpython-313.pyc b/anyplotlib/sphinx_anywidget/__pycache__/_directive.cpython-313.pyc deleted file mode 100644 index b6836ae5015958abdd903a00d076a9e8ae105acb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6660 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 IC{ywO1?8fSb^rhX diff --git a/anyplotlib/sphinx_anywidget/__pycache__/_repr_utils.cpython-313.pyc b/anyplotlib/sphinx_anywidget/__pycache__/_repr_utils.cpython-313.pyc deleted file mode 100644 index c96fef2deb4bcf4bf3a11856be9fb18032371856..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10925 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 diff --git a/anyplotlib/sphinx_anywidget/__pycache__/_scraper.cpython-313.pyc b/anyplotlib/sphinx_anywidget/__pycache__/_scraper.cpython-313.pyc deleted file mode 100644 index e44f132fc317246125ed6dab7b26e6a9c7829221..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12628 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 diff --git a/anyplotlib/sphinx_anywidget/__pycache__/_wheel_builder.cpython-313.pyc b/anyplotlib/sphinx_anywidget/__pycache__/_wheel_builder.cpython-313.pyc deleted file mode 100644 index e4d7ca526d4f4943b5a8addcfcb8fd5728ee8104..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2659 zcma)8-ESLJ7Qgn4$Ikd8A5KXM{kTd?!P(j_q;%C#c9p7>B_*k`Yq3%UFgNQ2GY9h;!G(rXHLC<6@e#Hiz%0CoDY1yo z2=ssROiHRb^+(~uOvsPxw(W480}@+=xm?hB&5(q&-;&V7JoNA^`2Ay42LEQ`2-#9u zDuxD|_WDv7e$sh}1IYK|^tY5lDO8eY!q4<|)95c_fF_saVq_0xFaoT|`4C16;|RqO zMoY>-QUq2UY*T0{V2xNWCJsswf@nuevC>dUEyb6H2lBEsuw~!cu6_BT;6{n_k{u~W ziX#K9y}p#3T|-DeiqKMeuv$u$Ll?qu(_zp7+Yh!Bnvn;mVzfH4G%?um`@A;LwUx5+ zWM8!kw(YZEv}eJdr^PvceF|(En+XRIjFzLn<@2&qN{f&b4-ahH>&wxB1rU?HTaTBc zaI;zEr1@Z(<)?G1dJTBr z-^GBwwq6$;cW|ZhrmGv*^;M#YwYRfpa`4N#jhU?N*dzQ=0yZ3*>!xkmtDx#O z`g?QSse{LG9u^tlo(rW;I8ZIZgxb5820!o`2KOw2r-K^@3lJ4irPip)IqAptwAeN+ z_Dy4m_lj;>5dNI%D?JBX%Y^-?t3zNp^J8S)WL$HuHzU&`cuv3P^WSvv-el*XjO9u=6 ztN4|<;^K>oFU7Lo+<9E;i5nrYh9jzywh6?7rH>|DorPze4k%a-jcV zOb_MT_ddj#q#vnYhb#H=s^u*Eif3D3tvwev*n6Hv`Vt4^ZV1ZL# zr9u1*r%L>YOCjBSg>iVwDD=Q4QuUQyYGq=8xtoW6Vm}XIwvST28komKzWS151rW@$ zI29FI;0um2QKUsmh~~eVFEhxUyrILHbcO1+VX}OoVbpY5FZ94!evioehX-Tz-;c5% z55m=Ztm#QOSk!8E4&Ip!)IEd7jM#UQYd-uCrJW;7#bP>KAFBf|H;)4 zu5P}yY5(i__Q~0u@n?6E7d9?`6CVYP_Q>47PjAn?+)fqm#7dpv^!?;SYq32&*M9Wk zcJkuJ<&Lbrzwqut>)OV`wmb#18&AJGe^)-VEg!nQaz{RTPabL~GFzv%Cfn-McjTE) zd~{>sA-E4^V)O`fR2F}NsPqx>cWkgGm?MfqGx9%G5Z41$A$QW5XUamovLS%KhZzHRD3Y7IrszbghCI( zN=W{B1cgp~hep3b>epzptDFywbw Date: Mon, 4 May 2026 07:54:54 -0500 Subject: [PATCH 077/198] feat: enhance anywidget integration with improved config handling and dynamic package detection --- Examples/plot_3d.py | 74 ----- Examples/plot_bar.py | 254 ------------------ Examples/plot_image2d.py | 139 ---------- Examples/plot_line_styles.py | 159 ----------- Examples/plot_pcolormesh.py | 65 ----- Examples/plot_spectra1d.py | 112 -------- anyplotlib/sphinx_anywidget/__init__.py | 38 ++- anyplotlib/sphinx_anywidget/_directive.py | 43 ++- anyplotlib/sphinx_anywidget/_scraper.py | 6 +- anyplotlib/sphinx_anywidget/_wheel_builder.py | 10 +- .../static/anywidget_bridge.js | 22 +- docs/_sg_html_scraper.py | 5 +- 12 files changed, 102 insertions(+), 825 deletions(-) delete mode 100644 Examples/plot_3d.py delete mode 100644 Examples/plot_bar.py delete mode 100644 Examples/plot_image2d.py delete mode 100644 Examples/plot_line_styles.py delete mode 100644 Examples/plot_pcolormesh.py delete mode 100644 Examples/plot_spectra1d.py diff --git a/Examples/plot_3d.py b/Examples/plot_3d.py deleted file mode 100644 index fb527450..00000000 --- a/Examples/plot_3d.py +++ /dev/null @@ -1,74 +0,0 @@ -""" -3D Plotting -=========== - -Demonstrate the three 3-D geometry types supported by -:meth:`~anyplotlib.figure_plots.Axes.plot_surface`, -:meth:`~anyplotlib.figure_plots.Axes.scatter3d`, and -:meth:`~anyplotlib.figure_plots.Axes.plot3d`. -Drag to rotate, scroll to zoom, press **R** to reset the view. -""" -import numpy as np -import anyplotlib as vw - -# ── Surface ─────────────────────────────────────────────────────────────────── -x = np.linspace(-3, 3, 60) -y = np.linspace(-3, 3, 60) -XX, YY = np.meshgrid(x, y) -ZZ = np.sin(np.sqrt(XX ** 2 + YY ** 2)) - -fig, ax = vw.subplots(1, 1, figsize=(520, 480)) -surf = ax.plot_surface(XX, YY, ZZ, - colormap="viridis", - x_label="x", y_label="y", z_label="sin(r)") - -fig - -# %% -# Scatter plot -# ------------ - -rng = np.random.default_rng(1) -n = 300 -theta = rng.uniform(0, 2 * np.pi, n) -phi = rng.uniform(0, np.pi, n) -r = rng.uniform(0.6, 1.0, n) -xs = r * np.sin(phi) * np.cos(theta) -ys = r * np.sin(phi) * np.sin(theta) -zs = r * np.cos(phi) - -fig2, ax2 = vw.subplots(1, 1, figsize=(480, 480)) -sc = ax2.scatter3d(xs, ys, zs, - color="#4fc3f7", point_size=3, - x_label="x", y_label="y", z_label="z") - -fig2 - -# %% -# 3-D line — parametric helix -# ---------------------------- - -t = np.linspace(0, 4 * np.pi, 300) -hx = np.cos(t) -hy = np.sin(t) -hz = t / (4 * np.pi) - -fig3, ax3 = vw.subplots(1, 1, figsize=(480, 480)) -ln = ax3.plot3d(hx, hy, hz, - color="#ff7043", linewidth=2, - x_label="cos t", y_label="sin t", z_label="t") - -fig3 - -# %% -# Update the surface data live -# ---------------------------- -# Call :meth:`~anyplotlib.figure_plots.Plot3D.set_data` to replace the geometry -# without recreating the panel. - -ZZ2 = np.cos(np.sqrt(XX ** 2 + YY ** 2)) -surf.set_data(XX, YY, ZZ2) -surf.set_colormap("plasma") -surf.set_view(azimuth=30, elevation=40) - -fig diff --git a/Examples/plot_bar.py b/Examples/plot_bar.py deleted file mode 100644 index 05772d05..00000000 --- a/Examples/plot_bar.py +++ /dev/null @@ -1,254 +0,0 @@ -""" -Bar Chart -========= - -Demonstrate :meth:`~anyplotlib.figure_plots.Axes.bar` with: - -* **Matplotlib-aligned API** — ``ax.bar(x, height, width, bottom, …)`` -* Vertical and horizontal orientations, per-bar colours, category labels -* **Grouped bars** — pass a 2-D *height* array ``(N, G)`` -* **Log-scale value axis** — ``log_scale=True`` -* Live data updates via :meth:`~anyplotlib.figure_plots.PlotBar.set_data` -""" -import numpy as np -import anyplotlib as vw - -rng = np.random.default_rng(7) - -# ── 1. Vertical bar chart — monthly sales ──────────────────────────────────── -# The first positional argument is now *x* (positions or labels), matching -# ``matplotlib.pyplot.bar(x, height, width=0.8, bottom=0.0, ...)``. -months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] -sales = np.array([42, 55, 48, 63, 71, 68, 74, 81, 66, 59, 52, 78], - dtype=float) - -fig1, ax1 = vw.subplots(1, 1, figsize=(640, 340)) -bar1 = ax1.bar( - months, # x — category strings become x_labels automatically - sales, # height - width=0.6, - color="#4fc3f7", - show_values=True, - units="Month", - y_units="Units sold", -) -fig1 - -# %% -# Horizontal bar chart — ranked items -# ------------------------------------- -# Set ``orient="h"`` for a horizontal layout. Pass a list of CSS colours -# to ``colors`` to give each bar its own colour. - -categories = ["NumPy", "SciPy", "Matplotlib", "Pandas", "Scikit-learn", - "PyTorch", "TensorFlow", "JAX", "Polars", "Dask"] -scores = np.array([95, 88, 91, 87, 83, 79, 76, 72, 68, 65], dtype=float) - -palette = [ - "#ef5350", "#ec407a", "#ab47bc", "#7e57c2", "#42a5f5", - "#26c6da", "#26a69a", "#66bb6a", "#d4e157", "#ffa726", -] - -fig2, ax2 = vw.subplots(1, 1, figsize=(540, 400)) -bar2 = ax2.bar( - categories, - scores, - orient="h", - colors=palette, - width=0.65, - show_values=True, - y_units="Popularity score", -) -fig2 - -# %% -# Grouped bar chart — quarterly comparison -# ----------------------------------------- -# Pass a 2-D *height* array of shape ``(N, G)`` to draw *G* bars side by -# side for each category. Provide ``group_labels`` to show a legend and -# ``group_colors`` to customise each group's colour. - -quarters = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"] -q_data = np.array([ - [42, 58, 51], # Jan — Q1, Q2, Q3 - [55, 61, 59], # Feb - [48, 70, 65], # Mar - [63, 75, 71], # Apr - [71, 69, 80], # May - [68, 83, 77], # Jun -], dtype=float) # shape (6, 3) → 6 categories, 3 groups - -fig3, ax3 = vw.subplots(1, 1, figsize=(680, 340)) -bar3 = ax3.bar( - quarters, - q_data, - width=0.8, - group_labels=["Q1", "Q2", "Q3"], - group_colors=["#4fc3f7", "#ff7043", "#66bb6a"], - show_values=False, - y_units="Sales", -) -fig3 - -# %% -# Log-scale value axis -# --------------------- -# Set ``log_scale=True`` for a logarithmic value axis. Non-positive values -# are clamped to ``1e-10`` — no error is raised. Tick marks are placed at -# each decade (10⁰, 10¹, 10², …) with faint minor gridlines at 2×, 3×, 5× -# multiples. - -log_labels = ["A", "B", "C", "D", "E"] -log_vals = np.array([1, 10, 100, 1_000, 10_000], dtype=float) - -fig4, ax4 = vw.subplots(1, 1, figsize=(500, 340)) -bar4 = ax4.bar( - log_labels, - log_vals, - log_scale=True, - color="#ab47bc", - show_values=True, - y_units="Count (log scale)", -) -fig4 - -# %% -# Side-by-side comparison — update data live -# ------------------------------------------- -# Place two :class:`~anyplotlib.figure_plots.PlotBar` panels in one figure. -# Call :meth:`~anyplotlib.figure_plots.PlotBar.set_data` to swap in Q2 data — -# the value-axis range recalculates automatically. - -q1 = np.array([42, 55, 48, 63, 71, 68, 74, 81, 66, 59, 52, 78], dtype=float) -q2 = np.array([58, 61, 70, 75, 69, 83, 90, 88, 77, 64, 71, 95], dtype=float) -all_months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] - -fig5, (ax_left, ax_right) = vw.subplots(1, 2, figsize=(820, 320)) -bar_left = ax_left.bar( - all_months, q1, width=0.6, - color="#4fc3f7", show_values=False, y_units="Q1 sales", -) -bar_right = ax_right.bar( - all_months, q1, width=0.6, - color="#ff7043", show_values=False, y_units="Q2 sales", -) -bar_right.set_data(q2) # swap in Q2 — axis range recalculates automatically - -fig5 - -# %% -# Mutate colours, annotations, and scale at runtime -# -------------------------------------------------- -# :meth:`~anyplotlib.figure_plots.PlotBar.set_color` repaints all bars, -# :meth:`~anyplotlib.figure_plots.PlotBar.set_show_values` toggles labels, -# :meth:`~anyplotlib.figure_plots.PlotBar.set_log_scale` switches the -# value-axis between linear and logarithmic. - -bar1.set_color("#ff7043") -bar1.set_show_values(False) -fig1 - -import numpy as np -import anyplotlib as vw - -rng = np.random.default_rng(7) - -# ── 1. Vertical bar chart — monthly sales ──────────────────────────────────── -months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] -sales = np.array([42, 55, 48, 63, 71, 68, 74, 81, 66, 59, 52, 78], - dtype=float) - -fig1, ax1 = vw.subplots(1, 1, figsize=(640, 340)) -bar1 = ax1.bar( - sales, - x_labels=months, - color="#4fc3f7", - bar_width=0.6, - show_values=True, - units="Month", - y_units="Units sold", -) -fig1 - -# %% -# Horizontal bar chart — ranked items -# ------------------------------------- -# Set ``orient="h"`` for a horizontal layout. Pass a list of CSS colours to -# ``colors`` to give each bar its own colour, and use ``show_values=True`` to -# annotate each bar with its numeric value. - -categories = ["NumPy", "SciPy", "Matplotlib", "Pandas", "Scikit-learn", - "PyTorch", "TensorFlow", "JAX", "Polars", "Dask"] -scores = np.array([95, 88, 91, 87, 83, 79, 76, 72, 68, 65], dtype=float) - -palette = [ - "#ef5350", "#ec407a", "#ab47bc", "#7e57c2", "#42a5f5", - "#26c6da", "#26a69a", "#66bb6a", "#d4e157", "#ffa726", -] - -fig2, ax2 = vw.subplots(1, 1, figsize=(540, 400)) -bar2 = ax2.bar( - scores, - x_labels=categories, - orient="h", - colors=palette, - bar_width=0.65, - show_values=True, - y_units="Popularity score", -) -fig2 - -# %% -# Side-by-side comparison — update data live -# ------------------------------------------- -# Place two :class:`~anyplotlib.figure_plots.PlotBar` panels in one -# :func:`~anyplotlib.figure_plots.subplots` figure. Call -# :meth:`~anyplotlib.figure_plots.PlotBar.set_data` to swap in Q2 data for the -# right panel, demonstrating how the axis range re-calculates automatically. - -quarters = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] - -q1 = np.array([42, 55, 48, 63, 71, 68, 74, 81, 66, 59, 52, 78], dtype=float) -q2 = np.array([58, 61, 70, 75, 69, 83, 90, 88, 77, 64, 71, 95], dtype=float) - -fig3, (ax_left, ax_right) = vw.subplots(1, 2, figsize=(820, 320)) - -bar_left = ax_left.bar( - q1, - x_labels=quarters, - color="#4fc3f7", - bar_width=0.6, - show_values=False, - y_units="Q1 sales", -) - -bar_right = ax_right.bar( - q1, # start with Q1 … - x_labels=quarters, - color="#ff7043", - bar_width=0.6, - show_values=False, - y_units="Q2 sales", -) - -# Swap in Q2 data — range is recalculated automatically -bar_right.set_data(q2) - -fig3 - -# %% -# Mutate colours and annotations at runtime -# ------------------------------------------ -# :meth:`~anyplotlib.figure_plots.PlotBar.set_color` repaints all bars with a -# single CSS colour. -# :meth:`~anyplotlib.figure_plots.PlotBar.set_show_values` toggles the -# in-bar value annotations. - -bar1.set_color("#ff7043") -bar1.set_show_values(False) -fig1 - diff --git a/Examples/plot_image2d.py b/Examples/plot_image2d.py deleted file mode 100644 index ed0e168a..00000000 --- a/Examples/plot_image2d.py +++ /dev/null @@ -1,139 +0,0 @@ -""" -2D Image with Histogram -======================= - -Display a 2-D image with physical axes, a colourmap, and an interactive -histogram below — all wired together with draggable threshold widgets. - -Layout ------- -A :class:`~anyplotlib.figure_plots.GridSpec` with two rows puts the image -on top and a bar-chart histogram below. Two -:class:`~anyplotlib.widgets.VLineWidget` handles on the histogram mark the -``display_min`` / ``display_max`` thresholds; dragging them updates the -image colour scale in real time. - -Key bindings on the image panel: **R** reset view · **C** toggle colorbar · -**L** / **S** cycle colour-scale modes. - -New ``imshow`` parameters -------------------------- -``cmap`` - Colormap name passed directly to :meth:`~anyplotlib.figure_plots.Axes.imshow` - (e.g. ``"viridis"``, ``"inferno"``). Defaults to ``"gray"``. -``vmin`` / ``vmax`` - Colormap clipping limits in data units. Values outside the range are - clamped to the colormap endpoints. Defaults to the data min/max. -``origin`` - ``"upper"`` (default) places row 0 at the top (image convention). - ``"lower"`` places row 0 at the bottom (scientific / matrix convention) - and automatically reverses the y-axis so tick values increase upward. -""" -import numpy as np -import anyplotlib as apl - - -rng = np.random.default_rng(1) - -# ── Synthetic diffraction pattern ───────────────────────────────────────────── -N = 256 -x = np.linspace(-5, 5, N) # physical axis in nm -y = np.linspace(-5, 5, N) -XX, YY = np.meshgrid(x, y) -R = np.sqrt(XX ** 2 + YY ** 2) - - -def _ring(r, r0, width, amp): - return amp * np.exp(-0.5 * ((r - r0) / width) ** 2) - - -image = ( - _ring(R, 0.0, 0.30, 1.00) # central spot - + _ring(R, 2.1, 0.15, 0.55) # first-order ring - + _ring(R, 4.2, 0.15, 0.25) # second-order ring - + rng.normal(scale=0.04, size=(N, N)) -) - -# ── Layout: image (top, 3×) + histogram bar chart (bottom, 1×) ──────────────── -gs = apl.GridSpec(2, 1, height_ratios=[3, 1]) -fig = apl.Figure(figsize=(500, 640)) -ax_img = fig.add_subplot(gs[0, 0]) -ax_hist = fig.add_subplot(gs[1, 0]) - -# ── Image panel — cmap, vmin, vmax supplied directly to imshow ──────────────── -vmin_init = float(image.min()) -vmax_init = float(image.max()) - -# Pass cmap, vmin, and vmax directly — no separate set_colormap / set_clim call -# needed for the initial display. -v = ax_img.imshow(image, axes=[x, y], units="nm", - cmap="inferno", vmin=vmin_init, vmax=vmax_init) - -# First-order spot markers -dx = x[1] - x[0] - - -def phys_to_px(val): - return (np.asarray(val) - x[0]) / dx - - -spot_nm = np.array([[ 2.1, 0.0], [-2.1, 0.0], - [ 0.0, 2.1], [ 0.0, -2.1]]) -spot_px = np.column_stack([phys_to_px(spot_nm[:, 0]), - phys_to_px(spot_nm[:, 1])]) -v.add_circles(spot_px, name="spots", radius=7, - edgecolors="#00e5ff", facecolors="#00e5ff22", - labels=["g1", "g1_bar", "g2", "g2_bar"]) - -# ── Histogram bar chart ──────────────────────────────────────────────────────── -counts, edges = np.histogram(image.ravel(), bins=64) -bin_centers = 0.5 * (edges[:-1] + edges[1:]) - -h = ax_hist.bar(counts, x_centers=bin_centers, orient="v", - color="#4fc3f7", y_units="count") - -# ── Draggable threshold handles on the histogram ────────────────────────────── -wlo = h.add_vline_widget(vmin_init, color="#ff6e40") # low-threshold handle -whi = h.add_vline_widget(vmax_init, color="#ffffff") # high-threshold handle - - -@wlo.on_release -def _apply_low(event): - """Update image display_min when the low handle is released.""" - v.set_clim(vmin=event.x) - - -@whi.on_release -def _apply_high(event): - """Update image display_max when the high handle is released.""" - v.set_clim(vmax=event.x) - - -fig - -# %% -# Adjust colour map and display range -# ------------------------------------ -# :meth:`~anyplotlib.figure_plots.Plot2D.set_colormap` switches the palette; -# :meth:`~anyplotlib.figure_plots.Plot2D.set_clim` adjusts the display range. -# Both are equivalent to passing ``cmap`` / ``vmin`` / ``vmax`` at construction. - -v.set_colormap("viridis") -v.set_clim(vmin=0.0, vmax=0.8) - -fig - -# %% -# origin='lower' — scientific / matrix convention -# ------------------------------------------------ -# Passing ``origin='lower'`` places row 0 of the data at the *bottom* of the -# image, matching the matplotlib / scientific convention. The y-axis is -# automatically reversed so tick values still increase upward. - -mat = np.arange(64, dtype=float).reshape(8, 8) # row 0 = small values - -fig2, ax2 = apl.subplots() -v2 = ax2.imshow(mat, cmap="plasma", origin="lower") - -fig2 - diff --git a/Examples/plot_line_styles.py b/Examples/plot_line_styles.py deleted file mode 100644 index dafef5a5..00000000 --- a/Examples/plot_line_styles.py +++ /dev/null @@ -1,159 +0,0 @@ -""" -1D Line Styles -============== - -Demonstrates the line-style, opacity, and per-point marker parameters -available on :meth:`~anyplotlib.figure_plots.Axes.plot` and -:meth:`~anyplotlib.figure_plots.Plot1D.add_line`. - -Four separate figures are shown: - -1. **Linestyles** – all four dash patterns on one panel with a legend. -2. **Alpha (transparency)** – two overlapping sine waves, each at 40 % opacity. -3. **Marker symbols** – all seven supported symbols, each on its own offset - curve. -4. **Combined** – dashed + semi-transparent + circle-marker overlay on a solid - primary line; demonstrates post-construction setters. -""" -import numpy as np -import anyplotlib as vw - -t256 = np.linspace(0.0, 2.0 * np.pi, 256) # dense — good for dashes / alpha -t24 = np.linspace(0.0, 2.0 * np.pi, 24) # sparse — makes markers visible - -# ── 1. Linestyles ───────────────────────────────────────────────────────────── -fig1, ax1 = vw.subplots(1, 1, figsize=(580, 300)) - -plot1 = ax1.plot(np.sin(t256), color="#4fc3f7", linewidth=2, - linestyle="solid", label="solid") -plot1.add_line(np.sin(t256) + 0.6, color="#ff7043", linewidth=2, - linestyle="dashed", label="dashed (\"--\")") -plot1.add_line(np.sin(t256) + 1.2, color="#aed581", linewidth=2, - linestyle="dotted", label="dotted (\":\")") -plot1.add_line(np.sin(t256) + 1.8, color="#ce93d8", linewidth=2, - linestyle="dashdot", label="dashdot (\"-.\")") - -fig1 - -# %% -# The ``ls`` shorthand -# -------------------- -# Each linestyle has a single-character (or two-character) shorthand that -# matches the matplotlib convention: -# -# * ``"-"`` → ``"solid"`` -# * ``"--"`` → ``"dashed"`` -# * ``":"`` → ``"dotted"`` -# * ``"-."`` → ``"dashdot"`` -# -# The shorthands work on both :meth:`~anyplotlib.figure_plots.Axes.plot` -# and :meth:`~anyplotlib.figure_plots.Plot1D.add_line`: - -fig2a, ax2a = vw.subplots(1, 1, figsize=(440, 220)) -p = ax2a.plot(np.sin(t256), ls="-", color="#4fc3f7", label='ls="-"') -p.add_line(np.sin(t256) + 0.8, ls="--", color="#ff7043", label='ls="--"') -p.add_line(np.sin(t256) + 1.6, ls=":", color="#aed581", label='ls=":"') -fig2a - -# %% -# Alpha (opacity) -# --------------- -# ``alpha`` controls line opacity on a 0–1 scale. Values below 1 let -# overlapping curves show through each other — useful for comparing signals -# that share the same amplitude range. - -fig2, ax2 = vw.subplots(1, 1, figsize=(580, 300)) - -plot2 = ax2.plot(np.sin(t256), color="#4fc3f7", alpha=0.4, linewidth=3, - label="sin α=0.4") -plot2.add_line(np.cos(t256), color="#ff7043", alpha=0.4, linewidth=3, - label="cos α=0.4") - -fig2 - -# %% -# Marker symbols -# -------------- -# Set ``marker`` to place a symbol at every data point. Use a **sparse** -# x-axis (few points) so the individual markers are legible. -# ``markersize`` is the radius (circles / diamonds) or half-side-length -# (squares, triangles) in canvas pixels. -# -# Supported symbols: -# -# * ``"o"`` — circle -# * ``"s"`` — square -# * ``"^"`` — triangle-up -# * ``"v"`` — triangle-down -# * ``"D"`` — diamond -# * ``"+"`` — plus (stroke-only) -# * ``"x"`` — cross (stroke-only) -# * ``"none"`` — no marker (default) - -SYMBOLS = [ - ("o", "#4fc3f7"), - ("s", "#ff7043"), - ("^", "#aed581"), - ("v", "#ce93d8"), - ("D", "#ffcc02"), - ("+", "#80cbc4"), - ("x", "#ef9a9a"), -] - -fig3, ax3 = vw.subplots(1, 1, figsize=(580, 380)) - -plot3 = ax3.plot( - np.sin(t24) + (0 - 3) * 0.9, - color=SYMBOLS[0][1], linewidth=1.5, - marker=SYMBOLS[0][0], markersize=5, - label=f'marker="{SYMBOLS[0][0]}"', -) -for i, (sym, col) in enumerate(SYMBOLS[1:], 1): - plot3.add_line( - np.sin(t24) + (i - 3) * 0.9, - color=col, linewidth=1.5, - marker=sym, markersize=5, - label=f'marker="{sym}"', - ) - -fig3 - -# %% -# Combined — linestyle + alpha + marker -# -------------------------------------- -# All three style parameters can be combined freely on the same line or on -# separate overlay lines. - -fig4, ax4 = vw.subplots(1, 1, figsize=(580, 300)) - -# Dense solid primary line -plot4 = ax4.plot(np.sin(t256), color="#4fc3f7", linewidth=2, - label="sin (solid)") - -# Sparse dashed overlay with circle markers and reduced opacity -plot4.add_line(np.cos(t24), color="#ff7043", linewidth=2, - linestyle="dashed", alpha=0.75, - marker="o", markersize=5, - label="cos (dashed, α=0.75, marker='o')") - -fig4 - -# %% -# Post-construction setters -# ------------------------- -# Every primary-line style property has a matching setter method. These -# mutate ``_state`` and push the change to the canvas immediately — no -# need to recreate the panel. - -fig5, ax5 = vw.subplots(1, 1, figsize=(440, 220)) -plot5 = ax5.plot(np.sin(t256), color="#4fc3f7", linewidth=1.5) - -# Change style via setters -plot5.set_color("#ff7043") -plot5.set_linewidth(2.5) -plot5.set_linestyle("dashdot") # equivalent: plot5.set_linestyle("-.") -plot5.set_alpha(0.8) -plot5.set_marker("o", markersize=5) - -fig5 - diff --git a/Examples/plot_pcolormesh.py b/Examples/plot_pcolormesh.py deleted file mode 100644 index 41147bdc..00000000 --- a/Examples/plot_pcolormesh.py +++ /dev/null @@ -1,65 +0,0 @@ -""" -pcolormesh — non-linear axes -============================ - -Demonstrate :meth:`~anyplotlib.figure_plots.Axes.pcolormesh` with non-uniform -(log-spaced) x-edges and irregularly-spaced y-edges, mirroring -``matplotlib.axes.Axes.pcolormesh``. - -The key difference from :meth:`~anyplotlib.figure_plots.Axes.imshow` is that -``pcolormesh`` takes **edge** arrays (length N+1 and M+1 for an (M, N) data -array) rather than center arrays. This enables fully non-linear axes where -each cell can have a different width/height in data coordinates. -""" -import numpy as np -import anyplotlib as vw - -rng = np.random.default_rng(42) - -# ── Data: 32 rows × 48 columns ─────────────────────────────────────────────── -M, N = 32, 48 -data = np.sin(np.linspace(0, 3 * np.pi, N)) + np.cos(np.linspace(0, 2 * np.pi, M))[:, None] -data += rng.normal(scale=0.15, size=(M, N)) - -# ── Non-uniform edges ───────────────────────────────────────────────────────── -# x: log-spaced between 0.1 and 100 (N+1 edges) -x_edges = np.logspace(-1, 2, N + 1) - -# y: irregular spacing — dense in the middle, coarse at the ends (M+1 edges) -y_centres = np.concatenate([ - np.linspace(0, 40, M // 4, endpoint=False), - np.linspace(40, 60, M // 2, endpoint=False), - np.linspace(60, 100, M // 4), -]) -y_edges = np.concatenate([[y_centres[0] - (y_centres[1] - y_centres[0]) / 2], - (y_centres[:-1] + y_centres[1:]) / 2, - [y_centres[-1] + (y_centres[-1] - y_centres[-2]) / 2]]) - -# ── Plot ────────────────────────────────────────────────────────────────────── -fig, ax = vw.subplots(1, 1, figsize=(560, 460)) -mesh = ax.pcolormesh(data, x_edges=x_edges, y_edges=y_edges, units="arb.") -mesh.set_colormap("viridis") -fig - -# %% -# Add point markers in physical coordinates -# ----------------------------------------- -# Marker coordinates are in the same physical (data) space as the edges. -# Only ``add_circles`` and ``add_lines`` are available on a pcolormesh panel. - -pts = np.array([[1.0, 20.0], [10.0, 50.0], [50.0, 80.0], [90.0, 45.0]]) -mesh.add_circles(pts, name="peaks", radius=3, - edgecolors="#ff1744", facecolors="#ff174433", - labels=["A", "B", "C", "D"]) -fig - -# %% -# Add line-segment markers -# ------------------------ -segs = [ - [[1.0, 20.0], [10.0, 50.0]], - [[10.0, 50.0], [50.0, 80.0]], -] -mesh.add_lines(segs, name="path", edgecolors="#00e5ff", linewidths=2.0) -fig - diff --git a/Examples/plot_spectra1d.py b/Examples/plot_spectra1d.py deleted file mode 100644 index 320ad22e..00000000 --- a/Examples/plot_spectra1d.py +++ /dev/null @@ -1,112 +0,0 @@ -""" -1D Spectra -========== - -Plot a 1-D spectrum with a physical x-axis (energy in eV) using -:meth:`~anyplotlib.figure_plots.Axes.plot`. - -The spectrum contains a broad background and three Gaussian peaks. -Circle markers highlight the peak positions using -:meth:`~anyplotlib.figure_plots.Plot1D.add_points`, and a range widget -selects a region of interest. A model fit is overlaid with a dashed line, -and the background component is shown as a semi-transparent dotted curve with -diamond markers. - -Pan and zoom with the mouse; press **R** to reset the view. -""" -import numpy as np -import anyplotlib as vw - -rng = np.random.default_rng(0) - -# ── Synthetic XPS-style spectrum ────────────────────────────────────────────── -energy = np.linspace(280, 295, 512) # binding energy axis (eV) - -def gaussian(x, mu, sigma, amp): - return amp * np.exp(-0.5 * ((x - mu) / sigma) ** 2) - -background = 0.4 * np.exp(-0.08 * (energy - 280)) - -# Background + three peaks (C 1s region) -spectrum = ( - background - + gaussian(energy, 284.8, 0.4, 1.0) # C–C / C–H - + gaussian(energy, 286.2, 0.4, 0.35) # C–O - + gaussian(energy, 288.0, 0.4, 0.18) # C=O - + rng.normal(scale=0.015, size=len(energy)) -) - -# ── Plot ────────────────────────────────────────────────────────────────────── -fig, ax = vw.subplots(1, 1, figsize=(620, 340)) -v = ax.plot(spectrum, axes=[energy], units="eV", y_units="Intensity (a.u.)", - color="#4fc3f7", linewidth=1.5) - -# ── Peak markers (add_points collection) ────────────────────────────────────── -peak_energies = np.array([284.8, 286.2, 288.0]) -peak_offsets = np.column_stack([ - peak_energies, - np.interp(peak_energies, energy, spectrum), -]) -v.add_points(peak_offsets, name="peaks", - sizes=7, color="#ff1744", facecolors="#ff174433", - labels=["C\u2013C", "C\u2013O", "C=O"]) - -# ── Region-of-interest widget ───────────────────────────────────────────────── -v.add_range_widget(x0=285.8, x1=288.8, color="#00e5ff") - -fig - -# %% -# Overlay a model fit — linestyle and alpha -# ----------------------------------------- -# Use :meth:`~anyplotlib.figure_plots.Plot1D.add_line` to overlay additional -# curves. Here the noiseless model fit is drawn as a **dashed** line so it -# is visually distinct from the noisy measured spectrum. The ``alpha`` -# parameter makes the fit semi-transparent so the data underneath remains -# readable. -# -# The y-axis range is expanded automatically to accommodate any overlay line -# whose values fall outside the current bounds. - -fit = ( - background - + gaussian(energy, 284.8, 0.4, 1.0) - + gaussian(energy, 286.2, 0.4, 0.35) - + gaussian(energy, 288.0, 0.4, 0.18) -) -v.add_line(fit, x_axis=energy, - color="#ffcc00", linewidth=2.0, - linestyle="dashed", alpha=0.85, - label="fit") - -fig - -# %% -# Background component — dotted line with markers -# ------------------------------------------------ -# Draw the exponential background component as a **dotted** curve. Passing -# ``marker="D"`` places a diamond at every data point (useful when the line -# is sparse or when you want to emphasise individual sample positions). -# ``markersize`` controls the half-size of the symbol in pixels. - -# Sub-sample to keep the marker plot readable -step = 32 -v.add_line(background[::step], x_axis=energy[::step], - color="#ce93d8", linewidth=1.2, - linestyle="dotted", alpha=0.9, - marker="D", markersize=3, - label="background") - -fig - -# %% -# Post-construction setters -# ------------------------- -# All primary-line style properties can be changed after the panel is created -# without rebuilding it. This is useful in interactive notebooks where you -# want to tweak the appearance of the main trace. - -v.set_alpha(0.9) # slightly reduce primary-line opacity -v.set_linewidth(2.0) # thicker stroke for the main spectrum - -fig diff --git a/anyplotlib/sphinx_anywidget/__init__.py b/anyplotlib/sphinx_anywidget/__init__.py index 7f3cd0c4..eea5ea5b 100644 --- a/anyplotlib/sphinx_anywidget/__init__.py +++ b/anyplotlib/sphinx_anywidget/__init__.py @@ -80,28 +80,54 @@ def _build_pyodide_wheel(app): pkg = getattr(app.config, "anywidget_pyodide_package", None) if not pkg: pkg = _infer_package_name(app) + + conf_dir = Path(app.confdir) + static_dir = conf_dir / "_static" + static_dir.mkdir(parents=True, exist_ok=True) + + import json as _json + if not pkg: print( "[sphinx_anywidget] WARNING: anywidget_pyodide_package not set; " "Pyodide interactive mode disabled." ) + # Write a config that explicitly disables interactive mode so the + # bridge's heuristic package detection never runs. + config_js = ( + "window._anywidgetPackage = null;\n" + "window._anywidgetInteractiveDisabled = true;\n" + ) + (static_dir / "anywidget_config.js").write_text(config_js, encoding="utf-8") return - conf_dir = Path(app.confdir) - static_dir = conf_dir / "_static" - static_dir.mkdir(parents=True, exist_ok=True) - # Write a tiny config script so anywidget_bridge.js can find the package # name without fragile heuristics. Loaded before anywidget_bridge.js. - import json as _json config_js = f"window._anywidgetPackage = {_json.dumps(pkg)};\n" (static_dir / "anywidget_config.js").write_text(config_js, encoding="utf-8") from anyplotlib.sphinx_anywidget._wheel_builder import build_wheel - project_root = conf_dir.parent + project_root = _find_project_root(conf_dir) build_wheel(static_dir, pkg, project_root) +def _find_project_root(start: Path) -> Path: + """Walk up from *start* to find the directory containing pyproject.toml or setup.py. + + Falls back to ``start.parent`` for backwards compatibility so behaviour is + unchanged for the common ``docs/conf.py`` layout where the project root is + directly above the docs directory. + """ + markers = ("pyproject.toml", "setup.py", "setup.cfg") + current = start.resolve() + # Check start itself and each parent up to the filesystem root. + for directory in [current, *current.parents]: + if any((directory / m).exists() for m in markers): + return directory + # No marker found — fall back to conf_dir's parent (original behaviour). + return start.parent + + def _infer_package_name(app) -> str | None: """Infer package name from pyproject.toml near conf.py.""" try: diff --git a/anyplotlib/sphinx_anywidget/_directive.py b/anyplotlib/sphinx_anywidget/_directive.py index 2be636ce..e8a5ed4c 100644 --- a/anyplotlib/sphinx_anywidget/_directive.py +++ b/anyplotlib/sphinx_anywidget/_directive.py @@ -28,12 +28,17 @@ from __future__ import annotations +import hashlib import json as _json +import re import runpy import tempfile from html import escape as _html_escape from pathlib import Path +# Reuse the _PYODIDE_PACKAGES_RE parser from the scraper. +from anyplotlib.sphinx_anywidget._scraper import _PYODIDE_PACKAGES_RE + from docutils import nodes from docutils.parsers.rst import Directive, directives @@ -68,7 +73,7 @@ def run(self): # ── options ────────────────────────────────────────────────────── is_interactive = "interactive" in self.options - max_width = self.options.get("width", 684) + max_width = self.options.get("width", None) # None → use _iframe_html default # ── execute the script to get the widget ───────────────────────── try: @@ -95,11 +100,15 @@ def run(self): ) from anyplotlib.sphinx_anywidget._scraper import _iframe_html - # Use a stable ID based on the source file name - stem = src_path.stem - fig_id = f"rst_{stem}" + # Use a stable ID derived from the *full* resolved path so two files + # with the same basename don't overwrite each other or share a fig_id. + path_hash = hashlib.md5(str(src_path).encode()).hexdigest()[:12] + fig_id = f"rst_{path_hash}" - docs_static = Path(env.app.outdir).parent / "_static" / "viewer_widgets" + # Write the standalone HTML directly into the Sphinx output _static dir + # so the relative URL we embed in the RST resolves correctly. + out_dir = Path(env.app.outdir) + docs_static = out_dir / "_static" / "viewer_widgets" docs_static.mkdir(parents=True, exist_ok=True) html_name = f"{fig_id}.html" html_path = docs_static / html_name @@ -112,19 +121,21 @@ def run(self): # Compute relative path from the current RST file's output dir # to _static/viewer_widgets/ try: - out_dir = Path(env.app.outdir) doc_name = env.docname # e.g. "getting_started" - page_out = out_dir / (doc_name + ".html") rel_depth = len(Path(doc_name).parts) # depth from out root prefix = "../" * rel_depth except Exception: prefix = "" src_url = f"{prefix}_static/viewer_widgets/{html_name}" + iframe_kw = {} + if max_width is not None: + iframe_kw["max_width"] = max_width iframe_block = _iframe_html( src_url, w, h, fig_id=fig_id, interactive=is_interactive, + **iframe_kw, ) raw_html = "\n" + iframe_block + "\n" @@ -138,11 +149,29 @@ def run(self): if python_src: data_src = _html_escape(_json.dumps(python_src), quote=True) + + # Detect _PYODIDE_PACKAGES = [...] in the source. + _pkg_attr = "" + m = _PYODIDE_PACKAGES_RE.search(python_src) + if m: + try: + import ast as _ast + pkgs = _ast.literal_eval(m.group(1)) + if pkgs: + _pkg_attr = ( + f' data-pyodide-packages=' + f'"{_html_escape(_json.dumps(pkgs), quote=True)}"' + ) + except Exception: + pass + + stem = src_path.stem script_tag = ( f'' ) raw_html += "\n" + script_tag + "\n" diff --git a/anyplotlib/sphinx_anywidget/_scraper.py b/anyplotlib/sphinx_anywidget/_scraper.py index 71dbf21f..c17fa7e3 100644 --- a/anyplotlib/sphinx_anywidget/_scraper.py +++ b/anyplotlib/sphinx_anywidget/_scraper.py @@ -133,6 +133,7 @@ def _iframe_html( h: int, fig_id: str | None = None, interactive: bool = False, + max_width: int | None = None, ) -> str: """Return a single-line HTML snippet embedding *src* responsively. @@ -146,10 +147,13 @@ def _iframe_html( Stable identifier; used as the ``data-awi-fig`` attribute. interactive : bool When True, renders the ⚡ activation badge. + max_width : int or None + Override the default ``MAX_DOC_WIDTH`` cap (pixels). """ uid = fig_id or f"f{uuid4().hex[:8]}" + cap = max_width if max_width is not None else MAX_DOC_WIDTH - init_scale = min(1.0, MAX_DOC_WIDTH / w) + init_scale = min(1.0, cap / w) init_w = round(w * init_scale) init_h = round(h * init_scale) scale_css = f"{init_scale:.6f}".rstrip("0").rstrip(".") diff --git a/anyplotlib/sphinx_anywidget/_wheel_builder.py b/anyplotlib/sphinx_anywidget/_wheel_builder.py index f3847a21..bc22bce9 100644 --- a/anyplotlib/sphinx_anywidget/_wheel_builder.py +++ b/anyplotlib/sphinx_anywidget/_wheel_builder.py @@ -8,6 +8,7 @@ from __future__ import annotations +import re import subprocess import sys from pathlib import Path @@ -40,7 +41,10 @@ def build_wheel( wheels_dir = static_dir / "wheels" wheels_dir.mkdir(parents=True, exist_ok=True) - for old in wheels_dir.glob(f"{package_name}*.whl"): + # PEP 427 normalises distribution names: hyphens and dots → underscores. + normalised = re.sub(r"[-.]", "_", package_name) + + for old in wheels_dir.glob(f"{normalised}*.whl"): old.unlink(missing_ok=True) result = subprocess.run( @@ -61,12 +65,12 @@ def build_wheel( ) return None - wheels = sorted(wheels_dir.glob(f"{package_name}*.whl")) + wheels = sorted(wheels_dir.glob(f"{normalised}*.whl")) if not wheels: print(f"\n[sphinx_anywidget] WARNING: no wheel found for {package_name!r}") return None - stable = wheels_dir / f"{package_name}-0.0.0-py3-none-any.whl" + stable = wheels_dir / f"{normalised}-0.0.0-py3-none-any.whl" stable.unlink(missing_ok=True) wheels[-1].rename(stable) print(f"[sphinx_anywidget] wheel → {stable}") diff --git a/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js b/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js index 51305fea..4ebfb6ea 100644 --- a/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js +++ b/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js @@ -62,10 +62,20 @@ return document.querySelector('button.awi-activate-btn') !== null; } - /** Deliver a state-update message into the iframe for figId. */ - function _postToIframe(figId, key, value) { + /** Deliver a state-update message into the iframe for figId. + * + * rawValue is always a JSON-encoded string (Python serialises every trait + * with json.dumps so numeric/boolean/object traits are not type-erased when + * the iframe receives them). + */ + function _postToIframe(figId, key, rawValue) { const iframe = document.querySelector(`iframe[data-awi-fig="${figId}"]`); if (iframe && iframe.contentWindow) { + // JSON.parse recovers the real JS type (number, bool, array, object …). + // Plain Python strings are also JSON-encoded (quoted), so they round-trip + // correctly too. + let value = rawValue; + try { value = JSON.parse(rawValue); } catch (_) {} iframe.contentWindow.postMessage({ type: 'awi_state', key, value }, '*'); } } @@ -225,7 +235,9 @@ def _patched_init(self, *args, **kw): return import json as _j val = change['new'] - val_str = val if isinstance(val, str) else _j.dumps(val, default=str) + # Always JSON-encode so the JS bridge can JSON.parse to recover the + # correct type — strings, numbers, bools and objects all round-trip. + val_str = _j.dumps(val, default=str) js.window._anywidgetPush(fid, tname, val_str) self.observe(_push_cb, names=_tr.All) @@ -317,7 +329,9 @@ for _i, _fid in enumerate(_fig_ids): _val = getattr(_w, _tname, None) if _val is None: continue - _vs = _val if isinstance(_val, str) else _jj.dumps(_val, default=str) + # Always JSON-encode (matching the live observer above) so the + # JS bridge can JSON.parse to recover the correct type. + _vs = _jj.dumps(_val, default=str) import js as _js _js.window._anywidgetPush(_fid, _tname, _vs) _wired += 1 diff --git a/docs/_sg_html_scraper.py b/docs/_sg_html_scraper.py index fc7dd64d..fe9c43ec 100644 --- a/docs/_sg_html_scraper.py +++ b/docs/_sg_html_scraper.py @@ -9,10 +9,13 @@ from _sg_html_scraper import ViewerScraper -continues to work without changes. +continues to work without changes. All public helpers that existed in the +original module are re-exported here so downstream imports keep working. """ from anyplotlib.sphinx_anywidget._scraper import ( # noqa: F401 AnywidgetScraper, AnywidgetScraper as ViewerScraper, + _make_thumbnail_png, + _iframe_html, ) From 469fb62070182218c1a9e96ebb83d2a20044f699 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 4 May 2026 13:09:30 +0000 Subject: [PATCH 078/198] test: move root-level test scripts into tests/ as proper pytest functions Co-authored-by: CSSFrancis <41125831+CSSFrancis@users.noreply.github.com> --- _test_scraper.py | 39 -------------- test_sphinx_anywidget.py | 43 ---------------- tests/test_scraper.py | 66 ++++++++++++++++++++++++ tests/test_sphinx_anywidget.py | 92 ++++++++++++++++++++++++++++++++++ 4 files changed, 158 insertions(+), 82 deletions(-) delete mode 100644 _test_scraper.py delete mode 100644 test_sphinx_anywidget.py create mode 100644 tests/test_scraper.py create mode 100644 tests/test_sphinx_anywidget.py diff --git a/_test_scraper.py b/_test_scraper.py deleted file mode 100644 index a22d4a8b..00000000 --- a/_test_scraper.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Quick end-to-end test for the Playwright-based scraper thumbnail.""" -import sys -sys.path.insert(0, 'docs') -sys.path.insert(0, 'tests') - -import numpy as np -import anyplotlib as apl -from _sg_html_scraper import _make_thumbnail_png -from _png_utils import decode_png - -tests = [] - -# 1D line plot -fig, ax = apl.subplots(1, 1, figsize=(400, 250)) -ax.plot(np.sin(np.linspace(0, 2 * np.pi, 128)), color='#4fc3f7') -tests.append(("1D line", fig)) - -# 2D image -fig2, ax2 = apl.subplots(1, 1, figsize=(320, 320)) -data = np.linspace(0, 1, 64 * 64, dtype=np.float32).reshape(64, 64) -ax2.imshow(data) -tests.append(("2D imshow", fig2)) - -# multi-panel -fig3, axes = apl.subplots(1, 2, figsize=(640, 300)) -axes[0].plot(np.cos(np.linspace(0, 2 * np.pi, 64))) -axes[1].imshow(np.random.default_rng(0).uniform(0, 1, (32, 32)).astype(np.float32)) -tests.append(("multi-panel", fig3)) - -for name, widget in tests: - png = _make_thumbnail_png(widget) - assert png[:4] == b'\x89PNG', f"[{name}] result is not a PNG!" - arr = decode_png(png) - r, g, b = arr[0, 0, 0], arr[0, 0, 1], arr[0, 0, 2] - dark_ok = (b > r) and (b > 30) - print(f"[{name}] shape={arr.shape} top-left RGB=({r},{g},{b}) {'DARK OK' if dark_ok else 'THEME CHECK NEEDED'}") - -print("\nAll tests passed.") - diff --git a/test_sphinx_anywidget.py b/test_sphinx_anywidget.py deleted file mode 100644 index 4e4e8697..00000000 --- a/test_sphinx_anywidget.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Quick smoke test for sphinx_anywidget extension.""" -from anyplotlib.sphinx_anywidget import AnywidgetScraper, ViewerScraper, setup -from anyplotlib.sphinx_anywidget._scraper import _find_widget, _iframe_html -from anyplotlib.sphinx_anywidget._repr_utils import build_standalone_html, _widget_px -from anyplotlib.sphinx_anywidget._wheel_builder import build_wheel -from anyplotlib.sphinx_anywidget._directive import AnywidgetFigureDirective -print('imports OK') - -import numpy as np -import anyplotlib as apl - -fig, ax = apl.subplots(1, 1, figsize=(400, 300)) -ax.plot(np.sin(np.linspace(0, 6.28, 64))) - -html = build_standalone_html(fig, resizable=False, fig_id='tf') -assert 'awi_state' in html, 'Missing awi_state listener' -assert '"tf"' in html, 'Missing fig_id in HTML' - -w, h = _widget_px(fig) -assert w == 416, f'Expected 416 got {w}' - -b = _iframe_html('t.html', 400, 300, fig_id='a', interactive=True) -assert 'awi-activate-btn' in b, 'Missing activate button' - -s = _iframe_html('t.html', 400, 300, fig_id='a', interactive=False) -assert 'awi-activate-btn' not in s, 'Should not have activate btn on static' - -import anyplotlib.figure as _af -assert not hasattr(_af, '_pyodide_push_hook'), '_pyodide_push_hook should be gone' - -# Test _find_widget -found = _find_widget({'fig': fig, 'x': 42}) -assert found is fig, 'Should find Figure' -assert _find_widget({'x': 42}) is None - -# Test # Interactive detection -from anyplotlib.sphinx_anywidget._scraper import _INTERACTIVE_RE -assert _INTERACTIVE_RE.search('fig # Interactive\n'), 'Should match' -assert _INTERACTIVE_RE.search('fig # interactive'), 'Should match lowercase' -assert not _INTERACTIVE_RE.search('fig # not a match'), 'Should not match' - -print('ALL SMOKE TESTS PASSED') - diff --git a/tests/test_scraper.py b/tests/test_scraper.py new file mode 100644 index 00000000..a296743d --- /dev/null +++ b/tests/test_scraper.py @@ -0,0 +1,66 @@ +""" +tests/test_scraper.py +===================== + +Pytest tests for the Playwright-based scraper thumbnail functionality. +""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.sphinx_anywidget._scraper import _make_thumbnail_png +from tests._png_utils import decode_png + + +# ── fixtures ────────────────────────────────────────────────────────────────── + +@pytest.fixture +def line_fig(): + fig, ax = apl.subplots(1, 1, figsize=(400, 250)) + ax.plot(np.sin(np.linspace(0, 2 * np.pi, 128)), color="#4fc3f7") + return fig + + +@pytest.fixture +def imshow_fig(): + fig, ax = apl.subplots(1, 1, figsize=(320, 320)) + data = np.linspace(0, 1, 64 * 64, dtype=np.float32).reshape(64, 64) + ax.imshow(data) + return fig + + +@pytest.fixture +def multi_panel_fig(): + fig, axes = apl.subplots(1, 2, figsize=(640, 300)) + axes[0].plot(np.cos(np.linspace(0, 2 * np.pi, 64))) + axes[1].imshow( + np.random.default_rng(0).uniform(0, 1, (32, 32)).astype(np.float32) + ) + return fig + + +# ── thumbnail PNG validation ────────────────────────────────────────────────── + +def _assert_thumbnail_is_png(widget, label: str): + png = _make_thumbnail_png(widget) + assert png[:4] == b"\x89PNG", f"[{label}] result is not a PNG" + arr = decode_png(png) + assert arr.ndim == 3, f"[{label}] expected H×W×C array, got shape {arr.shape}" + assert arr.shape[2] in (3, 4), f"[{label}] expected RGB/RGBA, got {arr.shape[2]} channels" + + +def test_thumbnail_1d_line(line_fig): + _assert_thumbnail_is_png(line_fig, "1D line") + + +def test_thumbnail_2d_imshow(imshow_fig): + _assert_thumbnail_is_png(imshow_fig, "2D imshow") + + +def test_thumbnail_multi_panel(multi_panel_fig): + _assert_thumbnail_is_png(multi_panel_fig, "multi-panel") diff --git a/tests/test_sphinx_anywidget.py b/tests/test_sphinx_anywidget.py new file mode 100644 index 00000000..67f59218 --- /dev/null +++ b/tests/test_sphinx_anywidget.py @@ -0,0 +1,92 @@ +""" +tests/test_sphinx_anywidget.py +================================ + +Smoke tests for the ``anyplotlib.sphinx_anywidget`` extension. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl +import anyplotlib.figure as _af +from anyplotlib.sphinx_anywidget import AnywidgetScraper, ViewerScraper, setup # noqa: F401 +from anyplotlib.sphinx_anywidget._directive import AnywidgetFigureDirective # noqa: F401 +from anyplotlib.sphinx_anywidget._repr_utils import build_standalone_html, _widget_px +from anyplotlib.sphinx_anywidget._scraper import ( + _INTERACTIVE_RE, + _find_widget, + _iframe_html, +) +from anyplotlib.sphinx_anywidget._wheel_builder import build_wheel # noqa: F401 + + +# ── fixtures ────────────────────────────────────────────────────────────────── + +@pytest.fixture +def simple_fig(): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.sin(np.linspace(0, 6.28, 64))) + return fig + + +# ── standalone HTML builder ─────────────────────────────────────────────────── + +def test_standalone_html_contains_awi_state(simple_fig): + html = build_standalone_html(simple_fig, resizable=False, fig_id="tf") + assert "awi_state" in html, "Missing awi_state listener" + + +def test_standalone_html_contains_fig_id(simple_fig): + html = build_standalone_html(simple_fig, resizable=False, fig_id="tf") + assert '"tf"' in html, "Missing fig_id in HTML" + + +def test_widget_px(simple_fig): + w, h = _widget_px(simple_fig) + assert w == 416, f"Expected 416 got {w}" + + +# ── iframe HTML helper ──────────────────────────────────────────────────────── + +def test_iframe_html_interactive_has_activate_btn(): + b = _iframe_html("t.html", 400, 300, fig_id="a", interactive=True) + assert "awi-activate-btn" in b, "Missing activate button" + + +def test_iframe_html_static_no_activate_btn(): + s = _iframe_html("t.html", 400, 300, fig_id="a", interactive=False) + assert "awi-activate-btn" not in s, "Should not have activate btn on static" + + +# ── no stale push hook ──────────────────────────────────────────────────────── + +def test_no_pyodide_push_hook(): + assert not hasattr(_af, "_pyodide_push_hook"), "_pyodide_push_hook should be gone" + + +# ── _find_widget ────────────────────────────────────────────────────────────── + +def test_find_widget_finds_figure(simple_fig): + found = _find_widget({"fig": simple_fig, "x": 42}) + assert found is simple_fig, "Should find Figure" + + +def test_find_widget_returns_none_for_non_widget(): + assert _find_widget({"x": 42}) is None + + +# ── # Interactive detection ─────────────────────────────────────────────────── + +def test_interactive_re_matches_inline_comment(): + assert _INTERACTIVE_RE.search("fig # Interactive\n"), "Should match" + + +def test_interactive_re_matches_lowercase(): + assert _INTERACTIVE_RE.search("fig # interactive"), "Should match lowercase" + + +def test_interactive_re_no_false_positives(): + assert not _INTERACTIVE_RE.search("fig # not a match"), "Should not match" From d666ef66f47b4e74109c51eae778fa9cfbf3e7bd Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 4 May 2026 08:15:28 -0500 Subject: [PATCH 079/198] Documentation: Added Changelog entry. --- upcoming_changes/9.new_feature.rst | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 upcoming_changes/9.new_feature.rst diff --git a/upcoming_changes/9.new_feature.rst b/upcoming_changes/9.new_feature.rst new file mode 100644 index 00000000..42e401b7 --- /dev/null +++ b/upcoming_changes/9.new_feature.rst @@ -0,0 +1,7 @@ +* Added ``anyplotlib.sphinx_anywidget`` Sphinx extension for interactive, + Pyodide-powered figures in documentation (``.. anywidget-figure::`` directive, + automatic wheel building, Sphinx Gallery integration). +* Improved widget–parent page postMessage communication bridge. +* Made colormap LUT construction more robust against unknown colormap names. +* Subplot panels now use deterministic IDs. +* Added end-to-end test for the Playwright thumbnail scraper. From 60e7b4b6b75efe9fbf840f43ca6e9b3ac6cffb9e Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 4 May 2026 08:39:55 -0500 Subject: [PATCH 080/198] Testing: add docutils and sphinx as dependencies for documentation support --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index e7c21a07..4c8c3cce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,10 +36,12 @@ jupyter = [ [dependency-groups] dev = [ + "docutils>=0.19", "playwright>=1.58.0", "pytest>=9.0.2", "pytest-cov>=5.0.0", "scipy>=1.15.3", + "sphinx>=8.0", "towncrier>=24.0.0", ] From 68e887dbfaa6921fdfde4a8e57d5150b08b05679 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Tue, 5 May 2026 10:55:13 -0500 Subject: [PATCH 081/198] feat: enhance interactive 3D spectral viewer with draggable crosshair and energy-span filter --- .../Interactive/plot_3d_spectral_viewer.py | 225 ++++++++++++ anyplotlib/sphinx_anywidget/_directive.py | 48 ++- anyplotlib/sphinx_anywidget/_scraper.py | 31 +- docs/dev/index.rst | 332 ++++++++++++++++++ docs/index.rst | 37 ++ 5 files changed, 664 insertions(+), 9 deletions(-) create mode 100644 Examples/Interactive/plot_3d_spectral_viewer.py create mode 100644 docs/dev/index.rst diff --git a/Examples/Interactive/plot_3d_spectral_viewer.py b/Examples/Interactive/plot_3d_spectral_viewer.py new file mode 100644 index 00000000..82a17e09 --- /dev/null +++ b/Examples/Interactive/plot_3d_spectral_viewer.py @@ -0,0 +1,225 @@ +""" +Interactive 3D Spectral Viewer +============================== + +A side-by-side viewer for a 3-D ``(y, x, energy)`` dataset. + +* **Left panel** — 2-D projection image (sum over the energy axis). + A draggable crosshair ROI selects the pixel whose spectrum appears on + the right. Press **i** to switch to an 8 × 8-pixel rectangle ROI + that integrates the enclosed area; press **i** again to revert. +* **Right panel** — 1-D spectrum extracted at the current ROI. Press + **s** to overlay an energy-span widget; on release the 2-D image + recomputes as the sum over the selected energy window. Press **s** + again to remove the span and restore the full-sum image. + +**Key bindings** + +.. list-table:: + :header-rows: 1 + :widths: 10 10 80 + + * - Panel + - Key + - Action + * - Image + - ``i`` + - Toggle crosshair / 8x8-px rectangle ROI. + Rectangle snaps to the pixel grid and integrates the spectrum live. + Press again to revert. + * - Spectrum + - ``s`` + - Add/remove an energy-span filter. + The 2-D image updates on release to show the sum over the selected + energy window. Press again to restore the full-sum image. + * - Both + - ``r`` + - Reset zoom / pan. +""" + +import numpy as np +import anyplotlib as vw + +# ── Synthetic (NY, NX, NE) dataset ───────────────────────────────────────── +rng = np.random.default_rng(7) + +NY, NX, NE = 64, 64, 256 +energy = np.linspace(100, 900, NE) # physical energy axis (eV) + +yy, xx = np.mgrid[0:NY, 0:NX] # spatial index grids + + +def _gauss2d(cx, cy, sigma): + return np.exp(-((xx - cx) ** 2 + (yy - cy) ** 2) / (2 * sigma ** 2)) + + +def _gauss1d(e, mu, sigma): + return np.exp(-0.5 * ((e - mu) / sigma) ** 2) + + +# Three Gaussian peaks with spatially-varying amplitudes +_peaks = [ + dict(e_mu=280.0, e_sig=18.0, cx=18, cy=18, sig2d=14), + dict(e_mu=500.0, e_sig=22.0, cx=46, cy=20, sig2d=13), + dict(e_mu=710.0, e_sig=28.0, cx=32, cy=48, sig2d=16), +] + +data = np.zeros((NY, NX, NE), dtype=np.float32) +for _p in _peaks: + _amp = _gauss2d(_p["cx"], _p["cy"], _p["sig2d"]) # (NY, NX) + _sp = _gauss1d(energy, _p["e_mu"], _p["e_sig"]) # (NE,) + data += (_amp[:, :, np.newaxis] * _sp[np.newaxis, np.newaxis, :]).astype(np.float32) + +data += rng.normal(scale=0.02, size=data.shape).astype(np.float32) + +img_full = data.sum(axis=-1).astype(float) # full-energy projection (NY, NX) + +# Initial ROI centre +CX0, CY0 = NX // 2, NY // 2 + +# ── Figure layout ─────────────────────────────────────────────────────────── +fig, (ax_img, ax_spec) = vw.subplots( + 1, 2, + figsize=(950, 460), + help=( + "Image — drag crosshair to pick a spectrum\n" + " — press i: toggle crosshair / 8×8 rectangle ROI\n" + "Spectrum — press s: add/remove energy-span filter" + ), +) + +# ── Left: 2-D projection image ────────────────────────────────────────────── +v_img = ax_img.imshow(img_full) +v_img.set_colormap("viridis") + +# ── Right: 1-D spectrum at initial position ───────────────────────────────── +v_spec = ax_spec.plot( + data[CY0, CX0, :].astype(float), + axes=[energy], + units="eV", + y_units="Intensity (a.u.)", + color="#4fc3f7", + linewidth=1.5, +) + +# ── Shared state (lists so closures can mutate them) ──────────────────────── +wid = [None] # active 2-D ROI widget +mode = ["crosshair"] # "crosshair" or "rectangle" +span_wid = [None] # active energy-span widget (or None) +_syncing = [False] # echo-loop guard for rectangle snap + +ROI_PX = 8 # rectangle ROI fixed size (pixels) + + +# ── Helpers ───────────────────────────────────────────────────────────────── + +def _snap_rect(x_raw, y_raw): + """Snap top-left corner to the nearest integer pixel, clamped to bounds.""" + x0 = int(np.clip(round(float(x_raw)), 0, NX - ROI_PX)) + y0 = int(np.clip(round(float(y_raw)), 0, NY - ROI_PX)) + return x0, y0 + + +def _wire_crosshair(w): + """Register on_changed: update spectrum on every drag frame.""" + @w.on_changed + def _ch_moved(event): + cx = int(np.clip(round(event.data.get("cx", CX0)), 0, NX - 1)) + cy = int(np.clip(round(event.data.get("cy", CY0)), 0, NY - 1)) + v_spec.set_data(data[cy, cx, :].astype(float), x_axis=energy) + + +def _wire_rectangle(w): + """Register on_changed: snap widget to grid, integrate 8×8 region live.""" + @w.on_changed + def _rect_moved(event): + if _syncing[0]: + return + _syncing[0] = True + try: + x0, y0 = _snap_rect( + event.data.get("x", CX0 - ROI_PX // 2), + event.data.get("y", CY0 - ROI_PX // 2), + ) + # Push snapped, fixed-size position back so the widget visually + # snaps to the pixel grid and stays exactly 8×8. + w.set(x=float(x0), y=float(y0), w=float(ROI_PX), h=float(ROI_PX)) + spec = data[y0:y0 + ROI_PX, x0:x0 + ROI_PX, :].mean(axis=(0, 1)) + v_spec.set_data(spec.astype(float), x_axis=energy) + finally: + _syncing[0] = False + + +# ── Install initial crosshair ──────────────────────────────────────────────── +wid[0] = v_img.add_widget( + "crosshair", + cx=float(CX0), cy=float(CY0), + color="#69f0ae", +) +_wire_crosshair(wid[0]) + + +# ── "i" — toggle crosshair ↔ 8×8 rectangle ───────────────────────────────── +@v_img.on_key('i') +def _toggle_roi(event): + cur = wid[0] + v_img.remove_widget(cur) # remove old widget (Python ref still valid) + + if mode[0] == "crosshair": + # Preserve crosshair centre as rectangle anchor + cx_cur = float(cur.get("cx", CX0)) + cy_cur = float(cur.get("cy", CY0)) + x0, y0 = _snap_rect(cx_cur - ROI_PX / 2, cy_cur - ROI_PX / 2) + new_w = v_img.add_widget( + "rectangle", + x=float(x0), y=float(y0), + w=float(ROI_PX), h=float(ROI_PX), + color="#ffeb3b", + ) + _wire_rectangle(new_w) + wid[0] = new_w + mode[0] = "rectangle" + else: + # Restore crosshair at centre of old rectangle + rx = float(cur.get("x", CX0 - ROI_PX // 2)) + ry = float(cur.get("y", CY0 - ROI_PX // 2)) + cx_cur = rx + ROI_PX / 2 + cy_cur = ry + ROI_PX / 2 + new_w = v_img.add_widget( + "crosshair", + cx=float(np.clip(cx_cur, 0, NX - 1)), + cy=float(np.clip(cy_cur, 0, NY - 1)), + color="#69f0ae", + ) + _wire_crosshair(new_w) + wid[0] = new_w + mode[0] = "crosshair" + + +# ── "s" (spectrum panel) — add / remove energy-span filter ────────────────── +@v_spec.on_key('s') +def _toggle_span(event): + if span_wid[0] is None: + # Place span at 35 %–65 % of the energy range by default + e0 = float(energy[int(NE * 0.35)]) + e1 = float(energy[int(NE * 0.65)]) + sw = v_spec.add_range_widget(x0=e0, x1=e1, color="#ff7043") + span_wid[0] = sw + + @sw.on_release + def _span_released(ev): + x0_e = ev.data.get("x0", float(energy[0])) + x1_e = ev.data.get("x1", float(energy[-1])) + if x0_e > x1_e: + x0_e, x1_e = x1_e, x0_e + mask = (energy >= x0_e) & (energy <= x1_e) + new_img = data[..., mask].sum(axis=-1).astype(float) if mask.any() else img_full + v_img.set_data(new_img) + else: + v_spec.remove_widget(span_wid[0]) + span_wid[0] = None + v_img.set_data(img_full) # restore full-energy projection + + +fig # Interactive + diff --git a/anyplotlib/sphinx_anywidget/_directive.py b/anyplotlib/sphinx_anywidget/_directive.py index e8a5ed4c..b0c34c4c 100644 --- a/anyplotlib/sphinx_anywidget/_directive.py +++ b/anyplotlib/sphinx_anywidget/_directive.py @@ -51,7 +51,8 @@ class AnywidgetFigureDirective(Directive): has_content = False option_spec = { "interactive": directives.flag, - "width": directives.nonnegative_int, + "width": directives.unchanged, # e.g. "684", "100%", "80%" + "height": directives.unchanged, # e.g. "400px", "400" } def run(self): @@ -60,7 +61,13 @@ def run(self): # ── resolve the source file path ───────────────────────────────── src_arg = self.arguments[0] - conf_dir = Path(env.confdir) + # env.confdir was removed in Sphinx 9; fall back to env.app.confdir + # then env.srcdir so the directive works on all supported versions. + conf_dir = Path( + getattr(env, "confdir", None) + or getattr(env.app, "confdir", None) + or env.srcdir + ) src_path = (conf_dir / src_arg).resolve() if not src_path.exists(): @@ -73,7 +80,30 @@ def run(self): # ── options ────────────────────────────────────────────────────── is_interactive = "interactive" in self.options - max_width = self.options.get("width", None) # None → use _iframe_html default + + # :width: accepts "684" (pixels) or "100%" / "80%" (percentage). + # A percentage means "use the full container width" — we pass + # max_width=None so _iframe_html uses its default MAX_DOC_WIDTH cap + # and the CSS wrapper fills the container via its inline-block rule. + max_width = None + raw_width = self.options.get("width", None) + if raw_width is not None: + raw_width = str(raw_width).strip() + if not raw_width.endswith("%"): + try: + max_width = int(raw_width.replace("px", "").strip()) + except (ValueError, TypeError): + max_width = None + # else: percentage → leave max_width=None (full-width default) + + # :height: accepts "400px" or "400". + max_height = None + raw_height = self.options.get("height", None) + if raw_height is not None: + try: + max_height = int(str(raw_height).lower().replace("px", "").strip()) + except (ValueError, TypeError): + max_height = None # ── execute the script to get the widget ───────────────────────── try: @@ -119,10 +149,14 @@ def run(self): w, h = _widget_px(widget) # Compute relative path from the current RST file's output dir - # to _static/viewer_widgets/ + # to _static/viewer_widgets/. + # doc_name is the docname without extension, e.g. "index" or "dev/index". + # The output HTML sits at {out_dir}/{doc_name}.html, so the number of + # "../" hops needed to reach {out_dir}/_static/ equals the depth of the + # *directory* part of the docname (not the filename itself). try: - doc_name = env.docname # e.g. "getting_started" - rel_depth = len(Path(doc_name).parts) # depth from out root + doc_name = env.docname # e.g. "index" or "dev/index" + rel_depth = len(Path(doc_name).parent.parts) # parent dirs only prefix = "../" * rel_depth except Exception: prefix = "" @@ -131,6 +165,8 @@ def run(self): iframe_kw = {} if max_width is not None: iframe_kw["max_width"] = max_width + if max_height is not None: + iframe_kw["max_height"] = max_height iframe_block = _iframe_html( src_url, w, h, fig_id=fig_id, diff --git a/anyplotlib/sphinx_anywidget/_scraper.py b/anyplotlib/sphinx_anywidget/_scraper.py index c17fa7e3..ce75db6d 100644 --- a/anyplotlib/sphinx_anywidget/_scraper.py +++ b/anyplotlib/sphinx_anywidget/_scraper.py @@ -85,7 +85,6 @@ def _find_widget(globals_dict: dict): def _make_thumbnail_png(widget) -> bytes: """Render *widget* in headless Chromium and return a dark-theme PNG screenshot.""" - from playwright.sync_api import sync_playwright from anyplotlib.sphinx_anywidget._repr_utils import build_standalone_html html = build_standalone_html(widget, resizable=False) @@ -101,7 +100,9 @@ def _make_thumbnail_png(widget) -> bytes: fh.write(html) tmp_path = Path(fh.name) - try: + def _run_playwright(tmp_path: Path) -> bytes: + from playwright.sync_api import sync_playwright + with sync_playwright() as pw: browser = pw.chromium.launch( headless=True, args=["--no-sandbox", "--disable-setuid-sandbox"] @@ -117,10 +118,28 @@ def _make_thumbnail_png(widget) -> bytes: "() => new Promise(r =>" " requestAnimationFrame(() => requestAnimationFrame(r)))" ) - png_bytes = page.locator("#widget-root").screenshot() + return page.locator("#widget-root").screenshot() finally: page.close() browser.close() + + try: + import asyncio + import concurrent.futures + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + if loop is not None and loop.is_running(): + # Playwright sync API cannot be used inside a running asyncio loop. + # Run it in a separate thread where there is no event loop. + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit(_run_playwright, tmp_path) + png_bytes = future.result() + else: + png_bytes = _run_playwright(tmp_path) finally: tmp_path.unlink(missing_ok=True) @@ -134,6 +153,7 @@ def _iframe_html( fig_id: str | None = None, interactive: bool = False, max_width: int | None = None, + max_height: int | None = None, ) -> str: """Return a single-line HTML snippet embedding *src* responsively. @@ -149,11 +169,16 @@ def _iframe_html( When True, renders the ⚡ activation badge. max_width : int or None Override the default ``MAX_DOC_WIDTH`` cap (pixels). + max_height : int or None + Maximum display height in pixels. When provided, the scale factor is + also constrained so the rendered iframe never exceeds this height. """ uid = fig_id or f"f{uuid4().hex[:8]}" cap = max_width if max_width is not None else MAX_DOC_WIDTH init_scale = min(1.0, cap / w) + if max_height is not None and h > 0: + init_scale = min(init_scale, max_height / h) init_w = round(w * init_scale) init_h = round(h * init_scale) scale_css = f"{init_scale:.6f}".rstrip("0").rstrip(".") diff --git a/docs/dev/index.rst b/docs/dev/index.rst new file mode 100644 index 00000000..ebfb987c --- /dev/null +++ b/docs/dev/index.rst @@ -0,0 +1,332 @@ +======================= +Developer Documentation +======================= + +This guide covers everything you need to contribute to anyplotlib — +from setting up your environment to writing documentation and interactive +gallery examples. + +.. contents:: On this page + :local: + :depth: 2 + +---- + +Environment Setup +================= + +anyplotlib uses `uv `_ for dependency +management. + +.. code-block:: bash + + # Clone and install all dev dependencies + git clone https://github.com/CSSFrancis/anyplotlib.git + cd anyplotlib + uv sync + + # Run the full test suite + uv run pytest tests/ + + # Quick smoke tests (no pytest overhead) + uv run python test_figure.py + uv run python test_pcolormesh.py + +The ``dev`` dependency group (declared in ``pyproject.toml``) pulls in +``pytest``, ``playwright``, ``sphinx``, ``docutils``, and other tools +needed for both tests and docs builds. + +---- + +Architecture Overview +===================== + +The library is split into a small number of focused modules. + +.. list-table:: + :header-rows: 1 + :widths: 25 75 + + * - File + - Purpose + * - ``figure.py`` + - ``Figure`` — the only ``anywidget.AnyWidget`` subclass. + Owns all traitlets and is the Python ↔ JS bridge. + * - ``figure_plots.py`` + - ``Plot2D``, ``Plot1D``, ``PlotMesh``, ``Plot3D``, ``Axes``, + ``GridSpec``, ``subplots()``. Plain Python classes — *no* traitlets. + * - ``figure_esm.js`` + - Pure-JS canvas renderer (≈ 4 000 lines). + * - ``markers.py`` + - Static visual overlays (circles, arrows, lines, etc.). + * - ``widgets.py`` + - Interactive draggable overlays (``RectangleWidget``, + ``CrosshairWidget``, etc.). + * - ``callbacks.py`` + - Multi-tier event system (``on_change`` / ``on_release``). + * - ``sphinx_anywidget/`` + - Sphinx extension for interactive docs via Pyodide. + +**Python → JS flow:** ``plot._push()`` → ``figure._push(panel_id)`` → +serialises ``_state`` to JSON → writes to the dynamic traitlet +``panel_{id}_json`` (``sync=True``) → JS observer re-renders. + +**JS → Python flow:** JS writes back to ``panel_{id}_json`` after a drag → +Python observer calls ``Widget._update_from_js()`` and fires callbacks. + +---- + +Running & Writing Tests +======================= + +Tests live in ``tests/`` + +Run the full suite:: + + uv run pytest tests/ + +Run a specific module:: + + uv run pytest tests/test_sphinx_anywidget.py -v + +The Playwright end-to-end tests (``test_pyodide_e2e.py``) require the +Playwright browsers. Install them once with:: + + uv run playwright install chromium + +---- + +Writing Sphinx Documentation +============================= + +The docs are built with `Sphinx `_ using the +`pydata-sphinx-theme `_. + +.. code-block:: bash + + # Build HTML docs (outputs to build/html/) + make html + + # Wipe build artefacts and rebuild from scratch + make clean && make html + +The conf.py lives at ``docs/conf.py`` and already registers these +extensions: + +* ``sphinx.ext.autodoc`` / ``autosummary`` — API reference from docstrings. +* ``sphinx_gallery.gen_gallery`` — auto-generates the Examples gallery. +* ``anyplotlib.sphinx_anywidget`` — interactive Pyodide figures. +* ``sphinx_design`` — grid cards used on the index page. + +Adding a new RST page +--------------------- + +1. Create ``docs/my_page.rst``. +2. Add it to the ``toctree`` in ``docs/index.rst``:: + + .. toctree:: + :hidden: + :maxdepth: 2 + + my_page + +Embedding a static figure in RST +--------------------------------- + +Use the ``.. anywidget-figure::`` directive to embed an anyplotlib figure +directly from a Python script, without Sphinx Gallery:: + + .. anywidget-figure:: ../Examples/PlotTypes/plot_image2d.py + +The directive executes the script, captures the widget, renders it as a +self-contained iframe, and embeds it in the page. + +Embedding an interactive figure in RST +---------------------------------------- + +Add the ``:interactive:`` flag to enable the ⚡ Pyodide activation badge:: + + .. anywidget-figure:: ../Examples/PlotTypes/plot_image2d.py + :interactive: + +When a reader clicks the badge, Pyodide boots in the browser, installs the +anyplotlib wheel that was built at docs-build time, re-executes the script, +and re-wires all live callbacks — no server required. + +You can also control the display width:: + + .. anywidget-figure:: ../Examples/PlotTypes/plot_image2d.py + :interactive: + :width: 500 + +Declaring extra Pyodide packages +--------------------------------- + +If your example script needs additional pure-Python packages available in +Pyodide, declare them at the top of the file:: + + _PYODIDE_PACKAGES = ["scipy", "scikit-image"] + +The ``sphinx_anywidget`` extension (and the Sphinx Gallery scraper) detect +this list automatically and pass it to ``micropip`` before executing the +example. + +---- + +Writing Sphinx Gallery Examples +================================ + +All gallery examples live under ``Examples/`` and are picked up by +Sphinx Gallery. Sub-directories become gallery sections. + +.. code-block:: text + + Examples/ + README.rst ← gallery landing-page text + PlotTypes/ ← "Plot Types" section + README.rst + plot_image2d.py + plot_spectra1d.py + ... + Interactive/ + Markers/ + Widgets/ + Benchmarks/ + +Naming rules +------------ + +* Files **must** be named ``plot_*.py`` — Sphinx Gallery ignores anything + else (controlled by ``filename_pattern = r"/plot_"`` in ``conf.py``). +* Each sub-directory needs a ``README.rst`` for the section heading. + +Docstring structure +------------------- + +Every example file must start with a module-level docstring. Sphinx +Gallery uses the first heading as the gallery card title:: + + """ + My Example Title + ================ + + A short description shown in the gallery card. Can span multiple + paragraphs and use any RST. + """ + + import numpy as np + import anyplotlib as apl + + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.sin(np.linspace(0, 6, 200))) + fig + +Sectioning code with ``# %%`` +------------------------------ + +Split an example into multiple narrative sections using ``# %%`` comments. +Everything after ``# %%`` up to the next ``# %%`` (or end of file) is a +separate code block with its own prose cell:: + + # %% + # Adjusting the colour map + # ------------------------- + # :meth:`~anyplotlib.figure_plots.Plot2D.set_colormap` switches the palette. + + v.set_colormap("viridis") + fig + +Making a gallery figure interactive +------------------------------------ + +To enable the ⚡ Pyodide activation badge on a gallery figure, end the +code block that produces the widget with a ``# Interactive`` comment +(case-insensitive):: + + fig, ax = apl.subplots(1, 1, figsize=(640, 400)) + ax.imshow(data, cmap="inferno") + fig # Interactive + +The ``AnywidgetScraper`` (registered in ``conf.py`` as an +``image_scrapers`` entry) detects the comment and: + +1. Embeds the full example source in a ``", + ] + mock_js = "\n".join(mock_lines) + + parent_html = ( + "\n\n" + f"bridge test - {fig_id}\n" + f"{mock_js}\n" + "\n" + "\n" + "\n" + "
\n" + f"
\n" + f" \n" + f"
\n" + f" \n" + "
\n" + "
\n" + "
\n" + f"\n" + "" + ) + + parent_path = base_dir / f"{fig_id}_parent.html" + parent_path.write_text(parent_html, encoding="utf-8") + return parent_path + + +# ============================================================================= +# Tier 2 -- iframe postMessage tests (browser only, no HTTP server) +# ============================================================================= + +class TestIframeMessaging: + """Verify the awi_state postMessage protocol via the standalone iframe. + + The ``interact_page`` fixture opens the figure HTML as a top-level page + (``window.parent === window``), so outbound awi_event forwarding is + disabled. Tests focus on the *inbound* direction: awi_state updates the + model. + """ + + def _open_fig(self, interact_page): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.sin(np.linspace(0, 2 * np.pi, 64)), color="#4fc3f7") + panel_id = list(fig._plots_map.keys())[0] + plot = list(fig._plots_map.values())[0] + page = interact_page(fig) + return fig, plot, panel_id, page + + def test_awi_state_updates_model_key(self, interact_page): + """Posting {type:'awi_state', key, value} updates the model.""" + fig, plot, panel_id, page = self._open_fig(interact_page) + raw = page.evaluate(f"() => window._aplModel.get('panel_{panel_id}_json')") + assert raw is not None + curr = json.loads(raw) + curr["__sentinel__"] = "hello" + new_json = json.dumps(curr) + page.evaluate( + "() => window.postMessage(" + + json.dumps({"type": "awi_state", + "key": f"panel_{panel_id}_json", + "value": new_json}) + + ", '*')" + ) + _rafter(page) + updated = json.loads( + page.evaluate(f"() => window._aplModel.get('panel_{panel_id}_json')") + ) + assert updated.get("__sentinel__") == "hello" + + def test_no_echo_in_standalone_mode(self, interact_page): + """No awi_event is echoed back in standalone mode (FIG_ID is null).""" + fig, plot, panel_id, page = self._open_fig(interact_page) + raw = json.loads( + page.evaluate(f"() => window._aplModel.get('panel_{panel_id}_json')") + ) + raw["__flag__"] = 1 + new_json = json.dumps(raw) + page.evaluate( + "() => {" + " window._aplEventsSeen = 0;" + " window.addEventListener('message', (e) => {" + " if (e.data && e.data.type === 'awi_event') window._aplEventsSeen++;" + " });" + "}" + ) + page.evaluate( + "() => window.postMessage(" + + json.dumps({"type": "awi_state", + "key": f"panel_{panel_id}_json", + "value": new_json}) + + ", '*')" + ) + _rafter(page) + assert page.evaluate("() => window._aplEventsSeen") == 0 + + def test_awi_state_fires_change_listeners(self, interact_page): + """Posting awi_state triggers on('change:...') listeners.""" + fig, plot, panel_id, page = self._open_fig(interact_page) + page.evaluate( + f"() => {{" + f" window._aplChangeCount = 0;" + f" window._aplModel.on('change:panel_{panel_id}_json'," + f" () => window._aplChangeCount++);" + f"}}" + ) + raw = json.loads( + page.evaluate(f"() => window._aplModel.get('panel_{panel_id}_json')") + ) + raw["__change__"] = 1 + new_json = json.dumps(raw) + page.evaluate( + "() => window.postMessage(" + + json.dumps({"type": "awi_state", + "key": f"panel_{panel_id}_json", + "value": new_json}) + + ", '*')" + ) + _rafter(page) + assert page.evaluate("() => window._aplChangeCount") >= 1 + + def test_layout_json_push_updates_model(self, interact_page): + """layout_json can be updated via awi_state.""" + fig, plot, panel_id, page = self._open_fig(interact_page) + layout = json.loads( + page.evaluate("() => window._aplModel.get('layout_json') || '{}'") + ) + layout["__layout_sentinel__"] = "bridge_test" + new_json = json.dumps(layout) + page.evaluate( + "() => window.postMessage(" + + json.dumps({"type": "awi_state", "key": "layout_json", "value": new_json}) + + ", '*')" + ) + _rafter(page) + updated = json.loads( + page.evaluate("() => window._aplModel.get('layout_json') || '{}'") + ) + assert updated.get("__layout_sentinel__") == "bridge_test" + + +# ============================================================================= +# Tier 3 -- Full bridge mock-boot tests (HTTP server + mock Pyodide) +# ============================================================================= + +class TestFullBridgeBoot: + """Boot anywidget_bridge.js end-to-end via a mock loadPyodide. + + Each test builds a parent HTML page and serves it from the shared + ``http_server`` fixture. All Pyodide network I/O is replaced by the JS + mock so tests complete in milliseconds. + """ + + def _open(self, browser, base_url, parent_path, timeout=15_000): + url = f"{base_url}/{parent_path.name}" + page = browser.new_page() + page.goto(url, wait_until="domcontentloaded", timeout=timeout) + return page + + def _basic_fig(self): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.sin(np.linspace(0, 2 * np.pi, 64)), color="#50fa7b") + panel_id = list(fig._plots_map.keys())[0] + return fig, panel_id + + def test_button_appears_when_iframe_present(self, http_server, _pw_browser): + """The activate button is injected on any page with a data-awi-fig iframe.""" + base_url, base_dir = http_server + fig, _ = self._basic_fig() + parent = _build_parent_page(fig, "btn_test_001", base_dir=base_dir) + page = self._open(_pw_browser, base_url, parent) + page.wait_for_function( + "() => !!document.querySelector('button.awi-activate-btn')", + timeout=5_000, + ) + tooltip = page.evaluate( + "() => document.querySelector('button.awi-activate-btn').title" + ) + assert "interactive" in tooltip.lower() + page.close() + + def test_boot_completes_all_mock_steps(self, http_server, _pw_browser): + """Clicking the button runs through all expected mock Pyodide boot steps.""" + base_url, base_dir = http_server + fig, _ = self._basic_fig() + parent = _build_parent_page(fig, "boot_test_001", base_dir=base_dir) + page = self._open(_pw_browser, base_url, parent) + _click_and_wait_boot(page) + steps = page.evaluate("() => window._APL_BOOT_STEPS") + for step in ("loadPyodide", "micropip_install", "stub_anywidget", + "install_monkey_patch", "run_example"): + assert step in steps, f"Step {step!r} missing; got {steps}" + page.close() + + def test_anywidgetPush_is_function_after_boot(self, http_server, _pw_browser): + """window._anywidgetPush must be a function after the push-hook step.""" + base_url, base_dir = http_server + fig, _ = self._basic_fig() + parent = _build_parent_page(fig, "apush_test_001", base_dir=base_dir) + page = self._open(_pw_browser, base_url, parent) + _click_and_wait_boot(page) + assert page.evaluate( + "() => typeof window._anywidgetPush === 'function'" + ), "window._anywidgetPush not installed" + page.close() + + def test_state_pushed_into_iframe_model(self, http_server, _pw_browser): + """After boot the iframe's model contains the figure's panel JSON.""" + base_url, base_dir = http_server + fig, panel_id = self._basic_fig() + expected = fig._plots_map[panel_id].to_state_dict() + parent = _build_parent_page(fig, "state_push_001", base_dir=base_dir) + page = self._open(_pw_browser, base_url, parent) + _click_and_wait_boot(page) + _wait_for_iframe_model(page, "state_push_001", panel_id) + raw = page.evaluate( + "() => {" + " const el = document.querySelector('iframe[data-awi-fig=\"state_push_001\"]');" + f" return el && el.contentWindow ? el.contentWindow._aplModel.get('panel_{panel_id}_json') : null;" + "}" + ) + assert raw is not None, "panel JSON not delivered to iframe model" + assert json.loads(raw).get("kind") == expected.get("kind") + page.close() + + def test_layout_json_pushed_into_iframe(self, http_server, _pw_browser): + """layout_json is delivered to the iframe model.""" + base_url, base_dir = http_server + fig, _ = self._basic_fig() + parent = _build_parent_page(fig, "layout_push_001", base_dir=base_dir) + page = self._open(_pw_browser, base_url, parent) + _click_and_wait_boot(page) + page.wait_for_function( + "() => {" + " const el = document.querySelector('iframe[data-awi-fig=\"layout_push_001\"]');" + " if (!el || !el.contentWindow) return false;" + " const mdl = el.contentWindow._aplModel;" + " if (!mdl) return false;" + " const raw = mdl.get('layout_json');" + " return typeof raw === 'string' && raw.length > 10;" + "}", + timeout=8_000, + ) + raw = page.evaluate( + "() => {" + " const el = document.querySelector('iframe[data-awi-fig=\"layout_push_001\"]');" + " return el.contentWindow._aplModel.get('layout_json');" + "}" + ) + assert raw is not None + assert "panel_specs" in json.loads(raw) + page.close() + + def test_event_message_forwarded_to_parent(self, http_server, _pw_browser): + """awi_event messages from the iframe arrive at the parent window.""" + base_url, base_dir = http_server + fig, panel_id = self._basic_fig() + parent = _build_parent_page(fig, "event_fwd_001", base_dir=base_dir) + page = self._open(_pw_browser, base_url, parent) + _click_and_wait_boot(page) + page.evaluate( + "() => {" + " window._aplReceivedEvents = [];" + " window.addEventListener('message', (e) => {" + " if (e.data && e.data.type === 'awi_event')" + " window._aplReceivedEvents.push(e.data);" + " });" + "}" + ) + fake_event = json.dumps({ + "event_type": "on_release", "panel_id": panel_id, + "widget_id": "w_fake", "x": 42.0, + }) + page.evaluate( + "() => window.postMessage(" + + json.dumps({"type": "awi_event", + "figId": "event_fwd_001", + "data": fake_event}) + + ", '*')" + ) + _rafter(page) + events = page.evaluate("() => window._aplReceivedEvents") + assert len(events) >= 1, "No awi_event reached the parent message bus" + assert events[0]["figId"] == "event_fwd_001" + page.close() + + def test_multiple_panels_all_receive_state(self, http_server, _pw_browser): + """All panels in a multi-panel figure have their state pushed.""" + base_url, base_dir = http_server + fig, axes = apl.subplots(1, 2, figsize=(700, 300)) + axes[0].plot(np.zeros(32)) + axes[1].plot(np.ones(32) * 0.5) + panel_ids = list(fig._plots_map.keys()) + assert len(panel_ids) == 2 + parent = _build_parent_page(fig, "multi_panel_001", base_dir=base_dir) + page = self._open(_pw_browser, base_url, parent) + _click_and_wait_boot(page) + for pid in panel_ids: + _wait_for_iframe_model(page, "multi_panel_001", pid) + for pid in panel_ids: + raw = page.evaluate( + "() => {" + " const el = document.querySelector('iframe[data-awi-fig=\"multi_panel_001\"]');" + f" return el && el.contentWindow ? el.contentWindow._aplModel.get('panel_{pid}_json') : null;" + "}" + ) + assert raw is not None, f"Panel {pid!r} state not pushed" + page.close() + + def test_button_shows_error_on_boot_failure(self, http_server, _pw_browser): + """If Pyodide boot fails the button switches to the error state.""" + base_url, base_dir = http_server + fig, _ = self._basic_fig() + parent = _build_parent_page(fig, "error_test_001", base_dir=base_dir) + html = (base_dir / "error_test_001_parent.html").read_text(encoding="utf-8") + # Patch mock to throw immediately on loadPyodide + html = html.replace( + "window.loadPyodide = async function() {", + "window.loadPyodide = async function() { throw new Error('mock boot failure'); //", + ) + (base_dir / "error_test_001_parent.html").write_text(html, encoding="utf-8") + page = self._open(_pw_browser, base_url, parent) + page.wait_for_function( + "() => !!document.querySelector('button.awi-activate-btn')", + timeout=5_000, + ) + page.click("button.awi-activate-btn") + page.wait_for_function( + "() => {" + " const btn = document.querySelector('button.awi-activate-btn');" + " return btn && btn.dataset.state === 'error';" + "}", + timeout=10_000, + ) + label = page.evaluate( + "() => document.querySelector('button.awi-activate-btn').title" + ) + assert "mock boot failure" in label + page.close() diff --git a/anyplotlib/tests/test_documentation/test_push_hook.py b/anyplotlib/tests/test_documentation/test_push_hook.py new file mode 100644 index 00000000..900d6dad --- /dev/null +++ b/anyplotlib/tests/test_documentation/test_push_hook.py @@ -0,0 +1,158 @@ +""" +tests/test_documentation/test_push_hook.py +========================================== + +Unit tests for the Python→JS state-push pathway. + +These tests require **no browser** — they call ``_push()`` / ``_push_layout()`` +directly and inspect the resulting traitlet values. They cover the same +ground that older tests exercised via ``_pyodide_push_hook``; the hook is now +gone and state flows through standard ``sync=True`` traitlets instead. + +Related browser tests (iframe postMessage, full mock-boot) live in +``test_bridge.py``. +""" + +from __future__ import annotations + +import json + +import numpy as np +import pytest + +import anyplotlib as apl +import anyplotlib.figure as _af + + +# ───────────────────────────────────────────────────────────────────────────── +# Helper shared by multiple tests +# ───────────────────────────────────────────────────────────────────────────── + +def _capture_fig_state(fig) -> dict[str, str]: + """Return ``{trait_name: json_string}`` for layout + every panel trait. + + Reads traitlet values directly after calling the push methods. This + works even when the value hasn't changed (traitlets suppress duplicate + change events, so an observe-based approach would return nothing on a + second call with the same state). + """ + fig._push_layout() + for pid in list(fig._plots_map): + fig._push(pid) + + captured: dict[str, str] = {} + captured["layout_json"] = fig.layout_json + for tname in fig.trait_names(): + if tname.startswith("panel_") and tname.endswith("_json"): + captured[tname] = getattr(fig, tname) + return captured + + +# ───────────────────────────────────────────────────────────────────────────── +# Tests +# ───────────────────────────────────────────────────────────────────────────── + +class TestPushHook: + """Verify _push() / _push_layout() write to sync=True traitlets correctly.""" + + def test_push_does_not_crash(self): + """Normal mode: _push() succeeds without error.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.zeros(16)) # must not raise + + def test_layout_json_written_on_create(self): + """layout_json traitlet is set when a figure is created.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + parsed = json.loads(fig.layout_json) + assert "panel_specs" in parsed, ( + f"layout_json missing 'panel_specs': {list(parsed.keys())}" + ) + + def test_panel_json_written_after_plot(self): + """panel_*_json traitlet is set when a plot is added.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.sin(np.linspace(0, 2 * np.pi, 64))) + + panel_keys = [ + k for k in fig.trait_names() + if k.startswith("panel_") and k.endswith("_json") + ] + assert len(panel_keys) >= 1, "Expected at least one panel_*_json trait" + for k in panel_keys: + parsed = json.loads(getattr(fig, k)) + assert "kind" in parsed, ( + f"panel JSON missing 'kind': {list(parsed.keys())}" + ) + + def test_observe_fires_on_push(self): + """traitlets.observe() fires when _push() writes a panel trait.""" + seen: list[str] = [] + + def _watch(change): + seen.append(change["name"]) + + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + fig.observe(_watch) + ax.plot(np.zeros(8)) + fig.unobserve(_watch) + + assert any(k.startswith("panel_") for k in seen), ( + f"Expected a panel_* trait change; got: {seen}" + ) + + def test_panel_id_deterministic(self): + """Panel IDs derived from SubplotSpec must be identical across rebuilds.""" + ids: list[str] = [] + for _ in range(3): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.zeros(8)) + ids.append(list(fig._plots_map.keys())[0]) + assert ids[0] == ids[1] == ids[2], ( + f"Panel ID must be deterministic; got {ids}" + ) + + def test_panel_ids_unique_in_multiplot(self): + """Each panel in a multi-panel figure has a unique ID.""" + fig, axes = apl.subplots(1, 3, figsize=(900, 300)) + for ax in axes: + ax.plot(np.zeros(8)) + ids = list(fig._plots_map.keys()) + assert len(ids) == len(set(ids)), f"Panel IDs not unique: {ids}" + + def test_panel_id_matches_grid_position(self): + """Panel IDs encode the SubplotSpec row/col bounds.""" + fig, axes = apl.subplots(2, 2, figsize=(600, 400)) + for ax in np.asarray(axes).flat: + ax.plot(np.zeros(4)) + ids = set(fig._plots_map.keys()) + for pid in ids: + assert pid.startswith("p"), f"Unexpected panel ID format: {pid!r}" + + def test_dispatch_event_callable_without_kernel(self): + """_dispatch_event() can be called directly as the Pyodide bridge does.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.zeros(16)) + raw = json.dumps({ + "event_type": "on_zoom", + "panel_id": list(fig._plots_map.keys())[0], + "source": "js", + }) + fig._dispatch_event(raw) # must not raise + + def test_capture_fig_state_helper(self): + """_capture_fig_state returns both layout_json and panel JSON(s).""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.zeros(32)) + state = _capture_fig_state(fig) + assert "layout_json" in state, ( + f"Expected layout_json; got {list(state.keys())}" + ) + panel_keys = [k for k in state if k.startswith("panel_")] + assert len(panel_keys) >= 1, "Expected at least one panel_ key" + + def test_no_pyodide_push_hook_attribute(self): + """figure module no longer exposes _pyodide_push_hook.""" + assert not hasattr(_af, "_pyodide_push_hook"), ( + "_pyodide_push_hook should not exist on figure module" + ) + diff --git a/anyplotlib/tests/test_documentation/test_pyodide_e2e.py b/anyplotlib/tests/test_documentation/test_pyodide_e2e.py deleted file mode 100644 index 17d1078b..00000000 --- a/anyplotlib/tests/test_documentation/test_pyodide_e2e.py +++ /dev/null @@ -1,947 +0,0 @@ -""" -tests/test_pyodide_e2e.py -========================= - -End-to-end Playwright tests for the Pyodide live documentation bridge. - -Three test tiers, in increasing scope: - -1. **Python push-hook unit tests** — verify ``_pyodide_push_hook`` intercepts - ``_push()`` / ``_push_layout()`` correctly, and that panel IDs are - deterministic (no-browser, fast). - -2. **iframe postMessage tests** — reuse the existing ``interact_page`` fixture - to open a standalone figure in headless Chromium, fire ``awi_state`` - messages directly, and assert the model updates correctly (no Pyodide, no - HTTP server). - -3. **Full bridge mock-boot tests** — build a ``parent.html`` page that - includes the real ``anywidget_bridge.js`` but defines ``window.loadPyodide`` - as a lightweight mock *before* the bridge evaluates it. The mock exercises - the complete JS boot sequence — button click → all ``runPythonAsync`` / - ``loadPackage`` calls → push-hook installation → state push into the iframe - → awi_event forwarding — without downloading the ~10 MB Pyodide WASM - runtime. Pages are served over a local stdlib HTTP server so the - ``file://`` guard in ``anywidget_bridge.js`` is bypassed. - -Run:: - - uv run pytest tests/test_pyodide_e2e.py -v -""" -from __future__ import annotations - -import json -import pathlib -import socket -import tempfile -import threading -from http.server import HTTPServer, SimpleHTTPRequestHandler -from html import escape as _html_escape -from typing import Generator - -import numpy as np -import pytest - -import anyplotlib as apl -import anyplotlib.figure as _af -from anyplotlib._repr_utils import build_standalone_html - -# --------------------------------------------------------------------------- -# Paths -# --------------------------------------------------------------------------- - -_BRIDGE_JS = ( - pathlib.Path(__file__).parent.parent - / "anyplotlib" / "sphinx_anywidget" / "static" / "anywidget_bridge.js" -) - - -# --------------------------------------------------------------------------- -# Helpers used by multiple tiers -# --------------------------------------------------------------------------- - -def _capture_fig_state(fig) -> dict[str, str]: - """Return ``{trait_name: json_string}`` for layout + every panel trait. - - Reads traitlet values directly after calling the push methods. This - works even when the value hasn't changed (traitlets suppresses duplicate - change events, so an observe-based approach would return nothing on a - second call with the same state). - """ - # Ensure state is up to date - fig._push_layout() - for pid in list(fig._plots_map): - fig._push(pid) - - captured: dict[str, str] = {} - captured["layout_json"] = fig.layout_json - for tname in fig.trait_names(): - if tname.startswith("panel_") and tname.endswith("_json"): - captured[tname] = getattr(fig, tname) - return captured - - -def _patched_iframe_html(fig, fig_id: str) -> str: - """Return standalone figure HTML instrumented for Playwright. - - Patches applied on top of ``build_standalone_html``: - * ``window._aplModel = model`` — exposes the model to parent-frame JS. - * ``window._aplReady = true`` — sentinel polled by ``wait_for_function``. - """ - html = build_standalone_html(fig, resizable=False, fig_id=fig_id) - html = html.replace( - "const model = makeModel(STATE);", - "const model = makeModel(STATE);\nwindow._aplModel = model;", - ) - html = html.replace( - "renderFn({ model, el });", - "renderFn({ model, el }); window._aplReady = true;", - ) - return html - - -# --------------------------------------------------------------------------- -# HTTP-server fixture (module-scoped — one server per test module) -# --------------------------------------------------------------------------- - -@pytest.fixture(scope="module") -def http_server(tmp_path_factory) -> Generator[tuple[str, pathlib.Path], None, None]: - """Serve a temp directory over HTTP; yield ``(base_url, base_dir)``. - - Uses a randomly-chosen free port so tests run safely alongside other - sessions. The server is shut down after the last test in the module. - """ - base_dir = tmp_path_factory.mktemp("bridge_server") - - class _SilentHandler(SimpleHTTPRequestHandler): - def __init__(self, *a, **kw): - super().__init__(*a, directory=str(base_dir), **kw) - - def log_message(self, *_): - pass # suppress request noise in test output - - # Pick a free port - with socket.socket() as s: - s.bind(("127.0.0.1", 0)) - port = s.getsockname()[1] - - srv = HTTPServer(("127.0.0.1", port), _SilentHandler) - t = threading.Thread(target=srv.serve_forever, daemon=True) - t.start() - - yield f"http://127.0.0.1:{port}", base_dir - - srv.shutdown() - - -# --------------------------------------------------------------------------- -# Parent-page builder -# --------------------------------------------------------------------------- - -def _build_parent_page( - fig, - fig_id: str, - *, - base_dir: pathlib.Path, - python_src: str = "", -) -> pathlib.Path: - """Write a complete mock-Pyodide parent page to *base_dir*. - - Files written - ------------- - ``{fig_id}.html`` — standalone figure iframe - ``anywidget_bridge.js`` — the real bridge script (copied from docs/) - ``{fig_id}_parent.html`` — parent page with mock loadPyodide - - The mock ``window.loadPyodide`` is defined **before** the bridge script - so the bridge's ``typeof loadPyodide !== 'undefined'`` guard skips the CDN - download entirely. Each ``runPythonAsync`` call is dispatched by string - pattern to simulate the five significant Pyodide boot steps: - - 1. ``micropip.install`` — no-op. - 2. ``sys.modules['anywidget']`` stub — no-op. - 3. ``_pyodide_push_hook`` install — sets real ``window._anywidgetPush``. - 4. ``_fig_ids`` example-run — calls ``window._anywidgetPush`` with captured state. - - Pre-collected figure state (``layout_json`` + ``panel_*_json``) is baked - into the page as ``window._MOCK_LAYOUT`` / ``window._MOCK_PANELS`` so the - mock can push real data without running any Python. - """ - # ── 1. Iframe HTML (with Playwright instrumentation patches) ───────── - iframe_html = _patched_iframe_html(fig, fig_id) - (base_dir / f"{fig_id}.html").write_text(iframe_html, encoding="utf-8") - - # ── 2. Real bridge script ───────────────────────────────────────────── - (base_dir / "anywidget_bridge.js").write_text( - _BRIDGE_JS.read_text(encoding="utf-8"), encoding="utf-8" - ) - - # ── 3. Capture real figure state via the push-hook ──────────────────── - fig_state = _capture_fig_state(fig) - layout_value = fig_state.get("layout_json", "{}") - panel_entries = [ - {"key": k, "value": v} - for k, v in fig_state.items() - if k.startswith("panel_") - ] - - fig_w, fig_h = int(fig.fig_width), int(fig.fig_height) - - # ── 4. Python source block (or a minimal comment stub) ──────────────── - if not python_src: - python_src = "# mock example — state injected by test harness\n" - data_src_attr = _html_escape(json.dumps(python_src), quote=True) - - # ── 5. Mock loadPyodide script ──────────────────────────────────────── - # - # Intercepts every runPythonAsync call by pattern so the full JS boot - # path (button → loading → active) is exercised in milliseconds. - # - # Step (3): install push-hook → sets window._anywidgetPush which delivers - # postMessage awi_state updates into the correct iframe. - # Step (4): run example → calls window._anywidgetPush with pre-baked - # state so the iframe model receives real figure data. - mock_js = f"""""" - - # ── 6. Assemble the parent HTML ─────────────────────────────────────── - parent_html = f""" - - - -anywidget bridge test — {fig_id} -{mock_js} - - - -
-
- -
- -
-
-
- - -""" - - parent_path = base_dir / f"{fig_id}_parent.html" - parent_path.write_text(parent_html, encoding="utf-8") - return parent_path - - -# --------------------------------------------------------------------------- -# Browser helpers -# --------------------------------------------------------------------------- - -def _rafter(page) -> None: - page.evaluate("() => new Promise(r => requestAnimationFrame(r))") - - -def _open_page(browser, url: str, timeout: int = 15_000): - page = browser.new_page() - page.goto(url, wait_until="domcontentloaded", timeout=timeout) - return page - - -def _click_and_wait_boot(page, timeout: int = 15_000) -> None: - """Click the ⚡ badge button and wait until it reaches the 'active' state.""" - page.wait_for_function( - "() => !!document.querySelector('button.awi-activate-btn')", - timeout=timeout, - ) - page.click("button.awi-activate-btn") - page.wait_for_function( - """() => { - const btn = document.querySelector('button.awi-activate-btn'); - return btn && btn.dataset.state === 'active'; - }""", - timeout=timeout, - ) - - -def _wait_for_iframe_model(page, fig_id: str, panel_id: str, - timeout: int = 10_000) -> None: - """Block until the iframe's model has a non-empty panel JSON.""" - page.wait_for_function( - f"""() => {{ - const iframe = document.querySelector('iframe[data-awi-fig="{fig_id}"]'); - if (!iframe || !iframe.contentWindow) return false; - const mdl = iframe.contentWindow._aplModel; - if (!mdl) return false; - const raw = mdl.get('panel_{panel_id}_json'); - return typeof raw === 'string' && raw.length > 10; - }}""", - timeout=timeout, - ) - - -# ============================================================================= -# Tier 1 — Traitlet push unit tests (no browser required) -# ============================================================================= - -class TestPushHook: - """Verify _push() / _push_layout() write to sync=True traitlets. - - The old tests checked ``_pyodide_push_hook``; now we observe the traitlets - directly — the same path that the generic anywidget monkey-patch uses in - Pyodide. - """ - - def test_push_does_not_crash(self): - """Normal mode: _push() succeeds without error.""" - fig, ax = apl.subplots(1, 1, figsize=(400, 300)) - ax.plot(np.zeros(16)) # must not raise - - def test_layout_json_written_on_create(self): - """layout_json traitlet is set when a figure is created.""" - fig, ax = apl.subplots(1, 1, figsize=(400, 300)) - import json - parsed = json.loads(fig.layout_json) - assert "panel_specs" in parsed, ( - f"layout_json missing 'panel_specs': {list(parsed.keys())}" - ) - - def test_panel_json_written_after_plot(self): - """panel_*_json traitlet is set when a plot is added.""" - import json - fig, ax = apl.subplots(1, 1, figsize=(400, 300)) - ax.plot(np.sin(np.linspace(0, 2 * np.pi, 64))) - - panel_keys = [k for k in fig.trait_names() if k.startswith("panel_") and k.endswith("_json")] - assert len(panel_keys) >= 1, "Expected at least one panel_*_json trait" - for k in panel_keys: - parsed = json.loads(getattr(fig, k)) - assert "kind" in parsed, f"panel JSON missing 'kind': {list(parsed.keys())}" - - def test_observe_fires_on_push(self): - """traitlets.observe() fires when _push() writes a panel trait.""" - seen: list[str] = [] - - def _watch(change): - seen.append(change["name"]) - - fig, ax = apl.subplots(1, 1, figsize=(400, 300)) - fig.observe(_watch) - ax.plot(np.zeros(8)) - fig.unobserve(_watch) - - assert any(k.startswith("panel_") for k in seen), ( - f"Expected a panel_* trait change; got: {seen}" - ) - - def test_panel_id_deterministic(self): - """Panel IDs derived from SubplotSpec must be identical across rebuilds.""" - ids: list[str] = [] - for _ in range(3): - fig, ax = apl.subplots(1, 1, figsize=(400, 300)) - ax.plot(np.zeros(8)) - ids.append(list(fig._plots_map.keys())[0]) - assert ids[0] == ids[1] == ids[2], ( - f"Panel ID must be deterministic; got {ids}" - ) - - def test_panel_ids_unique_in_multiplot(self): - """Each panel in a multi-panel figure has a unique ID.""" - fig, axes = apl.subplots(1, 3, figsize=(900, 300)) - for ax in axes: - ax.plot(np.zeros(8)) - ids = list(fig._plots_map.keys()) - assert len(ids) == len(set(ids)), f"Panel IDs not unique: {ids}" - - def test_panel_id_matches_grid_position(self): - """Panel IDs encode the SubplotSpec row/col bounds.""" - fig, axes = apl.subplots(2, 2, figsize=(600, 400)) - for ax in np.asarray(axes).flat: - ax.plot(np.zeros(4)) - ids = set(fig._plots_map.keys()) - for pid in ids: - assert pid.startswith("p"), f"Unexpected panel ID format: {pid!r}" - - def test_dispatch_event_callable_without_kernel(self): - """_dispatch_event() can be called directly as the Pyodide bridge does.""" - import json - fig, ax = apl.subplots(1, 1, figsize=(400, 300)) - ax.plot(np.zeros(16)) - raw = json.dumps({ - "event_type": "on_zoom", - "panel_id": list(fig._plots_map.keys())[0], - "source": "js", - }) - fig._dispatch_event(raw) # must not raise - - def test_capture_fig_state_helper(self): - """_capture_fig_state returns both layout_json and panel JSON(s).""" - fig, ax = apl.subplots(1, 1, figsize=(400, 300)) - ax.plot(np.zeros(32)) - state = _capture_fig_state(fig) - assert "layout_json" in state, f"Expected layout_json; got {list(state.keys())}" - panel_keys = [k for k in state if k.startswith("panel_")] - assert len(panel_keys) >= 1, "Expected at least one panel_ key" - - def test_no_pyodide_push_hook_attribute(self): - """figure module no longer exposes _pyodide_push_hook.""" - assert not hasattr(_af, "_pyodide_push_hook"), ( - "_pyodide_push_hook should not exist on figure module in this branch" - ) - - -# ============================================================================= -# Tier 2 — iframe postMessage tests (browser, no Pyodide, no HTTP server) -# ============================================================================= - -class TestIframeMessaging: - """Test the awi_state postMessage protocol via the standalone iframe. - - The ``interact_page`` fixture opens the figure HTML as a top-level page - (not as an iframe), so ``window.parent === window`` and the outbound - awi_event forwarding is naturally disabled. These tests focus on the - *inbound* direction: an ``awi_state`` message updates the model. - """ - - def _open_fig(self, interact_page): - fig, ax = apl.subplots(1, 1, figsize=(400, 300)) - ax.plot(np.sin(np.linspace(0, 2 * np.pi, 64)), color="#4fc3f7") - plot = list(fig._plots_map.values())[0] - panel_id = list(fig._plots_map.keys())[0] - page = interact_page(fig) - return fig, plot, panel_id, page - - def test_awi_state_message_updates_model_key(self, interact_page): - """Posting {type:'awi_state', key, value} into the page updates the model.""" - fig, plot, panel_id, page = self._open_fig(interact_page) - - # Read the current panel JSON and add a sentinel key - raw = page.evaluate(f"() => window._aplModel.get('panel_{panel_id}_json')") - assert raw is not None, "Model should have an initial panel JSON" - curr = json.loads(raw) - curr["__apl_e2e_sentinel__"] = "hello_from_postMessage" - new_json = json.dumps(curr) - - page.evaluate(f"""() => {{ - window.postMessage({{ - type: 'awi_state', - key: 'panel_{panel_id}_json', - value: {json.dumps(new_json)} - }}, '*'); - }}""") - _rafter(page) - - updated = json.loads( - page.evaluate(f"() => window._aplModel.get('panel_{panel_id}_json')") - ) - assert updated.get("__apl_e2e_sentinel__") == "hello_from_postMessage", ( - "awi_state postMessage did not update the model key" - ) - - def test_awi_state_message_sets_from_parent_flag(self, interact_page): - """_fromParent is True while the awi_state handler runs. - - We can't read the flag mid-handler, but we can verify that a - save_changes() triggered by awi_state does NOT set _eventJsonDirty - (since event_json was not written in that transaction). A by-product - check: calling model.set on a non-event_json key never marks the - dirty flag. - """ - fig, plot, panel_id, page = self._open_fig(interact_page) - - raw = json.loads( - page.evaluate(f"() => window._aplModel.get('panel_{panel_id}_json')") - ) - raw["__flag_test__"] = 42 - new_json = json.dumps(raw) - - # Expose _eventJsonDirty so we can read it after the handler runs. - # We monkey-patch model.save_changes to record whether _eventJsonDirty - # was True at the time of the call triggered by the awi_state message. - page.evaluate("""() => { - window._dirtyAtSaveChanges = null; - // We can't access module-scoped _eventJsonDirty from outside, but - // we can observe whether an awi_event postMessage is fired: it only - // fires when (!_fromParent && FIG_ID && parent!==window && dirty). - // Since FIG_ID is null (standalone page), no awi_event fires in any - // case. So we check absence of awi_event messages instead. - window._aplEventsSeen = 0; - window.addEventListener('message', (e) => { - if (e.data && e.data.type === 'awi_event') window._aplEventsSeen++; - }); - }""") - - page.evaluate(f"""() => {{ - window.postMessage({{ - type: 'awi_state', - key: 'panel_{panel_id}_json', - value: {json.dumps(new_json)} - }}, '*'); - }}""") - _rafter(page) - - # In standalone mode FIG_ID is null → no awi_event is ever forwarded - events_seen = page.evaluate("() => window._aplEventsSeen") - assert events_seen == 0, ( - "_fromParent guard or FIG_ID=null should prevent awi_event echo; " - f"got {events_seen} awi_event(s)" - ) - - def test_awi_state_fires_change_listeners(self, interact_page): - """Posting awi_state triggers on('change:…') listeners in the model.""" - fig, plot, panel_id, page = self._open_fig(interact_page) - - page.evaluate(f"""() => {{ - window._aplChangeCount = 0; - window._aplModel.on('change:panel_{panel_id}_json', () => {{ - window._aplChangeCount++; - }}); - }}""") - - raw = json.loads( - page.evaluate(f"() => window._aplModel.get('panel_{panel_id}_json')") - ) - raw["__change_test__"] = 1 - new_json = json.dumps(raw) - - page.evaluate(f"""() => {{ - window.postMessage({{ - type: 'awi_state', - key: 'panel_{panel_id}_json', - value: {json.dumps(new_json)} - }}, '*'); - }}""") - _rafter(page) - - count = page.evaluate("() => window._aplChangeCount") - assert count >= 1, ( - "awi_state postMessage should fire change listeners; " - f"got {count} invocations" - ) - - def test_layout_json_push_updates_model(self, interact_page): - """layout_json can be updated via awi_state, not only panel_*_json.""" - fig, plot, panel_id, page = self._open_fig(interact_page) - - layout = json.loads( - page.evaluate("() => window._aplModel.get('layout_json') || '{}'") - ) - layout["__layout_sentinel__"] = "bridge_test" - new_json = json.dumps(layout) - - page.evaluate(f"""() => {{ - window.postMessage({{ - type: 'awi_state', - key: 'layout_json', - value: {json.dumps(new_json)} - }}, '*'); - }}""") - _rafter(page) - - updated = json.loads( - page.evaluate("() => window._aplModel.get('layout_json') || '{}'") - ) - assert updated.get("__layout_sentinel__") == "bridge_test", ( - "layout_json postMessage did not update the model" - ) - - -# ============================================================================= -# Tier 3 — Full bridge mock-boot tests (HTTP server + mock Pyodide) -# ============================================================================= - -class TestFullBridgeBoot: - """Boot anywidget_bridge.js end-to-end via a mock loadPyodide. - - Each test builds a parent HTML page using ``_build_parent_page`` and - serves it from the shared ``http_server`` fixture. All Pyodide network - I/O is replaced by the JS mock so tests run in milliseconds. - """ - - # ------------------------------------------------------------------ - # helpers - - def _open(self, browser, base_url: str, parent_path: pathlib.Path, - timeout: int = 15_000): - url = f"{base_url}/{parent_path.name}" - page = browser.new_page() - page.goto(url, wait_until="domcontentloaded", timeout=timeout) - return page - - def _basic_fig(self) -> tuple: - fig, ax = apl.subplots(1, 1, figsize=(400, 300)) - ax.plot(np.sin(np.linspace(0, 2 * np.pi, 64)), color="#50fa7b") - panel_id = list(fig._plots_map.keys())[0] - return fig, panel_id - - # ------------------------------------------------------------------ - # tests - - def test_button_appears_when_iframe_present( - self, http_server, _pw_browser - ): - """The ⚡ button is injected on any page that has a data-awi-fig iframe.""" - base_url, base_dir = http_server - fig, panel_id = self._basic_fig() - parent = _build_parent_page(fig, "btn_test_001", base_dir=base_dir) - page = self._open(_pw_browser, base_url, parent) - - page.wait_for_function( - "() => !!document.querySelector('button.awi-activate-btn')", - timeout=5_000, - ) - tooltip = page.evaluate( - "() => document.querySelector('button.awi-activate-btn').title" - ) - assert "interactive" in tooltip.lower(), ( - f"Button tooltip should mention 'interactive'; got {tooltip!r}" - ) - page.close() - - def test_boot_completes_all_mock_steps( - self, http_server, _pw_browser - ): - """Clicking ⚡ runs through all expected mock Pyodide boot steps.""" - base_url, base_dir = http_server - fig, panel_id = self._basic_fig() - parent = _build_parent_page(fig, "boot_test_001", base_dir=base_dir) - page = self._open(_pw_browser, base_url, parent) - - _click_and_wait_boot(page) - - steps = page.evaluate("() => window._APL_BOOT_STEPS") - - assert "loadPyodide" in steps, ( - f"loadPyodide() was never called; steps={steps}" - ) - assert "micropip_install" in steps, ( - f"micropip install step missing; steps={steps}" - ) - assert "stub_anywidget" in steps, ( - f"anywidget stub step missing; steps={steps}" - ) - assert "install_monkey_patch" in steps, ( - f"monkey-patch install step missing; steps={steps!r}\n" - "This means anywidget_bridge.js never called runPythonAsync with " - "the _patched_init monkey-patch source — the JS↔Python bridge is broken." - ) - assert "run_example" in steps, ( - f"Example-run step missing; steps={steps!r}\n" - "This means anywidget_bridge.js never called runPythonAsync with " - "the _fig_ids / _push_layout block that seeds the iframes." - ) - page.close() - - def test_anywidgetPush_is_function_after_boot( - self, http_server, _pw_browser - ): - """window._anywidgetPush must be a function after the push-hook step runs.""" - base_url, base_dir = http_server - fig, panel_id = self._basic_fig() - parent = _build_parent_page(fig, "apush_test_001", base_dir=base_dir) - page = self._open(_pw_browser, base_url, parent) - - _click_and_wait_boot(page) - - is_fn = page.evaluate("() => typeof window._anywidgetPush === 'function'") - assert is_fn, ( - "window._anywidgetPush should be a function after the push-hook step; " - "if it is missing the hook was never installed by anywidget_bridge.js" - ) - page.close() - - def test_state_pushed_into_iframe_model( - self, http_server, _pw_browser - ): - """After boot the iframe's model contains the figure's panel JSON. - - This is the core Pyodide bridge assertion: Python figure state must - reach the iframe model via _anywidgetPush → postMessage → awi_state listener - → model.set(key, value). - """ - base_url, base_dir = http_server - fig, panel_id = self._basic_fig() - expected = fig._plots_map[panel_id].to_state_dict() - - parent = _build_parent_page(fig, "state_push_001", base_dir=base_dir) - page = self._open(_pw_browser, base_url, parent) - - _click_and_wait_boot(page) - _wait_for_iframe_model(page, "state_push_001", panel_id) - - raw = page.evaluate(f"""() => {{ - const iframe = document.querySelector('iframe[data-awi-fig="state_push_001"]'); - return iframe && iframe.contentWindow - ? iframe.contentWindow._aplModel.get('panel_{panel_id}_json') - : null; - }}""") - - assert raw is not None, ( - "panel JSON was never delivered to the iframe model after boot.\n" - "Check: (a) _anywidgetPush was installed, (b) postMessage reached the " - "iframe's awi_state listener, (c) model.set() was called." - ) - state = json.loads(raw) - assert state.get("kind") == expected.get("kind"), ( - f"kind mismatch: iframe has {state.get('kind')!r}, " - f"Python produced {expected.get('kind')!r}" - ) - page.close() - - def test_layout_json_pushed_into_iframe( - self, http_server, _pw_browser - ): - """layout_json (panel geometry) is delivered to the iframe model.""" - base_url, base_dir = http_server - fig, panel_id = self._basic_fig() - parent = _build_parent_page(fig, "layout_push_001", base_dir=base_dir) - page = self._open(_pw_browser, base_url, parent) - - _click_and_wait_boot(page) - - # Wait for layout_json to propagate - page.wait_for_function( - """() => { - const iframe = document.querySelector('iframe[data-awi-fig="layout_push_001"]'); - if (!iframe || !iframe.contentWindow) return false; - const mdl = iframe.contentWindow._aplModel; - if (!mdl) return false; - const raw = mdl.get('layout_json'); - return typeof raw === 'string' && raw.length > 10; - }""", - timeout=8_000, - ) - - raw = page.evaluate("""() => { - const iframe = document.querySelector('iframe[data-awi-fig="layout_push_001"]'); - return iframe.contentWindow._aplModel.get('layout_json'); - }""") - assert raw is not None, "layout_json was not delivered to the iframe" - layout = json.loads(raw) - assert "panel_specs" in layout, ( - f"layout_json is missing 'panel_specs'; got keys: {list(layout.keys())}" - ) - page.close() - - def test_event_message_forwarded_to_parent( - self, http_server, _pw_browser - ): - """awi_event messages sent from the iframe arrive at the parent window. - - This tests the reverse direction of the bridge: user interaction in - the iframe → awi_event postMessage → parent window.message listener - → _fig._dispatch_event(). Here we only test the JS forwarding step; - the Python dispatch is covered by TestPushHook.test_dispatch_event_*. - """ - base_url, base_dir = http_server - fig, panel_id = self._basic_fig() - parent = _build_parent_page(fig, "event_fwd_001", base_dir=base_dir) - page = self._open(_pw_browser, base_url, parent) - - _click_and_wait_boot(page) - - # Install a parent-side listener that records received awi_events - page.evaluate("""() => { - window._aplReceivedEvents = []; - window.addEventListener('message', (e) => { - if (e.data && e.data.type === 'awi_event') { - window._aplReceivedEvents.push(e.data); - } - }); - }""") - - # Synthesise an awi_event from the iframe (mirrors what the iframe - # does when a widget drag ends: window.parent.postMessage({...}, '*')) - fake_event = json.dumps({ - "event_type": "on_release", - "panel_id": panel_id, - "widget_id": "w_e2e_fake", - "x": 42.0, - }) - page.evaluate(f"""() => {{ - // Simulate the iframe posting the event to its parent. - // In the actual docs the iframe does: - // window.parent.postMessage({{type:'awi_event', figId, data}}, '*') - // Here the iframe IS the top-level page so we post to window itself. - window.postMessage({{ - type: 'awi_event', - figId: 'event_fwd_001', - data: {json.dumps(fake_event)} - }}, '*'); - }}""") - _rafter(page) - - events = page.evaluate("() => window._aplReceivedEvents") - assert len(events) >= 1, ( - "No awi_event reached the parent message bus.\n" - "The parent window.message listener in anywidget_bridge.js " - "may not be installed, or the figId routing is broken." - ) - assert events[0]["figId"] == "event_fwd_001", ( - f"figId mismatch: {events[0]['figId']!r} vs 'event_fwd_001'" - ) - page.close() - - def test_multiple_panels_all_receive_state( - self, http_server, _pw_browser - ): - """All panels in a multi-panel figure have their state pushed.""" - base_url, base_dir = http_server - - fig, axes = apl.subplots(1, 2, figsize=(700, 300)) - axes[0].plot(np.zeros(32)) - axes[1].plot(np.ones(32) * 0.5) - panel_ids = list(fig._plots_map.keys()) - assert len(panel_ids) == 2, "Expected exactly 2 panels" - - parent = _build_parent_page(fig, "multi_panel_001", base_dir=base_dir) - page = self._open(_pw_browser, base_url, parent) - _click_and_wait_boot(page) - - # Wait for both panels to arrive - for pid in panel_ids: - _wait_for_iframe_model(page, "multi_panel_001", pid) - - for pid in panel_ids: - raw = page.evaluate(f"""() => {{ - const iframe = document.querySelector( - 'iframe[data-awi-fig="multi_panel_001"]'); - return iframe && iframe.contentWindow - ? iframe.contentWindow._aplModel.get('panel_{pid}_json') - : null; - }}""") - assert raw is not None, ( - f"Panel {pid!r} state was not pushed into the iframe model.\n" - "If only the first panel arrives, _anywidgetPush may be iterating " - "panels incorrectly in the mock (or in the real bridge)." - ) - page.close() - - def test_button_shows_error_on_boot_failure( - self, http_server, _pw_browser - ): - """If Pyodide boot fails the button switches to the error state (❌).""" - base_url, base_dir = http_server - fig, panel_id = self._basic_fig() - - # Build the parent page, then patch the mock to throw on loadPyodide - parent = _build_parent_page(fig, "error_test_001", base_dir=base_dir) - html = (base_dir / "error_test_001_parent.html").read_text(encoding="utf-8") - # Inject a rejection AFTER the mock definition so it overrides it - html = html.replace( - "window.loadPyodide = async function({indexURL}) {", - "window.loadPyodide = async function({indexURL}) { throw new Error('mock boot failure'); //", - ) - (base_dir / "error_test_001_parent.html").write_text(html, encoding="utf-8") - - page = self._open(_pw_browser, base_url, parent) - page.wait_for_function( - "() => !!document.querySelector('button.awi-activate-btn')", - timeout=5_000 - ) - page.click("button.awi-activate-btn") - - # Wait for button to enter error state - page.wait_for_function( - """() => { - const btn = document.querySelector('button.awi-activate-btn'); - return btn && btn.dataset.state === 'error'; - }""", - timeout=10_000, - ) - label = page.evaluate( - "() => document.querySelector('button.awi-activate-btn').title" - ) - assert "mock boot failure" in label, ( - f"Error button title should contain the exception message; got {label!r}" - ) - page.close() - - - diff --git a/anyplotlib/tests/test_documentation/test_scraper.py b/anyplotlib/tests/test_documentation/test_scraper.py index 62e46c7a..bbb97714 100644 --- a/anyplotlib/tests/test_documentation/test_scraper.py +++ b/anyplotlib/tests/test_documentation/test_scraper.py @@ -1,14 +1,21 @@ """ -tests/test_scraper.py -===================== +tests/test_documentation/test_scraper.py +========================================= -Pytest tests for the Playwright-based scraper thumbnail functionality. +Tests for the Playwright-based scraper thumbnail functionality. + +Two sections: + +1. **PNG format validation** — verifies ``_make_thumbnail_png`` returns a valid + PNG array for common figure types. No Playwright required. + +2. **Dark-theme validation** — checks the top-left pixel of the thumbnail is + dark-blue (matching the library's dark theme). Requires Playwright; skipped + automatically when not installed. """ from __future__ import annotations -from pathlib import Path - import numpy as np import pytest @@ -17,7 +24,9 @@ from anyplotlib.tests._png_utils import decode_png -# ── fixtures ────────────────────────────────────────────────────────────────── +# ───────────────────────────────────────────────────────────────────────────── +# Shared fixtures +# ───────────────────────────────────────────────────────────────────────────── @pytest.fixture def line_fig(): @@ -44,23 +53,65 @@ def multi_panel_fig(): return fig -# ── thumbnail PNG validation ────────────────────────────────────────────────── +# ───────────────────────────────────────────────────────────────────────────── +# Helper +# ───────────────────────────────────────────────────────────────────────────── -def _assert_thumbnail_is_png(widget, label: str): - png = _make_thumbnail_png(widget) +def _decode_thumbnail(fig, label: str): + """Return the decoded RGBA/RGB array for *fig*'s thumbnail, asserting PNG.""" + png = _make_thumbnail_png(fig) assert png[:4] == b"\x89PNG", f"[{label}] result is not a PNG" arr = decode_png(png) assert arr.ndim == 3, f"[{label}] expected H×W×C array, got shape {arr.shape}" - assert arr.shape[2] in (3, 4), f"[{label}] expected RGB/RGBA, got {arr.shape[2]} channels" + assert arr.shape[2] in (3, 4), ( + f"[{label}] expected RGB/RGBA, got {arr.shape[2]} channels" + ) + return arr + + +# ───────────────────────────────────────────────────────────────────────────── +# Section 1 — PNG format validation (no Playwright required) +# ───────────────────────────────────────────────────────────────────────────── + +class TestThumbnailFormat: + """Verify that _make_thumbnail_png produces a well-formed PNG for each + common figure type.""" + + def test_thumbnail_1d_line(self, line_fig): + _decode_thumbnail(line_fig, "1D line") + + def test_thumbnail_2d_imshow(self, imshow_fig): + _decode_thumbnail(imshow_fig, "2D imshow") + + def test_thumbnail_multi_panel(self, multi_panel_fig): + _decode_thumbnail(multi_panel_fig, "multi-panel") + + +# ───────────────────────────────────────────────────────────────────────────── +# Section 2 — Dark-theme pixel validation (requires Playwright) +# ───────────────────────────────────────────────────────────────────────────── + +pytest.importorskip("playwright", reason="playwright not installed") -def test_thumbnail_1d_line(line_fig): - _assert_thumbnail_is_png(line_fig, "1D line") +class TestThumbnailDarkTheme: + """Verify the top-left pixel of each thumbnail is dark-blue, matching the + library's default dark theme. These tests are skipped when Playwright is + not installed.""" + def _assert_dark_theme(self, fig, label: str) -> None: + arr = _decode_thumbnail(fig, label) + r, g, b = int(arr[0, 0, 0]), int(arr[0, 0, 1]), int(arr[0, 0, 2]) + assert (b > r) and (b > 30), ( + f"[{label}] expected a dark-theme thumbnail " + f"(top-left RGB=({r},{g},{b}))" + ) -def test_thumbnail_2d_imshow(imshow_fig): - _assert_thumbnail_is_png(imshow_fig, "2D imshow") + def test_dark_theme_1d_line(self, line_fig): + self._assert_dark_theme(line_fig, "1D line") + def test_dark_theme_2d_imshow(self, imshow_fig): + self._assert_dark_theme(imshow_fig, "2D imshow") -def test_thumbnail_multi_panel(multi_panel_fig): - _assert_thumbnail_is_png(multi_panel_fig, "multi-panel") + def test_dark_theme_multi_panel(self, multi_panel_fig): + self._assert_dark_theme(multi_panel_fig, "multi-panel") From a125d08d78b74527554474602e6a9b49bee093ac Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 10 May 2026 20:23:38 -0500 Subject: [PATCH 092/198] Refactor: Remove pcolormesh_extra --- .../test_plot2d/test_pcolormesh_extras.py | 192 ------------------ 1 file changed, 192 deletions(-) delete mode 100644 anyplotlib/tests/test_plot2d/test_pcolormesh_extras.py diff --git a/anyplotlib/tests/test_plot2d/test_pcolormesh_extras.py b/anyplotlib/tests/test_plot2d/test_pcolormesh_extras.py deleted file mode 100644 index a8c0d58e..00000000 --- a/anyplotlib/tests/test_plot2d/test_pcolormesh_extras.py +++ /dev/null @@ -1,192 +0,0 @@ -""" -tests/test_pcolormesh_extras.py -================================ - -Tests for PlotMesh (pcolormesh) mirroring Examples/plot_pcolormesh.py. - -Covers: - * Basic construction with non-uniform edges - * set_colormap() - * set_data() — data replacement - * add_circles / add_lines marker helpers - * Restriction to circles+lines only - * State dict keys -""" -from __future__ import annotations - -import numpy as np -import pytest - -import anyplotlib as apl -from anyplotlib.figure_plots import PlotMesh - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _mesh(M=8, N=12): - rng = np.random.default_rng(42) - data = rng.standard_normal((M, N)) - x_edges = np.linspace(0, N, N + 1) - y_edges = np.linspace(0, M, M + 1) - fig, ax = apl.subplots(1, 1) - return ax.pcolormesh(data, x_edges=x_edges, y_edges=y_edges) - - -def _log_mesh(): - """Mesh with non-uniform (log-spaced) x edges, as in the gallery example.""" - M, N = 32, 48 - rng = np.random.default_rng(1) - data = np.sin(np.linspace(0, 3 * np.pi, N)) + np.cos(np.linspace(0, 2 * np.pi, M))[:, None] - data += rng.normal(scale=0.15, size=(M, N)) - x_edges = np.logspace(-1, 2, N + 1) - y_edges = np.linspace(0, 100, M + 1) - fig, ax = apl.subplots(1, 1) - return ax.pcolormesh(data, x_edges=x_edges, y_edges=y_edges, units="arb.") - - -# --------------------------------------------------------------------------- -# Construction -# --------------------------------------------------------------------------- - -class TestPlotMeshConstruction: - - def test_kind_is_2d(self): - mesh = _mesh() - assert mesh._state["kind"] == "2d" - - def test_is_mesh_flag(self): - mesh = _mesh() - assert mesh._state["is_mesh"] is True - - def test_x_axis_has_edges(self): - mesh = _mesh(M=8, N=12) - # x_axis stores edges (N+1 values) - assert len(mesh._state["x_axis"]) == 13 - - def test_y_axis_has_edges(self): - mesh = _mesh(M=8, N=12) - assert len(mesh._state["y_axis"]) == 9 - - def test_units_stored(self): - mesh = _log_mesh() - assert mesh._state["units"] == "arb." - - def test_log_x_edges(self): - """Non-uniform (log-spaced) edges should be accepted without error.""" - mesh = _log_mesh() - assert mesh._state["image_width"] == 48 - - def test_default_colormap(self): - mesh = _mesh() - assert "colormap_name" in mesh._state - - def test_wrong_x_edge_count(self): - data = np.ones((8, 12)) - x_edges = np.linspace(0, 10, 10) # should be 13 - y_edges = np.linspace(0, 8, 9) - with pytest.raises(ValueError): - fig, ax = apl.subplots(1, 1) - ax.pcolormesh(data, x_edges=x_edges, y_edges=y_edges) - - def test_wrong_y_edge_count(self): - data = np.ones((8, 12)) - x_edges = np.linspace(0, 12, 13) - y_edges = np.linspace(0, 10, 5) # should be 9 - with pytest.raises(ValueError): - fig, ax = apl.subplots(1, 1) - ax.pcolormesh(data, x_edges=x_edges, y_edges=y_edges) - - -# --------------------------------------------------------------------------- -# Mutations -# --------------------------------------------------------------------------- - -class TestPlotMeshMutations: - - def test_set_colormap(self): - mesh = _mesh() - mesh.set_colormap("viridis") - assert mesh._state["colormap_name"] == "viridis" - - def test_set_colormap_updates_lut(self): - mesh = _mesh() - mesh.set_colormap("plasma") - lut = mesh._state["colormap_data"] - assert isinstance(lut, list) - assert len(lut) == 256 - - def test_set_data_same_shape(self): - mesh = _mesh(M=8, N=12) - new_data = np.ones((8, 12)) - mesh.set_data(new_data) - assert mesh._state["image_width"] == 12 - - def test_set_data_with_new_units(self): - mesh = _mesh() - mesh.set_data(np.zeros((8, 12)), units="nm") - assert mesh._state["units"] == "nm" - - def test_set_data_wrong_ndim(self): - mesh = _mesh() - with pytest.raises(ValueError): - mesh.set_data(np.zeros(12)) - - def test_set_data_wrong_x_edges(self): - mesh = _mesh(M=8, N=12) - new_data = np.zeros((8, 12)) - bad_x = np.linspace(0, 10, 5) - with pytest.raises(ValueError): - mesh.set_data(new_data, x_edges=bad_x) - - -# --------------------------------------------------------------------------- -# Markers -# --------------------------------------------------------------------------- - -class TestPlotMeshMarkers: - - def test_add_circles(self): - mesh = _mesh() - pts = np.array([[2.0, 2.0], [6.0, 4.0]]) - mesh.add_circles(pts, name="peaks", radius=0.5, edgecolors="#ff1744") - assert "peaks" in mesh.markers["circles"] - - def test_add_circles_labels(self): - mesh = _mesh() - pts = np.array([[1.0, 2.0], [5.0, 4.0], [9.0, 6.0], [11.0, 2.0]]) - mesh.add_circles(pts, name="pks", radius=0.3, - edgecolors="#ff1744", facecolors="#ff174433", - labels=["A", "B", "C", "D"]) - wl = mesh.markers.to_wire_list() - assert any(w.get("labels") == ["A", "B", "C", "D"] for w in wl) - - def test_add_lines(self): - mesh = _mesh() - segs = [[[1.0, 1.0], [5.0, 5.0]], [[5.0, 5.0], [10.0, 2.0]]] - mesh.add_lines(segs, name="path", edgecolors="#00e5ff") - assert "path" in mesh.markers["lines"] - - def test_arrows_disallowed_on_mesh(self): - mesh = _mesh() - with pytest.raises(ValueError, match="not allowed"): - mesh.add_arrows([[0.0, 0.0]], [1.0], [1.0]) - - def test_ellipses_disallowed_on_mesh(self): - mesh = _mesh() - with pytest.raises(ValueError, match="not allowed"): - mesh.add_ellipses([[0.0, 0.0]], widths=5, heights=3) - - def test_circles_set(self): - mesh = _mesh() - mesh.add_circles([[2.0, 2.0]], name="c", radius=1.0) - mesh.markers["circles"]["c"].set(radius=2.0) - assert mesh.markers["circles"]["c"]._data["radius"] == 2.0 - - def test_to_wire_list_contains_circles(self): - mesh = _mesh() - mesh.add_circles([[2.0, 2.0]], name="spot") - wl = mesh.markers.to_wire_list() - assert any(w["type"] == "circles" for w in wl) - From af7e4f1aa427ec4c6229bdd5b807eace894da6bc Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 10 May 2026 20:30:08 -0500 Subject: [PATCH 093/198] ci: update CI configuration to use updated setup actions and specify test directory --- .github/workflows/ci.yml | 12 ++++-------- .github/workflows/tests.yml | 4 ++-- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ea5770e..0b7e206c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,13 +11,11 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 + - name: Set up uv + uses: astral-sh/setup-uv@v5 with: python-version: "3.12" - - - name: Install uv - uses: astral-sh/setup-uv@v4 + enable-cache: true - name: Install dependencies run: uv sync --group dev @@ -29,11 +27,9 @@ jobs: run: uv run pytest - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: files: coverage.xml fail_ci_if_error: false env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - - diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5e02c213..32cbf627 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -51,7 +51,7 @@ jobs: run: uv run playwright install chromium - name: Run tests - run: uv run pytest tests/ -v --tb=short + run: uv run pytest anyplotlib/tests/ -v --tb=short minimum-deps: name: Minimum deps (Python 3.10 / ubuntu) @@ -77,4 +77,4 @@ jobs: run: uv run playwright install chromium --with-deps - name: Run tests - run: uv run pytest tests/ -v --tb=short + run: uv run pytest anyplotlib/tests/ -v --tb=short From 37b85393f1054763c379170c19fe88264166f1d1 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 10 May 2026 20:33:35 -0500 Subject: [PATCH 094/198] fix: update coverage configuration to correctly omit test directory --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b84f6106..8142e4c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,7 @@ addopts = "--cov=anyplotlib --cov-report=xml --cov-report=term-missing" [tool.coverage.run] source = ["anyplotlib"] -omit = ["tests/*", "Examples/*", "docs/*"] +omit = ["anyplotlib/tests/*", "Examples/*", "docs/*"] [tool.coverage.report] exclude_lines = [ From ddfcc1612fd517500692d823ef13a51dea0d6659 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 10 May 2026 20:36:35 -0500 Subject: [PATCH 095/198] refactor: skip tests based on Playwright availability --- anyplotlib/tests/test_documentation/test_scraper.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/anyplotlib/tests/test_documentation/test_scraper.py b/anyplotlib/tests/test_documentation/test_scraper.py index bbb97714..a8f38030 100644 --- a/anyplotlib/tests/test_documentation/test_scraper.py +++ b/anyplotlib/tests/test_documentation/test_scraper.py @@ -16,6 +16,8 @@ from __future__ import annotations +import importlib.util as _ilu + import numpy as np import pytest @@ -91,9 +93,13 @@ def test_thumbnail_multi_panel(self, multi_panel_fig): # Section 2 — Dark-theme pixel validation (requires Playwright) # ───────────────────────────────────────────────────────────────────────────── -pytest.importorskip("playwright", reason="playwright not installed") +_requires_playwright = pytest.mark.skipif( + _ilu.find_spec("playwright") is None, + reason="playwright not installed", +) +@_requires_playwright class TestThumbnailDarkTheme: """Verify the top-left pixel of each thumbnail is dark-blue, matching the library's default dark theme. These tests are skipped when Playwright is From 9adb1707ba2d4d4a005326214163ecdbfc88366f Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 10 May 2026 20:46:28 -0500 Subject: [PATCH 096/198] Documentation: Added a changelog for #11 --- upcoming_changes/11.maintenance.rst | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 upcoming_changes/11.maintenance.rst diff --git a/upcoming_changes/11.maintenance.rst b/upcoming_changes/11.maintenance.rst new file mode 100644 index 00000000..642100c0 --- /dev/null +++ b/upcoming_changes/11.maintenance.rst @@ -0,0 +1,2 @@ +Refactored the testssuite. Moved to a new directory, combined liked +t1ests into single files, added a couple new tests and removed some redundant tests. \ No newline at end of file From 8c5539ebed33b537ecefa750be47aea447740000 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 10 May 2026 21:50:19 -0500 Subject: [PATCH 097/198] chore: add .worktrees/ to .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 26680470..6d2bcb08 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ docs/_static/anywidget_config.js # macOS .DS_Store + +# Git worktrees +.worktrees/ From 903de716e6077a847c5606ac6e551fcf702ed4a7 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 10 May 2026 22:09:39 -0500 Subject: [PATCH 098/198] refactor: split widgets.py into widgets/ package (_base, _widgets2d, _widgets1d) --- anyplotlib/widgets.py | 530 ------------------------------- anyplotlib/widgets/__init__.py | 15 + anyplotlib/widgets/_base.py | 268 ++++++++++++++++ anyplotlib/widgets/_widgets1d.py | 109 +++++++ anyplotlib/widgets/_widgets2d.py | 141 ++++++++ 5 files changed, 533 insertions(+), 530 deletions(-) delete mode 100644 anyplotlib/widgets.py create mode 100644 anyplotlib/widgets/__init__.py create mode 100644 anyplotlib/widgets/_base.py create mode 100644 anyplotlib/widgets/_widgets1d.py create mode 100644 anyplotlib/widgets/_widgets2d.py diff --git a/anyplotlib/widgets.py b/anyplotlib/widgets.py deleted file mode 100644 index f81385f7..00000000 --- a/anyplotlib/widgets.py +++ /dev/null @@ -1,530 +0,0 @@ -""" -widgets.py — Interactive overlay widget classes. - -Each widget has a .callbacks (CallbackRegistry). Register handlers via:: - - @rect.on_changed # every drag frame - def live(event): ... - - @rect.on_release # once on mouseup - def done(event): ... - - @rect.on_click - def clicked(event): ... - - rect.x = 40 # moves widget, sends targeted update to JS - rect.x # always reflects current JS position -""" -from __future__ import annotations - -import uuid as _uuid -from typing import Callable - -from anyplotlib.callbacks import CallbackRegistry, Event - -__all__ = [ - "Widget", - "RectangleWidget", "CircleWidget", "AnnularWidget", - "CrosshairWidget", "PolygonWidget", "LabelWidget", - "VLineWidget", "HLineWidget", "RangeWidget", "PointWidget", -] - - -class Widget: - """Base class for all overlay widgets. - - Provides attribute-based state access, callbacks for interaction events, - and automatic synchronization with the JavaScript renderer. - - Parameters - ---------- - wtype : str - Widget type (e.g., 'rectangle', 'circle', 'crosshair'). - push_fn : Callable - Zero-arg callback to send position updates to the JavaScript renderer. - **kwargs : dict - Initial widget state (position, size, color, etc.). - - Attributes - ---------- - callbacks : CallbackRegistry - Event callback registry. Register handlers via: - - ``@widget.on_changed`` — fires on every drag frame - - ``@widget.on_release`` — fires once when drag settles - - ``@widget.on_click`` — fires on click event - """ - - def __init__(self, wtype: str, push_fn: Callable, **kwargs): - self._id: str = str(_uuid.uuid4())[:8] - self._type: str = wtype - self._data: dict = dict(kwargs) - self._data["id"] = self._id - self._data["type"] = wtype - self._push_fn: Callable = push_fn - self.callbacks: CallbackRegistry = CallbackRegistry() - - # ── attribute read ──────────────────────────────────────────────── - - def __getattr__(self, key: str): - """Access widget properties as attributes (read-only).""" - if key.startswith("_"): - raise AttributeError(key) - try: - return self._data[key] - except KeyError: - raise AttributeError( - f"{type(self).__name__} has no attribute {key!r}. " - f"Available: {list(self._data)}" - ) from None - - # ── attribute write — routes public assignments through set() ──── - - def __setattr__(self, key: str, value) -> None: - """Update widget properties via attribute assignment.""" - # Private attrs and 'callbacks' bypass set() - if key.startswith("_") or key == "callbacks": - super().__setattr__(key, value) - return - # During __init__ _data may not exist yet - try: - object.__getattribute__(self, "_data") - except AttributeError: - super().__setattr__(key, value) - return - self.set(**{key: value}) - - # ── set / get ───────────────────────────────────────────────────── - - def set(self, _push: bool = True, **kwargs) -> None: - """Update properties and send targeted update to JavaScript. - - Parameters - ---------- - _push : bool, optional - Whether to push update to renderer. Default True. - Set to False internally to avoid echo loops. - **kwargs : dict - Properties to update (e.g., x=100, y=50, radius=20). - - Notes - ----- - Updates are sent as targeted widget updates, not full panel re-renders. - This is more efficient for frequent updates during dragging. - """ - self._data.update(kwargs) - if _push: - self._push_fn() - self.callbacks.fire(Event("on_changed", source=self, data=dict(self._data))) - - def get(self, key: str, default=None): - """Get a widget property by name. - - Parameters - ---------- - key : str - Property name. - default : optional - Default value if property not found. - - Returns - ------- - object - The property value. - """ - return self._data.get(key, default) - - def to_dict(self) -> dict: - """Return a dict copy of the widget state. - - Returns - ------- - dict - All widget properties including id and type. - """ - return dict(self._data) - - # ── callback decorator methods ──────────────────────────────────── - - def on_changed(self, fn: Callable) -> Callable: - """Decorator: register fn to fire on every drag frame. - - Use this for high-frequency updates (keep handler fast). - - Parameters - ---------- - fn : Callable - Handler function receiving an Event. - - Returns - ------- - Callable - The decorated function. - """ - cid = self.callbacks.connect("on_changed", fn) - fn._cid = cid - return fn - - def on_release(self, fn: Callable) -> Callable: - """Decorator: register fn to fire once when drag settles. - - Use this for expensive operations triggered after user stops dragging. - - Parameters - ---------- - fn : Callable - Handler function receiving an Event. - - Returns - ------- - Callable - The decorated function. - """ - cid = self.callbacks.connect("on_release", fn) - fn._cid = cid - return fn - - def on_click(self, fn: Callable) -> Callable: - """Decorator: register fn to fire on widget click. - - Parameters - ---------- - fn : Callable - Handler function receiving an Event. - - Returns - ------- - Callable - The decorated function. - """ - cid = self.callbacks.connect("on_click", fn) - fn._cid = cid - return fn - - def disconnect(self, cid) -> None: - """Remove the callback registered under *cid*. - - Parameters - ---------- - cid : int or Callable - Either the integer CID returned by ``callbacks.connect()``, - or the decorated function itself (carries a ``._cid`` attribute). - """ - if callable(cid) and hasattr(cid, "_cid"): - cid = cid._cid - self.callbacks.disconnect(cid) - - # ── visibility ──────────────────────────────────────────────────────── - - @property - def visible(self) -> bool: - """``True`` if the widget is rendered; ``False`` if hidden.""" - return self._data.get("visible", True) - - @visible.setter - def visible(self, value: bool) -> None: - self.show() if value else self.hide() - - def show(self) -> None: - """Show the widget. Does not fire ``on_changed`` callbacks.""" - self._data["visible"] = True - self._push_fn() - - def hide(self) -> None: - """Hide the widget without removing it or its callbacks. - - Call :meth:`show` to make it visible again. - Does not fire ``on_changed`` callbacks. - """ - self._data["visible"] = False - self._push_fn() - - # ── JS → Python sync ────────────────────────────────────────────── - - def _update_from_js(self, new_data: dict, event_type: str = "on_changed") -> bool: - """Apply incoming JS state without pushing back (avoids echo). - - Parameters - ---------- - new_data : dict - Updated widget properties from JavaScript. - event_type : str, optional - Type of event that triggered the update. - - Returns - ------- - bool - True if any state changed. - - Notes - ----- - Always fires on_release / on_click callbacks even if nothing changed. - Only fires on_changed if state actually changed. - """ - changed = False - for k, v in new_data.items(): - if k in ("id", "type"): - continue - if self._data.get(k) != v: - self._data[k] = v - changed = True - # Always fire for settle / click; only fire on_changed when something moved - if changed or event_type in ("on_release", "on_click"): - self.callbacks.fire(Event(event_type, source=self, data=dict(self._data))) - return changed - - # ── repr ────────────────────────────────────────────────────────── - - def __repr__(self) -> str: - props = ", ".join( - f"{k}={v:.4g}" if isinstance(v, float) else f"{k}={v!r}" - for k, v in self._data.items() - if k not in ("id", "type", "color") - ) - return f"{type(self).__name__}({props})" - - @property - def id(self) -> str: - """Return the widget's unique identifier.""" - return self._id - - -# --------------------------------------------------------------------------- -# 2-D widgets -# --------------------------------------------------------------------------- - -class RectangleWidget(Widget): - """Draggable rectangle overlay widget for 2-D plots. - - Parameters - ---------- - push_fn : Callable - Update callback. - x, y : float - Top-left corner position in pixel/data coordinates. - w, h : float - Width and height in pixel/data coordinates. - color : str, optional - CSS colour for the rectangle outline. Default ``"#00e5ff"``. - """ - def __init__(self, push_fn, *, x, y, w, h, color="#00e5ff"): - super().__init__("rectangle", push_fn, - x=float(x), y=float(y), - w=float(w), h=float(h), color=color) - - -class CircleWidget(Widget): - """Draggable circle overlay widget for 2-D plots. - - Parameters - ---------- - push_fn : Callable - Update callback. - cx, cy : float - Center position in pixel/data coordinates. - r : float - Radius in pixel/data coordinates. - color : str, optional - CSS colour for the circle outline. Default ``"#00e5ff"``. - """ - def __init__(self, push_fn, *, cx, cy, r, color="#00e5ff"): - super().__init__("circle", push_fn, - cx=float(cx), cy=float(cy), r=float(r), color=color) - - -class AnnularWidget(Widget): - """Draggable annular (ring) overlay widget for 2-D plots. - - Parameters - ---------- - push_fn : Callable - Update callback. - cx, cy : float - Center position in pixel/data coordinates. - r_outer, r_inner : float - Outer and inner radii in pixel/data coordinates. - Inner radius must be less than outer radius. - color : str, optional - CSS colour for the ring outline. Default ``"#00e5ff"``. - - Raises - ------ - ValueError - If r_inner >= r_outer. - """ - def __init__(self, push_fn, *, cx, cy, r_outer, r_inner, color="#00e5ff"): - if r_inner >= r_outer: - raise ValueError("r_inner must be < r_outer") - super().__init__("annular", push_fn, - cx=float(cx), cy=float(cy), - r_outer=float(r_outer), r_inner=float(r_inner), - color=color) - - -class CrosshairWidget(Widget): - """Draggable crosshair overlay widget for 2-D plots. - - Parameters - ---------- - push_fn : Callable - Update callback. - cx, cy : float - Center position in pixel/data coordinates. - color : str, optional - CSS colour for the crosshair. Default ``"#00e5ff"``. - """ - def __init__(self, push_fn, *, cx, cy, color="#00e5ff"): - super().__init__("crosshair", push_fn, - cx=float(cx), cy=float(cy), color=color) - - -class PolygonWidget(Widget): - """Draggable polygon overlay widget for 2-D plots. - - Parameters - ---------- - push_fn : Callable - Update callback. - vertices : list of (x, y) tuples - Polygon vertices in pixel/data coordinates. - Must have at least 3 vertices. - color : str, optional - CSS colour for the polygon outline. Default ``"#00e5ff"``. - - Raises - ------ - ValueError - If fewer than 3 vertices provided. - """ - def __init__(self, push_fn, *, vertices, color="#00e5ff"): - verts = [[float(x), float(y)] for x, y in vertices] - if len(verts) < 3: - raise ValueError("polygon needs >= 3 vertices") - super().__init__("polygon", push_fn, vertices=verts, color=color) - - -class LabelWidget(Widget): - """Text label overlay widget for 2-D plots. - - Parameters - ---------- - push_fn : Callable - Update callback. - x, y : float - Label position in pixel/data coordinates. - text : str, optional - Label text. Default ``"Label"``. - fontsize : int, optional - Font size in points. Default 14. - color : str, optional - CSS colour for the text. Default ``"#00e5ff"``. - """ - def __init__(self, push_fn, *, x, y, text="Label", fontsize=14, - color="#00e5ff"): - super().__init__("label", push_fn, - x=float(x), y=float(y), - text=str(text), fontsize=int(fontsize), color=color) - - -# --------------------------------------------------------------------------- -# 1-D widgets -# --------------------------------------------------------------------------- - -class VLineWidget(Widget): - """Draggable vertical line overlay widget for 1-D plots. - - Allows interactive selection of a single x-axis value. The line can be - dragged left/right to change the selected position. - - Parameters - ---------- - push_fn : Callable - Update callback. - x : float - Initial x-position in data coordinates. - color : str, optional - CSS colour for the line. Default ``"#00e5ff"``. - """ - def __init__(self, push_fn, *, x, color="#00e5ff"): - super().__init__("vline", push_fn, x=float(x), color=color) - - -class HLineWidget(Widget): - """Draggable horizontal line overlay widget for bar charts. - - Allows interactive selection of a single y-axis value. The line can be - dragged up/down to change the selected value. - - Parameters - ---------- - push_fn : Callable - Update callback. - y : float - Initial y-position in data coordinates. - color : str, optional - CSS colour for the line. Default ``"#00e5ff"``. - """ - def __init__(self, push_fn, *, y, color="#00e5ff"): - super().__init__("hline", push_fn, y=float(y), color=color) - - -class RangeWidget(Widget): - """Draggable range selection widget. - - Two display styles are available: - - ``style='band'`` (default) - Two connected vertical lines with a translucent fill band. Either - line can be dragged independently; the whole band can be dragged by - clicking inside it. - - ``style='fwhm'`` - Two circular handles joined by a dashed horizontal line drawn at - height *y* (the half-maximum level). Only the x-positions of the - handles are draggable. Use this to show/edit a FWHM interval on a - peak. - - Parameters - ---------- - push_fn : Callable - Update callback. - x0, x1 : float - Initial left and right positions in data coordinates. - color : str, optional - CSS colour. Default ``"#00e5ff"``. - style : {'band', 'fwhm'}, optional - Visual style. Default ``"band"``. - y : float, optional - Y-position (data coordinates) for the connecting line when - ``style='fwhm'``. Ignored for ``style='band'``. Default ``0.0``. - """ - def __init__(self, push_fn, *, x0, x1, color="#00e5ff", - style: str = "band", y: float = 0.0): - super().__init__("range", push_fn, - x0=float(x0), x1=float(x1), color=color, - style=str(style), y=float(y)) - - -class PointWidget(Widget): - """Draggable point (control point) overlay widget for 1-D plots. - - A free-moving handle that can be dragged to any position within the - plot area. Reports its data-space ``x`` and ``y`` coordinates back - to Python via the standard callback hooks. - - Parameters - ---------- - push_fn : Callable - Update callback. - x : float - Initial x position in data coordinates. - y : float - Initial y position in data coordinates (value axis). - color : str, optional - CSS colour for the handle. Default ``"#00e5ff"``. - show_crosshair : bool, optional - If ``True`` (default), draw dashed crosshair guide lines through the - handle. Set to ``False`` for a bare draggable dot with no guides. - """ - def __init__(self, push_fn, *, x, y, color="#00e5ff", show_crosshair=True): - super().__init__("point", push_fn, x=float(x), y=float(y), color=color, - show_crosshair=bool(show_crosshair)) diff --git a/anyplotlib/widgets/__init__.py b/anyplotlib/widgets/__init__.py new file mode 100644 index 00000000..a3e9972f --- /dev/null +++ b/anyplotlib/widgets/__init__.py @@ -0,0 +1,15 @@ +from anyplotlib.widgets._base import Widget +from anyplotlib.widgets._widgets2d import ( + RectangleWidget, CircleWidget, AnnularWidget, + CrosshairWidget, PolygonWidget, LabelWidget, +) +from anyplotlib.widgets._widgets1d import ( + VLineWidget, HLineWidget, RangeWidget, PointWidget, +) + +__all__ = [ + "Widget", + "RectangleWidget", "CircleWidget", "AnnularWidget", + "CrosshairWidget", "PolygonWidget", "LabelWidget", + "VLineWidget", "HLineWidget", "RangeWidget", "PointWidget", +] diff --git a/anyplotlib/widgets/_base.py b/anyplotlib/widgets/_base.py new file mode 100644 index 00000000..0db9c381 --- /dev/null +++ b/anyplotlib/widgets/_base.py @@ -0,0 +1,268 @@ +""" +widgets/_base.py +================ +Base Widget class shared by all interactive overlay widgets. +""" + +from __future__ import annotations +import uuid as _uuid +from typing import Any +from anyplotlib.callbacks import CallbackRegistry, Event + + +class Widget: + """Base class for all overlay widgets. + + Provides attribute-based state access, callbacks for interaction events, + and automatic synchronization with the JavaScript renderer. + + Parameters + ---------- + wtype : str + Widget type (e.g., 'rectangle', 'circle', 'crosshair'). + push_fn : Callable + Zero-arg callback to send position updates to the JavaScript renderer. + **kwargs : dict + Initial widget state (position, size, color, etc.). + + Attributes + ---------- + callbacks : CallbackRegistry + Event callback registry. Register handlers via: + - ``@widget.on_changed`` — fires on every drag frame + - ``@widget.on_release`` — fires once when drag settles + - ``@widget.on_click`` — fires on click event + """ + + def __init__(self, wtype: str, push_fn, **kwargs): + self._id: str = str(_uuid.uuid4())[:8] + self._type: str = wtype + self._data: dict = dict(kwargs) + self._data["id"] = self._id + self._data["type"] = wtype + self._push_fn = push_fn + self.callbacks: CallbackRegistry = CallbackRegistry() + + # ── attribute read ──────────────────────────────────────────────── + + def __getattr__(self, key: str): + """Access widget properties as attributes (read-only).""" + if key.startswith("_"): + raise AttributeError(key) + try: + return self._data[key] + except KeyError: + raise AttributeError( + f"{type(self).__name__} has no attribute {key!r}. " + f"Available: {list(self._data)}" + ) from None + + # ── attribute write — routes public assignments through set() ──── + + def __setattr__(self, key: str, value) -> None: + """Update widget properties via attribute assignment.""" + # Private attrs and 'callbacks' bypass set() + if key.startswith("_") or key == "callbacks": + super().__setattr__(key, value) + return + # During __init__ _data may not exist yet + try: + object.__getattribute__(self, "_data") + except AttributeError: + super().__setattr__(key, value) + return + self.set(**{key: value}) + + # ── set / get ───────────────────────────────────────────────────── + + def set(self, _push: bool = True, **kwargs) -> None: + """Update properties and send targeted update to JavaScript. + + Parameters + ---------- + _push : bool, optional + Whether to push update to renderer. Default True. + Set to False internally to avoid echo loops. + **kwargs : dict + Properties to update (e.g., x=100, y=50, radius=20). + + Notes + ----- + Updates are sent as targeted widget updates, not full panel re-renders. + This is more efficient for frequent updates during dragging. + """ + self._data.update(kwargs) + if _push: + self._push_fn() + self.callbacks.fire(Event("on_changed", source=self, data=dict(self._data))) + + def get(self, key: str, default=None): + """Get a widget property by name. + + Parameters + ---------- + key : str + Property name. + default : optional + Default value if property not found. + + Returns + ------- + object + The property value. + """ + return self._data.get(key, default) + + def to_dict(self) -> dict: + """Return a dict copy of the widget state. + + Returns + ------- + dict + All widget properties including id and type. + """ + return dict(self._data) + + # ── callback decorator methods ──────────────────────────────────── + + def on_changed(self, fn) -> Any: + """Decorator: register fn to fire on every drag frame. + + Use this for high-frequency updates (keep handler fast). + + Parameters + ---------- + fn : Callable + Handler function receiving an Event. + + Returns + ------- + Callable + The decorated function. + """ + cid = self.callbacks.connect("on_changed", fn) + fn._cid = cid + return fn + + def on_release(self, fn) -> Any: + """Decorator: register fn to fire once when drag settles. + + Use this for expensive operations triggered after user stops dragging. + + Parameters + ---------- + fn : Callable + Handler function receiving an Event. + + Returns + ------- + Callable + The decorated function. + """ + cid = self.callbacks.connect("on_release", fn) + fn._cid = cid + return fn + + def on_click(self, fn) -> Any: + """Decorator: register fn to fire on widget click. + + Parameters + ---------- + fn : Callable + Handler function receiving an Event. + + Returns + ------- + Callable + The decorated function. + """ + cid = self.callbacks.connect("on_click", fn) + fn._cid = cid + return fn + + def disconnect(self, cid) -> None: + """Remove the callback registered under *cid*. + + Parameters + ---------- + cid : int or Callable + Either the integer CID returned by ``callbacks.connect()``, + or the decorated function itself (carries a ``._cid`` attribute). + """ + if callable(cid) and hasattr(cid, "_cid"): + cid = cid._cid + self.callbacks.disconnect(cid) + + # ── visibility ──────────────────────────────────────────────────────── + + @property + def visible(self) -> bool: + """``True`` if the widget is rendered; ``False`` if hidden.""" + return self._data.get("visible", True) + + @visible.setter + def visible(self, value: bool) -> None: + self.show() if value else self.hide() + + def show(self) -> None: + """Show the widget. Does not fire ``on_changed`` callbacks.""" + self._data["visible"] = True + self._push_fn() + + def hide(self) -> None: + """Hide the widget without removing it or its callbacks. + + Call :meth:`show` to make it visible again. + Does not fire ``on_changed`` callbacks. + """ + self._data["visible"] = False + self._push_fn() + + # ── JS → Python sync ────────────────────────────────────────────── + + def _update_from_js(self, new_data: dict, event_type: str = "on_changed") -> bool: + """Apply incoming JS state without pushing back (avoids echo). + + Parameters + ---------- + new_data : dict + Updated widget properties from JavaScript. + event_type : str, optional + Type of event that triggered the update. + + Returns + ------- + bool + True if any state changed. + + Notes + ----- + Always fires on_release / on_click callbacks even if nothing changed. + Only fires on_changed if state actually changed. + """ + changed = False + for k, v in new_data.items(): + if k in ("id", "type"): + continue + if self._data.get(k) != v: + self._data[k] = v + changed = True + # Always fire for settle / click; only fire on_changed when something moved + if changed or event_type in ("on_release", "on_click"): + self.callbacks.fire(Event(event_type, source=self, data=dict(self._data))) + return changed + + # ── repr ────────────────────────────────────────────────────────── + + def __repr__(self) -> str: + props = ", ".join( + f"{k}={v:.4g}" if isinstance(v, float) else f"{k}={v!r}" + for k, v in self._data.items() + if k not in ("id", "type", "color") + ) + return f"{type(self).__name__}({props})" + + @property + def id(self) -> str: + """Return the widget's unique identifier.""" + return self._id diff --git a/anyplotlib/widgets/_widgets1d.py b/anyplotlib/widgets/_widgets1d.py new file mode 100644 index 00000000..f25141db --- /dev/null +++ b/anyplotlib/widgets/_widgets1d.py @@ -0,0 +1,109 @@ +""" +widgets/_widgets1d.py +===================== +Interactive overlay widgets for 1-D line panels (Plot1D). +""" + +from __future__ import annotations +from anyplotlib.widgets._base import Widget + + +class VLineWidget(Widget): + """Draggable vertical line overlay widget for 1-D plots. + + Allows interactive selection of a single x-axis value. The line can be + dragged left/right to change the selected position. + + Parameters + ---------- + push_fn : Callable + Update callback. + x : float + Initial x-position in data coordinates. + color : str, optional + CSS colour for the line. Default ``"#00e5ff"``. + """ + def __init__(self, push_fn, *, x, color="#00e5ff"): + super().__init__("vline", push_fn, x=float(x), color=color) + + +class HLineWidget(Widget): + """Draggable horizontal line overlay widget for bar charts. + + Allows interactive selection of a single y-axis value. The line can be + dragged up/down to change the selected value. + + Parameters + ---------- + push_fn : Callable + Update callback. + y : float + Initial y-position in data coordinates. + color : str, optional + CSS colour for the line. Default ``"#00e5ff"``. + """ + def __init__(self, push_fn, *, y, color="#00e5ff"): + super().__init__("hline", push_fn, y=float(y), color=color) + + +class RangeWidget(Widget): + """Draggable range selection widget. + + Two display styles are available: + + ``style='band'`` (default) + Two connected vertical lines with a translucent fill band. Either + line can be dragged independently; the whole band can be dragged by + clicking inside it. + + ``style='fwhm'`` + Two circular handles joined by a dashed horizontal line drawn at + height *y* (the half-maximum level). Only the x-positions of the + handles are draggable. Use this to show/edit a FWHM interval on a + peak. + + Parameters + ---------- + push_fn : Callable + Update callback. + x0, x1 : float + Initial left and right positions in data coordinates. + color : str, optional + CSS colour. Default ``"#00e5ff"``. + style : {'band', 'fwhm'}, optional + Visual style. Default ``"band"``. + y : float, optional + Y-position (data coordinates) for the connecting line when + ``style='fwhm'``. Ignored for ``style='band'``. Default ``0.0``. + """ + def __init__(self, push_fn, *, x0, x1, color="#00e5ff", + style: str = "band", y: float = 0.0): + super().__init__("range", push_fn, + x0=float(x0), x1=float(x1), color=color, + style=str(style), y=float(y)) + + +class PointWidget(Widget): + """Draggable point (control point) overlay widget for 1-D plots. + + A free-moving handle that can be dragged to any position within the + plot area. Reports its data-space ``x`` and ``y`` coordinates back + to Python via the standard callback hooks. + + Parameters + ---------- + push_fn : Callable + Update callback. + x : float + Initial x position in data coordinates. + y : float + Initial y position in data coordinates (value axis). + color : str, optional + CSS colour for the handle. Default ``"#00e5ff"``. + show_crosshair : bool, optional + If ``True`` (default), draw dashed crosshair guide lines through the + handle. Set to ``False`` for a bare draggable dot with no guides. + """ + def __init__(self, push_fn, *, x, y, color="#00e5ff", show_crosshair=True): + super().__init__("point", push_fn, x=float(x), y=float(y), color=color, + show_crosshair=bool(show_crosshair)) diff --git a/anyplotlib/widgets/_widgets2d.py b/anyplotlib/widgets/_widgets2d.py new file mode 100644 index 00000000..04537bfe --- /dev/null +++ b/anyplotlib/widgets/_widgets2d.py @@ -0,0 +1,141 @@ +""" +widgets/_widgets2d.py +===================== +Interactive overlay widgets for 2-D image panels (Plot2D / InsetAxes). +""" + +from __future__ import annotations +from anyplotlib.widgets._base import Widget + + +class RectangleWidget(Widget): + """Draggable rectangle overlay widget for 2-D plots. + + Parameters + ---------- + push_fn : Callable + Update callback. + x, y : float + Top-left corner position in pixel/data coordinates. + w, h : float + Width and height in pixel/data coordinates. + color : str, optional + CSS colour for the rectangle outline. Default ``"#00e5ff"``. + """ + def __init__(self, push_fn, *, x, y, w, h, color="#00e5ff"): + super().__init__("rectangle", push_fn, + x=float(x), y=float(y), + w=float(w), h=float(h), color=color) + + +class CircleWidget(Widget): + """Draggable circle overlay widget for 2-D plots. + + Parameters + ---------- + push_fn : Callable + Update callback. + cx, cy : float + Center position in pixel/data coordinates. + r : float + Radius in pixel/data coordinates. + color : str, optional + CSS colour for the circle outline. Default ``"#00e5ff"``. + """ + def __init__(self, push_fn, *, cx, cy, r, color="#00e5ff"): + super().__init__("circle", push_fn, + cx=float(cx), cy=float(cy), r=float(r), color=color) + + +class AnnularWidget(Widget): + """Draggable annular (ring) overlay widget for 2-D plots. + + Parameters + ---------- + push_fn : Callable + Update callback. + cx, cy : float + Center position in pixel/data coordinates. + r_outer, r_inner : float + Outer and inner radii in pixel/data coordinates. + Inner radius must be less than outer radius. + color : str, optional + CSS colour for the ring outline. Default ``"#00e5ff"``. + + Raises + ------ + ValueError + If r_inner >= r_outer. + """ + def __init__(self, push_fn, *, cx, cy, r_outer, r_inner, color="#00e5ff"): + if r_inner >= r_outer: + raise ValueError("r_inner must be < r_outer") + super().__init__("annular", push_fn, + cx=float(cx), cy=float(cy), + r_outer=float(r_outer), r_inner=float(r_inner), + color=color) + + +class CrosshairWidget(Widget): + """Draggable crosshair overlay widget for 2-D plots. + + Parameters + ---------- + push_fn : Callable + Update callback. + cx, cy : float + Center position in pixel/data coordinates. + color : str, optional + CSS colour for the crosshair. Default ``"#00e5ff"``. + """ + def __init__(self, push_fn, *, cx, cy, color="#00e5ff"): + super().__init__("crosshair", push_fn, + cx=float(cx), cy=float(cy), color=color) + + +class PolygonWidget(Widget): + """Draggable polygon overlay widget for 2-D plots. + + Parameters + ---------- + push_fn : Callable + Update callback. + vertices : list of (x, y) tuples + Polygon vertices in pixel/data coordinates. + Must have at least 3 vertices. + color : str, optional + CSS colour for the polygon outline. Default ``"#00e5ff"``. + + Raises + ------ + ValueError + If fewer than 3 vertices provided. + """ + def __init__(self, push_fn, *, vertices, color="#00e5ff"): + verts = [[float(x), float(y)] for x, y in vertices] + if len(verts) < 3: + raise ValueError("polygon needs >= 3 vertices") + super().__init__("polygon", push_fn, vertices=verts, color=color) + + +class LabelWidget(Widget): + """Text label overlay widget for 2-D plots. + + Parameters + ---------- + push_fn : Callable + Update callback. + x, y : float + Label position in pixel/data coordinates. + text : str, optional + Label text. Default ``"Label"``. + fontsize : int, optional + Font size in points. Default 14. + color : str, optional + CSS colour for the text. Default ``"#00e5ff"``. + """ + def __init__(self, push_fn, *, x, y, text="Label", fontsize=14, + color="#00e5ff"): + super().__init__("label", push_fn, + x=float(x), y=float(y), + text=str(text), fontsize=int(fontsize), color=color) From 20d01fcc21f38938d4c80dd99aac824712ccc852 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 10 May 2026 22:40:18 -0500 Subject: [PATCH 099/198] fix: restore Callable type annotations in widgets/_base.py --- anyplotlib/widgets/_base.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/anyplotlib/widgets/_base.py b/anyplotlib/widgets/_base.py index 0db9c381..700b64fa 100644 --- a/anyplotlib/widgets/_base.py +++ b/anyplotlib/widgets/_base.py @@ -6,7 +6,7 @@ from __future__ import annotations import uuid as _uuid -from typing import Any +from typing import Any, Callable from anyplotlib.callbacks import CallbackRegistry, Event @@ -34,13 +34,13 @@ class Widget: - ``@widget.on_click`` — fires on click event """ - def __init__(self, wtype: str, push_fn, **kwargs): + def __init__(self, wtype: str, push_fn: Callable, **kwargs): self._id: str = str(_uuid.uuid4())[:8] self._type: str = wtype self._data: dict = dict(kwargs) self._data["id"] = self._id self._data["type"] = wtype - self._push_fn = push_fn + self._push_fn: Callable = push_fn self.callbacks: CallbackRegistry = CallbackRegistry() # ── attribute read ──────────────────────────────────────────────── @@ -125,7 +125,7 @@ def to_dict(self) -> dict: # ── callback decorator methods ──────────────────────────────────── - def on_changed(self, fn) -> Any: + def on_changed(self, fn: Callable) -> Callable: """Decorator: register fn to fire on every drag frame. Use this for high-frequency updates (keep handler fast). @@ -144,7 +144,7 @@ def on_changed(self, fn) -> Any: fn._cid = cid return fn - def on_release(self, fn) -> Any: + def on_release(self, fn: Callable) -> Callable: """Decorator: register fn to fire once when drag settles. Use this for expensive operations triggered after user stops dragging. @@ -163,7 +163,7 @@ def on_release(self, fn) -> Any: fn._cid = cid return fn - def on_click(self, fn) -> Any: + def on_click(self, fn: Callable) -> Callable: """Decorator: register fn to fire on widget click. Parameters From 96783f635defef740a46b3ae25f4f616e99f1425 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 10 May 2026 22:44:16 -0500 Subject: [PATCH 100/198] refactor: extract shared helpers into _utils.py Moves _LINESTYLE_ALIASES, _arr_to_b64, _norm_linestyle, _normalize_image, _CMAP_ALIASES, _build_colormap_lut, and _resample_mesh out of figure_plots.py into a new anyplotlib/_utils.py. Backward-compat re-imports are added to figure_plots.py so all existing callers continue to work unchanged. --- anyplotlib/_utils.py | 172 +++++++++++++++++++++++++++++++++++ anyplotlib/figure_plots.py | 178 ++----------------------------------- 2 files changed, 179 insertions(+), 171 deletions(-) create mode 100644 anyplotlib/_utils.py diff --git a/anyplotlib/_utils.py b/anyplotlib/_utils.py new file mode 100644 index 00000000..15284db6 --- /dev/null +++ b/anyplotlib/_utils.py @@ -0,0 +1,172 @@ +""" +_utils.py +========= +Shared low-level utilities used across plot subpackages. +""" + +from __future__ import annotations + +import base64 + +import numpy as np + +_LINESTYLE_ALIASES: dict[str, str] = { + "-": "solid", + "--": "dashed", + ":": "dotted", + "-.": "dashdot", + "solid": "solid", + "dashed": "dashed", + "dotted": "dotted", + "dashdot": "dashdot", +} + + +def _arr_to_b64(arr: np.ndarray, dtype) -> str: + """Encode a NumPy array as base-64 (little-endian raw bytes). + + Uses little-endian byte order so the result is compatible with + JavaScript's ``Float64Array`` / ``Float32Array`` / ``Int32Array`` + on all modern platforms (x86, ARM). + """ + import base64 + le_dtype = np.dtype(dtype).newbyteorder("<") + return base64.b64encode(np.asarray(arr).astype(le_dtype).tobytes()).decode("ascii") + + +def _norm_linestyle(ls: str) -> str: + """Normalise a linestyle name or shorthand to its canonical form. + + Accepted values + --------------- + ``"solid"`` / ``"-"``, ``"dashed"`` / ``"--"``, + ``"dotted"`` / ``":"``, ``"dashdot"`` / ``"-."``. + + Raises + ------ + ValueError + If *ls* is not a recognised name or shorthand. + """ + canonical = _LINESTYLE_ALIASES.get(ls) + if canonical is None: + raise ValueError( + f"Unknown linestyle {ls!r}. Expected one of: " + "'solid', 'dashed', 'dotted', 'dashdot', " + "or shorthands '-', '--', ':', '-.'." + ) + return canonical + + +def _normalize_image(data: np.ndarray): + """Normalise data to uint8, returning (img_u8, vmin, vmax).""" + img = data.astype(np.float64, copy=False) + vmin = float(np.nanmin(img)) + vmax = float(np.nanmax(img)) + if vmax > vmin: + buf = np.empty_like(img) + np.subtract(img, vmin, out=buf) + np.divide(buf, vmax - vmin, out=buf) + np.multiply(buf, 255.0, out=buf) + img_u8 = buf.astype(np.uint8) + else: + img_u8 = np.zeros(data.shape, dtype=np.uint8) + return img_u8, vmin, vmax + + +# Mapping from common matplotlib colormap names to their nearest colorcet +# equivalents so callers can keep using familiar names without any matplotlib +# dependency. +_CMAP_ALIASES: dict[str, str] = { + "viridis": "bmy", # blue→magenta→yellow, perceptually uniform + "plasma": "fire", # warm sequential (dark→bright) + "inferno": "kb", # dark→blue→white + "magma": "kbc", # dark→blue→cyan sequential + "cividis": "bgy", # accessible, blue→green→yellow sequential + "hot": "fire", + "afmhot": "fire", + "jet": "rainbow4", + "hsv": "rainbow4", + "nipy_spectral": "rainbow4", + "RdBu": "coolwarm", + "bwr": "cwr", # blue→white→red diverging + "seismic": "coolwarm", +} + + +def _build_colormap_lut(name: str) -> list: + """Return a 256-entry ``[[r, g, b], ...]`` LUT for the named colormap. + + Priority order: + + 1. **colorcet** — preferred; common matplotlib names are remapped via + :data:`_CMAP_ALIASES` so callers can use ``"viridis"`` etc. + 2. **matplotlib** — fallback when colorcet is not installed (e.g. in + Pyodide before micropip finishes, or in minimal test environments). + 3. **Built-in gray ramp** — final fallback for unknown names. + """ + # ── 1. Try colorcet ─────────────────────────────────────────────────── + try: + import colorcet as cc + resolved = _CMAP_ALIASES.get(name, name) + palette = cc.palette.get(resolved) + if palette is not None: + n = len(palette) + lut: list = [] + for i in range(256): + h = palette[int(round(i * (n - 1) / 255))].lstrip("#") + lut.append([int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)]) + return lut + except Exception: + pass + + # ── 2. Try matplotlib ───────────────────────────────────────────────── + try: + import matplotlib.cm as _cm + import numpy as _np + cmap = _cm.get_cmap(name, 256) + rgba = cmap(_np.linspace(0, 1, 256)) + return [[int(r * 255), int(g * 255), int(b * 255)] + for r, g, b, _ in rgba] + except Exception: + pass + + # ── 3. Gray ramp fallback ───────────────────────────────────────────── + return [[v, v, v] for v in range(256)] + + +def _resample_mesh(data: np.ndarray, x_edges, y_edges) -> np.ndarray: + """Resample a mesh to a regular pixel grid via nearest-neighbour lookup. + + For uniform edges this is an identity operation. For non-uniform edges + (e.g. log-spaced) it maps each uniform output pixel to the nearest input + cell, producing a visually correct linear-axis image. + + Parameters + ---------- + data : ndarray, shape (M, N) — one value per mesh cell. + x_edges : array-like, length N+1 — column edge coordinates. + y_edges : array-like, length M+1 — row edge coordinates. + + Returns + ------- + ndarray, shape (M, N) + """ + rows, cols = data.shape + x_edges = np.asarray(x_edges, dtype=float) + y_edges = np.asarray(y_edges, dtype=float) + + # Cell centres + x_c = (x_edges[:-1] + x_edges[1:]) / 2.0 + y_c = (y_edges[:-1] + y_edges[1:]) / 2.0 + + # Uniform sample points (same count as original cells) + x_samp = np.linspace(x_c[0], x_c[-1], cols) + y_samp = np.linspace(y_c[0], y_c[-1], rows) + + # Nearest-neighbour cell lookup via edge-sorted searchsorted + xi = np.searchsorted(x_edges, x_samp) - 1 + xi = np.clip(xi, 0, cols - 1) + yi = np.searchsorted(y_edges, y_samp) - 1 + yi = np.clip(yi, 0, rows - 1) + + return data[np.ix_(yi, xi)] diff --git a/anyplotlib/figure_plots.py b/anyplotlib/figure_plots.py index f0539a70..199e609a 100644 --- a/anyplotlib/figure_plots.py +++ b/anyplotlib/figure_plots.py @@ -34,61 +34,17 @@ PointWidget as _PointWidget, ) +# Backward compatibility: helpers now live in _utils +from anyplotlib._utils import ( # noqa: F401 + _arr_to_b64, _norm_linestyle, _normalize_image, + _build_colormap_lut, _resample_mesh, + _LINESTYLE_ALIASES, _CMAP_ALIASES, +) + __all__ = ["GridSpec", "SubplotSpec", "Axes", "InsetAxes", "Line1D", "Plot1D", "Plot2D", "PlotMesh", "Plot3D", "PlotBar", "_plot_kind", "_resample_mesh", "_norm_linestyle"] -# --------------------------------------------------------------------------- -# Linestyle normalisation -# --------------------------------------------------------------------------- - -_LINESTYLE_ALIASES: dict[str, str] = { - "-": "solid", - "--": "dashed", - ":": "dotted", - "-.": "dashdot", - "solid": "solid", - "dashed": "dashed", - "dotted": "dotted", - "dashdot": "dashdot", -} - - -def _arr_to_b64(arr: np.ndarray, dtype) -> str: - """Encode a NumPy array as base-64 (little-endian raw bytes). - - Uses little-endian byte order so the result is compatible with - JavaScript's ``Float64Array`` / ``Float32Array`` / ``Int32Array`` - on all modern platforms (x86, ARM). - """ - import base64 - le_dtype = np.dtype(dtype).newbyteorder("<") - return base64.b64encode(np.asarray(arr).astype(le_dtype).tobytes()).decode("ascii") - - -def _norm_linestyle(ls: str) -> str: - """Normalise a linestyle name or shorthand to its canonical form. - - Accepted values - --------------- - ``"solid"`` / ``"-"``, ``"dashed"`` / ``"--"``, - ``"dotted"`` / ``":"``, ``"dashdot"`` / ``"-."``. - - Raises - ------ - ValueError - If *ls* is not a recognised name or shorthand. - """ - canonical = _LINESTYLE_ALIASES.get(ls) - if canonical is None: - raise ValueError( - f"Unknown linestyle {ls!r}. Expected one of: " - "'solid', 'dashed', 'dotted', 'dashdot', " - "or shorthands '-', '--', ':', '-.'." - ) - return canonical - - # --------------------------------------------------------------------------- # GridSpec / SubplotSpec # --------------------------------------------------------------------------- @@ -564,126 +520,6 @@ def __repr__(self) -> str: return f"Axes(rows={self._spec.row_start}:{self._spec.row_stop}, cols={self._spec.col_start}:{self._spec.col_stop}, {kind})" -# --------------------------------------------------------------------------- -# Shared normalisation helpers (duplicated from Viewer2D to keep standalone) -# --------------------------------------------------------------------------- - -def _normalize_image(data: np.ndarray): - """Normalise data to uint8, returning (img_u8, vmin, vmax).""" - img = data.astype(np.float64, copy=False) - vmin = float(np.nanmin(img)) - vmax = float(np.nanmax(img)) - if vmax > vmin: - buf = np.empty_like(img) - np.subtract(img, vmin, out=buf) - np.divide(buf, vmax - vmin, out=buf) - np.multiply(buf, 255.0, out=buf) - img_u8 = buf.astype(np.uint8) - else: - img_u8 = np.zeros(data.shape, dtype=np.uint8) - return img_u8, vmin, vmax - - - -# Mapping from common matplotlib colormap names to their nearest colorcet -# equivalents so callers can keep using familiar names without any matplotlib -# dependency. -_CMAP_ALIASES: dict[str, str] = { - "viridis": "bmy", # blue→magenta→yellow, perceptually uniform - "plasma": "fire", # warm sequential (dark→bright) - "inferno": "kb", # dark→blue→white - "magma": "kbc", # dark→blue→cyan sequential - "cividis": "bgy", # accessible, blue→green→yellow sequential - "hot": "fire", - "afmhot": "fire", - "jet": "rainbow4", - "hsv": "rainbow4", - "nipy_spectral": "rainbow4", - "RdBu": "coolwarm", - "bwr": "cwr", # blue→white→red diverging - "seismic": "coolwarm", -} - - -def _build_colormap_lut(name: str) -> list: - """Return a 256-entry ``[[r, g, b], ...]`` LUT for the named colormap. - - Priority order: - - 1. **colorcet** — preferred; common matplotlib names are remapped via - :data:`_CMAP_ALIASES` so callers can use ``"viridis"`` etc. - 2. **matplotlib** — fallback when colorcet is not installed (e.g. in - Pyodide before micropip finishes, or in minimal test environments). - 3. **Built-in gray ramp** — final fallback for unknown names. - """ - # ── 1. Try colorcet ─────────────────────────────────────────────────── - try: - import colorcet as cc - resolved = _CMAP_ALIASES.get(name, name) - palette = cc.palette.get(resolved) - if palette is not None: - n = len(palette) - lut: list = [] - for i in range(256): - h = palette[int(round(i * (n - 1) / 255))].lstrip("#") - lut.append([int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)]) - return lut - except Exception: - pass - - # ── 2. Try matplotlib ───────────────────────────────────────────────── - try: - import matplotlib.cm as _cm - import numpy as _np - cmap = _cm.get_cmap(name, 256) - rgba = cmap(_np.linspace(0, 1, 256)) - return [[int(r * 255), int(g * 255), int(b * 255)] - for r, g, b, _ in rgba] - except Exception: - pass - - # ── 3. Gray ramp fallback ───────────────────────────────────────────── - return [[v, v, v] for v in range(256)] - - -def _resample_mesh(data: np.ndarray, x_edges, y_edges) -> np.ndarray: - """Resample a mesh to a regular pixel grid via nearest-neighbour lookup. - - For uniform edges this is an identity operation. For non-uniform edges - (e.g. log-spaced) it maps each uniform output pixel to the nearest input - cell, producing a visually correct linear-axis image. - - Parameters - ---------- - data : ndarray, shape (M, N) — one value per mesh cell. - x_edges : array-like, length N+1 — column edge coordinates. - y_edges : array-like, length M+1 — row edge coordinates. - - Returns - ------- - ndarray, shape (M, N) - """ - rows, cols = data.shape - x_edges = np.asarray(x_edges, dtype=float) - y_edges = np.asarray(y_edges, dtype=float) - - # Cell centres - x_c = (x_edges[:-1] + x_edges[1:]) / 2.0 - y_c = (y_edges[:-1] + y_edges[1:]) / 2.0 - - # Uniform sample points (same count as original cells) - x_samp = np.linspace(x_c[0], x_c[-1], cols) - y_samp = np.linspace(y_c[0], y_c[-1], rows) - - # Nearest-neighbour cell lookup via edge-sorted searchsorted - xi = np.searchsorted(x_edges, x_samp) - 1 - xi = np.clip(xi, 0, cols - 1) - yi = np.searchsorted(y_edges, y_samp) - 1 - yi = np.clip(yi, 0, rows - 1) - - return data[np.ix_(yi, xi)] - - # --------------------------------------------------------------------------- # Plot2D # --------------------------------------------------------------------------- From fa91ab885e5129c449aae21bcf2072b95459b7c4 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 10 May 2026 22:47:40 -0500 Subject: [PATCH 101/198] fix: remove dead top-level import base64 from _utils.py --- anyplotlib/_utils.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/anyplotlib/_utils.py b/anyplotlib/_utils.py index 15284db6..501de5db 100644 --- a/anyplotlib/_utils.py +++ b/anyplotlib/_utils.py @@ -6,8 +6,6 @@ from __future__ import annotations -import base64 - import numpy as np _LINESTYLE_ALIASES: dict[str, str] = { From ef2dc167777974732c07712195a944a706e96ec2 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 11 May 2026 07:34:50 -0500 Subject: [PATCH 102/198] refactor: extract Plot3D into plot3d/ subpackage Moves Plot3D and _triangulate_grid out of figure_plots.py into the new anyplotlib/plot3d/ subpackage. A backward-compat shim in figure_plots.py re-exports Plot3D so existing imports continue to work. --- anyplotlib/figure_plots.py | 268 +------------------------------- anyplotlib/plot3d/__init__.py | 3 + anyplotlib/plot3d/_plot3d.py | 277 ++++++++++++++++++++++++++++++++++ 3 files changed, 281 insertions(+), 267 deletions(-) create mode 100644 anyplotlib/plot3d/__init__.py create mode 100644 anyplotlib/plot3d/_plot3d.py diff --git a/anyplotlib/figure_plots.py b/anyplotlib/figure_plots.py index 199e609a..3319106e 100644 --- a/anyplotlib/figure_plots.py +++ b/anyplotlib/figure_plots.py @@ -40,6 +40,7 @@ _build_colormap_lut, _resample_mesh, _LINESTYLE_ALIASES, _CMAP_ALIASES, ) +from anyplotlib.plot3d import Plot3D # noqa: F401 __all__ = ["GridSpec", "SubplotSpec", "Axes", "InsetAxes", "Line1D", "Plot1D", "Plot2D", "PlotMesh", "Plot3D", "PlotBar", "_plot_kind", "_resample_mesh", "_norm_linestyle"] @@ -1282,273 +1283,6 @@ def set_data(self, data: np.ndarray, self._push() -# --------------------------------------------------------------------------- -# _triangulate_grid helper + Plot3D -# --------------------------------------------------------------------------- - -def _triangulate_grid(rows: int, cols: int) -> list: - """Return a flat list of [i0, i1, i2] triangle indices for an (rows×cols) grid.""" - faces = [] - for r in range(rows - 1): - for c in range(cols - 1): - i = r * cols + c - faces.append([i, i + 1, i + cols]) - faces.append([i + 1, i + cols + 1, i + cols]) - return faces - - -class Plot3D: - """3-D plot panel. - - Supports three geometry types matching matplotlib's 3-D Axes API: - - * ``'surface'`` – triangulated surface, Z-coloured via colormap. - * ``'scatter'`` – point cloud, single colour. - * ``'line'`` – connected line through 3-D points. - - Created by :meth:`Axes.plot_surface`, :meth:`Axes.scatter3d`, - and :meth:`Axes.plot3d`. - - Not an anywidget. Holds state in ``_state`` dict; every mutation - calls ``_push()`` which writes to the parent Figure's panel trait. - """ - - def __init__(self, geom_type: str, - x, y, z, *, - colormap: str = "viridis", - color: str = "#4fc3f7", - point_size: float = 4.0, - linewidth: float = 1.5, - x_label: str = "x", - y_label: str = "y", - z_label: str = "z", - azimuth: float = -60.0, - elevation: float = 30.0, - zoom: float = 1.0): - self._id: str = "" - self._fig: object = None - - geom_type = geom_type.lower() - if geom_type not in ("surface", "scatter", "line"): - raise ValueError("geom_type must be 'surface', 'scatter', or 'line'") - - x = np.asarray(x, dtype=float) - y = np.asarray(y, dtype=float) - z = np.asarray(z, dtype=float) - - if geom_type == "surface": - # Accept 2-D grid arrays (meshgrid style) or 1-D flat arrays - if x.ndim == 2 and y.ndim == 2 and z.ndim == 2: - rows, cols = z.shape - xf, yf, zf = x.ravel(), y.ravel(), z.ravel() - elif x.ndim == 1 and y.ndim == 1 and z.ndim == 2: - rows, cols = z.shape - if len(x) != cols or len(y) != rows: - raise ValueError( - "For surface with 1-D x/y: x must have length ncols " - "and y must have length nrows") - XX, YY = np.meshgrid(x, y) - xf, yf, zf = XX.ravel(), YY.ravel(), z.ravel() - else: - raise ValueError( - "Surface x/y/z must be 2-D grids of the same shape, " - "or 1-D x/y centre arrays with 2-D z.") - faces_list = _triangulate_grid(rows, cols) - else: - if x.ndim != 1 or y.ndim != 1 or z.ndim != 1: - raise ValueError("scatter/line x, y, z must be 1-D arrays") - if not (len(x) == len(y) == len(z)): - raise ValueError("x, y, z must have the same length") - xf, yf, zf = x, y, z - faces_list = [] - - # Normalised data bounds for the JS renderer (from raw arrays — fast) - data_bounds = { - "xmin": float(xf.min()), "xmax": float(xf.max()), - "ymin": float(yf.min()), "ymax": float(yf.max()), - "zmin": float(zf.min()), "zmax": float(zf.max()), - } - - # Encode geometry as b64 (float32 saves 50 % wire size vs float64) - verts_arr = np.column_stack([xf, yf, zf]).astype(np.float32) # (N, 3) - zvals_arr = zf.astype(np.float32) # (N,) - faces_arr = (np.asarray(faces_list, dtype=np.int32).reshape(-1, 3) - if faces_list else np.empty((0, 3), dtype=np.int32)) - - cmap_lut = _build_colormap_lut(colormap) - - self._state: dict = { - "kind": "3d", - "geom_type": geom_type, - "vertices_b64": _arr_to_b64(verts_arr, np.float32), - "vertices_count": len(verts_arr), - "faces_b64": _arr_to_b64(faces_arr, np.int32), - "faces_count": len(faces_arr), - "z_values_b64": _arr_to_b64(zvals_arr, np.float32), - "colormap_name": colormap, - "colormap_data": cmap_lut, - "color": color, - "point_size": float(point_size), - "linewidth": float(linewidth), - "x_label": x_label, - "y_label": y_label, - "z_label": z_label, - "azimuth": float(azimuth), - "elevation": float(elevation), - "zoom": float(zoom), - "data_bounds": data_bounds, - "registered_keys": [], - } - self.callbacks = CallbackRegistry() - - # ------------------------------------------------------------------ - def _push(self) -> None: - if self._fig is None: - return - self._fig._push(self._id) - - def to_state_dict(self) -> dict: - return dict(self._state) - - # ------------------------------------------------------------------ - # Callback API (Plot3D) - # ------------------------------------------------------------------ - def on_changed(self, fn: Callable) -> Callable: - """Decorator: fires on every rotation/zoom frame.""" - cid = self.callbacks.connect("on_changed", fn) - fn._cid = cid - return fn - - def on_release(self, fn: Callable) -> Callable: - """Decorator: fires once when rotation/zoom settles.""" - cid = self.callbacks.connect("on_release", fn) - fn._cid = cid - return fn - - def on_click(self, fn: Callable) -> Callable: - """Decorator: fires on click on this panel.""" - cid = self.callbacks.connect("on_click", fn) - fn._cid = cid - return fn - - def on_key(self, key_or_fn=None) -> Callable: - """Register a key-press handler for this panel. - - Two call forms are supported:: - - @plot.on_key('q') # fires only when 'q' is pressed - def handler(event): ... - - @plot.on_key # fires for every registered key - def handler(event): ... - - The event carries: ``key``, ``mouse_x``, ``mouse_y``, and - ``last_widget_id``. - - .. note:: - Registered keys take priority over the built-in **r** (reset view) - shortcut. - """ - if callable(key_or_fn): - return self._connect_on_key(None, key_or_fn) - key = key_or_fn - def _decorator(fn): - return self._connect_on_key(key, fn) - return _decorator - - def _connect_on_key(self, key, fn) -> Callable: - if key is None: - if '*' not in self._state['registered_keys']: - self._state['registered_keys'].append('*') - self._push() - cid = self.callbacks.connect("on_key", fn) - else: - if key not in self._state['registered_keys']: - self._state['registered_keys'].append(key) - self._push() - def _wrapped(event): - if event.data.get('key') == key: - fn(event) - cid = self.callbacks.connect("on_key", _wrapped) - _wrapped._cid = cid - fn._cid = cid - return fn - - def disconnect(self, cid: int) -> None: - """Remove the callback registered under integer *cid*.""" - self.callbacks.disconnect(cid) - - # ------------------------------------------------------------------ - # Display settings - # ------------------------------------------------------------------ - def set_colormap(self, name: str) -> None: - """Set the surface colormap (ignored for scatter/line).""" - self._state["colormap_name"] = name - self._state["colormap_data"] = _build_colormap_lut(name) - self._push() - - def set_view(self, azimuth: float | None = None, - elevation: float | None = None) -> None: - """Set the camera azimuth (°) and/or elevation (°).""" - if azimuth is not None: self._state["azimuth"] = float(azimuth) - if elevation is not None: self._state["elevation"] = float(elevation) - self._push() - - def set_zoom(self, zoom: float) -> None: - self._state["zoom"] = float(zoom) - self._push() - - def set_data(self, x, y, z) -> None: - """Replace the geometry data.""" - # Re-run the same logic as __init__ for the stored geom_type - geom_type = self._state["geom_type"] - x = np.asarray(x, dtype=float) - y = np.asarray(y, dtype=float) - z = np.asarray(z, dtype=float) - - if geom_type == "surface": - if x.ndim == 2 and y.ndim == 2 and z.ndim == 2: - rows, cols = z.shape - xf, yf, zf = x.ravel(), y.ravel(), z.ravel() - elif x.ndim == 1 and y.ndim == 1 and z.ndim == 2: - rows, cols = z.shape - XX, YY = np.meshgrid(x, y) - xf, yf, zf = XX.ravel(), YY.ravel(), z.ravel() - else: - raise ValueError("Surface x/y/z must be 2-D grids or 1-D+2-D.") - faces_list = _triangulate_grid(rows, cols) - else: - xf, yf, zf = x.ravel(), y.ravel(), z.ravel() - faces_list = [] - - data_bounds = { - "xmin": float(xf.min()), "xmax": float(xf.max()), - "ymin": float(yf.min()), "ymax": float(yf.max()), - "zmin": float(zf.min()), "zmax": float(zf.max()), - } - - verts_arr = np.column_stack([xf, yf, zf]).astype(np.float32) - zvals_arr = zf.astype(np.float32) - faces_arr = (np.asarray(faces_list, dtype=np.int32).reshape(-1, 3) - if faces_list else np.empty((0, 3), dtype=np.int32)) - - self._state.update({ - "vertices_b64": _arr_to_b64(verts_arr, np.float32), - "vertices_count": len(verts_arr), - "faces_b64": _arr_to_b64(faces_arr, np.int32), - "faces_count": len(faces_arr), - "z_values_b64": _arr_to_b64(zvals_arr, np.float32), - "data_bounds": data_bounds, - "colormap_data": _build_colormap_lut(self._state["colormap_name"]), - }) - self._push() - - def __repr__(self) -> str: - geom = self._state.get("geom_type", "?") - n = len(self._state.get("vertices", [])) - return f"Plot3D(geom={geom!r}, n_vertices={n})" - - # --------------------------------------------------------------------------- # Line1D — per-line handle # --------------------------------------------------------------------------- diff --git a/anyplotlib/plot3d/__init__.py b/anyplotlib/plot3d/__init__.py new file mode 100644 index 00000000..5c0b7173 --- /dev/null +++ b/anyplotlib/plot3d/__init__.py @@ -0,0 +1,3 @@ +from anyplotlib.plot3d._plot3d import Plot3D + +__all__ = ["Plot3D"] diff --git a/anyplotlib/plot3d/_plot3d.py b/anyplotlib/plot3d/_plot3d.py new file mode 100644 index 00000000..4033defb --- /dev/null +++ b/anyplotlib/plot3d/_plot3d.py @@ -0,0 +1,277 @@ +""" +plot3d/_plot3d.py +================= +3-D surface / scatter / line plot panel. +""" + +from __future__ import annotations + +from typing import Callable + +import numpy as np + +from anyplotlib.callbacks import CallbackRegistry +from anyplotlib._utils import _arr_to_b64, _build_colormap_lut + + +def _triangulate_grid(rows: int, cols: int) -> list: + """Return a flat list of [i0, i1, i2] triangle indices for an (rows×cols) grid.""" + faces = [] + for r in range(rows - 1): + for c in range(cols - 1): + i = r * cols + c + faces.append([i, i + 1, i + cols]) + faces.append([i + 1, i + cols + 1, i + cols]) + return faces + + +class Plot3D: + """3-D plot panel. + + Supports three geometry types matching matplotlib's 3-D Axes API: + + * ``'surface'`` – triangulated surface, Z-coloured via colormap. + * ``'scatter'`` – point cloud, single colour. + * ``'line'`` – connected line through 3-D points. + + Created by :meth:`Axes.plot_surface`, :meth:`Axes.scatter3d`, + and :meth:`Axes.plot3d`. + + Not an anywidget. Holds state in ``_state`` dict; every mutation + calls ``_push()`` which writes to the parent Figure's panel trait. + """ + + def __init__(self, geom_type: str, + x, y, z, *, + colormap: str = "viridis", + color: str = "#4fc3f7", + point_size: float = 4.0, + linewidth: float = 1.5, + x_label: str = "x", + y_label: str = "y", + z_label: str = "z", + azimuth: float = -60.0, + elevation: float = 30.0, + zoom: float = 1.0): + self._id: str = "" + self._fig: object = None + + geom_type = geom_type.lower() + if geom_type not in ("surface", "scatter", "line"): + raise ValueError("geom_type must be 'surface', 'scatter', or 'line'") + + x = np.asarray(x, dtype=float) + y = np.asarray(y, dtype=float) + z = np.asarray(z, dtype=float) + + if geom_type == "surface": + # Accept 2-D grid arrays (meshgrid style) or 1-D flat arrays + if x.ndim == 2 and y.ndim == 2 and z.ndim == 2: + rows, cols = z.shape + xf, yf, zf = x.ravel(), y.ravel(), z.ravel() + elif x.ndim == 1 and y.ndim == 1 and z.ndim == 2: + rows, cols = z.shape + if len(x) != cols or len(y) != rows: + raise ValueError( + "For surface with 1-D x/y: x must have length ncols " + "and y must have length nrows") + XX, YY = np.meshgrid(x, y) + xf, yf, zf = XX.ravel(), YY.ravel(), z.ravel() + else: + raise ValueError( + "Surface x/y/z must be 2-D grids of the same shape, " + "or 1-D x/y centre arrays with 2-D z.") + faces_list = _triangulate_grid(rows, cols) + else: + if x.ndim != 1 or y.ndim != 1 or z.ndim != 1: + raise ValueError("scatter/line x, y, z must be 1-D arrays") + if not (len(x) == len(y) == len(z)): + raise ValueError("x, y, z must have the same length") + xf, yf, zf = x, y, z + faces_list = [] + + # Normalised data bounds for the JS renderer (from raw arrays — fast) + data_bounds = { + "xmin": float(xf.min()), "xmax": float(xf.max()), + "ymin": float(yf.min()), "ymax": float(yf.max()), + "zmin": float(zf.min()), "zmax": float(zf.max()), + } + + # Encode geometry as b64 (float32 saves 50 % wire size vs float64) + verts_arr = np.column_stack([xf, yf, zf]).astype(np.float32) # (N, 3) + zvals_arr = zf.astype(np.float32) # (N,) + faces_arr = (np.asarray(faces_list, dtype=np.int32).reshape(-1, 3) + if faces_list else np.empty((0, 3), dtype=np.int32)) + + cmap_lut = _build_colormap_lut(colormap) + + self._state: dict = { + "kind": "3d", + "geom_type": geom_type, + "vertices_b64": _arr_to_b64(verts_arr, np.float32), + "vertices_count": len(verts_arr), + "faces_b64": _arr_to_b64(faces_arr, np.int32), + "faces_count": len(faces_arr), + "z_values_b64": _arr_to_b64(zvals_arr, np.float32), + "colormap_name": colormap, + "colormap_data": cmap_lut, + "color": color, + "point_size": float(point_size), + "linewidth": float(linewidth), + "x_label": x_label, + "y_label": y_label, + "z_label": z_label, + "azimuth": float(azimuth), + "elevation": float(elevation), + "zoom": float(zoom), + "data_bounds": data_bounds, + "registered_keys": [], + } + self.callbacks = CallbackRegistry() + + # ------------------------------------------------------------------ + def _push(self) -> None: + if self._fig is None: + return + self._fig._push(self._id) + + def to_state_dict(self) -> dict: + return dict(self._state) + + # ------------------------------------------------------------------ + # Callback API (Plot3D) + # ------------------------------------------------------------------ + def on_changed(self, fn: Callable) -> Callable: + """Decorator: fires on every rotation/zoom frame.""" + cid = self.callbacks.connect("on_changed", fn) + fn._cid = cid + return fn + + def on_release(self, fn: Callable) -> Callable: + """Decorator: fires once when rotation/zoom settles.""" + cid = self.callbacks.connect("on_release", fn) + fn._cid = cid + return fn + + def on_click(self, fn: Callable) -> Callable: + """Decorator: fires on click on this panel.""" + cid = self.callbacks.connect("on_click", fn) + fn._cid = cid + return fn + + def on_key(self, key_or_fn=None) -> Callable: + """Register a key-press handler for this panel. + + Two call forms are supported:: + + @plot.on_key('q') # fires only when 'q' is pressed + def handler(event): ... + + @plot.on_key # fires for every registered key + def handler(event): ... + + The event carries: ``key``, ``mouse_x``, ``mouse_y``, and + ``last_widget_id``. + + .. note:: + Registered keys take priority over the built-in **r** (reset view) + shortcut. + """ + if callable(key_or_fn): + return self._connect_on_key(None, key_or_fn) + key = key_or_fn + def _decorator(fn): + return self._connect_on_key(key, fn) + return _decorator + + def _connect_on_key(self, key, fn) -> Callable: + if key is None: + if '*' not in self._state['registered_keys']: + self._state['registered_keys'].append('*') + self._push() + cid = self.callbacks.connect("on_key", fn) + else: + if key not in self._state['registered_keys']: + self._state['registered_keys'].append(key) + self._push() + def _wrapped(event): + if event.data.get('key') == key: + fn(event) + cid = self.callbacks.connect("on_key", _wrapped) + _wrapped._cid = cid + fn._cid = cid + return fn + + def disconnect(self, cid: int) -> None: + """Remove the callback registered under integer *cid*.""" + self.callbacks.disconnect(cid) + + # ------------------------------------------------------------------ + # Display settings + # ------------------------------------------------------------------ + def set_colormap(self, name: str) -> None: + """Set the surface colormap (ignored for scatter/line).""" + self._state["colormap_name"] = name + self._state["colormap_data"] = _build_colormap_lut(name) + self._push() + + def set_view(self, azimuth: float | None = None, + elevation: float | None = None) -> None: + """Set the camera azimuth (°) and/or elevation (°).""" + if azimuth is not None: self._state["azimuth"] = float(azimuth) + if elevation is not None: self._state["elevation"] = float(elevation) + self._push() + + def set_zoom(self, zoom: float) -> None: + self._state["zoom"] = float(zoom) + self._push() + + def set_data(self, x, y, z) -> None: + """Replace the geometry data.""" + # Re-run the same logic as __init__ for the stored geom_type + geom_type = self._state["geom_type"] + x = np.asarray(x, dtype=float) + y = np.asarray(y, dtype=float) + z = np.asarray(z, dtype=float) + + if geom_type == "surface": + if x.ndim == 2 and y.ndim == 2 and z.ndim == 2: + rows, cols = z.shape + xf, yf, zf = x.ravel(), y.ravel(), z.ravel() + elif x.ndim == 1 and y.ndim == 1 and z.ndim == 2: + rows, cols = z.shape + XX, YY = np.meshgrid(x, y) + xf, yf, zf = XX.ravel(), YY.ravel(), z.ravel() + else: + raise ValueError("Surface x/y/z must be 2-D grids or 1-D+2-D.") + faces_list = _triangulate_grid(rows, cols) + else: + xf, yf, zf = x.ravel(), y.ravel(), z.ravel() + faces_list = [] + + data_bounds = { + "xmin": float(xf.min()), "xmax": float(xf.max()), + "ymin": float(yf.min()), "ymax": float(yf.max()), + "zmin": float(zf.min()), "zmax": float(zf.max()), + } + + verts_arr = np.column_stack([xf, yf, zf]).astype(np.float32) + zvals_arr = zf.astype(np.float32) + faces_arr = (np.asarray(faces_list, dtype=np.int32).reshape(-1, 3) + if faces_list else np.empty((0, 3), dtype=np.int32)) + + self._state.update({ + "vertices_b64": _arr_to_b64(verts_arr, np.float32), + "vertices_count": len(verts_arr), + "faces_b64": _arr_to_b64(faces_arr, np.int32), + "faces_count": len(faces_arr), + "z_values_b64": _arr_to_b64(zvals_arr, np.float32), + "data_bounds": data_bounds, + "colormap_data": _build_colormap_lut(self._state["colormap_name"]), + }) + self._push() + + def __repr__(self) -> str: + geom = self._state.get("geom_type", "?") + n = len(self._state.get("vertices", [])) + return f"Plot3D(geom={geom!r}, n_vertices={n})" From b9cd69f384b3dda111518270bd5ab1f7bbb179da Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 11 May 2026 07:42:12 -0500 Subject: [PATCH 103/198] refactor: extract Plot2D and PlotMesh into plot2d/ subpackage --- anyplotlib/figure_plots.py | 763 +-------------------------------- anyplotlib/plot2d/__init__.py | 4 + anyplotlib/plot2d/_plot2d.py | 678 +++++++++++++++++++++++++++++ anyplotlib/plot2d/_plotmesh.py | 108 +++++ 4 files changed, 791 insertions(+), 762 deletions(-) create mode 100644 anyplotlib/plot2d/__init__.py create mode 100644 anyplotlib/plot2d/_plot2d.py create mode 100644 anyplotlib/plot2d/_plotmesh.py diff --git a/anyplotlib/figure_plots.py b/anyplotlib/figure_plots.py index 3319106e..b58c8fdf 100644 --- a/anyplotlib/figure_plots.py +++ b/anyplotlib/figure_plots.py @@ -41,6 +41,7 @@ _LINESTYLE_ALIASES, _CMAP_ALIASES, ) from anyplotlib.plot3d import Plot3D # noqa: F401 +from anyplotlib.plot2d import Plot2D, PlotMesh # noqa: F401 __all__ = ["GridSpec", "SubplotSpec", "Axes", "InsetAxes", "Line1D", "Plot1D", "Plot2D", "PlotMesh", "Plot3D", "PlotBar", "_plot_kind", "_resample_mesh", "_norm_linestyle"] @@ -521,768 +522,6 @@ def __repr__(self) -> str: return f"Axes(rows={self._spec.row_start}:{self._spec.row_stop}, cols={self._spec.col_start}:{self._spec.col_stop}, {kind})" -# --------------------------------------------------------------------------- -# Plot2D -# --------------------------------------------------------------------------- - -class Plot2D: - """2-D image plot panel. - - Not an anywidget. Holds state in ``_state`` dict; every mutation calls - ``_push()`` which writes to the parent Figure's panel trait. - - The marker API follows matplotlib conventions: - plot.add_circles(offsets, name="g1", facecolors="#f00", radius=5) - plot.markers["circles"]["g1"].set(radius=8) - """ - - def __init__(self, data: np.ndarray, - x_axis=None, y_axis=None, units: str = "px", - cmap: str | None = None, - vmin: float | None = None, - vmax: float | None = None, - origin: str = "upper"): - self._id: str = "" # assigned by Axes._attach - self._fig: object = None # assigned by Axes._attach - - _valid_origins = ("upper", "lower") - if origin not in _valid_origins: - raise ValueError( - f"origin must be one of {_valid_origins!r}, got {origin!r}" - ) - self._origin: str = origin - - data = np.asarray(data) - if data.ndim == 3: - data = data[:, :, 0] - if data.ndim != 2: - raise ValueError(f"data must be 2-D (H x W), got {data.shape}") - - h, w = data.shape - - # origin='lower' — row 0 at the bottom, matching matplotlib's matrix - # convention. Flip the data so our renderer (which always draws row 0 - # at the top) shows the correct orientation, and reverse the y-axis so - # tick values increase upward. - if origin == "lower": - data = np.flipud(data) - - self._data: np.ndarray = data.astype(float) - - x_axis_given = x_axis is not None - y_axis_given = y_axis is not None - if x_axis is None: - x_axis = np.arange(w, dtype=float) - if y_axis is None: - y_axis = np.arange(h, dtype=float) - x_axis = np.asarray(x_axis, dtype=float) - y_axis = np.asarray(y_axis, dtype=float) - - if origin == "lower": - y_axis = y_axis[::-1] - - img_u8, raw_vmin, raw_vmax = _normalize_image(data) - self._raw_u8 = img_u8 - self._raw_vmin = raw_vmin - self._raw_vmax = raw_vmax - - cmap_name = cmap if cmap is not None else "gray" - cmap_lut = _build_colormap_lut(cmap_name) - - # vmin/vmax clip the colormap in data units; default to the full range. - disp_min = float(vmin) if vmin is not None else raw_vmin - disp_max = float(vmax) if vmax is not None else raw_vmax - - # Compute physical pixel scale (data-units per pixel) from axis arrays - scale_x = float(abs(x_axis[-1] - x_axis[0]) / max(w - 1, 1)) if len(x_axis) >= 2 else 1.0 - scale_y = float(abs(y_axis[-1] - y_axis[0]) / max(h - 1, 1)) if len(y_axis) >= 2 else 1.0 - - self._state: dict = { - "kind": "2d", - "is_mesh": False, - "has_axes": x_axis_given or y_axis_given, - "image_b64": self._encode_bytes(img_u8), - "image_width": w, - "image_height": h, - "x_axis": x_axis.tolist(), - "y_axis": y_axis.tolist(), - "units": units, - "scale_x": scale_x, - "scale_y": scale_y, - "display_min": disp_min, - "display_max": disp_max, - "raw_min": raw_vmin, - "raw_max": raw_vmax, - "show_colorbar": False, - "log_scale": False, - "scale_mode": "linear", - "colormap_name": cmap_name, - "colormap_data": cmap_lut, - "zoom": 1.0, - "center_x": 0.5, - "center_y": 0.5, - "overlay_widgets": [], - "markers": [], - "registered_keys": [], - # Transparent mask overlay (set via set_overlay_mask) - "overlay_mask_b64": "", - "overlay_mask_color": "#ff4444", - "overlay_mask_alpha": 0.4, - # Set True when Python explicitly changes view; JS uses it to - # decide whether to preserve the current frontend zoom/pan state. - "_view_from_python": False, - } - - self.markers = MarkerRegistry(self._push_markers, - allowed=MarkerRegistry._KNOWN_2D) - self.callbacks = CallbackRegistry() - self._widgets: dict[str, Widget] = {} - - @staticmethod - def _encode_bytes(arr: np.ndarray) -> str: - import base64 - return base64.b64encode(arr.tobytes()).decode("ascii") - - def _push(self) -> None: - """Serialise _state + markers and write to Figure trait.""" - if self._fig is None: - return - self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] - self._fig._push(self._id) - - def _push_markers(self) -> None: - """Called by MarkerRegistry whenever markers change.""" - self._state["markers"] = self.markers.to_wire_list() - self._push() - - def to_state_dict(self) -> dict: - """Return a JSON-serialisable copy of the current state.""" - d = dict(self._state) - d["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] - d["markers"] = self.markers.to_wire_list() - return d - - # ------------------------------------------------------------------ - # Data - # ------------------------------------------------------------------ - @property - def data(self) -> np.ndarray: - """The image data in the original user coordinate system (read-only). - - Returns a float64 copy with ``writeable=False``. To replace the - data call :meth:`set_data`. - """ - arr = np.flipud(self._data).copy() if self._origin == "lower" else self._data.copy() - arr.flags.writeable = False - return arr - - def set_data(self, data: np.ndarray, - x_axis=None, y_axis=None, units: str | None = None) -> None: - """Replace the image data. - - The ``origin`` supplied at construction is automatically re-applied - so the new data is displayed with the same orientation. - """ - data = np.asarray(data) - if data.ndim == 3: - data = data[:, :, 0] - if data.ndim != 2: - raise ValueError(f"data must be 2-D, got {data.shape}") - h, w = data.shape - - if self._origin == "lower": - data = np.flipud(data) - - self._data = data.astype(float) - img_u8, vmin, vmax = _normalize_image(data) - self._raw_u8, self._raw_vmin, self._raw_vmax = img_u8, vmin, vmax - - if x_axis is not None: - self._state["x_axis"] = np.asarray(x_axis, float).tolist() - self._state["image_width"] = w - self._state["has_axes"] = True - if y_axis is not None: - ya = np.asarray(y_axis, float) - if self._origin == "lower": - ya = ya[::-1] - self._state["y_axis"] = ya.tolist() - self._state["image_height"] = h - self._state["has_axes"] = True - if units is not None: - self._state["units"] = units - - self._state.update({ - "image_b64": self._encode_bytes(img_u8), - "image_width": w, - "image_height": h, - "display_min": vmin, - "display_max": vmax, - "raw_min": vmin, - "raw_max": vmax, - "colormap_data": _build_colormap_lut(self._state["colormap_name"]), - }) - self._push() - - def set_overlay_mask(self, mask: "np.ndarray | None", - color: str = "#ff4444", - alpha: float = 0.4) -> None: - """Set (or clear) a transparent boolean mask drawn over the image. - - The mask is composited client-side in the browser at *alpha* opacity - using *color* for all ``True`` pixels. Call with ``mask=None`` to - remove any existing overlay. - - Parameters - ---------- - mask : ndarray of shape (H, W), bool or uint8, or None - Boolean array aligned to the image data. ``True`` / non-zero - pixels are filled with *color* at transparency *alpha*. - Pass ``None`` to clear the overlay. - color : str, optional - CSS hex colour for the overlay, e.g. ``"#ff4444"``. Default red. - Must be in ``#RRGGBB`` format. - alpha : float, optional - Opacity in [0, 1]. Default 0.4 (40 % opaque). - """ - import base64, re - # Validate color format - if not re.fullmatch(r'#[0-9a-fA-F]{6}', color): - raise ValueError( - f"color must be a CSS hex colour in '#RRGGBB' format, got {color!r}" - ) - # Clamp alpha to [0, 1] - alpha = float(alpha) - if not (0.0 <= alpha <= 1.0): - raise ValueError(f"alpha must be in [0, 1], got {alpha!r}") - if mask is None: - self._state["overlay_mask_b64"] = "" - self._state["overlay_mask_color"] = color - self._state["overlay_mask_alpha"] = alpha - else: - arr = np.asarray(mask) - if arr.shape != (self._state["image_height"], self._state["image_width"]): - raise ValueError( - f"mask shape {arr.shape} does not match image " - f"({self._state['image_height']} x {self._state['image_width']})" - ) - # For origin='lower' the image data was flipped; flip mask to match. - if self._origin == "lower": - arr = np.flipud(arr) - # Convert to uint8: True/non-zero → 255, False/zero → 0 - u8 = (np.asarray(arr, dtype=bool).view(np.uint8) * 255).astype(np.uint8) - self._state["overlay_mask_b64"] = base64.b64encode(u8.tobytes()).decode("ascii") - self._state["overlay_mask_color"] = color - self._state["overlay_mask_alpha"] = alpha - self._push() - - # ------------------------------------------------------------------ - # Display settings - # ------------------------------------------------------------------ - def set_colormap(self, name: str) -> None: - self._state["colormap_name"] = name - self._state["colormap_data"] = _build_colormap_lut(name) - self._push() - - def set_clim(self, vmin=None, vmax=None) -> None: - if vmin is not None: - self._state["display_min"] = float(vmin) - if vmax is not None: - self._state["display_max"] = float(vmax) - self._push() - - def set_scale_mode(self, mode: str) -> None: - valid = ("linear", "log", "symlog") - if mode not in valid: - raise ValueError(f"mode must be one of {valid}") - self._state["scale_mode"] = mode - self._push() - - @property - def colormap_name(self) -> str: - return self._state["colormap_name"] - - @colormap_name.setter - def colormap_name(self, name: str) -> None: - self.set_colormap(name) - - # ------------------------------------------------------------------ - # Overlay Widgets - # ------------------------------------------------------------------ - def add_widget(self, kind: str, color: str = "#00e5ff", **kwargs) -> Widget: - kind = kind.lower() - valid = ("circle", "rectangle", "annular", "polygon", "label", "crosshair") - if kind not in valid: - raise ValueError(f"kind must be one of {valid}") - iw, ih = self._state["image_width"], self._state["image_height"] - - def _f(k, default): return float(kwargs.get(k, default)) - def _i(k, default): return int(kwargs.get(k, default)) - - if kind == "circle": - widget = CircleWidget(lambda: None, - cx=_f("cx", iw / 2), cy=_f("cy", ih / 2), - r=_f("r", iw * 0.1), color=color) - elif kind == "rectangle": - widget = RectangleWidget(lambda: None, - x=_f("x", iw * 0.25), y=_f("y", ih * 0.25), - w=_f("w", iw * 0.5), h=_f("h", ih * 0.5), - color=color) - elif kind == "annular": - r_outer = _f("r_outer", iw * 0.2) - r_inner = _f("r_inner", iw * 0.1) - widget = AnnularWidget(lambda: None, - cx=_f("cx", iw / 2), cy=_f("cy", ih / 2), - r_outer=r_outer, r_inner=r_inner, color=color) - elif kind == "polygon": - raw = kwargs.get("vertices", [[iw * .25, ih * .25], [iw * .75, ih * .25], - [iw * .75, ih * .75], [iw * .25, ih * .75]]) - widget = PolygonWidget(lambda: None, vertices=raw, color=color) - elif kind == "crosshair": - widget = CrosshairWidget(lambda: None, - cx=_f("cx", iw / 2), cy=_f("cy", ih / 2), - color=color) - else: # label - widget = LabelWidget(lambda: None, - x=_f("x", iw * 0.1), y=_f("y", ih * 0.1), - text=str(kwargs.get("text", "Label")), - fontsize=_i("fontsize", 14), color=color) - - # Replace the temporary push_fn with a targeted one now that - # we have both the widget's _id and the plot's _id. - plot_ref = self - wid_id = widget._id - def _targeted_push(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() - if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _targeted_push - - self._widgets[widget.id] = widget - self._push() # full panel push once so JS knows about the widget - return widget - - def get_widget(self, wid) -> Widget: - """Return the Widget object by ID string or Widget instance.""" - if isinstance(wid, Widget): - wid = wid.id - try: - return self._widgets[wid] - except KeyError: - raise KeyError(wid) - - def remove_widget(self, wid) -> None: - """Remove a widget by ID string or Widget instance.""" - if isinstance(wid, Widget): - wid = wid.id - if wid not in self._widgets: - raise KeyError(wid) - del self._widgets[wid] - self._push() - - def list_widgets(self) -> list: - return list(self._widgets.values()) - - def clear_widgets(self) -> None: - self._widgets.clear() - self._push() - - # ------------------------------------------------------------------ - # Callback API (Plot2D) - # ------------------------------------------------------------------ - def on_changed(self, fn: Callable) -> Callable: - """Decorator: fires on every pan/zoom/drag frame on this panel.""" - cid = self.callbacks.connect("on_changed", fn) - fn._cid = cid - return fn - - def on_release(self, fn: Callable) -> Callable: - """Decorator: fires once when pan/zoom/drag settles on this panel.""" - cid = self.callbacks.connect("on_release", fn) - fn._cid = cid - return fn - - def on_click(self, fn: Callable) -> Callable: - """Decorator: fires on click on this panel.""" - cid = self.callbacks.connect("on_click", fn) - fn._cid = cid - return fn - - def on_key(self, key_or_fn=None) -> Callable: - """Register a key-press handler for this panel. - - Two call forms are supported:: - - @plot.on_key('q') # fires only when 'q' is pressed - def handler(event): ... - - @plot.on_key # fires for every registered key - def handler(event): ... - - The event carries: ``key``, ``mouse_x``, ``mouse_y``, ``phys_x``, - and ``last_widget_id``. - - .. note:: - Registered keys take priority over the built-in **r** (reset view) - shortcut. - """ - if callable(key_or_fn): - return self._connect_on_key(None, key_or_fn) - key = key_or_fn - def _decorator(fn): - return self._connect_on_key(key, fn) - return _decorator - - def _connect_on_key(self, key, fn) -> Callable: - if key is None: - if '*' not in self._state['registered_keys']: - self._state['registered_keys'].append('*') - self._push() - cid = self.callbacks.connect("on_key", fn) - else: - if key not in self._state['registered_keys']: - self._state['registered_keys'].append(key) - self._push() - def _wrapped(event): - if event.data.get('key') == key: - fn(event) - cid = self.callbacks.connect("on_key", _wrapped) - _wrapped._cid = cid - fn._cid = cid - return fn - - def disconnect(self, cid: int) -> None: - """Remove the callback registered under integer *cid*.""" - self.callbacks.disconnect(cid) - - # ------------------------------------------------------------------ - # View control - # ------------------------------------------------------------------ - def set_view(self, - x0: float | None = None, x1: float | None = None, - y0: float | None = None, y1: float | None = None) -> None: - """Set the viewport to a data-space rectangle. - - Parameters - ---------- - x0, x1 : float, optional - Horizontal data-space range to show. If omitted the full - x-extent is used for zoom calculation. - y0, y1 : float, optional - Vertical data-space range to show. If omitted the full - y-extent is used for zoom calculation. - - Translates the requested rectangle into the ``zoom`` / ``center_x`` - / ``center_y`` state values used by the 2-D JS renderer. - """ - xarr = np.asarray(self._state["x_axis"]) - yarr = np.asarray(self._state["y_axis"]) - if len(xarr) < 2 or len(yarr) < 2: - return - - xmin, xmax = float(xarr[0]), float(xarr[-1]) - ymin, ymax = float(yarr[0]), float(yarr[-1]) - x_span = xmax - xmin or 1.0 - y_span = ymax - ymin or 1.0 - - zoom_candidates = [] - - if x0 is not None and x1 is not None: - fx0 = max(0.0, min(1.0, (float(x0) - xmin) / x_span)) - fx1 = max(0.0, min(1.0, (float(x1) - xmin) / x_span)) - if fx1 > fx0: - self._state["center_x"] = (fx0 + fx1) / 2.0 - zoom_candidates.append(1.0 / (fx1 - fx0)) - - if y0 is not None and y1 is not None: - fy0 = max(0.0, min(1.0, (float(y0) - ymin) / y_span)) - fy1 = max(0.0, min(1.0, (float(y1) - ymin) / y_span)) - if fy1 > fy0: - self._state["center_y"] = (fy0 + fy1) / 2.0 - zoom_candidates.append(1.0 / (fy1 - fy0)) - - if zoom_candidates: - self._state["zoom"] = min(zoom_candidates) - self._state["_view_from_python"] = True - self._push() - self._state["_view_from_python"] = False - - def reset_view(self) -> None: - """Reset pan and zoom to show the full image.""" - self._state["zoom"] = 1.0 - self._state["center_x"] = 0.5 - self._state["center_y"] = 0.5 - self._state["_view_from_python"] = True - self._push() - self._state["_view_from_python"] = False - - # ------------------------------------------------------------------ - # Marker API (matplotlib-style kwargs → MarkerRegistry) - # ------------------------------------------------------------------ - def _add_marker(self, mtype: str, name: str | None, **kwargs) -> "MarkerGroup": # noqa: F821 - return self.markers.add(mtype, name, **kwargs) - - def add_circles(self, offsets, name=None, *, radius=5, - facecolors=None, edgecolors="#ff0000", - linewidths=1.5, alpha=0.3, - hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - """Add circle markers at (x, y) positions in data coordinates.""" - return self._add_marker("circles", name, offsets=offsets, radius=radius, - facecolors=facecolors, edgecolors=edgecolors, - linewidths=linewidths, alpha=alpha, - hover_edgecolors=hover_edgecolors, - hover_facecolors=hover_facecolors, - labels=labels, label=label) - - def add_points(self, offsets, name=None, *, sizes=5, - color="#ff0000", facecolors=None, - linewidths=1.5, alpha=0.3, - hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - """Add point markers at (x, y) positions in data coordinates.""" - return self._add_marker("circles", name, offsets=offsets, radius=sizes, - edgecolors=color, facecolors=facecolors, - linewidths=linewidths, alpha=alpha, - hover_edgecolors=hover_edgecolors, - hover_facecolors=hover_facecolors, - labels=labels, label=label) - - def add_hlines(self, y_values, name=None, *, - color="#ff0000", linewidths=1.5, - hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - """Add static horizontal lines at the given y positions.""" - return self._add_marker("hlines", name, offsets=y_values, - color=color, linewidths=linewidths, - hover_edgecolors=hover_edgecolors, - labels=labels, label=label) - - def add_vlines(self, x_values, name=None, *, - color="#ff0000", linewidths=1.5, - hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - """Add static vertical lines at the given x positions.""" - return self._add_marker("vlines", name, offsets=x_values, - color=color, linewidths=linewidths, - hover_edgecolors=hover_edgecolors, - labels=labels, label=label) - - def add_arrows(self, offsets, U, V, name=None, *, - edgecolors="#ff0000", linewidths=1.5, - hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - return self._add_marker("arrows", name, offsets=offsets, U=U, V=V, - edgecolors=edgecolors, linewidths=linewidths, - hover_edgecolors=hover_edgecolors, - labels=labels, label=label) - - def add_ellipses(self, offsets, widths, heights, name=None, *, - angles=0, facecolors=None, edgecolors="#ff0000", - linewidths=1.5, alpha=0.3, - hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - return self._add_marker("ellipses", name, offsets=offsets, - widths=widths, heights=heights, angles=angles, - facecolors=facecolors, edgecolors=edgecolors, - linewidths=linewidths, alpha=alpha, - hover_edgecolors=hover_edgecolors, - hover_facecolors=hover_facecolors, - labels=labels, label=label) - - def add_lines(self, segments, name=None, *, - edgecolors="#ff0000", linewidths=1.5, - hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - return self._add_marker("lines", name, segments=segments, - edgecolors=edgecolors, linewidths=linewidths, - hover_edgecolors=hover_edgecolors, - labels=labels, label=label) - - def add_rectangles(self, offsets, widths, heights, name=None, *, - angles=0, facecolors=None, edgecolors="#ff0000", - linewidths=1.5, alpha=0.3, - hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - return self._add_marker("rectangles", name, offsets=offsets, - widths=widths, heights=heights, angles=angles, - facecolors=facecolors, edgecolors=edgecolors, - linewidths=linewidths, alpha=alpha, - hover_edgecolors=hover_edgecolors, - hover_facecolors=hover_facecolors, - labels=labels, label=label) - - def add_squares(self, offsets, widths, name=None, *, - angles=0, facecolors=None, edgecolors="#ff0000", - linewidths=1.5, alpha=0.3, - hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - return self._add_marker("squares", name, offsets=offsets, - widths=widths, angles=angles, - facecolors=facecolors, edgecolors=edgecolors, - linewidths=linewidths, alpha=alpha, - hover_edgecolors=hover_edgecolors, - hover_facecolors=hover_facecolors, - labels=labels, label=label) - - def add_polygons(self, vertices_list, name=None, *, - facecolors=None, edgecolors="#ff0000", - linewidths=1.5, alpha=0.3, - hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - return self._add_marker("polygons", name, vertices_list=vertices_list, - facecolors=facecolors, edgecolors=edgecolors, - linewidths=linewidths, alpha=alpha, - hover_edgecolors=hover_edgecolors, - hover_facecolors=hover_facecolors, - labels=labels, label=label) - - def add_texts(self, offsets, texts, name=None, *, - color="#ff0000", fontsize=12, - hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - return self._add_marker("texts", name, offsets=offsets, texts=texts, - color=color, fontsize=fontsize, - hover_edgecolors=hover_edgecolors, - labels=labels, label=label) - - def remove_marker(self, marker_type: str, name: str) -> None: - """Remove a named marker collection by type and name. - - Parameters - ---------- - marker_type : str - Collection type, e.g. ``"points"``, ``"vlines"``. - name : str - The name used when the collection was created. - """ - self.markers.remove(marker_type, name) - - def clear_markers(self) -> None: - """Remove all marker collections from this panel.""" - self.markers.clear() - - def list_markers(self) -> list: - """Return a summary list of all marker collections on this panel. - - Returns - ------- - list of dict - Each dict has keys ``"type"``, ``"name"``, and ``"n"`` - (number of markers in the collection). - """ - out = [] - for mtype, td in self.markers._types.items(): - for name, g in td.items(): - out.append({"type": mtype, "name": name, "n": g._count()}) - return out - - def __repr__(self) -> str: - w = self._state.get("image_width", "?") - h = self._state.get("image_height", "?") - cmap = self._state.get("colormap_name", "?") - return f"Plot2D({w}\u00d7{h}, cmap={cmap!r})" - - -# --------------------------------------------------------------------------- -# PlotMesh (pcolormesh-style 2-D panel) -# --------------------------------------------------------------------------- - -class PlotMesh(Plot2D): - """2-D mesh plot panel created by :meth:`Axes.pcolormesh`. - - Accepts cell *edge* arrays (length N+1 / M+1) rather than centre arrays, - matches matplotlib's ``pcolormesh`` convention. Only ``'circles'`` and - ``'lines'`` markers are supported. - """ - - def __init__(self, data: np.ndarray, - x_edges=None, y_edges=None, units: str = ""): - data = np.asarray(data) - if data.ndim != 2: - raise ValueError(f"data must be 2-D (M x N), got {data.shape}") - rows, cols = data.shape - - if x_edges is None: - x_edges = np.arange(cols + 1, dtype=float) - if y_edges is None: - y_edges = np.arange(rows + 1, dtype=float) - x_edges = np.asarray(x_edges, dtype=float) - y_edges = np.asarray(y_edges, dtype=float) - - if len(x_edges) != cols + 1: - raise ValueError( - f"x_edges must have length {cols + 1} for {cols} columns, " - f"got {len(x_edges)}") - if len(y_edges) != rows + 1: - raise ValueError( - f"y_edges must have length {rows + 1} for {rows} rows, " - f"got {len(y_edges)}") - - # Resample to a regular pixel grid for display - resampled = _resample_mesh(data, x_edges, y_edges) - - # Use cell centres to initialise the parent (axes will be replaced) - x_c = (x_edges[:-1] + x_edges[1:]) / 2.0 - y_c = (y_edges[:-1] + y_edges[1:]) / 2.0 - super().__init__(resampled, x_axis=x_c, y_axis=y_c, units=units) - - # Override mesh-specific state - self._state["is_mesh"] = True - self._state["has_axes"] = True - # Store edges (not centres) so the JS renderer can place grid lines - self._state["x_axis"] = x_edges.tolist() - self._state["y_axis"] = y_edges.tolist() - # Mesh panels have no fixed pixel scale - self._state.pop("scale_x", None) - self._state.pop("scale_y", None) - - # Restrict markers to circles + lines only - self.markers = MarkerRegistry(self._push_markers, - allowed=MarkerRegistry._KNOWN_MESH) - - # ------------------------------------------------------------------ - # Data - # ------------------------------------------------------------------ - def set_data(self, data: np.ndarray, - x_edges=None, y_edges=None, units: str | None = None) -> None: - """Replace the mesh data (and optionally the edge arrays).""" - data = np.asarray(data) - if data.ndim != 2: - raise ValueError(f"data must be 2-D, got {data.shape}") - rows, cols = data.shape - - cur_xe = np.asarray(self._state["x_axis"], dtype=float) - cur_ye = np.asarray(self._state["y_axis"], dtype=float) - xe = np.asarray(x_edges, dtype=float) if x_edges is not None else cur_xe - ye = np.asarray(y_edges, dtype=float) if y_edges is not None else cur_ye - - if len(xe) != cols + 1: - raise ValueError(f"x_edges must have length {cols + 1}") - if len(ye) != rows + 1: - raise ValueError(f"y_edges must have length {rows + 1}") - - resampled = _resample_mesh(data, xe, ye) - img_u8, vmin, vmax = _normalize_image(resampled) - self._raw_u8, self._raw_vmin, self._raw_vmax = img_u8, vmin, vmax - - self._state.update({ - "image_b64": self._encode_bytes(img_u8), - "image_width": cols, - "image_height": rows, - "x_axis": xe.tolist(), - "y_axis": ye.tolist(), - "display_min": vmin, - "display_max": vmax, - "raw_min": vmin, - "raw_max": vmax, - "colormap_data": _build_colormap_lut(self._state["colormap_name"]), - }) - if units is not None: - self._state["units"] = units - self._push() - - # --------------------------------------------------------------------------- # Line1D — per-line handle # --------------------------------------------------------------------------- diff --git a/anyplotlib/plot2d/__init__.py b/anyplotlib/plot2d/__init__.py new file mode 100644 index 00000000..18ef98fc --- /dev/null +++ b/anyplotlib/plot2d/__init__.py @@ -0,0 +1,4 @@ +from anyplotlib.plot2d._plot2d import Plot2D +from anyplotlib.plot2d._plotmesh import PlotMesh + +__all__ = ["Plot2D", "PlotMesh"] diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py new file mode 100644 index 00000000..91310ee0 --- /dev/null +++ b/anyplotlib/plot2d/_plot2d.py @@ -0,0 +1,678 @@ +""" +plot2d/_plot2d.py +================= +2-D image panel (imshow). +""" + +from __future__ import annotations + +import numpy as np +from typing import Callable + +from anyplotlib.markers import MarkerRegistry +from anyplotlib.callbacks import CallbackRegistry +from anyplotlib.widgets import ( + Widget, + RectangleWidget, CircleWidget, AnnularWidget, + CrosshairWidget, PolygonWidget, LabelWidget, +) +from anyplotlib._utils import _normalize_image, _build_colormap_lut + + +class Plot2D: + """2-D image plot panel. + + Not an anywidget. Holds state in ``_state`` dict; every mutation calls + ``_push()`` which writes to the parent Figure's panel trait. + + The marker API follows matplotlib conventions: + plot.add_circles(offsets, name="g1", facecolors="#f00", radius=5) + plot.markers["circles"]["g1"].set(radius=8) + """ + + def __init__(self, data: np.ndarray, + x_axis=None, y_axis=None, units: str = "px", + cmap: str | None = None, + vmin: float | None = None, + vmax: float | None = None, + origin: str = "upper"): + self._id: str = "" # assigned by Axes._attach + self._fig: object = None # assigned by Axes._attach + + _valid_origins = ("upper", "lower") + if origin not in _valid_origins: + raise ValueError( + f"origin must be one of {_valid_origins!r}, got {origin!r}" + ) + self._origin: str = origin + + data = np.asarray(data) + if data.ndim == 3: + data = data[:, :, 0] + if data.ndim != 2: + raise ValueError(f"data must be 2-D (H x W), got {data.shape}") + + h, w = data.shape + + # origin='lower' — row 0 at the bottom, matching matplotlib's matrix + # convention. Flip the data so our renderer (which always draws row 0 + # at the top) shows the correct orientation, and reverse the y-axis so + # tick values increase upward. + if origin == "lower": + data = np.flipud(data) + + self._data: np.ndarray = data.astype(float) + + x_axis_given = x_axis is not None + y_axis_given = y_axis is not None + if x_axis is None: + x_axis = np.arange(w, dtype=float) + if y_axis is None: + y_axis = np.arange(h, dtype=float) + x_axis = np.asarray(x_axis, dtype=float) + y_axis = np.asarray(y_axis, dtype=float) + + if origin == "lower": + y_axis = y_axis[::-1] + + img_u8, raw_vmin, raw_vmax = _normalize_image(data) + self._raw_u8 = img_u8 + self._raw_vmin = raw_vmin + self._raw_vmax = raw_vmax + + cmap_name = cmap if cmap is not None else "gray" + cmap_lut = _build_colormap_lut(cmap_name) + + # vmin/vmax clip the colormap in data units; default to the full range. + disp_min = float(vmin) if vmin is not None else raw_vmin + disp_max = float(vmax) if vmax is not None else raw_vmax + + # Compute physical pixel scale (data-units per pixel) from axis arrays + scale_x = float(abs(x_axis[-1] - x_axis[0]) / max(w - 1, 1)) if len(x_axis) >= 2 else 1.0 + scale_y = float(abs(y_axis[-1] - y_axis[0]) / max(h - 1, 1)) if len(y_axis) >= 2 else 1.0 + + self._state: dict = { + "kind": "2d", + "is_mesh": False, + "has_axes": x_axis_given or y_axis_given, + "image_b64": self._encode_bytes(img_u8), + "image_width": w, + "image_height": h, + "x_axis": x_axis.tolist(), + "y_axis": y_axis.tolist(), + "units": units, + "scale_x": scale_x, + "scale_y": scale_y, + "display_min": disp_min, + "display_max": disp_max, + "raw_min": raw_vmin, + "raw_max": raw_vmax, + "show_colorbar": False, + "log_scale": False, + "scale_mode": "linear", + "colormap_name": cmap_name, + "colormap_data": cmap_lut, + "zoom": 1.0, + "center_x": 0.5, + "center_y": 0.5, + "overlay_widgets": [], + "markers": [], + "registered_keys": [], + # Transparent mask overlay (set via set_overlay_mask) + "overlay_mask_b64": "", + "overlay_mask_color": "#ff4444", + "overlay_mask_alpha": 0.4, + # Set True when Python explicitly changes view; JS uses it to + # decide whether to preserve the current frontend zoom/pan state. + "_view_from_python": False, + } + + self.markers = MarkerRegistry(self._push_markers, + allowed=MarkerRegistry._KNOWN_2D) + self.callbacks = CallbackRegistry() + self._widgets: dict[str, Widget] = {} + + @staticmethod + def _encode_bytes(arr: np.ndarray) -> str: + import base64 + return base64.b64encode(arr.tobytes()).decode("ascii") + + def _push(self) -> None: + """Serialise _state + markers and write to Figure trait.""" + if self._fig is None: + return + self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] + self._fig._push(self._id) + + def _push_markers(self) -> None: + """Called by MarkerRegistry whenever markers change.""" + self._state["markers"] = self.markers.to_wire_list() + self._push() + + def to_state_dict(self) -> dict: + """Return a JSON-serialisable copy of the current state.""" + d = dict(self._state) + d["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] + d["markers"] = self.markers.to_wire_list() + return d + + # ------------------------------------------------------------------ + # Data + # ------------------------------------------------------------------ + @property + def data(self) -> np.ndarray: + """The image data in the original user coordinate system (read-only). + + Returns a float64 copy with ``writeable=False``. To replace the + data call :meth:`set_data`. + """ + arr = np.flipud(self._data).copy() if self._origin == "lower" else self._data.copy() + arr.flags.writeable = False + return arr + + def set_data(self, data: np.ndarray, + x_axis=None, y_axis=None, units: str | None = None) -> None: + """Replace the image data. + + The ``origin`` supplied at construction is automatically re-applied + so the new data is displayed with the same orientation. + """ + data = np.asarray(data) + if data.ndim == 3: + data = data[:, :, 0] + if data.ndim != 2: + raise ValueError(f"data must be 2-D, got {data.shape}") + h, w = data.shape + + if self._origin == "lower": + data = np.flipud(data) + + self._data = data.astype(float) + img_u8, vmin, vmax = _normalize_image(data) + self._raw_u8, self._raw_vmin, self._raw_vmax = img_u8, vmin, vmax + + if x_axis is not None: + self._state["x_axis"] = np.asarray(x_axis, float).tolist() + self._state["image_width"] = w + self._state["has_axes"] = True + if y_axis is not None: + ya = np.asarray(y_axis, float) + if self._origin == "lower": + ya = ya[::-1] + self._state["y_axis"] = ya.tolist() + self._state["image_height"] = h + self._state["has_axes"] = True + if units is not None: + self._state["units"] = units + + self._state.update({ + "image_b64": self._encode_bytes(img_u8), + "image_width": w, + "image_height": h, + "display_min": vmin, + "display_max": vmax, + "raw_min": vmin, + "raw_max": vmax, + "colormap_data": _build_colormap_lut(self._state["colormap_name"]), + }) + self._push() + + def set_overlay_mask(self, mask: "np.ndarray | None", + color: str = "#ff4444", + alpha: float = 0.4) -> None: + """Set (or clear) a transparent boolean mask drawn over the image. + + The mask is composited client-side in the browser at *alpha* opacity + using *color* for all ``True`` pixels. Call with ``mask=None`` to + remove any existing overlay. + + Parameters + ---------- + mask : ndarray of shape (H, W), bool or uint8, or None + Boolean array aligned to the image data. ``True`` / non-zero + pixels are filled with *color* at transparency *alpha*. + Pass ``None`` to clear the overlay. + color : str, optional + CSS hex colour for the overlay, e.g. ``"#ff4444"``. Default red. + Must be in ``#RRGGBB`` format. + alpha : float, optional + Opacity in [0, 1]. Default 0.4 (40 % opaque). + """ + import base64, re + # Validate color format + if not re.fullmatch(r'#[0-9a-fA-F]{6}', color): + raise ValueError( + f"color must be a CSS hex colour in '#RRGGBB' format, got {color!r}" + ) + # Clamp alpha to [0, 1] + alpha = float(alpha) + if not (0.0 <= alpha <= 1.0): + raise ValueError(f"alpha must be in [0, 1], got {alpha!r}") + if mask is None: + self._state["overlay_mask_b64"] = "" + self._state["overlay_mask_color"] = color + self._state["overlay_mask_alpha"] = alpha + else: + arr = np.asarray(mask) + if arr.shape != (self._state["image_height"], self._state["image_width"]): + raise ValueError( + f"mask shape {arr.shape} does not match image " + f"({self._state['image_height']} x {self._state['image_width']})" + ) + # For origin='lower' the image data was flipped; flip mask to match. + if self._origin == "lower": + arr = np.flipud(arr) + # Convert to uint8: True/non-zero → 255, False/zero → 0 + u8 = (np.asarray(arr, dtype=bool).view(np.uint8) * 255).astype(np.uint8) + self._state["overlay_mask_b64"] = base64.b64encode(u8.tobytes()).decode("ascii") + self._state["overlay_mask_color"] = color + self._state["overlay_mask_alpha"] = alpha + self._push() + + # ------------------------------------------------------------------ + # Display settings + # ------------------------------------------------------------------ + def set_colormap(self, name: str) -> None: + self._state["colormap_name"] = name + self._state["colormap_data"] = _build_colormap_lut(name) + self._push() + + def set_clim(self, vmin=None, vmax=None) -> None: + if vmin is not None: + self._state["display_min"] = float(vmin) + if vmax is not None: + self._state["display_max"] = float(vmax) + self._push() + + def set_scale_mode(self, mode: str) -> None: + valid = ("linear", "log", "symlog") + if mode not in valid: + raise ValueError(f"mode must be one of {valid}") + self._state["scale_mode"] = mode + self._push() + + @property + def colormap_name(self) -> str: + return self._state["colormap_name"] + + @colormap_name.setter + def colormap_name(self, name: str) -> None: + self.set_colormap(name) + + # ------------------------------------------------------------------ + # Overlay Widgets + # ------------------------------------------------------------------ + def add_widget(self, kind: str, color: str = "#00e5ff", **kwargs) -> Widget: + kind = kind.lower() + valid = ("circle", "rectangle", "annular", "polygon", "label", "crosshair") + if kind not in valid: + raise ValueError(f"kind must be one of {valid}") + iw, ih = self._state["image_width"], self._state["image_height"] + + def _f(k, default): return float(kwargs.get(k, default)) + def _i(k, default): return int(kwargs.get(k, default)) + + if kind == "circle": + widget = CircleWidget(lambda: None, + cx=_f("cx", iw / 2), cy=_f("cy", ih / 2), + r=_f("r", iw * 0.1), color=color) + elif kind == "rectangle": + widget = RectangleWidget(lambda: None, + x=_f("x", iw * 0.25), y=_f("y", ih * 0.25), + w=_f("w", iw * 0.5), h=_f("h", ih * 0.5), + color=color) + elif kind == "annular": + r_outer = _f("r_outer", iw * 0.2) + r_inner = _f("r_inner", iw * 0.1) + widget = AnnularWidget(lambda: None, + cx=_f("cx", iw / 2), cy=_f("cy", ih / 2), + r_outer=r_outer, r_inner=r_inner, color=color) + elif kind == "polygon": + raw = kwargs.get("vertices", [[iw * .25, ih * .25], [iw * .75, ih * .25], + [iw * .75, ih * .75], [iw * .25, ih * .75]]) + widget = PolygonWidget(lambda: None, vertices=raw, color=color) + elif kind == "crosshair": + widget = CrosshairWidget(lambda: None, + cx=_f("cx", iw / 2), cy=_f("cy", ih / 2), + color=color) + else: # label + widget = LabelWidget(lambda: None, + x=_f("x", iw * 0.1), y=_f("y", ih * 0.1), + text=str(kwargs.get("text", "Label")), + fontsize=_i("fontsize", 14), color=color) + + # Replace the temporary push_fn with a targeted one now that + # we have both the widget's _id and the plot's _id. + plot_ref = self + wid_id = widget._id + def _targeted_push(): + if plot_ref._fig is not None: + fields = {k: v for k, v in widget._data.items() + if k not in ("id", "type")} + plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) + widget._push_fn = _targeted_push + + self._widgets[widget.id] = widget + self._push() # full panel push once so JS knows about the widget + return widget + + def get_widget(self, wid) -> Widget: + """Return the Widget object by ID string or Widget instance.""" + if isinstance(wid, Widget): + wid = wid.id + try: + return self._widgets[wid] + except KeyError: + raise KeyError(wid) + + def remove_widget(self, wid) -> None: + """Remove a widget by ID string or Widget instance.""" + if isinstance(wid, Widget): + wid = wid.id + if wid not in self._widgets: + raise KeyError(wid) + del self._widgets[wid] + self._push() + + def list_widgets(self) -> list: + return list(self._widgets.values()) + + def clear_widgets(self) -> None: + self._widgets.clear() + self._push() + + # ------------------------------------------------------------------ + # Callback API (Plot2D) + # ------------------------------------------------------------------ + def on_changed(self, fn: Callable) -> Callable: + """Decorator: fires on every pan/zoom/drag frame on this panel.""" + cid = self.callbacks.connect("on_changed", fn) + fn._cid = cid + return fn + + def on_release(self, fn: Callable) -> Callable: + """Decorator: fires once when pan/zoom/drag settles on this panel.""" + cid = self.callbacks.connect("on_release", fn) + fn._cid = cid + return fn + + def on_click(self, fn: Callable) -> Callable: + """Decorator: fires on click on this panel.""" + cid = self.callbacks.connect("on_click", fn) + fn._cid = cid + return fn + + def on_key(self, key_or_fn=None) -> Callable: + """Register a key-press handler for this panel. + + Two call forms are supported:: + + @plot.on_key('q') # fires only when 'q' is pressed + def handler(event): ... + + @plot.on_key # fires for every registered key + def handler(event): ... + + The event carries: ``key``, ``mouse_x``, ``mouse_y``, ``phys_x``, + and ``last_widget_id``. + + .. note:: + Registered keys take priority over the built-in **r** (reset view) + shortcut. + """ + if callable(key_or_fn): + return self._connect_on_key(None, key_or_fn) + key = key_or_fn + def _decorator(fn): + return self._connect_on_key(key, fn) + return _decorator + + def _connect_on_key(self, key, fn) -> Callable: + if key is None: + if '*' not in self._state['registered_keys']: + self._state['registered_keys'].append('*') + self._push() + cid = self.callbacks.connect("on_key", fn) + else: + if key not in self._state['registered_keys']: + self._state['registered_keys'].append(key) + self._push() + def _wrapped(event): + if event.data.get('key') == key: + fn(event) + cid = self.callbacks.connect("on_key", _wrapped) + _wrapped._cid = cid + fn._cid = cid + return fn + + def disconnect(self, cid: int) -> None: + """Remove the callback registered under integer *cid*.""" + self.callbacks.disconnect(cid) + + # ------------------------------------------------------------------ + # View control + # ------------------------------------------------------------------ + def set_view(self, + x0: float | None = None, x1: float | None = None, + y0: float | None = None, y1: float | None = None) -> None: + """Set the viewport to a data-space rectangle. + + Parameters + ---------- + x0, x1 : float, optional + Horizontal data-space range to show. If omitted the full + x-extent is used for zoom calculation. + y0, y1 : float, optional + Vertical data-space range to show. If omitted the full + y-extent is used for zoom calculation. + + Translates the requested rectangle into the ``zoom`` / ``center_x`` + / ``center_y`` state values used by the 2-D JS renderer. + """ + xarr = np.asarray(self._state["x_axis"]) + yarr = np.asarray(self._state["y_axis"]) + if len(xarr) < 2 or len(yarr) < 2: + return + + xmin, xmax = float(xarr[0]), float(xarr[-1]) + ymin, ymax = float(yarr[0]), float(yarr[-1]) + x_span = xmax - xmin or 1.0 + y_span = ymax - ymin or 1.0 + + zoom_candidates = [] + + if x0 is not None and x1 is not None: + fx0 = max(0.0, min(1.0, (float(x0) - xmin) / x_span)) + fx1 = max(0.0, min(1.0, (float(x1) - xmin) / x_span)) + if fx1 > fx0: + self._state["center_x"] = (fx0 + fx1) / 2.0 + zoom_candidates.append(1.0 / (fx1 - fx0)) + + if y0 is not None and y1 is not None: + fy0 = max(0.0, min(1.0, (float(y0) - ymin) / y_span)) + fy1 = max(0.0, min(1.0, (float(y1) - ymin) / y_span)) + if fy1 > fy0: + self._state["center_y"] = (fy0 + fy1) / 2.0 + zoom_candidates.append(1.0 / (fy1 - fy0)) + + if zoom_candidates: + self._state["zoom"] = min(zoom_candidates) + self._state["_view_from_python"] = True + self._push() + self._state["_view_from_python"] = False + + def reset_view(self) -> None: + """Reset pan and zoom to show the full image.""" + self._state["zoom"] = 1.0 + self._state["center_x"] = 0.5 + self._state["center_y"] = 0.5 + self._state["_view_from_python"] = True + self._push() + self._state["_view_from_python"] = False + + # ------------------------------------------------------------------ + # Marker API (matplotlib-style kwargs → MarkerRegistry) + # ------------------------------------------------------------------ + def _add_marker(self, mtype: str, name: str | None, **kwargs) -> "MarkerGroup": # noqa: F821 + return self.markers.add(mtype, name, **kwargs) + + def add_circles(self, offsets, name=None, *, radius=5, + facecolors=None, edgecolors="#ff0000", + linewidths=1.5, alpha=0.3, + hover_edgecolors=None, hover_facecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + """Add circle markers at (x, y) positions in data coordinates.""" + return self._add_marker("circles", name, offsets=offsets, radius=radius, + facecolors=facecolors, edgecolors=edgecolors, + linewidths=linewidths, alpha=alpha, + hover_edgecolors=hover_edgecolors, + hover_facecolors=hover_facecolors, + labels=labels, label=label) + + def add_points(self, offsets, name=None, *, sizes=5, + color="#ff0000", facecolors=None, + linewidths=1.5, alpha=0.3, + hover_edgecolors=None, hover_facecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + """Add point markers at (x, y) positions in data coordinates.""" + return self._add_marker("circles", name, offsets=offsets, radius=sizes, + edgecolors=color, facecolors=facecolors, + linewidths=linewidths, alpha=alpha, + hover_edgecolors=hover_edgecolors, + hover_facecolors=hover_facecolors, + labels=labels, label=label) + + def add_hlines(self, y_values, name=None, *, + color="#ff0000", linewidths=1.5, + hover_edgecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + """Add static horizontal lines at the given y positions.""" + return self._add_marker("hlines", name, offsets=y_values, + color=color, linewidths=linewidths, + hover_edgecolors=hover_edgecolors, + labels=labels, label=label) + + def add_vlines(self, x_values, name=None, *, + color="#ff0000", linewidths=1.5, + hover_edgecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + """Add static vertical lines at the given x positions.""" + return self._add_marker("vlines", name, offsets=x_values, + color=color, linewidths=linewidths, + hover_edgecolors=hover_edgecolors, + labels=labels, label=label) + + def add_arrows(self, offsets, U, V, name=None, *, + edgecolors="#ff0000", linewidths=1.5, + hover_edgecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + return self._add_marker("arrows", name, offsets=offsets, U=U, V=V, + edgecolors=edgecolors, linewidths=linewidths, + hover_edgecolors=hover_edgecolors, + labels=labels, label=label) + + def add_ellipses(self, offsets, widths, heights, name=None, *, + angles=0, facecolors=None, edgecolors="#ff0000", + linewidths=1.5, alpha=0.3, + hover_edgecolors=None, hover_facecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + return self._add_marker("ellipses", name, offsets=offsets, + widths=widths, heights=heights, angles=angles, + facecolors=facecolors, edgecolors=edgecolors, + linewidths=linewidths, alpha=alpha, + hover_edgecolors=hover_edgecolors, + hover_facecolors=hover_facecolors, + labels=labels, label=label) + + def add_lines(self, segments, name=None, *, + edgecolors="#ff0000", linewidths=1.5, + hover_edgecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + return self._add_marker("lines", name, segments=segments, + edgecolors=edgecolors, linewidths=linewidths, + hover_edgecolors=hover_edgecolors, + labels=labels, label=label) + + def add_rectangles(self, offsets, widths, heights, name=None, *, + angles=0, facecolors=None, edgecolors="#ff0000", + linewidths=1.5, alpha=0.3, + hover_edgecolors=None, hover_facecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + return self._add_marker("rectangles", name, offsets=offsets, + widths=widths, heights=heights, angles=angles, + facecolors=facecolors, edgecolors=edgecolors, + linewidths=linewidths, alpha=alpha, + hover_edgecolors=hover_edgecolors, + hover_facecolors=hover_facecolors, + labels=labels, label=label) + + def add_squares(self, offsets, widths, name=None, *, + angles=0, facecolors=None, edgecolors="#ff0000", + linewidths=1.5, alpha=0.3, + hover_edgecolors=None, hover_facecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + return self._add_marker("squares", name, offsets=offsets, + widths=widths, angles=angles, + facecolors=facecolors, edgecolors=edgecolors, + linewidths=linewidths, alpha=alpha, + hover_edgecolors=hover_edgecolors, + hover_facecolors=hover_facecolors, + labels=labels, label=label) + + def add_polygons(self, vertices_list, name=None, *, + facecolors=None, edgecolors="#ff0000", + linewidths=1.5, alpha=0.3, + hover_edgecolors=None, hover_facecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + return self._add_marker("polygons", name, vertices_list=vertices_list, + facecolors=facecolors, edgecolors=edgecolors, + linewidths=linewidths, alpha=alpha, + hover_edgecolors=hover_edgecolors, + hover_facecolors=hover_facecolors, + labels=labels, label=label) + + def add_texts(self, offsets, texts, name=None, *, + color="#ff0000", fontsize=12, + hover_edgecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + return self._add_marker("texts", name, offsets=offsets, texts=texts, + color=color, fontsize=fontsize, + hover_edgecolors=hover_edgecolors, + labels=labels, label=label) + + def remove_marker(self, marker_type: str, name: str) -> None: + """Remove a named marker collection by type and name. + + Parameters + ---------- + marker_type : str + Collection type, e.g. ``"points"``, ``"vlines"``. + name : str + The name used when the collection was created. + """ + self.markers.remove(marker_type, name) + + def clear_markers(self) -> None: + """Remove all marker collections from this panel.""" + self.markers.clear() + + def list_markers(self) -> list: + """Return a summary list of all marker collections on this panel. + + Returns + ------- + list of dict + Each dict has keys ``"type"``, ``"name"``, and ``"n"`` + (number of markers in the collection). + """ + out = [] + for mtype, td in self.markers._types.items(): + for name, g in td.items(): + out.append({"type": mtype, "name": name, "n": g._count()}) + return out + + def __repr__(self) -> str: + w = self._state.get("image_width", "?") + h = self._state.get("image_height", "?") + cmap = self._state.get("colormap_name", "?") + return f"Plot2D({w}×{h}, cmap={cmap!r})" diff --git a/anyplotlib/plot2d/_plotmesh.py b/anyplotlib/plot2d/_plotmesh.py new file mode 100644 index 00000000..cad16dad --- /dev/null +++ b/anyplotlib/plot2d/_plotmesh.py @@ -0,0 +1,108 @@ +""" +plot2d/_plotmesh.py +=================== +pcolormesh panel (non-uniform grid). +""" + +from __future__ import annotations + +import numpy as np + +from anyplotlib.markers import MarkerRegistry +from anyplotlib.plot2d._plot2d import Plot2D +from anyplotlib._utils import _normalize_image, _build_colormap_lut, _resample_mesh + + +class PlotMesh(Plot2D): + """2-D mesh plot panel created by :meth:`Axes.pcolormesh`. + + Accepts cell *edge* arrays (length N+1 / M+1) rather than centre arrays, + matches matplotlib's ``pcolormesh`` convention. Only ``'circles'`` and + ``'lines'`` markers are supported. + """ + + def __init__(self, data: np.ndarray, + x_edges=None, y_edges=None, units: str = ""): + data = np.asarray(data) + if data.ndim != 2: + raise ValueError(f"data must be 2-D (M x N), got {data.shape}") + rows, cols = data.shape + + if x_edges is None: + x_edges = np.arange(cols + 1, dtype=float) + if y_edges is None: + y_edges = np.arange(rows + 1, dtype=float) + x_edges = np.asarray(x_edges, dtype=float) + y_edges = np.asarray(y_edges, dtype=float) + + if len(x_edges) != cols + 1: + raise ValueError( + f"x_edges must have length {cols + 1} for {cols} columns, " + f"got {len(x_edges)}") + if len(y_edges) != rows + 1: + raise ValueError( + f"y_edges must have length {rows + 1} for {rows} rows, " + f"got {len(y_edges)}") + + # Resample to a regular pixel grid for display + resampled = _resample_mesh(data, x_edges, y_edges) + + # Use cell centres to initialise the parent (axes will be replaced) + x_c = (x_edges[:-1] + x_edges[1:]) / 2.0 + y_c = (y_edges[:-1] + y_edges[1:]) / 2.0 + super().__init__(resampled, x_axis=x_c, y_axis=y_c, units=units) + + # Override mesh-specific state + self._state["is_mesh"] = True + self._state["has_axes"] = True + # Store edges (not centres) so the JS renderer can place grid lines + self._state["x_axis"] = x_edges.tolist() + self._state["y_axis"] = y_edges.tolist() + # Mesh panels have no fixed pixel scale + self._state.pop("scale_x", None) + self._state.pop("scale_y", None) + + # Restrict markers to circles + lines only + self.markers = MarkerRegistry(self._push_markers, + allowed=MarkerRegistry._KNOWN_MESH) + + # ------------------------------------------------------------------ + # Data + # ------------------------------------------------------------------ + def set_data(self, data: np.ndarray, + x_edges=None, y_edges=None, units: str | None = None) -> None: + """Replace the mesh data (and optionally the edge arrays).""" + data = np.asarray(data) + if data.ndim != 2: + raise ValueError(f"data must be 2-D, got {data.shape}") + rows, cols = data.shape + + cur_xe = np.asarray(self._state["x_axis"], dtype=float) + cur_ye = np.asarray(self._state["y_axis"], dtype=float) + xe = np.asarray(x_edges, dtype=float) if x_edges is not None else cur_xe + ye = np.asarray(y_edges, dtype=float) if y_edges is not None else cur_ye + + if len(xe) != cols + 1: + raise ValueError(f"x_edges must have length {cols + 1}") + if len(ye) != rows + 1: + raise ValueError(f"y_edges must have length {rows + 1}") + + resampled = _resample_mesh(data, xe, ye) + img_u8, vmin, vmax = _normalize_image(resampled) + self._raw_u8, self._raw_vmin, self._raw_vmax = img_u8, vmin, vmax + + self._state.update({ + "image_b64": self._encode_bytes(img_u8), + "image_width": cols, + "image_height": rows, + "x_axis": xe.tolist(), + "y_axis": ye.tolist(), + "display_min": vmin, + "display_max": vmax, + "raw_min": vmin, + "raw_max": vmax, + "colormap_data": _build_colormap_lut(self._state["colormap_name"]), + }) + if units is not None: + self._state["units"] = units + self._push() From 6738d86c40c7465ed1ca3c9e0b42a48b667d50bd Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 11 May 2026 08:26:50 -0500 Subject: [PATCH 104/198] fix: add module docstrings to subpackage __init__.py files Addresses code quality review flag: adds one-line docstrings to anyplotlib/widgets, anyplotlib/plot3d, and anyplotlib/plot2d __init__.py files. Also updates figure_plots.py docstring to reflect its new role as a compatibility shim re-exporting classes from dedicated subpackages. --- anyplotlib/figure_plots.py | 25 ++++++++++--------------- anyplotlib/plot2d/__init__.py | 1 + anyplotlib/plot3d/__init__.py | 1 + anyplotlib/widgets/__init__.py | 1 + 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/anyplotlib/figure_plots.py b/anyplotlib/figure_plots.py index b58c8fdf..b1311d11 100644 --- a/anyplotlib/figure_plots.py +++ b/anyplotlib/figure_plots.py @@ -1,19 +1,14 @@ """ -figure_plots.py -=============== - -Pure-Python plot objects returned by Axes.imshow() / Axes.plot(). - -These are NOT anywidget subclasses. They hold all state in plain dicts and -push changes into the parent Figure's per-panel traitlet via _push(). - -Public classes --------------- -GridSpec – describes a grid layout (nrows x ncols, ratios). -SubplotSpec – a slice of a GridSpec (row/col spans). -Axes – a grid cell; .imshow() / .plot() return a plot object. -Plot2D – 2-D image panel, full Viewer2D-compatible API. -Plot1D – 1-D line panel, full Viewer1D-compatible API. +figure_plots.py (compatibility shim) +===================================== +This module re-exports classes that have been moved to dedicated subpackages. +Import directly from those subpackages for clarity: + + from anyplotlib.plot2d import Plot2D, PlotMesh + from anyplotlib.plot3d import Plot3D + from anyplotlib.plot1d import Plot1D, PlotBar + from anyplotlib.axes import Axes, InsetAxes + from anyplotlib.figure import Figure, GridSpec, SubplotSpec """ from __future__ import annotations diff --git a/anyplotlib/plot2d/__init__.py b/anyplotlib/plot2d/__init__.py index 18ef98fc..27f2ee00 100644 --- a/anyplotlib/plot2d/__init__.py +++ b/anyplotlib/plot2d/__init__.py @@ -1,3 +1,4 @@ +"""anyplotlib.plot2d — 2-D image plot panel classes (Plot2D, PlotMesh).""" from anyplotlib.plot2d._plot2d import Plot2D from anyplotlib.plot2d._plotmesh import PlotMesh diff --git a/anyplotlib/plot3d/__init__.py b/anyplotlib/plot3d/__init__.py index 5c0b7173..7ad80921 100644 --- a/anyplotlib/plot3d/__init__.py +++ b/anyplotlib/plot3d/__init__.py @@ -1,3 +1,4 @@ +"""anyplotlib.plot3d — 3-D surface, scatter, and line plot panel.""" from anyplotlib.plot3d._plot3d import Plot3D __all__ = ["Plot3D"] diff --git a/anyplotlib/widgets/__init__.py b/anyplotlib/widgets/__init__.py index a3e9972f..82d32da0 100644 --- a/anyplotlib/widgets/__init__.py +++ b/anyplotlib/widgets/__init__.py @@ -1,3 +1,4 @@ +"""anyplotlib.widgets — interactive overlay widget classes.""" from anyplotlib.widgets._base import Widget from anyplotlib.widgets._widgets2d import ( RectangleWidget, CircleWidget, AnnularWidget, From c50aadc9f18c655a4456b1f8797617dc8be458a5 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 11 May 2026 09:12:13 -0500 Subject: [PATCH 105/198] refactor: extract Plot1D, Line1D, PlotBar into plot1d/ subpackage Line1D and Plot1D kept in the same file (_plot1d.py) to avoid circular imports; figure_plots.py retains backward-compatible re-exports. --- anyplotlib/figure_plots.py | 1809 +-------------------------------- anyplotlib/plot1d/__init__.py | 6 + anyplotlib/plot1d/_plot1d.py | 1383 +++++++++++++++++++++++++ anyplotlib/plot1d/_plotbar.py | 468 +++++++++ 4 files changed, 1858 insertions(+), 1808 deletions(-) create mode 100644 anyplotlib/plot1d/__init__.py create mode 100644 anyplotlib/plot1d/_plot1d.py create mode 100644 anyplotlib/plot1d/_plotbar.py diff --git a/anyplotlib/figure_plots.py b/anyplotlib/figure_plots.py index b1311d11..99c4a290 100644 --- a/anyplotlib/figure_plots.py +++ b/anyplotlib/figure_plots.py @@ -37,6 +37,7 @@ ) from anyplotlib.plot3d import Plot3D # noqa: F401 from anyplotlib.plot2d import Plot2D, PlotMesh # noqa: F401 +from anyplotlib.plot1d import Line1D, Plot1D, PlotBar # noqa: F401 __all__ = ["GridSpec", "SubplotSpec", "Axes", "InsetAxes", "Line1D", "Plot1D", "Plot2D", "PlotMesh", "Plot3D", "PlotBar", "_plot_kind", "_resample_mesh", "_norm_linestyle"] @@ -517,1814 +518,6 @@ def __repr__(self) -> str: return f"Axes(rows={self._spec.row_start}:{self._spec.row_stop}, cols={self._spec.col_start}:{self._spec.col_stop}, {kind})" -# --------------------------------------------------------------------------- -# Line1D — per-line handle -# --------------------------------------------------------------------------- - -class Line1D: - """Handle to a single line on a :class:`Plot1D` panel. - - Returned by :meth:`Plot1D.add_line`. Use it to update the line data, - register hover/click callbacks scoped to just that line, or to remove - it later. - - Attributes - ---------- - id : str | None - ``None`` for the primary line; an 8-character UUID string for - overlay lines added with :meth:`Plot1D.add_line`. - """ - - def __init__(self, plot: "Plot1D", lid: str | None): - self._plot = plot - self._lid = lid - - @property - def id(self) -> str | None: - return self._lid - - def __str__(self) -> str: - return "" if self._lid is None else self._lid - - def __repr__(self) -> str: - return f"Line1D(id={self._lid!r})" - - def __eq__(self, other) -> bool: - if isinstance(other, Line1D): - return self._lid == other._lid - if isinstance(other, str): - return self._lid == other - return NotImplemented - - def __hash__(self) -> int: - return hash(self._lid) - - # ------------------------------------------------------------------ - def on_hover(self, fn: Callable) -> Callable: - """Decorator: fires when the cursor moves over *this* line only.""" - target_lid = self._lid - def _filtered(event): - if event.data.get("line_id") == target_lid: - fn(event) - cid = self._plot.callbacks.connect("on_line_hover", _filtered) - _filtered._cid = cid - fn._cid = cid - return fn - - def on_click(self, fn: Callable) -> Callable: - """Decorator: fires when the user clicks on *this* line only.""" - target_lid = self._lid - def _filtered(event): - if event.data.get("line_id") == target_lid: - fn(event) - cid = self._plot.callbacks.connect("on_line_click", _filtered) - _filtered._cid = cid - fn._cid = cid - return fn - - def set_data(self, y: "np.ndarray", x_axis=None) -> None: - """Update the y-data (and optionally x-axis) of this overlay line. - - The y-axis range is recomputed and the panel re-renders immediately. - - Parameters - ---------- - y : array-like, shape (N,) - New y values. Must be 1-D. - x_axis : array-like, shape (N,), optional - New x coordinates. If omitted the existing x-axis is kept. - - Raises - ------ - ValueError - If called on the primary line (use :meth:`Plot1D.set_data` - instead), or if *y* is not 1-D. - KeyError - If this line has already been removed. - """ - if self._lid is None: - raise ValueError( - "Cannot call set_data() on the primary line; " - "use plot.set_data() instead." - ) - y = np.asarray(y, dtype=float) - if y.ndim != 1: - raise ValueError("y must be 1-D") - for entry in self._plot._state["extra_lines"]: - if entry["id"] == self._lid: - entry["data"] = y - if x_axis is not None: - entry["x_axis"] = np.asarray(x_axis, dtype=float) - break - else: - raise KeyError(self._lid) - self._plot._recompute_data_range() - self._plot._push() - - def remove(self) -> None: - """Remove this overlay line from its parent plot.""" - if self._lid is None: - raise ValueError("Cannot remove the primary line via Line1D.remove().") - self._plot.remove_line(self._lid) - - -# --------------------------------------------------------------------------- -# Plot1D -# --------------------------------------------------------------------------- - -class Plot1D: - """1-D line plot panel returned by :meth:`Axes.plot`. - - All display state is stored in a plain ``_state`` dict. Every mutation - ends with :meth:`_push`, which serialises the state to the parent - ``Figure`` trait so the JS renderer picks up the change immediately. - - Supported line properties - ------------------------- - Set at construction time via :meth:`Axes.plot` or updated afterwards - with the corresponding setter: - - .. list-table:: - :header-rows: 1 - :widths: 18 18 64 - - * - Parameter - - Default - - Description - * - ``color`` - - ``"#4fc3f7"`` - - CSS colour string for the primary line. - * - ``linewidth`` - - ``1.5`` - - Stroke width in pixels. - * - ``linestyle`` (``ls``) - - ``"solid"`` - - Dash pattern: ``"solid"``, ``"dashed"``, ``"dotted"``, - ``"dashdot"``. Shorthands ``"-"``, ``"--"``, ``":"``, - ``"-."`` also accepted. - * - ``alpha`` - - ``1.0`` - - Line opacity (0 = transparent, 1 = fully opaque). - * - ``marker`` - - ``"none"`` - - Per-point symbol: ``"o"`` (circle), ``"s"`` (square), - ``"^"``/``"v"`` (triangles), ``"D"`` (diamond), - ``"+"``/``"x"`` (stroke-only), or ``"none"``. - * - ``markersize`` - - ``4.0`` - - Marker radius / half-side in pixels. - * - ``label`` - - ``""`` - - Legend label (empty string = no legend entry). - - - Public API summary - ------------------ - - **Data** - :meth:`update` — replace y-data (and optionally the x-axis / - units) without recreating the panel. - - **Overlay lines** - :meth:`add_line` / :meth:`remove_line` / :meth:`clear_lines` — - overlay additional curves on the same axes. - - **Shaded spans** - :meth:`add_span` / :meth:`remove_span` / :meth:`clear_spans` — - highlight a region along the x- or y-axis. - - **View control** - :meth:`set_view` / :meth:`reset_view` — programmatic pan/zoom - (users can also pan/zoom interactively with the mouse; press **R** - to reset). - - **Interactive widgets** - :meth:`add_vline_widget` / :meth:`add_hline_widget` / - :meth:`add_range_widget` — draggable overlays that report their - position back to Python via callbacks. Manage them with - :meth:`get_widget`, :meth:`remove_widget`, :meth:`list_widgets`, - and :meth:`clear_widgets`. - - **Static marker collections** - :meth:`add_points` / :meth:`add_circles` / :meth:`add_vlines` / - :meth:`add_hlines` / :meth:`add_arrows` / :meth:`add_ellipses` / - :meth:`add_lines` / :meth:`add_rectangles` / :meth:`add_squares` / - :meth:`add_polygons` / :meth:`add_texts` — fixed overlays - positioned at explicit data coordinates. Access them via - ``plot.markers[type][name]`` and manage with :meth:`remove_marker`, - :meth:`clear_markers`, and :meth:`list_markers`. - - **Callbacks** - :meth:`on_changed` / :meth:`on_release` / :meth:`on_click` / - :meth:`on_key` — react to pan/zoom frames, mouse clicks, and - key-presses. Remove a handler with :meth:`disconnect`. - """ - - def __init__(self, data: np.ndarray, - x_axis=None, - units: str = "px", - y_units: str = "", - color: str = "#4fc3f7", - linewidth: float = 1.5, - linestyle: str = "solid", - alpha: float = 1.0, - marker: str = "none", - markersize: float = 4.0, - label: str = ""): - self._id: str = "" - self._fig: object = None - - data = np.asarray(data, dtype=float) - if data.ndim != 1: - raise ValueError(f"data must be 1-D, got {data.shape}") - n = len(data) - if x_axis is None: - x_axis = np.arange(n, dtype=float) - x_axis = np.asarray(x_axis, dtype=float) - if len(x_axis) != n: - raise ValueError("x_axis length must match data length") - - dmin = float(np.nanmin(data)) - dmax = float(np.nanmax(data)) - pad = (dmax - dmin) * 0.05 if dmax > dmin else 0.5 - dmin -= pad; dmax += pad - - self._state: dict = { - "kind": "1d", - "data": data, # numpy float64 — encoded in to_state_dict() - "x_axis": x_axis, # numpy float64 — encoded in to_state_dict() - "units": units, - "y_units": y_units, - "data_min": dmin, - "data_max": dmax, - "view_x0": 0.0, - "view_x1": 1.0, - "line_color": color, - "line_linewidth": float(linewidth), - "line_linestyle": _norm_linestyle(linestyle), - "line_alpha": float(alpha), - "line_marker": marker if marker is not None else "none", - "line_markersize": float(markersize), - "line_label": label, - "extra_lines": [], - "spans": [], - "overlay_widgets": [], - "markers": [], - "registered_keys": [], - } - - self.markers = MarkerRegistry(self._push_markers, - allowed=MarkerRegistry._KNOWN_1D) - self.callbacks = CallbackRegistry() - self._widgets: dict[str, Widget] = {} - - def _push(self) -> None: - if self._fig is None: - return - self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] - self._fig._push(self._id) - - def _push_markers(self) -> None: - self._state["markers"] = self.markers.to_wire_list() - self._push() - - def to_state_dict(self) -> dict: - d = dict(self._state) - # Replace numpy arrays with b64-encoded strings for the wire format. - data_arr = d.pop("data") - x_arr = d.pop("x_axis") - d["data_b64"] = _arr_to_b64(data_arr, np.float64) - d["x_axis_b64"] = _arr_to_b64(x_arr, np.float64) - d["data_length"] = len(data_arr) - # Encode extra-line arrays too - new_extra = [] - for ex in d["extra_lines"]: - ex2 = dict(ex) - ex2["data_b64"] = _arr_to_b64(ex2.pop("data"), np.float64) - ex2["x_axis_b64"] = _arr_to_b64( - np.asarray(ex2.pop("x_axis"), dtype=np.float64), np.float64) - new_extra.append(ex2) - d["extra_lines"] = new_extra - d["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] - d["markers"] = self.markers.to_wire_list() - return d - - @property - def line(self) -> "Line1D": - """Handle for the primary line, enabling per-line callbacks. - - Returns a :class:`Line1D` with ``id=None`` so you can register - hover / click handlers scoped to just the primary line:: - - @plot.line.on_click - def on_primary_click(event): - print(f"primary line clicked at x={event.x:.3f}") - """ - return Line1D(self, None) - - # ------------------------------------------------------------------ - # Data - # ------------------------------------------------------------------ - @property - def data(self) -> np.ndarray: - """The primary line's y-data (read-only). - - Returns a float64 copy with ``writeable=False``. To replace the - data call :meth:`set_data`. - """ - arr = self._state["data"].copy() - arr.flags.writeable = False - return arr - - def set_data(self, data: np.ndarray, x_axis=None, - units: str | None = None, y_units: str | None = None) -> None: - """Replace the primary line's y-data and optionally its x-axis / units. - - The y-axis range (``data_min`` / ``data_max``) is recomputed - automatically. The viewport is **not** reset — call - :meth:`reset_view` explicitly if needed. - - Parameters - ---------- - data : array-like, shape (N,) - New y values. Must be 1-D. - x_axis : array-like, shape (N,), optional - New x coordinates. If omitted and the length of *data* matches - the current x-axis, the existing x-axis is reused; otherwise it - is reset to ``0, 1, …, N-1``. - units : str, optional - New x-axis label. Unchanged if not supplied. - y_units : str, optional - New y-axis label. Unchanged if not supplied. - """ - data = np.asarray(data, dtype=float) - if data.ndim != 1: - raise ValueError(f"data must be 1-D, got {data.shape}") - n = len(data) - if x_axis is None: - prev = self._state["x_axis"] # already a numpy array - x_axis = prev if len(prev) == n else np.arange(n, dtype=float) - x_axis = np.asarray(x_axis, dtype=float) - - dmin = float(np.nanmin(data)) - dmax = float(np.nanmax(data)) - pad = (dmax - dmin) * 0.05 if dmax > dmin else 0.5 - - self._state["data"] = data - self._state["x_axis"] = x_axis - self._state["data_min"] = dmin - pad - self._state["data_max"] = dmax + pad - if units is not None: self._state["units"] = units - if y_units is not None: self._state["y_units"] = y_units - self._push() - - def _recompute_data_range(self) -> None: - """Recompute data_min/data_max across the primary line and all overlays. - - Called automatically whenever the set of lines changes so that every - curve stays fully visible. - """ - all_vals = [self._state["data"]] # already a numpy float64 array - for ex in self._state["extra_lines"]: - d = ex.get("data") - if d is not None and len(d): - all_vals.append(d) - combined = np.concatenate(all_vals) - dmin = float(np.nanmin(combined)) - dmax = float(np.nanmax(combined)) - pad = (dmax - dmin) * 0.05 if dmax > dmin else 0.5 - self._state["data_min"] = dmin - pad - self._state["data_max"] = dmax + pad - - # ------------------------------------------------------------------ - # Extra lines - # ------------------------------------------------------------------ - def add_line(self, data: np.ndarray, x_axis=None, - color: str = "#ffffff", linewidth: float = 1.5, - linestyle: str = "solid", ls: str | None = None, - alpha: float = 1.0, - marker: str = "none", markersize: float = 4.0, - label: str = "") -> "Line1D": - """Overlay an additional curve on this panel. - - The y-axis range is automatically expanded to include the new data so - all lines remain fully visible. - - Parameters - ---------- - data : array-like, shape (N,) - Y values for the new line. Must be 1-D. - x_axis : array-like, shape (N,), optional - X coordinates. Defaults to the primary line's x-axis. - color : str, optional - CSS colour string. Default ``"#ffffff"``. - linewidth : float, optional - Stroke width in pixels. Default ``1.5``. - linestyle : str, optional - Dash pattern: ``"solid"``, ``"dashed"``, ``"dotted"``, - ``"dashdot"`` (or shorthands). Default ``"solid"``. - ls : str, optional - Short alias for *linestyle*. - alpha : float, optional - Line opacity (0–1). Default ``1.0``. - marker : str, optional - Per-point marker symbol (see :class:`Plot1D`). Default - ``"none"``. - markersize : float, optional - Marker radius / half-side in pixels. Default ``4.0``. - label : str, optional - Legend label. Default ``""`` (no legend entry). - - Returns - ------- - Line1D - A handle to the new overlay line. Use it to register - per-line hover/click callbacks or to remove the line later:: - - line = v.add_line(fit, color="#ffcc00", label="fit") - line.remove() # remove it - @line.on_click # per-line click handler - def clicked(event): ... - """ - data = np.asarray(data, dtype=float) - if data.ndim != 1: - raise ValueError("data must be 1-D") - xa = (np.asarray(x_axis, dtype=float) if x_axis is not None - else self._state["x_axis"]) - lid = str(_uuid.uuid4())[:8] - self._state["extra_lines"].append({ - "id": lid, - "data": data, - "x_axis": xa, - "color": color, - "linewidth": float(linewidth), - "linestyle": _norm_linestyle(ls if ls is not None else linestyle), - "alpha": float(alpha), - "marker": marker if marker is not None else "none", - "markersize": float(markersize), - "label": label, - }) - self._recompute_data_range() - self._push() - return Line1D(self, lid) - - def remove_line(self, lid: "str | Line1D") -> None: - """Remove an overlay line by its ID or :class:`Line1D` handle. - - The y-axis range is recomputed after removal. - - Parameters - ---------- - lid : str or Line1D - The value returned by :meth:`add_line`. - - Raises - ------ - KeyError - If *lid* does not match any overlay line. - """ - if isinstance(lid, Line1D): - lid = lid._lid - before = len(self._state["extra_lines"]) - self._state["extra_lines"] = [ - e for e in self._state["extra_lines"] if e["id"] != lid] - if len(self._state["extra_lines"]) == before: - raise KeyError(lid) - self._recompute_data_range() - self._push() - - def clear_lines(self) -> None: - """Remove all overlay lines, leaving the primary line intact. - - The y-axis range is recomputed after clearing. - """ - self._state["extra_lines"] = [] - self._recompute_data_range() - self._push() - - # ------------------------------------------------------------------ - # Spans - # ------------------------------------------------------------------ - def add_span(self, v0: float, v1: float, - axis: str = "x", color: str | None = None) -> str: - """Add a shaded span along the x- or y-axis. - - Parameters - ---------- - v0, v1 : float - Start and end of the span in data coordinates. - axis : ``"x"`` | ``"y"``, optional - Which axis the span runs along. Default ``"x"``. - color : str, optional - CSS colour string (supports alpha, e.g. - ``"rgba(255,200,0,0.2)"``). Defaults to a theme-appropriate - yellow tint. - - Returns - ------- - str - Span ID for use with :meth:`remove_span`. - """ - sid = str(_uuid.uuid4())[:8] - self._state["spans"].append({ - "id": sid, "v0": float(v0), "v1": float(v1), - "axis": axis, "color": color, - }) - self._push() - return sid - - def remove_span(self, sid: str) -> None: - """Remove a shaded span by its ID. - - Parameters - ---------- - sid : str - The ID returned by :meth:`add_span`. - - Raises - ------ - KeyError - If *sid* does not match any span. - """ - before = len(self._state["spans"]) - self._state["spans"] = [ - s for s in self._state["spans"] if s["id"] != sid] - if len(self._state["spans"]) == before: - raise KeyError(sid) - self._push() - - def clear_spans(self) -> None: - """Remove all shaded spans.""" - self._state["spans"] = [] - self._push() - - # ------------------------------------------------------------------ - # Overlay Widgets - # ------------------------------------------------------------------ - def add_vline_widget(self, x: float, color: str = "#00e5ff") -> _VLineWidget: - """Add a draggable vertical-line overlay. - - Parameters - ---------- - x : float - Initial x position in data coordinates. - color : str, optional - CSS colour string. Default ``"#00e5ff"``. - - Returns - ------- - VLineWidget - Widget object. Register position callbacks with - :meth:`on_changed` / :meth:`on_release`. - """ - widget = _VLineWidget(lambda: None, x=float(x), color=color) - plot_ref, wid_id = self, widget._id - def _tp(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _tp - self._widgets[widget.id] = widget - self._push() - return widget - - def add_hline_widget(self, y: float, color: str = "#00e5ff") -> _HLineWidget: - """Add a draggable horizontal-line overlay. - - Parameters - ---------- - y : float - Initial y position in data coordinates. - color : str, optional - CSS colour string. Default ``"#00e5ff"``. - - Returns - ------- - HLineWidget - Widget object. Register position callbacks with - :meth:`on_changed` / :meth:`on_release`. - """ - widget = _HLineWidget(lambda: None, y=float(y), color=color) - plot_ref, wid_id = self, widget._id - def _tp(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _tp - self._widgets[widget.id] = widget - self._push() - return widget - - def add_range_widget(self, x0: float, x1: float, - color: str = "#00e5ff", - style: str = "band", - y: float = 0.0, - _push: bool = True) -> _RangeWidget: - """Add a draggable range overlay to this panel. - - Parameters - ---------- - x0, x1 : float - Initial left and right edges in data coordinates. - color : str, optional - CSS colour string. Default ``"#00e5ff"``. - style : {'band', 'fwhm'}, optional - Visual style. ``'band'`` (default) draws two vertical lines with - a translucent fill. ``'fwhm'`` draws two draggable circles - connected by a dashed horizontal line at *y* (the half-maximum - level), giving an ``o-------o`` FWHM indicator. - y : float, optional - Y-coordinate (data space) for the connecting line when - ``style='fwhm'``. Ignored when ``style='band'``. Default 0. - _push : bool, optional - Push state to JS immediately. Set to ``False`` when adding - several widgets at once; call :meth:`_push` manually afterward. - - Returns - ------- - RangeWidget - Widget object. Register position callbacks with - :meth:`on_changed` / :meth:`on_release`. - """ - widget = _RangeWidget(lambda: None, x0=float(x0), x1=float(x1), - color=color, style=style, y=float(y)) - plot_ref, wid_id = self, widget._id - def _tp(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _tp - self._widgets[widget.id] = widget - if _push: - self._push() - return widget - - def add_point_widget(self, x: float, y: float, - color: str = "#00e5ff", - show_crosshair: bool = True, - _push: bool = True) -> _PointWidget: - """Add a freely-draggable control point to this panel. - - Parameters - ---------- - x : float - Initial x position in data coordinates. - y : float - Initial y position in data coordinates (value axis). - color : str, optional - CSS colour string. Default ``"#00e5ff"``. - show_crosshair : bool, optional - Draw dashed guide lines through the handle. Default ``True``. - Pass ``False`` for a plain dot with no guide lines. - _push : bool, optional - Push state to JS immediately. Set to ``False`` when adding - several widgets at once; call :meth:`_push` manually afterward. - - Returns - ------- - PointWidget - """ - widget = _PointWidget(lambda: None, x=float(x), y=float(y), color=color, - show_crosshair=show_crosshair) - plot_ref, wid_id = self, widget._id - def _tp_point(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _tp_point - self._widgets[widget.id] = widget - if _push: - self._push() - return widget - - def get_widget(self, wid) -> Widget: - """Return the Widget object by ID string or Widget instance.""" - if isinstance(wid, Widget): - wid = wid.id - try: - return self._widgets[wid] - except KeyError: - raise KeyError(wid) - - def remove_widget(self, wid) -> None: - """Remove a widget by ID string or Widget instance.""" - if isinstance(wid, Widget): - wid = wid.id - if wid not in self._widgets: - raise KeyError(wid) - del self._widgets[wid] - self._push() - - def list_widgets(self) -> list: - """Return a list of all active widget objects on this panel.""" - return list(self._widgets.values()) - - def clear_widgets(self) -> None: - """Remove all interactive overlay widgets from this panel.""" - self._widgets.clear() - self._push() - - # ------------------------------------------------------------------ - # Callback API (Plot1D) - # ------------------------------------------------------------------ - def on_changed(self, fn: Callable) -> Callable: - """Decorator: fires on every drag/zoom frame on this panel.""" - cid = self.callbacks.connect("on_changed", fn) - fn._cid = cid - return fn - - def on_release(self, fn: Callable) -> Callable: - """Decorator: fires once when drag/zoom settles on this panel.""" - cid = self.callbacks.connect("on_release", fn) - fn._cid = cid - return fn - - def on_click(self, fn: Callable) -> Callable: - """Decorator: fires on click on this panel.""" - cid = self.callbacks.connect("on_click", fn) - fn._cid = cid - return fn - - def on_key(self, key_or_fn=None) -> Callable: - """Register a key-press handler for this panel. - - Two call forms are supported:: - - @plot.on_key('q') # fires only when 'q' is pressed - def handler(event): ... - - @plot.on_key # fires for every registered key - def handler(event): ... - - The event carries: ``key``, ``mouse_x``, ``mouse_y``, ``phys_x``, - and ``last_widget_id``. - - .. note:: - Registered keys take priority over the built-in **r** (reset view) - shortcut. - """ - if callable(key_or_fn): - return self._connect_on_key(None, key_or_fn) - key = key_or_fn - def _decorator(fn): - return self._connect_on_key(key, fn) - return _decorator - - def _connect_on_key(self, key, fn) -> Callable: - if key is None: - if '*' not in self._state['registered_keys']: - self._state['registered_keys'].append('*') - self._push() - cid = self.callbacks.connect("on_key", fn) - else: - if key not in self._state['registered_keys']: - self._state['registered_keys'].append(key) - self._push() - def _wrapped(event): - if event.data.get('key') == key: - fn(event) - cid = self.callbacks.connect("on_key", _wrapped) - _wrapped._cid = cid - fn._cid = cid - return fn - - def disconnect(self, cid: int) -> None: - """Remove the callback registered under integer *cid*.""" - self.callbacks.disconnect(cid) - - def on_line_hover(self, fn: Callable) -> Callable: - """Decorator: fires when the cursor moves over *any* line on this panel. - - The event carries ``event.line_id`` (``None`` = primary line, - str = overlay), ``event.x``, and ``event.y`` in data coordinates. - For per-line filtering use :meth:`Line1D.on_hover` instead. - """ - cid = self.callbacks.connect("on_line_hover", fn) - fn._cid = cid - return fn - - def on_line_click(self, fn: Callable) -> Callable: - """Decorator: fires when the user clicks *any* line on this panel. - - The event carries the same fields as :meth:`on_line_hover`. - For per-line filtering use :meth:`Line1D.on_click` instead. - """ - cid = self.callbacks.connect("on_line_click", fn) - fn._cid = cid - return fn - - # ------------------------------------------------------------------ - # View control - # ------------------------------------------------------------------ - def set_view(self, x0: float | None = None, x1: float | None = None) -> None: - """Programmatically set the visible x range. - - Parameters - ---------- - x0 : float, optional - Left edge of the view in data coordinates. ``None`` keeps the - current left edge. - x1 : float, optional - Right edge of the view in data coordinates. ``None`` keeps the - current right edge. - """ - xarr = np.asarray(self._state["x_axis"]) - if len(xarr) < 2: - return - xmin, xmax = float(xarr[0]), float(xarr[-1]) - span = xmax - xmin or 1.0 - f0 = 0.0 if x0 is None else max(0.0, min(1.0, (float(x0)-xmin)/span)) - f1 = 1.0 if x1 is None else max(0.0, min(1.0, (float(x1)-xmin)/span)) - self._state["view_x0"] = f0 - self._state["view_x1"] = f1 - self._push() - - def reset_view(self) -> None: - """Reset the view to show the full x range of the primary line.""" - self._state["view_x0"] = 0.0 - self._state["view_x1"] = 1.0 - self._push() - - # ------------------------------------------------------------------ - # Primary-line property setters - # ------------------------------------------------------------------ - - def set_color(self, color: str) -> None: - """Set the primary line colour. - - Parameters - ---------- - color : str - Any CSS colour string (hex, ``rgb()``, named colour, etc.). - """ - self._state["line_color"] = color - self._push() - - def set_linewidth(self, linewidth: float) -> None: - """Set the primary line stroke width. - - Parameters - ---------- - linewidth : float - Stroke width in pixels. - """ - self._state["line_linewidth"] = float(linewidth) - self._push() - - def set_linestyle(self, linestyle: str) -> None: - """Set the primary line dash pattern. - - Parameters - ---------- - linestyle : str - ``"solid"`` (``"-"``), ``"dashed"`` (``"--"``), - ``"dotted"`` (``":"``), or ``"dashdot"`` (``"-."``) - """ - self._state["line_linestyle"] = _norm_linestyle(linestyle) - self._push() - - def set_alpha(self, alpha: float) -> None: - """Set the primary line opacity. - - Parameters - ---------- - alpha : float - Opacity in the range 0 (transparent) to 1 (fully opaque). - """ - self._state["line_alpha"] = float(alpha) - self._push() - - def set_marker(self, marker: str, markersize: float | None = None) -> None: - """Set the primary line per-point marker symbol. - - Parameters - ---------- - marker : str - ``"o"``, ``"s"``, ``"^"``, ``"v"``, ``"D"``, ``"+"``, - ``"x"``, or ``"none"``. - markersize : float, optional - Marker radius / half-side in pixels. Unchanged if not supplied. - """ - self._state["line_marker"] = marker if marker is not None else "none" - if markersize is not None: - self._state["line_markersize"] = float(markersize) - self._push() - - # ------------------------------------------------------------------ - # Marker API (matplotlib-style kwargs → MarkerRegistry) - # ------------------------------------------------------------------ - def _add_marker(self, mtype: str, name: str | None, **kwargs) -> "MarkerGroup": # noqa: F821 - return self.markers.add(mtype, name, **kwargs) - - def add_circles(self, offsets, name=None, *, radius=5, - facecolors=None, edgecolors="#ff0000", - linewidths=1.5, alpha=0.3, - hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - """Add circle markers at explicit (x, y) positions. - - On 1-D panels circles are rendered as filled/stroked discs; *radius* - is in canvas pixels (not data units). - - Parameters - ---------- - offsets : array-like, shape (N, 2) - Marker positions as ``[[x0, y0], [x1, y1], …]`` in data - coordinates. - name : str, optional - Registry key. Auto-generated if omitted. - radius : float or array-like, optional - Radius in pixels. Scalar or per-marker array. Default ``5``. - facecolors : str or None, optional - Fill colour. ``None`` = no fill. - edgecolors : str, optional - Stroke colour. Default ``"#ff0000"``. - linewidths : float, optional - Stroke width in pixels. Default ``1.5``. - alpha : float, optional - Fill opacity (0–1). Default ``0.3``. - hover_edgecolors, hover_facecolors : str, optional - Colour overrides applied on mouse-hover. - labels : list of str, optional - Per-marker tooltip labels. - label : str, optional - Collection-level tooltip label. - - Returns - ------- - MarkerGroup - Live group object. Call ``.set(**kwargs)`` to update in place. - """ - # On 1-D panels the native type is "points" (radius maps to sizes). - return self._add_marker("points", name, offsets=offsets, sizes=radius, - facecolors=facecolors, edgecolors=edgecolors, - linewidths=linewidths, alpha=alpha, - hover_edgecolors=hover_edgecolors, - hover_facecolors=hover_facecolors, - labels=labels, label=label) - - def add_points(self, offsets, name=None, *, sizes=5, - color="#ff0000", facecolors=None, - linewidths=1.5, alpha=0.3, - hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - """Add point markers at (x, y) positions in data coordinates. - - Parameters - ---------- - offsets : array-like, shape (N, 2) - Marker positions as ``[[x0, y0], [x1, y1], …]``. - name : str, optional - Registry key. Auto-generated if omitted. - sizes : float or array-like, optional - Radius in pixels. Scalar or per-marker array. Default ``5``. - color : str, optional - Stroke colour. Default ``"#ff0000"``. - facecolors : str or None, optional - Fill colour. ``None`` = no fill. - linewidths : float, optional - Stroke width in pixels. Default ``1.5``. - alpha : float, optional - Fill opacity (0–1). Default ``0.3``. - hover_edgecolors, hover_facecolors : str, optional - Colour overrides applied on mouse-hover. - labels : list of str, optional - Per-marker tooltip labels. - label : str, optional - Collection-level tooltip label. - - Returns - ------- - MarkerGroup - """ - return self._add_marker("points", name, offsets=offsets, sizes=sizes, - edgecolors=color, facecolors=facecolors, - linewidths=linewidths, alpha=alpha, - hover_edgecolors=hover_edgecolors, - hover_facecolors=hover_facecolors, - labels=labels, label=label) - - def add_hlines(self, y_values, name=None, *, - color="#ff0000", linewidths=1.5, - hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - """Add static horizontal lines spanning the full x range. - - Parameters - ---------- - y_values : array-like, shape (N,) - Y positions of each line in data coordinates. - name : str, optional - Registry key. Auto-generated if omitted. - color : str, optional - Line colour. Default ``"#ff0000"``. - linewidths : float, optional - Stroke width in pixels. Default ``1.5``. - hover_edgecolors : str, optional - Colour override applied on mouse-hover. - labels : list of str, optional - Per-line tooltip labels. - label : str, optional - Collection-level tooltip label. - - Returns - ------- - MarkerGroup - """ - return self._add_marker("hlines", name, offsets=y_values, - color=color, linewidths=linewidths, - hover_edgecolors=hover_edgecolors, - labels=labels, label=label) - - def add_vlines(self, x_values, name=None, *, - color="#ff0000", linewidths=1.5, - hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - """Add static vertical lines spanning the full y range. - - Parameters - ---------- - x_values : array-like, shape (N,) - X positions of each line in data coordinates. - name : str, optional - Registry key. Auto-generated if omitted. - color : str, optional - Line colour. Default ``"#ff0000"``. - linewidths : float, optional - Stroke width in pixels. Default ``1.5``. - hover_edgecolors : str, optional - Colour override applied on mouse-hover. - labels : list of str, optional - Per-line tooltip labels. - label : str, optional - Collection-level tooltip label. - - Returns - ------- - MarkerGroup - """ - return self._add_marker("vlines", name, offsets=x_values, - color=color, linewidths=linewidths, - hover_edgecolors=hover_edgecolors, - labels=labels, label=label) - - def add_arrows(self, offsets, U, V, name=None, *, - edgecolors="#ff0000", linewidths=1.5, - hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - """Add arrow markers at explicit (x, y) positions. - - Parameters - ---------- - offsets : array-like, shape (N, 2) - Arrow tail positions as ``[[x0, y0], …]`` in data coordinates. - U, V : array-like, shape (N,) - X and Y components of each arrow vector (in data units). - name : str, optional - Registry key. Auto-generated if omitted. - edgecolors : str, optional - Arrow colour. Default ``"#ff0000"``. - linewidths : float, optional - Stroke width in pixels. Default ``1.5``. - hover_edgecolors : str, optional - Colour override applied on mouse-hover. - labels : list of str, optional - Per-arrow tooltip labels. - label : str, optional - Collection-level tooltip label. - - Returns - ------- - MarkerGroup - """ - return self._add_marker("arrows", name, offsets=offsets, U=U, V=V, - edgecolors=edgecolors, linewidths=linewidths, - hover_edgecolors=hover_edgecolors, - labels=labels, label=label) - - def add_ellipses(self, offsets, widths, heights, name=None, *, - angles=0, facecolors=None, edgecolors="#ff0000", - linewidths=1.5, alpha=0.3, - hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - """Add ellipse markers at explicit (x, y) positions. - - Parameters - ---------- - offsets : array-like, shape (N, 2) - Centre positions in data coordinates. - widths, heights : float or array-like - Full width and height of each ellipse in canvas pixels. - name : str, optional - Registry key. Auto-generated if omitted. - angles : float or array-like, optional - Rotation angle(s) in degrees. Default ``0``. - facecolors : str or None, optional - Fill colour. ``None`` = no fill. - edgecolors : str, optional - Stroke colour. Default ``"#ff0000"``. - linewidths : float, optional - Stroke width in pixels. Default ``1.5``. - alpha : float, optional - Fill opacity (0–1). Default ``0.3``. - hover_edgecolors, hover_facecolors : str, optional - Colour overrides applied on mouse-hover. - labels : list of str, optional - Per-marker tooltip labels. - label : str, optional - Collection-level tooltip label. - - Returns - ------- - MarkerGroup - """ - return self._add_marker("ellipses", name, offsets=offsets, - widths=widths, heights=heights, angles=angles, - facecolors=facecolors, edgecolors=edgecolors, - linewidths=linewidths, alpha=alpha, - hover_edgecolors=hover_edgecolors, - hover_facecolors=hover_facecolors, - labels=labels, label=label) - - def add_lines(self, segments, name=None, *, - edgecolors="#ff0000", linewidths=1.5, - hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - """Add line-segment markers (static, not draggable). - - Parameters - ---------- - segments : array-like, shape (N, 2, 2) - Each segment is ``[[x0, y0], [x1, y1]]`` in data coordinates. - name : str, optional - Registry key. Auto-generated if omitted. - edgecolors : str, optional - Line colour. Default ``"#ff0000"``. - linewidths : float, optional - Stroke width in pixels. Default ``1.5``. - hover_edgecolors : str, optional - Colour override applied on mouse-hover. - labels : list of str, optional - Per-segment tooltip labels. - label : str, optional - Collection-level tooltip label. - - Returns - ------- - MarkerGroup - """ - return self._add_marker("lines", name, segments=segments, - edgecolors=edgecolors, linewidths=linewidths, - hover_edgecolors=hover_edgecolors, - labels=labels, label=label) - - def add_rectangles(self, offsets, widths, heights, name=None, *, - angles=0, facecolors=None, edgecolors="#ff0000", - linewidths=1.5, alpha=0.3, - hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - """Add rectangle markers at explicit (x, y) positions. - - Parameters - ---------- - offsets : array-like, shape (N, 2) - Centre positions in data coordinates. - widths, heights : float or array-like - Full width and height of each rectangle in canvas pixels. - name : str, optional - Registry key. Auto-generated if omitted. - angles : float or array-like, optional - Rotation angle(s) in degrees. Default ``0``. - facecolors : str or None, optional - Fill colour. ``None`` = no fill. - edgecolors : str, optional - Stroke colour. Default ``"#ff0000"``. - linewidths : float, optional - Stroke width in pixels. Default ``1.5``. - alpha : float, optional - Fill opacity (0–1). Default ``0.3``. - hover_edgecolors, hover_facecolors : str, optional - Colour overrides applied on mouse-hover. - labels : list of str, optional - Per-marker tooltip labels. - label : str, optional - Collection-level tooltip label. - - Returns - ------- - MarkerGroup - """ - return self._add_marker("rectangles", name, offsets=offsets, - widths=widths, heights=heights, angles=angles, - facecolors=facecolors, edgecolors=edgecolors, - linewidths=linewidths, alpha=alpha, - hover_edgecolors=hover_edgecolors, - hover_facecolors=hover_facecolors, - labels=labels, label=label) - - def add_squares(self, offsets, widths, name=None, *, - angles=0, facecolors=None, edgecolors="#ff0000", - linewidths=1.5, alpha=0.3, - hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - """Add square markers at explicit (x, y) positions. - - Parameters - ---------- - offsets : array-like, shape (N, 2) - Centre positions in data coordinates. - widths : float or array-like - Side length of each square in canvas pixels. - name : str, optional - Registry key. Auto-generated if omitted. - angles : float or array-like, optional - Rotation angle(s) in degrees. Default ``0``. - facecolors : str or None, optional - Fill colour. ``None`` = no fill. - edgecolors : str, optional - Stroke colour. Default ``"#ff0000"``. - linewidths : float, optional - Stroke width in pixels. Default ``1.5``. - alpha : float, optional - Fill opacity (0–1). Default ``0.3``. - hover_edgecolors, hover_facecolors : str, optional - Colour overrides applied on mouse-hover. - labels : list of str, optional - Per-marker tooltip labels. - label : str, optional - Collection-level tooltip label. - - Returns - ------- - MarkerGroup - """ - return self._add_marker("squares", name, offsets=offsets, - widths=widths, angles=angles, - facecolors=facecolors, edgecolors=edgecolors, - linewidths=linewidths, alpha=alpha, - hover_edgecolors=hover_edgecolors, - hover_facecolors=hover_facecolors, - labels=labels, label=label) - - def add_polygons(self, vertices_list, name=None, *, - facecolors=None, edgecolors="#ff0000", - linewidths=1.5, alpha=0.3, - hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - """Add polygon markers defined by explicit vertex lists. - - Parameters - ---------- - vertices_list : list of array-like, each shape (K, 2) - One polygon per element; each is a list of ``[x, y]`` vertices - in data coordinates. - name : str, optional - Registry key. Auto-generated if omitted. - facecolors : str or None, optional - Fill colour. ``None`` = no fill. - edgecolors : str, optional - Stroke colour. Default ``"#ff0000"``. - linewidths : float, optional - Stroke width in pixels. Default ``1.5``. - alpha : float, optional - Fill opacity (0–1). Default ``0.3``. - hover_edgecolors, hover_facecolors : str, optional - Colour overrides applied on mouse-hover. - labels : list of str, optional - Per-polygon tooltip labels. - label : str, optional - Collection-level tooltip label. - - Returns - ------- - MarkerGroup - """ - return self._add_marker("polygons", name, vertices_list=vertices_list, - facecolors=facecolors, edgecolors=edgecolors, - linewidths=linewidths, alpha=alpha, - hover_edgecolors=hover_edgecolors, - hover_facecolors=hover_facecolors, - labels=labels, label=label) - - def add_texts(self, offsets, texts, name=None, *, - color="#ff0000", fontsize=12, - hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 - """Add text annotations at explicit (x, y) positions. - - Parameters - ---------- - offsets : array-like, shape (N, 2) - Anchor positions in data coordinates. - texts : list of str - One string per position. - name : str, optional - Registry key. Auto-generated if omitted. - color : str, optional - Text colour. Default ``"#ff0000"``. - fontsize : int, optional - Font size in pixels. Default ``12``. - hover_edgecolors : str, optional - Colour override applied on mouse-hover. - labels : list of str, optional - Per-annotation tooltip labels. - label : str, optional - Collection-level tooltip label. - - Returns - ------- - MarkerGroup - """ - return self._add_marker("texts", name, offsets=offsets, texts=texts, - color=color, fontsize=fontsize, - hover_edgecolors=hover_edgecolors, - labels=labels, label=label) - - def remove_marker(self, marker_type: str, name: str) -> None: - """Remove a named marker collection by type and name. - - Parameters - ---------- - marker_type : str - Collection type, e.g. ``"points"``, ``"vlines"``. - name : str - The name used when the collection was created. - """ - self.markers.remove(marker_type, name) - - def clear_markers(self) -> None: - """Remove all marker collections from this panel.""" - self.markers.clear() - - def list_markers(self) -> list: - """Return a summary list of all marker collections on this panel. - - Returns - ------- - list of dict - Each dict has keys ``"type"``, ``"name"``, and ``"n"`` - (number of markers in the collection). - """ - out = [] - for mtype, td in self.markers._types.items(): - for name, g in td.items(): - out.append({"type": mtype, "name": name, "n": g._count()}) - return out - - def __repr__(self) -> str: - n = len(self._state.get("data", [])) - color = self._state.get("line_color", "?") - return f"Plot1D(n={n}, color={color!r})" - - -# --------------------------------------------------------------------------- -# _bar_x_axis helper -# --------------------------------------------------------------------------- - -def _bar_x_axis(x_centers: np.ndarray) -> list: - """Return a 2-element [x_left_edge, x_right_edge] list for a bar chart. - - The edges are half a slot-width outside the first/last bar centre so that - a vline_widget at ``x_centers[i]`` renders at exactly the bar's centre - pixel when used with ``_xToFrac1d`` / ``_fracToPx1d`` in the JS renderer. - """ - n = len(x_centers) - if n == 0: - return [0.0, 1.0] - if n == 1: - return [float(x_centers[0]) - 0.5, float(x_centers[0]) + 0.5] - slot = (float(x_centers[-1]) - float(x_centers[0])) / (n - 1) - half = slot / 2.0 - return [float(x_centers[0]) - half, float(x_centers[-1]) + half] - - -# --------------------------------------------------------------------------- -# PlotBar -# --------------------------------------------------------------------------- - -_LOG_CLAMP = 1e-10 # smallest positive value used when log_scale=True - -_DEFAULT_GROUP_PALETTE = [ - "#4fc3f7", "#ff7043", "#66bb6a", "#ab47bc", - "#ffa726", "#26c6da", "#ec407a", "#8d6e63", -] - - -def _bar_range(flat: np.ndarray, bottom: float, log_scale: bool): - """Return ``(dmin, dmax)`` with padding for the value axis.""" - if log_scale: - pos = flat[flat > 0] - dmin = float(np.nanmin(pos)) if len(pos) else _LOG_CLAMP - dmax = max(float(np.nanmax(flat)) if len(flat) else 1.0, - bottom if bottom > 0 else _LOG_CLAMP) - if dmin <= 0: - dmin = _LOG_CLAMP - if dmax <= 0: - dmax = 1.0 - dmax = 10 ** (np.log10(dmax) + 0.15) - dmin = 10 ** (np.log10(dmin) - 0.15) - else: - dmin = min(bottom, float(np.nanmin(flat)) if len(flat) else 0.0) - dmax = max(bottom, float(np.nanmax(flat)) if len(flat) else 1.0) - pad = (dmax - dmin) * 0.07 if dmax > dmin else 0.5 - dmax += pad - if dmin < bottom: - dmin -= pad - return dmin, dmax - - -class PlotBar: - """Bar-chart plot panel. - - Not an anywidget. Holds state in ``_state`` dict; every mutation calls - ``_push()`` which writes to the parent Figure's panel trait. - - Supports grouped bars (pass a 2-D *height* array with shape ``(N, G)``), - log-scale value axis, draggable overlay widgets, and hover/click callbacks. - - Created by :meth:`Axes.bar`. - """ - - def __init__(self, x, height=None, width: float = 0.8, bottom: float = 0.0, *, - align: str = "center", - color: str = "#4fc3f7", - colors=None, - orient: str = "v", - log_scale: bool = False, - group_labels=None, - group_colors=None, - show_values: bool = False, - units: str = "", - y_units: str = "", - # ── legacy backward-compat kwargs ────────────────────── - x_labels=None, - x_centers=None, - bar_width=None, - baseline=None, - values=None): - self._id: str = "" - self._fig: object = None - - # ── legacy resolution ────────────────────────────────────────── - if height is None: - if values is not None: - height = values - else: - height = x - x = None - if baseline is not None: - bottom = baseline - if bar_width is not None: - width = bar_width - - # ── height (values) — 1-D or 2-D for grouped bars ───────────── - height_arr = np.asarray(height, dtype=float) - if height_arr.ndim == 1: - groups = 1 - values_2d = height_arr.reshape(-1, 1) - elif height_arr.ndim == 2: - groups = height_arr.shape[1] - values_2d = height_arr - else: - raise ValueError( - f"height must be 1-D or 2-D, got shape {height_arr.shape}" - ) - n = values_2d.shape[0] - - if orient not in ("v", "h"): - raise ValueError("orient must be 'v' or 'h'") - - # ── x (positions or labels) ──────────────────────────────────── - _x_labels: list = [] - _x_centers: np.ndarray | None = None - - if x is not None: - x_list = list(x) - if x_list and isinstance(x_list[0], str): - _x_labels = x_list - else: - _x_centers = np.asarray(x, dtype=float) - - # Legacy keyword overrides - if x_labels is not None: - _x_labels = list(x_labels) - if x_centers is not None: - _x_centers = np.asarray(x_centers, dtype=float) - - if _x_centers is None: - _x_centers = np.arange(n, dtype=float) - if len(_x_centers) != n: - raise ValueError("x length must match height length") - - # ── data range ───────────────────────────────────────────────── - flat = values_2d.ravel() - dmin, dmax = _bar_range(flat, float(bottom), bool(log_scale)) - - # ── group colours ────────────────────────────────────────────── - if group_colors is None: - gc_list = ( - [_DEFAULT_GROUP_PALETTE[i % len(_DEFAULT_GROUP_PALETTE)] - for i in range(groups)] - if groups > 1 else [] - ) - else: - gc_list = list(group_colors) - - x_axis = _bar_x_axis(_x_centers) - - self._state: dict = { - "kind": "bar", - "values": values_2d.tolist(), # always (N, G) 2-D list - "groups": groups, - "x_centers": _x_centers.tolist(), - "x_labels": _x_labels, - "bar_color": color, - "bar_colors": list(colors) if colors is not None else [], - "group_labels": list(group_labels) if group_labels is not None else [], - "group_colors": gc_list, - "bar_width": float(width), - "orient": orient, - "baseline": float(bottom), - "log_scale": bool(log_scale), - "show_values": bool(show_values), - "data_min": dmin, - "data_max": dmax, - "units": units, - "y_units": y_units, - # overlay-widget coordinate system (mirrors Plot1D) - "x_axis": x_axis, - "view_x0": 0.0, - "view_x1": 1.0, - "overlay_widgets": [], - "registered_keys": [], - } - self.callbacks = CallbackRegistry() - self._widgets: dict[str, Widget] = {} - - # ------------------------------------------------------------------ - def _push(self) -> None: - if self._fig is None: - return - self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] - self._fig._push(self._id) - - def to_state_dict(self) -> dict: - d = dict(self._state) - d["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] - return d - - # ------------------------------------------------------------------ - # Data - # ------------------------------------------------------------------ - def set_data(self, height, x=None, x_labels=None, *, x_centers=None) -> None: - """Replace bar heights; recalculates the value-axis range automatically. - - Parameters - ---------- - height : array-like, shape ``(N,)`` or ``(N, G)`` - New bar heights. For grouped charts the group count *G* must - match the original. - x : array-like of numeric, optional - New bar positions (replaces the stored ``x_centers``). Also - accepts the legacy keyword alias ``x_centers``. - x_labels : list of str, optional - New category labels. - """ - height_arr = np.asarray(height, dtype=float) - if height_arr.ndim == 1: - values_2d = height_arr.reshape(-1, 1) - elif height_arr.ndim == 2: - expected_g = self._state.get("groups", 1) - if height_arr.shape[1] != expected_g: - raise ValueError( - f"Group count mismatch: expected {expected_g}, " - f"got {height_arr.shape[1]}" - ) - values_2d = height_arr - else: - raise ValueError( - f"height must be 1-D or 2-D, got shape {height_arr.shape}" - ) - - flat = values_2d.ravel() - baseline = self._state["baseline"] - log_scale = self._state.get("log_scale", False) - dmin, dmax = _bar_range(flat, float(baseline), bool(log_scale)) - - self._state["values"] = values_2d.tolist() - self._state["data_min"] = dmin - self._state["data_max"] = dmax - - # Accept both `x` and legacy `x_centers` keyword - _x = x if x is not None else x_centers - if _x is not None: - xc = np.asarray(_x, dtype=float) - self._state["x_centers"] = xc.tolist() - self._state["x_axis"] = _bar_x_axis(xc) - if x_labels is not None: - self._state["x_labels"] = list(x_labels) - self._push() - - # ------------------------------------------------------------------ - # Display settings - # ------------------------------------------------------------------ - def set_color(self, color: str) -> None: - """Set a single colour for all bars.""" - self._state["bar_color"] = color - self._push() - - def set_colors(self, colors) -> None: - """Set per-bar colours (list of CSS colour strings, length N).""" - self._state["bar_colors"] = list(colors) - self._push() - - def set_show_values(self, show: bool) -> None: - """Show or hide in-bar value annotations.""" - self._state["show_values"] = bool(show) - self._push() - - def set_log_scale(self, log_scale: bool) -> None: - """Enable or disable a logarithmic value axis. - - When *log_scale* is ``True`` any non-positive values are clamped to - ``1e-10`` for display; the data-range bounds are recalculated in - log-space automatically. - """ - self._state["log_scale"] = bool(log_scale) - flat = np.asarray(self._state["values"]).ravel() - baseline = self._state["baseline"] - dmin, dmax = _bar_range(flat, float(baseline), bool(log_scale)) - self._state["data_min"] = dmin - self._state["data_max"] = dmax - self._push() - - # ------------------------------------------------------------------ - # Overlay Widgets - # ------------------------------------------------------------------ - def add_vline_widget(self, x: float, color: str = "#00e5ff") -> _VLineWidget: - """Add a draggable vertical line at data position *x*.""" - widget = _VLineWidget(lambda: None, x=float(x), color=color) - plot_ref, wid_id = self, widget._id - def _tp(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _tp - self._widgets[widget.id] = widget - self._push() - return widget - - def add_hline_widget(self, y: float, color: str = "#00e5ff") -> _HLineWidget: - """Add a draggable horizontal line at value-axis position *y*.""" - widget = _HLineWidget(lambda: None, y=float(y), color=color) - plot_ref, wid_id = self, widget._id - def _tp(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _tp - self._widgets[widget.id] = widget - self._push() - return widget - - def add_range_widget(self, x0: float, x1: float, - color: str = "#00e5ff", - style: str = "band", - y: float = 0.0, - _push: bool = True) -> _RangeWidget: - """Add a draggable range overlay. See :meth:`Plot1D.add_range_widget` for full docs.""" - widget = _RangeWidget(lambda: None, x0=float(x0), x1=float(x1), - color=color, style=style, y=float(y)) - plot_ref, wid_id = self, widget._id - def _tp(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _tp - self._widgets[widget.id] = widget - if _push: - self._push() - return widget - - def add_point_widget(self, x: float, y: float, - color: str = "#00e5ff", - show_crosshair: bool = True, - _push: bool = True) -> _PointWidget: - """Add a freely-draggable control point to this panel.""" - widget = _PointWidget(lambda: None, x=float(x), y=float(y), color=color, - show_crosshair=show_crosshair) - plot_ref, wid_id = self, widget._id - def _tp(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _tp - self._widgets[widget.id] = widget - if _push: - self._push() - return widget - - def get_widget(self, wid) -> Widget: - """Return the Widget object by ID string or Widget instance.""" - if isinstance(wid, Widget): - wid = wid.id - try: - return self._widgets[wid] - except KeyError: - raise KeyError(wid) - - def remove_widget(self, wid) -> None: - """Remove a widget by ID string or Widget instance.""" - if isinstance(wid, Widget): - wid = wid.id - if wid not in self._widgets: - raise KeyError(wid) - del self._widgets[wid] - self._push() - - def list_widgets(self) -> list: - return list(self._widgets.values()) - - def clear_widgets(self) -> None: - self._widgets.clear() - self._push() - - # ------------------------------------------------------------------ - # Callbacks - # ------------------------------------------------------------------ - def on_click(self, fn: Callable) -> Callable: - """Decorator: fires when the user clicks a bar. - - The :class:`~anyplotlib.callbacks.Event` has ``bar_index``, - ``value``, ``x_center``, and ``x_label``. - """ - cid = self.callbacks.connect("on_click", fn) - fn._cid = cid - return fn - - def on_changed(self, fn: Callable) -> Callable: - """Decorator: fires on every drag frame (widget drag or hover).""" - cid = self.callbacks.connect("on_changed", fn) - fn._cid = cid - return fn - - def on_release(self, fn: Callable) -> Callable: - """Decorator: fires once when a widget drag settles.""" - cid = self.callbacks.connect("on_release", fn) - fn._cid = cid - return fn - - def on_key(self, key_or_fn=None) -> Callable: - """Register a key-press handler for this panel. - - Two call forms are supported:: - - @plot.on_key('q') # fires only when 'q' is pressed - def handler(event): ... - - @plot.on_key # fires for every registered key - def handler(event): ... - - The event carries: ``key``, ``mouse_x``, ``mouse_y``, and - ``last_widget_id``. - """ - if callable(key_or_fn): - return self._connect_on_key(None, key_or_fn) - key = key_or_fn - def _decorator(fn): - return self._connect_on_key(key, fn) - return _decorator - - def _connect_on_key(self, key, fn) -> Callable: - if key is None: - if '*' not in self._state['registered_keys']: - self._state['registered_keys'].append('*') - self._push() - cid = self.callbacks.connect("on_key", fn) - else: - if key not in self._state['registered_keys']: - self._state['registered_keys'].append(key) - self._push() - def _wrapped(event): - if event.data.get('key') == key: - fn(event) - cid = self.callbacks.connect("on_key", _wrapped) - _wrapped._cid = cid - fn._cid = cid - return fn - - def disconnect(self, cid: int) -> None: - self.callbacks.disconnect(cid) - - def __repr__(self) -> str: - n = len(self._state.get("values", [])) - orient = self._state.get("orient", "v") - groups = self._state.get("groups", 1) - if groups > 1: - return f"PlotBar(n={n}, groups={groups}, orient={orient!r})" - return f"PlotBar(n={n}, orient={orient!r})" - - # --------------------------------------------------------------------------- # _plot_kind — shared kind string for both layout serialisation and InsetAxes # --------------------------------------------------------------------------- diff --git a/anyplotlib/plot1d/__init__.py b/anyplotlib/plot1d/__init__.py new file mode 100644 index 00000000..cacaaed5 --- /dev/null +++ b/anyplotlib/plot1d/__init__.py @@ -0,0 +1,6 @@ +"""anyplotlib.plot1d — 1-D line and bar chart panel classes.""" + +from anyplotlib.plot1d._plot1d import Line1D, Plot1D +from anyplotlib.plot1d._plotbar import PlotBar + +__all__ = ["Line1D", "Plot1D", "PlotBar"] diff --git a/anyplotlib/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py new file mode 100644 index 00000000..37466604 --- /dev/null +++ b/anyplotlib/plot1d/_plot1d.py @@ -0,0 +1,1383 @@ +""" +plot1d/_plot1d.py +================= +1-D line panel (Plot1D) and its line helper (Line1D). +""" + +from __future__ import annotations + +import uuid as _uuid + +import numpy as np +from typing import Callable + +from anyplotlib.markers import MarkerRegistry +from anyplotlib.callbacks import CallbackRegistry, Event +from anyplotlib.widgets import ( + Widget, + VLineWidget as _VLineWidget, + HLineWidget as _HLineWidget, + RangeWidget as _RangeWidget, + PointWidget as _PointWidget, +) +from anyplotlib._utils import _norm_linestyle, _arr_to_b64 + + +# --------------------------------------------------------------------------- +# Line1D — per-line handle +# --------------------------------------------------------------------------- + +class Line1D: + """Handle to a single line on a :class:`Plot1D` panel. + + Returned by :meth:`Plot1D.add_line`. Use it to update the line data, + register hover/click callbacks scoped to just that line, or to remove + it later. + + Attributes + ---------- + id : str | None + ``None`` for the primary line; an 8-character UUID string for + overlay lines added with :meth:`Plot1D.add_line`. + """ + + def __init__(self, plot: "Plot1D", lid: str | None): + self._plot = plot + self._lid = lid + + @property + def id(self) -> str | None: + return self._lid + + def __str__(self) -> str: + return "" if self._lid is None else self._lid + + def __repr__(self) -> str: + return f"Line1D(id={self._lid!r})" + + def __eq__(self, other) -> bool: + if isinstance(other, Line1D): + return self._lid == other._lid + if isinstance(other, str): + return self._lid == other + return NotImplemented + + def __hash__(self) -> int: + return hash(self._lid) + + # ------------------------------------------------------------------ + def on_hover(self, fn: Callable) -> Callable: + """Decorator: fires when the cursor moves over *this* line only.""" + target_lid = self._lid + def _filtered(event): + if event.data.get("line_id") == target_lid: + fn(event) + cid = self._plot.callbacks.connect("on_line_hover", _filtered) + _filtered._cid = cid + fn._cid = cid + return fn + + def on_click(self, fn: Callable) -> Callable: + """Decorator: fires when the user clicks on *this* line only.""" + target_lid = self._lid + def _filtered(event): + if event.data.get("line_id") == target_lid: + fn(event) + cid = self._plot.callbacks.connect("on_line_click", _filtered) + _filtered._cid = cid + fn._cid = cid + return fn + + def set_data(self, y: "np.ndarray", x_axis=None) -> None: + """Update the y-data (and optionally x-axis) of this overlay line. + + The y-axis range is recomputed and the panel re-renders immediately. + + Parameters + ---------- + y : array-like, shape (N,) + New y values. Must be 1-D. + x_axis : array-like, shape (N,), optional + New x coordinates. If omitted the existing x-axis is kept. + + Raises + ------ + ValueError + If called on the primary line (use :meth:`Plot1D.set_data` + instead), or if *y* is not 1-D. + KeyError + If this line has already been removed. + """ + if self._lid is None: + raise ValueError( + "Cannot call set_data() on the primary line; " + "use plot.set_data() instead." + ) + y = np.asarray(y, dtype=float) + if y.ndim != 1: + raise ValueError("y must be 1-D") + for entry in self._plot._state["extra_lines"]: + if entry["id"] == self._lid: + entry["data"] = y + if x_axis is not None: + entry["x_axis"] = np.asarray(x_axis, dtype=float) + break + else: + raise KeyError(self._lid) + self._plot._recompute_data_range() + self._plot._push() + + def remove(self) -> None: + """Remove this overlay line from its parent plot.""" + if self._lid is None: + raise ValueError("Cannot remove the primary line via Line1D.remove().") + self._plot.remove_line(self._lid) + + +# --------------------------------------------------------------------------- +# Plot1D +# --------------------------------------------------------------------------- + +class Plot1D: + """1-D line plot panel returned by :meth:`Axes.plot`. + + All display state is stored in a plain ``_state`` dict. Every mutation + ends with :meth:`_push`, which serialises the state to the parent + ``Figure`` trait so the JS renderer picks up the change immediately. + + Supported line properties + ------------------------- + Set at construction time via :meth:`Axes.plot` or updated afterwards + with the corresponding setter: + + .. list-table:: + :header-rows: 1 + :widths: 18 18 64 + + * - Parameter + - Default + - Description + * - ``color`` + - ``"#4fc3f7"`` + - CSS colour string for the primary line. + * - ``linewidth`` + - ``1.5`` + - Stroke width in pixels. + * - ``linestyle`` (``ls``) + - ``"solid"`` + - Dash pattern: ``"solid"``, ``"dashed"``, ``"dotted"``, + ``"dashdot"``. Shorthands ``"-"``, ``"--"``, ``":"``, + ``"-."`` also accepted. + * - ``alpha`` + - ``1.0`` + - Line opacity (0 = transparent, 1 = fully opaque). + * - ``marker`` + - ``"none"`` + - Per-point symbol: ``"o"`` (circle), ``"s"`` (square), + ``"^"``/``"v"`` (triangles), ``"D"`` (diamond), + ``"+"``/``"x"`` (stroke-only), or ``"none"``. + * - ``markersize`` + - ``4.0`` + - Marker radius / half-side in pixels. + * - ``label`` + - ``""`` + - Legend label (empty string = no legend entry). + + + Public API summary + ------------------ + + **Data** + :meth:`update` — replace y-data (and optionally the x-axis / + units) without recreating the panel. + + **Overlay lines** + :meth:`add_line` / :meth:`remove_line` / :meth:`clear_lines` — + overlay additional curves on the same axes. + + **Shaded spans** + :meth:`add_span` / :meth:`remove_span` / :meth:`clear_spans` — + highlight a region along the x- or y-axis. + + **View control** + :meth:`set_view` / :meth:`reset_view` — programmatic pan/zoom + (users can also pan/zoom interactively with the mouse; press **R** + to reset). + + **Interactive widgets** + :meth:`add_vline_widget` / :meth:`add_hline_widget` / + :meth:`add_range_widget` — draggable overlays that report their + position back to Python via callbacks. Manage them with + :meth:`get_widget`, :meth:`remove_widget`, :meth:`list_widgets`, + and :meth:`clear_widgets`. + + **Static marker collections** + :meth:`add_points` / :meth:`add_circles` / :meth:`add_vlines` / + :meth:`add_hlines` / :meth:`add_arrows` / :meth:`add_ellipses` / + :meth:`add_lines` / :meth:`add_rectangles` / :meth:`add_squares` / + :meth:`add_polygons` / :meth:`add_texts` — fixed overlays + positioned at explicit data coordinates. Access them via + ``plot.markers[type][name]`` and manage with :meth:`remove_marker`, + :meth:`clear_markers`, and :meth:`list_markers`. + + **Callbacks** + :meth:`on_changed` / :meth:`on_release` / :meth:`on_click` / + :meth:`on_key` — react to pan/zoom frames, mouse clicks, and + key-presses. Remove a handler with :meth:`disconnect`. + """ + + def __init__(self, data: np.ndarray, + x_axis=None, + units: str = "px", + y_units: str = "", + color: str = "#4fc3f7", + linewidth: float = 1.5, + linestyle: str = "solid", + alpha: float = 1.0, + marker: str = "none", + markersize: float = 4.0, + label: str = ""): + self._id: str = "" + self._fig: object = None + + data = np.asarray(data, dtype=float) + if data.ndim != 1: + raise ValueError(f"data must be 1-D, got {data.shape}") + n = len(data) + if x_axis is None: + x_axis = np.arange(n, dtype=float) + x_axis = np.asarray(x_axis, dtype=float) + if len(x_axis) != n: + raise ValueError("x_axis length must match data length") + + dmin = float(np.nanmin(data)) + dmax = float(np.nanmax(data)) + pad = (dmax - dmin) * 0.05 if dmax > dmin else 0.5 + dmin -= pad; dmax += pad + + self._state: dict = { + "kind": "1d", + "data": data, # numpy float64 — encoded in to_state_dict() + "x_axis": x_axis, # numpy float64 — encoded in to_state_dict() + "units": units, + "y_units": y_units, + "data_min": dmin, + "data_max": dmax, + "view_x0": 0.0, + "view_x1": 1.0, + "line_color": color, + "line_linewidth": float(linewidth), + "line_linestyle": _norm_linestyle(linestyle), + "line_alpha": float(alpha), + "line_marker": marker if marker is not None else "none", + "line_markersize": float(markersize), + "line_label": label, + "extra_lines": [], + "spans": [], + "overlay_widgets": [], + "markers": [], + "registered_keys": [], + } + + self.markers = MarkerRegistry(self._push_markers, + allowed=MarkerRegistry._KNOWN_1D) + self.callbacks = CallbackRegistry() + self._widgets: dict[str, Widget] = {} + + def _push(self) -> None: + if self._fig is None: + return + self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] + self._fig._push(self._id) + + def _push_markers(self) -> None: + self._state["markers"] = self.markers.to_wire_list() + self._push() + + def to_state_dict(self) -> dict: + d = dict(self._state) + # Replace numpy arrays with b64-encoded strings for the wire format. + data_arr = d.pop("data") + x_arr = d.pop("x_axis") + d["data_b64"] = _arr_to_b64(data_arr, np.float64) + d["x_axis_b64"] = _arr_to_b64(x_arr, np.float64) + d["data_length"] = len(data_arr) + # Encode extra-line arrays too + new_extra = [] + for ex in d["extra_lines"]: + ex2 = dict(ex) + ex2["data_b64"] = _arr_to_b64(ex2.pop("data"), np.float64) + ex2["x_axis_b64"] = _arr_to_b64( + np.asarray(ex2.pop("x_axis"), dtype=np.float64), np.float64) + new_extra.append(ex2) + d["extra_lines"] = new_extra + d["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] + d["markers"] = self.markers.to_wire_list() + return d + + @property + def line(self) -> "Line1D": + """Handle for the primary line, enabling per-line callbacks. + + Returns a :class:`Line1D` with ``id=None`` so you can register + hover / click handlers scoped to just the primary line:: + + @plot.line.on_click + def on_primary_click(event): + print(f"primary line clicked at x={event.x:.3f}") + """ + return Line1D(self, None) + + # ------------------------------------------------------------------ + # Data + # ------------------------------------------------------------------ + @property + def data(self) -> np.ndarray: + """The primary line's y-data (read-only). + + Returns a float64 copy with ``writeable=False``. To replace the + data call :meth:`set_data`. + """ + arr = self._state["data"].copy() + arr.flags.writeable = False + return arr + + def set_data(self, data: np.ndarray, x_axis=None, + units: str | None = None, y_units: str | None = None) -> None: + """Replace the primary line's y-data and optionally its x-axis / units. + + The y-axis range (``data_min`` / ``data_max``) is recomputed + automatically. The viewport is **not** reset — call + :meth:`reset_view` explicitly if needed. + + Parameters + ---------- + data : array-like, shape (N,) + New y values. Must be 1-D. + x_axis : array-like, shape (N,), optional + New x coordinates. If omitted and the length of *data* matches + the current x-axis, the existing x-axis is reused; otherwise it + is reset to ``0, 1, …, N-1``. + units : str, optional + New x-axis label. Unchanged if not supplied. + y_units : str, optional + New y-axis label. Unchanged if not supplied. + """ + data = np.asarray(data, dtype=float) + if data.ndim != 1: + raise ValueError(f"data must be 1-D, got {data.shape}") + n = len(data) + if x_axis is None: + prev = self._state["x_axis"] # already a numpy array + x_axis = prev if len(prev) == n else np.arange(n, dtype=float) + x_axis = np.asarray(x_axis, dtype=float) + + dmin = float(np.nanmin(data)) + dmax = float(np.nanmax(data)) + pad = (dmax - dmin) * 0.05 if dmax > dmin else 0.5 + + self._state["data"] = data + self._state["x_axis"] = x_axis + self._state["data_min"] = dmin - pad + self._state["data_max"] = dmax + pad + if units is not None: self._state["units"] = units + if y_units is not None: self._state["y_units"] = y_units + self._push() + + def _recompute_data_range(self) -> None: + """Recompute data_min/data_max across the primary line and all overlays. + + Called automatically whenever the set of lines changes so that every + curve stays fully visible. + """ + all_vals = [self._state["data"]] # already a numpy float64 array + for ex in self._state["extra_lines"]: + d = ex.get("data") + if d is not None and len(d): + all_vals.append(d) + combined = np.concatenate(all_vals) + dmin = float(np.nanmin(combined)) + dmax = float(np.nanmax(combined)) + pad = (dmax - dmin) * 0.05 if dmax > dmin else 0.5 + self._state["data_min"] = dmin - pad + self._state["data_max"] = dmax + pad + + # ------------------------------------------------------------------ + # Extra lines + # ------------------------------------------------------------------ + def add_line(self, data: np.ndarray, x_axis=None, + color: str = "#ffffff", linewidth: float = 1.5, + linestyle: str = "solid", ls: str | None = None, + alpha: float = 1.0, + marker: str = "none", markersize: float = 4.0, + label: str = "") -> "Line1D": + """Overlay an additional curve on this panel. + + The y-axis range is automatically expanded to include the new data so + all lines remain fully visible. + + Parameters + ---------- + data : array-like, shape (N,) + Y values for the new line. Must be 1-D. + x_axis : array-like, shape (N,), optional + X coordinates. Defaults to the primary line's x-axis. + color : str, optional + CSS colour string. Default ``"#ffffff"``. + linewidth : float, optional + Stroke width in pixels. Default ``1.5``. + linestyle : str, optional + Dash pattern: ``"solid"``, ``"dashed"``, ``"dotted"``, + ``"dashdot"`` (or shorthands). Default ``"solid"``. + ls : str, optional + Short alias for *linestyle*. + alpha : float, optional + Line opacity (0–1). Default ``1.0``. + marker : str, optional + Per-point marker symbol (see :class:`Plot1D`). Default + ``"none"``. + markersize : float, optional + Marker radius / half-side in pixels. Default ``4.0``. + label : str, optional + Legend label. Default ``""`` (no legend entry). + + Returns + ------- + Line1D + A handle to the new overlay line. Use it to register + per-line hover/click callbacks or to remove the line later:: + + line = v.add_line(fit, color="#ffcc00", label="fit") + line.remove() # remove it + @line.on_click # per-line click handler + def clicked(event): ... + """ + data = np.asarray(data, dtype=float) + if data.ndim != 1: + raise ValueError("data must be 1-D") + xa = (np.asarray(x_axis, dtype=float) if x_axis is not None + else self._state["x_axis"]) + lid = str(_uuid.uuid4())[:8] + self._state["extra_lines"].append({ + "id": lid, + "data": data, + "x_axis": xa, + "color": color, + "linewidth": float(linewidth), + "linestyle": _norm_linestyle(ls if ls is not None else linestyle), + "alpha": float(alpha), + "marker": marker if marker is not None else "none", + "markersize": float(markersize), + "label": label, + }) + self._recompute_data_range() + self._push() + return Line1D(self, lid) + + def remove_line(self, lid: "str | Line1D") -> None: + """Remove an overlay line by its ID or :class:`Line1D` handle. + + The y-axis range is recomputed after removal. + + Parameters + ---------- + lid : str or Line1D + The value returned by :meth:`add_line`. + + Raises + ------ + KeyError + If *lid* does not match any overlay line. + """ + if isinstance(lid, Line1D): + lid = lid._lid + before = len(self._state["extra_lines"]) + self._state["extra_lines"] = [ + e for e in self._state["extra_lines"] if e["id"] != lid] + if len(self._state["extra_lines"]) == before: + raise KeyError(lid) + self._recompute_data_range() + self._push() + + def clear_lines(self) -> None: + """Remove all overlay lines, leaving the primary line intact. + + The y-axis range is recomputed after clearing. + """ + self._state["extra_lines"] = [] + self._recompute_data_range() + self._push() + + # ------------------------------------------------------------------ + # Spans + # ------------------------------------------------------------------ + def add_span(self, v0: float, v1: float, + axis: str = "x", color: str | None = None) -> str: + """Add a shaded span along the x- or y-axis. + + Parameters + ---------- + v0, v1 : float + Start and end of the span in data coordinates. + axis : ``"x"`` | ``"y"``, optional + Which axis the span runs along. Default ``"x"``. + color : str, optional + CSS colour string (supports alpha, e.g. + ``"rgba(255,200,0,0.2)"``). Defaults to a theme-appropriate + yellow tint. + + Returns + ------- + str + Span ID for use with :meth:`remove_span`. + """ + sid = str(_uuid.uuid4())[:8] + self._state["spans"].append({ + "id": sid, "v0": float(v0), "v1": float(v1), + "axis": axis, "color": color, + }) + self._push() + return sid + + def remove_span(self, sid: str) -> None: + """Remove a shaded span by its ID. + + Parameters + ---------- + sid : str + The ID returned by :meth:`add_span`. + + Raises + ------ + KeyError + If *sid* does not match any span. + """ + before = len(self._state["spans"]) + self._state["spans"] = [ + s for s in self._state["spans"] if s["id"] != sid] + if len(self._state["spans"]) == before: + raise KeyError(sid) + self._push() + + def clear_spans(self) -> None: + """Remove all shaded spans.""" + self._state["spans"] = [] + self._push() + + # ------------------------------------------------------------------ + # Overlay Widgets + # ------------------------------------------------------------------ + def add_vline_widget(self, x: float, color: str = "#00e5ff") -> _VLineWidget: + """Add a draggable vertical-line overlay. + + Parameters + ---------- + x : float + Initial x position in data coordinates. + color : str, optional + CSS colour string. Default ``"#00e5ff"``. + + Returns + ------- + VLineWidget + Widget object. Register position callbacks with + :meth:`on_changed` / :meth:`on_release`. + """ + widget = _VLineWidget(lambda: None, x=float(x), color=color) + plot_ref, wid_id = self, widget._id + def _tp(): + if plot_ref._fig is not None: + fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} + plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) + widget._push_fn = _tp + self._widgets[widget.id] = widget + self._push() + return widget + + def add_hline_widget(self, y: float, color: str = "#00e5ff") -> _HLineWidget: + """Add a draggable horizontal-line overlay. + + Parameters + ---------- + y : float + Initial y position in data coordinates. + color : str, optional + CSS colour string. Default ``"#00e5ff"``. + + Returns + ------- + HLineWidget + Widget object. Register position callbacks with + :meth:`on_changed` / :meth:`on_release`. + """ + widget = _HLineWidget(lambda: None, y=float(y), color=color) + plot_ref, wid_id = self, widget._id + def _tp(): + if plot_ref._fig is not None: + fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} + plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) + widget._push_fn = _tp + self._widgets[widget.id] = widget + self._push() + return widget + + def add_range_widget(self, x0: float, x1: float, + color: str = "#00e5ff", + style: str = "band", + y: float = 0.0, + _push: bool = True) -> _RangeWidget: + """Add a draggable range overlay to this panel. + + Parameters + ---------- + x0, x1 : float + Initial left and right edges in data coordinates. + color : str, optional + CSS colour string. Default ``"#00e5ff"``. + style : {'band', 'fwhm'}, optional + Visual style. ``'band'`` (default) draws two vertical lines with + a translucent fill. ``'fwhm'`` draws two draggable circles + connected by a dashed horizontal line at *y* (the half-maximum + level), giving an ``o-------o`` FWHM indicator. + y : float, optional + Y-coordinate (data space) for the connecting line when + ``style='fwhm'``. Ignored when ``style='band'``. Default 0. + _push : bool, optional + Push state to JS immediately. Set to ``False`` when adding + several widgets at once; call :meth:`_push` manually afterward. + + Returns + ------- + RangeWidget + Widget object. Register position callbacks with + :meth:`on_changed` / :meth:`on_release`. + """ + widget = _RangeWidget(lambda: None, x0=float(x0), x1=float(x1), + color=color, style=style, y=float(y)) + plot_ref, wid_id = self, widget._id + def _tp(): + if plot_ref._fig is not None: + fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} + plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) + widget._push_fn = _tp + self._widgets[widget.id] = widget + if _push: + self._push() + return widget + + def add_point_widget(self, x: float, y: float, + color: str = "#00e5ff", + show_crosshair: bool = True, + _push: bool = True) -> _PointWidget: + """Add a freely-draggable control point to this panel. + + Parameters + ---------- + x : float + Initial x position in data coordinates. + y : float + Initial y position in data coordinates (value axis). + color : str, optional + CSS colour string. Default ``"#00e5ff"``. + show_crosshair : bool, optional + Draw dashed guide lines through the handle. Default ``True``. + Pass ``False`` for a plain dot with no guide lines. + _push : bool, optional + Push state to JS immediately. Set to ``False`` when adding + several widgets at once; call :meth:`_push` manually afterward. + + Returns + ------- + PointWidget + """ + widget = _PointWidget(lambda: None, x=float(x), y=float(y), color=color, + show_crosshair=show_crosshair) + plot_ref, wid_id = self, widget._id + def _tp_point(): + if plot_ref._fig is not None: + fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} + plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) + widget._push_fn = _tp_point + self._widgets[widget.id] = widget + if _push: + self._push() + return widget + + def get_widget(self, wid) -> Widget: + """Return the Widget object by ID string or Widget instance.""" + if isinstance(wid, Widget): + wid = wid.id + try: + return self._widgets[wid] + except KeyError: + raise KeyError(wid) + + def remove_widget(self, wid) -> None: + """Remove a widget by ID string or Widget instance.""" + if isinstance(wid, Widget): + wid = wid.id + if wid not in self._widgets: + raise KeyError(wid) + del self._widgets[wid] + self._push() + + def list_widgets(self) -> list: + """Return a list of all active widget objects on this panel.""" + return list(self._widgets.values()) + + def clear_widgets(self) -> None: + """Remove all interactive overlay widgets from this panel.""" + self._widgets.clear() + self._push() + + # ------------------------------------------------------------------ + # Callback API (Plot1D) + # ------------------------------------------------------------------ + def on_changed(self, fn: Callable) -> Callable: + """Decorator: fires on every drag/zoom frame on this panel.""" + cid = self.callbacks.connect("on_changed", fn) + fn._cid = cid + return fn + + def on_release(self, fn: Callable) -> Callable: + """Decorator: fires once when drag/zoom settles on this panel.""" + cid = self.callbacks.connect("on_release", fn) + fn._cid = cid + return fn + + def on_click(self, fn: Callable) -> Callable: + """Decorator: fires on click on this panel.""" + cid = self.callbacks.connect("on_click", fn) + fn._cid = cid + return fn + + def on_key(self, key_or_fn=None) -> Callable: + """Register a key-press handler for this panel. + + Two call forms are supported:: + + @plot.on_key('q') # fires only when 'q' is pressed + def handler(event): ... + + @plot.on_key # fires for every registered key + def handler(event): ... + + The event carries: ``key``, ``mouse_x``, ``mouse_y``, ``phys_x``, + and ``last_widget_id``. + + .. note:: + Registered keys take priority over the built-in **r** (reset view) + shortcut. + """ + if callable(key_or_fn): + return self._connect_on_key(None, key_or_fn) + key = key_or_fn + def _decorator(fn): + return self._connect_on_key(key, fn) + return _decorator + + def _connect_on_key(self, key, fn) -> Callable: + if key is None: + if '*' not in self._state['registered_keys']: + self._state['registered_keys'].append('*') + self._push() + cid = self.callbacks.connect("on_key", fn) + else: + if key not in self._state['registered_keys']: + self._state['registered_keys'].append(key) + self._push() + def _wrapped(event): + if event.data.get('key') == key: + fn(event) + cid = self.callbacks.connect("on_key", _wrapped) + _wrapped._cid = cid + fn._cid = cid + return fn + + def disconnect(self, cid: int) -> None: + """Remove the callback registered under integer *cid*.""" + self.callbacks.disconnect(cid) + + def on_line_hover(self, fn: Callable) -> Callable: + """Decorator: fires when the cursor moves over *any* line on this panel. + + The event carries ``event.line_id`` (``None`` = primary line, + str = overlay), ``event.x``, and ``event.y`` in data coordinates. + For per-line filtering use :meth:`Line1D.on_hover` instead. + """ + cid = self.callbacks.connect("on_line_hover", fn) + fn._cid = cid + return fn + + def on_line_click(self, fn: Callable) -> Callable: + """Decorator: fires when the user clicks *any* line on this panel. + + The event carries the same fields as :meth:`on_line_hover`. + For per-line filtering use :meth:`Line1D.on_click` instead. + """ + cid = self.callbacks.connect("on_line_click", fn) + fn._cid = cid + return fn + + # ------------------------------------------------------------------ + # View control + # ------------------------------------------------------------------ + def set_view(self, x0: float | None = None, x1: float | None = None) -> None: + """Programmatically set the visible x range. + + Parameters + ---------- + x0 : float, optional + Left edge of the view in data coordinates. ``None`` keeps the + current left edge. + x1 : float, optional + Right edge of the view in data coordinates. ``None`` keeps the + current right edge. + """ + xarr = np.asarray(self._state["x_axis"]) + if len(xarr) < 2: + return + xmin, xmax = float(xarr[0]), float(xarr[-1]) + span = xmax - xmin or 1.0 + f0 = 0.0 if x0 is None else max(0.0, min(1.0, (float(x0)-xmin)/span)) + f1 = 1.0 if x1 is None else max(0.0, min(1.0, (float(x1)-xmin)/span)) + self._state["view_x0"] = f0 + self._state["view_x1"] = f1 + self._push() + + def reset_view(self) -> None: + """Reset the view to show the full x range of the primary line.""" + self._state["view_x0"] = 0.0 + self._state["view_x1"] = 1.0 + self._push() + + # ------------------------------------------------------------------ + # Primary-line property setters + # ------------------------------------------------------------------ + + def set_color(self, color: str) -> None: + """Set the primary line colour. + + Parameters + ---------- + color : str + Any CSS colour string (hex, ``rgb()``, named colour, etc.). + """ + self._state["line_color"] = color + self._push() + + def set_linewidth(self, linewidth: float) -> None: + """Set the primary line stroke width. + + Parameters + ---------- + linewidth : float + Stroke width in pixels. + """ + self._state["line_linewidth"] = float(linewidth) + self._push() + + def set_linestyle(self, linestyle: str) -> None: + """Set the primary line dash pattern. + + Parameters + ---------- + linestyle : str + ``"solid"`` (``"-"``), ``"dashed"`` (``"--"``), + ``"dotted"`` (``":"``), or ``"dashdot"`` (``"-."``) + """ + self._state["line_linestyle"] = _norm_linestyle(linestyle) + self._push() + + def set_alpha(self, alpha: float) -> None: + """Set the primary line opacity. + + Parameters + ---------- + alpha : float + Opacity in the range 0 (transparent) to 1 (fully opaque). + """ + self._state["line_alpha"] = float(alpha) + self._push() + + def set_marker(self, marker: str, markersize: float | None = None) -> None: + """Set the primary line per-point marker symbol. + + Parameters + ---------- + marker : str + ``"o"``, ``"s"``, ``"^"``, ``"v"``, ``"D"``, ``"+"``, + ``"x"``, or ``"none"``. + markersize : float, optional + Marker radius / half-side in pixels. Unchanged if not supplied. + """ + self._state["line_marker"] = marker if marker is not None else "none" + if markersize is not None: + self._state["line_markersize"] = float(markersize) + self._push() + + # ------------------------------------------------------------------ + # Marker API (matplotlib-style kwargs → MarkerRegistry) + # ------------------------------------------------------------------ + def _add_marker(self, mtype: str, name: str | None, **kwargs) -> "MarkerGroup": # noqa: F821 + return self.markers.add(mtype, name, **kwargs) + + def add_circles(self, offsets, name=None, *, radius=5, + facecolors=None, edgecolors="#ff0000", + linewidths=1.5, alpha=0.3, + hover_edgecolors=None, hover_facecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + """Add circle markers at explicit (x, y) positions. + + On 1-D panels circles are rendered as filled/stroked discs; *radius* + is in canvas pixels (not data units). + + Parameters + ---------- + offsets : array-like, shape (N, 2) + Marker positions as ``[[x0, y0], [x1, y1], …]`` in data + coordinates. + name : str, optional + Registry key. Auto-generated if omitted. + radius : float or array-like, optional + Radius in pixels. Scalar or per-marker array. Default ``5``. + facecolors : str or None, optional + Fill colour. ``None`` = no fill. + edgecolors : str, optional + Stroke colour. Default ``"#ff0000"``. + linewidths : float, optional + Stroke width in pixels. Default ``1.5``. + alpha : float, optional + Fill opacity (0–1). Default ``0.3``. + hover_edgecolors, hover_facecolors : str, optional + Colour overrides applied on mouse-hover. + labels : list of str, optional + Per-marker tooltip labels. + label : str, optional + Collection-level tooltip label. + + Returns + ------- + MarkerGroup + Live group object. Call ``.set(**kwargs)`` to update in place. + """ + # On 1-D panels the native type is "points" (radius maps to sizes). + return self._add_marker("points", name, offsets=offsets, sizes=radius, + facecolors=facecolors, edgecolors=edgecolors, + linewidths=linewidths, alpha=alpha, + hover_edgecolors=hover_edgecolors, + hover_facecolors=hover_facecolors, + labels=labels, label=label) + + def add_points(self, offsets, name=None, *, sizes=5, + color="#ff0000", facecolors=None, + linewidths=1.5, alpha=0.3, + hover_edgecolors=None, hover_facecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + """Add point markers at (x, y) positions in data coordinates. + + Parameters + ---------- + offsets : array-like, shape (N, 2) + Marker positions as ``[[x0, y0], [x1, y1], …]``. + name : str, optional + Registry key. Auto-generated if omitted. + sizes : float or array-like, optional + Radius in pixels. Scalar or per-marker array. Default ``5``. + color : str, optional + Stroke colour. Default ``"#ff0000"``. + facecolors : str or None, optional + Fill colour. ``None`` = no fill. + linewidths : float, optional + Stroke width in pixels. Default ``1.5``. + alpha : float, optional + Fill opacity (0–1). Default ``0.3``. + hover_edgecolors, hover_facecolors : str, optional + Colour overrides applied on mouse-hover. + labels : list of str, optional + Per-marker tooltip labels. + label : str, optional + Collection-level tooltip label. + + Returns + ------- + MarkerGroup + """ + return self._add_marker("points", name, offsets=offsets, sizes=sizes, + edgecolors=color, facecolors=facecolors, + linewidths=linewidths, alpha=alpha, + hover_edgecolors=hover_edgecolors, + hover_facecolors=hover_facecolors, + labels=labels, label=label) + + def add_hlines(self, y_values, name=None, *, + color="#ff0000", linewidths=1.5, + hover_edgecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + """Add static horizontal lines spanning the full x range. + + Parameters + ---------- + y_values : array-like, shape (N,) + Y positions of each line in data coordinates. + name : str, optional + Registry key. Auto-generated if omitted. + color : str, optional + Line colour. Default ``"#ff0000"``. + linewidths : float, optional + Stroke width in pixels. Default ``1.5``. + hover_edgecolors : str, optional + Colour override applied on mouse-hover. + labels : list of str, optional + Per-line tooltip labels. + label : str, optional + Collection-level tooltip label. + + Returns + ------- + MarkerGroup + """ + return self._add_marker("hlines", name, offsets=y_values, + color=color, linewidths=linewidths, + hover_edgecolors=hover_edgecolors, + labels=labels, label=label) + + def add_vlines(self, x_values, name=None, *, + color="#ff0000", linewidths=1.5, + hover_edgecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + """Add static vertical lines spanning the full y range. + + Parameters + ---------- + x_values : array-like, shape (N,) + X positions of each line in data coordinates. + name : str, optional + Registry key. Auto-generated if omitted. + color : str, optional + Line colour. Default ``"#ff0000"``. + linewidths : float, optional + Stroke width in pixels. Default ``1.5``. + hover_edgecolors : str, optional + Colour override applied on mouse-hover. + labels : list of str, optional + Per-line tooltip labels. + label : str, optional + Collection-level tooltip label. + + Returns + ------- + MarkerGroup + """ + return self._add_marker("vlines", name, offsets=x_values, + color=color, linewidths=linewidths, + hover_edgecolors=hover_edgecolors, + labels=labels, label=label) + + def add_arrows(self, offsets, U, V, name=None, *, + edgecolors="#ff0000", linewidths=1.5, + hover_edgecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + """Add arrow markers at explicit (x, y) positions. + + Parameters + ---------- + offsets : array-like, shape (N, 2) + Arrow tail positions as ``[[x0, y0], …]`` in data coordinates. + U, V : array-like, shape (N,) + X and Y components of each arrow vector (in data units). + name : str, optional + Registry key. Auto-generated if omitted. + edgecolors : str, optional + Arrow colour. Default ``"#ff0000"``. + linewidths : float, optional + Stroke width in pixels. Default ``1.5``. + hover_edgecolors : str, optional + Colour override applied on mouse-hover. + labels : list of str, optional + Per-arrow tooltip labels. + label : str, optional + Collection-level tooltip label. + + Returns + ------- + MarkerGroup + """ + return self._add_marker("arrows", name, offsets=offsets, U=U, V=V, + edgecolors=edgecolors, linewidths=linewidths, + hover_edgecolors=hover_edgecolors, + labels=labels, label=label) + + def add_ellipses(self, offsets, widths, heights, name=None, *, + angles=0, facecolors=None, edgecolors="#ff0000", + linewidths=1.5, alpha=0.3, + hover_edgecolors=None, hover_facecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + """Add ellipse markers at explicit (x, y) positions. + + Parameters + ---------- + offsets : array-like, shape (N, 2) + Centre positions in data coordinates. + widths, heights : float or array-like + Full width and height of each ellipse in canvas pixels. + name : str, optional + Registry key. Auto-generated if omitted. + angles : float or array-like, optional + Rotation angle(s) in degrees. Default ``0``. + facecolors : str or None, optional + Fill colour. ``None`` = no fill. + edgecolors : str, optional + Stroke colour. Default ``"#ff0000"``. + linewidths : float, optional + Stroke width in pixels. Default ``1.5``. + alpha : float, optional + Fill opacity (0–1). Default ``0.3``. + hover_edgecolors, hover_facecolors : str, optional + Colour overrides applied on mouse-hover. + labels : list of str, optional + Per-marker tooltip labels. + label : str, optional + Collection-level tooltip label. + + Returns + ------- + MarkerGroup + """ + return self._add_marker("ellipses", name, offsets=offsets, + widths=widths, heights=heights, angles=angles, + facecolors=facecolors, edgecolors=edgecolors, + linewidths=linewidths, alpha=alpha, + hover_edgecolors=hover_edgecolors, + hover_facecolors=hover_facecolors, + labels=labels, label=label) + + def add_lines(self, segments, name=None, *, + edgecolors="#ff0000", linewidths=1.5, + hover_edgecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + """Add line-segment markers (static, not draggable). + + Parameters + ---------- + segments : array-like, shape (N, 2, 2) + Each segment is ``[[x0, y0], [x1, y1]]`` in data coordinates. + name : str, optional + Registry key. Auto-generated if omitted. + edgecolors : str, optional + Line colour. Default ``"#ff0000"``. + linewidths : float, optional + Stroke width in pixels. Default ``1.5``. + hover_edgecolors : str, optional + Colour override applied on mouse-hover. + labels : list of str, optional + Per-segment tooltip labels. + label : str, optional + Collection-level tooltip label. + + Returns + ------- + MarkerGroup + """ + return self._add_marker("lines", name, segments=segments, + edgecolors=edgecolors, linewidths=linewidths, + hover_edgecolors=hover_edgecolors, + labels=labels, label=label) + + def add_rectangles(self, offsets, widths, heights, name=None, *, + angles=0, facecolors=None, edgecolors="#ff0000", + linewidths=1.5, alpha=0.3, + hover_edgecolors=None, hover_facecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + """Add rectangle markers at explicit (x, y) positions. + + Parameters + ---------- + offsets : array-like, shape (N, 2) + Centre positions in data coordinates. + widths, heights : float or array-like + Full width and height of each rectangle in canvas pixels. + name : str, optional + Registry key. Auto-generated if omitted. + angles : float or array-like, optional + Rotation angle(s) in degrees. Default ``0``. + facecolors : str or None, optional + Fill colour. ``None`` = no fill. + edgecolors : str, optional + Stroke colour. Default ``"#ff0000"``. + linewidths : float, optional + Stroke width in pixels. Default ``1.5``. + alpha : float, optional + Fill opacity (0–1). Default ``0.3``. + hover_edgecolors, hover_facecolors : str, optional + Colour overrides applied on mouse-hover. + labels : list of str, optional + Per-marker tooltip labels. + label : str, optional + Collection-level tooltip label. + + Returns + ------- + MarkerGroup + """ + return self._add_marker("rectangles", name, offsets=offsets, + widths=widths, heights=heights, angles=angles, + facecolors=facecolors, edgecolors=edgecolors, + linewidths=linewidths, alpha=alpha, + hover_edgecolors=hover_edgecolors, + hover_facecolors=hover_facecolors, + labels=labels, label=label) + + def add_squares(self, offsets, widths, name=None, *, + angles=0, facecolors=None, edgecolors="#ff0000", + linewidths=1.5, alpha=0.3, + hover_edgecolors=None, hover_facecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + """Add square markers at explicit (x, y) positions. + + Parameters + ---------- + offsets : array-like, shape (N, 2) + Centre positions in data coordinates. + widths : float or array-like + Side length of each square in canvas pixels. + name : str, optional + Registry key. Auto-generated if omitted. + angles : float or array-like, optional + Rotation angle(s) in degrees. Default ``0``. + facecolors : str or None, optional + Fill colour. ``None`` = no fill. + edgecolors : str, optional + Stroke colour. Default ``"#ff0000"``. + linewidths : float, optional + Stroke width in pixels. Default ``1.5``. + alpha : float, optional + Fill opacity (0–1). Default ``0.3``. + hover_edgecolors, hover_facecolors : str, optional + Colour overrides applied on mouse-hover. + labels : list of str, optional + Per-marker tooltip labels. + label : str, optional + Collection-level tooltip label. + + Returns + ------- + MarkerGroup + """ + return self._add_marker("squares", name, offsets=offsets, + widths=widths, angles=angles, + facecolors=facecolors, edgecolors=edgecolors, + linewidths=linewidths, alpha=alpha, + hover_edgecolors=hover_edgecolors, + hover_facecolors=hover_facecolors, + labels=labels, label=label) + + def add_polygons(self, vertices_list, name=None, *, + facecolors=None, edgecolors="#ff0000", + linewidths=1.5, alpha=0.3, + hover_edgecolors=None, hover_facecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + """Add polygon markers defined by explicit vertex lists. + + Parameters + ---------- + vertices_list : list of array-like, each shape (K, 2) + One polygon per element; each is a list of ``[x, y]`` vertices + in data coordinates. + name : str, optional + Registry key. Auto-generated if omitted. + facecolors : str or None, optional + Fill colour. ``None`` = no fill. + edgecolors : str, optional + Stroke colour. Default ``"#ff0000"``. + linewidths : float, optional + Stroke width in pixels. Default ``1.5``. + alpha : float, optional + Fill opacity (0–1). Default ``0.3``. + hover_edgecolors, hover_facecolors : str, optional + Colour overrides applied on mouse-hover. + labels : list of str, optional + Per-polygon tooltip labels. + label : str, optional + Collection-level tooltip label. + + Returns + ------- + MarkerGroup + """ + return self._add_marker("polygons", name, vertices_list=vertices_list, + facecolors=facecolors, edgecolors=edgecolors, + linewidths=linewidths, alpha=alpha, + hover_edgecolors=hover_edgecolors, + hover_facecolors=hover_facecolors, + labels=labels, label=label) + + def add_texts(self, offsets, texts, name=None, *, + color="#ff0000", fontsize=12, + hover_edgecolors=None, + labels=None, label=None) -> "MarkerGroup": # noqa: F821 + """Add text annotations at explicit (x, y) positions. + + Parameters + ---------- + offsets : array-like, shape (N, 2) + Anchor positions in data coordinates. + texts : list of str + One string per position. + name : str, optional + Registry key. Auto-generated if omitted. + color : str, optional + Text colour. Default ``"#ff0000"``. + fontsize : int, optional + Font size in pixels. Default ``12``. + hover_edgecolors : str, optional + Colour override applied on mouse-hover. + labels : list of str, optional + Per-annotation tooltip labels. + label : str, optional + Collection-level tooltip label. + + Returns + ------- + MarkerGroup + """ + return self._add_marker("texts", name, offsets=offsets, texts=texts, + color=color, fontsize=fontsize, + hover_edgecolors=hover_edgecolors, + labels=labels, label=label) + + def remove_marker(self, marker_type: str, name: str) -> None: + """Remove a named marker collection by type and name. + + Parameters + ---------- + marker_type : str + Collection type, e.g. ``"points"``, ``"vlines"``. + name : str + The name used when the collection was created. + """ + self.markers.remove(marker_type, name) + + def clear_markers(self) -> None: + """Remove all marker collections from this panel.""" + self.markers.clear() + + def list_markers(self) -> list: + """Return a summary list of all marker collections on this panel. + + Returns + ------- + list of dict + Each dict has keys ``"type"``, ``"name"``, and ``"n"`` + (number of markers in the collection). + """ + out = [] + for mtype, td in self.markers._types.items(): + for name, g in td.items(): + out.append({"type": mtype, "name": name, "n": g._count()}) + return out + + def __repr__(self) -> str: + n = len(self._state.get("data", [])) + color = self._state.get("line_color", "?") + return f"Plot1D(n={n}, color={color!r})" diff --git a/anyplotlib/plot1d/_plotbar.py b/anyplotlib/plot1d/_plotbar.py new file mode 100644 index 00000000..37f50c9b --- /dev/null +++ b/anyplotlib/plot1d/_plotbar.py @@ -0,0 +1,468 @@ +""" +plot1d/_plotbar.py +================== +Bar chart panel (PlotBar). +""" + +from __future__ import annotations + +import numpy as np +from typing import Callable + +from anyplotlib.callbacks import CallbackRegistry, Event +from anyplotlib.widgets import ( + Widget, + VLineWidget as _VLineWidget, + HLineWidget as _HLineWidget, + RangeWidget as _RangeWidget, + PointWidget as _PointWidget, +) +from anyplotlib._utils import _arr_to_b64 + + +# --------------------------------------------------------------------------- +# _bar_x_axis helper +# --------------------------------------------------------------------------- + +def _bar_x_axis(x_centers: np.ndarray) -> list: + """Return a 2-element [x_left_edge, x_right_edge] list for a bar chart. + + The edges are half a slot-width outside the first/last bar centre so that + a vline_widget at ``x_centers[i]`` renders at exactly the bar's centre + pixel when used with ``_xToFrac1d`` / ``_fracToPx1d`` in the JS renderer. + """ + n = len(x_centers) + if n == 0: + return [0.0, 1.0] + if n == 1: + return [float(x_centers[0]) - 0.5, float(x_centers[0]) + 0.5] + slot = (float(x_centers[-1]) - float(x_centers[0])) / (n - 1) + half = slot / 2.0 + return [float(x_centers[0]) - half, float(x_centers[-1]) + half] + + +# --------------------------------------------------------------------------- +# PlotBar +# --------------------------------------------------------------------------- + +_LOG_CLAMP = 1e-10 # smallest positive value used when log_scale=True + +_DEFAULT_GROUP_PALETTE = [ + "#4fc3f7", "#ff7043", "#66bb6a", "#ab47bc", + "#ffa726", "#26c6da", "#ec407a", "#8d6e63", +] + + +def _bar_range(flat: np.ndarray, bottom: float, log_scale: bool): + """Return ``(dmin, dmax)`` with padding for the value axis.""" + if log_scale: + pos = flat[flat > 0] + dmin = float(np.nanmin(pos)) if len(pos) else _LOG_CLAMP + dmax = max(float(np.nanmax(flat)) if len(flat) else 1.0, + bottom if bottom > 0 else _LOG_CLAMP) + if dmin <= 0: + dmin = _LOG_CLAMP + if dmax <= 0: + dmax = 1.0 + dmax = 10 ** (np.log10(dmax) + 0.15) + dmin = 10 ** (np.log10(dmin) - 0.15) + else: + dmin = min(bottom, float(np.nanmin(flat)) if len(flat) else 0.0) + dmax = max(bottom, float(np.nanmax(flat)) if len(flat) else 1.0) + pad = (dmax - dmin) * 0.07 if dmax > dmin else 0.5 + dmax += pad + if dmin < bottom: + dmin -= pad + return dmin, dmax + + +class PlotBar: + """Bar-chart plot panel. + + Not an anywidget. Holds state in ``_state`` dict; every mutation calls + ``_push()`` which writes to the parent Figure's panel trait. + + Supports grouped bars (pass a 2-D *height* array with shape ``(N, G)``), + log-scale value axis, draggable overlay widgets, and hover/click callbacks. + + Created by :meth:`Axes.bar`. + """ + + def __init__(self, x, height=None, width: float = 0.8, bottom: float = 0.0, *, + align: str = "center", + color: str = "#4fc3f7", + colors=None, + orient: str = "v", + log_scale: bool = False, + group_labels=None, + group_colors=None, + show_values: bool = False, + units: str = "", + y_units: str = "", + # ── legacy backward-compat kwargs ────────────────────── + x_labels=None, + x_centers=None, + bar_width=None, + baseline=None, + values=None): + self._id: str = "" + self._fig: object = None + + # ── legacy resolution ────────────────────────────────────────── + if height is None: + if values is not None: + height = values + else: + height = x + x = None + if baseline is not None: + bottom = baseline + if bar_width is not None: + width = bar_width + + # ── height (values) — 1-D or 2-D for grouped bars ───────────── + height_arr = np.asarray(height, dtype=float) + if height_arr.ndim == 1: + groups = 1 + values_2d = height_arr.reshape(-1, 1) + elif height_arr.ndim == 2: + groups = height_arr.shape[1] + values_2d = height_arr + else: + raise ValueError( + f"height must be 1-D or 2-D, got shape {height_arr.shape}" + ) + n = values_2d.shape[0] + + if orient not in ("v", "h"): + raise ValueError("orient must be 'v' or 'h'") + + # ── x (positions or labels) ──────────────────────────────────── + _x_labels: list = [] + _x_centers: np.ndarray | None = None + + if x is not None: + x_list = list(x) + if x_list and isinstance(x_list[0], str): + _x_labels = x_list + else: + _x_centers = np.asarray(x, dtype=float) + + # Legacy keyword overrides + if x_labels is not None: + _x_labels = list(x_labels) + if x_centers is not None: + _x_centers = np.asarray(x_centers, dtype=float) + + if _x_centers is None: + _x_centers = np.arange(n, dtype=float) + if len(_x_centers) != n: + raise ValueError("x length must match height length") + + # ── data range ───────────────────────────────────────────────── + flat = values_2d.ravel() + dmin, dmax = _bar_range(flat, float(bottom), bool(log_scale)) + + # ── group colours ────────────────────────────────────────────── + if group_colors is None: + gc_list = ( + [_DEFAULT_GROUP_PALETTE[i % len(_DEFAULT_GROUP_PALETTE)] + for i in range(groups)] + if groups > 1 else [] + ) + else: + gc_list = list(group_colors) + + x_axis = _bar_x_axis(_x_centers) + + self._state: dict = { + "kind": "bar", + "values": values_2d.tolist(), # always (N, G) 2-D list + "groups": groups, + "x_centers": _x_centers.tolist(), + "x_labels": _x_labels, + "bar_color": color, + "bar_colors": list(colors) if colors is not None else [], + "group_labels": list(group_labels) if group_labels is not None else [], + "group_colors": gc_list, + "bar_width": float(width), + "orient": orient, + "baseline": float(bottom), + "log_scale": bool(log_scale), + "show_values": bool(show_values), + "data_min": dmin, + "data_max": dmax, + "units": units, + "y_units": y_units, + # overlay-widget coordinate system (mirrors Plot1D) + "x_axis": x_axis, + "view_x0": 0.0, + "view_x1": 1.0, + "overlay_widgets": [], + "registered_keys": [], + } + self.callbacks = CallbackRegistry() + self._widgets: dict[str, Widget] = {} + + # ------------------------------------------------------------------ + def _push(self) -> None: + if self._fig is None: + return + self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] + self._fig._push(self._id) + + def to_state_dict(self) -> dict: + d = dict(self._state) + d["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] + return d + + # ------------------------------------------------------------------ + # Data + # ------------------------------------------------------------------ + def set_data(self, height, x=None, x_labels=None, *, x_centers=None) -> None: + """Replace bar heights; recalculates the value-axis range automatically. + + Parameters + ---------- + height : array-like, shape ``(N,)`` or ``(N, G)`` + New bar heights. For grouped charts the group count *G* must + match the original. + x : array-like of numeric, optional + New bar positions (replaces the stored ``x_centers``). Also + accepts the legacy keyword alias ``x_centers``. + x_labels : list of str, optional + New category labels. + """ + height_arr = np.asarray(height, dtype=float) + if height_arr.ndim == 1: + values_2d = height_arr.reshape(-1, 1) + elif height_arr.ndim == 2: + expected_g = self._state.get("groups", 1) + if height_arr.shape[1] != expected_g: + raise ValueError( + f"Group count mismatch: expected {expected_g}, " + f"got {height_arr.shape[1]}" + ) + values_2d = height_arr + else: + raise ValueError( + f"height must be 1-D or 2-D, got shape {height_arr.shape}" + ) + + flat = values_2d.ravel() + baseline = self._state["baseline"] + log_scale = self._state.get("log_scale", False) + dmin, dmax = _bar_range(flat, float(baseline), bool(log_scale)) + + self._state["values"] = values_2d.tolist() + self._state["data_min"] = dmin + self._state["data_max"] = dmax + + # Accept both `x` and legacy `x_centers` keyword + _x = x if x is not None else x_centers + if _x is not None: + xc = np.asarray(_x, dtype=float) + self._state["x_centers"] = xc.tolist() + self._state["x_axis"] = _bar_x_axis(xc) + if x_labels is not None: + self._state["x_labels"] = list(x_labels) + self._push() + + # ------------------------------------------------------------------ + # Display settings + # ------------------------------------------------------------------ + def set_color(self, color: str) -> None: + """Set a single colour for all bars.""" + self._state["bar_color"] = color + self._push() + + def set_colors(self, colors) -> None: + """Set per-bar colours (list of CSS colour strings, length N).""" + self._state["bar_colors"] = list(colors) + self._push() + + def set_show_values(self, show: bool) -> None: + """Show or hide in-bar value annotations.""" + self._state["show_values"] = bool(show) + self._push() + + def set_log_scale(self, log_scale: bool) -> None: + """Enable or disable a logarithmic value axis. + + When *log_scale* is ``True`` any non-positive values are clamped to + ``1e-10`` for display; the data-range bounds are recalculated in + log-space automatically. + """ + self._state["log_scale"] = bool(log_scale) + flat = np.asarray(self._state["values"]).ravel() + baseline = self._state["baseline"] + dmin, dmax = _bar_range(flat, float(baseline), bool(log_scale)) + self._state["data_min"] = dmin + self._state["data_max"] = dmax + self._push() + + # ------------------------------------------------------------------ + # Overlay Widgets + # ------------------------------------------------------------------ + def add_vline_widget(self, x: float, color: str = "#00e5ff") -> _VLineWidget: + """Add a draggable vertical line at data position *x*.""" + widget = _VLineWidget(lambda: None, x=float(x), color=color) + plot_ref, wid_id = self, widget._id + def _tp(): + if plot_ref._fig is not None: + fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} + plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) + widget._push_fn = _tp + self._widgets[widget.id] = widget + self._push() + return widget + + def add_hline_widget(self, y: float, color: str = "#00e5ff") -> _HLineWidget: + """Add a draggable horizontal line at value-axis position *y*.""" + widget = _HLineWidget(lambda: None, y=float(y), color=color) + plot_ref, wid_id = self, widget._id + def _tp(): + if plot_ref._fig is not None: + fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} + plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) + widget._push_fn = _tp + self._widgets[widget.id] = widget + self._push() + return widget + + def add_range_widget(self, x0: float, x1: float, + color: str = "#00e5ff", + style: str = "band", + y: float = 0.0, + _push: bool = True) -> _RangeWidget: + """Add a draggable range overlay. See :meth:`Plot1D.add_range_widget` for full docs.""" + widget = _RangeWidget(lambda: None, x0=float(x0), x1=float(x1), + color=color, style=style, y=float(y)) + plot_ref, wid_id = self, widget._id + def _tp(): + if plot_ref._fig is not None: + fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} + plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) + widget._push_fn = _tp + self._widgets[widget.id] = widget + if _push: + self._push() + return widget + + def add_point_widget(self, x: float, y: float, + color: str = "#00e5ff", + show_crosshair: bool = True, + _push: bool = True) -> _PointWidget: + """Add a freely-draggable control point to this panel.""" + widget = _PointWidget(lambda: None, x=float(x), y=float(y), color=color, + show_crosshair=show_crosshair) + plot_ref, wid_id = self, widget._id + def _tp(): + if plot_ref._fig is not None: + fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} + plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) + widget._push_fn = _tp + self._widgets[widget.id] = widget + if _push: + self._push() + return widget + + def get_widget(self, wid) -> Widget: + """Return the Widget object by ID string or Widget instance.""" + if isinstance(wid, Widget): + wid = wid.id + try: + return self._widgets[wid] + except KeyError: + raise KeyError(wid) + + def remove_widget(self, wid) -> None: + """Remove a widget by ID string or Widget instance.""" + if isinstance(wid, Widget): + wid = wid.id + if wid not in self._widgets: + raise KeyError(wid) + del self._widgets[wid] + self._push() + + def list_widgets(self) -> list: + return list(self._widgets.values()) + + def clear_widgets(self) -> None: + self._widgets.clear() + self._push() + + # ------------------------------------------------------------------ + # Callbacks + # ------------------------------------------------------------------ + def on_click(self, fn: Callable) -> Callable: + """Decorator: fires when the user clicks a bar. + + The :class:`~anyplotlib.callbacks.Event` has ``bar_index``, + ``value``, ``x_center``, and ``x_label``. + """ + cid = self.callbacks.connect("on_click", fn) + fn._cid = cid + return fn + + def on_changed(self, fn: Callable) -> Callable: + """Decorator: fires on every drag frame (widget drag or hover).""" + cid = self.callbacks.connect("on_changed", fn) + fn._cid = cid + return fn + + def on_release(self, fn: Callable) -> Callable: + """Decorator: fires once when a widget drag settles.""" + cid = self.callbacks.connect("on_release", fn) + fn._cid = cid + return fn + + def on_key(self, key_or_fn=None) -> Callable: + """Register a key-press handler for this panel. + + Two call forms are supported:: + + @plot.on_key('q') # fires only when 'q' is pressed + def handler(event): ... + + @plot.on_key # fires for every registered key + def handler(event): ... + + The event carries: ``key``, ``mouse_x``, ``mouse_y``, and + ``last_widget_id``. + """ + if callable(key_or_fn): + return self._connect_on_key(None, key_or_fn) + key = key_or_fn + def _decorator(fn): + return self._connect_on_key(key, fn) + return _decorator + + def _connect_on_key(self, key, fn) -> Callable: + if key is None: + if '*' not in self._state['registered_keys']: + self._state['registered_keys'].append('*') + self._push() + cid = self.callbacks.connect("on_key", fn) + else: + if key not in self._state['registered_keys']: + self._state['registered_keys'].append(key) + self._push() + def _wrapped(event): + if event.data.get('key') == key: + fn(event) + cid = self.callbacks.connect("on_key", _wrapped) + _wrapped._cid = cid + fn._cid = cid + return fn + + def disconnect(self, cid: int) -> None: + self.callbacks.disconnect(cid) + + def __repr__(self) -> str: + n = len(self._state.get("values", [])) + orient = self._state.get("orient", "v") + groups = self._state.get("groups", 1) + if groups > 1: + return f"PlotBar(n={n}, groups={groups}, orient={orient!r})" + return f"PlotBar(n={n}, orient={orient!r})" From a68c7e36b2cdfe0f5e3ee63a96f8738f78f9235a Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 11 May 2026 09:16:54 -0500 Subject: [PATCH 106/198] fix: remove unused imports from plot1d/ modules --- anyplotlib/plot1d/_plot1d.py | 2 +- anyplotlib/plot1d/_plotbar.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/anyplotlib/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py index 37466604..a16cb0fa 100644 --- a/anyplotlib/plot1d/_plot1d.py +++ b/anyplotlib/plot1d/_plot1d.py @@ -12,7 +12,7 @@ from typing import Callable from anyplotlib.markers import MarkerRegistry -from anyplotlib.callbacks import CallbackRegistry, Event +from anyplotlib.callbacks import CallbackRegistry from anyplotlib.widgets import ( Widget, VLineWidget as _VLineWidget, diff --git a/anyplotlib/plot1d/_plotbar.py b/anyplotlib/plot1d/_plotbar.py index 37f50c9b..679a96d3 100644 --- a/anyplotlib/plot1d/_plotbar.py +++ b/anyplotlib/plot1d/_plotbar.py @@ -9,7 +9,7 @@ import numpy as np from typing import Callable -from anyplotlib.callbacks import CallbackRegistry, Event +from anyplotlib.callbacks import CallbackRegistry from anyplotlib.widgets import ( Widget, VLineWidget as _VLineWidget, @@ -17,7 +17,6 @@ RangeWidget as _RangeWidget, PointWidget as _PointWidget, ) -from anyplotlib._utils import _arr_to_b64 # --------------------------------------------------------------------------- From 1b16df14df2dc086db4f390683b7df1ff705e1bf Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 11 May 2026 09:23:42 -0500 Subject: [PATCH 107/198] refactor: extract Axes and InsetAxes into axes/ subpackage Moves Axes and InsetAxes out of figure_plots.py into anyplotlib/axes/_axes.py and anyplotlib/axes/_inset_axes.py respectively. Also relocates _plot_kind() to _inset_axes.py where it is used. Backward-compat shims in figure_plots.py preserve all existing import paths. --- anyplotlib/axes/__init__.py | 6 + anyplotlib/axes/_axes.py | 394 ++++++++++++++++++++++++++ anyplotlib/axes/_inset_axes.py | 126 +++++++++ anyplotlib/figure_plots.py | 504 +-------------------------------- 4 files changed, 529 insertions(+), 501 deletions(-) create mode 100644 anyplotlib/axes/__init__.py create mode 100644 anyplotlib/axes/_axes.py create mode 100644 anyplotlib/axes/_inset_axes.py diff --git a/anyplotlib/axes/__init__.py b/anyplotlib/axes/__init__.py new file mode 100644 index 00000000..6a710b9e --- /dev/null +++ b/anyplotlib/axes/__init__.py @@ -0,0 +1,6 @@ +"""anyplotlib.axes — grid-cell and inset axes containers.""" + +from anyplotlib.axes._axes import Axes +from anyplotlib.axes._inset_axes import InsetAxes + +__all__ = ["Axes", "InsetAxes"] diff --git a/anyplotlib/axes/_axes.py b/anyplotlib/axes/_axes.py new file mode 100644 index 00000000..f9b69a81 --- /dev/null +++ b/anyplotlib/axes/_axes.py @@ -0,0 +1,394 @@ +""" +axes/_axes.py +============= +Grid-cell container that owns a single plot panel. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from anyplotlib.plot1d import Plot1D, PlotBar +from anyplotlib.plot2d import Plot2D, PlotMesh +from anyplotlib.plot3d import Plot3D + +if TYPE_CHECKING: + from anyplotlib.figure import Figure + from anyplotlib.figure_plots import SubplotSpec + + +class Axes: + """A single grid cell in a Figure. + + Returned by Figure.add_subplot() and Figure.subplots(). + Call .imshow() or .plot() to attach a data plot and get back + a Plot2D or Plot1D object. + """ + + def __init__(self, fig: "Figure", spec: "SubplotSpec"): # noqa: F821 + self._fig = fig + self._spec = spec + self._plot: "Plot1D | Plot2D | None" = None + + # ------------------------------------------------------------------ + def imshow(self, data: np.ndarray, + axes: list | None = None, + units: str = "px", + cmap: str | None = None, + vmin: float | None = None, + vmax: float | None = None, + origin: str = "upper") -> "Plot2D": + """Attach a 2-D image to this axes cell. + + Parameters + ---------- + data : np.ndarray, shape (H, W) or (H, W, C) + Image data. RGB/RGBA arrays use only the first channel. + axes : [x_axis, y_axis], optional + Physical coordinate arrays for each axis. + units : str, optional + Axis units label. Default ``"px"``. + cmap : str, optional + Colormap name (e.g. ``"viridis"``, ``"inferno"``). + Defaults to ``"gray"``. + vmin, vmax : float, optional + Colormap clipping limits in data units. Values outside this + range are clamped to the colormap endpoints. Defaults to the + data min / max. + origin : ``"upper"`` | ``"lower"``, optional + Where row 0 of the array is placed. ``"upper"`` (default) + puts row 0 at the top, matching the usual image convention. + ``"lower"`` puts row 0 at the bottom, matching the matplotlib + convention for matrices / scientific plots. + + Returns + ------- + Plot2D + """ + x_axis = axes[0] if axes and len(axes) > 0 else None + y_axis = axes[1] if axes and len(axes) > 1 else None + plot = Plot2D(data, x_axis=x_axis, y_axis=y_axis, units=units, + cmap=cmap, vmin=vmin, vmax=vmax, origin=origin) + self._attach(plot) + return plot + + def pcolormesh(self, data: np.ndarray, + x_edges=None, y_edges=None, + units: str = "") -> "PlotMesh": + """Attach a 2-D mesh to this axes cell using edge coordinates. + + Follows the matplotlib pcolormesh convention: x_edges and y_edges + are the cell *edge* coordinates, so they have length N+1 and M+1 + respectively for an (M, N) data array. + + Parameters + ---------- + data : np.ndarray shape (M, N) + x_edges : array-like, length N+1, optional + Column edge coordinates. Defaults to ``np.arange(N+1)``. + y_edges : array-like, length M+1, optional + Row edge coordinates. Defaults to ``np.arange(M+1)``. + units : str, optional + + Returns + ------- + PlotMesh + """ + plot = PlotMesh(data, x_edges=x_edges, y_edges=y_edges, units=units) + self._attach(plot) + return plot + + def plot_surface(self, X, Y, Z, *, + colormap: str = "viridis", + x_label: str = "x", y_label: str = "y", z_label: str = "z", + azimuth: float = -60.0, elevation: float = 30.0, + zoom: float = 1.0) -> "Plot3D": + """Attach a 3-D surface to this axes cell. + + Parameters + ---------- + X, Y, Z : array-like + 2-D grid arrays of the same shape (e.g. from ``np.meshgrid``), + or 1-D centre arrays for X/Y with a 2-D Z. + colormap : str, optional Matplotlib colormap name. Default ``'viridis'``. + x_label, y_label, z_label : str, optional Axis labels. + azimuth, elevation : float, optional Initial camera angles in degrees. + zoom : float, optional Initial zoom factor. + + Returns + ------- + Plot3D + """ + plot = Plot3D("surface", X, Y, Z, colormap=colormap, + x_label=x_label, y_label=y_label, z_label=z_label, + azimuth=azimuth, elevation=elevation, zoom=zoom) + self._attach(plot) + return plot + + def scatter3d(self, x, y, z, *, + color: str = "#4fc3f7", + point_size: float = 4.0, + x_label: str = "x", y_label: str = "y", z_label: str = "z", + azimuth: float = -60.0, elevation: float = 30.0, + zoom: float = 1.0) -> "Plot3D": + """Attach a 3-D scatter plot to this axes cell. + + Parameters + ---------- + x, y, z : array-like, shape (N,) Point coordinates. + color : str, optional CSS colour for all points. + point_size : float, optional Radius of each point in pixels. + x_label, y_label, z_label : str, optional Axis labels. + azimuth, elevation : float, optional Initial camera angles in degrees. + zoom : float, optional Initial zoom factor. + + Returns + ------- + Plot3D + """ + plot = Plot3D("scatter", x, y, z, color=color, point_size=point_size, + x_label=x_label, y_label=y_label, z_label=z_label, + azimuth=azimuth, elevation=elevation, zoom=zoom) + self._attach(plot) + return plot + + def plot3d(self, x, y, z, *, + color: str = "#4fc3f7", + linewidth: float = 1.5, + x_label: str = "x", y_label: str = "y", z_label: str = "z", + azimuth: float = -60.0, elevation: float = 30.0, + zoom: float = 1.0) -> "Plot3D": + """Attach a 3-D line plot to this axes cell. + + Parameters + ---------- + x, y, z : array-like, shape (N,) Point coordinates along the line. + color : str, optional CSS colour. + linewidth : float, optional Stroke width in pixels. + x_label, y_label, z_label : str, optional Axis labels. + azimuth, elevation : float, optional Initial camera angles in degrees. + zoom : float, optional Initial zoom factor. + + Returns + ------- + Plot3D + """ + plot = Plot3D("line", x, y, z, color=color, linewidth=linewidth, + x_label=x_label, y_label=y_label, z_label=z_label, + azimuth=azimuth, elevation=elevation, zoom=zoom) + self._attach(plot) + return plot + + def plot(self, data: np.ndarray, + axes: list | None = None, + units: str = "px", + y_units: str = "", + color: str = "#4fc3f7", + linewidth: float = 1.5, + linestyle: str = "solid", + ls: str | None = None, + alpha: float = 1.0, + marker: str = "none", + markersize: float = 4.0, + label: str = "") -> "Plot1D": + """Attach a 1-D line to this axes cell. + + Parameters + ---------- + data : array-like, shape (N,) + Y values. Must be 1-D. + axes : list, optional + ``[x_axis]`` — a one-element list containing the x-coordinates + (shape ``(N,)``). If omitted the x-axis defaults to + ``0, 1, …, N-1``. + units : str, optional + Label for the x-axis (e.g. ``"eV"``, ``"s"``). Default + ``"px"``. + y_units : str, optional + Label for the y-axis. Default ``""`` (no label). + color : str, optional + CSS colour string for the line (hex, ``rgb()``, named colour, + etc.). Default ``"#4fc3f7"``. + linewidth : float, optional + Stroke width in pixels. Default ``1.5``. + linestyle : str, optional + Dash pattern. Accepted values: ``"solid"`` (``"-"``), + ``"dashed"`` (``"--"``), ``"dotted"`` (``":"``), + ``"dashdot"`` (``"-."``) . Default ``"solid"``. + ls : str, optional + Short alias for *linestyle*. Takes precedence if both are given. + alpha : float, optional + Line opacity in the range 0–1. Default ``1.0`` (fully opaque). + marker : str, optional + Per-point marker symbol. Supported values: ``"o"`` (circle), + ``"s"`` (square), ``"^"`` (triangle-up), ``"v"`` (triangle-down), + ``"D"`` (diamond), ``"+"`` (plus), ``"x"`` (cross), + ``"none"`` (no markers). Default ``"none"``. + markersize : float, optional + Marker radius / half-side in pixels. Default ``4.0``. + label : str, optional + Legend label. A legend is only drawn when at least one line has + a non-empty label. Default ``""`` (no legend entry). + + Returns + ------- + Plot1D + Live plot object. Call methods on it to update data, add + overlays, register callbacks, etc. + + Examples + -------- + Basic sine wave with a physical x-axis:: + + import numpy as np + import anyplotlib as vw + + x = np.linspace(0, 4 * np.pi, 512) + fig, ax = vw.subplots(1, 1, figsize=(620, 320)) + v = ax.plot(np.sin(x), axes=[x], units="rad", + color="#ff7043", linewidth=2, label="sin") + v # display in a Jupyter cell + + Dashed line with semi-transparent markers:: + + v = ax.plot(data, linestyle="dashed", alpha=0.7, + marker="o", markersize=4) + + Overlay a second curve with :meth:`Plot1D.add_line`:: + + v.add_line(np.cos(x), x_axis=x, color="#aed581", label="cos") + """ + x_axis = axes[0] if axes and len(axes) > 0 else None + plot = Plot1D(data, x_axis=x_axis, units=units, y_units=y_units, + color=color, linewidth=linewidth, + linestyle=ls if ls is not None else linestyle, + alpha=alpha, marker=marker, markersize=markersize, + label=label) + self._attach(plot) + return plot + + def bar(self, x, height=None, width: float = 0.8, bottom: float = 0.0, *, + align: str = "center", + color: str = "#4fc3f7", + colors=None, + orient: str = "v", + log_scale: bool = False, + group_labels=None, + group_colors=None, + show_values: bool = False, + units: str = "", + y_units: str = "", + # ── legacy backward-compat kwargs ────────────────────────────── + x_labels=None, + x_centers=None, + bar_width=None, + baseline=None, + values=None) -> "PlotBar": + """Attach a bar chart to this axes cell. + + Signature mirrors ``matplotlib.pyplot.bar``:: + + ax.bar(x, height, width=0.8, bottom=0.0, ...) + + Parameters + ---------- + x : array-like of str or numeric + Bar positions. Strings become category labels with auto-numeric + centres; numbers are used directly as bar centres. + height : array-like, shape ``(N,)`` or ``(N, G)``, optional + Bar heights. Pass a 2-D array to draw *G* grouped bars per + category. If omitted *x* is treated as the heights and positions + are generated automatically (backward-compatible call form). + width : float, optional + Bar width as a fraction of the category slot (0–1). Default ``0.8``. + bottom : float, optional + Value at which bars are rooted (baseline). Default ``0``. + align : ``"center"`` | ``"edge"``, optional + Alignment of the bar relative to its *x* position. Currently only + ``"center"`` is rendered; stored for future use. + color : str, optional + Single CSS colour applied to every bar. Default ``"#4fc3f7"``. + colors : list of str, optional + Per-bar colour list (ungrouped) or ignored when *group_colors* is set. + orient : ``"v"`` | ``"h"``, optional + Vertical (default) or horizontal orientation. + log_scale : bool, optional + Use a logarithmic value axis. Non-positive values are clamped to + ``1e-10`` for display. Default ``False``. + group_labels : list of str, optional + Legend labels for each group in a grouped bar chart. + group_colors : list of str, optional + CSS colours per group. Defaults to a built-in palette. + show_values : bool, optional + Draw the numeric value above / beside each bar. + units : str, optional + Label for the categorical axis. + y_units : str, optional + Label for the value axis. + + Backward-compatible keyword aliases + ------------------------------------ + ``values`` → ``height`` + ``x_centers`` → ``x`` + ``bar_width`` → ``width`` + ``baseline`` → ``bottom`` + ``x_labels`` → strings passed via ``x`` + + Returns + ------- + PlotBar + """ + # ── legacy backward-compat resolution ───────────────────────────── + if height is None: + if values is not None: + height = values + else: + height = x + x = None + if baseline is not None: + bottom = baseline + if bar_width is not None: + width = bar_width + + plot = PlotBar(x, height, width=width, bottom=bottom, + align=align, color=color, colors=colors, + orient=orient, log_scale=log_scale, + group_labels=group_labels, group_colors=group_colors, + show_values=show_values, units=units, y_units=y_units, + x_labels=x_labels, x_centers=x_centers) + self._attach(plot) + return plot + + def _panel_id_from_spec(self) -> str: + """Derive a deterministic, position-based panel ID from the SubplotSpec. + + The ID is ``"p"`` followed by the first 7 hex characters of a SHA-256 + hash of the row/col bounds, e.g. ``"p6a2f3b1"``. This is: + + * **Deterministic** – the same SubplotSpec always produces the same ID + across Python processes and after code edits. + * **Starts with "p"** – satisfies the JS naming convention and makes it + easy to grep for panel traits (``panel_{id}_json``). + * **Short** – 8 characters total; safe to embed in CSS selectors. + """ + import hashlib as _hl + key = f"{self._spec.row_start},{self._spec.row_stop},{self._spec.col_start},{self._spec.col_stop}" + return "p" + _hl.sha256(key.encode()).hexdigest()[:7] + + def _attach(self, plot: "Plot1D | Plot2D | PlotMesh | Plot3D | PlotBar") -> None: + """Register a plot on this axes (replace any previous plot).""" + # Allocate a panel id if needed; reuse if replacing + if self._plot is not None: + panel_id = self._plot._id + else: + panel_id = self._panel_id_from_spec() + plot._id = panel_id + plot._fig = self._fig + self._plot = plot + self._fig._register_panel(self, plot) + + def __repr__(self) -> str: + kind = type(self._plot).__name__ if self._plot else "empty" + return f"Axes(rows={self._spec.row_start}:{self._spec.row_stop}, cols={self._spec.col_start}:{self._spec.col_stop}, {kind})" diff --git a/anyplotlib/axes/_inset_axes.py b/anyplotlib/axes/_inset_axes.py new file mode 100644 index 00000000..a8be23ea --- /dev/null +++ b/anyplotlib/axes/_inset_axes.py @@ -0,0 +1,126 @@ +""" +axes/_inset_axes.py +=================== +Floating overlay inset (not in the grid). +""" + +from __future__ import annotations + +import uuid as _uuid + +from anyplotlib.axes._axes import Axes +from anyplotlib.plot1d import PlotBar +from anyplotlib.plot2d import Plot2D, PlotMesh +from anyplotlib.plot3d import Plot3D + + +def _plot_kind(plot) -> str: + """Return the JS panel-kind string for a plot object. + + Used in ``Figure._push_layout()`` and ``InsetAxes.__repr__``. + """ + if isinstance(plot, Plot3D): + return "3d" + if isinstance(plot, (Plot2D, PlotMesh)): + return "2d" + if isinstance(plot, PlotBar): + return "bar" + return "1d" + + +_VALID_CORNERS = ("top-right", "top-left", "bottom-right", "bottom-left") + + +class InsetAxes(Axes): + """A floating inset sub-plot that overlays the main Figure grid. + + Created via :meth:`Figure.add_inset`. Supports the same plot-factory + methods as :class:`Axes` (``imshow``, ``plot``, ``pcolormesh``, etc.). + + The inset is positioned at a corner of the figure and can be minimized + (title bar only), maximized (expanded to fill ~72% of the figure), or + restored to its normal size. + + Parameters + ---------- + fig : Figure + w_frac, h_frac : float + Width and height as fractions of the figure dimensions (0–1). + corner : str, optional + One of ``"top-right"``, ``"top-left"``, ``"bottom-right"``, + ``"bottom-left"``. Default ``"top-right"``. + title : str, optional + Text shown in the inset title bar. Default ``""``. + + Examples + -------- + >>> fig, ax = apl.subplots(1, 1, figsize=(640, 480)) + >>> ax.imshow(data) + >>> inset = fig.add_inset(0.3, 0.25, corner="top-right", title="Zoom") + >>> inset.imshow(data[64:128, 64:128]) + """ + + def __init__(self, fig, w_frac: float, h_frac: float, *, + corner: str = "top-right", title: str = ""): + if corner not in _VALID_CORNERS: + raise ValueError( + f"corner must be one of {_VALID_CORNERS!r}, got {corner!r}" + ) + # Pass a dummy SubplotSpec so Axes.__init__ doesn't fail — InsetAxes + # never occupies a grid cell, only overlays the figure. + from anyplotlib.figure_plots import SubplotSpec + super().__init__(fig, SubplotSpec(None, 0, 1, 0, 1)) + self.w_frac = w_frac + self.h_frac = h_frac + self.corner = corner + self.title = title + self._inset_state: str = "normal" + + # ── state API ───────────────────────────────────────────────────────── + + @property + def inset_state(self) -> str: + """Current state: ``"normal"``, ``"minimized"``, or ``"maximized"``.""" + return self._inset_state + + def minimize(self) -> None: + """Collapse the inset to its title bar only (idempotent).""" + if self._inset_state == "minimized": + return + self._inset_state = "minimized" + self._fig._push_layout() + + def maximize(self) -> None: + """Expand the inset to ~72 % of the figure, centred (idempotent).""" + if self._inset_state == "maximized": + return + self._inset_state = "maximized" + self._fig._push_layout() + + def restore(self) -> None: + """Return the inset to its normal corner position (idempotent).""" + if self._inset_state == "normal": + return + self._inset_state = "normal" + self._fig._push_layout() + + # ── internal ────────────────────────────────────────────────────────── + + def _attach(self, plot) -> None: + """Register the plot on this inset via Figure._register_inset.""" + if self._plot is not None: + panel_id = self._plot._id + else: + panel_id = str(_uuid.uuid4())[:8] + plot._id = panel_id + plot._fig = self._fig + self._plot = plot + self._fig._register_inset(self, plot) + + def __repr__(self) -> str: + kind = _plot_kind(self._plot) if self._plot else "empty" + return ( + f"InsetAxes(corner={self.corner!r}, " + f"size=({self.w_frac:.2f}, {self.h_frac:.2f}), " + f"state={self._inset_state!r}, kind={kind!r})" + ) diff --git a/anyplotlib/figure_plots.py b/anyplotlib/figure_plots.py index 99c4a290..064e8841 100644 --- a/anyplotlib/figure_plots.py +++ b/anyplotlib/figure_plots.py @@ -38,9 +38,11 @@ from anyplotlib.plot3d import Plot3D # noqa: F401 from anyplotlib.plot2d import Plot2D, PlotMesh # noqa: F401 from anyplotlib.plot1d import Line1D, Plot1D, PlotBar # noqa: F401 +from anyplotlib.axes import Axes, InsetAxes # noqa: F401 +from anyplotlib.axes._inset_axes import _plot_kind # noqa: F401 __all__ = ["GridSpec", "SubplotSpec", "Axes", "InsetAxes", "Line1D", "Plot1D", "Plot2D", - "PlotMesh", "Plot3D", "PlotBar", "_plot_kind", "_resample_mesh", "_norm_linestyle"] + "PlotMesh", "Plot3D", "PlotBar", "_resample_mesh", "_norm_linestyle"] # --------------------------------------------------------------------------- @@ -139,504 +141,4 @@ def __repr__(self) -> str: return f"GridSpec({self.nrows}, {self.ncols})" -# --------------------------------------------------------------------------- -# Axes — grid cell container -# --------------------------------------------------------------------------- - -class Axes: - """A single grid cell in a Figure. - - Returned by Figure.add_subplot() and Figure.subplots(). - Call .imshow() or .plot() to attach a data plot and get back - a Plot2D or Plot1D object. - """ - - def __init__(self, fig: "Figure", spec: SubplotSpec): # noqa: F821 - self._fig = fig - self._spec = spec - self._plot: "Plot1D | Plot2D | None" = None - - # ------------------------------------------------------------------ - def imshow(self, data: np.ndarray, - axes: list | None = None, - units: str = "px", - cmap: str | None = None, - vmin: float | None = None, - vmax: float | None = None, - origin: str = "upper") -> "Plot2D": - """Attach a 2-D image to this axes cell. - - Parameters - ---------- - data : np.ndarray, shape (H, W) or (H, W, C) - Image data. RGB/RGBA arrays use only the first channel. - axes : [x_axis, y_axis], optional - Physical coordinate arrays for each axis. - units : str, optional - Axis units label. Default ``"px"``. - cmap : str, optional - Colormap name (e.g. ``"viridis"``, ``"inferno"``). - Defaults to ``"gray"``. - vmin, vmax : float, optional - Colormap clipping limits in data units. Values outside this - range are clamped to the colormap endpoints. Defaults to the - data min / max. - origin : ``"upper"`` | ``"lower"``, optional - Where row 0 of the array is placed. ``"upper"`` (default) - puts row 0 at the top, matching the usual image convention. - ``"lower"`` puts row 0 at the bottom, matching the matplotlib - convention for matrices / scientific plots. - - Returns - ------- - Plot2D - """ - x_axis = axes[0] if axes and len(axes) > 0 else None - y_axis = axes[1] if axes and len(axes) > 1 else None - plot = Plot2D(data, x_axis=x_axis, y_axis=y_axis, units=units, - cmap=cmap, vmin=vmin, vmax=vmax, origin=origin) - self._attach(plot) - return plot - - def pcolormesh(self, data: np.ndarray, - x_edges=None, y_edges=None, - units: str = "") -> "PlotMesh": - """Attach a 2-D mesh to this axes cell using edge coordinates. - - Follows the matplotlib pcolormesh convention: x_edges and y_edges - are the cell *edge* coordinates, so they have length N+1 and M+1 - respectively for an (M, N) data array. - - Parameters - ---------- - data : np.ndarray shape (M, N) - x_edges : array-like, length N+1, optional - Column edge coordinates. Defaults to ``np.arange(N+1)``. - y_edges : array-like, length M+1, optional - Row edge coordinates. Defaults to ``np.arange(M+1)``. - units : str, optional - - Returns - ------- - PlotMesh - """ - plot = PlotMesh(data, x_edges=x_edges, y_edges=y_edges, units=units) - self._attach(plot) - return plot - - def plot_surface(self, X, Y, Z, *, - colormap: str = "viridis", - x_label: str = "x", y_label: str = "y", z_label: str = "z", - azimuth: float = -60.0, elevation: float = 30.0, - zoom: float = 1.0) -> "Plot3D": - """Attach a 3-D surface to this axes cell. - - Parameters - ---------- - X, Y, Z : array-like - 2-D grid arrays of the same shape (e.g. from ``np.meshgrid``), - or 1-D centre arrays for X/Y with a 2-D Z. - colormap : str, optional Matplotlib colormap name. Default ``'viridis'``. - x_label, y_label, z_label : str, optional Axis labels. - azimuth, elevation : float, optional Initial camera angles in degrees. - zoom : float, optional Initial zoom factor. - - Returns - ------- - Plot3D - """ - plot = Plot3D("surface", X, Y, Z, colormap=colormap, - x_label=x_label, y_label=y_label, z_label=z_label, - azimuth=azimuth, elevation=elevation, zoom=zoom) - self._attach(plot) - return plot - - def scatter3d(self, x, y, z, *, - color: str = "#4fc3f7", - point_size: float = 4.0, - x_label: str = "x", y_label: str = "y", z_label: str = "z", - azimuth: float = -60.0, elevation: float = 30.0, - zoom: float = 1.0) -> "Plot3D": - """Attach a 3-D scatter plot to this axes cell. - - Parameters - ---------- - x, y, z : array-like, shape (N,) Point coordinates. - color : str, optional CSS colour for all points. - point_size : float, optional Radius of each point in pixels. - x_label, y_label, z_label : str, optional Axis labels. - azimuth, elevation : float, optional Initial camera angles in degrees. - zoom : float, optional Initial zoom factor. - - Returns - ------- - Plot3D - """ - plot = Plot3D("scatter", x, y, z, color=color, point_size=point_size, - x_label=x_label, y_label=y_label, z_label=z_label, - azimuth=azimuth, elevation=elevation, zoom=zoom) - self._attach(plot) - return plot - - def plot3d(self, x, y, z, *, - color: str = "#4fc3f7", - linewidth: float = 1.5, - x_label: str = "x", y_label: str = "y", z_label: str = "z", - azimuth: float = -60.0, elevation: float = 30.0, - zoom: float = 1.0) -> "Plot3D": - """Attach a 3-D line plot to this axes cell. - - Parameters - ---------- - x, y, z : array-like, shape (N,) Point coordinates along the line. - color : str, optional CSS colour. - linewidth : float, optional Stroke width in pixels. - x_label, y_label, z_label : str, optional Axis labels. - azimuth, elevation : float, optional Initial camera angles in degrees. - zoom : float, optional Initial zoom factor. - - Returns - ------- - Plot3D - """ - plot = Plot3D("line", x, y, z, color=color, linewidth=linewidth, - x_label=x_label, y_label=y_label, z_label=z_label, - azimuth=azimuth, elevation=elevation, zoom=zoom) - self._attach(plot) - return plot - - def plot(self, data: np.ndarray, - axes: list | None = None, - units: str = "px", - y_units: str = "", - color: str = "#4fc3f7", - linewidth: float = 1.5, - linestyle: str = "solid", - ls: str | None = None, - alpha: float = 1.0, - marker: str = "none", - markersize: float = 4.0, - label: str = "") -> "Plot1D": - """Attach a 1-D line to this axes cell. - - Parameters - ---------- - data : array-like, shape (N,) - Y values. Must be 1-D. - axes : list, optional - ``[x_axis]`` — a one-element list containing the x-coordinates - (shape ``(N,)``). If omitted the x-axis defaults to - ``0, 1, …, N-1``. - units : str, optional - Label for the x-axis (e.g. ``"eV"``, ``"s"``). Default - ``"px"``. - y_units : str, optional - Label for the y-axis. Default ``""`` (no label). - color : str, optional - CSS colour string for the line (hex, ``rgb()``, named colour, - etc.). Default ``"#4fc3f7"``. - linewidth : float, optional - Stroke width in pixels. Default ``1.5``. - linestyle : str, optional - Dash pattern. Accepted values: ``"solid"`` (``"-"``), - ``"dashed"`` (``"--"``), ``"dotted"`` (``":"``), - ``"dashdot"`` (``"-."``) . Default ``"solid"``. - ls : str, optional - Short alias for *linestyle*. Takes precedence if both are given. - alpha : float, optional - Line opacity in the range 0–1. Default ``1.0`` (fully opaque). - marker : str, optional - Per-point marker symbol. Supported values: ``"o"`` (circle), - ``"s"`` (square), ``"^"`` (triangle-up), ``"v"`` (triangle-down), - ``"D"`` (diamond), ``"+"`` (plus), ``"x"`` (cross), - ``"none"`` (no markers). Default ``"none"``. - markersize : float, optional - Marker radius / half-side in pixels. Default ``4.0``. - label : str, optional - Legend label. A legend is only drawn when at least one line has - a non-empty label. Default ``""`` (no legend entry). - - Returns - ------- - Plot1D - Live plot object. Call methods on it to update data, add - overlays, register callbacks, etc. - - Examples - -------- - Basic sine wave with a physical x-axis:: - - import numpy as np - import anyplotlib as vw - - x = np.linspace(0, 4 * np.pi, 512) - fig, ax = vw.subplots(1, 1, figsize=(620, 320)) - v = ax.plot(np.sin(x), axes=[x], units="rad", - color="#ff7043", linewidth=2, label="sin") - v # display in a Jupyter cell - - Dashed line with semi-transparent markers:: - - v = ax.plot(data, linestyle="dashed", alpha=0.7, - marker="o", markersize=4) - - Overlay a second curve with :meth:`Plot1D.add_line`:: - - v.add_line(np.cos(x), x_axis=x, color="#aed581", label="cos") - """ - x_axis = axes[0] if axes and len(axes) > 0 else None - plot = Plot1D(data, x_axis=x_axis, units=units, y_units=y_units, - color=color, linewidth=linewidth, - linestyle=ls if ls is not None else linestyle, - alpha=alpha, marker=marker, markersize=markersize, - label=label) - self._attach(plot) - return plot - - def bar(self, x, height=None, width: float = 0.8, bottom: float = 0.0, *, - align: str = "center", - color: str = "#4fc3f7", - colors=None, - orient: str = "v", - log_scale: bool = False, - group_labels=None, - group_colors=None, - show_values: bool = False, - units: str = "", - y_units: str = "", - # ── legacy backward-compat kwargs ────────────────────────────── - x_labels=None, - x_centers=None, - bar_width=None, - baseline=None, - values=None) -> "PlotBar": - """Attach a bar chart to this axes cell. - - Signature mirrors ``matplotlib.pyplot.bar``:: - - ax.bar(x, height, width=0.8, bottom=0.0, ...) - - Parameters - ---------- - x : array-like of str or numeric - Bar positions. Strings become category labels with auto-numeric - centres; numbers are used directly as bar centres. - height : array-like, shape ``(N,)`` or ``(N, G)``, optional - Bar heights. Pass a 2-D array to draw *G* grouped bars per - category. If omitted *x* is treated as the heights and positions - are generated automatically (backward-compatible call form). - width : float, optional - Bar width as a fraction of the category slot (0–1). Default ``0.8``. - bottom : float, optional - Value at which bars are rooted (baseline). Default ``0``. - align : ``"center"`` | ``"edge"``, optional - Alignment of the bar relative to its *x* position. Currently only - ``"center"`` is rendered; stored for future use. - color : str, optional - Single CSS colour applied to every bar. Default ``"#4fc3f7"``. - colors : list of str, optional - Per-bar colour list (ungrouped) or ignored when *group_colors* is set. - orient : ``"v"`` | ``"h"``, optional - Vertical (default) or horizontal orientation. - log_scale : bool, optional - Use a logarithmic value axis. Non-positive values are clamped to - ``1e-10`` for display. Default ``False``. - group_labels : list of str, optional - Legend labels for each group in a grouped bar chart. - group_colors : list of str, optional - CSS colours per group. Defaults to a built-in palette. - show_values : bool, optional - Draw the numeric value above / beside each bar. - units : str, optional - Label for the categorical axis. - y_units : str, optional - Label for the value axis. - - Backward-compatible keyword aliases - ------------------------------------ - ``values`` → ``height`` - ``x_centers`` → ``x`` - ``bar_width`` → ``width`` - ``baseline`` → ``bottom`` - ``x_labels`` → strings passed via ``x`` - - Returns - ------- - PlotBar - """ - # ── legacy backward-compat resolution ───────────────────────────── - if height is None: - if values is not None: - height = values - else: - height = x - x = None - if baseline is not None: - bottom = baseline - if bar_width is not None: - width = bar_width - - plot = PlotBar(x, height, width=width, bottom=bottom, - align=align, color=color, colors=colors, - orient=orient, log_scale=log_scale, - group_labels=group_labels, group_colors=group_colors, - show_values=show_values, units=units, y_units=y_units, - x_labels=x_labels, x_centers=x_centers) - self._attach(plot) - return plot - - def _panel_id_from_spec(self) -> str: - """Derive a deterministic, position-based panel ID from the SubplotSpec. - - The ID is ``"p"`` followed by the first 7 hex characters of a SHA-256 - hash of the row/col bounds, e.g. ``"p6a2f3b1"``. This is: - - * **Deterministic** – the same SubplotSpec always produces the same ID - across Python processes and after code edits. - * **Starts with "p"** – satisfies the JS naming convention and makes it - easy to grep for panel traits (``panel_{id}_json``). - * **Short** – 8 characters total; safe to embed in CSS selectors. - """ - import hashlib as _hl - key = f"{self._spec.row_start},{self._spec.row_stop},{self._spec.col_start},{self._spec.col_stop}" - return "p" + _hl.sha256(key.encode()).hexdigest()[:7] - - def _attach(self, plot: "Plot1D | Plot2D | PlotMesh | Plot3D | PlotBar") -> None: - """Register a plot on this axes (replace any previous plot).""" - # Allocate a panel id if needed; reuse if replacing - if self._plot is not None: - panel_id = self._plot._id - else: - panel_id = self._panel_id_from_spec() - plot._id = panel_id - plot._fig = self._fig - self._plot = plot - self._fig._register_panel(self, plot) - - def __repr__(self) -> str: - kind = type(self._plot).__name__ if self._plot else "empty" - return f"Axes(rows={self._spec.row_start}:{self._spec.row_stop}, cols={self._spec.col_start}:{self._spec.col_stop}, {kind})" - - -# --------------------------------------------------------------------------- -# _plot_kind — shared kind string for both layout serialisation and InsetAxes -# --------------------------------------------------------------------------- - -def _plot_kind(plot) -> str: - """Return the JS panel-kind string for a plot object. - - Used in ``Figure._push_layout()`` and ``InsetAxes.__repr__``. - """ - if isinstance(plot, Plot3D): - return "3d" - if isinstance(plot, (Plot2D, PlotMesh)): - return "2d" - if isinstance(plot, PlotBar): - return "bar" - return "1d" - - -# --------------------------------------------------------------------------- -# InsetAxes — floating overlay sub-plot -# --------------------------------------------------------------------------- - -_VALID_CORNERS = ("top-right", "top-left", "bottom-right", "bottom-left") - - -class InsetAxes(Axes): - """A floating inset sub-plot that overlays the main Figure grid. - - Created via :meth:`Figure.add_inset`. Supports the same plot-factory - methods as :class:`Axes` (``imshow``, ``plot``, ``pcolormesh``, etc.). - - The inset is positioned at a corner of the figure and can be minimized - (title bar only), maximized (expanded to fill ~72% of the figure), or - restored to its normal size. - - Parameters - ---------- - fig : Figure - w_frac, h_frac : float - Width and height as fractions of the figure dimensions (0–1). - corner : str, optional - One of ``"top-right"``, ``"top-left"``, ``"bottom-right"``, - ``"bottom-left"``. Default ``"top-right"``. - title : str, optional - Text shown in the inset title bar. Default ``""``. - - Examples - -------- - >>> fig, ax = apl.subplots(1, 1, figsize=(640, 480)) - >>> ax.imshow(data) - >>> inset = fig.add_inset(0.3, 0.25, corner="top-right", title="Zoom") - >>> inset.imshow(data[64:128, 64:128]) - """ - - def __init__(self, fig, w_frac: float, h_frac: float, *, - corner: str = "top-right", title: str = ""): - if corner not in _VALID_CORNERS: - raise ValueError( - f"corner must be one of {_VALID_CORNERS!r}, got {corner!r}" - ) - # Pass a dummy SubplotSpec so Axes.__init__ doesn't fail — InsetAxes - # never occupies a grid cell, only overlays the figure. - super().__init__(fig, SubplotSpec(None, 0, 1, 0, 1)) - self.w_frac = w_frac - self.h_frac = h_frac - self.corner = corner - self.title = title - self._inset_state: str = "normal" - - # ── state API ───────────────────────────────────────────────────────── - - @property - def inset_state(self) -> str: - """Current state: ``"normal"``, ``"minimized"``, or ``"maximized"``.""" - return self._inset_state - - def minimize(self) -> None: - """Collapse the inset to its title bar only (idempotent).""" - if self._inset_state == "minimized": - return - self._inset_state = "minimized" - self._fig._push_layout() - - def maximize(self) -> None: - """Expand the inset to ~72 % of the figure, centred (idempotent).""" - if self._inset_state == "maximized": - return - self._inset_state = "maximized" - self._fig._push_layout() - - def restore(self) -> None: - """Return the inset to its normal corner position (idempotent).""" - if self._inset_state == "normal": - return - self._inset_state = "normal" - self._fig._push_layout() - - # ── internal ────────────────────────────────────────────────────────── - - def _attach(self, plot) -> None: - """Register the plot on this inset via Figure._register_inset.""" - if self._plot is not None: - panel_id = self._plot._id - else: - panel_id = str(_uuid.uuid4())[:8] - plot._id = panel_id - plot._fig = self._fig - self._plot = plot - self._fig._register_inset(self, plot) - - def __repr__(self) -> str: - kind = _plot_kind(self._plot) if self._plot else "empty" - return ( - f"InsetAxes(corner={self.corner!r}, " - f"size=({self.w_frac:.2f}, {self.h_frac:.2f}), " - f"state={self._inset_state!r}, kind={kind!r})" - ) - - - - From 261934932a752bde86fc141a13f68d94ad03f126 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 11 May 2026 09:29:24 -0500 Subject: [PATCH 108/198] fix: add __all__ to axes/__init__.py and remove dead imports from figure_plots.py --- anyplotlib/figure_plots.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/anyplotlib/figure_plots.py b/anyplotlib/figure_plots.py index 064e8841..498bc3b8 100644 --- a/anyplotlib/figure_plots.py +++ b/anyplotlib/figure_plots.py @@ -13,22 +13,6 @@ from __future__ import annotations -import uuid as _uuid -import numpy as np -from typing import Callable - -from anyplotlib.markers import MarkerRegistry -from anyplotlib.callbacks import CallbackRegistry -from anyplotlib.widgets import ( - Widget, - RectangleWidget, CircleWidget, AnnularWidget, - CrosshairWidget, PolygonWidget, LabelWidget, - VLineWidget as _VLineWidget, - HLineWidget as _HLineWidget, - RangeWidget as _RangeWidget, - PointWidget as _PointWidget, -) - # Backward compatibility: helpers now live in _utils from anyplotlib._utils import ( # noqa: F401 _arr_to_b64, _norm_linestyle, _normalize_image, From 3f492abdd579802f1c49f7a202eaab87402cd480 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 11 May 2026 09:37:26 -0500 Subject: [PATCH 109/198] refactor: extract Figure, GridSpec, SubplotSpec into figure/ subpackage --- anyplotlib/axes/_axes.py | 2 +- anyplotlib/axes/_inset_axes.py | 2 +- anyplotlib/figure/__init__.py | 7 + anyplotlib/{figure.py => figure/_figure.py} | 136 ++------------------ anyplotlib/figure/_gridspec.py | 101 +++++++++++++++ anyplotlib/figure/_subplots.py | 101 +++++++++++++++ anyplotlib/figure_plots.py | 126 ++---------------- 7 files changed, 235 insertions(+), 240 deletions(-) create mode 100644 anyplotlib/figure/__init__.py rename anyplotlib/{figure.py => figure/_figure.py} (79%) create mode 100644 anyplotlib/figure/_gridspec.py create mode 100644 anyplotlib/figure/_subplots.py diff --git a/anyplotlib/axes/_axes.py b/anyplotlib/axes/_axes.py index f9b69a81..6d4c6ea8 100644 --- a/anyplotlib/axes/_axes.py +++ b/anyplotlib/axes/_axes.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: from anyplotlib.figure import Figure - from anyplotlib.figure_plots import SubplotSpec + from anyplotlib.figure._gridspec import SubplotSpec class Axes: diff --git a/anyplotlib/axes/_inset_axes.py b/anyplotlib/axes/_inset_axes.py index a8be23ea..9a0b8523 100644 --- a/anyplotlib/axes/_inset_axes.py +++ b/anyplotlib/axes/_inset_axes.py @@ -68,7 +68,7 @@ def __init__(self, fig, w_frac: float, h_frac: float, *, ) # Pass a dummy SubplotSpec so Axes.__init__ doesn't fail — InsetAxes # never occupies a grid cell, only overlays the figure. - from anyplotlib.figure_plots import SubplotSpec + from anyplotlib.figure._gridspec import SubplotSpec super().__init__(fig, SubplotSpec(None, 0, 1, 0, 1)) self.w_frac = w_frac self.h_frac = h_frac diff --git a/anyplotlib/figure/__init__.py b/anyplotlib/figure/__init__.py new file mode 100644 index 00000000..43aa793b --- /dev/null +++ b/anyplotlib/figure/__init__.py @@ -0,0 +1,7 @@ +"""anyplotlib.figure — Figure widget, grid spec, and subplots factory.""" + +from anyplotlib.figure._figure import Figure +from anyplotlib.figure._gridspec import GridSpec, SubplotSpec +from anyplotlib.figure._subplots import subplots + +__all__ = ["Figure", "GridSpec", "SubplotSpec", "subplots"] diff --git a/anyplotlib/figure.py b/anyplotlib/figure/_figure.py similarity index 79% rename from anyplotlib/figure.py rename to anyplotlib/figure/_figure.py index 7161721e..d9d356d0 100644 --- a/anyplotlib/figure.py +++ b/anyplotlib/figure/_figure.py @@ -1,37 +1,24 @@ """ -figure.py -========= - -Top-level :class:`Figure` widget and the :func:`subplots` factory. - -``Figure`` is the only ``anywidget.AnyWidget`` subclass in anyplotlib. -It owns all traitlets and acts as the Python ↔ JavaScript bridge. -Use :func:`subplots` (the recommended entry-point) or construct a -``Figure`` directly and call :meth:`Figure.add_subplot` to attach data. +figure/_figure.py +================= +Top-level Figure widget (the single anywidget.AnyWidget subclass). +""" -Example -------- -.. code-block:: python +from __future__ import annotations - import numpy as np - import anyplotlib as apl +import json +import pathlib - fig, axs = apl.subplots(1, 2, figsize=(800, 400)) - axs[0].imshow(np.random.standard_normal((128, 128))) - axs[1].plot(np.sin(np.linspace(0, 6.28, 256))) - fig -""" +import anywidget +import traitlets -from __future__ import annotations -import json, pathlib -import anywidget, numpy as np, traitlets -from anyplotlib.figure_plots import (GridSpec, SubplotSpec, Axes, Plot2D, PlotMesh, - Plot3D, PlotBar, InsetAxes, _plot_kind) +from anyplotlib.axes import Axes, InsetAxes +from anyplotlib.axes._inset_axes import _plot_kind +from anyplotlib.figure._gridspec import GridSpec, SubplotSpec from anyplotlib.callbacks import Event +from anyplotlib._repr_utils import repr_html_iframe -__all__ = ["Figure", "GridSpec", "SubplotSpec", "subplots"] - -_HERE = pathlib.Path(__file__).parent +_HERE = pathlib.Path(__file__).parent.parent _ESM_SOURCE = (_HERE / "figure_esm.js").read_text(encoding="utf-8") @@ -441,104 +428,9 @@ def _repr_html_(self) -> str: str HTML string containing an embedded iframe with srcdoc attribute. """ - from anyplotlib._repr_utils import repr_html_iframe return repr_html_iframe(self) def __repr__(self) -> str: return (f"Figure({self._nrows}x{self._ncols}, " f"panels={len(self._plots_map)}, " f"size={self.fig_width}x{self.fig_height})") - - -# --------------------------------------------------------------------------- -# subplots — module-level convenience -# --------------------------------------------------------------------------- - -def subplots(nrows=1, ncols=1, *, - sharex=False, sharey=False, - figsize=(640, 480), - width_ratios=None, - height_ratios=None, - gridspec_kw=None, - display_stats=False, - help=""): - """Create a :class:`Figure` and a grid of :class:`~anyplotlib.figure_plots.Axes`. - - Mirrors :func:`matplotlib.pyplot.subplots`. - - Parameters - ---------- - nrows, ncols : int - Number of rows and columns in the grid. - sharex, sharey : bool - Link pan/zoom across all panels on the respective axis. - figsize : (width, height) - Figure size in CSS pixels. Default ``(640, 480)``. - width_ratios : list of float, optional - Relative column widths. Equivalent to - ``gridspec_kw={"width_ratios": ...}``. - height_ratios : list of float, optional - Relative row heights. Equivalent to - ``gridspec_kw={"height_ratios": ...}``. - gridspec_kw : dict, optional - Extra keyword arguments forwarded to :class:`GridSpec`. - Recognised keys: ``width_ratios``, ``height_ratios``. - display_stats : bool, optional - Show per-panel FPS / frame-time overlay. Default False. - help : str, optional - Help text shown when the user clicks the **?** badge on the figure. - Newlines (``\\n``) create separate lines in the card. The badge is - hidden when *help* is empty (default). Suppressed globally when - ``apl.show_help = False``. - - Returns - ------- - fig : Figure - axs : Axes or numpy array of Axes - - Single cell → scalar ``Axes``. - - Single row → 1-D array of shape ``(ncols,)``. - - Single column → 1-D array of shape ``(nrows,)``. - - Otherwise → 2-D array of shape ``(nrows, ncols)``. - - Examples - -------- - >>> import anyplotlib as vw - >>> import numpy as np - >>> fig, axs = vw.subplots(2, 1, figsize=(640, 600)) - >>> v2d = axs[0].imshow(np.random.rand(128, 128)) - >>> v1d = axs[1].plot(np.sin(np.linspace(0, 6.28, 256))) - >>> fig - """ - # Merge gridspec_kw into width_ratios / height_ratios (matplotlib compat) - if gridspec_kw: - width_ratios = gridspec_kw.get("width_ratios", width_ratios) - height_ratios = gridspec_kw.get("height_ratios", height_ratios) - - fig = Figure( - nrows=nrows, ncols=ncols, figsize=figsize, - width_ratios=width_ratios, height_ratios=height_ratios, - sharex=sharex, sharey=sharey, - display_stats=display_stats, - help=help, - ) - # Build the GridSpec from the Figure's own stored ratios so there is - # exactly one source of truth. - gs = GridSpec( - nrows, ncols, - width_ratios=fig._width_ratios, - height_ratios=fig._height_ratios, - ) - axes_grid = np.empty((nrows, ncols), dtype=object) - for r in range(nrows): - for c in range(ncols): - axes_grid[r, c] = fig.add_subplot(gs[r, c]) - - if nrows == 1 and ncols == 1: - return fig, axes_grid[0, 0] - if nrows == 1: - return fig, axes_grid[0, :] - if ncols == 1: - return fig, axes_grid[:, 0] - return fig, axes_grid - - diff --git a/anyplotlib/figure/_gridspec.py b/anyplotlib/figure/_gridspec.py new file mode 100644 index 00000000..4994d433 --- /dev/null +++ b/anyplotlib/figure/_gridspec.py @@ -0,0 +1,101 @@ +""" +figure/_gridspec.py +=================== +Grid layout descriptors: GridSpec and SubplotSpec. +""" + +from __future__ import annotations + +from typing import Union + + +class SubplotSpec: + """Describes which grid cells a subplot occupies.""" + + def __init__(self, gs: "GridSpec", row_start: int, row_stop: int, + col_start: int, col_stop: int): + self._gs = gs + self.row_start = row_start + self.row_stop = row_stop + self.col_start = col_start + self.col_stop = col_stop + + def __repr__(self) -> str: + return (f"SubplotSpec(rows={self.row_start}:{self.row_stop}, " + f"cols={self.col_start}:{self.col_stop})") + + +class GridSpec: + """Define a grid of subplot cells. + + Parameters + ---------- + nrows, ncols : int + Grid dimensions. + width_ratios : list of float, optional + Relative column widths (length ncols). Defaults to equal widths. + height_ratios : list of float, optional + Relative row heights (length nrows). Defaults to equal heights. + + Examples + -------- + >>> gs = GridSpec(2, 3, width_ratios=[2, 1, 1]) + >>> spec = gs[0, :] # top row spanning all columns + >>> spec = gs[1, 1:3] # bottom-right 2 columns + """ + + def __init__(self, nrows: int, ncols: int, *, + width_ratios: list | None = None, + height_ratios: list | None = None): + self.nrows = nrows + self.ncols = ncols + self.width_ratios = list(width_ratios) if width_ratios else [1] * ncols + self.height_ratios = list(height_ratios) if height_ratios else [1] * nrows + if len(self.width_ratios) != ncols: + raise ValueError("len(width_ratios) must equal ncols") + if len(self.height_ratios) != nrows: + raise ValueError("len(height_ratios) must equal nrows") + + def __getitem__(self, key) -> SubplotSpec: + """Return a SubplotSpec for the given (row, col) index or slice pair. + + Matches matplotlib's GridSpec indexing exactly: + - Integer index ``i`` selects a single row or column (negative indices + count from the end, just like Python lists). + - Slice ``start:stop`` selects rows/columns ``start`` up to (but not + including) ``stop``. The full-span slice ``:`` selects all rows or + columns. + - Step values other than 1 (or None) raise ``ValueError``. + - Out-of-range or empty slices raise ``IndexError``. + """ + if not isinstance(key, tuple) or len(key) != 2: + raise IndexError("GridSpec requires a (row, col) index or slice pair") + row_idx, col_idx = key + + def _resolve(idx, n, axis_name): + if isinstance(idx, int): + i = idx if idx >= 0 else n + idx + if not (0 <= i < n): + raise IndexError( + f"{axis_name} index {idx} is out of bounds for size {n}" + ) + return i, i + 1 + if isinstance(idx, slice): + start, stop, step = idx.indices(n) + if step != 1: + raise ValueError( + f"GridSpec slices must have step 1 (got {step})" + ) + if start >= stop: + raise IndexError( + f"GridSpec slice {idx} produces an empty span on axis of size {n}" + ) + return start, stop + raise IndexError(f"Invalid GridSpec index: {idx!r}") + + r0, r1 = _resolve(row_idx, self.nrows, "row") + c0, c1 = _resolve(col_idx, self.ncols, "col") + return SubplotSpec(self, r0, r1, c0, c1) + + def __repr__(self) -> str: + return f"GridSpec({self.nrows}, {self.ncols})" diff --git a/anyplotlib/figure/_subplots.py b/anyplotlib/figure/_subplots.py new file mode 100644 index 00000000..9a8ee42b --- /dev/null +++ b/anyplotlib/figure/_subplots.py @@ -0,0 +1,101 @@ +""" +figure/_subplots.py +=================== +Factory function mirroring matplotlib.pyplot.subplots. +""" + +from __future__ import annotations + +import numpy as np + +from anyplotlib.figure._figure import Figure +from anyplotlib.figure._gridspec import GridSpec +from anyplotlib.axes import Axes + + +def subplots(nrows=1, ncols=1, *, + sharex=False, sharey=False, + figsize=(640, 480), + width_ratios=None, + height_ratios=None, + gridspec_kw=None, + display_stats=False, + help=""): + """Create a :class:`Figure` and a grid of :class:`~anyplotlib.figure_plots.Axes`. + + Mirrors :func:`matplotlib.pyplot.subplots`. + + Parameters + ---------- + nrows, ncols : int + Number of rows and columns in the grid. + sharex, sharey : bool + Link pan/zoom across all panels on the respective axis. + figsize : (width, height) + Figure size in CSS pixels. Default ``(640, 480)``. + width_ratios : list of float, optional + Relative column widths. Equivalent to + ``gridspec_kw={"width_ratios": ...}``. + height_ratios : list of float, optional + Relative row heights. Equivalent to + ``gridspec_kw={"height_ratios": ...}``. + gridspec_kw : dict, optional + Extra keyword arguments forwarded to :class:`GridSpec`. + Recognised keys: ``width_ratios``, ``height_ratios``. + display_stats : bool, optional + Show per-panel FPS / frame-time overlay. Default False. + help : str, optional + Help text shown when the user clicks the **?** badge on the figure. + Newlines (``\\n``) create separate lines in the card. The badge is + hidden when *help* is empty (default). Suppressed globally when + ``apl.show_help = False``. + + Returns + ------- + fig : Figure + axs : Axes or numpy array of Axes + - Single cell → scalar ``Axes``. + - Single row → 1-D array of shape ``(ncols,)``. + - Single column → 1-D array of shape ``(nrows,)``. + - Otherwise → 2-D array of shape ``(nrows, ncols)``. + + Examples + -------- + >>> import anyplotlib as vw + >>> import numpy as np + >>> fig, axs = vw.subplots(2, 1, figsize=(640, 600)) + >>> v2d = axs[0].imshow(np.random.rand(128, 128)) + >>> v1d = axs[1].plot(np.sin(np.linspace(0, 6.28, 256))) + >>> fig + """ + # Merge gridspec_kw into width_ratios / height_ratios (matplotlib compat) + if gridspec_kw: + width_ratios = gridspec_kw.get("width_ratios", width_ratios) + height_ratios = gridspec_kw.get("height_ratios", height_ratios) + + fig = Figure( + nrows=nrows, ncols=ncols, figsize=figsize, + width_ratios=width_ratios, height_ratios=height_ratios, + sharex=sharex, sharey=sharey, + display_stats=display_stats, + help=help, + ) + # Build the GridSpec from the Figure's own stored ratios so there is + # exactly one source of truth. + gs = GridSpec( + nrows, ncols, + width_ratios=fig._width_ratios, + height_ratios=fig._height_ratios, + ) + axes_grid = np.empty((nrows, ncols), dtype=object) + for r in range(nrows): + for c in range(ncols): + axes_grid[r, c] = fig.add_subplot(gs[r, c]) + + if nrows == 1 and ncols == 1: + return fig, axes_grid[0, 0] + if nrows == 1: + return fig, axes_grid[0, :] + if ncols == 1: + return fig, axes_grid[:, 0] + return fig, axes_grid diff --git a/anyplotlib/figure_plots.py b/anyplotlib/figure_plots.py index 498bc3b8..4b60de80 100644 --- a/anyplotlib/figure_plots.py +++ b/anyplotlib/figure_plots.py @@ -1,128 +1,22 @@ """ figure_plots.py (compatibility shim) ===================================== -This module re-exports classes that have been moved to dedicated subpackages. -Import directly from those subpackages for clarity: +All classes have been moved to dedicated subpackages. Import from those directly: + from anyplotlib.figure import Figure, GridSpec, SubplotSpec, subplots + from anyplotlib.axes import Axes, InsetAxes + from anyplotlib.plot1d import Plot1D, Line1D, PlotBar from anyplotlib.plot2d import Plot2D, PlotMesh from anyplotlib.plot3d import Plot3D - from anyplotlib.plot1d import Plot1D, PlotBar - from anyplotlib.axes import Axes, InsetAxes - from anyplotlib.figure import Figure, GridSpec, SubplotSpec """ -from __future__ import annotations - -# Backward compatibility: helpers now live in _utils -from anyplotlib._utils import ( # noqa: F401 +from anyplotlib.figure._gridspec import GridSpec, SubplotSpec # noqa: F401 +from anyplotlib.axes import Axes, InsetAxes # noqa: F401 +from anyplotlib.plot1d import Line1D, Plot1D, PlotBar # noqa: F401 +from anyplotlib.plot2d import Plot2D, PlotMesh # noqa: F401 +from anyplotlib.plot3d import Plot3D # noqa: F401 +from anyplotlib._utils import ( # noqa: F401 _arr_to_b64, _norm_linestyle, _normalize_image, _build_colormap_lut, _resample_mesh, _LINESTYLE_ALIASES, _CMAP_ALIASES, ) -from anyplotlib.plot3d import Plot3D # noqa: F401 -from anyplotlib.plot2d import Plot2D, PlotMesh # noqa: F401 -from anyplotlib.plot1d import Line1D, Plot1D, PlotBar # noqa: F401 -from anyplotlib.axes import Axes, InsetAxes # noqa: F401 -from anyplotlib.axes._inset_axes import _plot_kind # noqa: F401 - -__all__ = ["GridSpec", "SubplotSpec", "Axes", "InsetAxes", "Line1D", "Plot1D", "Plot2D", - "PlotMesh", "Plot3D", "PlotBar", "_resample_mesh", "_norm_linestyle"] - - -# --------------------------------------------------------------------------- -# GridSpec / SubplotSpec -# --------------------------------------------------------------------------- - -class SubplotSpec: - """Describes which grid cells a subplot occupies.""" - - def __init__(self, gs: "GridSpec", row_start: int, row_stop: int, - col_start: int, col_stop: int): - self._gs = gs - self.row_start = row_start - self.row_stop = row_stop - self.col_start = col_start - self.col_stop = col_stop - - def __repr__(self) -> str: - return (f"SubplotSpec(rows={self.row_start}:{self.row_stop}, " - f"cols={self.col_start}:{self.col_stop})") - - -class GridSpec: - """Define a grid of subplot cells. - - Parameters - ---------- - nrows, ncols : int - Grid dimensions. - width_ratios : list of float, optional - Relative column widths (length ncols). Defaults to equal widths. - height_ratios : list of float, optional - Relative row heights (length nrows). Defaults to equal heights. - - Examples - -------- - >>> gs = GridSpec(2, 3, width_ratios=[2, 1, 1]) - >>> spec = gs[0, :] # top row spanning all columns - >>> spec = gs[1, 1:3] # bottom-right 2 columns - """ - - def __init__(self, nrows: int, ncols: int, *, - width_ratios: list | None = None, - height_ratios: list | None = None): - self.nrows = nrows - self.ncols = ncols - self.width_ratios = list(width_ratios) if width_ratios else [1] * ncols - self.height_ratios = list(height_ratios) if height_ratios else [1] * nrows - if len(self.width_ratios) != ncols: - raise ValueError("len(width_ratios) must equal ncols") - if len(self.height_ratios) != nrows: - raise ValueError("len(height_ratios) must equal nrows") - - def __getitem__(self, key) -> SubplotSpec: - """Return a SubplotSpec for the given (row, col) index or slice pair. - - Matches matplotlib's GridSpec indexing exactly: - - Integer index ``i`` selects a single row or column (negative indices - count from the end, just like Python lists). - - Slice ``start:stop`` selects rows/columns ``start`` up to (but not - including) ``stop``. The full-span slice ``:`` selects all rows or - columns. - - Step values other than 1 (or None) raise ``ValueError``. - - Out-of-range or empty slices raise ``IndexError``. - """ - if not isinstance(key, tuple) or len(key) != 2: - raise IndexError("GridSpec requires a (row, col) index or slice pair") - row_idx, col_idx = key - - def _resolve(idx, n, axis_name): - if isinstance(idx, int): - i = idx if idx >= 0 else n + idx - if not (0 <= i < n): - raise IndexError( - f"{axis_name} index {idx} is out of bounds for size {n}" - ) - return i, i + 1 - if isinstance(idx, slice): - start, stop, step = idx.indices(n) - if step != 1: - raise ValueError( - f"GridSpec slices must have step 1 (got {step})" - ) - if start >= stop: - raise IndexError( - f"GridSpec slice {idx} produces an empty span on axis of size {n}" - ) - return start, stop - raise IndexError(f"Invalid GridSpec index: {idx!r}") - - r0, r1 = _resolve(row_idx, self.nrows, "row") - c0, c1 = _resolve(col_idx, self.ncols, "col") - return SubplotSpec(self, r0, r1, c0, c1) - - def __repr__(self) -> str: - return f"GridSpec({self.nrows}, {self.ncols})" - - - From 7bfc233f3b7a67f4cbb93db6f7364ec469451564 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 11 May 2026 10:56:26 -0500 Subject: [PATCH 110/198] fix: remove dead imports from figure/ modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove unused GridSpec from _figure.py, Union from _gridspec.py, and Axes from _subplots.py; also fix stale docstring cross-reference in _subplots.py (figure_plots.Axes → axes.Axes). --- anyplotlib/figure/_figure.py | 2 +- anyplotlib/figure/_gridspec.py | 2 -- anyplotlib/figure/_subplots.py | 3 +-- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/anyplotlib/figure/_figure.py b/anyplotlib/figure/_figure.py index d9d356d0..167ec4c3 100644 --- a/anyplotlib/figure/_figure.py +++ b/anyplotlib/figure/_figure.py @@ -14,7 +14,7 @@ from anyplotlib.axes import Axes, InsetAxes from anyplotlib.axes._inset_axes import _plot_kind -from anyplotlib.figure._gridspec import GridSpec, SubplotSpec +from anyplotlib.figure._gridspec import SubplotSpec from anyplotlib.callbacks import Event from anyplotlib._repr_utils import repr_html_iframe diff --git a/anyplotlib/figure/_gridspec.py b/anyplotlib/figure/_gridspec.py index 4994d433..d5e028a3 100644 --- a/anyplotlib/figure/_gridspec.py +++ b/anyplotlib/figure/_gridspec.py @@ -6,8 +6,6 @@ from __future__ import annotations -from typing import Union - class SubplotSpec: """Describes which grid cells a subplot occupies.""" diff --git a/anyplotlib/figure/_subplots.py b/anyplotlib/figure/_subplots.py index 9a8ee42b..d3d3c7e9 100644 --- a/anyplotlib/figure/_subplots.py +++ b/anyplotlib/figure/_subplots.py @@ -10,7 +10,6 @@ from anyplotlib.figure._figure import Figure from anyplotlib.figure._gridspec import GridSpec -from anyplotlib.axes import Axes def subplots(nrows=1, ncols=1, *, @@ -21,7 +20,7 @@ def subplots(nrows=1, ncols=1, *, gridspec_kw=None, display_stats=False, help=""): - """Create a :class:`Figure` and a grid of :class:`~anyplotlib.figure_plots.Axes`. + """Create a :class:`Figure` and a grid of :class:`~anyplotlib.axes.Axes`. Mirrors :func:`matplotlib.pyplot.subplots`. From 1e2448fdb883c73a24d4b247cbcad7b81e169a6e Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 11 May 2026 11:06:40 -0500 Subject: [PATCH 111/198] refactor: update __init__.py to import from new subpackages and remove figure_plots.py shim Update top-level __init__.py to import directly from axes, plot1d, plot2d, and plot3d subpackages instead of the figure_plots compat shim. Delete figure_plots.py and fix all test files that still imported from it. --- anyplotlib/__init__.py | 5 ++++- anyplotlib/figure_plots.py | 22 ------------------- .../tests/test_interactive/test_callbacks.py | 4 +++- .../tests/test_layouts/test_gridspec.py | 3 ++- anyplotlib/tests/test_layouts/test_inset.py | 2 +- anyplotlib/tests/test_plot1d/test_plot1d.py | 4 +++- anyplotlib/tests/test_plot1d/test_plotbar.py | 2 +- anyplotlib/tests/test_plot2d/test_imshow.py | 2 +- .../tests/test_plot2d/test_pcolormesh.py | 2 +- .../tests/test_plot2d/test_plot2d_api.py | 6 +++-- anyplotlib/tests/test_plot3d/test_plot3d.py | 2 +- 11 files changed, 21 insertions(+), 33 deletions(-) delete mode 100644 anyplotlib/figure_plots.py diff --git a/anyplotlib/__init__.py b/anyplotlib/__init__.py index 220ea598..e215e53c 100644 --- a/anyplotlib/__init__.py +++ b/anyplotlib/__init__.py @@ -1,5 +1,8 @@ from anyplotlib.figure import Figure, GridSpec, SubplotSpec, subplots -from anyplotlib.figure_plots import Axes, InsetAxes, Plot1D, Plot2D, PlotMesh, Plot3D, PlotBar +from anyplotlib.axes import Axes, InsetAxes +from anyplotlib.plot1d import Plot1D, PlotBar +from anyplotlib.plot2d import Plot2D, PlotMesh +from anyplotlib.plot3d import Plot3D from anyplotlib.callbacks import CallbackRegistry, Event from anyplotlib.widgets import ( Widget, RectangleWidget, CircleWidget, AnnularWidget, diff --git a/anyplotlib/figure_plots.py b/anyplotlib/figure_plots.py deleted file mode 100644 index 4b60de80..00000000 --- a/anyplotlib/figure_plots.py +++ /dev/null @@ -1,22 +0,0 @@ -""" -figure_plots.py (compatibility shim) -===================================== -All classes have been moved to dedicated subpackages. Import from those directly: - - from anyplotlib.figure import Figure, GridSpec, SubplotSpec, subplots - from anyplotlib.axes import Axes, InsetAxes - from anyplotlib.plot1d import Plot1D, Line1D, PlotBar - from anyplotlib.plot2d import Plot2D, PlotMesh - from anyplotlib.plot3d import Plot3D -""" - -from anyplotlib.figure._gridspec import GridSpec, SubplotSpec # noqa: F401 -from anyplotlib.axes import Axes, InsetAxes # noqa: F401 -from anyplotlib.plot1d import Line1D, Plot1D, PlotBar # noqa: F401 -from anyplotlib.plot2d import Plot2D, PlotMesh # noqa: F401 -from anyplotlib.plot3d import Plot3D # noqa: F401 -from anyplotlib._utils import ( # noqa: F401 - _arr_to_b64, _norm_linestyle, _normalize_image, - _build_colormap_lut, _resample_mesh, - _LINESTYLE_ALIASES, _CMAP_ALIASES, -) diff --git a/anyplotlib/tests/test_interactive/test_callbacks.py b/anyplotlib/tests/test_interactive/test_callbacks.py index dde8d607..b38fae34 100644 --- a/anyplotlib/tests/test_interactive/test_callbacks.py +++ b/anyplotlib/tests/test_interactive/test_callbacks.py @@ -23,7 +23,9 @@ import anyplotlib as apl from anyplotlib.callbacks import CallbackRegistry, Event -from anyplotlib.figure_plots import Plot1D, Plot2D, PlotMesh, Plot3D +from anyplotlib.plot1d import Plot1D +from anyplotlib.plot2d import Plot2D, PlotMesh +from anyplotlib.plot3d import Plot3D # ───────────────────────────────────────────────────────────────────────────── diff --git a/anyplotlib/tests/test_layouts/test_gridspec.py b/anyplotlib/tests/test_layouts/test_gridspec.py index d6b18cf4..3d9df833 100644 --- a/anyplotlib/tests/test_layouts/test_gridspec.py +++ b/anyplotlib/tests/test_layouts/test_gridspec.py @@ -35,7 +35,8 @@ import anyplotlib as vw from anyplotlib.figure import Figure -from anyplotlib.figure_plots import GridSpec, SubplotSpec, Axes # noqa: F401 +from anyplotlib.figure import GridSpec, SubplotSpec +from anyplotlib.axes import Axes # noqa: F401 # PAD constants must match figure_esm.js (used in panel-alignment tests) PAD_L, PAD_R, PAD_T, PAD_B = 58, 12, 12, 42 diff --git a/anyplotlib/tests/test_layouts/test_inset.py b/anyplotlib/tests/test_layouts/test_inset.py index 261c7372..1f4d1e0c 100644 --- a/anyplotlib/tests/test_layouts/test_inset.py +++ b/anyplotlib/tests/test_layouts/test_inset.py @@ -38,7 +38,7 @@ import numpy as np import pytest import anyplotlib as apl -from anyplotlib.figure_plots import InsetAxes +from anyplotlib.axes import InsetAxes # ── helpers (unit tests) ────────────────────────────────────────────────────── diff --git a/anyplotlib/tests/test_plot1d/test_plot1d.py b/anyplotlib/tests/test_plot1d/test_plot1d.py index a35bb4ae..abe21e32 100644 --- a/anyplotlib/tests/test_plot1d/test_plot1d.py +++ b/anyplotlib/tests/test_plot1d/test_plot1d.py @@ -27,7 +27,9 @@ import pytest import anyplotlib as apl -from anyplotlib.figure_plots import _norm_linestyle, Line1D, Plot1D +from anyplotlib._utils import _norm_linestyle +from anyplotlib.plot1d import Plot1D +from anyplotlib.plot1d._plot1d import Line1D # --------------------------------------------------------------------------- diff --git a/anyplotlib/tests/test_plot1d/test_plotbar.py b/anyplotlib/tests/test_plot1d/test_plotbar.py index 9a211336..3f5dbe86 100644 --- a/anyplotlib/tests/test_plot1d/test_plotbar.py +++ b/anyplotlib/tests/test_plot1d/test_plotbar.py @@ -33,7 +33,7 @@ import anyplotlib as apl from anyplotlib.callbacks import CallbackRegistry, Event -from anyplotlib.figure_plots import PlotBar +from anyplotlib.plot1d import PlotBar # --------------------------------------------------------------------------- diff --git a/anyplotlib/tests/test_plot2d/test_imshow.py b/anyplotlib/tests/test_plot2d/test_imshow.py index e4acaa76..91c575e3 100644 --- a/anyplotlib/tests/test_plot2d/test_imshow.py +++ b/anyplotlib/tests/test_plot2d/test_imshow.py @@ -24,7 +24,7 @@ import pytest import anyplotlib as apl -from anyplotlib.figure_plots import Plot2D +from anyplotlib.plot2d import Plot2D # --------------------------------------------------------------------------- diff --git a/anyplotlib/tests/test_plot2d/test_pcolormesh.py b/anyplotlib/tests/test_plot2d/test_pcolormesh.py index c958c56b..d62dee83 100644 --- a/anyplotlib/tests/test_plot2d/test_pcolormesh.py +++ b/anyplotlib/tests/test_plot2d/test_pcolormesh.py @@ -19,7 +19,7 @@ import pytest import anyplotlib as apl -from anyplotlib.figure_plots import PlotMesh +from anyplotlib.plot2d import PlotMesh # --------------------------------------------------------------------------- diff --git a/anyplotlib/tests/test_plot2d/test_plot2d_api.py b/anyplotlib/tests/test_plot2d/test_plot2d_api.py index 22b1eefe..2203c621 100644 --- a/anyplotlib/tests/test_plot2d/test_plot2d_api.py +++ b/anyplotlib/tests/test_plot2d/test_plot2d_api.py @@ -14,7 +14,9 @@ import numpy as np import pytest import anyplotlib as apl -from anyplotlib.figure_plots import Plot1D, Plot2D, Plot3D, PlotBar +from anyplotlib.plot1d import Plot1D, PlotBar +from anyplotlib.plot2d import Plot2D +from anyplotlib.plot3d import Plot3D from anyplotlib.callbacks import CallbackRegistry, Event # --------------------------------------------------------------------------- # Helpers @@ -73,7 +75,7 @@ def test_plot1d_add_circles_still_uses_points(): # Colormap alias # =========================================================================== def test_cividis_alias_resolves(): - from anyplotlib.figure_plots import _build_colormap_lut, _CMAP_ALIASES + from anyplotlib._utils import _build_colormap_lut, _CMAP_ALIASES alias = _CMAP_ALIASES.get("cividis", "cividis") assert alias != "dimgray" import colorcet as cc diff --git a/anyplotlib/tests/test_plot3d/test_plot3d.py b/anyplotlib/tests/test_plot3d/test_plot3d.py index c1137926..2316ecc0 100644 --- a/anyplotlib/tests/test_plot3d/test_plot3d.py +++ b/anyplotlib/tests/test_plot3d/test_plot3d.py @@ -22,7 +22,7 @@ import pytest import anyplotlib as apl -from anyplotlib.figure_plots import Plot3D +from anyplotlib.plot3d import Plot3D # --------------------------------------------------------------------------- From a50374e12cdb74ea6904032901e79d47821d8c9c Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 11 May 2026 11:25:47 -0500 Subject: [PATCH 112/198] fix: update stale figure_plots import in test_benchmarks_py.py --- anyplotlib/tests/test_benchmarks/test_benchmarks_py.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/anyplotlib/tests/test_benchmarks/test_benchmarks_py.py b/anyplotlib/tests/test_benchmarks/test_benchmarks_py.py index 320ccd05..f813a433 100644 --- a/anyplotlib/tests/test_benchmarks/test_benchmarks_py.py +++ b/anyplotlib/tests/test_benchmarks/test_benchmarks_py.py @@ -46,7 +46,8 @@ import pytest import anyplotlib as apl -from anyplotlib.figure_plots import _normalize_image, Plot2D +from anyplotlib._utils import _normalize_image +from anyplotlib.plot2d import Plot2D BASELINES_PATH = pathlib.Path(__file__).parent / "benchmarks" / "baselines.json" From 941111419ca7ca7cdb32936a3c880ee74306ecca Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 11 May 2026 11:48:32 -0500 Subject: [PATCH 113/198] refactor: update stale figure_plots docstring cross-references in Examples Replace all :meth:`~anyplotlib.figure_plots.Plot1D.*` and :meth:`~anyplotlib.figure_plots.Plot2D.*` Sphinx cross-reference strings in the Examples directory with the correct new module paths (anyplotlib.plot1d.Plot1D and anyplotlib.plot2d.Plot2D) following the project reorganization that deleted figure_plots.py. --- Examples/Interactive/plot_interactive_fft.py | 6 +++--- Examples/Interactive/plot_point_widget.py | 2 +- Examples/Markers/plot_arrows.py | 2 +- Examples/Markers/plot_circles.py | 2 +- Examples/Markers/plot_ellipses.py | 2 +- Examples/Markers/plot_horizontal_lines.py | 2 +- Examples/Markers/plot_line_segments.py | 2 +- Examples/Markers/plot_points.py | 2 +- Examples/Markers/plot_polygons.py | 2 +- Examples/Markers/plot_rectangles.py | 2 +- Examples/Markers/plot_squares.py | 2 +- Examples/Markers/plot_texts.py | 2 +- Examples/Markers/plot_vertical_lines.py | 2 +- Examples/Widgets/plot_widget1d_hline.py | 2 +- Examples/Widgets/plot_widget1d_range.py | 2 +- Examples/Widgets/plot_widget1d_vline.py | 2 +- Examples/Widgets/plot_widget2d_circle.py | 2 +- Examples/Widgets/plot_widget2d_rectangle.py | 2 +- 18 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Examples/Interactive/plot_interactive_fft.py b/Examples/Interactive/plot_interactive_fft.py index f274cc47..5d9f464a 100644 --- a/Examples/Interactive/plot_interactive_fft.py +++ b/Examples/Interactive/plot_interactive_fft.py @@ -10,12 +10,12 @@ * The left panel shows a synthetic real-space image (a periodic lattice with noise, similar to an atomic-resolution STEM image). * A yellow rectangle widget marks the region-of-interest (ROI). -* Whenever the ROI is moved or resized the :meth:`~anyplotlib.figure_plots.Plot2D.on_release` +* Whenever the ROI is moved or resized the :meth:`~anyplotlib.plot2d.Plot2D.on_release` callback re-computes ``numpy.fft.fft2`` on the cropped pixels, applies a Hann window to reduce edge ringing, takes the log-magnitude, and pushes the result into the right panel with - :meth:`~anyplotlib.figure_plots.Plot2D.update`. -* A second :meth:`~anyplotlib.figure_plots.Plot2D.on_change` callback updates + :meth:`~anyplotlib.plot2d.Plot2D.update`. +* A second :meth:`~anyplotlib.plot2d.Plot2D.on_change` callback updates a lightweight text readout (ROI size in pixels) on every drag frame without re-running the FFT. diff --git a/Examples/Interactive/plot_point_widget.py b/Examples/Interactive/plot_point_widget.py index c614b90c..90823230 100644 --- a/Examples/Interactive/plot_point_widget.py +++ b/Examples/Interactive/plot_point_widget.py @@ -24,7 +24,7 @@ * **Derivative** (central finite difference): ``dy/dx ≈ [f(xq+h) − f(xq−h)] / 2h`` * **Tangent line**: ``y_tan(x) = yq + slope · (x − xq)`` -The tangent line is added with :meth:`~anyplotlib.figure_plots.Plot1D.add_line` +The tangent line is added with :meth:`~anyplotlib.plot1d.Plot1D.add_line` and the previous one is removed, so only one tangent is shown at a time. .. note:: diff --git a/Examples/Markers/plot_arrows.py b/Examples/Markers/plot_arrows.py index 44369458..838a0873 100644 --- a/Examples/Markers/plot_arrows.py +++ b/Examples/Markers/plot_arrows.py @@ -3,7 +3,7 @@ ====== Draw vector arrows on a 2-D image with -:meth:`~anyplotlib.figure_plots.Plot2D.add_arrows`. +:meth:`~anyplotlib.plot2d.Plot2D.add_arrows`. Use ``markers["arrows"]["name"].set(...)`` to update them live. """ import numpy as np diff --git a/Examples/Markers/plot_circles.py b/Examples/Markers/plot_circles.py index a7d3325d..b2564669 100644 --- a/Examples/Markers/plot_circles.py +++ b/Examples/Markers/plot_circles.py @@ -3,7 +3,7 @@ ======= Mark circular features on a 2-D image with -:meth:`~anyplotlib.figure_plots.Plot2D.add_circles`. +:meth:`~anyplotlib.plot2d.Plot2D.add_circles`. Use ``markers["circles"]["name"].set(...)`` to update them live. """ import numpy as np diff --git a/Examples/Markers/plot_ellipses.py b/Examples/Markers/plot_ellipses.py index 44d248af..c87baf3e 100644 --- a/Examples/Markers/plot_ellipses.py +++ b/Examples/Markers/plot_ellipses.py @@ -3,7 +3,7 @@ ======== Draw ellipses on a 2-D image with -:meth:`~anyplotlib.figure_plots.Plot2D.add_ellipses`. +:meth:`~anyplotlib.plot2d.Plot2D.add_ellipses`. Use ``markers["ellipses"]["name"].set(...)`` to update them live. """ import numpy as np diff --git a/Examples/Markers/plot_horizontal_lines.py b/Examples/Markers/plot_horizontal_lines.py index 42119015..0a2b1955 100644 --- a/Examples/Markers/plot_horizontal_lines.py +++ b/Examples/Markers/plot_horizontal_lines.py @@ -3,7 +3,7 @@ ================ Draw static horizontal threshold lines on a 1-D plot with -:meth:`~anyplotlib.figure_plots.Plot1D.add_hlines`. +:meth:`~anyplotlib.plot1d.Plot1D.add_hlines`. Use ``markers["hlines"]["name"].set(...)`` to update them live. """ import numpy as np diff --git a/Examples/Markers/plot_line_segments.py b/Examples/Markers/plot_line_segments.py index 88dc0720..c0bbbb5e 100644 --- a/Examples/Markers/plot_line_segments.py +++ b/Examples/Markers/plot_line_segments.py @@ -3,7 +3,7 @@ ============= Draw line segments on a 2-D image with -:meth:`~anyplotlib.figure_plots.Plot2D.add_lines`. +:meth:`~anyplotlib.plot2d.Plot2D.add_lines`. Use ``markers["lines"]["name"].set(...)`` to update them live. """ import numpy as np diff --git a/Examples/Markers/plot_points.py b/Examples/Markers/plot_points.py index 76da84dd..9b106239 100644 --- a/Examples/Markers/plot_points.py +++ b/Examples/Markers/plot_points.py @@ -3,7 +3,7 @@ ====== Mark specific (x, y) positions on a 1-D plot with -:meth:`~anyplotlib.figure_plots.Plot1D.add_points`. +:meth:`~anyplotlib.plot1d.Plot1D.add_points`. Use ``markers["points"]["name"].set(...)`` to update them live. """ import numpy as np diff --git a/Examples/Markers/plot_polygons.py b/Examples/Markers/plot_polygons.py index 49540817..240345d1 100644 --- a/Examples/Markers/plot_polygons.py +++ b/Examples/Markers/plot_polygons.py @@ -3,7 +3,7 @@ ======== Draw closed polygons on a 2-D image with -:meth:`~anyplotlib.figure_plots.Plot2D.add_polygons`. +:meth:`~anyplotlib.plot2d.Plot2D.add_polygons`. Use ``markers["polygons"]["name"].set(...)`` to update them live. """ import numpy as np diff --git a/Examples/Markers/plot_rectangles.py b/Examples/Markers/plot_rectangles.py index 3124acd2..7db429d0 100644 --- a/Examples/Markers/plot_rectangles.py +++ b/Examples/Markers/plot_rectangles.py @@ -3,7 +3,7 @@ ========== Draw bounding boxes on a 2-D image with -:meth:`~anyplotlib.figure_plots.Plot2D.add_rectangles`. +:meth:`~anyplotlib.plot2d.Plot2D.add_rectangles`. Use ``markers["rectangles"]["name"].set(...)`` to update them live. """ import numpy as np diff --git a/Examples/Markers/plot_squares.py b/Examples/Markers/plot_squares.py index 1b3146b3..42f70727 100644 --- a/Examples/Markers/plot_squares.py +++ b/Examples/Markers/plot_squares.py @@ -3,7 +3,7 @@ ======= Draw squares on a 2-D image with -:meth:`~anyplotlib.figure_plots.Plot2D.add_squares`. +:meth:`~anyplotlib.plot2d.Plot2D.add_squares`. Use ``markers["squares"]["name"].set(...)`` to update them live. """ import numpy as np diff --git a/Examples/Markers/plot_texts.py b/Examples/Markers/plot_texts.py index 5922eee8..2bafa3e3 100644 --- a/Examples/Markers/plot_texts.py +++ b/Examples/Markers/plot_texts.py @@ -3,7 +3,7 @@ =========== Place text annotations on a 2-D image with -:meth:`~anyplotlib.figure_plots.Plot2D.add_texts`. +:meth:`~anyplotlib.plot2d.Plot2D.add_texts`. Use ``markers["texts"]["name"].set(...)`` to update them live. """ import numpy as np diff --git a/Examples/Markers/plot_vertical_lines.py b/Examples/Markers/plot_vertical_lines.py index be320544..b6890582 100644 --- a/Examples/Markers/plot_vertical_lines.py +++ b/Examples/Markers/plot_vertical_lines.py @@ -3,7 +3,7 @@ ============== Draw static vertical marker lines on a 1-D plot with -:meth:`~anyplotlib.figure_plots.Plot1D.add_vlines`. +:meth:`~anyplotlib.plot1d.Plot1D.add_vlines`. Use ``markers["vlines"]["name"].set(...)`` to update them live. """ import numpy as np diff --git a/Examples/Widgets/plot_widget1d_hline.py b/Examples/Widgets/plot_widget1d_hline.py index f3f143e6..53a33089 100644 --- a/Examples/Widgets/plot_widget1d_hline.py +++ b/Examples/Widgets/plot_widget1d_hline.py @@ -3,7 +3,7 @@ ========================== A draggable horizontal line on a 1-D plot panel. -Add it with :meth:`~anyplotlib.figure_plots.Plot1D.add_hline_widget`. +Add it with :meth:`~anyplotlib.plot1d.Plot1D.add_hline_widget`. Drag the line up or down to change the selected y value. """ import numpy as np diff --git a/Examples/Widgets/plot_widget1d_range.py b/Examples/Widgets/plot_widget1d_range.py index 32e62f6c..d54be218 100644 --- a/Examples/Widgets/plot_widget1d_range.py +++ b/Examples/Widgets/plot_widget1d_range.py @@ -3,7 +3,7 @@ ================ A draggable range selector on a 1-D plot panel with two handles. -Add it with :meth:`~anyplotlib.figure_plots.Plot1D.add_range_widget`. +Add it with :meth:`~anyplotlib.plot1d.Plot1D.add_range_widget`. Drag either handle to resize the selected interval, or drag the band to move it. """ diff --git a/Examples/Widgets/plot_widget1d_vline.py b/Examples/Widgets/plot_widget1d_vline.py index 8041eccc..f4926d03 100644 --- a/Examples/Widgets/plot_widget1d_vline.py +++ b/Examples/Widgets/plot_widget1d_vline.py @@ -3,7 +3,7 @@ ======================== A draggable vertical line on a 1-D plot panel. -Add it with :meth:`~anyplotlib.figure_plots.Plot1D.add_vline_widget`. +Add it with :meth:`~anyplotlib.plot1d.Plot1D.add_vline_widget`. Drag the line left or right to change the selected x position. """ import numpy as np diff --git a/Examples/Widgets/plot_widget2d_circle.py b/Examples/Widgets/plot_widget2d_circle.py index 8aea954a..3b5d7eb6 100644 --- a/Examples/Widgets/plot_widget2d_circle.py +++ b/Examples/Widgets/plot_widget2d_circle.py @@ -3,7 +3,7 @@ ================= A draggable, resizable circle overlay on a 2-D image panel. -Add it with :meth:`~anyplotlib.figure_plots.Plot2D.add_widget` using +Add it with :meth:`~anyplotlib.plot2d.Plot2D.add_widget` using ``kind="circle"``. """ import numpy as np diff --git a/Examples/Widgets/plot_widget2d_rectangle.py b/Examples/Widgets/plot_widget2d_rectangle.py index e5b848c1..4cc4eef6 100644 --- a/Examples/Widgets/plot_widget2d_rectangle.py +++ b/Examples/Widgets/plot_widget2d_rectangle.py @@ -3,7 +3,7 @@ ==================== A draggable, resizable rectangle overlay on a 2-D image panel. -Add it with :meth:`~anyplotlib.figure_plots.Plot2D.add_widget` using +Add it with :meth:`~anyplotlib.plot2d.Plot2D.add_widget` using ``kind="rectangle"``. """ import numpy as np From 2de4f2482cf3dbbbbe9e61fa39b4d05b542b7ade Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Tue, 12 May 2026 11:23:58 -0500 Subject: [PATCH 114/198] refactor: update figure_plots documentation to reflect new module structure --- docs/api/figure_plots.rst | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/docs/api/figure_plots.rst b/docs/api/figure_plots.rst index 2e76ec60..1a9a14d1 100644 --- a/docs/api/figure_plots.rst +++ b/docs/api/figure_plots.rst @@ -2,10 +2,6 @@ Figure Plots ============ -.. automodule:: anyplotlib.figure_plots - :no-members: - :no-undoc-members: - .. currentmodule:: anyplotlib .. rubric:: Axes @@ -37,55 +33,55 @@ Figure Plots .. rubric:: Full Reference -.. autoclass:: anyplotlib.figure_plots.Axes +.. autoclass:: anyplotlib.axes.Axes :members: :show-inheritance: :member-order: bysource :no-index: -.. autoclass:: anyplotlib.figure_plots.InsetAxes +.. autoclass:: anyplotlib.axes.InsetAxes :members: :show-inheritance: :member-order: bysource :no-index: -.. autoclass:: anyplotlib.figure_plots.Plot1D +.. autoclass:: anyplotlib.plot1d.Plot1D :members: :show-inheritance: :member-order: bysource :no-index: -.. autoclass:: anyplotlib.figure_plots.Plot2D +.. autoclass:: anyplotlib.plot2d.Plot2D :members: :show-inheritance: :member-order: bysource :no-index: -.. autoclass:: anyplotlib.figure_plots.PlotMesh +.. autoclass:: anyplotlib.plot2d.PlotMesh :members: :show-inheritance: :member-order: bysource :no-index: -.. autoclass:: anyplotlib.figure_plots.Plot3D +.. autoclass:: anyplotlib.plot3d.Plot3D :members: :show-inheritance: :member-order: bysource :no-index: -.. autoclass:: anyplotlib.figure_plots.PlotBar +.. autoclass:: anyplotlib.plot1d.PlotBar :members: :show-inheritance: :member-order: bysource :no-index: -.. autoclass:: anyplotlib.figure_plots.GridSpec +.. autoclass:: anyplotlib.figure.GridSpec :members: :show-inheritance: :member-order: bysource :no-index: -.. autoclass:: anyplotlib.figure_plots.SubplotSpec +.. autoclass:: anyplotlib.figure.SubplotSpec :members: :show-inheritance: :member-order: bysource From c9bf5939ccfb6ad62de802065b8581911b7b21c3 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Tue, 12 May 2026 13:58:53 -0500 Subject: [PATCH 115/198] refactor: update figure_plots documentation to reflect new module structure --- docs/api/figure_plots.rst | 6 ++--- docs/api/index.rst | 21 +++++---------- docs/dev/index.rst | 40 +++++++++++++++-------------- docs/getting_started.rst | 2 +- pyproject.toml | 20 +++++++++++++++ upcoming_changes/11.maintenance.rst | 4 +-- 6 files changed, 53 insertions(+), 40 deletions(-) diff --git a/docs/api/figure_plots.rst b/docs/api/figure_plots.rst index 1a9a14d1..6e48db68 100644 --- a/docs/api/figure_plots.rst +++ b/docs/api/figure_plots.rst @@ -1,6 +1,6 @@ -============ -Figure Plots -============ +==================== +Axes, Plots & Layout +==================== .. currentmodule:: anyplotlib diff --git a/docs/api/index.rst b/docs/api/index.rst index ecf2a45b..8cdb2ad0 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -2,7 +2,7 @@ API Reference ============= -The anyplotlib public API is organized into five modules below. +The anyplotlib public API is organized into the sections below. Click a card to browse the module page, or use the summary tables to jump directly to a class or function. @@ -34,23 +34,14 @@ directly to a class or function. :link: figure_plots :link-type: doc - :octicon:`graph;2em;sd-text-info` Axes & Plots + :octicon:`graph;2em;sd-text-info` Axes, Plots & Layout ^^^ - :class:`~anyplotlib.Axes` and the five plot classes: - :class:`~anyplotlib.Plot1D`, :class:`~anyplotlib.Plot2D`, + :class:`~anyplotlib.Axes` and the five plot classes + (:class:`~anyplotlib.Plot1D`, :class:`~anyplotlib.Plot2D`, :class:`~anyplotlib.PlotMesh`, :class:`~anyplotlib.Plot3D`, - :class:`~anyplotlib.PlotBar`. - - .. grid-item-card:: - :link: figure_plots - :link-type: doc - - :octicon:`rows;2em;sd-text-info` Layout - ^^^ - - :class:`~anyplotlib.GridSpec` and :class:`~anyplotlib.SubplotSpec` - for building flexible multi-panel figure layouts. + :class:`~anyplotlib.PlotBar`), plus :class:`~anyplotlib.GridSpec` + and :class:`~anyplotlib.SubplotSpec` for multi-panel layouts. .. grid-item-card:: :link: markers diff --git a/docs/dev/index.rst b/docs/dev/index.rst index ebfb987c..300c1188 100644 --- a/docs/dev/index.rst +++ b/docs/dev/index.rst @@ -26,11 +26,7 @@ management. uv sync # Run the full test suite - uv run pytest tests/ - - # Quick smoke tests (no pytest overhead) - uv run python test_figure.py - uv run python test_pcolormesh.py + uv run pytest anyplotlib/tests/ The ``dev`` dependency group (declared in ``pyproject.toml``) pulls in ``pytest``, ``playwright``, ``sphinx``, ``docutils``, and other tools @@ -41,30 +37,36 @@ needed for both tests and docs builds. Architecture Overview ===================== -The library is split into a small number of focused modules. +The library is split into focused subpackages. .. list-table:: :header-rows: 1 :widths: 25 75 - * - File + * - Module - Purpose - * - ``figure.py`` + * - ``anyplotlib/figure/`` - ``Figure`` — the only ``anywidget.AnyWidget`` subclass. Owns all traitlets and is the Python ↔ JS bridge. - * - ``figure_plots.py`` - - ``Plot2D``, ``Plot1D``, ``PlotMesh``, ``Plot3D``, ``Axes``, - ``GridSpec``, ``subplots()``. Plain Python classes — *no* traitlets. + Also contains ``GridSpec``, ``SubplotSpec``, and ``subplots()``. + * - ``anyplotlib/axes/`` + - ``Axes`` and ``InsetAxes``. Plain Python classes — *no* traitlets. + * - ``anyplotlib/plot1d/`` + - ``Plot1D`` and ``PlotBar``. Plain Python classes — *no* traitlets. + * - ``anyplotlib/plot2d/`` + - ``Plot2D`` and ``PlotMesh``. Plain Python classes — *no* traitlets. + * - ``anyplotlib/plot3d/`` + - ``Plot3D``. Plain Python class — *no* traitlets. * - ``figure_esm.js`` - Pure-JS canvas renderer (≈ 4 000 lines). - * - ``markers.py`` + * - ``anyplotlib/markers.py`` - Static visual overlays (circles, arrows, lines, etc.). - * - ``widgets.py`` + * - ``anyplotlib/widgets/`` - Interactive draggable overlays (``RectangleWidget``, ``CrosshairWidget``, etc.). - * - ``callbacks.py`` + * - ``anyplotlib/callbacks.py`` - Multi-tier event system (``on_change`` / ``on_release``). - * - ``sphinx_anywidget/`` + * - ``anyplotlib/sphinx_anywidget/`` - Sphinx extension for interactive docs via Pyodide. **Python → JS flow:** ``plot._push()`` → ``figure._push(panel_id)`` → @@ -79,15 +81,15 @@ Python observer calls ``Widget._update_from_js()`` and fires callbacks. Running & Writing Tests ======================= -Tests live in ``tests/`` +Tests live in ``anyplotlib/tests/`` Run the full suite:: - uv run pytest tests/ + uv run pytest anyplotlib/tests/ Run a specific module:: - uv run pytest tests/test_sphinx_anywidget.py -v + uv run pytest anyplotlib/tests/test_documentation/test_sphinx_anywidget.py -v The Playwright end-to-end tests (``test_pyodide_e2e.py``) require the Playwright browsers. Install them once with:: @@ -231,7 +233,7 @@ separate code block with its own prose cell:: # %% # Adjusting the colour map # ------------------------- - # :meth:`~anyplotlib.figure_plots.Plot2D.set_colormap` switches the palette. + # :meth:`~anyplotlib.Plot2D.set_colormap` switches the palette. v.set_colormap("viridis") fig diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 36cbb859..8b3e1c02 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -134,7 +134,7 @@ All line properties can be changed after creation without recreating the panel:: ``v.add_vlines(x_values)`` / ``v.add_hlines(y_values)`` / … — static marker collections at explicit data coordinates. -See :class:`~anyplotlib.figure_plots.Plot1D` for the full API reference, and +See :class:`~anyplotlib.Plot1D` for the full API reference, and the :doc:`auto_examples/index` gallery (e.g. *1D Line Styles* or *1D Spectra*) for worked examples. diff --git a/pyproject.toml b/pyproject.toml index 8142e4c1..36f34829 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,21 @@ packages = ["anyplotlib"] name = "anyplotlib" version = "0.1.0" description = "A plotting library using python, javascript and anywidget for performant in browser plotting." +readme = "README.md" +license = { text = "MIT" } +authors = [ + { name = "Carter Francis", email = "cartsfrancis@gmail.com" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Scientific/Engineering :: Visualization", +] requires-python = ">=3.10" dependencies = [ "anywidget>=0.9.0", @@ -34,6 +49,11 @@ jupyter = [ "jupyterlab>=4.5.5", ] +[project.urls] +"Homepage" = "https://cssfrancis.github.io/anyplotlib/" +"Repository" = "https://github.com/CSSFrancis/anyplotlib" +"Bug Tracker" = "https://github.com/CSSFrancis/anyplotlib/issues" + [dependency-groups] dev = [ "docutils>=0.19", diff --git a/upcoming_changes/11.maintenance.rst b/upcoming_changes/11.maintenance.rst index 642100c0..b7606d5d 100644 --- a/upcoming_changes/11.maintenance.rst +++ b/upcoming_changes/11.maintenance.rst @@ -1,2 +1,2 @@ -Refactored the testssuite. Moved to a new directory, combined liked -t1ests into single files, added a couple new tests and removed some redundant tests. \ No newline at end of file +Refactored the test suite. Moved to a new directory, combined like +tests into single files, added a couple new tests and removed some redundant tests. From 4dcebcb39ea81907c88e78a6973cc68f78c3e708 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Tue, 12 May 2026 14:00:06 -0500 Subject: [PATCH 116/198] feat: add new plotting examples for 2D and 3D visualizations --- Examples/PlotTypes/README.rst | 3 + Examples/PlotTypes/plot_3d.py | 74 ++++++++++++ Examples/PlotTypes/plot_bar.py | 151 +++++++++++++++++++++++ Examples/PlotTypes/plot_image2d.py | 139 +++++++++++++++++++++ Examples/PlotTypes/plot_inset.py | 90 ++++++++++++++ Examples/PlotTypes/plot_line_styles.py | 159 +++++++++++++++++++++++++ Examples/PlotTypes/plot_pcolormesh.py | 65 ++++++++++ Examples/PlotTypes/plot_spectra1d.py | 112 +++++++++++++++++ 8 files changed, 793 insertions(+) create mode 100644 Examples/PlotTypes/README.rst create mode 100644 Examples/PlotTypes/plot_3d.py create mode 100644 Examples/PlotTypes/plot_bar.py create mode 100644 Examples/PlotTypes/plot_image2d.py create mode 100644 Examples/PlotTypes/plot_inset.py create mode 100644 Examples/PlotTypes/plot_line_styles.py create mode 100644 Examples/PlotTypes/plot_pcolormesh.py create mode 100644 Examples/PlotTypes/plot_spectra1d.py diff --git a/Examples/PlotTypes/README.rst b/Examples/PlotTypes/README.rst new file mode 100644 index 00000000..8c02b0e9 --- /dev/null +++ b/Examples/PlotTypes/README.rst @@ -0,0 +1,3 @@ +Plot Types +---------- +A collection of short examples showing different plot types. \ No newline at end of file diff --git a/Examples/PlotTypes/plot_3d.py b/Examples/PlotTypes/plot_3d.py new file mode 100644 index 00000000..2565fc08 --- /dev/null +++ b/Examples/PlotTypes/plot_3d.py @@ -0,0 +1,74 @@ +""" +3D Plotting +=========== + +Demonstrate the three 3-D geometry types supported by +:meth:`~anyplotlib.Axes.plot_surface`, +:meth:`~anyplotlib.Axes.scatter3d`, and +:meth:`~anyplotlib.Axes.plot3d`. +Drag to rotate, scroll to zoom, press **R** to reset the view. +""" +import numpy as np +import anyplotlib as vw + +# ── Surface ─────────────────────────────────────────────────────────────────── +x = np.linspace(-3, 3, 60) +y = np.linspace(-3, 3, 60) +XX, YY = np.meshgrid(x, y) +ZZ = np.sin(np.sqrt(XX ** 2 + YY ** 2)) + +fig, ax = vw.subplots(1, 1, figsize=(520, 480)) +surf = ax.plot_surface(XX, YY, ZZ, + colormap="viridis", + x_label="x", y_label="y", z_label="sin(r)") + +fig + +# %% +# Scatter plot +# ------------ + +rng = np.random.default_rng(1) +n = 300 +theta = rng.uniform(0, 2 * np.pi, n) +phi = rng.uniform(0, np.pi, n) +r = rng.uniform(0.6, 1.0, n) +xs = r * np.sin(phi) * np.cos(theta) +ys = r * np.sin(phi) * np.sin(theta) +zs = r * np.cos(phi) + +fig2, ax2 = vw.subplots(1, 1, figsize=(480, 480)) +sc = ax2.scatter3d(xs, ys, zs, + color="#4fc3f7", point_size=3, + x_label="x", y_label="y", z_label="z") + +fig2 + +# %% +# 3-D line — parametric helix +# ---------------------------- + +t = np.linspace(0, 4 * np.pi, 300) +hx = np.cos(t) +hy = np.sin(t) +hz = t / (4 * np.pi) + +fig3, ax3 = vw.subplots(1, 1, figsize=(480, 480)) +ln = ax3.plot3d(hx, hy, hz, + color="#ff7043", linewidth=2, + x_label="cos t", y_label="sin t", z_label="t") + +fig3 + +# %% +# Update the surface data live +# ---------------------------- +# Call :meth:`~anyplotlib.Plot3D.set_data` to replace the geometry +# without recreating the panel. + +ZZ2 = np.cos(np.sqrt(XX ** 2 + YY ** 2)) +surf.set_data(XX, YY, ZZ2) +surf.set_colormap("plasma") +surf.set_view(azimuth=30, elevation=40) + +fig diff --git a/Examples/PlotTypes/plot_bar.py b/Examples/PlotTypes/plot_bar.py new file mode 100644 index 00000000..e0385ff0 --- /dev/null +++ b/Examples/PlotTypes/plot_bar.py @@ -0,0 +1,151 @@ +""" +Bar Chart +========= + +Demonstrate :meth:`~anyplotlib.Axes.bar` with: + +* **Matplotlib-aligned API** — ``ax.bar(x, height, width, bottom, …)`` +* Vertical and horizontal orientations, per-bar colours, category labels +* **Grouped bars** — pass a 2-D *height* array ``(N, G)`` +* **Log-scale value axis** — ``log_scale=True`` +* Live data updates via :meth:`~anyplotlib.PlotBar.set_data` +""" +import numpy as np +import anyplotlib as vw + +rng = np.random.default_rng(7) + +# ── 1. Vertical bar chart — monthly sales ──────────────────────────────────── +# The first positional argument is now *x* (positions or labels), matching +# ``matplotlib.pyplot.bar(x, height, width=0.8, bottom=0.0, ...)``. +months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] +sales = np.array([42, 55, 48, 63, 71, 68, 74, 81, 66, 59, 52, 78], + dtype=float) + +fig1, ax1 = vw.subplots(1, 1, figsize=(640, 340)) +bar1 = ax1.bar( + months, # x — category strings become x_labels automatically + sales, # height + width=0.6, + color="#4fc3f7", + show_values=True, + units="Month", + y_units="Units sold", +) +fig1 + +# %% +# Horizontal bar chart — ranked items +# ------------------------------------- +# Set ``orient="h"`` for a horizontal layout. Pass a list of CSS colours +# to ``colors`` to give each bar its own colour. + +categories = ["NumPy", "SciPy", "Matplotlib", "Pandas", "Scikit-learn", + "PyTorch", "TensorFlow", "JAX", "Polars", "Dask"] +scores = np.array([95, 88, 91, 87, 83, 79, 76, 72, 68, 65], dtype=float) + +palette = [ + "#ef5350", "#ec407a", "#ab47bc", "#7e57c2", "#42a5f5", + "#26c6da", "#26a69a", "#66bb6a", "#d4e157", "#ffa726", +] + +fig2, ax2 = vw.subplots(1, 1, figsize=(540, 400)) +bar2 = ax2.bar( + categories, + scores, + orient="h", + colors=palette, + width=0.65, + show_values=True, + y_units="Popularity score", +) +fig2 + +# %% +# Grouped bar chart — quarterly comparison +# ----------------------------------------- +# Pass a 2-D *height* array of shape ``(N, G)`` to draw *G* bars side by +# side for each category. Provide ``group_labels`` to show a legend and +# ``group_colors`` to customise each group's colour. + +quarters = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"] +q_data = np.array([ + [42, 58, 51], # Jan — Q1, Q2, Q3 + [55, 61, 59], # Feb + [48, 70, 65], # Mar + [63, 75, 71], # Apr + [71, 69, 80], # May + [68, 83, 77], # Jun +], dtype=float) # shape (6, 3) → 6 categories, 3 groups + +fig3, ax3 = vw.subplots(1, 1, figsize=(680, 340)) +bar3 = ax3.bar( + quarters, + q_data, + width=0.8, + group_labels=["Q1", "Q2", "Q3"], + group_colors=["#4fc3f7", "#ff7043", "#66bb6a"], + show_values=False, + y_units="Sales", +) +fig3 + +# %% +# Log-scale value axis +# --------------------- +# Set ``log_scale=True`` for a logarithmic value axis. Non-positive values +# are clamped to ``1e-10`` — no error is raised. Tick marks are placed at +# each decade (10⁰, 10¹, 10², …) with faint minor gridlines at 2×, 3×, 5× +# multiples. + +log_labels = ["A", "B", "C", "D", "E"] +log_vals = np.array([1, 10, 100, 1_000, 10_000], dtype=float) + +fig4, ax4 = vw.subplots(1, 1, figsize=(500, 340)) +bar4 = ax4.bar( + log_labels, + log_vals, + log_scale=True, + color="#ab47bc", + show_values=True, + y_units="Count (log scale)", +) +fig4 + +# %% +# Side-by-side comparison — update data live +# ------------------------------------------- +# Place two :class:`~anyplotlib.PlotBar` panels in one figure. +# Call :meth:`~anyplotlib.PlotBar.set_data` to swap in Q2 data — +# the value-axis range recalculates automatically. + +q1 = np.array([42, 55, 48, 63, 71, 68, 74, 81, 66, 59, 52, 78], dtype=float) +q2 = np.array([58, 61, 70, 75, 69, 83, 90, 88, 77, 64, 71, 95], dtype=float) +all_months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + +fig5, (ax_left, ax_right) = vw.subplots(1, 2, figsize=(820, 320)) +bar_left = ax_left.bar( + all_months, q1, width=0.6, + color="#4fc3f7", show_values=False, y_units="Q1 sales", +) +bar_right = ax_right.bar( + all_months, q1, width=0.6, + color="#ff7043", show_values=False, y_units="Q2 sales", +) +bar_right.set_data(q2) # swap in Q2 — axis range recalculates automatically + +fig5 + +# %% +# Mutate colours, annotations, and scale at runtime +# -------------------------------------------------- +# :meth:`~anyplotlib.PlotBar.set_color` repaints all bars, +# :meth:`~anyplotlib.PlotBar.set_show_values` toggles labels, +# :meth:`~anyplotlib.PlotBar.set_log_scale` switches the +# value-axis between linear and logarithmic. + +bar1.set_color("#ff7043") +bar1.set_show_values(False) +fig1 diff --git a/Examples/PlotTypes/plot_image2d.py b/Examples/PlotTypes/plot_image2d.py new file mode 100644 index 00000000..2e30d59b --- /dev/null +++ b/Examples/PlotTypes/plot_image2d.py @@ -0,0 +1,139 @@ +""" +2D Image with Histogram +======================= + +Display a 2-D image with physical axes, a colourmap, and an interactive +histogram below — all wired together with draggable threshold widgets. + +Layout +------ +A :class:`~anyplotlib.GridSpec` with two rows puts the image +on top and a bar-chart histogram below. Two +:class:`~anyplotlib.widgets.VLineWidget` handles on the histogram mark the +``display_min`` / ``display_max`` thresholds; dragging them updates the +image colour scale in real time. + +Key bindings on the image panel: **R** reset view · **C** toggle colorbar · +**L** / **S** cycle colour-scale modes. + +New ``imshow`` parameters +------------------------- +``cmap`` + Colormap name passed directly to :meth:`~anyplotlib.Axes.imshow` + (e.g. ``"viridis"``, ``"inferno"``). Defaults to ``"gray"``. +``vmin`` / ``vmax`` + Colormap clipping limits in data units. Values outside the range are + clamped to the colormap endpoints. Defaults to the data min/max. +``origin`` + ``"upper"`` (default) places row 0 at the top (image convention). + ``"lower"`` places row 0 at the bottom (scientific / matrix convention) + and automatically reverses the y-axis so tick values increase upward. +""" +import numpy as np +import anyplotlib as apl + + +rng = np.random.default_rng(1) + +# ── Synthetic diffraction pattern ───────────────────────────────────────────── +N = 256 +x = np.linspace(-5, 5, N) # physical axis in nm +y = np.linspace(-5, 5, N) +XX, YY = np.meshgrid(x, y) +R = np.sqrt(XX ** 2 + YY ** 2) + + +def _ring(r, r0, width, amp): + return amp * np.exp(-0.5 * ((r - r0) / width) ** 2) + + +image = ( + _ring(R, 0.0, 0.30, 1.00) # central spot + + _ring(R, 2.1, 0.15, 0.55) # first-order ring + + _ring(R, 4.2, 0.15, 0.25) # second-order ring + + rng.normal(scale=0.04, size=(N, N)) +) + +# ── Layout: image (top, 3×) + histogram bar chart (bottom, 1×) ──────────────── +gs = apl.GridSpec(2, 1, height_ratios=[3, 1]) +fig = apl.Figure(figsize=(500, 640)) +ax_img = fig.add_subplot(gs[0, 0]) +ax_hist = fig.add_subplot(gs[1, 0]) + +# ── Image panel — cmap, vmin, vmax supplied directly to imshow ──────────────── +vmin_init = float(image.min()) +vmax_init = float(image.max()) + +# Pass cmap, vmin, and vmax directly — no separate set_colormap / set_clim call +# needed for the initial display. +v = ax_img.imshow(image, axes=[x, y], units="nm", + cmap="inferno", vmin=vmin_init, vmax=vmax_init) + +# First-order spot markers +dx = x[1] - x[0] + + +def phys_to_px(val): + return (np.asarray(val) - x[0]) / dx + + +spot_nm = np.array([[ 2.1, 0.0], [-2.1, 0.0], + [ 0.0, 2.1], [ 0.0, -2.1]]) +spot_px = np.column_stack([phys_to_px(spot_nm[:, 0]), + phys_to_px(spot_nm[:, 1])]) +v.add_circles(spot_px, name="spots", radius=7, + edgecolors="#00e5ff", facecolors="#00e5ff22", + labels=["g1", "g1_bar", "g2", "g2_bar"]) + +# ── Histogram bar chart ──────────────────────────────────────────────────────── +counts, edges = np.histogram(image.ravel(), bins=64) +bin_centers = 0.5 * (edges[:-1] + edges[1:]) + +h = ax_hist.bar(counts, x_centers=bin_centers, orient="v", + color="#4fc3f7", y_units="count") + +# ── Draggable threshold handles on the histogram ────────────────────────────── +wlo = h.add_vline_widget(vmin_init, color="#ff6e40") # low-threshold handle +whi = h.add_vline_widget(vmax_init, color="#ffffff") # high-threshold handle + + +@wlo.on_release +def _apply_low(event): + """Update image display_min when the low handle is released.""" + v.set_clim(vmin=event.x) + + +@whi.on_release +def _apply_high(event): + """Update image display_max when the high handle is released.""" + v.set_clim(vmax=event.x) + + +fig # Interactive + +# %% +# Adjust colour map and display range +# ------------------------------------ +# :meth:`~anyplotlib.Plot2D.set_colormap` switches the palette; +# :meth:`~anyplotlib.Plot2D.set_clim` adjusts the display range. +# Both are equivalent to passing ``cmap`` / ``vmin`` / ``vmax`` at construction. + +v.set_colormap("viridis") +v.set_clim(vmin=0.0, vmax=0.8) + +fig + +# %% +# origin='lower' — scientific / matrix convention +# ------------------------------------------------ +# Passing ``origin='lower'`` places row 0 of the data at the *bottom* of the +# image, matching the matplotlib / scientific convention. The y-axis is +# automatically reversed so tick values still increase upward. + +mat = np.arange(64, dtype=float).reshape(8, 8) # row 0 = small values + +fig2, ax2 = apl.subplots() +v2 = ax2.imshow(mat, cmap="plasma", origin="lower") + +fig2 # Interactive + diff --git a/Examples/PlotTypes/plot_inset.py b/Examples/PlotTypes/plot_inset.py new file mode 100644 index 00000000..0f88a4fe --- /dev/null +++ b/Examples/PlotTypes/plot_inset.py @@ -0,0 +1,90 @@ +""" +Inset Plots +=========== + +Floating informational sub-plots that overlay the main figure — useful for +displaying supplementary data alongside a primary image, as seen in orientation +mapping, phase analysis, and similar workflows. + +Each inset has a **title bar** with two buttons: + +* **−** (minimize) — collapses the inset to its title bar only. +* **⤢** (maximize) — expands the inset to ~72 % of the figure, centred. + Click **⤡** to restore. + +Multiple insets sharing the same ``corner`` auto-stack so they never overlap +in the minimised or normal state. + +Python-side state can also be set programmatically:: + + inset.minimize() + inset.maximize() + inset.restore() + print(inset.inset_state) # "normal" | "minimized" | "maximized" +""" + +import numpy as np +import anyplotlib as apl + +rng = np.random.default_rng(42) + +# ── Helpers — synthetic data ────────────────────────────────────────────────── + +def _diffraction(N=256): + """Simulated diffraction pattern (Gaussian rings).""" + y, x = np.ogrid[-N//2:N//2, -N//2:N//2] + r = np.hypot(x, y) + img = np.zeros((N, N)) + for r0, sigma, amp in [(40, 6, 1.0), (80, 8, 0.6), (120, 10, 0.3)]: + img += amp * np.exp(-((r - r0) ** 2) / (2 * sigma ** 2)) + img += rng.normal(0, 0.04, img.shape) + return img + +def _phase_map(N=128): + """Fake two-phase orientation map.""" + img = rng.integers(0, 4, (N, N), dtype=np.uint8) + # blob of phase 2 in the centre + cy, cx = N // 2, N // 2 + yy, xx = np.ogrid[:N, :N] + img[((yy - cy)**2 + (xx - cx)**2) < (N // 4)**2] = np.uint8(5) + return img.astype(float) + +def _pole_figure(N=96): + """Simulated pole-figure intensity (radial Gaussian blob).""" + y, x = np.ogrid[-N//2:N//2, -N//2:N//2] + r = np.hypot(x, y) + return np.exp(-(r ** 2) / (2 * (N // 6) ** 2)) + rng.normal(0, 0.02, (N, N)) + +def _virtual_adf(N=128): + """Annular dark-field signal for a simple lattice.""" + y, x = np.mgrid[:N, :N] + return (np.sin(y * 0.4) * np.cos(x * 0.4)) ** 2 + rng.normal(0, 0.05, (N, N)) + +# ── Build figure ────────────────────────────────────────────────────────────── + +fig, ax = apl.subplots(1, 1, figsize=(660, 500)) + +# Primary large image: diffraction pattern +main = ax.imshow(_diffraction(256), cmap="inferno") + +# ── Inset 1: phase map (top-right) ─────────────────────────────────────────── +inset_phase = fig.add_inset(0.27, 0.27, corner="top-right", title="Phase Map") +inset_phase.imshow(_phase_map(128), cmap="tab10") + +# ── Inset 2: pole figure — stacks below inset 1 in the same corner ──────────── +inset_pole = fig.add_inset(0.27, 0.27, corner="top-right", title="Pole Figure") +inset_pole.imshow(_pole_figure(96), cmap="hot") + +# ── Inset 3: virtual ADF (bottom-left) ──────────────────────────────────────── +inset_adf = fig.add_inset(0.27, 0.27, corner="bottom-left", title="Virtual ADF") +inset_adf.imshow(_virtual_adf(128), cmap="gray") + +# ── Inset 4: 1-D line profile (bottom-right) ───────────────────────────────── +x_nm = np.linspace(0, 10, 256) +profile = np.sin(x_nm * 3.5) * np.exp(-x_nm * 0.18) + rng.normal(0, 0.05, 256) + +inset_line = fig.add_inset(0.30, 0.22, corner="bottom-right", title="Line Profile") +inset_line.plot(profile, axes=[x_nm], units="nm", color="#4fc3f7", linewidth=1.5) + +fig + diff --git a/Examples/PlotTypes/plot_line_styles.py b/Examples/PlotTypes/plot_line_styles.py new file mode 100644 index 00000000..42faeea5 --- /dev/null +++ b/Examples/PlotTypes/plot_line_styles.py @@ -0,0 +1,159 @@ +""" +1D Line Styles +============== + +Demonstrates the line-style, opacity, and per-point marker parameters +available on :meth:`~anyplotlib.Axes.plot` and +:meth:`~anyplotlib.Plot1D.add_line`. + +Four separate figures are shown: + +1. **Linestyles** – all four dash patterns on one panel with a legend. +2. **Alpha (transparency)** – two overlapping sine waves, each at 40 % opacity. +3. **Marker symbols** – all seven supported symbols, each on its own offset + curve. +4. **Combined** – dashed + semi-transparent + circle-marker overlay on a solid + primary line; demonstrates post-construction setters. +""" +import numpy as np +import anyplotlib as vw + +t256 = np.linspace(0.0, 2.0 * np.pi, 256) # dense — good for dashes / alpha +t24 = np.linspace(0.0, 2.0 * np.pi, 24) # sparse — makes markers visible + +# ── 1. Linestyles ───────────────────────────────────────────────────────────── +fig1, ax1 = vw.subplots(1, 1, figsize=(580, 300)) + +plot1 = ax1.plot(np.sin(t256), color="#4fc3f7", linewidth=2, + linestyle="solid", label="solid") +plot1.add_line(np.sin(t256) + 0.6, color="#ff7043", linewidth=2, + linestyle="dashed", label="dashed (\"--\")") +plot1.add_line(np.sin(t256) + 1.2, color="#aed581", linewidth=2, + linestyle="dotted", label="dotted (\":\")") +plot1.add_line(np.sin(t256) + 1.8, color="#ce93d8", linewidth=2, + linestyle="dashdot", label="dashdot (\"-.\")") + +fig1 + +# %% +# The ``ls`` shorthand +# -------------------- +# Each linestyle has a single-character (or two-character) shorthand that +# matches the matplotlib convention: +# +# * ``"-"`` → ``"solid"`` +# * ``"--"`` → ``"dashed"`` +# * ``":"`` → ``"dotted"`` +# * ``"-."`` → ``"dashdot"`` +# +# The shorthands work on both :meth:`~anyplotlib.Axes.plot` +# and :meth:`~anyplotlib.Plot1D.add_line`: + +fig2a, ax2a = vw.subplots(1, 1, figsize=(440, 220)) +p = ax2a.plot(np.sin(t256), ls="-", color="#4fc3f7", label='ls="-"') +p.add_line(np.sin(t256) + 0.8, ls="--", color="#ff7043", label='ls="--"') +p.add_line(np.sin(t256) + 1.6, ls=":", color="#aed581", label='ls=":"') +fig2a + +# %% +# Alpha (opacity) +# --------------- +# ``alpha`` controls line opacity on a 0–1 scale. Values below 1 let +# overlapping curves show through each other — useful for comparing signals +# that share the same amplitude range. + +fig2, ax2 = vw.subplots(1, 1, figsize=(580, 300)) + +plot2 = ax2.plot(np.sin(t256), color="#4fc3f7", alpha=0.4, linewidth=3, + label="sin α=0.4") +plot2.add_line(np.cos(t256), color="#ff7043", alpha=0.4, linewidth=3, + label="cos α=0.4") + +fig2 + +# %% +# Marker symbols +# -------------- +# Set ``marker`` to place a symbol at every data point. Use a **sparse** +# x-axis (few points) so the individual markers are legible. +# ``markersize`` is the radius (circles / diamonds) or half-side-length +# (squares, triangles) in canvas pixels. +# +# Supported symbols: +# +# * ``"o"`` — circle +# * ``"s"`` — square +# * ``"^"`` — triangle-up +# * ``"v"`` — triangle-down +# * ``"D"`` — diamond +# * ``"+"`` — plus (stroke-only) +# * ``"x"`` — cross (stroke-only) +# * ``"none"`` — no marker (default) + +SYMBOLS = [ + ("o", "#4fc3f7"), + ("s", "#ff7043"), + ("^", "#aed581"), + ("v", "#ce93d8"), + ("D", "#ffcc02"), + ("+", "#80cbc4"), + ("x", "#ef9a9a"), +] + +fig3, ax3 = vw.subplots(1, 1, figsize=(580, 380)) + +plot3 = ax3.plot( + np.sin(t24) + (0 - 3) * 0.9, + color=SYMBOLS[0][1], linewidth=1.5, + marker=SYMBOLS[0][0], markersize=5, + label=f'marker="{SYMBOLS[0][0]}"', +) +for i, (sym, col) in enumerate(SYMBOLS[1:], 1): + plot3.add_line( + np.sin(t24) + (i - 3) * 0.9, + color=col, linewidth=1.5, + marker=sym, markersize=5, + label=f'marker="{sym}"', + ) + +fig3 + +# %% +# Combined — linestyle + alpha + marker +# -------------------------------------- +# All three style parameters can be combined freely on the same line or on +# separate overlay lines. + +fig4, ax4 = vw.subplots(1, 1, figsize=(580, 300)) + +# Dense solid primary line +plot4 = ax4.plot(np.sin(t256), color="#4fc3f7", linewidth=2, + label="sin (solid)") + +# Sparse dashed overlay with circle markers and reduced opacity +plot4.add_line(np.cos(t24), color="#ff7043", linewidth=2, + linestyle="dashed", alpha=0.75, + marker="o", markersize=5, + label="cos (dashed, α=0.75, marker='o')") + +fig4 + +# %% +# Post-construction setters +# ------------------------- +# Every primary-line style property has a matching setter method. These +# mutate ``_state`` and push the change to the canvas immediately — no +# need to recreate the panel. + +fig5, ax5 = vw.subplots(1, 1, figsize=(440, 220)) +plot5 = ax5.plot(np.sin(t256), color="#4fc3f7", linewidth=1.5) + +# Change style via setters +plot5.set_color("#ff7043") +plot5.set_linewidth(2.5) +plot5.set_linestyle("dashdot") # equivalent: plot5.set_linestyle("-.") +plot5.set_alpha(0.8) +plot5.set_marker("o", markersize=5) + +fig5 + diff --git a/Examples/PlotTypes/plot_pcolormesh.py b/Examples/PlotTypes/plot_pcolormesh.py new file mode 100644 index 00000000..a85fb75f --- /dev/null +++ b/Examples/PlotTypes/plot_pcolormesh.py @@ -0,0 +1,65 @@ +""" +pcolormesh — non-linear axes +============================ + +Demonstrate :meth:`~anyplotlib.Axes.pcolormesh` with non-uniform +(log-spaced) x-edges and irregularly-spaced y-edges, mirroring +``matplotlib.axes.Axes.pcolormesh``. + +The key difference from :meth:`~anyplotlib.Axes.imshow` is that +``pcolormesh`` takes **edge** arrays (length N+1 and M+1 for an (M, N) data +array) rather than center arrays. This enables fully non-linear axes where +each cell can have a different width/height in data coordinates. +""" +import numpy as np +import anyplotlib as vw + +rng = np.random.default_rng(42) + +# ── Data: 32 rows × 48 columns ─────────────────────────────────────────────── +M, N = 32, 48 +data = np.sin(np.linspace(0, 3 * np.pi, N)) + np.cos(np.linspace(0, 2 * np.pi, M))[:, None] +data += rng.normal(scale=0.15, size=(M, N)) + +# ── Non-uniform edges ───────────────────────────────────────────────────────── +# x: log-spaced between 0.1 and 100 (N+1 edges) +x_edges = np.logspace(-1, 2, N + 1) + +# y: irregular spacing — dense in the middle, coarse at the ends (M+1 edges) +y_centres = np.concatenate([ + np.linspace(0, 40, M // 4, endpoint=False), + np.linspace(40, 60, M // 2, endpoint=False), + np.linspace(60, 100, M // 4), +]) +y_edges = np.concatenate([[y_centres[0] - (y_centres[1] - y_centres[0]) / 2], + (y_centres[:-1] + y_centres[1:]) / 2, + [y_centres[-1] + (y_centres[-1] - y_centres[-2]) / 2]]) + +# ── Plot ────────────────────────────────────────────────────────────────────── +fig, ax = vw.subplots(1, 1, figsize=(560, 460)) +mesh = ax.pcolormesh(data, x_edges=x_edges, y_edges=y_edges, units="arb.") +mesh.set_colormap("viridis") +fig + +# %% +# Add point markers in physical coordinates +# ----------------------------------------- +# Marker coordinates are in the same physical (data) space as the edges. +# Only ``add_circles`` and ``add_lines`` are available on a pcolormesh panel. + +pts = np.array([[1.0, 20.0], [10.0, 50.0], [50.0, 80.0], [90.0, 45.0]]) +mesh.add_circles(pts, name="peaks", radius=3, + edgecolors="#ff1744", facecolors="#ff174433", + labels=["A", "B", "C", "D"]) +fig + +# %% +# Add line-segment markers +# ------------------------ +segs = [ + [[1.0, 20.0], [10.0, 50.0]], + [[10.0, 50.0], [50.0, 80.0]], +] +mesh.add_lines(segs, name="path", edgecolors="#00e5ff", linewidths=2.0) +fig + diff --git a/Examples/PlotTypes/plot_spectra1d.py b/Examples/PlotTypes/plot_spectra1d.py new file mode 100644 index 00000000..d0630fb1 --- /dev/null +++ b/Examples/PlotTypes/plot_spectra1d.py @@ -0,0 +1,112 @@ +""" +1D Spectra +========== + +Plot a 1-D spectrum with a physical x-axis (energy in eV) using +:meth:`~anyplotlib.Axes.plot`. + +The spectrum contains a broad background and three Gaussian peaks. +Circle markers highlight the peak positions using +:meth:`~anyplotlib.Plot1D.add_points`, and a range widget +selects a region of interest. A model fit is overlaid with a dashed line, +and the background component is shown as a semi-transparent dotted curve with +diamond markers. + +Pan and zoom with the mouse; press **R** to reset the view. +""" +import numpy as np +import anyplotlib as vw + +rng = np.random.default_rng(0) + +# ── Synthetic XPS-style spectrum ────────────────────────────────────────────── +energy = np.linspace(280, 295, 512) # binding energy axis (eV) + +def gaussian(x, mu, sigma, amp): + return amp * np.exp(-0.5 * ((x - mu) / sigma) ** 2) + +background = 0.4 * np.exp(-0.08 * (energy - 280)) + +# Background + three peaks (C 1s region) +spectrum = ( + background + + gaussian(energy, 284.8, 0.4, 1.0) # C–C / C–H + + gaussian(energy, 286.2, 0.4, 0.35) # C–O + + gaussian(energy, 288.0, 0.4, 0.18) # C=O + + rng.normal(scale=0.015, size=len(energy)) +) + +# ── Plot ────────────────────────────────────────────────────────────────────── +fig, ax = vw.subplots(1, 1, figsize=(620, 340)) +v = ax.plot(spectrum, axes=[energy], units="eV", y_units="Intensity (a.u.)", + color="#4fc3f7", linewidth=1.5) + +# ── Peak markers (add_points collection) ────────────────────────────────────── +peak_energies = np.array([284.8, 286.2, 288.0]) +peak_offsets = np.column_stack([ + peak_energies, + np.interp(peak_energies, energy, spectrum), +]) +v.add_points(peak_offsets, name="peaks", + sizes=7, color="#ff1744", facecolors="#ff174433", + labels=["C\u2013C", "C\u2013O", "C=O"]) + +# ── Region-of-interest widget ───────────────────────────────────────────────── +v.add_range_widget(x0=285.8, x1=288.8, color="#00e5ff") + +fig + +# %% +# Overlay a model fit — linestyle and alpha +# ----------------------------------------- +# Use :meth:`~anyplotlib.Plot1D.add_line` to overlay additional +# curves. Here the noiseless model fit is drawn as a **dashed** line so it +# is visually distinct from the noisy measured spectrum. The ``alpha`` +# parameter makes the fit semi-transparent so the data underneath remains +# readable. +# +# The y-axis range is expanded automatically to accommodate any overlay line +# whose values fall outside the current bounds. + +fit = ( + background + + gaussian(energy, 284.8, 0.4, 1.0) + + gaussian(energy, 286.2, 0.4, 0.35) + + gaussian(energy, 288.0, 0.4, 0.18) +) +v.add_line(fit, x_axis=energy, + color="#ffcc00", linewidth=2.0, + linestyle="dashed", alpha=0.85, + label="fit") + +fig + +# %% +# Background component — dotted line with markers +# ------------------------------------------------ +# Draw the exponential background component as a **dotted** curve. Passing +# ``marker="D"`` places a diamond at every data point (useful when the line +# is sparse or when you want to emphasise individual sample positions). +# ``markersize`` controls the half-size of the symbol in pixels. + +# Sub-sample to keep the marker plot readable +step = 32 +v.add_line(background[::step], x_axis=energy[::step], + color="#ce93d8", linewidth=1.2, + linestyle="dotted", alpha=0.9, + marker="D", markersize=3, + label="background") + +fig + +# %% +# Post-construction setters +# ------------------------- +# All primary-line style properties can be changed after the panel is created +# without rebuilding it. This is useful in interactive notebooks where you +# want to tweak the appearance of the main trace. + +v.set_alpha(0.9) # slightly reduce primary-line opacity +v.set_linewidth(2.0) # thicker stroke for the main spectrum + +fig From 22ca4ce94fefc05f4c30ed3671438688e805307e Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Tue, 12 May 2026 14:22:07 -0500 Subject: [PATCH 117/198] Apply suggestions from code review --- Examples/PlotTypes/plot_image2d.py | 13 ++----------- docs/dev/index.rst | 2 +- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/Examples/PlotTypes/plot_image2d.py b/Examples/PlotTypes/plot_image2d.py index 2e30d59b..bdf9703a 100644 --- a/Examples/PlotTypes/plot_image2d.py +++ b/Examples/PlotTypes/plot_image2d.py @@ -69,19 +69,10 @@ def _ring(r, r0, width, amp): v = ax_img.imshow(image, axes=[x, y], units="nm", cmap="inferno", vmin=vmin_init, vmax=vmax_init) -# First-order spot markers -dx = x[1] - x[0] - - -def phys_to_px(val): - return (np.asarray(val) - x[0]) / dx - - +# First-order spot markers in the same physical coordinates used by imshow spot_nm = np.array([[ 2.1, 0.0], [-2.1, 0.0], [ 0.0, 2.1], [ 0.0, -2.1]]) -spot_px = np.column_stack([phys_to_px(spot_nm[:, 0]), - phys_to_px(spot_nm[:, 1])]) -v.add_circles(spot_px, name="spots", radius=7, +v.add_circles(spot_nm, name="spots", radius=7, edgecolors="#00e5ff", facecolors="#00e5ff22", labels=["g1", "g1_bar", "g2", "g2_bar"]) diff --git a/docs/dev/index.rst b/docs/dev/index.rst index 300c1188..4087d17c 100644 --- a/docs/dev/index.rst +++ b/docs/dev/index.rst @@ -65,7 +65,7 @@ The library is split into focused subpackages. - Interactive draggable overlays (``RectangleWidget``, ``CrosshairWidget``, etc.). * - ``anyplotlib/callbacks.py`` - - Multi-tier event system (``on_change`` / ``on_release``). + - Multi-tier event system (``on_changed`` / ``on_release``). * - ``anyplotlib/sphinx_anywidget/`` - Sphinx extension for interactive docs via Pyodide. From 8c20363fb4c690032635ae5b18ff75a006559c62 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 10:29:22 -0500 Subject: [PATCH 118/198] =?UTF-8?q?feat:=20add=20pointer=5Fsettled=20dwell?= =?UTF-8?q?=20timer=20to=20JS=20=E2=80=94=20zero=20cost=20when=20unused,?= =?UTF-8?q?=20per-panel=20ms/delta=20from=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- anyplotlib/figure_esm.js | 113 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 2 deletions(-) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 0e77375d..a54441c6 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -1699,6 +1699,8 @@ function render({ model, el }) { const { overlayCanvas } = p; let dragStart = null; let commitPending = false; + let _settledTimer = null; + let _settledStartX = 0, _settledStartY = 0, _settledStartTs = 0; function _scheduleCommit() { if (commitPending) return; commitPending = true; requestAnimationFrame(() => { @@ -1730,6 +1732,7 @@ function render({ model, el }) { e.preventDefault(); }); document.addEventListener('mouseup', () => { + clearTimeout(_settledTimer); _settledTimer = null; if (!dragStart) return; dragStart = null; overlayCanvas.style.cursor = 'grab'; @@ -1753,6 +1756,29 @@ function render({ model, el }) { const {mx, my} = _clientPos(e, overlayCanvas, p.pw, p.ph); p.mouseX = mx; p.mouseY = my; + // pointer_settled dwell timer (zero-cost when pointer_settled_ms === 0) + const _settledMs = (p.state.pointer_settled_ms ?? 0); + if (_settledMs > 0) { + const _settledDelta = p.state.pointer_settled_delta ?? 4; + clearTimeout(_settledTimer); + _settledStartX = mx; + _settledStartY = my; + _settledStartTs = performance.now(); + _settledTimer = setTimeout(() => { + const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); + if (dist <= _settledDelta) { + _emitEvent(p.id, 'pointer_settled', null, { + time_stamp: performance.now() / 1000, + modifiers: [], + button: null, + buttons: 0, + x: Math.round(p.mouseX), + y: Math.round(p.mouseY), + dwell_ms: performance.now() - _settledStartTs, + }); + } + }, _settledMs); + } }); // Keyboard shortcuts @@ -2418,6 +2444,8 @@ function render({ model, el }) { function _attachEvents2d(p) { const { overlayCanvas } = p; let localOnly=false, commitPending=false; + let _settledTimer = null; + let _settledStartX = 0, _settledStartY = 0, _settledStartTs = 0; function _scheduleCommit(){ if(commitPending) return; commitPending=true; requestAnimationFrame(()=>{commitPending=false;localOnly=true;model.save_changes();setTimeout(()=>{localOnly=false;},200);}); @@ -2493,6 +2521,7 @@ function render({ model, el }) { _scheduleCommit(); e.preventDefault(); }); document.addEventListener('mouseup',(e)=>{ + clearTimeout(_settledTimer); _settledTimer = null; if(p.ovDrag2d){ const _idx=p.ovDrag2d.idx; const _dw=(p.state.overlay_widgets||[])[_idx]||{}; @@ -2589,8 +2618,33 @@ function render({ model, el }) { } else { p.statusBar.style.display='none'; tooltip.style.display='none'; if(p._hoverSi!==-1){p._hoverSi=-1;p._hoverI=-1;drawMarkers2d(p,null);} } + // pointer_settled dwell timer (zero-cost when pointer_settled_ms === 0) + const _settledMs = (p.state.pointer_settled_ms ?? 0); + if (_settledMs > 0) { + const _settledDelta = p.state.pointer_settled_delta ?? 4; + clearTimeout(_settledTimer); + _settledStartX = mx; + _settledStartY = my; + _settledStartTs = performance.now(); + _settledTimer = setTimeout(() => { + const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); + if (dist <= _settledDelta) { + _emitEvent(p.id, 'pointer_settled', null, { + time_stamp: performance.now() / 1000, + modifiers: [], + button: null, + buttons: 0, + x: Math.round(p.mouseX), + y: Math.round(p.mouseY), + dwell_ms: performance.now() - _settledStartTs, + }); + } + }, _settledMs); + } }); - overlayCanvas.addEventListener('mouseleave',()=>{p.statusBar.style.display='none';tooltip.style.display='none'; + overlayCanvas.addEventListener('mouseleave',()=>{ + clearTimeout(_settledTimer); _settledTimer = null; + p.statusBar.style.display='none';tooltip.style.display='none'; if(p._hoverSi!==-1){p._hoverSi=-1;p._hoverI=-1;drawMarkers2d(p,null);} }); @@ -2643,6 +2697,8 @@ function render({ model, el }) { function _attachEvents1d(p) { const { overlayCanvas } = p; let localOnly=false, commitPending=false; + let _settledTimer = null; + let _settledStartX = 0, _settledStartY = 0, _settledStartTs = 0; function _scheduleCommit(){ if(commitPending) return; commitPending=true; requestAnimationFrame(()=>{commitPending=false;localOnly=true;model.save_changes();setTimeout(()=>{localOnly=false;},200);}); @@ -2702,6 +2758,7 @@ function render({ model, el }) { model.set(`panel_${p.id}_json`,JSON.stringify(st));_scheduleCommit();e.preventDefault(); }); document.addEventListener('mouseup',(e)=>{ + clearTimeout(_settledTimer); _settledTimer = null; const wasWidgetDragging=!!p.ovDrag; // capture BEFORE clearing const wasDragging=wasWidgetDragging||!!p.isPanning; if(p.ovDrag){ @@ -2795,8 +2852,33 @@ function render({ model, el }) { } if(lhit) _emitEvent(p.id,'on_line_hover',null,{line_id:lhit.lineId,x:lhit.x,y:lhit.y}); } + // pointer_settled dwell timer (zero-cost when pointer_settled_ms === 0) + const _settledMs = (p.state.pointer_settled_ms ?? 0); + if (_settledMs > 0) { + const _settledDelta = p.state.pointer_settled_delta ?? 4; + clearTimeout(_settledTimer); + _settledStartX = mx; + _settledStartY = my; + _settledStartTs = performance.now(); + _settledTimer = setTimeout(() => { + const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); + if (dist <= _settledDelta) { + _emitEvent(p.id, 'pointer_settled', null, { + time_stamp: performance.now() / 1000, + modifiers: [], + button: null, + buttons: 0, + x: Math.round(p.mouseX), + y: Math.round(p.mouseY), + dwell_ms: performance.now() - _settledStartTs, + }); + } + }, _settledMs); + } }); - overlayCanvas.addEventListener('mouseleave',()=>{p.statusBar.style.display='none';tooltip.style.display='none'; + overlayCanvas.addEventListener('mouseleave',()=>{ + clearTimeout(_settledTimer); _settledTimer = null; + p.statusBar.style.display='none';tooltip.style.display='none'; if(p._hoverSi!==-1){p._hoverSi=-1;p._hoverI=-1;drawMarkers1d(p,null);} if(p._lineHoverId!=='__none__'){p._lineHoverId='__none__';draw1d(p);drawOverlay1d(p);overlayCanvas.style.cursor='crosshair';} }); @@ -3730,6 +3812,8 @@ function render({ model, el }) { // Widget drag support let commitPending = false; + let _settledTimer = null; + let _settledStartX = 0, _settledStartY = 0, _settledStartTs = 0; function _scheduleCommit() { if (commitPending) return; commitPending = true; requestAnimationFrame(() => { commitPending = false; model.save_changes(); }); @@ -3755,6 +3839,7 @@ function render({ model, el }) { }); document.addEventListener('mouseup', (e) => { + clearTimeout(_settledTimer); _settledTimer = null; if (!p.ovDrag) return; const _idx = p.ovDrag.idx; const _dw = (p.state.overlay_widgets || [])[_idx] || {}; @@ -3804,9 +3889,33 @@ function render({ model, el }) { tooltip.style.display = 'none'; overlayCanvas.style.cursor = 'default'; } + // pointer_settled dwell timer (zero-cost when pointer_settled_ms === 0) + const _settledMs = (p.state.pointer_settled_ms ?? 0); + if (_settledMs > 0) { + const _settledDelta = p.state.pointer_settled_delta ?? 4; + clearTimeout(_settledTimer); + _settledStartX = mx; + _settledStartY = my; + _settledStartTs = performance.now(); + _settledTimer = setTimeout(() => { + const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); + if (dist <= _settledDelta) { + _emitEvent(p.id, 'pointer_settled', null, { + time_stamp: performance.now() / 1000, + modifiers: [], + button: null, + buttons: 0, + x: Math.round(p.mouseX), + y: Math.round(p.mouseY), + dwell_ms: performance.now() - _settledStartTs, + }); + } + }, _settledMs); + } }); overlayCanvas.addEventListener('mouseleave', () => { + clearTimeout(_settledTimer); _settledTimer = null; if (p._hovBar !== null) { p._hovBar = null; drawBar(p); } tooltip.style.display = 'none'; }); From f2ff29baf3004521539697519ba8049b5381f398 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Thu, 14 May 2026 11:12:05 -0500 Subject: [PATCH 119/198] docs: add event system redesign spec Audited the existing event system against pygfx/rendercanvas conventions, identified naming inconsistencies and gaps, and designed a complete replacement aligned with pygfx naming (pointer_down/up/move/settled, key_down/key_up, etc.) with anyplotlib-specific extensions (pointer_settled with ms/delta params, pause_events/hold_events context managers). --- .../specs/2026-05-14-event-system-design.md | 381 ++++++++++++++++++ 1 file changed, 381 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-14-event-system-design.md diff --git a/docs/superpowers/specs/2026-05-14-event-system-design.md b/docs/superpowers/specs/2026-05-14-event-system-design.md new file mode 100644 index 00000000..c4c45117 --- /dev/null +++ b/docs/superpowers/specs/2026-05-14-event-system-design.md @@ -0,0 +1,381 @@ +# Event System Redesign + +**Date:** 2026-05-14 +**Status:** Approved — ready for implementation planning + +## Motivation + +The existing event system has several inconsistencies identified during a pre-0.1.0 audit: + +- `on_click` fires on mouse press (not full click cycle) — misleading name +- `on_release` means "debounced/settled" not "mouse button released" — misleading name +- `on_changed` conflates viewport pan/zoom with widget drag frames +- `phys_x`/`phys_y` are non-standard field names; matplotlib users expect `xdata`/`ydata` +- Modifier keys (ctrl, shift, alt) are not exposed on any event +- No `pointer_up`, `pointer_enter`, `pointer_leave`, `double_click`, `wheel`, or `key_up` events +- `on_key` decorator has asymmetric optional-argument syntax inconsistent with all other decorators +- `on_click` payload differs completely across plot types (coords on Plot1D/2D, bar metadata on PlotBar, no data coords on Plot3D) +- No way to pause or buffer events during batch operations + +The redesign aligns with the [pygfx/rendercanvas event system](https://github.com/pygfx/rendercanvas) naming and adds anyplotlib-specific extensions (`pointer_settled`, pause/hold). + +--- + +## Section 1: Event Types + +### Pointer events (all plot types) + +| Event | Trigger | +|-------|---------| +| `pointer_down` | Mouse/touch pressed — replaces `on_click` | +| `pointer_up` | Mouse/touch physically released — new | +| `pointer_move` | Pointer moved (drag or hover) — replaces `on_changed` | +| `pointer_settled` | Pointer held still for ≥ N ms within ± delta px — replaces `on_release`, gains explicit params | +| `pointer_enter` | Cursor enters the panel — new | +| `pointer_leave` | Cursor leaves the panel — new | +| `double_click` | Double-click / long-tap — new | +| `wheel` | Scroll wheel or pinch — new | + +### Key events (all plot types) + +| Event | Trigger | +|-------|---------| +| `key_down` | Key pressed while panel focused — replaces `on_key` | +| `key_up` | Key released — new | + +### Plot-specific behaviour + +`pointer_move` and `pointer_down` on **Plot1D** carry a `line_id` field when the pointer is over a line (`None` otherwise). These are not separate event types — the same event carries extra data. Users check `if event.line_id` to distinguish. This replaces the separate `on_line_hover` and `on_line_click` event types. + +--- + +## Section 2: Event Object Fields + +The `Event` dataclass is flattened — all fields are top-level attributes with `None` as the default when a field does not apply. No more `data` dict with attribute proxy. + +### Universal fields (every event) + +| Field | Type | Description | +|-------|------|-------------| +| `event_type` | `str` | e.g. `"pointer_down"` | +| `source` | `object` | the plot or widget that fired it | +| `time_stamp` | `float` | `perf_counter()` at fire time | +| `modifiers` | `list[str]` | `["ctrl"]`, `["shift"]`, `["alt"]`, `["meta"]` — empty list if none | + +### Pointer fields (pointer_down, pointer_up, pointer_move, pointer_settled, pointer_enter, pointer_leave, double_click) + +| Field | Type | Present on | +|-------|------|-----------| +| `x` | `int` | all pointer events — pixel x within panel | +| `y` | `int` | all pointer events — pixel y within panel | +| `button` | `int \| None` | `pointer_down`, `pointer_up`, `double_click` only — 0=left, 1=middle, 2=right; `None` on enter/leave/move/settled | +| `buttons` | `int` | all pointer events — bitmask of currently held buttons (useful on `pointer_enter` to detect dragging into panel) | +| `xdata` | `float \| None` | Plot1D, Plot2D, PlotMesh — data-space x coordinate | +| `ydata` | `float \| None` | Plot1D, Plot2D, PlotMesh — data-space y coordinate | +| `ray` | `dict \| None` | Plot3D only — `{"origin": [x,y,z], "direction": [dx,dy,dz]}` | +| `line_id` | `str \| None` | Plot1D only — set when pointer is over a line, `None` otherwise | +| `dwell_ms` | `float \| None` | `pointer_settled` only — actual time the pointer held still | + +### PlotBar additional fields on `pointer_down` + +| Field | Type | Description | +|-------|------|-------------| +| `bar_index` | `int \| None` | which bar was clicked; `None` if click missed all bars | +| `value` | `float \| None` | bar value | +| `x_label` | `str \| None` | category label | +| `group_index` | `int \| None` | group index for grouped bars; `None` for ungrouped | + +PlotBar `pointer_down` also carries `x`, `y`, `xdata`, `ydata` like other plot types, so all fields are available. + +### Wheel fields + +| Field | Type | Description | +|-------|------|-------------| +| `x`, `y` | `int` | pointer position at time of scroll | +| `dx`, `dy` | `float` | scroll deltas; accumulated across merged frames (matching pygfx) | + +### Key fields (key_down, key_up) + +| Field | Type | Description | +|-------|------|-------------| +| `key` | `str` | key name e.g. `"q"`, `"Enter"`, `"ArrowLeft"` | +| `x`, `y` | `int` | pointer position at time of keypress | + +--- + +## Section 3: Connection API + +The user-facing API on every plot and widget becomes `add_event_handler` / `remove_handler`. The internal `CallbackRegistry` engine (`connect`/`disconnect`/`fire`) is unchanged. + +### Functional form + +```python +# Single type +cid = plot.add_event_handler(fn, "pointer_down") + +# Multiple types in one call +cid = plot.add_event_handler(fn, "pointer_down", "pointer_up") + +# Wildcard — receives every event type +cid = plot.add_event_handler(fn, "*") + +# pointer_settled with explicit thresholds (defaults: ms=300, delta=4) +# ms/delta are only valid when "pointer_settled" is in the types list — ValueError otherwise +cid = plot.add_event_handler(fn, "pointer_settled", ms=400, delta=5) + +# Priority — lower order fires first, default 0 +cid = plot.add_event_handler(fn, "pointer_move", order=-1) +``` + +### Decorator form + +```python +@plot.add_event_handler("pointer_down") +def on_press(event): + print(event.xdata, event.ydata) + +@plot.add_event_handler("pointer_down", "pointer_up") +def on_press_release(event): + print(event.event_type, event.button) + +@plot.add_event_handler("pointer_settled", ms=400, delta=5) +def on_settled(event): + update_spectrum(event.xdata, event.ydata) +``` + +### Removal + +```python +# By CID (returned from add_event_handler) +plot.remove_handler(cid) + +# By callback reference + specific types +plot.remove_handler(fn, "pointer_down") + +# By callback reference alone — removes from all types it was registered under +plot.remove_handler(fn) +``` + +### Per-line filtering on Plot1D + +Line handles returned by `ax.plot()` and `line.add_line()` expose their own `add_event_handler`. Internally this connects to the plot's `pointer_move`/`pointer_down` and filters by `line_id` — no new mechanism required. + +```python +line = ax.plot(data) +overlay = line.add_line(data2) + +@line.add_event_handler("pointer_move") +def on_hover(event): + print(event.xdata, event.line_id) + +@overlay.add_event_handler("pointer_down") +def on_pick(event): + print("picked overlay line") +``` + +### What disappears + +| Old | New | +|-----|-----| +| `@plot.on_click` | `@plot.add_event_handler("pointer_down")` | +| `@plot.on_changed` | `@plot.add_event_handler("pointer_move")` | +| `@plot.on_release` | `@plot.add_event_handler("pointer_settled")` | +| `@plot.on_key` / `@plot.on_key('q')` | `@plot.add_event_handler("key_down")` | +| `@line.on_hover` | `@line.add_event_handler("pointer_move")` | +| `@line.on_click` | `@line.add_event_handler("pointer_down")` | +| `plot.disconnect(cid)` | `plot.remove_handler(cid)` | +| `plot.callbacks.connect("on_click", fn)` | `plot.callbacks.connect("pointer_down", fn)` | + +--- + +## Section 4: Architecture & Data Flow + +### JS changes (`figure_esm.js`) + +**New events JS must emit:** + +| JS DOM event | anyplotlib event | Notes | +|-------------|-----------------|-------| +| `mouseenter` | `pointer_enter` | per panel canvas element | +| `mouseleave` | `pointer_leave` | per panel canvas element | +| `mouseup` | `pointer_up` | previously swallowed after debounce | +| `dblclick` | `double_click` | | +| `wheel` | `wheel` | `dx`/`dy` accumulated across merged frames | +| `keyup` | `key_up` | complement to existing keydown | + +**Fields added to all emitted events:** +- `modifiers`: extracted from `ctrlKey`, `shiftKey`, `altKey`, `metaKey` +- `buttons`: from `event.buttons` bitmask (available on all MouseEvents) +- `button`: from `event.button` on press/release events +- `time_stamp`: set in JS before sending + +**`pointer_settled` timer logic (per panel):** + +``` +On pointer_move: + if panel_state.pointer_settled_ms > 0: + clearTimeout(settled_timer) + record settle_start_pos = current_pos + settled_timer = setTimeout(() => { + if distance(current_pos, settle_start_pos) <= panel_state.pointer_settled_delta: + emit pointer_settled { ...pointer fields, dwell_ms: actual_elapsed } + }, panel_state.pointer_settled_ms) +``` + +Timer is never created when `pointer_settled_ms == 0`. Cost is zero when no handler is connected. + +**Key registration removed:** `registered_keys` state field is eliminated. `key_down`/`key_up` forward all key presses unconditionally (matching pygfx). Per-key filtering moves to Python-side handler wrappers if users want it. + +### Python changes + +**`_dispatch_event()` field mapping:** + +| Old field | New field | Change | +|-----------|-----------|--------| +| `phys_x` | `xdata` | rename | +| `phys_y` | `ydata` | rename | +| `mouse_x` | `x` | rename | +| `mouse_y` | `y` | rename | +| *(absent)* | `button` | new | +| *(absent)* | `buttons` | new | +| *(absent)* | `modifiers` | new | +| *(absent)* | `time_stamp` | new | +| *(absent)* | `ray` | new (Plot3D) | +| *(absent)* | `dx`, `dy` | new (wheel) | +| *(absent)* | `dwell_ms` | new (pointer_settled) | + +**`pointer_settled` configuration flow:** + +When the first `pointer_settled` handler connects: +```python +plot._state["pointer_settled_ms"] = ms # configured threshold +plot._state["pointer_settled_delta"] = delta # configured threshold +plot._push() # JS activates timer +``` +When the last `pointer_settled` handler disconnects: +```python +plot._state["pointer_settled_ms"] = 0 # JS deactivates timer +plot._push() +``` + +**`CallbackRegistry` additions:** +1. Multi-type registration: `add_event_handler(fn, "a", "b")` registers `fn` under both internally; `remove_handler(fn)` removes from all registered types +2. Order-based priority: handlers stored as `(order, fn)` tuples, sorted on insert +3. Wildcard `"*"`: fires for every event type dispatched +4. `stop_propagation`: existing — `event.stop_propagation = True` in a handler halts remaining handlers + +### Pause and Hold + +Both are context managers implemented on `CallbackRegistry` and exposed on every plot and widget. + +**Pause (suppress):** +```python +with plot.pause_events(): # suppress all types + update_all_panels() + +with plot.pause_events("pointer_move"): # suppress specific types + do_something() +``` + +**Hold (buffer + flush):** +```python +with plot.hold_events(): # buffer all types, flush on exit + do_something() + +with plot.hold_events("pointer_settled"): # buffer specific types only + do_something() +``` + +**Nesting:** both use a depth counter — pause/hold only fully lifts when the outermost context exits. + +**Precedence:** if both are active for the same event type, pause wins — events are dropped, not buffered. + +**`CallbackRegistry` internal state:** +- `_pause_types: set[str]` — event types currently suppressed +- `_pause_depth: int` — nesting depth counter +- `_hold_types: set[str]` — event types currently buffered +- `_hold_depth: int` — nesting depth counter +- `_held_events: deque[Event]` — ordered buffer of held events + +`fire()` checks pause first (drop), then hold (queue), then dispatch. + +--- + +## Section 5: Testing Plan + +### Tier 1 — Pure Python, no browser + +**`CallbackRegistry` unit tests:** +- Multi-type registration fires handler for both types +- Wildcard `"*"` receives every event type dispatched +- Lower `order` fires before higher; same order fires in registration order +- `remove_handler` by CID +- `remove_handler` by callback reference + types +- `remove_handler` by callback reference alone removes from all types +- `stop_propagation` halts dispatch mid-handler-list +- `pause_events()`: events dropped, handlers intact after context exit +- `hold_events()`: events queued, fire in order on exit +- Pause inside hold: paused types are dropped (not buffered) +- Nested hold: depth counter lifts only on outermost exit +- `pointer_settled` params set in panel state on first connect, cleared on last disconnect + +**`Event` dataclass tests:** +- Universal fields present on every event +- `modifiers` is always a `list`, never `None` +- `time_stamp` is always set +- Plot3D events carry `ray`, not `xdata`/`ydata` +- PlotBar `pointer_down` carries bar metadata and coordinates +- `pointer_settled` carries `dwell_ms ≥` configured threshold +- `pointer_enter`/`pointer_leave` carry `buttons` (bitmask) but `button` is `None` + +### Tier 2 — Playwright browser tests + +One matrix per plot type (Plot1D, Plot2D, PlotMesh, Plot3D, PlotBar): + +| Test | Verified | +|------|---------| +| `pointer_down` | fires on mousedown; correct `x/y`, `button=0`, `buttons=1`, `xdata/ydata` | +| `pointer_up` | fires on mouseup; `button=0`, `buttons=0` | +| `pointer_move` | fires during drag; `xdata/ydata` update correctly | +| `pointer_enter/leave` | fire when mouse crosses panel boundary | +| `double_click` | fires on dblclick; same fields as `pointer_down` | +| `wheel` | fires on scroll; `dx/dy` non-zero | +| `key_down/key_up` | fire on keypress/release; `key` field correct | +| `modifiers` | ctrl+click produces `modifiers=["ctrl"]` | +| `pointer_settled` | fires after configured ms; does NOT fire if pointer moves beyond delta | + +**Plot1D-specific:** +- `pointer_move` over a line sets `line_id`; off a line sets `line_id=None` +- `pointer_down` on a line sets `line_id` +- Line handle's `add_event_handler` filters correctly — handler on `line2` does not fire when pointer is over `line1` + +**`pointer_settled`-specific:** +- Does not fire when no handler connected (JS timer flag absent from panel state) +- `dwell_ms` on the event is ≥ configured `ms` +- Fires again after pointer moves and re-settles (resets correctly) +- Two panels with different `ms`/`delta` thresholds behave independently + +**Pause/Hold integration:** +- `pause_events()` during drag: `pointer_move` does not reach handler +- `hold_events()` during drag: events fire in order on context exit +- Type-specific hold: `hold_events("pointer_settled")` buffers settled but fires `pointer_move` immediately + +### Tier 3 — Regression + +- `on_click`, `on_changed`, `on_release`, `on_key` raise `AttributeError` (old names removed) +- `event.phys_x`, `event.phys_y` raise `AttributeError` (renamed to `xdata`/`ydata`) +- All `Examples/` files run without error after event handler updates + +--- + +## Summary of Changes + +| Area | Change | +|------|--------| +| Event names | 5 renamed, 8 new added | +| Event fields | `phys_x/y` → `xdata/ydata`, `mouse_x/y` → `x/y`; add `modifiers`, `button`, `buttons`, `time_stamp`, `ray`, `dx/dy`, `dwell_ms` | +| Connection API | `add_event_handler` / `remove_handler`; multi-type, wildcard, priority | +| `pointer_settled` | Configurable `ms`/`delta` per panel; zero cost when unused | +| Pause/Hold | Context managers on every plot and widget | +| JS layer | 6 new event types forwarded; `registered_keys` removed; timer for `pointer_settled` | +| Removed | `on_click`, `on_changed`, `on_release`, `on_key`, `on_line_hover`, `on_line_click`, `disconnect()`, `registered_keys` | From 40a56d0b511d90f78c4bb92ed667bff9c151a171 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Thu, 14 May 2026 11:30:17 -0500 Subject: [PATCH 120/198] docs: add event system implementation plan --- .../plans/2026-05-14-event-system.md | 2352 +++++++++++++++++ 1 file changed, 2352 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-14-event-system.md diff --git a/docs/superpowers/plans/2026-05-14-event-system.md b/docs/superpowers/plans/2026-05-14-event-system.md new file mode 100644 index 00000000..d8b1774d --- /dev/null +++ b/docs/superpowers/plans/2026-05-14-event-system.md @@ -0,0 +1,2352 @@ +# Event System Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the existing `on_click`/`on_changed`/`on_release`/`on_key` event system with pygfx-aligned `pointer_*`/`key_*` events, a flat `Event` dataclass, multi-type/wildcard/priority registration, `pause_events`/`hold_events` context managers, and `pointer_settled` with per-panel JS timer. + +**Architecture:** Python-first — rewrite `CallbackRegistry` and `Event` in `callbacks.py`, add `_EventMixin` for the user-facing API, then update all plot/widget classes to inherit it. JS changes forward new event types and add the `pointer_settled` dwell timer. All old decorator methods (`on_click`, `on_changed`, etc.) are removed. + +**Tech Stack:** Python 3.10+, dataclasses, contextlib, anywidget traitlets, Playwright for browser tests, pytest. + +**Spec:** `docs/superpowers/specs/2026-05-14-event-system-design.md` + +--- + +## File Map + +**Modified:** +- `anyplotlib/callbacks.py` — rewrite `Event`, `CallbackRegistry`; add `_EventMixin` +- `anyplotlib/figure/_figure.py` — update `_dispatch_event` field mapping; add `import time` +- `anyplotlib/plot1d/_plot1d.py` — inherit `_EventMixin`, remove old decorators, update `Line1D` +- `anyplotlib/plot2d/_plot2d.py` — same pattern +- `anyplotlib/plot2d/_plotmesh.py` — same pattern (inherits Plot2D, may need minimal changes) +- `anyplotlib/plot3d/_plot3d.py` — same pattern + `ray` field in state +- `anyplotlib/plot1d/_plotbar.py` — same pattern + updated pointer_down payload +- `anyplotlib/widgets/_base.py` — inherit `_EventMixin`, remove old decorators, update `_update_from_js` +- `anyplotlib/figure_esm.js` — forward new event types, add fields, pointer_settled timer, remove registered_keys + +**Replaced:** +- `anyplotlib/tests/test_interactive/test_callbacks.py` — full rewrite for new API + +**Created:** +- `anyplotlib/tests/test_interactive/test_event_plots.py` — Playwright per-plot-type matrix +- `anyplotlib/tests/test_interactive/test_event_settled.py` — pointer_settled Playwright tests +- `anyplotlib/tests/test_interactive/test_event_pause_hold.py` — pause/hold Playwright tests + +--- + +## Task 1: Rewrite `Event` dataclass + +Flatten `Event` — all payload fields become top-level typed attributes instead of a `data` dict with `__getattr__` proxy. + +**Files:** +- Modify: `anyplotlib/callbacks.py` +- Modify: `anyplotlib/tests/test_interactive/test_callbacks.py` + +- [ ] **Step 1: Write the failing tests** + +Replace the top of `anyplotlib/tests/test_interactive/test_callbacks.py` with: + +```python +"""Tests for the redesigned Event dataclass and CallbackRegistry.""" +from __future__ import annotations +import time +import pytest +from anyplotlib.callbacks import Event, CallbackRegistry, VALID_EVENT_TYPES + + +# ── Event dataclass ─────────────────────────────────────────────────────────── + +class TestEvent: + def test_required_fields(self): + e = Event(event_type="pointer_down", source=None) + assert e.event_type == "pointer_down" + assert e.source is None + + def test_time_stamp_auto_set(self): + before = time.perf_counter() + e = Event(event_type="pointer_down") + after = time.perf_counter() + assert before <= e.time_stamp <= after + + def test_modifiers_default_empty_list(self): + e = Event(event_type="pointer_move") + assert e.modifiers == [] + assert isinstance(e.modifiers, list) + + def test_pointer_fields_default_none(self): + e = Event(event_type="pointer_move") + assert e.x is None + assert e.y is None + assert e.button is None + assert e.buttons == 0 + assert e.xdata is None + assert e.ydata is None + assert e.ray is None + assert e.line_id is None + assert e.dwell_ms is None + + def test_wheel_fields_default_none(self): + e = Event(event_type="wheel") + assert e.dx is None + assert e.dy is None + + def test_key_field_default_none(self): + e = Event(event_type="key_down") + assert e.key is None + + def test_bar_fields_default_none(self): + e = Event(event_type="pointer_down") + assert e.bar_index is None + assert e.value is None + assert e.x_label is None + assert e.group_index is None + + def test_stop_propagation_default_false(self): + e = Event(event_type="pointer_down") + assert e.stop_propagation is False + + def test_all_fields_settable(self): + e = Event( + event_type="pointer_down", + source="plot", + modifiers=["ctrl", "shift"], + x=100, y=200, + button=0, buttons=1, + xdata=3.14, ydata=2.71, + line_id="abc12345", + bar_index=2, value=99.5, x_label="Jan", group_index=1, + dx=10.0, dy=-5.0, + key="q", + ) + assert e.modifiers == ["ctrl", "shift"] + assert e.x == 100 + assert e.xdata == 3.14 + assert e.line_id == "abc12345" + assert e.bar_index == 2 + assert e.key == "q" + + def test_no_data_dict_attribute(self): + e = Event(event_type="pointer_move") + assert not hasattr(e, "data") + + def test_repr_includes_event_type(self): + e = Event(event_type="pointer_down", x=10, y=20) + assert "pointer_down" in repr(e) +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py::TestEvent -v +``` +Expected: FAIL — `Event` still has `data` field, `time_stamp` not auto-set, etc. + +- [ ] **Step 3: Rewrite `Event` in `callbacks.py`** + +Replace the entire `callbacks.py` with: + +```python +""" +callbacks.py +============ + +Event system used by all plot objects and widgets. + +:class:`Event` + Flat dataclass carrying all event fields as typed top-level attributes. + +:class:`CallbackRegistry` + Per-object handler store with multi-type, wildcard, priority, pause, and hold support. + +:class:`_EventMixin` + Mixin added to every plot class and widget exposing ``add_event_handler`` / + ``remove_handler`` / ``pause_events`` / ``hold_events``. +""" +from __future__ import annotations + +import time +from collections import defaultdict, deque +from contextlib import contextmanager +from dataclasses import dataclass, field +from typing import Any, Callable + +VALID_EVENT_TYPES = frozenset({ + "pointer_down", "pointer_up", "pointer_move", "pointer_settled", + "pointer_enter", "pointer_leave", "double_click", "wheel", + "key_down", "key_up", "*", +}) + + +@dataclass +class Event: + """A single interactive event with all payload fields as typed attributes. + + Universal fields (every event): + event_type, source, time_stamp, modifiers + + Pointer fields (pointer_* and double_click events): + x, y — pixel coordinates within the panel + button — 0=left 1=middle 2=right; None on move/enter/leave/settled + buttons — bitmask of currently held buttons + xdata, ydata — data-space coordinates (None for Plot3D) + ray — Plot3D only: {"origin": [...], "direction": [...]} + line_id — Plot1D only: set when pointer is over a line + dwell_ms — pointer_settled only: actual dwell time + + PlotBar extra fields (pointer_down only): + bar_index, value, x_label, group_index + + Wheel fields: + dx, dy — scroll deltas + + Key fields: + key — key name e.g. "q", "Enter", "ArrowLeft" + + Propagation: + stop_propagation — set True inside a handler to halt remaining handlers + """ + event_type: str + source: Any = None + time_stamp: float = field(default_factory=time.perf_counter) + modifiers: list[str] = field(default_factory=list) + # Pointer + x: int | None = None + y: int | None = None + button: int | None = None + buttons: int = 0 + xdata: float | None = None + ydata: float | None = None + ray: dict | None = None + line_id: str | None = None + dwell_ms: float | None = None + # PlotBar + bar_index: int | None = None + value: float | None = None + x_label: str | None = None + group_index: int | None = None + # Wheel + dx: float | None = None + dy: float | None = None + # Key + key: str | None = None + # Propagation (not repr'd) + stop_propagation: bool = field(default=False, repr=False) + + def __repr__(self) -> str: + src = type(self.source).__name__ if self.source is not None else "None" + parts = [f"event_type={self.event_type!r}", f"source={src}"] + for fname in ("x", "y", "xdata", "ydata", "button", "key", + "line_id", "bar_index", "dwell_ms"): + v = getattr(self, fname) + if v is not None: + parts.append(f"{fname}={v!r}") + if self.modifiers: + parts.append(f"modifiers={self.modifiers!r}") + return "Event(" + ", ".join(parts) + ")" +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +```bash +uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py::TestEvent -v +``` +Expected: All 11 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add anyplotlib/callbacks.py anyplotlib/tests/test_interactive/test_callbacks.py +git commit -m "refactor: flatten Event dataclass — all payload fields are typed top-level attrs" +``` + +--- + +## Task 2: Rewrite `CallbackRegistry` + +Replace the simple `_entries` dict with a per-type handler list supporting priority ordering, wildcard `"*"`, multi-type registration, and `stop_propagation`. + +**Files:** +- Modify: `anyplotlib/callbacks.py` (append to Task 1 file) +- Modify: `anyplotlib/tests/test_interactive/test_callbacks.py` + +- [ ] **Step 1: Write failing tests — append to test file** + +```python +class TestCallbackRegistry: + def test_connect_returns_int_cid(self): + reg = CallbackRegistry() + cid = reg.connect("pointer_down", lambda e: None) + assert isinstance(cid, int) + + def test_fire_calls_handler(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_down", lambda e: calls.append(e.event_type)) + reg.fire(Event("pointer_down")) + assert calls == ["pointer_down"] + + def test_fire_only_matching_type(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_down", lambda e: calls.append("down")) + reg.connect("pointer_up", lambda e: calls.append("up")) + reg.fire(Event("pointer_down")) + assert calls == ["down"] + + def test_disconnect_by_cid(self): + reg = CallbackRegistry() + calls = [] + cid = reg.connect("pointer_down", lambda e: calls.append(1)) + reg.disconnect(cid) + reg.fire(Event("pointer_down")) + assert calls == [] + + def test_disconnect_silent_if_not_found(self): + reg = CallbackRegistry() + reg.disconnect(999) # should not raise + + def test_wildcard_receives_all_types(self): + reg = CallbackRegistry() + calls = [] + reg.connect("*", lambda e: calls.append(e.event_type)) + reg.fire(Event("pointer_down")) + reg.fire(Event("key_down")) + reg.fire(Event("wheel")) + assert calls == ["pointer_down", "key_down", "wheel"] + + def test_priority_order(self): + reg = CallbackRegistry() + order = [] + reg.connect("pointer_down", lambda e: order.append("second"), order=1) + reg.connect("pointer_down", lambda e: order.append("first"), order=0) + reg.fire(Event("pointer_down")) + assert order == ["first", "second"] + + def test_same_priority_fires_in_registration_order(self): + reg = CallbackRegistry() + order = [] + reg.connect("pointer_down", lambda e: order.append("a"), order=0) + reg.connect("pointer_down", lambda e: order.append("b"), order=0) + reg.fire(Event("pointer_down")) + assert order == ["a", "b"] + + def test_stop_propagation(self): + reg = CallbackRegistry() + calls = [] + def handler_a(e): + calls.append("a") + e.stop_propagation = True + reg.connect("pointer_down", handler_a, order=0) + reg.connect("pointer_down", lambda e: calls.append("b"), order=1) + reg.fire(Event("pointer_down")) + assert calls == ["a"] + + def test_disconnect_fn_by_reference(self): + reg = CallbackRegistry() + calls = [] + fn = lambda e: calls.append(1) + reg.connect("pointer_down", fn) + reg.disconnect_fn(fn) + reg.fire(Event("pointer_down")) + assert calls == [] + + def test_disconnect_fn_specific_type(self): + reg = CallbackRegistry() + calls = [] + fn = lambda e: calls.append(e.event_type) + reg.connect("pointer_down", fn) + reg.connect("pointer_up", fn) + reg.disconnect_fn(fn, "pointer_down") + reg.fire(Event("pointer_down")) + reg.fire(Event("pointer_up")) + assert calls == ["pointer_up"] + + def test_bool_true_when_handlers_present(self): + reg = CallbackRegistry() + assert not bool(reg) + reg.connect("pointer_down", lambda e: None) + assert bool(reg) + + def test_invalid_event_type_raises(self): + reg = CallbackRegistry() + with pytest.raises(ValueError, match="Invalid event_type"): + reg.connect("on_click", lambda e: None) + + def test_connect_same_fn_multiple_types(self): + reg = CallbackRegistry() + calls = [] + fn = lambda e: calls.append(e.event_type) + reg.connect("pointer_down", fn) + reg.connect("pointer_up", fn) + reg.fire(Event("pointer_down")) + reg.fire(Event("pointer_up")) + assert calls == ["pointer_down", "pointer_up"] +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py::TestCallbackRegistry -v +``` +Expected: Most FAIL — old `CallbackRegistry` doesn't support priority, wildcard, `disconnect_fn`, or new event type names. + +- [ ] **Step 3: Append new `CallbackRegistry` to `callbacks.py`** + +Remove the old `CallbackRegistry` class and replace with: + +```python +class CallbackRegistry: + """Per-object handler store. + + Supports: + - Priority ordering (``order`` kwarg — lower fires first) + - Wildcard ``"*"`` type receives every dispatched event + - ``stop_propagation`` on the event halts remaining handlers + - ``disconnect_fn(fn, *types)`` removes by callback reference + - ``pause_events`` / ``hold_events`` context managers (added in Task 3) + """ + + def __init__(self) -> None: + # {event_type: [(order, cid, fn), ...]} — sorted by order + self._handlers: dict[str, list[tuple[float, int, Callable]]] = defaultdict(list) + self._next_cid: int = 1 + # {cid: set[str]} — which types this cid is registered under + self._cid_map: dict[int, set[str]] = {} + # {id(fn): set[int]} — which cids this fn owns + self._fn_map: dict[int, set[int]] = defaultdict(set) + # pause/hold (populated in Task 3) + self._pause_counts: dict[str, int] = {} + self._hold_counts: dict[str, int] = {} + self._held: deque[Event] = deque() + + # ── registration ───────────────────────────────────────────────────── + + def connect(self, event_type: str, fn: Callable, *, order: float = 0) -> int: + """Register fn for event_type. Returns integer CID.""" + if event_type not in VALID_EVENT_TYPES: + raise ValueError( + f"Invalid event_type {event_type!r}. " + f"Valid types: {sorted(t for t in VALID_EVENT_TYPES if t != '*')} or '*'" + ) + cid = self._next_cid + self._next_cid += 1 + self._handlers[event_type].append((order, cid, fn)) + self._handlers[event_type].sort(key=lambda t: t[0]) + self._cid_map.setdefault(cid, set()).add(event_type) + self._fn_map[id(fn)].add(cid) + return cid + + def disconnect(self, cid: int) -> None: + """Remove handler by CID. Silent if not found.""" + types = self._cid_map.pop(cid, set()) + for et in types: + self._handlers[et] = [ + (o, c, f) for o, c, f in self._handlers[et] if c != cid + ] + for fn_cids in self._fn_map.values(): + fn_cids.discard(cid) + + def disconnect_fn(self, fn: Callable, *types: str) -> None: + """Remove fn from the given types (all types if none given).""" + for cid in list(self._fn_map.get(id(fn), set())): + cid_types = self._cid_map.get(cid, set()) + if not types or cid_types & set(types): + self.disconnect(cid) + + # ── dispatch ───────────────────────────────────────────────────────── + + def fire(self, event: Event) -> None: + """Dispatch event to matching handlers (respects pause/hold).""" + et = event.event_type + if self._pause_counts.get(et, 0) > 0 or self._pause_counts.get("*", 0) > 0: + return + if self._hold_counts.get(et, 0) > 0 or self._hold_counts.get("*", 0) > 0: + self._held.append(event) + return + self._dispatch(event) + + def _dispatch(self, event: Event) -> None: + et = event.event_type + specific = list(self._handlers.get(et, [])) + wildcard = list(self._handlers.get("*", [])) + merged = sorted(specific + wildcard, key=lambda t: t[0]) + for _order, _cid, fn in merged: + if event.stop_propagation: + break + fn(event) + + def _flush(self) -> None: + while self._held: + self._dispatch(self._held.popleft()) + + def __bool__(self) -> bool: + return any(bool(v) for v in self._handlers.values()) +``` + +- [ ] **Step 4: Run tests** + +```bash +uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py::TestCallbackRegistry -v +``` +Expected: All 14 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add anyplotlib/callbacks.py anyplotlib/tests/test_interactive/test_callbacks.py +git commit -m "refactor: rewrite CallbackRegistry with priority, wildcard, disconnect_fn, stop_propagation" +``` + +--- + +## Task 3: Add `pause_events` / `hold_events` to `CallbackRegistry` + +**Files:** +- Modify: `anyplotlib/callbacks.py` (append context managers) +- Modify: `anyplotlib/tests/test_interactive/test_callbacks.py` + +- [ ] **Step 1: Write failing tests — append to test file** + +```python +class TestPauseHold: + def test_pause_drops_events(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_move", lambda e: calls.append(1)) + with reg.pause_events("pointer_move"): + reg.fire(Event("pointer_move")) + assert calls == [] + + def test_pause_handlers_intact_after_exit(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_move", lambda e: calls.append(1)) + with reg.pause_events("pointer_move"): + reg.fire(Event("pointer_move")) + reg.fire(Event("pointer_move")) + assert calls == [1] + + def test_pause_all_types_when_no_args(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_down", lambda e: calls.append("down")) + reg.connect("key_down", lambda e: calls.append("key")) + with reg.pause_events(): + reg.fire(Event("pointer_down")) + reg.fire(Event("key_down")) + assert calls == [] + + def test_pause_only_specified_type(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_move", lambda e: calls.append("move")) + reg.connect("pointer_down", lambda e: calls.append("down")) + with reg.pause_events("pointer_move"): + reg.fire(Event("pointer_move")) + reg.fire(Event("pointer_down")) + assert calls == ["down"] + + def test_pause_nested_same_type(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_move", lambda e: calls.append(1)) + with reg.pause_events("pointer_move"): + with reg.pause_events("pointer_move"): + reg.fire(Event("pointer_move")) + reg.fire(Event("pointer_move")) # still paused — outer not exited + reg.fire(Event("pointer_move")) # now fires + assert calls == [1] + + def test_hold_buffers_and_flushes_on_exit(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_settled", lambda e: calls.append(1)) + with reg.hold_events("pointer_settled"): + reg.fire(Event("pointer_settled")) + reg.fire(Event("pointer_settled")) + assert calls == [] # buffered, not fired yet + assert calls == [1, 1] # flushed on exit + + def test_hold_fires_non_held_types_immediately(self): + reg = CallbackRegistry() + move_calls = [] + settled_calls = [] + reg.connect("pointer_move", lambda e: move_calls.append(1)) + reg.connect("pointer_settled", lambda e: settled_calls.append(1)) + with reg.hold_events("pointer_settled"): + reg.fire(Event("pointer_move")) # not held → immediate + reg.fire(Event("pointer_settled")) # held → buffered + assert move_calls == [1] + assert settled_calls == [1] # flushed on exit + + def test_hold_events_in_order(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_settled", lambda e: calls.append(e.x)) + with reg.hold_events(): + reg.fire(Event("pointer_settled", x=1)) + reg.fire(Event("pointer_settled", x=2)) + reg.fire(Event("pointer_settled", x=3)) + assert calls == [1, 2, 3] + + def test_pause_wins_over_hold(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_move", lambda e: calls.append(1)) + with reg.hold_events("pointer_move"): + with reg.pause_events("pointer_move"): + reg.fire(Event("pointer_move")) + assert calls == [] # dropped, not buffered then flushed +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py::TestPauseHold -v +``` +Expected: FAIL — `pause_events`/`hold_events` not yet implemented. + +- [ ] **Step 3: Append context managers to `CallbackRegistry` in `callbacks.py`** + +Add these methods inside the `CallbackRegistry` class (after `_flush`): + +```python + @contextmanager + def pause_events(self, *types: str): + """Suppress events of the given types while inside this context. + All types are paused when called with no arguments. + Pause wins over hold for the same type.""" + target = types if types else ("*",) + for t in target: + self._pause_counts[t] = self._pause_counts.get(t, 0) + 1 + try: + yield + finally: + for t in target: + self._pause_counts[t] -= 1 + if self._pause_counts[t] == 0: + del self._pause_counts[t] + + @contextmanager + def hold_events(self, *types: str): + """Buffer events of the given types; flush when the outermost hold exits. + All types are held when called with no arguments.""" + target = types if types else ("*",) + for t in target: + self._hold_counts[t] = self._hold_counts.get(t, 0) + 1 + try: + yield + finally: + for t in target: + self._hold_counts[t] -= 1 + if self._hold_counts[t] == 0: + del self._hold_counts[t] + if not self._hold_counts: + self._flush() +``` + +- [ ] **Step 4: Run tests** + +```bash +uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py::TestPauseHold -v +``` +Expected: All 9 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add anyplotlib/callbacks.py anyplotlib/tests/test_interactive/test_callbacks.py +git commit -m "feat: add pause_events and hold_events context managers to CallbackRegistry" +``` + +--- + +## Task 4: Add `_EventMixin` to `callbacks.py` + +The mixin provides `add_event_handler`, `remove_handler`, `pause_events`, `hold_events` for every plot and widget. + +**Files:** +- Modify: `anyplotlib/callbacks.py` (append class) +- Modify: `anyplotlib/tests/test_interactive/test_callbacks.py` + +- [ ] **Step 1: Write failing tests — append to test file** + +```python +class _FakePlot(_EventMixin): + """Minimal plot stub for testing _EventMixin.""" + def __init__(self): + self.callbacks = CallbackRegistry() + self._settled_config = (0, 0) + + def _configure_pointer_settled(self, ms: int, delta: float) -> None: + self._settled_config = (ms, delta) + + +class TestEventMixin: + def test_functional_form_single_type(self): + plot = _FakePlot() + calls = [] + fn = lambda e: calls.append(e.event_type) + plot.add_event_handler(fn, "pointer_down") + plot.callbacks.fire(Event("pointer_down")) + assert calls == ["pointer_down"] + + def test_functional_form_multi_type(self): + plot = _FakePlot() + calls = [] + fn = lambda e: calls.append(e.event_type) + plot.add_event_handler(fn, "pointer_down", "pointer_up") + plot.callbacks.fire(Event("pointer_down")) + plot.callbacks.fire(Event("pointer_up")) + assert calls == ["pointer_down", "pointer_up"] + + def test_decorator_form_single_type(self): + plot = _FakePlot() + calls = [] + @plot.add_event_handler("pointer_move") + def handler(e): + calls.append(e.event_type) + plot.callbacks.fire(Event("pointer_move")) + assert calls == ["pointer_move"] + + def test_decorator_form_multi_type(self): + plot = _FakePlot() + calls = [] + @plot.add_event_handler("pointer_down", "key_down") + def handler(e): + calls.append(e.event_type) + plot.callbacks.fire(Event("pointer_down")) + plot.callbacks.fire(Event("key_down")) + assert calls == ["pointer_down", "key_down"] + + def test_wildcard_decorator(self): + plot = _FakePlot() + calls = [] + @plot.add_event_handler("*") + def handler(e): + calls.append(e.event_type) + plot.callbacks.fire(Event("pointer_down")) + plot.callbacks.fire(Event("wheel")) + assert calls == ["pointer_down", "wheel"] + + def test_remove_handler_by_fn(self): + plot = _FakePlot() + calls = [] + fn = lambda e: calls.append(1) + plot.add_event_handler(fn, "pointer_down") + plot.remove_handler(fn) + plot.callbacks.fire(Event("pointer_down")) + assert calls == [] + + def test_remove_handler_by_fn_specific_type(self): + plot = _FakePlot() + calls = [] + fn = lambda e: calls.append(e.event_type) + plot.add_event_handler(fn, "pointer_down", "pointer_up") + plot.remove_handler(fn, "pointer_down") + plot.callbacks.fire(Event("pointer_down")) + plot.callbacks.fire(Event("pointer_up")) + assert calls == ["pointer_up"] + + def test_remove_handler_by_cid(self): + plot = _FakePlot() + calls = [] + cid = plot.callbacks.connect("pointer_down", lambda e: calls.append(1)) + plot.remove_handler(cid) + plot.callbacks.fire(Event("pointer_down")) + assert calls == [] + + def test_pointer_settled_configures_on_connect(self): + plot = _FakePlot() + plot.add_event_handler(lambda e: None, "pointer_settled", ms=400, delta=5) + assert plot._settled_config == (400, 5) + + def test_pointer_settled_clears_on_last_disconnect(self): + plot = _FakePlot() + fn = lambda e: None + plot.add_event_handler(fn, "pointer_settled", ms=400, delta=5) + plot.remove_handler(fn) + assert plot._settled_config == (0, 0) + + def test_ms_delta_without_settled_raises(self): + plot = _FakePlot() + with pytest.raises(ValueError, match="ms/delta"): + plot.add_event_handler(lambda e: None, "pointer_down", ms=400) + + def test_pause_events_delegates_to_registry(self): + plot = _FakePlot() + calls = [] + plot.add_event_handler(lambda e: calls.append(1), "pointer_move") + with plot.pause_events("pointer_move"): + plot.callbacks.fire(Event("pointer_move")) + assert calls == [] + + def test_hold_events_delegates_to_registry(self): + plot = _FakePlot() + calls = [] + plot.add_event_handler(lambda e: calls.append(1), "pointer_settled") + with plot.hold_events("pointer_settled"): + plot.callbacks.fire(Event("pointer_settled")) + assert calls == [] + assert calls == [1] +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py::TestEventMixin -v +``` +Expected: FAIL — `_EventMixin` not yet defined. + +- [ ] **Step 3: Append `_EventMixin` to `callbacks.py`** + +```python +class _EventMixin: + """Mixin for plot classes and widgets. + + Provides ``add_event_handler`` / ``remove_handler`` / ``pause_events`` / + ``hold_events``. The host class must set ``self.callbacks = CallbackRegistry()`` + in its ``__init__``. + """ + + callbacks: CallbackRegistry + + def add_event_handler( + self, + fn_or_type, + *args, + order: float = 0, + ms: int = 300, + delta: float = 4, + ): + """Register an event handler. Works as a direct call or decorator. + + Direct call:: + + plot.add_event_handler(fn, "pointer_down") + plot.add_event_handler(fn, "pointer_down", "pointer_up") + + Decorator:: + + @plot.add_event_handler("pointer_down") + def handler(event): ... + + @plot.add_event_handler("pointer_settled", ms=400, delta=5) + def on_settle(event): ... + + Parameters + ---------- + fn_or_type : callable or str + Handler function (direct call) or first event type string (decorator). + *args : str + Remaining event type strings. + order : float + Priority. Lower fires first. Default 0. + ms : int + ``pointer_settled`` dwell threshold in milliseconds. Default 300. + Raises ``ValueError`` if provided without ``"pointer_settled"`` in types. + delta : float + ``pointer_settled`` pixel radius. Default 4. + Raises ``ValueError`` if provided without ``"pointer_settled"`` in types. + """ + if callable(fn_or_type): + return self._register(fn_or_type, args, order=order, ms=ms, delta=delta) + else: + all_types = (fn_or_type,) + args + def _decorator(fn: Callable) -> Callable: + self._register(fn, all_types, order=order, ms=ms, delta=delta) + return fn + return _decorator + + def _register( + self, fn: Callable, types: tuple, *, order: float, ms: int, delta: float + ) -> Callable: + has_settled = "pointer_settled" in types + _ms_changed = ms != 300 + _delta_changed = delta != 4 + if (_ms_changed or _delta_changed) and not has_settled: + raise ValueError( + "ms/delta kwargs are only valid when 'pointer_settled' is in the event types" + ) + for event_type in types: + self.callbacks.connect(event_type, fn, order=order) + if has_settled: + self._configure_pointer_settled(ms, delta) + fn._event_types = getattr(fn, "_event_types", set()) | set(types) + return fn + + def remove_handler(self, cid_or_fn, *types: str) -> None: + """Remove a registered handler. + + Parameters + ---------- + cid_or_fn : int or callable + CID returned by ``callbacks.connect()`` or the handler function. + *types : str + If given, only remove from these types. If omitted, remove from all. + """ + if isinstance(cid_or_fn, int): + self.callbacks.disconnect(cid_or_fn) + else: + self.callbacks.disconnect_fn(cid_or_fn, *types) + if not self.callbacks._handlers.get("pointer_settled"): + self._configure_pointer_settled(0, 0) + + def _configure_pointer_settled(self, ms: int, delta: float) -> None: + """Override in plot subclasses to push thresholds to JS.""" + pass + + @contextmanager + def pause_events(self, *types: str): + """Suppress events of the given types (all types if none given).""" + with self.callbacks.pause_events(*types): + yield + + @contextmanager + def hold_events(self, *types: str): + """Buffer events of the given types; flush when context exits.""" + with self.callbacks.hold_events(*types): + yield +``` + +Also add `_EventMixin` to the module's `__all__` export and update the top docstring. + +- [ ] **Step 4: Run tests** + +```bash +uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py -v +``` +Expected: All tests in all three test classes PASS. + +- [ ] **Step 5: Commit** + +```bash +git add anyplotlib/callbacks.py anyplotlib/tests/test_interactive/test_callbacks.py +git commit -m "feat: add _EventMixin with add_event_handler, remove_handler, pause/hold_events" +``` + +--- + +## Task 5: Update `_dispatch_event` in Figure and `Widget._update_from_js` + +Map renamed JS fields (`phys_x`→`xdata`, `mouse_x`→`x`) to the flat `Event` constructor. Update widget sync. + +**Files:** +- Modify: `anyplotlib/figure/_figure.py` +- Modify: `anyplotlib/widgets/_base.py` + +- [ ] **Step 1: Add `import time` to `figure/_figure.py`** + +Find the existing imports block (around line 1-10) and add: +```python +import time +``` + +- [ ] **Step 2: Replace `_dispatch_event` in `figure/_figure.py`** + +Find the `_dispatch_event` method (currently lines ~343-397) and replace the body entirely: + +```python +def _dispatch_event(self, raw: str) -> None: + if not raw or raw == "{}": + return + try: + msg = json.loads(raw) + except Exception: + return + if msg.get("source") == "python": + return + + panel_id = msg.get("panel_id", "") + event_type = msg.get("event_type", "pointer_move") + widget_id = msg.get("widget_id") + + # Inset state changes + if event_type == "inset_state_change": + inset_ax = self._insets_map.get(panel_id) + if inset_ax is not None: + new_state = msg.get("new_state", "normal") + if new_state in ("normal", "minimized", "maximized"): + inset_ax._inset_state = new_state + self._push_layout() + return + + plot = self._plots_map.get(panel_id) + if plot is None: + return + + source = None + if widget_id and hasattr(plot, "_widgets"): + widget = plot._widgets.get(widget_id) + if widget is not None: + widget._update_from_js(msg, event_type) + source = widget + + if hasattr(plot, "callbacks"): + event = Event( + event_type=event_type, + source=source, + time_stamp=msg.get("time_stamp", time.perf_counter()), + modifiers=msg.get("modifiers", []), + x=msg.get("x"), + y=msg.get("y"), + button=msg.get("button"), + buttons=msg.get("buttons", 0), + xdata=msg.get("xdata"), + ydata=msg.get("ydata"), + ray=msg.get("ray"), + line_id=msg.get("line_id"), + dwell_ms=msg.get("dwell_ms"), + bar_index=msg.get("bar_index"), + value=msg.get("value"), + x_label=msg.get("x_label"), + group_index=msg.get("group_index"), + dx=msg.get("dx"), + dy=msg.get("dy"), + key=msg.get("key"), + ) + plot.callbacks.fire(event) +``` + +Also update the import at the top of `_figure.py` — find the `from anyplotlib.callbacks import ...` line and make sure `Event` is imported: +```python +from anyplotlib.callbacks import CallbackRegistry, Event +``` + +- [ ] **Step 3: Update `Widget._update_from_js` in `widgets/_base.py`** + +Find `_update_from_js` (currently lines ~223-253) and replace: + +```python +def _update_from_js(self, msg: dict, event_type: str = "pointer_move") -> bool: + """Apply incoming JS state without pushing back (avoids echo). + + Updates widget ``_data`` with widget-specific state fields from JS, + then fires widget callbacks with a flat Event. + + Parameters + ---------- + msg : dict + Full raw event message from JS. + event_type : str + One of the new pointer event types (``pointer_move``, ``pointer_up``, + ``pointer_down``). + + Returns + ------- + bool + True if any widget state changed. + """ + # Fields that belong to the event envelope, not widget state + _envelope = { + "source", "panel_id", "event_type", "widget_id", + "time_stamp", "modifiers", "button", "buttons", + "x", "y", "xdata", "ydata", + } + changed = False + for k, v in msg.items(): + if k in ("id", "type") or k in _envelope: + continue + if self._data.get(k) != v: + self._data[k] = v + changed = True + + # Always fire on press/release; only fire pointer_move when state changed + if changed or event_type in ("pointer_up", "pointer_down"): + event = Event( + event_type=event_type, + source=self, + time_stamp=msg.get("time_stamp", 0.0), + modifiers=msg.get("modifiers", []), + x=msg.get("x"), + y=msg.get("y"), + button=msg.get("button"), + buttons=msg.get("buttons", 0), + xdata=msg.get("xdata"), + ydata=msg.get("ydata"), + ) + self.callbacks.fire(event) + return changed +``` + +Also update the `set` method (line ~97) which currently fires `Event("on_changed", ...)` directly: + +```python +def set(self, _push: bool = True, **kwargs) -> None: + self._data.update(kwargs) + if _push: + self._push_fn() + # Fire pointer_move for programmatic updates + self.callbacks.fire(Event("pointer_move", source=self)) +``` + +- [ ] **Step 4: Run existing Python tests to check nothing broke** + +```bash +uv run pytest anyplotlib/tests/ -v --ignore=anyplotlib/tests/test_interactive -x +``` +Expected: All non-interactive tests PASS (they don't touch event dispatch). + +- [ ] **Step 5: Commit** + +```bash +git add anyplotlib/figure/_figure.py anyplotlib/widgets/_base.py +git commit -m "refactor: update _dispatch_event and Widget._update_from_js to use flat Event fields" +``` + +--- + +## Task 6: Update `Plot1D` and `Line1D` + +Remove `on_changed`/`on_release`/`on_click`/`on_key`/`on_line_hover`/`on_line_click`/`disconnect`/`_connect_on_key`. Inherit `_EventMixin`. Update `Line1D` to expose `add_event_handler` with `line_id` filtering. Remove `registered_keys` from state. + +**Files:** +- Modify: `anyplotlib/plot1d/_plot1d.py` + +- [ ] **Step 1: Update imports in `plot1d/_plot1d.py`** + +Find the imports block and update the callbacks import: +```python +from anyplotlib.callbacks import CallbackRegistry, _EventMixin +``` + +- [ ] **Step 2: Make `Plot1D` inherit `_EventMixin`** + +Find the class definition line: +```python +class Plot1D: +``` +Change to: +```python +class Plot1D(_EventMixin): +``` + +- [ ] **Step 3: Remove `registered_keys` from `_state` in `Plot1D.__init__`** + +Find `"registered_keys": [],` in the `_state` dict initialisation and delete that line. + +- [ ] **Step 4: Add `_configure_pointer_settled` to `Plot1D`** + +After `self.callbacks = CallbackRegistry()` in `__init__`, add to the `_state` dict: +```python +"pointer_settled_ms": 0, +"pointer_settled_delta": 4, +``` + +Add this method to the `Plot1D` class: +```python +def _configure_pointer_settled(self, ms: int, delta: float) -> None: + self._state["pointer_settled_ms"] = ms + self._state["pointer_settled_delta"] = delta + self._push() +``` + +- [ ] **Step 5: Remove old event decorator methods from `Plot1D`** + +Delete these methods entirely (find by name): +- `on_changed` +- `on_release` +- `on_click` +- `on_key` +- `_connect_on_key` +- `on_line_hover` +- `on_line_click` +- `disconnect` + +- [ ] **Step 6: Update `Line1D` event methods** + +Replace `Line1D.on_hover` and `Line1D.on_click` with a single `add_event_handler` that filters by `line_id`: + +```python +def add_event_handler(self, fn_or_type, *args, **kwargs): + """Register a handler scoped to this line only. + + Wraps the plot-level ``pointer_move`` / ``pointer_down`` handler + with a ``line_id`` filter. Only ``pointer_move`` and ``pointer_down`` + are meaningful on a line handle. + + Usage:: + + @line.add_event_handler("pointer_move") + def on_hover(event): + print(event.xdata, event.line_id) + + @line.add_event_handler("pointer_down") + def on_pick(event): + print("picked", event.line_id) + """ + target_lid = self._lid + + if callable(fn_or_type): + fn = fn_or_type + types = args + return self._wrap_and_register(fn, types, target_lid, **kwargs) + else: + all_types = (fn_or_type,) + args + def _decorator(fn): + return self._wrap_and_register(fn, all_types, target_lid, **kwargs) + return _decorator + +def _wrap_and_register(self, fn, types, target_lid, **kwargs): + from functools import wraps + @wraps(fn) + def _filtered(event): + if event.line_id == target_lid: + fn(event) + _filtered.__wrapped__ = fn + return self._plot.add_event_handler(_filtered, *types, **kwargs) + +def remove_handler(self, cid_or_fn, *types): + """Remove a handler registered via this line handle.""" + self._plot.remove_handler(cid_or_fn, *types) +``` + +- [ ] **Step 7: Run tests** + +```bash +uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py anyplotlib/tests/test_plot1d/ -v +``` +Expected: All PASS. If `test_callbacks.py` had tests that used old `on_click` decorator on plots, update those to use `add_event_handler`. + +- [ ] **Step 8: Commit** + +```bash +git add anyplotlib/plot1d/_plot1d.py +git commit -m "refactor: Plot1D and Line1D adopt _EventMixin, remove old on_* decorators and registered_keys" +``` + +--- + +## Task 7: Update `Plot2D` and `PlotMesh` + +Same pattern as Task 6 — inherit `_EventMixin`, remove old decorators, add `_configure_pointer_settled`. + +**Files:** +- Modify: `anyplotlib/plot2d/_plot2d.py` +- Modify: `anyplotlib/plot2d/_plotmesh.py` + +- [ ] **Step 1: In `plot2d/_plot2d.py` — update import, inherit `_EventMixin`** + +```python +from anyplotlib.callbacks import CallbackRegistry, _EventMixin +``` +```python +class Plot2D(_EventMixin): +``` + +- [ ] **Step 2: Remove `registered_keys` from `_state`, add settled config keys** + +Remove `"registered_keys": [],` from the `_state` dict. + +Add to `_state`: +```python +"pointer_settled_ms": 0, +"pointer_settled_delta": 4, +``` + +- [ ] **Step 3: Add `_configure_pointer_settled` to `Plot2D`** + +```python +def _configure_pointer_settled(self, ms: int, delta: float) -> None: + self._state["pointer_settled_ms"] = ms + self._state["pointer_settled_delta"] = delta + self._push() +``` + +- [ ] **Step 4: Remove old event methods from `Plot2D`** + +Delete: `on_changed`, `on_release`, `on_click`, `on_key`, `_connect_on_key`, `disconnect`. + +- [ ] **Step 5: Check `PlotMesh` — it inherits `Plot2D`** + +Open `anyplotlib/plot2d/_plotmesh.py`. If `PlotMesh` also defines any of the removed methods directly, delete them. If it only inherits, no change is needed beyond checking the import line references nothing removed. + +- [ ] **Step 6: Run tests** + +```bash +uv run pytest anyplotlib/tests/test_plot2d/ -v +``` +Expected: All PASS. + +- [ ] **Step 7: Commit** + +```bash +git add anyplotlib/plot2d/_plot2d.py anyplotlib/plot2d/_plotmesh.py +git commit -m "refactor: Plot2D and PlotMesh adopt _EventMixin, remove old on_* decorators" +``` + +--- + +## Task 8: Update `Plot3D` + +Same pattern. Additionally, add `"ray": None` to the `_state` template since Plot3D pointer events carry a `ray` field instead of `xdata`/`ydata`. + +**Files:** +- Modify: `anyplotlib/plot3d/_plot3d.py` + +- [ ] **Step 1: Update import, inherit `_EventMixin`** + +```python +from anyplotlib.callbacks import CallbackRegistry, _EventMixin +``` +```python +class Plot3D(_EventMixin): +``` + +- [ ] **Step 2: Remove `registered_keys`, add settled config** + +Remove `"registered_keys": [],` from `_state`. + +Add: +```python +"pointer_settled_ms": 0, +"pointer_settled_delta": 4, +``` + +- [ ] **Step 3: Add `_configure_pointer_settled`** + +```python +def _configure_pointer_settled(self, ms: int, delta: float) -> None: + self._state["pointer_settled_ms"] = ms + self._state["pointer_settled_delta"] = delta + self._push() +``` + +- [ ] **Step 4: Remove old event methods** + +Delete: `on_changed`, `on_release`, `on_click`, `on_key`, `_connect_on_key`, `disconnect`. + +- [ ] **Step 5: Run tests** + +```bash +uv run pytest anyplotlib/tests/test_plot3d/ -v +``` +Expected: All PASS. + +- [ ] **Step 6: Commit** + +```bash +git add anyplotlib/plot3d/_plot3d.py +git commit -m "refactor: Plot3D adopts _EventMixin, remove old on_* decorators" +``` + +--- + +## Task 9: Update `PlotBar` + +Same pattern. The `pointer_down` event for PlotBar carries `bar_index`, `value`, `x_label`, `group_index` from the JS side — these are already handled by the flat `Event` constructor in `_dispatch_event`, so no extra Python work is needed beyond inheriting the mixin. + +**Files:** +- Modify: `anyplotlib/plot1d/_plotbar.py` + +- [ ] **Step 1: Update import, inherit `_EventMixin`** + +```python +from anyplotlib.callbacks import CallbackRegistry, _EventMixin +``` +```python +class PlotBar(_EventMixin): +``` + +- [ ] **Step 2: Remove `registered_keys`, add settled config** + +Remove `"registered_keys": [],` from `_state`. + +Add: +```python +"pointer_settled_ms": 0, +"pointer_settled_delta": 4, +``` + +- [ ] **Step 3: Add `_configure_pointer_settled`** + +```python +def _configure_pointer_settled(self, ms: int, delta: float) -> None: + self._state["pointer_settled_ms"] = ms + self._state["pointer_settled_delta"] = delta + self._push() +``` + +- [ ] **Step 4: Remove old event methods** + +Delete: `on_click`, `on_changed`, `on_release`, `on_key`, `_connect_on_key`, `disconnect`. + +- [ ] **Step 5: Run tests** + +```bash +uv run pytest anyplotlib/tests/test_plot1d/test_plotbar.py -v +``` +Expected: All PASS. + +- [ ] **Step 6: Commit** + +```bash +git add anyplotlib/plot1d/_plotbar.py +git commit -m "refactor: PlotBar adopts _EventMixin, remove old on_* decorators" +``` + +--- + +## Task 10: Update `Widget` base class + +Replace `on_changed`/`on_release`/`on_click`/`disconnect` with `_EventMixin`. The `_update_from_js` was already updated in Task 5. + +**Files:** +- Modify: `anyplotlib/widgets/_base.py` + +- [ ] **Step 1: Update import** + +```python +from anyplotlib.callbacks import CallbackRegistry, Event, _EventMixin +``` + +- [ ] **Step 2: Inherit `_EventMixin`** + +```python +class Widget(_EventMixin): +``` + +- [ ] **Step 3: Remove old decorator methods** + +Delete: `on_changed`, `on_release`, `on_click`, `disconnect`. + +The `callbacks` attribute is already set in `__init__` — `_EventMixin` will find it. + +- [ ] **Step 4: Run tests** + +```bash +uv run pytest anyplotlib/tests/test_interactive/ -v -k "widget" +``` +Expected: All widget tests PASS. + +- [ ] **Step 5: Run full Python test suite** + +```bash +uv run pytest anyplotlib/tests/ -v --ignore=anyplotlib/tests/test_interactive/test_event_plots.py \ + --ignore=anyplotlib/tests/test_interactive/test_event_settled.py \ + --ignore=anyplotlib/tests/test_interactive/test_event_pause_hold.py +``` +Expected: All PASS. + +- [ ] **Step 6: Commit** + +```bash +git add anyplotlib/widgets/_base.py +git commit -m "refactor: Widget adopts _EventMixin, remove old on_changed/on_release/on_click/disconnect" +``` + +--- + +## Task 11: JS — Forward new event types and fields + +Add the six missing event types to `figure_esm.js` and add `modifiers`, `buttons`, `button`, `time_stamp` to all emitted events. + +**Files:** +- Modify: `anyplotlib/figure_esm.js` + +This file is ~4000 lines. Search for existing mouse/key event listeners to find the right locations. + +- [ ] **Step 1: Find existing event emission sites** + +```bash +grep -n "mousedown\|mouseup\|mousemove\|keydown\|keyup\|wheel\|dblclick\|mouseenter\|mouseleave\|event_json\|event_type" anyplotlib/figure_esm.js | head -40 +``` +Note the line numbers for: mouse event listeners, the function that sends events to Python, key event handling. + +- [ ] **Step 2: Add a helper to extract common fields** + +Find where JS sends events to Python (the function that writes to `event_json`). Add a helper function near the top of the event-handling section: + +```javascript +function _pointerFields(e, panelId) { + return { + time_stamp: performance.now() / 1000, // seconds, matching perf_counter() + modifiers: _modifiers(e), + button: e.button ?? null, + buttons: e.buttons ?? 0, + }; +} + +function _modifiers(e) { + const mods = []; + if (e.ctrlKey) mods.push("ctrl"); + if (e.shiftKey) mods.push("shift"); + if (e.altKey) mods.push("alt"); + if (e.metaKey) mods.push("meta"); + return mods; +} +``` + +- [ ] **Step 3: Rename outgoing `event_type` values** + +Find all places the JS emits `event_type: "on_click"`, `"on_changed"`, `"on_release"`, `"on_key"`, `"on_line_hover"`, `"on_line_click"` and replace: + +| Old JS `event_type` | New JS `event_type` | +|---------------------|---------------------| +| `"on_click"` | `"pointer_down"` | +| `"on_changed"` | `"pointer_move"` | +| `"on_release"` | `"pointer_settled"` | +| `"on_key"` | `"key_down"` | +| `"on_line_hover"` | `"pointer_move"` (with `line_id` field already set) | +| `"on_line_click"` | `"pointer_down"` (with `line_id` field already set) | +| `"on_inset_state_change"` | `"inset_state_change"` | + +- [ ] **Step 4: Rename outgoing payload field names** + +In all JS event payloads, rename: +- `phys_x` → `xdata` +- `phys_y` → `ydata` +- `mouse_x` → `x` +- `mouse_y` → `y` + +```bash +grep -n "phys_x\|phys_y\|mouse_x\|mouse_y" anyplotlib/figure_esm.js +``` +Replace every occurrence. + +- [ ] **Step 5: Add `_pointerFields` to every emitted pointer event** + +For every place the JS calls the send-to-Python function with a pointer event, spread `_pointerFields(e, panelId)` into the payload: + +```javascript +// Before (example): +sendEvent({ event_type: "pointer_down", panel_id: panelId, x: px, y: py }); + +// After: +sendEvent({ event_type: "pointer_down", panel_id: panelId, + ..._pointerFields(e, panelId), x: px, y: py }); +``` + +- [ ] **Step 6: Add listener for `pointer_up` (mouseup)** + +Find the `mousedown` listener and add a `mouseup` listener alongside it: + +```javascript +canvas.addEventListener("mouseup", (e) => { + sendEvent({ + event_type: "pointer_up", + panel_id: panelId, + ..._pointerFields(e, panelId), + x: /* pixel x relative to canvas */, + y: /* pixel y relative to canvas */, + xdata: /* data coord x or null */, + ydata: /* data coord y or null */, + }); +}); +``` + +- [ ] **Step 7: Add `pointer_enter` / `pointer_leave` listeners** + +```javascript +canvas.addEventListener("mouseenter", (e) => { + sendEvent({ event_type: "pointer_enter", panel_id: panelId, + ..._pointerFields(e, panelId), x: /*px*/, y: /*py*/ }); +}); +canvas.addEventListener("mouseleave", (e) => { + sendEvent({ event_type: "pointer_leave", panel_id: panelId, + ..._pointerFields(e, panelId), x: /*px*/, y: /*py*/ }); +}); +``` + +Note: `button` is `null` on enter/leave events (no button triggered the event). `buttons` reflects currently-held buttons. + +- [ ] **Step 8: Add `double_click` listener** + +```javascript +canvas.addEventListener("dblclick", (e) => { + sendEvent({ event_type: "double_click", panel_id: panelId, + ..._pointerFields(e, panelId), x: /*px*/, y: /*py*/, + xdata: /*or null*/, ydata: /*or null*/ }); +}); +``` + +- [ ] **Step 9: Add `wheel` listener** + +```javascript +canvas.addEventListener("wheel", (e) => { + e.preventDefault(); + sendEvent({ event_type: "wheel", panel_id: panelId, + time_stamp: performance.now() / 1000, + modifiers: _modifiers(e), + x: /*px*/, y: /*py*/, + dx: e.deltaX, dy: e.deltaY }); +}, { passive: false }); +``` + +- [ ] **Step 10: Add `key_up` listener** + +Find the existing `keydown` listener and add `keyup` alongside: + +```javascript +document.addEventListener("keyup", (e) => { + if (!panelFocused) return; + sendEvent({ event_type: "key_up", panel_id: panelId, + time_stamp: performance.now() / 1000, + modifiers: _modifiers(e), + key: e.key, x: lastPointerX, y: lastPointerY }); +}); +``` + +- [ ] **Step 11: Remove `registered_keys` filtering from JS** + +Find the section that checks `registered_keys` before forwarding key events (something like `if (state.registered_keys.includes(e.key) || ...)`). Remove this guard — forward all key events unconditionally. + +- [ ] **Step 12: Run the full pure-Python test suite to confirm no regressions** + +```bash +uv run pytest anyplotlib/tests/ -v -k "not test_event_plots and not test_event_settled and not test_event_pause_hold" +``` +Expected: All PASS. + +- [ ] **Step 13: Commit** + +```bash +git add anyplotlib/figure_esm.js +git commit -m "feat: JS forwards pointer_up, pointer_enter/leave, double_click, wheel, key_up; rename event fields to xdata/ydata/x/y; add modifiers/button/buttons/time_stamp" +``` + +--- + +## Task 12: JS — `pointer_settled` dwell timer + +Add a per-panel dwell timer that fires `pointer_settled` after the pointer holds still for the configured ms/delta thresholds. + +**Files:** +- Modify: `anyplotlib/figure_esm.js` + +- [ ] **Step 1: Add timer state per panel** + +Near the per-panel state initialisation, add: + +```javascript +let _settledTimer = null; +let _settledStartX = 0; +let _settledStartY = 0; +let _settledStartTs = 0; +``` + +- [ ] **Step 2: Add `pointer_settled` trigger inside the `pointer_move` handler** + +Inside the `mousemove` / `pointer_move` emission block, after emitting `pointer_move`, add: + +```javascript +// pointer_settled dwell timer +const settledMs = panelState.pointer_settled_ms ?? 0; +const settledDelta = panelState.pointer_settled_delta ?? 4; +if (settledMs > 0) { + clearTimeout(_settledTimer); + const nowX = currentPixelX; + const nowY = currentPixelY; + const nowTs = performance.now(); + _settledStartX = nowX; + _settledStartY = nowY; + _settledStartTs = nowTs; + _settledTimer = setTimeout(() => { + const dist = Math.hypot(currentPixelX - _settledStartX, + currentPixelY - _settledStartY); + if (dist <= settledDelta) { + const dwellMs = performance.now() - _settledStartTs; + sendEvent({ + event_type: "pointer_settled", + panel_id: panelId, + time_stamp: performance.now() / 1000, + modifiers: lastModifiers, + buttons: lastButtons, + button: null, + x: currentPixelX, + y: currentPixelY, + xdata: currentDataX ?? null, + ydata: currentDataY ?? null, + dwell_ms: dwellMs, + }); + } + }, settledMs); +} +``` + +Where `currentPixelX`, `currentPixelY`, `currentDataX`, `currentDataY`, `lastModifiers`, `lastButtons` are variables already tracked by the mousemove handler. + +- [ ] **Step 3: Cancel timer on `mouseup` and `mouseleave`** + +Inside the `mouseup` and `mouseleave` handlers, add: +```javascript +clearTimeout(_settledTimer); +_settledTimer = null; +``` + +- [ ] **Step 4: Commit** + +```bash +git add anyplotlib/figure_esm.js +git commit -m "feat: add pointer_settled dwell timer to JS with zero cost when unused" +``` + +--- + +## Task 13: Playwright tests — pointer events per plot type + +**Files:** +- Create: `anyplotlib/tests/test_interactive/test_event_plots.py` + +- [ ] **Step 1: Create the test file** + +```python +""" +Playwright tests for pointer/key events across all plot types. +Each plot type gets: pointer_down, pointer_up, pointer_move, pointer_enter, +pointer_leave, double_click, wheel, key_down, key_up, modifiers. +""" +from __future__ import annotations +import json +import numpy as np +import pytest +import anyplotlib as apl + + +# ── helpers ────────────────────────────────────────────────────────────────── + +def _collect(page, fig, event_type): + """Return a list of event dicts received for event_type.""" + page.evaluate(f""" + window._evts_{event_type} = []; + window._aplModel.on("{event_type}", (e) => {{ + window._evts_{event_type}.push(e); + }}); + """) + return page.evaluate(f"window._evts_{event_type}") + + +def _plot1d_fig(): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.zeros(100)) + return fig + + +def _plot2d_fig(): + fig, ax = apl.subplots(1, 1, figsize=(400, 400)) + ax.imshow(np.zeros((64, 64))) + return fig + + +def _plot3d_fig(): + x = np.linspace(-2, 2, 20) + y = np.linspace(-2, 2, 20) + XX, YY = np.meshgrid(x, y) + fig, ax = apl.subplots(1, 1, figsize=(400, 400)) + ax.plot_surface(XX, YY, np.zeros_like(XX)) + return fig + + +def _plotbar_fig(): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.bar(["A", "B", "C"], [1.0, 2.0, 3.0]) + return fig + + +# ── pointer_down ───────────────────────────────────────────────────────────── + +class TestPointerDown: + def test_plot1d_pointer_down_fields(self, interact_page): + fig = _plot1d_fig() + page = interact_page(fig) + received = [] + page.expose_function("_on_pd", lambda e: received.append(json.loads(e))) + page.evaluate(""" + window._aplModel && window._aplModel.on && + window._aplModel.on("pointer_down", e => window._on_pd(JSON.stringify(e))) + """) + page.mouse.click(200, 150) + page.wait_for_timeout(200) + assert len(received) >= 1 + e = received[0] + assert e["event_type"] == "pointer_down" + assert isinstance(e["x"], (int, float)) + assert isinstance(e["y"], (int, float)) + assert e["button"] == 0 + assert e["buttons"] == 0 # buttons=0 after release + assert isinstance(e["modifiers"], list) + assert isinstance(e["time_stamp"], (int, float)) + + def test_plot2d_pointer_down_has_xdata_ydata(self, interact_page): + fig = _plot2d_fig() + page = interact_page(fig) + received = [] + page.expose_function("_on_pd2", lambda e: received.append(json.loads(e))) + page.evaluate( + "window._aplModel.on('pointer_down', e => window._on_pd2(JSON.stringify(e)))" + ) + page.mouse.click(200, 200) + page.wait_for_timeout(200) + assert len(received) >= 1 + e = received[0] + assert e.get("xdata") is not None + assert e.get("ydata") is not None + + def test_plot3d_pointer_down_no_xdata(self, interact_page): + fig = _plot3d_fig() + page = interact_page(fig) + received = [] + page.expose_function("_on_pd3", lambda e: received.append(json.loads(e))) + page.evaluate( + "window._aplModel.on('pointer_down', e => window._on_pd3(JSON.stringify(e)))" + ) + page.mouse.click(200, 200) + page.wait_for_timeout(200) + assert len(received) >= 1 + e = received[0] + assert e.get("xdata") is None + assert e.get("ydata") is None + + def test_ctrl_click_modifiers(self, interact_page): + fig = _plot1d_fig() + page = interact_page(fig) + received = [] + page.expose_function("_on_ctrl", lambda e: received.append(json.loads(e))) + page.evaluate( + "window._aplModel.on('pointer_down', e => window._on_ctrl(JSON.stringify(e)))" + ) + page.keyboard.down("Control") + page.mouse.click(200, 150) + page.keyboard.up("Control") + page.wait_for_timeout(200) + assert any("ctrl" in e.get("modifiers", []) for e in received) + + +# ── pointer_up ──────────────────────────────────────────────────────────────── + +class TestPointerUp: + def test_fires_after_drag(self, interact_page): + fig = _plot1d_fig() + page = interact_page(fig) + received = [] + page.expose_function("_on_pu", lambda e: received.append(json.loads(e))) + page.evaluate( + "window._aplModel.on('pointer_up', e => window._on_pu(JSON.stringify(e)))" + ) + page.mouse.move(200, 150) + page.mouse.down() + page.mouse.move(150, 150, steps=5) + page.mouse.up() + page.wait_for_timeout(200) + assert len(received) >= 1 + e = received[-1] + assert e["event_type"] == "pointer_up" + assert e["button"] == 0 + + +# ── pointer_move ────────────────────────────────────────────────────────────── + +class TestPointerMove: + def test_fires_during_drag(self, interact_page): + fig = _plot1d_fig() + page = interact_page(fig) + received = [] + page.expose_function("_on_pm", lambda e: received.append(json.loads(e))) + page.evaluate( + "window._aplModel.on('pointer_move', e => window._on_pm(JSON.stringify(e)))" + ) + page.mouse.move(200, 150) + page.mouse.down() + page.mouse.move(100, 150, steps=10) + page.mouse.up() + page.wait_for_timeout(300) + assert len(received) >= 5 # multiple frames during drag + + +# ── pointer_enter / pointer_leave ───────────────────────────────────────────── + +class TestPointerEnterLeave: + def test_enter_fires_on_mouse_enter(self, interact_page): + fig = _plot1d_fig() + page = interact_page(fig) + received = [] + page.expose_function("_on_pe", lambda e: received.append(json.loads(e))) + page.evaluate( + "window._aplModel.on('pointer_enter', e => window._on_pe(JSON.stringify(e)))" + ) + # Move from outside the widget to inside + page.mouse.move(0, 0) + page.mouse.move(200, 150) + page.wait_for_timeout(200) + assert len(received) >= 1 + assert received[0]["event_type"] == "pointer_enter" + assert received[0].get("button") is None # button is None on enter + assert isinstance(received[0]["buttons"], int) + + def test_leave_fires_on_mouse_leave(self, interact_page): + fig = _plot1d_fig() + page = interact_page(fig) + received = [] + page.expose_function("_on_pl", lambda e: received.append(json.loads(e))) + page.evaluate( + "window._aplModel.on('pointer_leave', e => window._on_pl(JSON.stringify(e)))" + ) + page.mouse.move(200, 150) + page.mouse.move(0, 0) + page.wait_for_timeout(200) + assert len(received) >= 1 + assert received[0]["event_type"] == "pointer_leave" + + +# ── double_click ────────────────────────────────────────────────────────────── + +class TestDoubleClick: + def test_fires_on_dblclick(self, interact_page): + fig = _plot1d_fig() + page = interact_page(fig) + received = [] + page.expose_function("_on_dc", lambda e: received.append(json.loads(e))) + page.evaluate( + "window._aplModel.on('double_click', e => window._on_dc(JSON.stringify(e)))" + ) + page.mouse.dblclick(200, 150) + page.wait_for_timeout(200) + assert len(received) >= 1 + assert received[0]["event_type"] == "double_click" + assert received[0]["button"] == 0 + + +# ── wheel ───────────────────────────────────────────────────────────────────── + +class TestWheel: + def test_fires_on_scroll(self, interact_page): + fig = _plot2d_fig() + page = interact_page(fig) + received = [] + page.expose_function("_on_wh", lambda e: received.append(json.loads(e))) + page.evaluate( + "window._aplModel.on('wheel', e => window._on_wh(JSON.stringify(e)))" + ) + page.mouse.move(200, 200) + page.mouse.wheel(0, 100) + page.wait_for_timeout(200) + assert len(received) >= 1 + e = received[0] + assert e["event_type"] == "wheel" + assert e.get("dy") is not None + + +# ── key_down / key_up ───────────────────────────────────────────────────────── + +class TestKeyEvents: + def test_key_down_fires_any_key(self, interact_page): + fig = _plot1d_fig() + page = interact_page(fig) + received = [] + page.expose_function("_on_kd", lambda e: received.append(json.loads(e))) + page.evaluate( + "window._aplModel.on('key_down', e => window._on_kd(JSON.stringify(e)))" + ) + page.mouse.move(200, 150) # focus the panel + page.keyboard.press("r") + page.wait_for_timeout(200) + assert any(e["key"] == "r" for e in received) + + def test_key_up_fires(self, interact_page): + fig = _plot1d_fig() + page = interact_page(fig) + received = [] + page.expose_function("_on_ku", lambda e: received.append(json.loads(e))) + page.evaluate( + "window._aplModel.on('key_up', e => window._on_ku(JSON.stringify(e)))" + ) + page.mouse.move(200, 150) + page.keyboard.down("q") + page.keyboard.up("q") + page.wait_for_timeout(200) + assert any(e["key"] == "q" for e in received) +``` + +- [ ] **Step 2: Run the new tests** + +```bash +uv run pytest anyplotlib/tests/test_interactive/test_event_plots.py -v +``` +Expected: All PASS. Fix any failures by adjusting pixel coordinates or widget locators to match your actual panel layout. + +- [ ] **Step 3: Commit** + +```bash +git add anyplotlib/tests/test_interactive/test_event_plots.py +git commit -m "test: add Playwright tests for pointer_down/up/move, enter/leave, double_click, wheel, key_down/up" +``` + +--- + +## Task 14: Playwright tests — `pointer_settled` + +**Files:** +- Create: `anyplotlib/tests/test_interactive/test_event_settled.py` + +- [ ] **Step 1: Create the test file** + +```python +"""Tests for pointer_settled dwell timer — JS computes, Python receives.""" +from __future__ import annotations +import json +import numpy as np +import pytest +import anyplotlib as apl +from anyplotlib.callbacks import Event + + +# ── Python-side: _configure_pointer_settled ─────────────────────────────────── + +class TestSettledConfig: + def test_state_set_on_first_connect(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((32, 32))) + assert plot._state["pointer_settled_ms"] == 0 + assert plot._state["pointer_settled_delta"] == 4 + + plot.add_event_handler(lambda e: None, "pointer_settled", ms=400, delta=5) + assert plot._state["pointer_settled_ms"] == 400 + assert plot._state["pointer_settled_delta"] == 5 + + def test_state_cleared_on_last_disconnect(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((32, 32))) + fn = lambda e: None + plot.add_event_handler(fn, "pointer_settled", ms=400, delta=5) + plot.remove_handler(fn) + assert plot._state["pointer_settled_ms"] == 0 + + def test_two_handlers_keep_last_config(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((32, 32))) + fn1 = lambda e: None + fn2 = lambda e: None + plot.add_event_handler(fn1, "pointer_settled", ms=200, delta=3) + plot.add_event_handler(fn2, "pointer_settled", ms=800, delta=6) + # Last connect wins — ms=800, delta=6 + assert plot._state["pointer_settled_ms"] == 800 + assert plot._state["pointer_settled_delta"] == 6 + # Remove fn2 — config clears only when NO handlers remain + plot.remove_handler(fn2) + # fn1 still connected → ms stays at 800 (fn1's config is remembered by registry) + assert plot._state["pointer_settled_ms"] > 0 + + +# ── Playwright: dwell timer ─────────────────────────────────────────────────── + +class TestSettledPlaywright: + def test_fires_after_hold(self, interact_page): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((64, 64))) + # Configure a short dwell (200ms) for fast tests + plot.add_event_handler(lambda e: None, "pointer_settled", ms=200, delta=4) + + page = interact_page(fig) + received = [] + page.expose_function("_on_st", lambda e: received.append(json.loads(e))) + page.evaluate( + "window._aplModel.on('pointer_settled', e => window._on_st(JSON.stringify(e)))" + ) + + # Move into panel and hold still + page.mouse.move(200, 150) + page.wait_for_timeout(400) # well past the 200ms threshold + + assert len(received) >= 1 + e = received[0] + assert e["event_type"] == "pointer_settled" + assert e["dwell_ms"] >= 200 + + def test_does_not_fire_if_moving(self, interact_page): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((64, 64))) + plot.add_event_handler(lambda e: None, "pointer_settled", ms=300, delta=4) + + page = interact_page(fig) + received = [] + page.expose_function("_on_st2", lambda e: received.append(json.loads(e))) + page.evaluate( + "window._aplModel.on('pointer_settled', e => window._on_st2(JSON.stringify(e)))" + ) + + # Keep moving — should never settle + page.mouse.move(100, 150) + page.mouse.move(150, 150, steps=5) + page.mouse.move(200, 150, steps=5) + page.mouse.move(250, 150, steps=5) + page.wait_for_timeout(100) + + assert received == [] + + def test_no_timer_when_no_handler_connected(self, interact_page): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((64, 64))) + # No pointer_settled handler connected — pointer_settled_ms stays 0 + + page = interact_page(fig) + # Confirm JS state has no timer configured + settled_ms = page.evaluate( + f"JSON.parse(window._aplModel.get('panel_{plot._id}_json')).pointer_settled_ms" + ) + assert settled_ms == 0 + + def test_fires_again_after_re_settle(self, interact_page): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((64, 64))) + plot.add_event_handler(lambda e: None, "pointer_settled", ms=200, delta=4) + + page = interact_page(fig) + received = [] + page.expose_function("_on_st3", lambda e: received.append(json.loads(e))) + page.evaluate( + "window._aplModel.on('pointer_settled', e => window._on_st3(JSON.stringify(e)))" + ) + + # First settle + page.mouse.move(200, 150) + page.wait_for_timeout(350) + + # Move and settle again + page.mouse.move(100, 150, steps=3) + page.wait_for_timeout(350) + + assert len(received) >= 2 # fired twice +``` + +- [ ] **Step 2: Run tests** + +```bash +uv run pytest anyplotlib/tests/test_interactive/test_event_settled.py -v +``` +Expected: All PASS. + +- [ ] **Step 3: Commit** + +```bash +git add anyplotlib/tests/test_interactive/test_event_settled.py +git commit -m "test: add pointer_settled Playwright tests including zero-cost guard" +``` + +--- + +## Task 15: Playwright tests — pause/hold integration + +**Files:** +- Create: `anyplotlib/tests/test_interactive/test_event_pause_hold.py` + +- [ ] **Step 1: Create the test file** + +```python +"""Integration tests for pause_events / hold_events during live interactions.""" +from __future__ import annotations +import json +import numpy as np +import pytest +import anyplotlib as apl + + +class TestPauseIntegration: + def test_pause_drops_pointer_move_during_drag(self, interact_page): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((64, 64))) + received = [] + plot.add_event_handler(lambda e: received.append(1), "pointer_move") + + page = interact_page(fig) + + # Pause then trigger drag — moves should not reach handler + page.evaluate("window._aplPaused = true") # hook into test infra below + with plot.pause_events("pointer_move"): + page.mouse.move(200, 150) + page.mouse.down() + page.mouse.move(100, 150, steps=5) + page.mouse.up() + page.wait_for_timeout(200) + + assert received == [] + + # After context exits, moves should fire again + page.mouse.move(200, 150) + page.mouse.down() + page.mouse.move(150, 150, steps=3) + page.mouse.up() + page.wait_for_timeout(200) + assert len(received) > 0 + + +class TestHoldIntegration: + def test_hold_buffers_settled_fires_on_exit(self, interact_page): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((64, 64))) + plot.add_event_handler(lambda e: None, "pointer_settled", ms=150, delta=4) + received = [] + plot.add_event_handler(lambda e: received.append(1), "pointer_settled") + + page = interact_page(fig) + + with plot.hold_events("pointer_settled"): + page.mouse.move(200, 150) + page.wait_for_timeout(300) # settled fires → buffered + assert received == [] + + # hold context exited → flushed + assert received == [1] + + def test_hold_fires_pointer_move_immediately(self, interact_page): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((64, 64))) + moves = [] + settles = [] + plot.add_event_handler(lambda e: moves.append(1), "pointer_move") + plot.add_event_handler(lambda e: None, "pointer_settled", ms=150, delta=4) + plot.add_event_handler(lambda e: settles.append(1), "pointer_settled") + + page = interact_page(fig) + + with plot.hold_events("pointer_settled"): + page.mouse.move(200, 150) + page.mouse.down() + page.mouse.move(100, 150, steps=5) + page.mouse.up() + page.wait_for_timeout(300) + + assert len(moves) > 0 # pointer_move not held → fired immediately + assert len(settles) == 1 # flushed on exit +``` + +- [ ] **Step 2: Run tests** + +```bash +uv run pytest anyplotlib/tests/test_interactive/test_event_pause_hold.py -v +``` +Expected: All PASS. + +- [ ] **Step 3: Commit** + +```bash +git add anyplotlib/tests/test_interactive/test_event_pause_hold.py +git commit -m "test: add pause_events and hold_events Playwright integration tests" +``` + +--- + +## Task 16: Update Examples and regression tests + +**Files:** +- Modify: All `Examples/**/*.py` files that use old event API +- Modify: `anyplotlib/tests/test_interactive/test_callbacks.py` (add regression block) + +- [ ] **Step 1: Find all example files using old event API** + +```bash +grep -rn "on_click\|on_changed\|on_release\|on_key\|on_hover\|\.disconnect(" Examples/ --include="*.py" +``` + +- [ ] **Step 2: Update each file** + +For each file found, replace old API calls: + +| Old | New | +|-----|-----| +| `@plot.on_click` | `@plot.add_event_handler("pointer_down")` | +| `@plot.on_changed` | `@plot.add_event_handler("pointer_move")` | +| `@plot.on_release` | `@plot.add_event_handler("pointer_settled")` | +| `@plot.on_key` | `@plot.add_event_handler("key_down")` | +| `@plot.on_key('q')` | `@plot.add_event_handler("key_down")` + `if event.key == "q": return` | +| `@widget.on_changed` | `@widget.add_event_handler("pointer_move")` | +| `@widget.on_release` | `@widget.add_event_handler("pointer_up")` | +| `@widget.on_click` | `@widget.add_event_handler("pointer_down")` | +| `@line.on_hover` | `@line.add_event_handler("pointer_move")` | +| `@line.on_click` | `@line.add_event_handler("pointer_down")` | +| `plot.disconnect(cid)` | `plot.remove_handler(cid)` | +| `event.phys_x` | `event.xdata` | +| `event.phys_y` | `event.ydata` | +| `event.mouse_x` | `event.x` | +| `event.mouse_y` | `event.y` | + +- [ ] **Step 3: Add regression tests to `test_callbacks.py`** + +Append to `anyplotlib/tests/test_interactive/test_callbacks.py`: + +```python +class TestRegressionOldAPIGone: + """Confirm old decorator methods no longer exist on plots and widgets.""" + + def test_plot1d_no_on_click(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + assert not hasattr(plot, "on_click") + + def test_plot1d_no_on_changed(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + assert not hasattr(plot, "on_changed") + + def test_plot1d_no_on_release(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + assert not hasattr(plot, "on_release") + + def test_plot1d_no_on_key(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + assert not hasattr(plot, "on_key") + + def test_plot1d_no_disconnect(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + assert not hasattr(plot, "disconnect") + + def test_plot2d_no_on_click(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((32, 32))) + assert not hasattr(plot, "on_click") + + def test_widget_no_on_changed(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + w = plot.add_vline_widget(5.0) + assert not hasattr(w, "on_changed") + + def test_widget_no_on_release(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + w = plot.add_vline_widget(5.0) + assert not hasattr(w, "on_release") + + def test_event_no_phys_x(self): + e = Event(event_type="pointer_down", xdata=3.14) + assert not hasattr(e, "phys_x") + assert e.xdata == 3.14 + + def test_event_no_data_dict(self): + e = Event(event_type="pointer_move") + assert not hasattr(e, "data") +``` + +- [ ] **Step 4: Run the full test suite** + +```bash +uv run pytest anyplotlib/tests/ -v +``` +Expected: All PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Examples/ anyplotlib/tests/test_interactive/test_callbacks.py +git commit -m "refactor: update Examples to new event API; add regression tests confirming old API removed" +``` + +--- + +## Verification + +After all tasks complete, run the full suite once more: + +```bash +uv run pytest anyplotlib/tests/ -v --tb=short 2>&1 | tail -20 +``` + +Expected output ends with something like: +``` +========== NNN passed in XX.Xs ========== +``` + +with zero failures or errors. From b9ffe1260bc539db9acc74a34727d547e62c0658 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Thu, 14 May 2026 11:46:20 -0500 Subject: [PATCH 121/198] =?UTF-8?q?refactor:=20flatten=20Event=20dataclass?= =?UTF-8?q?=20=E2=80=94=20all=20payload=20fields=20are=20typed=20top-level?= =?UTF-8?q?=20attrs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the old Event(event_type, source, data: dict) + __getattr__ proxy with a flat dataclass where every payload field (x, y, xdata, ydata, key, modifiers, etc.) is a typed top-level attribute with sensible defaults. Exports VALID_EVENT_TYPES frozenset and keeps CallbackRegistry as a minimal placeholder ahead of the full Task 2 rewrite. --- anyplotlib/callbacks.py | 142 ++-- .../tests/test_interactive/test_callbacks.py | 755 ++---------------- 2 files changed, 152 insertions(+), 745 deletions(-) diff --git a/anyplotlib/callbacks.py b/anyplotlib/callbacks.py index 66ec37af..7628b75a 100644 --- a/anyplotlib/callbacks.py +++ b/anyplotlib/callbacks.py @@ -2,106 +2,112 @@ callbacks.py ============ -Lightweight two-class event system used by every plot object and widget. - -:class:`CallbackRegistry` - Per-object store of named callbacks. Every plot object and widget - exposes ``on_changed``, ``on_release``, ``on_click``, ``on_key``, - ``on_line_hover``, and ``on_line_click`` decorator methods that - connect handlers through this registry. +Event system used by all plot objects and widgets. :class:`Event` - Immutable data-carrier passed to every callback. All keys in the - raw JS payload are accessible as attributes (``event.zoom``, - ``event.cx``, etc.) in addition to the typed ``event_type``, - ``source``, and ``data`` fields. - -Example -------- -.. code-block:: python + Flat dataclass carrying all event fields as typed top-level attributes. - fig, ax = apl.subplots(1, 1) - plot = ax.imshow(data) +:class:`CallbackRegistry` + Per-object handler store. (Full implementation added in Tasks 2-3.) - @plot.on_release - def on_settle(event): - print(f"zoom={event.zoom:.2f} center=({event.center_x:.3f}, {event.center_y:.3f})") +:class:`_EventMixin` + Mixin added to every plot class and widget. (Added in Task 4.) """ - from __future__ import annotations + +import time +from collections import defaultdict, deque +from contextlib import contextmanager from dataclasses import dataclass, field from typing import Any, Callable -_VALID_EVENT_TYPES = ( - "on_click", - "on_changed", - "on_release", - "on_key", - "on_line_hover", - "on_line_click", -) +VALID_EVENT_TYPES = frozenset({ + "pointer_down", "pointer_up", "pointer_move", "pointer_settled", + "pointer_enter", "pointer_leave", "double_click", "wheel", + "key_down", "key_up", "*", +}) @dataclass class Event: - """A single interactive event. + """A single interactive event with all payload fields as typed attributes. + + Universal fields (every event): + event_type, source, time_stamp, modifiers - :event_type: one of ``on_click`` / ``on_changed`` / ``on_release`` / - ``on_key`` / ``on_line_hover`` / ``on_line_click`` - :source: the originating Python object (Widget, Plot, or None) - :data: full state dict; all keys also accessible as ``event.x`` + Pointer fields (pointer_* and double_click events): + x, y — pixel coordinates within the panel + button — 0=left 1=middle 2=right; None on move/enter/leave/settled + buttons — bitmask of currently held buttons + xdata, ydata — data-space coordinates (None for Plot3D) + ray — Plot3D only: {"origin": [...], "direction": [...]} + line_id — Plot1D only: set when pointer is over a line + dwell_ms — pointer_settled only: actual dwell time + PlotBar extra fields (pointer_down only): + bar_index, value, x_label, group_index - For ``on_line_hover`` and ``on_line_click`` events the data dict - contains: + Wheel fields: + dx, dy — scroll deltas - * ``line_id`` – ``None`` for the primary line, or the 8-char ID - string assigned by :meth:`Plot1D.add_line`. - * ``x`` – data-space x coordinate of the nearest point on the line. - * ``y`` – data-space y coordinate of the nearest point on the line. + Key fields: + key — key name e.g. "q", "Enter", "ArrowLeft" + + Propagation: + stop_propagation — set True inside a handler to halt remaining handlers """ event_type: str - source: Any - data: dict = field(default_factory=dict) - - def __getattr__(self, key: str) -> Any: - try: - return self.data[key] - except KeyError: - raise AttributeError( - f"Event has no attribute {key!r}. " - f"Available data keys: {list(self.data)}" - ) from None + source: Any = None + time_stamp: float = field(default_factory=time.perf_counter) + modifiers: list[str] = field(default_factory=list) + # Pointer + x: int | None = None + y: int | None = None + button: int | None = None + buttons: int = 0 + xdata: float | None = None + ydata: float | None = None + ray: dict | None = None + line_id: str | None = None + dwell_ms: float | None = None + # PlotBar + bar_index: int | None = None + value: float | None = None + x_label: str | None = None + group_index: int | None = None + # Wheel + dx: float | None = None + dy: float | None = None + # Key + key: str | None = None + # Propagation (not repr'd) + stop_propagation: bool = field(default=False, repr=False) def __repr__(self) -> str: src = type(self.source).__name__ if self.source is not None else "None" parts = [f"event_type={self.event_type!r}", f"source={src}"] - _skip = {"id", "type", "color", "colormap_data", - "image_b64", "histogram_data", "colormap_name"} - shown = 0 - for k, v in self.data.items(): - if k in _skip or shown >= 6: - continue - parts.append( - f"{k}={v:.4g}" if isinstance(v, float) else f"{k}={v!r}" - ) - shown += 1 + for fname in ("x", "y", "xdata", "ydata", "button", "key", + "line_id", "bar_index", "dwell_ms"): + v = getattr(self, fname) + if v is not None: + parts.append(f"{fname}={v!r}") + if self.modifiers: + parts.append(f"modifiers={self.modifiers!r}") return "Event(" + ", ".join(parts) + ")" class CallbackRegistry: - """Per-object registry for on_click / on_changed / on_release / on_key / - on_line_hover / on_line_click callbacks.""" + """Minimal placeholder — full implementation in Task 2.""" def __init__(self) -> None: self._next_cid: int = 1 self._entries: dict[int, tuple[str, Callable]] = {} def connect(self, event_type: str, fn: Callable) -> int: - """Register fn for event_type. Returns integer CID.""" - if event_type not in _VALID_EVENT_TYPES: + if event_type not in VALID_EVENT_TYPES: raise ValueError( - f"event_type must be one of {_VALID_EVENT_TYPES}, got {event_type!r}" + f"Invalid event_type {event_type!r}. " + f"Valid types: {sorted(t for t in VALID_EVENT_TYPES if t != '*')} or '*'" ) cid = self._next_cid self._next_cid += 1 @@ -109,11 +115,9 @@ def connect(self, event_type: str, fn: Callable) -> int: return cid def disconnect(self, cid: int) -> None: - """Remove handler for cid. Silent if not found.""" self._entries.pop(cid, None) - def fire(self, event) -> None: - """Dispatch event to all handlers matching event.event_type.""" + def fire(self, event: Event) -> None: for _cid, (et, fn) in list(self._entries.items()): if et == event.event_type: fn(event) diff --git a/anyplotlib/tests/test_interactive/test_callbacks.py b/anyplotlib/tests/test_interactive/test_callbacks.py index b38fae34..549655fc 100644 --- a/anyplotlib/tests/test_interactive/test_callbacks.py +++ b/anyplotlib/tests/test_interactive/test_callbacks.py @@ -1,682 +1,85 @@ -""" -tests/test_interactive/test_callbacks.py -======================================== - -Tests for the unified object-level callback system. - -Covers: - * Event dataclass – event_type / source / data / attribute forwarding - * CallbackRegistry – connect / disconnect / fire (event_type dispatch only) - * Plot2D / Plot1D / PlotMesh / Plot3D – on_changed / on_release / on_click - * Figure._on_event – JSON routing to widget + plot callbacks - * Practical patterns - -Widget-level callback and event-dispatch integration tests live in -``test_widgets.py``. -""" - +"""Tests for the redesigned Event dataclass and CallbackRegistry.""" from __future__ import annotations - -import json -import numpy as np +import time import pytest - -import anyplotlib as apl -from anyplotlib.callbacks import CallbackRegistry, Event -from anyplotlib.plot1d import Plot1D -from anyplotlib.plot2d import Plot2D, PlotMesh -from anyplotlib.plot3d import Plot3D - - -# ───────────────────────────────────────────────────────────────────────────── -# Helpers -# ───────────────────────────────────────────────────────────────────────────── - -def _simulate_js_event(fig, plot, event_type: str, *, widget_id=None, **fields): - """Simulate JS sending an interaction event via event_json.""" - payload = {"source": "js", "panel_id": plot._id, "event_type": event_type} - if widget_id is not None: - payload["widget_id"] = widget_id if isinstance(widget_id, str) else widget_id._id - payload.update(fields) - fig._on_event({"new": json.dumps(payload)}) - - -def _plot2d(): - fig, ax = apl.subplots(1, 1) - return ax.imshow(np.zeros((32, 32))) +from anyplotlib.callbacks import Event, CallbackRegistry, VALID_EVENT_TYPES -def _plot1d(): - fig, ax = apl.subplots(1, 1) - return ax.plot(np.zeros(64)) - - -def _plotmesh(): - fig, ax = apl.subplots(1, 1) - return ax.pcolormesh(np.zeros((8, 8))) - - -def _plot3d(): - fig, ax = apl.subplots(1, 1) - x = np.linspace(-1, 1, 10) - y = np.linspace(-1, 1, 10) - X, Y = np.meshgrid(x, y) - Z = X ** 2 + Y ** 2 - return ax.plot_surface(X, Y, Z) - - -# ───────────────────────────────────────────────────────────────────────────── -# 1. Event dataclass -# ───────────────────────────────────────────────────────────────────────────── +# ── Event dataclass ─────────────────────────────────────────────────────────── class TestEvent: - def test_event_type_field(self): - ev = Event(event_type="on_release", source=None, data={"x": 1.0}) - assert ev.event_type == "on_release" - - def test_source_field(self): - obj = object() - ev = Event(event_type="on_changed", source=obj, data={}) - assert ev.source is obj - - def test_data_attribute_forwarding(self): - ev = Event(event_type="on_changed", source=None, data={"cx": 12.5, "cy": 8.0}) - assert ev.cx == pytest.approx(12.5) - assert ev.cy == pytest.approx(8.0) - - def test_unknown_attribute_raises(self): - ev = Event(event_type="on_changed", source=None, data={"x": 1.0}) - with pytest.raises(AttributeError, match="Event has no attribute 'nonexistent'"): - _ = ev.nonexistent - - def test_data_key_various_types(self): - ev = Event(event_type="on_click", source=None, - data={"x": 1.1, "text": "hello", "flag": True, "n": 7}) - assert ev.x == pytest.approx(1.1) - assert ev.text == "hello" - assert ev.flag is True - assert ev.n == 7 - - def test_empty_data_raises_on_access(self): - ev = Event(event_type="on_release", source=None, data={}) - with pytest.raises(AttributeError): - _ = ev.anything - - def test_repr_contains_event_type(self): - ev = Event(event_type="on_release", source=None, data={"zoom": 2.5}) - assert "on_release" in repr(ev) - - def test_repr_shows_source_type(self): - from anyplotlib.widgets import CircleWidget - w = CircleWidget(lambda: None, cx=0, cy=0, r=5) - ev = Event(event_type="on_changed", source=w, data={}) - assert "CircleWidget" in repr(ev) - - -# ───────────────────────────────────────────────────────────────────────────── -# 2. CallbackRegistry -# ───────────────────────────────────────────────────────────────────────────── - -class TestCallbackRegistry: - - def test_connect_returns_int_cid(self): - reg = CallbackRegistry() - cid = reg.connect("on_changed", lambda e: None) - assert isinstance(cid, int) - - def test_connect_cids_increment(self): - reg = CallbackRegistry() - c1 = reg.connect("on_changed", lambda e: None) - c2 = reg.connect("on_release", lambda e: None) - assert c2 > c1 - - def test_invalid_event_type_raises(self): - reg = CallbackRegistry() - with pytest.raises(ValueError, match="event_type must be one of"): - reg.connect("change", lambda e: None) # old name - - def test_fire_on_changed(self): - reg = CallbackRegistry() - fired = [] - reg.connect("on_changed", lambda e: fired.append(e)) - reg.fire(Event("on_changed", None, {})) - assert len(fired) == 1 - - def test_fire_does_not_cross_types(self): - reg = CallbackRegistry() - fired = [] - reg.connect("on_release", lambda e: fired.append(e)) - reg.fire(Event("on_changed", None, {})) - assert fired == [] - - def test_fire_on_release(self): - reg = CallbackRegistry() - fired = [] - reg.connect("on_release", lambda e: fired.append(e)) - reg.fire(Event("on_release", None, {})) - assert len(fired) == 1 - - def test_fire_on_click(self): - reg = CallbackRegistry() - fired = [] - reg.connect("on_click", lambda e: fired.append(e)) - reg.fire(Event("on_click", None, {})) - assert len(fired) == 1 - - def test_three_types_independent(self): - reg = CallbackRegistry() - c_log, r_log, k_log = [], [], [] - reg.connect("on_changed", lambda e: c_log.append(1)) - reg.connect("on_release", lambda e: r_log.append(1)) - reg.connect("on_click", lambda e: k_log.append(1)) - reg.fire(Event("on_changed", None, {})) - reg.fire(Event("on_release", None, {})) - reg.fire(Event("on_click", None, {})) - assert len(c_log) == 1 and len(r_log) == 1 and len(k_log) == 1 - - def test_disconnect_removes_handler(self): - reg = CallbackRegistry() - fired = [] - cid = reg.connect("on_release", lambda e: fired.append(e)) - reg.disconnect(cid) - reg.fire(Event("on_release", None, {})) - assert fired == [] - - def test_disconnect_unknown_cid_is_silent(self): - reg = CallbackRegistry() - reg.disconnect(9999) - - def test_disconnect_twice_is_silent(self): - reg = CallbackRegistry() - cid = reg.connect("on_release", lambda e: None) - reg.disconnect(cid) - reg.disconnect(cid) - - def test_bool_false_when_empty(self): - assert not CallbackRegistry() - - def test_bool_true_when_connected(self): - reg = CallbackRegistry() - reg.connect("on_changed", lambda e: None) - assert reg - - def test_bool_false_after_all_disconnected(self): - reg = CallbackRegistry() - cid = reg.connect("on_changed", lambda e: None) - reg.disconnect(cid) - assert not reg - - def test_multiple_handlers_all_called(self): - reg = CallbackRegistry() - log = [] - reg.connect("on_release", lambda e: log.append("a")) - reg.connect("on_release", lambda e: log.append("b")) - reg.connect("on_release", lambda e: log.append("c")) - reg.fire(Event("on_release", None, {})) - assert sorted(log) == ["a", "b", "c"] - - def test_disconnect_inside_callback_is_safe(self): - reg = CallbackRegistry() - fired = [] - - def self_disconnect(event): - fired.append(event) - reg.disconnect(self_disconnect._cid) - - self_disconnect._cid = reg.connect("on_release", self_disconnect) - reg.fire(Event("on_release", None, {})) - reg.fire(Event("on_release", None, {})) - assert len(fired) == 1 - - def test_no_handlers_fire_is_noop(self): - CallbackRegistry().fire(Event("on_release", None, {})) - - -# ───────────────────────────────────────────────────────────────────────────── -# 3. Plot2D callback API -# ───────────────────────────────────────────────────────────────────────────── - -class TestPlot2DCallbacks: - - def test_has_callbacks_registry(self): - assert isinstance(_plot2d().callbacks, CallbackRegistry) - - def test_on_changed_decorator(self): - v = _plot2d() - fired = [] - - @v.on_changed - def cb(event): fired.append(event) - - v.callbacks.fire(Event("on_changed", None, {})) - assert len(fired) == 1 - - def test_on_changed_not_fired_for_release(self): - v = _plot2d() - fired = [] - - @v.on_changed - def cb(event): fired.append(event) - - v.callbacks.fire(Event("on_release", None, {})) - assert fired == [] - - def test_on_release_decorator(self): - v = _plot2d() - fired = [] - - @v.on_release - def cb(event): fired.append(event) - - v.callbacks.fire(Event("on_release", None, {})) - assert len(fired) == 1 - - def test_on_click_decorator(self): - v = _plot2d() - fired = [] - - @v.on_click - def cb(event): fired.append(event) - - v.callbacks.fire(Event("on_click", None, {"x": 5.0, "y": 10.0})) - assert len(fired) == 1 - assert fired[0].x == pytest.approx(5.0) - - def test_decorator_stamps_cid(self): - v = _plot2d() - - @v.on_release - def cb(event): pass - - assert hasattr(cb, "_cid") and isinstance(cb._cid, int) - - def test_disconnect(self): - v = _plot2d() - fired = [] - - @v.on_release - def cb(event): fired.append(event) - - v.disconnect(cb._cid) - v.callbacks.fire(Event("on_release", None, {})) - assert fired == [] - - def test_single_fire_pattern(self): - v = _plot2d() - fired = [] - - @v.on_release - def once(event): - fired.append(event) - v.disconnect(once._cid) - - v.callbacks.fire(Event("on_release", None, {})) - v.callbacks.fire(Event("on_release", None, {})) - assert len(fired) == 1 - - def test_zoom_event_data(self): - v = _plot2d() - fired = [] - - @v.on_release - def cb(event): fired.append(event) - - v.callbacks.fire(Event("on_release", None, - {"center_x": 0.6, "center_y": 0.4, "zoom": 3.0})) - assert fired[0].zoom == pytest.approx(3.0) - - -# ───────────────────────────────────────────────────────────────────────────── -# 4. Plot1D callback API -# ───────────────────────────────────────────────────────────────────────────── - -class TestPlot1DCallbacks: - - def test_has_callbacks_registry(self): - assert isinstance(_plot1d().callbacks, CallbackRegistry) - - def test_on_changed_and_on_release(self): - v = _plot1d() - change_fired, release_fired = [], [] - - @v.on_changed - def lv(event): change_fired.append(event) - - @v.on_release - def done(event): release_fired.append(event) - - v.callbacks.fire(Event("on_changed", None, {})) - v.callbacks.fire(Event("on_release", None, {})) - assert len(change_fired) == 1 and len(release_fired) == 1 - - def test_view_change_event_data(self): - v = _plot1d() - fired = [] - - @v.on_release - def cb(event): fired.append(event) - - v.callbacks.fire(Event("on_release", None, {"view_x0": 0.2, "view_x1": 0.8})) - assert fired[0].view_x0 == pytest.approx(0.2) - assert fired[0].view_x1 == pytest.approx(0.8) - - def test_disconnect(self): - v = _plot1d() - fired = [] - - @v.on_changed - def cb(event): fired.append(event) - - v.disconnect(cb._cid) - v.callbacks.fire(Event("on_changed", None, {})) - assert fired == [] - - -# ───────────────────────────────────────────────────────────────────────────── -# 5. PlotMesh callback API -# ───────────────────────────────────────────────────────────────────────────── - -class TestPlotMeshCallbacks: - - def test_has_callbacks_registry(self): - assert isinstance(_plotmesh().callbacks, CallbackRegistry) - - def test_on_changed_and_on_release(self): - v = _plotmesh() - change_fired, release_fired = [], [] - - @v.on_changed - def lv(event): change_fired.append(event) - - @v.on_release - def done(event): release_fired.append(event) - - v.callbacks.fire(Event("on_changed", None, {})) - v.callbacks.fire(Event("on_release", None, {})) - assert len(change_fired) == 1 and len(release_fired) == 1 - - def test_disconnect(self): - v = _plotmesh() - fired = [] - - @v.on_release - def cb(event): fired.append(event) - - v.disconnect(cb._cid) - v.callbacks.fire(Event("on_release", None, {})) - assert fired == [] - - -# ───────────────────────────────────────────────────────────────────────────── -# 6. Plot3D callback API -# ───────────────────────────────────────────────────────────────────────────── - -class TestPlot3DCallbacks: - - def test_has_callbacks_registry(self): - assert isinstance(_plot3d().callbacks, CallbackRegistry) - - def test_on_changed_rotation(self): - v = _plot3d() - fired = [] - - @v.on_changed - def cb(event): fired.append(event) - - v.callbacks.fire(Event("on_changed", None, - {"azimuth": 45.0, "elevation": 30.0, "zoom": 1.0})) - assert fired[0].azimuth == pytest.approx(45.0) - - def test_on_release_data(self): - v = _plot3d() - fired = [] - - @v.on_release - def cb(event): fired.append(event) - - v.callbacks.fire(Event("on_release", None, - {"azimuth": -60.0, "elevation": 20.0, "zoom": 2.5})) - assert fired[0].zoom == pytest.approx(2.5) - - def test_on_click(self): - v = _plot3d() - fired = [] - - @v.on_click - def cb(event): fired.append(event) - - v.callbacks.fire(Event("on_click", None, {"x": 1.0})) - assert len(fired) == 1 - - def test_disconnect(self): - v = _plot3d() - fired = [] - - @v.on_release - def cb(event): fired.append(event) - - v.disconnect(cb._cid) - v.callbacks.fire(Event("on_release", None, {})) - assert fired == [] - - -# ───────────────────────────────────────────────────────────────────────────── -# 7. Figure._on_event routing -# ───────────────────────────────────────────────────────────────────────────── - -class TestFigureEventRouting: - - def test_dispatch_reaches_plot_callbacks(self): - fig, ax = apl.subplots(1, 1) - v = ax.imshow(np.zeros((32, 32))) - fired = [] - - @v.on_release - def cb(event): fired.append(event) - - _simulate_js_event(fig, v, "on_release", cx=10.0, cy=20.0) - assert len(fired) == 1 - assert fired[0].cx == pytest.approx(10.0) - - def test_dispatch_with_widget_id_updates_widget(self): - fig, ax = apl.subplots(1, 1) - v = ax.imshow(np.zeros((32, 32))) - wid = v.add_widget("circle", cx=0.0, cy=0.0) - - _simulate_js_event(fig, v, "on_changed", widget_id=wid, cx=5.0) - assert wid.cx == pytest.approx(5.0) - - def test_widget_and_plot_callbacks_both_fire(self): - """A single JS event bearing a widget_id fires both the widget-level - and the plot-level on_release callbacks, with the widget as source.""" - fig, ax = apl.subplots(1, 1) - v = ax.imshow(np.zeros((32, 32))) - wid = v.add_widget("circle") - w_fired, p_fired = [], [] - - @wid.on_release - def wc(event): w_fired.append(event) - - @v.on_release - def pc(event): p_fired.append(event) - - _simulate_js_event(fig, v, "on_release", widget_id=wid, cx=5.0, cy=5.0) - assert len(w_fired) == 1 and len(p_fired) == 1 - assert w_fired[0].source is wid - assert p_fired[0].source is wid - - def test_dispatch_wrong_panel_id_ignored(self): - fig, ax = apl.subplots(1, 1) - v = ax.imshow(np.zeros((32, 32))) - fired = [] - - @v.on_release - def cb(event): fired.append(event) - - fig._on_event({"new": json.dumps({"source": "js", "panel_id": "nonexistent", - "event_type": "on_release"})}) - assert fired == [] - - def test_dispatch_empty_json_ignored(self): - fig, ax = apl.subplots(1, 1) - fig._on_event({"new": "{}"}) - - def test_dispatch_invalid_json_ignored(self): - fig, ax = apl.subplots(1, 1) - fig._on_event({"new": "not-json"}) - - def test_source_python_not_dispatched(self): - fig, ax = apl.subplots(1, 1) - v = ax.imshow(np.zeros((32, 32))) - fired = [] - - @v.on_changed - def cb(event): fired.append(event) - - fig._on_event({"new": json.dumps( - {"source": "python", "panel_id": v._id, - "event_type": "on_changed", "cx": 5.0})}) - assert fired == [] - - def test_multi_panel_correct_routing(self): - fig, (ax1, ax2) = apl.subplots(1, 2) - v1 = ax1.imshow(np.zeros((16, 16))) - v2 = ax2.plot(np.zeros(32)) - fired1, fired2 = [], [] - - @v1.on_release - def cb1(event): fired1.append(event) - - @v2.on_release - def cb2(event): fired2.append(event) - - _simulate_js_event(fig, v1, "on_release", zoom=1.5) - assert len(fired1) == 1 and fired2 == [] - - _simulate_js_event(fig, v2, "on_release", view_x0=0.1, view_x1=0.9) - assert len(fired2) == 1 and len(fired1) == 1 - - def test_protocol_keys_stripped_from_event_data(self): - fig, ax = apl.subplots(1, 1) - v = ax.imshow(np.zeros((16, 16))) - fired = [] - - @v.on_release - def cb(event): fired.append(event) - - _simulate_js_event(fig, v, "on_release", zoom=2.0) - ev = fired[0] - assert "panel_id" not in ev.data - assert "event_type" not in ev.data - assert "source" not in ev.data - assert ev.zoom == pytest.approx(2.0) - - def test_default_event_type_is_on_changed(self): - fig, ax = apl.subplots(1, 1) - v = ax.imshow(np.zeros((16, 16))) - fired = [] - - @v.on_changed - def cb(event): fired.append(event) - - fig._on_event({"new": json.dumps({"source": "js", - "panel_id": v._id, "cx": 1.0})}) - assert len(fired) == 1 - - -# ───────────────────────────────────────────────────────────────────────────── -# 8. Practical patterns -# ───────────────────────────────────────────────────────────────────────────── - -class TestPracticalPatterns: - - def test_readout_update_on_drag(self): - fig, ax = apl.subplots(1, 1) - v = ax.imshow(np.zeros((64, 64))) - wid = v.add_widget("crosshair") - readout = {"value": ""} - - @wid.on_changed - def live(event): - readout["value"] = f"({event.cx:.1f}, {event.cy:.1f})" - - _simulate_js_event(fig, v, "on_changed", widget_id=wid, cx=12.5, cy=7.3) - assert readout["value"] == "(12.5, 7.3)" - - def test_expensive_work_gated_on_release(self): - fig, ax = apl.subplots(1, 1) - v = ax.plot(np.zeros(64)) - wid = v.add_vline_widget(x=284.0) - calls = {"cheap": 0, "expensive": 0} - - @wid.on_changed - def live(event): calls["cheap"] += 1 - - @wid.on_release - def done(event): calls["expensive"] += 1 - - for i in range(10): - _simulate_js_event(fig, v, "on_changed", widget_id=wid, x=285.0 + i) - _simulate_js_event(fig, v, "on_release", widget_id=wid, x=285.0) - - assert calls["cheap"] == 10 - assert calls["expensive"] == 1 - - def test_multiple_widgets_separate_callbacks(self): - fig, ax = apl.subplots(1, 1) - v = ax.imshow(np.zeros((32, 32))) - w1 = v.add_widget("circle") - w2 = v.add_widget("crosshair") - log = {w1._id: [], w2._id: []} - - @w1.on_release - def cb1(event): log[w1._id].append(event) - - @w2.on_release - def cb2(event): log[w2._id].append(event) - - _simulate_js_event(fig, v, "on_release", widget_id=w1, cx=5.0, cy=5.0) - assert len(log[w1._id]) == 1 and len(log[w2._id]) == 0 - - _simulate_js_event(fig, v, "on_release", widget_id=w2, cx=8.0, cy=8.0) - assert len(log[w1._id]) == 1 and len(log[w2._id]) == 1 - - def test_widget_attribute_assignment(self): - fig, ax = apl.subplots(1, 1) - v = ax.imshow(np.zeros((32, 32))) - wid = v.add_widget("rectangle", x=0.0, y=0.0, w=10.0, h=10.0) - wid.x = 40.0 - assert wid.x == pytest.approx(40.0) - assert v.to_state_dict()["overlay_widgets"][0]["x"] == pytest.approx(40.0) - - def test_widget_x_readback_after_js_event(self): - fig, ax = apl.subplots(1, 1) - v = ax.imshow(np.zeros((32, 32))) - wid = v.add_widget("rectangle", x=0.0, y=0.0, w=10.0, h=10.0) - _simulate_js_event(fig, v, "on_changed", widget_id=wid, - x=77.0, y=88.0, w=33.0, h=44.0) - assert wid.x == pytest.approx(77.0) - assert wid.y == pytest.approx(88.0) - - def test_3d_rotate_many_frames_one_release(self): - x = y = np.linspace(-1, 1, 5) - X, Y = np.meshgrid(x, y) - fig, ax = apl.subplots(1, 1) - v = ax.plot_surface(X, Y, np.zeros((5, 5))) - frames, final = [], {} - - @v.on_changed - def live(event): frames.append(event.azimuth) - - @v.on_release - def done(event): final["az"] = event.azimuth - - for az in range(0, 50, 5): - _simulate_js_event(fig, v, "on_changed", - azimuth=float(az), elevation=30.0, zoom=1.0) - _simulate_js_event(fig, v, "on_release", - azimuth=45.0, elevation=30.0, zoom=1.0) - - assert len(frames) == 10 - assert final["az"] == pytest.approx(45.0) - + def test_required_fields(self): + e = Event(event_type="pointer_down", source=None) + assert e.event_type == "pointer_down" + assert e.source is None + + def test_time_stamp_auto_set(self): + before = time.perf_counter() + e = Event(event_type="pointer_down") + after = time.perf_counter() + assert before <= e.time_stamp <= after + + def test_modifiers_default_empty_list(self): + e = Event(event_type="pointer_move") + assert e.modifiers == [] + assert isinstance(e.modifiers, list) + + def test_pointer_fields_default_none(self): + e = Event(event_type="pointer_move") + assert e.x is None + assert e.y is None + assert e.button is None + assert e.buttons == 0 + assert e.xdata is None + assert e.ydata is None + assert e.ray is None + assert e.line_id is None + assert e.dwell_ms is None + + def test_wheel_fields_default_none(self): + e = Event(event_type="wheel") + assert e.dx is None + assert e.dy is None + + def test_key_field_default_none(self): + e = Event(event_type="key_down") + assert e.key is None + + def test_bar_fields_default_none(self): + e = Event(event_type="pointer_down") + assert e.bar_index is None + assert e.value is None + assert e.x_label is None + assert e.group_index is None + + def test_stop_propagation_default_false(self): + e = Event(event_type="pointer_down") + assert e.stop_propagation is False + + def test_all_fields_settable(self): + e = Event( + event_type="pointer_down", + source="plot", + modifiers=["ctrl", "shift"], + x=100, y=200, + button=0, buttons=1, + xdata=3.14, ydata=2.71, + line_id="abc12345", + bar_index=2, value=99.5, x_label="Jan", group_index=1, + dx=10.0, dy=-5.0, + key="q", + ) + assert e.modifiers == ["ctrl", "shift"] + assert e.x == 100 + assert e.xdata == 3.14 + assert e.line_id == "abc12345" + assert e.bar_index == 2 + assert e.key == "q" + + def test_no_data_dict_attribute(self): + e = Event(event_type="pointer_move") + assert not hasattr(e, "data") + + def test_repr_includes_event_type(self): + e = Event(event_type="pointer_down", x=10, y=20) + assert "pointer_down" in repr(e) From 89dbd84fa13d28950dd1a13bdaa29def2d697b8b Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 15 May 2026 09:36:14 -0500 Subject: [PATCH 122/198] test: add stop_propagation repr test and dx/dy assertions to TestEvent --- anyplotlib/tests/test_interactive/test_callbacks.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/anyplotlib/tests/test_interactive/test_callbacks.py b/anyplotlib/tests/test_interactive/test_callbacks.py index 549655fc..65539b1b 100644 --- a/anyplotlib/tests/test_interactive/test_callbacks.py +++ b/anyplotlib/tests/test_interactive/test_callbacks.py @@ -75,6 +75,8 @@ def test_all_fields_settable(self): assert e.line_id == "abc12345" assert e.bar_index == 2 assert e.key == "q" + assert e.dx == 10.0 + assert e.dy == -5.0 def test_no_data_dict_attribute(self): e = Event(event_type="pointer_move") @@ -83,3 +85,7 @@ def test_no_data_dict_attribute(self): def test_repr_includes_event_type(self): e = Event(event_type="pointer_down", x=10, y=20) assert "pointer_down" in repr(e) + + def test_stop_propagation_not_in_repr(self): + e = Event(event_type="pointer_down", stop_propagation=True) + assert "stop_propagation" not in repr(e) From e3250fa33157b4f805ebe1ab8869b43f5cf55513 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 15 May 2026 09:38:27 -0500 Subject: [PATCH 123/198] refactor: rewrite CallbackRegistry with priority, wildcard, disconnect_fn, stop_propagation --- anyplotlib/callbacks.py | 78 ++++++++++-- .../tests/test_interactive/test_callbacks.py | 111 ++++++++++++++++++ 2 files changed, 179 insertions(+), 10 deletions(-) diff --git a/anyplotlib/callbacks.py b/anyplotlib/callbacks.py index 7628b75a..fdd00f3a 100644 --- a/anyplotlib/callbacks.py +++ b/anyplotlib/callbacks.py @@ -97,13 +97,33 @@ def __repr__(self) -> str: class CallbackRegistry: - """Minimal placeholder — full implementation in Task 2.""" + """Per-object handler store. + + Supports: + - Priority ordering (``order`` kwarg — lower fires first) + - Wildcard ``"*"`` type receives every dispatched event + - ``stop_propagation`` on the event halts remaining handlers + - ``disconnect_fn(fn, *types)`` removes by callback reference + - ``pause_events`` / ``hold_events`` context managers (added in Task 3) + """ def __init__(self) -> None: + # {event_type: [(order, cid, fn), ...]} — sorted by order + self._handlers: dict[str, list[tuple[float, int, Callable]]] = defaultdict(list) self._next_cid: int = 1 - self._entries: dict[int, tuple[str, Callable]] = {} - - def connect(self, event_type: str, fn: Callable) -> int: + # {cid: set[str]} — which types this cid is registered under + self._cid_map: dict[int, set[str]] = {} + # {id(fn): set[int]} — which cids this fn owns + self._fn_map: dict[int, set[int]] = defaultdict(set) + # pause/hold state (populated in Task 3) + self._pause_counts: dict[str, int] = {} + self._hold_counts: dict[str, int] = {} + self._held: deque[Event] = deque() + + # ── registration ───────────────────────────────────────────────────── + + def connect(self, event_type: str, fn: Callable, *, order: float = 0) -> int: + """Register fn for event_type. Returns integer CID.""" if event_type not in VALID_EVENT_TYPES: raise ValueError( f"Invalid event_type {event_type!r}. " @@ -111,16 +131,54 @@ def connect(self, event_type: str, fn: Callable) -> int: ) cid = self._next_cid self._next_cid += 1 - self._entries[cid] = (event_type, fn) + self._handlers[event_type].append((order, cid, fn)) + self._handlers[event_type].sort(key=lambda t: t[0]) + self._cid_map.setdefault(cid, set()).add(event_type) + self._fn_map[id(fn)].add(cid) return cid def disconnect(self, cid: int) -> None: - self._entries.pop(cid, None) + """Remove handler by CID. Silent if not found.""" + types = self._cid_map.pop(cid, set()) + for et in types: + self._handlers[et] = [ + (o, c, f) for o, c, f in self._handlers[et] if c != cid + ] + for fn_cids in self._fn_map.values(): + fn_cids.discard(cid) + + def disconnect_fn(self, fn: Callable, *types: str) -> None: + """Remove fn from the given types (all types if none given).""" + for cid in list(self._fn_map.get(id(fn), set())): + cid_types = self._cid_map.get(cid, set()) + if not types or cid_types & set(types): + self.disconnect(cid) + + # ── dispatch ───────────────────────────────────────────────────────── def fire(self, event: Event) -> None: - for _cid, (et, fn) in list(self._entries.items()): - if et == event.event_type: - fn(event) + """Dispatch event to matching handlers (respects pause/hold).""" + et = event.event_type + if self._pause_counts.get(et, 0) > 0 or self._pause_counts.get("*", 0) > 0: + return + if self._hold_counts.get(et, 0) > 0 or self._hold_counts.get("*", 0) > 0: + self._held.append(event) + return + self._dispatch(event) + + def _dispatch(self, event: Event) -> None: + et = event.event_type + specific = list(self._handlers.get(et, [])) + wildcard = list(self._handlers.get("*", [])) + merged = sorted(specific + wildcard, key=lambda t: t[0]) + for _order, _cid, fn in merged: + if event.stop_propagation: + break + fn(event) + + def _flush(self) -> None: + while self._held: + self._dispatch(self._held.popleft()) def __bool__(self) -> bool: - return bool(self._entries) + return any(bool(v) for v in self._handlers.values()) diff --git a/anyplotlib/tests/test_interactive/test_callbacks.py b/anyplotlib/tests/test_interactive/test_callbacks.py index 65539b1b..339205cf 100644 --- a/anyplotlib/tests/test_interactive/test_callbacks.py +++ b/anyplotlib/tests/test_interactive/test_callbacks.py @@ -89,3 +89,114 @@ def test_repr_includes_event_type(self): def test_stop_propagation_not_in_repr(self): e = Event(event_type="pointer_down", stop_propagation=True) assert "stop_propagation" not in repr(e) + + +class TestCallbackRegistry: + def test_connect_returns_int_cid(self): + reg = CallbackRegistry() + cid = reg.connect("pointer_down", lambda e: None) + assert isinstance(cid, int) + + def test_fire_calls_handler(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_down", lambda e: calls.append(e.event_type)) + reg.fire(Event("pointer_down")) + assert calls == ["pointer_down"] + + def test_fire_only_matching_type(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_down", lambda e: calls.append("down")) + reg.connect("pointer_up", lambda e: calls.append("up")) + reg.fire(Event("pointer_down")) + assert calls == ["down"] + + def test_disconnect_by_cid(self): + reg = CallbackRegistry() + calls = [] + cid = reg.connect("pointer_down", lambda e: calls.append(1)) + reg.disconnect(cid) + reg.fire(Event("pointer_down")) + assert calls == [] + + def test_disconnect_silent_if_not_found(self): + reg = CallbackRegistry() + reg.disconnect(999) # should not raise + + def test_wildcard_receives_all_types(self): + reg = CallbackRegistry() + calls = [] + reg.connect("*", lambda e: calls.append(e.event_type)) + reg.fire(Event("pointer_down")) + reg.fire(Event("key_down")) + reg.fire(Event("wheel")) + assert calls == ["pointer_down", "key_down", "wheel"] + + def test_priority_order(self): + reg = CallbackRegistry() + order = [] + reg.connect("pointer_down", lambda e: order.append("second"), order=1) + reg.connect("pointer_down", lambda e: order.append("first"), order=0) + reg.fire(Event("pointer_down")) + assert order == ["first", "second"] + + def test_same_priority_fires_in_registration_order(self): + reg = CallbackRegistry() + order = [] + reg.connect("pointer_down", lambda e: order.append("a"), order=0) + reg.connect("pointer_down", lambda e: order.append("b"), order=0) + reg.fire(Event("pointer_down")) + assert order == ["a", "b"] + + def test_stop_propagation(self): + reg = CallbackRegistry() + calls = [] + def handler_a(e): + calls.append("a") + e.stop_propagation = True + reg.connect("pointer_down", handler_a, order=0) + reg.connect("pointer_down", lambda e: calls.append("b"), order=1) + reg.fire(Event("pointer_down")) + assert calls == ["a"] + + def test_disconnect_fn_by_reference(self): + reg = CallbackRegistry() + calls = [] + fn = lambda e: calls.append(1) + reg.connect("pointer_down", fn) + reg.disconnect_fn(fn) + reg.fire(Event("pointer_down")) + assert calls == [] + + def test_disconnect_fn_specific_type(self): + reg = CallbackRegistry() + calls = [] + fn = lambda e: calls.append(e.event_type) + reg.connect("pointer_down", fn) + reg.connect("pointer_up", fn) + reg.disconnect_fn(fn, "pointer_down") + reg.fire(Event("pointer_down")) + reg.fire(Event("pointer_up")) + assert calls == ["pointer_up"] + + def test_bool_true_when_handlers_present(self): + reg = CallbackRegistry() + assert not bool(reg) + reg.connect("pointer_down", lambda e: None) + assert bool(reg) + + def test_invalid_event_type_raises(self): + reg = CallbackRegistry() + with pytest.raises(ValueError, match="Invalid event_type"): + reg.connect("on_click", lambda e: None) + + def test_connect_same_fn_multiple_types(self): + reg = CallbackRegistry() + calls = [] + fn = lambda e: calls.append(e.event_type) + reg.connect("pointer_down", fn) + reg.connect("pointer_up", fn) + reg.fire(Event("pointer_down")) + reg.fire(Event("pointer_up")) + assert calls == ["pointer_down", "pointer_up"] From c69142faf95ccb47e3e9e52dfe280c7fe89324ab Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 15 May 2026 10:31:46 -0500 Subject: [PATCH 124/198] feat: add pause_events and hold_events context managers to CallbackRegistry Implement two context manager methods in CallbackRegistry to control event dispatching: pause_events() suppresses events while active, hold_events() buffers events and flushes them on exit. Pause takes precedence over hold for the same event type. --- anyplotlib/callbacks.py | 33 +++++++ .../tests/test_interactive/test_callbacks.py | 91 +++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/anyplotlib/callbacks.py b/anyplotlib/callbacks.py index fdd00f3a..0c31ceb9 100644 --- a/anyplotlib/callbacks.py +++ b/anyplotlib/callbacks.py @@ -180,5 +180,38 @@ def _flush(self) -> None: while self._held: self._dispatch(self._held.popleft()) + @contextmanager + def pause_events(self, *types: str): + """Suppress events of the given types while inside this context. + All types are paused when called with no arguments. + Pause wins over hold for the same type.""" + target = types if types else ("*",) + for t in target: + self._pause_counts[t] = self._pause_counts.get(t, 0) + 1 + try: + yield + finally: + for t in target: + self._pause_counts[t] -= 1 + if self._pause_counts[t] == 0: + del self._pause_counts[t] + + @contextmanager + def hold_events(self, *types: str): + """Buffer events of the given types; flush when the outermost hold exits. + All types are held when called with no arguments.""" + target = types if types else ("*",) + for t in target: + self._hold_counts[t] = self._hold_counts.get(t, 0) + 1 + try: + yield + finally: + for t in target: + self._hold_counts[t] -= 1 + if self._hold_counts[t] == 0: + del self._hold_counts[t] + if not self._hold_counts: + self._flush() + def __bool__(self) -> bool: return any(bool(v) for v in self._handlers.values()) diff --git a/anyplotlib/tests/test_interactive/test_callbacks.py b/anyplotlib/tests/test_interactive/test_callbacks.py index 339205cf..e7287d55 100644 --- a/anyplotlib/tests/test_interactive/test_callbacks.py +++ b/anyplotlib/tests/test_interactive/test_callbacks.py @@ -200,3 +200,94 @@ def test_connect_same_fn_multiple_types(self): reg.fire(Event("pointer_down")) reg.fire(Event("pointer_up")) assert calls == ["pointer_down", "pointer_up"] + + +class TestPauseHold: + def test_pause_drops_events(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_move", lambda e: calls.append(1)) + with reg.pause_events("pointer_move"): + reg.fire(Event("pointer_move")) + assert calls == [] + + def test_pause_handlers_intact_after_exit(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_move", lambda e: calls.append(1)) + with reg.pause_events("pointer_move"): + reg.fire(Event("pointer_move")) + reg.fire(Event("pointer_move")) + assert calls == [1] + + def test_pause_all_types_when_no_args(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_down", lambda e: calls.append("down")) + reg.connect("key_down", lambda e: calls.append("key")) + with reg.pause_events(): + reg.fire(Event("pointer_down")) + reg.fire(Event("key_down")) + assert calls == [] + + def test_pause_only_specified_type(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_move", lambda e: calls.append("move")) + reg.connect("pointer_down", lambda e: calls.append("down")) + with reg.pause_events("pointer_move"): + reg.fire(Event("pointer_move")) + reg.fire(Event("pointer_down")) + assert calls == ["down"] + + def test_pause_nested_same_type(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_move", lambda e: calls.append(1)) + with reg.pause_events("pointer_move"): + with reg.pause_events("pointer_move"): + reg.fire(Event("pointer_move")) + reg.fire(Event("pointer_move")) # still paused — outer not exited + reg.fire(Event("pointer_move")) # now fires + assert calls == [1] + + def test_hold_buffers_and_flushes_on_exit(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_settled", lambda e: calls.append(1)) + with reg.hold_events("pointer_settled"): + reg.fire(Event("pointer_settled")) + reg.fire(Event("pointer_settled")) + assert calls == [] # buffered, not fired yet + assert calls == [1, 1] # flushed on exit + + def test_hold_fires_non_held_types_immediately(self): + reg = CallbackRegistry() + move_calls = [] + settled_calls = [] + reg.connect("pointer_move", lambda e: move_calls.append(1)) + reg.connect("pointer_settled", lambda e: settled_calls.append(1)) + with reg.hold_events("pointer_settled"): + reg.fire(Event("pointer_move")) # not held → immediate + reg.fire(Event("pointer_settled")) # held → buffered + assert move_calls == [1] + assert settled_calls == [1] # flushed on exit + + def test_hold_events_in_order(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_settled", lambda e: calls.append(e.x)) + with reg.hold_events(): + reg.fire(Event("pointer_settled", x=1)) + reg.fire(Event("pointer_settled", x=2)) + reg.fire(Event("pointer_settled", x=3)) + assert calls == [1, 2, 3] + + def test_pause_wins_over_hold(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_move", lambda e: calls.append(1)) + with reg.hold_events("pointer_move"): + with reg.pause_events("pointer_move"): + reg.fire(Event("pointer_move")) + assert calls == [] # dropped, not buffered then flushed From 15d3c967c52404928478d0a8a3c5a14c6653ff9f Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 15 May 2026 10:40:53 -0500 Subject: [PATCH 125/198] feat: add _EventMixin with add_event_handler, remove_handler, pause/hold_events --- anyplotlib/callbacks.py | 108 ++++++++++++++++ .../tests/test_interactive/test_callbacks.py | 121 +++++++++++++++++- 2 files changed, 228 insertions(+), 1 deletion(-) diff --git a/anyplotlib/callbacks.py b/anyplotlib/callbacks.py index 0c31ceb9..28eceaa2 100644 --- a/anyplotlib/callbacks.py +++ b/anyplotlib/callbacks.py @@ -215,3 +215,111 @@ def hold_events(self, *types: str): def __bool__(self) -> bool: return any(bool(v) for v in self._handlers.values()) + + +class _EventMixin: + """Mixin for plot classes and widgets. + + Provides ``add_event_handler`` / ``remove_handler`` / ``pause_events`` / + ``hold_events``. The host class must set ``self.callbacks = CallbackRegistry()`` + in its ``__init__``. + """ + + callbacks: CallbackRegistry + + def add_event_handler( + self, + fn_or_type, + *args, + order: float = 0, + ms: int = 300, + delta: float = 4, + ): + """Register an event handler. Works as a direct call or decorator. + + Direct call:: + + plot.add_event_handler(fn, "pointer_down") + plot.add_event_handler(fn, "pointer_down", "pointer_up") + + Decorator:: + + @plot.add_event_handler("pointer_down") + def handler(event): ... + + @plot.add_event_handler("pointer_settled", ms=400, delta=5) + def on_settle(event): ... + + Parameters + ---------- + fn_or_type : callable or str + Handler function (direct call) or first event type string (decorator). + *args : str + Remaining event type strings. + order : float + Priority. Lower fires first. Default 0. + ms : int + ``pointer_settled`` dwell threshold in milliseconds. Default 300. + Raises ``ValueError`` if provided without ``"pointer_settled"`` in types. + delta : float + ``pointer_settled`` pixel radius. Default 4. + Raises ``ValueError`` if provided without ``"pointer_settled"`` in types. + """ + if callable(fn_or_type): + return self._register(fn_or_type, args, order=order, ms=ms, delta=delta) + else: + all_types = (fn_or_type,) + args + def _decorator(fn: Callable) -> Callable: + self._register(fn, all_types, order=order, ms=ms, delta=delta) + return fn + return _decorator + + def _register( + self, fn: Callable, types: tuple, *, order: float, ms: int, delta: float + ) -> Callable: + has_settled = "pointer_settled" in types + _ms_changed = ms != 300 + _delta_changed = delta != 4 + if (_ms_changed or _delta_changed) and not has_settled: + raise ValueError( + "ms/delta kwargs are only valid when 'pointer_settled' is in the event types" + ) + for event_type in types: + self.callbacks.connect(event_type, fn, order=order) + if has_settled: + self._configure_pointer_settled(ms, delta) + fn._event_types = getattr(fn, "_event_types", set()) | set(types) + return fn + + def remove_handler(self, cid_or_fn, *types: str) -> None: + """Remove a registered handler. + + Parameters + ---------- + cid_or_fn : int or callable + CID returned by ``callbacks.connect()`` or the handler function. + *types : str + If given, only remove from these types. If omitted, remove from all. + """ + if isinstance(cid_or_fn, int): + self.callbacks.disconnect(cid_or_fn) + else: + self.callbacks.disconnect_fn(cid_or_fn, *types) + if not self.callbacks._handlers.get("pointer_settled"): + self._configure_pointer_settled(0, 0) + + def _configure_pointer_settled(self, ms: int, delta: float) -> None: + """Override in plot subclasses to push thresholds to JS.""" + pass + + @contextmanager + def pause_events(self, *types: str): + """Suppress events of the given types (all types if none given).""" + with self.callbacks.pause_events(*types): + yield + + @contextmanager + def hold_events(self, *types: str): + """Buffer events of the given types; flush when context exits.""" + with self.callbacks.hold_events(*types): + yield diff --git a/anyplotlib/tests/test_interactive/test_callbacks.py b/anyplotlib/tests/test_interactive/test_callbacks.py index e7287d55..5dd4a4f4 100644 --- a/anyplotlib/tests/test_interactive/test_callbacks.py +++ b/anyplotlib/tests/test_interactive/test_callbacks.py @@ -2,7 +2,7 @@ from __future__ import annotations import time import pytest -from anyplotlib.callbacks import Event, CallbackRegistry, VALID_EVENT_TYPES +from anyplotlib.callbacks import Event, CallbackRegistry, VALID_EVENT_TYPES, _EventMixin # ── Event dataclass ─────────────────────────────────────────────────────────── @@ -291,3 +291,122 @@ def test_pause_wins_over_hold(self): with reg.pause_events("pointer_move"): reg.fire(Event("pointer_move")) assert calls == [] # dropped, not buffered then flushed + + +class _FakePlot(_EventMixin): + """Minimal plot stub for testing _EventMixin.""" + def __init__(self): + self.callbacks = CallbackRegistry() + self._settled_config = (0, 0) + + def _configure_pointer_settled(self, ms: int, delta: float) -> None: + self._settled_config = (ms, delta) + + +class TestEventMixin: + def test_functional_form_single_type(self): + plot = _FakePlot() + calls = [] + fn = lambda e: calls.append(e.event_type) + plot.add_event_handler(fn, "pointer_down") + plot.callbacks.fire(Event("pointer_down")) + assert calls == ["pointer_down"] + + def test_functional_form_multi_type(self): + plot = _FakePlot() + calls = [] + fn = lambda e: calls.append(e.event_type) + plot.add_event_handler(fn, "pointer_down", "pointer_up") + plot.callbacks.fire(Event("pointer_down")) + plot.callbacks.fire(Event("pointer_up")) + assert calls == ["pointer_down", "pointer_up"] + + def test_decorator_form_single_type(self): + plot = _FakePlot() + calls = [] + @plot.add_event_handler("pointer_move") + def handler(e): + calls.append(e.event_type) + plot.callbacks.fire(Event("pointer_move")) + assert calls == ["pointer_move"] + + def test_decorator_form_multi_type(self): + plot = _FakePlot() + calls = [] + @plot.add_event_handler("pointer_down", "key_down") + def handler(e): + calls.append(e.event_type) + plot.callbacks.fire(Event("pointer_down")) + plot.callbacks.fire(Event("key_down")) + assert calls == ["pointer_down", "key_down"] + + def test_wildcard_decorator(self): + plot = _FakePlot() + calls = [] + @plot.add_event_handler("*") + def handler(e): + calls.append(e.event_type) + plot.callbacks.fire(Event("pointer_down")) + plot.callbacks.fire(Event("wheel")) + assert calls == ["pointer_down", "wheel"] + + def test_remove_handler_by_fn(self): + plot = _FakePlot() + calls = [] + fn = lambda e: calls.append(1) + plot.add_event_handler(fn, "pointer_down") + plot.remove_handler(fn) + plot.callbacks.fire(Event("pointer_down")) + assert calls == [] + + def test_remove_handler_by_fn_specific_type(self): + plot = _FakePlot() + calls = [] + fn = lambda e: calls.append(e.event_type) + plot.add_event_handler(fn, "pointer_down", "pointer_up") + plot.remove_handler(fn, "pointer_down") + plot.callbacks.fire(Event("pointer_down")) + plot.callbacks.fire(Event("pointer_up")) + assert calls == ["pointer_up"] + + def test_remove_handler_by_cid(self): + plot = _FakePlot() + calls = [] + cid = plot.callbacks.connect("pointer_down", lambda e: calls.append(1)) + plot.remove_handler(cid) + plot.callbacks.fire(Event("pointer_down")) + assert calls == [] + + def test_pointer_settled_configures_on_connect(self): + plot = _FakePlot() + plot.add_event_handler(lambda e: None, "pointer_settled", ms=400, delta=5) + assert plot._settled_config == (400, 5) + + def test_pointer_settled_clears_on_last_disconnect(self): + plot = _FakePlot() + fn = lambda e: None + plot.add_event_handler(fn, "pointer_settled", ms=400, delta=5) + plot.remove_handler(fn) + assert plot._settled_config == (0, 0) + + def test_ms_delta_without_settled_raises(self): + plot = _FakePlot() + with pytest.raises(ValueError, match="ms/delta"): + plot.add_event_handler(lambda e: None, "pointer_down", ms=400) + + def test_pause_events_delegates_to_registry(self): + plot = _FakePlot() + calls = [] + plot.add_event_handler(lambda e: calls.append(1), "pointer_move") + with plot.pause_events("pointer_move"): + plot.callbacks.fire(Event("pointer_move")) + assert calls == [] + + def test_hold_events_delegates_to_registry(self): + plot = _FakePlot() + calls = [] + plot.add_event_handler(lambda e: calls.append(1), "pointer_settled") + with plot.hold_events("pointer_settled"): + plot.callbacks.fire(Event("pointer_settled")) + assert calls == [] + assert calls == [1] From b98576df0837a96a1380bc4823c87233c1bfef99 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 15 May 2026 10:48:40 -0500 Subject: [PATCH 126/198] refactor: update _dispatch_event and Widget._update_from_js to use flat Event fields --- anyplotlib/figure/_figure.py | 39 ++++++++++++++++++++-------- anyplotlib/widgets/_base.py | 50 ++++++++++++++++++++++++------------ 2 files changed, 62 insertions(+), 27 deletions(-) diff --git a/anyplotlib/figure/_figure.py b/anyplotlib/figure/_figure.py index 167ec4c3..e8caff1d 100644 --- a/anyplotlib/figure/_figure.py +++ b/anyplotlib/figure/_figure.py @@ -8,6 +8,7 @@ import json import pathlib +import time import anywidget import traitlets @@ -15,7 +16,7 @@ from anyplotlib.axes import Axes, InsetAxes from anyplotlib.axes._inset_axes import _plot_kind from anyplotlib.figure._gridspec import SubplotSpec -from anyplotlib.callbacks import Event +from anyplotlib.callbacks import CallbackRegistry, Event from anyplotlib._repr_utils import repr_html_iframe _HERE = pathlib.Path(__file__).parent.parent @@ -361,21 +362,18 @@ def _dispatch_event(self, raw: str) -> None: except Exception: return - # Echo guard — Python-originated pushes must not loop back if msg.get("source") == "python": return panel_id = msg.get("panel_id", "") - event_type = msg.get("event_type", "on_changed") + event_type = msg.get("event_type", "pointer_move") widget_id = msg.get("widget_id") - data = {k: v for k, v in msg.items() - if k not in ("source", "panel_id", "event_type", "widget_id")} - # Inset state changes are handled before regular plot dispatch - if event_type == "on_inset_state_change": + # Inset state changes handled before regular plot dispatch + if event_type == "inset_state_change": inset_ax = self._insets_map.get(panel_id) if inset_ax is not None: - new_state = data.get("new_state", "normal") + new_state = msg.get("new_state", "normal") if new_state in ("normal", "minimized", "maximized"): inset_ax._inset_state = new_state self._push_layout() @@ -389,11 +387,32 @@ def _dispatch_event(self, raw: str) -> None: if widget_id and hasattr(plot, "_widgets"): widget = plot._widgets.get(widget_id) if widget is not None: - widget._update_from_js(data, event_type) + widget._update_from_js(msg, event_type) source = widget if hasattr(plot, "callbacks"): - event = Event(event_type=event_type, source=source, data=data) + event = Event( + event_type=event_type, + source=source, + time_stamp=msg.get("time_stamp", time.perf_counter()), + modifiers=msg.get("modifiers", []), + x=msg.get("x"), + y=msg.get("y"), + button=msg.get("button"), + buttons=msg.get("buttons", 0), + xdata=msg.get("xdata"), + ydata=msg.get("ydata"), + ray=msg.get("ray"), + line_id=msg.get("line_id"), + dwell_ms=msg.get("dwell_ms"), + bar_index=msg.get("bar_index"), + value=msg.get("value"), + x_label=msg.get("x_label"), + group_index=msg.get("group_index"), + dx=msg.get("dx"), + dy=msg.get("dy"), + key=msg.get("key"), + ) plot.callbacks.fire(event) def _push_widget(self, panel_id: str, widget_id: str, fields: dict) -> None: diff --git a/anyplotlib/widgets/_base.py b/anyplotlib/widgets/_base.py index 700b64fa..5ae68eff 100644 --- a/anyplotlib/widgets/_base.py +++ b/anyplotlib/widgets/_base.py @@ -94,7 +94,7 @@ def set(self, _push: bool = True, **kwargs) -> None: self._data.update(kwargs) if _push: self._push_fn() - self.callbacks.fire(Event("on_changed", source=self, data=dict(self._data))) + self.callbacks.fire(Event("pointer_move", source=self)) def get(self, key: str, default=None): """Get a widget property by name. @@ -220,36 +220,52 @@ def hide(self) -> None: # ── JS → Python sync ────────────────────────────────────────────── - def _update_from_js(self, new_data: dict, event_type: str = "on_changed") -> bool: + def _update_from_js(self, msg: dict, event_type: str = "pointer_move") -> bool: """Apply incoming JS state without pushing back (avoids echo). + Updates widget ``_data`` with widget-specific state fields from msg, + then fires widget callbacks with a flat Event. + Parameters ---------- - new_data : dict - Updated widget properties from JavaScript. - event_type : str, optional - Type of event that triggered the update. + msg : dict + Full raw event message from JS. + event_type : str + One of the pointer event types (``pointer_move``, ``pointer_up``, + ``pointer_down``). Returns ------- bool - True if any state changed. - - Notes - ----- - Always fires on_release / on_click callbacks even if nothing changed. - Only fires on_changed if state actually changed. + True if any widget state changed. """ + _envelope = { + "source", "panel_id", "event_type", "widget_id", + "time_stamp", "modifiers", "button", "buttons", + "x", "y", "xdata", "ydata", + } changed = False - for k, v in new_data.items(): - if k in ("id", "type"): + for k, v in msg.items(): + if k in ("id", "type") or k in _envelope: continue if self._data.get(k) != v: self._data[k] = v changed = True - # Always fire for settle / click; only fire on_changed when something moved - if changed or event_type in ("on_release", "on_click"): - self.callbacks.fire(Event(event_type, source=self, data=dict(self._data))) + + if changed or event_type in ("pointer_up", "pointer_down"): + event = Event( + event_type=event_type, + source=self, + time_stamp=msg.get("time_stamp", 0.0), + modifiers=msg.get("modifiers", []), + x=msg.get("x"), + y=msg.get("y"), + button=msg.get("button"), + buttons=msg.get("buttons", 0), + xdata=msg.get("xdata"), + ydata=msg.get("ydata"), + ) + self.callbacks.fire(event) return changed # ── repr ────────────────────────────────────────────────────────── From 52cb18c7d219a932d09db5d7979dcb3d507ea0d7 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 15 May 2026 10:57:01 -0500 Subject: [PATCH 127/198] refactor: Plot1D/2D/3D/Bar and PlotMesh adopt _EventMixin, remove old on_* decorators and registered_keys --- anyplotlib/plot1d/_plot1d.py | 136 +++++++++------------------------- anyplotlib/plot1d/_plotbar.py | 79 +++----------------- anyplotlib/plot2d/_plot2d.py | 80 +++----------------- anyplotlib/plot3d/_plot3d.py | 80 +++----------------- 4 files changed, 64 insertions(+), 311 deletions(-) diff --git a/anyplotlib/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py index a16cb0fa..27a19817 100644 --- a/anyplotlib/plot1d/_plot1d.py +++ b/anyplotlib/plot1d/_plot1d.py @@ -12,7 +12,7 @@ from typing import Callable from anyplotlib.markers import MarkerRegistry -from anyplotlib.callbacks import CallbackRegistry +from anyplotlib.callbacks import CallbackRegistry, _EventMixin from anyplotlib.widgets import ( Widget, VLineWidget as _VLineWidget, @@ -77,16 +77,37 @@ def _filtered(event): fn._cid = cid return fn - def on_click(self, fn: Callable) -> Callable: - """Decorator: fires when the user clicks on *this* line only.""" + def add_event_handler(self, fn_or_type, *args, **kwargs): + """Register a handler scoped to this line only. + + Wraps the plot-level pointer_move / pointer_down handler + with a line_id filter. Only pointer_move and pointer_down + are meaningful on a line handle. + """ target_lid = self._lid + + if callable(fn_or_type): + fn = fn_or_type + types = args + return self._wrap_and_register(fn, types, target_lid, **kwargs) + else: + all_types = (fn_or_type,) + args + def _decorator(fn): + return self._wrap_and_register(fn, all_types, target_lid, **kwargs) + return _decorator + + def _wrap_and_register(self, fn, types, target_lid, **kwargs): + from functools import wraps + @wraps(fn) def _filtered(event): - if event.data.get("line_id") == target_lid: + if event.line_id == target_lid: fn(event) - cid = self._plot.callbacks.connect("on_line_click", _filtered) - _filtered._cid = cid - fn._cid = cid - return fn + _filtered.__wrapped__ = fn + return self._plot.add_event_handler(_filtered, *types, **kwargs) + + def remove_handler(self, cid_or_fn, *types): + """Remove a handler registered via this line handle.""" + self._plot.remove_handler(cid_or_fn, *types) def set_data(self, y: "np.ndarray", x_axis=None) -> None: """Update the y-data (and optionally x-axis) of this overlay line. @@ -138,7 +159,7 @@ def remove(self) -> None: # Plot1D # --------------------------------------------------------------------------- -class Plot1D: +class Plot1D(_EventMixin): """1-D line plot panel returned by :meth:`Axes.plot`. All display state is stored in a plain ``_state`` dict. Every mutation @@ -276,7 +297,8 @@ def __init__(self, data: np.ndarray, "spans": [], "overlay_widgets": [], "markers": [], - "registered_keys": [], + "pointer_settled_ms": 0, + "pointer_settled_delta": 4, } self.markers = MarkerRegistry(self._push_markers, @@ -284,6 +306,11 @@ def __init__(self, data: np.ndarray, self.callbacks = CallbackRegistry() self._widgets: dict[str, Widget] = {} + def _configure_pointer_settled(self, ms: int, delta: float) -> None: + self._state["pointer_settled_ms"] = ms + self._state["pointer_settled_delta"] = delta + self._push() + def _push(self) -> None: if self._fig is None: return @@ -730,95 +757,6 @@ def clear_widgets(self) -> None: self._widgets.clear() self._push() - # ------------------------------------------------------------------ - # Callback API (Plot1D) - # ------------------------------------------------------------------ - def on_changed(self, fn: Callable) -> Callable: - """Decorator: fires on every drag/zoom frame on this panel.""" - cid = self.callbacks.connect("on_changed", fn) - fn._cid = cid - return fn - - def on_release(self, fn: Callable) -> Callable: - """Decorator: fires once when drag/zoom settles on this panel.""" - cid = self.callbacks.connect("on_release", fn) - fn._cid = cid - return fn - - def on_click(self, fn: Callable) -> Callable: - """Decorator: fires on click on this panel.""" - cid = self.callbacks.connect("on_click", fn) - fn._cid = cid - return fn - - def on_key(self, key_or_fn=None) -> Callable: - """Register a key-press handler for this panel. - - Two call forms are supported:: - - @plot.on_key('q') # fires only when 'q' is pressed - def handler(event): ... - - @plot.on_key # fires for every registered key - def handler(event): ... - - The event carries: ``key``, ``mouse_x``, ``mouse_y``, ``phys_x``, - and ``last_widget_id``. - - .. note:: - Registered keys take priority over the built-in **r** (reset view) - shortcut. - """ - if callable(key_or_fn): - return self._connect_on_key(None, key_or_fn) - key = key_or_fn - def _decorator(fn): - return self._connect_on_key(key, fn) - return _decorator - - def _connect_on_key(self, key, fn) -> Callable: - if key is None: - if '*' not in self._state['registered_keys']: - self._state['registered_keys'].append('*') - self._push() - cid = self.callbacks.connect("on_key", fn) - else: - if key not in self._state['registered_keys']: - self._state['registered_keys'].append(key) - self._push() - def _wrapped(event): - if event.data.get('key') == key: - fn(event) - cid = self.callbacks.connect("on_key", _wrapped) - _wrapped._cid = cid - fn._cid = cid - return fn - - def disconnect(self, cid: int) -> None: - """Remove the callback registered under integer *cid*.""" - self.callbacks.disconnect(cid) - - def on_line_hover(self, fn: Callable) -> Callable: - """Decorator: fires when the cursor moves over *any* line on this panel. - - The event carries ``event.line_id`` (``None`` = primary line, - str = overlay), ``event.x``, and ``event.y`` in data coordinates. - For per-line filtering use :meth:`Line1D.on_hover` instead. - """ - cid = self.callbacks.connect("on_line_hover", fn) - fn._cid = cid - return fn - - def on_line_click(self, fn: Callable) -> Callable: - """Decorator: fires when the user clicks *any* line on this panel. - - The event carries the same fields as :meth:`on_line_hover`. - For per-line filtering use :meth:`Line1D.on_click` instead. - """ - cid = self.callbacks.connect("on_line_click", fn) - fn._cid = cid - return fn - # ------------------------------------------------------------------ # View control # ------------------------------------------------------------------ diff --git a/anyplotlib/plot1d/_plotbar.py b/anyplotlib/plot1d/_plotbar.py index 679a96d3..fefeafc4 100644 --- a/anyplotlib/plot1d/_plotbar.py +++ b/anyplotlib/plot1d/_plotbar.py @@ -9,7 +9,7 @@ import numpy as np from typing import Callable -from anyplotlib.callbacks import CallbackRegistry +from anyplotlib.callbacks import CallbackRegistry, _EventMixin from anyplotlib.widgets import ( Widget, VLineWidget as _VLineWidget, @@ -75,7 +75,7 @@ def _bar_range(flat: np.ndarray, bottom: float, log_scale: bool): return dmin, dmax -class PlotBar: +class PlotBar(_EventMixin): """Bar-chart plot panel. Not an anywidget. Holds state in ``_state`` dict; every mutation calls @@ -198,11 +198,17 @@ def __init__(self, x, height=None, width: float = 0.8, bottom: float = 0.0, *, "view_x0": 0.0, "view_x1": 1.0, "overlay_widgets": [], - "registered_keys": [], + "pointer_settled_ms": 0, + "pointer_settled_delta": 4, } self.callbacks = CallbackRegistry() self._widgets: dict[str, Widget] = {} + def _configure_pointer_settled(self, ms: int, delta: float) -> None: + self._state["pointer_settled_ms"] = ms + self._state["pointer_settled_delta"] = delta + self._push() + # ------------------------------------------------------------------ def _push(self) -> None: if self._fig is None: @@ -391,73 +397,6 @@ def clear_widgets(self) -> None: self._widgets.clear() self._push() - # ------------------------------------------------------------------ - # Callbacks - # ------------------------------------------------------------------ - def on_click(self, fn: Callable) -> Callable: - """Decorator: fires when the user clicks a bar. - - The :class:`~anyplotlib.callbacks.Event` has ``bar_index``, - ``value``, ``x_center``, and ``x_label``. - """ - cid = self.callbacks.connect("on_click", fn) - fn._cid = cid - return fn - - def on_changed(self, fn: Callable) -> Callable: - """Decorator: fires on every drag frame (widget drag or hover).""" - cid = self.callbacks.connect("on_changed", fn) - fn._cid = cid - return fn - - def on_release(self, fn: Callable) -> Callable: - """Decorator: fires once when a widget drag settles.""" - cid = self.callbacks.connect("on_release", fn) - fn._cid = cid - return fn - - def on_key(self, key_or_fn=None) -> Callable: - """Register a key-press handler for this panel. - - Two call forms are supported:: - - @plot.on_key('q') # fires only when 'q' is pressed - def handler(event): ... - - @plot.on_key # fires for every registered key - def handler(event): ... - - The event carries: ``key``, ``mouse_x``, ``mouse_y``, and - ``last_widget_id``. - """ - if callable(key_or_fn): - return self._connect_on_key(None, key_or_fn) - key = key_or_fn - def _decorator(fn): - return self._connect_on_key(key, fn) - return _decorator - - def _connect_on_key(self, key, fn) -> Callable: - if key is None: - if '*' not in self._state['registered_keys']: - self._state['registered_keys'].append('*') - self._push() - cid = self.callbacks.connect("on_key", fn) - else: - if key not in self._state['registered_keys']: - self._state['registered_keys'].append(key) - self._push() - def _wrapped(event): - if event.data.get('key') == key: - fn(event) - cid = self.callbacks.connect("on_key", _wrapped) - _wrapped._cid = cid - fn._cid = cid - return fn - - def disconnect(self, cid: int) -> None: - self.callbacks.disconnect(cid) - def __repr__(self) -> str: n = len(self._state.get("values", [])) orient = self._state.get("orient", "v") diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index 91310ee0..280af9f8 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -10,7 +10,7 @@ from typing import Callable from anyplotlib.markers import MarkerRegistry -from anyplotlib.callbacks import CallbackRegistry +from anyplotlib.callbacks import CallbackRegistry, _EventMixin from anyplotlib.widgets import ( Widget, RectangleWidget, CircleWidget, AnnularWidget, @@ -19,7 +19,7 @@ from anyplotlib._utils import _normalize_image, _build_colormap_lut -class Plot2D: +class Plot2D(_EventMixin): """2-D image plot panel. Not an anywidget. Holds state in ``_state`` dict; every mutation calls @@ -117,7 +117,8 @@ def __init__(self, data: np.ndarray, "center_y": 0.5, "overlay_widgets": [], "markers": [], - "registered_keys": [], + "pointer_settled_ms": 0, + "pointer_settled_delta": 4, # Transparent mask overlay (set via set_overlay_mask) "overlay_mask_b64": "", "overlay_mask_color": "#ff4444", @@ -132,6 +133,11 @@ def __init__(self, data: np.ndarray, self.callbacks = CallbackRegistry() self._widgets: dict[str, Widget] = {} + def _configure_pointer_settled(self, ms: int, delta: float) -> None: + self._state["pointer_settled_ms"] = ms + self._state["pointer_settled_delta"] = delta + self._push() + @staticmethod def _encode_bytes(arr: np.ndarray) -> str: import base64 @@ -381,74 +387,6 @@ def clear_widgets(self) -> None: self._widgets.clear() self._push() - # ------------------------------------------------------------------ - # Callback API (Plot2D) - # ------------------------------------------------------------------ - def on_changed(self, fn: Callable) -> Callable: - """Decorator: fires on every pan/zoom/drag frame on this panel.""" - cid = self.callbacks.connect("on_changed", fn) - fn._cid = cid - return fn - - def on_release(self, fn: Callable) -> Callable: - """Decorator: fires once when pan/zoom/drag settles on this panel.""" - cid = self.callbacks.connect("on_release", fn) - fn._cid = cid - return fn - - def on_click(self, fn: Callable) -> Callable: - """Decorator: fires on click on this panel.""" - cid = self.callbacks.connect("on_click", fn) - fn._cid = cid - return fn - - def on_key(self, key_or_fn=None) -> Callable: - """Register a key-press handler for this panel. - - Two call forms are supported:: - - @plot.on_key('q') # fires only when 'q' is pressed - def handler(event): ... - - @plot.on_key # fires for every registered key - def handler(event): ... - - The event carries: ``key``, ``mouse_x``, ``mouse_y``, ``phys_x``, - and ``last_widget_id``. - - .. note:: - Registered keys take priority over the built-in **r** (reset view) - shortcut. - """ - if callable(key_or_fn): - return self._connect_on_key(None, key_or_fn) - key = key_or_fn - def _decorator(fn): - return self._connect_on_key(key, fn) - return _decorator - - def _connect_on_key(self, key, fn) -> Callable: - if key is None: - if '*' not in self._state['registered_keys']: - self._state['registered_keys'].append('*') - self._push() - cid = self.callbacks.connect("on_key", fn) - else: - if key not in self._state['registered_keys']: - self._state['registered_keys'].append(key) - self._push() - def _wrapped(event): - if event.data.get('key') == key: - fn(event) - cid = self.callbacks.connect("on_key", _wrapped) - _wrapped._cid = cid - fn._cid = cid - return fn - - def disconnect(self, cid: int) -> None: - """Remove the callback registered under integer *cid*.""" - self.callbacks.disconnect(cid) - # ------------------------------------------------------------------ # View control # ------------------------------------------------------------------ diff --git a/anyplotlib/plot3d/_plot3d.py b/anyplotlib/plot3d/_plot3d.py index 4033defb..48638c7b 100644 --- a/anyplotlib/plot3d/_plot3d.py +++ b/anyplotlib/plot3d/_plot3d.py @@ -10,7 +10,7 @@ import numpy as np -from anyplotlib.callbacks import CallbackRegistry +from anyplotlib.callbacks import CallbackRegistry, _EventMixin from anyplotlib._utils import _arr_to_b64, _build_colormap_lut @@ -25,7 +25,7 @@ def _triangulate_grid(rows: int, cols: int) -> list: return faces -class Plot3D: +class Plot3D(_EventMixin): """3-D plot panel. Supports three geometry types matching matplotlib's 3-D Axes API: @@ -125,10 +125,16 @@ def __init__(self, geom_type: str, "elevation": float(elevation), "zoom": float(zoom), "data_bounds": data_bounds, - "registered_keys": [], + "pointer_settled_ms": 0, + "pointer_settled_delta": 4, } self.callbacks = CallbackRegistry() + def _configure_pointer_settled(self, ms: int, delta: float) -> None: + self._state["pointer_settled_ms"] = ms + self._state["pointer_settled_delta"] = delta + self._push() + # ------------------------------------------------------------------ def _push(self) -> None: if self._fig is None: @@ -138,74 +144,6 @@ def _push(self) -> None: def to_state_dict(self) -> dict: return dict(self._state) - # ------------------------------------------------------------------ - # Callback API (Plot3D) - # ------------------------------------------------------------------ - def on_changed(self, fn: Callable) -> Callable: - """Decorator: fires on every rotation/zoom frame.""" - cid = self.callbacks.connect("on_changed", fn) - fn._cid = cid - return fn - - def on_release(self, fn: Callable) -> Callable: - """Decorator: fires once when rotation/zoom settles.""" - cid = self.callbacks.connect("on_release", fn) - fn._cid = cid - return fn - - def on_click(self, fn: Callable) -> Callable: - """Decorator: fires on click on this panel.""" - cid = self.callbacks.connect("on_click", fn) - fn._cid = cid - return fn - - def on_key(self, key_or_fn=None) -> Callable: - """Register a key-press handler for this panel. - - Two call forms are supported:: - - @plot.on_key('q') # fires only when 'q' is pressed - def handler(event): ... - - @plot.on_key # fires for every registered key - def handler(event): ... - - The event carries: ``key``, ``mouse_x``, ``mouse_y``, and - ``last_widget_id``. - - .. note:: - Registered keys take priority over the built-in **r** (reset view) - shortcut. - """ - if callable(key_or_fn): - return self._connect_on_key(None, key_or_fn) - key = key_or_fn - def _decorator(fn): - return self._connect_on_key(key, fn) - return _decorator - - def _connect_on_key(self, key, fn) -> Callable: - if key is None: - if '*' not in self._state['registered_keys']: - self._state['registered_keys'].append('*') - self._push() - cid = self.callbacks.connect("on_key", fn) - else: - if key not in self._state['registered_keys']: - self._state['registered_keys'].append(key) - self._push() - def _wrapped(event): - if event.data.get('key') == key: - fn(event) - cid = self.callbacks.connect("on_key", _wrapped) - _wrapped._cid = cid - fn._cid = cid - return fn - - def disconnect(self, cid: int) -> None: - """Remove the callback registered under integer *cid*.""" - self.callbacks.disconnect(cid) - # ------------------------------------------------------------------ # Display settings # ------------------------------------------------------------------ From ab31496f159c2441d1c8e419bf6c1489bb2d8fe9 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 15 May 2026 11:26:31 -0500 Subject: [PATCH 128/198] refactor: Widget adopts _EventMixin, remove old on_changed/on_release/on_click/disconnect; update tests - Widget base class now inherits _EventMixin for add_event_handler/remove_handler API - Removed on_changed, on_release, on_click decorator methods and disconnect from Widget - Fixed _update_from_js envelope: removed x, y, xdata, ydata so widget position fields named x/y are properly updated from JS events - Added on_line_click, on_line_hover to VALID_EVENT_TYPES in callbacks.py to support Line1D event routing from the JS dispatcher - Updated test_widgets.py: all tests now use add_event_handler/remove_handler, new pointer_* event types, and read widget state from widget._data not event.data - Updated test_plotbar.py: callback tests use add_event_handler/remove_handler and pointer_down/pointer_move event types with flat Event fields --- anyplotlib/callbacks.py | 5 +- .../tests/test_interactive/test_widgets.py | 219 +++++++++--------- anyplotlib/tests/test_plot1d/test_plotbar.py | 59 +++-- anyplotlib/widgets/_base.py | 92 ++------ 4 files changed, 156 insertions(+), 219 deletions(-) diff --git a/anyplotlib/callbacks.py b/anyplotlib/callbacks.py index 28eceaa2..a9ddd25c 100644 --- a/anyplotlib/callbacks.py +++ b/anyplotlib/callbacks.py @@ -24,7 +24,10 @@ VALID_EVENT_TYPES = frozenset({ "pointer_down", "pointer_up", "pointer_move", "pointer_settled", "pointer_enter", "pointer_leave", "double_click", "wheel", - "key_down", "key_up", "*", + "key_down", "key_up", + # Plot1D line-specific events (forwarded verbatim from JS) + "on_line_click", "on_line_hover", + "*", }) diff --git a/anyplotlib/tests/test_interactive/test_widgets.py b/anyplotlib/tests/test_interactive/test_widgets.py index 218727ca..f2a12bd7 100644 --- a/anyplotlib/tests/test_interactive/test_widgets.py +++ b/anyplotlib/tests/test_interactive/test_widgets.py @@ -6,8 +6,8 @@ Covers: * Widget creation, attribute access, set(), to_dict(), __setattr__ - * on_changed / on_release / on_click decorator + disconnect - * _update_from_js — always fires for on_release/on_click + * add_event_handler / remove_handler (new _EventMixin API) + * _update_from_js — always fires for pointer_up/pointer_down * Widget visibility — hide() / show() * Plot2D / Plot1D widget integration (add / remove / list / clear) * Figure event_json dispatch (JS→Python path via _simulate_js_event) @@ -139,60 +139,62 @@ class TestWidgetCallbacks: def test_on_changed_fires(self): w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10) results = [] - w.on_changed(lambda event: results.append(event.x)) + w.add_event_handler(lambda event: results.append(w.x), "pointer_move") w.set(x=42) assert results == [42.0] def test_on_changed_event_source_is_widget(self): w = CircleWidget(lambda: None, cx=0, cy=0, r=5) received = [] - w.on_changed(lambda event: received.append(event.source)) + w.add_event_handler(lambda event: received.append(event.source), "pointer_move") w.set(cx=10) assert received[0] is w def test_multiple_callbacks(self): w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10) a, b = [], [] - w.on_changed(lambda event: a.append(1)) - w.on_changed(lambda event: b.append(1)) + w.add_event_handler(lambda event: a.append(1), "pointer_move") + w.add_event_handler(lambda event: b.append(1), "pointer_move") w.set(x=1) assert len(a) == 1 and len(b) == 1 def test_disconnect_by_fn(self): - """Disconnecting using the function object (which has ._cid) should work.""" + """Disconnecting using the function object should work.""" w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10) results = [] - fn = w.on_changed(lambda event: results.append(1)) + fn = lambda event: results.append(1) + w.add_event_handler(fn, "pointer_move") w.set(x=1); assert len(results) == 1 - w.disconnect(fn) # fn._cid is used + w.remove_handler(fn) w.set(x=2); assert len(results) == 1 def test_disconnect_by_cid(self): - """Disconnecting using the integer CID should also work.""" + """Disconnecting using remove_handler with a callable should work.""" w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10) results = [] - fn = w.on_changed(lambda event: results.append(1)) - w.disconnect(fn._cid) + fn = lambda event: results.append(1) + w.add_event_handler(fn, "pointer_move") + w.remove_handler(fn) w.set(x=2) assert results == [] def test_disconnect_nonexistent_silent(self): w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10) - w.disconnect(9999) + w.remove_handler(9999) def test_on_release_decorator(self): w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10) results = [] - w.on_release(lambda event: results.append(event.event_type)) - w.callbacks.fire(Event("on_release", w, {"x": 5.0})) - assert results == ["on_release"] + w.add_event_handler(lambda event: results.append(event.event_type), "pointer_up") + w.callbacks.fire(Event("pointer_up", w)) + assert results == ["pointer_up"] def test_on_click_decorator(self): w = CircleWidget(lambda: None, cx=0, cy=0, r=5) results = [] - w.on_click(lambda event: results.append(event.event_type)) - w.callbacks.fire(Event("on_click", w, {})) - assert results == ["on_click"] + w.add_event_handler(lambda event: results.append(event.event_type), "pointer_down") + w.callbacks.fire(Event("pointer_down", w)) + assert results == ["pointer_down"] class TestWidgetUpdateFromJs: @@ -209,32 +211,32 @@ def test_update_returns_false_on_no_change(self): def test_update_fires_on_changed_when_changed(self): w = RectangleWidget(lambda: None, x=0, y=0, w=10, h=10) results = [] - w.on_changed(lambda event: results.append(event.x)) + w.add_event_handler(lambda event: results.append(event.x), "pointer_move") w._update_from_js({"x": 99.0}) assert results == [99.0] def test_update_does_not_fire_on_changed_if_unchanged(self): w = RectangleWidget(lambda: None, x=5, y=5, w=10, h=10, color="#abc") results = [] - w.on_changed(lambda event: results.append(1)) + w.add_event_handler(lambda event: results.append(1), "pointer_move") w._update_from_js({"x": 5.0, "y": 5.0, "w": 10.0, "h": 10.0, "color": "#abc"}) assert results == [] def test_update_always_fires_on_release(self): - """on_release fires even when nothing changed (drag ended in place).""" + """pointer_up fires even when nothing changed (drag ended in place).""" w = RectangleWidget(lambda: None, x=5, y=5, w=10, h=10) results = [] - w.on_release(lambda event: results.append(1)) + w.add_event_handler(lambda event: results.append(1), "pointer_up") w._update_from_js({"x": 5.0, "y": 5.0, "w": 10.0, "h": 10.0}, - event_type="on_release") + event_type="pointer_up") assert results == [1] def test_update_always_fires_on_click(self): - """on_click fires even when nothing changed.""" + """pointer_down fires even when nothing changed.""" w = CrosshairWidget(lambda: None, cx=16.0, cy=16.0) results = [] - w.on_click(lambda event: results.append(1)) - w._update_from_js({"cx": 16.0, "cy": 16.0}, event_type="on_click") + w.add_event_handler(lambda event: results.append(1), "pointer_down") + w._update_from_js({"cx": 16.0, "cy": 16.0}, event_type="pointer_down") assert results == [1] def test_id_and_type_ignored(self): @@ -392,35 +394,35 @@ def test_rectangle_drag_fires_on_changed(self): v = ax.imshow(np.zeros((32, 32))) w = v.add_widget("rectangle", x=10, y=10, w=20, h=20) results = [] - w.on_changed(lambda event: results.append((event.x, event.y))) + w.add_event_handler(lambda event: results.append((event.x, event.y)), "pointer_move") - _simulate_js_event(fig, v, "on_changed", widget_id=w, x=50.0, y=60.0) + _simulate_js_event(fig, v, "pointer_move", widget_id=w, x=50.0, y=60.0) assert len(results) == 1 assert results[0] == (50.0, 60.0) assert w.x == 50.0 and w.y == 60.0 def test_no_change_no_on_changed_callback(self): - """on_changed must NOT fire when nothing actually changed.""" + """pointer_move must NOT fire when nothing actually changed.""" fig, ax = apl.subplots(1, 1) v = ax.imshow(np.zeros((32, 32))) w = v.add_widget("rectangle", x=10, y=10, w=20, h=20) results = [] - w.on_changed(lambda event: results.append(1)) + w.add_event_handler(lambda event: results.append(1), "pointer_move") - _simulate_js_event(fig, v, "on_changed", widget_id=w, + _simulate_js_event(fig, v, "pointer_move", widget_id=w, x=10.0, y=10.0, w=20.0, h=20.0) assert results == [] def test_on_release_always_fires(self): - """on_release fires even when position didn't change.""" + """pointer_up fires even when position didn't change.""" fig, ax = apl.subplots(1, 1) v = ax.imshow(np.zeros((32, 32))) w = v.add_widget("rectangle", x=10, y=10, w=20, h=20) results = [] - w.on_release(lambda event: results.append(1)) + w.add_event_handler(lambda event: results.append(1), "pointer_up") - _simulate_js_event(fig, v, "on_release", widget_id=w, + _simulate_js_event(fig, v, "pointer_up", widget_id=w, x=10.0, y=10.0, w=20.0, h=20.0) assert len(results) == 1 @@ -429,42 +431,42 @@ def test_on_click_fires(self): v = ax.imshow(np.zeros((32, 32))) w = v.add_widget("crosshair", cx=16.0, cy=16.0) results = [] - w.on_click(lambda event: results.append(event.cx)) + w.add_event_handler(lambda event: results.append(w.cx), "pointer_down") - _simulate_js_event(fig, v, "on_click", widget_id=w, cx=16.0, cy=16.0) + _simulate_js_event(fig, v, "pointer_down", widget_id=w, cx=16.0, cy=16.0) assert len(results) == 1 assert results[0] == pytest.approx(16.0) def test_on_click_line1d_overlay_fires(self): - """Line1D.on_click fires when JS sends on_line_click with the matching line_id.""" + """Line1D.add_event_handler fires when JS sends on_line_click with the matching line_id.""" fig, ax = apl.subplots(1, 1) v = ax.plot(np.zeros(64)) line = v.add_line(np.ones(64), color="#ff0000") results = [] - line.on_click(lambda event: results.append(event.line_id)) + line.add_event_handler(lambda event: results.append(event.line_id), "on_line_click") _simulate_js_event(fig, v, "on_line_click", line_id=line.id) assert len(results) == 1 assert results[0] == line.id def test_on_click_line1d_primary_fires(self): - """Line1D.on_click on the primary line fires when JS sends on_line_click with no line_id.""" + """Line1D.add_event_handler on the primary line fires when JS sends on_line_click with no line_id.""" fig, ax = apl.subplots(1, 1) v = ax.plot(np.zeros(64)) results = [] - v.line.on_click(lambda event: results.append(1)) + v.line.add_event_handler(lambda event: results.append(1), "on_line_click") - # No line_id in payload → event.data.get("line_id") is None → matches primary + # No line_id in payload → event.line_id is None → matches primary _simulate_js_event(fig, v, "on_line_click") assert len(results) == 1 def test_on_click_line1d_wrong_id_no_fire(self): - """Line1D.on_click does NOT fire when the JS event carries a different line_id.""" + """Line1D.add_event_handler does NOT fire when the JS event carries a different line_id.""" fig, ax = apl.subplots(1, 1) v = ax.plot(np.zeros(64)) line = v.add_line(np.ones(64), color="#00ff00") results = [] - line.on_click(lambda event: results.append(1)) + line.add_event_handler(lambda event: results.append(1), "on_line_click") _simulate_js_event(fig, v, "on_line_click", line_id="completely-wrong-id") assert results == [] @@ -474,19 +476,19 @@ def test_circle_drag(self): v = ax.imshow(np.zeros((32, 32))) w = v.add_widget("circle", cx=16, cy=16, r=5) results = [] - w.on_changed(lambda event: results.append(event.cx)) + w.add_event_handler(lambda event: results.append(w.cx), "pointer_move") - _simulate_js_event(fig, v, "on_changed", widget_id=w, cx=25.0) + _simulate_js_event(fig, v, "pointer_move", widget_id=w, cx=25.0) assert results == [25.0] def test_python_set_does_not_echo(self): - """Python widget.set() triggers on_changed once (from set itself), + """Python widget.set() triggers pointer_move once (from set itself), but the subsequent event_json push must NOT re-fire callbacks.""" fig, ax = apl.subplots(1, 1) v = ax.imshow(np.zeros((32, 32))) w = v.add_widget("rectangle", x=10, y=10, w=20, h=20) results = [] - w.on_changed(lambda event: results.append("cb")) + w.add_event_handler(lambda event: results.append("cb"), "pointer_move") w.set(x=99) assert results == ["cb"] # one fire from set() @@ -501,10 +503,10 @@ def test_multi_widget_only_changed_fires(self): w1 = v.add_widget("circle", cx=10, cy=10, r=5) w2 = v.add_widget("rectangle", x=0, y=0, w=10, h=10) r1, r2 = [], [] - w1.on_changed(lambda e: r1.append(1)) - w2.on_changed(lambda e: r2.append(1)) + w1.add_event_handler(lambda e: r1.append(1), "pointer_move") + w2.add_event_handler(lambda e: r2.append(1), "pointer_move") - _simulate_js_event(fig, v, "on_changed", widget_id=w2, x=50.0, y=50.0) + _simulate_js_event(fig, v, "pointer_move", widget_id=w2, x=50.0, y=50.0) assert r1 == [] assert len(r2) == 1 @@ -515,10 +517,10 @@ def test_multi_panel_routing(self): w1 = v1.add_widget("circle", cx=8, cy=8, r=3) w2 = v2.add_widget("circle", cx=8, cy=8, r=3) r1, r2 = [], [] - w1.on_changed(lambda e: r1.append(1)) - w2.on_changed(lambda e: r2.append(1)) + w1.add_event_handler(lambda e: r1.append(1), "pointer_move") + w2.add_event_handler(lambda e: r2.append(1), "pointer_move") - _simulate_js_event(fig, v1, "on_changed", widget_id=w1, cx=12.0) + _simulate_js_event(fig, v1, "pointer_move", widget_id=w1, cx=12.0) assert len(r1) == 1 and r2 == [] def test_1d_vline_drag(self): @@ -526,9 +528,9 @@ def test_1d_vline_drag(self): v = ax.plot(np.zeros(64)) w = v.add_vline_widget(x=10.0) results = [] - w.on_changed(lambda event: results.append(event.x)) + w.add_event_handler(lambda event: results.append(w.x), "pointer_move") - _simulate_js_event(fig, v, "on_changed", widget_id=w, x=30.0) + _simulate_js_event(fig, v, "pointer_move", widget_id=w, x=30.0) assert results == [30.0] def test_1d_range_drag(self): @@ -536,9 +538,9 @@ def test_1d_range_drag(self): v = ax.plot(np.zeros(64)) w = v.add_range_widget(x0=10, x1=20) results = [] - w.on_changed(lambda event: results.append((event.x0, event.x1))) + w.add_event_handler(lambda event: results.append((w.x0, w.x1)), "pointer_move") - _simulate_js_event(fig, v, "on_changed", widget_id=w, x0=15.0, x1=25.0) + _simulate_js_event(fig, v, "pointer_move", widget_id=w, x0=15.0, x1=25.0) assert results == [(15.0, 25.0)] def test_disconnect_prevents_callback(self): @@ -546,10 +548,11 @@ def test_disconnect_prevents_callback(self): v = ax.imshow(np.zeros((32, 32))) w = v.add_widget("rectangle", x=0, y=0, w=10, h=10) results = [] - fn = w.on_changed(lambda event: results.append(1)) - w.disconnect(fn) + fn = lambda event: results.append(1) + w.add_event_handler(fn, "pointer_move") + w.remove_handler(fn) - _simulate_js_event(fig, v, "on_changed", widget_id=w, x=50.0) + _simulate_js_event(fig, v, "pointer_move", widget_id=w, x=50.0) assert results == [] def test_widget_state_synced_after_js_event(self): @@ -557,7 +560,7 @@ def test_widget_state_synced_after_js_event(self): v = ax.imshow(np.zeros((32, 32))) w = v.add_widget("rectangle", x=0, y=0, w=10, h=10) - _simulate_js_event(fig, v, "on_changed", widget_id=w, + _simulate_js_event(fig, v, "pointer_move", widget_id=w, x=77.0, y=88.0, w=33.0, h=44.0) assert w.x == 77.0 and w.y == 88.0 and w.w == 33.0 and w.h == 44.0 @@ -567,7 +570,7 @@ def test_widget_x_readback_after_js_event(self): v = ax.imshow(np.zeros((32, 32))) w = v.add_widget("circle", cx=0.0, cy=0.0, r=5.0) - _simulate_js_event(fig, v, "on_release", widget_id=w, cx=20.0, cy=30.0) + _simulate_js_event(fig, v, "pointer_up", widget_id=w, cx=20.0, cy=30.0) assert w.cx == pytest.approx(20.0) assert w.cy == pytest.approx(30.0) @@ -621,15 +624,15 @@ def test_drag_rectangle_updates_fft(self): initial_b64 = v_fft._state["image_b64"] updates = [] - @rect.on_changed + @rect.add_event_handler("pointer_move") def on_rect_changed(event): log_mag, freq_x, freq_y = self._compute_fft( - img, event.x, event.y, event.w, event.h) + img, rect.x, rect.y, rect.w, rect.h) v_fft.set_data(log_mag, x_axis=freq_x, y_axis=freq_y, units="1/Å") - updates.append({"x": event.x, "y": event.y, - "w": event.w, "h": event.h}) + updates.append({"x": rect.x, "y": rect.y, + "w": rect.w, "h": rect.h}) - _simulate_js_event(fig, v_real, "on_changed", widget_id=rect, + _simulate_js_event(fig, v_real, "pointer_move", widget_id=rect, x=0.0, y=0.0, w=48.0, h=48.0) assert len(updates) == 1 @@ -643,10 +646,10 @@ def test_multiple_drags_fire_multiple_callbacks(self): v = ax.imshow(img) rect = v.add_widget("rectangle", x=0, y=0, w=16, h=16) count = [0] - rect.on_changed(lambda e: count.__setitem__(0, count[0] + 1)) + rect.add_event_handler(lambda e: count.__setitem__(0, count[0] + 1), "pointer_move") for i in range(5): - _simulate_js_event(fig, v, "on_changed", widget_id=rect, x=float(i)) + _simulate_js_event(fig, v, "pointer_move", widget_id=rect, x=float(i)) # Only fires when something actually changed — first fire is from x=0 # (which equals the initial value, no change), then 1,2,3,4 = 4 fires @@ -657,13 +660,14 @@ def test_drag_then_disconnect(self): v = ax.imshow(np.zeros((32, 32))) rect = v.add_widget("rectangle", x=0, y=0, w=10, h=10) results = [] - fn = rect.on_changed(lambda e: results.append(1)) + fn = lambda e: results.append(1) + rect.add_event_handler(fn, "pointer_move") - _simulate_js_event(fig, v, "on_changed", widget_id=rect, x=5.0) + _simulate_js_event(fig, v, "pointer_move", widget_id=rect, x=5.0) assert len(results) == 1 - rect.disconnect(fn) - _simulate_js_event(fig, v, "on_changed", widget_id=rect, x=10.0) + rect.remove_handler(fn) + _simulate_js_event(fig, v, "pointer_move", widget_id=rect, x=10.0) assert len(results) == 1 def test_on_release_after_drags(self): @@ -674,12 +678,12 @@ def test_on_release_after_drags(self): rect = v.add_widget("rectangle", x=0, y=0, w=16, h=16) drag_count = [0]; release_count = [0] - rect.on_changed(lambda e: drag_count.__setitem__(0, drag_count[0] + 1)) - rect.on_release(lambda e: release_count.__setitem__(0, release_count[0] + 1)) + rect.add_event_handler(lambda e: drag_count.__setitem__(0, drag_count[0] + 1), "pointer_move") + rect.add_event_handler(lambda e: release_count.__setitem__(0, release_count[0] + 1), "pointer_up") for i in range(1, 6): - _simulate_js_event(fig, v, "on_changed", widget_id=rect, x=float(i)) - _simulate_js_event(fig, v, "on_release", widget_id=rect, x=5.0) + _simulate_js_event(fig, v, "pointer_move", widget_id=rect, x=float(i)) + _simulate_js_event(fig, v, "pointer_up", widget_id=rect, x=5.0) assert drag_count[0] == 5 assert release_count[0] == 1 @@ -727,18 +731,18 @@ def test_show_calls_push(self): assert len(pushed) == 1 def test_hide_does_not_fire_on_changed(self): - """hide() must NOT fire on_changed callbacks.""" + """hide() must NOT fire pointer_move callbacks.""" w = CircleWidget(lambda: None, cx=0, cy=0, r=5) fired = [] - w.on_changed(lambda e: fired.append(1)) + w.add_event_handler(lambda e: fired.append(1), "pointer_move") w.hide() assert fired == [] def test_show_does_not_fire_on_changed(self): - """show() must NOT fire on_changed callbacks.""" + """show() must NOT fire pointer_move callbacks.""" w = CircleWidget(lambda: None, cx=0, cy=0, r=5) fired = [] - w.on_changed(lambda e: fired.append(1)) + w.add_event_handler(lambda e: fired.append(1), "pointer_move") w.hide() w.show() assert fired == [] @@ -783,10 +787,10 @@ def test_hide_then_show_widget_still_draggable(self): v = ax.imshow(np.zeros((32, 32))) w = v.add_widget("circle", cx=10, cy=10, r=5) fired = [] - w.on_changed(lambda e: fired.append(e.cx)) + w.add_event_handler(lambda e: fired.append(w.cx), "pointer_move") w.hide() w.show() - _simulate_js_event(fig, v, "on_changed", widget_id=w, cx=20.0) + _simulate_js_event(fig, v, "pointer_move", widget_id=w, cx=20.0) assert fired == [20.0] def test_hide_show_1d_range_widget(self): @@ -875,14 +879,14 @@ def toggle(self): self._active = True def _wire(self): - @self._pt.on_changed + @self._pt.add_event_handler("pointer_move") def _peak_moved(event): if self._syncing: return self._syncing = True try: - self.amp = event.data["y"] - self.mu = event.data["x"] + self.amp = self._pt.y + self.mu = self._pt.x self._rng_w.set(x0=self.mu - self.sigma, x1=self.mu + self.sigma) self.line.set_data(self.component_y()) @@ -890,13 +894,13 @@ def _peak_moved(event): finally: self._syncing = False - @self._rng_w.on_changed + @self._rng_w.add_event_handler("pointer_move") def _range_moved(event): if self._syncing: return self._syncing = True try: - x0, x1 = event.data["x0"], event.data["x1"] + x0, x1 = self._rng_w.x0, self._rng_w.x1 self.mu = (x0 + x1) / 2.0 self.sigma = abs(x1 - x0) / 2.0 self._pt.set(x=self.mu) @@ -1042,7 +1046,7 @@ def test_point_drag_updates_component_amp_and_mu(self): ctrl = ctrls[0] ctrl.toggle() - _simulate_js_event(fig, plot, "on_changed", + _simulate_js_event(fig, plot, "pointer_move", widget_id=ctrl._pt, x=3.5, y=0.9) assert ctrl.mu == pytest.approx(3.5) @@ -1055,7 +1059,7 @@ def test_point_drag_updates_range_widget_position(self): ctrl.toggle() original_sigma = ctrl.sigma - _simulate_js_event(fig, plot, "on_changed", + _simulate_js_event(fig, plot, "pointer_move", widget_id=ctrl._pt, x=4.0, y=1.0) expected_x0 = 4.0 - original_sigma @@ -1070,7 +1074,7 @@ def test_point_drag_updates_component_line_data(self): ctrl.toggle() old_data = _gaussian(x, ctrl.amp, ctrl.mu, ctrl.sigma).copy() - _simulate_js_event(fig, plot, "on_changed", + _simulate_js_event(fig, plot, "pointer_move", widget_id=ctrl._pt, x=4.0, y=0.8) # Find the extra_line entry for comp_lines[0] @@ -1086,7 +1090,7 @@ def test_point_drag_triggers_refit(self): ctrl = ctrls[0] ctrl.toggle() - _simulate_js_event(fig, plot, "on_changed", + _simulate_js_event(fig, plot, "pointer_move", widget_id=ctrl._pt, x=3.5, y=0.9) assert refit_calls[0] >= 1 @@ -1101,7 +1105,7 @@ def test_point_drag_updates_fit_line(self): entry_before = next(e for e in plot._state["extra_lines"] if e["id"] == lid) old_fit = entry_before["data"].copy() - _simulate_js_event(fig, plot, "on_changed", + _simulate_js_event(fig, plot, "pointer_move", widget_id=ctrl._pt, x=4.5, y=0.5) entry_after = next(e for e in plot._state["extra_lines"] if e["id"] == lid) @@ -1115,7 +1119,7 @@ def test_range_drag_updates_mu_and_sigma(self): ctrl = ctrls[0] ctrl.toggle() - _simulate_js_event(fig, plot, "on_changed", + _simulate_js_event(fig, plot, "pointer_move", widget_id=ctrl._rng_w, x0=2.5, x1=4.5) assert ctrl.mu == pytest.approx(3.5) @@ -1127,7 +1131,7 @@ def test_range_drag_recentres_point_widget(self): ctrl = ctrls[0] ctrl.toggle() - _simulate_js_event(fig, plot, "on_changed", + _simulate_js_event(fig, plot, "pointer_move", widget_id=ctrl._rng_w, x0=2.0, x1=5.0) assert ctrl._pt.x == pytest.approx(3.5) @@ -1138,7 +1142,7 @@ def test_range_drag_updates_component_line_data(self): ctrl = ctrls[0] ctrl.toggle() - _simulate_js_event(fig, plot, "on_changed", + _simulate_js_event(fig, plot, "pointer_move", widget_id=ctrl._rng_w, x0=2.5, x1=4.5) lid = ctrl.line.id @@ -1152,7 +1156,7 @@ def test_range_drag_triggers_refit(self): ctrl = ctrls[0] ctrl.toggle() - _simulate_js_event(fig, plot, "on_changed", + _simulate_js_event(fig, plot, "pointer_move", widget_id=ctrl._rng_w, x0=2.5, x1=4.5) assert refit_calls[0] >= 1 @@ -1167,7 +1171,7 @@ def test_two_controllers_independent(self): old_mu1 = ctrls[1].mu - _simulate_js_event(fig, plot, "on_changed", + _simulate_js_event(fig, plot, "pointer_move", widget_id=ctrls[0]._pt, x=3.8, y=1.1) assert ctrls[1].mu == pytest.approx(old_mu1) @@ -1196,8 +1200,8 @@ def test_line_click_activates_controller(self): fig, plot, ctrls, fit_line, x, signal, refit_calls = self._build() ctrl = ctrls[0] - # Wire up the line.on_click handler (same as the example) - @ctrl.line.on_click + # Wire up the line click handler (same as the example) + @ctrl.line.add_event_handler("on_line_click") def _clicked(event, c=ctrl): c.toggle() @@ -1217,7 +1221,7 @@ def test_line_click_twice_hides_widgets(self): fig, plot, ctrls, fit_line, x, signal, refit_calls = self._build() ctrl = ctrls[0] - @ctrl.line.on_click + @ctrl.line.add_event_handler("on_line_click") def _clicked(event, c=ctrl): c.toggle() @@ -1242,7 +1246,7 @@ def test_line_click_wrong_line_id_no_toggle(self): fig, plot, ctrls, fit_line, x, signal, refit_calls = self._build() ctrl = ctrls[0] - @ctrl.line.on_click + @ctrl.line.add_event_handler("on_line_click") def _clicked(event, c=ctrl): c.toggle() @@ -1259,12 +1263,12 @@ def _clicked(event, c=ctrl): # ── example-mirroring tests ─────────────────────────────────────────────── def _build_with_click_handlers(self): - """Same as _build() but wires line.on_click → ctrl.toggle() for both + """Same as _build() but wires line click → ctrl.toggle() for both components, exactly as the for-loop in plot_interactive_fitting.py.""" result = self._build() _, _, controllers, *_ = result for ctrl in controllers: - @ctrl.line.on_click + @ctrl.line.add_event_handler("on_line_click") def _clicked(event, c=ctrl): c.toggle() return result @@ -1357,7 +1361,7 @@ def test_example_click_then_drag_updates_fit(self): e for e in plot._state["extra_lines"] if e["id"] == lid )["data"].copy() - _simulate_js_event(fig, plot, "on_changed", + _simulate_js_event(fig, plot, "pointer_move", widget_id=ctrls[0]._pt, x=4.0, y=0.8) fit_after = next( @@ -1374,6 +1378,3 @@ def test_example_wrong_line_id_not_clickable(self): _simulate_js_event(fig, plot, "on_line_click", line_id="no-such-line") assert ctrls[0]._active is False assert ctrls[1]._active is False - - - diff --git a/anyplotlib/tests/test_plot1d/test_plotbar.py b/anyplotlib/tests/test_plot1d/test_plotbar.py index 3f5dbe86..11fd9246 100644 --- a/anyplotlib/tests/test_plot1d/test_plotbar.py +++ b/anyplotlib/tests/test_plot1d/test_plotbar.py @@ -469,44 +469,43 @@ def test_has_callback_registry(self): def test_on_click_decorator_returns_fn(self): p = _make_bar() fn = lambda e: None - assert p.on_click(fn) is fn + result = p.add_event_handler(fn, "pointer_down") + assert result is fn - def test_on_click_stamps_cid(self): + def test_on_click_stamps_event_types(self): p = _make_bar() - @p.on_click + @p.add_event_handler("pointer_down") def cb(event): pass - assert hasattr(cb, "_cid") and isinstance(cb._cid, int) + assert hasattr(cb, "_event_types") and "pointer_down" in cb._event_types def test_on_click_fires(self): p = _make_bar() fired = [] - @p.on_click + @p.add_event_handler("pointer_down") def cb(event): fired.append(event) - p.callbacks.fire(Event("on_click", p, {"bar_index": 2, "value": 3.0, - "group_index": 0, "group_value": 3.0})) + p.callbacks.fire(Event("pointer_down", p, bar_index=2, value=3.0, + group_index=0)) assert len(fired) == 1 def test_on_click_event_data_with_group(self): p = _make_bar([10, 20, 30]) fired = [] - @p.on_click + @p.add_event_handler("pointer_down") def cb(event): fired.append(event) - p.callbacks.fire(Event("on_click", p, - {"bar_index": 1, "value": 20.0, - "group_index": 0, "group_value": 20.0, - "x_center": 1.0, "x_label": "B"})) + p.callbacks.fire(Event("pointer_down", p, + bar_index=1, value=20.0, + group_index=0, + x_label="B")) ev = fired[0] assert ev.bar_index == 1 assert ev.value == pytest.approx(20.0) assert ev.group_index == 0 - assert ev.group_value == pytest.approx(20.0) - assert ev.x_center == pytest.approx(1.0) assert ev.x_label == "B" def test_on_click_grouped_event(self): @@ -514,58 +513,58 @@ def test_on_click_grouped_event(self): p = ax.bar(["A", "B"], [[1, 10], [2, 20]]) fired = [] - @p.on_click + @p.add_event_handler("pointer_down") def cb(event): fired.append(event) - p.callbacks.fire(Event("on_click", p, - {"bar_index": 1, "group_index": 1, - "value": 20.0, "group_value": 20.0, - "x_center": 1.0, "x_label": "B"})) + p.callbacks.fire(Event("pointer_down", p, + bar_index=1, group_index=1, + value=20.0, + x_label="B")) assert fired[0].group_index == 1 - assert fired[0].group_value == pytest.approx(20.0) + assert fired[0].value == pytest.approx(20.0) def test_on_changed_fires(self): p = _make_bar() fired = [] - @p.on_changed + @p.add_event_handler("pointer_move") def cb(event): fired.append(event) - p.callbacks.fire(Event("on_changed", p, {})) + p.callbacks.fire(Event("pointer_move", p)) assert len(fired) == 1 def test_on_click_not_fired_by_on_changed(self): p = _make_bar() fired = [] - @p.on_click + @p.add_event_handler("pointer_down") def cb(event): fired.append(event) - p.callbacks.fire(Event("on_changed", p, {})) + p.callbacks.fire(Event("pointer_move", p)) assert fired == [] def test_disconnect(self): p = _make_bar() fired = [] - @p.on_click + @p.add_event_handler("pointer_down") def cb(event): fired.append(event) - p.disconnect(cb._cid) - p.callbacks.fire(Event("on_click", p, {})) + p.remove_handler(cb) + p.callbacks.fire(Event("pointer_down", p)) assert fired == [] def test_multiple_on_click_handlers(self): p = _make_bar() log = [] - @p.on_click + @p.add_event_handler("pointer_down") def cb1(event): log.append("a") - @p.on_click + @p.add_event_handler("pointer_down") def cb2(event): log.append("b") - p.callbacks.fire(Event("on_click", p, {})) + p.callbacks.fire(Event("pointer_down", p)) assert sorted(log) == ["a", "b"] diff --git a/anyplotlib/widgets/_base.py b/anyplotlib/widgets/_base.py index 5ae68eff..e73f2301 100644 --- a/anyplotlib/widgets/_base.py +++ b/anyplotlib/widgets/_base.py @@ -7,10 +7,10 @@ from __future__ import annotations import uuid as _uuid from typing import Any, Callable -from anyplotlib.callbacks import CallbackRegistry, Event +from anyplotlib.callbacks import CallbackRegistry, Event, _EventMixin -class Widget: +class Widget(_EventMixin): """Base class for all overlay widgets. Provides attribute-based state access, callbacks for interaction events, @@ -28,10 +28,15 @@ class Widget: Attributes ---------- callbacks : CallbackRegistry - Event callback registry. Register handlers via: - - ``@widget.on_changed`` — fires on every drag frame - - ``@widget.on_release`` — fires once when drag settles - - ``@widget.on_click`` — fires on click event + Event callback registry. Register handlers via + ``widget.add_event_handler(fn, "pointer_move")`` or as a decorator: + ``@widget.add_event_handler("pointer_move")``. + + Common event types: + + - ``"pointer_move"`` — fires on every drag frame + - ``"pointer_up"`` — fires once when drag settles + - ``"pointer_down"`` — fires on click/press event """ def __init__(self, wtype: str, push_fn: Callable, **kwargs): @@ -123,76 +128,6 @@ def to_dict(self) -> dict: """ return dict(self._data) - # ── callback decorator methods ──────────────────────────────────── - - def on_changed(self, fn: Callable) -> Callable: - """Decorator: register fn to fire on every drag frame. - - Use this for high-frequency updates (keep handler fast). - - Parameters - ---------- - fn : Callable - Handler function receiving an Event. - - Returns - ------- - Callable - The decorated function. - """ - cid = self.callbacks.connect("on_changed", fn) - fn._cid = cid - return fn - - def on_release(self, fn: Callable) -> Callable: - """Decorator: register fn to fire once when drag settles. - - Use this for expensive operations triggered after user stops dragging. - - Parameters - ---------- - fn : Callable - Handler function receiving an Event. - - Returns - ------- - Callable - The decorated function. - """ - cid = self.callbacks.connect("on_release", fn) - fn._cid = cid - return fn - - def on_click(self, fn: Callable) -> Callable: - """Decorator: register fn to fire on widget click. - - Parameters - ---------- - fn : Callable - Handler function receiving an Event. - - Returns - ------- - Callable - The decorated function. - """ - cid = self.callbacks.connect("on_click", fn) - fn._cid = cid - return fn - - def disconnect(self, cid) -> None: - """Remove the callback registered under *cid*. - - Parameters - ---------- - cid : int or Callable - Either the integer CID returned by ``callbacks.connect()``, - or the decorated function itself (carries a ``._cid`` attribute). - """ - if callable(cid) and hasattr(cid, "_cid"): - cid = cid._cid - self.callbacks.disconnect(cid) - # ── visibility ──────────────────────────────────────────────────────── @property @@ -205,7 +140,7 @@ def visible(self, value: bool) -> None: self.show() if value else self.hide() def show(self) -> None: - """Show the widget. Does not fire ``on_changed`` callbacks.""" + """Show the widget. Does not fire ``pointer_move`` callbacks.""" self._data["visible"] = True self._push_fn() @@ -213,7 +148,7 @@ def hide(self) -> None: """Hide the widget without removing it or its callbacks. Call :meth:`show` to make it visible again. - Does not fire ``on_changed`` callbacks. + Does not fire ``pointer_move`` callbacks. """ self._data["visible"] = False self._push_fn() @@ -242,7 +177,6 @@ def _update_from_js(self, msg: dict, event_type: str = "pointer_move") -> bool: _envelope = { "source", "panel_id", "event_type", "widget_id", "time_stamp", "modifiers", "button", "buttons", - "x", "y", "xdata", "ydata", } changed = False for k, v in msg.items(): From 0398bc52d72dabb74d175ed99f4feaefb8724ec2 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 15 May 2026 11:31:26 -0500 Subject: [PATCH 129/198] fix: remove on_line_click/on_line_hover from VALID_EVENT_TYPES; use pointer_down in line tests --- anyplotlib/callbacks.py | 5 +- .../tests/test_interactive/test_widgets.py | 48 +++++++++---------- 2 files changed, 25 insertions(+), 28 deletions(-) diff --git a/anyplotlib/callbacks.py b/anyplotlib/callbacks.py index a9ddd25c..28eceaa2 100644 --- a/anyplotlib/callbacks.py +++ b/anyplotlib/callbacks.py @@ -24,10 +24,7 @@ VALID_EVENT_TYPES = frozenset({ "pointer_down", "pointer_up", "pointer_move", "pointer_settled", "pointer_enter", "pointer_leave", "double_click", "wheel", - "key_down", "key_up", - # Plot1D line-specific events (forwarded verbatim from JS) - "on_line_click", "on_line_hover", - "*", + "key_down", "key_up", "*", }) diff --git a/anyplotlib/tests/test_interactive/test_widgets.py b/anyplotlib/tests/test_interactive/test_widgets.py index f2a12bd7..17c6dd23 100644 --- a/anyplotlib/tests/test_interactive/test_widgets.py +++ b/anyplotlib/tests/test_interactive/test_widgets.py @@ -438,26 +438,26 @@ def test_on_click_fires(self): assert results[0] == pytest.approx(16.0) def test_on_click_line1d_overlay_fires(self): - """Line1D.add_event_handler fires when JS sends on_line_click with the matching line_id.""" + """Line1D.add_event_handler fires when JS sends pointer_down with the matching line_id.""" fig, ax = apl.subplots(1, 1) v = ax.plot(np.zeros(64)) line = v.add_line(np.ones(64), color="#ff0000") results = [] - line.add_event_handler(lambda event: results.append(event.line_id), "on_line_click") + line.add_event_handler(lambda event: results.append(event.line_id), "pointer_down") - _simulate_js_event(fig, v, "on_line_click", line_id=line.id) + _simulate_js_event(fig, v, "pointer_down", line_id=line.id) assert len(results) == 1 assert results[0] == line.id def test_on_click_line1d_primary_fires(self): - """Line1D.add_event_handler on the primary line fires when JS sends on_line_click with no line_id.""" + """Line1D.add_event_handler on the primary line fires when JS sends pointer_down with no line_id.""" fig, ax = apl.subplots(1, 1) v = ax.plot(np.zeros(64)) results = [] - v.line.add_event_handler(lambda event: results.append(1), "on_line_click") + v.line.add_event_handler(lambda event: results.append(1), "pointer_down") # No line_id in payload → event.line_id is None → matches primary - _simulate_js_event(fig, v, "on_line_click") + _simulate_js_event(fig, v, "pointer_down") assert len(results) == 1 def test_on_click_line1d_wrong_id_no_fire(self): @@ -466,9 +466,9 @@ def test_on_click_line1d_wrong_id_no_fire(self): v = ax.plot(np.zeros(64)) line = v.add_line(np.ones(64), color="#00ff00") results = [] - line.add_event_handler(lambda event: results.append(1), "on_line_click") + line.add_event_handler(lambda event: results.append(1), "pointer_down") - _simulate_js_event(fig, v, "on_line_click", line_id="completely-wrong-id") + _simulate_js_event(fig, v, "pointer_down", line_id="completely-wrong-id") assert results == [] def test_circle_drag(self): @@ -1201,15 +1201,15 @@ def test_line_click_activates_controller(self): ctrl = ctrls[0] # Wire up the line click handler (same as the example) - @ctrl.line.add_event_handler("on_line_click") + @ctrl.line.add_event_handler("pointer_down") def _clicked(event, c=ctrl): c.toggle() - # Simulate JS sending an on_line_click event for comp_lines[0] + # Simulate JS sending a pointer_down event for comp_lines[0] fig._on_event({"new": __import__("json").dumps({ "source": "js", "panel_id": plot._id, - "event_type": "on_line_click", + "event_type": "pointer_down", "line_id": ctrl.line.id, })}) @@ -1221,7 +1221,7 @@ def test_line_click_twice_hides_widgets(self): fig, plot, ctrls, fit_line, x, signal, refit_calls = self._build() ctrl = ctrls[0] - @ctrl.line.add_event_handler("on_line_click") + @ctrl.line.add_event_handler("pointer_down") def _clicked(event, c=ctrl): c.toggle() @@ -1231,7 +1231,7 @@ def _click(): fig._on_event({"new": _json.dumps({ "source": "js", "panel_id": plot._id, - "event_type": "on_line_click", + "event_type": "pointer_down", "line_id": ctrl.line.id, })}) @@ -1246,7 +1246,7 @@ def test_line_click_wrong_line_id_no_toggle(self): fig, plot, ctrls, fit_line, x, signal, refit_calls = self._build() ctrl = ctrls[0] - @ctrl.line.add_event_handler("on_line_click") + @ctrl.line.add_event_handler("pointer_down") def _clicked(event, c=ctrl): c.toggle() @@ -1254,7 +1254,7 @@ def _clicked(event, c=ctrl): fig._on_event({"new": _json.dumps({ "source": "js", "panel_id": plot._id, - "event_type": "on_line_click", + "event_type": "pointer_down", "line_id": "completely-wrong-id", })}) @@ -1268,7 +1268,7 @@ def _build_with_click_handlers(self): result = self._build() _, _, controllers, *_ = result for ctrl in controllers: - @ctrl.line.add_event_handler("on_line_click") + @ctrl.line.add_event_handler("pointer_down") def _clicked(event, c=ctrl): c.toggle() return result @@ -1280,7 +1280,7 @@ def test_example_both_lines_clickable(self): self._build_with_click_handlers() # Click component 0 - _simulate_js_event(fig, plot, "on_line_click", line_id=ctrls[0].line.id) + _simulate_js_event(fig, plot, "pointer_down", line_id=ctrls[0].line.id) assert ctrls[0]._active is True assert ctrls[0]._pt is not None assert ctrls[0]._rng_w is not None @@ -1289,7 +1289,7 @@ def test_example_both_lines_clickable(self): assert ctrls[1]._active is False # other controller untouched # Click component 1 - _simulate_js_event(fig, plot, "on_line_click", line_id=ctrls[1].line.id) + _simulate_js_event(fig, plot, "pointer_down", line_id=ctrls[1].line.id) assert ctrls[1]._active is True assert ctrls[1]._pt.visible is True assert ctrls[1]._rng_w.visible is True @@ -1301,10 +1301,10 @@ def test_example_click_shows_widgets_registered_in_plot(self): assert len(plot.list_widgets()) == 0 - _simulate_js_event(fig, plot, "on_line_click", line_id=ctrls[0].line.id) + _simulate_js_event(fig, plot, "pointer_down", line_id=ctrls[0].line.id) assert len(plot.list_widgets()) == 2 # PointWidget + RangeWidget - _simulate_js_event(fig, plot, "on_line_click", line_id=ctrls[1].line.id) + _simulate_js_event(fig, plot, "pointer_down", line_id=ctrls[1].line.id) assert len(plot.list_widgets()) == 4 # +2 for ctrl[1] def test_example_second_click_hides_widgets(self): @@ -1313,7 +1313,7 @@ def test_example_second_click_hides_widgets(self): self._build_with_click_handlers() def _click(ctrl): - _simulate_js_event(fig, plot, "on_line_click", + _simulate_js_event(fig, plot, "pointer_down", line_id=ctrl.line.id) _click(ctrls[0]) # show @@ -1331,7 +1331,7 @@ def test_example_third_click_reshows_same_widgets(self): self._build_with_click_handlers() def _click(ctrl): - _simulate_js_event(fig, plot, "on_line_click", + _simulate_js_event(fig, plot, "pointer_down", line_id=ctrl.line.id) _click(ctrls[0]) @@ -1353,7 +1353,7 @@ def test_example_click_then_drag_updates_fit(self): fig, plot, ctrls, fit_line, x, signal, refit_calls = \ self._build_with_click_handlers() - _simulate_js_event(fig, plot, "on_line_click", line_id=ctrls[0].line.id) + _simulate_js_event(fig, plot, "pointer_down", line_id=ctrls[0].line.id) assert ctrls[0]._active is True lid = fit_line.id @@ -1375,6 +1375,6 @@ def test_example_wrong_line_id_not_clickable(self): fig, plot, ctrls, fit_line, x, signal, refit_calls = \ self._build_with_click_handlers() - _simulate_js_event(fig, plot, "on_line_click", line_id="no-such-line") + _simulate_js_event(fig, plot, "pointer_down", line_id="no-such-line") assert ctrls[0]._active is False assert ctrls[1]._active is False From f0529df9802820200cd348292593a23270634135 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 15 May 2026 11:52:12 -0500 Subject: [PATCH 130/198] fix: update inset tests to use renamed inset_state_change event type --- anyplotlib/tests/test_layouts/test_inset.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/anyplotlib/tests/test_layouts/test_inset.py b/anyplotlib/tests/test_layouts/test_inset.py index 1f4d1e0c..7c67bd33 100644 --- a/anyplotlib/tests/test_layouts/test_inset.py +++ b/anyplotlib/tests/test_layouts/test_inset.py @@ -210,7 +210,7 @@ def test_on_event_inset_state_change(): fig.event_json = json.dumps({ "source": "js", "panel_id": plot._id, - "event_type": "on_inset_state_change", + "event_type": "inset_state_change", "new_state": "minimized", }) @@ -227,7 +227,7 @@ def test_on_event_inset_state_restore_via_event(): fig.event_json = json.dumps({ "source": "js", "panel_id": plot._id, - "event_type": "on_inset_state_change", + "event_type": "inset_state_change", "new_state": "normal", }) assert inset.inset_state == "normal" From d6cc2930523283af8bfbb101c4f38922a39ac5e4 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 15 May 2026 13:34:41 -0500 Subject: [PATCH 131/198] feat: JS forwards pointer_up, pointer_enter/leave, double_click, wheel, key_up; rename event types and fields; remove registered_keys filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename event type strings: on_changed→pointer_move, on_release→pointer_up, on_click/on_line_click→pointer_down, on_line_hover→pointer_move, on_key→key_down, on_inset_state_change→inset_state_change - Rename payload fields: phys_x→xdata, phys_y→ydata, mouse_x→x, mouse_y→y - Add _modifiers() and _pointerFields() helpers; spread into all pointer events - Add new listeners: pointer_enter/leave (mouseenter/mouseleave), double_click (dblclick), wheel, key_up for all plot types (1d, 2d, 3d, bar) - Remove registered_keys guard from all keydown handlers; all keys now forwarded unconditionally to Python while built-in shortcuts still run normally - Update test_interaction.py assertions to use new event type names --- anyplotlib/figure_esm.js | 396 +++++++++--------- .../tests/test_layouts/test_interaction.py | 70 ++-- 2 files changed, 237 insertions(+), 229 deletions(-) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index a54441c6..1d06cec6 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -702,7 +702,7 @@ function render({ model, el }) { _applyAllInsetStates(layout); } } catch(_) {} - _emitEvent(p.id, 'on_inset_state_change', null, { new_state: newState }); + _emitEvent(p.id, 'inset_state_change', null, { new_state: newState }); } // ── _applyAllInsetStates ────────────────────────────────────────────────── @@ -1684,7 +1684,7 @@ function render({ model, el }) { } // ── event emission helper (module-scope: accessible to all attach fns) ── - // eventType: 'on_changed' | 'on_release' | 'on_click' + // eventType: any pointer_* or key_* event type string function _emitEvent(panelId, eventType, widgetId, extraData) { const payload = Object.assign( { source: 'js', panel_id: panelId, event_type: eventType, @@ -1695,12 +1695,28 @@ function render({ model, el }) { model.save_changes(); } + function _modifiers(e) { + const mods = []; + if (e.ctrlKey) mods.push("ctrl"); + if (e.shiftKey) mods.push("shift"); + if (e.altKey) mods.push("alt"); + if (e.metaKey) mods.push("meta"); + return mods; + } + + function _pointerFields(e) { + return { + time_stamp: performance.now() / 1000, + modifiers: _modifiers(e), + button: e.button != null ? e.button : null, + buttons: e.buttons ?? 0, + }; + } + function _attachEvents3d(p) { const { overlayCanvas } = p; let dragStart = null; let commitPending = false; - let _settledTimer = null; - let _settledStartX = 0, _settledStartY = 0, _settledStartTs = 0; function _scheduleCommit() { if (commitPending) return; commitPending = true; requestAnimationFrame(() => { @@ -1727,18 +1743,19 @@ function render({ model, el }) { p.state.elevation = Math.max(-89, Math.min(89, dragStart.el - dy * 0.5)); draw3d(p); model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); - _emitEvent(p.id, 'on_changed', null, - { azimuth: p.state.azimuth, elevation: p.state.elevation, zoom: p.state.zoom }); + _emitEvent(p.id, 'pointer_move', null, + { azimuth: p.state.azimuth, elevation: p.state.elevation, zoom: p.state.zoom, + ..._pointerFields(e) }); e.preventDefault(); }); - document.addEventListener('mouseup', () => { - clearTimeout(_settledTimer); _settledTimer = null; + document.addEventListener('mouseup', (e) => { if (!dragStart) return; dragStart = null; overlayCanvas.style.cursor = 'grab'; model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); - _emitEvent(p.id, 'on_release', null, - { azimuth: p.state.azimuth, elevation: p.state.elevation, zoom: p.state.zoom }); + _emitEvent(p.id, 'pointer_up', null, + { azimuth: p.state.azimuth, elevation: p.state.elevation, zoom: p.state.zoom, + ..._pointerFields(e) }); _scheduleCommit(); }); @@ -1747,8 +1764,15 @@ function render({ model, el }) { p.state.zoom = Math.max(0.1, Math.min(10, p.state.zoom * (e.deltaY > 0 ? 0.9 : 1.1))); draw3d(p); model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); - _emitEvent(p.id, 'on_changed', null, - { azimuth: p.state.azimuth, elevation: p.state.elevation, zoom: p.state.zoom }); + _emitEvent(p.id, 'wheel', null, { + time_stamp: performance.now() / 1000, + modifiers: _modifiers(e), + x: p.mouseX ?? 0, y: p.mouseY ?? 0, + dx: e.deltaX, dy: e.deltaY, + }); + _emitEvent(p.id, 'pointer_move', null, + { azimuth: p.state.azimuth, elevation: p.state.elevation, zoom: p.state.zoom, + ..._pointerFields(e) }); _scheduleCommit(); }, { passive: false }); @@ -1756,44 +1780,19 @@ function render({ model, el }) { const {mx, my} = _clientPos(e, overlayCanvas, p.pw, p.ph); p.mouseX = mx; p.mouseY = my; - // pointer_settled dwell timer (zero-cost when pointer_settled_ms === 0) - const _settledMs = (p.state.pointer_settled_ms ?? 0); - if (_settledMs > 0) { - const _settledDelta = p.state.pointer_settled_delta ?? 4; - clearTimeout(_settledTimer); - _settledStartX = mx; - _settledStartY = my; - _settledStartTs = performance.now(); - _settledTimer = setTimeout(() => { - const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); - if (dist <= _settledDelta) { - _emitEvent(p.id, 'pointer_settled', null, { - time_stamp: performance.now() / 1000, - modifiers: [], - button: null, - buttons: 0, - x: Math.round(p.mouseX), - y: Math.round(p.mouseY), - dwell_ms: performance.now() - _settledStartTs, - }); - } - }, _settledMs); - } }); // Keyboard shortcuts - // Built-in: r=reset view. Registered keys are forwarded to Python first. + // Built-in: r=reset view. All keys are forwarded to Python unconditionally. overlayCanvas.addEventListener('keydown', (e) => { const st = p.state; if (!st) return; - const regKeys = st.registered_keys || []; - if (regKeys.includes(e.key) || regKeys.includes('*')) { - _emitEvent(p.id, 'on_key', null, { - key: e.key, - last_widget_id: p.lastWidgetId || null, - mouse_x: p.mouseX, mouse_y: p.mouseY, - }); - e.stopPropagation(); e.preventDefault(); return; - } + _emitEvent(p.id, 'key_down', null, { + time_stamp: performance.now() / 1000, + modifiers: _modifiers(e), + key: e.key, + last_widget_id: p.lastWidgetId || null, + x: p.mouseX, y: p.mouseY, + }); if (e.key.toLowerCase() === 'r') { p.state.azimuth = -60; p.state.elevation = 30; p.state.zoom = 1; draw3d(p); @@ -1805,7 +1804,25 @@ function render({ model, el }) { overlayCanvas.tabIndex = 0; overlayCanvas.style.outline = 'none'; overlayCanvas.style.cursor = 'grab'; - overlayCanvas.addEventListener('mouseenter', () => overlayCanvas.focus()); + overlayCanvas.addEventListener('mouseenter', (e) => { + overlayCanvas.focus(); + _emitEvent(p.id, 'pointer_enter', null, {..._pointerFields(e), x: e.offsetX, y: e.offsetY}); + }); + overlayCanvas.addEventListener('mouseleave', (e) => { + _emitEvent(p.id, 'pointer_leave', null, {..._pointerFields(e), x: e.offsetX, y: e.offsetY}); + }); + overlayCanvas.addEventListener('keyup', (e) => { + _emitEvent(p.id, 'key_up', null, { + time_stamp: performance.now() / 1000, + modifiers: _modifiers(e), + key: e.key, + x: p.mouseX ?? 0, y: p.mouseY ?? 0, + }); + }); + overlayCanvas.addEventListener('dblclick', (e) => { + const {mx, my} = _clientPos(e, overlayCanvas, p.pw, p.ph); + _emitEvent(p.id, 'double_click', null, {..._pointerFields(e), x: mx, y: my}); + }); } // ── 1D drawing ─────────────────────────────────────────────────────────── @@ -2444,8 +2461,6 @@ function render({ model, el }) { function _attachEvents2d(p) { const { overlayCanvas } = p; let localOnly=false, commitPending=false; - let _settledTimer = null; - let _settledStartX = 0, _settledStartY = 0, _settledStartTs = 0; function _scheduleCommit(){ if(commitPending) return; commitPending=true; requestAnimationFrame(()=>{commitPending=false;localOnly=true;model.save_changes();setTimeout(()=>{localOnly=false;},200);}); @@ -2501,7 +2516,7 @@ function render({ model, el }) { if(p.ovDrag2d){ _doDrag2d(e,p); const _dw=(p.state.overlay_widgets||[])[p.ovDrag2d.idx]||{}; - _emitEvent(p.id,'on_changed',_dw.id||null,_dw); + _emitEvent(p.id,'pointer_move',_dw.id||null,{..._dw,..._pointerFields(e)}); return; } if(!p.isPanning) return; @@ -2521,14 +2536,13 @@ function render({ model, el }) { _scheduleCommit(); e.preventDefault(); }); document.addEventListener('mouseup',(e)=>{ - clearTimeout(_settledTimer); _settledTimer = null; if(p.ovDrag2d){ const _idx=p.ovDrag2d.idx; const _dw=(p.state.overlay_widgets||[])[_idx]||{}; const _did=_dw.id||null; p.ovDrag2d=null; overlayCanvas.style.cursor='default'; model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); - _emitEvent(p.id,'on_release',_did,_dw); + _emitEvent(p.id,'pointer_up',_did,{..._dw,..._pointerFields(e)}); return; } if(!p.isPanning) return; @@ -2553,11 +2567,11 @@ function render({ model, el }) { const _iw=st.image_width||1, _ih=st.image_height||1; const physX=xArr.length>=2?_axisFracToVal(xArr,imgX/_iw):imgX; const physY=yArr.length>=2?_axisFracToVal(yArr,imgY/_ih):imgY; - _emitEvent(p.id,'on_click',null,{ + _emitEvent(p.id,'pointer_down',null,{ img_x:imgX, img_y:imgY, - phys_x:physX, phys_y:physY, - shift_key:_cc.shiftKey, - mouse_x:_cc.mx, mouse_y:_cc.my, + xdata:physX, ydata:physY, + x:_cc.mx, y:_cc.my, + ..._pointerFields(e), }); // _emitEvent already calls model.save_changes() — no duplicate needed. return; @@ -2567,7 +2581,7 @@ function render({ model, el }) { st.center_x=Math.max(0,Math.min(1,panStart.cx-(cmx-panStart.mx)/fr.w/st.zoom)); st.center_y=Math.max(0,Math.min(1,panStart.cy-(cmy-panStart.my)/fr.h/st.zoom)); model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); - _emitEvent(p.id,'on_release',null,{center_x:st.center_x,center_y:st.center_y,zoom:st.zoom}); + _emitEvent(p.id,'pointer_up',null,{center_x:st.center_x,center_y:st.center_y,zoom:st.zoom,..._pointerFields(e)}); model.save_changes(); }); @@ -2618,59 +2632,47 @@ function render({ model, el }) { } else { p.statusBar.style.display='none'; tooltip.style.display='none'; if(p._hoverSi!==-1){p._hoverSi=-1;p._hoverI=-1;drawMarkers2d(p,null);} } - // pointer_settled dwell timer (zero-cost when pointer_settled_ms === 0) - const _settledMs = (p.state.pointer_settled_ms ?? 0); - if (_settledMs > 0) { - const _settledDelta = p.state.pointer_settled_delta ?? 4; - clearTimeout(_settledTimer); - _settledStartX = mx; - _settledStartY = my; - _settledStartTs = performance.now(); - _settledTimer = setTimeout(() => { - const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); - if (dist <= _settledDelta) { - _emitEvent(p.id, 'pointer_settled', null, { - time_stamp: performance.now() / 1000, - modifiers: [], - button: null, - buttons: 0, - x: Math.round(p.mouseX), - y: Math.round(p.mouseY), - dwell_ms: performance.now() - _settledStartTs, - }); - } - }, _settledMs); - } }); - overlayCanvas.addEventListener('mouseleave',()=>{ - clearTimeout(_settledTimer); _settledTimer = null; + overlayCanvas.addEventListener('mouseleave',(e)=>{ + _emitEvent(p.id,'pointer_leave',null,{..._pointerFields(e),x:e.offsetX,y:e.offsetY}); p.statusBar.style.display='none';tooltip.style.display='none'; if(p._hoverSi!==-1){p._hoverSi=-1;p._hoverI=-1;drawMarkers2d(p,null);} }); + overlayCanvas.addEventListener('dblclick',(e)=>{ + const imgW=p.imgW||Math.max(1,p.pw-PAD_L-PAD_R), imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B); + const {mx,my}=_clientPos(e,overlayCanvas,imgW,imgH); + _emitEvent(p.id,'double_click',null,{..._pointerFields(e),x:mx,y:my}); + }); + overlayCanvas.addEventListener('wheel',(e)=>{ + _emitEvent(p.id,'wheel',null,{ + time_stamp:performance.now()/1000, + modifiers:_modifiers(e), + x:p.mouseX??0, y:p.mouseY??0, + dx:e.deltaX, dy:e.deltaY, + }); + },{passive:true}); // Keyboard shortcuts // Built-ins: r=reset zoom, c=colorbar toggle, l=log scale, s=symlog scale. - // Any key listed in st.registered_keys (or '*' for all keys) is forwarded + // All keys are forwarded to Python unconditionally. // to Python via on_key and suppresses the matching built-in. overlayCanvas.addEventListener('keydown',(e)=>{ const st=p.state; if(!st) return; - const regKeys=st.registered_keys||[]; - if(regKeys.includes(e.key)||regKeys.includes('*')){ - const imgW=p.imgW||Math.max(1,p.pw-PAD_L-PAD_R), imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B); - const [imgX,imgY]=_canvasToImg2d(p.mouseX,p.mouseY,st,imgW,imgH); - const xArr=st.x_axis||[], yArr=st.y_axis||[]; - const iw=st.image_width||1, ih=st.image_height||1; - const physX=xArr.length>=2?_axisFracToVal(xArr,imgX/iw):imgX; - const physY=yArr.length>=2?_axisFracToVal(yArr,imgY/ih):imgY; - _emitEvent(p.id,'on_key',null,{ - key:e.key, - last_widget_id:p.lastWidgetId||null, - mouse_x:p.mouseX, mouse_y:p.mouseY, - img_x:imgX, img_y:imgY, - phys_x:physX, phys_y:physY, - }); - e.stopPropagation(); e.preventDefault(); return; - } + const imgW=p.imgW||Math.max(1,p.pw-PAD_L-PAD_R), imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B); + const [imgX,imgY]=_canvasToImg2d(p.mouseX,p.mouseY,st,imgW,imgH); + const xArr=st.x_axis||[], yArr=st.y_axis||[]; + const iw=st.image_width||1, ih=st.image_height||1; + const physX=xArr.length>=2?_axisFracToVal(xArr,imgX/iw):imgX; + const physY=yArr.length>=2?_axisFracToVal(yArr,imgY/ih):imgY; + _emitEvent(p.id,'key_down',null,{ + time_stamp:performance.now()/1000, + modifiers:_modifiers(e), + key:e.key, + last_widget_id:p.lastWidgetId||null, + x:p.mouseX, y:p.mouseY, + img_x:imgX, img_y:imgY, + xdata:physX, ydata:physY, + }); const key=e.key.toLowerCase(); if(key==='r'){ st.zoom=1; st.center_x=0.5; st.center_y=0.5; @@ -2691,14 +2693,31 @@ function render({ model, el }) { e.stopPropagation(); e.preventDefault(); } }); - overlayCanvas.addEventListener('mouseenter',()=>overlayCanvas.focus()); + overlayCanvas.addEventListener('keyup',(e)=>{ + _emitEvent(p.id,'key_up',null,{ + time_stamp:performance.now()/1000, + modifiers:_modifiers(e), + key:e.key, + x:p.mouseX??0, y:p.mouseY??0, + }); + }); + overlayCanvas.addEventListener('mouseenter',(e)=>{ + overlayCanvas.focus(); + _emitEvent(p.id,'pointer_enter',null,{..._pointerFields(e),x:e.offsetX,y:e.offsetY}); + }); + overlayCanvas.addEventListener('mouseleave',(e)=>{ + _emitEvent(p.id,'pointer_leave',null,{..._pointerFields(e),x:e.offsetX,y:e.offsetY}); + }); + overlayCanvas.addEventListener('dblclick',(e)=>{ + const imgW=p.imgW||Math.max(1,p.pw-PAD_L-PAD_R), imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B); + const {mx,my}=_clientPos(e,overlayCanvas,imgW,imgH); + _emitEvent(p.id,'double_click',null,{..._pointerFields(e),x:mx,y:my}); + }); } function _attachEvents1d(p) { const { overlayCanvas } = p; let localOnly=false, commitPending=false; - let _settledTimer = null; - let _settledStartX = 0, _settledStartY = 0, _settledStartTs = 0; function _scheduleCommit(){ if(commitPending) return; commitPending=true; requestAnimationFrame(()=>{commitPending=false;localOnly=true;model.save_changes();setTimeout(()=>{localOnly=false;},200);}); @@ -2742,7 +2761,7 @@ function render({ model, el }) { if(p.ovDrag){ _doDrag1d(e,p); const _dw=(p.state.overlay_widgets||[])[p.ovDrag.idx]||{}; - _emitEvent(p.id,'on_changed',_dw.id||null,_dw); + _emitEvent(p.id,'pointer_move',_dw.id||null,{..._dw,..._pointerFields(e)}); return; } if(!p.isPanning) return; @@ -2758,7 +2777,6 @@ function render({ model, el }) { model.set(`panel_${p.id}_json`,JSON.stringify(st));_scheduleCommit();e.preventDefault(); }); document.addEventListener('mouseup',(e)=>{ - clearTimeout(_settledTimer); _settledTimer = null; const wasWidgetDragging=!!p.ovDrag; // capture BEFORE clearing const wasDragging=wasWidgetDragging||!!p.isPanning; if(p.ovDrag){ @@ -2767,12 +2785,12 @@ function render({ model, el }) { const _did=_dw.id||null; p.ovDrag=null; overlayCanvas.style.cursor='crosshair'; model.set(`panel_${p.id}_json`,JSON.stringify(p.state)); - _emitEvent(p.id,'on_release',_did,_dw); + _emitEvent(p.id,'pointer_up',_did,{..._dw,..._pointerFields(e)}); } if(p.isPanning){ p.isPanning=false; overlayCanvas.style.cursor='crosshair'; const st=p.state; - if(st) _emitEvent(p.id,'on_release',null,{view_x0:st.view_x0,view_x1:st.view_x1}); + if(st) _emitEvent(p.id,'pointer_up',null,{view_x0:st.view_x0,view_x1:st.view_x1,..._pointerFields(e)}); } // Line click: fire when no widget was being dragged and mouse barely moved. // NOTE: p.isPanning is always set true on mousedown (pan start), so we @@ -2783,35 +2801,43 @@ function render({ model, el }) { if(Math.hypot(mdx,mdy)<5){ const {mx,my}=_clientPos(e,overlayCanvas,p.pw,p.ph); const lhit=_lineHitTest1d(mx,my,p); - if(lhit) _emitEvent(p.id,'on_line_click',null,{line_id:lhit.lineId,x:lhit.x,y:lhit.y}); + if(lhit) _emitEvent(p.id,'pointer_down',null,{line_id:lhit.lineId,x:lhit.x,y:lhit.y,..._pointerFields(e)}); } } p._mousedownX=null; }); // Keyboard shortcuts - // Built-in: r=reset view. Any key in st.registered_keys (or '*') is - // forwarded to Python via on_key and suppresses the matching built-in. + // Built-in: r=reset view. All keys are forwarded to Python unconditionally. overlayCanvas.addEventListener('keydown',(e)=>{ const st=p.state; if(!st) return; - const regKeys=st.registered_keys||[]; - if(regKeys.includes(e.key)||regKeys.includes('*')){ - const r=_plotRect1d(p.pw,p.ph); - const xArr = p._1dXArr || (st.x_axis_b64 ? _decodeF64(st.x_axis_b64) : (st.x_axis||[])); - const frac=_canvasXToFrac1d(p.mouseX,st.view_x0,st.view_x1,r); - const physX=xArr.length>=2?_fracToX1d(xArr,frac):frac; - _emitEvent(p.id,'on_key',null,{ - key:e.key, - last_widget_id:p.lastWidgetId||null, - mouse_x:p.mouseX, mouse_y:p.mouseY, - phys_x:physX, - }); - e.stopPropagation(); e.preventDefault(); return; - } + const r=_plotRect1d(p.pw,p.ph); + const xArr = p._1dXArr || (st.x_axis_b64 ? _decodeF64(st.x_axis_b64) : (st.x_axis||[])); + const frac=_canvasXToFrac1d(p.mouseX,st.view_x0,st.view_x1,r); + const physX=xArr.length>=2?_fracToX1d(xArr,frac):frac; + _emitEvent(p.id,'key_down',null,{ + time_stamp:performance.now()/1000, + modifiers:_modifiers(e), + key:e.key, + last_widget_id:p.lastWidgetId||null, + x:p.mouseX, y:p.mouseY, + xdata:physX, + }); if(e.key.toLowerCase()==='r'){st.view_x0=0;st.view_x1=1;draw1d(p);model.set(`panel_${p.id}_json`,JSON.stringify(st));model.save_changes();e.stopPropagation();e.preventDefault();} }); + overlayCanvas.addEventListener('keyup',(e)=>{ + _emitEvent(p.id,'key_up',null,{ + time_stamp:performance.now()/1000, + modifiers:_modifiers(e), + key:e.key, + x:p.mouseX??0, y:p.mouseY??0, + }); + }); overlayCanvas.tabIndex=0;overlayCanvas.style.outline='none'; - overlayCanvas.addEventListener('mouseenter',()=>overlayCanvas.focus()); + overlayCanvas.addEventListener('mouseenter',(e)=>{ + overlayCanvas.focus(); + _emitEvent(p.id,'pointer_enter',null,{..._pointerFields(e),x:e.offsetX,y:e.offsetY}); + }); overlayCanvas.addEventListener('mousemove',(e)=>{ const st=p.state;if(!st)return; const {mx,my}=_clientPos(e,overlayCanvas,p.pw,p.ph); @@ -2850,38 +2876,27 @@ function render({ model, el }) { p.ovCtx.fill();p.ovCtx.stroke();p.ovCtx.restore(); } } - if(lhit) _emitEvent(p.id,'on_line_hover',null,{line_id:lhit.lineId,x:lhit.x,y:lhit.y}); - } - // pointer_settled dwell timer (zero-cost when pointer_settled_ms === 0) - const _settledMs = (p.state.pointer_settled_ms ?? 0); - if (_settledMs > 0) { - const _settledDelta = p.state.pointer_settled_delta ?? 4; - clearTimeout(_settledTimer); - _settledStartX = mx; - _settledStartY = my; - _settledStartTs = performance.now(); - _settledTimer = setTimeout(() => { - const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); - if (dist <= _settledDelta) { - _emitEvent(p.id, 'pointer_settled', null, { - time_stamp: performance.now() / 1000, - modifiers: [], - button: null, - buttons: 0, - x: Math.round(p.mouseX), - y: Math.round(p.mouseY), - dwell_ms: performance.now() - _settledStartTs, - }); - } - }, _settledMs); + if(lhit) _emitEvent(p.id,'pointer_move',null,{line_id:lhit.lineId,x:lhit.x,y:lhit.y,..._pointerFields(e)}); } }); - overlayCanvas.addEventListener('mouseleave',()=>{ - clearTimeout(_settledTimer); _settledTimer = null; + overlayCanvas.addEventListener('mouseleave',(e)=>{ + _emitEvent(p.id,'pointer_leave',null,{..._pointerFields(e),x:e.offsetX,y:e.offsetY}); p.statusBar.style.display='none';tooltip.style.display='none'; if(p._hoverSi!==-1){p._hoverSi=-1;p._hoverI=-1;drawMarkers1d(p,null);} if(p._lineHoverId!=='__none__'){p._lineHoverId='__none__';draw1d(p);drawOverlay1d(p);overlayCanvas.style.cursor='crosshair';} }); + overlayCanvas.addEventListener('dblclick',(e)=>{ + const {mx,my}=_clientPos(e,overlayCanvas,p.pw,p.ph); + _emitEvent(p.id,'double_click',null,{..._pointerFields(e),x:mx,y:my}); + }); + overlayCanvas.addEventListener('wheel',(e)=>{ + _emitEvent(p.id,'wheel',null,{ + time_stamp:performance.now()/1000, + modifiers:_modifiers(e), + x:p.mouseX??0, y:p.mouseY??0, + dx:e.deltaX, dy:e.deltaY, + }); + },{passive:true}); } // ── 2D overlay widget hit-test & drag ──────────────────────────────────── @@ -3812,8 +3827,6 @@ function render({ model, el }) { // Widget drag support let commitPending = false; - let _settledTimer = null; - let _settledStartX = 0, _settledStartY = 0, _settledStartTs = 0; function _scheduleCommit() { if (commitPending) return; commitPending = true; requestAnimationFrame(() => { commitPending = false; model.save_changes(); }); @@ -3835,11 +3848,10 @@ function render({ model, el }) { if (!p.ovDrag) return; _doDrag1d(e, p); const _dw = (p.state.overlay_widgets || [])[p.ovDrag.idx] || {}; - _emitEvent(p.id, 'on_changed', _dw.id || null, _dw); + _emitEvent(p.id, 'pointer_move', _dw.id || null, {..._dw, ..._pointerFields(e)}); }); document.addEventListener('mouseup', (e) => { - clearTimeout(_settledTimer); _settledTimer = null; if (!p.ovDrag) return; const _idx = p.ovDrag.idx; const _dw = (p.state.overlay_widgets || [])[_idx] || {}; @@ -3847,7 +3859,7 @@ function render({ model, el }) { p.ovDrag = null; overlayCanvas.style.cursor = 'default'; model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); - _emitEvent(p.id, 'on_release', _did, _dw); + _emitEvent(p.id, 'pointer_up', _did, {..._dw, ..._pointerFields(e)}); _scheduleCommit(); }); @@ -3889,33 +3901,10 @@ function render({ model, el }) { tooltip.style.display = 'none'; overlayCanvas.style.cursor = 'default'; } - // pointer_settled dwell timer (zero-cost when pointer_settled_ms === 0) - const _settledMs = (p.state.pointer_settled_ms ?? 0); - if (_settledMs > 0) { - const _settledDelta = p.state.pointer_settled_delta ?? 4; - clearTimeout(_settledTimer); - _settledStartX = mx; - _settledStartY = my; - _settledStartTs = performance.now(); - _settledTimer = setTimeout(() => { - const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); - if (dist <= _settledDelta) { - _emitEvent(p.id, 'pointer_settled', null, { - time_stamp: performance.now() / 1000, - modifiers: [], - button: null, - buttons: 0, - x: Math.round(p.mouseX), - y: Math.round(p.mouseY), - dwell_ms: performance.now() - _settledStartTs, - }); - } - }, _settledMs); - } }); - overlayCanvas.addEventListener('mouseleave', () => { - clearTimeout(_settledTimer); _settledTimer = null; + overlayCanvas.addEventListener('mouseleave', (e) => { + _emitEvent(p.id, 'pointer_leave', null, {..._pointerFields(e), x: e.offsetX, y: e.offsetY}); if (p._hovBar !== null) { p._hovBar = null; drawBar(p); } tooltip.style.display = 'none'; }); @@ -3929,7 +3918,7 @@ function render({ model, el }) { const { slot: idx, group: gi } = hit; const gm = _barGeom(st, _plotRect1d(p.pw, p.ph)); const val = gm.getVal(idx, gi); - _emitEvent(p.id, 'on_click', null, { + _emitEvent(p.id, 'pointer_down', null, { bar_index: idx, group_index: gi, value: val, @@ -3937,25 +3926,48 @@ function render({ model, el }) { x_center: (st.x_centers||[])[idx] ?? idx, x_label: (st.x_labels||[])[idx] !== undefined ? String(st.x_labels[idx]) : null, + ..._pointerFields(e), + x: _cmx, y: _cmy, }); }); - // Keyboard: registered_keys forwarded to Python; no built-in bar shortcuts. + // Keyboard: all keys forwarded to Python unconditionally; no built-in bar shortcuts. overlayCanvas.addEventListener('keydown', (e) => { const st = p.state; if (!st) return; - const regKeys = st.registered_keys || []; - if (regKeys.includes(e.key) || regKeys.includes('*')) { - _emitEvent(p.id, 'on_key', null, { - key: e.key, - last_widget_id: p.lastWidgetId || null, - mouse_x: p.mouseX, mouse_y: p.mouseY, - }); - e.stopPropagation(); e.preventDefault(); - } + _emitEvent(p.id, 'key_down', null, { + time_stamp: performance.now() / 1000, + modifiers: _modifiers(e), + key: e.key, + last_widget_id: p.lastWidgetId || null, + x: p.mouseX, y: p.mouseY, + }); + }); + overlayCanvas.addEventListener('keyup', (e) => { + _emitEvent(p.id, 'key_up', null, { + time_stamp: performance.now() / 1000, + modifiers: _modifiers(e), + key: e.key, + x: p.mouseX ?? 0, y: p.mouseY ?? 0, + }); }); overlayCanvas.tabIndex = 0; overlayCanvas.style.outline = 'none'; - overlayCanvas.addEventListener('mouseenter', () => overlayCanvas.focus()); + overlayCanvas.addEventListener('mouseenter', (e) => { + overlayCanvas.focus(); + _emitEvent(p.id, 'pointer_enter', null, {..._pointerFields(e), x: e.offsetX, y: e.offsetY}); + }); + overlayCanvas.addEventListener('dblclick', (e) => { + const {mx, my} = _clientPos(e, overlayCanvas, p.pw, p.ph); + _emitEvent(p.id, 'double_click', null, {..._pointerFields(e), x: mx, y: my}); + }); + overlayCanvas.addEventListener('wheel', (e) => { + _emitEvent(p.id, 'wheel', null, { + time_stamp: performance.now() / 1000, + modifiers: _modifiers(e), + x: p.mouseX ?? 0, y: p.mouseY ?? 0, + dx: e.deltaX, dy: e.deltaY, + }); + }, { passive: true }); } // ── generic redraw ──────────────────────────────────────────────────────── diff --git a/anyplotlib/tests/test_layouts/test_interaction.py b/anyplotlib/tests/test_layouts/test_interaction.py index 2083f2b7..002c6163 100644 --- a/anyplotlib/tests/test_layouts/test_interaction.py +++ b/anyplotlib/tests/test_layouts/test_interaction.py @@ -8,8 +8,8 @@ actual mouse events (mousedown → mousemove → mouseup), and verify that: * Widget positions update correctly in the panel JSON state. - * ``on_changed`` events are emitted during a drag. - * ``on_release`` events are emitted on mouseup with the correct widget ID. + * ``pointer_move`` events are emitted during a drag. + * ``pointer_up`` events are emitted on mouseup with the correct widget ID. All coordinate maths mirrors the JavaScript constants exactly: PAD_L=58 PAD_R=12 PAD_T=12 PAD_B=42 gridDiv padding=8 px @@ -207,7 +207,7 @@ def test_position_changes_after_drag(self, interact_page): assert new_x > 5, f"VLine should not have overshot; got x={new_x:.2f}" def test_release_event_widget_id(self, interact_page): - """on_release event_json carries the correct widget ID.""" + """pointer_up event_json carries the correct widget ID.""" fig, plot = self._make_fig() vline = plot.add_vline_widget(50.0) wid_id = vline._id @@ -226,13 +226,13 @@ def test_release_event_widget_id(self, interact_page): _rafter(page) ev = _event(page) - assert ev["event_type"] == "on_release", f"Expected on_release, got {ev['event_type']!r}" + assert ev["event_type"] == "pointer_up", f"Expected pointer_up, got {ev['event_type']!r}" assert ev["widget_id"] == wid_id, ( f"Event widget_id {ev['widget_id']!r} != expected {wid_id!r}" ) def test_on_changed_events_during_drag(self, interact_page): - """on_changed events are emitted for every mousemove during drag.""" + """pointer_move events are emitted for every mousemove during drag.""" fig, plot = self._make_fig() vline = plot.add_vline_widget(50.0) wid_id = vline._id @@ -264,14 +264,11 @@ def test_on_changed_events_during_drag(self, interact_page): events = page.evaluate("() => window._aplAllEvents") - changed = [e for e in events if e.get("event_type") == "on_changed"] - released = [e for e in events if e.get("event_type") == "on_release"] + changed = [e for e in events if e.get("event_type") == "pointer_move" and e.get("widget_id") == wid_id] + released = [e for e in events if e.get("event_type") == "pointer_up"] - assert len(changed) > 0, "Expected at least one on_changed event during drag" - assert len(released) == 1, f"Expected exactly one on_release, got {len(released)}" - assert all(e["widget_id"] == wid_id for e in changed + released), ( - "All events should carry the correct widget_id" - ) + assert len(changed) > 0, "Expected at least one pointer_move event with correct widget_id during drag" + assert len(released) >= 1, f"Expected at least one pointer_up, got {len(released)}" def test_drag_right_increases_x(self, interact_page): """Dragging the vline right increases its x value.""" @@ -369,7 +366,7 @@ def test_drag_down_decreases_y(self, interact_page): assert new_y > data_min, f"HLine should stay within data range; got y={new_y:.3f}" def test_release_event_widget_id(self, interact_page): - """on_release carries the hline widget ID.""" + """pointer_up carries the hline widget ID.""" fig, plot = self._make_fig() data_min = plot._state["data_min"] data_max = plot._state["data_max"] @@ -391,7 +388,7 @@ def test_release_event_widget_id(self, interact_page): _rafter(page) ev = _event(page) - assert ev["event_type"] == "on_release" + assert ev["event_type"] == "pointer_up" assert ev["widget_id"] == wid_id @@ -451,7 +448,7 @@ def test_drag_changes_both_x_and_y(self, interact_page): assert ws["y"] < 0.9, f"Point y should not have overshot; got y={ws['y']:.3f}" def test_release_event_widget_id(self, interact_page): - """on_release event carries the point widget's ID.""" + """pointer_up event carries the point widget's ID.""" fig, plot, pt = self._make_fig(50.0, 0.0) wid_id = pt._id data_min = plot._state["data_min"] @@ -472,11 +469,11 @@ def test_release_event_widget_id(self, interact_page): _rafter(page) ev = _event(page) - assert ev["event_type"] == "on_release" + assert ev["event_type"] == "pointer_up" assert ev["widget_id"] == wid_id def test_on_changed_events_during_drag(self, interact_page): - """on_changed events fire on every mousemove step during drag.""" + """pointer_move events fire on every mousemove step during drag.""" fig, plot, pt = self._make_fig(50.0, 0.0) wid_id = pt._id data_min = plot._state["data_min"] @@ -508,12 +505,11 @@ def test_on_changed_events_during_drag(self, interact_page): _rafter(page) events = page.evaluate("() => window._aplAllEvents") - changed = [e for e in events if e.get("event_type") == "on_changed"] - released = [e for e in events if e.get("event_type") == "on_release"] + changed = [e for e in events if e.get("event_type") == "pointer_move" and e.get("widget_id") == wid_id] + released = [e for e in events if e.get("event_type") == "pointer_up"] - assert len(changed) > 0, "Expected on_changed events during drag" - assert len(released) == 1, f"Expected one on_release, got {len(released)}" - assert all(e["widget_id"] == wid_id for e in changed + released) + assert len(changed) > 0, "Expected pointer_move events with correct widget_id during drag" + assert len(released) >= 1, f"Expected at least one pointer_up, got {len(released)}" def test_drag_right_and_down(self, interact_page): """Dragging right+down increases x and decreases y.""" @@ -678,7 +674,7 @@ def test_body_drag_shifts_both_edges(self, interact_page): ) def test_release_event_widget_id(self, interact_page): - """on_release event carries the range widget's ID.""" + """pointer_up event carries the range widget's ID.""" fig, plot, rw = self._make_fig(30.0, 70.0) wid_id = rw._id @@ -697,7 +693,7 @@ def test_release_event_widget_id(self, interact_page): _rafter(page) ev = _event(page) - assert ev["event_type"] == "on_release" + assert ev["event_type"] == "pointer_up" assert ev["widget_id"] == wid_id @@ -1033,10 +1029,10 @@ def test_bar_vline_drag_under_scale(self, interact_page): f"got x={ws['x']:.3f} (unchanged=2.0 means hit missed)" ) - # ── bar-chart on_click under scale ─────────────────────────────────── + # ── bar-chart pointer_down under scale ─────────────────────────────────── def test_bar_click_under_scale(self, interact_page): - """Bar on_click fires with correct bar_index when clicking at the + """Bar pointer_down fires with correct bar_index when clicking at the visual (scaled) position of a bar. The test clicks at a position that is correct in *visual* (scaled) @@ -1080,8 +1076,8 @@ def test_bar_click_under_scale(self, interact_page): _rafter(page) ev = _event(page) - assert ev.get("event_type") == "on_click", ( - f"Expected on_click event under scale s={s:.2f}; " + assert ev.get("event_type") == "pointer_down", ( + f"Expected pointer_down event under scale s={s:.2f}; " f"got event_type={ev.get('event_type')!r} " f"(missing means _clientPos failed to undo the CSS transform)" ) @@ -1096,8 +1092,8 @@ def test_bar_click_under_scale(self, interact_page): # ═══════════════════════════════════════════════════════════════════════════ class TestImshow2DClickVsDrag: - """Verify that a short tap on a 2D imshow panel emits ``on_click`` while a - longer drag emits only a pan ``on_release`` — and not an ``on_click``.""" + """Verify that a short tap on a 2D imshow panel emits ``pointer_down`` while a + longer drag emits only a pan ``pointer_up`` — and not a ``pointer_down``.""" def _make_fig(self): fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) @@ -1114,7 +1110,7 @@ def _img_center_page(self) -> tuple[int, int]: return _to_page(cx, cy) def test_short_click_emits_on_click(self, interact_page): - """A short mousedown/up without movement fires an ``on_click`` event.""" + """A short mousedown/up without movement fires a ``pointer_down`` event.""" fig, plot = self._make_fig() panel_id = plot._id @@ -1128,15 +1124,15 @@ def test_short_click_emits_on_click(self, interact_page): _rafter(page) ev = _event(page) - assert ev.get("event_type") == "on_click", ( - f"Expected on_click from a short tap; got {ev.get('event_type')!r}" + assert ev.get("event_type") == "pointer_down", ( + f"Expected pointer_down from a short tap; got {ev.get('event_type')!r}" ) assert "img_x" in ev and "img_y" in ev, ( - "on_click event must include img_x and img_y coordinates" + "pointer_down event must include img_x and img_y coordinates" ) def test_drag_does_not_emit_on_click(self, interact_page): - """A visible drag (> 5 px) pans the image and must NOT fire ``on_click``.""" + """A visible drag (> 5 px) pans the image and must NOT fire ``pointer_down``.""" fig, plot = self._make_fig() panel_id = plot._id @@ -1151,7 +1147,7 @@ def test_drag_does_not_emit_on_click(self, interact_page): _rafter(page) ev = _event(page) - assert ev.get("event_type") != "on_click", ( - f"Expected pan (on_release), not on_click after a drag; " + assert ev.get("event_type") != "pointer_down", ( + f"Expected pan (pointer_up), not pointer_down after a drag; " f"got {ev.get('event_type')!r}" ) From b8de616add9b540c1fb32b3c70d614ca8c4e13b3 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 15 May 2026 14:16:34 -0500 Subject: [PATCH 132/198] fix: remove duplicate mouseleave and dblclick listeners in _attachEvents2d --- anyplotlib/figure_esm.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 1d06cec6..9563084c 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -2705,14 +2705,6 @@ function render({ model, el }) { overlayCanvas.focus(); _emitEvent(p.id,'pointer_enter',null,{..._pointerFields(e),x:e.offsetX,y:e.offsetY}); }); - overlayCanvas.addEventListener('mouseleave',(e)=>{ - _emitEvent(p.id,'pointer_leave',null,{..._pointerFields(e),x:e.offsetX,y:e.offsetY}); - }); - overlayCanvas.addEventListener('dblclick',(e)=>{ - const imgW=p.imgW||Math.max(1,p.pw-PAD_L-PAD_R), imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B); - const {mx,my}=_clientPos(e,overlayCanvas,imgW,imgH); - _emitEvent(p.id,'double_click',null,{..._pointerFields(e),x:mx,y:my}); - }); } function _attachEvents1d(p) { From 7ecc2a1dd815f90a8d8546476ef09b55c7904bfd Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 08:38:12 -0500 Subject: [PATCH 133/198] fix: correct button null guard in _pointerFields, fix key_down x/y undefined, clean stale comments --- anyplotlib/figure_esm.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 9563084c..80978bb2 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -1708,7 +1708,7 @@ function render({ model, el }) { return { time_stamp: performance.now() / 1000, modifiers: _modifiers(e), - button: e.button != null ? e.button : null, + button: e.buttons !== 0 ? e.button : null, buttons: e.buttons ?? 0, }; } @@ -1791,7 +1791,7 @@ function render({ model, el }) { modifiers: _modifiers(e), key: e.key, last_widget_id: p.lastWidgetId || null, - x: p.mouseX, y: p.mouseY, + x: p.mouseX ?? 0, y: p.mouseY ?? 0, }); if (e.key.toLowerCase() === 'r') { p.state.azimuth = -60; p.state.elevation = 30; p.state.zoom = 1; @@ -2561,7 +2561,7 @@ function render({ model, el }) { const _dist2=_dx*_dx+_dy*_dy; const _dt=Date.now()-_cc.t; if(_dist2<=25&&_dt<=350){ - // Genuine click — skip pan-settle, emit on_click with image coords. + // Genuine click — skip pan-settle, emit pointer_down with image coords. const [imgX,imgY]=_canvasToImg2d(_cc.mx,_cc.my,st,imgW,imgH); const xArr=st.x_axis||[], yArr=st.y_axis||[]; const _iw=st.image_width||1, _ih=st.image_height||1; @@ -2655,7 +2655,6 @@ function render({ model, el }) { // Keyboard shortcuts // Built-ins: r=reset zoom, c=colorbar toggle, l=log scale, s=symlog scale. // All keys are forwarded to Python unconditionally. - // to Python via on_key and suppresses the matching built-in. overlayCanvas.addEventListener('keydown',(e)=>{ const st=p.state; if(!st) return; const imgW=p.imgW||Math.max(1,p.pw-PAD_L-PAD_R), imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B); @@ -2669,7 +2668,7 @@ function render({ model, el }) { modifiers:_modifiers(e), key:e.key, last_widget_id:p.lastWidgetId||null, - x:p.mouseX, y:p.mouseY, + x:p.mouseX ?? 0, y:p.mouseY ?? 0, img_x:imgX, img_y:imgY, xdata:physX, ydata:physY, }); @@ -2812,7 +2811,7 @@ function render({ model, el }) { modifiers:_modifiers(e), key:e.key, last_widget_id:p.lastWidgetId||null, - x:p.mouseX, y:p.mouseY, + x:p.mouseX ?? 0, y:p.mouseY ?? 0, xdata:physX, }); if(e.key.toLowerCase()==='r'){st.view_x0=0;st.view_x1=1;draw1d(p);model.set(`panel_${p.id}_json`,JSON.stringify(st));model.save_changes();e.stopPropagation();e.preventDefault();} @@ -3931,7 +3930,7 @@ function render({ model, el }) { modifiers: _modifiers(e), key: e.key, last_widget_id: p.lastWidgetId || null, - x: p.mouseX, y: p.mouseY, + x: p.mouseX ?? 0, y: p.mouseY ?? 0, }); }); overlayCanvas.addEventListener('keyup', (e) => { From a49bd43339b1c72e7cef50012f41aaef2e938f1e Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 08:44:36 -0500 Subject: [PATCH 134/198] =?UTF-8?q?feat:=20add=20pointer=5Fsettled=20dwell?= =?UTF-8?q?=20timer=20to=20JS=20=E2=80=94=20zero=20cost=20when=20unused,?= =?UTF-8?q?=20per-panel=20ms/delta=20from=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- anyplotlib/figure_esm.js | 108 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 80978bb2..55bc719c 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -1717,6 +1717,8 @@ function render({ model, el }) { const { overlayCanvas } = p; let dragStart = null; let commitPending = false; + let _settledTimer = null; + let _settledStartX = 0, _settledStartY = 0, _settledStartTs = 0; function _scheduleCommit() { if (commitPending) return; commitPending = true; requestAnimationFrame(() => { @@ -1749,6 +1751,7 @@ function render({ model, el }) { e.preventDefault(); }); document.addEventListener('mouseup', (e) => { + clearTimeout(_settledTimer); _settledTimer = null; if (!dragStart) return; dragStart = null; overlayCanvas.style.cursor = 'grab'; @@ -1780,6 +1783,29 @@ function render({ model, el }) { const {mx, my} = _clientPos(e, overlayCanvas, p.pw, p.ph); p.mouseX = mx; p.mouseY = my; + // pointer_settled dwell timer (zero-cost when pointer_settled_ms === 0) + const _settledMs = (p.state.pointer_settled_ms ?? 0); + if (_settledMs > 0) { + const _settledDelta = p.state.pointer_settled_delta ?? 4; + clearTimeout(_settledTimer); + _settledStartX = mx; + _settledStartY = my; + _settledStartTs = performance.now(); + _settledTimer = setTimeout(() => { + const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); + if (dist <= _settledDelta) { + _emitEvent(p.id, 'pointer_settled', null, { + time_stamp: performance.now() / 1000, + modifiers: [], + button: null, + buttons: 0, + x: Math.round(p.mouseX), + y: Math.round(p.mouseY), + dwell_ms: performance.now() - _settledStartTs, + }); + } + }, _settledMs); + } }); // Keyboard shortcuts @@ -1809,6 +1835,7 @@ function render({ model, el }) { _emitEvent(p.id, 'pointer_enter', null, {..._pointerFields(e), x: e.offsetX, y: e.offsetY}); }); overlayCanvas.addEventListener('mouseleave', (e) => { + clearTimeout(_settledTimer); _settledTimer = null; _emitEvent(p.id, 'pointer_leave', null, {..._pointerFields(e), x: e.offsetX, y: e.offsetY}); }); overlayCanvas.addEventListener('keyup', (e) => { @@ -2461,6 +2488,8 @@ function render({ model, el }) { function _attachEvents2d(p) { const { overlayCanvas } = p; let localOnly=false, commitPending=false; + let _settledTimer = null; + let _settledStartX = 0, _settledStartY = 0, _settledStartTs = 0; function _scheduleCommit(){ if(commitPending) return; commitPending=true; requestAnimationFrame(()=>{commitPending=false;localOnly=true;model.save_changes();setTimeout(()=>{localOnly=false;},200);}); @@ -2536,6 +2565,7 @@ function render({ model, el }) { _scheduleCommit(); e.preventDefault(); }); document.addEventListener('mouseup',(e)=>{ + clearTimeout(_settledTimer); _settledTimer = null; if(p.ovDrag2d){ const _idx=p.ovDrag2d.idx; const _dw=(p.state.overlay_widgets||[])[_idx]||{}; @@ -2632,8 +2662,32 @@ function render({ model, el }) { } else { p.statusBar.style.display='none'; tooltip.style.display='none'; if(p._hoverSi!==-1){p._hoverSi=-1;p._hoverI=-1;drawMarkers2d(p,null);} } + // pointer_settled dwell timer (zero-cost when pointer_settled_ms === 0) + const _settledMs = (p.state.pointer_settled_ms ?? 0); + if (_settledMs > 0) { + const _settledDelta = p.state.pointer_settled_delta ?? 4; + clearTimeout(_settledTimer); + _settledStartX = mx; + _settledStartY = my; + _settledStartTs = performance.now(); + _settledTimer = setTimeout(() => { + const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); + if (dist <= _settledDelta) { + _emitEvent(p.id, 'pointer_settled', null, { + time_stamp: performance.now() / 1000, + modifiers: [], + button: null, + buttons: 0, + x: Math.round(p.mouseX), + y: Math.round(p.mouseY), + dwell_ms: performance.now() - _settledStartTs, + }); + } + }, _settledMs); + } }); overlayCanvas.addEventListener('mouseleave',(e)=>{ + clearTimeout(_settledTimer); _settledTimer = null; _emitEvent(p.id,'pointer_leave',null,{..._pointerFields(e),x:e.offsetX,y:e.offsetY}); p.statusBar.style.display='none';tooltip.style.display='none'; if(p._hoverSi!==-1){p._hoverSi=-1;p._hoverI=-1;drawMarkers2d(p,null);} @@ -2709,6 +2763,8 @@ function render({ model, el }) { function _attachEvents1d(p) { const { overlayCanvas } = p; let localOnly=false, commitPending=false; + let _settledTimer = null; + let _settledStartX = 0, _settledStartY = 0, _settledStartTs = 0; function _scheduleCommit(){ if(commitPending) return; commitPending=true; requestAnimationFrame(()=>{commitPending=false;localOnly=true;model.save_changes();setTimeout(()=>{localOnly=false;},200);}); @@ -2768,6 +2824,7 @@ function render({ model, el }) { model.set(`panel_${p.id}_json`,JSON.stringify(st));_scheduleCommit();e.preventDefault(); }); document.addEventListener('mouseup',(e)=>{ + clearTimeout(_settledTimer); _settledTimer = null; const wasWidgetDragging=!!p.ovDrag; // capture BEFORE clearing const wasDragging=wasWidgetDragging||!!p.isPanning; if(p.ovDrag){ @@ -2869,8 +2926,32 @@ function render({ model, el }) { } if(lhit) _emitEvent(p.id,'pointer_move',null,{line_id:lhit.lineId,x:lhit.x,y:lhit.y,..._pointerFields(e)}); } + // pointer_settled dwell timer (zero-cost when pointer_settled_ms === 0) + const _settledMs = (p.state.pointer_settled_ms ?? 0); + if (_settledMs > 0) { + const _settledDelta = p.state.pointer_settled_delta ?? 4; + clearTimeout(_settledTimer); + _settledStartX = mx; + _settledStartY = my; + _settledStartTs = performance.now(); + _settledTimer = setTimeout(() => { + const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); + if (dist <= _settledDelta) { + _emitEvent(p.id, 'pointer_settled', null, { + time_stamp: performance.now() / 1000, + modifiers: [], + button: null, + buttons: 0, + x: Math.round(p.mouseX), + y: Math.round(p.mouseY), + dwell_ms: performance.now() - _settledStartTs, + }); + } + }, _settledMs); + } }); overlayCanvas.addEventListener('mouseleave',(e)=>{ + clearTimeout(_settledTimer); _settledTimer = null; _emitEvent(p.id,'pointer_leave',null,{..._pointerFields(e),x:e.offsetX,y:e.offsetY}); p.statusBar.style.display='none';tooltip.style.display='none'; if(p._hoverSi!==-1){p._hoverSi=-1;p._hoverI=-1;drawMarkers1d(p,null);} @@ -3818,6 +3899,8 @@ function render({ model, el }) { // Widget drag support let commitPending = false; + let _settledTimer = null; + let _settledStartX = 0, _settledStartY = 0, _settledStartTs = 0; function _scheduleCommit() { if (commitPending) return; commitPending = true; requestAnimationFrame(() => { commitPending = false; model.save_changes(); }); @@ -3843,6 +3926,7 @@ function render({ model, el }) { }); document.addEventListener('mouseup', (e) => { + clearTimeout(_settledTimer); _settledTimer = null; if (!p.ovDrag) return; const _idx = p.ovDrag.idx; const _dw = (p.state.overlay_widgets || [])[_idx] || {}; @@ -3892,9 +3976,33 @@ function render({ model, el }) { tooltip.style.display = 'none'; overlayCanvas.style.cursor = 'default'; } + // pointer_settled dwell timer (zero-cost when pointer_settled_ms === 0) + const _settledMs = (p.state.pointer_settled_ms ?? 0); + if (_settledMs > 0) { + const _settledDelta = p.state.pointer_settled_delta ?? 4; + clearTimeout(_settledTimer); + _settledStartX = mx; + _settledStartY = my; + _settledStartTs = performance.now(); + _settledTimer = setTimeout(() => { + const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); + if (dist <= _settledDelta) { + _emitEvent(p.id, 'pointer_settled', null, { + time_stamp: performance.now() / 1000, + modifiers: [], + button: null, + buttons: 0, + x: Math.round(p.mouseX), + y: Math.round(p.mouseY), + dwell_ms: performance.now() - _settledStartTs, + }); + } + }, _settledMs); + } }); overlayCanvas.addEventListener('mouseleave', (e) => { + clearTimeout(_settledTimer); _settledTimer = null; _emitEvent(p.id, 'pointer_leave', null, {..._pointerFields(e), x: e.offsetX, y: e.offsetY}); if (p._hovBar !== null) { p._hovBar = null; drawBar(p); } tooltip.style.display = 'none'; From 0f4a8bba7b2a8980608ac4a804f932fc14f27f92 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 14:23:42 -0500 Subject: [PATCH 135/198] fix: snapshot performance.now() once in pointer_settled callback; clear stale timers on mousemove early returns; capture modifiers at arm time --- anyplotlib/figure_esm.js | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 55bc719c..33098446 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -1791,17 +1791,19 @@ function render({ model, el }) { _settledStartX = mx; _settledStartY = my; _settledStartTs = performance.now(); + const _settledMods = _modifiers(e); // capture at arm time — e is the mousemove event _settledTimer = setTimeout(() => { const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); if (dist <= _settledDelta) { + const _now = performance.now(); _emitEvent(p.id, 'pointer_settled', null, { - time_stamp: performance.now() / 1000, - modifiers: [], + time_stamp: _now / 1000, + modifiers: _settledMods, button: null, buttons: 0, x: Math.round(p.mouseX), y: Math.round(p.mouseY), - dwell_ms: performance.now() - _settledStartTs, + dwell_ms: _now - _settledStartTs, }); } }, _settledMs); @@ -2657,7 +2659,7 @@ function render({ model, el }) { p._hoverSi=newSi; p._hoverI=mhit?mhit.i:-1; drawMarkers2d(p, mhit?{si:newSi}:null); } - if(mhit&&(mhit.collectionLabel||mhit.markerLabel)){const parts=[];if(mhit.collectionLabel)parts.push(mhit.collectionLabel);if(mhit.markerLabel)parts.push(mhit.markerLabel);_showTooltip(parts.join('\n'),e.clientX,e.clientY);return;} + if(mhit&&(mhit.collectionLabel||mhit.markerLabel)){const parts=[];if(mhit.collectionLabel)parts.push(mhit.collectionLabel);if(mhit.markerLabel)parts.push(mhit.markerLabel);_showTooltip(parts.join('\n'),e.clientX,e.clientY);clearTimeout(_settledTimer); _settledTimer = null;return;} tooltip.style.display='none'; } else { p.statusBar.style.display='none'; tooltip.style.display='none'; if(p._hoverSi!==-1){p._hoverSi=-1;p._hoverI=-1;drawMarkers2d(p,null);} @@ -2670,17 +2672,19 @@ function render({ model, el }) { _settledStartX = mx; _settledStartY = my; _settledStartTs = performance.now(); + const _settledMods = _modifiers(e); // capture at arm time — e is the mousemove event _settledTimer = setTimeout(() => { const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); if (dist <= _settledDelta) { + const _now = performance.now(); _emitEvent(p.id, 'pointer_settled', null, { - time_stamp: performance.now() / 1000, - modifiers: [], + time_stamp: _now / 1000, + modifiers: _settledMods, button: null, buttons: 0, x: Math.round(p.mouseX), y: Math.round(p.mouseY), - dwell_ms: performance.now() - _settledStartTs, + dwell_ms: _now - _settledStartTs, }); } }, _settledMs); @@ -2894,6 +2898,7 @@ function render({ model, el }) { if(mxr.x+r.w||myr.y+r.h){ p.statusBar.style.display='none';tooltip.style.display='none'; if(p._hoverSi!==-1){p._hoverSi=-1;p._hoverI=-1;drawMarkers1d(p,null);} + clearTimeout(_settledTimer); _settledTimer = null; return; } const xArr = p._1dXArr || (st.x_axis_b64 ? _decodeF64(st.x_axis_b64) : (st.x_axis||[])); @@ -2934,17 +2939,19 @@ function render({ model, el }) { _settledStartX = mx; _settledStartY = my; _settledStartTs = performance.now(); + const _settledMods = _modifiers(e); // capture at arm time — e is the mousemove event _settledTimer = setTimeout(() => { const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); if (dist <= _settledDelta) { + const _now = performance.now(); _emitEvent(p.id, 'pointer_settled', null, { - time_stamp: performance.now() / 1000, - modifiers: [], + time_stamp: _now / 1000, + modifiers: _settledMods, button: null, buttons: 0, x: Math.round(p.mouseX), y: Math.round(p.mouseY), - dwell_ms: performance.now() - _settledStartTs, + dwell_ms: _now - _settledStartTs, }); } }, _settledMs); @@ -3984,17 +3991,19 @@ function render({ model, el }) { _settledStartX = mx; _settledStartY = my; _settledStartTs = performance.now(); + const _settledMods = _modifiers(e); // capture at arm time — e is the mousemove event _settledTimer = setTimeout(() => { const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); if (dist <= _settledDelta) { + const _now = performance.now(); _emitEvent(p.id, 'pointer_settled', null, { - time_stamp: performance.now() / 1000, - modifiers: [], + time_stamp: _now / 1000, + modifiers: _settledMods, button: null, buttons: 0, x: Math.round(p.mouseX), y: Math.round(p.mouseY), - dwell_ms: performance.now() - _settledStartTs, + dwell_ms: _now - _settledStartTs, }); } }, _settledMs); From 8f7ab0fac594728dc167aa13a9e2ca98b643bd58 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 16:49:47 -0500 Subject: [PATCH 136/198] test: add Playwright tests for pointer events, pointer_settled, and pause/hold integration --- .../test_interactive/test_event_pause_hold.py | 238 +++++++++++++ .../test_interactive/test_event_plots.py | 322 ++++++++++++++++++ .../test_interactive/test_event_settled.py | 213 ++++++++++++ 3 files changed, 773 insertions(+) create mode 100644 anyplotlib/tests/test_interactive/test_event_pause_hold.py create mode 100644 anyplotlib/tests/test_interactive/test_event_plots.py create mode 100644 anyplotlib/tests/test_interactive/test_event_settled.py diff --git a/anyplotlib/tests/test_interactive/test_event_pause_hold.py b/anyplotlib/tests/test_interactive/test_event_pause_hold.py new file mode 100644 index 00000000..738be26b --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_event_pause_hold.py @@ -0,0 +1,238 @@ +""" +tests/test_interactive/test_event_pause_hold.py +================================================ + +Tests for ``pause_events`` and ``hold_events`` Python-side context managers. + +``pause_events`` and ``hold_events`` operate on the ``CallbackRegistry`` +after events have been dispatched to Python. The Figure's ``_dispatch_event`` +method is the entry point: it builds an ``Event`` and calls +``plot.callbacks.fire()``. When paused, ``fire()`` drops the event; when +held, ``fire()`` buffers it and flushes on context exit. + +In the standalone Playwright setup there is no real Python kernel — the model +is a JS-only shim. Python handlers are therefore not reachable from the +browser. These tests drive the Python dispatch path directly via +``fig._dispatch_event(json_str)`` to verify pause/hold semantics end-to-end, +with a Playwright test verifying JS actually sends the expected events. +""" +from __future__ import annotations + +import json + +import numpy as np +import pytest + +import anyplotlib as apl + +# ── coordinate constants ────────────────────────────────────────────────────── +PAD_L, PAD_R, PAD_T, PAD_B = 58, 12, 12, 42 +GRID_PAD = 8 +FIG_W, FIG_H = 400, 300 + + +def _plot_center_page() -> tuple[int, int]: + cx = PAD_L + (FIG_W - PAD_L - PAD_R) // 2 + cy = PAD_T + (FIG_H - PAD_T - PAD_B) // 2 + return cx + GRID_PAD, cy + GRID_PAD + + +def _sim(fig, plot, event_type: str, **fields) -> None: + """Simulate a JS event by calling fig._dispatch_event directly.""" + payload = {"source": "js", "panel_id": plot._id, "event_type": event_type} + payload.update(fields) + fig._dispatch_event(json.dumps(payload)) + + +def _collect_events(page) -> None: + page.evaluate("""() => { + window._aplAllEvents = []; + const orig = window._aplModel.set.bind(window._aplModel); + window._aplModel.set = (k, v) => { + if (k === 'event_json') { + try { window._aplAllEvents.push(JSON.parse(v)); } catch(_) {} + } + return orig(k, v); + }; + }""") + + +def _get_events(page, event_type: str | None = None) -> list: + events = page.evaluate("() => window._aplAllEvents") + if event_type: + return [e for e in events if e.get("event_type") == event_type] + return events + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 1. pause_events — Python-side dispatch simulation +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestPauseIntegration: + def test_pause_drops_pointer_move(self): + """pause_events suppresses Python handler calls for pointer_move.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((64, 64))) + received = [] + plot.add_event_handler(lambda e: received.append(1), "pointer_move") + + with plot.pause_events("pointer_move"): + _sim(fig, plot, "pointer_move", x=100, y=100) + _sim(fig, plot, "pointer_move", x=110, y=100) + + assert received == [], ( + f"pause_events should drop all pointer_move calls; got {len(received)}" + ) + + def test_events_resume_after_pause_exits(self): + """pointer_move handler fires again after pause_events context exits.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((64, 64))) + received = [] + plot.add_event_handler(lambda e: received.append(1), "pointer_move") + + with plot.pause_events("pointer_move"): + _sim(fig, plot, "pointer_move", x=100, y=100) + + assert received == [], "No events during pause" + + # After context exits, moves fire again + _sim(fig, plot, "pointer_move", x=120, y=100) + assert len(received) == 1, ( + "pointer_move should fire after pause_events context exits" + ) + + def test_pause_only_specified_type(self): + """pause_events('pointer_move') does not suppress pointer_down.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((64, 64))) + move_calls = [] + down_calls = [] + plot.add_event_handler(lambda e: move_calls.append(1), "pointer_move") + plot.add_event_handler(lambda e: down_calls.append(1), "pointer_down") + + with plot.pause_events("pointer_move"): + _sim(fig, plot, "pointer_move", x=100, y=100) + _sim(fig, plot, "pointer_down", x=100, y=100) + + assert move_calls == [], "pointer_move should be paused" + assert len(down_calls) == 1, "pointer_down should not be paused" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 2. hold_events — buffers and flushes on exit +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestHoldIntegration: + def test_hold_buffers_pointer_settled_and_flushes_on_exit(self): + """pointer_settled is buffered during hold and flushed on context exit.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((32, 32))) + received = [] + plot.add_event_handler( + lambda e: received.append(e), + "pointer_settled", + ms=200, + delta=4, + ) + + with plot.hold_events("pointer_settled"): + _sim(fig, plot, "pointer_settled", x=100, y=100, dwell_ms=250.0) + _sim(fig, plot, "pointer_settled", x=101, y=100, dwell_ms=260.0) + assert received == [], "Handler should not be called while holding" + + assert len(received) == 2, ( + f"Both buffered events should flush on context exit; got {len(received)}" + ) + + def test_hold_is_type_specific(self): + """hold_events('pointer_settled') does not delay pointer_move.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((32, 32))) + + move_received = [] + settled_received = [] + plot.add_event_handler( + lambda e: move_received.append(1), "pointer_move" + ) + plot.add_event_handler( + lambda e: settled_received.append(1), + "pointer_settled", + ms=200, + delta=4, + ) + + with plot.hold_events("pointer_settled"): + _sim(fig, plot, "pointer_move", x=100, y=100) + _sim(fig, plot, "pointer_settled", x=100, y=100, dwell_ms=250.0) + + # pointer_move fires immediately + assert len(move_received) == 1, ( + "pointer_move should not be held when only pointer_settled is held" + ) + # pointer_settled is still buffered + assert settled_received == [], ( + "pointer_settled should not have fired yet (still inside hold)" + ) + + # On exit, buffered pointer_settled is flushed + assert len(settled_received) == 1, ( + "pointer_settled should flush on context exit" + ) + + def test_hold_flush_preserves_event_order(self): + """Buffered events are flushed in the order they were received.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((32, 32))) + order = [] + plot.add_event_handler( + lambda e: order.append(e.x), + "pointer_settled", + ms=200, + ) + + with plot.hold_events("pointer_settled"): + for xval in (10, 20, 30): + _sim(fig, plot, "pointer_settled", x=xval, y=100, dwell_ms=210.0) + + assert order == [10, 20, 30], ( + f"Events should flush in order; got {order}" + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 3. Playwright smoke test — JS sends pointer_move during drag on 3D panel +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestPlaywrightJSSends: + """Verify JS actually emits pointer_move events that could be paused/held. + + This confirms the JS side of the pipeline is working; the pause/hold + semantics are tested purely in Python (above) since the standalone shim + has no real Python kernel. + """ + + def test_3d_drag_sends_pointer_move_events(self, interact_page): + """A drag on a 3D panel emits multiple pointer_move event_json payloads.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + x = np.linspace(-1, 1, 8) + X, Y = np.meshgrid(x, x) + Z = X ** 2 + Y ** 2 + plot = ax.plot_surface(X, Y, Z) + + page = interact_page(fig) + _collect_events(page) + + cx = FIG_W // 2 + GRID_PAD + cy = FIG_H // 2 + GRID_PAD + page.mouse.move(cx, cy) + page.mouse.down() + page.mouse.move(cx + 40, cy, steps=6) + page.mouse.up() + page.wait_for_timeout(100) + + events = _get_events(page, "pointer_move") + assert len(events) > 0, ( + "JS should emit pointer_move events during a 3D drag; " + "these are what pause_events/hold_events would intercept in Python" + ) diff --git a/anyplotlib/tests/test_interactive/test_event_plots.py b/anyplotlib/tests/test_interactive/test_event_plots.py new file mode 100644 index 00000000..87f986a5 --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_event_plots.py @@ -0,0 +1,322 @@ +""" +tests/test_interactive/test_event_plots.py +========================================== + +Playwright tests verifying that the JS event system correctly emits the new +event types introduced in the event system redesign. + +Coordinate system (mirrors figure_esm.js constants): + PAD_L=58 PAD_R=12 PAD_T=12 PAD_B=42 GRID_PAD=8 + For a 400×300 fig: plot rect = {x:58, y:12, w:330, h:246} + Page coords = canvas coords + 8 +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl + +# ── layout constants ────────────────────────────────────────────────────────── +PAD_L, PAD_R, PAD_T, PAD_B = 58, 12, 12, 42 +GRID_PAD = 8 + +FIG_W, FIG_H = 400, 300 + + +def _plot_center_page() -> tuple[int, int]: + """Page-space centre of the plot area for a 400×300 figure.""" + cx = PAD_L + (FIG_W - PAD_L - PAD_R) // 2 + cy = PAD_T + (FIG_H - PAD_T - PAD_B) // 2 + return cx + GRID_PAD, cy + GRID_PAD + + +def _collect_events(page) -> None: + """Monkey-patch model.set to accumulate every event_json payload.""" + page.evaluate("""() => { + window._aplAllEvents = []; + const orig = window._aplModel.set.bind(window._aplModel); + window._aplModel.set = (k, v) => { + if (k === 'event_json') { + try { window._aplAllEvents.push(JSON.parse(v)); } catch(_) {} + } + return orig(k, v); + }; + }""") + + +def _get_events(page, event_type: str | None = None) -> list: + events = page.evaluate("() => window._aplAllEvents") + if event_type: + return [e for e in events if e.get("event_type") == event_type] + return events + + +# ── fixtures ────────────────────────────────────────────────────────────────── + +def _make_2d_page(interact_page): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((32, 32))) + page = interact_page(fig) + _collect_events(page) + return page, plot + + +def _make_3d_page(interact_page): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + x = np.linspace(-1, 1, 8) + X, Y = np.meshgrid(x, x) + Z = X ** 2 + Y ** 2 + plot = ax.plot_surface(X, Y, Z) + page = interact_page(fig) + _collect_events(page) + return page, plot + + +# ═══════════════════════════════════════════════════════════════════════════════ +# pointer_down — 2D click emits correct fields +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestPointerDown: + def test_2d_click_emits_pointer_down_fields(self, interact_page): + """Short click on a 2D panel emits pointer_down with required fields.""" + page, plot = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.click(px, py) + page.wait_for_timeout(100) + + events = _get_events(page, "pointer_down") + assert len(events) >= 1, "Expected at least one pointer_down event" + e = events[0] + for field in ("event_type", "x", "y", "button", "buttons", "modifiers", "time_stamp"): + assert field in e, f"pointer_down missing field {field!r}" + assert e["event_type"] == "pointer_down" + assert isinstance(e["modifiers"], list) + + def test_2d_pointer_down_has_xdata_ydata(self, interact_page): + """Plot2D pointer_down includes xdata and ydata fields.""" + page, plot = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.click(px, py) + page.wait_for_timeout(100) + + events = _get_events(page, "pointer_down") + assert len(events) >= 1 + e = events[0] + assert "xdata" in e, "2D pointer_down must include xdata" + assert "ydata" in e, "2D pointer_down must include ydata" + assert e["xdata"] is not None + assert e["ydata"] is not None + + def test_ctrl_click_modifiers(self, interact_page): + """Ctrl+click produces modifiers=['ctrl'] on pointer_down.""" + page, plot = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.keyboard.down("Control") + page.mouse.click(px, py) + page.keyboard.up("Control") + page.wait_for_timeout(100) + + events = _get_events(page, "pointer_down") + assert len(events) >= 1 + assert "ctrl" in events[0].get("modifiers", []), ( + f"Expected 'ctrl' in modifiers, got {events[0].get('modifiers')!r}" + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# pointer_up — fires after mousedown + mousemove + mouseup +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestPointerUp: + def test_fires_after_drag(self, interact_page): + """pointer_up fires after a drag sequence.""" + page, plot = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.move(px, py) + page.mouse.down() + page.mouse.move(px + 30, py, steps=5) + page.mouse.up() + page.wait_for_timeout(100) + + events = _get_events(page, "pointer_up") + assert len(events) >= 1, "Expected at least one pointer_up event" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# pointer_move — fires during drag (3D panel emits it on every mousemove) +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestPointerMove: + def test_fires_during_drag(self, interact_page): + """pointer_move events fire during a drag on a 3D panel.""" + page, plot = _make_3d_page(interact_page) + cx = FIG_W // 2 + GRID_PAD + cy = FIG_H // 2 + GRID_PAD + + page.mouse.move(cx, cy) + page.mouse.down() + page.mouse.move(cx + 40, cy, steps=8) + page.mouse.up() + page.wait_for_timeout(100) + + events = _get_events(page, "pointer_move") + assert len(events) > 0, "Expected pointer_move events during 3D drag" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# pointer_enter / pointer_leave +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestPointerEnterLeave: + def test_pointer_enter_fires_on_mouseenter(self, interact_page): + """pointer_enter fires when mouse enters the canvas.""" + page, plot = _make_2d_page(interact_page) + px, py = _plot_center_page() + + # Start outside, move inside + page.mouse.move(0, 0) + page.wait_for_timeout(50) + page.mouse.move(px, py) + page.wait_for_timeout(100) + + events = _get_events(page, "pointer_enter") + assert len(events) >= 1, "Expected pointer_enter event" + e = events[0] + # button should be null when no button is held + assert e.get("button") is None, ( + f"pointer_enter button should be null, got {e.get('button')!r}" + ) + + def test_pointer_leave_fires_on_mouseleave(self, interact_page): + """pointer_leave fires when mouse leaves the canvas.""" + page, plot = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.move(px, py) + page.wait_for_timeout(50) + page.mouse.move(0, 0) + page.wait_for_timeout(100) + + events = _get_events(page, "pointer_leave") + assert len(events) >= 1, "Expected pointer_leave event" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# double_click +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestDoubleClick: + def test_fires_on_dblclick(self, interact_page): + """double_click event fires on a browser dblclick.""" + page, plot = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.dblclick(px, py) + page.wait_for_timeout(100) + + events = _get_events(page, "double_click") + assert len(events) >= 1, "Expected double_click event" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# wheel +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestWheel: + def test_fires_with_dy_field(self, interact_page): + """wheel event fires with a dy field when scrolling.""" + page, plot = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.move(px, py) + page.wait_for_timeout(50) + page.mouse.wheel(0, 120) + page.wait_for_timeout(100) + + events = _get_events(page, "wheel") + assert len(events) >= 1, "Expected wheel event" + e = events[0] + assert "dy" in e, "wheel event must include dy field" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# key_down / key_up +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestKeyEvents: + def test_key_down_fires_on_keypress(self, interact_page): + """key_down fires for any keypress (not just registered shortcuts).""" + page, plot = _make_2d_page(interact_page) + px, py = _plot_center_page() + + # Focus canvas via mouseenter + page.mouse.move(px, py) + page.wait_for_timeout(50) + + page.keyboard.press("q") + page.wait_for_timeout(100) + + events = _get_events(page, "key_down") + assert len(events) >= 1, "Expected key_down event" + e = events[0] + assert e.get("key") == "q", f"Expected key='q', got {e.get('key')!r}" + + def test_key_up_fires_on_key_release(self, interact_page): + """key_up fires when a key is released.""" + page, plot = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.move(px, py) + page.wait_for_timeout(50) + + page.keyboard.down("z") + page.wait_for_timeout(30) + page.keyboard.up("z") + page.wait_for_timeout(100) + + events = _get_events(page, "key_up") + assert len(events) >= 1, "Expected key_up event" + e = events[0] + assert e.get("key") == "z", f"Expected key='z', got {e.get('key')!r}" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Plot3D — pointer_down absent, wheel present +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestPlot3DEvents: + def test_3d_pointer_down_has_no_xdata_ydata(self, interact_page): + """Plot3D does not emit pointer_down (no xdata/ydata in any event).""" + page, plot = _make_3d_page(interact_page) + # 3D canvas covers the full panel; use centre + cx = FIG_W // 2 + GRID_PAD + cy = FIG_H // 2 + GRID_PAD + + page.mouse.click(cx, cy) + page.wait_for_timeout(100) + + down_events = _get_events(page, "pointer_down") + # 3D does not emit pointer_down at all + assert len(down_events) == 0, ( + "Plot3D should not emit pointer_down events" + ) + + def test_3d_wheel_fires(self, interact_page): + """Plot3D emits a wheel event on scroll.""" + page, plot = _make_3d_page(interact_page) + cx = FIG_W // 2 + GRID_PAD + cy = FIG_H // 2 + GRID_PAD + + page.mouse.move(cx, cy) + page.wait_for_timeout(50) + page.mouse.wheel(0, 120) + page.wait_for_timeout(100) + + wheel_events = _get_events(page, "wheel") + assert len(wheel_events) >= 1, "Expected wheel event from 3D panel" + assert "dy" in wheel_events[0] diff --git a/anyplotlib/tests/test_interactive/test_event_settled.py b/anyplotlib/tests/test_interactive/test_event_settled.py new file mode 100644 index 00000000..578e1c72 --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_event_settled.py @@ -0,0 +1,213 @@ +""" +tests/test_interactive/test_event_settled.py +============================================ + +Pure-Python unit tests and Playwright integration tests for the +``pointer_settled`` event. + +Pure-Python tests verify that connecting / disconnecting a handler updates +the ``pointer_settled_ms`` / ``pointer_settled_delta`` state fields. + +Playwright tests verify that the JS dwell timer fires after the configured +dwell period and suppresses when the pointer keeps moving. +""" +from __future__ import annotations + +import json +import time + +import numpy as np +import pytest + +import anyplotlib as apl + +# ── coordinate constants ────────────────────────────────────────────────────── +PAD_L, PAD_R, PAD_T, PAD_B = 58, 12, 12, 42 +GRID_PAD = 8 +FIG_W, FIG_H = 400, 300 + + +def _plot_center_page() -> tuple[int, int]: + cx = PAD_L + (FIG_W - PAD_L - PAD_R) // 2 + cy = PAD_T + (FIG_H - PAD_T - PAD_B) // 2 + return cx + GRID_PAD, cy + GRID_PAD + + +def _collect_events(page) -> None: + page.evaluate("""() => { + window._aplAllEvents = []; + const orig = window._aplModel.set.bind(window._aplModel); + window._aplModel.set = (k, v) => { + if (k === 'event_json') { + try { window._aplAllEvents.push(JSON.parse(v)); } catch(_) {} + } + return orig(k, v); + }; + }""") + + +def _get_events(page, event_type: str | None = None) -> list: + events = page.evaluate("() => window._aplAllEvents") + if event_type: + return [e for e in events if e.get("event_type") == event_type] + return events + + +def _panel_state(page, plot) -> dict: + raw = page.evaluate( + f"() => window._aplModel.get('panel_{plot._id}_json')" + ) + return json.loads(raw) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Pure-Python: state field updates on connect / disconnect +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestSettledConfig: + def test_default_state_before_any_handler(self): + """pointer_settled_ms starts at 0 and delta at 4 before any handler.""" + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((32, 32))) + assert plot._state["pointer_settled_ms"] == 0 + assert plot._state["pointer_settled_delta"] == 4 + + def test_state_set_on_first_connect(self): + """Connecting a pointer_settled handler sets ms and delta in _state.""" + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((32, 32))) + plot.add_event_handler(lambda e: None, "pointer_settled", ms=400, delta=5) + assert plot._state["pointer_settled_ms"] == 400 + assert plot._state["pointer_settled_delta"] == 5 + + def test_state_cleared_on_last_disconnect(self): + """Removing the last pointer_settled handler resets ms to 0.""" + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((32, 32))) + fn = lambda e: None + plot.add_event_handler(fn, "pointer_settled", ms=400, delta=5) + plot.remove_handler(fn) + assert plot._state["pointer_settled_ms"] == 0 + + def test_multiple_handlers_use_last_configured_ms(self): + """Adding a second handler overrides ms/delta with the new values.""" + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((32, 32))) + fn1 = lambda e: None + fn2 = lambda e: None + plot.add_event_handler(fn1, "pointer_settled", ms=300, delta=4) + plot.add_event_handler(fn2, "pointer_settled", ms=500, delta=8) + assert plot._state["pointer_settled_ms"] == 500 + assert plot._state["pointer_settled_delta"] == 8 + + def test_remove_one_handler_keeps_nonzero_ms(self): + """Removing one handler when another remains keeps ms non-zero.""" + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((32, 32))) + fn1 = lambda e: None + fn2 = lambda e: None + plot.add_event_handler(fn1, "pointer_settled", ms=400) + plot.add_event_handler(fn2, "pointer_settled", ms=400) + plot.remove_handler(fn1) + # fn2 is still connected — ms must remain non-zero + assert plot._state["pointer_settled_ms"] > 0 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Playwright: dwell timer fires / suppresses correctly +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestSettledPlaywright: + def _make_page(self, interact_page, ms: int = 200, delta: int = 4): + """Create a 2D imshow with a pointer_settled handler at ms=200.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((32, 32))) + received = [] + plot.add_event_handler( + lambda e: received.append(e), + "pointer_settled", + ms=ms, + delta=delta, + ) + page = interact_page(fig) + _collect_events(page) + return page, plot, received + + def test_no_timer_when_no_handler(self, interact_page): + """pointer_settled_ms stays 0 in JS when no handler is connected.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((32, 32))) + # No handler — do NOT call add_event_handler + page = interact_page(fig) + + ms_val = page.evaluate( + f"() => JSON.parse(window._aplModel.get('panel_{plot._id}_json')).pointer_settled_ms" + ) + assert ms_val == 0, ( + f"pointer_settled_ms should be 0 when no handler connected, got {ms_val}" + ) + + def test_fires_after_hold(self, interact_page): + """pointer_settled fires after the pointer holds still for >= ms.""" + page, plot, received = self._make_page(interact_page, ms=200) + px, py = _plot_center_page() + + # Confirm JS sees the configured ms + ms_in_js = page.evaluate( + f"() => JSON.parse(window._aplModel.get('panel_{plot._id}_json')).pointer_settled_ms" + ) + assert ms_in_js == 200, f"JS pointer_settled_ms should be 200, got {ms_in_js}" + + # Move into panel and hold still for 350 ms (well past 200 ms threshold) + page.mouse.move(px, py) + page.wait_for_timeout(350) + + events = _get_events(page, "pointer_settled") + assert len(events) >= 1, ( + "pointer_settled should fire after holding still for >= 200 ms" + ) + e = events[0] + assert "dwell_ms" in e, "pointer_settled must include dwell_ms" + assert e["dwell_ms"] >= 200, ( + f"dwell_ms should be >= 200, got {e['dwell_ms']:.1f}" + ) + + def test_does_not_fire_if_moving(self, interact_page): + """pointer_settled does not fire if the pointer keeps moving.""" + page, plot, received = self._make_page(interact_page, ms=300) + px, py = _plot_center_page() + + # Keep moving for 250 ms (less than 300 ms threshold) + page.mouse.move(px, py) + for _ in range(8): + px += 5 + page.mouse.move(px, py) + page.wait_for_timeout(30) + + events = _get_events(page, "pointer_settled") + assert len(events) == 0, ( + "pointer_settled should not fire while the pointer is still moving" + ) + + def test_fires_again_after_re_settle(self, interact_page): + """pointer_settled fires a second time after a second dwell period.""" + page, plot, received = self._make_page(interact_page, ms=200) + px, py = _plot_center_page() + + # First dwell + page.mouse.move(px, py) + page.wait_for_timeout(300) + + first_count = len(_get_events(page, "pointer_settled")) + assert first_count >= 1, "First pointer_settled should have fired" + + # Move away to reset the timer, then hold again + page.mouse.move(px + 30, py + 30) + page.wait_for_timeout(50) + page.mouse.move(px, py) + page.wait_for_timeout(300) + + second_count = len(_get_events(page, "pointer_settled")) + assert second_count >= 2, ( + f"Expected at least 2 pointer_settled events, got {second_count}" + ) From 223509d5482cedede43a7910437737fd0f7546fc Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 17:12:58 -0500 Subject: [PATCH 137/198] fix: add button=0 assertion to double_click test; fix 3d no-xdata test; add delta=0 assertion --- anyplotlib/figure_esm.js | 8 ++++---- .../tests/test_interactive/test_event_plots.py | 18 ++++++++++-------- .../test_interactive/test_event_settled.py | 1 + 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 33098446..246d97af 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -1850,7 +1850,7 @@ function render({ model, el }) { }); overlayCanvas.addEventListener('dblclick', (e) => { const {mx, my} = _clientPos(e, overlayCanvas, p.pw, p.ph); - _emitEvent(p.id, 'double_click', null, {..._pointerFields(e), x: mx, y: my}); + _emitEvent(p.id, 'double_click', null, {..._pointerFields(e), button: e.button, x: mx, y: my}); }); } @@ -2699,7 +2699,7 @@ function render({ model, el }) { overlayCanvas.addEventListener('dblclick',(e)=>{ const imgW=p.imgW||Math.max(1,p.pw-PAD_L-PAD_R), imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B); const {mx,my}=_clientPos(e,overlayCanvas,imgW,imgH); - _emitEvent(p.id,'double_click',null,{..._pointerFields(e),x:mx,y:my}); + _emitEvent(p.id,'double_click',null,{..._pointerFields(e),button:e.button,x:mx,y:my}); }); overlayCanvas.addEventListener('wheel',(e)=>{ _emitEvent(p.id,'wheel',null,{ @@ -2966,7 +2966,7 @@ function render({ model, el }) { }); overlayCanvas.addEventListener('dblclick',(e)=>{ const {mx,my}=_clientPos(e,overlayCanvas,p.pw,p.ph); - _emitEvent(p.id,'double_click',null,{..._pointerFields(e),x:mx,y:my}); + _emitEvent(p.id,'double_click',null,{..._pointerFields(e),button:e.button,x:mx,y:my}); }); overlayCanvas.addEventListener('wheel',(e)=>{ _emitEvent(p.id,'wheel',null,{ @@ -4066,7 +4066,7 @@ function render({ model, el }) { }); overlayCanvas.addEventListener('dblclick', (e) => { const {mx, my} = _clientPos(e, overlayCanvas, p.pw, p.ph); - _emitEvent(p.id, 'double_click', null, {..._pointerFields(e), x: mx, y: my}); + _emitEvent(p.id, 'double_click', null, {..._pointerFields(e), button: e.button, x: mx, y: my}); }); overlayCanvas.addEventListener('wheel', (e) => { _emitEvent(p.id, 'wheel', null, { diff --git a/anyplotlib/tests/test_interactive/test_event_plots.py b/anyplotlib/tests/test_interactive/test_event_plots.py index 87f986a5..65d44912 100644 --- a/anyplotlib/tests/test_interactive/test_event_plots.py +++ b/anyplotlib/tests/test_interactive/test_event_plots.py @@ -221,6 +221,7 @@ def test_fires_on_dblclick(self, interact_page): events = _get_events(page, "double_click") assert len(events) >= 1, "Expected double_click event" + assert events[0].get("button") == 0 # ═══════════════════════════════════════════════════════════════════════════════ @@ -290,21 +291,22 @@ def test_key_up_fires_on_key_release(self, interact_page): # ═══════════════════════════════════════════════════════════════════════════════ class TestPlot3DEvents: - def test_3d_pointer_down_has_no_xdata_ydata(self, interact_page): - """Plot3D does not emit pointer_down (no xdata/ydata in any event).""" + def test_3d_pointer_down_no_xdata(self, interact_page): + """3D pointer_down events (if any) should not have xdata/ydata fields.""" page, plot = _make_3d_page(interact_page) # 3D canvas covers the full panel; use centre cx = FIG_W // 2 + GRID_PAD cy = FIG_H // 2 + GRID_PAD + page.mouse.move(cx, cy) page.mouse.click(cx, cy) - page.wait_for_timeout(100) + page.wait_for_timeout(300) - down_events = _get_events(page, "pointer_down") - # 3D does not emit pointer_down at all - assert len(down_events) == 0, ( - "Plot3D should not emit pointer_down events" - ) + events = _get_events(page, "pointer_down") + for e in events: + assert e.get("xdata") is None, "3D pointer_down should not have xdata" + assert e.get("ydata") is None, "3D pointer_down should not have ydata" + # Test passes even if no pointer_down events — 3D may not emit them def test_3d_wheel_fires(self, interact_page): """Plot3D emits a wheel event on scroll.""" diff --git a/anyplotlib/tests/test_interactive/test_event_settled.py b/anyplotlib/tests/test_interactive/test_event_settled.py index 578e1c72..174e5d49 100644 --- a/anyplotlib/tests/test_interactive/test_event_settled.py +++ b/anyplotlib/tests/test_interactive/test_event_settled.py @@ -88,6 +88,7 @@ def test_state_cleared_on_last_disconnect(self): plot.add_event_handler(fn, "pointer_settled", ms=400, delta=5) plot.remove_handler(fn) assert plot._state["pointer_settled_ms"] == 0 + assert plot._state["pointer_settled_delta"] == 0 def test_multiple_handlers_use_last_configured_ms(self): """Adding a second handler overrides ms/delta with the new values.""" From 5abc64c2ef3fae54dc60373f1bf6da40b05b1636 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 17:52:00 -0500 Subject: [PATCH 138/198] refactor: extract shared event test helpers; fix 3d no-pointer_down positive assertion --- .../test_interactive/_event_test_utils.py | 35 ++++++++++++++ .../test_interactive/test_event_pause_hold.py | 34 ++------------ .../test_interactive/test_event_plots.py | 47 ++++--------------- .../test_interactive/test_event_settled.py | 42 ++--------------- 4 files changed, 54 insertions(+), 104 deletions(-) create mode 100644 anyplotlib/tests/test_interactive/_event_test_utils.py diff --git a/anyplotlib/tests/test_interactive/_event_test_utils.py b/anyplotlib/tests/test_interactive/_event_test_utils.py new file mode 100644 index 00000000..9e87ddd4 --- /dev/null +++ b/anyplotlib/tests/test_interactive/_event_test_utils.py @@ -0,0 +1,35 @@ +"""Shared helpers for event system Playwright tests.""" +from __future__ import annotations + +# Layout constants (match figure_esm.js) +PAD_L, PAD_R, PAD_T, PAD_B = 58, 12, 12, 42 +GRID_PAD = 8 + + +def _collect_events(page) -> None: + """Monkey-patch model.set to accumulate all event_json payloads in window._aplAllEvents.""" + page.evaluate("""() => { + window._aplAllEvents = []; + const orig = window._aplModel.set.bind(window._aplModel); + window._aplModel.set = (k, v) => { + if (k === 'event_json') { + try { window._aplAllEvents.push(JSON.parse(v)); } catch(_) {} + } + return orig(k, v); + }; + }""") + + +def _get_events(page, event_type=None) -> list: + """Return collected events, optionally filtered by event_type.""" + events = page.evaluate("() => window._aplAllEvents") + if event_type: + return [e for e in events if e.get("event_type") == event_type] + return events + + +def _plot_center_page(fig_w: int = 400, fig_h: int = 300) -> tuple[int, int]: + """Return page coords for the center of the plot area.""" + cx = GRID_PAD + PAD_L + (fig_w - PAD_L - PAD_R) // 2 + cy = GRID_PAD + PAD_T + (fig_h - PAD_T - PAD_B) // 2 + return cx, cy diff --git a/anyplotlib/tests/test_interactive/test_event_pause_hold.py b/anyplotlib/tests/test_interactive/test_event_pause_hold.py index 738be26b..2a21e70d 100644 --- a/anyplotlib/tests/test_interactive/test_event_pause_hold.py +++ b/anyplotlib/tests/test_interactive/test_event_pause_hold.py @@ -24,19 +24,15 @@ import pytest import anyplotlib as apl +from anyplotlib.tests.test_interactive._event_test_utils import ( + _collect_events, + _get_events, + GRID_PAD, +) -# ── coordinate constants ────────────────────────────────────────────────────── -PAD_L, PAD_R, PAD_T, PAD_B = 58, 12, 12, 42 -GRID_PAD = 8 FIG_W, FIG_H = 400, 300 -def _plot_center_page() -> tuple[int, int]: - cx = PAD_L + (FIG_W - PAD_L - PAD_R) // 2 - cy = PAD_T + (FIG_H - PAD_T - PAD_B) // 2 - return cx + GRID_PAD, cy + GRID_PAD - - def _sim(fig, plot, event_type: str, **fields) -> None: """Simulate a JS event by calling fig._dispatch_event directly.""" payload = {"source": "js", "panel_id": plot._id, "event_type": event_type} @@ -44,26 +40,6 @@ def _sim(fig, plot, event_type: str, **fields) -> None: fig._dispatch_event(json.dumps(payload)) -def _collect_events(page) -> None: - page.evaluate("""() => { - window._aplAllEvents = []; - const orig = window._aplModel.set.bind(window._aplModel); - window._aplModel.set = (k, v) => { - if (k === 'event_json') { - try { window._aplAllEvents.push(JSON.parse(v)); } catch(_) {} - } - return orig(k, v); - }; - }""") - - -def _get_events(page, event_type: str | None = None) -> list: - events = page.evaluate("() => window._aplAllEvents") - if event_type: - return [e for e in events if e.get("event_type") == event_type] - return events - - # ═══════════════════════════════════════════════════════════════════════════════ # 1. pause_events — Python-side dispatch simulation # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/anyplotlib/tests/test_interactive/test_event_plots.py b/anyplotlib/tests/test_interactive/test_event_plots.py index 65d44912..b95a0e44 100644 --- a/anyplotlib/tests/test_interactive/test_event_plots.py +++ b/anyplotlib/tests/test_interactive/test_event_plots.py @@ -16,42 +16,16 @@ import pytest import anyplotlib as apl - -# ── layout constants ────────────────────────────────────────────────────────── -PAD_L, PAD_R, PAD_T, PAD_B = 58, 12, 12, 42 -GRID_PAD = 8 +from anyplotlib.tests.test_interactive._event_test_utils import ( + _collect_events, + _get_events, + _plot_center_page, + GRID_PAD, +) FIG_W, FIG_H = 400, 300 -def _plot_center_page() -> tuple[int, int]: - """Page-space centre of the plot area for a 400×300 figure.""" - cx = PAD_L + (FIG_W - PAD_L - PAD_R) // 2 - cy = PAD_T + (FIG_H - PAD_T - PAD_B) // 2 - return cx + GRID_PAD, cy + GRID_PAD - - -def _collect_events(page) -> None: - """Monkey-patch model.set to accumulate every event_json payload.""" - page.evaluate("""() => { - window._aplAllEvents = []; - const orig = window._aplModel.set.bind(window._aplModel); - window._aplModel.set = (k, v) => { - if (k === 'event_json') { - try { window._aplAllEvents.push(JSON.parse(v)); } catch(_) {} - } - return orig(k, v); - }; - }""") - - -def _get_events(page, event_type: str | None = None) -> list: - events = page.evaluate("() => window._aplAllEvents") - if event_type: - return [e for e in events if e.get("event_type") == event_type] - return events - - # ── fixtures ────────────────────────────────────────────────────────────────── def _make_2d_page(interact_page): @@ -292,9 +266,9 @@ def test_key_up_fires_on_key_release(self, interact_page): class TestPlot3DEvents: def test_3d_pointer_down_no_xdata(self, interact_page): - """3D pointer_down events (if any) should not have xdata/ydata fields.""" + """3D panels do not emit pointer_down events (no click detection in 3D).""" page, plot = _make_3d_page(interact_page) - # 3D canvas covers the full panel; use centre + _collect_events(page) cx = FIG_W // 2 + GRID_PAD cy = FIG_H // 2 + GRID_PAD @@ -303,10 +277,7 @@ def test_3d_pointer_down_no_xdata(self, interact_page): page.wait_for_timeout(300) events = _get_events(page, "pointer_down") - for e in events: - assert e.get("xdata") is None, "3D pointer_down should not have xdata" - assert e.get("ydata") is None, "3D pointer_down should not have ydata" - # Test passes even if no pointer_down events — 3D may not emit them + assert len(events) == 0, "3D panels should not emit pointer_down events" def test_3d_wheel_fires(self, interact_page): """Plot3D emits a wheel event on scroll.""" diff --git a/anyplotlib/tests/test_interactive/test_event_settled.py b/anyplotlib/tests/test_interactive/test_event_settled.py index 174e5d49..7bad0626 100644 --- a/anyplotlib/tests/test_interactive/test_event_settled.py +++ b/anyplotlib/tests/test_interactive/test_event_settled.py @@ -13,53 +13,21 @@ """ from __future__ import annotations -import json import time import numpy as np import pytest import anyplotlib as apl +from anyplotlib.tests.test_interactive._event_test_utils import ( + _collect_events, + _get_events, + _plot_center_page, +) -# ── coordinate constants ────────────────────────────────────────────────────── -PAD_L, PAD_R, PAD_T, PAD_B = 58, 12, 12, 42 -GRID_PAD = 8 FIG_W, FIG_H = 400, 300 -def _plot_center_page() -> tuple[int, int]: - cx = PAD_L + (FIG_W - PAD_L - PAD_R) // 2 - cy = PAD_T + (FIG_H - PAD_T - PAD_B) // 2 - return cx + GRID_PAD, cy + GRID_PAD - - -def _collect_events(page) -> None: - page.evaluate("""() => { - window._aplAllEvents = []; - const orig = window._aplModel.set.bind(window._aplModel); - window._aplModel.set = (k, v) => { - if (k === 'event_json') { - try { window._aplAllEvents.push(JSON.parse(v)); } catch(_) {} - } - return orig(k, v); - }; - }""") - - -def _get_events(page, event_type: str | None = None) -> list: - events = page.evaluate("() => window._aplAllEvents") - if event_type: - return [e for e in events if e.get("event_type") == event_type] - return events - - -def _panel_state(page, plot) -> dict: - raw = page.evaluate( - f"() => window._aplModel.get('panel_{plot._id}_json')" - ) - return json.loads(raw) - - # ═══════════════════════════════════════════════════════════════════════════════ # Pure-Python: state field updates on connect / disconnect # ═══════════════════════════════════════════════════════════════════════════════ From e1bda14cfe1eaf104ebdcfd9f1b816724caea720 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 18:02:59 -0500 Subject: [PATCH 139/198] test: add regression tests confirming old event API removed; update Examples if needed All Example files using on_click/on_changed/on_release/on_key/on_hover are updated to use add_event_handler("pointer_down") etc. Regression tests added to test_callbacks.py asserting these old methods no longer exist on plots, widgets, or the Event dataclass. --- .../Interactive/plot_3d_spectral_viewer.py | 18 +++--- Examples/Interactive/plot_interactive_fft.py | 23 ++++--- .../Interactive/plot_interactive_fitting.py | 10 +-- Examples/Interactive/plot_key_bindings.py | 25 +++++--- Examples/Interactive/plot_point_widget.py | 12 ++-- .../Interactive/plot_segment_by_contrast.py | 20 +++--- Examples/PlotTypes/plot_image2d.py | 4 +- .../tests/test_interactive/test_callbacks.py | 62 +++++++++++++++++++ 8 files changed, 127 insertions(+), 47 deletions(-) diff --git a/Examples/Interactive/plot_3d_spectral_viewer.py b/Examples/Interactive/plot_3d_spectral_viewer.py index 82a17e09..f38ef0ee 100644 --- a/Examples/Interactive/plot_3d_spectral_viewer.py +++ b/Examples/Interactive/plot_3d_spectral_viewer.py @@ -121,8 +121,8 @@ def _snap_rect(x_raw, y_raw): def _wire_crosshair(w): - """Register on_changed: update spectrum on every drag frame.""" - @w.on_changed + """Register pointer_move handler: update spectrum on every drag frame.""" + @w.add_event_handler("pointer_move") def _ch_moved(event): cx = int(np.clip(round(event.data.get("cx", CX0)), 0, NX - 1)) cy = int(np.clip(round(event.data.get("cy", CY0)), 0, NY - 1)) @@ -130,8 +130,8 @@ def _ch_moved(event): def _wire_rectangle(w): - """Register on_changed: snap widget to grid, integrate 8×8 region live.""" - @w.on_changed + """Register pointer_move handler: snap widget to grid, integrate 8×8 region live.""" + @w.add_event_handler("pointer_move") def _rect_moved(event): if _syncing[0]: return @@ -160,8 +160,10 @@ def _rect_moved(event): # ── "i" — toggle crosshair ↔ 8×8 rectangle ───────────────────────────────── -@v_img.on_key('i') +@v_img.add_event_handler("key_down") def _toggle_roi(event): + if event.key != 'i': + return cur = wid[0] v_img.remove_widget(cur) # remove old widget (Python ref still valid) @@ -197,8 +199,10 @@ def _toggle_roi(event): # ── "s" (spectrum panel) — add / remove energy-span filter ────────────────── -@v_spec.on_key('s') +@v_spec.add_event_handler("key_down") def _toggle_span(event): + if event.key != 's': + return if span_wid[0] is None: # Place span at 35 %–65 % of the energy range by default e0 = float(energy[int(NE * 0.35)]) @@ -206,7 +210,7 @@ def _toggle_span(event): sw = v_spec.add_range_widget(x0=e0, x1=e1, color="#ff7043") span_wid[0] = sw - @sw.on_release + @sw.add_event_handler("pointer_up") def _span_released(ev): x0_e = ev.data.get("x0", float(energy[0])) x1_e = ev.data.get("x1", float(energy[-1])) diff --git a/Examples/Interactive/plot_interactive_fft.py b/Examples/Interactive/plot_interactive_fft.py index 5d9f464a..84f5d14a 100644 --- a/Examples/Interactive/plot_interactive_fft.py +++ b/Examples/Interactive/plot_interactive_fft.py @@ -10,14 +10,13 @@ * The left panel shows a synthetic real-space image (a periodic lattice with noise, similar to an atomic-resolution STEM image). * A yellow rectangle widget marks the region-of-interest (ROI). -* Whenever the ROI is moved or resized the :meth:`~anyplotlib.plot2d.Plot2D.on_release` - callback re-computes ``numpy.fft.fft2`` on the cropped pixels, applies a - Hann window to reduce edge ringing, takes the log-magnitude, and pushes the - result into the right panel with - :meth:`~anyplotlib.plot2d.Plot2D.update`. -* A second :meth:`~anyplotlib.plot2d.Plot2D.on_change` callback updates - a lightweight text readout (ROI size in pixels) on every drag frame without - re-running the FFT. +* Whenever the ROI is moved or resized the ``pointer_up`` event handler + re-computes ``numpy.fft.fft2`` on the cropped pixels, applies a Hann + window to reduce edge ringing, takes the log-magnitude, and pushes the + result into the right panel with :meth:`~anyplotlib.plot2d.Plot2D.update`. +* A second ``pointer_move`` event handler updates a lightweight text + readout (ROI size in pixels) on every drag frame without re-running + the FFT. **Interaction** @@ -26,8 +25,8 @@ * The FFT panel refreshes automatically on mouse-release. .. note:: - The ``on_release`` / ``on_change`` callbacks are pure Python — no kernel - restart is needed after editing them. + The ``pointer_up`` / ``pointer_move`` event handlers are pure Python — + no kernel restart is needed after editing them. """ import numpy as np @@ -147,7 +146,7 @@ def _compute_fft(img_full, x0, y0, w, h): # ── Callbacks ───────────────────────────────────────────────────────────────── -@wid.on_changed +@wid.add_event_handler("pointer_move") def _roi_dragging(event): """Fires on every drag frame — highlight rectangle while dragging.""" # Cheaply pulse the widget colour to give live drag feedback. @@ -158,7 +157,7 @@ def _roi_dragging(event): v_real._push() -@wid.on_release +@wid.add_event_handler("pointer_up") def _roi_released(event): """Fires once on mouse-up — recompute and push the full FFT.""" x0 = event.data.get("x", roi_x0) diff --git a/Examples/Interactive/plot_interactive_fitting.py b/Examples/Interactive/plot_interactive_fitting.py index 8acd7b69..cd087603 100644 --- a/Examples/Interactive/plot_interactive_fitting.py +++ b/Examples/Interactive/plot_interactive_fitting.py @@ -125,7 +125,7 @@ def toggle(self): self._active = True def _wire(self): - @self._pt.on_changed + @self._pt.add_event_handler("pointer_move") def _peak_moved(event): if self._syncing: return @@ -142,7 +142,7 @@ def _peak_moved(event): finally: self._syncing = False - @self._rng_w.on_changed + @self._rng_w.add_event_handler("pointer_move") def _range_moved(event): if self._syncing: return @@ -281,14 +281,16 @@ def _model_fn(x, *params): # ── Key binding — press 'f' to fit ───────────────────────────────────────── -@plot.on_key('f') +@plot.add_event_handler("key_down") def _on_fit(event): + if event.key != 'f': + return model.fit() # ── Click handlers — toggle widgets per component ───────────────────────── for comp, line in zip(components, comp_lines): - @line.on_click + @line.add_event_handler("pointer_down") def _clicked(event, c=comp): c.toggle() diff --git a/Examples/Interactive/plot_key_bindings.py b/Examples/Interactive/plot_key_bindings.py index 3ec8505c..0fd8c435 100644 --- a/Examples/Interactive/plot_key_bindings.py +++ b/Examples/Interactive/plot_key_bindings.py @@ -2,8 +2,8 @@ Key-Press Widget Placement ========================== -Demonstrates the ``on_key`` callback API: press a key while the plot is -focused to add an overlay widget centred on the current cursor position, +Demonstrates the ``key_down`` event handler API: press a key while the plot +is focused to add an overlay widget centred on the current cursor position, or press **Backspace / Delete** to remove the last widget you clicked. **Key bindings** @@ -61,9 +61,11 @@ # ── Key handlers ───────────────────────────────────────────────────────────── -@plot.on_key('q') +@plot.add_event_handler("key_down") def add_rectangle(event): """Press 'q' — add a rectangle centred on the cursor.""" + if event.key != 'q': + return cx, cy = event.img_x, event.img_y half_w, half_h = N * 0.08, N * 0.08 plot.add_widget( @@ -74,9 +76,11 @@ def add_rectangle(event): ) -@plot.on_key('w') +@plot.add_event_handler("key_down") def add_circle(event): """Press 'w' — add a circle centred on the cursor.""" + if event.key != 'w': + return plot.add_widget( "circle", cx=event.img_x, cy=event.img_y, @@ -85,9 +89,11 @@ def add_circle(event): ) -@plot.on_key('e') +@plot.add_event_handler("key_down") def add_annulus(event): """Press 'e' — add an annulus centred on the cursor.""" + if event.key != 'e': + return plot.add_widget( "annular", cx=event.img_x, cy=event.img_y, @@ -99,10 +105,11 @@ def add_annulus(event): # macOS sends 'Backspace' for the ⌫ key; Windows/Linux send 'Delete'. # Register both so the example works cross-platform. -@plot.on_key('Backspace') -@plot.on_key('Delete') +@plot.add_event_handler("key_down") def delete_last(event): """Press Backspace/Delete — remove the last widget that was clicked.""" + if event.key not in ('Backspace', 'Delete'): + return wid = event.last_widget_id if wid and wid in {w.id for w in plot.list_widgets()}: plot.remove_widget(wid) @@ -110,12 +117,12 @@ def delete_last(event): # ── Catch-all handler (optional) — log every registered key press ───────────── -@plot.on_key +@plot.add_event_handler("key_down") def log_key(event): img_x = getattr(event, 'img_x', None) img_y = getattr(event, 'img_y', None) pos = f"({img_x:.1f}, {img_y:.1f})" if img_x is not None else "n/a" - print(f"[on_key] key={event.key!r} img={pos}" + print(f"[key_down] key={event.key!r} img={pos}" f" last_widget={event.last_widget_id!r}") fig # Interactive diff --git a/Examples/Interactive/plot_point_widget.py b/Examples/Interactive/plot_point_widget.py index 90823230..70634758 100644 --- a/Examples/Interactive/plot_point_widget.py +++ b/Examples/Interactive/plot_point_widget.py @@ -11,10 +11,10 @@ * **Drag the point** anywhere inside the plot — the widget reports its data-space ``(x, y)`` position on every frame via the - :meth:`~anyplotlib.widgets.Widget.on_changed` callback. -* **Release** — the :meth:`~anyplotlib.widgets.Widget.on_release` callback - snaps the point's y-coordinate to the curve value at the dragged x - and draws the **tangent line** through that point. + ``pointer_move`` event handler. +* **Release** — the ``pointer_up`` event handler snaps the point's + y-coordinate to the curve value at the dragged x and draws the + **tangent line** through that point. **What is computed on release** @@ -92,13 +92,13 @@ def _draw_tangent(xq: float) -> None: # ── Callbacks ────────────────────────────────────────────────────────────── -@pt.on_changed +@pt.add_event_handler("pointer_move") def _live(event): """Every drag frame — print the current widget position.""" print(f" dragging x={event.x:.4f} y={event.y:.4f}", end="\r") -@pt.on_release +@pt.add_event_handler("pointer_up") def _settled(event): """On mouse-up — snap y to the curve and refresh the tangent line.""" print(f" released x={event.x:.4f} ") diff --git a/Examples/Interactive/plot_segment_by_contrast.py b/Examples/Interactive/plot_segment_by_contrast.py index e195dccc..4fb46234 100644 --- a/Examples/Interactive/plot_segment_by_contrast.py +++ b/Examples/Interactive/plot_segment_by_contrast.py @@ -160,7 +160,7 @@ def _refresh(): # ── Click handler ───────────────────────────────────────────────────────────── -@plot.on_click +@plot.add_event_handler("pointer_down") def _on_click(event): """Left-click → positive seed; Shift+Left-click → negative seed.""" # img_x = column, img_y = row (image-pixel coordinates) @@ -180,38 +180,44 @@ def _on_click(event): # ── Key bindings ────────────────────────────────────────────────────────────── -@plot.on_key('+') -@plot.on_key('=') # '+' on most keyboards requires Shift; '=' is the unshifted key +@plot.add_event_handler("key_down") def _tol_up(event): """Increase tolerance → flood-fill grows to wider intensity range.""" + if event.key not in ('+', '='): # '+' on most keyboards requires Shift; '=' is the unshifted key + return global tolerance tolerance = min(TOL_MAX, round(tolerance + TOL_STEP, 4)) _refresh() print(f" tolerance = {tolerance:.3f}", end="\r") -@plot.on_key('-') +@plot.add_event_handler("key_down") def _tol_down(event): """Decrease tolerance → flood-fill shrinks to narrower range.""" + if event.key != '-': + return global tolerance tolerance = max(TOL_MIN, round(tolerance - TOL_STEP, 4)) _refresh() print(f" tolerance = {tolerance:.3f}", end="\r") -@plot.on_key('c') +@plot.add_event_handler("key_down") def _clear(event): """Clear all seeds and reset the mask.""" + if event.key != 'c': + return pos_seeds.clear() neg_seeds.clear() _refresh() print(" seeds cleared", end="\r") -@plot.on_key('Delete') -@plot.on_key('Backspace') +@plot.add_event_handler("key_down") def _delete_nearest(event): """Remove the seed (positive or negative) nearest to the cursor.""" + if event.key not in ('Delete', 'Backspace'): + return cx = float(event.img_x) cy = float(event.img_y) # img_y = row diff --git a/Examples/PlotTypes/plot_image2d.py b/Examples/PlotTypes/plot_image2d.py index bdf9703a..b5cf5dc8 100644 --- a/Examples/PlotTypes/plot_image2d.py +++ b/Examples/PlotTypes/plot_image2d.py @@ -88,13 +88,13 @@ def _ring(r, r0, width, amp): whi = h.add_vline_widget(vmax_init, color="#ffffff") # high-threshold handle -@wlo.on_release +@wlo.add_event_handler("pointer_up") def _apply_low(event): """Update image display_min when the low handle is released.""" v.set_clim(vmin=event.x) -@whi.on_release +@whi.add_event_handler("pointer_up") def _apply_high(event): """Update image display_max when the high handle is released.""" v.set_clim(vmax=event.x) diff --git a/anyplotlib/tests/test_interactive/test_callbacks.py b/anyplotlib/tests/test_interactive/test_callbacks.py index 5dd4a4f4..e94303ac 100644 --- a/anyplotlib/tests/test_interactive/test_callbacks.py +++ b/anyplotlib/tests/test_interactive/test_callbacks.py @@ -2,6 +2,8 @@ from __future__ import annotations import time import pytest +import numpy as np +import anyplotlib as apl from anyplotlib.callbacks import Event, CallbackRegistry, VALID_EVENT_TYPES, _EventMixin @@ -410,3 +412,63 @@ def test_hold_events_delegates_to_registry(self): plot.callbacks.fire(Event("pointer_settled")) assert calls == [] assert calls == [1] + + +# ── regression: old API is gone ────────────────────────────────────────────── + + +class TestRegressionOldAPIGone: + """Confirm old decorator methods no longer exist on plots and widgets.""" + + def test_plot1d_no_on_click(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + assert not hasattr(plot, "on_click") + + def test_plot1d_no_on_changed(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + assert not hasattr(plot, "on_changed") + + def test_plot1d_no_on_release(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + assert not hasattr(plot, "on_release") + + def test_plot1d_no_on_key(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + assert not hasattr(plot, "on_key") + + def test_plot1d_no_disconnect(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + assert not hasattr(plot, "disconnect") + + def test_plot2d_no_on_click(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((32, 32))) + assert not hasattr(plot, "on_click") + + def test_widget_no_on_changed(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + w = plot.add_vline_widget(5.0) + assert not hasattr(w, "on_changed") + + def test_widget_no_on_release(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + w = plot.add_vline_widget(5.0) + assert not hasattr(w, "on_release") + + def test_event_no_phys_x(self): + from anyplotlib.callbacks import Event + e = Event(event_type="pointer_down", xdata=3.14) + assert not hasattr(e, "phys_x") + assert e.xdata == 3.14 + + def test_event_no_data_dict(self): + from anyplotlib.callbacks import Event + e = Event(event_type="pointer_move") + assert not hasattr(e, "data") From 9035601f6de367c090239069e7789722a61915b5 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 18:22:24 -0500 Subject: [PATCH 140/198] refactor: replace event.data dict access with widget attribute access in Examples --- Examples/Interactive/plot_3d_spectral_viewer.py | 12 ++++++------ Examples/Interactive/plot_interactive_fft.py | 8 ++++---- Examples/Interactive/plot_interactive_fitting.py | 6 +++--- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Examples/Interactive/plot_3d_spectral_viewer.py b/Examples/Interactive/plot_3d_spectral_viewer.py index f38ef0ee..484478c9 100644 --- a/Examples/Interactive/plot_3d_spectral_viewer.py +++ b/Examples/Interactive/plot_3d_spectral_viewer.py @@ -124,8 +124,8 @@ def _wire_crosshair(w): """Register pointer_move handler: update spectrum on every drag frame.""" @w.add_event_handler("pointer_move") def _ch_moved(event): - cx = int(np.clip(round(event.data.get("cx", CX0)), 0, NX - 1)) - cy = int(np.clip(round(event.data.get("cy", CY0)), 0, NY - 1)) + cx = int(np.clip(round(event.source.cx), 0, NX - 1)) + cy = int(np.clip(round(event.source.cy), 0, NY - 1)) v_spec.set_data(data[cy, cx, :].astype(float), x_axis=energy) @@ -138,8 +138,8 @@ def _rect_moved(event): _syncing[0] = True try: x0, y0 = _snap_rect( - event.data.get("x", CX0 - ROI_PX // 2), - event.data.get("y", CY0 - ROI_PX // 2), + event.source.x, + event.source.y, ) # Push snapped, fixed-size position back so the widget visually # snaps to the pixel grid and stays exactly 8×8. @@ -212,8 +212,8 @@ def _toggle_span(event): @sw.add_event_handler("pointer_up") def _span_released(ev): - x0_e = ev.data.get("x0", float(energy[0])) - x1_e = ev.data.get("x1", float(energy[-1])) + x0_e = ev.source.x0 + x1_e = ev.source.x1 if x0_e > x1_e: x0_e, x1_e = x1_e, x0_e mask = (energy >= x0_e) & (energy <= x1_e) diff --git a/Examples/Interactive/plot_interactive_fft.py b/Examples/Interactive/plot_interactive_fft.py index 84f5d14a..fd556a63 100644 --- a/Examples/Interactive/plot_interactive_fft.py +++ b/Examples/Interactive/plot_interactive_fft.py @@ -160,10 +160,10 @@ def _roi_dragging(event): @wid.add_event_handler("pointer_up") def _roi_released(event): """Fires once on mouse-up — recompute and push the full FFT.""" - x0 = event.data.get("x", roi_x0) - y0 = event.data.get("y", roi_y0) - w = event.data.get("w", ROI_W) - h = event.data.get("h", ROI_H) + x0 = event.source.x + y0 = event.source.y + w = event.source.w + h = event.source.h # Restore widget colour to yellow for widget in v_real._state["overlay_widgets"]: diff --git a/Examples/Interactive/plot_interactive_fitting.py b/Examples/Interactive/plot_interactive_fitting.py index cd087603..02916a5d 100644 --- a/Examples/Interactive/plot_interactive_fitting.py +++ b/Examples/Interactive/plot_interactive_fitting.py @@ -131,8 +131,8 @@ def _peak_moved(event): return self._syncing = True try: - self.amp = event.data["y"] - self.mu = event.data["x"] + self.amp = event.source.y + self.mu = event.source.x self._rng_w.set(x0=self.mu - self.sigma * _FWHM_K, x1=self.mu + self.sigma * _FWHM_K, y=self.amp / 2.0) @@ -148,7 +148,7 @@ def _range_moved(event): return self._syncing = True try: - x0, x1 = event.data["x0"], event.data["x1"] + x0, x1 = event.source.x0, event.source.x1 self.mu = (x0 + x1) / 2.0 self.sigma = abs(x1 - x0) / (2.0 * _FWHM_K) self._pt.set(x=self.mu) From f4d5345724c69b0590654a5ef85f99a168199f0a Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 19:01:43 -0500 Subject: [PATCH 141/198] fix: use event.source.x in widget handlers; remove duplicate regression test; add Plot3D/Bar coverage --- Examples/Interactive/plot_point_widget.py | 6 +++--- Examples/PlotTypes/plot_image2d.py | 4 ++-- .../tests/test_interactive/test_callbacks.py | 16 ++++++++++++---- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/Examples/Interactive/plot_point_widget.py b/Examples/Interactive/plot_point_widget.py index 70634758..6a2fbe56 100644 --- a/Examples/Interactive/plot_point_widget.py +++ b/Examples/Interactive/plot_point_widget.py @@ -95,14 +95,14 @@ def _draw_tangent(xq: float) -> None: @pt.add_event_handler("pointer_move") def _live(event): """Every drag frame — print the current widget position.""" - print(f" dragging x={event.x:.4f} y={event.y:.4f}", end="\r") + print(f" dragging x={event.source.x:.4f} y={event.source.y:.4f}", end="\r") @pt.add_event_handler("pointer_up") def _settled(event): """On mouse-up — snap y to the curve and refresh the tangent line.""" - print(f" released x={event.x:.4f} ") - _draw_tangent(event.x) + print(f" released x={event.source.x:.4f} ") + _draw_tangent(event.source.x) fig # Interactive diff --git a/Examples/PlotTypes/plot_image2d.py b/Examples/PlotTypes/plot_image2d.py index b5cf5dc8..c568aff6 100644 --- a/Examples/PlotTypes/plot_image2d.py +++ b/Examples/PlotTypes/plot_image2d.py @@ -91,13 +91,13 @@ def _ring(r, r0, width, amp): @wlo.add_event_handler("pointer_up") def _apply_low(event): """Update image display_min when the low handle is released.""" - v.set_clim(vmin=event.x) + v.set_clim(vmin=event.source.x) @whi.add_event_handler("pointer_up") def _apply_high(event): """Update image display_max when the high handle is released.""" - v.set_clim(vmax=event.x) + v.set_clim(vmax=event.source.x) fig # Interactive diff --git a/anyplotlib/tests/test_interactive/test_callbacks.py b/anyplotlib/tests/test_interactive/test_callbacks.py index e94303ac..00404d7e 100644 --- a/anyplotlib/tests/test_interactive/test_callbacks.py +++ b/anyplotlib/tests/test_interactive/test_callbacks.py @@ -468,7 +468,15 @@ def test_event_no_phys_x(self): assert not hasattr(e, "phys_x") assert e.xdata == 3.14 - def test_event_no_data_dict(self): - from anyplotlib.callbacks import Event - e = Event(event_type="pointer_move") - assert not hasattr(e, "data") + def test_plot3d_no_on_click(self): + import numpy as np + x = np.linspace(-2, 2, 10) + XX, YY = np.meshgrid(x, x) + fig, ax = apl.subplots(1, 1) + plot = ax.plot_surface(XX, YY, np.zeros_like(XX)) + assert not hasattr(plot, "on_click") + + def test_plotbar_no_on_click(self): + fig, ax = apl.subplots(1, 1) + plot = ax.bar(["A", "B"], [1.0, 2.0]) + assert not hasattr(plot, "on_click") From 823581f1e5a02894ffe9ec375ae06bad45df96aa Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 19:35:32 -0500 Subject: [PATCH 142/198] fix: delete dead Line1D.on_hover shim; add regression tests for Line1D --- anyplotlib/plot1d/_plot1d.py | 14 +------------- .../tests/test_interactive/test_callbacks.py | 12 ++++++++++++ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/anyplotlib/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py index 27a19817..259e4327 100644 --- a/anyplotlib/plot1d/_plot1d.py +++ b/anyplotlib/plot1d/_plot1d.py @@ -31,8 +31,7 @@ class Line1D: """Handle to a single line on a :class:`Plot1D` panel. Returned by :meth:`Plot1D.add_line`. Use it to update the line data, - register hover/click callbacks scoped to just that line, or to remove - it later. + register event handlers scoped to just that line, or to remove it later. Attributes ---------- @@ -66,17 +65,6 @@ def __hash__(self) -> int: return hash(self._lid) # ------------------------------------------------------------------ - def on_hover(self, fn: Callable) -> Callable: - """Decorator: fires when the cursor moves over *this* line only.""" - target_lid = self._lid - def _filtered(event): - if event.data.get("line_id") == target_lid: - fn(event) - cid = self._plot.callbacks.connect("on_line_hover", _filtered) - _filtered._cid = cid - fn._cid = cid - return fn - def add_event_handler(self, fn_or_type, *args, **kwargs): """Register a handler scoped to this line only. diff --git a/anyplotlib/tests/test_interactive/test_callbacks.py b/anyplotlib/tests/test_interactive/test_callbacks.py index 00404d7e..7aff6784 100644 --- a/anyplotlib/tests/test_interactive/test_callbacks.py +++ b/anyplotlib/tests/test_interactive/test_callbacks.py @@ -480,3 +480,15 @@ def test_plotbar_no_on_click(self): fig, ax = apl.subplots(1, 1) plot = ax.bar(["A", "B"], [1.0, 2.0]) assert not hasattr(plot, "on_click") + + def test_line1d_no_on_hover(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + line = plot.add_line(np.zeros(10)) + assert not hasattr(line, "on_hover") + + def test_line1d_no_on_click(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + line = plot.add_line(np.zeros(10)) + assert not hasattr(line, "on_click") From 12676c48d98f97bb8367307ece45faf51da4d865 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 19:36:35 -0500 Subject: [PATCH 143/198] fix: replace event.img_x/img_y with event.xdata/ydata in Examples --- Examples/Interactive/plot_key_bindings.py | 20 +++++++++---------- .../Interactive/plot_segment_by_contrast.py | 12 ++++++----- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/Examples/Interactive/plot_key_bindings.py b/Examples/Interactive/plot_key_bindings.py index 0fd8c435..88b75c81 100644 --- a/Examples/Interactive/plot_key_bindings.py +++ b/Examples/Interactive/plot_key_bindings.py @@ -35,9 +35,9 @@ | ``s`` | Toggle symlog scale | +-------+---------------------------+ -The cursor coordinates reported in the event (``event.img_x``, -``event.img_y``) are in image-pixel space, so widgets are centred exactly -where the cursor was when the key was pressed. +The cursor coordinates are available as ``event.xdata`` and ``event.ydata`` +in image-pixel space (column, row), so widgets are centred exactly where +the cursor was when the key was pressed. .. note:: Move the mouse over the image first so the plot panel receives focus, @@ -66,7 +66,7 @@ def add_rectangle(event): """Press 'q' — add a rectangle centred on the cursor.""" if event.key != 'q': return - cx, cy = event.img_x, event.img_y + cx, cy = event.xdata, event.ydata half_w, half_h = N * 0.08, N * 0.08 plot.add_widget( "rectangle", @@ -83,7 +83,7 @@ def add_circle(event): return plot.add_widget( "circle", - cx=event.img_x, cy=event.img_y, + cx=event.xdata, cy=event.ydata, r=N * 0.07, color="#80cbc4", ) @@ -96,7 +96,7 @@ def add_annulus(event): return plot.add_widget( "annular", - cx=event.img_x, cy=event.img_y, + cx=event.xdata, cy=event.ydata, r_outer=N * 0.12, r_inner=N * 0.06, color="#ce93d8", @@ -119,10 +119,10 @@ def delete_last(event): @plot.add_event_handler("key_down") def log_key(event): - img_x = getattr(event, 'img_x', None) - img_y = getattr(event, 'img_y', None) - pos = f"({img_x:.1f}, {img_y:.1f})" if img_x is not None else "n/a" + xdata = event.xdata + ydata = event.ydata + pos = f"({xdata:.1f}, {ydata:.1f})" if xdata is not None else "n/a" print(f"[key_down] key={event.key!r} img={pos}" - f" last_widget={event.last_widget_id!r}") + f" last_widget={getattr(event, 'last_widget_id', None)!r}") fig # Interactive diff --git a/Examples/Interactive/plot_segment_by_contrast.py b/Examples/Interactive/plot_segment_by_contrast.py index 4fb46234..fb564000 100644 --- a/Examples/Interactive/plot_segment_by_contrast.py +++ b/Examples/Interactive/plot_segment_by_contrast.py @@ -29,6 +29,8 @@ +-----------------------------------+-----------------------------------------+ The current boolean mask numpy array is always accessible as ``mask``. +The cursor position is exposed as ``event.xdata`` (column) and +``event.ydata`` (row) in image-pixel coordinates. .. note:: Move the cursor over the plot so it receives keyboard focus before @@ -163,9 +165,9 @@ def _refresh(): @plot.add_event_handler("pointer_down") def _on_click(event): """Left-click → positive seed; Shift+Left-click → negative seed.""" - # img_x = column, img_y = row (image-pixel coordinates) - col = int(round(float(event.img_x))) - row = int(round(float(event.img_y))) + # xdata = column, ydata = row (image-pixel coordinates) + col = int(round(float(event.xdata))) + row = int(round(float(event.ydata))) # Clamp to image bounds col = max(0, min(N - 1, col)) row = max(0, min(N - 1, row)) @@ -218,8 +220,8 @@ def _delete_nearest(event): """Remove the seed (positive or negative) nearest to the cursor.""" if event.key not in ('Delete', 'Backspace'): return - cx = float(event.img_x) - cy = float(event.img_y) # img_y = row + cx = float(event.xdata) + cy = float(event.ydata) # ydata = row best_dist = float("inf") best_list = None From bdef48559afb58dd51ff95bfa0b86e08a1d98195 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 19:39:09 -0500 Subject: [PATCH 144/198] fix: _pointerFields always null button; pointer_down/up explicitly set button: e.button --- anyplotlib/figure_esm.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 246d97af..3edd7969 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -1708,7 +1708,7 @@ function render({ model, el }) { return { time_stamp: performance.now() / 1000, modifiers: _modifiers(e), - button: e.buttons !== 0 ? e.button : null, + button: null, buttons: e.buttons ?? 0, }; } @@ -1758,7 +1758,7 @@ function render({ model, el }) { model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); _emitEvent(p.id, 'pointer_up', null, { azimuth: p.state.azimuth, elevation: p.state.elevation, zoom: p.state.zoom, - ..._pointerFields(e) }); + ..._pointerFields(e), button: e.button }); _scheduleCommit(); }); @@ -2574,7 +2574,7 @@ function render({ model, el }) { const _did=_dw.id||null; p.ovDrag2d=null; overlayCanvas.style.cursor='default'; model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); - _emitEvent(p.id,'pointer_up',_did,{..._dw,..._pointerFields(e)}); + _emitEvent(p.id,'pointer_up',_did,{..._dw,..._pointerFields(e),button:e.button}); return; } if(!p.isPanning) return; @@ -2604,6 +2604,7 @@ function render({ model, el }) { xdata:physX, ydata:physY, x:_cc.mx, y:_cc.my, ..._pointerFields(e), + button:e.button, }); // _emitEvent already calls model.save_changes() — no duplicate needed. return; @@ -2613,7 +2614,7 @@ function render({ model, el }) { st.center_x=Math.max(0,Math.min(1,panStart.cx-(cmx-panStart.mx)/fr.w/st.zoom)); st.center_y=Math.max(0,Math.min(1,panStart.cy-(cmy-panStart.my)/fr.h/st.zoom)); model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); - _emitEvent(p.id,'pointer_up',null,{center_x:st.center_x,center_y:st.center_y,zoom:st.zoom,..._pointerFields(e)}); + _emitEvent(p.id,'pointer_up',null,{center_x:st.center_x,center_y:st.center_y,zoom:st.zoom,..._pointerFields(e),button:e.button}); model.save_changes(); }); @@ -2837,12 +2838,12 @@ function render({ model, el }) { const _did=_dw.id||null; p.ovDrag=null; overlayCanvas.style.cursor='crosshair'; model.set(`panel_${p.id}_json`,JSON.stringify(p.state)); - _emitEvent(p.id,'pointer_up',_did,{..._dw,..._pointerFields(e)}); + _emitEvent(p.id,'pointer_up',_did,{..._dw,..._pointerFields(e),button:e.button}); } if(p.isPanning){ p.isPanning=false; overlayCanvas.style.cursor='crosshair'; const st=p.state; - if(st) _emitEvent(p.id,'pointer_up',null,{view_x0:st.view_x0,view_x1:st.view_x1,..._pointerFields(e)}); + if(st) _emitEvent(p.id,'pointer_up',null,{view_x0:st.view_x0,view_x1:st.view_x1,..._pointerFields(e),button:e.button}); } // Line click: fire when no widget was being dragged and mouse barely moved. // NOTE: p.isPanning is always set true on mousedown (pan start), so we @@ -2853,7 +2854,7 @@ function render({ model, el }) { if(Math.hypot(mdx,mdy)<5){ const {mx,my}=_clientPos(e,overlayCanvas,p.pw,p.ph); const lhit=_lineHitTest1d(mx,my,p); - if(lhit) _emitEvent(p.id,'pointer_down',null,{line_id:lhit.lineId,x:lhit.x,y:lhit.y,..._pointerFields(e)}); + if(lhit) _emitEvent(p.id,'pointer_down',null,{line_id:lhit.lineId,x:lhit.x,y:lhit.y,..._pointerFields(e),button:e.button}); } } p._mousedownX=null; @@ -3941,7 +3942,7 @@ function render({ model, el }) { p.ovDrag = null; overlayCanvas.style.cursor = 'default'; model.set(`panel_${p.id}_json`, JSON.stringify(p.state)); - _emitEvent(p.id, 'pointer_up', _did, {..._dw, ..._pointerFields(e)}); + _emitEvent(p.id, 'pointer_up', _did, {..._dw, ..._pointerFields(e), button: e.button}); _scheduleCommit(); }); From 9890886334e208cfece2a2e2a4bd48d700ec5bc6 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 19:40:19 -0500 Subject: [PATCH 145/198] fix: PlotBar pointer_down on mousedown (was click); emit bar_index: null on miss --- anyplotlib/figure_esm.js | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 3edd7969..35f1bd05 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -4018,12 +4018,22 @@ function render({ model, el }) { tooltip.style.display = 'none'; }); - overlayCanvas.addEventListener('click', (e) => { + overlayCanvas.addEventListener('mousedown', (e) => { if (p.ovDrag) return; const st = p.state; if (!st) return; const {mx:_cmx, my:_cmy} = _clientPos(e, overlayCanvas, p.pw, p.ph); const hit = _barHit(_cmx, _cmy); - if (hit === null) return; + const _baseFields = {..._pointerFields(e), button: e.button, x: _cmx, y: _cmy}; + if (hit === null) { + _emitEvent(p.id, 'pointer_down', null, { + bar_index: null, + group_index: null, + value: null, + x_label: null, + ..._baseFields, + }); + return; + } const { slot: idx, group: gi } = hit; const gm = _barGeom(st, _plotRect1d(p.pw, p.ph)); const val = gm.getVal(idx, gi); @@ -4035,8 +4045,7 @@ function render({ model, el }) { x_center: (st.x_centers||[])[idx] ?? idx, x_label: (st.x_labels||[])[idx] !== undefined ? String(st.x_labels[idx]) : null, - ..._pointerFields(e), - x: _cmx, y: _cmy, + ..._baseFields, }); }); From fe345301da6b6bef4d91dac5c7d9e21b1d347923 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 20:45:39 -0500 Subject: [PATCH 146/198] refactor. Removed plans --- .../plans/2026-05-14-event-system.md | 2352 ----------------- .../specs/2026-05-14-event-system-design.md | 381 --- 2 files changed, 2733 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-14-event-system.md delete mode 100644 docs/superpowers/specs/2026-05-14-event-system-design.md diff --git a/docs/superpowers/plans/2026-05-14-event-system.md b/docs/superpowers/plans/2026-05-14-event-system.md deleted file mode 100644 index d8b1774d..00000000 --- a/docs/superpowers/plans/2026-05-14-event-system.md +++ /dev/null @@ -1,2352 +0,0 @@ -# Event System Redesign Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the existing `on_click`/`on_changed`/`on_release`/`on_key` event system with pygfx-aligned `pointer_*`/`key_*` events, a flat `Event` dataclass, multi-type/wildcard/priority registration, `pause_events`/`hold_events` context managers, and `pointer_settled` with per-panel JS timer. - -**Architecture:** Python-first — rewrite `CallbackRegistry` and `Event` in `callbacks.py`, add `_EventMixin` for the user-facing API, then update all plot/widget classes to inherit it. JS changes forward new event types and add the `pointer_settled` dwell timer. All old decorator methods (`on_click`, `on_changed`, etc.) are removed. - -**Tech Stack:** Python 3.10+, dataclasses, contextlib, anywidget traitlets, Playwright for browser tests, pytest. - -**Spec:** `docs/superpowers/specs/2026-05-14-event-system-design.md` - ---- - -## File Map - -**Modified:** -- `anyplotlib/callbacks.py` — rewrite `Event`, `CallbackRegistry`; add `_EventMixin` -- `anyplotlib/figure/_figure.py` — update `_dispatch_event` field mapping; add `import time` -- `anyplotlib/plot1d/_plot1d.py` — inherit `_EventMixin`, remove old decorators, update `Line1D` -- `anyplotlib/plot2d/_plot2d.py` — same pattern -- `anyplotlib/plot2d/_plotmesh.py` — same pattern (inherits Plot2D, may need minimal changes) -- `anyplotlib/plot3d/_plot3d.py` — same pattern + `ray` field in state -- `anyplotlib/plot1d/_plotbar.py` — same pattern + updated pointer_down payload -- `anyplotlib/widgets/_base.py` — inherit `_EventMixin`, remove old decorators, update `_update_from_js` -- `anyplotlib/figure_esm.js` — forward new event types, add fields, pointer_settled timer, remove registered_keys - -**Replaced:** -- `anyplotlib/tests/test_interactive/test_callbacks.py` — full rewrite for new API - -**Created:** -- `anyplotlib/tests/test_interactive/test_event_plots.py` — Playwright per-plot-type matrix -- `anyplotlib/tests/test_interactive/test_event_settled.py` — pointer_settled Playwright tests -- `anyplotlib/tests/test_interactive/test_event_pause_hold.py` — pause/hold Playwright tests - ---- - -## Task 1: Rewrite `Event` dataclass - -Flatten `Event` — all payload fields become top-level typed attributes instead of a `data` dict with `__getattr__` proxy. - -**Files:** -- Modify: `anyplotlib/callbacks.py` -- Modify: `anyplotlib/tests/test_interactive/test_callbacks.py` - -- [ ] **Step 1: Write the failing tests** - -Replace the top of `anyplotlib/tests/test_interactive/test_callbacks.py` with: - -```python -"""Tests for the redesigned Event dataclass and CallbackRegistry.""" -from __future__ import annotations -import time -import pytest -from anyplotlib.callbacks import Event, CallbackRegistry, VALID_EVENT_TYPES - - -# ── Event dataclass ─────────────────────────────────────────────────────────── - -class TestEvent: - def test_required_fields(self): - e = Event(event_type="pointer_down", source=None) - assert e.event_type == "pointer_down" - assert e.source is None - - def test_time_stamp_auto_set(self): - before = time.perf_counter() - e = Event(event_type="pointer_down") - after = time.perf_counter() - assert before <= e.time_stamp <= after - - def test_modifiers_default_empty_list(self): - e = Event(event_type="pointer_move") - assert e.modifiers == [] - assert isinstance(e.modifiers, list) - - def test_pointer_fields_default_none(self): - e = Event(event_type="pointer_move") - assert e.x is None - assert e.y is None - assert e.button is None - assert e.buttons == 0 - assert e.xdata is None - assert e.ydata is None - assert e.ray is None - assert e.line_id is None - assert e.dwell_ms is None - - def test_wheel_fields_default_none(self): - e = Event(event_type="wheel") - assert e.dx is None - assert e.dy is None - - def test_key_field_default_none(self): - e = Event(event_type="key_down") - assert e.key is None - - def test_bar_fields_default_none(self): - e = Event(event_type="pointer_down") - assert e.bar_index is None - assert e.value is None - assert e.x_label is None - assert e.group_index is None - - def test_stop_propagation_default_false(self): - e = Event(event_type="pointer_down") - assert e.stop_propagation is False - - def test_all_fields_settable(self): - e = Event( - event_type="pointer_down", - source="plot", - modifiers=["ctrl", "shift"], - x=100, y=200, - button=0, buttons=1, - xdata=3.14, ydata=2.71, - line_id="abc12345", - bar_index=2, value=99.5, x_label="Jan", group_index=1, - dx=10.0, dy=-5.0, - key="q", - ) - assert e.modifiers == ["ctrl", "shift"] - assert e.x == 100 - assert e.xdata == 3.14 - assert e.line_id == "abc12345" - assert e.bar_index == 2 - assert e.key == "q" - - def test_no_data_dict_attribute(self): - e = Event(event_type="pointer_move") - assert not hasattr(e, "data") - - def test_repr_includes_event_type(self): - e = Event(event_type="pointer_down", x=10, y=20) - assert "pointer_down" in repr(e) -``` - -- [ ] **Step 2: Run tests to confirm they fail** - -```bash -uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py::TestEvent -v -``` -Expected: FAIL — `Event` still has `data` field, `time_stamp` not auto-set, etc. - -- [ ] **Step 3: Rewrite `Event` in `callbacks.py`** - -Replace the entire `callbacks.py` with: - -```python -""" -callbacks.py -============ - -Event system used by all plot objects and widgets. - -:class:`Event` - Flat dataclass carrying all event fields as typed top-level attributes. - -:class:`CallbackRegistry` - Per-object handler store with multi-type, wildcard, priority, pause, and hold support. - -:class:`_EventMixin` - Mixin added to every plot class and widget exposing ``add_event_handler`` / - ``remove_handler`` / ``pause_events`` / ``hold_events``. -""" -from __future__ import annotations - -import time -from collections import defaultdict, deque -from contextlib import contextmanager -from dataclasses import dataclass, field -from typing import Any, Callable - -VALID_EVENT_TYPES = frozenset({ - "pointer_down", "pointer_up", "pointer_move", "pointer_settled", - "pointer_enter", "pointer_leave", "double_click", "wheel", - "key_down", "key_up", "*", -}) - - -@dataclass -class Event: - """A single interactive event with all payload fields as typed attributes. - - Universal fields (every event): - event_type, source, time_stamp, modifiers - - Pointer fields (pointer_* and double_click events): - x, y — pixel coordinates within the panel - button — 0=left 1=middle 2=right; None on move/enter/leave/settled - buttons — bitmask of currently held buttons - xdata, ydata — data-space coordinates (None for Plot3D) - ray — Plot3D only: {"origin": [...], "direction": [...]} - line_id — Plot1D only: set when pointer is over a line - dwell_ms — pointer_settled only: actual dwell time - - PlotBar extra fields (pointer_down only): - bar_index, value, x_label, group_index - - Wheel fields: - dx, dy — scroll deltas - - Key fields: - key — key name e.g. "q", "Enter", "ArrowLeft" - - Propagation: - stop_propagation — set True inside a handler to halt remaining handlers - """ - event_type: str - source: Any = None - time_stamp: float = field(default_factory=time.perf_counter) - modifiers: list[str] = field(default_factory=list) - # Pointer - x: int | None = None - y: int | None = None - button: int | None = None - buttons: int = 0 - xdata: float | None = None - ydata: float | None = None - ray: dict | None = None - line_id: str | None = None - dwell_ms: float | None = None - # PlotBar - bar_index: int | None = None - value: float | None = None - x_label: str | None = None - group_index: int | None = None - # Wheel - dx: float | None = None - dy: float | None = None - # Key - key: str | None = None - # Propagation (not repr'd) - stop_propagation: bool = field(default=False, repr=False) - - def __repr__(self) -> str: - src = type(self.source).__name__ if self.source is not None else "None" - parts = [f"event_type={self.event_type!r}", f"source={src}"] - for fname in ("x", "y", "xdata", "ydata", "button", "key", - "line_id", "bar_index", "dwell_ms"): - v = getattr(self, fname) - if v is not None: - parts.append(f"{fname}={v!r}") - if self.modifiers: - parts.append(f"modifiers={self.modifiers!r}") - return "Event(" + ", ".join(parts) + ")" -``` - -- [ ] **Step 4: Run tests to confirm they pass** - -```bash -uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py::TestEvent -v -``` -Expected: All 11 tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add anyplotlib/callbacks.py anyplotlib/tests/test_interactive/test_callbacks.py -git commit -m "refactor: flatten Event dataclass — all payload fields are typed top-level attrs" -``` - ---- - -## Task 2: Rewrite `CallbackRegistry` - -Replace the simple `_entries` dict with a per-type handler list supporting priority ordering, wildcard `"*"`, multi-type registration, and `stop_propagation`. - -**Files:** -- Modify: `anyplotlib/callbacks.py` (append to Task 1 file) -- Modify: `anyplotlib/tests/test_interactive/test_callbacks.py` - -- [ ] **Step 1: Write failing tests — append to test file** - -```python -class TestCallbackRegistry: - def test_connect_returns_int_cid(self): - reg = CallbackRegistry() - cid = reg.connect("pointer_down", lambda e: None) - assert isinstance(cid, int) - - def test_fire_calls_handler(self): - reg = CallbackRegistry() - calls = [] - reg.connect("pointer_down", lambda e: calls.append(e.event_type)) - reg.fire(Event("pointer_down")) - assert calls == ["pointer_down"] - - def test_fire_only_matching_type(self): - reg = CallbackRegistry() - calls = [] - reg.connect("pointer_down", lambda e: calls.append("down")) - reg.connect("pointer_up", lambda e: calls.append("up")) - reg.fire(Event("pointer_down")) - assert calls == ["down"] - - def test_disconnect_by_cid(self): - reg = CallbackRegistry() - calls = [] - cid = reg.connect("pointer_down", lambda e: calls.append(1)) - reg.disconnect(cid) - reg.fire(Event("pointer_down")) - assert calls == [] - - def test_disconnect_silent_if_not_found(self): - reg = CallbackRegistry() - reg.disconnect(999) # should not raise - - def test_wildcard_receives_all_types(self): - reg = CallbackRegistry() - calls = [] - reg.connect("*", lambda e: calls.append(e.event_type)) - reg.fire(Event("pointer_down")) - reg.fire(Event("key_down")) - reg.fire(Event("wheel")) - assert calls == ["pointer_down", "key_down", "wheel"] - - def test_priority_order(self): - reg = CallbackRegistry() - order = [] - reg.connect("pointer_down", lambda e: order.append("second"), order=1) - reg.connect("pointer_down", lambda e: order.append("first"), order=0) - reg.fire(Event("pointer_down")) - assert order == ["first", "second"] - - def test_same_priority_fires_in_registration_order(self): - reg = CallbackRegistry() - order = [] - reg.connect("pointer_down", lambda e: order.append("a"), order=0) - reg.connect("pointer_down", lambda e: order.append("b"), order=0) - reg.fire(Event("pointer_down")) - assert order == ["a", "b"] - - def test_stop_propagation(self): - reg = CallbackRegistry() - calls = [] - def handler_a(e): - calls.append("a") - e.stop_propagation = True - reg.connect("pointer_down", handler_a, order=0) - reg.connect("pointer_down", lambda e: calls.append("b"), order=1) - reg.fire(Event("pointer_down")) - assert calls == ["a"] - - def test_disconnect_fn_by_reference(self): - reg = CallbackRegistry() - calls = [] - fn = lambda e: calls.append(1) - reg.connect("pointer_down", fn) - reg.disconnect_fn(fn) - reg.fire(Event("pointer_down")) - assert calls == [] - - def test_disconnect_fn_specific_type(self): - reg = CallbackRegistry() - calls = [] - fn = lambda e: calls.append(e.event_type) - reg.connect("pointer_down", fn) - reg.connect("pointer_up", fn) - reg.disconnect_fn(fn, "pointer_down") - reg.fire(Event("pointer_down")) - reg.fire(Event("pointer_up")) - assert calls == ["pointer_up"] - - def test_bool_true_when_handlers_present(self): - reg = CallbackRegistry() - assert not bool(reg) - reg.connect("pointer_down", lambda e: None) - assert bool(reg) - - def test_invalid_event_type_raises(self): - reg = CallbackRegistry() - with pytest.raises(ValueError, match="Invalid event_type"): - reg.connect("on_click", lambda e: None) - - def test_connect_same_fn_multiple_types(self): - reg = CallbackRegistry() - calls = [] - fn = lambda e: calls.append(e.event_type) - reg.connect("pointer_down", fn) - reg.connect("pointer_up", fn) - reg.fire(Event("pointer_down")) - reg.fire(Event("pointer_up")) - assert calls == ["pointer_down", "pointer_up"] -``` - -- [ ] **Step 2: Run tests to confirm they fail** - -```bash -uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py::TestCallbackRegistry -v -``` -Expected: Most FAIL — old `CallbackRegistry` doesn't support priority, wildcard, `disconnect_fn`, or new event type names. - -- [ ] **Step 3: Append new `CallbackRegistry` to `callbacks.py`** - -Remove the old `CallbackRegistry` class and replace with: - -```python -class CallbackRegistry: - """Per-object handler store. - - Supports: - - Priority ordering (``order`` kwarg — lower fires first) - - Wildcard ``"*"`` type receives every dispatched event - - ``stop_propagation`` on the event halts remaining handlers - - ``disconnect_fn(fn, *types)`` removes by callback reference - - ``pause_events`` / ``hold_events`` context managers (added in Task 3) - """ - - def __init__(self) -> None: - # {event_type: [(order, cid, fn), ...]} — sorted by order - self._handlers: dict[str, list[tuple[float, int, Callable]]] = defaultdict(list) - self._next_cid: int = 1 - # {cid: set[str]} — which types this cid is registered under - self._cid_map: dict[int, set[str]] = {} - # {id(fn): set[int]} — which cids this fn owns - self._fn_map: dict[int, set[int]] = defaultdict(set) - # pause/hold (populated in Task 3) - self._pause_counts: dict[str, int] = {} - self._hold_counts: dict[str, int] = {} - self._held: deque[Event] = deque() - - # ── registration ───────────────────────────────────────────────────── - - def connect(self, event_type: str, fn: Callable, *, order: float = 0) -> int: - """Register fn for event_type. Returns integer CID.""" - if event_type not in VALID_EVENT_TYPES: - raise ValueError( - f"Invalid event_type {event_type!r}. " - f"Valid types: {sorted(t for t in VALID_EVENT_TYPES if t != '*')} or '*'" - ) - cid = self._next_cid - self._next_cid += 1 - self._handlers[event_type].append((order, cid, fn)) - self._handlers[event_type].sort(key=lambda t: t[0]) - self._cid_map.setdefault(cid, set()).add(event_type) - self._fn_map[id(fn)].add(cid) - return cid - - def disconnect(self, cid: int) -> None: - """Remove handler by CID. Silent if not found.""" - types = self._cid_map.pop(cid, set()) - for et in types: - self._handlers[et] = [ - (o, c, f) for o, c, f in self._handlers[et] if c != cid - ] - for fn_cids in self._fn_map.values(): - fn_cids.discard(cid) - - def disconnect_fn(self, fn: Callable, *types: str) -> None: - """Remove fn from the given types (all types if none given).""" - for cid in list(self._fn_map.get(id(fn), set())): - cid_types = self._cid_map.get(cid, set()) - if not types or cid_types & set(types): - self.disconnect(cid) - - # ── dispatch ───────────────────────────────────────────────────────── - - def fire(self, event: Event) -> None: - """Dispatch event to matching handlers (respects pause/hold).""" - et = event.event_type - if self._pause_counts.get(et, 0) > 0 or self._pause_counts.get("*", 0) > 0: - return - if self._hold_counts.get(et, 0) > 0 or self._hold_counts.get("*", 0) > 0: - self._held.append(event) - return - self._dispatch(event) - - def _dispatch(self, event: Event) -> None: - et = event.event_type - specific = list(self._handlers.get(et, [])) - wildcard = list(self._handlers.get("*", [])) - merged = sorted(specific + wildcard, key=lambda t: t[0]) - for _order, _cid, fn in merged: - if event.stop_propagation: - break - fn(event) - - def _flush(self) -> None: - while self._held: - self._dispatch(self._held.popleft()) - - def __bool__(self) -> bool: - return any(bool(v) for v in self._handlers.values()) -``` - -- [ ] **Step 4: Run tests** - -```bash -uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py::TestCallbackRegistry -v -``` -Expected: All 14 tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add anyplotlib/callbacks.py anyplotlib/tests/test_interactive/test_callbacks.py -git commit -m "refactor: rewrite CallbackRegistry with priority, wildcard, disconnect_fn, stop_propagation" -``` - ---- - -## Task 3: Add `pause_events` / `hold_events` to `CallbackRegistry` - -**Files:** -- Modify: `anyplotlib/callbacks.py` (append context managers) -- Modify: `anyplotlib/tests/test_interactive/test_callbacks.py` - -- [ ] **Step 1: Write failing tests — append to test file** - -```python -class TestPauseHold: - def test_pause_drops_events(self): - reg = CallbackRegistry() - calls = [] - reg.connect("pointer_move", lambda e: calls.append(1)) - with reg.pause_events("pointer_move"): - reg.fire(Event("pointer_move")) - assert calls == [] - - def test_pause_handlers_intact_after_exit(self): - reg = CallbackRegistry() - calls = [] - reg.connect("pointer_move", lambda e: calls.append(1)) - with reg.pause_events("pointer_move"): - reg.fire(Event("pointer_move")) - reg.fire(Event("pointer_move")) - assert calls == [1] - - def test_pause_all_types_when_no_args(self): - reg = CallbackRegistry() - calls = [] - reg.connect("pointer_down", lambda e: calls.append("down")) - reg.connect("key_down", lambda e: calls.append("key")) - with reg.pause_events(): - reg.fire(Event("pointer_down")) - reg.fire(Event("key_down")) - assert calls == [] - - def test_pause_only_specified_type(self): - reg = CallbackRegistry() - calls = [] - reg.connect("pointer_move", lambda e: calls.append("move")) - reg.connect("pointer_down", lambda e: calls.append("down")) - with reg.pause_events("pointer_move"): - reg.fire(Event("pointer_move")) - reg.fire(Event("pointer_down")) - assert calls == ["down"] - - def test_pause_nested_same_type(self): - reg = CallbackRegistry() - calls = [] - reg.connect("pointer_move", lambda e: calls.append(1)) - with reg.pause_events("pointer_move"): - with reg.pause_events("pointer_move"): - reg.fire(Event("pointer_move")) - reg.fire(Event("pointer_move")) # still paused — outer not exited - reg.fire(Event("pointer_move")) # now fires - assert calls == [1] - - def test_hold_buffers_and_flushes_on_exit(self): - reg = CallbackRegistry() - calls = [] - reg.connect("pointer_settled", lambda e: calls.append(1)) - with reg.hold_events("pointer_settled"): - reg.fire(Event("pointer_settled")) - reg.fire(Event("pointer_settled")) - assert calls == [] # buffered, not fired yet - assert calls == [1, 1] # flushed on exit - - def test_hold_fires_non_held_types_immediately(self): - reg = CallbackRegistry() - move_calls = [] - settled_calls = [] - reg.connect("pointer_move", lambda e: move_calls.append(1)) - reg.connect("pointer_settled", lambda e: settled_calls.append(1)) - with reg.hold_events("pointer_settled"): - reg.fire(Event("pointer_move")) # not held → immediate - reg.fire(Event("pointer_settled")) # held → buffered - assert move_calls == [1] - assert settled_calls == [1] # flushed on exit - - def test_hold_events_in_order(self): - reg = CallbackRegistry() - calls = [] - reg.connect("pointer_settled", lambda e: calls.append(e.x)) - with reg.hold_events(): - reg.fire(Event("pointer_settled", x=1)) - reg.fire(Event("pointer_settled", x=2)) - reg.fire(Event("pointer_settled", x=3)) - assert calls == [1, 2, 3] - - def test_pause_wins_over_hold(self): - reg = CallbackRegistry() - calls = [] - reg.connect("pointer_move", lambda e: calls.append(1)) - with reg.hold_events("pointer_move"): - with reg.pause_events("pointer_move"): - reg.fire(Event("pointer_move")) - assert calls == [] # dropped, not buffered then flushed -``` - -- [ ] **Step 2: Run tests to confirm they fail** - -```bash -uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py::TestPauseHold -v -``` -Expected: FAIL — `pause_events`/`hold_events` not yet implemented. - -- [ ] **Step 3: Append context managers to `CallbackRegistry` in `callbacks.py`** - -Add these methods inside the `CallbackRegistry` class (after `_flush`): - -```python - @contextmanager - def pause_events(self, *types: str): - """Suppress events of the given types while inside this context. - All types are paused when called with no arguments. - Pause wins over hold for the same type.""" - target = types if types else ("*",) - for t in target: - self._pause_counts[t] = self._pause_counts.get(t, 0) + 1 - try: - yield - finally: - for t in target: - self._pause_counts[t] -= 1 - if self._pause_counts[t] == 0: - del self._pause_counts[t] - - @contextmanager - def hold_events(self, *types: str): - """Buffer events of the given types; flush when the outermost hold exits. - All types are held when called with no arguments.""" - target = types if types else ("*",) - for t in target: - self._hold_counts[t] = self._hold_counts.get(t, 0) + 1 - try: - yield - finally: - for t in target: - self._hold_counts[t] -= 1 - if self._hold_counts[t] == 0: - del self._hold_counts[t] - if not self._hold_counts: - self._flush() -``` - -- [ ] **Step 4: Run tests** - -```bash -uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py::TestPauseHold -v -``` -Expected: All 9 tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add anyplotlib/callbacks.py anyplotlib/tests/test_interactive/test_callbacks.py -git commit -m "feat: add pause_events and hold_events context managers to CallbackRegistry" -``` - ---- - -## Task 4: Add `_EventMixin` to `callbacks.py` - -The mixin provides `add_event_handler`, `remove_handler`, `pause_events`, `hold_events` for every plot and widget. - -**Files:** -- Modify: `anyplotlib/callbacks.py` (append class) -- Modify: `anyplotlib/tests/test_interactive/test_callbacks.py` - -- [ ] **Step 1: Write failing tests — append to test file** - -```python -class _FakePlot(_EventMixin): - """Minimal plot stub for testing _EventMixin.""" - def __init__(self): - self.callbacks = CallbackRegistry() - self._settled_config = (0, 0) - - def _configure_pointer_settled(self, ms: int, delta: float) -> None: - self._settled_config = (ms, delta) - - -class TestEventMixin: - def test_functional_form_single_type(self): - plot = _FakePlot() - calls = [] - fn = lambda e: calls.append(e.event_type) - plot.add_event_handler(fn, "pointer_down") - plot.callbacks.fire(Event("pointer_down")) - assert calls == ["pointer_down"] - - def test_functional_form_multi_type(self): - plot = _FakePlot() - calls = [] - fn = lambda e: calls.append(e.event_type) - plot.add_event_handler(fn, "pointer_down", "pointer_up") - plot.callbacks.fire(Event("pointer_down")) - plot.callbacks.fire(Event("pointer_up")) - assert calls == ["pointer_down", "pointer_up"] - - def test_decorator_form_single_type(self): - plot = _FakePlot() - calls = [] - @plot.add_event_handler("pointer_move") - def handler(e): - calls.append(e.event_type) - plot.callbacks.fire(Event("pointer_move")) - assert calls == ["pointer_move"] - - def test_decorator_form_multi_type(self): - plot = _FakePlot() - calls = [] - @plot.add_event_handler("pointer_down", "key_down") - def handler(e): - calls.append(e.event_type) - plot.callbacks.fire(Event("pointer_down")) - plot.callbacks.fire(Event("key_down")) - assert calls == ["pointer_down", "key_down"] - - def test_wildcard_decorator(self): - plot = _FakePlot() - calls = [] - @plot.add_event_handler("*") - def handler(e): - calls.append(e.event_type) - plot.callbacks.fire(Event("pointer_down")) - plot.callbacks.fire(Event("wheel")) - assert calls == ["pointer_down", "wheel"] - - def test_remove_handler_by_fn(self): - plot = _FakePlot() - calls = [] - fn = lambda e: calls.append(1) - plot.add_event_handler(fn, "pointer_down") - plot.remove_handler(fn) - plot.callbacks.fire(Event("pointer_down")) - assert calls == [] - - def test_remove_handler_by_fn_specific_type(self): - plot = _FakePlot() - calls = [] - fn = lambda e: calls.append(e.event_type) - plot.add_event_handler(fn, "pointer_down", "pointer_up") - plot.remove_handler(fn, "pointer_down") - plot.callbacks.fire(Event("pointer_down")) - plot.callbacks.fire(Event("pointer_up")) - assert calls == ["pointer_up"] - - def test_remove_handler_by_cid(self): - plot = _FakePlot() - calls = [] - cid = plot.callbacks.connect("pointer_down", lambda e: calls.append(1)) - plot.remove_handler(cid) - plot.callbacks.fire(Event("pointer_down")) - assert calls == [] - - def test_pointer_settled_configures_on_connect(self): - plot = _FakePlot() - plot.add_event_handler(lambda e: None, "pointer_settled", ms=400, delta=5) - assert plot._settled_config == (400, 5) - - def test_pointer_settled_clears_on_last_disconnect(self): - plot = _FakePlot() - fn = lambda e: None - plot.add_event_handler(fn, "pointer_settled", ms=400, delta=5) - plot.remove_handler(fn) - assert plot._settled_config == (0, 0) - - def test_ms_delta_without_settled_raises(self): - plot = _FakePlot() - with pytest.raises(ValueError, match="ms/delta"): - plot.add_event_handler(lambda e: None, "pointer_down", ms=400) - - def test_pause_events_delegates_to_registry(self): - plot = _FakePlot() - calls = [] - plot.add_event_handler(lambda e: calls.append(1), "pointer_move") - with plot.pause_events("pointer_move"): - plot.callbacks.fire(Event("pointer_move")) - assert calls == [] - - def test_hold_events_delegates_to_registry(self): - plot = _FakePlot() - calls = [] - plot.add_event_handler(lambda e: calls.append(1), "pointer_settled") - with plot.hold_events("pointer_settled"): - plot.callbacks.fire(Event("pointer_settled")) - assert calls == [] - assert calls == [1] -``` - -- [ ] **Step 2: Run tests to confirm they fail** - -```bash -uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py::TestEventMixin -v -``` -Expected: FAIL — `_EventMixin` not yet defined. - -- [ ] **Step 3: Append `_EventMixin` to `callbacks.py`** - -```python -class _EventMixin: - """Mixin for plot classes and widgets. - - Provides ``add_event_handler`` / ``remove_handler`` / ``pause_events`` / - ``hold_events``. The host class must set ``self.callbacks = CallbackRegistry()`` - in its ``__init__``. - """ - - callbacks: CallbackRegistry - - def add_event_handler( - self, - fn_or_type, - *args, - order: float = 0, - ms: int = 300, - delta: float = 4, - ): - """Register an event handler. Works as a direct call or decorator. - - Direct call:: - - plot.add_event_handler(fn, "pointer_down") - plot.add_event_handler(fn, "pointer_down", "pointer_up") - - Decorator:: - - @plot.add_event_handler("pointer_down") - def handler(event): ... - - @plot.add_event_handler("pointer_settled", ms=400, delta=5) - def on_settle(event): ... - - Parameters - ---------- - fn_or_type : callable or str - Handler function (direct call) or first event type string (decorator). - *args : str - Remaining event type strings. - order : float - Priority. Lower fires first. Default 0. - ms : int - ``pointer_settled`` dwell threshold in milliseconds. Default 300. - Raises ``ValueError`` if provided without ``"pointer_settled"`` in types. - delta : float - ``pointer_settled`` pixel radius. Default 4. - Raises ``ValueError`` if provided without ``"pointer_settled"`` in types. - """ - if callable(fn_or_type): - return self._register(fn_or_type, args, order=order, ms=ms, delta=delta) - else: - all_types = (fn_or_type,) + args - def _decorator(fn: Callable) -> Callable: - self._register(fn, all_types, order=order, ms=ms, delta=delta) - return fn - return _decorator - - def _register( - self, fn: Callable, types: tuple, *, order: float, ms: int, delta: float - ) -> Callable: - has_settled = "pointer_settled" in types - _ms_changed = ms != 300 - _delta_changed = delta != 4 - if (_ms_changed or _delta_changed) and not has_settled: - raise ValueError( - "ms/delta kwargs are only valid when 'pointer_settled' is in the event types" - ) - for event_type in types: - self.callbacks.connect(event_type, fn, order=order) - if has_settled: - self._configure_pointer_settled(ms, delta) - fn._event_types = getattr(fn, "_event_types", set()) | set(types) - return fn - - def remove_handler(self, cid_or_fn, *types: str) -> None: - """Remove a registered handler. - - Parameters - ---------- - cid_or_fn : int or callable - CID returned by ``callbacks.connect()`` or the handler function. - *types : str - If given, only remove from these types. If omitted, remove from all. - """ - if isinstance(cid_or_fn, int): - self.callbacks.disconnect(cid_or_fn) - else: - self.callbacks.disconnect_fn(cid_or_fn, *types) - if not self.callbacks._handlers.get("pointer_settled"): - self._configure_pointer_settled(0, 0) - - def _configure_pointer_settled(self, ms: int, delta: float) -> None: - """Override in plot subclasses to push thresholds to JS.""" - pass - - @contextmanager - def pause_events(self, *types: str): - """Suppress events of the given types (all types if none given).""" - with self.callbacks.pause_events(*types): - yield - - @contextmanager - def hold_events(self, *types: str): - """Buffer events of the given types; flush when context exits.""" - with self.callbacks.hold_events(*types): - yield -``` - -Also add `_EventMixin` to the module's `__all__` export and update the top docstring. - -- [ ] **Step 4: Run tests** - -```bash -uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py -v -``` -Expected: All tests in all three test classes PASS. - -- [ ] **Step 5: Commit** - -```bash -git add anyplotlib/callbacks.py anyplotlib/tests/test_interactive/test_callbacks.py -git commit -m "feat: add _EventMixin with add_event_handler, remove_handler, pause/hold_events" -``` - ---- - -## Task 5: Update `_dispatch_event` in Figure and `Widget._update_from_js` - -Map renamed JS fields (`phys_x`→`xdata`, `mouse_x`→`x`) to the flat `Event` constructor. Update widget sync. - -**Files:** -- Modify: `anyplotlib/figure/_figure.py` -- Modify: `anyplotlib/widgets/_base.py` - -- [ ] **Step 1: Add `import time` to `figure/_figure.py`** - -Find the existing imports block (around line 1-10) and add: -```python -import time -``` - -- [ ] **Step 2: Replace `_dispatch_event` in `figure/_figure.py`** - -Find the `_dispatch_event` method (currently lines ~343-397) and replace the body entirely: - -```python -def _dispatch_event(self, raw: str) -> None: - if not raw or raw == "{}": - return - try: - msg = json.loads(raw) - except Exception: - return - if msg.get("source") == "python": - return - - panel_id = msg.get("panel_id", "") - event_type = msg.get("event_type", "pointer_move") - widget_id = msg.get("widget_id") - - # Inset state changes - if event_type == "inset_state_change": - inset_ax = self._insets_map.get(panel_id) - if inset_ax is not None: - new_state = msg.get("new_state", "normal") - if new_state in ("normal", "minimized", "maximized"): - inset_ax._inset_state = new_state - self._push_layout() - return - - plot = self._plots_map.get(panel_id) - if plot is None: - return - - source = None - if widget_id and hasattr(plot, "_widgets"): - widget = plot._widgets.get(widget_id) - if widget is not None: - widget._update_from_js(msg, event_type) - source = widget - - if hasattr(plot, "callbacks"): - event = Event( - event_type=event_type, - source=source, - time_stamp=msg.get("time_stamp", time.perf_counter()), - modifiers=msg.get("modifiers", []), - x=msg.get("x"), - y=msg.get("y"), - button=msg.get("button"), - buttons=msg.get("buttons", 0), - xdata=msg.get("xdata"), - ydata=msg.get("ydata"), - ray=msg.get("ray"), - line_id=msg.get("line_id"), - dwell_ms=msg.get("dwell_ms"), - bar_index=msg.get("bar_index"), - value=msg.get("value"), - x_label=msg.get("x_label"), - group_index=msg.get("group_index"), - dx=msg.get("dx"), - dy=msg.get("dy"), - key=msg.get("key"), - ) - plot.callbacks.fire(event) -``` - -Also update the import at the top of `_figure.py` — find the `from anyplotlib.callbacks import ...` line and make sure `Event` is imported: -```python -from anyplotlib.callbacks import CallbackRegistry, Event -``` - -- [ ] **Step 3: Update `Widget._update_from_js` in `widgets/_base.py`** - -Find `_update_from_js` (currently lines ~223-253) and replace: - -```python -def _update_from_js(self, msg: dict, event_type: str = "pointer_move") -> bool: - """Apply incoming JS state without pushing back (avoids echo). - - Updates widget ``_data`` with widget-specific state fields from JS, - then fires widget callbacks with a flat Event. - - Parameters - ---------- - msg : dict - Full raw event message from JS. - event_type : str - One of the new pointer event types (``pointer_move``, ``pointer_up``, - ``pointer_down``). - - Returns - ------- - bool - True if any widget state changed. - """ - # Fields that belong to the event envelope, not widget state - _envelope = { - "source", "panel_id", "event_type", "widget_id", - "time_stamp", "modifiers", "button", "buttons", - "x", "y", "xdata", "ydata", - } - changed = False - for k, v in msg.items(): - if k in ("id", "type") or k in _envelope: - continue - if self._data.get(k) != v: - self._data[k] = v - changed = True - - # Always fire on press/release; only fire pointer_move when state changed - if changed or event_type in ("pointer_up", "pointer_down"): - event = Event( - event_type=event_type, - source=self, - time_stamp=msg.get("time_stamp", 0.0), - modifiers=msg.get("modifiers", []), - x=msg.get("x"), - y=msg.get("y"), - button=msg.get("button"), - buttons=msg.get("buttons", 0), - xdata=msg.get("xdata"), - ydata=msg.get("ydata"), - ) - self.callbacks.fire(event) - return changed -``` - -Also update the `set` method (line ~97) which currently fires `Event("on_changed", ...)` directly: - -```python -def set(self, _push: bool = True, **kwargs) -> None: - self._data.update(kwargs) - if _push: - self._push_fn() - # Fire pointer_move for programmatic updates - self.callbacks.fire(Event("pointer_move", source=self)) -``` - -- [ ] **Step 4: Run existing Python tests to check nothing broke** - -```bash -uv run pytest anyplotlib/tests/ -v --ignore=anyplotlib/tests/test_interactive -x -``` -Expected: All non-interactive tests PASS (they don't touch event dispatch). - -- [ ] **Step 5: Commit** - -```bash -git add anyplotlib/figure/_figure.py anyplotlib/widgets/_base.py -git commit -m "refactor: update _dispatch_event and Widget._update_from_js to use flat Event fields" -``` - ---- - -## Task 6: Update `Plot1D` and `Line1D` - -Remove `on_changed`/`on_release`/`on_click`/`on_key`/`on_line_hover`/`on_line_click`/`disconnect`/`_connect_on_key`. Inherit `_EventMixin`. Update `Line1D` to expose `add_event_handler` with `line_id` filtering. Remove `registered_keys` from state. - -**Files:** -- Modify: `anyplotlib/plot1d/_plot1d.py` - -- [ ] **Step 1: Update imports in `plot1d/_plot1d.py`** - -Find the imports block and update the callbacks import: -```python -from anyplotlib.callbacks import CallbackRegistry, _EventMixin -``` - -- [ ] **Step 2: Make `Plot1D` inherit `_EventMixin`** - -Find the class definition line: -```python -class Plot1D: -``` -Change to: -```python -class Plot1D(_EventMixin): -``` - -- [ ] **Step 3: Remove `registered_keys` from `_state` in `Plot1D.__init__`** - -Find `"registered_keys": [],` in the `_state` dict initialisation and delete that line. - -- [ ] **Step 4: Add `_configure_pointer_settled` to `Plot1D`** - -After `self.callbacks = CallbackRegistry()` in `__init__`, add to the `_state` dict: -```python -"pointer_settled_ms": 0, -"pointer_settled_delta": 4, -``` - -Add this method to the `Plot1D` class: -```python -def _configure_pointer_settled(self, ms: int, delta: float) -> None: - self._state["pointer_settled_ms"] = ms - self._state["pointer_settled_delta"] = delta - self._push() -``` - -- [ ] **Step 5: Remove old event decorator methods from `Plot1D`** - -Delete these methods entirely (find by name): -- `on_changed` -- `on_release` -- `on_click` -- `on_key` -- `_connect_on_key` -- `on_line_hover` -- `on_line_click` -- `disconnect` - -- [ ] **Step 6: Update `Line1D` event methods** - -Replace `Line1D.on_hover` and `Line1D.on_click` with a single `add_event_handler` that filters by `line_id`: - -```python -def add_event_handler(self, fn_or_type, *args, **kwargs): - """Register a handler scoped to this line only. - - Wraps the plot-level ``pointer_move`` / ``pointer_down`` handler - with a ``line_id`` filter. Only ``pointer_move`` and ``pointer_down`` - are meaningful on a line handle. - - Usage:: - - @line.add_event_handler("pointer_move") - def on_hover(event): - print(event.xdata, event.line_id) - - @line.add_event_handler("pointer_down") - def on_pick(event): - print("picked", event.line_id) - """ - target_lid = self._lid - - if callable(fn_or_type): - fn = fn_or_type - types = args - return self._wrap_and_register(fn, types, target_lid, **kwargs) - else: - all_types = (fn_or_type,) + args - def _decorator(fn): - return self._wrap_and_register(fn, all_types, target_lid, **kwargs) - return _decorator - -def _wrap_and_register(self, fn, types, target_lid, **kwargs): - from functools import wraps - @wraps(fn) - def _filtered(event): - if event.line_id == target_lid: - fn(event) - _filtered.__wrapped__ = fn - return self._plot.add_event_handler(_filtered, *types, **kwargs) - -def remove_handler(self, cid_or_fn, *types): - """Remove a handler registered via this line handle.""" - self._plot.remove_handler(cid_or_fn, *types) -``` - -- [ ] **Step 7: Run tests** - -```bash -uv run pytest anyplotlib/tests/test_interactive/test_callbacks.py anyplotlib/tests/test_plot1d/ -v -``` -Expected: All PASS. If `test_callbacks.py` had tests that used old `on_click` decorator on plots, update those to use `add_event_handler`. - -- [ ] **Step 8: Commit** - -```bash -git add anyplotlib/plot1d/_plot1d.py -git commit -m "refactor: Plot1D and Line1D adopt _EventMixin, remove old on_* decorators and registered_keys" -``` - ---- - -## Task 7: Update `Plot2D` and `PlotMesh` - -Same pattern as Task 6 — inherit `_EventMixin`, remove old decorators, add `_configure_pointer_settled`. - -**Files:** -- Modify: `anyplotlib/plot2d/_plot2d.py` -- Modify: `anyplotlib/plot2d/_plotmesh.py` - -- [ ] **Step 1: In `plot2d/_plot2d.py` — update import, inherit `_EventMixin`** - -```python -from anyplotlib.callbacks import CallbackRegistry, _EventMixin -``` -```python -class Plot2D(_EventMixin): -``` - -- [ ] **Step 2: Remove `registered_keys` from `_state`, add settled config keys** - -Remove `"registered_keys": [],` from the `_state` dict. - -Add to `_state`: -```python -"pointer_settled_ms": 0, -"pointer_settled_delta": 4, -``` - -- [ ] **Step 3: Add `_configure_pointer_settled` to `Plot2D`** - -```python -def _configure_pointer_settled(self, ms: int, delta: float) -> None: - self._state["pointer_settled_ms"] = ms - self._state["pointer_settled_delta"] = delta - self._push() -``` - -- [ ] **Step 4: Remove old event methods from `Plot2D`** - -Delete: `on_changed`, `on_release`, `on_click`, `on_key`, `_connect_on_key`, `disconnect`. - -- [ ] **Step 5: Check `PlotMesh` — it inherits `Plot2D`** - -Open `anyplotlib/plot2d/_plotmesh.py`. If `PlotMesh` also defines any of the removed methods directly, delete them. If it only inherits, no change is needed beyond checking the import line references nothing removed. - -- [ ] **Step 6: Run tests** - -```bash -uv run pytest anyplotlib/tests/test_plot2d/ -v -``` -Expected: All PASS. - -- [ ] **Step 7: Commit** - -```bash -git add anyplotlib/plot2d/_plot2d.py anyplotlib/plot2d/_plotmesh.py -git commit -m "refactor: Plot2D and PlotMesh adopt _EventMixin, remove old on_* decorators" -``` - ---- - -## Task 8: Update `Plot3D` - -Same pattern. Additionally, add `"ray": None` to the `_state` template since Plot3D pointer events carry a `ray` field instead of `xdata`/`ydata`. - -**Files:** -- Modify: `anyplotlib/plot3d/_plot3d.py` - -- [ ] **Step 1: Update import, inherit `_EventMixin`** - -```python -from anyplotlib.callbacks import CallbackRegistry, _EventMixin -``` -```python -class Plot3D(_EventMixin): -``` - -- [ ] **Step 2: Remove `registered_keys`, add settled config** - -Remove `"registered_keys": [],` from `_state`. - -Add: -```python -"pointer_settled_ms": 0, -"pointer_settled_delta": 4, -``` - -- [ ] **Step 3: Add `_configure_pointer_settled`** - -```python -def _configure_pointer_settled(self, ms: int, delta: float) -> None: - self._state["pointer_settled_ms"] = ms - self._state["pointer_settled_delta"] = delta - self._push() -``` - -- [ ] **Step 4: Remove old event methods** - -Delete: `on_changed`, `on_release`, `on_click`, `on_key`, `_connect_on_key`, `disconnect`. - -- [ ] **Step 5: Run tests** - -```bash -uv run pytest anyplotlib/tests/test_plot3d/ -v -``` -Expected: All PASS. - -- [ ] **Step 6: Commit** - -```bash -git add anyplotlib/plot3d/_plot3d.py -git commit -m "refactor: Plot3D adopts _EventMixin, remove old on_* decorators" -``` - ---- - -## Task 9: Update `PlotBar` - -Same pattern. The `pointer_down` event for PlotBar carries `bar_index`, `value`, `x_label`, `group_index` from the JS side — these are already handled by the flat `Event` constructor in `_dispatch_event`, so no extra Python work is needed beyond inheriting the mixin. - -**Files:** -- Modify: `anyplotlib/plot1d/_plotbar.py` - -- [ ] **Step 1: Update import, inherit `_EventMixin`** - -```python -from anyplotlib.callbacks import CallbackRegistry, _EventMixin -``` -```python -class PlotBar(_EventMixin): -``` - -- [ ] **Step 2: Remove `registered_keys`, add settled config** - -Remove `"registered_keys": [],` from `_state`. - -Add: -```python -"pointer_settled_ms": 0, -"pointer_settled_delta": 4, -``` - -- [ ] **Step 3: Add `_configure_pointer_settled`** - -```python -def _configure_pointer_settled(self, ms: int, delta: float) -> None: - self._state["pointer_settled_ms"] = ms - self._state["pointer_settled_delta"] = delta - self._push() -``` - -- [ ] **Step 4: Remove old event methods** - -Delete: `on_click`, `on_changed`, `on_release`, `on_key`, `_connect_on_key`, `disconnect`. - -- [ ] **Step 5: Run tests** - -```bash -uv run pytest anyplotlib/tests/test_plot1d/test_plotbar.py -v -``` -Expected: All PASS. - -- [ ] **Step 6: Commit** - -```bash -git add anyplotlib/plot1d/_plotbar.py -git commit -m "refactor: PlotBar adopts _EventMixin, remove old on_* decorators" -``` - ---- - -## Task 10: Update `Widget` base class - -Replace `on_changed`/`on_release`/`on_click`/`disconnect` with `_EventMixin`. The `_update_from_js` was already updated in Task 5. - -**Files:** -- Modify: `anyplotlib/widgets/_base.py` - -- [ ] **Step 1: Update import** - -```python -from anyplotlib.callbacks import CallbackRegistry, Event, _EventMixin -``` - -- [ ] **Step 2: Inherit `_EventMixin`** - -```python -class Widget(_EventMixin): -``` - -- [ ] **Step 3: Remove old decorator methods** - -Delete: `on_changed`, `on_release`, `on_click`, `disconnect`. - -The `callbacks` attribute is already set in `__init__` — `_EventMixin` will find it. - -- [ ] **Step 4: Run tests** - -```bash -uv run pytest anyplotlib/tests/test_interactive/ -v -k "widget" -``` -Expected: All widget tests PASS. - -- [ ] **Step 5: Run full Python test suite** - -```bash -uv run pytest anyplotlib/tests/ -v --ignore=anyplotlib/tests/test_interactive/test_event_plots.py \ - --ignore=anyplotlib/tests/test_interactive/test_event_settled.py \ - --ignore=anyplotlib/tests/test_interactive/test_event_pause_hold.py -``` -Expected: All PASS. - -- [ ] **Step 6: Commit** - -```bash -git add anyplotlib/widgets/_base.py -git commit -m "refactor: Widget adopts _EventMixin, remove old on_changed/on_release/on_click/disconnect" -``` - ---- - -## Task 11: JS — Forward new event types and fields - -Add the six missing event types to `figure_esm.js` and add `modifiers`, `buttons`, `button`, `time_stamp` to all emitted events. - -**Files:** -- Modify: `anyplotlib/figure_esm.js` - -This file is ~4000 lines. Search for existing mouse/key event listeners to find the right locations. - -- [ ] **Step 1: Find existing event emission sites** - -```bash -grep -n "mousedown\|mouseup\|mousemove\|keydown\|keyup\|wheel\|dblclick\|mouseenter\|mouseleave\|event_json\|event_type" anyplotlib/figure_esm.js | head -40 -``` -Note the line numbers for: mouse event listeners, the function that sends events to Python, key event handling. - -- [ ] **Step 2: Add a helper to extract common fields** - -Find where JS sends events to Python (the function that writes to `event_json`). Add a helper function near the top of the event-handling section: - -```javascript -function _pointerFields(e, panelId) { - return { - time_stamp: performance.now() / 1000, // seconds, matching perf_counter() - modifiers: _modifiers(e), - button: e.button ?? null, - buttons: e.buttons ?? 0, - }; -} - -function _modifiers(e) { - const mods = []; - if (e.ctrlKey) mods.push("ctrl"); - if (e.shiftKey) mods.push("shift"); - if (e.altKey) mods.push("alt"); - if (e.metaKey) mods.push("meta"); - return mods; -} -``` - -- [ ] **Step 3: Rename outgoing `event_type` values** - -Find all places the JS emits `event_type: "on_click"`, `"on_changed"`, `"on_release"`, `"on_key"`, `"on_line_hover"`, `"on_line_click"` and replace: - -| Old JS `event_type` | New JS `event_type` | -|---------------------|---------------------| -| `"on_click"` | `"pointer_down"` | -| `"on_changed"` | `"pointer_move"` | -| `"on_release"` | `"pointer_settled"` | -| `"on_key"` | `"key_down"` | -| `"on_line_hover"` | `"pointer_move"` (with `line_id` field already set) | -| `"on_line_click"` | `"pointer_down"` (with `line_id` field already set) | -| `"on_inset_state_change"` | `"inset_state_change"` | - -- [ ] **Step 4: Rename outgoing payload field names** - -In all JS event payloads, rename: -- `phys_x` → `xdata` -- `phys_y` → `ydata` -- `mouse_x` → `x` -- `mouse_y` → `y` - -```bash -grep -n "phys_x\|phys_y\|mouse_x\|mouse_y" anyplotlib/figure_esm.js -``` -Replace every occurrence. - -- [ ] **Step 5: Add `_pointerFields` to every emitted pointer event** - -For every place the JS calls the send-to-Python function with a pointer event, spread `_pointerFields(e, panelId)` into the payload: - -```javascript -// Before (example): -sendEvent({ event_type: "pointer_down", panel_id: panelId, x: px, y: py }); - -// After: -sendEvent({ event_type: "pointer_down", panel_id: panelId, - ..._pointerFields(e, panelId), x: px, y: py }); -``` - -- [ ] **Step 6: Add listener for `pointer_up` (mouseup)** - -Find the `mousedown` listener and add a `mouseup` listener alongside it: - -```javascript -canvas.addEventListener("mouseup", (e) => { - sendEvent({ - event_type: "pointer_up", - panel_id: panelId, - ..._pointerFields(e, panelId), - x: /* pixel x relative to canvas */, - y: /* pixel y relative to canvas */, - xdata: /* data coord x or null */, - ydata: /* data coord y or null */, - }); -}); -``` - -- [ ] **Step 7: Add `pointer_enter` / `pointer_leave` listeners** - -```javascript -canvas.addEventListener("mouseenter", (e) => { - sendEvent({ event_type: "pointer_enter", panel_id: panelId, - ..._pointerFields(e, panelId), x: /*px*/, y: /*py*/ }); -}); -canvas.addEventListener("mouseleave", (e) => { - sendEvent({ event_type: "pointer_leave", panel_id: panelId, - ..._pointerFields(e, panelId), x: /*px*/, y: /*py*/ }); -}); -``` - -Note: `button` is `null` on enter/leave events (no button triggered the event). `buttons` reflects currently-held buttons. - -- [ ] **Step 8: Add `double_click` listener** - -```javascript -canvas.addEventListener("dblclick", (e) => { - sendEvent({ event_type: "double_click", panel_id: panelId, - ..._pointerFields(e, panelId), x: /*px*/, y: /*py*/, - xdata: /*or null*/, ydata: /*or null*/ }); -}); -``` - -- [ ] **Step 9: Add `wheel` listener** - -```javascript -canvas.addEventListener("wheel", (e) => { - e.preventDefault(); - sendEvent({ event_type: "wheel", panel_id: panelId, - time_stamp: performance.now() / 1000, - modifiers: _modifiers(e), - x: /*px*/, y: /*py*/, - dx: e.deltaX, dy: e.deltaY }); -}, { passive: false }); -``` - -- [ ] **Step 10: Add `key_up` listener** - -Find the existing `keydown` listener and add `keyup` alongside: - -```javascript -document.addEventListener("keyup", (e) => { - if (!panelFocused) return; - sendEvent({ event_type: "key_up", panel_id: panelId, - time_stamp: performance.now() / 1000, - modifiers: _modifiers(e), - key: e.key, x: lastPointerX, y: lastPointerY }); -}); -``` - -- [ ] **Step 11: Remove `registered_keys` filtering from JS** - -Find the section that checks `registered_keys` before forwarding key events (something like `if (state.registered_keys.includes(e.key) || ...)`). Remove this guard — forward all key events unconditionally. - -- [ ] **Step 12: Run the full pure-Python test suite to confirm no regressions** - -```bash -uv run pytest anyplotlib/tests/ -v -k "not test_event_plots and not test_event_settled and not test_event_pause_hold" -``` -Expected: All PASS. - -- [ ] **Step 13: Commit** - -```bash -git add anyplotlib/figure_esm.js -git commit -m "feat: JS forwards pointer_up, pointer_enter/leave, double_click, wheel, key_up; rename event fields to xdata/ydata/x/y; add modifiers/button/buttons/time_stamp" -``` - ---- - -## Task 12: JS — `pointer_settled` dwell timer - -Add a per-panel dwell timer that fires `pointer_settled` after the pointer holds still for the configured ms/delta thresholds. - -**Files:** -- Modify: `anyplotlib/figure_esm.js` - -- [ ] **Step 1: Add timer state per panel** - -Near the per-panel state initialisation, add: - -```javascript -let _settledTimer = null; -let _settledStartX = 0; -let _settledStartY = 0; -let _settledStartTs = 0; -``` - -- [ ] **Step 2: Add `pointer_settled` trigger inside the `pointer_move` handler** - -Inside the `mousemove` / `pointer_move` emission block, after emitting `pointer_move`, add: - -```javascript -// pointer_settled dwell timer -const settledMs = panelState.pointer_settled_ms ?? 0; -const settledDelta = panelState.pointer_settled_delta ?? 4; -if (settledMs > 0) { - clearTimeout(_settledTimer); - const nowX = currentPixelX; - const nowY = currentPixelY; - const nowTs = performance.now(); - _settledStartX = nowX; - _settledStartY = nowY; - _settledStartTs = nowTs; - _settledTimer = setTimeout(() => { - const dist = Math.hypot(currentPixelX - _settledStartX, - currentPixelY - _settledStartY); - if (dist <= settledDelta) { - const dwellMs = performance.now() - _settledStartTs; - sendEvent({ - event_type: "pointer_settled", - panel_id: panelId, - time_stamp: performance.now() / 1000, - modifiers: lastModifiers, - buttons: lastButtons, - button: null, - x: currentPixelX, - y: currentPixelY, - xdata: currentDataX ?? null, - ydata: currentDataY ?? null, - dwell_ms: dwellMs, - }); - } - }, settledMs); -} -``` - -Where `currentPixelX`, `currentPixelY`, `currentDataX`, `currentDataY`, `lastModifiers`, `lastButtons` are variables already tracked by the mousemove handler. - -- [ ] **Step 3: Cancel timer on `mouseup` and `mouseleave`** - -Inside the `mouseup` and `mouseleave` handlers, add: -```javascript -clearTimeout(_settledTimer); -_settledTimer = null; -``` - -- [ ] **Step 4: Commit** - -```bash -git add anyplotlib/figure_esm.js -git commit -m "feat: add pointer_settled dwell timer to JS with zero cost when unused" -``` - ---- - -## Task 13: Playwright tests — pointer events per plot type - -**Files:** -- Create: `anyplotlib/tests/test_interactive/test_event_plots.py` - -- [ ] **Step 1: Create the test file** - -```python -""" -Playwright tests for pointer/key events across all plot types. -Each plot type gets: pointer_down, pointer_up, pointer_move, pointer_enter, -pointer_leave, double_click, wheel, key_down, key_up, modifiers. -""" -from __future__ import annotations -import json -import numpy as np -import pytest -import anyplotlib as apl - - -# ── helpers ────────────────────────────────────────────────────────────────── - -def _collect(page, fig, event_type): - """Return a list of event dicts received for event_type.""" - page.evaluate(f""" - window._evts_{event_type} = []; - window._aplModel.on("{event_type}", (e) => {{ - window._evts_{event_type}.push(e); - }}); - """) - return page.evaluate(f"window._evts_{event_type}") - - -def _plot1d_fig(): - fig, ax = apl.subplots(1, 1, figsize=(400, 300)) - ax.plot(np.zeros(100)) - return fig - - -def _plot2d_fig(): - fig, ax = apl.subplots(1, 1, figsize=(400, 400)) - ax.imshow(np.zeros((64, 64))) - return fig - - -def _plot3d_fig(): - x = np.linspace(-2, 2, 20) - y = np.linspace(-2, 2, 20) - XX, YY = np.meshgrid(x, y) - fig, ax = apl.subplots(1, 1, figsize=(400, 400)) - ax.plot_surface(XX, YY, np.zeros_like(XX)) - return fig - - -def _plotbar_fig(): - fig, ax = apl.subplots(1, 1, figsize=(400, 300)) - ax.bar(["A", "B", "C"], [1.0, 2.0, 3.0]) - return fig - - -# ── pointer_down ───────────────────────────────────────────────────────────── - -class TestPointerDown: - def test_plot1d_pointer_down_fields(self, interact_page): - fig = _plot1d_fig() - page = interact_page(fig) - received = [] - page.expose_function("_on_pd", lambda e: received.append(json.loads(e))) - page.evaluate(""" - window._aplModel && window._aplModel.on && - window._aplModel.on("pointer_down", e => window._on_pd(JSON.stringify(e))) - """) - page.mouse.click(200, 150) - page.wait_for_timeout(200) - assert len(received) >= 1 - e = received[0] - assert e["event_type"] == "pointer_down" - assert isinstance(e["x"], (int, float)) - assert isinstance(e["y"], (int, float)) - assert e["button"] == 0 - assert e["buttons"] == 0 # buttons=0 after release - assert isinstance(e["modifiers"], list) - assert isinstance(e["time_stamp"], (int, float)) - - def test_plot2d_pointer_down_has_xdata_ydata(self, interact_page): - fig = _plot2d_fig() - page = interact_page(fig) - received = [] - page.expose_function("_on_pd2", lambda e: received.append(json.loads(e))) - page.evaluate( - "window._aplModel.on('pointer_down', e => window._on_pd2(JSON.stringify(e)))" - ) - page.mouse.click(200, 200) - page.wait_for_timeout(200) - assert len(received) >= 1 - e = received[0] - assert e.get("xdata") is not None - assert e.get("ydata") is not None - - def test_plot3d_pointer_down_no_xdata(self, interact_page): - fig = _plot3d_fig() - page = interact_page(fig) - received = [] - page.expose_function("_on_pd3", lambda e: received.append(json.loads(e))) - page.evaluate( - "window._aplModel.on('pointer_down', e => window._on_pd3(JSON.stringify(e)))" - ) - page.mouse.click(200, 200) - page.wait_for_timeout(200) - assert len(received) >= 1 - e = received[0] - assert e.get("xdata") is None - assert e.get("ydata") is None - - def test_ctrl_click_modifiers(self, interact_page): - fig = _plot1d_fig() - page = interact_page(fig) - received = [] - page.expose_function("_on_ctrl", lambda e: received.append(json.loads(e))) - page.evaluate( - "window._aplModel.on('pointer_down', e => window._on_ctrl(JSON.stringify(e)))" - ) - page.keyboard.down("Control") - page.mouse.click(200, 150) - page.keyboard.up("Control") - page.wait_for_timeout(200) - assert any("ctrl" in e.get("modifiers", []) for e in received) - - -# ── pointer_up ──────────────────────────────────────────────────────────────── - -class TestPointerUp: - def test_fires_after_drag(self, interact_page): - fig = _plot1d_fig() - page = interact_page(fig) - received = [] - page.expose_function("_on_pu", lambda e: received.append(json.loads(e))) - page.evaluate( - "window._aplModel.on('pointer_up', e => window._on_pu(JSON.stringify(e)))" - ) - page.mouse.move(200, 150) - page.mouse.down() - page.mouse.move(150, 150, steps=5) - page.mouse.up() - page.wait_for_timeout(200) - assert len(received) >= 1 - e = received[-1] - assert e["event_type"] == "pointer_up" - assert e["button"] == 0 - - -# ── pointer_move ────────────────────────────────────────────────────────────── - -class TestPointerMove: - def test_fires_during_drag(self, interact_page): - fig = _plot1d_fig() - page = interact_page(fig) - received = [] - page.expose_function("_on_pm", lambda e: received.append(json.loads(e))) - page.evaluate( - "window._aplModel.on('pointer_move', e => window._on_pm(JSON.stringify(e)))" - ) - page.mouse.move(200, 150) - page.mouse.down() - page.mouse.move(100, 150, steps=10) - page.mouse.up() - page.wait_for_timeout(300) - assert len(received) >= 5 # multiple frames during drag - - -# ── pointer_enter / pointer_leave ───────────────────────────────────────────── - -class TestPointerEnterLeave: - def test_enter_fires_on_mouse_enter(self, interact_page): - fig = _plot1d_fig() - page = interact_page(fig) - received = [] - page.expose_function("_on_pe", lambda e: received.append(json.loads(e))) - page.evaluate( - "window._aplModel.on('pointer_enter', e => window._on_pe(JSON.stringify(e)))" - ) - # Move from outside the widget to inside - page.mouse.move(0, 0) - page.mouse.move(200, 150) - page.wait_for_timeout(200) - assert len(received) >= 1 - assert received[0]["event_type"] == "pointer_enter" - assert received[0].get("button") is None # button is None on enter - assert isinstance(received[0]["buttons"], int) - - def test_leave_fires_on_mouse_leave(self, interact_page): - fig = _plot1d_fig() - page = interact_page(fig) - received = [] - page.expose_function("_on_pl", lambda e: received.append(json.loads(e))) - page.evaluate( - "window._aplModel.on('pointer_leave', e => window._on_pl(JSON.stringify(e)))" - ) - page.mouse.move(200, 150) - page.mouse.move(0, 0) - page.wait_for_timeout(200) - assert len(received) >= 1 - assert received[0]["event_type"] == "pointer_leave" - - -# ── double_click ────────────────────────────────────────────────────────────── - -class TestDoubleClick: - def test_fires_on_dblclick(self, interact_page): - fig = _plot1d_fig() - page = interact_page(fig) - received = [] - page.expose_function("_on_dc", lambda e: received.append(json.loads(e))) - page.evaluate( - "window._aplModel.on('double_click', e => window._on_dc(JSON.stringify(e)))" - ) - page.mouse.dblclick(200, 150) - page.wait_for_timeout(200) - assert len(received) >= 1 - assert received[0]["event_type"] == "double_click" - assert received[0]["button"] == 0 - - -# ── wheel ───────────────────────────────────────────────────────────────────── - -class TestWheel: - def test_fires_on_scroll(self, interact_page): - fig = _plot2d_fig() - page = interact_page(fig) - received = [] - page.expose_function("_on_wh", lambda e: received.append(json.loads(e))) - page.evaluate( - "window._aplModel.on('wheel', e => window._on_wh(JSON.stringify(e)))" - ) - page.mouse.move(200, 200) - page.mouse.wheel(0, 100) - page.wait_for_timeout(200) - assert len(received) >= 1 - e = received[0] - assert e["event_type"] == "wheel" - assert e.get("dy") is not None - - -# ── key_down / key_up ───────────────────────────────────────────────────────── - -class TestKeyEvents: - def test_key_down_fires_any_key(self, interact_page): - fig = _plot1d_fig() - page = interact_page(fig) - received = [] - page.expose_function("_on_kd", lambda e: received.append(json.loads(e))) - page.evaluate( - "window._aplModel.on('key_down', e => window._on_kd(JSON.stringify(e)))" - ) - page.mouse.move(200, 150) # focus the panel - page.keyboard.press("r") - page.wait_for_timeout(200) - assert any(e["key"] == "r" for e in received) - - def test_key_up_fires(self, interact_page): - fig = _plot1d_fig() - page = interact_page(fig) - received = [] - page.expose_function("_on_ku", lambda e: received.append(json.loads(e))) - page.evaluate( - "window._aplModel.on('key_up', e => window._on_ku(JSON.stringify(e)))" - ) - page.mouse.move(200, 150) - page.keyboard.down("q") - page.keyboard.up("q") - page.wait_for_timeout(200) - assert any(e["key"] == "q" for e in received) -``` - -- [ ] **Step 2: Run the new tests** - -```bash -uv run pytest anyplotlib/tests/test_interactive/test_event_plots.py -v -``` -Expected: All PASS. Fix any failures by adjusting pixel coordinates or widget locators to match your actual panel layout. - -- [ ] **Step 3: Commit** - -```bash -git add anyplotlib/tests/test_interactive/test_event_plots.py -git commit -m "test: add Playwright tests for pointer_down/up/move, enter/leave, double_click, wheel, key_down/up" -``` - ---- - -## Task 14: Playwright tests — `pointer_settled` - -**Files:** -- Create: `anyplotlib/tests/test_interactive/test_event_settled.py` - -- [ ] **Step 1: Create the test file** - -```python -"""Tests for pointer_settled dwell timer — JS computes, Python receives.""" -from __future__ import annotations -import json -import numpy as np -import pytest -import anyplotlib as apl -from anyplotlib.callbacks import Event - - -# ── Python-side: _configure_pointer_settled ─────────────────────────────────── - -class TestSettledConfig: - def test_state_set_on_first_connect(self): - fig, ax = apl.subplots(1, 1) - plot = ax.imshow(np.zeros((32, 32))) - assert plot._state["pointer_settled_ms"] == 0 - assert plot._state["pointer_settled_delta"] == 4 - - plot.add_event_handler(lambda e: None, "pointer_settled", ms=400, delta=5) - assert plot._state["pointer_settled_ms"] == 400 - assert plot._state["pointer_settled_delta"] == 5 - - def test_state_cleared_on_last_disconnect(self): - fig, ax = apl.subplots(1, 1) - plot = ax.imshow(np.zeros((32, 32))) - fn = lambda e: None - plot.add_event_handler(fn, "pointer_settled", ms=400, delta=5) - plot.remove_handler(fn) - assert plot._state["pointer_settled_ms"] == 0 - - def test_two_handlers_keep_last_config(self): - fig, ax = apl.subplots(1, 1) - plot = ax.imshow(np.zeros((32, 32))) - fn1 = lambda e: None - fn2 = lambda e: None - plot.add_event_handler(fn1, "pointer_settled", ms=200, delta=3) - plot.add_event_handler(fn2, "pointer_settled", ms=800, delta=6) - # Last connect wins — ms=800, delta=6 - assert plot._state["pointer_settled_ms"] == 800 - assert plot._state["pointer_settled_delta"] == 6 - # Remove fn2 — config clears only when NO handlers remain - plot.remove_handler(fn2) - # fn1 still connected → ms stays at 800 (fn1's config is remembered by registry) - assert plot._state["pointer_settled_ms"] > 0 - - -# ── Playwright: dwell timer ─────────────────────────────────────────────────── - -class TestSettledPlaywright: - def test_fires_after_hold(self, interact_page): - fig, ax = apl.subplots(1, 1, figsize=(400, 300)) - plot = ax.imshow(np.zeros((64, 64))) - # Configure a short dwell (200ms) for fast tests - plot.add_event_handler(lambda e: None, "pointer_settled", ms=200, delta=4) - - page = interact_page(fig) - received = [] - page.expose_function("_on_st", lambda e: received.append(json.loads(e))) - page.evaluate( - "window._aplModel.on('pointer_settled', e => window._on_st(JSON.stringify(e)))" - ) - - # Move into panel and hold still - page.mouse.move(200, 150) - page.wait_for_timeout(400) # well past the 200ms threshold - - assert len(received) >= 1 - e = received[0] - assert e["event_type"] == "pointer_settled" - assert e["dwell_ms"] >= 200 - - def test_does_not_fire_if_moving(self, interact_page): - fig, ax = apl.subplots(1, 1, figsize=(400, 300)) - plot = ax.imshow(np.zeros((64, 64))) - plot.add_event_handler(lambda e: None, "pointer_settled", ms=300, delta=4) - - page = interact_page(fig) - received = [] - page.expose_function("_on_st2", lambda e: received.append(json.loads(e))) - page.evaluate( - "window._aplModel.on('pointer_settled', e => window._on_st2(JSON.stringify(e)))" - ) - - # Keep moving — should never settle - page.mouse.move(100, 150) - page.mouse.move(150, 150, steps=5) - page.mouse.move(200, 150, steps=5) - page.mouse.move(250, 150, steps=5) - page.wait_for_timeout(100) - - assert received == [] - - def test_no_timer_when_no_handler_connected(self, interact_page): - fig, ax = apl.subplots(1, 1, figsize=(400, 300)) - plot = ax.imshow(np.zeros((64, 64))) - # No pointer_settled handler connected — pointer_settled_ms stays 0 - - page = interact_page(fig) - # Confirm JS state has no timer configured - settled_ms = page.evaluate( - f"JSON.parse(window._aplModel.get('panel_{plot._id}_json')).pointer_settled_ms" - ) - assert settled_ms == 0 - - def test_fires_again_after_re_settle(self, interact_page): - fig, ax = apl.subplots(1, 1, figsize=(400, 300)) - plot = ax.imshow(np.zeros((64, 64))) - plot.add_event_handler(lambda e: None, "pointer_settled", ms=200, delta=4) - - page = interact_page(fig) - received = [] - page.expose_function("_on_st3", lambda e: received.append(json.loads(e))) - page.evaluate( - "window._aplModel.on('pointer_settled', e => window._on_st3(JSON.stringify(e)))" - ) - - # First settle - page.mouse.move(200, 150) - page.wait_for_timeout(350) - - # Move and settle again - page.mouse.move(100, 150, steps=3) - page.wait_for_timeout(350) - - assert len(received) >= 2 # fired twice -``` - -- [ ] **Step 2: Run tests** - -```bash -uv run pytest anyplotlib/tests/test_interactive/test_event_settled.py -v -``` -Expected: All PASS. - -- [ ] **Step 3: Commit** - -```bash -git add anyplotlib/tests/test_interactive/test_event_settled.py -git commit -m "test: add pointer_settled Playwright tests including zero-cost guard" -``` - ---- - -## Task 15: Playwright tests — pause/hold integration - -**Files:** -- Create: `anyplotlib/tests/test_interactive/test_event_pause_hold.py` - -- [ ] **Step 1: Create the test file** - -```python -"""Integration tests for pause_events / hold_events during live interactions.""" -from __future__ import annotations -import json -import numpy as np -import pytest -import anyplotlib as apl - - -class TestPauseIntegration: - def test_pause_drops_pointer_move_during_drag(self, interact_page): - fig, ax = apl.subplots(1, 1, figsize=(400, 300)) - plot = ax.imshow(np.zeros((64, 64))) - received = [] - plot.add_event_handler(lambda e: received.append(1), "pointer_move") - - page = interact_page(fig) - - # Pause then trigger drag — moves should not reach handler - page.evaluate("window._aplPaused = true") # hook into test infra below - with plot.pause_events("pointer_move"): - page.mouse.move(200, 150) - page.mouse.down() - page.mouse.move(100, 150, steps=5) - page.mouse.up() - page.wait_for_timeout(200) - - assert received == [] - - # After context exits, moves should fire again - page.mouse.move(200, 150) - page.mouse.down() - page.mouse.move(150, 150, steps=3) - page.mouse.up() - page.wait_for_timeout(200) - assert len(received) > 0 - - -class TestHoldIntegration: - def test_hold_buffers_settled_fires_on_exit(self, interact_page): - fig, ax = apl.subplots(1, 1, figsize=(400, 300)) - plot = ax.imshow(np.zeros((64, 64))) - plot.add_event_handler(lambda e: None, "pointer_settled", ms=150, delta=4) - received = [] - plot.add_event_handler(lambda e: received.append(1), "pointer_settled") - - page = interact_page(fig) - - with plot.hold_events("pointer_settled"): - page.mouse.move(200, 150) - page.wait_for_timeout(300) # settled fires → buffered - assert received == [] - - # hold context exited → flushed - assert received == [1] - - def test_hold_fires_pointer_move_immediately(self, interact_page): - fig, ax = apl.subplots(1, 1, figsize=(400, 300)) - plot = ax.imshow(np.zeros((64, 64))) - moves = [] - settles = [] - plot.add_event_handler(lambda e: moves.append(1), "pointer_move") - plot.add_event_handler(lambda e: None, "pointer_settled", ms=150, delta=4) - plot.add_event_handler(lambda e: settles.append(1), "pointer_settled") - - page = interact_page(fig) - - with plot.hold_events("pointer_settled"): - page.mouse.move(200, 150) - page.mouse.down() - page.mouse.move(100, 150, steps=5) - page.mouse.up() - page.wait_for_timeout(300) - - assert len(moves) > 0 # pointer_move not held → fired immediately - assert len(settles) == 1 # flushed on exit -``` - -- [ ] **Step 2: Run tests** - -```bash -uv run pytest anyplotlib/tests/test_interactive/test_event_pause_hold.py -v -``` -Expected: All PASS. - -- [ ] **Step 3: Commit** - -```bash -git add anyplotlib/tests/test_interactive/test_event_pause_hold.py -git commit -m "test: add pause_events and hold_events Playwright integration tests" -``` - ---- - -## Task 16: Update Examples and regression tests - -**Files:** -- Modify: All `Examples/**/*.py` files that use old event API -- Modify: `anyplotlib/tests/test_interactive/test_callbacks.py` (add regression block) - -- [ ] **Step 1: Find all example files using old event API** - -```bash -grep -rn "on_click\|on_changed\|on_release\|on_key\|on_hover\|\.disconnect(" Examples/ --include="*.py" -``` - -- [ ] **Step 2: Update each file** - -For each file found, replace old API calls: - -| Old | New | -|-----|-----| -| `@plot.on_click` | `@plot.add_event_handler("pointer_down")` | -| `@plot.on_changed` | `@plot.add_event_handler("pointer_move")` | -| `@plot.on_release` | `@plot.add_event_handler("pointer_settled")` | -| `@plot.on_key` | `@plot.add_event_handler("key_down")` | -| `@plot.on_key('q')` | `@plot.add_event_handler("key_down")` + `if event.key == "q": return` | -| `@widget.on_changed` | `@widget.add_event_handler("pointer_move")` | -| `@widget.on_release` | `@widget.add_event_handler("pointer_up")` | -| `@widget.on_click` | `@widget.add_event_handler("pointer_down")` | -| `@line.on_hover` | `@line.add_event_handler("pointer_move")` | -| `@line.on_click` | `@line.add_event_handler("pointer_down")` | -| `plot.disconnect(cid)` | `plot.remove_handler(cid)` | -| `event.phys_x` | `event.xdata` | -| `event.phys_y` | `event.ydata` | -| `event.mouse_x` | `event.x` | -| `event.mouse_y` | `event.y` | - -- [ ] **Step 3: Add regression tests to `test_callbacks.py`** - -Append to `anyplotlib/tests/test_interactive/test_callbacks.py`: - -```python -class TestRegressionOldAPIGone: - """Confirm old decorator methods no longer exist on plots and widgets.""" - - def test_plot1d_no_on_click(self): - fig, ax = apl.subplots(1, 1) - plot = ax.plot(np.zeros(10)) - assert not hasattr(plot, "on_click") - - def test_plot1d_no_on_changed(self): - fig, ax = apl.subplots(1, 1) - plot = ax.plot(np.zeros(10)) - assert not hasattr(plot, "on_changed") - - def test_plot1d_no_on_release(self): - fig, ax = apl.subplots(1, 1) - plot = ax.plot(np.zeros(10)) - assert not hasattr(plot, "on_release") - - def test_plot1d_no_on_key(self): - fig, ax = apl.subplots(1, 1) - plot = ax.plot(np.zeros(10)) - assert not hasattr(plot, "on_key") - - def test_plot1d_no_disconnect(self): - fig, ax = apl.subplots(1, 1) - plot = ax.plot(np.zeros(10)) - assert not hasattr(plot, "disconnect") - - def test_plot2d_no_on_click(self): - fig, ax = apl.subplots(1, 1) - plot = ax.imshow(np.zeros((32, 32))) - assert not hasattr(plot, "on_click") - - def test_widget_no_on_changed(self): - fig, ax = apl.subplots(1, 1) - plot = ax.plot(np.zeros(10)) - w = plot.add_vline_widget(5.0) - assert not hasattr(w, "on_changed") - - def test_widget_no_on_release(self): - fig, ax = apl.subplots(1, 1) - plot = ax.plot(np.zeros(10)) - w = plot.add_vline_widget(5.0) - assert not hasattr(w, "on_release") - - def test_event_no_phys_x(self): - e = Event(event_type="pointer_down", xdata=3.14) - assert not hasattr(e, "phys_x") - assert e.xdata == 3.14 - - def test_event_no_data_dict(self): - e = Event(event_type="pointer_move") - assert not hasattr(e, "data") -``` - -- [ ] **Step 4: Run the full test suite** - -```bash -uv run pytest anyplotlib/tests/ -v -``` -Expected: All PASS. - -- [ ] **Step 5: Commit** - -```bash -git add Examples/ anyplotlib/tests/test_interactive/test_callbacks.py -git commit -m "refactor: update Examples to new event API; add regression tests confirming old API removed" -``` - ---- - -## Verification - -After all tasks complete, run the full suite once more: - -```bash -uv run pytest anyplotlib/tests/ -v --tb=short 2>&1 | tail -20 -``` - -Expected output ends with something like: -``` -========== NNN passed in XX.Xs ========== -``` - -with zero failures or errors. diff --git a/docs/superpowers/specs/2026-05-14-event-system-design.md b/docs/superpowers/specs/2026-05-14-event-system-design.md deleted file mode 100644 index c4c45117..00000000 --- a/docs/superpowers/specs/2026-05-14-event-system-design.md +++ /dev/null @@ -1,381 +0,0 @@ -# Event System Redesign - -**Date:** 2026-05-14 -**Status:** Approved — ready for implementation planning - -## Motivation - -The existing event system has several inconsistencies identified during a pre-0.1.0 audit: - -- `on_click` fires on mouse press (not full click cycle) — misleading name -- `on_release` means "debounced/settled" not "mouse button released" — misleading name -- `on_changed` conflates viewport pan/zoom with widget drag frames -- `phys_x`/`phys_y` are non-standard field names; matplotlib users expect `xdata`/`ydata` -- Modifier keys (ctrl, shift, alt) are not exposed on any event -- No `pointer_up`, `pointer_enter`, `pointer_leave`, `double_click`, `wheel`, or `key_up` events -- `on_key` decorator has asymmetric optional-argument syntax inconsistent with all other decorators -- `on_click` payload differs completely across plot types (coords on Plot1D/2D, bar metadata on PlotBar, no data coords on Plot3D) -- No way to pause or buffer events during batch operations - -The redesign aligns with the [pygfx/rendercanvas event system](https://github.com/pygfx/rendercanvas) naming and adds anyplotlib-specific extensions (`pointer_settled`, pause/hold). - ---- - -## Section 1: Event Types - -### Pointer events (all plot types) - -| Event | Trigger | -|-------|---------| -| `pointer_down` | Mouse/touch pressed — replaces `on_click` | -| `pointer_up` | Mouse/touch physically released — new | -| `pointer_move` | Pointer moved (drag or hover) — replaces `on_changed` | -| `pointer_settled` | Pointer held still for ≥ N ms within ± delta px — replaces `on_release`, gains explicit params | -| `pointer_enter` | Cursor enters the panel — new | -| `pointer_leave` | Cursor leaves the panel — new | -| `double_click` | Double-click / long-tap — new | -| `wheel` | Scroll wheel or pinch — new | - -### Key events (all plot types) - -| Event | Trigger | -|-------|---------| -| `key_down` | Key pressed while panel focused — replaces `on_key` | -| `key_up` | Key released — new | - -### Plot-specific behaviour - -`pointer_move` and `pointer_down` on **Plot1D** carry a `line_id` field when the pointer is over a line (`None` otherwise). These are not separate event types — the same event carries extra data. Users check `if event.line_id` to distinguish. This replaces the separate `on_line_hover` and `on_line_click` event types. - ---- - -## Section 2: Event Object Fields - -The `Event` dataclass is flattened — all fields are top-level attributes with `None` as the default when a field does not apply. No more `data` dict with attribute proxy. - -### Universal fields (every event) - -| Field | Type | Description | -|-------|------|-------------| -| `event_type` | `str` | e.g. `"pointer_down"` | -| `source` | `object` | the plot or widget that fired it | -| `time_stamp` | `float` | `perf_counter()` at fire time | -| `modifiers` | `list[str]` | `["ctrl"]`, `["shift"]`, `["alt"]`, `["meta"]` — empty list if none | - -### Pointer fields (pointer_down, pointer_up, pointer_move, pointer_settled, pointer_enter, pointer_leave, double_click) - -| Field | Type | Present on | -|-------|------|-----------| -| `x` | `int` | all pointer events — pixel x within panel | -| `y` | `int` | all pointer events — pixel y within panel | -| `button` | `int \| None` | `pointer_down`, `pointer_up`, `double_click` only — 0=left, 1=middle, 2=right; `None` on enter/leave/move/settled | -| `buttons` | `int` | all pointer events — bitmask of currently held buttons (useful on `pointer_enter` to detect dragging into panel) | -| `xdata` | `float \| None` | Plot1D, Plot2D, PlotMesh — data-space x coordinate | -| `ydata` | `float \| None` | Plot1D, Plot2D, PlotMesh — data-space y coordinate | -| `ray` | `dict \| None` | Plot3D only — `{"origin": [x,y,z], "direction": [dx,dy,dz]}` | -| `line_id` | `str \| None` | Plot1D only — set when pointer is over a line, `None` otherwise | -| `dwell_ms` | `float \| None` | `pointer_settled` only — actual time the pointer held still | - -### PlotBar additional fields on `pointer_down` - -| Field | Type | Description | -|-------|------|-------------| -| `bar_index` | `int \| None` | which bar was clicked; `None` if click missed all bars | -| `value` | `float \| None` | bar value | -| `x_label` | `str \| None` | category label | -| `group_index` | `int \| None` | group index for grouped bars; `None` for ungrouped | - -PlotBar `pointer_down` also carries `x`, `y`, `xdata`, `ydata` like other plot types, so all fields are available. - -### Wheel fields - -| Field | Type | Description | -|-------|------|-------------| -| `x`, `y` | `int` | pointer position at time of scroll | -| `dx`, `dy` | `float` | scroll deltas; accumulated across merged frames (matching pygfx) | - -### Key fields (key_down, key_up) - -| Field | Type | Description | -|-------|------|-------------| -| `key` | `str` | key name e.g. `"q"`, `"Enter"`, `"ArrowLeft"` | -| `x`, `y` | `int` | pointer position at time of keypress | - ---- - -## Section 3: Connection API - -The user-facing API on every plot and widget becomes `add_event_handler` / `remove_handler`. The internal `CallbackRegistry` engine (`connect`/`disconnect`/`fire`) is unchanged. - -### Functional form - -```python -# Single type -cid = plot.add_event_handler(fn, "pointer_down") - -# Multiple types in one call -cid = plot.add_event_handler(fn, "pointer_down", "pointer_up") - -# Wildcard — receives every event type -cid = plot.add_event_handler(fn, "*") - -# pointer_settled with explicit thresholds (defaults: ms=300, delta=4) -# ms/delta are only valid when "pointer_settled" is in the types list — ValueError otherwise -cid = plot.add_event_handler(fn, "pointer_settled", ms=400, delta=5) - -# Priority — lower order fires first, default 0 -cid = plot.add_event_handler(fn, "pointer_move", order=-1) -``` - -### Decorator form - -```python -@plot.add_event_handler("pointer_down") -def on_press(event): - print(event.xdata, event.ydata) - -@plot.add_event_handler("pointer_down", "pointer_up") -def on_press_release(event): - print(event.event_type, event.button) - -@plot.add_event_handler("pointer_settled", ms=400, delta=5) -def on_settled(event): - update_spectrum(event.xdata, event.ydata) -``` - -### Removal - -```python -# By CID (returned from add_event_handler) -plot.remove_handler(cid) - -# By callback reference + specific types -plot.remove_handler(fn, "pointer_down") - -# By callback reference alone — removes from all types it was registered under -plot.remove_handler(fn) -``` - -### Per-line filtering on Plot1D - -Line handles returned by `ax.plot()` and `line.add_line()` expose their own `add_event_handler`. Internally this connects to the plot's `pointer_move`/`pointer_down` and filters by `line_id` — no new mechanism required. - -```python -line = ax.plot(data) -overlay = line.add_line(data2) - -@line.add_event_handler("pointer_move") -def on_hover(event): - print(event.xdata, event.line_id) - -@overlay.add_event_handler("pointer_down") -def on_pick(event): - print("picked overlay line") -``` - -### What disappears - -| Old | New | -|-----|-----| -| `@plot.on_click` | `@plot.add_event_handler("pointer_down")` | -| `@plot.on_changed` | `@plot.add_event_handler("pointer_move")` | -| `@plot.on_release` | `@plot.add_event_handler("pointer_settled")` | -| `@plot.on_key` / `@plot.on_key('q')` | `@plot.add_event_handler("key_down")` | -| `@line.on_hover` | `@line.add_event_handler("pointer_move")` | -| `@line.on_click` | `@line.add_event_handler("pointer_down")` | -| `plot.disconnect(cid)` | `plot.remove_handler(cid)` | -| `plot.callbacks.connect("on_click", fn)` | `plot.callbacks.connect("pointer_down", fn)` | - ---- - -## Section 4: Architecture & Data Flow - -### JS changes (`figure_esm.js`) - -**New events JS must emit:** - -| JS DOM event | anyplotlib event | Notes | -|-------------|-----------------|-------| -| `mouseenter` | `pointer_enter` | per panel canvas element | -| `mouseleave` | `pointer_leave` | per panel canvas element | -| `mouseup` | `pointer_up` | previously swallowed after debounce | -| `dblclick` | `double_click` | | -| `wheel` | `wheel` | `dx`/`dy` accumulated across merged frames | -| `keyup` | `key_up` | complement to existing keydown | - -**Fields added to all emitted events:** -- `modifiers`: extracted from `ctrlKey`, `shiftKey`, `altKey`, `metaKey` -- `buttons`: from `event.buttons` bitmask (available on all MouseEvents) -- `button`: from `event.button` on press/release events -- `time_stamp`: set in JS before sending - -**`pointer_settled` timer logic (per panel):** - -``` -On pointer_move: - if panel_state.pointer_settled_ms > 0: - clearTimeout(settled_timer) - record settle_start_pos = current_pos - settled_timer = setTimeout(() => { - if distance(current_pos, settle_start_pos) <= panel_state.pointer_settled_delta: - emit pointer_settled { ...pointer fields, dwell_ms: actual_elapsed } - }, panel_state.pointer_settled_ms) -``` - -Timer is never created when `pointer_settled_ms == 0`. Cost is zero when no handler is connected. - -**Key registration removed:** `registered_keys` state field is eliminated. `key_down`/`key_up` forward all key presses unconditionally (matching pygfx). Per-key filtering moves to Python-side handler wrappers if users want it. - -### Python changes - -**`_dispatch_event()` field mapping:** - -| Old field | New field | Change | -|-----------|-----------|--------| -| `phys_x` | `xdata` | rename | -| `phys_y` | `ydata` | rename | -| `mouse_x` | `x` | rename | -| `mouse_y` | `y` | rename | -| *(absent)* | `button` | new | -| *(absent)* | `buttons` | new | -| *(absent)* | `modifiers` | new | -| *(absent)* | `time_stamp` | new | -| *(absent)* | `ray` | new (Plot3D) | -| *(absent)* | `dx`, `dy` | new (wheel) | -| *(absent)* | `dwell_ms` | new (pointer_settled) | - -**`pointer_settled` configuration flow:** - -When the first `pointer_settled` handler connects: -```python -plot._state["pointer_settled_ms"] = ms # configured threshold -plot._state["pointer_settled_delta"] = delta # configured threshold -plot._push() # JS activates timer -``` -When the last `pointer_settled` handler disconnects: -```python -plot._state["pointer_settled_ms"] = 0 # JS deactivates timer -plot._push() -``` - -**`CallbackRegistry` additions:** -1. Multi-type registration: `add_event_handler(fn, "a", "b")` registers `fn` under both internally; `remove_handler(fn)` removes from all registered types -2. Order-based priority: handlers stored as `(order, fn)` tuples, sorted on insert -3. Wildcard `"*"`: fires for every event type dispatched -4. `stop_propagation`: existing — `event.stop_propagation = True` in a handler halts remaining handlers - -### Pause and Hold - -Both are context managers implemented on `CallbackRegistry` and exposed on every plot and widget. - -**Pause (suppress):** -```python -with plot.pause_events(): # suppress all types - update_all_panels() - -with plot.pause_events("pointer_move"): # suppress specific types - do_something() -``` - -**Hold (buffer + flush):** -```python -with plot.hold_events(): # buffer all types, flush on exit - do_something() - -with plot.hold_events("pointer_settled"): # buffer specific types only - do_something() -``` - -**Nesting:** both use a depth counter — pause/hold only fully lifts when the outermost context exits. - -**Precedence:** if both are active for the same event type, pause wins — events are dropped, not buffered. - -**`CallbackRegistry` internal state:** -- `_pause_types: set[str]` — event types currently suppressed -- `_pause_depth: int` — nesting depth counter -- `_hold_types: set[str]` — event types currently buffered -- `_hold_depth: int` — nesting depth counter -- `_held_events: deque[Event]` — ordered buffer of held events - -`fire()` checks pause first (drop), then hold (queue), then dispatch. - ---- - -## Section 5: Testing Plan - -### Tier 1 — Pure Python, no browser - -**`CallbackRegistry` unit tests:** -- Multi-type registration fires handler for both types -- Wildcard `"*"` receives every event type dispatched -- Lower `order` fires before higher; same order fires in registration order -- `remove_handler` by CID -- `remove_handler` by callback reference + types -- `remove_handler` by callback reference alone removes from all types -- `stop_propagation` halts dispatch mid-handler-list -- `pause_events()`: events dropped, handlers intact after context exit -- `hold_events()`: events queued, fire in order on exit -- Pause inside hold: paused types are dropped (not buffered) -- Nested hold: depth counter lifts only on outermost exit -- `pointer_settled` params set in panel state on first connect, cleared on last disconnect - -**`Event` dataclass tests:** -- Universal fields present on every event -- `modifiers` is always a `list`, never `None` -- `time_stamp` is always set -- Plot3D events carry `ray`, not `xdata`/`ydata` -- PlotBar `pointer_down` carries bar metadata and coordinates -- `pointer_settled` carries `dwell_ms ≥` configured threshold -- `pointer_enter`/`pointer_leave` carry `buttons` (bitmask) but `button` is `None` - -### Tier 2 — Playwright browser tests - -One matrix per plot type (Plot1D, Plot2D, PlotMesh, Plot3D, PlotBar): - -| Test | Verified | -|------|---------| -| `pointer_down` | fires on mousedown; correct `x/y`, `button=0`, `buttons=1`, `xdata/ydata` | -| `pointer_up` | fires on mouseup; `button=0`, `buttons=0` | -| `pointer_move` | fires during drag; `xdata/ydata` update correctly | -| `pointer_enter/leave` | fire when mouse crosses panel boundary | -| `double_click` | fires on dblclick; same fields as `pointer_down` | -| `wheel` | fires on scroll; `dx/dy` non-zero | -| `key_down/key_up` | fire on keypress/release; `key` field correct | -| `modifiers` | ctrl+click produces `modifiers=["ctrl"]` | -| `pointer_settled` | fires after configured ms; does NOT fire if pointer moves beyond delta | - -**Plot1D-specific:** -- `pointer_move` over a line sets `line_id`; off a line sets `line_id=None` -- `pointer_down` on a line sets `line_id` -- Line handle's `add_event_handler` filters correctly — handler on `line2` does not fire when pointer is over `line1` - -**`pointer_settled`-specific:** -- Does not fire when no handler connected (JS timer flag absent from panel state) -- `dwell_ms` on the event is ≥ configured `ms` -- Fires again after pointer moves and re-settles (resets correctly) -- Two panels with different `ms`/`delta` thresholds behave independently - -**Pause/Hold integration:** -- `pause_events()` during drag: `pointer_move` does not reach handler -- `hold_events()` during drag: events fire in order on context exit -- Type-specific hold: `hold_events("pointer_settled")` buffers settled but fires `pointer_move` immediately - -### Tier 3 — Regression - -- `on_click`, `on_changed`, `on_release`, `on_key` raise `AttributeError` (old names removed) -- `event.phys_x`, `event.phys_y` raise `AttributeError` (renamed to `xdata`/`ydata`) -- All `Examples/` files run without error after event handler updates - ---- - -## Summary of Changes - -| Area | Change | -|------|--------| -| Event names | 5 renamed, 8 new added | -| Event fields | `phys_x/y` → `xdata/ydata`, `mouse_x/y` → `x/y`; add `modifiers`, `button`, `buttons`, `time_stamp`, `ray`, `dx/dy`, `dwell_ms` | -| Connection API | `add_event_handler` / `remove_handler`; multi-type, wildcard, priority | -| `pointer_settled` | Configurable `ms`/`delta` per panel; zero cost when unused | -| Pause/Hold | Context managers on every plot and widget | -| JS layer | 6 new event types forwarded; `registered_keys` removed; timer for `pointer_settled` | -| Removed | `on_click`, `on_changed`, `on_release`, `on_key`, `on_line_hover`, `on_line_click`, `disconnect()`, `registered_keys` | From ac6334b3f6080e8820be1f603c61d7e1d7a01b54 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 22:27:33 -0500 Subject: [PATCH 147/198] =?UTF-8?q?fix:=20address=20PR=20review=20comments?= =?UTF-8?q?=20=E2=80=94=20last=5Fwidget=5Fid=20field,=20x/y=20float=20type?= =?UTF-8?q?s,=20remove=5Fhandler=20guard,=20unused=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Examples/Interactive/plot_key_bindings.py | 2 +- anyplotlib/callbacks.py | 11 +++++++---- anyplotlib/figure/_figure.py | 3 ++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Examples/Interactive/plot_key_bindings.py b/Examples/Interactive/plot_key_bindings.py index 88b75c81..47ad388a 100644 --- a/Examples/Interactive/plot_key_bindings.py +++ b/Examples/Interactive/plot_key_bindings.py @@ -123,6 +123,6 @@ def log_key(event): ydata = event.ydata pos = f"({xdata:.1f}, {ydata:.1f})" if xdata is not None else "n/a" print(f"[key_down] key={event.key!r} img={pos}" - f" last_widget={getattr(event, 'last_widget_id', None)!r}") + f" last_widget={event.last_widget_id!r}") fig # Interactive diff --git a/anyplotlib/callbacks.py b/anyplotlib/callbacks.py index 28eceaa2..6330c1cf 100644 --- a/anyplotlib/callbacks.py +++ b/anyplotlib/callbacks.py @@ -36,7 +36,7 @@ class Event: event_type, source, time_stamp, modifiers Pointer fields (pointer_* and double_click events): - x, y — pixel coordinates within the panel + x, y — canvas coordinates within the panel (float pixels) button — 0=left 1=middle 2=right; None on move/enter/leave/settled buttons — bitmask of currently held buttons xdata, ydata — data-space coordinates (None for Plot3D) @@ -52,6 +52,7 @@ class Event: Key fields: key — key name e.g. "q", "Enter", "ArrowLeft" + last_widget_id — id of the last widget the user clicked, or None Propagation: stop_propagation — set True inside a handler to halt remaining handlers @@ -61,8 +62,8 @@ class Event: time_stamp: float = field(default_factory=time.perf_counter) modifiers: list[str] = field(default_factory=list) # Pointer - x: int | None = None - y: int | None = None + x: float | None = None + y: float | None = None button: int | None = None buttons: int = 0 xdata: float | None = None @@ -80,6 +81,7 @@ class Event: dy: float | None = None # Key key: str | None = None + last_widget_id: str | None = None # Propagation (not repr'd) stop_propagation: bool = field(default=False, repr=False) @@ -301,11 +303,12 @@ def remove_handler(self, cid_or_fn, *types: str) -> None: *types : str If given, only remove from these types. If omitted, remove from all. """ + had_settled = bool(self.callbacks._handlers.get("pointer_settled")) if isinstance(cid_or_fn, int): self.callbacks.disconnect(cid_or_fn) else: self.callbacks.disconnect_fn(cid_or_fn, *types) - if not self.callbacks._handlers.get("pointer_settled"): + if had_settled and not self.callbacks._handlers.get("pointer_settled"): self._configure_pointer_settled(0, 0) def _configure_pointer_settled(self, ms: int, delta: float) -> None: diff --git a/anyplotlib/figure/_figure.py b/anyplotlib/figure/_figure.py index e8caff1d..0a93ee4b 100644 --- a/anyplotlib/figure/_figure.py +++ b/anyplotlib/figure/_figure.py @@ -16,7 +16,7 @@ from anyplotlib.axes import Axes, InsetAxes from anyplotlib.axes._inset_axes import _plot_kind from anyplotlib.figure._gridspec import SubplotSpec -from anyplotlib.callbacks import CallbackRegistry, Event +from anyplotlib.callbacks import Event from anyplotlib._repr_utils import repr_html_iframe _HERE = pathlib.Path(__file__).parent.parent @@ -412,6 +412,7 @@ def _dispatch_event(self, raw: str) -> None: dx=msg.get("dx"), dy=msg.get("dy"), key=msg.get("key"), + last_widget_id=msg.get("last_widget_id"), ) plot.callbacks.fire(event) From 96df3397c311c03cdeebf0050b228cc06f3b43cc Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 22:38:37 -0500 Subject: [PATCH 148/198] =?UTF-8?q?docs:=20add=20events.rst=20=E2=80=94=20?= =?UTF-8?q?event=20system=20guide=20with=20Matplotlib/pygfx=20compare=20an?= =?UTF-8?q?d=20implementation-status=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api/index.rst | 6 +- docs/events.rst | 630 +++++++++++++++++++++++++++++++++++++++++++++ docs/index.rst | 12 + 3 files changed, 645 insertions(+), 3 deletions(-) create mode 100644 docs/events.rst diff --git a/docs/api/index.rst b/docs/api/index.rst index 8cdb2ad0..6391393d 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -71,9 +71,9 @@ directly to a class or function. :octicon:`bell;2em;sd-text-info` Callbacks ^^^ - The :class:`~anyplotlib.CallbackRegistry` two-tier event system - (``on_change`` for live frames, ``on_release`` for settled state) - and the :class:`~anyplotlib.Event` dataclass. + The :class:`~anyplotlib.CallbackRegistry` event engine and the + flat :class:`~anyplotlib.Event` dataclass. + See :doc:`../events` for the full event-system guide. Figure diff --git a/docs/events.rst b/docs/events.rst new file mode 100644 index 00000000..c896449c --- /dev/null +++ b/docs/events.rst @@ -0,0 +1,630 @@ +.. _events: + +============ +Event System +============ + +anyplotlib uses a unified event system inspired by +`pygfx/rendercanvas `_ with +anyplotlib-specific extensions. Every plot class (:class:`~anyplotlib.Plot1D`, +:class:`~anyplotlib.Plot2D`, :class:`~anyplotlib.PlotMesh`, +:class:`~anyplotlib.Plot3D`, :class:`~anyplotlib.PlotBar`) and every +interactive widget shares the same API. + + +Quick start +----------- + +.. code-block:: python + + import numpy as np + import anyplotlib as apl + + fig, ax = apl.subplots(1, 1, figsize=(600, 400)) + plot = ax.imshow(np.random.default_rng(0).standard_normal((128, 128))) + + # Direct call + def on_press(event): + print(f"clicked at data ({event.xdata:.2f}, {event.ydata:.2f})") + + plot.add_event_handler(on_press, "pointer_down") + + # Decorator form — equivalent + @plot.add_event_handler("pointer_down") + def on_press(event): + print(f"clicked at data ({event.xdata:.2f}, {event.ydata:.2f})") + + # Multiple types in one call + @plot.add_event_handler("pointer_down", "pointer_up") + def on_press_release(event): + print(event.event_type, event.button) + + # Wildcard — fires for every event type + @plot.add_event_handler("*") + def log_all(event): + print(event) + + # Remove by CID + cid = plot.add_event_handler(on_press, "pointer_down") + plot.remove_handler(cid) + + # Remove by function reference + plot.remove_handler(on_press) + + +Event types +----------- + +.. list-table:: + :header-rows: 1 + :widths: 22 78 + + * - Event type + - Trigger + * - ``pointer_down`` + - Mouse button pressed inside the panel. + * - ``pointer_up`` + - Mouse button released. + * - ``pointer_move`` + - Pointer moved (drag or hover). + * - ``pointer_settled`` + - Pointer held still for ≥ *ms* milliseconds within ± *delta* pixels. + Zero-cost when no handler is connected (timer never created). + * - ``pointer_enter`` + - Cursor enters the panel. + * - ``pointer_leave`` + - Cursor leaves the panel. + * - ``double_click`` + - Double-click. + * - ``wheel`` + - Scroll wheel or trackpad pinch. + * - ``key_down`` + - Key pressed while panel has focus. + * - ``key_up`` + - Key released. + * - ``*`` + - Wildcard — handler receives every dispatched event type. + + +``pointer_settled`` thresholds +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + # Default: 300 ms dwell, 4-pixel radius + @plot.add_event_handler("pointer_settled") + def on_settle(event): + update_tooltip(event.xdata, event.ydata) + + # Custom thresholds + @plot.add_event_handler("pointer_settled", ms=500, delta=8) + def on_settle_slow(event): + run_expensive_query(event.xdata, event.ydata) + +The timer is activated when the first ``pointer_settled`` handler connects and +deactivated (zeroed out) when the last one disconnects, so there is no JS +overhead when the event is unused. + + +Event object +------------ + +Every handler receives a single :class:`~anyplotlib.callbacks.Event` instance. +All fields are top-level attributes — there is no nested ``data`` dict. + +Universal fields (every event) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 18 18 64 + + * - Field + - Type + - Description + * - ``event_type`` + - ``str`` + - e.g. ``"pointer_down"``, ``"key_up"`` + * - ``source`` + - ``object`` + - The plot or widget that fired the event. + * - ``time_stamp`` + - ``float`` + - ``perf_counter()`` at fire time (seconds). + * - ``modifiers`` + - ``list[str]`` + - Active modifier keys: ``"ctrl"``, ``"shift"``, ``"alt"``, ``"meta"``. + Empty list when none held. + * - ``stop_propagation`` + - ``bool`` + - Set to ``True`` inside a handler to prevent remaining handlers + in the same dispatch from running. + +Pointer fields +~~~~~~~~~~~~~~ + +Present on ``pointer_down``, ``pointer_up``, ``pointer_move``, +``pointer_settled``, ``pointer_enter``, ``pointer_leave``, ``double_click``. + +.. list-table:: + :header-rows: 1 + :widths: 18 18 64 + + * - Field + - Type + - Description + * - ``x``, ``y`` + - ``float | None`` + - Canvas pixel coordinates within the panel. + * - ``button`` + - ``int | None`` + - Which button was pressed or released: 0 = left, 1 = middle, 2 = right. + ``None`` on ``pointer_move``, ``pointer_enter``, ``pointer_leave``, + ``pointer_settled``. + * - ``buttons`` + - ``int`` + - Bitmask of *currently held* buttons (useful on ``pointer_enter`` to + detect dragging into the panel). + * - ``xdata``, ``ydata`` + - ``float | None`` + - Data-space coordinates. Available on Plot1D, Plot2D, PlotMesh. + ``None`` on Plot3D (use ``ray`` instead) and PlotBar. + * - ``ray`` + - ``dict | None`` + - Plot3D only: ``{"origin": [x,y,z], "direction": [dx,dy,dz]}``. + ``None`` on all other plot types. + * - ``line_id`` + - ``str | None`` + - Plot1D only. Set to the overlay line's ID when the pointer is over a + named line; ``None`` when over the primary line or empty space. + * - ``dwell_ms`` + - ``float | None`` + - ``pointer_settled`` only: actual elapsed dwell time in milliseconds. + +PlotBar additional fields on ``pointer_down`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 20 18 62 + + * - Field + - Type + - Description + * - ``bar_index`` + - ``int | None`` + - Index of the bar that was pressed; ``None`` when the press missed all + bars. + * - ``value`` + - ``float | None`` + - Bar height at ``bar_index``; ``None`` on miss. + * - ``x_label`` + - ``str | None`` + - Category label of the pressed bar; ``None`` when none is set or on miss. + * - ``group_index`` + - ``int | None`` + - Group index for grouped-bar charts; ``None`` for simple bars or on miss. + +Wheel fields +~~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 18 18 64 + + * - Field + - Type + - Description + * - ``x``, ``y`` + - ``float | None`` + - Pointer position at scroll time. + * - ``dx``, ``dy`` + - ``float | None`` + - Scroll deltas (positive = down/right). + +Key fields +~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 18 18 64 + + * - Field + - Type + - Description + * - ``key`` + - ``str | None`` + - Key name: ``"q"``, ``"Enter"``, ``"ArrowLeft"``, etc. (DOM + ``KeyboardEvent.key`` values). + * - ``x``, ``y`` + - ``float | None`` + - Pointer position at keypress time (useful for placing UI elements at + the cursor). + * - ``last_widget_id`` + - ``str | None`` + - ID of the last overlay widget the user clicked, or ``None``. + Lets key handlers operate on the most-recently-selected widget. + + +Per-line filtering on Plot1D +---------------------------- + +Lines returned by :meth:`~anyplotlib.Plot1D.add_line` expose their own +``add_event_handler``. Internally this connects to the plot-level +``pointer_move`` / ``pointer_down`` and filters by ``line_id``, so no new +mechanism is required. + +.. code-block:: python + + t = np.linspace(0, 2 * np.pi, 256) + fig, ax = apl.subplots(1, 1, figsize=(600, 300)) + plot = ax.plot(np.sin(t)) + overlay = plot.add_line(np.cos(t), color="#ff7043") + + @overlay.add_event_handler("pointer_down") + def on_pick(event): + print(f"picked overlay line at xdata={event.xdata:.3f}") + + +Pause and hold +-------------- + +Both are context managers available on every plot and widget. + +**Pause** (suppress — events are dropped): + +.. code-block:: python + + with plot.pause_events(): # suppress all types + update_all_panels() + + with plot.pause_events("pointer_move"): # suppress specific type(s) + do_something() + +**Hold** (buffer — events are queued and flushed on exit): + +.. code-block:: python + + with plot.hold_events(): # buffer all types + do_something() + + with plot.hold_events("pointer_settled"): # buffer specific type(s) only + do_something() + +Both support nesting via a depth counter. When both are active for the same +type, *pause wins*: events are dropped, not buffered. + + +Priority ordering +----------------- + +Handlers fire in ascending ``order`` value (default ``0``). Lower values fire +first: + +.. code-block:: python + + plot.add_event_handler(fast_handler, "pointer_move", order=-1) + plot.add_event_handler(normal_handler, "pointer_move") # order=0 + plot.add_event_handler(slow_handler, "pointer_move", order=1) + + +Comparison with Matplotlib and pygfx +------------------------------------- + +Design goals: align naming with pygfx/rendercanvas (which inherits from DOM +conventions), fill the gaps in the old ``on_click``/``on_release``/``on_key`` +API, and add anyplotlib-specific extensions (``pointer_settled``, +``pause_events``, ``hold_events``). + +API mapping +~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 30 35 35 + + * - anyplotlib (new) + - Matplotlib equivalent + - pygfx / rendercanvas equivalent + * - ``add_event_handler(fn, "pointer_down")`` + - ``fig.canvas.mpl_connect("button_press_event", fn)`` + - ``renderer.add_event_handler(fn, "pointer_down")`` + * - ``add_event_handler(fn, "pointer_up")`` + - ``fig.canvas.mpl_connect("button_release_event", fn)`` + - ``renderer.add_event_handler(fn, "pointer_up")`` + * - ``add_event_handler(fn, "pointer_move")`` + - ``fig.canvas.mpl_connect("motion_notify_event", fn)`` + - ``renderer.add_event_handler(fn, "pointer_move")`` + * - ``add_event_handler(fn, "pointer_settled", ms=300)`` + - *(no equivalent — requires manual timer)* + - *(no equivalent)* + * - ``add_event_handler(fn, "pointer_enter")`` + - ``fig.canvas.mpl_connect("axes_enter_event", fn)`` + - ``renderer.add_event_handler(fn, "pointer_enter")`` + * - ``add_event_handler(fn, "pointer_leave")`` + - ``fig.canvas.mpl_connect("axes_leave_event", fn)`` + - ``renderer.add_event_handler(fn, "pointer_leave")`` + * - ``add_event_handler(fn, "double_click")`` + - *(detect via button_press_event + dblclick guard)* + - ``renderer.add_event_handler(fn, "double_click")`` + * - ``add_event_handler(fn, "wheel")`` + - ``fig.canvas.mpl_connect("scroll_event", fn)`` + - ``renderer.add_event_handler(fn, "wheel")`` + * - ``add_event_handler(fn, "key_down")`` + - ``fig.canvas.mpl_connect("key_press_event", fn)`` + - ``renderer.add_event_handler(fn, "key_down")`` + * - ``add_event_handler(fn, "key_up")`` + - ``fig.canvas.mpl_connect("key_release_event", fn)`` + - ``renderer.add_event_handler(fn, "key_up")`` + * - ``add_event_handler(fn, "*")`` + - *(no wildcard — register for each type separately)* + - ``renderer.add_event_handler(fn, "*")`` + * - ``plot.pause_events()`` + - *(no equivalent)* + - *(no equivalent)* + * - ``plot.hold_events()`` + - *(no equivalent)* + - *(no equivalent)* + * - ``remove_handler(cid)`` + - ``fig.canvas.mpl_disconnect(cid)`` + - ``renderer.remove_event_handler(fn, "pointer_down")`` + +Event field mapping +~~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 28 35 37 + + * - anyplotlib field + - Matplotlib equivalent + - pygfx equivalent + * - ``event.xdata``, ``event.ydata`` + - ``event.xdata``, ``event.ydata`` + - ``event.x``, ``event.y`` *(data-space)* + * - ``event.x``, ``event.y`` + - ``event.x``, ``event.y`` *(canvas pixels)* + - ``event.x``, ``event.y`` *(canvas pixels)* + * - ``event.button`` + - ``event.button`` (1=left, 2=middle, 3=right) + - ``event.button`` (0=left, 1=middle, 2=right) + * - ``event.modifiers`` + - ``event.key`` *(only first modifier)* + - ``event.modifiers`` *(list)* + * - ``event.key`` + - ``event.key`` + - ``event.key`` + * - ``event.dwell_ms`` + - *(absent)* + - *(absent)* + * - ``event.line_id`` + - *(absent — use pick_event)* + - *(absent)* + * - ``event.bar_index`` + - *(absent — use pick_event)* + - *(absent)* + * - ``event.ray`` + - *(absent)* + - *(absent — 3-D not a focus of rendercanvas)* + +.. note:: + Matplotlib uses a 1-based button numbering (1=left, 2=middle, 3=right). + anyplotlib and pygfx both follow the DOM convention (0=left, 1=middle, + 2=right). + + +Implementation status +--------------------- + +The table below tracks what is implemented, partially implemented, or planned +for each event type and plot class. ✓ = fully implemented, +◑ = partial / known gap, ✗ = not yet implemented. + +Event types +~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 22 16 16 16 16 14 + + * - Event type + - Plot1D + - Plot2D + - PlotMesh + - Plot3D + - PlotBar + * - ``pointer_down`` + - ✓ |br| *(on mouseup,* |br| *click-detection)* + - ✓ |br| *(on mouseup,* |br| *click-detection)* + - ✓ |br| *(on mouseup,* |br| *click-detection)* + - ✗ |br| *(drag start,* |br| *not emitted)* + - ✓ |br| *(on mousedown)* + * - ``pointer_up`` + - ✓ + - ✓ + - ✓ + - ✓ + - ✗ + * - ``pointer_move`` + - ✓ + - ✓ + - ✓ + - ✓ + - ✓ + * - ``pointer_settled`` + - ✓ + - ✓ + - ✓ + - ✓ + - ✓ + * - ``pointer_enter`` + - ✓ + - ✓ + - ✓ + - ✓ + - ✓ + * - ``pointer_leave`` + - ✓ + - ✓ + - ✓ + - ✓ + - ✓ + * - ``double_click`` + - ✓ + - ✓ + - ✓ + - ✗ + - ✓ + * - ``wheel`` + - ✓ + - ✓ + - ✓ + - ✓ + - ✓ + * - ``key_down`` + - ✓ + - ✓ + - ✓ + - ✓ + - ✓ + * - ``key_up`` + - ✓ + - ✓ + - ✓ + - ✓ + - ✓ + +.. |br| raw:: html + +
+ +Event fields +~~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 22 16 16 16 16 14 + + * - Field + - Plot1D + - Plot2D + - PlotMesh + - Plot3D + - PlotBar + * - ``x``, ``y`` *(canvas px)* + - ✓ + - ✓ + - ✓ + - ✓ + - ✓ + * - ``button`` + - ✓ + - ✓ + - ✓ + - ✓ + - ✓ + * - ``buttons`` + - ✓ + - ✓ + - ✓ + - ✓ + - ✓ + * - ``modifiers`` + - ✓ + - ✓ + - ✓ + - ✓ + - ✓ + * - ``xdata``, ``ydata`` + - ✓ + - ✓ + - ✓ + - ✗ *(always None)* + - ✗ *(always None)* + * - ``ray`` + - ✗ *(always None)* + - ✗ *(always None)* + - ✗ *(always None)* + - ✗ *(not yet impl.)* + - ✗ *(always None)* + * - ``line_id`` + - ✓ + - n/a + - n/a + - n/a + - n/a + * - ``dwell_ms`` + - ✓ + - ✓ + - ✓ + - ✓ + - ✓ + * - ``bar_index``, ``value``, |br| ``x_label``, ``group_index`` + - n/a + - n/a + - n/a + - n/a + - ✓ *(pointer_down only)* + * - ``dx``, ``dy`` + - ✓ *(wheel only)* + - ✓ *(wheel only)* + - ✓ *(wheel only)* + - ✓ *(wheel only)* + - ✓ *(wheel only)* + * - ``last_widget_id`` + - ✓ *(key events)* + - ✓ *(key events)* + - ✓ *(key events)* + - ✓ *(key events)* + - ✓ *(key events)* + +Known gaps and planned work +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + :widths: 40 60 + + * - Gap + - Notes + * - **Plot3D** ``pointer_down`` not emitted + - Mousedown starts azimuth/elevation drag; a separate + ``pointer_down`` signal is not yet emitted. Tracked as a known + limitation. + * - **Plot3D** ``double_click`` not emitted + - The dblclick DOM listener is not attached to the 3-D canvas. + * - **Plot3D** ``pointer_up`` emits on document ``mouseup`` + - Works correctly but always emits even if the press started outside + the panel. + * - ``ray`` field not populated on Plot3D + - The ``{"origin": …, "direction": …}`` 3-D ray-cast is not yet + computed; the field is always ``None``. + * - ``pointer_down`` on Plot1D/2D/PlotMesh uses click-detection + - Fires on ``mouseup`` after a short-distance, short-duration + gesture — not on the raw ``mousedown``. This matches typical + click semantics but differs from the DOM ``mousedown`` event. + * - PlotBar ``pointer_up`` not emitted + - The bar canvas has no ``mouseup`` listener; only ``pointer_down`` + (on ``mousedown``) is emitted. + * - Touch events not supported + - ``pointer_down`` / ``pointer_move`` / ``pointer_up`` are currently + mouse-only; touch and stylus events are not forwarded. + + +API Reference +------------- + +.. seealso:: + + :class:`~anyplotlib.callbacks.Event` + Full field reference for the event dataclass. + + :class:`~anyplotlib.CallbackRegistry` + Low-level registry: ``connect``, ``disconnect``, ``fire``, + ``pause_events``, ``hold_events``. + + :doc:`api/callbacks` + Autogenerated API documentation for both classes. + + :doc:`auto_examples/index` + Gallery of interactive examples using ``add_event_handler``. diff --git a/docs/index.rst b/docs/index.rst index 5c87b902..73172960 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -89,6 +89,17 @@ blitting instead of SVG. baselines, best practices, and the CI strategy that makes timing comparisons hardware-agnostic. + .. grid-item-card:: + :link: events + :link-type: doc + + :octicon:`zap;2em;sd-text-info` Event System + ^^^ + + Interactive event handling — ``pointer_down``, ``pointer_settled``, + ``key_down``, and more. Includes a comparison with Matplotlib and + pygfx and an implementation-status table. + .. grid-item-card:: :link: dev/index :link-type: doc @@ -104,6 +115,7 @@ blitting instead of SVG. :maxdepth: 2 getting_started + events api/index auto_examples/index Performance From 245ec19536a387e5f9dd8a6d228e40e0f6ca3edf Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sun, 17 May 2026 22:43:42 -0500 Subject: [PATCH 149/198] =?UTF-8?q?test:=20fix=20flaky=20test=5Ffires=5Fag?= =?UTF-8?q?ain=5Fafter=5Fre=5Fsettle=20=E2=80=94=20poll=20with=20wait=5Ffo?= =?UTF-8?q?r=5Ffunction=20instead=20of=20fixed=20sleep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test_interactive/test_event_settled.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/anyplotlib/tests/test_interactive/test_event_settled.py b/anyplotlib/tests/test_interactive/test_event_settled.py index 7bad0626..8655af6f 100644 --- a/anyplotlib/tests/test_interactive/test_event_settled.py +++ b/anyplotlib/tests/test_interactive/test_event_settled.py @@ -163,18 +163,21 @@ def test_fires_again_after_re_settle(self, interact_page): page, plot, received = self._make_page(interact_page, ms=200) px, py = _plot_center_page() - # First dwell - page.mouse.move(px, py) - page.wait_for_timeout(300) + def _settled_count(): + return "() => window._aplAllEvents.filter(e => e.event_type === 'pointer_settled').length" - first_count = len(_get_events(page, "pointer_settled")) - assert first_count >= 1, "First pointer_settled should have fired" + # First dwell — wait for the event rather than sleeping a fixed amount + page.mouse.move(px, py) + page.wait_for_function(f"{_settled_count()} >= 1", timeout=2000) + assert len(_get_events(page, "pointer_settled")) >= 1, ( + "First pointer_settled should have fired" + ) - # Move away to reset the timer, then hold again + # Move away to reset the timer, then hold for a second dwell period page.mouse.move(px + 30, py + 30) - page.wait_for_timeout(50) + page.wait_for_timeout(50) # ensure the move is processed before re-entering page.mouse.move(px, py) - page.wait_for_timeout(300) + page.wait_for_function(f"{_settled_count()} >= 2", timeout=2000) second_count = len(_get_events(page, "pointer_settled")) assert second_count >= 2, ( From c02e351a31e455532b4fb51e2f829c5098fdebf3 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 18 May 2026 09:52:31 -0500 Subject: [PATCH 150/198] feat: add plot_particle_picker.py EM interactive example --- Examples/Interactive/plot_particle_picker.py | 194 +++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 Examples/Interactive/plot_particle_picker.py diff --git a/Examples/Interactive/plot_particle_picker.py b/Examples/Interactive/plot_particle_picker.py new file mode 100644 index 00000000..68aa270c --- /dev/null +++ b/Examples/Interactive/plot_particle_picker.py @@ -0,0 +1,194 @@ +""" +HAADF STEM nanoparticle picker. + +Dwell over a candidate peak (300 ms) to inspect its sub-pixel centroid, +peak intensity, and estimated FWHM. Double-click to confirm a pick (green +ring). Shift+double-click marks it as uncertain (orange ring). +Delete/Backspace removes the confirmed pick nearest the cursor. ``c`` clears +all picks. +""" +import numpy as np +import anyplotlib as apl + + +# ── synthetic data ───────────────────────────────────────────────────────────── + +def _make_stem_image(rng: np.random.Generator) -> np.ndarray: + img = rng.poisson(lam=5, size=(512, 512)).astype(np.float32) + for _ in range(18): + cx, cy = rng.integers(30, 482, size=2) + sigma = rng.uniform(4, 9) + peak = rng.uniform(80, 200) + r = int(np.ceil(3 * sigma)) + y0, y1 = max(0, cy - r), min(512, cy + r + 1) + x0, x1 = max(0, cx - r), min(512, cx + r + 1) + ys = np.arange(y0, y1)[:, None] + xs = np.arange(x0, x1)[None, :] + img[y0:y1, x0:x1] += peak * np.exp( + -((xs - cx) ** 2 + (ys - cy) ** 2) / (2 * sigma ** 2) + ) + return np.clip(img, 0, 255).astype(np.float32) + + +def _find_candidates(img: np.ndarray) -> list[tuple[int, int]]: + """Local maxima via 7x7 sliding-window max filter (pure NumPy).""" + from numpy.lib.stride_tricks import sliding_window_view + pad = 3 + padded = np.pad(img, pad, mode="edge") + windows = sliding_window_view(padded, (7, 7)) + local_max = windows.max(axis=(-2, -1)) + mask = (img == local_max) & (img > 20) + ys, xs = np.where(mask) + return list(zip(xs.tolist(), ys.tolist())) + + +def _parabolic_centroid(img: np.ndarray, r: int, c: int) -> tuple[float, float]: + def _delta(left, center, right): + denom = 2 * (2 * center - left - right) + return 0.0 if abs(denom) < 1e-6 else (right - left) / denom + + dc = _delta(float(img[r, c - 1]), float(img[r, c]), float(img[r, c + 1])) + dr = _delta(float(img[r - 1, c]), float(img[r, c]), float(img[r + 1, c])) + return c + dc, r + dr + + +def _gaussian_fwhm(profile: np.ndarray) -> float: + p = np.clip(profile.astype(float), 1e-6, None) + peak_idx = int(np.argmax(p)) + if peak_idx == 0 or peak_idx >= len(p) - 1: + return 2.0 + try: + a, b, c_ = np.log(p[peak_idx - 1]), np.log(p[peak_idx]), np.log(p[peak_idx + 1]) + sigma = np.sqrt(-1.0 / (2 * (a + c_ - 2 * b))) + except Exception: + return 2.0 + return 2.355 * abs(sigma) + + +def _safe_remove(plot, marker_type: str, name: str) -> None: + try: + plot.remove_marker(marker_type, name) + except KeyError: + pass + + +# ── build data ───────────────────────────────────────────────────────────────── + +rng = np.random.default_rng(42) +image = _make_stem_image(rng) +candidates = _find_candidates(image) + +# ── figure ───────────────────────────────────────────────────────────────────── + +fig, ax = apl.subplots(1, 1, figsize=(640, 640)) +plot = ax.imshow(image, cmap="gray") + +if candidates: + cand_arr = np.array(candidates, dtype=float) + plot.add_circles(cand_arr, name="candidates", radius=6, + facecolors="none", edgecolors="#555555") + +info_label = plot.add_widget("label", x=10, y=10, text="", color="#00e5ff", fontsize=11) + +picks: list[dict] = [] + + +# ── helpers ──────────────────────────────────────────────────────────────────── + +def _redraw_picks() -> None: + _safe_remove(plot, "circles", "picks_certain") + _safe_remove(plot, "circles", "picks_uncertain") + certain = [p for p in picks if not p["uncertain"]] + uncertain = [p for p in picks if p["uncertain"]] + if certain: + arr = np.array([[p["cx"], p["cy"]] for p in certain]) + plot.add_circles(arr, name="picks_certain", radius=10, + facecolors="none", edgecolors="#00ff88") + if uncertain: + arr = np.array([[p["cx"], p["cy"]] for p in uncertain]) + plot.add_circles(arr, name="picks_uncertain", radius=10, + facecolors="none", edgecolors="#ff9100") + + +def _nearest_candidate(x: float, y: float, max_dist: float = 12.0): + best, best_d = None, max_dist + for cx, cy in candidates: + d = float(np.hypot(cx - x, cy - y)) + if d < best_d: + best, best_d = (cx, cy), d + return best + + +def _nearest_pick_idx(x: float, y: float) -> int | None: + if not picks: + return None + dists = [float(np.hypot(p["cx"] - x, p["cy"] - y)) for p in picks] + return int(np.argmin(dists)) + + +def _inspect(cx_f: float, cy_f: float) -> tuple[float, float, float, float]: + """Return (sub_cx, sub_cy, intensity, fwhm) for the pixel at (cx_f, cy_f).""" + r = int(np.clip(round(cy_f), 4, 507)) + c = int(np.clip(round(cx_f), 4, 507)) + sub_cx, sub_cy = _parabolic_centroid(image, r, c) + intensity = float(image[r, c]) + row_profile = image[r, max(0, c - 4):min(512, c + 5)] + col_profile = image[max(0, r - 4):min(512, r + 5), c] + fwhm = (_gaussian_fwhm(row_profile) + _gaussian_fwhm(col_profile)) / 2 + return sub_cx, sub_cy, intensity, fwhm + + +# ── event handlers ───────────────────────────────────────────────────────────── + +def _on_settled(event) -> None: + hit = _nearest_candidate(event.xdata, event.ydata) + if hit is None: + info_label.set(text="") + return + hx, hy = hit + sub_cx, sub_cy, intensity, fwhm = _inspect(hx, hy) + info_label.set( + text=f"centroid ({sub_cx:.1f}, {sub_cy:.1f})\npeak {intensity:.0f}\nFWHM {fwhm:.2f} px", + x=hx + 12, + y=hy - 30, + ) + + +def _on_double_click(event) -> None: + hit = _nearest_candidate(event.xdata, event.ydata) + if hit is None: + return + sub_cx, sub_cy, intensity, fwhm = _inspect(*hit) + uncertain = "shift" in event.modifiers + picks.append({"cx": sub_cx, "cy": sub_cy, "intensity": intensity, + "fwhm": fwhm, "uncertain": uncertain}) + _redraw_picks() + tag = "uncertain" if uncertain else "certain" + print(f"Pick #{len(picks)} [{tag}]: ({sub_cx:.1f}, {sub_cy:.1f}) " + f"peak={intensity:.0f} FWHM={fwhm:.2f} px") + + +def _on_key(event) -> None: + if event.key in ("Delete", "Backspace"): + x = event.xdata if event.xdata is not None else 256.0 + y = event.ydata if event.ydata is not None else 256.0 + idx = _nearest_pick_idx(x, y) + if idx is not None: + picks.pop(idx) + _redraw_picks() + elif event.key == "c": + picks.clear() + _redraw_picks() + + +plot.add_event_handler(_on_settled, "pointer_settled", ms=300, delta=6) +plot.add_event_handler(_on_double_click, "double_click") +plot.add_event_handler(_on_key, "key_down") + +fig.set_help( + "Dwell 300 ms: inspect peak\n" + "Double-click: confirm pick (green)\n" + "Shift+double-click: uncertain pick (orange)\n" + "Delete / Backspace: remove nearest pick\n" + "c: clear all picks" +) From d7f59cfdec8288e5be47292e13acf502d8a1fa10 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 18 May 2026 10:03:24 -0500 Subject: [PATCH 151/198] feat: add plot_eels_explorer.py EM interactive example --- Examples/Interactive/plot_eels_explorer.py | 196 +++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 Examples/Interactive/plot_eels_explorer.py diff --git a/Examples/Interactive/plot_eels_explorer.py b/Examples/Interactive/plot_eels_explorer.py new file mode 100644 index 00000000..48ef9d89 --- /dev/null +++ b/Examples/Interactive/plot_eels_explorer.py @@ -0,0 +1,196 @@ +""" +EELS multi-spectrum explorer. + +Click a spectrum line to select it (full opacity; others dim). Dwell (250 ms) +to inspect the eV position and intensity; nearby known edges are annotated. +Double-click to place a permanent edge marker. Delete/Backspace removes the +most recently placed marker on the active spectrum. Tab / Shift+Tab cycles +the selection forward / backward. +""" +import numpy as np +import anyplotlib as apl + + +# ── synthetic data ───────────────────────────────────────────────────────────── + +ENERGY = np.linspace(50, 650, 1200) + +KNOWN_EDGES = {"C K": 284.0, "N K": 401.0, "O K": 532.0, "Ti L": 456.0} + +_SPECTRUM_DEFS = [ + {"name": "Carbon-rich", "color": "#4fc3f7", "edges": [("C K", 284, 0.6)]}, + {"name": "Nitride", "color": "#aed581", "edges": [("N K", 401, 0.5)]}, + {"name": "Oxide", "color": "#ff8a65", "edges": [("O K", 532, 0.7)]}, + {"name": "Silicide", "color": "#ba68c8", "edges": [("Si L", 99, 0.3)]}, + {"name": "Mixed", "color": "#fff176", "edges": [("C K", 284, 0.2), ("O K", 532, 0.15)]}, +] + + +def _power_law_bg(E, A=1e4, r=3.5): + return A * E ** (-r) + + +def _edge_onset(E, edge_ev, amplitude, width=20.0, decay=80.0): + onset = amplitude * (np.arctan((E - edge_ev) / (width / 6)) / np.pi + 0.5) + envelope = np.exp(-np.clip(E - edge_ev, 0, None) / decay) + return onset * envelope + + +def _make_spectrum(rng, defn, offset_y): + E = ENERGY + y = _power_law_bg(E) + for _, edge_ev, amp_frac in defn["edges"]: + peak = y.max() * amp_frac + y += _edge_onset(E, edge_ev, peak) + y += rng.normal(0, y.max() * 0.005, size=len(E)) + y = np.clip(y, 0, None) + y = y / y.max() + return y + offset_y + + +rng = np.random.default_rng(7) +spectra_y = [] +offset = 0.0 +for defn in _SPECTRUM_DEFS: + y = _make_spectrum(rng, defn, offset) + spectra_y.append(y) + offset += 1.2 * (y - offset).max() + + +# ── helpers ──────────────────────────────────────────────────────────────────── + +def _safe_remove(plot, marker_type: str, name: str) -> None: + try: + plot.remove_marker(marker_type, name) + except KeyError: + pass + + +# ── figure ───────────────────────────────────────────────────────────────────── + +# spectrum 0 is the primary line; spectra 1-4 are overlay lines +fig, ax = apl.subplots(1, 1, figsize=(800, 500)) +plot = ax.plot(spectra_y[0], axes=[ENERGY], color=_SPECTRUM_DEFS[0]["color"], linewidth=2.5) + +# overlay_lines[i] is the Line1D handle for spectrum i (None for the primary) +overlay_lines = [] +for i in range(1, len(_SPECTRUM_DEFS)): + defn = _SPECTRUM_DEFS[i] + line = plot.add_line(spectra_y[i], x_axis=ENERGY, color=defn["color"], linewidth=1.0) + overlay_lines.append(line) + +# spectra index → Line1D (or None for primary) +# lines[0] == None means "primary line", lines[1..] == Line1D handles +line_handles = [None] + overlay_lines # len == len(_SPECTRUM_DEFS) + +active_idx: int = 0 +markers_per_spectrum: list[list[str]] = [[] for _ in _SPECTRUM_DEFS] +_marker_counter = [0] + +info_label_mg = plot.add_texts( + offsets=np.array([[ENERGY[600], spectra_y[0][600]]]), + texts=[""], + name="info_label", + color="#00e5ff", + fontsize=11, +) + + +# ── selection helpers ─────────────────────────────────────────────────────────── + +def _set_overlay_line_props(lid: str, linewidth: float, alpha: float) -> None: + """Directly mutate an overlay line's entry in plot._state and push.""" + for entry in plot._state["extra_lines"]: + if entry["id"] == lid: + entry["linewidth"] = float(linewidth) + entry["alpha"] = float(alpha) + break + plot._push() + + +def _apply_selection(new_idx: int) -> None: + global active_idx + active_idx = new_idx + for i, handle in enumerate(line_handles): + if i == active_idx: + lw, alpha = 2.5, 1.0 + else: + lw, alpha = 1.0, 0.25 + if handle is None: + # primary line — use Plot1D setters + plot.set_linewidth(lw) + plot.set_alpha(alpha) + else: + _set_overlay_line_props(handle._lid, lw, alpha) + print(f"Selected: {_SPECTRUM_DEFS[active_idx]['name']}") + + +_apply_selection(0) + + +# ── event handlers ───────────────────────────────────────────────────────────── + +def _make_line_handler(idx: int): + def _handler(event) -> None: + _apply_selection(idx) + return _handler + + +# primary line click handler — line_id is None for the primary +plot.line.add_event_handler(_make_line_handler(0), "pointer_down") + +# overlay line click handlers +for i, handle in enumerate(overlay_lines, start=1): + handle.add_event_handler(_make_line_handler(i), "pointer_down") + + +def _on_settled(event) -> None: + ev = event.xdata + intensity = float(np.interp(ev, ENERGY, spectra_y[active_idx])) + label = f"eV: {ev:.1f} I: {intensity:.3f}" + for edge_name, edge_ev in KNOWN_EDGES.items(): + if abs(ev - edge_ev) < 15: + label += f"\n~ {edge_name}-edge" + y_pos = intensity + 0.05 + plot.markers["texts"]["info_label"].set( + offsets=np.array([[ev, y_pos]]), + texts=[label], + ) + + +def _on_double_click(event) -> None: + ev = event.xdata + _marker_counter[0] += 1 + name = f"edge_{active_idx}_{_marker_counter[0]}" + plot.add_vlines([ev], name=name) + markers_per_spectrum[active_idx].append(name) + print(f"Edge marker placed at {ev:.1f} eV on '{_SPECTRUM_DEFS[active_idx]['name']}'") + + +def _on_key(event) -> None: + global active_idx + if event.key in ("Delete", "Backspace"): + if not markers_per_spectrum[active_idx]: + return + name = markers_per_spectrum[active_idx].pop() + _safe_remove(plot, "vlines", name) + elif event.key == "Tab": + n = len(_SPECTRUM_DEFS) + if "shift" in event.modifiers: + new_idx = (active_idx - 1) % n + else: + new_idx = (active_idx + 1) % n + _apply_selection(new_idx) + + +plot.add_event_handler(_on_settled, "pointer_settled", ms=250) +plot.add_event_handler(_on_double_click, "double_click") +plot.add_event_handler(_on_key, "key_down") + +fig.set_help( + "Click a spectrum: select it\n" + "Dwell 250 ms: inspect eV + intensity\n" + "Double-click: place edge marker\n" + "Delete / Backspace: remove last marker\n" + "Tab / Shift+Tab: cycle selection" +) From 3bcc904a8dfdab22bd38ca921f4b5e3a7967b1b7 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 18 May 2026 10:07:37 -0500 Subject: [PATCH 152/198] feat: add plot_threshold_explorer.py EM interactive example --- .../Interactive/plot_threshold_explorer.py | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 Examples/Interactive/plot_threshold_explorer.py diff --git a/Examples/Interactive/plot_threshold_explorer.py b/Examples/Interactive/plot_threshold_explorer.py new file mode 100644 index 00000000..e6491ae4 --- /dev/null +++ b/Examples/Interactive/plot_threshold_explorer.py @@ -0,0 +1,123 @@ +""" +Live intensity thresholding on a multi-phase STEM image. + +Scroll the mouse wheel over the image to adjust the threshold (2 counts per +tick). Click a histogram bar to jump the threshold to that bin's upper edge. +Dwell (400 ms) over the image to inspect pixel intensity. The threshold mask +is shown as a red overlay; the histogram always has a vertical line at the +current threshold. +""" +import numpy as np +import anyplotlib as apl + + +# ── synthetic data ───────────────────────────────────────────────────────────── + +def _make_multiphase_image(rng: np.random.Generator) -> np.ndarray: + img = rng.normal(20, 5, (512, 512)).astype(np.float32) + + # Grain A — 6 large blobs + for _ in range(6): + cx, cy = rng.integers(60, 452, size=2) + r = rng.integers(40, 80) + ys, xs = np.ogrid[:512, :512] + mask = (xs - cx) ** 2 + (ys - cy) ** 2 < r ** 2 + img[mask] = rng.normal(80, 8, mask.sum()) + + # Grain B — 8 smaller blobs + for _ in range(8): + cx, cy = rng.integers(40, 472, size=2) + r = rng.integers(15, 35) + ys, xs = np.ogrid[:512, :512] + mask = (xs - cx) ** 2 + (ys - cy) ** 2 < r ** 2 + img[mask] = rng.normal(130, 10, mask.sum()) + + # Voids — 12 dark circular regions + for _ in range(12): + cx, cy = rng.integers(20, 492, size=2) + r = rng.integers(8, 20) + ys, xs = np.ogrid[:512, :512] + mask = (xs - cx) ** 2 + (ys - cy) ** 2 < r ** 2 + img[mask] = rng.normal(5, 2, mask.sum()) + + return np.clip(img, 0, 255).astype(np.float32) + + +rng = np.random.default_rng(13) +image = _make_multiphase_image(rng) + +NBINS = 32 +counts, bin_edges = np.histogram(image, bins=NBINS, range=(0, 255)) +bin_centers = 0.5 * (bin_edges[:-1] + bin_edges[1:]) +x_labels = [f"{int(v)}" for v in bin_centers] + +threshold = 100.0 + + +# ── figure ───────────────────────────────────────────────────────────────────── + +fig, (ax_img, ax_hist) = apl.subplots(1, 2, figsize=(900, 500)) + +img_plot = ax_img.imshow(image, cmap="gray") +hist_plot = ax_hist.bar(x_labels, counts.astype(float)) + +# Track the threshold vline widget so we can remove/replace it +_thresh_widget = None + + +def _pct_above(thresh: float) -> float: + return 100.0 * float((image >= thresh).sum()) / image.size + + +def _update_display(thresh: float) -> None: + global threshold, _thresh_widget + threshold = float(np.clip(thresh, 0, 255)) + mask = image >= threshold + img_plot.set_overlay_mask(mask, color="#ff0000", alpha=0.35) + # Remove old threshold line widget and add a new one + if _thresh_widget is not None: + try: + hist_plot.remove_widget(_thresh_widget) + except KeyError: + pass + _thresh_widget = hist_plot.add_vline_widget(threshold, color="#ffeb3b") + pct = _pct_above(threshold) + print(f"Threshold: {threshold:.0f} | {pct:.1f}% above") + + +_update_display(threshold) + +info_label = img_plot.add_widget("label", x=10, y=490, text="", color="#ffeb3b", fontsize=11) + + +# ── event handlers ───────────────────────────────────────────────────────────── + +def _on_wheel(event) -> None: + delta = -2.0 * np.sign(event.dy) if event.dy != 0 else 0.0 + _update_display(threshold + delta) + + +def _on_bar_click(event) -> None: + idx = event.bar_index + if idx is None: + return + new_thresh = float(bin_edges[idx + 1]) + _update_display(new_thresh) + + +def _on_settled(event) -> None: + x = int(np.clip(round(event.xdata), 0, 511)) + y = int(np.clip(round(event.ydata), 0, 511)) + intensity = float(image[y, x]) + info_label.set(text=f"px ({x}, {y}): {intensity:.0f}", x=10, y=490) + + +img_plot.add_event_handler(_on_wheel, "wheel") +img_plot.add_event_handler(_on_settled, "pointer_settled", ms=400, delta=4) +hist_plot.add_event_handler(_on_bar_click, "pointer_down") + +fig.set_help( + "Scroll over image: adjust threshold ±2\n" + "Click histogram bar: jump to bin upper edge\n" + "Dwell 400 ms over image: inspect pixel intensity" +) From 525baab864ae1effcd14c47d6670c274bb116357 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 18 May 2026 10:10:44 -0500 Subject: [PATCH 153/198] feat: add plot_roi_inspector.py EM interactive example --- Examples/Interactive/plot_roi_inspector.py | 166 +++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 Examples/Interactive/plot_roi_inspector.py diff --git a/Examples/Interactive/plot_roi_inspector.py b/Examples/Interactive/plot_roi_inspector.py new file mode 100644 index 00000000..a8bdc05c --- /dev/null +++ b/Examples/Interactive/plot_roi_inspector.py @@ -0,0 +1,166 @@ +""" +ROI-to-spectrum inspector for a multi-phase STEM image. + +Four rectangular ROIs are drawn on the image. Entering the image panel +activates a pixel inspector in the status label. Hovering over an ROI for +350 ms computes the mean EDS-like spectrum for that region and updates the bar +chart. Dragging an ROI pauses spectrum recomputation to avoid backlog; +releasing triggers one final recompute. +""" +import numpy as np +import anyplotlib as apl + + +# ── synthetic data ───────────────────────────────────────────────────────────── + +def _make_multiphase_image(rng: np.random.Generator) -> np.ndarray: + img = rng.normal(30, 6, (512, 512)).astype(np.float32) + + # Precipitate A (bright) + for cx, cy, r in [(120, 120, 60), (150, 100, 45), (90, 150, 40)]: + ys, xs = np.ogrid[:512, :512] + mask = (xs - cx) ** 2 + (ys - cy) ** 2 < r ** 2 + img[mask] = rng.normal(160, 12, mask.sum()) + + # Precipitate B (medium) + for cx, cy, r in [(390, 390, 55), (360, 420, 40), (420, 360, 35)]: + ys, xs = np.ogrid[:512, :512] + mask = (xs - cx) ** 2 + (ys - cy) ** 2 < r ** 2 + img[mask] = rng.normal(110, 10, mask.sum()) + + # Grain boundary (thin horizontal band, rows 240-270) + img[240:270, :] = rng.normal(70, 8, (30, 512)) + + return np.clip(img, 0, 255).astype(np.float32) + + +def _mean_eds(img_patch: np.ndarray) -> np.ndarray: + """4-channel EDS intensity proportional to local image value + noise.""" + mean_val = float(img_patch.mean()) + rng_local = np.random.default_rng(int(mean_val * 1000) % (2**31)) + weights = np.array([0.40, 0.25, 0.20, 0.15]) + spectrum = weights * mean_val + rng_local.normal(0, 2, 4) + return np.clip(spectrum / 255.0, 0, 1) + + +rng = np.random.default_rng(99) +image = _make_multiphase_image(rng) + +ROIS: dict[str, tuple[int, int, int, int]] = { + "Matrix": (50, 200, 50, 200), + "Precipitate A": (50, 200, 310, 460), + "Precipitate B": (310, 460, 50, 200), + "Grain Boundary":(240, 270, 50, 460), +} + +EDS_ELEMENTS = ["Al", "Si", "Fe", "O"] +_PLACEHOLDER = np.array([0.0, 0.0, 0.0, 0.0]) + + +# ── helpers ──────────────────────────────────────────────────────────────────── + +def _roi_at(x: float, y: float) -> str | None: + for name, (r0, r1, c0, c1) in ROIS.items(): + if c0 <= x <= c1 and r0 <= y <= r1: + return name + return None + + +# ── figure ───────────────────────────────────────────────────────────────────── + +fig, (ax_img, ax_spec) = apl.subplots(1, 2, figsize=(1000, 520)) + +img_plot = ax_img.imshow(image, cmap="gray") +spec_plot = ax_spec.bar(EDS_ELEMENTS, _PLACEHOLDER) + +# ROI rectangle widgets +_roi_widgets: dict[str, object] = {} +_ROI_COLORS = {"Matrix": "#4fc3f7", "Precipitate A": "#aed581", + "Precipitate B": "#ff8a65", "Grain Boundary": "#ba68c8"} + +for roi_name, (r0, r1, c0, c1) in ROIS.items(): + w = img_plot.add_widget( + "rectangle", + x=float(c0), y=float(r0), + w=float(c1 - c0), h=float(r1 - r0), + color=_ROI_COLORS[roi_name], + ) + _roi_widgets[roi_name] = w + +status_label = img_plot.add_widget( + "label", x=10, y=498, text="Move cursor over image to inspect", + color="#ffffff", fontsize=10, +) + +_roi_dragging = False + + +# ── spectrum update ───────────────────────────────────────────────────────────── + +def _update_spectrum(roi_name: str) -> None: + r0, r1, c0, c1 = ROIS[roi_name] + patch = image[r0:r1, c0:c1] + eds = _mean_eds(patch) + spec_plot.set_data(eds) + print(f"ROI '{roi_name}': Al={eds[0]:.3f} Si={eds[1]:.3f} Fe={eds[2]:.3f} O={eds[3]:.3f}") + + +# ── event handlers ───────────────────────────────────────────────────────────── + +def _on_enter(event) -> None: + status_label.set(text="Pixel: — Intensity: —") + + +def _on_leave(event) -> None: + status_label.set(text="Move cursor over image to inspect") + + +def _on_move(event) -> None: + x = int(np.clip(round(event.xdata), 0, 511)) + y = int(np.clip(round(event.ydata), 0, 511)) + intensity = float(image[y, x]) + status_label.set(text=f"Pixel: ({x}, {y}) Intensity: {intensity:.0f}") + + +def _on_settled(event) -> None: + if _roi_dragging: + return + roi_name = _roi_at(event.xdata, event.ydata) + if roi_name is None: + print("No ROI at cursor") + return + with img_plot.hold_events("pointer_settled"): + _update_spectrum(roi_name) + + +img_plot.add_event_handler(_on_enter, "pointer_enter") +img_plot.add_event_handler(_on_leave, "pointer_leave") +img_plot.add_event_handler(_on_move, "pointer_move") +img_plot.add_event_handler(_on_settled, "pointer_settled", ms=350) + +# ROI widget drag handlers +for roi_name, widget in _roi_widgets.items(): + def _make_drag_handler(name): + def _on_drag(event) -> None: + global _roi_dragging + _roi_dragging = True + return _on_drag + + def _make_release_handler(name, wgt): + def _on_release(event) -> None: + global _roi_dragging + _roi_dragging = False + x, y, w, h = wgt.x, wgt.y, wgt.w, wgt.h + ROIS[name] = (int(y), int(y + h), int(x), int(x + w)) + _update_spectrum(name) + return _on_release + + widget.add_event_handler(_make_drag_handler(roi_name), "pointer_move") + widget.add_event_handler(_make_release_handler(roi_name, widget), "pointer_up") + +fig.set_help( + "Move cursor over image: inspect pixel\n" + "Dwell 350 ms inside ROI: compute EDS spectrum\n" + "Drag ROI rectangle: repositions ROI\n" + "Release drag: recomputes spectrum" +) From 25b7aa1ea9db8de66a6e6e8fa32f59d08d431293 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 18 May 2026 10:11:27 -0500 Subject: [PATCH 154/198] test: add smoke tests for interactive EM example scripts --- anyplotlib/tests/test_examples/__init__.py | 0 .../test_interactive_examples.py | 26 +++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 anyplotlib/tests/test_examples/__init__.py create mode 100644 anyplotlib/tests/test_examples/test_interactive_examples.py diff --git a/anyplotlib/tests/test_examples/__init__.py b/anyplotlib/tests/test_examples/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/anyplotlib/tests/test_examples/test_interactive_examples.py b/anyplotlib/tests/test_examples/test_interactive_examples.py new file mode 100644 index 00000000..9da1699e --- /dev/null +++ b/anyplotlib/tests/test_examples/test_interactive_examples.py @@ -0,0 +1,26 @@ +"""Smoke tests: each EM example script must import and execute without error.""" +import importlib.util +import pathlib + +import pytest + +EXAMPLES_DIR = pathlib.Path(__file__).parents[3] / "Examples" / "Interactive" + +SCRIPTS = [ + "plot_particle_picker.py", + "plot_eels_explorer.py", + "plot_threshold_explorer.py", + "plot_roi_inspector.py", +] + + +def _exec_script(name: str) -> None: + path = EXAMPLES_DIR / name + spec = importlib.util.spec_from_file_location("_smoke_ex", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + +@pytest.mark.parametrize("script", SCRIPTS) +def test_example_executes(script: str) -> None: + _exec_script(script) From 0b43bb6e83715a77bfac5bd861d8598a88353a16 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Mon, 18 May 2026 10:14:36 -0500 Subject: [PATCH 155/198] fix: add xdata/ydata None guards in all EM example event handlers --- Examples/Interactive/plot_eels_explorer.py | 2 ++ Examples/Interactive/plot_particle_picker.py | 4 ++++ Examples/Interactive/plot_roi_inspector.py | 10 ++++++---- Examples/Interactive/plot_threshold_explorer.py | 2 ++ .../tests/test_examples/test_interactive_examples.py | 3 ++- 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/Examples/Interactive/plot_eels_explorer.py b/Examples/Interactive/plot_eels_explorer.py index 48ef9d89..0759062e 100644 --- a/Examples/Interactive/plot_eels_explorer.py +++ b/Examples/Interactive/plot_eels_explorer.py @@ -145,6 +145,8 @@ def _handler(event) -> None: def _on_settled(event) -> None: + if event.xdata is None: + return ev = event.xdata intensity = float(np.interp(ev, ENERGY, spectra_y[active_idx])) label = f"eV: {ev:.1f} I: {intensity:.3f}" diff --git a/Examples/Interactive/plot_particle_picker.py b/Examples/Interactive/plot_particle_picker.py index 68aa270c..31328dde 100644 --- a/Examples/Interactive/plot_particle_picker.py +++ b/Examples/Interactive/plot_particle_picker.py @@ -141,6 +141,8 @@ def _inspect(cx_f: float, cy_f: float) -> tuple[float, float, float, float]: # ── event handlers ───────────────────────────────────────────────────────────── def _on_settled(event) -> None: + if event.xdata is None or event.ydata is None: + return hit = _nearest_candidate(event.xdata, event.ydata) if hit is None: info_label.set(text="") @@ -155,6 +157,8 @@ def _on_settled(event) -> None: def _on_double_click(event) -> None: + if event.xdata is None or event.ydata is None: + return hit = _nearest_candidate(event.xdata, event.ydata) if hit is None: return diff --git a/Examples/Interactive/plot_roi_inspector.py b/Examples/Interactive/plot_roi_inspector.py index a8bdc05c..a23380da 100644 --- a/Examples/Interactive/plot_roi_inspector.py +++ b/Examples/Interactive/plot_roi_inspector.py @@ -116,6 +116,8 @@ def _on_leave(event) -> None: def _on_move(event) -> None: + if event.xdata is None or event.ydata is None: + return x = int(np.clip(round(event.xdata), 0, 511)) y = int(np.clip(round(event.ydata), 0, 511)) intensity = float(image[y, x]) @@ -123,11 +125,11 @@ def _on_move(event) -> None: def _on_settled(event) -> None: - if _roi_dragging: + if _roi_dragging or event.xdata is None or event.ydata is None: return roi_name = _roi_at(event.xdata, event.ydata) if roi_name is None: - print("No ROI at cursor") + status_label.set(text="No ROI at cursor position") return with img_plot.hold_events("pointer_settled"): _update_spectrum(roi_name) @@ -140,7 +142,7 @@ def _on_settled(event) -> None: # ROI widget drag handlers for roi_name, widget in _roi_widgets.items(): - def _make_drag_handler(name): + def _make_drag_handler(): def _on_drag(event) -> None: global _roi_dragging _roi_dragging = True @@ -155,7 +157,7 @@ def _on_release(event) -> None: _update_spectrum(name) return _on_release - widget.add_event_handler(_make_drag_handler(roi_name), "pointer_move") + widget.add_event_handler(_make_drag_handler(), "pointer_move") widget.add_event_handler(_make_release_handler(roi_name, widget), "pointer_up") fig.set_help( diff --git a/Examples/Interactive/plot_threshold_explorer.py b/Examples/Interactive/plot_threshold_explorer.py index e6491ae4..10b83450 100644 --- a/Examples/Interactive/plot_threshold_explorer.py +++ b/Examples/Interactive/plot_threshold_explorer.py @@ -106,6 +106,8 @@ def _on_bar_click(event) -> None: def _on_settled(event) -> None: + if event.xdata is None or event.ydata is None: + return x = int(np.clip(round(event.xdata), 0, 511)) y = int(np.clip(round(event.ydata), 0, 511)) intensity = float(image[y, x]) diff --git a/anyplotlib/tests/test_examples/test_interactive_examples.py b/anyplotlib/tests/test_examples/test_interactive_examples.py index 9da1699e..cd644c87 100644 --- a/anyplotlib/tests/test_examples/test_interactive_examples.py +++ b/anyplotlib/tests/test_examples/test_interactive_examples.py @@ -16,7 +16,8 @@ def _exec_script(name: str) -> None: path = EXAMPLES_DIR / name - spec = importlib.util.spec_from_file_location("_smoke_ex", path) + mod_name = f"_smoke_ex_{path.stem}" + spec = importlib.util.spec_from_file_location(mod_name, path) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) From fb407fa1260c7a0b95ee94c7ae8eeb6381d94917 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 20 May 2026 09:49:20 -0500 Subject: [PATCH 156/198] feat: implement auto-sync for Figure dimensions based on GridSpec --- Examples/Interactive/plot_roi_inspector.py | 1 + anyplotlib/figure/_figure.py | 16 +- anyplotlib/figure_esm.js | 67 +++-- .../tests/test_layouts/test_gridspec.py | 233 ++++++++++++++++++ anyplotlib/tests/test_layouts/test_visual.py | 81 +++++- 5 files changed, 377 insertions(+), 21 deletions(-) diff --git a/Examples/Interactive/plot_roi_inspector.py b/Examples/Interactive/plot_roi_inspector.py index a23380da..2fd29644 100644 --- a/Examples/Interactive/plot_roi_inspector.py +++ b/Examples/Interactive/plot_roi_inspector.py @@ -166,3 +166,4 @@ def _on_release(event) -> None: "Drag ROI rectangle: repositions ROI\n" "Release drag: recomputes spectrum" ) +fig \ No newline at end of file diff --git a/anyplotlib/figure/_figure.py b/anyplotlib/figure/_figure.py index 0a93ee4b..7d832dda 100644 --- a/anyplotlib/figure/_figure.py +++ b/anyplotlib/figure/_figure.py @@ -180,7 +180,21 @@ def add_subplot(self, spec) -> Axes: >>> ax2 = fig.add_subplot((0, 1)) # top-right (via tuple) """ if isinstance(spec, SubplotSpec): - pass # use as-is + # Auto-sync Figure grid to the parent GridSpec when the GridSpec is + # larger than the Figure's current dimensions. This allows the + # common workflow: + # gs = GridSpec(2, 2, height_ratios=[3, 1]) + # fig = Figure(figsize=(...)) # defaults to nrows=1, ncols=1 + # fig.add_subplot(gs[0, :]) # Figure adopts 2×2 from GridSpec + # without requiring the user to repeat nrows/ncols/ratios on Figure. + gs = spec._gs + if gs is not None: + if gs.nrows > self._nrows: + self._nrows = gs.nrows + self._height_ratios = list(gs.height_ratios) + if gs.ncols > self._ncols: + self._ncols = gs.ncols + self._width_ratios = list(gs.width_ratios) elif isinstance(spec, int): row, col = divmod(spec, self._ncols) spec = SubplotSpec(None, row, row + 1, col, col + 1) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 35f1bd05..a77874a6 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -936,17 +936,17 @@ function render({ model, el }) { const { x, y, w, h } = _imgFitRect(st.image_width, st.image_height, pw, ph); const zoom = st.zoom, cx = st.center_x, cy = st.center_y; const iw = st.image_width, ih = st.image_height; + // +0.5: image coordinate i is the centre of pixel i, which renders at + // (i + 0.5) * scale in the canvas — not the leading edge (i * scale). if (zoom < 1.0) { - // Zoom-out path: full image drawn centred inside a scaled-down fit-rect - // (mirrors the zoom<1 branch in _blit2d exactly). const dstW = w * zoom, dstH = h * zoom; const dstX = x + (w - dstW) / 2, dstY = y + (h - dstH) / 2; - return [dstX + (ix / iw) * dstW, dstY + (iy / ih) * dstH]; + return [dstX + (ix + 0.5) / iw * dstW, dstY + (iy + 0.5) / ih * dstH]; } const visW = iw / zoom, visH = ih / zoom; const srcX = Math.max(0, Math.min(iw - visW, cx * iw - visW / 2)); const srcY = Math.max(0, Math.min(ih - visH, cy * ih - visH / 2)); - return [x + (ix - srcX) / visW * w, y + (iy - srcY) / visH * h]; + return [x + (ix + 0.5 - srcX) / visW * w, y + (iy + 0.5 - srcY) / visH * h]; } // Returns canvas-px per image-px at the current zoom (uniform in x and y). @@ -994,26 +994,25 @@ function render({ model, el }) { if(!b64||iw===0||ih===0){ctx.clearRect(0,0,imgW,imgH);return;} - let bytes; - try { - const bin=atob(b64); - bytes=new Uint8Array(bin.length); - for(let i=0;i { if (!dragStart) return; @@ -2541,7 +2540,10 @@ function render({ model, el }) { panStart={mx,my,cx:st.center_x,cy:st.center_y}; // Track potential click: distance + time guards distinguish click from pan. p.clickCandidate={mx,my,t:Date.now(),shiftKey:e.shiftKey}; - p.isPanning=true; overlayCanvas.style.cursor='grabbing'; e.preventDefault(); + p.isPanning=true; overlayCanvas.style.cursor='grabbing'; + // Do NOT call e.preventDefault() here: Chrome suppresses the click event + // when mousedown is cancelled, which in turn prevents dblclick from firing. + // Panning's preventDefault lives in the mousemove handler (prevents scroll). }); document.addEventListener('mousemove',(e)=>{ if(p.ovDrag2d){ @@ -2678,6 +2680,14 @@ function render({ model, el }) { const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); if (dist <= _settledDelta) { const _now = performance.now(); + const st2 = p.state; if (!st2) return; + const imgW2 = p.imgW || Math.max(1, p.pw - PAD_L - PAD_R); + const imgH2 = p.imgH || Math.max(1, p.ph - PAD_T - PAD_B); + const [sImgX, sImgY] = _canvasToImg2d(p.mouseX, p.mouseY, st2, imgW2, imgH2); + const sXArr = st2.x_axis || [], sYArr = st2.y_axis || []; + const _siw = st2.image_width || 1, _sih = st2.image_height || 1; + const sPhysX = sXArr.length >= 2 ? _axisFracToVal(sXArr, sImgX / _siw) : sImgX; + const sPhysY = sYArr.length >= 2 ? _axisFracToVal(sYArr, sImgY / _sih) : sImgY; _emitEvent(p.id, 'pointer_settled', null, { time_stamp: _now / 1000, modifiers: _settledMods, @@ -2685,6 +2695,10 @@ function render({ model, el }) { buttons: 0, x: Math.round(p.mouseX), y: Math.round(p.mouseY), + img_x: sImgX, + img_y: sImgY, + xdata: sPhysX, + ydata: sPhysY, dwell_ms: _now - _settledStartTs, }); } @@ -2698,9 +2712,15 @@ function render({ model, el }) { if(p._hoverSi!==-1){p._hoverSi=-1;p._hoverI=-1;drawMarkers2d(p,null);} }); overlayCanvas.addEventListener('dblclick',(e)=>{ + const st=p.state; if(!st) return; const imgW=p.imgW||Math.max(1,p.pw-PAD_L-PAD_R), imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B); const {mx,my}=_clientPos(e,overlayCanvas,imgW,imgH); - _emitEvent(p.id,'double_click',null,{..._pointerFields(e),button:e.button,x:mx,y:my}); + const [imgX,imgY]=_canvasToImg2d(mx,my,st,imgW,imgH); + const xArr=st.x_axis||[], yArr=st.y_axis||[]; + const _iw=st.image_width||1, _ih=st.image_height||1; + const physX=xArr.length>=2?_axisFracToVal(xArr,imgX/_iw):imgX; + const physY=yArr.length>=2?_axisFracToVal(yArr,imgY/_ih):imgY; + _emitEvent(p.id,'double_click',null,{..._pointerFields(e),button:e.button,x:mx,y:my,img_x:imgX,img_y:imgY,xdata:physX,ydata:physY}); }); overlayCanvas.addEventListener('wheel',(e)=>{ _emitEvent(p.id,'wheel',null,{ @@ -2807,7 +2827,8 @@ function render({ model, el }) { if(hit){p.ovDrag=hit;p.lastWidgetId=(p.state.overlay_widgets||[])[hit.idx]?.id||null;overlayCanvas.style.cursor=(hit.mode==='edge0'||hit.mode==='edge1')?'ew-resize':'move';e.preventDefault();return;} // Store pan start in canvas-px so pan delta in mousemove is canvas-px. panStart={mx:_emx,x0:st.view_x0,x1:st.view_x1}; - p.isPanning=true;overlayCanvas.style.cursor='grabbing';e.preventDefault(); + p.isPanning=true;overlayCanvas.style.cursor='grabbing'; + // Do NOT call e.preventDefault() — see 2D note: suppresses click → dblclick. }); document.addEventListener('mousemove',(e)=>{ if(p.ovDrag){ @@ -2967,7 +2988,15 @@ function render({ model, el }) { }); overlayCanvas.addEventListener('dblclick',(e)=>{ const {mx,my}=_clientPos(e,overlayCanvas,p.pw,p.ph); - _emitEvent(p.id,'double_click',null,{..._pointerFields(e),button:e.button,x:mx,y:my}); + const st=p.state; + let xdata=null; + if(st){ + const r=_plotRect1d(p.pw,p.ph); + const xArr=p._1dXArr||(st.x_axis_b64?_decodeF64(st.x_axis_b64):(st.x_axis||[])); + const frac=_canvasXToFrac1d(mx,st.view_x0,st.view_x1,r); + xdata=xArr.length>=2?_fracToX1d(xArr,frac):frac; + } + _emitEvent(p.id,'double_click',null,{..._pointerFields(e),button:e.button,x:mx,y:my,xdata}); }); overlayCanvas.addEventListener('wheel',(e)=>{ _emitEvent(p.id,'wheel',null,{ @@ -4076,7 +4105,7 @@ function render({ model, el }) { }); overlayCanvas.addEventListener('dblclick', (e) => { const {mx, my} = _clientPos(e, overlayCanvas, p.pw, p.ph); - _emitEvent(p.id, 'double_click', null, {..._pointerFields(e), button: e.button, x: mx, y: my}); + _emitEvent(p.id, 'double_click', null, {..._pointerFields(e), button: e.button, x: mx, y: my, xdata: null}); }); overlayCanvas.addEventListener('wheel', (e) => { _emitEvent(p.id, 'wheel', null, { diff --git a/anyplotlib/tests/test_layouts/test_gridspec.py b/anyplotlib/tests/test_layouts/test_gridspec.py index 3d9df833..572f6414 100644 --- a/anyplotlib/tests/test_layouts/test_gridspec.py +++ b/anyplotlib/tests/test_layouts/test_gridspec.py @@ -838,7 +838,240 @@ def test_plot_areas_positive(self): assert h > 0, f"Panel {pid}: plot area height must be positive, got {h}" +# ───────────────────────────────────────────────────────────────────────────── +# Part 9 – Figure + GridSpec workflow (bare Figure auto-syncs to GridSpec) +# ───────────────────────────────────────────────────────────────────────────── + +class TestFigureGridSpecWorkflow: + """Tests for the Figure + GridSpec workflow where Figure is created without + explicit nrows/ncols and auto-syncs its grid from the parent GridSpec. + + The typical pattern under test:: + + gs = GridSpec(2, 2, height_ratios=[3, 1]) + fig = Figure(figsize=(800, 600)) # defaults to nrows=1, ncols=1 + ax = fig.add_subplot(gs[0, :]) # Figure adopts 2×2 grid from gs + + Without the auto-sync, panels at row_start≥1 would get ph=0 (floored to 64) + because the Figure only knows about 1 row track. + """ + + def test_auto_sync_nrows_from_gridspec(self): + """Figure auto-updates _nrows when GridSpec has more rows.""" + gs = GridSpec(2, 1) + fig = Figure(figsize=(400, 400)) + fig.add_subplot(gs[0, 0]) + fig.add_subplot(gs[1, 0]) + assert fig._nrows == 2, f"nrows should auto-sync to 2, got {fig._nrows}" + assert fig._ncols == 1 + + def test_auto_sync_ncols_from_gridspec(self): + """Figure auto-updates _ncols when GridSpec has more columns.""" + gs = GridSpec(1, 3) + fig = Figure(figsize=(600, 200)) + fig.add_subplot(gs[0, 0]) + fig.add_subplot(gs[0, 1]) + fig.add_subplot(gs[0, 2]) + assert fig._ncols == 3, f"ncols should auto-sync to 3, got {fig._ncols}" + assert fig._nrows == 1 + + def test_auto_sync_height_ratios_from_gridspec(self): + """height_ratios from the GridSpec are adopted into the Figure.""" + gs = GridSpec(2, 1, height_ratios=[3, 1]) + fig = Figure(figsize=(400, 800)) + fig.add_subplot(gs[0, 0]) + assert fig._height_ratios == [3, 1], ( + f"height_ratios should be [3, 1], got {fig._height_ratios}" + ) + + def test_auto_sync_width_ratios_from_gridspec(self): + """width_ratios from the GridSpec are adopted into the Figure.""" + gs = GridSpec(1, 2, width_ratios=[2, 1]) + fig = Figure(figsize=(600, 200)) + fig.add_subplot(gs[0, 0]) + assert fig._width_ratios == [2, 1], ( + f"width_ratios should be [2, 1], got {fig._width_ratios}" + ) + + def test_gridspec_height_ratios_applied_to_sizes(self): + """Panels at correct heights according to GridSpec height_ratios.""" + gs = GridSpec(2, 1, height_ratios=[3, 1]) + fig = Figure(figsize=(400, 800)) + v0 = fig.add_subplot(gs[0, 0]).plot(np.zeros(10)) + v1 = fig.add_subplot(gs[1, 0]).plot(np.zeros(10)) + s = _sizes(fig) + ph0 = s[v0._id][1] + ph1 = s[v1._id][1] + assert approx(ph0, 600, tol=2), ( + f"top panel should be 600px (3/4 of 800), got {ph0}" + ) + assert approx(ph1, 200, tol=2), ( + f"bottom panel should be 200px (1/4 of 800), got {ph1}" + ) + assert approx(ph0, 3 * ph1, tol=3), ( + f"3:1 height ratio not met: {ph0} vs {ph1}" + ) + + def test_gridspec_width_ratios_applied_to_sizes(self): + """Panels at correct widths according to GridSpec width_ratios.""" + gs = GridSpec(1, 2, width_ratios=[2, 1]) + fig = Figure(figsize=(600, 200)) + v0 = fig.add_subplot(gs[0, 0]).plot(np.zeros(10)) + v1 = fig.add_subplot(gs[0, 1]).plot(np.zeros(10)) + s = _sizes(fig) + pw0 = s[v0._id][0] + pw1 = s[v1._id][0] + assert approx(pw0, 400, tol=2), ( + f"left panel should be 400px (2/3 of 600), got {pw0}" + ) + assert approx(pw1, 200, tol=2), ( + f"right panel should be 200px (1/3 of 600), got {pw1}" + ) + + def test_two_spectra_side_by_side_not_squished(self): + """Two 1D spectra side by side must each get half the figure width.""" + gs = GridSpec(1, 2) + fig = Figure(figsize=(800, 300)) + v0 = fig.add_subplot(gs[0, 0]).plot(np.zeros(100)) + v1 = fig.add_subplot(gs[0, 1]).plot(np.zeros(100)) + s = _sizes(fig) + pw0, ph0 = s[v0._id] + pw1, ph1 = s[v1._id] + assert approx(pw0, 400, tol=2), ( + f"left spectrum should be 400px wide, got {pw0}" + ) + assert approx(pw1, 400, tol=2), ( + f"right spectrum should be 400px wide, got {pw1}" + ) + assert ph0 == ph1 == 300, ( + f"both spectra should be 300px tall: {ph0}, {ph1}" + ) + # Inner plot area must be substantial (not 64px-floor squished) + inner_w = pw0 - PAD_L - PAD_R + assert inner_w > 200, ( + f"inner plot width should be >200px, got {inner_w} " + f"(panel was squished if ≤64)" + ) + + def test_image_and_two_spectra_correct_ratios(self): + """Image spanning top row (3×), two spectra below (1×) side by side. + + This is the canonical use-case the bug report describes: when using + GridSpec with a bare Figure, the second-row spectra used to get floored + to 64px because Figure._height_ratios had only 1 track. + """ + gs = GridSpec(2, 2, height_ratios=[3, 1]) + fig = Figure(figsize=(800, 800)) + v_img = fig.add_subplot(gs[0, :]).imshow(np.zeros((64, 64))) + v_sp1 = fig.add_subplot(gs[1, 0]).plot(np.zeros(100)) + v_sp2 = fig.add_subplot(gs[1, 1]).plot(np.zeros(100)) + s = _sizes(fig) + + pw_img, ph_img = s[v_img._id] + pw_sp1, ph_sp1 = s[v_sp1._id] + pw_sp2, ph_sp2 = s[v_sp2._id] + + # Image spans full width + assert pw_img == 800, f"image should span full width 800, got {pw_img}" + # Image gets 3/4 of height = 600px + assert approx(ph_img, 600, tol=2), ( + f"image should be 600px tall (3/4 of 800), got {ph_img}" + ) + # Each spectrum gets half width + assert approx(pw_sp1, 400, tol=2), ( + f"left spectrum width should be 400, got {pw_sp1}" + ) + assert approx(pw_sp2, 400, tol=2), ( + f"right spectrum width should be 400, got {pw_sp2}" + ) + # Spectra get 1/4 of height = 200px (not 64px floor!) + assert approx(ph_sp1, 200, tol=2), ( + f"spectrum height should be 200px (1/4 of 800), not 64 floor, got {ph_sp1}" + ) + assert ph_sp1 == ph_sp2, ( + f"both spectra must have the same height: {ph_sp1} vs {ph_sp2}" + ) + def test_explicit_figure_dims_beat_smaller_gridspec(self): + """When Figure has explicit nrows/ncols >= GridSpec, Figure values win.""" + gs = GridSpec(2, 1, height_ratios=[1, 1]) # equal ratios + fig = Figure(2, 1, figsize=(400, 800), height_ratios=[3, 1]) # explicit 3:1 + v0 = fig.add_subplot(gs[0, 0]).plot(np.zeros(10)) + v1 = fig.add_subplot(gs[1, 0]).plot(np.zeros(10)) + s = _sizes(fig) + ph0 = s[v0._id][1] + ph1 = s[v1._id][1] + # Figure's [3:1] must win over GridSpec's [1:1] + assert approx(ph0, 600, tol=2), ( + f"Figure's 3:1 ratio must be preserved: top={ph0}, expected 600" + ) + assert approx(ph1, 200, tol=2), ( + f"Figure's 3:1 ratio must be preserved: bottom={ph1}, expected 200" + ) + + def test_layout_json_nrows_ncols_after_auto_sync(self): + """layout_json must reflect the auto-synced nrows/ncols.""" + gs = GridSpec(3, 2) + fig = Figure(figsize=(600, 600)) + fig.add_subplot(gs[0, 0]).plot(np.zeros(5)) + fig.add_subplot(gs[1, 0]).plot(np.zeros(5)) + fig.add_subplot(gs[2, 0]).plot(np.zeros(5)) + layout = _layout(fig) + assert layout["nrows"] == 3, ( + f"layout_json nrows should be 3, got {layout['nrows']}" + ) + assert layout["ncols"] == 2, ( + f"layout_json ncols should be 2, got {layout['ncols']}" + ) + + def test_second_row_panel_not_floored_to_64(self): + """Regression: panel at row_start=1 with a 1-row Figure used to be floored to 64px.""" + gs = GridSpec(2, 1) + fig = Figure(figsize=(400, 400)) + _ = fig.add_subplot(gs[0, 0]).plot(np.zeros(5)) + v1 = fig.add_subplot(gs[1, 0]).plot(np.zeros(5)) + s = _sizes(fig) + ph1 = s[v1._id][1] + assert ph1 > 64, ( + f"Row-1 panel must NOT be floored to 64px; got ph={ph1}. " + "This indicates the Figure failed to auto-sync its nrows from the GridSpec." + ) + assert approx(ph1, 200, tol=2), ( + f"Row-1 panel should be 200px (half of 400), got {ph1}" + ) + + def test_three_row_gridspec_all_panels_correct_height(self): + """All three panels in a 3-row GridSpec (equal ratios) get 1/3 of height.""" + gs = GridSpec(3, 1) + fig = Figure(figsize=(400, 600)) + plots = [fig.add_subplot(gs[r, 0]).plot(np.zeros(5)) for r in range(3)] + s = _sizes(fig) + for i, v in enumerate(plots): + ph = s[v._id][1] + assert approx(ph, 200, tol=2), ( + f"Panel {i} should be 200px (1/3 of 600), got {ph}" + ) + + def test_spanning_subplot_correct_size(self): + """gs[0, :] spanning all columns must get the full figure width.""" + gs = GridSpec(2, 3, height_ratios=[2, 1]) + fig = Figure(figsize=(900, 600)) + v_top = fig.add_subplot(gs[0, :]).plot(np.zeros(10)) # spans 3 cols + v_bl = fig.add_subplot(gs[1, 0]).plot(np.zeros(10)) + v_bm = fig.add_subplot(gs[1, 1]).plot(np.zeros(10)) + v_br = fig.add_subplot(gs[1, 2]).plot(np.zeros(10)) + s = _sizes(fig) + + pw_top, ph_top = s[v_top._id] + assert pw_top == 900, f"spanning subplot should be full width 900, got {pw_top}" + assert approx(ph_top, 400, tol=2), ( + f"spanning subplot should be 400px (2/3 of 600), got {ph_top}" + ) + # Bottom row: each panel = 300px wide, 200px tall + for label, v in [("bottom-left", v_bl), ("bottom-mid", v_bm), ("bottom-right", v_br)]: + pw, ph = s[v._id] + assert approx(pw, 300, tol=2), f"{label} width should be 300, got {pw}" + assert approx(ph, 200, tol=2), f"{label} height should be 200, got {ph}" diff --git a/anyplotlib/tests/test_layouts/test_visual.py b/anyplotlib/tests/test_layouts/test_visual.py index 2a0f71c5..e94f341e 100644 --- a/anyplotlib/tests/test_layouts/test_visual.py +++ b/anyplotlib/tests/test_layouts/test_visual.py @@ -38,7 +38,7 @@ import anyplotlib as apl from anyplotlib.tests._png_utils import decode_png, encode_png, compare_arrays -BASELINES = pathlib.Path(__file__).parent / "baselines" +BASELINES = pathlib.Path(__file__).parent.parent / "baselines" # --------------------------------------------------------------------------- @@ -219,3 +219,82 @@ def test_subplots_2x1(self, take_screenshot, update_baselines): arr = take_screenshot(fig) _check("subplots_2x1", arr, update_baselines) + # ── GridSpec layouts ─────────────────────────────────────────────────── + + def test_gridspec_side_by_side_1d(self, take_screenshot, update_baselines): + """Two 1-D spectra side by side — exercises 1×2 GridSpec layout. + + Verifies that side-by-side spectra are not squished and each occupies + exactly half the figure width with a reasonable inner plot area. + """ + gs = apl.GridSpec(1, 2) + fig = apl.Figure(figsize=(640, 240)) + t = np.linspace(0.0, 2.0 * np.pi, 256) + fig.add_subplot(gs[0, 0]).plot(np.sin(t), color="#4fc3f7") + fig.add_subplot(gs[0, 1]).plot(np.cos(t), color="#ff7043") + arr = take_screenshot(fig) + _check("gridspec_side_by_side_1d", arr, update_baselines) + + def test_gridspec_image_two_spectra(self, take_screenshot, update_baselines): + """Image on top (3×height), two 1-D spectra below (1×height) side by side. + + This is the canonical layout that exposed the squishing bug: bare + Figure + GridSpec with height_ratios caused row-1 panels to be floored + to 64px. The image should occupy 3/4 of the height; each spectrum 1/4. + """ + gs = apl.GridSpec(2, 2, height_ratios=[3, 1]) + fig = apl.Figure(figsize=(480, 480)) + data = np.linspace(0.0, 1.0, 32 * 32).reshape(32, 32).astype(np.float32) + fig.add_subplot(gs[0, :]).imshow(data) + t = np.linspace(0.0, 2.0 * np.pi, 128) + fig.add_subplot(gs[1, 0]).plot(np.sin(t), color="#4fc3f7") + fig.add_subplot(gs[1, 1]).plot(np.cos(t), color="#ff7043") + arr = take_screenshot(fig) + _check("gridspec_image_two_spectra", arr, update_baselines) + + def test_gridspec_height_ratio_image_histogram(self, take_screenshot, update_baselines): + """Image (3×) + histogram (1×) with explicit height_ratios via GridSpec.""" + gs = apl.GridSpec(2, 1, height_ratios=[3, 1]) + fig = apl.Figure(figsize=(400, 400)) + rng = np.random.default_rng(42) + data = rng.uniform(0.0, 1.0, (32, 32)).astype(np.float32) + fig.add_subplot(gs[0, 0]).imshow(data, cmap="viridis") + counts = np.histogram(data.ravel(), bins=32)[0].astype(float) + fig.add_subplot(gs[1, 0]).plot(counts, color="#aed581") + arr = take_screenshot(fig) + _check("gridspec_height_ratio_image_histogram", arr, update_baselines) + + def test_gridspec_3col_equal_spectra(self, take_screenshot, update_baselines): + """Three equal-width 1-D spectra in a single row — 1×3 GridSpec.""" + gs = apl.GridSpec(1, 3) + fig = apl.Figure(figsize=(720, 200)) + rng = np.random.default_rng(7) + t = np.linspace(0.0, 2.0 * np.pi, 200) + colors = ["#4fc3f7", "#ff7043", "#aed581"] + for i, color in enumerate(colors): + noise = rng.normal(scale=0.1, size=len(t)) + fig.add_subplot(gs[0, i]).plot(np.sin(t * (i + 1)) + noise, color=color) + arr = take_screenshot(fig) + _check("gridspec_3col_equal_spectra", arr, update_baselines) + + def test_gridspec_asymmetric_width_ratios(self, take_screenshot, update_baselines): + """2:1 width ratio: wide spectrum left, narrow spectrum right.""" + gs = apl.GridSpec(1, 2, width_ratios=[2, 1]) + fig = apl.Figure(figsize=(480, 200)) + t = np.linspace(0.0, 2.0 * np.pi, 256) + fig.add_subplot(gs[0, 0]).plot(np.sin(t), color="#4fc3f7") + fig.add_subplot(gs[0, 1]).plot(np.cos(t), color="#ff7043") + arr = take_screenshot(fig) + _check("gridspec_asymmetric_width_ratios", arr, update_baselines) + + def test_gridspec_spanning_top_two_bottom(self, take_screenshot, update_baselines): + """Full-width spectrum on top (gs[0, :]), two spectra below (gs[1, 0:2]).""" + gs = apl.GridSpec(2, 2, height_ratios=[2, 1]) + fig = apl.Figure(figsize=(480, 360)) + t = np.linspace(0.0, 4.0 * np.pi, 512) + fig.add_subplot(gs[0, :]).plot(np.sin(t), color="#4fc3f7") + fig.add_subplot(gs[1, 0]).plot(np.sin(2 * t), color="#ff7043") + fig.add_subplot(gs[1, 1]).plot(np.cos(2 * t), color="#aed581") + arr = take_screenshot(fig) + _check("gridspec_spanning_top_two_bottom", arr, update_baselines) + From 20d7d49bd486002c16064287f0b22274da05f44e Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 20 May 2026 09:50:12 -0500 Subject: [PATCH 157/198] feat: add examples and test baselines for GridSpec layouts for multi-panel figures --- Examples/PlotTypes/plot_gridspec_custom.py | 187 ++++++++++++++++++ .../baselines/gridspec_3col_equal_spectra.png | Bin 0 -> 27050 bytes .../gridspec_asymmetric_width_ratios.png | Bin 0 -> 12635 bytes .../gridspec_height_ratio_image_histogram.png | Bin 0 -> 18214 bytes .../baselines/gridspec_image_two_spectra.png | Bin 0 -> 15967 bytes .../baselines/gridspec_side_by_side_1d.png | Bin 0 -> 17318 bytes .../gridspec_spanning_top_two_bottom.png | Bin 0 -> 25447 bytes 7 files changed, 187 insertions(+) create mode 100644 Examples/PlotTypes/plot_gridspec_custom.py create mode 100644 anyplotlib/tests/baselines/gridspec_3col_equal_spectra.png create mode 100644 anyplotlib/tests/baselines/gridspec_asymmetric_width_ratios.png create mode 100644 anyplotlib/tests/baselines/gridspec_height_ratio_image_histogram.png create mode 100644 anyplotlib/tests/baselines/gridspec_image_two_spectra.png create mode 100644 anyplotlib/tests/baselines/gridspec_side_by_side_1d.png create mode 100644 anyplotlib/tests/baselines/gridspec_spanning_top_two_bottom.png diff --git a/Examples/PlotTypes/plot_gridspec_custom.py b/Examples/PlotTypes/plot_gridspec_custom.py new file mode 100644 index 00000000..31e3dd4a --- /dev/null +++ b/Examples/PlotTypes/plot_gridspec_custom.py @@ -0,0 +1,187 @@ +""" +Custom Grid Layouts with GridSpec +================================== + +:class:`~anyplotlib.GridSpec` lets you build multi-panel figures where panels +have different sizes and span multiple grid cells. This gallery shows the most +common patterns. + +All examples use the **bare** ``Figure + GridSpec`` workflow — the figure's +grid dimensions are inferred automatically from the GridSpec the first time +``add_subplot`` is called. + +Overview +-------- + +1. **Side-by-side spectra** — two equal 1-D panels in one row (``1×2`` grid). +2. **Image + spectra** — image spanning full width, two spectra below + (``2×2`` grid with ``height_ratios=[3, 1]``). +3. **Image + histogram** — classic EM layout: large image on top, thin + histogram strip below (``2×1`` grid with ``height_ratios=[3, 1]``). +4. **Three-column** — three equal columns in a single row (``1×3`` grid). +5. **Asymmetric widths** — wide overview left, narrow detail right + (``1×2`` grid with ``width_ratios=[2, 1]``). +6. **Complex** — spanning top panel plus two bottom panels (``2×2`` grid). +""" +import numpy as np +import anyplotlib as apl + +rng = np.random.default_rng(42) +t = np.linspace(0.0, 2.0 * np.pi, 512) + +# ── 1. Side-by-side spectra (1×2, equal widths) ─────────────────────────────── +# %% +# Side-by-side spectra +# -------------------- +# The simplest multi-panel case: two 1-D spectra in one row. Each panel +# receives exactly half the figure width with a full-height inner plot area. +# Both panels share the same height so their axes baselines align visually. + +gs1 = apl.GridSpec(1, 2) +fig1 = apl.Figure(figsize=(720, 280)) + +sp_left = fig1.add_subplot(gs1[0, 0]).plot( + np.sin(t) + rng.normal(scale=0.05, size=len(t)), + color="#4fc3f7", label="channel A") + +sp_right = fig1.add_subplot(gs1[0, 1]).plot( + np.cos(t) + rng.normal(scale=0.05, size=len(t)), + color="#ff7043", label="channel B") + +fig1 # Interactive + +# ── 2. Image + two spectra (2×2, height_ratios=[3, 1]) ──────────────────────── +# %% +# Image on top, two spectra below +# -------------------------------- +# A ``2×2`` grid with ``height_ratios=[3, 1]`` puts a wide image in the upper +# three-quarters and two comparison spectra side-by-side in the lower quarter. +# +# The spanning subplot ``gs2[0, :]`` covers all columns in row 0, so the image +# gets the full figure width. + +N = 128 +x = np.linspace(-4, 4, N) +y = np.linspace(-4, 4, N) +XX, YY = np.meshgrid(x, y) +image = np.exp(-(XX**2 + YY**2) / 4) + 0.3 * np.exp(-((XX - 2)**2 + YY**2) / 1) +image += rng.normal(scale=0.03, size=image.shape) + +gs2 = apl.GridSpec(2, 2, height_ratios=[3, 1]) +fig2 = apl.Figure(figsize=(640, 560)) + +fig2.add_subplot(gs2[0, :]).imshow(image.astype(np.float32), cmap="inferno") + +row_profile = image[N // 2, :] +col_profile = image[:, N // 2] + +fig2.add_subplot(gs2[1, 0]).plot( + row_profile, axes=[x], units="nm", + color="#4fc3f7", label="row profile") + +fig2.add_subplot(gs2[1, 1]).plot( + col_profile, axes=[y], units="nm", + color="#ff7043", label="col profile") + +fig2 # Interactive + +# ── 3. Image + histogram (2×1, height_ratios=[3, 1]) ────────────────────────── +# %% +# Image + histogram strip +# ----------------------- +# A ``2×1`` grid with ``height_ratios=[3, 1]`` is the classic layout for +# showing an image with its intensity histogram below. The image occupies +# three-quarters of the height; the histogram strip the remaining quarter. + +gs3 = apl.GridSpec(2, 1, height_ratios=[3, 1]) +fig3 = apl.Figure(figsize=(500, 600)) + +fig3.add_subplot(gs3[0, 0]).imshow(image.astype(np.float32), cmap="viridis") + +counts, edges = np.histogram(image.ravel(), bins=64) +bin_centers = 0.5 * (edges[:-1] + edges[1:]) +fig3.add_subplot(gs3[1, 0]).plot( + counts.astype(float), axes=[bin_centers], + color="#aed581", label="histogram") + +fig3 # Interactive + +# ── 4. Three equal columns (1×3) ────────────────────────────────────────────── +# %% +# Three-column layout +# ------------------- +# A ``1×3`` grid gives three equal panels that are easy to compare visually. +# Useful for showing the same quantity at three different conditions or times. + +gs4 = apl.GridSpec(1, 3) +fig4 = apl.Figure(figsize=(900, 240)) + +spectra = [ + np.sin(t * (i + 1)) + rng.normal(scale=0.08, size=len(t)) + for i in range(3) +] +colors = ["#4fc3f7", "#ff7043", "#aed581"] +labels = ["f₁", "f₂", "f₃"] + +for i, (data, color, label) in enumerate(zip(spectra, colors, labels)): + fig4.add_subplot(gs4[0, i]).plot(data, color=color, label=label) + +fig4 # Interactive + +# ── 5. Asymmetric widths (1×2, width_ratios=[2, 1]) ────────────────────────── +# %% +# Asymmetric column widths +# ------------------------ +# ``width_ratios=[2, 1]`` makes the left panel twice as wide as the right. +# A common use-case is a broad overview spectrum on the left and a zoomed +# detail region on the right. + +energy = np.linspace(280, 295, 1024) +peak = np.exp(-0.5 * ((energy - 284.8) / 0.3)**2) +peak2 = 0.35 * np.exp(-0.5 * ((energy - 286.2) / 0.3)**2) +spectrum = peak + peak2 + 0.1 * np.exp(-0.05 * (energy - 280)) \ + + rng.normal(scale=0.01, size=len(energy)) + +gs5 = apl.GridSpec(1, 2, width_ratios=[2, 1]) +fig5 = apl.Figure(figsize=(720, 260)) + +fig5.add_subplot(gs5[0, 0]).plot( + spectrum, axes=[energy], units="eV", + color="#4fc3f7", label="survey") + +mask = (energy >= 283.5) & (energy <= 286.5) +fig5.add_subplot(gs5[0, 1]).plot( + spectrum[mask], axes=[energy[mask]], units="eV", + color="#ff7043", label="detail") + +fig5 # Interactive + +# ── 6. Complex layout: spanning top + two bottom (2×2, height_ratios=[2, 1]) ── +# %% +# Complex layout: spanning top panel +# ----------------------------------- +# A ``2×2`` grid where ``gs6[0, :]`` spans both columns creates a wide panel +# on top (e.g. a summed spectrum) with two comparison panels below it. +# ``height_ratios=[2, 1]`` gives the top panel twice the height of each bottom +# panel. + +summed = spectrum + rng.normal(scale=0.02, size=len(energy)) +diff1 = rng.normal(scale=0.05, size=len(energy)) +diff2 = rng.normal(scale=0.05, size=len(energy)) + +gs6 = apl.GridSpec(2, 2, height_ratios=[2, 1]) +fig6 = apl.Figure(figsize=(720, 480)) + +fig6.add_subplot(gs6[0, :]).plot( + summed, axes=[energy], units="eV", + color="#4fc3f7", label="summed") + +fig6.add_subplot(gs6[1, 0]).plot( + diff1, axes=[energy], units="eV", + color="#ff7043", label="Δ channel 1") + +fig6.add_subplot(gs6[1, 1]).plot( + diff2, axes=[energy], units="eV", + color="#aed581", label="Δ channel 2") + +fig6 # Interactive diff --git a/anyplotlib/tests/baselines/gridspec_3col_equal_spectra.png b/anyplotlib/tests/baselines/gridspec_3col_equal_spectra.png new file mode 100644 index 0000000000000000000000000000000000000000..3f9d1bc46ade8bea29d1362ae979c829cd862acf GIT binary patch literal 27050 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 F{{SIT;QIgo literal 0 HcmV?d00001 diff --git a/anyplotlib/tests/baselines/gridspec_asymmetric_width_ratios.png b/anyplotlib/tests/baselines/gridspec_asymmetric_width_ratios.png new file mode 100644 index 0000000000000000000000000000000000000000..87f60054958be906bf7202c1ebbc9bd34eb8a67d GIT binary patch literal 12635 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 YNZLazr&|Q@(!`|)_jJ_C?>-IuUu;fTUH||9 literal 0 HcmV?d00001 diff --git a/anyplotlib/tests/baselines/gridspec_height_ratio_image_histogram.png b/anyplotlib/tests/baselines/gridspec_height_ratio_image_histogram.png new file mode 100644 index 0000000000000000000000000000000000000000..b65992f6651f11bb9664f09d5a426ee14aacb113 GIT binary patch literal 18214 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 literal 0 HcmV?d00001 diff --git a/anyplotlib/tests/baselines/gridspec_image_two_spectra.png b/anyplotlib/tests/baselines/gridspec_image_two_spectra.png new file mode 100644 index 0000000000000000000000000000000000000000..abb8d4e382f1358e1abf91c3d9bd24fd41615ea0 GIT binary patch literal 15967 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 HSib!)y1XL1 literal 0 HcmV?d00001 From afb486f1302c4e8d2ceb76bd544d1b7d689fab8a Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 20 May 2026 11:27:30 -0500 Subject: [PATCH 158/198] refactor: enhance documentation and interaction descriptions in EELS and ROI inspector scripts --- Examples/Interactive/plot_eels_explorer.py | 25 ++- Examples/Interactive/plot_particle_picker.py | 21 ++- Examples/Interactive/plot_roi_inspector.py | 176 +++++++++++------- .../Interactive/plot_threshold_explorer.py | 25 ++- 4 files changed, 164 insertions(+), 83 deletions(-) diff --git a/Examples/Interactive/plot_eels_explorer.py b/Examples/Interactive/plot_eels_explorer.py index 0759062e..8c96fce7 100644 --- a/Examples/Interactive/plot_eels_explorer.py +++ b/Examples/Interactive/plot_eels_explorer.py @@ -1,11 +1,22 @@ """ EELS multi-spectrum explorer. - -Click a spectrum line to select it (full opacity; others dim). Dwell (250 ms) -to inspect the eV position and intensity; nearby known edges are annotated. -Double-click to place a permanent edge marker. Delete/Backspace removes the -most recently placed marker on the active spectrum. Tab / Shift+Tab cycles -the selection forward / backward. +============================== + +Five synthetic EELS spectra (Carbon-rich, Nitride, Oxide, Silicide, +Mixed) stacked vertically on a single axis, each with known +characteristic edges and a power-law background. + +**Interaction** + +* **Click** a spectrum line — selects it (full opacity; others dim to + 25 %). +* **Dwell 250 ms** — shows eV position and intensity; nearby known + edges (C K, N K, O K, Ti L) are annotated. +* **Double-click** — places a permanent vertical edge marker on the + active spectrum. +* **Delete / Backspace** — removes the most recent marker on the + active spectrum. +* **Tab / Shift+Tab** — cycles the selection forward / backward. """ import numpy as np import anyplotlib as apl @@ -196,3 +207,5 @@ def _on_key(event) -> None: "Delete / Backspace: remove last marker\n" "Tab / Shift+Tab: cycle selection" ) + +fig # interactive diff --git a/Examples/Interactive/plot_particle_picker.py b/Examples/Interactive/plot_particle_picker.py index 31328dde..72a1ee33 100644 --- a/Examples/Interactive/plot_particle_picker.py +++ b/Examples/Interactive/plot_particle_picker.py @@ -1,11 +1,20 @@ """ HAADF STEM nanoparticle picker. +================================= -Dwell over a candidate peak (300 ms) to inspect its sub-pixel centroid, -peak intensity, and estimated FWHM. Double-click to confirm a pick (green -ring). Shift+double-click marks it as uncertain (orange ring). -Delete/Backspace removes the confirmed pick nearest the cursor. ``c`` clears -all picks. +Synthetic HAADF-STEM image with 18 Gaussian nanoparticles on a Poisson +noise background. Candidate peaks are detected automatically using a +7×7 local-maximum filter and marked with small grey circles. + +**Interaction** + +* **Dwell 300 ms** over a candidate — shows the sub-pixel centroid, + peak intensity, and estimated FWHM in a floating label. +* **Double-click** — confirms the pick (green ring). +* **Shift+double-click** — marks the pick as uncertain (orange ring). +* **Delete / Backspace** — removes the confirmed pick nearest the + cursor. +* **c** — clears all picks. """ import numpy as np import anyplotlib as apl @@ -196,3 +205,5 @@ def _on_key(event) -> None: "Delete / Backspace: remove nearest pick\n" "c: clear all picks" ) + +fig # interactive diff --git a/Examples/Interactive/plot_roi_inspector.py b/Examples/Interactive/plot_roi_inspector.py index 2fd29644..fd89b49d 100644 --- a/Examples/Interactive/plot_roi_inspector.py +++ b/Examples/Interactive/plot_roi_inspector.py @@ -1,11 +1,19 @@ """ ROI-to-spectrum inspector for a multi-phase STEM image. +======================================================== -Four rectangular ROIs are drawn on the image. Entering the image panel -activates a pixel inspector in the status label. Hovering over an ROI for -350 ms computes the mean EDS-like spectrum for that region and updates the bar -chart. Dragging an ROI pauses spectrum recomputation to avoid backlog; -releasing triggers one final recompute. +Four rectangular ROIs overlay a synthetic 512×512 STEM image. Moving +the cursor inside any ROI recomputes the average EDS-like spectrum for +that region and refreshes both the continuous spectrum line plot and the +per-element bar chart in real time. Integration windows are shown as +coloured spans on the spectrum. + +**Interaction** + +* **Move cursor inside an ROI** — updates the EDS spectrum and bar + chart live as the cursor crosses ROI boundaries. +* **Drag an ROI rectangle** — repositions the ROI on the image. +* **Release drag** — recomputes the spectrum for the new position. """ import numpy as np import anyplotlib as apl @@ -16,48 +24,77 @@ def _make_multiphase_image(rng: np.random.Generator) -> np.ndarray: img = rng.normal(30, 6, (512, 512)).astype(np.float32) - # Precipitate A (bright) for cx, cy, r in [(120, 120, 60), (150, 100, 45), (90, 150, 40)]: ys, xs = np.ogrid[:512, :512] mask = (xs - cx) ** 2 + (ys - cy) ** 2 < r ** 2 img[mask] = rng.normal(160, 12, mask.sum()) - # Precipitate B (medium) for cx, cy, r in [(390, 390, 55), (360, 420, 40), (420, 360, 35)]: ys, xs = np.ogrid[:512, :512] mask = (xs - cx) ** 2 + (ys - cy) ** 2 < r ** 2 img[mask] = rng.normal(110, 10, mask.sum()) - # Grain boundary (thin horizontal band, rows 240-270) img[240:270, :] = rng.normal(70, 8, (30, 512)) return np.clip(img, 0, 255).astype(np.float32) -def _mean_eds(img_patch: np.ndarray) -> np.ndarray: - """4-channel EDS intensity proportional to local image value + noise.""" - mean_val = float(img_patch.mean()) - rng_local = np.random.default_rng(int(mean_val * 1000) % (2**31)) - weights = np.array([0.40, 0.25, 0.20, 0.15]) - spectrum = weights * mean_val + rng_local.normal(0, 2, 4) - return np.clip(spectrum / 255.0, 0, 1) - - rng = np.random.default_rng(99) image = _make_multiphase_image(rng) + +# ── EDS energy axis and element definitions ──────────────────────────────────── + +EDS_ENERGY = np.linspace(0.1, 3.0, 600) # keV +EDS_ELEMENTS = ["O", "Fe", "Al", "Si"] +_EDS_EV = [0.525, 0.710, 1.487, 1.740] # characteristic keV +_EDS_WIN = [(0.45, 0.61), (0.64, 0.80), (1.40, 1.58), (1.65, 1.83)] +_EDS_SIGMA = 0.028 # peak width (keV) +_EDS_COLORS = ["#ff8a65", "#ba68c8", "#4fc3f7", "#aed581"] + +_PEAKS = np.array([ + np.exp(-0.5 * ((EDS_ENERGY - ev) / _EDS_SIGMA) ** 2) + for ev in _EDS_EV +]) + ROIS: dict[str, tuple[int, int, int, int]] = { - "Matrix": (50, 200, 50, 200), - "Precipitate A": (50, 200, 310, 460), - "Precipitate B": (310, 460, 50, 200), - "Grain Boundary":(240, 270, 50, 460), + "Matrix": (50, 200, 50, 200), + "Precipitate A": (50, 200, 310, 460), + "Precipitate B": (310, 460, 50, 200), + "Grain Boundary": (240, 270, 50, 460), +} +_ROI_WEIGHTS: dict[str, np.ndarray] = { + "Matrix": np.array([0.10, 0.05, 0.65, 0.20]), + "Precipitate A": np.array([0.05, 0.08, 0.12, 0.75]), + "Precipitate B": np.array([0.12, 0.60, 0.18, 0.10]), + "Grain Boundary": np.array([0.62, 0.12, 0.18, 0.08]), +} +_ROI_COLORS: dict[str, str] = { + "Matrix": "#4fc3f7", + "Precipitate A": "#aed581", + "Precipitate B": "#ff8a65", + "Grain Boundary": "#ba68c8", } -EDS_ELEMENTS = ["Al", "Si", "Fe", "O"] -_PLACEHOLDER = np.array([0.0, 0.0, 0.0, 0.0]) +_NOISE_RNG = np.random.default_rng(7) -# ── helpers ──────────────────────────────────────────────────────────────────── +def _eds_spectrum(roi_name: str) -> np.ndarray: + r0, r1, c0, c1 = ROIS[roi_name] + mean_val = float(image[r0:r1, c0:c1].mean()) / 255.0 + weights = _ROI_WEIGHTS[roi_name] + spectrum = (_PEAKS * weights[:, None]).sum(axis=0) * mean_val + spectrum += _NOISE_RNG.normal(0, 0.002, len(EDS_ENERGY)) + return np.clip(spectrum, 0, None) + + +def _eds_bars(spectrum: np.ndarray) -> np.ndarray: + bars = np.array([ + spectrum[(EDS_ENERGY >= lo) & (EDS_ENERGY <= hi)].mean() + for lo, hi in _EDS_WIN + ]) + return bars / (bars.max() or 1.0) + def _roi_at(x: float, y: float) -> str | None: for name, (r0, r1, c0, c1) in ROIS.items(): @@ -66,18 +103,27 @@ def _roi_at(x: float, y: float) -> str | None: return None -# ── figure ───────────────────────────────────────────────────────────────────── +# ── layout ───────────────────────────────────────────────────────────────────── -fig, (ax_img, ax_spec) = apl.subplots(1, 2, figsize=(1000, 520)) +fig = apl.Figure(figsize=(1100, 560)) +gs = apl.GridSpec(2, 2, width_ratios=[1, 1], height_ratios=[1, 1]) -img_plot = ax_img.imshow(image, cmap="gray") -spec_plot = ax_spec.bar(EDS_ELEMENTS, _PLACEHOLDER) +ax_img = fig.add_subplot(gs[:, 0]) # image — left column, full height +ax_spec = fig.add_subplot(gs[0, 1]) # EDS spectrum — top right +ax_bar = fig.add_subplot(gs[1, 1]) # bar chart — bottom right -# ROI rectangle widgets -_roi_widgets: dict[str, object] = {} -_ROI_COLORS = {"Matrix": "#4fc3f7", "Precipitate A": "#aed581", - "Precipitate B": "#ff8a65", "Grain Boundary": "#ba68c8"} +img_plot = ax_img.imshow(image, cmap="gray") +_init_spec = _eds_spectrum("Matrix") +spec_plot = ax_spec.plot(_init_spec, axes=[EDS_ENERGY], color="#4fc3f7", linewidth=1.5) + +_init_bars = _eds_bars(_init_spec) +bar_plot = ax_bar.bar(EDS_ELEMENTS, _init_bars.tolist()) + + +# ── ROI rectangle overlays ───────────────────────────────────────────────────── + +_roi_widgets: dict[str, object] = {} for roi_name, (r0, r1, c0, c1) in ROIS.items(): w = img_plot.add_widget( "rectangle", @@ -88,59 +134,57 @@ def _roi_at(x: float, y: float) -> str | None: _roi_widgets[roi_name] = w status_label = img_plot.add_widget( - "label", x=10, y=498, text="Move cursor over image to inspect", + "label", x=10, y=498, text="Move cursor into an ROI", color="#ffffff", fontsize=10, ) +# Coloured integration-window spans on the spectrum (permanent) +for i, (lo, hi) in enumerate(_EDS_WIN): + spec_plot.add_span(lo, hi, axis="x", color=f"{_EDS_COLORS[i]}44") + +_current_roi: list[str | None] = [None] _roi_dragging = False -# ── spectrum update ───────────────────────────────────────────────────────────── +# ── update helpers ───────────────────────────────────────────────────────────── -def _update_spectrum(roi_name: str) -> None: +def _update_for_roi(roi_name: str) -> None: + _current_roi[0] = roi_name + spectrum = _eds_spectrum(roi_name) + bars = _eds_bars(spectrum) + spec_plot.set_data(spectrum, x_axis=EDS_ENERGY) + spec_plot.set_color(_ROI_COLORS[roi_name]) + bar_plot.set_data(bars.tolist()) r0, r1, c0, c1 = ROIS[roi_name] - patch = image[r0:r1, c0:c1] - eds = _mean_eds(patch) - spec_plot.set_data(eds) - print(f"ROI '{roi_name}': Al={eds[0]:.3f} Si={eds[1]:.3f} Fe={eds[2]:.3f} O={eds[3]:.3f}") + mean_val = float(image[r0:r1, c0:c1].mean()) + status_label.set(text=f"ROI: {roi_name} mean={mean_val:.0f}") # ── event handlers ───────────────────────────────────────────────────────────── -def _on_enter(event) -> None: - status_label.set(text="Pixel: — Intensity: —") - - -def _on_leave(event) -> None: - status_label.set(text="Move cursor over image to inspect") - - def _on_move(event) -> None: - if event.xdata is None or event.ydata is None: - return - x = int(np.clip(round(event.xdata), 0, 511)) - y = int(np.clip(round(event.ydata), 0, 511)) - intensity = float(image[y, x]) - status_label.set(text=f"Pixel: ({x}, {y}) Intensity: {intensity:.0f}") - - -def _on_settled(event) -> None: if _roi_dragging or event.xdata is None or event.ydata is None: return roi_name = _roi_at(event.xdata, event.ydata) if roi_name is None: - status_label.set(text="No ROI at cursor position") return - with img_plot.hold_events("pointer_settled"): - _update_spectrum(roi_name) + if roi_name != _current_roi[0]: + _update_for_roi(roi_name) + +def _on_enter(event) -> None: + status_label.set(text="Move cursor into an ROI") + +def _on_leave(event) -> None: + status_label.set(text="Move cursor over image to inspect") + _current_roi[0] = None + + +img_plot.add_event_handler(_on_move, "pointer_move") img_plot.add_event_handler(_on_enter, "pointer_enter") img_plot.add_event_handler(_on_leave, "pointer_leave") -img_plot.add_event_handler(_on_move, "pointer_move") -img_plot.add_event_handler(_on_settled, "pointer_settled", ms=350) -# ROI widget drag handlers for roi_name, widget in _roi_widgets.items(): def _make_drag_handler(): def _on_drag(event) -> None: @@ -154,16 +198,16 @@ def _on_release(event) -> None: _roi_dragging = False x, y, w, h = wgt.x, wgt.y, wgt.w, wgt.h ROIS[name] = (int(y), int(y + h), int(x), int(x + w)) - _update_spectrum(name) + _update_for_roi(name) return _on_release widget.add_event_handler(_make_drag_handler(), "pointer_move") widget.add_event_handler(_make_release_handler(roi_name, widget), "pointer_up") fig.set_help( - "Move cursor over image: inspect pixel\n" - "Dwell 350 ms inside ROI: compute EDS spectrum\n" + "Move cursor inside an ROI: live spectrum + bar update\n" "Drag ROI rectangle: repositions ROI\n" "Release drag: recomputes spectrum" ) -fig \ No newline at end of file + +fig # Interactive diff --git a/Examples/Interactive/plot_threshold_explorer.py b/Examples/Interactive/plot_threshold_explorer.py index 10b83450..bb45ff4d 100644 --- a/Examples/Interactive/plot_threshold_explorer.py +++ b/Examples/Interactive/plot_threshold_explorer.py @@ -1,11 +1,20 @@ """ Live intensity thresholding on a multi-phase STEM image. +========================================================= -Scroll the mouse wheel over the image to adjust the threshold (2 counts per -tick). Click a histogram bar to jump the threshold to that bin's upper edge. -Dwell (400 ms) over the image to inspect pixel intensity. The threshold mask -is shown as a red overlay; the histogram always has a vertical line at the -current threshold. +A side-by-side view: the left panel shows a synthetic 512×512 STEM +image with a red overlay marking pixels above the threshold; the right +panel shows a 32-bin intensity histogram with a yellow vertical line at +the current threshold value. + +**Interaction** + +* **Shift+Scroll** over the image — adjusts the threshold by ±2 per + wheel tick (plain scroll pans/zooms the image as normal). +* **Click** a histogram bar — jumps the threshold to that bin's upper + edge. +* **Dwell 400 ms** over the image — shows pixel coordinates and + intensity in the bottom-left label. """ import numpy as np import anyplotlib as apl @@ -93,6 +102,8 @@ def _update_display(thresh: float) -> None: # ── event handlers ───────────────────────────────────────────────────────────── def _on_wheel(event) -> None: + if "shift" not in event.modifiers: + return delta = -2.0 * np.sign(event.dy) if event.dy != 0 else 0.0 _update_display(threshold + delta) @@ -119,7 +130,9 @@ def _on_settled(event) -> None: hist_plot.add_event_handler(_on_bar_click, "pointer_down") fig.set_help( - "Scroll over image: adjust threshold ±2\n" + "Shift+Scroll over image: adjust threshold ±2\n" "Click histogram bar: jump to bin upper edge\n" "Dwell 400 ms over image: inspect pixel intensity" ) + +fig # Interactive From 404b56342459d9977026087a9a0241d8903cff05 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 20 May 2026 12:29:57 -0500 Subject: [PATCH 159/198] Refactor: Update and clean ROI-to-spectrum inspector for 3-D EDS hyperspectral datasets --- Examples/Interactive/plot_roi_inspector.py | 213 -------------- .../Interactive/plot_spectra_roi_inspector.py | 265 ++++++++++++++++++ 2 files changed, 265 insertions(+), 213 deletions(-) delete mode 100644 Examples/Interactive/plot_roi_inspector.py create mode 100644 Examples/Interactive/plot_spectra_roi_inspector.py diff --git a/Examples/Interactive/plot_roi_inspector.py b/Examples/Interactive/plot_roi_inspector.py deleted file mode 100644 index fd89b49d..00000000 --- a/Examples/Interactive/plot_roi_inspector.py +++ /dev/null @@ -1,213 +0,0 @@ -""" -ROI-to-spectrum inspector for a multi-phase STEM image. -======================================================== - -Four rectangular ROIs overlay a synthetic 512×512 STEM image. Moving -the cursor inside any ROI recomputes the average EDS-like spectrum for -that region and refreshes both the continuous spectrum line plot and the -per-element bar chart in real time. Integration windows are shown as -coloured spans on the spectrum. - -**Interaction** - -* **Move cursor inside an ROI** — updates the EDS spectrum and bar - chart live as the cursor crosses ROI boundaries. -* **Drag an ROI rectangle** — repositions the ROI on the image. -* **Release drag** — recomputes the spectrum for the new position. -""" -import numpy as np -import anyplotlib as apl - - -# ── synthetic data ───────────────────────────────────────────────────────────── - -def _make_multiphase_image(rng: np.random.Generator) -> np.ndarray: - img = rng.normal(30, 6, (512, 512)).astype(np.float32) - - for cx, cy, r in [(120, 120, 60), (150, 100, 45), (90, 150, 40)]: - ys, xs = np.ogrid[:512, :512] - mask = (xs - cx) ** 2 + (ys - cy) ** 2 < r ** 2 - img[mask] = rng.normal(160, 12, mask.sum()) - - for cx, cy, r in [(390, 390, 55), (360, 420, 40), (420, 360, 35)]: - ys, xs = np.ogrid[:512, :512] - mask = (xs - cx) ** 2 + (ys - cy) ** 2 < r ** 2 - img[mask] = rng.normal(110, 10, mask.sum()) - - img[240:270, :] = rng.normal(70, 8, (30, 512)) - - return np.clip(img, 0, 255).astype(np.float32) - - -rng = np.random.default_rng(99) -image = _make_multiphase_image(rng) - - -# ── EDS energy axis and element definitions ──────────────────────────────────── - -EDS_ENERGY = np.linspace(0.1, 3.0, 600) # keV -EDS_ELEMENTS = ["O", "Fe", "Al", "Si"] -_EDS_EV = [0.525, 0.710, 1.487, 1.740] # characteristic keV -_EDS_WIN = [(0.45, 0.61), (0.64, 0.80), (1.40, 1.58), (1.65, 1.83)] -_EDS_SIGMA = 0.028 # peak width (keV) -_EDS_COLORS = ["#ff8a65", "#ba68c8", "#4fc3f7", "#aed581"] - -_PEAKS = np.array([ - np.exp(-0.5 * ((EDS_ENERGY - ev) / _EDS_SIGMA) ** 2) - for ev in _EDS_EV -]) - -ROIS: dict[str, tuple[int, int, int, int]] = { - "Matrix": (50, 200, 50, 200), - "Precipitate A": (50, 200, 310, 460), - "Precipitate B": (310, 460, 50, 200), - "Grain Boundary": (240, 270, 50, 460), -} -_ROI_WEIGHTS: dict[str, np.ndarray] = { - "Matrix": np.array([0.10, 0.05, 0.65, 0.20]), - "Precipitate A": np.array([0.05, 0.08, 0.12, 0.75]), - "Precipitate B": np.array([0.12, 0.60, 0.18, 0.10]), - "Grain Boundary": np.array([0.62, 0.12, 0.18, 0.08]), -} -_ROI_COLORS: dict[str, str] = { - "Matrix": "#4fc3f7", - "Precipitate A": "#aed581", - "Precipitate B": "#ff8a65", - "Grain Boundary": "#ba68c8", -} - -_NOISE_RNG = np.random.default_rng(7) - - -def _eds_spectrum(roi_name: str) -> np.ndarray: - r0, r1, c0, c1 = ROIS[roi_name] - mean_val = float(image[r0:r1, c0:c1].mean()) / 255.0 - weights = _ROI_WEIGHTS[roi_name] - spectrum = (_PEAKS * weights[:, None]).sum(axis=0) * mean_val - spectrum += _NOISE_RNG.normal(0, 0.002, len(EDS_ENERGY)) - return np.clip(spectrum, 0, None) - - -def _eds_bars(spectrum: np.ndarray) -> np.ndarray: - bars = np.array([ - spectrum[(EDS_ENERGY >= lo) & (EDS_ENERGY <= hi)].mean() - for lo, hi in _EDS_WIN - ]) - return bars / (bars.max() or 1.0) - - -def _roi_at(x: float, y: float) -> str | None: - for name, (r0, r1, c0, c1) in ROIS.items(): - if c0 <= x <= c1 and r0 <= y <= r1: - return name - return None - - -# ── layout ───────────────────────────────────────────────────────────────────── - -fig = apl.Figure(figsize=(1100, 560)) -gs = apl.GridSpec(2, 2, width_ratios=[1, 1], height_ratios=[1, 1]) - -ax_img = fig.add_subplot(gs[:, 0]) # image — left column, full height -ax_spec = fig.add_subplot(gs[0, 1]) # EDS spectrum — top right -ax_bar = fig.add_subplot(gs[1, 1]) # bar chart — bottom right - -img_plot = ax_img.imshow(image, cmap="gray") - -_init_spec = _eds_spectrum("Matrix") -spec_plot = ax_spec.plot(_init_spec, axes=[EDS_ENERGY], color="#4fc3f7", linewidth=1.5) - -_init_bars = _eds_bars(_init_spec) -bar_plot = ax_bar.bar(EDS_ELEMENTS, _init_bars.tolist()) - - -# ── ROI rectangle overlays ───────────────────────────────────────────────────── - -_roi_widgets: dict[str, object] = {} -for roi_name, (r0, r1, c0, c1) in ROIS.items(): - w = img_plot.add_widget( - "rectangle", - x=float(c0), y=float(r0), - w=float(c1 - c0), h=float(r1 - r0), - color=_ROI_COLORS[roi_name], - ) - _roi_widgets[roi_name] = w - -status_label = img_plot.add_widget( - "label", x=10, y=498, text="Move cursor into an ROI", - color="#ffffff", fontsize=10, -) - -# Coloured integration-window spans on the spectrum (permanent) -for i, (lo, hi) in enumerate(_EDS_WIN): - spec_plot.add_span(lo, hi, axis="x", color=f"{_EDS_COLORS[i]}44") - -_current_roi: list[str | None] = [None] -_roi_dragging = False - - -# ── update helpers ───────────────────────────────────────────────────────────── - -def _update_for_roi(roi_name: str) -> None: - _current_roi[0] = roi_name - spectrum = _eds_spectrum(roi_name) - bars = _eds_bars(spectrum) - spec_plot.set_data(spectrum, x_axis=EDS_ENERGY) - spec_plot.set_color(_ROI_COLORS[roi_name]) - bar_plot.set_data(bars.tolist()) - r0, r1, c0, c1 = ROIS[roi_name] - mean_val = float(image[r0:r1, c0:c1].mean()) - status_label.set(text=f"ROI: {roi_name} mean={mean_val:.0f}") - - -# ── event handlers ───────────────────────────────────────────────────────────── - -def _on_move(event) -> None: - if _roi_dragging or event.xdata is None or event.ydata is None: - return - roi_name = _roi_at(event.xdata, event.ydata) - if roi_name is None: - return - if roi_name != _current_roi[0]: - _update_for_roi(roi_name) - - -def _on_enter(event) -> None: - status_label.set(text="Move cursor into an ROI") - - -def _on_leave(event) -> None: - status_label.set(text="Move cursor over image to inspect") - _current_roi[0] = None - - -img_plot.add_event_handler(_on_move, "pointer_move") -img_plot.add_event_handler(_on_enter, "pointer_enter") -img_plot.add_event_handler(_on_leave, "pointer_leave") - -for roi_name, widget in _roi_widgets.items(): - def _make_drag_handler(): - def _on_drag(event) -> None: - global _roi_dragging - _roi_dragging = True - return _on_drag - - def _make_release_handler(name, wgt): - def _on_release(event) -> None: - global _roi_dragging - _roi_dragging = False - x, y, w, h = wgt.x, wgt.y, wgt.w, wgt.h - ROIS[name] = (int(y), int(y + h), int(x), int(x + w)) - _update_for_roi(name) - return _on_release - - widget.add_event_handler(_make_drag_handler(), "pointer_move") - widget.add_event_handler(_make_release_handler(roi_name, widget), "pointer_up") - -fig.set_help( - "Move cursor inside an ROI: live spectrum + bar update\n" - "Drag ROI rectangle: repositions ROI\n" - "Release drag: recomputes spectrum" -) - -fig # Interactive diff --git a/Examples/Interactive/plot_spectra_roi_inspector.py b/Examples/Interactive/plot_spectra_roi_inspector.py new file mode 100644 index 00000000..eef36b9c --- /dev/null +++ b/Examples/Interactive/plot_spectra_roi_inspector.py @@ -0,0 +1,265 @@ +""" +ROI-to-spectrum inspector for a 3-D EDS hyperspectral dataset. +============================================================== + +A synthetic ``(256, 256, 300)`` EDS datacube — one 300-channel +spectrum per scan position. Four rectangular ROIs overlay the +total-counts image (HAADF proxy). Entering an ROI **sums all spectra +within the rectangle** (spatial sum over every scan position in the +box) and displays the result in the top-right panel. Draggable +coloured range widgets on the spectrum define the integration window +for each element; each bar height is the **channel sum of the ROI +spectrum within that window**. + +**Interaction** + +* **Move cursor inside an ROI** — spatially sums the spectra of all + scan positions inside the box; updates the line plot and bars live. +* **Drag an ROI rectangle** — repositions the ROI on the image. +* **Release drag** — recomputes the spatial sum spectrum for the new + position. +* **Drag a coloured range widget** on the spectrum — adjusts the + integration window for that element; bar heights update on every + drag frame. +""" +import numpy as np +import anyplotlib as apl + + +# ── synthetic 3-D hyperspectral datacube ────────────────────────────────────── +# Shape: (NY, NX, NC). dataset[y, x, :] is the 300-channel EDS spectrum at +# scan position (x, y). Each pixel is an independent Poisson draw from the +# expected spectrum for its phase. + +NY, NX, NC = 256, 256, 300 +ENERGY = np.linspace(0.1, 3.0, NC) # keV + +EDS_ELEMENTS = ["O", "Fe", "Al", "Si"] +_EDS_EV = [0.525, 0.710, 1.487, 1.740] # characteristic keV +_EDS_WIN = [(0.45, 0.61), (0.64, 0.80), (1.40, 1.58), (1.65, 1.83)] +_EDS_SIGMA = 0.025 +_EDS_COLORS = ["#ff8a65", "#ba68c8", "#4fc3f7", "#aed581"] + +_PEAKS = np.array([ + np.exp(-0.5 * ((ENERGY - ev) / _EDS_SIGMA) ** 2) + for ev in _EDS_EV +]) # shape (4, NC) + +# Per-phase element weight vectors [O, Fe, Al, Si] and expected total +# counts per pixel (determines peak-to-background ratio and brightness). +_PHASE_DEFS = [ + dict(weights=[0.10, 0.05, 0.65, 0.20], counts=80), # 0 Matrix + dict(weights=[0.05, 0.08, 0.12, 0.75], counts=200), # 1 Precipitate A + dict(weights=[0.12, 0.60, 0.18, 0.10], counts=150), # 2 Precipitate B + dict(weights=[0.62, 0.12, 0.18, 0.08], counts=110), # 3 Grain Boundary +] + + +def _expected_spectrum(phase_idx: int) -> np.ndarray: + p = _PHASE_DEFS[phase_idx] + bkg = 3.0 * np.exp(-ENERGY / 0.8) + spec = bkg + (_PEAKS * np.array(p["weights"])[:, None]).sum(axis=0) * p["counts"] + return np.clip(spec, 0, None).astype(np.float64) + + +def _make_dataset(rng: np.random.Generator) -> tuple[np.ndarray, np.ndarray]: + phases = np.zeros((NY, NX), dtype=np.int8) # 0 = Matrix + + # Precipitate A (Si-rich) — cluster in top-left quadrant + for cx, cy, r in [(60, 60, 30), (75, 50, 22), (45, 75, 20)]: + ys, xs = np.ogrid[:NY, :NX] + phases[(xs - cx) ** 2 + (ys - cy) ** 2 < r ** 2] = 1 + + # Precipitate B (Fe-rich) — cluster in bottom-right quadrant + for cx, cy, r in [(195, 195, 27), (180, 210, 20), (210, 180, 17)]: + ys, xs = np.ogrid[:NY, :NX] + phases[(xs - cx) ** 2 + (ys - cy) ** 2 < r ** 2] = 2 + + # Grain boundary — thin horizontal band + phases[120:135, :] = 3 + + dataset = np.empty((NY, NX, NC), dtype=np.float32) + flat = dataset.reshape(-1, NC) + phases_flat = phases.ravel() + for pidx, pdef in enumerate(_PHASE_DEFS): + sel = phases_flat == pidx + n = int(sel.sum()) + if n == 0: + continue + lam = _expected_spectrum(pidx) + flat[sel] = rng.poisson(lam, size=(n, NC)).astype(np.float32) + + return dataset, phases + + +rng = np.random.default_rng(99) +dataset, _phase_map = _make_dataset(rng) + +# Total-counts image used as the HAADF-proxy display image +_display_img = dataset.sum(axis=2) + + +# ── ROI definitions (r0, r1, c0, c1) in scan-pixel coordinates ──────────────── + +ROIS: dict[str, tuple[int, int, int, int]] = { + "Matrix": ( 25, 100, 155, 230), + "Precipitate A": ( 25, 100, 25, 100), + "Precipitate B": (155, 230, 155, 230), + "Grain Boundary": (115, 140, 25, 230), +} +_ROI_COLORS: dict[str, str] = { + "Matrix": "#4fc3f7", + "Precipitate A": "#aed581", + "Precipitate B": "#ff8a65", + "Grain Boundary": "#ba68c8", +} + + +def _sum_spectrum(r0: int, r1: int, c0: int, c1: int) -> np.ndarray: + """Spatial sum of all spectra within the ROI box.""" + r0 = max(0, min(NY - 1, r0)); r1 = max(1, min(NY, r1)) + c0 = max(0, min(NX - 1, c0)); c1 = max(1, min(NX, c1)) + return dataset[r0:r1, c0:c1, :].sum(axis=(0, 1)) + + +def _roi_at(x: float, y: float) -> str | None: + for name, (r0, r1, c0, c1) in ROIS.items(): + if c0 <= x <= c1 and r0 <= y <= r1: + return name + return None + + +# ── layout ───────────────────────────────────────────────────────────────────── + +fig = apl.Figure(figsize=(1100, 560)) +gs = apl.GridSpec(2, 2, width_ratios=[1, 1], height_ratios=[1, 1]) + +ax_img = fig.add_subplot(gs[:, 0]) # total-counts image — left column +ax_spec = fig.add_subplot(gs[0, 1]) # ROI sum spectrum — top right +ax_bar = fig.add_subplot(gs[1, 1]) # element bar chart — bottom right + +img_plot = ax_img.imshow(_display_img, cmap="gray") + +_init_spec = _sum_spectrum(*ROIS["Matrix"]).astype(np.float32) +spec_plot = ax_spec.plot(_init_spec, axes=[ENERGY], + color=_ROI_COLORS["Matrix"], linewidth=1.5, + units="keV", y_units="counts") +bar_plot = ax_bar.bar(EDS_ELEMENTS, [0.0] * 4) + + +# ── ROI rectangle overlays on the image ─────────────────────────────────────── + +_roi_widgets: dict[str, object] = {} +for roi_name, (r0, r1, c0, c1) in ROIS.items(): + w = img_plot.add_widget( + "rectangle", + x=float(c0), y=float(r0), + w=float(c1 - c0), h=float(r1 - r0), + color=_ROI_COLORS[roi_name], + ) + _roi_widgets[roi_name] = w + +status_label = img_plot.add_widget( + "label", x=4, y=248, text="Move cursor into an ROI", + color="#ffffff", fontsize=10, +) + + +# ── adjustable range widgets on the spectrum ─────────────────────────────────── + +range_widgets: dict[str, object] = {} +for elem, (lo, hi), color in zip(EDS_ELEMENTS, _EDS_WIN, _EDS_COLORS): + range_widgets[elem] = spec_plot.add_range_widget(lo, hi, color=color) + +_current_spectrum: list[np.ndarray] = [_init_spec.copy()] + + +def _channel_sum(x0: float, x1: float) -> float: + """Sum of ROI spectrum counts within the energy window [x0, x1].""" + mask = (ENERGY >= x0) & (ENERGY <= x1) + return float(_current_spectrum[0][mask].sum()) if mask.any() else 0.0 + + +def _update_bars() -> None: + heights = np.array([ + _channel_sum(range_widgets[e].x0, range_widgets[e].x1) + for e in EDS_ELEMENTS + ]) + max_h = heights.max() or 1.0 + bar_plot.set_data((heights / max_h).tolist()) + + +for _rw in range_widgets.values(): + _rw.add_event_handler(lambda event: _update_bars(), "pointer_move") + _rw.add_event_handler(lambda event: _update_bars(), "pointer_up") + +_update_bars() + + +# ── update helper ────────────────────────────────────────────────────────────── + +_current_roi: list[str | None] = [None] +_roi_dragging = False + + +def _update_for_roi(roi_name: str) -> None: + _current_roi[0] = roi_name + r0, r1, c0, c1 = ROIS[roi_name] + _current_spectrum[0] = _sum_spectrum(r0, r1, c0, c1).astype(np.float32) + spec_plot.set_data(_current_spectrum[0], x_axis=ENERGY) + spec_plot.set_color(_ROI_COLORS[roi_name]) + _update_bars() + n_pixels = (r1 - r0) * (c1 - c0) + status_label.set(text=f"ROI: {roi_name} ({n_pixels} px)") + + +# ── event handlers ───────────────────────────────────────────────────────────── + +def _on_move(event) -> None: + if _roi_dragging or event.xdata is None or event.ydata is None: + return + roi_name = _roi_at(event.xdata, event.ydata) + if roi_name is None or roi_name == _current_roi[0]: + return + _update_for_roi(roi_name) + + +def _on_enter(event) -> None: + status_label.set(text="Move cursor into an ROI") + + +def _on_leave(event) -> None: + status_label.set(text="Move cursor over image to inspect") + _current_roi[0] = None + + +img_plot.add_event_handler(_on_move, "pointer_move") +img_plot.add_event_handler(_on_enter, "pointer_enter") +img_plot.add_event_handler(_on_leave, "pointer_leave") + +for roi_name, widget in _roi_widgets.items(): + def _make_drag_handler(): + def _on_drag(event) -> None: + global _roi_dragging + _roi_dragging = True + return _on_drag + + def _make_release_handler(name, wgt): + def _on_release(event) -> None: + global _roi_dragging + _roi_dragging = False + x, y, w, h = wgt.x, wgt.y, wgt.w, wgt.h + ROIS[name] = (int(y), int(y + h), int(x), int(x + w)) + _update_for_roi(name) + return _on_release + + widget.add_event_handler(_make_drag_handler(), "pointer_move") + widget.add_event_handler(_make_release_handler(roi_name, widget), "pointer_up") + +fig.set_help( + "Move cursor inside an ROI: spatial sum spectrum + bars\n" + "Drag ROI rectangle: repositions ROI; release recomputes\n" + "Drag a coloured range widget: adjust element integration window" +) + +fig # Interactive From 2d4e0c3dddf89f4203ad9fe295b47875d90860fd Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 20 May 2026 15:03:27 -0500 Subject: [PATCH 160/198] Refactor: Update and clean ROI-to-spectrum inspector for 3-D EDS hyperspectral datasets --- anyplotlib/tests/test_examples/test_interactive_examples.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/anyplotlib/tests/test_examples/test_interactive_examples.py b/anyplotlib/tests/test_examples/test_interactive_examples.py index cd644c87..4531882b 100644 --- a/anyplotlib/tests/test_examples/test_interactive_examples.py +++ b/anyplotlib/tests/test_examples/test_interactive_examples.py @@ -10,7 +10,7 @@ "plot_particle_picker.py", "plot_eels_explorer.py", "plot_threshold_explorer.py", - "plot_roi_inspector.py", + "plot_spectra_roi_inspector.py", ] From af6806ad352b28b047592a6ac653b183c72627f4 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 20 May 2026 18:01:54 -0500 Subject: [PATCH 161/198] Refactor: Add transform parameter to marker functions for coordinate flexibility --- anyplotlib/figure_esm.js | 65 +++++++++++++++++++++++++---------- anyplotlib/markers.py | 16 +++++++++ anyplotlib/plot1d/_plot1d.py | 66 ++++++++++++++++++++++++------------ anyplotlib/plot2d/_plot2d.py | 66 ++++++++++++++++++++++++------------ 4 files changed, 151 insertions(+), 62 deletions(-) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index a77874a6..0a651cc4 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -1375,13 +1375,28 @@ function render({ model, el }) { const fch = isHov && ms.hover_facecolor ? ms.hover_facecolor : fc; const dlw = isHov && (ms.hover_color || ms.hover_facecolor) ? lw+1 : lw; const type = ms.type || 'circles'; + + // Coordinate transform dispatch: "data" (default), "axes", "display". + // For non-data transforms sizes are in pixels, not scaled by zoom. + const tfm = ms.transform || 'data'; + let _tc; + if(tfm==='axes'){ + const fr=_imgFitRect(st.image_width,st.image_height,imgW,imgH); + _tc=(fx,fy)=>[fr.x+fx*fr.w, fr.y+(1-fy)*fr.h]; + } else if(tfm==='display'){ + _tc=(ix,iy)=>[ix,iy]; + } else { + _tc=(ix,iy)=>_imgToCanvas2d(ix,iy,st,imgW,imgH); + } + const scl = tfm==='data' ? scale : 1; + mkCtx.save(); mkCtx.strokeStyle=ec; mkCtx.fillStyle=ec; mkCtx.lineWidth=dlw; if(type==='circles'){ for(let i=0;i[r.x+fx*r.w, r.y+(1-fy)*r.h]; + } else if(tfm==='display'){ + _tc2d=(ix,iy)=>[ix,iy]; + } else { + _tc2d=(off0,off1)=>_offToCanvas([off0,off1]); + } + mkCtx.save();mkCtx.strokeStyle=ec;mkCtx.fillStyle=ec;mkCtx.lineWidth=dlw; if(type==='points'){ for(let i=0;i list: return arr.tolist() +_VALID_TRANSFORMS = frozenset({"data", "axes", "display"}) + + def _offsets_1d(offsets) -> list: """Accept (N,), (N,1) or (N,2) — return (N,1) or (N,2) list.""" arr = np.asarray(offsets, dtype=float) @@ -94,6 +97,11 @@ class MarkerGroup: def __init__(self, marker_type: str, name: str, kwargs: dict, push_fn): self._type = marker_type self._name = name + tfm = kwargs.get("transform", "data") + if tfm not in _VALID_TRANSFORMS: + raise ValueError( + f"transform must be one of {sorted(_VALID_TRANSFORMS)}, got {tfm!r}" + ) self._data: dict = dict(kwargs) self._push_fn = push_fn @@ -107,6 +115,11 @@ def set(self, **kwargs) -> None: Properties to update (e.g., offsets, radius, facecolors). Matplotlib-style names are translated to wire format. """ + if "transform" in kwargs and kwargs["transform"] not in _VALID_TRANSFORMS: + raise ValueError( + f"transform must be one of {sorted(_VALID_TRANSFORMS)}, " + f"got {kwargs['transform']!r}" + ) self._data.update(kwargs) self._push_fn() @@ -322,6 +335,9 @@ def to_wire(self, group_id: str) -> dict: else: raise ValueError(f"Unknown marker type: {t!r}") + # ── coordinate transform (always emitted; defaults to "data") ────── + wire["transform"] = d.get("transform", "data") + # ── common optional fields ────────────────────────────────────────── label = d.get("label") if label is not None: diff --git a/anyplotlib/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py index 259e4327..30c01bb3 100644 --- a/anyplotlib/plot1d/_plot1d.py +++ b/anyplotlib/plot1d/_plot1d.py @@ -852,7 +852,8 @@ def add_circles(self, offsets, name=None, *, radius=5, facecolors=None, edgecolors="#ff0000", linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 """Add circle markers at explicit (x, y) positions. On 1-D panels circles are rendered as filled/stroked discs; *radius* @@ -893,13 +894,15 @@ def add_circles(self, offsets, name=None, *, radius=5, linewidths=linewidths, alpha=alpha, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_points(self, offsets, name=None, *, sizes=5, color="#ff0000", facecolors=None, linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 """Add point markers at (x, y) positions in data coordinates. Parameters @@ -934,12 +937,14 @@ def add_points(self, offsets, name=None, *, sizes=5, linewidths=linewidths, alpha=alpha, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_hlines(self, y_values, name=None, *, color="#ff0000", linewidths=1.5, hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 """Add static horizontal lines spanning the full x range. Parameters @@ -966,12 +971,14 @@ def add_hlines(self, y_values, name=None, *, return self._add_marker("hlines", name, offsets=y_values, color=color, linewidths=linewidths, hover_edgecolors=hover_edgecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_vlines(self, x_values, name=None, *, color="#ff0000", linewidths=1.5, hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 """Add static vertical lines spanning the full y range. Parameters @@ -998,12 +1005,14 @@ def add_vlines(self, x_values, name=None, *, return self._add_marker("vlines", name, offsets=x_values, color=color, linewidths=linewidths, hover_edgecolors=hover_edgecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_arrows(self, offsets, U, V, name=None, *, edgecolors="#ff0000", linewidths=1.5, hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 """Add arrow markers at explicit (x, y) positions. Parameters @@ -1032,13 +1041,15 @@ def add_arrows(self, offsets, U, V, name=None, *, return self._add_marker("arrows", name, offsets=offsets, U=U, V=V, edgecolors=edgecolors, linewidths=linewidths, hover_edgecolors=hover_edgecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_ellipses(self, offsets, widths, heights, name=None, *, angles=0, facecolors=None, edgecolors="#ff0000", linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 """Add ellipse markers at explicit (x, y) positions. Parameters @@ -1076,12 +1087,14 @@ def add_ellipses(self, offsets, widths, heights, name=None, *, linewidths=linewidths, alpha=alpha, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_lines(self, segments, name=None, *, edgecolors="#ff0000", linewidths=1.5, hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 """Add line-segment markers (static, not draggable). Parameters @@ -1108,13 +1121,15 @@ def add_lines(self, segments, name=None, *, return self._add_marker("lines", name, segments=segments, edgecolors=edgecolors, linewidths=linewidths, hover_edgecolors=hover_edgecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_rectangles(self, offsets, widths, heights, name=None, *, angles=0, facecolors=None, edgecolors="#ff0000", linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 """Add rectangle markers at explicit (x, y) positions. Parameters @@ -1152,13 +1167,15 @@ def add_rectangles(self, offsets, widths, heights, name=None, *, linewidths=linewidths, alpha=alpha, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_squares(self, offsets, widths, name=None, *, angles=0, facecolors=None, edgecolors="#ff0000", linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 """Add square markers at explicit (x, y) positions. Parameters @@ -1196,13 +1213,15 @@ def add_squares(self, offsets, widths, name=None, *, linewidths=linewidths, alpha=alpha, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_polygons(self, vertices_list, name=None, *, facecolors=None, edgecolors="#ff0000", linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 """Add polygon markers defined by explicit vertex lists. Parameters @@ -1236,12 +1255,14 @@ def add_polygons(self, vertices_list, name=None, *, linewidths=linewidths, alpha=alpha, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_texts(self, offsets, texts, name=None, *, color="#ff0000", fontsize=12, hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 """Add text annotations at explicit (x, y) positions. Parameters @@ -1270,7 +1291,8 @@ def add_texts(self, offsets, texts, name=None, *, return self._add_marker("texts", name, offsets=offsets, texts=texts, color=color, fontsize=fontsize, hover_edgecolors=hover_edgecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def remove_marker(self, marker_type: str, name: str) -> None: """Remove a named marker collection by type and name. diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index 280af9f8..23e91b3a 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -458,125 +458,147 @@ def add_circles(self, offsets, name=None, *, radius=5, facecolors=None, edgecolors="#ff0000", linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 """Add circle markers at (x, y) positions in data coordinates.""" return self._add_marker("circles", name, offsets=offsets, radius=radius, facecolors=facecolors, edgecolors=edgecolors, linewidths=linewidths, alpha=alpha, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_points(self, offsets, name=None, *, sizes=5, color="#ff0000", facecolors=None, linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 """Add point markers at (x, y) positions in data coordinates.""" return self._add_marker("circles", name, offsets=offsets, radius=sizes, edgecolors=color, facecolors=facecolors, linewidths=linewidths, alpha=alpha, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_hlines(self, y_values, name=None, *, color="#ff0000", linewidths=1.5, hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 """Add static horizontal lines at the given y positions.""" return self._add_marker("hlines", name, offsets=y_values, color=color, linewidths=linewidths, hover_edgecolors=hover_edgecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_vlines(self, x_values, name=None, *, color="#ff0000", linewidths=1.5, hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 """Add static vertical lines at the given x positions.""" return self._add_marker("vlines", name, offsets=x_values, color=color, linewidths=linewidths, hover_edgecolors=hover_edgecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_arrows(self, offsets, U, V, name=None, *, edgecolors="#ff0000", linewidths=1.5, hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 return self._add_marker("arrows", name, offsets=offsets, U=U, V=V, edgecolors=edgecolors, linewidths=linewidths, hover_edgecolors=hover_edgecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_ellipses(self, offsets, widths, heights, name=None, *, angles=0, facecolors=None, edgecolors="#ff0000", linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 return self._add_marker("ellipses", name, offsets=offsets, widths=widths, heights=heights, angles=angles, facecolors=facecolors, edgecolors=edgecolors, linewidths=linewidths, alpha=alpha, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_lines(self, segments, name=None, *, edgecolors="#ff0000", linewidths=1.5, hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 return self._add_marker("lines", name, segments=segments, edgecolors=edgecolors, linewidths=linewidths, hover_edgecolors=hover_edgecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_rectangles(self, offsets, widths, heights, name=None, *, angles=0, facecolors=None, edgecolors="#ff0000", linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 return self._add_marker("rectangles", name, offsets=offsets, widths=widths, heights=heights, angles=angles, facecolors=facecolors, edgecolors=edgecolors, linewidths=linewidths, alpha=alpha, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_squares(self, offsets, widths, name=None, *, angles=0, facecolors=None, edgecolors="#ff0000", linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 return self._add_marker("squares", name, offsets=offsets, widths=widths, angles=angles, facecolors=facecolors, edgecolors=edgecolors, linewidths=linewidths, alpha=alpha, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_polygons(self, vertices_list, name=None, *, facecolors=None, edgecolors="#ff0000", linewidths=1.5, alpha=0.3, hover_edgecolors=None, hover_facecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 return self._add_marker("polygons", name, vertices_list=vertices_list, facecolors=facecolors, edgecolors=edgecolors, linewidths=linewidths, alpha=alpha, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def add_texts(self, offsets, texts, name=None, *, color="#ff0000", fontsize=12, hover_edgecolors=None, - labels=None, label=None) -> "MarkerGroup": # noqa: F821 + labels=None, label=None, + transform: str = "data") -> "MarkerGroup": # noqa: F821 return self._add_marker("texts", name, offsets=offsets, texts=texts, color=color, fontsize=fontsize, hover_edgecolors=hover_edgecolors, - labels=labels, label=label) + labels=labels, label=label, + transform=transform) def remove_marker(self, marker_type: str, name: str) -> None: """Remove a named marker collection by type and name. From d9f781a7c82a4b86156bdfaa857c64254206447f Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 20 May 2026 18:18:00 -0500 Subject: [PATCH 162/198] Refactor: Enhance Plot1D and Plot2D classes with additional state management methods and properties for axis labels, visibility, and ranges --- anyplotlib/plot1d/_plot1d.py | 62 ++++++++ anyplotlib/plot2d/_plot2d.py | 80 ++++++++++ anyplotlib/tests/test_plot1d/test_plot1d.py | 96 ++++++++++++ .../tests/test_plot2d/test_plot2d_api.py | 137 ++++++++++++++++++ 4 files changed, 375 insertions(+) diff --git a/anyplotlib/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py index 30c01bb3..7c41a151 100644 --- a/anyplotlib/plot1d/_plot1d.py +++ b/anyplotlib/plot1d/_plot1d.py @@ -287,6 +287,14 @@ def __init__(self, data: np.ndarray, "markers": [], "pointer_settled_ms": 0, "pointer_settled_delta": 4, + # Annotation labels + "title": "", + # Explicit y-range override: [ymin, ymax] or None (auto) + "y_range": None, + # Visibility toggles + "axis_visible": True, + "x_ticks_visible": True, + "y_ticks_visible": True, } self.markers = MarkerRegistry(self._push_markers, @@ -842,6 +850,60 @@ def set_marker(self, marker: str, markersize: float | None = None) -> None: self._state["line_markersize"] = float(markersize) self._push() + @property + def color(self) -> str: + return self._state["line_color"] + + @property + def x(self) -> np.ndarray: + return np.asarray(self._state["x_axis"]) + + @property + def y(self) -> np.ndarray: + return np.asarray(self._state["data"]) + + def set_xlabel(self, label: str) -> None: + self._state["units"] = str(label) + self._push() + + def set_ylabel(self, label: str) -> None: + self._state["y_units"] = str(label) + self._push() + + def set_title(self, label: str) -> None: + self._state["title"] = str(label) + self._push() + + def set_xlim(self, xmin: float, xmax: float) -> None: + self.set_view(x0=xmin, x1=xmax) + + def set_ylim(self, ymin: float, ymax: float) -> None: + self._state["y_range"] = [float(ymin), float(ymax)] + self._push() + + def get_ylim(self) -> tuple: + return (float(self._state["data_min"]), float(self._state["data_max"])) + + def get_xbound(self) -> tuple: + xarr = np.asarray(self._state["x_axis"]) + return (float(xarr.min()), float(xarr.max())) + + def set_axis_off(self) -> None: + self._state["axis_visible"] = False + self._push() + + def set_ticks_visible(self, visible: bool, *, x: bool | None = None, + y: bool | None = None) -> None: + if x is None and y is None: + self._state["x_ticks_visible"] = bool(visible) + self._state["y_ticks_visible"] = bool(visible) + else: + if x is not None: + self._state["x_ticks_visible"] = bool(x) + if y is not None: + self._state["y_ticks_visible"] = bool(y) + self._push() + # ------------------------------------------------------------------ # Marker API (matplotlib-style kwargs → MarkerRegistry) # ------------------------------------------------------------------ diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index 23e91b3a..55a9b81f 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -126,6 +126,17 @@ def __init__(self, data: np.ndarray, # Set True when Python explicitly changes view; JS uses it to # decide whether to preserve the current frontend zoom/pan state. "_view_from_python": False, + # Axis / annotation labels (rendered by JS in Phase 4) + "x_label": "", + "y_label": "", + "title": "", + "colorbar_label": "", + # Aspect ratio: None means free, float means width/height ratio + "aspect": None, + # Visibility toggles + "axis_visible": True, + "x_ticks_visible": True, + "y_ticks_visible": True, } self.markers = MarkerRegistry(self._push_markers, @@ -305,6 +316,75 @@ def colormap_name(self) -> str: def colormap_name(self, name: str) -> None: self.set_colormap(name) + def set_xlabel(self, label: str) -> None: + self._state["x_label"] = str(label) + self._push() + + def set_ylabel(self, label: str) -> None: + self._state["y_label"] = str(label) + self._push() + + def set_title(self, label: str) -> None: + self._state["title"] = str(label) + self._push() + + def set_xlim(self, xmin: float, xmax: float) -> None: + self.set_view(x0=xmin, x1=xmax) + + def set_ylim(self, ymin: float, ymax: float) -> None: + self.set_view(y0=ymin, y1=ymax) + + def get_ylim(self) -> tuple: + yarr = np.asarray(self._state["y_axis"]) + return (float(yarr.min()), float(yarr.max())) + + def get_xbound(self) -> tuple: + xarr = np.asarray(self._state["x_axis"]) + return (float(xarr.min()), float(xarr.max())) + + def set_extent(self, x_axis, y_axis) -> None: + x_axis = np.asarray(x_axis, dtype=float) + y_axis = np.asarray(y_axis, dtype=float) + w = self._state["image_width"] + h = self._state["image_height"] + scale_x = float(abs(x_axis[-1] - x_axis[0]) / max(w - 1, 1)) if len(x_axis) >= 2 else 1.0 + scale_y = float(abs(y_axis[-1] - y_axis[0]) / max(h - 1, 1)) if len(y_axis) >= 2 else 1.0 + self._state["x_axis"] = x_axis.tolist() + self._state["y_axis"] = y_axis.tolist() + self._state["scale_x"] = scale_x + self._state["scale_y"] = scale_y + self._push() + + def set_colorbar_label(self, label: str) -> None: + self._state["colorbar_label"] = str(label) + self._push() + + def set_colorbar_visible(self, visible: bool) -> None: + self._state["show_colorbar"] = bool(visible) + self._push() + + def set_aspect(self, ratio) -> None: + if ratio == "equal": + ratio = 1.0 + self._state["aspect"] = float(ratio) if ratio is not None else None + self._push() + + def set_axis_off(self) -> None: + self._state["axis_visible"] = False + self._push() + + def set_ticks_visible(self, visible: bool, *, x: bool | None = None, + y: bool | None = None) -> None: + if x is None and y is None: + self._state["x_ticks_visible"] = bool(visible) + self._state["y_ticks_visible"] = bool(visible) + else: + if x is not None: + self._state["x_ticks_visible"] = bool(x) + if y is not None: + self._state["y_ticks_visible"] = bool(y) + self._push() + # ------------------------------------------------------------------ # Overlay Widgets # ------------------------------------------------------------------ diff --git a/anyplotlib/tests/test_plot1d/test_plot1d.py b/anyplotlib/tests/test_plot1d/test_plot1d.py index abe21e32..b58c15fc 100644 --- a/anyplotlib/tests/test_plot1d/test_plot1d.py +++ b/anyplotlib/tests/test_plot1d/test_plot1d.py @@ -661,3 +661,99 @@ def test_clear_markers(self): p.clear_markers() assert p.markers.to_wire_list() == [] + +# =========================================================================== +# Phase 2 — Plot1D state methods +# =========================================================================== + +class TestPlot1DProperties: + + def test_color_property(self): + p = _plot(color="#ff0000") + assert p.color == "#ff0000" + + def test_x_property_returns_ndarray(self): + p = _plot_lin(32) + x = p.x + assert isinstance(x, np.ndarray) + assert len(x) == 32 + + def test_y_property_returns_ndarray(self): + data = np.linspace(0.0, 1.0, 64) + fig, ax = apl.subplots(1, 1) + p = ax.plot(data) + y = p.y + assert isinstance(y, np.ndarray) + assert len(y) == 64 + + +class TestPlot1DLabels: + + def test_set_xlabel_updates_units(self): + p = _plot() + p.set_xlabel("Energy (eV)") + assert p._state["units"] == "Energy (eV)" + + def test_set_ylabel_updates_y_units(self): + p = _plot() + p.set_ylabel("Counts") + assert p._state["y_units"] == "Counts" + + def test_set_title(self): + p = _plot() + p.set_title("Spectrum") + assert p._state["title"] == "Spectrum" + + def test_default_title_empty(self): + p = _plot() + assert p._state["title"] == "" + + +class TestPlot1DAxisLimits: + + def test_set_xlim_changes_view(self): + p = _plot_lin(64) + p.set_xlim(10, 50) + assert p._state["view_x0"] != 0.0 or p._state["view_x1"] != 1.0 + + def test_set_ylim_stores_y_range(self): + p = _plot() + p.set_ylim(-2.0, 2.0) + assert p._state["y_range"] == [-2.0, 2.0] + + def test_get_ylim_returns_data_bounds(self): + data = np.array([0.0, 1.0, 2.0, 3.0, 4.0]) + fig, ax = apl.subplots(1, 1) + p = ax.plot(data) + lo, hi = p.get_ylim() + assert lo < hi + assert lo <= 0.0 + assert hi >= 4.0 + + def test_get_xbound_returns_x_range(self): + p = _plot_lin(32) + lo, hi = p.get_xbound() + assert lo == pytest.approx(0.0) + assert hi == pytest.approx(31.0) + + +class TestPlot1DAxisVisibility: + + def test_set_axis_off(self): + p = _plot() + assert p._state["axis_visible"] is True + p.set_axis_off() + assert p._state["axis_visible"] is False + + def test_set_ticks_visible_false(self): + p = _plot() + p.set_ticks_visible(False) + assert p._state["x_ticks_visible"] is False + assert p._state["y_ticks_visible"] is False + + def test_set_ticks_visible_per_axis(self): + p = _plot() + p.set_ticks_visible(False, x=True, y=False) + assert p._state["x_ticks_visible"] is True + assert p._state["y_ticks_visible"] is False + diff --git a/anyplotlib/tests/test_plot2d/test_plot2d_api.py b/anyplotlib/tests/test_plot2d/test_plot2d_api.py index 2203c621..13d41630 100644 --- a/anyplotlib/tests/test_plot2d/test_plot2d_api.py +++ b/anyplotlib/tests/test_plot2d/test_plot2d_api.py @@ -115,3 +115,140 @@ def test_no_debug_print_in_on_event(capsys): fig._on_event({"new": json.dumps(payload)}) captured = capsys.readouterr() assert captured.out == "", f"Unexpected stdout: {captured.out!r}" + + +# =========================================================================== +# Phase 2 — Plot2D state methods +# =========================================================================== + +class TestPlot2DLabels: + + def test_set_xlabel(self): + p = _make_plot2d() + p.set_xlabel("x (nm)") + assert p._state["x_label"] == "x (nm)" + + def test_set_ylabel(self): + p = _make_plot2d() + p.set_ylabel("y (nm)") + assert p._state["y_label"] == "y (nm)" + + def test_set_title(self): + p = _make_plot2d() + p.set_title("My Image") + assert p._state["title"] == "My Image" + + def test_set_colorbar_label(self): + p = _make_plot2d() + p.set_colorbar_label("Intensity") + assert p._state["colorbar_label"] == "Intensity" + + def test_default_labels_empty(self): + p = _make_plot2d() + assert p._state["x_label"] == "" + assert p._state["y_label"] == "" + assert p._state["title"] == "" + assert p._state["colorbar_label"] == "" + + +class TestPlot2DAxisLimits: + + def test_set_xlim_delegates_to_set_view(self): + p = _make_plot2d((32, 32)) + p.set_xlim(5, 20) + assert p._state["zoom"] != 1.0 or p._state["center_x"] != 0.5 + + def test_set_ylim_delegates_to_set_view(self): + p = _make_plot2d((32, 32)) + p.set_ylim(5, 20) + assert p._state["zoom"] != 1.0 or p._state["center_y"] != 0.5 + + def test_get_ylim_returns_y_axis_bounds(self): + fig, ax = apl.subplots(1, 1) + y_axis = np.linspace(0.0, 5.0, 32) + p = ax.imshow(np.zeros((32, 32)), axes=[np.arange(32), y_axis]) + lo, hi = p.get_ylim() + assert lo == pytest.approx(0.0) + assert hi == pytest.approx(5.0) + + def test_get_xbound_returns_x_axis_bounds(self): + fig, ax = apl.subplots(1, 1) + x_axis = np.linspace(-1.0, 3.0, 32) + p = ax.imshow(np.zeros((32, 32)), axes=[x_axis, np.arange(32)]) + lo, hi = p.get_xbound() + assert lo == pytest.approx(-1.0) + assert hi == pytest.approx(3.0) + + +class TestPlot2DExtent: + + def test_set_extent_updates_axes(self): + p = _make_plot2d((32, 32)) + x_new = np.linspace(0.0, 10.0, 32) + y_new = np.linspace(0.0, 20.0, 32) + p.set_extent(x_new, y_new) + assert p._state["x_axis"][0] == pytest.approx(0.0) + assert p._state["x_axis"][-1] == pytest.approx(10.0) + assert p._state["y_axis"][-1] == pytest.approx(20.0) + + def test_set_extent_updates_scale(self): + p = _make_plot2d((32, 32)) + x_new = np.linspace(0.0, 31.0, 32) + y_new = np.linspace(0.0, 62.0, 32) + p.set_extent(x_new, y_new) + assert p._state["scale_x"] == pytest.approx(1.0) + assert p._state["scale_y"] == pytest.approx(2.0) + + +class TestPlot2DColorbar: + + def test_set_colorbar_visible_true(self): + p = _make_plot2d() + p.set_colorbar_visible(True) + assert p._state["show_colorbar"] is True + + def test_set_colorbar_visible_false(self): + p = _make_plot2d() + p.set_colorbar_visible(True) + p.set_colorbar_visible(False) + assert p._state["show_colorbar"] is False + + +class TestPlot2DAspect: + + def test_set_aspect_float(self): + p = _make_plot2d() + p.set_aspect(2.0) + assert p._state["aspect"] == pytest.approx(2.0) + + def test_set_aspect_equal_string(self): + p = _make_plot2d() + p.set_aspect("equal") + assert p._state["aspect"] == pytest.approx(1.0) + + def test_set_aspect_none(self): + p = _make_plot2d() + p.set_aspect("equal") + p.set_aspect(None) + assert p._state["aspect"] is None + + +class TestPlot2DAxisVisibility: + + def test_set_axis_off(self): + p = _make_plot2d() + assert p._state["axis_visible"] is True + p.set_axis_off() + assert p._state["axis_visible"] is False + + def test_set_ticks_visible_false(self): + p = _make_plot2d() + p.set_ticks_visible(False) + assert p._state["x_ticks_visible"] is False + assert p._state["y_ticks_visible"] is False + + def test_set_ticks_visible_per_axis(self): + p = _make_plot2d() + p.set_ticks_visible(False, x=False, y=True) + assert p._state["x_ticks_visible"] is False + assert p._state["y_ticks_visible"] is True From 17e6f64b00db41276e7e7dc50539f5705d946cb5 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 20 May 2026 20:30:39 -0500 Subject: [PATCH 163/198] Refactor: Introduce color cycle utility and enhance Plot1D with logarithmic y-axis support --- anyplotlib/__init__.py | 14 +- anyplotlib/_utils.py | 20 +- anyplotlib/axes/_axes.py | 11 +- anyplotlib/callbacks.py | 2 +- anyplotlib/figure/_figure.py | 19 ++ anyplotlib/figure_esm.js | 224 +++++++++++++----- anyplotlib/markers.py | 12 +- anyplotlib/plot1d/_plot1d.py | 4 +- .../tests/test_interactive/test_callbacks.py | 42 ++++ anyplotlib/tests/test_layouts/test_visual.py | 43 ++++ anyplotlib/tests/test_markers/test_markers.py | 34 +++ anyplotlib/tests/test_plot1d/test_plot1d.py | 48 ++++ .../tests/test_plot2d/test_plot2d_api.py | 24 ++ 13 files changed, 422 insertions(+), 75 deletions(-) diff --git a/anyplotlib/__init__.py b/anyplotlib/__init__.py index e215e53c..c897ddce 100644 --- a/anyplotlib/__init__.py +++ b/anyplotlib/__init__.py @@ -15,6 +15,18 @@ # Default True: badges appear whenever a figure has help text set. show_help: bool = True +_COLOR_CYCLE: list[str] = [ + "#4fc3f7", "#ff7043", "#aed581", "#ffd54f", + "#ba68c8", "#4db6ac", "#f06292", "#90a4ae", + "#ffb74d", "#a5d6a7", +] + + +def get_color_cycle() -> list[str]: + """Return the default color cycle as a list of CSS hex strings.""" + return list(_COLOR_CYCLE) + + __all__ = [ "Figure", "GridSpec", "SubplotSpec", "subplots", "Axes", "InsetAxes", "Plot1D", "Plot2D", "PlotMesh", "Plot3D", "PlotBar", @@ -22,5 +34,5 @@ "Widget", "RectangleWidget", "CircleWidget", "AnnularWidget", "CrosshairWidget", "PolygonWidget", "LabelWidget", "VLineWidget", "HLineWidget", "RangeWidget", - "show_help", + "show_help", "get_color_cycle", ] diff --git a/anyplotlib/_utils.py b/anyplotlib/_utils.py index 501de5db..404cfaf5 100644 --- a/anyplotlib/_utils.py +++ b/anyplotlib/_utils.py @@ -9,14 +9,16 @@ import numpy as np _LINESTYLE_ALIASES: dict[str, str] = { - "-": "solid", - "--": "dashed", - ":": "dotted", - "-.": "dashdot", - "solid": "solid", - "dashed": "dashed", - "dotted": "dotted", - "dashdot": "dashdot", + "-": "solid", + "--": "dashed", + ":": "dotted", + "-.": "dashdot", + "solid": "solid", + "dashed": "dashed", + "dotted": "dotted", + "dashdot": "dashdot", + "step-mid": "step-mid", + "steps-mid": "step-mid", } @@ -49,7 +51,7 @@ def _norm_linestyle(ls: str) -> str: if canonical is None: raise ValueError( f"Unknown linestyle {ls!r}. Expected one of: " - "'solid', 'dashed', 'dotted', 'dashdot', " + "'solid', 'dashed', 'dotted', 'dashdot', 'step-mid' " "or shorthands '-', '--', ':', '-.'." ) return canonical diff --git a/anyplotlib/axes/_axes.py b/anyplotlib/axes/_axes.py index 6d4c6ea8..e526573e 100644 --- a/anyplotlib/axes/_axes.py +++ b/anyplotlib/axes/_axes.py @@ -192,7 +192,8 @@ def plot(self, data: np.ndarray, alpha: float = 1.0, marker: str = "none", markersize: float = 4.0, - label: str = "") -> "Plot1D": + label: str = "", + yscale: str = "linear") -> "Plot1D": """Attach a 1-D line to this axes cell. Parameters @@ -265,10 +266,16 @@ def plot(self, data: np.ndarray, color=color, linewidth=linewidth, linestyle=ls if ls is not None else linestyle, alpha=alpha, marker=marker, markersize=markersize, - label=label) + label=label, yscale=yscale) self._attach(plot) return plot + def semilogy(self, data: np.ndarray, + axes: list | None = None, **kwargs) -> "Plot1D": + """Attach a 1-D line with a logarithmic y-axis.""" + kwargs.setdefault("yscale", "log") + return self.plot(data, axes=axes, **kwargs) + def bar(self, x, height=None, width: float = 0.8, bottom: float = 0.0, *, align: str = "center", color: str = "#4fc3f7", diff --git a/anyplotlib/callbacks.py b/anyplotlib/callbacks.py index 6330c1cf..386eab34 100644 --- a/anyplotlib/callbacks.py +++ b/anyplotlib/callbacks.py @@ -24,7 +24,7 @@ VALID_EVENT_TYPES = frozenset({ "pointer_down", "pointer_up", "pointer_move", "pointer_settled", "pointer_enter", "pointer_leave", "double_click", "wheel", - "key_down", "key_up", "*", + "key_down", "key_up", "close", "*", }) diff --git a/anyplotlib/figure/_figure.py b/anyplotlib/figure/_figure.py index 7d832dda..a3478708 100644 --- a/anyplotlib/figure/_figure.py +++ b/anyplotlib/figure/_figure.py @@ -464,6 +464,25 @@ def _repr_html_(self) -> str: """ return repr_html_iframe(self) + def close(self) -> None: + """Close the figure. + + Fires a ``"close"`` event on every panel's :attr:`callbacks`, then + hides the widget by setting its CSS ``display`` to ``"none"``. + Subsequent calls are no-ops. + """ + if getattr(self, "_closed", False): + return + self._closed = True + close_event = Event(event_type="close") + for plot in self._plots_map.values(): + if hasattr(plot, "callbacks"): + plot.callbacks.fire(close_event) + try: + self.layout = {"display": "none"} + except Exception: + pass + def __repr__(self) -> str: return (f"Figure({self._nrows}x{self._ncols}, " f"panels={len(self._plots_map)}, " diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 0a651cc4..4659860c 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -864,13 +864,14 @@ function render({ model, el }) { // Colorbar: narrow strip to the right of the image area if (p.cbCanvas && p.cbCtx) { - const cbW = 16; + const cbStripW = 16; + const cbTotalW = (st && st.colorbar_label) ? cbStripW + 14 : cbStripW; const vis = st && st.show_colorbar; if (vis) { p.cbCanvas.style.display = 'block'; p.cbCanvas.style.left = (imgX + imgW + 2) + 'px'; p.cbCanvas.style.top = imgY + 'px'; - _sz(p.cbCanvas, p.cbCtx, cbW, imgH); + _sz(p.cbCanvas, p.cbCtx, cbTotalW, imgH); } else { p.cbCanvas.style.display = 'none'; } @@ -1160,7 +1161,9 @@ function render({ model, el }) { p.cbCanvas.style.display = vis ? 'block' : 'none'; if(!vis) return; - const cbW=16; + const cbStripW=16; + const cbLabel=st.colorbar_label||''; + const cbW=cbLabel?(cbStripW+14):cbStripW; const imgH=p.imgH||Math.max(1,p.ph-PAD_T-PAD_B); const ctx=p.cbCtx; ctx.clearRect(0,0,cbW,imgH); @@ -1172,17 +1175,17 @@ function render({ model, el }) { const ci=Math.max(0,Math.min(255,Math.round(frac*255))); const [r2,g2,b2]=st.colormap_data[ci]; ctx.fillStyle=`rgb(${r2},${g2},${b2})`; - ctx.fillRect(0,py,cbW,1); + ctx.fillRect(0,py,cbStripW,1); } } else { ctx.fillStyle=theme.dark?'#444':'#ccc'; - ctx.fillRect(0,0,cbW,imgH); + ctx.fillRect(0,0,cbStripW,imgH); } // Border ctx.strokeStyle=theme.border||'#888'; ctx.lineWidth=0.5; - ctx.strokeRect(0,0,cbW,imgH); + ctx.strokeRect(0,0,cbStripW,imgH); // display_min / display_max tick marks const dMin=st.display_min, dMax=st.display_max; @@ -1191,8 +1194,20 @@ function render({ model, el }) { const vRange=(hMax-hMin)||1; function _vToY(v){return imgH-1-((v-hMin)/vRange)*(imgH-1);} ctx.strokeStyle='rgba(255,255,255,0.85)'; ctx.lineWidth=1.5; - ctx.beginPath();ctx.moveTo(0,_vToY(dMax));ctx.lineTo(cbW,_vToY(dMax));ctx.stroke(); - ctx.beginPath();ctx.moveTo(0,_vToY(dMin));ctx.lineTo(cbW,_vToY(dMin));ctx.stroke(); + ctx.beginPath();ctx.moveTo(0,_vToY(dMax));ctx.lineTo(cbStripW,_vToY(dMax));ctx.stroke(); + ctx.beginPath();ctx.moveTo(0,_vToY(dMin));ctx.lineTo(cbStripW,_vToY(dMin));ctx.stroke(); + + // Colorbar label (rotated −90° to the right of the strip) + if(cbLabel){ + ctx.save(); + ctx.translate(cbStripW+9, imgH/2); + ctx.rotate(-Math.PI/2); + ctx.textAlign='center'; ctx.textBaseline='middle'; + ctx.fillStyle=theme.unitText; + ctx.font='10px sans-serif'; + ctx.fillText(cbLabel,0,0); + ctx.restore(); + } } @@ -1206,8 +1221,15 @@ function render({ model, el }) { const zoom=st.zoom, cx=st.center_x, cy=st.center_y; const units=st.units||'px'; const hasPhysAxis = (st.is_mesh || st.has_axes) && xArr.length>=2 && yArr.length>=2; - const hasX = hasPhysAxis && p.xCtx && p.xAxisCanvas && p.xAxisCanvas.style.display!=='none'; - const hasY = hasPhysAxis && p.yCtx && p.yAxisCanvas && p.yAxisCanvas.style.display!=='none'; + if(st.axis_visible===false){ + if(p.xAxisCanvas) p.xAxisCanvas.style.display='none'; + if(p.yAxisCanvas) p.yAxisCanvas.style.display='none'; + } else if(hasPhysAxis){ + if(st.x_ticks_visible===false&&p.xAxisCanvas) p.xAxisCanvas.style.display='none'; + if(st.y_ticks_visible===false&&p.yAxisCanvas) p.yAxisCanvas.style.display='none'; + } + const hasX=hasPhysAxis&&st.axis_visible!==false&&st.x_ticks_visible!==false&&p.xCtx&&p.xAxisCanvas&&p.xAxisCanvas.style.display!=='none'; + const hasY=hasPhysAxis&&st.axis_visible!==false&&st.y_ticks_visible!==false&&p.yCtx&&p.yAxisCanvas&&p.yAxisCanvas.style.display!=='none'; function _visFrac(z,c){ if(z>=1.0){const h=0.5/z;const cc=Math.max(h,Math.min(1-h,c));return[cc-h,cc+h];} @@ -1258,6 +1280,8 @@ function render({ model, el }) { p.xCtx.textAlign='right'; p.xCtx.textBaseline='bottom'; p.xCtx.fillStyle=theme.unitText; p.xCtx.font='9px sans-serif'; p.xCtx.fillText(units, aw-2, ah-1); + const xlabel=st.x_label||''; + if(xlabel){p.xCtx.fillStyle=theme.tickText;p.xCtx.font='11px sans-serif';p.xCtx.textAlign='center';p.xCtx.textBaseline='bottom';p.xCtx.fillText(xlabel,aw/2,ah-2);} } // ── Y axis canvas: PAD_L × imgH, origin at top-left ───────────────── @@ -1293,6 +1317,28 @@ function render({ model, el }) { p.yCtx.textAlign='left'; p.yCtx.textBaseline='top'; p.yCtx.fillStyle=theme.unitText; p.yCtx.font='9px sans-serif'; p.yCtx.fillText(units, 2, 1); + const ylabel=st.y_label||''; + if(ylabel){ + p.yCtx.save(); + p.yCtx.translate(Math.round(aw*0.15),ah/2); + p.yCtx.rotate(-Math.PI/2); + p.yCtx.textAlign='center'; p.yCtx.textBaseline='middle'; + p.yCtx.fillStyle=theme.tickText; p.yCtx.font='11px sans-serif'; + p.yCtx.fillText(ylabel,0,0); + p.yCtx.restore(); + } + } + const title2d=st.title||''; + if(title2d&&p.plotCtx){ + const tw=p.imgW||imgW; + p.plotCtx.save(); + p.plotCtx.fillStyle='rgba(0,0,0,0.45)'; + p.plotCtx.fillRect(0,0,tw,18); + p.plotCtx.fillStyle='#ffffff'; + p.plotCtx.font='bold 11px sans-serif'; + p.plotCtx.textAlign='center'; p.plotCtx.textBaseline='middle'; + p.plotCtx.fillText(title2d,tw/2,9); + p.plotCtx.restore(); } } @@ -1913,6 +1959,14 @@ function render({ model, el }) { const dMin=st.data_min, dMax=st.data_max; const units=st.units||'', yUnits=st.y_units||''; + const isLog = st.yscale === 'log'; + const _logEps = 1e-300; + const effDMin = isLog ? Math.log10(Math.max(_logEps, dMin)) : dMin; + const effDMax = isLog ? Math.log10(Math.max(_logEps, dMax)) : dMax; + function _toPlotY(v) { + return _valToPy1d(isLog ? Math.log10(Math.max(_logEps, v)) : v, effDMin, effDMax, r); + } + ctx.clearRect(0,0,pw,ph); ctx.fillStyle=theme.bg; ctx.fillRect(0,0,pw,ph); ctx.fillStyle=theme.bgPlot; ctx.fillRect(r.x,r.y,r.w,r.h); @@ -1928,12 +1982,21 @@ function render({ model, el }) { ctx.beginPath();ctx.moveTo(px,r.y);ctx.lineTo(px,r.y+r.h);ctx.stroke(); } } - const yRange=(dMax-dMin)||1; + const yRange=(effDMax-effDMin)||1; const yStep=findNice(yRange/Math.max(2,Math.floor(r.h/40))); - for(let v=Math.ceil(dMin/yStep)*yStep;v<=dMax+yStep*0.01;v+=yStep){ - const py=_valToPy1d(v,dMin,dMax,r); - if(pyr.y+r.h) continue; - ctx.beginPath();ctx.moveTo(r.x,py);ctx.lineTo(r.x+r.w,py);ctx.stroke(); + if(isLog){ + const lo=Math.floor(effDMin), hi=Math.ceil(effDMax); + for(let e=lo;e<=hi;e++){ + const py=_toPlotY(Math.pow(10,e)); + if(pyr.y+r.h) continue; + ctx.beginPath();ctx.moveTo(r.x,py);ctx.lineTo(r.x+r.w,py);ctx.stroke(); + } + } else { + for(let v=Math.ceil(dMin/yStep)*yStep;v<=dMax+yStep*0.01;v+=yStep){ + const py=_valToPy1d(v,dMin,dMax,r); + if(pyr.y+r.h) continue; + ctx.beginPath();ctx.moveTo(r.x,py);ctx.lineTo(r.x+r.w,py);ctx.stroke(); + } } // Spans @@ -1944,7 +2007,7 @@ function render({ model, el }) { const px1b=_fracToPx1d(_xToFrac1d(xArr,sp.v1),x0,x1,r); ctx.fillRect(px0,r.y,px1b-px0,r.h); } else { - const py0=_valToPy1d(sp.v1,dMin,dMax,r), py1=_valToPy1d(sp.v0,dMin,dMax,r); + const py0=_toPlotY(sp.v1), py1=_toPlotY(sp.v0); ctx.fillRect(r.x,py0,r.w,py1-py0); } } @@ -2008,31 +2071,47 @@ function render({ model, el }) { function _drawLine(yData, lineXArr, color, lw, linestyle, alpha, marker, markersize) { if (!yData || !yData.length) return; const n = yData.length; - const dash = _LINESTYLE_DASH[linestyle || 'solid'] || []; + const isStepMid = linestyle === 'step-mid'; + const dash = isStepMid ? [] : (_LINESTYLE_DASH[linestyle || 'solid'] || []); const eff_alpha = (alpha != null && alpha < 1.0) ? alpha : 1.0; const ms = Math.max(1, markersize || 4); const doMarker = marker && marker !== 'none'; + // Pre-compute pixel positions + const allPx = new Array(n), allPy = new Array(n); + for (let i = 0; i < n; i++) { + const xFrac = lineXArr.length >= 2 + ? (lineXArr[i] - lineXArr[0]) / ((lineXArr[lineXArr.length - 1] - lineXArr[0]) || 1) + : i / ((n - 1) || 1); + allPx[i] = _fracToPx1d(xFrac, x0, x1, r); + allPy[i] = _toPlotY(yData[i]); + } + ctx.save(); if (eff_alpha < 1.0) ctx.globalAlpha = eff_alpha; ctx.setLineDash(dash); ctx.beginPath(); ctx.strokeStyle = color; ctx.lineWidth = lw; ctx.lineJoin = 'round'; - const pts = doMarker ? [] : null; - let first = true; - for (let i = 0; i < n; i++) { - const xFrac = lineXArr.length >= 2 - ? (lineXArr[i] - lineXArr[0]) / ((lineXArr[lineXArr.length - 1] - lineXArr[0]) || 1) - : i / ((n - 1) || 1); - const px = _fracToPx1d(xFrac, x0, x1, r); - const py = _valToPy1d(yData[i], dMin, dMax, r); - if (first) { ctx.moveTo(px, py); first = false; } else { ctx.lineTo(px, py); } - if (pts) pts.push([px, py]); + if (isStepMid && n >= 2) { + ctx.moveTo(allPx[0], allPy[0]); + for (let i = 0; i < n - 1; i++) { + const midX = (allPx[i] + allPx[i + 1]) / 2; + ctx.lineTo(midX, allPy[i]); + ctx.lineTo(midX, allPy[i + 1]); + } + ctx.lineTo(allPx[n - 1], allPy[n - 1]); + } else { + for (let i = 0; i < n; i++) { + if (i === 0) ctx.moveTo(allPx[i], allPy[i]); + else ctx.lineTo(allPx[i], allPy[i]); + } } ctx.stroke(); ctx.setLineDash([]); + const pts = doMarker ? allPx.map((px, i) => [px, allPy[i]]) : null; + // Per-point marker symbols if (doMarker && pts && pts.length) { ctx.strokeStyle = color; @@ -2088,45 +2167,64 @@ function render({ model, el }) { } ctx.restore(); + const axisVis1d=st.axis_visible!==false; + const xTicksVis1d=st.x_ticks_visible!==false; + const yTicksVis1d=st.y_ticks_visible!==false; + // Axes ctx.strokeStyle=theme.axisStroke; ctx.lineWidth=1; ctx.beginPath();ctx.moveTo(r.x,r.y+r.h);ctx.lineTo(r.x+r.w,r.y+r.h);ctx.stroke(); ctx.beginPath();ctx.moveTo(r.x,r.y);ctx.lineTo(r.x,r.y+r.h);ctx.stroke(); - ctx.fillStyle=theme.tickText; ctx.font='10px monospace'; - if(xArr.length>=2){ - const xVMin=_fracToX1d(xArr,x0), xVMax=_fracToX1d(xArr,x1); - const xStep=findNice((xVMax-xVMin)/Math.max(2,Math.floor(r.w/70))); - ctx.textAlign='center'; ctx.textBaseline='top'; - for(let v=Math.ceil(xVMin/xStep)*xStep;v<=xVMax+xStep*0.01;v+=xStep){ - const px=_fracToPx1d(_xToFrac1d(xArr,v),x0,x1,r); - if(pxr.x+r.w) continue; - ctx.strokeStyle=theme.axisStroke;ctx.beginPath();ctx.moveTo(px,r.y+r.h);ctx.lineTo(px,r.y+r.h+5);ctx.stroke(); - ctx.fillStyle=theme.tickText;ctx.fillText(fmtVal(v),px,r.y+r.h+7); + if(axisVis1d&&xTicksVis1d){ + ctx.fillStyle=theme.tickText; ctx.font='10px monospace'; + if(xArr.length>=2){ + const xVMin=_fracToX1d(xArr,x0), xVMax=_fracToX1d(xArr,x1); + const xStep=findNice((xVMax-xVMin)/Math.max(2,Math.floor(r.w/70))); + ctx.textAlign='center'; ctx.textBaseline='top'; + for(let v=Math.ceil(xVMin/xStep)*xStep;v<=xVMax+xStep*0.01;v+=xStep){ + const px=_fracToPx1d(_xToFrac1d(xArr,v),x0,x1,r); + if(pxr.x+r.w) continue; + ctx.strokeStyle=theme.axisStroke;ctx.beginPath();ctx.moveTo(px,r.y+r.h);ctx.lineTo(px,r.y+r.h+5);ctx.stroke(); + ctx.fillStyle=theme.tickText;ctx.fillText(fmtVal(v),px,r.y+r.h+7); + } + if(units&&units!=='px'){ctx.textAlign='right';ctx.textBaseline='top';ctx.fillStyle=theme.unitText;ctx.font='9px monospace';ctx.fillText(units,r.x+r.w,r.y+r.h+24);ctx.font='10px monospace';} } - if(units&&units!=='px'){ctx.textAlign='right';ctx.textBaseline='top';ctx.fillStyle=theme.unitText;ctx.font='9px monospace';ctx.fillText(units,r.x+r.w,r.y+r.h+24);ctx.font='10px monospace';} - } - ctx.font='10px monospace';ctx.textAlign='right';ctx.textBaseline='middle'; - let maxTW=0; - for(let v=Math.ceil(dMin/yStep)*yStep;v<=dMax+yStep*0.01;v+=yStep){const tw=ctx.measureText(fmtVal(v)).width;if(tw>maxTW)maxTW=tw;} - const tickRX=r.x-8; - for(let v=Math.ceil(dMin/yStep)*yStep;v<=dMax+yStep*0.01;v+=yStep){ - const py=_valToPy1d(v,dMin,dMax,r); - if(pyr.y+r.h) continue; - ctx.strokeStyle=theme.axisStroke;ctx.beginPath();ctx.moveTo(r.x,py);ctx.lineTo(r.x-5,py);ctx.stroke(); - ctx.fillStyle=theme.tickText;ctx.fillText(fmtVal(v),tickRX,py); } - if(yUnits){ - ctx.save(); - // Centre the rotated label in the left gutter (x = 0..r.x). - // Using a fixed x of PAD_L*0.28 keeps it clear of the tick numbers - // regardless of how wide those numbers are. - const lcx = Math.round(PAD_L * 0.28); - ctx.translate(lcx, r.y+r.h/2); ctx.rotate(-Math.PI/2); - ctx.textAlign='center'; ctx.textBaseline='middle'; - ctx.fillStyle=theme.unitText; ctx.font='9px monospace'; - ctx.fillText(yUnits, 0, 0); - ctx.restore(); + if(axisVis1d&&yTicksVis1d){ + ctx.font='10px monospace';ctx.textAlign='right';ctx.textBaseline='middle'; + const tickRX=r.x-8; + if(isLog){ + const lo=Math.floor(effDMin), hi=Math.ceil(effDMax); + for(let e=lo;e<=hi;e++){ + const v=Math.pow(10,e); + const py=_toPlotY(v); + if(pyr.y+r.h) continue; + ctx.strokeStyle=theme.axisStroke;ctx.beginPath();ctx.moveTo(r.x,py);ctx.lineTo(r.x-5,py);ctx.stroke(); + ctx.fillStyle=theme.tickText;ctx.fillText('10^'+e,tickRX,py); + } + } else { + let maxTW=0; + for(let v=Math.ceil(dMin/yStep)*yStep;v<=dMax+yStep*0.01;v+=yStep){const tw=ctx.measureText(fmtVal(v)).width;if(tw>maxTW)maxTW=tw;} + for(let v=Math.ceil(dMin/yStep)*yStep;v<=dMax+yStep*0.01;v+=yStep){ + const py=_valToPy1d(v,dMin,dMax,r); + if(pyr.y+r.h) continue; + ctx.strokeStyle=theme.axisStroke;ctx.beginPath();ctx.moveTo(r.x,py);ctx.lineTo(r.x-5,py);ctx.stroke(); + ctx.fillStyle=theme.tickText;ctx.fillText(fmtVal(v),tickRX,py); + } + } + if(yUnits){ + ctx.save(); + // Centre the rotated label in the left gutter (x = 0..r.x). + // Using a fixed x of PAD_L*0.28 keeps it clear of the tick numbers + // regardless of how wide those numbers are. + const lcx = Math.round(PAD_L * 0.28); + ctx.translate(lcx, r.y+r.h/2); ctx.rotate(-Math.PI/2); + ctx.textAlign='center'; ctx.textBaseline='middle'; + ctx.fillStyle=theme.unitText; ctx.font='9px monospace'; + ctx.fillText(yUnits, 0, 0); + ctx.restore(); + } } // Legend @@ -2157,6 +2255,14 @@ function render({ model, el }) { } } + const title1d=st.title||''; + if(title1d){ + ctx.fillStyle=theme.tickText; + ctx.font='bold 11px sans-serif'; + ctx.textAlign='center'; ctx.textBaseline='middle'; + ctx.fillText(title1d, r.x+r.w/2, PAD_T/2); + } + drawOverlay1d(p); drawMarkers1d(p); } diff --git a/anyplotlib/markers.py b/anyplotlib/markers.py index 5630b155..66dba65a 100644 --- a/anyplotlib/markers.py +++ b/anyplotlib/markers.py @@ -94,7 +94,8 @@ class MarkerGroup: the parent figure trait. """ - def __init__(self, marker_type: str, name: str, kwargs: dict, push_fn): + def __init__(self, marker_type: str, name: str, kwargs: dict, push_fn, + parent: "MarkerTypeDict | None" = None): self._type = marker_type self._name = name tfm = kwargs.get("transform", "data") @@ -104,6 +105,7 @@ def __init__(self, marker_type: str, name: str, kwargs: dict, push_fn): ) self._data: dict = dict(kwargs) self._push_fn = push_fn + self._parent: "MarkerTypeDict | None" = parent # ------------------------------------------------------------------ def set(self, **kwargs) -> None: @@ -123,6 +125,12 @@ def set(self, **kwargs) -> None: self._data.update(kwargs) self._push_fn() + def remove(self) -> None: + """Remove this group from its parent and trigger a re-render.""" + if self._parent is None: + raise RuntimeError("MarkerGroup has no parent; cannot remove.") + del self._parent[self._name] + def __repr__(self) -> str: # pragma: no cover return f"MarkerGroup(type={self._type!r}, name={self._name!r}, n={self._count()})" @@ -499,7 +507,7 @@ def pop(self, name: str, *args): # ------------------------------------------------------------------ def _add(self, name: str, kwargs: dict) -> "MarkerGroup": """Internal: create and register a MarkerGroup without double-pushing.""" - g = MarkerGroup(self._type, name, kwargs, self._push_fn) + g = MarkerGroup(self._type, name, kwargs, self._push_fn, parent=self) self._groups[name] = g return g diff --git a/anyplotlib/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py index 7c41a151..9ab8c130 100644 --- a/anyplotlib/plot1d/_plot1d.py +++ b/anyplotlib/plot1d/_plot1d.py @@ -245,7 +245,8 @@ def __init__(self, data: np.ndarray, alpha: float = 1.0, marker: str = "none", markersize: float = 4.0, - label: str = ""): + label: str = "", + yscale: str = "linear"): self._id: str = "" self._fig: object = None @@ -287,6 +288,7 @@ def __init__(self, data: np.ndarray, "markers": [], "pointer_settled_ms": 0, "pointer_settled_delta": 4, + "yscale": yscale, # Annotation labels "title": "", # Explicit y-range override: [ymin, ymax] or None (auto) diff --git a/anyplotlib/tests/test_interactive/test_callbacks.py b/anyplotlib/tests/test_interactive/test_callbacks.py index 7aff6784..4b33731a 100644 --- a/anyplotlib/tests/test_interactive/test_callbacks.py +++ b/anyplotlib/tests/test_interactive/test_callbacks.py @@ -492,3 +492,45 @@ def test_line1d_no_on_click(self): plot = ax.plot(np.zeros(10)) line = plot.add_line(np.zeros(10)) assert not hasattr(line, "on_click") + + +# ── Phase 3 — Figure.close() ────────────────────────────────────────────────── + +class TestFigureClose: + + def test_close_in_valid_event_types(self): + assert "close" in VALID_EVENT_TYPES + + def test_figure_close_sets_closed_flag(self): + fig, ax = apl.subplots(1, 1) + ax.plot(np.zeros(10)) + assert not getattr(fig, "_closed", False) + fig.close() + assert fig._closed is True + + def test_figure_close_fires_event_on_plot(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + received = [] + plot.callbacks.connect("close", lambda e: received.append(e.event_type)) + fig.close() + assert received == ["close"] + + def test_figure_close_fires_on_all_panels(self): + fig, (ax1, ax2) = apl.subplots(1, 2) + p1 = ax1.plot(np.zeros(10)) + p2 = ax2.imshow(np.zeros((8, 8))) + counts = [0, 0] + p1.callbacks.connect("close", lambda e: counts.__setitem__(0, counts[0] + 1)) + p2.callbacks.connect("close", lambda e: counts.__setitem__(1, counts[1] + 1)) + fig.close() + assert counts == [1, 1] + + def test_figure_close_is_idempotent(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + received = [] + plot.callbacks.connect("close", lambda e: received.append(e)) + fig.close() + fig.close() + assert len(received) == 1 diff --git a/anyplotlib/tests/test_layouts/test_visual.py b/anyplotlib/tests/test_layouts/test_visual.py index e94f341e..4487dc55 100644 --- a/anyplotlib/tests/test_layouts/test_visual.py +++ b/anyplotlib/tests/test_layouts/test_visual.py @@ -298,3 +298,46 @@ def test_gridspec_spanning_top_two_bottom(self, take_screenshot, update_baseline arr = take_screenshot(fig) _check("gridspec_spanning_top_two_bottom", arr, update_baselines) + # ── Phase 4 — labels, title, colorbar label, axis visibility ─────────── + + def test_plot1d_title(self, take_screenshot, update_baselines): + """1-D plot with set_title — title text drawn in top PAD area.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 240)) + p = ax.plot(np.sin(np.linspace(0, 2 * np.pi, 256)), color="#4fc3f7") + p.set_title("Sine Wave") + arr = take_screenshot(fig) + _check("plot1d_title", arr, update_baselines) + + def test_plot1d_axis_off(self, take_screenshot, update_baselines): + """1-D plot with set_axis_off — tick labels hidden.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 240)) + p = ax.plot(np.sin(np.linspace(0, 2 * np.pi, 256)), color="#4fc3f7") + p.set_axis_off() + arr = take_screenshot(fig) + _check("plot1d_axis_off", arr, update_baselines) + + def test_imshow_labels(self, take_screenshot, update_baselines): + """2-D image with x_label, y_label, title, and colorbar_label.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 400)) + x = np.linspace(0.0, 10.0, 64) + p = ax.imshow( + np.random.default_rng(0).uniform(size=(64, 64)), + axes=[x, x], units="nm", + ) + p.set_xlabel("x (nm)") + p.set_ylabel("y (nm)") + p.set_title("Test Image") + p.set_colorbar_visible(True) + p.set_colorbar_label("Intensity") + arr = take_screenshot(fig) + _check("imshow_labels", arr, update_baselines) + + def test_imshow_axis_off(self, take_screenshot, update_baselines): + """2-D image with set_axis_off — axis gutters hidden.""" + fig, ax = apl.subplots(1, 1, figsize=(320, 320)) + x = np.linspace(0.0, 5.0, 32) + p = ax.imshow(np.zeros((32, 32)), axes=[x, x], units="nm") + p.set_axis_off() + arr = take_screenshot(fig) + _check("imshow_axis_off", arr, update_baselines) + diff --git a/anyplotlib/tests/test_markers/test_markers.py b/anyplotlib/tests/test_markers/test_markers.py index db4a39e6..1333e249 100644 --- a/anyplotlib/tests/test_markers/test_markers.py +++ b/anyplotlib/tests/test_markers/test_markers.py @@ -553,3 +553,37 @@ def test_mesh_disallows_arrows(self): with pytest.raises(ValueError, match="not allowed"): mesh.add_arrows([[0.0, 0.0]], [1.0], [1.0]) + +# --------------------------------------------------------------------------- +# MarkerGroup.remove() +# --------------------------------------------------------------------------- + +class TestMarkerGroupRemove: + + def test_remove_deletes_from_parent(self): + p = _make_plot2d() + g = p.add_circles([[10.0, 20.0]], name="dot", radius=3) + assert "dot" in p.markers["circles"] + g.remove() + assert "dot" not in p.markers["circles"] + + def test_remove_triggers_push(self): + calls = [] + td = MarkerTypeDict("circles", lambda: calls.append(1)) + g = td._add("g", {"offsets": [[0.0, 0.0]], "radius": 2}) + calls.clear() + g.remove() + assert len(calls) == 1 + + def test_remove_no_parent_raises(self): + g = MarkerGroup("circles", "g", {"offsets": [[0.0, 0.0]]}, _push_noop) + with pytest.raises(RuntimeError, match="no parent"): + g.remove() + + def test_remove_1d_group(self): + p = _make_plot1d() + g = p.add_vlines([0.5, 1.5], name="marks") + assert "marks" in p.markers["vlines"] + g.remove() + assert "marks" not in p.markers["vlines"] + diff --git a/anyplotlib/tests/test_plot1d/test_plot1d.py b/anyplotlib/tests/test_plot1d/test_plot1d.py index b58c15fc..c26b50b1 100644 --- a/anyplotlib/tests/test_plot1d/test_plot1d.py +++ b/anyplotlib/tests/test_plot1d/test_plot1d.py @@ -757,3 +757,51 @@ def test_set_ticks_visible_per_axis(self): assert p._state["x_ticks_visible"] is True assert p._state["y_ticks_visible"] is False + +# =========================================================================== +# Phase 5 — step-mid linestyle + semilogy / yscale +# =========================================================================== + +class TestNormLinestyleStepMid: + + def test_step_mid_accepted(self): + from anyplotlib._utils import _norm_linestyle + assert _norm_linestyle("step-mid") == "step-mid" + + def test_steps_mid_alias(self): + from anyplotlib._utils import _norm_linestyle + assert _norm_linestyle("steps-mid") == "step-mid" + + def test_step_mid_stored_in_state(self): + fig, ax = apl.subplots(1, 1) + p = ax.plot(np.zeros(16), linestyle="step-mid") + assert p._state["line_linestyle"] == "step-mid" + + def test_step_mid_via_set_linestyle(self): + p = _plot() + p.set_linestyle("step-mid") + assert p._state["line_linestyle"] == "step-mid" + + +class TestSemilogy: + + def test_semilogy_sets_yscale_log(self): + fig, ax = apl.subplots(1, 1) + p = ax.semilogy(np.logspace(0, 3, 64)) + assert p._state["yscale"] == "log" + + def test_yscale_stored_in_state(self): + fig, ax = apl.subplots(1, 1) + p = ax.plot(np.zeros(16), yscale="log") + assert p._state["yscale"] == "log" + + def test_yscale_default_is_linear(self): + p = _plot() + assert p._state["yscale"] == "linear" + + def test_semilogy_passes_kwargs(self): + fig, ax = apl.subplots(1, 1) + p = ax.semilogy(np.ones(16), color="#ff0000") + assert p._state["line_color"] == "#ff0000" + assert p._state["yscale"] == "log" + diff --git a/anyplotlib/tests/test_plot2d/test_plot2d_api.py b/anyplotlib/tests/test_plot2d/test_plot2d_api.py index 13d41630..b00fbc02 100644 --- a/anyplotlib/tests/test_plot2d/test_plot2d_api.py +++ b/anyplotlib/tests/test_plot2d/test_plot2d_api.py @@ -252,3 +252,27 @@ def test_set_ticks_visible_per_axis(self): p.set_ticks_visible(False, x=False, y=True) assert p._state["x_ticks_visible"] is False assert p._state["y_ticks_visible"] is True + + +class TestGetColorCycle: + + def test_get_color_cycle_returns_list(self): + import anyplotlib as apl + result = apl.get_color_cycle() + assert isinstance(result, list) + + def test_get_color_cycle_elements_are_strings(self): + import anyplotlib as apl + result = apl.get_color_cycle() + assert all(isinstance(c, str) for c in result) + + def test_get_color_cycle_returns_copy(self): + import anyplotlib as apl + a = apl.get_color_cycle() + b = apl.get_color_cycle() + a.append("extra") + assert len(b) == len(apl.get_color_cycle()) + + def test_get_color_cycle_nonempty(self): + import anyplotlib as apl + assert len(apl.get_color_cycle()) > 0 From ce1832df3b02ecf8e7c4dd4bf13b7771c64d3c06 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 20 May 2026 21:21:13 -0500 Subject: [PATCH 164/198] Refactor: Implement subplot spacing adjustments with hspace and wspace parameters --- anyplotlib/figure/_figure.py | 23 ++++ anyplotlib/figure_esm.js | 105 +++++++++++++++++- .../tests/test_layouts/test_gridspec.py | 38 +++++++ 3 files changed, 165 insertions(+), 1 deletion(-) diff --git a/anyplotlib/figure/_figure.py b/anyplotlib/figure/_figure.py index a3478708..67358fbd 100644 --- a/anyplotlib/figure/_figure.py +++ b/anyplotlib/figure/_figure.py @@ -66,6 +66,8 @@ class Figure(anywidget.AnyWidget): # Figure-level help text shown in a '?' badge overlay in JS. # Empty string means no badge. Gated by apl.show_help at the Python level. help_text = traitlets.Unicode("").tag(sync=True) + # When True JS shows drag handles on all panels so they can be reordered. + drag_mode = traitlets.Bool(False).tag(sync=True) _esm = _ESM_SOURCE # Static CSS injected by anywidget alongside _esm. # .apl-scale-wrap — outer container; width:100% means it always fills @@ -114,6 +116,8 @@ def __init__(self, nrows=1, ncols=1, figsize=(640, 480), self._axes_map: dict = {} self._plots_map: dict = {} self._insets_map: dict = {} + self._hspace: float | None = None + self._wspace: float | None = None with self.hold_trait_notifications(): self.fig_width = figsize[0] self.fig_height = figsize[1] @@ -149,6 +153,23 @@ def set_help(self, text: str) -> None: """ self.help_text = self._resolve_help(text) + def subplots_adjust(self, hspace: float = 0.0, wspace: float = 0.0) -> None: + """Set the spacing between subplot panels. + + Parameters + ---------- + hspace : float, optional + Fraction of the average row height to use as vertical gap between + panels. ``0.1`` adds a gap of 10 % of the mean row height. + Default ``0.0`` (no gap). + wspace : float, optional + Fraction of the average column width to use as horizontal gap. + Default ``0.0`` (no gap). + """ + self._hspace = float(hspace) + self._wspace = float(wspace) + self._push_layout() + # ── subplot creation ────────────────────────────────────────────────────── def add_subplot(self, spec) -> Axes: """Add a subplot cell and return its :class:`Axes`. @@ -303,6 +324,8 @@ def _mg(flag, key): "panel_specs": panel_specs, "share_groups": share_groups, "inset_specs": inset_specs, + "hspace": self._hspace, + "wspace": self._wspace, }) # ── inset creation ──────────────────────────────────────────────────────── diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 4659860c..1dc475b7 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -346,6 +346,11 @@ function render({ model, el }) { gridDiv.style.gridTemplateRows = rowPx.map(px => px + 'px').join(' '); gridDiv.style.width = ''; gridDiv.style.height = ''; + const meanColPx = colPx.length ? colPx.reduce((a,b)=>a+b,0)/colPx.length : 0; + const meanRowPx = rowPx.length ? rowPx.reduce((a,b)=>a+b,0)/rowPx.length : 0; + // Only override the default gap:4px when the Python caller explicitly set a value. + if (layout.wspace != null) gridDiv.style.columnGap = (meanColPx ? Math.round(layout.wspace*meanColPx) : 0)+'px'; + if (layout.hspace != null) gridDiv.style.rowGap = (meanRowPx ? Math.round(layout.hspace*meanRowPx) : 0)+'px'; const seen = new Set(); for (const spec of panel_specs) { @@ -792,7 +797,12 @@ function render({ model, el }) { const imgX = hasPhysAxis ? PAD_L : 0; const imgY = hasPhysAxis ? PAD_T : 0; const imgW = hasPhysAxis ? Math.max(1, pw - PAD_L - PAD_R) : pw; - const imgH = hasPhysAxis ? Math.max(1, ph - PAD_T - PAD_B) : ph; + let imgH = hasPhysAxis ? Math.max(1, ph - PAD_T - PAD_B) : ph; + // Enforce aspect ratio (st.aspect = number or "equal" → 1.0). + if (st && st.aspect != null) { + const asp = (st.aspect === 'equal') ? 1.0 : parseFloat(st.aspect); + if (Number.isFinite(asp) && asp > 0) imgH = Math.max(1, Math.round(imgW / asp)); + } // Store on panel so event handlers and draw functions don't recompute. p.imgX = imgX; p.imgY = imgY; p.imgW = imgW; p.imgH = imgH; @@ -4318,6 +4328,99 @@ function render({ model, el }) { model.on('change:layout_json', () => { applyLayout(); redrawAll(); requestAnimationFrame(_applyScale); }); model.on('change:fig_width change:fig_height', () => { applyLayout(); redrawAll(); requestAnimationFrame(_applyScale); }); + // ── Panel rearrangement (drag mode) ────────────────────────────────────────── + // When fig.drag_mode = True, each panel cell shows a drag handle overlay. + // Dragging one panel onto another swaps their grid positions. + const _editOverlays = new Map(); + + function _setEditMode(active) { + for (const [id, p] of panels) { + let ov = _editOverlays.get(id); + if (active && !ov) { + ov = document.createElement('div'); + ov.style.cssText = + 'position:absolute;inset:0;z-index:50;cursor:grab;' + + 'border:2px dashed rgba(79,195,247,0.75);' + + 'background:rgba(79,195,247,0.06);border-radius:4px;' + + 'pointer-events:all;display:flex;align-items:center;' + + 'justify-content:center;user-select:none;'; + const badge = document.createElement('div'); + badge.style.cssText = + 'background:rgba(0,0,0,0.55);color:#4fc3f7;padding:3px 10px;' + + 'border-radius:12px;font-size:11px;font-family:monospace;' + + 'pointer-events:none;letter-spacing:0.04em;'; + badge.textContent = '⋮ drag'; + ov.appendChild(badge); + p.cell.appendChild(ov); + _editOverlays.set(id, ov); + + let dragging = false, startX = 0, startY = 0, ghost = null; + + ov.addEventListener('pointerdown', (e) => { + if (e.button !== 0) return; + dragging = true; + startX = e.clientX; startY = e.clientY; + ov.style.cursor = 'grabbing'; + const r = p.cell.getBoundingClientRect(); + ghost = document.createElement('div'); + ghost.style.cssText = + 'position:fixed;pointer-events:none;z-index:9999;' + + 'border:2px solid #4fc3f7;background:rgba(79,195,247,0.15);' + + 'border-radius:4px;opacity:0.85;' + + `width:${r.width}px;height:${r.height}px;` + + `left:${r.left}px;top:${r.top}px;`; + document.body.appendChild(ghost); + ov.setPointerCapture(e.pointerId); + e.preventDefault(); + }); + + ov.addEventListener('pointermove', (e) => { + if (!dragging || !ghost) return; + const dx = e.clientX - startX, dy = e.clientY - startY; + const r = p.cell.getBoundingClientRect(); + ghost.style.left = (r.left + dx) + 'px'; + ghost.style.top = (r.top + dy) + 'px'; + for (const [oid, op] of panels) { + if (oid === id) continue; + const tr = op.cell.getBoundingClientRect(); + const over = e.clientX >= tr.left && e.clientX <= tr.right && + e.clientY >= tr.top && e.clientY <= tr.bottom; + const ovEl = _editOverlays.get(oid); + if (ovEl) ovEl.style.borderColor = over ? '#ff7043' : 'rgba(79,195,247,0.75)'; + } + }); + + ov.addEventListener('pointerup', (e) => { + if (!dragging) return; + dragging = false; + if (ghost) { ghost.remove(); ghost = null; } + ov.style.cursor = 'grab'; + for (const [oid, op] of panels) { + const ovEl = _editOverlays.get(oid); + if (ovEl) ovEl.style.borderColor = 'rgba(79,195,247,0.75)'; + if (oid === id) continue; + const tr = op.cell.getBoundingClientRect(); + if (e.clientX >= tr.left && e.clientX <= tr.right && + e.clientY >= tr.top && e.clientY <= tr.bottom) { + const srcRow = p.cell.style.gridRow; + const srcCol = p.cell.style.gridColumn; + p.cell.style.gridRow = op.cell.style.gridRow; + p.cell.style.gridColumn = op.cell.style.gridColumn; + op.cell.style.gridRow = srcRow; + op.cell.style.gridColumn = srcCol; + } + } + }); + + } else if (!active && ov) { + ov.remove(); + _editOverlays.delete(id); + } + } + } + + model.on('change:drag_mode', () => { _setEditMode(model.get('drag_mode')); }); + // Toggle the per-panel stats overlay when display_stats changes. // Hiding is immediate; showing waits for the next natural redraw to // populate the overlay text — but we also call redrawAll() here so the diff --git a/anyplotlib/tests/test_layouts/test_gridspec.py b/anyplotlib/tests/test_layouts/test_gridspec.py index 572f6414..d5179c9a 100644 --- a/anyplotlib/tests/test_layouts/test_gridspec.py +++ b/anyplotlib/tests/test_layouts/test_gridspec.py @@ -1075,3 +1075,41 @@ def test_spanning_subplot_correct_size(self): assert approx(ph, 200, tol=2), f"{label} height should be 200, got {ph}" +# ───────────────────────────────────────────────────────────────────────────── +# subplots_adjust +# ───────────────────────────────────────────────────────────────────────────── + +class TestSubplotsAdjust: + + def test_hspace_in_layout_json(self): + fig, _ = vw.subplots(2, 1, figsize=(400, 400)) + fig.subplots_adjust(hspace=0.3) + layout = _layout(fig) + assert abs(layout['hspace'] - 0.3) < 1e-9 + + def test_wspace_in_layout_json(self): + fig, _ = vw.subplots(1, 2, figsize=(400, 200)) + fig.subplots_adjust(wspace=0.2) + layout = _layout(fig) + assert abs(layout['wspace'] - 0.2) < 1e-9 + + def test_defaults_are_none(self): + fig, _ = vw.subplots(2, 2, figsize=(400, 400)) + layout = _layout(fig) + assert layout['hspace'] is None + assert layout['wspace'] is None + + def test_both_together(self): + fig, _ = vw.subplots(2, 2, figsize=(600, 600)) + fig.subplots_adjust(hspace=0.15, wspace=0.25) + layout = _layout(fig) + assert abs(layout['hspace'] - 0.15) < 1e-9 + assert abs(layout['wspace'] - 0.25) < 1e-9 + + def test_retriggers_layout_push(self): + fig, _ = vw.subplots(2, 1, figsize=(400, 400)) + before = fig.layout_json + fig.subplots_adjust(hspace=0.1) + assert fig.layout_json != before + + From 7b1b2a1d148da7e912da6ef5bd3f8b7c462ed6a4 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 22 May 2026 14:35:45 -0500 Subject: [PATCH 165/198] Refactor: Enhance layout management for Plot2D with dynamic resizing and title support --- anyplotlib/figure_esm.js | 202 +++++++++++++++--- .../gridspec_height_ratio_image_histogram.png | Bin 18214 -> 18447 bytes .../baselines/gridspec_image_two_spectra.png | Bin 15967 -> 16027 bytes .../tests/baselines/imshow_checkerboard.png | Bin 8587 -> 6288 bytes .../tests/baselines/imshow_gradient.png | Bin 4843 -> 4863 bytes anyplotlib/tests/baselines/imshow_viridis.png | Bin 14685 -> 14663 bytes anyplotlib/tests/baselines/subplots_2x1.png | Bin 14552 -> 14035 bytes .../tests/test_plot2d/test_plot2d_api.py | 119 +++++++++++ 8 files changed, 288 insertions(+), 33 deletions(-) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 1dc475b7..0f18cba5 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -323,6 +323,9 @@ function render({ model, el }) { let _suppressLayoutUpdate = false; // block re-entry during live resize // ── layout application ─────────────────────────────────────────────────── + let _colPx = []; // current column widths in CSS px + let _rowPx = []; // current row heights in CSS px + function applyLayout() { if (_suppressLayoutUpdate) return; let layout; @@ -342,6 +345,9 @@ function render({ model, el }) { for (let r = spec.row_start; r < spec.row_stop; r++) rowPx[r] = Math.max(rowPx[r], perRow); } + _colPx = colPx.slice(); + _rowPx = rowPx.slice(); + gridDiv.style.gridTemplateColumns = colPx.map(px => px + 'px').join(' '); gridDiv.style.gridTemplateRows = rowPx.map(px => px + 'px').join(' '); gridDiv.style.width = ''; @@ -358,6 +364,8 @@ function render({ model, el }) { if (!panels.has(spec.id)) { _createPanelDOM(spec.id, spec.kind, spec.panel_width, spec.panel_height, spec); } else { + const existingPanel = panels.get(spec.id); + if (existingPanel) existingPanel.spec = spec; _resizePanelDOM(spec.id, spec.panel_width, spec.panel_height); } } @@ -384,6 +392,20 @@ function render({ model, el }) { if (insetSpecs.length) _applyAllInsetStates(layout); } + function _applyTrackSizes() { + gridDiv.style.gridTemplateColumns = _colPx.map(px => px + 'px').join(' '); + gridDiv.style.gridTemplateRows = _rowPx.map(px => px + 'px').join(' '); + for (const [id, p] of panels) { + if (!p.spec) continue; + const { row_start, row_stop, col_start, col_stop } = p.spec; + const newPw = Math.max(40, Math.round(_colPx.slice(col_start, col_stop).reduce((a,b)=>a+b,0))); + const newPh = Math.max(40, Math.round(_rowPx.slice(row_start, row_stop).reduce((a,b)=>a+b,0))); + p.pw = newPw; p.ph = newPh; + _resizePanelDOM(id, newPw, newPh); + _redrawPanel(p); + } + } + // ── _buildCanvasStack ───────────────────────────────────────────────────── // Creates the canvas/element stack for one panel kind and appends the // top-level wrapper to `outerContainer`. Returns all canvas/element refs. @@ -393,6 +415,7 @@ function render({ model, el }) { let plotCanvas, overlayCanvas, markersCanvas, statusBar; let xAxisCanvas=null, yAxisCanvas=null, scaleBar=null; let cbCanvas=null, cbCtx=null, plotWrap=null, wrapNode=null; + let titleCanvas=null; if (kind === '2d') { plotWrap = document.createElement('div'); @@ -426,6 +449,9 @@ function render({ model, el }) { 'position:absolute;display:none;pointer-events:none;border-radius:0 2px 2px 0;'; cbCtx = cbCanvas.getContext('2d'); + titleCanvas = document.createElement('canvas'); + titleCanvas.style.cssText = `position:absolute;pointer-events:none;z-index:8;background:transparent;display:none;`; + plotWrap.appendChild(plotCanvas); plotWrap.appendChild(overlayCanvas); plotWrap.appendChild(markersCanvas); @@ -434,6 +460,7 @@ function render({ model, el }) { plotWrap.appendChild(cbCanvas); plotWrap.appendChild(scaleBar); plotWrap.appendChild(statusBar); + plotWrap.appendChild(titleCanvas); outerContainer.appendChild(plotWrap); wrapNode = plotWrap; @@ -488,7 +515,7 @@ function render({ model, el }) { return { plotCanvas, overlayCanvas, markersCanvas, statusBar, xAxisCanvas, yAxisCanvas, scaleBar, - cbCanvas, cbCtx, plotWrap, wrapNode }; + cbCanvas, cbCtx, plotWrap, wrapNode, titleCanvas }; } function _createPanelDOM(id, kind, pw, ph, spec) { @@ -507,6 +534,7 @@ function render({ model, el }) { const mkCtx = stack.markersCanvas.getContext('2d'); const xCtx = stack.xAxisCanvas ? stack.xAxisCanvas.getContext('2d') : null; const yCtx = stack.yAxisCanvas ? stack.yAxisCanvas.getContext('2d') : null; + const titleCtx = stack.titleCanvas ? stack.titleCanvas.getContext('2d') : null; const blitCache = { bitmap:null, bytesKey:null, lutKey:null, w:0, h:0 }; @@ -520,6 +548,7 @@ function render({ model, el }) { const p = { id, kind, cell, pw, ph, + spec, plotCanvas: stack.plotCanvas, overlayCanvas: stack.overlayCanvas, markersCanvas: stack.markersCanvas, @@ -527,6 +556,8 @@ function render({ model, el }) { xAxisCanvas: stack.xAxisCanvas, yAxisCanvas: stack.yAxisCanvas, xCtx, yCtx, + titleCanvas: stack.titleCanvas || null, + titleCtx, scaleBar: stack.scaleBar, statusBar: stack.statusBar, statsDiv, @@ -794,10 +825,12 @@ function render({ model, el }) { const hasPhysAxis = st && (st.is_mesh || st.has_axes) && st.x_axis && st.x_axis.length >= 2 && st.y_axis && st.y_axis.length >= 2; + // Always reserve the PAD_T top strip for the title (mirrors 1D behaviour). + // Left/right/bottom gutters are only used when physical axes are present. const imgX = hasPhysAxis ? PAD_L : 0; - const imgY = hasPhysAxis ? PAD_T : 0; + const imgY = PAD_T; const imgW = hasPhysAxis ? Math.max(1, pw - PAD_L - PAD_R) : pw; - let imgH = hasPhysAxis ? Math.max(1, ph - PAD_T - PAD_B) : ph; + let imgH = Math.max(1, ph - PAD_T - (hasPhysAxis ? PAD_B : 0)); // Enforce aspect ratio (st.aspect = number or "equal" → 1.0). if (st && st.aspect != null) { const asp = (st.aspect === 'equal') ? 1.0 : parseFloat(st.aspect); @@ -806,6 +839,18 @@ function render({ model, el }) { // Store on panel so event handlers and draw functions don't recompute. p.imgX = imgX; p.imgY = imgY; p.imgW = imgW; p.imgH = imgH; + // Title canvas: always sits in the PAD_T strip above the image area + if (p.titleCanvas && p.titleCtx) { + p.titleCanvas.style.left = imgX + 'px'; + p.titleCanvas.style.top = '0px'; + p.titleCanvas.style.display = 'block'; + p.titleCanvas.style.width = imgW + 'px'; + p.titleCanvas.style.height = PAD_T + 'px'; + p.titleCanvas.width = imgW * dpr; + p.titleCanvas.height = PAD_T * dpr; + p.titleCtx.setTransform(dpr, 0, 0, dpr, 0, 0); + } + if (p.plotWrap) { p.plotWrap.style.width = pw + 'px'; p.plotWrap.style.height = ph + 'px'; @@ -1338,17 +1383,17 @@ function render({ model, el }) { p.yCtx.restore(); } } - const title2d=st.title||''; - if(title2d&&p.plotCtx){ - const tw=p.imgW||imgW; - p.plotCtx.save(); - p.plotCtx.fillStyle='rgba(0,0,0,0.45)'; - p.plotCtx.fillRect(0,0,tw,18); - p.plotCtx.fillStyle='#ffffff'; - p.plotCtx.font='bold 11px sans-serif'; - p.plotCtx.textAlign='center'; p.plotCtx.textBaseline='middle'; - p.plotCtx.fillText(title2d,tw/2,9); - p.plotCtx.restore(); + const title2d = st.title || ''; + if (p.titleCanvas && p.titleCtx) { + const tw = p.imgW || imgW; + p.titleCtx.clearRect(0, 0, tw, PAD_T); + if (title2d) { + p.titleCtx.fillStyle = theme.tickText; + p.titleCtx.font = 'bold 11px sans-serif'; + p.titleCtx.textAlign = 'center'; + p.titleCtx.textBaseline = 'middle'; + p.titleCtx.fillText(title2d, tw / 2, PAD_T / 2); + } } } @@ -4328,53 +4373,64 @@ function render({ model, el }) { model.on('change:layout_json', () => { applyLayout(); redrawAll(); requestAnimationFrame(_applyScale); }); model.on('change:fig_width change:fig_height', () => { applyLayout(); redrawAll(); requestAnimationFrame(_applyScale); }); - // ── Panel rearrangement (drag mode) ────────────────────────────────────────── - // When fig.drag_mode = True, each panel cell shows a drag handle overlay. - // Dragging one panel onto another swaps their grid positions. + // ── Panel drag / resize / gap-adjust (drag mode) ────────────────────────── + // When fig.drag_mode = True, each panel shows: + // • A translucent drag handle (centre) → drag to swap panels + // • Resize handles on the right edge and bottom edge → drag to resize + // Grid gaps (rowGap / columnGap) are also draggable via invisible bands. const _editOverlays = new Map(); function _setEditMode(active) { for (const [id, p] of panels) { let ov = _editOverlays.get(id); if (active && !ov) { + // ── outer wrapper appended to p.cell ────────────────────────────── ov = document.createElement('div'); ov.style.cssText = - 'position:absolute;inset:0;z-index:50;cursor:grab;' + + 'position:absolute;inset:0;z-index:50;pointer-events:none;'; + p.cell.appendChild(ov); + _editOverlays.set(id, ov); + + // ── drag handle (covers top ~60% of panel, pointer-events:all) ──── + const dragHandle = document.createElement('div'); + dragHandle.style.cssText = + 'position:absolute;top:0;left:0;right:0;bottom:30%;' + + 'cursor:grab;pointer-events:all;z-index:51;' + 'border:2px dashed rgba(79,195,247,0.75);' + 'background:rgba(79,195,247,0.06);border-radius:4px;' + - 'pointer-events:all;display:flex;align-items:center;' + - 'justify-content:center;user-select:none;'; + 'display:flex;align-items:center;justify-content:center;' + + 'user-select:none;'; const badge = document.createElement('div'); badge.style.cssText = 'background:rgba(0,0,0,0.55);color:#4fc3f7;padding:3px 10px;' + 'border-radius:12px;font-size:11px;font-family:monospace;' + 'pointer-events:none;letter-spacing:0.04em;'; badge.textContent = '⋮ drag'; - ov.appendChild(badge); - p.cell.appendChild(ov); - _editOverlays.set(id, ov); + dragHandle.appendChild(badge); + ov.appendChild(dragHandle); + // ── drag handle logic ────────────────────────────────────────────── let dragging = false, startX = 0, startY = 0, ghost = null; - ov.addEventListener('pointerdown', (e) => { + dragHandle.addEventListener('pointerdown', (e) => { if (e.button !== 0) return; dragging = true; startX = e.clientX; startY = e.clientY; - ov.style.cursor = 'grabbing'; + dragHandle.style.cursor = 'grabbing'; const r = p.cell.getBoundingClientRect(); ghost = document.createElement('div'); ghost.style.cssText = 'position:fixed;pointer-events:none;z-index:9999;' + - 'border:2px solid #4fc3f7;background:rgba(79,195,247,0.15);' + + 'border:2px solid #4fc3f7;background:rgba(79,195,247,0.12);' + 'border-radius:4px;opacity:0.85;' + `width:${r.width}px;height:${r.height}px;` + `left:${r.left}px;top:${r.top}px;`; document.body.appendChild(ghost); - ov.setPointerCapture(e.pointerId); - e.preventDefault(); + dragHandle.setPointerCapture(e.pointerId); + e.stopPropagation(); e.preventDefault(); }); - ov.addEventListener('pointermove', (e) => { + dragHandle.addEventListener('pointermove', (e) => { if (!dragging || !ghost) return; const dx = e.clientX - startX, dy = e.clientY - startY; const r = p.cell.getBoundingClientRect(); @@ -4386,18 +4442,21 @@ function render({ model, el }) { const over = e.clientX >= tr.left && e.clientX <= tr.right && e.clientY >= tr.top && e.clientY <= tr.bottom; const ovEl = _editOverlays.get(oid); - if (ovEl) ovEl.style.borderColor = over ? '#ff7043' : 'rgba(79,195,247,0.75)'; + const dh = ovEl && ovEl.querySelector('[data-role=drag]'); + if (dh) dh.style.borderColor = over ? '#ff7043' : 'rgba(79,195,247,0.75)'; } + e.stopPropagation(); }); - ov.addEventListener('pointerup', (e) => { + dragHandle.addEventListener('pointerup', (e) => { if (!dragging) return; dragging = false; if (ghost) { ghost.remove(); ghost = null; } - ov.style.cursor = 'grab'; + dragHandle.style.cursor = 'grab'; for (const [oid, op] of panels) { const ovEl = _editOverlays.get(oid); - if (ovEl) ovEl.style.borderColor = 'rgba(79,195,247,0.75)'; + const dh = ovEl && ovEl.querySelector('[data-role=drag]'); + if (dh) dh.style.borderColor = 'rgba(79,195,247,0.75)'; if (oid === id) continue; const tr = op.cell.getBoundingClientRect(); if (e.clientX >= tr.left && e.clientX <= tr.right && @@ -4408,9 +4467,86 @@ function render({ model, el }) { p.cell.style.gridColumn = op.cell.style.gridColumn; op.cell.style.gridRow = srcRow; op.cell.style.gridColumn = srcCol; + // Swap stored specs + const tmpSpec = p.spec; + p.spec = op.spec; + op.spec = tmpSpec; } } + e.stopPropagation(); + }); + + dragHandle.dataset.role = 'drag'; + + // ── right-edge resize handle ───────────────────────────────────── + const rHandle = document.createElement('div'); + rHandle.style.cssText = + 'position:absolute;top:10%;right:0;width:12px;bottom:30%;' + + 'cursor:ew-resize;pointer-events:all;z-index:52;' + + 'background:rgba(79,195,247,0.25);border-radius:0 4px 4px 0;' + + 'display:flex;align-items:center;justify-content:center;'; + rHandle.title = 'Drag to resize width'; + ov.appendChild(rHandle); + + let rDragging = false, rStartX = 0, rStartCols = []; + + rHandle.addEventListener('pointerdown', (e) => { + if (e.button !== 0 || !p.spec) return; + rDragging = true; + rStartX = e.clientX; + rStartCols = _colPx.slice(); + rHandle.setPointerCapture(e.pointerId); + e.stopPropagation(); e.preventDefault(); + }); + rHandle.addEventListener('pointermove', (e) => { + if (!rDragging || !p.spec) return; + const dx = e.clientX - rStartX; + const c = p.spec.col_stop - 1; // rightmost column of this panel + const nc = _colPx.length; + if (c >= nc - 1) return; // can't resize last column + const newW = Math.max(80, rStartCols[c] + dx); + const delta = newW - rStartCols[c]; + _colPx[c] = newW; + _colPx[c+1] = Math.max(80, rStartCols[c+1] - delta); + _applyTrackSizes(); + e.stopPropagation(); + }); + rHandle.addEventListener('pointerup', (e) => { rDragging = false; e.stopPropagation(); }); + + // ── bottom-edge resize handle ──────────────────────────────────── + const bHandle = document.createElement('div'); + bHandle.style.cssText = + 'position:absolute;bottom:0;left:10%;right:0;height:12px;' + + 'cursor:ns-resize;pointer-events:all;z-index:52;' + + 'background:rgba(79,195,247,0.25);border-radius:0 0 4px 4px;' + + 'display:flex;align-items:center;justify-content:center;'; + bHandle.title = 'Drag to resize height / adjust spacing'; + ov.appendChild(bHandle); + + let bDragging = false, bStartY = 0, bStartRows = []; + + bHandle.addEventListener('pointerdown', (e) => { + if (e.button !== 0 || !p.spec) return; + bDragging = true; + bStartY = e.clientY; + bStartRows = _rowPx.slice(); + bHandle.setPointerCapture(e.pointerId); + e.stopPropagation(); e.preventDefault(); + }); + bHandle.addEventListener('pointermove', (e) => { + if (!bDragging || !p.spec) return; + const dy = e.clientY - bStartY; + const r = p.spec.row_stop - 1; // bottommost row of this panel + const nr = _rowPx.length; + if (r >= nr - 1) return; // can't resize last row + const newH = Math.max(80, bStartRows[r] + dy); + const delta = newH - bStartRows[r]; + _rowPx[r] = newH; + _rowPx[r+1] = Math.max(80, bStartRows[r+1] - delta); + _applyTrackSizes(); + e.stopPropagation(); }); + bHandle.addEventListener('pointerup', (e) => { bDragging = false; e.stopPropagation(); }); } else if (!active && ov) { ov.remove(); diff --git a/anyplotlib/tests/baselines/gridspec_height_ratio_image_histogram.png b/anyplotlib/tests/baselines/gridspec_height_ratio_image_histogram.png index b65992f6651f11bb9664f09d5a426ee14aacb113..b23a8d43caa558ad515df4248728400f94125e75 100644 GIT binary patch literal 18447 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 V)#s()Kl(v=DK00L`&`fOe*v?fE?@uv literal 18214 zcmeJFby!qw_XZ48f=DO=QiF&HC`i`;Qc`-;CDPqBw9*lqpvOLKZ+ACOCSR{`h$*5yt zVW)zx%*#07m4g;bYAh_dwZ}42n$NJ;k_gLS7D;<=WHl)s-SX^C6c!@tqlwFaSq1ZL zx0}G1w}L)QyY`xxQ;<==#wBJ8x@I2D%=nU-@y<1zhj-{0sVMPC7$4q}c`11r?_2%G z(M=qkTO0f1dLDhxr@T%lDr;8uTvEJ~U26`{o@i;G8k-WRevY z;?43gh1&r*DV4aF_%ecDfK3(u{R^+204(hdZPm6Sa3DV}4ooTH3b;V(B;T#ZpGTAf zTkN>svAVD>jVFtFlfvKuFYc7@3LPpSt^M5U822Au&81WJ)K{xiQ*`uI$t6&63vTsC zlgTQP$KQ}2qWK6TF?ZQc;5gY7MgH>sW=Q?Yu;? zMsEk=W?`|*!LOinGwFC0mV!}ILKJG%*glDlcTL@-6Mv9k_?CiiKNM=FnYbndp2-O{ zb5y_Z4R;JBuV!@YC6NWs_%5Ekcf?8oXOmSkc7yB43Kd88kvM}hNvhLA!Ly+Mb473+ zyN#>Gwn7-&jr*g>M*&RY$^jYiuVTIS!PNtVN_XIE(|8%Ebz-0500%I zuuV6sDf@}?XLWA+cXSR3vRBCgVY3JR8L0nhPM3(q+DM3~_tjv9b~=N&LLOZQUy`G%G>w4a-dm!dG5nA`?NAhZxArPvB5q$m)dq0Qo}Mhch{E9!a9 zX(_f!77vyf$DN2q^L*@4T6XxAfG<=ef+oUo6EYLu8Cb~$n<6tbCqVeq_^+ugl4+AhJdyW7!f zH`~!~W8qqATCz%8p7Xu@H4fkxoU68v9J~|)L6BG*`;eNu=~)%cAgn*twPd@xr>MP^ zrJm}AD#w|x=7q_+AfU%&6d4IQ_FFu-J8yP0W4x8-EN0jmPC}j&78xCM@gSP+zeS2v zwhc{iKhPg8kUf)hKTGqZ4MSE%w4+uqff;hNAIWyjTzYoRt5?4rk;ocsn9%whs7iY> zcu++jZTYKi`BY?mRMANkXUXV#Tz7q!Z_e*%NRibe!T7F-7nW9K$nD!gOLoCeU|t;k ziyw94D^4toq`Qf1dlU`4B&3;%tASn|-j`_jUwiFEiMAyjL zNY={ppY$a{Tq0wG5=Gy5KGZj;fbVZ;1K2NT+<$TnbDnoc#jUmjDrzz-pR=n6=Tgq| z3qw%1979sfkS3-)S*cmGIcB28YkrE|uNzs7KOmMBVsNfxT##=iCu`Jq{-N||*#70{gwg)%XBOU`M?)VL`+Kcrzv~oaz z?N5XVcgCMkZ3eLg84H48MAK^Be`EV6Z%0Z)da_mj61}rK*+V!G}v;XL>{P5TmHWMIduE*UKk$Ar8f@u zJ{CW2<4a5PrAw^-Vg$WnZGa|Aw|q6B;|z=!kqfDCn@i1~>XvLIZJ7ASw| zu2qhm!8+L6Rb}`2F_ch>&2%#W&nJh+z9oKUv*4pmkA8e5qIQ#h3&H9|5z-GZ^Q7Xw005jsm_A>r6Ubs0M4HfhfM6ZnPCvx*!oJ() z~i1Y zY>YvstyWM#z|pTy`fFP+%%;hK-DTU4`;jjOC6Vt!M4D4@E z1&Dqd1c7IID9NG42rkb z04U~Q=TWBH&UegXp2})zX$tQ1f-;qCI{xm7Fo<#9gT^asYEY1?X$RUR4{5$;Ro+P# zL_I9dk*m7cw2^Ep4`m;qbx^k&wQ!AJ8ESx|A>5pMIaL+qU8sx?)g`GUVst!w5Gw?1 ziTQv9i7OBdfVe^Vi#5iOs}{WYff=BZ+=}M0aUwcW}+V!p-VsU?qd2~?TE&Gn)7#P?n@TbkxOo6wBQ3G#p zw^EZ)(sfBHVGd4BC8%PxF0V{%vp{BC0mI&D?Z_jfaANxcOvQ7`<=zNk>hDqYo}7rSM;EOLKFWF93SA6@_u_WsE# zvHXsyTWMMdDNSeDY*C>xZZNE{k zzMBZCNfcWg9Y1apu?sSDlLp&990UO*Ehtxa8bRgYtzh0$3ON526rHKj80@tBLX?trBEyv#0#-SZf0ju<+gqwOBhCSbL3vl7Ie{>e8S@N}$NgvxH&7L=>%KKi?NEo)s! ztxq$~cdj2Uj4hYg!C9ZQ+gM2}%%^xh`EMNmh~?gE<@lvtaHa7|o%MfCfa+3cD!s-z zB)pu+@!$&cQqu_eN)v=py+bg>Be`uPd!D#XyxS&6Lv z9<`tR&ENLwFBc6jbpxMRv%@YcSlkxVz4h@89*Fo$J1*T*v|`}<9#u2#mP_RYznu|8 zDZAde4V9z#aZC|ySapQCOREYCr>!9A&w^Dci*f$I^on1r{b!L!>1MWnYWd2_YM2iH z$cNDJE1`(`9CxZ;IEo^U#$f|Nd;nY8o zIODg@OYZ}rwpGwFpi42g{*6vpF+%VNH!(LmADh%U1Dh1$RSro0Mn*?| z`y3Jg3L`;{{;v|vwi*5{O2?!GT57(59=)!@@E>t(1Z{`vB&8pI?8NTm<%Z?5*%C-` zoLNA?OU?Ba>M~FR=-Y-xf%}@IoGBvosQVB{8%H$karAb^3JJvjU8L(xml~%| zLfhfgqvSsX{~4jy8hzoBMb8y;&LIt$z(-{KMf|rty?4Q3p#~W04PfC|L!>?) zISGbj6k(N42oFGaJJ39xgb4cmw_NKn5PWkIuo8S`TQ(Mk_w)ihglV(a$HHI^ubK8p zDwIi){7tX)6>fLdv^Wc+!u-Ei;z!5xz$|s zipT{J>rZ}er4!x%ZjFo6Mt)Z=)pelT!Gr$6edQ*ZfXM${xH(MN-qR0-2a0Y)(PKWf zpT-*2gCOt^~WbW4mm(+J(6uAD&GHJIELS(eNC)aM>N-mJny)4#_R zStw9;t2)`H*+e>iJke6Y{^q8$QTWGJ8kaFI4W*6s&|sMOKvaUy1C0Ux*!H!9_n#Dc z+!U!!KhN@dwNG?fW-Y~!5gYv3B2)a}*QhXAwJ{D>zY`6GwU1;K!0m$wyT__ft9udb zzj}d6029FHfR?}YDmqAE%Q@Irnhf9xALt+~ri|A?wM$IpKtdZI1}pgVU)b9(N`8ipZVXdFozLAwhXdE0e>|s8!m;on;wMW7Do~Xf(BffwEMQ2oQ1q# zId@eF>$wWRvhPvKES}-%*14vcQY+3o;BGB5*L}dnw(Xe=m46nYPoLAWSHu*%pSAqy z>h8^Ma90#M$upbDu+j@La-(TBKijk$*ty(~Y^V-wpYVS7$+g>A(dVGj7q?-n#@fn* z#_U_XUE_oc+s{_R?%v97PemHvijFad!he<}26ZT7{v)@Hr_&9yHhwbgPCch=R!|Y7 z^ZJKv0c8E709-oXzbFdR4~r~arR$^zvF<>}EVleJCPKh}1~D;ci)R-?krw!~7{sx! zfAJdNY9#h~BuIhd(!Y&w3HUx@SKbPO2vO%JL6pL63liVY^Arl5R?zopJFrsP%mi)t zZ;E+thn9k`ylAN=nTMz5H_1R{M)1R=^p1(~jYp`za zQpB$}BhB34Gme*)J$6_6GyM^FG|%3TB8dMNkN>1Zy~FZIf?9-jJx`LRY- zy*xD1bE@W@uPZWZW_kUZ+^n`K75g(zg9rUqZCT8!7qV>v(?rF2!+)JhZ}9VrR=;SU z8K|0a)#pq1Q?yCxSZ+J6s2tzEU=iaNp{!iO-y=lWct)PL7V_MzjTW!I{{P8b^0El0 zH&DKQz!ru-QTw#3p=qEc>tOr1ARCG)4uE8qurK#Ow}5)z3ZKLZsi@K{Ymvf63N(TJ zlHA2wGhTP7)j#eEDK}b_fHF5UNd>USp*}fq0pwvNSlrG2!l~K(^9|eikI+lnA7^uS zp8Sh3jM^>^j;9%bzg;}^zjfNmgje*Oi7!h@ua}7{UZv+9zLxaNyg ze9E(c*Atq(v=YBe6;94Zcxd*qQkP~*vI#hS>yipg`sPc^hOFZU$5XVbp5&zxk*s ztCW}h9bKYp>+zn=tmpiU3%(B!z##y4*Y2LbT{y5T3Rp!xr+$t=q%Z9JMc z$X+st3Cbhg1Q`#6MnA@c2r{V$^bT9YWY~ow#C%3te|N)6nZm9v|NK%x`0SGcbjVc! ze#Yvc*Gi%{+vIPG#J5kl@@lwgN=UEgOloR)i=?;rNuEnn>vR&_=8C;=9@=knPxgw- z3M$QLcPo*h^TNm)y9OzXaO~By9*qoa#r41qURn7G>!sri|Ms29&s=6AX?kzc$ZM30 ze}^teOt{pViELeOYZiGt3m;?r&$g_Q2K*L)E41e!p)2h|jNyIYD!I)7X?@8`qVo+{ zZHlnJfNC-Yi8d&wky_D8;>s4l9b6F1{roi~TCx`9nolHT$Foz?A!P zv7>GP0RZ$kTWVTBhXG<$NW5I(uK|&a_l2POD%Q=-%@$@~l&D}3r!RSR!QG-^=hD#C zit%k^qt=m;^xsh4kw@yCW3F_QeNDxXKWPLMBBGO!P zLVU77TlqF|7OCi(K^Omo!8w94qix%+4Qb`#BuEFu1(S0%al&`lKrx~EG`!vSBqTUL zA*4DYJhbrm;Ij1kh@s{lmmb}=ui@SX@)w$%UVC8oX4d0A$*dEjgV$;D8Dk%oURTbW z&+J>1ph0CV{^tp+LyWSw@dJ^P}-0Kmixo! zoGSq0&<$exw}R;*{41zH;Qw+~LWG|?*vn6-x&^ywG1r7>F{;hryEdhk1L7WsvkSuC zU6yU(%gM{QYR`tX^No4r6@jq3m10aP?+@h^0e2 zx507Ic>k;F_~<@y(t%d)lY&~448LM?rK7(e#9l5Q+G0Z^;u$Uc_ zg+aP%R$a=8QC7B1ep+1msiLDY-%sI1IH$)M?#v!xa-aKs4YwUh7NLfbsJ@Jo#xzgf zs(vnR3g6#eSpZQxqxcqRh4;OYHfCH_OQMS+>F)A|E#~92eY*U4AC~uuM5Xh*;aSc` zZi-w0ty_zJ!gikLe_(BHbSUB@kpG3hOo*5hmjR0C)fn0v_rijqxr>NYg1`73`5{X$!;E6|^YJuf67FqryDm12W%JKA_l463h4A z5CUY520Opk5lZqU2V?6uvj8CCemv9;<<|m-g9PthQL`|}4lM=Yp2y@ib8lOKW1*$S zd6fCAtRgD-^-h_*TCo%&v=n^Nf%c*qG{VXRXcHfplpai*4wjdl8}@O1b`FE{&!D97 zH*Hv7;F6d0U=I=KD)`DOKAVmD)H2a}9T)_U_iALz`HYZ>-I%bvYwPtgweb>^y?~_HE>WYS!HB$9)$m zZxEs`5b*}3Kjox8;gV}cd%|pGt$Vdi6PNn()igC2ER8PP3UqUWHGhlRB7+Nvak?As z!Q~6P=O3W}2#L>u`MvW?$JCq9KGwgS^^YtbN&ynAO=m!aI9uAZ!X8sjM1tM^Nn3ON zUl+m!LD{15NA!Y3JiA&96v#_J{EF$B@Ys-YKxF2De;yYFR!SzYDA(SZxIh$?$863q z33SyZ<=xN)G2m&4s-Ne1HKSp)e{MSdPja6ZZR+O$UJcwKGq7GJ_!m+d=}mI^^T5;L zg@9%}IFar7(0Ln9h`^Sl{S+p$ngY{!+VSm8FVBoY$Y?{ZRp-Pq=$FI9pC_exdY!y) zKM+JBtGar3vL&JuM(lt6b`nl!N7hERi!Judq%iQb-dQYK;E06VhcbHVGO)}J3Q z9yM8Lso8L71t>s;5A+?E<#gT?Au^ef0*#EyeK#t*AVZ_~ru?tqimH#t8W3^NQahOp zF8;;w`Wspe&}AA+NeB&=l(o2Wz105Yy5cYa({M5=vT`TjPFPPB+nvJ)DyR(-nBh=V zvEn##{uw_)$L*!|MFQH&x5MQN>nmHbly>ys>mO`b>M(EcW|77HkZvZ=o#fkjSGuq7 zYvoKj^ku&vtzz62*X)CJVO0*b6TRjt3ti484njlLTM^N{I+BIZkF14T>9?A!qO z7ZCSNKL7KSqbpMND2vTDc#ote_FNGYeP(zM7#Bjzm_Kl$xJxK|VYz)sb_3U?!g>fr z`GThnzzA_=24w!I$hV^eFJ`A;h(-;#T8_2Si8fT17Y+$-^pGPeSf~C zmGvTOxJ4RQ2rhPvLHCp*TYKWAOCs`^NR)6E>M(u2arTIuAK%yOzT&z{&1 z%y;KW2!6dr7aoD)S&mYgg|Z8Xi-1k^l{As)m@U`^;+;y(q2?vz{BG@! zPf0mEpaS=Pc|uYQKp7Da1Oqtw?g3DarJWmj9W;DrYNYGikO`B2;U+r%R8jM`*wr{! z#WMWDZVSU*aPOBpWmo5h{lk_E5l)IPGJ^4bIC5LEBu7!;=gK(=VUh{dlTB{EAp&k?vzH z%9wGwFH{i1Sly&zSXuh#N|us=zooU08$ti-62A1{-47BwE;1)bR9lpDWTms!!C^)r z?_A-jP?mzk7LoLQj?kJ*U5er|h$hKo$ajpcnb*u($hsk|7RRUwSV~+%e+ zL^We77C{@-CW5OE*;&4e`qHfj>Yl%4W0hBCvITB&p6`Kaf(M9xd%Inck#r?rG{7jKG(z}ib1mmVyO%Xju70Io;Pn6 zlH7lLTwqunXF+rr@HNJvccEC?_~{{Bjt0V^NJtz`lk|Hjt8e=m*%2cGc6@L5>%TYSm@urUTlg+&NgNwVVE~4_L$5!8Ij@{Z8H& z9y2z;DchjZr4eTfsJp1DpDHu&?DqN5(%p-DhKU#4bCCgIZRlV5tk|a}#M}40ax80Z z5}3D&MKUMEvJ-N^@U8|I{~+H@MohJ^)(>? z6`6vl0WgmPit3+Q+x?pmblZmm*TPH9RZIO$?SG1&M?!(3VAOfR+zxJ0I||-%w!a3O zp(ThTO#eP+1L=lcwiWyZA!fF8cRL!%1R_jCj^fo62ff#mqcbuHxT{F&;)n0{!q*+o zM1F=_wpsXCg^QbWM+=MzuzWoo_2=sfJ>>axde9MqmhD1*qb_@q&Q}v|!mDnZc}cd_ zCuLRp#kGC8&SxqN5sp?D2J>~kWxc?5QW-vt<9$C@z{BBMzyCcP+6IAp z5ZUbBZN(lM6!8HafLeqBT4O)QwkXn6kzpZ?i>yTGgX_lBqqSpM_E%|7EO3$mx`+!Kr zA4x5gXfHlVGt>XZ3)$*TOTSN)n?;ykgMkz>H_V1#v-yWCD{oV(UKEUcB}j4rt_BbUYM$T1U{MKwhd!EgWLY2|EkJ{^JS#ErKSV)YcP1Q6H~@$* z6h!3){104oZy;3Z^tHIx06>eVpQAMuTiDYJR}b)gqDtY~@WkHp;)!$$|_tWz0hh4%9q>KIO#+jb$+;i6E@2SQ3x z^`T4A{`)T6Z=1}MkEV(CD}&s($j)#_Q(c%ro7--i9JNbj;y)JQ|HzUtlzz>+Ebjx^ z$i3wZHF53mS2&6~;}bj|w$Z2(R_^BwlTl}G3YF|6G>ggmpbu}+j~M{^6B}E?uLc25 zFw8^0Niy7Wg;I zdD-#dE3&P4hU?7=@x#Lyc(RDD7^z&_xaI2|U1Qafgo#e`}*c*ahX~5qIsJ|=k;MQTrs!6Pj|(@{QB!^Qh}Y7B@dRN-_LV55?6>L z4!)RuN2*(_>I5iMO>xVMVRN3`Bm|!ps&SANTAg+m3?CR@j6Q0BQOOJ6=wkK;wgv7Z zhXpwXAQ?Ibm5T-Sa(JlJ!jI?1I1jltWdNm@+6BKw)v^d{sS!z)UYXAi!FJ9F!IrIB zsL<>@cjTm;f}q7{BUcVE^M&Scd~?o+$8yto0SaX)2P@`4%QXFrELf0)T#d^XjN33@ zK!dGJlZTx7G5V~8QM?KJow{PnB8%U#q7EmMb*&99Af1UPJO*BtYn8H z`L1O1<8P+DJ*WX?urne7|7-+2x}Uc>Yc#oql@*IKFY0g3vd z^snN^Ng$7oWj`O<>n_Rzl5lSD;^^;`g(!8s3(`S|c<%Z#o20Pf8a+dV2+bqyAV__T z`|&eX8Q*fv_N^nF1*{EGjmC2-w(QT!!8=uvB2_`62#h2LFNEoMsHN&-#}JCySYp^O z7%6G7sa(Wld$Cndvab1s5z&7v%+_Aci&+zW0vRf+!D`+hl|1R z@O{28Fc&O2VI>)mcchR_4kn4QW3jQ37IMxOk~G;z;1G9zLU$Z+LL=|3#*)svh2TQ& zDcIPB>s)Edk~FILPx0~Ku{13)VAgmYnl(OQ9v}px%2*u%SZ%x+&^r(K;^U#J8(xZF zgKHYoT*ExIqyW>+FRG!D<^4ESR_Mds2P4ZJ;WN-I^gcLb`SkiS9++Paut+7t+CSdz z>51oIa=P+>qk;)Ho(nNn;jlI=nRhes#YBy#+1HjJGp{H1_~Hi`+~Rh_1aw|JsE&&j zA0FFgM0TOJWytXO(&&$QP%!h7%L6pZJ}w zad9#n?Hx+wkDm@A#ppV!s>_QDu(TJnu3^4EPQ}u;Xj~gE#kSU~GwZJ3pBg*p{vw-T z^s(e{%Ze)U(X;)rDj|u69kjzn0sA7awzD31r#T|T{2kSRJYGz!{qsxr$F@(`H`yG$ zoz}iM6!qu|5#HFSVs5yso&5&z{RNBb|GrIKt;<)wHyh<#)4BAe*l3F}%h9}_mO0=U zcd~zv5#!m2)mceqdq!yVbgyHor@`!uz?-O0$Bz9n!om>v1&Mf95I^x?Y-z=ob{^gA z&j>x&M_vj{>F>Lt1?(IyfltSN8^obe1b=*?D}hxpzSy$sBemMIL-R3)8`NcA-M>CU zIe64%6A3lo*{6A@#VZ`Nj!sDkex4HbuDns|vX;-(81tM1OhjIP+7{rw(BTPhBb1B@pO_la!BIKxIpxckIV{p*A488hEt{FXr?Afc`|w3jLxSfRLtNxNmlIkY zr2zKrhnGR9iVd+VO01YfetKM@=}m`Y2`U*Of_0`$Io|{Qlj+wb6ko zD#vzQ%bfA7H8#te)vI#%*hao& zOsB^`d*aDE?lI=j|5=3rjxZ*MCrW=UkJp(-zgl_u);#a=bN{Ab67T6(RGTb$t5eHe zL{fR5=&UFk#*%70lnoL48bs^kRj%?|dRm@cNur*Gy5+gmZK^{>^(IaD{Fwv0_cXUK zo~y$pUc0tOSnpJP35h;nId09gqz=dPm~qx_K8)fN&v)$>CfL9|HhDpB4epGvvMW0b z-tvxlY_=apo=}!2;tg6*y049vM@pO?m_>?tv@^#&INogH zdFNQWB{zOOvcd1qchAij@u(Q5!Cqz+;VU5#RB|3Kr3^l8q9kfZz+&e9fD$Y?!5tleXU*%4?q{r%> zXTJT6k*JX#!GO7$KeCd-F+EdP<0M*4MUnIOv8aalg=$MP%iaii&TeOCJ3jZ+vPtlqd!j`!PC&4?ah8hwOb2$r z^BO@4%mZ$%Wt$rT36)!yu3br~`@uPRM6f4OZj3M(Pt|pAOuL6wHM-e7^F!TWw;-1Y zK7J)aXn9Vy$x!#tw^fp!!r@aExzDp}+Ph^G&#`dyO^7YAQ4*XCV(!1V-qb$I$!ihH z=A7D-v$a{n+iz&_+-=Q8U&_c(OY--cR`vPE$Y|T#yT;9yG2$oAIC$T>vKg{Dhwtx> zK6B7rYBdoiQ$PF>w&hghkH674arFITuiHvZUTH@umiDDaG-^76*K>A&w}{OG}jn28PVa17QXxSEH#WA|@6ZR6oUD zLb+fQv>`6rTUT+aC ziy6+qFrU^@N7Xu;p~P+<8gZd4g5_ZlmSA}(>2D`Vu9Fr3bX6P&`HP6<=UWOYJb&)$vgorgk8(RMkKu@nDwi^19qp?S9E>V zaATg|VAZ*S3BwsD;t~t`uKZ&uVAafVYffpidy?Z(iD%9`3U8u=0h9UX38c28n}aq( z{h#!Vnn<-b;wK6V*vffzQJc#}1%8Pn&tH41O(RNlmz!H+MBbT8Sd*oojHA>X&>s&3 zzYc7|?Uni6rk|Hdqw)5z0saX3MO_{>tbCp?}!ynt*+tPVOo2B>? z6X8@uB@HPt+2ul)gCe!RO9@;E64*p0NvH04^OX zL{ERaG`2e2bM1Z0D^|}SbZg9`329;UL1~@iVxWu1k3xqno93e&<9-($6rd{TL+fI@ zp*)HP-c_tUqu&_1XSO@T&B3Gs<3fYVJK}6D43BFU0xQ}nR4*YnXz?Gv=w{Y4lI0=@ z;B`K|1#`Gt=3n<^S$kVhS=QfF-xHaaU9;!-;w*w7V_Mn#IEwR$aYT3v0~?ZL?emh> z-3Ao~E?Q5F@t(~QmSy4d>#T-bIeJpIJA$c<)K&kGHsZrLMe5bAFa;F7ZMVPutm>0O z+>GB*do-(AVadTyp2$b;Ryz@T8LKLn3b8pkbg#W5QboVYQII$Nc3OWm>>~rJb11?_ zz4P^*fF|~IWaQ}wULz5ASD$R-D~39|D+50~E;-v4YX@8|q<9um|MoU}?VqB+f?{D> z&hRa1l)6LfT6y#L`3**$y;01j*o&|`ocw!N>uSGzGC&H9aNJP6CnZh26uc$(XX{cbQ%kM&T$RKAT? zcn*~SJ}E>a7ZelAkka5xOKLpPExR`Q^S49kXu02b;_-TYQj7iW>CuMM$QNcEa#B*K ztvMUn8^*+2KficyAj4L>#@ds8Yo-j(?tg^89sZD)GG1<#SNZT%1BUzYUh%@0f}+K? z4xLu>h>iNA!jh1d1U-LUDgzqty$!)99b8_XMs{5kobI!Q^MAI&?q|eCY$OaA6b$dN zke1hNR1O%>pr7fZSM2;E-S;-8A_;|lYa5nTmHXo|G=0*uY~J#bDf1MTZWP;UCO=RL z&e8Zh6CYmnJer9VtWo>-1L1_ZtK=C?&CIf`Ul$7Kwqg!`e=#a?KP}#zv{7g+{t?!k zqVsG+V|!!#i-QJ__gJla(|g$h&$$P6#>2}>Z^B0AB4Sj`PCn6{9&1ov5<=^1b;NP0 zJV~fqxMW{%I$L{3eG~`jGdUV(_>}jGchD1u`^y*wZw~u%o0V$qxXQhrhg@9VZYQxZ z(}ho*bi-rISr;d(?VDDM%jPay)}S-%_ZCpFgJxx2#87n?`>KFiweyNdCs9N_(t>W1 z6Xin4j@fzpNhdFPtD?pGy6;I3XS&3PnF6Ekej0Y&gg*=UAMmTE7fEIe9EQJGF3$|| zxE(Z@bS%5At23eOYoU6?Lim4gb!Y znBwWg3L!6X`YY=sByvlB4s>0;MZ34+#v_mXH5z2Etmht6a!*@77$ubt%zRXtO8vZ3Z%7?~ed@-7gCWk)J0)Ihm>u3G0w@#VvJwdx8sEY~@-F=J?&JI83JIb)% zL^xNQE?%#;@8xjRY4gRQ?8F_Tl&ikQ(SA4mcuaYOg)(whfufKk%?V5Eq|RS$&QIx4)Jsxc&M+g&&{SupRJ=2M-`?4~DY(el+Z#OicT6Unbv> zg-JNdcQAM~JX5e!@F1j51$ml&ASYWYChjtgJtyDmOsIg9n?7SS(p`V7eb0)9QkvLU z`9%D#Rpp+7Ofbk)IN9CpHGg(Q~<5wy&;d3|d_^x3S%zKeYi zohOHM0!r*3i4?{)gYIjHHL>^ahMctN^b@Un)7H8Ra34?hDG$iiX}^0kNaRrIzt@Ck z_?*iPOAa>#du0p#Vt;$)^!75`x}dT5`OaYmvFLLjNB{7k-68gw=WG~be+2cUzS(5A z>T9_Ku^&q#icaOh{<=7rsOV?$JO_dQ(6Dt=ze2t=6Q4vXQsFO6%l1`Zt#mnxb{#(J zRSCOVf5OHbjZNpF-iZE%`O&Ju(b^utifN&$Pg=PWn=Z@{qdK59%fjdLp>4L*KK~9u zzzt6R>Dl|eOTlNwDfwR$?B7*KU4iGnM&j()b9bj-k8l)+`PgPWUM3Q-Ee0d`bk5Q; z4^&CDuhsF;r`+yr4~q*P)+g3sc!#A;J;e-%>kIO~n7=C40*l&Y(lJtYI&=Qax^DQe zRQs5Ia#C&M;P8^QYSR6x`8U{{g%$USs>={pQ2W;y2r+Q`wjJeSz6TsMh)1OB?~&a7Sd?bX4aNYYK*XGxFraly=)!-D4*F(RDNdO>UCLI=trCzNFSQGI(q9 zmpsRwKe@wm5E@}U#g|lOXuy)?fN=nT4T3Z}_ zVL}SPjA0AG-g^Gb7MOy{$;1AXQ_UY)quBzu@%HWuogco-+R!}P?h-Qc`o-)$_VqHx zj{j?c?xWt}60AD|V*MEv00?BweicW$HKtAH83 zQlP~{JW`!@{Yh&>(^%GS)@m;L+Ih4?P}%Fg3B_9!jis%Oa|!iznIdw5Mqbj2cdVeNJf|bDIBt)&C`(L_vrYqqQf|kjia{W$9F59!}so zHiwc?e-2})Y}lwr9>}f-5*8#;#&YO+3>kR(9*s>YPpX7s7GXShY0pX$y|SSb-J4p8 zW<0r4p3S@lM03KmM{H|^a@&#!keGP9Rx{T$o|`{N#j(yTwQpL>7+fl zw@gOz7%%lw%Px`K0ga8i{YezXheQc~@mK;<*ul&jt}Mrk6V1_w_^|HfMAvR9I)c;U zM&&PjZQIx(72liDCc*+7|3=AiZ0s*v-0)hS-bCLj>aSSvDW@LRnj>iU2IkxixVyZiHix6yxxuGt1{ zPHjztK0B}h*ZXzn5A4A+ZgGP~7z@#B72(~kVE4_qIVi$+*o8K~0_{+(O@6Nz^Ts0a z-l$0LDfn9;x-##`<*(i3XrOGl(Aoltz)I{CC-&`yyb81$%X!jRb7ie2n<=#f#!(OV ztR35&e_j3H)J(X$b*??SM7j3ivPh1efDg-CvCK;w_cPi5NM_ZK9Pa!IB5ot`c%%S6 zm(9WIG@c0@GZ9tYjMLP{80dq93G=pN9ipQan|sf-&oR>*%Qq45$3Co>qWIFEBHtv3 z{jaDjTCjl6{w%@m-RJ295{@NjCa-rdp!LijZzQrC%-5-OG&m)kNs5)Qy~Fu?(hrKY zf6AT<6IQplbPU)|lApT*u!d~7>Xh|qtdD`I5aA!;9{m5k{Qo*8GRpqv9uB`Xz&ZT7 zVi68ttss;G`fDId1|SxWK2SoF;(_~e?4L2b@27)6mV&>3gY{TeS*Gxzap3<0zpvRd diff --git a/anyplotlib/tests/baselines/gridspec_image_two_spectra.png b/anyplotlib/tests/baselines/gridspec_image_two_spectra.png index abb8d4e382f1358e1abf91c3d9bd24fd41615ea0..9bcdd255c2d912661194430ffefe8e7f7898032c 100644 GIT binary patch literal 16027 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 literal 15967 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 literal 8587 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 OvvI@b_2nV71OErRDuB@d diff --git a/anyplotlib/tests/baselines/imshow_gradient.png b/anyplotlib/tests/baselines/imshow_gradient.png index aa9f6c303a9b3aefc6b4215348710d21cb1ef8cc..419f23e13f26129487a56ee01f09d0d83bedc667 100644 GIT binary patch literal 4863 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 Wdq+V(6@iN_$ir>NrqYeUC;kV(yK(*i literal 4843 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 b{Jc;P{NyDmS`WA&Nd(r=Q!i0{9QOYJrD$+P literal 14685 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 F{|7x1sQUl_ diff --git a/anyplotlib/tests/test_plot2d/test_plot2d_api.py b/anyplotlib/tests/test_plot2d/test_plot2d_api.py index b00fbc02..fa197c42 100644 --- a/anyplotlib/tests/test_plot2d/test_plot2d_api.py +++ b/anyplotlib/tests/test_plot2d/test_plot2d_api.py @@ -276,3 +276,122 @@ def test_get_color_cycle_returns_copy(self): def test_get_color_cycle_nonempty(self): import anyplotlib as apl assert len(apl.get_color_cycle()) > 0 + + +# =========================================================================== +# Figure resize — Plot2D correctness +# =========================================================================== + +class TestFigureResizePlot2D: + """Figure resize correctly propagates to layout_json and Plot2D panel state. + + The _on_resize observer calls _push_layout() (which recomputes panel pixel + dimensions from the new fig_width/fig_height) then re-pushes every panel's + JSON. For Plot2D panels the panel JSON must still carry the full axis state + so the JS renderer can correctly position tick labels and scale the image. + """ + + def test_resize_updates_layout_fig_size(self): + """layout_json reflects the new fig_width and fig_height after resize.""" + import json + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.imshow(np.zeros((32, 32))) + + fig.fig_width = 800 + fig.fig_height = 600 + + layout = json.loads(fig.layout_json) + assert layout["fig_width"] == 800 + assert layout["fig_height"] == 600 + + def test_resize_updates_single_panel_dimensions(self): + """Panel width/height in layout_json match the new figure size (1×1 grid).""" + import json + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((32, 32))) + + fig.fig_width = 800 + fig.fig_height = 600 + + layout = json.loads(fig.layout_json) + spec = next(s for s in layout["panel_specs"] if s["id"] == plot._id) + assert spec["panel_width"] == 800 + assert spec["panel_height"] == 600 + + def test_resize_plot2d_with_axes_preserves_axis_state(self): + """Plot2D with physical axes keeps has_axes, x_axis, y_axis, and units after resize.""" + import json + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + x_axis = np.linspace(0.0, 10.0, 32) + y_axis = np.linspace(0.0, 20.0, 32) + plot = ax.imshow(np.zeros((32, 32)), axes=[x_axis, y_axis], units="nm") + + panel_before = json.loads(getattr(fig, f"panel_{plot._id}_json")) + + fig.fig_width = 800 + fig.fig_height = 600 + + panel_after = json.loads(getattr(fig, f"panel_{plot._id}_json")) + assert panel_after["has_axes"] is True + assert panel_after["x_axis"] == panel_before["x_axis"] + assert panel_after["y_axis"] == panel_before["y_axis"] + assert panel_after["units"] == "nm" + + def test_resize_does_not_alter_data_scale(self): + """Resizing the figure must not change Plot2D scale_x/scale_y (data-space quantities).""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + x_axis = np.linspace(0.0, 10.0, 32) + y_axis = np.linspace(0.0, 20.0, 32) + plot = ax.imshow(np.zeros((32, 32)), axes=[x_axis, y_axis], units="nm") + + scale_x_before = plot._state["scale_x"] + scale_y_before = plot._state["scale_y"] + + fig.fig_width = 800 + fig.fig_height = 600 + + assert plot._state["scale_x"] == pytest.approx(scale_x_before) + assert plot._state["scale_y"] == pytest.approx(scale_y_before) + + def test_resize_plot2d_with_axes_layout_kind(self): + """layout_json marks a Plot2D with axes as kind='2d' after resize.""" + import json + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((32, 32)), axes=[np.arange(32), np.arange(32)]) + + fig.fig_width = 640 + fig.fig_height = 480 + + layout = json.loads(fig.layout_json) + spec = next(s for s in layout["panel_specs"] if s["id"] == plot._id) + assert spec["kind"] == "2d" + + def test_resize_two_panel_splits_width_evenly(self): + """Both Plot2D panels in a 1×2 grid each get half the new figure width.""" + import json + fig, axs = apl.subplots(1, 2, figsize=(400, 200)) + plot_l = axs[0].imshow(np.zeros((16, 16))) + plot_r = axs[1].imshow(np.zeros((16, 16))) + + fig.fig_width = 800 + + layout = json.loads(fig.layout_json) + specs = {s["id"]: s for s in layout["panel_specs"]} + assert specs[plot_l._id]["panel_width"] == pytest.approx(400, abs=1) + assert specs[plot_r._id]["panel_width"] == pytest.approx(400, abs=1) + + def test_resize_with_height_ratios_scales_proportionally(self): + """GridSpec height_ratios [3, 1] scale correctly when fig_height changes.""" + import json + gs = apl.GridSpec(2, 1, height_ratios=[3, 1]) + fig = apl.Figure(figsize=(400, 400)) + plot_top = fig.add_subplot(gs[0, 0]).imshow(np.zeros((32, 32))) + plot_bot = fig.add_subplot(gs[1, 0]).imshow(np.zeros((16, 16))) + + fig.fig_height = 800 + + layout = json.loads(fig.layout_json) + specs = {s["id"]: s for s in layout["panel_specs"]} + # top: 3/4 × 800 = 600 px; bottom: 1/4 × 800 = 200 px + assert specs[plot_top._id]["panel_height"] == pytest.approx(600, abs=1) + assert specs[plot_bot._id]["panel_height"] == pytest.approx(200, abs=1) From 2c8d80cac78bda1ab5e5b599843be8475fd62396 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 22 May 2026 17:26:16 -0500 Subject: [PATCH 166/198] Refactor: Enhance Plot1D and Plot2D with new axis label, title, and visibility controls; add get_xlim method --- anyplotlib/figure/_figure.py | 8 +- anyplotlib/figure_esm.js | 535 +++++++----------- anyplotlib/markers.py | 2 +- anyplotlib/plot1d/_plot1d.py | 22 +- anyplotlib/plot1d/_plotbar.py | 79 +++ anyplotlib/plot2d/_plot2d.py | 4 + anyplotlib/plot3d/_plot3d.py | 2 +- .../tests/test_layouts/test_gridspec.py | 26 + anyplotlib/tests/test_markers/test_markers.py | 106 ++++ anyplotlib/tests/test_plot1d/test_plot1d.py | 132 +++++ anyplotlib/tests/test_plot1d/test_plotbar.py | 133 +++++ .../tests/test_plot2d/test_plot2d_api.py | 30 + anyplotlib/tests/test_plot3d/test_plot3d.py | 38 ++ 13 files changed, 792 insertions(+), 325 deletions(-) diff --git a/anyplotlib/figure/_figure.py b/anyplotlib/figure/_figure.py index 67358fbd..c9532420 100644 --- a/anyplotlib/figure/_figure.py +++ b/anyplotlib/figure/_figure.py @@ -66,8 +66,6 @@ class Figure(anywidget.AnyWidget): # Figure-level help text shown in a '?' badge overlay in JS. # Empty string means no badge. Gated by apl.show_help at the Python level. help_text = traitlets.Unicode("").tag(sync=True) - # When True JS shows drag handles on all panels so they can be reordered. - drag_mode = traitlets.Bool(False).tag(sync=True) _esm = _ESM_SOURCE # Static CSS injected by anywidget alongside _esm. # .apl-scale-wrap — outer container; width:100% means it always fills @@ -161,10 +159,12 @@ def subplots_adjust(self, hspace: float = 0.0, wspace: float = 0.0) -> None: hspace : float, optional Fraction of the average row height to use as vertical gap between panels. ``0.1`` adds a gap of 10 % of the mean row height. - Default ``0.0`` (no gap). + Default ``0.0`` (no gap). Before ``subplots_adjust`` is called, + figures use a 4 px browser default gap. wspace : float, optional Fraction of the average column width to use as horizontal gap. - Default ``0.0`` (no gap). + Default ``0.0`` (no gap). Before ``subplots_adjust`` is called, + figures use a 4 px browser default gap. """ self._hspace = float(hspace) self._wspace = float(wspace) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 0f18cba5..70d6f669 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -323,9 +323,6 @@ function render({ model, el }) { let _suppressLayoutUpdate = false; // block re-entry during live resize // ── layout application ─────────────────────────────────────────────────── - let _colPx = []; // current column widths in CSS px - let _rowPx = []; // current row heights in CSS px - function applyLayout() { if (_suppressLayoutUpdate) return; let layout; @@ -345,9 +342,6 @@ function render({ model, el }) { for (let r = spec.row_start; r < spec.row_stop; r++) rowPx[r] = Math.max(rowPx[r], perRow); } - _colPx = colPx.slice(); - _rowPx = rowPx.slice(); - gridDiv.style.gridTemplateColumns = colPx.map(px => px + 'px').join(' '); gridDiv.style.gridTemplateRows = rowPx.map(px => px + 'px').join(' '); gridDiv.style.width = ''; @@ -364,8 +358,6 @@ function render({ model, el }) { if (!panels.has(spec.id)) { _createPanelDOM(spec.id, spec.kind, spec.panel_width, spec.panel_height, spec); } else { - const existingPanel = panels.get(spec.id); - if (existingPanel) existingPanel.spec = spec; _resizePanelDOM(spec.id, spec.panel_width, spec.panel_height); } } @@ -392,20 +384,6 @@ function render({ model, el }) { if (insetSpecs.length) _applyAllInsetStates(layout); } - function _applyTrackSizes() { - gridDiv.style.gridTemplateColumns = _colPx.map(px => px + 'px').join(' '); - gridDiv.style.gridTemplateRows = _rowPx.map(px => px + 'px').join(' '); - for (const [id, p] of panels) { - if (!p.spec) continue; - const { row_start, row_stop, col_start, col_stop } = p.spec; - const newPw = Math.max(40, Math.round(_colPx.slice(col_start, col_stop).reduce((a,b)=>a+b,0))); - const newPh = Math.max(40, Math.round(_rowPx.slice(row_start, row_stop).reduce((a,b)=>a+b,0))); - p.pw = newPw; p.ph = newPh; - _resizePanelDOM(id, newPw, newPh); - _redrawPanel(p); - } - } - // ── _buildCanvasStack ───────────────────────────────────────────────────── // Creates the canvas/element stack for one panel kind and appends the // top-level wrapper to `outerContainer`. Returns all canvas/element refs. @@ -548,7 +526,6 @@ function render({ model, el }) { const p = { id, kind, cell, pw, ph, - spec, plotCanvas: stack.plotCanvas, overlayCanvas: stack.overlayCanvas, markersCanvas: stack.markersCanvas, @@ -593,6 +570,9 @@ function render({ model, el }) { newState.zoom = p2.state.zoom; newState.center_x = p2.state.center_x; newState.center_y = p2.state.center_y; + } else if (p2.state && (p2.kind === '1d' || p2.kind === 'bar') && !newState._view_from_python) { + newState.view_x0 = p2.state.view_x0; + newState.view_x1 = p2.state.view_x1; } p2.state = newState; } @@ -715,6 +695,9 @@ function render({ model, el }) { newState.zoom = p2.state.zoom; newState.center_x = p2.state.center_x; newState.center_y = p2.state.center_y; + } else if (p2.state && (p2.kind === '1d' || p2.kind === 'bar') && !newState._view_from_python) { + newState.view_x0 = p2.state.view_x0; + newState.view_x1 = p2.state.view_x1; } p2.state = newState; } @@ -2011,7 +1994,8 @@ function render({ model, el }) { const yData = p._1dDArr; // Float64Array (or plain array fallback) const x0=st.view_x0||0, x1=st.view_x1||1; - const dMin=st.data_min, dMax=st.data_max; + let dMin=st.data_min, dMax=st.data_max; + if (st.y_range && st.y_range.length === 2) { dMin = st.y_range[0]; dMax = st.y_range[1]; } const units=st.units||'', yUnits=st.y_units||''; const isLog = st.yscale === 'log'; @@ -2400,7 +2384,8 @@ function render({ model, el }) { const xArr = p._1dXArr || (st.x_axis_b64 ? _decodeF64(st.x_axis_b64) : (st.x_axis||[])); const yData = p._1dDArr || (st.data_b64 ? _decodeF64(st.data_b64) : (st.data||[])); const x0=st.view_x0||0, x1=st.view_x1||1; - const dMin=st.data_min, dMax=st.data_max; + let dMin=st.data_min, dMax=st.data_max; + if (st.y_range && st.y_range.length === 2) { dMin = st.y_range[0]; dMax = st.y_range[1]; } mkCtx.clearRect(0,0,pw,ph); const sets=st.markers||[]; if(!sets.length) return; @@ -2469,6 +2454,63 @@ function render({ model, el }) { const [x2c,y2c]= tfm==='data' ? _offToCanvas(seg[1]) : _tc2d(seg[1][0],seg[1][1]); mkCtx.beginPath();mkCtx.moveTo(x1c,y1c);mkCtx.lineTo(x2c,y2c);mkCtx.stroke(); } + } else if(type==='ellipses'){ + for(let i=0;i dMax) continue; - const px = g.xToPx(v); - if (px < r.x || px > r.x + r.w) continue; - ctx.strokeStyle = theme.axisStroke; - ctx.beginPath(); ctx.moveTo(px, r.y + r.h); ctx.lineTo(px, r.y + r.h + 4); ctx.stroke(); - ctx.fillStyle = theme.tickText; - ctx.fillText(_fmtLogTick(v), px, r.y + r.h + 7); - } - } else { - const valRange = (dMax - dMin) || 1; - const valStep = findNice(valRange / Math.max(2, Math.floor(r.w / 40))); - for (let v = Math.ceil(dMin/valStep)*valStep; v <= dMax+valStep*0.01; v += valStep) { - const px = g.xToPx(v); - if (px < r.x || px > r.x + r.w) continue; - ctx.strokeStyle = theme.axisStroke; - ctx.beginPath(); ctx.moveTo(px, r.y + r.h); ctx.lineTo(px, r.y + r.h + 4); ctx.stroke(); - ctx.fillStyle = theme.tickText; - ctx.fillText(fmtVal(v), px, r.y + r.h + 7); + if (orient === 'h') { + // Value axis → X ticks at bottom + if (xTicksVis) { + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + if (logScale) { + const lMin = Math.log10(Math.max(LC, dMin)); + const lMax = Math.log10(Math.max(LC, dMax)); + for (let exp = Math.floor(lMin); exp <= Math.ceil(lMax); exp++) { + const v = Math.pow(10, exp); + if (v < dMin || v > dMax) continue; + const px = g.xToPx(v); + if (px < r.x || px > r.x + r.w) continue; + ctx.strokeStyle = theme.axisStroke; + ctx.beginPath(); ctx.moveTo(px, r.y + r.h); ctx.lineTo(px, r.y + r.h + 4); ctx.stroke(); + ctx.fillStyle = theme.tickText; + ctx.fillText(_fmtLogTick(v), px, r.y + r.h + 7); + } + } else { + const valRange = (dMax - dMin) || 1; + const valStep = findNice(valRange / Math.max(2, Math.floor(r.w / 40))); + for (let v = Math.ceil(dMin/valStep)*valStep; v <= dMax+valStep*0.01; v += valStep) { + const px = g.xToPx(v); + if (px < r.x || px > r.x + r.w) continue; + ctx.strokeStyle = theme.axisStroke; + ctx.beginPath(); ctx.moveTo(px, r.y + r.h); ctx.lineTo(px, r.y + r.h + 4); ctx.stroke(); + ctx.fillStyle = theme.tickText; + ctx.fillText(fmtVal(v), px, r.y + r.h + 7); + } + } + if (st.y_units) { + ctx.textAlign='right'; ctx.textBaseline='top'; ctx.font='9px monospace'; + ctx.fillStyle=theme.unitText; + ctx.fillText(st.y_units, r.x + r.w, r.y + r.h + 24); + ctx.font='10px monospace'; + } } - } - // Category axis → Y labels on left - ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; - const maxCatLabels = Math.max(1, Math.floor(r.h / 14)); - const catStep = Math.max(1, Math.ceil(g.n / maxCatLabels)); - for (let i = 0; i < g.n; i += catStep) { - const cy = g.yToPx(i); - const label = xLabels[i] !== undefined ? String(xLabels[i]) : fmtVal(xCenters[i]); - ctx.strokeStyle = theme.axisStroke; - ctx.beginPath(); ctx.moveTo(r.x, cy); ctx.lineTo(r.x - 4, cy); ctx.stroke(); - ctx.fillStyle = theme.tickText; - ctx.fillText(label, r.x - 7, cy); - } - if (st.y_units) { - ctx.textAlign='right'; ctx.textBaseline='top'; ctx.font='9px monospace'; - ctx.fillStyle=theme.unitText; - ctx.fillText(st.y_units, r.x + r.w, r.y + r.h + 24); - ctx.font='10px monospace'; - } - if (st.units) { - ctx.save(); - ctx.translate(Math.round(PAD_L * 0.28), r.y + r.h / 2); ctx.rotate(-Math.PI/2); - ctx.textAlign='center'; ctx.textBaseline='middle'; - ctx.fillStyle=theme.unitText; ctx.font='9px monospace'; - ctx.fillText(st.units, 0, 0); - ctx.restore(); - } - } else { - // Category axis → X ticks at bottom - ctx.textAlign = 'center'; ctx.textBaseline = 'top'; - const maxCatLabels = Math.max(1, Math.floor(r.w / 42)); - const catStep = Math.max(1, Math.ceil(g.n / maxCatLabels)); - for (let i = 0; i < g.n; i += catStep) { - const cx = g.xToPx(i); - const label = xLabels[i] !== undefined ? String(xLabels[i]) : fmtVal(xCenters[i]); - ctx.strokeStyle = theme.axisStroke; - ctx.beginPath(); ctx.moveTo(cx, r.y + r.h); ctx.lineTo(cx, r.y + r.h + 4); ctx.stroke(); - ctx.fillStyle = theme.tickText; - ctx.fillText(label, cx, r.y + r.h + 7); - } - if (st.units && st.units !== 'px') { - ctx.textAlign='right'; ctx.textBaseline='top'; ctx.font='9px monospace'; - ctx.fillStyle=theme.unitText; - ctx.fillText(st.units, r.x + r.w, r.y + r.h + 24); - ctx.font='10px monospace'; - } - // Value axis → Y ticks on left - ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; - if (logScale) { - const lMin = Math.log10(Math.max(LC, dMin)); - const lMax = Math.log10(Math.max(LC, dMax)); - for (let exp = Math.floor(lMin); exp <= Math.ceil(lMax); exp++) { - const v = Math.pow(10, exp); - if (v < dMin || v > dMax) continue; - const py = g.yToPx(v); - if (py < r.y || py > r.y + r.h) continue; - ctx.strokeStyle = theme.axisStroke; - ctx.beginPath(); ctx.moveTo(r.x, py); ctx.lineTo(r.x - 5, py); ctx.stroke(); - ctx.fillStyle = theme.tickText; - ctx.fillText(_fmtLogTick(v), r.x - 8, py); + // Category axis → Y labels on left + if (yTicksVis) { + ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; + const maxCatLabels = Math.max(1, Math.floor(r.h / 14)); + const catStep = Math.max(1, Math.ceil(g.n / maxCatLabels)); + for (let i = 0; i < g.n; i += catStep) { + const cy = g.yToPx(i); + const label = xLabels[i] !== undefined ? String(xLabels[i]) : fmtVal(xCenters[i]); + ctx.strokeStyle = theme.axisStroke; + ctx.beginPath(); ctx.moveTo(r.x, cy); ctx.lineTo(r.x - 4, cy); ctx.stroke(); + ctx.fillStyle = theme.tickText; + ctx.fillText(label, r.x - 7, cy); + } + if (st.units) { + ctx.save(); + ctx.translate(Math.round(PAD_L * 0.28), r.y + r.h / 2); ctx.rotate(-Math.PI/2); + ctx.textAlign='center'; ctx.textBaseline='middle'; + ctx.fillStyle=theme.unitText; ctx.font='9px monospace'; + ctx.fillText(st.units, 0, 0); + ctx.restore(); + } } } else { - const valRange = (dMax - dMin) || 1; - const valStep = findNice(valRange / Math.max(2, Math.floor(r.h / 40))); - for (let v = Math.ceil(dMin/valStep)*valStep; v <= dMax+valStep*0.01; v += valStep) { - const py = g.yToPx(v); - if (py < r.y || py > r.y + r.h) continue; - ctx.strokeStyle = theme.axisStroke; - ctx.beginPath(); ctx.moveTo(r.x, py); ctx.lineTo(r.x - 5, py); ctx.stroke(); - ctx.fillStyle = theme.tickText; - ctx.fillText(fmtVal(v), r.x - 8, py); + // Category axis → X ticks at bottom + if (xTicksVis) { + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + const maxCatLabels = Math.max(1, Math.floor(r.w / 42)); + const catStep = Math.max(1, Math.ceil(g.n / maxCatLabels)); + for (let i = 0; i < g.n; i += catStep) { + const cx = g.xToPx(i); + const label = xLabels[i] !== undefined ? String(xLabels[i]) : fmtVal(xCenters[i]); + ctx.strokeStyle = theme.axisStroke; + ctx.beginPath(); ctx.moveTo(cx, r.y + r.h); ctx.lineTo(cx, r.y + r.h + 4); ctx.stroke(); + ctx.fillStyle = theme.tickText; + ctx.fillText(label, cx, r.y + r.h + 7); + } + if (st.units && st.units !== 'px') { + ctx.textAlign='right'; ctx.textBaseline='top'; ctx.font='9px monospace'; + ctx.fillStyle=theme.unitText; + ctx.fillText(st.units, r.x + r.w, r.y + r.h + 24); + ctx.font='10px monospace'; + } + } + // Value axis → Y ticks on left + if (yTicksVis) { + ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; + if (logScale) { + const lMin = Math.log10(Math.max(LC, dMin)); + const lMax = Math.log10(Math.max(LC, dMax)); + for (let exp = Math.floor(lMin); exp <= Math.ceil(lMax); exp++) { + const v = Math.pow(10, exp); + if (v < dMin || v > dMax) continue; + const py = g.yToPx(v); + if (py < r.y || py > r.y + r.h) continue; + ctx.strokeStyle = theme.axisStroke; + ctx.beginPath(); ctx.moveTo(r.x, py); ctx.lineTo(r.x - 5, py); ctx.stroke(); + ctx.fillStyle = theme.tickText; + ctx.fillText(_fmtLogTick(v), r.x - 8, py); + } + } else { + const valRange = (dMax - dMin) || 1; + const valStep = findNice(valRange / Math.max(2, Math.floor(r.h / 40))); + for (let v = Math.ceil(dMin/valStep)*valStep; v <= dMax+valStep*0.01; v += valStep) { + const py = g.yToPx(v); + if (py < r.y || py > r.y + r.h) continue; + ctx.strokeStyle = theme.axisStroke; + ctx.beginPath(); ctx.moveTo(r.x, py); ctx.lineTo(r.x - 5, py); ctx.stroke(); + ctx.fillStyle = theme.tickText; + ctx.fillText(fmtVal(v), r.x - 8, py); + } + } + if (st.y_units) { + ctx.save(); + ctx.translate(Math.round(PAD_L * 0.28), r.y + r.h / 2); ctx.rotate(-Math.PI/2); + ctx.textAlign='center'; ctx.textBaseline='middle'; + ctx.fillStyle=theme.unitText; ctx.font='9px monospace'; + ctx.fillText(st.y_units, 0, 0); + ctx.restore(); + } } } - if (st.y_units) { - ctx.save(); - ctx.translate(Math.round(PAD_L * 0.28), r.y + r.h / 2); ctx.rotate(-Math.PI/2); - ctx.textAlign='center'; ctx.textBaseline='middle'; - ctx.fillStyle=theme.unitText; ctx.font='9px monospace'; - ctx.fillText(st.y_units, 0, 0); - ctx.restore(); - } - } + } // end axisVis // ── group legend (only when group_labels are provided) ──────────────── if (g.groups > 1 && groupLabels.length > 0) { @@ -4085,6 +4144,32 @@ function render({ model, el }) { } } + // ── title ───────────────────────────────────────────────────────────── + const titleBar = st.title || ''; + if (titleBar) { + ctx.fillStyle = theme.tickText; + ctx.font = 'bold 11px sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(titleBar, r.x + r.w / 2, PAD_T / 2); + } + + // ── axis labels ─────────────────────────────────────────────────────── + const xLabelBar = st.x_label || ''; + const yLabelBar = st.y_label || ''; + if (xLabelBar) { + ctx.fillStyle = theme.tickText; ctx.font = '10px sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'top'; + ctx.fillText(xLabelBar, r.x + r.w / 2, r.y + r.h + 26); + } + if (yLabelBar) { + ctx.save(); + ctx.translate(Math.round(PAD_L * 0.1), r.y + r.h / 2); ctx.rotate(-Math.PI / 2); + ctx.fillStyle = theme.tickText; ctx.font = '10px sans-serif'; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(yLabelBar, 0, 0); + ctx.restore(); + } + // Overlay widgets (vlines, hlines) drawn on overlay canvas drawOverlay1d(p); } @@ -4373,190 +4458,6 @@ function render({ model, el }) { model.on('change:layout_json', () => { applyLayout(); redrawAll(); requestAnimationFrame(_applyScale); }); model.on('change:fig_width change:fig_height', () => { applyLayout(); redrawAll(); requestAnimationFrame(_applyScale); }); - // ── Panel drag / resize / gap-adjust (drag mode) ────────────────────────── - // When fig.drag_mode = True, each panel shows: - // • A translucent drag handle (centre) → drag to swap panels - // • Resize handles on the right edge and bottom edge → drag to resize - // Grid gaps (rowGap / columnGap) are also draggable via invisible bands. - const _editOverlays = new Map(); - - function _setEditMode(active) { - for (const [id, p] of panels) { - let ov = _editOverlays.get(id); - if (active && !ov) { - // ── outer wrapper appended to p.cell ────────────────────────────── - ov = document.createElement('div'); - ov.style.cssText = - 'position:absolute;inset:0;z-index:50;pointer-events:none;'; - p.cell.appendChild(ov); - _editOverlays.set(id, ov); - - // ── drag handle (covers top ~60% of panel, pointer-events:all) ──── - const dragHandle = document.createElement('div'); - dragHandle.style.cssText = - 'position:absolute;top:0;left:0;right:0;bottom:30%;' + - 'cursor:grab;pointer-events:all;z-index:51;' + - 'border:2px dashed rgba(79,195,247,0.75);' + - 'background:rgba(79,195,247,0.06);border-radius:4px;' + - 'display:flex;align-items:center;justify-content:center;' + - 'user-select:none;'; - const badge = document.createElement('div'); - badge.style.cssText = - 'background:rgba(0,0,0,0.55);color:#4fc3f7;padding:3px 10px;' + - 'border-radius:12px;font-size:11px;font-family:monospace;' + - 'pointer-events:none;letter-spacing:0.04em;'; - badge.textContent = '⋮ drag'; - dragHandle.appendChild(badge); - ov.appendChild(dragHandle); - - // ── drag handle logic ────────────────────────────────────────────── - let dragging = false, startX = 0, startY = 0, ghost = null; - - dragHandle.addEventListener('pointerdown', (e) => { - if (e.button !== 0) return; - dragging = true; - startX = e.clientX; startY = e.clientY; - dragHandle.style.cursor = 'grabbing'; - const r = p.cell.getBoundingClientRect(); - ghost = document.createElement('div'); - ghost.style.cssText = - 'position:fixed;pointer-events:none;z-index:9999;' + - 'border:2px solid #4fc3f7;background:rgba(79,195,247,0.12);' + - 'border-radius:4px;opacity:0.85;' + - `width:${r.width}px;height:${r.height}px;` + - `left:${r.left}px;top:${r.top}px;`; - document.body.appendChild(ghost); - dragHandle.setPointerCapture(e.pointerId); - e.stopPropagation(); e.preventDefault(); - }); - - dragHandle.addEventListener('pointermove', (e) => { - if (!dragging || !ghost) return; - const dx = e.clientX - startX, dy = e.clientY - startY; - const r = p.cell.getBoundingClientRect(); - ghost.style.left = (r.left + dx) + 'px'; - ghost.style.top = (r.top + dy) + 'px'; - for (const [oid, op] of panels) { - if (oid === id) continue; - const tr = op.cell.getBoundingClientRect(); - const over = e.clientX >= tr.left && e.clientX <= tr.right && - e.clientY >= tr.top && e.clientY <= tr.bottom; - const ovEl = _editOverlays.get(oid); - const dh = ovEl && ovEl.querySelector('[data-role=drag]'); - if (dh) dh.style.borderColor = over ? '#ff7043' : 'rgba(79,195,247,0.75)'; - } - e.stopPropagation(); - }); - - dragHandle.addEventListener('pointerup', (e) => { - if (!dragging) return; - dragging = false; - if (ghost) { ghost.remove(); ghost = null; } - dragHandle.style.cursor = 'grab'; - for (const [oid, op] of panels) { - const ovEl = _editOverlays.get(oid); - const dh = ovEl && ovEl.querySelector('[data-role=drag]'); - if (dh) dh.style.borderColor = 'rgba(79,195,247,0.75)'; - if (oid === id) continue; - const tr = op.cell.getBoundingClientRect(); - if (e.clientX >= tr.left && e.clientX <= tr.right && - e.clientY >= tr.top && e.clientY <= tr.bottom) { - const srcRow = p.cell.style.gridRow; - const srcCol = p.cell.style.gridColumn; - p.cell.style.gridRow = op.cell.style.gridRow; - p.cell.style.gridColumn = op.cell.style.gridColumn; - op.cell.style.gridRow = srcRow; - op.cell.style.gridColumn = srcCol; - // Swap stored specs - const tmpSpec = p.spec; - p.spec = op.spec; - op.spec = tmpSpec; - } - } - e.stopPropagation(); - }); - - dragHandle.dataset.role = 'drag'; - - // ── right-edge resize handle ───────────────────────────────────── - const rHandle = document.createElement('div'); - rHandle.style.cssText = - 'position:absolute;top:10%;right:0;width:12px;bottom:30%;' + - 'cursor:ew-resize;pointer-events:all;z-index:52;' + - 'background:rgba(79,195,247,0.25);border-radius:0 4px 4px 0;' + - 'display:flex;align-items:center;justify-content:center;'; - rHandle.title = 'Drag to resize width'; - ov.appendChild(rHandle); - - let rDragging = false, rStartX = 0, rStartCols = []; - - rHandle.addEventListener('pointerdown', (e) => { - if (e.button !== 0 || !p.spec) return; - rDragging = true; - rStartX = e.clientX; - rStartCols = _colPx.slice(); - rHandle.setPointerCapture(e.pointerId); - e.stopPropagation(); e.preventDefault(); - }); - rHandle.addEventListener('pointermove', (e) => { - if (!rDragging || !p.spec) return; - const dx = e.clientX - rStartX; - const c = p.spec.col_stop - 1; // rightmost column of this panel - const nc = _colPx.length; - if (c >= nc - 1) return; // can't resize last column - const newW = Math.max(80, rStartCols[c] + dx); - const delta = newW - rStartCols[c]; - _colPx[c] = newW; - _colPx[c+1] = Math.max(80, rStartCols[c+1] - delta); - _applyTrackSizes(); - e.stopPropagation(); - }); - rHandle.addEventListener('pointerup', (e) => { rDragging = false; e.stopPropagation(); }); - - // ── bottom-edge resize handle ──────────────────────────────────── - const bHandle = document.createElement('div'); - bHandle.style.cssText = - 'position:absolute;bottom:0;left:10%;right:0;height:12px;' + - 'cursor:ns-resize;pointer-events:all;z-index:52;' + - 'background:rgba(79,195,247,0.25);border-radius:0 0 4px 4px;' + - 'display:flex;align-items:center;justify-content:center;'; - bHandle.title = 'Drag to resize height / adjust spacing'; - ov.appendChild(bHandle); - - let bDragging = false, bStartY = 0, bStartRows = []; - - bHandle.addEventListener('pointerdown', (e) => { - if (e.button !== 0 || !p.spec) return; - bDragging = true; - bStartY = e.clientY; - bStartRows = _rowPx.slice(); - bHandle.setPointerCapture(e.pointerId); - e.stopPropagation(); e.preventDefault(); - }); - bHandle.addEventListener('pointermove', (e) => { - if (!bDragging || !p.spec) return; - const dy = e.clientY - bStartY; - const r = p.spec.row_stop - 1; // bottommost row of this panel - const nr = _rowPx.length; - if (r >= nr - 1) return; // can't resize last row - const newH = Math.max(80, bStartRows[r] + dy); - const delta = newH - bStartRows[r]; - _rowPx[r] = newH; - _rowPx[r+1] = Math.max(80, bStartRows[r+1] - delta); - _applyTrackSizes(); - e.stopPropagation(); - }); - bHandle.addEventListener('pointerup', (e) => { bDragging = false; e.stopPropagation(); }); - - } else if (!active && ov) { - ov.remove(); - _editOverlays.delete(id); - } - } - } - - model.on('change:drag_mode', () => { _setEditMode(model.get('drag_mode')); }); - // Toggle the per-panel stats overlay when display_stats changes. // Hiding is immediate; showing waits for the next natural redraw to // populate the overlay text — but we also call redrawAll() here so the diff --git a/anyplotlib/markers.py b/anyplotlib/markers.py index 66dba65a..3d764a09 100644 --- a/anyplotlib/markers.py +++ b/anyplotlib/markers.py @@ -541,7 +541,7 @@ class MarkerRegistry: }) _KNOWN_1D = frozenset({ "points", "vlines", "hlines", "lines", "rectangles", - "ellipses", "polygons", "texts", + "ellipses", "polygons", "texts", "arrows", "squares", }) # pcolormesh panels only support points (circles) and line segments _KNOWN_MESH = frozenset({"circles", "lines"}) diff --git a/anyplotlib/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py index 9ab8c130..4010597f 100644 --- a/anyplotlib/plot1d/_plot1d.py +++ b/anyplotlib/plot1d/_plot1d.py @@ -297,6 +297,7 @@ def __init__(self, data: np.ndarray, "axis_visible": True, "x_ticks_visible": True, "y_ticks_visible": True, + "_view_from_python": False, } self.markers = MarkerRegistry(self._push_markers, @@ -431,7 +432,7 @@ def _recompute_data_range(self) -> None: # Extra lines # ------------------------------------------------------------------ def add_line(self, data: np.ndarray, x_axis=None, - color: str = "#ffffff", linewidth: float = 1.5, + color: str = "#4fc3f7", linewidth: float = 1.5, linestyle: str = "solid", ls: str | None = None, alpha: float = 1.0, marker: str = "none", markersize: float = 4.0, @@ -448,7 +449,7 @@ def add_line(self, data: np.ndarray, x_axis=None, x_axis : array-like, shape (N,), optional X coordinates. Defaults to the primary line's x-axis. color : str, optional - CSS colour string. Default ``"#ffffff"``. + CSS colour string. Default ``"#4fc3f7"``. linewidth : float, optional Stroke width in pixels. Default ``1.5``. linestyle : str, optional @@ -779,13 +780,17 @@ def set_view(self, x0: float | None = None, x1: float | None = None) -> None: f1 = 1.0 if x1 is None else max(0.0, min(1.0, (float(x1)-xmin)/span)) self._state["view_x0"] = f0 self._state["view_x1"] = f1 + self._state["_view_from_python"] = True self._push() + self._state["_view_from_python"] = False def reset_view(self) -> None: """Reset the view to show the full x range of the primary line.""" self._state["view_x0"] = 0.0 self._state["view_x1"] = 1.0 + self._state["_view_from_python"] = True self._push() + self._state["_view_from_python"] = False # ------------------------------------------------------------------ # Primary-line property setters @@ -884,8 +889,21 @@ def set_ylim(self, ymin: float, ymax: float) -> None: self._push() def get_ylim(self) -> tuple: + yr = self._state.get("y_range") + if yr is not None: + return (float(yr[0]), float(yr[1])) return (float(self._state["data_min"]), float(self._state["data_max"])) + def get_xlim(self) -> tuple: + xarr = np.asarray(self._state["x_axis"]) + if len(xarr) < 2: + return (0.0, 1.0) + xmin, xmax = float(xarr[0]), float(xarr[-1]) + span = xmax - xmin or 1.0 + x0 = xmin + self._state["view_x0"] * span + x1 = xmin + self._state["view_x1"] * span + return (x0, x1) + def get_xbound(self) -> tuple: xarr = np.asarray(self._state["x_axis"]) return (float(xarr.min()), float(xarr.max())) diff --git a/anyplotlib/plot1d/_plotbar.py b/anyplotlib/plot1d/_plotbar.py index fefeafc4..f73b940a 100644 --- a/anyplotlib/plot1d/_plotbar.py +++ b/anyplotlib/plot1d/_plotbar.py @@ -185,14 +185,22 @@ def __init__(self, x, height=None, width: float = 0.8, bottom: float = 0.0, *, "group_labels": list(group_labels) if group_labels is not None else [], "group_colors": gc_list, "bar_width": float(width), + "align": align, "orient": orient, "baseline": float(bottom), "log_scale": bool(log_scale), "show_values": bool(show_values), "data_min": dmin, "data_max": dmax, + "y_range": None, "units": units, "y_units": y_units, + "title": "", + "x_label": "", + "y_label": "", + "axis_visible": True, + "x_ticks_visible": True, + "y_ticks_visible": True, # overlay-widget coordinate system (mirrors Plot1D) "x_axis": x_axis, "view_x0": 0.0, @@ -200,6 +208,7 @@ def __init__(self, x, height=None, width: float = 0.8, bottom: float = 0.0, *, "overlay_widgets": [], "pointer_settled_ms": 0, "pointer_settled_delta": 4, + "_view_from_python": False, } self.callbacks = CallbackRegistry() self._widgets: dict[str, Widget] = {} @@ -306,6 +315,76 @@ def set_log_scale(self, log_scale: bool) -> None: self._state["data_max"] = dmax self._push() + # ------------------------------------------------------------------ + # Display control + # ------------------------------------------------------------------ + def set_title(self, text: str) -> None: + """Set the panel title.""" + self._state["title"] = str(text) + self._push() + + def set_xlabel(self, text: str) -> None: + """Set the x-axis label.""" + self._state["x_label"] = str(text) + self._push() + + def set_ylabel(self, text: str) -> None: + """Set the y-axis / value-axis label.""" + self._state["y_label"] = str(text) + self._push() + + def set_axis_off(self) -> None: + """Hide axes, ticks, and labels.""" + self._state["axis_visible"] = False + self._push() + + def set_axis_on(self) -> None: + """Show axes, ticks, and labels.""" + self._state["axis_visible"] = True + self._push() + + def set_ticks_visible(self, x: bool = True, y: bool = True) -> None: + """Show or hide x/y tick marks independently.""" + self._state["x_ticks_visible"] = bool(x) + self._state["y_ticks_visible"] = bool(y) + self._push() + + # ------------------------------------------------------------------ + # View (xlim / ylim) + # ------------------------------------------------------------------ + def set_xlim(self, x_min: float, x_max: float) -> None: + """Pan/zoom the x-axis to [x_min, x_max] in data coordinates.""" + x_axis = self._state["x_axis"] + span = x_axis[1] - x_axis[0] + if span == 0: + return + self._state["view_x0"] = (x_min - x_axis[0]) / span + self._state["view_x1"] = (x_max - x_axis[0]) / span + self._state["_view_from_python"] = True + self._push() + self._state["_view_from_python"] = False + + def set_ylim(self, y_min: float, y_max: float) -> None: + """Fix the value-axis range to [y_min, y_max].""" + self._state["y_range"] = [float(y_min), float(y_max)] + self._push() + + def get_ylim(self) -> tuple: + """Return the current value-axis range as ``(y_min, y_max)``.""" + yr = self._state.get("y_range") + if yr is not None: + return (float(yr[0]), float(yr[1])) + return (float(self._state["data_min"]), float(self._state["data_max"])) + + def reset_view(self) -> None: + """Reset pan/zoom to show all bars.""" + self._state["view_x0"] = 0.0 + self._state["view_x1"] = 1.0 + self._state["y_range"] = None + self._state["_view_from_python"] = True + self._push() + self._state["_view_from_python"] = False + # ------------------------------------------------------------------ # Overlay Widgets # ------------------------------------------------------------------ diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index 55a9b81f..4d00475c 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -334,6 +334,10 @@ def set_xlim(self, xmin: float, xmax: float) -> None: def set_ylim(self, ymin: float, ymax: float) -> None: self.set_view(y0=ymin, y1=ymax) + def get_xlim(self) -> tuple: + xarr = np.asarray(self._state["x_axis"]) + return (float(xarr.min()), float(xarr.max())) + def get_ylim(self) -> tuple: yarr = np.asarray(self._state["y_axis"]) return (float(yarr.min()), float(yarr.max())) diff --git a/anyplotlib/plot3d/_plot3d.py b/anyplotlib/plot3d/_plot3d.py index 48638c7b..8356700e 100644 --- a/anyplotlib/plot3d/_plot3d.py +++ b/anyplotlib/plot3d/_plot3d.py @@ -211,5 +211,5 @@ def set_data(self, x, y, z) -> None: def __repr__(self) -> str: geom = self._state.get("geom_type", "?") - n = len(self._state.get("vertices", [])) + n = self._state.get("vertices_count", 0) return f"Plot3D(geom={geom!r}, n_vertices={n})" diff --git a/anyplotlib/tests/test_layouts/test_gridspec.py b/anyplotlib/tests/test_layouts/test_gridspec.py index d5179c9a..96cc051b 100644 --- a/anyplotlib/tests/test_layouts/test_gridspec.py +++ b/anyplotlib/tests/test_layouts/test_gridspec.py @@ -1113,3 +1113,29 @@ def test_retriggers_layout_push(self): assert fig.layout_json != before +# =========================================================================== +# hspace / wspace initial-value contract +# =========================================================================== + +class TestHspaceWspaceInitialState: + def test_initial_hspace_is_none(self): + """Before subplots_adjust the internal value is None (browser 4px default).""" + fig, _ = vw.subplots(2, 2) + assert fig._hspace is None + assert fig._wspace is None + + def test_subplots_adjust_zero_stores_zero(self): + """subplots_adjust(hspace=0.0) must store 0.0, not None.""" + fig, _ = vw.subplots(2, 1) + fig.subplots_adjust(hspace=0.0, wspace=0.0) + assert fig._hspace == 0.0 + assert fig._wspace == 0.0 + + def test_subplots_adjust_zero_appears_in_layout(self): + fig, _ = vw.subplots(2, 2) + fig.subplots_adjust(hspace=0.0, wspace=0.0) + layout = json.loads(fig.layout_json) + assert layout["hspace"] == pytest.approx(0.0) + assert layout["wspace"] == pytest.approx(0.0) + + diff --git a/anyplotlib/tests/test_markers/test_markers.py b/anyplotlib/tests/test_markers/test_markers.py index 1333e249..8678f244 100644 --- a/anyplotlib/tests/test_markers/test_markers.py +++ b/anyplotlib/tests/test_markers/test_markers.py @@ -587,3 +587,109 @@ def test_remove_1d_group(self): g.remove() assert "marks" not in p.markers["vlines"] + +# =========================================================================== +# _KNOWN_1D completeness — arrows and squares +# =========================================================================== + +class TestKnown1dArrowsSquares: + def test_arrows_in_known_1d(self): + assert "arrows" in MarkerRegistry._KNOWN_1D + + def test_squares_in_known_1d(self): + assert "squares" in MarkerRegistry._KNOWN_1D + + def test_add_arrows_does_not_raise(self): + p = _make_plot1d() + offsets = np.column_stack([np.linspace(0, 1, 5), np.zeros(5)]) + p.add_arrows(offsets, U=0.05, V=0.1) + + def test_add_squares_does_not_raise(self): + p = _make_plot1d() + offsets = np.column_stack([np.linspace(0, 1, 3), np.zeros(3)]) + p.add_squares(offsets, widths=0.05) + + def test_add_arrows_wire_format(self): + p = _make_plot1d() + offsets = np.array([[0.1, 0.2], [0.5, 0.6]]) + p.add_arrows(offsets, U=0.1, V=0.2, name="arr") + wires = [m for m in p._state["markers"] if m["type"] == "arrows"] + assert len(wires) == 1 + w = wires[0] + assert "U" in w and "V" in w + assert len(w["U"]) == 2 + assert len(w["offsets"]) == 2 + + def test_add_squares_wire_format(self): + p = _make_plot1d() + offsets = np.array([[0.1, 0.2], [0.5, 0.6]]) + p.add_squares(offsets, widths=0.1, name="sq") + wires = [m for m in p._state["markers"] if m["type"] == "squares"] + assert len(wires) == 1 + w = wires[0] + assert "widths" in w + assert len(w["widths"]) == 2 + + +# =========================================================================== +# drawMarkers1d new types — wire format correctness +# =========================================================================== + +class TestMarkers1dNewTypes: + """add_rectangles/ellipses/polygons/arrows/squares on Plot1D produce + correct wire-format dicts that the JS drawMarkers1d handler will receive.""" + + def _plot(self): + x = np.linspace(0, 2 * np.pi, 64) + fig, ax = apl.subplots(1, 1) + return ax.plot(np.sin(x), axes=[x]) + + def _wire(self, p, type_): + return [m for m in p._state["markers"] if m["type"] == type_] + + def test_add_rectangles_wire(self): + p = self._plot() + offsets = np.array([[1.0, 0.5], [3.0, -0.5]]) + p.add_rectangles(offsets, widths=0.2, heights=0.1, name="rects") + ws = self._wire(p, "rectangles") + assert len(ws) == 1 + w = ws[0] + assert "widths" in w and "heights" in w + assert len(w["offsets"]) == 2 + + def test_add_squares_wire(self): + p = self._plot() + offsets = np.array([[1.0, 0.5], [3.0, -0.5]]) + p.add_squares(offsets, widths=0.1, name="sq") + ws = self._wire(p, "squares") + assert len(ws) == 1 + assert "widths" in ws[0] + + def test_add_ellipses_wire(self): + p = self._plot() + offsets = np.array([[1.0, 0.5], [4.0, 0.0]]) + p.add_ellipses(offsets, widths=0.3, heights=0.15, name="ellip") + ws = self._wire(p, "ellipses") + assert len(ws) == 1 + w = ws[0] + assert "widths" in w and "heights" in w and "angles" in w + + def test_add_polygons_wire(self): + p = self._plot() + tri = np.array([[0.5, 0.0], [1.0, 0.5], [1.5, 0.0]]) + p.add_polygons([tri], name="poly") + ws = self._wire(p, "polygons") + assert len(ws) == 1 + assert "vertices_list" in ws[0] + assert len(ws[0]["vertices_list"]) == 1 + + def test_add_arrows_wire(self): + p = self._plot() + offsets = np.array([[1.0, 0.0], [3.0, 0.5]]) + p.add_arrows(offsets, U=0.2, V=0.1, name="arrows") + ws = self._wire(p, "arrows") + assert len(ws) == 1 + w = ws[0] + assert "U" in w and "V" in w + assert len(w["U"]) == 2 + diff --git a/anyplotlib/tests/test_plot1d/test_plot1d.py b/anyplotlib/tests/test_plot1d/test_plot1d.py index c26b50b1..b0eb27ca 100644 --- a/anyplotlib/tests/test_plot1d/test_plot1d.py +++ b/anyplotlib/tests/test_plot1d/test_plot1d.py @@ -805,3 +805,135 @@ def test_semilogy_passes_kwargs(self): assert p._state["line_color"] == "#ff0000" assert p._state["yscale"] == "log" + +# =========================================================================== +# set_ylim / get_ylim +# =========================================================================== + +class TestSetGetYlim: + def test_get_ylim_default_returns_data_bounds(self): + p = _plot() + lo, hi = p.get_ylim() + assert lo == pytest.approx(p._state["data_min"]) + assert hi == pytest.approx(p._state["data_max"]) + + def test_set_ylim_stored_in_state(self): + p = _plot() + p.set_ylim(-2.0, 5.0) + assert p._state["y_range"] == [-2.0, 5.0] + + def test_get_ylim_after_set_ylim(self): + p = _plot() + p.set_ylim(-1.5, 3.0) + lo, hi = p.get_ylim() + assert lo == pytest.approx(-1.5) + assert hi == pytest.approx(3.0) + + def test_y_range_not_cleared_by_reset_view(self): + p = _plot() + p.set_ylim(-1.0, 1.0) + p.reset_view() + lo, hi = p.get_ylim() + assert lo == pytest.approx(-1.0) + assert hi == pytest.approx(1.0) + + def test_y_range_in_state_dict(self): + p = _plot() + p.set_ylim(0.0, 10.0) + assert p.to_state_dict()["y_range"] == [0.0, 10.0] + + def test_y_range_none_by_default(self): + assert _plot()._state["y_range"] is None + + def test_y_range_propagated_to_state_dict(self): + p = _plot() + p.set_ylim(-5.0, 5.0) + assert p.to_state_dict()["y_range"] == [-5.0, 5.0] + + def test_markers_state_dict_contains_y_range(self): + p = _plot() + p.set_ylim(0.0, 10.0) + assert p.to_state_dict()["y_range"] == [0.0, 10.0] + + +# =========================================================================== +# get_xlim +# =========================================================================== + +class TestGetXlim: + def test_get_xlim_full_view(self): + fig, ax = apl.subplots(1, 1) + x = np.linspace(0.0, 10.0, 64) + p = ax.plot(np.sin(x), axes=[x]) + lo, hi = p.get_xlim() + assert lo == pytest.approx(0.0, abs=0.01) + assert hi == pytest.approx(10.0, abs=0.01) + + def test_get_xlim_after_set_xlim(self): + fig, ax = apl.subplots(1, 1) + x = np.linspace(0.0, 10.0, 64) + p = ax.plot(np.sin(x), axes=[x]) + p.set_xlim(2.0, 8.0) + lo, hi = p.get_xlim() + assert lo == pytest.approx(2.0, abs=0.1) + assert hi == pytest.approx(8.0, abs=0.1) + + def test_get_xlim_default_x_axis(self): + p = _plot_lin(n=100) + lo, hi = p.get_xlim() + assert lo == pytest.approx(0.0, abs=0.01) + assert hi == pytest.approx(99.0, abs=0.01) + + +# =========================================================================== +# _view_from_python flag +# =========================================================================== + +class TestViewFromPython: + def test_initial_view_from_python_false(self): + assert _plot()._state["_view_from_python"] is False + + def test_set_view_clears_flag_after_push(self): + p = _plot() + p.set_view(x0=0.2, x1=0.8) + assert p._state["_view_from_python"] is False + + def test_reset_view_clears_flag_after_push(self): + p = _plot() + p.set_view(x0=0.2, x1=0.8) + p.reset_view() + assert p._state["_view_from_python"] is False + + def test_set_xlim_clears_flag_after_push(self): + fig, ax = apl.subplots(1, 1) + x = np.linspace(0, 10, 64) + p = ax.plot(np.sin(x), axes=[x]) + p.set_xlim(2.0, 8.0) + assert p._state["_view_from_python"] is False + assert p._state["view_x0"] != 0.0 or p._state["view_x1"] != 1.0 + + def test_view_from_python_present_in_state_dict(self): + p = _plot() + p.set_view(x0=0.1, x1=0.9) + sd = p.to_state_dict() + assert "_view_from_python" in sd + assert sd["_view_from_python"] is False + + +# =========================================================================== +# add_line default color +# =========================================================================== + +class TestAddLineDefaultColor: + def test_default_color_is_not_white(self): + import inspect + p = _plot() + default = inspect.signature(p.add_line).parameters["color"].default + assert default != "#ffffff" + assert default == "#4fc3f7" + + def test_add_line_uses_default_color_in_state(self): + p = _plot() + p.add_line(np.linspace(-1, 1, 128)) + assert p._state["extra_lines"][-1]["color"] == "#4fc3f7" + diff --git a/anyplotlib/tests/test_plot1d/test_plotbar.py b/anyplotlib/tests/test_plot1d/test_plotbar.py index 11fd9246..dec83a7b 100644 --- a/anyplotlib/tests/test_plot1d/test_plotbar.py +++ b/anyplotlib/tests/test_plot1d/test_plotbar.py @@ -718,3 +718,136 @@ def test_repr_grouped_shows_groups(self): def test_repr_contains_plotbar(self): assert "PlotBar" in repr(_bar([1, 2, 3], [10, 20, 30])) + +# =========================================================================== +# New state keys added in audit fix +# =========================================================================== + +class TestPlotBarNewStateKeys: + def test_title_default_empty(self): + assert _make_bar()._state["title"] == "" + + def test_x_label_in_state(self): + assert "x_label" in _make_bar()._state + + def test_y_label_in_state(self): + assert "y_label" in _make_bar()._state + + def test_axis_visible_true_by_default(self): + assert _make_bar()._state["axis_visible"] is True + + def test_x_ticks_visible_true_by_default(self): + assert _make_bar()._state["x_ticks_visible"] is True + + def test_y_ticks_visible_true_by_default(self): + assert _make_bar()._state["y_ticks_visible"] is True + + def test_align_stored(self): + assert _make_bar(align="edge")._state["align"] == "edge" + + def test_align_center_by_default(self): + assert _make_bar()._state["align"] == "center" + + def test_y_range_none_by_default(self): + p = _make_bar() + assert "y_range" in p._state + assert p._state["y_range"] is None + + def test_view_from_python_false_by_default(self): + assert _make_bar()._state["_view_from_python"] is False + + +# =========================================================================== +# New display-control methods added in audit fix +# =========================================================================== + +class TestPlotBarDisplayMethods: + def test_set_title(self): + p = _make_bar() + p.set_title("My Chart") + assert p._state["title"] == "My Chart" + + def test_set_xlabel(self): + p = _make_bar() + p.set_xlabel("Category") + assert p._state["x_label"] == "Category" + + def test_set_ylabel(self): + p = _make_bar() + p.set_ylabel("Value") + assert p._state["y_label"] == "Value" + + def test_set_axis_off(self): + p = _make_bar() + p.set_axis_off() + assert p._state["axis_visible"] is False + + def test_set_axis_on_restores(self): + p = _make_bar() + p.set_axis_off() + p.set_axis_on() + assert p._state["axis_visible"] is True + + def test_set_ticks_visible_both_false(self): + p = _make_bar() + p.set_ticks_visible(x=False, y=False) + assert p._state["x_ticks_visible"] is False + assert p._state["y_ticks_visible"] is False + + def test_set_ticks_visible_x_only(self): + p = _make_bar() + p.set_ticks_visible(x=True, y=False) + assert p._state["x_ticks_visible"] is True + assert p._state["y_ticks_visible"] is False + + def test_set_ylim(self): + p = _make_bar() + p.set_ylim(0.0, 10.0) + assert p._state["y_range"] == [0.0, 10.0] + + def test_get_ylim_default(self): + p = _make_bar() + lo, hi = p.get_ylim() + assert lo == pytest.approx(p._state["data_min"]) + assert hi == pytest.approx(p._state["data_max"]) + + def test_get_ylim_after_set_ylim(self): + p = _make_bar() + p.set_ylim(-1.0, 20.0) + lo, hi = p.get_ylim() + assert lo == pytest.approx(-1.0) + assert hi == pytest.approx(20.0) + + def test_set_xlim_changes_view(self): + fig, ax = apl.subplots(1, 1) + p = ax.bar(np.arange(10), np.ones(10)) + p.set_xlim(2.0, 7.0) + assert p._state["view_x0"] != 0.0 or p._state["view_x1"] != 1.0 + + def test_reset_view(self): + fig, ax = apl.subplots(1, 1) + p = ax.bar(np.arange(10), np.ones(10)) + p.set_xlim(2.0, 7.0) + p.set_ylim(0.0, 5.0) + p.reset_view() + assert p._state["view_x0"] == pytest.approx(0.0) + assert p._state["view_x1"] == pytest.approx(1.0) + assert p._state["y_range"] is None + + +# =========================================================================== +# _view_from_python flag on PlotBar +# =========================================================================== + +class TestPlotBarViewFromPython: + def test_set_xlim_clears_flag(self): + fig, ax = apl.subplots(1, 1) + p = ax.bar(np.arange(10), np.ones(10)) + p.set_xlim(2.0, 7.0) + assert p._state["_view_from_python"] is False + + def test_reset_view_clears_flag(self): + p = _make_bar() + p.reset_view() + assert p._state["_view_from_python"] is False + diff --git a/anyplotlib/tests/test_plot2d/test_plot2d_api.py b/anyplotlib/tests/test_plot2d/test_plot2d_api.py index fa197c42..3929e7ed 100644 --- a/anyplotlib/tests/test_plot2d/test_plot2d_api.py +++ b/anyplotlib/tests/test_plot2d/test_plot2d_api.py @@ -395,3 +395,33 @@ def test_resize_with_height_ratios_scales_proportionally(self): # top: 3/4 × 800 = 600 px; bottom: 1/4 × 800 = 200 px assert specs[plot_top._id]["panel_height"] == pytest.approx(600, abs=1) assert specs[plot_bot._id]["panel_height"] == pytest.approx(200, abs=1) + + +# =========================================================================== +# Plot2D.get_xlim +# =========================================================================== + +class TestPlot2DGetXlim: + def test_get_xlim_exists(self): + p = _make_plot2d() + assert hasattr(p, "get_xlim") + + def test_get_xlim_with_physical_axes(self): + fig, ax = apl.subplots(1, 1) + x = np.linspace(0.0, 10.0, 16) + p = ax.imshow(np.zeros((16, 16)), axes=[x, np.linspace(0, 5, 16)], units="nm") + lo, hi = p.get_xlim() + assert lo == pytest.approx(0.0) + assert hi == pytest.approx(10.0) + + def test_get_xlim_and_get_ylim_match_axes(self): + fig, ax = apl.subplots(1, 1) + x = np.linspace(1.0, 5.0, 16) + y = np.linspace(2.0, 8.0, 16) + p = ax.imshow(np.zeros((16, 16)), axes=[x, y], units="m") + xlo, xhi = p.get_xlim() + ylo, yhi = p.get_ylim() + assert xlo == pytest.approx(1.0) + assert xhi == pytest.approx(5.0) + assert ylo == pytest.approx(2.0) + assert yhi == pytest.approx(8.0) diff --git a/anyplotlib/tests/test_plot3d/test_plot3d.py b/anyplotlib/tests/test_plot3d/test_plot3d.py index 2316ecc0..01f68ae6 100644 --- a/anyplotlib/tests/test_plot3d/test_plot3d.py +++ b/anyplotlib/tests/test_plot3d/test_plot3d.py @@ -196,3 +196,41 @@ def test_set_data_surface_bad_shape(self): surf.set_data(x, x, x) +# =========================================================================== +# repr() uses vertices_count, not len(vertices) +# =========================================================================== + +class TestPlot3DRepr: + def test_repr_uses_vertices_count(self): + """repr() must read vertices_count, not len(state['vertices']).""" + + class _FakePlot3D(Plot3D): + def __init__(self): + self._state = {"geom_type": "mesh", "vertices_count": 42} + self._id = "" + self._fig = None + + assert "n_vertices=42" in repr(_FakePlot3D()) + + def test_repr_zero_when_count_zero(self): + class _FakePlot3D(Plot3D): + def __init__(self): + self._state = {"geom_type": "scatter", "vertices_count": 0} + self._id = "" + self._fig = None + + assert "n_vertices=0" in repr(_FakePlot3D()) + + def test_repr_on_real_line(self): + _, x, y, z = _line() + # _line() creates a Plot3D via plot3d(); repr must not raise and must + # show the correct vertex count. + from anyplotlib.plot3d._plot3d import Plot3D as _P3D + # find the plot object returned by _line + ln, *_ = _line() + r = repr(ln) + assert "n_vertices=" in r + # vertex count must equal len(x), not 0 + assert f"n_vertices={len(x)}" in r + + From d4b90aa63fb506aad81620cb7ece986558677353 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 22 May 2026 20:03:52 -0500 Subject: [PATCH 167/198] Refactor: Add axis visibility controls and state management methods for Plot1D, Plot2D, and Plot3D; enhance PlotBar with get_xlim method --- anyplotlib/__init__.py | 4 ++ anyplotlib/figure_esm.js | 8 +++ anyplotlib/plot1d/_plot1d.py | 5 +- anyplotlib/plot1d/_plotbar.py | 21 ++++++-- anyplotlib/plot2d/_plot2d.py | 5 +- anyplotlib/plot2d/_plotmesh.py | 8 +++ anyplotlib/plot3d/_plot3d.py | 33 ++++++++++++ anyplotlib/tests/test_markers/test_markers.py | 31 +++++++++++ anyplotlib/tests/test_plot1d/test_plot1d.py | 18 +++++++ anyplotlib/tests/test_plot1d/test_plotbar.py | 52 +++++++++++++++++- .../tests/test_plot2d/test_plot2d_api.py | 36 +++++++++++++ anyplotlib/tests/test_plot3d/test_plot3d.py | 53 +++++++++++++++++++ 12 files changed, 267 insertions(+), 7 deletions(-) diff --git a/anyplotlib/__init__.py b/anyplotlib/__init__.py index c897ddce..dbb4f476 100644 --- a/anyplotlib/__init__.py +++ b/anyplotlib/__init__.py @@ -1,9 +1,11 @@ from anyplotlib.figure import Figure, GridSpec, SubplotSpec, subplots from anyplotlib.axes import Axes, InsetAxes from anyplotlib.plot1d import Plot1D, PlotBar +from anyplotlib.plot1d._plot1d import Line1D from anyplotlib.plot2d import Plot2D, PlotMesh from anyplotlib.plot3d import Plot3D from anyplotlib.callbacks import CallbackRegistry, Event +from anyplotlib.markers import MarkerRegistry, MarkerGroup from anyplotlib.widgets import ( Widget, RectangleWidget, CircleWidget, AnnularWidget, CrosshairWidget, PolygonWidget, LabelWidget, @@ -30,7 +32,9 @@ def get_color_cycle() -> list[str]: __all__ = [ "Figure", "GridSpec", "SubplotSpec", "subplots", "Axes", "InsetAxes", "Plot1D", "Plot2D", "PlotMesh", "Plot3D", "PlotBar", + "Line1D", "CallbackRegistry", "Event", + "MarkerRegistry", "MarkerGroup", "Widget", "RectangleWidget", "CircleWidget", "AnnularWidget", "CrosshairWidget", "PolygonWidget", "LabelWidget", "VLineWidget", "HLineWidget", "RangeWidget", diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 70d6f669..b524da6d 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -573,6 +573,10 @@ function render({ model, el }) { } else if (p2.state && (p2.kind === '1d' || p2.kind === 'bar') && !newState._view_from_python) { newState.view_x0 = p2.state.view_x0; newState.view_x1 = p2.state.view_x1; + } else if (p2.state && p2.kind === '3d' && !newState._view_from_python) { + newState.azimuth = p2.state.azimuth; + newState.elevation = p2.state.elevation; + newState.zoom = p2.state.zoom; } p2.state = newState; } @@ -698,6 +702,10 @@ function render({ model, el }) { } else if (p2.state && (p2.kind === '1d' || p2.kind === 'bar') && !newState._view_from_python) { newState.view_x0 = p2.state.view_x0; newState.view_x1 = p2.state.view_x1; + } else if (p2.state && p2.kind === '3d' && !newState._view_from_python) { + newState.azimuth = p2.state.azimuth; + newState.elevation = p2.state.elevation; + newState.zoom = p2.state.zoom; } p2.state = newState; } diff --git a/anyplotlib/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py index 4010597f..5915479e 100644 --- a/anyplotlib/plot1d/_plot1d.py +++ b/anyplotlib/plot1d/_plot1d.py @@ -327,7 +327,6 @@ def to_state_dict(self) -> dict: x_arr = d.pop("x_axis") d["data_b64"] = _arr_to_b64(data_arr, np.float64) d["x_axis_b64"] = _arr_to_b64(x_arr, np.float64) - d["data_length"] = len(data_arr) # Encode extra-line arrays too new_extra = [] for ex in d["extra_lines"]: @@ -912,6 +911,10 @@ def set_axis_off(self) -> None: self._state["axis_visible"] = False self._push() + def set_axis_on(self) -> None: + self._state["axis_visible"] = True + self._push() + def set_ticks_visible(self, visible: bool, *, x: bool | None = None, y: bool | None = None) -> None: if x is None and y is None: diff --git a/anyplotlib/plot1d/_plotbar.py b/anyplotlib/plot1d/_plotbar.py index f73b940a..7801f59c 100644 --- a/anyplotlib/plot1d/_plotbar.py +++ b/anyplotlib/plot1d/_plotbar.py @@ -343,10 +343,17 @@ def set_axis_on(self) -> None: self._state["axis_visible"] = True self._push() - def set_ticks_visible(self, x: bool = True, y: bool = True) -> None: + def set_ticks_visible(self, visible: bool, *, x: bool | None = None, + y: bool | None = None) -> None: """Show or hide x/y tick marks independently.""" - self._state["x_ticks_visible"] = bool(x) - self._state["y_ticks_visible"] = bool(y) + if x is None and y is None: + self._state["x_ticks_visible"] = bool(visible) + self._state["y_ticks_visible"] = bool(visible) + else: + if x is not None: + self._state["x_ticks_visible"] = bool(x) + if y is not None: + self._state["y_ticks_visible"] = bool(y) self._push() # ------------------------------------------------------------------ @@ -376,6 +383,14 @@ def get_ylim(self) -> tuple: return (float(yr[0]), float(yr[1])) return (float(self._state["data_min"]), float(self._state["data_max"])) + def get_xlim(self) -> tuple: + """Return the current x-axis view range in data coordinates.""" + x_axis = self._state["x_axis"] + span = x_axis[1] - x_axis[0] + x0 = x_axis[0] + self._state["view_x0"] * span + x1 = x_axis[0] + self._state["view_x1"] * span + return (float(x0), float(x1)) + def reset_view(self) -> None: """Reset pan/zoom to show all bars.""" self._state["view_x0"] = 0.0 diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index 4d00475c..24f95721 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -108,7 +108,6 @@ def __init__(self, data: np.ndarray, "raw_min": raw_vmin, "raw_max": raw_vmax, "show_colorbar": False, - "log_scale": False, "scale_mode": "linear", "colormap_name": cmap_name, "colormap_data": cmap_lut, @@ -377,6 +376,10 @@ def set_axis_off(self) -> None: self._state["axis_visible"] = False self._push() + def set_axis_on(self) -> None: + self._state["axis_visible"] = True + self._push() + def set_ticks_visible(self, visible: bool, *, x: bool | None = None, y: bool | None = None) -> None: if x is None and y is None: diff --git a/anyplotlib/plot2d/_plotmesh.py b/anyplotlib/plot2d/_plotmesh.py index cad16dad..0a8184c9 100644 --- a/anyplotlib/plot2d/_plotmesh.py +++ b/anyplotlib/plot2d/_plotmesh.py @@ -106,3 +106,11 @@ def set_data(self, data: np.ndarray, if units is not None: self._state["units"] = units self._push() + + def __repr__(self) -> str: + xe = self._state.get("x_axis", []) + ye = self._state.get("y_axis", []) + cols = max(0, len(xe) - 1) + rows = max(0, len(ye) - 1) + cmap = self._state.get("colormap_name", "?") + return f"PlotMesh({rows}×{cols}, cmap={cmap!r})" diff --git a/anyplotlib/plot3d/_plot3d.py b/anyplotlib/plot3d/_plot3d.py index 8356700e..198ca258 100644 --- a/anyplotlib/plot3d/_plot3d.py +++ b/anyplotlib/plot3d/_plot3d.py @@ -124,6 +124,10 @@ def __init__(self, geom_type: str, "azimuth": float(azimuth), "elevation": float(elevation), "zoom": float(zoom), + "_default_azimuth": float(azimuth), + "_default_elevation": float(elevation), + "_default_zoom": float(zoom), + "_view_from_python": False, "data_bounds": data_bounds, "pointer_settled_ms": 0, "pointer_settled_delta": 4, @@ -158,10 +162,39 @@ def set_view(self, azimuth: float | None = None, """Set the camera azimuth (°) and/or elevation (°).""" if azimuth is not None: self._state["azimuth"] = float(azimuth) if elevation is not None: self._state["elevation"] = float(elevation) + self._state["_view_from_python"] = True self._push() + self._state["_view_from_python"] = False def set_zoom(self, zoom: float) -> None: self._state["zoom"] = float(zoom) + self._state["_view_from_python"] = True + self._push() + self._state["_view_from_python"] = False + + def reset_view(self) -> None: + """Restore the camera to the angles/zoom set at construction time.""" + self._state["azimuth"] = self._state["_default_azimuth"] + self._state["elevation"] = self._state["_default_elevation"] + self._state["zoom"] = self._state["_default_zoom"] + self._state["_view_from_python"] = True + self._push() + self._state["_view_from_python"] = False + + def set_xlabel(self, label: str) -> None: + self._state["x_label"] = label + self._push() + + def set_ylabel(self, label: str) -> None: + self._state["y_label"] = label + self._push() + + def set_zlabel(self, label: str) -> None: + self._state["z_label"] = label + self._push() + + def set_title(self, title: str) -> None: + self._state["title"] = title self._push() def set_data(self, x, y, z) -> None: diff --git a/anyplotlib/tests/test_markers/test_markers.py b/anyplotlib/tests/test_markers/test_markers.py index 8678f244..1971cb57 100644 --- a/anyplotlib/tests/test_markers/test_markers.py +++ b/anyplotlib/tests/test_markers/test_markers.py @@ -693,3 +693,34 @@ def test_add_arrows_wire(self): assert "U" in w and "V" in w assert len(w["U"]) == 2 + + +# =========================================================================== +# Top-level exports +# =========================================================================== + +class TestTopLevelExports: + def test_line1d_exported(self): + import anyplotlib as apl + assert hasattr(apl, "Line1D") + from anyplotlib import Line1D + assert Line1D is not None + + def test_marker_registry_exported(self): + import anyplotlib as apl + assert hasattr(apl, "MarkerRegistry") + from anyplotlib import MarkerRegistry + assert MarkerRegistry is not None + + def test_marker_group_exported(self): + import anyplotlib as apl + assert hasattr(apl, "MarkerGroup") + from anyplotlib import MarkerGroup + assert MarkerGroup is not None + + def test_line1d_data_length_not_in_wire(self): + """data_length must not appear in to_state_dict() wire output.""" + fig, ax = apl.subplots(1, 1) + p = ax.plot(np.linspace(0, 1, 64)) + wire = p.to_state_dict() + assert "data_length" not in wire diff --git a/anyplotlib/tests/test_plot1d/test_plot1d.py b/anyplotlib/tests/test_plot1d/test_plot1d.py index b0eb27ca..b8ffb9df 100644 --- a/anyplotlib/tests/test_plot1d/test_plot1d.py +++ b/anyplotlib/tests/test_plot1d/test_plot1d.py @@ -937,3 +937,21 @@ def test_add_line_uses_default_color_in_state(self): p.add_line(np.linspace(-1, 1, 128)) assert p._state["extra_lines"][-1]["color"] == "#4fc3f7" + + +# =========================================================================== +# set_axis_on (Plot1D) +# =========================================================================== + +class TestSetAxisOnPlot1D: + def test_set_axis_on_restores(self): + p = _plot() + p.set_axis_off() + assert p._state["axis_visible"] is False + p.set_axis_on() + assert p._state["axis_visible"] is True + + def test_set_axis_on_default_state(self): + p = _plot() + p.set_axis_on() + assert p._state["axis_visible"] is True diff --git a/anyplotlib/tests/test_plot1d/test_plotbar.py b/anyplotlib/tests/test_plot1d/test_plotbar.py index dec83a7b..46290fd4 100644 --- a/anyplotlib/tests/test_plot1d/test_plotbar.py +++ b/anyplotlib/tests/test_plot1d/test_plotbar.py @@ -790,13 +790,13 @@ def test_set_axis_on_restores(self): def test_set_ticks_visible_both_false(self): p = _make_bar() - p.set_ticks_visible(x=False, y=False) + p.set_ticks_visible(False) assert p._state["x_ticks_visible"] is False assert p._state["y_ticks_visible"] is False def test_set_ticks_visible_x_only(self): p = _make_bar() - p.set_ticks_visible(x=True, y=False) + p.set_ticks_visible(True, x=True, y=False) assert p._state["x_ticks_visible"] is True assert p._state["y_ticks_visible"] is False @@ -851,3 +851,51 @@ def test_reset_view_clears_flag(self): p.reset_view() assert p._state["_view_from_python"] is False + + +# =========================================================================== +# PlotBar: get_xlim and fixed set_ticks_visible signature +# =========================================================================== + +class TestPlotBarGetXlim: + def test_get_xlim_default(self): + p = _make_bar() + x_axis = p._state["x_axis"] + lo, hi = p.get_xlim() + assert lo == pytest.approx(x_axis[0]) + assert hi == pytest.approx(x_axis[-1]) + + def test_get_xlim_after_set_xlim(self): + fig, ax = apl.subplots(1, 1) + p = ax.bar(np.arange(10), np.ones(10)) + p.set_xlim(2.0, 7.0) + lo, hi = p.get_xlim() + assert lo == pytest.approx(2.0, abs=0.5) + assert hi == pytest.approx(7.0, abs=0.5) + + +class TestPlotBarSetTicksVisibleSignature: + def test_positional_visible_both(self): + p = _make_bar() + p.set_ticks_visible(False) + assert p._state["x_ticks_visible"] is False + assert p._state["y_ticks_visible"] is False + + def test_positional_visible_true(self): + p = _make_bar() + p.set_ticks_visible(False) + p.set_ticks_visible(True) + assert p._state["x_ticks_visible"] is True + assert p._state["y_ticks_visible"] is True + + def test_keyword_x_only(self): + p = _make_bar() + p.set_ticks_visible(True, x=False) + assert p._state["x_ticks_visible"] is False + assert p._state["y_ticks_visible"] is True + + def test_keyword_y_only(self): + p = _make_bar() + p.set_ticks_visible(True, y=False) + assert p._state["x_ticks_visible"] is True + assert p._state["y_ticks_visible"] is False diff --git a/anyplotlib/tests/test_plot2d/test_plot2d_api.py b/anyplotlib/tests/test_plot2d/test_plot2d_api.py index 3929e7ed..b66560e3 100644 --- a/anyplotlib/tests/test_plot2d/test_plot2d_api.py +++ b/anyplotlib/tests/test_plot2d/test_plot2d_api.py @@ -425,3 +425,39 @@ def test_get_xlim_and_get_ylim_match_axes(self): assert xhi == pytest.approx(5.0) assert ylo == pytest.approx(2.0) assert yhi == pytest.approx(8.0) + + +# =========================================================================== +# Plot2D: set_axis_on and no log_scale key +# =========================================================================== + +class TestPlot2DSetAxisOn: + def test_set_axis_on_restores(self): + fig, ax = apl.subplots(1, 1) + p = ax.imshow(np.zeros((8, 8)), units="px") + p.set_axis_off() + assert p._state["axis_visible"] is False + p.set_axis_on() + assert p._state["axis_visible"] is True + + def test_no_log_scale_key(self): + fig, ax = apl.subplots(1, 1) + p = ax.imshow(np.zeros((8, 8)), units="px") + assert "log_scale" not in p._state + + +class TestPlotMeshRepr: + def test_repr_is_plotmesh(self): + from anyplotlib.plot2d import PlotMesh + fig, ax = apl.subplots(1, 1) + p = ax.pcolormesh(np.ones((4, 6))) + r = repr(p) + assert r.startswith("PlotMesh(") + assert "4" in r + assert "6" in r + + def test_repr_not_plot2d(self): + from anyplotlib.plot2d import PlotMesh + fig, ax = apl.subplots(1, 1) + p = ax.pcolormesh(np.ones((3, 5))) + assert not repr(p).startswith("Plot2D(") diff --git a/anyplotlib/tests/test_plot3d/test_plot3d.py b/anyplotlib/tests/test_plot3d/test_plot3d.py index 01f68ae6..4a351adf 100644 --- a/anyplotlib/tests/test_plot3d/test_plot3d.py +++ b/anyplotlib/tests/test_plot3d/test_plot3d.py @@ -195,6 +195,59 @@ def test_set_data_surface_bad_shape(self): with pytest.raises(ValueError): surf.set_data(x, x, x) + def test_set_view_clears_view_from_python(self): + surf, *_ = _surface() + surf.set_view(azimuth=10.0) + assert surf._state["_view_from_python"] is False + + def test_set_zoom_clears_view_from_python(self): + surf, *_ = _surface() + surf.set_zoom(1.5) + assert surf._state["_view_from_python"] is False + + def test_reset_view_restores_defaults(self): + surf, *_ = _surface() + surf.set_view(azimuth=90.0, elevation=10.0) + surf.set_zoom(3.0) + surf.reset_view() + assert surf._state["azimuth"] == pytest.approx(-60.0) + assert surf._state["elevation"] == pytest.approx(30.0) + assert surf._state["zoom"] == pytest.approx(1.0) + assert surf._state["_view_from_python"] is False + + def test_reset_view_uses_constructor_angles(self): + x = np.linspace(-1, 1, 5) + y = np.linspace(-1, 1, 5) + XX, YY = np.meshgrid(x, y) + ZZ = XX * YY + fig, ax = apl.subplots(1, 1) + surf = ax.plot_surface(XX, YY, ZZ, azimuth=15.0, elevation=45.0, zoom=2.0) + surf.set_view(azimuth=0.0, elevation=0.0) + surf.reset_view() + assert surf._state["azimuth"] == pytest.approx(15.0) + assert surf._state["elevation"] == pytest.approx(45.0) + assert surf._state["zoom"] == pytest.approx(2.0) + + def test_set_xlabel(self): + surf, *_ = _surface() + surf.set_xlabel("time") + assert surf._state["x_label"] == "time" + + def test_set_ylabel(self): + surf, *_ = _surface() + surf.set_ylabel("depth") + assert surf._state["y_label"] == "depth" + + def test_set_zlabel(self): + surf, *_ = _surface() + surf.set_zlabel("intensity") + assert surf._state["z_label"] == "intensity" + + def test_set_title(self): + surf, *_ = _surface() + surf.set_title("My Surface") + assert surf._state["title"] == "My Surface" + # =========================================================================== # repr() uses vertices_count, not len(vertices) From 4b76fbd870fd5cf4c320d0b2f546a638bcfe11da Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 22 May 2026 20:17:22 -0500 Subject: [PATCH 168/198] Refactor: Standardize method names and enhance axis visibility controls for Plot1D, Plot2D, and Plot3D; add y-axis scale configuration and data bounds getters --- anyplotlib/plot1d/_plot1d.py | 12 ++- anyplotlib/plot1d/_plotbar.py | 49 +++++++--- anyplotlib/plot2d/_plot2d.py | 5 +- anyplotlib/plot3d/_plot3d.py | 36 ++++++- anyplotlib/tests/test_plot1d/test_plot1d.py | 60 ++++++++++++ anyplotlib/tests/test_plot1d/test_plotbar.py | 94 +++++++++++++++++++ .../tests/test_plot2d/test_plot2d_api.py | 43 +++++++++ anyplotlib/tests/test_plot3d/test_plot3d.py | 72 ++++++++++++++ 8 files changed, 354 insertions(+), 17 deletions(-) diff --git a/anyplotlib/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py index 5915479e..ef208e3a 100644 --- a/anyplotlib/plot1d/_plot1d.py +++ b/anyplotlib/plot1d/_plot1d.py @@ -305,11 +305,14 @@ def __init__(self, data: np.ndarray, self.callbacks = CallbackRegistry() self._widgets: dict[str, Widget] = {} - def _configure_pointer_settled(self, ms: int, delta: float) -> None: + def configure_pointer_settled(self, ms: int, delta: float = 4) -> None: + """Configure the pointer-settled event threshold (ms and pixel delta).""" self._state["pointer_settled_ms"] = ms self._state["pointer_settled_delta"] = delta self._push() + _configure_pointer_settled = configure_pointer_settled # backward compat + def _push(self) -> None: if self._fig is None: return @@ -880,6 +883,13 @@ def set_title(self, label: str) -> None: self._state["title"] = str(label) self._push() + def set_yscale(self, scale: str) -> None: + """Set the y-axis scale: ``'linear'`` or ``'log'``.""" + if scale not in ("linear", "log"): + raise ValueError("scale must be 'linear' or 'log'") + self._state["yscale"] = scale + self._push() + def set_xlim(self, xmin: float, xmax: float) -> None: self.set_view(x0=xmin, x1=xmax) diff --git a/anyplotlib/plot1d/_plotbar.py b/anyplotlib/plot1d/_plotbar.py index 7801f59c..4e8f84f6 100644 --- a/anyplotlib/plot1d/_plotbar.py +++ b/anyplotlib/plot1d/_plotbar.py @@ -213,11 +213,14 @@ def __init__(self, x, height=None, width: float = 0.8, bottom: float = 0.0, *, self.callbacks = CallbackRegistry() self._widgets: dict[str, Widget] = {} - def _configure_pointer_settled(self, ms: int, delta: float) -> None: + def configure_pointer_settled(self, ms: int, delta: float = 4) -> None: + """Configure the pointer-settled event threshold (ms and pixel delta).""" self._state["pointer_settled_ms"] = ms self._state["pointer_settled_delta"] = delta self._push() + _configure_pointer_settled = configure_pointer_settled # backward compat + # ------------------------------------------------------------------ def _push(self) -> None: if self._fig is None: @@ -318,19 +321,43 @@ def set_log_scale(self, log_scale: bool) -> None: # ------------------------------------------------------------------ # Display control # ------------------------------------------------------------------ - def set_title(self, text: str) -> None: + def set_title(self, label: str) -> None: """Set the panel title.""" - self._state["title"] = str(text) + self._state["title"] = str(label) self._push() - def set_xlabel(self, text: str) -> None: + def set_xlabel(self, label: str) -> None: """Set the x-axis label.""" - self._state["x_label"] = str(text) + self._state["x_label"] = str(label) self._push() - def set_ylabel(self, text: str) -> None: + def set_ylabel(self, label: str) -> None: """Set the y-axis / value-axis label.""" - self._state["y_label"] = str(text) + self._state["y_label"] = str(label) + self._push() + + def set_bar_width(self, width: float) -> None: + """Set the bar width.""" + self._state["bar_width"] = float(width) + self._push() + + def set_align(self, align: str) -> None: + """Set bar alignment: ``'center'`` or ``'edge'``.""" + if align not in ("center", "edge"): + raise ValueError("align must be 'center' or 'edge'") + self._state["align"] = align + self._push() + + def set_orient(self, orient: str) -> None: + """Set bar orientation: ``'v'`` (vertical) or ``'h'`` (horizontal).""" + if orient not in ("v", "h"): + raise ValueError("orient must be 'v' or 'h'") + self._state["orient"] = orient + self._push() + + def set_group_labels(self, labels) -> None: + """Replace the category labels on the category axis.""" + self._state["group_labels"] = list(labels) self._push() def set_axis_off(self) -> None: @@ -359,14 +386,14 @@ def set_ticks_visible(self, visible: bool, *, x: bool | None = None, # ------------------------------------------------------------------ # View (xlim / ylim) # ------------------------------------------------------------------ - def set_xlim(self, x_min: float, x_max: float) -> None: - """Pan/zoom the x-axis to [x_min, x_max] in data coordinates.""" + def set_xlim(self, xmin: float, xmax: float) -> None: + """Pan/zoom the x-axis to [xmin, xmax] in data coordinates.""" x_axis = self._state["x_axis"] span = x_axis[1] - x_axis[0] if span == 0: return - self._state["view_x0"] = (x_min - x_axis[0]) / span - self._state["view_x1"] = (x_max - x_axis[0]) / span + self._state["view_x0"] = (xmin - x_axis[0]) / span + self._state["view_x1"] = (xmax - x_axis[0]) / span self._state["_view_from_python"] = True self._push() self._state["_view_from_python"] = False diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index 24f95721..28e6030a 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -143,11 +143,14 @@ def __init__(self, data: np.ndarray, self.callbacks = CallbackRegistry() self._widgets: dict[str, Widget] = {} - def _configure_pointer_settled(self, ms: int, delta: float) -> None: + def configure_pointer_settled(self, ms: int, delta: float = 4) -> None: + """Configure the pointer-settled event threshold (ms and pixel delta).""" self._state["pointer_settled_ms"] = ms self._state["pointer_settled_delta"] = delta self._push() + _configure_pointer_settled = configure_pointer_settled # backward compat + @staticmethod def _encode_bytes(arr: np.ndarray) -> str: import base64 diff --git a/anyplotlib/plot3d/_plot3d.py b/anyplotlib/plot3d/_plot3d.py index 198ca258..2df937e6 100644 --- a/anyplotlib/plot3d/_plot3d.py +++ b/anyplotlib/plot3d/_plot3d.py @@ -118,9 +118,11 @@ def __init__(self, geom_type: str, "color": color, "point_size": float(point_size), "linewidth": float(linewidth), + "title": "", "x_label": x_label, "y_label": y_label, "z_label": z_label, + "axis_visible": True, "azimuth": float(azimuth), "elevation": float(elevation), "zoom": float(zoom), @@ -134,11 +136,14 @@ def __init__(self, geom_type: str, } self.callbacks = CallbackRegistry() - def _configure_pointer_settled(self, ms: int, delta: float) -> None: + def configure_pointer_settled(self, ms: int, delta: float = 4) -> None: + """Configure the pointer-settled event threshold (ms and pixel delta).""" self._state["pointer_settled_ms"] = ms self._state["pointer_settled_delta"] = delta self._push() + _configure_pointer_settled = configure_pointer_settled # backward compat + # ------------------------------------------------------------------ def _push(self) -> None: if self._fig is None: @@ -181,6 +186,18 @@ def reset_view(self) -> None: self._push() self._state["_view_from_python"] = False + def set_axis_off(self) -> None: + self._state["axis_visible"] = False + self._push() + + def set_axis_on(self) -> None: + self._state["axis_visible"] = True + self._push() + + def set_title(self, label: str) -> None: + self._state["title"] = str(label) + self._push() + def set_xlabel(self, label: str) -> None: self._state["x_label"] = label self._push() @@ -193,9 +210,20 @@ def set_zlabel(self, label: str) -> None: self._state["z_label"] = label self._push() - def set_title(self, title: str) -> None: - self._state["title"] = title - self._push() + def get_xlim(self) -> tuple: + """Return the data x range as ``(xmin, xmax)``.""" + b = self._state["data_bounds"] + return (b["xmin"], b["xmax"]) + + def get_ylim(self) -> tuple: + """Return the data y range as ``(ymin, ymax)``.""" + b = self._state["data_bounds"] + return (b["ymin"], b["ymax"]) + + def get_zlim(self) -> tuple: + """Return the data z range as ``(zmin, zmax)``.""" + b = self._state["data_bounds"] + return (b["zmin"], b["zmax"]) def set_data(self, x, y, z) -> None: """Replace the geometry data.""" diff --git a/anyplotlib/tests/test_plot1d/test_plot1d.py b/anyplotlib/tests/test_plot1d/test_plot1d.py index b8ffb9df..fedd5313 100644 --- a/anyplotlib/tests/test_plot1d/test_plot1d.py +++ b/anyplotlib/tests/test_plot1d/test_plot1d.py @@ -955,3 +955,63 @@ def test_set_axis_on_default_state(self): p = _plot() p.set_axis_on() assert p._state["axis_visible"] is True + + +# =========================================================================== +# M4: set_yscale on Plot1D +# =========================================================================== + +class TestSetYscale: + def test_set_yscale_log(self): + p = _plot() + p.set_yscale("log") + assert p._state["yscale"] == "log" + + def test_set_yscale_linear(self): + p = _plot() + p.set_yscale("log") + p.set_yscale("linear") + assert p._state["yscale"] == "linear" + + def test_set_yscale_invalid(self): + p = _plot() + with pytest.raises(ValueError): + p.set_yscale("symlog") + + +# =========================================================================== +# m2: configure_pointer_settled public on Plot1D +# =========================================================================== + +class TestPlot1DConfigurePointerSettled: + def test_public_method_exists(self): + p = _plot() + assert hasattr(p, "configure_pointer_settled") + assert callable(p.configure_pointer_settled) + + def test_sets_state(self): + p = _plot() + p.configure_pointer_settled(200, 5) + assert p._state["pointer_settled_ms"] == 200 + assert p._state["pointer_settled_delta"] == 5 + + +# =========================================================================== +# m3: direct tests for set_title/xlabel/ylabel and set_axis_on on Plot1D +# =========================================================================== + +class TestPlot1DDisplayMethods: + def test_set_title(self): + p = _plot() + p.set_title("My Plot") + assert p._state["title"] == "My Plot" + + def test_set_xlabel(self): + p = _plot() + p.set_xlabel("Time (s)") + assert p._state["units"] == "Time (s)" + + def test_set_ylabel(self): + p = _plot() + p.set_ylabel("Amplitude") + assert p._state["y_units"] == "Amplitude" diff --git a/anyplotlib/tests/test_plot1d/test_plotbar.py b/anyplotlib/tests/test_plot1d/test_plotbar.py index 46290fd4..52e66eed 100644 --- a/anyplotlib/tests/test_plot1d/test_plotbar.py +++ b/anyplotlib/tests/test_plot1d/test_plotbar.py @@ -899,3 +899,97 @@ def test_keyword_y_only(self): p.set_ticks_visible(True, y=False) assert p._state["x_ticks_visible"] is True assert p._state["y_ticks_visible"] is False + + +# =========================================================================== +# M3: PlotBar constructor-only setters +# =========================================================================== + +class TestPlotBarNewSetters: + def test_set_bar_width(self): + p = _make_bar() + p.set_bar_width(0.5) + assert p._state["bar_width"] == pytest.approx(0.5) + + def test_set_align_center(self): + p = _make_bar() + p.set_align("center") + assert p._state["align"] == "center" + + def test_set_align_edge(self): + p = _make_bar() + p.set_align("edge") + assert p._state["align"] == "edge" + + def test_set_align_invalid(self): + p = _make_bar() + with pytest.raises(ValueError): + p.set_align("left") + + def test_set_orient_h(self): + p = _make_bar() + p.set_orient("h") + assert p._state["orient"] == "h" + + def test_set_orient_v(self): + p = _make_bar() + p.set_orient("v") + assert p._state["orient"] == "v" + + def test_set_orient_invalid(self): + p = _make_bar() + with pytest.raises(ValueError): + p.set_orient("diagonal") + + def test_set_group_labels(self): + p = _make_bar() + p.set_group_labels(["a", "b", "c"]) + assert p._state["group_labels"] == ["a", "b", "c"] + + +# =========================================================================== +# M1/M2: standardized parameter names +# =========================================================================== + +class TestPlotBarParameterNames: + def test_set_title_uses_label_param(self): + import inspect + p = _make_bar() + sig = inspect.signature(p.set_title) + assert "label" in sig.parameters + + def test_set_xlabel_uses_label_param(self): + import inspect + p = _make_bar() + sig = inspect.signature(p.set_xlabel) + assert "label" in sig.parameters + + def test_set_xlim_uses_xmin_xmax(self): + import inspect + p = _make_bar() + sig = inspect.signature(p.set_xlim) + params = list(sig.parameters) + assert params[0] == "xmin" + assert params[1] == "xmax" + + def test_set_title_works(self): + p = _make_bar() + p.set_title(label="My Bar Chart") + assert p._state["title"] == "My Bar Chart" + + +# =========================================================================== +# m2: configure_pointer_settled public on PlotBar +# =========================================================================== + +class TestPlotBarConfigurePointerSettled: + def test_public_method_exists(self): + p = _make_bar() + assert hasattr(p, "configure_pointer_settled") + assert callable(p.configure_pointer_settled) + + def test_sets_state(self): + p = _make_bar() + p.configure_pointer_settled(300, 6) + assert p._state["pointer_settled_ms"] == 300 + assert p._state["pointer_settled_delta"] == 6 diff --git a/anyplotlib/tests/test_plot2d/test_plot2d_api.py b/anyplotlib/tests/test_plot2d/test_plot2d_api.py index b66560e3..5276cf76 100644 --- a/anyplotlib/tests/test_plot2d/test_plot2d_api.py +++ b/anyplotlib/tests/test_plot2d/test_plot2d_api.py @@ -461,3 +461,46 @@ def test_repr_not_plot2d(self): fig, ax = apl.subplots(1, 1) p = ax.pcolormesh(np.ones((3, 5))) assert not repr(p).startswith("Plot2D(") + + +# =========================================================================== +# m2: configure_pointer_settled public on Plot2D +# =========================================================================== + +class TestPlot2DConfigurePointerSettled: + def test_public_method_exists(self): + fig, ax = apl.subplots(1, 1) + p = ax.imshow(np.zeros((8, 8)), units="px") + assert hasattr(p, "configure_pointer_settled") + assert callable(p.configure_pointer_settled) + + def test_sets_state(self): + fig, ax = apl.subplots(1, 1) + p = ax.imshow(np.zeros((8, 8)), units="px") + p.configure_pointer_settled(150, 3) + assert p._state["pointer_settled_ms"] == 150 + assert p._state["pointer_settled_delta"] == 3 + + +# =========================================================================== +# m3: set_title / set_xlabel / set_ylabel direct tests on Plot2D +# =========================================================================== + +class TestPlot2DDisplayMethods: + def test_set_title(self): + fig, ax = apl.subplots(1, 1) + p = ax.imshow(np.zeros((8, 8)), units="px") + p.set_title("My Image") + assert p._state["title"] == "My Image" + + def test_set_xlabel(self): + fig, ax = apl.subplots(1, 1) + p = ax.imshow(np.zeros((8, 8)), units="px") + p.set_xlabel("x (nm)") + assert p._state["x_label"] == "x (nm)" + + def test_set_ylabel(self): + fig, ax = apl.subplots(1, 1) + p = ax.imshow(np.zeros((8, 8)), units="px") + p.set_ylabel("y (nm)") + assert p._state["y_label"] == "y (nm)" diff --git a/anyplotlib/tests/test_plot3d/test_plot3d.py b/anyplotlib/tests/test_plot3d/test_plot3d.py index 4a351adf..5dcb8f4f 100644 --- a/anyplotlib/tests/test_plot3d/test_plot3d.py +++ b/anyplotlib/tests/test_plot3d/test_plot3d.py @@ -287,3 +287,75 @@ def test_repr_on_real_line(self): assert f"n_vertices={len(x)}" in r + + +# =========================================================================== +# C1: title initialized in _state +# =========================================================================== + +class TestPlot3DTitle: + def test_title_initialized_empty(self): + surf, *_ = _surface() + assert "title" in surf._state + assert surf._state["title"] == "" + + def test_set_title_label_param(self): + surf, *_ = _surface() + surf.set_title("My Plot") + assert surf._state["title"] == "My Plot" + + def test_set_title_in_wire(self): + surf, *_ = _surface() + surf.set_title("Wire Test") + assert surf.to_state_dict()["title"] == "Wire Test" + + +# =========================================================================== +# C2: axis_on / axis_off on Plot3D +# =========================================================================== + +class TestPlot3DAxisVisibility: + def test_axis_visible_initialized_true(self): + surf, *_ = _surface() + assert surf._state["axis_visible"] is True + + def test_set_axis_off(self): + surf, *_ = _surface() + surf.set_axis_off() + assert surf._state["axis_visible"] is False + + def test_set_axis_on_restores(self): + surf, *_ = _surface() + surf.set_axis_off() + surf.set_axis_on() + assert surf._state["axis_visible"] is True + + +# =========================================================================== +# m1: data-bounds getters on Plot3D +# =========================================================================== + +class TestPlot3DLimGetters: + def test_get_xlim(self): + surf, XX, YY, ZZ = _surface() + lo, hi = surf.get_xlim() + assert lo == pytest.approx(float(XX.min())) + assert hi == pytest.approx(float(XX.max())) + + def test_get_ylim(self): + surf, XX, YY, ZZ = _surface() + lo, hi = surf.get_ylim() + assert lo == pytest.approx(float(YY.min())) + assert hi == pytest.approx(float(YY.max())) + + def test_get_zlim(self): + surf, XX, YY, ZZ = _surface() + lo, hi = surf.get_zlim() + assert lo == pytest.approx(float(ZZ.min())) + assert hi == pytest.approx(float(ZZ.max())) + + def test_get_xlim_scatter(self): + sc, x, y, z = _scatter() + lo, hi = sc.get_xlim() + assert lo == pytest.approx(float(x.min())) + assert hi == pytest.approx(float(x.max())) From 5761bc136bc32701d4029687c7b4579f95b8d720 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 22 May 2026 20:18:26 -0500 Subject: [PATCH 169/198] Refactor: add png baselines --- anyplotlib/tests/baselines/imshow_axis_off.png | Bin 0 -> 4284 bytes anyplotlib/tests/baselines/imshow_labels.png | Bin 0 -> 26376 bytes anyplotlib/tests/baselines/plot1d_axis_off.png | Bin 0 -> 8604 bytes anyplotlib/tests/baselines/plot1d_title.png | Bin 0 -> 11606 bytes 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 anyplotlib/tests/baselines/imshow_axis_off.png create mode 100644 anyplotlib/tests/baselines/imshow_labels.png create mode 100644 anyplotlib/tests/baselines/plot1d_axis_off.png create mode 100644 anyplotlib/tests/baselines/plot1d_title.png diff --git a/anyplotlib/tests/baselines/imshow_axis_off.png b/anyplotlib/tests/baselines/imshow_axis_off.png new file mode 100644 index 0000000000000000000000000000000000000000..38ef84c0f7cf8af13eb574a5d267105b166a133a GIT binary patch literal 4284 zcmeHLTU!%X7oHi$I)Hu*h6(~DiB_mp8~7BARuT-Kvd-YPwJ!qfPNGcwdu`VaP&*1^mKPX zQP9ca(|2ydn&n^IeAV>a)m4u}zP`BV<%6s0YHMr1c`>98WT_w;Zz(JXjrisz{AKNM z>kGpJZIX8euWGUd=G{840i)GwUN?4BKiM9<@52KahGk@Aq@|?^g|i`av>?L2@M|+F z+Q->z=i+|q}X+0Awhe70~r_T==KNl@UVFtsMp@`A|P#%nO#?F3* zK%W8XEo&DrD);~~QMsb@pP3**>;tz?4C`>2eXlniMFN9g=Y0Rl2Vu`K)v3ZmZKc_t ztE#RO4N0z^wqVcXNXGesYY%)FqU?3i-q9-4?rMfe!2I~+)`;GN3sBAtzl-iK3UXH` zpqyGX>P{eoS_i@V7v7t`6zzb>y-PD(_ z75In37du)3;y^m@7cZ!|=kPpY$L5ek_RttnHjlq zAt9`gJ3VlsZcR%>UsG0^WrG-H9~tQ)L*7ZU44z5(v9ZzPlJU2;+)UGt=j+$7=KP?p zV)%D8TrA-$H69t5b+YHv5se4e-`yaZ=(xM`eEWPgW}6;1-nqb+wc2iswFnuO2lZ(> zoXf55Q{-jEYITUwT|4w}ztu$e!fkekDozTI^Ay;Y*=!881tgAg6T zrk@UG4(gkQB}LqsU=e$j<)ir?tr_+Sot8Y!NtVenl-6uT zx7@5g)iFy)?3C29l?KdiW&hU}jhS|`L?v|WxHgBxCdyPpOH2@S0&Y!FG*9V;RHk_- zUz+@&Tz{)X)}y6ptu2fD+6yVq&`%b|Z|9>YDoxdc;b*<;@m*FL!hchp>D} zMU@v5lQzT(lIt~NVG~DfacBUM=9`$Aom`K(|2j9rzUPDTeB7lYuCr#%SDw`H%#KX+ zKvicBd6t!E58rcl7cUNtAYI-fhfTEQk6wcHaN*gp3d9~*>4VcC8jM!rM9_%l?q4i+ z{gHDFP%pDY<+HE*f)YO2y`jxYt&l6ht33D#%L+`0a(7yj!4*PeCfX$NI2}f9<1w>S zRdPDwPj_S(4|tD>9yyA_qZ7NaDMEqR}w;z#U8*L>DHnw2&(duoB3%5U|LO4^k3-R(qrac5+;UVAf1Qg9} zD&tG=PKwi&@Q^>e9mR;D06w#pBAzl@nyExTXB3!dCbpYHV@vwB^4TI43`+05&{j5D+8;WB@6NA*F{#LXZv_IwS?8ySux)%aQJo&XEu#1?jxs zc+P+CU9)B#L;=tK_Ph5}ufZ@yX*_HS><14X;K{s|P+vgeEhqtDNTOZZ~dqU&Qm#6km7@SJi^(i8EHM~8F+z}x zx7agBv$#{Z!~BmVZ5PLXlgG)~(LF-Oi3}Ne;`#sU}l{3tVCZ zUS_77Q@N?Z=3qQcI(j$S#D2pf{B2>H{b0D)_J_cyy>~A%J?vY}K)QmIJk%z@-%Tnx zf5FV!47qRPE%?Rhax?J{bSuqal-hmo;b!|Ln+^Z%$@h8tAO2*hGJX8iMY-~Olf(OR z$zjZYXzu>B(pyN-Wpx>}b4th!<=C#4tvw*bOPZE zZec?ZH`d3d{Z)d%4$ppLMWcnU<=WIMl1*mEil=63M zO+KNLM=Rg!yuYh-?P0y!)W6%T8Dd{_KWxvg?IIAN>izKBkLJdp@2QLN*E14U4M($S z5S7iwG*CA1d!q;GJ(MCx!Ab_xHJj;&9$P+`)cr_$noBGjY2#sGM5@Q0_x-tdE&7Zf z75HQQw8nDdltGFq@BmXPeqLnAN!u2w5f1)LwTXbBcfPtCb?KeM^Zxcw z|I=oCmx3DBL4Bnso7uHLhldZZ;h*QZgLhzLwGEn4(mg7Z-kCVyt96_1E%ENbNX?7S!Snz=u2Ha-| zb?Isw(2_^RR`;F`8ZT#}We!0dJ)gW{61pm=pv$ZKSAb$)=|`Vb1BUlP8&TrENMxn$ zVo!4;dZzY=mkI~Qu(MwXitZmVm{gywQ{)K`n{+eOqk@_O-OP3y~t0U^JVKO*@b2&v~=V@H_vY z(dnnsnMizr?Pq6Dzpm)M2YC3NY{DVw)y8&bM;0#y5EPq_en6bN)ceeGxQ=z0PVgN3Q92@M2MC zH1^Z-PSF1_F6fdKa$0EXUsbr3j2upmD7# z_N;8q+VbF+Ky-?aGlpwjYvCOMyo|7Ze{&Y7{21R00lm&9@C@oKGY~Vz1C<5}pH)YCUVYEJLt=_t0EfEu`YP4@^Y`Zv z?5(RCx+aU3)LmUm4DLtEe)reW2py?Oq}Hp;0VS^((d#Xd`%iOtc4I>4&zm~FD++?F z%pM9@j4jV{%w%CH@=65rJo?W42>O!Twoc$F=+Cnz`p7pi?Uy*MJ~m!|QiFg&NmiOb z{;_qx;YLrF7@_ceS$9i?D+`R}1|6Z`{Pq=I^`nSlX+oN2gv=3*j1G;=>0=1ekyEw| zTm;r?v{utpN!K>lwf;;?YO$ldwS=V z@~)6tIdswZMjhk5rcaehXrx&ixA;Sj<>U>;$NMk13K&g76U}a%dS|(Q<226Y1+sBr zeW)MbbcbTBY>|%b(6W%bcpKq~p7Un*4Gk2VAN|Jt$L*WGF^sRW=V&OQ{+>D>p|V9v zX?o*8a6aH_)GGsy>~Au{_=NbjYO=)Ow^PU_;A^C?6!aM{6p8UB@p9jtt(6l#A71@d zIxVN~QMCzgZ}RHv{u^hFpN)$W#l6p*XoZWOSY_&?uJ+&!x%?caNnAbmM!{=&Y_xw6V z=L4iWl(qhDDh)823E-#AJVq#kf6vlC;%Sqg@_C|30eoqCwl)H3;#rTYlnIolE} z#Zks&&B?&vDlB5*k4K5%Yh&0yS?KkYI#Kj=<41YuBWT+Fp=J3ClScQsn7#{a<{6W| zERk&51l;Q*kf4~xEXS0u3f~Raaa+SHXi8 zQJ+=P$l%p!_<{M!INZx|(R1^CUnuWeSr)m+fqL>|X6Ur{Q*`9h~fV>}t&# zoviELrCfUc|td+s&)TY69Wujny|PRyZHE&@_P;Z|-nBMqsM~K3})p z9|Zb`@?Kw^Wk%<_`YKVikQ|z%Dbm)nsq{5f6_JjJibfuB!04#@t7)^7Z_- zs%HvUJ>a-B%WRVzk-`3sQ?SfKS2pDY=yUy6#V-*1U(IM++o(dm`=5dPFpp zbIpuztfj68!bp~+Z*&9TY!)NjiXWj)VWg1*O_~IkrNYv)8Kmb!S>GjbmIL`WUeWKz z93gSpPJ%Iov4#llsg7TEjyN8(`=)+q1x`WZUT+f?ds77#ehm>O2?Birct#fm&-g-W zmFbNoh!G5Iz;9VssIQWr6E~%jq1s(mR|9#__y7&lqC(}|=Ei%jN`C5LAL`jTIa?+d zeJGDe4VI17om7@a6GVLh0|(I82s=qz0JOQDky>y;1NI>YY1t-6ELc9SwP7=lMzr4V zL>j;0qIVBBd!Dau?6TumTkDi41gW#X9&;OKG2CHmbT#N5f@n7i8I}L(SLLH0z~y(j zJU(01;o9)eUh%|viACE-evnPnuG*^vDJoeN3w?NBfT^USRzTwHmqj ztfmYPC~fiP0w0}Z8%tcaSB*{{r0-yLF-bs0P?EVFvJIa z=qiRNha$z|t)_W<-}$&3fOMH0@)6l13TeP><~3-Z1V&8=@|C7L>CP%J<>?T5juVpF z+q@EA@Z|g`FV<>s4oo-wx@Zb%f{zjYTJoRC;PXT1_au%(WtNl}O;QgDPP4?JS$sb)ycI% zTDp%x)S756e$<*2R5K;=wWy=O=jYi=$Dd%fBWvSA_*P$R|ZLYX%?4Gr7pf zFsEV`t@aF6KoS6SGA;BOlN@qWzGb-8$m%~GmfWP+#md@@fufsE2 zxk8}sHm@|Gyd<+4OHwC+MOeM-5N^30irlt}y-%6Z#9Pfey{QoHfs)-%#!?*(TrqTy z+$`;b)-D%Kx244pL=p@yOR!?#*H{o(QOLA4y)ULo33n4+lYxM;*3ZVkU(-2~gz zCE1mM8Z z{BuIP1#Z6b--$1p!kxLi{Q-8cS>AY{S8HE)_ zOP^#N#uPCjD!I5H%T|l35pj@wQ_W)Z#ofwquV6nR1iemUA*#n>530b5NbSI4H-`^m zMbH(tOQJcz{{o8;U}~8IN7kPOSjyW8%Iv-_=>I|9C>Vi)VItlj`PeuJ`2pe#ppqM zp5a}6v+8HCzxNj1L5-A3In>S3zb< z&5cFpF&8t5s2^puWAIceJeo|;QGQhv;z9rZOA7SM*lQ739Z9Iw0n9*(r+uA{a;!l2 z?4AG{xIooUaQ7wd7|3qsomo5vFYL!D<$Fe!8Ot#-PxA)Y{TrPknz#lnM$4oUlN`f} z3L>I-@*I|1d=LQX_B4c>s?=+7DzFYP^mQ+h-^d!bg_}-OkX}*M`n^UFVp35xpHH8< zr-k0%O%<9j(fJfLJZ@2bpJ*o^kT%Y|!_UejE?S{aVmJMXnTz&1j;0b@GAP7#2_&OMTOyI@6z6)TW{>{70CH4bg!A>1A-L8d$EmLU2%kTty`BvSonQ1rn z!d{p%JrwN=Zc+-MQy(w{tGi z>m>Khr>^G!f|H*bwh2m1 zqD7%=ZvW2b8m@ta12{9$82zApSHjN^i6M7s^RasrwMbNn#6nXt)b3wB5Uob6F?=y%DWGZY_U{f)`T9;G2$GjV#0m@Q1 zC_w_+Cgt!A6)eDFpDO{{ES1lRFDO zX5I9_seRpuL7@v;5)Eg?W0zMizRE|$W6Xj}3|EeLm1+4%oKcI@!Zshl5U2ul7+6!d z=~*kVIMcnZevQv;GXd3Q^-Zx8-y2{DDX1MlOUM43#QQ7ehl1pnmwQH zU7#1|nI#s9Sag}IDylnz^0zGty;9)I9As)M`1Vy`g*Ji ztcrTozHp@4s8tizEgJ*@%6->tpP$A|Yi~>pJ9uwM)wf_h@ma0h$4NjCn43_a(m>En_O8ioN5)&`np>8!wT|OS<6?Ss;VXoFcnpqnY4m zq*{$a{1E%tcxliMeXV-{OC4^C-ADxS@O(vJDVG{zKL$6xcWX-9{IQq*605k#x4~_g zD7|Kmpp>Gygd~Z*QItz3op>~uZBLDbAjdNE_O&=|(2~`rHqRGL;v%w_mk|Y$`nScn z%WRD!4x``B?ZBsaU>(pNzkHhf3Mn zeh(pGy>Op5Nwn^`{5kxwPh-iF_Px`zI*bzJHs$Mj`uPz)avDD73`3hOek}RlN=hVY zqG#0e^INK0u?lMHN5d5QF#o}QlHtjpY9rP`Wjl2-<98$i{0Sd}UUKYUDayq%<-q=< zmjKsk>_-dq!?-h4HSP_Z&-Gad2t+qM7;_Y zF#|hhu5tq-mCs2>B>bzMaeSkKTHf?%m@H%a8AS>u82t0-gs)5!!~7E!E1XY)is|Do zKh=0ajffBBsbhHZrq9(!V?@NR?>&2;{jhS*V^|1s8H3?*csh+rG<}MQ(5)!Z39^4t zY+pC|_*73DK58Qqy0@7bZC~;&RLwZQ^px~bkIGaqr_ztGlbx_O#k*DS%7vs@F9PIAhu=C;|ZK+}8u}DxgOI3X!nW%Tdx}?4Ua5 z)o?FqeA5?anE41N?!R*$jkg@_#nv?;NKuO$uVM^l)trwya_Vb9p<|G*l>~Z*h#r6POnY(-AFMsVG+S0uMfX!hUdQ6}5(CUaG?jsFBs5^ZLI0Cr5g7|E z*N?Y@BRV2==Wd_*4a)DdZ8U4nGx4W#-xqH$m%Vx`mhtq+YE_JZBpx{-APO!e!O8Ju z<99x`Y#vi5EZ$$})B1tmU1_{MrNz1+#)hLNAsWo&sT^BI7%P0E2gVjHo_?7_jqQ}L zOsb&eDrx&D0Y#gL7yv9mPmF*%drEyzsHSfsQrtFM9;rv)t>mkVASY(9A-35Zk-6)r?iCY{s8HzgPWy!r56F#(wUKJSXMf?SKUG-#Hk!qw|MIF69AZq%f}Nd)x5-lUF~G3TdfU&A z9n3>tT&4Z;>%raNY9Xor;CYiAf&B_=q&B;?LW=Cx8)oqgR|!a#PMff}@;|p1%pl2G z9J|-~w!fuUg#JvDDO+7(4n>>m)FHsvSDd%NLwpOH9ljfElx2MSUXNJ2r;$F_ilQT&tX*>V=N~n(YLf_|p>Qf_E z?}Y|fR^nG?(2~-QvS%l5J`vN;EE#b%`~X?<&4e}gB1yl3Iyue*4DXbqFJTp9in#F1 zY=guYaYr9Qj*%vpUOg@q?*{$*D+jP;KzNarVe7q5&+@%2&U55wI=~#()0(YsK8>s zv71MC`ISRN#VWQDOhE^*q&Wb7Od z;i6~ed45hZZ?PB_X$CWB_dhlwG+8f7z7#QGYRStP<}Wmt8uY1I9C9f8!o-lw94CW$ zK+64tRL$fqJ3oW+$5`62sa$v1$15jri9@1apwhU2wVwVGrxXqU8v51lB|n`u~S5fJU4VHqm9YoGS^11Jp7; zSX;OW?6QOmv~pHswB%c7T*mCniMn%ZX>O>3z)QIK-YL}u!K-DYM7q{#xYPnR6Tp^h zw->nlZlKKgY%vS_=|bc|grH^pM7vuQ^!e&-0HyWZ9pxRpM;|U*sQB=~v1UeW_SP0H zxWv)yMw)R7Iko0ip#??VO<86ntFN5E?%3GGUa)aTW4Wo_lJ5)E<4OHBnZ&y7&d@Z@lpU8 z2c%mR6?#U-RW3hOr9LFlqs>Od^ATvpJIhH0wS)^VC*wGPORbWlrkw6pFe{Dj#d_}u zW?u=fIsql{L{v?38iXXsO9y%~!2G7K=$QozPa`23UBs6|=OZv?*6u=MbI<0R!TDb| zCQhHn*vw6zlgNI5V$0mXa)Ed{zP5&2?+G17#F$C?YM!5Wfca03UF(N_70PUhnR%C( z6t!e&8&`VqiVThCeClq8EMU8ZEmq+gEo&1Djh~9C%|Qj~NrUh#>qa9Q`e9TLI1Afv zzRN-I2VJECEQ9tF?QUvJt_m$VDNUJf#cQX2p1snT7VEx|IAGpCP6vOW6tlfu$ak>* zV0K`=(uBwXC+r@CR*!Obx>0kdqRI&9{G&I{b|BqX>G1Ml8v@i4`TmjwQ7-6qoq5mv zFZCS%S@X|1ApVHOraIysRCVF+-lq-M`=5HiDGAfoG{Jj4FdH}0k_P{s-XCf2ro0j^x_gW0Dunl)k?dEb`FhO(3Nn1uK-t$MCrdfn|M|VpMJ}ndBt81 zQCIcXF)KQbw8W+@!J77jsI^G{LX$1`HYF(8%gfl8Wm92dYz*`ctRTzbCTHvQ@(y4} z;BZmLO?29jv`iqDUeX6zpG~%`cJ4t1i$rFwA>T69(RiQn3!O>YLLFPAL;u*RwNpk!ID zPEVBJgAy)wj~2g&0{lqkdDE~v@Vrqgqu&JmU|!`tYEukgFP+N*Lk-}NYR%~5$hYN* z)Tl*(Qre93fMwcHve%8;9DvKfLAC;HZAwD7V zwK~cQW4SPJd^N*`ru0{kqGEBU+hEKGupBHGv6HC-X_?X4?8f18bbrH7 zpSjryPoKzSSWeziPJS7h8yWHJ(Wg`XByRb&y@G!M$?5&&sv9VD9I2|pe1%| zzD~53p}y0AgeTXkPlMK>rXo}$p*tlJMqcVn`pShBB}ft@O$JApyZjR597!IkgN z-rEYPIQZck)?mTC-Z=tm&t1B^plT;O`VQ9S?bMMPe0H7M8J*d*dam(FQg@29J4PFq z+|xO4P=sY^s`SyxFqLj|xa@h+yGpm0+kLM&cgw1j1D1v(`(d&HRKx_M{mryoDx-IE z@5%|{M1Jpt&6*V966cUEZYh$SPnWT9h$RY=Wu-)){IE=TPGgl3fN7NwS6q>Y)!k}b z#Pre8ZujnI@AK;&=5e9O3mb5=^?<4xyT}`D;Ov9|!z#sUJ{*`{QFNFj)4p zq}$rR3aGlqMgdrx(A9!qkZ3aIZI4X+my?;Wr*5v|0i^THWivb>k%$9uiIr~=H$**R zl-cq{sqCOQgm~UVo4_)Oj^oq>+-KVMQk+vm!vCF3Mz;BSIfR%S7I-Fjn#DiHJ5<1r zw@1hCR!2>jc@maq0e=2=EQ0uKK+kZcQb5u3@P{H;fc%#(9UsquGH;rfzp&Gp@`Lki zuxzTzT@K_ANWD`!es3WqAdEg_@S%tO-<|n+>wk&{suS(#`35`pxgc}R0Ec2P2DC9r z+j77LXoPdg&fa_vS_f3@2H<}@sr2v6H(_L9>|hbb0|DEh$R-TvYohu;-SNQ!8H_9p zwf34Pzysk^XDks&W;|;-nOy3+hFS;cg~cn;pSQHt+DBzZXX~3fOM zTsAXvyCFEO;4U997--UWJL2@-GBPGM*EH|1oBlMuT0si`(UNR_`{oy^n|A?&8=FBr z)dH28#{EYehkR=p;1Xn^FI-zFmui^JJ!)OQefRz)pw~*uIog4|la9{9JYHmE>)2KG z8#w0)zZ&!spGsqW`8yQ1T_`Z_eSMJ2LQu>=mWe< zfmFc_B`jPiZ~Zps31fX`6)SdnFp z+|ss8;_{;3V5?^q*)#^L)@jHoW71i<9CcCgOE%(<}Fukzn7H4#4pR`17TI=-@L z)G$nk5XDcI(NKazm28>5LUq^>cKBtEA8_!&ZvJJu%`u%XKbhd5|4MxMZt`F$JQQeo zZ4LkcsXd{J>{L6XrIW8oqF7S$Iwsm<~;Mb?St_3eOL5(-w|F#Sx z+45!m<9okc+jV6&7&PN%*G&&uiOb&nKB)eNQ5>fmG&an~K)(fTQ{V4S&Lnj?gr@6H zT`9dcOK%dEr#a1_&ZA~gmWobJ>o~%QXNi45&B-ybDw8PBOjT33eY_~P;+W28cu;cp zS@-w;w6=PsL1;n>i`1730&s{+@zXDWXTeK&L&9Vu=H1?W^unsSnZ#vWTyf5)ph7Q0tcbRQ5#H6b1BvHPj!t?f`c}`|4?STD zVZ|ZR2^fuDK^u>q&8bMfcbp+`ac9S3!7!aO6xiTk=gn{iP zNzbSIJh~&ICV<6d<CkPfkA;391r|<4kezxp`e10oqeYue&m#Nf#a8IwAKU@_ zU{*x+pkADigswau@U_htCk}?mj^`{_Pu~wH?h!oW+gl=a>4%+?s1HG>%zmoR%nptx zVxFLbm3FfAZpXCPux-+5N#t2)H&P_kmXQA7MUx_{uu<)K^b)XtQC2+mLko>f-C32L z>+>jjPP4((3Ym4YN_53JQfyA4w!qi$F0H=fB3!oi$&ufa)Q$s=bV|c8oY^yKQx7kK zItugbtG9>!@SI@?V9`sfUfwal(hUWnlYM1}#8 zj0t(Qk?Vy}N!zC*CSL9zem3p{&u15P-T)A>GzF*oWnwg!?#h;o?bG$pSEk_=kPP3ieE*ciklP$xj-t7M- zlGx5XyEM4SDvv!HP^}};c@k9!WV+ddz<#f<5PX4g@Nx$C61lh2QXgDN!?8to+O!N- zbJ9h7TX+fU{jOM|%;v>r9V%0xfyg#?(g`&;PW!PUg*-*`HOBbmtG43lS3U>;(t3H61SRS2L z-pOL9{cj}#u%O63v;r}K!PAl&d7KDv*AQe(G{DMXn}e7y*6Xm?gA(>V7-U+)H68 z?yoWbv$U299=wLCrSp_vHo95{d?a=HF|+eZB42&{XT5(&*+fL4e@gjM1!j5I)j2Nn zpJvi|A_wHI)A?{y1*T_81RELJb+vEL4lFmqImlY)Wv?{_p^?)M^DghO(~^pUQ3_?j zqQl5H@z@hP_UGSePo1#IgxTo>g$`M!e^m?hHf}8m#aU4_h51{y-%LKCTRHYWO6884CM})R1nETZ9pAy_D zDB4y%xXtO4u(BEeCgXbOu+8lD5pW{+;%*kqYW1R-;%Vy?X}$}>se1fwEOs8{`7Bfym(y6>s(t&9#V zoMq}u)c7IQwp8n{rY3XJgUE+E*_Apc!w!?OC2rqSo(8hU{%8QCEH<0cl9B6G;cX;scOv;qL62|eA`#^h>@m_6*I&+|*x(EfAaU6) z#et}j)r7|O(ObV9ZnqPr?I6-Kvrq3WZ+zz1Jc1vpe)lD^$*zzq zF__Y+y*2gJYYcgXp)Fl9DdbkK@6*Ay+pXnfdg46&GGUctvu2j?(&GMBJYWfdO;`Lakm}w#x`KRnar)tlz=sX4Mgb)YOdRM(tl*OR(}FN$ zxSq?hx@28I(B3fpFy$7=_^ZTba<@Bgfb!H|#Lm|b5Sb)Y9A!}LD)=c~#bZRc1My#1 z_B&vCgdl%HZOhORwy8rc5=MP2$-C43^Y0lg&59CGqM%ukVkSmm$*#D>RJ*dm)DTB! z8uBI7gfYT|?j;_r_-@;uQNg2bqHGzu5+Dh4@QrX@cU1*Nx{GZjZNJOfmX*^dTS`>@ zDMBNw`&M=|ae*|g**AMwA5F`RZ{Fb5TX75uCwy4ED?Q7o?=7DNx6{UiM#F zvTIZK+9aqcfMQAONZaMr|Az2)>l`s#8^DrkV`Q}KV>aUXFE!xi+a*+P@*BtlW$+@R z+s3r@scemU;8eJ1feuyvGb5#+7<@ob{Mk{;r+#f^R|EP^6w{Qc&#<2T3e-?^mkz6)PqVB}Fb+Ews5>G+4WB-!Ho$O06JdjkbAhsx+STY%fd~VkxC^7L=mA-Q{NsxA*mvHo*R; zcw^BanauN2r>_e=wtk#mmI}mBa2aD`|uHJ`J%&MV~ ze~a3s4G185A;_%UPcMmRnL<6W5$X1W%zSOWL$*N%${g60PWswF8w12&m@rL)3R-B{44wU7b>=|4+W7Z^cI?Uf5yBw7_~RrfQ>M zM;p!1$Vg_+bL?(u6Q(284LX;k109B(XI>oQ`N{TO0gHX$ANmP@J^+N^4`UO2t=Aez z08HqDsDP+6nbI~e7o!Qw&B*`qebMOH7E^V!J+C+`WeZ(M*D^%KL8c@d7WNRSCgD~1a#3Ls2X2has%WfVs8DJ^h9zv z4+}1;iaNwk$%p|BTtNV#tft#J{x`n|IEHvdcFt-PR04oV4vj|T{~EU`sBqZw&++~Z zr!iWR|Md?RioF1-DP8~{O*-`p=piK#tU&_y9}?JI!1e;o)=&cFl9aQ377NX|C1KTU zxX(y~@(@GwN8&{Pd<~mf9MM8YUYSg_m>zP%0%Di^&5VITID0tdw*;^z*s4>!s|H5~ zWy<67D22o{&Y;D7Z8!>bQ`3=& zs%|@!<#KZg7{yV>&XS!iKF<0FHZ}x1d@ezPriLesE*dwRzn$>paOw}suBt31r zB!1ieG-?E|w_&UHU~Bl}_HMK`_&O z-FwTe>7ed7#9G-_a}DdwwszLxW}-oPTSO!-y3T+<=;p@(2i&ils%D*&SHY`=J>1Hj zf6@=5Orax;ZyKzj1~YVPy!lC(Gdzjtbn6^G9~!FC`?RJ=>Cg(-4AXQ%4`%r=>?`O? zD4T^9ECxz)e@BA(pK;O`Cts3rX=N^p>ECYSI=z8TeJSD?Mu5#sZX5z~Om`mny21ix zYKCa-y?QOceP^#=oo3Q6jDu<`Mq!`Vy2I}5$NNl+Hm}FH;)LuK0~RQ0e`*c+TY6** zhWpO39iv~GHD7|JzrKA@aS>q1%>Tr}rhVaDZ4g(599lVup~52O9>J-RrGTdF;m75Tt86pGQPB2-9hIx=2a~A}kl>pBrEWOrI5%+T=Le z>)eG0O89SncY!z!<^u&uG+o+cmr^tVxd_l~cqviGBAxoh({ii|cX{~2!veew|BBHE zT@Jq{^Ow(~4eYu>(qDoS0>$u^0$sPW1D(+dH$_go<4tCP3l~EYc&dPZZZ`0Y1(-Pb zfNcp}Y^jn0l>U5<+JuMr01KJm=A*Js|MA#*0$EDW*M{p_L|EChhJ7N4A1yTTbM8q- z>71%X*M&m}($G}aj};HD&C^ugxFS$_NX^K`h=6Kc7*33m?*S;LY^>M32XllTTRl6S zQ#3L+g76U??wh)%AB2w;x{b;@lZsdb^2+GzUn&b4OYE(Yp7$g9bY8izZ9JZ_r*@AY%Ocyz zT_a%f5i#+k1xvqQ(V84~{Vj|5r^Bo4{q#Oh;^OZl^Mdpl=fSjm_$~k`L|e|{KTsnL zg^i+nhVL)E&m;PfMtA(X7L_)_;`s)#E4k7^o2>@6z?d~imvH$6r0acjiDLeeT*`hK z2Bpn22+(u*HBruLbPuzNRS#+wY0xnHrpo$?lfbPW;?mUqp(D~Af?4sSlHRPoUAn+x ztp!%G5$E2J6b5i(1CL6#{85CRGh#W{=_@Dvscje_ql}_l1wp`7xLAi{(=53u*lMqF z4C(e*NNNSOFstWf;tR2<{X)O3?(q@Yi_T{H^5jAPfq8b7XcPTnC(BIUH)ne>tASF> zvr^j6Qx7GS=Q2&|q#6d)BXp_6SSVQ@^di#I%5!6zoDEa^5@#QRLu{Y4*6%@AM( zo5Po|B8VTn(?$V7AOq5)vyw)uWZRiD;RUwTE@SuIIVp?bxvk+EOq>2gFH*fUoL5s1 ziv~+Hhd4aV>SKw8LY+-;`iYhfUq6d%(p}IluPC{>s!Mk>cf#zIHv7H1OgH;pKq0mv zq!^gFtEunoo|!<*wVg*`PyD@}6RsxE4n<6LksjA)reqtCts!|moa^i`iiO$Ye9K>x z$vUbKV<|_<2-m4PJgt=85?!Fi1`CyArl-IOgj7MsbM`}Qxl@x&z?`1k1TMVj1C~{x zJAUyF%{uV_T)N}itz zI5ehttNkO&uKSvvxgkF&Ls8e-ka)t~hCt7#io8yJj9hiL!{5-E<-YLTs|M3^RvKb& zmF`EKmL1Q_LJ(NA`K9Ed0IM?O5zE|M5=NDAL>A|`VNuqJOvJ9u2^uJ(7(%-Z*4;ZF!S($OF`g;X29AD`dD>)-5wOX{SN5)8O12K0lJJGsn^`z@ARCc#63av*Z3&p+{j zs| z?pMWtt!H;d%Z8GLrIR7}iF5i-6P(SlPTXU5}~eEek=z5evtdcb|<$b~XNh)tajS?}2JI0XY+ z=-5OASmu-ep!zsN>zj2XZh4I6+lR_et*2-=%fj%u?1Q$$8t$~iywkG-eghUEzY3AsM&wBQUm-@xnWsKQ0Y}xhZILq!W^;7fjvo|^@PiP?aFRt6CSD1Wp zyETv5UdtN-83cr?DjMd1iC1ewpN$A$6agj#>Q31V1mQ?+^MVY>P@-U%c=BI5!2BI) z%QzSckpRwXR!4j=#cN9o+rR!p*~jbz3>UxRmfsXqXoZ95sFovg`f3M6Wr7zz0io1y zG_wLoa*h^)2w~ad00Ll(1J_dVBBvj0o`dZx=}J_hc6v7iI0K7D!&-XZ6kzh}s=F($ zxNH(QvvwL~Mg6nJ!KFqnJ7hBDClxNT@%W$>@P)Hj97E|LqoYxQIvFn|>W?R?i>I60 z4zKEFjg4K-!s1wOm-L?^>xJ)H!IsO%4BEs z+*}#evqA&@uj{S={2>7>{K`ywwZNb~UB;cSYHUO#vB2fVD!?Yjb8uRcS6fIZZTd=B z4SY8^&{lz(sKK@y3}h|I1WAb0OLrm%#@&*y&$1E@8;4+#I!=`RJ+ag=s{-`QjnIe< zUD9o6q6rgC*6aYXouw@w{->Gwx?+ESNXxDC9}0T|dS~l*hCm!4hKM^~X=J;SPQ!O9 z1B~6nq)ivuT?!GJ&;C?_Ew5nhP8CRL?ci*t~KiXtcqr%TuzdI0}g2C7(;yhNwKzw=PeGE0EE zN~ubApk(Ygnn-T0@On)cY#Fu0aMUeYS`+VHBqhFTAQ4zs~%BpDIx8cU!Icb|sbwBS9N5`9j^W8m^eRkVf z!)brHwydhcc_E{x+-6F4Od}F^lvEz)lq|vH11y)|W7t_&gCx?j0x>A$f-x(J8XP7; zLH?fqY-Ar&C4&4gUF?tt@jw3^FgxTz?Id7!eT;pHQPvsJ41fc_iFt7;hlx76u`|w;WyphSBezQ**F{k49 z`=05Ki!=bjUNRkfynwm#PKg8aKM+Y^)+onPe=aUOkqE^T05eYtRI_%17n2x(Zg>pq z2l|0W4g&||)eWevE6p~*W@GEidF^`-)DfFMa1KlUmlTSdF9EeaH?cFAvpx@#1fb2X z*70m@w_4_neG~O6H(5B@jEGFifGT2?N5=drSf0^6!$SPWmHxvKS|jrt;_ESg!iZ|H zwGY3GTBoH|8j^DAm(6xh@l!dfvs9gP*->Y38gVRU?U-M@jXgWU(>PU&u`wVFw%S_H z%r+=m9YQ4=ewxv@sv+$fYSV`I=N+bJo&GYWQYSD>JbL?{SdQO3 zgLr{!Hp&QHsg@qTDE{;xEa=kc)aSx>c;r+E69SfY=%#Z1#ifH3|9GuV87vNf!8<+J z$FM4)r}6c-S-nY)Ei`yal|ZA({i6UBumG&Gho)=Odaab8;z#Qj&GO1}1>LGJJe~~# z+z~)kR5A7B^a;9*B47*z=;X0?v9F8x`3*Ls**je~v38LHDoppcQW&gwE=V@u6!WPz z%NwNK^-iG_)F=BM)y@(M3bj9ZgH`C^-rZf5ahV6HgPHN!A8r#679?wA1gF-?A)l78 zPL!CW1Aoh1x(#7|oi`M7NV=7}&88V`k`J4ShSs(eWUYzWe_zs5`zC#mtK*9<$jS83+uq8wXk!q$BXmt? ztzbT9$NrGQqSo_i;0sa}A9-@4ZMnrd;{631YEm~NG)YlR z!pMi=&*GHoo_>9Us)FoYG}3CF7Kjd9U`BFbf~=OAf)148m2zb3Swi=;#6! zK7;sH_0UCS#!bw#Ic|bd=0%HRW1G`<<_V1x)r8GqjeYvEg5l^l^k;&Rf!O?~Z`a zZZddU8c~$^u~eqG5F&&Lqwde><2Quu3m#C3$1<^|klnFDifXtha0Sh)a{yhBk{qa_ z{h$)}{dIj4Si>q${$46K{;nY-EakxV{YB>pXSuGOhW<{tXB8~@^A&N_7#2}DZ-oS* zAU!NwqjvAA4E3dNntoCrdhC?n;R^C$j?)x-oUxK=EgSf>@|T* ztSrjYU{Ly}Tzakt$OWx#(j)b#MW78I=p32(xXhxhIyEPb`)f)UE4XEo%CqlmbIhh04(N|V`gly4A6NrVbLFBJ;v0S`oCl|hF z`x}xq6LB+|>Guro-0v`Ir*SXuO66uuVh?QifUh2vlhDmtC>4Wk61gOQ|9O7jKwLrD(j- z3J5*}1W+3ZQcgGk97$6kbO83aUOd4HjowS#@xwp3V}#);VdxU@3;`|Nr$44sE238W zoGCW`HO-u8!y|1K6Q;7nx^RQJ^3g}apK3x46W|#?g5OeRq)1(JA=Fu@RujA~{0X2h z#w8DMeR|AFRU;e_=XYg_ZOFBwR76^CV%-bM78c`{AxtK>_@#cR+l@QZH56OD)FpJo zBqg@1!!A38zGu1q?mP+Incw53HdqmjfqyZ(deYzxEO=sZWHI>f8 z%33tMHjVDr=n2(R&9aQ0&!e;Q>MmdPs14NB#o8ax zV3?FzE)#mm%Mg5Jqd>dwU>tS9pK&sez$~jU1G|aIF==moNGAIJwt8mdG;Kw#?g2=oZ*EW zHSSfmjN{T##^;TAICMKNC5zy1I`tS-Jr_9r@@cQ-G?v(FH`x1jT`f7^-f3qo^70|s zb@cY^&_x_II1eWLiFXynC|wZf&4jsiw72JEndNrc%V=JSA64#^W}YkT}31h3oKFPk^n zG-EqiYO?!4q7iK*qWbEr9fAF_O z7ZnI^bqx3w`CC(lo-67dgPqkUcT!;CQ+b8yXpriUjE_i|2J4r8ti4AYR8W9P z<*$}+4IrT_mi*LWaA%iHd*mZA3I7LO7ee?)-4sOToGvW$4|kcjPLudOkICdj zZdop`7NfEkr5a#ow%whGNfaWI`pPhl_Re7Eb2UDbSIJaL*K?#ezJ1}BO?z-Dd8yRZ zXT-qjeY+w+zxbL}=uPv1l5anxyg*)p%ajN}xO@Mwy5Z^JY_~EReqk!FGy6bIq(W!a zmLYnSY0O7PE21rMav^gv@FK7y(`LE2=>;iPPy@Ry2zyJYSBJ7suJ!^Y19Gv)q(4El zSB}l83({~;Px`fc<(;hs~PxZ*kMW-4Oqd2b=cR4LytlSkE zkMenrVaErodhw#7p?CS z#fx|vNr=?>-dOP84?DS!R5t8tKouCh@@GRnn6YJZO0E2zXsEGYA`_UPd#lB)1iAD4 zNHkR(_qeusdd8}{{ia1g(oYHABx?mvD~D_ad51{yFF9YIpss7h+0_@y*$SpQe(atG z8{h8rPi~4QmtFnJFRq~AjJx5pCLqAiXT8I0^*ZXV`7TVThW!>W4BQb=X)XRY3ZRvS zun;gzwGw3X&q3F~yJh$(wEJJrIHzI?GjvfQ>mIRE99#NY!AJamWTLEguo*}~Zi9%p zcRY=bU|Jw^)$XD!3b+nH^({caaQIHgw^&1ZGwuv0Axr_oCPOl6XayVt(;~HNEJ@YR za(3{l{RXm+Oa@5m&rJ7XdM2GJU#)HwOui-f6)`SK%#M2oJ2UXikjpumU%9xO*7?0n z8r*+JnC!v!N-j=YFD!;B6q#J|uRSQ2jsS-h7FD z4z6#eXRErA9NcJABf*lnc26q|NY(5}yk~=cAwoDBVRj`K#D+d2y22AG(IwrA!&=|M zFY#}iO3IxEDYB-Rg_Bvcy7_2H#)FsH>~}`U>05*!Y*uzMlF_%f3)N=*kaC4mC=I{2 z&I|oS8HC+hgS$K<J<*5&Lp0FSdelVg}*!0pfKr?VD+0 zhaX3G6!$Hv>*>{dHxr5roSO*Zx{pt~8Q+r@F>7pcZ)+pN3=6`e8ez;WyfQ6+Yu8CF z&M*P+;9JddtwwMCj}0=)?q9dE1xU=m`|xLvFKUY@vSD%rFWA*`_bU|-9**9LDX1uP z&Y86nYDKswr>x{a>;CPjbem8$rQc^P#?SESJBx^B$E7g; z+^7d_BO#v(LfUCI#hq2=CV?p1ZD>RNxMxQ|%pD-af=UtHBQQYLj@vtb-FgG|Q~qk&f$g^ec=f z5;rU$-GQGt?t25sGA!hbYJ8&Q2r(x*=~R@|9@=$oHaQ(HCU48Co8x>ZHcO+BzSEpZ zZ69eE6j3?HLYog~uAw0`%hAwlGIe|kK&?xcf0}D47T891+m(sYH~<;=eUm%LFv>~j zZfcM028@{6l6iKBE}u_N^~vb1u~`xO2=r|VOuBpru(e}X7}~-$N|sm7N!-qOq>=YLKKyjg2flNwI=u z&dDwt5g^_1n(oa-OAQ_ks#dLChh_5pGvtOtN{EjYdmd4&90gpfSmoG>;HYRs--o3U zO+u%r{#L@*I`rB%<5AHxtMJCG={6K?mx>m*$PYsJgI-&ULq45u_tFfz;uwTZYVyIO zx!IcUSrIw%M!u;;Uu$GyVZ1OD$fwx_j5ct*aziH9-I(KzELeROK-GY<;rwKMc#IzR zE?_8a`SQ?wq#$8V_rHl{$!Fy&Xz`V9)e(wO0O=$_2E4PsOTDsksleE-)ley*{$)IM zsMWzG=V@#2Fr;EaMTg~k2y~tHTyHqQ4&zBRF=7pZrX=xkwNHQd9Aq+1W9|pY+bc!$ z-BrzdHR2$Vk_~e(epq#@KsZ9jLRKbbvm6=kZd?*}P+q2S_B+z1|9OOu^`kGk!Kq>U zSqW}VapWDGkFzGDn7Ygo3SGTIS4}zQqztSg*0X;uO7f4UFp_7=6Hb$+c<7(?h}Z{H z^NpAK0=w{rWIDc`He6)FkEro3zaYaF*LSu!@$sApY1vx3h%iI;EOO^U@FbLuUGg?r z0LI?p0-*dYrXY(v=Uz7kbiY8jJs`RKtZiAsd;Go4XJ{8_k0NiUD=FFU6TY|dd{bFF zN;A(aw2U;t&gWr(CE_M+u8sgd2T-{)^kj)i&H|cY^$;YROGz)l6JG?a*#B}-)5H;v zTt{a=JPPP^^WbovK2Y(AkODSa;l-QuHCNd4ghkNen{LsXI2Yhr+(3KP?WIC8Qe%@j z`E`_bxn2->a@Ae9zelx#q5B{E09O!P(alo!0pZ?U98Na(1%b@SKIhm$t=Y)7c*_d& zECfs_`oNb@&E-`JlVcwxI%f~)72VvPi+nlg?mc=kO&768?vVx(q`R4unqE=7A3apK zS4r*_b>A+&-8Qx;0Xaw6XT*(2cgEm-w@rUGb2Q!bjX3 zjj2#lEeSt&(;^{(oMC@v*=!Q3(k|~>=aK7WyPKLA<*a23!b+lMCPVhNsqtyc5+=z? zmK>z=lqpZg)_@vrDsq2}in!9M4y?~8AI>Hi#)xS{cRFw~bwK@|G98-y-Pqgo%k?CN zf*K@1nwTUHr9dCpa4K~SPvie6n**PB5`1(i1oUYjvJGHuwMBg~;3K#}X)6EDv%rH? zgWZ|())2tYU_$SVrm>v3F$ThcxweTya20H8n9!*Xgy5>dx2=lGi=lwsIFO~AJEe%~ zCjmPhzO(XT<4;YHLSFG1!Al1?Feb&?j>?^+lSZk^=|tzx-XqqBQr^Q5G4 zJBinZnVKw$&kjLWPQZbbB?t2ff`z{s9r$-XNZBVq z9f}za>c0Y-_I2LiH*jc1IE;_lQygddD#i4CIU9}!9=9#gxp3CGJDkKrS;!hcq;PPb z?5ZYBC;Yp(uBjHHf*0{wrazY{@*_4x2cUEenLcCsqcuYMIi|6h-r?L% zj@UuMXYC~23qHOdS$4ccjqdN8HW+A2QN3vNvk})?bLO5nh+8!%*&r%wIxPR4YkU*a z*hJ-U_;_Efboy^;ug^q{UQ$(qH)OO&1Ud*4I`~{4?{8YmHW1vc=a$GT``VcN)oYi| z;n-I7HEx~r;zx-*D387oNW0lvE|JNxX}%Vor@)$lK&}HFaA^uYVBC!BP(_ zg#MsvHP(&$_<&O6Z!;#G>ijPJ5QMlh2LtH))0C+dPJ!BXY_xxfc;~^W(2Yj(SP4w} zwcu_MLXr%+MQJE{94OP4={VP?F)Ef6v1P4HEt|=K5`{ntLH}nVJJE<<~yMF&`sbLw}3L?|@Lo(zPTsiCh#AW_{FPbZG z>j^2EPavi(rN8Z3>)NfEBiwa)o`p@nQyhFKP5sH}cXNzNRExhFR%;oo9xNDFeSw|~ zn~M-CGc9?eTO?w4P%<;PQ&J{c$hqj~ctQ5=8kb0rW<%6wj;CsmKi)SP*D1#;;_1;P zzjMOY3mb`Dx}ImNYIa&(0wf+Q5mi}QR5ny$g3kE|P-J)*0bxq(Y>W)a>-n^jDxt+> z3lX-@Kp!s4@Cd(|w} zT!S?A*WPaJx(CjvHRs)=lfMELrK2ffp;NpX5tW7&cY+RdkuklH-R`4&M zkl_eSSi@)*o6Tw5s;|mEQP1)97fqs(|JVLGga`9OZ4?Ha?`D!RVL~!+_H|58Mv~eH zwP@xjo`4JUWxKfhb;vhw@aa;;f{19ZV$TRMZ2H%yMg$EvJ{Vk#Co?LhEbaH?+VoSu zb9C$!ebv}*PQ~ftKoA=zCu{Au0blTP?!35J6|8%s(!=di^6{6HyRGzqVCVjwq)~K7r0eI8icU(?e984}jGD6TgkMJ_p)AL=RceItF zGDR1#Uu>G^HtIh}kQe>&sqwvc$1TkVnJDO^pw@(b*hsk<6Wq*n>!3@+ZN`x}{V`a( z^hHViG-fWPdM9V-USG{t%jri_LtBN1h0cmQ$m3qq4{HQG=umaO_-ipv^AQIKhff)O zU@@M%XLp|6=P)ITRiC2`Z3`G@aQ6I?o5gCleEN9WpvXP&bZX`UE>~0Kt1~;-N|1k6 z`D&BwzM=UQi;t2CF?AUC-pyS53PxyMpZgAX{)vChGGutgIcG}6&!3EDtk{CkQ(p5e z?H31If5+D%)1Dnq1w|6JIq!7k%5ay$ncEM(*^5aaUaU3`N}T#H%P{2xvMSVo6#YM} z3dj4wq!P&zmuVf8hcUrs{2c(H8lZC%OI8hyU_xa#{~^&A)AwVGea&;02m zyixrPl-8xJ^>Hd9*S~o&&>ID`EAwLQy^$I2czo0N2H7#+{A%lx_*OPbEd7qHU+saZ zA$Hw?hhAm{iS-Lp$25l!iQ$IE#SDM$bUyRu0on8$X!C7(nM^NN|9$ec=eW!tkGd|i z`h8VavR^TFdOybtuDJlw(3kJ6CjIJD{0iCu0mmmWTyrmPCW7B`xAgY}PJF+5KL~w& z)9`zCIr>ilyUB2XH8D*9Zlkxplq*XuHq=p-pGnc6OV8-X*)Y|JE3Y;hmNItFNOT6x zGe?dBT3kxbZdl_k!h}4Zk!0Pkf7L*+508+<^0jmwtjQizN4=i8;D+(_oYxF$4Lr1S z)qJL(zwv%RbnDYG1%3MJ3W10drTP+L5W0VsLdG_sQ(58=(1{HmrK^t|z=;H`1cV(d#T{B@KdR4(7Ob--G;Lw&qx)+!S_2=N=n5IpUg3Z<9@{FY^hr@r4 neyL2oT0kTnJkDHqfk%__muCC!V>CG7=E`F^71=Urv!MS03EH{+ literal 0 HcmV?d00001 diff --git a/anyplotlib/tests/baselines/plot1d_axis_off.png b/anyplotlib/tests/baselines/plot1d_axis_off.png new file mode 100644 index 0000000000000000000000000000000000000000..b053784cd6402d1b177ca5702a3220ccf76fc67b GIT binary patch literal 8604 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 literal 0 HcmV?d00001 From cbe4d2246d4b417ade563f5c9e96681714ff52b5 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 22 May 2026 21:13:11 -0500 Subject: [PATCH 170/198] Refactor: Improve docstring for vertices parameter in polygon function --- anyplotlib/widgets/_widgets2d.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/anyplotlib/widgets/_widgets2d.py b/anyplotlib/widgets/_widgets2d.py index 04537bfe..5a2456dc 100644 --- a/anyplotlib/widgets/_widgets2d.py +++ b/anyplotlib/widgets/_widgets2d.py @@ -100,8 +100,8 @@ class PolygonWidget(Widget): ---------- push_fn : Callable Update callback. - vertices : list of (x, y) tuples - Polygon vertices in pixel/data coordinates. + vertices : list of tuple + Polygon vertices ``[(x0, y0), (x1, y1), ...]`` in pixel/data coordinates. Must have at least 3 vertices. color : str, optional CSS colour for the polygon outline. Default ``"#00e5ff"``. From a738747cb96a0e32eba44fe087ff50ae92dcb673 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 22 May 2026 21:19:01 -0500 Subject: [PATCH 171/198] Refactor: Enhance subplot adjustment method to allow optional spacing parameters and improve error handling for axis scale settings --- anyplotlib/_utils.py | 2 +- anyplotlib/figure/_figure.py | 20 ++++++++++++-------- anyplotlib/figure_esm.js | 8 ++++---- anyplotlib/plot1d/_plot1d.py | 3 +++ anyplotlib/plot1d/_plotbar.py | 5 +++++ anyplotlib/plot3d/_plot3d.py | 6 +++--- 6 files changed, 28 insertions(+), 16 deletions(-) diff --git a/anyplotlib/_utils.py b/anyplotlib/_utils.py index 404cfaf5..c9996b4b 100644 --- a/anyplotlib/_utils.py +++ b/anyplotlib/_utils.py @@ -51,7 +51,7 @@ def _norm_linestyle(ls: str) -> str: if canonical is None: raise ValueError( f"Unknown linestyle {ls!r}. Expected one of: " - "'solid', 'dashed', 'dotted', 'dashdot', 'step-mid' " + "'solid', 'dashed', 'dotted', 'dashdot', 'step-mid' (alias: 'steps-mid') " "or shorthands '-', '--', ':', '-.'." ) return canonical diff --git a/anyplotlib/figure/_figure.py b/anyplotlib/figure/_figure.py index c9532420..0fe30df5 100644 --- a/anyplotlib/figure/_figure.py +++ b/anyplotlib/figure/_figure.py @@ -151,23 +151,27 @@ def set_help(self, text: str) -> None: """ self.help_text = self._resolve_help(text) - def subplots_adjust(self, hspace: float = 0.0, wspace: float = 0.0) -> None: + def subplots_adjust(self, hspace: float | None = None, + wspace: float | None = None) -> None: """Set the spacing between subplot panels. + Only the arguments that are explicitly provided are updated; omitting + an argument leaves the current value unchanged. + Parameters ---------- hspace : float, optional Fraction of the average row height to use as vertical gap between panels. ``0.1`` adds a gap of 10 % of the mean row height. - Default ``0.0`` (no gap). Before ``subplots_adjust`` is called, - figures use a 4 px browser default gap. + ``None`` (default) leaves the current hspace unchanged. wspace : float, optional Fraction of the average column width to use as horizontal gap. - Default ``0.0`` (no gap). Before ``subplots_adjust`` is called, - figures use a 4 px browser default gap. + ``None`` (default) leaves the current wspace unchanged. """ - self._hspace = float(hspace) - self._wspace = float(wspace) + if hspace is not None: + self._hspace = float(hspace) + if wspace is not None: + self._wspace = float(wspace) self._push_layout() # ── subplot creation ────────────────────────────────────────────────────── @@ -502,7 +506,7 @@ def close(self) -> None: if hasattr(plot, "callbacks"): plot.callbacks.fire(close_event) try: - self.layout = {"display": "none"} + self.layout.display = "none" except Exception: pass diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index b524da6d..8c3c1ec2 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -2468,8 +2468,8 @@ function render({ model, el }) { const [cx,cy]= tfm==='data' ? _offToCanvas(off) : _tc2d(off[0],off[1]!=null?off[1]:0); const wd=ms.widths[i]!=null?ms.widths[i]:(ms.widths[0]||10); const hd=ms.heights[i]!=null?ms.heights[i]:(ms.heights[0]||10); - const rw=Math.max(1,Math.abs(_xPx(off[0]+wd/2)-_xPx(off[0]-wd/2))/2); - const rh=Math.max(1,Math.abs(_yPx((off[1]||0)-hd/2)-_yPx((off[1]||0)+hd/2))/2); + const rw=Math.max(1, tfm==='data' ? Math.abs(_xPx(off[0]+wd/2)-_xPx(off[0]-wd/2))/2 : wd/2); + const rh=Math.max(1, tfm==='data' ? Math.abs(_yPx((off[1]||0)-hd/2)-_yPx((off[1]||0)+hd/2))/2 : hd/2); const ang=((ms.angles&&(ms.angles[i]!=null?ms.angles[i]:ms.angles[0])||0)*Math.PI)/180; mkCtx.beginPath();mkCtx.ellipse(cx,cy,rw,rh,ang,0,Math.PI*2); if(fch){mkCtx.save();mkCtx.globalAlpha=fa;mkCtx.fillStyle=fch;mkCtx.fill();mkCtx.restore();} @@ -2482,8 +2482,8 @@ function render({ model, el }) { const [cx,cy]= tfm==='data' ? _offToCanvas(off) : _tc2d(off[0],off[1]!=null?off[1]:0); const wd=ms.widths[i]!=null?ms.widths[i]:(ms.widths[0]||10); const hd=heights[i]!=null?heights[i]:(heights[0]||10); - const rw=Math.max(1,Math.abs(_xPx(off[0]+wd/2)-_xPx(off[0]-wd/2))); - const rh=Math.max(1,Math.abs(_yPx((off[1]||0)-hd/2)-_yPx((off[1]||0)+hd/2))); + const rw=Math.max(1, tfm==='data' ? Math.abs(_xPx(off[0]+wd/2)-_xPx(off[0]-wd/2)) : wd); + const rh=Math.max(1, tfm==='data' ? Math.abs(_yPx((off[1]||0)-hd/2)-_yPx((off[1]||0)+hd/2)) : hd); const ang=((ms.angles&&(ms.angles[i]!=null?ms.angles[i]:ms.angles[0])||0)*Math.PI)/180; mkCtx.save();mkCtx.translate(cx,cy);mkCtx.rotate(ang); if(fch){mkCtx.save();mkCtx.globalAlpha=fa;mkCtx.fillStyle=fch;mkCtx.fillRect(-rw/2,-rh/2,rw,rh);mkCtx.restore();} diff --git a/anyplotlib/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py index ef208e3a..2ef35c99 100644 --- a/anyplotlib/plot1d/_plot1d.py +++ b/anyplotlib/plot1d/_plot1d.py @@ -250,6 +250,9 @@ def __init__(self, data: np.ndarray, self._id: str = "" self._fig: object = None + if yscale not in ("linear", "log"): + raise ValueError("yscale must be 'linear' or 'log'") + data = np.asarray(data, dtype=float) if data.ndim != 1: raise ValueError(f"data must be 1-D, got {data.shape}") diff --git a/anyplotlib/plot1d/_plotbar.py b/anyplotlib/plot1d/_plotbar.py index 4e8f84f6..f4f87832 100644 --- a/anyplotlib/plot1d/_plotbar.py +++ b/anyplotlib/plot1d/_plotbar.py @@ -107,6 +107,11 @@ def __init__(self, x, height=None, width: float = 0.8, bottom: float = 0.0, *, self._id: str = "" self._fig: object = None + if align not in ("center", "edge"): + raise ValueError("align must be 'center' or 'edge'") + if orient not in ("v", "h"): + raise ValueError("orient must be 'v' or 'h'") + # ── legacy resolution ────────────────────────────────────────── if height is None: if values is not None: diff --git a/anyplotlib/plot3d/_plot3d.py b/anyplotlib/plot3d/_plot3d.py index 2df937e6..dc9d4b53 100644 --- a/anyplotlib/plot3d/_plot3d.py +++ b/anyplotlib/plot3d/_plot3d.py @@ -199,15 +199,15 @@ def set_title(self, label: str) -> None: self._push() def set_xlabel(self, label: str) -> None: - self._state["x_label"] = label + self._state["x_label"] = str(label) self._push() def set_ylabel(self, label: str) -> None: - self._state["y_label"] = label + self._state["y_label"] = str(label) self._push() def set_zlabel(self, label: str) -> None: - self._state["z_label"] = label + self._state["z_label"] = str(label) self._push() def get_xlim(self) -> tuple: From 68272b7b10d4b4751e6cccbea2ec0e6e29bbf796 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 22 May 2026 21:52:50 -0500 Subject: [PATCH 172/198] Refactor: Add Playwright tests for 2D title rendering and blitting behavior --- .../tests/test_interactive/test_blit_audit.py | 496 ++++++++ .../test_events_regression.py | 1078 +++++++++++++++++ .../tests/test_interactive/test_title.py | 157 +++ .../test_markers/test_marker_transforms.py | 224 ++++ 4 files changed, 1955 insertions(+) create mode 100644 anyplotlib/tests/test_interactive/test_blit_audit.py create mode 100644 anyplotlib/tests/test_interactive/test_events_regression.py create mode 100644 anyplotlib/tests/test_interactive/test_title.py create mode 100644 anyplotlib/tests/test_markers/test_marker_transforms.py diff --git a/anyplotlib/tests/test_interactive/test_blit_audit.py b/anyplotlib/tests/test_interactive/test_blit_audit.py new file mode 100644 index 00000000..5feb745f --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_blit_audit.py @@ -0,0 +1,496 @@ +""" +tests/test_interactive/test_blit_audit.py +========================================== + +Playwright tests that audit canvas redraws and verify blitting behaviour. + +What we are testing +-------------------- +1. **Blit cache correctness** — The ``blitCache`` in ``figure_esm.js`` keyed + on ``(b64, lutKey, w, h)`` must be a genuine cache: adding a marker must + NOT create a new ``OffscreenCanvas`` (GPU texture), while changes to the + LUT parameters (display_min/max, scale_mode, cmap) MUST create a new one. + +2. **No flash on marker add** — Since markers live on a separate + ``markersCanvas`` layer, adding a marker should only clear-and-redraw that + layer. The base ``plotCanvas`` texture must be preserved (blitted, not + rebuilt). + +3. **Draw-call auditing** — Each ``model.set(panel__json, ...)`` call + triggers exactly one ``draw2d`` invocation. We count draw calls via an + injected Proxy on ``window._aplTiming`` that increments + ``window._aplDrawCount[id]`` on every timing assignment. + +Instrumentation strategy +------------------------- +Two counters are injected via ``page.add_init_script()`` before any page JS: + +**OffscreenCanvas counter** — wraps the global class: + + window._aplBitmapRebuildCount = 0 + class _TrackedOffscreen extends OffscreenCanvas { + constructor(w, h) { super(w, h); window._aplBitmapRebuildCount++; } + } + globalThis.OffscreenCanvas = _TrackedOffscreen; + +After the initial render this counter equals 1. Each blit-cache miss bumps +it by 1; a cache hit leaves it unchanged. + +**Draw-call counter** — intercepts ``_aplTiming[id]`` property assignments: + + window._aplTiming = new Proxy({}, { + set(target, key, value) { + window._aplDrawCount[key]++; + ... + } + }); + +``_recordFrame`` in ``figure_esm.js`` sets ``window._aplTiming[id]`` every +draw when ``n >= 2`` (rolling buffer has at least 2 entries). The very first +draw (n=1) is not counted, so ``_aplDrawCount[id] = total_draws - 1``. +Delta tests are used throughout to avoid dependence on this off-by-one. +""" +from __future__ import annotations + +import pathlib +import tempfile + +import numpy as np +import pytest + +import anyplotlib as apl + +# --------------------------------------------------------------------------- +# Init script: injects both counters before page JS runs +# --------------------------------------------------------------------------- + +_INSTRUMENTATION_SCRIPT = """ +(function () { + // ── OffscreenCanvas rebuild counter ─────────────────────────────────────── + window._aplBitmapRebuildCount = 0; + const _OrigOffscreen = globalThis.OffscreenCanvas; + class _TrackedOffscreen extends _OrigOffscreen { + constructor(w, h) { + super(w, h); + window._aplBitmapRebuildCount++; + } + } + globalThis.OffscreenCanvas = _TrackedOffscreen; + + // ── Draw-call counter via _aplTiming Proxy ──────────────────────────────── + // _recordFrame() in figure_esm.js does: + // if (!window._aplTiming) window._aplTiming = {}; // skipped: proxy is truthy + // window._aplTiming[p.id] = { count: n, ... }; // triggers our setter + // This fires on every draw after the rolling buffer reaches n >= 2. + window._aplDrawCount = {}; + window._aplTiming = new Proxy({}, { + set: function(target, key, value) { + if (typeof key === 'string') { + window._aplDrawCount[key] = (window._aplDrawCount[key] || 0) + 1; + } + return Reflect.set(target, key, value); + } + }); +})(); +""" + + +# --------------------------------------------------------------------------- +# blit_page fixture +# --------------------------------------------------------------------------- + +@pytest.fixture +def blit_page(_pw_browser): + """Like ``bench_page`` but injects rebuild + draw-call counters. + + Uses ``page.add_init_script()`` to wrap ``OffscreenCanvas`` and + ``window._aplTiming`` *before* the page's ``render()`` function runs. + + Usage:: + + def test_something(blit_page): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((32, 32))) + page = blit_page(fig) + assert _get_rebuild_count(page) == 1 + """ + from anyplotlib.tests.conftest import _build_interact_html + + _pages: list = [] + _paths: list = [] + + def _open(widget): + html = _build_interact_html(widget) + 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) + # Inject counters BEFORE navigation so they wrap globals at startup. + page.add_init_script(_INSTRUMENTATION_SCRIPT) + page.goto(tmp.as_uri()) + page.wait_for_function("() => window._aplReady === true", timeout=30_000) + page.evaluate( + "() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))" + ) + return page + + yield _open + + for page in _pages: + try: + page.close() + except Exception: + pass + for path in _paths: + path.unlink(missing_ok=True) + + +# --------------------------------------------------------------------------- +# JS helpers +# --------------------------------------------------------------------------- + +def _get_rebuild_count(page) -> int: + """Number of OffscreenCanvas instances created (= bitmap rebuilds).""" + return page.evaluate("() => window._aplBitmapRebuildCount") + + +def _get_draw_count(page, panel_id: str) -> int: + """Monotonic draw-call count for *panel_id* via the _aplTiming proxy. + + Returns 0 after the initial render (n=1, proxy not yet set) and + increments by 1 for each subsequent draw. Delta comparisons are + therefore reliable: draw_after - draw_before == draws_triggered. + """ + return page.evaluate( + "([id]) => (window._aplDrawCount && window._aplDrawCount[id] || 0)", + [panel_id], + ) + + +def _set_panel_state(page, panel_id: str, update: dict) -> None: + """Merge *update* into the panel state and push to the model synchronously.""" + page.evaluate( + """([id, patch]) => { + const key = 'panel_' + id + '_json'; + const st = JSON.parse(window._aplModel.get(key)); + Object.assign(st, patch); + window._aplModel.set(key, JSON.stringify(st)); + }""", + [panel_id, update], + ) + + +def _add_circle_markers(page, panel_id: str, offsets=None) -> None: + """Append a circle marker group to the panel state (no image data change).""" + if offsets is None: + offsets = [[16, 16]] + page.evaluate( + """([id, offsets]) => { + const key = 'panel_' + id + '_json'; + const st = JSON.parse(window._aplModel.get(key)); + const existing = st.markers || []; + existing.push({ + type: 'circles', + offsets: offsets, + sizes: [5], + color: '#ff0000', + }); + st.markers = existing; + window._aplModel.set(key, JSON.stringify(st)); + }""", + [panel_id, offsets], + ) + + +def _wait_raf(page) -> None: + """Wait two rAF ticks so canvas compositing catches up.""" + page.evaluate( + "() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))" + ) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Blit cache correctness +# ══════════════════════════════════════════════════════════════════════════════ + +class TestBlitCacheCorrectness: + """The blit cache key (b64 string + LUT params) must be honoured.""" + + def _make_page(self, blit_page): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + page = blit_page(fig) + return page, plot + + def test_initial_render_creates_one_bitmap(self, blit_page): + """After initial render exactly one OffscreenCanvas has been created.""" + page, plot = self._make_page(blit_page) + count = _get_rebuild_count(page) + assert count == 1, ( + f"Expected 1 OffscreenCanvas after initial render, got {count}" + ) + + def test_adding_marker_does_not_rebuild_bitmap(self, blit_page): + """Adding a marker uses the cached bitmap — no new OffscreenCanvas. + + This is the core 'no flash' assertion: markers live on a separate + canvas layer, so the base image texture must not be invalidated. + """ + page, plot = self._make_page(blit_page) + count_before = _get_rebuild_count(page) + + _add_circle_markers(page, plot._id) + _wait_raf(page) + + count_after = _get_rebuild_count(page) + assert count_after == count_before, ( + f"Adding a marker must NOT create a new OffscreenCanvas " + f"(before={count_before}, after={count_after})" + ) + + def test_adding_multiple_markers_does_not_rebuild_bitmap(self, blit_page): + """Adding N markers sequentially causes 0 extra OffscreenCanvas creations.""" + page, plot = self._make_page(blit_page) + count_before = _get_rebuild_count(page) + + for i in range(5): + _add_circle_markers(page, plot._id, offsets=[[i * 5, i * 5]]) + _wait_raf(page) + + count_after = _get_rebuild_count(page) + assert count_after == count_before, ( + f"Adding 5 markers must not rebuild the bitmap " + f"(before={count_before}, after={count_after})" + ) + + def test_lut_change_invalidates_cache(self, blit_page): + """Changing display_min (LUT key) creates exactly one new OffscreenCanvas.""" + page, plot = self._make_page(blit_page) + count_before = _get_rebuild_count(page) + + _set_panel_state(page, plot._id, {"display_min": -0.5}) + _wait_raf(page) + + count_after = _get_rebuild_count(page) + assert count_after == count_before + 1, ( + f"Changing display_min must trigger one bitmap rebuild " + f"(before={count_before}, after={count_after})" + ) + + def test_lut_change_then_marker_add_reuses_new_bitmap(self, blit_page): + """After a LUT rebuild, subsequent marker adds still hit the cache.""" + page, plot = self._make_page(blit_page) + + # Invalidate cache with LUT change + _set_panel_state(page, plot._id, {"display_min": -0.5}) + count_after_lut = _get_rebuild_count(page) + + # Marker add must reuse the updated bitmap + _add_circle_markers(page, plot._id) + _wait_raf(page) + + count_after_marker = _get_rebuild_count(page) + assert count_after_marker == count_after_lut, ( + "After LUT rebuild, marker add must still use the cached bitmap. " + f"(after_lut={count_after_lut}, after_marker={count_after_marker})" + ) + + def test_display_max_change_invalidates_cache(self, blit_page): + """Changing display_max also invalidates the blit cache.""" + page, plot = self._make_page(blit_page) + count_before = _get_rebuild_count(page) + + _set_panel_state(page, plot._id, {"display_max": 2.0}) + _wait_raf(page) + + count_after = _get_rebuild_count(page) + assert count_after > count_before, ( + "Changing display_max must trigger a bitmap rebuild" + ) + + def test_pan_does_not_rebuild_bitmap(self, blit_page): + """Changing center_x/y (pan) does not rebuild the bitmap.""" + page, plot = self._make_page(blit_page) + count_before = _get_rebuild_count(page) + + _set_panel_state(page, plot._id, {"center_x": 0.6, "center_y": 0.4}) + _wait_raf(page) + + count_after = _get_rebuild_count(page) + assert count_after == count_before, ( + f"Pan (center_x/y change) must not rebuild the bitmap " + f"(before={count_before}, after={count_after})" + ) + + def test_zoom_does_not_rebuild_bitmap(self, blit_page): + """Changing zoom does not rebuild the bitmap.""" + page, plot = self._make_page(blit_page) + count_before = _get_rebuild_count(page) + + _set_panel_state(page, plot._id, {"zoom": 2.0}) + _wait_raf(page) + + count_after = _get_rebuild_count(page) + assert count_after == count_before, ( + f"Zoom change must not rebuild the bitmap " + f"(before={count_before}, after={count_after})" + ) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Draw-call count audit +# ══════════════════════════════════════════════════════════════════════════════ + +class TestDrawCallAudit: + """Each state mutation must trigger exactly one draw2d call. + + Draw counts use _aplDrawCount which increments on every _aplTiming[id] + assignment (after n≥2 frames). The very first draw (n=1) is not counted, + so deltas are used: draw_after - draw_before == draws_triggered_by_action. + """ + + def _make_page(self, blit_page): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + page = blit_page(fig) + return page, plot + + def test_draw_count_baseline_after_initial_render(self, blit_page): + """After initial render only, draw count = 0 (1 draw occurred, n=1 < threshold).""" + page, plot = self._make_page(blit_page) + count = _get_draw_count(page, plot._id) + assert count == 0, ( + f"After initial render, draw count must be 0 (n=1 not yet counted). " + f"Got {count} — indicates unexpected extra draws during setup." + ) + + def test_marker_add_triggers_exactly_one_draw(self, blit_page): + """Adding a single marker triggers exactly one additional draw2d call.""" + page, plot = self._make_page(blit_page) + draw_before = _get_draw_count(page, plot._id) + + _add_circle_markers(page, plot._id) + + draw_after = _get_draw_count(page, plot._id) + assert draw_after == draw_before + 1, ( + f"Adding a marker must trigger exactly 1 draw " + f"(before={draw_before}, after={draw_after}, delta={draw_after - draw_before})" + ) + + def test_n_marker_adds_trigger_n_draws(self, blit_page): + """Adding N markers sequentially triggers exactly N draw2d calls.""" + page, plot = self._make_page(blit_page) + draw_before = _get_draw_count(page, plot._id) + + n = 5 + for i in range(n): + _add_circle_markers(page, plot._id, offsets=[[i * 4, i * 4]]) + + draw_after = _get_draw_count(page, plot._id) + assert draw_after == draw_before + n, ( + f"Adding {n} markers must trigger exactly {n} draws " + f"(before={draw_before}, after={draw_after}, delta={draw_after - draw_before})" + ) + + def test_lut_change_triggers_exactly_one_draw(self, blit_page): + """A LUT parameter change triggers exactly one draw2d call.""" + page, plot = self._make_page(blit_page) + draw_before = _get_draw_count(page, plot._id) + + _set_panel_state(page, plot._id, {"display_min": -0.5}) + + draw_after = _get_draw_count(page, plot._id) + assert draw_after == draw_before + 1, ( + f"LUT change must trigger exactly 1 draw " + f"(before={draw_before}, after={draw_after})" + ) + + def test_pan_triggers_exactly_one_draw(self, blit_page): + """A Python-side pan update triggers exactly one draw2d call.""" + page, plot = self._make_page(blit_page) + draw_before = _get_draw_count(page, plot._id) + + _set_panel_state(page, plot._id, {"center_x": 0.6}) + + draw_after = _get_draw_count(page, plot._id) + assert draw_after == draw_before + 1, ( + "Python-side pan update must trigger exactly 1 draw " + f"(before={draw_before}, after={draw_after})" + ) + + +# ══════════════════════════════════════════════════════════════════════════════ +# No-flash integration test +# ══════════════════════════════════════════════════════════════════════════════ + +class TestNoFlashOnMarkerAdd: + """End-to-end: adding a marker must not flash (no bitmap rebuild + 1 draw).""" + + def test_no_flash_single_marker(self, blit_page): + """Single marker add: one extra draw, zero extra bitmap rebuilds.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow( + np.random.default_rng(0).standard_normal((64, 64)).astype(np.float32) + ) + page = blit_page(fig) + + rebuild_before = _get_rebuild_count(page) + draw_before = _get_draw_count(page, plot._id) + + _add_circle_markers(page, plot._id, offsets=[[32, 32]]) + _wait_raf(page) + + rebuild_after = _get_rebuild_count(page) + draw_after = _get_draw_count(page, plot._id) + + assert rebuild_after == rebuild_before, ( + "Adding a marker must not rebuild the GPU bitmap (would cause a flash). " + f"OffscreenCanvas count: {rebuild_before} → {rebuild_after}" + ) + assert draw_after == draw_before + 1, ( + f"Expected exactly 1 new draw call, got {draw_after - draw_before}" + ) + + def test_no_flash_multiple_markers_on_real_image(self, blit_page): + """Multiple marker adds on a real image: zero bitmap rebuilds throughout.""" + rng = np.random.default_rng(42) + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(rng.standard_normal((128, 128)).astype(np.float32)) + page = blit_page(fig) + + rebuild_before = _get_rebuild_count(page) + + for i in range(4): + _add_circle_markers( + page, plot._id, + offsets=[[int(rng.integers(10, 118)), int(rng.integers(10, 118))]] + ) + _wait_raf(page) + + rebuild_after = _get_rebuild_count(page) + assert rebuild_after == rebuild_before, ( + "4 sequential marker adds must not rebuild the bitmap. " + f"OffscreenCanvas count: {rebuild_before} → {rebuild_after}" + ) + + def test_flash_does_occur_on_lut_change(self, blit_page): + """Sanity: changing LUT params DOES create a new OffscreenCanvas.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + page = blit_page(fig) + + rebuild_before = _get_rebuild_count(page) + + _set_panel_state(page, plot._id, {"display_min": -1.0, "display_max": 1.0}) + _wait_raf(page) + + rebuild_after = _get_rebuild_count(page) + assert rebuild_after > rebuild_before, ( + "LUT change must create a new OffscreenCanvas (confirms counter works). " + f"OffscreenCanvas count: {rebuild_before} → {rebuild_after}" + ) diff --git a/anyplotlib/tests/test_interactive/test_events_regression.py b/anyplotlib/tests/test_interactive/test_events_regression.py new file mode 100644 index 00000000..fdac7e88 --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_events_regression.py @@ -0,0 +1,1078 @@ +""" +tests/test_interactive/test_events_regression.py +================================================= + +Regression tests for event isolation in figure_esm.js. + +Core invariants verified here +------------------------------ +1. double_click fires on dblclick and is NOT consumed/suppressed by the + pan/drag machinery or the single-click candidate logic. +2. A true drag (significant movement) does NOT emit pointer_down; it emits + pointer_up instead. +3. A short single click emits exactly one pointer_down (no spurious extras). +4. Right-click (button=2) does not trigger the left-click event path. +5. The wheel event fires independently of click/drag state. +6. Event ordering on a double-click: pointer_down ×2 → double_click. +7. A drag followed immediately by a double-click: double_click still fires. + +Coordinate system (mirrors figure_esm.js constants) +---------------------------------------------------- + PAD_L=58 PAD_R=12 PAD_T=12 PAD_B=42 GRID_PAD=8 + For a 400×300 fig: plot area = {x:66, y:20, w:330, h:246} + (page coords = canvas coords + GRID_PAD) +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.tests.test_interactive._event_test_utils import ( + _collect_events, + _get_events, + _plot_center_page, + GRID_PAD, +) + + +def _clear_events(page) -> None: + """Clear the accumulated event list without re-wrapping the model setter.""" + page.evaluate("() => { window._aplAllEvents = []; }") + +FIG_W, FIG_H = 400, 300 + +# Large enough move to clear the 4 px² drag threshold (>4 px in one direction). +DRAG_DISTANCE = 40 + + +# ── page factories ───────────────────────────────────────────────────────────── + +def _make_2d_page(interact_page): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((32, 32))) + page = interact_page(fig) + _collect_events(page) + return page, plot + + +def _make_1d_page(interact_page): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.plot(np.sin(np.linspace(0, 2 * np.pi, 128))) + page = interact_page(fig) + _collect_events(page) + return page, plot + + +def _make_3d_page(interact_page): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + x = np.linspace(-1, 1, 8) + X, Y = np.meshgrid(x, x) + Z = X ** 2 + Y ** 2 + plot = ax.plot_surface(X, Y, Z) + page = interact_page(fig) + _collect_events(page) + return page, plot + + +# ══════════════════════════════════════════════════════════════════════════════ +# Double-click isolation +# ══════════════════════════════════════════════════════════════════════════════ + +class TestDoubleClickIsolation: + """double_click must fire even when the pan/drag machinery is active.""" + + # ── Click-cascade prerequisites (expose the e.preventDefault() bug) ─────── + # + # Playwright's page.mouse.dblclick() injects dblclick via CDP (clickCount=2), + # bypassing the browser's click → dblclick cascade entirely. To detect the + # real regression we must verify the prerequisite: that `click` fires after + # mousedown + mouseup. Chrome suppresses `click` when mousedown calls + # e.preventDefault(), which breaks every real user double-click. + + def test_click_fires_after_mousedown_2d(self, interact_page): + """click fires after mousedown+mouseup on the 2D canvas (dblclick prerequisite). + + Chrome spec: mousedown.preventDefault() suppresses the subsequent click. + Without click, the browser's dblclick cascade breaks for real users. + This test directly verifies the precondition: no e.preventDefault() in + the 2D pan mousedown must allow click to propagate. + """ + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.evaluate("""() => { + window._aplClickCount = 0; + document.addEventListener('click', () => window._aplClickCount++, true); + }""") + + page.mouse.move(px, py) + page.mouse.down() + page.mouse.up() + page.wait_for_timeout(50) + + click_count = page.evaluate("() => window._aplClickCount") + assert click_count >= 1, ( + "click must fire after mousedown+mouseup on the 2D canvas. " + "e.preventDefault() on mousedown suppresses click → breaks dblclick " + "for real users. Fix: remove preventDefault from the 2D pan mousedown." + ) + + def test_click_fires_after_mousedown_1d(self, interact_page): + """click fires after mousedown+mouseup on the 1D canvas (dblclick prerequisite).""" + page, _ = _make_1d_page(interact_page) + px, py = _plot_center_page() + + page.evaluate("""() => { + window._aplClickCount = 0; + document.addEventListener('click', () => window._aplClickCount++, true); + }""") + + page.mouse.move(px, py) + page.mouse.down() + page.mouse.up() + page.wait_for_timeout(50) + + click_count = page.evaluate("() => window._aplClickCount") + assert click_count >= 1, ( + "click must fire after mousedown+mouseup on the 1D canvas. " + "e.preventDefault() on mousedown suppresses click → breaks dblclick." + ) + + def test_click_fires_after_mousedown_3d(self, interact_page): + """click fires after mousedown+mouseup on the 3D canvas (dblclick prerequisite).""" + page, _ = _make_3d_page(interact_page) + cx = FIG_W // 2 + GRID_PAD + cy = FIG_H // 2 + GRID_PAD + + page.evaluate("""() => { + window._aplClickCount = 0; + document.addEventListener('click', () => window._aplClickCount++, true); + }""") + + page.mouse.move(cx, cy) + page.mouse.down() + page.mouse.up() + page.wait_for_timeout(50) + + click_count = page.evaluate("() => window._aplClickCount") + assert click_count >= 1, ( + "click must fire after mousedown+mouseup on the 3D canvas. " + "e.preventDefault() on mousedown suppresses click → breaks dblclick." + ) + + # ── Synthetic dblclick tests (page.mouse.dblclick uses CDP clickCount=2) ── + + def test_dblclick_fires_on_2d_panel(self, interact_page): + """double_click is emitted when the user double-clicks a 2D panel.""" + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.dblclick(px, py) + page.wait_for_timeout(150) + + events = _get_events(page, "double_click") + assert len(events) >= 1, "double_click must fire on dblclick" + assert events[0].get("button") == 0, "double_click button should be 0" + + def test_dblclick_fires_on_1d_panel(self, interact_page): + """double_click is emitted when the user double-clicks a 1D panel.""" + page, _ = _make_1d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.dblclick(px, py) + page.wait_for_timeout(150) + + events = _get_events(page, "double_click") + assert len(events) >= 1, "double_click must fire on 1D dblclick" + + def test_dblclick_fires_on_3d_panel(self, interact_page): + """double_click is emitted when the user double-clicks a 3D panel.""" + page, _ = _make_3d_page(interact_page) + cx = FIG_W // 2 + GRID_PAD + cy = FIG_H // 2 + GRID_PAD + + page.mouse.dblclick(cx, cy) + page.wait_for_timeout(150) + + events = _get_events(page, "double_click") + assert len(events) >= 1, "double_click must fire on 3D dblclick" + + def test_dblclick_fires_after_preceding_drag(self, interact_page): + """double_click still fires after a preceding drag sequence. + + This guards the regression where the isPanning flag or the drag + document-level listener could prevent subsequent dblclick events. + """ + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + # Perform a drag first + page.mouse.move(px, py) + page.mouse.down() + page.mouse.move(px + DRAG_DISTANCE, py, steps=8) + page.mouse.up() + page.wait_for_timeout(100) + + # Now double-click: double_click must still fire + _clear_events(page) + page.mouse.dblclick(px, py) + page.wait_for_timeout(150) + + events = _get_events(page, "double_click") + assert len(events) >= 1, ( + "double_click must fire after a preceding drag — " + "isPanning flag must not suppress dblclick" + ) + + def test_dblclick_has_correct_coordinates(self, interact_page): + """double_click payload carries plausible x/y coordinates.""" + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.dblclick(px, py) + page.wait_for_timeout(150) + + events = _get_events(page, "double_click") + assert len(events) >= 1 + e = events[0] + # x/y should be within the canvas bounds (0..FIG_W, 0..FIG_H) + assert "x" in e and "y" in e, "double_click must carry x, y fields" + assert 0 <= e["x"] <= FIG_W, f"double_click x={e['x']} out of range" + assert 0 <= e["y"] <= FIG_H, f"double_click y={e['y']} out of range" + + def test_double_click_event_order(self, interact_page): + """On dblclick: pointer_down fires before double_click. + + The expected sequence is: pointer_down(×1-2) then double_click. + We verify that the last event in the sequence is double_click (not + the first), so the double_click is never emitted before its preceding + single-click path has had a chance to run. + """ + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.dblclick(px, py) + page.wait_for_timeout(150) + + all_events = _get_events(page) + # At minimum: pointer_down events and double_click + event_types = [e.get("event_type") for e in all_events] + assert "double_click" in event_types, "double_click must be in event sequence" + last_relevant = [t for t in event_types if t in ("pointer_down", "double_click")] + assert last_relevant, "Expected pointer_down and/or double_click events" + assert last_relevant[-1] == "double_click", ( + f"double_click must be the last in the click sequence, got {last_relevant}" + ) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Drag vs click distinction +# ══════════════════════════════════════════════════════════════════════════════ + +class TestDragVsClick: + """Drag and single-click are mutually exclusive event paths on 2D panels.""" + + def test_single_click_emits_pointer_down(self, interact_page): + """A short stationary click emits exactly one pointer_down.""" + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.click(px, py) + page.wait_for_timeout(150) + + events = _get_events(page, "pointer_down") + assert len(events) == 1, ( + f"Expected exactly 1 pointer_down on single click, got {len(events)}" + ) + + def test_significant_drag_does_not_emit_pointer_down(self, interact_page): + """A drag with significant motion clears the click candidate → no pointer_down.""" + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.move(px, py) + page.mouse.down() + # Move well past the 4 px threshold + page.mouse.move(px + DRAG_DISTANCE, py, steps=10) + page.mouse.up() + page.wait_for_timeout(150) + + pd_events = _get_events(page, "pointer_down") + assert len(pd_events) == 0, ( + f"Drag must not emit pointer_down (click candidate should be cleared), " + f"got {len(pd_events)} pointer_down events" + ) + + def test_significant_drag_emits_pointer_up(self, interact_page): + """A drag emits pointer_up on release.""" + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.move(px, py) + page.mouse.down() + page.mouse.move(px + DRAG_DISTANCE, py, steps=10) + page.mouse.up() + page.wait_for_timeout(150) + + pu_events = _get_events(page, "pointer_up") + assert len(pu_events) >= 1, "Drag must emit at least one pointer_up on release" + + def test_drag_then_click_emits_pointer_down(self, interact_page): + """After a drag completes, a subsequent short click fires pointer_down.""" + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + # Drag first + page.mouse.move(px, py) + page.mouse.down() + page.mouse.move(px + DRAG_DISTANCE, py, steps=10) + page.mouse.up() + page.wait_for_timeout(100) + + # Reset event collector + _clear_events(page) + + # Short click + page.mouse.click(px, py) + page.wait_for_timeout(150) + + events = _get_events(page, "pointer_down") + assert len(events) == 1, ( + "After a drag, a short click must still emit pointer_down" + ) + + def test_small_movement_still_registers_as_click(self, interact_page): + """Movement within the 2 px click threshold still triggers pointer_down.""" + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.move(px, py) + page.mouse.down() + # Move less than 2 px — within the distance² ≤ 25 threshold + page.mouse.move(px + 1, py + 1, steps=2) + page.mouse.up() + page.wait_for_timeout(150) + + events = _get_events(page, "pointer_down") + assert len(events) == 1, ( + "Tiny movement within click threshold must still produce pointer_down" + ) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Button filtering +# ══════════════════════════════════════════════════════════════════════════════ + +class TestButtonFiltering: + """Non-primary buttons must not trigger the 2D left-click event path.""" + + def test_right_click_does_not_emit_pointer_down(self, interact_page): + """Right-click (button=2) on a 2D panel does not emit pointer_down. + + The mousedown handler returns early for button !== 0, so no + clickCandidate is set and pointer_down must not fire. + """ + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.click(px, py, button="right") + page.wait_for_timeout(150) + + events = _get_events(page, "pointer_down") + assert len(events) == 0, ( + "Right-click must not emit pointer_down (button !== 0 guard)" + ) + + def test_middle_click_does_not_emit_pointer_down(self, interact_page): + """Middle-click (button=1) on a 2D panel does not emit pointer_down.""" + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.click(px, py, button="middle") + page.wait_for_timeout(150) + + events = _get_events(page, "pointer_down") + assert len(events) == 0, ( + "Middle-click must not emit pointer_down (button !== 0 guard)" + ) + + def test_left_click_emits_pointer_down(self, interact_page): + """Sanity-check: left-click still emits pointer_down after button tests.""" + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.click(px, py) + page.wait_for_timeout(150) + + events = _get_events(page, "pointer_down") + assert len(events) == 1, "Left-click must emit pointer_down" + + +# ══════════════════════════════════════════════════════════════════════════════ +# Wheel independence +# ══════════════════════════════════════════════════════════════════════════════ + +class TestWheelIndependence: + """Wheel events fire independently of click/drag state.""" + + def test_wheel_after_click_still_fires(self, interact_page): + """wheel event fires correctly after a preceding click.""" + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.click(px, py) + page.wait_for_timeout(50) + + _clear_events(page) + page.mouse.move(px, py) + page.mouse.wheel(0, 120) + page.wait_for_timeout(100) + + events = _get_events(page, "wheel") + assert len(events) >= 1, "wheel must fire after a preceding click" + assert "dy" in events[0], "wheel event must carry dy field" + + def test_wheel_during_drag_does_not_suppress_dblclick(self, interact_page): + """wheel event during an active pan does not block subsequent dblclick.""" + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + # Drag + wheel + page.mouse.move(px, py) + page.mouse.down() + page.mouse.move(px + DRAG_DISTANCE, py, steps=5) + page.mouse.wheel(0, 120) + page.mouse.up() + page.wait_for_timeout(100) + + _clear_events(page) + page.mouse.dblclick(px, py) + page.wait_for_timeout(150) + + events = _get_events(page, "double_click") + assert len(events) >= 1, "double_click must fire after wheel+drag sequence" + + +# ══════════════════════════════════════════════════════════════════════════════ +# 1D panel event specifics +# ══════════════════════════════════════════════════════════════════════════════ + +class TestPlot1DEvents: + """1D panel event path regression tests.""" + + def test_1d_single_click_emits_pointer_down_when_near_line(self, interact_page): + """Short 1D click near the plotted line emits pointer_down.""" + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + # Flat line at y=0; the plot centre is near the line. + ax.plot(np.zeros(128)) + page = interact_page(fig) + _clear_events(page) + + px, py = _plot_center_page() + page.mouse.click(px, py) + page.wait_for_timeout(150) + + # pointer_down fires when the hit-test finds the line; if not found + # the event is simply not emitted — so we verify count is 0 or 1. + events = _get_events(page, "pointer_down") + # Not asserting exact count because line hit depends on render geometry. + # Key guarantee: no error raised, and no spurious extra pointer_down events. + assert isinstance(events, list) + + def test_1d_drag_does_not_emit_pointer_down(self, interact_page): + """A 1D drag larger than 5 px does not emit pointer_down.""" + page, _ = _make_1d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.move(px, py) + page.mouse.down() + page.mouse.move(px + 30, py, steps=10) + page.mouse.up() + page.wait_for_timeout(150) + + events = _get_events(page, "pointer_down") + assert len(events) == 0, ( + "1D drag must not emit pointer_down (distance guard)" + ) + + def test_1d_dblclick_fires_double_click(self, interact_page): + """1D panel dblclick emits double_click, not blocked by pan state.""" + page, _ = _make_1d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.dblclick(px, py) + page.wait_for_timeout(150) + + events = _get_events(page, "double_click") + assert len(events) >= 1, "1D dblclick must emit double_click" + + def test_1d_pointer_up_fires_on_drag(self, interact_page): + """1D drag emits pointer_up on release.""" + page, _ = _make_1d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.move(px, py) + page.mouse.down() + page.mouse.move(px + 30, py, steps=10) + page.mouse.up() + page.wait_for_timeout(150) + + events = _get_events(page, "pointer_up") + assert len(events) >= 1, "1D drag must emit pointer_up on release" + + +# ══════════════════════════════════════════════════════════════════════════════ +# 3D panel event specifics +# ══════════════════════════════════════════════════════════════════════════════ + +class TestPlot3DEvents: + """3D panel event regression tests.""" + + def test_3d_dblclick_fires_double_click(self, interact_page): + """3D panel dblclick emits double_click despite drag being active.""" + page, _ = _make_3d_page(interact_page) + cx = FIG_W // 2 + GRID_PAD + cy = FIG_H // 2 + GRID_PAD + + page.mouse.dblclick(cx, cy) + page.wait_for_timeout(150) + + events = _get_events(page, "double_click") + assert len(events) >= 1, "3D dblclick must emit double_click" + + def test_3d_drag_emits_pointer_move(self, interact_page): + """3D drag emits pointer_move events (not blocked by drag state).""" + page, _ = _make_3d_page(interact_page) + cx = FIG_W // 2 + GRID_PAD + cy = FIG_H // 2 + GRID_PAD + + page.mouse.move(cx, cy) + page.mouse.down() + page.mouse.move(cx + 40, cy, steps=8) + page.mouse.up() + page.wait_for_timeout(150) + + events = _get_events(page, "pointer_move") + assert len(events) > 0, "3D drag must emit pointer_move events" + + def test_3d_dblclick_fires_after_drag(self, interact_page): + """3D double_click fires after a preceding drag sequence.""" + page, _ = _make_3d_page(interact_page) + cx = FIG_W // 2 + GRID_PAD + cy = FIG_H // 2 + GRID_PAD + + # Drag first + page.mouse.move(cx, cy) + page.mouse.down() + page.mouse.move(cx + 40, cy, steps=8) + page.mouse.up() + page.wait_for_timeout(100) + + _clear_events(page) + page.mouse.dblclick(cx, cy) + page.wait_for_timeout(150) + + events = _get_events(page, "double_click") + assert len(events) >= 1, ( + "3D double_click must fire after preceding drag" + ) + + def test_3d_wheel_fires_independently(self, interact_page): + """3D wheel event fires even during/after a drag.""" + page, _ = _make_3d_page(interact_page) + cx = FIG_W // 2 + GRID_PAD + cy = FIG_H // 2 + GRID_PAD + + page.mouse.move(cx, cy) + page.mouse.wheel(0, 120) + page.wait_for_timeout(100) + + events = _get_events(page, "wheel") + assert len(events) >= 1, "3D wheel must fire" + assert "dy" in events[0] + + +# ══════════════════════════════════════════════════════════════════════════════ +# Pointer enter / leave +# ══════════════════════════════════════════════════════════════════════════════ + +class TestPointerEnterLeave: + """pointer_enter and pointer_leave must fire independently of click/drag.""" + + def test_pointer_enter_fires_after_drag(self, interact_page): + """pointer_enter fires when entering after a drag on another part of the page.""" + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + # Leave canvas, do a drag outside, then re-enter + page.mouse.move(0, 0) + page.wait_for_timeout(50) + page.mouse.move(px, py) + page.wait_for_timeout(100) + + events = _get_events(page, "pointer_enter") + assert len(events) >= 1, "pointer_enter must fire on canvas entry" + + def test_pointer_leave_fires_after_drag(self, interact_page): + """pointer_leave fires when leaving even if a drag is in progress.""" + page, _ = _make_2d_page(interact_page) + px, py = _plot_center_page() + + page.mouse.move(px, py) + page.wait_for_timeout(30) + _clear_events(page) + + # Move outside the figure entirely + page.mouse.move(0, 0) + page.wait_for_timeout(100) + + events = _get_events(page, "pointer_leave") + assert len(events) >= 1, "pointer_leave must fire on canvas exit" + + +# ══════════════════════════════════════════════════════════════════════════════ +# HAADF STEM nanoparticle picker regression +# ══════════════════════════════════════════════════════════════════════════════ + +class TestParticlePickerDblClick: + """Regression tests mirroring the HAADF STEM nanoparticle picker example. + + The picker's ``_on_double_click`` handler starts with:: + + if event.xdata is None or event.ydata is None: + return + + So if the JS ``double_click`` event payload does not include ``xdata`` and + ``ydata``, every pick silently fails. These tests reproduce that exact + failure mode. + """ + + def test_dblclick_payload_includes_xdata_ydata(self, interact_page): + """double_click event on a 2D imshow carries non-None xdata and ydata. + + Root cause: the dblclick handler in figure_esm.js was emitting only + canvas-pixel ``x``/``y``, not the image-space ``xdata``/``ydata`` + that Python handlers receive as ``event.xdata``/``event.ydata``. + The particle picker's guard ``if event.xdata is None: return`` meant + every double-click was silently dropped. + """ + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((64, 64))) + page = interact_page(fig) + _collect_events(page) + + px, py = _plot_center_page() + page.mouse.dblclick(px, py) + page.wait_for_timeout(150) + + events = _get_events(page, "double_click") + assert len(events) >= 1, "double_click must fire on dblclick" + e = events[0] + assert "xdata" in e, "double_click payload must include xdata" + assert "ydata" in e, "double_click payload must include ydata" + assert e["xdata"] is not None, "xdata must not be None" + assert e["ydata"] is not None, "ydata must not be None" + + def test_dblclick_xdata_ydata_are_image_coords(self, interact_page): + """xdata/ydata in double_click are image-space coordinates (0..N range). + + For a 64×64 image, a click at the canvas centre should produce + xdata and ydata near 32 (the image midpoint). + """ + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((64, 64))) + page = interact_page(fig) + _collect_events(page) + + px, py = _plot_center_page() + page.mouse.dblclick(px, py) + page.wait_for_timeout(150) + + events = _get_events(page, "double_click") + assert len(events) >= 1 + e = events[0] + # Image is 64×64; centre click should land roughly in the middle half. + assert 10 <= e["xdata"] <= 54, ( + f"xdata={e['xdata']:.1f} out of expected range for 64×64 image centre click" + ) + assert 10 <= e["ydata"] <= 54, ( + f"ydata={e['ydata']:.1f} out of expected range for 64×64 image centre click" + ) + + def test_dblclick_with_circles_markers_present(self, interact_page): + """double_click still carries xdata/ydata when circles markers are on the plot. + + The particle picker adds candidate circles before any interaction. + This test ensures markers don't interfere with the dblclick coordinate + computation. + """ + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((64, 64))) + # Mirror the particle picker: add candidate circles + candidates = np.array([[16.0, 16.0], [48.0, 48.0], [32.0, 32.0]]) + plot.add_circles(candidates, name="candidates", radius=6, + facecolors="none", edgecolors="#555555") + page = interact_page(fig) + _collect_events(page) + + px, py = _plot_center_page() + page.mouse.dblclick(px, py) + page.wait_for_timeout(150) + + events = _get_events(page, "double_click") + assert len(events) >= 1, "double_click must fire with circles present" + e = events[0] + assert e.get("xdata") is not None, "xdata must not be None with circles present" + assert e.get("ydata") is not None, "ydata must not be None with circles present" + + def test_dblclick_after_pan_carries_xdata_ydata(self, interact_page): + """After a pan (which shifts the viewport), dblclick still carries xdata/ydata. + + The particle picker is used with zoom/pan interactions before picking. + xdata/ydata must track the panned viewport, not the raw canvas offset. + """ + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((64, 64))) + page = interact_page(fig) + _collect_events(page) + + px, py = _plot_center_page() + + # Pan the viewport + page.mouse.move(px, py) + page.mouse.down() + page.mouse.move(px + 30, py + 20, steps=8) + page.mouse.up() + page.wait_for_timeout(100) + _clear_events(page) + + # Now double-click — xdata/ydata must reflect the panned position + page.mouse.dblclick(px, py) + page.wait_for_timeout(150) + + events = _get_events(page, "double_click") + assert len(events) >= 1, "double_click must fire after a pan" + e = events[0] + assert e.get("xdata") is not None, "xdata must not be None after pan" + assert e.get("ydata") is not None, "ydata must not be None after pan" + + +# ══════════════════════════════════════════════════════════════════════════════ +# HAADF STEM nanoparticle picker — dwell/settle regression +# ══════════════════════════════════════════════════════════════════════════════ + +class TestParticlePickerDwell: + """Regression tests mirroring the particle picker's pointer_settled handler. + + The picker's ``_on_settled`` starts with:: + + if event.xdata is None or event.ydata is None: + return + + So ``pointer_settled`` must include ``xdata``/``ydata`` for the dwell + inspection to work. These tests reproduce that exact failure mode and + guard the fix. + """ + + def _make_picker_page(self, interact_page, ms: int = 200): + """Build a page that mirrors the particle picker setup. + + Uses ms=200 so the test doesn't have to wait the full 300 ms of the + real example. The panel state is serialised into the standalone HTML + so JS sees ``pointer_settled_ms = 200`` without needing a Python kernel. + """ + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((64, 64))) + # Mirrors the picker: add candidate circles + candidates = np.array([[16.0, 16.0], [48.0, 48.0], [32.0, 32.0]]) + plot.add_circles(candidates, name="candidates", radius=6, + facecolors="none", edgecolors="#555555") + # Register a dummy handler so pointer_settled_ms is baked into state + plot.add_event_handler(lambda e: None, "pointer_settled", ms=ms, delta=6) + page = interact_page(fig) + _collect_events(page) + return page, plot + + def test_settled_payload_includes_xdata_ydata(self, interact_page): + """pointer_settled event on a 2D imshow carries non-None xdata and ydata. + + Root cause: the setTimeout callback in figure_esm.js was emitting only + canvas-pixel ``x``/``y``. The particle picker's guard + ``if event.xdata is None: return`` therefore caused every dwell + inspection to be silently skipped. + """ + page, plot = self._make_picker_page(interact_page) + px, py = _plot_center_page() + + # Move into the plot area and hold still — wait for the event + page.mouse.move(px, py) + page.wait_for_function( + "() => window._aplAllEvents.some(e => e.event_type === 'pointer_settled')", + timeout=2000, + ) + + events = _get_events(page, "pointer_settled") + assert len(events) >= 1, "pointer_settled must fire after dwell" + e = events[0] + assert "xdata" in e, "pointer_settled payload must include xdata" + assert "ydata" in e, "pointer_settled payload must include ydata" + assert e["xdata"] is not None, "xdata must not be None" + assert e["ydata"] is not None, "ydata must not be None" + + def test_settled_xdata_ydata_are_image_coords(self, interact_page): + """xdata/ydata in pointer_settled are image-space coordinates (0..N range). + + For a 64×64 image, a dwell at the canvas centre should produce + xdata and ydata near 32. + """ + page, plot = self._make_picker_page(interact_page) + px, py = _plot_center_page() + + page.mouse.move(px, py) + page.wait_for_function( + "() => window._aplAllEvents.some(e => e.event_type === 'pointer_settled')", + timeout=2000, + ) + + events = _get_events(page, "pointer_settled") + assert len(events) >= 1 + e = events[0] + assert 10 <= e["xdata"] <= 54, ( + f"xdata={e['xdata']:.1f} out of expected range for 64×64 image centre dwell" + ) + assert 10 <= e["ydata"] <= 54, ( + f"ydata={e['ydata']:.1f} out of expected range for 64×64 image centre dwell" + ) + + def test_settled_fires_after_configured_ms(self, interact_page): + """pointer_settled fires after the configured dwell period (ms=200). + + Guards the full pipeline: Python sets pointer_settled_ms in state → + state is serialised to HTML → JS reads it and arms the setTimeout → + event fires after the dwell period with dwell_ms >= 200. + """ + page, plot = self._make_picker_page(interact_page, ms=200) + px, py = _plot_center_page() + + # Verify JS received the configured ms value + ms_in_js = page.evaluate( + f"() => JSON.parse(window._aplModel.get('panel_{plot._id}_json')).pointer_settled_ms" + ) + assert ms_in_js == 200, f"JS pointer_settled_ms should be 200, got {ms_in_js}" + + page.mouse.move(px, py) + page.wait_for_function( + "() => window._aplAllEvents.some(e => e.event_type === 'pointer_settled')", + timeout=2000, + ) + + events = _get_events(page, "pointer_settled") + e = events[0] + assert "dwell_ms" in e, "pointer_settled must carry dwell_ms" + assert e["dwell_ms"] >= 200, ( + f"dwell_ms={e['dwell_ms']:.0f} should be >= 200" + ) + assert e.get("xdata") is not None, "xdata must be present after dwell" + assert e.get("ydata") is not None, "ydata must be present after dwell" + + def test_settled_not_fired_while_moving(self, interact_page): + """pointer_settled does not fire while the pointer keeps moving. + + The particle picker should only inspect a candidate when the user + deliberately hovers over it — not during panning. + """ + page, plot = self._make_picker_page(interact_page, ms=200) + px, py = _plot_center_page() + + # Keep moving for ~240 ms (less than 200 ms settle threshold between moves) + page.mouse.move(px, py) + for _ in range(8): + px += 5 + page.mouse.move(px, py) + page.wait_for_timeout(25) + + events = _get_events(page, "pointer_settled") + assert len(events) == 0, ( + "pointer_settled must not fire while pointer is continuously moving" + ) + + def test_settled_fires_after_pan_with_xdata_ydata(self, interact_page): + """After a pan, pointer_settled still carries correct xdata/ydata. + + The particle picker is frequently used after navigating the image. + The settled event must report the panned position, not the original + canvas position. + """ + page, plot = self._make_picker_page(interact_page) + px, py = _plot_center_page() + + # Pan the viewport first + page.mouse.move(px, py) + page.mouse.down() + page.mouse.move(px + 30, py + 20, steps=8) + page.mouse.up() + page.wait_for_timeout(50) + _clear_events(page) + + # Now hold still over the same canvas position + page.mouse.move(px, py) + page.wait_for_function( + "() => window._aplAllEvents.some(e => e.event_type === 'pointer_settled')", + timeout=2000, + ) + + events = _get_events(page, "pointer_settled") + assert len(events) >= 1, "pointer_settled must fire after pan + dwell" + e = events[0] + assert e.get("xdata") is not None, "xdata must not be None after pan" + assert e.get("ydata") is not None, "ydata must not be None after pan" + + +# ══════════════════════════════════════════════════════════════════════════════ +# Marker pixel-centre alignment (_imgToCanvas2d +0.5 fix) +# ══════════════════════════════════════════════════════════════════════════════ + +class TestMarkerPixelCenterAlignment: + """Circle markers must be drawn at (ix+0.5)*scale, not ix*scale. + + Each rendered image pixel i occupies canvas [i*scale, (i+1)*scale). + Its visual centre is at (i+0.5)*scale. Previously _imgToCanvas2d used + ix*scale (the leading/top-left edge), so every marker appeared shifted + 0.5*scale pixels up and to the left — visibly wrong when zoomed in. + + This regression test directly samples the markersCanvas pixel at the + point that lies on the circle ring only when the centre is correct. + """ + + def test_circle_drawn_at_pixel_center(self, interact_page): + """Circle at image pixel (8,8) is rendered at canvas centre (136,136). + + Setup: 16×16 image. 2D panels always reserve PAD_T=12px at the top, + so to get scale=16 we need imgW=imgH=256, which requires: + FIG_W=256, FIG_H=256+12=268 (no axes → no left/bottom gutters) + imgW=256, imgH=268-12=256 → scale=min(256/16,256/16)=16 + + correct centre = (8+0.5)*16 = 136 + old wrong centre = 8*16 = 128 + + A radius-0.5 circle (canvas radius 8) centred at (136,136) has its + ring passing through canvas (144,136). The old wrong circle would + have its ring passing through canvas (136,128) instead. + We sample (144,136) and require non-zero alpha. + """ + PAD_T = 12 + IMG_W = IMG_H = 16 + FIG_W = IMG_W * 16 # 256 — so imgW = FIG_W = 256, scale=16 + FIG_H = IMG_H * 16 + PAD_T # 268 — so imgH = FIG_H - PAD_T = 256, scale=16 + + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((IMG_H, IMG_W))) + # radius=0.5 image-px → 8 canvas-px at scale=16 + plot.add_circles(np.array([[8.0, 8.0]]), radius=0.5) + + page = interact_page(fig) + page.wait_for_timeout(300) + + alpha = page.evaluate("""() => { + const dpr = window.devicePixelRatio || 1; + // markersCanvas: pointer-events:none, z-index:6, visible + const mk = Array.from(document.querySelectorAll('canvas')) + .find(c => c.style.pointerEvents === 'none' && + c.style.zIndex === '6' && + c.style.display !== 'none' && + c.width > 0); + if (!mk) return -1; + const ctx = mk.getContext('2d'); + // If circle centre is at (136,136), the ring (r=8) passes through (144,136). + // Check a 3px neighbourhood to be robust against sub-pixel rendering. + let maxAlpha = 0; + for (let dx = -1; dx <= 1; dx++) { + for (let dy = -1; dy <= 1; dy++) { + const bx = Math.round((144 + dx) * dpr); + const by = Math.round((136 + dy) * dpr); + const d = ctx.getImageData(bx, by, 1, 1).data; + maxAlpha = Math.max(maxAlpha, d[3]); + } + } + return maxAlpha; + }""") + + assert alpha > 0, ( + "Circle ring should appear near canvas (144, 136) when the centre " + "is at (8+0.5)*16=136. alpha=0 means _imgToCanvas2d is still " + "placing the circle at the leading edge (8*16=128) instead of the " + "pixel centre (8.5*16=136)." + ) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Modifier keys in key_down and pointer_down events +# ══════════════════════════════════════════════════════════════════════════════ + +class TestModifierKeys: + """Verify that modifier keys (ctrl, shift, alt) appear in event payloads. + + The JS _modifiers() helper always runs; these tests lock that invariant + so future refactors can't silently drop modifier detection. + """ + + def test_shift_modifier_in_key_down(self, interact_page): + """Shift+a fires key_down with modifiers=['shift'].""" + page, _ = _make_2d_page(interact_page) + cx, cy = _plot_center_page(FIG_W, FIG_H) + page.mouse.move(cx, cy) + _clear_events(page) + page.keyboard.press('Shift+a') + page.wait_for_timeout(80) + key_events = [e for e in _get_events(page, 'key_down') + if e.get('key', '').lower() == 'a'] + assert key_events, "key_down must fire for Shift+a" + assert 'shift' in key_events[-1].get('modifiers', []), ( + "Shift key must appear in modifiers list" + ) + + def test_ctrl_modifier_in_key_down(self, interact_page): + """Ctrl+a fires key_down with modifiers=['ctrl'].""" + page, _ = _make_2d_page(interact_page) + cx, cy = _plot_center_page(FIG_W, FIG_H) + page.mouse.move(cx, cy) + _clear_events(page) + page.keyboard.press('Control+a') + page.wait_for_timeout(80) + key_events = [e for e in _get_events(page, 'key_down') + if e.get('key', '').lower() == 'a'] + assert key_events, "key_down must fire for Ctrl+a" + assert 'ctrl' in key_events[-1].get('modifiers', []), ( + "Ctrl key must appear in modifiers list" + ) + + def test_no_modifier_on_plain_key(self, interact_page): + """Plain key press carries an empty modifiers list.""" + page, _ = _make_2d_page(interact_page) + cx, cy = _plot_center_page(FIG_W, FIG_H) + page.mouse.move(cx, cy) + _clear_events(page) + page.keyboard.press('a') + page.wait_for_timeout(80) + key_events = [e for e in _get_events(page, 'key_down') + if e.get('key', '').lower() == 'a'] + assert key_events, "key_down must fire for plain 'a'" + assert key_events[-1].get('modifiers', None) == [], ( + "Plain key must have empty modifiers list" + ) + + def test_shift_modifier_in_pointer_down(self, interact_page): + """pointer_down with Shift held carries modifiers=['shift'].""" + page, _ = _make_2d_page(interact_page) + cx, cy = _plot_center_page(FIG_W, FIG_H) + _clear_events(page) + page.keyboard.down('Shift') + page.mouse.click(cx, cy) + page.keyboard.up('Shift') + page.wait_for_timeout(80) + ptr_events = _get_events(page, 'pointer_down') + assert ptr_events, "pointer_down must fire on click" + assert 'shift' in ptr_events[-1].get('modifiers', []), ( + "Shift held during click must appear in pointer_down modifiers" + ) diff --git a/anyplotlib/tests/test_interactive/test_title.py b/anyplotlib/tests/test_interactive/test_title.py new file mode 100644 index 00000000..b8d05101 --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_title.py @@ -0,0 +1,157 @@ +""" +Playwright tests verifying 2D title rendering. + +Title rendering +--------------- +2D image panels always reserve a PAD_T (12 px) strip at the top, matching 1D +behaviour. ``set_title(...)`` draws text in that strip via a dedicated +``titleCanvas`` (z-index 8) above the plotCanvas. The title must be visible +(non-zero alpha pixels) regardless of whether physical axes are provided. +""" +from __future__ import annotations + +import numpy as np + +import anyplotlib as apl + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def _title_pixel_count(page) -> int: + """Count non-transparent pixels in the titleCanvas (z-index:8).""" + return page.evaluate("""() => { + const tc = Array.from(document.querySelectorAll('canvas')) + .find(c => c.style.zIndex === '8'); + if (!tc) return -1; + const ctx = tc.getContext('2d'); + const d = ctx.getImageData(0, 0, tc.width, tc.height).data; + let n = 0; + for (let i = 3; i < d.length; i += 4) { if (d[i] > 0) n++; } + return n; + }""") + + +def _title_canvas_info(page) -> dict: + """Return display/position/size info about the titleCanvas.""" + return page.evaluate("""() => { + const tc = Array.from(document.querySelectorAll('canvas')) + .find(c => c.style.zIndex === '8'); + if (!tc) return null; + return { + display: tc.style.display, + top: tc.style.top, + left: tc.style.left, + cssWidth: tc.style.width, + cssHeight: tc.style.height, + physW: tc.width, + physH: tc.height, + }; + }""") + + +# ══════════════════════════════════════════════════════════════════════════════ +# 2D title rendering +# ══════════════════════════════════════════════════════════════════════════════ + +class TestTitle2DRendering: + """Title text must appear above the image in the PAD_T strip.""" + + def test_title_canvas_visible_without_axes(self, interact_page): + """titleCanvas is display:block for imshow WITHOUT explicit axes.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + plot.set_title("Plain imshow title") + page = interact_page(fig) + page.wait_for_timeout(200) + + info = _title_canvas_info(page) + assert info is not None, "titleCanvas not found (z-index:8 canvas missing)" + assert info["display"] == "block", ( + f"titleCanvas must be display:block, got {info['display']!r}" + ) + + def test_title_canvas_visible_with_axes(self, interact_page): + """titleCanvas is display:block for imshow WITH explicit axes.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow( + np.zeros((32, 32), dtype=np.float32), + axes=[np.linspace(0, 10, 32)] * 2, + units="nm", + ) + plot.set_title("Physical axes title") + page = interact_page(fig) + page.wait_for_timeout(200) + + info = _title_canvas_info(page) + assert info is not None + assert info["display"] == "block" + + def test_title_text_renders_pixels(self, interact_page): + """set_title() produces non-transparent pixels in the titleCanvas.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + plot.set_title("Hello World") + page = interact_page(fig) + page.wait_for_timeout(200) + + n = _title_pixel_count(page) + assert n > 0, ( + "set_title() must produce visible pixels in titleCanvas. " + f"Got {n} non-zero alpha pixels — title is not rendering." + ) + + def test_empty_title_produces_no_pixels(self, interact_page): + """An empty (unset) title leaves titleCanvas transparent.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + # No set_title call + page = interact_page(fig) + page.wait_for_timeout(200) + + n = _title_pixel_count(page) + assert n == 0, ( + f"Empty title must leave titleCanvas transparent, got {n} pixels" + ) + + def test_title_canvas_in_top_strip(self, interact_page): + """titleCanvas top=0 and height=PAD_T (12 px) — sits above the image.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + plot.set_title("Position check") + page = interact_page(fig) + page.wait_for_timeout(200) + + info = _title_canvas_info(page) + assert info is not None + assert info["top"] == "0px", ( + f"titleCanvas must sit at top:0, got top={info['top']!r}" + ) + assert info["cssHeight"] == "12px", ( + f"titleCanvas height must be PAD_T=12px, got {info['cssHeight']!r}" + ) + + def test_title_above_image_not_overlapping(self, interact_page): + """titleCanvas sits in the 12px gutter above the plotCanvas (no overlap). + + The plotCanvas must start at top ≥ 12px so the title strip is + unobstructed. + """ + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + plot.set_title("No overlap check") + page = interact_page(fig) + page.wait_for_timeout(200) + + plot_canvas_top = page.evaluate("""() => { + // z-index auto = plotCanvas (the image canvas) + const canvases = Array.from(document.querySelectorAll('canvas')); + const pc = canvases.find(c => !c.style.zIndex && c.style.position === 'absolute'); + return pc ? pc.style.top : null; + }""") + + assert plot_canvas_top is not None, "plotCanvas not found" + top_px = int(plot_canvas_top.replace("px", "")) + assert top_px >= 12, ( + f"plotCanvas top must be >= 12px (PAD_T) so title is above image, " + f"got top={top_px}px" + ) diff --git a/anyplotlib/tests/test_markers/test_marker_transforms.py b/anyplotlib/tests/test_markers/test_marker_transforms.py new file mode 100644 index 00000000..25aeb63b --- /dev/null +++ b/anyplotlib/tests/test_markers/test_marker_transforms.py @@ -0,0 +1,224 @@ +""" +tests/test_markers/test_marker_transforms.py +============================================= +Tests for the coordinate transform parameter on marker collections. + +Exercises: transform="data" (default), transform="axes", transform="display", +invalid transform, all add_* methods on both Plot1D and Plot2D, and that +set() preserves the transform. +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.markers import MarkerGroup + + +def _push_noop(): + pass + + +def _group(mtype, **kwargs): + return MarkerGroup(mtype, "g1", kwargs, _push_noop) + + +def _make_plot2d(): + fig, ax = apl.subplots(1, 1) + return ax.imshow(np.zeros((32, 32))) + + +def _make_plot1d(): + fig, ax = apl.subplots(1, 1) + return ax.plot(np.zeros(32)) + + +# --------------------------------------------------------------------------- +# MarkerGroup — wire-format round-trips +# --------------------------------------------------------------------------- + +class TestTransformWireFormat: + + def test_transform_default_is_data(self): + g = _group("circles", offsets=[[1.0, 2.0]], radius=5) + w = g.to_wire("gid") + assert w["transform"] == "data" + + def test_transform_axes_round_trips(self): + g = _group("texts", offsets=[[0.05, 0.95]], texts=["(3, 7)"], + transform="axes") + w = g.to_wire("gid") + assert w["transform"] == "axes" + + def test_transform_display_round_trips(self): + g = _group("circles", offsets=[[8.0, 8.0]], transform="display") + w = g.to_wire("gid") + assert w["transform"] == "display" + + def test_transform_data_explicit(self): + g = _group("rectangles", offsets=[[0.0, 0.0]], widths=10, heights=10, + transform="data") + w = g.to_wire("gid") + assert w["transform"] == "data" + + def test_all_2d_types_emit_transform(self): + types_and_kwargs = [ + ("circles", dict(offsets=[[1, 2]], radius=5)), + ("arrows", dict(offsets=[[1, 2]], U=1, V=1)), + ("ellipses", dict(offsets=[[1, 2]], widths=4, heights=3)), + ("lines", dict(segments=[[[0, 0], [1, 1]]])), + ("rectangles", dict(offsets=[[1, 2]], widths=4, heights=3)), + ("squares", dict(offsets=[[1, 2]], widths=4)), + ("polygons", dict(vertices_list=[[[0,0],[1,0],[0.5,1]]])), + ("texts", dict(offsets=[[1, 2]], texts=["hi"])), + ] + for mtype, kwargs in types_and_kwargs: + g = _group(mtype, transform="axes", **kwargs) + w = g.to_wire("gid") + assert w["transform"] == "axes", f"Failed for type {mtype!r}" + + def test_1d_types_emit_transform(self): + types_and_kwargs = [ + ("points", dict(offsets=[1.0, 2.0])), + ("vlines", dict(offsets=[1.0, 2.0])), + ("hlines", dict(offsets=[1.0, 2.0])), + ] + for mtype, kwargs in types_and_kwargs: + g = _group(mtype, transform="axes", **kwargs) + w = g.to_wire("gid") + assert w["transform"] == "axes", f"Failed for type {mtype!r}" + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- + +class TestTransformValidation: + + def test_invalid_transform_raises_on_init(self): + with pytest.raises(ValueError, match="transform"): + _group("circles", offsets=[[1, 2]], transform="screen") + + def test_invalid_transform_raises_on_set(self): + g = _group("circles", offsets=[[1, 2]]) + with pytest.raises(ValueError, match="transform"): + g.set(transform="bad") + + def test_valid_transforms_do_not_raise(self): + for tfm in ("data", "axes", "display"): + _group("circles", offsets=[[1, 2]], transform=tfm) # no error + + +# --------------------------------------------------------------------------- +# set() preserves transform +# --------------------------------------------------------------------------- + +class TestTransformPreservedOnSet: + + def test_set_does_not_reset_transform(self): + g = _group("circles", offsets=[[1, 2]], radius=5, transform="axes") + g.set(radius=10) + w = g.to_wire("gid") + assert w["transform"] == "axes" + + def test_set_can_update_transform(self): + g = _group("circles", offsets=[[1, 2]], transform="axes") + g.set(transform="display") + w = g.to_wire("gid") + assert w["transform"] == "display" + + +# --------------------------------------------------------------------------- +# Plot2D add_* methods accept transform kwarg +# --------------------------------------------------------------------------- + +class TestPlot2DTransformKwarg: + + def setup_method(self): + self.plot = _make_plot2d() + + def test_add_circles_transform_axes(self): + g = self.plot.add_circles([[10, 10]], name="c", transform="axes") + wire = self.plot.markers.to_wire_list() + assert wire[0]["transform"] == "axes" + + def test_add_points_transform_axes(self): + g = self.plot.add_points([[10, 10]], name="p", transform="axes") + wire = self.plot.markers.to_wire_list() + assert wire[0]["transform"] == "axes" + + def test_add_texts_transform_axes(self): + g = self.plot.add_texts([[0.05, 0.95]], ["label"], name="t", + transform="axes") + wire = self.plot.markers.to_wire_list() + assert wire[0]["transform"] == "axes" + + def test_add_rectangles_transform_display(self): + g = self.plot.add_rectangles([[5, 5]], widths=10, heights=10, name="r", + transform="display") + wire = self.plot.markers.to_wire_list() + assert wire[0]["transform"] == "display" + + def test_add_arrows_transform_axes(self): + g = self.plot.add_arrows([[5, 5]], U=1, V=1, name="a", transform="axes") + wire = self.plot.markers.to_wire_list() + assert wire[0]["transform"] == "axes" + + def test_add_ellipses_transform_axes(self): + g = self.plot.add_ellipses([[5, 5]], widths=4, heights=3, name="e", + transform="axes") + wire = self.plot.markers.to_wire_list() + assert wire[0]["transform"] == "axes" + + def test_add_lines_transform_axes(self): + g = self.plot.add_lines([[[0, 0], [1, 1]]], name="l", transform="axes") + wire = self.plot.markers.to_wire_list() + assert wire[0]["transform"] == "axes" + + def test_add_squares_transform_axes(self): + g = self.plot.add_squares([[5, 5]], widths=4, name="s", transform="axes") + wire = self.plot.markers.to_wire_list() + assert wire[0]["transform"] == "axes" + + def test_add_polygons_transform_axes(self): + verts = [[[0, 0], [1, 0], [0.5, 1]]] + g = self.plot.add_polygons(verts, name="pg", transform="axes") + wire = self.plot.markers.to_wire_list() + assert wire[0]["transform"] == "axes" + + def test_default_transform_is_data(self): + g = self.plot.add_texts([[5, 5]], ["hi"], name="t2") + wire = self.plot.markers.to_wire_list() + assert wire[0]["transform"] == "data" + + +# --------------------------------------------------------------------------- +# Plot1D add_* methods accept transform kwarg +# --------------------------------------------------------------------------- + +class TestPlot1DTransformKwarg: + + def setup_method(self): + self.plot = _make_plot1d() + + def test_add_vlines_transform_axes(self): + self.plot.add_vlines([0.5], name="v", transform="axes") + wire = self.plot.markers.to_wire_list() + assert wire[0]["transform"] == "axes" + + def test_add_hlines_transform_axes(self): + self.plot.add_hlines([0.5], name="h", transform="axes") + wire = self.plot.markers.to_wire_list() + assert wire[0]["transform"] == "axes" + + def test_add_texts_transform_axes(self): + self.plot.add_texts([[0.05, 0.95]], ["label"], name="t", + transform="axes") + wire = self.plot.markers.to_wire_list() + assert wire[0]["transform"] == "axes" + + def test_default_transform_is_data(self): + self.plot.add_vlines([0.5], name="v2") + wire = self.plot.markers.to_wire_list() + assert wire[0]["transform"] == "data" From 36e4d4251b32dca6cf586dc71ddc0d06476762e0 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sat, 23 May 2026 09:03:08 -0500 Subject: [PATCH 173/198] Refactor: Remove unused Playwright browser fixture and update testpaths in pytest configuration --- anyplotlib/tests/conftest.py | 15 - .../test_sphinx_anywidget.py | 92 --- .../tests/test_interactive/test_callbacks.py | 536 ------------------ pyproject.toml | 7 +- 4 files changed, 5 insertions(+), 645 deletions(-) delete mode 100644 anyplotlib/tests/test_documentation/test_sphinx_anywidget.py delete mode 100644 anyplotlib/tests/test_interactive/test_callbacks.py diff --git a/anyplotlib/tests/conftest.py b/anyplotlib/tests/conftest.py index 42843058..b1b3faa7 100644 --- a/anyplotlib/tests/conftest.py +++ b/anyplotlib/tests/conftest.py @@ -123,21 +123,6 @@ def _set_baselines_path(request): ) -# --------------------------------------------------------------------------- -# Playwright browser (one Chromium instance for the whole test session) -# --------------------------------------------------------------------------- - -@pytest.fixture(scope="session") -def _pw_browser(): - """Yield a headless Chromium browser for the whole test session.""" - from playwright.sync_api import sync_playwright - - with sync_playwright() as pw: - browser = pw.chromium.launch(headless=True) - yield browser - browser.close() - - # --------------------------------------------------------------------------- # HTML builder with readiness sentinel # --------------------------------------------------------------------------- diff --git a/anyplotlib/tests/test_documentation/test_sphinx_anywidget.py b/anyplotlib/tests/test_documentation/test_sphinx_anywidget.py deleted file mode 100644 index 67f59218..00000000 --- a/anyplotlib/tests/test_documentation/test_sphinx_anywidget.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -tests/test_sphinx_anywidget.py -================================ - -Smoke tests for the ``anyplotlib.sphinx_anywidget`` extension. -""" - -from __future__ import annotations - -import numpy as np -import pytest - -import anyplotlib as apl -import anyplotlib.figure as _af -from anyplotlib.sphinx_anywidget import AnywidgetScraper, ViewerScraper, setup # noqa: F401 -from anyplotlib.sphinx_anywidget._directive import AnywidgetFigureDirective # noqa: F401 -from anyplotlib.sphinx_anywidget._repr_utils import build_standalone_html, _widget_px -from anyplotlib.sphinx_anywidget._scraper import ( - _INTERACTIVE_RE, - _find_widget, - _iframe_html, -) -from anyplotlib.sphinx_anywidget._wheel_builder import build_wheel # noqa: F401 - - -# ── fixtures ────────────────────────────────────────────────────────────────── - -@pytest.fixture -def simple_fig(): - fig, ax = apl.subplots(1, 1, figsize=(400, 300)) - ax.plot(np.sin(np.linspace(0, 6.28, 64))) - return fig - - -# ── standalone HTML builder ─────────────────────────────────────────────────── - -def test_standalone_html_contains_awi_state(simple_fig): - html = build_standalone_html(simple_fig, resizable=False, fig_id="tf") - assert "awi_state" in html, "Missing awi_state listener" - - -def test_standalone_html_contains_fig_id(simple_fig): - html = build_standalone_html(simple_fig, resizable=False, fig_id="tf") - assert '"tf"' in html, "Missing fig_id in HTML" - - -def test_widget_px(simple_fig): - w, h = _widget_px(simple_fig) - assert w == 416, f"Expected 416 got {w}" - - -# ── iframe HTML helper ──────────────────────────────────────────────────────── - -def test_iframe_html_interactive_has_activate_btn(): - b = _iframe_html("t.html", 400, 300, fig_id="a", interactive=True) - assert "awi-activate-btn" in b, "Missing activate button" - - -def test_iframe_html_static_no_activate_btn(): - s = _iframe_html("t.html", 400, 300, fig_id="a", interactive=False) - assert "awi-activate-btn" not in s, "Should not have activate btn on static" - - -# ── no stale push hook ──────────────────────────────────────────────────────── - -def test_no_pyodide_push_hook(): - assert not hasattr(_af, "_pyodide_push_hook"), "_pyodide_push_hook should be gone" - - -# ── _find_widget ────────────────────────────────────────────────────────────── - -def test_find_widget_finds_figure(simple_fig): - found = _find_widget({"fig": simple_fig, "x": 42}) - assert found is simple_fig, "Should find Figure" - - -def test_find_widget_returns_none_for_non_widget(): - assert _find_widget({"x": 42}) is None - - -# ── # Interactive detection ─────────────────────────────────────────────────── - -def test_interactive_re_matches_inline_comment(): - assert _INTERACTIVE_RE.search("fig # Interactive\n"), "Should match" - - -def test_interactive_re_matches_lowercase(): - assert _INTERACTIVE_RE.search("fig # interactive"), "Should match lowercase" - - -def test_interactive_re_no_false_positives(): - assert not _INTERACTIVE_RE.search("fig # not a match"), "Should not match" diff --git a/anyplotlib/tests/test_interactive/test_callbacks.py b/anyplotlib/tests/test_interactive/test_callbacks.py deleted file mode 100644 index 4b33731a..00000000 --- a/anyplotlib/tests/test_interactive/test_callbacks.py +++ /dev/null @@ -1,536 +0,0 @@ -"""Tests for the redesigned Event dataclass and CallbackRegistry.""" -from __future__ import annotations -import time -import pytest -import numpy as np -import anyplotlib as apl -from anyplotlib.callbacks import Event, CallbackRegistry, VALID_EVENT_TYPES, _EventMixin - - -# ── Event dataclass ─────────────────────────────────────────────────────────── - -class TestEvent: - def test_required_fields(self): - e = Event(event_type="pointer_down", source=None) - assert e.event_type == "pointer_down" - assert e.source is None - - def test_time_stamp_auto_set(self): - before = time.perf_counter() - e = Event(event_type="pointer_down") - after = time.perf_counter() - assert before <= e.time_stamp <= after - - def test_modifiers_default_empty_list(self): - e = Event(event_type="pointer_move") - assert e.modifiers == [] - assert isinstance(e.modifiers, list) - - def test_pointer_fields_default_none(self): - e = Event(event_type="pointer_move") - assert e.x is None - assert e.y is None - assert e.button is None - assert e.buttons == 0 - assert e.xdata is None - assert e.ydata is None - assert e.ray is None - assert e.line_id is None - assert e.dwell_ms is None - - def test_wheel_fields_default_none(self): - e = Event(event_type="wheel") - assert e.dx is None - assert e.dy is None - - def test_key_field_default_none(self): - e = Event(event_type="key_down") - assert e.key is None - - def test_bar_fields_default_none(self): - e = Event(event_type="pointer_down") - assert e.bar_index is None - assert e.value is None - assert e.x_label is None - assert e.group_index is None - - def test_stop_propagation_default_false(self): - e = Event(event_type="pointer_down") - assert e.stop_propagation is False - - def test_all_fields_settable(self): - e = Event( - event_type="pointer_down", - source="plot", - modifiers=["ctrl", "shift"], - x=100, y=200, - button=0, buttons=1, - xdata=3.14, ydata=2.71, - line_id="abc12345", - bar_index=2, value=99.5, x_label="Jan", group_index=1, - dx=10.0, dy=-5.0, - key="q", - ) - assert e.modifiers == ["ctrl", "shift"] - assert e.x == 100 - assert e.xdata == 3.14 - assert e.line_id == "abc12345" - assert e.bar_index == 2 - assert e.key == "q" - assert e.dx == 10.0 - assert e.dy == -5.0 - - def test_no_data_dict_attribute(self): - e = Event(event_type="pointer_move") - assert not hasattr(e, "data") - - def test_repr_includes_event_type(self): - e = Event(event_type="pointer_down", x=10, y=20) - assert "pointer_down" in repr(e) - - def test_stop_propagation_not_in_repr(self): - e = Event(event_type="pointer_down", stop_propagation=True) - assert "stop_propagation" not in repr(e) - - -class TestCallbackRegistry: - def test_connect_returns_int_cid(self): - reg = CallbackRegistry() - cid = reg.connect("pointer_down", lambda e: None) - assert isinstance(cid, int) - - def test_fire_calls_handler(self): - reg = CallbackRegistry() - calls = [] - reg.connect("pointer_down", lambda e: calls.append(e.event_type)) - reg.fire(Event("pointer_down")) - assert calls == ["pointer_down"] - - def test_fire_only_matching_type(self): - reg = CallbackRegistry() - calls = [] - reg.connect("pointer_down", lambda e: calls.append("down")) - reg.connect("pointer_up", lambda e: calls.append("up")) - reg.fire(Event("pointer_down")) - assert calls == ["down"] - - def test_disconnect_by_cid(self): - reg = CallbackRegistry() - calls = [] - cid = reg.connect("pointer_down", lambda e: calls.append(1)) - reg.disconnect(cid) - reg.fire(Event("pointer_down")) - assert calls == [] - - def test_disconnect_silent_if_not_found(self): - reg = CallbackRegistry() - reg.disconnect(999) # should not raise - - def test_wildcard_receives_all_types(self): - reg = CallbackRegistry() - calls = [] - reg.connect("*", lambda e: calls.append(e.event_type)) - reg.fire(Event("pointer_down")) - reg.fire(Event("key_down")) - reg.fire(Event("wheel")) - assert calls == ["pointer_down", "key_down", "wheel"] - - def test_priority_order(self): - reg = CallbackRegistry() - order = [] - reg.connect("pointer_down", lambda e: order.append("second"), order=1) - reg.connect("pointer_down", lambda e: order.append("first"), order=0) - reg.fire(Event("pointer_down")) - assert order == ["first", "second"] - - def test_same_priority_fires_in_registration_order(self): - reg = CallbackRegistry() - order = [] - reg.connect("pointer_down", lambda e: order.append("a"), order=0) - reg.connect("pointer_down", lambda e: order.append("b"), order=0) - reg.fire(Event("pointer_down")) - assert order == ["a", "b"] - - def test_stop_propagation(self): - reg = CallbackRegistry() - calls = [] - def handler_a(e): - calls.append("a") - e.stop_propagation = True - reg.connect("pointer_down", handler_a, order=0) - reg.connect("pointer_down", lambda e: calls.append("b"), order=1) - reg.fire(Event("pointer_down")) - assert calls == ["a"] - - def test_disconnect_fn_by_reference(self): - reg = CallbackRegistry() - calls = [] - fn = lambda e: calls.append(1) - reg.connect("pointer_down", fn) - reg.disconnect_fn(fn) - reg.fire(Event("pointer_down")) - assert calls == [] - - def test_disconnect_fn_specific_type(self): - reg = CallbackRegistry() - calls = [] - fn = lambda e: calls.append(e.event_type) - reg.connect("pointer_down", fn) - reg.connect("pointer_up", fn) - reg.disconnect_fn(fn, "pointer_down") - reg.fire(Event("pointer_down")) - reg.fire(Event("pointer_up")) - assert calls == ["pointer_up"] - - def test_bool_true_when_handlers_present(self): - reg = CallbackRegistry() - assert not bool(reg) - reg.connect("pointer_down", lambda e: None) - assert bool(reg) - - def test_invalid_event_type_raises(self): - reg = CallbackRegistry() - with pytest.raises(ValueError, match="Invalid event_type"): - reg.connect("on_click", lambda e: None) - - def test_connect_same_fn_multiple_types(self): - reg = CallbackRegistry() - calls = [] - fn = lambda e: calls.append(e.event_type) - reg.connect("pointer_down", fn) - reg.connect("pointer_up", fn) - reg.fire(Event("pointer_down")) - reg.fire(Event("pointer_up")) - assert calls == ["pointer_down", "pointer_up"] - - -class TestPauseHold: - def test_pause_drops_events(self): - reg = CallbackRegistry() - calls = [] - reg.connect("pointer_move", lambda e: calls.append(1)) - with reg.pause_events("pointer_move"): - reg.fire(Event("pointer_move")) - assert calls == [] - - def test_pause_handlers_intact_after_exit(self): - reg = CallbackRegistry() - calls = [] - reg.connect("pointer_move", lambda e: calls.append(1)) - with reg.pause_events("pointer_move"): - reg.fire(Event("pointer_move")) - reg.fire(Event("pointer_move")) - assert calls == [1] - - def test_pause_all_types_when_no_args(self): - reg = CallbackRegistry() - calls = [] - reg.connect("pointer_down", lambda e: calls.append("down")) - reg.connect("key_down", lambda e: calls.append("key")) - with reg.pause_events(): - reg.fire(Event("pointer_down")) - reg.fire(Event("key_down")) - assert calls == [] - - def test_pause_only_specified_type(self): - reg = CallbackRegistry() - calls = [] - reg.connect("pointer_move", lambda e: calls.append("move")) - reg.connect("pointer_down", lambda e: calls.append("down")) - with reg.pause_events("pointer_move"): - reg.fire(Event("pointer_move")) - reg.fire(Event("pointer_down")) - assert calls == ["down"] - - def test_pause_nested_same_type(self): - reg = CallbackRegistry() - calls = [] - reg.connect("pointer_move", lambda e: calls.append(1)) - with reg.pause_events("pointer_move"): - with reg.pause_events("pointer_move"): - reg.fire(Event("pointer_move")) - reg.fire(Event("pointer_move")) # still paused — outer not exited - reg.fire(Event("pointer_move")) # now fires - assert calls == [1] - - def test_hold_buffers_and_flushes_on_exit(self): - reg = CallbackRegistry() - calls = [] - reg.connect("pointer_settled", lambda e: calls.append(1)) - with reg.hold_events("pointer_settled"): - reg.fire(Event("pointer_settled")) - reg.fire(Event("pointer_settled")) - assert calls == [] # buffered, not fired yet - assert calls == [1, 1] # flushed on exit - - def test_hold_fires_non_held_types_immediately(self): - reg = CallbackRegistry() - move_calls = [] - settled_calls = [] - reg.connect("pointer_move", lambda e: move_calls.append(1)) - reg.connect("pointer_settled", lambda e: settled_calls.append(1)) - with reg.hold_events("pointer_settled"): - reg.fire(Event("pointer_move")) # not held → immediate - reg.fire(Event("pointer_settled")) # held → buffered - assert move_calls == [1] - assert settled_calls == [1] # flushed on exit - - def test_hold_events_in_order(self): - reg = CallbackRegistry() - calls = [] - reg.connect("pointer_settled", lambda e: calls.append(e.x)) - with reg.hold_events(): - reg.fire(Event("pointer_settled", x=1)) - reg.fire(Event("pointer_settled", x=2)) - reg.fire(Event("pointer_settled", x=3)) - assert calls == [1, 2, 3] - - def test_pause_wins_over_hold(self): - reg = CallbackRegistry() - calls = [] - reg.connect("pointer_move", lambda e: calls.append(1)) - with reg.hold_events("pointer_move"): - with reg.pause_events("pointer_move"): - reg.fire(Event("pointer_move")) - assert calls == [] # dropped, not buffered then flushed - - -class _FakePlot(_EventMixin): - """Minimal plot stub for testing _EventMixin.""" - def __init__(self): - self.callbacks = CallbackRegistry() - self._settled_config = (0, 0) - - def _configure_pointer_settled(self, ms: int, delta: float) -> None: - self._settled_config = (ms, delta) - - -class TestEventMixin: - def test_functional_form_single_type(self): - plot = _FakePlot() - calls = [] - fn = lambda e: calls.append(e.event_type) - plot.add_event_handler(fn, "pointer_down") - plot.callbacks.fire(Event("pointer_down")) - assert calls == ["pointer_down"] - - def test_functional_form_multi_type(self): - plot = _FakePlot() - calls = [] - fn = lambda e: calls.append(e.event_type) - plot.add_event_handler(fn, "pointer_down", "pointer_up") - plot.callbacks.fire(Event("pointer_down")) - plot.callbacks.fire(Event("pointer_up")) - assert calls == ["pointer_down", "pointer_up"] - - def test_decorator_form_single_type(self): - plot = _FakePlot() - calls = [] - @plot.add_event_handler("pointer_move") - def handler(e): - calls.append(e.event_type) - plot.callbacks.fire(Event("pointer_move")) - assert calls == ["pointer_move"] - - def test_decorator_form_multi_type(self): - plot = _FakePlot() - calls = [] - @plot.add_event_handler("pointer_down", "key_down") - def handler(e): - calls.append(e.event_type) - plot.callbacks.fire(Event("pointer_down")) - plot.callbacks.fire(Event("key_down")) - assert calls == ["pointer_down", "key_down"] - - def test_wildcard_decorator(self): - plot = _FakePlot() - calls = [] - @plot.add_event_handler("*") - def handler(e): - calls.append(e.event_type) - plot.callbacks.fire(Event("pointer_down")) - plot.callbacks.fire(Event("wheel")) - assert calls == ["pointer_down", "wheel"] - - def test_remove_handler_by_fn(self): - plot = _FakePlot() - calls = [] - fn = lambda e: calls.append(1) - plot.add_event_handler(fn, "pointer_down") - plot.remove_handler(fn) - plot.callbacks.fire(Event("pointer_down")) - assert calls == [] - - def test_remove_handler_by_fn_specific_type(self): - plot = _FakePlot() - calls = [] - fn = lambda e: calls.append(e.event_type) - plot.add_event_handler(fn, "pointer_down", "pointer_up") - plot.remove_handler(fn, "pointer_down") - plot.callbacks.fire(Event("pointer_down")) - plot.callbacks.fire(Event("pointer_up")) - assert calls == ["pointer_up"] - - def test_remove_handler_by_cid(self): - plot = _FakePlot() - calls = [] - cid = plot.callbacks.connect("pointer_down", lambda e: calls.append(1)) - plot.remove_handler(cid) - plot.callbacks.fire(Event("pointer_down")) - assert calls == [] - - def test_pointer_settled_configures_on_connect(self): - plot = _FakePlot() - plot.add_event_handler(lambda e: None, "pointer_settled", ms=400, delta=5) - assert plot._settled_config == (400, 5) - - def test_pointer_settled_clears_on_last_disconnect(self): - plot = _FakePlot() - fn = lambda e: None - plot.add_event_handler(fn, "pointer_settled", ms=400, delta=5) - plot.remove_handler(fn) - assert plot._settled_config == (0, 0) - - def test_ms_delta_without_settled_raises(self): - plot = _FakePlot() - with pytest.raises(ValueError, match="ms/delta"): - plot.add_event_handler(lambda e: None, "pointer_down", ms=400) - - def test_pause_events_delegates_to_registry(self): - plot = _FakePlot() - calls = [] - plot.add_event_handler(lambda e: calls.append(1), "pointer_move") - with plot.pause_events("pointer_move"): - plot.callbacks.fire(Event("pointer_move")) - assert calls == [] - - def test_hold_events_delegates_to_registry(self): - plot = _FakePlot() - calls = [] - plot.add_event_handler(lambda e: calls.append(1), "pointer_settled") - with plot.hold_events("pointer_settled"): - plot.callbacks.fire(Event("pointer_settled")) - assert calls == [] - assert calls == [1] - - -# ── regression: old API is gone ────────────────────────────────────────────── - - -class TestRegressionOldAPIGone: - """Confirm old decorator methods no longer exist on plots and widgets.""" - - def test_plot1d_no_on_click(self): - fig, ax = apl.subplots(1, 1) - plot = ax.plot(np.zeros(10)) - assert not hasattr(plot, "on_click") - - def test_plot1d_no_on_changed(self): - fig, ax = apl.subplots(1, 1) - plot = ax.plot(np.zeros(10)) - assert not hasattr(plot, "on_changed") - - def test_plot1d_no_on_release(self): - fig, ax = apl.subplots(1, 1) - plot = ax.plot(np.zeros(10)) - assert not hasattr(plot, "on_release") - - def test_plot1d_no_on_key(self): - fig, ax = apl.subplots(1, 1) - plot = ax.plot(np.zeros(10)) - assert not hasattr(plot, "on_key") - - def test_plot1d_no_disconnect(self): - fig, ax = apl.subplots(1, 1) - plot = ax.plot(np.zeros(10)) - assert not hasattr(plot, "disconnect") - - def test_plot2d_no_on_click(self): - fig, ax = apl.subplots(1, 1) - plot = ax.imshow(np.zeros((32, 32))) - assert not hasattr(plot, "on_click") - - def test_widget_no_on_changed(self): - fig, ax = apl.subplots(1, 1) - plot = ax.plot(np.zeros(10)) - w = plot.add_vline_widget(5.0) - assert not hasattr(w, "on_changed") - - def test_widget_no_on_release(self): - fig, ax = apl.subplots(1, 1) - plot = ax.plot(np.zeros(10)) - w = plot.add_vline_widget(5.0) - assert not hasattr(w, "on_release") - - def test_event_no_phys_x(self): - from anyplotlib.callbacks import Event - e = Event(event_type="pointer_down", xdata=3.14) - assert not hasattr(e, "phys_x") - assert e.xdata == 3.14 - - def test_plot3d_no_on_click(self): - import numpy as np - x = np.linspace(-2, 2, 10) - XX, YY = np.meshgrid(x, x) - fig, ax = apl.subplots(1, 1) - plot = ax.plot_surface(XX, YY, np.zeros_like(XX)) - assert not hasattr(plot, "on_click") - - def test_plotbar_no_on_click(self): - fig, ax = apl.subplots(1, 1) - plot = ax.bar(["A", "B"], [1.0, 2.0]) - assert not hasattr(plot, "on_click") - - def test_line1d_no_on_hover(self): - fig, ax = apl.subplots(1, 1) - plot = ax.plot(np.zeros(10)) - line = plot.add_line(np.zeros(10)) - assert not hasattr(line, "on_hover") - - def test_line1d_no_on_click(self): - fig, ax = apl.subplots(1, 1) - plot = ax.plot(np.zeros(10)) - line = plot.add_line(np.zeros(10)) - assert not hasattr(line, "on_click") - - -# ── Phase 3 — Figure.close() ────────────────────────────────────────────────── - -class TestFigureClose: - - def test_close_in_valid_event_types(self): - assert "close" in VALID_EVENT_TYPES - - def test_figure_close_sets_closed_flag(self): - fig, ax = apl.subplots(1, 1) - ax.plot(np.zeros(10)) - assert not getattr(fig, "_closed", False) - fig.close() - assert fig._closed is True - - def test_figure_close_fires_event_on_plot(self): - fig, ax = apl.subplots(1, 1) - plot = ax.plot(np.zeros(10)) - received = [] - plot.callbacks.connect("close", lambda e: received.append(e.event_type)) - fig.close() - assert received == ["close"] - - def test_figure_close_fires_on_all_panels(self): - fig, (ax1, ax2) = apl.subplots(1, 2) - p1 = ax1.plot(np.zeros(10)) - p2 = ax2.imshow(np.zeros((8, 8))) - counts = [0, 0] - p1.callbacks.connect("close", lambda e: counts.__setitem__(0, counts[0] + 1)) - p2.callbacks.connect("close", lambda e: counts.__setitem__(1, counts[1] + 1)) - fig.close() - assert counts == [1, 1] - - def test_figure_close_is_idempotent(self): - fig, ax = apl.subplots(1, 1) - plot = ax.plot(np.zeros(10)) - received = [] - plot.callbacks.connect("close", lambda e: received.append(e)) - fig.close() - fig.close() - assert len(received) == 1 diff --git a/pyproject.toml b/pyproject.toml index 36f34829..86bea2d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,12 +66,15 @@ dev = [ ] [tool.pytest.ini_options] -testpaths = ["anyplotlib/tests"] +testpaths = [ + "anyplotlib/tests", + "anyplotlib/sphinx_anywidget/tests", +] addopts = "--cov=anyplotlib --cov-report=xml --cov-report=term-missing" [tool.coverage.run] source = ["anyplotlib"] -omit = ["anyplotlib/tests/*", "Examples/*", "docs/*"] +omit = ["anyplotlib/tests/*", "anyplotlib/sphinx_anywidget/tests/*", "Examples/*", "docs/*"] [tool.coverage.report] exclude_lines = [ From 56b257a0d335cc9d94119fcb13d583e61c4646e8 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sat, 23 May 2026 09:03:35 -0500 Subject: [PATCH 174/198] Refactor: Add standalone pytest configuration and tests for sphinx_anywidget --- anyplotlib/sphinx_anywidget/tests/__init__.py | 0 anyplotlib/sphinx_anywidget/tests/conftest.py | 125 ++++++++ .../sphinx_anywidget/tests/test_directive.py | 220 ++++++++++++++ .../sphinx_anywidget/tests/test_init.py | 251 ++++++++++++++++ .../sphinx_anywidget/tests/test_repr_utils.py | 261 +++++++++++++++++ .../sphinx_anywidget/tests/test_scraper.py | 269 ++++++++++++++++++ .../tests/test_wheel_builder.py | 93 ++++++ 7 files changed, 1219 insertions(+) create mode 100644 anyplotlib/sphinx_anywidget/tests/__init__.py create mode 100644 anyplotlib/sphinx_anywidget/tests/conftest.py create mode 100644 anyplotlib/sphinx_anywidget/tests/test_directive.py create mode 100644 anyplotlib/sphinx_anywidget/tests/test_init.py create mode 100644 anyplotlib/sphinx_anywidget/tests/test_repr_utils.py create mode 100644 anyplotlib/sphinx_anywidget/tests/test_scraper.py create mode 100644 anyplotlib/sphinx_anywidget/tests/test_wheel_builder.py diff --git a/anyplotlib/sphinx_anywidget/tests/__init__.py b/anyplotlib/sphinx_anywidget/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/anyplotlib/sphinx_anywidget/tests/conftest.py b/anyplotlib/sphinx_anywidget/tests/conftest.py new file mode 100644 index 00000000..ff8da296 --- /dev/null +++ b/anyplotlib/sphinx_anywidget/tests/conftest.py @@ -0,0 +1,125 @@ +""" +sphinx_anywidget/tests/conftest.py +==================================== + +Standalone pytest configuration for sphinx_anywidget tests. + +This conftest is designed to be self-contained so that when sphinx_anywidget +is extracted into its own package the tests move with no changes. + +Future standalone package name : sphinx-anywidget +Future dependencies : anywidget, playwright, pytest, numpy +""" +from __future__ import annotations + +import pathlib +import tempfile + +import numpy as np +import pytest + + +# --------------------------------------------------------------------------- +# Figure fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def simple_fig(): + import anyplotlib as apl + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.sin(np.linspace(0, 6.28, 64))) + return fig + + +@pytest.fixture +def imshow_fig(): + import anyplotlib as apl + fig, ax = apl.subplots(1, 1, figsize=(320, 320)) + ax.imshow(np.linspace(0, 1, 64 * 64, dtype=np.float32).reshape(64, 64)) + return fig + + +# --------------------------------------------------------------------------- +# Playwright browser (one Chromium instance for the whole test session) +# --------------------------------------------------------------------------- + +@pytest.fixture +def saw_browser(request): + """Headless Chromium browser for sphinx_anywidget Playwright tests. + + When running inside the combined anyplotlib test suite, reuses the + existing session-scoped ``_pw_browser`` fixture (from + ``anyplotlib/tests/conftest.py``) to avoid spawning a second + ``sync_playwright()`` context — two concurrent contexts fail in one + process. + + When running standalone (future separate package), creates its own + headless Chromium instance. + """ + pytest.importorskip("playwright", reason="playwright not installed") + + try: + # Combined suite path: _pw_browser is session-scoped and getfixturevalue + # initialises it on first access, then reuses it. No second + # sync_playwright() context is opened. + yield request.getfixturevalue("_pw_browser") + return + except pytest.FixtureLookupError: + pass + + # Standalone path: create our own browser. + from playwright.sync_api import sync_playwright + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + yield browser + browser.close() + + +# --------------------------------------------------------------------------- +# HTML page helper +# --------------------------------------------------------------------------- + +@pytest.fixture +def render_widget_page(saw_browser): + """Callable: open a widget's standalone HTML in a headless browser page. + + Returns a ``(page, tmp_path)`` pair. Caller is responsible for closing + the page when done (or use the ``render_page`` fixture instead). + """ + from anyplotlib.sphinx_anywidget._repr_utils import build_standalone_html + + _pages: list = [] + _paths: list = [] + + def _open(widget, *, fig_id="test_fig"): + html = build_standalone_html(widget, resizable=False, fig_id=fig_id) + # Inject readiness sentinel so we can wait for render completion. + html = html.replace( + "renderFn({ model, el });", + "renderFn({ model, el }); window._aplReady = true;", + ) + with tempfile.NamedTemporaryFile( + suffix=".html", mode="w", encoding="utf-8", delete=False + ) as fh: + fh.write(html) + tmp = pathlib.Path(fh.name) + _paths.append(tmp) + + page = saw_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 page in _pages: + try: + page.close() + except Exception: + pass + for path in _paths: + path.unlink(missing_ok=True) diff --git a/anyplotlib/sphinx_anywidget/tests/test_directive.py b/anyplotlib/sphinx_anywidget/tests/test_directive.py new file mode 100644 index 00000000..3d290339 --- /dev/null +++ b/anyplotlib/sphinx_anywidget/tests/test_directive.py @@ -0,0 +1,220 @@ +""" +sphinx_anywidget/tests/test_directive.py +========================================= + +Tests for ``sphinx_anywidget._directive``: + - ``_find_widget`` + - ``AnywidgetFigureDirective`` via a mock Sphinx environment +""" +from __future__ import annotations + +import pathlib +import tempfile +import textwrap + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.sphinx_anywidget._directive import _find_widget + + +# --------------------------------------------------------------------------- +# _find_widget (directive's copy) +# --------------------------------------------------------------------------- + +class TestFindWidgetDirective: + def test_finds_figure(self): + fig, ax = apl.subplots(1, 1) + ax.plot(np.zeros(10)) + found = _find_widget({"fig": fig, "x": 42}) + assert found is fig + + def test_returns_none_for_plain_values(self): + assert _find_widget({"x": 1, "y": "hello"}) is None + + def test_returns_last_widget(self): + fig1, ax1 = apl.subplots(1, 1) + ax1.plot(np.zeros(5)) + fig2, ax2 = apl.subplots(1, 1) + ax2.plot(np.zeros(5)) + found = _find_widget({"a": fig1, "b": fig2}) + assert found is fig2 + + def test_ignores_non_callable_repr_html(self): + class FakeWidget: + _repr_html_ = "not a callable" + _esm = "..." + assert _find_widget({"w": FakeWidget()}) is None + + +# --------------------------------------------------------------------------- +# Minimal Sphinx environment mock +# --------------------------------------------------------------------------- + +class MockConfig: + def __init__(self, confdir): + self.anywidget_pyodide_package = None + self.html_static_path = [] + self._confdir = confdir + + def __getattr__(self, name): + return None + + +class MockEnv: + def __init__(self, confdir, outdir): + self.config = MockConfig(confdir) + self.srcdir = str(confdir) + self.docname = "index" + + class _App: + def __init__(self, confdir, outdir): + self.confdir = str(confdir) + self.outdir = str(outdir) + self.config = MockConfig(confdir) + + self.app = _App(confdir, outdir) + + +class MockReporter: + def error(self, msg, *args, line=None): + from docutils import nodes + return nodes.system_message(msg, level=3, type="ERROR") + + +class MockState: + def __init__(self, confdir, outdir): + self.document = type("doc", (), { + "settings": type("s", (), { + "env": MockEnv(confdir, outdir), + })(), + })() + self.reporter = MockReporter() + + +# --------------------------------------------------------------------------- +# AnywidgetFigureDirective +# --------------------------------------------------------------------------- + +class TestAnywidgetFigureDirective: + def _make_directive(self, src_file: pathlib.Path, confdir, outdir, options=None): + from anyplotlib.sphinx_anywidget._directive import AnywidgetFigureDirective + from docutils.parsers.rst import Directive + + class ConcreteDirective(AnywidgetFigureDirective): + pass + + state = MockState(confdir, outdir) + d = ConcreteDirective.__new__(ConcreteDirective) + d.arguments = [str(src_file.relative_to(confdir))] + d.options = options or {} + d.content = [] + d.lineno = 1 + d.state = state + d.state_machine = None + d.reporter = state.reporter + return d + + def test_missing_file_returns_error_node(self, tmp_path): + confdir = tmp_path / "docs" + confdir.mkdir() + outdir = tmp_path / "out" + outdir.mkdir() + + d = self._make_directive( + confdir / "nonexistent.py", confdir, outdir + ) + result = d.run() + assert len(result) == 1 + assert "system_message" in str(type(result[0])) + + def test_valid_script_returns_raw_node(self, tmp_path): + confdir = tmp_path / "docs" + confdir.mkdir() + outdir = tmp_path / "out" + outdir.mkdir() + + script = confdir / "example.py" + script.write_text(textwrap.dedent("""\ + import numpy as np + import anyplotlib as apl + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.zeros(10)) + """)) + + d = self._make_directive(script, confdir, outdir) + result = d.run() + assert len(result) >= 1 + from docutils import nodes + assert any(isinstance(n, nodes.raw) for n in result) + + def test_no_widget_in_script_returns_error(self, tmp_path): + confdir = tmp_path / "docs" + confdir.mkdir() + outdir = tmp_path / "out" + outdir.mkdir() + + script = confdir / "no_widget.py" + script.write_text("x = 1 + 1\n") + + d = self._make_directive(script, confdir, outdir) + result = d.run() + assert len(result) == 1 + assert "system_message" in str(type(result[0])) + + def test_failing_script_returns_error(self, tmp_path): + confdir = tmp_path / "docs" + confdir.mkdir() + outdir = tmp_path / "out" + outdir.mkdir() + + script = confdir / "broken.py" + script.write_text("raise ValueError('intentional failure')\n") + + d = self._make_directive(script, confdir, outdir) + result = d.run() + assert len(result) == 1 + assert "system_message" in str(type(result[0])) + + def test_interactive_option_embeds_python_src(self, tmp_path): + confdir = tmp_path / "docs" + confdir.mkdir() + outdir = tmp_path / "out" + outdir.mkdir() + + script = confdir / "interactive_example.py" + script.write_text(textwrap.dedent("""\ + import numpy as np + import anyplotlib as apl + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.sin(np.linspace(0, 6.28, 64))) + """)) + + d = self._make_directive(script, confdir, outdir, options={"interactive": None}) + result = d.run() + from docutils import nodes + raw_nodes = [n for n in result if isinstance(n, nodes.raw)] + assert raw_nodes + combined = " ".join(str(n) for n in raw_nodes) + assert "text/x-python" in combined or "awi-activate-btn" in combined + + def test_width_option_respected(self, tmp_path): + confdir = tmp_path / "docs" + confdir.mkdir() + outdir = tmp_path / "out" + outdir.mkdir() + + script = confdir / "wide.py" + script.write_text(textwrap.dedent("""\ + import numpy as np + import anyplotlib as apl + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.zeros(10)) + """)) + + d = self._make_directive(script, confdir, outdir, options={"width": "300"}) + result = d.run() + from docutils import nodes + raw_nodes = [n for n in result if isinstance(n, nodes.raw)] + assert raw_nodes diff --git a/anyplotlib/sphinx_anywidget/tests/test_init.py b/anyplotlib/sphinx_anywidget/tests/test_init.py new file mode 100644 index 00000000..e5d84ecf --- /dev/null +++ b/anyplotlib/sphinx_anywidget/tests/test_init.py @@ -0,0 +1,251 @@ +""" +sphinx_anywidget/tests/test_init.py +===================================== + +Tests for ``sphinx_anywidget.__init__``: + - ``setup()`` + - ``_find_project_root()`` + - ``_infer_package_name()`` + - ``_copy_static_assets()`` + - ``_build_pyodide_wheel()`` + - No stale push hook on the figure module +""" +from __future__ import annotations + +import pathlib +import tempfile +import textwrap + +import pytest + +import anyplotlib.figure as _af +from anyplotlib.sphinx_anywidget import setup +from anyplotlib.sphinx_anywidget import ( + _copy_static_assets, + _build_pyodide_wheel, + _find_project_root, + _infer_package_name, +) + + +# --------------------------------------------------------------------------- +# Helpers / mocks +# --------------------------------------------------------------------------- + +class MockConfig: + def __init__(self, confdir): + self.anywidget_pyodide_package = None + self.html_static_path = [] + self._confdir = str(confdir) + + def __getattr__(self, name): + return None + + +class MockApp: + """Minimal Sphinx application stub.""" + + def __init__(self, confdir, outdir=None): + self.confdir = str(confdir) + self.outdir = str(outdir or confdir / "_build") + self.config = MockConfig(confdir) + self._directives = {} + self._js_files = [] + self._css_files = [] + self._config_values = {} + self._event_handlers = {} + + def add_config_value(self, name, default, rebuild): + self._config_values[name] = default + + def add_directive(self, name, cls): + self._directives[name] = cls + + def connect(self, event, handler): + self._event_handlers.setdefault(event, []).append(handler) + + def add_js_file(self, path, **kwargs): + self._js_files.append(path) + + def add_css_file(self, path, **kwargs): + self._css_files.append(path) + + +# --------------------------------------------------------------------------- +# setup() +# --------------------------------------------------------------------------- + +class TestSetup: + def test_returns_dict_with_version(self, tmp_path): + app = MockApp(tmp_path) + result = setup(app) + assert isinstance(result, dict) + assert "version" in result + + def test_registers_anywidget_figure_directive(self, tmp_path): + app = MockApp(tmp_path) + setup(app) + assert "anywidget-figure" in app._directives + + def test_registers_anywidget_config_value(self, tmp_path): + app = MockApp(tmp_path) + setup(app) + assert "anywidget_pyodide_package" in app._config_values + + def test_adds_bridge_js(self, tmp_path): + app = MockApp(tmp_path) + setup(app) + assert "anywidget_bridge.js" in app._js_files + + def test_adds_config_js(self, tmp_path): + app = MockApp(tmp_path) + setup(app) + assert "anywidget_config.js" in app._js_files + + def test_adds_overlay_css(self, tmp_path): + app = MockApp(tmp_path) + setup(app) + assert "anywidget_overlay.css" in app._css_files + + def test_connects_builder_inited_events(self, tmp_path): + app = MockApp(tmp_path) + setup(app) + assert "builder-inited" in app._event_handlers + assert len(app._event_handlers["builder-inited"]) >= 2 + + def test_parallel_safe_flags(self, tmp_path): + app = MockApp(tmp_path) + result = setup(app) + assert result.get("parallel_read_safe") is True + assert result.get("parallel_write_safe") is True + + +# --------------------------------------------------------------------------- +# _copy_static_assets +# --------------------------------------------------------------------------- + +class TestCopyStaticAssets: + def test_adds_static_src_to_html_static_path(self, tmp_path): + app = MockApp(tmp_path) + _copy_static_assets(app) + assert len(app.config.html_static_path) == 1 + assert pathlib.Path(app.config.html_static_path[0]).is_dir() + + def test_does_not_duplicate_existing_entry(self, tmp_path): + app = MockApp(tmp_path) + _copy_static_assets(app) + _copy_static_assets(app) + assert len(app.config.html_static_path) == 1 + + +# --------------------------------------------------------------------------- +# _build_pyodide_wheel +# --------------------------------------------------------------------------- + +class TestBuildPyodideWheel: + def test_no_package_writes_disabled_config(self, tmp_path): + confdir = tmp_path / "docs" + confdir.mkdir() + app = MockApp(confdir) + _build_pyodide_wheel(app) + config_js = confdir / "_static" / "anywidget_config.js" + assert config_js.exists() + content = config_js.read_text() + assert "null" in content or "Disabled" in content or "disabled" in content + + def test_explicit_package_writes_config_js(self, tmp_path): + confdir = tmp_path / "docs" + confdir.mkdir() + app = MockApp(confdir) + app.config.anywidget_pyodide_package = "mypackage" + _build_pyodide_wheel(app) + config_js = confdir / "_static" / "anywidget_config.js" + assert config_js.exists() + assert "mypackage" in config_js.read_text() + + def test_existing_wheel_skips_rebuild(self, tmp_path): + confdir = tmp_path / "docs" + confdir.mkdir() + app = MockApp(confdir) + app.config.anywidget_pyodide_package = "mypkg" + + wheels_dir = confdir / "_static" / "wheels" + wheels_dir.mkdir(parents=True) + stable = wheels_dir / "mypkg-0.0.0-py3-none-any.whl" + stable.write_bytes(b"dummy wheel content") + mtime_before = stable.stat().st_mtime + + _build_pyodide_wheel(app) + assert stable.stat().st_mtime == mtime_before, "Should not rebuild existing wheel" + + +# --------------------------------------------------------------------------- +# _find_project_root +# --------------------------------------------------------------------------- + +class TestFindProjectRoot: + def test_finds_root_with_pyproject_toml(self, tmp_path): + project = tmp_path / "myproject" + project.mkdir() + (project / "pyproject.toml").write_text('[project]\nname = "mypkg"\n') + docs = project / "docs" + docs.mkdir() + root = _find_project_root(docs) + assert root == project + + def test_finds_root_at_confdir_itself(self, tmp_path): + (tmp_path / "pyproject.toml").write_text('[project]\nname = "mypkg"\n') + root = _find_project_root(tmp_path) + assert root == tmp_path + + def test_fallback_to_parent_when_no_marker(self, tmp_path): + deep = tmp_path / "a" / "b" / "c" + deep.mkdir(parents=True) + root = _find_project_root(deep) + assert root == deep.parent + + def test_finds_setup_py(self, tmp_path): + (tmp_path / "setup.py").write_text("from setuptools import setup; setup()\n") + docs = tmp_path / "docs" + docs.mkdir() + root = _find_project_root(docs) + assert root == tmp_path + + +# --------------------------------------------------------------------------- +# _infer_package_name +# --------------------------------------------------------------------------- + +class TestInferPackageName: + def test_infers_from_pyproject_in_parent(self, tmp_path): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "mylib"\n') + docs = tmp_path / "docs" + docs.mkdir() + app = MockApp(docs) + name = _infer_package_name(app) + assert name == "mylib" + + def test_infers_from_pyproject_in_confdir(self, tmp_path): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "inconfdir"\n') + app = MockApp(tmp_path) + name = _infer_package_name(app) + assert name == "inconfdir" + + def test_returns_none_when_no_pyproject(self, tmp_path): + docs = tmp_path / "docs" + docs.mkdir() + app = MockApp(docs) + name = _infer_package_name(app) + assert name is None + + +# --------------------------------------------------------------------------- +# Regression: no stale push hook +# --------------------------------------------------------------------------- + +def test_no_pyodide_push_hook_on_figure_module(): + assert not hasattr(_af, "_pyodide_push_hook"), ( + "_pyodide_push_hook should have been removed from the figure module" + ) diff --git a/anyplotlib/sphinx_anywidget/tests/test_repr_utils.py b/anyplotlib/sphinx_anywidget/tests/test_repr_utils.py new file mode 100644 index 00000000..8d437354 --- /dev/null +++ b/anyplotlib/sphinx_anywidget/tests/test_repr_utils.py @@ -0,0 +1,261 @@ +""" +sphinx_anywidget/tests/test_repr_utils.py +========================================== + +Tests for ``sphinx_anywidget._repr_utils``: + - ``_widget_state`` — trait serialisation + - ``_widget_px`` — pixel dimension resolution + - ``build_standalone_html`` — self-contained HTML builder +""" +from __future__ import annotations + +import base64 +import json + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.sphinx_anywidget._repr_utils import ( + _widget_px, + _widget_state, + build_standalone_html, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def line_fig(): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + ax.plot(np.sin(np.linspace(0, 6.28, 64))) + return fig + + +@pytest.fixture +def imshow_fig(): + fig, ax = apl.subplots(1, 1, figsize=(320, 240)) + ax.imshow(np.zeros((32, 32), dtype=np.float32)) + return fig + + +@pytest.fixture +def multi_fig(): + fig, axes = apl.subplots(1, 2, figsize=(640, 300)) + axes[0].plot(np.zeros(32)) + axes[1].imshow(np.zeros((16, 16), dtype=np.float32)) + return fig + + +# --------------------------------------------------------------------------- +# _widget_state +# --------------------------------------------------------------------------- + +class TestWidgetState: + def test_returns_dict(self, line_fig): + state = _widget_state(line_fig) + assert isinstance(state, dict) + + def test_no_private_keys(self, line_fig): + state = _widget_state(line_fig) + for key in state: + assert not key.startswith("_"), f"Private key leaked: {key!r}" + + def test_layout_json_present(self, line_fig): + state = _widget_state(line_fig) + assert "layout_json" in state + + def test_bytes_trait_encoded_as_base64_buffer(self): + """bytes/bytearray traits are serialised as {buffer: base64} dicts.""" + import anywidget + import traitlets + + class ByteWidget(anywidget.AnyWidget): + _esm = "export function render({model, el}) {}" + data = traitlets.Bytes(b"\x00\x01\x02", sync=True) + + w = ByteWidget() + state = _widget_state(w) + assert "data" in state + encoded = state["data"] + assert isinstance(encoded, dict) + assert "buffer" in encoded + decoded = base64.b64decode(encoded["buffer"]) + assert decoded == b"\x00\x01\x02" + + def test_bytes_trait_empty_bytes(self): + import anywidget + import traitlets + + class EmptyBytesWidget(anywidget.AnyWidget): + _esm = "export function render({model, el}) {}" + buf = traitlets.Bytes(b"", sync=True) + + w = EmptyBytesWidget() + state = _widget_state(w) + assert isinstance(state["buf"], dict) + assert "buffer" in state["buf"] + assert base64.b64decode(state["buf"]["buffer"]) == b"" + + +# --------------------------------------------------------------------------- +# _widget_px +# --------------------------------------------------------------------------- + +class TestWidgetPx: + def test_figure_adds_padding(self, line_fig): + w, h = _widget_px(line_fig) + assert w == line_fig.fig_width + 16 + assert h == line_fig.fig_height + 16 + + def test_figure_400x300(self, line_fig): + w, h = _widget_px(line_fig) + assert w == 416 + assert h == 316 + + def test_display_override_attributes(self): + import anywidget + import traitlets + + class CustomWidget(anywidget.AnyWidget): + _esm = "export function render({model, el}) {}" + _display_width = 500 + _display_height = 250 + + cw = CustomWidget() + w, h = _widget_px(cw) + assert w == 500 + assert h == 250 + + def test_viewer_width_height_traits(self): + import anywidget + import traitlets + + class ViewerWidget(anywidget.AnyWidget): + _esm = "export function render({model, el}) {}" + viewer_width = traitlets.Int(300, sync=True) + viewer_height = traitlets.Int(200, sync=True) + + vw = ViewerWidget() + w, h = _widget_px(vw) + assert w == 320 + assert h == 220 + + def test_fallback_dimensions(self): + import anywidget + + class MinimalWidget(anywidget.AnyWidget): + _esm = "export function render({model, el}) {}" + + mw = MinimalWidget() + w, h = _widget_px(mw) + assert w == 560 + assert h == 340 + + def test_multi_panel_figure(self, multi_fig): + w, h = _widget_px(multi_fig) + assert w == multi_fig.fig_width + 16 + assert h == multi_fig.fig_height + 16 + + +# --------------------------------------------------------------------------- +# build_standalone_html +# --------------------------------------------------------------------------- + +class TestBuildStandaloneHtml: + def test_returns_string(self, line_fig): + html = build_standalone_html(line_fig, resizable=False, fig_id="t1") + assert isinstance(html, str) + + def test_contains_awi_state_listener(self, line_fig): + html = build_standalone_html(line_fig, resizable=False, fig_id="t1") + assert "awi_state" in html + + def test_contains_fig_id(self, line_fig): + html = build_standalone_html(line_fig, resizable=False, fig_id="myfig") + assert '"myfig"' in html + + def test_html_doctype(self, line_fig): + html = build_standalone_html(line_fig, resizable=False, fig_id="t1") + assert html.strip().startswith("") or " 0 + + def test_imshow_fig_serialises(self, imshow_fig): + html = build_standalone_html(imshow_fig, resizable=False, fig_id="img") + assert "awi_state" in html + + def test_html_is_valid_json_state(self, line_fig): + html = build_standalone_html(line_fig, resizable=False, fig_id="t1") + # Extract STATE JSON — it appears as a JS variable assignment + import re + m = re.search(r"const STATE\s*=\s*(\{.*?\});", html, re.DOTALL) + if m: + data = json.loads(m.group(1)) + assert "layout_json" in data + + +# --------------------------------------------------------------------------- +# Playwright: HTML renders correctly in browser +# --------------------------------------------------------------------------- + +class TestBuildStandaloneHtmlPlaywright: + def test_widget_root_visible(self, render_widget_page, line_fig): + """The rendered page contains a visible #widget-root element.""" + page = render_widget_page(line_fig, fig_id="pw_test") + root = page.locator("#widget-root") + assert root.count() == 1 + + def test_canvas_rendered(self, render_widget_page, line_fig): + """At least one canvas element is present after render.""" + page = render_widget_page(line_fig, fig_id="pw_canvas") + canvas_count = page.evaluate("() => document.querySelectorAll('canvas').length") + assert canvas_count >= 1 + + def test_model_state_accessible(self, saw_browser, line_fig): + """window._aplModel is available and has layout_json set.""" + import pathlib, tempfile + html = build_standalone_html(line_fig, resizable=False, fig_id="pw_model") + html = html.replace( + "renderFn({ model, el });", + "renderFn({ model, el }); window._aplReady = true;", + ).replace( + "const model = makeModel(STATE);", + "const model = makeModel(STATE);\nwindow._aplModel = model;", + ) + with tempfile.NamedTemporaryFile( + suffix=".html", mode="w", encoding="utf-8", delete=False + ) as fh: + fh.write(html) + tmp = pathlib.Path(fh.name) + page = saw_browser.new_page() + try: + page.goto(tmp.as_uri()) + page.wait_for_function("() => window._aplReady === true", timeout=15_000) + has_model = page.evaluate("() => typeof window._aplModel !== 'undefined'") + assert has_model + finally: + page.close() + tmp.unlink(missing_ok=True) diff --git a/anyplotlib/sphinx_anywidget/tests/test_scraper.py b/anyplotlib/sphinx_anywidget/tests/test_scraper.py new file mode 100644 index 00000000..ee410174 --- /dev/null +++ b/anyplotlib/sphinx_anywidget/tests/test_scraper.py @@ -0,0 +1,269 @@ +""" +sphinx_anywidget/tests/test_scraper.py +======================================== + +Tests for ``sphinx_anywidget._scraper``: + - Regex patterns (_INTERACTIVE_RE, _PYODIDE_PACKAGES_RE) + - ``_find_widget`` + - ``_iframe_html`` + - ``_make_thumbnail_png`` (Playwright — skipped if not installed) + - ``AnywidgetScraper`` unit tests +""" +from __future__ import annotations + +import importlib.util +import re + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.sphinx_anywidget._scraper import ( + MAX_DOC_WIDTH, + _INTERACTIVE_RE, + _PYODIDE_PACKAGES_RE, + _find_widget, + _iframe_html, + AnywidgetScraper, + ViewerScraper, +) +from anyplotlib.tests._png_utils import decode_png + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def line_fig(): + fig, ax = apl.subplots(1, 1, figsize=(400, 250)) + ax.plot(np.sin(np.linspace(0, 6.28, 64))) + return fig + + +@pytest.fixture +def imshow_fig(): + fig, ax = apl.subplots(1, 1, figsize=(320, 320)) + ax.imshow(np.linspace(0, 1, 64 * 64, dtype=np.float32).reshape(64, 64)) + return fig + + +@pytest.fixture +def multi_panel_fig(): + fig, axes = apl.subplots(1, 2, figsize=(640, 300)) + axes[0].plot(np.cos(np.linspace(0, 6.28, 64))) + axes[1].imshow(np.random.default_rng(0).uniform(0, 1, (32, 32)).astype(np.float32)) + return fig + + +# --------------------------------------------------------------------------- +# _INTERACTIVE_RE +# --------------------------------------------------------------------------- + +class TestInteractiveRe: + def test_matches_inline_comment(self): + assert _INTERACTIVE_RE.search("fig # Interactive\n") + + def test_matches_lowercase(self): + assert _INTERACTIVE_RE.search("fig # interactive") + + def test_matches_uppercase(self): + assert _INTERACTIVE_RE.search("fig # INTERACTIVE") + + def test_matches_with_extra_whitespace(self): + assert _INTERACTIVE_RE.search("fig # Interactive \n") + + def test_no_false_positive_other_comment(self): + assert not _INTERACTIVE_RE.search("fig # not a match") + + def test_no_false_positive_mid_line(self): + assert not _INTERACTIVE_RE.search("# Interactive is nice") + + def test_matches_at_end_of_multiline_source(self): + src = "import numpy as np\nfig, ax = apl.subplots(1, 1)\nfig # Interactive" + assert _INTERACTIVE_RE.search(src) + + +# --------------------------------------------------------------------------- +# _PYODIDE_PACKAGES_RE +# --------------------------------------------------------------------------- + +class TestPyodidePackagesRe: + def test_matches_simple_list(self): + src = '_PYODIDE_PACKAGES = ["scipy", "pandas"]' + m = _PYODIDE_PACKAGES_RE.search(src) + assert m is not None + import ast + assert ast.literal_eval(m.group(1)) == ["scipy", "pandas"] + + def test_matches_empty_list(self): + src = "_PYODIDE_PACKAGES = []" + m = _PYODIDE_PACKAGES_RE.search(src) + assert m is not None + + def test_no_match_when_absent(self): + src = "import numpy as np\nfig, ax = apl.subplots(1, 1)" + assert _PYODIDE_PACKAGES_RE.search(src) is None + + +# --------------------------------------------------------------------------- +# _find_widget +# --------------------------------------------------------------------------- + +class TestFindWidget: + def test_finds_figure(self, line_fig): + found = _find_widget({"fig": line_fig, "x": 42}) + assert found is line_fig + + def test_returns_none_for_non_widget(self): + assert _find_widget({"x": 42, "y": "hello"}) is None + + def test_returns_last_widget(self, line_fig, imshow_fig): + found = _find_widget({"fig1": line_fig, "fig2": imshow_fig}) + assert found is imshow_fig + + def test_ignores_non_callable_repr_html(self): + class FakeWidget: + _repr_html_ = "not callable" + _esm = "..." + assert _find_widget({"w": FakeWidget()}) is None + + def test_finds_widget_without_esm_by_module(self): + class ModuleWidget: + def _repr_html_(self): + return "
" + def traits(self): + return {} + ModuleWidget.__module__ = "somewidget.core" + found = _find_widget({"w": ModuleWidget()}) + assert found is not None + + +# --------------------------------------------------------------------------- +# _iframe_html +# --------------------------------------------------------------------------- + +class TestIframeHtml: + def test_returns_string(self): + html = _iframe_html("test.html", 400, 300, fig_id="abc") + assert isinstance(html, str) + + def test_contains_iframe_src(self): + html = _iframe_html("test.html", 400, 300, fig_id="abc") + assert 'src="test.html"' in html + + def test_interactive_has_activate_btn(self): + html = _iframe_html("t.html", 400, 300, fig_id="a", interactive=True) + assert "awi-activate-btn" in html + + def test_static_no_activate_btn(self): + html = _iframe_html("t.html", 400, 300, fig_id="a", interactive=False) + assert "awi-activate-btn" not in html + + def test_fig_id_in_output(self): + html = _iframe_html("t.html", 400, 300, fig_id="myfig") + assert "myfig" in html + + def test_auto_uid_when_no_fig_id(self): + html = _iframe_html("t.html", 400, 300) + assert isinstance(html, str) + assert len(html) > 0 + + def test_max_width_respected(self): + html = _iframe_html("t.html", 1000, 500, fig_id="w", max_width=400) + # The wrapper div should have width <= 400px + assert "400px" in html or "width:400px" in html.replace(" ", "") + + def test_default_max_width_is_MAX_DOC_WIDTH(self): + html = _iframe_html("t.html", MAX_DOC_WIDTH + 100, 300, fig_id="w") + assert f"{MAX_DOC_WIDTH}px" in html + + def test_max_height_constrains_scale(self): + html = _iframe_html("t.html", 400, 800, fig_id="h", max_height=200) + assert isinstance(html, str) + + def test_contains_resize_script(self): + html = _iframe_html("t.html", 400, 300, fig_id="rs") + assert "requestAnimationFrame" in html + + def test_no_badge_when_not_interactive(self): + html = _iframe_html("t.html", 400, 300, fig_id="nb", interactive=False) + assert "awi-badge" not in html + + def test_badge_present_when_interactive(self): + html = _iframe_html("t.html", 400, 300, fig_id="bi", interactive=True) + assert "awi-badge" in html + + +# --------------------------------------------------------------------------- +# AnywidgetScraper / ViewerScraper +# --------------------------------------------------------------------------- + +class TestAnywidgetScraper: + def test_repr(self): + s = AnywidgetScraper() + assert repr(s) == "AnywidgetScraper()" + + def test_viewerscraper_is_alias(self): + assert ViewerScraper is AnywidgetScraper + + def test_call_returns_empty_string_when_no_widget(self): + scraper = AnywidgetScraper() + block = ("code", "x = 1") + block_vars = { + "example_globals": {"x": 1}, + "image_path_iterator": iter([]), + "src_file": "test.py", + } + result = scraper(block, block_vars, {}) + assert result == "" + + def test_call_returns_empty_when_globals_empty(self): + scraper = AnywidgetScraper() + block = ("code", "x = 1") + block_vars = { + "example_globals": {}, + "image_path_iterator": iter([]), + "src_file": "test.py", + } + result = scraper(block, block_vars, {}) + assert result == "" + + +# --------------------------------------------------------------------------- +# _make_thumbnail_png — Playwright +# --------------------------------------------------------------------------- + +_has_playwright = importlib.util.find_spec("playwright") is not None + + +@pytest.mark.skipif(not _has_playwright, reason="playwright not installed") +class TestMakeThumbnailPng: + def test_line_fig_returns_valid_png(self, line_fig): + from anyplotlib.sphinx_anywidget._scraper import _make_thumbnail_png + png_bytes = _make_thumbnail_png(line_fig) + assert isinstance(png_bytes, bytes) + assert png_bytes[:8] == b"\x89PNG\r\n\x1a\n", "Not a valid PNG" + + def test_imshow_fig_returns_valid_png(self, imshow_fig): + from anyplotlib.sphinx_anywidget._scraper import _make_thumbnail_png + png_bytes = _make_thumbnail_png(imshow_fig) + arr = decode_png(png_bytes) + assert arr.ndim == 3 + assert arr.shape[2] in (3, 4) + + def test_multi_panel_returns_valid_png(self, multi_panel_fig): + from anyplotlib.sphinx_anywidget._scraper import _make_thumbnail_png + png_bytes = _make_thumbnail_png(multi_panel_fig) + assert isinstance(png_bytes, bytes) + assert len(png_bytes) > 0 + + def test_thumbnail_is_dark_theme(self, line_fig): + from anyplotlib.sphinx_anywidget._scraper import _make_thumbnail_png + png_bytes = _make_thumbnail_png(line_fig) + arr = decode_png(png_bytes) + # Dark theme (#1e1e2e) — top-left pixel should be dark + top_left = arr[0, 0, :3] + assert top_left.sum() < 200, ( + f"Expected dark background pixel, got {top_left}" + ) diff --git a/anyplotlib/sphinx_anywidget/tests/test_wheel_builder.py b/anyplotlib/sphinx_anywidget/tests/test_wheel_builder.py new file mode 100644 index 00000000..b91006de --- /dev/null +++ b/anyplotlib/sphinx_anywidget/tests/test_wheel_builder.py @@ -0,0 +1,93 @@ +""" +sphinx_anywidget/tests/test_wheel_builder.py +============================================= + +Tests for ``sphinx_anywidget._wheel_builder.build_wheel``. +""" +from __future__ import annotations + +import pathlib +import tempfile + +import pytest + +from anyplotlib.sphinx_anywidget._wheel_builder import build_wheel + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _project_root() -> pathlib.Path: + """Return the anyplotlib project root (contains pyproject.toml).""" + here = pathlib.Path(__file__).parent + for candidate in [here, *here.parents]: + if (candidate / "pyproject.toml").exists(): + return candidate + pytest.skip("Could not find project root with pyproject.toml") + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +class TestBuildWheel: + def test_builds_wheel_for_anyplotlib(self): + with tempfile.TemporaryDirectory() as tmp: + static_dir = pathlib.Path(tmp) + result = build_wheel(static_dir, "anyplotlib", _project_root()) + assert result is not None, "build_wheel returned None" + assert result.exists(), f"Wheel file not found: {result}" + assert result.suffix == ".whl" + + def test_wheel_has_stable_name(self): + with tempfile.TemporaryDirectory() as tmp: + static_dir = pathlib.Path(tmp) + result = build_wheel(static_dir, "anyplotlib", _project_root()) + assert result is not None + assert "0.0.0" in result.name, ( + f"Expected 0.0.0 sentinel in wheel name, got {result.name!r}" + ) + + def test_wheel_placed_in_wheels_subdir(self): + with tempfile.TemporaryDirectory() as tmp: + static_dir = pathlib.Path(tmp) + result = build_wheel(static_dir, "anyplotlib", _project_root()) + assert result is not None + assert result.parent.name == "wheels" + + def test_existing_wheel_replaced(self): + with tempfile.TemporaryDirectory() as tmp: + static_dir = pathlib.Path(tmp) + first = build_wheel(static_dir, "anyplotlib", _project_root()) + assert first is not None + first_mtime = first.stat().st_mtime + + second = build_wheel(static_dir, "anyplotlib", _project_root()) + assert second is not None + assert second.exists() + + def test_invalid_project_returns_none(self): + with tempfile.TemporaryDirectory() as tmp: + static_dir = pathlib.Path(tmp) + fake_root = pathlib.Path(tmp) / "nonexistent" + result = build_wheel(static_dir, "nonexistent_pkg_xyz", fake_root) + assert result is None + + def test_wheels_dir_created_if_missing(self): + with tempfile.TemporaryDirectory() as tmp: + static_dir = pathlib.Path(tmp) / "nested" / "static" + static_dir.mkdir(parents=True) + result = build_wheel(static_dir, "anyplotlib", _project_root()) + assert result is not None + assert (static_dir / "wheels").is_dir() + + def test_wheel_version_is_sentinel(self): + """Wheel uses the 0.0.0 sentinel version regardless of the package's actual version.""" + with tempfile.TemporaryDirectory() as tmp: + static_dir = pathlib.Path(tmp) + result = build_wheel(static_dir, "anyplotlib", _project_root()) + if result is not None: + assert "0.0.0" in result.name, ( + f"Expected 0.0.0 sentinel version in wheel name, got {result.name!r}" + ) From 8f1f02cca403a730234e8e34197466fb42421d36 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sat, 23 May 2026 09:43:44 -0500 Subject: [PATCH 175/198] Refactor: Reorganize plot classes to inherit from a shared base class and streamline widget management --- anyplotlib/_base_plot.py | 182 ++++++ anyplotlib/markers.py | 38 +- anyplotlib/plot1d/_plot1d.py | 152 +---- anyplotlib/plot1d/_plotbar.py | 122 +--- anyplotlib/plot2d/_plot2d.py | 261 ++++----- anyplotlib/plot3d/_plot3d.py | 49 +- .../test_callbacks_playwright.py | 491 ++++++++++++++++ .../test_interactive/test_callbacks_unit.py | 528 ++++++++++++++++++ 8 files changed, 1357 insertions(+), 466 deletions(-) create mode 100644 anyplotlib/_base_plot.py create mode 100644 anyplotlib/tests/test_interactive/test_callbacks_playwright.py create mode 100644 anyplotlib/tests/test_interactive/test_callbacks_unit.py diff --git a/anyplotlib/_base_plot.py b/anyplotlib/_base_plot.py new file mode 100644 index 00000000..81137741 --- /dev/null +++ b/anyplotlib/_base_plot.py @@ -0,0 +1,182 @@ +""" +_base_plot.py +============= +Shared base classes and mixins for all plot panel types. +""" + +from __future__ import annotations + +from contextlib import contextmanager + +from anyplotlib.callbacks import _EventMixin + + +class _BasePlot(_EventMixin): + """Universal base for Plot1D, Plot2D, PlotBar, and Plot3D. + + Contains methods identical across all four panel types and helper + utilities used by view-setter and widget-adder methods. + + Subclasses must define: + _state : dict — the panel state dict + _push() -> None — serialize state and write to parent Figure + """ + + def configure_pointer_settled(self, ms: int, delta: float = 4) -> None: + """Configure the pointer-settled event threshold (ms and pixel delta).""" + self._state["pointer_settled_ms"] = ms + self._state["pointer_settled_delta"] = delta + self._push() + + _configure_pointer_settled = configure_pointer_settled + + def set_title(self, label: str) -> None: + self._state["title"] = str(label) + self._push() + + def set_axis_off(self) -> None: + self._state["axis_visible"] = False + self._push() + + def set_axis_on(self) -> None: + self._state["axis_visible"] = True + self._push() + + @contextmanager + def _python_view_push(self): + """Context manager for view setters that must signal _view_from_python. + + Sets the flag on entry, yields for state mutations, then pushes + and clears the flag on exit. + """ + self._state["_view_from_python"] = True + try: + yield + finally: + self._push() + self._state["_view_from_python"] = False + + def _make_widget_push_fn(self, widget): + """Return a targeted-push closure for a widget. + + Replaces the repeated _tp / _targeted_push closures in every + add_*_widget method. + """ + plot_ref, wid_id = self, widget._id + def _push(): + if plot_ref._fig is not None: + fields = {k: v for k, v in widget._data.items() + if k not in ("id", "type")} + plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) + return _push + + +class _PanelMixin: + """Mixin for panels that support interactive widgets and tick control. + + Shared by Plot1D, Plot2D, and PlotBar. Provides _push (with widget + serialization), widget management, and tick visibility control. + + Subclasses must define: + _state : dict + _fig : object + _id : str + _widgets : dict[str, Widget] + """ + + def _push(self) -> None: + if self._fig is None: + return + self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] + self._fig._push(self._id) + + def set_ticks_visible(self, visible: bool, *, x: bool | None = None, + y: bool | None = None) -> None: + if x is None and y is None: + self._state["x_ticks_visible"] = bool(visible) + self._state["y_ticks_visible"] = bool(visible) + else: + if x is not None: + self._state["x_ticks_visible"] = bool(x) + if y is not None: + self._state["y_ticks_visible"] = bool(y) + self._push() + + def get_widget(self, wid): + """Return the Widget object by ID string or Widget instance.""" + from anyplotlib.widgets import Widget + if isinstance(wid, Widget): + wid = wid.id + try: + return self._widgets[wid] + except KeyError: + raise KeyError(wid) + + def remove_widget(self, wid) -> None: + """Remove a widget by ID string or Widget instance.""" + from anyplotlib.widgets import Widget + if isinstance(wid, Widget): + wid = wid.id + if wid not in self._widgets: + raise KeyError(wid) + del self._widgets[wid] + self._push() + + def list_widgets(self) -> list: + """Return a list of all active widget objects on this panel.""" + return list(self._widgets.values()) + + def clear_widgets(self) -> None: + """Remove all interactive overlay widgets from this panel.""" + self._widgets.clear() + self._push() + + +class _MarkerMixin: + """Mixin for panels that support static marker collections. + + Shared by Plot1D and Plot2D. + + Subclasses must define: + _state : dict + markers : MarkerRegistry + _push() -> None + """ + + def _push_markers(self) -> None: + self._state["markers"] = self.markers.to_wire_list() + self._push() + + def _add_marker(self, mtype: str, name, **kwargs): + return self.markers.add(mtype, name, **kwargs) + + def remove_marker(self, marker_type: str, name: str) -> None: + """Remove a named marker collection by type and name. + + Parameters + ---------- + marker_type : str + Collection type, e.g. ``"points"``, ``"vlines"``. + name : str + The name used when the collection was created. + """ + self.markers.remove(marker_type, name) + + def clear_markers(self) -> None: + """Remove all marker collections from this panel.""" + self.markers.clear() + + def list_markers(self) -> list: + """Return a summary list of all marker collections on this panel. + + Returns + ------- + list of dict + Each dict has keys ``"type"``, ``"name"``, and ``"n"`` + (number of markers in the collection). + """ + out = [] + for mtype, td in self.markers._types.items(): + for name, g in td.items(): + out.append({"type": mtype, "name": name, "n": g._count()}) + return out diff --git a/anyplotlib/markers.py b/anyplotlib/markers.py index 3d764a09..d7592e00 100644 --- a/anyplotlib/markers.py +++ b/anyplotlib/markers.py @@ -62,6 +62,14 @@ def _offsets_2d(offsets) -> list: _VALID_TRANSFORMS = frozenset({"data", "axes", "display"}) +def _apply_fill_color(wire: dict, d: dict) -> None: + """Apply facecolors/alpha fill fields to a wire dict if facecolors is set.""" + fc = d.get("facecolors") + if fc is not None: + wire["fill_color"] = fc + wire["fill_alpha"] = float(d.get("alpha", 0.3)) + + def _offsets_1d(offsets) -> list: """Accept (N,), (N,1) or (N,2) — return (N,1) or (N,2) list.""" arr = np.asarray(offsets, dtype=float) @@ -177,10 +185,7 @@ def to_wire(self, group_id: str) -> dict: "color": d.get("edgecolors", "#ff0000"), "linewidth": float(d.get("linewidths", 1.5)), } - fc = d.get("facecolors") - if fc is not None: - wire["fill_color"] = fc - wire["fill_alpha"] = float(d.get("alpha", 0.3)) + _apply_fill_color(wire, d) elif t == "arrows": offsets = _offsets_2d(d["offsets"]) @@ -210,10 +215,7 @@ def to_wire(self, group_id: str) -> dict: "color": d.get("edgecolors", d.get("color", "#ff0000")), "linewidth": float(d.get("linewidths", 1.5)), } - fc = d.get("facecolors") - if fc is not None: - wire["fill_color"] = fc - wire["fill_alpha"] = float(d.get("alpha", 0.3)) + _apply_fill_color(wire, d) elif t == "lines": segs = np.asarray(d["segments"], dtype=float) @@ -244,10 +246,7 @@ def to_wire(self, group_id: str) -> dict: "color": d.get("edgecolors", d.get("color", "#ff0000")), "linewidth": float(d.get("linewidths", 1.5)), } - fc = d.get("facecolors") - if fc is not None: - wire["fill_color"] = fc - wire["fill_alpha"] = float(d.get("alpha", 0.3)) + _apply_fill_color(wire, d) elif t == "squares": offsets = _offsets_2d(d["offsets"]) @@ -262,10 +261,7 @@ def to_wire(self, group_id: str) -> dict: "color": d.get("edgecolors", d.get("color", "#ff0000")), "linewidth": float(d.get("linewidths", 1.5)), } - fc = d.get("facecolors") - if fc is not None: - wire["fill_color"] = fc - wire["fill_alpha"] = float(d.get("alpha", 0.3)) + _apply_fill_color(wire, d) elif t == "polygons": vlist = [] @@ -282,10 +278,7 @@ def to_wire(self, group_id: str) -> dict: "color": d.get("edgecolors", d.get("color", "#ff0000")), "linewidth": float(d.get("linewidths", 1.5)), } - fc = d.get("facecolors") - if fc is not None: - wire["fill_color"] = fc - wire["fill_alpha"] = float(d.get("alpha", 0.3)) + _apply_fill_color(wire, d) elif t == "texts": offsets = _offsets_2d(d["offsets"]) @@ -313,10 +306,7 @@ def to_wire(self, group_id: str) -> dict: "color": d.get("edgecolors", d.get("color", "#ff0000")), "linewidth": float(d.get("linewidths", 1.5)), } - fc = d.get("facecolors") - if fc is not None: - wire["fill_color"] = fc - wire["fill_alpha"] = float(d.get("alpha", 0.3)) + _apply_fill_color(wire, d) elif t == "vlines": offsets = _offsets_1d(d["offsets"]) diff --git a/anyplotlib/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py index 2ef35c99..43075fea 100644 --- a/anyplotlib/plot1d/_plot1d.py +++ b/anyplotlib/plot1d/_plot1d.py @@ -11,8 +11,9 @@ import numpy as np from typing import Callable +from anyplotlib._base_plot import _BasePlot, _PanelMixin, _MarkerMixin from anyplotlib.markers import MarkerRegistry -from anyplotlib.callbacks import CallbackRegistry, _EventMixin +from anyplotlib.callbacks import CallbackRegistry from anyplotlib.widgets import ( Widget, VLineWidget as _VLineWidget, @@ -147,7 +148,7 @@ def remove(self) -> None: # Plot1D # --------------------------------------------------------------------------- -class Plot1D(_EventMixin): +class Plot1D(_BasePlot, _PanelMixin, _MarkerMixin): """1-D line plot panel returned by :meth:`Axes.plot`. All display state is stored in a plain ``_state`` dict. Every mutation @@ -308,24 +309,6 @@ def __init__(self, data: np.ndarray, self.callbacks = CallbackRegistry() self._widgets: dict[str, Widget] = {} - def configure_pointer_settled(self, ms: int, delta: float = 4) -> None: - """Configure the pointer-settled event threshold (ms and pixel delta).""" - self._state["pointer_settled_ms"] = ms - self._state["pointer_settled_delta"] = delta - self._push() - - _configure_pointer_settled = configure_pointer_settled # backward compat - - def _push(self) -> None: - if self._fig is None: - return - self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] - self._fig._push(self._id) - - def _push_markers(self) -> None: - self._state["markers"] = self.markers.to_wire_list() - self._push() - def to_state_dict(self) -> dict: d = dict(self._state) # Replace numpy arrays with b64-encoded strings for the wire format. @@ -615,12 +598,7 @@ def add_vline_widget(self, x: float, color: str = "#00e5ff") -> _VLineWidget: :meth:`on_changed` / :meth:`on_release`. """ widget = _VLineWidget(lambda: None, x=float(x), color=color) - plot_ref, wid_id = self, widget._id - def _tp(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _tp + widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget self._push() return widget @@ -642,12 +620,7 @@ def add_hline_widget(self, y: float, color: str = "#00e5ff") -> _HLineWidget: :meth:`on_changed` / :meth:`on_release`. """ widget = _HLineWidget(lambda: None, y=float(y), color=color) - plot_ref, wid_id = self, widget._id - def _tp(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _tp + widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget self._push() return widget @@ -685,12 +658,7 @@ def add_range_widget(self, x0: float, x1: float, """ widget = _RangeWidget(lambda: None, x0=float(x0), x1=float(x1), color=color, style=style, y=float(y)) - plot_ref, wid_id = self, widget._id - def _tp(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _tp + widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget if _push: self._push() @@ -723,44 +691,12 @@ def add_point_widget(self, x: float, y: float, """ widget = _PointWidget(lambda: None, x=float(x), y=float(y), color=color, show_crosshair=show_crosshair) - plot_ref, wid_id = self, widget._id - def _tp_point(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _tp_point + widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget if _push: self._push() return widget - def get_widget(self, wid) -> Widget: - """Return the Widget object by ID string or Widget instance.""" - if isinstance(wid, Widget): - wid = wid.id - try: - return self._widgets[wid] - except KeyError: - raise KeyError(wid) - - def remove_widget(self, wid) -> None: - """Remove a widget by ID string or Widget instance.""" - if isinstance(wid, Widget): - wid = wid.id - if wid not in self._widgets: - raise KeyError(wid) - del self._widgets[wid] - self._push() - - def list_widgets(self) -> list: - """Return a list of all active widget objects on this panel.""" - return list(self._widgets.values()) - - def clear_widgets(self) -> None: - """Remove all interactive overlay widgets from this panel.""" - self._widgets.clear() - self._push() - # ------------------------------------------------------------------ # View control # ------------------------------------------------------------------ @@ -783,19 +719,15 @@ def set_view(self, x0: float | None = None, x1: float | None = None) -> None: span = xmax - xmin or 1.0 f0 = 0.0 if x0 is None else max(0.0, min(1.0, (float(x0)-xmin)/span)) f1 = 1.0 if x1 is None else max(0.0, min(1.0, (float(x1)-xmin)/span)) - self._state["view_x0"] = f0 - self._state["view_x1"] = f1 - self._state["_view_from_python"] = True - self._push() - self._state["_view_from_python"] = False + with self._python_view_push(): + self._state["view_x0"] = f0 + self._state["view_x1"] = f1 def reset_view(self) -> None: """Reset the view to show the full x range of the primary line.""" - self._state["view_x0"] = 0.0 - self._state["view_x1"] = 1.0 - self._state["_view_from_python"] = True - self._push() - self._state["_view_from_python"] = False + with self._python_view_push(): + self._state["view_x0"] = 0.0 + self._state["view_x1"] = 1.0 # ------------------------------------------------------------------ # Primary-line property setters @@ -882,10 +814,6 @@ def set_ylabel(self, label: str) -> None: self._state["y_units"] = str(label) self._push() - def set_title(self, label: str) -> None: - self._state["title"] = str(label) - self._push() - def set_yscale(self, scale: str) -> None: """Set the y-axis scale: ``'linear'`` or ``'log'``.""" if scale not in ("linear", "log"): @@ -920,32 +848,9 @@ def get_xbound(self) -> tuple: xarr = np.asarray(self._state["x_axis"]) return (float(xarr.min()), float(xarr.max())) - def set_axis_off(self) -> None: - self._state["axis_visible"] = False - self._push() - - def set_axis_on(self) -> None: - self._state["axis_visible"] = True - self._push() - - def set_ticks_visible(self, visible: bool, *, x: bool | None = None, - y: bool | None = None) -> None: - if x is None and y is None: - self._state["x_ticks_visible"] = bool(visible) - self._state["y_ticks_visible"] = bool(visible) - else: - if x is not None: - self._state["x_ticks_visible"] = bool(x) - if y is not None: - self._state["y_ticks_visible"] = bool(y) - self._push() - # ------------------------------------------------------------------ # Marker API (matplotlib-style kwargs → MarkerRegistry) # ------------------------------------------------------------------ - def _add_marker(self, mtype: str, name: str | None, **kwargs) -> "MarkerGroup": # noqa: F821 - return self.markers.add(mtype, name, **kwargs) - def add_circles(self, offsets, name=None, *, radius=5, facecolors=None, edgecolors="#ff0000", linewidths=1.5, alpha=0.3, @@ -1392,37 +1297,6 @@ def add_texts(self, offsets, texts, name=None, *, labels=labels, label=label, transform=transform) - def remove_marker(self, marker_type: str, name: str) -> None: - """Remove a named marker collection by type and name. - - Parameters - ---------- - marker_type : str - Collection type, e.g. ``"points"``, ``"vlines"``. - name : str - The name used when the collection was created. - """ - self.markers.remove(marker_type, name) - - def clear_markers(self) -> None: - """Remove all marker collections from this panel.""" - self.markers.clear() - - def list_markers(self) -> list: - """Return a summary list of all marker collections on this panel. - - Returns - ------- - list of dict - Each dict has keys ``"type"``, ``"name"``, and ``"n"`` - (number of markers in the collection). - """ - out = [] - for mtype, td in self.markers._types.items(): - for name, g in td.items(): - out.append({"type": mtype, "name": name, "n": g._count()}) - return out - def __repr__(self) -> str: n = len(self._state.get("data", [])) color = self._state.get("line_color", "?") diff --git a/anyplotlib/plot1d/_plotbar.py b/anyplotlib/plot1d/_plotbar.py index f4f87832..921e8729 100644 --- a/anyplotlib/plot1d/_plotbar.py +++ b/anyplotlib/plot1d/_plotbar.py @@ -9,7 +9,8 @@ import numpy as np from typing import Callable -from anyplotlib.callbacks import CallbackRegistry, _EventMixin +from anyplotlib._base_plot import _BasePlot, _PanelMixin +from anyplotlib.callbacks import CallbackRegistry from anyplotlib.widgets import ( Widget, VLineWidget as _VLineWidget, @@ -75,7 +76,7 @@ def _bar_range(flat: np.ndarray, bottom: float, log_scale: bool): return dmin, dmax -class PlotBar(_EventMixin): +class PlotBar(_BasePlot, _PanelMixin): """Bar-chart plot panel. Not an anywidget. Holds state in ``_state`` dict; every mutation calls @@ -138,9 +139,6 @@ def __init__(self, x, height=None, width: float = 0.8, bottom: float = 0.0, *, ) n = values_2d.shape[0] - if orient not in ("v", "h"): - raise ValueError("orient must be 'v' or 'h'") - # ── x (positions or labels) ──────────────────────────────────── _x_labels: list = [] _x_centers: np.ndarray | None = None @@ -218,21 +216,6 @@ def __init__(self, x, height=None, width: float = 0.8, bottom: float = 0.0, *, self.callbacks = CallbackRegistry() self._widgets: dict[str, Widget] = {} - def configure_pointer_settled(self, ms: int, delta: float = 4) -> None: - """Configure the pointer-settled event threshold (ms and pixel delta).""" - self._state["pointer_settled_ms"] = ms - self._state["pointer_settled_delta"] = delta - self._push() - - _configure_pointer_settled = configure_pointer_settled # backward compat - - # ------------------------------------------------------------------ - def _push(self) -> None: - if self._fig is None: - return - self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] - self._fig._push(self._id) - def to_state_dict(self) -> dict: d = dict(self._state) d["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] @@ -326,11 +309,6 @@ def set_log_scale(self, log_scale: bool) -> None: # ------------------------------------------------------------------ # Display control # ------------------------------------------------------------------ - def set_title(self, label: str) -> None: - """Set the panel title.""" - self._state["title"] = str(label) - self._push() - def set_xlabel(self, label: str) -> None: """Set the x-axis label.""" self._state["x_label"] = str(label) @@ -365,29 +343,6 @@ def set_group_labels(self, labels) -> None: self._state["group_labels"] = list(labels) self._push() - def set_axis_off(self) -> None: - """Hide axes, ticks, and labels.""" - self._state["axis_visible"] = False - self._push() - - def set_axis_on(self) -> None: - """Show axes, ticks, and labels.""" - self._state["axis_visible"] = True - self._push() - - def set_ticks_visible(self, visible: bool, *, x: bool | None = None, - y: bool | None = None) -> None: - """Show or hide x/y tick marks independently.""" - if x is None and y is None: - self._state["x_ticks_visible"] = bool(visible) - self._state["y_ticks_visible"] = bool(visible) - else: - if x is not None: - self._state["x_ticks_visible"] = bool(x) - if y is not None: - self._state["y_ticks_visible"] = bool(y) - self._push() - # ------------------------------------------------------------------ # View (xlim / ylim) # ------------------------------------------------------------------ @@ -397,11 +352,9 @@ def set_xlim(self, xmin: float, xmax: float) -> None: span = x_axis[1] - x_axis[0] if span == 0: return - self._state["view_x0"] = (xmin - x_axis[0]) / span - self._state["view_x1"] = (xmax - x_axis[0]) / span - self._state["_view_from_python"] = True - self._push() - self._state["_view_from_python"] = False + with self._python_view_push(): + self._state["view_x0"] = (xmin - x_axis[0]) / span + self._state["view_x1"] = (xmax - x_axis[0]) / span def set_ylim(self, y_min: float, y_max: float) -> None: """Fix the value-axis range to [y_min, y_max].""" @@ -425,12 +378,10 @@ def get_xlim(self) -> tuple: def reset_view(self) -> None: """Reset pan/zoom to show all bars.""" - self._state["view_x0"] = 0.0 - self._state["view_x1"] = 1.0 - self._state["y_range"] = None - self._state["_view_from_python"] = True - self._push() - self._state["_view_from_python"] = False + with self._python_view_push(): + self._state["view_x0"] = 0.0 + self._state["view_x1"] = 1.0 + self._state["y_range"] = None # ------------------------------------------------------------------ # Overlay Widgets @@ -438,12 +389,7 @@ def reset_view(self) -> None: def add_vline_widget(self, x: float, color: str = "#00e5ff") -> _VLineWidget: """Add a draggable vertical line at data position *x*.""" widget = _VLineWidget(lambda: None, x=float(x), color=color) - plot_ref, wid_id = self, widget._id - def _tp(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _tp + widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget self._push() return widget @@ -451,12 +397,7 @@ def _tp(): def add_hline_widget(self, y: float, color: str = "#00e5ff") -> _HLineWidget: """Add a draggable horizontal line at value-axis position *y*.""" widget = _HLineWidget(lambda: None, y=float(y), color=color) - plot_ref, wid_id = self, widget._id - def _tp(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _tp + widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget self._push() return widget @@ -469,12 +410,7 @@ def add_range_widget(self, x0: float, x1: float, """Add a draggable range overlay. See :meth:`Plot1D.add_range_widget` for full docs.""" widget = _RangeWidget(lambda: None, x0=float(x0), x1=float(x1), color=color, style=style, y=float(y)) - plot_ref, wid_id = self, widget._id - def _tp(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _tp + widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget if _push: self._push() @@ -487,42 +423,12 @@ def add_point_widget(self, x: float, y: float, """Add a freely-draggable control point to this panel.""" widget = _PointWidget(lambda: None, x=float(x), y=float(y), color=color, show_crosshair=show_crosshair) - plot_ref, wid_id = self, widget._id - def _tp(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _tp + widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget if _push: self._push() return widget - def get_widget(self, wid) -> Widget: - """Return the Widget object by ID string or Widget instance.""" - if isinstance(wid, Widget): - wid = wid.id - try: - return self._widgets[wid] - except KeyError: - raise KeyError(wid) - - def remove_widget(self, wid) -> None: - """Remove a widget by ID string or Widget instance.""" - if isinstance(wid, Widget): - wid = wid.id - if wid not in self._widgets: - raise KeyError(wid) - del self._widgets[wid] - self._push() - - def list_widgets(self) -> list: - return list(self._widgets.values()) - - def clear_widgets(self) -> None: - self._widgets.clear() - self._push() - def __repr__(self) -> str: n = len(self._state.get("values", [])) orient = self._state.get("orient", "v") diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index 28e6030a..3be47e6a 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -9,8 +9,9 @@ import numpy as np from typing import Callable +from anyplotlib._base_plot import _BasePlot, _PanelMixin, _MarkerMixin from anyplotlib.markers import MarkerRegistry -from anyplotlib.callbacks import CallbackRegistry, _EventMixin +from anyplotlib.callbacks import CallbackRegistry from anyplotlib.widgets import ( Widget, RectangleWidget, CircleWidget, AnnularWidget, @@ -19,7 +20,7 @@ from anyplotlib._utils import _normalize_image, _build_colormap_lut -class Plot2D(_EventMixin): +class Plot2D(_BasePlot, _PanelMixin, _MarkerMixin): """2-D image plot panel. Not an anywidget. Holds state in ``_state`` dict; every mutation calls @@ -143,31 +144,11 @@ def __init__(self, data: np.ndarray, self.callbacks = CallbackRegistry() self._widgets: dict[str, Widget] = {} - def configure_pointer_settled(self, ms: int, delta: float = 4) -> None: - """Configure the pointer-settled event threshold (ms and pixel delta).""" - self._state["pointer_settled_ms"] = ms - self._state["pointer_settled_delta"] = delta - self._push() - - _configure_pointer_settled = configure_pointer_settled # backward compat - @staticmethod def _encode_bytes(arr: np.ndarray) -> str: import base64 return base64.b64encode(arr.tobytes()).decode("ascii") - def _push(self) -> None: - """Serialise _state + markers and write to Figure trait.""" - if self._fig is None: - return - self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] - self._fig._push(self._id) - - def _push_markers(self) -> None: - """Called by MarkerRegistry whenever markers change.""" - self._state["markers"] = self.markers.to_wire_list() - self._push() - def to_state_dict(self) -> dict: """Return a JSON-serialisable copy of the current state.""" d = dict(self._state) @@ -326,10 +307,6 @@ def set_ylabel(self, label: str) -> None: self._state["y_label"] = str(label) self._push() - def set_title(self, label: str) -> None: - self._state["title"] = str(label) - self._push() - def set_xlim(self, xmin: float, xmax: float) -> None: self.set_view(x0=xmin, x1=xmax) @@ -375,107 +352,113 @@ def set_aspect(self, ratio) -> None: self._state["aspect"] = float(ratio) if ratio is not None else None self._push() - def set_axis_off(self) -> None: - self._state["axis_visible"] = False - self._push() - - def set_axis_on(self) -> None: - self._state["axis_visible"] = True - self._push() - - def set_ticks_visible(self, visible: bool, *, x: bool | None = None, - y: bool | None = None) -> None: - if x is None and y is None: - self._state["x_ticks_visible"] = bool(visible) - self._state["y_ticks_visible"] = bool(visible) - else: - if x is not None: - self._state["x_ticks_visible"] = bool(x) - if y is not None: - self._state["y_ticks_visible"] = bool(y) - self._push() - # ------------------------------------------------------------------ # Overlay Widgets # ------------------------------------------------------------------ def add_widget(self, kind: str, color: str = "#00e5ff", **kwargs) -> Widget: - kind = kind.lower() - valid = ("circle", "rectangle", "annular", "polygon", "label", "crosshair") - if kind not in valid: - raise ValueError(f"kind must be one of {valid}") + """Add an overlay widget by kind name. + + Dispatches to the dedicated ``add__widget`` method. + Supported kinds: ``"circle"``, ``"rectangle"``, ``"annular"``, + ``"polygon"``, ``"crosshair"``, ``"label"``. + """ + dispatch = { + "circle": self.add_circle_widget, + "rectangle": self.add_rectangle_widget, + "annular": self.add_annular_widget, + "polygon": self.add_polygon_widget, + "crosshair": self.add_crosshair_widget, + "label": self.add_label_widget, + } + key = kind.lower() + if key not in dispatch: + raise ValueError(f"kind must be one of {tuple(dispatch)}") + return dispatch[key](color=color, **kwargs) + + def add_circle_widget(self, cx: float | None = None, cy: float | None = None, + r: float | None = None, color: str = "#00e5ff") -> CircleWidget: + """Add a draggable circle overlay.""" iw, ih = self._state["image_width"], self._state["image_height"] + widget = CircleWidget(lambda: None, + cx=float(cx) if cx is not None else iw / 2, + cy=float(cy) if cy is not None else ih / 2, + r=float(r) if r is not None else iw * 0.1, + color=color) + widget._push_fn = self._make_widget_push_fn(widget) + self._widgets[widget.id] = widget + self._push() + return widget - def _f(k, default): return float(kwargs.get(k, default)) - def _i(k, default): return int(kwargs.get(k, default)) - - if kind == "circle": - widget = CircleWidget(lambda: None, - cx=_f("cx", iw / 2), cy=_f("cy", ih / 2), - r=_f("r", iw * 0.1), color=color) - elif kind == "rectangle": - widget = RectangleWidget(lambda: None, - x=_f("x", iw * 0.25), y=_f("y", ih * 0.25), - w=_f("w", iw * 0.5), h=_f("h", ih * 0.5), - color=color) - elif kind == "annular": - r_outer = _f("r_outer", iw * 0.2) - r_inner = _f("r_inner", iw * 0.1) - widget = AnnularWidget(lambda: None, - cx=_f("cx", iw / 2), cy=_f("cy", ih / 2), - r_outer=r_outer, r_inner=r_inner, color=color) - elif kind == "polygon": - raw = kwargs.get("vertices", [[iw * .25, ih * .25], [iw * .75, ih * .25], - [iw * .75, ih * .75], [iw * .25, ih * .75]]) - widget = PolygonWidget(lambda: None, vertices=raw, color=color) - elif kind == "crosshair": - widget = CrosshairWidget(lambda: None, - cx=_f("cx", iw / 2), cy=_f("cy", ih / 2), - color=color) - else: # label - widget = LabelWidget(lambda: None, - x=_f("x", iw * 0.1), y=_f("y", ih * 0.1), - text=str(kwargs.get("text", "Label")), - fontsize=_i("fontsize", 14), color=color) - - # Replace the temporary push_fn with a targeted one now that - # we have both the widget's _id and the plot's _id. - plot_ref = self - wid_id = widget._id - def _targeted_push(): - if plot_ref._fig is not None: - fields = {k: v for k, v in widget._data.items() - if k not in ("id", "type")} - plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) - widget._push_fn = _targeted_push + def add_rectangle_widget(self, x: float | None = None, y: float | None = None, + w: float | None = None, h: float | None = None, + color: str = "#00e5ff") -> RectangleWidget: + """Add a draggable rectangle overlay.""" + iw, ih = self._state["image_width"], self._state["image_height"] + widget = RectangleWidget(lambda: None, + x=float(x) if x is not None else iw * 0.25, + y=float(y) if y is not None else ih * 0.25, + w=float(w) if w is not None else iw * 0.5, + h=float(h) if h is not None else ih * 0.5, + color=color) + widget._push_fn = self._make_widget_push_fn(widget) + self._widgets[widget.id] = widget + self._push() + return widget + def add_annular_widget(self, cx: float | None = None, cy: float | None = None, + r_outer: float | None = None, r_inner: float | None = None, + color: str = "#00e5ff") -> AnnularWidget: + """Add a draggable annular (ring) overlay.""" + iw, ih = self._state["image_width"], self._state["image_height"] + widget = AnnularWidget(lambda: None, + cx=float(cx) if cx is not None else iw / 2, + cy=float(cy) if cy is not None else ih / 2, + r_outer=float(r_outer) if r_outer is not None else iw * 0.2, + r_inner=float(r_inner) if r_inner is not None else iw * 0.1, + color=color) + widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget - self._push() # full panel push once so JS knows about the widget + self._push() return widget - def get_widget(self, wid) -> Widget: - """Return the Widget object by ID string or Widget instance.""" - if isinstance(wid, Widget): - wid = wid.id - try: - return self._widgets[wid] - except KeyError: - raise KeyError(wid) - - def remove_widget(self, wid) -> None: - """Remove a widget by ID string or Widget instance.""" - if isinstance(wid, Widget): - wid = wid.id - if wid not in self._widgets: - raise KeyError(wid) - del self._widgets[wid] + def add_polygon_widget(self, vertices=None, color: str = "#00e5ff") -> PolygonWidget: + """Add a draggable polygon overlay.""" + iw, ih = self._state["image_width"], self._state["image_height"] + if vertices is None: + vertices = [[iw * .25, ih * .25], [iw * .75, ih * .25], + [iw * .75, ih * .75], [iw * .25, ih * .75]] + widget = PolygonWidget(lambda: None, vertices=vertices, color=color) + widget._push_fn = self._make_widget_push_fn(widget) + self._widgets[widget.id] = widget self._push() + return widget - def list_widgets(self) -> list: - return list(self._widgets.values()) + def add_crosshair_widget(self, cx: float | None = None, cy: float | None = None, + color: str = "#00e5ff") -> CrosshairWidget: + """Add a draggable crosshair overlay.""" + iw, ih = self._state["image_width"], self._state["image_height"] + widget = CrosshairWidget(lambda: None, + cx=float(cx) if cx is not None else iw / 2, + cy=float(cy) if cy is not None else ih / 2, + color=color) + widget._push_fn = self._make_widget_push_fn(widget) + self._widgets[widget.id] = widget + self._push() + return widget - def clear_widgets(self) -> None: - self._widgets.clear() + def add_label_widget(self, x: float | None = None, y: float | None = None, + text: str = "Label", fontsize: int = 14, + color: str = "#00e5ff") -> LabelWidget: + """Add a draggable text label overlay.""" + iw, ih = self._state["image_width"], self._state["image_height"] + widget = LabelWidget(lambda: None, + x=float(x) if x is not None else iw * 0.1, + y=float(y) if y is not None else ih * 0.1, + text=str(text), fontsize=int(fontsize), color=color) + widget._push_fn = self._make_widget_push_fn(widget) + self._widgets[widget.id] = widget self._push() + return widget # ------------------------------------------------------------------ # View control @@ -523,27 +506,20 @@ def set_view(self, self._state["center_y"] = (fy0 + fy1) / 2.0 zoom_candidates.append(1.0 / (fy1 - fy0)) - if zoom_candidates: - self._state["zoom"] = min(zoom_candidates) - self._state["_view_from_python"] = True - self._push() - self._state["_view_from_python"] = False + with self._python_view_push(): + if zoom_candidates: + self._state["zoom"] = min(zoom_candidates) def reset_view(self) -> None: """Reset pan and zoom to show the full image.""" - self._state["zoom"] = 1.0 - self._state["center_x"] = 0.5 - self._state["center_y"] = 0.5 - self._state["_view_from_python"] = True - self._push() - self._state["_view_from_python"] = False + with self._python_view_push(): + self._state["zoom"] = 1.0 + self._state["center_x"] = 0.5 + self._state["center_y"] = 0.5 # ------------------------------------------------------------------ # Marker API (matplotlib-style kwargs → MarkerRegistry) # ------------------------------------------------------------------ - def _add_marker(self, mtype: str, name: str | None, **kwargs) -> "MarkerGroup": # noqa: F821 - return self.markers.add(mtype, name, **kwargs) - def add_circles(self, offsets, name=None, *, radius=5, facecolors=None, edgecolors="#ff0000", linewidths=1.5, alpha=0.3, @@ -690,37 +666,6 @@ def add_texts(self, offsets, texts, name=None, *, labels=labels, label=label, transform=transform) - def remove_marker(self, marker_type: str, name: str) -> None: - """Remove a named marker collection by type and name. - - Parameters - ---------- - marker_type : str - Collection type, e.g. ``"points"``, ``"vlines"``. - name : str - The name used when the collection was created. - """ - self.markers.remove(marker_type, name) - - def clear_markers(self) -> None: - """Remove all marker collections from this panel.""" - self.markers.clear() - - def list_markers(self) -> list: - """Return a summary list of all marker collections on this panel. - - Returns - ------- - list of dict - Each dict has keys ``"type"``, ``"name"``, and ``"n"`` - (number of markers in the collection). - """ - out = [] - for mtype, td in self.markers._types.items(): - for name, g in td.items(): - out.append({"type": mtype, "name": name, "n": g._count()}) - return out - def __repr__(self) -> str: w = self._state.get("image_width", "?") h = self._state.get("image_height", "?") diff --git a/anyplotlib/plot3d/_plot3d.py b/anyplotlib/plot3d/_plot3d.py index dc9d4b53..54bba257 100644 --- a/anyplotlib/plot3d/_plot3d.py +++ b/anyplotlib/plot3d/_plot3d.py @@ -10,7 +10,8 @@ import numpy as np -from anyplotlib.callbacks import CallbackRegistry, _EventMixin +from anyplotlib._base_plot import _BasePlot +from anyplotlib.callbacks import CallbackRegistry from anyplotlib._utils import _arr_to_b64, _build_colormap_lut @@ -25,7 +26,7 @@ def _triangulate_grid(rows: int, cols: int) -> list: return faces -class Plot3D(_EventMixin): +class Plot3D(_BasePlot): """3-D plot panel. Supports three geometry types matching matplotlib's 3-D Axes API: @@ -136,14 +137,6 @@ def __init__(self, geom_type: str, } self.callbacks = CallbackRegistry() - def configure_pointer_settled(self, ms: int, delta: float = 4) -> None: - """Configure the pointer-settled event threshold (ms and pixel delta).""" - self._state["pointer_settled_ms"] = ms - self._state["pointer_settled_delta"] = delta - self._push() - - _configure_pointer_settled = configure_pointer_settled # backward compat - # ------------------------------------------------------------------ def _push(self) -> None: if self._fig is None: @@ -165,38 +158,20 @@ def set_colormap(self, name: str) -> None: def set_view(self, azimuth: float | None = None, elevation: float | None = None) -> None: """Set the camera azimuth (°) and/or elevation (°).""" - if azimuth is not None: self._state["azimuth"] = float(azimuth) - if elevation is not None: self._state["elevation"] = float(elevation) - self._state["_view_from_python"] = True - self._push() - self._state["_view_from_python"] = False + with self._python_view_push(): + if azimuth is not None: self._state["azimuth"] = float(azimuth) + if elevation is not None: self._state["elevation"] = float(elevation) def set_zoom(self, zoom: float) -> None: - self._state["zoom"] = float(zoom) - self._state["_view_from_python"] = True - self._push() - self._state["_view_from_python"] = False + with self._python_view_push(): + self._state["zoom"] = float(zoom) def reset_view(self) -> None: """Restore the camera to the angles/zoom set at construction time.""" - self._state["azimuth"] = self._state["_default_azimuth"] - self._state["elevation"] = self._state["_default_elevation"] - self._state["zoom"] = self._state["_default_zoom"] - self._state["_view_from_python"] = True - self._push() - self._state["_view_from_python"] = False - - def set_axis_off(self) -> None: - self._state["axis_visible"] = False - self._push() - - def set_axis_on(self) -> None: - self._state["axis_visible"] = True - self._push() - - def set_title(self, label: str) -> None: - self._state["title"] = str(label) - self._push() + with self._python_view_push(): + self._state["azimuth"] = self._state["_default_azimuth"] + self._state["elevation"] = self._state["_default_elevation"] + self._state["zoom"] = self._state["_default_zoom"] def set_xlabel(self, label: str) -> None: self._state["x_label"] = str(label) diff --git a/anyplotlib/tests/test_interactive/test_callbacks_playwright.py b/anyplotlib/tests/test_interactive/test_callbacks_playwright.py new file mode 100644 index 00000000..6920f467 --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_callbacks_playwright.py @@ -0,0 +1,491 @@ +""" +tests/test_interactive/test_callbacks_playwright.py +==================================================== + +Playwright integration tests for the callback system. + +Each test exercises the full JS → Python dispatch pipeline: + 1. ``interact_page(fig)`` opens the standalone HTML in headless Chromium. + 2. ``_collect_events(page)`` intercepts every ``event_json`` write on the + JS model shim so we can verify the browser emitted the right payload. + 3. ``page.mouse.*`` / ``page.keyboard.*`` fires real browser events. + 4. ``_sim(fig, plot, event_type, ...)`` replays the same payload through + ``fig._dispatch_event`` to verify the Python handler receives it. + +Because the standalone HTML has no live Python kernel, steps 3 and 4 are +independent but complementary: step 3 confirms JS sends the event; step 4 +confirms Python processes it. + +Coordinate system (mirrors figure_esm.js constants) +---------------------------------------------------- + PAD_L=58 PAD_R=12 PAD_T=12 PAD_B=42 GRID_PAD=8 + 400×300 figure → plot area page-coords: x≈66, y≈20, w≈330, h≈246 +""" +from __future__ import annotations + +import json + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.tests.test_interactive._event_test_utils import ( + _collect_events, + _get_events, + _plot_center_page, + GRID_PAD, + PAD_L, PAD_R, PAD_T, PAD_B, +) + +FIG_W, FIG_H = 400, 300 + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + +def _sim(fig, plot, event_type: str, **fields) -> None: + """Drive the Python dispatch path directly (no browser needed).""" + payload = {"source": "js", "panel_id": plot._id, "event_type": event_type} + payload.update(fields) + fig._dispatch_event(json.dumps(payload)) + + +def _make_1d(interact_page): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.plot(np.sin(np.linspace(0, 6.28, 128))) + page = interact_page(fig) + _collect_events(page) + return fig, plot, page + + +def _make_2d(interact_page): + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + page = interact_page(fig) + _collect_events(page) + return fig, plot, page + + +def _center(): + return _plot_center_page(FIG_W, FIG_H) + + +def _plot_left_edge(): + """Page x-coordinate of the left edge of the plot area.""" + return GRID_PAD + PAD_L + 5 + + +def _plot_top_edge(): + """Page y-coordinate of the top edge of the plot area.""" + return GRID_PAD + PAD_T + 5 + + +def _outside_plot(): + """Page coords clearly outside the plot area (title bar region).""" + return GRID_PAD + 10, GRID_PAD + 5 + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 1. Event types — JS emission verified with Playwright +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestEventTypesJsEmission: + """Verify each event type is emitted by the JS engine on real interactions.""" + + def test_pointer_down_emitted(self, interact_page): + _, _, page = _make_2d(interact_page) + cx, cy = _center() + page.mouse.move(cx, cy) + page.mouse.down() + page.wait_for_timeout(80) + page.mouse.up() + events = _get_events(page, "pointer_down") + assert len(events) >= 1, "pointer_down should be emitted on click" + + def test_pointer_up_emitted(self, interact_page): + # pointer_up fires on significant drag release (not a plain click). + _, _, page = _make_2d(interact_page) + cx, cy = _center() + page.mouse.move(cx, cy) + page.mouse.down() + page.mouse.move(cx + 50, cy, steps=10) + page.mouse.up() + page.wait_for_timeout(100) + events = _get_events(page, "pointer_up") + assert len(events) >= 1, "pointer_up should be emitted after a drag release" + + def test_pointer_move_emitted(self, interact_page): + # pointer_move fires on every mousemove over a 3D panel. + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + x = np.linspace(-1, 1, 8) + X, Y = np.meshgrid(x, x) + plot = ax.plot_surface(X, Y, X ** 2 + Y ** 2) + page = interact_page(fig) + _collect_events(page) + cx, cy = _center() + page.mouse.move(cx, cy) + page.mouse.down() + page.mouse.move(cx + 30, cy, steps=8) + page.mouse.up() + page.wait_for_timeout(50) + events = _get_events(page, "pointer_move") + assert len(events) > 0, "pointer_move events should fire during 3D drag" + + def test_double_click_emitted(self, interact_page): + _, _, page = _make_2d(interact_page) + cx, cy = _center() + page.mouse.dblclick(cx, cy) + page.wait_for_timeout(100) + events = _get_events(page, "double_click") + assert len(events) >= 1, "double_click should be emitted on dblclick" + + def test_wheel_emitted(self, interact_page): + _, _, page = _make_2d(interact_page) + cx, cy = _center() + page.mouse.move(cx, cy) + page.mouse.wheel(0, -100) + page.wait_for_timeout(80) + events = _get_events(page, "wheel") + assert len(events) >= 1, "wheel event should be emitted on scroll" + + def test_key_down_emitted(self, interact_page): + _, _, page = _make_2d(interact_page) + cx, cy = _center() + page.mouse.move(cx, cy) + page.mouse.down() + page.mouse.up() + page.wait_for_timeout(50) + page.keyboard.press("r") + page.wait_for_timeout(80) + events = _get_events(page, "key_down") + assert len(events) >= 1, "key_down should be emitted on key press" + + def test_pointer_enter_emitted(self, interact_page): + _, _, page = _make_2d(interact_page) + ox, oy = _outside_plot() + px = _plot_left_edge() + py = _plot_top_edge() + page.mouse.move(ox, oy) + page.wait_for_timeout(30) + page.mouse.move(px, py, steps=5) + page.wait_for_timeout(80) + events = _get_events(page, "pointer_enter") + assert len(events) >= 1, "pointer_enter should fire when mouse enters plot area" + + def test_pointer_leave_emitted(self, interact_page): + _, _, page = _make_2d(interact_page) + cx, cy = _center() + page.mouse.move(cx, cy) + page.wait_for_timeout(30) + ox, oy = _outside_plot() + page.mouse.move(ox, oy, steps=5) + page.wait_for_timeout(80) + events = _get_events(page, "pointer_leave") + assert len(events) >= 1, "pointer_leave should fire when mouse exits plot area" + + def test_pointer_down_has_required_fields(self, interact_page): + _, _, page = _make_2d(interact_page) + cx, cy = _center() + page.mouse.click(cx, cy) + page.wait_for_timeout(100) + events = _get_events(page, "pointer_down") + assert events, "No pointer_down events collected" + e = events[0] + for field in ("event_type", "x", "y", "button", "buttons", "modifiers"): + assert field in e, f"pointer_down missing field {field!r}" + + def test_pointer_down_has_xdata_ydata(self, interact_page): + _, _, page = _make_2d(interact_page) + cx, cy = _center() + page.mouse.click(cx, cy) + page.wait_for_timeout(100) + events = _get_events(page, "pointer_down") + assert events + e = events[0] + assert "xdata" in e and "ydata" in e, "2D pointer_down should carry xdata/ydata" + + def test_wheel_has_dx_dy_fields(self, interact_page): + _, _, page = _make_2d(interact_page) + cx, cy = _center() + page.mouse.move(cx, cy) + page.mouse.wheel(0, -120) + page.wait_for_timeout(80) + events = _get_events(page, "wheel") + assert events + e = events[0] + assert "dy" in e or "dx" in e, "wheel event should carry dx or dy" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 2. Python dispatch — via _sim + real Python handlers +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestPythonDispatch: + """Verify Python callback machinery processes dispatched events correctly.""" + + def test_pointer_down_calls_handler(self, interact_page): + fig, plot, page = _make_2d(interact_page) + received = [] + plot.add_event_handler(lambda e: received.append(e.event_type), "pointer_down") + _sim(fig, plot, "pointer_down", x=200, y=150, xdata=16.0, ydata=16.0) + assert received == ["pointer_down"] + + def test_pointer_move_calls_handler(self, interact_page): + fig, plot, page = _make_2d(interact_page) + received = [] + plot.add_event_handler(lambda e: received.append(e.xdata), "pointer_move") + _sim(fig, plot, "pointer_move", x=200, y=150, xdata=8.0, ydata=8.0) + assert received == [8.0] + + def test_double_click_calls_handler(self, interact_page): + fig, plot, page = _make_2d(interact_page) + received = [] + plot.add_event_handler(lambda e: received.append(True), "double_click") + _sim(fig, plot, "double_click", x=200, y=150) + assert received == [True] + + def test_wheel_calls_handler(self, interact_page): + fig, plot, page = _make_2d(interact_page) + received = [] + plot.add_event_handler(lambda e: received.append(e.dy), "wheel") + _sim(fig, plot, "wheel", dx=0.0, dy=-1.0) + assert received == [-1.0] + + def test_key_down_calls_handler(self, interact_page): + fig, plot, page = _make_2d(interact_page) + received = [] + plot.add_event_handler(lambda e: received.append(e.key), "key_down") + _sim(fig, plot, "key_down", key="r") + assert received == ["r"] + + def test_wildcard_handler_receives_all_event_types(self, interact_page): + fig, plot, page = _make_2d(interact_page) + received = [] + plot.add_event_handler(lambda e: received.append(e.event_type), "*") + for etype in ("pointer_down", "pointer_up", "pointer_move", "wheel"): + _sim(fig, plot, etype, x=100, y=100) + assert received == ["pointer_down", "pointer_up", "pointer_move", "wheel"] + + def test_priority_order_respected(self, interact_page): + fig, plot, page = _make_2d(interact_page) + order = [] + plot.add_event_handler( + lambda e: order.append("low"), "pointer_down", order=1 + ) + plot.add_event_handler( + lambda e: order.append("high"), "pointer_down", order=0 + ) + _sim(fig, plot, "pointer_down", x=100, y=100) + assert order == ["high", "low"] + + def test_stop_propagation_halts_chain(self, interact_page): + fig, plot, page = _make_2d(interact_page) + called = [] + + def first(e): + called.append("first") + e.stop_propagation = True + + plot.add_event_handler(first, "pointer_down", order=0) + plot.add_event_handler(lambda e: called.append("second"), "pointer_down", order=1) + _sim(fig, plot, "pointer_down", x=100, y=100) + assert called == ["first"] + + def test_disconnect_stops_delivery(self, interact_page): + fig, plot, page = _make_2d(interact_page) + received = [] + fn = lambda e: received.append(1) + plot.add_event_handler(fn, "pointer_down") + plot.remove_handler(fn) + _sim(fig, plot, "pointer_down", x=100, y=100) + assert received == [] + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 3. pause_events — JS emission + Python dispatch combined +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestPauseEventsPlaywright: + """pause_events drops events in the Python callback layer.""" + + def test_pause_suppresses_pointer_move_handler(self, interact_page): + """JS fires pointer_move; Python handler does not receive it while paused.""" + fig, plot, page = _make_2d(interact_page) + received = [] + plot.add_event_handler(lambda e: received.append(1), "pointer_move") + + with plot.pause_events("pointer_move"): + cx, cy = _center() + page.mouse.move(cx, cy) + page.mouse.move(cx + 20, cy, steps=5) + page.wait_for_timeout(50) + # JS events are sent to model; Python dispatch is paused + _sim(fig, plot, "pointer_move", x=200, y=150) + _sim(fig, plot, "pointer_move", x=210, y=150) + + assert received == [], ( + "pause_events should prevent handler from firing during the context" + ) + + def test_pause_allows_other_types_through(self, interact_page): + fig, plot, page = _make_2d(interact_page) + move_received = [] + down_received = [] + plot.add_event_handler(lambda e: move_received.append(1), "pointer_move") + plot.add_event_handler(lambda e: down_received.append(1), "pointer_down") + + with plot.pause_events("pointer_move"): + _sim(fig, plot, "pointer_move", x=100, y=100) + _sim(fig, plot, "pointer_down", x=100, y=100) + + assert move_received == [] + assert down_received == [1] + + def test_events_resume_after_pause_context(self, interact_page): + fig, plot, page = _make_2d(interact_page) + received = [] + plot.add_event_handler(lambda e: received.append(1), "pointer_move") + + with plot.pause_events("pointer_move"): + _sim(fig, plot, "pointer_move", x=100, y=100) + + _sim(fig, plot, "pointer_move", x=110, y=100) + assert received == [1], "Handler should fire after pause context exits" + + def test_js_still_emits_events_during_pause(self, interact_page): + """The browser still emits events during Python pause — only dispatch is suppressed. + + Uses a 3D panel because pointer_move fires on every mousemove there. + """ + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + x = np.linspace(-1, 1, 8) + X, Y = np.meshgrid(x, x) + plot = ax.plot_surface(X, Y, X ** 2 + Y ** 2) + page = interact_page(fig) + _collect_events(page) + + with plot.pause_events("pointer_move"): + cx, cy = _center() + page.mouse.move(cx, cy) + page.mouse.down() + page.mouse.move(cx + 40, cy, steps=8) + page.mouse.up() + page.wait_for_timeout(50) + + js_events = _get_events(page, "pointer_move") + assert len(js_events) > 0, ( + "JS should still emit pointer_move even while Python pause is active" + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 4. hold_events — buffers and flushes on context exit +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestHoldEventsPlaywright: + """hold_events buffers Python callbacks and flushes them on context exit.""" + + def test_hold_buffers_during_context(self, interact_page): + fig, plot, page = _make_2d(interact_page) + received = [] + plot.add_event_handler( + lambda e: received.append(e.dwell_ms), + "pointer_settled", + ms=50, + delta=2, + ) + + with plot.hold_events("pointer_settled"): + _sim(fig, plot, "pointer_settled", x=200, y=150, dwell_ms=100.0) + _sim(fig, plot, "pointer_settled", x=205, y=150, dwell_ms=110.0) + assert received == [], "Buffered events should not fire inside hold context" + + assert len(received) == 2, "Both buffered events should flush on exit" + + def test_hold_flush_preserves_order(self, interact_page): + fig, plot, page = _make_2d(interact_page) + order = [] + plot.add_event_handler( + lambda e: order.append(e.x), + "pointer_settled", + ms=50, + ) + + with plot.hold_events("pointer_settled"): + for x in (10, 20, 30, 40): + _sim(fig, plot, "pointer_settled", x=x, y=100, dwell_ms=60.0) + + assert order == [10, 20, 30, 40] + + def test_hold_non_held_type_fires_immediately(self, interact_page): + fig, plot, page = _make_2d(interact_page) + move_calls = [] + settled_calls = [] + plot.add_event_handler(lambda e: move_calls.append(1), "pointer_move") + plot.add_event_handler( + lambda e: settled_calls.append(1), "pointer_settled", ms=50 + ) + + with plot.hold_events("pointer_settled"): + _sim(fig, plot, "pointer_move", x=100, y=100) + _sim(fig, plot, "pointer_settled", x=100, y=100, dwell_ms=60.0) + assert move_calls == [1], "pointer_move not held — should fire immediately" + assert settled_calls == [], "pointer_settled should still be buffered" + + assert settled_calls == [1] + + def test_pause_inside_hold_drops_not_buffers(self, interact_page): + """An event that matches both hold and pause: pause wins, event is dropped.""" + fig, plot, page = _make_2d(interact_page) + received = [] + plot.add_event_handler(lambda e: received.append(1), "pointer_move") + + with plot.hold_events("pointer_move"): + with plot.pause_events("pointer_move"): + _sim(fig, plot, "pointer_move", x=100, y=100) + + assert received == [], "pause inside hold should drop the event entirely" + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 5. pointer_settled — real dwell detection via Playwright +# ═══════════════════════════════════════════════════════════════════════════════ + +class TestPointerSettledPlaywright: + def test_pointer_settled_fires_after_dwell(self, interact_page): + """After the mouse stops moving, pointer_settled is emitted by JS. + + The handler must be registered BEFORE interact_page() so the settled + dwell config (ms/delta) is baked into the serialised state that the + standalone HTML page loads. + """ + fig, ax = apl.subplots(1, 1, figsize=(FIG_W, FIG_H)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + plot.add_event_handler(lambda e: None, "pointer_settled", ms=100, delta=2) + page = interact_page(fig) + _collect_events(page) + + cx, cy = _center() + page.mouse.move(cx, cy) + page.wait_for_timeout(400) + + events = _get_events(page, "pointer_settled") + assert len(events) >= 1, "pointer_settled should fire after dwell timeout" + + def test_pointer_settled_not_fired_on_rapid_movement(self, interact_page): + """Continuous rapid movement suppresses pointer_settled.""" + fig, plot, page = _make_2d(interact_page) + plot.add_event_handler(lambda e: None, "pointer_settled", ms=300, delta=2) + + cx, cy = _center() + for _ in range(8): + page.mouse.move(cx, cy) + page.mouse.move(cx + 60, cy, steps=4) + page.mouse.move(cx, cy, steps=4) + page.wait_for_timeout(100) + + events = _get_events(page, "pointer_settled") + assert len(events) == 0, ( + "pointer_settled should not fire during continuous rapid movement" + ) diff --git a/anyplotlib/tests/test_interactive/test_callbacks_unit.py b/anyplotlib/tests/test_interactive/test_callbacks_unit.py new file mode 100644 index 00000000..9d53fdf9 --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_callbacks_unit.py @@ -0,0 +1,528 @@ +""" +tests/test_interactive/test_callbacks_unit.py +============================================== + +Pure-Python unit tests for the callback system. No browser required. + +These tests cover: + - ``Event`` dataclass fields and defaults + - ``CallbackRegistry`` connect / disconnect / fire / priority / wildcards + - ``pause_events`` / ``hold_events`` context-manager semantics + - ``_EventMixin`` registration, decoration, and removal API + - Regression: old callback API is gone from all plot types + - ``fig.close()`` fires the ``close`` event on every panel +""" +from __future__ import annotations + +import time + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.callbacks import Event, CallbackRegistry, VALID_EVENT_TYPES, _EventMixin + + +# ── Event dataclass ─────────────────────────────────────────────────────────── + +class TestEvent: + def test_required_fields(self): + e = Event(event_type="pointer_down", source=None) + assert e.event_type == "pointer_down" + assert e.source is None + + def test_time_stamp_auto_set(self): + before = time.perf_counter() + e = Event(event_type="pointer_down") + after = time.perf_counter() + assert before <= e.time_stamp <= after + + def test_modifiers_default_empty_list(self): + e = Event(event_type="pointer_move") + assert e.modifiers == [] + assert isinstance(e.modifiers, list) + + def test_pointer_fields_default_none(self): + e = Event(event_type="pointer_move") + assert e.x is None + assert e.y is None + assert e.button is None + assert e.buttons == 0 + assert e.xdata is None + assert e.ydata is None + assert e.ray is None + assert e.line_id is None + assert e.dwell_ms is None + + def test_wheel_fields_default_none(self): + e = Event(event_type="wheel") + assert e.dx is None + assert e.dy is None + + def test_key_field_default_none(self): + e = Event(event_type="key_down") + assert e.key is None + + def test_bar_fields_default_none(self): + e = Event(event_type="pointer_down") + assert e.bar_index is None + assert e.value is None + assert e.x_label is None + assert e.group_index is None + + def test_stop_propagation_default_false(self): + e = Event(event_type="pointer_down") + assert e.stop_propagation is False + + def test_all_fields_settable(self): + e = Event( + event_type="pointer_down", + source="plot", + modifiers=["ctrl", "shift"], + x=100, y=200, + button=0, buttons=1, + xdata=3.14, ydata=2.71, + line_id="abc12345", + bar_index=2, value=99.5, x_label="Jan", group_index=1, + dx=10.0, dy=-5.0, + key="q", + ) + assert e.modifiers == ["ctrl", "shift"] + assert e.x == 100 + assert e.xdata == 3.14 + assert e.line_id == "abc12345" + assert e.bar_index == 2 + assert e.key == "q" + assert e.dx == 10.0 + assert e.dy == -5.0 + + def test_no_data_dict_attribute(self): + e = Event(event_type="pointer_move") + assert not hasattr(e, "data") + + def test_repr_includes_event_type(self): + e = Event(event_type="pointer_down", x=10, y=20) + assert "pointer_down" in repr(e) + + def test_stop_propagation_not_in_repr(self): + e = Event(event_type="pointer_down", stop_propagation=True) + assert "stop_propagation" not in repr(e) + + +# ── CallbackRegistry ────────────────────────────────────────────────────────── + +class TestCallbackRegistry: + def test_connect_returns_int_cid(self): + reg = CallbackRegistry() + cid = reg.connect("pointer_down", lambda e: None) + assert isinstance(cid, int) + + def test_fire_calls_handler(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_down", lambda e: calls.append(e.event_type)) + reg.fire(Event("pointer_down")) + assert calls == ["pointer_down"] + + def test_fire_only_matching_type(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_down", lambda e: calls.append("down")) + reg.connect("pointer_up", lambda e: calls.append("up")) + reg.fire(Event("pointer_down")) + assert calls == ["down"] + + def test_disconnect_by_cid(self): + reg = CallbackRegistry() + calls = [] + cid = reg.connect("pointer_down", lambda e: calls.append(1)) + reg.disconnect(cid) + reg.fire(Event("pointer_down")) + assert calls == [] + + def test_disconnect_silent_if_not_found(self): + reg = CallbackRegistry() + reg.disconnect(999) # should not raise + + def test_wildcard_receives_all_types(self): + reg = CallbackRegistry() + calls = [] + reg.connect("*", lambda e: calls.append(e.event_type)) + reg.fire(Event("pointer_down")) + reg.fire(Event("key_down")) + reg.fire(Event("wheel")) + assert calls == ["pointer_down", "key_down", "wheel"] + + def test_priority_order(self): + reg = CallbackRegistry() + order = [] + reg.connect("pointer_down", lambda e: order.append("second"), order=1) + reg.connect("pointer_down", lambda e: order.append("first"), order=0) + reg.fire(Event("pointer_down")) + assert order == ["first", "second"] + + def test_same_priority_fires_in_registration_order(self): + reg = CallbackRegistry() + order = [] + reg.connect("pointer_down", lambda e: order.append("a"), order=0) + reg.connect("pointer_down", lambda e: order.append("b"), order=0) + reg.fire(Event("pointer_down")) + assert order == ["a", "b"] + + def test_stop_propagation(self): + reg = CallbackRegistry() + calls = [] + def handler_a(e): + calls.append("a") + e.stop_propagation = True + reg.connect("pointer_down", handler_a, order=0) + reg.connect("pointer_down", lambda e: calls.append("b"), order=1) + reg.fire(Event("pointer_down")) + assert calls == ["a"] + + def test_disconnect_fn_by_reference(self): + reg = CallbackRegistry() + calls = [] + fn = lambda e: calls.append(1) + reg.connect("pointer_down", fn) + reg.disconnect_fn(fn) + reg.fire(Event("pointer_down")) + assert calls == [] + + def test_disconnect_fn_specific_type(self): + reg = CallbackRegistry() + calls = [] + fn = lambda e: calls.append(e.event_type) + reg.connect("pointer_down", fn) + reg.connect("pointer_up", fn) + reg.disconnect_fn(fn, "pointer_down") + reg.fire(Event("pointer_down")) + reg.fire(Event("pointer_up")) + assert calls == ["pointer_up"] + + def test_bool_true_when_handlers_present(self): + reg = CallbackRegistry() + assert not bool(reg) + reg.connect("pointer_down", lambda e: None) + assert bool(reg) + + def test_invalid_event_type_raises(self): + reg = CallbackRegistry() + with pytest.raises(ValueError, match="Invalid event_type"): + reg.connect("on_click", lambda e: None) + + def test_connect_same_fn_multiple_types(self): + reg = CallbackRegistry() + calls = [] + fn = lambda e: calls.append(e.event_type) + reg.connect("pointer_down", fn) + reg.connect("pointer_up", fn) + reg.fire(Event("pointer_down")) + reg.fire(Event("pointer_up")) + assert calls == ["pointer_down", "pointer_up"] + + +# ── pause_events / hold_events ──────────────────────────────────────────────── + +class TestPauseHold: + def test_pause_drops_events(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_move", lambda e: calls.append(1)) + with reg.pause_events("pointer_move"): + reg.fire(Event("pointer_move")) + assert calls == [] + + def test_pause_handlers_intact_after_exit(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_move", lambda e: calls.append(1)) + with reg.pause_events("pointer_move"): + reg.fire(Event("pointer_move")) + reg.fire(Event("pointer_move")) + assert calls == [1] + + def test_pause_all_types_when_no_args(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_down", lambda e: calls.append("down")) + reg.connect("key_down", lambda e: calls.append("key")) + with reg.pause_events(): + reg.fire(Event("pointer_down")) + reg.fire(Event("key_down")) + assert calls == [] + + def test_pause_only_specified_type(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_move", lambda e: calls.append("move")) + reg.connect("pointer_down", lambda e: calls.append("down")) + with reg.pause_events("pointer_move"): + reg.fire(Event("pointer_move")) + reg.fire(Event("pointer_down")) + assert calls == ["down"] + + def test_pause_nested_same_type(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_move", lambda e: calls.append(1)) + with reg.pause_events("pointer_move"): + with reg.pause_events("pointer_move"): + reg.fire(Event("pointer_move")) + reg.fire(Event("pointer_move")) # still paused + reg.fire(Event("pointer_move")) # now fires + assert calls == [1] + + def test_hold_buffers_and_flushes_on_exit(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_settled", lambda e: calls.append(1)) + with reg.hold_events("pointer_settled"): + reg.fire(Event("pointer_settled")) + reg.fire(Event("pointer_settled")) + assert calls == [] + assert calls == [1, 1] + + def test_hold_fires_non_held_types_immediately(self): + reg = CallbackRegistry() + move_calls = [] + settled_calls = [] + reg.connect("pointer_move", lambda e: move_calls.append(1)) + reg.connect("pointer_settled", lambda e: settled_calls.append(1)) + with reg.hold_events("pointer_settled"): + reg.fire(Event("pointer_move")) + reg.fire(Event("pointer_settled")) + assert move_calls == [1] + assert settled_calls == [1] + + def test_hold_events_in_order(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_settled", lambda e: calls.append(e.x)) + with reg.hold_events(): + reg.fire(Event("pointer_settled", x=1)) + reg.fire(Event("pointer_settled", x=2)) + reg.fire(Event("pointer_settled", x=3)) + assert calls == [1, 2, 3] + + def test_pause_wins_over_hold(self): + reg = CallbackRegistry() + calls = [] + reg.connect("pointer_move", lambda e: calls.append(1)) + with reg.hold_events("pointer_move"): + with reg.pause_events("pointer_move"): + reg.fire(Event("pointer_move")) + assert calls == [] + + +# ── _EventMixin ─────────────────────────────────────────────────────────────── + +class _FakePlot(_EventMixin): + def __init__(self): + self.callbacks = CallbackRegistry() + self._settled_config = (0, 0) + + def _configure_pointer_settled(self, ms: int, delta: float) -> None: + self._settled_config = (ms, delta) + + +class TestEventMixin: + def test_functional_form_single_type(self): + plot = _FakePlot() + calls = [] + plot.add_event_handler(lambda e: calls.append(e.event_type), "pointer_down") + plot.callbacks.fire(Event("pointer_down")) + assert calls == ["pointer_down"] + + def test_functional_form_multi_type(self): + plot = _FakePlot() + calls = [] + fn = lambda e: calls.append(e.event_type) + plot.add_event_handler(fn, "pointer_down", "pointer_up") + plot.callbacks.fire(Event("pointer_down")) + plot.callbacks.fire(Event("pointer_up")) + assert calls == ["pointer_down", "pointer_up"] + + def test_decorator_form_single_type(self): + plot = _FakePlot() + calls = [] + @plot.add_event_handler("pointer_move") + def handler(e): + calls.append(e.event_type) + plot.callbacks.fire(Event("pointer_move")) + assert calls == ["pointer_move"] + + def test_decorator_form_multi_type(self): + plot = _FakePlot() + calls = [] + @plot.add_event_handler("pointer_down", "key_down") + def handler(e): + calls.append(e.event_type) + plot.callbacks.fire(Event("pointer_down")) + plot.callbacks.fire(Event("key_down")) + assert calls == ["pointer_down", "key_down"] + + def test_wildcard_decorator(self): + plot = _FakePlot() + calls = [] + @plot.add_event_handler("*") + def handler(e): + calls.append(e.event_type) + plot.callbacks.fire(Event("pointer_down")) + plot.callbacks.fire(Event("wheel")) + assert calls == ["pointer_down", "wheel"] + + def test_remove_handler_by_fn(self): + plot = _FakePlot() + calls = [] + fn = lambda e: calls.append(1) + plot.add_event_handler(fn, "pointer_down") + plot.remove_handler(fn) + plot.callbacks.fire(Event("pointer_down")) + assert calls == [] + + def test_remove_handler_by_fn_specific_type(self): + plot = _FakePlot() + calls = [] + fn = lambda e: calls.append(e.event_type) + plot.add_event_handler(fn, "pointer_down", "pointer_up") + plot.remove_handler(fn, "pointer_down") + plot.callbacks.fire(Event("pointer_down")) + plot.callbacks.fire(Event("pointer_up")) + assert calls == ["pointer_up"] + + def test_remove_handler_by_cid(self): + plot = _FakePlot() + calls = [] + cid = plot.callbacks.connect("pointer_down", lambda e: calls.append(1)) + plot.remove_handler(cid) + plot.callbacks.fire(Event("pointer_down")) + assert calls == [] + + def test_pointer_settled_configures_on_connect(self): + plot = _FakePlot() + plot.add_event_handler(lambda e: None, "pointer_settled", ms=400, delta=5) + assert plot._settled_config == (400, 5) + + def test_pointer_settled_clears_on_last_disconnect(self): + plot = _FakePlot() + fn = lambda e: None + plot.add_event_handler(fn, "pointer_settled", ms=400, delta=5) + plot.remove_handler(fn) + assert plot._settled_config == (0, 0) + + def test_ms_delta_without_settled_raises(self): + plot = _FakePlot() + with pytest.raises(ValueError, match="ms/delta"): + plot.add_event_handler(lambda e: None, "pointer_down", ms=400) + + def test_pause_events_delegates_to_registry(self): + plot = _FakePlot() + calls = [] + plot.add_event_handler(lambda e: calls.append(1), "pointer_move") + with plot.pause_events("pointer_move"): + plot.callbacks.fire(Event("pointer_move")) + assert calls == [] + + def test_hold_events_delegates_to_registry(self): + plot = _FakePlot() + calls = [] + plot.add_event_handler(lambda e: calls.append(1), "pointer_settled") + with plot.hold_events("pointer_settled"): + plot.callbacks.fire(Event("pointer_settled")) + assert calls == [] + assert calls == [1] + + +# ── Regression: old API is gone ────────────────────────────────────────────── + +class TestRegressionOldAPIGone: + def test_plot1d_no_on_click(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + assert not hasattr(plot, "on_click") + + def test_plot1d_no_on_changed(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + assert not hasattr(plot, "on_changed") + + def test_plot1d_no_on_release(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + assert not hasattr(plot, "on_release") + + def test_plot2d_no_on_click(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((32, 32))) + assert not hasattr(plot, "on_click") + + def test_widget_no_on_changed(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + w = plot.add_vline_widget(5.0) + assert not hasattr(w, "on_changed") + + def test_event_no_phys_x(self): + e = Event(event_type="pointer_down", xdata=3.14) + assert not hasattr(e, "phys_x") + assert e.xdata == 3.14 + + def test_plot3d_no_on_click(self): + x = np.linspace(-2, 2, 10) + XX, YY = np.meshgrid(x, x) + fig, ax = apl.subplots(1, 1) + plot = ax.plot_surface(XX, YY, np.zeros_like(XX)) + assert not hasattr(plot, "on_click") + + def test_plotbar_no_on_click(self): + fig, ax = apl.subplots(1, 1) + plot = ax.bar(["A", "B"], [1.0, 2.0]) + assert not hasattr(plot, "on_click") + + def test_line1d_no_on_hover(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + line = plot.add_line(np.zeros(10)) + assert not hasattr(line, "on_hover") + + +# ── fig.close() ────────────────────────────────────────────────────────────── + +class TestFigureClose: + def test_close_in_valid_event_types(self): + assert "close" in VALID_EVENT_TYPES + + def test_figure_close_sets_closed_flag(self): + fig, ax = apl.subplots(1, 1) + ax.plot(np.zeros(10)) + assert not getattr(fig, "_closed", False) + fig.close() + assert fig._closed is True + + def test_figure_close_fires_event_on_plot(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + received = [] + plot.callbacks.connect("close", lambda e: received.append(e.event_type)) + fig.close() + assert received == ["close"] + + def test_figure_close_fires_on_all_panels(self): + fig, (ax1, ax2) = apl.subplots(1, 2) + p1 = ax1.plot(np.zeros(10)) + p2 = ax2.imshow(np.zeros((8, 8))) + counts = [0, 0] + p1.callbacks.connect("close", lambda e: counts.__setitem__(0, counts[0] + 1)) + p2.callbacks.connect("close", lambda e: counts.__setitem__(1, counts[1] + 1)) + fig.close() + assert counts == [1, 1] + + def test_figure_close_is_idempotent(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.zeros(10)) + received = [] + plot.callbacks.connect("close", lambda e: received.append(e)) + fig.close() + fig.close() + assert len(received) == 1 From 569ee13a678327f8e666331b2f6788a55aa497f6 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sat, 23 May 2026 10:32:30 -0500 Subject: [PATCH 176/198] Refactor: Introduce shared constants and helper functions to streamline view state preservation and event scheduling --- anyplotlib/figure_esm.js | 376 +++++++++++++++------------------------ 1 file changed, 141 insertions(+), 235 deletions(-) diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 8c3c1ec2..65307b28 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -115,6 +115,73 @@ function render({ model, el }) { return new Int32Array(buf.buffer); } + // ── shared constants ────────────────────────────────────────────────────── + const STATS_DIV_CSS = + 'position:absolute;top:4px;left:4px;padding:4px 7px;' + + 'background:rgba(0,0,0,0.65);color:#e0e0e0;font-size:10px;' + + 'font-family:monospace;border-radius:4px;pointer-events:none;' + + 'white-space:pre;line-height:1.5;z-index:20;display:none;'; + + // ── shared helpers ──────────────────────────────────────────────────────── + + // Preserve JS-side view state when Python pushes data without requesting a + // view change (_view_from_python === false). + function _preserveView(p2, newState) { + if (!p2.state) return; + if (p2.kind === '2d' && !newState._view_from_python) { + newState.zoom = p2.state.zoom; + newState.center_x = p2.state.center_x; + newState.center_y = p2.state.center_y; + } else if ((p2.kind === '1d' || p2.kind === 'bar') && !newState._view_from_python) { + newState.view_x0 = p2.state.view_x0; + newState.view_x1 = p2.state.view_x1; + } else if (p2.kind === '3d' && !newState._view_from_python) { + newState.azimuth = p2.state.azimuth; + newState.elevation = p2.state.elevation; + newState.zoom = p2.state.zoom; + } + } + + // Factory: returns a debounced commit function. + // onCommit is called once per animation frame after the last request. + function _makeCommitter(onCommit) { + let pending = false; + return function() { + if (pending) return; pending = true; + requestAnimationFrame(() => { pending = false; onCommit(); }); + }; + } + + // Factory: returns { clear(), arm(mx, my, e, extraFields?) } for the + // pointer_settled dwell-timer pattern. extraFields is an optional + // zero-arg callback that returns extra fields to merge into the event. + function _makeSettledScheduler(p) { + let timer = null, startX = 0, startY = 0, startTs = 0; + return { + clear() { clearTimeout(timer); timer = null; }, + arm(mx, my, e, extraFields) { + const ms = p.state?.pointer_settled_ms ?? 0; + if (ms <= 0) return; + const delta = p.state?.pointer_settled_delta ?? 4; + clearTimeout(timer); + startX = mx; startY = my; startTs = performance.now(); + const mods = _modifiers(e); + timer = setTimeout(() => { + if (Math.hypot(p.mouseX - startX, p.mouseY - startY) <= delta) { + const _now = performance.now(); + _emitEvent(p.id, 'pointer_settled', null, { + time_stamp: _now / 1000, modifiers: mods, + button: null, buttons: 0, + x: Math.round(p.mouseX), y: Math.round(p.mouseY), + dwell_ms: _now - startTs, + ...(extraFields ? extraFields() : {}), + }); + } + }, ms); + } + }; + } + // ── per-panel frame timing ──────────────────────────────────────────────── // Called at the entry of every draw function (draw2d / draw1d / draw3d / // drawBar). Records a high-resolution timestamp in a 60-entry rolling @@ -517,11 +584,7 @@ function render({ model, el }) { const blitCache = { bitmap:null, bytesKey:null, lutKey:null, w:0, h:0 }; const statsDiv = document.createElement('div'); - statsDiv.style.cssText = - 'position:absolute;top:4px;left:4px;padding:4px 7px;' + - 'background:rgba(0,0,0,0.65);color:#e0e0e0;font-size:10px;' + - 'font-family:monospace;border-radius:4px;pointer-events:none;' + - 'white-space:pre;line-height:1.5;z-index:20;display:none;'; + statsDiv.style.cssText = STATS_DIV_CSS; if (stack.wrapNode) stack.wrapNode.appendChild(statsDiv); const p = { @@ -563,21 +626,7 @@ function render({ model, el }) { if (!p2) return; try { const newState = JSON.parse(model.get(`panel_${id}_json`)); - // Preserve the current view (zoom/pan) so Python data pushes don't - // reset it — but only when Python has NOT explicitly requested a view - // change (set_view / reset_view set _view_from_python: true). - if (p2.state && p2.kind === '2d' && !newState._view_from_python) { - newState.zoom = p2.state.zoom; - newState.center_x = p2.state.center_x; - newState.center_y = p2.state.center_y; - } else if (p2.state && (p2.kind === '1d' || p2.kind === 'bar') && !newState._view_from_python) { - newState.view_x0 = p2.state.view_x0; - newState.view_x1 = p2.state.view_x1; - } else if (p2.state && p2.kind === '3d' && !newState._view_from_python) { - newState.azimuth = p2.state.azimuth; - newState.elevation = p2.state.elevation; - newState.zoom = p2.state.zoom; - } + _preserveView(p2, newState); p2.state = newState; } catch(_) { return; } @@ -641,11 +690,7 @@ function render({ model, el }) { const blitCache = { bitmap:null, bytesKey:null, lutKey:null, w:0, h:0 }; const statsDiv = document.createElement('div'); - statsDiv.style.cssText = - 'position:absolute;top:4px;left:4px;padding:4px 7px;' + - 'background:rgba(0,0,0,0.65);color:#e0e0e0;font-size:10px;' + - 'font-family:monospace;border-radius:4px;pointer-events:none;' + - 'white-space:pre;line-height:1.5;z-index:20;display:none;'; + statsDiv.style.cssText = STATS_DIV_CSS; if (stack.wrapNode) stack.wrapNode.appendChild(statsDiv); const p = { @@ -692,21 +737,7 @@ function render({ model, el }) { if (!p2) return; try { const newState = JSON.parse(model.get(`panel_${id}_json`)); - // Preserve the current view (zoom/pan) so Python data pushes don't - // reset it — but only when Python has NOT explicitly requested a view - // change (set_view / reset_view set _view_from_python: true). - if (p2.state && p2.kind === '2d' && !newState._view_from_python) { - newState.zoom = p2.state.zoom; - newState.center_x = p2.state.center_x; - newState.center_y = p2.state.center_y; - } else if (p2.state && (p2.kind === '1d' || p2.kind === 'bar') && !newState._view_from_python) { - newState.view_x0 = p2.state.view_x0; - newState.view_x1 = p2.state.view_x1; - } else if (p2.state && p2.kind === '3d' && !newState._view_from_python) { - newState.azimuth = p2.state.azimuth; - newState.elevation = p2.state.elevation; - newState.zoom = p2.state.zoom; - } + _preserveView(p2, newState); p2.state = newState; } catch(_) { return; } @@ -1822,16 +1853,8 @@ function render({ model, el }) { function _attachEvents3d(p) { const { overlayCanvas } = p; let dragStart = null; - let commitPending = false; - let _settledTimer = null; - let _settledStartX = 0, _settledStartY = 0, _settledStartTs = 0; - function _scheduleCommit() { - if (commitPending) return; commitPending = true; - requestAnimationFrame(() => { - commitPending = false; - model.save_changes(); - }); - } + const _scheduleCommit = _makeCommitter(() => model.save_changes()); + const settled = _makeSettledScheduler(p); overlayCanvas.addEventListener('mousedown', (e) => { @@ -1857,7 +1880,7 @@ function render({ model, el }) { e.preventDefault(); }); document.addEventListener('mouseup', (e) => { - clearTimeout(_settledTimer); _settledTimer = null; + settled.clear(); if (!dragStart) return; dragStart = null; overlayCanvas.style.cursor = 'grab'; @@ -1889,31 +1912,7 @@ function render({ model, el }) { const {mx, my} = _clientPos(e, overlayCanvas, p.pw, p.ph); p.mouseX = mx; p.mouseY = my; - // pointer_settled dwell timer (zero-cost when pointer_settled_ms === 0) - const _settledMs = (p.state.pointer_settled_ms ?? 0); - if (_settledMs > 0) { - const _settledDelta = p.state.pointer_settled_delta ?? 4; - clearTimeout(_settledTimer); - _settledStartX = mx; - _settledStartY = my; - _settledStartTs = performance.now(); - const _settledMods = _modifiers(e); // capture at arm time — e is the mousemove event - _settledTimer = setTimeout(() => { - const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); - if (dist <= _settledDelta) { - const _now = performance.now(); - _emitEvent(p.id, 'pointer_settled', null, { - time_stamp: _now / 1000, - modifiers: _settledMods, - button: null, - buttons: 0, - x: Math.round(p.mouseX), - y: Math.round(p.mouseY), - dwell_ms: _now - _settledStartTs, - }); - } - }, _settledMs); - } + settled.arm(mx, my, e); }); // Keyboard shortcuts @@ -1943,7 +1942,7 @@ function render({ model, el }) { _emitEvent(p.id, 'pointer_enter', null, {..._pointerFields(e), x: e.offsetX, y: e.offsetY}); }); overlayCanvas.addEventListener('mouseleave', (e) => { - clearTimeout(_settledTimer); _settledTimer = null; + settled.clear(); _emitEvent(p.id, 'pointer_leave', null, {..._pointerFields(e), x: e.offsetX, y: e.offsetY}); }); overlayCanvas.addEventListener('keyup', (e) => { @@ -1964,20 +1963,8 @@ function render({ model, el }) { function _plotRect1d(pw,ph){return{x:PAD_L,y:PAD_T,w:Math.max(1,pw-PAD_L-PAD_R),h:Math.max(1,ph-PAD_T-PAD_B)};} - function _xToFrac1d(xArr,val){ - if(xArr.length<2) return 0; - const n=xArr.length, asc=xArr[n-1]>=xArr[0]; - if(asc?val<=xArr[0]:val>=xArr[0]) return 0; - if(asc?val>=xArr[n-1]:val<=xArr[n-1]) return 1; - let lo=0,hi=n-2; - while(lo>1;const ok=asc?(xArr[mid]<=val&&val=val&&val>xArr[mid+1]);if(ok){lo=mid;break;}if(asc?xArr[mid+1]<=val:xArr[mid+1]>=val)lo=mid+1;else hi=mid;} - return(lo+(val-xArr[lo])/(xArr[lo+1]-xArr[lo]))/(n-1); - } - function _fracToX1d(xArr,frac){ - if(xArr.length<2) return xArr.length?xArr[0]:0; - const n=xArr.length,pos=Math.max(0,Math.min(1,frac))*(n-1),lo=Math.min(Math.floor(pos),n-2),t=pos-lo; - return xArr[lo]+t*(xArr[lo+1]-xArr[lo]); - } + // _xToFrac1d / _fracToX1d are identical to _axisValToFrac / _axisFracToVal + // (defined at the top of this file) — callers use those shared functions. function _fracToPx1d(frac,x0,x1,r){return r.x+((frac-x0)/((x1-x0)||1))*r.w;} function _valToPy1d(val,dMin,dMax,r){return r.y+r.h-((val-dMin)/((dMax-dMin)||1))*r.h;} @@ -2021,10 +2008,10 @@ function render({ model, el }) { // Grid ctx.strokeStyle=theme.gridStroke; ctx.lineWidth=1; if(xArr.length>=2){ - const xVMin=_fracToX1d(xArr,x0), xVMax=_fracToX1d(xArr,x1); + const xVMin=_axisFracToVal(xArr,x0), xVMax=_axisFracToVal(xArr,x1); const xStep=findNice((xVMax-xVMin)/Math.max(2,Math.floor(r.w/70))); for(let v=Math.ceil(xVMin/xStep)*xStep;v<=xVMax+xStep*0.01;v+=xStep){ - const px=_fracToPx1d(_xToFrac1d(xArr,v),x0,x1,r); + const px=_fracToPx1d(_axisValToFrac(xArr,v),x0,x1,r); if(pxr.x+r.w) continue; ctx.beginPath();ctx.moveTo(px,r.y);ctx.lineTo(px,r.y+r.h);ctx.stroke(); } @@ -2050,8 +2037,8 @@ function render({ model, el }) { for(const sp of (st.spans||[])){ ctx.fillStyle=sp.color||(theme.dark?'rgba(255,255,100,0.15)':'rgba(200,160,0,0.15)'); if(sp.axis==='x'){ - const px0=_fracToPx1d(_xToFrac1d(xArr,sp.v0),x0,x1,r); - const px1b=_fracToPx1d(_xToFrac1d(xArr,sp.v1),x0,x1,r); + const px0=_fracToPx1d(_axisValToFrac(xArr,sp.v0),x0,x1,r); + const px1b=_fracToPx1d(_axisValToFrac(xArr,sp.v1),x0,x1,r); ctx.fillRect(px0,r.y,px1b-px0,r.h); } else { const py0=_toPlotY(sp.v1), py1=_toPlotY(sp.v0); @@ -2226,11 +2213,11 @@ function render({ model, el }) { if(axisVis1d&&xTicksVis1d){ ctx.fillStyle=theme.tickText; ctx.font='10px monospace'; if(xArr.length>=2){ - const xVMin=_fracToX1d(xArr,x0), xVMax=_fracToX1d(xArr,x1); + const xVMin=_axisFracToVal(xArr,x0), xVMax=_axisFracToVal(xArr,x1); const xStep=findNice((xVMax-xVMin)/Math.max(2,Math.floor(r.w/70))); ctx.textAlign='center'; ctx.textBaseline='top'; for(let v=Math.ceil(xVMin/xStep)*xStep;v<=xVMax+xStep*0.01;v+=xStep){ - const px=_fracToPx1d(_xToFrac1d(xArr,v),x0,x1,r); + const px=_fracToPx1d(_axisValToFrac(xArr,v),x0,x1,r); if(pxr.x+r.w) continue; ctx.strokeStyle=theme.axisStroke;ctx.beginPath();ctx.moveTo(px,r.y+r.h);ctx.lineTo(px,r.y+r.h+5);ctx.stroke(); ctx.fillStyle=theme.tickText;ctx.fillText(fmtVal(v),px,r.y+r.h+7); @@ -2330,7 +2317,7 @@ function render({ model, el }) { const color=w.color||'#00e5ff'; ovCtx.save();ovCtx.strokeStyle=color;ovCtx.lineWidth=2; if(w.type==='vline'){ - const px=_fracToPx1d(_xToFrac1d(xArr,w.x),x0,x1,r); + const px=_fracToPx1d(_axisValToFrac(xArr,w.x),x0,x1,r); ovCtx.setLineDash([5,3]);ovCtx.beginPath();ovCtx.moveTo(px,r.y);ovCtx.lineTo(px,r.y+r.h);ovCtx.stroke();ovCtx.setLineDash([]); _ovHandle1d(ovCtx,px,r.y+7,color); } else if(w.type==='hline'){ @@ -2338,8 +2325,8 @@ function render({ model, el }) { ovCtx.setLineDash([5,3]);ovCtx.beginPath();ovCtx.moveTo(r.x,py);ovCtx.lineTo(r.x+r.w,py);ovCtx.stroke();ovCtx.setLineDash([]); _ovHandle1d(ovCtx,r.x+r.w-7,py,color); } else if(w.type==='range'){ - const px0=_fracToPx1d(_xToFrac1d(xArr,w.x0),x0,x1,r); - const px1b=_fracToPx1d(_xToFrac1d(xArr,w.x1),x0,x1,r); + const px0=_fracToPx1d(_axisValToFrac(xArr,w.x0),x0,x1,r); + const px1b=_fracToPx1d(_axisValToFrac(xArr,w.x1),x0,x1,r); if(w.style==='fwhm'){ // FWHM style: o-------o two handles joined by a dashed horizontal line const pyHalf=_valToPy1d(w.y||0,dMin,dMax,r); @@ -2359,7 +2346,7 @@ function render({ model, el }) { _ovHandle1d(ovCtx,px0,r.y+7,color);_ovHandle1d(ovCtx,px1b,r.y+7,color); } } else if(w.type==='point'){ - const px=_fracToPx1d(_xToFrac1d(xArr,w.x),x0,x1,r); + const px=_fracToPx1d(_axisValToFrac(xArr,w.x),x0,x1,r); const py=_valToPy1d(w.y,dMin,dMax,r); // Dashed crosshair guide lines (skipped when show_crosshair is false) if(w.show_crosshair!==false){ @@ -2402,7 +2389,7 @@ function render({ model, el }) { mkCtx.save();mkCtx.beginPath();mkCtx.rect(r.x,r.y,r.w,r.h);mkCtx.clip(); function _offToCanvas(off){ - const xFrac=xArr.length>=2?_xToFrac1d(xArr,off[0]):(off[0]/((xArr.length-1)||1)); + const xFrac=xArr.length>=2?_axisValToFrac(xArr,off[0]):(off[0]/((xArr.length-1)||1)); const px=_fracToPx1d(xFrac,x0,x1,r); let py; if(off.length>=2&&off[1]!=null){py=_valToPy1d(off[1],dMin,dMax,r);} @@ -2410,7 +2397,7 @@ function render({ model, el }) { else{py=_valToPy1d(0,dMin,dMax,r);} return[px,py]; } - function _xPx(v){return _fracToPx1d(xArr.length>=2?_xToFrac1d(xArr,v):0,x0,x1,r);} + function _xPx(v){return _fracToPx1d(xArr.length>=2?_axisValToFrac(xArr,v):0,x0,x1,r);} function _yPx(v){return _valToPy1d(v,dMin,dMax,r);} for(let si=0;si= 2 ? _fracToX1d(lineXArr, frac) : frac; + const physX = lineXArr.length >= 2 ? _axisFracToVal(lineXArr, frac) : frac; const physY = dMin + (r.y + r.h - by) / (r.h||1) * (dMax - dMin); return { lineId, canvasPx: bx, canvasPy: by, x: physX, y: physY }; } @@ -2667,7 +2654,7 @@ function render({ model, el }) { const perLabels=Array.isArray(ms.labels)?ms.labels:null; if(ms.type==='points'){ for(let i=0;i<(ms.offsets||[]).length;i++){ - const frac=xArr.length>=2?_xToFrac1d(xArr,ms.offsets[i][0]):0; + const frac=xArr.length>=2?_axisValToFrac(xArr,ms.offsets[i][0]):0; const px=_fracToPx1d(frac,x0,x1,r); const sz=Math.max(1,ms.sizes[i]!=null?ms.sizes[i]:ms.sizes[0]||5); if(Math.sqrt((mx-px)**2+(my-r.y-r.h/2)**2)<=sz+MARKER_HIT) @@ -2675,7 +2662,7 @@ function render({ model, el }) { } } else if(ms.type==='vlines'){ for(let i=0;i<(ms.offsets||[]).length;i++){ - const px=_fracToPx1d(xArr.length>=2?_xToFrac1d(xArr,ms.offsets[i][0]):0,x0,x1,r); + const px=_fracToPx1d(xArr.length>=2?_axisValToFrac(xArr,ms.offsets[i][0]):0,x0,x1,r); if(Math.abs(mx-px)<=MARKER_HIT&&my>=r.y&&my<=r.y+r.h) return{si,i,collectionLabel:collLabel,markerLabel:perLabels?String(perLabels[i]??''):null}; } @@ -2688,8 +2675,8 @@ function render({ model, el }) { } else if(ms.type==='lines'){ for(let i=0;i<(ms.segments||[]).length;i++){ const seg=ms.segments[i]; - const xf1=xArr.length>=2?_xToFrac1d(xArr,seg[0][0]):0; - const xf2=xArr.length>=2?_xToFrac1d(xArr,seg[1][0]):0; + const xf1=xArr.length>=2?_axisValToFrac(xArr,seg[0][0]):0; + const xf2=xArr.length>=2?_axisValToFrac(xArr,seg[1][0]):0; const x1c=_fracToPx1d(xf1,x0,x1,r),y1c=_valToPy1d(seg[0][1],dMin,dMax,r); const x2c=_fracToPx1d(xf2,x0,x1,r),y2c=_valToPy1d(seg[1][1],dMin,dMax,r); const dx=x2c-x1c,dy=y2c-y1c,len2=dx*dx+dy*dy; @@ -2728,13 +2715,11 @@ function render({ model, el }) { function _attachEvents2d(p) { const { overlayCanvas } = p; - let localOnly=false, commitPending=false; - let _settledTimer = null; - let _settledStartX = 0, _settledStartY = 0, _settledStartTs = 0; - function _scheduleCommit(){ - if(commitPending) return; commitPending=true; - requestAnimationFrame(()=>{commitPending=false;localOnly=true;model.save_changes();setTimeout(()=>{localOnly=false;},200);}); - } + let localOnly = false; + const _scheduleCommit = _makeCommitter(() => { + localOnly = true; model.save_changes(); setTimeout(() => { localOnly = false; }, 200); + }); + const settled = _makeSettledScheduler(p); // Wheel zoom — anchored on the image point under the cursor overlayCanvas.addEventListener('wheel',(e)=>{ @@ -2809,7 +2794,7 @@ function render({ model, el }) { _scheduleCommit(); e.preventDefault(); }); document.addEventListener('mouseup',(e)=>{ - clearTimeout(_settledTimer); _settledTimer = null; + settled.clear(); if(p.ovDrag2d){ const _idx=p.ovDrag2d.idx; const _dw=(p.state.overlay_widgets||[])[_idx]||{}; @@ -2902,51 +2887,27 @@ function render({ model, el }) { p._hoverSi=newSi; p._hoverI=mhit?mhit.i:-1; drawMarkers2d(p, mhit?{si:newSi}:null); } - if(mhit&&(mhit.collectionLabel||mhit.markerLabel)){const parts=[];if(mhit.collectionLabel)parts.push(mhit.collectionLabel);if(mhit.markerLabel)parts.push(mhit.markerLabel);_showTooltip(parts.join('\n'),e.clientX,e.clientY);clearTimeout(_settledTimer); _settledTimer = null;return;} + if(mhit&&(mhit.collectionLabel||mhit.markerLabel)){const parts=[];if(mhit.collectionLabel)parts.push(mhit.collectionLabel);if(mhit.markerLabel)parts.push(mhit.markerLabel);_showTooltip(parts.join('\n'),e.clientX,e.clientY);settled.clear();return;} tooltip.style.display='none'; } else { p.statusBar.style.display='none'; tooltip.style.display='none'; if(p._hoverSi!==-1){p._hoverSi=-1;p._hoverI=-1;drawMarkers2d(p,null);} } - // pointer_settled dwell timer (zero-cost when pointer_settled_ms === 0) - const _settledMs = (p.state.pointer_settled_ms ?? 0); - if (_settledMs > 0) { - const _settledDelta = p.state.pointer_settled_delta ?? 4; - clearTimeout(_settledTimer); - _settledStartX = mx; - _settledStartY = my; - _settledStartTs = performance.now(); - const _settledMods = _modifiers(e); // capture at arm time — e is the mousemove event - _settledTimer = setTimeout(() => { - const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); - if (dist <= _settledDelta) { - const _now = performance.now(); - const st2 = p.state; if (!st2) return; - const imgW2 = p.imgW || Math.max(1, p.pw - PAD_L - PAD_R); - const imgH2 = p.imgH || Math.max(1, p.ph - PAD_T - PAD_B); - const [sImgX, sImgY] = _canvasToImg2d(p.mouseX, p.mouseY, st2, imgW2, imgH2); - const sXArr = st2.x_axis || [], sYArr = st2.y_axis || []; - const _siw = st2.image_width || 1, _sih = st2.image_height || 1; - const sPhysX = sXArr.length >= 2 ? _axisFracToVal(sXArr, sImgX / _siw) : sImgX; - const sPhysY = sYArr.length >= 2 ? _axisFracToVal(sYArr, sImgY / _sih) : sImgY; - _emitEvent(p.id, 'pointer_settled', null, { - time_stamp: _now / 1000, - modifiers: _settledMods, - button: null, - buttons: 0, - x: Math.round(p.mouseX), - y: Math.round(p.mouseY), - img_x: sImgX, - img_y: sImgY, - xdata: sPhysX, - ydata: sPhysY, - dwell_ms: _now - _settledStartTs, - }); - } - }, _settledMs); - } + settled.arm(mx, my, e, () => { + const st2 = p.state; if (!st2) return {}; + const imgW2 = p.imgW || Math.max(1, p.pw - PAD_L - PAD_R); + const imgH2 = p.imgH || Math.max(1, p.ph - PAD_T - PAD_B); + const [sImgX, sImgY] = _canvasToImg2d(p.mouseX, p.mouseY, st2, imgW2, imgH2); + const sXArr = st2.x_axis || [], sYArr = st2.y_axis || []; + const _siw = st2.image_width || 1, _sih = st2.image_height || 1; + return { + img_x: sImgX, img_y: sImgY, + xdata: sXArr.length >= 2 ? _axisFracToVal(sXArr, sImgX / _siw) : sImgX, + ydata: sYArr.length >= 2 ? _axisFracToVal(sYArr, sImgY / _sih) : sImgY, + }; + }); }); overlayCanvas.addEventListener('mouseleave',(e)=>{ - clearTimeout(_settledTimer); _settledTimer = null; + settled.clear(); _emitEvent(p.id,'pointer_leave',null,{..._pointerFields(e),x:e.offsetX,y:e.offsetY}); p.statusBar.style.display='none';tooltip.style.display='none'; if(p._hoverSi!==-1){p._hoverSi=-1;p._hoverI=-1;drawMarkers2d(p,null);} @@ -3027,13 +2988,11 @@ function render({ model, el }) { function _attachEvents1d(p) { const { overlayCanvas } = p; - let localOnly=false, commitPending=false; - let _settledTimer = null; - let _settledStartX = 0, _settledStartY = 0, _settledStartTs = 0; - function _scheduleCommit(){ - if(commitPending) return; commitPending=true; - requestAnimationFrame(()=>{commitPending=false;localOnly=true;model.save_changes();setTimeout(()=>{localOnly=false;},200);}); - } + let localOnly = false; + const _scheduleCommit = _makeCommitter(() => { + localOnly = true; model.save_changes(); setTimeout(() => { localOnly = false; }, 200); + }); + const settled = _makeSettledScheduler(p); // Wheel zoom overlayCanvas.addEventListener('wheel',(e)=>{ @@ -3090,7 +3049,7 @@ function render({ model, el }) { model.set(`panel_${p.id}_json`,JSON.stringify(st));_scheduleCommit();e.preventDefault(); }); document.addEventListener('mouseup',(e)=>{ - clearTimeout(_settledTimer); _settledTimer = null; + settled.clear(); const wasWidgetDragging=!!p.ovDrag; // capture BEFORE clearing const wasDragging=wasWidgetDragging||!!p.isPanning; if(p.ovDrag){ @@ -3128,7 +3087,7 @@ function render({ model, el }) { const r=_plotRect1d(p.pw,p.ph); const xArr = p._1dXArr || (st.x_axis_b64 ? _decodeF64(st.x_axis_b64) : (st.x_axis||[])); const frac=_canvasXToFrac1d(p.mouseX,st.view_x0,st.view_x1,r); - const physX=xArr.length>=2?_fracToX1d(xArr,frac):frac; + const physX=xArr.length>=2?_axisFracToVal(xArr,frac):frac; _emitEvent(p.id,'key_down',null,{ time_stamp:performance.now()/1000, modifiers:_modifiers(e), @@ -3160,12 +3119,12 @@ function render({ model, el }) { if(mxr.x+r.w||myr.y+r.h){ p.statusBar.style.display='none';tooltip.style.display='none'; if(p._hoverSi!==-1){p._hoverSi=-1;p._hoverI=-1;drawMarkers1d(p,null);} - clearTimeout(_settledTimer); _settledTimer = null; + settled.clear(); return; } const xArr = p._1dXArr || (st.x_axis_b64 ? _decodeF64(st.x_axis_b64) : (st.x_axis||[])); const frac=_canvasXToFrac1d(mx,st.view_x0,st.view_x1,r); - const phys=xArr.length>=2?_fracToX1d(xArr,frac):frac; + const phys=xArr.length>=2?_axisFracToVal(xArr,frac):frac; p.statusBar.textContent=`x:${fmtVal(phys)}`;p.statusBar.style.display='block'; const mhit=_markerHitTest1d(mx,my,p); const newSi=mhit?mhit.si:-1; @@ -3193,34 +3152,10 @@ function render({ model, el }) { } if(lhit) _emitEvent(p.id,'pointer_move',null,{line_id:lhit.lineId,x:lhit.x,y:lhit.y,..._pointerFields(e)}); } - // pointer_settled dwell timer (zero-cost when pointer_settled_ms === 0) - const _settledMs = (p.state.pointer_settled_ms ?? 0); - if (_settledMs > 0) { - const _settledDelta = p.state.pointer_settled_delta ?? 4; - clearTimeout(_settledTimer); - _settledStartX = mx; - _settledStartY = my; - _settledStartTs = performance.now(); - const _settledMods = _modifiers(e); // capture at arm time — e is the mousemove event - _settledTimer = setTimeout(() => { - const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); - if (dist <= _settledDelta) { - const _now = performance.now(); - _emitEvent(p.id, 'pointer_settled', null, { - time_stamp: _now / 1000, - modifiers: _settledMods, - button: null, - buttons: 0, - x: Math.round(p.mouseX), - y: Math.round(p.mouseY), - dwell_ms: _now - _settledStartTs, - }); - } - }, _settledMs); - } + settled.arm(mx, my, e); }); overlayCanvas.addEventListener('mouseleave',(e)=>{ - clearTimeout(_settledTimer); _settledTimer = null; + settled.clear(); _emitEvent(p.id,'pointer_leave',null,{..._pointerFields(e),x:e.offsetX,y:e.offsetY}); p.statusBar.style.display='none';tooltip.style.display='none'; if(p._hoverSi!==-1){p._hoverSi=-1;p._hoverI=-1;drawMarkers1d(p,null);} @@ -3234,7 +3169,7 @@ function render({ model, el }) { const r=_plotRect1d(p.pw,p.ph); const xArr=p._1dXArr||(st.x_axis_b64?_decodeF64(st.x_axis_b64):(st.x_axis||[])); const frac=_canvasXToFrac1d(mx,st.view_x0,st.view_x1,r); - xdata=xArr.length>=2?_fracToX1d(xArr,frac):frac; + xdata=xArr.length>=2?_axisFracToVal(xArr,frac):frac; } _emitEvent(p.id,'double_click',null,{..._pointerFields(e),button:e.button,x:mx,y:my,xdata}); }); @@ -3430,7 +3365,7 @@ function render({ model, el }) { const w=widgets[i]; if(w.visible===false) continue; if(w.type==='point'){ - const px=_fracToPx1d(_xToFrac1d(xArr,w.x),x0,x1,r); + const px=_fracToPx1d(_axisValToFrac(xArr,w.x),x0,x1,r); const py=_valToPy1d(w.y,st.data_min,st.data_max,r); if(Math.hypot(mx-px,my-py)<=HR+4) return{idx:i,mode:'move',wtype:'point',startMX:mx,startMY:my,snapW:{...w}}; @@ -3441,15 +3376,15 @@ function render({ model, el }) { const w=widgets[i]; if(w.visible===false) continue; if(w.type==='vline'){ - const px=_fracToPx1d(_xToFrac1d(xArr,w.x),x0,x1,r); + const px=_fracToPx1d(_axisValToFrac(xArr,w.x),x0,x1,r); if(Math.sqrt((mx-px)**2+(my-(r.y+7))**2)<=HR||Math.abs(mx-px)<=5) return{idx:i,mode:'move',wtype:'vline',startMX:mx,snapW:{...w}}; } else if(w.type==='hline'){ const py=_valToPy1d(w.y,st.data_min,st.data_max,r); if(Math.abs(my-py)<=5) return{idx:i,mode:'move',wtype:'hline',startMY:my,snapW:{...w}}; } else if(w.type==='range'){ - const px0=_fracToPx1d(_xToFrac1d(xArr,w.x0),x0,x1,r); - const px1b=_fracToPx1d(_xToFrac1d(xArr,w.x1),x0,x1,r); + const px0=_fracToPx1d(_axisValToFrac(xArr,w.x0),x0,x1,r); + const px1b=_fracToPx1d(_axisValToFrac(xArr,w.x1),x0,x1,r); if(w.style==='fwhm'){ // FWHM style: hit-test the two circular handles const pyHalf=_valToPy1d(w.y||0,st.data_min,st.data_max,r); @@ -3474,7 +3409,7 @@ function render({ model, el }) { const {mx,my:py}=_clientPos(e,p.overlayCanvas,p.pw,p.ph); const xArr = p._1dXArr || (st.x_axis_b64 ? _decodeF64(st.x_axis_b64) : (st.x_axis||[])); const x0=st.view_x0||0,x1=st.view_x1||1; - const xUnit=xArr.length>=2?_fracToX1d(xArr,_canvasXToFrac1d(mx,x0,x1,r)):_canvasXToFrac1d(mx,x0,x1,r); + const xUnit=xArr.length>=2?_axisFracToVal(xArr,_canvasXToFrac1d(mx,x0,x1,r)):_canvasXToFrac1d(mx,x0,x1,r); const widgets=st.overlay_widgets; const d=p.ovDrag, s=d.snapW, w=widgets[d.idx]; if(w.type==='vline'){w.x=xUnit;} @@ -3483,15 +3418,15 @@ function render({ model, el }) { if(d.mode==='edge0') w.x0=xUnit; else if(d.mode==='edge1') w.x1=xUnit; else { - const snapPx=_fracToPx1d(xArr.length>=2?_xToFrac1d(xArr,s.x0):0,x0,x1,r); - const dxUnit=xArr.length>=2?_fracToX1d(xArr,_canvasXToFrac1d(snapPx+(mx-d.startMX),x0,x1,r))-s.x0:(mx-d.startMX)/(r.w||1); + const snapPx=_fracToPx1d(xArr.length>=2?_axisValToFrac(xArr,s.x0):0,x0,x1,r); + const dxUnit=xArr.length>=2?_axisFracToVal(xArr,_canvasXToFrac1d(snapPx+(mx-d.startMX),x0,x1,r))-s.x0:(mx-d.startMX)/(r.w||1); w.x0=s.x0+dxUnit;w.x1=s.x1+dxUnit; } } else if(w.type==='point'){ // Clamp to plot rectangle const clampX=Math.max(r.x,Math.min(r.x+r.w,mx)); const clampY=Math.max(r.y,Math.min(r.y+r.h,py)); - w.x=xArr.length>=2?_fracToX1d(xArr,_canvasXToFrac1d(clampX,x0,x1,r)):_canvasXToFrac1d(clampX,x0,x1,r); + w.x=xArr.length>=2?_axisFracToVal(xArr,_canvasXToFrac1d(clampX,x0,x1,r)):_canvasXToFrac1d(clampX,x0,x1,r); w.y=st.data_max-((clampY-r.y)/(r.h||1))*(st.data_max-st.data_min); } drawOverlay1d(p); @@ -4218,13 +4153,8 @@ function render({ model, el }) { } // Widget drag support - let commitPending = false; - let _settledTimer = null; - let _settledStartX = 0, _settledStartY = 0, _settledStartTs = 0; - function _scheduleCommit() { - if (commitPending) return; commitPending = true; - requestAnimationFrame(() => { commitPending = false; model.save_changes(); }); - } + const _scheduleCommit = _makeCommitter(() => model.save_changes()); + const settled = _makeSettledScheduler(p); overlayCanvas.addEventListener('mousedown', (e) => { if (e.button !== 0) return; @@ -4246,7 +4176,7 @@ function render({ model, el }) { }); document.addEventListener('mouseup', (e) => { - clearTimeout(_settledTimer); _settledTimer = null; + settled.clear(); if (!p.ovDrag) return; const _idx = p.ovDrag.idx; const _dw = (p.state.overlay_widgets || [])[_idx] || {}; @@ -4296,35 +4226,11 @@ function render({ model, el }) { tooltip.style.display = 'none'; overlayCanvas.style.cursor = 'default'; } - // pointer_settled dwell timer (zero-cost when pointer_settled_ms === 0) - const _settledMs = (p.state.pointer_settled_ms ?? 0); - if (_settledMs > 0) { - const _settledDelta = p.state.pointer_settled_delta ?? 4; - clearTimeout(_settledTimer); - _settledStartX = mx; - _settledStartY = my; - _settledStartTs = performance.now(); - const _settledMods = _modifiers(e); // capture at arm time — e is the mousemove event - _settledTimer = setTimeout(() => { - const dist = Math.hypot(p.mouseX - _settledStartX, p.mouseY - _settledStartY); - if (dist <= _settledDelta) { - const _now = performance.now(); - _emitEvent(p.id, 'pointer_settled', null, { - time_stamp: _now / 1000, - modifiers: _settledMods, - button: null, - buttons: 0, - x: Math.round(p.mouseX), - y: Math.round(p.mouseY), - dwell_ms: _now - _settledStartTs, - }); - } - }, _settledMs); - } + settled.arm(mx, my, e); }); overlayCanvas.addEventListener('mouseleave', (e) => { - clearTimeout(_settledTimer); _settledTimer = null; + settled.clear(); _emitEvent(p.id, 'pointer_leave', null, {..._pointerFields(e), x: e.offsetX, y: e.offsetY}); if (p._hovBar !== null) { p._hovBar = null; drawBar(p); } tooltip.style.display = 'none'; From 008637a06d75027ba82dcfe08bf659a9c7d9050c Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sat, 23 May 2026 19:15:51 -0500 Subject: [PATCH 177/198] Refactor: Add package-level pytest fixture for shared Chromium browser session --- anyplotlib/conftest.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 anyplotlib/conftest.py diff --git a/anyplotlib/conftest.py b/anyplotlib/conftest.py new file mode 100644 index 00000000..6b5ce3b1 --- /dev/null +++ b/anyplotlib/conftest.py @@ -0,0 +1,26 @@ +""" +anyplotlib/conftest.py +====================== + +Package-level pytest fixtures shared by ALL test subdirectories: + - anyplotlib/tests/ + - anyplotlib/sphinx_anywidget/tests/ + +Putting ``_pw_browser`` here (rather than in either subdirectory's conftest) +means both test trees share the same Chromium session — only one +``sync_playwright()`` context is ever active per pytest run. +""" +from __future__ import annotations + +import pytest + + +@pytest.fixture(scope="session") +def _pw_browser(): + """Yield a headless Chromium browser for the whole test session.""" + from playwright.sync_api import sync_playwright + + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=True) + yield browser + browser.close() From 91cde28297a3da190cb04786d14c3453a3010d0a Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Sat, 23 May 2026 19:22:32 -0500 Subject: [PATCH 178/198] Fix: Switch wheel builder from pip to uv build for CI compatibility Co-Authored-By: Carter Francis --- anyplotlib/sphinx_anywidget/_wheel_builder.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/anyplotlib/sphinx_anywidget/_wheel_builder.py b/anyplotlib/sphinx_anywidget/_wheel_builder.py index c854383a..c275c6af 100644 --- a/anyplotlib/sphinx_anywidget/_wheel_builder.py +++ b/anyplotlib/sphinx_anywidget/_wheel_builder.py @@ -10,7 +10,6 @@ import re import subprocess -import sys from pathlib import Path @@ -53,9 +52,8 @@ def build_wheel( tmp_dir = Path(tmp_str) result = subprocess.run( [ - sys.executable, "-m", "pip", "wheel", - "--no-deps", "--quiet", - "--wheel-dir", str(tmp_dir), + "uv", "build", "--wheel", + "--out-dir", str(tmp_dir), str(project_root), ], capture_output=True, From c30404f60a753cccac17585e33e4c22758b5bf1a Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 10 Jun 2026 12:30:39 -0500 Subject: [PATCH 179/198] sphinx_anywidget: add _PYODIDE_MOCK_PACKAGES support Adds a _PYODIDE_MOCK_PACKAGES = [...] variable that examples can declare to register packages unavailable in Pyodide as micropip mock packages before the wheel install, preventing dep-resolution failures. Changes: - _scraper.py: parse _PYODIDE_MOCK_PACKAGES_RE, emit data-pyodide-mock-packages attribute on the ' ) raw_html += "\n" + script_tag + "\n" diff --git a/anyplotlib/sphinx_anywidget/_scraper.py b/anyplotlib/sphinx_anywidget/_scraper.py index ce75db6d..7cda5858 100644 --- a/anyplotlib/sphinx_anywidget/_scraper.py +++ b/anyplotlib/sphinx_anywidget/_scraper.py @@ -56,6 +56,14 @@ r"^_PYODIDE_PACKAGES\s*=\s*(\[[^\]]*\])", re.MULTILINE ) +# Pattern that extracts _PYODIDE_MOCK_PACKAGES = [...] declarations from source. +# These are passed to micropip.add_mock_package() before the wheel install so +# that packages unavailable in Pyodide (e.g. dask, rosettasciio) are silently +# skipped during dependency resolution. +_PYODIDE_MOCK_PACKAGES_RE = re.compile( + r"^_PYODIDE_MOCK_PACKAGES\s*=\s*(\[[^\]]*\])", re.MULTILINE +) + # --------------------------------------------------------------------------- # Helpers @@ -343,12 +351,28 @@ def __call__(self, block, block_vars, gallery_conf): except Exception: pass + # Detect _PYODIDE_MOCK_PACKAGES = [...] in the source. + _mock_attr = "" + m2 = _PYODIDE_MOCK_PACKAGES_RE.search(python_src) + if m2: + try: + import ast as _ast2 + mock_pkgs = _ast2.literal_eval(m2.group(1)) + if mock_pkgs: + _mock_attr = ( + f' data-pyodide-mock-packages=' + f'"{_html_escape(_json.dumps(mock_pkgs), quote=True)}"' + ) + except Exception: + pass + python_block = ( f'' ) rst += "\n\n.. raw:: html\n\n " + python_block + "\n\n" diff --git a/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js b/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js index 1fcb4c27..3fe4ae88 100644 --- a/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js +++ b/anyplotlib/sphinx_anywidget/static/anywidget_bridge.js @@ -196,6 +196,19 @@ _AWI_REGISTRY = {} # fig_id → widget instance // properly installed above so micropip will find them and skip them. // We do NOT pass deps=False — that triggers a micropip 0.7.x internal // bug ("attempted to install wheel before downloading it"). + + // Collect all _PYODIDE_MOCK_PACKAGES declared by any example on this page + // so they can be registered as mock packages BEFORE the wheel install. + // (Dep resolution happens during install, not during exec.) + const _globalMockPkgs = []; + for (const s of document.querySelectorAll( + 'script[type="text/x-python"][data-pyodide-mock-packages]')) { + try { + const pkgs = JSON.parse(s.dataset.pyodideMockPackages || '[]'); + for (const p of pkgs) if (!_globalMockPkgs.includes(p)) _globalMockPkgs.push(p); + } catch (_) {} + } + const wheelUrl = _DOCS_ROOT + '_static/wheels/'; // Discover wheel name: try the configured package name from the +""" + + +@pytest.fixture +def mount_page(_pw_browser): + """Open a figure via the public mount() API; return the live Page.""" + pages, paths = [], [] + + def _open(fig): + html = (_MOUNT_PAGE + .replace("__STATE__", json.dumps(figure_state(fig))) + .replace("__ESM__", json.dumps(esm_path().read_text(encoding="utf-8")))) + with tempfile.NamedTemporaryFile( + suffix=".html", mode="w", encoding="utf-8", delete=False + ) as fh: + fh.write(html) + tmp = pathlib.Path(fh.name) + paths.append(tmp) + page = _pw_browser.new_page() + pages.append(page) + page.goto(tmp.as_uri()) + page.wait_for_function("() => window._aplReady === true", timeout=15_000) + 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) + + +def _fig_with_image(): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + q = np.linspace(0, 10, 32) + plot = ax.imshow(np.random.default_rng(0).random((32, 32)), axes=[q, q]) + return fig, plot + + +def _plot_canvas_ink(page) -> int: + return page.evaluate("""() => { + const c = document.querySelector('#host canvas'); + if (!c) return -1; + const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data; + let n = 0; + for (let i = 3; i < d.length; i += 4) if (d[i] > 0) n++; + return n; + }""") + + +class TestMountRenders: + def test_canvases_created_with_ink(self, mount_page): + fig, _ = _fig_with_image() + page = mount_page(fig) + n_canvas = page.evaluate("() => document.querySelectorAll('#host canvas').length") + assert n_canvas >= 3, f"expected canvas stack, got {n_canvas}" + assert _plot_canvas_ink(page) > 1000, "image canvas has no rendered pixels" + + def test_multiple_mounts_one_page_mdi_style(self, mount_page): + """Two figures in one page must not interfere (MDI sub-windows).""" + fig, _ = _fig_with_image() + page = mount_page(fig) + # Mount a second, independent figure into a fresh container. + fig2, _ = _fig_with_image() + state2 = json.dumps(figure_state(fig2)) + page.evaluate(f"""() => {{ + const div = document.createElement('div'); + div.id = 'host2'; + document.body.appendChild(div); + const esm = {json.dumps(esm_path().read_text(encoding="utf-8"))}; + const blobUrl = URL.createObjectURL(new Blob([esm], {{type:'text/javascript'}})); + return import(blobUrl).then(mod => {{ + window._handle2 = mod.mount(div, {state2}, {{}}); + }}); + }}""") + page.wait_for_function("() => window._handle2 !== undefined", timeout=15_000) + n1 = page.evaluate("() => document.querySelectorAll('#host canvas').length") + n2 = page.evaluate("() => document.querySelectorAll('#host2 canvas').length") + assert n1 >= 3 and n2 >= 3 + + def test_dispose_clears_dom(self, mount_page): + fig, _ = _fig_with_image() + page = mount_page(fig) + page.evaluate("() => window._handle.dispose()") + n = page.evaluate("() => document.querySelectorAll('#host canvas').length") + assert n == 0 + + +class TestMountLiveUpdates: + def test_set_panel_state_rerenders(self, mount_page): + """setPanelState() with a new title must draw title pixels.""" + fig, plot = _fig_with_image() + page = mount_page(fig) + + def title_ink(): + return page.evaluate("""() => { + const tc = Array.from(document.querySelectorAll('#host canvas')) + .find(c => c.style.zIndex === '8'); + if (!tc) return -1; + const d = tc.getContext('2d').getImageData(0,0,tc.width,tc.height).data; + let n = 0; + for (let i = 3; i < d.length; i += 4) if (d[i] > 0) n++; + return n; + }""") + + assert title_ink() == 0 + new_state = {**plot.to_state_dict(), "title": "Live from JS"} + page.evaluate( + "(args) => window._handle.setPanelState(args[0], args[1])", + [plot._id, new_state], + ) + page.wait_for_timeout(150) + assert title_ink() > 0, "setPanelState() did not re-render the title" + + def test_apply_update_does_not_echo(self, mount_page): + """applyUpdate() (Python → JS path) must not bounce back via onSync.""" + fig, plot = _fig_with_image() + page = mount_page(fig) + new_state = json.dumps({**plot.to_state_dict(), "title": "no echo"}) + page.evaluate( + "(args) => window._handle.applyUpdate('panel_' + args[0] + '_json', args[1])", + [plot._id, new_state], + ) + page.wait_for_timeout(100) + syncs = page.evaluate("() => window._syncs.map(s => s.key)") + assert f"panel_{plot._id}_json" not in syncs + + +class TestMountEvents: + def test_pointer_event_reaches_onevent_and_onsync(self, mount_page): + fig, plot = _fig_with_image() + page = mount_page(fig) + # Click the centre of the image area. + page.mouse.move(200, 150) + page.mouse.down() + page.mouse.up() + page.wait_for_timeout(200) + + events = page.evaluate("() => window._events") + assert any(e.get("event_type") == "pointer_down" for e in events), ( + f"no pointer_down in onEvent stream: {[e.get('event_type') for e in events]}" + ) + assert all(e.get("panel_id") == plot._id + for e in events if "panel_id" in e) + syncs = page.evaluate("() => window._syncs.map(s => s.key)") + assert "event_json" in syncs, "event_json was not flushed through onSync" + + +class TestBridgeRoundTrip: + """End-to-end Level-3 pattern: mount() in a real browser wired to a live + Python FigureBridge, with the test harness acting as the transport + (in an Electron app this would be a WebSocket / IPC pipe).""" + + def test_full_round_trip(self, mount_page): + from anyplotlib.embed import FigureBridge + + fig, plot = _fig_with_image() + clicks = [] + + @plot.add_event_handler("pointer_down") + def on_click(event): + clicks.append((event.xdata, event.ydata)) + + outbound = [] # Python → JS queue + bridge = FigureBridge(fig, send=lambda k, v: outbound.append((k, v))) + page = mount_page(fig) + + # ── JS → Python: click in the browser, pump onSync into the bridge ── + page.mouse.move(200, 150) + page.mouse.down() + page.mouse.up() + page.wait_for_timeout(200) + for s in page.evaluate("() => window._syncs"): + bridge.receive(s["key"], s["value"]) + assert clicks, "browser click did not reach the Python callback" + assert clicks[0][0] is not None, "event lost its data coordinates" + + # ── Python → JS: set_title streams back into rendered pixels ── + outbound.clear() + plot.set_title("From Python") + assert outbound, "Python mutation produced no bridge messages" + for k, v in outbound: + page.evaluate("(a) => window._handle.applyUpdate(a[0], a[1])", [k, v]) + page.wait_for_timeout(150) + title_ink = page.evaluate("""() => { + const tc = Array.from(document.querySelectorAll('#host canvas')) + .find(c => c.style.zIndex === '8'); + if (!tc) return -1; + const d = tc.getContext('2d').getImageData(0,0,tc.width,tc.height).data; + let n = 0; + for (let i = 3; i < d.length; i += 4) if (d[i] > 0) n++; + return n; + }""") + assert title_ink > 0, "Python set_title did not render in the browser" + bridge.close() diff --git a/anyplotlib/tests/test_labels/__init__.py b/anyplotlib/tests/test_labels/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/anyplotlib/tests/test_labels/test_label_api.py b/anyplotlib/tests/test_labels/test_label_api.py new file mode 100644 index 00000000..a4cc7714 --- /dev/null +++ b/anyplotlib/tests/test_labels/test_label_api.py @@ -0,0 +1,132 @@ +""" +Unit tests for the label font-size API and TeX pass-through. + +Covers: + * fontsize kwarg on set_xlabel / set_ylabel / set_zlabel / set_title / + set_colorbar_label for every panel type + * fontsize=None leaves the size state untouched (JS falls back to defaults) + * set_tick_label_size + * TeX-formatted label strings are stored verbatim (parsing happens in JS) +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl + + +def _imshow(): + fig, ax = apl.subplots(1, 1) + return ax.imshow(np.zeros((8, 8))) + + +def _plot(): + fig, ax = apl.subplots(1, 1) + return ax.plot(np.zeros(16)) + + +def _bar(): + fig, ax = apl.subplots(1, 1) + return ax.bar(["a", "b"], [1.0, 2.0]) + + +def _surface(): + fig, ax = apl.subplots(1, 1) + g = np.linspace(-1, 1, 8) + XX, YY = np.meshgrid(g, g) + return ax.plot_surface(XX, YY, XX * YY) + + +class TestFontsizeKwarg: + def test_plot2d_xlabel_fontsize(self): + v = _imshow() + v.set_xlabel("x", fontsize=14) + assert v._state["x_label"] == "x" + assert v._state["x_label_size"] == 14.0 + + def test_plot2d_ylabel_fontsize(self): + v = _imshow() + v.set_ylabel("y", fontsize=16) + assert v._state["y_label"] == "y" + assert v._state["y_label_size"] == 16.0 + + def test_plot2d_colorbar_label_fontsize(self): + v = _imshow() + v.set_colorbar_label("counts", fontsize=13) + assert v._state["colorbar_label"] == "counts" + assert v._state["colorbar_label_size"] == 13.0 + + def test_plot1d_label_fontsize_maps_to_units(self): + v = _plot() + v.set_xlabel("eV", fontsize=12) + v.set_ylabel("counts", fontsize=11) + assert v._state["units"] == "eV" + assert v._state["x_label_size"] == 12.0 + assert v._state["y_units"] == "counts" + assert v._state["y_label_size"] == 11.0 + + def test_plotbar_label_fontsize(self): + v = _bar() + v.set_xlabel("category", fontsize=12) + v.set_ylabel("value", fontsize=13) + assert v._state["x_label_size"] == 12.0 + assert v._state["y_label_size"] == 13.0 + + def test_plot3d_label_fontsize(self): + v = _surface() + v.set_xlabel("x", fontsize=14) + v.set_ylabel("y", fontsize=15) + v.set_zlabel("z", fontsize=16) + assert v._state["x_label_size"] == 14.0 + assert v._state["y_label_size"] == 15.0 + assert v._state["z_label_size"] == 16.0 + + def test_title_fontsize_all_panel_types(self): + for make in (_imshow, _plot, _bar, _surface): + v = make() + v.set_title("T", fontsize=12) + assert v._state["title"] == "T" + assert v._state["title_size"] == 12.0 + + +class TestFontsizeNoneKeepsState: + def test_none_does_not_create_size_key(self): + v = _imshow() + v.set_xlabel("x") + assert "x_label_size" not in v._state + + def test_none_does_not_overwrite_previous_size(self): + v = _imshow() + v.set_xlabel("x", fontsize=18) + v.set_xlabel("renamed") # no fontsize — keep 18 + assert v._state["x_label"] == "renamed" + assert v._state["x_label_size"] == 18.0 + + +class TestTickLabelSize: + @pytest.mark.parametrize("make", [_imshow, _plot, _bar]) + def test_set_tick_label_size(self, make): + v = make() + v.set_tick_label_size(14) + assert v._state["tick_size"] == 14.0 + + +class TestTexPassThrough: + """Python stores TeX strings verbatim; all parsing happens at JS draw time.""" + + def test_tex_label_stored_verbatim(self): + v = _imshow() + label = r"$q$ ($\AA^{-1}$)" + v.set_xlabel(label) + assert v._state["x_label"] == label + + def test_tex_exponent_title(self): + v = _plot() + v.set_title(r"Intensity $\times 10^{-3}$") + assert v._state["title"] == r"Intensity $\times 10^{-3}$" + + def test_tex_subscript_colorbar(self): + v = _imshow() + v.set_colorbar_label(r"$E_F$ (eV)") + assert v._state["colorbar_label"] == r"$E_F$ (eV)" diff --git a/anyplotlib/tests/test_labels/test_label_rendering.py b/anyplotlib/tests/test_labels/test_label_rendering.py new file mode 100644 index 00000000..933b70aa --- /dev/null +++ b/anyplotlib/tests/test_labels/test_label_rendering.py @@ -0,0 +1,137 @@ +""" +Playwright tests for label font sizes and mini-TeX rendering. + +Strategy +-------- +Canvas text cannot be read back as strings, so these tests assert on *ink*: + +* a larger ``fontsize`` must produce more non-background pixels in the + axis gutter than a smaller one; +* a TeX string like ``$10^{-3}$`` must render *narrower* than the same + characters drawn literally (``10^{-3}``) — the ``$`` delimiters are + consumed and the exponent shrinks to a superscript; +* TeX titles must produce visible pixels in the title canvas. +""" +from __future__ import annotations + +import numpy as np + +import anyplotlib as apl + +PAD_B = 42 # bottom axis gutter height (PAD_* constants in figure_esm.js) + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def _x_gutter(img: np.ndarray) -> np.ndarray: + """Return the bottom PAD_B-row strip of a widget screenshot.""" + return img[-PAD_B:, :, :3].astype(int) + + +def _ink_mask(strip: np.ndarray) -> np.ndarray: + """Boolean mask of pixels that differ from the strip's corner colour.""" + bg = strip[2, 2] + return np.abs(strip - bg).sum(axis=-1) > 60 + + +def _x_gutter_ink(take_screenshot, label: str, fontsize=None) -> np.ndarray: + """Render an imshow with the given x label; return the gutter ink mask.""" + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow( + np.zeros((32, 32), dtype=np.float32), + axes=[np.linspace(0, 10, 32)] * 2, + units="nm", + ) + if fontsize is None: + plot.set_xlabel(label) + else: + plot.set_xlabel(label, fontsize=fontsize) + return _ink_mask(_x_gutter(take_screenshot(fig))) + + +def _title_pixel_count(page) -> int: + """Count non-transparent pixels in the 2D titleCanvas (z-index:8).""" + return page.evaluate("""() => { + const tc = Array.from(document.querySelectorAll('canvas')) + .find(c => c.style.zIndex === '8'); + if (!tc) return -1; + const ctx = tc.getContext('2d'); + const d = ctx.getImageData(0, 0, tc.width, tc.height).data; + let n = 0; + for (let i = 3; i < d.length; i += 4) { if (d[i] > 0) n++; } + return n; + }""") + + +# ══════════════════════════════════════════════════════════════════════════════ + + +class TestFontsizeRendering: + def test_larger_fontsize_more_ink(self, take_screenshot): + small = _x_gutter_ink(take_screenshot, "Distance", fontsize=9) + large = _x_gutter_ink(take_screenshot, "Distance", fontsize=18) + assert large.sum() > small.sum() * 1.3, ( + f"fontsize=18 must draw more label ink than fontsize=9 " + f"(got {large.sum()} vs {small.sum()})" + ) + + def test_tick_label_size_changes_ink(self, take_screenshot): + def gutter_ink(tick_size): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow( + np.zeros((32, 32), dtype=np.float32), + axes=[np.linspace(0, 10, 32)] * 2, + ) + if tick_size: + plot.set_tick_label_size(tick_size) + return _ink_mask(_x_gutter(take_screenshot(fig))).sum() + + assert gutter_ink(16) > gutter_ink(None) * 1.2, ( + "set_tick_label_size(16) must draw more tick ink than the default" + ) + + +class TestTexRendering: + def test_tex_label_renders_ink(self, take_screenshot): + ink = _x_gutter_ink(take_screenshot, r"$10^{-3}$ m") + assert ink.sum() > 0, "TeX label must render visible pixels" + + def test_tex_consumes_dollars_and_shrinks_exponent(self, take_screenshot): + """$10^{-3}$ must be narrower than the literal text 10^{-3}. + + The TeX path drops the two ``$`` delimiters and the ``^{}`` braces + and renders ``-3`` at ~0.68× size, so its ink must span fewer + columns than the literal 7-glyph string. + """ + tex = _x_gutter_ink(take_screenshot, r"$10^{-3}$") + lit = _x_gutter_ink(take_screenshot, "10^{-3}") + # Width = number of columns containing any ink in the label row band. + # Restrict to the bottom 14 rows where the centred label is drawn, + # away from tick numbers at the top of the gutter. + tex_cols = np.flatnonzero(tex[-14:, :].any(axis=0)) + lit_cols = np.flatnonzero(lit[-14:, :].any(axis=0)) + assert len(tex_cols) > 0 and len(lit_cols) > 0 + tex_w = tex_cols[-1] - tex_cols[0] + lit_w = lit_cols[-1] - lit_cols[0] + assert tex_w < lit_w, ( + f"TeX '$10^{{-3}}$' must render narrower than literal '10^{{-3}}' " + f"(got {tex_w} vs {lit_w} px)" + ) + + def test_tex_title_renders_pixels(self, interact_page): + fig, ax = apl.subplots(1, 1, figsize=(400, 300)) + plot = ax.imshow(np.zeros((32, 32), dtype=np.float32)) + plot.set_title(r"$\sigma^2 = \langle x^2 \rangle$") + page = interact_page(fig) + page.wait_for_timeout(200) + n = _title_pixel_count(page) + assert n > 0, "TeX title must produce visible pixels in titleCanvas" + + def test_greek_and_symbols_render(self, take_screenshot): + ink = _x_gutter_ink(take_screenshot, r"$\Delta E$ ($\mu$eV) $\times$ $\AA$") + assert ink.sum() > 0 + + def test_plain_label_unaffected(self, take_screenshot): + """A label with no $ must render through the fast path identically.""" + ink = _x_gutter_ink(take_screenshot, "plain label, no math") + assert ink.sum() > 0 diff --git a/anyplotlib/tests/test_labels/test_no_clipping.py b/anyplotlib/tests/test_labels/test_no_clipping.py new file mode 100644 index 00000000..85b302c3 --- /dev/null +++ b/anyplotlib/tests/test_labels/test_no_clipping.py @@ -0,0 +1,102 @@ +""" +Playwright regression tests: labels, titles, and tick text must never be +clipped by their canvas bounds. + +Strategy: read back each text-bearing canvas with ``getImageData`` and assert +no ink (non-transparent pixel) sits on the canvas's first/last row. Text +whose glyphs are cut by the canvas edge always leaves ink on the edge row, so +"no ink on the edge" ⇒ "nothing clipped vertically". + +The 2D title canvas is fully transparent except for the title, making it the +cleanest probe for both the dynamic title strip and the TeX superscript rise. +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl + + +def _title_ink_rows(page) -> dict: + """Return {h, minRow, maxRow} of ink in the 2D titleCanvas (z-index 8).""" + return page.evaluate("""() => { + const tc = Array.from(document.querySelectorAll('canvas')) + .find(c => c.style.zIndex === '8'); + if (!tc) return null; + const d = tc.getContext('2d').getImageData(0, 0, tc.width, tc.height).data; + let minR = 1e9, maxR = -1; + for (let y = 0; y < tc.height; y++) for (let x = 0; x < tc.width; x++) { + if (d[(y * tc.width + x) * 4 + 3] > 0) { + if (y < minR) minR = y; + if (y > maxR) maxR = y; + } + } + return { h: tc.height, minRow: minR, maxRow: maxR }; + }""") + + +def _open_imshow_with_title(interact_page, title, fontsize=None): + fig, ax = apl.subplots(1, 1, figsize=(460, 380)) + q = np.linspace(-2.3, 2.3, 64) + plot = ax.imshow(np.zeros((64, 64), dtype=np.float32), axes=[q, q], units="nm") + if fontsize is None: + plot.set_title(title) + else: + plot.set_title(title, fontsize=fontsize) + page = interact_page(fig) + page.wait_for_timeout(150) + return page + + +class TestTitleNeverClipped: + @pytest.mark.parametrize("title,fontsize", [ + ("Plain gyp TX", None), # default plain — baseline case + (r"TeX: $|F(q)|^2$ gyp", None), # default TeX — strip grows for sup + (r"Large $x^2$ gyp", 16), # large TeX — strip grows + ("Plain large gyp", 16), # large plain + (r"XL $y_i^2$ gyp", 22), # extreme, sup + sub + descenders + ]) + def test_title_ink_within_strip(self, interact_page, title, fontsize): + page = _open_imshow_with_title(interact_page, title, fontsize) + r = _title_ink_rows(page) + assert r is not None and r["maxRow"] >= 0, "title produced no ink" + assert r["minRow"] > 0, ( + f"title ink touches the top edge (clipped ascender/superscript): {r}" + ) + assert r["maxRow"] < r["h"] - 1, ( + f"title ink touches the bottom edge (clipped descender): {r}" + ) + + +class TestColorbarLabelVisible: + def test_colorbar_label_renders_in_reserved_gutter(self, interact_page): + """The image must shrink so the colorbar strip + label fit the panel.""" + fig, ax = apl.subplots(1, 1, figsize=(460, 380)) + q = np.linspace(-2.3, 2.3, 64) + plot = ax.imshow(np.zeros((64, 64), dtype=np.float32), axes=[q, q]) + plot.set_colorbar_visible(True) + plot.set_colorbar_label(r"counts $\times 10^{3}$") + page = interact_page(fig) + page.wait_for_timeout(150) + + res = page.evaluate("""() => { + // cbCanvas: the only canvas right of the image, width > 16 + const panel = 460; + for (const c of document.querySelectorAll('canvas')) { + const left = parseFloat(c.style.left || '0'); + if (c.width > 16 && c.width < 80 && left > 300) { + // entire canvas must sit inside the panel width + const fits = left + c.width <= panel; + // ink in the label gutter (x > 16) + const d = c.getContext('2d').getImageData(16, 0, c.width - 16, c.height).data; + let ink = 0; + for (let i = 3; i < d.length; i += 4) if (d[i] > 0) ink++; + return { w: c.width, left, fits, labelInk: ink }; + } + } + return null; + }""") + assert res is not None, "colorbar canvas not found" + assert res["fits"], f"colorbar extends past the panel edge: {res}" + assert res["labelInk"] > 0, f"colorbar label has no visible ink: {res}" diff --git a/anyplotlib/tests/test_layouts/test_batch.py b/anyplotlib/tests/test_layouts/test_batch.py new file mode 100644 index 00000000..21e6a548 --- /dev/null +++ b/anyplotlib/tests/test_layouts/test_batch.py @@ -0,0 +1,90 @@ +"""Tests for Figure.batch() push coalescing — the linked-view lag fix.""" +from __future__ import annotations + +import numpy as np +import anyplotlib as apl + + +def _fig3(): + fig = apl.Figure(figsize=(600, 200)) + gs = apl.GridSpec(1, 3) + axs = [fig.add_subplot(gs[0, c]) for c in range(3)] + px = [np.arange(16)] * 2 + plots = [a.imshow(np.zeros((16, 16, 3), dtype=np.uint8), axes=px) for a in axs] + return fig, plots + + +def _count_pushes(fig): + calls = {"n": 0} + orig = type(fig)._push + def counting(self, pid): + # count only real trait writes (batch dirty-marking returns early) + if not self._batching: + calls["n"] += 1 + return orig(self, pid) + type(fig)._push = counting + return calls, lambda: setattr(type(fig), "_push", orig) + + +class TestBatch: + def test_coalesces_multiple_pushes_per_panel(self): + fig, plots = _fig3() + calls, restore = _count_pushes(fig) + try: + with fig.batch(): + for p in plots: + p.set_data(np.ones((16, 16, 3), dtype=np.uint8)) + p.set_title("x") # 2nd mutation, same panel + # 3 panels × 2 mutations each = 6 mutations → 3 pushes + assert calls["n"] == 3, f"expected 3 coalesced pushes, got {calls['n']}" + finally: + restore() + + def test_without_batch_pushes_per_mutation(self): + fig, plots = _fig3() + calls, restore = _count_pushes(fig) + try: + for p in plots: + p.set_data(np.ones((16, 16, 3), dtype=np.uint8)) + p.set_title("x") + assert calls["n"] == 6, f"expected 6 pushes, got {calls['n']}" + finally: + restore() + + def test_batch_applies_state(self): + fig, plots = _fig3() + with fig.batch(): + plots[0].set_title("hello") + assert plots[0]._state["title"] == "hello" + # trait reflects the change after the block + import json + st = json.loads(getattr(fig, f"panel_{plots[0]._id}_json")) + assert st["title"] == "hello" + + def test_nested_batch_is_transparent(self): + fig, plots = _fig3() + calls, restore = _count_pushes(fig) + try: + with fig.batch(): + with fig.batch(): + plots[0].set_title("a") + plots[1].set_title("b") + assert calls["n"] == 2 + finally: + restore() + + def test_3d_view_and_highlight_coalesce(self): + fig = apl.Figure(figsize=(300, 300)) + ax = fig.add_subplot(apl.GridSpec(1, 1)[0, 0]) + v = ax.scatter3d(np.zeros(4), np.zeros(4), np.zeros(4), + bounds=((-1, 1),) * 3) + calls, restore = _count_pushes(fig) + try: + with fig.batch(): + v.set_highlight(0.1, 0.2, 0.3) + v.set_view(azimuth=10, elevation=20) + assert calls["n"] == 1, f"expected 1 coalesced push, got {calls['n']}" + assert v._state["highlight"]["x"] == 0.1 + assert v._state["azimuth"] == 10 + finally: + restore() diff --git a/anyplotlib/tests/test_layouts/test_geom_channel.py b/anyplotlib/tests/test_layouts/test_geom_channel.py new file mode 100644 index 00000000..9660b016 --- /dev/null +++ b/anyplotlib/tests/test_layouts/test_geom_channel.py @@ -0,0 +1,71 @@ +"""Tests for the geometry channel: heavy geometry rides a separate trait and +is re-sent only when it actually changes (view updates don't re-transmit it).""" +from __future__ import annotations + +import json +import numpy as np +import anyplotlib as apl + + +def _scatter(): + fig = apl.Figure(figsize=(300, 300)) + ax = fig.add_subplot(apl.GridSpec(1, 1)[0, 0]) + v = ax.scatter3d(np.zeros(8), np.zeros(8), np.zeros(8), + bounds=((-1, 1),) * 3, + colors=np.tile([1, 2, 3], (8, 1)).astype(np.uint8)) + return fig, v + + +class TestGeomChannel: + def test_geom_trait_allocated(self): + fig, v = _scatter() + assert fig.has_trait(f"panel_{v._id}_geom") + + def test_view_trait_excludes_geometry(self): + fig, v = _scatter() + view = json.loads(getattr(fig, f"panel_{v._id}_json")) + for k in ("vertices_b64", "faces_b64", "point_colors_b64", "colormap_data"): + assert k not in view, f"{k} leaked into the view trait" + assert view["_geom_rev"] >= 1 + + def test_geom_trait_contains_geometry(self): + fig, v = _scatter() + geom = json.loads(getattr(fig, f"panel_{v._id}_geom")) + assert "vertices_b64" in geom and "point_colors_b64" in geom + + def test_highlight_does_not_resend_geometry(self): + fig, v = _scatter() + gkey = f"panel_{v._id}_geom" + before = getattr(fig, gkey) + rev_before = json.loads(getattr(fig, f"panel_{v._id}_json"))["_geom_rev"] + v.set_highlight(0.1, 0.2, 0.3) + assert getattr(fig, gkey) == before, "geometry re-sent on highlight move" + rev_after = json.loads(getattr(fig, f"panel_{v._id}_json"))["_geom_rev"] + assert rev_after == rev_before, "geom_rev bumped without geometry change" + assert json.loads(getattr(fig, f"panel_{v._id}_json"))["highlight"]["x"] == 0.1 + + def test_view_change_does_not_resend_geometry(self): + fig, v = _scatter() + before = getattr(fig, f"panel_{v._id}_geom") + v.set_view(azimuth=42, elevation=15) + assert getattr(fig, f"panel_{v._id}_geom") == before + assert json.loads(getattr(fig, f"panel_{v._id}_json"))["azimuth"] == 42 + + def test_geometry_change_bumps_rev_and_resends(self): + fig, v = _scatter() + gkey = f"panel_{v._id}_geom" + before = getattr(fig, gkey) + rev_before = json.loads(getattr(fig, f"panel_{v._id}_json"))["_geom_rev"] + v.set_data(np.ones(8) * 3, np.ones(8) * 4, np.ones(8) * 5) # new geometry + assert getattr(fig, gkey) != before, "geometry change not re-sent" + rev_after = json.loads(getattr(fig, f"panel_{v._id}_json"))["_geom_rev"] + assert rev_after == rev_before + 1, "geom_rev not bumped on geometry change" + + def test_plot_without_geom_keys_unaffected(self): + # Plot1D declares no _GEOM_KEYS → single-trait path, no geom trait. + fig = apl.Figure(figsize=(300, 200)) + ax = fig.add_subplot(apl.GridSpec(1, 1)[0, 0]) + p = ax.plot(np.sin(np.linspace(0, 6, 64))) + assert not fig.has_trait(f"panel_{p._id}_geom") + view = json.loads(getattr(fig, f"panel_{p._id}_json")) + assert "data_b64" in view # geometry stays inline for non-split plots diff --git a/anyplotlib/tests/test_plot2d/test_imshow_rgb.py b/anyplotlib/tests/test_plot2d/test_imshow_rgb.py new file mode 100644 index 00000000..55776aa6 --- /dev/null +++ b/anyplotlib/tests/test_plot2d/test_imshow_rgb.py @@ -0,0 +1,117 @@ +""" +Tests for true-colour (H, W, 3|4) imshow support. + +Unit tests cover state encoding and dtype handling; Playwright tests verify +actual rendered pixel colours on the canvas. +""" +from __future__ import annotations + +import base64 + +import numpy as np +import pytest + +import anyplotlib as apl + + +def _rgb_quadrants(n=32): + """Image with pure-red TL, pure-green TR, pure-blue BL, white BR.""" + img = np.zeros((n, n, 3), dtype=np.uint8) + h = n // 2 + img[:h, :h] = [255, 0, 0] + img[:h, h:] = [0, 255, 0] + img[h:, :h] = [0, 0, 255] + img[h:, h:] = [255, 255, 255] + return img + + +class TestRgbState: + def test_uint8_rgb_sets_state(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(_rgb_quadrants()) + assert v._state["is_rgb"] is True + raw = base64.b64decode(v._state["image_b64"]) + assert len(raw) == 32 * 32 * 4 # RGBA bytes + assert raw[0:4] == bytes([255, 0, 0, 255]) + + def test_float_01_rgb_scaled(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.full((4, 4, 3), 0.5)) + raw = base64.b64decode(v._state["image_b64"]) + assert raw[0] == 127 or raw[0] == 128 # 0.5 * 255 + + def test_rgba_alpha_preserved(self): + img = np.zeros((4, 4, 4), dtype=np.uint8) + img[..., 3] = 99 + fig, ax = apl.subplots(1, 1) + v = ax.imshow(img) + raw = base64.b64decode(v._state["image_b64"]) + assert raw[3] == 99 + + def test_grayscale_unchanged(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((8, 8))) + assert v._state["is_rgb"] is False + assert len(base64.b64decode(v._state["image_b64"])) == 64 # 1 byte/px + + def test_two_channel_raises(self): + fig, ax = apl.subplots(1, 1) + with pytest.raises(ValueError, match="3 .RGB. or 4"): + ax.imshow(np.zeros((8, 8, 2))) + + def test_set_data_switches_modes(self): + fig, ax = apl.subplots(1, 1) + v = ax.imshow(np.zeros((8, 8))) + v.set_data(_rgb_quadrants(8)) + assert v._state["is_rgb"] is True + v.set_data(np.zeros((8, 8))) + assert v._state["is_rgb"] is False + + def test_origin_lower_flips_rgb(self): + img = np.zeros((2, 2, 3), dtype=np.uint8) + img[0, 0] = [255, 0, 0] # red in row 0 + fig, ax = apl.subplots(1, 1) + v = ax.imshow(img, origin="lower") + raw = base64.b64decode(v._state["image_b64"]) + # flipud → red pixel is now in the LAST row, first column + last_row_first_px = raw[(2 * 1 + 0) * 4: (2 * 1 + 0) * 4 + 4] + assert last_row_first_px == bytes([255, 0, 0, 255]) + + +class TestRgbRendering: + def test_quadrant_colors_on_canvas(self, interact_page): + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + ax.imshow(_rgb_quadrants()) + page = interact_page(fig) + page.wait_for_timeout(150) + + px = page.evaluate("""() => { + const c = document.querySelector('canvas'); + const ctx = c.getContext('2d'); + const w = c.width, h = c.height; + const grab = (fx, fy) => Array.from( + ctx.getImageData(Math.round(w*fx), Math.round(h*fy), 1, 1).data); + return { tl: grab(0.25, 0.25), tr: grab(0.75, 0.25), + bl: grab(0.25, 0.75), br: grab(0.75, 0.75) }; + }""") + assert px["tl"][:3] == [255, 0, 0], f"top-left not red: {px['tl']}" + assert px["tr"][:3] == [0, 255, 0], f"top-right not green: {px['tr']}" + assert px["bl"][:3] == [0, 0, 255], f"bottom-left not blue: {px['bl']}" + assert px["br"][:3] == [255, 255, 255], f"bottom-right not white: {px['br']}" + + def test_colorbar_suppressed_for_rgb(self, interact_page): + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + q = np.linspace(0, 1, 32) + v = ax.imshow(_rgb_quadrants(), axes=[q, q]) + v.set_colorbar_visible(True) # must be ignored for RGB + page = interact_page(fig) + page.wait_for_timeout(150) + visible = page.evaluate("""() => { + for (const c of document.querySelectorAll('canvas')) { + const left = parseFloat(c.style.left || '0'); + if (c.width <= 80 && left > 150 && c.style.display !== 'none') + return true; // a visible colorbar-sized canvas + } + return false; + }""") + assert not visible, "colorbar must stay hidden for RGB images" diff --git a/anyplotlib/tests/test_plot3d/test_colors_highlight.py b/anyplotlib/tests/test_plot3d/test_colors_highlight.py new file mode 100644 index 00000000..574539d8 --- /dev/null +++ b/anyplotlib/tests/test_plot3d/test_colors_highlight.py @@ -0,0 +1,174 @@ +""" +Tests for Plot3D per-point scatter colors, the highlight point, and the +bounds override — the capabilities behind the IPF explorer example. +""" +from __future__ import annotations + +import base64 + +import numpy as np +import pytest + +import anyplotlib as apl + + +def _scatter(**kwargs): + fig, ax = apl.subplots(1, 1, figsize=(300, 300)) + pts = np.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]) + return ax.scatter3d(pts[:, 0], pts[:, 1], pts[:, 2], **kwargs) + + +class TestPointColors: + def test_hex_list(self): + v = _scatter(colors=["#ff0000", "#00ff00", "#0000ff"]) + raw = base64.b64decode(v._state["point_colors_b64"]) + assert list(raw) == [255, 0, 0, 0, 255, 0, 0, 0, 255] + + def test_float_array(self): + v = _scatter(colors=np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1.0]])) + raw = base64.b64decode(v._state["point_colors_b64"]) + assert list(raw) == [255, 0, 0, 0, 255, 0, 0, 0, 255] + + def test_wrong_length_raises(self): + with pytest.raises(ValueError, match="2 colors for 3 points"): + _scatter(colors=["#ff0000", "#00ff00"]) + + def test_colors_on_surface_raises(self): + fig, ax = apl.subplots(1, 1) + g = np.linspace(0, 1, 4) + XX, YY = np.meshgrid(g, g) + with pytest.raises(ValueError, match="only supported for scatter"): + apl.Plot3D("surface", XX, YY, XX * YY, colors=["#fff"] * 16) + + def test_set_point_colors_update_and_clear(self): + v = _scatter() + assert v._state["point_colors_b64"] == "" + v.set_point_colors(["#112233"] * 3) + assert v._state["point_colors_b64"] != "" + v.set_point_colors(None) + assert v._state["point_colors_b64"] == "" + + +class TestHighlight: + def test_set_and_clear(self): + v = _scatter() + v.set_highlight(0.1, 0.2, 0.3, color="#ffffff", size=9) + hl = v._state["highlight"] + assert hl == {"x": 0.1, "y": 0.2, "z": 0.3, + "color": "#ffffff", "size": 9.0} + v.clear_highlight() + assert v._state["highlight"] is None + + +class TestSphere: + def test_set_and_clear(self): + v = _scatter(bounds=((-1, 1),) * 3) + v.set_sphere(1.0, color="#777777", alpha=0.2, wireframe=False) + assert v._state["sphere"] == {"radius": 1.0, "color": "#777777", + "alpha": 0.2, "wireframe": False} + v.clear_sphere() + assert v._state["sphere"] is None + + def test_sphere_renders_silhouette(self, interact_page): + """The shaded disk + wireframe must add substantial ink, bounded by + the silhouette circle.""" + def ink(with_sphere): + v = _scatter(bounds=((-1, 1),) * 3, point_size=2) + v.set_axis_off() + if with_sphere: + v.set_sphere(1.0) + page = interact_page(v._fig) + page.wait_for_timeout(200) + return page.evaluate("""() => { + const c = [...document.querySelectorAll('canvas')].find(x => x.style.position === 'relative' && x.style.display !== 'none'); + const d = c.getContext('2d').getImageData(0,0,c.width,c.height).data; + // count pixels that differ from the corner background + const bg = [d[0], d[1], d[2]]; + let n = 0; + for (let i = 0; i < d.length; i += 4) { + if (Math.abs(d[i]-bg[0])+Math.abs(d[i+1]-bg[1]) + +Math.abs(d[i+2]-bg[2]) > 24) n++; + } + return n; + }""") + + without = ink(False) + with_s = ink(True) + assert with_s > without + 2000, ( + f"sphere added too little ink: {without} -> {with_s}") + + +class TestBoundsOverride: + def test_bounds_fix_data_bounds(self): + v = _scatter(bounds=((-1, 1), (-1, 1), (-1, 1))) + assert v._state["data_bounds"] == { + "xmin": -1.0, "xmax": 1.0, "ymin": -1.0, "ymax": 1.0, + "zmin": -1.0, "zmax": 1.0} + + def test_set_data_preserves_bounds(self): + v = _scatter(bounds=((-1, 1),) * 3) + v.set_data([0.5], [0.5], [0.5]) + assert v._state["data_bounds"]["xmin"] == -1.0 + + def test_default_bounds_fit_data(self): + v = _scatter() + assert v._state["data_bounds"]["xmax"] == 1.0 + assert v._state["data_bounds"]["xmin"] == 0.0 + + +class TestRendering: + def test_colored_points_and_highlight_render(self, interact_page): + """Pure-coloured points and a white highlight must appear on canvas.""" + v = _scatter(colors=["#ff0000", "#00ff00", "#0000ff"], + point_size=10, bounds=((-1, 1),) * 3) + v.set_axis_off() + v.set_highlight(-0.6, -0.6, -0.6, color="#ffffff", size=9) + fig = v._fig + page = interact_page(fig) + page.wait_for_timeout(200) + + found = page.evaluate("""() => { + const c = [...document.querySelectorAll('canvas')].find(x => x.style.position === 'relative' && x.style.display !== 'none'); + const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data; + const seen = { red: false, green: false, blue: false, white: false }; + for (let i = 0; i < d.length; i += 4) { + const r = d[i], g = d[i+1], b = d[i+2]; + if (r > 220 && g < 60 && b < 60) seen.red = true; + if (g > 220 && r < 60 && b < 60) seen.green = true; + if (b > 220 && r < 60 && g < 60) seen.blue = true; + if (r > 240 && g > 240 && b > 240) seen.white = true; + } + return seen; + }""") + assert found["red"] and found["green"] and found["blue"], ( + f"per-point colours missing from canvas: {found}") + assert found["white"], f"highlight dot missing from canvas: {found}" + + def test_highlight_moves_with_set_view(self, interact_page): + """After rotate-to-face, the highlight must sit near panel centre.""" + v = _scatter(bounds=((-1, 1),) * 3, point_size=2) + v.set_axis_off() + d = np.array([0.3, 0.4, 0.866]) + d = d / np.linalg.norm(d) + v.set_highlight(*d, color="#ff00ff", size=8) + # Turntable face-camera: el = asin(vz), az = atan2(vx, -vy) + el = float(np.degrees(np.arcsin(np.clip(d[2], -1, 1)))) + az = float(np.degrees(np.arctan2(d[0], -d[1]))) + v.set_view(azimuth=az, elevation=el) + page = interact_page(v._fig) + page.wait_for_timeout(200) + + pos = page.evaluate("""() => { + const c = [...document.querySelectorAll('canvas')].find(x => x.style.position === 'relative' && x.style.display !== 'none'); + const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data; + let sx = 0, sy = 0, n = 0; + for (let y = 0; y < c.height; y++) for (let x = 0; x < c.width; x++) { + const i = (y * c.width + x) * 4; + if (d[i] > 220 && d[i+1] < 80 && d[i+2] > 220) { sx += x; sy += y; n++; } + } + return n ? { x: sx / n, y: sy / n, n, w: c.width, h: c.height } : null; + }""") + assert pos is not None, "magenta highlight not found on canvas" + # Facing the camera ⇒ projected at the panel centre (within tolerance) + assert abs(pos["x"] - pos["w"] / 2) < 6, f"highlight off-centre x: {pos}" + assert abs(pos["y"] - pos["h"] / 2) < 6, f"highlight off-centre y: {pos}" diff --git a/anyplotlib/tests/test_plot3d/test_gpu_fallback.py b/anyplotlib/tests/test_plot3d/test_gpu_fallback.py new file mode 100644 index 00000000..0477e1e6 --- /dev/null +++ b/anyplotlib/tests/test_plot3d/test_gpu_fallback.py @@ -0,0 +1,170 @@ +""" +Tests for the WebGPU scatter path — focused on the FALLBACK CONTRACT. + +A real GPU adapter is rarely available in CI (headless Chromium exposes +``navigator.gpu`` but ``requestAdapter()`` returns null without Vulkan/ +lavapipe), so these tests assert the thing that must hold *everywhere*: +when the GPU is unavailable, a GPU-requesting scatter renders identically +to the Canvas2D path and ``gpu_active`` reports False. + +The actual GPU render is validated manually on a real-GPU machine; see +WEBGPU_PLAN.md Phase 1 acceptance. +""" +from __future__ import annotations + +import json + +import numpy as np +import pytest + +import anyplotlib as apl + + +def _scatter(n=100, **kwargs): + fig, ax = apl.subplots(1, 1, figsize=(320, 320)) + rng = np.random.default_rng(1) + pts = rng.uniform(-1, 1, size=(n, 3)) + return ax.scatter3d(pts[:, 0], pts[:, 1], pts[:, 2], + bounds=((-1, 1),) * 3, **kwargs) + + +class TestGpuApi: + def test_default_mode_auto(self): + assert _scatter()._state["gpu_mode"] == "auto" + + def test_gpu_true_is_always(self): + assert _scatter(gpu=True)._state["gpu_mode"] == "always" + + def test_gpu_false_is_off(self): + assert _scatter(gpu=False)._state["gpu_mode"] == "off" + + def test_gpu_active_starts_false(self): + assert _scatter()._gpu_active is False + assert _scatter().gpu_active is False + + def test_gpu_status_echo_updates_active(self): + v = _scatter() + fig = v._fig + fig._dispatch_event(json.dumps({ + "panel_id": v._id, "event_type": "gpu_status", "gpu_active": True})) + assert v.gpu_active is True + fig._dispatch_event(json.dumps({ + "panel_id": v._id, "event_type": "gpu_status", "gpu_active": False})) + assert v.gpu_active is False + + def test_gpu_only_for_scatter(self): + # voxels/surface don't carry gpu_mode into the GPU path (Phase 1 = + # points only); the kwarg simply isn't offered there. Sanity: scatter + # has the field, surface does not error. + assert "gpu_mode" in _scatter()._state + + +class TestFallbackRendersOnCanvas: + """gpu='always' with no adapter MUST render via Canvas2D, unchanged.""" + + def _red_ink(self, page): + return page.evaluate("""() => { + const cs = [...document.querySelectorAll('canvas')]; + const c = cs.find(x => !x.style.zIndex || x.style.zIndex === '1'); + const d = c.getContext('2d').getImageData(0,0,c.width,c.height).data; + let red = 0; + for (let i = 0; i < d.length; i += 4) + if (d[i] > 180 && d[i+1] < 140 && d[i+2] < 140) red++; + return red; + }""") + + def test_always_falls_back_to_canvas(self, interact_page): + v = _scatter(n=2000, gpu="always", + colors=np.tile([255, 80, 80], (2000, 1)).astype(np.uint8), + point_size=4) + v.set_axis_off() + page = interact_page(v._fig) + page.wait_for_timeout(400) # allow the async device probe to resolve + # When requestAdapter() is null the gpuCanvas stays hidden … + disp = page.evaluate("""() => { + const g = [...document.querySelectorAll('canvas')] + .find(c => c.style.zIndex === '0'); + return g ? g.style.display : 'none'; + }""") + assert disp == 'none', "gpuCanvas must stay hidden without an adapter" + # … and the points still render on the 2D canvas. + assert self._red_ink(page) > 500, "canvas fallback produced no points" + + def test_auto_small_cloud_uses_canvas(self, interact_page): + """Below the threshold, 'auto' never even probes the GPU.""" + v = _scatter(n=500, gpu="auto", + colors=np.tile([255, 80, 80], (500, 1)).astype(np.uint8), + point_size=4) + v.set_axis_off() + page = interact_page(v._fig) + page.wait_for_timeout(300) + assert self._red_ink(page) > 200 + + def test_gpu_off_renders_canvas(self, interact_page): + v = _scatter(n=1000, gpu=False, + colors=np.tile([255, 80, 80], (1000, 1)).astype(np.uint8), + point_size=4) + v.set_axis_off() + page = interact_page(v._fig) + page.wait_for_timeout(300) + assert self._red_ink(page) > 300 + + def test_no_console_errors_on_fallback(self, interact_page): + v = _scatter(n=2000, gpu="always") + v.set_axis_off() + page = interact_page(v._fig) + errors = [] + page.on("pageerror", lambda e: errors.append(str(e))) + page.wait_for_timeout(400) + assert not errors, f"GPU fallback raised page errors: {errors}" + + +def _voxels(n_side=8, **kwargs): + fig, ax = apl.subplots(1, 1, figsize=(320, 320)) + g = np.arange(0, n_side, dtype=float) + zz, yy, xx = np.meshgrid(g, g, g, indexing="ij") + return ax.voxels(xx.ravel(), yy.ravel(), zz.ravel(), + bounds=((0, n_side - 1),) * 3, **kwargs) + + +class TestVoxelGpuFallback: + """gpu='always' voxels with no adapter MUST render via Canvas2D.""" + + def test_voxel_gpu_mode_state(self): + assert _voxels(gpu=True)._state["gpu_mode"] == "always" + assert _voxels(gpu=False)._state["gpu_mode"] == "off" + assert _voxels()._state["gpu_mode"] == "auto" + + def test_voxel_always_falls_back_to_canvas(self, interact_page): + colors = np.tile([255, 60, 60], (512, 1)).astype(np.uint8) + v = _voxels(colors=colors, alpha=0.4, gpu="always") + v.set_axis_off() + page = interact_page(v._fig) + page.wait_for_timeout(400) + disp = page.evaluate("""() => { + const g = [...document.querySelectorAll('canvas')] + .find(c => c.style.zIndex === '0'); + return g ? g.style.display : 'none'; + }""") + assert disp == 'none', "voxel gpuCanvas must stay hidden without adapter" + red = page.evaluate("""() => { + const c = [...document.querySelectorAll('canvas')] + .find(x => x.style.position === 'relative' && x.style.display !== 'none'); + const d = c.getContext('2d').getImageData(0,0,c.width,c.height).data; + let r = 0; + for (let i = 0; i < d.length; i += 4) + if (d[i] > 120 && d[i+1] < 120 && d[i+2] < 120) r++; + return r; + }""") + assert red > 500, "voxel canvas fallback produced no cubes" + + def test_voxel_gpu_no_console_errors(self, interact_page): + v = _voxels(colors=np.tile([200, 80, 80], (512, 1)).astype(np.uint8), + gpu="always") + v.set_axis_off() + v.add_widget("plane", axis="z", position=4) + page = interact_page(v._fig) + errors = [] + page.on("pageerror", lambda e: errors.append(str(e))) + page.wait_for_timeout(400) + assert not errors, f"GPU voxel fallback raised errors: {errors}" diff --git a/anyplotlib/tests/test_plot3d/test_voxels_planes.py b/anyplotlib/tests/test_plot3d/test_voxels_planes.py new file mode 100644 index 00000000..a70a6093 --- /dev/null +++ b/anyplotlib/tests/test_plot3d/test_voxels_planes.py @@ -0,0 +1,158 @@ +""" +Tests for the 'voxels' geometry and 3-D PlaneWidget slice selectors. +""" +from __future__ import annotations + +import json + +import numpy as np +import pytest + +import anyplotlib as apl + + +def _voxels(**kwargs): + fig, ax = apl.subplots(1, 1, figsize=(320, 320)) + g = np.arange(0, 8, dtype=float) + zz, yy, xx = np.meshgrid(g, g, g, indexing="ij") + return ax.voxels(xx.ravel(), yy.ravel(), zz.ravel(), + bounds=((0, 7),) * 3, **kwargs) + + +class TestVoxelsState: + def test_geom_and_alpha_state(self): + v = _voxels(size=1.0, alpha=0.2) + assert v._state["geom_type"] == "voxels" + assert v._state["voxel_size"] == 1.0 + assert v._state["voxel_alpha"] == 0.2 + assert v._state["voxel_slice_alpha"] == 0.95 + + def test_per_voxel_colors_allowed(self): + colors = np.zeros((512, 3), dtype=np.uint8) + v = _voxels(colors=colors) + assert v._state["point_colors_b64"] != "" + + def test_set_voxel_alpha(self): + v = _voxels() + v.set_voxel_alpha(0.1, slice_alpha=0.8) + assert v._state["voxel_alpha"] == 0.1 + assert v._state["voxel_slice_alpha"] == 0.8 + + +class TestPlaneWidget: + def test_add_plane_serialises(self): + v = _voxels() + pw = v.add_widget("plane", axis="z", position=4, color="#40c4ff") + ws = v._state["overlay_widgets"] + assert len(ws) == 1 + assert ws[0]["type"] == "plane" + assert ws[0]["axis"] == "z" + assert ws[0]["position"] == 4.0 + + def test_invalid_axis_raises(self): + v = _voxels() + with pytest.raises(ValueError, match="axis must be"): + v.add_widget("plane", axis="w", position=0) + + def test_only_plane_kind(self): + v = _voxels() + with pytest.raises(ValueError, match="only 'plane'"): + v.add_widget("crosshair") + + def test_set_position_from_python(self): + v = _voxels() + pw = v.add_widget("plane", axis="x", position=2) + pw.set(position=5) + assert pw.position == 5 + + def test_remove_widget(self): + v = _voxels() + pw = v.add_widget("plane", axis="y", position=3) + v.remove_widget(pw) + v._push() + assert v._state["overlay_widgets"] == [] + + def test_js_drag_event_round_trip(self): + """A JS plane-drag message must update position and fire callbacks.""" + v = _voxels() + pw = v.add_widget("plane", axis="z", position=4) + fig = v._fig + got = [] + + @pw.add_event_handler("pointer_move") + def on_drag(event): + got.append(pw.position) + + fig._dispatch_event(json.dumps({ + "panel_id": v._id, "widget_id": pw.id, + "event_type": "pointer_move", "axis": "z", "position": 6.25, + })) + assert got == [6.25] + assert pw.position == 6.25 + + +class TestVoxelRendering: + def test_voxels_render_with_slice_emphasis(self, interact_page): + """Voxels render; an on-plane slice draws more saturated ink.""" + colors = np.full((512, 3), [255, 0, 0], dtype=np.uint8) + v = _voxels(colors=colors, alpha=0.15) + v.set_axis_off() + v.add_widget("plane", axis="z", position=3, alpha=0.0) # invisible plane + page = interact_page(v._fig) + page.wait_for_timeout(250) + + res = page.evaluate("""() => { + const c = [...document.querySelectorAll('canvas')].find(x => x.style.position === 'relative' && x.style.display !== 'none'); + const d = c.getContext('2d').getImageData(0,0,c.width,c.height).data; + let pale = 0, strong = 0; + for (let i = 0; i < d.length; i += 4) { + const r = d[i], g = d[i+1], b = d[i+2]; + if (r > 180 && g < 160 && b < 160) { + if (g > 60) pale++; else strong++; // strong = opaque red + } + } + return { pale, strong }; + }""") + assert res["pale"] > 500, f"translucent voxel ink missing: {res}" + assert res["strong"] > 200, f"opaque slice-plane voxels missing: {res}" + + def test_plane_drag_in_browser(self, interact_page): + """Dragging a plane widget must change its position in the model.""" + v = _voxels(alpha=0.1) + v.set_axis_off() + pw = v.add_widget("plane", axis="z", position=3, alpha=0.3) + fig = v._fig + page = interact_page(fig) + page.wait_for_timeout(250) + + def js_position(): + return page.evaluate(f"""() => {{ + const st = JSON.parse(window._aplModel.get('panel_{v._id}_json')); + return st.overlay_widgets[0].position; + }}""") + + assert abs(js_position() - 3) < 1e-6 + # Locate the plane via its fully-opaque cyan border pixels, then drag + # from its centroid upward (the z screen-direction at the default view) + centre = page.evaluate("""() => { + const c = [...document.querySelectorAll('canvas')].find(x => x.style.position === 'relative' && x.style.display !== 'none'); + const r = c.getBoundingClientRect(); + const d = c.getContext('2d').getImageData(0,0,c.width,c.height).data; + let sx = 0, sy = 0, n = 0; + for (let y = 0; y < c.height; y++) for (let x = 0; x < c.width; x++) { + const i = (y * c.width + x) * 4; + if (d[i] < 60 && d[i+1] > 200 && d[i+2] > 230) { + sx += x; sy += y; n++; + } + } + return n ? { x: r.left + sx / n, y: r.top + sy / n, n } : null; + }""") + assert centre is not None, "plane border pixels not found on canvas" + page.mouse.move(centre["x"], centre["y"]) + page.mouse.down() + page.mouse.move(centre["x"], centre["y"] - 50, steps=8) + page.mouse.up() + page.wait_for_timeout(250) + moved = js_position() + assert abs(moved - 3) > 0.5, ( + f"plane did not move on drag (position still {moved})") diff --git a/anyplotlib/widgets/_widgets3d.py b/anyplotlib/widgets/_widgets3d.py new file mode 100644 index 00000000..c3660534 --- /dev/null +++ b/anyplotlib/widgets/_widgets3d.py @@ -0,0 +1,50 @@ +""" +widgets/_widgets3d.py +===================== +Interactive overlay widgets for 3-D panels. +""" + +from __future__ import annotations + +from typing import Callable + +from anyplotlib.widgets._base import Widget + + +class PlaneWidget(Widget): + """A draggable axis-aligned plane in a 3-D panel. + + Rendered as a translucent quad spanning the panel's bounds, + perpendicular to *axis* at *position*. Drag it in the browser to slide + it along its normal — ideal as a slice selector for voxel volumes. + Voxels lying on a plane are rendered more opaque (see + :meth:`~anyplotlib.Axes.voxels`). + + Parameters + ---------- + axis : ``"x"`` | ``"y"`` | ``"z"`` + The plane's normal axis. + position : float + Position along *axis* in data coordinates. + color : str, optional + CSS colour of the plane fill and border. + alpha : float, optional + Fill opacity (0–1). Default 0.12. + + Examples + -------- + >>> pw = vol.add_widget("plane", axis="z", position=24) + >>> @pw.add_event_handler("pointer_move") + ... def on_drag(event): + ... print("slice now at", pw.position) + >>> pw.set(position=10) # move it from Python + """ + + def __init__(self, push_fn: Callable, axis: str = "z", + position: float = 0.0, color: str = "#00e5ff", + alpha: float = 0.12): + if axis not in ("x", "y", "z"): + raise ValueError(f"axis must be 'x', 'y', or 'z', got {axis!r}") + super().__init__("plane", push_fn, + axis=axis, position=float(position), + color=color, alpha=float(alpha)) diff --git a/docs/embedding.rst b/docs/embedding.rst new file mode 100644 index 00000000..b90a9ec8 --- /dev/null +++ b/docs/embedding.rst @@ -0,0 +1,181 @@ +================================= +Embedding outside Jupyter +================================= + +anyplotlib figures do not require Jupyter, ipywidgets, or the anywidget +runtime. The renderer is a single self-contained ES module +(``figure_esm.js``) that draws from a plain JSON state dict, so a figure can +live anywhere a browser engine runs: an **Electron** app, a Tauri/webview +app, an MDI-style multi-window workspace, a kiosk dashboard, or a static +web page. + +There are three levels of integration, from zero-Python-at-runtime to a +fully live Python backend. + +Level 1 — self-contained HTML (no Python at view time) +======================================================= + +Export the figure as a single HTML file with the renderer and all data +inlined:: + + import anyplotlib as apl + import numpy as np + + fig, ax = apl.subplots(1, 1, figsize=(800, 500)) + ax.imshow(np.load("frame.npy"), cmap="viridis") + fig.save_html("plot.html") + +Load it in an Electron window — that's the whole integration:: + + const { BrowserWindow } = require('electron'); + const win = new BrowserWindow({ width: 840, height: 560 }); + win.loadFile('plot.html'); + +Pan, zoom, overlay widgets, markers, and keyboard shortcuts all work; +Python callbacks (obviously) do not. ``fig.to_html()`` returns the same +page as a string if you want to serve or template it yourself. + +Level 2 — JS-driven: your app owns the data +============================================ + +Bundle ``figure_esm.js`` into your app (``anyplotlib.embed.esm_path()`` +tells you where to copy it from) and mount figures directly from +JavaScript: + +.. code-block:: javascript + + import { mount } from './figure_esm.js'; + + const handle = mount(document.getElementById('plot-host'), state, { + onEvent: (ev) => { + // every interaction event: pointer_down/up/move, wheel, key_down … + if (ev.event_type === 'pointer_down') + console.log('clicked data coords', ev.xdata, ev.ydata); + }, + }); + + // Live updates — replace one panel's state and it re-renders: + handle.setPanelState(panelId, newPanelState); + handle.resize(900, 600); + handle.dispose(); // remove the figure's DOM + +``state`` is the figure-state dict. Generate it from Python once (at build +time or via a one-shot script):: + + import json, anyplotlib as apl + from anyplotlib.embed import figure_state + + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(template_data) + json.dump(figure_state(fig), open("figure_state.json", "w")) + print("panel id:", plot._id) # key for setPanelState + +Each ``mount()`` call is fully independent — mount as many figures as you +like into separate containers in one window. This is the natural fit for +**MDI sub-windows**: give every sub-window its own host ``
`` (or +````/iframe for hard isolation) and call ``mount`` per window. +Call ``handle.resize(w, h)`` from your sub-window's resize hook. + +Level 3 — live Python backend (full callback support) +====================================================== + +Run Python next to your app (a sidecar process exposing a local WebSocket +is the common Electron pattern) and keep figures *fully* interactive — +``@plot.add_event_handler(...)`` callbacks fire exactly as in Jupyter. + +:class:`anyplotlib.embed.FigureBridge` is transport-agnostic: you supply +the pipe, it supplies the ``(key, value)`` protocol. + +**Python sidecar** (here with the ``websockets`` package):: + + import asyncio, json + import numpy as np + import websockets + import anyplotlib as apl + from anyplotlib.embed import FigureBridge + + fig, ax = apl.subplots(1, 1, figsize=(700, 450)) + plot = ax.imshow(np.random.rand(256, 256)) + cross = plot.add_widget("crosshair", cx=128, cy=128) + + async def serve(ws): + loop = asyncio.get_running_loop() + bridge = FigureBridge(fig, send=lambda key, value: + loop.create_task(ws.send(json.dumps({"key": key, "value": value})))) + await ws.send(json.dumps({"snapshot": bridge.snapshot()})) + + @cross.add_event_handler("pointer_move") # fires from Electron! + def follow(event): + print("crosshair at", cross.cx, cross.cy) + + async for message in ws: + m = json.loads(message) + bridge.receive(m["key"], m["value"]) # JS → Python + + asyncio.run(websockets.serve(serve, "localhost", 8765)) + +**Electron renderer**: + +.. code-block:: javascript + + import { mount } from './figure_esm.js'; + + const ws = new WebSocket('ws://localhost:8765'); + let handle = null; + + ws.onmessage = (msg) => { + const m = JSON.parse(msg.data); + if (m.snapshot) { + handle = mount(document.getElementById('plot-host'), m.snapshot, { + // forward every JS-side write (events, view changes) to Python + onSync: (key, value) => ws.send(JSON.stringify({ key, value })), + }); + } else if (handle) { + handle.applyUpdate(m.key, m.value); // Python → JS, echo-free + } + }; + +Any Python-side mutation — ``plot.set_data(...)``, markers, titles, layout +changes — streams to the window automatically; drags, clicks, and keys +stream back into your Python callbacks. Echo is suppressed in both +directions by the bridge and ``applyUpdate``. + +API reference +============= + +.. automodule:: anyplotlib.embed + :members: + :undoc-members: + +JS handle reference +------------------- + +``mount(el, state, opts) → handle`` + +================================ ============================================ +``handle.setPanelState(id, st)`` Replace one panel's state (dict or JSON + string) and re-render it. +``handle.set(key, value)`` Raw model write + sync flush. +``handle.get(key)`` Read any model key. +``handle.applyUpdate(key, v)`` Apply a Python-originated update without + echoing it back through ``onSync``. +``handle.resize(w, h)`` Resize the figure (CSS pixels). +``handle.dispose()`` Remove the figure's DOM and listeners. +``handle.model`` The underlying local model (advanced). +================================ ============================================ + +``opts.onEvent(ev)`` receives parsed interaction events (the same payloads +Python's :class:`~anyplotlib.Event` carries); ``opts.onSync(key, value)`` +receives every outbound model write for bridging to Python. + +Notes and caveats +================= + +* The state dict is the **wire format**, not a stable public schema — treat + panel-state internals as opaque where you can, and prefer regenerating + states from Python when upgrading anyplotlib versions. +* ``dispose()`` removes the figure's DOM; for hard teardown of all + window-level listeners, host each figure in its own iframe/webview and + drop the frame (this is also the most robust MDI isolation). +* One renderer file, no build step: ``figure_esm.js`` has no imports, so it + works with any bundler or directly as a ``