Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions Examples/PlotTypes/plot_aspect_ratio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""
Aspect Ratio Control
====================

:meth:`~anyplotlib.Plot2D.set_aspect` constrains the displayed image canvas so
that one unit on the x-axis occupies the same number of pixels as one unit on
the y-axis.

Three modes are shown:

1. **Default** — the image fills the panel exactly (no constraint).
2. ``set_aspect("equal")`` — forces a 1 : 1 pixel-per-unit ratio. A square
array is displayed as a square image regardless of panel dimensions.
3. ``set_aspect(2.0)`` — makes the canvas twice as wide as it is tall.

The scale bar, axis ticks, and colorbar all update automatically when the
canvas is resized by ``set_aspect``.
"""
import numpy as np
import anyplotlib as apl

rng = np.random.default_rng(0)

# ── Synthetic calibrated image ────────────────────────────────────────────────
N = 64
x = np.linspace(0, 10, N) # nm
y = np.linspace(0, 10, N)
XX, YY = np.meshgrid(x, y)
data = (np.sin(XX) * np.cos(YY) + 0.2 * rng.standard_normal((N, N))).astype(np.float32)

# ── 1. Default (no aspect constraint) ────────────────────────────────────────
# %%
# Default — no aspect constraint
# --------------------------------
# The image stretches to fill the full panel area.

fig1, ax1 = apl.subplots(1, 1, figsize=(420, 340))
plot1 = ax1.imshow(data, axes=[x, y], units="nm", cmap="viridis")
plot1.set_title("Default (no constraint)")
plot1.set_xlabel("x (nm)")
plot1.set_ylabel("y (nm)")

fig1 # Interactive

# ── 2. Equal aspect ratio ─────────────────────────────────────────────────────
# %%
# Equal aspect ratio
# ------------------
# ``set_aspect("equal")`` (or equivalently ``set_aspect(1.0)``) ensures that
# one nm on the x-axis occupies the same number of canvas pixels as one nm on
# the y-axis. The canvas height adjusts to be equal to the canvas width.

fig2, ax2 = apl.subplots(1, 1, figsize=(420, 340))
plot2 = ax2.imshow(data, axes=[x, y], units="nm", cmap="viridis")
plot2.set_aspect("equal")
plot2.set_title("set_aspect('equal')")
plot2.set_xlabel("x (nm)")
plot2.set_ylabel("y (nm)")

fig2 # Interactive

# ── 3. Explicit ratio ─────────────────────────────────────────────────────────
# %%
# Explicit ratio (2 : 1)
# -----------------------
# ``set_aspect(2.0)`` makes the canvas twice as wide as it is tall. Useful
# when the physical x and y scales differ (e.g. a time-series scan where one
# axis is faster than the other).

fig3, ax3 = apl.subplots(1, 1, figsize=(420, 340))
plot3 = ax3.imshow(data, axes=[x, y], units="nm", cmap="inferno")
plot3.set_aspect(2.0)
plot3.set_title("set_aspect(2.0)")
plot3.set_xlabel("x (nm)")
plot3.set_ylabel("y (nm)")

fig3 # Interactive
90 changes: 90 additions & 0 deletions Examples/PlotTypes/plot_subplots_adjust.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""
Subplot Spacing with subplots_adjust
=====================================

:meth:`~anyplotlib.Figure.subplots_adjust` controls the gap between panels in
a multi-panel figure.

* **hspace** — vertical gap as a fraction of the mean row height.
``hspace=0.15`` adds 15 % of the average row height as space between rows.
* **wspace** — horizontal gap as a fraction of the mean column width.
``wspace=0.10`` adds 10 % of the average column width as space between
columns.

Both values default to ``0.0`` (panels are flush with no gap).

The examples below compare default flush spacing against adjusted spacing on
the same 2 × 2 grid.
"""
import numpy as np
import anyplotlib as apl

rng = np.random.default_rng(42)
t = np.linspace(0, 2 * np.pi, 512)

COLORS = ["#4fc3f7", "#ff7043", "#aed581", "#ffd54f"]
LABELS = ["α", "β", "γ", "δ"]
SIGNALS = [
np.sin(t * (i + 1)) + rng.normal(scale=0.08, size=len(t))
for i in range(4)
]


def _fill_grid(fig):
"""Add four 1-D spectra to a 2×2 Figure."""
import anyplotlib as _apl
gs = _apl.GridSpec(2, 2)
positions = [(0, 0), (0, 1), (1, 0), (1, 1)]
for idx, (r, c) in enumerate(positions):
ax = fig.add_subplot(gs[r, c])
ax.plot(SIGNALS[idx], color=COLORS[idx], label=LABELS[idx])


# ── 1. Default spacing (flush) ────────────────────────────────────────────────
# %%
# Default spacing — panels flush
# --------------------------------
# Without calling ``subplots_adjust`` the panels abut each other with no gap.

fig1 = apl.Figure(2, 2, figsize=(680, 480))
_fill_grid(fig1)

fig1 # Interactive

# ── 2. Vertical gap only (hspace) ────────────────────────────────────────────
# %%
# Vertical gap only — hspace=0.15
# ---------------------------------
# ``subplots_adjust(hspace=0.15)`` inserts a vertical gap equal to 15 % of
# the mean row height between the two rows.

fig2 = apl.Figure(2, 2, figsize=(680, 480))
_fill_grid(fig2)
fig2.subplots_adjust(hspace=0.15)

fig2 # Interactive

# ── 3. Horizontal gap only (wspace) ──────────────────────────────────────────
# %%
# Horizontal gap only — wspace=0.10
# -----------------------------------
# ``subplots_adjust(wspace=0.10)`` inserts a horizontal gap equal to 10 % of
# the mean column width between the two columns.

fig3 = apl.Figure(2, 2, figsize=(680, 480))
_fill_grid(fig3)
fig3.subplots_adjust(wspace=0.10)

fig3 # Interactive

# ── 4. Both gaps ─────────────────────────────────────────────────────────────
# %%
# Both hspace and wspace
# -----------------------
# Combine both arguments to add space in both directions.

fig4 = apl.Figure(2, 2, figsize=(680, 480))
_fill_grid(fig4)
fig4.subplots_adjust(hspace=0.15, wspace=0.10)

fig4 # Interactive
37 changes: 36 additions & 1 deletion anyplotlib/FIGURE_ESM.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ Rule 5 – Text never clips. Optional gutters earn real layout space:
| `drawOverlay2d` / `drawMarkers2d` | 3395 / 3559 |
| **Image layers**: `_layerBytes` / `_layerBitmap` / `_drawLayers2d` | 2533 / 2557 / 2618 |
| Binary-bytes splice: `_spliceBinaryBytes` / `_registerBinaryPixelListeners` | 730 / 761 |
| **Hover readout**: `_pixelValue2d` / `_readoutInfo2d` | 3942 / 4024 |
| `_notifyReadout` / `_updateStatus2d` / `_armValueProbe` | 4064 / 4079 / 4100 |
| **3D drawing**: `draw3d` | 5298 |
| Event emission `_emitEvent` | 6135 |
| 3D event handlers `_attachEvents3d` | 6187 |
Expand Down Expand Up @@ -259,7 +261,9 @@ Key state fields:
```
st.image_b64, st.image_width/height
st.zoom, st.center_x/y
st.display_min/max, st.raw_min/max, st.scale_mode
st.display_min/max, st.raw_min/max, st.raw_is_int, st.scale_mode
st.detail_b64, st.detail_region/width/height/seq, st.detail_min/max/is_int
st.readout_visible, st.probe_ms, st.probe_x/probe_y/probe_value
st.colormap_data [[r,g,b], ...] × 256
st.x_axis, st.y_axis, st.axis_visible
st.markers, st.overlay_widgets, st.overlay_mask_b64/_color/_alpha
Expand All @@ -281,6 +285,37 @@ Zoom model: at `zoom=1` the whole image fills the fit-rect; at `zoom=Z>1` a
`1/Z` region fills it. `_imgToCanvas2d` / `_canvasToImg2d` must stay exact
inverses of the blit geometry.

### Hover readout

Visibility: `st.readout_visible` (Python, authoritative) AND `p.readoutHidden` (the
viewer's `v` key — kept off the state so a per-frame push can't undo it).

`_updateStatus2d(p)` composes it from `p.mouseX/mouseY` and is called from BOTH the
`mousemove` handler in `_attachEvents2d` and the `change:panel_<id>_json` observer —
the latter because a probe answer landing under a stationary cursor has no mousemove
to piggyback on. It writes `p.statusBar` (unless `st.readout_visible === false`) and
hands `_readoutInfo2d`'s payload to the host via `_notifyReadout`
(`mount()`'s `opts.onReadout` + an `apl:readout` CustomEvent bubbling off `el`), so an
embedding app can render the readout in its own chrome with the pill switched off.

Content: physical `x:`/`y:` in `st.units`, the pixel index `[ix, iy]`, and the pixel
VALUE — `v:<value>` for a scalar image, `rgb:r,g,b` for `st.is_rgb`.

Value precision, in order of preference:

1. **`st.probe_value`** — the exact value Python answered for `st.probe_x/probe_y`.
`_armValueProbe` emits a `value_probe` event after the cursor dwells `st.probe_ms`
on a pixel (once per pixel, never per move); `Plot2D._answer_value_probe` answers from
the array/backend. Ignored unless it matches the pixel now under the cursor.
2. **Inverted codes** — with `raw_is_int`/`detail_is_int` and a band spanning ≤ 255
levels, `_codeToValue` recovers the exact integer from the byte (see its comment).
3. **Quantised estimate** — `raw_min + code/255*(raw_max-raw_min)`, resolving the
band to `range/255`. This is what a kernel-less page (`save_html`) always shows.

`_pixelValue2d` reads the codes from the SAME bytes the blit draws — including the
detail tile's native pixels above `zoom=1`, where the base is a downsampled overview
— so the readout always names the source actually on screen.

---

## Image layers (multi-image overlay)
Expand Down
14 changes: 14 additions & 0 deletions anyplotlib/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,20 @@ def _image_to_data_url(image) -> str:
return "data:image/png;base64," + base64.b64encode(png).decode("ascii")


def _codes_are_int(data: np.ndarray) -> bool:
"""True when *data* holds integers, so the uint8 codes it quantises to can be
inverted back to the EXACT source values.

The renderer needs this for its hover readout: with an integral source and a
range that fits in 255 levels, each code's quantisation interval is narrower
than 1 and so contains exactly ONE integer — the original value (see
``_codeToValue`` in ``figure_esm.js``, which checks the range part). Float data
gets no such guarantee, hence the dtype test rather than a value scan: probing
4 M pixels for integrality on every frame would cost more than the readout.
"""
return bool(np.issubdtype(np.asarray(data).dtype, np.integer))


def _normalize_image(data: np.ndarray, clim: "tuple | None" = None):
"""Normalise data to uint8, returning (img_u8, vmin, vmax) where vmin/vmax are
the QUANTISATION endpoints the 8-bit codes span (the caller stores them as
Expand Down
10 changes: 8 additions & 2 deletions anyplotlib/axes/_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ def imshow(self, data,
tile: "str | bool" = "auto",
integration_method: str = "mean",
overview_method: str = "mean",
tile_backend=None) -> "Plot2D":
tile_backend=None,
probe_exact: bool = True) -> "Plot2D":
"""Attach a 2-D image to this axes cell.

Parameters
Expand All @@ -71,6 +72,11 @@ def imshow(self, data,
puts row 0 at the top, matching the usual image convention.
``"lower"`` puts row 0 at the bottom, matching the matplotlib
convention for matrices / scientific plots.
probe_exact : bool, optional
Whether the hover readout asks Python for the EXACT value once the
cursor dwells on a pixel (default ``True``). Without it the value
comes from the 8-bit codes the renderer holds, which quantises wide
dtypes to ``range/255``. See :meth:`~anyplotlib.Plot2D.set_value_probe`.

Returns
-------
Expand All @@ -82,7 +88,7 @@ def imshow(self, data,
cmap=cmap, vmin=vmin, vmax=vmax, origin=origin, gpu=gpu,
tile=tile, integration_method=integration_method,
overview_method=overview_method,
tile_backend=tile_backend)
tile_backend=tile_backend, probe_exact=probe_exact)
self._attach(plot)
return plot

Expand Down
4 changes: 4 additions & 0 deletions anyplotlib/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class Event:
button — 0=left 1=middle 2=right; None on move/enter/leave/settled
buttons — bitmask of currently held buttons
xdata, ydata — data-space coordinates (None for Plot3D)
img_x, img_y — Plot2D/PlotMesh: position in IMAGE PIXELS (column, row; row 0 at the top, origin already applied), so a handler can index the source array directly. None on other plot types.
ray — Plot3D only: {"origin": [...], "direction": [...]}
line_id — Plot1D only: set when pointer is over a line
dwell_ms — pointer_settled only: actual dwell time
Expand Down Expand Up @@ -80,6 +81,9 @@ class Event:
buttons: int = 0
xdata: float | None = None
ydata: float | None = None
# Image-pixel position (2-D panels): column/row into the displayed frame.
img_x: float | None = None
img_y: float | None = None
ray: dict | None = None
line_id: str | None = None
dwell_ms: float | None = None
Expand Down
12 changes: 12 additions & 0 deletions anyplotlib/figure/_figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,16 @@ def _dispatch_event(self, raw: str) -> None:
plot._set_gpu_active(bool(msg.get("gpu_active", False)))
return

# Hover-readout probe: the renderer wants the EXACT value under a pixel the
# cursor dwelled on (its own readout comes from 8-bit codes). Answered
# directly and NOT fired through the callback registry — this is renderer
# plumbing, so a user's wildcard handler must not see it and pause_events()
# must not stall the readout. Same treatment as gpu_status above.
if event_type == "value_probe":
if hasattr(plot, "_answer_value_probe"):
plot._answer_value_probe(msg)
return

source = None
if widget_id and hasattr(plot, "_widgets"):
widget = plot._widgets.get(widget_id)
Expand All @@ -671,6 +681,8 @@ def _dispatch_event(self, raw: str) -> None:
buttons=msg.get("buttons", 0),
xdata=msg.get("xdata"),
ydata=msg.get("ydata"),
img_x=msg.get("img_x"),
img_y=msg.get("img_y"),
ray=msg.get("ray"),
line_id=msg.get("line_id"),
dwell_ms=msg.get("dwell_ms"),
Expand Down
Loading
Loading