Skip to content

Commit 1d9575a

Browse files
committed
feat: added threshold textfields
1 parent e554149 commit 1d9575a

File tree

5 files changed

+480
-100
lines changed

5 files changed

+480
-100
lines changed

compose/neurosynth-frontend/src/components/Visualizer/NiiVueVisualizer.tsx

Lines changed: 205 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,27 @@
1-
import { Box, Button, Checkbox, Link, Slider, Typography } from '@mui/material';
2-
import { ChangeEvent, useEffect, useRef, useState } from 'react';
1+
import { Box, Button, Checkbox, Link, Slider, TextField, Typography } from '@mui/material';
2+
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
33
import { Niivue, SHOW_RENDER } from '@niivue/niivue';
44
import { Download, OpenInNew } from '@mui/icons-material';
55
import ImageIcon from '@mui/icons-material/Image';
6+
import ThresholdSlider from './ThresholdSlider';
7+
import StateHandlerComponent from 'components/StateHandlerComponent/StateHandlerComponent';
68

7-
let niivue: Niivue;
9+
let thresholdDebounce: NodeJS.Timeout;
810

911
const NiiVueVisualizer: React.FC<{ file: string; filename: string; neurovaultLink?: string }> = ({
1012
file,
1113
filename,
1214
neurovaultLink,
1315
}) => {
1416
const canvasRef = useRef<HTMLCanvasElement>(null);
15-
const [softThreshold, setSoftThresold] = useState(true);
17+
const niivueRef = useRef<Niivue | null>(null);
18+
const [softThreshold, setSoftThreshold] = useState(true);
1619
const [showNegatives, setShowNegatives] = useState(false);
20+
const [disableNegatives, setDisableNegatives] = useState(false);
1721
const [showCrosshairs, setShowCrosshairs] = useState(true);
22+
const [brainCoordinateString, setBrainCoordinateString] = useState('');
23+
const [isLoading, setIsLoading] = useState(false);
24+
1825
const [threshold, setThreshold] = useState<{
1926
min: number;
2027
max: number;
@@ -25,155 +32,257 @@ const NiiVueVisualizer: React.FC<{ file: string; filename: string; neurovaultLin
2532
value: 3,
2633
});
2734

28-
const handleUpdateThreshold = (event: Event, newValue: number | number[]) => {
29-
if (!niivue) return;
30-
const typedVal = newValue as number;
31-
setThreshold((prev) => ({
32-
...prev,
33-
value: typedVal,
34-
}));
35+
const handleChangeLocation = (location: unknown) => {
36+
const typedLocation = location as {
37+
axCorSage: number;
38+
frac: Float32Array;
39+
mm: Float32Array;
40+
string: string;
41+
values: { id: string; mm: Float32Array; name: string; value: number; vox: number[] }[];
42+
vox: Float32Array;
43+
xy: number[];
44+
};
3545

36-
// update threshold positive
37-
niivue.volumes[1].cal_min = typedVal;
46+
const fileValues = typedLocation?.values?.[1];
47+
if (!fileValues) return;
48+
const [x, y, z] = fileValues?.mm || [];
49+
const value = fileValues?.value;
3850

39-
// update threshold negative
40-
niivue.volumes[1].cal_maxNeg = -1 * typedVal;
51+
const str = `X: ${Math.round(x)} | Y: ${Math.round(y)} | Z: ${Math.round(z)} = ${value.toFixed(3)}`;
52+
setBrainCoordinateString(str);
53+
};
54+
55+
const updateSoftThresholdInNiivue = (softThresholdEnabled: boolean) => {
56+
if (!niivueRef.current) return;
4157

42-
niivue.updateGLVolume();
58+
if (softThresholdEnabled) {
59+
niivueRef.current.overlayOutlineWidth = 2;
60+
niivueRef.current.volumes[1].alphaThreshold = 5;
61+
} else {
62+
niivueRef.current.overlayOutlineWidth = 0;
63+
niivueRef.current.volumes[1].alphaThreshold = 0;
64+
}
65+
niivueRef.current.updateGLVolume();
4366
};
4467

4568
const handleToggleSoftThreshold = (event: ChangeEvent<HTMLInputElement>, checked: boolean) => {
46-
if (!niivue) return;
69+
setSoftThreshold(checked);
70+
updateSoftThresholdInNiivue(checked);
71+
};
4772

48-
setSoftThresold(checked);
49-
if (checked) {
50-
niivue.overlayOutlineWidth = 2;
51-
niivue.volumes[1].alphaThreshold = 5;
73+
const updateCrosshairsInNiivue = (showCrosshairsEnabled: boolean) => {
74+
if (!niivueRef.current) return;
75+
if (showCrosshairsEnabled) {
76+
niivueRef.current.setCrosshairWidth(1);
5277
} else {
53-
niivue.overlayOutlineWidth = 0;
54-
niivue.volumes[1].alphaThreshold = 0;
78+
niivueRef.current.setCrosshairWidth(0);
5579
}
56-
niivue.updateGLVolume();
80+
niivueRef.current.updateGLVolume();
5781
};
5882

5983
const handleToggleShowCrosshairs = (event: ChangeEvent<HTMLInputElement>, checked: boolean) => {
60-
if (!niivue) return;
6184
setShowCrosshairs(checked);
62-
if (checked) {
63-
niivue.setCrosshairWidth(1);
85+
updateCrosshairsInNiivue(checked);
86+
};
87+
88+
const updateNegativesInNiivue = (showNegativesEnabled: boolean) => {
89+
if (!niivueRef.current) return;
90+
91+
if (showNegativesEnabled) {
92+
niivueRef.current.volumes[1].colormapNegative = 'winter';
6493
} else {
65-
niivue.setCrosshairWidth(0);
94+
niivueRef.current.volumes[1].colormapNegative = '';
6695
}
67-
niivue.updateGLVolume();
96+
niivueRef.current.updateGLVolume();
6897
};
6998

7099
const handleToggleNegatives = (event: ChangeEvent<HTMLInputElement>, checked: boolean) => {
71-
if (!niivue) return;
72100
setShowNegatives(checked);
73-
if (checked) {
74-
niivue.volumes[1].colormapNegative = 'winter';
75-
} else {
76-
niivue.volumes[1].colormapNegative = '';
77-
}
78-
niivue.updateGLVolume();
101+
updateNegativesInNiivue(checked);
79102
};
80103

81104
useEffect(() => {
82-
if (!canvasRef.current) return;
83-
84-
const volumes = [
85-
{
86-
// TODO: need to check if TAL vs MNI and set accordingly
87-
url: 'https://neurovault.org/static/images/GenericMNI.nii.gz',
88-
// url: 'https://niivue.github.io/niivue/images/fslmean.nii.gz',
89-
colormap: 'gray',
90-
opacity: 1,
91-
},
92-
{
105+
const updateNiivue = async () => {
106+
if (!canvasRef.current) return;
107+
108+
// this should only run once initially to load the niivue instance as well as a base image
109+
if (niivueRef.current === null) {
110+
niivueRef.current = new Niivue({
111+
show3Dcrosshair: true,
112+
});
113+
niivueRef.current.attachToCanvas(canvasRef.current);
114+
niivueRef.current.overlayOutlineWidth = 2;
115+
niivueRef.current.opts.multiplanarShowRender = SHOW_RENDER.ALWAYS;
116+
niivueRef.current.opts.isColorbar = true;
117+
niivueRef.current.setSliceMM(false);
118+
niivueRef.current.onLocationChange = handleChangeLocation;
119+
await niivueRef.current.addVolumeFromUrl({
120+
// we can assume that maps will only be in MNI space
121+
url: 'https://neurovault.org/static/images/GenericMNI.nii.gz',
122+
colormap: 'gray',
123+
opacity: 1,
124+
colorbarVisible: false,
125+
});
126+
}
127+
128+
const niivue = niivueRef.current;
129+
await niivueRef.current.addVolumeFromUrl({
93130
url: file,
94-
// url: 'https://niivue.github.io/niivue/images/fslt.nii.gz',
95-
colorMap: 'warm',
131+
colormap: 'warm',
96132
cal_min: 0, // default
97133
cal_max: 6, // default
98134
cal_minNeg: -6, // default
99135
cal_maxNeg: 0, // default
100136
opacity: 1,
101-
},
102-
];
103-
104-
niivue = new Niivue({
105-
show3Dcrosshair: true,
106-
});
107-
108-
niivue.opts.isColorbar = true;
109-
niivue.setSliceMM(false);
110-
111-
niivue.attachToCanvas(canvasRef.current);
112-
niivue.addVolumesFromUrl(volumes).then(() => {
113-
niivue.overlayOutlineWidth = 2;
114-
niivue.volumes[1].alphaThreshold = 5;
115-
116-
niivue.volumes[0].colorbarVisible = false;
117-
niivue.volumes[1].colormapNegative = '';
118-
119-
niivue.opts.multiplanarShowRender = SHOW_RENDER.ALWAYS;
137+
});
120138

121-
const globalMax = niivue.volumes[1].global_max || 6;
139+
const globalMax = niivue.volumes[1].global_max || 2.58;
122140
const globalMin = niivue.volumes[1].global_min || 0;
123141
const largestAbsoluteValue = Math.max(Math.abs(globalMin), globalMax);
124-
const startingValue = largestAbsoluteValue < 2.58 ? largestAbsoluteValue : 2.58;
142+
143+
updateCrosshairsInNiivue(showCrosshairs); // update crosshair settings in case they have been updated in other maps
144+
updateSoftThresholdInNiivue(softThreshold); // update threshold settings in case they have been updated in other maps
145+
// update negative settings in case they have been updated in other maps. If no negatives, disable
146+
if (globalMin < 0) {
147+
setShowNegatives(false);
148+
setDisableNegatives(false);
149+
updateNegativesInNiivue(false);
150+
} else {
151+
setShowNegatives(false);
152+
setDisableNegatives(true);
153+
updateNegativesInNiivue(false);
154+
}
155+
156+
let startingValue;
157+
let maxOrThreshold;
158+
if (filename.startsWith('z_')) {
159+
startingValue = 2.58;
160+
maxOrThreshold = largestAbsoluteValue < 2.58 ? 2.58 : largestAbsoluteValue;
161+
} else {
162+
startingValue = 0;
163+
maxOrThreshold = largestAbsoluteValue;
164+
}
125165

126166
setThreshold({
127167
min: 0,
128-
max: Math.round((largestAbsoluteValue + 0.1) * 100) / 100,
129-
value: startingValue,
168+
max: Math.round(maxOrThreshold * 100) / 100,
169+
value: Math.round(startingValue * 100) / 100,
130170
});
171+
131172
niivue.volumes[1].cal_min = startingValue;
132-
niivue.volumes[1].cal_max = largestAbsoluteValue + 0.1;
173+
niivue.volumes[1].cal_max = maxOrThreshold;
133174

134175
niivue.setInterpolation(true);
135176
niivue.updateGLVolume();
136-
});
137-
}, [file]);
177+
};
178+
179+
updateNiivue();
180+
181+
return () => {
182+
if (niivueRef.current && niivueRef.current.volumes[1]) {
183+
niivueRef.current.removeVolume(niivueRef.current.volumes[1]);
184+
}
185+
};
186+
}, [file, filename]);
138187

139188
const handleDownloadImage = () => {
140-
if (!niivue) return;
141-
niivue.saveScene(filename + '.png');
189+
if (!niivueRef.current) return;
190+
niivueRef.current.saveScene(filename + '.png');
191+
};
192+
193+
const updateThresholdNiivue = (update: { thresholdValue: number; thresholdMax: number; thresholdMin: number }) => {
194+
if (!niivueRef.current) return;
195+
196+
// update threshold positive
197+
niivueRef.current.volumes[1].cal_min = update.thresholdValue;
198+
// update threshold negative
199+
niivueRef.current.volumes[1].cal_minNeg = -1 * update.thresholdValue;
200+
201+
niivueRef.current.volumes[1].cal_max = update.thresholdMax;
202+
niivueRef.current.volumes[1].cal_maxNeg = -1 * update.thresholdMax;
203+
204+
niivueRef.current.updateGLVolume();
142205
};
143206

207+
const handleUpdateThreshold = useCallback(
208+
(update: { thresholdValue: number; thresholdMax: number; thresholdMin: number }) => {
209+
setThreshold({
210+
min: update.thresholdMin,
211+
max: update.thresholdMax,
212+
value: update.thresholdValue,
213+
});
214+
215+
updateThresholdNiivue(update);
216+
},
217+
[]
218+
);
219+
144220
return (
145221
<Box>
222+
{/* <StateHandlerComponent isLoading={isLoading} isError={false}> */}
146223
<Box sx={{ marginBottom: '10px', display: 'flex', justifyContent: 'space-between' }}>
147224
<Box width="250px">
148-
<Typography gutterBottom={false}>Threshold</Typography>
149-
<Slider
150-
valueLabelDisplay="auto"
151-
min={threshold.min}
152-
step={0.01}
153-
max={threshold.max}
154-
onChange={handleUpdateThreshold}
155-
value={threshold.value}
156-
></Slider>
225+
<ThresholdSlider
226+
thresholdMin={threshold.min}
227+
thresholdMax={threshold.max}
228+
threshold={threshold.value}
229+
onDebouncedThresholdChange={handleUpdateThreshold}
230+
/>
157231
</Box>
158-
<Box display="flex">
159-
<Box width="100px">
160-
<Typography gutterBottom={false}>Soft Threshold</Typography>
161-
<Checkbox checked={softThreshold} onChange={handleToggleSoftThreshold} />
232+
<Box width="130px" display="flex" flexDirection="column">
233+
<Box display="flex" justifyContent="space-between" alignItems="center">
234+
<Typography variant="caption" gutterBottom={false}>
235+
Soft Threshold
236+
</Typography>
237+
<Checkbox sx={{ padding: 0 }} checked={softThreshold} onChange={handleToggleSoftThreshold} />
162238
</Box>
163-
<Box width="100px">
164-
<Typography gutterBottom={false}>Show Negatives</Typography>
165-
<Checkbox checked={showNegatives} onChange={handleToggleNegatives} />
166-
</Box>
167-
<Box width="100px">
168-
<Typography gutterBottom={false}>Show Crosshairs</Typography>
239+
<Box display="flex" justifyContent="space-between" alignItems="center">
240+
<Typography variant="caption" gutterBottom={false}>
241+
Show Crosshairs
242+
</Typography>
169243
<Checkbox
244+
sx={{ padding: 0 }}
170245
value={showCrosshairs}
171246
checked={showCrosshairs}
172247
onChange={handleToggleShowCrosshairs}
173248
/>
174249
</Box>
250+
<Box display="flex" justifyContent="space-between" alignItems="center">
251+
<Typography
252+
variant="caption"
253+
color={disableNegatives ? 'muted.main' : 'inherit'}
254+
gutterBottom={false}
255+
>
256+
{disableNegatives ? 'No Negatives' : 'Show Negatives'}
257+
{/* Show Negatives */}
258+
</Typography>
259+
<Checkbox
260+
sx={{ padding: 0 }}
261+
disabled={disableNegatives}
262+
checked={showNegatives}
263+
onChange={handleToggleNegatives}
264+
/>
265+
</Box>
175266
</Box>
176267
</Box>
268+
{/* </StateHandlerComponent> */}
269+
<Box sx={{ height: '32px' }}>
270+
{brainCoordinateString && (
271+
<Box
272+
sx={{
273+
width: '260px',
274+
backgroundColor: 'black',
275+
textAlign: 'center',
276+
borderTopLeftRadius: '4px',
277+
borderTopRightRadius: '4px',
278+
}}
279+
>
280+
<Typography padding="4px 8px" display="inline-block" color="white">
281+
{brainCoordinateString}
282+
</Typography>
283+
</Box>
284+
)}
285+
</Box>
177286
<Box sx={{ height: '300px' }}>
178287
<canvas ref={canvasRef} />
179288
</Box>

0 commit comments

Comments
 (0)