Refactor CT presets and enhance modality-specific preset options for improved functionality

This commit is contained in:
Ross
2026-05-18 10:48:28 +01:00
parent 1674254a3c
commit 19702b6052
+284 -217
View File
@@ -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<number[][]>(
() => Array(MAX_VIEWPORTS).fill(null).map(() => [])
);
const viewportStackSignaturesRef = useRef<string[]>(Array(MAX_VIEWPORTS).fill(""));
const [viewportModes, setViewportModes] = useState<Array<'stack' | 'volume'>>(
() => 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"})
<span style={{ float: "right", fontWeight: "normal" }}>
{presetsOpenArr[i] ? "▲" : "▼"}
</span>
@@ -3487,58 +3613,45 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
}}
onClick={e => e.stopPropagation()} // <-- prevent bubbling to viewport
>
<button
style={{
display: "block",
width: "100%",
margin: "2px 0 8px 0",
background: "#444",
color: "#fff",
border: "none",
borderRadius: "3px",
cursor: "pointer",
padding: "6px 0",
fontSize: "13px",
fontWeight: "600",
}}
onClick={(e) => {
e.stopPropagation();
// Reset viewport properties (window/level and camera) to defaults
try {
const viewportId = `CT_${i}`;
const renderingEngine = renderingEngineRef.current;
const viewport = renderingEngine?.getViewport(viewportId);
if (viewport) {
if (typeof viewport.resetProperties === 'function') viewport.resetProperties();
if (typeof viewport.resetCamera === 'function') viewport.resetCamera();
if (typeof viewport.render === 'function') viewport.render();
updateOverlay(i, viewport);
}
} catch (err) { console.warn('Failed to reset viewport properties', err); }
setPresetsOpenArr(prev => { const next = [...prev]; next[i] = false; return next; });
}}
>
Reset to default
</button>
{WINDOW_LEVEL_PRESETS.map((preset) => (
{getPresetOptionsForModality(imageInfos[i]?.modality).map((preset) => (
<button
key={preset.name}
style={{
display: "block",
width: "100%",
margin: "2px 0",
background: "#333",
background: preset.action === "preset" ? "#333" : "#444",
color: "#fff",
border: "none",
borderRadius: "3px",
cursor: "pointer",
padding: "4px 0",
padding: preset.action === "preset" ? "4px 0" : "6px 0",
fontSize: "13px",
opacity: 0.9,
fontWeight: preset.action === "preset" ? "normal" : "600",
}}
onClick={e => {
e.stopPropagation(); // <-- prevent bubbling to viewport
handleApplyPreset(i, preset.center, preset.width);
if (preset.action === "reset") {
try {
const viewportId = `CT_${i}`;
const renderingEngine = renderingEngineRef.current;
const viewport = renderingEngine?.getViewport(viewportId);
if (viewport) {
if (typeof viewport.resetProperties === "function") viewport.resetProperties();
if (typeof viewport.resetCamera === "function") viewport.resetCamera();
if (typeof viewport.render === "function") viewport.render();
updateOverlay(i, viewport);
}
} catch (err) {
console.warn("Failed to reset viewport properties", err);
}
} else if (preset.action === "autolevel") {
handleAutoLevel(i);
} else {
const ctPreset = preset as Extract<PresetOption, { action: "preset" }>;
handleApplyPreset(i, ctPreset.center, ctPreset.width);
}
setPresetsOpenArr(prev => {
const next = [...prev];
next[i] = false;
@@ -3560,96 +3673,124 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
position: "absolute",
top: "10%",
right: 8,
width: "12px",
width: "14px",
height: "80%",
maxHeight: "100%",
minHeight: 0,
zIndex: 30,
zIndex: 35,
display: viewportImageIds[i].length > 0 ? "flex" : "none",
alignItems: "flex-start",
justifyContent: "center",
pointerEvents: "none",
pointerEvents: "auto",
flexDirection: "column",
padding: 0,
}}
>
<div
style={{
width: "8px",
height: "100%",
maxHeight: "100%",
minHeight: 0,
borderRadius: "6px",
background: "#222",
position: "relative",
display: "flex",
flexDirection: "column",
justifyContent: "flex-start",
boxShadow: "0 0 8px #2196f3cc",
overflow: "hidden",
}}
>
{viewportImageIds[i].map((id, idx) => {
const loaded = !!metaData.get("imagePlaneModule", id);
const renderingEngine = renderingEngineRef.current;
let currentIdx = -1;
if (
renderingEngine &&
typeof renderingEngine.getViewport === "function"
) {
const viewport = renderingEngine.getViewport(`CT_${i}`);
if (viewport && typeof viewport.getCurrentImageIdIndex === "function") {
currentIdx = viewport.getCurrentImageIdIndex();
}
}
const isCurrent = idx === currentIdx;
return (
<div
key={id}
style={{
width: "100%",
height: `${100 / viewportImageIds[i].length}%`,
background: isCurrent
? "linear-gradient(to right, #ffeb3b 60%, #ff9800 100%)"
: loaded
? "linear-gradient(to right, #4caf50 60%, #2196f3 100%)"
: "#444",
opacity: loaded ? 0.95 : 0.4,
transition: "background 0.3s, opacity 0.3s",
borderBottom:
idx < viewportImageIds[i].length - 1
? "1px solid #222"
: "none",
pointerEvents: "auto",
cursor: "pointer",
}}
title={
isCurrent
? `Current image #${idx + 1}`
: loaded
? `Go to image #${idx + 1}`
: `Loading...`
{(() => {
const imageIds = viewportImageIds[i] || [];
const total = imageIds.length;
if (total === 0) return null;
const renderingEngine = renderingEngineRef.current;
const viewport = renderingEngine?.getViewport(`CT_${i}`);
const currentIdx = viewport && typeof viewport.getCurrentImageIdIndex === "function"
? viewport.getCurrentImageIdIndex()
: -1;
const viewedSet = new Set(viewedIndicesByViewport[i] || []);
const loadedFlags = imageIds.map((id) => {
if (id.startsWith("external:")) return true;
return !!metaData.get("imagePlaneModule", id) || !!metaData.get("generalSeriesModule", id);
});
const loadedCount = loadedFlags.reduce((sum, loaded) => sum + (loaded ? 1 : 0), 0);
const viewedCount = imageIds.reduce((sum, _id, idx) => sum + (viewedSet.has(idx) ? 1 : 0), 0);
const segmentCount = Math.min(total, 180);
const segments = Array.from({ length: segmentCount }, () => ({ loaded: false, viewed: false, current: false }));
for (let idx = 0; idx < total; idx++) {
const segmentIdx = total === 1
? 0
: Math.min(segmentCount - 1, Math.floor((idx / (total - 1)) * (segmentCount - 1)));
if (loadedFlags[idx]) segments[segmentIdx].loaded = true;
if (viewedSet.has(idx)) segments[segmentIdx].viewed = true;
if (idx === currentIdx) segments[segmentIdx].current = true;
}
return (
<div
style={{
width: "10px",
height: "100%",
maxHeight: "100%",
minHeight: 0,
borderRadius: "6px",
background: "#191919",
position: "relative",
display: "flex",
flexDirection: "column",
justifyContent: "flex-start",
boxShadow: "0 0 8px #2196f3cc",
overflow: "hidden",
cursor: "pointer",
}}
title={`Loaded ${loadedCount}/${total} • Viewed ${viewedCount}/${total}`}
onClick={(e) => {
const bar = e.currentTarget as HTMLDivElement;
const rect = bar.getBoundingClientRect();
const y = Math.min(rect.height, Math.max(0, e.clientY - rect.top));
const ratio = rect.height > 0 ? y / rect.height : 0;
const targetIndex = Math.min(total - 1, Math.max(0, Math.round(ratio * (total - 1))));
const targetViewport = renderingEngineRef.current?.getViewport(`CT_${i}`);
if (
targetViewport &&
typeof targetViewport.setImageIdIndex === "function" &&
viewportModes[i] === "stack"
) {
csUtilities.jumpToSlice(targetViewport.element, { imageIndex: targetIndex });
updateOverlay(i, targetViewport);
}
onClick={() => {
const renderingEngine = renderingEngineRef.current;
const viewport = renderingEngine?.getViewport(`CT_${i}`);
if (
viewport &&
typeof viewport.setImageIdIndex === "function" &&
viewportModes[i] === "stack"
) {
}}
>
{segments.map((segment, idx) => {
let background = "#3f3f3f";
if (segment.loaded) background = "#2e7d32";
if (segment.viewed) background = "#1565c0";
if (segment.current) background = "#fbc02d";
//viewport.setImageIdIndex(idx);
csUtilities.jumpToSlice(viewport.element, { imageIndex: idx })
//viewport.render();
updateOverlay(i, viewport);
}
}}
/>
);
})}
</div>
return (
<div
key={idx}
style={{
width: "100%",
height: `${100 / segmentCount}%`,
minHeight: 1,
background,
opacity: segment.loaded || segment.viewed || segment.current ? 0.95 : 0.45,
borderBottom: idx < segmentCount - 1 ? "1px solid #222" : "none",
pointerEvents: "none",
}}
/>
);
})}
{currentIdx >= 0 && total > segmentCount && (
<div
style={{
position: "absolute",
left: 0,
right: 0,
top: `${(currentIdx / Math.max(1, total - 1)) * 100}%`,
height: 2,
background: "#ffeb3b",
boxShadow: "0 0 4px #ffeb3b",
transform: "translateY(-1px)",
pointerEvents: "none",
}}
/>
)}
</div>
);
})()}
</div>
)}
{viewportModes[i] === "volume" && (() => {
@@ -4012,33 +4153,23 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
{/* Right-side stack panel is implemented in the main content flex container below */}
{/* Right-edge hover strip to reveal open button */}
<div
onMouseEnter={() => { if (!stackPanelOpen) setShowOpenButtonHover(true); }}
onMouseLeave={() => { if (!stackPanelOpen) setShowOpenButtonHover(false); }}
style={{ position: 'absolute', right: 0, top: 0, height: '100%', width: 56, zIndex: 2400, pointerEvents: stackPanelOpen ? 'none' : 'auto' }}
/>
{/* Open button only visible when hover strip is active */}
{showOpenButtonHover && !stackPanelOpen && (
{/* Open stacks button: compact and lower z-index so it does not cover modals/controls */}
{!stackPanelOpen && (
<button
onClick={() => setStackPanelOpen(true)}
onMouseEnter={() => setShowOpenButtonHover(true)}
onMouseLeave={() => setShowOpenButtonHover(false)}
aria-pressed={false}
title="Open stacks"
style={{
position: 'absolute',
right: 14,
top: '50%',
transform: 'translateY(-50%)',
zIndex: 2700,
width: 28,
height: 48,
right: 8,
top: 76,
zIndex: 120,
width: 24,
height: 42,
background: '#1976d2',
color: '#fff',
border: 'none',
borderRadius: 6,
borderRadius: 5,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
@@ -4288,83 +4419,21 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
className="grid-menu-hover-container"
style={{
position: "absolute",
top: 0,
top: 8,
left: "50%",
transform: "translateX(-50%)",
zIndex: 100,
display: "flex",
flexDirection: "column",
flexDirection: "row",
alignItems: "center",
width: "auto",
height: "48px",
pointerEvents: "none", // Only the hit area and button will be interactive
height: "40px",
pointerEvents: "auto",
}}
>
{/* Transparent hit area to catch hover/focus */}
<div
style={{
position: "absolute",
top: 0,
left: "50%",
transform: "translateX(-50%)",
width: 80,
height: 14,
zIndex: 1,
pointerEvents: "auto",
background: "transparent",
}}
onMouseEnter={() => setGridBtnActive(true)}
onFocus={() => setGridBtnActive(true)}
tabIndex={-1}
/>
{/* --- Fullscreen button next to grid button --- */}
<button
style={{
position: "relative",
top: gridBtnActive ? "0" : "-43px",
opacity: gridBtnActive ? 1 : 0.5,
transition: "top 0.25s cubic-bezier(.4,2,.6,1), opacity 0.2s",
padding: "6px 16px",
background: "#222",
color: "#fff",
border: "none",
borderRadius: "4px",
fontWeight: "bold",
cursor: "pointer",
fontSize: "15px",
marginLeft: "4px",
marginRight: "8px",
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
pointerEvents: "auto",
zIndex: 2,
display: "block",
}}
title="Toggle Fullscreen"
onMouseEnter={() => setGridBtnActive(true)}
onMouseLeave={() => setGridBtnActive(false)}
onClick={() => {
const root = appRootRef.current;
if (!root) return;
if (document.fullscreenElement === root) {
document.exitFullscreen();
} else if (root.requestFullscreen) {
root.requestFullscreen();
} else if ((root as any).webkitRequestFullscreen) {
(root as any).webkitRequestFullscreen();
} else if ((root as any).msRequestFullscreen) {
(root as any).msRequestFullscreen();
}
}}
>
Fullscreen
</button>
<button
className="grid-menu-btn"
style={{
position: "relative",
top: gridBtnActive ? "0" : "-43px",
opacity: gridBtnActive ? 1 : 0.5,
transition: "top 0.25s cubic-bezier(.4,2,.6,1), opacity 0.2s",
padding: "6px 16px",
background: "#222",
color: "#fff",
@@ -4377,11 +4446,9 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
pointerEvents: "auto",
zIndex: 2,
display: "block",
display: "inline-flex",
alignItems: "center",
}}
onMouseEnter={() => setGridBtnActive(true)}
onMouseLeave={() => setGridBtnActive(false)}
onBlur={() => setGridBtnActive(false)}
onClick={() => setViewportMenuOpen(open => !open)}
tabIndex={0}
>