Upload 3 files
Browse files- backend.py +861 -0
- requirements.txt +10 -0
- site.html +2235 -0
backend.py
ADDED
|
@@ -0,0 +1,861 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
EEG Mental Imagery Classification Backend
|
| 3 |
+
Full-stack server: OpenBCI Cyton+Daisy (16ch), LSL markers, neurofeedback, websocket API
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
import math
|
| 10 |
+
import os
|
| 11 |
+
import queue
|
| 12 |
+
import threading
|
| 13 |
+
import time
|
| 14 |
+
from collections import deque
|
| 15 |
+
from dataclasses import asdict, dataclass, field
|
| 16 |
+
from datetime import datetime
|
| 17 |
+
from enum import Enum
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
| 23 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 24 |
+
from fastapi.responses import FileResponse, JSONResponse
|
| 25 |
+
import uvicorn
|
| 26 |
+
|
| 27 |
+
# ─── Optional heavy imports (graceful degradation) ───────────────────────────
|
| 28 |
+
try:
|
| 29 |
+
from brainflow.board_shim import BoardShim, BrainFlowInputParams, BoardIds, BrainFlowError
|
| 30 |
+
from brainflow.data_filter import DataFilter, FilterTypes, DetrendOperations, WindowOperations
|
| 31 |
+
BRAINFLOW_AVAILABLE = True
|
| 32 |
+
except ImportError:
|
| 33 |
+
BRAINFLOW_AVAILABLE = False
|
| 34 |
+
logging.warning("BrainFlow not installed – running in SIMULATION mode")
|
| 35 |
+
|
| 36 |
+
try:
|
| 37 |
+
from pylsl import StreamInfo, StreamOutlet, StreamInlet, resolve_streams, cf_string, cf_float32
|
| 38 |
+
LSL_AVAILABLE = True
|
| 39 |
+
except ImportError:
|
| 40 |
+
LSL_AVAILABLE = False
|
| 41 |
+
logging.warning("pylsl not installed – LSL streaming disabled")
|
| 42 |
+
|
| 43 |
+
try:
|
| 44 |
+
import scipy.signal as signal
|
| 45 |
+
from scipy.signal import welch, butter, filtfilt
|
| 46 |
+
SCIPY_AVAILABLE = True
|
| 47 |
+
except ImportError:
|
| 48 |
+
SCIPY_AVAILABLE = False
|
| 49 |
+
|
| 50 |
+
try:
|
| 51 |
+
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
|
| 52 |
+
from sklearn.preprocessing import StandardScaler
|
| 53 |
+
from sklearn.pipeline import Pipeline
|
| 54 |
+
SKLEARN_AVAILABLE = True
|
| 55 |
+
except ImportError:
|
| 56 |
+
SKLEARN_AVAILABLE = False
|
| 57 |
+
|
| 58 |
+
# ─── Logging ─────────────────────────────────────────────────────────────────
|
| 59 |
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s │ %(levelname)s │ %(message)s")
|
| 60 |
+
logger = logging.getLogger("EEG-Backend")
|
| 61 |
+
|
| 62 |
+
# ─── Constants ────────────────────────────────────────────────────────────────
|
| 63 |
+
SAMPLE_RATE = 250 # Hz – Cyton+Daisy
|
| 64 |
+
N_CHANNELS = 16
|
| 65 |
+
EPOCH_DURATION = 4.0 # seconds
|
| 66 |
+
BASELINE_DURATION = 1.0 # seconds pre-stimulus
|
| 67 |
+
BUFFER_SECONDS = 30
|
| 68 |
+
ALPHA_BAND = (8, 13)
|
| 69 |
+
BETA_BAND = (13, 30)
|
| 70 |
+
THETA_BAND = (4, 8)
|
| 71 |
+
GAMMA_BAND = (30, 45)
|
| 72 |
+
|
| 73 |
+
# Standard 10-20 positions for 16-ch parieto-occipital cap
|
| 74 |
+
ELECTRODE_POSITIONS_16CH = {
|
| 75 |
+
"P3": (-0.30, 0.50), "Pz": (0.00, 0.58), "P4": (0.30, 0.50),
|
| 76 |
+
"P7": (-0.55, 0.42), "P8": (0.55, 0.42),
|
| 77 |
+
"PO3": (-0.22, 0.38), "POz": (0.00, 0.38), "PO4": (0.22, 0.38),
|
| 78 |
+
"PO7": (-0.40, 0.28), "PO8": (0.40, 0.28),
|
| 79 |
+
"O1": (-0.20, 0.18), "Oz": (0.00, 0.18), "O2": (0.20, 0.18),
|
| 80 |
+
"CP3": (-0.25, 0.68), "CPz": (0.00, 0.72), "CP4": (0.25, 0.68),
|
| 81 |
+
}
|
| 82 |
+
CHANNEL_NAMES = list(ELECTRODE_POSITIONS_16CH.keys())
|
| 83 |
+
|
| 84 |
+
# ─── Data Structures ──────────────────────────────────────────────────────────
|
| 85 |
+
|
| 86 |
+
class SessionPhase(str, Enum):
|
| 87 |
+
IDLE = "IDLE"
|
| 88 |
+
BASELINE = "BASELINE"
|
| 89 |
+
ACQUISITION = "ACQUISITION"
|
| 90 |
+
FEEDBACK = "FEEDBACK"
|
| 91 |
+
CONVERGENCE = "CONVERGENCE"
|
| 92 |
+
LIBRARY = "LIBRARY"
|
| 93 |
+
|
| 94 |
+
@dataclass
|
| 95 |
+
class Trial:
|
| 96 |
+
trial_id: int
|
| 97 |
+
class_label: str
|
| 98 |
+
onset_time: float
|
| 99 |
+
quality_score: Optional[int] = None # 1-5 self-report
|
| 100 |
+
eeg_epoch: Optional[np.ndarray] = None # (n_channels, n_samples)
|
| 101 |
+
features: Optional[np.ndarray] = None
|
| 102 |
+
nf_score: Optional[float] = None # neurofeedback distance [0,1]
|
| 103 |
+
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
|
| 104 |
+
|
| 105 |
+
def to_dict(self) -> dict:
|
| 106 |
+
return {
|
| 107 |
+
"trial_id": self.trial_id,
|
| 108 |
+
"class_label": self.class_label,
|
| 109 |
+
"onset_time": self.onset_time,
|
| 110 |
+
"quality_score": self.quality_score,
|
| 111 |
+
"nf_score": self.nf_score,
|
| 112 |
+
"timestamp": self.timestamp,
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
@dataclass
|
| 116 |
+
class NeuralState:
|
| 117 |
+
"""Stable neural state = cluster centroid in feature space"""
|
| 118 |
+
class_label: str
|
| 119 |
+
centroid: np.ndarray
|
| 120 |
+
covariance: np.ndarray
|
| 121 |
+
n_trials: int
|
| 122 |
+
convergence_score: float # how tight the cluster is [0,1]
|
| 123 |
+
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
| 124 |
+
|
| 125 |
+
@dataclass
|
| 126 |
+
class SessionConfig:
|
| 127 |
+
subject_id: str = "S001"
|
| 128 |
+
session_id: str = field(default_factory=lambda: datetime.now().strftime("%Y%m%d_%H%M%S"))
|
| 129 |
+
class_a: str = "Class_A"
|
| 130 |
+
class_b: str = "Class_B"
|
| 131 |
+
n_trials_per_class: int = 20
|
| 132 |
+
min_quality_for_model: int = 4 # only use trials rated ≥ 4
|
| 133 |
+
feedback_threshold: float = 0.65 # similarity to converge
|
| 134 |
+
port: str = "/dev/ttyUSB0"
|
| 135 |
+
board_id: int = 2 # 2=Cyton+Daisy
|
| 136 |
+
simulate: bool = not BRAINFLOW_AVAILABLE
|
| 137 |
+
|
| 138 |
+
# ─── Signal Processing ────────────────────────────────────────────────────────
|
| 139 |
+
|
| 140 |
+
class SignalProcessor:
|
| 141 |
+
def __init__(self, srate: int = SAMPLE_RATE, n_channels: int = N_CHANNELS):
|
| 142 |
+
self.srate = srate
|
| 143 |
+
self.n_channels = n_channels
|
| 144 |
+
self._design_filters()
|
| 145 |
+
|
| 146 |
+
def _design_filters(self):
|
| 147 |
+
"""Pre-compute Butterworth filters"""
|
| 148 |
+
nyq = self.srate / 2
|
| 149 |
+
self.bp_coefs = {}
|
| 150 |
+
for name, (lo, hi) in [("broad", (1, 45)), ("alpha", ALPHA_BAND),
|
| 151 |
+
("beta", BETA_BAND), ("theta", THETA_BAND)]:
|
| 152 |
+
b, a = butter(4, [lo/nyq, hi/nyq], btype="band")
|
| 153 |
+
self.bp_coefs[name] = (b, a)
|
| 154 |
+
# Notch 50/60 Hz
|
| 155 |
+
b60, a60 = butter(4, [58/nyq, 62/nyq], btype="bandstop")
|
| 156 |
+
b50, a50 = butter(4, [48/nyq, 52/nyq], btype="bandstop")
|
| 157 |
+
self.notch_60 = (b60, a60)
|
| 158 |
+
self.notch_50 = (b50, a50)
|
| 159 |
+
|
| 160 |
+
def preprocess(self, epoch: np.ndarray) -> np.ndarray:
|
| 161 |
+
"""Band-pass + notch filter. epoch: (n_ch, n_samples)"""
|
| 162 |
+
out = epoch.copy().astype(float)
|
| 163 |
+
b, a = self.bp_coefs["broad"]
|
| 164 |
+
bn60, an60 = self.notch_60
|
| 165 |
+
for ch in range(out.shape[0]):
|
| 166 |
+
out[ch] = filtfilt(b, a, out[ch])
|
| 167 |
+
out[ch] = filtfilt(bn60, an60, out[ch])
|
| 168 |
+
# Remove DC
|
| 169 |
+
out[ch] -= out[ch].mean()
|
| 170 |
+
return out
|
| 171 |
+
|
| 172 |
+
def compute_psd(self, epoch: np.ndarray, fmax: float = 50.0) -> Tuple[np.ndarray, np.ndarray]:
|
| 173 |
+
"""Welch PSD per channel. Returns (freqs, psd): psd shape (n_ch, n_freqs)"""
|
| 174 |
+
nperseg = min(self.srate, epoch.shape[1])
|
| 175 |
+
freqs, psd = welch(epoch, fs=self.srate, nperseg=nperseg, axis=1)
|
| 176 |
+
mask = freqs <= fmax
|
| 177 |
+
return freqs[mask], psd[:, mask]
|
| 178 |
+
|
| 179 |
+
def band_power(self, epoch: np.ndarray) -> Dict[str, np.ndarray]:
|
| 180 |
+
"""Band power per channel per band. Returns dict of (n_ch,) arrays"""
|
| 181 |
+
freqs, psd = self.compute_psd(epoch)
|
| 182 |
+
powers = {}
|
| 183 |
+
for name, (lo, hi) in [("alpha", ALPHA_BAND), ("beta", BETA_BAND),
|
| 184 |
+
("theta", THETA_BAND), ("gamma", GAMMA_BAND)]:
|
| 185 |
+
idx = np.logical_and(freqs >= lo, freqs <= hi)
|
| 186 |
+
powers[name] = np.trapz(psd[:, idx], freqs[idx], axis=1)
|
| 187 |
+
return powers
|
| 188 |
+
|
| 189 |
+
def extract_features(self, epoch: np.ndarray) -> np.ndarray:
|
| 190 |
+
"""
|
| 191 |
+
Feature vector: band-power ratios + log-band-powers + covariance diagonal
|
| 192 |
+
Returns 1D numpy array
|
| 193 |
+
"""
|
| 194 |
+
clean = self.preprocess(epoch)
|
| 195 |
+
bp = self.band_power(clean)
|
| 196 |
+
alpha = bp["alpha"] + 1e-10
|
| 197 |
+
beta = bp["beta"] + 1e-10
|
| 198 |
+
theta = bp["theta"] + 1e-10
|
| 199 |
+
gamma = bp["gamma"] + 1e-10
|
| 200 |
+
|
| 201 |
+
feats = []
|
| 202 |
+
feats.extend(np.log(alpha)) # 16 log-alpha
|
| 203 |
+
feats.extend(np.log(beta)) # 16 log-beta
|
| 204 |
+
feats.extend(np.log(theta)) # 16 log-theta
|
| 205 |
+
feats.extend(beta / alpha) # 16 beta/alpha
|
| 206 |
+
feats.extend(theta / alpha) # 16 theta/alpha
|
| 207 |
+
# Covariance diagonal (channel variances)
|
| 208 |
+
cov = np.cov(clean)
|
| 209 |
+
feats.extend(np.log(np.diag(cov) + 1e-10)) # 16 log-var
|
| 210 |
+
|
| 211 |
+
# Frontal asymmetry (not applicable for occipital, skip ratio index)
|
| 212 |
+
return np.array(feats, dtype=float)
|
| 213 |
+
|
| 214 |
+
def compute_topomap(self, epoch: np.ndarray) -> Dict[str, float]:
|
| 215 |
+
"""Return per-channel alpha power for topomap display"""
|
| 216 |
+
clean = self.preprocess(epoch)
|
| 217 |
+
bp = self.band_power(clean)
|
| 218 |
+
return {name: float(val) for name, val in zip(CHANNEL_NAMES, bp["alpha"])}
|
| 219 |
+
|
| 220 |
+
def artifact_rejection(self, epoch: np.ndarray, threshold_uv: float = 100.0) -> bool:
|
| 221 |
+
"""Returns True if epoch is clean"""
|
| 222 |
+
peak_to_peak = epoch.max(axis=1) - epoch.min(axis=1)
|
| 223 |
+
return bool(np.all(peak_to_peak < threshold_uv * 1e-6)) # BrainFlow in Volts
|
| 224 |
+
|
| 225 |
+
# ─── Neurofeedback Engine ─────────────────────────────────────────────────────
|
| 226 |
+
|
| 227 |
+
class NeurofeedbackEngine:
|
| 228 |
+
"""
|
| 229 |
+
Computes similarity between current epoch features and stored neural state centroids.
|
| 230 |
+
Score ∈ [0,1] where 1 = perfectly matching the target state.
|
| 231 |
+
"""
|
| 232 |
+
def __init__(self):
|
| 233 |
+
self.states: Dict[str, NeuralState] = {}
|
| 234 |
+
|
| 235 |
+
def update_state(self, class_label: str, features_list: List[np.ndarray]):
|
| 236 |
+
"""Build/update stable state from a list of high-quality feature vectors"""
|
| 237 |
+
if len(features_list) < 3:
|
| 238 |
+
return
|
| 239 |
+
mat = np.stack(features_list)
|
| 240 |
+
centroid = mat.mean(axis=0)
|
| 241 |
+
cov = np.cov(mat.T) + np.eye(mat.shape[1]) * 1e-6
|
| 242 |
+
# Convergence = 1 - normalised mean intra-cluster distance
|
| 243 |
+
dists = [np.linalg.norm(f - centroid) for f in features_list]
|
| 244 |
+
convergence = max(0.0, 1.0 - np.mean(dists) / (np.std(dists) + 1.0))
|
| 245 |
+
self.states[class_label] = NeuralState(
|
| 246 |
+
class_label=class_label,
|
| 247 |
+
centroid=centroid,
|
| 248 |
+
covariance=cov,
|
| 249 |
+
n_trials=len(features_list),
|
| 250 |
+
convergence_score=float(convergence),
|
| 251 |
+
)
|
| 252 |
+
logger.info(f"Neural state updated for {class_label}: convergence={convergence:.3f}")
|
| 253 |
+
|
| 254 |
+
def compute_similarity(self, features: np.ndarray, class_label: str) -> float:
|
| 255 |
+
"""Mahalanobis-based similarity to target state"""
|
| 256 |
+
if class_label not in self.states:
|
| 257 |
+
return 0.0
|
| 258 |
+
state = self.states[class_label]
|
| 259 |
+
diff = features - state.centroid
|
| 260 |
+
try:
|
| 261 |
+
inv_cov = np.linalg.pinv(state.covariance)
|
| 262 |
+
dist = float(np.sqrt(diff @ inv_cov @ diff))
|
| 263 |
+
# Sigmoid mapping: dist=0 → score=1, dist=5 → score≈0.5
|
| 264 |
+
score = 1.0 / (1.0 + dist / 3.0)
|
| 265 |
+
except Exception:
|
| 266 |
+
score = 0.0
|
| 267 |
+
return float(np.clip(score, 0.0, 1.0))
|
| 268 |
+
|
| 269 |
+
def get_feedback_audio_params(self, score: float) -> dict:
|
| 270 |
+
"""Returns audio synthesis params based on NF score"""
|
| 271 |
+
freq_hz = 200 + score * 600 # 200–800 Hz
|
| 272 |
+
volume = 0.3 + score * 0.7 # 0.3–1.0
|
| 273 |
+
tone = "positive" if score > 0.65 else ("neutral" if score > 0.35 else "negative")
|
| 274 |
+
return {"freq_hz": round(freq_hz, 1), "volume": round(volume, 3), "tone": tone}
|
| 275 |
+
|
| 276 |
+
# ─── LDA Classifier ───────────────────────────────────────────────────────────
|
| 277 |
+
|
| 278 |
+
class EEGClassifier:
|
| 279 |
+
def __init__(self):
|
| 280 |
+
if SKLEARN_AVAILABLE:
|
| 281 |
+
self.model = Pipeline([
|
| 282 |
+
("scaler", StandardScaler()),
|
| 283 |
+
("lda", LinearDiscriminantAnalysis(solver="svd")),
|
| 284 |
+
])
|
| 285 |
+
self.is_trained = False
|
| 286 |
+
self.classes_: List[str] = []
|
| 287 |
+
|
| 288 |
+
def fit(self, X: np.ndarray, y: List[str]):
|
| 289 |
+
if not SKLEARN_AVAILABLE or len(X) < 6:
|
| 290 |
+
return False
|
| 291 |
+
self.model.fit(X, y)
|
| 292 |
+
self.is_trained = True
|
| 293 |
+
self.classes_ = list(np.unique(y))
|
| 294 |
+
logger.info(f"LDA trained on {len(X)} epochs, classes={self.classes_}")
|
| 295 |
+
return True
|
| 296 |
+
|
| 297 |
+
def predict_proba(self, features: np.ndarray) -> Dict[str, float]:
|
| 298 |
+
if not self.is_trained:
|
| 299 |
+
return {}
|
| 300 |
+
proba = self.model.predict_proba(features.reshape(1, -1))[0]
|
| 301 |
+
return {cls: float(p) for cls, p in zip(self.classes_, proba)}
|
| 302 |
+
|
| 303 |
+
# ─── LSL Manager ──────────────────────────────────────────────────────────────
|
| 304 |
+
|
| 305 |
+
class LSLManager:
|
| 306 |
+
def __init__(self, stream_name: str = "EEGMarkers"):
|
| 307 |
+
self.outlet = None
|
| 308 |
+
self.eeg_outlet = None
|
| 309 |
+
if LSL_AVAILABLE:
|
| 310 |
+
self._init_marker_stream(stream_name)
|
| 311 |
+
self._init_eeg_stream()
|
| 312 |
+
|
| 313 |
+
def _init_marker_stream(self, name: str):
|
| 314 |
+
info = StreamInfo(name, "Markers", 1, 0, cf_string, f"markers-{name}")
|
| 315 |
+
info.desc().append_child_value("manufacturer", "EEG-MI-Backend")
|
| 316 |
+
self.outlet = StreamOutlet(info)
|
| 317 |
+
logger.info(f"LSL marker stream '{name}' opened")
|
| 318 |
+
|
| 319 |
+
def _init_eeg_stream(self):
|
| 320 |
+
info = StreamInfo("EEG-MI", "EEG", N_CHANNELS, SAMPLE_RATE, cf_float32, "eeg-mi")
|
| 321 |
+
chns = info.desc().append_child("channels")
|
| 322 |
+
for name in CHANNEL_NAMES:
|
| 323 |
+
ch = chns.append_child("channel")
|
| 324 |
+
ch.append_child_value("label", name)
|
| 325 |
+
ch.append_child_value("unit", "microvolts")
|
| 326 |
+
ch.append_child_value("type", "EEG")
|
| 327 |
+
self.eeg_outlet = StreamOutlet(info, 32, 360)
|
| 328 |
+
logger.info("LSL EEG stream opened")
|
| 329 |
+
|
| 330 |
+
def push_marker(self, marker: str):
|
| 331 |
+
if self.outlet:
|
| 332 |
+
self.outlet.push_sample([marker])
|
| 333 |
+
logger.debug(f"LSL marker: {marker}")
|
| 334 |
+
|
| 335 |
+
def push_eeg_chunk(self, chunk: np.ndarray):
|
| 336 |
+
"""chunk: (n_samples, n_channels)"""
|
| 337 |
+
if self.eeg_outlet and chunk.size > 0:
|
| 338 |
+
self.eeg_outlet.push_chunk(chunk.tolist())
|
| 339 |
+
|
| 340 |
+
# ─── Board Manager ────────────────────────────────────────────────────────────
|
| 341 |
+
|
| 342 |
+
class BoardManager:
|
| 343 |
+
"""Handles Cyton+Daisy board or simulation"""
|
| 344 |
+
|
| 345 |
+
def __init__(self, config: SessionConfig):
|
| 346 |
+
self.config = config
|
| 347 |
+
self.board = None
|
| 348 |
+
self.board_id = config.board_id
|
| 349 |
+
self.srate = SAMPLE_RATE
|
| 350 |
+
self._running = False
|
| 351 |
+
self._thread: Optional[threading.Thread] = None
|
| 352 |
+
self._buffer = deque(maxlen=BUFFER_SECONDS * SAMPLE_RATE)
|
| 353 |
+
self._lock = threading.Lock()
|
| 354 |
+
self.connected = False
|
| 355 |
+
|
| 356 |
+
# Simulation state
|
| 357 |
+
self._sim_t = 0.0
|
| 358 |
+
self._sim_phase = 0.0
|
| 359 |
+
|
| 360 |
+
def connect(self) -> bool:
|
| 361 |
+
if self.config.simulate:
|
| 362 |
+
logger.info("Board: SIMULATION mode active")
|
| 363 |
+
self.connected = True
|
| 364 |
+
return True
|
| 365 |
+
|
| 366 |
+
if not BRAINFLOW_AVAILABLE:
|
| 367 |
+
logger.error("BrainFlow not available; cannot connect to hardware")
|
| 368 |
+
return False
|
| 369 |
+
|
| 370 |
+
params = BrainFlowInputParams()
|
| 371 |
+
params.serial_port = self.config.port
|
| 372 |
+
try:
|
| 373 |
+
BoardShim.enable_dev_board_logger()
|
| 374 |
+
self.board = BoardShim(self.board_id, params)
|
| 375 |
+
self.board.prepare_session()
|
| 376 |
+
self.board.start_stream(45000)
|
| 377 |
+
self.srate = BoardShim.get_sampling_rate(self.board_id)
|
| 378 |
+
self.connected = True
|
| 379 |
+
logger.info(f"Cyton+Daisy connected: {self.config.port} @ {self.srate} Hz")
|
| 380 |
+
return True
|
| 381 |
+
except BrainFlowError as e:
|
| 382 |
+
logger.error(f"Board connection failed: {e}")
|
| 383 |
+
return False
|
| 384 |
+
|
| 385 |
+
def disconnect(self):
|
| 386 |
+
self._running = False
|
| 387 |
+
if self.board and BRAINFLOW_AVAILABLE:
|
| 388 |
+
try:
|
| 389 |
+
self.board.stop_stream()
|
| 390 |
+
self.board.release_session()
|
| 391 |
+
except Exception:
|
| 392 |
+
pass
|
| 393 |
+
self.connected = False
|
| 394 |
+
|
| 395 |
+
def start_acquisition(self):
|
| 396 |
+
self._running = True
|
| 397 |
+
self._thread = threading.Thread(target=self._acquisition_loop, daemon=True)
|
| 398 |
+
self._thread.start()
|
| 399 |
+
|
| 400 |
+
def stop_acquisition(self):
|
| 401 |
+
self._running = False
|
| 402 |
+
|
| 403 |
+
def _acquisition_loop(self):
|
| 404 |
+
while self._running:
|
| 405 |
+
if self.config.simulate:
|
| 406 |
+
chunk = self._generate_sim_chunk(50) # 50 samples at a time
|
| 407 |
+
else:
|
| 408 |
+
chunk = self._read_board_chunk()
|
| 409 |
+
|
| 410 |
+
if chunk is not None and chunk.shape[1] > 0:
|
| 411 |
+
with self._lock:
|
| 412 |
+
for s in range(chunk.shape[1]):
|
| 413 |
+
self._buffer.append(chunk[:, s])
|
| 414 |
+
time.sleep(0.05)
|
| 415 |
+
|
| 416 |
+
def _read_board_chunk(self) -> Optional[np.ndarray]:
|
| 417 |
+
try:
|
| 418 |
+
data = self.board.get_board_data()
|
| 419 |
+
eeg_channels = BoardShim.get_eeg_channels(self.board_id)
|
| 420 |
+
if data.shape[1] == 0:
|
| 421 |
+
return None
|
| 422 |
+
return data[eeg_channels[:N_CHANNELS], :] # (16, n_samples)
|
| 423 |
+
except Exception as e:
|
| 424 |
+
logger.warning(f"Board read error: {e}")
|
| 425 |
+
return None
|
| 426 |
+
|
| 427 |
+
def _generate_sim_chunk(self, n_samples: int) -> np.ndarray:
|
| 428 |
+
"""Realistic EEG simulation: alpha rhythm + noise + ocular artifacts"""
|
| 429 |
+
chunk = np.zeros((N_CHANNELS, n_samples))
|
| 430 |
+
t = np.linspace(self._sim_t, self._sim_t + n_samples/SAMPLE_RATE, n_samples)
|
| 431 |
+
self._sim_t += n_samples / SAMPLE_RATE
|
| 432 |
+
|
| 433 |
+
for ch in range(N_CHANNELS):
|
| 434 |
+
# Alpha (8-12 Hz) with spatial gradient
|
| 435 |
+
alpha_amp = (0.5 + 0.5 * math.sin(ch * 0.4)) * 15e-6
|
| 436 |
+
alpha = alpha_amp * np.sin(2 * np.pi * 10 * t + ch * 0.3)
|
| 437 |
+
# Beta (13-25 Hz)
|
| 438 |
+
beta = 5e-6 * np.sin(2 * np.pi * 20 * t + ch * 0.6)
|
| 439 |
+
# Theta (4-8 Hz) – stronger in frontal (not this cap, but simulated)
|
| 440 |
+
theta = 8e-6 * np.sin(2 * np.pi * 6 * t)
|
| 441 |
+
# White noise
|
| 442 |
+
noise = np.random.randn(n_samples) * 3e-6
|
| 443 |
+
chunk[ch] = alpha + beta + theta + noise
|
| 444 |
+
|
| 445 |
+
# Occasional blink artifact on ch 0-1
|
| 446 |
+
if np.random.rand() < 0.02:
|
| 447 |
+
idx = np.random.randint(0, max(1, n_samples - 20))
|
| 448 |
+
pulse = np.hanning(20) * 150e-6
|
| 449 |
+
end = min(idx + 20, n_samples)
|
| 450 |
+
chunk[0, idx:end] += pulse[:end-idx]
|
| 451 |
+
chunk[1, idx:end] += pulse[:end-idx] * 0.5
|
| 452 |
+
|
| 453 |
+
return chunk
|
| 454 |
+
|
| 455 |
+
def get_epoch(self, duration: float, offset: float = 0.0) -> Optional[np.ndarray]:
|
| 456 |
+
"""Extract latest epoch of `duration` seconds. Returns (n_ch, n_samples)"""
|
| 457 |
+
n_wanted = int((duration + offset) * SAMPLE_RATE)
|
| 458 |
+
with self._lock:
|
| 459 |
+
buf = list(self._buffer)
|
| 460 |
+
if len(buf) < n_wanted:
|
| 461 |
+
return None
|
| 462 |
+
arr = np.array(buf[-n_wanted:]).T # (n_ch, n_samples)
|
| 463 |
+
# Return only the epoch portion (skip offset)
|
| 464 |
+
n_offset = int(offset * SAMPLE_RATE)
|
| 465 |
+
return arr[:, n_offset:]
|
| 466 |
+
|
| 467 |
+
def get_psd_snapshot(self) -> Optional[np.ndarray]:
|
| 468 |
+
"""Latest 2-second window for live display (n_ch, n_samples)"""
|
| 469 |
+
return self.get_epoch(2.0)
|
| 470 |
+
|
| 471 |
+
# ─── Session Manager ──────────────────────────────────────────────────────────
|
| 472 |
+
|
| 473 |
+
class SessionManager:
|
| 474 |
+
def __init__(self):
|
| 475 |
+
self.config = SessionConfig()
|
| 476 |
+
self.phase = SessionPhase.IDLE
|
| 477 |
+
self.trials: List[Trial] = []
|
| 478 |
+
self.trial_counter = 0
|
| 479 |
+
self.current_class: Optional[str] = None
|
| 480 |
+
self.target_class: Optional[str] = None
|
| 481 |
+
|
| 482 |
+
self.processor = SignalProcessor()
|
| 483 |
+
self.nf_engine = NeurofeedbackEngine()
|
| 484 |
+
self.classifier = EEGClassifier()
|
| 485 |
+
self.lsl = LSLManager()
|
| 486 |
+
self.board = BoardManager(self.config)
|
| 487 |
+
|
| 488 |
+
self._ws_clients: List[WebSocket] = []
|
| 489 |
+
self._event_queue: asyncio.Queue = asyncio.Queue()
|
| 490 |
+
self._current_loop: Optional[asyncio.AbstractEventLoop] = None
|
| 491 |
+
|
| 492 |
+
# ── Connection Management ──────────────────────────────────────────────
|
| 493 |
+
|
| 494 |
+
def connect_board(self) -> dict:
|
| 495 |
+
ok = self.board.connect()
|
| 496 |
+
if ok:
|
| 497 |
+
self.board.start_acquisition()
|
| 498 |
+
self.lsl.push_marker("SESSION_START")
|
| 499 |
+
return {"status": "connected" if ok else "error", "simulate": self.config.simulate}
|
| 500 |
+
|
| 501 |
+
def disconnect_board(self):
|
| 502 |
+
self.board.disconnect()
|
| 503 |
+
self.lsl.push_marker("SESSION_END")
|
| 504 |
+
|
| 505 |
+
# ── Trial Management ───────────────────────────────────────────────────
|
| 506 |
+
|
| 507 |
+
def start_trial(self, class_label: str) -> Trial:
|
| 508 |
+
self.trial_counter += 1
|
| 509 |
+
onset = time.time()
|
| 510 |
+
trial = Trial(
|
| 511 |
+
trial_id=self.trial_counter,
|
| 512 |
+
class_label=class_label,
|
| 513 |
+
onset_time=onset,
|
| 514 |
+
)
|
| 515 |
+
self.trials.append(trial)
|
| 516 |
+
self.current_class = class_label
|
| 517 |
+
self.phase = SessionPhase.ACQUISITION
|
| 518 |
+
|
| 519 |
+
marker = f"TRIAL_START;class={class_label};id={self.trial_counter}"
|
| 520 |
+
self.lsl.push_marker(marker)
|
| 521 |
+
logger.info(f"Trial {self.trial_counter} started: {class_label}")
|
| 522 |
+
return trial
|
| 523 |
+
|
| 524 |
+
def end_trial(self, quality: int) -> dict:
|
| 525 |
+
"""Called after subject rates the trial 1-5"""
|
| 526 |
+
active = self._get_active_trial()
|
| 527 |
+
if not active:
|
| 528 |
+
return {"error": "no active trial"}
|
| 529 |
+
|
| 530 |
+
active.quality_score = quality
|
| 531 |
+
self.lsl.push_marker(f"TRIAL_END;id={active.trial_id};quality={quality}")
|
| 532 |
+
|
| 533 |
+
# Extract epoch (4s before now, skip first 0.5s baseline)
|
| 534 |
+
epoch = self.board.get_epoch(EPOCH_DURATION, offset=0.5)
|
| 535 |
+
if epoch is not None:
|
| 536 |
+
# Artifact check
|
| 537 |
+
clean = self.processor.artifact_rejection(epoch)
|
| 538 |
+
if clean:
|
| 539 |
+
active.eeg_epoch = epoch
|
| 540 |
+
active.features = self.processor.extract_features(epoch)
|
| 541 |
+
logger.info(f"Trial {active.trial_id}: clean epoch extracted, quality={quality}")
|
| 542 |
+
else:
|
| 543 |
+
logger.warning(f"Trial {active.trial_id}: artifact detected – epoch discarded")
|
| 544 |
+
self.lsl.push_marker(f"ARTIFACT;id={active.trial_id}")
|
| 545 |
+
|
| 546 |
+
# Update model if quality ≥ threshold
|
| 547 |
+
self._maybe_update_model(active.class_label)
|
| 548 |
+
|
| 549 |
+
# Compute NF score if model exists
|
| 550 |
+
nf = 0.0
|
| 551 |
+
if active.features is not None:
|
| 552 |
+
nf = self.nf_engine.compute_similarity(active.features, active.class_label)
|
| 553 |
+
active.nf_score = nf
|
| 554 |
+
|
| 555 |
+
return {
|
| 556 |
+
"trial_id": active.trial_id,
|
| 557 |
+
"quality": quality,
|
| 558 |
+
"nf_score": round(nf, 4),
|
| 559 |
+
"feedback": self.nf_engine.get_feedback_audio_params(nf),
|
| 560 |
+
"model_updated": active.class_label in self.nf_engine.states,
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
def _get_active_trial(self) -> Optional[Trial]:
|
| 564 |
+
for t in reversed(self.trials):
|
| 565 |
+
if t.quality_score is None:
|
| 566 |
+
return t
|
| 567 |
+
return None
|
| 568 |
+
|
| 569 |
+
def _maybe_update_model(self, class_label: str):
|
| 570 |
+
"""Rebuild neural state from high-quality trials"""
|
| 571 |
+
good_features = [
|
| 572 |
+
t.features for t in self.trials
|
| 573 |
+
if t.class_label == class_label
|
| 574 |
+
and t.quality_score is not None
|
| 575 |
+
and t.quality_score >= self.config.min_quality_for_model
|
| 576 |
+
and t.features is not None
|
| 577 |
+
]
|
| 578 |
+
if len(good_features) >= 3:
|
| 579 |
+
self.nf_engine.update_state(class_label, good_features)
|
| 580 |
+
# Retrain classifier if both classes have data
|
| 581 |
+
self._retrain_classifier()
|
| 582 |
+
|
| 583 |
+
def _retrain_classifier(self):
|
| 584 |
+
X, y = [], []
|
| 585 |
+
for t in self.trials:
|
| 586 |
+
if t.features is not None and t.quality_score is not None \
|
| 587 |
+
and t.quality_score >= self.config.min_quality_for_model:
|
| 588 |
+
X.append(t.features)
|
| 589 |
+
y.append(t.class_label)
|
| 590 |
+
if len(set(y)) >= 2:
|
| 591 |
+
self.classifier.fit(np.array(X), y)
|
| 592 |
+
|
| 593 |
+
# ── Live Signals ───────────────────────────────────────────────────────
|
| 594 |
+
|
| 595 |
+
def get_live_signals(self) -> dict:
|
| 596 |
+
epoch = self.board.get_psd_snapshot()
|
| 597 |
+
if epoch is None:
|
| 598 |
+
# Return synthetic zeros
|
| 599 |
+
return {
|
| 600 |
+
"channels": CHANNEL_NAMES,
|
| 601 |
+
"alpha_power": [0.0] * N_CHANNELS,
|
| 602 |
+
"beta_power": [0.0] * N_CHANNELS,
|
| 603 |
+
"theta_power": [0.0] * N_CHANNELS,
|
| 604 |
+
"raw_samples": [[0.0] * 50] * 4, # 4 channels preview
|
| 605 |
+
"topomap": {n: 0.0 for n in CHANNEL_NAMES},
|
| 606 |
+
"signal_quality": [0.0] * N_CHANNELS,
|
| 607 |
+
}
|
| 608 |
+
|
| 609 |
+
try:
|
| 610 |
+
clean = self.processor.preprocess(epoch)
|
| 611 |
+
bp = self.processor.band_power(clean)
|
| 612 |
+
topo = self.processor.compute_topomap(epoch)
|
| 613 |
+
|
| 614 |
+
# Signal quality: inverse of variance relative to expected range
|
| 615 |
+
peak2peak = (epoch.max(axis=1) - epoch.min(axis=1)) * 1e6 # µV
|
| 616 |
+
quality = [float(np.clip(1.0 - (pp - 10) / 90.0, 0.0, 1.0)) for pp in peak2peak]
|
| 617 |
+
|
| 618 |
+
# Raw traces for 4 selected channels (µV)
|
| 619 |
+
sel = [0, 4, 8, 12]
|
| 620 |
+
raw = (clean[sel, -50:] * 1e6).tolist() # last 200ms
|
| 621 |
+
|
| 622 |
+
return {
|
| 623 |
+
"channels": CHANNEL_NAMES,
|
| 624 |
+
"alpha_power": [float(v * 1e12) for v in bp["alpha"]],
|
| 625 |
+
"beta_power": [float(v * 1e12) for v in bp["beta"]],
|
| 626 |
+
"theta_power": [float(v * 1e12) for v in bp["theta"]],
|
| 627 |
+
"raw_samples": raw,
|
| 628 |
+
"topomap": {k: float(v * 1e12) for k, v in topo.items()},
|
| 629 |
+
"signal_quality": quality,
|
| 630 |
+
}
|
| 631 |
+
except Exception as e:
|
| 632 |
+
logger.warning(f"Live signal error: {e}")
|
| 633 |
+
return {}
|
| 634 |
+
|
| 635 |
+
# ── Session State ──────────────────────────────────────────────────────
|
| 636 |
+
|
| 637 |
+
def get_state(self) -> dict:
|
| 638 |
+
class_a_trials = [t.to_dict() for t in self.trials if t.class_label == self.config.class_a]
|
| 639 |
+
class_b_trials = [t.to_dict() for t in self.trials if t.class_label == self.config.class_b]
|
| 640 |
+
|
| 641 |
+
states = {}
|
| 642 |
+
for label, state in self.nf_engine.states.items():
|
| 643 |
+
states[label] = {
|
| 644 |
+
"n_trials": state.n_trials,
|
| 645 |
+
"convergence_score": round(state.convergence_score, 4),
|
| 646 |
+
"created_at": state.created_at,
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
return {
|
| 650 |
+
"phase": self.phase.value,
|
| 651 |
+
"config": {
|
| 652 |
+
"subject_id": self.config.subject_id,
|
| 653 |
+
"session_id": self.config.session_id,
|
| 654 |
+
"class_a": self.config.class_a,
|
| 655 |
+
"class_b": self.config.class_b,
|
| 656 |
+
"simulate": self.config.simulate,
|
| 657 |
+
"min_quality_for_model": self.config.min_quality_for_model,
|
| 658 |
+
},
|
| 659 |
+
"class_a_trials": class_a_trials,
|
| 660 |
+
"class_b_trials": class_b_trials,
|
| 661 |
+
"neural_states": states,
|
| 662 |
+
"classifier_trained": self.classifier.is_trained,
|
| 663 |
+
"board_connected": self.board.connected,
|
| 664 |
+
"total_trials": len(self.trials),
|
| 665 |
+
}
|
| 666 |
+
|
| 667 |
+
# ── WebSocket Broadcast ────────────────────────────────────────────────
|
| 668 |
+
|
| 669 |
+
def add_ws_client(self, ws: WebSocket):
|
| 670 |
+
self._ws_clients.append(ws)
|
| 671 |
+
|
| 672 |
+
def remove_ws_client(self, ws: WebSocket):
|
| 673 |
+
if ws in self._ws_clients:
|
| 674 |
+
self._ws_clients.remove(ws)
|
| 675 |
+
|
| 676 |
+
async def broadcast(self, data: dict):
|
| 677 |
+
dead = []
|
| 678 |
+
for ws in self._ws_clients:
|
| 679 |
+
try:
|
| 680 |
+
await ws.send_json(data)
|
| 681 |
+
except Exception:
|
| 682 |
+
dead.append(ws)
|
| 683 |
+
for ws in dead:
|
| 684 |
+
self.remove_ws_client(ws)
|
| 685 |
+
|
| 686 |
+
async def live_broadcast_loop(self):
|
| 687 |
+
"""Continuously push live EEG data to all connected WS clients"""
|
| 688 |
+
while True:
|
| 689 |
+
await asyncio.sleep(0.1) # 10 Hz update
|
| 690 |
+
if self._ws_clients and self.board.connected:
|
| 691 |
+
live = self.get_live_signals()
|
| 692 |
+
if live:
|
| 693 |
+
live["type"] = "live_eeg"
|
| 694 |
+
live["timestamp"] = time.time()
|
| 695 |
+
await self.broadcast(live)
|
| 696 |
+
|
| 697 |
+
# ─── FastAPI App ──────────────────────────────────────────────────────────────
|
| 698 |
+
|
| 699 |
+
ROOT_DIR = Path(__file__).resolve().parent
|
| 700 |
+
SITE_HTML = ROOT_DIR / "site.html"
|
| 701 |
+
|
| 702 |
+
app = FastAPI(title="EEG Mental Imagery Backend", version="2.0.0")
|
| 703 |
+
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
| 704 |
+
|
| 705 |
+
session = SessionManager()
|
| 706 |
+
|
| 707 |
+
|
| 708 |
+
@app.get("/")
|
| 709 |
+
def serve_ui():
|
| 710 |
+
"""Serve the project UI (site.html) from the same origin as the API / WebSocket."""
|
| 711 |
+
if not SITE_HTML.is_file():
|
| 712 |
+
return JSONResponse(
|
| 713 |
+
status_code=404,
|
| 714 |
+
content={"error": "site.html not found", "path": str(SITE_HTML)},
|
| 715 |
+
)
|
| 716 |
+
return FileResponse(SITE_HTML, media_type="text/html; charset=utf-8")
|
| 717 |
+
|
| 718 |
+
@app.on_event("startup")
|
| 719 |
+
async def startup():
|
| 720 |
+
asyncio.create_task(session.live_broadcast_loop())
|
| 721 |
+
logger.info("EEG Backend started")
|
| 722 |
+
|
| 723 |
+
# ── REST Endpoints ─────────────────────────────────────────────────────────
|
| 724 |
+
|
| 725 |
+
@app.get("/health")
|
| 726 |
+
def health():
|
| 727 |
+
return {"status": "ok", "brainflow": BRAINFLOW_AVAILABLE, "lsl": LSL_AVAILABLE,
|
| 728 |
+
"sklearn": SKLEARN_AVAILABLE, "scipy": SCIPY_AVAILABLE}
|
| 729 |
+
|
| 730 |
+
@app.post("/board/connect")
|
| 731 |
+
def connect_board(port: str = "/dev/ttyUSB0", simulate: bool = False):
|
| 732 |
+
session.config.port = port
|
| 733 |
+
session.config.simulate = simulate or not BRAINFLOW_AVAILABLE
|
| 734 |
+
return session.connect_board()
|
| 735 |
+
|
| 736 |
+
@app.post("/board/disconnect")
|
| 737 |
+
def disconnect_board():
|
| 738 |
+
session.disconnect_board()
|
| 739 |
+
return {"status": "disconnected"}
|
| 740 |
+
|
| 741 |
+
@app.post("/session/configure")
|
| 742 |
+
def configure_session(
|
| 743 |
+
subject_id: str = "S001",
|
| 744 |
+
class_a: str = "Apple",
|
| 745 |
+
class_b: str = "House",
|
| 746 |
+
min_quality: int = 4
|
| 747 |
+
):
|
| 748 |
+
session.config.subject_id = subject_id
|
| 749 |
+
session.config.class_a = class_a
|
| 750 |
+
session.config.class_b = class_b
|
| 751 |
+
session.config.min_quality_for_model = min_quality
|
| 752 |
+
session.config.session_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 753 |
+
return {"status": "configured", "config": asdict(session.config)}
|
| 754 |
+
|
| 755 |
+
@app.post("/trial/start")
|
| 756 |
+
def start_trial(class_label: str):
|
| 757 |
+
trial = session.start_trial(class_label)
|
| 758 |
+
return trial.to_dict()
|
| 759 |
+
|
| 760 |
+
@app.post("/trial/end")
|
| 761 |
+
def end_trial(quality: int):
|
| 762 |
+
return session.end_trial(quality)
|
| 763 |
+
|
| 764 |
+
@app.get("/state")
|
| 765 |
+
def get_state():
|
| 766 |
+
return session.get_state()
|
| 767 |
+
|
| 768 |
+
@app.get("/signals/live")
|
| 769 |
+
def live_signals():
|
| 770 |
+
return session.get_live_signals()
|
| 771 |
+
|
| 772 |
+
@app.get("/channels/info")
|
| 773 |
+
def channel_info():
|
| 774 |
+
return {
|
| 775 |
+
"n_channels": N_CHANNELS,
|
| 776 |
+
"channel_names": CHANNEL_NAMES,
|
| 777 |
+
"positions": ELECTRODE_POSITIONS_16CH,
|
| 778 |
+
"sample_rate": SAMPLE_RATE,
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
+
@app.post("/lsl/marker")
|
| 782 |
+
def push_marker(marker: str):
|
| 783 |
+
session.lsl.push_marker(marker)
|
| 784 |
+
return {"pushed": marker, "timestamp": time.time()}
|
| 785 |
+
|
| 786 |
+
@app.get("/classifier/predict")
|
| 787 |
+
def predict_current():
|
| 788 |
+
epoch = session.board.get_epoch(EPOCH_DURATION)
|
| 789 |
+
if epoch is None:
|
| 790 |
+
return {"error": "no data"}
|
| 791 |
+
feats = session.processor.extract_features(epoch)
|
| 792 |
+
proba = session.classifier.predict_proba(feats)
|
| 793 |
+
nf_a = session.nf_engine.compute_similarity(feats, session.config.class_a)
|
| 794 |
+
nf_b = session.nf_engine.compute_similarity(feats, session.config.class_b)
|
| 795 |
+
return {
|
| 796 |
+
"probabilities": proba,
|
| 797 |
+
"nf_similarity": {session.config.class_a: nf_a, session.config.class_b: nf_b},
|
| 798 |
+
"timestamp": time.time(),
|
| 799 |
+
}
|
| 800 |
+
|
| 801 |
+
@app.post("/session/reset")
|
| 802 |
+
def reset_session():
|
| 803 |
+
session.trials.clear()
|
| 804 |
+
session.trial_counter = 0
|
| 805 |
+
session.nf_engine.states.clear()
|
| 806 |
+
session.classifier.is_trained = False
|
| 807 |
+
session.phase = SessionPhase.IDLE
|
| 808 |
+
return {"status": "reset"}
|
| 809 |
+
|
| 810 |
+
# ── WebSocket ──────────────────────────────────────────────────────────────
|
| 811 |
+
|
| 812 |
+
@app.websocket("/ws")
|
| 813 |
+
async def websocket_endpoint(ws: WebSocket):
|
| 814 |
+
await ws.accept()
|
| 815 |
+
session.add_ws_client(ws)
|
| 816 |
+
logger.info(f"WebSocket client connected. Total: {len(session._ws_clients)}")
|
| 817 |
+
try:
|
| 818 |
+
while True:
|
| 819 |
+
msg = await ws.receive_json()
|
| 820 |
+
msg_type = msg.get("type", "")
|
| 821 |
+
|
| 822 |
+
if msg_type == "ping":
|
| 823 |
+
await ws.send_json({"type": "pong", "timestamp": time.time()})
|
| 824 |
+
|
| 825 |
+
elif msg_type == "state":
|
| 826 |
+
await ws.send_json({"type": "state", **session.get_state()})
|
| 827 |
+
|
| 828 |
+
elif msg_type == "start_trial":
|
| 829 |
+
trial = session.start_trial(msg["class_label"])
|
| 830 |
+
await ws.send_json({"type": "trial_started", **trial.to_dict()})
|
| 831 |
+
|
| 832 |
+
elif msg_type == "end_trial":
|
| 833 |
+
result = session.end_trial(msg["quality"])
|
| 834 |
+
await ws.send_json({"type": "trial_ended", **result})
|
| 835 |
+
# Broadcast state update to all clients
|
| 836 |
+
await session.broadcast({"type": "state_update", **session.get_state()})
|
| 837 |
+
|
| 838 |
+
elif msg_type == "configure":
|
| 839 |
+
session.config.subject_id = msg.get("subject_id", session.config.subject_id)
|
| 840 |
+
session.config.class_a = msg.get("class_a", session.config.class_a)
|
| 841 |
+
session.config.class_b = msg.get("class_b", session.config.class_b)
|
| 842 |
+
mq = msg.get("min_quality")
|
| 843 |
+
if mq is not None:
|
| 844 |
+
session.config.min_quality_for_model = int(mq)
|
| 845 |
+
await ws.send_json({"type": "configured"})
|
| 846 |
+
|
| 847 |
+
elif msg_type == "connect_board":
|
| 848 |
+
session.config.simulate = msg.get("simulate", True)
|
| 849 |
+
if msg.get("port"):
|
| 850 |
+
session.config.port = str(msg["port"])
|
| 851 |
+
result = session.connect_board()
|
| 852 |
+
await ws.send_json({"type": "board_status", **result})
|
| 853 |
+
|
| 854 |
+
except WebSocketDisconnect:
|
| 855 |
+
session.remove_ws_client(ws)
|
| 856 |
+
logger.info(f"WebSocket client disconnected. Remaining: {len(session._ws_clients)}")
|
| 857 |
+
|
| 858 |
+
# ─── Entry Point ──────────────────────────────────────────────────────────────
|
| 859 |
+
|
| 860 |
+
if __name__ == "__main__":
|
| 861 |
+
uvicorn.run("backend:app", host="0.0.0.0", port=8765, reload=False, log_level="info")
|
requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.110.0
|
| 2 |
+
uvicorn[standard]>=0.29.0
|
| 3 |
+
numpy>=1.26.0
|
| 4 |
+
scipy>=1.12.0
|
| 5 |
+
scikit-learn>=1.4.0
|
| 6 |
+
brainflow>=5.12.0
|
| 7 |
+
pylsl>=1.16.0
|
| 8 |
+
websockets>=12.0
|
| 9 |
+
python-multipart>=0.0.9
|
| 10 |
+
|
site.html
ADDED
|
@@ -0,0 +1,2235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>Neural State Classifier · EEG Mental Imagery</title>
|
| 7 |
+
<style>
|
| 8 |
+
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=DM+Sans:wght@200;300;400;500&display=swap');
|
| 9 |
+
|
| 10 |
+
:root {
|
| 11 |
+
/* Semantic tokens (referenced throughout) */
|
| 12 |
+
--color-background-primary: #FAFAF8;
|
| 13 |
+
--color-background-secondary: #F3F2EE;
|
| 14 |
+
--color-background-tertiary: #EAE8E3;
|
| 15 |
+
--color-text-primary: #1C1B19;
|
| 16 |
+
--color-text-secondary: #4D4C47;
|
| 17 |
+
--color-text-tertiary: #8E8C86;
|
| 18 |
+
--color-border-tertiary: #DAD7D1;
|
| 19 |
+
--color-border-secondary: #C5C2BB;
|
| 20 |
+
--font-sans: 'DM Sans', system-ui, -apple-system, 'Segoe UI', sans-serif;
|
| 21 |
+
--border-radius-lg: 10px;
|
| 22 |
+
--border-radius-md: 8px;
|
| 23 |
+
--shadow-sm: 0 1px 2px rgba(24, 22, 18, 0.06);
|
| 24 |
+
--shadow-md: 0 8px 32px rgba(24, 22, 18, 0.08);
|
| 25 |
+
--focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent);
|
| 26 |
+
|
| 27 |
+
--bg: var(--color-background-primary);
|
| 28 |
+
--bg2: var(--color-background-secondary);
|
| 29 |
+
--bg3: var(--color-background-tertiary);
|
| 30 |
+
--txt: var(--color-text-primary);
|
| 31 |
+
--txt2: var(--color-text-secondary);
|
| 32 |
+
--txt3: var(--color-text-tertiary);
|
| 33 |
+
--border: var(--color-border-tertiary);
|
| 34 |
+
--border2: var(--color-border-secondary);
|
| 35 |
+
--accent: #185FA5;
|
| 36 |
+
--accent-light: #E6F1FB;
|
| 37 |
+
--teal: #0F6E56;
|
| 38 |
+
--teal-light: #E1F5EE;
|
| 39 |
+
--amber: #854F0B;
|
| 40 |
+
--amber-light: #FAEEDA;
|
| 41 |
+
--coral: #993C1D;
|
| 42 |
+
--coral-light: #FAECE7;
|
| 43 |
+
--purple: #3C3489;
|
| 44 |
+
--purple-light: #EEEDFE;
|
| 45 |
+
--mono: 'DM Mono', ui-monospace, 'Cascadia Mono', monospace;
|
| 46 |
+
--sans: var(--font-sans);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 50 |
+
|
| 51 |
+
html {
|
| 52 |
+
height: 100%;
|
| 53 |
+
-webkit-text-size-adjust: 100%;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
body {
|
| 57 |
+
font-family: var(--sans);
|
| 58 |
+
min-height: 100%;
|
| 59 |
+
background: linear-gradient(165deg, var(--color-background-secondary) 0%, var(--color-background-primary) 45%, #EFECE6 100%);
|
| 60 |
+
background-attachment: fixed;
|
| 61 |
+
color: var(--txt);
|
| 62 |
+
font-size: 13px;
|
| 63 |
+
line-height: 1.45;
|
| 64 |
+
-webkit-font-smoothing: antialiased;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
::selection {
|
| 68 |
+
background: var(--accent-light);
|
| 69 |
+
color: var(--txt);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.app {
|
| 73 |
+
display: grid;
|
| 74 |
+
grid-template-columns: minmax(180px, 220px) 1fr;
|
| 75 |
+
grid-template-rows: auto 1fr;
|
| 76 |
+
min-height: min(720px, 100vh);
|
| 77 |
+
max-width: 1400px;
|
| 78 |
+
margin: 12px auto;
|
| 79 |
+
padding: 0 12px 12px;
|
| 80 |
+
border: 0.5px solid var(--border);
|
| 81 |
+
border-radius: var(--border-radius-lg);
|
| 82 |
+
overflow: hidden;
|
| 83 |
+
background: var(--bg);
|
| 84 |
+
box-shadow: var(--shadow-md);
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
/* HEADER */
|
| 88 |
+
.header {
|
| 89 |
+
grid-column: 1 / -1;
|
| 90 |
+
display: flex;
|
| 91 |
+
align-items: center;
|
| 92 |
+
justify-content: space-between;
|
| 93 |
+
padding: 12px 20px;
|
| 94 |
+
border-bottom: 0.5px solid var(--border);
|
| 95 |
+
background: var(--bg);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
.header-left {
|
| 99 |
+
display: flex;
|
| 100 |
+
align-items: center;
|
| 101 |
+
gap: 12px;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.logo-mark {
|
| 105 |
+
width: 28px;
|
| 106 |
+
height: 28px;
|
| 107 |
+
display: flex;
|
| 108 |
+
align-items: center;
|
| 109 |
+
justify-content: center;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
.header-title {
|
| 113 |
+
font-size: 12px;
|
| 114 |
+
font-weight: 500;
|
| 115 |
+
letter-spacing: 0.08em;
|
| 116 |
+
text-transform: uppercase;
|
| 117 |
+
color: var(--txt2);
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
.header-sub {
|
| 121 |
+
font-size: 11px;
|
| 122 |
+
color: var(--txt3);
|
| 123 |
+
font-family: var(--mono);
|
| 124 |
+
margin-top: 1px;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.status-row {
|
| 128 |
+
display: flex;
|
| 129 |
+
align-items: center;
|
| 130 |
+
gap: 16px;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
.status-pill {
|
| 134 |
+
display: flex;
|
| 135 |
+
align-items: center;
|
| 136 |
+
gap: 5px;
|
| 137 |
+
font-size: 11px;
|
| 138 |
+
font-family: var(--mono);
|
| 139 |
+
color: var(--txt3);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.dot {
|
| 143 |
+
width: 6px;
|
| 144 |
+
height: 6px;
|
| 145 |
+
border-radius: 50%;
|
| 146 |
+
background: var(--color-border-secondary);
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.dot.active {
|
| 150 |
+
background: #1D9E75;
|
| 151 |
+
animation: pulse 2s ease-in-out infinite;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.dot.warn {
|
| 155 |
+
background: #BA7517;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
@keyframes pulse {
|
| 159 |
+
0%, 100% { opacity: 1; }
|
| 160 |
+
50% { opacity: 0.4; }
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
/* SIDEBAR */
|
| 164 |
+
.sidebar {
|
| 165 |
+
border-right: 0.5px solid var(--border);
|
| 166 |
+
background: var(--bg2);
|
| 167 |
+
padding: 0;
|
| 168 |
+
display: flex;
|
| 169 |
+
flex-direction: column;
|
| 170 |
+
min-height: 0;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
.sidebar-section {
|
| 174 |
+
padding: 14px 14px 8px;
|
| 175 |
+
border-bottom: 0.5px solid var(--border);
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
.sidebar-label {
|
| 179 |
+
font-size: 10px;
|
| 180 |
+
letter-spacing: 0.1em;
|
| 181 |
+
text-transform: uppercase;
|
| 182 |
+
color: var(--txt3);
|
| 183 |
+
font-family: var(--mono);
|
| 184 |
+
margin-bottom: 8px;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
.phase-item {
|
| 188 |
+
display: flex;
|
| 189 |
+
align-items: center;
|
| 190 |
+
gap: 8px;
|
| 191 |
+
padding: 6px 8px;
|
| 192 |
+
border-radius: 6px;
|
| 193 |
+
cursor: pointer;
|
| 194 |
+
margin-bottom: 2px;
|
| 195 |
+
transition: background 0.15s;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
.phase-item:hover { background: var(--bg3); }
|
| 199 |
+
.phase-item.active { background: var(--accent-light); }
|
| 200 |
+
|
| 201 |
+
.phase-num {
|
| 202 |
+
width: 18px;
|
| 203 |
+
height: 18px;
|
| 204 |
+
border-radius: 4px;
|
| 205 |
+
display: flex;
|
| 206 |
+
align-items: center;
|
| 207 |
+
justify-content: center;
|
| 208 |
+
font-size: 10px;
|
| 209 |
+
font-family: var(--mono);
|
| 210 |
+
font-weight: 500;
|
| 211 |
+
background: var(--border);
|
| 212 |
+
color: var(--txt2);
|
| 213 |
+
flex-shrink: 0;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.phase-item.active .phase-num {
|
| 217 |
+
background: var(--accent);
|
| 218 |
+
color: white;
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
.phase-item.done .phase-num {
|
| 222 |
+
background: #1D9E75;
|
| 223 |
+
color: white;
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
.phase-text {
|
| 227 |
+
font-size: 11px;
|
| 228 |
+
color: var(--txt2);
|
| 229 |
+
line-height: 1.3;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
.phase-item.active .phase-text { color: var(--accent); font-weight: 500; }
|
| 233 |
+
|
| 234 |
+
.class-pair {
|
| 235 |
+
display: flex;
|
| 236 |
+
flex-direction: column;
|
| 237 |
+
gap: 4px;
|
| 238 |
+
margin-bottom: 8px;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
.class-tag {
|
| 242 |
+
display: flex;
|
| 243 |
+
align-items: center;
|
| 244 |
+
justify-content: space-between;
|
| 245 |
+
padding: 5px 8px;
|
| 246 |
+
border-radius: 6px;
|
| 247 |
+
border: 0.5px solid var(--border2);
|
| 248 |
+
background: var(--bg);
|
| 249 |
+
cursor: pointer;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
.class-tag.a { border-color: #B5D4F4; background: #E6F1FB; }
|
| 253 |
+
.class-tag.b { border-color: #9FE1CB; background: #E1F5EE; }
|
| 254 |
+
|
| 255 |
+
.class-name {
|
| 256 |
+
font-size: 11px;
|
| 257 |
+
font-weight: 500;
|
| 258 |
+
color: var(--txt);
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
.class-tag.a .class-name { color: #0C447C; }
|
| 262 |
+
.class-tag.b .class-name { color: #085041; }
|
| 263 |
+
|
| 264 |
+
.class-trials {
|
| 265 |
+
font-size: 10px;
|
| 266 |
+
font-family: var(--mono);
|
| 267 |
+
color: var(--txt3);
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
.class-tag.a .class-trials { color: #185FA5; }
|
| 271 |
+
.class-tag.b .class-trials { color: #0F6E56; }
|
| 272 |
+
|
| 273 |
+
.metric-mini {
|
| 274 |
+
display: flex;
|
| 275 |
+
flex-direction: column;
|
| 276 |
+
padding: 6px 8px;
|
| 277 |
+
border-radius: 6px;
|
| 278 |
+
background: var(--bg);
|
| 279 |
+
border: 0.5px solid var(--border);
|
| 280 |
+
margin-bottom: 4px;
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
.metric-mini-label { font-size: 10px; color: var(--txt3); font-family: var(--mono); }
|
| 284 |
+
.metric-mini-value { font-size: 15px; font-weight: 500; color: var(--txt); margin-top: 1px; }
|
| 285 |
+
|
| 286 |
+
.lib-entry {
|
| 287 |
+
display: flex;
|
| 288 |
+
align-items: center;
|
| 289 |
+
gap: 6px;
|
| 290 |
+
padding: 4px 6px;
|
| 291 |
+
border-radius: 4px;
|
| 292 |
+
margin-bottom: 2px;
|
| 293 |
+
cursor: pointer;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
.lib-entry:hover { background: var(--bg3); }
|
| 297 |
+
|
| 298 |
+
.lib-dot {
|
| 299 |
+
width: 6px;
|
| 300 |
+
height: 6px;
|
| 301 |
+
border-radius: 50%;
|
| 302 |
+
flex-shrink: 0;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
.lib-text { font-size: 11px; color: var(--txt2); }
|
| 306 |
+
.lib-score { font-size: 10px; font-family: var(--mono); color: var(--txt3); margin-left: auto; }
|
| 307 |
+
|
| 308 |
+
/* MAIN CONTENT */
|
| 309 |
+
.main {
|
| 310 |
+
display: flex;
|
| 311 |
+
flex-direction: column;
|
| 312 |
+
overflow: hidden;
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
/* TAB BAR */
|
| 316 |
+
.tabbar {
|
| 317 |
+
display: flex;
|
| 318 |
+
border-bottom: 0.5px solid var(--border);
|
| 319 |
+
background: var(--bg);
|
| 320 |
+
padding: 0 16px;
|
| 321 |
+
gap: 0;
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
.tab {
|
| 325 |
+
padding: 10px 14px;
|
| 326 |
+
font-size: 12px;
|
| 327 |
+
color: var(--txt3);
|
| 328 |
+
cursor: pointer;
|
| 329 |
+
border-bottom: 2px solid transparent;
|
| 330 |
+
margin-bottom: -0.5px;
|
| 331 |
+
transition: color 0.15s;
|
| 332 |
+
white-space: nowrap;
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
.tab:hover { color: var(--txt2); }
|
| 336 |
+
.tab.active { color: var(--txt); border-bottom-color: var(--txt); font-weight: 500; }
|
| 337 |
+
|
| 338 |
+
/* PANELS */
|
| 339 |
+
.panel { display: none; flex: 1; overflow-y: auto; }
|
| 340 |
+
.panel.active { display: flex; flex-direction: column; }
|
| 341 |
+
|
| 342 |
+
/* ACQUISITION PANEL */
|
| 343 |
+
.acq-layout {
|
| 344 |
+
display: grid;
|
| 345 |
+
grid-template-columns: 1fr 1fr;
|
| 346 |
+
gap: 12px;
|
| 347 |
+
padding: 16px;
|
| 348 |
+
flex: 1;
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
.card {
|
| 352 |
+
border: 0.5px solid var(--border);
|
| 353 |
+
border-radius: var(--border-radius-lg);
|
| 354 |
+
background: var(--bg);
|
| 355 |
+
overflow: hidden;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
.card-header {
|
| 359 |
+
display: flex;
|
| 360 |
+
align-items: center;
|
| 361 |
+
justify-content: space-between;
|
| 362 |
+
padding: 10px 14px;
|
| 363 |
+
border-bottom: 0.5px solid var(--border);
|
| 364 |
+
background: var(--bg2);
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
.card-title {
|
| 368 |
+
font-size: 11px;
|
| 369 |
+
font-weight: 500;
|
| 370 |
+
text-transform: uppercase;
|
| 371 |
+
letter-spacing: 0.07em;
|
| 372 |
+
color: var(--txt2);
|
| 373 |
+
font-family: var(--mono);
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
.card-badge {
|
| 377 |
+
font-size: 10px;
|
| 378 |
+
font-family: var(--mono);
|
| 379 |
+
padding: 2px 6px;
|
| 380 |
+
border-radius: 4px;
|
| 381 |
+
background: var(--accent-light);
|
| 382 |
+
color: var(--accent);
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
.card-body { padding: 14px; }
|
| 386 |
+
|
| 387 |
+
/* EEG topography */
|
| 388 |
+
.topo-container {
|
| 389 |
+
display: flex;
|
| 390 |
+
justify-content: center;
|
| 391 |
+
padding: 8px 0;
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
/* Trial timeline */
|
| 395 |
+
.trial-row {
|
| 396 |
+
display: grid;
|
| 397 |
+
grid-template-columns: 28px 1fr auto auto;
|
| 398 |
+
align-items: center;
|
| 399 |
+
gap: 8px;
|
| 400 |
+
padding: 6px 0;
|
| 401 |
+
border-bottom: 0.5px solid var(--border);
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
.trial-row:last-child { border-bottom: none; }
|
| 405 |
+
|
| 406 |
+
.trial-num {
|
| 407 |
+
font-size: 11px;
|
| 408 |
+
font-family: var(--mono);
|
| 409 |
+
color: var(--txt3);
|
| 410 |
+
text-align: right;
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
.trial-bar-wrap {
|
| 414 |
+
position: relative;
|
| 415 |
+
height: 8px;
|
| 416 |
+
border-radius: 4px;
|
| 417 |
+
background: var(--bg3);
|
| 418 |
+
overflow: hidden;
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
.trial-bar {
|
| 422 |
+
height: 100%;
|
| 423 |
+
border-radius: 4px;
|
| 424 |
+
transition: width 0.4s ease;
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
.trial-score {
|
| 428 |
+
font-size: 11px;
|
| 429 |
+
font-family: var(--mono);
|
| 430 |
+
font-weight: 500;
|
| 431 |
+
min-width: 16px;
|
| 432 |
+
text-align: center;
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
.trial-badge {
|
| 436 |
+
font-size: 9px;
|
| 437 |
+
font-family: var(--mono);
|
| 438 |
+
padding: 1px 5px;
|
| 439 |
+
border-radius: 3px;
|
| 440 |
+
min-width: 28px;
|
| 441 |
+
text-align: center;
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
.trial-badge.keep { background: #E1F5EE; color: #085041; }
|
| 445 |
+
.trial-badge.drop { background: #F1EFE8; color: #5F5E5A; }
|
| 446 |
+
|
| 447 |
+
/* Rating stars */
|
| 448 |
+
.rating-row {
|
| 449 |
+
display: flex;
|
| 450 |
+
align-items: center;
|
| 451 |
+
justify-content: space-between;
|
| 452 |
+
margin: 10px 0;
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
.star-group {
|
| 456 |
+
display: flex;
|
| 457 |
+
gap: 6px;
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
.star {
|
| 461 |
+
width: 32px;
|
| 462 |
+
height: 32px;
|
| 463 |
+
display: flex;
|
| 464 |
+
align-items: center;
|
| 465 |
+
justify-content: center;
|
| 466 |
+
border-radius: 6px;
|
| 467 |
+
border: 0.5px solid var(--border2);
|
| 468 |
+
cursor: pointer;
|
| 469 |
+
font-size: 16px;
|
| 470 |
+
transition: all 0.15s;
|
| 471 |
+
background: var(--bg);
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
.star:hover, .star.sel {
|
| 475 |
+
background: #FAEEDA;
|
| 476 |
+
border-color: #FAC775;
|
| 477 |
+
transform: scale(1.05);
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
.current-trial-info {
|
| 481 |
+
background: var(--bg2);
|
| 482 |
+
border-radius: 8px;
|
| 483 |
+
padding: 12px;
|
| 484 |
+
margin-bottom: 12px;
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
.trial-class-label {
|
| 488 |
+
font-size: 10px;
|
| 489 |
+
font-family: var(--mono);
|
| 490 |
+
color: var(--txt3);
|
| 491 |
+
margin-bottom: 4px;
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
.trial-object {
|
| 495 |
+
font-size: 20px;
|
| 496 |
+
font-weight: 300;
|
| 497 |
+
color: var(--txt);
|
| 498 |
+
letter-spacing: 0.02em;
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
.trial-timer {
|
| 502 |
+
font-size: 11px;
|
| 503 |
+
font-family: var(--mono);
|
| 504 |
+
color: var(--txt3);
|
| 505 |
+
margin-top: 4px;
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
/* Progress bar */
|
| 509 |
+
.progress-wrap {
|
| 510 |
+
height: 3px;
|
| 511 |
+
background: var(--bg3);
|
| 512 |
+
border-radius: 2px;
|
| 513 |
+
margin: 10px 0;
|
| 514 |
+
overflow: hidden;
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
.progress-bar {
|
| 518 |
+
height: 100%;
|
| 519 |
+
background: var(--accent);
|
| 520 |
+
border-radius: 2px;
|
| 521 |
+
transition: width 0.3s;
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
/* Neurofeedback panel */
|
| 525 |
+
.nf-layout {
|
| 526 |
+
display: grid;
|
| 527 |
+
grid-template-columns: 1fr 1fr;
|
| 528 |
+
gap: 12px;
|
| 529 |
+
padding: 16px;
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
.nf-full { grid-column: 1 / -1; }
|
| 533 |
+
|
| 534 |
+
.convergence-track {
|
| 535 |
+
position: relative;
|
| 536 |
+
height: 60px;
|
| 537 |
+
border: 0.5px solid var(--border);
|
| 538 |
+
border-radius: 8px;
|
| 539 |
+
overflow: hidden;
|
| 540 |
+
background: var(--bg2);
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
.conv-fill {
|
| 544 |
+
position: absolute;
|
| 545 |
+
top: 0; left: 0; bottom: 0;
|
| 546 |
+
border-radius: 8px 0 0 8px;
|
| 547 |
+
transition: width 1s ease;
|
| 548 |
+
opacity: 0.6;
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
.conv-label {
|
| 552 |
+
position: absolute;
|
| 553 |
+
top: 50%;
|
| 554 |
+
transform: translateY(-50%);
|
| 555 |
+
right: 10px;
|
| 556 |
+
font-size: 11px;
|
| 557 |
+
font-family: var(--mono);
|
| 558 |
+
color: var(--txt2);
|
| 559 |
+
z-index: 2;
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
.conv-pct {
|
| 563 |
+
position: absolute;
|
| 564 |
+
top: 50%;
|
| 565 |
+
transform: translateY(-50%);
|
| 566 |
+
left: 10px;
|
| 567 |
+
font-size: 13px;
|
| 568 |
+
font-weight: 500;
|
| 569 |
+
font-family: var(--mono);
|
| 570 |
+
color: var(--txt);
|
| 571 |
+
z-index: 2;
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
.audio-row {
|
| 575 |
+
display: flex;
|
| 576 |
+
align-items: center;
|
| 577 |
+
gap: 8px;
|
| 578 |
+
padding: 8px;
|
| 579 |
+
border-radius: 6px;
|
| 580 |
+
background: var(--bg2);
|
| 581 |
+
margin-bottom: 6px;
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
.audio-icon {
|
| 585 |
+
width: 24px;
|
| 586 |
+
height: 24px;
|
| 587 |
+
border-radius: 5px;
|
| 588 |
+
display: flex;
|
| 589 |
+
align-items: center;
|
| 590 |
+
justify-content: center;
|
| 591 |
+
flex-shrink: 0;
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
+
.audio-icon.pos { background: #E1F5EE; }
|
| 595 |
+
.audio-icon.neg { background: #FAECE7; }
|
| 596 |
+
|
| 597 |
+
.audio-text {
|
| 598 |
+
flex: 1;
|
| 599 |
+
font-size: 11px;
|
| 600 |
+
color: var(--txt2);
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
.audio-freq {
|
| 604 |
+
font-size: 10px;
|
| 605 |
+
font-family: var(--mono);
|
| 606 |
+
color: var(--txt3);
|
| 607 |
+
}
|
| 608 |
+
|
| 609 |
+
/* Library panel */
|
| 610 |
+
.lib-layout {
|
| 611 |
+
padding: 16px;
|
| 612 |
+
display: flex;
|
| 613 |
+
flex-direction: column;
|
| 614 |
+
gap: 12px;
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
.lib-grid {
|
| 618 |
+
display: grid;
|
| 619 |
+
grid-template-columns: repeat(3, 1fr);
|
| 620 |
+
gap: 8px;
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
.lib-card {
|
| 624 |
+
border: 0.5px solid var(--border);
|
| 625 |
+
border-radius: var(--border-radius-lg);
|
| 626 |
+
padding: 12px;
|
| 627 |
+
background: var(--bg);
|
| 628 |
+
cursor: pointer;
|
| 629 |
+
transition: border-color 0.15s, background 0.15s;
|
| 630 |
+
}
|
| 631 |
+
|
| 632 |
+
.lib-card:hover { background: var(--bg2); border-color: var(--border2); }
|
| 633 |
+
|
| 634 |
+
.lib-card-name {
|
| 635 |
+
font-size: 13px;
|
| 636 |
+
font-weight: 500;
|
| 637 |
+
margin-bottom: 6px;
|
| 638 |
+
color: var(--txt);
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
.lib-card-meta {
|
| 642 |
+
font-size: 10px;
|
| 643 |
+
font-family: var(--mono);
|
| 644 |
+
color: var(--txt3);
|
| 645 |
+
margin-bottom: 8px;
|
| 646 |
+
line-height: 1.6;
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
.lib-card-bar {
|
| 650 |
+
height: 3px;
|
| 651 |
+
border-radius: 2px;
|
| 652 |
+
background: var(--bg3);
|
| 653 |
+
overflow: hidden;
|
| 654 |
+
}
|
| 655 |
+
|
| 656 |
+
.lib-card-fill {
|
| 657 |
+
height: 100%;
|
| 658 |
+
border-radius: 2px;
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
.subject-row {
|
| 662 |
+
display: flex;
|
| 663 |
+
align-items: center;
|
| 664 |
+
gap: 8px;
|
| 665 |
+
padding: 8px 10px;
|
| 666 |
+
border-radius: 6px;
|
| 667 |
+
background: var(--bg2);
|
| 668 |
+
margin-bottom: 4px;
|
| 669 |
+
}
|
| 670 |
+
|
| 671 |
+
.subject-avatar {
|
| 672 |
+
width: 24px;
|
| 673 |
+
height: 24px;
|
| 674 |
+
border-radius: 50%;
|
| 675 |
+
display: flex;
|
| 676 |
+
align-items: center;
|
| 677 |
+
justify-content: center;
|
| 678 |
+
font-size: 10px;
|
| 679 |
+
font-weight: 500;
|
| 680 |
+
flex-shrink: 0;
|
| 681 |
+
}
|
| 682 |
+
|
| 683 |
+
.subject-info { flex: 1; }
|
| 684 |
+
.subject-name { font-size: 12px; font-weight: 500; color: var(--txt); }
|
| 685 |
+
.subject-meta { font-size: 10px; font-family: var(--mono); color: var(--txt3); }
|
| 686 |
+
|
| 687 |
+
.subject-acc {
|
| 688 |
+
font-size: 12px;
|
| 689 |
+
font-family: var(--mono);
|
| 690 |
+
font-weight: 500;
|
| 691 |
+
}
|
| 692 |
+
|
| 693 |
+
/* Action buttons */
|
| 694 |
+
.btn {
|
| 695 |
+
display: inline-flex;
|
| 696 |
+
align-items: center;
|
| 697 |
+
gap: 6px;
|
| 698 |
+
padding: 7px 14px;
|
| 699 |
+
border-radius: 6px;
|
| 700 |
+
border: 0.5px solid var(--border2);
|
| 701 |
+
background: var(--bg);
|
| 702 |
+
color: var(--txt);
|
| 703 |
+
font-size: 12px;
|
| 704 |
+
cursor: pointer;
|
| 705 |
+
transition: background 0.15s;
|
| 706 |
+
font-family: var(--sans);
|
| 707 |
+
}
|
| 708 |
+
|
| 709 |
+
.btn:hover { background: var(--bg2); }
|
| 710 |
+
.btn.primary { background: var(--accent); color: white; border-color: var(--accent); }
|
| 711 |
+
.btn.primary:hover { opacity: 0.9; }
|
| 712 |
+
|
| 713 |
+
/* Bottom actions */
|
| 714 |
+
.panel-footer {
|
| 715 |
+
display: flex;
|
| 716 |
+
align-items: center;
|
| 717 |
+
justify-content: space-between;
|
| 718 |
+
padding: 10px 16px;
|
| 719 |
+
border-top: 0.5px solid var(--border);
|
| 720 |
+
background: var(--bg2);
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
/* Waveform */
|
| 724 |
+
.waveform-wrap {
|
| 725 |
+
height: 80px;
|
| 726 |
+
position: relative;
|
| 727 |
+
overflow: hidden;
|
| 728 |
+
}
|
| 729 |
+
|
| 730 |
+
canvas.waveform {
|
| 731 |
+
width: 100%;
|
| 732 |
+
height: 80px;
|
| 733 |
+
}
|
| 734 |
+
|
| 735 |
+
/* General info */
|
| 736 |
+
.info-row {
|
| 737 |
+
display: flex;
|
| 738 |
+
align-items: baseline;
|
| 739 |
+
justify-content: space-between;
|
| 740 |
+
padding: 5px 0;
|
| 741 |
+
border-bottom: 0.5px solid var(--border);
|
| 742 |
+
font-size: 12px;
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
.info-row:last-child { border-bottom: none; }
|
| 746 |
+
.info-key { color: var(--txt3); font-family: var(--mono); }
|
| 747 |
+
.info-val { color: var(--txt); font-weight: 500; }
|
| 748 |
+
|
| 749 |
+
.section-title {
|
| 750 |
+
font-size: 11px;
|
| 751 |
+
font-weight: 500;
|
| 752 |
+
text-transform: uppercase;
|
| 753 |
+
letter-spacing: 0.08em;
|
| 754 |
+
color: var(--txt3);
|
| 755 |
+
font-family: var(--mono);
|
| 756 |
+
margin-bottom: 8px;
|
| 757 |
+
}
|
| 758 |
+
|
| 759 |
+
/* ─── Forms & inputs ─── */
|
| 760 |
+
input[type="text"],
|
| 761 |
+
input[type="number"],
|
| 762 |
+
select,
|
| 763 |
+
textarea {
|
| 764 |
+
font-family: var(--sans);
|
| 765 |
+
font-size: 12px;
|
| 766 |
+
padding: 7px 10px;
|
| 767 |
+
border-radius: 6px;
|
| 768 |
+
border: 0.5px solid var(--border2);
|
| 769 |
+
background: var(--bg);
|
| 770 |
+
color: var(--txt);
|
| 771 |
+
width: 100%;
|
| 772 |
+
max-width: 100%;
|
| 773 |
+
transition: border-color 0.15s, box-shadow 0.15s;
|
| 774 |
+
}
|
| 775 |
+
|
| 776 |
+
input:focus-visible,
|
| 777 |
+
select:focus-visible,
|
| 778 |
+
textarea:focus-visible {
|
| 779 |
+
outline: none;
|
| 780 |
+
border-color: var(--accent);
|
| 781 |
+
box-shadow: var(--focus-ring);
|
| 782 |
+
}
|
| 783 |
+
|
| 784 |
+
label.field-label {
|
| 785 |
+
display: block;
|
| 786 |
+
font-size: 10px;
|
| 787 |
+
font-family: var(--mono);
|
| 788 |
+
letter-spacing: 0.06em;
|
| 789 |
+
text-transform: uppercase;
|
| 790 |
+
color: var(--txt3);
|
| 791 |
+
margin-bottom: 4px;
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
+
.field-group {
|
| 795 |
+
margin-bottom: 10px;
|
| 796 |
+
}
|
| 797 |
+
|
| 798 |
+
/* ─── Sidebar scroll & flex ─── */
|
| 799 |
+
.sidebar-section:last-child {
|
| 800 |
+
flex: 1;
|
| 801 |
+
min-height: 0;
|
| 802 |
+
overflow-y: auto;
|
| 803 |
+
scrollbar-width: thin;
|
| 804 |
+
scrollbar-color: var(--border2) transparent;
|
| 805 |
+
}
|
| 806 |
+
|
| 807 |
+
.sidebar-section:last-child::-webkit-scrollbar {
|
| 808 |
+
width: 6px;
|
| 809 |
+
}
|
| 810 |
+
|
| 811 |
+
.sidebar-section:last-child::-webkit-scrollbar-thumb {
|
| 812 |
+
background: var(--border2);
|
| 813 |
+
border-radius: 4px;
|
| 814 |
+
}
|
| 815 |
+
|
| 816 |
+
/* ─── Panels scroll ─── */
|
| 817 |
+
.panel {
|
| 818 |
+
scrollbar-width: thin;
|
| 819 |
+
scrollbar-color: var(--border2) transparent;
|
| 820 |
+
}
|
| 821 |
+
|
| 822 |
+
.panel::-webkit-scrollbar {
|
| 823 |
+
width: 8px;
|
| 824 |
+
}
|
| 825 |
+
|
| 826 |
+
.panel::-webkit-scrollbar-thumb {
|
| 827 |
+
background: var(--border2);
|
| 828 |
+
border-radius: 4px;
|
| 829 |
+
}
|
| 830 |
+
|
| 831 |
+
/* ─── Buttons ─── */
|
| 832 |
+
button.btn {
|
| 833 |
+
appearance: none;
|
| 834 |
+
border: 0.5px solid var(--border2);
|
| 835 |
+
}
|
| 836 |
+
|
| 837 |
+
button.btn:focus-visible {
|
| 838 |
+
outline: none;
|
| 839 |
+
box-shadow: var(--focus-ring);
|
| 840 |
+
}
|
| 841 |
+
|
| 842 |
+
button.btn:disabled {
|
| 843 |
+
opacity: 0.45;
|
| 844 |
+
cursor: not-allowed;
|
| 845 |
+
}
|
| 846 |
+
|
| 847 |
+
.btn.danger {
|
| 848 |
+
border-color: #E8B4A8;
|
| 849 |
+
background: var(--coral-light);
|
| 850 |
+
color: var(--coral);
|
| 851 |
+
}
|
| 852 |
+
|
| 853 |
+
.btn.danger:hover {
|
| 854 |
+
background: #F5DCD4;
|
| 855 |
+
}
|
| 856 |
+
|
| 857 |
+
.btn.ghost {
|
| 858 |
+
background: transparent;
|
| 859 |
+
border-color: transparent;
|
| 860 |
+
}
|
| 861 |
+
|
| 862 |
+
.btn.ghost:hover {
|
| 863 |
+
background: var(--bg2);
|
| 864 |
+
border-color: var(--border);
|
| 865 |
+
}
|
| 866 |
+
|
| 867 |
+
.btn.sm {
|
| 868 |
+
padding: 5px 10px;
|
| 869 |
+
font-size: 11px;
|
| 870 |
+
}
|
| 871 |
+
|
| 872 |
+
/* ─── Tabs accessibility ─── */
|
| 873 |
+
.tab:focus-visible {
|
| 874 |
+
outline: none;
|
| 875 |
+
box-shadow: inset 0 -2px 0 var(--accent);
|
| 876 |
+
color: var(--txt);
|
| 877 |
+
}
|
| 878 |
+
|
| 879 |
+
.phase-item:focus-visible {
|
| 880 |
+
outline: none;
|
| 881 |
+
box-shadow: 0 0 0 2px var(--accent-light);
|
| 882 |
+
}
|
| 883 |
+
|
| 884 |
+
/* ─── Cards & canvases ─── */
|
| 885 |
+
.card-body canvas {
|
| 886 |
+
display: block;
|
| 887 |
+
max-width: 100%;
|
| 888 |
+
height: auto;
|
| 889 |
+
}
|
| 890 |
+
|
| 891 |
+
#distCanvas {
|
| 892 |
+
width: 100%;
|
| 893 |
+
max-width: 100%;
|
| 894 |
+
height: auto;
|
| 895 |
+
min-height: 80px;
|
| 896 |
+
}
|
| 897 |
+
|
| 898 |
+
/* ─── Links ─── */
|
| 899 |
+
a {
|
| 900 |
+
color: var(--accent);
|
| 901 |
+
text-decoration: none;
|
| 902 |
+
}
|
| 903 |
+
|
| 904 |
+
a:hover {
|
| 905 |
+
text-decoration: underline;
|
| 906 |
+
}
|
| 907 |
+
|
| 908 |
+
/* ─── Utility ─── */
|
| 909 |
+
.sr-only {
|
| 910 |
+
position: absolute;
|
| 911 |
+
width: 1px;
|
| 912 |
+
height: 1px;
|
| 913 |
+
padding: 0;
|
| 914 |
+
margin: -1px;
|
| 915 |
+
overflow: hidden;
|
| 916 |
+
clip: rect(0, 0, 0, 0);
|
| 917 |
+
white-space: nowrap;
|
| 918 |
+
border: 0;
|
| 919 |
+
}
|
| 920 |
+
|
| 921 |
+
.muted { color: var(--txt3); }
|
| 922 |
+
.mono { font-family: var(--mono); }
|
| 923 |
+
.stack { display: flex; flex-direction: column; gap: 8px; }
|
| 924 |
+
|
| 925 |
+
/* ─── Responsive ─── */
|
| 926 |
+
@media (max-width: 900px) {
|
| 927 |
+
.app {
|
| 928 |
+
grid-template-columns: 1fr;
|
| 929 |
+
grid-template-rows: auto auto 1fr;
|
| 930 |
+
margin: 0;
|
| 931 |
+
padding: 0;
|
| 932 |
+
border-radius: 0;
|
| 933 |
+
border-left: none;
|
| 934 |
+
border-right: none;
|
| 935 |
+
min-height: 100vh;
|
| 936 |
+
max-width: none;
|
| 937 |
+
}
|
| 938 |
+
|
| 939 |
+
.header {
|
| 940 |
+
flex-wrap: wrap;
|
| 941 |
+
gap: 10px;
|
| 942 |
+
}
|
| 943 |
+
|
| 944 |
+
.sidebar {
|
| 945 |
+
border-right: none;
|
| 946 |
+
border-bottom: 0.5px solid var(--border);
|
| 947 |
+
flex-direction: row;
|
| 948 |
+
flex-wrap: wrap;
|
| 949 |
+
max-height: none;
|
| 950 |
+
}
|
| 951 |
+
|
| 952 |
+
.sidebar-section {
|
| 953 |
+
flex: 1 1 140px;
|
| 954 |
+
border-bottom: 0.5px solid var(--border);
|
| 955 |
+
}
|
| 956 |
+
|
| 957 |
+
.sidebar-section:last-child {
|
| 958 |
+
flex: 1 1 100%;
|
| 959 |
+
max-height: 160px;
|
| 960 |
+
}
|
| 961 |
+
|
| 962 |
+
.acq-layout,
|
| 963 |
+
.nf-layout {
|
| 964 |
+
grid-template-columns: 1fr;
|
| 965 |
+
}
|
| 966 |
+
|
| 967 |
+
.lib-grid {
|
| 968 |
+
grid-template-columns: repeat(2, 1fr);
|
| 969 |
+
}
|
| 970 |
+
|
| 971 |
+
.tabbar {
|
| 972 |
+
overflow-x: auto;
|
| 973 |
+
-webkit-overflow-scrolling: touch;
|
| 974 |
+
}
|
| 975 |
+
}
|
| 976 |
+
|
| 977 |
+
@media (max-width: 520px) {
|
| 978 |
+
.lib-grid {
|
| 979 |
+
grid-template-columns: 1fr;
|
| 980 |
+
}
|
| 981 |
+
|
| 982 |
+
.status-row {
|
| 983 |
+
flex-wrap: wrap;
|
| 984 |
+
gap: 8px;
|
| 985 |
+
}
|
| 986 |
+
|
| 987 |
+
.star {
|
| 988 |
+
width: 28px;
|
| 989 |
+
height: 28px;
|
| 990 |
+
font-size: 14px;
|
| 991 |
+
}
|
| 992 |
+
}
|
| 993 |
+
|
| 994 |
+
@media (prefers-reduced-motion: reduce) {
|
| 995 |
+
*,
|
| 996 |
+
*::before,
|
| 997 |
+
*::after {
|
| 998 |
+
animation-duration: 0.01ms !important;
|
| 999 |
+
animation-iteration-count: 1 !important;
|
| 1000 |
+
transition-duration: 0.01ms !important;
|
| 1001 |
+
}
|
| 1002 |
+
|
| 1003 |
+
.dot.active {
|
| 1004 |
+
animation: none;
|
| 1005 |
+
}
|
| 1006 |
+
}
|
| 1007 |
+
|
| 1008 |
+
/* ─── Print ─── */
|
| 1009 |
+
@media print {
|
| 1010 |
+
body {
|
| 1011 |
+
background: white;
|
| 1012 |
+
}
|
| 1013 |
+
|
| 1014 |
+
.app {
|
| 1015 |
+
box-shadow: none;
|
| 1016 |
+
margin: 0;
|
| 1017 |
+
max-width: none;
|
| 1018 |
+
min-height: auto;
|
| 1019 |
+
}
|
| 1020 |
+
|
| 1021 |
+
.panel-footer,
|
| 1022 |
+
.tabbar .tab:not(.active),
|
| 1023 |
+
.btn {
|
| 1024 |
+
display: none !important;
|
| 1025 |
+
}
|
| 1026 |
+
|
| 1027 |
+
.panel {
|
| 1028 |
+
display: flex !important;
|
| 1029 |
+
overflow: visible;
|
| 1030 |
+
}
|
| 1031 |
+
}
|
| 1032 |
+
|
| 1033 |
+
</style>
|
| 1034 |
+
</head>
|
| 1035 |
+
<body>
|
| 1036 |
+
|
| 1037 |
+
<h2 class="sr-only">EEG mental-imagery classification paradigm — acquisition, neurofeedback, and object library dashboard</h2>
|
| 1038 |
+
|
| 1039 |
+
<div class="app">
|
| 1040 |
+
|
| 1041 |
+
<!-- HEADER -->
|
| 1042 |
+
<div class="header">
|
| 1043 |
+
<div class="header-left">
|
| 1044 |
+
<div class="logo-mark">
|
| 1045 |
+
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
|
| 1046 |
+
<circle cx="14" cy="14" r="13" stroke="var(--border2)" stroke-width="0.5"/>
|
| 1047 |
+
<circle cx="14" cy="14" r="9" stroke="var(--accent)" stroke-width="0.5" stroke-dasharray="2 2"/>
|
| 1048 |
+
<circle cx="14" cy="14" r="3" fill="var(--accent)"/>
|
| 1049 |
+
<circle cx="14" cy="6" r="1.5" fill="var(--txt3)"/>
|
| 1050 |
+
<circle cx="22" cy="10" r="1.5" fill="var(--txt3)"/>
|
| 1051 |
+
<circle cx="22" cy="18" r="1.5" fill="var(--txt3)"/>
|
| 1052 |
+
<circle cx="14" cy="22" r="1.5" fill="var(--txt3)"/>
|
| 1053 |
+
<circle cx="6" cy="18" r="1.5" fill="var(--txt3)"/>
|
| 1054 |
+
<circle cx="6" cy="10" r="1.5" fill="var(--txt3)"/>
|
| 1055 |
+
</svg>
|
| 1056 |
+
</div>
|
| 1057 |
+
<div>
|
| 1058 |
+
<div class="header-title">Neural State Classifier</div>
|
| 1059 |
+
<div class="header-sub">EEG Mental Imagery Paradigm · 16ch Parieto-Occipital</div>
|
| 1060 |
+
</div>
|
| 1061 |
+
</div>
|
| 1062 |
+
<div class="status-row">
|
| 1063 |
+
<div class="status-pill"><span class="dot" id="dot-phase"></span> <span id="txt-phase">IDLE</span></div>
|
| 1064 |
+
<div class="status-pill"><span class="dot" id="dot-session"></span> <span id="txt-session">— · —</span></div>
|
| 1065 |
+
<div class="status-pill"><span class="dot" id="dot-board"></span> <span id="txt-hz">250 Hz · 16 ch</span></div>
|
| 1066 |
+
</div>
|
| 1067 |
+
</div>
|
| 1068 |
+
|
| 1069 |
+
<!-- SIDEBAR -->
|
| 1070 |
+
<div class="sidebar">
|
| 1071 |
+
|
| 1072 |
+
<div class="sidebar-section">
|
| 1073 |
+
<div class="sidebar-label">Backend</div>
|
| 1074 |
+
<div id="backend-status" style="font-size:10px;font-family:var(--mono);color:var(--txt3);line-height:1.5;margin-bottom:10px">REST: … · WS: … · Board: …</div>
|
| 1075 |
+
<label class="field-label" for="cfg-subject">Subject ID</label>
|
| 1076 |
+
<input id="cfg-subject" type="text" value="S001" autocomplete="off">
|
| 1077 |
+
<label class="field-label" for="cfg-class-a">Class A label</label>
|
| 1078 |
+
<input id="cfg-class-a" type="text" value="Chair" autocomplete="off">
|
| 1079 |
+
<label class="field-label" for="cfg-class-b">Class B label</label>
|
| 1080 |
+
<input id="cfg-class-b" type="text" value="Spiral" autocomplete="off">
|
| 1081 |
+
<label class="field-label" for="cfg-min-q">Min quality (1–5)</label>
|
| 1082 |
+
<input id="cfg-min-q" type="number" min="1" max="5" value="4">
|
| 1083 |
+
<label class="field-label" for="cfg-port">Serial port (hardware)</label>
|
| 1084 |
+
<input id="cfg-port" type="text" placeholder="/dev/ttyUSB0 or COM3" autocomplete="off">
|
| 1085 |
+
<div style="display:flex;flex-direction:column;gap:6px;margin-top:10px">
|
| 1086 |
+
<button type="button" class="btn primary sm" onclick="applyConfiguration()">Apply configuration</button>
|
| 1087 |
+
<button type="button" class="btn sm" onclick="connectBoard(true)">Connect · simulate</button>
|
| 1088 |
+
<button type="button" class="btn sm" onclick="connectBoard(false)">Connect · hardware</button>
|
| 1089 |
+
<button type="button" class="btn sm" onclick="disconnectBoard()">Disconnect board</button>
|
| 1090 |
+
<button type="button" class="btn ghost sm" onclick="resetSession()">Reset session</button>
|
| 1091 |
+
</div>
|
| 1092 |
+
</div>
|
| 1093 |
+
|
| 1094 |
+
<div class="sidebar-section">
|
| 1095 |
+
<div class="sidebar-label">Phases</div>
|
| 1096 |
+
<div class="phase-item done" onclick="showTab('acq')">
|
| 1097 |
+
<div class="phase-num">1</div>
|
| 1098 |
+
<div class="phase-text">Acquisition + self-rating</div>
|
| 1099 |
+
</div>
|
| 1100 |
+
<div class="phase-item done" onclick="showTab('acq')">
|
| 1101 |
+
<div class="phase-num">2</div>
|
| 1102 |
+
<div class="phase-text">Filtering · stable state</div>
|
| 1103 |
+
</div>
|
| 1104 |
+
<div class="phase-item active" onclick="showTab('nf')">
|
| 1105 |
+
<div class="phase-num">3</div>
|
| 1106 |
+
<div class="phase-text">Real-time neurofeedback</div>
|
| 1107 |
+
</div>
|
| 1108 |
+
<div class="phase-item" onclick="showTab('lib')">
|
| 1109 |
+
<div class="phase-num">4</div>
|
| 1110 |
+
<div class="phase-text">Multi-class object library</div>
|
| 1111 |
+
</div>
|
| 1112 |
+
<div class="phase-item" onclick="showTab('lib')">
|
| 1113 |
+
<div class="phase-num">5</div>
|
| 1114 |
+
<div class="phase-text">Cross-subject generalization</div>
|
| 1115 |
+
</div>
|
| 1116 |
+
</div>
|
| 1117 |
+
|
| 1118 |
+
<div class="sidebar-section">
|
| 1119 |
+
<div class="sidebar-label">Current session</div>
|
| 1120 |
+
<div class="class-pair">
|
| 1121 |
+
<div class="class-tag a">
|
| 1122 |
+
<span class="class-name" id="ui-class-a-name">Chair</span>
|
| 1123 |
+
<span class="class-trials" id="ui-class-a-n">0 trials</span>
|
| 1124 |
+
</div>
|
| 1125 |
+
<div class="class-tag b">
|
| 1126 |
+
<span class="class-name" id="ui-class-b-name">Spiral</span>
|
| 1127 |
+
<span class="class-trials" id="ui-class-b-n">0 trials</span>
|
| 1128 |
+
</div>
|
| 1129 |
+
</div>
|
| 1130 |
+
</div>
|
| 1131 |
+
|
| 1132 |
+
<div class="sidebar-section">
|
| 1133 |
+
<div class="sidebar-label">Metrics</div>
|
| 1134 |
+
<div class="metric-mini">
|
| 1135 |
+
<span class="metric-mini-label">Convergence (mean)</span>
|
| 1136 |
+
<span class="metric-mini-value" id="metric-conv">—</span>
|
| 1137 |
+
</div>
|
| 1138 |
+
<div class="metric-mini">
|
| 1139 |
+
<span class="metric-mini-label">Trials kept</span>
|
| 1140 |
+
<span class="metric-mini-value" id="metric-kept">—</span>
|
| 1141 |
+
</div>
|
| 1142 |
+
<div class="metric-mini">
|
| 1143 |
+
<span class="metric-mini-label">Mean score</span>
|
| 1144 |
+
<span class="metric-mini-value" id="metric-mean">—</span>
|
| 1145 |
+
</div>
|
| 1146 |
+
<div class="metric-mini">
|
| 1147 |
+
<span class="metric-mini-label">Classifier</span>
|
| 1148 |
+
<span class="metric-mini-value" id="metric-lda">—</span>
|
| 1149 |
+
</div>
|
| 1150 |
+
</div>
|
| 1151 |
+
|
| 1152 |
+
<div class="sidebar-section" style="flex:1; overflow-y:auto;">
|
| 1153 |
+
<div class="sidebar-label">Neural states</div>
|
| 1154 |
+
<div id="sidebar-library-list"></div>
|
| 1155 |
+
</div>
|
| 1156 |
+
|
| 1157 |
+
</div><!-- /sidebar -->
|
| 1158 |
+
|
| 1159 |
+
<!-- MAIN -->
|
| 1160 |
+
<div class="main">
|
| 1161 |
+
|
| 1162 |
+
<!-- TABBAR -->
|
| 1163 |
+
<div class="tabbar">
|
| 1164 |
+
<div class="tab active" id="tab-acq" onclick="showTab('acq')">Acquisition</div>
|
| 1165 |
+
<div class="tab" id="tab-nf" onclick="showTab('nf')">Neurofeedback</div>
|
| 1166 |
+
<div class="tab" id="tab-model" onclick="showTab('model')">Model</div>
|
| 1167 |
+
<div class="tab" id="tab-lib" onclick="showTab('lib')">Library</div>
|
| 1168 |
+
</div>
|
| 1169 |
+
|
| 1170 |
+
<!-- ======== PANEL: ACQUISITION ======== -->
|
| 1171 |
+
<div class="panel active" id="panel-acq">
|
| 1172 |
+
<div class="acq-layout">
|
| 1173 |
+
|
| 1174 |
+
<!-- Current trial -->
|
| 1175 |
+
<div class="card">
|
| 1176 |
+
<div class="card-header">
|
| 1177 |
+
<span class="card-title">Current trial</span>
|
| 1178 |
+
<span class="card-badge" id="badge-trial-id">—</span>
|
| 1179 |
+
</div>
|
| 1180 |
+
<div class="card-body">
|
| 1181 |
+
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px">
|
| 1182 |
+
<button type="button" class="btn sm primary" onclick="startTrialClass('a')">Start · Class A</button>
|
| 1183 |
+
<button type="button" class="btn sm primary" onclick="startTrialClass('b')">Start · Class B</button>
|
| 1184 |
+
</div>
|
| 1185 |
+
<div class="current-trial-info">
|
| 1186 |
+
<div class="trial-class-label" id="trial-class-heading">—</div>
|
| 1187 |
+
<div class="trial-object" id="trial-object-name">—</div>
|
| 1188 |
+
<div class="trial-timer" id="timer-display">No active trial — start Class A or B</div>
|
| 1189 |
+
</div>
|
| 1190 |
+
<div class="progress-wrap">
|
| 1191 |
+
<div class="progress-bar" id="trial-progress" style="width:0%"></div>
|
| 1192 |
+
</div>
|
| 1193 |
+
<div class="section-title" style="margin-top:12px">Self-rating</div>
|
| 1194 |
+
<div class="rating-row">
|
| 1195 |
+
<div class="star-group" id="stars">
|
| 1196 |
+
<div class="star" data-v="1" onclick="rateTrial(1)">1</div>
|
| 1197 |
+
<div class="star" data-v="2" onclick="rateTrial(2)">2</div>
|
| 1198 |
+
<div class="star" data-v="3" onclick="rateTrial(3)">3</div>
|
| 1199 |
+
<div class="star" data-v="4" onclick="rateTrial(4)">4</div>
|
| 1200 |
+
<div class="star sel" data-v="5" onclick="rateTrial(5)">5</div>
|
| 1201 |
+
</div>
|
| 1202 |
+
<span id="rate-label" style="font-size:11px;font-family:var(--mono);color:var(--txt3)">Excellent</span>
|
| 1203 |
+
</div>
|
| 1204 |
+
<div style="display:flex;gap:8px;margin-top:10px">
|
| 1205 |
+
<button class="btn" onclick="sendPrompt('How can I improve EEG trial quality in a mental-imagery paradigm?')">↗ Help</button>
|
| 1206 |
+
<button class="btn primary" onclick="confirmTrial()">Confirm trial →</button>
|
| 1207 |
+
</div>
|
| 1208 |
+
</div>
|
| 1209 |
+
</div>
|
| 1210 |
+
|
| 1211 |
+
<!-- Topographie EEG -->
|
| 1212 |
+
<div class="card">
|
| 1213 |
+
<div class="card-header">
|
| 1214 |
+
<span class="card-title">EEG topomap</span>
|
| 1215 |
+
<span class="card-badge">Live</span>
|
| 1216 |
+
</div>
|
| 1217 |
+
<div class="card-body">
|
| 1218 |
+
<div class="topo-container">
|
| 1219 |
+
<canvas id="topoCanvas" width="160" height="160" style="border-radius:50%"></canvas>
|
| 1220 |
+
</div>
|
| 1221 |
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:10px">
|
| 1222 |
+
<div class="info-row"><span class="info-key">Alpha</span><span class="info-val" id="live-alpha" style="color:#185FA5">—</span></div>
|
| 1223 |
+
<div class="info-row"><span class="info-key">Beta</span><span class="info-val" id="live-beta" style="color:#0F6E56">—</span></div>
|
| 1224 |
+
<div class="info-row"><span class="info-key">Theta</span><span class="info-val" id="live-theta">—</span></div>
|
| 1225 |
+
<div class="info-row"><span class="info-key">Gamma</span><span class="info-val" id="live-gamma">—</span></div>
|
| 1226 |
+
</div>
|
| 1227 |
+
</div>
|
| 1228 |
+
</div>
|
| 1229 |
+
|
| 1230 |
+
<!-- Trial history -->
|
| 1231 |
+
<div class="card" style="grid-column:1/-1">
|
| 1232 |
+
<div class="card-header">
|
| 1233 |
+
<span class="card-title" id="history-card-title">History · Class A</span>
|
| 1234 |
+
<span class="card-badge" id="history-card-badge">0 trials · 0 kept</span>
|
| 1235 |
+
</div>
|
| 1236 |
+
<div class="card-body">
|
| 1237 |
+
<div id="trial-history"></div>
|
| 1238 |
+
</div>
|
| 1239 |
+
</div>
|
| 1240 |
+
|
| 1241 |
+
</div>
|
| 1242 |
+
<div class="panel-footer">
|
| 1243 |
+
<span style="font-size:11px;font-family:var(--mono);color:var(--txt3)">Phases 1–2 active · Auto filter score ≥ 4</span>
|
| 1244 |
+
<div style="display:flex;gap:8px">
|
| 1245 |
+
<button class="btn" onclick="showTab('nf')">→ Neurofeedback</button>
|
| 1246 |
+
</div>
|
| 1247 |
+
</div>
|
| 1248 |
+
</div>
|
| 1249 |
+
|
| 1250 |
+
<!-- ======== PANEL: NEUROFEEDBACK ======== -->
|
| 1251 |
+
<div class="panel" id="panel-nf">
|
| 1252 |
+
<div class="nf-layout">
|
| 1253 |
+
|
| 1254 |
+
<!-- Convergence tracking -->
|
| 1255 |
+
<div class="card nf-full">
|
| 1256 |
+
<div class="card-header">
|
| 1257 |
+
<span class="card-title">Convergence to stable state</span>
|
| 1258 |
+
<span class="card-badge">Phase 3 · Active</span>
|
| 1259 |
+
</div>
|
| 1260 |
+
<div class="card-body">
|
| 1261 |
+
<div style="margin-bottom:10px">
|
| 1262 |
+
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px">
|
| 1263 |
+
<span class="section-title" id="nf-a-title">Class A</span>
|
| 1264 |
+
<span id="nf-a-pct" style="font-size:11px;font-family:var(--mono);color:#185FA5">—</span>
|
| 1265 |
+
</div>
|
| 1266 |
+
<div class="convergence-track">
|
| 1267 |
+
<div class="conv-fill" id="nf-a-fill" style="width:0%;background:linear-gradient(to right,#E6F1FB,#B5D4F4)"></div>
|
| 1268 |
+
<div class="conv-pct" id="nf-a-pct-bar">0%</div>
|
| 1269 |
+
<div class="conv-label">Target</div>
|
| 1270 |
+
</div>
|
| 1271 |
+
</div>
|
| 1272 |
+
<div>
|
| 1273 |
+
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:6px">
|
| 1274 |
+
<span class="section-title" id="nf-b-title">Class B</span>
|
| 1275 |
+
<span id="nf-b-pct" style="font-size:11px;font-family:var(--mono);color:#0F6E56">—</span>
|
| 1276 |
+
</div>
|
| 1277 |
+
<div class="convergence-track">
|
| 1278 |
+
<div class="conv-fill" id="nf-b-fill" style="width:0%;background:linear-gradient(to right,#E1F5EE,#9FE1CB)"></div>
|
| 1279 |
+
<div class="conv-pct" id="nf-b-pct-bar">0%</div>
|
| 1280 |
+
<div class="conv-label">Target</div>
|
| 1281 |
+
</div>
|
| 1282 |
+
</div>
|
| 1283 |
+
</div>
|
| 1284 |
+
</div>
|
| 1285 |
+
|
| 1286 |
+
<!-- Signal waveform -->
|
| 1287 |
+
<div class="card">
|
| 1288 |
+
<div class="card-header">
|
| 1289 |
+
<span class="card-title" id="wave-card-title">Raw signal · preview</span>
|
| 1290 |
+
<span class="card-badge">250 Hz</span>
|
| 1291 |
+
</div>
|
| 1292 |
+
<div class="card-body" style="padding:8px">
|
| 1293 |
+
<div class="waveform-wrap">
|
| 1294 |
+
<canvas id="waveCanvas" width="300" height="80"></canvas>
|
| 1295 |
+
</div>
|
| 1296 |
+
</div>
|
| 1297 |
+
</div>
|
| 1298 |
+
|
| 1299 |
+
<!-- Audio feedback -->
|
| 1300 |
+
<div class="card">
|
| 1301 |
+
<div class="card-header">
|
| 1302 |
+
<span class="card-title">Audio feedback (NF)</span>
|
| 1303 |
+
</div>
|
| 1304 |
+
<div class="card-body">
|
| 1305 |
+
<div class="audio-row">
|
| 1306 |
+
<div class="audio-icon pos">
|
| 1307 |
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
| 1308 |
+
<path d="M3 7h8M7 3l4 4-4 4" stroke="#0F6E56" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
| 1309 |
+
</svg>
|
| 1310 |
+
</div>
|
| 1311 |
+
<div>
|
| 1312 |
+
<div class="audio-text" id="nf-audio-target-title" style="color:#085041;font-weight:500">On target</div>
|
| 1313 |
+
<div class="audio-freq" id="nf-audio-target-detail">—</div>
|
| 1314 |
+
</div>
|
| 1315 |
+
</div>
|
| 1316 |
+
<div class="audio-row">
|
| 1317 |
+
<div class="audio-icon neg">
|
| 1318 |
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
| 1319 |
+
<path d="M11 7H3M7 3L3 7l4 4" stroke="#993C1D" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
|
| 1320 |
+
</svg>
|
| 1321 |
+
</div>
|
| 1322 |
+
<div>
|
| 1323 |
+
<div class="audio-text" id="nf-audio-neg-title" style="color:#712B13;font-weight:500">Divergence detected</div>
|
| 1324 |
+
<div class="audio-freq" id="nf-audio-neg-detail">—</div>
|
| 1325 |
+
</div>
|
| 1326 |
+
</div>
|
| 1327 |
+
<div class="audio-row">
|
| 1328 |
+
<div class="audio-icon" style="background:#EEEDFE">
|
| 1329 |
+
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
| 1330 |
+
<circle cx="7" cy="7" r="4" stroke="#534AB7" stroke-width="1.2"/>
|
| 1331 |
+
<circle cx="7" cy="7" r="1.5" fill="#534AB7"/>
|
| 1332 |
+
</svg>
|
| 1333 |
+
</div>
|
| 1334 |
+
<div>
|
| 1335 |
+
<div class="audio-text" id="nf-audio-neutral-title" style="color:#3C3489;font-weight:500">Neutral band</div>
|
| 1336 |
+
<div class="audio-freq" id="nf-audio-neutral-detail">Last trial NF · —</div>
|
| 1337 |
+
</div>
|
| 1338 |
+
</div>
|
| 1339 |
+
</div>
|
| 1340 |
+
</div>
|
| 1341 |
+
|
| 1342 |
+
<!-- Similarity score history -->
|
| 1343 |
+
<div class="card nf-full">
|
| 1344 |
+
<div class="card-header">
|
| 1345 |
+
<span class="card-title">Distance to reference model · history</span>
|
| 1346 |
+
</div>
|
| 1347 |
+
<div class="card-body" style="padding:8px">
|
| 1348 |
+
<canvas id="distCanvas" width="580" height="80"></canvas>
|
| 1349 |
+
</div>
|
| 1350 |
+
</div>
|
| 1351 |
+
|
| 1352 |
+
</div>
|
| 1353 |
+
<div class="panel-footer">
|
| 1354 |
+
<span style="font-size:11px;font-family:var(--mono);color:var(--txt3)">Metric: cosine distance · 8D CSP space</span>
|
| 1355 |
+
<button class="btn primary" onclick="sendPrompt('Which algorithms do you recommend for EEG neurofeedback in mental imagery?')">↗ NF algorithm notes</button>
|
| 1356 |
+
</div>
|
| 1357 |
+
</div>
|
| 1358 |
+
|
| 1359 |
+
<!-- ======== PANEL: MODEL ======== -->
|
| 1360 |
+
<div class="panel" id="panel-model">
|
| 1361 |
+
<div style="padding:16px;display:flex;flex-direction:column;gap:12px;flex:1">
|
| 1362 |
+
|
| 1363 |
+
<div class="card">
|
| 1364 |
+
<div class="card-header">
|
| 1365 |
+
<span class="card-title">Feature extraction pipeline</span>
|
| 1366 |
+
</div>
|
| 1367 |
+
<div class="card-body">
|
| 1368 |
+
<div style="display:flex;align-items:center;gap:0;overflow-x:auto;padding-bottom:4px">
|
| 1369 |
+
<div style="text-align:center;min-width:90px">
|
| 1370 |
+
<div style="width:60px;height:44px;border-radius:8px;background:var(--accent-light);border:0.5px solid #B5D4F4;display:flex;align-items:center;justify-content:center;margin:0 auto 5px;font-size:10px;font-family:var(--mono);color:#185FA5;text-align:center;padding:4px">EEG raw<br>16 ch</div>
|
| 1371 |
+
</div>
|
| 1372 |
+
<div style="flex:0 0 20px;text-align:center;color:var(--txt3);font-size:12px">→</div>
|
| 1373 |
+
<div style="text-align:center;min-width:90px">
|
| 1374 |
+
<div style="width:60px;height:44px;border-radius:8px;background:#E1F5EE;border:0.5px solid #9FE1CB;display:flex;align-items:center;justify-content:center;margin:0 auto 5px;font-size:10px;font-family:var(--mono);color:#085041;text-align:center;padding:4px">Filter<br>1–40 Hz</div>
|
| 1375 |
+
</div>
|
| 1376 |
+
<div style="flex:0 0 20px;text-align:center;color:var(--txt3);font-size:12px">→</div>
|
| 1377 |
+
<div style="text-align:center;min-width:90px">
|
| 1378 |
+
<div style="width:60px;height:44px;border-radius:8px;background:#EEEDFE;border:0.5px solid #CECBF6;display:flex;align-items:center;justify-content:center;margin:0 auto 5px;font-size:10px;font-family:var(--mono);color:#3C3489;text-align:center;padding:4px">CSP<br>8 comp.</div>
|
| 1379 |
+
</div>
|
| 1380 |
+
<div style="flex:0 0 20px;text-align:center;color:var(--txt3);font-size:12px">→</div>
|
| 1381 |
+
<div style="text-align:center;min-width:90px">
|
| 1382 |
+
<div style="width:60px;height:44px;border-radius:8px;background:#FAEEDA;border:0.5px solid #FAC775;display:flex;align-items:center;justify-content:center;margin:0 auto 5px;font-size:10px;font-family:var(--mono);color:#633806;text-align:center;padding:4px">Log-var<br>features</div>
|
| 1383 |
+
</div>
|
| 1384 |
+
<div style="flex:0 0 20px;text-align:center;color:var(--txt3);font-size:12px">→</div>
|
| 1385 |
+
<div style="text-align:center;min-width:90px">
|
| 1386 |
+
<div style="width:60px;height:44px;border-radius:8px;background:#FAECE7;border:0.5px solid #F5C4B3;display:flex;align-items:center;justify-content:center;margin:0 auto 5px;font-size:10px;font-family:var(--mono);color:#712B13;text-align:center;padding:4px">LDA<br>classifier</div>
|
| 1387 |
+
</div>
|
| 1388 |
+
<div style="flex:0 0 20px;text-align:center;color:var(--txt3);font-size:12px">→</div>
|
| 1389 |
+
<div style="text-align:center;min-width:90px">
|
| 1390 |
+
<div style="width:60px;height:44px;border-radius:8px;background:#EAF3DE;border:0.5px solid #C0DD97;display:flex;align-items:center;justify-content:center;margin:0 auto 5px;font-size:10px;font-family:var(--mono);color:#27500A;text-align:center;padding:4px">Stable<br>state</div>
|
| 1391 |
+
</div>
|
| 1392 |
+
</div>
|
| 1393 |
+
</div>
|
| 1394 |
+
</div>
|
| 1395 |
+
|
| 1396 |
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
| 1397 |
+
<div class="card">
|
| 1398 |
+
<div class="card-header"><span class="card-title">CSP parameters</span></div>
|
| 1399 |
+
<div class="card-body">
|
| 1400 |
+
<div class="info-row"><span class="info-key">n_components</span><span class="info-val">8</span></div>
|
| 1401 |
+
<div class="info-row"><span class="info-key">reg</span><span class="info-val">0.05 (Ledoit-Wolf)</span></div>
|
| 1402 |
+
<div class="info-row"><span class="info-key">log</span><span class="info-val">True</span></div>
|
| 1403 |
+
<div class="info-row"><span class="info-key">norm_trace</span><span class="info-val">False</span></div>
|
| 1404 |
+
<div class="info-row"><span class="info-key">epoch window</span><span class="info-val">2–8 s</span></div>
|
| 1405 |
+
</div>
|
| 1406 |
+
</div>
|
| 1407 |
+
<div class="card">
|
| 1408 |
+
<div class="card-header"><span class="card-title">Convergence criterion</span></div>
|
| 1409 |
+
<div class="card-body">
|
| 1410 |
+
<div class="info-row"><span class="info-key">Metric</span><span class="info-val">Cosine distance</span></div>
|
| 1411 |
+
<div class="info-row"><span class="info-key">Convergence threshold</span><span class="info-val">d < 0.12</span></div>
|
| 1412 |
+
<div class="info-row"><span class="info-key">Rolling window</span><span class="info-val">5 trials</span></div>
|
| 1413 |
+
<div class="info-row"><span class="info-key">Min. trials kept</span><span class="info-val">8</span></div>
|
| 1414 |
+
<div class="info-row"><span class="info-key">Min. score</span><span class="info-val">≥ 4 / 5</span></div>
|
| 1415 |
+
</div>
|
| 1416 |
+
</div>
|
| 1417 |
+
</div>
|
| 1418 |
+
|
| 1419 |
+
<button class="btn" style="align-self:flex-start" onclick="sendPrompt('Beyond CSP+LDA, which EEG mental-imagery classifiers work well with few channels?')">↗ Algorithm alternatives</button>
|
| 1420 |
+
|
| 1421 |
+
</div>
|
| 1422 |
+
</div>
|
| 1423 |
+
|
| 1424 |
+
<!-- ======== PANEL: LIBRARY ======== -->
|
| 1425 |
+
<div class="panel" id="panel-lib">
|
| 1426 |
+
<div class="lib-layout">
|
| 1427 |
+
|
| 1428 |
+
<div class="section-title" id="lib-section-title">Library · session</div>
|
| 1429 |
+
<div class="lib-grid">
|
| 1430 |
+
<div class="lib-card" onclick="sendPrompt('How do you compare stable mental states for two imagined objects in a BCI?')">
|
| 1431 |
+
<div class="lib-card-name">Apple</div>
|
| 1432 |
+
<div class="lib-card-meta">Session 1 · 23 trials<br>11 kept · 3 NF sessions</div>
|
| 1433 |
+
<div class="lib-card-bar"><div class="lib-card-fill" style="width:94%;background:#1D9E75"></div></div>
|
| 1434 |
+
</div>
|
| 1435 |
+
<div class="lib-card">
|
| 1436 |
+
<div class="lib-card-name">Cube</div>
|
| 1437 |
+
<div class="lib-card-meta">Session 1 · 20 trials<br>9 kept · 2 NF sessions</div>
|
| 1438 |
+
<div class="lib-card-bar"><div class="lib-card-fill" style="width:88%;background:#185FA5"></div></div>
|
| 1439 |
+
</div>
|
| 1440 |
+
<div class="lib-card">
|
| 1441 |
+
<div class="lib-card-name">Flame</div>
|
| 1442 |
+
<div class="lib-card-meta">Session 2 · 25 trials<br>13 kept · 4 NF sessions</div>
|
| 1443 |
+
<div class="lib-card-bar"><div class="lib-card-fill" style="width:91%;background:#534AB7"></div></div>
|
| 1444 |
+
</div>
|
| 1445 |
+
<div class="lib-card" style="border-color:#FAC775;background:#FAEEDA">
|
| 1446 |
+
<div class="lib-card-name" style="color:#633806">Chair</div>
|
| 1447 |
+
<div class="lib-card-meta" style="color:#854F0B">Session 3 · in progress<br>18 trials · NF active</div>
|
| 1448 |
+
<div class="lib-card-bar"><div class="lib-card-fill" style="width:76%;background:#BA7517"></div></div>
|
| 1449 |
+
</div>
|
| 1450 |
+
<div class="lib-card" style="opacity:0.5;cursor:default">
|
| 1451 |
+
<div class="lib-card-name">Spiral</div>
|
| 1452 |
+
<div class="lib-card-meta">Session 3 · in progress<br>17 trials · phase 2</div>
|
| 1453 |
+
<div class="lib-card-bar"><div class="lib-card-fill" style="width:31%;background:#888"></div></div>
|
| 1454 |
+
</div>
|
| 1455 |
+
<div class="lib-card" style="border-style:dashed;cursor:pointer;display:flex;align-items:center;justify-content:center;min-height:80px" onclick="sendPrompt('Which visual objects work best for a mental-imagery BCI paradigm?')">
|
| 1456 |
+
<div style="text-align:center;color:var(--txt3)">
|
| 1457 |
+
<div style="font-size:20px;margin-bottom:4px">+</div>
|
| 1458 |
+
<div style="font-size:11px">New class</div>
|
| 1459 |
+
</div>
|
| 1460 |
+
</div>
|
| 1461 |
+
</div>
|
| 1462 |
+
|
| 1463 |
+
<div class="section-title" style="margin-top:4px">Cross-subject generalization</div>
|
| 1464 |
+
<div>
|
| 1465 |
+
<div class="subject-row">
|
| 1466 |
+
<div class="subject-avatar" style="background:#E6F1FB;color:#0C447C">S01</div>
|
| 1467 |
+
<div class="subject-info">
|
| 1468 |
+
<div class="subject-name">Subject 01</div>
|
| 1469 |
+
<div class="subject-meta">4 classes · 86 trials · Ref</div>
|
| 1470 |
+
</div>
|
| 1471 |
+
<span class="subject-acc" style="color:#185FA5">91%</span>
|
| 1472 |
+
</div>
|
| 1473 |
+
<div class="subject-row">
|
| 1474 |
+
<div class="subject-avatar" style="background:#E1F5EE;color:#085041">S02</div>
|
| 1475 |
+
<div class="subject-info">
|
| 1476 |
+
<div class="subject-name">Subject 02</div>
|
| 1477 |
+
<div class="subject-meta">4 classes · 72 trials · Transfer</div>
|
| 1478 |
+
</div>
|
| 1479 |
+
<span class="subject-acc" style="color:#0F6E56">78%</span>
|
| 1480 |
+
</div>
|
| 1481 |
+
<div class="subject-row" style="opacity:0.5">
|
| 1482 |
+
<div class="subject-avatar" style="background:#F1EFE8;color:#5F5E5A">S03</div>
|
| 1483 |
+
<div class="subject-info">
|
| 1484 |
+
<div class="subject-name">Subject 03</div>
|
| 1485 |
+
<div class="subject-meta">In progress · session 1</div>
|
| 1486 |
+
</div>
|
| 1487 |
+
<span class="subject-acc" style="color:#888">—</span>
|
| 1488 |
+
</div>
|
| 1489 |
+
</div>
|
| 1490 |
+
|
| 1491 |
+
<button class="btn" style="align-self:flex-start" onclick="sendPrompt('How should I implement transfer learning for cross-subject EEG BCI generalization?')">↗ Cross-subject transfer learning</button>
|
| 1492 |
+
|
| 1493 |
+
</div>
|
| 1494 |
+
</div>
|
| 1495 |
+
|
| 1496 |
+
</div><!-- /main -->
|
| 1497 |
+
|
| 1498 |
+
</div><!-- /app -->
|
| 1499 |
+
|
| 1500 |
+
<script>
|
| 1501 |
+
(function () {
|
| 1502 |
+
'use strict';
|
| 1503 |
+
|
| 1504 |
+
const API = '';
|
| 1505 |
+
const EPOCH_SEC = 4;
|
| 1506 |
+
|
| 1507 |
+
let channelInfo = null;
|
| 1508 |
+
let lastLive = null;
|
| 1509 |
+
let lastState = null;
|
| 1510 |
+
let ws = null;
|
| 1511 |
+
let wsConnected = false;
|
| 1512 |
+
let restOk = false;
|
| 1513 |
+
let selectedQuality = 5;
|
| 1514 |
+
let nfHistory = [];
|
| 1515 |
+
let lastTrialFeedback = null;
|
| 1516 |
+
let lastNfScore = null;
|
| 1517 |
+
|
| 1518 |
+
function sendPrompt(text) {
|
| 1519 |
+
console.info('[Help]', text);
|
| 1520 |
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
| 1521 |
+
navigator.clipboard.writeText(text).catch(function () {});
|
| 1522 |
+
}
|
| 1523 |
+
}
|
| 1524 |
+
|
| 1525 |
+
async function api(path, opts) {
|
| 1526 |
+
var r = await fetch(API + path, Object.assign({ headers: { 'Content-Type': 'application/json' } }, opts || {}));
|
| 1527 |
+
var ct = r.headers.get('content-type') || '';
|
| 1528 |
+
var j = {};
|
| 1529 |
+
if (ct.indexOf('application/json') !== -1) {
|
| 1530 |
+
try { j = await r.json(); } catch (e) { j = {}; }
|
| 1531 |
+
}
|
| 1532 |
+
if (!r.ok) {
|
| 1533 |
+
var msg = (j.detail && (typeof j.detail === 'string' ? j.detail : JSON.stringify(j.detail))) || j.error || r.statusText;
|
| 1534 |
+
throw new Error(msg);
|
| 1535 |
+
}
|
| 1536 |
+
if (j.error) throw new Error(j.error);
|
| 1537 |
+
return j;
|
| 1538 |
+
}
|
| 1539 |
+
|
| 1540 |
+
async function loadChannelInfo() {
|
| 1541 |
+
try {
|
| 1542 |
+
channelInfo = await api('/channels/info', { method: 'GET' });
|
| 1543 |
+
} catch (e) {
|
| 1544 |
+
console.warn('channels/info', e);
|
| 1545 |
+
}
|
| 1546 |
+
}
|
| 1547 |
+
|
| 1548 |
+
function wsUrl() {
|
| 1549 |
+
var p = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
| 1550 |
+
return p + '//' + location.host + '/ws';
|
| 1551 |
+
}
|
| 1552 |
+
|
| 1553 |
+
function connectWS() {
|
| 1554 |
+
if (ws && ws.readyState === WebSocket.OPEN) return;
|
| 1555 |
+
try {
|
| 1556 |
+
ws = new WebSocket(wsUrl());
|
| 1557 |
+
} catch (e) {
|
| 1558 |
+
console.warn(e);
|
| 1559 |
+
return;
|
| 1560 |
+
}
|
| 1561 |
+
ws.onopen = function () {
|
| 1562 |
+
wsConnected = true;
|
| 1563 |
+
updateBackendStatus();
|
| 1564 |
+
try { ws.send(JSON.stringify({ type: 'ping' })); } catch (e) {}
|
| 1565 |
+
};
|
| 1566 |
+
ws.onmessage = function (ev) {
|
| 1567 |
+
var d = JSON.parse(ev.data);
|
| 1568 |
+
if (d.type === 'live_eeg') {
|
| 1569 |
+
lastLive = d;
|
| 1570 |
+
drawTopoFromLive();
|
| 1571 |
+
drawWaveFromLive();
|
| 1572 |
+
updateBandDisplays(d);
|
| 1573 |
+
} else if (d.type === 'state' || d.type === 'state_update') {
|
| 1574 |
+
var copy = Object.assign({}, d);
|
| 1575 |
+
delete copy.type;
|
| 1576 |
+
renderState(copy);
|
| 1577 |
+
} else if (d.type === 'trial_ended') {
|
| 1578 |
+
if (typeof d.nf_score === 'number') {
|
| 1579 |
+
nfHistory.push(d.nf_score);
|
| 1580 |
+
if (nfHistory.length > 80) nfHistory.shift();
|
| 1581 |
+
lastNfScore = d.nf_score;
|
| 1582 |
+
lastTrialFeedback = d.feedback || null;
|
| 1583 |
+
updateAudioFeedbackUI();
|
| 1584 |
+
}
|
| 1585 |
+
refreshState();
|
| 1586 |
+
drawDistCanvas();
|
| 1587 |
+
} else if (d.type === 'pong') {
|
| 1588 |
+
/* ignore */
|
| 1589 |
+
}
|
| 1590 |
+
};
|
| 1591 |
+
ws.onclose = function () {
|
| 1592 |
+
wsConnected = false;
|
| 1593 |
+
updateBackendStatus();
|
| 1594 |
+
setTimeout(connectWS, 2500);
|
| 1595 |
+
};
|
| 1596 |
+
ws.onerror = function () {};
|
| 1597 |
+
}
|
| 1598 |
+
|
| 1599 |
+
function updateBackendStatus() {
|
| 1600 |
+
var el = document.getElementById('backend-status');
|
| 1601 |
+
if (!el) return;
|
| 1602 |
+
var board = lastState && lastState.board_connected;
|
| 1603 |
+
el.textContent =
|
| 1604 |
+
'REST: ' + (restOk ? 'OK' : '…') +
|
| 1605 |
+
' · WS: ' + (wsConnected ? 'live' : '…') +
|
| 1606 |
+
' · Board: ' + (board ? 'on' : 'off');
|
| 1607 |
+
}
|
| 1608 |
+
|
| 1609 |
+
async function refreshState() {
|
| 1610 |
+
try {
|
| 1611 |
+
var st = await api('/state', { method: 'GET' });
|
| 1612 |
+
restOk = true;
|
| 1613 |
+
renderState(st);
|
| 1614 |
+
} catch (e) {
|
| 1615 |
+
restOk = false;
|
| 1616 |
+
console.warn('/state', e);
|
| 1617 |
+
}
|
| 1618 |
+
updateBackendStatus();
|
| 1619 |
+
}
|
| 1620 |
+
|
| 1621 |
+
function findActiveTrial(st) {
|
| 1622 |
+
var all = (st.class_a_trials || []).concat(st.class_b_trials || []);
|
| 1623 |
+
var pending = all.filter(function (t) { return t.quality_score == null; });
|
| 1624 |
+
if (!pending.length) return null;
|
| 1625 |
+
return pending.reduce(function (a, b) { return a.trial_id > b.trial_id ? a : b; });
|
| 1626 |
+
}
|
| 1627 |
+
|
| 1628 |
+
function meanBand(arr) {
|
| 1629 |
+
if (!arr || !arr.length) return 0;
|
| 1630 |
+
var s = 0;
|
| 1631 |
+
for (var i = 0; i < arr.length; i++) s += arr[i];
|
| 1632 |
+
return s / arr.length;
|
| 1633 |
+
}
|
| 1634 |
+
|
| 1635 |
+
function fmtUvSq(v) {
|
| 1636 |
+
if (!isFinite(v) || v === 0) return '—';
|
| 1637 |
+
return v.toExponential(2) + ' (arb.)';
|
| 1638 |
+
}
|
| 1639 |
+
|
| 1640 |
+
function renderState(st) {
|
| 1641 |
+
lastState = st;
|
| 1642 |
+
var cfg = st.config || {};
|
| 1643 |
+
var phase = st.phase || 'IDLE';
|
| 1644 |
+
var dotPhase = document.getElementById('dot-phase');
|
| 1645 |
+
if (dotPhase) {
|
| 1646 |
+
dotPhase.className = 'dot' + (phase !== 'IDLE' ? ' active' : '');
|
| 1647 |
+
}
|
| 1648 |
+
var txtPhase = document.getElementById('txt-phase');
|
| 1649 |
+
if (txtPhase) txtPhase.textContent = phase;
|
| 1650 |
+
|
| 1651 |
+
var sid = cfg.subject_id || '—';
|
| 1652 |
+
var sess = (cfg.session_id || '').split('_').pop() || '—';
|
| 1653 |
+
var txtSession = document.getElementById('txt-session');
|
| 1654 |
+
if (txtSession) txtSession.textContent = sid + ' · ' + sess;
|
| 1655 |
+
|
| 1656 |
+
var dotBoard = document.getElementById('dot-board');
|
| 1657 |
+
if (dotBoard) dotBoard.className = 'dot' + (st.board_connected ? ' active' : '');
|
| 1658 |
+
|
| 1659 |
+
var dotSession = document.getElementById('dot-session');
|
| 1660 |
+
if (dotSession) dotSession.className = 'dot' + (st.total_trials > 0 ? ' warn' : '');
|
| 1661 |
+
|
| 1662 |
+
document.getElementById('ui-class-a-name').textContent = cfg.class_a || 'A';
|
| 1663 |
+
document.getElementById('ui-class-b-name').textContent = cfg.class_b || 'B';
|
| 1664 |
+
var aTrials = st.class_a_trials || [];
|
| 1665 |
+
var bTrials = st.class_b_trials || [];
|
| 1666 |
+
document.getElementById('ui-class-a-n').textContent = aTrials.length + ' trials';
|
| 1667 |
+
document.getElementById('ui-class-b-n').textContent = bTrials.length + ' trials';
|
| 1668 |
+
|
| 1669 |
+
var ns = st.neural_states || {};
|
| 1670 |
+
var keys = Object.keys(ns);
|
| 1671 |
+
var convSum = 0;
|
| 1672 |
+
var convN = 0;
|
| 1673 |
+
keys.forEach(function (k) {
|
| 1674 |
+
convSum += ns[k].convergence_score || 0;
|
| 1675 |
+
convN++;
|
| 1676 |
+
});
|
| 1677 |
+
document.getElementById('metric-conv').textContent =
|
| 1678 |
+
convN ? Math.round((convSum / convN) * 100) + '%' : '—';
|
| 1679 |
+
|
| 1680 |
+
var minQ = cfg.min_quality_for_model != null ? cfg.min_quality_for_model : 4;
|
| 1681 |
+
var allRated = aTrials.concat(bTrials).filter(function (t) { return t.quality_score != null; });
|
| 1682 |
+
var kept = allRated.filter(function (t) { return t.quality_score >= minQ; }).length;
|
| 1683 |
+
document.getElementById('metric-kept').textContent = kept + ' / ' + allRated.length;
|
| 1684 |
+
|
| 1685 |
+
var scN = 0;
|
| 1686 |
+
var totalSc = 0;
|
| 1687 |
+
allRated.forEach(function (t) {
|
| 1688 |
+
totalSc += t.quality_score;
|
| 1689 |
+
scN++;
|
| 1690 |
+
});
|
| 1691 |
+
document.getElementById('metric-mean').textContent =
|
| 1692 |
+
scN ? (totalSc / scN).toFixed(1) + ' / 5' : '—';
|
| 1693 |
+
|
| 1694 |
+
document.getElementById('metric-lda').textContent = st.classifier_trained ? 'trained' : 'not trained';
|
| 1695 |
+
|
| 1696 |
+
var libList = document.getElementById('sidebar-library-list');
|
| 1697 |
+
if (libList) {
|
| 1698 |
+
libList.innerHTML = '';
|
| 1699 |
+
keys.forEach(function (label) {
|
| 1700 |
+
var info = ns[label];
|
| 1701 |
+
var row = document.createElement('div');
|
| 1702 |
+
row.className = 'lib-entry';
|
| 1703 |
+
row.innerHTML =
|
| 1704 |
+
'<span class="lib-dot" style="background:#185FA5"></span>' +
|
| 1705 |
+
'<span class="lib-text">' +
|
| 1706 |
+
label +
|
| 1707 |
+
'</span>' +
|
| 1708 |
+
'<span class="lib-score">' +
|
| 1709 |
+
Math.round((info.convergence_score || 0) * 100) +
|
| 1710 |
+
'% · n=' +
|
| 1711 |
+
info.n_trials +
|
| 1712 |
+
'</span>';
|
| 1713 |
+
libList.appendChild(row);
|
| 1714 |
+
});
|
| 1715 |
+
if (!keys.length) {
|
| 1716 |
+
libList.innerHTML = '<div class="muted" style="font-size:11px;padding:4px">No stable states yet</div>';
|
| 1717 |
+
}
|
| 1718 |
+
}
|
| 1719 |
+
|
| 1720 |
+
var ca = cfg.class_a || 'Class A';
|
| 1721 |
+
var cb = cfg.class_b || 'Class B';
|
| 1722 |
+
var pctA = ns[ca] ? Math.round((ns[ca].convergence_score || 0) * 100) : 0;
|
| 1723 |
+
var pctB = ns[cb] ? Math.round((ns[cb].convergence_score || 0) * 100) : 0;
|
| 1724 |
+
|
| 1725 |
+
var nfAT = document.getElementById('nf-a-title');
|
| 1726 |
+
var nfBT = document.getElementById('nf-b-title');
|
| 1727 |
+
if (nfAT) nfAT.textContent = ca + ' (Class A)';
|
| 1728 |
+
if (nfBT) nfBT.textContent = cb + ' (Class B)';
|
| 1729 |
+
|
| 1730 |
+
var elAp = document.getElementById('nf-a-pct');
|
| 1731 |
+
var elBp = document.getElementById('nf-b-pct');
|
| 1732 |
+
if (elAp) elAp.textContent = ns[ca] ? pctA + '% converged' : '—';
|
| 1733 |
+
if (elBp) elBp.textContent = ns[cb] ? pctB + '% converged' : '—';
|
| 1734 |
+
|
| 1735 |
+
var fillA = document.getElementById('nf-a-fill');
|
| 1736 |
+
var fillB = document.getElementById('nf-b-fill');
|
| 1737 |
+
var barA = document.getElementById('nf-a-pct-bar');
|
| 1738 |
+
var barB = document.getElementById('nf-b-pct-bar');
|
| 1739 |
+
if (fillA) fillA.style.width = pctA + '%';
|
| 1740 |
+
if (fillB) fillB.style.width = pctB + '%';
|
| 1741 |
+
if (barA) barA.textContent = pctA + '%';
|
| 1742 |
+
if (barB) barB.textContent = pctB + '%';
|
| 1743 |
+
|
| 1744 |
+
var active = findActiveTrial(st);
|
| 1745 |
+
var badge = document.getElementById('badge-trial-id');
|
| 1746 |
+
var obj = document.getElementById('trial-object-name');
|
| 1747 |
+
var head = document.getElementById('trial-class-heading');
|
| 1748 |
+
if (badge) badge.textContent = active ? 'Trial ' + active.trial_id : '—';
|
| 1749 |
+
if (obj) obj.textContent = active ? active.class_label : '—';
|
| 1750 |
+
if (head) {
|
| 1751 |
+
if (!active) head.textContent = '—';
|
| 1752 |
+
else if (active.class_label === ca) head.textContent = 'CLASS A · Imagine';
|
| 1753 |
+
else if (active.class_label === cb) head.textContent = 'CLASS B · Imagine';
|
| 1754 |
+
else head.textContent = active.class_label;
|
| 1755 |
+
}
|
| 1756 |
+
|
| 1757 |
+
var ht = document.getElementById('history-card-title');
|
| 1758 |
+
var hb = document.getElementById('history-card-badge');
|
| 1759 |
+
if (ht) ht.textContent = 'History · Class A (' + ca + ')';
|
| 1760 |
+
var ratedA = aTrials.filter(function (t) { return t.quality_score != null; });
|
| 1761 |
+
var keptA = ratedA.filter(function (t) { return t.quality_score >= minQ; }).length;
|
| 1762 |
+
if (hb) hb.textContent = ratedA.length + ' trials · ' + keptA + ' kept';
|
| 1763 |
+
|
| 1764 |
+
renderHistoryClassA(st, minQ, ca);
|
| 1765 |
+
|
| 1766 |
+
var libTitle = document.getElementById('lib-section-title');
|
| 1767 |
+
if (libTitle) libTitle.textContent = 'Library · ' + sid;
|
| 1768 |
+
|
| 1769 |
+
updateBackendStatus();
|
| 1770 |
+
updateTrialTimer(st);
|
| 1771 |
+
updateAudioFeedbackUI();
|
| 1772 |
+
}
|
| 1773 |
+
|
| 1774 |
+
function updateAudioFeedbackUI() {
|
| 1775 |
+
var fb = lastTrialFeedback;
|
| 1776 |
+
var t1 = document.getElementById('nf-audio-target-detail');
|
| 1777 |
+
var t2 = document.getElementById('nf-audio-neg-detail');
|
| 1778 |
+
var t3 = document.getElementById('nf-audio-neutral-detail');
|
| 1779 |
+
if (fb && t1) {
|
| 1780 |
+
t1.textContent = fb.freq_hz + ' Hz · vol ' + fb.volume + ' · ' + fb.tone;
|
| 1781 |
+
} else if (t1) t1.textContent = '—';
|
| 1782 |
+
if (t2) t2.textContent = lastNfScore != null && lastNfScore < 0.35 ? 'Low NF score' : '—';
|
| 1783 |
+
if (t3) {
|
| 1784 |
+
t3.textContent =
|
| 1785 |
+
'Last trial NF · ' + (lastNfScore != null ? lastNfScore.toFixed(3) : '—');
|
| 1786 |
+
}
|
| 1787 |
+
}
|
| 1788 |
+
|
| 1789 |
+
function updateTrialTimer(st) {
|
| 1790 |
+
var active = findActiveTrial(st);
|
| 1791 |
+
var el = document.getElementById('timer-display');
|
| 1792 |
+
var prog = document.getElementById('trial-progress');
|
| 1793 |
+
if (!active) {
|
| 1794 |
+
if (el) el.textContent = 'No active trial — start Class A or B';
|
| 1795 |
+
if (prog) prog.style.width = '0%';
|
| 1796 |
+
return;
|
| 1797 |
+
}
|
| 1798 |
+
var onsetMs = active.onset_time * 1000;
|
| 1799 |
+
var elapsed = (Date.now() - onsetMs) / 1000;
|
| 1800 |
+
var left = Math.max(0, EPOCH_SEC - elapsed);
|
| 1801 |
+
if (el) {
|
| 1802 |
+
el.textContent =
|
| 1803 |
+
left.toFixed(1) +
|
| 1804 |
+
' s left · Trial #' +
|
| 1805 |
+
active.trial_id +
|
| 1806 |
+
' · ' +
|
| 1807 |
+
active.class_label;
|
| 1808 |
+
}
|
| 1809 |
+
if (prog) prog.style.width = Math.min(100, (elapsed / EPOCH_SEC) * 100) + '%';
|
| 1810 |
+
}
|
| 1811 |
+
|
| 1812 |
+
function renderHistoryClassA(st, minQ, classAName) {
|
| 1813 |
+
var h = document.getElementById('trial-history');
|
| 1814 |
+
if (!h) return;
|
| 1815 |
+
h.innerHTML = '';
|
| 1816 |
+
var trials = (st.class_a_trials || []).filter(function (t) {
|
| 1817 |
+
return t.quality_score != null;
|
| 1818 |
+
});
|
| 1819 |
+
var colors = ['', '#E24B4A', '#EF9F27', '#888', '#1D9E75', '#185FA5'];
|
| 1820 |
+
trials.forEach(function (t, i) {
|
| 1821 |
+
var r = t.quality_score;
|
| 1822 |
+
var keep = r >= minQ;
|
| 1823 |
+
var barW = (r / 5) * 100;
|
| 1824 |
+
var row = document.createElement('div');
|
| 1825 |
+
row.className = 'trial-row';
|
| 1826 |
+
row.innerHTML =
|
| 1827 |
+
'<div class="trial-num">' +
|
| 1828 |
+
t.trial_id +
|
| 1829 |
+
'</div>' +
|
| 1830 |
+
'<div class="trial-bar-wrap"><div class="trial-bar" style="width:' +
|
| 1831 |
+
barW +
|
| 1832 |
+
'%;background:' +
|
| 1833 |
+
colors[r] +
|
| 1834 |
+
'"></div></div>' +
|
| 1835 |
+
'<div class="trial-score" style="color:' +
|
| 1836 |
+
colors[r] +
|
| 1837 |
+
'">' +
|
| 1838 |
+
r +
|
| 1839 |
+
'</div>' +
|
| 1840 |
+
'<div class="trial-badge ' +
|
| 1841 |
+
(keep ? 'keep' : 'drop') +
|
| 1842 |
+
'">' +
|
| 1843 |
+
(keep ? '✓ keep' : '✗ drop') +
|
| 1844 |
+
'</div>';
|
| 1845 |
+
h.appendChild(row);
|
| 1846 |
+
});
|
| 1847 |
+
if (!trials.length) {
|
| 1848 |
+
h.innerHTML =
|
| 1849 |
+
'<div class="muted" style="font-size:11px;padding:8px">No Class A trials rated yet (' +
|
| 1850 |
+
classAName +
|
| 1851 |
+
').</div>';
|
| 1852 |
+
}
|
| 1853 |
+
}
|
| 1854 |
+
|
| 1855 |
+
function channelPositionsForTopo() {
|
| 1856 |
+
if (!channelInfo || !channelInfo.channel_names) return null;
|
| 1857 |
+
var pos = channelInfo.positions || {};
|
| 1858 |
+
var list = [];
|
| 1859 |
+
channelInfo.channel_names.forEach(function (name) {
|
| 1860 |
+
var xy = pos[name];
|
| 1861 |
+
if (!xy) return;
|
| 1862 |
+
var px = Array.isArray(xy) ? xy[0] : xy.x;
|
| 1863 |
+
var py = Array.isArray(xy) ? xy[1] : xy.y;
|
| 1864 |
+
list.push({ name: name, x: 80 + px * 100, y: 80 - py * 100 });
|
| 1865 |
+
});
|
| 1866 |
+
return list.length ? list : null;
|
| 1867 |
+
}
|
| 1868 |
+
|
| 1869 |
+
function drawTopoFromLive() {
|
| 1870 |
+
var c = document.getElementById('topoCanvas');
|
| 1871 |
+
if (!c) return;
|
| 1872 |
+
var ctx = c.getContext('2d');
|
| 1873 |
+
var W = 160;
|
| 1874 |
+
var H = 160;
|
| 1875 |
+
var cx = 80;
|
| 1876 |
+
var cy = 80;
|
| 1877 |
+
var R = 72;
|
| 1878 |
+
|
| 1879 |
+
var topo = lastLive && lastLive.topomap;
|
| 1880 |
+
var base = channelPositionsForTopo();
|
| 1881 |
+
var channels;
|
| 1882 |
+
if (base && topo) {
|
| 1883 |
+
var vals = base.map(function (ch) {
|
| 1884 |
+
return topo[ch.name] != null ? topo[ch.name] : 0;
|
| 1885 |
+
});
|
| 1886 |
+
var vmin = Math.min.apply(null, vals);
|
| 1887 |
+
var vmax = Math.max.apply(null, vals);
|
| 1888 |
+
var span = vmax - vmin || 1;
|
| 1889 |
+
channels = base.map(function (ch, i) {
|
| 1890 |
+
return {
|
| 1891 |
+
x: ch.x,
|
| 1892 |
+
y: ch.y,
|
| 1893 |
+
v: (vals[i] - vmin) / span,
|
| 1894 |
+
};
|
| 1895 |
+
});
|
| 1896 |
+
} else {
|
| 1897 |
+
channels = [
|
| 1898 |
+
{ x: 80, y: 20, v: 0.35 },
|
| 1899 |
+
{ x: 120, y: 35, v: 0.5 },
|
| 1900 |
+
{ x: 140, y: 70, v: 0.55 },
|
| 1901 |
+
{ x: 140, y: 110, v: 0.6 },
|
| 1902 |
+
{ x: 120, y: 135, v: 0.5 },
|
| 1903 |
+
{ x: 80, y: 148, v: 0.45 },
|
| 1904 |
+
{ x: 40, y: 135, v: 0.5 },
|
| 1905 |
+
{ x: 20, y: 110, v: 0.55 },
|
| 1906 |
+
{ x: 20, y: 70, v: 0.58 },
|
| 1907 |
+
{ x: 40, y: 35, v: 0.4 },
|
| 1908 |
+
{ x: 80, y: 60, v: 0.3 },
|
| 1909 |
+
{ x: 110, y: 75, v: 0.48 },
|
| 1910 |
+
{ x: 110, y: 105, v: 0.52 },
|
| 1911 |
+
{ x: 80, y: 118, v: 0.5 },
|
| 1912 |
+
{ x: 50, y: 105, v: 0.53 },
|
| 1913 |
+
{ x: 50, y: 75, v: 0.47 },
|
| 1914 |
+
];
|
| 1915 |
+
}
|
| 1916 |
+
|
| 1917 |
+
var imgData = ctx.createImageData(W, H);
|
| 1918 |
+
for (var px = 0; px < W; px++) {
|
| 1919 |
+
for (var py = 0; py < H; py++) {
|
| 1920 |
+
var dx = px - cx;
|
| 1921 |
+
var dy = py - cy;
|
| 1922 |
+
if (dx * dx + dy * dy > R * R) continue;
|
| 1923 |
+
var wSum = 0;
|
| 1924 |
+
var vSum = 0;
|
| 1925 |
+
channels.forEach(function (ch) {
|
| 1926 |
+
var d2 = (px - ch.x) * (px - ch.x) + (py - ch.y) * (py - ch.y) + 0.001;
|
| 1927 |
+
var w = 1 / d2;
|
| 1928 |
+
wSum += w;
|
| 1929 |
+
vSum += w * ch.v;
|
| 1930 |
+
});
|
| 1931 |
+
var v = vSum / wSum;
|
| 1932 |
+
var r;
|
| 1933 |
+
var g;
|
| 1934 |
+
var b;
|
| 1935 |
+
if (v < 0.5) {
|
| 1936 |
+
var t = v * 2;
|
| 1937 |
+
r = Math.round(t * 100);
|
| 1938 |
+
g = Math.round(t * 150);
|
| 1939 |
+
b = Math.round(180 + t * 50);
|
| 1940 |
+
} else {
|
| 1941 |
+
var t2 = (v - 0.5) * 2;
|
| 1942 |
+
r = Math.round(100 + t2 * 155);
|
| 1943 |
+
g = Math.round(150 - t2 * 100);
|
| 1944 |
+
b = Math.round(230 - t2 * 210);
|
| 1945 |
+
}
|
| 1946 |
+
var idx = (py * W + px) * 4;
|
| 1947 |
+
imgData.data[idx] = r;
|
| 1948 |
+
imgData.data[idx + 1] = g;
|
| 1949 |
+
imgData.data[idx + 2] = b;
|
| 1950 |
+
imgData.data[idx + 3] = 200;
|
| 1951 |
+
}
|
| 1952 |
+
}
|
| 1953 |
+
ctx.clearRect(0, 0, W, H);
|
| 1954 |
+
ctx.save();
|
| 1955 |
+
ctx.beginPath();
|
| 1956 |
+
ctx.arc(cx, cy, R, 0, Math.PI * 2);
|
| 1957 |
+
ctx.clip();
|
| 1958 |
+
ctx.putImageData(imgData, 0, 0);
|
| 1959 |
+
ctx.restore();
|
| 1960 |
+
ctx.beginPath();
|
| 1961 |
+
ctx.arc(cx, cy, R, 0, Math.PI * 2);
|
| 1962 |
+
ctx.strokeStyle = 'rgba(128,128,128,0.3)';
|
| 1963 |
+
ctx.lineWidth = 0.5;
|
| 1964 |
+
ctx.stroke();
|
| 1965 |
+
ctx.beginPath();
|
| 1966 |
+
ctx.arc(4, 80, 4, 0, Math.PI * 2);
|
| 1967 |
+
ctx.fillStyle = 'rgba(128,128,128,0.3)';
|
| 1968 |
+
ctx.fill();
|
| 1969 |
+
ctx.beginPath();
|
| 1970 |
+
ctx.arc(156, 80, 4, 0, Math.PI * 2);
|
| 1971 |
+
ctx.fill();
|
| 1972 |
+
channels.forEach(function (ch) {
|
| 1973 |
+
ctx.beginPath();
|
| 1974 |
+
ctx.arc(ch.x, ch.y, 3, 0, Math.PI * 2);
|
| 1975 |
+
ctx.fillStyle = 'rgba(255,255,255,0.7)';
|
| 1976 |
+
ctx.fill();
|
| 1977 |
+
ctx.strokeStyle = 'rgba(0,0,0,0.3)';
|
| 1978 |
+
ctx.lineWidth = 0.5;
|
| 1979 |
+
ctx.stroke();
|
| 1980 |
+
});
|
| 1981 |
+
}
|
| 1982 |
+
|
| 1983 |
+
function drawWaveFromLive() {
|
| 1984 |
+
var c = document.getElementById('waveCanvas');
|
| 1985 |
+
if (!c) return;
|
| 1986 |
+
var parent = c.parentElement;
|
| 1987 |
+
var rect = parent ? parent.getBoundingClientRect() : { width: 300 };
|
| 1988 |
+
var dpr = window.devicePixelRatio || 1;
|
| 1989 |
+
var W = Math.floor(rect.width * dpr) || 300;
|
| 1990 |
+
var H = Math.floor(80 * dpr);
|
| 1991 |
+
if (c.width !== W) c.width = W;
|
| 1992 |
+
if (c.height !== H) c.height = H;
|
| 1993 |
+
c.style.width = W / dpr + 'px';
|
| 1994 |
+
c.style.height = H / dpr + 'px';
|
| 1995 |
+
|
| 1996 |
+
var ctx = c.getContext('2d');
|
| 1997 |
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
| 1998 |
+
ctx.clearRect(0, 0, W, H);
|
| 1999 |
+
ctx.scale(dpr, dpr);
|
| 2000 |
+
var drawW = W / dpr;
|
| 2001 |
+
var drawH = H / dpr;
|
| 2002 |
+
|
| 2003 |
+
var raw = lastLive && lastLive.raw_samples;
|
| 2004 |
+
if (!raw || !raw.length) {
|
| 2005 |
+
ctx.strokeStyle = 'rgba(24,95,165,0.25)';
|
| 2006 |
+
ctx.beginPath();
|
| 2007 |
+
ctx.moveTo(0, drawH / 2);
|
| 2008 |
+
ctx.lineTo(drawW, drawH / 2);
|
| 2009 |
+
ctx.stroke();
|
| 2010 |
+
return;
|
| 2011 |
+
}
|
| 2012 |
+
|
| 2013 |
+
var colors = ['#185FA5', '#0F6E56', '#3C3489', '#BA7517'];
|
| 2014 |
+
for (var ch = 0; ch < raw.length; ch++) {
|
| 2015 |
+
var samp = raw[ch];
|
| 2016 |
+
if (!samp || !samp.length) continue;
|
| 2017 |
+
ctx.beginPath();
|
| 2018 |
+
var minV = samp[0];
|
| 2019 |
+
var maxV = samp[0];
|
| 2020 |
+
for (var i = 1; i < samp.length; i++) {
|
| 2021 |
+
if (samp[i] < minV) minV = samp[i];
|
| 2022 |
+
if (samp[i] > maxV) maxV = samp[i];
|
| 2023 |
+
}
|
| 2024 |
+
var span = maxV - minV || 1;
|
| 2025 |
+
for (var x = 0; x < samp.length; x++) {
|
| 2026 |
+
var nx = (x / (samp.length - 1)) * drawW;
|
| 2027 |
+
var ny = drawH * 0.15 + (1 - (samp[x] - minV) / span) * (drawH * 0.7) + ch * 4;
|
| 2028 |
+
if (x === 0) ctx.moveTo(nx, ny);
|
| 2029 |
+
else ctx.lineTo(nx, ny);
|
| 2030 |
+
}
|
| 2031 |
+
ctx.strokeStyle = colors[ch % colors.length];
|
| 2032 |
+
ctx.lineWidth = 1;
|
| 2033 |
+
ctx.stroke();
|
| 2034 |
+
}
|
| 2035 |
+
}
|
| 2036 |
+
|
| 2037 |
+
function updateBandDisplays(d) {
|
| 2038 |
+
if (!d) return;
|
| 2039 |
+
var a = document.getElementById('live-alpha');
|
| 2040 |
+
var b = document.getElementById('live-beta');
|
| 2041 |
+
var t = document.getElementById('live-theta');
|
| 2042 |
+
var g = document.getElementById('live-gamma');
|
| 2043 |
+
if (a) a.textContent = d.alpha_power ? fmtUvSq(meanBand(d.alpha_power)) : '—';
|
| 2044 |
+
if (b) b.textContent = d.beta_power ? fmtUvSq(meanBand(d.beta_power)) : '—';
|
| 2045 |
+
if (t) t.textContent = d.theta_power ? fmtUvSq(meanBand(d.theta_power)) : '—';
|
| 2046 |
+
if (g) g.textContent = d.gamma_power ? fmtUvSq(meanBand(d.gamma_power)) : '—';
|
| 2047 |
+
|
| 2048 |
+
var names = d.channels || (channelInfo && channelInfo.channel_names);
|
| 2049 |
+
var wtitle = document.getElementById('wave-card-title');
|
| 2050 |
+
if (wtitle && names && names.length >= 4) {
|
| 2051 |
+
wtitle.textContent =
|
| 2052 |
+
'Raw signal · ' + names[0] + ' · ' + names[4] + ' · ' + names[8] + ' · ' + names[12];
|
| 2053 |
+
}
|
| 2054 |
+
}
|
| 2055 |
+
|
| 2056 |
+
function drawDistCanvas() {
|
| 2057 |
+
var c = document.getElementById('distCanvas');
|
| 2058 |
+
if (!c) return;
|
| 2059 |
+
var ctx = c.getContext('2d');
|
| 2060 |
+
var rect = c.parentElement ? c.parentElement.getBoundingClientRect() : { width: 580 };
|
| 2061 |
+
var W = Math.floor(rect.width) || 580;
|
| 2062 |
+
var H = 80;
|
| 2063 |
+
if (c.width !== W) c.width = W;
|
| 2064 |
+
c.height = H;
|
| 2065 |
+
|
| 2066 |
+
var data = nfHistory.slice();
|
| 2067 |
+
ctx.clearRect(0, 0, W, H);
|
| 2068 |
+
var pad = 20;
|
| 2069 |
+
var maxD = 1;
|
| 2070 |
+
ctx.beginPath();
|
| 2071 |
+
ctx.setLineDash([3, 3]);
|
| 2072 |
+
var ty = H - pad - (0.35 / maxD) * (H - 2 * pad);
|
| 2073 |
+
ctx.moveTo(pad, ty);
|
| 2074 |
+
ctx.lineTo(W - pad, ty);
|
| 2075 |
+
ctx.strokeStyle = 'rgba(128,128,128,0.4)';
|
| 2076 |
+
ctx.lineWidth = 0.5;
|
| 2077 |
+
ctx.stroke();
|
| 2078 |
+
ctx.setLineDash([]);
|
| 2079 |
+
if (data.length < 2) {
|
| 2080 |
+
ctx.font = '11px monospace';
|
| 2081 |
+
ctx.fillStyle = 'rgba(128,128,128,0.7)';
|
| 2082 |
+
ctx.fillText('NF scores appear after rated trials', pad, H / 2);
|
| 2083 |
+
return;
|
| 2084 |
+
}
|
| 2085 |
+
ctx.beginPath();
|
| 2086 |
+
data.forEach(function (d, i) {
|
| 2087 |
+
var x = pad + (i * (W - 2 * pad)) / (data.length - 1);
|
| 2088 |
+
var y = H - pad - (d / maxD) * (H - 2 * pad);
|
| 2089 |
+
if (i === 0) ctx.moveTo(x, y);
|
| 2090 |
+
else ctx.lineTo(x, y);
|
| 2091 |
+
});
|
| 2092 |
+
ctx.strokeStyle = '#185FA5';
|
| 2093 |
+
ctx.lineWidth = 1.2;
|
| 2094 |
+
ctx.stroke();
|
| 2095 |
+
data.forEach(function (d, i) {
|
| 2096 |
+
var x = pad + (i * (W - 2 * pad)) / (data.length - 1);
|
| 2097 |
+
var y = H - pad - (d / maxD) * (H - 2 * pad);
|
| 2098 |
+
ctx.beginPath();
|
| 2099 |
+
ctx.arc(x, y, 3, 0, Math.PI * 2);
|
| 2100 |
+
ctx.fillStyle = d >= 0.65 ? '#1D9E75' : '#185FA5';
|
| 2101 |
+
ctx.fill();
|
| 2102 |
+
});
|
| 2103 |
+
ctx.font = '10px monospace';
|
| 2104 |
+
ctx.fillStyle = 'rgba(128,128,128,0.7)';
|
| 2105 |
+
ctx.fillText('NF similarity (higher is better)', pad, 12);
|
| 2106 |
+
}
|
| 2107 |
+
|
| 2108 |
+
window.showTab = function (t) {
|
| 2109 |
+
document.querySelectorAll('.tab').forEach(function (x) {
|
| 2110 |
+
x.classList.remove('active');
|
| 2111 |
+
});
|
| 2112 |
+
document.querySelectorAll('.panel').forEach(function (x) {
|
| 2113 |
+
x.classList.remove('active');
|
| 2114 |
+
});
|
| 2115 |
+
document.getElementById('tab-' + t).classList.add('active');
|
| 2116 |
+
document.getElementById('panel-' + t).classList.add('active');
|
| 2117 |
+
if (t === 'nf') drawDistCanvas();
|
| 2118 |
+
if (t === 'acq') drawTopoFromLive();
|
| 2119 |
+
if (t === 'nf') drawWaveFromLive();
|
| 2120 |
+
};
|
| 2121 |
+
|
| 2122 |
+
window.rateTrial = function (v) {
|
| 2123 |
+
selectedQuality = v;
|
| 2124 |
+
document.querySelectorAll('.star').forEach(function (s) {
|
| 2125 |
+
s.classList.toggle('sel', parseInt(s.dataset.v, 10) === v);
|
| 2126 |
+
});
|
| 2127 |
+
var labels = ['', 'Poor', 'Fair', 'Average', 'Good', 'Excellent'];
|
| 2128 |
+
document.getElementById('rate-label').textContent = labels[v];
|
| 2129 |
+
};
|
| 2130 |
+
|
| 2131 |
+
window.applyConfiguration = async function () {
|
| 2132 |
+
var subject_id = document.getElementById('cfg-subject').value.trim();
|
| 2133 |
+
var class_a = document.getElementById('cfg-class-a').value.trim();
|
| 2134 |
+
var class_b = document.getElementById('cfg-class-b').value.trim();
|
| 2135 |
+
var min_quality = parseInt(document.getElementById('cfg-min-q').value, 10) || 4;
|
| 2136 |
+
var q =
|
| 2137 |
+
'/session/configure?subject_id=' +
|
| 2138 |
+
encodeURIComponent(subject_id) +
|
| 2139 |
+
'&class_a=' +
|
| 2140 |
+
encodeURIComponent(class_a) +
|
| 2141 |
+
'&class_b=' +
|
| 2142 |
+
encodeURIComponent(class_b) +
|
| 2143 |
+
'&min_quality=' +
|
| 2144 |
+
min_quality;
|
| 2145 |
+
await api(q, { method: 'POST' });
|
| 2146 |
+
await refreshState();
|
| 2147 |
+
};
|
| 2148 |
+
|
| 2149 |
+
window.connectBoard = async function (simulate) {
|
| 2150 |
+
var port =
|
| 2151 |
+
document.getElementById('cfg-port').value.trim() || '/dev/ttyUSB0';
|
| 2152 |
+
var q =
|
| 2153 |
+
'/board/connect?port=' +
|
| 2154 |
+
encodeURIComponent(port) +
|
| 2155 |
+
'&simulate=' +
|
| 2156 |
+
(!!simulate);
|
| 2157 |
+
await api(q, { method: 'POST' });
|
| 2158 |
+
connectWS();
|
| 2159 |
+
await refreshState();
|
| 2160 |
+
};
|
| 2161 |
+
|
| 2162 |
+
window.disconnectBoard = async function () {
|
| 2163 |
+
await api('/board/disconnect', { method: 'POST' });
|
| 2164 |
+
await refreshState();
|
| 2165 |
+
};
|
| 2166 |
+
|
| 2167 |
+
window.resetSession = async function () {
|
| 2168 |
+
await api('/session/reset', { method: 'POST' });
|
| 2169 |
+
nfHistory = [];
|
| 2170 |
+
lastNfScore = null;
|
| 2171 |
+
lastTrialFeedback = null;
|
| 2172 |
+
await refreshState();
|
| 2173 |
+
drawDistCanvas();
|
| 2174 |
+
};
|
| 2175 |
+
|
| 2176 |
+
window.startTrialClass = async function (ab) {
|
| 2177 |
+
var ca = document.getElementById('cfg-class-a').value.trim();
|
| 2178 |
+
var cb = document.getElementById('cfg-class-b').value.trim();
|
| 2179 |
+
var label = ab === 'a' ? ca : cb;
|
| 2180 |
+
if (!label) return;
|
| 2181 |
+
await api('/trial/start?class_label=' + encodeURIComponent(label), { method: 'POST' });
|
| 2182 |
+
await refreshState();
|
| 2183 |
+
};
|
| 2184 |
+
|
| 2185 |
+
window.confirmTrial = async function () {
|
| 2186 |
+
try {
|
| 2187 |
+
var res = await api('/trial/end?quality=' + selectedQuality, { method: 'POST' });
|
| 2188 |
+
if (typeof res.nf_score === 'number') {
|
| 2189 |
+
nfHistory.push(res.nf_score);
|
| 2190 |
+
if (nfHistory.length > 80) nfHistory.shift();
|
| 2191 |
+
lastNfScore = res.nf_score;
|
| 2192 |
+
lastTrialFeedback = res.feedback || null;
|
| 2193 |
+
updateAudioFeedbackUI();
|
| 2194 |
+
}
|
| 2195 |
+
await refreshState();
|
| 2196 |
+
drawDistCanvas();
|
| 2197 |
+
} catch (e) {
|
| 2198 |
+
alert(e.message || String(e));
|
| 2199 |
+
}
|
| 2200 |
+
};
|
| 2201 |
+
|
| 2202 |
+
function tickLoop() {
|
| 2203 |
+
if (lastState) updateTrialTimer(lastState);
|
| 2204 |
+
}
|
| 2205 |
+
|
| 2206 |
+
window.addEventListener('load', async function () {
|
| 2207 |
+
await loadChannelInfo();
|
| 2208 |
+
try {
|
| 2209 |
+
await api('/health', { method: 'GET' });
|
| 2210 |
+
restOk = true;
|
| 2211 |
+
} catch (e) {
|
| 2212 |
+
restOk = false;
|
| 2213 |
+
}
|
| 2214 |
+
connectWS();
|
| 2215 |
+
await refreshState();
|
| 2216 |
+
setInterval(refreshState, 4000);
|
| 2217 |
+
setInterval(tickLoop, 100);
|
| 2218 |
+
setInterval(function () {
|
| 2219 |
+
if (lastLive && document.getElementById('panel-acq').classList.contains('active')) {
|
| 2220 |
+
drawTopoFromLive();
|
| 2221 |
+
}
|
| 2222 |
+
}, 200);
|
| 2223 |
+
drawTopoFromLive();
|
| 2224 |
+
drawDistCanvas();
|
| 2225 |
+
});
|
| 2226 |
+
|
| 2227 |
+
window.addEventListener('resize', function () {
|
| 2228 |
+
drawWaveFromLive();
|
| 2229 |
+
drawDistCanvas();
|
| 2230 |
+
});
|
| 2231 |
+
})();
|
| 2232 |
+
</script>
|
| 2233 |
+
|
| 2234 |
+
</body>
|
| 2235 |
+
</html>
|