Source code for anyplotlib._base_plot

"""
_base_plot.py
=============
Shared base classes and mixins for all plot panel types.
"""

from __future__ import annotations

from contextlib import contextmanager

from anyplotlib.callbacks import _EventMixin


class _BasePlot(_EventMixin):
    """Universal base for Plot1D, Plot2D, PlotBar, and Plot3D.

    Contains methods identical across all four panel types and helper
    utilities used by view-setter and widget-adder methods.

    Subclasses must define:
        _state : dict         — the panel state dict
        _push() -> None       — serialize state and write to parent Figure
    """

    def configure_pointer_settled(self, ms: int, delta: float = 4) -> None:
        """Configure the pointer-settled event threshold (ms and pixel delta)."""
        self._state["pointer_settled_ms"]    = ms
        self._state["pointer_settled_delta"] = delta
        self._push()

    _configure_pointer_settled = configure_pointer_settled

    #: Mini-TeX formatting note shared by all label setters.
    #:
    #: Label strings support a small TeX subset inside ``$...$`` delimiters,
    #: rendered by the JS canvas engine (no MathJax needed):
    #:
    #: * ``$10^{-3}$`` / ``$x^2$``  — superscripts (exponents)
    #: * ``$E_F$`` / ``$k_{B}T$``   — subscripts
    #: * ``$\\alpha$ … $\\Omega$``  — Greek letters
    #: * ``\\times \\cdot \\pm \\degree \\AA \\infty \\propto \\approx``
    #:   ``\\leq \\geq \\neq \\partial \\nabla \\hbar \\rightarrow`` — symbols
    #: * ``$\\mathrm{...}$``        — upright text inside math (letters in
    #:   math mode are italic by default)
    #:
    #: Example: ``plot.set_xlabel(r"$q$ ($\\AA^{-1}$)", fontsize=14)``

    def _set_label(self, key: str, label: str, size_key: str,
                   fontsize: float | None) -> None:
        """Store a label string (TeX subset allowed) and its optional size."""
        self._state[key] = str(label)
        if fontsize is not None:
            self._state[size_key] = float(fontsize)
        self._push()

    def set_title(self, label: str, fontsize: float | None = None) -> None:
        """Set the panel title.

        Parameters
        ----------
        label : str
            Title text.  Supports the mini-TeX subset (``$10^{-3}$``,
            ``$\\alpha$``, …) — see the class notes on label formatting.
        fontsize : float, optional
            Font size in CSS pixels.  Default 11.  On 2-D panels the title
            strip grows to fit larger sizes.  1-D and bar titles render in a
            fixed 12-px strip, so the drawn size is clamped to 11 there.
        """
        self._set_label("title", label, "title_size", fontsize)

    def set_axis_off(self) -> None:
        self._state["axis_visible"] = False
        self._push()

    def set_axis_on(self) -> None:
        self._state["axis_visible"] = True
        self._push()

    # ------------------------------------------------------------------
    # Floating image keys (see anyplotlib/keys.py)
    # ------------------------------------------------------------------
[docs] def add_key(self, image, *, corner: str = "top-right", anchor=None, size: float = 0.22, margin: float = 10.0, bgcolor=None, border=None, border_width: float = 1.0, radius: float = 4.0, alpha: float = 1.0, hover_only: bool = False, visible: bool = True, label=None, label_size: float = 10.0, label_color=None, labels=None, name=None) -> "KeyOverlay": """Pin a floating image key over this panel. A key is a small picture that floats in screen space over the plot area — it does not pan or zoom with the data. Use it for a colour legend a colorbar cannot express: an inverse pole figure triangle over an orientation map, a hue wheel over a polarization field, a phase key over a segmentation:: key = plot.add_key(ipf_triangle, corner="bottom-right", size=0.28) wheel = plot.add_key(hue_wheel, corner="top-left", bgcolor="none", hover_only=True) This is the lightweight sibling of :meth:`Figure.add_inset`. An inset is a draggable window with a title bar and its own canvas stack — the right tool when the overlay is a live plot. A key is a static picture with no chrome unless you ask for it, so it reads as part of the figure rather than as a floating panel. Parameters ---------- image : array-like, bytes, or path An ``(H, W, 3|4)`` colour array (uint8, or float 0–1), the raw bytes of a PNG/JPEG/GIF/WebP, or a path to such a file. An RGBA array is the usual choice: alpha 0 outside the shape lets a triangle or a disc sit on the image without a rectangular card around it. corner : str, optional ``"top-right"`` (default), ``"top-left"``, ``"bottom-right"`` or ``"bottom-left"``. Ignored when *anchor* is given. anchor : (x_frac, y_frac), optional Free placement: the key's centre as a fraction of the plot area, from its top-left. Overrides *corner*. size : float, optional Width as a fraction of the plot area's **shorter** side, so a key keeps its proportions when the panel is resized. Default 0.22. Height follows the image's aspect ratio. margin : float, optional Gap in CSS px between the key and the plot-area edge, for corner placement. Default 10. bgcolor : str, optional CSS colour painted behind the image — e.g. ``"rgba(0,0,0,0.45)"`` for a legible card over busy data. Default ``None`` (fully transparent); ``"none"`` means the same thing. border : str, optional CSS colour for a hairline around the card. Default ``None``. border_width : float, optional Border stroke width in px. Default 1. radius : float, optional Corner radius of the card in px. Default 4. alpha : float, optional Opacity of the whole key, 0–1. Default 1. hover_only : bool, optional Show the key only while the pointer is over the panel. Default ``False``. Useful for a reading aid you do not want sitting over the data all the time. PNG export renders the panel as though the pointer were over it, so a hover-only key **is** included in an exported figure — what you save is what you see while reading. visible : bool, optional Draw the key at all. Default ``True``. label : str, optional Caption drawn under the image, mini-TeX enabled like axis labels. label_size : float, optional Caption size in px. Default 10. label_color : str, optional Caption colour. Default ``None`` (the theme's tick-label colour). labels : list, optional Text drawn *inside* the key, for annotating the picture itself — an IPF triangle's corner indices, a wheel's compass points. Each entry is ``(x, y, text)`` or a dict with those keys plus optional ``size`` / ``color`` / ``align``. ``x`` and ``y`` are fractions of the key image, so the text follows the picture when the key is resized. Mini-TeX works here as in any label:: tri.add_key(ipf, labels=[ (0.02, 0.97, "[1 0 0]"), (0.98, 0.97, "[1 1 0]"), (0.98, 0.03, "[1 1 1]"), ]) name : str, optional Handle for :meth:`get_key`. Defaults to the generated id. Returns ------- KeyOverlay See Also -------- remove_key, list_keys, get_key anyplotlib.Figure.add_inset : a full floating axes, for live plots. """ from anyplotlib._utils import _image_to_data_url from anyplotlib.keys import KeyOverlay, _validate fields = _validate(dict( corner=corner, anchor=anchor, size=size, margin=margin, bgcolor=bgcolor, border=border, border_width=border_width, radius=radius, alpha=alpha, hover_only=hover_only, visible=visible, label=label, label_size=label_size, label_color=label_color, labels=labels)) key = KeyOverlay(self, _image_to_data_url(image), name=name, **fields) if any(k.name == key.name for k in self._key_map.values()): raise ValueError(f"a key named {key.name!r} already exists on this " "panel; pass a different name=") self._key_map[key.id] = key self._push_keys() return key
@property def _key_map(self) -> dict: """Lazily-created ``{id: KeyOverlay}`` — panels predate this feature.""" if getattr(self, "_keys_dict", None) is None: self._keys_dict = {} return self._keys_dict def _push_keys(self) -> None: """Re-serialise the key list + image table, then push. The pictures ride the geometry channel under ``key_images`` so that restyling a key — or a hover toggle — never re-transmits them. """ keys = list(self._key_map.values()) self._state["keys"] = [k.to_dict() for k in keys] self._state["key_images"] = {k.id: k.image_url for k in keys} self._push()
[docs] def get_key(self, name): """Return the key with this *name* (or id). Raises ------ KeyError If no key matches. """ for k in self._key_map.values(): if k.name == name or k.id == name: return k raise KeyError(f"no key named {name!r}")
[docs] def remove_key(self, key) -> None: """Remove a key, by object, name, or id.""" from anyplotlib.keys import KeyOverlay kid = key.id if isinstance(key, KeyOverlay) else self.get_key(key).id if kid not in self._key_map: raise KeyError(kid) del self._key_map[kid] self._push_keys()
[docs] def list_keys(self) -> list: """Every key on this panel, in creation order.""" return list(self._key_map.values())
[docs] def clear_keys(self) -> None: """Remove every key from this panel.""" self._key_map.clear() self._push_keys()
@contextmanager def _python_view_push(self): """Context manager for view setters that must signal _view_from_python. Sets the flag on entry, yields for state mutations, then pushes and clears the flag on exit. """ self._state["_view_from_python"] = True try: yield finally: self._push() self._state["_view_from_python"] = False def _make_widget_push_fn(self, widget): """Return a targeted-push closure for a widget. Replaces the repeated _tp / _targeted_push closures in every add_*_widget method. Also back-links the widget to this plot so :meth:`Widget.remove` can find its owner. Every ``add_*_widget`` routes through here, so this is the one place that has to know. """ plot_ref, wid_id = self, widget._id widget._plot = self def _push(): if plot_ref._fig is not None: fields = {k: v for k, v in widget._data.items() if k not in ("id", "type")} plot_ref._fig._push_widget(plot_ref._id, wid_id, fields) return _push class _PanelMixin: """Mixin for panels that support interactive widgets and tick control. Shared by Plot1D, Plot2D, and PlotBar. Provides _push (with widget serialization), widget management, and tick visibility control. Subclasses must define: _state : dict _fig : object _id : str _widgets : dict[str, Widget] """ def _push(self) -> None: if self._fig is None: return self._state["overlay_widgets"] = [w.to_dict() for w in self._widgets.values()] self._fig._push(self._id) # ── geometry / coordinate conversion ────────────────────────────────── # # These mirror the layout constants and letterbox math in figure_esm.js # (the PAD_* block near the top, and _imgFitRect). They are here so that # callers doing display-space work — pixel-sized handles, hit-test # tolerances, screenshot-driven tests — do not each have to re-derive the # renderer's layout in their own code and then drift from it. #: Plot-area padding in CSS px: (left, right, top, bottom). #: Matches ``PAD_L``/``PAD_R``/``PAD_T``/``PAD_B`` in ``figure_esm.js``. PLOT_PADDING = (58, 12, 12, 42) def _panel_size(self) -> tuple[float, float]: """(panel_width, panel_height) in CSS px, from the figure's layout.""" import json if self._fig is None: raise RuntimeError("panel is not attached to a figure") try: layout = json.loads(self._fig.layout_json) spec = next(s for s in layout["panel_specs"] if s["id"] == self._id) except (AttributeError, KeyError, StopIteration, ValueError) as exc: raise RuntimeError(f"no layout entry for panel {self._id!r}") from exc return float(spec["panel_width"]), float(spec["panel_height"]) def _pad_top(self) -> float: """Title-strip height. Mirrors ``_padT`` in figure_esm.js.""" pad_t = float(self.PLOT_PADDING[2]) title = self._state.get("title") if not title: return pad_t size = float(self._state.get("title_size") or 11) has_tex = "$" in str(title) if size <= 11 and not has_tex: return pad_t import math return max(pad_t, math.ceil(size * 1.3) + (4 if has_tex else 2)) def plot_box(self) -> dict: """Return the panel's plot area in CSS pixels. Returns ------- dict ``{"x", "y", "width", "height"}`` — the drawable rectangle relative to the panel's top-left corner. On a 2-D image panel it is the *letterboxed image* rather than the whole padded area, because that is what image coordinates actually map onto. Raises ------ RuntimeError If the panel is not attached to a figure, so it has no layout. Notes ----- Computed from the figure's own layout, so it is exact for the sizes the renderer was given. A browser resized without a re-layout will disagree. Zoom and pan are *not* folded in — this is the box, not the visible data window; :meth:`data_to_display` accounts for them. """ left, right, top, bottom = self.PLOT_PADDING pw, ph = self._panel_size() pad_t = self._pad_top() # A 2-D panel drops the left/right/bottom gutters when it has no # physical axes to label — the image then fills the panel width. # 1-D panels always draw axes. Mirrors `hasPhysAxis` in the JS. iw = self._state.get("image_width") ih = self._state.get("image_height") is_image = bool(iw and ih) has_axes = (not is_image) or bool( self._state.get("has_axes") or self._state.get("is_mesh") ) x = float(left) if has_axes else 0.0 y = float(pad_t) width = max((pw - left - right) if has_axes else pw, 1.0) height = max(ph - pad_t - (bottom if has_axes else 0.0), 1.0) # The colorbar strip and its gap come out of the image width. if self._state.get("show_colorbar") and not self._state.get("is_rgb"): label = self._state.get("colorbar_label") label_w = round((self._state.get("colorbar_label_size") or 10) + 8) \ if label else 0 pad = self._state.get("colorbar_pad") gap = 6.0 if pad is None else max(0.0, float(pad)) width = max(width - (16 + label_w) - gap, 1.0) if is_image: # Images are drawn "contain" — see _imgFitRect. scale = min(width / float(iw), height / float(ih)) fit_w, fit_h = float(iw) * scale, float(ih) * scale x += (width - fit_w) / 2.0 y += (height - fit_h) / 2.0 width, height = fit_w, fit_h return {"x": x, "y": y, "width": width, "height": height} def _visible_image_window(self) -> tuple[float, float, float, float]: """(src_x, src_y, vis_w, vis_h) of the visible image region, in image px. Mirrors the zoom/pan window in ``_imgToCanvas2d``. """ iw = float(self._state.get("image_width") or 1) ih = float(self._state.get("image_height") or 1) zoom = float(self._state.get("zoom") or 1.0) cx = float(self._state.get("center_x", 0.5)) cy = float(self._state.get("center_y", 0.5)) if zoom < 1.0: # Zoomed out: the whole image, drawn smaller and centred. The box # shrinks rather than the source window growing. return 0.0, 0.0, iw, ih vis_w, vis_h = iw / zoom, ih / zoom src_x = max(0.0, min(iw - vis_w, cx * iw - vis_w / 2.0)) src_y = max(0.0, min(ih - vis_h, cy * ih - vis_h / 2.0)) return src_x, src_y, vis_w, vis_h def data_to_display(self, points): """Convert data coordinates to CSS pixels within the panel. Parameters ---------- points : array-like A single ``(x, y)`` pair or an ``(N, 2)`` sequence of them. On a 1-D panel these are axis values. On a 2-D panel they are **image-pixel coordinates** — the same space marker ``offsets`` and widget positions use — where integer *i* means the *centre* of pixel *i*, not its leading edge. Returns ------- numpy.ndarray Same shape as *points*, in panel pixels with the origin at the panel's top-left corner and y increasing downwards. Zoom and pan are accounted for. """ import numpy as np 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}") box = self.plot_box() if self._state.get("image_width") and self._state.get("image_height"): zoom = float(self._state.get("zoom") or 1.0) src_x, src_y, vis_w, vis_h = self._visible_image_window() w = box["width"] * (zoom if zoom < 1.0 else 1.0) h = box["height"] * (zoom if zoom < 1.0 else 1.0) x0 = box["x"] + (box["width"] - w) / 2.0 y0 = box["y"] + (box["height"] - h) / 2.0 px = x0 + (pts[:, 0] + 0.5 - src_x) / vis_w * w py = y0 + (pts[:, 1] + 0.5 - src_y) / vis_h * h else: xlo, xhi = self.get_xlim() ylo, yhi = self.get_ylim() px = box["x"] + (pts[:, 0] - xlo) / ((xhi - xlo) or 1.0) * box["width"] # Screen y grows downwards, data y upwards. py = box["y"] + ( 1.0 - (pts[:, 1] - ylo) / ((yhi - ylo) or 1.0) ) * box["height"] out = np.column_stack([px, py]) return out if np.ndim(points) == 2 else out[0] def display_to_data(self, points): """Convert CSS pixels within the panel to data coordinates. The inverse of :meth:`data_to_display`; same argument shapes and the same per-panel-kind coordinate meaning. """ import numpy as np 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}") box = self.plot_box() if self._state.get("image_width") and self._state.get("image_height"): zoom = float(self._state.get("zoom") or 1.0) src_x, src_y, vis_w, vis_h = self._visible_image_window() w = box["width"] * (zoom if zoom < 1.0 else 1.0) h = box["height"] * (zoom if zoom < 1.0 else 1.0) x0 = box["x"] + (box["width"] - w) / 2.0 y0 = box["y"] + (box["height"] - h) / 2.0 dx = (pts[:, 0] - x0) / (w or 1.0) * vis_w + src_x - 0.5 dy = (pts[:, 1] - y0) / (h or 1.0) * vis_h + src_y - 0.5 else: xlo, xhi = self.get_xlim() ylo, yhi = self.get_ylim() dx = xlo + (pts[:, 0] - box["x"]) / (box["width"] or 1.0) \ * ((xhi - xlo) or 1.0) dy = ylo + ( 1.0 - (pts[:, 1] - box["y"]) / (box["height"] or 1.0) ) * ((yhi - ylo) or 1.0) out = np.column_stack([dx, dy]) return out if np.ndim(points) == 2 else out[0] def set_tick_label_size(self, size: float) -> None: """Set the font size of the tick (axis number) labels in CSS pixels. Applies to both axes of the panel. Default 10. Parameters ---------- size : float Tick label font size in pixels. """ self._state["tick_size"] = float(size) self._push() def set_ticks_visible(self, visible: bool, *, x: bool | None = None, y: bool | None = None) -> None: if x is None and y is None: self._state["x_ticks_visible"] = bool(visible) self._state["y_ticks_visible"] = bool(visible) else: if x is not None: self._state["x_ticks_visible"] = bool(x) if y is not None: self._state["y_ticks_visible"] = bool(y) self._push() def get_widget(self, wid): """Return the Widget object by ID string or Widget instance.""" from anyplotlib.widgets import Widget if isinstance(wid, Widget): wid = wid.id try: return self._widgets[wid] except KeyError: raise KeyError(wid) def remove_widget(self, wid) -> None: """Remove a widget by ID string or Widget instance.""" from anyplotlib.widgets import Widget if isinstance(wid, Widget): wid = wid.id if wid not in self._widgets: raise KeyError(wid) del self._widgets[wid] self._push() def list_widgets(self) -> list: """Return a list of all active widget objects on this panel.""" return list(self._widgets.values()) def clear_widgets(self) -> None: """Remove all interactive overlay widgets from this panel.""" self._widgets.clear() self._push() class _TextHandle: """Handle for a single text annotation created by :meth:`add_text`. Wraps the underlying single-text :class:`~anyplotlib.markers.MarkerGroup` and exposes label-oriented mutators (``set_text`` / ``set_color`` / ``remove``) so callers do not need to know it is backed by a texts marker collection. """ __slots__ = ("_group",) def __init__(self, group): self._group = group def set_text(self, s: str) -> None: """Replace the displayed string.""" self._group.set(texts=[s]) def set_color(self, color: str) -> None: """Change the text colour.""" self._group.set(color=color) def remove(self) -> None: """Remove the annotation from its panel.""" self._group.remove() def __repr__(self) -> str: # pragma: no cover texts = self._group._data.get("texts") or [""] return f"_TextHandle(text={texts[0]!r})" class _MarkerMixin: """Mixin for panels that support static marker collections. Shared by Plot1D and Plot2D. Subclasses must define: _state : dict markers : MarkerRegistry _push() -> None """ def _push_markers(self) -> None: self._state["markers"] = self.markers.to_wire_list() self._push() def _add_marker(self, mtype: str, name, **kwargs): return self.markers.add(mtype, name, **kwargs) def add_text(self, x, y, s, name=None, *, color="#ff0000", fontsize=12, transform="data", **kwargs) -> "_TextHandle": """Add a single text annotation at ``(x, y)``. A convenience wrapper over :meth:`add_texts` for the common single-label case (e.g. a navigation index or scale-bar label). The returned handle exposes ``set_text``, ``set_color`` and ``remove`` so callers can mutate the label after creation. Parameters ---------- x, y : float Anchor position. Interpreted in the coordinate system named by ``transform`` (``"data"``, ``"axes"``, or ``"display"``). s : str The text to display. name : str, optional Registry key. Auto-generated if omitted. color : str, optional Text colour. Default ``"#ff0000"``. fontsize : int, optional Font size in pixels. Default ``12``. transform : str, optional Coordinate system for ``(x, y)``. Default ``"data"``. **kwargs : dict Forwarded to :meth:`add_texts` (e.g. ``clip_display``). Returns ------- _TextHandle Handle wrapping the underlying single-text marker group. """ group = self.add_texts( offsets=[(x, y)], texts=[s], name=name, color=color, fontsize=fontsize, transform=transform, **kwargs, ) return _TextHandle(group) def remove_marker(self, marker_type: str, name: str) -> None: """Remove a named marker collection by type and name. Parameters ---------- marker_type : str Collection type, e.g. ``"points"``, ``"vlines"``. name : str The name used when the collection was created. """ self.markers.remove(marker_type, name) def clear_markers(self) -> None: """Remove all marker collections from this panel.""" self.markers.clear() def list_markers(self) -> list: """Return a summary list of all marker collections on this panel. Returns ------- list of dict Each dict has keys ``"type"``, ``"name"``, and ``"n"`` (number of markers in the collection). """ out = [] for mtype, td in self.markers._types.items(): for name, g in td.items(): out.append({"type": mtype, "name": name, "n": g._count()}) return out