work towards functional multiviewport

This commit is contained in:
Ross
2025-05-20 09:31:04 +01:00
parent eb5219b355
commit 4213e3b680
+395 -279
View File
@@ -53,6 +53,7 @@ const MOUSE_BUTTONS = [
]
function App() {
const elementRef = useRef<HTMLDivElement>(null)
const viewportRef = useRef<any>(null) // Store the viewport instance
@@ -71,12 +72,16 @@ function App() {
windowWidth: "",
})
// Store imageIds globally after loading so they can be reused
const [loadedImageIds, setLoadedImageIds] = useState<string[] | null>(null);
const [viewportGrid, setViewportGrid] = useState({ rows: 1, cols: 1 })
const [viewportMenuOpen, setViewportMenuOpen] = useState(false)
// Store refs for each viewport
const viewportRefs = useRef<Array<HTMLDivElement | null>>([])
const renderingEnginesRef = useRef<Record<string, any>>({});
// Helper to generate grid options
const gridOptions = []
@@ -86,6 +91,26 @@ function App() {
}
}
// State for overlay info per viewport
const [imageInfos, setImageInfos] = useState<Array<{
index: number,
total: number,
windowCenter: string,
windowWidth: string,
}>>(() =>
Array(viewportGrid.rows * viewportGrid.cols).fill({
index: 0,
total: 0,
windowCenter: "",
windowWidth: "",
})
);
// State for preset menu open/close per viewport
const [presetsOpenArr, setPresetsOpenArr] = useState<boolean[]>(
() => Array(viewportGrid.rows * viewportGrid.cols).fill(false)
);
// State for selected tool per mouse button
const [mouseToolBindings, setMouseToolBindings] = useState({
Primary: WindowLevelTool.toolName,
@@ -160,172 +185,161 @@ function App() {
setMouseToolBindings(prev => ({ ...prev, [mouseButton]: toolName }))
}
const updateOverlay = useCallback(() => {
const viewport = viewportRef.current
if (!viewport) return
// For StackViewport in Cornerstone3D v3:
// Update overlay info for a specific viewport
const updateOverlay = useCallback((viewportIdx: number, viewport: any) => {
if (!viewport) return;
const currentIndex = typeof viewport.getCurrentImageIdIndex === "function"
? viewport.getCurrentImageIdIndex()
: 0
: 0;
const imageIds = typeof viewport.getImageIds === "function"
? viewport.getImageIds()
: []
const total = imageIds.length
: [];
const total = imageIds.length;
// Use getVOI() for current window center/width
let windowCenter = ""
let windowWidth = ""
let windowCenter = "";
let windowWidth = "";
if (typeof viewport.getProperties === "function") {
const props = viewport.getProperties()
const props = viewport.getProperties();
if (props.voiRange) {
const { lower, upper } = props.voiRange
windowCenter = ((upper + lower) / 2).toFixed(0)
windowWidth = (upper - lower).toFixed(0)
const { lower, upper } = props.voiRange;
windowCenter = ((upper + lower) / 2).toFixed(0);
windowWidth = (upper - lower).toFixed(0);
}
}
setImageInfo({
index: currentIndex + 1,
total,
windowCenter,
windowWidth,
})
}, [])
setImageInfos(prev => {
const next = [...prev];
next[viewportIdx] = {
index: currentIndex + 1,
total,
windowCenter,
windowWidth,
};
return next;
});
}, []);
// Store the properties of existing viewports before grid change
const prevViewportProps = useRef<Record<number, any>>({});
useEffect(() => {
const setup = async () => {
if (running.current) {
return
}
running.current = true
await csRenderInit()
await csToolsInit()
dicomImageLoaderInit({ maxWebWorkers: 1 })
// Add tools to Cornerstone3D
cornerstoneTools.addTool(PanTool)
cornerstoneTools.addTool(WindowLevelTool)
cornerstoneTools.addTool(StackScrollTool)
cornerstoneTools.addTool(ZoomTool)
cornerstoneTools.addTool(PlanarRotateTool)
cornerstoneTools.addTool(LengthTool)
cornerstoneTools.addTool(ProbeTool)
cornerstoneTools.addTool(ArrowAnnotateTool)
cornerstoneTools.addTool(RectangleROITool)
cornerstoneTools.addTool(EllipticalROITool)
cornerstoneTools.addTool(PlanarFreehandROITool)
cornerstoneTools.addTool(PlanarFreehandContourSegmentationTool)
cornerstoneTools.addTool(SculptorTool)
// Create tool group ONCE
let toolGroup = ToolGroupManager.getToolGroup(toolGroupId)
if (!toolGroup) {
toolGroup = ToolGroupManager.createToolGroup(toolGroupId)
toolGroup.addTool(WindowLevelTool.toolName)
toolGroup.addTool(PanTool.toolName)
toolGroup.addTool(ZoomTool.toolName)
toolGroup.addTool(StackScrollTool.toolName, { loop: false })
toolGroup.addTool(PlanarRotateTool.toolName)
toolGroup.addTool(LengthTool.toolName)
toolGroup.addTool(ArrowAnnotateTool.toolName, {
getTextCallback: () => '',
})
toolGroup.addTool(ProbeTool.toolName, {
statsCalculator: () => null,
})
toolGroup.addTool(RectangleROITool.toolName, {
calculateStats: false,
getTextCallback: () => '',
})
toolGroup.addTool(EllipticalROITool.toolName, {
getTextCallback: () => '',
})
toolGroup.addTool(PlanarFreehandROITool.toolName, {
calculateStats: false,
})
toolGroup.addTool(PlanarFreehandContourSegmentationTool.toolName, {
})
toolGroup.addTool(SculptorTool.toolName, {
})
}
// The Stack Scroll mouse wheel is a tool using the `mouseWheelCallback`
// and needs to be registered against the 'Wheel' binding.
toolGroup.setToolActive(StackScrollTool.toolName, {
bindings: [
{
mouseButton: MouseBindings.Wheel, // Wheel Mouse
},
],
});
const imageIds = await createImageIdsAndCacheMetaData()
// Instantiate a rendering engine
const renderingEngineId = "myRenderingEngine"
const renderingEngine = new RenderingEngine(renderingEngineId)
const viewportId = "CT"
const viewportInput = {
viewportId,
type: Enums.ViewportType.STACK,
element: elementRef.current,
}
renderingEngine.enableElement(viewportInput)
// Set the tool group on the viewport
toolGroup.addViewport(viewportId, renderingEngineId);
// Get the stack viewport that was created
const viewport = renderingEngine.getViewport(viewportId);
viewportRef.current = viewport; // Save for later use
// Create a stack volume
viewport.setStack(imageIds)
const segmentationId = 'my-segmentation-id';
// Create a segmentation representation for the stack
segmentation.addSegmentations([{
segmentationId,
representation: {
type: csEnums.SegmentationRepresentations.Contour
// 1. Capture state of existing viewports before grid change
for (let i = 0; i < viewportGrid.rows * viewportGrid.cols; i++) {
const renderingEngineId = `renderingEngine_${i}`;
const viewportId = `CT_${i}`;
try {
const renderingEngine = renderingEnginesRef.current[renderingEngineId];
if (renderingEngine) {
const viewport = renderingEngine.getViewport(viewportId);
if (viewport && typeof viewport.getProperties === "function") {
prevViewportProps.current[i] = viewport.getProperties();
}
}
} catch (e) {
// Ignore if not found
}
}]);
//segmentation.activeSegmentation.setActiveSegmentationRepresentations(segmentationId);
// Create a segmentation representation associated to the viewportId
await segmentation.addSegmentationRepresentations(viewportId, [
{
segmentationId,
type: csToolsEnums.SegmentationRepresentations.Contour,
},
]);
}
segmentation.segmentIndex.setActiveSegmentIndex(segmentationId, 1);
cornerstoneTools.utilities.stackPrefetch.enable(viewport.element);
if (running.current) {
if (loadedImageIds) {
for (let i = 0; i < viewportGrid.rows * viewportGrid.cols; i++) {
const renderingEngineId = `renderingEngine_${i}`;
const viewportId = `CT_${i}`;
const element = viewportRefs.current[i];
if (!element) continue;
// Render the image
viewport.render()
viewport.element.addEventListener(Enums.Events.IMAGE_RENDERED, updateOverlay)
///viewport.element.addEventListener(Enums.Events.STACK_NEW_IMAGE, updateOverlay)
//viewport.element.addEventListener(Enums.Events.CAMERA_MODIFIED, updateOverlay)
//viewport.element.addEventListener(Enums.Events.PRESET_MODIFIED, updateOverlay)
const renderingEngine = new RenderingEngine(renderingEngineId);
renderingEnginesRef.current[renderingEngineId] = renderingEngine;
const viewportInput = {
viewportId,
type: Enums.ViewportType.STACK,
element,
};
renderingEngine.enableElement(viewportInput);
// Initial overlay update
updateOverlay()
const toolGroup = ToolGroupManager.getToolGroup(toolGroupId);
toolGroup?.addViewport(viewportId, renderingEngineId);
// Apply mouse tool bindings after setup
applyMouseToolBindings()
}
const viewport = renderingEngine.getViewport(viewportId);
viewport.setStack(loadedImageIds);
setup()
/* // Restore previous state for existing viewports only
if (prevViewportProps.current[i] && typeof viewport.setProperties === "function") {
viewport.setProperties(prevViewportProps.current[i]);
} */
// Create a stack viewport
}, [elementRef, updateOverlay])
cornerstoneTools.utilities.stackPrefetch.enable(viewport.element);
console.log(i, viewport)
viewport.resetCameraForResize();
viewport.render();
// Attach overlay update for each viewport
viewport.element.addEventListener(Enums.Events.IMAGE_RENDERED, () => updateOverlay(i, viewport));
// Initial overlay update
updateOverlay(i, viewport);
}
applyMouseToolBindings();
return;
}
return;
}
running.current = true;
await csRenderInit();
await csToolsInit();
dicomImageLoaderInit({ maxWebWorkers: 1 });
const toolGroup = setupTools();
const imageIds = await createImageIdsAndCacheMetaData();
setLoadedImageIds(imageIds);
for (let i = 0; i < viewportGrid.rows * viewportGrid.cols; i++) {
const renderingEngineId = `renderingEngine_${i}`;
const viewportId = `CT_${i}`;
const element = viewportRefs.current[i];
if (!element) continue;
const renderingEngine = new RenderingEngine(renderingEngineId);
renderingEnginesRef.current[renderingEngineId] = renderingEngine;
const viewportInput = {
viewportId,
type: Enums.ViewportType.STACK,
element,
};
renderingEngine.enableElement(viewportInput);
toolGroup.addViewport(viewportId, renderingEngineId);
const viewport = renderingEngine.getViewport(viewportId);
viewport.setStack(imageIds);
// Restore previous state for existing viewports only
if (prevViewportProps.current[i] && typeof viewport.setProperties === "function") {
viewport.setProperties(prevViewportProps.current[i]);
}
cornerstoneTools.utilities.stackPrefetch.enable(viewport.element);
viewport.resize();
viewport.render();
console.log(viewport)
// Attach overlay update for each viewport
viewport.element.addEventListener(Enums.Events.IMAGE_RENDERED, () => updateOverlay(i, viewport));
// Initial overlay update
updateOverlay(i, viewport);
}
applyMouseToolBindings();
};
// Delay setup by 100ms (adjust as needed)
const timeout = setTimeout(() => {
setup();
}, 300);
return () => clearTimeout(timeout);
}, [elementRef, updateOverlay, viewportGrid, loadedImageIds]);
// Handler to reset the view (camera and window/level)
const handleResetView = useCallback(() => {
@@ -340,21 +354,42 @@ function App() {
// Double-click handler (reuse the same logic)
const handleDoubleClick = handleResetView
// Handler to apply a preset
const handleApplyPreset = useCallback((center: number, width: number) => {
const viewport = viewportRef.current
if (viewport && typeof viewport.setProperties === "function") {
const props = viewport.getProperties()
viewport.setProperties({
...props,
voiRange: {
lower: center - width / 2,
upper: center + width / 2,
},
})
viewport.render()
// Handler to apply a preset for a specific viewport
const handleApplyPreset = useCallback((viewportIdx: number, center: number, width: number) => {
console.log("Applying preset:", center, width);
const renderingEngineId = `renderingEngine_${viewportIdx}`;
const viewportId = `CT_${viewportIdx}`;
try {
const renderingEngine = renderingEnginesRef.current[renderingEngineId];
console.log("Rendering engine:", renderingEngine);
if (renderingEngine) {
const viewport = renderingEngine.getViewport(viewportId);
if (viewport && typeof viewport.setProperties === "function") {
viewport.setProperties({
voiRange: {
lower: center - width / 2,
upper: center + width / 2,
},
});
viewport.render();
updateOverlay(viewportIdx, viewport);
}
}
} catch (e) {
// Ignore if not found
}
}, [])
}, [updateOverlay]);
// Update overlay and preset state arrays when grid changes
useEffect(() => {
setImageInfos(Array(viewportGrid.rows * viewportGrid.cols).fill({
index: 0,
total: 0,
windowCenter: "",
windowWidth: "",
}));
setPresetsOpenArr(Array(viewportGrid.rows * viewportGrid.cols).fill(false));
}, [viewportGrid]);
// Re-apply bindings when tool sets or ctrl changes
useEffect(() => {
@@ -374,9 +409,18 @@ function App() {
return () => document.removeEventListener("mousedown", handleClick);
}, [presetsOpen]);
useEffect(() => {
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
};
document.addEventListener("contextmenu", handleContextMenu);
return () => {
document.removeEventListener("contextmenu", handleContextMenu);
};
}, []);
// Render the grid of viewports
const viewports = []
const viewports = [];
for (let i = 0; i < viewportGrid.rows * viewportGrid.cols; i++) {
viewports.push(
<div
@@ -389,15 +433,132 @@ function App() {
minWidth: 0,
minHeight: 0,
position: "relative",
overflow: "hidden",
display: "flex",
flexDirection: "column",
}}
>
{/* You can render overlays or controls per-viewport here */}
{/* Overlay info (bottom left of viewport) */}
<div
style={{
position: "absolute",
left: 8,
bottom: 8,
background: "rgba(0,0,0,0.7)",
color: "#fff",
padding: "6px 12px",
borderRadius: "4px",
fontSize: "14px",
zIndex: 10,
pointerEvents: "none",
minWidth: "120px",
userSelect: "none",
}}
>
<div>
Image: {imageInfos[i]?.index} / {imageInfos[i]?.total}
</div>
<div>
WC: {imageInfos[i]?.windowCenter} &nbsp; WW: {imageInfos[i]?.windowWidth}
</div>
</div>
{/* Preset menu (bottom right of viewport) */}
<div
style={{
position: "absolute",
right: 8,
bottom: 8,
zIndex: 10,
}}
>
<div
className="preset-menu"
style={{
background: "rgba(0,0,0,0.7)",
color: "#fff",
borderRadius: "4px",
fontSize: "14px",
minWidth: "120px",
overflow: "hidden",
cursor: "pointer",
position: "relative",
boxShadow: presetsOpenArr[i] ? "0 2px 8px rgba(0,0,0,0.3)" : undefined,
}}
>
<div
style={{
padding: "6px 12px",
fontWeight: "bold",
userSelect: "none",
}}
onClick={() =>
setPresetsOpenArr(prev => {
const next = [...prev];
next[i] = !next[i];
return next;
})
}
>
Presets
<span style={{ float: "right", fontWeight: "normal" }}>
{presetsOpenArr[i] ? "▲" : "▼"}
</span>
</div>
{presetsOpenArr[i] && (
<div
className="preset-menu-content"
style={{
background: "rgba(0,0,0,0.95)",
borderRadius: "0 0 4px 4px",
padding: "6px 0 6px 0",
display: "flex",
flexDirection: "column",
zIndex: 11,
}}
>
{WINDOW_LEVEL_PRESETS.map((preset) => (
<button
key={preset.name}
style={{
display: "block",
width: "100%",
margin: "2px 0",
background: "#333",
color: "#fff",
border: "none",
borderRadius: "3px",
cursor: "pointer",
padding: "4px 0",
fontSize: "13px",
opacity: 0.9,
}}
onClick={() => {
console.log("Preset clicked for viewport", i, preset.name);
handleApplyPreset(i, preset.center, preset.width);
setPresetsOpenArr(prev => {
const next = [...prev];
next[i] = false;
return next;
});
}}
>
{preset.name}
</button>
))}
</div>
)}
</div>
</div>
</div>
)
);
}
return (
<div style={{ position: "relative", width: "512px", height: "512px" }}>
<div style={{ position: "relative", width: "100vw", height: "100vh", background: "#111" }}>
{/* Reset View Button */}
<button
style={{
position: "absolute",
@@ -411,22 +572,13 @@ function App() {
borderRadius: "4px",
cursor: "pointer",
opacity: 0.85,
userSelect: "none", // <-- Prevents text selection
userSelect: "none",
}}
onClick={handleResetView}
>
Reset View
</button>
<div
ref={elementRef}
onDoubleClick={handleDoubleClick}
onContextMenu={e => e.preventDefault()} // <-- Add this line
style={{
width: "100%",
height: "100%",
backgroundColor: "#000",
}}
></div>
{/* Viewport grid menu at top center */}
<div
style={{
@@ -497,119 +649,25 @@ function App() {
)}
</div>
{/* Viewport grid */}
{/* Viewport grid (inside main div) */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
display: "grid",
gridTemplateRows: `repeat(${viewportGrid.rows}, 1fr)`,
gridTemplateColumns: `repeat(${viewportGrid.cols}, 1fr)`,
width: "100vw",
height: "100vh",
gap: "2px",
zIndex: 1,
}}
>
{viewports}
</div>
{/* Overlay at bottom left */}
<div
style={{
position: "absolute",
left: 8,
bottom: 8,
background: "rgba(0,0,0,0.7)",
color: "#fff",
padding: "6px 12px",
borderRadius: "4px",
fontSize: "14px",
zIndex: 10,
pointerEvents: "none",
minWidth: "120px",
userSelect: "none", // <-- Prevents text selection
}}
>
<div>
Image: {imageInfo.index} / {imageInfo.total}
</div>
<div>
WC: {imageInfo.windowCenter} &nbsp; WW: {imageInfo.windowWidth}
</div>
</div>
{/* Preset menu at bottom right, expands on click */}
<div
style={{
position: "absolute",
right: 8,
bottom: 8,
zIndex: 10,
}}
>
<div
className="preset-menu"
style={{
background: "rgba(0,0,0,0.7)",
color: "#fff",
borderRadius: "4px",
fontSize: "14px",
minWidth: "120px",
overflow: "hidden",
cursor: "pointer",
position: "relative",
boxShadow: presetsOpen ? "0 2px 8px rgba(0,0,0,0.3)" : undefined,
}}
>
<div
style={{
padding: "6px 12px",
fontWeight: "bold",
userSelect: "none",
}}
onClick={() => setPresetsOpen((open) => !open)}
>
Presets
<span style={{ float: "right", fontWeight: "normal" }}>
{presetsOpen ? "▲" : "▼"}
</span>
</div>
{presetsOpen && (
<div
className="preset-menu-content"
style={{
background: "rgba(0,0,0,0.95)",
borderRadius: "0 0 4px 4px",
padding: "6px 0 6px 0",
display: "flex",
flexDirection: "column",
zIndex: 11,
}}
>
{WINDOW_LEVEL_PRESETS.map((preset) => (
<button
key={preset.name}
style={{
display: "block",
width: "100%",
margin: "2px 0",
background: "#333",
color: "#fff",
border: "none",
borderRadius: "3px",
cursor: "pointer",
padding: "4px 0",
fontSize: "13px",
opacity: 0.9,
}}
onClick={() => {
handleApplyPreset(preset.center, preset.width);
setPresetsOpen(false);
}}
>
{preset.name}
</button>
))}
</div>
)}
</div>
</div>
{/* Settings button at top right */}
<button
style={{
@@ -736,3 +794,61 @@ function App() {
}
export default App
function setupTools() {
cornerstoneTools.addTool(PanTool)
cornerstoneTools.addTool(WindowLevelTool)
cornerstoneTools.addTool(StackScrollTool)
cornerstoneTools.addTool(ZoomTool)
cornerstoneTools.addTool(PlanarRotateTool)
cornerstoneTools.addTool(LengthTool)
cornerstoneTools.addTool(ProbeTool)
cornerstoneTools.addTool(ArrowAnnotateTool)
cornerstoneTools.addTool(RectangleROITool)
cornerstoneTools.addTool(EllipticalROITool)
cornerstoneTools.addTool(PlanarFreehandROITool)
cornerstoneTools.addTool(PlanarFreehandContourSegmentationTool)
cornerstoneTools.addTool(SculptorTool)
// Create tool group ONCE
let toolGroup = ToolGroupManager.getToolGroup(toolGroupId)
if (!toolGroup) {
toolGroup = ToolGroupManager.createToolGroup(toolGroupId)
toolGroup.addTool(WindowLevelTool.toolName)
toolGroup.addTool(PanTool.toolName)
toolGroup.addTool(ZoomTool.toolName)
toolGroup.addTool(StackScrollTool.toolName, { loop: false })
toolGroup.addTool(PlanarRotateTool.toolName)
toolGroup.addTool(LengthTool.toolName)
toolGroup.addTool(ArrowAnnotateTool.toolName, {
getTextCallback: () => '',
})
toolGroup.addTool(ProbeTool.toolName, {
statsCalculator: () => null,
})
toolGroup.addTool(RectangleROITool.toolName, {
calculateStats: false,
getTextCallback: () => '',
})
toolGroup.addTool(EllipticalROITool.toolName, {
getTextCallback: () => '',
})
toolGroup.addTool(PlanarFreehandROITool.toolName, {
calculateStats: false,
})
toolGroup.addTool(PlanarFreehandContourSegmentationTool.toolName, {})
toolGroup.addTool(SculptorTool.toolName, {})
}
// The Stack Scroll mouse wheel is a tool using the `mouseWheelCallback`
// and needs to be registered against the 'Wheel' binding.
toolGroup.setToolActive(StackScrollTool.toolName, {
bindings: [
{
mouseButton: MouseBindings.Wheel, // Wheel Mouse
},
],
})
return toolGroup
}