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/Interactive/plot_voxel_grain_explorer.py b/Examples/Interactive/plot_voxel_grain_explorer.py index 47f73fb9..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,6 +141,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): ~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") 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..cf298c80 --- /dev/null +++ b/Examples/PlotTypes/plot_tile_backend.py @@ -0,0 +1,184 @@ +""" +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. 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 + + +# %% +# Wire it into a plot +# ------------------- +# Pass the backend as ``tile_backend=``. The ``data`` argument is just a +# 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() + +# 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, + gpu="auto", # WebGPU texture path when available (see above) + 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 + +# %% +# 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 +# --------------------- +# 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/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). 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 6391393d..266d80a5 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -155,9 +155,12 @@ Interactive Widgets CrosshairWidget PolygonWidget LabelWidget + ArrowWidget VLineWidget HLineWidget RangeWidget + PointWidget + PlaneWidget Callbacks 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),