import numpy as np import plotly.graph_objects as go from data_loader import ARTIFACT_COLORS, ARTIFACT_LABEL_MAP CHANNEL_SPACING = 1.0 def build_eeg_figure( signal: np.ndarray, channel_names: list[str], sampling_rate_hz: float, start_time_s: float = 0.0, annotations: list[dict] | None = None, title: str = "EEG Signal", ) -> go.Figure: n_channels, n_samples = signal.shape time_axis = start_time_s + np.arange(n_samples) / sampling_rate_hz normalized = np.zeros_like(signal, dtype=np.float32) for i in range(n_channels): p2p = np.nanpercentile(signal[i], 97.5) - np.nanpercentile(signal[i], 2.5) if p2p > 0: normalized[i] = signal[i] / p2p * CHANNEL_SPACING * 0.8 else: normalized[i] = 0.0 fig = go.Figure() if annotations: _add_artifact_shapes(fig, annotations, start_time_s, time_axis[-1], n_channels) for i in range(n_channels): ch_data = normalized[i] offset = -i * CHANNEL_SPACING name = channel_names[i] if i < len(channel_names) else f"Ch{i}" fig.add_trace(go.Scatter( x=time_axis, y=ch_data + offset, mode="lines", name=name, line=dict(width=0.8, color=f"hsl({(i * 37) % 360}, 70%, 65%)"), hovertemplate=( f"{name}
" "Time: %{x:.3f}s
" "Amplitude: %{customdata:.1f} uV" ), customdata=signal[i], )) y_ticks = [-i * CHANNEL_SPACING for i in range(n_channels)] y_labels = [channel_names[i] if i < len(channel_names) else f"Ch{i}" for i in range(n_channels)] fig.update_layout( title=dict(text=title, font=dict(size=14)), xaxis=dict( title="Time (s)", showgrid=True, gridcolor="rgba(128,128,128,0.2)", zeroline=False, dtick=1.0, ), yaxis=dict( tickvals=y_ticks, ticktext=y_labels, showgrid=False, zeroline=False, ), height=max(500, n_channels * 30 + 120), margin=dict(l=110, r=20, t=50, b=50), plot_bgcolor="rgb(15, 15, 25)", paper_bgcolor="rgb(10, 10, 20)", font=dict(color="rgb(200, 200, 210)", size=11), showlegend=False, hovermode="x unified", dragmode="zoom", ) return fig def _add_artifact_shapes( fig: go.Figure, annotations: list[dict], view_start: float, view_end: float, n_channels: int, ) -> None: y_top = CHANNEL_SPACING y_bottom = -(n_channels - 0.5) * CHANNEL_SPACING for ann in annotations: onset = max(ann["onset_s"], view_start) end = min(ann["end_s"], view_end) if onset >= end: continue label = ann.get("label", ann.get("raw_label", "")) color = ann.get("color", "rgba(128, 128, 128, 0.3)") strong_color = color.replace("0.25)", "0.35)") fig.add_shape( type="rect", x0=onset, x1=end, y0=y_bottom, y1=y_top, fillcolor=strong_color, line=dict(width=1, color=strong_color.replace("0.35)", "0.7)")), layer="below", ) mid_x = (onset + end) / 2 if (end - onset) > 0.2: fig.add_annotation( x=mid_x, y=y_top + CHANNEL_SPACING * 0.3, text=f"{label}", showarrow=False, font=dict(size=11, color="white"), bgcolor=strong_color.replace("0.35)", "0.85)"), borderpad=3, ) def build_artifact_legend() -> str: items = [] for label, color in ARTIFACT_COLORS.items(): if label in ("Clean", "Background"): continue rgba_solid = color.replace("0.25)", "0.85)") items.append( f'
' f'' f'{label}
' ) return '
' + "".join(items) + "
" def build_metadata_html(info: dict) -> str: rows = [ ("Recording ID", info.get("recording_id", "N/A")), ("Dataset", info.get("dataset_id", "N/A")), ("Subject", info.get("subject", "N/A")), ("Session", info.get("session", "N/A")), ("Task", info.get("task", "N/A")), ("Duration", f"{info.get('duration_s', 0):.1f} s"), ("Channels", f"{info.get('n_channels', 0)} ({info.get('n_eeg_channels', 0)} EEG)"), ("Sampling Rate", f"{info.get('sampling_rate_hz', 0):.0f} Hz"), ("Reference", info.get("reference", "N/A")), ("Montage", info.get("montage_name", "N/A")), ("Format", info.get("archival_format", "N/A")), ("Roundtrip", info.get("roundtrip_class", "N/A")), ] html = '
' for label, value in rows: html += ( f'
' f'{label}: {value}
' ) html += "
" return html def build_annotation_summary(annotations: list[dict]) -> str: if not annotations: return ( '
' 'No artifact annotations in this window. ' 'Try navigating to a different time region.
' ) counts: dict[str, int] = {} total_dur: dict[str, float] = {} for ann in annotations: label = ann.get("label", "Unknown") counts[label] = counts.get(label, 0) + 1 total_dur[label] = total_dur.get(label, 0) + ann.get("duration_s", 0) html = '
' html += f'
Artifacts found: {len(annotations)}
' for label in sorted(counts.keys()): color = ARTIFACT_COLORS.get(label, "rgba(128,128,128,0.5)") rgba_solid = color.replace("0.25)", "0.85)") html += ( f'
' f'' f'{label}: ' f'{counts[label]}x, {total_dur[label]:.1f}s total' f'
' ) html += "
" return html