diff --git a/doc/changes/dev/14025.bugfix.rst b/doc/changes/dev/14025.bugfix.rst new file mode 100644 index 00000000000..87bb2fa5676 --- /dev/null +++ b/doc/changes/dev/14025.bugfix.rst @@ -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`_. \ No newline at end of file diff --git a/doc/changes/dev/14025.newfeature.rst b/doc/changes/dev/14025.newfeature.rst new file mode 100644 index 00000000000..dcbcd8b77b1 --- /dev/null +++ b/doc/changes/dev/14025.newfeature.rst @@ -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`_. \ No newline at end of file diff --git a/mne/viz/evoked.py b/mne/viz/evoked.py index 63aca378b59..9098ca168a4 100644 --- a/mne/viz/evoked.py +++ b/mne/viz/evoked.py @@ -46,6 +46,7 @@ _set_contour_locator, plot_topomap, ) +from .ui_events import TimeChange, publish, subscribe from .utils import ( DraggableColorbar, _check_cov, @@ -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, @@ -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 @@ -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: @@ -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.""" @@ -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, @@ -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, diff --git a/mne/viz/tests/test_evoked.py b/mne/viz/tests/test_evoked.py index 5335f163bad..c835f10695a 100644 --- a/mne/viz/tests/test_evoked.py +++ b/mne/viz/tests/test_evoked.py @@ -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" @@ -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."""