-
Notifications
You must be signed in to change notification settings - Fork 25
Epoch reviewer Phase 2: bad-channel flagging, auto-flag suggestions, condition labels #225
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: epoch-reviewer-phase1
Are you sure you want to change the base?
Changes from 3 commits
6370962
a089484
330f912
b9a5387
5d4376c
beb1620
68a410b
7fdccfb
c1cb40d
69396e4
4247b15
1e38197
8d15e44
b68096a
5f2fc1e
45afe4b
376740f
5e30843
7f789e4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,6 +27,12 @@ import { | |
| // each epoch click-to-reject (rejected epochs grey out); Prev/Next page through | ||
| // all epochs; amp +/- scale trace amplitude. Rendering stays Canvas 2D + a DOM | ||
| // overlay for labels/hit-targets (no canvas hit-testing). | ||
| // | ||
| // Interactive (Phase 2): channel labels in the left gutter are click-to-flag | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove these process-specific comments. Leaveo only a high level comment that concisely describes the intent and bejavoir of the component. |
||
| // bad-channel toggles — a flagged channel's LANE is washed translucent red | ||
| // across ALL epoch columns (and its label renders struck-through/red) so bad | ||
| // channels read at a glance. An optional condition legend maps numeric event | ||
| // codes to human-readable labels via `codeToLabel`. | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| interface Props { | ||
|
|
@@ -35,6 +41,12 @@ interface Props { | |
| rejected: Set<number>; | ||
| // Toggle a single ABSOLUTE epoch index in/out of the rejected set. | ||
| onToggleEpoch: (index: number) => void; | ||
| // Channel names the student has flagged as "bad" (controlled by the parent). | ||
| badChannels: Set<string>; | ||
| // Toggle a single channel name in/out of the bad-channel set. | ||
| onToggleChannel: (name: string) => void; | ||
| // Optional map from numeric event code to a human-readable condition label. | ||
| codeToLabel?: Record<number, string>; | ||
| } | ||
|
|
||
| // Logical canvas size (scaled up for devicePixelRatio at draw time). | ||
|
|
@@ -56,11 +68,16 @@ const GAIN_MAX = 20; | |
|
|
||
| const REJECTED_TRACE_COLOR = 'rgba(120, 120, 120, 0.5)'; | ||
| const REJECTED_FILL_COLOR = 'rgba(120, 120, 120, 0.15)'; | ||
| // Translucent wash over a flagged bad channel's lane (drawn across all epochs). | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove |
||
| const BAD_CHANNEL_FILL_COLOR = 'rgba(200, 60, 60, 0.10)'; | ||
|
|
||
| export default function EpochReviewer({ | ||
| epochArrays, | ||
| rejected, | ||
| onToggleEpoch, | ||
| badChannels, | ||
| onToggleChannel, | ||
| codeToLabel, | ||
| }: Props): JSX.Element { | ||
| const canvasRef = useRef<HTMLCanvasElement | null>(null); | ||
| // First epoch of the current page (absolute index). | ||
|
|
@@ -100,7 +117,7 @@ export default function EpochReviewer({ | |
| ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); | ||
|
|
||
| const { buffer } = epochArrays; | ||
| const { n_epochs, n_channels, n_times, event_codes } = meta; | ||
| const { n_epochs, n_channels, n_times, event_codes, ch_names } = meta; | ||
|
|
||
| const plotWidth = CANVAS_WIDTH - LABEL_GUTTER; | ||
| const plotHeight = CANVAS_HEIGHT - BOTTOM_GUTTER; | ||
|
|
@@ -120,6 +137,20 @@ export default function EpochReviewer({ | |
| } | ||
| } | ||
|
|
||
| // Translucent red wash over flagged bad-channel lanes — spans every epoch | ||
| // column (drawn under traces) so bad channels read at a glance. | ||
| for (let ch = 0; ch < n_channels; ch += 1) { | ||
| if (badChannels.has(ch_names[ch])) { | ||
| ctx.fillStyle = BAD_CHANNEL_FILL_COLOR; | ||
| ctx.fillRect( | ||
| LABEL_GUTTER, | ||
| ch * laneHeight, | ||
| CANVAS_WIDTH - LABEL_GUTTER, | ||
| laneHeight | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| // Faint lane dividers (channels). | ||
| ctx.strokeStyle = 'rgba(0, 0, 0, 0.08)'; | ||
| ctx.lineWidth = 1; | ||
|
|
@@ -235,7 +266,7 @@ export default function EpochReviewer({ | |
| ctx.stroke(); | ||
| } | ||
| } | ||
| }, [epochArrays, meta, rejected, clampedStart, perPage, gain]); | ||
| }, [epochArrays, meta, rejected, clampedStart, perPage, gain, badChannels]); | ||
|
|
||
| // Empty state — friendly, brand-styled, student-facing. | ||
| if (!epochArrays || !meta || meta.n_epochs === 0) { | ||
|
|
@@ -252,6 +283,12 @@ export default function EpochReviewer({ | |
| const firstShown = clampedStart + 1; | ||
| const lastShown = clampedStart + visibleCount; | ||
|
|
||
| // Unique condition codes (sorted) for the legend — mirrors the canvas draw's | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. remove |
||
| // deterministic per-condition coloring. | ||
| const uniqueSortedCodes = [...new Set(meta.event_codes)].sort( | ||
| (a, b) => a - b | ||
| ); | ||
|
|
||
| return ( | ||
| <div className="text-left"> | ||
| <div className="mb-2 flex items-center justify-between"> | ||
|
|
@@ -306,22 +343,41 @@ export default function EpochReviewer({ | |
| style={{ width: CANVAS_WIDTH, height: CANVAS_HEIGHT }} | ||
| /> | ||
|
|
||
| {/* Channel labels (left gutter). */} | ||
| {meta.ch_names.map((name, ch) => ( | ||
| <div | ||
| key={name} | ||
| className="absolute truncate pr-1 text-right text-[10px] text-gray-500" | ||
| style={{ | ||
| left: 0, | ||
| width: LABEL_GUTTER, | ||
| top: ch * laneHeight, | ||
| height: laneHeight, | ||
| lineHeight: `${laneHeight}px`, | ||
| }} | ||
| > | ||
| {name} | ||
| </div> | ||
| ))} | ||
| {/* Channel labels (left gutter) double as click-to-flag bad-channel | ||
| toggles. A flagged channel renders struck-through + red and its lane | ||
| is washed red across every epoch column (see canvas draw). */} | ||
| {meta.ch_names.map((name, ch) => { | ||
| const isBad = badChannels.has(name); | ||
| return ( | ||
| <div | ||
| key={name} | ||
| role="button" | ||
| tabIndex={0} | ||
| aria-pressed={isBad} | ||
| aria-label={`${isBad ? 'Unflag' : 'Flag'} channel ${name} as bad`} | ||
| title={`${isBad ? 'Unflag' : 'Flag'} channel ${name} as bad`} | ||
| className={`absolute cursor-pointer truncate pr-1 text-right text-[10px] ${ | ||
| isBad ? 'text-red-500 line-through' : 'text-gray-500' | ||
| }`} | ||
| style={{ | ||
| left: 0, | ||
| width: LABEL_GUTTER, | ||
| top: ch * laneHeight, | ||
| height: laneHeight, | ||
| lineHeight: `${laneHeight}px`, | ||
| }} | ||
| onClick={() => onToggleChannel(name)} | ||
| onKeyDown={(e) => { | ||
| if (e.key === 'Enter' || e.key === ' ') { | ||
| e.preventDefault(); | ||
| onToggleChannel(name); | ||
| } | ||
| }} | ||
| > | ||
| {name} | ||
| </div> | ||
| ); | ||
| })} | ||
|
|
||
| {/* Transparent click targets — one per visible epoch column. Clicking | ||
| toggles that ABSOLUTE epoch index in/out of the rejected set. */} | ||
|
|
@@ -375,6 +431,28 @@ export default function EpochReviewer({ | |
| })} | ||
| </div> | ||
|
|
||
| {/* Condition legend: one swatch + human-readable label per unique code. */} | ||
| {uniqueSortedCodes.length > 0 && ( | ||
| <div className="mt-2 flex flex-wrap gap-3"> | ||
| {uniqueSortedCodes.map((code) => ( | ||
| <span | ||
| key={code} | ||
| className="flex items-center gap-1 text-xs text-gray-600" | ||
| > | ||
| <span | ||
| className="inline-block h-3 w-3 rounded-sm" | ||
| style={{ | ||
| backgroundColor: cssColorForIndex( | ||
| conditionIndexForCode(code, uniqueSortedCodes) | ||
| ), | ||
| }} | ||
| /> | ||
| {codeToLabel?.[code] ?? `Condition ${code}`} | ||
| </span> | ||
| ))} | ||
| </div> | ||
| )} | ||
|
|
||
| <p className="mt-1 text-xs text-gray-500"> | ||
| showing {firstShown}–{lastShown} of {meta.n_epochs} epochs | ||
| {rejected.size > 0 && ` · ${rejected.size} marked for rejection`} | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
remove this comment