diff --git a/.llms/learnings.md b/.llms/learnings.md index 11fbe124..36e235d9 100644 --- a/.llms/learnings.md +++ b/.llms/learnings.md @@ -208,8 +208,6 @@ All four surfaced in one Explore playtest and all live in rounded to duplicate labels ("-4s -4s -3s -3s"). - **`rx: 999` is not a pill.** SVG clamps `rx` to half the box, so a 22 px-tall annotation label rendered as an oval. Use `LABEL_RADIUS = LABEL_HEIGHT / 2`. - The annotation clip-path must also extend `LABEL_GUTTER` above and below the - plot box, or the end label (drawn at `plotHeight + 6`) is clipped away. - **Data arrives at 4 Hz; motion does not have to.** `PLOTTING_INTERVAL` is 250 ms and is load-bearing for the filter/signal-quality windows, so do not lower it to make the plot smoother. `slideIn()` instead offsets the line and diff --git a/src/renderer/components/EEGExplorationComponent.test.tsx b/src/renderer/components/EEGExplorationComponent.test.tsx new file mode 100644 index 00000000..6176d06c --- /dev/null +++ b/src/renderer/components/EEGExplorationComponent.test.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Subject } from 'rxjs'; +import EEGExplorationComponent from './EEGExplorationComponent'; +import { DeviceActions } from '../actions'; +import { + CONNECTION_STATUS, + MUSE_CHANNELS, + SIGNAL_QUALITY, +} from '../constants/constants'; +import { SignalQualityData } from '../constants/interfaces'; + +const chunk: SignalQualityData = { + data: MUSE_CHANNELS.map(() => new Array(64).fill(0)), + info: { + startTime: 1000, + samplingRate: 256, + channelNames: MUSE_CHANNELS, + signalQuality: Object.fromEntries(MUSE_CHANNELS.map((c) => [c, 1])), + }, + signalQuality: Object.fromEntries( + MUSE_CHANNELS.map((c) => [c, SIGNAL_QUALITY.GREAT]) + ), +}; + +beforeEach(() => { + window.electronAPI = { + ...window.electronAPI, + getViewerUrl: async () => 'http://viewer.local/', + }; +}); + +describe('EEGExplorationComponent', () => { + it('shows the stream-stopped banner when the stream ends during a lesson', async () => { + const stream = new Subject(); + render( + + ); + act(() => stream.next(chunk)); + fireEvent.click( + screen.getByRole('button', { + name: 'Start How do I get a cleaner signal?', + }) + ); + expect(screen.getByText(/Tip 1 of 3/i)).toBeVisible(); + + act(() => stream.complete()); + + const alert = screen.getByRole('alert'); + expect(alert).toHaveTextContent('The signal stream stopped.'); + expect( + screen.getByRole('button', { name: 'Reconnect headset' }) + ).toBeVisible(); + expect(screen.getByText(/Tip 1 of 3/i)).toBeVisible(); + await act(async () => {}); + }); +}); diff --git a/src/renderer/components/EEGExplorationComponent.tsx b/src/renderer/components/EEGExplorationComponent.tsx index ee0d39a4..b0cc2b64 100644 --- a/src/renderer/components/EEGExplorationComponent.tsx +++ b/src/renderer/components/EEGExplorationComponent.tsx @@ -1,6 +1,5 @@ import React, { useContext, useEffect, useMemo, useState } from 'react'; import { Observable, shareReplay } from 'rxjs'; -import { Button } from './ui/button'; import { PLOTTING_INTERVAL, CONNECTION_STATUS, @@ -8,17 +7,17 @@ import { MUSE_SAMPLING_RATE, VIEWER_DEFAULTS, } from '../constants/constants'; -import eegImage from '../assets/common/EEG.png'; -import SignalQualityIndicatorComponent from './SignalQualityIndicatorComponent'; import ViewerComponent from './ViewerComponent'; import { HeadsetSetupContext } from '../containers/AppShellContainer'; -import ExploreSensorCard from './ExploreSensorCard'; import ExploreLessonFlow from './ExploreLessonFlow'; import LiveSignalPrep from './HeadsetSetup/LiveSignalPrep'; -import { EXPLORE_LESSONS, LessonId } from '../constants/exploreLessons'; -import { ExploreSession } from '../utils/eeg/exploreSignal'; +import { LessonId } from '../constants/exploreLessons'; +import { ExploreSession, FRONTAL } from '../utils/eeg/exploreSignal'; import { DeviceActions } from '../actions'; import { DeviceInfo, SignalQualityData } from '../constants/interfaces'; +import { ExploreDisconnected, ExploreSurface } from './Explore/ExploreScreens'; +import { ErrorBanner } from './Explore/ExploreParts'; +import { summarizeQuality } from './Explore/quality'; interface Props { connectedDevice: DeviceInfo | null | undefined; @@ -31,11 +30,15 @@ interface Props { function ConnectedExplore({ observable, device, - onDisconnect, + onReconnect, + onSwitchHeadset, }: { observable?: Observable; device: DeviceInfo | null | undefined; - onDisconnect: () => void; + /** "Reconnect headset" on the stream-stopped banner. */ + onReconnect: () => void; + /** "Switch headset" on the unsupported-channels banner. */ + onSwitchHeadset: () => void; }) { const channels = device?.channels ?? MUSE_CHANNELS; const samplingRate = device?.samplingRate ?? MUSE_SAMPLING_RATE; @@ -56,22 +59,20 @@ function ConnectedExplore({ [channels, samplingRate] ); const [sample, setSample] = useState(null); - const [streamError, setStreamError] = useState(''); + const [streamStopped, setStreamStopped] = useState(false); const [activeLesson, setActiveLesson] = useState(null); const [hoveredChannel, setHoveredChannel] = useState(null); useEffect(() => { setSample(null); - setStreamError(''); + setStreamStopped(false); const subscription = stream?.subscribe({ next: (chunk) => { session.consume(chunk); setSample(chunk); }, - error: () => - setStreamError( - 'The signal stream stopped. Disconnect and reconnect your headset.' - ), + error: () => setStreamStopped(true), + complete: () => setStreamStopped(true), }); return () => subscription?.unsubscribe(); }, [stream, session]); @@ -82,116 +83,66 @@ function ConnectedExplore({ setActiveLesson(lesson); } + const legend = `${device?.name ?? ''} · ${samplingRate} Hz`; + const { state, sensors } = summarizeQuality(sample?.signalQuality, channels); + const missing = FRONTAL.filter((channel) => !channels.includes(channel)); return ( -
- {streamError && ( -
- {streamError} +
+ {streamStopped && ( +
+
)} -
-
- -
-
-
- - -
-
-
- {!activeLesson && ( - + {activeLesson ? ( + setActiveLesson(null)} + /> + ) : ( + + } + legend={legend} + hoveredChannel={hoveredChannel} + onHoveredChannelChange={setHoveredChannel} + onStartLesson={startLesson} + banner={ + !streamStopped && + !session.status().supported && + missing.length ? ( + - )} - {!sample && ( -
- Waiting for the headset signal… -
- )} -
-
-
- LEARN WITH THIS SIGNAL -
-
- {EXPLORE_LESSONS.map((lesson) => ( -
-
-
- {lesson.title} -
-
- {lesson.detail} -
-
- -
- ))} -
-
-
-
+ ) : undefined + } + /> + )}
- {activeLesson && ( - setActiveLesson(null)} - /> - )}
); } @@ -202,7 +153,7 @@ export default function EEGExplorationComponent(props: Props) { const connected = props.connectionStatus === CONNECTION_STATUS.CONNECTED; return ( -
+
{connected && signalPrep ? ( props.DeviceActions.DisconnectFromDevice()} + onReconnect={() => { + props.DeviceActions.DisconnectFromDevice(); + openHeadsetSetup(); + }} + onSwitchHeadset={openHeadsetSetup} /> ) : ( -
-
- EEG headset -
-
- RAW SIGNAL -
-

Explore Raw EEG

-

- Connect a headset and watch your brain's electricity show - up live — no experiment to set up, no data to save. -

-
- - - Takes about 30 seconds - -
-
-
- ONCE YOU'RE CONNECTED -
-
- Two short lessons are waiting: improving signal quality, and - where noise comes from. -
-
-
-
-
+ )}
); diff --git a/src/renderer/components/Explore/Explore.stories.tsx b/src/renderer/components/Explore/Explore.stories.tsx index 784e7560..7ffaaf36 100644 --- a/src/renderer/components/Explore/Explore.stories.tsx +++ b/src/renderer/components/Explore/Explore.stories.tsx @@ -2,6 +2,9 @@ import React from 'react'; import type { Decorator, Meta, StoryObj } from '@storybook/react-vite'; import { MemoryRouter } from 'react-router-dom'; import { fn } from 'storybook/test'; +import { of } from 'rxjs'; +import type { EEGSnapshot, PlotAnnotation } from '../../../shared/eegVizTypes'; +import { channelColors } from '../../utils/eeg/traceColors'; import AppShell from '../AppShell/AppShell'; import type { DeviceState } from '../AppShell/types'; import { @@ -14,36 +17,52 @@ import { BLINK_PREDICT_ANNOTATIONS, BLINKING_SNAPSHOT, CALM_SNAPSHOT, + CLOSED_SEGMENT, COMPARISON_RATIO, + EXPLORE_CHANNELS, EYES_CLOSED_LIVE, LIVE_SNAPSHOT, NO_AF8_SENSORS, NO_AF8_SNAPSHOT, NO_SIGNAL_SNAPSHOT, - QUALITY_SCENARIOS, - QualityState, + OPEN_SEGMENT, + QUALITY_SENSORS, RHYTHM_INCREASE_RATIO, RHYTHM_NO_EFFECT_RATIO, - SensorStatus, + qualitySample, } from './fixtures'; +import type { QualityState, SensorStatus } from './quality'; import { BlinkLessonView, + CleanSignalView, EyesClosedView, ExploreDisconnected, ExploreSurface, } from './ExploreScreens'; -import { ErrorBanner } from './ExploreParts'; +import { ErrorBanner, SnapshotPlot } from './ExploreParts'; /** SIGNAL_QUALITY values are the trace colors, one per sensor. */ const qualityColors = (sensors: SensorStatus[]) => sensors.map((sensor) => sensor.quality); -/** - * Explore in the real AppShell with no workspace (Explore is workspace-free; - * the shell bar is the only chrome). All signal traces are synthetic fixture - * series — labelled "example" in these descriptions only, exactly as the app - * copy will read once the real webview is wired in. - */ +/** Stand-in for a lesson's live `ViewerComponent`, in stable channel colors. */ +const lessonPlot = ( + snapshot: EEGSnapshot, + annotations?: PlotAnnotation[], + markerStyle?: 'band' | 'tick' +) => ( + +); + +const REVIEW_SEGMENTS = { open: OPEN_SEGMENT, closed: CLOSED_SEGMENT }; + const withExploreChrome: Decorator = (Story, { parameters }) => ( ( const meta: Meta = { title: 'Domain/Explore', - parameters: { layout: 'fullscreen' }, + parameters: { + layout: 'fullscreen', + viewport: { + options: { + rule1366: { + name: 'Rule A 1366×768', + styles: { width: '1366px', height: '768px' }, + }, + rule1280: { + name: 'Rule A 1280×720', + styles: { width: '1280px', height: '720px' }, + }, + }, + }, + }, decorators: [withExploreChrome], }; export default meta; @@ -67,12 +100,14 @@ type Story = StoryObj; /** The connected surface in one of the four quality states. */ function Surface({ state, + snapshot = LIVE_SNAPSHOT, + sensors = QUALITY_SENSORS[state === 'waiting' ? 'ready' : state], ...props }: { state: QualityState | 'waiting'; + snapshot?: EEGSnapshot; + sensors?: SensorStatus[]; } & Partial>) { - const sensors = - QUALITY_SCENARIOS[state === 'waiting' ? 'ready' : state].sensors; const [hoveredChannel, setHoveredChannel] = React.useState( null ); @@ -80,8 +115,17 @@ function Surface({ sensor.channel)} + sample={state === 'waiting' ? null : qualitySample(sensors)} + head={state === 'waiting' ? null : of(qualitySample(sensors))} + livePlot={ + + } hoveredChannel={hoveredChannel} onHoveredChannelChange={setHoveredChannel} onStartLesson={fn()} @@ -98,7 +142,7 @@ export const Disconnected: Story = { /** X02 — Connected, no data yet: an explicit waiting state; lessons stay disabled until signal arrives. */ export const Waiting: Story = { - render: () => , + render: () => , }; /** Q01 — Ready: a light status row; the card only earns its weight in the yellow/red states. */ @@ -126,7 +170,7 @@ export const NoiseDefinition: Story = { render: () => ( , }; +/** C01 — Cleaner-signal tips at 1366×768: the tips beside the live plot (quality colors) and head diagram. */ +export const CleanSignalTips: Story = { + globals: { viewport: { value: 'rule1366', isRotated: false } }, + render: () => ( + + } + head={of(qualitySample(QUALITY_SENSORS.settling))} + channels={EXPLORE_CHANNELS} + onBack={fn()} + onNext={fn()} + onExit={fn()} + /> + ), +}; + +/** C01b — Cleaner-signal tips at 1280×720. */ +export const CleanSignalTips720: Story = { + ...CleanSignalTips, + globals: { viewport: { value: 'rule1280', isRotated: false } }, +}; + /** B01 — Blink step 1/4: blink once and find the marked response on the plot. */ export const BlinkStep1: Story = { render: () => ( ( ( ( ( ( ), -}; \ No newline at end of file +}; diff --git a/src/renderer/components/Explore/ExploreParts.tsx b/src/renderer/components/Explore/ExploreParts.tsx index 7445fcc3..4a3a5864 100644 --- a/src/renderer/components/Explore/ExploreParts.tsx +++ b/src/renderer/components/Explore/ExploreParts.tsx @@ -9,12 +9,15 @@ import { NOISE_DEFINITION, NOISE_SETTLING_NOTE, OPEN_SEGMENT, - QUALITY_SCENARIOS, - QUALITY_STATE_TONE, - QualityState, REVIEW_SEGMENT_SCALE, } from './fixtures'; import { channelColor } from '../../utils/eeg/traceColors'; +import { + QUALITY_STATE_TONE, + QualityState, + SensorStatus, + qualityCopy, +} from './quality'; /** Small uppercase label for lesson step counters and section titles. */ export const stepLabel = @@ -45,8 +48,8 @@ const PILL_FONT = 12; const AXIS_FONT = '11px Lato, "Helvetica Neue", sans-serif'; const PILL_FONT_STACK = `${PILL_FONT}px Lato, "Helvetica Neue", sans-serif`; -interface FixturePlotProps { - /** Synthetic stand-in data; the real viewer renders this from the webview. */ +interface SnapshotPlotProps { + /** A frozen snapshot, or a synthetic series standing in for the live viewer in Storybook. */ snapshot: EEGSnapshot; annotations?: PlotAnnotation[]; /** Per-channel stroke colors, index-aligned with `snapshot.channels`. */ @@ -67,13 +70,14 @@ interface FixturePlotProps { } /** - * Storybook stand-in for the live `` plot (`ViewerComponent` / - * `EEGViewer`, which cannot run outside Electron). Same geometry as the real - * viewer: 20/10/30/44 margins, channels stacked in equal bands, channel labels - * on the left, time running left→right with a whole-second offset axis, and - * annotation bands with pill labels. Drawn from a synthetic fixture series. + * Static SVG plot of one `EEGSnapshot`: the app's frozen plots (comparison + * strips, review segments, the example) and Storybook's stand-in for the live + * `` viewer (`ViewerComponent` / `EEGViewer`, Electron-only). Same + * geometry as the real viewer: 20/10/30/44 margins, channels stacked in equal + * bands, channel labels on the left, a whole-second offset axis, and + * annotation bands with pill labels. */ -export function FixturePlot({ +export function SnapshotPlot({ snapshot, annotations = [], colors, @@ -83,7 +87,7 @@ export function FixturePlot({ markerStyle = 'band', showAxis = true, tight = false, -}: FixturePlotProps) { +}: SnapshotPlotProps) { const clipId = useId(); const margin = tight ? { top: 2, right: 2, bottom: 2, left: 2 } : PLOT_MARGIN; const plotW = width - margin.left - margin.right; @@ -116,13 +120,13 @@ export function FixturePlot({ const bands = annotations .filter( (annotation) => - (annotation.endTime == null || annotation.endTime >= snapshot.startTime) && + (annotation.endTime == null || + annotation.endTime >= snapshot.startTime) && annotation.startTime <= snapshot.endTime ) .map((annotation) => { const startX = xAt(annotation.startTime); - const endX = - annotation.endTime != null ? xAt(annotation.endTime) : plotW; + const endX = annotation.endTime != null ? xAt(annotation.endTime) : plotW; return { annotation, x: Math.max(0, Math.min(plotW, startX)), @@ -137,7 +141,7 @@ export function FixturePlot({ preserveAspectRatio="xMidYMid meet" className="h-full w-full" role="img" - aria-label="Live EEG trace, four channels stacked over time" + aria-label={`EEG trace, ${count} channels stacked over time`} > @@ -171,155 +175,177 @@ export function FixturePlot({ ))} {markerStyle !== 'tick' && bands.map(({ annotation, x, width: bandWidth, ended }) => { - const style = TONE_STYLES[annotation.tone]; - const solid = annotation.tone === 'eyes-closed'; - const pillW = (text: string) => - text.length * (PILL_FONT * 0.55) + 18; - const startW = pillW(annotation.label); - const endW = annotation.endLabel ? pillW(annotation.endLabel) : 0; - return ( - - - - {ended && ( + const style = TONE_STYLES[annotation.tone]; + const solid = annotation.tone === 'eyes-closed'; + const pillW = (text: string) => + text.length * (PILL_FONT * 0.55) + 18; + const startW = pillW(annotation.label); + const endW = annotation.endLabel ? pillW(annotation.endLabel) : 0; + return ( + + - )} - {bandWidth > 40 && ( - <> - - - - {annotation.label} - - - {ended && annotation.endLabel && ( + {ended && ( + + )} + {bandWidth > 40 && ( + <> - {annotation.endLabel} + {annotation.label} - )} - - )} - - ); - })} + {ended && annotation.endLabel && ( + + + + {annotation.endLabel} + + + )} + + )} + + ); + })} {showAxis && ( <> - - {ticks.map((offset) => ( - - - - {Math.round(offset / 1000)}s - - - ))} - {snapshot.channels.map((channel, i) => ( - - - - {channel} - - - ))} + + {ticks.map((offset) => ( + + + + {Math.round(offset / 1000)}s + + + ))} + {snapshot.channels.map((channel, i) => ( + + + + {channel} + + + ))} )} - + ); } -/** A plot card: one caption line above a plot that fills the given space. */ +/** + * A plot card: one caption line above a plot that fills the given space. The + * dot marks a live plot; a `paused` capture of the student's signal has none. + */ export function PlotCard({ caption, aside, children, className, + paused, }: { caption: string; aside?: ReactNode; children: ReactNode; className?: string; + paused?: boolean; }) { return (
- + {!paused && ( + + )} {caption} {aside} @@ -346,12 +377,15 @@ export function PlotCard({ */ export function QualitySummary({ state, + sensors, className, }: { state: QualityState; + /** Adjust names the sensors whose quality is BAD. */ + sensors: SensorStatus[]; className?: string; }) { - const scenario = QUALITY_SCENARIOS[state]; + const scenario = qualityCopy(state, sensors); // The card earns its place in the yellow/red states; ready stays a light row. const light = scenario.action === ''; return ( @@ -418,6 +452,7 @@ export function LessonStepPanel({ onExit, backLabel = 'Back', nextLabel = 'Next', + unit = 'Step', backDisabled, nextDisabled, }: { @@ -426,6 +461,8 @@ export function LessonStepPanel({ /** Current step; 0 hides the counter and pips (pre-step screens). */ step: number; steps: number; + /** Counter noun: `Step 2 of 4`, `Tip 2 of 3`. */ + unit?: string; title: string; /** The expected action at a glance (plan §5.2). */ action?: string; @@ -451,7 +488,7 @@ export function LessonStepPanel({
{label} - {step > 0 && ` · Step ${step} of ${steps}`} + {step > 0 && ` · ${unit} ${step} of ${steps}`} -
@@ -515,10 +557,6 @@ export function LessonStepPanel({ ); } -/** - * Frozen five-second comparison window with its peak-to-peak readout, like the - * lesson flow's frozen strips. - */ export function FrozenStrip({ label, sublabel, @@ -549,7 +587,7 @@ export function FrozenStrip({
-
-
); -} \ No newline at end of file +} diff --git a/src/renderer/components/Explore/ExploreScreens.tsx b/src/renderer/components/Explore/ExploreScreens.tsx index f0a19fb1..a209fec4 100644 --- a/src/renderer/components/Explore/ExploreScreens.tsx +++ b/src/renderer/components/Explore/ExploreScreens.tsx @@ -1,10 +1,15 @@ -import React, { ReactNode, useMemo, useState } from 'react'; -import { of } from 'rxjs'; -import type { EEGSnapshot, PlotAnnotation } from '../../../shared/eegVizTypes'; +import React, { ReactNode, useState } from 'react'; +import type { Observable } from 'rxjs'; +import type { EEGSnapshot } from '../../../shared/eegVizTypes'; import { PLOTTING_INTERVAL } from '../../constants/constants'; -import { EXPLORE_LESSONS } from '../../constants/exploreLessons'; +import type { SignalQualityData } from '../../constants/interfaces'; +import { + CLEAN_SIGNAL_LESSON, + EXPLORE_LESSONS, + LessonId, +} from '../../constants/exploreLessons'; import eegArt from '../../assets/common/EEG.png'; -import { channelColor } from '../../utils/eeg/traceColors'; +import { channelColors } from '../../utils/eeg/traceColors'; import ExploreSensorCard from '../ExploreSensorCard'; import SignalQualityIndicatorComponent from '../SignalQualityIndicatorComponent'; import { Button } from '../ui/button'; @@ -14,24 +19,19 @@ import { ALPHA_RESULT_BODY, BLINK_NOT_DETECTED, BLINK_STEPS, - CLOSED_SEGMENT, EXPLORE_CHANNELS, EYES_END_BODY, EYES_INTRO_BODY, EYES_INTRO_OPENER, EYES_INTERVAL_BODY, EYES_PROXY_NOTE, - OPEN_SEGMENT, PLOT_LEGEND, - QualityState, REVIEW_SEGMENT_SCALE, - SensorStatus, - qualitySample, } from './fixtures'; +import type { QualityState, SensorStatus } from './quality'; import { AlphaExampleCard, Countdown, - FixturePlot, FrozenStrip, LessonStepPanel, NoiseDefinitionCard, @@ -45,9 +45,8 @@ import { const BODY_TEXT = 'm-0 !text-[16px] leading-normal !tracking-normal [text-wrap:pretty]'; -/** Channel colors from the full device list, so down-selected views agree. */ -const colorsFor = (channels: string[]) => - channels.map((channel) => channelColor(channel, EXPLORE_CHANNELS)); +const LESSON_NOTICE = + 'rounded-md border-2 border-accent px-[12px] py-[8px] text-[14px] leading-[1.45] text-ink'; /** * Redesigned disconnected landing: what Explore is, one primary action, and @@ -85,18 +84,18 @@ export function ExploreDisconnected({ onConnect }: { onConnect(): void }) { ); } -/** The two lessons as equal, local choices; both actions stay outlined. */ +/** The lessons as equal, local choices; every action stays outlined. */ export function LessonPicker({ disabled, onStart, }: { disabled?: boolean; - onStart(): void; + onStart(id: LessonId): void; }) { return (

Learn with this signal

-
+
{EXPLORE_LESSONS.map((lesson) => (
onStart(lesson.id)} > Start @@ -128,14 +127,23 @@ export interface ExploreSurfaceProps { /** `waiting` = connected but no data yet; the four quality states after. */ quality: QualityState | 'waiting'; sensors: SensorStatus[]; - /** Null shows the explicit waiting state in the plot area. */ - snapshot: EEGSnapshot | null; - /** Quality colors per channel: the main surface teaches signal quality. */ - colors: string[]; + /** The device's channels. Pass a stable array: a new one resets the head diagram. */ + channels: string[]; + /** Latest epoch for the sensor card; null while waiting. */ + sample: SignalQualityData | null; + /** The head diagram's stream: the live stream in the app. */ + head: Observable | null | undefined; + /** + * The live plot, drawn in quality colors: the main surface teaches signal + * quality. `ViewerComponent` in the app, a `SnapshotPlot` in stories. + */ + livePlot: ReactNode; + /** Plot caption: `device name · sampling rate Hz`. */ + legend?: string; /** Shared hover state between the head diagram and the sensor card. */ hoveredChannel: string | null; onHoveredChannelChange(channel: string | null): void; - onStartLesson(): void; + onStartLesson(id: LessonId): void; /** Above the status summary, e.g. the stream-error banner. */ banner?: ReactNode; } @@ -148,19 +156,17 @@ export interface ExploreSurfaceProps { export function ExploreSurface({ quality, sensors, - snapshot, - colors, + channels, + sample, + head, + livePlot, + legend = PLOT_LEGEND, hoveredChannel, onHoveredChannelChange, onStartLesson, banner, }: ExploreSurfaceProps) { const waiting = quality === 'waiting'; - const sample = useMemo( - () => (waiting ? null : qualitySample(sensors)), - [waiting, sensors] - ); - const head = useMemo(() => (sample ? of(sample) : null), [sample]); return (
{banner} @@ -181,7 +187,7 @@ export function ExploreSurface({
) : ( - + )}
@@ -189,12 +195,12 @@ export function ExploreSurface({ signalQualityObservable={head} plottingInterval={PLOTTING_INTERVAL} height={250} - channels={sensors.map((s) => s.channel)} + channels={channels} hoveredChannel={hoveredChannel} onHoveredChannelChange={onHoveredChannelChange} /> s.channel)} + channels={channels} sample={sample} hoveredChannel={hoveredChannel} onHoveredChannelChange={onHoveredChannelChange} @@ -202,23 +208,75 @@ export function ExploreSurface({
- {snapshot ? ( - - ) : ( + {waiting ? (
Waiting for the headset signal…
+ ) : ( + livePlot )}
- + +
+
+
+ ); +} + +interface CleanSignalViewProps { + tip: 1 | 2 | 3; + /** The live plot in quality colors: this lesson is about signal quality. */ + livePlot: ReactNode; + legend?: string; + /** The head diagram's stream: the live stream in the app. */ + head: Observable | null | undefined; + channels: string[]; + onBack(): void; + onNext(): void; + onExit(): void; +} + +/** + * The cleaner-signal lesson: the three tips shared with Collect's lesson + * sidebar, in the lesson step panel, beside the live plot and head diagram. + */ +export function CleanSignalView({ + tip, + livePlot, + legend = PLOT_LEGEND, + head, + channels, + onBack, + onNext, + onExit, +}: CleanSignalViewProps) { + return ( +
+ +
+ {livePlot} +
+
@@ -228,19 +286,24 @@ export function ExploreSurface({ export interface BlinkLessonViewProps { /** 0 is the noise-definition intro; 1–4 are the blink steps (plan §5.3). */ step: 0 | 1 | 2 | 3 | 4; - /** Band markers, or the detection-time tick fallback. */ - markerStyle?: 'band' | 'tick'; /** The lesson continues gracefully when detection misses (plan §5.3). */ notDetected?: boolean; /** Pre-answered prediction, for the answered-state story. */ defaultPrediction?: 'hump' | 'flat'; - snapshot: EEGSnapshot; - annotations?: PlotAnnotation[]; + /** The live plot, until step 4's comparison freezes. */ + livePlot: ReactNode; + /** Blinks marked on the live plot, counted on steps 1–2. */ + blinkCount?: number; + legend?: string; + /** The device's full channel list, so frozen strips keep each channel's color. */ + deviceChannels?: string[]; comparison?: { calm: EEGSnapshot; blinking: EEGSnapshot; ratio: number; }; + /** Step 4's plot range as a µV half-range, for the live plot to follow. */ + onRangeChange?(halfRange: number): void; onBack(): void; onNext(): void; onExit(): void; @@ -253,19 +316,22 @@ export interface BlinkLessonViewProps { */ export function BlinkLessonView({ step, - markerStyle = 'band', notDetected, defaultPrediction, - snapshot, - annotations, + livePlot, + blinkCount = 0, + legend = PLOT_LEGEND, + deviceChannels = EXPLORE_CHANNELS, comparison, + onRangeChange, onBack, onNext, onExit, }: BlinkLessonViewProps) { - const [range, setRange] = useState<'wide' | 'narrow'>('wide'); - const colors = colorsFor(snapshot.channels); - const caught = (annotations ?? []).length; + const [range, setRange] = useState(150); + const colors = comparison + ? channelColors(comparison.calm.channels, deviceChannels) + : []; return (
0 ? BLINK_STEPS[step - 1].action : undefined} + action={ + step > 0 && !(step === 4 && !comparison) + ? BLINK_STEPS[step - 1].action + : undefined + } body={ - step === 0 ? : BLINK_STEPS[step - 1].body + step === 0 ? ( + + ) : step === 4 && !comparison ? ( + BLINK_STEPS[3].action + ) : ( + BLINK_STEPS[step - 1].body + ) } backLabel={step === 0 ? 'Exit' : 'Back'} nextLabel={ - step === 0 - ? 'Start the steps' - : step === 4 - ? 'Finish lesson' - : 'Next' + step === 0 ? 'Start the steps' : step === 4 ? 'Finish lesson' : 'Next' } onBack={step === 0 ? onExit : onBack} onNext={onNext} @@ -295,64 +367,62 @@ export function BlinkLessonView({ > {step === 2 && } {notDetected && ( -
+
{BLINK_NOT_DETECTED}
)}
+ {step === 4 && ( +
+ Plot range + {[ + { value: 150, text: '±150 µV' }, + { value: 50, text: '±50 µV' }, + ].map((option) => { + const active = range === option.value; + return ( + + ); + })} +
+ )} {comparison ? ( <> - {step === 4 && ( -
- Plot range - {( - [ - { value: 'wide', text: '±150 µV' }, - { value: 'narrow', text: '±50 µV' }, - ] as const - ).map((option) => { - const active = range === option.value; - return ( - - ); - })} -
- )} @@ -360,24 +430,17 @@ export function BlinkLessonView({ ) : ( 0 && step < 3 ? ( - {caught} {caught === 1 ? 'blink' : 'blinks'} marked + {blinkCount} {blinkCount === 1 ? 'blink' : 'blinks'} marked ) : undefined } > - + {livePlot} )}
@@ -399,7 +462,15 @@ export interface EyesClosedViewProps { /** Measured comparison (eyes-closed ÷ before); null = not enough data. */ rhythmRatio: number | null; showExample?: boolean; - snapshot: EEGSnapshot; + /** The live plot for every phase before review. */ + livePlot: ReactNode; + /** Frozen equal-length review segments; null = not enough data to show. */ + segments?: { open: EEGSnapshot; closed: EEGSnapshot } | null; + legend?: string; + /** The device's full channel list, so the segments keep each channel's color. */ + deviceChannels?: string[]; + /** Why the activity could not start, shown in the step panel. */ + error?: string; onBack(): void; onNext(): void; onExit(): void; @@ -415,7 +486,11 @@ export function EyesClosedView({ countdown = 3, rhythmRatio, showExample, - snapshot, + livePlot, + segments, + legend = PLOT_LEGEND, + deviceChannels = EXPLORE_CHANNELS, + error, onBack, onNext, onExit, @@ -451,17 +526,15 @@ export function EyesClosedView({ : undefined } body={ - phase === 'intro' ? ( - EYES_INTRO_BODY - ) : phase === 'countdown' ? ( - 'Close your eyes when you hear the single chime.' - ) : phase === 'interval' ? ( - EYES_INTERVAL_BODY - ) : phase === 'end' ? ( - EYES_END_BODY - ) : ( - resultBody - ) + phase === 'intro' + ? EYES_INTRO_BODY + : phase === 'countdown' + ? 'Close your eyes when you hear the single chime.' + : phase === 'interval' + ? EYES_INTERVAL_BODY + : phase === 'end' + ? EYES_END_BODY + : resultBody } nextLabel={ { @@ -478,6 +551,11 @@ export function EyesClosedView({ onNext={onNext} onExit={onExit} > + {error && ( +
+ {error} +
+ )} {phase === 'intro' && showExample && } {phase === 'review' && ( <> @@ -497,25 +575,29 @@ export function EyesClosedView({
{phase === 'review' ? ( - - + + {segments ? ( + + ) : ( +
+ Not enough continuous data to show this interval. +
+ )}
) : ( - + {livePlot} )} {(running || phase === 'end') && ( @@ -539,4 +621,4 @@ export function EyesClosedView({
); -} \ No newline at end of file +} diff --git a/src/renderer/components/Explore/fixtures.ts b/src/renderer/components/Explore/fixtures.ts index 8f463848..602d0251 100644 --- a/src/renderer/components/Explore/fixtures.ts +++ b/src/renderer/components/Explore/fixtures.ts @@ -1,9 +1,7 @@ import type { EEGSnapshot, PlotAnnotation } from '../../../shared/eegVizTypes'; -import { - MUSE_SAMPLING_RATE, - SIGNAL_QUALITY, -} from '../../constants/constants'; +import { MUSE_SAMPLING_RATE, SIGNAL_QUALITY } from '../../constants/constants'; import type { SignalQualityData } from '../../constants/interfaces'; +import type { QualityState, SensorStatus } from './quality'; /** Muse montage order, matching the live viewer's channel order. */ export const EXPLORE_CHANNELS = ['TP9', 'AF7', 'AF8', 'TP10']; @@ -178,24 +176,27 @@ export const EYES_CLOSED_LIVE = makeSnapshot(EXPLORE_CHANNELS, 5000, { */ export const REVIEW_SEGMENT_MS = 3000; export const REVIEW_SEGMENT_SCALE = 50; -export const OPEN_SEGMENT = makeSnapshot(POSTERIOR_CHANNELS, REVIEW_SEGMENT_MS, { - seed: 51, - gain: 0.6, -}); -export const CLOSED_SEGMENT = makeSnapshot(POSTERIOR_CHANNELS, REVIEW_SEGMENT_MS, { - seed: 52, - alphaMs: [0, REVIEW_SEGMENT_MS], -}); +export const OPEN_SEGMENT = makeSnapshot( + POSTERIOR_CHANNELS, + REVIEW_SEGMENT_MS, + { + seed: 51, + gain: 0.6, + } +); +export const CLOSED_SEGMENT = makeSnapshot( + POSTERIOR_CHANNELS, + REVIEW_SEGMENT_MS, + { + seed: 52, + alphaMs: [0, REVIEW_SEGMENT_MS], + } +); /** Measured comparison (eyes-closed ÷ before) for the result stories. */ export const RHYTHM_INCREASE_RATIO = 2.4; export const RHYTHM_NO_EFFECT_RATIO = 0.9; -export interface SensorStatus { - channel: string; - quality: SIGNAL_QUALITY; -} - /** A one-emission fixture stream for `SignalQualityIndicatorComponent`. */ export function qualitySample(sensors: SensorStatus[]): SignalQualityData { return { @@ -211,66 +212,29 @@ export function qualitySample(sensors: SensorStatus[]): SignalQualityData { }; } -export type QualityState = 'ready' | 'settling' | 'adjust' | 'no-signal'; - -/** - * Overall status above the plot (plan §5.1). The four headings are the - * product's wording; `action` names what to do where an action exists. The - * ready state stays light — the card mainly earns its place in the yellow and - * red states, where it gives real instructions. - */ -export const QUALITY_SCENARIOS: Record< - QualityState, - { heading: string; action: string; sensors: SensorStatus[] } -> = { - ready: { - heading: 'Ready to explore', - action: '', - sensors: EXPLORE_CHANNELS.map((channel) => ({ - channel, - quality: SIGNAL_QUALITY.GREAT, - })), - }, - settling: { - heading: 'Sensors are still settling', - action: - 'Better contact means less static. Give the sensors a few minutes to settle, and keep still.', - sensors: EXPLORE_CHANNELS.map((channel) => ({ - channel, - quality: channel === 'TP10' ? SIGNAL_QUALITY.GREAT : SIGNAL_QUALITY.OK, - })), - }, - adjust: { - heading: 'Adjust AF7 and TP10', - action: - 'Press AF7 and TP10 gently against your skin — or move hair aside — and hold for 10 seconds.', - sensors: EXPLORE_CHANNELS.map((channel) => ({ - channel, - quality: - channel === 'AF7' || channel === 'TP10' - ? SIGNAL_QUALITY.BAD - : channel === 'AF8' - ? SIGNAL_QUALITY.OK - : SIGNAL_QUALITY.GREAT, - })), - }, - 'no-signal': { - heading: 'No signal detected', - action: - 'Your headset is off or disconnected. Turn it on or reconnect it, then check that the sensors touch your skin.', - sensors: EXPLORE_CHANNELS.map((channel) => ({ - channel, - quality: SIGNAL_QUALITY.DISCONNECTED, - })), - }, -}; - -/** Overall dot color; supporting signal only — the words carry the meaning. */ -export const QUALITY_STATE_TONE: Record = { - ready: SIGNAL_QUALITY.GREAT, - settling: SIGNAL_QUALITY.OK, - adjust: SIGNAL_QUALITY.BAD, - 'no-signal': SIGNAL_QUALITY.DISCONNECTED, +/** Per-sensor quality for each overall state's story (copy lives in `quality.ts`). */ +export const QUALITY_SENSORS: Record = { + ready: EXPLORE_CHANNELS.map((channel) => ({ + channel, + quality: SIGNAL_QUALITY.GREAT, + })), + settling: EXPLORE_CHANNELS.map((channel) => ({ + channel, + quality: channel === 'TP10' ? SIGNAL_QUALITY.GREAT : SIGNAL_QUALITY.OK, + })), + adjust: EXPLORE_CHANNELS.map((channel) => ({ + channel, + quality: + channel === 'AF7' || channel === 'TP10' + ? SIGNAL_QUALITY.BAD + : channel === 'AF8' + ? SIGNAL_QUALITY.OK + : SIGNAL_QUALITY.GREAT, + })), + 'no-signal': EXPLORE_CHANNELS.map((channel) => ({ + channel, + quality: SIGNAL_QUALITY.DISCONNECTED, + })), }; /** Plan §5.1 wording, shown before the student judges anything. */ @@ -304,7 +268,7 @@ export const BLINK_STEPS: { { title: 'Compare the blinking interval with a quiet interval', action: 'Now sit still, eyes open, for 5 seconds.', - body: 'One frozen five seconds while you were blinking, one while you sat still. Your brain signal is in both — the blinks just tower over it. This is why researchers ask you to hold still.', + body: 'Five paused seconds while you were blinking, five while you sat still. Your brain signal is in both — the blinks just tower over it. This is why researchers ask you to hold still.', }, ]; @@ -328,4 +292,4 @@ export const ALPHA_RESULT_BODY = export const ALPHA_NO_EFFECT_BODY = 'That is a real result, not a failed lesson — this change is clearest in some people and nearly invisible in others.'; export const ALPHA_EXAMPLE_CAPTION = - 'Some recordings look like this. Yours will be yours.'; \ No newline at end of file + 'Some recordings look like this. Yours will be yours.'; diff --git a/src/renderer/components/Explore/quality.test.ts b/src/renderer/components/Explore/quality.test.ts new file mode 100644 index 00000000..9d111428 --- /dev/null +++ b/src/renderer/components/Explore/quality.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { SIGNAL_QUALITY } from '../../constants/constants'; +import { qualityCopy, summarizeQuality } from './quality'; + +const CHANNELS = ['TP9', 'AF7', 'AF8', 'TP10']; +const { GREAT, OK, BAD, DISCONNECTED } = SIGNAL_QUALITY; +const sample = (...qualities: SIGNAL_QUALITY[]) => + Object.fromEntries(CHANNELS.map((channel, i) => [channel, qualities[i]])); + +describe('summarizeQuality', () => { + it('ranks no signal, then BAD, then OK, then ready', () => { + expect(summarizeQuality(undefined, CHANNELS).state).toBe('no-signal'); + expect( + summarizeQuality( + sample(DISCONNECTED, DISCONNECTED, DISCONNECTED, DISCONNECTED), + CHANNELS + ).state + ).toBe('no-signal'); + expect( + summarizeQuality(sample(OK, BAD, GREAT, DISCONNECTED), CHANNELS).state + ).toBe('adjust'); + expect( + summarizeQuality(sample(OK, GREAT, GREAT, DISCONNECTED), CHANNELS).state + ).toBe('settling'); + expect( + summarizeQuality(sample(GREAT, GREAT, GREAT, GREAT), CHANNELS).state + ).toBe('ready'); + }); + + it('treats a channel missing from the epoch as disconnected', () => { + const { sensors } = summarizeQuality({ AF7: GREAT }, CHANNELS); + expect(sensors.map((s) => s.quality)).toEqual([ + DISCONNECTED, + GREAT, + DISCONNECTED, + DISCONNECTED, + ]); + }); + + it('names exactly the BAD sensors in the adjust copy', () => { + const { state, sensors } = summarizeQuality( + sample(GREAT, BAD, OK, BAD), + CHANNELS + ); + expect(qualityCopy(state, sensors).heading).toBe('Adjust AF7 and TP10'); + }); +}); diff --git a/src/renderer/components/Explore/quality.ts b/src/renderer/components/Explore/quality.ts new file mode 100644 index 00000000..36d8177e --- /dev/null +++ b/src/renderer/components/Explore/quality.ts @@ -0,0 +1,79 @@ +import { SIGNAL_QUALITY } from '../../constants/constants'; + +export type QualityState = 'ready' | 'settling' | 'adjust' | 'no-signal'; + +export interface SensorStatus { + channel: string; + quality: SIGNAL_QUALITY; +} + +/** + * Overall status copy above the plot (plan §5.1). The headings are the + * product's wording; `action` names what to do. Ready has no action, so it + * stays a light row; `adjust` is built from the sensors that need it. + */ +const QUALITY_COPY: Record< + Exclude, + { heading: string; action: string } +> = { + ready: { heading: 'Ready to explore', action: '' }, + settling: { + heading: 'Sensors are still settling', + action: + 'Better contact means less static. Give the sensors a few minutes to settle, and keep still.', + }, + 'no-signal': { + heading: 'No signal detected', + action: + 'Your headset is off or disconnected. Turn it on or reconnect it, then check that the sensors touch your skin.', + }, +}; + +/** Overall dot color; supporting signal only — the words carry the meaning. */ +export const QUALITY_STATE_TONE: Record = { + ready: SIGNAL_QUALITY.GREAT, + settling: SIGNAL_QUALITY.OK, + adjust: SIGNAL_QUALITY.BAD, + 'no-signal': SIGNAL_QUALITY.DISCONNECTED, +}; + +/** Heading and action for a state; `adjust` names the BAD sensors. */ +export function qualityCopy(state: QualityState, sensors: SensorStatus[]) { + if (state !== 'adjust') return QUALITY_COPY[state]; + const names = new Intl.ListFormat('en').format( + sensors + .filter((s) => s.quality === SIGNAL_QUALITY.BAD) + .map((s) => s.channel) + ); + return { + heading: `Adjust ${names}`, + action: `Press ${names} gently against your skin — or move hair aside — and hold for 10 seconds.`, + }; +} + +/** + * One epoch's per-channel quality colors → the overall state. No sample or + * every channel disconnected is `no-signal`; then any BAD → `adjust`, any OK + * → `settling`, else `ready`. Callers show `waiting` before the first sample. + */ +export function summarizeQuality( + signalQuality: Record | undefined, + channels: string[] +): { state: QualityState; sensors: SensorStatus[] } { + const sensors = channels.map((channel) => ({ + channel, + quality: + (signalQuality?.[channel] as SIGNAL_QUALITY | undefined) ?? + SIGNAL_QUALITY.DISCONNECTED, + })); + const state: QualityState = + !signalQuality || + sensors.every((s) => s.quality === SIGNAL_QUALITY.DISCONNECTED) + ? 'no-signal' + : sensors.some((s) => s.quality === SIGNAL_QUALITY.BAD) + ? 'adjust' + : sensors.some((s) => s.quality === SIGNAL_QUALITY.OK) + ? 'settling' + : 'ready'; + return { state, sensors }; +} diff --git a/src/renderer/components/ExploreLessonFlow.tsx b/src/renderer/components/ExploreLessonFlow.tsx index 08dd2177..83444be6 100644 --- a/src/renderer/components/ExploreLessonFlow.tsx +++ b/src/renderer/components/ExploreLessonFlow.tsx @@ -1,25 +1,33 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Observable } from 'rxjs'; -import { EEGSnapshot, PlotAnnotation } from '../../shared/eegVizTypes'; -import { - CLEAN_SIGNAL_LESSON, - LessonId, - NOISE_LESSON, -} from '../constants/exploreLessons'; +import type { EEGSnapshot, PlotAnnotation } from '../../shared/eegVizTypes'; +import { LessonId } from '../constants/exploreLessons'; import { PLOTTING_INTERVAL } from '../constants/constants'; import { SignalQualityData } from '../constants/interfaces'; -import { ExploreSession, FrozenComparison } from '../utils/eeg/exploreSignal'; +import { + ExploreSession, + FRONTAL, + FrozenComparison, + POSTERIOR, +} from '../utils/eeg/exploreSignal'; import { LessonAudio } from '../utils/eeg/lessonAudio'; -import SignalQualityIndicatorComponent from './SignalQualityIndicatorComponent'; +import { channelColors } from '../utils/eeg/traceColors'; import ViewerComponent from './ViewerComponent'; -import { Button } from './ui/button'; -import { cn } from './ui/utils'; +import { + BlinkLessonView, + CleanSignalView, + EyesClosedPhase, + EyesClosedView, +} from './Explore/ExploreScreens'; +import { REVIEW_SEGMENT_MS } from './Explore/fixtures'; interface Props { lesson: LessonId; stream?: Observable; + /** The device's full channel list. */ channels: string[]; - samplingRate: number; + /** Plot caption: `device name · sampling rate Hz`. */ + legend: string; session: ExploreSession; sample: SignalQualityData | null; onExit: () => void; @@ -30,96 +38,50 @@ interface CueWindow { endTime: number | null; } -const FRONTAL_CHANNELS = ['AF7', 'AF8']; -const labelClass = 'text-[13px] font-bold tracking-[0.5px] text-ink-muted'; - -function FrozenStrip({ - snapshot, - scale, - blinking, - ratio, -}: { - snapshot: EEGSnapshot; - scale: number; - blinking?: boolean; - ratio?: number; -}) { - return ( -
-
- - {blinking - ? 'WHILE YOU WERE BLINKING · FROZEN' - : 'SITTING STILL · 5 SECONDS, FROZEN'} - - - {blinking ? 'same sensors, same scale' : 'measured on AF7 · AF8'} - -
-
-
- -
-
-
-
-
{snapshot.peakToPeak.toFixed(0)} µV
-
- {blinking && ratio !== undefined - ? `${Math.round(ratio)}× the still signal` - : 'peak to peak'} -
-
-
-
-
- ); -} +/** Blink steps 1–3 say no blink was seen after this long without one. */ +const NO_BLINK_MS = 8000; +/** Step 4 asks for five still seconds before freezing the comparison. */ +const STILL_MS = 5000; +/** A stream older than this cannot time the eyes-closed interval. */ +const STALE_MS = 1500; -/** The live viewer stays mounted across steps; comparison/review use owned copies. */ -export default function ExploreLessonFlow(props: Props) { - const { lesson, stream, channels, samplingRate, session, sample, onExit } = - props; +/** + * Runs one Explore lesson in the design's views, all state local: the + * cleaner-signal tips 1–3, the noise lesson's blink steps 0–4, and the + * eyes-closed activity (intro → 3-2-1 → the chime-bounded interval → review). + * One live viewer stays mounted across a lesson's steps; frozen copies come + * from `session`. Arrow keys and Escape, from the page or the focused viewer, + * go Back / Next / Exit. + */ +export default function ExploreLessonFlow({ + lesson, + stream, + channels, + legend, + session, + sample, + onExit, +}: Props) { const noise = lesson === 'noise-sources'; - const [step, setStep] = useState(0); + const [step, setStep] = useState(lesson === 'clean-signal' ? 1 : 0); + const [waited, setWaited] = useState(false); const [comparison, setComparison] = useState(null); - const [comparisonSkipped, setComparisonSkipped] = useState(false); - const [waitedForBaseline, setWaitedForBaseline] = useState(false); - const [audioUnlocked, setAudioUnlocked] = useState(false); - const [audioBusy, setAudioBusy] = useState(false); - const [running, setRunning] = useState(false); + const [range, setRange] = useState(150); + const [phase, setPhase] = useState('intro'); + const [countdown, setCountdown] = useState<3 | 2 | 1>(3); const [cue, setCue] = useState(null); - const [review, setReview] = useState(null); - const [alphaRatio, setAlphaRatio] = useState(null); + const [review, setReview] = useState<{ + segments: { open: EEGSnapshot; closed: EEGSnapshot } | null; + ratio: number | null; + } | null>(null); const [cueError, setCueError] = useState(''); const audio = useRef(null); - const mounted = useRef(true); const cueGeneration = useRef(0); - const heading = useRef(null); + const stepStart = useRef(null); const signalClock = useRef({ sampleTime: 0, wallTime: 0 }); + const lastSample = useRef(null); const status = session.status(); const { latestTime } = status; - const lastSample = useRef(null); if (sample && sample !== lastSample.current) { lastSample.current = sample; signalClock.current = { @@ -129,24 +91,45 @@ export default function ExploreLessonFlow(props: Props) { wallTime: Date.now(), }; } + const running = phase === 'countdown' || phase === 'interval'; useEffect(() => { - mounted.current = true; audio.current = new LessonAudio(); return () => { - mounted.current = false; cueGeneration.current++; audio.current?.dispose(); }; }, []); useEffect(() => { - heading.current?.focus({ preventScroll: true }); - setWaitedForBaseline(false); - if (!noise || step !== 1) return; - const timer = window.setTimeout(() => setWaitedForBaseline(true), 8000); + stepStart.current = session.status().latestTime; + setWaited(false); + if (!noise || step < 1 || step > 3) return; + const timer = window.setTimeout(() => setWaited(true), NO_BLINK_MS); + return () => window.clearTimeout(timer); + }, [noise, step, session]); + + useEffect(() => { + if ( + !noise || + step !== 4 || + comparison || + latestTime == null || + stepStart.current == null || + latestTime < stepStart.current + STILL_MS + ) + return; + setComparison(session.comparison()); + }, [noise, step, comparison, latestTime, session]); + + useEffect(() => { + if (phase !== 'countdown') return; + const timer = window.setTimeout(() => { + if (countdown > 1) setCountdown((countdown - 1) as 2 | 1); + else void startInterval(); + }, 1000); return () => window.clearTimeout(timer); - }, [noise, step]); + }, [phase, countdown]); useEffect(() => { if ( @@ -156,46 +139,120 @@ export default function ExploreLessonFlow(props: Props) { review ) return; - const frozen = session.snapshot(cue.startTime - 2000, cue.endTime + 250); - if (!frozen) return; - setReview(frozen); - setAlphaRatio(session.alphaRatio(cue.startTime, cue.endTime)); - }, [cue, latestTime, review, session]); + const { startTime, endTime } = cue; + const posterior = channels.filter((name) => POSTERIOR.test(name)); + const middle = (startTime + endTime) / 2; + const open = posterior.length + ? session.snapshot(startTime - REVIEW_SEGMENT_MS, startTime, posterior) + : null; + const closed = posterior.length + ? session.snapshot( + middle - REVIEW_SEGMENT_MS / 2, + middle + REVIEW_SEGMENT_MS / 2, + posterior + ) + : null; + setReview({ + segments: open && closed ? { open, closed } : null, + ratio: session.alphaRatio(startTime, endTime), + }); + }, [cue, latestTime, review, session, channels]); function stopCues() { cueGeneration.current++; audio.current?.cancel(); - setRunning(false); - setAudioBusy(false); + setCountdown(3); setCue(null); setReview(null); - setAlphaRatio(null); setCueError(''); } + function sampleTimeAt(wallTime: number) { + return ( + signalClock.current.sampleTime + wallTime - signalClock.current.wallTime + ); + } + + /** Begin: unlock audio inside the click (autoplay needs the gesture), then count down. */ + async function begin() { + if ( + latestTime == null || + Date.now() - signalClock.current.wallTime > STALE_MS + ) { + setCueError('Wait for a live signal before starting the interval.'); + return; + } + stopCues(); + const generation = cueGeneration.current; + try { + await audio.current?.preview(); + } catch (error) { + if (generation === cueGeneration.current) + setCueError( + error instanceof Error ? error.message : 'Check your sound output.' + ); + return; + } + if (generation === cueGeneration.current) setPhase('countdown'); + } + + async function startInterval() { + const generation = cueGeneration.current; + setPhase('interval'); + try { + await audio.current?.start( + (wallTime) => { + if (generation === cueGeneration.current) + setCue({ startTime: sampleTimeAt(wallTime), endTime: null }); + }, + (wallTime) => { + if (generation !== cueGeneration.current) return; + setCue( + (current) => + current && { ...current, endTime: sampleTimeAt(wallTime) } + ); + setPhase('end'); + } + ); + } catch (error) { + if (generation !== cueGeneration.current) return; + setPhase('intro'); + setCueError( + error instanceof Error + ? error.message + : 'Audio could not start. Check your sound output.' + ); + } + } + function exit() { stopCues(); onExit(); } function back() { - stopCues(); - if (step === 0) onExit(); - else setStep(noise && step === 3 && comparisonSkipped ? 1 : step - 1); + if (running) return; + if (lesson === 'eyes-closed') { + if (phase === 'intro') exit(); + else { + stopCues(); + setPhase('intro'); + } + } else if (step === (noise ? 0 : 1)) exit(); + else { + setComparison(null); + setStep(step - 1); + } } function next() { - if (running || audioBusy) return; - if (step === 3) { - exit(); - return; - } - if (noise && step === 1) { - const result = session.comparison(); - setComparison(result); - setComparisonSkipped(result === null); - setStep(result ? 2 : 3); - } else setStep(step + 1); + if (running) return; + if (lesson === 'eyes-closed') { + if (phase === 'intro') void begin(); + else if (phase === 'end') setPhase('review'); + else exit(); + } else if (step === (noise ? 4 : 3)) exit(); + else setStep(step + 1); } const navigation = useRef({ back, next, exit }); @@ -230,340 +287,107 @@ export default function ExploreLessonFlow(props: Props) { else navigation.current.exit(); } - function rerecord() { - stopCues(); - session.reset(); - setComparison(null); - setComparisonSkipped(false); - setWaitedForBaseline(false); - setStep(1); - } + const shown = noise && step >= 1 && status.supported ? FRONTAL : channels; + const colors = useMemo( + () => channelColors(shown, channels), + [shown, channels] + ); + const visibleBlinks = + noise && step >= 1 && latestTime != null + ? status.blinkEvents.filter((event) => event.endTime >= latestTime - 5000) + : []; + const annotations: PlotAnnotation[] = noise + ? visibleBlinks.map((event) => ({ + id: `blink-${event.startTime}`, + startTime: event.startTime, + endTime: event.endTime, + label: 'blink · from your eyes, not your brain', + tone: 'blink', + })) + : lesson === 'eyes-closed' && cue + ? [ + { + id: 'eyes-closed', + ...cue, + label: 'eyes closed', + endLabel: 'eyes open', + tone: 'eyes-closed', + }, + ] + : []; + const livePlot = ( + + ); - function sampleTimeAt(wallTime: number) { + if (lesson === 'clean-signal') return ( - signalClock.current.sampleTime + wallTime - signalClock.current.wallTime + ); - } - - async function hearChime() { - if (!audio.current) return; - setAudioBusy(true); - setCueError(''); - const generation = cueGeneration.current; - try { - const played = await audio.current.preview(); - if (mounted.current && generation === cueGeneration.current) - setAudioUnlocked(played); - } catch (error) { - if (mounted.current && generation === cueGeneration.current) - setCueError( - error instanceof Error ? error.message : 'Check your sound output.' - ); - } finally { - if (mounted.current && generation === cueGeneration.current) - setAudioBusy(false); - } - } - - async function startInterval() { - if ( - latestTime == null || - Date.now() - signalClock.current.wallTime > 1500 - ) { - setCueError('Wait for a live signal before starting the interval.'); - return; - } - stopCues(); - const generation = cueGeneration.current; - setRunning(true); - const startAt = (wallTime: number) => { - if (!mounted.current || generation !== cueGeneration.current) return; - setCue({ startTime: sampleTimeAt(wallTime), endTime: null }); - }; - const endAt = (wallTime: number) => { - if (!mounted.current || generation !== cueGeneration.current) return; - setCue( - (current) => current && { ...current, endTime: sampleTimeAt(wallTime) } - ); - setRunning(false); - }; - try { - await audio.current?.start(startAt, endAt); - } catch (error) { - if (mounted.current && generation === cueGeneration.current) { - setRunning(false); - setCueError( - error instanceof Error - ? error.message - : 'Audio could not start. Check your sound output.' - ); - } - } - } - - const visibleBlinks = - latestTime == null - ? [] - : status.blinkEvents.filter( - (event) => event.endTime >= latestTime - 5000 - ); - const annotations: PlotAnnotation[] = - noise && step === 1 - ? visibleBlinks.map((event) => ({ - id: `blink-${event.startTime}`, - startTime: event.startTime, - endTime: event.endTime, - label: 'blink · eye muscle, not brain', - tone: 'blink', - })) - : noise && step === 3 && cue - ? [ - { - id: 'eyes-closed', - ...cue, - label: 'eyes closed', - endLabel: 'eyes open', - tone: 'eyes-closed', - }, - ] - : []; - const caption = - !noise || step === 0 - ? 'your signal, live' - : step === 1 - ? 'watching the frontal sensors (AF7, AF8)' - : running - ? 'recording' - : 'your signal, live'; - const title = noise - ? NOISE_LESSON[step].title - : CLEAN_SIGNAL_LESSON[step].title; - const displayChannels = - noise && step === 1 && status.supported ? FRONTAL_CHANNELS : channels; - - return ( -
-
-
- {noise - ? 'WHERE IS THIS NOISE COMING FROM?' - : 'HOW DO I GET A CLEANER SIGNAL?'}{' '} - · STEP {step + 1} OF 4 -
- - -
-
= 1 && + step <= 3 && + !status.blinkEvents.some( + (event) => + stepStart.current != null && event.endTime >= stepStart.current + ) } - > -
-
- - - {caption} - - {noise && step === 1 && ( - - {visibleBlinks.length}{' '} - {visibleBlinks.length === 1 ? 'blink' : 'blinks'} caught - - )} -
- -
- {(!noise || step === 0) && ( -
- -
- )} -
- {noise && step === 2 && comparison && ( -
- - -
- )} - {noise && step === 1 && ( -
- {!status.supported - ? 'Blink detection needs both AF7 and AF8. You can still watch your signal and continue to the eyes-closed step.' - : !status.baselineStable - ? waitedForBaseline - ? 'We cannot see your blinks yet, check the forehead sensors.' - : 'Sit still for a moment while the forehead sensors settle, then try blinking.' - : 'Forehead baseline ready. Blink a few times, then sit still so we can compare.'} -
- )} - {noise && step === 3 && comparisonSkipped && ( -
- - We don't have a blink window and five quiet seconds to compare. - - -
- )} - {noise && step === 3 && review && ( -
- - Review your marked interval · frozen copy - - -
- {alphaRatio === null - ? 'There is not enough continuous posterior-channel data to compare alpha power.' - : alphaRatio > 1 - ? `Measured alpha power was ${alphaRatio.toFixed(1)}× the preceding five seconds.` - : 'Alpha did not increase in this interval. That is a real result, not a failed lesson.'} -
-
- )} -
-
-

- {title} -

- {!noise ? ( -

{CLEAN_SIGNAL_LESSON[step].body}

- ) : step === 0 ? ( -

- Those wiggles are real voltage picked up off your scalp — a few - millionths of a volt, arriving {samplingRate} times a second. - Watch them for a moment before we start poking at them. -

- ) : step === 1 ? ( -

{NOISE_LESSON[1].body}

- ) : step === 2 ? ( -

- {comparison && Math.round(comparison.ratio) >= 2 - ? `This is why researchers ask you to hold still: the noise is not small and shy, it’s ${Math.round(comparison.ratio)} times louder than the thing we came to measure.` - : 'Same sensors, same scale. These windows are close in size: try re-recording while sitting still, then blinking clearly.'} -

- ) : ( - <> -

{NOISE_LESSON[3].body}

-

- The back of your head starts humming a steady rhythm when it has - nothing to look at. That is the paradox of alpha waves: the - seeing part of your brain gets louder once you stop giving it - anything to see. Unlike the blink, this one really is your - brain. -

- - )} - {cueError && ( - - {cueError} - - )} -
-
- {noise && step === 2 && ( - - )} - {noise && step === 3 && ( - <> - - - - )} - - -
-
-
+ livePlot={livePlot} + blinkCount={visibleBlinks.length} + legend={legend} + deviceChannels={channels} + comparison={ + comparison + ? { + calm: comparison.calm, + blinking: comparison.blink, + ratio: comparison.ratio, + } + : undefined + } + onRangeChange={setRange} + onBack={back} + onNext={next} + onExit={exit} + /> + ); + return ( + ); } diff --git a/src/renderer/components/HeadsetSetup/HeadsetSetup.stories.tsx b/src/renderer/components/HeadsetSetup/HeadsetSetup.stories.tsx index c85dc0be..2a6c1dfd 100644 --- a/src/renderer/components/HeadsetSetup/HeadsetSetup.stories.tsx +++ b/src/renderer/components/HeadsetSetup/HeadsetSetup.stories.tsx @@ -35,6 +35,7 @@ const meta: Meta = { onConnect: fn(), onStartSoftwareSource: fn(), onDone: fn(), + onDisconnect: fn(), onClose: fn(), }, }; diff --git a/src/renderer/components/HeadsetSetup/HeadsetSetup.tsx b/src/renderer/components/HeadsetSetup/HeadsetSetup.tsx index 964241fa..58740e0f 100644 --- a/src/renderer/components/HeadsetSetup/HeadsetSetup.tsx +++ b/src/renderer/components/HeadsetSetup/HeadsetSetup.tsx @@ -71,6 +71,8 @@ interface Props { onStartSoftwareSource(): void; /** Leave pairing for signal preparation on Explore/Collect. */ onDone(): void; + /** Connected screen: disconnect the headset. */ + onDisconnect(): void; /** Closing must also cancel any pending search. */ onClose(): void; } @@ -532,6 +534,9 @@ export default function HeadsetSetup(props: Props) { when you run an experiment.

+ diff --git a/src/renderer/components/HeadsetSetup/HeadsetSetupDialog.tsx b/src/renderer/components/HeadsetSetup/HeadsetSetupDialog.tsx index a510e8e2..12780b60 100644 --- a/src/renderer/components/HeadsetSetup/HeadsetSetupDialog.tsx +++ b/src/renderer/components/HeadsetSetup/HeadsetSetupDialog.tsx @@ -147,6 +147,10 @@ export default function HeadsetSetupDialog({ open, onClose, onDone }: Props) { onDone(device); close(); }} + onDisconnect={() => { + dispatch(DeviceActions.DisconnectFromDevice()); + close(); + }} onClose={close} /> diff --git a/src/renderer/components/ViewerComponent.tsx b/src/renderer/components/ViewerComponent.tsx index ae586a96..6ae9aad7 100644 --- a/src/renderer/components/ViewerComponent.tsx +++ b/src/renderer/components/ViewerComponent.tsx @@ -19,8 +19,10 @@ interface Props { windowDuration?: number; annotations?: PlotAnnotation[]; snapshot?: EEGSnapshot | null; - /** Symmetric microvolt half-range; snapshots are centered on each channel's mean. */ + /** Symmetric microvolt half-range, live or frozen; snapshots are centered on each channel's mean. */ amplitudeScale?: number; + /** Fixed trace colors, index-aligned with `channels`; absent = quality colors. Pass a stable array. */ + channelColors?: string[]; /** Forward Left/Right/Escape from a focused webview guest to surrounding lesson UI. */ onNavigate?: (action: 'left' | 'right' | 'escape') => void; } @@ -31,7 +33,7 @@ function graphParameters(props: Props): ViewerGraphParameters { channels, plottingInterval: props.plottingInterval, domain: props.windowDuration ?? VIEWER_DEFAULTS.domain, - channelColours: channels.map(() => '#66B0A9'), + channelColours: props.channelColors, annotations: props.annotations ?? [], snapshot: props.snapshot ?? null, amplitudeScale: props.amplitudeScale, @@ -96,8 +98,8 @@ export default function ViewerComponent(props: Props) { }, [viewerUrl]); useEffect(() => { - send('updateChannels', channels); - }, [ready, channels]); + send('updateChannels', { channels, channelColours: props.channelColors }); + }, [ready, channels, props.channelColors]); useEffect(() => { send('updateDomain', props.windowDuration ?? VIEWER_DEFAULTS.domain); @@ -110,9 +112,9 @@ export default function ViewerComponent(props: Props) { useEffect(() => { send('updateSnapshot', { snapshot: props.snapshot ?? null, - amplitudeScale: propsRef.current.amplitudeScale, + amplitudeScale: props.amplitudeScale, }); - }, [ready, props.snapshot]); + }, [ready, props.snapshot, props.amplitudeScale]); useEffect(() => { if (!ready || frozen || !props.signalQualityObservable) return; diff --git a/src/renderer/components/d3Classes/EEGViewer.d.ts b/src/renderer/components/d3Classes/EEGViewer.d.ts index 2c1c5a59..71efba36 100644 --- a/src/renderer/components/d3Classes/EEGViewer.d.ts +++ b/src/renderer/components/d3Classes/EEGViewer.d.ts @@ -10,7 +10,7 @@ export default class EEGViewer { constructor(svg: SVGSVGElement, parameters: ViewerGraphParameters); destroy(): void; updateData(epoch: ViewerEpoch): void; - updateChannels(channels: string[]): void; + updateChannels(channels: string[], channelColours?: string[]): void; updateDomain(domain: number): void; zoomIn(): void; zoomOut(): void; diff --git a/src/renderer/components/d3Classes/EEGViewer.js b/src/renderer/components/d3Classes/EEGViewer.js index 1f793f22..24bf4e06 100644 --- a/src/renderer/components/d3Classes/EEGViewer.js +++ b/src/renderer/components/d3Classes/EEGViewer.js @@ -5,6 +5,9 @@ import simplify from 'simplify-js'; const ZOOM_SCALAR = 1.5; +/** Trace color before the first epoch reports quality. */ +const DEFAULT_COLOUR = '#66B0A9'; + const TONE_STYLES = { blink: { fill: 'rgba(255, 193, 7, 0.18)', @@ -39,7 +42,9 @@ export default class EEGViewer { this.channels = parameters.channels; this.plottingInterval = parameters.plottingInterval; this.domain = parameters.domain; - this.channelColours = parameters.channelColours; + this.fixedColours = parameters.channelColours != null; + this.channelColours = + parameters.channelColours ?? this.channels.map(() => DEFAULT_COLOUR); this.annotations = parameters.annotations ?? []; this.snapshot = parameters.snapshot ?? null; this.amplitudeScale = parameters.amplitudeScale; @@ -135,8 +140,6 @@ export default class EEGViewer { .append('rect') .attr('class', 'annotation-clip') .attr('x', 0) - // Bands stay inside the plot box, but their pills sit just above and - // below it — a plot-height clip would hide the end label entirely. .attr('y', -LABEL_GUTTER) .attr('width', this.width) .attr('height', this.height + 2 * LABEL_GUTTER); @@ -304,12 +307,13 @@ export default class EEGViewer { ); } - this.channelColours = this.channels.map( - (channelName) => - epoch.signalQuality?.[channelName] ?? - this.channelColours[this.channels.indexOf(channelName)] ?? - '#66B0A9' - ); + if (!this.fixedColours) + this.channelColours = this.channels.map( + (channelName) => + epoch.signalQuality?.[channelName] ?? + this.channelColours[this.channels.indexOf(channelName)] ?? + DEFAULT_COLOUR + ); this.redraw(); this.slideIn(this.lastTimestamp - previousLast); @@ -351,8 +355,11 @@ export default class EEGViewer { return this.channels.map((name) => sourceNames.indexOf(name)); } - updateChannels(channels) { + updateChannels(channels, channelColours) { this.channels = channels; + this.fixedColours = channelColours != null; + this.channelColours = + channelColours ?? this.channels.map(() => DEFAULT_COLOUR); this.data = new Array(this.channels.length).fill(null).map(() => []); this.channelMeans = new Array(this.channels.length).fill(0); this.init(); @@ -370,10 +377,15 @@ export default class EEGViewer { ); } + /** `null` returns to live mode; an already-live viewer keeps its data and only rescales. */ updateSnapshot(snapshot, amplitudeScale) { if (snapshot == null) { - this.snapshot = null; this.amplitudeScale = amplitudeScale; + if (this.snapshot == null) { + this.redraw(); + return; + } + this.snapshot = null; this.resetData(); this.init(); return; @@ -529,7 +541,7 @@ export default class EEGViewer { 'end', band.endLabel ?? '', band.x + band.width + 6, - this.height + 6, + this.height - LABEL_HEIGHT - 4, style ); } diff --git a/src/renderer/components/d3Classes/EEGViewer.test.ts b/src/renderer/components/d3Classes/EEGViewer.test.ts index 8a5ef55d..759deea7 100644 --- a/src/renderer/components/d3Classes/EEGViewer.test.ts +++ b/src/renderer/components/d3Classes/EEGViewer.test.ts @@ -25,7 +25,6 @@ function createGraph(overrides: Partial = {}) { channels: ['AF7', 'AF8'], plottingInterval: 250, domain: 5000, - channelColours: ['#66B0A9', '#66B0A9'], annotations: [], snapshot: null, ...overrides, @@ -226,6 +225,85 @@ describe('EEGViewer time and amplitude coordinates', () => { expect(Number(pill.getAttribute('width'))).toBeGreaterThan(2 * height); }); + it('draws the end pill inside the plot box, clear of the time axis', () => { + const { graph, svg } = createGraph(); + graph.updateAnnotations([ + { + id: 'alpha', + startTime: -1000, + endTime: 1000, + label: 'eyes closed', + endLabel: 'eyes open', + tone: 'eyes-closed', + }, + ]); + graph.updateData( + epoch(1000, [ + [0, 0], + [0, 1], + [0, -1], + [0, 0], + ]) + ); + const end = svg.querySelector('.annotation-end-label')!; + const y = Number( + /,\s*(-?[\d.]+)\)/.exec(end.getAttribute('transform')!)![1] + ); + const pillHeight = Number( + end.querySelector('rect')!.getAttribute('height') + ); + const plotHeight = 256 - 20 - 30; + expect(y).toBeGreaterThanOrEqual(0); + expect(y + pillHeight).toBeLessThanOrEqual(plotHeight); + }); + + it('keeps fixed channel colors when epochs report quality', () => { + const badEpoch = { + ...epoch(1000, [ + [0, 0], + [0, 1], + [0, -1], + [0, 0], + ]), + signalQuality: { AF7: '#ed5a5a', AF8: '#ed5a5a' }, + }; + const fixed = createGraph({ channelColours: ['#123456', '#abcdef'] }); + const quality = createGraph(); + fixed.graph.updateData(badEpoch); + quality.graph.updateData(badEpoch); + expect(fixed.svg.querySelector('#line-AF7')!.getAttribute('stroke')).toBe( + '#123456' + ); + expect(quality.svg.querySelector('#line-AF7')!.getAttribute('stroke')).toBe( + '#ed5a5a' + ); + }); + + it('applies a new amplitude scale to live data without clearing it', () => { + const { graph, svg } = createGraph(); + graph.updateData( + epoch(1000, [ + [0, 0], + [0, 10], + [0, -10], + [0, 0], + ]) + ); + const span = () => { + const ys = svg + .querySelector('#line-AF7')! + .getAttribute('d')! + .split(/[ML]/) + .filter(Boolean) + .map((point) => Number(point.split(',')[1])); + return Math.max(...ys) - Math.min(...ys); + }; + const wide = span(); + graph.updateSnapshot(null, 50); + expect(wide).toBeGreaterThan(0); + expect(span()).toBeCloseTo(wide * 4, 5); + }); + it('slides new data in without leaving the transform to d3-interpolate', async () => { const { graph, svg } = createGraph(); const flat: number[][] = [ diff --git a/src/renderer/constants/exploreLessons.ts b/src/renderer/constants/exploreLessons.ts index e476e3a3..0a857253 100644 --- a/src/renderer/constants/exploreLessons.ts +++ b/src/renderer/constants/exploreLessons.ts @@ -1,4 +1,4 @@ -export type LessonId = 'clean-signal' | 'noise-sources'; +export type LessonId = 'clean-signal' | 'noise-sources' | 'eyes-closed'; /** Shared with Collect's existing help; the noise lesson there remains unchanged. */ export const CLEAN_SIGNAL_LESSON = [ @@ -20,19 +20,6 @@ export const CLEAN_SIGNAL_LESSON = [ }, ] as const; -export const NOISE_LESSON = [ - { title: 'Your brain is making electricity right now' }, - { - title: 'Now blink — hard, a few times', - body: 'Every blink drops a big slow hump onto the two front sensors. That is your eyelid muscle moving, not your brain thinking, and it is the loudest thing in most student recordings.', - }, - { title: 'Same brain, same sensors, five seconds apart' }, - { - title: 'Close your eyes until the second chime', - body: 'One chime starts the ten seconds, two chimes end them. Nothing on screen needs watching in between.', - }, -] as const; - export const EXPLORE_LESSONS = [ { id: 'clean-signal', @@ -44,4 +31,9 @@ export const EXPLORE_LESSONS = [ title: 'Where is this noise coming from?', detail: '4 steps · about 2 minutes', }, + { + id: 'eyes-closed', + title: 'Eyes-closed activity', + detail: 'about 1 minute', + }, ] as const; diff --git a/src/renderer/utils/eeg/exploreSignal.ts b/src/renderer/utils/eeg/exploreSignal.ts index 17ff42e2..9d876f31 100644 --- a/src/renderer/utils/eeg/exploreSignal.ts +++ b/src/renderer/utils/eeg/exploreSignal.ts @@ -15,7 +15,6 @@ export interface BlinkEvent { } export interface ExploreStatus { - baselineStable: boolean; blinkEvents: BlinkEvent[]; latestTime: number | null; supported: boolean; @@ -24,13 +23,16 @@ export interface ExploreStatus { export interface FrozenComparison { calm: EEGSnapshot; blink: EEGSnapshot; - sharedScale: number; ratio: number; } const BUFFER_MS = 60000; const WINDOW_MS = 5000; -const FRONTAL = ['AF7', 'AF8']; +/** The forehead pair blink detection needs. */ +export const FRONTAL = ['AF7', 'AF8']; + +/** Channels over the back of the head: TP9/TP10 on Muse, O and PO sites elsewhere. */ +export const POSTERIOR = /^(?:TP9|TP10|(?:O|PO)(?:\d+|z))$/i; /** Causal second-order Butterworth section; unlike offline zero-phase filters, it delays peaks. */ class Butterworth { @@ -366,7 +368,6 @@ export class ExploreSession { status(): ExploreStatus { const latestTime = this.size ? this.timeAt(this.size - 1) : null; return { - baselineStable: this.stable, blinkEvents: this.events.map((event) => ({ ...event, channels: [...event.channels], @@ -547,15 +548,13 @@ export class ExploreSession { sharedScale <= 0 ) return null; - return { calm, blink, sharedScale, ratio }; + return { calm, blink, ratio }; } /** Posterior 8–12 Hz band power versus the preceding five seconds, not an alpha-rise prediction. */ alphaRatio(startTime: number, endTime: number): number | null { if (this.samplingRate <= 24 || endTime - startTime < 1000) return null; - const posterior = this.channels.filter((name) => - /^(?:TP9|TP10|(?:O|PO)(?:\d+|z))$/i.test(name) - ); + const posterior = this.channels.filter((name) => POSTERIOR.test(name)); if (!posterior.length) return null; const before = this.snapshot(startTime - WINDOW_MS, startTime, posterior); const during = this.snapshot(startTime, endTime, posterior); diff --git a/src/renderer/utils/eeg/traceColors.ts b/src/renderer/utils/eeg/traceColors.ts index da744963..201ef01e 100644 --- a/src/renderer/utils/eeg/traceColors.ts +++ b/src/renderer/utils/eeg/traceColors.ts @@ -11,3 +11,9 @@ export function channelColor( ): string { return interpolateRainbow(allChannels.indexOf(name) / allChannels.length); } + +/** `channelColor` for each shown channel, index-aligned with `names`. */ +export const channelColors = ( + names: readonly string[], + allChannels: readonly string[] +) => names.map((name) => channelColor(name, allChannels)); diff --git a/src/renderer/viewer.ts b/src/renderer/viewer.ts index 7ef3e924..e0917cfa 100644 --- a/src/renderer/viewer.ts +++ b/src/renderer/viewer.ts @@ -37,7 +37,9 @@ const unsubscribe = [ api.onNewData((epoch) => graph?.updateData(epoch)), api.onZoomIn(() => graph?.zoomIn()), api.onZoomOut(() => graph?.zoomOut()), - api.onUpdateChannels((channels) => graph?.updateChannels(channels)), + api.onUpdateChannels(({ channels, channelColours }) => + graph?.updateChannels(channels, channelColours) + ), api.onUpdateDomain((domain) => graph?.updateDomain(domain)), api.onUpdateAnnotations((annotations) => graph?.updateAnnotations(annotations) diff --git a/src/shared/viewerTypes.ts b/src/shared/viewerTypes.ts index d3222a40..0d3a205f 100644 --- a/src/shared/viewerTypes.ts +++ b/src/shared/viewerTypes.ts @@ -14,7 +14,8 @@ export interface ViewerGraphParameters { channels: string[]; plottingInterval: number; domain: number; - channelColours: string[]; + /** Fixed trace colors, index-aligned with `channels`; absent = each epoch's quality colors. */ + channelColours?: string[]; annotations: PlotAnnotation[]; snapshot: EEGSnapshot | null; amplitudeScale?: number; @@ -25,12 +26,18 @@ export interface ViewerSnapshotUpdate { amplitudeScale?: number; } +export interface ViewerChannelsUpdate { + channels: string[]; + /** Same contract as `ViewerGraphParameters.channelColours`. */ + channelColours?: string[]; +} + export interface ViewerMessages { initGraph: ViewerGraphParameters; newData: ViewerEpoch; zoomIn: undefined; zoomOut: undefined; - updateChannels: string[]; + updateChannels: ViewerChannelsUpdate; updateDomain: number; updateAnnotations: PlotAnnotation[]; updateSnapshot: ViewerSnapshotUpdate;