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/_base_plot.py b/anyplotlib/_base_plot.py index 80eb3c34f..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 ────────────────────────────────── @@ -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/axes/_axes.py b/anyplotlib/axes/_axes.py index 45d450c49..e6fcd6406 100644 --- a/anyplotlib/axes/_axes.py +++ b/anyplotlib/axes/_axes.py @@ -31,6 +31,123 @@ def __init__(self, fig: "Figure", spec: "SubplotSpec"): # noqa: F821 self._fig = fig self._spec = spec self._plot: "Plot1D | Plot2D | None" = None + # 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) + # ------------------------------------------------------------------ + 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 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 + ---------- + 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 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) + + # ------------------------------------------------------------------ + # 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, @@ -45,7 +162,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 @@ -77,11 +195,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, @@ -89,6 +215,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 @@ -538,6 +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_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 d25f2ee04..3e865cd63 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_axes_labels(plot) def __repr__(self) -> str: kind = _plot_kind(self._plot) if self._plot else "empty" 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/figure/_figure.py b/anyplotlib/figure/_figure.py index d837bd7cd..0d064f20b 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 @@ -168,6 +172,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] @@ -280,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: @@ -924,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 ------- @@ -932,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: @@ -947,6 +968,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 +1039,125 @@ 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. + + 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 + end. """ - 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) + 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: + 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 + # 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 def __repr__(self) -> str: return (f"Figure({self._nrows}x{self._ncols}, " diff --git a/anyplotlib/markers.py b/anyplotlib/markers.py index 4b1a85fd3..59e8c1918 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 @@ -52,17 +63,57 @@ # --------------------------------------------------------------------------- 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: 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: @@ -104,8 +155,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: @@ -120,6 +171,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 # --------------------------------------------------------------------------- @@ -152,10 +242,69 @@ 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 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"``. + + 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. @@ -164,7 +313,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( @@ -174,6 +324,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() @@ -213,7 +366,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 @@ -226,8 +379,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 +394,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 +409,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 +428,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 +443,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 +458,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 +475,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 +494,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 +513,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 +525,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 +536,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 +597,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 @@ -470,9 +623,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] = {} # ------------------------------------------------------------------ @@ -630,7 +786,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 ---------- @@ -639,9 +796,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] = {} # ------------------------------------------------------------------ @@ -669,7 +831,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: @@ -716,6 +879,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/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/__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..86956e209 100644 --- a/anyplotlib/plot2d/_plot2d.py +++ b/anyplotlib/plot2d/_plot2d.py @@ -22,7 +22,9 @@ 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._base import _check_coords, _convert_role, _convert_span from anyplotlib.widgets import ( Widget, RectangleWidget, CircleWidget, AnnularWidget, @@ -79,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. @@ -389,7 +402,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 @@ -986,7 +1000,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 @@ -1862,6 +1876,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. @@ -1886,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) @@ -1945,6 +2063,35 @@ 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 + 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.""" + 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. @@ -1956,8 +2103,19 @@ def add_widget(self, kind: str, color: str = "#00e5ff", **kwargs) -> Widget: Every kind also accepts ``show_handles`` (default ``True``) to toggle 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, and neither does ``brush`` (a freehand - stroke has no grab point). + 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) + 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 """ dispatch = { "circle": self.add_circle_widget, @@ -1981,7 +2139,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 — @@ -1990,6 +2149,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, @@ -1998,23 +2159,33 @@ 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. + 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). """ + 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 — @@ -2030,6 +2201,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, @@ -2038,16 +2221,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, @@ -2056,31 +2241,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 @@ -2124,6 +2307,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 ------- @@ -2132,6 +2318,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) @@ -2140,50 +2329,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, @@ -2192,15 +2381,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 @@ -2221,6 +2408,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 ------- @@ -2228,6 +2418,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, @@ -2236,13 +2428,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 @@ -2257,22 +2447,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 @@ -2286,19 +2478,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 @@ -2395,6 +2588,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. @@ -2402,6 +2596,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, @@ -2409,7 +2608,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) @@ -2419,6 +2618,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. @@ -2433,7 +2633,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) @@ -2470,12 +2670,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, *, @@ -2484,6 +2685,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, @@ -2493,7 +2695,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) @@ -2502,12 +2704,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, *, @@ -2516,6 +2719,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, @@ -2525,7 +2729,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) @@ -2535,6 +2739,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, @@ -2544,7 +2749,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) @@ -2554,6 +2759,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, @@ -2561,7 +2767,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, *, @@ -2570,6 +2776,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. @@ -2607,6 +2814,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 ------- @@ -2626,7 +2836,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/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/plot3d/_plot3d.py b/anyplotlib/plot3d/_plot3d.py index a8e05fc6f..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: @@ -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: @@ -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_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/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..a32f8f2a5 --- /dev/null +++ b/anyplotlib/tests/test_interactive/test_widget_data_coords.py @@ -0,0 +1,343 @@ +""" +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 + + +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) + + +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/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/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/tests/test_layouts/test_axes_identity.py b/anyplotlib/tests/test_layouts/test_axes_identity.py new file mode 100644 index 000000000..9b6f842cb --- /dev/null +++ b/anyplotlib/tests/test_layouts/test_axes_identity.py @@ -0,0 +1,129 @@ +""" +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 = () + + +# ── 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/anyplotlib/tests/test_layouts/test_lifecycle.py b/anyplotlib/tests/test_layouts/test_lifecycle.py new file mode 100644 index 000000000..c46069add --- /dev/null +++ b/anyplotlib/tests/test_layouts/test_lifecycle.py @@ -0,0 +1,315 @@ +""" +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" + + 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))) + 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): + 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_labels_survive_plot_replacement(self): + fig, ax = apl.subplots(1, 1) + 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["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) + 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") + 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 + + 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))) # 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/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/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..29b1101e4 --- /dev/null +++ b/anyplotlib/tests/test_markers/test_marker_data_coords.py @@ -0,0 +1,210 @@ +""" +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]] + + +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/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/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/anyplotlib/widgets/_base.py b/anyplotlib/widgets/_base.py index 32c0451e0..db10591a1 100644 --- a/anyplotlib/widgets/_base.py +++ b/anyplotlib/widgets/_base.py @@ -9,6 +9,99 @@ 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 + + +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. @@ -43,6 +136,21 @@ 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 = {} + + #: ``((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 @@ -59,12 +167,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() ──── @@ -74,6 +183,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") @@ -84,46 +199,61 @@ 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). + 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 ----- - 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._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: + 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 @@ -132,24 +262,144 @@ 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. 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 + 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) + 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 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) + + 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 @@ -247,14 +497,41 @@ 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 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/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..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 @@ -41,6 +45,12 @@ 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"} + # 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): if max_extent is None: @@ -90,6 +100,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 +135,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 +172,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 +206,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 +238,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 +268,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 +397,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 +453,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 +468,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 +519,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 +546,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/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/docs/events.rst b/docs/events.rst index 148441b52..b798831b7 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,54 @@ 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 + +``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 +--------------------------------------- + +``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 -------------- diff --git a/docs/getting_started.rst b/docs/getting_started.rst index aae8adb80..23ab75fa8 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -151,6 +151,41 @@ 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"``). +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/+axes-deferred-labels.new_feature.rst b/upcoming_changes/+axes-deferred-labels.new_feature.rst new file mode 100644 index 000000000..d24c232d9 --- /dev/null +++ b/upcoming_changes/+axes-deferred-labels.new_feature.rst @@ -0,0 +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 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). 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. 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``. 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. 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. 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. 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. 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. 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 `. 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. 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. 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. 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. 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. 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..24d67a6be --- /dev/null +++ b/upcoming_changes/+widget-data-coords.new_feature.rst @@ -0,0 +1,11 @@ +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. +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. 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. 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. 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.