Enhance viewer functionality with volume diagnostics and fullscreen toggle

This commit is contained in:
Ross
2026-05-18 11:51:38 +01:00
parent f0e0fe0ef6
commit f169238057
+256 -49
View File
@@ -224,9 +224,11 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
const SIDE_MENU_WIDTH = 220; const SIDE_MENU_WIDTH = 220;
const SIDE_MENU_BUTTON_SIZE = 40; const SIDE_MENU_BUTTON_SIZE = 40;
const SIDE_MENU_BUTTON_PEEK = 14; const SIDE_MENU_BUTTON_PEEK = 14;
const topControlsRef = useRef<HTMLDivElement | null>(null);
const [fullscreenHoverIdx, setFullscreenHoverIdx] = useState<number | null>(null); const [fullscreenHoverIdx, setFullscreenHoverIdx] = useState<number | null>(null);
const [openDropdownIdx, setOpenDropdownIdx] = useState<number | null>(null); const [openDropdownIdx, setOpenDropdownIdx] = useState<number | null>(null);
const [topControlsPeek, setTopControlsPeek] = useState(false);
const MAX_VIEWPORTS = 9; const MAX_VIEWPORTS = 9;
const renderingEngineId = "renderingEngine_" + container_id; const renderingEngineId = "renderingEngine_" + container_id;
@@ -236,6 +238,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
const restoringStateRef = useRef(false); const restoringStateRef = useRef(false);
const [annotationNavEnabled, setAnnotationNavEnabled] = useState(true); const [annotationNavEnabled, setAnnotationNavEnabled] = useState(true);
const [volumeDiagnosticsEnabled, setVolumeDiagnosticsEnabled] = useState(false);
const [availableStacks, setAvailableStacks] = useState<StackEntry[]>([]); const [availableStacks, setAvailableStacks] = useState<StackEntry[]>([]);
const [activeViewport, setActiveViewport] = useState<number | null>(0); const [activeViewport, setActiveViewport] = useState<number | null>(0);
@@ -992,6 +995,10 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
windowWidth: string, windowWidth: string,
slicePosition: string, slicePosition: string,
modality: string, modality: string,
orientation: string,
resolution: string,
spacing: string,
focalPoint: string,
}>>(() => }>>(() =>
Array(viewportGrid.rows * viewportGrid.cols).fill({ Array(viewportGrid.rows * viewportGrid.cols).fill({
index: 0, index: 0,
@@ -1000,6 +1007,10 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
windowWidth: "", windowWidth: "",
slicePosition: "", slicePosition: "",
modality: "", modality: "",
orientation: "",
resolution: "",
spacing: "",
focalPoint: "",
}) })
); );
@@ -1647,6 +1658,10 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
let windowWidth = ""; let windowWidth = "";
let slicePosition = ""; let slicePosition = "";
let modality = ""; let modality = "";
let orientation = "";
let resolution = "";
let spacing = "";
let focalPoint = "";
if (viewportModes[viewportIdx] === "stack") { if (viewportModes[viewportIdx] === "stack") {
currentIndex = typeof viewport.getCurrentImageIdIndex === "function" currentIndex = typeof viewport.getCurrentImageIdIndex === "function"
@@ -1719,7 +1734,28 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
const firstImageId = viewportImageIds[viewportIdx]?.[0]; const firstImageId = viewportImageIds[viewportIdx]?.[0];
if (firstImageId) { if (firstImageId) {
const seriesMeta = metaData.get("generalSeriesModule", firstImageId) as any; const seriesMeta = metaData.get("generalSeriesModule", firstImageId) as any;
const planeMeta = metaData.get("imagePlaneModule", firstImageId) as any;
const pixelMeta = metaData.get("imagePixelModule", firstImageId) as any;
modality = seriesMeta?.modality || ""; modality = seriesMeta?.modality || "";
orientation = String(viewport.viewportProperties?.orientation || "").toUpperCase();
if (pixelMeta?.rows && pixelMeta?.columns) {
resolution = `${pixelMeta.columns} x ${pixelMeta.rows}`;
}
const spacingParts = [
planeMeta?.columnPixelSpacing,
planeMeta?.rowPixelSpacing,
planeMeta?.sliceThickness,
].filter((value) => value !== undefined && value !== null && value !== "");
if (spacingParts.length > 0) {
spacing = spacingParts.join(" x ");
}
}
if (typeof viewport.getCamera === "function") {
const camera = viewport.getCamera();
if (camera?.focalPoint && Array.isArray(camera.focalPoint)) {
focalPoint = camera.focalPoint.map((value: number) => value.toFixed(1)).join(", ");
}
} }
} }
@@ -1732,6 +1768,10 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
windowWidth, windowWidth,
slicePosition, // add this field slicePosition, // add this field
modality, modality,
orientation,
resolution,
spacing,
focalPoint,
}; };
return next; return next;
}); });
@@ -2301,6 +2341,21 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
} }
}, [updateOverlay, viewportImageIds]); }, [updateOverlay, viewportImageIds]);
const toggleViewerFullscreen = useCallback(() => {
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();
}
}, []);
// Update overlay and preset state arrays when grid changes // Update overlay and preset state arrays when grid changes
useEffect(() => { useEffect(() => {
setImageInfos(Array(viewportGrid.rows * viewportGrid.cols).fill({ setImageInfos(Array(viewportGrid.rows * viewportGrid.cols).fill({
@@ -2310,6 +2365,10 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
windowWidth: "", windowWidth: "",
slicePosition: "", slicePosition: "",
modality: "", modality: "",
orientation: "",
resolution: "",
spacing: "",
focalPoint: "",
})); }));
setPresetsOpenArr(Array(viewportGrid.rows * viewportGrid.cols).fill(false)); setPresetsOpenArr(Array(viewportGrid.rows * viewportGrid.cols).fill(false));
}, [viewportGrid]); }, [viewportGrid]);
@@ -2332,6 +2391,21 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
return () => document.removeEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick);
}, [presetsOpenArr]); }, [presetsOpenArr]);
useEffect(() => {
if (!viewportMenuOpen) return;
const handleOutsideClick = (e: MouseEvent) => {
const host = topControlsRef.current;
if (host && !host.contains(e.target as Node)) {
setViewportMenuOpen(false);
setTopControlsPeek(false);
}
};
document.addEventListener("mousedown", handleOutsideClick);
return () => document.removeEventListener("mousedown", handleOutsideClick);
}, [viewportMenuOpen]);
useEffect(() => { useEffect(() => {
const preventExtraButtonDefault = (e: MouseEvent) => { const preventExtraButtonDefault = (e: MouseEvent) => {
// 4th button: button === 3, 5th button: button === 4 // 4th button: button === 3, 5th button: button === 4
@@ -3725,6 +3799,30 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
)} )}
<br /> <br />
WC: {imageInfos[i]?.windowCenter} &nbsp; WW: {imageInfos[i]?.windowWidth} WC: {imageInfos[i]?.windowCenter} &nbsp; WW: {imageInfos[i]?.windowWidth}
{volumeDiagnosticsEnabled && (
<>
<br />
Ori: {imageInfos[i]?.orientation || "N/A"}
{imageInfos[i]?.resolution && (
<>
<br />
Res: {imageInfos[i]?.resolution}
</>
)}
{imageInfos[i]?.spacing && (
<>
<br />
Spacing: {imageInfos[i]?.spacing}
</>
)}
{imageInfos[i]?.focalPoint && (
<>
<br />
Pos: {imageInfos[i]?.focalPoint}
</>
)}
</>
)}
</div> </div>
)} )}
</div> </div>
@@ -4582,6 +4680,23 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
> >
{annotationNavEnabled ? "Hide" : "Show"} Annotation Navigation {annotationNavEnabled ? "Hide" : "Show"} Annotation Navigation
</button> </button>
<button
style={{
padding: "10px 18px",
background: volumeDiagnosticsEnabled ? "#1976d2" : "#333",
color: "#fff",
border: "none",
borderRadius: "4px",
fontWeight: "bold",
cursor: "pointer",
fontSize: "15px",
marginBottom: "18px",
width: "100%",
}}
onClick={() => setVolumeDiagnosticsEnabled((prev) => !prev)}
>
{volumeDiagnosticsEnabled ? "Disable" : "Enable"} Volume Diagnostics
</button>
{/* Add more menu items here if needed */} {/* Add more menu items here if needed */}
</div> </div>
@@ -4592,88 +4707,180 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
<div <div
ref={topControlsRef}
className="grid-menu-hover-container" className="grid-menu-hover-container"
style={{ style={{
position: "absolute", position: "absolute",
top: 8, top: viewportMenuOpen || topControlsPeek ? 8 : -18,
left: "50%", left: "50%",
transform: "translateX(-50%)", transform: "translateX(-50%)",
zIndex: 100, zIndex: 100,
display: "flex", display: "flex",
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "flex-start",
gap: 8,
width: "auto", width: "auto",
height: "40px",
pointerEvents: "auto", pointerEvents: "auto",
transition: "top 180ms ease",
}}
onMouseEnter={() => setTopControlsPeek(true)}
onMouseLeave={() => {
if (!viewportMenuOpen) setTopControlsPeek(false);
}} }}
> >
<button <button
className="grid-menu-btn"
style={{ style={{
padding: "6px 16px", height: 40,
padding: "6px 12px 6px 10px",
background: "#222", background: "#222",
color: "#fff", color: "#fff",
border: "none", border: "none",
borderRadius: "4px", borderRadius: "0 0 4px 4px",
fontWeight: "bold", fontWeight: "bold",
cursor: "pointer", cursor: "pointer",
fontSize: "15px", fontSize: "14px",
marginRight: "8px",
boxShadow: "0 2px 8px rgba(0,0,0,0.2)", boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
pointerEvents: "auto",
zIndex: 2,
display: "inline-flex", display: "inline-flex",
alignItems: "center", alignItems: "center",
gap: 8,
}}
title="Toggle viewer fullscreen"
onFocus={() => setTopControlsPeek(true)}
onClick={() => {
setTopControlsPeek(true);
toggleViewerFullscreen();
}} }}
onClick={() => setViewportMenuOpen(open => !open)}
tabIndex={0}
> >
Grid: {viewportGrid.rows}x{viewportGrid.cols} <span style={{ fontSize: 16, lineHeight: 1 }}></span>
<span>Viewer</span>
</button> </button>
{viewportMenuOpen && (
<div <div style={{ position: "relative", display: "flex", flexDirection: "column", alignItems: "stretch" }}>
<button
className="grid-menu-btn"
style={{ style={{
position: "absolute", height: 40,
top: "110%", padding: "6px 14px 6px 10px",
left: "50%",
transform: "translateX(-50%)",
background: "#222", background: "#222",
color: "#fff", color: "#fff",
borderRadius: "6px", border: "none",
boxShadow: "0 2px 12px rgba(0,0,0,0.4)", borderRadius: "0 0 4px 4px",
padding: "12px", fontWeight: "bold",
display: "grid", cursor: "pointer",
gridTemplateColumns: "repeat(3, 40px)", fontSize: "14px",
gap: "8px", boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
zIndex: 200, display: "inline-flex",
pointerEvents: "auto", alignItems: "center",
gap: 10,
justifyContent: "space-between",
minWidth: 136,
}} }}
onFocus={() => setTopControlsPeek(true)}
onClick={() => {
setTopControlsPeek(true);
setViewportMenuOpen(open => !open);
}}
tabIndex={0}
> >
{gridOptions.map(opt => ( <span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
<button <span
key={`${opt.rows}x${opt.cols}`}
style={{ style={{
width: "40px", display: "grid",
height: "40px", gridTemplateColumns: `repeat(${viewportGrid.cols}, 5px)`,
background: (opt.rows === viewportGrid.rows && opt.cols === viewportGrid.cols) ? "#444" : "#333", gridTemplateRows: `repeat(${viewportGrid.rows}, 5px)`,
color: "#fff", gap: 2,
border: "none", padding: 1,
borderRadius: "4px",
fontWeight: "bold",
cursor: "pointer",
fontSize: "15px",
}}
onClick={() => {
saveVisibleViewportState();
setViewportGrid({ rows: opt.rows, cols: opt.cols })
setViewportMenuOpen(false)
}} }}
> >
{opt.rows}x{opt.cols} {Array.from({ length: viewportGrid.rows * viewportGrid.cols }).map((_, idx) => (
</button> <span
))} key={idx}
</div> style={{
)} width: 5,
height: 5,
borderRadius: 1,
background: "#90caf9",
display: "block",
}}
/>
))}
</span>
<span>{viewportGrid.rows}x{viewportGrid.cols}</span>
</span>
<span style={{ opacity: 0.8 }}>{viewportMenuOpen ? "▲" : "▼"}</span>
</button>
{viewportMenuOpen && (
<div
style={{
position: "absolute",
top: "calc(100% + 8px)",
right: 0,
background: "#222",
color: "#fff",
borderRadius: "6px",
boxShadow: "0 2px 12px rgba(0,0,0,0.4)",
padding: "12px",
display: "grid",
gridTemplateColumns: "repeat(3, minmax(68px, 1fr))",
gap: "8px",
zIndex: 200,
pointerEvents: "auto",
}}
onMouseDown={(e) => e.stopPropagation()}
>
{gridOptions.map(opt => (
<button
key={`${opt.rows}x${opt.cols}`}
style={{
minWidth: "68px",
height: "54px",
background: (opt.rows === viewportGrid.rows && opt.cols === viewportGrid.cols) ? "#444" : "#333",
color: "#fff",
border: "none",
borderRadius: "4px",
fontWeight: "bold",
cursor: "pointer",
fontSize: "13px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 6,
}}
onClick={() => {
saveVisibleViewportState();
setViewportGrid({ rows: opt.rows, cols: opt.cols })
setViewportMenuOpen(false)
setTopControlsPeek(false)
}}
>
<span
style={{
display: "grid",
gridTemplateColumns: `repeat(${opt.cols}, 6px)`,
gridTemplateRows: `repeat(${opt.rows}, 6px)`,
gap: 2,
}}
>
{Array.from({ length: opt.rows * opt.cols }).map((_, idx) => (
<span
key={idx}
style={{
width: 6,
height: 6,
borderRadius: 1,
background: "#90caf9",
display: "block",
}}
/>
))}
</span>
<span>{opt.rows}x{opt.cols}</span>
</button>
))}
</div>
)}
</div>
</div> </div>