From 68bdf9fe68c8bfa97a42bc0dae4a6fc1be6feab0 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 27 Sep 2026 21:15:06 -0400 Subject: [PATCH 01/10] feat(viewer): fixed channel colors, live amplitude scale, end pill inside plot ViewerComponent gains channelColors, forwarded via initGraph/updateChannels; EEGViewer keeps fixed colors instead of recoloring by quality, rescales a live view without clearing it, and draws the end pill inside the plot box. --- src/renderer/components/ViewerComponent.tsx | 14 ++-- .../components/d3Classes/EEGViewer.d.ts | 2 +- .../components/d3Classes/EEGViewer.js | 35 ++++++--- .../components/d3Classes/EEGViewer.test.ts | 78 ++++++++++++++++++- src/renderer/viewer.ts | 4 +- src/shared/viewerTypes.ts | 11 ++- 6 files changed, 123 insertions(+), 21 deletions(-) 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..d8b10ed4 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,10 @@ export default class EEGViewer { this.channels = parameters.channels; this.plottingInterval = parameters.plottingInterval; this.domain = parameters.domain; - this.channelColours = parameters.channelColours; + // Fixed colors (e.g. a lesson's stable palette) are never recolored by quality. + 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; @@ -304,12 +310,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 +358,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 +380,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 +544,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..0bc2807b 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,83 @@ 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/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; From 6c8fc5db1b63fa19df57bc3c6533cc380446f1d6 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 27 Sep 2026 21:25:33 -0400 Subject: [PATCH 02/10] feat(explore): design views take live-plot slots and runtime quality copy FixturePlot becomes SnapshotPlot (it now draws real frozen snapshots too). ExploreSurface, BlinkLessonView and EyesClosedView take a livePlot slot, a legend and the device channel list; review segments and step 4's range are props. Quality copy and summarizeQuality move to Explore/quality.ts, adjust names the BAD sensors. LessonPicker starts a lesson by id. Adds CleanSignalView and its story at 1366x768 and 1280x720. --- .../components/Explore/Explore.stories.tsx | 149 ++++++--- .../components/Explore/ExploreParts.tsx | 39 ++- .../components/Explore/ExploreScreens.tsx | 297 +++++++++++------- src/renderer/components/Explore/fixtures.ts | 89 ++---- .../components/Explore/quality.test.ts | 47 +++ src/renderer/components/Explore/quality.ts | 77 +++++ src/renderer/utils/eeg/traceColors.ts | 6 + 7 files changed, 472 insertions(+), 232 deletions(-) create mode 100644 src/renderer/components/Explore/quality.test.ts create mode 100644 src/renderer/components/Explore/quality.ts diff --git a/src/renderer/components/Explore/Explore.stories.tsx b/src/renderer/components/Explore/Explore.stories.tsx index 784e7560..5b0719fd 100644 --- a/src/renderer/components/Explore/Explore.stories.tsx +++ b/src/renderer/components/Explore/Explore.stories.tsx @@ -2,6 +2,8 @@ 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 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,30 +16,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); +/** 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 }; + /** * 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 @@ -58,7 +82,21 @@ 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 +105,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 +120,15 @@ function Surface({ + } hoveredChannel={hoveredChannel} onHoveredChannelChange={setHoveredChannel} onStartLesson={fn()} @@ -98,7 +145,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 +173,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: () => ( + + } + sample={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..95c608ec 100644 --- a/src/renderer/components/Explore/ExploreParts.tsx +++ b/src/renderer/components/Explore/ExploreParts.tsx @@ -9,12 +9,10 @@ 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 +43,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 +65,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 +82,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; @@ -137,7 +136,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`} > @@ -346,12 +345,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 +420,7 @@ export function LessonStepPanel({ onExit, backLabel = 'Back', nextLabel = 'Next', + unit = 'Step', backDisabled, nextDisabled, }: { @@ -426,6 +429,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 +456,7 @@ export function LessonStepPanel({
{label} - {step > 0 && ` · Step ${step} of ${steps}`} + {step > 0 && ` · ${unit} ${step} of ${steps}`} @@ -128,14 +127,19 @@ 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[]; + /** Latest epoch for the head diagram and sensor card; null while waiting. */ + sample: SignalQualityData | null; + /** + * 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,18 +152,15 @@ export interface ExploreSurfaceProps { export function ExploreSurface({ quality, sensors, - snapshot, - colors, + sample, + 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 (
@@ -181,7 +182,7 @@ export function ExploreSurface({
) : ( - + )}
@@ -202,23 +203,76 @@ export function ExploreSurface({
- {snapshot ? ( - - ) : ( + {waiting ? (
Waiting for the headset signal…
+ ) : ( + livePlot )}
- + +
+
+
+ ); +} + +export interface CleanSignalViewProps { + tip: 1 | 2 | 3; + /** The live plot in quality colors: this lesson is about signal quality. */ + livePlot: ReactNode; + legend?: string; + /** Latest epoch for the head diagram; null before the first sample. */ + sample: SignalQualityData | null; + 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, + sample, + channels, + onBack, + onNext, + onExit, +}: CleanSignalViewProps) { + const head = useMemo(() => (sample ? of(sample) : null), [sample]); + return ( +
+ +
+ {livePlot} +
+
@@ -228,19 +282,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 +312,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 (
{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 +420,17 @@ export function BlinkLessonView({ ) : ( 0 && step < 3 ? ( - {caught} {caught === 1 ? 'blink' : 'blinks'} marked + {blinkCount} {blinkCount === 1 ? 'blink' : 'blinks'} marked ) : undefined } > - + {livePlot} )}
@@ -399,7 +452,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 +476,11 @@ export function EyesClosedView({ countdown = 3, rhythmRatio, showExample, - snapshot, + livePlot, + segments, + legend = PLOT_LEGEND, + deviceChannels = EXPLORE_CHANNELS, + error, onBack, onNext, onExit, @@ -478,6 +543,11 @@ export function EyesClosedView({ onNext={onNext} onExit={onExit} > + {error && ( +
+ {error} +
+ )} {phase === 'intro' && showExample && } {phase === 'review' && ( <> @@ -498,24 +568,25 @@ export function EyesClosedView({
{phase === 'review' ? ( - + {segments ? ( + + ) : ( +
+ Not enough continuous data to show this interval. +
+ )}
) : ( - + {livePlot} )} {(running || phase === 'end') && ( @@ -539,4 +610,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..de314d9f 100644 --- a/src/renderer/components/Explore/fixtures.ts +++ b/src/renderer/components/Explore/fixtures.ts @@ -4,6 +4,7 @@ import { 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']; @@ -191,11 +192,6 @@ export const CLOSED_SEGMENT = makeSnapshot(POSTERIOR_CHANNELS, REVIEW_SEGMENT_MS 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 +207,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. */ diff --git a/src/renderer/components/Explore/quality.test.ts b/src/renderer/components/Explore/quality.test.ts new file mode 100644 index 00000000..6019b8ae --- /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..959b7eac --- /dev/null +++ b/src/renderer/components/Explore/quality.ts @@ -0,0 +1,77 @@ +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/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)); From 9c041ac1240b73c7e4274d7f33aa400dbfc9676a Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 27 Sep 2026 21:26:29 -0400 Subject: [PATCH 03/10] feat(explore): three lesson cards, eyes-closed activity on its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EXPLORE_LESSONS gains 'Eyes-closed activity · about 1 minute'; the noise lesson is the four blink steps. The picker lays the three cards out in one row. --- src/renderer/components/Explore/ExploreScreens.tsx | 2 +- src/renderer/constants/exploreLessons.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/renderer/components/Explore/ExploreScreens.tsx b/src/renderer/components/Explore/ExploreScreens.tsx index a0aef7bc..867fd11e 100644 --- a/src/renderer/components/Explore/ExploreScreens.tsx +++ b/src/renderer/components/Explore/ExploreScreens.tsx @@ -95,7 +95,7 @@ export function LessonPicker({ return (

Learn with this signal

-
+
{EXPLORE_LESSONS.map((lesson) => (
Date: Sun, 27 Sep 2026 21:31:31 -0400 Subject: [PATCH 04/10] feat(explore): lesson runner renders the design views ExploreLessonFlow now runs the cleaner-signal tips (tip n of 3), the blink steps 0-4 and the separate eyes-closed activity in CleanSignalView, BlinkLessonView and EyesClosedView, with one live ViewerComponent per lesson in stable channel colors (quality colors for the tips). Step 4's range drives the live scale until five still seconds freeze the comparison. Eyes-closed: Begin unlocks audio and checks stream freshness, a 3-2-1 component timer, the chime-bounded interval, then 3 s eyes-open/eyes-closed review segments and the posterior rhythm ratio. NOISE_LESSON and the local FrozenStrip go. --- .../components/EEGExplorationComponent.tsx | 2 +- src/renderer/components/ExploreLessonFlow.tsx | 728 +++++++----------- src/renderer/constants/exploreLessons.ts | 13 - src/renderer/utils/eeg/exploreSignal.ts | 10 +- 4 files changed, 283 insertions(+), 470 deletions(-) diff --git a/src/renderer/components/EEGExplorationComponent.tsx b/src/renderer/components/EEGExplorationComponent.tsx index ee0d39a4..eba41b32 100644 --- a/src/renderer/components/EEGExplorationComponent.tsx +++ b/src/renderer/components/EEGExplorationComponent.tsx @@ -186,7 +186,7 @@ function ConnectedExplore({ lesson={activeLesson} stream={stream} channels={channels} - samplingRate={samplingRate} + legend={`${device?.name ?? ''} · ${samplingRate} Hz`} session={session} sample={sample} onExit={() => setActiveLesson(null)} diff --git a/src/renderer/components/ExploreLessonFlow.tsx b/src/renderer/components/ExploreLessonFlow.tsx index 08dd2177..ff532665 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/constants/exploreLessons.ts b/src/renderer/constants/exploreLessons.ts index 8ebac2f9..0a857253 100644 --- a/src/renderer/constants/exploreLessons.ts +++ b/src/renderer/constants/exploreLessons.ts @@ -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', diff --git a/src/renderer/utils/eeg/exploreSignal.ts b/src/renderer/utils/eeg/exploreSignal.ts index 17ff42e2..f9634bee 100644 --- a/src/renderer/utils/eeg/exploreSignal.ts +++ b/src/renderer/utils/eeg/exploreSignal.ts @@ -30,7 +30,11 @@ export interface FrozenComparison { 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 { @@ -553,9 +557,7 @@ export class ExploreSession { /** 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); From 8f60007fbce773dfd5f9dd90e93eaf7fa0449743 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 27 Sep 2026 21:33:56 -0400 Subject: [PATCH 05/10] feat(explore): Explore screen renders the design landing and surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disconnected → ExploreDisconnected; connected → ExploreSurface with the live ViewerComponent (quality colors), the legend from deviceInfo, the quality summary from summarizeQuality, and ErrorBanners for a stopped stream (error or complete → Reconnect headset) and missing frontal channels (Switch headset). Explore's own Disconnect button goes; the shell chip's setup dialog owns it next. LiveSignalPrep is unchanged. --- .../components/EEGExplorationComponent.tsx | 230 ++++++------------ 1 file changed, 70 insertions(+), 160 deletions(-) diff --git a/src/renderer/components/EEGExplorationComponent.tsx b/src/renderer/components/EEGExplorationComponent.tsx index eba41b32..34faeffb 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,117 +83,57 @@ function ConnectedExplore({ setActiveLesson(lesson); } + const legend = `${device?.name ?? ''} · ${samplingRate} Hz`; + if (activeLesson) + return ( + setActiveLesson(null)} + /> + ); + + const { state, sensors } = summarizeQuality(sample?.signalQuality, channels); + const missing = FRONTAL.filter((channel) => !channels.includes(channel)); return ( -
- {streamError && ( -
- {streamError} -
- )} -
-
- -
-
-
- - -
-
-
- {!activeLesson && ( - - )} - {!sample && ( -
- Waiting for the headset signal… -
- )} -
-
-
- LEARN WITH THIS SIGNAL -
-
- {EXPLORE_LESSONS.map((lesson) => ( -
-
-
- {lesson.title} -
-
- {lesson.detail} -
-
- -
- ))} -
-
-
-
-
- {activeLesson && ( - setActiveLesson(null)} + plottingInterval={PLOTTING_INTERVAL} + height="100%" /> - )} -
+ } + legend={legend} + hoveredChannel={hoveredChannel} + onHoveredChannelChange={setHoveredChannel} + onStartLesson={startLesson} + banner={ + streamStopped ? ( + + ) : !session.status().supported && missing.length ? ( + + ) : undefined + } + /> ); } @@ -202,7 +143,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. -
-
-
-
-
+ )}
); From 1b0a19482c571156ad4e784dddce3d28ed106728 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 27 Sep 2026 21:35:31 -0400 Subject: [PATCH 06/10] feat(headset): Disconnect on the setup dialog's Connected screen The shell device chip opens HeadsetSetupDialog; its Connected screen now has a secondary Disconnect beside Check my signal, which dispatches DisconnectFromDevice and closes the dialog. The Connected story shows it. --- .../components/HeadsetSetup/HeadsetSetup.stories.tsx | 1 + src/renderer/components/HeadsetSetup/HeadsetSetup.tsx | 5 +++++ src/renderer/components/HeadsetSetup/HeadsetSetupDialog.tsx | 4 ++++ 3 files changed, 10 insertions(+) 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} /> From 51d5cff50b5ae6340017273eb3ecd65751301f17 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Sun, 27 Sep 2026 21:41:35 -0400 Subject: [PATCH 07/10] feat(explore): captures say they are the student's own signal, paused The blink comparison strips and the eyes-closed review caption now read "your recent signal, paused" / "your eyes-closed interval, paused" and the review card drops the live dot (PlotCard paused). No UI copy says frozen. --- src/renderer/components/Explore/ExploreParts.tsx | 14 ++++++++++++-- src/renderer/components/Explore/ExploreScreens.tsx | 10 +++++----- src/renderer/components/Explore/fixtures.ts | 2 +- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/renderer/components/Explore/ExploreParts.tsx b/src/renderer/components/Explore/ExploreParts.tsx index 95c608ec..080b9818 100644 --- a/src/renderer/components/Explore/ExploreParts.tsx +++ b/src/renderer/components/Explore/ExploreParts.tsx @@ -308,17 +308,22 @@ export function SnapshotPlot({ ); } -/** 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} diff --git a/src/renderer/components/Explore/ExploreScreens.tsx b/src/renderer/components/Explore/ExploreScreens.tsx index 867fd11e..497adb60 100644 --- a/src/renderer/components/Explore/ExploreScreens.tsx +++ b/src/renderer/components/Explore/ExploreScreens.tsx @@ -401,15 +401,15 @@ export function BlinkLessonView({ {comparison ? ( <>
{phase === 'review' ? ( - + {segments ? ( Date: Sun, 27 Sep 2026 21:55:45 -0400 Subject: [PATCH 08/10] fix(explore): head diagram follows the live stream with stable channels Found in the Electron playtest: feeding the head diagram of(sample) with a per-render channels array reset every electrode to grey on each epoch. ExploreSurface and CleanSignalView now take the shared stream (head) and a stable channels list, as the old screen wired them. --- .../components/EEGExplorationComponent.tsx | 2 ++ .../components/Explore/Explore.stories.tsx | 5 +++- .../components/Explore/ExploreScreens.tsx | 24 +++++++++++-------- src/renderer/components/ExploreLessonFlow.tsx | 2 +- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/renderer/components/EEGExplorationComponent.tsx b/src/renderer/components/EEGExplorationComponent.tsx index 34faeffb..f0c373d6 100644 --- a/src/renderer/components/EEGExplorationComponent.tsx +++ b/src/renderer/components/EEGExplorationComponent.tsx @@ -103,7 +103,9 @@ function ConnectedExplore({ sensor.channel)} sample={state === 'waiting' ? null : qualitySample(sensors)} + head={state === 'waiting' ? null : of(qualitySample(sensors))} livePlot={ } - sample={qualitySample(QUALITY_SENSORS.settling)} + head={of(qualitySample(QUALITY_SENSORS.settling))} channels={EXPLORE_CHANNELS} onBack={fn()} onNext={fn()} diff --git a/src/renderer/components/Explore/ExploreScreens.tsx b/src/renderer/components/Explore/ExploreScreens.tsx index 497adb60..f90a2d1a 100644 --- a/src/renderer/components/Explore/ExploreScreens.tsx +++ b/src/renderer/components/Explore/ExploreScreens.tsx @@ -1,5 +1,5 @@ -import React, { ReactNode, useMemo, useState } from 'react'; -import { of } from 'rxjs'; +import React, { ReactNode, useState } from 'react'; +import type { Observable } from 'rxjs'; import type { EEGSnapshot } from '../../../shared/eegVizTypes'; import { PLOTTING_INTERVAL } from '../../constants/constants'; import type { SignalQualityData } from '../../constants/interfaces'; @@ -127,8 +127,12 @@ export interface ExploreSurfaceProps { /** `waiting` = connected but no data yet; the four quality states after. */ quality: QualityState | 'waiting'; sensors: SensorStatus[]; - /** Latest epoch for the head diagram and sensor card; null while waiting. */ + /** 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. @@ -152,7 +156,9 @@ export interface ExploreSurfaceProps { export function ExploreSurface({ quality, sensors, + channels, sample, + head, livePlot, legend = PLOT_LEGEND, hoveredChannel, @@ -161,7 +167,6 @@ export function ExploreSurface({ banner, }: ExploreSurfaceProps) { const waiting = quality === 'waiting'; - const head = useMemo(() => (sample ? of(sample) : null), [sample]); return (
{banner} @@ -190,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} @@ -226,8 +231,8 @@ export interface CleanSignalViewProps { /** The live plot in quality colors: this lesson is about signal quality. */ livePlot: ReactNode; legend?: string; - /** Latest epoch for the head diagram; null before the first sample. */ - sample: SignalQualityData | null; + /** The head diagram's stream: the live stream in the app. */ + head: Observable | null | undefined; channels: string[]; onBack(): void; onNext(): void; @@ -242,13 +247,12 @@ export function CleanSignalView({ tip, livePlot, legend = PLOT_LEGEND, - sample, + head, channels, onBack, onNext, onExit, }: CleanSignalViewProps) { - const head = useMemo(() => (sample ? of(sample) : null), [sample]); return (
Date: Sun, 27 Sep 2026 22:01:18 -0400 Subject: [PATCH 09/10] style: prettier on changed files --- .../components/Explore/ExploreParts.tsx | 243 ++++++++++-------- .../components/Explore/ExploreScreens.tsx | 35 ++- src/renderer/components/Explore/fixtures.ts | 31 ++- .../components/Explore/quality.test.ts | 18 +- src/renderer/components/Explore/quality.ts | 4 +- .../components/d3Classes/EEGViewer.test.ts | 16 +- 6 files changed, 189 insertions(+), 158 deletions(-) diff --git a/src/renderer/components/Explore/ExploreParts.tsx b/src/renderer/components/Explore/ExploreParts.tsx index 080b9818..1e47c277 100644 --- a/src/renderer/components/Explore/ExploreParts.tsx +++ b/src/renderer/components/Explore/ExploreParts.tsx @@ -12,7 +12,12 @@ import { REVIEW_SEGMENT_SCALE, } from './fixtures'; import { channelColor } from '../../utils/eeg/traceColors'; -import { QUALITY_STATE_TONE, QualityState, SensorStatus, qualityCopy } from './quality'; +import { + QUALITY_STATE_TONE, + QualityState, + SensorStatus, + qualityCopy, +} from './quality'; /** Small uppercase label for lesson step counters and section titles. */ export const stepLabel = @@ -115,13 +120,13 @@ export function SnapshotPlot({ 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)), @@ -170,138 +175,155 @@ export function SnapshotPlot({ ))} {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} + + + ))} )} - + @@ -522,7 +544,12 @@ export function LessonStepPanel({ > {backLabel} -
@@ -808,4 +835,4 @@ export function ErrorBanner({
); -} \ No newline at end of file +} diff --git a/src/renderer/components/Explore/ExploreScreens.tsx b/src/renderer/components/Explore/ExploreScreens.tsx index f90a2d1a..71eee656 100644 --- a/src/renderer/components/Explore/ExploreScreens.tsx +++ b/src/renderer/components/Explore/ExploreScreens.tsx @@ -344,16 +344,10 @@ export function BlinkLessonView({ : BLINK_STEPS[step - 1].title } action={step > 0 ? BLINK_STEPS[step - 1].action : undefined} - body={ - step === 0 ? : BLINK_STEPS[step - 1].body - } + body={step === 0 ? : 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} @@ -520,17 +514,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={ { @@ -571,7 +563,10 @@ export function EyesClosedView({
{phase === 'review' ? ( - + {segments ? ( { 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' - ); + 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', () => { diff --git a/src/renderer/components/Explore/quality.ts b/src/renderer/components/Explore/quality.ts index 959b7eac..36d8177e 100644 --- a/src/renderer/components/Explore/quality.ts +++ b/src/renderer/components/Explore/quality.ts @@ -41,7 +41,9 @@ export const QUALITY_STATE_TONE: Record = { 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) + sensors + .filter((s) => s.quality === SIGNAL_QUALITY.BAD) + .map((s) => s.channel) ); return { heading: `Adjust ${names}`, diff --git a/src/renderer/components/d3Classes/EEGViewer.test.ts b/src/renderer/components/d3Classes/EEGViewer.test.ts index 0bc2807b..759deea7 100644 --- a/src/renderer/components/d3Classes/EEGViewer.test.ts +++ b/src/renderer/components/d3Classes/EEGViewer.test.ts @@ -246,7 +246,9 @@ describe('EEGViewer time and amplitude coordinates', () => { ]) ); const end = svg.querySelector('.annotation-end-label')!; - const y = Number(/,\s*(-?[\d.]+)\)/.exec(end.getAttribute('transform')!)![1]); + const y = Number( + /,\s*(-?[\d.]+)\)/.exec(end.getAttribute('transform')!)![1] + ); const pillHeight = Number( end.querySelector('rect')!.getAttribute('height') ); @@ -269,12 +271,12 @@ describe('EEGViewer time and amplitude coordinates', () => { 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'); + 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', () => { From 7d93cf1388d974347191929e1b7d341deb384a5c Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Mon, 28 Sep 2026 09:33:17 -0400 Subject: [PATCH 10/10] =?UTF-8?q?fix(explore):=20review=20round=20?= =?UTF-8?q?=E2=80=94=20stream=20banner=20over=20lessons,=20step-4=20copy,?= =?UTF-8?q?=20cuts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stream-stopped banner now sits above both the surface and a running lesson (one wrapper), with a test that a stream ending mid-lesson shows it. Step 4 shows only its prompt until the paused strips appear. Drops the unused ExploreStatus.baselineStable and FrozenComparison.sharedScale fields, un-exports CleanSignalViewProps, and removes stale comments and the obsolete end-pill learning. --- .llms/learnings.md | 2 - .../EEGExplorationComponent.test.tsx | 69 ++++++++++++++ .../components/EEGExplorationComponent.tsx | 92 ++++++++++--------- .../components/Explore/Explore.stories.tsx | 6 -- .../components/Explore/ExploreParts.tsx | 4 - .../components/Explore/ExploreScreens.tsx | 18 +++- .../components/d3Classes/EEGViewer.js | 3 - src/renderer/utils/eeg/exploreSignal.ts | 5 +- 8 files changed, 135 insertions(+), 64 deletions(-) create mode 100644 src/renderer/components/EEGExplorationComponent.test.tsx 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 f0c373d6..b0cc2b64 100644 --- a/src/renderer/components/EEGExplorationComponent.tsx +++ b/src/renderer/components/EEGExplorationComponent.tsx @@ -84,58 +84,66 @@ function ConnectedExplore({ } const legend = `${device?.name ?? ''} · ${samplingRate} Hz`; - if (activeLesson) - return ( - setActiveLesson(null)} - /> - ); - const { state, sensors } = summarizeQuality(sample?.signalQuality, channels); const missing = FRONTAL.filter((channel) => !channels.includes(channel)); return ( - - } - legend={legend} - hoveredChannel={hoveredChannel} - onHoveredChannelChange={setHoveredChannel} - onStartLesson={startLesson} - banner={ - streamStopped ? ( +
+ {streamStopped && ( +
- ) : !session.status().supported && missing.length ? ( - + )} +
+ {activeLesson ? ( + setActiveLesson(null)} + /> + ) : ( + + } + legend={legend} + hoveredChannel={hoveredChannel} + onHoveredChannelChange={setHoveredChannel} + onStartLesson={startLesson} + banner={ + !streamStopped && + !session.status().supported && + missing.length ? ( + + ) : undefined + } /> - ) : undefined - } - /> + )} +
+
); } diff --git a/src/renderer/components/Explore/Explore.stories.tsx b/src/renderer/components/Explore/Explore.stories.tsx index c48883d1..7ffaaf36 100644 --- a/src/renderer/components/Explore/Explore.stories.tsx +++ b/src/renderer/components/Explore/Explore.stories.tsx @@ -63,12 +63,6 @@ const lessonPlot = ( const REVIEW_SEGMENTS = { open: OPEN_SEGMENT, closed: CLOSED_SEGMENT }; -/** - * 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. - */ const withExploreChrome: Decorator = (Story, { parameters }) => ( 0 ? BLINK_STEPS[step - 1].action : undefined} - body={step === 0 ? : BLINK_STEPS[step - 1].body} + action={ + step > 0 && !(step === 4 && !comparison) + ? BLINK_STEPS[step - 1].action + : undefined + } + 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' diff --git a/src/renderer/components/d3Classes/EEGViewer.js b/src/renderer/components/d3Classes/EEGViewer.js index d8b10ed4..24bf4e06 100644 --- a/src/renderer/components/d3Classes/EEGViewer.js +++ b/src/renderer/components/d3Classes/EEGViewer.js @@ -42,7 +42,6 @@ export default class EEGViewer { this.channels = parameters.channels; this.plottingInterval = parameters.plottingInterval; this.domain = parameters.domain; - // Fixed colors (e.g. a lesson's stable palette) are never recolored by quality. this.fixedColours = parameters.channelColours != null; this.channelColours = parameters.channelColours ?? this.channels.map(() => DEFAULT_COLOUR); @@ -141,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); diff --git a/src/renderer/utils/eeg/exploreSignal.ts b/src/renderer/utils/eeg/exploreSignal.ts index f9634bee..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,7 +23,6 @@ export interface ExploreStatus { export interface FrozenComparison { calm: EEGSnapshot; blink: EEGSnapshot; - sharedScale: number; ratio: number; } @@ -370,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], @@ -551,7 +548,7 @@ 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. */