Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .llms/learnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
69 changes: 69 additions & 0 deletions src/renderer/components/EEGExplorationComponent.test.tsx
Original file line number Diff line number Diff line change
@@ -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<SignalQualityData>();
render(
<EEGExplorationComponent
connectedDevice={{
name: 'Muse-1A2B',
samplingRate: 256,
channels: MUSE_CHANNELS,
}}
signalQualityObservable={stream}
connectionStatus={CONNECTION_STATUS.CONNECTED}
DeviceActions={
{ DisconnectFromDevice: vi.fn() } as unknown as typeof DeviceActions
}
/>
);
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 () => {});
});
});
234 changes: 77 additions & 157 deletions src/renderer/components/EEGExplorationComponent.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
import React, { useContext, useEffect, useMemo, useState } from 'react';
import { Observable, shareReplay } from 'rxjs';
import { Button } from './ui/button';
import {
PLOTTING_INTERVAL,
CONNECTION_STATUS,
MUSE_CHANNELS,
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;
Expand All @@ -31,11 +30,15 @@ interface Props {
function ConnectedExplore({
observable,
device,
onDisconnect,
onReconnect,
onSwitchHeadset,
}: {
observable?: Observable<SignalQualityData>;
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;
Expand All @@ -56,22 +59,20 @@ function ConnectedExplore({
[channels, samplingRate]
);
const [sample, setSample] = useState<SignalQualityData | null>(null);
const [streamError, setStreamError] = useState('');
const [streamStopped, setStreamStopped] = useState(false);
const [activeLesson, setActiveLesson] = useState<LessonId | null>(null);
const [hoveredChannel, setHoveredChannel] = useState<string | null>(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]);
Expand All @@ -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 (
<div className="flex min-h-full flex-col text-ink" data-explore-connected>
{streamError && (
<div role="alert" className="mb-3 text-sm">
{streamError}
<div className="flex h-full min-h-0 flex-col">
{streamStopped && (
<div className="flex flex-none flex-col px-[24px] pt-[10px]">
<ErrorBanner
title="The signal stream stopped."
body="The headset is still connected, but its data stream ended. Disconnect and reconnect to start it again."
actionLabel="Reconnect headset"
onAction={onReconnect}
/>
</div>
)}
<div
className={
activeLesson ? 'hidden' : 'flex min-h-full flex-1 flex-col gap-5'
}
>
<div className="flex flex-none justify-end">
<Button variant="outline-brand" onClick={onDisconnect}>
Disconnect EEG Device
</Button>
</div>
<div
className="grid min-h-0 flex-1 grid-cols-[260px_minmax(0,1fr)] gap-7 max-[620px]:grid-cols-1"
data-explore-surface
>
<div className="flex min-w-0 flex-col gap-3.5">
<SignalQualityIndicatorComponent
signalQualityObservable={stream}
plottingInterval={PLOTTING_INTERVAL}
height={250}
channels={channels}
hoveredChannel={hoveredChannel}
onHoveredChannelChange={setHoveredChannel}
/>
<ExploreSensorCard
channels={channels}
sample={sample}
hoveredChannel={hoveredChannel}
onHoveredChannelChange={setHoveredChannel}
/>
</div>
<div className="flex min-w-0 flex-col gap-5">
<div
className="relative min-h-0 flex-1 rounded-lg border border-gray-200 bg-white px-[18px] py-4"
data-explore-trace
>
{!activeLesson && (
<ViewerComponent
signalQualityObservable={stream}
channels={channels}
plottingInterval={PLOTTING_INTERVAL}
height="100%"
<div className="min-h-0 flex-1">
{activeLesson ? (
<ExploreLessonFlow
lesson={activeLesson}
stream={stream}
channels={channels}
legend={legend}
session={session}
sample={sample}
onExit={() => setActiveLesson(null)}
/>
) : (
<ExploreSurface
quality={sample ? state : 'waiting'}
sensors={sensors}
channels={channels}
sample={sample}
head={stream}
livePlot={
<ViewerComponent
signalQualityObservable={stream}
channels={channels}
plottingInterval={PLOTTING_INTERVAL}
height="100%"
/>
}
legend={legend}
hoveredChannel={hoveredChannel}
onHoveredChannelChange={setHoveredChannel}
onStartLesson={startLesson}
banner={
!streamStopped &&
!session.status().supported &&
missing.length ? (
<ErrorBanner
title={`This headset is not reporting ${new Intl.ListFormat('en').format(missing)}.`}
body="Blink detection needs both AF7 and AF8, so the blink steps will show your signal without blink marks — or pick a headset with both forehead sensors. The eyes-closed activity works either way."
actionLabel="Switch headset"
onAction={onSwitchHeadset}
/>
)}
{!sample && (
<div
role="status"
className="absolute inset-x-[18px] top-4 text-sm text-ink-muted"
>
Waiting for the headset signal…
</div>
)}
</div>
<section
className="flex flex-none flex-col gap-2.5"
aria-labelledby="explore-lessons"
data-explore-lessons
>
<div
id="explore-lessons"
className="text-[13px] font-bold tracking-[0.5px] text-ink-muted"
>
LEARN WITH THIS SIGNAL
</div>
<div className="grid grid-cols-2 gap-4 max-[900px]:grid-cols-1">
{EXPLORE_LESSONS.map((lesson) => (
<div
key={lesson.id}
className="flex items-center gap-4 rounded-lg border border-gray-200 bg-white p-4"
>
<div className="min-w-0">
<div className="text-lg tracking-[0.3px]">
{lesson.title}
</div>
<div className="text-sm text-ink-muted">
{lesson.detail}
</div>
</div>
<Button
variant="outline-brand"
className="ml-auto flex-none"
aria-label={`Start ${lesson.title}`}
onClick={() => startLesson(lesson.id)}
>
Start
</Button>
</div>
))}
</div>
</section>
</div>
</div>
) : undefined
}
/>
)}
</div>
{activeLesson && (
<ExploreLessonFlow
lesson={activeLesson}
stream={stream}
channels={channels}
samplingRate={samplingRate}
session={session}
sample={sample}
onExit={() => setActiveLesson(null)}
/>
)}
</div>
);
}
Expand All @@ -202,7 +153,7 @@ export default function EEGExplorationComponent(props: Props) {
const connected = props.connectionStatus === CONNECTION_STATUS.CONNECTED;

return (
<div className="h-[90%] min-h-[560px]">
<div className="h-full">
{connected && signalPrep ? (
<LiveSignalPrep
device={signalPrep}
Expand All @@ -214,45 +165,14 @@ export default function EEGExplorationComponent(props: Props) {
<ConnectedExplore
observable={props.signalQualityObservable}
device={props.connectedDevice}
onDisconnect={() => props.DeviceActions.DisconnectFromDevice()}
onReconnect={() => {
props.DeviceActions.DisconnectFromDevice();
openHeadsetSetup();
}}
onSwitchHeadset={openHeadsetSetup}
/>
) : (
<div className="flex h-full items-center justify-center text-ink">
<div className="flex max-w-[880px] items-center justify-center gap-14 max-[740px]:flex-col max-[740px]:gap-7">
<img
src={eegImage}
alt="EEG headset"
className="h-auto w-[300px] flex-none"
/>
<div className="flex max-w-[440px] flex-col items-start gap-[18px]">
<div className="text-[13px] font-bold tracking-[0.5px] text-ink-muted">
RAW SIGNAL
</div>
<h1>Explore Raw EEG</h1>
<p className="leading-7">
Connect a headset and watch your brain&apos;s electricity show
up live — no experiment to set up, no data to save.
</p>
<div className="flex flex-wrap items-center gap-4">
<Button size="lg" onClick={openHeadsetSetup}>
Connect a headset
</Button>
<span className="text-sm text-ink-muted">
Takes about 30 seconds
</span>
</div>
<div className="mt-2 flex w-full flex-col gap-1.5 border-t border-gray-200 pt-[18px]">
<div className="text-[13px] font-bold tracking-[0.5px] text-ink-muted">
ONCE YOU&apos;RE CONNECTED
</div>
<div className="text-base tracking-[0.3px]">
Two short lessons are waiting: improving signal quality, and
where noise comes from.
</div>
</div>
</div>
</div>
</div>
<ExploreDisconnected onConnect={openHeadsetSetup} />
)}
</div>
);
Expand Down
Loading
Loading