-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathApp.js
More file actions
874 lines (749 loc) · 30.8 KB
/
Copy pathApp.js
File metadata and controls
874 lines (749 loc) · 30.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
import { StatusBar } from 'expo-status-bar';
import React, { useState, useEffect, useCallback } from 'react';
import { View, Text, TouchableOpacity, SafeAreaView, Platform, Dimensions } from 'react-native';
import { calculateContrastPercentiles, zNormalize } from './utils/imageProcessing';
import { analyzeLesionPair, updateLesionStatuses } from './utils/lesionAnalysis';
import DataLoadModal from './components/DataLoadModal';
import SliceViewer from './components/SliceViewer';
import ControlPanel from './components/ControlPanel';
import { readNiftiFile } from './utils/niftiLoader';
import './global.css';
export default function App() {
// --- State ---
const [volumes, setVolumes] = useState({});
const [dims, setDims] = useState([1, 1, 1]);
const [pixDims, setPixDims] = useState([1, 1, 1]);
const [lesions, setLesions] = useState([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [coords, setCoords] = useState({ x: 0, y: 0, z: 0 });
const [viewMode, setViewMode] = useState(1); // 1 = Baseline, 2 = Followup
const [loading, setLoading] = useState(false);
const [loadingMsg, setLoadingMsg] = useState("");
const [modalVisible, setModalVisible] = useState(true);
const [visibility, setVisibility] = useState('One');
const [opacity, setOpacity] = useState(0.5);
const [windowMin, setWindowMin] = useState(0);
const [windowMax, setWindowMax] = useState(1000);
const [contrast, setContrast] = useState({ min: 0, max: 1000 });
// --- Handlers ---
const handleDataLoad = async (buffers) => {
setLoading(true);
setLoadingMsg("Parsing NIfTI headers and data...");
try {
// We assume DataLoadModal returns parsed Float32Arrays for simplicity
// OR we parse them here if it returns ArrayBuffers.
// Let's assume DataLoadModal calls `analyzeLesionPair`?
// No, separation of concern: Modal loads files, App coordinates analysis.
// Wait, Modal code I wrote returns `buffers` (ArrayBuffers) to `onLoadData`.
// We need to parse NIfTI here or in a helper.
// To keep App clean, let's assume `analyzeLesionPair` handles reading?
// No, `analyzeLesionPair` takes `mask1, mask2`.
// So we need to parse NIfTI first.
// I should have put parse logic in Modal or a helper.
// I'll import `readNiftiFile` here and parse.
} catch (e) {
console.error(e);
alert("Error processing data: " + e.message);
setLoading(false);
}
};
// Re-write of handleDataLoad to actually implement logic:
return (
<LesionViewApp />
);
}
function LesionViewApp() {
const [volumes, setVolumes] = useState({});
const [dims, setDims] = useState([256, 256, 256]);
const [pixDims, setPixDims] = useState([1, 1, 1]); // Standard 1mm isotropic default
// Lesion List
const [lesions, setLesions] = useState([]);
const [currentIndex, setCurrentIndex] = useState(-1); // -1 means no selection
// View State
const [coords, setCoords] = useState({ x: 128, y: 128, z: 128 });
const [viewMode, setViewMode] = useState(1); // 1 = Baseline (Blue), 2 = Followup (Green)
const [isLoadModalOpen, setIsLoadModalOpen] = useState(true);
// UI State
const [loadingMsg, setLoadingMsg] = useState(null);
const [contrast, setContrast] = useState({ min: -0.5, max: 3.5 });
const [windowMin, setWindowMin] = useState(-0.5);
const [windowMax, setWindowMax] = useState(3.5);
const [visibility, setVisibility] = useState('One'); // 'One' or 'All'
const [opacity, setOpacity] = useState(0.5);
// Filter State
const [filters, setFilters] = useState({
new: true,
growing: true,
stable: true, // Includes: static, shrinking, gone
});
// Filtered Lesions Logic
// Check if ALL filters are enabled (user viewing everything)
const allFiltersEnabled = filters.new && filters.growing && filters.stable;
const filteredLesions = React.useMemo(() => {
return lesions.filter(l => {
// When ALL filters are enabled, include deleted lesions for navigation (to allow undo)
// When specific filters are active, exclude deleted lesions
if (!allFiltersEnabled) {
// Check if deleted on EITHER timepoint - exclude from specific filter views
if (l.review1 === 'delete' && l.review2 === 'delete') return false;
}
// Status Mapping
if (l.status === 'new') return filters.new;
if (l.status === 'growing') return filters.growing;
// Stable Category includes: shrinking, static, gone
if (l.status === 'shrinking' || l.status === 'static' || l.status === 'gone') {
return filters.stable;
}
return true; // Default show if unknown status
});
}, [lesions, filters, allFiltersEnabled]);
const currentFilteredIndex = filteredLesions.findIndex(l => l.id === lesions[currentIndex]?.id);
// Zoom Handling
const [topZoom, setTopZoom] = useState(4); // Zoom factor for top row
// --- Actions ---
const handleLoadData = async (buffers) => {
setIsLoadModalOpen(false);
setLoadingMsg("Parsing Volumes...");
// Note: readNiftiFile is imported at top level now
try {
// 1. Parse all 4 files
await new Promise(r => setTimeout(r, 50));
setLoadingMsg("Parsing Normalizing FLAIRs...");
const f1Raw = readNiftiFile(buffers.flair1);
const f2Raw = readNiftiFile(buffers.flair2);
// Normalize immediately
const f1Data = zNormalize(f1Raw.data);
const f2Data = zNormalize(f2Raw.data);
setLoadingMsg("Parsing Masks...");
const m1 = readNiftiFile(buffers.mask1);
const m2 = readNiftiFile(buffers.mask2);
// Construct verified objects
const f1 = { ...f1Raw, data: f1Data };
const f2 = { ...f2Raw, data: f2Data };
// Validation: Check dimensions match
if (f1.dims[1] !== f2.dims[1] || f1.dims[2] !== f2.dims[2]) {
throw new Error("Dimension mismatch between time points!");
}
const dimensions = [f1.dims[0], f1.dims[1], f1.dims[2]];
setDims(dimensions);
setPixDims(f1.pixDims);
// 2. Contrast Estimation (from Baseline FLAIR)
setLoadingMsg("Calculating Contrast...");
await new Promise(r => setTimeout(r, 50));
const range = calculateContrastPercentiles(f1.data);
setContrast(range);
// Also update the window values used by sliders/viewer immediately
// This is crucial: if windowMin/Max are 0/1000 but range is e.g. 0/1, it looks black
setWindowMin(range.min);
setWindowMax(range.max);
// 3. Lesion Analysis
setLoadingMsg("Analyzing Lesion Changes...");
// Pass the raw data (Float32 or Int16) to analysis
const analysis = await analyzeLesionPair(m1.data, m2.data, dimensions, (msg) => {
setLoadingMsg(msg);
});
// 4. Set State
setVolumes({
flair1: f1.data,
mask1: analysis.L1, // The labeled mask (Int32Array)
mask1_orig: new Int32Array(analysis.L1), // Backup for Reset
flair2: f2.data,
mask2: analysis.L2, // The labeled mask (Int32Array)
mask2_orig: new Int32Array(analysis.L2), // Backup for Reset
rawCount1: analysis.rawCount1,
rawCount2: analysis.rawCount2
});
setLesions(analysis.lesions);
if (analysis.lesions.length > 0) {
jumpToLesion(0, analysis.lesions);
} else {
setCoords({ x: Math.floor(dimensions[0] / 2), y: Math.floor(dimensions[1] / 2), z: Math.floor(dimensions[2] / 2) });
}
setLoadingMsg(null);
} catch (e) {
console.error(e);
alert("Error: " + e.message);
setLoadingMsg(null);
setIsLoadModalOpen(true);
}
};
const jumpToLesion = (idx, list = lesions) => {
if (!list || list.length === 0 || idx < 0 || idx >= list.length) return;
setCurrentIndex(idx);
const l = list[idx];
// Jump to centroid of the ACTIVE time point
// If viewMode=1 (Baseline), try jump to base. If base is null (new lesion), jump to follow.
// If viewMode=2 (Followup), try jump to follow.
let target = (viewMode === 1) ? l.base : l.follow;
if (!target) target = (viewMode === 1) ? l.follow : l.base; // Fallback
if (target) {
setCoords({ x: target.x, y: target.y, z: target.z });
}
};
// --- Navigation Handlers ---
const handleNext = () => {
if (currentFilteredIndex < filteredLesions.length - 1) {
const nextLesion = filteredLesions[currentFilteredIndex + 1];
const realIndex = lesions.findIndex(l => l.id === nextLesion.id);
jumpToLesion(realIndex);
}
};
const handlePrev = () => {
if (currentFilteredIndex > 0) {
const prevLesion = filteredLesions[currentFilteredIndex - 1];
const realIndex = lesions.findIndex(l => l.id === prevLesion.id);
jumpToLesion(realIndex);
}
};
const handleFirst = () => {
if (filteredLesions.length > 0) {
const firstLesion = filteredLesions[0];
const realIndex = lesions.findIndex(l => l.id === firstLesion.id);
jumpToLesion(realIndex);
}
};
const handleLast = () => {
if (filteredLesions.length > 0) {
const lastLesion = filteredLesions[filteredLesions.length - 1];
const realIndex = lesions.findIndex(l => l.id === lastLesion.id);
jumpToLesion(realIndex);
}
};
const handleUpdateCoords = (newCoords) => {
setCoords(prev => ({ ...prev, ...newCoords }));
};
// Updated Filter Handler
const handleFilterChange = (type) => { // type: 'all' | 'new' | 'growing' | 'stable'
if (type === 'all') {
// Toggle All
const allSelected = filters.new && filters.growing && filters.stable;
const newState = !allSelected;
setFilters({
new: newState,
growing: newState,
stable: newState
});
} else {
setFilters(prev => ({
...prev,
[type]: !prev[type]
}));
}
};
// --- Manipulation Handlers ---
const handleReview = (state, timepoint = null) => {
if (currentIndex === -1) return;
// If timepoint is specified, update only that timepoint's review state
// If not specified, update both (legacy behavior for backwards compat)
const newLesions = [...lesions];
const l = newLesions[currentIndex];
if (timepoint === 1) {
newLesions[currentIndex] = { ...l, review1: state };
} else if (timepoint === 2) {
newLesions[currentIndex] = { ...l, review2: state };
} else {
// No timepoint specified - update both (or use viewMode)
// Use current viewMode to determine which timepoint
const tp = viewMode;
if (tp === 1) {
newLesions[currentIndex] = { ...l, review1: state };
} else {
newLesions[currentIndex] = { ...l, review2: state };
}
}
setLesions(newLesions);
};
const handleEdit = (action) => {
if (currentIndex === -1) return;
const l = lesions[currentIndex];
const id = l.id;
// Direct buffer manipulation (Be Careful! State mutation allowed if we force re-render?)
// React doesn't detect deep Int32Array changes. We might need to trick SliceViewer to re-render.
// Or just `setVolumes({ ...volumes })` to trigger effect.
const L1 = volumes.mask1;
const L2 = volumes.mask2;
const size = L1.length;
// Logic from MATLAB
// Clone 1: L2 becomes L1
// Clone 2: L1 becomes L2
// Merge: Both become ID
// Reset: (Need backup... skip for MVP or re-implement backup logic later)
if (action === 'clone 1') {
// Copy L1 shape to L2
// 1. Clear L2 where it currently has ID
// 2. Set L2 where L1 has ID
// Optimization: Single pass?
for (let i = 0; i < size; i++) {
if (L2[i] === id) L2[i] = 0; // Clear old
if (L1[i] === id) L2[i] = id; // Set new
}
} else if (action === 'clone 2') {
for (let i = 0; i < size; i++) {
if (L1[i] === id) L1[i] = 0;
if (L2[i] === id) L1[i] = id;
}
} else if (action === 'merge') {
for (let i = 0; i < size; i++) {
if (L1[i] === id) L2[i] = id;
if (L2[i] === id) L1[i] = id;
}
} else if (action === 'reset') {
// Revert voxels for this specific lesion ID from the backup
// We stored original masks in volumes.mask1_orig and volumes.mask2_orig
let L1_orig = volumes.mask1_orig;
let L2_orig = volumes.mask2_orig;
if (!L1_orig || !L2_orig) {
// Fallback: If reset is called but no initial snapshot exists (e.g. legacy data loaded),
// we can't truly reset. But to avoid crash, we warn.
console.warn("Original data not available for reset. Initializing backup now (current state will be new baseline).");
// Lazy init to avoid crash loop, but this means 'reset' sets current as baseline.
// Better than nothing? Or maybe user WANTS this if they reloaded?
L1_orig = new Int32Array(L1);
L2_orig = new Int32Array(L2);
// Update state with new backups so future resets work relative to ANY saved checkpoint?
// No, we shouldn't update state here inside render/handler easily without loop.
// We will just use these locals for now.
// To persist:
setVolumes(prev => ({ ...prev, mask1_orig: L1_orig, mask2_orig: L2_orig }));
}
for (let i = 0; i < size; i++) {
// Condition for Reset:
// 1. If pixel IS currently 'id', revert it (it might have been drawn over or erased, or shouldn't be 'id')
// 2. If pixel WAS originally 'id', restore it (in case we erased it)
// Check L1
if (L1[i] === id || L1_orig[i] === id) {
L1[i] = L1_orig[i];
}
// Check L2
if (L2[i] === id || L2_orig[i] === id) {
L2[i] = L2_orig[i];
}
}
}
// Trigger re-render
setVolumes({ ...volumes });
// Also update volume in stats?
// Re-calculating volume is expensive (full iteration).
// Maybe just update the specific lesion object volume?
// TODO: Update `l.base.volume` / `l.follow.volume` based on change.
};
// --- Key Stats ---
// Calculates Report-Style Stats (Transition & Volumes)
const stats = React.useMemo(() => {
// raw counts come from the analysis result (need to store them in state if we want them here)
// We didn't store `rawCount1` in `volumes` state. Let's assume we update `setVolumes` to include them
// or just calculate from lesions if we accept "Unified Count" as the "Count" (but user asked for Raw "64 -> 44")
// To get Raw Counts "64 -> 44", we need the info from `analyzeLesionPair`.
// I updated `analyzeLesionPair` to return them, BUT I didn't save them in `App.js` state in `handleLoadData`.
// I need to update `handleLoadData` first to save them!
// Fallback if not saved yet: Use Unified Count (which is 70->70 effectively if we don't separate).
// Actually, `volumes.rawCount1` will be undefined unless I update `handleLoadData`.
// Correct Stats Logic to match Report "64 -> 44"
// The Report shows the number of tracked lesions that are present (Volume > 0) at each time point.
// Unified Count (70) is the union.
// 1. Filter out fully deleted lesions (deleted on BOTH timepoints)
// For per-timepoint counts, consider only lesions not deleted on that TP
const activeTP1 = lesions.filter(l => l.review1 !== 'delete');
const activeTP2 = lesions.filter(l => l.review2 !== 'delete');
// Count Active at T1 (non-deleted on TP1 with volume > 0)
const count1 = activeTP1.filter(l => (l.base?.volume || 0) > 0).length;
// Count Active at T2 (non-deleted on TP2 with volume > 0)
const count2 = activeTP2.filter(l => (l.follow?.volume || 0) > 0).length;
// Calculate Volumes (ml)
// Use per-timepoint filtering for volumes
let v1Total = 0;
let v2Total = 0;
activeTP1.forEach(l => {
v1Total += (l.base?.volume || 0);
});
activeTP2.forEach(l => {
v2Total += (l.follow?.volume || 0);
});
// Convert to ml (approx 0.001 per voxel if 1mm3)
// TODO: Use pixDims!
const voxelSize = pixDims[0] * pixDims[1] * pixDims[2]; // e.g. 1*1*1 = 1
const v1Ml = v1Total * voxelSize; // volume in mm3
const v2Ml = v2Total * voxelSize;
// User requested "14155.6", which is mm3. "11041.5".
// If we want "ml", we divide by 1000.
// The previous screenshot showed "-9.08 ml".
// "14155" mm3 is ~14ml.
// I will show mm3 to match the "14155.6 -> 11041.5" request exactly.
// For new/growing counts, use lesions not deleted on TP2 (where the status applies)
const activeForStatus = lesions.filter(l => l.review2 !== 'delete');
return {
countStr: `${count1} → ${count2}`,
volStr: `${v1Ml.toFixed(1)} → ${v2Ml.toFixed(1)}`, // mm3
newCount: activeForStatus.filter(l => l.status === 'new').length,
growingCount: activeForStatus.filter(l => l.status === 'growing').length,
volChange: (v2Ml - v1Ml).toFixed(1),
pctChange: v1Ml > 0 ? ((v2Ml - v1Ml) / v1Ml * 100).toFixed(1) + '%' : '0%'
};
}, [lesions, pixDims]);
// --- Shortcut & Playback Logic ---
// Playback state
const [isPlaying, setIsPlaying] = useState(false);
useEffect(() => {
let interval;
if (isPlaying) {
interval = setInterval(() => {
setViewMode(m => m === 1 ? 2 : 1);
}, 500); // 500ms toggle
}
return () => clearInterval(interval);
}, [isPlaying]);
// Keyboard Shortcuts
useEffect(() => {
const handleKeyDown = (e) => {
// Prevent default only for mapped keys to avoid blocking other inputs?
// Actually, for web, let's just use e.key
switch (e.key) {
case 'ArrowUp':
setViewMode(1);
e.preventDefault();
break;
case 'ArrowDown':
setViewMode(2);
e.preventDefault();
break;
case 'ArrowLeft':
handlePrev();
e.preventDefault();
break;
case 'ArrowRight':
handleNext();
e.preventDefault();
break;
case 'Enter':
if (e.shiftKey) {
// Shift+Enter: Reset Mask
handleEdit('reset');
e.preventDefault();
e.stopPropagation(); // Try to stop propagation just in case
} else {
// Enter: Update Analysis
handleUpdateAnalysis();
e.preventDefault();
}
break;
case 'Delete':
case 'Backspace':
// Toggle Keep/Delete for CURRENT timepoint only
const curLesion = lesions[currentIndex];
const reviewKey = viewMode === 1 ? 'review1' : 'review2';
const curStatus = curLesion?.[reviewKey];
const nStatus = (curStatus === 'keep') ? 'delete' : 'keep';
handleReview(nStatus, viewMode);
e.preventDefault();
break;
case ' ': // Space
setIsPlaying(p => !p);
e.preventDefault();
break;
case 'x':
case 'X':
if (e.shiftKey) {
// Shift+X: Toggle Scope (All vs Selected)
setVisibility(prev => prev === 'All' ? 'One' : 'All');
} else {
// X: Toggle Visibility (Global)
setShowSelectedMask(prev => !prev);
}
e.preventDefault();
break;
case '1':
handleEdit('clone 1');
break;
case '2':
handleEdit('clone 2');
break;
case '3':
handleEdit('merge');
break;
case '4':
handleEdit('reset'); // Alias
break;
case '-':
case '_':
// Zoom out (decrease top view zoom)
setTopZoom(prev => Math.max(1, prev - 0.5));
e.preventDefault();
break;
case '=':
case '+':
// Zoom in (increase top view zoom)
setTopZoom(prev => Math.min(10, prev + 0.5));
e.preventDefault();
break;
}
};
// Attach to window
if (Platform.OS === 'web') {
// Use capture mode to intercept Enter before buttons process it if focused
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
}
}, [currentIndex, lesions, viewMode, topZoom]); // Dependencies for handlers
// New State for Overlay Visibility
const [showSelectedMask, setShowSelectedMask] = useState(true);
const handleUpdateAnalysis = async () => {
if (!volumes.mask1 || !volumes.mask2) return;
setLoadingMsg("Updating Lesion Status...");
// Force a small delay to allow UI to render the loading message
await new Promise(r => setTimeout(r, 50));
try {
// Use the Label-Preserving update updateLesionStatuses
// This mimics MATLAB's updateNewGoneLesionIndex + updateLesionSize
// It DOES NOT re-segment, so it preserves IDs even after manual edits (Clone/Merge).
const updatedLesions = updateLesionStatuses(volumes.mask1, volumes.mask2, lesions, pixDims);
setLesions(updatedLesions);
// Force volume update to trigger listeners if needed
setVolumes(prev => ({ ...prev }));
} catch (e) {
console.error(e);
alert("Error updating analysis: " + e.message);
} finally {
setLoadingMsg(null);
}
};
// --- Render Props ---
const activeModality = viewMode === 1 ? 'flair1' : 'flair2';
const activeMaskName = viewMode === 1 ? 'mask1' : 'mask2';
// Standardize colors: Blue for T1, Green for T2. No specific "selected" color needed as per request.
const activeColor = viewMode === 1 ? [96, 165, 250] : [74, 222, 128];
const activeSelectedColor = activeColor; // Use same color, maybe rely on crosshair?
// Zoomed coords
const fovZoomVal = topZoom;
// Calculate visible IDs for the viewer based on per-timepoint delete state
const visibleLesionIds = React.useMemo(() => {
// Determine which review key to use based on viewMode
const reviewKey = viewMode === 1 ? 'review1' : 'review2';
// If we are in "Show All" mode (Shift+X), show all EXCEPT deleted for current TP
if (visibility === 'All') {
const allNonDeleted = lesions.filter(l => l[reviewKey] !== 'delete').map(l => l.id);
return new Set(allNonDeleted);
}
// Otherwise, show filtered list (AND exclude deleted for current TP)
return new Set(filteredLesions.filter(l => l[reviewKey] !== 'delete').map(l => l.id));
}, [filteredLesions, visibility, lesions, viewMode]);
return (
<SafeAreaView className="flex-1 bg-black">
<StatusBar style="light" />
{/* Top Bar / Header */}
<View className="h-14 bg-surface border-b border-border flex-row items-center px-4 justify-between z-10">
<Text className="text-white font-bold text-lg">LesionView <Text className="text-primary font-normal text-sm">Online</Text></Text>
{/* View Toggles & Update */}
<View className="flex-row items-center gap-2">
<View className="flex-row bg-black/50 rounded-lg p-1 border border-border">
<TouchableOpacity
onPress={() => setViewMode(1)}
className={`px-4 py-1 rounded ${viewMode === 1 ? 'bg-blue-600' : 'hover:bg-white/10'}`}
>
<Text className={`font-bold ${viewMode === 1 ? 'text-white' : 'text-muted'}`}>Time 1</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setViewMode(2)}
className={`px-4 py-1 rounded ${viewMode === 2 ? 'bg-green-600' : 'hover:bg-white/10'}`}
>
<Text className={`font-bold ${viewMode === 2 ? 'text-white' : 'text-muted'}`}>Time 2</Text>
</TouchableOpacity>
</View>
<TouchableOpacity
onPress={handleUpdateAnalysis}
className="px-3 py-1.5 rounded-lg border border-primary bg-primary/20 hover:bg-primary/40 ml-2"
>
<Text className="text-white font-bold text-sm">Update</Text>
</TouchableOpacity>
</View>
</View>
{/* Main Content: Viewer (Left) + Controls (Right) */}
<View className="flex-1 flex-row">
{/* Left: Viewer Grid */}
<View className="flex-1 bg-black flex-col">
{/* Top Row: Zoomed Views */}
<View className="flex-1 flex-row border-b border-white/20" style={{ minHeight: '40%' }}>
<SliceViewer
label="Sagittal (Zoom)"
axis="x"
volumes={volumes}
dims={dims}
pixDims={pixDims}
coords={coords}
zoom={1}
fovZoom={fovZoomVal}
windowMin={windowMin}
windowMax={windowMax}
modality={activeModality}
lesionVolumeName={activeMaskName}
showMask={showSelectedMask}
maskScope={visibility === 'All' ? 'all' : 'selected'} // If showing all, scope is all. If one, scope is selected.
visibleLesionIds={visibleLesionIds}
currentLesionLabel={lesions[currentIndex]?.id}
maskColor={activeColor}
selectedColor={activeSelectedColor}
interactive={true}
onClick={handleUpdateCoords}
onSliceChange={(v) => handleUpdateCoords({ ...coords, x: v })}
/>
<SliceViewer
label="Coronal (Zoom)"
axis="y"
volumes={volumes}
dims={dims}
pixDims={pixDims}
coords={coords}
zoom={1}
fovZoom={fovZoomVal}
windowMin={windowMin}
windowMax={windowMax}
modality={activeModality}
lesionVolumeName={activeMaskName}
showMask={showSelectedMask}
maskScope={visibility === 'All' ? 'all' : 'selected'}
visibleLesionIds={visibleLesionIds}
currentLesionLabel={lesions[currentIndex]?.id}
maskColor={activeColor}
selectedColor={activeSelectedColor}
interactive={true}
onClick={handleUpdateCoords}
onSliceChange={(v) => handleUpdateCoords({ ...coords, y: v })}
/>
<SliceViewer
label="Axial (Zoom)"
axis="z"
volumes={volumes}
dims={dims}
pixDims={pixDims}
coords={coords}
zoom={1}
fovZoom={fovZoomVal}
windowMin={windowMin}
windowMax={windowMax}
modality={activeModality}
lesionVolumeName={activeMaskName}
showMask={showSelectedMask}
visibleLesionIds={visibleLesionIds}
maskScope={visibility === 'All' ? 'all' : 'selected'}
currentLesionLabel={lesions[currentIndex]?.id}
maskColor={activeColor}
selectedColor={activeSelectedColor}
interactive={true}
onClick={handleUpdateCoords}
onSliceChange={(v) => handleUpdateCoords({ ...coords, z: v })}
/>
</View>
{/* Bottom Row: Full Views */}
<View className="flex-1 flex-row">
<SliceViewer
label="Sagittal"
axis="x"
volumes={volumes}
dims={dims}
pixDims={pixDims}
coords={coords}
zoom={1}
boxZoom={fovZoomVal} // Show box indicating zoom area
windowMin={windowMin}
windowMax={windowMax}
modality={activeModality}
lesionVolumeName={activeMaskName}
showMask={showSelectedMask}
maskScope={visibility === 'All' ? 'all' : 'selected'}
visibleLesionIds={visibleLesionIds}
currentLesionLabel={lesions[currentIndex]?.id}
maskColor={activeColor}
selectedColor={activeSelectedColor}
cursor='box'
interactive={true}
onClick={handleUpdateCoords}
onSliceChange={(v) => handleUpdateCoords({ ...coords, x: v })}
/>
<SliceViewer
label="Coronal"
axis="y"
volumes={volumes}
dims={dims}
pixDims={pixDims}
coords={coords}
zoom={1}
boxZoom={fovZoomVal}
windowMin={windowMin}
windowMax={windowMax}
modality={activeModality}
lesionVolumeName={activeMaskName}
showMask={showSelectedMask}
maskScope={visibility === 'All' ? 'all' : 'selected'}
visibleLesionIds={visibleLesionIds}
currentLesionLabel={lesions[currentIndex]?.id}
maskColor={activeColor}
selectedColor={activeSelectedColor}
cursor='box'
interactive={true}
onClick={handleUpdateCoords}
onSliceChange={(v) => handleUpdateCoords({ ...coords, y: v })}
/>
<SliceViewer
label="Axial"
axis="z"
volumes={volumes}
dims={dims}
pixDims={pixDims}
coords={coords}
zoom={1}
boxZoom={fovZoomVal}
windowMin={windowMin}
windowMax={windowMax}
modality={activeModality}
lesionVolumeName={activeMaskName}
showMask={showSelectedMask}
maskScope={visibility === 'All' ? 'all' : 'selected'}
visibleLesionIds={visibleLesionIds}
currentLesionLabel={lesions[currentIndex]?.id}
maskColor={activeColor}
selectedColor={activeSelectedColor}
cursor='box'
interactive={true}
onClick={handleUpdateCoords}
onSliceChange={(v) => handleUpdateCoords({ ...coords, z: v })}
/>
</View>
</View>
{/* Right: Controls */}
<ControlPanel
lesions={lesions}
currentIndex={currentIndex}
onNext={handleNext}
onPrev={handlePrev}
onFirst={handleFirst}
onLast={handleLast}
reviewState={viewMode === 1 ? lesions[currentIndex]?.review1 : lesions[currentIndex]?.review2}
onReview={handleReview}
editState={lesions[currentIndex]?.edit}
onEdit={handleEdit}
onUpdateAnalysis={handleUpdateAnalysis}
stats={stats}
visibility={visibility}
onVisibilityChange={setVisibility}
filters={filters}
onFilterChange={handleFilterChange}
opacity={opacity}
onOpacityChange={setOpacity}
/>
</View>
{/* Overlays */}
{isLoadModalOpen && (
<DataLoadModal
visible={isLoadModalOpen}
onClose={() => setIsLoadModalOpen(false)}
onLoadData={handleLoadData}
/>
)}
{loadingMsg && (
<View className="absolute inset-0 bg-black/80 items-center justify-center z-50">
<Text className="text-white font-bold text-xl">{loadingMsg}</Text>
{/* ActivityIndicator could go here */}
</View>
)}
</SafeAreaView>
);
}