Enhance viewer functionality with volume diagnostics and fullscreen toggle
This commit is contained in:
+227
-20
@@ -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} WW: {imageInfos[i]?.windowWidth}
|
WC: {imageInfos[i]?.windowCenter} 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,89 +4707,181 @@ 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();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 16, lineHeight: 1 }}>⛶</span>
|
||||||
|
<span>Viewer</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{ position: "relative", display: "flex", flexDirection: "column", alignItems: "stretch" }}>
|
||||||
|
<button
|
||||||
|
className="grid-menu-btn"
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
padding: "6px 14px 6px 10px",
|
||||||
|
background: "#222",
|
||||||
|
color: "#fff",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "0 0 4px 4px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "14px",
|
||||||
|
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
justifyContent: "space-between",
|
||||||
|
minWidth: 136,
|
||||||
|
}}
|
||||||
|
onFocus={() => setTopControlsPeek(true)}
|
||||||
|
onClick={() => {
|
||||||
|
setTopControlsPeek(true);
|
||||||
|
setViewportMenuOpen(open => !open);
|
||||||
}}
|
}}
|
||||||
onClick={() => setViewportMenuOpen(open => !open)}
|
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
Grid: {viewportGrid.rows}x{viewportGrid.cols} ▼
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(${viewportGrid.cols}, 5px)`,
|
||||||
|
gridTemplateRows: `repeat(${viewportGrid.rows}, 5px)`,
|
||||||
|
gap: 2,
|
||||||
|
padding: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Array.from({ length: viewportGrid.rows * viewportGrid.cols }).map((_, idx) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
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>
|
</button>
|
||||||
{viewportMenuOpen && (
|
{viewportMenuOpen && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: "110%",
|
top: "calc(100% + 8px)",
|
||||||
left: "50%",
|
right: 0,
|
||||||
transform: "translateX(-50%)",
|
|
||||||
background: "#222",
|
background: "#222",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
borderRadius: "6px",
|
borderRadius: "6px",
|
||||||
boxShadow: "0 2px 12px rgba(0,0,0,0.4)",
|
boxShadow: "0 2px 12px rgba(0,0,0,0.4)",
|
||||||
padding: "12px",
|
padding: "12px",
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "repeat(3, 40px)",
|
gridTemplateColumns: "repeat(3, minmax(68px, 1fr))",
|
||||||
gap: "8px",
|
gap: "8px",
|
||||||
zIndex: 200,
|
zIndex: 200,
|
||||||
pointerEvents: "auto",
|
pointerEvents: "auto",
|
||||||
}}
|
}}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{gridOptions.map(opt => (
|
{gridOptions.map(opt => (
|
||||||
<button
|
<button
|
||||||
key={`${opt.rows}x${opt.cols}`}
|
key={`${opt.rows}x${opt.cols}`}
|
||||||
style={{
|
style={{
|
||||||
width: "40px",
|
minWidth: "68px",
|
||||||
height: "40px",
|
height: "54px",
|
||||||
background: (opt.rows === viewportGrid.rows && opt.cols === viewportGrid.cols) ? "#444" : "#333",
|
background: (opt.rows === viewportGrid.rows && opt.cols === viewportGrid.cols) ? "#444" : "#333",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
fontWeight: "bold",
|
fontWeight: "bold",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
fontSize: "15px",
|
fontSize: "13px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: 6,
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
saveVisibleViewportState();
|
saveVisibleViewportState();
|
||||||
setViewportGrid({ rows: opt.rows, cols: opt.cols })
|
setViewportGrid({ rows: opt.rows, cols: opt.cols })
|
||||||
setViewportMenuOpen(false)
|
setViewportMenuOpen(false)
|
||||||
|
setTopControlsPeek(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{opt.rows}x{opt.cols}
|
<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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* Main content area: viewports and right-side panel in a row so panel pushes content */}
|
{/* Main content area: viewports and right-side panel in a row so panel pushes content */}
|
||||||
|
|||||||
Reference in New Issue
Block a user