From 1115f8b6557b2238553d04844fdaad21dc80b9e8 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 21 Oct 2026 13:45:44 -0500 Subject: [PATCH 01/24] callbacks: accept bound methods as event handlers add_event_handler stamped _event_types onto every handler, which a bound method refuses, so registering obj.method raised AttributeError. The stamp is now best-effort, and handlers are keyed by (instance, function) for bound methods so remove_handler(obj.method) finds the registration even though each attribute access builds a new method object. Assisted-by: claude-code:claude-opus-5-5 --- anyplotlib/callbacks.py | 42 +++++++-- .../test_callbacks_bound_methods.py | 89 +++++++++++++++++++ .../+bound-method-handlers.bugfix.rst | 3 + 3 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 anyplotlib/tests/test_interactive/test_callbacks_bound_methods.py create mode 100644 upcoming_changes/+bound-method-handlers.bugfix.rst diff --git a/anyplotlib/callbacks.py b/anyplotlib/callbacks.py index 2c6cc5188..f04a06469 100644 --- a/anyplotlib/callbacks.py +++ b/anyplotlib/callbacks.py @@ -146,6 +146,23 @@ def __repr__(self) -> str: return "Event(" + ", ".join(parts) + ")" +def _fn_key(fn: Callable): + """Return a stable identity key for a handler. + + A bound method is a *new* object on every attribute access + (``obj.method is obj.method`` is ``False``), so ``id(fn)`` cannot identify + it: ``remove_handler(obj.method)`` would never find the handler registered + with ``add_event_handler(obj.method, ...)``. Key bound methods by their + ``(instance, function)`` pair instead, which is what makes two accesses + compare equal. + """ + self_ = getattr(fn, "__self__", None) + func = getattr(fn, "__func__", None) + if self_ is not None and func is not None: + return (id(self_), id(func)) + return id(fn) + + class CallbackRegistry: """Per-object handler store. @@ -163,8 +180,8 @@ def __init__(self) -> None: 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) + # {_fn_key(fn): set[int]} — which cids this fn owns + self._fn_map: dict = defaultdict(set) # pause/hold state (populated in Task 3) self._pause_counts: dict[str, int] = {} self._hold_counts: dict[str, int] = {} @@ -184,7 +201,7 @@ def connect(self, event_type: str, fn: Callable, *, order: float = 0) -> int: 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) + self._fn_map[_fn_key(fn)].add(cid) return cid def disconnect(self, cid: int) -> None: @@ -199,7 +216,7 @@ def disconnect(self, cid: int) -> None: 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())): + for cid in list(self._fn_map.get(_fn_key(fn), set())): cid_types = self._cid_map.get(cid, set()) if not types or cid_types & set(types): self.disconnect(cid) @@ -300,10 +317,17 @@ def handler(event): ... @plot.add_event_handler("pointer_settled", ms=400, delta=5) def on_settle(event): ... + Any callable works as a handler, including bound methods:: + + plot.add_event_handler(self.on_move, "pointer_move") + plot.remove_handler(self.on_move) # finds the same registration + Parameters ---------- fn_or_type : callable or str - Handler function (direct call) or first event type string (decorator). + Handler (direct call) or first event type string (decorator). + Plain functions, lambdas, bound methods and other callables are + all accepted. *args : str Remaining event type strings. order : float @@ -338,7 +362,13 @@ def _register( 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) + try: + fn._event_types = getattr(fn, "_event_types", set()) | set(types) + except AttributeError: + # Bound methods (and builtins / functools.partial with __slots__) + # refuse new attributes. The stamp is informational only, so a + # handler that cannot carry it is still registered. + pass return fn def remove_handler(self, cid_or_fn, *types: str) -> None: diff --git a/anyplotlib/tests/test_interactive/test_callbacks_bound_methods.py b/anyplotlib/tests/test_interactive/test_callbacks_bound_methods.py new file mode 100644 index 000000000..eda9f788c --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_callbacks_bound_methods.py @@ -0,0 +1,89 @@ +""" +``add_event_handler`` with bound methods. + +``_register`` stamps ``_event_types`` onto the handler, which a bound method +refuses — so registering ``obj.method`` used to raise ``AttributeError`` and +callers had to wrap every method in a plain function. Removal by +``remove_handler(obj.method)`` also has to work, even though each attribute +access builds a new bound-method object. +""" +from __future__ import annotations + +import functools + +import numpy as np + +import anyplotlib as apl +from anyplotlib.callbacks import Event + + +class _Listener: + def __init__(self): + self.seen = [] + + def on_event(self, event): + self.seen.append(event.event_type) + + +def _plot(): + fig, ax = apl.subplots(1, 1) + return ax.imshow(np.zeros((12, 25))) + + +class TestBoundMethodHandlers: + def test_bound_method_registers(self): + plot = _plot() + listener = _Listener() + plot.add_event_handler(listener.on_event, "pointer_down") + plot.callbacks.fire(Event("pointer_down")) + assert listener.seen == ["pointer_down"] + + def test_bound_method_as_decorator_target(self): + plot = _plot() + listener = _Listener() + plot.add_event_handler("pointer_up")(listener.on_event) + plot.callbacks.fire(Event("pointer_up")) + assert listener.seen == ["pointer_up"] + + def test_remove_by_fresh_bound_method(self): + """A second ``obj.method`` access is a new object but the same handler.""" + plot = _plot() + listener = _Listener() + plot.add_event_handler(listener.on_event, "pointer_down") + plot.remove_handler(listener.on_event) + plot.callbacks.fire(Event("pointer_down")) + assert listener.seen == [] + + def test_remove_only_matching_instance(self): + plot = _plot() + a, b = _Listener(), _Listener() + plot.add_event_handler(a.on_event, "pointer_down") + plot.add_event_handler(b.on_event, "pointer_down") + plot.remove_handler(a.on_event) + plot.callbacks.fire(Event("pointer_down")) + assert a.seen == [] and b.seen == ["pointer_down"] + + def test_widget_accepts_bound_method(self): + plot = _plot() + widget = plot.add_widget("crosshair", cx=3, cy=4) + listener = _Listener() + widget.add_event_handler(listener.on_event, "pointer_move") + widget.set(cx=5) + assert listener.seen == ["pointer_move"] + + def test_partial_accepted(self): + plot = _plot() + seen = [] + handler = functools.partial(lambda tag, e: seen.append(tag), "p") + plot.add_event_handler(handler, "pointer_down") + plot.callbacks.fire(Event("pointer_down")) + assert seen == ["p"] + + def test_plain_function_still_stamped(self): + plot = _plot() + + def handler(event): + pass + + plot.add_event_handler(handler, "pointer_down", "pointer_up") + assert handler._event_types == {"pointer_down", "pointer_up"} diff --git a/upcoming_changes/+bound-method-handlers.bugfix.rst b/upcoming_changes/+bound-method-handlers.bugfix.rst new file mode 100644 index 000000000..7ad09e890 --- /dev/null +++ b/upcoming_changes/+bound-method-handlers.bugfix.rst @@ -0,0 +1,3 @@ +Fixed :meth:`~anyplotlib.callbacks._EventMixin.add_event_handler` raising +``AttributeError`` for bound methods, and made ``remove_handler(obj.method)`` +find a handler registered as ``obj.method``. From eb85cb0946d65034439aa92ed8e8754f413a4e94 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 21 Oct 2026 13:46:08 -0500 Subject: [PATCH 02/24] widgets: add public notify keyword to Widget.set widget.set(..., notify=False) is the documented way to move a widget from Python without echoing through its pointer_move handlers. The private _notify flag stays as an alias so existing callers keep working. Assisted-by: claude-code:claude-opus-5-5 --- .../test_widget_set_notify.py | 38 +++++++++++++++++++ anyplotlib/widgets/_base.py | 29 ++++++++------ .../+widget-set-notify.new_feature.rst | 3 ++ 3 files changed, 59 insertions(+), 11 deletions(-) create mode 100644 upcoming_changes/+widget-set-notify.new_feature.rst diff --git a/anyplotlib/tests/test_interactive/test_widget_set_notify.py b/anyplotlib/tests/test_interactive/test_widget_set_notify.py index e1434647b..819342828 100644 --- a/anyplotlib/tests/test_interactive/test_widget_set_notify.py +++ b/anyplotlib/tests/test_interactive/test_widget_set_notify.py @@ -80,6 +80,44 @@ def clamp(event): assert widget.x == 10 +class TestPublicNotify: + """``notify=`` is the public spelling; ``_notify=`` remains an alias.""" + + def test_notify_false_suppresses_callbacks(self): + _, _, widget = _plot_with_widget() + seen = [] + widget.add_event_handler(lambda e: seen.append(e), "pointer_move") + widget.set(x=5, notify=False) + assert seen == [] + assert widget.x == 5 + + def test_notify_true_fires(self): + _, _, widget = _plot_with_widget() + seen = [] + widget.add_event_handler(lambda e: seen.append(e), "pointer_move") + widget.set(x=5, notify=True) + assert len(seen) == 1 + + def test_notify_wins_over_alias(self): + _, _, widget = _plot_with_widget() + seen = [] + widget.add_event_handler(lambda e: seen.append(e), "pointer_move") + widget.set(x=5, notify=False, _notify=True) + assert seen == [] + + def test_notify_false_still_pushes(self): + _, _, widget = _plot_with_widget() + pushed = [] + widget._push_fn = lambda: pushed.append(True) + widget.set(x=9, notify=False) + assert pushed == [True] + + def test_notify_not_stored_as_state(self): + _, _, widget = _plot_with_widget() + widget.set(x=3, notify=False) + assert "notify" not in widget.to_dict() + + class TestWidgetRemove: def test_remove_detaches_from_plot(self): _, plot, widget = _plot_with_widget() diff --git a/anyplotlib/widgets/_base.py b/anyplotlib/widgets/_base.py index 32c0451e0..886eafd05 100644 --- a/anyplotlib/widgets/_base.py +++ b/anyplotlib/widgets/_base.py @@ -84,43 +84,50 @@ def __setattr__(self, key: str, value) -> None: # ── set / get ───────────────────────────────────────────────────── - def set(self, _push: bool = True, _notify: bool = True, **kwargs) -> None: + def set(self, _push: bool = True, _notify: bool = True, *, + notify: bool | None = None, **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. - _notify : bool, optional - Whether to fire ``pointer_move`` callbacks. Default True. + notify : bool, optional + Whether to fire ``pointer_move`` callbacks. Default ``True``. A ``set()`` from Python is otherwise indistinguishable from a user drag: handlers that react to the widget moving will run, and one that writes back to the widget feeds into itself. Pass - ``_notify=False`` when *you* are the one moving the widget and the + ``notify=False`` when *you* are the one moving the widget and the handlers are only meant to hear about user input:: - widget.set(_notify=False, x=new_x) + widget.set(x=new_x, notify=False) This supersedes wrapping the call in :meth:`pause_events`, which suppresses every event type for the duration rather than just this update's echo. + _notify : bool, optional + Older spelling of ``notify``, kept as an alias. ``notify`` wins + when both are given. + _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 ----- - Both flags are spelled with a leading underscore so they can never - collide with a widget property of the same name in ``**kwargs``. + No widget has a property called ``notify``, so the keyword cannot + collide with one; ``_notify`` / ``_push`` keep their leading + underscore for the same reason. Updates are sent as targeted widget updates, not full panel re-renders. This is more efficient for frequent updates during dragging. """ + if notify is None: + notify = _notify self._data.update(kwargs) if _push: self._push_fn() - if _notify: + if notify: self.callbacks.fire(Event("pointer_move", source=self)) def get(self, key: str, default=None): diff --git a/upcoming_changes/+widget-set-notify.new_feature.rst b/upcoming_changes/+widget-set-notify.new_feature.rst new file mode 100644 index 000000000..859009fae --- /dev/null +++ b/upcoming_changes/+widget-set-notify.new_feature.rst @@ -0,0 +1,3 @@ +Added a public ``notify`` keyword to :meth:`~anyplotlib.Widget.set` +(``widget.set(x=..., notify=False)``) to move a widget from Python without +firing its ``pointer_move`` handlers; ``_notify`` remains as an alias. From f4027dbe4061c9c5ddff118ff0b9c09775181f6f Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 21 Oct 2026 13:47:14 -0500 Subject: [PATCH 03/24] markers: broadcast length-1 sequences like scalars Per-marker kwargs (radius, widths, heights, angles, U, V, sizes) now broadcast a length-1 sequence across the group instead of rejecting it when the group has more than one marker, and group-wide kwargs (linewidths, alpha, fontsize, outline_width, single colours) unwrap one. This matches matplotlib, which stores singleton styles as 1-tuples. Assisted-by: claude-code:claude-opus-5-5 --- anyplotlib/markers.py | 92 +++++++++++++------ .../test_markers/test_length1_broadcast.py | 92 +++++++++++++++++++ .../+marker-length1-broadcast.new_feature.rst | 3 + 3 files changed, 158 insertions(+), 29 deletions(-) create mode 100644 anyplotlib/tests/test_markers/test_length1_broadcast.py create mode 100644 upcoming_changes/+marker-length1-broadcast.new_feature.rst diff --git a/anyplotlib/markers.py b/anyplotlib/markers.py index 4b1a85fd3..6e3e20c23 100644 --- a/anyplotlib/markers.py +++ b/anyplotlib/markers.py @@ -52,13 +52,47 @@ # --------------------------------------------------------------------------- def _broadcast(val, n: int) -> list: - """Broadcast scalar or sequence to a list of length *n*.""" + """Broadcast scalar or sequence to a list of length *n*. + + A length-1 sequence counts as a scalar, as it does in matplotlib (whose + collections cycle per-item properties), so ``radius=[5]`` sizes every + marker in the group rather than being rejected when ``n > 1``. + """ arr = np.asarray(val, dtype=float) if arr.ndim == 0: return np.full(n, float(arr)).tolist() if arr.ndim == 1 and len(arr) == n: return arr.tolist() - raise ValueError(f"Expected scalar or length-{n} array, got shape {arr.shape}") + if arr.ndim == 1 and len(arr) == 1: + return np.full(n, float(arr[0])).tolist() + raise ValueError( + f"Expected scalar, length-1 or length-{n} array, got shape {arr.shape}") + + +def _scalar(val): + """Unwrap a length-1 sequence to its element; return anything else as-is. + + Used for group-wide (not per-marker) properties such as ``linewidths``, + ``alpha`` and ``fontsize``, so a matplotlib-style ``(1.5,)`` is accepted + where a bare ``1.5`` is expected. Strings are never unwrapped. + """ + if isinstance(val, str): + return val + if isinstance(val, np.ndarray): + return val.reshape(-1)[0].item() if val.size == 1 else val + if isinstance(val, (list, tuple)) and len(val) == 1: + return val[0] + return val + + +def _color(val): + """A colour kwarg: unwrap a single-colour sequence (``["#f00"]``) to the + colour itself; longer sequences stay per-marker lists.""" + if isinstance(val, np.ndarray): + val = val.tolist() + if isinstance(val, (list, tuple)) and len(val) == 1 and isinstance(val[0], str): + return val[0] + return val def _offsets_2d(offsets) -> list: @@ -104,8 +138,8 @@ 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)) + wire["fill_color"] = _color(fc) + wire["fill_alpha"] = float(_scalar(d.get("alpha", 0.3))) def _offsets_1d(offsets) -> list: @@ -226,8 +260,8 @@ def to_wire(self, group_id: str) -> dict: "type": "circles", "offsets": offsets, "sizes": _broadcast(d.get("radius", 5), n), - "color": d.get("edgecolors", "#ff0000"), - "linewidth": float(d.get("linewidths", 1.5)), + "color": _color(d.get("edgecolors", "#ff0000")), + "linewidth": float(_scalar(d.get("linewidths", 1.5))), } _apply_fill_color(wire, d) @@ -241,8 +275,8 @@ def to_wire(self, group_id: str) -> dict: "offsets": offsets, "U": _broadcast(d.get("U", 0), n), "V": _broadcast(d.get("V", 0), n), - "color": d.get("edgecolors", d.get("color", "#ff0000")), - "linewidth": float(d.get("linewidths", 1.5)), + "color": _color(d.get("edgecolors", d.get("color", "#ff0000"))), + "linewidth": float(_scalar(d.get("linewidths", 1.5))), } elif t == "ellipses": @@ -256,8 +290,8 @@ def to_wire(self, group_id: str) -> dict: "widths": _broadcast(d.get("widths", 10), n), "heights": _broadcast(d.get("heights", 10), n), "angles": _broadcast(d.get("angles", 0), n), - "color": d.get("edgecolors", d.get("color", "#ff0000")), - "linewidth": float(d.get("linewidths", 1.5)), + "color": _color(d.get("edgecolors", d.get("color", "#ff0000"))), + "linewidth": float(_scalar(d.get("linewidths", 1.5))), } _apply_fill_color(wire, d) @@ -275,8 +309,8 @@ def to_wire(self, group_id: str) -> dict: "name": self._name, "type": "lines", "segments": segs.tolist(), - "color": d.get("edgecolors", d.get("color", "#ff0000")), - "linewidth": float(d.get("linewidths", 1.5)), + "color": _color(d.get("edgecolors", d.get("color", "#ff0000"))), + "linewidth": float(_scalar(d.get("linewidths", 1.5))), } elif t == "rectangles": @@ -290,8 +324,8 @@ def to_wire(self, group_id: str) -> dict: "widths": _broadcast(d.get("widths", 10), n), "heights": _broadcast(d.get("heights", 10), n), "angles": _broadcast(d.get("angles", 0), n), - "color": d.get("edgecolors", d.get("color", "#ff0000")), - "linewidth": float(d.get("linewidths", 1.5)), + "color": _color(d.get("edgecolors", d.get("color", "#ff0000"))), + "linewidth": float(_scalar(d.get("linewidths", 1.5))), } _apply_fill_color(wire, d) @@ -305,8 +339,8 @@ def to_wire(self, group_id: str) -> dict: "offsets": offsets, "widths": _broadcast(d.get("widths", 10), n), "angles": _broadcast(d.get("angles", 0), n), - "color": d.get("edgecolors", d.get("color", "#ff0000")), - "linewidth": float(d.get("linewidths", 1.5)), + "color": _color(d.get("edgecolors", d.get("color", "#ff0000"))), + "linewidth": float(_scalar(d.get("linewidths", 1.5))), } _apply_fill_color(wire, d) @@ -322,8 +356,8 @@ def to_wire(self, group_id: str) -> dict: "name": self._name, "type": "polygons", "vertices_list": vlist, - "color": d.get("edgecolors", d.get("color", "#ff0000")), - "linewidth": float(d.get("linewidths", 1.5)), + "color": _color(d.get("edgecolors", d.get("color", "#ff0000"))), + "linewidth": float(_scalar(d.get("linewidths", 1.5))), } cp = d.get("clip_path") if cp is not None: @@ -341,14 +375,14 @@ def to_wire(self, group_id: str) -> dict: "type": "texts", "offsets": offsets, "texts": texts, - "color": d.get("color", d.get("edgecolors", "#ff0000")), - "fontsize": int(d.get("fontsize", 12)), + "color": _color(d.get("color", d.get("edgecolors", "#ff0000"))), + "fontsize": int(_scalar(d.get("fontsize", 12))), "fontweight": d.get("fontweight", "normal"), } # Outline (a halo stroked under the fill): only on the wire when set. if d.get("outline_color") is not None: wire["outline_color"] = d["outline_color"] - wire["outline_width"] = float(d.get("outline_width", 3.0)) + wire["outline_width"] = float(_scalar(d.get("outline_width", 3.0))) # ── 1D-only types ─────────────────────────────────────────────────── elif t == "points": @@ -360,8 +394,8 @@ def to_wire(self, group_id: str) -> dict: "type": "points", "offsets": offsets, "sizes": _broadcast(d.get("sizes", 5), n), - "color": d.get("edgecolors", d.get("color", "#ff0000")), - "linewidth": float(d.get("linewidths", 1.5)), + "color": _color(d.get("edgecolors", d.get("color", "#ff0000"))), + "linewidth": float(_scalar(d.get("linewidths", 1.5))), } _apply_fill_color(wire, d) @@ -372,8 +406,8 @@ def to_wire(self, group_id: str) -> dict: "name": self._name, "type": "vlines", "offsets": [[row[0]] for row in offsets], - "color": d.get("color", d.get("edgecolors", "#ff0000")), - "linewidth": float(d.get("linewidths", 1.5)), + "color": _color(d.get("color", d.get("edgecolors", "#ff0000"))), + "linewidth": float(_scalar(d.get("linewidths", 1.5))), } elif t == "hlines": @@ -383,8 +417,8 @@ def to_wire(self, group_id: str) -> dict: "name": self._name, "type": "hlines", "offsets": [[row[0]] for row in offsets], - "color": d.get("color", d.get("edgecolors", "#ff0000")), - "linewidth": float(d.get("linewidths", 1.5)), + "color": _color(d.get("color", d.get("edgecolors", "#ff0000"))), + "linewidth": float(_scalar(d.get("linewidths", 1.5))), } elif t == "raster": @@ -444,10 +478,10 @@ def to_wire(self, group_id: str) -> dict: # facecolors → hover_facecolors. Any CSS colour string is accepted. hec = d.get("hover_edgecolors") if hec is not None: - wire["hover_color"] = str(hec) + wire["hover_color"] = str(_color(hec)) hfc = d.get("hover_facecolors") if hfc is not None: - wire["hover_facecolor"] = str(hfc) + wire["hover_facecolor"] = str(_color(hfc)) return wire diff --git a/anyplotlib/tests/test_markers/test_length1_broadcast.py b/anyplotlib/tests/test_markers/test_length1_broadcast.py new file mode 100644 index 000000000..31a5d89bf --- /dev/null +++ b/anyplotlib/tests/test_markers/test_length1_broadcast.py @@ -0,0 +1,92 @@ +""" +Length-1 sequences in marker kwargs broadcast like scalars. + +matplotlib stores a singleton style as a 1-tuple and cycles it over the +collection, so code ported from matplotlib passes ``radius=[5]`` or +``linewidths=(1.5,)``. Per-marker kwargs must broadcast such a value across +the group, and group-wide kwargs must unwrap it. +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl + + +def _plot2d(): + fig, ax = apl.subplots(1, 1) + return ax.imshow(np.zeros((12, 25))) + + +def _wire(group): + return group.to_wire("g") + + +OFFSETS = [[1, 2], [3, 4], [5, 6]] + + +class TestPerMarkerBroadcast: + @pytest.mark.parametrize("value", [[4.0], (4.0,), np.array([4.0])]) + def test_circles_radius(self, value): + g = _plot2d().add_circles(OFFSETS, radius=value) + assert _wire(g)["sizes"] == [4.0, 4.0, 4.0] + + def test_rectangles_widths_heights_angles(self): + g = _plot2d().add_rectangles(OFFSETS, widths=[2], heights=(3,), angles=[10]) + w = _wire(g) + assert w["widths"] == [2.0] * 3 + assert w["heights"] == [3.0] * 3 + assert w["angles"] == [10.0] * 3 + + def test_arrows_uv(self): + g = _plot2d().add_arrows(OFFSETS, U=[1], V=(2,)) + w = _wire(g) + assert w["U"] == [1.0] * 3 and w["V"] == [2.0] * 3 + + def test_set_with_length1(self): + g = _plot2d().add_circles(OFFSETS, radius=2) + g.set(radius=[7]) + assert _wire(g)["sizes"] == [7.0] * 3 + + def test_full_length_unchanged(self): + g = _plot2d().add_circles(OFFSETS, radius=[1, 2, 3]) + assert _wire(g)["sizes"] == [1.0, 2.0, 3.0] + + def test_wrong_length_still_rejected(self): + with pytest.raises(ValueError): + _plot2d().add_circles(OFFSETS, radius=[1, 2]) + + +class TestGroupWideUnwrap: + def test_linewidths(self): + g = _plot2d().add_circles(OFFSETS, linewidths=(2.5,)) + assert _wire(g)["linewidth"] == 2.5 + + def test_alpha(self): + g = _plot2d().add_circles(OFFSETS, facecolors="#f00", alpha=[0.5]) + assert _wire(g)["fill_alpha"] == 0.5 + + def test_fontsize(self): + g = _plot2d().add_texts(OFFSETS, ["a", "b", "c"], fontsize=[14]) + assert _wire(g)["fontsize"] == 14 + + def test_single_colour_lists(self): + g = _plot2d().add_circles(OFFSETS, edgecolors=["#00ff00"], + facecolors=("#0000ff",), + hover_edgecolors=["#ffffff"]) + w = _wire(g) + assert w["color"] == "#00ff00" + assert w["fill_color"] == "#0000ff" + assert w["hover_color"] == "#ffffff" + + def test_per_marker_colours_kept(self): + cols = ["#f00", "#0f0", "#00f"] + g = _plot2d().add_circles(OFFSETS, edgecolors=cols) + assert _wire(g)["color"] == cols + + def test_1d_lines(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.arange(48.0)) + g = plot.markers.add("vlines", offsets=[1, 2], linewidths=[3]) + assert _wire(g)["linewidth"] == 3.0 diff --git a/upcoming_changes/+marker-length1-broadcast.new_feature.rst b/upcoming_changes/+marker-length1-broadcast.new_feature.rst new file mode 100644 index 000000000..dae15325b --- /dev/null +++ b/upcoming_changes/+marker-length1-broadcast.new_feature.rst @@ -0,0 +1,3 @@ +Marker kwargs now accept a length-1 sequence wherever a scalar is accepted +(``radius=[5]``, ``linewidths=(1.5,)``, ``edgecolors=["#f00"]``), broadcasting +it across the group as matplotlib's property cycling does. From 281b2721cb580cd0da23de555d52b079f59ff619 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 21 Oct 2026 13:50:27 -0500 Subject: [PATCH 04/24] plot2d: add ImageTransform for data <-> image-pixel coordinates Widgets and transform='data' markers on a Plot2D are placed in image pixels, but users work in the calibrated axis units. Plot2D.transform returns a live ImageTransform that converts points, (x, y) pairs and lengths both ways, mirroring the renderer's _axisValToImg2d mapping: pixel-centre axes for imshow, cell edges for pcolormesh, linear continuation past either end, and support for offset, non-unit scale, descending (origin='lower') and non-uniform axes. Plot2D also gains data_to_pixel / pixel_to_data shorthands and read-only x_axis / y_axis properties. Assisted-by: claude-code:claude-opus-5-5 --- anyplotlib/__init__.py | 4 +- anyplotlib/plot2d/__init__.py | 6 +- anyplotlib/plot2d/_plot2d.py | 68 +++++ anyplotlib/plot2d/_transform.py | 283 ++++++++++++++++++ .../tests/test_plot2d/test_transform.py | 194 ++++++++++++ docs/api/figure_plots.rst | 13 + .../+plot2d-transform.new_feature.rst | 6 + 7 files changed, 570 insertions(+), 4 deletions(-) create mode 100644 anyplotlib/plot2d/_transform.py create mode 100644 anyplotlib/tests/test_plot2d/test_transform.py create mode 100644 upcoming_changes/+plot2d-transform.new_feature.rst diff --git a/anyplotlib/__init__.py b/anyplotlib/__init__.py index 17bae7bb2..807be5619 100644 --- a/anyplotlib/__init__.py +++ b/anyplotlib/__init__.py @@ -9,7 +9,7 @@ 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.plot2d import Plot2D, PlotMesh, ImageTransform from anyplotlib.plot3d import Patch3D, Plot3D from anyplotlib.plotxy import PlotXY from anyplotlib.callbacks import CallbackRegistry, Event @@ -41,7 +41,7 @@ def get_color_cycle() -> list[str]: __all__ = [ "Figure", "GridSpec", "SubplotSpec", "subplots", - "Axes", "InsetAxes", "Plot1D", "Plot2D", "PlotMesh", "Plot3D", "Patch3D", "PlotBar", + "Axes", "InsetAxes", "Plot1D", "Plot2D", "PlotMesh", "ImageTransform", "Plot3D", "Patch3D", "PlotBar", "Line1D", "CallbackRegistry", "Event", "MarkerRegistry", "MarkerGroup", "KeyOverlay", diff --git a/anyplotlib/plot2d/__init__.py b/anyplotlib/plot2d/__init__.py index f4183b063..6d8cc397e 100644 --- a/anyplotlib/plot2d/__init__.py +++ b/anyplotlib/plot2d/__init__.py @@ -1,6 +1,8 @@ -"""anyplotlib.plot2d — 2-D image plot panel classes (Plot2D, PlotMesh, Layer).""" +"""anyplotlib.plot2d — 2-D image plot panel classes (Plot2D, PlotMesh, Layer, +ImageTransform).""" from anyplotlib.plot2d._plot2d import Plot2D from anyplotlib.plot2d._plotmesh import PlotMesh from anyplotlib.plot2d._layer import Layer +from anyplotlib.plot2d._transform import ImageTransform -__all__ = ["Plot2D", "PlotMesh", "Layer"] +__all__ = ["Plot2D", "PlotMesh", "Layer", "ImageTransform"] diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index 72f703c1c..1b54d9adf 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -22,6 +22,7 @@ from anyplotlib._base_plot import _BasePlot, _PanelMixin, _MarkerMixin from anyplotlib.markers import MarkerRegistry +from anyplotlib.plot2d._transform import ImageTransform from anyplotlib.callbacks import CallbackRegistry from anyplotlib.widgets import ( Widget, @@ -1862,6 +1863,73 @@ def get_xbound(self) -> tuple: xarr = np.asarray(self._state["x_axis"]) return (float(xarr.min()), float(xarr.max())) + # ------------------------------------------------------------------ + # Calibration / data <-> pixel coordinates + # ------------------------------------------------------------------ + @property + def x_axis(self) -> np.ndarray: + """The calibrated x coordinates (read-only copy). + + One value per pixel centre for an ``imshow`` panel, the ``N + 1`` cell + edges for a ``pcolormesh`` one. Change it with :meth:`set_extent`. + """ + return np.asarray(self._state["x_axis"], dtype=float) + + @property + def y_axis(self) -> np.ndarray: + """The calibrated y coordinates, top row first (read-only copy). + + Already ``origin``-applied: for ``origin="lower"`` this is the axis + passed in, reversed. See :attr:`x_axis`. + """ + return np.asarray(self._state["y_axis"], dtype=float) + + @property + def transform(self) -> ImageTransform: + """Data ↔ image-pixel coordinate transform for this panel. + + Widgets and ``transform="data"`` markers on a 2-D panel are placed in + image pixels (integer *i* = centre of pixel *i*); the transform maps + those to and from the calibrated axis values, handling offset, + non-unit scale, descending axes and non-uniform axes. It reads the + current axes on every call, so it never goes stale. + + Most code does not need it directly: pass ``coords="data"`` to the + ``add_*_widget`` and marker methods instead. + + Returns + ------- + ImageTransform + """ + return ImageTransform(self) + + def data_to_pixel(self, points): + """Convert calibrated ``(x, y)`` data points to image-pixel coordinates. + + Shorthand for ``plot.transform.data_to_pixel(points)``. Compose with + :meth:`data_to_display` (which takes image pixels on a 2-D panel) to + reach screen pixels. + + Parameters + ---------- + points : array-like + A single ``(x, y)`` pair or an ``(N, 2)`` sequence. + + Returns + ------- + numpy.ndarray + Same shape as *points*: ``(column, row)`` with integer *i* at the + centre of pixel *i*. + """ + return self.transform.data_to_pixel(points) + + def pixel_to_data(self, points): + """Convert image-pixel ``(column, row)`` points to calibrated data. + + The inverse of :meth:`data_to_pixel`. + """ + return self.transform.pixel_to_data(points) + def set_extent(self, x_axis, y_axis, units: str | None = None) -> None: """Recalibrate the image axes to the given coordinate arrays. diff --git a/anyplotlib/plot2d/_transform.py b/anyplotlib/plot2d/_transform.py new file mode 100644 index 000000000..2cce6c43f --- /dev/null +++ b/anyplotlib/plot2d/_transform.py @@ -0,0 +1,283 @@ +""" +plot2d/_transform.py +==================== +Data ↔ image-pixel coordinate transform for 2-D panels. + +Overlay widgets and ``transform="data"`` markers on a :class:`Plot2D` are +positioned in **image pixels**: column / row indices into the displayed frame +where integer *i* is the *centre* of pixel *i* (row 0 at the top, ``origin`` +already applied). Users usually think in the calibrated units of the axes +passed to :meth:`~anyplotlib.Axes.imshow` / :meth:`Plot2D.set_extent`. +:class:`ImageTransform` converts between the two. + +The mapping mirrors the renderer exactly (``_axisValToImg2d`` / +``_imgToAxisVal2d`` in ``figure_esm.js``): + +* ``imshow`` axes hold one value per pixel **centre**, so ``x_axis[i]`` sits at + pixel ``i``. Between samples the mapping is linear; beyond either end it + continues the end segment linearly, so a coordinate half a pixel outside the + first centre lands on the image edge. +* ``pcolormesh`` axes hold the ``n + 1`` cell **edges**, so ``x_edges[i]`` sits + at pixel ``i - 0.5``. + +Axes may be ascending or descending (``origin="lower"`` reverses ``y_axis``), +uniform or not. Length conversions use the average pixel size unless an +``at=`` position is given, in which case the local pixel size there is used — +which only matters for a non-uniform axis. +""" + +from __future__ import annotations + +import numpy as np + +__all__ = ["ImageTransform"] + + +def _as_out(value, result): + """Return a Python float for scalar input, an ndarray otherwise.""" + if np.ndim(value) == 0: + return float(result) + return result + + +class _AxisMap: + """Piecewise-linear map between one axis' values and pixel positions.""" + + __slots__ = ("values", "pixels") + + def __init__(self, values: np.ndarray, pixels: np.ndarray): + self.values = values + self.pixels = pixels + + @classmethod + def for_axis(cls, axis, n: int, is_mesh: bool) -> "_AxisMap": + arr = np.asarray(axis, dtype=float).ravel() + m = len(arr) - 1 + if is_mesh: + # n + 1 cell edges: edge i is the leading edge of pixel i. + pix = np.arange(m + 1, dtype=float) - 0.5 + elif m >= 1: + # One value per pixel centre. In tile mode the axis can be + # shorter than the logical image, so stretch it over n pixels, + # exactly as the renderer does. + pix = np.arange(m + 1, dtype=float) * ((n - 1) / m if n > 1 else 0.0) + else: + pix = np.zeros(len(arr)) + return cls(arr, pix) + + # ------------------------------------------------------------------ + def _single(self): + """``(origin_value, origin_pixel)`` for an axis with < 2 samples.""" + if len(self.values) == 0: + return 0.0, 0.0 + return float(self.values[0]), float(self.pixels[0]) + + def _check(self) -> None: + if len(self.values) >= 2 and self.values[-1] == self.values[0]: + raise ValueError( + "axis is degenerate (first and last values are equal); " + "cannot convert between data and pixel coordinates") + + @staticmethod + def _interp(v, xp, fp): + """``np.interp`` continued linearly past both ends; *xp* may descend.""" + if xp[-1] < xp[0]: + xp, fp = xp[::-1], fp[::-1] + v = np.asarray(v, dtype=float) + out = np.asarray(np.interp(v, xp, fp), dtype=float) + dx0 = xp[1] - xp[0] + dx1 = xp[-1] - xp[-2] + s0 = (fp[1] - fp[0]) / dx0 if dx0 else 0.0 + s1 = (fp[-1] - fp[-2]) / dx1 if dx1 else 0.0 + out = np.where(v < xp[0], fp[0] + (v - xp[0]) * s0, out) + out = np.where(v > xp[-1], fp[-1] + (v - xp[-1]) * s1, out) + return out + + def to_pixel(self, value): + if len(self.values) < 2: + v0, p0 = self._single() + return _as_out(value, np.asarray(value, dtype=float) - v0 + p0) + self._check() + return _as_out(value, self._interp(value, self.values, self.pixels)) + + def to_value(self, pixel): + if len(self.values) < 2: + v0, p0 = self._single() + return _as_out(pixel, np.asarray(pixel, dtype=float) - p0 + v0) + self._check() + # pixels always ascend, so interpolate values over them directly. + return _as_out(pixel, self._interp(pixel, self.pixels, self.values)) + + def scale(self) -> float: + """Signed average data units per pixel (negative for a descending axis).""" + if len(self.values) < 2: + return 1.0 + self._check() + span = self.pixels[-1] - self.pixels[0] + return float((self.values[-1] - self.values[0]) / span) if span else 1.0 + + def length_to_pixels(self, length, at=None): + length = np.asarray(length, dtype=float) + if at is None: + return _as_out(length, np.abs(length / self.scale())) + a = np.asarray(at, dtype=float) + res = np.abs(np.asarray(self.to_pixel(a + length)) - np.asarray(self.to_pixel(a))) + return _as_out(length, res) + + def pixels_to_length(self, pixels, at=None): + pixels = np.asarray(pixels, dtype=float) + if at is None: + return _as_out(pixels, np.abs(pixels * self.scale())) + p = np.asarray(self.to_pixel(np.asarray(at, dtype=float))) + res = np.abs(np.asarray(self.to_value(p + pixels)) - np.asarray(self.to_value(p))) + return _as_out(pixels, res) + + +class ImageTransform: + """Converts between calibrated data coordinates and image pixels. + + Obtain one from :attr:`Plot2D.transform`. It is a live view: it reads the + plot's current axes on every call, so it stays correct after + :meth:`Plot2D.set_extent` or ``set_data(x_axis=..., y_axis=...)``. + + *Image pixels* are the coordinates overlay widgets and ``transform="data"`` + markers use on a 2-D panel: ``(column, row)`` into the displayed frame, + integer *i* is the centre of pixel *i*, row 0 at the top. *Data* means the + calibrated values of the panel's ``x_axis`` / ``y_axis``. + + Point methods accept a scalar or an array; a scalar in gives a ``float`` + out. :meth:`data_to_pixel` / :meth:`pixel_to_data` take ``(x, y)`` pairs + with the same shape rules as :meth:`~anyplotlib.Plot2D.data_to_display`. + + Examples + -------- + >>> import numpy as np + >>> import anyplotlib as apl + >>> fig, ax = apl.subplots() + >>> x = 10.0 + 0.5 * np.arange(25) # 25 columns, 0.5 nm apart + >>> y = -3.0 + 0.25 * np.arange(12) # 12 rows, 0.25 nm apart + >>> plot = ax.imshow(np.zeros((12, 25)), axes=[x, y], units="nm") + >>> plot.transform.x_to_pixel(11.0) + 2.0 + >>> plot.transform.pixel_to_data([2, 4]).tolist() + [11.0, -2.0] + >>> plot.transform.x_length_to_pixels(1.5) + 3.0 + """ + + def __init__(self, plot): + self._plot = plot + + # ── calibration (live) ──────────────────────────────────────────────── + def _maps(self) -> tuple[_AxisMap, _AxisMap]: + st = self._plot._state + is_mesh = bool(st.get("is_mesh")) + n_x = int(st.get("image_width") or len(st["x_axis"])) + n_y = int(st.get("image_height") or len(st["y_axis"])) + return (_AxisMap.for_axis(st["x_axis"], n_x, is_mesh), + _AxisMap.for_axis(st["y_axis"], n_y, is_mesh)) + + @property + def x_scale(self) -> float: + """Average data units per image pixel along x (signed: negative when + the x axis descends).""" + return self._maps()[0].scale() + + @property + def y_scale(self) -> float: + """Average data units per image pixel along y, signed. Positive when + y grows *downwards* on screen (the ``origin="upper"`` default), + negative for ``origin="lower"``.""" + return self._maps()[1].scale() + + # ── single-axis points ──────────────────────────────────────────────── + def x_to_pixel(self, x): + """Data x (scalar or array) → image column (fractional).""" + return self._maps()[0].to_pixel(x) + + def y_to_pixel(self, y): + """Data y (scalar or array) → image row (fractional).""" + return self._maps()[1].to_pixel(y) + + def pixel_to_x(self, px): + """Image column (scalar or array) → data x.""" + return self._maps()[0].to_value(px) + + def pixel_to_y(self, py): + """Image row (scalar or array) → data y.""" + return self._maps()[1].to_value(py) + + # ── lengths ─────────────────────────────────────────────────────────── + def x_length_to_pixels(self, length, at=None): + """A length along x in data units → a (non-negative) number of pixels. + + Parameters + ---------- + length : float or array-like + Length in data units. The sign is ignored. + at : float or array-like, optional + Data x where the length starts. Only matters for a non-uniform + axis; by default the average pixel size is used. + """ + return self._maps()[0].length_to_pixels(length, at) + + def y_length_to_pixels(self, length, at=None): + """A length along y in data units → pixels. See :meth:`x_length_to_pixels`.""" + return self._maps()[1].length_to_pixels(length, at) + + def pixels_to_x_length(self, pixels, at=None): + """A number of pixels along x → a (non-negative) length in data units. + + ``at`` is the data x where the span starts (non-uniform axes only). + """ + return self._maps()[0].pixels_to_length(pixels, at) + + def pixels_to_y_length(self, pixels, at=None): + """A number of pixels along y → a length in data units. See + :meth:`pixels_to_x_length`.""" + return self._maps()[1].pixels_to_length(pixels, at) + + # ── (x, y) pairs ────────────────────────────────────────────────────── + @staticmethod + def _pairs(points): + pts = np.atleast_2d(np.asarray(points, dtype=float)) + if pts.shape[-1] != 2: + raise ValueError(f"points must have shape (N, 2); got {pts.shape}") + return pts + + def data_to_pixel(self, points): + """``(x, y)`` data pairs → ``(column, row)`` image-pixel pairs. + + Parameters + ---------- + points : array-like + A single ``(x, y)`` pair or an ``(N, 2)`` sequence of them. + + Returns + ------- + numpy.ndarray + Same shape as *points*. + """ + pts = self._pairs(points) + mx, my = self._maps() + out = np.column_stack([np.asarray(mx.to_pixel(pts[:, 0])), + np.asarray(my.to_pixel(pts[:, 1]))]) + return out if np.ndim(points) == 2 else out[0] + + def pixel_to_data(self, points): + """``(column, row)`` image-pixel pairs → ``(x, y)`` data pairs. + + The inverse of :meth:`data_to_pixel`, with the same shape rules. + """ + pts = self._pairs(points) + mx, my = self._maps() + out = np.column_stack([np.asarray(mx.to_value(pts[:, 0])), + np.asarray(my.to_value(pts[:, 1]))]) + return out if np.ndim(points) == 2 else out[0] + + def __repr__(self) -> str: + try: + return (f"ImageTransform(x_scale={self.x_scale:.4g}, " + f"y_scale={self.y_scale:.4g})") + except ValueError: + return "ImageTransform(degenerate)" diff --git a/anyplotlib/tests/test_plot2d/test_transform.py b/anyplotlib/tests/test_plot2d/test_transform.py new file mode 100644 index 000000000..8d9b5ea42 --- /dev/null +++ b/anyplotlib/tests/test_plot2d/test_transform.py @@ -0,0 +1,194 @@ +""" +Plot2D.transform — data ↔ image-pixel coordinates. + +Widgets and markers on a 2-D panel live in image pixels (integer i = centre +of pixel i); users think in the calibrated axis units. These tests pin the +conversion against the renderer's own convention, which the Python tick/view +code (``_axis_extent_frac``) already mirrors. + +Shapes are deliberately all different — 12 rows × 25 columns — so a swapped +x/y would show up immediately. +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib import ImageTransform +from anyplotlib.plot2d._transform import _AxisMap + +H, W = 12, 25 +X = 10.0 + 0.5 * np.arange(W) # 0.5 units / px, offset 10 +Y = -3.0 + 0.25 * np.arange(H) # 0.25 units / px, offset -3 + + +def _imshow(**kwargs): + fig, ax = apl.subplots(1, 1) + return ax.imshow(np.zeros((H, W)), **kwargs) + + +class TestBasics: + def test_type_and_export(self): + plot = _imshow() + assert isinstance(plot.transform, ImageTransform) + + def test_default_axes_are_identity(self): + t = _imshow().transform + assert t.x_to_pixel(7.0) == pytest.approx(7.0) + assert t.pixel_to_y(3.25) == pytest.approx(3.25) + assert t.x_scale == pytest.approx(1.0) + + def test_scalar_in_float_out(self): + t = _imshow(axes=[X, Y]).transform + assert isinstance(t.x_to_pixel(11.0), float) + assert isinstance(t.x_length_to_pixels(1.0), float) + + def test_axis_properties_are_copies(self): + plot = _imshow(axes=[X, Y]) + np.testing.assert_allclose(plot.x_axis, X) + np.testing.assert_allclose(plot.y_axis, Y) + plot.x_axis[0] = 999.0 + assert plot.x_axis[0] == pytest.approx(10.0) + + +class TestCalibrated: + def test_points(self): + t = _imshow(axes=[X, Y]).transform + assert t.x_to_pixel(10.0) == pytest.approx(0.0) + assert t.x_to_pixel(11.0) == pytest.approx(2.0) + assert t.y_to_pixel(-2.0) == pytest.approx(4.0) + assert t.pixel_to_x(2.0) == pytest.approx(11.0) + assert t.pixel_to_y(4.0) == pytest.approx(-2.0) + + def test_extrapolates_to_image_edges(self): + """Half a pixel beyond the first/last centre is the image edge.""" + t = _imshow(axes=[X, Y]).transform + assert t.x_to_pixel(X[0] - 0.25) == pytest.approx(-0.5) + assert t.x_to_pixel(X[-1] + 0.25) == pytest.approx(W - 0.5) + assert t.pixel_to_y(-0.5) == pytest.approx(Y[0] - 0.125) + + def test_lengths(self): + t = _imshow(axes=[X, Y]).transform + assert t.x_length_to_pixels(1.5) == pytest.approx(3.0) + assert t.y_length_to_pixels(1.5) == pytest.approx(6.0) + assert t.pixels_to_x_length(3.0) == pytest.approx(1.5) + assert t.pixels_to_y_length(6.0) == pytest.approx(1.5) + assert t.x_length_to_pixels(-1.5) == pytest.approx(3.0) + + def test_pairs_shapes(self): + t = _imshow(axes=[X, Y]).transform + one = t.data_to_pixel((11.0, -2.0)) + assert one.shape == (2,) + np.testing.assert_allclose(one, [2.0, 4.0]) + many = t.data_to_pixel([[10.0, -3.0], [11.0, -2.0], [12.0, -1.0]]) + assert many.shape == (3, 2) + np.testing.assert_allclose(t.pixel_to_data(many), + [[10.0, -3.0], [11.0, -2.0], [12.0, -1.0]]) + + def test_plot_shorthands(self): + plot = _imshow(axes=[X, Y]) + np.testing.assert_allclose(plot.data_to_pixel([11.0, -2.0]), [2.0, 4.0]) + np.testing.assert_allclose(plot.pixel_to_data([2.0, 4.0]), [11.0, -2.0]) + + def test_bad_pairs_shape(self): + with pytest.raises(ValueError): + _imshow().transform.data_to_pixel([1.0, 2.0, 3.0]) + + def test_arrays(self): + t = _imshow(axes=[X, Y]).transform + px = t.x_to_pixel(X) + assert isinstance(px, np.ndarray) + np.testing.assert_allclose(px, np.arange(W)) + np.testing.assert_allclose(t.pixel_to_x(px), X) + + def test_live_after_set_extent(self): + plot = _imshow(axes=[X, Y]) + t = plot.transform + plot.set_extent(2 * X, 2 * Y) + assert t.x_to_pixel(22.0) == pytest.approx(2.0) + assert t.x_scale == pytest.approx(1.0) + + def test_agrees_with_view_fraction(self): + """Matches the renderer convention used by set_view / ticks.""" + plot = _imshow(axes=[X, Y]) + for v in (9.0, 10.0, 13.3, X[-1], X[-1] + 2.0): + frac = plot._axis_extent_frac(plot.x_axis, v, W) + assert (plot.transform.x_to_pixel(v) + 0.5) / W == pytest.approx(frac) + + +class TestDescending: + def test_origin_lower(self): + """origin='lower' reverses y: the first y value is the bottom row.""" + plot = _imshow(axes=[X, Y], origin="lower") + t = plot.transform + assert t.y_to_pixel(Y[0]) == pytest.approx(H - 1) + assert t.y_to_pixel(Y[-1]) == pytest.approx(0.0) + assert t.pixel_to_y(H - 1) == pytest.approx(Y[0]) + assert t.y_scale == pytest.approx(-0.25) + # Lengths stay positive. + assert t.y_length_to_pixels(0.5) == pytest.approx(2.0) + assert t.pixels_to_y_length(2.0) == pytest.approx(0.5) + + def test_descending_x(self): + t = _imshow(axes=[X[::-1], Y]).transform + assert t.x_to_pixel(X[-1]) == pytest.approx(0.0) + assert t.x_to_pixel(X[0]) == pytest.approx(W - 1) + assert t.x_to_pixel(X[0] - 0.25) == pytest.approx(W - 0.5) + assert t.x_scale == pytest.approx(-0.5) + + +class TestNonUniform: + XN = np.cumsum(np.linspace(0.1, 2.0, W)) + + def test_points_interpolate(self): + t = _imshow(axes=[self.XN, Y]).transform + np.testing.assert_allclose(t.x_to_pixel(self.XN), np.arange(W)) + mid = 0.5 * (self.XN[3] + self.XN[4]) + assert t.x_to_pixel(mid) == pytest.approx(3.5) + + def test_length_at(self): + t = _imshow(axes=[self.XN, Y]).transform + step = self.XN[11] - self.XN[10] + assert t.x_length_to_pixels(step, at=self.XN[10]) == pytest.approx(1.0) + assert t.pixels_to_x_length(1.0, at=self.XN[10]) == pytest.approx(step) + # Without `at` the average pixel size is used. + avg = (self.XN[-1] - self.XN[0]) / (W - 1) + assert t.pixels_to_x_length(1.0) == pytest.approx(avg) + + +class TestMesh: + def test_edges(self): + xe = 5.0 + 2.0 * np.arange(W + 1) + ye = np.linspace(0.0, 1.2, H + 1) + fig, ax = apl.subplots(1, 1) + plot = ax.pcolormesh(np.zeros((H, W)), x_edges=xe, y_edges=ye) + t = plot.transform + assert t.x_to_pixel(xe[0]) == pytest.approx(-0.5) + assert t.x_to_pixel(6.0) == pytest.approx(0.0) # first cell centre + assert t.y_to_pixel(ye[-1]) == pytest.approx(H - 0.5) + assert t.x_length_to_pixels(4.0) == pytest.approx(2.0) + + +class TestEdgeCases: + def test_single_pixel_axis(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((1, W)), axes=[X, [7.0]]) + t = plot.transform + assert t.y_to_pixel(7.0) == pytest.approx(0.0) + assert t.pixel_to_y(0.0) == pytest.approx(7.0) + assert t.y_length_to_pixels(1.0) == pytest.approx(1.0) + + def test_degenerate_axis_raises(self): + plot = _imshow(axes=[np.full(W, 3.0), Y]) + with pytest.raises(ValueError, match="degenerate"): + plot.transform.x_to_pixel(3.0) + + def test_tile_mode_axis_shorter_than_image(self): + """In tile mode the axis can be sampled more coarsely than the image.""" + m = _AxisMap.for_axis([0.0, 10.0, 20.0], 21, is_mesh=False) + assert m.to_pixel(10.0) == pytest.approx(10.0) + assert m.to_value(5.0) == pytest.approx(5.0) + + def test_repr(self): + assert "x_scale=0.5" in repr(_imshow(axes=[X, Y]).transform) diff --git a/docs/api/figure_plots.rst b/docs/api/figure_plots.rst index 09a6513a4..468865de3 100644 --- a/docs/api/figure_plots.rst +++ b/docs/api/figure_plots.rst @@ -24,6 +24,13 @@ Axes, Plots & Layout Plot3D PlotBar +.. rubric:: Coordinates + +.. autosummary:: + :nosignatures: + + ImageTransform + .. rubric:: Layout .. autosummary:: @@ -70,6 +77,12 @@ Axes, Plots & Layout :member-order: bysource :no-index: +.. autoclass:: anyplotlib.plot2d.ImageTransform + :members: + :show-inheritance: + :member-order: bysource + :no-index: + .. autoclass:: anyplotlib.plot3d.Plot3D :members: :show-inheritance: diff --git a/upcoming_changes/+plot2d-transform.new_feature.rst b/upcoming_changes/+plot2d-transform.new_feature.rst new file mode 100644 index 000000000..bcc940697 --- /dev/null +++ b/upcoming_changes/+plot2d-transform.new_feature.rst @@ -0,0 +1,6 @@ +Added :attr:`~anyplotlib.Plot2D.transform` (an :class:`~anyplotlib.ImageTransform`), +:meth:`~anyplotlib.Plot2D.data_to_pixel`, :meth:`~anyplotlib.Plot2D.pixel_to_data` +and read-only :attr:`~anyplotlib.Plot2D.x_axis` / :attr:`~anyplotlib.Plot2D.y_axis` +for converting between calibrated data units and the image-pixel coordinates +that 2-D widgets and markers use, including offset, non-unit, descending and +non-uniform axes. From 3f3e9ede842e81690ea036f099d9e162b49edca2 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 21 Oct 2026 13:52:39 -0500 Subject: [PATCH 05/24] widgets: add public Widget.plot and Widget.kind A widget handle now exposes its owning plot and its kind, so callers (drag callbacks, removers, backends) no longer tag widgets with their own attributes or read _plot / _type. Widget.plot drops back to None when the widget is removed, and read-only properties can no longer be shadowed by an attribute assignment routed into the wire state. Assisted-by: claude-code:claude-opus-5-5 --- anyplotlib/_base_plot.py | 4 +- anyplotlib/plot3d/_plot3d.py | 2 +- .../test_interactive/test_widget_identity.py | 85 +++++++++++++++++++ anyplotlib/widgets/_base.py | 28 ++++++ .../+widget-identity.new_feature.rst | 4 + 5 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 anyplotlib/tests/test_interactive/test_widget_identity.py create mode 100644 upcoming_changes/+widget-identity.new_feature.rst diff --git a/anyplotlib/_base_plot.py b/anyplotlib/_base_plot.py index 80eb3c34f..0425de458 100644 --- a/anyplotlib/_base_plot.py +++ b/anyplotlib/_base_plot.py @@ -632,7 +632,7 @@ def remove_widget(self, wid) -> None: wid = wid.id if wid not in self._widgets: raise KeyError(wid) - del self._widgets[wid] + self._widgets.pop(wid)._plot = None # Widget.plot -> None once detached self._push() def list_widgets(self) -> list: @@ -641,6 +641,8 @@ def list_widgets(self) -> list: def clear_widgets(self) -> None: """Remove all interactive overlay widgets from this panel.""" + for w in self._widgets.values(): + w._plot = None self._widgets.clear() self._push() diff --git a/anyplotlib/plot3d/_plot3d.py b/anyplotlib/plot3d/_plot3d.py index a8e05fc6f..2a3b89e5e 100644 --- a/anyplotlib/plot3d/_plot3d.py +++ b/anyplotlib/plot3d/_plot3d.py @@ -461,7 +461,7 @@ def remove_widget(self, wid) -> None: wid = wid.id if wid not in self._widgets: raise KeyError(wid) - del self._widgets[wid] + self._widgets.pop(wid)._plot = None # Widget.plot -> None once detached self._push() def list_widgets(self) -> list: diff --git a/anyplotlib/tests/test_interactive/test_widget_identity.py b/anyplotlib/tests/test_interactive/test_widget_identity.py new file mode 100644 index 000000000..82091b2a0 --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_widget_identity.py @@ -0,0 +1,85 @@ +""" +Public widget identity: ``Widget.plot`` and ``Widget.kind``. + +Code that holds only a widget handle (a drag callback, a remover) needs the +owning plot and the widget kind without reaching into ``_plot`` / ``_type``. +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl +from anyplotlib.widgets import RectangleWidget + + +def _plot2d(): + fig, ax = apl.subplots(1, 1) + return ax.imshow(np.zeros((12, 25))) + + +class TestWidgetPlot: + def test_plot2d_widget(self): + plot = _plot2d() + w = plot.add_widget("rectangle", x=1, y=2, w=3, h=4) + assert w.plot is plot + + def test_plot1d_widget(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.arange(48.0)) + assert plot.add_vline_widget(3.0).plot is plot + assert plot.add_range_widget(1.0, 5.0).plot is plot + + def test_standalone_is_none(self): + assert RectangleWidget(lambda: None, x=0, y=0, w=1, h=1).plot is None + + def test_none_after_remove(self): + plot = _plot2d() + w = plot.add_widget("circle", cx=3, cy=4, r=2) + w.remove() + assert w.plot is None + + def test_none_after_remove_widget_and_clear(self): + plot = _plot2d() + a = plot.add_widget("crosshair", cx=3, cy=4) + b = plot.add_widget("crosshair", cx=5, cy=6) + plot.remove_widget(a) + assert a.plot is None and b.plot is plot + plot.clear_widgets() + assert b.plot is None + + def test_read_only(self): + plot = _plot2d() + w = plot.add_widget("crosshair", cx=3, cy=4) + with pytest.raises(AttributeError): + w.plot = None + assert "plot" not in w.to_dict() + + +class TestWidgetKind: + @pytest.mark.parametrize("kind", ["rectangle", "circle", "annular", + "polygon", "crosshair", "label", + "arrow", "line", "brush", "vline", + "hline"]) + def test_plot2d_kinds(self, kind): + w = _plot2d().add_widget(kind) + assert w.kind == kind == w.get("type") + + def test_plot1d_kinds(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.arange(48.0)) + assert plot.add_vline_widget(1.0).kind == "vline" + assert plot.add_hline_widget(1.0).kind == "hline" + assert plot.add_range_widget(1.0, 2.0).kind == "range" + assert plot.add_point_widget(1.0, 2.0).kind == "point" + + def test_read_only(self): + w = _plot2d().add_widget("crosshair") + with pytest.raises(AttributeError): + w.kind = "circle" + + def test_writable_property_still_routes(self): + """``visible`` has a setter, so assignment keeps working.""" + w = _plot2d().add_widget("crosshair") + w.visible = False + assert w.visible is False diff --git a/anyplotlib/widgets/_base.py b/anyplotlib/widgets/_base.py index 886eafd05..ea85a6998 100644 --- a/anyplotlib/widgets/_base.py +++ b/anyplotlib/widgets/_base.py @@ -74,6 +74,12 @@ def __setattr__(self, key: str, value) -> None: if key.startswith("_") or key == "callbacks": super().__setattr__(key, value) return + # Read-only properties (plot, kind, id, ...) must not be shadowed by a + # same-named entry in the wire state; let ``property`` raise. + attr = getattr(type(self), key, None) + if isinstance(attr, property) and attr.fset is None: + super().__setattr__(key, value) + return # During __init__ _data may not exist yet try: object.__getattribute__(self, "_data") @@ -265,3 +271,25 @@ def __repr__(self) -> str: def id(self) -> str: """Return the widget's unique identifier.""" return self._id + + # ── identity ────────────────────────────────────────────────────────── + + @property + def kind(self) -> str: + """The widget kind, e.g. ``"rectangle"``, ``"crosshair"``, ``"vline"``. + + The same string :meth:`~anyplotlib.Plot2D.add_widget` dispatches on + (and ``widget.get("type")`` returns), so code holding only a widget + can branch on what it is without ``isinstance`` checks. + """ + return self._type + + @property + def plot(self): + """The plot this widget is attached to, or ``None``. + + ``None`` for a widget constructed standalone, and again once the + widget has been removed (:meth:`remove`, ``plot.remove_widget`` or + ``plot.clear_widgets``). + """ + return self._plot diff --git a/upcoming_changes/+widget-identity.new_feature.rst b/upcoming_changes/+widget-identity.new_feature.rst new file mode 100644 index 000000000..3f2c8e8c8 --- /dev/null +++ b/upcoming_changes/+widget-identity.new_feature.rst @@ -0,0 +1,4 @@ +Added read-only :attr:`~anyplotlib.Widget.plot` (the owning plot, ``None`` +once removed) and :attr:`~anyplotlib.Widget.kind` (``"rectangle"``, +``"vline"``, …) so code holding only a widget handle can find its plot and +type without private attributes. From e0d7556cc0104824b58b074dbaeb91bd37188ab5 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 21 Oct 2026 14:05:59 -0500 Subject: [PATCH 06/24] widgets: opt-in data-coordinate geometry (coords="data") 2-D widgets are placed in image pixels, so callers working in calibrated units had to convert every position, length and drag readout themselves. Every Plot2D add_*_widget (and add_widget) now takes coords="data": the widget converts its geometry through Plot2D.transform on every read and write, so attribute access, get(), to_dict(), set() and therefore drag callbacks all see calibrated values. The wire state stays in pixels (serialised via the new private _wire_dict), so the renderer and JS drags are untouched. Each widget class declares the role of its geometric fields (position, length, signed vector, point list, strokes) in _GEOMETRY. get() and to_dict() take a coords= override, and set_coords() switches frames without moving the widget. 1-D and bar adders accept coords="data" (their only frame) so one call works on any panel. Assisted-by: claude-code:claude-opus-5-5 --- anyplotlib/_base_plot.py | 2 +- anyplotlib/plot1d/_plot1d.py | 31 ++- anyplotlib/plot1d/_plotbar.py | 15 +- anyplotlib/plot2d/_plot2d.py | 165 ++++++++----- anyplotlib/plot3d/_plot3d.py | 4 +- .../test_widget_data_coords.py | 233 ++++++++++++++++++ anyplotlib/widgets/_base.py | 181 +++++++++++++- anyplotlib/widgets/_widgets1d.py | 6 + anyplotlib/widgets/_widgets2d.py | 32 ++- .../+widget-data-coords.new_feature.rst | 8 + 10 files changed, 601 insertions(+), 76 deletions(-) create mode 100644 anyplotlib/tests/test_interactive/test_widget_data_coords.py create mode 100644 upcoming_changes/+widget-data-coords.new_feature.rst diff --git a/anyplotlib/_base_plot.py b/anyplotlib/_base_plot.py index 0425de458..f11cb7523 100644 --- a/anyplotlib/_base_plot.py +++ b/anyplotlib/_base_plot.py @@ -380,7 +380,7 @@ class _PanelMixin: def _push(self) -> None: if self._fig is None: return - self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] + self._state["overlay_widgets"] = [w._wire_dict() for w in self._widgets.values()] self._fig._push(self._id) # ── geometry / coordinate conversion ────────────────────────────────── diff --git a/anyplotlib/plot1d/_plot1d.py b/anyplotlib/plot1d/_plot1d.py index 1cc21e586..5183d365f 100644 --- a/anyplotlib/plot1d/_plot1d.py +++ b/anyplotlib/plot1d/_plot1d.py @@ -15,6 +15,7 @@ from anyplotlib._base_plot import _BasePlot, _PanelMixin, _MarkerMixin from anyplotlib.markers import MarkerRegistry from anyplotlib.callbacks import CallbackRegistry +from anyplotlib.widgets._base import _check_data_only from anyplotlib.widgets import ( Widget, VLineWidget as _VLineWidget, @@ -394,7 +395,7 @@ def to_state_dict(self) -> dict: 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["overlay_widgets"] = [w._wire_dict() for w in self._widgets.values()] markers = self.markers.to_wire_list() # Hoist any raster image bytes into the deduped geometry channel so a # view-only push never re-serialises the (potentially large) RGBA blob. @@ -703,7 +704,8 @@ def clear_spans(self) -> None: # ------------------------------------------------------------------ def add_vline_widget(self, x: float, color: str = "#00e5ff", linewidth: float = 2, - snap_values=None) -> _VLineWidget: + snap_values=None, + coords: str = "data") -> _VLineWidget: """Add a draggable vertical-line overlay. Parameters @@ -714,6 +716,10 @@ def add_vline_widget(self, x: float, color: str = "#00e5ff", CSS colour string. Default ``"#00e5ff"``. linewidth : float, optional Line stroke width in px. Default 2. + coords : ``"data"``, optional + Accepted for symmetry with :class:`~anyplotlib.Plot2D`, whose + widgets default to image pixels; 1-D widgets are always in data + coordinates, so ``"data"`` is the only valid value. Returns ------- @@ -723,6 +729,7 @@ def add_vline_widget(self, x: float, color: str = "#00e5ff", """ widget = _VLineWidget(lambda: None, x=float(x), color=color, linewidth=linewidth, snap_values=snap_values) + widget._coords = _check_data_only(coords) widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget self._push() @@ -730,7 +737,8 @@ def add_vline_widget(self, x: float, color: str = "#00e5ff", def add_hline_widget(self, y: float, color: str = "#00e5ff", linewidth: float = 2, - snap_values=None) -> _HLineWidget: + snap_values=None, + coords: str = "data") -> _HLineWidget: """Add a draggable horizontal-line overlay. Parameters @@ -741,6 +749,10 @@ def add_hline_widget(self, y: float, color: str = "#00e5ff", CSS colour string. Default ``"#00e5ff"``. linewidth : float, optional Line stroke width in px. Default 2. + coords : ``"data"``, optional + Accepted for symmetry with :class:`~anyplotlib.Plot2D`, whose + widgets default to image pixels; 1-D widgets are always in data + coordinates, so ``"data"`` is the only valid value. Returns ------- @@ -750,6 +762,7 @@ def add_hline_widget(self, y: float, color: str = "#00e5ff", """ widget = _HLineWidget(lambda: None, y=float(y), color=color, linewidth=linewidth, snap_values=snap_values) + widget._coords = _check_data_only(coords) widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget self._push() @@ -763,6 +776,7 @@ def add_range_widget(self, x0: float, x1: float, max_extent: float | None = None, orientation: str = "horizontal", snap_values=None, + coords: str = "data", _push: bool = True) -> _RangeWidget: """Add a draggable range overlay to this panel. @@ -801,6 +815,10 @@ def add_range_widget(self, x0: float, x1: float, _push : bool, optional Push state to JS immediately. Set to ``False`` when adding several widgets at once; call :meth:`_push` manually afterward. + coords : ``"data"``, optional + Accepted for symmetry with :class:`~anyplotlib.Plot2D`, whose + widgets default to image pixels; 1-D widgets are always in data + coordinates, so ``"data"`` is the only valid value. Returns ------- @@ -813,6 +831,7 @@ def add_range_widget(self, x0: float, x1: float, linewidth=linewidth, max_extent=max_extent, orientation=orientation, snap_values=snap_values) + widget._coords = _check_data_only(coords) widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget if _push: @@ -823,6 +842,7 @@ def add_point_widget(self, x: float, y: float, color: str = "#00e5ff", show_crosshair: bool = True, linewidth: float = 2, + coords: str = "data", _push: bool = True) -> _PointWidget: """Add a freely-draggable control point to this panel. @@ -842,6 +862,10 @@ def add_point_widget(self, x: float, y: float, _push : bool, optional Push state to JS immediately. Set to ``False`` when adding several widgets at once; call :meth:`_push` manually afterward. + coords : ``"data"``, optional + Accepted for symmetry with :class:`~anyplotlib.Plot2D`, whose + widgets default to image pixels; 1-D widgets are always in data + coordinates, so ``"data"`` is the only valid value. Returns ------- @@ -849,6 +873,7 @@ 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, linewidth=linewidth) + widget._coords = _check_data_only(coords) widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget if _push: diff --git a/anyplotlib/plot1d/_plotbar.py b/anyplotlib/plot1d/_plotbar.py index f7a29f697..10fe4c332 100644 --- a/anyplotlib/plot1d/_plotbar.py +++ b/anyplotlib/plot1d/_plotbar.py @@ -11,6 +11,7 @@ from anyplotlib._base_plot import _BasePlot, _PanelMixin from anyplotlib.callbacks import CallbackRegistry +from anyplotlib.widgets._base import _check_data_only from anyplotlib.widgets import ( Widget, VLineWidget as _VLineWidget, @@ -218,7 +219,7 @@ def __init__(self, x, height=None, width: float = 0.8, bottom: float = 0.0, *, def to_state_dict(self) -> dict: d = dict(self._state) - d["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] + d["overlay_widgets"] = [w._wire_dict() for w in self._widgets.values()] return d # ------------------------------------------------------------------ @@ -384,17 +385,21 @@ def reset_view(self) -> None: # ------------------------------------------------------------------ # Overlay Widgets # ------------------------------------------------------------------ - def add_vline_widget(self, x: float, color: str = "#00e5ff") -> _VLineWidget: + def add_vline_widget(self, x: float, color: str = "#00e5ff", + coords: str = "data") -> _VLineWidget: """Add a draggable vertical line at data position *x*.""" widget = _VLineWidget(lambda: None, x=float(x), color=color) + widget._coords = _check_data_only(coords) widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget self._push() return widget - def add_hline_widget(self, y: float, color: str = "#00e5ff") -> _HLineWidget: + def add_hline_widget(self, y: float, color: str = "#00e5ff", + coords: str = "data") -> _HLineWidget: """Add a draggable horizontal line at value-axis position *y*.""" widget = _HLineWidget(lambda: None, y=float(y), color=color) + widget._coords = _check_data_only(coords) widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget self._push() @@ -404,10 +409,12 @@ def add_range_widget(self, x0: float, x1: float, color: str = "#00e5ff", style: str = "band", y: float = 0.0, + coords: str = "data", _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)) + widget._coords = _check_data_only(coords) widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget if _push: @@ -417,10 +424,12 @@ def add_range_widget(self, x0: float, x1: float, def add_point_widget(self, x: float, y: float, color: str = "#00e5ff", show_crosshair: bool = True, + coords: str = "data", _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) + widget._coords = _check_data_only(coords) widget._push_fn = self._make_widget_push_fn(widget) self._widgets[widget.id] = widget if _push: diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index 1b54d9adf..a785eaaaa 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -24,6 +24,7 @@ from anyplotlib.markers import MarkerRegistry from anyplotlib.plot2d._transform import ImageTransform from anyplotlib.callbacks import CallbackRegistry +from anyplotlib.widgets._base import _check_coords, _convert_role from anyplotlib.widgets import ( Widget, RectangleWidget, CircleWidget, AnnularWidget, @@ -987,7 +988,7 @@ def to_state_dict(self) -> dict: :meth:`resolve_pixel_tokens` on the returned dict (``Figure._push`` does this for the standalone trait when binary transport is off).""" d = dict(self._state) - d["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] + d["overlay_widgets"] = [w._wire_dict() for w in self._widgets.values()] d["markers"] = self.markers.to_wire_list() return d @@ -2013,6 +2014,28 @@ def set_scalebar_style(self, color: str | None = None, # ------------------------------------------------------------------ # Overlay Widgets # ------------------------------------------------------------------ + def _widget_args(self, cls, coords: str, **fields) -> dict: + """Convert the geometry in *fields* from *coords* to image pixels. + + ``None`` values (meaning "use the default") pass through so the + defaults are still computed in pixel space. + """ + if _check_coords(coords) != "data": + return fields + t = self.transform + geom = cls._GEOMETRY + return {k: (_convert_role(geom[k], v, t, True) + if k in geom and v is not None else v) + for k, v in fields.items()} + + def _register_widget(self, widget: Widget, coords: str) -> Widget: + """Attach *widget* to this panel in the given coordinate frame and push.""" + widget._coords = coords + widget._push_fn = self._make_widget_push_fn(widget) + self._widgets[widget.id] = widget + self._push() + return widget + def add_widget(self, kind: str, color: str = "#00e5ff", **kwargs) -> Widget: """Add an overlay widget by kind name. @@ -2022,7 +2045,16 @@ def add_widget(self, kind: str, color: str = "#00e5ff", **kwargs) -> Widget: ``"brush"``, ``"vline"``, ``"hline"``. Every kind also accepts ``show_handles`` (default ``True``) to toggle - the grab-handle dots without changing hit-testing / draggability. + the grab-handle dots without changing hit-testing / draggability, and + ``coords`` (``"pixel"`` default, or ``"data"``) to give the geometry in + calibrated axis units — see :attr:`Widget.coords`:: + + x = 10.0 + 0.5 * np.arange(25) # nm + y = -3.0 + 0.25 * np.arange(12) + plot = ax.imshow(img, axes=[x, y], units="nm") + roi = plot.add_widget("rectangle", x=11.0, y=-2.0, w=2.0, h=1.0, + coords="data") + roi.x # 11.0 — reads come back in nm too ``vline`` / ``hline`` have no handles — the whole line is the grab target — so they ignore it, and neither does ``brush`` (a freehand stroke has no grab point). @@ -2049,7 +2081,8 @@ def add_circle_widget(self, cx: float | None = None, cy: float | None = None, r: float | None = None, color: str = "#00e5ff", linewidth: float = 2, show_handles: bool = True, - lock_center: bool = False) -> CircleWidget: + lock_center: bool = False, + coords: str = "pixel") -> CircleWidget: """Add a draggable circle overlay. ``lock_center`` pins the centre and leaves only the radius draggable — @@ -2058,6 +2091,8 @@ def add_circle_widget(self, cx: float | None = None, cy: float | None = None, centred on the DC term), so it cannot be nudged off and silently corrupt every radius measured from it. """ + a = self._widget_args(CircleWidget, coords, cx=cx, cy=cy, r=r) + cx, cy, r = a["cx"], a["cy"], a["r"] 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, @@ -2066,16 +2101,14 @@ def add_circle_widget(self, cx: float | None = None, cy: float | None = None, color=color, linewidth=linewidth, show_handles=show_handles, lock_center=lock_center) - widget._push_fn = self._make_widget_push_fn(widget) - self._widgets[widget.id] = widget - self._push() - return widget + return self._register_widget(widget, coords) def add_rectangle_widget(self, x: float | None = None, y: float | None = None, w: float | None = None, h: float | None = None, color: str = "#00e5ff", linewidth: float = 2, show_handles: bool = True, - max_extent=None) -> RectangleWidget: + max_extent=None, + coords: str = "pixel") -> RectangleWidget: """Add a draggable rectangle overlay. ``max_extent`` caps the rectangle's size in the widget's coordinates — @@ -2083,6 +2116,14 @@ def add_rectangle_widget(self, x: float | None = None, y: float | None = None, The rectangle then physically stops growing at the cap while dragging (the dragged corner pins, the opposite corner stays put). """ + a = self._widget_args(RectangleWidget, coords, x=x, y=y, w=w, h=h) + x, y, w, h = a["x"], a["y"], a["w"], a["h"] + if coords == "data" and max_extent is not None: + mw, mh = (max_extent if isinstance(max_extent, (tuple, list)) + else (max_extent, max_extent)) + t = self.transform + max_extent = (None if mw is None else t.x_length_to_pixels(mw), + None if mh is None else t.y_length_to_pixels(mh)) iw, ih = self._state["image_width"], self._state["image_height"] # The DEFAULT size honours max_extent. Without this a capped rectangle is # born at half the image and then snaps down to the cap on first use — @@ -2106,16 +2147,18 @@ def _cap(axis_default, cap): color=color, linewidth=linewidth, show_handles=show_handles, max_extent=max_extent) - widget._push_fn = self._make_widget_push_fn(widget) - self._widgets[widget.id] = widget - self._push() - return widget + return self._register_widget(widget, coords) 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", linewidth: float = 2, - show_handles: bool = True) -> AnnularWidget: + show_handles: bool = True, + coords: str = "pixel") -> AnnularWidget: """Add a draggable annular (ring) overlay.""" + a = self._widget_args(AnnularWidget, coords, cx=cx, cy=cy, + r_outer=r_outer, r_inner=r_inner) + cx, cy = a["cx"], a["cy"] + r_outer, r_inner = a["r_outer"], a["r_inner"] 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, @@ -2124,31 +2167,29 @@ def add_annular_widget(self, cx: float | None = None, cy: float | None = None, r_inner=float(r_inner) if r_inner is not None else iw * 0.1, color=color, linewidth=linewidth, show_handles=show_handles) - widget._push_fn = self._make_widget_push_fn(widget) - self._widgets[widget.id] = widget - self._push() - return widget + return self._register_widget(widget, coords) def add_polygon_widget(self, vertices=None, color: str = "#00e5ff", linewidth: float = 2, - show_handles: bool = True) -> PolygonWidget: + show_handles: bool = True, + coords: str = "pixel") -> PolygonWidget: """Add a draggable polygon overlay.""" + vertices = self._widget_args(PolygonWidget, coords, + vertices=vertices)["vertices"] 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, linewidth=linewidth, show_handles=show_handles) - widget._push_fn = self._make_widget_push_fn(widget) - self._widgets[widget.id] = widget - self._push() - return widget + return self._register_widget(widget, coords) def add_brush_widget(self, radius: float | None = None, color: str = "#00e5ff", colors=None, class_id: int = 0, strokes=None, stroke_classes=None, alpha: float = 0.6, active: bool = True, - erase: bool = False) -> BrushWidget: + erase: bool = False, + coords: str = "pixel") -> BrushWidget: """Add a freehand paint-brush overlay for labelling regions. **Shift** + drag paints a stroke; a *bare* drag still pans the image and @@ -2192,6 +2233,9 @@ def add_brush_widget(self, radius: float | None = None, erase : bool, optional Armed drags remove stroke points within ``radius`` instead of painting. Default ``False``. + coords : ``"pixel"`` | ``"data"``, optional + Frame for ``radius`` and ``strokes`` (and for later reads). + Default ``"pixel"``. Returns ------- @@ -2200,6 +2244,9 @@ def add_brush_widget(self, radius: float | None = None, the painted geometry; :meth:`~anyplotlib.BrushWidget.clear_strokes` and :meth:`~anyplotlib.BrushWidget.strokes_for_class` manage it. """ + a = self._widget_args(BrushWidget, coords, radius=radius, + strokes=strokes) + radius, strokes = a["radius"], a["strokes"] iw, ih = self._state["image_width"], self._state["image_height"] if radius is None: radius = max(2.0, min(iw, ih) * 0.02) @@ -2208,50 +2255,50 @@ def add_brush_widget(self, radius: float | None = None, class_id=class_id, strokes=strokes, stroke_classes=stroke_classes, alpha=alpha, active=active, erase=erase) - widget._push_fn = self._make_widget_push_fn(widget) - self._widgets[widget.id] = widget - self._push() - return widget + return self._register_widget(widget, coords) def add_crosshair_widget(self, cx: float | None = None, cy: float | None = None, color: str = "#00e5ff", linewidth: float = 2, - show_handles: bool = True) -> CrosshairWidget: + show_handles: bool = True, + coords: str = "pixel") -> CrosshairWidget: """Add a draggable crosshair overlay.""" + a = self._widget_args(CrosshairWidget, coords, cx=cx, cy=cy) + cx, cy = a["cx"], a["cy"] 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, linewidth=linewidth, show_handles=show_handles) - widget._push_fn = self._make_widget_push_fn(widget) - self._widgets[widget.id] = widget - self._push() - return widget + return self._register_widget(widget, coords) def add_label_widget(self, x: float | None = None, y: float | None = None, text: str = "Label", fontsize: int = 14, color: str = "#00e5ff", - show_handles: bool = True) -> LabelWidget: + show_handles: bool = True, + coords: str = "pixel") -> LabelWidget: """Add a draggable text label overlay.""" + a = self._widget_args(LabelWidget, coords, x=x, y=y) + x, y = a["x"], a["y"] 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, show_handles=show_handles) - widget._push_fn = self._make_widget_push_fn(widget) - self._widgets[widget.id] = widget - self._push() - return widget + return self._register_widget(widget, coords) def add_arrow_widget(self, x: float | None = None, y: float | None = None, u: float | None = None, v: float | None = None, color: str = "#00e5ff", linewidth: float = 2, - show_handles: bool = True) -> ArrowWidget: + show_handles: bool = True, + coords: str = "pixel") -> ArrowWidget: """Add a draggable arrow overlay (tail at ``(x, y)``, head at ``(x + u, y + v)``). Defaults place the tail at 25 %, 25 % of the image with a vector of 15 % of the image size, mirroring :meth:`add_label_widget`'s defaulting.""" + a = self._widget_args(ArrowWidget, coords, x=x, y=y, u=u, v=v) + x, y, u, v = a["x"], a["y"], a["u"], a["v"] iw, ih = self._state["image_width"], self._state["image_height"] widget = ArrowWidget(lambda: None, x=float(x) if x is not None else iw * 0.25, @@ -2260,15 +2307,13 @@ def add_arrow_widget(self, x: float | None = None, y: float | None = None, v=float(v) if v is not None else ih * 0.15, color=color, linewidth=linewidth, show_handles=show_handles) - widget._push_fn = self._make_widget_push_fn(widget) - self._widgets[widget.id] = widget - self._push() - return widget + return self._register_widget(widget, coords) def add_line_widget(self, x1: float | None = None, y1: float | None = None, x2: float | None = None, y2: float | None = None, color: str = "#00e5ff", linewidth: float = 2, - show_handles: bool = True) -> LineWidget: + show_handles: bool = True, + coords: str = "pixel") -> LineWidget: """Add a draggable two-endpoint line segment overlay. A bare segment with a grab handle at each end — no arrowhead (see @@ -2289,6 +2334,9 @@ def add_line_widget(self, x1: float | None = None, y1: float | None = None, Stroke width in px. Default 2. show_handles : bool, optional Draw the endpoint grab handles. Default ``True``. + coords : ``"pixel"`` | ``"data"``, optional + Frame for the endpoints (and for later reads / ``set``). + Default ``"pixel"``; see :attr:`Widget.coords`. Returns ------- @@ -2296,6 +2344,8 @@ def add_line_widget(self, x1: float | None = None, y1: float | None = None, Widget object. ``widget.length`` gives the segment length in data coordinates. """ + a = self._widget_args(LineWidget, coords, x1=x1, y1=y1, x2=x2, y2=y2) + x1, y1, x2, y2 = a["x1"], a["y1"], a["x2"], a["y2"] iw, ih = self._state["image_width"], self._state["image_height"] widget = LineWidget(lambda: None, x1=float(x1) if x1 is not None else iw * 0.25, @@ -2304,13 +2354,11 @@ def add_line_widget(self, x1: float | None = None, y1: float | None = None, y2=float(y2) if y2 is not None else ih * 0.75, color=color, linewidth=linewidth, show_handles=show_handles) - widget._push_fn = self._make_widget_push_fn(widget) - self._widgets[widget.id] = widget - self._push() - return widget + return self._register_widget(widget, coords) def add_vline_widget(self, x: float | None = None, color: str = "#00e5ff", - linewidth: float = 2) -> VLineWidget: + linewidth: float = 2, + coords: str = "pixel") -> VLineWidget: """Add a draggable full-height vertical line overlay. The line spans the whole panel and is grabbable anywhere along its @@ -2325,22 +2373,24 @@ def add_vline_widget(self, x: float | None = None, color: str = "#00e5ff", CSS colour string. Default ``"#00e5ff"``. linewidth : float, optional Stroke width in px. Default 2. + coords : ``"pixel"`` | ``"data"``, optional + Frame for ``x`` (and for later reads / ``set``). Default + ``"pixel"``; pass ``"data"`` to work in calibrated axis units. Returns ------- VLineWidget """ + x = self._widget_args(VLineWidget, coords, x=x)["x"] iw = self._state["image_width"] widget = VLineWidget(lambda: None, x=float(x) if x is not None else iw / 2, color=color, linewidth=linewidth) - widget._push_fn = self._make_widget_push_fn(widget) - self._widgets[widget.id] = widget - self._push() - return widget + return self._register_widget(widget, coords) def add_hline_widget(self, y: float | None = None, color: str = "#00e5ff", - linewidth: float = 2) -> HLineWidget: + linewidth: float = 2, + coords: str = "pixel") -> HLineWidget: """Add a draggable full-width horizontal line overlay. The row counterpart of :meth:`add_vline_widget`; grabbable anywhere @@ -2354,19 +2404,20 @@ def add_hline_widget(self, y: float | None = None, color: str = "#00e5ff", CSS colour string. Default ``"#00e5ff"``. linewidth : float, optional Stroke width in px. Default 2. + coords : ``"pixel"`` | ``"data"``, optional + Frame for ``y`` (and for later reads / ``set``). Default + ``"pixel"``; pass ``"data"`` to work in calibrated axis units. Returns ------- HLineWidget """ + y = self._widget_args(HLineWidget, coords, y=y)["y"] ih = self._state["image_height"] widget = HLineWidget(lambda: None, y=float(y) if y is not None else ih / 2, color=color, linewidth=linewidth) - widget._push_fn = self._make_widget_push_fn(widget) - self._widgets[widget.id] = widget - self._push() - return widget + return self._register_widget(widget, coords) # ------------------------------------------------------------------ # View control diff --git a/anyplotlib/plot3d/_plot3d.py b/anyplotlib/plot3d/_plot3d.py index 2a3b89e5e..f0141cd86 100644 --- a/anyplotlib/plot3d/_plot3d.py +++ b/anyplotlib/plot3d/_plot3d.py @@ -416,7 +416,7 @@ def __init__(self, geom_type: str, def _push(self) -> None: if self._fig is None: return - self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] + self._state["overlay_widgets"] = [w._wire_dict() for w in self._widgets.values()] self._fig._push(self._id) def _push_fields(self, **fields) -> None: @@ -493,7 +493,7 @@ def to_state_dict(self) -> dict: # an in-progress plane drag in JS — the "snap-back" symptom. d = dict(self._state) if self._widgets: - d["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] + d["overlay_widgets"] = [w._wire_dict() for w in self._widgets.values()] return d # ------------------------------------------------------------------ diff --git a/anyplotlib/tests/test_interactive/test_widget_data_coords.py b/anyplotlib/tests/test_interactive/test_widget_data_coords.py new file mode 100644 index 000000000..3ee454a12 --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_widget_data_coords.py @@ -0,0 +1,233 @@ +""" +Widgets in calibrated data coordinates (``coords="data"``). + +The renderer places 2-D widgets in image pixels. A widget created with +``coords="data"`` takes and reports its geometry in the panel's calibrated +axis units, while the wire state (what the renderer and JS drags see) stays +in pixels. + +The image is 12 rows × 25 columns so a swapped axis cannot pass. +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl + +H, W = 12, 25 +X = 10.0 + 0.5 * np.arange(W) # data x = 10 + 0.5 * col +Y = -3.0 + 0.25 * np.arange(H) # data y = -3 + 0.25 * row + + +def px_x(x): + return (x - 10.0) / 0.5 + + +def px_y(y): + return (y + 3.0) / 0.25 + + +def _plot(**kwargs): + fig, ax = apl.subplots(1, 1) + return ax.imshow(np.zeros((H, W)), axes=[X, Y], **kwargs) + + +def _wire(widget): + """The widget as serialised to the renderer.""" + plot = widget.plot + entries = plot.to_state_dict()["overlay_widgets"] + return next(e for e in entries if e["id"] == widget.id) + + +class TestFrame: + def test_default_is_pixel_on_2d(self): + w = _plot().add_widget("crosshair", cx=2, cy=3) + assert w.coords == "pixel" + assert w.cx == 2 + + def test_data_frame(self): + w = _plot().add_widget("crosshair", cx=11.0, cy=-2.0, coords="data") + assert w.coords == "data" + assert w.cx == pytest.approx(11.0) + assert w.cy == pytest.approx(-2.0) + assert _wire(w)["cx"] == pytest.approx(2.0) + assert _wire(w)["cy"] == pytest.approx(4.0) + + def test_invalid_coords(self): + with pytest.raises(ValueError, match="coords"): + _plot().add_widget("crosshair", coords="axes") + + def test_1d_is_data(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.arange(48.0), axes=[np.linspace(0, 1, 48)]) + w = plot.add_vline_widget(0.25, coords="data") + assert w.coords == "data" and w.x == 0.25 + assert plot.add_range_widget(0.1, 0.2).coords == "data" + assert plot.add_point_widget(0.1, 0.2, coords="data").x == 0.1 + assert plot.add_hline_widget(0.5, coords="data").y == 0.5 + with pytest.raises(ValueError, match="data"): + plot.add_vline_widget(0.25, coords="pixel") + + def test_bar_accepts_data(self): + fig, ax = apl.subplots(1, 1) + bar = ax.bar(["a", "b", "c"], [1.0, 2.0, 3.0]) + assert bar.add_hline_widget(1.5, coords="data").y == 1.5 + with pytest.raises(ValueError): + bar.add_vline_widget(0.5, coords="pixel") + + +class TestKinds: + def test_rectangle(self): + w = _plot().add_widget("rectangle", x=11.0, y=-2.0, w=2.0, h=1.0, + coords="data") + wire = _wire(w) + assert (wire["x"], wire["y"], wire["w"], wire["h"]) == pytest.approx( + (2.0, 4.0, 4.0, 4.0)) + assert (w.x, w.y, w.w, w.h) == pytest.approx((11.0, -2.0, 2.0, 1.0)) + + def test_rectangle_max_extent(self): + w = _plot().add_rectangle_widget(x=11.0, y=-2.0, w=1.0, h=0.5, + max_extent=(2.0, 1.0), coords="data") + assert _wire(w)["max_w"] == pytest.approx(4.0) + assert _wire(w)["max_h"] == pytest.approx(4.0) + assert w.max_w == pytest.approx(2.0) + + def test_rectangle_scalar_max_extent(self): + w = _plot().add_rectangle_widget(x=11.0, y=-2.0, w=0.5, h=0.5, + max_extent=1.0, coords="data") + assert _wire(w)["max_w"] == pytest.approx(2.0) + assert _wire(w)["max_h"] == pytest.approx(4.0) + + def test_circle(self): + w = _plot().add_widget("circle", cx=12.0, cy=-1.0, r=1.5, coords="data") + wire = _wire(w) + assert (wire["cx"], wire["cy"], wire["r"]) == pytest.approx((4.0, 8.0, 3.0)) + assert w.r == pytest.approx(1.5) + + def test_annular(self): + w = _plot().add_widget("annular", cx=12.0, cy=-1.0, r_outer=2.0, + r_inner=1.0, coords="data") + assert _wire(w)["r_outer"] == pytest.approx(4.0) + assert _wire(w)["r_inner"] == pytest.approx(2.0) + assert w.r_inner == pytest.approx(1.0) + + def test_line(self): + w = _plot().add_widget("line", x1=10.0, y1=-3.0, x2=12.0, y2=-2.0, + coords="data") + wire = _wire(w) + assert (wire["x1"], wire["y1"], wire["x2"], wire["y2"]) == pytest.approx( + (0.0, 0.0, 4.0, 4.0)) + # length is reported in the widget's frame + assert w.length == pytest.approx(np.hypot(2.0, 1.0)) + + def test_polygon(self): + verts = [(10.0, -3.0), (12.0, -3.0), (12.0, -2.0)] + w = _plot().add_widget("polygon", vertices=verts, coords="data") + np.testing.assert_allclose(_wire(w)["vertices"], + [[0.0, 0.0], [4.0, 0.0], [4.0, 4.0]]) + np.testing.assert_allclose(w.vertices, verts) + + def test_label(self): + w = _plot().add_widget("label", x=11.0, y=-2.0, text="hi", coords="data") + assert (_wire(w)["x"], _wire(w)["y"]) == pytest.approx((2.0, 4.0)) + assert w.text == "hi" + + def test_arrow(self): + w = _plot().add_widget("arrow", x=11.0, y=-2.0, u=1.0, v=0.5, + coords="data") + wire = _wire(w) + assert (wire["u"], wire["v"]) == pytest.approx((2.0, 2.0)) + assert (w.u, w.v) == pytest.approx((1.0, 0.5)) + + def test_arrow_descending_y_keeps_sign(self): + """origin='lower': data +y points up the screen, i.e. -rows.""" + w = _plot(origin="lower").add_widget("arrow", x=11.0, y=-2.0, u=0.0, + v=0.5, coords="data") + assert _wire(w)["v"] == pytest.approx(-2.0) + assert w.v == pytest.approx(0.5) + + def test_vline_hline(self): + plot = _plot() + v = plot.add_vline_widget(x=12.0, coords="data") + h = plot.add_hline_widget(y=-1.0, coords="data") + assert _wire(v)["x"] == pytest.approx(4.0) + assert _wire(h)["y"] == pytest.approx(8.0) + assert v.x == pytest.approx(12.0) and h.y == pytest.approx(-1.0) + + def test_vline_snap_values(self): + v = _plot().add_vline_widget(x=12.0, coords="data") + v.set(snap_values=[10.0, 11.0], notify=False) + assert _wire(v)["snap_values"] == pytest.approx([0.0, 2.0]) + assert v.snap_values == pytest.approx([10.0, 11.0]) + + def test_brush(self): + plot = _plot() + b = plot.add_brush_widget(radius=1.0, strokes=[[(10.0, -3.0), (11.0, -2.0)]], + coords="data") + assert _wire(b)["radius"] == pytest.approx(2.0) + np.testing.assert_allclose(_wire(b)["strokes"], [[[0.0, 0.0], [2.0, 4.0]]]) + b.add_stroke([(12.0, -1.0)], class_id=1) + np.testing.assert_allclose(_wire(b)["strokes"][0], [[0.0, 0.0], [2.0, 4.0]]) + np.testing.assert_allclose(_wire(b)["strokes"][1], [[4.0, 8.0]]) + np.testing.assert_allclose(b.strokes_for_class(1), [[[12.0, -1.0]]]) + np.testing.assert_allclose(b.strokes[0], [[10.0, -3.0], [11.0, -2.0]]) + + def test_defaults_are_in_pixels(self): + """Omitted geometry still defaults relative to the image.""" + w = _plot().add_widget("crosshair", coords="data") + assert _wire(w)["cx"] == pytest.approx(W / 2) + assert w.cx == pytest.approx(10.0 + 0.5 * W / 2) + + +class TestReadWrite: + def test_set_converts(self): + w = _plot().add_widget("crosshair", cx=11.0, cy=-2.0, coords="data") + w.set(cx=12.0, notify=False) + assert _wire(w)["cx"] == pytest.approx(4.0) + w.cy = -1.0 + assert _wire(w)["cy"] == pytest.approx(8.0) + + def test_get_and_to_dict(self): + w = _plot().add_widget("crosshair", cx=11.0, cy=-2.0, coords="data") + assert w.get("cx") == pytest.approx(11.0) + assert w.get("cx", coords="pixel") == pytest.approx(2.0) + assert w.get("missing", 7) == 7 + d = w.to_dict() + assert d["cx"] == pytest.approx(11.0) and d["color"] == w.color + assert w.to_dict(coords="pixel")["cx"] == pytest.approx(2.0) + + def test_pixel_widget_can_report_data(self): + w = _plot().add_widget("crosshair", cx=2.0, cy=4.0) + assert w.get("cx", coords="data") == pytest.approx(11.0) + assert w.to_dict(coords="data")["cy"] == pytest.approx(-2.0) + + def test_set_coords(self): + w = _plot().add_widget("crosshair", cx=2.0, cy=4.0) + w.set_coords("data") + assert w.cx == pytest.approx(11.0) + assert _wire(w)["cx"] == pytest.approx(2.0) # did not move + with pytest.raises(ValueError): + w.set_coords("screen") + + def test_js_drag_reports_data(self): + """A drag arrives in pixels; handlers read calibrated values.""" + w = _plot().add_widget("crosshair", cx=11.0, cy=-2.0, coords="data") + seen = [] + w.add_event_handler(lambda e: seen.append((e.source.cx, e.source.cy)), + "pointer_move") + w._update_from_js({"cx": 6.0, "cy": 2.0, "event_type": "pointer_move"}) + assert seen == [pytest.approx((13.0, -2.5))] + + def test_recalibration_keeps_pixels(self): + plot = _plot() + w = plot.add_widget("crosshair", cx=11.0, cy=-2.0, coords="data") + plot.set_extent(2 * X, 2 * Y) + assert _wire(w)["cx"] == pytest.approx(2.0) + assert w.cx == pytest.approx(22.0) + + def test_detached_widget_reads_raw(self): + plot = _plot() + w = plot.add_widget("crosshair", cx=11.0, cy=-2.0, coords="data") + w.remove() + assert w.cx == pytest.approx(2.0) # no panel, no calibration diff --git a/anyplotlib/widgets/_base.py b/anyplotlib/widgets/_base.py index ea85a6998..30d6f9361 100644 --- a/anyplotlib/widgets/_base.py +++ b/anyplotlib/widgets/_base.py @@ -9,6 +9,76 @@ from typing import Any, Callable from anyplotlib.callbacks import CallbackRegistry, Event, _EventMixin +#: Recognised coordinate frames for widget geometry. +COORDS = ("pixel", "data") + + +def _check_coords(coords: str) -> str: + if coords not in COORDS: + raise ValueError(f"coords must be one of {COORDS}, got {coords!r}") + return coords + + +def _check_data_only(coords: str) -> str: + """Validate ``coords`` on a panel whose widgets are always in data units + (1-D lines, bars). Accepting ``"data"`` lets code that also drives 2-D + panels pass ``coords="data"`` everywhere.""" + if coords != "data": + raise ValueError( + f"widgets on this panel are positioned in data coordinates; " + f"coords must be 'data', got {coords!r}") + return coords + + +def _convert_role(role: str, value, transform, to_pixels: bool): + """Convert one geometry value between data and pixel coordinates. + + *role* says what the value is (see ``Widget._GEOMETRY``): + + ``"x"`` / ``"y"`` + A position along that axis. + ``"dx"`` / ``"dy"`` / ``"r"`` + A non-negative length along x / y (``"r"`` is a radius, which on a + 2-D panel follows the x calibration since circles are drawn round in + pixel space). + ``"u"`` / ``"v"`` + A signed displacement along x / y (an arrow vector). + ``"points"`` + A list of ``[x, y]`` pairs. + ``"strokes"`` + A list of lists of ``[x, y]`` pairs. + ``"x_list"`` / ``"y_list"`` + A list of positions along one axis (``snap_values``). + """ + if value is None: + return None + t = transform + if role == "x": + return float(t.x_to_pixel(value) if to_pixels else t.pixel_to_x(value)) + if role == "y": + return float(t.y_to_pixel(value) if to_pixels else t.pixel_to_y(value)) + if role in ("dx", "r"): + return float(t.x_length_to_pixels(value) if to_pixels + else t.pixels_to_x_length(value)) + if role == "dy": + return float(t.y_length_to_pixels(value) if to_pixels + else t.pixels_to_y_length(value)) + if role == "u": + return float(value) / t.x_scale if to_pixels else float(value) * t.x_scale + if role == "v": + return float(value) / t.y_scale if to_pixels else float(value) * t.y_scale + if role == "points": + if len(value) == 0: + return [] + fn = t.data_to_pixel if to_pixels else t.pixel_to_data + return fn([[float(a), float(b)] for a, b in value]).tolist() + if role == "strokes": + return [_convert_role("points", stroke, t, to_pixels) for stroke in value] + if role in ("x_list", "y_list"): + axis = role[0] + return [_convert_role(axis, v, t, to_pixels) for v in value] + raise ValueError(f"unknown geometry role {role!r}") # pragma: no cover + class Widget(_EventMixin): """Base class for all overlay widgets. @@ -43,6 +113,15 @@ class Widget(_EventMixin): # plot; stays None for a widget constructed standalone (e.g. in a test). _plot = None + #: Coordinate frame for reads and writes; set by the plot's ``add_*`` + #: method. ``None`` means "the panel's native frame" (see :attr:`coords`). + _coords = None + + #: ``{field: role}`` for every geometric field, used to convert between + #: data and pixel coordinates. See ``_convert_role`` for the roles. + #: Subclasses override; non-geometric fields (colour, …) are left alone. + _GEOMETRY: dict = {} + def __init__(self, wtype: str, push_fn: Callable, **kwargs): self._id: str = str(_uuid.uuid4())[:8] self._type: str = wtype @@ -59,12 +138,13 @@ def __getattr__(self, key: str): if key.startswith("_"): raise AttributeError(key) try: - return self._data[key] + value = self._data[key] except KeyError: raise AttributeError( f"{type(self).__name__} has no attribute {key!r}. " f"Available: {list(self._data)}" ) from None + return self._geom_out({key: value})[key] # ── attribute write — routes public assignments through set() ──── @@ -117,7 +197,9 @@ def set(self, _push: bool = True, _notify: bool = True, *, 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). + Properties to update (e.g., x=100, y=50, radius=20). Geometry is + read in the widget's :attr:`coords` frame, so a widget created + with ``coords="data"`` takes calibrated values here. Notes ----- @@ -130,13 +212,19 @@ def set(self, _push: bool = True, _notify: bool = True, *, """ if notify is None: notify = _notify + self._set_pixels(_push, notify, **self._geom_in(kwargs)) + + def _set_pixels(self, _push: bool = True, notify: bool = True, + **kwargs) -> None: + """:meth:`set` without coordinate conversion: *kwargs* are already + in the wire (image-pixel) frame.""" self._data.update(kwargs) if _push: self._push_fn() if notify: self.callbacks.fire(Event("pointer_move", source=self)) - def get(self, key: str, default=None): + def get(self, key: str, default=None, *, coords: str | None = None): """Get a widget property by name. Parameters @@ -145,24 +233,107 @@ def get(self, key: str, default=None): Property name. default : optional Default value if property not found. + coords : ``"pixel"`` | ``"data"``, optional + Frame to report geometry in. Defaults to the widget's + :attr:`coords`. Ignored on panels without a pixel frame (1-D). Returns ------- object The property value. """ - return self._data.get(key, default) + if key not in self._data: + return default + return self._geom_out({key: self._data[key]}, coords)[key] - def to_dict(self) -> dict: + def to_dict(self, *, coords: str | None = None) -> dict: """Return a dict copy of the widget state. + Parameters + ---------- + coords : ``"pixel"`` | ``"data"``, optional + Frame to report geometry in. Defaults to the widget's + :attr:`coords`. + Returns ------- dict All widget properties including id and type. """ + return self._geom_out(dict(self._data), coords) + + def _wire_dict(self) -> dict: + """The widget state exactly as the renderer receives it (pixels).""" return dict(self._data) + # ── coordinate frames ───────────────────────────────────────────────── + + def _transform(self): + """The owning panel's :class:`~anyplotlib.ImageTransform`, or ``None`` + when the panel has no separate pixel frame (1-D, bar, 3-D, detached).""" + from anyplotlib.plot2d._transform import ImageTransform + t = getattr(self._plot, "transform", None) + return t if isinstance(t, ImageTransform) else None + + @property + def coords(self) -> str: + """Coordinate frame of this widget's geometry: ``"pixel"`` or ``"data"``. + + On a 2-D image panel, ``"pixel"`` (the default) means image pixels — + integer *i* is the centre of pixel *i*, row 0 at the top — and + ``"data"`` means the calibrated values of the panel's axes. Attribute + reads (``widget.cx``), :meth:`get`, :meth:`to_dict` and :meth:`set` + all use this frame; drag callbacks therefore see calibrated values on + a ``"data"`` widget. Choose it with ``coords=`` on the plot's + ``add_*_widget`` method, or change it with :meth:`set_coords`. + + On 1-D and bar panels positions are always in data units, so this is + ``"data"``. + + Notes + ----- + The renderer always works in pixels, so a widget stores pixels and a + ``"data"`` widget converts on every read and write. Re-calibrating + the panel (:meth:`~anyplotlib.Plot2D.set_extent`) therefore keeps the + widget on the same *pixels*; its data-coordinate values change. + """ + if self._coords is not None: + return self._coords + return "pixel" if self._transform() is not None else "data" + + def set_coords(self, coords: str) -> None: + """Switch the frame used for reading and writing geometry. + + Does not move the widget. + + Parameters + ---------- + coords : ``"pixel"`` | ``"data"`` + """ + self._coords = _check_coords(coords) + + def _geom_convert(self, fields: dict, to_pixels: bool, + coords: str | None = None) -> dict: + frame = self.coords if coords is None else _check_coords(coords) + if frame != "data" or not self._GEOMETRY: + return fields + t = self._transform() + if t is None: + return fields + out = dict(fields) + for key, role in self._GEOMETRY.items(): + if key in out: + out[key] = _convert_role(role, out[key], t, to_pixels) + return out + + def _geom_in(self, fields: dict, coords: str | None = None) -> dict: + """User-frame *fields* → wire (pixel) frame.""" + return self._geom_convert(fields, True, coords) + + def _geom_out(self, fields: dict, coords: str | None = None) -> dict: + """Wire (pixel) *fields* → user frame.""" + return self._geom_convert(fields, False, coords) + # ── visibility ──────────────────────────────────────────────────────── @property diff --git a/anyplotlib/widgets/_widgets1d.py b/anyplotlib/widgets/_widgets1d.py index ad3bb256e..0a74ca255 100644 --- a/anyplotlib/widgets/_widgets1d.py +++ b/anyplotlib/widgets/_widgets1d.py @@ -48,6 +48,9 @@ class VLineWidget(Widget): linewidth : float, optional Line stroke width in px. Default 2. """ + #: Geometry roles, used when the line sits on a 2-D image panel. + _GEOMETRY = {"x": "x", "snap_values": "x_list"} + def __init__(self, push_fn, *, x, color="#00e5ff", linewidth=2, snap_values=None): super().__init__("vline", push_fn, x=float(x), color=color, @@ -72,6 +75,9 @@ class HLineWidget(Widget): linewidth : float, optional Line stroke width in px. Default 2. """ + #: Geometry roles, used when the line sits on a 2-D image panel. + _GEOMETRY = {"y": "y", "snap_values": "y_list"} + def __init__(self, push_fn, *, y, color="#00e5ff", linewidth=2, snap_values=None): super().__init__("hline", push_fn, y=float(y), color=color, diff --git a/anyplotlib/widgets/_widgets2d.py b/anyplotlib/widgets/_widgets2d.py index cfd4ddd76..1ed0e0822 100644 --- a/anyplotlib/widgets/_widgets2d.py +++ b/anyplotlib/widgets/_widgets2d.py @@ -41,6 +41,8 @@ class RectangleWidget(Widget): Use this when the rectangle's area costs real work downstream — e.g. an integrating ROI whose size is a number of frames to read. """ + _GEOMETRY = {"x": "x", "y": "y", "w": "dx", "h": "dy", "max_w": "dx", "max_h": "dy"} + def __init__(self, push_fn, *, x, y, w, h, color="#00e5ff", linewidth=2, show_handles=True, max_extent=None): if max_extent is None: @@ -90,6 +92,8 @@ class CircleWidget(Widget): that can only ever be wrong, and one nudged off-centre silently corrupts every radius measured from it. """ + _GEOMETRY = {"cx": "x", "cy": "y", "r": "r"} + def __init__(self, push_fn, *, cx, cy, r, color="#00e5ff", linewidth=2, show_handles=True, lock_center=False): super().__init__("circle", push_fn, @@ -123,6 +127,8 @@ class AnnularWidget(Widget): ValueError If r_inner >= r_outer. """ + _GEOMETRY = {"cx": "x", "cy": "y", "r_outer": "r", "r_inner": "r"} + def __init__(self, push_fn, *, cx, cy, r_outer, r_inner, color="#00e5ff", linewidth=2, show_handles=True): if r_inner >= r_outer: @@ -158,6 +164,8 @@ class LineWidget(Widget): show_handles : bool, optional Draw the endpoint grab handles. Default ``True``. """ + _GEOMETRY = {"x1": "x", "y1": "y", "x2": "x", "y2": "y"} + def __init__(self, push_fn, *, x1, y1, x2, y2, color="#00e5ff", linewidth=2, show_handles=True): super().__init__("line", push_fn, @@ -190,6 +198,8 @@ class CrosshairWidget(Widget): show_handles : bool, optional Draw the centre dot handle. Default ``True``. """ + _GEOMETRY = {"cx": "x", "cy": "y"} + def __init__(self, push_fn, *, cx, cy, color="#00e5ff", linewidth=2, show_handles=True): super().__init__("crosshair", push_fn, @@ -220,6 +230,8 @@ class PolygonWidget(Widget): ValueError If fewer than 3 vertices provided. """ + _GEOMETRY = {"vertices": "points"} + def __init__(self, push_fn, *, vertices, color="#00e5ff", linewidth=2, show_handles=True): verts = [[float(x), float(y)] for x, y in vertices] @@ -248,6 +260,8 @@ class LabelWidget(Widget): show_handles : bool, optional Draw the anchor grab handle. Default ``True``. """ + _GEOMETRY = {"x": "x", "y": "y"} + def __init__(self, push_fn, *, x, y, text="Label", fontsize=14, color="#00e5ff", show_handles=True): super().__init__("label", push_fn, @@ -375,6 +389,8 @@ def stroke_done(event): PolygonWidget : Closed straight-edged region with draggable vertices. """ + _GEOMETRY = {"radius": "r", "strokes": "strokes"} + def __init__(self, push_fn, *, radius=8.0, color="#00e5ff", colors=None, class_id=0, strokes=None, stroke_classes=None, alpha=0.6, active=True, erase=False): @@ -429,7 +445,8 @@ def add_stroke(self, points, class_id: int | None = None) -> None: Parameters ---------- points : list of tuple - ``[(x, y), ...]`` in image-pixel coordinates; at least one point. + ``[(x, y), ...]`` in the widget's :attr:`~Widget.coords` frame + (image pixels by default); at least one point. class_id : int, optional Label class for this stroke. Defaults to the widget's current ``class_id``. @@ -443,8 +460,10 @@ def add_stroke(self, points, class_id: int | None = None) -> None: cid = int(self._data["class_id"] if class_id is None else class_id) if cid < 0: raise ValueError(f"class_id must be >= 0, got {cid}") - self.set(strokes=self._data["strokes"] + [stroke], - stroke_classes=self._data["stroke_classes"] + [cid]) + # The stored strokes are already in pixels; convert only the new one. + (stroke,) = self._geom_in({"strokes": [stroke]})["strokes"] + self._set_pixels(strokes=self._data["strokes"] + [stroke], + stroke_classes=self._data["stroke_classes"] + [cid]) def set_strokes(self, strokes, classes=None) -> None: """Replace every stroke (and its class) in one push. @@ -492,8 +511,9 @@ def strokes_for_class(self, class_id: int) -> list: ``[[[x, y], ...], ...]`` — the matching strokes, in paint order. """ cid = int(class_id) - return [s for s, c in zip(self._data["strokes"], - self._data["stroke_classes"]) if c == cid] + picked = [s for s, c in zip(self._data["strokes"], + self._data["stroke_classes"]) if c == cid] + return self._geom_out({"strokes": picked})["strokes"] class ArrowWidget(Widget): @@ -518,6 +538,8 @@ class ArrowWidget(Widget): show_handles : bool, optional Draw the tail/head grab handles. Default ``True``. """ + _GEOMETRY = {"x": "x", "y": "y", "u": "u", "v": "v"} + def __init__(self, push_fn, *, x, y, u, v, color="#00e5ff", linewidth=2, show_handles=True): super().__init__("arrow", push_fn, diff --git a/upcoming_changes/+widget-data-coords.new_feature.rst b/upcoming_changes/+widget-data-coords.new_feature.rst new file mode 100644 index 000000000..93d170b0a --- /dev/null +++ b/upcoming_changes/+widget-data-coords.new_feature.rst @@ -0,0 +1,8 @@ +Added ``coords="data"`` to every :class:`~anyplotlib.Plot2D` ``add_*_widget`` +method (and :meth:`~anyplotlib.Plot2D.add_widget`): the widget then takes and +reports its geometry — attribute reads, :meth:`~anyplotlib.Widget.get`, +:meth:`~anyplotlib.Widget.to_dict`, :meth:`~anyplotlib.Widget.set` and so the +values seen in drag callbacks — in the panel's calibrated axis units instead +of image pixels. See :attr:`~anyplotlib.Widget.coords` and +:meth:`~anyplotlib.Widget.set_coords`; 1-D and bar widget methods accept +``coords="data"`` too. From 2d2a620a85c2e338d94fbe4a5c76bf7638b69ff4 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 21 Oct 2026 14:11:19 -0500 Subject: [PATCH 07/24] markers: opt-in data-coordinate geometry (coords="data") transform='data' markers on a 2-D panel are positioned in image pixels. A group created with coords="data" (via the Plot2D add_* methods or markers.add) keeps its offsets, segments, vertices, widths/heights, radius and U/V in calibrated units and converts them through Plot2D.transform when serialised. Because conversion happens at render time the markers stay on their data position across set_extent. size_units='px' lengths and axes/display transforms are left alone. Assisted-by: claude-code:claude-opus-5-5 --- anyplotlib/markers.py | 118 ++++++++++++- anyplotlib/plot2d/_plot2d.py | 38 ++-- anyplotlib/plot2d/_plotmesh.py | 3 +- .../test_markers/test_marker_data_coords.py | 164 ++++++++++++++++++ .../+marker-data-coords.new_feature.rst | 6 + 5 files changed, 312 insertions(+), 17 deletions(-) create mode 100644 anyplotlib/tests/test_markers/test_marker_data_coords.py create mode 100644 upcoming_changes/+marker-data-coords.new_feature.rst diff --git a/anyplotlib/markers.py b/anyplotlib/markers.py index 6e3e20c23..0b1fcf1db 100644 --- a/anyplotlib/markers.py +++ b/anyplotlib/markers.py @@ -39,6 +39,17 @@ A sequence shorter than the group cycles, as matplotlib's colour cycle does. This works for every marker type on both 1-D and 2-D panels. + +Calibrated coordinates on 2-D panels +------------------------------------ +On a :class:`~anyplotlib.Plot2D`, ``transform="data"`` positions are image +pixels. Pass ``coords="data"`` to give positions *and* lengths in the panel's +calibrated axis units instead; the group keeps those values and converts them +through :attr:`Plot2D.transform ` each time it is +serialised, so it stays put when the panel is re-calibrated:: + + plot = ax.imshow(img, axes=[x_nm, y_nm], units="nm") + plot.add_circles([[11.0, -2.0]], radius=0.5, coords="data") """ from __future__ import annotations @@ -154,6 +165,45 @@ def _offsets_1d(offsets) -> list: raise ValueError("offsets must be 1-D or shape (N,1)/(N,2)") +_VALID_COORDS = ("pixel", "data") + +#: ``{key: role}`` for marker geometry, used by ``coords="data"``. +#: Lengths (``dx``/``dy``/``r``) are skipped for ``size_units="px"`` groups, +#: whose sizes are screen pixels rather than data. +_MARKER_POINT_KEYS = {"offsets": 1, "clip_path": 1, "segments": 2, + "vertices_list": 2} +_MARKER_LENGTH_KEYS = {"widths": "dx", "heights": "dy", "radius": "r", + "U": "u", "V": "v"} + + +def _check_coords(coords) -> str: + if coords not in _VALID_COORDS: + raise ValueError(f"coords must be one of {_VALID_COORDS}, got {coords!r}") + return coords + + +def _points_to_pixels(value, depth: int, transform): + """Convert ``[x, y]`` pairs nested *depth* list levels deep.""" + if depth == 1: + arr = np.asarray(value, dtype=float) + if arr.size == 0: + return arr.reshape(0, 2).tolist() + single = arr.ndim == 1 + out = transform.data_to_pixel(arr.reshape(-1, 2)) + return out[0].tolist() if single else out.tolist() + return [_points_to_pixels(item, depth - 1, transform) for item in value] + + +def _length_to_pixels(role: str, value, transform): + if role == "u": + return (np.asarray(value, dtype=float) / transform.x_scale).tolist() + if role == "v": + return (np.asarray(value, dtype=float) / transform.y_scale).tolist() + arr = np.asarray(value, dtype=float) + fn = transform.y_length_to_pixels if role == "dy" else transform.x_length_to_pixels + return np.asarray(fn(arr)).tolist() + + # --------------------------------------------------------------------------- # MarkerGroup # --------------------------------------------------------------------------- @@ -186,10 +236,50 @@ def __init__(self, marker_type: str, name: str, kwargs: dict, push_fn, if "clip_display" in kwargs and not isinstance(kwargs["clip_display"], bool): raise ValueError("clip_display must be a bool") _check_text_style(kwargs) - self._data: dict = dict(kwargs) + kwargs = dict(kwargs) + self._coords: str = _check_coords(kwargs.pop("coords", "pixel")) + self._data: dict = kwargs self._push_fn = push_fn self._parent: "MarkerTypeDict | None" = parent + @property + def coords(self) -> str: + """Frame of this group's positions and lengths: ``"pixel"`` or ``"data"``. + + Only meaningful for ``transform="data"`` groups on a 2-D image panel, + where ``"pixel"`` (the default) means image pixels and ``"data"`` the + calibrated axis values. On 1-D panels both mean data coordinates. + Change it with ``group.set(coords=...)``; the stored values are then + reinterpreted, not converted. + """ + return self._coords + + def _transform(self): + parent = self._parent + fn = getattr(parent, "_transform_fn", None) if parent is not None else None + return fn() if fn is not None else None + + def _wire_data(self) -> dict: + """``_data`` with ``coords="data"`` geometry converted to image pixels.""" + d = self._data + if self._coords != "data" or d.get("transform", "data") != "data": + return d + t = self._transform() + if t is None: + return d + out = dict(d) + for key, depth in _MARKER_POINT_KEYS.items(): + if out.get(key) is not None: + out[key] = _points_to_pixels(out[key], depth, t) + px_sizes = d.get("size_units") == "px" + for key, role in _MARKER_LENGTH_KEYS.items(): + if out.get(key) is None: + continue + if px_sizes and role in ("dx", "dy", "r"): + continue + out[key] = _length_to_pixels(role, out[key], t) + return out + # ------------------------------------------------------------------ def set(self, **kwargs) -> None: """Update one or more properties and push the change to the plot. @@ -198,7 +288,8 @@ def set(self, **kwargs) -> None: ---------- **kwargs : dict Properties to update (e.g., offsets, radius, facecolors). - Matplotlib-style names are translated to wire format. + Matplotlib-style names are translated to wire format. Positions + and lengths are read in the group's :attr:`coords` frame. """ if "transform" in kwargs and kwargs["transform"] not in _VALID_TRANSFORMS: raise ValueError( @@ -208,6 +299,9 @@ def set(self, **kwargs) -> None: if "clip_display" in kwargs and not isinstance(kwargs["clip_display"], bool): raise ValueError("clip_display must be a bool") _check_text_style(kwargs) + if "coords" in kwargs: + kwargs = dict(kwargs) + self._coords = _check_coords(kwargs.pop("coords")) self._data.update(kwargs) self._push_fn() @@ -247,7 +341,7 @@ def to_wire(self, group_id: str) -> dict: Wire-format dict with type-specific structure, ready for JSON serialization and transmission to the JavaScript renderer. """ - d = self._data + d = self._wire_data() t = self._type # Build the wire dict based on marker type @@ -504,9 +598,12 @@ class MarkerTypeDict: Zero-arg callback to trigger re-render on mutations. """ - def __init__(self, marker_type: str, push_fn): + def __init__(self, marker_type: str, push_fn, transform_fn=None): self._type = marker_type self._push_fn = push_fn + # Zero-arg callable returning the panel's ImageTransform (or None); + # used by groups created with coords="data". + self._transform_fn = transform_fn self._groups: dict[str, MarkerGroup] = {} # ------------------------------------------------------------------ @@ -664,7 +761,8 @@ class MarkerRegistry: # pcolormesh panels only support points (circles) and line segments _KNOWN_MESH = frozenset({"circles", "lines"}) - def __init__(self, push_fn, allowed: frozenset | None = None): + def __init__(self, push_fn, allowed: frozenset | None = None, + transform_fn=None): """ Parameters ---------- @@ -673,9 +771,14 @@ def __init__(self, push_fn, allowed: frozenset | None = None): Figure trait. allowed : Set of allowed type names. ``None`` means all known types. + transform_fn : + Zero-arg callable returning the panel's + :class:`~anyplotlib.ImageTransform`, for groups created with + ``coords="data"``. ``None`` (1-D panels) leaves them unconverted. """ self._push_fn = push_fn self._allowed = allowed + self._transform_fn = transform_fn self._types: dict[str, MarkerTypeDict] = {} # ------------------------------------------------------------------ @@ -703,7 +806,8 @@ def __getitem__(self, marker_type: str) -> MarkerTypeDict: f"Allowed types: {sorted(self._allowed)}" ) if marker_type not in self._types: - self._types[marker_type] = MarkerTypeDict(marker_type, self._push_fn) + self._types[marker_type] = MarkerTypeDict( + marker_type, self._push_fn, self._transform_fn) return self._types[marker_type] def __contains__(self, marker_type: object) -> bool: @@ -750,6 +854,8 @@ def add(self, marker_type: str, name: str | None = None, **kwargs) -> MarkerGrou Group name. Auto-generated (``'circles_1'`` etc.) if ``None``. **kwargs : dict Matplotlib-style kwargs for the group (offsets, radius, colors, etc.). + ``coords="data"`` gives positions and lengths in calibrated axis + units on a 2-D image panel (see :attr:`MarkerGroup.coords`). Returns ------- diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index a785eaaaa..a805f2cb0 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -391,7 +391,8 @@ def __init__(self, data, } self.markers = MarkerRegistry(self._push_markers, - allowed=MarkerRegistry._KNOWN_2D) + allowed=MarkerRegistry._KNOWN_2D, + transform_fn=lambda: self.transform) self.callbacks = CallbackRegistry() # Tile mode: anyplotlib itself reacts to view_changed (zoom/pan) → sample a # hi-res detail tile of the visible region from the backend. The consumer @@ -2514,6 +2515,7 @@ def add_circles(self, offsets, name=None, *, radius=5, hover_edgecolors=None, hover_facecolors=None, labels=None, label=None, transform: str = "data", + coords: str = "pixel", clip_display: bool = True, size_units: str = "data") -> "MarkerGroup": # noqa: F821 """Add circle markers at (x, y) positions in data coordinates. @@ -2521,6 +2523,11 @@ def add_circles(self, offsets, name=None, *, radius=5, ``size_units="px"`` pins *radius* to screen pixels so the circles keep their size through a zoom; the default ``"data"`` scales them with the data, as a shape drawn on the image does. + + Positions (and data-sized lengths) are image pixels by default; pass + ``coords="data"`` to give them in the panel's calibrated axis units. + This applies to every ``add_*`` marker method on a 2-D panel — see + :attr:`MarkerGroup.coords `. """ return self._add_marker("circles", name, offsets=offsets, radius=radius, facecolors=facecolors, edgecolors=edgecolors, @@ -2528,7 +2535,7 @@ def add_circles(self, offsets, name=None, *, radius=5, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, labels=labels, label=label, - transform=transform, + transform=transform, coords=coords, clip_display=clip_display, size_units=size_units) @@ -2538,6 +2545,7 @@ def add_points(self, offsets, name=None, *, sizes=5, hover_edgecolors=None, hover_facecolors=None, labels=None, label=None, transform: str = "data", + coords: str = "pixel", clip_display: bool = True, size_units: str = "data") -> "MarkerGroup": # noqa: F821 """Add point markers at (x, y) positions in data coordinates. @@ -2552,7 +2560,7 @@ def add_points(self, offsets, name=None, *, sizes=5, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, labels=labels, label=label, - transform=transform, + transform=transform, coords=coords, clip_display=clip_display, size_units=size_units) @@ -2589,12 +2597,13 @@ def add_arrows(self, offsets, U, V, name=None, *, hover_edgecolors=None, labels=None, label=None, transform: str = "data", + coords: str = "pixel", clip_display: bool = True) -> "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, - transform=transform, + transform=transform, coords=coords, clip_display=clip_display) def add_ellipses(self, offsets, widths, heights, name=None, *, @@ -2603,6 +2612,7 @@ def add_ellipses(self, offsets, widths, heights, name=None, *, hover_edgecolors=None, hover_facecolors=None, labels=None, label=None, transform: str = "data", + coords: str = "pixel", clip_display: bool = True, size_units: str = "data") -> "MarkerGroup": # noqa: F821 return self._add_marker("ellipses", name, offsets=offsets, @@ -2612,7 +2622,7 @@ def add_ellipses(self, offsets, widths, heights, name=None, *, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, labels=labels, label=label, - transform=transform, + transform=transform, coords=coords, clip_display=clip_display, size_units=size_units) @@ -2621,12 +2631,13 @@ def add_lines(self, segments, name=None, *, hover_edgecolors=None, labels=None, label=None, transform: str = "data", + coords: str = "pixel", clip_display: bool = True) -> "MarkerGroup": # noqa: F821 return self._add_marker("lines", name, segments=segments, edgecolors=edgecolors, linewidths=linewidths, hover_edgecolors=hover_edgecolors, labels=labels, label=label, - transform=transform, + transform=transform, coords=coords, clip_display=clip_display) def add_rectangles(self, offsets, widths, heights, name=None, *, @@ -2635,6 +2646,7 @@ def add_rectangles(self, offsets, widths, heights, name=None, *, hover_edgecolors=None, hover_facecolors=None, labels=None, label=None, transform: str = "data", + coords: str = "pixel", clip_display: bool = True, size_units: str = "data") -> "MarkerGroup": # noqa: F821 return self._add_marker("rectangles", name, offsets=offsets, @@ -2644,7 +2656,7 @@ def add_rectangles(self, offsets, widths, heights, name=None, *, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, labels=labels, label=label, - transform=transform, + transform=transform, coords=coords, clip_display=clip_display, size_units=size_units) @@ -2654,6 +2666,7 @@ def add_squares(self, offsets, widths, name=None, *, hover_edgecolors=None, hover_facecolors=None, labels=None, label=None, transform: str = "data", + coords: str = "pixel", clip_display: bool = True, size_units: str = "data") -> "MarkerGroup": # noqa: F821 return self._add_marker("squares", name, offsets=offsets, @@ -2663,7 +2676,7 @@ def add_squares(self, offsets, widths, name=None, *, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, labels=labels, label=label, - transform=transform, + transform=transform, coords=coords, clip_display=clip_display, size_units=size_units) @@ -2673,6 +2686,7 @@ def add_polygons(self, vertices_list, name=None, *, hover_edgecolors=None, hover_facecolors=None, labels=None, label=None, transform: str = "data", + coords: str = "pixel", clip_display: bool = True) -> "MarkerGroup": # noqa: F821 return self._add_marker("polygons", name, vertices_list=vertices_list, facecolors=facecolors, edgecolors=edgecolors, @@ -2680,7 +2694,7 @@ def add_polygons(self, vertices_list, name=None, *, hover_edgecolors=hover_edgecolors, hover_facecolors=hover_facecolors, labels=labels, label=label, - transform=transform, + transform=transform, coords=coords, clip_display=clip_display) def add_texts(self, offsets, texts, name=None, *, @@ -2689,6 +2703,7 @@ def add_texts(self, offsets, texts, name=None, *, hover_edgecolors=None, labels=None, label=None, transform: str = "data", + coords: str = "pixel", clip_display: bool = True) -> "MarkerGroup": # noqa: F821 """Add text annotations at image-pixel positions. @@ -2726,6 +2741,9 @@ def add_texts(self, offsets, texts, name=None, *, Coordinate system for ``offsets``. Default ``"data"``. clip_display : bool, optional Clip ``"display"``-transform texts to the image. Default ``True``. + coords : ``"pixel"`` | ``"data"``, optional + Frame for ``transform="data"`` offsets: image pixels (default) or + the panel's calibrated axis units. Returns ------- @@ -2745,7 +2763,7 @@ def add_texts(self, offsets, texts, name=None, *, outline_width=outline_width, hover_edgecolors=hover_edgecolors, labels=labels, label=label, - transform=transform, + transform=transform, coords=coords, clip_display=clip_display) def __repr__(self) -> str: diff --git a/anyplotlib/plot2d/_plotmesh.py b/anyplotlib/plot2d/_plotmesh.py index 0bb910569..f146cf9df 100644 --- a/anyplotlib/plot2d/_plotmesh.py +++ b/anyplotlib/plot2d/_plotmesh.py @@ -65,7 +65,8 @@ def __init__(self, data: np.ndarray, # Restrict markers to circles + lines only self.markers = MarkerRegistry(self._push_markers, - allowed=MarkerRegistry._KNOWN_MESH) + allowed=MarkerRegistry._KNOWN_MESH, + transform_fn=lambda: self.transform) # ------------------------------------------------------------------ # Data diff --git a/anyplotlib/tests/test_markers/test_marker_data_coords.py b/anyplotlib/tests/test_markers/test_marker_data_coords.py new file mode 100644 index 000000000..1b3f4eca8 --- /dev/null +++ b/anyplotlib/tests/test_markers/test_marker_data_coords.py @@ -0,0 +1,164 @@ +""" +Markers in calibrated data coordinates (``coords="data"``). + +On a 2-D panel ``transform="data"`` positions are image pixels. A group +created with ``coords="data"`` keeps calibrated values and converts them to +pixels on serialisation — so it also follows a re-calibration. + +12 rows × 25 columns throughout, so a swapped axis cannot pass. +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl + +H, W = 12, 25 +X = 10.0 + 0.5 * np.arange(W) # col = (x - 10) / 0.5 +Y = -3.0 + 0.25 * np.arange(H) # row = (y + 3) / 0.25 + + +def _plot(**kwargs): + fig, ax = apl.subplots(1, 1) + return ax.imshow(np.zeros((H, W)), axes=[X, Y], **kwargs) + + +def _wire(group): + return group.to_wire("g") + + +class TestPositions: + def test_circles(self): + g = _plot().add_circles([[11.0, -2.0], [12.0, -1.0]], radius=1.0, + coords="data") + w = _wire(g) + np.testing.assert_allclose(w["offsets"], [[2.0, 4.0], [4.0, 8.0]]) + assert w["sizes"] == pytest.approx([2.0, 2.0]) # radius follows x + # The group keeps what the caller gave it. + assert g._data["offsets"] == [[11.0, -2.0], [12.0, -1.0]] + assert g.coords == "data" + + def test_single_pair_offsets(self): + g = _plot().add_circles([11.0, -2.0], coords="data") + np.testing.assert_allclose(_wire(g)["offsets"], [[2.0, 4.0]]) + + def test_lines_segments(self): + g = _plot().add_lines([[[10.0, -3.0], [12.0, -2.0]]], coords="data") + np.testing.assert_allclose(_wire(g)["segments"], [[[0.0, 0.0], [4.0, 4.0]]]) + + def test_lines_ndarray_segments(self): + segs = np.array([[[10.0, -3.0], [12.0, -2.0]], + [[11.0, -1.0], [13.0, 0.0]]]) + g = _plot().add_lines(segs, coords="data") + np.testing.assert_allclose(_wire(g)["segments"][1], [[2.0, 8.0], [6.0, 12.0]]) + + def test_polygons(self): + g = _plot().add_polygons([[(10.0, -3.0), (12.0, -3.0), (12.0, -2.0)]], + coords="data") + np.testing.assert_allclose(_wire(g)["vertices_list"], + [[[0.0, 0.0], [4.0, 0.0], [4.0, 4.0]]]) + + def test_texts(self): + g = _plot().add_texts([[11.0, -2.0]], ["a"], coords="data") + np.testing.assert_allclose(_wire(g)["offsets"], [[2.0, 4.0]]) + + def test_add_text_single(self): + plot = _plot() + plot.add_text(11.0, -2.0, "a", coords="data") + wire = plot.to_state_dict()["markers"][0] + np.testing.assert_allclose(wire["offsets"], [[2.0, 4.0]]) + + def test_registry_add(self): + g = _plot().markers.add("circles", offsets=[[11.0, -2.0]], radius=0.5, + coords="data") + np.testing.assert_allclose(_wire(g)["offsets"], [[2.0, 4.0]]) + assert _wire(g)["sizes"] == pytest.approx([1.0]) + + +class TestLengths: + def test_rectangles(self): + g = _plot().add_rectangles([[11.0, -2.0]], widths=1.0, heights=[0.5], + coords="data") + w = _wire(g) + assert w["widths"] == pytest.approx([2.0]) + assert w["heights"] == pytest.approx([2.0]) + + def test_ellipses_and_squares(self): + plot = _plot() + e = plot.add_ellipses([[11.0, -2.0]], widths=[2.0], heights=[1.0], + coords="data") + assert _wire(e)["widths"] == pytest.approx([4.0]) + assert _wire(e)["heights"] == pytest.approx([4.0]) + sq = plot.add_squares([[11.0, -2.0]], widths=1.0, coords="data") + assert _wire(sq)["widths"] == pytest.approx([2.0]) + + def test_arrows_signed(self): + g = _plot(origin="lower").add_arrows([[11.0, -2.0]], U=[1.0], V=[0.5], + coords="data") + w = _wire(g) + assert w["U"] == pytest.approx([2.0]) + assert w["V"] == pytest.approx([-2.0]) # +y is up the screen + + def test_px_sizes_untouched(self): + g = _plot().add_circles([[11.0, -2.0]], radius=6, size_units="px", + coords="data") + w = _wire(g) + np.testing.assert_allclose(w["offsets"], [[2.0, 4.0]]) + assert w["sizes"] == pytest.approx([6.0]) + + +class TestBehaviour: + def test_default_is_pixel(self): + g = _plot().add_circles([[2.0, 4.0]]) + assert g.coords == "pixel" + np.testing.assert_allclose(_wire(g)["offsets"], [[2.0, 4.0]]) + + def test_set_in_data(self): + g = _plot().add_circles([[11.0, -2.0]], coords="data") + g.set(offsets=[[12.0, -1.0]]) + np.testing.assert_allclose(_wire(g)["offsets"], [[4.0, 8.0]]) + + def test_set_coords(self): + g = _plot().add_circles([[2.0, 4.0]]) + g.set(coords="data", offsets=[[11.0, -2.0]]) + assert g.coords == "data" + np.testing.assert_allclose(_wire(g)["offsets"], [[2.0, 4.0]]) + assert "coords" not in g._data + + def test_invalid_coords(self): + with pytest.raises(ValueError, match="coords"): + _plot().add_circles([[1.0, 2.0]], coords="screen") + g = _plot().add_circles([[1.0, 2.0]]) + with pytest.raises(ValueError, match="coords"): + g.set(coords="screen") + + def test_follows_recalibration(self): + plot = _plot() + plot.add_circles([[12.0, -1.0]], coords="data") + plot.set_extent(2 * X, 2 * Y) + wire = plot.to_state_dict()["markers"][0] + # 12 is now column 1 (x = 20 + col), -1 is now between rows. + np.testing.assert_allclose(wire["offsets"], [[-8.0, 10.0]]) + + def test_axes_transform_not_converted(self): + g = _plot().add_circles([[0.5, 0.5]], transform="axes", coords="data") + np.testing.assert_allclose(_wire(g)["offsets"], [[0.5, 0.5]]) + + def test_empty_offsets(self): + g = _plot().add_lines(np.zeros((0, 2, 2)), coords="data") + assert _wire(g)["segments"] == [] + + def test_mesh(self): + fig, ax = apl.subplots(1, 1) + xe = 5.0 + 2.0 * np.arange(W + 1) + ye = np.linspace(0.0, 1.2, H + 1) + plot = ax.pcolormesh(np.zeros((H, W)), x_edges=xe, y_edges=ye) + g = plot.add_circles([[6.0, 0.05]], coords="data") + np.testing.assert_allclose(_wire(g)["offsets"], [[0.0, 0.0]], atol=1e-12) + + def test_1d_accepts_and_ignores(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.arange(48.0)) + g = plot.markers.add("vlines", offsets=[3.0], coords="data") + assert _wire(g)["offsets"] == [[3.0]] diff --git a/upcoming_changes/+marker-data-coords.new_feature.rst b/upcoming_changes/+marker-data-coords.new_feature.rst new file mode 100644 index 000000000..18cebf980 --- /dev/null +++ b/upcoming_changes/+marker-data-coords.new_feature.rst @@ -0,0 +1,6 @@ +Added ``coords="data"`` to the :class:`~anyplotlib.Plot2D` marker methods and +:meth:`MarkerRegistry.add `: positions +and data-sized lengths are then given in the panel's calibrated axis units +and converted to image pixels on every render, so the markers follow +:meth:`~anyplotlib.Plot2D.set_extent`. See +:attr:`MarkerGroup.coords `. From ec4fff766a42dd3a478894da4a64db0790421dfc Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 21 Oct 2026 14:12:12 -0500 Subject: [PATCH 08/24] axes: add public figure / primary_plot / plots / image_plot Axes now exposes its owning figure and attached plot through read-only properties, so callers stop tagging axes with their own attributes or reading _plot. image_plot returns the primary plot only when it is a 2-D image (Plot2D/PlotMesh). Overlays (extra lines, markers, widgets) live on the plot and can never shadow it. Assigning the same figure to ax.figure is tolerated for matplotlib-style code. Assisted-by: claude-code:claude-opus-5-5 --- anyplotlib/axes/_axes.py | 57 +++++++++++ .../tests/test_layouts/test_axes_identity.py | 96 +++++++++++++++++++ .../+axes-identity.new_feature.rst | 4 + 3 files changed, 157 insertions(+) create mode 100644 anyplotlib/tests/test_layouts/test_axes_identity.py create mode 100644 upcoming_changes/+axes-identity.new_feature.rst diff --git a/anyplotlib/axes/_axes.py b/anyplotlib/axes/_axes.py index 45d450c49..26393fe0f 100644 --- a/anyplotlib/axes/_axes.py +++ b/anyplotlib/axes/_axes.py @@ -32,6 +32,63 @@ def __init__(self, fig: "Figure", spec: "SubplotSpec"): # noqa: F821 self._spec = spec self._plot: "Plot1D | Plot2D | None" = None + # ------------------------------------------------------------------ + # Identity + # ------------------------------------------------------------------ + @property + def figure(self) -> "Figure": + """The :class:`~anyplotlib.Figure` this axes belongs to (read-only). + + Assigning the same figure again is allowed (a no-op) so code written + for matplotlib's settable ``ax.figure`` keeps working; assigning a + different one raises ``AttributeError``. + """ + return self._fig + + @figure.setter + def figure(self, fig) -> None: + if fig is not self._fig: + raise AttributeError( + "Axes.figure is read-only; an Axes cannot move to another figure") + + @property + def primary_plot(self): + """The plot attached by the last factory call, or ``None`` if empty. + + This is the object :meth:`imshow`, :meth:`plot`, :meth:`pcolormesh`, + … returned (``Plot2D``, ``Plot1D``, ``PlotMesh``, ``Plot3D``, + ``PlotBar``, ``PlotXY``). Overlays drawn *on* it — extra lines from + :meth:`Plot1D.add_line `, markers, + widgets — belong to that plot and never replace it here. Calling a + factory again replaces the plot. + """ + return self._plot + + @property + def plots(self) -> tuple: + """All plot panels attached to this axes, primary first. + + An axes cell holds at most one plot panel today, so this is + ``(primary_plot,)`` or ``()``; iterate it rather than indexing ``[0]`` + so code keeps working if overlay panels are added later. + """ + return () if self._plot is None else (self._plot,) + + @property + def image_plot(self): + """The primary plot if it is a 2-D image (``Plot2D`` / ``PlotMesh``), + else ``None``. + + Handy for code that places image-space widgets or markers and must + not mistake a line or bar panel for an image. + """ + return self._plot if isinstance(self._plot, Plot2D) else None + + @property + def has_plot(self) -> bool: + """``True`` once a plot has been attached.""" + return self._plot is not None + # ------------------------------------------------------------------ def imshow(self, data, axes: list | None = None, diff --git a/anyplotlib/tests/test_layouts/test_axes_identity.py b/anyplotlib/tests/test_layouts/test_axes_identity.py new file mode 100644 index 000000000..caa690a01 --- /dev/null +++ b/anyplotlib/tests/test_layouts/test_axes_identity.py @@ -0,0 +1,96 @@ +""" +Public Axes identity: ``figure``, ``primary_plot``, ``plots``, ``image_plot``. +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl + + +def test_figure(): + fig, ax = apl.subplots(1, 1) + assert ax.figure is fig + + +def test_figure_on_grid_and_inset(): + fig, axes = apl.subplots(1, 2) + assert all(a.figure is fig for a in axes) + inset = fig.add_inset(0.3, 0.3) + assert inset.figure is fig + + +def test_figure_same_assignment_is_noop(): + fig, ax = apl.subplots(1, 1) + ax.figure = fig # matplotlib-style code does this + assert ax.figure is fig + + +def test_figure_reassign_raises(): + fig, ax = apl.subplots(1, 1) + other, _ = apl.subplots(1, 1) + with pytest.raises(AttributeError): + ax.figure = other + + +def test_empty_axes(): + fig, ax = apl.subplots(1, 1) + assert ax.primary_plot is None + assert ax.plots == () + assert ax.image_plot is None + assert ax.has_plot is False + + +def test_image(): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((12, 25))) + assert ax.primary_plot is plot + assert ax.plots == (plot,) + assert ax.image_plot is plot + assert ax.has_plot + + +def test_mesh_is_image(): + fig, ax = apl.subplots(1, 1) + plot = ax.pcolormesh(np.zeros((12, 25))) + assert ax.image_plot is plot + + +def test_line_is_not_image(): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.arange(48.0)) + assert ax.primary_plot is plot + assert ax.image_plot is None + + +def test_overlays_do_not_shadow_primary(): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.arange(48.0)) + plot.add_line(np.arange(48.0) * 2) + plot.add_vline_widget(3.0) + plot.add_vlines([1.0, 2.0]) + assert ax.primary_plot is plot + assert ax.plots == (plot,) + + fig, ax = apl.subplots(1, 1) + img = ax.imshow(np.zeros((12, 25))) + img.add_widget("crosshair") + img.add_lines([[[0, 0], [3, 3]]]) + assert ax.image_plot is img and ax.primary_plot is img + + +def test_replacing_plot(): + fig, ax = apl.subplots(1, 1) + ax.imshow(np.zeros((12, 25))) + line = ax.plot(np.arange(48.0)) + assert ax.primary_plot is line + assert ax.image_plot is None + + +def test_read_only(): + fig, ax = apl.subplots(1, 1) + with pytest.raises(AttributeError): + ax.primary_plot = None + with pytest.raises(AttributeError): + ax.plots = () diff --git a/upcoming_changes/+axes-identity.new_feature.rst b/upcoming_changes/+axes-identity.new_feature.rst new file mode 100644 index 000000000..872b878c6 --- /dev/null +++ b/upcoming_changes/+axes-identity.new_feature.rst @@ -0,0 +1,4 @@ +Added read-only :attr:`Axes.figure `, +:attr:`~anyplotlib.Axes.primary_plot`, :attr:`~anyplotlib.Axes.plots`, +:attr:`~anyplotlib.Axes.image_plot` and :attr:`~anyplotlib.Axes.has_plot` so +code holding an axes can reach its figure and plot without private attributes. From 1d381a4354a515abeeef24b7dfcae4e72b6dc06b Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 21 Oct 2026 14:14:12 -0500 Subject: [PATCH 09/24] axes: label setters that work before a plot is attached Axes.set_xlabel / set_ylabel / set_title delegate to the attached plot, and before one exists they store the label and apply it (once) when imshow / plot / ... attaches the first plot, on grid axes and insets alike. Callers no longer need to buffer labels themselves. Assisted-by: claude-code:claude-opus-5-5 --- anyplotlib/axes/_axes.py | 55 +++++++++++++++++++ anyplotlib/axes/_inset_axes.py | 1 + .../+axes-deferred-labels.new_feature.rst | 4 ++ 3 files changed, 60 insertions(+) create mode 100644 upcoming_changes/+axes-deferred-labels.new_feature.rst diff --git a/anyplotlib/axes/_axes.py b/anyplotlib/axes/_axes.py index 26393fe0f..d983ec7c6 100644 --- a/anyplotlib/axes/_axes.py +++ b/anyplotlib/axes/_axes.py @@ -31,6 +31,60 @@ def __init__(self, fig: "Figure", spec: "SubplotSpec"): # noqa: F821 self._fig = fig self._spec = spec self._plot: "Plot1D | Plot2D | None" = None + # Labels set before a plot exists: {setter name: (label, fontsize)}. + self._pending_labels: dict = {} + + # ------------------------------------------------------------------ + # Labels (work before a plot is attached) + # ------------------------------------------------------------------ + def set_xlabel(self, label: str, fontsize: float | None = None) -> None: + """Set the x-axis label of this axes' plot. + + Works before a plot is attached: the label is stored and applied when + :meth:`imshow`, :meth:`plot`, … attaches one, so layout code can label + an axes before deciding what to draw in it. Once attached it is the + same as ``ax.primary_plot.set_xlabel(...)``. + + Parameters + ---------- + label : str + Label text (mini-TeX allowed, see :meth:`Plot2D.set_xlabel + `). + fontsize : float, optional + Font size in CSS pixels. + + Raises + ------ + AttributeError + If the attached plot type has no x label. + """ + self._set_label("set_xlabel", label, fontsize) + + def set_ylabel(self, label: str, fontsize: float | None = None) -> None: + """Set the y-axis label. Same deferral rules as :meth:`set_xlabel`.""" + self._set_label("set_ylabel", label, fontsize) + + def set_title(self, label: str, fontsize: float | None = None) -> None: + """Set the panel title. Same deferral rules as :meth:`set_xlabel`.""" + self._set_label("set_title", label, fontsize) + + def _set_label(self, method: str, label: str, fontsize) -> None: + if self._plot is None: + self._pending_labels[method] = (label, fontsize) + return + fn = getattr(self._plot, method, None) + if fn is None: + raise AttributeError( + f"{type(self._plot).__name__} does not support {method}()") + fn(label, fontsize=fontsize) + + def _apply_pending_labels(self, plot) -> None: + """Apply labels stored before *plot* was attached (once).""" + pending, self._pending_labels = self._pending_labels, {} + for method, (label, fontsize) in pending.items(): + fn = getattr(plot, method, None) + if fn is not None: # e.g. a 3-D plot has no title setter + fn(label, fontsize=fontsize) # ------------------------------------------------------------------ # Identity @@ -595,6 +649,7 @@ def _attach(self, plot: "Plot1D | Plot2D | PlotMesh | Plot3D | PlotBar") -> None plot._fig = self._fig self._plot = plot self._fig._register_panel(self, plot) + self._apply_pending_labels(plot) def __repr__(self) -> str: kind = type(self._plot).__name__ if self._plot else "empty" diff --git a/anyplotlib/axes/_inset_axes.py b/anyplotlib/axes/_inset_axes.py index d25f2ee04..3f762e573 100644 --- a/anyplotlib/axes/_inset_axes.py +++ b/anyplotlib/axes/_inset_axes.py @@ -400,6 +400,7 @@ def _attach(self, plot) -> None: plot._fig = self._fig self._plot = plot self._fig._register_inset(self, plot) + self._apply_pending_labels(plot) def __repr__(self) -> str: kind = _plot_kind(self._plot) if self._plot else "empty" diff --git a/upcoming_changes/+axes-deferred-labels.new_feature.rst b/upcoming_changes/+axes-deferred-labels.new_feature.rst new file mode 100644 index 000000000..2bf0de06a --- /dev/null +++ b/upcoming_changes/+axes-deferred-labels.new_feature.rst @@ -0,0 +1,4 @@ +Added :meth:`Axes.set_xlabel `, +:meth:`~anyplotlib.Axes.set_ylabel` and :meth:`~anyplotlib.Axes.set_title`, +which work before a plot is attached: the labels are stored and applied when +the first plot is attached to the axes. From a618892ce736eb9fb9dabc8049257ba28a4ddd24 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 21 Oct 2026 14:14:12 -0500 Subject: [PATCH 10/24] figure: public close hook and display/closed lifecycle flags Figure.on_close(callback) registers a figure-level 'close' handler (a connection id is returned for remove_handler), and close() now fires 'close' on the figure registry before the panels', each with its own Event, and still hides the widget if a handler raises. Figure.closed and Figure.displayed expose the lifecycle state, and display() shows the figure at most once (force=True to repeat); being shown through the rich repr also counts as displayed. The lifecycle tests also cover the deferred Axes label setters. Assisted-by: claude-code:claude-opus-5-5 --- anyplotlib/figure/_figure.py | 107 ++++++++- .../tests/test_layouts/test_lifecycle.py | 210 ++++++++++++++++++ .../+figure-lifecycle.new_feature.rst | 5 + 3 files changed, 311 insertions(+), 11 deletions(-) create mode 100644 anyplotlib/tests/test_layouts/test_lifecycle.py create mode 100644 upcoming_changes/+figure-lifecycle.new_feature.rst diff --git a/anyplotlib/figure/_figure.py b/anyplotlib/figure/_figure.py index d837bd7cd..0fe392f0c 100644 --- a/anyplotlib/figure/_figure.py +++ b/anyplotlib/figure/_figure.py @@ -168,6 +168,9 @@ def __init__(self, nrows=1, ncols=1, figsize=(640, 480), self.callbacks: CallbackRegistry = CallbackRegistry() # Authoritative Python-side copy of the figure-level annotation list. self._figure_markers: list = [] + # Lifecycle flags (see the ``closed`` / ``displayed`` properties). + self._closed: bool = False + self._displayed: bool = False with self.hold_trait_notifications(): self.fig_width = figsize[0] self.fig_height = figsize[1] @@ -947,6 +950,7 @@ def _repr_html_(self) -> str: str HTML string containing an embedded iframe with srcdoc attribute. """ + self._displayed = True return repr_html_iframe(self) def to_html(self, *, resizable: bool = True) -> str: @@ -1017,24 +1021,105 @@ def savefig(self, path, *, source: str = "view", theme: str = "current", include_widgets=include_widgets, panel=panel, timeout_ms=timeout_ms) + # ── lifecycle ───────────────────────────────────────────────────────────── + @property + def closed(self) -> bool: + """``True`` once :meth:`close` has run.""" + return bool(getattr(self, "_closed", False)) + + @property + def displayed(self) -> bool: + """``True`` once the figure has been handed to a display front-end. + + Set by :meth:`display`, and also when the figure is shown any other + way that goes through its rich repr — the last expression of a + notebook cell, ``IPython.display.display(fig)``, or the HTML repr + Sphinx Gallery uses. + """ + return bool(getattr(self, "_displayed", False)) + + def _repr_mimebundle_(self, **kwargs): + self._displayed = True + return super()._repr_mimebundle_(**kwargs) + + def display(self, *, force: bool = False) -> bool: + """Show the figure in the current IPython front-end, at most once. + + Safe to call from several code paths that each want the figure on + screen: only the first call (or a figure not yet shown by any other + means, see :attr:`displayed`) actually displays it, so the output is + never duplicated. + + Parameters + ---------- + force : bool, optional + Display again even if already displayed (e.g. in a new cell). + + Returns + ------- + bool + ``True`` if the figure was displayed by this call, ``False`` if it + was skipped because it was already displayed or is closed. + """ + if self.closed or (self.displayed and not force): + return False + from IPython.display import display as _ipy_display + _ipy_display(self) + self._displayed = True + return True + + def on_close(self, callback) -> int: + """Register *callback* to run when the figure is closed. + + Shorthand for ``fig.add_event_handler(callback, "close")``; the + callback receives the ``close`` :class:`~anyplotlib.Event`. Figure + level close handlers run before the per-panel ones. + + Parameters + ---------- + callback : callable + ``callback(event)``; a bound method is fine. + + Returns + ------- + int + Connection id; pass it to :meth:`remove_handler` to unregister. + + Examples + -------- + >>> import anyplotlib as apl + >>> fig, ax = apl.subplots() + >>> cid = fig.on_close(lambda event: print("bye")) + >>> fig.close() + bye + """ + return self.callbacks.connect("close", callback) + 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. + Fires a ``"close"`` event on the figure's own :attr:`callbacks` (see + :meth:`on_close`), then on every panel's, then hides the widget by + setting its CSS ``display`` to ``"none"``. :attr:`closed` becomes + ``True``. Subsequent calls are no-ops. + + The figure is marked closed and hidden even if a handler raises; the + exception then propagates. """ - if getattr(self, "_closed", False): + if self.closed: 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) + close_event = Event(event_type="close", source=self) try: - self.layout.display = "none" - except Exception: - pass + self.callbacks.fire(close_event) + for plot in self._plots_map.values(): + if hasattr(plot, "callbacks"): + plot.callbacks.fire(Event(event_type="close", source=self)) + finally: + try: + self.layout.display = "none" + except Exception: + pass def __repr__(self) -> str: return (f"Figure({self._nrows}x{self._ncols}, " diff --git a/anyplotlib/tests/test_layouts/test_lifecycle.py b/anyplotlib/tests/test_layouts/test_lifecycle.py new file mode 100644 index 000000000..4535b5a78 --- /dev/null +++ b/anyplotlib/tests/test_layouts/test_lifecycle.py @@ -0,0 +1,210 @@ +""" +Figure lifecycle (close hook, ``closed`` / ``displayed`` flags, idempotent +``display()``) and Axes label setters that work before a plot is attached. +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl + + +class _Owner: + def __init__(self): + self.closed_with = [] + + def on_close(self, event): + self.closed_with.append(event) + + +class TestClose: + def test_closed_flag(self): + fig, ax = apl.subplots(1, 1) + assert fig.closed is False + fig.close() + assert fig.closed is True + + def test_on_close_receives_event(self): + fig, ax = apl.subplots(1, 1) + seen = [] + fig.on_close(lambda e: seen.append(e)) + fig.close() + assert len(seen) == 1 + assert seen[0].event_type == "close" and seen[0].source is fig + + def test_on_close_bound_method(self): + fig, ax = apl.subplots(1, 1) + owner = _Owner() + fig.on_close(owner.on_close) + fig.close() + assert len(owner.closed_with) == 1 + + def test_on_close_runs_once(self): + fig, ax = apl.subplots(1, 1) + seen = [] + fig.on_close(lambda e: seen.append(1)) + fig.close() + fig.close() + assert seen == [1] + + def test_multiple_callbacks_in_order(self): + fig, ax = apl.subplots(1, 1) + seen = [] + fig.on_close(lambda e: seen.append("a")) + fig.on_close(lambda e: seen.append("b")) + fig.close() + assert seen == ["a", "b"] + + def test_remove_by_cid(self): + fig, ax = apl.subplots(1, 1) + seen = [] + cid = fig.on_close(lambda e: seen.append(1)) + fig.remove_handler(cid) + fig.close() + assert seen == [] + + def test_add_event_handler_close(self): + fig, ax = apl.subplots(1, 1) + seen = [] + fig.add_event_handler(lambda e: seen.append(e.event_type), "close") + fig.close() + assert seen == ["close"] + + def test_figure_before_panels(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((12, 25))) + order = [] + plot.add_event_handler(lambda e: order.append("panel"), "close") + fig.on_close(lambda e: order.append("figure")) + fig.close() + assert order == ["figure", "panel"] + + def test_raising_handler_still_closes(self): + fig, ax = apl.subplots(1, 1) + + def boom(event): + raise RuntimeError("boom") + + fig.on_close(boom) + with pytest.raises(RuntimeError): + fig.close() + assert fig.closed is True + assert fig.layout.display == "none" + + +class TestDisplay: + def test_initially_not_displayed(self): + fig, ax = apl.subplots(1, 1) + assert fig.displayed is False + + def test_display_is_idempotent(self, monkeypatch): + import IPython.display + + shown = [] + monkeypatch.setattr(IPython.display, "display", lambda obj: shown.append(obj)) + fig, ax = apl.subplots(1, 1) + assert fig.display() is True + assert fig.display() is False + assert shown == [fig] + assert fig.displayed is True + + def test_force(self, monkeypatch): + import IPython.display + + shown = [] + monkeypatch.setattr(IPython.display, "display", lambda obj: shown.append(obj)) + fig, ax = apl.subplots(1, 1) + fig.display() + assert fig.display(force=True) is True + assert len(shown) == 2 + + def test_closed_figure_not_displayed(self, monkeypatch): + import IPython.display + + shown = [] + monkeypatch.setattr(IPython.display, "display", lambda obj: shown.append(obj)) + fig, ax = apl.subplots(1, 1) + fig.close() + assert fig.display() is False + assert shown == [] + + def test_rich_repr_marks_displayed(self): + fig, ax = apl.subplots(1, 1) + fig._repr_mimebundle_() + assert fig.displayed is True + + def test_html_repr_marks_displayed(self): + fig, ax = apl.subplots(1, 1) + ax.imshow(np.zeros((12, 25))) + fig._repr_html_() + assert fig.displayed is True + + def test_real_ipython_display(self): + """Through the real IPython machinery (no kernel: prints the repr).""" + fig, ax = apl.subplots(1, 1) + assert fig.display() is True + assert fig.displayed + + +class TestDeferredLabels: + def test_labels_before_imshow(self): + fig, ax = apl.subplots(1, 1) + ax.set_xlabel("x (nm)") + ax.set_ylabel("y (nm)", fontsize=13) + ax.set_title("Title") + plot = ax.imshow(np.zeros((12, 25))) + assert plot._state["x_label"] == "x (nm)" + assert plot._state["y_label"] == "y (nm)" + assert plot._state["y_label_size"] == 13 + assert plot._state["title"] == "Title" + + def test_labels_before_plot(self): + fig, ax = apl.subplots(1, 1) + ax.set_title("Spectrum") + plot = ax.plot(np.arange(48.0)) + assert plot._state["title"] == "Spectrum" + + def test_last_pending_wins(self): + fig, ax = apl.subplots(1, 1) + ax.set_xlabel("first") + ax.set_xlabel("second") + plot = ax.imshow(np.zeros((12, 25))) + assert plot._state["x_label"] == "second" + + def test_pending_applied_once(self): + fig, ax = apl.subplots(1, 1) + ax.set_title("once") + ax.imshow(np.zeros((12, 25))) + replacement = ax.imshow(np.zeros((12, 25))) + assert replacement._state["title"] == "" + + def test_after_attach_delegates(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((12, 25))) + ax.set_xlabel("x", fontsize=9) + assert plot._state["x_label"] == "x" + assert plot._state["x_label_size"] == 9 + + def test_inset(self): + fig, ax = apl.subplots(1, 1) + inset = fig.add_inset(0.3, 0.3) + inset.set_title("zoom") + plot = inset.imshow(np.zeros((12, 25))) + assert plot._state["title"] == "zoom" + + def test_unsupported_after_attach_raises(self): + from types import SimpleNamespace + + fig, ax = apl.subplots(1, 1) + ax._plot = SimpleNamespace() # a plot type with no label setters + with pytest.raises(AttributeError, match="set_title"): + ax.set_title("t") + + def test_unsupported_pending_is_dropped(self): + """A pending label the new plot cannot show is skipped, not an error.""" + fig, ax = apl.subplots(1, 1) + ax.set_ylabel("z") + X, Y = np.meshgrid(np.arange(5.0), np.arange(4.0)) + ax.plot_surface(X, Y, np.zeros((4, 5))) + assert ax._pending_labels == {} diff --git a/upcoming_changes/+figure-lifecycle.new_feature.rst b/upcoming_changes/+figure-lifecycle.new_feature.rst new file mode 100644 index 000000000..946bf89ac --- /dev/null +++ b/upcoming_changes/+figure-lifecycle.new_feature.rst @@ -0,0 +1,5 @@ +Added :meth:`Figure.on_close `, the +:attr:`~anyplotlib.Figure.closed` and :attr:`~anyplotlib.Figure.displayed` +flags and an idempotent :meth:`Figure.display `; +:meth:`~anyplotlib.Figure.close` now also fires ``close`` on the figure's own +callbacks (before the panels'), and still hides the figure if a handler raises. From 89ca7bc1b3689df7bfc6bee0589519c9e5f0bfa6 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 21 Oct 2026 14:14:12 -0500 Subject: [PATCH 11/24] docs: document close/lifecycle, bound-method handlers and notify=False Assisted-by: claude-code:claude-opus-5-5 --- docs/events.rst | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/docs/events.rst b/docs/events.rst index 148441b52..c9e9be2e0 100644 --- a/docs/events.rst +++ b/docs/events.rst @@ -82,6 +82,10 @@ Event types - Key pressed while panel has focus. * - ``key_up`` - Key released. + * - ``close`` + - :meth:`Figure.close ` ran. Fires on the + figure's own registry first (:meth:`Figure.on_close + `), then on every panel's. * - ``*`` - Wildcard — handler receives every dispatched event type. @@ -358,6 +362,49 @@ mechanism is required. print(f"picked overlay line at xdata={event.xdata:.3f}") +Handlers that are bound methods +------------------------------- + +Any callable is a valid handler, including a bound method; removing it by +the same expression finds the original registration even though Python +builds a new method object on each attribute access: + +.. code-block:: python + + class Tracker: + def on_move(self, event): ... + + tracker = Tracker() + plot.add_event_handler(tracker.on_move, "pointer_move") + plot.remove_handler(tracker.on_move) + + +Figure lifecycle +---------------- + +.. code-block:: python + + fig, ax = apl.subplots(1, 1) + cid = fig.on_close(lambda event: release_resources()) + fig.display() # shows it; a second call is a no-op + fig.display() + fig.displayed # True + fig.close() # runs the close handlers once + fig.closed # True + + +Moving widgets from Python without echo +--------------------------------------- + +``widget.set(...)`` fires the widget's ``pointer_move`` handlers so a Python +update looks like a drag. Pass ``notify=False`` when the handlers should only +hear about the user: + +.. code-block:: python + + widget.set(cx=new_x, cy=new_y, notify=False) + + Pause and hold -------------- From 9e9d5c14da54ae9112aa89ece49a5ee765f63ae3 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 21 Oct 2026 14:14:27 -0500 Subject: [PATCH 12/24] docs: show calibrated-coordinate widgets and markers in getting started Assisted-by: claude-code:claude-opus-5-5 --- docs/getting_started.rst | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/getting_started.rst b/docs/getting_started.rst index aae8adb80..20f151f5a 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -151,6 +151,31 @@ for worked examples. v = ax.imshow(data, units="px") v # display in a Jupyter cell +Calibrated coordinates +^^^^^^^^^^^^^^^^^^^^^^ + +Widgets and markers on a 2-D panel are positioned in *image pixels* by default +(integer *i* is the centre of pixel *i*). When the image has physical axes, +pass ``coords="data"`` to work in those units instead — positions, lengths, +attribute reads, ``set()`` and drag callbacks all use them: + +.. code-block:: python + + x = 10.0 + 0.5 * np.arange(25) # nm, one value per column + y = -3.0 + 0.25 * np.arange(12) # nm, one value per row + plot = ax.imshow(np.zeros((12, 25)), axes=[x, y], units="nm") + + roi = plot.add_widget("rectangle", x=11.0, y=-2.0, w=2.0, h=1.0, + coords="data") + roi.add_event_handler(lambda e: print(roi.x, roi.w), "pointer_up") # nm + plot.add_circles([[12.0, -1.0]], radius=0.5, coords="data") + + plot.transform.x_to_pixel(11.0) # 2.0 + plot.pixel_to_data([2, 4]) # array([11., -2.]) + +:attr:`Plot2D.transform ` handles offsets, +non-unit and non-uniform spacing, and descending axes (``origin="lower"``). + Bar chart ~~~~~~~~~ From 4349625f71b05db2d51be3649b190f563e7a98ee Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 21 Oct 2026 14:21:50 -0500 Subject: [PATCH 13/24] markers: public MarkerGroup.marker_type / name / get() Lets code holding a marker group read its type, registry name and properties without reaching into _type / _name / _data. Assisted-by: claude-code:claude-opus-5-5 --- anyplotlib/markers.py | 19 +++++++++++++++++++ .../+markergroup-accessors.new_feature.rst | 5 +++++ 2 files changed, 24 insertions(+) create mode 100644 upcoming_changes/+markergroup-accessors.new_feature.rst diff --git a/anyplotlib/markers.py b/anyplotlib/markers.py index 0b1fcf1db..696c21fb5 100644 --- a/anyplotlib/markers.py +++ b/anyplotlib/markers.py @@ -242,6 +242,25 @@ def __init__(self, marker_type: str, name: str, kwargs: dict, push_fn, self._push_fn = push_fn self._parent: "MarkerTypeDict | None" = parent + @property + def marker_type(self) -> str: + """The marker type, e.g. ``"circles"`` or ``"lines"`` (read-only).""" + return self._type + + @property + def name(self) -> str: + """The group's key in ``plot.markers[marker_type]`` (read-only).""" + return self._name + + def get(self, key: str, default=None): + """Return a property as last given to the constructor or :meth:`set` + (matplotlib-style name, in the group's :attr:`coords` frame). + + >>> group.get("edgecolors") # doctest: +SKIP + '#ff0000' + """ + return self._data.get(key, default) + @property def coords(self) -> str: """Frame of this group's positions and lengths: ``"pixel"`` or ``"data"``. diff --git a/upcoming_changes/+markergroup-accessors.new_feature.rst b/upcoming_changes/+markergroup-accessors.new_feature.rst new file mode 100644 index 000000000..8977b6c75 --- /dev/null +++ b/upcoming_changes/+markergroup-accessors.new_feature.rst @@ -0,0 +1,5 @@ +Added read-only :attr:`MarkerGroup.marker_type +` and +:attr:`~anyplotlib.markers.MarkerGroup.name`, and +:meth:`MarkerGroup.get ` for reading a +group's properties back. From 1c4f60f8b51bff00ec6bab99cd09c6d5f6b5f97e Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 21 Oct 2026 14:21:50 -0500 Subject: [PATCH 14/24] plot2d: calibrate images from matplotlib-style edge extents Axes.imshow(extent=(left, right, bottom, top)) and Plot2D.set_extent_edges() derive the per-pixel-centre axes from the image's outer edges, so callers coming from matplotlib no longer compute pixel centres themselves. top is the y at the top of the displayed image for either origin. The tests also cover the MarkerGroup accessors added in the next commit. Assisted-by: claude-code:claude-opus-5-5 --- anyplotlib/axes/_axes.py | 15 +++- anyplotlib/plot2d/_plot2d.py | 48 +++++++++++ .../tests/test_plot2d/test_extent_edges.py | 86 +++++++++++++++++++ .../+imshow-extent.new_feature.rst | 4 + 4 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 anyplotlib/tests/test_plot2d/test_extent_edges.py create mode 100644 upcoming_changes/+imshow-extent.new_feature.rst diff --git a/anyplotlib/axes/_axes.py b/anyplotlib/axes/_axes.py index d983ec7c6..7370061ca 100644 --- a/anyplotlib/axes/_axes.py +++ b/anyplotlib/axes/_axes.py @@ -156,7 +156,8 @@ def imshow(self, data, integration_method: str = "mean", overview_method: str = "mean", tile_backend=None, - probe_exact: bool = True) -> "Plot2D": + probe_exact: bool = True, + extent=None) -> "Plot2D": """Attach a 2-D image to this axes cell. Parameters @@ -188,11 +189,19 @@ def imshow(self, data, cursor dwells on a pixel (default ``True``). Without it the value comes from the 8-bit codes the renderer holds, which quantises wide dtypes to ``range/255``. See :meth:`~anyplotlib.Plot2D.set_value_probe`. + extent : (left, right, bottom, top), optional + Calibrate from the image's outer edges, as matplotlib's + ``imshow(extent=...)`` does, instead of per-pixel ``axes``. + ``top`` is the y coordinate of the top of the displayed image + whatever the ``origin``. Mutually exclusive with ``axes``. See + :meth:`~anyplotlib.Plot2D.set_extent_edges`. Returns ------- Plot2D """ + if extent is not None and axes is not None: + raise ValueError("pass either axes or extent, not both") 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, @@ -200,6 +209,10 @@ def imshow(self, data, tile=tile, integration_method=integration_method, overview_method=overview_method, tile_backend=tile_backend, probe_exact=probe_exact) + if extent is not None: + left, right, bottom, top = (float(v) for v in extent) + # Not attached yet, so this only updates state (no push). + plot.set_extent_edges(left, right, bottom, top) self._attach(plot) return plot diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index a805f2cb0..daad3fd3d 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -81,6 +81,17 @@ def _reduce_mask_any(mask, out_h: int, out_w: int): return out +def _edge_centres(edge0: float, edge1: float, count: int) -> np.ndarray: + """Pixel-centre coordinates of *count* pixels spanning ``edge0 → edge1``. + + The edges are the OUTER boundary of the image, so the centres sit half a + pixel in from either end (matplotlib's ``imshow(extent=...)``). + """ + count = int(count) + size = (float(edge1) - float(edge0)) / count + return float(edge0) + size * (np.arange(count, dtype=float) + 0.5) + + class Plot2D(_BasePlot, _PanelMixin, _MarkerMixin): """2-D image plot panel. @@ -1956,6 +1967,43 @@ def set_extent(self, x_axis, y_axis, units: str | None = None) -> None: self._state["units"] = units self._push() + def set_extent_edges(self, left: float, right: float, bottom: float, + top: float, units: str | None = None) -> None: + """Calibrate the image from its outer edges (matplotlib's ``extent``). + + The image is ``image_width`` × ``image_height`` pixels spanning + ``left → right`` horizontally and ``top → bottom`` from the top of the + panel down — the box matplotlib's ``imshow(extent=(left, right, + bottom, top))`` describes. Each pixel's centre coordinate is derived + from that box and passed to :meth:`set_extent`. + + Parameters + ---------- + left, right : float + x coordinate of the left and right image edges. + bottom, top : float + y coordinate of the bottom and top image edges, *as displayed*. + Pass ``top < bottom`` for the usual "y grows downwards" image + convention, e.g. ``(0, 25, 12, 0)`` for a 12 × 25 image in pixel + units. + units : str, optional + Scale-bar / axis units label. + + Examples + -------- + >>> import numpy as np + >>> import anyplotlib as apl + >>> fig, ax = apl.subplots() + >>> plot = ax.imshow(np.zeros((12, 25))) + >>> plot.set_extent_edges(0.0, 5.0, 2.4, 0.0, units="nm") + >>> plot.x_axis[:2].round(3).tolist(), plot.y_axis[:2].round(3).tolist() + ([0.1, 0.3], [0.1, 0.3]) + """ + w = int(self._state["image_width"]) + h = int(self._state["image_height"]) + self.set_extent(_edge_centres(left, right, w), + _edge_centres(top, bottom, h), units=units) + def set_colorbar_label(self, label: str, fontsize: float | None = None) -> None: """Set the colorbar label (mini-TeX allowed; default size 10 px).""" self._set_label("colorbar_label", label, "colorbar_label_size", fontsize) diff --git a/anyplotlib/tests/test_plot2d/test_extent_edges.py b/anyplotlib/tests/test_plot2d/test_extent_edges.py new file mode 100644 index 000000000..3e2c87636 --- /dev/null +++ b/anyplotlib/tests/test_plot2d/test_extent_edges.py @@ -0,0 +1,86 @@ +""" +Edge-based calibration: ``imshow(extent=...)`` and ``Plot2D.set_extent_edges``. + +matplotlib describes an image by its outer box ``(left, right, bottom, top)``; +anyplotlib stores one coordinate per pixel centre. These pin the conversion, +including ``origin="lower"`` and a "y grows downwards" box, on a 12 × 25 image. +""" +from __future__ import annotations + +import numpy as np +import pytest + +import anyplotlib as apl + +H, W = 12, 25 + + +def _ax(): + fig, ax = apl.subplots(1, 1) + return ax + + +class TestSetExtentEdges: + def test_centres(self): + plot = _ax().imshow(np.zeros((H, W))) + plot.set_extent_edges(0.0, 5.0, 2.4, 0.0, units="nm") + np.testing.assert_allclose(plot.x_axis, 0.1 + 0.2 * np.arange(W)) + np.testing.assert_allclose(plot.y_axis, 0.1 + 0.2 * np.arange(H)) + assert plot._state["units"] == "nm" + assert plot._state["has_axes"] is True + + def test_edges_land_on_image_edges(self): + plot = _ax().imshow(np.zeros((H, W))) + plot.set_extent_edges(10.0, 22.5, -3.0, 0.0) + t = plot.transform + assert t.x_to_pixel(10.0) == pytest.approx(-0.5) + assert t.x_to_pixel(22.5) == pytest.approx(W - 0.5) + # top of the panel is y = 0, bottom is y = -3 + assert t.y_to_pixel(0.0) == pytest.approx(-0.5) + assert t.y_to_pixel(-3.0) == pytest.approx(H - 0.5) + + def test_matches_axes_arrays(self): + x = 10.0 + 0.5 * np.arange(W) + y = -3.0 + 0.25 * np.arange(H) + a = _ax().imshow(np.zeros((H, W)), axes=[x, y]) + b = _ax().imshow(np.zeros((H, W))) + b.set_extent_edges(9.75, 22.25, y[-1] + 0.125, -3.125) + np.testing.assert_allclose(b.x_axis, a.x_axis) + np.testing.assert_allclose(b.y_axis, a.y_axis) + + +class TestImshowExtent: + def test_upper(self): + plot = _ax().imshow(np.zeros((H, W)), extent=(0.0, 5.0, 2.4, 0.0)) + np.testing.assert_allclose(plot.x_axis[[0, -1]], [0.1, 4.9]) + np.testing.assert_allclose(plot.y_axis[[0, -1]], [0.1, 2.3]) + + def test_lower(self): + """origin='lower': y increases upwards, so the top row is y ~ top.""" + data = np.zeros((H, W)) + plot = _ax().imshow(data, origin="lower", extent=(0.0, 5.0, 0.0, 2.4)) + np.testing.assert_allclose(plot.y_axis[[0, -1]], [2.3, 0.1]) + assert plot.transform.y_to_pixel(2.4) == pytest.approx(-0.5) + + def test_same_as_set_extent_edges(self): + a = _ax().imshow(np.zeros((H, W)), extent=(1.0, 2.0, 5.0, 3.0)) + b = _ax().imshow(np.zeros((H, W))) + b.set_extent_edges(1.0, 2.0, 5.0, 3.0) + np.testing.assert_allclose(a.x_axis, b.x_axis) + np.testing.assert_allclose(a.y_axis, b.y_axis) + assert a._state["has_axes"] is True + + def test_axes_and_extent_conflict(self): + with pytest.raises(ValueError, match="extent"): + _ax().imshow(np.zeros((H, W)), axes=[np.arange(W), np.arange(H)], + extent=(0, 1, 1, 0)) + + +class TestMarkerGroupAccessors: + def test_get_type_name(self): + plot = _ax().imshow(np.zeros((H, W))) + g = plot.add_circles([[1, 2]], name="spots", edgecolors="#00ff00") + assert g.marker_type == "circles" + assert g.name == "spots" + assert g.get("edgecolors") == "#00ff00" + assert g.get("missing", 3) == 3 diff --git a/upcoming_changes/+imshow-extent.new_feature.rst b/upcoming_changes/+imshow-extent.new_feature.rst new file mode 100644 index 000000000..a4c5fb398 --- /dev/null +++ b/upcoming_changes/+imshow-extent.new_feature.rst @@ -0,0 +1,4 @@ +Added ``extent=(left, right, bottom, top)`` to :meth:`Axes.imshow +` and :meth:`Plot2D.set_extent_edges +`, which calibrate an image from its +outer edges the way matplotlib's ``imshow(extent=...)`` does. From f006568e903b1ee81af8545d3dc5b4f21f3f4a2f Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 21 Oct 2026 14:58:04 -0500 Subject: [PATCH 15/24] figure: get_axes lists empty axes too Figure.get_axes only returned axes registered by _register_panel, i.e. ones with a plot, so apl.subplots(1, 1) gave fig.get_axes() == []. Track axes created by add_subplot and include the still-empty ones; plotted axes are listed exactly as before. Assisted-by: claude-code:claude-opus-5-5 --- anyplotlib/figure/_figure.py | 24 ++++++++++++-- .../tests/test_layouts/test_axes_identity.py | 33 +++++++++++++++++++ .../+figure-get-axes-empty.api_change.rst | 4 +++ 3 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 upcoming_changes/+figure-get-axes-empty.api_change.rst diff --git a/anyplotlib/figure/_figure.py b/anyplotlib/figure/_figure.py index 0fe392f0c..3f4efa832 100644 --- a/anyplotlib/figure/_figure.py +++ b/anyplotlib/figure/_figure.py @@ -143,6 +143,10 @@ def __init__(self, nrows=1, ncols=1, figsize=(640, 480), self._sharex = sharex self._sharey = sharey self._axes_map: dict = {} + # Every grid Axes created by add_subplot, in creation order, plotted + # or not; ``_axes_map`` only gains an entry once a plot is attached, + # so ``get_axes`` needs this to list empty axes too. + self._created_axes: list = [] self._plots_map: dict = {} self._insets_map: dict = {} self._hspace: float | None = None @@ -283,7 +287,9 @@ def add_subplot(self, spec) -> Axes: spec = SubplotSpec(None, row, row + 1, col, col + 1) else: raise TypeError(f"add_subplot: unsupported spec type {type(spec)!r}") - return Axes(self, spec) + ax = Axes(self, spec) + self._created_axes.append(ax) + return ax # ── internal registration (called by Axes._attach) ──────────────────────── def _register_panel(self, ax: Axes, plot) -> None: @@ -927,7 +933,11 @@ def _push_panel_fields(self, panel_id: str, fields: dict) -> None: # ── helpers ─────────────────────────────────────────────────────────────── def get_axes(self) -> list: - """Return a list of all Axes, sorted by grid position. + """Return a list of all grid Axes, sorted by grid position. + + Includes axes that have no plot attached yet (as matplotlib does), so + ``fig, ax = apl.subplots(1, 1); fig.get_axes()[0] is ax``. Floating + insets from :meth:`add_inset` are not listed. Returns ------- @@ -935,7 +945,15 @@ def get_axes(self) -> list: Axes sorted by (row_start, col_start) to match typical left-to-right, top-to-bottom iteration order. """ - return sorted(self._axes_map.values(), + axes = list(self._axes_map.values()) + seen = {id(a) for a in axes} + # Add the still-empty axes. An axes whose plot was superseded by + # another axes on the same cell (same panel id) is left out, as before. + for a in getattr(self, "_created_axes", ()): + if id(a) not in seen and a._plot is None: + axes.append(a) + seen.add(id(a)) + return sorted(axes, key=lambda a: (a._spec.row_start, a._spec.col_start)) def _repr_html_(self) -> str: diff --git a/anyplotlib/tests/test_layouts/test_axes_identity.py b/anyplotlib/tests/test_layouts/test_axes_identity.py index caa690a01..9b6f842cb 100644 --- a/anyplotlib/tests/test_layouts/test_axes_identity.py +++ b/anyplotlib/tests/test_layouts/test_axes_identity.py @@ -94,3 +94,36 @@ def test_read_only(): ax.primary_plot = None with pytest.raises(AttributeError): ax.plots = () + + +# ── Figure.get_axes lists every grid axes, plotted or not ─────────────────── + +def test_get_axes_includes_empty_axes(): + fig, ax = apl.subplots(1, 1) + assert fig.get_axes() == [ax] + assert fig.get_axes()[0] is ax + + +def test_get_axes_mixed_plotted_and_empty_in_grid_order(): + fig, axes = apl.subplots(2, 3) + axes[1, 2].imshow(np.zeros((4, 5))) + axes[0, 1].plot(np.zeros(6)) + got = fig.get_axes() + assert got == list(axes.ravel()) + assert len({id(a) for a in got}) == 6 + + +def test_get_axes_plotted_unchanged_and_no_duplicates(): + fig, axes = apl.subplots(1, 2) + for a in axes: + a.plot(np.zeros(5)) + a.plot(np.ones(5)) # replacing the plot must not duplicate + assert fig.get_axes() == list(axes) + + +def test_get_axes_add_subplot_and_insets_excluded(): + fig = apl.Figure(2, 1) + a0 = fig.add_subplot(0) + a1 = fig.add_subplot((1, 0)) + fig.add_inset(0.3, 0.3).plot(np.zeros(4)) + assert fig.get_axes() == [a0, a1] diff --git a/upcoming_changes/+figure-get-axes-empty.api_change.rst b/upcoming_changes/+figure-get-axes-empty.api_change.rst new file mode 100644 index 000000000..01da72973 --- /dev/null +++ b/upcoming_changes/+figure-get-axes-empty.api_change.rst @@ -0,0 +1,4 @@ +:meth:`Figure.get_axes ` now lists every grid +axes the figure created, including ones with no plot attached yet, as +matplotlib does — so ``fig, ax = apl.subplots(1, 1); fig.get_axes()[0] is ax``. +Floating insets are still not listed. From fa7a3030f1e544bcb3028e38bcfce56fc3e14e3f Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 21 Oct 2026 14:59:02 -0500 Subject: [PATCH 16/24] markers: allow empty groups in data coordinates _points_to_pixels turns an empty (0, 2) array into [] via tolist(), and _offsets_2d rejected [] as not (N, 2), so an empty coords="data" group could neither be created nor updated. Treat any empty offsets as zero markers. Assisted-by: claude-code:claude-opus-5-5 --- anyplotlib/markers.py | 6 +++ .../test_markers/test_marker_data_coords.py | 46 +++++++++++++++++++ .../+marker-empty-data-coords.bugfix.rst | 3 ++ 3 files changed, 55 insertions(+) create mode 100644 upcoming_changes/+marker-empty-data-coords.bugfix.rst diff --git a/anyplotlib/markers.py b/anyplotlib/markers.py index 696c21fb5..59e8c1918 100644 --- a/anyplotlib/markers.py +++ b/anyplotlib/markers.py @@ -108,6 +108,12 @@ def _color(val): def _offsets_2d(offsets) -> list: arr = np.asarray(offsets, dtype=float) + if arr.size == 0: + # An empty group (zero markers) is valid and renders nothing. Accept + # any empty input -- ``np.zeros((0, 2))`` but also ``[]``, which is what + # an empty (0, 2) array becomes after ``.tolist()`` (e.g. in the + # ``coords="data"`` -> pixel conversion). + return [] if arr.ndim == 1 and arr.shape[0] == 2: arr = arr[np.newaxis, :] if arr.ndim != 2 or arr.shape[1] != 2: diff --git a/anyplotlib/tests/test_markers/test_marker_data_coords.py b/anyplotlib/tests/test_markers/test_marker_data_coords.py index 1b3f4eca8..29b1101e4 100644 --- a/anyplotlib/tests/test_markers/test_marker_data_coords.py +++ b/anyplotlib/tests/test_markers/test_marker_data_coords.py @@ -162,3 +162,49 @@ def test_1d_accepts_and_ignores(self): plot = ax.plot(np.arange(48.0)) g = plot.markers.add("vlines", offsets=[3.0], coords="data") assert _wire(g)["offsets"] == [[3.0]] + + +class TestEmptyGroups: + """Zero markers is valid in data coords, exactly as in pixel coords.""" + + CASES = [ + ("circles", "offsets", {"radius": 1.0}, (0, 2)), + ("rectangles", "offsets", {"widths": 1.0, "heights": 0.5}, (0, 2)), + ("arrows", "offsets", {"U": 1.0, "V": 1.0}, (0, 2)), + ("lines", "segments", {}, (0, 2, 2)), + ("polygons", "vertices_list", {}, (0, 3, 2)), + ] + FULL = {"offsets": [[11.0, -2.0]], + "segments": [[[11.0, -2.0], [12.0, -1.0]]], + "vertices_list": [[[11.0, -2.0], [12.0, -1.0], [11.0, -1.0]]]} + + @pytest.mark.parametrize("coords", ["pixel", "data"]) + @pytest.mark.parametrize("mtype, key, kw, shape", CASES) + def test_create_empty(self, coords, mtype, key, kw, shape): + g = _plot().markers.add(mtype, coords=coords, + **{key: np.zeros(shape)}, **kw) + assert _wire(g)[key] == [] + + @pytest.mark.parametrize("coords", ["pixel", "data"]) + @pytest.mark.parametrize("mtype, key, kw, shape", CASES) + def test_update_to_empty_and_back(self, coords, mtype, key, kw, shape): + g = _plot().markers.add(mtype, coords=coords, + **{key: self.FULL[key]}, **kw) + g.set(**{key: np.zeros(shape)}) + assert _wire(g)[key] == [] + g.set(**{key: self.FULL[key]}) + assert len(_wire(g)[key]) == 1 + + def test_empty_list_offsets(self): + g = _plot().add_circles([[11.0, -2.0]], radius=1.0, coords="data") + g.set(offsets=[]) + w = _wire(g) + assert w["offsets"] == [] and w["sizes"] == [] + + def test_review_repro_flipped_extent(self): + fig, ax = apl.subplots(1, 1) + p = ax.imshow(np.zeros((8, 8)), extent=(0., 16., 16., 0.)) + g = p.markers.add("circles", offsets=[[4., 4.]], radius=1., + coords="data") + g.set(offsets=np.zeros((0, 2))) + assert g.to_wire("x")["offsets"] == [] diff --git a/upcoming_changes/+marker-empty-data-coords.bugfix.rst b/upcoming_changes/+marker-empty-data-coords.bugfix.rst new file mode 100644 index 000000000..55bf34ecc --- /dev/null +++ b/upcoming_changes/+marker-empty-data-coords.bugfix.rst @@ -0,0 +1,3 @@ +Fixed empty marker groups (``offsets=np.zeros((0, 2))``) raising +``ValueError: offsets must be shape (N, 2)`` when created or updated with +``coords="data"``; an empty ``offsets=[]`` is now accepted in either frame. From ebb2c9617b89e10005c9c669169d2fd8f278609a Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 21 Oct 2026 15:03:40 -0500 Subject: [PATCH 17/24] widgets: convert data-coordinate rectangles as spans A coords="data" rectangle converted its corner (x, y) and size (w, h) field by field, so with origin="lower" or a descending x axis the pixel rectangle was anchored at the wrong corner (off-image in the descending case). Define the data frame as the span [x, x + w] x [y, y + h] with (x, y) the corner of smallest data value, convert both ends of each span and normalise to (min, length) in pixels, and invert the same way for reads, get/to_dict and drags. Partial sets fill the other half from the current geometry, and omitted constructor arguments default to the same pixel rectangle as in pixel coordinates. Marker rectangles/squares are centred and use unsigned lengths, so they were already right. Assisted-by: claude-code:claude-opus-5-5 --- anyplotlib/plot2d/_plot2d.py | 35 ++++++-- .../test_widget_data_coords.py | 88 +++++++++++++++++++ anyplotlib/widgets/_base.py | 66 +++++++++++++- anyplotlib/widgets/_widgets2d.py | 12 ++- docs/getting_started.rst | 3 + .../+widget-data-coords.new_feature.rst | 3 + 6 files changed, 198 insertions(+), 9 deletions(-) diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index daad3fd3d..305b2a3c2 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -24,7 +24,7 @@ from anyplotlib.markers import MarkerRegistry from anyplotlib.plot2d._transform import ImageTransform from anyplotlib.callbacks import CallbackRegistry -from anyplotlib.widgets._base import _check_coords, _convert_role +from anyplotlib.widgets._base import _check_coords, _convert_role, _convert_span from anyplotlib.widgets import ( Widget, RectangleWidget, CircleWidget, AnnularWidget, @@ -2073,9 +2073,16 @@ def _widget_args(self, cls, coords: str, **fields) -> dict: return fields t = self.transform geom = cls._GEOMETRY - return {k: (_convert_role(geom[k], v, t, True) - if k in geom and v is not None else v) - for k, v in fields.items()} + out = {k: (_convert_role(geom[k], v, t, True) + if k in geom and v is not None else v) + for k, v in fields.items()} + # Corner + size pairs convert together (see ``_convert_span``). + # Callers fill defaults first so both halves are present. + for start, length in getattr(cls, "_SPANS", ()): + if fields.get(start) is not None and fields.get(length) is not None: + out[start], out[length] = _convert_span( + geom[start], fields[start], fields[length], t, True) + return out def _register_widget(self, widget: Widget, coords: str) -> Widget: """Attach *widget* to this panel in the given coordinate frame and push.""" @@ -2160,13 +2167,17 @@ def add_rectangle_widget(self, x: float | None = None, y: float | None = None, coords: str = "pixel") -> RectangleWidget: """Add a draggable rectangle overlay. + With ``coords="data"`` the rectangle spans ``[x, x + w] × [y, y + h]`` + in data: ``(x, y)`` is the corner with the smallest data x and y and + ``w``, ``h`` are positive data lengths, on flipped and descending axes + too. Omitted geometry defaults to the same pixel rectangle as in + pixel coordinates. + ``max_extent`` caps the rectangle's size in the widget's coordinates — a scalar caps both axes, a ``(max_w, max_h)`` pair caps them separately. The rectangle then physically stops growing at the cap while dragging (the dragged corner pins, the opposite corner stays put). """ - a = self._widget_args(RectangleWidget, coords, x=x, y=y, w=w, h=h) - x, y, w, h = a["x"], a["y"], a["w"], a["h"] if coords == "data" and max_extent is not None: mw, mh = (max_extent if isinstance(max_extent, (tuple, list)) else (max_extent, max_extent)) @@ -2188,6 +2199,18 @@ def _cap(axis_default, cap): cap_w = cap_h = max_extent def_w = _cap(iw * 0.5, cap_w) def_h = _cap(ih * 0.5, cap_h) + if coords == "data" and None in (x, y, w, h): + # Defaults are defined in pixels; express them in data so a + # partly-given data rectangle is completed span by span. + t = self.transform + dx, dw = _convert_span("x", iw * 0.25, def_w, t, False) + dy, dh = _convert_span("y", ih * 0.25, def_h, t, False) + x = dx if x is None else x + w = dw if w is None else w + y = dy if y is None else y + h = dh if h is None else h + a = self._widget_args(RectangleWidget, coords, x=x, y=y, w=w, h=h) + x, y, w, h = a["x"], a["y"], a["w"], a["h"] 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, diff --git a/anyplotlib/tests/test_interactive/test_widget_data_coords.py b/anyplotlib/tests/test_interactive/test_widget_data_coords.py index 3ee454a12..0de6f43a1 100644 --- a/anyplotlib/tests/test_interactive/test_widget_data_coords.py +++ b/anyplotlib/tests/test_interactive/test_widget_data_coords.py @@ -231,3 +231,91 @@ def test_detached_widget_reads_raw(self): w = plot.add_widget("crosshair", cx=11.0, cy=-2.0, coords="data") w.remove() assert w.cx == pytest.approx(2.0) # no panel, no calibration + + +class TestRectangleFlippedAxes: + """In data coords a rectangle's ``(x, y)`` is the corner with the + smallest data x and y and ``(w, h)`` are positive data lengths: it spans + ``[x, x + w] × [y, y + h]`` in data, whichever way the axes run. + + Data x 11..13 / y -2..-1 on the 12 × 25 image: + ascending → cols 2..6, rows 4..8 + origin="lower" → cols 2..6, rows 3..7 (row = 11 - (y + 3) / 0.25) + descending x → cols 18..22, rows 4..8 (col = (22 - x) / 0.5) + """ + + CASES = { + "ascending": ({}, (2.0, 4.0, 4.0, 4.0)), + "origin_lower": ({"origin": "lower"}, (2.0, 3.0, 4.0, 4.0)), + "descending_x": ({"axes": [X[::-1], Y]}, (18.0, 4.0, 4.0, 4.0)), + } + + @staticmethod + def _make(kw): + fig, ax = apl.subplots(1, 1) + kw = dict(kw) + axes = kw.pop("axes", [X, Y]) + return ax.imshow(np.zeros((H, W)), axes=axes, **kw) + + @staticmethod + def _px(w): + wire = _wire(w) + return (wire["x"], wire["y"], wire["w"], wire["h"]) + + @pytest.mark.parametrize("case", list(CASES)) + def test_stored_pixel_rect(self, case): + kw, expect = self.CASES[case] + w = self._make(kw).add_rectangle_widget(x=11.0, y=-2.0, w=2.0, h=1.0, + coords="data") + assert self._px(w) == pytest.approx(expect) + + @pytest.mark.parametrize("case", list(CASES)) + def test_round_trip_reads(self, case): + kw, _ = self.CASES[case] + w = self._make(kw).add_rectangle_widget(x=11.0, y=-2.0, w=2.0, h=1.0, + coords="data") + assert (w.x, w.y, w.w, w.h) == pytest.approx((11.0, -2.0, 2.0, 1.0)) + assert w.get("x") == pytest.approx(11.0) + d = w.to_dict() + assert (d["x"], d["y"], d["w"], d["h"]) == pytest.approx( + (11.0, -2.0, 2.0, 1.0)) + + def test_partial_set_descending_x(self): + w = self._make({"axes": [X[::-1], Y]}).add_rectangle_widget( + x=11.0, y=-2.0, w=2.0, h=1.0, coords="data") + w.set(x=11.5, notify=False) # span 11.5..13.5 → cols 17..21 + assert self._px(w) == pytest.approx((17.0, 4.0, 4.0, 4.0)) + w.w = 1.0 # span 11.5..12.5 → cols 19..21 + assert self._px(w) == pytest.approx((19.0, 4.0, 2.0, 4.0)) + assert (w.x, w.w) == pytest.approx((11.5, 1.0)) + + def test_partial_set_origin_lower(self): + w = self._make({"origin": "lower"}).add_rectangle_widget( + x=11.0, y=-2.0, w=2.0, h=1.0, coords="data") + w.h = 0.5 # y -2..-1.5 → rows 5..7 + assert self._px(w) == pytest.approx((2.0, 5.0, 4.0, 2.0)) + assert (w.y, w.h) == pytest.approx((-2.0, 0.5)) + + def test_drag_reports_data_corner(self): + """A JS drag sends the pixel rect; handlers see the data corner.""" + w = self._make({"origin": "lower"}).add_rectangle_widget( + x=11.0, y=-2.0, w=2.0, h=1.0, coords="data") + seen = [] + w.add_event_handler( + lambda e: seen.append((e.source.x, e.source.y, e.source.w, + e.source.h)), "pointer_move") + # pixel rows 1..5 → y from 11 - 5 = 6 → -1.5 up to 11 - 1 = 10 → -0.5 + w._update_from_js({"x": 4.0, "y": 1.0, "w": 4.0, "h": 4.0, + "event_type": "pointer_move"}) + assert seen == [pytest.approx((12.0, -1.5, 2.0, 1.0))] + + def test_defaults_on_descending_x(self): + """Omitted geometry still defaults to the same pixel rectangle.""" + p = self._make({"axes": [X[::-1], Y]}) + ref = p.add_rectangle_widget() + w = p.add_rectangle_widget(coords="data") + assert self._px(w) == pytest.approx(self._px(ref)) + w2 = p.add_rectangle_widget(x=11.0, coords="data") + # given x = left data edge; default data width keeps W/2 pixels + assert self._px(w2)[2] == pytest.approx(W * 0.5) + assert w2.x == pytest.approx(11.0) diff --git a/anyplotlib/widgets/_base.py b/anyplotlib/widgets/_base.py index 30d6f9361..6299fc5da 100644 --- a/anyplotlib/widgets/_base.py +++ b/anyplotlib/widgets/_base.py @@ -80,6 +80,29 @@ def _convert_role(role: str, value, transform, to_pixels: bool): raise ValueError(f"unknown geometry role {role!r}") # pragma: no cover +def _convert_span(axis: str, start, length, transform, to_pixels: bool): + """Convert a ``(start, length)`` span along one axis between frames. + + A corner + size pair (a rectangle's ``x, w`` or ``y, h``) cannot be + converted field by field: on a descending axis (or ``origin="lower"`` + for y) the corner with the smallest data value is *not* the one with the + smallest pixel index. Both ends are converted instead and the result is + normalised to ``(smallest end, non-negative length)`` in the target + frame, which also handles non-uniform axes exactly. + + Contract: in either frame the span is ``[start, start + length]`` with + ``start`` the smaller end and ``length >= 0``. + """ + t = transform + if to_pixels: + fn = t.x_to_pixel if axis == "x" else t.y_to_pixel + else: + fn = t.pixel_to_x if axis == "x" else t.pixel_to_y + start, length = float(start), float(length) + a, b = float(fn(start)), float(fn(start + length)) + return min(a, b), abs(b - a) + + class Widget(_EventMixin): """Base class for all overlay widgets. @@ -122,6 +145,12 @@ class Widget(_EventMixin): #: Subclasses override; non-geometric fields (colour, …) are left alone. _GEOMETRY: dict = {} + #: ``((start_key, length_key), ...)`` pairs in :attr:`_GEOMETRY` that + #: form a corner + size span and are converted together (see + #: ``_convert_span``). The start key's role (``"x"``/``"y"``) names the + #: axis. + _SPANS: tuple = () + def __init__(self, wtype: str, push_fn: Callable, **kwargs): self._id: str = str(_uuid.uuid4())[:8] self._type: str = wtype @@ -321,11 +350,46 @@ def _geom_convert(self, fields: dict, to_pixels: bool, if t is None: return fields out = dict(fields) + span_keys: set = set() + for start, length in self._SPANS: + if start not in out and length not in out: + continue + span_keys.update((start, length)) + self._convert_span_fields(out, start, length, t, to_pixels) for key, role in self._GEOMETRY.items(): - if key in out: + if key in out and key not in span_keys: out[key] = _convert_role(role, out[key], t, to_pixels) return out + def _convert_span_fields(self, out: dict, start: str, length: str, + t, to_pixels: bool) -> None: + """Convert one corner + size pair of *out* in place. + + A missing half is taken from the widget's current geometry. Going to + pixels both halves are written (changing only the data ``x`` of a + rectangle on a descending axis moves its pixel ``x`` *and* may change + its pixel ``w`` on a non-uniform one); going to data only the + requested keys are reported. + """ + axis = self._GEOMETRY[start] + if to_pixels: + if start not in out or length not in out: + cur = _convert_span(axis, self._data[start], self._data[length], + t, False) + lo = out.get(start, cur[0]) + ln = out.get(length, cur[1]) + else: + lo, ln = out[start], out[length] + out[start], out[length] = _convert_span(axis, lo, ln, t, True) + else: + lo = out.get(start, self._data.get(start)) + ln = out.get(length, self._data.get(length)) + d_lo, d_ln = _convert_span(axis, lo, ln, t, False) + if start in out: + out[start] = d_lo + if length in out: + out[length] = d_ln + def _geom_in(self, fields: dict, coords: str | None = None) -> dict: """User-frame *fields* → wire (pixel) frame.""" return self._geom_convert(fields, True, coords) diff --git a/anyplotlib/widgets/_widgets2d.py b/anyplotlib/widgets/_widgets2d.py index 1ed0e0822..bdde830f0 100644 --- a/anyplotlib/widgets/_widgets2d.py +++ b/anyplotlib/widgets/_widgets2d.py @@ -22,9 +22,13 @@ class RectangleWidget(Widget): push_fn : Callable Update callback. x, y : float - Top-left corner position in pixel/data coordinates. + Corner with the smallest coordinates: the top-left pixel corner in + pixel coordinates; in data coordinates (``coords="data"``) the corner + with the smallest data x and y, wherever that is on screen (top-right + on a descending x axis, bottom-left with ``origin="lower"``). w, h : float - Width and height in pixel/data coordinates. + Width and height, non-negative, in pixel/data coordinates. The + rectangle spans ``[x, x + w] × [y, y + h]`` in its frame. color : str, optional CSS colour for the rectangle outline. Default ``"#00e5ff"``. linewidth : float, optional @@ -42,6 +46,10 @@ class RectangleWidget(Widget): integrating ROI whose size is a number of frames to read. """ _GEOMETRY = {"x": "x", "y": "y", "w": "dx", "h": "dy", "max_w": "dx", "max_h": "dy"} + # In data coords (x, y) is the corner with the smallest data x and y and + # (w, h) are positive data lengths; converted as spans so a descending + # axis or origin="lower" anchors the right pixel corner. + _SPANS = (("x", "w"), ("y", "h")) def __init__(self, push_fn, *, x, y, w, h, color="#00e5ff", linewidth=2, show_handles=True, max_extent=None): diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 20f151f5a..549e4a107 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -175,6 +175,9 @@ attribute reads, ``set()`` and drag callbacks all use them: :attr:`Plot2D.transform ` handles offsets, non-unit and non-uniform spacing, and descending axes (``origin="lower"``). +A data-coordinate rectangle spans ``[x, x + w] × [y, y + h]`` in data — +``(x, y)`` is its corner with the smallest data values and ``w``, ``h`` are +positive — whichever way the axes run on screen. Bar chart ~~~~~~~~~ diff --git a/upcoming_changes/+widget-data-coords.new_feature.rst b/upcoming_changes/+widget-data-coords.new_feature.rst index 93d170b0a..24d67a6be 100644 --- a/upcoming_changes/+widget-data-coords.new_feature.rst +++ b/upcoming_changes/+widget-data-coords.new_feature.rst @@ -6,3 +6,6 @@ values seen in drag callbacks — in the panel's calibrated axis units instead of image pixels. See :attr:`~anyplotlib.Widget.coords` and :meth:`~anyplotlib.Widget.set_coords`; 1-D and bar widget methods accept ``coords="data"`` too. +A data-coordinate rectangle spans ``[x, x + w] × [y, y + h]`` in data, with +``(x, y)`` its corner of smallest data x and y and positive ``w``, ``h``, so it +covers the right pixels with ``origin="lower"`` or a descending axis. From a3e96fbfad1836329d9bd8324e97e54b6f1929ad Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 21 Oct 2026 15:04:10 -0500 Subject: [PATCH 18/24] figure: close runs figure and panel handlers independently The try/finally in Figure.close wrapped both the figure-level and the per-panel close dispatch, so a raising figure handler skipped every panel's close handlers (and a raising panel skipped later panels). Fire each registry on its own, still mark the figure closed and hidden, then re-raise the first exception. Assisted-by: claude-code:claude-opus-5-5 --- anyplotlib/figure/_figure.py | 31 ++++++++------ .../tests/test_layouts/test_lifecycle.py | 41 +++++++++++++++++++ ...gure-close-independent-handlers.bugfix.rst | 4 ++ 3 files changed, 64 insertions(+), 12 deletions(-) create mode 100644 upcoming_changes/+figure-close-independent-handlers.bugfix.rst diff --git a/anyplotlib/figure/_figure.py b/anyplotlib/figure/_figure.py index 3f4efa832..b0081cf6d 100644 --- a/anyplotlib/figure/_figure.py +++ b/anyplotlib/figure/_figure.py @@ -1121,23 +1121,30 @@ def close(self) -> None: setting its CSS ``display`` to ``"none"``. :attr:`closed` becomes ``True``. Subsequent calls are no-ops. - The figure is marked closed and hidden even if a handler raises; the - exception then propagates. + The figure-level handlers and each panel's handlers run independently: + if one of them raises, the others still run, the figure is still + marked closed and hidden, and the first exception is re-raised at the + end. """ if self.closed: return self._closed = True - close_event = Event(event_type="close", source=self) - try: - self.callbacks.fire(close_event) - for plot in self._plots_map.values(): - if hasattr(plot, "callbacks"): - plot.callbacks.fire(Event(event_type="close", source=self)) - finally: + registries = [self.callbacks] + registries += [plot.callbacks for plot in list(self._plots_map.values()) + if hasattr(plot, "callbacks")] + first_exc = None + for registry in registries: try: - self.layout.display = "none" - except Exception: - pass + registry.fire(Event(event_type="close", source=self)) + except Exception as exc: # keep closing; report the first error + if first_exc is None: + first_exc = exc + try: + self.layout.display = "none" + except Exception: + pass + if first_exc is not None: + raise first_exc def __repr__(self) -> str: return (f"Figure({self._nrows}x{self._ncols}, " diff --git a/anyplotlib/tests/test_layouts/test_lifecycle.py b/anyplotlib/tests/test_layouts/test_lifecycle.py index 4535b5a78..aa90d69bc 100644 --- a/anyplotlib/tests/test_layouts/test_lifecycle.py +++ b/anyplotlib/tests/test_layouts/test_lifecycle.py @@ -92,6 +92,47 @@ def boom(event): assert fig.closed is True assert fig.layout.display == "none" + def test_raising_figure_handler_does_not_skip_panels(self): + fig, axes = apl.subplots(1, 2) + p0 = axes[0].imshow(np.zeros((12, 25))) + p1 = axes[1].plot(np.zeros(48)) + seen = [] + + def boom(event): + raise RuntimeError("figure boom") + + fig.on_close(boom) + p0.add_event_handler(lambda e: seen.append("p0"), "close") + p1.add_event_handler(lambda e: seen.append("p1"), "close") + with pytest.raises(RuntimeError, match="figure boom"): + fig.close() + assert sorted(seen) == ["p0", "p1"] + assert fig.closed is True + assert fig.layout.display == "none" + + def test_raising_panel_handler_does_not_skip_others(self): + fig, axes = apl.subplots(1, 2) + p0 = axes[0].imshow(np.zeros((12, 25))) + p1 = axes[1].plot(np.zeros(48)) + seen = [] + + def boom(event): + raise ValueError("panel boom") + + def boom2(event): + raise KeyError("second") + + fig.on_close(lambda e: seen.append("fig")) + p0.add_event_handler(boom, "close") + p1.add_event_handler(lambda e: seen.append("p1"), "close") + p1.add_event_handler(boom2, "close") + # the first exception wins; a later failure does not replace it + with pytest.raises(ValueError, match="panel boom"): + fig.close() + assert seen == ["fig", "p1"] + assert fig.closed is True + assert fig.layout.display == "none" + class TestDisplay: def test_initially_not_displayed(self): diff --git a/upcoming_changes/+figure-close-independent-handlers.bugfix.rst b/upcoming_changes/+figure-close-independent-handlers.bugfix.rst new file mode 100644 index 000000000..b80d1e302 --- /dev/null +++ b/upcoming_changes/+figure-close-independent-handlers.bugfix.rst @@ -0,0 +1,4 @@ +:meth:`Figure.close ` now runs the figure-level and +each panel's ``close`` handlers independently: a handler that raises no +longer skips the remaining ones. The figure is still marked closed and +hidden, and the first exception is re-raised once all handlers have run. From 5678189298f02fae4a2b469085312aa773b7d237 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 21 Oct 2026 15:04:27 -0500 Subject: [PATCH 19/24] widgets: repr reports geometry in the widget's own frame Widget.__repr__ printed the raw _data, i.e. image pixels, for a coords="data" widget whose attributes read in data units. Build it from to_dict() and tag data-frame widgets on 2-D panels. Assisted-by: claude-code:claude-opus-5-5 --- .../test_widget_data_coords.py | 22 +++++++++++++++++++ anyplotlib/widgets/_base.py | 7 +++++- .../+widget-repr-frame.bugfix.rst | 4 ++++ 3 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 upcoming_changes/+widget-repr-frame.bugfix.rst diff --git a/anyplotlib/tests/test_interactive/test_widget_data_coords.py b/anyplotlib/tests/test_interactive/test_widget_data_coords.py index 0de6f43a1..a32f8f2a5 100644 --- a/anyplotlib/tests/test_interactive/test_widget_data_coords.py +++ b/anyplotlib/tests/test_interactive/test_widget_data_coords.py @@ -319,3 +319,25 @@ def test_defaults_on_descending_x(self): # given x = left data edge; default data width keeps W/2 pixels assert self._px(w2)[2] == pytest.approx(W * 0.5) assert w2.x == pytest.approx(11.0) + + +class TestRepr: + def test_data_widget_repr_in_data_units(self): + w = _plot().add_rectangle_widget(x=11.0, y=-2.0, w=2.0, h=1.0, + coords="data") + r = repr(w) + assert r.startswith("RectangleWidget(") + assert "x=11," in r and "y=-2," in r and "w=2," in r and "h=1," in r + assert "coords='data'" in r + + def test_pixel_widget_repr_unchanged(self): + w = _plot().add_widget("crosshair", cx=2.0, cy=4.0) + r = repr(w) + assert "cx=2," in r and "cy=4," in r + assert "coords" not in r + + def test_1d_widget_repr_has_no_frame_tag(self): + fig, ax = apl.subplots(1, 1) + plot = ax.plot(np.arange(48.0), axes=[np.linspace(0, 1, 48)]) + r = repr(plot.add_vline_widget(0.25, coords="data")) + assert "x=0.25" in r and "coords" not in r diff --git a/anyplotlib/widgets/_base.py b/anyplotlib/widgets/_base.py index 6299fc5da..34f2ba81e 100644 --- a/anyplotlib/widgets/_base.py +++ b/anyplotlib/widgets/_base.py @@ -495,11 +495,16 @@ def _update_from_js(self, msg: dict, event_type: str = "pointer_move") -> bool: # ── repr ────────────────────────────────────────────────────────── def __repr__(self) -> str: + # Report geometry in the widget's own frame (what attribute reads + # return), not the pixels stored for the renderer. Name the frame + # when it differs from the panel's native pixels. props = ", ".join( f"{k}={v:.4g}" if isinstance(v, float) else f"{k}={v!r}" - for k, v in self._data.items() + for k, v in self.to_dict().items() if k not in ("id", "type", "color") ) + if self.coords == "data" and self._transform() is not None: + props += ", coords='data'" return f"{type(self).__name__}({props})" @property diff --git a/upcoming_changes/+widget-repr-frame.bugfix.rst b/upcoming_changes/+widget-repr-frame.bugfix.rst new file mode 100644 index 000000000..8b204e6af --- /dev/null +++ b/upcoming_changes/+widget-repr-frame.bugfix.rst @@ -0,0 +1,4 @@ +A widget's ``repr`` now shows its geometry in the widget's own +:attr:`~anyplotlib.Widget.coords` frame (and says ``coords='data'`` for a +data-coordinate widget on a 2-D panel) instead of the image pixels stored for +the renderer. From 937371f531a67db3c0c79cde26adb37daf22adb8 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 21 Oct 2026 15:06:52 -0500 Subject: [PATCH 20/24] axes: keep Axes-level labels across plot replacement Labels set through Axes.set_xlabel/set_ylabel/set_title were applied once to the first attached plot and then forgotten, so ax.set_xlabel("A"); ax.imshow(a); ax.imshow(b) lost the label. Keep them on the axes and re-apply them in _attach (grid and inset). Labels set on the plot object itself stay with that plot. Adds tests for grid axes and insets before attach and for replacement. Assisted-by: claude-code:claude-opus-5-5 --- anyplotlib/axes/_axes.py | 46 ++++++++++-------- anyplotlib/axes/_inset_axes.py | 2 +- .../tests/test_layouts/test_lifecycle.py | 48 +++++++++++++++++-- .../+axes-deferred-labels.new_feature.rst | 5 +- 4 files changed, 73 insertions(+), 28 deletions(-) diff --git a/anyplotlib/axes/_axes.py b/anyplotlib/axes/_axes.py index 7370061ca..e6fcd6406 100644 --- a/anyplotlib/axes/_axes.py +++ b/anyplotlib/axes/_axes.py @@ -31,8 +31,11 @@ def __init__(self, fig: "Figure", spec: "SubplotSpec"): # noqa: F821 self._fig = fig self._spec = spec self._plot: "Plot1D | Plot2D | None" = None - # Labels set before a plot exists: {setter name: (label, fontsize)}. - self._pending_labels: dict = {} + # Labels set through Axes.set_xlabel / set_ylabel / set_title: + # {setter name: (label, fontsize)}. Kept on the axes (not only on the + # plot) so they are applied to every plot attached later -- the first + # one, and any that replaces it via a second factory call. + self._labels: dict = {} # ------------------------------------------------------------------ # Labels (work before a plot is attached) @@ -40,10 +43,15 @@ def __init__(self, fig: "Figure", spec: "SubplotSpec"): # noqa: F821 def set_xlabel(self, label: str, fontsize: float | None = None) -> None: """Set the x-axis label of this axes' plot. - Works before a plot is attached: the label is stored and applied when - :meth:`imshow`, :meth:`plot`, … attaches one, so layout code can label - an axes before deciding what to draw in it. Once attached it is the - same as ``ax.primary_plot.set_xlabel(...)``. + Works before a plot is attached: the label is stored on the axes and + applied when :meth:`imshow`, :meth:`plot`, … attaches one, so layout + code can label an axes before deciding what to draw in it. With a + plot attached it also sets ``ax.primary_plot.set_xlabel(...)``. + + The axes keeps the label, so a plot that *replaces* the current one + (a second ``imshow``/``plot``/… call on this axes) gets it too. + Labels set directly on the plot object (``plot.set_xlabel``) belong + to that plot only and are not carried over. Parameters ---------- @@ -69,19 +77,17 @@ def set_title(self, label: str, fontsize: float | None = None) -> None: self._set_label("set_title", label, fontsize) def _set_label(self, method: str, label: str, fontsize) -> None: - if self._plot is None: - self._pending_labels[method] = (label, fontsize) - return - fn = getattr(self._plot, method, None) - if fn is None: - raise AttributeError( - f"{type(self._plot).__name__} does not support {method}()") - fn(label, fontsize=fontsize) - - def _apply_pending_labels(self, plot) -> None: - """Apply labels stored before *plot* was attached (once).""" - pending, self._pending_labels = self._pending_labels, {} - for method, (label, fontsize) in pending.items(): + if self._plot is not None: + fn = getattr(self._plot, method, None) + if fn is None: + raise AttributeError( + f"{type(self._plot).__name__} does not support {method}()") + fn(label, fontsize=fontsize) + self._labels[method] = (label, fontsize) + + def _apply_axes_labels(self, plot) -> None: + """Apply the labels set through this axes to a newly attached *plot*.""" + for method, (label, fontsize) in self._labels.items(): fn = getattr(plot, method, None) if fn is not None: # e.g. a 3-D plot has no title setter fn(label, fontsize=fontsize) @@ -662,7 +668,7 @@ def _attach(self, plot: "Plot1D | Plot2D | PlotMesh | Plot3D | PlotBar") -> None plot._fig = self._fig self._plot = plot self._fig._register_panel(self, plot) - self._apply_pending_labels(plot) + self._apply_axes_labels(plot) def __repr__(self) -> str: kind = type(self._plot).__name__ if self._plot else "empty" diff --git a/anyplotlib/axes/_inset_axes.py b/anyplotlib/axes/_inset_axes.py index 3f762e573..3e865cd63 100644 --- a/anyplotlib/axes/_inset_axes.py +++ b/anyplotlib/axes/_inset_axes.py @@ -400,7 +400,7 @@ def _attach(self, plot) -> None: plot._fig = self._fig self._plot = plot self._fig._register_inset(self, plot) - self._apply_pending_labels(plot) + self._apply_axes_labels(plot) def __repr__(self) -> str: kind = _plot_kind(self._plot) if self._plot else "empty" diff --git a/anyplotlib/tests/test_layouts/test_lifecycle.py b/anyplotlib/tests/test_layouts/test_lifecycle.py index aa90d69bc..d6a4536ce 100644 --- a/anyplotlib/tests/test_layouts/test_lifecycle.py +++ b/anyplotlib/tests/test_layouts/test_lifecycle.py @@ -213,12 +213,42 @@ def test_last_pending_wins(self): plot = ax.imshow(np.zeros((12, 25))) assert plot._state["x_label"] == "second" - def test_pending_applied_once(self): + def test_labels_survive_plot_replacement(self): fig, ax = apl.subplots(1, 1) - ax.set_title("once") + ax.set_xlabel("A") ax.imshow(np.zeros((12, 25))) + ax.set_title("T", fontsize=11) # set while a plot is attached + replacement = ax.imshow(np.ones((12, 25))) + assert replacement._state["x_label"] == "A" + assert replacement._state["title"] == "T" + assert replacement._state["title_size"] == 11 + + def test_labels_survive_change_of_plot_type(self): + fig, ax = apl.subplots(1, 1) + ax.set_xlabel("energy (eV)") + ax.imshow(np.zeros((12, 25))) + line = ax.plot(np.arange(48.0)) + assert line._state["units"] == "energy (eV)" # Plot1D's x label + + def test_plot_level_label_not_carried_over(self): + fig, ax = apl.subplots(1, 1) + first = ax.imshow(np.zeros((12, 25))) + first.set_xlabel("only on first") replacement = ax.imshow(np.zeros((12, 25))) - assert replacement._state["title"] == "" + assert replacement._state["x_label"] == "" + + def test_grid_axes_before_attach(self): + fig, axes = apl.subplots(2, 3) + axes[1, 2].set_xlabel("x (nm)") + axes[1, 2].set_ylabel("y (nm)") + axes[1, 2].set_title("cell 1,2") + axes[0, 0].set_title("cell 0,0") + p12 = axes[1, 2].imshow(np.zeros((12, 25))) + p00 = axes[0, 0].plot(np.arange(48.0)) + assert (p12._state["x_label"], p12._state["y_label"], + p12._state["title"]) == ("x (nm)", "y (nm)", "cell 1,2") + assert p00._state["title"] == "cell 0,0" + assert p00._state["units"] != "x (nm)" # no cross-talk between cells def test_after_attach_delegates(self): fig, ax = apl.subplots(1, 1) @@ -231,8 +261,15 @@ def test_inset(self): fig, ax = apl.subplots(1, 1) inset = fig.add_inset(0.3, 0.3) inset.set_title("zoom") + inset.set_xlabel("x", fontsize=10) + inset.set_ylabel("y") plot = inset.imshow(np.zeros((12, 25))) assert plot._state["title"] == "zoom" + assert plot._state["x_label"] == "x" + assert plot._state["x_label_size"] == 10 + assert plot._state["y_label"] == "y" + replacement = inset.plot(np.arange(48.0)) + assert replacement._state["title"] == "zoom" def test_unsupported_after_attach_raises(self): from types import SimpleNamespace @@ -247,5 +284,6 @@ def test_unsupported_pending_is_dropped(self): fig, ax = apl.subplots(1, 1) ax.set_ylabel("z") X, Y = np.meshgrid(np.arange(5.0), np.arange(4.0)) - ax.plot_surface(X, Y, np.zeros((4, 5))) - assert ax._pending_labels == {} + ax.plot_surface(X, Y, np.zeros((4, 5))) # no error + # Still kept on the axes for a later plot that can show it. + assert ax.imshow(np.zeros((12, 25)))._state["y_label"] == "z" diff --git a/upcoming_changes/+axes-deferred-labels.new_feature.rst b/upcoming_changes/+axes-deferred-labels.new_feature.rst index 2bf0de06a..d24c232d9 100644 --- a/upcoming_changes/+axes-deferred-labels.new_feature.rst +++ b/upcoming_changes/+axes-deferred-labels.new_feature.rst @@ -1,4 +1,5 @@ Added :meth:`Axes.set_xlabel `, :meth:`~anyplotlib.Axes.set_ylabel` and :meth:`~anyplotlib.Axes.set_title`, -which work before a plot is attached: the labels are stored and applied when -the first plot is attached to the axes. +which work before a plot is attached: the axes keeps the labels and applies +them to the plot that is attached first and to any plot that later replaces +it (a second ``imshow``/``plot``/… call on the same axes). From c2850d8e10539afc1d66880d3f4a8fbdc84d96c4 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 21 Oct 2026 15:07:03 -0500 Subject: [PATCH 21/24] docs: note widget vs marker behaviour on re-calibration coords="data" widgets store pixels and stay put on screen after set_extent, while coords="data" marker groups keep their data position. Say so in Widget.coords and the getting started page. Covered by test_recalibration_keeps_pixels. Assisted-by: claude-code:claude-opus-5-5 --- anyplotlib/widgets/_base.py | 4 +++- docs/getting_started.rst | 7 +++++++ upcoming_changes/+widget-data-coords-recalibration.doc.rst | 3 +++ 3 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 upcoming_changes/+widget-data-coords-recalibration.doc.rst diff --git a/anyplotlib/widgets/_base.py b/anyplotlib/widgets/_base.py index 34f2ba81e..db10591a1 100644 --- a/anyplotlib/widgets/_base.py +++ b/anyplotlib/widgets/_base.py @@ -324,7 +324,9 @@ def coords(self) -> str: The renderer always works in pixels, so a widget stores pixels and a ``"data"`` widget converts on every read and write. Re-calibrating the panel (:meth:`~anyplotlib.Plot2D.set_extent`) therefore keeps the - widget on the same *pixels*; its data-coordinate values change. + widget on the same *pixels*; its data-coordinate values change. This + differs from ``coords="data"`` marker groups, which store data values + and so keep their *data* position (and move on screen). """ if self._coords is not None: return self._coords diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 549e4a107..23ab75fa8 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -179,6 +179,13 @@ A data-coordinate rectangle spans ``[x, x + w] × [y, y + h]`` in data — ``(x, y)`` is its corner with the smallest data values and ``w``, ``h`` are positive — whichever way the axes run on screen. +.. note:: + + A data-coordinate *widget* still stores pixels, so after + :meth:`~anyplotlib.Plot2D.set_extent` it stays on the same pixels and its + data values change, whereas a data-coordinate *marker group* keeps its data + position. + Bar chart ~~~~~~~~~ diff --git a/upcoming_changes/+widget-data-coords-recalibration.doc.rst b/upcoming_changes/+widget-data-coords-recalibration.doc.rst new file mode 100644 index 000000000..15099641b --- /dev/null +++ b/upcoming_changes/+widget-data-coords-recalibration.doc.rst @@ -0,0 +1,3 @@ +Documented that after :meth:`~anyplotlib.Plot2D.set_extent` a +``coords="data"`` widget stays on the same pixels (its data values change) +while a ``coords="data"`` marker group keeps its data position. From fafb6bff580e68d4c97d59d9c0c325ec60ef6b77 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 21 Oct 2026 15:08:22 -0500 Subject: [PATCH 22/24] figure: release the ipywidgets widget on close Figure.close overrides ipywidgets' Widget.close but never called it, so the comm stayed open and ipywidgets' global instance registry kept every figure (and its plots and data) alive forever: a closed figure was never collectable. Call the base close after the handlers and the hide. Python-side state stays usable (set_data, to_html). Assisted-by: claude-code:claude-opus-5-5 --- anyplotlib/figure/_figure.py | 13 ++++++++++ .../tests/test_layouts/test_lifecycle.py | 26 +++++++++++++++++++ .../+figure-close-releases-widget.bugfix.rst | 5 ++++ 3 files changed, 44 insertions(+) create mode 100644 upcoming_changes/+figure-close-releases-widget.bugfix.rst diff --git a/anyplotlib/figure/_figure.py b/anyplotlib/figure/_figure.py index b0081cf6d..0d064f20b 100644 --- a/anyplotlib/figure/_figure.py +++ b/anyplotlib/figure/_figure.py @@ -1121,6 +1121,11 @@ def close(self) -> None: setting its CSS ``display`` to ``"none"``. :attr:`closed` becomes ``True``. Subsequent calls are no-ops. + Finally the underlying ipywidgets comm is closed (front-end views are + removed) and the widget is released, so a closed figure that is no + longer referenced can be garbage collected. The Python objects stay + usable — plots can still be read, exported or saved. + The figure-level handlers and each panel's handlers run independently: if one of them raises, the others still run, the figure is still marked closed and hidden, and the first exception is re-raised at the @@ -1143,6 +1148,14 @@ def close(self) -> None: self.layout.display = "none" except Exception: pass + # Release the ipywidgets side: close the comm and drop the figure from + # ipywidgets' global instance registry. Without this every figure + # ever created stays referenced by that registry (and its comm), so a + # closed figure and all its plot data can never be garbage collected. + try: + super().close() + except Exception: + pass if first_exc is not None: raise first_exc diff --git a/anyplotlib/tests/test_layouts/test_lifecycle.py b/anyplotlib/tests/test_layouts/test_lifecycle.py index d6a4536ce..c46069add 100644 --- a/anyplotlib/tests/test_layouts/test_lifecycle.py +++ b/anyplotlib/tests/test_layouts/test_lifecycle.py @@ -92,6 +92,32 @@ def boom(event): assert fig.closed is True assert fig.layout.display == "none" + def test_closed_figure_is_collectable(self): + import gc + import weakref + + def make(): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((12, 25))) + plot.add_rectangle_widget(x=1, y=2, w=3, h=4) + plot.add_circles([[2.0, 3.0]], radius=1.0) + fig.close() + return weakref.ref(fig), weakref.ref(plot) + + fig_ref, plot_ref = make() + gc.collect() + assert fig_ref() is None + assert plot_ref() is None + + def test_closed_figure_objects_still_usable(self): + fig, ax = apl.subplots(1, 1) + plot = ax.imshow(np.zeros((12, 25))) + fig.close() + plot.set_data(np.ones((12, 25))) # no comm, must not raise + plot.set_title("after close") + assert fig.get_axes() == [ax] + assert "after close" in fig.to_html() + def test_raising_figure_handler_does_not_skip_panels(self): fig, axes = apl.subplots(1, 2) p0 = axes[0].imshow(np.zeros((12, 25))) diff --git a/upcoming_changes/+figure-close-releases-widget.bugfix.rst b/upcoming_changes/+figure-close-releases-widget.bugfix.rst new file mode 100644 index 000000000..137a18d7c --- /dev/null +++ b/upcoming_changes/+figure-close-releases-widget.bugfix.rst @@ -0,0 +1,5 @@ +:meth:`Figure.close ` now also closes the +underlying ipywidgets comm (removing the figure's front-end views) and drops +it from ipywidgets' instance registry, so a closed figure that is no longer +referenced — and its plot data — can be garbage collected. Previously every +figure stayed alive for the life of the process. From 25ca54ec2a348d39c1969a339cf7eff8ada9936e Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Wed, 21 Oct 2026 15:08:38 -0500 Subject: [PATCH 23/24] docs: describe close() error handling and widget release Assisted-by: claude-code:claude-opus-5-5 --- docs/events.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/events.rst b/docs/events.rst index c9e9be2e0..b798831b7 100644 --- a/docs/events.rst +++ b/docs/events.rst @@ -392,6 +392,11 @@ Figure lifecycle fig.close() # runs the close handlers once fig.closed # True +``close()`` runs the figure's handlers and each panel's even if one raises +(the first exception is re-raised afterwards), then hides the figure and +closes its widget comm, so a closed figure can be garbage collected once +nothing else references it. + Moving widgets from Python without echo --------------------------------------- From d8309ab30ca40bcadf113d9c64ac13fd3cc659f1 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Thu, 22 Oct 2026 09:28:50 -0500 Subject: [PATCH 24/24] docs: fix add_widget docstring literal block breaking the docs build The coords example was inserted mid-paragraph, so the sentence after it ended the literal block without a blank line (a -W failure). Keep the show_handles caveat with show_handles and put the example last. Assisted-by: claude-code:claude-opus-5-5 --- anyplotlib/plot2d/_plot2d.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/anyplotlib/plot2d/_plot2d.py b/anyplotlib/plot2d/_plot2d.py index 305b2a3c2..86956e209 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -2101,9 +2101,14 @@ def add_widget(self, kind: str, color: str = "#00e5ff", **kwargs) -> Widget: ``"brush"``, ``"vline"``, ``"hline"``. Every kind also accepts ``show_handles`` (default ``True``) to toggle - the grab-handle dots without changing hit-testing / draggability, and - ``coords`` (``"pixel"`` default, or ``"data"``) to give the geometry in - calibrated axis units — see :attr:`Widget.coords`:: + the grab-handle dots without changing hit-testing / draggability. + ``vline`` / ``hline`` have no handles — the whole line is the grab + target — so they ignore it, as does ``brush`` (a freehand stroke has + no grab point). + + Every kind also accepts ``coords`` (``"pixel"`` default, or + ``"data"``) to give the geometry in calibrated axis units — see + :attr:`Widget.coords`:: x = 10.0 + 0.5 * np.arange(25) # nm y = -3.0 + 0.25 * np.arange(12) @@ -2111,9 +2116,6 @@ def add_widget(self, kind: str, color: str = "#00e5ff", **kwargs) -> Widget: roi = plot.add_widget("rectangle", x=11.0, y=-2.0, w=2.0, h=1.0, coords="data") roi.x # 11.0 — reads come back in nm too - ``vline`` / ``hline`` have no handles — the whole line is the grab - target — so they ignore it, and neither does ``brush`` (a freehand - stroke has no grab point). """ dispatch = { "circle": self.add_circle_widget,