From 7cd9b283a95f145856335c80c5df1baad5304c75 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Tue, 14 Jul 2026 09:29:38 -0500 Subject: [PATCH 1/6] docs: examples for twinx, GPU voxels, tile backends, new widgets & annotations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add gallery examples covering features that were implemented but undocumented, and export the two widgets that were missing from the top-level package. Examples (all run headlessly + drive the interactive Pyodide path): - PlotTypes/plot_tile_backend.py — custom TileBackend Protocol: pan/zoom a 4.3-gigapixel procedural fractal that is generated tile-by-tile and never held in memory. - PlotTypes/plot_twinx.py — secondary (right) y-axis via add_right_axis + add_line(axis="right") + set_right_ylim. - PlotTypes/plot_gpu_voxels.py — ax.voxels(gpu="auto") WebGPU path with a PlaneWidget slice selector and gpu_active readback. - PlotTypes/plot_step_and_log.py — step-mid linestyle + semilogy log y-axis. - Markers/plot_transform_markers.py — transform="axes"/"display" anchored markers that stay put on zoom. - Widgets/plot_widget2d_arrow.py, plot_widget1d_point.py, plot_widget3d_plane.py — the ArrowWidget, PointWidget and PlaneWidget. - Interactive/plot_figure_annotations.py — figure-level annotation layer (set_figure_markers) + edit_chrome drag mode + double-click to annotate. Also: - anyplotlib/__init__.py — export ArrowWidget and PointWidget (were only in anyplotlib.widgets, missing from the top-level package __all__). - docs/api/index.rst — add Line1D, ArrowWidget, PointWidget, PlaneWidget to the API reference autosummary. Claude-Session: https://claude.ai/code/session_018MLraeaoQBWFDScg72W41h --- .../Interactive/plot_figure_annotations.py | 87 ++++++++++++ Examples/Markers/plot_transform_markers.py | 49 +++++++ Examples/PlotTypes/plot_gpu_voxels.py | 82 +++++++++++ Examples/PlotTypes/plot_step_and_log.py | 43 ++++++ Examples/PlotTypes/plot_tile_backend.py | 127 ++++++++++++++++++ Examples/PlotTypes/plot_twinx.py | 45 +++++++ Examples/Widgets/plot_widget1d_point.py | 40 ++++++ Examples/Widgets/plot_widget2d_arrow.py | 41 ++++++ Examples/Widgets/plot_widget3d_plane.py | 47 +++++++ anyplotlib/__init__.py | 8 +- docs/api/index.rst | 4 + 11 files changed, 569 insertions(+), 4 deletions(-) create mode 100644 Examples/Interactive/plot_figure_annotations.py create mode 100644 Examples/Markers/plot_transform_markers.py create mode 100644 Examples/PlotTypes/plot_gpu_voxels.py create mode 100644 Examples/PlotTypes/plot_step_and_log.py create mode 100644 Examples/PlotTypes/plot_tile_backend.py create mode 100644 Examples/PlotTypes/plot_twinx.py create mode 100644 Examples/Widgets/plot_widget1d_point.py create mode 100644 Examples/Widgets/plot_widget2d_arrow.py create mode 100644 Examples/Widgets/plot_widget3d_plane.py diff --git a/Examples/Interactive/plot_figure_annotations.py b/Examples/Interactive/plot_figure_annotations.py new file mode 100644 index 00000000..e6b09c24 --- /dev/null +++ b/Examples/Interactive/plot_figure_annotations.py @@ -0,0 +1,87 @@ +""" +Interactive figure annotations — double-click to add, drag to place +=================================================================== + +anyplotlib has a *figure-level* annotation layer that floats above the panels, +positioned in **figure fractions** (0…1, origin top-left) rather than any +panel's data coordinates. Set it with +:meth:`~anyplotlib.Figure.set_figure_markers`; the supported kinds are +``"text"``, ``"circle"``, ``"rect"`` and ``"arrow"``. + +Turn on ``fig.edit_chrome`` and every annotation becomes draggable in the +browser. Combine that with a ``double_click`` handler and you get a simple +annotate-by-clicking workflow: double-click a feature to drop a labelled +arrow, then drag it to line it up. Positions round-trip back to Python via +:attr:`~anyplotlib.Figure.figure_markers`. +""" +import numpy as np +import anyplotlib as apl + +rng = np.random.default_rng(7) +data = rng.standard_normal((160, 160)).cumsum(0).cumsum(1) +data = (data - data.min()) / (data.max() - data.min()) + +fig, ax = apl.subplots(1, 1, figsize=(520, 520)) +v = ax.imshow(data, cmap="magma", units="px") + +# Enable the editable-annotation ("report builder") mode so figure markers are +# hit-testable and draggable, and the figure emits background/marker events. +fig.edit_chrome = True + +# %% +# Seed a couple of annotations +# ---------------------------- +# Each marker is a dict with a ``kind`` and fraction-space geometry. A text +# label and an arrow pointing into the image to start with. + +fig.set_figure_markers([ + {"kind": "text", "x": 0.5, "y": 0.06, + "text": "Double-click a feature to annotate it", + "color": "#ffffff", "fontsize": 14}, + {"kind": "arrow", "x": 0.20, "y": 0.30, "u": 0.12, "v": 0.12, + "color": "#ffd54f", "linewidth": 2}, +]) + +fig + +# %% +# Double-click to drop a new annotation +# ------------------------------------- +# On a single-panel figure the panel nearly fills the canvas, so we turn the +# click's device pixels into a figure fraction with the panel's +# ``display_width`` / ``display_height`` and append an arrow + label there. +# Because ``edit_chrome`` is on, the new marker is immediately draggable. + + +def _on_double_click(event): + if event.x is None or event.display_width is None: + return + fx = float(np.clip(event.x / event.display_width, 0.02, 0.98)) + fy = float(np.clip(event.y / event.display_height, 0.02, 0.98)) + markers = fig.figure_markers # current list (a copy) + n = sum(1 for m in markers if m["kind"] == "text") + markers.append({"kind": "arrow", "x": fx, "y": fy, + "u": 0.08, "v": -0.08, "color": "#40c4ff", "linewidth": 2}) + markers.append({"kind": "text", "x": fx + 0.08, "y": fy - 0.10, + "text": f"mark {n}", "color": "#40c4ff", "fontsize": 13}) + fig.set_figure_markers(markers) + + +v.add_event_handler(_on_double_click, "double_click") + +fig.set_help( + "Double-click on the image to drop a labelled arrow.\n" + "Drag any annotation to reposition it (edit mode is on)." +) + +fig # Interactive + +# %% +# Read the placements back +# ------------------------ +# After the user drags things around, ``fig.figure_markers`` reflects the +# current fraction positions — persist them, export them, or feed them into a +# report. + +for m in fig.figure_markers: + print(m["kind"], round(m["x"], 3), round(m["y"], 3)) diff --git a/Examples/Markers/plot_transform_markers.py b/Examples/Markers/plot_transform_markers.py new file mode 100644 index 00000000..6c17add6 --- /dev/null +++ b/Examples/Markers/plot_transform_markers.py @@ -0,0 +1,49 @@ +""" +Coordinate transforms — pin markers to the axes or the screen +============================================================= + +Every ``add_*`` marker method takes a ``transform`` that decides which +coordinate system its positions live in: + +- ``"data"`` (default) — data coordinates; the marker moves and scales with + zoom / pan, staying glued to the underlying data. +- ``"axes"`` — axes-normalised ``(0, 0)`` bottom-left … ``(1, 1)`` top-right; + the marker stays in the same corner of the panel no matter how you zoom. +- ``"display"`` — raw CSS pixels within the panel; a fixed-size decoration. + +The ``"axes"`` and ``"display"`` transforms are how you build overlays that +should *not* track the data — a navigation index in the corner, a scale bar, a +persistent legend chip. +""" +import numpy as np +import anyplotlib as apl + +rng = np.random.default_rng(1) +data = rng.standard_normal((128, 128)).cumsum(0).cumsum(1) +data = (data - data.min()) / (data.max() - data.min()) +xy = np.linspace(0, 10, 128) + +fig, ax = apl.subplots(1, 1, figsize=(480, 480)) +v = ax.imshow(data, axes=[xy, xy], units="nm") + +# Data-anchored label — sits at (5, 5) in nm and rides along on zoom/pan. +v.add_texts(offsets=[(5, 5)], texts=["feature @ (5, 5)"], + color="#ffffff", fontsize=12, name="data_label") + +# Axes-anchored index — stays pinned to the top-left corner of the panel +# regardless of zoom (0, 1 = top-left in axes fractions). +v.add_texts(offsets=[(0.04, 0.96)], texts=["frame 3 / 20"], + transform="axes", color="#ffd54f", fontsize=13, name="nav_index") + +fig + +# %% +# Try it +# ------ +# Zoom into the image: the white ``feature`` label moves with the data, while +# the yellow ``frame 3 / 20`` index stays locked to the corner — because it is +# positioned in ``"axes"`` coordinates. + +fig.set_help("Zoom in: the corner index stays put; the data label moves.") + +fig # Interactive diff --git a/Examples/PlotTypes/plot_gpu_voxels.py b/Examples/PlotTypes/plot_gpu_voxels.py new file mode 100644 index 00000000..720b333e --- /dev/null +++ b/Examples/PlotTypes/plot_gpu_voxels.py @@ -0,0 +1,82 @@ +""" +GPU-accelerated voxels +====================== + +:meth:`~anyplotlib.Axes.voxels` renders shaded translucent cubes for a +volumetric field. With ``gpu="auto"`` (the default) anyplotlib uses WebGPU +instancing when a GPU is available and the cube count is large, so hundreds of +thousands of voxels stay interactive; otherwise it falls back to the Canvas2D +path. Read :attr:`~anyplotlib.Plot3D.gpu_active` after the first frame to see +which path was chosen. + +Here we build a dense spherical shell — enough cubes that the WebGPU path +kicks in — and drop a draggable :class:`~anyplotlib.PlaneWidget` through it as +a slice selector. +""" +import numpy as np +import anyplotlib as apl + +# %% +# Build a volumetric field +# ------------------------ +# Voxel *centres* are passed as three flat coordinate arrays (not a dense 3-D +# grid), so you only send the cubes you actually want drawn. We keep the +# voxels inside a spherical shell and colour them by radius. + +N = 64 +g = np.arange(N) +Z, Y, X = np.meshgrid(g, g, g, indexing="ij") +r = np.sqrt((X - N / 2) ** 2 + (Y - N / 2) ** 2 + (Z - N / 2) ** 2) +shell = (r > N * 0.30) & (r < N * 0.42) # a hollow sphere + +xs, ys, zs = X[shell], Y[shell], Z[shell] +print(f"{xs.size:,} voxels") # tens of thousands → GPU path under gpu='auto' + +# Colour by radius with the viridis-ish default cycle mapped through intensity. +t = (r[shell] - r[shell].min()) / (np.ptp(r[shell]) + 1e-9) +colors = np.stack([0.2 + 0.8 * t, 0.4 * np.ones_like(t), 1.0 - 0.8 * t], axis=1) + +fig, ax = apl.subplots(1, 1, figsize=(560, 520)) +vol = ax.voxels( + xs, ys, zs, colors=colors, + size=1.0, alpha=0.35, + bounds=((0, N - 1),) * 3, + azimuth=-55, elevation=28, zoom=1.1, + gpu="auto", # WebGPU when available, Canvas2D otherwise +) +vol.set_title("Spherical shell — drag to rotate, scroll to zoom") + +# %% +# Add a slice-selector plane +# -------------------------- +# A :class:`~anyplotlib.PlaneWidget` is a draggable axis-aligned plane. Voxels +# lying on it render more opaque, so the current slice pops out of the +# translucent volume. Drag it along z in the browser, or move it from Python. + +plane = vol.add_widget("plane", axis="z", position=N // 2, + color="#40c4ff", alpha=0.18) + + +@plane.add_event_handler("pointer_move") +def _on_slice(event): + # Fires while the plane is dragged; pw.position holds the live slice index. + print("slice at z =", round(plane.position, 1)) + + +fig.set_help( + "Drag: rotate · Scroll: zoom · R: reset view\n" + "Drag the blue plane to slide the z-slice through the shell." +) + +fig # Interactive + +# %% +# Which render path ran? +# ---------------------- +# ``gpu_active`` is populated once the browser reports back after the first +# frame. It is ``True`` when the WebGPU instanced path is live, ``False`` on +# the Canvas2D fallback, and ``None`` before the first frame (as when this +# gallery page is built headlessly). Force a path with ``gpu=True`` / +# ``gpu=False`` if you need determinism. + +print("gpu_active:", vol.gpu_active) diff --git a/Examples/PlotTypes/plot_step_and_log.py b/Examples/PlotTypes/plot_step_and_log.py new file mode 100644 index 00000000..c13c682d --- /dev/null +++ b/Examples/PlotTypes/plot_step_and_log.py @@ -0,0 +1,43 @@ +""" +Step lines and log-scale spectra +================================ + +Two 1-D options that suit spectral data: a **mid-riser step** line (constant +within each bin, jumping at bin midpoints) via ``linestyle="step-mid"``, and a +**logarithmic y-axis** via :meth:`~anyplotlib.Axes.semilogy` (or +``ax.plot(..., yscale="log")``). +""" +import numpy as np +import anyplotlib as apl + +# A noisy binned spectrum. +rng = np.random.default_rng(0) +energy = np.linspace(0, 20, 60) +counts = (np.exp(-(energy - 6) ** 2 / 4) * 1000 + + np.exp(-(energy - 13) ** 2 / 8) * 400 + + rng.uniform(0, 20, energy.size)) + +# %% +# Step line +# --------- +# ``linestyle="step-mid"`` draws a horizontal segment centred on each x value +# with vertical risers between them — the standard way to show histogram-like +# spectra without implying interpolation between channels. + +fig, ax = apl.subplots(1, 1, figsize=(560, 340)) +ax.plot(counts, axes=[energy], color="#4fc3f7", + linestyle="step-mid", label="counts") + +fig + +# %% +# Log y-axis +# ---------- +# ``semilogy`` is shorthand for a log y-scale, which brings out the small +# secondary peak that the linear plot flattens. Combine it with the step line +# for a classic spectroscopy view. + +fig2, ax2 = apl.subplots(1, 1, figsize=(560, 340)) +ax2.semilogy(counts, axes=[energy], color="#ff7043", linestyle="step-mid") + +fig2 diff --git a/Examples/PlotTypes/plot_tile_backend.py b/Examples/PlotTypes/plot_tile_backend.py new file mode 100644 index 00000000..ada357ee --- /dev/null +++ b/Examples/PlotTypes/plot_tile_backend.py @@ -0,0 +1,127 @@ +""" +Custom Tile Backend — pan & zoom a huge image you never hold in memory +====================================================================== + +For a very large image, anyplotlib does not need the whole array. It asks a +:class:`~anyplotlib.plot2d._tile_backend.TileBackend` for a downsampled +*overview* to use as the base texture, and then — on every zoom / pan — for a +crisp *detail tile* of just the visible region at the panel's resolution. +anyplotlib owns that zoom/pan → re-tile lifecycle; the backend owns the data. + +A backend is any object implementing the ``TileBackend`` protocol: + +``full_shape`` (H, W), ``dtype``, ``origin``, ``extent()``, and +``sample(x0, x1, y0, y1, out_w, out_h, method)`` — return the logical region +``[y0:y1, x0:x1]`` resampled to ``(out_h, out_w)``. + +Because ``sample`` is called *on demand*, the source never has to exist in +full. Here we make a **procedural** backend that computes a Mandelbrot fractal +for whatever region is requested — a 65 536 × 65 536 "image" (4.3 gigapixels) +that is generated tile-by-tile as you explore it. The array is never +materialised; only the small tiles you look at are ever computed. +""" +import numpy as np +import anyplotlib as apl + +# %% +# A procedural tile backend +# ------------------------- +# The Mandelbrot escape count over a data-space window. The full logical +# image is ``SIZE x SIZE`` pixels mapping to the complex plane +# ``[-2.5, 1.0] x [-1.75, 1.75]``, but no pixel is computed until ``sample`` +# is called for it. + +SIZE = 65_536 # 65k x 65k logical pixels ≈ 4.3 gigapixels — never allocated +CX0, CX1 = -2.5, 1.0 +CY0, CY1 = -1.75, 1.75 + + +class MandelbrotTileBackend: + """A ``TileBackend`` that synthesises fractal tiles on demand.""" + + max_iter = 80 + + @property + def full_shape(self): + return (SIZE, SIZE) + + @property + def dtype(self): + return np.dtype("uint16") # escape counts + + @property + def origin(self): + return "lower" # row 0 at the bottom (matches the y data-axis below) + + def extent(self): + # Data-space (x0, x1, y0, y1) the image spans, or None for pixel + # coordinates. Return None here and set the coordinate axes explicitly + # on imshow (below) so the ticks read as real/imaginary values. + return None + + def sample(self, x0, x1, y0, y1, out_w, out_h, method="mean"): + # Only the requested window is ever evaluated, at exactly the output + # resolution the panel asked for — so a zoomed-in tile is as sharp as a + # zoomed-out overview is cheap. + re = CX0 + (np.linspace(x0, x1, out_w) / SIZE) * (CX1 - CX0) + im = CY0 + (np.linspace(y0, y1, out_h) / SIZE) * (CY1 - CY0) + C = re[np.newaxis, :] + 1j * im[:, np.newaxis] + Z = np.zeros_like(C) + counts = np.zeros(C.shape, dtype=np.uint16) + alive = np.ones(C.shape, dtype=bool) + for i in range(self.max_iter): + Z[alive] = Z[alive] * Z[alive] + C[alive] + escaped = alive & (np.abs(Z) > 2.0) + counts[escaped] = i + alive &= ~escaped + counts[alive] = self.max_iter + return counts + + +# %% +# Wire it into a plot +# ------------------- +# Pass the backend as ``tile_backend=``. The ``data`` argument is just a +# placeholder — the backend's ``full_shape`` / ``extent`` drive the axes, and +# ``tile=True`` forces the tiled path. Zoom and pan to stream sharp detail +# tiles; anyplotlib computes only what is on screen. + +backend = MandelbrotTileBackend() + +# Coordinate axes spanning the logical image, mapped to the complex plane so +# ticks read as real / imaginary values (the tiling still works in pixels). +x_axis = np.linspace(CX0, CX1, SIZE) +y_axis = np.linspace(CY0, CY1, SIZE) + +fig, ax = apl.subplots(1, 1, figsize=(560, 560)) +v = ax.imshow( + np.zeros((2, 2)), # placeholder; overview comes from backend.sample + axes=[x_axis, y_axis], + tile_backend=backend, + tile=True, + cmap="inferno", + units="Re / Im", +) +fig.set_help( + "Scroll to zoom, drag to pan.\n" + "Each view fetches a fresh detail tile — the 4.3 GP fractal is\n" + "generated on demand and never held in memory." +) + +fig # Interactive + +# %% +# Swap in a real source +# --------------------- +# The same protocol wraps any large source without changing the plot: back +# ``sample`` with a memory-mapped file, a dask/zarr chunk store, or a +# GPU-resident tensor. For an in-memory ndarray you don't need a custom class +# at all — :func:`~anyplotlib.plot2d._tile_backend.as_tile_backend` (used +# internally when you pass a plain array with ``tile="auto"``) wraps it in the +# default :class:`~anyplotlib.plot2d._tile_backend.NumpyTileBackend`. +# +# .. note:: +# +# ``sample`` should be reasonably fast — it runs once per view change. A +# slow source (network, disk) still works, but pan/zoom will feel as +# responsive as the slowest ``sample`` call. diff --git a/Examples/PlotTypes/plot_twinx.py b/Examples/PlotTypes/plot_twinx.py new file mode 100644 index 00000000..c8d1ffa2 --- /dev/null +++ b/Examples/PlotTypes/plot_twinx.py @@ -0,0 +1,45 @@ +""" +Twinned (secondary) y-axis +========================== + +Overlay two series that live on very different scales on a single +:class:`~anyplotlib.Plot1D` panel, each with its own y-axis. Enable the +right-hand axis with :meth:`~anyplotlib.Plot1D.add_right_axis`, then add curves +to it with ``add_line(..., axis="right")`` — they are scaled and labelled +independently of the left axis. +""" +import numpy as np +import anyplotlib as apl + +x = np.linspace(0, 10, 400) +signal = np.sin(x) # left axis: −1 … 1 +temperature = 300 + 350 * np.cos(x / 2) # right axis: ~ −50 … 650 + +fig, ax = apl.subplots(1, 1, figsize=(560, 360)) +plot = ax.plot(signal, axes=[x], color="#4fc3f7", label="signal") +plot.set_ylabel("Amplitude") + +# %% +# Add the secondary axis +# ---------------------- +# ``add_right_axis`` turns on the right-hand y-axis; ``axis="right"`` anchors +# the new curve to it. The left axis stays fixed at −1 … 1 while the right +# axis auto-scales to the temperature range. + +plot.add_right_axis(color="#e05a2b") +plot.add_line(temperature, x_axis=x, color="#e05a2b", + axis="right", label="temperature") +plot.set_right_ylabel("Temperature (K)") + +fig + +# %% +# Pin the secondary range +# ----------------------- +# By default the right axis auto-scales to its lines. Call +# :meth:`~anyplotlib.Plot1D.set_right_ylim` to fix it explicitly (for example +# to line two datasets up at a shared reference level). + +plot.set_right_ylim(0, 700) + +fig diff --git a/Examples/Widgets/plot_widget1d_point.py b/Examples/Widgets/plot_widget1d_point.py new file mode 100644 index 00000000..08a1568f --- /dev/null +++ b/Examples/Widgets/plot_widget1d_point.py @@ -0,0 +1,40 @@ +""" +1D Point Widget +=============== + +A free-moving ``(x, y)`` control point on a 1-D panel, with an optional +crosshair. Unlike the vertical / horizontal line widgets it moves in both +directions, so it is handy as a draggable data cursor or a control handle for +an interactive fit. Add it with :meth:`~anyplotlib.Plot1D.add_point_widget`. +""" +import numpy as np +import anyplotlib as apl + +x = np.linspace(0, 4 * np.pi, 400) +y = np.sin(x) * np.exp(-x / 12) + +fig, ax = apl.subplots(1, 1, figsize=(560, 340)) +plot = ax.plot(y, axes=[x], color="#4fc3f7") + +point = plot.add_point_widget(x=3.0, y=0.5, color="#ff1744", + show_crosshair=True) + +fig + +# %% +# Snap the point onto the curve +# ----------------------------- +# A ``pointer_move`` handler fires while the point is dragged. Here we read +# ``point.x``, look up the nearest sample, and push the point back onto the +# curve with :meth:`~anyplotlib.Widget.set` — a one-line "snap to data" cursor. + + +@point.add_event_handler("pointer_move") +def _snap(event): + i = int(np.clip(np.searchsorted(x, point.x), 0, len(x) - 1)) + point.set(y=float(y[i])) + + +fig.set_help("Drag the point — it snaps onto the curve as you move it.") + +fig # Interactive diff --git a/Examples/Widgets/plot_widget2d_arrow.py b/Examples/Widgets/plot_widget2d_arrow.py new file mode 100644 index 00000000..e8b9b1cc --- /dev/null +++ b/Examples/Widgets/plot_widget2d_arrow.py @@ -0,0 +1,41 @@ +""" +2D Arrow Widget +=============== + +A draggable arrow overlay on a 2-D image panel. The tail sits at ``(x, y)`` +and the head at ``(x + u, y + v)`` in data coordinates. Drag the body to move +the whole arrow; drag the head handle to re-aim it (updating ``u`` / ``v``). +Add it with :meth:`~anyplotlib.Plot2D.add_arrow_widget`. +""" +import numpy as np +import anyplotlib as apl + +rng = np.random.default_rng(3) +data = rng.standard_normal((128, 128)).cumsum(0).cumsum(1) +data = (data - data.min()) / (data.max() - data.min()) +xy = np.linspace(0, 10, 128) + +fig, ax = apl.subplots(1, 1, figsize=(460, 460)) +v = ax.imshow(data, axes=[xy, xy], units="nm") + +arrow = v.add_arrow_widget(x=2.0, y=2.0, u=5.0, v=4.0, + color="#ff1744", linewidth=2) + +fig + +# %% +# React to drags +# -------------- +# Register a ``pointer_move`` handler to read the live geometry while the arrow +# is dragged or re-aimed (there is no ``on_changed`` method — the event system +# is the API). Read ``arrow.x/y/u/v`` inside the handler. + + +@arrow.add_event_handler("pointer_move") +def _report(event): + print(f"tail=({arrow.x:.1f}, {arrow.y:.1f}) vector=({arrow.u:.1f}, {arrow.v:.1f})") + + +fig.set_help("Drag the arrow body to move it; drag the head to re-aim it.") + +fig # Interactive diff --git a/Examples/Widgets/plot_widget3d_plane.py b/Examples/Widgets/plot_widget3d_plane.py new file mode 100644 index 00000000..e291840e --- /dev/null +++ b/Examples/Widgets/plot_widget3d_plane.py @@ -0,0 +1,47 @@ +""" +3D Plane Widget +=============== + +A draggable axis-aligned plane in a 3-D panel, rendered as a translucent quad +spanning the panel's bounds. Drag it in the browser to slide it along its +normal — ideal as a slice selector through a volume or point cloud. Add it +with ``plot.add_widget("plane", axis=..., position=...)``. +""" +import numpy as np +import anyplotlib as apl + +# %% +# A point cloud to slice +# ---------------------- +# A Gaussian blob of points; the plane widget will pick out a z-slab. + +rng = np.random.default_rng(0) +n = 4000 +pts = rng.normal(0, 8, size=(n, 3)) + 24 # centred in a 0..48 box +xs, ys, zs = pts[:, 0], pts[:, 1], pts[:, 2] + +fig, ax = apl.subplots(1, 1, figsize=(520, 500)) +cloud = ax.scatter3d(xs, ys, zs, color="#4fc3f7", point_size=3, + x_label="x", y_label="y", z_label="z") + +plane = cloud.add_widget("plane", axis="z", position=24, + color="#40c4ff", alpha=0.15) + +fig + +# %% +# Read the slice position on drag +# ------------------------------- +# ``pointer_move`` fires as the plane slides; ``plane.position`` is the current +# location along its normal. You can also move it from Python with +# ``plane.set(position=...)``. + + +@plane.add_event_handler("pointer_move") +def _on_drag(event): + print("z-slice at", round(plane.position, 1)) + + +fig.set_help("Drag the blue plane along z to move the slice.") + +fig # Interactive diff --git a/anyplotlib/__init__.py b/anyplotlib/__init__.py index c7d2dd6d..baf4f545 100644 --- a/anyplotlib/__init__.py +++ b/anyplotlib/__init__.py @@ -17,8 +17,8 @@ from anyplotlib.markers import MarkerRegistry, MarkerGroup from anyplotlib.widgets import ( Widget, RectangleWidget, CircleWidget, AnnularWidget, - CrosshairWidget, PolygonWidget, LabelWidget, - VLineWidget, HLineWidget, RangeWidget, PlaneWidget, + CrosshairWidget, PolygonWidget, LabelWidget, ArrowWidget, + VLineWidget, HLineWidget, RangeWidget, PointWidget, PlaneWidget, ) # ── Global help flag ────────────────────────────────────────────────────── @@ -45,8 +45,8 @@ def get_color_cycle() -> list[str]: "CallbackRegistry", "Event", "MarkerRegistry", "MarkerGroup", "Widget", "RectangleWidget", "CircleWidget", "AnnularWidget", - "CrosshairWidget", "PolygonWidget", "LabelWidget", - "VLineWidget", "HLineWidget", "RangeWidget", "PlaneWidget", + "CrosshairWidget", "PolygonWidget", "LabelWidget", "ArrowWidget", + "VLineWidget", "HLineWidget", "RangeWidget", "PointWidget", "PlaneWidget", "show_help", "get_color_cycle", "embed", "__version__", diff --git a/docs/api/index.rst b/docs/api/index.rst index 6391393d..a999aa0a 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -105,6 +105,7 @@ Axes & Plots Axes Plot1D + Line1D Plot2D PlotMesh Plot3D @@ -155,9 +156,12 @@ Interactive Widgets CrosshairWidget PolygonWidget LabelWidget + ArrowWidget VLineWidget HLineWidget RangeWidget + PointWidget + PlaneWidget Callbacks From d3d7a87b012a53b894c384529ba387bbd7cadbbf Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Tue, 14 Jul 2026 10:09:42 -0500 Subject: [PATCH 2/6] docs(tile_backend): make the Mandelbrot sample ~3-8x faster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the example backend's escape loop for speed (it runs once per pan/zoom, so responsiveness depends on it): - real/imag float32 arithmetic instead of complex128 (half the memory traffic, no complex-multiply overhead); - escape test |z|^2 > 4 instead of abs(z) > 2 (no sqrt); - no shrinking boolean mask — count iterations each pixel stays inside via `counts += inside` (bool→uint add); escaped pixels run to inf harmlessly (inf fails the <=4 test) inside an np.errstate guard. Measured: overview 800x600 ~600ms -> ~200ms (3x); a deep-zoom tile ~870ms -> ~106ms (8x) — the zoom case is where the old masked loop was worst, since nothing escapes early so the mask never shrank. Output is bit-identical in structure (corr 1.0). Claude-Session: https://claude.ai/code/session_018MLraeaoQBWFDScg72W41h --- Examples/PlotTypes/plot_tile_backend.py | 59 +++++++++++++++++++------ 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/Examples/PlotTypes/plot_tile_backend.py b/Examples/PlotTypes/plot_tile_backend.py index ada357ee..16435685 100644 --- a/Examples/PlotTypes/plot_tile_backend.py +++ b/Examples/PlotTypes/plot_tile_backend.py @@ -62,19 +62,52 @@ def extent(self): def sample(self, x0, x1, y0, y1, out_w, out_h, method="mean"): # Only the requested window is ever evaluated, at exactly the output # resolution the panel asked for — so a zoomed-in tile is as sharp as a - # zoomed-out overview is cheap. - re = CX0 + (np.linspace(x0, x1, out_w) / SIZE) * (CX1 - CX0) - im = CY0 + (np.linspace(y0, y1, out_h) / SIZE) * (CY1 - CY0) - C = re[np.newaxis, :] + 1j * im[:, np.newaxis] - Z = np.zeros_like(C) - counts = np.zeros(C.shape, dtype=np.uint16) - alive = np.ones(C.shape, dtype=bool) - for i in range(self.max_iter): - Z[alive] = Z[alive] * Z[alive] + C[alive] - escaped = alive & (np.abs(Z) > 2.0) - counts[escaped] = i - alive &= ~escaped - counts[alive] = self.max_iter + # zoomed-out overview is cheap. The escape loop is written for speed + # (it runs once per pan/zoom), using three tricks over the textbook + # version: + # * real / imag ``float32`` arithmetic instead of complex128 — half + # the memory traffic and no complex-multiply overhead; + # * the escape test is ``|z|² > 4`` (``zr² + zi²``), avoiding the + # ``sqrt`` in ``abs``; + # * no shrinking boolean mask. Instead we ``counts += inside`` every + # iteration (a cheap bool→uint add): each pixel's count is simply + # how many steps it stayed inside. Escaped pixels keep iterating to + # ``inf``, which is harmless — ``inf > 4`` just keeps them counted + # out. This is ~3× faster on the overview and ~8× faster on a + # deep-zoom tile (where nothing escapes early, so a mask never + # shrinks and the old code crawled). + cr = (CX0 + (np.linspace(x0, x1, out_w, dtype=np.float32) / SIZE) + * (CX1 - CX0))[np.newaxis, :] + ci = (CY0 + (np.linspace(y0, y1, out_h, dtype=np.float32) / SIZE) + * (CY1 - CY0))[:, np.newaxis] + cr = np.ascontiguousarray(np.broadcast_to(cr, (out_h, out_w))) + ci = np.ascontiguousarray(np.broadcast_to(ci, (out_h, out_w))) + + zr = np.zeros((out_h, out_w), np.float32) + zi = np.zeros((out_h, out_w), np.float32) + counts = np.zeros((out_h, out_w), np.uint16) + zr2 = np.empty_like(zr) + zi2 = np.empty_like(zr) + tmp = np.empty_like(zr) + # Escaped pixels run off to ±inf; the inf−inf / inf·0 they produce is + # expected and never touches `counts` (an escaped pixel already fails + # the `<= 4` test), so silence the overflow/invalid warnings. + with np.errstate(over="ignore", invalid="ignore"): + for i in range(self.max_iter): + np.multiply(zr, zr, out=zr2) + np.multiply(zi, zi, out=zi2) + inside = (zr2 + zi2) <= 4.0 + counts += inside # still-inside pixels tick up + # z ← z² + c (in real / imag form; the shared temporaries + # above keep this allocation-free inside the loop). + np.multiply(zr, zi, out=tmp) + tmp *= 2.0 + tmp += ci # zi_next = 2·zr·zi + ci + np.subtract(zr2, zi2, out=zr) + zr += cr # zr_next = zr² − zi² + cr + zi, tmp = tmp, zi # swap in the new zi + if i % 16 == 15 and not inside.any(): + break # whole tile escaped — done return counts From 43f5cb36d14247d574e5c12902e2129b0c6a68c2 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Tue, 14 Jul 2026 10:38:11 -0500 Subject: [PATCH 3/6] docs(tile_backend): make GPU use explicit and observable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The example already qualified for the WebGPU image path (gpu_mode="auto" + a 4.3-gigapixel logical size, far above the ~1 MP threshold; tiling does not disable the GPU — _gpuDraw2dImage stitches the crisp detail tile over the GPU-composited overview texture). But nothing said so, and headless renders (the static gallery thumbnail, CI) have no navigator.gpu and fall back to Canvas2D, so it read as "not using the GPU". Make it explicit and checkable: - pass gpu="auto" on imshow; - document that tiling + the GPU cooperate and why 4.3 GP crosses the auto threshold, and that headless builds fall back to Canvas2D; - print v.gpu_active so the live "Run in browser" view confirms the path (gpu_status is an internal echo, not a user event — read the property). Claude-Session: https://claude.ai/code/session_018MLraeaoQBWFDScg72W41h --- Examples/PlotTypes/plot_tile_backend.py | 30 ++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/Examples/PlotTypes/plot_tile_backend.py b/Examples/PlotTypes/plot_tile_backend.py index 16435685..cf298c80 100644 --- a/Examples/PlotTypes/plot_tile_backend.py +++ b/Examples/PlotTypes/plot_tile_backend.py @@ -115,9 +115,20 @@ def sample(self, x0, x1, y0, y1, out_w, out_h, method="mean"): # Wire it into a plot # ------------------- # Pass the backend as ``tile_backend=``. The ``data`` argument is just a -# placeholder — the backend's ``full_shape`` / ``extent`` drive the axes, and -# ``tile=True`` forces the tiled path. Zoom and pan to stream sharp detail -# tiles; anyplotlib computes only what is on screen. +# placeholder — the backend's ``full_shape`` drives the axes, and ``tile=True`` +# forces the tiled path. Zoom and pan to stream sharp detail tiles; anyplotlib +# computes only what is on screen. +# +# **Tiling and the GPU cooperate.** With ``gpu="auto"`` (the default) and a +# WebGPU-capable browser, the downsampled overview is uploaded once as a GPU +# texture and drawn by the shader-side colormap, and the crisp detail tile for +# the visible region is stitched on top of it each frame. The logical image +# here is 4.3 gigapixels, which is far above the ~1-megapixel threshold that +# switches the WebGPU image path on under ``"auto"`` — so the base raster is +# GPU-composited rather than blitted on the CPU. (Headless/off-screen renders, +# like this gallery's static thumbnail, have no WebGPU and fall back to +# Canvas2D; the live "Run in browser" version above uses the GPU when your +# browser supports it.) backend = MandelbrotTileBackend() @@ -132,6 +143,7 @@ def sample(self, x0, x1, y0, y1, out_w, out_h, method="mean"): axes=[x_axis, y_axis], tile_backend=backend, tile=True, + gpu="auto", # WebGPU texture path when available (see above) cmap="inferno", units="Re / Im", ) @@ -143,6 +155,18 @@ def sample(self, x0, x1, y0, y1, out_w, out_h, method="mean"): fig # Interactive +# %% +# Is the GPU actually painting? +# ----------------------------- +# Read :attr:`~anyplotlib.Plot2D.gpu_active` to see which path ran. anyplotlib +# updates it automatically once the browser reports back after the first frame: +# ``True`` when the WebGPU image path is live, ``False`` on the Canvas2D +# fallback. It is ``False`` here because the gallery build is headless (no +# WebGPU); open the live "Run in browser" version above and read ``v.gpu_active`` +# to confirm the accelerated path on your own machine. + +print("gpu_active:", v.gpu_active) + # %% # Swap in a real source # --------------------- From 418d0027b2ce8f5373efa293c7c234ea203c038c Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Tue, 14 Jul 2026 10:58:48 -0500 Subject: [PATCH 4/6] docs: silence the API reference build warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes so the docs build is warning-clean: - Line1D was added to the indexed autosummary in a previous commit, which duplicated its object descriptions against the plot classes' Full Reference block. Document it the same way as the other plot classes instead: list it in figure_plots.rst and autoclass it there with :no-index: (the convention the file already uses), and drop it from the index.rst autosummary. Removes the "duplicate object description of anyplotlib.Line1D.id" warning. - Event.x/.y (pixel fields) and Plot1D.x/.y (data properties) share short names, so autodoc reported "more than one target found for cross-reference 'x'/'y'" — a pre-existing, harmless ambiguity (each is documented on its own page). Add a scoped `suppress_warnings = ["ref.python"]` with a comment; nitpicky stays off so genuinely missing refs still fail. Verified: sphinx-build now reports "build succeeded." with no warnings. Claude-Session: https://claude.ai/code/session_018MLraeaoQBWFDScg72W41h --- docs/api/figure_plots.rst | 7 +++++++ docs/api/index.rst | 1 - docs/conf.py | 9 +++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/api/figure_plots.rst b/docs/api/figure_plots.rst index 6e48db68..09a6513a 100644 --- a/docs/api/figure_plots.rst +++ b/docs/api/figure_plots.rst @@ -18,6 +18,7 @@ Axes, Plots & Layout :nosignatures: Plot1D + Line1D Plot2D PlotMesh Plot3D @@ -51,6 +52,12 @@ Axes, Plots & Layout :member-order: bysource :no-index: +.. autoclass:: anyplotlib.plot1d.Line1D + :members: + :show-inheritance: + :member-order: bysource + :no-index: + .. autoclass:: anyplotlib.plot2d.Plot2D :members: :show-inheritance: diff --git a/docs/api/index.rst b/docs/api/index.rst index a999aa0a..266d80a5 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -105,7 +105,6 @@ Axes & Plots Axes Plot1D - Line1D Plot2D PlotMesh Plot3D diff --git a/docs/conf.py b/docs/conf.py index cd7502c4..d1a86c73 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,6 +50,15 @@ napoleon_use_param = True napoleon_use_rtype = True +# Several classes legitimately share short member names — e.g. an ``Event`` +# carries ``x``/``y`` pixel fields while ``Plot1D`` exposes ``x``/``y`` data +# properties. Autodoc then reports "more than one target found for +# cross-reference 'x'" for the bare names. These are unambiguous in context +# (each is documented on its own class page) and harmless, so quiet just the +# ambiguous-reference warning. Genuinely *missing* refs still fail because +# ``nitpicky`` stays off and this only touches the ``ref.python`` resolver. +suppress_warnings = ["ref.python"] + intersphinx_mapping = { "python": ("https://docs.python.org/3", None), "numpy": ("https://numpy.org/doc/stable", None), From c98891fc502f5cd0af9d4861128241b2fd099a90 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Tue, 14 Jul 2026 11:20:49 -0500 Subject: [PATCH 5/6] feat(voxels): lower the WebGPU auto threshold 8000 -> 1000 cubes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Voxel cubes cost ~6x a scatter point on Canvas2D, and interactive volumes re-slice (set_data on every plane drag), so the whole set re-rasterises per frame. A few thousand cubes already stutter on the CPU path, yet the old "auto" threshold (8000) kept them on Canvas2D. Drop it to 1000 so realistic interactive volumes take the GPU instanced path by default. This makes the voxel grain explorer example (a ~6.9k-cube slice that re-slices on drag) GPU-accelerated under plain gpu="auto" — previously it sat just under 8000 and crawled. Reverted the per-example gpu="always" workaround now that "auto" does the right thing. Updated the two ax.voxels() docstrings (~8k -> ~1k). scatter's threshold (20000) is unchanged. plot3d GPU tests still pass (they exercise always/off/fallback, not the auto cutoff). Claude-Session: https://claude.ai/code/session_018MLraeaoQBWFDScg72W41h --- Examples/Interactive/plot_voxel_grain_explorer.py | 2 ++ anyplotlib/axes/_axes.py | 4 ++-- anyplotlib/figure_esm.js | 6 +++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Examples/Interactive/plot_voxel_grain_explorer.py b/Examples/Interactive/plot_voxel_grain_explorer.py index 47f73fb9..f26e245a 100644 --- a/Examples/Interactive/plot_voxel_grain_explorer.py +++ b/Examples/Interactive/plot_voxel_grain_explorer.py @@ -140,6 +140,8 @@ def slice_voxels(ix, iy, iz): size=VOXSIZE, alpha=0.55, x_label="x", y_label="y", z_label="z", bounds=((0, N - 1),) * 3, zoom=1.1, + # gpu="auto" (default): ~7k cubes is over the ~1k voxel threshold, so the + # WebGPU instanced path handles each drag re-slice when WebGPU is present. ) v_vol.set_title("Grain volume — drag a plane to re-slice") diff --git a/anyplotlib/axes/_axes.py b/anyplotlib/axes/_axes.py index a8e1f86a..e7ab23aa 100644 --- a/anyplotlib/axes/_axes.py +++ b/anyplotlib/axes/_axes.py @@ -202,7 +202,7 @@ def voxels(self, x, y, z, *, selected slice pops out of the translucent volume. **Large volumes** With WebGPU (``gpu="auto"``, the default, active - above ~8k cubes when a GPU is present) hundreds of thousands of + above ~1k cubes when a GPU is present) hundreds of thousands of voxels render interactively via instancing. On the Canvas2D fallback the budget is ~20k cubes (~3–6 µs each); a warning is emitted above that *only when* ``gpu=False``. For volumes too large @@ -230,7 +230,7 @@ def voxels(self, x, y, z, *, Fix the axes bounds instead of fitting them to the data. gpu : ``"auto"`` | bool, optional WebGPU acceleration policy. ``"auto"`` (default) renders cubes - on the GPU when available and the set exceeds ~8k; ``True`` always + on the GPU when available and the set exceeds ~1k; ``True`` always attempts GPU; ``False`` forces Canvas2D. Falls back silently when WebGPU is unavailable — see :attr:`Plot3D.gpu_active`. diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index c42ce02c..544a44a1 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -3079,7 +3079,11 @@ function render({ model, el, onResize }) { // GPU; draw3d draws them over a transparent plotCanvas when GPU is active. // ═══════════════════════════════════════════════════════════════════════ const GPU_POINT_THRESHOLD = 20000; - const GPU_VOXEL_THRESHOLD = 8000; // cubes cost ~6× a point on canvas + const GPU_VOXEL_THRESHOLD = 1000; // cubes cost ~6× a point on canvas, and + // re-slicing (set_data on drag) redraws + // them every frame, so switch to the GPU + // early — a few thousand cubes already + // stutter on Canvas2D. // A 2-D scalar image goes to the GPU (shader-LUT colormap on a texture) above // this many pixels — below it the Canvas2D atob+LUT loop is already instant. // ~1 megapixel: a 1024² image and up (a large in-situ movie frame is 16-64 Mpx). From b4f795331117c298ff223559cde9bf5b153c4dff Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Tue, 14 Jul 2026 11:36:01 -0500 Subject: [PATCH 6/6] docs(voxel_grain_explorer): halve the volume (N 48 -> 24) for Pyodide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The explorer re-slices voxels + re-cuts three 2D image panels on every plane drag, which is fine natively but sluggish under Pyodide. Halving N in each dimension quarters both the per-frame on-plane voxel count (6912 -> 1728) and each 2D slice (48² -> 24²), so dragging stays responsive in the browser. Still above the ~1k voxel threshold, so the WebGPU render path is retained. Comments updated for the new counts. Claude-Session: https://claude.ai/code/session_018MLraeaoQBWFDScg72W41h --- Examples/Interactive/plot_voxel_grain_explorer.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Examples/Interactive/plot_voxel_grain_explorer.py b/Examples/Interactive/plot_voxel_grain_explorer.py index f26e245a..8cc1a413 100644 --- a/Examples/Interactive/plot_voxel_grain_explorer.py +++ b/Examples/Interactive/plot_voxel_grain_explorer.py @@ -29,7 +29,7 @@ rng = np.random.default_rng(11) # ── 1. Synthetic 3-D polycrystal: nearest-seed voxel grain map ────────────── -N = 48 # volume is N³ voxels, indexed V[z, y, x] +N = 24 # volume is N³ voxels, indexed V[z, y, x] N_GRAINS = 40 seeds = rng.uniform(0, N, size=(N_GRAINS, 3)) # (z, y, x) @@ -76,7 +76,8 @@ def random_rotations(n): # planes. This anchors the highlight exactly where the slices intersect, # shows real slice contents in 3-D, and scales: the on-plane count is # ~3·(N/step)² regardless of N, so it stays fast even for a 256³ volume. -VSTEP = max(1, N // 48) # in-plane downsample → ~48² cubes per plane +VSTEP = max(1, N // 48) # in-plane downsample, capping at ~48² cubes/plane + # for large N (no downsampling at this N=24) # Voxel cube size in data units. A touch larger than VSTEP so the three # slabs read as solid sheets rather than a dotted grid. @@ -140,7 +141,7 @@ def slice_voxels(ix, iy, iz): size=VOXSIZE, alpha=0.55, x_label="x", y_label="y", z_label="z", bounds=((0, N - 1),) * 3, zoom=1.1, - # gpu="auto" (default): ~7k cubes is over the ~1k voxel threshold, so the + # gpu="auto" (default): ~1.7k cubes is over the ~1k voxel threshold, so the # WebGPU instanced path handles each drag re-slice when WebGPU is present. ) v_vol.set_title("Grain volume — drag a plane to re-slice")