Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,8 @@ Critical MRI Source Images (LLM Agent - do not delete)/
# Local test artifacts (downloaded DICOMs, zips, etc.)
frontend/tmp/

# Vercel local project metadata
.vercel/

# Accidental root lockfile (repo does not have a root package.json)
/package-lock.json
6 changes: 6 additions & 0 deletions frontend/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
dist
release
node_modules
tmp
.logs
*.log
5 changes: 5 additions & 0 deletions frontend/.prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"printWidth": 120,
"singleQuote": true,
"trailingComma": "all"
}
56 changes: 56 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
"dev": "node scripts/dev.mjs",
"build": "tsc -b && vite build",
"lint": "eslint .",
"prettier": "prettier -w .",
"prettier:check": "prettier -c .",
"test": "vitest run",
"check": "npm run lint && npm run test",
"preview": "vite preview",
Expand All @@ -25,6 +27,7 @@
"idb": "^8.0.3",
"jszip": "^3.10.1",
"lucide-react": "^0.562.0",
"onnxruntime-web": "^1.23.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwindcss": "^4.1.18"
Expand All @@ -45,6 +48,7 @@
"fake-indexeddb": "^6.2.5",
"globals": "^16.5.0",
"jsdom": "^27.4.0",
"prettier": "^3.2.5",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4",
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/AlignmentControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export function AlignmentControls({
</span>
{progress.slicesChecked > 0 && (
<span className="text-[var(--text-tertiary)]">
{progress.slicesChecked} slices · MI {progress.bestMiSoFar.toFixed(3)}
{progress.slicesChecked} slices · Score {progress.bestMiSoFar.toFixed(3)}
</span>
)}
</div>
Expand Down
136 changes: 111 additions & 25 deletions frontend/src/components/ComparisonMatrix.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ import {
Trash2,
MoreVertical,
HelpCircle,
Box,
} from 'lucide-react';
import { HelpModal } from './HelpModal';
import { UploadModal } from './UploadModal';
import { ExportModal } from './ExportModal';
import { ClearDataModal } from './ClearDataModal';
import { Svr3DView } from './Svr3DView';
import { SliceLoopNavigator } from './comparison/SliceLoopNavigator';
import { GridView } from './comparison/GridView';
import { OverlayView } from './comparison/OverlayView';
Expand Down Expand Up @@ -139,6 +141,8 @@ export function ComparisonMatrix() {
isAligning,
progress: alignmentProgress,
results: alignmentResults,
error: alignmentError,
clearState: clearAlignmentState,
alignAllDates,
abort: abortAlignment,
} = useAutoAlign();
Expand All @@ -161,7 +165,8 @@ export function ComparisonMatrix() {
const planeKey = (plane: string | null) => (plane && plane.trim() ? plane : 'Other');

return data.sequences
.filter(s => planeKey(s.plane) === selectedPlane)
.filter((s) => planeKey(s.plane) === selectedPlane)
.filter((s) => formatSequenceLabel(s) !== 'Unknown')
.sort((a, b) => formatSequenceLabel(b).localeCompare(formatSequenceLabel(a))); // reverse alpha
}, [data, selectedPlane]);

Expand Down Expand Up @@ -280,6 +285,45 @@ export function ComparisonMatrix() {

const overlayViewerSize = getOverlayViewerSize(gridSize);

// Seed SVR 3D ROI preview slice:
// - Prefer the currently displayed overlay slice when available.
// - Otherwise fall back to the newest enabled date in the grid.
const svr3dSeed = useMemo(() => {
if (overlayDisplayedDate && overlayDisplayedRef) {
return {
defaultDateIso: overlayDisplayedDate,
fallbackRoiSeriesUid: overlayDisplayedRef.series_uid,
fallbackRoiSliceIndex: overlayDisplayedEffectiveSliceIndex,
};
}

const first = columns.find((c) => c.ref);
if (!first?.ref) {
return {
defaultDateIso: null,
fallbackRoiSeriesUid: null,
fallbackRoiSliceIndex: null,
};
}

const settings = panelSettings.get(first.date) || DEFAULT_PANEL_SETTINGS;
const sliceIndex = getSliceIndex(first.ref.instance_count, progress, settings.offset);
const effectiveIndex = getEffectiveInstanceIndex(sliceIndex, first.ref.instance_count, settings.reverseSliceOrder);

return {
defaultDateIso: first.date,
fallbackRoiSeriesUid: first.ref.series_uid,
fallbackRoiSliceIndex: effectiveIndex,
};
}, [
columns,
overlayDisplayedDate,
overlayDisplayedEffectiveSliceIndex,
overlayDisplayedRef,
panelSettings,
progress,
]);

const startAlignAll = useCallback(
async (reference: AlignmentReference, exclusionMask: ExclusionMask) => {
if (isAligning) {
Expand Down Expand Up @@ -323,10 +367,16 @@ export function ComparisonMatrix() {
// Notes:
// - We intentionally do NOT run this when the wheel event is over a scrollable container
// (e.g. the sidebars), so normal scrolling still works.
// - Individual DicomViewer instances still handle wheel events directly; those events call
// preventDefault, and we skip them here via `e.defaultPrevented`.
// - Individual DicomViewer instances handle wheel events over images (zoom) and call preventDefault.
// We skip them here via `e.defaultPrevented`.
const wheelNavContextRef = useRef<{ instanceCount: number; offset: number } | null>(null);
useEffect(() => {
if (viewMode === 'svr3d') {
// The SVR 3D view uses mousewheel for zoom; don't hijack wheel events for slice navigation.
wheelNavContextRef.current = null;
return;
}

let instanceCount = 1;
let offset = DEFAULT_PANEL_SETTINGS.offset;

Expand Down Expand Up @@ -438,12 +488,20 @@ export function ComparisonMatrix() {
</button>
<button
onClick={() => setViewMode('overlay')}
className={`px-3 py-1.5 text-xs rounded-r-lg transition-colors flex items-center gap-1.5 ${viewMode === 'overlay' ? 'bg-[var(--accent)] text-white' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
className={`px-3 py-1.5 text-xs transition-colors flex items-center gap-1.5 ${viewMode === 'overlay' ? 'bg-[var(--accent)] text-white' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
title="Overlay view - toggle between dates"
>
<Layers className="w-3.5 h-3.5" />
Overlay
</button>
<button
onClick={() => setViewMode('svr3d')}
className={`px-3 py-1.5 text-xs rounded-r-lg transition-colors flex items-center gap-1.5 ${viewMode === 'svr3d' ? 'bg-[var(--accent)] text-white' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
title="SVR 3D view"
>
<Box className="w-3.5 h-3.5" />
3D
</button>
</div>
</div>

Expand Down Expand Up @@ -582,20 +640,36 @@ export function ComparisonMatrix() {

{/* Main area with sidebar */}
<div className="flex-1 flex overflow-hidden relative">
<ComparisonFiltersSidebar
open={sidebarOpen}
onToggleOpen={() => setSidebarOpen((v) => !v)}
availablePlanes={availablePlanes}
selectedPlane={selectedPlane}
onSelectPlane={selectPlane}
sequencesForPlane={sequencesForPlane}
sequencesWithDataForDates={sequencesWithDataForDates}
selectedSeqId={selectedSeqId}
onSelectSequence={selectSequence}
/>

{/* Main content area - Grid or Overlay */}
{viewMode !== 'svr3d' ? (
<ComparisonFiltersSidebar
open={sidebarOpen}
onToggleOpen={() => setSidebarOpen((v) => !v)}
availablePlanes={availablePlanes}
selectedPlane={selectedPlane}
onSelectPlane={selectPlane}
sequencesForPlane={sequencesForPlane}
sequencesWithDataForDates={sequencesWithDataForDates}
selectedSeqId={selectedSeqId}
onSelectSequence={selectSequence}
/>
) : null}

{/* Main content area - Grid / Overlay / SVR 3D */}
<div ref={setCenterPaneRef} className="flex-1 overflow-hidden bg-black flex flex-col relative">
{alignmentError && !isAligning ? (
<div className="absolute top-2 left-2 right-2 z-50 flex items-center justify-between gap-3 px-3 py-2 rounded-lg bg-red-950/80 border border-red-500/30 text-red-100 text-sm">
<div className="min-w-0 truncate">
<span className="font-medium">Alignment failed:</span> {alignmentError}
</div>
<button
type="button"
className="shrink-0 px-2 py-1 rounded bg-red-500/20 hover:bg-red-500/30 border border-red-500/30"
onClick={() => clearAlignmentState()}
>
Dismiss
</button>
</div>
) : null}
{!hasData ? (
/* Empty state */
<div className="flex-1 flex flex-col items-center justify-center gap-8 text-center p-8 max-w-2xl mx-auto">
Expand Down Expand Up @@ -628,6 +702,7 @@ export function ComparisonMatrix() {
</div>
) : viewMode === 'grid' ? (
<GridView
comboId={selectedSeqId}
columns={columns}
gridCols={gridCols}
gridCellSize={gridCellSize}
Expand All @@ -641,8 +716,9 @@ export function ComparisonMatrix() {
abortAlignment={abortAlignment}
startAlignAll={startAlignAll}
/>
) : (
) : viewMode === 'overlay' ? (
<OverlayView
comboId={selectedSeqId}
overlayColumns={overlayColumns}
overlayViewerSize={overlayViewerSize}
overlayDisplayedRef={overlayDisplayedRef}
Expand All @@ -667,6 +743,14 @@ export function ComparisonMatrix() {
startAlignAll={startAlignAll}
setProgress={setProgress}
/>
) : (
<Svr3DView
data={data}
defaultDateIso={svr3dSeed.defaultDateIso}
defaultSeqId={selectedSeqId}
fallbackRoiSeriesUid={svr3dSeed.fallbackRoiSeriesUid}
fallbackRoiSliceIndex={svr3dSeed.fallbackRoiSliceIndex}
/>
)}

</div>
Expand All @@ -684,13 +768,15 @@ export function ComparisonMatrix() {
</div>

{/* Slice navigator with loop + speed controls */}
<SliceLoopNavigator
selectedSeqId={selectedSeqId}
playbackInstanceCount={playbackInstanceCount}
progress={progress}
progressRef={progressRef}
setProgress={setProgress}
/>
{viewMode !== 'svr3d' ? (
<SliceLoopNavigator
selectedSeqId={selectedSeqId}
playbackInstanceCount={playbackInstanceCount}
progress={progress}
progressRef={progressRef}
setProgress={setProgress}
/>
) : null}
</div>
);
}
Loading