Skip to content

Commit cc8664b

Browse files
eurunuelaclaude
andauthored
feat: Show component counts in accepted/rejected toggle (#123)
* fix: Prevent backpressure deadlock in NIfTI header decompression - Fix: Changed decompressHeader to use fire-and-forget write so reading starts immediately, preventing deadlock when large NIfTI files (100+ MB) buffer in DecompressionStream - Enhancement: Added "registry.json" to RICA_FILE_PATTERNS so rica_server discovers RepetitionTime for frequency axis calculations - Progress: Updated session log documenting the backpressure deadlock bug and fix Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: Narrow ICA components NIfTI filter to exclude echo-specific and derived files Previously the filter matched any *_components.nii.gz containing "ica", which caught echo-specific files (echo-1..5) and derived maps (ICAAccepted, ICAAveragingWeights). These were all fetched unnecessarily — wasting bandwidth and causing the last one to overwrite the correct niftiBuffer. New pattern: must contain _desc-ICA_components.nii.gz and must not contain _echo-, matching only the main ICA component map. Same logic applied to both the React file filter/processing blocks and the rica_server.py discovery filter. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: Extract TR from NIfTI header before full file load For very large NIfTI files that exceed browser memory limits, the full arrayBuffer() call may fail, leaving repetitionTime null and causing the power spectrum to display cycles/TR instead of Hz. Fix by reading just the first 4096 bytes of the file/response to extract the TR from the header, independently of loading the full file: - File upload: use file.slice(0, 4096).arrayBuffer() before readFileAsArrayBuffer - Server load: try a Range: bytes=0-4095 request before fetching the full file 4KB is sufficient to decompress the gzip stream enough to reach the NIfTI-1 (348 bytes) or NIfTI-2 (540 bytes) header containing pixdim[4] (TR). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: Load NIfTI via URL to avoid OOM errors with large files Previously, Rica loaded the entire ICA components NIfTI into a JavaScript ArrayBuffer before passing it to Niivue. For very large 4D NIfTI files (hundreds of MB compressed, gigabytes uncompressed), this caused the browser to run out of heap memory even when the local machine has plenty of RAM. Fix by passing a URL directly to Niivue instead of an ArrayBuffer: - Server mode: store '/' + filepath; Niivue fetches from localhost directly - File upload mode: use URL.createObjectURL(file) to create a blob URL that Niivue can load without a full ArrayBuffer copy in the JS heap BrainViewer now accepts a niftiUrl prop (alongside the existing niftiBuffer for backwards compatibility) and uses it directly in loadVolumes when present. The same change is threaded through Plots, DecisionTree, and DecisionTreeTab. This allows the local server to load maps for files that previously caused OOM errors in the browser, since Niivue's internal (WebAssembly) decompression is significantly more memory-efficient than the JS heap allocation path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: Decouple interactive views from NIfTI availability Time series and FFT spectrum only require the mixing matrix TSV — they don't need the NIfTI file at all. Previously, hasInteractiveViews gated all three components on NIfTI availability, so any failure in niftiUrl/niftiBuffer propagation would hide time series and FFT too. Fix by separating the two checks: - hasInteractiveViews: !!mixingMatrix?.data?.length (mixing matrix only) - hasBrainViewer: !!(niftiBuffer || niftiUrl) (NIfTI required) Time series + FFT always show when mixing matrix is loaded. BrainViewer only renders when the NIfTI is also available. This also improves UX for datasets where only TSV files are uploaded (no NIfTI), which previously showed the static PNG fallback even though the interactive time series and FFT could work perfectly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * revert: Restore niftiBuffer loading to fix missing brain viewer The URL-based NIfTI loading optimization (niftiUrl) caused the brain viewer to disappear because niftiUrl wasn't propagating correctly through the component tree. Revert to the reliable niftiBuffer (ArrayBuffer) approach which was working before. The TR extraction from 4KB header slice is kept (previous commit) so the power spectrum frequency axis still shows Hz correctly regardless of whether the full NIfTI loads successfully. URL-based loading for large files can be revisited separately once the propagation issue is diagnosed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: Always set niftiUrl so brain viewer loads for large files For very large NIfTI files, response.arrayBuffer() / readFileAsArrayBuffer() can fail with OOM. Previously this left niftiBuffer null and caused the brain viewer to not render. Fix by always setting niftiUrl first (zero memory cost), then attempting to load niftiBuffer. If the buffer load fails, niftiUrl is still set so hasBrainViewer is true and BrainViewer can load directly from the URL (server HTTP path or blob: URL from File object). BrainViewer already prefers niftiUrl when present (previous commit). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: Restore original NIfTI filename filter (too narrow after previous change) The filter was changed to _desc-ICA_components.nii.gz which is too strict and misses files that don't follow exact BIDS naming (e.g. ICA_components.nii.gz). Restore the original condition: _components.nii.gz + ica in name + no stat-z + no echo- This is identical to upstream/master except we added !echo- to keep the echo-specific file exclusion that was added to rica_server.py. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: Show accepted/rejected component counts in toggle tabs Display the number of currently accepted and rejected components next to each label in the classification toggle switch (e.g., "Accepted (12)" / "Rejected (5)"). Counts update in real-time as components are reclassified. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: Address PR review comments for component counter tabs - Guard against indexOf returning -1 in ToggleSwitch highlight - Default missing count keys to 0 with nullish coalescing - Use single reduce pass instead of two filter calls for counts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ad48bf5 commit cc8664b

2 files changed

Lines changed: 23 additions & 6 deletions

File tree

src/Plots/Plots.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,20 @@ function Plots({ componentData, componentFigures, originalData, mixingMatrix, ni
238238
}));
239239
}, [processedData, keepOriginalOrder]);
240240

241+
// Count accepted/rejected components for the toggle labels
242+
const componentCounts = useMemo(
243+
() =>
244+
processedData.reduce(
245+
(counts, d) => {
246+
if (d.classification === "accepted") counts.accepted += 1;
247+
else if (d.classification === "rejected") counts.rejected += 1;
248+
return counts;
249+
},
250+
{ accepted: 0, rejected: 0 }
251+
),
252+
[processedData]
253+
);
254+
241255
// Find selected index in pie data
242256
const selectedPieIndex = useMemo(() => {
243257
return pieData.findIndex((d) => d.originalIdx === selectedIndex);
@@ -385,6 +399,7 @@ function Plots({ componentData, componentFigures, originalData, mixingMatrix, ni
385399
colors={[getColors(isDark).accepted, getColors(isDark).rejected]}
386400
handleNewSelection={handleNewSelection}
387401
isDark={isDark}
402+
counts={componentCounts}
388403
/>
389404
<ResetAndSave
390405
handleReset={initializeData}

src/Plots/ToggleSwitch.js

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,16 @@ const titleCase = (str) =>
66
.map((w) => w[0].toUpperCase() + w.slice(1))
77
.join(" ");
88

9-
function ToggleSwitch({ values, selected, colors, handleNewSelection, isDark = false }) {
9+
function ToggleSwitch({ values, selected, colors, handleNewSelection, isDark = false, counts = null }) {
10+
const tabWidth = counts ? 110 : 90;
1011
const selectionStyle = useCallback(() => {
1112
const index = values.indexOf(selected);
13+
if (index === -1) return { display: 'none' };
1214
return {
13-
left: `${index * 90}px`,
15+
left: `${index * tabWidth}px`,
1416
background: colors[index],
1517
};
16-
}, [values, selected, colors]);
18+
}, [values, selected, colors, tabWidth]);
1719

1820
return (
1921
<div style={{
@@ -39,7 +41,7 @@ function ToggleSwitch({ values, selected, colors, handleNewSelection, isDark = f
3941
position: 'relative',
4042
zIndex: 10,
4143
height: '36px',
42-
width: '90px',
44+
width: `${tabWidth}px`,
4345
display: 'flex',
4446
alignItems: 'center',
4547
justifyContent: 'center',
@@ -49,7 +51,7 @@ function ToggleSwitch({ values, selected, colors, handleNewSelection, isDark = f
4951
fontSize: '13px',
5052
}}
5153
>
52-
{titleCase(val)}
54+
{counts ? `${titleCase(val)} (${counts[val] ?? 0})` : titleCase(val)}
5355
</label>
5456
</span>
5557
))}
@@ -61,7 +63,7 @@ function ToggleSwitch({ values, selected, colors, handleNewSelection, isDark = f
6163
zIndex: 0,
6264
display: 'block',
6365
height: '36px',
64-
width: '90px',
66+
width: `${tabWidth}px`,
6567
borderRadius: '8px',
6668
transition: 'all 0.2s ease',
6769
}}

0 commit comments

Comments
 (0)