"""
LiveNeuro core module.
This module provides the core LiveNeuro class, an interactive 2D visualization
interface for Eelbrain's NDVar and MNE source estimate data structures. It
transforms neuroscience data into explorable brain maps and time-series plots.
"""
from __future__ import annotations
import dash
import mne # type: ignore[import-untyped]
import numpy as np
from eelbrain import NDVar
from ._data_loader_helper import BrainData, DataLoaderHelper
from ._plot_factory_helper import PlotFactoryHelper
from ._layout_helper import LayoutBuilderHelper, LAYOUTS
from ._app_controller_helper import AppControllerHelper
from ._sample_data import SampleDataNDVar
[docs]
class LiveNeuro:
"""Interactive 2D brain visualization for brain data using Plotly and Dash.
Visualization for 3D vector field time series. Provides activity time course
with interactive 2D projections of brain volume vector data.
Parameters
----------
y
Data to plot ([case,] time, source[, space]).
If ``y`` has a case dimension, the mean is plotted.
If ``y`` has a space dimension, the norm is plotted.
If None, uses MNE sample data for demonstration.
Pass an Eelbrain NDVar, an MNE ``VolVectorSourceEstimate`` with
``src``, or the sample data object returned by
:func:`liveneuro.create_sample_brain_data`.
cmap
Plotly colorscale for heatmaps. Can be:
- Built-in colorscale name (e.g., 'YlOrRd', 'OrRd', 'Reds', 'Viridis')
- Custom colorscale list (e.g., [[0, 'white'], [1, 'red']])
Default is 'YlOrRd' (Yellow-Orange-Red) which works well with white
background and doesn't obscure arrows. See
https://plotly.com/python/builtin-colorscales/ for all available options.
vmin
Optional lower bound for the color range. If provided, locks the minimum
for all projections and time points.
vmax
Optional upper bound for the color range. If provided, locks the maximum
for all projections and time points.
show_max_only
If True, butterfly plot shows only mean and max traces.
If False, butterfly plot shows individual source traces, mean, and max.
Default is False.
arrow_threshold
Threshold for displaying arrows in brain projections. Only arrows with
magnitude greater than this value will be displayed. If None, all arrows
are shown. If 'auto', uses 10% of the maximum magnitude as threshold.
Default is None.
arrow_scale
Relative scale factor for arrow length in brain projections. The default
value of 1.0 provides a good balance for most datasets. Use 0.5 for half
the length, 2.0 for double the length, etc. Useful for adjusting
visualization clarity when vectors have large magnitudes or high density.
Default is 1.0. Typical range: 0.5 (short) to 2.0 (long).
layout_mode
Layout arrangement mode for the visualization interface. Options:
- 'vertical': Traditional layout with butterfly plot on top, brain views below
- 'horizontal': Compact layout with butterfly plot on left, brain views on right (default)
Default is 'horizontal'.
display_mode
Anatomical view mode for brain projections. Options:
- 'ortho': Orthogonal views (sagittal + coronal + axial) - Default
- 'x': Sagittal view only
- 'y': Coronal view only
- 'z': Axial view only
- 'xz': Sagittal + Axial views
- 'yx': Coronal + Sagittal views
- 'yz': Coronal + Axial views
- 'l': Left hemisphere view only
- 'r': Right hemisphere view only
- 'lr': Both hemisphere views (left + right)
- 'lzr': Left + Axial + Right hemispheres
- 'lyr': Left + Coronal + Right (GlassBrain default - best for hemisphere comparison)
- 'lzry': Left + Axial + Right + Coronal (4-view comprehensive)
- 'lyrz': Left + Coronal + Right + Axial (4-view comprehensive)
Default is 'lyr' (GlassBrain standard) for optimal hemisphere comparison.
show_labels
If True, shows plot titles and legends (e.g., 'Source Activity Time Series',
'Source 0', 'Source 1', etc.). If False, hides all titles and legends for a
cleaner visualization. Default is False.
src
Matching MNE SourceSpaces object when ``y`` is an
``mne.VolVectorSourceEstimate``. Required for MNE source estimates because
the source estimate stores vertex ids while LiveNeuro needs 3D source
coordinates.
Notes
-----
Expected input format
- For vector data: NDVar with dimensions ([case,] time, source, space)
- For scalar data: NDVar with dimensions ([case,] time, source)
- For MNE vector volume data: VolVectorSourceEstimate plus matching ``src``
- If case dimension present: mean across cases is plotted
- If space dimension present: norm across space is plotted for butterfly plot
- ``create_sample_brain_data`` returns a minimal NDVar-like object compatible
with the ``y`` parameter for quick demos
"""
def __init__(
self,
y: NDVar | mne.VolVectorSourceEstimate | SampleDataNDVar | None = None,
cmap: str | list = "YlOrRd",
vmin: float | None = None,
vmax: float | None = None,
show_max_only: bool = False,
arrow_threshold: float | str | None = None,
arrow_scale: float = 1.0,
realtime: bool = False,
layout_mode: str = "horizontal",
display_mode: str = "lyr",
show_labels: bool = False,
src: mne.SourceSpaces | None = None,
):
"""Initialize the visualization app and load data."""
# Use regular Dash with modern Jupyter integration
# Add external CSS to remove ALL default margins/padding from Dash containers
external_stylesheets = [
{
"href": (
"data:text/css;charset=utf-8,"
+ "*{box-sizing:border-box;}"
+ "html{margin:0!important;padding:0!important;"
+ "height:auto!important;overflow:hidden;}"
+ "body{margin:0!important;padding:0!important;"
+ "height:auto!important;overflow:hidden;}"
+ "#react-entry-point{margin:0!important;padding:0!important;"
+ "height:auto!important;}"
+ "#_dash-app-content{margin:0!important;padding:0!important;"
+ "height:auto!important;}"
+ "._dash-loading{margin:0!important;padding:0!important;}"
),
"rel": "stylesheet",
}
]
self.app: dash.Dash = dash.Dash(
__name__, external_stylesheets=external_stylesheets
)
# Initialize data attributes
self.glass_brain_data: np.ndarray | None = None # (n_sources, 3, n_times)
self.butterfly_data: np.ndarray | None = None # (n_sources, n_times)
self.source_coords: np.ndarray | None = None # (n_sources, 3)
self.time_values: np.ndarray | None = None # (n_times,)
self.cmap: str | list = cmap # Colorscale for heatmaps
self.user_vmin: float | None = vmin # Optional user-specified color min
self.user_vmax: float | None = vmax # Optional user-specified color max
self.show_max_only: bool = show_max_only # Control butterfly plot display mode
# Threshold for displaying arrows
self.arrow_threshold: float | str | None = arrow_threshold
# Scale factor for arrow length
self.arrow_scale: float = arrow_scale
self.is_jupyter_mode: bool = False # Track if running in Jupyter mode
self.realtime_mode_default = (
["realtime"] if realtime else []
) # Default state for real-time mode
self.show_labels: bool = show_labels # Control titles and legends display
self.current_layout_config: dict[str, object] | None = None
self.view_ranges: dict[str, dict[str, list[float]]] = {}
self.global_vmin: float = 0.0
self.global_vmax: float = 1.0
# Internal data model (populated during initialization)
self._brain_data: BrainData | None = None
# Internal helper components
self._data_loader = DataLoaderHelper()
self._plot_factory = PlotFactoryHelper(self)
self._layout_helper = LayoutBuilderHelper(self)
self._app_controller = AppControllerHelper(self)
# Validate and set layout mode (allow registered custom layouts)
valid_layouts = list(LAYOUTS.keys())
if layout_mode not in valid_layouts:
raise ValueError(
f"layout_mode must be one of {valid_layouts}, got '{layout_mode}'"
)
self.layout_mode: str = layout_mode
# Set display mode and parse required views
self.display_mode: str = display_mode
# Parse display mode to determine required views (includes validation)
self.brain_views = self._layout_helper.parse_display_mode(display_mode)
# Load data (data loader helper responsibility)
if y is not None:
if isinstance(y, mne.VolVectorSourceEstimate):
brain_data = self._data_loader.load_mne_vol_vector_source_estimate(
y, src
)
elif src is not None:
raise ValueError(
"src can only be used when y is an "
"mne.VolVectorSourceEstimate."
)
else:
brain_data = self._data_loader.load_ndvar_data(y)
else:
if src is not None:
raise ValueError(
"src can only be used when y is an "
"mne.VolVectorSourceEstimate."
)
brain_data = self._data_loader.load_source_data()
# Store data model and mirror key attributes for backward compatibility
self._brain_data = brain_data
self.glass_brain_data = brain_data.glass_brain_data
self.butterfly_data = brain_data.butterfly_data
self.source_coords = brain_data.source_coords
self.time_values = brain_data.time_values
# Calculate and store fixed axis ranges for each view to prevent size changes
self.view_ranges = self._plot_factory.calculate_view_ranges(
self.source_coords, self.brain_views
)
# Unify view sizes to ensure all brain plots have consistent display size
# This is especially important in horizontal layout mode
self.view_ranges = self._layout_helper.unify_view_sizes_for_jupyter(
self.view_ranges
)
# Calculate global colormap range across all time points for consistent visualization
self.global_vmin, self.global_vmax = (
self._plot_factory.calculate_global_colormap_range(
self.glass_brain_data, self.user_vmin, self.user_vmax
)
)
# Setup app layout and callbacks
self._rebuild_layout()
self._app_controller.setup_callbacks()
def _rebuild_layout(self) -> None:
"""Rebuild Dash layout and update current layout config."""
layout_info = self._layout_helper.setup_layout()
config = layout_info.get("config")
layout = layout_info.get("layout")
if config is not None:
self.current_layout_config = config
if layout is not None:
self.app.layout = layout
def prepare_for_jupyter(self) -> None:
"""Prepare visualization for Jupyter display (layout + sizing)."""
self.is_jupyter_mode = True
# Unify view sizes for Jupyter mode to ensure consistent display
self.view_ranges = self._layout_helper.unify_view_sizes_for_jupyter(
self.view_ranges
)
# Rebuild layout with Jupyter-specific styles
self._rebuild_layout()
[docs]
def run(
self,
port: int | None = None,
debug: bool = False,
mode: str | None = None,
) -> None:
"""Run the interactive visualization.
Parameters
----------
port
Port number for the server. If None, uses a random port.
debug
Enable Dash debug mode.
mode
Display mode. If None, auto-selects based on environment.
Common values are ``"inline"``, ``"jupyterlab"``, and ``"external"``.
"""
self._app_controller.run(port=port, debug=debug, mode=mode)
def _show_in_jupyter(self, debug: bool = False) -> None:
self._app_controller.show_in_jupyter(debug=debug)
[docs]
def export_images(
self,
output_dir: str = "./images",
time_idx: int | None = None,
format: str = "png",
) -> dict[str, object]:
"""Export plots as image files.
Parameters
----------
output_dir
Directory to save image files.
time_idx
Time index to export. If None, uses 0.
format
Image format (e.g., ``"png"``, ``"jpg"``, ``"svg"``, ``"pdf"``).
Returns
-------
dict
Dictionary with export status and file paths.
"""
return self._app_controller.export_images(
output_dir=output_dir, time_idx=time_idx, format=format
)
# Run the app when script is executed directly
if __name__ == "__main__":
try:
viz_2d = LiveNeuro(
cmap="Reds",
show_max_only=False,
arrow_threshold=None, # Show all arrows
layout_mode="vertical",
display_mode="lzry",
arrow_scale=0.5, # Shorter arrows for better visibility
)
viz_2d.run()
except Exception as error:
print(f"Error starting 2D visualization app: {error}")
import traceback
traceback.print_exc()