diff --git a/doc/changes/latest.inc b/doc/changes/latest.inc index bdc6c923547..faf79790f7e 100644 --- a/doc/changes/latest.inc +++ b/doc/changes/latest.inc @@ -23,7 +23,7 @@ Current (1.6.dev0) Enhancements ~~~~~~~~~~~~ -- None yet +- Added functionality for linking interactive figures together, such that changing one figure will affect another, see :ref:`tut-ui-events` and :mod:`mne.viz.ui_events` (:gh:`11685` by `Marijn van Vliet`_) Bugs ~~~~ diff --git a/doc/conf.py b/doc/conf.py index bfb899ff44b..7f48ad1ee7a 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -577,6 +577,7 @@ def __call__(self, gallery_conf, fname, when): "../tutorials/clinical/", "../tutorials/simulation/", "../tutorials/sample-datasets/", + "../tutorials/visualization/", "../tutorials/misc/", ] ), @@ -1572,6 +1573,7 @@ def reset_warnings(gallery_conf, fname): ml = "machine-learning" tf = "time-freq" si = "simulation" +vi = "visualization" custom_redirects = { # Custom redirects (one HTML path to another, relative to outdir) # can be added here as fr->to key->value mappings @@ -1628,6 +1630,7 @@ def reset_warnings(gallery_conf, fname): f"{ex}/{co}/mne_inverse_envelope_correlation.html": f"{mne_conn}/{ex}/mne_inverse_envelope_correlation.html", # noqa E501 f"{ex}/{co}/mne_inverse_psi_visual.html": f"{mne_conn}/{ex}/mne_inverse_psi_visual.html", # noqa E501 f"{ex}/{co}/sensor_connectivity.html": f"{mne_conn}/{ex}/sensor_connectivity.html", # noqa E501 + f"{ex}/{vi}/publication_figure.html": f"{tu}/{vi}/10_publication_figure.html", # noqa E501 } diff --git a/doc/visualization.rst b/doc/visualization.rst index 62fa54a8cee..f664a3a7320 100644 --- a/doc/visualization.rst +++ b/doc/visualization.rst @@ -86,3 +86,27 @@ Visualization set_browser_backend get_browser_backend use_browser_backend + +UI Events +--------- + +.. currentmodule:: mne.viz.ui_events + +:py:mod:`mne.viz.ui_events`: + +.. automodule:: mne.viz.ui_events + :no-members: + :no-inherited-members: + +.. autosummary:: + :toctree: generated/ + + subscribe + unsubscribe + publish + link + unlink + disable_ui_events + UIEvent + FigureClosing + TimeChange diff --git a/mne/viz/__init__.py b/mne/viz/__init__.py index fd9a60faed7..944671d4e05 100644 --- a/mne/viz/__init__.py +++ b/mne/viz/__init__.py @@ -87,3 +87,4 @@ from ._brain import Brain from ._figure import get_browser_backend, set_browser_backend, use_browser_backend from ._proj import plot_projs_joint +from . import ui_events diff --git a/mne/viz/tests/test_ui_events.py b/mne/viz/tests/test_ui_events.py new file mode 100644 index 00000000000..c175109623b --- /dev/null +++ b/mne/viz/tests/test_ui_events.py @@ -0,0 +1,261 @@ +# Authors: Marijn van Vliet +# +# License: Simplified BSD +import matplotlib.pyplot as plt +import pytest + +from mne.datasets import testing +from mne.viz import ui_events + +subjects_dir = testing.data_path(download=False) / "subjects" + + +@pytest.fixture +def event_channels(): + """Fixture that makes sure each test starts with a fresh UI event chans dict.""" + ui_events._event_channels.clear() + return ui_events._event_channels + + +@pytest.fixture +def event_channel_links(): + """Fixture that makes sure each test starts with a fresh channel links dict.""" + ui_events._event_channel_links.clear() + return ui_events._event_channel_links + + +@pytest.fixture +def disabled_event_channels(): + """Fixture that makes sure each test starts with a fresh disabled channels set.""" + ui_events._disabled_event_channels.clear() + return ui_events._disabled_event_channels + + +@testing.requires_testing_data +def test_get_event_channel(event_channels): + """Test creating and obtaining a figure's UI event channel.""" + # At first, no event channels exist + assert len(event_channels) == 0 + + # Open a figure and get the event channel. This should create it. + fig = plt.figure() + ui_events._get_event_channel(fig) + assert len(event_channels) == 1 + assert fig in event_channels + + # Closing a figure should delete the event channel. + # During tests, matplotlib does not open an actual window so we need to force the + # close event. + fig.canvas.callbacks.process("close_event", None) + assert len(event_channels) == 0 + + # TODO: Different types of figures: Brain, MNEFigure, Figure3D + + +def test_publish(event_channels): + """Test publishing UI events.""" + fig = plt.figure() + ui_events.publish(fig, ui_events.TimeChange(time=10.2)) + + # Publishing the event should have created the needed channel. + assert len(event_channels) == 1 + assert fig in event_channels + + +def test_subscribe(event_channels): + """Test subscribing to UI events.""" + callback_calls = list() + + def callback(event): + """Respond to time change event.""" + callback_calls.append(event) + assert isinstance(event, ui_events.TimeChange) + assert event.time == 10.2 + + fig = plt.figure() + ui_events.subscribe(fig, "time_change", callback) + + # Subscribing to the event should have created the needed channel. + assert "time_change" in ui_events._get_event_channel(fig) + + # Publishing the time change event should call the callback function. + ui_events.publish(fig, ui_events.TimeChange(time=10.2)) + assert callback_calls + + # Publishing a different event should not call the callback function. + callback_calls.clear() # Reset + ui_events.publish(fig, ui_events.FigureClosing()) + assert not callback_calls + + # Test disposing of the event channel, even with subscribers. + # During tests, matplotlib does not open an actual window so we need to force the + # close event. + fig.canvas.callbacks.process("close_event", None) + assert len(event_channels) == 0 + + +def test_unsubscribe(event_channels): + """Test unsubscribing from UI events.""" + callback1_calls = list() + callback2_calls = list() + + def callback1(event): + """Respond to time change event.""" + callback1_calls.append(event) + + def callback2(event): + """Respond to time change event.""" + callback2_calls.append(event) + + fig = plt.figure() + + def setup_events(): + """Reset UI event scenario.""" + callback1_calls.clear() + callback2_calls.clear() + ui_events.unsubscribe(fig, "all") + ui_events.subscribe(fig, "figure_closing", callback1) + ui_events.subscribe(fig, "time_change", callback1) + ui_events.subscribe(fig, "time_change", callback2) + + # Test unsubscribing from a single event + setup_events() + with pytest.warns(RuntimeWarning, match="Cannot unsubscribe"): + ui_events.unsubscribe(fig, "nonexisting_event") + ui_events.unsubscribe(fig, "time_change") + assert "time_change" not in ui_events._get_event_channel(fig) + assert "figure_closing" in ui_events._get_event_channel(fig) + ui_events.publish(fig, ui_events.TimeChange(time=10.2)) + assert not callback1_calls + assert not callback2_calls + ui_events.publish(fig, ui_events.FigureClosing()) + assert callback1_calls + + # Test unsubscribing from all events + setup_events() + ui_events.unsubscribe(fig, "all") + assert "time_change" not in ui_events._get_event_channel(fig) + assert "figure_closing" not in ui_events._get_event_channel(fig) + ui_events.publish(fig, ui_events.TimeChange(time=10.2)) + ui_events.publish(fig, ui_events.FigureClosing()) + assert not callback1_calls + assert not callback2_calls + + # Test unsubscribing from a list of events + setup_events() + ui_events.unsubscribe(fig, ["time_change", "figure_closing"]) + assert "time_change" not in ui_events._get_event_channel(fig) + assert "figure_closing" not in ui_events._get_event_channel(fig) + ui_events.publish(fig, ui_events.TimeChange(time=10.2)) + ui_events.publish(fig, ui_events.FigureClosing()) + assert not callback1_calls + assert not callback2_calls + + # Test unsubscribing a specific callback function from a single event + setup_events() + with pytest.warns(RuntimeWarning, match="Cannot unsubscribe"): + ui_events.unsubscribe(fig, "figure_closing", callback2) + ui_events.unsubscribe(fig, "time_change", callback2) + ui_events.publish(fig, ui_events.TimeChange(time=10.2)) + assert callback1_calls + assert not callback2_calls + + # Test unsubscribing a specific callback function from all events + setup_events() + ui_events.unsubscribe(fig, "all", callback2) + ui_events.publish(fig, ui_events.TimeChange(time=10.2)) + assert callback1_calls + assert not callback2_calls + + # Test unsubscribing a specific callback function from a list of events + setup_events() + ui_events.unsubscribe(fig, ["time_change", "figure_closing"], callback1) + ui_events.publish(fig, ui_events.TimeChange(time=10.2)) + ui_events.publish(fig, ui_events.FigureClosing()) + assert not callback1_calls + + +def test_link(event_channels, event_channel_links): + """Test linking the event channels of two functions.""" + fig1 = plt.figure() + fig2 = plt.figure() + + callback_calls = list() + + def callback(event): + """Respond to time change event.""" + callback_calls.append(event) + + # Both figures are subscribed to the time change events. + ui_events.subscribe(fig1, "time_change", callback) + ui_events.subscribe(fig2, "time_change", callback) + + # Linking the event channels causes events to be published on both channels. + ui_events.link(fig1, fig2) + assert len(event_channel_links) == 2 + assert fig2 in event_channel_links[fig1] + assert fig1 in event_channel_links[fig2] + + ui_events.publish(fig1, ui_events.TimeChange(time=10.2)) + assert len(callback_calls) == 2 + + callback_calls.clear() + ui_events.publish(fig2, ui_events.TimeChange(time=10.2)) + assert len(callback_calls) == 2 + + # Test linking only specific events + ui_events.link(fig1, fig2, ["time_change"]) + callback_calls.clear() + ui_events.publish(fig1, ui_events.TimeChange(time=10.2)) + ui_events.publish(fig2, ui_events.TimeChange(time=10.2)) + assert len(callback_calls) == 4 # Called for both figures two times + + ui_events.link(fig1, fig2, ["some_other_event"]) + callback_calls.clear() + ui_events.publish(fig1, ui_events.TimeChange(time=10.2)) + ui_events.publish(fig2, ui_events.TimeChange(time=10.2)) + assert len(callback_calls) == 2 # Only called for both figures once + + # Test cleanup + fig1.canvas.callbacks.process("close_event", None) + fig2.canvas.callbacks.process("close_event", None) + assert len(event_channels) == 0 + assert len(event_channel_links) == 0 + + +def test_unlink(event_channel_links): + """Test unlinking event channels.""" + fig1 = plt.figure() + fig2 = plt.figure() + fig3 = plt.figure() + ui_events.link(fig1, fig2) + ui_events.link(fig2, fig3) + assert len(event_channel_links) == 3 + + # Fig1 is involved in two of the 4 links. + ui_events.unlink(fig1) + assert len(event_channel_links) == 2 + assert fig1 not in event_channel_links[fig2] + assert fig1 not in event_channel_links[fig3] + ui_events.link(fig1, fig2) # Relink for the next test. + + # Fig2 is involved in all links, unlinking it should clear them all. + ui_events.unlink(fig2) + assert len(event_channel_links) == 0 + + +def test_disable_ui_events(event_channels, disabled_event_channels): + """Test disable_ui_events context manager.""" + callback_calls = list() + + def callback(event): + """Respond to time change event.""" + callback_calls.append(event) + + fig = plt.figure() + ui_events.subscribe(fig, "time_change", callback) + with ui_events.disable_ui_events(fig): + ui_events.publish(fig, ui_events.TimeChange(time=10.2)) + assert not callback_calls + ui_events.publish(fig, ui_events.TimeChange(time=10.2)) + assert callback_calls diff --git a/mne/viz/topomap.py b/mne/viz/topomap.py index 95bccb36172..11d62927683 100644 --- a/mne/viz/topomap.py +++ b/mne/viz/topomap.py @@ -18,6 +18,7 @@ import numpy as np +from . import ui_events from ..baseline import rescale from ..channels.channels import _get_ch_type from ..channels.layout import ( @@ -2057,6 +2058,11 @@ def plot_evoked_topomap( :meth:`axes.set_position() ` method or :doc:`gridspec ` interface to adjust the colorbar size yourself. + + When ``time=="interactive"``, the figure will publish and subscribe to the + following events: + + * :class:`~mne.viz.ui_events.TimeChange` whenever a new time is selected. """ import matplotlib.pyplot as plt from matplotlib.gridspec import GridSpec @@ -2295,6 +2301,8 @@ def plot_evoked_topomap( axes[ax_idx].set_title(axes_title) if interactive: + # Add a slider to the figure and start publishing and subscribing to time_change + # events. kwargs.update(vlim=_vlim) axes.append(plt.subplot(gs[1, :-1])) slider = Slider( @@ -2307,23 +2315,33 @@ def plot_evoked_topomap( ) slider.vline.remove() # remove initial point indicator func = _merge_ch_data if merge_channels else lambda x: x - changed_callback = partial( - _slider_changed, - ax=axes[0], - data=evoked.data, - times=evoked.times, - pos=pos, - scaling=scaling, - func=func, - time_format=time_format, - scaling_time=scaling_time, - kwargs=kwargs, - ) - slider.on_changed(changed_callback) + + def _slider_changed(val): + ui_events.publish(fig, ui_events.TimeChange(time=val)) + + slider.on_changed(_slider_changed) ts = np.tile(evoked.times, len(evoked.data)).reshape(evoked.data.shape) axes[-1].plot(ts, evoked.data, color="k") axes[-1].slider = slider + ui_events.subscribe( + fig, + "time_change", + partial( + _on_time_change, + fig=fig, + data=evoked.data, + times=evoked.times, + pos=pos, + scaling=scaling, + func=func, + time_format=time_format, + scaling_time=scaling_time, + slider=slider, + kwargs=kwargs, + ), + ) + if colorbar: if interactive: cax = plt.subplot(gs[0, -1]) @@ -2389,19 +2407,35 @@ def _resize_cbar(cax, n_fig_axes, size=1): cax.set_position(cpos) -def _slider_changed( - val, ax, data, times, pos, scaling, func, time_format, scaling_time, kwargs +def _on_time_change( + event, + fig, + data, + times, + pos, + scaling, + func, + time_format, + scaling_time, + slider, + kwargs, ): - """Handle selection in interactive topomap.""" - idx = np.argmin(np.abs(times - val)) + """Handle updating topomap to show a new time.""" + idx = np.argmin(np.abs(times - event.time)) data = func(data[:, idx]).ravel() * scaling + ax = fig.axes[0] ax.clear() im, _ = plot_topomap(data, pos, axes=ax, **kwargs) if hasattr(ax, "CB"): ax.CB.mappable = im _resize_cbar(ax.CB.cbar.ax, 2) if time_format is not None: - ax.set_title(time_format % (val * scaling_time)) + ax.set_title(time_format % (event.time * scaling_time)) + # Updating the slider will generate a new time_change event. To prevent an + # infinite loop, only update the slider if the time has actually changed. + if event.time != slider.val: + slider.set_val(event.time) + ax.figure.canvas.draw_idle() def _plot_topomap_multi_cbar( diff --git a/mne/viz/ui_events.py b/mne/viz/ui_events.py new file mode 100644 index 00000000000..694cbab9044 --- /dev/null +++ b/mne/viz/ui_events.py @@ -0,0 +1,305 @@ +""" +Event API for inter-figure communication. + +The event API allows figures to communicate with each other, such that a change +in one figure can trigger a change in another figure. For example, moving the +time cursor in one plot can update the current time in another plot. Another +scenario is two drawing routines drawing into the same window, using events to +stay in-sync. + +Authors: Marijn van Vliet +""" +import contextlib +from dataclasses import dataclass +from weakref import WeakKeyDictionary, WeakSet +import re + + +from ..utils import warn + + +# Global dict {fig: channel} containing all currently active event channels. +_event_channels = WeakKeyDictionary() + +# The event channels of figures can be linked together. This dict keeps track +# of these links. Links are bi-directional, so if {fig1: fig2} exists, then so +# must {fig2: fig1}. +_event_channel_links = WeakKeyDictionary() + +# Event channels that are temporarily disabled by the disable_ui_events context +# manager. +_disabled_event_channels = WeakSet() + +# Regex pattern used when converting CamelCase to snake_case. +# Detects all capital letters that are not at the beginning of a word. +_camel_to_snake = re.compile(r"(? list] + The event channel. An event channel is a list mapping string event + names to a list of callback representing all subscribers to the + channel. + """ + import matplotlib + + # Create the event channel if it doesn't exist yet + if fig not in _event_channels: + # The channel itself is a dict mapping string event names to a list of + # subscribers. No subscribers yet for this new event channel. + _event_channels[fig] = dict() + + # When the figure is closed, its associated event channel should be + # deleted. This is a good time to set this up. + def delete_event_channel(event=None): + """Delete the event channel (callback function).""" + publish(fig, event=FigureClosing()) # Notify subscribers of imminent close + unlink(fig) # Remove channel from the _event_channel_links dict + if fig in _event_channels: + del _event_channels[fig] + + # Hook up the above callback function to the close event of the figure + # window. How this is done exactly depends on the various figure types + # MNE-Python has. + if isinstance(fig, matplotlib.figure.Figure): + fig.canvas.mpl_connect("close_event", delete_event_channel) + else: + raise NotImplementedError("This figure type is not support yet.") + + # Now the event channel exists for sure. + return _event_channels[fig] + + +def publish(fig, event): + """Publish an event to all subscribers of the figure's channel. + + The figure's event channel and all linked event channels are searched for + subscribers to the given event. Each subscriber had provided a callback + function when subscribing, so we call that. + + Parameters + ---------- + fig : matplotlib.figure.Figure | Figure3D + The figure that publishes the event. + event : UIEvent + Event to publish. + """ + if fig in _disabled_event_channels: + return + + # Compile a list of all event channels that the event should be published + # on. + channels = [_get_event_channel(fig)] + links = _event_channel_links.get(fig, None) + if links is not None: + for linked_fig, event_names in links.items(): + if event_names == "all" or event.name in event_names: + channels.append(_get_event_channel(linked_fig)) + + # Publish the event by calling the registered callback functions. + event.source = fig + for channel in channels: + if event.name not in channel: + channel[event.name] = set() + for callback in channel[event.name]: + callback(event=event) + + +def subscribe(fig, event_name, callback): + """Subscribe to an event on a figure's event channel. + + Parameters + ---------- + fig : matplotlib.figure.Figure | Figure3D + The figure of which event channel to subscribe. + event_name : str + The name of the event to listen for. + callback : callable + The function that should be called whenever the event is published. + """ + channel = _get_event_channel(fig) + if event_name not in channel: + channel[event_name] = set() + channel[event_name].add(callback) + + +def unsubscribe(fig, event_names, callback=None): + """Unsubscribe from an event on a figure's event channel. + + Parameters + ---------- + fig : matplotlib.figure.Figure | Figure3D + The figure of which event channel to unsubscribe from. + event_names : str | list of str + Select which events to stop subscribing to. Can be a single string + event name, a list of event names or ``"all"`` which will unsubscribe + from all events. + callback : callable | None + The callback function that should be unsubscribed, leaving all other + callback functions that may be subscribed untouched. By default + (``None``) all callback functions are unsubscribed from the event. + """ + channel = _get_event_channel(fig) + + # Determine which events to unsubscribe for. + if event_names == "all": + if callback is None: + event_names = list(channel.keys()) + else: + event_names = list(k for k, v in channel.items() if callback in v) + elif isinstance(event_names, str): + event_names = [event_names] + + for event_name in event_names: + if event_name not in channel: + warn( + f'Cannot unsubscribe from event "{event_name}" as we have never ' + "subscribed to it." + ) + continue + + if callback is None: + del channel[event_name] + else: + # Unsubscribe specific callback function. + subscribers = channel[event_name] + if callback in subscribers: + subscribers.remove(callback) + else: + warn( + f'Cannot unsubscribe {callback} from event "{event_name}" ' + "as it was never subscribed to it." + ) + if len(subscribers) == 0: + del channel[event_name] # keep things tidy + + +def link(fig1, fig2, event_names="all"): + """Link the event channels of two figures together. + + When event channels are linked, any events that are published on one + channel are simultaneously published on the other channel. Links are + bi-directional. + + Parameters + ---------- + fig1 : matplotlib.figure.Figure | Figure3D + The first figure whose event channel will be linked to the second. + fig2 : matplotlib.figure.Figure | Figure3D + The second figure whose event channel will be linked to the first. + event_names : str | list of str + Select which events to publish across figures. By default (``"all"``), + both figures will receive all of each other's events. Passing a list of + event names will restrict the events being shared across the figures to + only the given ones. + """ + if event_names != "all": + event_names = set(event_names) + + if fig1 not in _event_channel_links: + _event_channel_links[fig1] = WeakKeyDictionary() + _event_channel_links[fig1][fig2] = event_names + if fig2 not in _event_channel_links: + _event_channel_links[fig2] = WeakKeyDictionary() + _event_channel_links[fig2][fig1] = event_names + + +def unlink(fig): + """Remove all links involving the event channel of the given figure. + + Parameters + ---------- + fig : matplotlib.figure.Figure | Figure3D + The figure whose event channel should be unlinked from all other event + channels. + """ + linked_figs = _event_channel_links.get(fig) + if linked_figs is not None: + for linked_fig in linked_figs.keys(): + del _event_channel_links[linked_fig][fig] + if len(_event_channel_links[linked_fig]) == 0: + del _event_channel_links[linked_fig] + if fig in _event_channel_links: # need to check again because of weak refs + del _event_channel_links[fig] + + +@contextlib.contextmanager +def disable_ui_events(fig): + """Temporarily disable generation of UI events. Use as context manager. + + Parameters + ---------- + fig : matplotlib.figure.Figure | Figure3D + The figure whose UI event generation should be temporarily disabled. + """ + _disabled_event_channels.add(fig) + try: + yield + finally: + _disabled_event_channels.remove(fig) diff --git a/examples/visualization/publication_figure.py b/tutorials/visualization/10_publication_figure.py similarity index 99% rename from examples/visualization/publication_figure.py rename to tutorials/visualization/10_publication_figure.py index f753c72a2c8..ab648890772 100644 --- a/examples/visualization/publication_figure.py +++ b/tutorials/visualization/10_publication_figure.py @@ -1,5 +1,5 @@ """ -.. _ex-publication-figure: +.. _tut-publication-figure: =================================== Make figures more publication ready diff --git a/tutorials/visualization/20_ui_events.py b/tutorials/visualization/20_ui_events.py new file mode 100644 index 00000000000..0298d7c357e --- /dev/null +++ b/tutorials/visualization/20_ui_events.py @@ -0,0 +1,100 @@ +""" +.. _tut-ui-events: + +====================================== +Using the event system to link figures +====================================== + +Many of MNE-Python's figures are interactive. For example, you can select channels or +scroll through time. The event system allows you to link figures together so that +interacting with one figure will simultaneously update another figure. + +In this example, we'll be looking at linking two topomap plots, such that selecting the +time in one will also update the time in the other, as well as hooking our own +custom plot into MNE-Python's event system. + +Since the figures on our website don't have any interaction capabilities, this example +will only work properly when run in an interactive environment. +""" +# Author: Marijn van Vliet +# +# License: BSD-3-Clause +import mne +import matplotlib.pyplot as plt +from mne.viz.ui_events import publish, subscribe, link, TimeChange + +# Turn on interactivity +plt.ion() + +######################################################################################## +# Linking interactive plots +# ========================= +# We load evoked data for two experimental conditions and create two topomap +# plots that have sliders controlling the time-point that is shown. By default, +# both figures are independent, but we will link the event channels of the +# figures together, so that moving the slider in one figure will also move the +# slider in the other. +data_path = mne.datasets.sample.data_path() +fname = data_path / "MEG" / "sample" / "sample_audvis-ave.fif" +aud_left = mne.read_evokeds(fname, condition="Left Auditory").apply_baseline() +aud_right = mne.read_evokeds(fname, condition="Right Auditory").apply_baseline() +fig1 = aud_left.plot_topomap("interactive") +fig2 = aud_right.plot_topomap("interactive") +link(fig1, fig2) # link the event channels + +######################################################################################## +# Hooking a custom plot into the event system +# =========================================== +# In MNE-Python, each figure has an associated event channel. Drawing routines +# can :func:`publish ` events on the channel and +# receive events by :func:`subscribe `-ing to the +# channel. When subscribing to an event on a channel, you specify a callback +# function to be called whenever a drawing routine publishes that event on +# the event channel. +# +# The events are modeled after matplotlib's event system. Each event has a string +# name (the snake-case version of its class name) and a list of relevant values. +# For example, the "time_change" event should have the new time as a value. +# Values can be any python object. When publishing an event, the publisher +# creates a new instance of the event's class. When subscribing to an event, +# having to dig up and import the correct class is a bit of a hassle. Following +# matplotlib's example, subscribers use the string name of the event to refer +# to it. +# +# Below, we create a custom plot and then make it publish and subscribe to +# :class:`~mne.viz.ui_events.TimeChange` events so it can work +# together with the topomap plots we created earlier. +fig3, ax = plt.subplots() +ax.plot(aud_left.times, aud_left.pick("mag").data.max(axis=0), label="Left") +ax.plot(aud_right.times, aud_right.pick("mag").data.max(axis=0), label="Right") +time_bar = ax.axvline(0, color="black") # Our time slider +ax.set_xlabel("Time (s)") +ax.set_ylabel("Maximum magnetic field strength") +ax.set_title("A custom plot") +plt.legend() + + +def on_motion_notify(mpl_event): + """Respond to matplotlib's mouse event. + + Publishes an MNE-Python TimeChange event. When the mouse goes out of + bounds, the xdata will be None, which is a special case that needs to be + handled. + """ + if mpl_event.xdata is not None: + publish(fig3, TimeChange(time=mpl_event.xdata)) + + +def on_time_change(event): + """Respond to MNE-Python's TimeChange event. Updates the plot.""" + time_bar.set_xdata([event.time]) + fig3.canvas.draw() # update the figure + + +plt.connect("motion_notify_event", on_motion_notify) +subscribe(fig3, "time_change", on_time_change) + +# Link the new figure with the topomap plots, so that the TimeChange events are +# sent to all of them. +link(fig3, fig1) +link(fig3, fig2) diff --git a/tutorials/visualization/README.txt b/tutorials/visualization/README.txt new file mode 100644 index 00000000000..6b54053d003 --- /dev/null +++ b/tutorials/visualization/README.txt @@ -0,0 +1,6 @@ +Visualization tutorials +----------------------- + +These tutorials cover the more advanced visualization options provided by +MNE-Python, such as how to produce publication-quality figures or how to make +plots more interactive.