Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
549b776
ENH: add vertical marker in plot evoked that listens to timechange
Gnefil Jul 4, 2026
6a8101a
ENH: Implement test for plot evoked timechange subscriber
Gnefil Jul 7, 2026
b0d9e29
ENH: Document timechange listening behaviour
Gnefil Jul 7, 2026
41933f5
ENH: include non-selectable channels and improve logic
Gnefil Jul 7, 2026
6029f36
BUG: Fix empty click after span select bug
Gnefil Jul 7, 2026
f8b7524
ENH: add publish timechange behaviour to plot_evoked
Gnefil Jul 7, 2026
d695aa3
BUG: Fix spatial colors not defaulted to auto bug
Gnefil Jul 7, 2026
dfb08a1
BUG: Add comments for the fix
Gnefil Jul 8, 2026
d7d09cc
ENH: Fix publish only when click is in axes
Gnefil Jul 8, 2026
5a216cd
ENH: Test implemented functions and pass
Gnefil Jul 8, 2026
c9ec1f9
ENH: Document publish behaviour in figure too
Gnefil Jul 8, 2026
7d1dd9e
ENH: Add changelogs
Gnefil Jul 8, 2026
8c6f9db
ENH: Swap the order of docstring sections
Gnefil Jul 9, 2026
fb2acc9
ENH: Improve code style
Gnefil Jul 9, 2026
2d82e6c
Update doc/changes/dev/14025.bugfix.rst
Gnefil Jul 14, 2026
255d956
ENH: Add hover effect for time selection
Gnefil Jul 16, 2026
14e5de9
ENH: Remove existing channel pick behaviour and implement hover for c…
Gnefil Jul 17, 2026
e2c46d8
FIX: Add back butterfly auxiliary functions because mne.viz.ica._plot…
Gnefil Jul 17, 2026
93cfdfd
FIX: Clear path effect on hover release
Gnefil Jul 18, 2026
991f13f
ENH: Add test to validate hover and press behaviour
Gnefil Jul 18, 2026
9cf4faa
DOC: Update changelog
Gnefil Jul 18, 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
2 changes: 2 additions & 0 deletions doc/changes/dev/14025.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Fix ``spatial_colors`` is not ``"auto"`` as described by docstring in :func:`mne.viz.plot_evoked`.
Fix empty topomap created when clicking after time range selection in :func:`mne.viz.plot_evoked`, by `Lifeng Qiu Lin`_.
1 change: 1 addition & 0 deletions doc/changes/dev/14025.newfeature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add ``TimeChange`` behaviour for :func:`mne.viz.plot_evoked`, where vertical time cursor is previewed on hover and marked on click. Also change previous channel names pop-up on click to on hover, by `Lifeng Qiu Lin`_.
103 changes: 88 additions & 15 deletions mne/viz/evoked.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
_set_contour_locator,
plot_topomap,
)
from .ui_events import TimeChange, publish, subscribe
from .utils import (
DraggableColorbar,
_check_cov,
Expand Down Expand Up @@ -111,6 +112,11 @@ def _butterfly_on_button_press(event, params):
event.canvas.draw()
params["need_draw"] = False

for ax in params["axes"]:
if event.inaxes is ax:
publish(ax.figure, TimeChange(time=event.xdata))
break


def _line_plot_onselect(
xmin,
Expand All @@ -132,6 +138,9 @@ def _line_plot_onselect(
ch_types = [type_ for type_ in ch_types if type_ in ("eeg", "grad", "mag")]
if len(ch_types) == 0:
raise ValueError("Interactive topomaps only allowed for EEG and MEG channels.")
# First click after SpanSelector triggers this again, so reject when zero-width span
if xmin == xmax:
return
if (
"grad" in ch_types
and len(_pair_grad_sensors(info, topomap_coords=False, raise_error=False)) < 2
Expand Down Expand Up @@ -625,20 +634,64 @@ def _plot_lines(
selectables[type_idx] = False

if selectable:
# Parameters for butterfly interactive plots
params = dict(
axes=axes,
texts=texts,
lines=lines,
ch_names=info["ch_names"],
idxs=idxs,
need_draw=False,
path_effects=path_effects,
)
fig.canvas.mpl_connect("pick_event", partial(_butterfly_onpick, params=params))
fig.canvas.mpl_connect(
"button_press_event", partial(_butterfly_on_button_press, params=params)
)

def _on_hover(event):
if not event.inaxes:
return

# pop up channel name on hover
ax = event.inaxes
ax_idx = np.where([ax is a for a in axes])[0]
if len(ax_idx): # do nothing if ax is used instead
ax_idx = ax_idx[0]
text = texts[ax_idx]
hovered = None
for line_idx, line in enumerate(lines[ax_idx]):
hit, details = line.contains(event)
if hit:
hovered = (line_idx, line, details["ind"][0])
break # stop recursion at first hit
if hovered is not None:
line_idx, line, ind = hovered
ch_name = info["ch_names"][idxs[ax_idx][line_idx]]
text.set_position((line.get_xdata()[ind], line.get_ydata()[ind]))
text.set_text(ch_name)
text.set_color(line.get_color())
text.set_alpha(1.0)
text.set_zorder(
len(ax.lines)
) # to make sure it goes on top of the lines
text.set_path_effects(path_effects)
else:
text.set_alpha(0.0)
text.set_path_effects([])

# vertical line to indicate time point
for ax in axes:
line = getattr(ax, "_cursorline", None)
if line is None:
ax._cursorline = ax.axvline(event.xdata, color="black", alpha=0.2)
else:
line.set_xdata([event.xdata, event.xdata])
ax.figure.canvas.draw_idle()

def _rm_cursor(event):
for ax in axes:
if ax._cursorline is not None:
ax._cursorline.remove()
ax._cursorline = None
ax.figure.canvas.draw_idle()

def _select_time(event):
for ax in axes:
if event.inaxes is ax:
publish(ax.figure, TimeChange(time=event.xdata))
break

fig.canvas.mpl_connect("motion_notify_event", _on_hover)
fig.canvas.mpl_connect("figure_leave_event", _rm_cursor)
fig.canvas.mpl_connect("button_press_event", _select_time)

for ai, (ax, this_type) in enumerate(zip(axes, ch_types_used)):
line_list = list() # 'line_list' contains the lines for this axes
if unit is False:
Expand Down Expand Up @@ -849,6 +902,18 @@ def _plot_lines(
props=dict(alpha=0.5, facecolor="red"),
)

def on_time_change(event):
"""Respond to a time change UI event."""
for ax in axes:
line = getattr(ax, "_selectline", None)
if line is None:
ax._selectline = ax.axvline(event.time, color="black", alpha=1)
else:
line.set_xdata([event.time, event.time])
ax.figure.canvas.draw()

subscribe(fig, "time_change", on_time_change)


def _add_nave(ax, nave):
"""Add nave to axes."""
Expand Down Expand Up @@ -986,7 +1051,7 @@ def plot_evoked(
axes=None,
gfp=False,
window_title=None,
spatial_colors=False,
spatial_colors="auto",
zorder="unsorted",
selectable=True,
noise_cov=None,
Expand Down Expand Up @@ -1114,6 +1179,14 @@ def plot_evoked(
See Also
--------
mne.viz.plot_evoked_white

Notes
-----
The figure will publish and subscribe to the following UI events:

* :class:`~mne.viz.ui_events.TimeChange`

.. versionadded:: 1.13.0
""" # noqa: E501
return _plot_evoked(
evoked=evoked,
Expand Down
55 changes: 54 additions & 1 deletion mne/viz/tests/test_evoked.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
from mne.io import read_raw_fif
from mne.stats.parametric import _parametric_ci
from mne.utils import _record_warnings, catch_logging
from mne.viz import plot_compare_evokeds, plot_evoked_white
from mne.viz import plot_compare_evokeds, plot_evoked_white, ui_events
from mne.viz.utils import _fake_click, _get_cmap

base_dir = Path(__file__).parents[2] / "io" / "tests" / "data"
Expand Down Expand Up @@ -229,6 +229,59 @@ def test_plot_evoked():
line_clr = [x.get_color() for x in fig.axes[0].get_lines()]
assert not np.all(np.isnan(line_clr) & (line_clr == 0))

# test hover and click functions
epochs = _get_epochs()
evoked = epochs.average()
fig = evoked.plot(picks="mag")
ax = fig.axes[0]

# test hover
# check cursorline is created on hover
assert not hasattr(ax, "_cursorline")
_fake_click(fig, ax, (0.5, 0.5), kind="motion")
assert hasattr(ax, "_cursorline")
assert ax._cursorline.get_xdata()[0] == 0.0
# check if text of channel name is displayed due to hover
text = ax.texts[0]
assert text.get_alpha() == 0.0
traces = [
line
for line in ax.get_lines()
if line.get_picker() and len(line.get_xdata()) > 3
]
trace = traces[0] # pick the first trace (MEG 0111)
x, y = trace.get_xdata(), trace.get_ydata()
i = len(x) // 2
_fake_click(fig, ax, (x[i], y[i]), xform="data", kind="motion")
assert text.get_alpha() == 1.0

# test click
assert not hasattr(ax, "_selectline")
_fake_click(fig, ax, (0.5, 0.5), kind="press")
assert hasattr(ax, "_selectline")
assert ax._selectline.get_xdata()[0] == 0.0

_fake_click(fig, ax, (0.6, 0.5), kind="press")
assert ax._selectline.get_xdata()[0] != 0.0

plt.close("all")


def test_plot_evoked_timechange():
"""Test that time change events are properly handled in plot_evoked."""
epochs = _get_epochs()
evoked = epochs.average()
fig = evoked.plot(picks="mag")
ax = fig.axes[0]

_fake_click(fig, ax, (0.5, 0.5), kind="press")
assert ax._selectline.get_xdata()[0] == 0.0

ui_events.publish(fig, ui_events.TimeChange(time=0.1))
assert ax._selectline.get_xdata()[0] == 0.1

plt.close("all")


def test_constrained_layout():
"""Test that we handle constrained layouts correctly."""
Expand Down
Loading