Files
dv3d/dicom-viewer/src/App.tsx
T
2025-05-19 21:47:52 +01:00

510 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useCallback, useState } from "react"
import createImageIdsAndCacheMetaData from "./lib/createImageIdsAndCacheMetaData"
import {
RenderingEngine,
Enums,
} from "@cornerstonejs/core"
import { init as csRenderInit } from "@cornerstonejs/core"
import { init as csToolsInit, addTool, ToolGroupManager, Enums as csToolsEnums, PanTool, WindowLevelTool, StackScrollTool, ZoomTool, PlanarRotateTool } from "@cornerstonejs/tools"
import { LengthTool, ProbeTool, ArrowAnnotateTool, RectangleROITool, EllipticalROITool } from "@cornerstonejs/tools"
import { init as dicomImageLoaderInit } from "@cornerstonejs/dicom-image-loader"
import * as cornerstoneTools from '@cornerstonejs/tools';
import { StackScrollOutOfBoundsEvent } from 'core/src/types/EventTypes';
const { MouseBindings } = csToolsEnums;
const { IMAGE_RENDERED } = Enums.Events;
const toolGroupId = 'STACK_TOOL_GROUP_ID';
// Common CT presets (add more as needed)
const WINDOW_LEVEL_PRESETS = [
{ name: "Soft Tissue", center: 40, width: 400 },
{ name: "Lung", center: -600, width: 1500 },
{ name: "Bone", center: 300, width: 1500 },
{ name: "Brain", center: 40, width: 80 },
{ name: "Abdomen", center: 60, width: 400 },
]
// Tool options for dropdowns
const TOOL_OPTIONS = [
{ label: "Window/Level", value: WindowLevelTool.toolName },
{ label: "Pan", value: PanTool.toolName },
{ label: "Zoom", value: ZoomTool.toolName },
{ label: "Stack Scroll", value: StackScrollTool.toolName },
{ label: "Rotate", value: PlanarRotateTool.toolName },
{ label: "Length", value: LengthTool.toolName },
{ label: "Probe", value: ProbeTool.toolName },
{ label: "Arrow Annotate", value: ArrowAnnotateTool.toolName },
{ label: "Rectangle ROI", value: RectangleROITool.toolName },
{ label: "Ellipse ROI", value: EllipticalROITool.toolName },
]
// Mouse button labels
const MOUSE_BUTTONS = [
{ label: "Left", value: "Primary" },
{ label: "Middle", value: "Auxiliary" },
{ label: "Right", value: "Secondary" },
]
function App() {
const elementRef = useRef<HTMLDivElement>(null)
const viewportRef = useRef<any>(null) // Store the viewport instance
const running = useRef(false)
// State to control preset menu open/close
const [presetsOpen, setPresetsOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
// State for overlay info
const [imageInfo, setImageInfo] = useState({
index: 0,
total: 0,
windowCenter: "",
windowWidth: "",
})
// State for selected tool per mouse button
const [mouseToolBindings, setMouseToolBindings] = useState({
Primary: WindowLevelTool.toolName,
Auxiliary: PanTool.toolName,
Secondary: ZoomTool.toolName,
})
// Helper to apply all mouse tool bindings
const applyMouseToolBindings = useCallback(() => {
console.log("applyMouseToolBindings", mouseToolBindings)
const toolGroup = cornerstoneTools.ToolGroupManager.getToolGroup(toolGroupId)
if (toolGroup) {
// Set all tools to passive first
TOOL_OPTIONS.forEach(opt => {
toolGroup.setToolPassive(opt.value, { removeAllBindings: true })
})
// Re-apply all bindings for all mouse buttons (Primary, Auxiliary, Secondary)
Object.entries(mouseToolBindings).forEach(([btn, tool]) => {
console.log("button", btn, "setToolActive", tool)
toolGroup.setToolActive(tool, {
bindings: [{ mouseButton: csToolsEnums.MouseBindings[btn] }],
})
})
// Do NOT set wheel bindings here!
}
}, [mouseToolBindings])
const handleToolChange = (mouseButton: string, toolName: string) => {
setMouseToolBindings(prev => ({ ...prev, [mouseButton]: toolName }))
}
const updateOverlay = useCallback(() => {
const viewport = viewportRef.current
if (!viewport) return
// For StackViewport in Cornerstone3D v3:
const currentIndex = typeof viewport.getCurrentImageIdIndex === "function"
? viewport.getCurrentImageIdIndex()
: 0
const imageIds = typeof viewport.getImageIds === "function"
? viewport.getImageIds()
: []
const total = imageIds.length
// Use getVOI() for current window center/width
let windowCenter = ""
let windowWidth = ""
if (typeof viewport.getProperties === "function") {
const props = viewport.getProperties()
if (props.voiRange) {
const { lower, upper } = props.voiRange
windowCenter = ((upper + lower) / 2).toFixed(0)
windowWidth = (upper - lower).toFixed(0)
}
}
setImageInfo({
index: currentIndex + 1,
total,
windowCenter,
windowWidth,
})
}, [])
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)
// 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(ProbeTool.toolName)
toolGroup.addTool(ArrowAnnotateTool.toolName)
toolGroup.addTool(RectangleROITool.toolName)
toolGroup.addTool(EllipticalROITool.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)
cornerstoneTools.utilities.stackPrefetch.enable(viewport.element);
// 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)
// Initial overlay update
updateOverlay()
// Apply mouse tool bindings after setup
applyMouseToolBindings()
}
setup()
// Create a stack viewport
}, [elementRef, updateOverlay])
// Handler to reset the view (camera and window/level)
const handleResetView = useCallback(() => {
const viewport = viewportRef.current
if (viewport && typeof viewport.resetCamera === "function") {
viewport.resetCamera();
viewport.resetProperties(); // Resets window/level and other properties
viewport.render();
}
}, [])
// 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()
}
}, [])
useEffect(() => {
applyMouseToolBindings();
}, [mouseToolBindings, applyMouseToolBindings]);
// Close the menu when clicking outside
useEffect(() => {
if (!presetsOpen) return;
const handleClick = (e: MouseEvent) => {
const menu = document.querySelector(".preset-menu");
if (menu && !menu.contains(e.target as Node)) {
setPresetsOpen(false);
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [presetsOpen]);
return (
<div style={{ position: "relative", width: "512px", height: "512px" }}>
<button
style={{
position: "absolute",
top: 8,
left: 8,
zIndex: 10,
padding: "6px 12px",
background: "#222",
color: "#fff",
border: "none",
borderRadius: "4px",
cursor: "pointer",
opacity: 0.85,
userSelect: "none", // <-- Prevents text selection
}}
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>
{/* 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={{
position: "absolute",
top: 8,
right: 8,
zIndex: 20,
padding: "6px 12px",
background: "#222",
color: "#fff",
border: "none",
borderRadius: "4px",
cursor: "pointer",
opacity: 0.85,
userSelect: "none",
}}
onClick={() => setSettingsOpen(true)}
>
Settings
</button>
{/* Settings Modal (viewport-local, not fixed to page) */}
{settingsOpen && (
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
background: "rgba(0,0,0,0.4)",
zIndex: 1000,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={() => setSettingsOpen(false)}
>
<div
style={{
background: "#222",
color: "#fff",
borderRadius: "8px",
minWidth: "320px",
minHeight: "180px",
padding: "24px",
boxShadow: "0 4px 24px rgba(0,0,0,0.4)",
position: "relative",
}}
onClick={e => e.stopPropagation()}
>
<div style={{ fontSize: "20px", marginBottom: "16px" }}>
Settings
</div>
{/* Settings content: Tool dropdowns */}
<div style={{ marginBottom: "24px" }}>
<div style={{ marginBottom: 12, fontWeight: "bold" }}>Mouse Button Tool Bindings</div>
{MOUSE_BUTTONS.map((btn) => (
<div key={btn.value} style={{ marginBottom: 10 }}>
<label style={{ marginRight: 8 }}>{btn.label} Button:</label>
<select
value={mouseToolBindings[btn.value]}
onChange={e => handleToolChange(btn.value, e.target.value)}
style={{
background: "#333",
color: "#fff",
border: "1px solid #444",
borderRadius: "4px",
padding: "4px 8px",
fontSize: "14px",
}}
>
{TOOL_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
))}
</div>
<button
style={{
position: "absolute",
top: 12,
right: 12,
background: "transparent",
color: "#fff",
border: "none",
fontSize: "20px",
cursor: "pointer",
}}
onClick={() => setSettingsOpen(false)}
aria-label="Close"
>
×
</button>
</div>
</div>
)}
{/* ...rest of your UI... */}
</div>
)
}
export default App