Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
09aca04
feat: add pointer_settled dwell timer to JS — zero cost when unused, …
CSSFrancis May 17, 2026
2dc0724
docs: add event system redesign spec
CSSFrancis May 14, 2026
3938d95
docs: add event system implementation plan
CSSFrancis May 14, 2026
7123d6d
refactor: flatten Event dataclass — all payload fields are typed top-…
CSSFrancis May 14, 2026
4263db6
test: add stop_propagation repr test and dx/dy assertions to TestEvent
CSSFrancis May 15, 2026
3db9043
refactor: rewrite CallbackRegistry with priority, wildcard, disconnec…
CSSFrancis May 15, 2026
0513bb1
feat: add pause_events and hold_events context managers to CallbackRe…
CSSFrancis May 15, 2026
b03ac75
feat: add _EventMixin with add_event_handler, remove_handler, pause/h…
CSSFrancis May 15, 2026
a39a604
refactor: update _dispatch_event and Widget._update_from_js to use fl…
CSSFrancis May 15, 2026
1897254
refactor: Plot1D/2D/3D/Bar and PlotMesh adopt _EventMixin, remove old…
CSSFrancis May 15, 2026
c6dbaa6
refactor: Widget adopts _EventMixin, remove old on_changed/on_release…
CSSFrancis May 15, 2026
5c98bbd
fix: remove on_line_click/on_line_hover from VALID_EVENT_TYPES; use p…
CSSFrancis May 15, 2026
2586ee3
fix: update inset tests to use renamed inset_state_change event type
CSSFrancis May 15, 2026
bcfc06a
feat: JS forwards pointer_up, pointer_enter/leave, double_click, whee…
CSSFrancis May 15, 2026
9bcf129
fix: remove duplicate mouseleave and dblclick listeners in _attachEve…
CSSFrancis May 15, 2026
36b9c0b
fix: correct button null guard in _pointerFields, fix key_down x/y un…
CSSFrancis May 17, 2026
bf5962b
feat: add pointer_settled dwell timer to JS — zero cost when unused, …
CSSFrancis May 17, 2026
e95190e
fix: snapshot performance.now() once in pointer_settled callback; cle…
CSSFrancis May 17, 2026
1cd0c12
test: add Playwright tests for pointer events, pointer_settled, and p…
CSSFrancis May 17, 2026
439d941
fix: add button=0 assertion to double_click test; fix 3d no-xdata tes…
CSSFrancis May 17, 2026
e54a04a
refactor: extract shared event test helpers; fix 3d no-pointer_down p…
CSSFrancis May 17, 2026
744f296
test: add regression tests confirming old event API removed; update E…
CSSFrancis May 17, 2026
f28dc25
refactor: replace event.data dict access with widget attribute access…
CSSFrancis May 17, 2026
7aa4ad9
fix: use event.source.x in widget handlers; remove duplicate regressi…
CSSFrancis May 18, 2026
11198eb
fix: delete dead Line1D.on_hover shim; add regression tests for Line1D
CSSFrancis May 18, 2026
ae060ff
fix: replace event.img_x/img_y with event.xdata/ydata in Examples
CSSFrancis May 18, 2026
2a12394
fix: _pointerFields always null button; pointer_down/up explicitly se…
CSSFrancis May 18, 2026
744515c
fix: PlotBar pointer_down on mousedown (was click); emit bar_index: n…
CSSFrancis May 18, 2026
540ebb7
refactor. Removed plans
CSSFrancis May 18, 2026
25f6161
fix: address PR review comments — last_widget_id field, x/y float typ…
CSSFrancis May 18, 2026
e62ca55
docs: add events.rst — event system guide with Matplotlib/pygfx compa…
CSSFrancis May 18, 2026
3e92f32
test: fix flaky test_fires_again_after_re_settle — poll with wait_for…
CSSFrancis May 18, 2026
98a1c21
feat: add plot_particle_picker.py EM interactive example
CSSFrancis May 18, 2026
0f32a30
feat: add plot_eels_explorer.py EM interactive example
CSSFrancis May 18, 2026
08c910a
feat: add plot_threshold_explorer.py EM interactive example
CSSFrancis May 18, 2026
dbe5bd9
feat: add plot_roi_inspector.py EM interactive example
CSSFrancis May 18, 2026
993ba53
test: add smoke tests for interactive EM example scripts
CSSFrancis May 18, 2026
6f831ee
fix: add xdata/ydata None guards in all EM example event handlers
CSSFrancis May 18, 2026
3bca3c2
feat: implement auto-sync for Figure dimensions based on GridSpec
CSSFrancis May 20, 2026
44503c2
feat: add examples and test baselines for GridSpec layouts for multi-…
CSSFrancis May 20, 2026
c67b7ed
refactor: enhance documentation and interaction descriptions in EELS …
CSSFrancis May 20, 2026
0a5b826
Refactor: Update and clean ROI-to-spectrum inspector for 3-D EDS hype…
CSSFrancis May 20, 2026
189a4ea
Refactor: Update and clean ROI-to-spectrum inspector for 3-D EDS hype…
CSSFrancis May 20, 2026
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
30 changes: 17 additions & 13 deletions Examples/Interactive/plot_3d_spectral_viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,25 +121,25 @@ def _snap_rect(x_raw, y_raw):


def _wire_crosshair(w):
"""Register on_changed: update spectrum on every drag frame."""
@w.on_changed
"""Register pointer_move handler: update spectrum on every drag frame."""
@w.add_event_handler("pointer_move")
def _ch_moved(event):
cx = int(np.clip(round(event.data.get("cx", CX0)), 0, NX - 1))
cy = int(np.clip(round(event.data.get("cy", CY0)), 0, NY - 1))
cx = int(np.clip(round(event.source.cx), 0, NX - 1))
cy = int(np.clip(round(event.source.cy), 0, NY - 1))
v_spec.set_data(data[cy, cx, :].astype(float), x_axis=energy)


def _wire_rectangle(w):
"""Register on_changed: snap widget to grid, integrate 8×8 region live."""
@w.on_changed
"""Register pointer_move handler: snap widget to grid, integrate 8×8 region live."""
@w.add_event_handler("pointer_move")
def _rect_moved(event):
if _syncing[0]:
return
_syncing[0] = True
try:
x0, y0 = _snap_rect(
event.data.get("x", CX0 - ROI_PX // 2),
event.data.get("y", CY0 - ROI_PX // 2),
event.source.x,
event.source.y,
)
# Push snapped, fixed-size position back so the widget visually
# snaps to the pixel grid and stays exactly 8×8.
Expand All @@ -160,8 +160,10 @@ def _rect_moved(event):


# ── "i" — toggle crosshair ↔ 8×8 rectangle ─────────────────────────────────
@v_img.on_key('i')
@v_img.add_event_handler("key_down")
def _toggle_roi(event):
if event.key != 'i':
return
cur = wid[0]
v_img.remove_widget(cur) # remove old widget (Python ref still valid)

Expand Down Expand Up @@ -197,19 +199,21 @@ def _toggle_roi(event):


# ── "s" (spectrum panel) — add / remove energy-span filter ──────────────────
@v_spec.on_key('s')
@v_spec.add_event_handler("key_down")
def _toggle_span(event):
if event.key != 's':
return
if span_wid[0] is None:
# Place span at 35 %–65 % of the energy range by default
e0 = float(energy[int(NE * 0.35)])
e1 = float(energy[int(NE * 0.65)])
sw = v_spec.add_range_widget(x0=e0, x1=e1, color="#ff7043")
span_wid[0] = sw

@sw.on_release
@sw.add_event_handler("pointer_up")
def _span_released(ev):
x0_e = ev.data.get("x0", float(energy[0]))
x1_e = ev.data.get("x1", float(energy[-1]))
x0_e = ev.source.x0
x1_e = ev.source.x1
if x0_e > x1_e:
x0_e, x1_e = x1_e, x0_e
mask = (energy >= x0_e) & (energy <= x1_e)
Expand Down
211 changes: 211 additions & 0 deletions Examples/Interactive/plot_eels_explorer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
"""
EELS multi-spectrum explorer.
==============================

Five synthetic EELS spectra (Carbon-rich, Nitride, Oxide, Silicide,
Mixed) stacked vertically on a single axis, each with known
characteristic edges and a power-law background.

**Interaction**

* **Click** a spectrum line — selects it (full opacity; others dim to
25 %).
* **Dwell 250 ms** — shows eV position and intensity; nearby known
edges (C K, N K, O K, Ti L) are annotated.
* **Double-click** — places a permanent vertical edge marker on the
active spectrum.
* **Delete / Backspace** — removes the most recent marker on the
active spectrum.
* **Tab / Shift+Tab** — cycles the selection forward / backward.
"""
import numpy as np
import anyplotlib as apl


# ── synthetic data ─────────────────────────────────────────────────────────────

ENERGY = np.linspace(50, 650, 1200)

KNOWN_EDGES = {"C K": 284.0, "N K": 401.0, "O K": 532.0, "Ti L": 456.0}

_SPECTRUM_DEFS = [
{"name": "Carbon-rich", "color": "#4fc3f7", "edges": [("C K", 284, 0.6)]},
{"name": "Nitride", "color": "#aed581", "edges": [("N K", 401, 0.5)]},
{"name": "Oxide", "color": "#ff8a65", "edges": [("O K", 532, 0.7)]},
{"name": "Silicide", "color": "#ba68c8", "edges": [("Si L", 99, 0.3)]},
{"name": "Mixed", "color": "#fff176", "edges": [("C K", 284, 0.2), ("O K", 532, 0.15)]},
]


def _power_law_bg(E, A=1e4, r=3.5):
return A * E ** (-r)


def _edge_onset(E, edge_ev, amplitude, width=20.0, decay=80.0):
onset = amplitude * (np.arctan((E - edge_ev) / (width / 6)) / np.pi + 0.5)
envelope = np.exp(-np.clip(E - edge_ev, 0, None) / decay)
return onset * envelope


def _make_spectrum(rng, defn, offset_y):
E = ENERGY
y = _power_law_bg(E)
for _, edge_ev, amp_frac in defn["edges"]:
peak = y.max() * amp_frac
y += _edge_onset(E, edge_ev, peak)
y += rng.normal(0, y.max() * 0.005, size=len(E))
y = np.clip(y, 0, None)
y = y / y.max()
return y + offset_y


rng = np.random.default_rng(7)
spectra_y = []
offset = 0.0
for defn in _SPECTRUM_DEFS:
y = _make_spectrum(rng, defn, offset)
spectra_y.append(y)
offset += 1.2 * (y - offset).max()


# ── helpers ────────────────────────────────────────────────────────────────────

def _safe_remove(plot, marker_type: str, name: str) -> None:
try:
plot.remove_marker(marker_type, name)
except KeyError:
pass


# ── figure ─────────────────────────────────────────────────────────────────────

# spectrum 0 is the primary line; spectra 1-4 are overlay lines
fig, ax = apl.subplots(1, 1, figsize=(800, 500))
plot = ax.plot(spectra_y[0], axes=[ENERGY], color=_SPECTRUM_DEFS[0]["color"], linewidth=2.5)

# overlay_lines[i] is the Line1D handle for spectrum i (None for the primary)
overlay_lines = []
for i in range(1, len(_SPECTRUM_DEFS)):
defn = _SPECTRUM_DEFS[i]
line = plot.add_line(spectra_y[i], x_axis=ENERGY, color=defn["color"], linewidth=1.0)
overlay_lines.append(line)

# spectra index → Line1D (or None for primary)
# lines[0] == None means "primary line", lines[1..] == Line1D handles
line_handles = [None] + overlay_lines # len == len(_SPECTRUM_DEFS)

active_idx: int = 0
markers_per_spectrum: list[list[str]] = [[] for _ in _SPECTRUM_DEFS]
_marker_counter = [0]

info_label_mg = plot.add_texts(
offsets=np.array([[ENERGY[600], spectra_y[0][600]]]),
texts=[""],
name="info_label",
color="#00e5ff",
fontsize=11,
)


# ── selection helpers ───────────────────────────────────────────────────────────

def _set_overlay_line_props(lid: str, linewidth: float, alpha: float) -> None:
"""Directly mutate an overlay line's entry in plot._state and push."""
for entry in plot._state["extra_lines"]:
if entry["id"] == lid:
entry["linewidth"] = float(linewidth)
entry["alpha"] = float(alpha)
break
plot._push()


def _apply_selection(new_idx: int) -> None:
global active_idx
active_idx = new_idx
for i, handle in enumerate(line_handles):
if i == active_idx:
lw, alpha = 2.5, 1.0
else:
lw, alpha = 1.0, 0.25
if handle is None:
# primary line — use Plot1D setters
plot.set_linewidth(lw)
plot.set_alpha(alpha)
else:
_set_overlay_line_props(handle._lid, lw, alpha)
print(f"Selected: {_SPECTRUM_DEFS[active_idx]['name']}")


_apply_selection(0)


# ── event handlers ─────────────────────────────────────────────────────────────

def _make_line_handler(idx: int):
def _handler(event) -> None:
_apply_selection(idx)
return _handler


# primary line click handler — line_id is None for the primary
plot.line.add_event_handler(_make_line_handler(0), "pointer_down")

# overlay line click handlers
for i, handle in enumerate(overlay_lines, start=1):
handle.add_event_handler(_make_line_handler(i), "pointer_down")


def _on_settled(event) -> None:
if event.xdata is None:
return
ev = event.xdata
intensity = float(np.interp(ev, ENERGY, spectra_y[active_idx]))
label = f"eV: {ev:.1f} I: {intensity:.3f}"
for edge_name, edge_ev in KNOWN_EDGES.items():
if abs(ev - edge_ev) < 15:
label += f"\n~ {edge_name}-edge"
y_pos = intensity + 0.05
plot.markers["texts"]["info_label"].set(
offsets=np.array([[ev, y_pos]]),
texts=[label],
)


def _on_double_click(event) -> None:
ev = event.xdata
_marker_counter[0] += 1
name = f"edge_{active_idx}_{_marker_counter[0]}"
plot.add_vlines([ev], name=name)
markers_per_spectrum[active_idx].append(name)
print(f"Edge marker placed at {ev:.1f} eV on '{_SPECTRUM_DEFS[active_idx]['name']}'")


def _on_key(event) -> None:
global active_idx
if event.key in ("Delete", "Backspace"):
if not markers_per_spectrum[active_idx]:
return
name = markers_per_spectrum[active_idx].pop()
_safe_remove(plot, "vlines", name)
elif event.key == "Tab":
n = len(_SPECTRUM_DEFS)
if "shift" in event.modifiers:
new_idx = (active_idx - 1) % n
else:
new_idx = (active_idx + 1) % n
_apply_selection(new_idx)


plot.add_event_handler(_on_settled, "pointer_settled", ms=250)
plot.add_event_handler(_on_double_click, "double_click")
plot.add_event_handler(_on_key, "key_down")

fig.set_help(
"Click a spectrum: select it\n"
"Dwell 250 ms: inspect eV + intensity\n"
"Double-click: place edge marker\n"
"Delete / Backspace: remove last marker\n"
"Tab / Shift+Tab: cycle selection"
)

fig # interactive
31 changes: 15 additions & 16 deletions Examples/Interactive/plot_interactive_fft.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,13 @@
* The left panel shows a synthetic real-space image (a periodic lattice with
noise, similar to an atomic-resolution STEM image).
* A yellow rectangle widget marks the region-of-interest (ROI).
* Whenever the ROI is moved or resized the :meth:`~anyplotlib.plot2d.Plot2D.on_release`
callback re-computes ``numpy.fft.fft2`` on the cropped pixels, applies a
Hann window to reduce edge ringing, takes the log-magnitude, and pushes the
result into the right panel with
:meth:`~anyplotlib.plot2d.Plot2D.update`.
* A second :meth:`~anyplotlib.plot2d.Plot2D.on_change` callback updates
a lightweight text readout (ROI size in pixels) on every drag frame without
re-running the FFT.
* Whenever the ROI is moved or resized the ``pointer_up`` event handler
re-computes ``numpy.fft.fft2`` on the cropped pixels, applies a Hann
window to reduce edge ringing, takes the log-magnitude, and pushes the
result into the right panel with :meth:`~anyplotlib.plot2d.Plot2D.update`.
* A second ``pointer_move`` event handler updates a lightweight text
readout (ROI size in pixels) on every drag frame without re-running
the FFT.

**Interaction**

Expand All @@ -26,8 +25,8 @@
* The FFT panel refreshes automatically on mouse-release.

.. note::
The ``on_release`` / ``on_change`` callbacks are pure Python — no kernel
restart is needed after editing them.
The ``pointer_up`` / ``pointer_move`` event handlers are pure Python —
no kernel restart is needed after editing them.
"""

import numpy as np
Expand Down Expand Up @@ -147,7 +146,7 @@ def _compute_fft(img_full, x0, y0, w, h):

# ── Callbacks ─────────────────────────────────────────────────────────────────

@wid.on_changed
@wid.add_event_handler("pointer_move")
def _roi_dragging(event):
"""Fires on every drag frame — highlight rectangle while dragging."""
# Cheaply pulse the widget colour to give live drag feedback.
Expand All @@ -158,13 +157,13 @@ def _roi_dragging(event):
v_real._push()


@wid.on_release
@wid.add_event_handler("pointer_up")
def _roi_released(event):
"""Fires once on mouse-up — recompute and push the full FFT."""
x0 = event.data.get("x", roi_x0)
y0 = event.data.get("y", roi_y0)
w = event.data.get("w", ROI_W)
h = event.data.get("h", ROI_H)
x0 = event.source.x
y0 = event.source.y
w = event.source.w
h = event.source.h

# Restore widget colour to yellow
for widget in v_real._state["overlay_widgets"]:
Expand Down
16 changes: 9 additions & 7 deletions Examples/Interactive/plot_interactive_fitting.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,14 +125,14 @@ def toggle(self):
self._active = True

def _wire(self):
@self._pt.on_changed
@self._pt.add_event_handler("pointer_move")
def _peak_moved(event):
if self._syncing:
return
self._syncing = True
try:
self.amp = event.data["y"]
self.mu = event.data["x"]
self.amp = event.source.y
self.mu = event.source.x
self._rng_w.set(x0=self.mu - self.sigma * _FWHM_K,
x1=self.mu + self.sigma * _FWHM_K,
y=self.amp / 2.0)
Expand All @@ -142,13 +142,13 @@ def _peak_moved(event):
finally:
self._syncing = False

@self._rng_w.on_changed
@self._rng_w.add_event_handler("pointer_move")
def _range_moved(event):
if self._syncing:
return
self._syncing = True
try:
x0, x1 = event.data["x0"], event.data["x1"]
x0, x1 = event.source.x0, event.source.x1
self.mu = (x0 + x1) / 2.0
self.sigma = abs(x1 - x0) / (2.0 * _FWHM_K)
self._pt.set(x=self.mu)
Expand Down Expand Up @@ -281,14 +281,16 @@ def _model_fn(x, *params):

# ── Key binding — press 'f' to fit ─────────────────────────────────────────

@plot.on_key('f')
@plot.add_event_handler("key_down")
def _on_fit(event):
if event.key != 'f':
return
model.fit()

# ── Click handlers — toggle widgets per component ─────────────────────────

for comp, line in zip(components, comp_lines):
@line.on_click
@line.add_event_handler("pointer_down")
def _clicked(event, c=comp):
c.toggle()

Expand Down
Loading
Loading