diff --git a/docs/uxr/2026-09-25-ws3-design-brief.md b/docs/uxr/2026-09-25-ws3-design-brief.md new file mode 100644 index 00000000..0c57583b --- /dev/null +++ b/docs/uxr/2026-09-25-ws3-design-brief.md @@ -0,0 +1,70 @@ +# WS3 design brief — Explore EEG + +**Gate:** plan §10.1 step 2. Storybook stories with fixture data only, no runtime wiring. Engineering integrates after product approves. + +**Sources:** +- `docs/uxr/playtest_naive_1_design_implementation_plan.md` §5, §10.2 ("Explore EEG"), §11 WS3. +- `docs/uxr/playtest_naive_1.md` findings 3 and 4. +- `docs/uxr/# Playtest Takeaways.md`, the "Live EEG Viewing" section. +- Design system: `docs/design/DESIGN.md`, `.design-sync/conventions.md`. +- Shape to match, all merged: #272 HeadsetSetup/SignalPrep, #273 ParticipantScreens, #277 Analyze (left controls rail + results, and the ERP walkthrough's Back/Next step panel). Build on #277's rail and walkthrough patterns, so the app has one lesson idiom. + +## The job + +A student puts on a headset, sees their own brain signal live, and learns three things without a facilitator: +- whether the signal is usable; +- what "noise" means (not sound); +- that they can cause visible changes: a blink artifact, and alpha with eyes closed. + +In the playtest this was the most engaging moment: the blink made the plot jump. It was also the most facilitator-dependent. Instructions sat too low, "noise" was misread, the eyes-closed activity had no clear start, countdown or chime meaning, and the alpha effect was subtle and unexplained. + +## Current code (read before designing) + +- `src/renderer/components/EEGExplorationComponent.tsx`: + - The disconnected landing: image, "Explore Raw EEG", "Connect a headset". + - `ConnectedExplore`: plot, sensor card and lesson picker. + - The disconnected landing is the "blank screen" product flagged; redesign it. +- `src/renderer/components/ExploreLessonFlow.tsx`: the lesson runner (steps, Back/Next/Exit, blink detector, eyes-closed interval, alpha comparison). +- `src/renderer/constants/exploreLessons.ts`: the two lessons and their step copy: `clean-signal` "How do I get a cleaner signal?" and `noise-sources` "Where is this noise coming from?". +- `src/renderer/components/ExploreSensorCard.tsx`, `SignalQualityIndicatorComponent.tsx`, `constants/electrodes.ts` (`QUALITY_LABELS`, `ELECTRODES`). +- `src/renderer/utils/eeg/exploreSignal.ts` (blink detection, alpha ratio) and `lessonAudio.ts` (chimes). +- `src/renderer/components/HeadsetSetup/SignalPrep.tsx` (#274): signal prep after pairing. It now shows on Explore before the connected view. Don't duplicate its checklist; the lessons follow it. +- **The live plot is a `` (`ViewerComponent`, d3 `EEGViewer`) and cannot run in Storybook.** Draw a fixture stand-in with React SVG: a few seconds of multi-channel trace from a synthetic series, with blink spikes and an eyes-closed alpha burst where a step needs them. Keep its geometry close to the real viewer's (channels stacked, time running left to right, labels on the left) so integration swaps in the webview. + +## Stories required (plan §5, §10.2) + +Pure-props components under `src/renderer/components/Explore/`, with a fixtures file and stories. Render inside the real AppShell chrome with no workspace (Explore is workspace-free), as #273/#277's decorators do. + +| Story | Must show | +|---|---| +| Disconnected | Redesigned landing. What Explore is, one primary `Connect a headset`, what you'll do once connected. Pairs visually with the Home Explore card. No "Live view only" chip (product removed it). | +| Waiting | Connected, no data yet: an explicit waiting state. | +| QualitySummary: Ready / Settling / AdjustSensors / NoSignal | Overall status above the plot (§5.1): `Ready to explore`, `Sensors are still settling`, `Adjust AF7 and TP10`, `No signal detected`. It names an action where one exists. Color supports; words explain. Per-sensor detail stays available without reading color. | +| NoiseDefinition | The plain-language definition of noise (§5.1 wording), shown before the student judges anything. Contact can improve over several minutes; never promise a fixed warm-up. | +| LessonPicker | The two lessons as clear, local choices, with no competing global nav. | +| Blink steps 1–4 | The sequence in §5.3: (1) blink once and find the marked response; (2) predict what another blink will do; (3) blink several times so it's unmistakable; (4) compare a blinking interval with a quiet one. The instruction sits above or beside the plot (§5.2), with Back/Next/Exit local to the lesson. Also a **BlinkNotDetected** state: the lesson continues gracefully if detection fails or the frontal sensors haven't settled. | +| NoiseLessonStableColors | During the noise-source demonstration, traces use stable colors (not signal-quality colors) so they don't compete (§5.2). | +| EyesClosed steps | The single guided sequence in §5.4: explain the start and end sounds → `Begin eyes-closed activity` → visible 3–2–1 countdown → `Close your eyes` → the interval (works without watching the screen) → unmistakable `Open your eyes` → review the marked interval plus the measured 8–12 Hz comparison. | +| AlphaResult / AlphaNoEffect | The student's real alpha comparison as the result. An optional ideal reference, labelled `Example`, visually separated, never implying everyone shows it. For Muse, describe TP9/TP10 as the available posterior-side proxy. NoEffect is a valid, encouraging outcome, not a failure. | +| StreamError | Error and unsupported-channel states that say what to do. | + +## Constraints + +- Explore never creates a workspace or records anything (§2.2, §13). +- No fake participant data presented as real (§13). Stand-in plots are fixtures; any reference curve is labelled `Example` in the UI. +- One filled-teal primary action per surface. Teal = action, gold = location. Signal colors only for signal status. Never color-only. +- Student-facing, friendly, direct copy. Light headings. +- No animation beyond what a step needs, and none during stimulus-like moments. The countdown may animate and must respect `prefers-reduced-motion`. +- Rule A (from #277): at 1366×768 and 1280×720, the plot, the current instruction and its lesson controls are all visible with no page scroll. Measure and report it. +- Traps in `.llms/learnings.md`: 18px root font-size; global `p { 18px !important }`; `li { list-style: none }`; lab.css `main/header`. +- New files + stories only. Scoped CSS classes are fine, but never modify existing shared global CSS classes. No new dependencies (package.json untouched). Don't edit EEGExplorationComponent, ExploreLessonFlow, ViewerComponent, EEGViewer.js, the constants or utils; read them for real copy, thresholds and step structure. + +## Out of scope + +Wiring, the webview itself, changes to detection math, the Collect-side signal checks. + +## Done when + +- Every story renders in Storybook inside the real shell chrome, is screenshotted at both sizes, and has 0 console errors. +- `npx tsc --noEmit` is clean. +- A PR with the review agenda, open copy questions, how the fixture plot maps to the real viewer, and any unmet constraint. diff --git a/src/renderer/app.global.css b/src/renderer/app.global.css index dd5829ff..5d888198 100644 --- a/src/renderer/app.global.css +++ b/src/renderer/app.global.css @@ -505,3 +505,25 @@ button:active { fill: var(--color-brand); cursor: pointer; } + +/* Explore (WS3) design pass: eyes-closed countdown pulse. Scoped to the + countdown digit; the only animation this surface needs. */ +.explore-countdown-active { + animation: explore-countdown-pulse 1s ease-in-out infinite; +} + +@keyframes explore-countdown-pulse { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(1.14); + } +} + +@media (prefers-reduced-motion: reduce) { + .explore-countdown-active { + animation: none; + } +} diff --git a/src/renderer/components/Explore/Explore.stories.tsx b/src/renderer/components/Explore/Explore.stories.tsx new file mode 100644 index 00000000..784e7560 --- /dev/null +++ b/src/renderer/components/Explore/Explore.stories.tsx @@ -0,0 +1,398 @@ +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 AppShell from '../AppShell/AppShell'; +import type { DeviceState } from '../AppShell/types'; +import { + BLINK_MANY, + BLINK_MANY_ALL, + BLINK_MANY_ANNOTATIONS, + BLINK_ONE, + BLINK_ONE_ANNOTATIONS, + BLINK_PREDICT, + BLINK_PREDICT_ANNOTATIONS, + BLINKING_SNAPSHOT, + CALM_SNAPSHOT, + COMPARISON_RATIO, + EYES_CLOSED_LIVE, + LIVE_SNAPSHOT, + NO_AF8_SENSORS, + NO_AF8_SNAPSHOT, + NO_SIGNAL_SNAPSHOT, + QUALITY_SCENARIOS, + QualityState, + RHYTHM_INCREASE_RATIO, + RHYTHM_NO_EFFECT_RATIO, + SensorStatus, +} from './fixtures'; +import { + BlinkLessonView, + EyesClosedView, + ExploreDisconnected, + ExploreSurface, +} from './ExploreScreens'; +import { ErrorBanner } from './ExploreParts'; + +/** SIGNAL_QUALITY values are the trace colors, one per sensor. */ +const qualityColors = (sensors: SensorStatus[]) => + sensors.map((sensor) => sensor.quality); + +/** + * Explore in the real AppShell with no workspace (Explore is workspace-free; + * the shell bar is the only chrome). All signal traces are synthetic fixture + * series — labelled "example" in these descriptions only, exactly as the app + * copy will read once the real webview is wired in. + */ +const withExploreChrome: Decorator = (Story, { parameters }) => ( + + + + + +); + +const meta: Meta = { + title: 'Domain/Explore', + parameters: { layout: 'fullscreen' }, + decorators: [withExploreChrome], +}; +export default meta; +type Story = StoryObj; + +/** The connected surface in one of the four quality states. */ +function Surface({ + state, + ...props +}: { + state: QualityState | 'waiting'; +} & Partial>) { + const sensors = + QUALITY_SCENARIOS[state === 'waiting' ? 'ready' : state].sensors; + const [hoveredChannel, setHoveredChannel] = React.useState( + null + ); + return ( + + ); +} + +/** X01 — Redesigned landing: what Explore is, one primary action, what waits once connected. */ +export const Disconnected: Story = { + parameters: { device: 'none' }, + render: () => , +}; + +/** X02 — Connected, no data yet: an explicit waiting state; lessons stay disabled until signal arrives. */ +export const Waiting: Story = { + render: () => , +}; + +/** Q01 — Ready: a light status row; the card only earns its weight in the yellow/red states. */ +export const QualitySummaryReady: Story = { + render: () => , +}; + +/** Q02 — `Sensors are still settling`: better contact means less static; no fixed warm-up promise. */ +export const QualitySummarySettling: Story = { + render: () => , +}; + +/** Q03 — `Adjust AF7 and TP10`: the status names the action and the sensors. */ +export const QualitySummaryAdjustSensors: Story = { + render: () => , +}; + +/** Q04 — `No signal detected`: headset off or disconnected, and the fix. */ +export const QualitySummaryNoSignal: Story = { + render: () => , +}; + +/** N01 — The plain-language definition of noise (plan §5.1) before the student judges anything. */ +export const NoiseDefinition: Story = { + render: () => ( + + ), +}; + +/** L01 — The two lessons as clear, local choices; no competing global nav. */ +export const LessonPicker: Story = { + render: () => , +}; + +/** B01 — Blink step 1/4: blink once and find the marked response on the plot. */ +export const BlinkStep1: Story = { + render: () => ( + + ), +}; + +/** B02 — Blink step 2/4: predict what another blink will do, then check. */ +export const BlinkStep2: Story = { + render: () => ( + + ), +}; + +/** B02b — The answered state: choosing either option immediately shows the expected answer. */ +export const BlinkStep2PredictionAnswered: Story = { + render: () => ( + + ), +}; + +/** B03 — Blink step 3/4: blink several times so the difference is unmistakable. */ +export const BlinkStep3: Story = { + render: () => ( + + ), +}; + +/** B04 — Blink step 4/4: a blinking interval beside a quiet one, same sensors, same scale; the range is adjustable. */ +export const BlinkStep4: Story = { + render: () => ( + + ), +}; + +/** B06 — Fallback marker style: a tick at the detection time, for detectors that report an instant instead of an interval. */ +export const BlinkTickMarker: Story = { + render: () => ( + + ), +}; + +/** B05 — BlinkNotDetected: detection misses or the frontal sensors have not settled; the lesson continues gracefully. */ +export const BlinkNotDetected: Story = { + render: () => ( + + ), +}; + +/** S01 — Noise demonstration on all four sensors: stable trace colors (§5.2), never the quality colors. */ +export const NoiseLessonStableColors: Story = { + render: () => ( + + ), +}; + +/** E01 — Eyes-closed 1/5: what the sounds mean, `Begin eyes-closed activity`, Example reference. */ +export const EyesClosedIntro: Story = { + render: () => ( + + ), +}; + +/** E02 — Eyes-closed 2/5: visible 3–2–1 countdown (mid-count), pulse respects reduced motion. */ +export const EyesClosedCountdown: Story = { + render: () => ( + + ), +}; + +/** E03 — Eyes-closed 3/5: `Close your eyes` and the interval — nothing on screen needs watching. */ +export const EyesClosedInterval: Story = { + render: () => ( + + ), +}; + +/** E04 — Eyes-closed 4/5: the unmistakable `Open your eyes` end cue. */ +export const EyesClosedEndCue: Story = { + render: () => ( + + ), +}; + +/** E05 — Eyes-closed 5/5: two equal segments from the marked interval on one scale, with the measured comparison. */ +export const EyesClosedReview: Story = { + render: () => ( + + ), +}; + +/** A01 — AlphaResult: the student's own comparison as the result, with the ideal reference labelled `Example`. */ +export const AlphaResult: Story = { + render: () => ( + + ), +}; + +/** A02 — AlphaNoEffect: a valid, encouraging outcome — the change is clearest in some people. */ +export const AlphaNoEffect: Story = { + render: () => ( + + ), +}; + +/** X03 — StreamError: the stream stopped; the message says exactly what to do. */ +export const StreamError: Story = { + render: () => ( + + } + /> + ), +}; + +/** X04 — UnsupportedChannels: this headset reports no AF8; the fixture agrees. */ +export const UnsupportedChannels: Story = { + render: () => ( + + } + /> + ), +}; \ No newline at end of file diff --git a/src/renderer/components/Explore/ExploreParts.tsx b/src/renderer/components/Explore/ExploreParts.tsx new file mode 100644 index 00000000..7445fcc3 --- /dev/null +++ b/src/renderer/components/Explore/ExploreParts.tsx @@ -0,0 +1,796 @@ +import React, { ReactNode, useEffect, useId, useRef, useState } from 'react'; +import type { EEGSnapshot, PlotAnnotation } from '../../../shared/eegVizTypes'; +import { Button } from '../ui/button'; +import { cn } from '../ui/utils'; +import { + ALPHA_EXAMPLE_CAPTION, + CLOSED_SEGMENT, + EXPLORE_CHANNELS, + NOISE_DEFINITION, + NOISE_SETTLING_NOTE, + OPEN_SEGMENT, + QUALITY_SCENARIOS, + QUALITY_STATE_TONE, + QualityState, + REVIEW_SEGMENT_SCALE, +} from './fixtures'; +import { channelColor } from '../../utils/eeg/traceColors'; + +/** Small uppercase label for lesson step counters and section titles. */ +export const stepLabel = + 'text-[12px] font-bold uppercase tracking-[0.5px] text-ink-muted'; + +const PLOT_MARGIN = { top: 20, right: 10, bottom: 30, left: 44 }; +const LABEL_HEIGHT = 22; +const LABEL_RADIUS = LABEL_HEIGHT / 2; +const LABEL_GUTTER = LABEL_HEIGHT + 8; + +/** Matches `EEGViewer`'s annotation tones so integration swaps in cleanly. */ +const TONE_STYLES = { + blink: { + fill: 'rgba(255, 193, 7, 0.18)', + stroke: '#ffc107', + text: '#1a1a1a', + }, + 'eyes-closed': { + fill: 'rgba(0, 124, 112, 0.10)', + stroke: '#007c70', + text: '#ffffff', + }, +} as const; + +/** Glyph-width estimate; the real viewer measures with `getBbox`. */ +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. */ + snapshot: EEGSnapshot; + annotations?: PlotAnnotation[]; + /** Per-channel stroke colors, index-aligned with `snapshot.channels`. */ + colors: string[]; + /** Symmetric µV half-range, like `ViewerComponent.amplitudeScale`. */ + amplitudeScale?: number; + width?: number; + height?: number; + /** + * `band` draws the real viewer's annotation bands; `tick` is the fallback + * marker for detectors that report a detection time instead of an interval. + */ + markerStyle?: 'band' | 'tick'; + /** Off for compact example plots. */ + showAxis?: boolean; + /** Near-zero margins for compact example plots. */ + tight?: boolean; +} + +/** + * 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. + */ +export function FixturePlot({ + snapshot, + annotations = [], + colors, + amplitudeScale = 200, + width = 900, + height = 420, + markerStyle = 'band', + showAxis = true, + tight = false, +}: FixturePlotProps) { + const clipId = useId(); + const margin = tight ? { top: 2, right: 2, bottom: 2, left: 2 } : PLOT_MARGIN; + const plotW = width - margin.left - margin.right; + const plotH = height - margin.top - margin.bottom; + const count = snapshot.channels.length; + const bandH = plotH / count; + const span = snapshot.endTime - snapshot.startTime; + const xAt = (t: number) => ((t - snapshot.startTime) / span) * plotW; + + const paths = snapshot.data.map((samples, i) => { + const center = samples.reduce((a, b) => a + b, 0) / samples.length; + const top = i * bandH; + const yAt = (v: number) => + top + ((center + amplitudeScale - v) / (2 * amplitudeScale)) * bandH; + // Same 2× downsampling as the real viewer's line paths. + let d = ''; + for (let n = 0; n < samples.length; n += 2) { + const x = xAt(snapshot.startTime + (n / samples.length) * span); + d += `${d ? 'L' : 'M'}${x.toFixed(1)},${yAt(samples[n]).toFixed(1)}`; + } + return d; + }); + + // Whole-second offset ticks only, mirroring `EEGViewer.buildTimeAxis`. + const maxTicks = Math.max(2, Math.floor(plotW / 80)); + const tickStep = Math.max(1, Math.ceil(span / 1000 / maxTicks)) * 1000; + const ticks: number[] = []; + for (let offset = 0; offset >= -span; offset -= tickStep) ticks.push(offset); + + const bands = annotations + .filter( + (annotation) => + (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; + return { + annotation, + x: Math.max(0, Math.min(plotW, startX)), + width: Math.max(0, Math.min(plotW, endX) - Math.max(0, startX)), + ended: annotation.endTime != null, + }; + }); + + return ( + + + + {paths.map((d, i) => ( + + ))} + + {markerStyle === 'tick' && + annotations + .filter( + (a) => + a.startTime >= snapshot.startTime && + a.startTime <= snapshot.endTime + ) + .map((a) => ( + + ))} + {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 && ( + + )} + {bandWidth > 40 && ( + <> + + + + {annotation.label} + + + {ended && annotation.endLabel && ( + + + + {annotation.endLabel} + + + )} + + )} + + ); + })} + {showAxis && ( + <> + + {ticks.map((offset) => ( + + + + {Math.round(offset / 1000)}s + + + ))} + {snapshot.channels.map((channel, i) => ( + + + + {channel} + + + ))} + + )} + + + + + + ); +} + +/** A plot card: one caption line above a plot that fills the given space. */ +export function PlotCard({ + caption, + aside, + children, + className, +}: { + caption: string; + aside?: ReactNode; + children: ReactNode; + className?: string; +}) { + return ( +
+
+ + + {caption} + + {aside} +
+
{children}
+
+ ); +} + +/** + * Overall signal status above the plot (plan §5.1). Words explain, color only + * supports; the sensor strip repeats every state in text. + */ +export function QualitySummary({ + state, + className, +}: { + state: QualityState; + className?: string; +}) { + const scenario = QUALITY_SCENARIOS[state]; + // The card earns its place in the yellow/red states; ready stays a light row. + const light = scenario.action === ''; + return ( +
+ + +

+ {scenario.heading} +

+
+ {scenario.action && ( + + {scenario.action} + + )} +
+ ); +} + +/** The plain-language definition of noise (plan §5.1), before any judging. */ +export function NoiseDefinitionCard() { + return ( +
+

What “noise” means here

+

+ {NOISE_DEFINITION} +

+

+ {NOISE_SETTLING_NOTE} +

+
+ ); +} + +/** + * The lesson step panel in the Analyze walkthrough's idiom: step counter with + * gold progress pips, local Exit, fading step copy, Back / Next at the bottom. + */ +export function LessonStepPanel({ + label, + step, + steps, + title, + action, + body, + children, + onBack, + onNext, + onExit, + backLabel = 'Back', + nextLabel = 'Next', + backDisabled, + nextDisabled, +}: { + /** Uppercase step counter prefix, e.g. `Where is this noise coming from?`. */ + label: string; + /** Current step; 0 hides the counter and pips (pre-step screens). */ + step: number; + steps: number; + title: string; + /** The expected action at a glance (plan §5.2). */ + action?: string; + body: ReactNode; + children?: ReactNode; + onBack(): void; + onNext(): void; + onExit(): void; + backLabel?: string; + nextLabel?: string; + backDisabled?: boolean; + nextDisabled?: boolean; +}) { + const heading = useRef(null); + useEffect(() => { + heading.current?.focus({ preventScroll: true }); + }, [step]); + return ( + + ); +} + +/** + * Frozen five-second comparison window with its peak-to-peak readout, like the + * lesson flow's frozen strips. + */ +export function FrozenStrip({ + label, + sublabel, + snapshot, + colors, + scale, + blinking, + ratio, +}: { + label: string; + sublabel: string; + snapshot: EEGSnapshot; + colors: string[]; + scale: number; + blinking?: boolean; + ratio?: number; +}) { + return ( +
+
+ {label} + {sublabel} +
+
+
+ +
+
+
+
+
+ {snapshot.peakToPeak.toFixed(0)} µV +
+
+ {blinking && ratio !== undefined + ? `${Math.round(ratio)}× the still signal` + : 'peak to peak'} +
+
+
+
+
+ ); +} + +/** Visible 3–2–1 countdown; pulses only when motion is allowed. */ +export function Countdown({ value }: { value: 3 | 2 | 1 }) { + return ( +
+ {([3, 2, 1] as const).map((digit) => { + const state = + digit > value ? 'done' : digit === value ? 'active' : 'ahead'; + return ( + + {digit} + + ); + })} +
+ ); +} + +/** + * The review view: an eyes-open segment above an eyes-closed segment of equal + * length, both on one µV scale so the change is directly comparable. `compact` + * renders the same picture small for the `Example` card. + */ +export function SegmentComparison({ + open, + closed, + colors, + scale, + compact, +}: { + open: EEGSnapshot; + closed: EEGSnapshot; + colors: string[]; + scale: number; + compact?: boolean; +}) { + const duration = `${Math.round((open.endTime - open.startTime) / 1000)} SECONDS`; + return ( +
+ {( + [ + { snapshot: open, label: 'EYES OPEN' }, + { snapshot: closed, label: 'EYES CLOSED' }, + ] as const + ).map(({ snapshot, label }) => ( +
+
+ {label} · {duration} +
+
+ +
+
+ ))} +
+ ); +} + +const QUIZ_OPTIONS = [ + { value: 'hump', text: 'A big, slow hump' }, + { value: 'flat', text: 'Not much change' }, +] as const; + +/** + * Prediction options styled as radios (not actions); choosing one immediately + * reveals the expected answer. Nothing is recorded. + */ +export function PredictionQuiz({ + defaultAnswer, +}: { + /** Pre-answered state, for the answered-state story. */ + defaultAnswer?: (typeof QUIZ_OPTIONS)[number]['value']; +}) { + const [answer, setAnswer] = useState< + (typeof QUIZ_OPTIONS)[number]['value'] | null + >(defaultAnswer ?? null); + return ( +
+ Your prediction +
+ {QUIZ_OPTIONS.map((option) => { + const active = answer === option.value; + return ( + + ); + })} +
+ {answer && ( +
+ The expected answer is “a big, slow hump” — every + blink drops one onto the two front sensors. Check the plot to see + yours. +
+ )} +
+ ); +} + +/** + * Optional ideal reference. Labelled `Example`, visually separated by a dashed + * border, and never presented as the expected outcome. Same picture as the + * review view, just small. + */ +export function AlphaExampleCard() { + return ( +
+ Example + + channelColor(ch, EXPLORE_CHANNELS) + )} + scale={REVIEW_SEGMENT_SCALE} + /> +

+ {ALPHA_EXAMPLE_CAPTION} +

+
+ ); +} + +/** An error state that says what to do, with the action beside it. */ +export function ErrorBanner({ + title, + body, + actionLabel, + onAction, +}: { + title: string; + body: string; + actionLabel: string; + onAction(): void; +}) { + return ( +
+
+ + ! + +
+
{title}
+
{body}
+
+
+ +
+ ); +} \ No newline at end of file diff --git a/src/renderer/components/Explore/ExploreScreens.tsx b/src/renderer/components/Explore/ExploreScreens.tsx new file mode 100644 index 00000000..f0a19fb1 --- /dev/null +++ b/src/renderer/components/Explore/ExploreScreens.tsx @@ -0,0 +1,542 @@ +import React, { ReactNode, useMemo, useState } from 'react'; +import { of } from 'rxjs'; +import type { EEGSnapshot, PlotAnnotation } from '../../../shared/eegVizTypes'; +import { PLOTTING_INTERVAL } from '../../constants/constants'; +import { EXPLORE_LESSONS } from '../../constants/exploreLessons'; +import eegArt from '../../assets/common/EEG.png'; +import { channelColor } from '../../utils/eeg/traceColors'; +import ExploreSensorCard from '../ExploreSensorCard'; +import SignalQualityIndicatorComponent from '../SignalQualityIndicatorComponent'; +import { Button } from '../ui/button'; +import { cn } from '../ui/utils'; +import { + ALPHA_NO_EFFECT_BODY, + ALPHA_RESULT_BODY, + BLINK_NOT_DETECTED, + BLINK_STEPS, + CLOSED_SEGMENT, + EXPLORE_CHANNELS, + EYES_END_BODY, + EYES_INTRO_BODY, + EYES_INTRO_OPENER, + EYES_INTERVAL_BODY, + EYES_PROXY_NOTE, + OPEN_SEGMENT, + PLOT_LEGEND, + QualityState, + REVIEW_SEGMENT_SCALE, + SensorStatus, + qualitySample, +} from './fixtures'; +import { + AlphaExampleCard, + Countdown, + FixturePlot, + FrozenStrip, + LessonStepPanel, + NoiseDefinitionCard, + PlotCard, + PredictionQuiz, + QualitySummary, + SegmentComparison, + stepLabel, +} from './ExploreParts'; + +const BODY_TEXT = + 'm-0 !text-[16px] leading-normal !tracking-normal [text-wrap:pretty]'; + +/** Channel colors from the full device list, so down-selected views agree. */ +const colorsFor = (channels: string[]) => + channels.map((channel) => channelColor(channel, EXPLORE_CHANNELS)); + +/** + * Redesigned disconnected landing: what Explore is, one primary action, and + * what waits on the other side. Pairs with Home's Explore card. + */ +export function ExploreDisconnected({ onConnect }: { onConnect(): void }) { + return ( +
+
+ +
+

+ Explore EEG +

+

+ Put on a headset and watch the EEG (electroencephalogram) signal in + real time — your own brain's electricity, arriving live. No + experiment to set up. +

+
+ + + Takes about 30 seconds + +
+
+
+
+ ); +} + +/** The two lessons as equal, local choices; both actions stay outlined. */ +export function LessonPicker({ + disabled, + onStart, +}: { + disabled?: boolean; + onStart(): void; +}) { + return ( +
+

Learn with this signal

+
+ {EXPLORE_LESSONS.map((lesson) => ( +
+
+
+ {lesson.title} +
+
{lesson.detail}
+
+ +
+ ))} +
+
+ ); +} + +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[]; + /** Shared hover state between the head diagram and the sensor card. */ + hoveredChannel: string | null; + onHoveredChannelChange(channel: string | null): void; + onStartLesson(): void; + /** Above the status summary, e.g. the stream-error banner. */ + banner?: ReactNode; +} + +/** + * The connected Explore surface: overall status above the plot, the head + * diagram at left, live plot and lesson choices at right. Fills the window + * without page scroll at 1366×768 and 1280×720. + */ +export function ExploreSurface({ + quality, + sensors, + snapshot, + colors, + hoveredChannel, + onHoveredChannelChange, + onStartLesson, + banner, +}: ExploreSurfaceProps) { + const waiting = quality === 'waiting'; + const sample = useMemo( + () => (waiting ? null : qualitySample(sensors)), + [waiting, sensors] + ); + const head = useMemo(() => (sample ? of(sample) : null), [sample]); + return ( +
+ {banner} + {waiting ? ( +
+ +

+ Waiting for the headset signal… +

+ + Connected — the first seconds of data are on their way. + +
+ ) : ( + + )} +
+
+ s.channel)} + hoveredChannel={hoveredChannel} + onHoveredChannelChange={onHoveredChannelChange} + /> + s.channel)} + sample={sample} + hoveredChannel={hoveredChannel} + onHoveredChannelChange={onHoveredChannelChange} + /> +
+
+ + {snapshot ? ( + + ) : ( +
+ Waiting for the headset signal… +
+ )} +
+ +
+
+
+ ); +} + +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[]; + comparison?: { + calm: EEGSnapshot; + blinking: EEGSnapshot; + ratio: number; + }; + onBack(): void; + onNext(): void; + onExit(): void; +} + +/** + * The noise-source lesson: the noise definition first, then the four blink + * steps, instruction beside the plot with the lesson's own Back/Next/Exit. + * Traces use stable colors (§5.2) so changing quality colors do not compete. + */ +export function BlinkLessonView({ + step, + markerStyle = 'band', + notDetected, + defaultPrediction, + snapshot, + annotations, + comparison, + onBack, + onNext, + onExit, +}: BlinkLessonViewProps) { + const [range, setRange] = useState<'wide' | 'narrow'>('wide'); + const colors = colorsFor(snapshot.channels); + const caught = (annotations ?? []).length; + return ( +
+ 0 ? BLINK_STEPS[step - 1].action : undefined} + body={ + step === 0 ? : BLINK_STEPS[step - 1].body + } + backLabel={step === 0 ? 'Exit' : 'Back'} + nextLabel={ + step === 0 + ? 'Start the steps' + : step === 4 + ? 'Finish lesson' + : 'Next' + } + onBack={step === 0 ? onExit : onBack} + onNext={onNext} + onExit={onExit} + > + {step === 2 && } + {notDetected && ( +
+ {BLINK_NOT_DETECTED} +
+ )} +
+
+ {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 ( + + ); + })} +
+ )} + + + + ) : ( + 0 && step < 3 ? ( + + {caught} {caught === 1 ? 'blink' : 'blinks'} marked + + ) : undefined + } + > + + + )} +
+
+ ); +} + +export type EyesClosedPhase = + | 'intro' + | 'countdown' + | 'interval' + | 'end' + | 'review'; + +export interface EyesClosedViewProps { + phase: EyesClosedPhase; + /** 3–2–1 position during the countdown. */ + countdown?: 3 | 2 | 1; + /** Measured comparison (eyes-closed ÷ before); null = not enough data. */ + rhythmRatio: number | null; + showExample?: boolean; + snapshot: EEGSnapshot; + onBack(): void; + onNext(): void; + onExit(): void; +} + +/** + * The single guided eyes-closed sequence (plan §5.4): explain the sounds, + * Begin, visible countdown, `Close your eyes`, the interval, an unmistakable + * `Open your eyes`, then the marked interval and the measured comparison. + */ +export function EyesClosedView({ + phase, + countdown = 3, + rhythmRatio, + showExample, + snapshot, + onBack, + onNext, + onExit, +}: EyesClosedViewProps) { + const running = phase === 'countdown' || phase === 'interval'; + const increase = rhythmRatio != null && rhythmRatio > 1; + const resultBody = + rhythmRatio == null + ? 'There is not enough continuous data from the back of your head to compare. The marked interval is still saved below.' + : increase + ? ALPHA_RESULT_BODY + : ALPHA_NO_EFFECT_BODY; + return ( +
+ + {phase === 'intro' && showExample && } + {phase === 'review' && ( + <> +
+ {rhythmRatio == null + ? 'Comparison: not enough data this time.' + : increase + ? `The steady rhythm from the back of your head was ${rhythmRatio.toFixed(1)}× as strong with your eyes closed.` + : 'The steady rhythm from the back of your head was about as strong as before.'} +
+ {showExample && } +
+ {EYES_PROXY_NOTE} +
+ + )} +
+
+ {phase === 'review' ? ( + + + + ) : ( + + + + )} + {(running || phase === 'end') && ( +
+ {phase === 'countdown' ? ( + + ) : ( +
+ {phase === 'interval' ? 'Close your eyes' : 'Open your eyes'} +
+ )} +
+ {phase === 'countdown' + ? 'Close your eyes when the chime sounds.' + : phase === 'interval' + ? 'About ten seconds. Two chimes will end it.' + : 'The activity just ended.'} +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/renderer/components/Explore/fixtures.ts b/src/renderer/components/Explore/fixtures.ts new file mode 100644 index 00000000..8f463848 --- /dev/null +++ b/src/renderer/components/Explore/fixtures.ts @@ -0,0 +1,331 @@ +import type { EEGSnapshot, PlotAnnotation } from '../../../shared/eegVizTypes'; +import { + MUSE_SAMPLING_RATE, + SIGNAL_QUALITY, +} from '../../constants/constants'; +import type { SignalQualityData } from '../../constants/interfaces'; + +/** Muse montage order, matching the live viewer's channel order. */ +export const EXPLORE_CHANNELS = ['TP9', 'AF7', 'AF8', 'TP10']; +export const FRONTAL_CHANNELS = ['AF7', 'AF8']; +export const POSTERIOR_CHANNELS = ['TP9', 'TP10']; +export const SAMPLING_RATE = MUSE_SAMPLING_RATE; + +/** Device identity for the plot legend; integration reads both from deviceInfo. */ +export const DEVICE_NAME = 'Muse-1A2B'; +export const PLOT_LEGEND = `${DEVICE_NAME} · ${SAMPLING_RATE} Hz`; + +/** Deterministic PRNG so every screenshot of the synthetic signal is identical. */ +function mulberry32(seed: number) { + let a = seed; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const bump = (t: number, center: number, width: number) => + Math.exp(-((t - center) ** 2) / (2 * width ** 2)); + +export interface TraceSpec { + seed: number; + /** Blink centers on the snapshot's own clock (0 = snapshot start), in ms. */ + blinksMs?: number[]; + /** Eyes-closed window on the snapshot's own clock, in ms. */ + alphaMs?: [number, number]; + /** Scales the whole signal; near zero for the no-signal state. */ + gain?: number; +} + +/** + * Synthetic multi-channel EEG for the fixture plot: background 10 Hz / 5 Hz + * rhythms with random phase, sensor noise, blink humps weighted to the frontal + * sensors, and a steady back-of-head rhythm weighted to TP9/TP10 while the + * eyes are closed. Not recorded data. + */ +export function makeSnapshot( + channels: string[], + durationMs: number, + spec: TraceSpec +): EEGSnapshot { + const random = mulberry32(spec.seed); + const step = 1000 / SAMPLING_RATE; + const count = Math.round(durationMs / step) + 1; + const phases = channels.map(() => random() * Math.PI * 2); + const data = channels.map((channel, i) => { + const frontal = FRONTAL_CHANNELS.includes(channel) ? 1 : 0.3; + const posterior = POSTERIOR_CHANNELS.includes(channel) ? 1 : 0.35; + const samples = new Array(count); + for (let n = 0; n < count; n += 1) { + const t = n * step; + let value = + 6 * Math.sin((2 * Math.PI * 10 * t) / 1000 + phases[i]) + + 5 * Math.sin((2 * Math.PI * 5.5 * t) / 1000 + phases[i] * 1.7) + + (random() - 0.5) * 8; + for (const center of spec.blinksMs ?? []) { + value += frontal * 130 * bump(t, center, 110); + } + if (spec.alphaMs && t >= spec.alphaMs[0] && t <= spec.alphaMs[1]) { + const edge = Math.min( + 1, + (t - spec.alphaMs[0]) / 400, + (spec.alphaMs[1] - t) / 400 + ); + value += + posterior * 15 * edge * Math.sin((2 * Math.PI * 10.5 * t) / 1000); + } + samples[n] = value * (spec.gain ?? 1); + } + return samples; + }); + return { + startTime: 0, + endTime: durationMs, + data, + channels, + samplingRate: SAMPLING_RATE, + peakToPeak: Math.max(...data.map((s) => Math.max(...s) - Math.min(...s))), + }; +} + +const blinkBand = ( + id: string, + startTime: number, + endTime: number +): PlotAnnotation => ({ + id: `blink-${id}`, + startTime, + endTime, + label: 'blink · from your eyes, not your brain', + tone: 'blink', +}); + +/** Live four-sensor window for the connected surface. */ +export const LIVE_SNAPSHOT = makeSnapshot(EXPLORE_CHANNELS, 5000, { + seed: 11, +}); + +/** Flat traces for the `No signal detected` state: no contact, not data. */ +export const NO_SIGNAL_SNAPSHOT = makeSnapshot(EXPLORE_CHANNELS, 5000, { + seed: 12, + gain: 0.04, +}); + +/** A headset that reports no AF8: the unsupported-channel state. */ +export const NO_AF8_CHANNELS = ['TP9', 'AF7', 'TP10']; +export const NO_AF8_SENSORS: SensorStatus[] = NO_AF8_CHANNELS.map( + (channel) => ({ channel, quality: SIGNAL_QUALITY.GREAT }) +); +export const NO_AF8_SNAPSHOT = makeSnapshot(NO_AF8_CHANNELS, 5000, { + seed: 13, +}); + +/** Blink step 1: one blink, one marked response. */ +export const BLINK_ONE = makeSnapshot(FRONTAL_CHANNELS, 5000, { + seed: 21, + blinksMs: [2100], +}); +export const BLINK_ONE_ANNOTATIONS = [blinkBand('one', 1650, 2650)]; + +/** Blink step 2: the first marked blink, second not yet taken. */ +export const BLINK_PREDICT = makeSnapshot(FRONTAL_CHANNELS, 5000, { + seed: 22, + blinksMs: [1050], +}); +export const BLINK_PREDICT_ANNOTATIONS = [blinkBand('predict', 650, 1550)]; + +/** Blink step 3: several blinks so the effect is unmistakable. */ +export const BLINK_MANY = makeSnapshot(FRONTAL_CHANNELS, 5000, { + seed: 23, + blinksMs: [900, 2050, 3250, 4350], +}); +export const BLINK_MANY_ANNOTATIONS = [ + blinkBand('m1', 500, 1350), + blinkBand('m2', 1650, 2500), + blinkBand('m3', 2850, 3700), + blinkBand('m4', 3950, 4750), +]; + +/** Stable-color demo: all four sensors while blinking (plan §5.2). */ +export const BLINK_MANY_ALL = makeSnapshot(EXPLORE_CHANNELS, 5000, { + seed: 24, + blinksMs: [900, 2050, 3250, 4350], +}); + +/** Blink step 4: two frozen five-second windows at one shared scale. */ +export const CALM_SNAPSHOT = makeSnapshot(FRONTAL_CHANNELS, 5000, { + seed: 31, +}); +export const BLINKING_SNAPSHOT = makeSnapshot(FRONTAL_CHANNELS, 5000, { + seed: 32, + blinksMs: [1150, 2350, 3650], +}); +export const COMPARISON_RATIO = + BLINKING_SNAPSHOT.peakToPeak / CALM_SNAPSHOT.peakToPeak; + +/** Live window shown during the countdown and interval. */ +export const EYES_CLOSED_LIVE = makeSnapshot(EXPLORE_CHANNELS, 5000, { + seed: 42, +}); + +/** + * The review view: equal 3-second segments from the eyes-open and eyes-closed + * parts of the marked interval, on one shared µV scale. At this length a + * ~10 Hz rhythm is ~29 px per cycle at 1366×768 — visible with real Muse + * amplitudes; 10-second segments would compress it to ~9 px (mush). + */ +export const REVIEW_SEGMENT_MS = 3000; +export const REVIEW_SEGMENT_SCALE = 50; +export const OPEN_SEGMENT = makeSnapshot(POSTERIOR_CHANNELS, REVIEW_SEGMENT_MS, { + seed: 51, + gain: 0.6, +}); +export const CLOSED_SEGMENT = makeSnapshot(POSTERIOR_CHANNELS, REVIEW_SEGMENT_MS, { + seed: 52, + alphaMs: [0, REVIEW_SEGMENT_MS], +}); + +/** Measured comparison (eyes-closed ÷ before) for the result stories. */ +export const RHYTHM_INCREASE_RATIO = 2.4; +export const RHYTHM_NO_EFFECT_RATIO = 0.9; + +export interface SensorStatus { + channel: string; + quality: SIGNAL_QUALITY; +} + +/** A one-emission fixture stream for `SignalQualityIndicatorComponent`. */ +export function qualitySample(sensors: SensorStatus[]): SignalQualityData { + return { + data: sensors.map(() => []), + info: { + samplingRate: SAMPLING_RATE, + startTime: 0, + signalQuality: Object.fromEntries(sensors.map((s) => [s.channel, 2])), + }, + signalQuality: Object.fromEntries( + sensors.map((s) => [s.channel, s.quality]) + ), + }; +} + +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, +}; + +/** Plan §5.1 wording, shown before the student judges anything. */ +export const NOISE_DEFINITION = + 'Noise is electrical activity the headset records that did not come from the brain signal we are trying to measure. Blinks, jaw tension, movement, and poor sensor contact can all create noise.'; + +export const NOISE_SETTLING_NOTE = + 'Not a sound — think of it as static in the recording. Better contact means less static: it usually improves over several minutes as the sensors settle onto your skin, and there is no fixed warm-up time.'; + +/** Blink lesson steps (plan §5.3). `action` is the expected step at a glance. */ +export const BLINK_STEPS: { + title: string; + action: string; + body: string; +}[] = [ + { + title: 'Blink once and find the marked response', + action: 'Blink once, then keep still and watch AF7 and AF8.', + body: 'A blink drops one big slow hump onto the two front sensors — from your eyes moving, not your brain thinking. When we spot one, the plot marks it with a gold band.', + }, + { + title: 'Predict what another blink will do', + action: 'Guess first, then blink once and check.', + body: 'The lines are flat again. Before you blink, decide what the next blink will look like — then blink once and see whether you were right.', + }, + { + title: 'Now blink several times in a row!', + action: 'Blink hard, three or four times in a row.', + body: 'Blinks are one of the loudest things in your signal. Seeing each one land clearly is good news: it means the front sensors are touching your skin and really picking you up.', + }, + { + title: 'Compare the blinking interval with a quiet interval', + action: 'Now sit still, eyes open, for 5 seconds.', + body: 'One frozen five seconds while you were blinking, one while you sat still. Your brain signal is in both — the blinks just tower over it. This is why researchers ask you to hold still.', + }, +]; + +export const BLINK_NOT_DETECTED = + 'We cannot see your blinks yet. Check that AF7 and AF8 sit flat against your forehead, then try again.'; + +export const EYES_INTRO_OPENER = + 'Let’s look at how your brain signal changes when you close your eyes'; +export const EYES_INTRO_BODY = + 'Two sounds guide this activity. One chime means close your eyes now. Two chimes, about ten seconds later, mean open them again. Nothing on screen needs watching in between.'; + +export const EYES_PROXY_NOTE = + 'Muse has no sensors over the visual cortex — TP9 and TP10 behind the ears are the closest look at the back of your head.'; + +export const EYES_INTERVAL_BODY = 'Keep them closed until you hear two chimes.'; + +export const EYES_END_BODY = 'Let’s look at your brainwaves.'; + +export const ALPHA_RESULT_BODY = + 'With your eyes closed, the seeing part of your brain has nothing to look at — and it gets louder.'; +export const ALPHA_NO_EFFECT_BODY = + 'That is a real result, not a failed lesson — this change is clearest in some people and nearly invisible in others.'; +export const ALPHA_EXAMPLE_CAPTION = + 'Some recordings look like this. Yours will be yours.'; \ No newline at end of file diff --git a/src/renderer/utils/eeg/traceColors.ts b/src/renderer/utils/eeg/traceColors.ts new file mode 100644 index 00000000..da744963 --- /dev/null +++ b/src/renderer/utils/eeg/traceColors.ts @@ -0,0 +1,13 @@ +import { interpolateRainbow } from 'd3'; + +/** + * A channel's trace color from d3's rainbow, indexed by its position in the + * device's full channel list so the color never changes when a view + * down-selects channels. d3 ships no types; the import is untyped. + */ +export function channelColor( + name: string, + allChannels: readonly string[] +): string { + return interpolateRainbow(allChannels.indexOf(name) / allChannels.length); +}