From 2a6d21caf7bbef1b9fad9a2c3e703af48f76a155 Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Thu, 24 Sep 2026 15:38:10 -0400 Subject: [PATCH 1/3] design(WS7): Analyze Storybook pass with fixtures and AppShell chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Analyze/fixtures.ts with dataset, epoch, channel and plot fixtures. - Add AnalyzeOverview, AnalyzeErp, AnalyzeBehavior pure-props components. - Add Analyze.stories.tsx with all required stories rendered inside the real AppShell chrome at location='analyze'. - Fix ClickableHeadDiagramSVG prop spread so onChannelClick is not passed to the underlying element. - Copy brief into docs/uxr. Refs: docs/uxr/2026-09-24-ws7-design-brief.md, playtest plan §9/§10.2. --- docs/uxr/2026-09-24-ws7-design-brief.md | 59 ++++ .../components/Analyze/Analyze.stories.tsx | 276 ++++++++++++++++++ .../components/Analyze/AnalyzeBehavior.tsx | 207 +++++++++++++ .../components/Analyze/AnalyzeErp.tsx | 181 ++++++++++++ .../components/Analyze/AnalyzeOverview.tsx | 141 +++++++++ src/renderer/components/Analyze/fixtures.ts | 158 ++++++++++ .../svgs/ClickableHeadDiagramSVG.tsx | 79 +++-- 7 files changed, 1061 insertions(+), 40 deletions(-) create mode 100644 docs/uxr/2026-09-24-ws7-design-brief.md create mode 100644 src/renderer/components/Analyze/Analyze.stories.tsx create mode 100644 src/renderer/components/Analyze/AnalyzeBehavior.tsx create mode 100644 src/renderer/components/Analyze/AnalyzeErp.tsx create mode 100644 src/renderer/components/Analyze/AnalyzeOverview.tsx create mode 100644 src/renderer/components/Analyze/fixtures.ts diff --git a/docs/uxr/2026-09-24-ws7-design-brief.md b/docs/uxr/2026-09-24-ws7-design-brief.md new file mode 100644 index 00000000..177169fe --- /dev/null +++ b/docs/uxr/2026-09-24-ws7-design-brief.md @@ -0,0 +1,59 @@ +# WS7 design brief — Analyze + +**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` §1.3, §8.4, §9, §10.2 ("Analyze"), §11 WS7. +- Playtest 1 P0 in `TODOS.md`: "Analyze: layout broken by recent component changes; stray elements popping up." +- Design system: `docs/design/DESIGN.md`, `.design-sync/conventions.md`. +- Shape to match: #272 (`components/HeadsetSetup/`) and #273 (`CollectComponent/RunResult.tsx`). Pure-props views, co-located stories, fixtures file. + +## The job + +After collecting (and for EEG, cleaning), the student wants to answer their research question. Today Analyze reads as a gallery of plots. Redesign Overview / ERP / Behavior around the student's questions (§9.1). Do **not** invent analysis behavior or change any calculation (§9.2). + +## Current code (read before designing) + +- `src/renderer/components/AnalyzeComponent.tsx` (~600 lines). `ANALYZE_STEPS` OVERVIEW / ERP / BEHAVIOR (behavior-only: BEHAVIOR only). Props: `epochsInfo`, `channelInfo`, `psdPlot`, `topoPlot`, `erpPlot` (Pyodide SVG results keyed as today), `isEEGEnabled`, `type`, `deviceType`. +- `src/renderer/components/PyodidePlotWidget.tsx` — how Pyodide SVG/PNG plots render. +- `src/renderer/components/svgs/ClickableHeadDiagramSVG.tsx` — channel selection by head diagram. +- `src/renderer/utils/behavior/compute.js` — behavior aggregation (response time / accuracy, outlier removal, plot types). Behavior values are strings after the CSV round-trip (`.llms/learnings.md`). +- `src/renderer/utils/eeg/conditionPalette.ts` — stable condition colors. +- `src/renderer/components/AppShell/WorkspaceAreaGate` — the existing whole-area blocked state (#269). Analyze must not be wholly gated when behavior data exists (§13). + +## Stories required (plan §9.2, §10.2 "Analyze") + +Split the screen into focused presentational components only where Storybook needs it (§11 WS7). E.g. `components/Analyze/{AnalyzeOverview,AnalyzeErp,AnalyzeBehavior}.tsx`, with a fixtures file whose shapes match the current props and Pyodide result shapes. For plots, use a captured or representative SVG string fixture; do not add a plotting library (§13). + +| Story | Must show | +|---|---| +| NoData | Explains what to do next with one action (`Go to Collect`). | +| BehaviorBeforeCleaning | EEG workspace with complete behavior but no cleaned EEG. Behavior fully usable. Overview/ERP show the clean-data-required state with one `Go to Clean`. | +| OverviewResults | Select one or more cleaned datasets; which subjects/recordings are included; dataset summary, PSD, topography. | +| OverviewLoading / OverviewError | Explicit loading and analysis-error states. | +| ErpExplainer + ErpResults | What an ERP represents, in plain language. Channel selection via the head diagram **and** a channel list. Conditions distinguished by stable labels and colors (never color alone). | +| ErpNoResult / ErpLoading / ErpError | Each state explicit. | +| BehaviorResults | Choose response time or accuracy; outlier removal, data-point display, plot type; what each visualization communicates. | +| BehaviorExport | Export aggregated data with visible success and failure feedback. | +| BehaviorOnlyWorkspace | Behavior-only workspace: no Overview/ERP tabs, no Clean references. | + +## Constraints + +- Keep the existing Overview / ERP / Behavior division as the starting point (§9.1). +- Analyze is available when any analysis has valid input. Prerequisites are enforced per section, never as a whole-page gate when behavior exists (§8.4, §13). +- Incomplete (ended-early) recordings never appear as selectable datasets (§1.5). Engineering gets this for free from the `*.incomplete.csv` naming in #275, so no story needs to show them. +- Condition colors come from `conditionPalette`; signal-quality colors are never reused for conditions (DESIGN.md). +- One filled-teal primary action per surface. Light headings. Student-facing tone. +- Root font-size is 18px and the window min width is 1180px (`.llms/learnings.md`). Render inside the real AppShell chrome (as #273's decorator does) and check 1366×768 and 1280×720. +- The "stray elements" P0: while reading `AnalyzeComponent.tsx`, list what causes it (e.g. leftover `HelpButton`/sidebar pieces, lab.css `main/header` leaks) in the PR so engineering fixes it in integration. Do not edit `AnalyzeComponent.tsx`. +- New files + stories only (plus scoped CSS in `app.global.css` if needed). No Redux, epic, Pyodide or worker changes. + +## Out of scope + +Wiring, statistics beyond what exists, V3 AI analysis, the Clean screen. + +## Done when + +- Every story renders in Storybook inside the real shell chrome, screenshotted at both sizes, with no console errors. +- `npx tsc --noEmit` clean. +- A PR with a review agenda, open questions, the stray-elements diagnosis, and any unmet constraint. diff --git a/src/renderer/components/Analyze/Analyze.stories.tsx b/src/renderer/components/Analyze/Analyze.stories.tsx new file mode 100644 index 00000000..34c629b6 --- /dev/null +++ b/src/renderer/components/Analyze/Analyze.stories.tsx @@ -0,0 +1,276 @@ +import React, { useState } from 'react'; +import type { Decorator, Meta, StoryObj } from '@storybook/react-vite'; +import { MemoryRouter, Link } from 'react-router-dom'; +import { fn } from 'storybook/test'; +import AppShell from '../AppShell/AppShell'; +import SecondaryNavComponent from '../SecondaryNavComponent'; +import AnalyzeOverview from './AnalyzeOverview'; +import AnalyzeErp from './AnalyzeErp'; +import AnalyzeBehavior from './AnalyzeBehavior'; +import { + ANALYZE_STEPS, + ANALYZE_STEPS_BEHAVIOR, + BEHAVIOR_DATASET_OPTIONS, + EEG_DATASET_OPTIONS, + EPOCHS_INFO, + ERP_PLOT_MIME, + MUSE_CHANNEL_INFO, + PSD_PLOT_MIME, + TOPO_PLOT_MIME, + CONDITION_SUMMARIES, + RT_ERRORBAR_PLOT, + ACCURACY_ERRORBAR_PLOT, + EMPTY_BEHAVIOR_PLOT, + FACES_HOUSES_TITLE, +} from './fixtures'; +import { SCREENS } from '../../constants/constants'; +import { Button } from '../ui/button'; +import { Card, CardContent } from '../ui/card'; +import type { AnalyzeOverviewProps } from './AnalyzeOverview'; +import type { AnalyzeErpProps } from './AnalyzeErp'; +import type { AnalyzeBehaviorProps } from './AnalyzeBehavior'; + +type AnalyzeStoryProps = { + modality: 'eeg' | 'behavior'; + activeStep: 'OVERVIEW' | 'ERP' | 'BEHAVIOR'; + isEEGEnabled: boolean; + children: React.ReactNode; +}; + +/** Wrap a section in Analyze chrome: real AppShell, memory router, redesigned secondary nav. */ +const withAnalyzeChrome: Decorator = ( + Story, + { args, parameters } +) => { + const modality = args.modality ?? parameters.modality ?? 'eeg'; + const isEEGEnabled = args.isEEGEnabled ?? (modality === 'eeg'); + const steps = isEEGEnabled ? ANALYZE_STEPS : ANALYZE_STEPS_BEHAVIOR; + const activeStep = args.activeStep ?? 'OVERVIEW'; + return ( + + +
+ +
+ +
+
+ + + ); +}; + +const meta: Meta = { + title: 'Domain/Analyze', + component: ({ children }) => <>{children}, + parameters: { layout: 'fullscreen' }, + decorators: [withAnalyzeChrome], + args: { + modality: 'eeg', + activeStep: 'OVERVIEW', + isEEGEnabled: true, + }, +}; +export default meta; +type Story = StoryObj; + +function OverviewSection(props: Partial) { + const [selected, setSelected] = useState(props.selectedDatasets ?? []); + return ( + + ); +} + +function ErpSection(props: Partial) { + const [channel, setChannel] = useState(props.selectedChannel ?? MUSE_CHANNEL_INFO[0]); + return ( + + ); +} + +function BehaviorSection(props: Partial) { + const [selected, setSelected] = useState(props.selectedDatasets ?? []); + const [dependentVariable, setDependentVariable] = useState( + props.dependentVariable ?? 'Response Time' + ); + const [removeOutliers, setRemoveOutliers] = useState(props.removeOutliers ?? false); + const [showDataPoints, setShowDataPoints] = useState(props.showDataPoints ?? false); + const [displayMode, setDisplayMode] = useState( + props.displayMode ?? 'errorbars' + ); + const plotData = dependentVariable === 'Response Time' ? RT_ERRORBAR_PLOT : ACCURACY_ERRORBAR_PLOT; + return ( + setRemoveOutliers((v) => !v)} + onToggleDataPoints={() => setShowDataPoints((v) => !v)} + onDisplayModeChange={setDisplayMode} + onExport={fn()} + {...props} + /> + ); +} + +/** A01 — Nothing to analyze yet; one action back to Collect. */ +export const NoData: Story = { + args: { modality: 'eeg', activeStep: 'OVERVIEW', isEEGEnabled: true }, + parameters: { modality: 'eeg' }, + render: () => ( +
+

No results yet

+

+ Analyze needs data from a run. Collect a recording first, then come back. +

+
+ ), +}; + +/** A02 — Behavior is ready; Overview/ERP explain the clean-data prerequisite. */ +export const BehaviorBeforeCleaning: Story = { + args: { modality: 'eeg', activeStep: 'OVERVIEW', isEEGEnabled: true }, + parameters: { modality: 'eeg' }, + render: () => ( +
+ + +

Overview and ERP need cleaned EEG

+

+ You have behavioral data, but cleaned EEG is required for the EEG analyses. Clean a + recording first; your behavior results stay available below. +

+ +
+
+ +
+ ), +}; + +/** A03 — Cleaned datasets selected, with PSD and topography visible. */ +export const OverviewResults: Story = { + args: { modality: 'eeg', activeStep: 'OVERVIEW', isEEGEnabled: true }, + render: () => , +}; + +/** A04 — Explicit loading state while PSD/topo compute. */ +export const OverviewLoading: Story = { + args: { modality: 'eeg', activeStep: 'OVERVIEW', isEEGEnabled: true }, + render: () => , +}; + +/** A05 — Error state with one retry action. */ +export const OverviewError: Story = { + args: { modality: 'eeg', activeStep: 'OVERVIEW', isEEGEnabled: true }, + render: () => , +}; + +/** A06 — ERP explainer + results side by side. */ +export const ErpExplainer: Story = { + args: { modality: 'eeg', activeStep: 'ERP', isEEGEnabled: true }, + render: () => , +}; + +/** A07 — Results state with channel and condition legend. */ +export const ErpResults: Story = { + args: { modality: 'eeg', activeStep: 'ERP', isEEGEnabled: true }, + render: () => , +}; + +/** A08 — ERP panel before any channel is selected. */ +export const ErpNoResult: Story = { + args: { modality: 'eeg', activeStep: 'ERP', isEEGEnabled: true }, + render: () => , +}; + +/** A09 — ERP loading spinner in full chrome. */ +export const ErpLoading: Story = { + args: { modality: 'eeg', activeStep: 'ERP', isEEGEnabled: true }, + render: () => , +}; + +/** A10 — ERP error with retry. */ +export const ErpError: Story = { + args: { modality: 'eeg', activeStep: 'ERP', isEEGEnabled: true }, + render: () => , +}; + +/** A11 — Behavior results with controls active. */ +export const BehaviorResults: Story = { + args: { modality: 'eeg', activeStep: 'BEHAVIOR', isEEGEnabled: true }, + render: () => , +}; + +/** A12 — Export success feedback visible. */ +export const BehaviorExport: Story = { + args: { modality: 'eeg', activeStep: 'BEHAVIOR', isEEGEnabled: true }, + render: () => ( + + ), +}; + +/** A13 — Behavior-only workspace: no Overview/ERP tabs, no Clean references. */ +export const BehaviorOnlyWorkspace: Story = { + args: { modality: 'behavior', activeStep: 'BEHAVIOR', isEEGEnabled: false }, + parameters: { modality: 'behavior' }, + render: () => , +}; diff --git a/src/renderer/components/Analyze/AnalyzeBehavior.tsx b/src/renderer/components/Analyze/AnalyzeBehavior.tsx new file mode 100644 index 00000000..161e486b --- /dev/null +++ b/src/renderer/components/Analyze/AnalyzeBehavior.tsx @@ -0,0 +1,207 @@ +import React from 'react'; +import { Card, CardContent, CardHeader } from '../ui/card'; +import { Button } from '../ui/button'; +import { Spinner } from '../ui/spinner'; +import Plot from 'react-plotly.js'; +import type { Data as PlotlyData } from 'plotly.js'; +import type { BehaviorDatasetOption, DisplayMode } from './fixtures'; + +export type ExportStatus = 'idle' | 'success' | 'error'; + +export interface AnalyzeBehaviorProps { + /** Whether this workspace is behavior-only (no EEG component at all). */ + behaviorOnly: boolean; + behaviorDatasets: BehaviorDatasetOption[]; + selectedDatasets: string[]; + dependentVariable: 'Response Time' | 'Accuracy'; + removeOutliers: boolean; + showDataPoints: boolean; + displayMode: DisplayMode; + dataToPlot: PlotlyData[]; + layout: Record; + exportStatus: ExportStatus; + onDatasetChange: (values: string[]) => void; + onDependentVariableChange: (value: 'Response Time' | 'Accuracy') => void; + onToggleOutliers: () => void; + onToggleDataPoints: () => void; + onDisplayModeChange: (mode: DisplayMode) => void; + onExport: () => void; +} + +const DEPENDENT_VARIABLES: { key: string; text: string; value: 'Response Time' | 'Accuracy' }[] = [ + { key: 'Response Time', text: 'Response Time', value: 'Response Time' }, + { key: 'Accuracy', text: 'Accuracy', value: 'Accuracy' }, +]; + +function ExplainBehavior({ mode }: { mode: DisplayMode }) { + const text: Record = { + errorbars: + 'Bar graph: the height of each bar shows the average for that condition, and the error bars show how much the values vary.', + datapoints: + 'Data points: every participant’s value is shown as a dot. This lets you see the spread and any clusters or outliers.', + whiskers: + 'Box plot: the box covers the middle 50% of values, the line inside is the median, and the whiskers show the full range.', + }; + return ( +

+ {text[mode]} +

+ ); +} + +export default function AnalyzeBehavior({ + behaviorOnly, + behaviorDatasets, + selectedDatasets, + dependentVariable, + removeOutliers, + showDataPoints, + displayMode, + dataToPlot, + layout, + exportStatus, + onDatasetChange, + onDependentVariableChange, + onToggleOutliers, + onToggleDataPoints, + onDisplayModeChange, + onExport, +}: AnalyzeBehaviorProps) { + return ( +
+
+

{behaviorOnly ? 'Behavior' : 'Behavioral Data'}

+

+ Look at how participants responded: were they fast or accurate? Choose the visualization that + best tells your research story. +

+
+ +
+ + +

Datasets

+
+ + +

+ Tip: hold Cmd (Mac) or Ctrl (Windows) to select more than one file. +

+
+
+ + + +

Plot options

+
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+ {(['errorbars', 'datapoints', 'whiskers'] as DisplayMode[]).map((mode) => ( + + ))} +
+
+
+
+
+ + + +

{dependentVariable}

+
+ +
+ +
+
+ {dataToPlot.length > 0 ? ( + + ) : ( +
Select at least one behavioral dataset to see the plot.
+ )} +
+
+
+ + + +

Export aggregated data

+
+ +

+ Save a summary CSV with one row per dataset and columns for each condition. +

+
+ + {exportStatus === 'success' && ( + Saved successfully. + )} + {exportStatus === 'error' && ( + Export failed — try again. + )} +
+
+
+
+ ); +} diff --git a/src/renderer/components/Analyze/AnalyzeErp.tsx b/src/renderer/components/Analyze/AnalyzeErp.tsx new file mode 100644 index 00000000..68156e42 --- /dev/null +++ b/src/renderer/components/Analyze/AnalyzeErp.tsx @@ -0,0 +1,181 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { Card, CardContent, CardHeader } from '../ui/card'; +import { Button } from '../ui/button'; +import { Spinner } from '../ui/spinner'; +import { SCREENS } from '../../constants/constants'; +import PyodidePlotWidget from '../PyodidePlotWidget'; +import ClickableHeadDiagramSVG from '../svgs/ClickableHeadDiagramSVG'; +import { cssColorForIndex } from '../../utils/eeg/conditionPalette'; +import type { EegDatasetOption, EpochInfoRow, ConditionSummary } from './fixtures'; + +export type ErpStatus = 'results' | 'noData' | 'loading' | 'error'; + +export interface AnalyzeErpProps { + status: ErpStatus; + title: string; + /** True when cleaned EEG exists and the ERP plot can be requested. */ + eegAvailable: boolean; + eegDatasets: EegDatasetOption[]; + channelInfo: string[]; + erpPlot: { [key: string]: string } | null; + selectedChannel: string; + epochsInfo: EpochInfoRow[]; + conditions: ConditionSummary[]; + onChannelSelect: (channel: string) => void; + onRequestErp: () => void; +} + +function ExplainErp() { + return ( +
+

+ An ERP (event-related potential) is the brain response that lines up with a specific event — + like seeing a face or a house. +

+

+ We average many trials together so the brain signal stands out from random noise. A positive + or negative peak at a particular time tells you when the brain differentiated one condition + from another. +

+
+ ); +} + +export default function AnalyzeErp({ + status, + title, + eegAvailable, + eegDatasets, + channelInfo, + erpPlot, + selectedChannel, + epochsInfo, + conditions, + onChannelSelect, + onRequestErp, +}: AnalyzeErpProps) { + if (status === 'loading') { + return ( +
+ +

Computing ERP…

+

Averaging trials and bootstrapping confidence intervals for the selected channel.

+
+ ); + } + + if (status === 'error') { + return ( +
+

ERP analysis failed

+

The worker could not compute the ERP. Try another channel or check the cleaned dataset.

+ +
+ ); + } + + if (!eegAvailable) { + return ( +
+

ERP needs cleaned EEG

+

+ You need at least one cleaned EEG dataset before you can look at brain responses. Clean your + recordings first. +

+ +
+ ); + } + + if (status === 'noData' || !erpPlot) { + return ( +
+
+

ERP

+ +
+ + +

Select a channel to compute and view the ERP.

+
+
+
+ ); + } + + return ( +
+
+
+

ERP

+ +
+ + +

+ Channel: {selectedChannel} +

+
+ + + +
+ {epochsInfo.length > 0 && ( +
+ {epochsInfo + .filter((row) => row.name !== 'Drop Percentage' && row.name !== 'Total Epochs') + .map((row, i) => ( + + ● {row.name}: {row.value} + + ))} +
+ )} +
+ + + +

Select a channel

+
+ +
+ +
+
+ {channelInfo.map((channel) => ( + + ))} +
+ {conditions.length > 0 && ( +
+

Conditions

+
    + {conditions.map((cond) => ( +
  • + + {cond.label} + {cond.count} trials +
  • + ))} +
+
+ )} +
+
+
+ ); +} diff --git a/src/renderer/components/Analyze/AnalyzeOverview.tsx b/src/renderer/components/Analyze/AnalyzeOverview.tsx new file mode 100644 index 00000000..1faf5875 --- /dev/null +++ b/src/renderer/components/Analyze/AnalyzeOverview.tsx @@ -0,0 +1,141 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { Card, CardContent, CardHeader } from '../ui/card'; +import { Button } from '../ui/button'; +import { Spinner } from '../ui/spinner'; +import { SCREENS } from '../../constants/constants'; +import PyodidePlotWidget from '../PyodidePlotWidget'; +import type { EegDatasetOption, EpochInfoRow } from './fixtures'; + +export type OverviewStatus = 'results' | 'loading' | 'error'; + +export interface AnalyzeOverviewProps { + status: OverviewStatus; + title: string; + eegDatasets: EegDatasetOption[]; + selectedDatasets: string[]; + epochsInfo: EpochInfoRow[]; + psdPlot: { [key: string]: string } | null; + topoPlot: { [key: string]: string } | null; + onDatasetChange: (values: string[]) => void; +} + +function SelectedSummary({ selectedDatasets, epochsInfo }: { selectedDatasets: string[]; epochsInfo: EpochInfoRow[] }) { + if (selectedDatasets.length === 0) return null; + return ( +
+ {selectedDatasets.length} dataset{selectedDatasets.length === 1 ? '' : 's'} selected + {epochsInfo + .filter((row) => row.name !== 'Drop Percentage' && row.name !== 'Total Epochs') + .map((row) => ( + + {row.name}: {row.value} + + ))} +
+ ); +} + +export default function AnalyzeOverview({ + status, + title, + eegDatasets, + selectedDatasets, + epochsInfo, + psdPlot, + topoPlot, + onDatasetChange, +}: AnalyzeOverviewProps) { + if (status === 'loading') { + return ( +
+ +

Loading overview…

+

This may take a few moments while the averaged PSD and topography are computed.

+
+ ); + } + + if (status === 'error') { + return ( +
+

Couldn’t build the overview

+

The analysis worker returned an error. Try a different dataset or clean the recording again.

+ +
+ ); + } + + const hasDatasets = eegDatasets.some((ds) => ds.key !== ''); + + return ( +
+
+

Overview

+

+ Get a bird's-eye view of your cleaned EEG. Select one or more datasets to see averaged power and topography. +

+ {hasDatasets ? ( + + +

Cleaned EEG datasets

+
+ + +
+ +
+
+
+ ) : ( + + +

+ No cleaned EEG yet. Clean a recording first, then it will show up here to analyze. +

+ +
+
+ )} +
+ + {hasDatasets && ( +
+ {psdPlot ? ( + + +

Power by frequency

+
+ + + +
+ ) : null} + {topoPlot ? ( + + +

Voltage across the scalp

+
+ + + +
+ ) : null} +
+ )} +
+ ); +} diff --git a/src/renderer/components/Analyze/fixtures.ts b/src/renderer/components/Analyze/fixtures.ts new file mode 100644 index 00000000..24c241fe --- /dev/null +++ b/src/renderer/components/Analyze/fixtures.ts @@ -0,0 +1,158 @@ +import { EXPERIMENTS, DEVICES } from '../../constants/constants'; +import { cssColorForIndex } from '../../utils/eeg/conditionPalette'; +import type { Data as PlotlyData } from 'plotly.js'; + +/** One available cleaned EEG recording. */ +export interface EegDatasetOption { + key: string; + text: string; + value: string; +} + +/** One available behavioral CSV. */ +export interface BehaviorDatasetOption { + key: string; + text: string; + value: string; +} + +/** Condition summary row returned by Python get_epochs_info. */ +export interface EpochInfoRow { + name: string; + value: number | string; +} + +/** Supported behavior-plot display modes. */ +export type DisplayMode = 'errorbars' | 'datapoints' | 'whiskers'; + +export const FACES_HOUSES_TITLE = 'Faces_Houses_3'; +export const STROOP_TITLE = 'Stroop_2'; + +export const ANALYZE_STEPS = { + OVERVIEW: 'OVERVIEW', + ERP: 'ERP', + BEHAVIOR: 'BEHAVIOR', +} as const; + +export const ANALYZE_STEPS_BEHAVIOR = { + BEHAVIOR: 'BEHAVIOR', +} as const; + +export const MUSE_CHANNEL_INFO = ['TP9', 'AF7', 'AF8', 'TP10']; + +export const EEG_DATASET_OPTIONS: EegDatasetOption[] = [ + { key: 'P01', text: 'P01-Faces_Houses_3-cleaned-epo.fif', value: '/workspaces/Faces_Houses_3/P01/P01-Faces_Houses_3-cleaned-epo.fif' }, + { key: 'P02', text: 'P02-Faces_Houses_3-cleaned-epo.fif', value: '/workspaces/Faces_Houses_3/P02/P02-Faces_Houses_3-cleaned-epo.fif' }, + { key: 'P03', text: 'P03-Faces_Houses_3-cleaned-epo.fif', value: '/workspaces/Faces_Houses_3/P03/P03-Faces_Houses_3-cleaned-epo.fif' }, +]; + +export const BEHAVIOR_DATASET_OPTIONS: BehaviorDatasetOption[] = [ + { key: 'P01', text: 'P01-Faces_Houses_3_behavior.csv', value: '/workspaces/Faces_Houses_3/P01/P01-Faces_Houses_3_behavior.csv' }, + { key: 'P02', text: 'P02-Faces_Houses_3_behavior.csv', value: '/workspaces/Faces_Houses_3/P02/P02-Faces_Houses_3_behavior.csv' }, + { key: 'P03', text: 'P03-Faces_Houses_3_behavior.csv', value: '/workspaces/Faces_Houses_3/P03/P03-Faces_Houses_3_behavior.csv' }, +]; + +export const EPOCHS_INFO: EpochInfoRow[] = [ + { name: 'Faces', value: 124 }, + { name: 'Houses', value: 118 }, + { name: 'Drop Percentage', value: '6.1%' }, + { name: 'Total Epochs', value: 242 }, +]; + +/** Representative SVG for a PSD plot, the same MIME-bundle shape Pyodide returns. */ +export const PSD_PLOT_MIME = { + 'image/svg+xml': `Power Spectral Density — averaged over selected datasetsFrequency (Hz)Power (µV²/Hz)Average PSD`, +}; + +/** Representative SVG for a topography plot. */ +export const TOPO_PLOT_MIME = { + 'image/svg+xml': `Topography — mean voltage across conditionsT7T8FzPz● Active`, +}; + +/** Representative SVG for an ERP plot. */ +export const ERP_PLOT_MIME = { + 'image/svg+xml': `TP9 — Event-Related Potential by conditionTime (s)Amplitude (µV)● Faces● Houses`, +}; + +/** Stable condition labels for display. */ +export const CONDITION_LABELS: Record = { + 1: 'Faces', + 2: 'Houses', +}; + +export interface ConditionSummary { + code: number; + label: string; + count: number; + color: string; +} + +export const CONDITION_SUMMARIES: ConditionSummary[] = [ + { code: 1, label: 'Faces', count: 124, color: cssColorForIndex(0) }, + { code: 2, label: 'Houses', count: 118, color: cssColorForIndex(1) }, +]; + +/** Behavior plot fixtures matching the shape returned by aggregateDataForPlot. */ +export const RT_ERRORBAR_PLOT: { dataToPlot: PlotlyData[]; layout: Record } = { + dataToPlot: [ + { + x: ['P01', 'P02', 'P03'], + y: [482, 521, 505], + name: '1', + type: 'bar', + marker: { color: '#28619E', size: 7 }, + error_y: { type: 'data', array: [23, 31, 19], visible: true }, + }, + { + x: ['P01', 'P02', 'P03'], + y: [512, 548, 533], + name: '2', + type: 'bar', + marker: { color: '#3DBBDB', size: 7 }, + error_y: { type: 'data', array: [27, 35, 22], visible: true }, + }, + ], + layout: { + yaxis: { title: 'Response Time (milliseconds)', zeroline: false, range: [0, 700] }, + barmode: 'group', + title: 'Response Time', + }, +}; + +export const ACCURACY_ERRORBAR_PLOT: { dataToPlot: PlotlyData[]; layout: Record } = { + dataToPlot: [ + { + x: ['P01', 'P02', 'P03'], + y: [94, 91, 96], + name: '1', + type: 'bar', + marker: { color: '#28619E', size: 7 }, + error_y: { type: 'data', array: [2.1, 2.8, 1.5], visible: true }, + }, + { + x: ['P01', 'P02', 'P03'], + y: [89, 87, 92], + name: '2', + type: 'bar', + marker: { color: '#3DBBDB', size: 7 }, + error_y: { type: 'data', array: [2.5, 3.1, 1.9], visible: true }, + }, + ], + layout: { + yaxis: { title: '% correct', zeroline: false, range: [0, 105] }, + barmode: 'group', + title: 'Accuracy', + }, +}; + +export const EMPTY_BEHAVIOR_PLOT: { dataToPlot: PlotlyData[]; layout: Record } = { + dataToPlot: [], + layout: { title: 'Response Time' }, +}; + +export interface AnalyzeWorkspaceProps { + title: string; + type: EXPERIMENTS; + deviceType: DEVICES; + isEEGEnabled: boolean; +} diff --git a/src/renderer/components/svgs/ClickableHeadDiagramSVG.tsx b/src/renderer/components/svgs/ClickableHeadDiagramSVG.tsx index f38f113c..c70eac06 100644 --- a/src/renderer/components/svgs/ClickableHeadDiagramSVG.tsx +++ b/src/renderer/components/svgs/ClickableHeadDiagramSVG.tsx @@ -5,13 +5,12 @@ interface Props { onChannelClick: (arg0: string) => void; } -const SvgComponent = (props: Props) => ( +const SvgComponent = ({ channelinfo, onChannelClick }: Props) => ( Signal Quality Indicator @@ -92,8 +91,8 @@ const SvgComponent = (props: Props) => ( props.onChannelClick('T7')} + visibility={channelinfo.includes('T7') ? 'show' : 'hidden'} + onClick={() => onChannelClick('T7')} > ( props.onChannelClick('FC5')} - visibility={props.channelinfo.includes('FC5') ? 'show' : 'hidden'} + onClick={() => onChannelClick('FC5')} + visibility={channelinfo.includes('FC5') ? 'show' : 'hidden'} > ( props.onChannelClick('FC6')} - visibility={props.channelinfo.includes('FC6') ? 'show' : 'hidden'} + onClick={() => onChannelClick('FC6')} + visibility={channelinfo.includes('FC6') ? 'show' : 'hidden'} > ( props.onChannelClick('F3')} - visibility={props.channelinfo.includes('F3') ? 'show' : 'hidden'} + onClick={() => onChannelClick('F3')} + visibility={channelinfo.includes('F3') ? 'show' : 'hidden'} > ( props.onChannelClick('F4')} - visibility={props.channelinfo.includes('F4') ? 'show' : 'hidden'} + onClick={() => onChannelClick('F4')} + visibility={channelinfo.includes('F4') ? 'show' : 'hidden'} > ( props.onChannelClick('AF3')} - visibility={props.channelinfo.includes('AF3') ? 'show' : 'hidden'} + onClick={() => onChannelClick('AF3')} + visibility={channelinfo.includes('AF3') ? 'show' : 'hidden'} > ( props.onChannelClick('AF4')} - visibility={props.channelinfo.includes('AF4') ? 'show' : 'hidden'} + onClick={() => onChannelClick('AF4')} + visibility={channelinfo.includes('AF4') ? 'show' : 'hidden'} > ( props.onChannelClick('M1')} - visibility={props.channelinfo.includes('M1') ? 'show' : 'hidden'} + onClick={() => onChannelClick('M1')} + visibility={channelinfo.includes('M1') ? 'show' : 'hidden'} > ( props.onChannelClick('P7')} - visibility={props.channelinfo.includes('P7') ? 'show' : 'hidden'} + onClick={() => onChannelClick('P7')} + visibility={channelinfo.includes('P7') ? 'show' : 'hidden'} > ( props.onChannelClick('O1')} - visibility={props.channelinfo.includes('O1') ? 'show' : 'hidden'} + onClick={() => onChannelClick('O1')} + visibility={channelinfo.includes('O1') ? 'show' : 'hidden'} > ( props.onChannelClick('O2')} - visibility={props.channelinfo.includes('O2') ? 'show' : 'hidden'} + onClick={() => onChannelClick('O2')} + visibility={channelinfo.includes('O2') ? 'show' : 'hidden'} > ( props.onChannelClick('P8')} - visibility={props.channelinfo.includes('P8') ? 'show' : 'hidden'} + onClick={() => onChannelClick('P8')} + visibility={channelinfo.includes('P8') ? 'show' : 'hidden'} > ( props.onChannelClick('T8')} - visibility={props.channelinfo.includes('T8') ? 'show' : 'hidden'} + onClick={() => onChannelClick('T8')} + visibility={channelinfo.includes('T8') ? 'show' : 'hidden'} > ( props.onChannelClick('M2')} - visibility={props.channelinfo.includes('M2') ? 'show' : 'hidden'} + onClick={() => onChannelClick('M2')} + visibility={channelinfo.includes('M2') ? 'show' : 'hidden'} > ( props.onChannelClick('TP10')} - visibility={props.channelinfo.includes('TP10') ? 'show' : 'hidden'} + onClick={() => onChannelClick('TP10')} + visibility={channelinfo.includes('TP10') ? 'show' : 'hidden'} > ( props.onChannelClick('Fpz')} - visibility={props.channelinfo.includes('Fpz') ? 'show' : 'hidden'} + onClick={() => onChannelClick('Fpz')} + visibility={channelinfo.includes('Fpz') ? 'show' : 'hidden'} > ( props.onChannelClick('TP9')} - visibility={props.channelinfo.includes('TP9') ? 'show' : 'hidden'} + onClick={() => onChannelClick('TP9')} + visibility={channelinfo.includes('TP9') ? 'show' : 'hidden'} > ( props.onChannelClick('AF7')} - visibility={props.channelinfo.includes('AF7') ? 'show' : 'hidden'} + onClick={() => onChannelClick('AF7')} + visibility={channelinfo.includes('AF7') ? 'show' : 'hidden'} > ( props.onChannelClick('AF8')} - visibility={props.channelinfo.includes('AF8') ? 'show' : 'hidden'} + onClick={() => onChannelClick('AF8')} + visibility={channelinfo.includes('AF8') ? 'show' : 'hidden'} > Date: Thu, 24 Sep 2026 15:42:53 -0400 Subject: [PATCH 2/3] fix(analyze): correct shell props per story state and ERP channel fixture - Make AppShell badges/nextArea configurable per story via parameters. - NoData: nextArea=collect, no badges. - BehaviorBeforeCleaning: only Collect badge (4 recordings), nextArea=clean. - EEG results stories: Collect + Clean badges, nextArea=analyze. - Behavior-only workspace: Collect badge only, nextArea=analyze, Clean tab hidden. - ErpResults: selectedChannel TP9 to match fixture SVG title. - Re-screenshot all stories at 1366x768 and 1280x720; no overflow or console errors. --- .../components/Analyze/Analyze.stories.tsx | 39 ++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/src/renderer/components/Analyze/Analyze.stories.tsx b/src/renderer/components/Analyze/Analyze.stories.tsx index 34c629b6..7dc8c510 100644 --- a/src/renderer/components/Analyze/Analyze.stories.tsx +++ b/src/renderer/components/Analyze/Analyze.stories.tsx @@ -34,6 +34,10 @@ type AnalyzeStoryProps = { modality: 'eeg' | 'behavior'; activeStep: 'OVERVIEW' | 'ERP' | 'BEHAVIOR'; isEEGEnabled: boolean; + /** Workspace badges shown in AppShell; derived from the story's data state. */ + badges?: { collect?: string[]; clean?: string[] }; + /** Recommended next area on the AppShell workflow nav. */ + nextArea?: 'prepare' | 'collect' | 'clean' | 'analyze'; children: React.ReactNode; }; @@ -46,6 +50,8 @@ const withAnalyzeChrome: Decorator = ( const isEEGEnabled = args.isEEGEnabled ?? (modality === 'eeg'); const steps = isEEGEnabled ? ANALYZE_STEPS : ANALYZE_STEPS_BEHAVIOR; const activeStep = args.activeStep ?? 'OVERVIEW'; + const badges = args.badges ?? parameters.badges; + const nextArea = args.nextArea ?? parameters.nextArea; return ( = ( }} device="connected" deviceName="Muse 2" - badges={ - isEEGEnabled - ? { collect: ['4 recordings'], clean: ['3 cleaned'] } - : { collect: ['4 recordings'] } - } + badges={badges} + nextArea={nextArea} >
) { /** A01 — Nothing to analyze yet; one action back to Collect. */ export const NoData: Story = { args: { modality: 'eeg', activeStep: 'OVERVIEW', isEEGEnabled: true }, - parameters: { modality: 'eeg' }, + parameters: { modality: 'eeg', nextArea: 'collect' }, render: () => (

No results yet

@@ -183,7 +186,11 @@ export const NoData: Story = { /** A02 — Behavior is ready; Overview/ERP explain the clean-data prerequisite. */ export const BehaviorBeforeCleaning: Story = { args: { modality: 'eeg', activeStep: 'OVERVIEW', isEEGEnabled: true }, - parameters: { modality: 'eeg' }, + parameters: { + modality: 'eeg', + badges: { collect: ['4 recordings'] }, + nextArea: 'clean', + }, render: () => (
@@ -206,60 +213,70 @@ export const BehaviorBeforeCleaning: Story = { /** A03 — Cleaned datasets selected, with PSD and topography visible. */ export const OverviewResults: Story = { args: { modality: 'eeg', activeStep: 'OVERVIEW', isEEGEnabled: true }, + parameters: { modality: 'eeg', badges: { collect: ['4 recordings'], clean: ['3 cleaned'] }, nextArea: 'analyze' }, render: () => , }; /** A04 — Explicit loading state while PSD/topo compute. */ export const OverviewLoading: Story = { args: { modality: 'eeg', activeStep: 'OVERVIEW', isEEGEnabled: true }, + parameters: { modality: 'eeg', badges: { collect: ['4 recordings'], clean: ['3 cleaned'] }, nextArea: 'analyze' }, render: () => , }; /** A05 — Error state with one retry action. */ export const OverviewError: Story = { args: { modality: 'eeg', activeStep: 'OVERVIEW', isEEGEnabled: true }, + parameters: { modality: 'eeg', badges: { collect: ['4 recordings'], clean: ['3 cleaned'] }, nextArea: 'analyze' }, render: () => , }; /** A06 — ERP explainer + results side by side. */ export const ErpExplainer: Story = { args: { modality: 'eeg', activeStep: 'ERP', isEEGEnabled: true }, + parameters: { modality: 'eeg', badges: { collect: ['4 recordings'], clean: ['3 cleaned'] }, nextArea: 'analyze' }, render: () => , }; /** A07 — Results state with channel and condition legend. */ export const ErpResults: Story = { args: { modality: 'eeg', activeStep: 'ERP', isEEGEnabled: true }, - render: () => , + parameters: { modality: 'eeg', badges: { collect: ['4 recordings'], clean: ['3 cleaned'] }, nextArea: 'analyze' }, + render: () => , }; /** A08 — ERP panel before any channel is selected. */ export const ErpNoResult: Story = { args: { modality: 'eeg', activeStep: 'ERP', isEEGEnabled: true }, + parameters: { modality: 'eeg', badges: { collect: ['4 recordings'], clean: ['3 cleaned'] }, nextArea: 'analyze' }, render: () => , }; /** A09 — ERP loading spinner in full chrome. */ export const ErpLoading: Story = { args: { modality: 'eeg', activeStep: 'ERP', isEEGEnabled: true }, + parameters: { modality: 'eeg', badges: { collect: ['4 recordings'], clean: ['3 cleaned'] }, nextArea: 'analyze' }, render: () => , }; /** A10 — ERP error with retry. */ export const ErpError: Story = { args: { modality: 'eeg', activeStep: 'ERP', isEEGEnabled: true }, + parameters: { modality: 'eeg', badges: { collect: ['4 recordings'], clean: ['3 cleaned'] }, nextArea: 'analyze' }, render: () => , }; /** A11 — Behavior results with controls active. */ export const BehaviorResults: Story = { args: { modality: 'eeg', activeStep: 'BEHAVIOR', isEEGEnabled: true }, + parameters: { modality: 'eeg', badges: { collect: ['4 recordings'], clean: ['3 cleaned'] }, nextArea: 'analyze' }, render: () => , }; /** A12 — Export success feedback visible. */ export const BehaviorExport: Story = { args: { modality: 'eeg', activeStep: 'BEHAVIOR', isEEGEnabled: true }, + parameters: { modality: 'eeg', badges: { collect: ['4 recordings'], clean: ['3 cleaned'] }, nextArea: 'analyze' }, render: () => ( , }; From 1a435313de13fdb052fa76814f3b3daaa80de8bb Mon Sep 17 00:00:00 2001 From: jdpigeon Date: Thu, 24 Sep 2026 16:28:23 -0400 Subject: [PATCH 3/3] design(analyze): rail layout, graph-first ERP with walkthrough, real topo shape - Controls rail + results on every tab; plots fit 1366x768 and 1280x720 without scroll. - CompareOverviewPlotsFirst: plots on top, recordings strip below (review comparison). - Topography fixture now mirrors plot_topo (per-sensor ERPs on a head), built from example epochs. - ERP: graph and sensor picker first; 4-step walkthrough drawn as React SVG from EpochArraysMeta-shaped example epochs, averages computed with meanTrace. - Behavior: rail holds recordings, plot options and export; plot computed by the real aggregateDataForPlot from example CSV rows; export success and failure stories. - Scoped .bw-analyze-plot CSS lets PyodidePlotWidget images shrink to their card. --- src/renderer/app.global.css | 24 + .../components/Analyze/Analyze.stories.tsx | 389 ++++++++------- .../components/Analyze/AnalyzeBehavior.tsx | 350 ++++++------- .../components/Analyze/AnalyzeErp.tsx | 463 ++++++++++++------ .../components/Analyze/AnalyzeOverview.tsx | 279 +++++++---- .../components/Analyze/AnalyzeParts.tsx | 277 +++++++++++ .../components/Analyze/ErpTraceChart.tsx | 288 +++++++++++ src/renderer/components/Analyze/fixtures.ts | 421 +++++++++++----- 8 files changed, 1764 insertions(+), 727 deletions(-) create mode 100644 src/renderer/components/Analyze/AnalyzeParts.tsx create mode 100644 src/renderer/components/Analyze/ErpTraceChart.tsx diff --git a/src/renderer/app.global.css b/src/renderer/app.global.css index 01809b3a..3c00e667 100644 --- a/src/renderer/app.global.css +++ b/src/renderer/app.global.css @@ -376,6 +376,30 @@ p { } } +/* Analyze plot cards (components/Analyze/AnalyzeParts PlotFigure): the + PyodidePlotWidget image shrinks to the card instead of growing the page, + keeping its aspect ratio; the Save buttons stay under it. */ +.bw-analyze-plot { + display: flex; + flex: 1 1 0; + min-height: 0; +} + +.bw-analyze-plot > div { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + min-height: 0; + padding: 6px 0 0; +} + +.bw-analyze-plot img { + flex: 1 1 0; + min-height: 0; + object-fit: contain; +} + li { list-style: none; } diff --git a/src/renderer/components/Analyze/Analyze.stories.tsx b/src/renderer/components/Analyze/Analyze.stories.tsx index 7dc8c510..5a2d5b04 100644 --- a/src/renderer/components/Analyze/Analyze.stories.tsx +++ b/src/renderer/components/Analyze/Analyze.stories.tsx @@ -1,297 +1,340 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import type { Decorator, Meta, StoryObj } from '@storybook/react-vite'; -import { MemoryRouter, Link } from 'react-router-dom'; +import { MemoryRouter } from 'react-router-dom'; import { fn } from 'storybook/test'; import AppShell from '../AppShell/AppShell'; +import BlockedAreaEmptyState from '../AppShell/BlockedAreaEmptyState'; +import type { Area } from '../AppShell/types'; import SecondaryNavComponent from '../SecondaryNavComponent'; -import AnalyzeOverview from './AnalyzeOverview'; -import AnalyzeErp from './AnalyzeErp'; -import AnalyzeBehavior from './AnalyzeBehavior'; +import { aggregateDataForPlot } from '../../utils/behavior/compute'; +import AnalyzeOverview, { AnalyzeOverviewProps } from './AnalyzeOverview'; +import AnalyzeErp, { AnalyzeErpProps, ErpWalkthroughStep } from './AnalyzeErp'; +import AnalyzeBehavior, { + AnalyzeBehaviorProps, + DependentVariable, + DisplayMode, +} from './AnalyzeBehavior'; import { - ANALYZE_STEPS, - ANALYZE_STEPS_BEHAVIOR, + BEHAVIOR_CSVS, BEHAVIOR_DATASET_OPTIONS, + BehaviorPlot, EEG_DATASET_OPTIONS, EPOCHS_INFO, - ERP_PLOT_MIME, + EXAMPLE_EPOCH_ARRAYS, + FACES_HOUSES_CODE_TO_LABEL, MUSE_CHANNEL_INFO, PSD_PLOT_MIME, TOPO_PLOT_MIME, - CONDITION_SUMMARIES, - RT_ERRORBAR_PLOT, - ACCURACY_ERRORBAR_PLOT, - EMPTY_BEHAVIOR_PLOT, - FACES_HOUSES_TITLE, + WORKSPACE_TITLE, + erpPlotMime, } from './fixtures'; -import { SCREENS } from '../../constants/constants'; -import { Button } from '../ui/button'; -import { Card, CardContent } from '../ui/card'; -import type { AnalyzeOverviewProps } from './AnalyzeOverview'; -import type { AnalyzeErpProps } from './AnalyzeErp'; -import type { AnalyzeBehaviorProps } from './AnalyzeBehavior'; -type AnalyzeStoryProps = { - modality: 'eeg' | 'behavior'; - activeStep: 'OVERVIEW' | 'ERP' | 'BEHAVIOR'; - isEEGEnabled: boolean; - /** Workspace badges shown in AppShell; derived from the story's data state. */ - badges?: { collect?: string[]; clean?: string[] }; - /** Recommended next area on the AppShell workflow nav. */ - nextArea?: 'prepare' | 'collect' | 'clean' | 'analyze'; - children: React.ReactNode; +type Tab = 'OVERVIEW' | 'ERP' | 'BEHAVIOR'; + +interface ChromeParameters { + modality?: 'eeg' | 'behavior'; + tab?: Tab; + /** Shell badges for the story's data state (`useWorkspaceProgress.summarize`). */ + badges?: Partial>; + nextArea?: Area; + /** WorkspaceAreaGate replaces the whole screen, tab bar included. */ + gated?: boolean; +} + +/** Shell facts for an EEG workspace with 4 recordings, 3 of them cleaned. */ +const CLEANED: ChromeParameters = { + badges: { collect: ['4 recordings'], clean: ['3 cleaned'] }, + nextArea: 'analyze', }; -/** Wrap a section in Analyze chrome: real AppShell, memory router, redesigned secondary nav. */ -const withAnalyzeChrome: Decorator = ( - Story, - { args, parameters } -) => { - const modality = args.modality ?? parameters.modality ?? 'eeg'; - const isEEGEnabled = args.isEEGEnabled ?? (modality === 'eeg'); - const steps = isEEGEnabled ? ANALYZE_STEPS : ANALYZE_STEPS_BEHAVIOR; - const activeStep = args.activeStep ?? 'OVERVIEW'; - const badges = args.badges ?? parameters.badges; - const nextArea = args.nextArea ?? parameters.nextArea; +/** Shell facts for an EEG workspace with 4 recordings and nothing cleaned yet. */ +const NOT_CLEANED: ChromeParameters = { + badges: { collect: ['4 recordings'] }, + nextArea: 'clean', +}; + +/** + * Storybook deep-merges object parameters, so stories set `badges` whole and + * the cleaned-workspace default lives here rather than in `meta.parameters`. + * + * The real chrome around Analyze: AppShell at `analyze` with the story's + * workspace facts, then Analyze's tab bar (Overview / ERP / Behavior, or + * Behavior only). The tab body fills the rest without page scroll. + */ +const withAnalyzeChrome: Decorator = (Story, { parameters }) => { + const { + modality = 'eeg', + tab = 'OVERVIEW', + badges = CLEANED.badges, + nextArea = CLEANED.nextArea, + gated, + } = parameters as ChromeParameters; + const eeg = modality === 'eeg'; return ( -
- -
- + {gated ? ( + + ) : ( +
+ +
+ +
-
+ )} ); }; -const meta: Meta = { +const meta: Meta = { title: 'Domain/Analyze', - component: ({ children }) => <>{children}, parameters: { layout: 'fullscreen' }, decorators: [withAnalyzeChrome], - args: { - modality: 'eeg', - activeStep: 'OVERVIEW', - isEEGEnabled: true, - }, }; export default meta; -type Story = StoryObj; +type Story = StoryObj; -function OverviewSection(props: Partial) { - const [selected, setSelected] = useState(props.selectedDatasets ?? []); +/** Overview with local dataset selection; everything else fixed by the story. */ +function Overview(props: Partial) { + const [selected, setSelected] = useState( + props.selectedDatasets ?? [EEG_DATASET_OPTIONS[0].value] + ); return ( ); } -function ErpSection(props: Partial) { - const [channel, setChannel] = useState(props.selectedChannel ?? MUSE_CHANNEL_INFO[0]); +/** ERP with a live sensor pick and walkthrough; the plot follows the sensor. */ +function Erp(props: Partial) { + const [channel, setChannel] = useState( + props.selectedChannel === undefined ? 'TP9' : props.selectedChannel + ); + const [step, setStep] = useState( + props.walkthroughStep ?? 0 + ); return ( ); } -function BehaviorSection(props: Partial) { - const [selected, setSelected] = useState(props.selectedDatasets ?? []); - const [dependentVariable, setDependentVariable] = useState( - props.dependentVariable ?? 'Response Time' +/** Behavior plotted by the real `aggregateDataForPlot` from the example CSVs. */ +function Behavior(props: Partial) { + const [selected, setSelected] = useState( + props.selectedDatasets ?? BEHAVIOR_DATASET_OPTIONS.slice(0, 3).map((o) => o.value) ); - const [removeOutliers, setRemoveOutliers] = useState(props.removeOutliers ?? false); - const [showDataPoints, setShowDataPoints] = useState(props.showDataPoints ?? false); - const [displayMode, setDisplayMode] = useState( - props.displayMode ?? 'errorbars' + const [dependentVariable, setDependentVariable] = + useState('Response Time'); + const [removeOutliers, setRemoveOutliers] = useState(true); + const [showDataPoints, setShowDataPoints] = useState(false); + const [displayMode, setDisplayMode] = useState('errorbars'); + const plot = useMemo( + () => + (aggregateDataForPlot( + selected.map((path) => BEHAVIOR_CSVS[path]), + dependentVariable, + removeOutliers, + showDataPoints, + displayMode + ) as BehaviorPlot | undefined) ?? null, + [selected, dependentVariable, removeOutliers, showDataPoints, displayMode] ); - const plotData = dependentVariable === 'Response Time' ? RT_ERRORBAR_PLOT : ACCURACY_ERRORBAR_PLOT; return ( setRemoveOutliers((v) => !v)} onToggleDataPoints={() => setShowDataPoints((v) => !v)} onDisplayModeChange={setDisplayMode} - onExport={fn()} - {...props} /> ); } -/** A01 — Nothing to analyze yet; one action back to Collect. */ +/** A01 — No data at all: WorkspaceAreaGate's blocked state, one action to Collect. */ export const NoData: Story = { - args: { modality: 'eeg', activeStep: 'OVERVIEW', isEEGEnabled: true }, - parameters: { modality: 'eeg', nextArea: 'collect' }, + parameters: { gated: true, badges: {}, nextArea: 'collect' }, render: () => ( -
-

No results yet

-

- Analyze needs data from a run. Collect a recording first, then come back. -

-
+ ), }; -/** A02 — Behavior is ready; Overview/ERP explain the clean-data prerequisite. */ +/** A02 — Behavior is complete, no EEG is cleaned: Overview explains why and offers Go to Clean. Clean is Next in the shell. */ export const BehaviorBeforeCleaning: Story = { - args: { modality: 'eeg', activeStep: 'OVERVIEW', isEEGEnabled: true }, - parameters: { - modality: 'eeg', - badges: { collect: ['4 recordings'] }, - nextArea: 'clean', - }, - render: () => ( -
- - -

Overview and ERP need cleaned EEG

-

- You have behavioral data, but cleaned EEG is required for the EEG analyses. Clean a - recording first; your behavior results stay available below. -

- -
-
- -
- ), + parameters: { ...NOT_CLEANED, tab: 'OVERVIEW' }, + render: () => , }; -/** A03 — Cleaned datasets selected, with PSD and topography visible. */ +/** A02b — Same workspace, ERP tab: the same prerequisite, one Go to Clean. */ +export const ErpCleanRequired: Story = { + parameters: { ...NOT_CLEANED, tab: 'ERP' }, + render: () => , +}; + +/** A02c — Same workspace, Behavior tab: fully usable before cleaning. */ +export const BehaviorBeforeCleaningBehaviorTab: Story = { + parameters: { ...NOT_CLEANED, tab: 'BEHAVIOR' }, + render: () => , +}; + +/** A03 — Rail: tick recordings (P01 here, whose example epochs feed every EEG story), see who's included. Results: PSD and per-sensor ERPs side by side. */ export const OverviewResults: Story = { - args: { modality: 'eeg', activeStep: 'OVERVIEW', isEEGEnabled: true }, - parameters: { modality: 'eeg', badges: { collect: ['4 recordings'], clean: ['3 cleaned'] }, nextArea: 'analyze' }, - render: () => , + render: () => , }; -/** A04 — Explicit loading state while PSD/topo compute. */ +/** A03b — Comparison for review: plots on top, a compact recordings strip below. One of A03/A03b gets deleted. */ +export const CompareOverviewPlotsFirst: Story = { + render: () => , +}; + +/** A04 — Loading in the results area; the rail stays usable. */ export const OverviewLoading: Story = { - args: { modality: 'eeg', activeStep: 'OVERVIEW', isEEGEnabled: true }, - parameters: { modality: 'eeg', badges: { collect: ['4 recordings'], clean: ['3 cleaned'] }, nextArea: 'analyze' }, - render: () => , + render: () => , }; -/** A05 — Error state with one retry action. */ +/** A05 — Analysis error in words, with Try again. */ export const OverviewError: Story = { - args: { modality: 'eeg', activeStep: 'OVERVIEW', isEEGEnabled: true }, - parameters: { modality: 'eeg', badges: { collect: ['4 recordings'], clean: ['3 cleaned'] }, nextArea: 'analyze' }, - render: () => , + render: () => , }; -/** A06 — ERP explainer + results side by side. */ -export const ErpExplainer: Story = { - args: { modality: 'eeg', activeStep: 'ERP', isEEGEnabled: true }, - parameters: { modality: 'eeg', badges: { collect: ['4 recordings'], clean: ['3 cleaned'] }, nextArea: 'analyze' }, - render: () => , +/** E01 — Graph first: the MNE ERP for the picked sensor, then an invitation to the walkthrough. Uses example epochs. */ +export const ErpResults: Story = { + parameters: { tab: 'ERP' }, + render: () => , }; -/** A07 — Results state with channel and condition legend. */ -export const ErpResults: Story = { - args: { modality: 'eeg', activeStep: 'ERP', isEEGEnabled: true }, - parameters: { modality: 'eeg', badges: { collect: ['4 recordings'], clean: ['3 cleaned'] }, nextArea: 'analyze' }, - render: () => , +/** E02 — Walkthrough 1/4, example epochs: every trial as a faint line, one highlighted. */ +export const ErpWalkthroughStep1: Story = { + parameters: { tab: 'ERP' }, + render: () => , +}; + +/** E03 — Walkthrough 2/4, example epochs: the mean of all trials, computed from the arrays. */ +export const ErpWalkthroughStep2: Story = { + parameters: { tab: 'ERP' }, + render: () => , +}; + +/** E04 — Walkthrough 3/4, example epochs: one mean per image type; solid vs dashed plus end labels. */ +export const ErpWalkthroughStep3: Story = { + parameters: { tab: 'ERP' }, + render: () => , +}; + +/** E05 — Walkthrough 4/4, example epochs: the ~170 ms window, worded as "may". */ +export const ErpWalkthroughStep4: Story = { + parameters: { tab: 'ERP' }, + render: () => , }; -/** A08 — ERP panel before any channel is selected. */ +/** E06 — No sensor picked yet. */ export const ErpNoResult: Story = { - args: { modality: 'eeg', activeStep: 'ERP', isEEGEnabled: true }, - parameters: { modality: 'eeg', badges: { collect: ['4 recordings'], clean: ['3 cleaned'] }, nextArea: 'analyze' }, - render: () => , + parameters: { tab: 'ERP' }, + render: () => , }; -/** A09 — ERP loading spinner in full chrome. */ +/** E07 — ERP computing for the picked sensor. */ export const ErpLoading: Story = { - args: { modality: 'eeg', activeStep: 'ERP', isEEGEnabled: true }, - parameters: { modality: 'eeg', badges: { collect: ['4 recordings'], clean: ['3 cleaned'] }, nextArea: 'analyze' }, - render: () => , + parameters: { tab: 'ERP' }, + render: () => , }; -/** A10 — ERP error with retry. */ +/** E08 — ERP failed, with Try again. */ export const ErpError: Story = { - args: { modality: 'eeg', activeStep: 'ERP', isEEGEnabled: true }, - parameters: { modality: 'eeg', badges: { collect: ['4 recordings'], clean: ['3 cleaned'] }, nextArea: 'analyze' }, - render: () => , + parameters: { tab: 'ERP' }, + render: () => , }; -/** A11 — Behavior results with controls active. */ +/** B01 — Rail: recordings, measure, plot type, outliers. Plot beside it, with what it shows. */ export const BehaviorResults: Story = { - args: { modality: 'eeg', activeStep: 'BEHAVIOR', isEEGEnabled: true }, - parameters: { modality: 'eeg', badges: { collect: ['4 recordings'], clean: ['3 cleaned'] }, nextArea: 'analyze' }, - render: () => , + parameters: { tab: 'BEHAVIOR' }, + render: () => , }; -/** A12 — Export success feedback visible. */ +/** B02 — Export succeeded: said in words next to the button. */ export const BehaviorExport: Story = { - args: { modality: 'eeg', activeStep: 'BEHAVIOR', isEEGEnabled: true }, - parameters: { modality: 'eeg', badges: { collect: ['4 recordings'], clean: ['3 cleaned'] }, nextArea: 'analyze' }, - render: () => ( - - ), + parameters: { tab: 'BEHAVIOR' }, + render: () => , +}; + +/** B03 — Export failed: said in words, nothing saved. */ +export const BehaviorExportFailed: Story = { + parameters: { tab: 'BEHAVIOR' }, + render: () => , }; -/** A13 — Behavior-only workspace: no Overview/ERP tabs, no Clean references. */ +/** B04 — Behavior-only workspace: Behavior tab only, no Clean area, no EEG copy. */ export const BehaviorOnlyWorkspace: Story = { - args: { modality: 'behavior', activeStep: 'BEHAVIOR', isEEGEnabled: false }, parameters: { modality: 'behavior', + tab: 'BEHAVIOR', badges: { collect: ['4 recordings'] }, nextArea: 'analyze', }, - render: () => , + render: () => , }; diff --git a/src/renderer/components/Analyze/AnalyzeBehavior.tsx b/src/renderer/components/Analyze/AnalyzeBehavior.tsx index 161e486b..21bcea0d 100644 --- a/src/renderer/components/Analyze/AnalyzeBehavior.tsx +++ b/src/renderer/components/Analyze/AnalyzeBehavior.tsx @@ -1,64 +1,83 @@ import React from 'react'; -import { Card, CardContent, CardHeader } from '../ui/card'; -import { Button } from '../ui/button'; -import { Spinner } from '../ui/spinner'; import Plot from 'react-plotly.js'; -import type { Data as PlotlyData } from 'plotly.js'; -import type { BehaviorDatasetOption, DisplayMode } from './fixtures'; +import { Button } from '../ui/button'; +import { cn } from '../ui/utils'; +import { + AnalyzeLayout, + DatasetChecklist, + RailSection, + ResultStatus, + Segmented, +} from './AnalyzeParts'; +import type { BehaviorPlot, DatasetOption } from './fixtures'; -export type ExportStatus = 'idle' | 'success' | 'error'; +/** `aggregateDataForPlot`'s dependent variables. */ +export type DependentVariable = 'Response Time' | 'Accuracy'; + +/** `aggregateDataForPlot`'s display modes. */ +export type DisplayMode = 'errorbars' | 'datapoints' | 'whiskers'; export interface AnalyzeBehaviorProps { - /** Whether this workspace is behavior-only (no EEG component at all). */ - behaviorOnly: boolean; - behaviorDatasets: BehaviorDatasetOption[]; + behaviorDatasets: DatasetOption[]; selectedDatasets: string[]; - dependentVariable: 'Response Time' | 'Accuracy'; + dependentVariable: DependentVariable; removeOutliers: boolean; showDataPoints: boolean; displayMode: DisplayMode; - dataToPlot: PlotlyData[]; - layout: Record; - exportStatus: ExportStatus; - onDatasetChange: (values: string[]) => void; - onDependentVariableChange: (value: 'Response Time' | 'Accuracy') => void; - onToggleOutliers: () => void; - onToggleDataPoints: () => void; - onDisplayModeChange: (mode: DisplayMode) => void; - onExport: () => void; + /** `aggregateDataForPlot` output for the current choices; null before any selection. */ + plot: BehaviorPlot | null; + /** Result of the last `storeAggregatedBehaviorData`. */ + exportStatus: 'idle' | 'saving' | 'success' | 'error'; + onDatasetChange(values: string[]): void; + onDependentVariableChange(value: DependentVariable): void; + onToggleOutliers(): void; + onToggleDataPoints(): void; + onDisplayModeChange(mode: DisplayMode): void; + onExport(): void; } -const DEPENDENT_VARIABLES: { key: string; text: string; value: 'Response Time' | 'Accuracy' }[] = [ - { key: 'Response Time', text: 'Response Time', value: 'Response Time' }, - { key: 'Accuracy', text: 'Accuracy', value: 'Accuracy' }, -]; +/** What each plot type communicates, per measure. */ +const CAPTIONS: Record> = { + errorbars: { + 'Response Time': + 'Each bar is one participant’s average time for that image type. The thin line on top shows how precise that average is.', + Accuracy: + 'Each bar is one participant’s percent correct for that image type. Taller means more correct answers.', + }, + datapoints: { + 'Response Time': + 'Each dot is one correct trial. You can see how spread out the times are, and spot unusually fast or slow responses.', + Accuracy: + 'Each dot is one participant’s percent correct for that image type.', + }, + whiskers: { + 'Response Time': + 'The box holds the middle half of the times and the line inside is the median. The whiskers reach the rest, so you can compare spread as well as the middle.', + Accuracy: + 'The box holds the middle half of the scores and the line inside is the median, so you can compare spread as well as the middle.', + }, +}; -function ExplainBehavior({ mode }: { mode: DisplayMode }) { - const text: Record = { - errorbars: - 'Bar graph: the height of each bar shows the average for that condition, and the error bars show how much the values vary.', - datapoints: - 'Data points: every participant’s value is shown as a dot. This lets you see the spread and any clusters or outliers.', - whiskers: - 'Box plot: the box covers the middle 50% of values, the line inside is the median, and the whiskers show the full range.', - }; - return ( -

- {text[mode]} -

- ); -} +const EXPORT_FEEDBACK = { + saving: 'Saving…', + success: '✓ Saved to this workspace’s Data folder.', + error: + '✕ Couldn’t export: the selected recordings could not be read. Nothing was saved.', +}; +/** + * Behavior tab: choose complete behavioral recordings and how to plot them, + * read the plot beside the controls, and export a per-participant summary. + * Available before any EEG is cleaned. Pure props. + */ export default function AnalyzeBehavior({ - behaviorOnly, behaviorDatasets, selectedDatasets, dependentVariable, removeOutliers, showDataPoints, displayMode, - dataToPlot, - layout, + plot, exportStatus, onDatasetChange, onDependentVariableChange, @@ -67,141 +86,124 @@ export default function AnalyzeBehavior({ onDisplayModeChange, onExport, }: AnalyzeBehaviorProps) { - return ( -
-
-

{behaviorOnly ? 'Behavior' : 'Behavioral Data'}

-

- Look at how participants responded: were they fast or accurate? Choose the visualization that - best tells your research story. -

-
- -
- - -

Datasets

-
- - -

- Tip: hold Cmd (Mac) or Ctrl (Windows) to select more than one file. -

-
-
- - - -

Plot options

-
- -
-
- - -
-
- - -
-
+ const rail = ( + <> + + + + + + + + + + + +
+ {exportStatus === 'idle' + ? 'One row per participant.' + : EXPORT_FEEDBACK[exportStatus]} +
+
+ + ); -
- -
- {(['errorbars', 'datapoints', 'whiskers'] as DisplayMode[]).map((mode) => ( - - ))} -
+ return ( + + {selectedDatasets.length === 0 || !plot ? ( + + ) : ( +
+
+

+ {dependentVariable} by participant +

+
+ {CAPTIONS[displayMode][dependentVariable]}
- - -
- - - -

{dependentVariable}

-
- -
- -
-
- {dataToPlot.length > 0 ? ( - - ) : ( -
Select at least one behavioral dataset to see the plot.
- )} -
-
-
- - - -

Export aggregated data

-
- -

- Save a summary CSV with one row per dataset and columns for each condition. -

-
- - {exportStatus === 'success' && ( - Saved successfully. - )} - {exportStatus === 'error' && ( - Export failed — try again. - )} + +
+
- - -
+ + )} + ); } diff --git a/src/renderer/components/Analyze/AnalyzeErp.tsx b/src/renderer/components/Analyze/AnalyzeErp.tsx index 68156e42..4312037f 100644 --- a/src/renderer/components/Analyze/AnalyzeErp.tsx +++ b/src/renderer/components/Analyze/AnalyzeErp.tsx @@ -1,181 +1,346 @@ -import React from 'react'; -import { Link } from 'react-router-dom'; -import { Card, CardContent, CardHeader } from '../ui/card'; -import { Button } from '../ui/button'; -import { Spinner } from '../ui/spinner'; -import { SCREENS } from '../../constants/constants'; -import PyodidePlotWidget from '../PyodidePlotWidget'; -import ClickableHeadDiagramSVG from '../svgs/ClickableHeadDiagramSVG'; +import React, { useEffect, useRef } from 'react'; +import type { EpochArraysMeta } from '../../actions'; import { cssColorForIndex } from '../../utils/eeg/conditionPalette'; -import type { EegDatasetOption, EpochInfoRow, ConditionSummary } from './fixtures'; +import ClickableHeadDiagramSVG from '../svgs/ClickableHeadDiagramSVG'; +import { Button } from '../ui/button'; +import { cn } from '../ui/utils'; +import { + AnalyzeLayout, + EPOCH_TOTAL_ROWS, + CleanRequired, + PlotFigure, + RailSection, + ResultStatus, + railLabel, +} from './AnalyzeParts'; +import ErpTraceChart, { CONDITION_DASH, ErpChartStep } from './ErpTraceChart'; +import type { EpochInfoRow } from './fixtures'; -export type ErpStatus = 'results' | 'noData' | 'loading' | 'error'; +/** 0 shows the MNE ERP plot; 1–4 are walkthrough steps drawn from the epoch arrays. */ +export type ErpWalkthroughStep = 0 | ErpChartStep; export interface AnalyzeErpProps { - status: ErpStatus; - title: string; - /** True when cleaned EEG exists and the ERP plot can be requested. */ + /** `empty` until a sensor has been picked and its ERP requested. */ + status: 'results' | 'loading' | 'error' | 'empty'; + /** False until the workspace has at least one cleaned recording. */ eegAvailable: boolean; - eegDatasets: EegDatasetOption[]; + workspaceTitle: string; + /** `pyodide.channelInfo`. */ channelInfo: string[]; + selectedChannel: string | null; + /** `pyodide.erpPlot` for `selectedChannel`. */ erpPlot: { [key: string]: string } | null; - selectedChannel: string; + /** `pyodide.epochsInfo`; condition rows feed the legend. */ epochsInfo: EpochInfoRow[]; - conditions: ConditionSummary[]; - onChannelSelect: (channel: string) => void; - onRequestErp: () => void; + /** Cleaned epochs as `get_epochs_arrays` ships them. Null hides the walkthrough. */ + epochArrays: { buffer: ArrayBuffer; meta: EpochArraysMeta } | null; + /** Marker registry `codeToLabel`, so the walkthrough says Face/House, not 1/2. */ + codeToLabel: Record; + walkthroughStep: ErpWalkthroughStep; + onChannelSelect(channel: string): void; + onWalkthroughStepChange(step: ErpWalkthroughStep): void; + onRetry(): void; + onGoToClean(): void; +} + +/** Walkthrough copy. `n` is the trial count, `ch` the sensor. */ +const WALKTHROUGH: Record< + ErpChartStep, + { title: string; body: (ch: string, n: number) => string } +> = { + 1: { + title: 'Every line is one trial', + body: (ch) => + `Each faint line is the voltage at ${ch} during one trial, from just before an image appeared until 0.8 seconds after. On its own, one trial is mostly noise: blinks, muscles and background rhythms.`, + }, + 2: { + title: 'Average the trials together', + body: (_, n) => + `Noise goes up and down at random, so averaging ${n} trials mostly cancels it out. What is left happened at the same moment after every image: the brain’s response. That average is the ERP.`, + }, + 3: { + title: 'Compare the two image types', + body: () => + 'Now there is one average for faces and one for houses. We zoomed in, so the scale is smaller. Where the lines pull apart, the brain responded differently to the two kinds of image.', + }, + 4: { + title: 'Look around 170 ms', + body: () => + 'In many people the face average dips lower than the house average about 170 ms after the image. Your recording may show this clearly, weakly or not at all. Each of those is a real result.', + }, +}; + +const STEPS: ErpChartStep[] = [1, 2, 3, 4]; + +/** Solid/dashed swatch matching the chart and legend line styles. */ +function LineSwatch({ index }: { index: number }) { + return ( + + + + ); } -function ExplainErp() { +/** + * The strip under the graph: an invitation to the walkthrough (step 0) or the + * current step with Back / Next / Exit, one step at a time like Explore's + * lesson flow. + */ +function WalkthroughStrip({ + step, + channel, + trialCount, + onStepChange, +}: { + step: ErpWalkthroughStep; + channel: string; + trialCount: number; + onStepChange(step: ErpWalkthroughStep): void; +}) { + const heading = useRef(null); + const mounted = useRef(false); + useEffect(() => { + if (mounted.current) heading.current?.focus(); + mounted.current = true; + }, [step]); + + const copy = step === 0 ? null : WALKTHROUGH[step]; return ( -
-

- An ERP (event-related potential) is the brain response that lines up with a specific event — - like seeing a face or a house. -

-

- We average many trials together so the brain signal stands out from random noise. A positive - or negative peak at a particular time tells you when the brain differentiated one condition - from another. -

-
+
+
+
+ + Reading an ERP{step > 0 && ` · Step ${step} of 4`} + + {step > 0 && ( + + {STEPS.map((s) => ( + + ))} + + )} +
+

+ {copy ? copy.title : 'What am I looking at?'} +

+
+ {copy + ? copy.body(channel, trialCount) + : `An ERP is the brain’s average response to one kind of event, like seeing a face. Four short steps show how this graph is made from your ${trialCount} trials.`} +
+
+
+ {step > 0 ? ( + <> + +
+ + +
+ + ) : ( + + )} +
+
); } +/** + * ERP tab, graph first: pick a sensor on the head or in the list, read its + * ERP, and optionally step through how an ERP is built from single trials. + * Pure props. + */ export default function AnalyzeErp({ status, - title, eegAvailable, - eegDatasets, + workspaceTitle, channelInfo, - erpPlot, selectedChannel, + erpPlot, epochsInfo, - conditions, + epochArrays, + codeToLabel, + walkthroughStep, onChannelSelect, - onRequestErp, + onWalkthroughStepChange, + onRetry, + onGoToClean, }: AnalyzeErpProps) { - if (status === 'loading') { - return ( -
- -

Computing ERP…

-

Averaging trials and bootstrapping confidence intervals for the selected channel.

-
- ); + if (!eegAvailable) { + return ; } - if (status === 'error') { - return ( -
-

ERP analysis failed

-

The worker could not compute the ERP. Try another channel or check the cleaned dataset.

- -
- ); - } + const conditions = epochsInfo.filter((row) => !EPOCH_TOTAL_ROWS[row.name]); + const trialCount = epochArrays?.meta.n_epochs ?? 0; - if (!eegAvailable) { - return ( -
-

ERP needs cleaned EEG

-

- You need at least one cleaned EEG dataset before you can look at brain responses. Clean your - recordings first. -

- -
- ); - } + const rail = ( + <> + +
+
+ +
+
+
+ {channelInfo.map((channel) => ( + + ))} +
+
+ {conditions.length > 0 && ( + +
    + {conditions.map((row, i) => ( +
  • + + {row.name} + + {row.value} trials + +
  • + ))} +
+
+ )} + + ); - if (status === 'noData' || !erpPlot) { - return ( -
-
-

ERP

- -
- - -

Select a channel to compute and view the ERP.

-
-
-
+ let results: React.ReactNode; + if (status === 'loading') { + results = ( + + ); + } else if (status === 'error') { + results = ( + + ); + } else if (status === 'empty' || !selectedChannel || !erpPlot) { + results = ( + + ); + } else { + results = ( + <> + {epochArrays && walkthroughStep > 0 ? ( +
+
+

+ {walkthroughStep <= 2 + ? `Your ${trialCount} trials at ${selectedChannel}` + : `Face vs House at ${selectedChannel}`} +

+
+ +
+ ) : ( + + )} + {epochArrays && ( + + )} + ); } return ( -
-
-
-

ERP

- -
- - -

- Channel: {selectedChannel} -

-
- - - -
- {epochsInfo.length > 0 && ( -
- {epochsInfo - .filter((row) => row.name !== 'Drop Percentage' && row.name !== 'Total Epochs') - .map((row, i) => ( - - ● {row.name}: {row.value} - - ))} -
- )} -
- - - -

Select a channel

-
- -
- -
-
- {channelInfo.map((channel) => ( - - ))} -
- {conditions.length > 0 && ( -
-

Conditions

-
    - {conditions.map((cond) => ( -
  • - - {cond.label} - {cond.count} trials -
  • - ))} -
-
- )} -
-
-
+ + {results} + ); } diff --git a/src/renderer/components/Analyze/AnalyzeOverview.tsx b/src/renderer/components/Analyze/AnalyzeOverview.tsx index 1faf5875..c486c130 100644 --- a/src/renderer/components/Analyze/AnalyzeOverview.tsx +++ b/src/renderer/components/Analyze/AnalyzeOverview.tsx @@ -1,141 +1,206 @@ import React from 'react'; -import { Link } from 'react-router-dom'; -import { Card, CardContent, CardHeader } from '../ui/card'; -import { Button } from '../ui/button'; -import { Spinner } from '../ui/spinner'; -import { SCREENS } from '../../constants/constants'; -import PyodidePlotWidget from '../PyodidePlotWidget'; -import type { EegDatasetOption, EpochInfoRow } from './fixtures'; - -export type OverviewStatus = 'results' | 'loading' | 'error'; +import { cssColorForIndex } from '../../utils/eeg/conditionPalette'; +import { getSubjectNamesFromFiles } from '../../utils/filesystem/storage'; +import { + AnalyzeLayout, + EPOCH_TOTAL_ROWS, + CleanRequired, + DatasetChecklist, + PlotFigure, + RailSection, + ResultStatus, + railLabel, +} from './AnalyzeParts'; +import type { DatasetOption, EpochInfoRow } from './fixtures'; export interface AnalyzeOverviewProps { - status: OverviewStatus; - title: string; - eegDatasets: EegDatasetOption[]; + /** Result of loading the selected datasets and plotting PSD + topography. */ + status: 'results' | 'loading' | 'error'; + /** False until the workspace has at least one cleaned recording. */ + eegAvailable: boolean; + workspaceTitle: string; + eegDatasets: DatasetOption[]; selectedDatasets: string[]; + /** `pyodide.epochsInfo` for the loaded selection. */ epochsInfo: EpochInfoRow[]; psdPlot: { [key: string]: string } | null; topoPlot: { [key: string]: string } | null; - onDatasetChange: (values: string[]) => void; + /** `rail` (controls left) or `plotsFirst` (plots on top, datasets strip below). */ + arrangement?: 'rail' | 'plotsFirst'; + onDatasetChange(values: string[]): void; + onRetry(): void; + onGoToClean(): void; } -function SelectedSummary({ selectedDatasets, epochsInfo }: { selectedDatasets: string[]; epochsInfo: EpochInfoRow[] }) { - if (selectedDatasets.length === 0) return null; +/** Who and what is in the loaded selection: participants, trials per condition, trials removed. */ +function IncludedSummary({ + selectedDatasets, + epochsInfo, + inline, +}: { + selectedDatasets: string[]; + epochsInfo: EpochInfoRow[]; + inline?: boolean; +}) { + const participants = getSubjectNamesFromFiles(selectedDatasets); + const conditions = epochsInfo.filter((row) => !EPOCH_TOTAL_ROWS[row.name]); + const dropped = epochsInfo.find((row) => row.name === 'Drop Percentage'); return ( -
- {selectedDatasets.length} dataset{selectedDatasets.length === 1 ? '' : 's'} selected - {epochsInfo - .filter((row) => row.name !== 'Drop Percentage' && row.name !== 'Total Epochs') - .map((row) => ( - - {row.name}: {row.value} - - ))} +
+ + {participants.length} participant + {participants.length === 1 ? '' : 's'}: {participants.join(', ')} + + {conditions.map((row, i) => ( + + + {row.name}: {row.value} trials + + ))} + {dropped && ( + + {dropped.value}% of trials left out in cleaning + + )}
); } +/** + * Overview tab: choose cleaned recordings, see who is included, and read the + * PSD and per-sensor ERPs side by side. Pure props. + */ export default function AnalyzeOverview({ status, - title, + eegAvailable, + workspaceTitle, eegDatasets, selectedDatasets, epochsInfo, psdPlot, topoPlot, + arrangement = 'rail', onDatasetChange, + onRetry, + onGoToClean, }: AnalyzeOverviewProps) { - if (status === 'loading') { - return ( -
- -

Loading overview…

-

This may take a few moments while the averaged PSD and topography are computed.

+ if (!eegAvailable) { + return ; + } + + const results = + selectedDatasets.length === 0 ? ( + + ) : status === 'loading' ? ( + + ) : status === 'error' ? ( + + ) : ( +
+ {psdPlot && ( + + )} + {topoPlot && ( + + )}
); - } - if (status === 'error') { + if (arrangement === 'plotsFirst') { return ( -
-

Couldn’t build the overview

-

The analysis worker returned an error. Try a different dataset or clean the recording again.

- +
+

Overview

+ {results} +
); } - const hasDatasets = eegDatasets.some((ds) => ds.key !== ''); - return ( -
-
-

Overview

-

- Get a bird's-eye view of your cleaned EEG. Select one or more datasets to see averaged power and topography. -

- {hasDatasets ? ( - - -

Cleaned EEG datasets

-
- - -
- -
-
-
- ) : ( - - -

- No cleaned EEG yet. Clean a recording first, then it will show up here to analyze. -

- -
-
- )} -
- - {hasDatasets && ( -
- {psdPlot ? ( - - -

Power by frequency

-
- - - -
- ) : null} - {topoPlot ? ( - - -

Voltage across the scalp

-
- - - -
- ) : null} -
- )} -
+ + + + + {selectedDatasets.length > 0 && status === 'results' && ( + + + + )} + + } + > + {results} + ); } diff --git a/src/renderer/components/Analyze/AnalyzeParts.tsx b/src/renderer/components/Analyze/AnalyzeParts.tsx new file mode 100644 index 00000000..25484810 --- /dev/null +++ b/src/renderer/components/Analyze/AnalyzeParts.tsx @@ -0,0 +1,277 @@ +import React, { ReactNode } from 'react'; +import { Button } from '../ui/button'; +import { Spinner } from '../ui/spinner'; +import { cn } from '../ui/utils'; +import PyodidePlotWidget from '../PyodidePlotWidget'; +import { getSubjectNamesFromFiles } from '../../utils/filesystem/storage'; +import type { DatasetOption } from './fixtures'; + +/** `get_epochs_info` rows that are totals, not conditions. */ +export const EPOCH_TOTAL_ROWS: Record = { + 'Drop Percentage': true, + 'Total Epochs': true, +}; + +/** Small uppercase label used for rail sections and step counters. */ +export const railLabel = + 'text-[12px] font-bold uppercase tracking-[0.5px] text-ink-muted'; + +/** + * Analyze tab body: a fixed-width controls rail on the left and the results + * on the right, sized so both fit the window without page scroll. + */ +export function AnalyzeLayout({ + title, + rail, + children, +}: { + /** Screen-reader heading for the tab (the tab bar already shows it). */ + title: string; + rail: ReactNode; + children: ReactNode; +}) { + return ( +
+

{title}

+ +
+ {children} +
+
+ ); +} + +/** A titled group of controls inside the rail. */ +export function RailSection({ + label, + children, + className, +}: { + label: string; + children: ReactNode; + className?: string; +}) { + return ( +
+

{label}

+ {children} +
+ ); +} + +/** + * Checkbox list of recordings, one row per file, led by the participant ID. + * Replaces the native multi-select so choosing several needs no modifier key. + */ +export function DatasetChecklist({ + options, + selected, + onChange, + inline = false, +}: { + options: DatasetOption[]; + selected: string[]; + onChange(values: string[]): void; + /** Horizontal chips for the plots-first strip. */ + inline?: boolean; +}) { + const subjects = getSubjectNamesFromFiles(options.map((o) => o.value)); + return ( +
    + {options.map((option, i) => { + const checked = selected.includes(option.value); + return ( +
  • + +
  • + ); + })} +
+ ); +} + +/** Loading or failure in place of a result, keeping the rail usable. */ +export function ResultStatus({ + status, + title, + body, + onRetry, +}: { + status: 'loading' | 'error' | 'empty'; + title: string; + body: string; + onRetry?(): void; +}) { + return ( +
+ {status === 'loading' && } + {status === 'error' && ( + + ! + + )} +

{title}

+
+ {body} +
+ {onRetry && ( + + )} +
+ ); +} + +/** + * Per-section prerequisite: this EEG analysis needs cleaned data. Shown in + * place of the section, never as a whole-Analyze gate, with one action. + */ +export function CleanRequired({ + analysis, + onGoToClean, +}: { + /** What is blocked, e.g. `Overview` or `ERP`. */ + analysis: string; + onGoToClean(): void; +}) { + return ( +
+
+

+ {analysis} needs cleaned EEG +

+
+ Brain results are made from recordings with the noisy trials taken + out. Clean at least one recording, then come back. Your behavior + results are ready now in the Behavior tab. +
+ +
+
+ ); +} + +/** + * Two-to-three option picker. The chosen option is outlined in teal, not + * filled, so the surface keeps a single filled primary action. + */ +export function Segmented({ + label, + options, + value, + onChange, +}: { + label: string; + options: { value: T; text: string }[]; + value: T; + onChange(value: T): void; +}) { + return ( +
+ {options.map((option) => { + const active = option.value === value; + return ( + + ); + })} +
+ ); +} + +/** + * A Pyodide plot in a card that fills the space it is given: the image + * scales down to fit (`.bw-analyze-plot`) instead of pushing the page taller. + * Save as SVG/PNG comes from `PyodidePlotWidget`. + */ +export function PlotFigure({ + heading, + caption, + workspaceTitle, + imageTitle, + plot, + aside, +}: { + heading: ReactNode; + /** One line on what the plot shows, in student terms. */ + caption: string; + workspaceTitle: string; + imageTitle: string; + plot: { [key: string]: string }; + /** Right side of the heading row, e.g. a legend. */ + aside?: ReactNode; +}) { + return ( +
+
+
+

{heading}

+ {aside} +
+
+ {caption} +
+
+
+ +
+
+ ); +} diff --git a/src/renderer/components/Analyze/ErpTraceChart.tsx b/src/renderer/components/Analyze/ErpTraceChart.tsx new file mode 100644 index 00000000..49fd6c6d --- /dev/null +++ b/src/renderer/components/Analyze/ErpTraceChart.tsx @@ -0,0 +1,288 @@ +import React, { useLayoutEffect, useMemo, useRef, useState } from 'react'; +import type { EpochArraysMeta } from '../../actions'; +import { cssColorForIndex } from '../../utils/eeg/conditionPalette'; +import { epochChannelSeries, meanTrace } from '../CleanComponent/epochArrays'; + +/** Walkthrough steps the chart can draw: trials, their mean, condition means, the ~170 ms window. */ +export type ErpChartStep = 1 | 2 | 3 | 4; + +interface Props { + epochArrays: { buffer: ArrayBuffer; meta: EpochArraysMeta }; + channel: string; + codeToLabel: Record; + step: ErpChartStep; +} + +/** Dash pattern per condition index, so conditions differ by more than color. */ +export const CONDITION_DASH = ['', '7 4', '2 3', '10 3 2 3']; + +const M = { left: 52, right: 96, top: 30, bottom: 44 }; +const INK = '#1a1a1a'; +const MUTED = '#666'; +/** Window highlighted in step 4; the face-sensitive N170 usually peaks inside it. */ +const N170_WINDOW: [number, number] = [0.13, 0.21]; + +/** Smallest multiple of `step` that is at least `value`. */ +const ceilTo = (value: number, step: number) => + Math.max(step, Math.ceil(value / step) * step); + +/** + * Teaching chart for the ERP walkthrough, drawn from the same epoch arrays + * the worker ships for Clean (`get_epochs_arrays`). Every line is computed + * here from the buffer: single trials, the grand mean, and one mean per + * condition (`meanTrace`). Pointers annotate the part each step is about. + */ +export default function ErpTraceChart({ + epochArrays, + channel, + codeToLabel, + step, +}: Props) { + const box = useRef(null); + const [size, setSize] = useState({ width: 0, height: 0 }); + + useLayoutEffect(() => { + const el = box.current; + if (!el) return undefined; + const observer = new ResizeObserver(([entry]) => + setSize({ + width: entry.contentRect.width, + height: entry.contentRect.height, + }) + ); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + const { buffer, meta } = epochArrays; + const ch = meta.ch_names.indexOf(channel); + + const lines = useMemo(() => { + const all = meta.event_codes.map((_, i) => i); + const codes = [...new Set(meta.event_codes)].sort((a, b) => a - b); + return { + trials: all.map((e) => epochChannelSeries(buffer, meta, e, ch)), + grandMean: meanTrace(buffer, meta, all, ch), + conditions: codes.map((code, index) => { + const epochs = all.filter((e) => meta.event_codes[e] === code); + return { + label: codeToLabel[code] ?? `Condition ${code}`, + count: epochs.length, + color: cssColorForIndex(index), + dash: CONDITION_DASH[index % CONDITION_DASH.length], + mean: meanTrace(buffer, meta, epochs, ch), + }; + }), + }; + }, [buffer, meta, ch, codeToLabel]); + + const showTrials = step <= 2; + const extent = useMemo(() => { + const series = showTrials ? lines.trials : lines.conditions.map((c) => c.mean); + let max = 0; + for (const s of series) for (const v of s) max = Math.max(max, Math.abs(v)); + return showTrials ? ceilTo(max, 10) : ceilTo(max * 1.15, 2); + }, [lines, showTrials]); + + const { width, height } = size; + const plotW = Math.max(0, width - M.left - M.right); + const plotH = Math.max(0, height - M.top - M.bottom); + const t0 = meta.times[0]; + const t1 = meta.times[meta.times.length - 1]; + const x = (t: number) => M.left + ((t - t0) / (t1 - t0)) * plotW; + const y = (v: number) => M.top + ((extent - v) / (2 * extent)) * plotH; + const path = (series: ArrayLike) => + Array.from( + series, + (v, i) => `${i ? 'L' : 'M'}${x(meta.times[i]).toFixed(1)} ${y(v).toFixed(1)}` + ).join(''); + + const face = lines.conditions[0]; + const pointerT = step === 4 ? 0.17 : 0.52; + const pointerSeries = + step === 1 ? lines.trials[0] : step === 2 ? lines.grandMean : face.mean; + const pointer = { + x: x(pointerT), + y: y(pointerSeries[meta.times.findIndex((t) => t >= pointerT)]), + text: + step === 1 + ? 'This is one trial' + : step === 2 + ? `The average of all ${lines.trials.length} trials` + : step === 4 + ? `${face.label} may dip lower here, around 170 ms` + : '', + }; + const labelW = pointer.text.length * 6.9 + 16; + const labelX = Math.min( + Math.max(M.left + 4, pointer.x + 24), + M.left + plotW - labelW + ); + const besidePointer = step === 4; + const labelY = besidePointer + ? Math.min(Math.max(M.top + 6, pointer.y - 12), M.top + plotH - 30) + : pointer.y > M.top + plotH / 2 + ? M.top + 6 + : M.top + plotH - 30; + const endLabelY: number[] = []; + let lastLabelY = -Infinity; + for (const { i, wanted } of lines.conditions + .map((c, i) => ({ i, wanted: y(c.mean[c.mean.length - 1]) })) + .sort((a, b) => a.wanted - b.wanted)) { + lastLabelY = Math.max(wanted, lastLabelY + 16); + endLabelY[i] = lastLabelY; + } + const yTicks = [-extent, -extent / 2, 0, extent / 2, extent]; + + return ( +
+ {width > 0 && height > 0 && ( + `${c.label} average`).join(' and ') + }, -100 to ${Math.round(t1 * 1000)} ms after the image`} + className="absolute inset-0 font-sans" + > + {step === 4 && ( + + )} + {yTicks.map((v) => ( + + + + {v} + + + ))} + + µV + + {[-0.1, 0, 0.2, 0.4, 0.6, 0.8].map((t) => ( + + {Math.round(t * 1000)} + + ))} + + Time after the image appears (ms) + + + + Image appears + + + {showTrials && ( + + )} + {step === 1 && ( + + )} + {step === 2 && ( + + )} + {!showTrials && + lines.conditions.map((c, i) => { + const end = c.mean[c.mean.length - 1]; + return ( + + + + + {c.label} + + + ); + })} + + {pointer.text && ( + + + + + + {pointer.text} + + + )} + + )} +
+ ); +} diff --git a/src/renderer/components/Analyze/fixtures.ts b/src/renderer/components/Analyze/fixtures.ts index 24c241fe..0fd5c729 100644 --- a/src/renderer/components/Analyze/fixtures.ts +++ b/src/renderer/components/Analyze/fixtures.ts @@ -1,158 +1,331 @@ -import { EXPERIMENTS, DEVICES } from '../../constants/constants'; -import { cssColorForIndex } from '../../utils/eeg/conditionPalette'; import type { Data as PlotlyData } from 'plotly.js'; +import type { EpochArraysMeta } from '../../actions'; +import { cssColorForIndex } from '../../utils/eeg/conditionPalette'; +import { epochChannelSeries, meanTrace } from '../CleanComponent/epochArrays'; -/** One available cleaned EEG recording. */ -export interface EegDatasetOption { - key: string; - text: string; - value: string; -} - -/** One available behavioral CSV. */ -export interface BehaviorDatasetOption { +/** A dataset option, shaped like `AnalyzeComponent`'s `{ key, text, value }` dropdown entries. */ +export interface DatasetOption { key: string; text: string; value: string; } -/** Condition summary row returned by Python get_epochs_info. */ +/** One row of Python `get_epochs_info`: `{ name, value }` after the reducer flattens it. */ export interface EpochInfoRow { name: string; value: number | string; } -/** Supported behavior-plot display modes. */ -export type DisplayMode = 'errorbars' | 'datapoints' | 'whiskers'; - -export const FACES_HOUSES_TITLE = 'Faces_Houses_3'; -export const STROOP_TITLE = 'Stroop_2'; +/** `aggregateDataForPlot`'s return shape. */ +export interface BehaviorPlot { + dataToPlot: PlotlyData[]; + layout: Record; +} -export const ANALYZE_STEPS = { - OVERVIEW: 'OVERVIEW', - ERP: 'ERP', - BEHAVIOR: 'BEHAVIOR', -} as const; +/** Epoch arrays exactly as `pyodide.epochArrays` holds them (utils.py `get_epochs_arrays`). */ +export interface EpochArrays { + buffer: ArrayBuffer; + meta: EpochArraysMeta; +} -export const ANALYZE_STEPS_BEHAVIOR = { - BEHAVIOR: 'BEHAVIOR', -} as const; +export const WORKSPACE_TITLE = 'Faces_Houses_3'; export const MUSE_CHANNEL_INFO = ['TP9', 'AF7', 'AF8', 'TP10']; -export const EEG_DATASET_OPTIONS: EegDatasetOption[] = [ - { key: 'P01', text: 'P01-Faces_Houses_3-cleaned-epo.fif', value: '/workspaces/Faces_Houses_3/P01/P01-Faces_Houses_3-cleaned-epo.fif' }, - { key: 'P02', text: 'P02-Faces_Houses_3-cleaned-epo.fif', value: '/workspaces/Faces_Houses_3/P02/P02-Faces_Houses_3-cleaned-epo.fif' }, - { key: 'P03', text: 'P03-Faces_Houses_3-cleaned-epo.fif', value: '/workspaces/Faces_Houses_3/P03/P03-Faces_Houses_3-cleaned-epo.fif' }, -]; +/** Faces/Houses marker registry: `Face` = STIMULUS_1, `House` = STIMULUS_2. */ +export const FACES_HOUSES_CODE_TO_LABEL: Record = { + 1: 'Face', + 2: 'House', +}; -export const BEHAVIOR_DATASET_OPTIONS: BehaviorDatasetOption[] = [ - { key: 'P01', text: 'P01-Faces_Houses_3_behavior.csv', value: '/workspaces/Faces_Houses_3/P01/P01-Faces_Houses_3_behavior.csv' }, - { key: 'P02', text: 'P02-Faces_Houses_3_behavior.csv', value: '/workspaces/Faces_Houses_3/P02/P02-Faces_Houses_3_behavior.csv' }, - { key: 'P03', text: 'P03-Faces_Houses_3_behavior.csv', value: '/workspaces/Faces_Houses_3/P03/P03-Faces_Houses_3_behavior.csv' }, -]; +/** Where `readWorkspace*Data` finds a participant's files. */ +const WORKSPACE_DATA_DIR = `/Users/student/BrainWaves_Workspaces/${WORKSPACE_TITLE}/Data`; -export const EPOCHS_INFO: EpochInfoRow[] = [ - { name: 'Faces', value: 124 }, - { name: 'Houses', value: 118 }, - { name: 'Drop Percentage', value: '6.1%' }, - { name: 'Total Epochs', value: 242 }, -]; +export const EEG_DATASET_OPTIONS: DatasetOption[] = ['P01', 'P02', 'P03'].map( + (subject) => { + const name = `${subject}-cleaned-epo.fif`; + return { + key: name, + text: name, + value: `${WORKSPACE_DATA_DIR}/${subject}/EEG/${name}`, + }; + } +); -/** Representative SVG for a PSD plot, the same MIME-bundle shape Pyodide returns. */ -export const PSD_PLOT_MIME = { - 'image/svg+xml': `Power Spectral Density — averaged over selected datasetsFrequency (Hz)Power (µV²/Hz)Average PSD`, -}; +export const BEHAVIOR_DATASET_OPTIONS: DatasetOption[] = [ + 'P01', + 'P02', + 'P03', + 'P04', +].map((subject) => { + const name = `${subject}-1-1-behavior.csv`; + return { + key: name, + text: name, + value: `${WORKSPACE_DATA_DIR}/${subject}/Behavior/${name}`, + }; +}); -/** Representative SVG for a topography plot. */ -export const TOPO_PLOT_MIME = { - 'image/svg+xml': `Topography — mean voltage across conditionsT7T8FzPz● Active`, -}; +const SAMPLING_RATE = 256; +const T_MIN = -0.1; +const T_MAX = 0.8; +const TRIALS_PER_CONDITION = 40; -/** Representative SVG for an ERP plot. */ -export const ERP_PLOT_MIME = { - 'image/svg+xml': `TP9 — Event-Related Potential by conditionTime (s)Amplitude (µV)● Faces● Houses`, -}; +/** Deterministic PRNG so every screenshot of the example data 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; + }; +} -/** Stable condition labels for display. */ -export const CONDITION_LABELS: Record = { - 1: 'Faces', - 2: 'Houses', -}; +const bump = (t: number, center: number, width: number) => + Math.exp(-((t - center) ** 2) / (2 * width ** 2)); -export interface ConditionSummary { - code: number; - label: string; - count: number; - color: string; -} +/** + * Example Faces/Houses epochs for one participant: 40 trials per condition on + * the four Muse channels, -100…800 ms at 256 Hz, in µV and baseline-corrected + * the way `load_data` + epoching leave them. Each trial is background rhythm + * and sensor noise plus a small evoked response whose ~170 ms dip is deeper + * for faces on the ear-side sensors. Synthetic, not recorded. + */ +function buildExampleEpochs(): EpochArrays { + const random = mulberry32(170); + const nTimes = Math.round((T_MAX - T_MIN) * SAMPLING_RATE) + 1; + const times = Array.from( + { length: nTimes }, + (_, i) => T_MIN + i / SAMPLING_RATE + ); + const codes = [ + ...Array(TRIALS_PER_CONDITION).fill(1), + ...Array(TRIALS_PER_CONDITION).fill(2), + ]; + for (let i = codes.length - 1; i > 0; i -= 1) { + const j = Math.floor(random() * (i + 1)); + [codes[i], codes[j]] = [codes[j], codes[i]]; + } + const posteriorWeight = [1, 0.35, 0.35, 1]; + const nChannels = MUSE_CHANNEL_INFO.length; + const data = new Float32Array(codes.length * nChannels * nTimes); + const baselineEnd = times.findIndex((t) => t >= 0); -export const CONDITION_SUMMARIES: ConditionSummary[] = [ - { code: 1, label: 'Faces', count: 124, color: cssColorForIndex(0) }, - { code: 2, label: 'Houses', count: 118, color: cssColorForIndex(1) }, -]; + codes.forEach((code, epoch) => { + const n170 = code === 1 ? -5.5 : -2; + const latency = (random() - 0.5) * 0.02; + const gain = 0.7 + random() * 0.6; + for (let ch = 0; ch < nChannels; ch += 1) { + const alphaPhase = random() * Math.PI * 2; + const thetaPhase = random() * Math.PI * 2; + const alphaAmp = 2 + random() * 4; + const thetaAmp = 1 + random() * 3; + const offset = (epoch * nChannels + ch) * nTimes; + for (let i = 0; i < nTimes; i += 1) { + const t = times[i] - latency; + const evoked = + gain * + posteriorWeight[ch] * + (3 * bump(t, 0.1, 0.022) + + n170 * bump(t, 0.17, 0.028) + + 2 * bump(t, 0.36, 0.08)); + data[offset + i] = + evoked + + alphaAmp * Math.sin(2 * Math.PI * 10 * times[i] + alphaPhase) + + thetaAmp * Math.sin(2 * Math.PI * 5 * times[i] + thetaPhase) + + (random() - 0.5) * 7; + } + let baseline = 0; + for (let i = 0; i < baselineEnd; i += 1) baseline += data[offset + i]; + baseline /= baselineEnd; + for (let i = 0; i < nTimes; i += 1) data[offset + i] -= baseline; + } + }); -/** Behavior plot fixtures matching the shape returned by aggregateDataForPlot. */ -export const RT_ERRORBAR_PLOT: { dataToPlot: PlotlyData[]; layout: Record } = { - dataToPlot: [ - { - x: ['P01', 'P02', 'P03'], - y: [482, 521, 505], - name: '1', - type: 'bar', - marker: { color: '#28619E', size: 7 }, - error_y: { type: 'data', array: [23, 31, 19], visible: true }, - }, - { - x: ['P01', 'P02', 'P03'], - y: [512, 548, 533], - name: '2', - type: 'bar', - marker: { color: '#3DBBDB', size: 7 }, - error_y: { type: 'data', array: [27, 35, 22], visible: true }, + return { + buffer: data.buffer, + meta: { + n_epochs: codes.length, + n_channels: nChannels, + n_times: nTimes, + ch_names: MUSE_CHANNEL_INFO, + times, + event_codes: codes, }, - ], - layout: { - yaxis: { title: 'Response Time (milliseconds)', zeroline: false, range: [0, 700] }, - barmode: 'group', - title: 'Response Time', - }, -}; + }; +} -export const ACCURACY_ERRORBAR_PLOT: { dataToPlot: PlotlyData[]; layout: Record } = { - dataToPlot: [ - { - x: ['P01', 'P02', 'P03'], - y: [94, 91, 96], - name: '1', - type: 'bar', - marker: { color: '#28619E', size: 7 }, - error_y: { type: 'data', array: [2.1, 2.8, 1.5], visible: true }, - }, - { - x: ['P01', 'P02', 'P03'], - y: [89, 87, 92], - name: '2', - type: 'bar', - marker: { color: '#3DBBDB', size: 7 }, - error_y: { type: 'data', array: [2.5, 3.1, 1.9], visible: true }, - }, - ], - layout: { - yaxis: { title: '% correct', zeroline: false, range: [0, 105] }, - barmode: 'group', - title: 'Accuracy', +/** Example cleaned epochs for P01, feeding the ERP walkthrough and plot fixtures (synthetic). */ +export const EXAMPLE_EPOCH_ARRAYS = buildExampleEpochs(); + +/** Epochs dropped in cleaning, for the example's `Drop Percentage`. */ +const DROPPED_EPOCHS = 4; + +/** `get_epochs_info(clean_epochs)` for the example epochs. */ +export const EPOCHS_INFO: EpochInfoRow[] = [ + { name: 'Face', value: TRIALS_PER_CONDITION }, + { name: 'House', value: TRIALS_PER_CONDITION }, + { + name: 'Drop Percentage', + value: + Math.round( + (10000 * DROPPED_EPOCHS) / (2 * TRIALS_PER_CONDITION + DROPPED_EPOCHS) + ) / 100, }, + { name: 'Total Epochs', value: 2 * TRIALS_PER_CONDITION + DROPPED_EPOCHS }, +]; + +const CONDITIONS = Object.entries(FACES_HOUSES_CODE_TO_LABEL).map( + ([code, label], index) => ({ + label, + epochs: EXAMPLE_EPOCH_ARRAYS.meta.event_codes.flatMap((c, i) => + c === Number(code) ? [i] : [] + ), + color: cssColorForIndex(index), + }) +); + +const polyline = ( + values: ArrayLike, + x: (i: number) => number, + y: (v: number) => number +) => + Array.from(values, (v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join( + ' ' + ); + +/** + * Representative `plot_conditions` output (utils.py) for one channel: the + * condition means of the example epochs with a ±2 SEM band, as an SVG MIME + * bundle like the worker returns. + */ +export function erpPlotMime(channel: string): { 'image/svg+xml': string } { + const { buffer, meta } = EXAMPLE_EPOCH_ARRAYS; + const ch = meta.ch_names.indexOf(channel); + const [W, H, L, R, T, B] = [720, 440, 76, 24, 44, 60]; + const x = (i: number) => L + (i / (meta.n_times - 1)) * (W - L - R); + const y = (v: number) => T + ((8 - v) / 16) * (H - T - B); + const series = CONDITIONS.map(({ label, epochs, color }) => { + const mean = meanTrace(buffer, meta, epochs, ch); + const sem = new Float32Array(meta.n_times); + for (const e of epochs) { + const s = epochChannelSeries(buffer, meta, e, ch); + for (let t = 0; t < meta.n_times; t += 1) sem[t] += (s[t] - mean[t]) ** 2; + } + for (let t = 0; t < meta.n_times; t += 1) { + sem[t] = Math.sqrt(sem[t] / (epochs.length - 1) / epochs.length); + } + const upper = polyline( + mean.map((m, t) => m + 2 * sem[t]), + x, + y + ); + const lower = Array.from(mean, (m, t) => [x(t), y(m - 2 * sem[t])]) + .reverse() + .map(([px, py]) => `${px.toFixed(1)},${py.toFixed(1)}`) + .join(' '); + return ``; + }).join(''); + const zeroX = x(meta.times.findIndex((t) => t >= 0)); + const ticks = [-0.1, 0, 0.2, 0.4, 0.6, 0.8] + .map((s) => { + const tx = L + ((s - T_MIN) / (T_MAX - T_MIN)) * (W - L - R); + return `${s.toFixed(1)}`; + }) + .join(''); + const yTicks = [-8, -4, 0, 4, 8] + .map( + (v) => + `${v}` + ) + .join(''); + const legend = CONDITIONS.map( + ({ label, color }, i) => + `${label}` + ).join(''); + return { + 'image/svg+xml': `${channel}${series}${ticks}${yTicks}Time (s)Amplitude (uV)${legend}`, + }; +} + +/** Where MNE's topo layout puts each Muse sensor, as fractions of the head box. */ +const TOPO_POSITIONS: Record = { + AF7: [0.3, 0.2], + AF8: [0.7, 0.2], + TP9: [0.14, 0.62], + TP10: [0.86, 0.62], }; -export const EMPTY_BEHAVIOR_PLOT: { dataToPlot: PlotlyData[]; layout: Record } = { - dataToPlot: [], - layout: { title: 'Response Time' }, +/** + * Representative `plot_topo` output (MNE `plot_evoked_topo`): one small ERP + * per sensor at its place on the head, one line per condition in + * `conditionPalette` order, legend text in the condition colors. + */ +function buildTopoSvg(): string { + const { buffer, meta } = EXAMPLE_EPOCH_ARRAYS; + const [W, H, boxW, boxH] = [640, 520, 150, 92]; + const panels = meta.ch_names + .map((name, ch) => { + const [fx, fy] = TOPO_POSITIONS[name]; + const left = 40 + fx * (W - 80) - boxW / 2; + const top = 60 + fy * (H - 120) - boxH / 2; + const x = (i: number) => left + (i / (meta.n_times - 1)) * boxW; + const y = (v: number) => top + boxH / 2 - (v / 8) * (boxH / 2); + const zeroX = x(meta.times.findIndex((t) => t >= 0)); + const lines = CONDITIONS.map( + ({ epochs, color }) => + `` + ).join(''); + return `${lines}${name}`; + }) + .join(''); + const legend = CONDITIONS.map( + ({ label, color }, i) => + `${label}` + ).join(''); + return `${panels}${legend}each box: -0.1 to 0.8 s, ±8 µV`; +} + +/** Topography MIME bundle (`pyodide.topoPlot`). */ +export const TOPO_PLOT_MIME = { 'image/svg+xml': buildTopoSvg() }; + +/** Representative PSD MIME bundle (`pyodide.psdPlot`). */ +export const PSD_PLOT_MIME = { + 'image/svg+xml': `Power Spectral Density — averaged over selected datasetsFrequency (Hz)Power (µV²/Hz)Average PSD`, }; -export interface AnalyzeWorkspaceProps { - title: string; - type: EXPERIMENTS; - deviceType: DEVICES; - isEEGEnabled: boolean; +/** A behavior CSV as `readBehaviorData` returns it: Papa.parse output plus `meta.datafile`. */ +export interface BehaviorCsv { + data: Record[]; + meta: { fields: string[]; datafile: string }; } + +/** + * Example Faces/Houses behavior CSVs keyed by path, rows as strings like + * every value after the CSV round-trip. Feed them to the real + * `aggregateDataForPlot`; faces are answered a little faster on average. + */ +export const BEHAVIOR_CSVS: Record = Object.fromEntries( + BEHAVIOR_DATASET_OPTIONS.map((option, p) => { + const random = mulberry32(900 + p); + const data = Array.from({ length: 60 }, (_, i) => { + const face = i % 2 === 0; + const normal = (random() + random() + random() - 1.5) * 2; + const reactionTime = + (face ? 470 : 515) + p * 18 + normal * 70 + (random() < 0.04 ? 600 : 0); + return { + trial_number: String(i + 1), + phase: 'main', + condition: face ? 'Face' : 'House', + reaction_time: reactionTime.toFixed(0), + correct_response: String(random() > (face ? 0.06 : 0.1)), + response_given: 'yes', + }; + }); + return [ + option.value, + { data, meta: { fields: Object.keys(data[0]), datafile: option.value } }, + ]; + }) +);