From 1d9575a3914dfc3037999d3a90c055ece1283755 Mon Sep 17 00:00:00 2001 From: Nicholas Lee Date: Mon, 13 Jan 2025 20:01:10 -0800 Subject: [PATCH] feat: added threshold textfields --- .../Visualizer/NiiVueVisualizer.tsx | 301 ++++++++++++------ .../components/Visualizer/ThresholdSlider.tsx | 248 +++++++++++++++ .../DisplayMetaAnalysisResults.spec.tsx | 19 ++ .../components/DisplayMetaAnalysisResults.tsx | 6 +- .../MetaAnalysisResultStatusAlert.tsx | 6 +- 5 files changed, 480 insertions(+), 100 deletions(-) create mode 100644 compose/neurosynth-frontend/src/components/Visualizer/ThresholdSlider.tsx diff --git a/compose/neurosynth-frontend/src/components/Visualizer/NiiVueVisualizer.tsx b/compose/neurosynth-frontend/src/components/Visualizer/NiiVueVisualizer.tsx index cd1cd374..bcd3afbc 100644 --- a/compose/neurosynth-frontend/src/components/Visualizer/NiiVueVisualizer.tsx +++ b/compose/neurosynth-frontend/src/components/Visualizer/NiiVueVisualizer.tsx @@ -1,10 +1,12 @@ -import { Box, Button, Checkbox, Link, Slider, Typography } from '@mui/material'; -import { ChangeEvent, useEffect, useRef, useState } from 'react'; +import { Box, Button, Checkbox, Link, Slider, TextField, Typography } from '@mui/material'; +import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react'; import { Niivue, SHOW_RENDER } from '@niivue/niivue'; import { Download, OpenInNew } from '@mui/icons-material'; import ImageIcon from '@mui/icons-material/Image'; +import ThresholdSlider from './ThresholdSlider'; +import StateHandlerComponent from 'components/StateHandlerComponent/StateHandlerComponent'; -let niivue: Niivue; +let thresholdDebounce: NodeJS.Timeout; const NiiVueVisualizer: React.FC<{ file: string; filename: string; neurovaultLink?: string }> = ({ file, @@ -12,9 +14,14 @@ const NiiVueVisualizer: React.FC<{ file: string; filename: string; neurovaultLin neurovaultLink, }) => { const canvasRef = useRef(null); - const [softThreshold, setSoftThresold] = useState(true); + const niivueRef = useRef(null); + const [softThreshold, setSoftThreshold] = useState(true); const [showNegatives, setShowNegatives] = useState(false); + const [disableNegatives, setDisableNegatives] = useState(false); const [showCrosshairs, setShowCrosshairs] = useState(true); + const [brainCoordinateString, setBrainCoordinateString] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [threshold, setThreshold] = useState<{ min: number; max: number; @@ -25,155 +32,257 @@ const NiiVueVisualizer: React.FC<{ file: string; filename: string; neurovaultLin value: 3, }); - const handleUpdateThreshold = (event: Event, newValue: number | number[]) => { - if (!niivue) return; - const typedVal = newValue as number; - setThreshold((prev) => ({ - ...prev, - value: typedVal, - })); + const handleChangeLocation = (location: unknown) => { + const typedLocation = location as { + axCorSage: number; + frac: Float32Array; + mm: Float32Array; + string: string; + values: { id: string; mm: Float32Array; name: string; value: number; vox: number[] }[]; + vox: Float32Array; + xy: number[]; + }; - // update threshold positive - niivue.volumes[1].cal_min = typedVal; + const fileValues = typedLocation?.values?.[1]; + if (!fileValues) return; + const [x, y, z] = fileValues?.mm || []; + const value = fileValues?.value; - // update threshold negative - niivue.volumes[1].cal_maxNeg = -1 * typedVal; + const str = `X: ${Math.round(x)} | Y: ${Math.round(y)} | Z: ${Math.round(z)} = ${value.toFixed(3)}`; + setBrainCoordinateString(str); + }; + + const updateSoftThresholdInNiivue = (softThresholdEnabled: boolean) => { + if (!niivueRef.current) return; - niivue.updateGLVolume(); + if (softThresholdEnabled) { + niivueRef.current.overlayOutlineWidth = 2; + niivueRef.current.volumes[1].alphaThreshold = 5; + } else { + niivueRef.current.overlayOutlineWidth = 0; + niivueRef.current.volumes[1].alphaThreshold = 0; + } + niivueRef.current.updateGLVolume(); }; const handleToggleSoftThreshold = (event: ChangeEvent, checked: boolean) => { - if (!niivue) return; + setSoftThreshold(checked); + updateSoftThresholdInNiivue(checked); + }; - setSoftThresold(checked); - if (checked) { - niivue.overlayOutlineWidth = 2; - niivue.volumes[1].alphaThreshold = 5; + const updateCrosshairsInNiivue = (showCrosshairsEnabled: boolean) => { + if (!niivueRef.current) return; + if (showCrosshairsEnabled) { + niivueRef.current.setCrosshairWidth(1); } else { - niivue.overlayOutlineWidth = 0; - niivue.volumes[1].alphaThreshold = 0; + niivueRef.current.setCrosshairWidth(0); } - niivue.updateGLVolume(); + niivueRef.current.updateGLVolume(); }; const handleToggleShowCrosshairs = (event: ChangeEvent, checked: boolean) => { - if (!niivue) return; setShowCrosshairs(checked); - if (checked) { - niivue.setCrosshairWidth(1); + updateCrosshairsInNiivue(checked); + }; + + const updateNegativesInNiivue = (showNegativesEnabled: boolean) => { + if (!niivueRef.current) return; + + if (showNegativesEnabled) { + niivueRef.current.volumes[1].colormapNegative = 'winter'; } else { - niivue.setCrosshairWidth(0); + niivueRef.current.volumes[1].colormapNegative = ''; } - niivue.updateGLVolume(); + niivueRef.current.updateGLVolume(); }; const handleToggleNegatives = (event: ChangeEvent, checked: boolean) => { - if (!niivue) return; setShowNegatives(checked); - if (checked) { - niivue.volumes[1].colormapNegative = 'winter'; - } else { - niivue.volumes[1].colormapNegative = ''; - } - niivue.updateGLVolume(); + updateNegativesInNiivue(checked); }; useEffect(() => { - if (!canvasRef.current) return; - - const volumes = [ - { - // TODO: need to check if TAL vs MNI and set accordingly - url: 'https://neurovault.org/static/images/GenericMNI.nii.gz', - // url: 'https://niivue.github.io/niivue/images/fslmean.nii.gz', - colormap: 'gray', - opacity: 1, - }, - { + const updateNiivue = async () => { + if (!canvasRef.current) return; + + // this should only run once initially to load the niivue instance as well as a base image + if (niivueRef.current === null) { + niivueRef.current = new Niivue({ + show3Dcrosshair: true, + }); + niivueRef.current.attachToCanvas(canvasRef.current); + niivueRef.current.overlayOutlineWidth = 2; + niivueRef.current.opts.multiplanarShowRender = SHOW_RENDER.ALWAYS; + niivueRef.current.opts.isColorbar = true; + niivueRef.current.setSliceMM(false); + niivueRef.current.onLocationChange = handleChangeLocation; + await niivueRef.current.addVolumeFromUrl({ + // we can assume that maps will only be in MNI space + url: 'https://neurovault.org/static/images/GenericMNI.nii.gz', + colormap: 'gray', + opacity: 1, + colorbarVisible: false, + }); + } + + const niivue = niivueRef.current; + await niivueRef.current.addVolumeFromUrl({ url: file, - // url: 'https://niivue.github.io/niivue/images/fslt.nii.gz', - colorMap: 'warm', + colormap: 'warm', cal_min: 0, // default cal_max: 6, // default cal_minNeg: -6, // default cal_maxNeg: 0, // default opacity: 1, - }, - ]; - - niivue = new Niivue({ - show3Dcrosshair: true, - }); - - niivue.opts.isColorbar = true; - niivue.setSliceMM(false); - - niivue.attachToCanvas(canvasRef.current); - niivue.addVolumesFromUrl(volumes).then(() => { - niivue.overlayOutlineWidth = 2; - niivue.volumes[1].alphaThreshold = 5; - - niivue.volumes[0].colorbarVisible = false; - niivue.volumes[1].colormapNegative = ''; - - niivue.opts.multiplanarShowRender = SHOW_RENDER.ALWAYS; + }); - const globalMax = niivue.volumes[1].global_max || 6; + const globalMax = niivue.volumes[1].global_max || 2.58; const globalMin = niivue.volumes[1].global_min || 0; const largestAbsoluteValue = Math.max(Math.abs(globalMin), globalMax); - const startingValue = largestAbsoluteValue < 2.58 ? largestAbsoluteValue : 2.58; + + updateCrosshairsInNiivue(showCrosshairs); // update crosshair settings in case they have been updated in other maps + updateSoftThresholdInNiivue(softThreshold); // update threshold settings in case they have been updated in other maps + // update negative settings in case they have been updated in other maps. If no negatives, disable + if (globalMin < 0) { + setShowNegatives(false); + setDisableNegatives(false); + updateNegativesInNiivue(false); + } else { + setShowNegatives(false); + setDisableNegatives(true); + updateNegativesInNiivue(false); + } + + let startingValue; + let maxOrThreshold; + if (filename.startsWith('z_')) { + startingValue = 2.58; + maxOrThreshold = largestAbsoluteValue < 2.58 ? 2.58 : largestAbsoluteValue; + } else { + startingValue = 0; + maxOrThreshold = largestAbsoluteValue; + } setThreshold({ min: 0, - max: Math.round((largestAbsoluteValue + 0.1) * 100) / 100, - value: startingValue, + max: Math.round(maxOrThreshold * 100) / 100, + value: Math.round(startingValue * 100) / 100, }); + niivue.volumes[1].cal_min = startingValue; - niivue.volumes[1].cal_max = largestAbsoluteValue + 0.1; + niivue.volumes[1].cal_max = maxOrThreshold; niivue.setInterpolation(true); niivue.updateGLVolume(); - }); - }, [file]); + }; + + updateNiivue(); + + return () => { + if (niivueRef.current && niivueRef.current.volumes[1]) { + niivueRef.current.removeVolume(niivueRef.current.volumes[1]); + } + }; + }, [file, filename]); const handleDownloadImage = () => { - if (!niivue) return; - niivue.saveScene(filename + '.png'); + if (!niivueRef.current) return; + niivueRef.current.saveScene(filename + '.png'); + }; + + const updateThresholdNiivue = (update: { thresholdValue: number; thresholdMax: number; thresholdMin: number }) => { + if (!niivueRef.current) return; + + // update threshold positive + niivueRef.current.volumes[1].cal_min = update.thresholdValue; + // update threshold negative + niivueRef.current.volumes[1].cal_minNeg = -1 * update.thresholdValue; + + niivueRef.current.volumes[1].cal_max = update.thresholdMax; + niivueRef.current.volumes[1].cal_maxNeg = -1 * update.thresholdMax; + + niivueRef.current.updateGLVolume(); }; + const handleUpdateThreshold = useCallback( + (update: { thresholdValue: number; thresholdMax: number; thresholdMin: number }) => { + setThreshold({ + min: update.thresholdMin, + max: update.thresholdMax, + value: update.thresholdValue, + }); + + updateThresholdNiivue(update); + }, + [] + ); + return ( + {/* */} - Threshold - + - - - Soft Threshold - + + + + Soft Threshold + + - - Show Negatives - - - - Show Crosshairs + + + Show Crosshairs + + + + {disableNegatives ? 'No Negatives' : 'Show Negatives'} + {/* Show Negatives */} + + + + {/* */} + + {brainCoordinateString && ( + + + {brainCoordinateString} + + + )} + diff --git a/compose/neurosynth-frontend/src/components/Visualizer/ThresholdSlider.tsx b/compose/neurosynth-frontend/src/components/Visualizer/ThresholdSlider.tsx new file mode 100644 index 00000000..78ec515c --- /dev/null +++ b/compose/neurosynth-frontend/src/components/Visualizer/ThresholdSlider.tsx @@ -0,0 +1,248 @@ +import { Box, Slider, TextField, Typography } from '@mui/material'; +import { useEffect, useState } from 'react'; + +const ThresholdSlider: React.FC<{ + onDebouncedThresholdChange: (update: { + thresholdValue: number; + thresholdMin: number; + thresholdMax: number; + }) => void; + thresholdMin: number; + thresholdMax: number; + threshold: number; +}> = ({ threshold, thresholdMin, thresholdMax, onDebouncedThresholdChange }) => { + // These just hold the values for the various inputs, the actual threshold value may differ as it debounces changes + const [thresholdInputs, setThresholdInputs] = useState<{ + thresholdMin: string; + thresholdMax: string; + sliderThreshold: number; + inputThreshold: string; + }>({ + thresholdMin: thresholdMin.toString(), + thresholdMax: thresholdMax.toString(), + sliderThreshold: threshold, + inputThreshold: threshold.toString(), + }); + + useEffect(() => { + setThresholdInputs((prev) => ({ + ...prev, + thresholdMin: thresholdMin.toString(), + })); + }, [thresholdMin]); + + useEffect(() => { + setThresholdInputs((prev) => ({ + ...prev, + thresholdMax: thresholdMax.toString(), + })); + }, [thresholdMax]); + + useEffect(() => { + setThresholdInputs((prev) => ({ + ...prev, + sliderThreshold: threshold, + inputThreshold: threshold.toString(), + })); + }, [threshold]); + + // debounced threshold min and max + useEffect(() => { + const debounce = setTimeout(() => { + const parsedthresholdMin = parseFloat(thresholdInputs.thresholdMin); + const parsedthresholdMax = parseFloat(thresholdInputs.thresholdMax); + + if (isNaN(parsedthresholdMin) || isNaN(parsedthresholdMax)) return; + if (parsedthresholdMin >= parsedthresholdMax) return; + if (parsedthresholdMin < 0) return; + if (parsedthresholdMin === thresholdMin && parsedthresholdMax === thresholdMax) return; // no change + + onDebouncedThresholdChange({ + thresholdValue: + threshold < parsedthresholdMin + ? parsedthresholdMin + : threshold > parsedthresholdMax + ? parsedthresholdMax + : threshold, + thresholdMin: parsedthresholdMin, + thresholdMax: parsedthresholdMax, + }); + }); + + return () => { + clearTimeout(debounce); + }; + }, [onDebouncedThresholdChange, thresholdInputs.thresholdMax, thresholdInputs.thresholdMin]); + + // debounced input threshold value + useEffect(() => { + const debounce = setTimeout(() => { + const parsedThreshold = parseFloat(thresholdInputs.inputThreshold); + if (isNaN(parsedThreshold)) return; + if (parsedThreshold === threshold) return; + if (parsedThreshold < 0) return; + if (parsedThreshold > thresholdMax) return; + + onDebouncedThresholdChange({ + thresholdValue: parsedThreshold, + thresholdMin, + thresholdMax, + }); + setThresholdInputs((prev) => ({ + ...prev, + sliderThreshold: parsedThreshold, + })); + }, 50); + + return () => { + clearTimeout(debounce); + }; + }, [thresholdInputs.inputThreshold, threshold, thresholdMin, thresholdMax, onDebouncedThresholdChange]); + + // debounced slider threshold value + useEffect(() => { + const debounce = setTimeout(() => { + const newThreshold = thresholdInputs.sliderThreshold; + if (newThreshold === threshold) return; + if (newThreshold < 0) return; + if (newThreshold > thresholdMax) return; + + onDebouncedThresholdChange({ + thresholdValue: newThreshold, + thresholdMin, + thresholdMax, + }); + setThresholdInputs((prev) => ({ + ...prev, + inputThreshold: newThreshold.toString(), + })); + }, 50); + + return () => { + clearTimeout(debounce); + }; + }, [thresholdInputs.sliderThreshold, threshold, thresholdMin, thresholdMax, onDebouncedThresholdChange]); + + const hasThresholdValueError = + thresholdInputs.thresholdMin !== '' && + thresholdInputs.thresholdMax !== '' && + parseFloat(thresholdInputs.thresholdMin) >= parseFloat(thresholdInputs.thresholdMax); + const hasThresholdMinEmptyError = thresholdInputs.thresholdMin === ''; + const hasThresholdMaxEmptyError = thresholdInputs.thresholdMax === ''; + + const hasMinMaxError = hasThresholdValueError || hasThresholdMinEmptyError || hasThresholdMaxEmptyError; + + const hasThresholdInputEmptyError = thresholdInputs.inputThreshold === ''; + const parsedThresholdValue = parseFloat(thresholdInputs.inputThreshold); + const hasThresholdInputValueError = + thresholdInputs.inputThreshold !== '' && + (parsedThresholdValue > thresholdMax || parsedThresholdValue < thresholdMin); + const hasThresholdError = hasThresholdInputEmptyError || hasThresholdInputValueError; + + return ( + + + + Threshold + + { + setThresholdInputs((prev) => ({ + ...prev, + inputThreshold: event.target.value, + })); + }} + error={hasThresholdError} + variant="outlined" + /> + + + { + setThresholdInputs((prev) => ({ + ...prev, + sliderThreshold: newValue as number, + })); + }} + > + + {/* { + setThresholdInputs((prev) => ({ + ...prev, + thresholdMin: event.target.value, + })); + }} + error={hasThresholdMinEmptyError || hasThresholdValueError} + value={thresholdInputs.thresholdMin} + /> */} + {thresholdInputs.thresholdMin} + {(hasMinMaxError || hasThresholdError) && ( + + {hasThresholdValueError + ? 'Min cannot be greater than or equal to Max' + : hasThresholdMinEmptyError + ? 'Min is empty' + : hasThresholdMaxEmptyError + ? 'Max is empty' + : hasThresholdInputEmptyError + ? 'Input is empty' + : 'Input is out of range'} + + )} + { + setThresholdInputs((prev) => ({ + ...prev, + thresholdMax: event.target.value, + })); + }} + error={hasThresholdMaxEmptyError || hasThresholdValueError} + value={thresholdInputs.thresholdMax} + /> + + + + ); +}; + +export default ThresholdSlider; diff --git a/compose/neurosynth-frontend/src/pages/MetaAnalysis/components/DisplayMetaAnalysisResults.spec.tsx b/compose/neurosynth-frontend/src/pages/MetaAnalysis/components/DisplayMetaAnalysisResults.spec.tsx index ff5b0eba..0d9581a6 100644 --- a/compose/neurosynth-frontend/src/pages/MetaAnalysis/components/DisplayMetaAnalysisResults.spec.tsx +++ b/compose/neurosynth-frontend/src/pages/MetaAnalysis/components/DisplayMetaAnalysisResults.spec.tsx @@ -48,6 +48,11 @@ const caseMKDAChi2: Partial[] = [ { id: 5, name: 'z_desc-associationMass' }, ]; +const caseMoreSegments: Partial[] = [ + { id: 2, name: 'z_desc-association_level-voxel_corr-FDR_method-indep.nii.gz' }, + { id: 1, name: 'z_desc-association.nii.gz' }, +]; + describe('DisplayMetaAnalysisResults', () => { it('should render', () => { render(); @@ -142,4 +147,18 @@ describe('DisplayMetaAnalysisResults', () => { expect(buttons[3].textContent).toBe('z_desc-CCC'); expect(buttons[4].textContent).toBe('z_desc-ZZZ'); }); + + it('should show the correctly sorted list if one file name is larger than the other', () => { + (useGetNeurovaultImages as Mock).mockReturnValue({ + data: caseMoreSegments, + isLoading: false, + isError: false, + }); + + render(); + const buttons = screen.getAllByRole('button'); + expect(buttons.length).toEqual(caseMoreSegments.length); + expect(buttons[0].textContent).toBe('z_desc-association_level-voxel_corr-FDR_method-indep.nii.gz'); + expect(buttons[1].textContent).toBe('z_desc-association.nii.gz'); + }); }); diff --git a/compose/neurosynth-frontend/src/pages/MetaAnalysis/components/DisplayMetaAnalysisResults.tsx b/compose/neurosynth-frontend/src/pages/MetaAnalysis/components/DisplayMetaAnalysisResults.tsx index 6311a284..a6901720 100644 --- a/compose/neurosynth-frontend/src/pages/MetaAnalysis/components/DisplayMetaAnalysisResults.tsx +++ b/compose/neurosynth-frontend/src/pages/MetaAnalysis/components/DisplayMetaAnalysisResults.tsx @@ -61,7 +61,7 @@ const DisplayMetaAnalysisResults: React.FC<{ if (segmentA.value === segmentB.value) continue; return segmentA.value.localeCompare(segmentB.value); } else { - return orderA - orderB; + return orderB - orderA; } } return 0; @@ -85,7 +85,7 @@ const DisplayMetaAnalysisResults: React.FC<{ } } - return sorted; + return sorted.reverse(); }, [neurovaultFiles, metaAnalysis]); useEffect(() => { @@ -108,7 +108,7 @@ const DisplayMetaAnalysisResults: React.FC<{ onClick={() => setSelectedNeurovaultImage(neurovaultFile)} selected={selectedNeurovaultImage?.id === neurovaultFile.id} > - + ))} diff --git a/compose/neurosynth-frontend/src/pages/MetaAnalysis/components/MetaAnalysisResultStatusAlert.tsx b/compose/neurosynth-frontend/src/pages/MetaAnalysis/components/MetaAnalysisResultStatusAlert.tsx index 4d805726..8dcef75f 100644 --- a/compose/neurosynth-frontend/src/pages/MetaAnalysis/components/MetaAnalysisResultStatusAlert.tsx +++ b/compose/neurosynth-frontend/src/pages/MetaAnalysis/components/MetaAnalysisResultStatusAlert.tsx @@ -20,7 +20,11 @@ const MetaAnalysisResultStatusAlert: React.FC<{ const shouldHide = !!localStorage.getItem( `${localStorageResultAlertKey}-${resultStatus.severity}-${metaAnalysis?.id}` ); - setHideAlert(shouldHide); + if (resultStatus.severity === 'success') { + setHideAlert(true); + } else { + setHideAlert(shouldHide); + } }, [metaAnalysis?.id, resultStatus]); if (hideAlert === undefined) return null;