diff --git a/dicom-viewer/src/App.tsx b/dicom-viewer/src/App.tsx index e4bbd52..7b15d06 100644 --- a/dicom-viewer/src/App.tsx +++ b/dicom-viewer/src/App.tsx @@ -104,8 +104,8 @@ const { MouseBindings, KeyboardBindings } = csToolsEnums; const { IMAGE_RENDERED } = Enums.Events; -// Common CT presets (add more as needed) -const WINDOW_LEVEL_PRESETS = [ +// CT-specific presets. Other modalities use reset + autolevel only. +const CT_WINDOW_LEVEL_PRESETS = [ { name: "Soft Tissue", center: 40, width: 400 }, { name: "Lung", center: -600, width: 1500 }, { name: "Bone", center: 300, width: 1500 }, @@ -113,6 +113,32 @@ const WINDOW_LEVEL_PRESETS = [ { name: "Abdomen", center: 60, width: 400 }, ] +type PresetOption = + | { name: string; action: "reset" | "autolevel" } + | { name: string; action: "preset"; center: number; width: number }; + +const getPresetOptionsForModality = (modality?: string): PresetOption[] => { + const normalized = (modality || "").toUpperCase(); + const common: PresetOption[] = [ + { name: "Reset to default", action: "reset" }, + { name: "Auto level", action: "autolevel" }, + ]; + + if (normalized !== "CT") { + return common; + } + + return [ + ...common, + ...CT_WINDOW_LEVEL_PRESETS.map((preset) => ({ + name: preset.name, + action: "preset" as const, + center: preset.center, + width: preset.width, + })), + ]; +} + // Tool options for dropdowns (icons for compact UI) const TOOL_OPTIONS = [ { label: "Window/Level", value: WindowLevelTool.toolName, icon: "🌓" }, @@ -177,16 +203,12 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer const MOUSE_SETTINGS_KEY = "dv3d_mouseToolBindings"; const CTRL_MOUSE_SETTINGS_KEY = "dv3d_ctrlMouseToolBindings"; - // State to control preset menu open/close - const [presetsOpen, setPresetsOpen] = useState(false); - const [settingsOpen, setSettingsOpen] = useState(false); // Settings open state // (top bar removed; stack browser moved to right-side panel) // Right-side stack panel open state (toggled by button) const [stackPanelOpen, setStackPanelOpen] = useState(false); - const [showOpenButtonHover, setShowOpenButtonHover] = useState(false); const [stackPanelWidth, setStackPanelWidth] = useState(320); const stackPanelResizeRef = useRef<{ resizing: boolean; startX: number; startWidth: number }>({ resizing: false, @@ -936,16 +958,23 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer windowCenter: string, windowWidth: string, slicePosition: string, + modality: string, }>>(() => Array(viewportGrid.rows * viewportGrid.cols).fill({ index: 0, total: 0, windowCenter: "", windowWidth: "", - slicePosition: 0, + slicePosition: "", + modality: "", }) ); + const [viewedIndicesByViewport, setViewedIndicesByViewport] = useState( + () => Array(MAX_VIEWPORTS).fill(null).map(() => []) + ); + const viewportStackSignaturesRef = useRef(Array(MAX_VIEWPORTS).fill("")); + const [viewportModes, setViewportModes] = useState>( () => Array(MAX_VIEWPORTS).fill('stack') ); @@ -1584,6 +1613,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer let windowCenter = ""; let windowWidth = ""; let slicePosition = ""; + let modality = ""; if (viewportModes[viewportIdx] === "stack") { currentIndex = typeof viewport.getCurrentImageIdIndex === "function" @@ -1593,6 +1623,26 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer ? viewport.getImageIds() : []; total = imageIds.length; + + const currentImageId = imageIds[currentIndex]; + if (currentImageId) { + const seriesMeta = metaData.get("generalSeriesModule", currentImageId) as any; + modality = seriesMeta?.modality || ""; + } + + if (currentIndex >= 0) { + setViewedIndicesByViewport((prev) => { + const next = [...prev]; + const existing = new Set(next[viewportIdx] || []); + if (!existing.has(currentIndex)) { + existing.add(currentIndex); + next[viewportIdx] = Array.from(existing).sort((a, b) => a - b); + return next; + } + return prev; + }); + } + if (typeof viewport.getProperties === "function") { const props = viewport.getProperties(); if (props.voiRange) { @@ -1632,6 +1682,12 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer slicePosition = coords.position.toFixed(2) + " mm"; } } + + const firstImageId = viewportImageIds[viewportIdx]?.[0]; + if (firstImageId) { + const seriesMeta = metaData.get("generalSeriesModule", firstImageId) as any; + modality = seriesMeta?.modality || ""; + } } setImageInfos(prev => { @@ -1642,13 +1698,30 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer windowCenter, windowWidth, slicePosition, // add this field + modality, }; return next; }); - }, [viewportModes]); + }, [viewportModes, viewportImageIds]); + useEffect(() => { + setViewedIndicesByViewport((prev) => { + let changed = false; + const next = [...prev]; - const [gridBtnActive, setGridBtnActive] = useState(false); + for (let i = 0; i < MAX_VIEWPORTS; i++) { + const ids = viewportImageIds[i] || []; + const signature = `${ids.length}:${ids[0] || ""}:${ids[ids.length - 1] || ""}`; + if (viewportStackSignaturesRef.current[i] !== signature) { + viewportStackSignaturesRef.current[i] = signature; + next[i] = []; + changed = true; + } + } + + return changed ? next : prev; + }); + }, [viewportImageIds]); function saveVisibleViewportState() { for (let i = 0; i < viewportGrid.rows * viewportGrid.cols; i++) { @@ -2062,6 +2135,57 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer } }, [updateOverlay]); + const handleAutoLevel = useCallback((viewportIdx: number) => { + const viewportId = `CT_${viewportIdx}`; + try { + const renderingEngine = renderingEngineRef.current; + if (!renderingEngine) return; + const viewport = renderingEngine.getViewport(viewportId); + if (!viewport) return; + + const ids = typeof viewport.getImageIds === "function" + ? viewport.getImageIds() + : (viewportImageIds[viewportIdx] || []); + const currentIndex = typeof viewport.getCurrentImageIdIndex === "function" + ? viewport.getCurrentImageIdIndex() + : 0; + const imageId = ids[currentIndex] || ids[0]; + + if (!imageId) { + if (typeof viewport.resetProperties === "function") viewport.resetProperties(); + viewport.render(); + updateOverlay(viewportIdx, viewport); + return; + } + + const pixelMeta = (metaData.get("imagePixelModule", imageId) || {}) as any; + const lutMeta = (metaData.get("modalityLutModule", imageId) || {}) as any; + + const minRaw = Number(pixelMeta.smallestPixelValue ?? pixelMeta.smallestImagePixelValue); + const maxRaw = Number(pixelMeta.largestPixelValue ?? pixelMeta.largestImagePixelValue); + const slope = Number(lutMeta.rescaleSlope ?? 1); + const intercept = Number(lutMeta.rescaleIntercept ?? 0); + + if (!Number.isFinite(minRaw) || !Number.isFinite(maxRaw) || maxRaw <= minRaw) { + if (typeof viewport.resetProperties === "function") viewport.resetProperties(); + viewport.render(); + updateOverlay(viewportIdx, viewport); + return; + } + + viewport.setProperties({ + voiRange: { + lower: minRaw * slope + intercept, + upper: maxRaw * slope + intercept, + }, + }); + viewport.render(); + updateOverlay(viewportIdx, viewport); + } catch (e) { + // Ignore failures and keep existing VOI. + } + }, [updateOverlay, viewportImageIds]); + // Update overlay and preset state arrays when grid changes useEffect(() => { setImageInfos(Array(viewportGrid.rows * viewportGrid.cols).fill({ @@ -2069,6 +2193,8 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer total: 0, windowCenter: "", windowWidth: "", + slicePosition: "", + modality: "", })); setPresetsOpenArr(Array(viewportGrid.rows * viewportGrid.cols).fill(false)); }, [viewportGrid]); @@ -2078,18 +2204,18 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer applyMouseToolBindings() }, [mouseToolBindings, ctrlMouseToolBindings, ctrlPressed, applyMouseToolBindings]) - // Close the menu when clicking outside + // Close preset menus when clicking outside useEffect(() => { - if (!presetsOpen) return; + if (!presetsOpenArr.some(Boolean)) return; const handleClick = (e: MouseEvent) => { const menu = document.querySelector(".preset-menu"); if (menu && !menu.contains(e.target as Node)) { - setPresetsOpen(false); + setPresetsOpenArr(prev => prev.map(() => false)); } }; document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); - }, [presetsOpen]); + }, [presetsOpenArr]); useEffect(() => { const preventExtraButtonDefault = (e: MouseEvent) => { @@ -3468,7 +3594,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer }); }} > - Presets + Presets ({imageInfos[i]?.modality || "Unknown"}) {presetsOpenArr[i] ? "▲" : "▼"} @@ -3487,58 +3613,45 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer }} onClick={e => e.stopPropagation()} // <-- prevent bubbling to viewport > - - {WINDOW_LEVEL_PRESETS.map((preset) => ( + {getPresetOptionsForModality(imageInfos[i]?.modality).map((preset) => (