Files
dv3d/dicom-viewer/src/App.tsx
T
2025-06-09 13:31:56 +01:00

2997 lines
108 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
RenderingEngine,
Enums,
} from "@cornerstonejs/core"
import { init as csRenderInit, utilities as csUtilities } from "@cornerstonejs/core"
import { init as csToolsInit, addTool, ToolGroupManager, Enums as csToolsEnums, PanTool, WindowLevelTool, StackScrollTool, ZoomTool, PlanarRotateTool, utilities as csToolsUtilities } from "@cornerstonejs/tools"
import { LengthTool, ProbeTool, ArrowAnnotateTool, RectangleROITool, EllipticalROITool, PlanarFreehandROITool, PlanarFreehandContourSegmentationTool, SculptorTool, CrosshairsTool, ReferenceLinesTool, ReferenceCursors } from "@cornerstonejs/tools"
import { init as dicomImageLoaderInit } from "@cornerstonejs/dicom-image-loader"
import * as cornerstoneTools from '@cornerstonejs/tools';
import { segmentation, Enums as csEnums } from '@cornerstonejs/tools';
import { voi } from "@cornerstonejs/tools/utilities"
import { readDicomElementExplicit } from "dicom-parser"
import { volumeLoader } from '@cornerstonejs/core';
import { metaData } from '@cornerstonejs/core';
import * as dicomParser from "dicom-parser";
import cornerstoneDICOMImageLoader from "@cornerstonejs/dicom-image-loader"
import { NoLabelArrowAnnotateTool } from "./NoLabelArrowAnnotateTool";
import { render } from "@cornerstonejs/tools/tools/displayTools/Labelmap/labelmapDisplay"
// Extend the Window interface to include cornerstoneTools
declare global {
interface Window {
cornerstone3dTools: typeof cornerstoneTools;
}
}
function pixelToWorld(imageId: string, point: { x: number, y: number }) {
const plane = metaData.get("imagePlaneModule", imageId);
if (!plane) return undefined;
const { rowCosines, columnCosines, imagePositionPatient, rowPixelSpacing, columnPixelSpacing } = plane;
if (!rowCosines || !columnCosines || !imagePositionPatient) return undefined;
// Default to 1 if spacing is missing
const dx = columnPixelSpacing || 1;
const dy = rowPixelSpacing || 1;
// Convert cosines to vectors
const rc = rowCosines;
const cc = columnCosines;
const ipp = imagePositionPatient;
// World = IPP + x*col*cos*spacing + y*row*cos*spacing
return [
ipp[0] + point.x * cc[0] * dx + point.y * rc[0] * dy,
ipp[1] + point.x * cc[1] * dx + point.y * rc[1] * dy,
ipp[2] + point.x * cc[2] * dx + point.y * rc[2] * dy,
];
}
// Add to global types:
declare global {
interface Window {
exportViewerState?: () => string;
importViewerState?: (json: string) => void;
exportAnnotations?: () => string;
importAnnotations?: (json: string) => void;
importLegacyAnnotations?: (json: string) => void;
importLegacyViewport?: (json: string) => void;
}
}
type StackEntry = { imageIds: string[], studyInstanceUID?: string };
// Add this type for clarity
declare global {
interface Window {
loadDicomStackFromFiles?: (files: FileList | File[]) => void;
}
}
declare global {
interface Window {
loadDicomStackFromWadouriList?: (wadouriImageIds: string[]) => void;
}
}
window.cornerstone3dTools = cornerstoneTools;
type ViewportDiv = HTMLDivElement & {
_cornerstoneEnabled?: boolean;
_lastViewportType?: any;
_resizeObserver?: ResizeObserver;
_overlayListener?: EventListener;
};
const { MouseBindings, KeyboardBindings } = csToolsEnums;
const { IMAGE_RENDERED } = Enums.Events;
// 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 (No Label)", value: NoLabelArrowAnnotateTool.toolName },
{ label: "Arrow Annotate", value: ArrowAnnotateTool.toolName },
{ label: "Rectangle ROI", value: RectangleROITool.toolName },
{ label: "Ellipse ROI", value: EllipticalROITool.toolName },
{ label: "Freehand ROI", value: PlanarFreehandROITool.toolName },
{ label: "Freehand Contour Segmentation", value: PlanarFreehandContourSegmentationTool.toolName },
{ label: "Sculptor", value: SculptorTool.toolName },
{ label: "Reference Cursors", value: ReferenceCursors.toolName },
]
// Mouse button labels
const MOUSE_BUTTONS = [
{ label: "Left", value: "Primary" },
{ label: "Middle", value: "Auxiliary" },
{ label: "Right", value: "Secondary" },
]
const ALL_MOUSE_BINDINGS = [
{ label: "Left", value: "Primary", mask: 1 },
{ label: "Middle", value: "Auxiliary", mask: 4 },
{ label: "Right", value: "Secondary", mask: 2 },
{ label: "Left + Right", value: "Primary_and_Secondary", mask: 3 },
{ label: "Left + Middle", value: "Primary_and_Auxiliary", mask: 5 },
{ label: "Right + Middle", value: "Secondary_and_Auxiliary", mask: 6 },
{ label: "Left + Right + Middle", value: "Primary_and_Secondary_and_Auxiliary", mask: 7 },
{ label: "Fourth Button", value: "Fourth_Button", mask: 8 },
{ label: "Fifth Button", value: "Fifth_Button", mask: 16 },
{ label: "Mouse Wheel", value: "Wheel", mask: 524288 },
{ label: "Mouse Wheel + Left", value: "Wheel_Primary", mask: 524289 },
];
type AppProps = {
container_id?: string;
imageStacks: () => Promise<string[][]>;
autoCacheStack?: boolean; // <-- new prop
// ...other props if needed
};
// App definintion
function App({ container_id, imageStacks, autoCacheStack, ...props }: AppProps) {
const elementRef = useRef<HTMLDivElement>(null)
const running = useRef(false)
// State to control preset menu open/close
const [presetsOpen, setPresetsOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [fullscreenHoverIdx, setFullscreenHoverIdx] = useState<number | null>(null);
const [openDropdownIdx, setOpenDropdownIdx] = useState<number | null>(null);
const MAX_VIEWPORTS = 9;
const renderingEngineId = "renderingEngine_" + container_id;
const MAIN_TOOL_GROUP_ID = "TOOL_GROUP_" + container_id;
const renderingEngineRef = useRef<any>(null);
const restoringStateRef = useRef(false);
const [availableStacks, setAvailableStacks] = useState<StackEntry[]>([]);
const [activeViewport, setActiveViewport] = useState<number | null>(0);
const [viewportImageIds, setViewportImageIds] = useState<string[][]>(
() => Array(MAX_VIEWPORTS).fill([])
);
const [selectedStackIdx, setSelectedStackIdx] = useState<number[]>(
() => Array(MAX_VIEWPORTS).fill(0)
);
// Load available stacks on mount
useEffect(() => {
imageStacks().then(async stacks => {
// Try to extract StudyInstanceUID for each stack
const stackEntries: StackEntry[] = await Promise.all(
stacks.map(async imageIds => {
let studyInstanceUID: string | undefined = undefined;
try {
// Try to fetch the first image as a blob and parse it
const firstId = imageIds[0];
if (firstId && firstId.startsWith("wadouri:")) {
const url = firstId.slice(8);
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
studyInstanceUID = dataSet.string('x0020000d');
}
} catch (e) {
// Ignore errors, fallback to undefined
}
return { imageIds, studyInstanceUID };
})
);
setAvailableStacks(stackEntries);
//setAvailableStacks(stacks.map(imageIds => ({ imageIds, studyInstanceUID: undefined })));
await setLoadedImageIdsAndCacheMeta(stacks[0]);
setViewportImageIds(Array(MAX_VIEWPORTS).fill(stacks[0] || []));
});
}, []);
const handleLoadStack = async (viewportIdx: number, stackIdx: number) => {
const stackObj = availableStacks[stackIdx];
if (!stackObj) return;
const stack = stackObj.imageIds;
// Always revert to stack mode when loading a new stack
setViewportModes(prev => {
const next = [...prev];
next[viewportIdx] = 'stack';
return next;
});
setViewportImageIds(prev => {
const next = [...prev];
next[viewportIdx] = stack;
return next;
});
setSelectedStackIdx(prev => {
const next = [...prev];
next[viewportIdx] = stackIdx;
return next;
});
// Cache imageIds and load metadata for this stack
await setLoadedImageIdsAndCacheMeta(stack);
};
const [crossHairsEnabled, setCrossHairsEnabled] = useState(false);
const [referenceLinesEnabled, setReferenceLinesEnabled] = useState(true);
const [showAdvancedMouseBindings, setShowAdvancedMouseBindings] = useState(false);
const [showAdvancedCtrlMouseBindings, setShowAdvancedCtrlMouseBindings] = useState(false);
const sortViewportImageIdsBySliceLocation = () => {
console.log("Sorting viewport imageIds by slice location");
setViewportImageIds(prev => {
const next = prev.map((imageIds, idx) => {
if (!imageIds || imageIds.length === 0) return imageIds;
const withSortKey = imageIds.map(id => {
const plane = metaData.get('imagePlaneModule', id);
let sortKey = null;
if (plane) {
if (plane.sliceLocation !== undefined) {
sortKey = Number(plane.sliceLocation);
} else if (
plane.imagePositionPatient &&
Array.isArray(plane.imagePositionPatient) &&
plane.imagePositionPatient.length === 3
) {
sortKey = Number(plane.imagePositionPatient[2]);
}
}
return { imageId: id, sortKey };
});
const sorted = [...withSortKey].sort((a, b) => {
if (a.sortKey !== null && b.sortKey !== null) {
return a.sortKey - b.sortKey;
}
return a.imageId.localeCompare(b.imageId);
});
const sortedIds = sorted.map(obj => obj.imageId);
if (JSON.stringify(sortedIds) === JSON.stringify(imageIds)) {
return imageIds;
}
return sortedIds;
});
return next;
});
};
// Wrap setLoadedImageIds to also cache DICOM metadata
const setLoadedImageIdsAndCacheMeta = async (imageIds: string[]) => {
console.log("Setting loaded imageIds:", imageIds);
console.log("autoCacheStack:", autoCacheStack);
const loadPromises: Promise<any>[] = [];
if (autoCacheStack) {
for (const imageId of imageIds) {
if (imageId.startsWith("wadouri:")) {
console.log("Caching metadata for imageId:", imageId);
try {
const url = imageId.slice(8);
if (cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager) {
// @ts-ignore
const result = cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager.load(url);
if (result && typeof result.then === "function") {
loadPromises.push(result);
}
} else {
console.warn("MetaDataManager not found. Unable to cache metadata.");
}
} catch (e) {
console.warn(`Failed to cache metadata for ${imageId}:`, e);
}
}
}
// Wait for all metadata loads to finish
await Promise.all(loadPromises);
// Trigger sort if enabled
if (orderBySliceLocation) {
console.log("Sorting viewport imageIds by slice location");
sortViewportImageIdsBySliceLocation();
}
}
};
const [viewportGrid, setViewportGrid] = useState({ rows: 1, cols: 1 })
const [viewportMenuOpen, setViewportMenuOpen] = useState(false)
// Store refs for each viewport
const viewportRefs = useRef<Array<ViewportDiv | null>>([]);
// Store the properties and scroll position of existing viewports before grid change
const prevViewportProps = useRef<Record<number, any>>({});
const prevScrollIndices = useRef<Record<number, number>>({});
const [orderBySliceLocation, setOrderBySliceLocation] = useState(true);
const [altEnablesReferenceCursors, setAltEnablesReferenceCursors] = useState(true);
// Helper to generate grid options
const gridOptions = []
for (let rows = 1; rows <= 3; rows++) {
for (let cols = 1; cols <= 3; cols++) {
gridOptions.push({ rows, cols })
}
}
// 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: "",
})
);
const [viewportModes, setViewportModes] = useState<Array<'stack' | 'volume'>>(
() => Array(MAX_VIEWPORTS).fill('stack')
);
// 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: StackScrollTool.toolName,
Auxiliary: PanTool.toolName,
Secondary: WindowLevelTool.toolName,
Primary_and_Secondary: ZoomTool.toolName, // 1 (left) + 2 (right) = 3
Fourth_Button: PlanarRotateTool.toolName, // 16
Wheel: StackScrollTool.toolName, // 524288
})
const [ctrlMouseToolBindings, setCtrlMouseToolBindings] = useState({
Primary: LengthTool.toolName,
Auxiliary: ProbeTool.toolName,
Secondary: ArrowAnnotateTool.toolName,
})
// Track if Ctrl is pressed
const [ctrlPressed, setCtrlPressed] = useState(false)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey) setCtrlPressed(true)
}
const handleKeyUp = (e: KeyboardEvent) => {
if (!e.ctrlKey) setCtrlPressed(false)
}
window.addEventListener("keydown", handleKeyDown)
window.addEventListener("keyup", handleKeyUp)
return () => {
window.removeEventListener("keydown", handleKeyDown)
window.removeEventListener("keyup", handleKeyUp)
}
}, [])
const [metaModalOpen, setMetaModalOpen] = useState(false);
const [metaModalContent, setMetaModalContent] = useState<any>(null);
const handleSelectAnnotation = (viewportIdx: number, uid: string | null) => {
// Use the official selection API for Cornerstone3D Tools
if (cornerstoneTools?.annotation?.selection?.setAnnotationSelected) {
cornerstoneTools.annotation.selection.setAnnotationSelected(uid);
}
// Optionally, update your own UI state
setSelectedAnnUIDs(prev => {
const next = [...prev];
next[viewportIdx] = uid;
return next;
});
};
const handleDeleteAnnotation = (viewportIdx: number, uid: string, currentAnnotations: any[]) => {
const allAnnotations = cornerstoneTools.annotation.state.getAllAnnotations() || [];
allAnnotations.forEach(ann => {
if (ann.annotationUID === uid) ann.isSelected = false;
});
cornerstoneTools.annotation.state.removeAnnotation(uid);
renderingEngineRef.current.render();
//}
// Auto-select another annotation if available
const remaining = currentAnnotations.filter(ann => ann.annotationUID !== uid);
setSelectedAnnUIDs(prev => {
const next = [...prev];
const newSelected = remaining.length > 0 ? remaining[0].annotationUID : null;
next[viewportIdx] = newSelected;
allAnnotations.forEach(ann => {
ann.isSelected = ann.annotationUID === newSelected;
});
cornerstoneTools.annotation.selection.setAnnotationSelected(newSelected);
renderingEngineRef.current.render();
return next;
});
};
// Add this function to gather and format metadata:
const handleShowMetadata = () => {
// Find the first visible viewport with images
const firstIdx = viewportImageIds.findIndex(ids => ids && ids.length > 0);
if (firstIdx === -1) {
setMetaModalContent("No images loaded.");
setMetaModalOpen(true);
return;
}
const imageId = viewportImageIds[firstIdx][0];
// Gather all available metadata modules for this imageId
const modules = [
"imagePlaneModule",
"generalSeriesModule",
"generalStudyModule",
"patientModule",
"imagePixelModule",
"voiLutModule",
"modalityLutModule",
"sopCommonModule",
"petIsotopeModule",
"multiframeModule",
"cineModule",
"overlayPlaneModule",
"generalImageModule",
"stackModule",
"voiLutModule",
"modalityLutModule",
"petSeriesModule",
"petImageModule",
"petIsotopeModule",
"petSeriesModule",
"petImageModule",
"petIsotopeModule",
"petSeriesModule",
"petImageModule",
"petIsotopeModule",
"petSeriesModule",
"petImageModule",
"petIsotopeModule",
// Add more if your app or cornerstone supports them
];
const allMeta: Record<string, any> = {};
modules.forEach((mod) => {
const val = metaData.get(mod, imageId);
if (val !== undefined) allMeta[mod] = val;
});
setMetaModalContent(
<pre style={{ maxHeight: 600, overflow: "auto", fontSize: 13, color: "#fff" }}>
{JSON.stringify(allMeta, null, 2)}
</pre>
);
setMetaModalOpen(true);
};
// Inside your App component:
useEffect(() => {
window.loadDicomStackFromFiles = (files: FileList | File[]) => {
// Convert FileList to array if needed
const fileArray = Array.from(files);
// Your logic to handle the files and load them as a stack
// For example, call your existing handler:
handleFilesSelected({ target: { files: fileArray } } as any);
};
// Cleanup on unmount
return () => {
window.loadDicomStackFromFiles = undefined;
};
}, []);
const [altPressed, setAltPressed] = useState(false);
useEffect(() => {
const mainToolGroup = ToolGroupManager.getToolGroup(MAIN_TOOL_GROUP_ID);
if (!mainToolGroup) return;
// List all annotation tools you use
[
'ArrowAnnotate',
'NoLabelArrowAnnotate',
'Length',
'RectangleROI',
'EllipticalROI',
'PlanarFreehandROI',
'PlanarFreehandContourSegmentation',
// Add more if needed
].forEach(toolName => {
mainToolGroup.setToolConfiguration(toolName, {
color: '#00e676', // normal color
selectedColor: '#ffeb3b', // yellow for selected
});
});
}, [MAIN_TOOL_GROUP_ID]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.altKey) setAltPressed(true);
};
const handleKeyUp = (e: KeyboardEvent) => {
if (!e.altKey) setAltPressed(false);
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, []);
useEffect(() => {
if (!altEnablesReferenceCursors) return;
const mainToolGroup = ToolGroupManager.getToolGroup(MAIN_TOOL_GROUP_ID);
if (!mainToolGroup) return;
if (altPressed) {
if (!mainToolGroup.hasTool(ReferenceCursors.toolName)) {
mainToolGroup.addTool(ReferenceCursors.toolName);
}
mainToolGroup.setToolEnabled(ReferenceCursors.toolName);
mainToolGroup.setToolActive(ReferenceCursors.toolName);
} else {
if (mainToolGroup.hasTool(ReferenceCursors.toolName)) {
mainToolGroup.setToolDisabled(ReferenceCursors.toolName);
}
}
}, [altPressed, altEnablesReferenceCursors]);
// Helper to apply all mouse tool bindings (normal or ctrl)
const applyMouseToolBindings = useCallback(() => {
// Apply to both stack and volume tool groups
const mainToolGroup = cornerstoneTools.ToolGroupManager.getToolGroup(MAIN_TOOL_GROUP_ID);
[mainToolGroup].forEach(toolGroup => {
if (toolGroup) {
// Set all tools to passive first, removing all bindings
TOOL_OPTIONS.forEach(opt => {
toolGroup.setToolPassive(opt.value, { removeAllBindings: true });
});
toolGroup.setToolDisabled(ReferenceCursors.toolName);
// Collect all bindings for each tool
const toolBindings: Record<string, Array<any>> = {};
// Normal bindings
Object.entries(mouseToolBindings).forEach(([btn, tool]) => {
if (!toolBindings[tool]) toolBindings[tool] = [];
// Find the mask for this binding
const binding = ALL_MOUSE_BINDINGS.find(b => b.value === btn);
if (binding) {
toolBindings[tool].push({ mouseButton: binding.mask });
} else if (MouseBindings[btn]) {
// Fallback for legacy bindings
toolBindings[tool].push({ mouseButton: MouseBindings[btn] });
}
});
// Ctrl bindings (now supports advanced mouse bindings)
Object.entries(ctrlMouseToolBindings).forEach(([btn, tool]) => {
if (!tool) return;
if (!toolBindings[tool]) toolBindings[tool] = [];
const binding = ALL_MOUSE_BINDINGS.find(b => b.value === btn);
if (binding) {
toolBindings[tool].push({
mouseButton: binding.mask,
modifierKey: KeyboardBindings.Ctrl,
});
} else if (MouseBindings[btn]) {
toolBindings[tool].push({
mouseButton: MouseBindings[btn],
modifierKey: KeyboardBindings.Ctrl,
});
}
});
// Apply all bindings for each tool in a single call
Object.entries(toolBindings).forEach(([tool, bindings]) => {
toolGroup.setToolActive(tool, { bindings });
});
//// Always re-apply the mouse wheel binding for StackScrollTool
//toolGroup.setToolActive(StackScrollTool.toolName, {
// bindings: [
// {
// mouseButton: MouseBindings.Wheel, // Wheel Mouse
// },
// ],
//});
}
});
}, [mouseToolBindings, ctrlMouseToolBindings]);
const [sideMenuOpen, setSideMenuOpen] = useState(false);
// Add a ref to store selected files
const fileInputRef = useRef<HTMLInputElement | null>(null);
// Handler for file input change
const handleFilesSelected = async (event: React.ChangeEvent<HTMLInputElement> | { target: { files: File[] | FileList } }) => {
const files = event.target.files;
if (!files || files.length === 0) return;
setOrderBySliceLocation(false);
const fileArray = Array.from(files).sort((a, b) => a.name.localeCompare(b.name));
let studyInstanceUID: string | undefined = undefined;
const arrayBuffer = await fileArray[0].arrayBuffer();
const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
studyInstanceUID = dataSet.string('x0020000d');
console.log("StudyInstanceUID:", studyInstanceUID);
const imageIds = fileArray.map(
file => `wadouri:${URL.createObjectURL(file)}`
);
setAvailableStacks(prev => {
const next = [...prev, { imageIds, studyInstanceUID }];
setViewportImageIds(vpPrev => {
const vpNext = [...vpPrev];
vpNext[0] = imageIds;
return vpNext;
});
setSelectedStackIdx(selPrev => {
const selNext = [...selPrev];
selNext[0] = next.length - 1;
return selNext;
});
setLoadedImageIdsAndCacheMeta(imageIds);
return next;
});
};
const handleToolChange = (mouseButton: string, toolName: string) => {
setMouseToolBindings(prev => ({ ...prev, [mouseButton]: toolName }))
}
// 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;
const imageIds = typeof viewport.getImageIds === "function"
? viewport.getImageIds()
: [];
const total = imageIds.length;
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);
}
}
setImageInfos(prev => {
const next = [...prev];
next[viewportIdx] = {
index: currentIndex + 1,
total,
windowCenter,
windowWidth,
};
return next;
});
}, []);
const [gridBtnActive, setGridBtnActive] = useState(false);
function saveVisibleViewportState() {
for (let i = 0; i < viewportGrid.rows * viewportGrid.cols; i++) {
const viewportId = `CT_${i}`;
const renderingEngine = renderingEngineRef.current;
if (renderingEngine) {
const viewport = renderingEngine.getViewport(viewportId);
if (viewport && typeof viewport.getProperties === "function") {
prevViewportProps.current[i] = viewport.getProperties();
}
if (viewport && typeof viewport.getCurrentImageIdIndex === "function") {
prevScrollIndices.current[i] = viewport.getCurrentImageIdIndex();
}
}
}
}
useEffect(() => {
const setup = async () => {
console.log("setup")
// 1. Initialize Cornerstone3D and tools ONCE
if (!running.current) {
running.current = true;
await csRenderInit();
await csToolsInit(
{ showSVGCursors: true, }
);
dicomImageLoaderInit({ maxWebWorkers: 1 });
}
// 3. Get or create the rendering engine
let renderingEngine = renderingEngineRef.current;
if (!renderingEngine) {
renderingEngine = new RenderingEngine(renderingEngineId);
renderingEngineRef.current = renderingEngine;
}
// --- In your setup function, use the correct tool group per viewport ---
let mainToolGroup = ToolGroupManager.getToolGroup(MAIN_TOOL_GROUP_ID);
if (!mainToolGroup) {
mainToolGroup = setupTools(MAIN_TOOL_GROUP_ID)
}
const visibleVolumeViewports = viewportModes
.slice(0, viewportGrid.rows * viewportGrid.cols)
.filter(mode => mode === 'volume').length;
// --- In your setup function, after tool group creation, enable/disable ReferenceLinesTool for each tool group ---
updateActiveReferenceLineViewport(mainToolGroup)
// 6. Enable and configure each viewport
for (let i = 0; i < MAX_VIEWPORTS; i++) {
let imageIds = viewportImageIds[i];
if (!imageIds || imageIds.length === 0) continue;
const viewportId = `CT_${i}`;
const element = viewportRefs.current[i];
if (!element) continue;
const viewportType =
viewportModes[i] === 'volume'
? Enums.ViewportType.ORTHOGRAPHIC // or Enums.ViewportType.VOLUME if supported
: Enums.ViewportType.STACK;
// Choose tool group based on viewport type
//const toolGroup = viewportModes[i] === 'volume' ? volumeToolGroup : stackToolGroup;
if (!element._cornerstoneEnabled || element._lastViewportType !== viewportType) {
if (renderingEngine.getViewport(viewportId)) {
renderingEngine.disableElement(viewportId);
}
renderingEngine.enableElement({
viewportId,
type: viewportType,
element,
});
element._cornerstoneEnabled = true;
element._lastViewportType = viewportType;
//mainToolGroup.addViewport(viewportId, renderingEngineId);
//toolGroup.addViewport(viewportId, renderingEngineId);
}
const viewport = renderingEngine.getViewport(viewportId);
if (!viewport) continue;
if (!element._resizeObserver) {
const resizeObserver = new window.ResizeObserver(() => {
if (element.offsetWidth > 0 && element.offsetHeight > 0) {
renderingEngine.resize();
// Always get the latest viewport instance
const vp = renderingEngine.getViewport(viewportId);
if (vp && typeof vp.resize === "function") {
vp.resize();
}
if (vp && typeof vp.render === "function") {
vp.render();
}
}
});
resizeObserver.observe(element);
element._resizeObserver = resizeObserver;
}
if (viewportModes[i] === 'stack') {
if (imageIds && !restoringStateRef.current) {
viewport.setStack(imageIds);
}
// CrosshairsTool is not applicable for stack viewports
if (!crossHairsEnabled) {
mainToolGroup.addViewport(viewportId, renderingEngineId);
} else {
mainToolGroup.removeViewports(renderingEngineId, viewportId);
}
// Restore scroll, properties, etc.
} else if (viewportModes[i] === 'volume') {
mainToolGroup.addViewport(viewportId, renderingEngineId);
//let sortedImageIds = imageIds;
//if (imageIds && imageIds.length > 1) {
// sortedImageIds = [...imageIds].sort((a, b) => {
// const metaA = metaData.get('imagePlaneModule', a);
// const metaB = metaData.get('imagePlaneModule', b);
// const sliceA = metaA && metaA.sliceLocation !== undefined ? Number(metaA.sliceLocation) : 0;
// const sliceB = metaB && metaB.sliceLocation !== undefined ? Number(metaB.sliceLocation) : 0;
// return sliceA - sliceB;
// });
//}
// Use a unique volumeId per viewport
const volumeId = `myVolumeId_${i}`;
const volume = await volumeLoader.createAndCacheVolume(volumeId, { imageIds: imageIds });
volume.load();
viewport.setVolumes([{ volumeId }]);
viewport.render();
continue
}
viewport.render();
if (!restoringStateRef.current) {
// Restore previous scroll position for existing viewports only
if (
prevScrollIndices.current[i] !== undefined &&
typeof viewport.setImageIdIndex === "function"
) {
//viewport.setImageIdIndex(prevScrollIndices.current[i]);
csUtilities.jumpToSlice(viewport.element, { imageIndex: prevScrollIndices.current[i] })
}
// Restore previous state for existing viewports only
const prevProps = prevViewportProps.current[i];
if (prevProps && typeof viewport.setProperties === "function") {
// Only restore safe properties
const { voiRange, invert, interpolationType, VOILUTFunction, colormap } = prevProps;
const propsToRestore: any = {};
if (voiRange && voiRange.lower !== undefined && voiRange.upper !== undefined) propsToRestore.voiRange = voiRange;
if (invert !== undefined) propsToRestore.invert = invert;
if (interpolationType !== undefined) propsToRestore.interpolationType = interpolationType;
if (VOILUTFunction) propsToRestore.VOILUTFunction = VOILUTFunction;
if (
colormap !== undefined &&
colormap !== null &&
(typeof colormap === "string" || (typeof colormap === "object" && !("actor" in colormap)))
) {
propsToRestore.colormap = colormap;
}
setTimeout(() => {
console.log("Restoring properties (timeout) for viewport", i, propsToRestore);
viewport.setProperties(propsToRestore);
viewport.render()
}, 100);
}
}
// Always (re-)attach overlay update for each viewport
if ((element)._overlayListener) {
// Remove previous listener if present
viewport.element.removeEventListener(IMAGE_RENDERED, (element)._overlayListener);
}
const overlayListener = () => updateOverlay(i, viewport);
viewport.element.addEventListener(IMAGE_RENDERED, overlayListener);
(element)._overlayListener = overlayListener;
updateOverlay(i, viewport);
}
const activeVolumeViewports = [];
for (let i = 0; i < viewportGrid.rows * viewportGrid.cols; i++) {
const viewportId = `CT_${i}`;
const viewport = renderingEngineRef.current?.getViewport(viewportId);
if (viewport && viewportModes[i] === 'volume') {
activeVolumeViewports.push(viewport);
}
}
// Find all active volume viewports
const activeVolumeViewportIds = [];
for (let i = 0; i < viewportGrid.rows * viewportGrid.cols; i++) {
if (viewportModes[i] === 'volume') {
activeVolumeViewportIds.push(`CT_${i}`);
}
}
if (mainToolGroup && crossHairsEnabled) {
// Only enable CrosshairsTool if at least two volume viewports are active
if (activeVolumeViewportIds.length >= 2) {
// Make sure CrosshairsTool is added to the tool group
if (!mainToolGroup.hasTool(CrosshairsTool.toolName)) {
mainToolGroup.addTool(CrosshairsTool.toolName);
}
mainToolGroup.setToolEnabled(CrosshairsTool.toolName);
mainToolGroup.setToolConfiguration(CrosshairsTool.toolName, {
// Example: color: '#ffeb3b', lineWidth: 2
});
mainToolGroup.setToolActive(CrosshairsTool.toolName, {
bindings: [{ mouseButton: MouseBindings.Primary }],
});
} else {
// Disable CrosshairsTool if not enough volume viewports
if (mainToolGroup.hasTool(CrosshairsTool.toolName)) {
mainToolGroup.setToolDisabled(CrosshairsTool.toolName);
}
}
}
// 7. Apply mouse tool bindings
applyMouseToolBindings();
};
// Use requestAnimationFrame to ensure DOM is painted before setup
const raf = requestAnimationFrame(() => {
setup();
});
// Cleanup: disconnect ResizeObservers
return () => {
viewportRefs.current.forEach(el => {
if (el && (el)._resizeObserver) {
(el)._resizeObserver.disconnect();
delete (el)._resizeObserver;
}
});
cancelAnimationFrame(raf);
};
}, [elementRef, updateOverlay, viewportGrid, viewportModes, viewportImageIds]);
// Handler to reset the view (camera and window/level)
const handleDoubleClick = useCallback((viewportIdx: number) => {
const viewportId = `CT_${viewportIdx}`;
const renderingEngine = renderingEngineRef.current;
const viewport = renderingEngine.getViewport(viewportId);
if (viewport && typeof viewport.resetCamera === "function") {
viewport.resetCamera();
console.log("Restoring viewport properties for viewport", viewportIdx);
viewport.resetProperties(); // Resets window/level and other properties
viewport.render();
}
}, [])
// Handler to apply a preset for a specific viewport
const handleApplyPreset = useCallback((viewportIdx: number, center: number, width: number) => {
const viewportId = `CT_${viewportIdx}`;
try {
const renderingEngine = renderingEngineRef.current;
if (renderingEngine) {
const viewport = renderingEngine.getViewport(viewportId);
if (viewport && typeof viewport.setProperties === "function") {
console.log(`Applying preset to viewport ${viewportIdx}: center=${center}, width=${width}`);
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(() => {
applyMouseToolBindings()
}, [mouseToolBindings, ctrlMouseToolBindings, ctrlPressed, 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]);
useEffect(() => {
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
};
document.addEventListener("contextmenu", handleContextMenu);
return () => {
document.removeEventListener("contextmenu", handleContextMenu);
};
}, []);
useEffect(() => {
if (!referenceLinesEnabled) return;
const mainToolGroup = ToolGroupManager.getToolGroup(MAIN_TOOL_GROUP_ID);
updateActiveReferenceLineViewport(mainToolGroup)
}, [activeViewport, referenceLinesEnabled]);
useEffect(() => {
if (fileInputRef.current) {
fileInputRef.current.setAttribute("webkitdirectory", "true");
fileInputRef.current.setAttribute("directory", "true");
}
}, []);
useEffect(() => {
// API: Load a stack from a list of wadouri imageIds (strings)
window.loadDicomStackFromWadouriList = async (wadouriImageIds: string[]) => {
if (!wadouriImageIds || wadouriImageIds.length === 0) return;
// Try to extract StudyInstanceUID from the first image if possible
let studyInstanceUID: string | undefined = undefined;
try {
const firstId = wadouriImageIds[0];
if (firstId && firstId.startsWith("wadouri:")) {
const url = firstId.slice(8);
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
studyInstanceUID = dataSet.string('x0020000d');
}
} catch (e) {
// Ignore errors, fallback to undefined
}
setAvailableStacks(prev => {
const next = [...prev, { imageIds: wadouriImageIds, studyInstanceUID }];
setViewportImageIds(vpPrev => {
const vpNext = [...vpPrev];
vpNext[0] = wadouriImageIds;
return vpNext;
});
setSelectedStackIdx(selPrev => {
const selNext = [...selPrev];
selNext[0] = next.length - 1;
return selNext;
});
setLoadedImageIdsAndCacheMeta(wadouriImageIds);
return next;
});
};
// Cleanup
return () => {
window.loadDicomStackFromWadouriList = undefined;
};
}, [setAvailableStacks, setViewportImageIds, setSelectedStackIdx, setLoadedImageIdsAndCacheMeta]);
useEffect(() => {
if (!orderBySliceLocation) return;
// Only sort if all metadata is loaded for all viewports
const allLoaded = viewportImageIds.every(imageIds =>
!imageIds || imageIds.length === 0 ||
imageIds.every(id => !!metaData.get("imagePlaneModule", id))
);
if (!allLoaded) return;
setViewportImageIds(prev => {
const next = prev.map(imageIds => {
if (!imageIds || imageIds.length === 0) return imageIds;
const withSliceLoc = imageIds.map(id => ({
imageId: id,
sliceLoc: (() => {
const meta = metaData.get('imagePlaneModule', id);
return meta && meta.sliceLocation !== undefined ? Number(meta.sliceLocation) : null;
})(),
}));
const sorted = [...withSliceLoc].sort((a, b) => {
if (a.sliceLoc !== null && b.sliceLoc !== null) {
return a.sliceLoc - b.sliceLoc;
}
return a.imageId.localeCompare(b.imageId);
});
const sortedIds = sorted.map(obj => obj.imageId);
if (JSON.stringify(sortedIds) === JSON.stringify(imageIds)) {
return imageIds;
}
return sortedIds;
});
return next;
});
}, [orderBySliceLocation, availableStacks]);
function updateActiveReferenceLineViewport(mainToolGroup: cornerstoneTools.Types.IToolGroup) {
// Ensure the main tool group is defined (it won't be during setup)
if (!mainToolGroup) return;
const sourceViewportId = `CT_${activeViewport}`
if (referenceLinesEnabled) {
mainToolGroup.setToolEnabled(ReferenceLinesTool.toolName)
mainToolGroup.setToolConfiguration(ReferenceLinesTool.toolName, {
sourceViewportId,
})
} else {
mainToolGroup.setToolDisabled(ReferenceLinesTool.toolName)
}
}
useEffect(() => {
const apiKey = container_id || "default";
function jumpToSliceByImageId(viewportIdx: number, imageId: string) {
const renderingEngine = renderingEngineRef.current;
if (!renderingEngine) return;
const viewport = renderingEngine.getViewport(`CT_${viewportIdx}`);
if (!viewport || typeof viewport.getImageIds !== "function") return;
const imageIds = viewport.getImageIds();
// Some stacks may have imageIds with or without "wadouri:" prefix, so normalize for comparison
const normImageIds = imageIds.map(id => id.replace(/^wadouri:/, ""));
const normTarget = imageId.replace(/^wadouri:/, "");
const idx = normImageIds.indexOf(normTarget);
if (idx !== -1 && typeof viewport.setImageIdIndex === "function") {
csUtilities.jumpToSlice(viewport.element, { imageIndex: idx });
updateOverlay(viewportIdx, viewport);
}
}
// Attach to window with a unique name
window[`jumpToSliceByImageId_${apiKey}`] = jumpToSliceByImageId;
// Export the current viewer state as JSON
window[`exportViewerState_${apiKey}`] = () => {
const numActive = viewportGrid.rows * viewportGrid.cols;
const modes = viewportModes.slice(0, numActive);
const stackIdx = selectedStackIdx.slice(0, numActive);
const stacks = availableStacks.map(s => ({
i: s.imageIds, // imageIds
u: s.studyInstanceUID // studyInstanceUID
}));
// Only keep minimal properties
const renderingEngine = renderingEngineRef.current;
const props: any[] = [];
const stackPos: number[] = [];
const cam: any[] = [];
for (let i = 0; i < numActive; i++) {
let p = null, s = null, c = null;
if (renderingEngine) {
const viewportId = `CT_${i}`;
const viewport = renderingEngine.getViewport(viewportId);
if (viewport) {
if (typeof viewport.getProperties === "function") {
const { voiRange, invert, interpolationType, VOILUTFunction, colormap } = viewport.getProperties();
p = { voiRange, invert, interpolationType, VOILUTFunction, colormap };
}
if (typeof viewport.getCurrentImageIdIndex === "function") {
s = viewport.getCurrentImageIdIndex();
}
if (typeof viewport.getCamera === "function") {
c = viewport.getCamera();
}
}
}
props.push(p);
stackPos.push(s);
cam.push(c);
}
// Use short keys for compactness
const state = {
grid: viewportGrid,
modes,
stacks,
stackIdx,
stackPos,
props,
cam,
};
return JSON.stringify(state);
};
// Import and restore a viewer state from JSON (using stack indices)
window[`importViewerState_${apiKey}`] = (json: string) => {
try {
restoringStateRef.current = true;
const state = typeof json === "string" ? JSON.parse(json) : json;
if (state.grid) setViewportGrid(state.grid);
if (state.modes) setViewportModes(state.modes);
if (state.stacks) setAvailableStacks(state.stacks.map((s: any) => ({
imageIds: s.i,
studyInstanceUID: s.u,
})));
if (state.stackIdx && state.stacks) {
const reconstructed = state.stackIdx.map(
(idx: number) => state.stacks[idx]?.i || []
);
setViewportImageIds(reconstructed);
setSelectedStackIdx(state.stackIdx);
}
setTimeout(() => {
const renderingEngine = renderingEngineRef.current;
if (!renderingEngine) {
restoringStateRef.current = false;
return;
}
const numActive = state.grid?.rows * state.grid?.cols || 1;
for (let i = 0; i < numActive; i++) {
const viewportId = `CT_${i}`;
const viewport = renderingEngine.getViewport(viewportId);
if (!viewport) continue;
// Restore stack position
if (
state.stackPos &&
typeof state.stackPos[i] === "number" &&
typeof viewport.setImageIdIndex === "function"
) {
if (csUtilities && csUtilities.jumpToSlice) {
csUtilities.jumpToSlice(viewport.element, { imageIndex: state.stackPos[i] });
} else {
viewport.setImageIdIndex(state.stackPos[i]);
}
}
// Restore properties
if (
state.props &&
state.props[i] &&
typeof viewport.setProperties === "function"
) {
viewport.setProperties(state.props[i]);
viewport.render();
}
// Restore camera
if (
state.cam &&
state.cam[i] &&
typeof viewport.setCamera === "function"
) {
viewport.setCamera(state.cam[i]);
viewport.render();
}
}
restoringStateRef.current = false;
}, 300);
} catch (e) {
restoringStateRef.current = false;
alert("Failed to import viewer state: " + e);
}
};
// Export all annotations using cornerstoneTools.annotation.state.getAllAnnotations()
window[`exportAnnotations_${apiKey}`] = () => {
try {
let allAnnotations = cornerstoneTools.annotation.state.getAllAnnotations();
console.log("Exporting annotations:", allAnnotations);
// Remove ReferenceLines annotations (toolName may be 'ReferenceLines')
if (Array.isArray(allAnnotations)) {
// Only keep annotations with toolName in the allowed list
const allowedTools = [
LengthTool.toolName,
NoLabelArrowAnnotateTool.toolName,
ArrowAnnotateTool.toolName,
RectangleROITool.toolName,
EllipticalROITool.toolName,
PlanarFreehandROITool.toolName,
PlanarFreehandContourSegmentationTool.toolName,
ProbeTool.toolName,
SculptorTool.toolName,
];
allAnnotations = allAnnotations.filter(
ann => allowedTools.includes(ann.metadata.toolName)
);
} else {
return "{}"; // If no annotations found, return empty object
}
return JSON.stringify(allAnnotations, null, 2);
} catch (e) {
alert("Failed to export annotations: " + e);
return "{}";
}
};
// Import and restore all annotations using cornerstoneTools.annotation.state.restoreAnnotations()
window[`importAnnotations_${apiKey}`] = (json: string) => {
try {
const annotationData = typeof json === "string" ? JSON.parse(json) : json;
// Remove all existing annotations if needed
if (cornerstoneTools.annotation.state.removeAllAnnotations) {
cornerstoneTools.annotation.state.removeAllAnnotations();
}
// Add each annotation from the imported data
if (Array.isArray(annotationData)) {
// For now just add them to a new group
//const group = new cornerstoneTools.annotation.AnnotationGroup();
annotationData.forEach(ann => {
cornerstoneTools.annotation.state.addAnnotation(ann, ann.annotationUID);
});
}
if (renderingEngineRef.current) {
renderingEngineRef.current.render();
}
} catch (e) {
alert("Failed to import annotations: " + e);
}
};
window[`importLegacyAnnotations_${apiKey}`] = (json: string) => {
// Remove all existing annotations
if (cornerstoneTools.annotation.state.removeAllAnnotations) {
cornerstoneTools.annotation.state.removeAllAnnotations();
}
let legacyData: any;
try {
legacyData = typeof json === "string" ? JSON.parse(json) : json;
} catch (e) {
alert("Invalid legacy annotation JSON");
return;
}
const toolNameMap: Record<string, string> = {
ArrowAnnotate: "ArrowAnnotate",
// Add more mappings if needed
};
Object.entries(legacyData).forEach(([imageId, tools]) => {
console.log("Importing legacy annotations for imageId:", imageId);
Object.entries(tools as any).forEach(([legacyTool, toolData]: [string, any]) => {
const cs3Tool = toolNameMap[legacyTool] || legacyTool;
if (toolData.data && Array.isArray(toolData.data)) {
toolData.data.forEach((ann: any) => {
// Convert pixel handles to world coordinates
const startWorld = pixelToWorld(imageId, { x: ann.handles.start.y, y: ann.handles.start.x });
const endWorld = pixelToWorld(imageId, { x: ann.handles.end.y, y: ann.handles.end.x });
console.log("Importing legacy annotation:")
console.log(" Image ID:", imageId);
console.log(" Start World:", startWorld);
console.log(" End World:", endWorld);
// Get spatial metadata
const plane = metaData.get("imagePlaneModule", imageId);
console.log(metaData);
let FrameOfReferenceUID = metaData.get("frameOfReferenceUID", imageId);
if (!FrameOfReferenceUID && plane && plane.frameOfReferenceUID) {
FrameOfReferenceUID = plane.frameOfReferenceUID;
}
const viewPlaneNormal =
plane?.rowCosines && plane?.columnCosines
? [
plane.rowCosines[1] * plane.columnCosines[2] - plane.rowCosines[2] * plane.columnCosines[1],
plane.rowCosines[2] * plane.columnCosines[0] - plane.rowCosines[0] * plane.columnCosines[2],
plane.rowCosines[0] * plane.columnCosines[1] - plane.rowCosines[1] * plane.columnCosines[0],
]
: undefined;
// Only include viewPlaneNormal if it's a valid 3-element array
const metadata: any = {
toolName: cs3Tool,
referencedImageId: imageId,
FrameOfReferenceUID,
};
if (Array.isArray(viewPlaneNormal) && viewPlaneNormal.length === 3) {
metadata.viewPlaneNormal = viewPlaneNormal as [number, number, number];
}
// --- Add rotation if present in legacy annotation ---
// Does this actually exist?
if (typeof ann.rotation === "number") {
metadata.rotation = ann.rotation;
}
const annotation = {
annotationUID: ann.uuid, // || cornerstoneTools.utilities.uuidv4(),
metadata: {
toolName: cs3Tool,
referencedImageId: imageId,
FrameOfReferenceUID,
viewPlaneNormal,
// Optionally add viewUp, cameraFocalPoint, etc.
},
data: {
handles: {
points: [startWorld, endWorld],
textBox: ann.handles.textBox,
},
text: ann.text || "",
// Add more fields as needed
},
visibility: ann.visible !== false,
active: ann.active || false,
};
// @ts-expect-error legacy
cornerstoneTools.annotation.state.addAnnotation(annotation, annotation.annotationUID);
});
}
});
});
if (renderingEngineRef.current) {
renderingEngineRef.current.render();
}
};
// Import and apply a legacy viewport state to the active viewport
window[`importLegacyViewport_${apiKey}`] = (json: string) => {
let legacy: any;
try {
legacy = typeof json === "string" ? JSON.parse(json) : json;
} catch (e) {
alert("Invalid legacy viewport JSON");
return;
}
const renderingEngine = renderingEngineRef.current;
if (!renderingEngine) return;
const viewportId = `CT_${activeViewport ?? 0}`;
const viewport = renderingEngine.getViewport(viewportId);
if (!viewport) return;
// Map legacy fields to Cornerstone3D properties
const props: any = {};
if (legacy.voi) {
props.voiRange = {
lower: legacy.voi.windowCenter - legacy.voi.windowWidth / 2,
upper: legacy.voi.windowCenter + legacy.voi.windowWidth / 2,
};
}
if (typeof legacy.invert === "boolean") props.invert = legacy.invert;
if (typeof legacy.pixelReplication === "boolean") props.interpolationType = legacy.pixelReplication ? 0 : 1;
if (typeof legacy.hflip === "boolean") props.hflip = legacy.hflip;
if (typeof legacy.vflip === "boolean") props.vflip = legacy.vflip;
if (typeof viewport.setProperties === "function") {
viewport.setProperties(props);
}
// Apply translation and scale (pan/zoom)
if (legacy.translation || legacy.scale) {
const camera = viewport.getCamera ? viewport.getCamera() : {};
if (legacy.translation) {
camera.translation = { ...camera.translation, ...legacy.translation };
}
if (legacy.scale) {
camera.parallelScale = 200 / legacy.scale;
}
console.log("Applying legacy camera properties:", camera);
if (typeof viewport.setCamera === "function") {
viewport.setCamera(camera);
}
}
// --- Fix: Import rotation from legacy viewport ---
if (typeof legacy.rotation === "number") {
viewport.setRotation(legacy.rotation);
}
viewport.render();
updateOverlay(activeViewport ?? 0, viewport);
};
// Cleanup
return () => {
window[`exportViewerState_${apiKey}`] = undefined;
window[`importViewerState_${apiKey}`] = undefined;
window[`exportAnnotations_${apiKey}`] = undefined;
window[`importAnnotations_${apiKey}`] = undefined;
window[`importLegacyAnnotations_${apiKey}`] = undefined;
window[`importLegacyViewport_${apiKey}`] = undefined;
window[`jumpToSliceByImageId_${apiKey}`] = undefined;
};
}, [
viewportGrid,
viewportModes,
selectedStackIdx,
availableStacks,
viewportImageIds,
setViewportGrid,
setViewportModes,
setSelectedStackIdx,
setAvailableStacks,
setViewportImageIds,
]);
// Reference stack and UID for study comparison (use active viewport)
const referenceStackIdx = activeViewport !== null ? selectedStackIdx[activeViewport] : 0;
const referenceStackObj = availableStacks[referenceStackIdx];
const referenceUID = referenceStackObj?.studyInstanceUID;
const [selectedAnnUIDs, setSelectedAnnUIDs] = useState<(string | null)[]>(() =>
Array(MAX_VIEWPORTS).fill(null)
);
const numVisible = viewportGrid.rows * viewportGrid.cols;
const viewports = [];
//for (let i = 0; i < MAX_VIEWPORTS; i++) {
// Viewports rendering loop
for (let i = 0; i < numVisible; i++) {
// Determine if this viewport should be visible in the current grid
const isVisible = i < viewportGrid.rows * viewportGrid.cols;
viewports.push(
<div
key={i}
ref={el => (viewportRefs.current[i] = el)}
style={{
flex: 1,
border: isVisible ? "1px solid #222" : "none", // Only show border if visible
background: "#000",
minWidth: 0,
minHeight: 0,
position: "relative",
overflow: "hidden",
flexDirection: "column",
gridRow: Math.floor(i / viewportGrid.cols) + 1,
gridColumn: (i % viewportGrid.cols) + 1,
height: "100%",
//visibility: isVisible ? "visible" : "hidden",
//pointerEvents: isVisible ? "auto" : "none",
// Remove outline, use only boxShadow for indicator
boxShadow: activeViewport === i
? "0 0 0 4px #1976d2, 0 0 16px 4px #1976d2cc"
: undefined,
// cursor: "pointer",
zIndex: activeViewport === i ? 10 : 1,
}}
onClick={() => setActiveViewport(i)}
onDoubleClick={() => handleDoubleClick(i)}
>
<button
style={{
position: "absolute",
top: 8,
left: 8,
zIndex: 20,
background: "#333",
color: "#fff",
border: "none",
borderRadius: "4px",
padding: "4px 8px",
cursor: "pointer",
fontSize: "12px",
opacity: 0.85,
}}
onClick={() => {
setViewportModes(prev => {
const next = [...prev];
next[i] = prev[i] === 'stack' ? 'volume' : 'stack';
return next;
});
}}
>
{viewportModes[i] === 'stack' ? 'Volume' : 'Stack'}
</button>
{viewportModes[i] === 'volume' && (
<div
style={{
position: "absolute",
top: 8,
left: 70,
zIndex: 21,
display: "flex",
gap: "6px",
background: "rgba(34,34,34,0.85)",
borderRadius: "4px",
padding: "2px 6px",
alignItems: "center",
}}
>
<button
style={{
background: "#444",
color: "#fff",
border: "none",
borderRadius: "3px",
padding: "2px 8px",
cursor: "pointer",
fontSize: "12px",
marginRight: "2px",
}}
onClick={() => {
const renderingEngine = renderingEngineRef.current;
const viewport = renderingEngine?.getViewport(`CT_${i}`);
if (viewport && viewport.setOrientation) {
viewport.setOrientation("axial");
viewport.render();
}
}}
>
Axial
</button>
<button
style={{
background: "#444",
color: "#fff",
border: "none",
borderRadius: "3px",
padding: "2px 8px",
cursor: "pointer",
fontSize: "12px",
marginRight: "2px",
}}
onClick={() => {
const renderingEngine = renderingEngineRef.current;
const viewport = renderingEngine?.getViewport(`CT_${i}`);
if (viewport && viewport.setOrientation) {
viewport.setOrientation("sagittal");
viewport.render();
}
}}
>
Sagittal
</button>
<button
style={{
background: "#444",
color: "#fff",
border: "none",
borderRadius: "3px",
padding: "2px 8px",
cursor: "pointer",
fontSize: "12px",
}}
onClick={() => {
const renderingEngine = renderingEngineRef.current;
const viewport = renderingEngine?.getViewport(`CT_${i}`);
if (viewport && viewport.setOrientation) {
viewport.setOrientation("coronal");
viewport.render();
}
}}
>
Coronal
</button>
</div>
)}
{/* Fullscreen button below the Volume/Stack toggle */}
<button
style={{
position: "absolute",
top: 40,
left: 8,
zIndex: 20,
background: "#111",
color: "#fff",
border: "none",
borderRadius: "4px",
padding: "4px 8px",
cursor: "pointer",
fontSize: "13px",
opacity: 0.85,
marginTop: "4px",
display: "flex",
alignItems: "center",
gap: "6px",
}}
title="Toggle Fullscreen"
onClick={e => {
e.stopPropagation();
const el = viewportRefs.current[i];
if (document.fullscreenElement === el) {
document.exitFullscreen();
} else if (el?.requestFullscreen) {
el.requestFullscreen();
} else if ((el as any)?.webkitRequestFullscreen) {
(el as any).webkitRequestFullscreen();
} else if ((el as any)?.msRequestFullscreen) {
(el as any).msRequestFullscreen();
}
}}
onMouseEnter={() => setFullscreenHoverIdx(i)}
onMouseLeave={() => setFullscreenHoverIdx(null)}
>
<span style={{ fontSize: "18px", lineHeight: 1 }}></span>
{fullscreenHoverIdx === i && (
<span style={{ marginLeft: 6 }}>
{document.fullscreenElement === viewportRefs.current[i]
? "Exit Fullscreen"
: "Fullscreen"}
</span>
)}
</button>
// Annotations buttons
{viewportModes[i] === "stack" && (() => {
// Get all annotations for this stack
const imageIds = viewportImageIds[i];
const allAnnotations = cornerstoneTools.annotation.state.getAllAnnotations() || [];
const stackAnnotations = allAnnotations.filter(
ann =>
ann.metadata &&
ann.metadata.referencedImageId &&
imageIds.includes(ann.metadata.referencedImageId) &&
ann.metadata.toolName !== ReferenceCursors.toolName // Exclude ReferenceCursors
);
if (!stackAnnotations.length) return null;
// Find the annotation(s) on the current image
const renderingEngine = renderingEngineRef.current;
const viewport = renderingEngine?.getViewport(`CT_${i}`);
const currentIdx = viewport?.getCurrentImageIdIndex?.() ?? 0;
const currentImageId = imageIds[currentIdx];
const currentAnnotations = stackAnnotations.filter(
ann => ann.metadata.referencedImageId === currentImageId
);
// Use per-viewport selection state
const selectedAnnUID = selectedAnnUIDs[i];
return (
<div
style={{
position: "absolute",
left: "50%",
bottom: 60,
transform: "translateX(-50%)",
zIndex: 30,
display: "flex",
gap: 4,
pointerEvents: "auto",
alignItems: "center",
}}
>
{/* Prev/Next buttons ... */}
<button
style={{
background: "rgba(30,30,30,0.7)",
color: "#fff",
border: "none",
borderRadius: "50%",
width: 28,
height: 28,
fontSize: "16px",
fontWeight: "bold",
cursor: "pointer",
opacity: 0.7,
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 1px 4px rgba(0,0,0,0.18)",
transition: "opacity 0.2s",
}}
title="Previous Annotation"
aria-label="Previous Annotation"
onClick={() => {
const renderingEngine = renderingEngineRef.current;
const viewport = renderingEngine?.getViewport(`CT_${i}`);
if (!viewport || typeof viewport.getCurrentImageIdIndex !== "function") return;
const currentIdx = viewport.getCurrentImageIdIndex();
const prevIdx = findNextAnnotationIdx(currentIdx, imageIds, stackAnnotations, -1);
if (prevIdx !== null && typeof viewport.setImageIdIndex === "function") {
csUtilities.jumpToSlice(viewport.element, { imageIndex: prevIdx });
updateOverlay(i, viewport);
// Select the first annotation on the new image
const newImageId = imageIds[prevIdx];
const annsOnImage = stackAnnotations.filter(
ann => ann.metadata.referencedImageId === newImageId
);
handleSelectAnnotation(i, annsOnImage.length > 0 ? annsOnImage[0].annotationUID : null);
}
}}
>
<span aria-hidden="true"></span>
</button>
<button
style={{
background: "rgba(30,30,30,0.7)",
color: "#fff",
border: "none",
borderRadius: "50%",
width: 28,
height: 28,
fontSize: "16px",
fontWeight: "bold",
cursor: "pointer",
opacity: 0.7,
display: "flex",
alignItems: "center",
justifyContent: "center",
boxShadow: "0 1px 4px rgba(0,0,0,0.18)",
transition: "opacity 0.2s",
}}
title="Next Annotation"
aria-label="Next Annotation"
onClick={() => {
const renderingEngine = renderingEngineRef.current;
const viewport = renderingEngine?.getViewport(`CT_${i}`);
if (!viewport || typeof viewport.getCurrentImageIdIndex !== "function") return;
const currentIdx = viewport.getCurrentImageIdIndex();
const nextIdx = findNextAnnotationIdx(currentIdx, imageIds, stackAnnotations, 1);
if (nextIdx !== null && typeof viewport.setImageIdIndex === "function") {
csUtilities.jumpToSlice(viewport.element, { imageIndex: nextIdx });
updateOverlay(i, viewport);
// Select the first annotation on the new image
const newImageId = imageIds[nextIdx];
const annsOnImage = stackAnnotations.filter(
ann => ann.metadata.referencedImageId === newImageId
);
handleSelectAnnotation(i, annsOnImage.length > 0 ? annsOnImage[0].annotationUID : null);
}
}}
>
<span aria-hidden="true"></span>
</button>
{/* List and select/delete individual annotations */}
{currentAnnotations.length > 0 && (
<div style={{ display: "flex", gap: 2, alignItems: "center" }}>
{currentAnnotations.map(ann => (
<div
key={ann.annotationUID}
style={{
display: "flex",
alignItems: "center",
background: selectedAnnUID === ann.annotationUID ? "rgb(4, 225, 0)" : "#444",
color: selectedAnnUID === ann.annotationUID ? "#222" : "#fff",
borderRadius: "4px",
padding: "2px 6px",
marginRight: 2,
cursor: "pointer",
border: selectedAnnUID === ann.annotationUID ? "2px solid #1976d2" : "none",
fontWeight: selectedAnnUID === ann.annotationUID ? "bold" : "normal",
}}
onClick={() => handleSelectAnnotation(i, ann.annotationUID)}
>
<span style={{ marginRight: 4 }}>
{selectedAnnUID === ann.annotationUID ? "★ " : ""}
{ann.metadata.toolName || "Annotation"}
</span>
<button
style={{
background: "#b71c1c",
color: "#fff",
border: "none",
borderRadius: "50%",
width: 20,
height: 20,
fontSize: "13px",
fontWeight: "bold",
cursor: "pointer",
marginLeft: 2,
display: "flex",
alignItems: "center",
justifyContent: "center",
opacity: 0.85,
}}
title="Delete this annotation"
aria-label="Delete annotation"
onClick={e => {
e.stopPropagation();
handleDeleteAnnotation(i, ann.annotationUID, currentAnnotations);
}}
>
🗑
</button>
</div>
))}
</div>
)}
</div>
);
})()}
// ...existing code...
{/* 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",
}}
>
{viewportModes[i] === "stack" ? (
<>
<div>
Image: {imageInfos[i]?.index} / {imageInfos[i]?.total}
</div>
<div>
WC: {imageInfos[i]?.windowCenter} &nbsp; WW: {imageInfos[i]?.windowWidth}
</div>
</>
) : (
// Volume mode: do not show image position
<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={() => {
handleApplyPreset(i, preset.center, preset.width);
setPresetsOpenArr(prev => {
const next = [...prev];
next[i] = false;
return next;
});
}}
>
{preset.name}
</button>
))}
</div>
)}
</div>
</div>
{/* Position bar inside each viewport */}
{viewportModes[i] === "stack" && viewportImageIds[i] && (
<div
style={{
position: "absolute",
top: "10%",
right: 8,
width: "12px",
height: "80%",
maxHeight: "100%",
minHeight: 0,
zIndex: 30,
display: viewportImageIds[i].length > 0 ? "flex" : "none",
alignItems: "flex-start",
justifyContent: "center",
pointerEvents: "none",
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...`
}
onClick={() => {
const renderingEngine = renderingEngineRef.current;
const viewport = renderingEngine?.getViewport(`CT_${i}`);
if (
viewport &&
typeof viewport.setImageIdIndex === "function" &&
viewportModes[i] === "stack"
) {
//viewport.setImageIdIndex(idx);
csUtilities.jumpToSlice(viewport.element, { imageIndex: idx })
//viewport.render();
updateOverlay(i, viewport);
}
}}
/>
);
})}
</div>
</div>
)}
{isVisible && availableStacks.length > 1 && (
<div
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 8,
zIndex: 20,
display: "flex",
alignItems: "center",
justifyContent: "center", // Center horizontally
pointerEvents: "auto",
width: "100%",
// Remove background/gap if you want only the selector to be visible
background: "none",
borderRadius: "0",
padding: 0,
}}
>
{/* Custom Stack Dropdown */}
<div style={{ position: "relative" }}>
<button
style={{
background: "#222",
color: "#fff",
border: "1px solid #444",
borderRadius: "4px",
padding: "2px 8px",
fontSize: "13px",
marginRight: "6px",
minWidth: 120,
textAlign: "left",
cursor: "pointer",
}}
onClick={() => setOpenDropdownIdx(openDropdownIdx === i ? null : i)}
//onBlur={() => setTimeout(() => setOpenDropdownIdx(null), 150)}
aria-haspopup="listbox"
aria-expanded={openDropdownIdx === i}
>
{/* Show current stack with indicator */}
{(() => {
const stackObj = availableStacks[selectedStackIdx[i] || 0];
const thisUID = stackObj?.studyInstanceUID;
const sameStudy = referenceUID && thisUID && referenceUID === thisUID;
const dotColor = sameStudy ? "#4caf50" : "#888";
return (
<span>
<span style={{
color: dotColor,
fontWeight: "bold",
marginRight: 6,
fontSize: "16px",
verticalAlign: "middle",
}}></span>
Stack {selectedStackIdx[i] + 1} ({stackObj?.imageIds.length || 0} images)
</span>
);
})()}
<span style={{ float: "right", fontSize: "12px" }}></span>
</button>
{openDropdownIdx === i && (
<div
ref={el => {
if (el) el.focus();
}}
tabIndex={0}
style={{
position: "absolute",
left: 0,
bottom: "110%",
background: "#222",
border: "1px solid #444",
borderRadius: "4px",
boxShadow: "0 2px 8px rgba(0,0,0,0.3)",
zIndex: 100,
minWidth: 180,
maxHeight: 220,
overflowY: "auto",
overscrollBehavior: "contain",
scrollbarWidth: "thin",
}}
role="listbox"
onMouseDown={e => e.stopPropagation()}
onWheel={e => {
// Trap scroll inside the dropdown
const target = e.currentTarget as HTMLDivElement;
const { scrollTop, scrollHeight, clientHeight } = target;
const atTop = scrollTop === 0;
const atBottom = scrollTop + clientHeight >= scrollHeight - 1;
if (
(e.deltaY < 0 && atTop) ||
(e.deltaY > 0 && atBottom)
) {
// Let the event bubble to parent (viewport)
return;
}
// Otherwise, prevent scrolling the viewport
e.stopPropagation();
e.preventDefault();
// Manually scroll the menu
target.scrollTop += e.deltaY;
}}
onBlur={e => {
const related = e.relatedTarget as Node;
const parent = e.currentTarget.parentElement;
if (!parent?.contains(related)) {
setTimeout(() => setOpenDropdownIdx(null), 100);
}
}}
>
{availableStacks.map((stackObj, idx) => {
const thisUID = stackObj.studyInstanceUID;
const sameStudy = referenceUID && thisUID && referenceUID === thisUID;
const dotColor = sameStudy ? "#4caf50" : "#888";
const isEmpty = stackObj.imageIds.length === 0;
return (
<div
key={idx}
role="option"
aria-selected={selectedStackIdx[i] === idx}
style={{
padding: "6px 12px",
background: selectedStackIdx[i] === idx ? "#444" : "#222",
color: isEmpty ? "#aaa" : "#fff",
cursor: isEmpty ? "not-allowed" : "pointer",
display: "flex",
alignItems: "center",
fontWeight: selectedStackIdx[i] === idx ? "bold" : "normal",
borderBottom: "1px solid #333",
userSelect: "none",
opacity: isEmpty ? 0.6 : 1,
}}
tabIndex={-1}
onMouseDown={e => {
e.preventDefault();
if (!isEmpty) {
handleLoadStack(i, idx);
setOpenDropdownIdx(null);
}
}}
>
<span style={{
color: dotColor,
fontWeight: "bold",
marginRight: 8,
fontSize: "16px",
verticalAlign: "middle",
}}></span>
Stack {idx + 1} ({stackObj.imageIds.length} images
{isEmpty && <span style={{ color: "#f44336", marginLeft: 6 }}>(empty)</span>})
</div>
);
})}
</div>
)}
</div>
</div>
)}
</div>
);
}
return (
// App root
<div style={{
position: "relative",
inset: 0,
width: "100%",
height: "100%",
boxSizing: "border-box",
background: "#222",
padding: "12px",
overflow: "hidden",
}}>
<div>
{/* Side menu toggle button */}
<button
style={{
position: "absolute",
top: "50%",
transform: "translateY(-50%)",
left: sideMenuOpen ? 250 : -20, // Slide right when open
zIndex: 200,
width: 40,
height: 40,
background: "#222",
color: "#fff",
border: "none",
borderRadius: "50%",
fontWeight: "bold",
cursor: "pointer",
fontSize: "22px",
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
transition: "left 0.25s cubic-bezier(.4,2,.6,1), background 0.2s",
}}
onClick={() => setSideMenuOpen(open => !open)}
aria-label="Open menu"
>
</button>
{/* Side menu drawer */}
{sideMenuOpen && (
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: 220,
height: "100%",
background: "#222",
color: "#fff",
zIndex: 199,
boxShadow: "2px 0 12px rgba(0,0,0,0.3)",
flexDirection: "column",
padding: "24px 16px 16px 16px",
display: "flex",
overflow: "hidden",
}}
>
<div
style={{
flex: 1,
minHeight: 0,
overflowY: "auto", // <-- enables scrolling for menu content
display: "flex",
flexDirection: "column",
}}
>
<div style={{ fontWeight: "bold", fontSize: 18, marginBottom: 24 }}>
Menu
<button
style={{
float: "right",
background: "transparent",
color: "#fff",
border: "none",
fontSize: "22px",
cursor: "pointer",
}}
onClick={() => setSideMenuOpen(false)}
aria-label="Close menu"
>
×
</button>
</div>
<button
style={{
padding: "10px 18px",
background: "#333",
color: "#fff",
border: "none",
borderRadius: "4px",
fontWeight: "bold",
cursor: "pointer",
fontSize: "15px",
marginBottom: "18px",
width: "100%",
}}
onClick={() => fileInputRef.current?.click()}
>
Load DICOM Folder
</button>
<input
ref={fileInputRef}
type="file"
style={{ display: "none" }}
multiple
onChange={handleFilesSelected}
accept=".dcm,application/dicom"
/>
<div
style={{
marginBottom: "18px",
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<input
type="checkbox"
id="orderBySliceLocation"
checked={orderBySliceLocation}
onChange={e => setOrderBySliceLocation(e.target.checked)}
style={{ accentColor: "#666" }}
/>
<label htmlFor="orderBySliceLocation" style={{ fontSize: "15px", cursor: "pointer" }}>
Order by Slice Location
</label>
</div>
<button
style={{
padding: "10px 18px",
background: "#333",
color: "#fff",
border: "none",
borderRadius: "4px",
fontWeight: "bold",
cursor: "pointer",
fontSize: "15px",
marginBottom: "18px",
width: "100%",
}}
onClick={handleShowMetadata}
>
Show DICOM Metadata
</button>
<button
style={{
padding: "10px 18px",
background: crossHairsEnabled ? "#1976d2" : "#333",
color: "#fff",
border: "none",
borderRadius: "4px",
fontWeight: "bold",
cursor: "pointer",
fontSize: "15px",
marginBottom: "18px",
width: "100%",
}}
onClick={() => setCrossHairsEnabled((prev) => !prev)}
>
{crossHairsEnabled ? "Disable" : "Enable"} Cross Reference Lines
</button>
<button
style={{
padding: "10px 18px",
background: referenceLinesEnabled ? "#1976d2" : "#333",
color: "#fff",
border: "none",
borderRadius: "4px",
fontWeight: "bold",
cursor: "pointer",
fontSize: "15px",
marginBottom: "18px",
width: "100%",
}}
onClick={() => setReferenceLinesEnabled((prev) => !prev)}
>
{referenceLinesEnabled ? "Disable" : "Enable"} Reference Lines
</button>
<button
onClick={sortViewportImageIdsBySliceLocation}
style={{ margin: 8, padding: 8 }}
>
Sort All Viewports by Slice Location
</button>
<button
style={{
padding: "10px 18px",
background: altEnablesReferenceCursors ? "#1976d2" : "#333",
color: "#fff",
border: "none",
borderRadius: "4px",
fontWeight: "bold",
cursor: "pointer",
fontSize: "15px",
marginBottom: "18px",
width: "100%",
}}
onClick={() => setAltEnablesReferenceCursors((prev) => !prev)}
>
{altEnablesReferenceCursors
? "Disable Alt Key for Reference Cursors"
: "Enable Alt Key for Reference Cursors"}
</button>
{/* Add more menu items here if needed */}
</div>
</div>
)}
</div>
{/* Viewport grid menu at top center */}
<div
className="grid-menu-hover-container"
style={{
position: "absolute",
top: 0,
left: "50%",
transform: "translateX(-50%)",
zIndex: 100,
display: "flex",
flexDirection: "column",
alignItems: "center",
width: "auto",
height: "48px",
pointerEvents: "none", // Only the hit area and button will be interactive
}}
>
{/* 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}
/>
<button
className="grid-menu-btn"
style={{
position: "relative",
top: gridBtnActive ? "0" : "-22px",
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",
marginRight: "8px",
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
pointerEvents: gridBtnActive ? "auto" : "none", // Only clickable when active
zIndex: 2,
display: gridBtnActive ? "block" : "none",
}}
onMouseLeave={() => setGridBtnActive(false)}
onBlur={() => setGridBtnActive(false)}
onClick={() => setViewportMenuOpen(open => !open)}
tabIndex={0}
>
Grid: {viewportGrid.rows}x{viewportGrid.cols}
</button>
{viewportMenuOpen && (
<div
style={{
position: "absolute",
top: "110%",
left: "50%",
transform: "translateX(-50%)",
background: "#222",
color: "#fff",
borderRadius: "6px",
boxShadow: "0 2px 12px rgba(0,0,0,0.4)",
padding: "12px",
display: "grid",
gridTemplateColumns: "repeat(3, 40px)",
gap: "8px",
zIndex: 200,
pointerEvents: "auto",
}}
>
{gridOptions.map(opt => (
<button
key={`${opt.rows}x${opt.cols}`}
style={{
width: "40px",
height: "40px",
background: (opt.rows === viewportGrid.rows && opt.cols === viewportGrid.cols) ? "#444" : "#333",
color: "#fff",
border: "none",
borderRadius: "4px",
fontWeight: "bold",
cursor: "pointer",
fontSize: "15px",
}}
onClick={() => {
saveVisibleViewportState();
setViewportGrid({ rows: opt.rows, cols: opt.cols })
setViewportMenuOpen(false)
}}
>
{opt.rows}x{opt.cols}
</button>
))}
</div>
)}
</div>
{/* Viewport grid (inside main div) */}
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
display: "grid",
margin: 3,
gridTemplateRows: `repeat(${viewportGrid.rows}, 1fr)`,
gridTemplateColumns: `repeat(${viewportGrid.cols}, 1fr)`,
gap: "5px", // Increase gap for better visibility
zIndex: 1,
}}
>
{viewports}
</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 => setMouseToolBindings(prev => ({ ...prev, [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>
))}
{/* Expandable advanced mouse bindings */}
<div style={{ margin: "12px 0" }}>
<button
style={{
background: "#333",
color: "#fff",
border: "none",
borderRadius: "4px",
padding: "4px 12px",
cursor: "pointer",
fontSize: "14px",
marginBottom: "8px",
}}
onClick={() => setShowAdvancedMouseBindings(v => !v)}
>
{showAdvancedMouseBindings ? "Hide" : "Show"} Advanced Mouse Bindings
</button>
{showAdvancedMouseBindings && (
<div style={{ marginTop: 8 }}>
{ALL_MOUSE_BINDINGS.slice(3).map((btn) => (
<div key={btn.value} style={{ marginBottom: 10 }}>
<label style={{ marginRight: 8 }}>{btn.label}:</label>
<select
value={mouseToolBindings[btn.value] || ""}
onChange={e => setMouseToolBindings(prev => ({ ...prev, [btn.value]: e.target.value }))}
style={{
background: "#333",
color: "#fff",
border: "1px solid #444",
borderRadius: "4px",
padding: "4px 8px",
fontSize: "14px",
}}
>
<option value="">(None)</option>
{TOOL_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
))}
</div>
)}
</div>
{/* Ctrl + Mouse Button Tool Bindings */}
<div style={{ margin: "18px 0 8px 0", fontWeight: "bold" }}>Ctrl + Mouse Button Tool Bindings</div>
{MOUSE_BUTTONS.map((btn) => (
<div key={btn.value + "_ctrl"} style={{ marginBottom: 10 }}>
<label style={{ marginRight: 8 }}>{btn.label} + Ctrl:</label>
<select
value={ctrlMouseToolBindings[btn.value] || ""}
onChange={e => setCtrlMouseToolBindings(prev => ({ ...prev, [btn.value]: e.target.value }))}
style={{
background: "#333",
color: "#fff",
border: "1px solid #444",
borderRadius: "4px",
padding: "4px 8px",
fontSize: "14px",
}}
>
<option value="">(None)</option>
{TOOL_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
))}
{/* Separate button for advanced Ctrl+Mouse bindings */}
<div style={{ margin: "12px 0" }}>
<button
style={{
background: "#333",
color: "#fff",
border: "none",
borderRadius: "4px",
padding: "4px 12px",
cursor: "pointer",
fontSize: "14px",
marginBottom: "8px",
}}
onClick={() => setShowAdvancedCtrlMouseBindings(v => !v)}
>
{showAdvancedCtrlMouseBindings ? "Hide" : "Show"} Advanced Ctrl + Mouse Bindings
</button>
{showAdvancedCtrlMouseBindings && (
<div style={{ marginTop: 8 }}>
{ALL_MOUSE_BINDINGS.slice(3).map((btn) => (
<div key={btn.value + "_ctrl"} style={{ marginBottom: 10 }}>
<label style={{ marginRight: 8 }}>{btn.label} + Ctrl:</label>
<select
value={ctrlMouseToolBindings[btn.value] || ""}
onChange={e => setCtrlMouseToolBindings(prev => ({ ...prev, [btn.value]: e.target.value }))}
style={{
background: "#333",
color: "#fff",
border: "1px solid #444",
borderRadius: "4px",
padding: "4px 8px",
fontSize: "14px",
}}
>
<option value="">(None)</option>
{TOOL_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
))}
</div>
)}
</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>
)
}
{
metaModalOpen && (
<div
style={{
position: "fixed",
top: 0,
left: 0,
width: "100vw",
height: "100vh",
background: "rgba(0,0,0,0.5)",
zIndex: 2000,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={() => setMetaModalOpen(false)}
>
<div
style={{
background: "#222",
color: "#fff",
borderRadius: "8px",
minWidth: "320px",
maxWidth: "80vw",
maxHeight: "80vh",
padding: "24px",
boxShadow: "0 4px 24px rgba(0,0,0,0.4)",
position: "relative",
overflow: "auto",
}}
onClick={e => e.stopPropagation()}
>
<div style={{ fontSize: "20px", marginBottom: "16px" }}>
DICOM Metadata
</div>
{metaModalContent}
<button
style={{
position: "absolute",
top: 12,
right: 12,
background: "transparent",
color: "#fff",
border: "none",
fontSize: "20px",
cursor: "pointer",
}}
onClick={() => setMetaModalOpen(false)}
aria-label="Close"
>
×
</button>
</div>
</div>
)
}
{/* ...rest of your UI... */}
</div >
)
}
export default App
function setupTools(MAIN_TOOL_GROUP_ID) {
console.log('Setting up tools...', MAIN_TOOL_GROUP_ID);
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)
cornerstoneTools.addTool(ReferenceCursors)
cornerstoneTools.addTool(CrosshairsTool)
cornerstoneTools.addTool(ReferenceLinesTool);
cornerstoneTools.addTool(NoLabelArrowAnnotateTool);
let mainToolGroup = ToolGroupManager.getToolGroup(MAIN_TOOL_GROUP_ID);
if (!mainToolGroup) {
mainToolGroup = ToolGroupManager.createToolGroup(MAIN_TOOL_GROUP_ID);
// Add all tools you need for both stack and volume
mainToolGroup.addTool(WindowLevelTool.toolName);
mainToolGroup.addTool(PanTool.toolName);
mainToolGroup.addTool(ZoomTool.toolName);
mainToolGroup.addTool(StackScrollTool.toolName, { loop: false });
mainToolGroup.addTool(PlanarRotateTool.toolName);
mainToolGroup.addTool(LengthTool.toolName);
mainToolGroup.addTool(NoLabelArrowAnnotateTool.toolName, { getTextCallback: () => '' });
mainToolGroup.addTool(ArrowAnnotateTool.toolName);
mainToolGroup.addTool(ProbeTool.toolName, { statsCalculator: () => null });
mainToolGroup.addTool(RectangleROITool.toolName, { calculateStats: false, getTextCallback: () => '' });
mainToolGroup.addTool(EllipticalROITool.toolName, { getTextCallback: () => '' });
mainToolGroup.addTool(PlanarFreehandROITool.toolName, { calculateStats: false });
mainToolGroup.addTool(PlanarFreehandContourSegmentationTool.toolName, {});
mainToolGroup.addTool(SculptorTool.toolName, {});
//mainToolGroup.addTool(CrosshairsTool.toolName);
mainToolGroup.addTool(ReferenceLinesTool.toolName);
mainToolGroup.addTool(ReferenceCursors.toolName);
}
return mainToolGroup;
}
// Add this helper to find the next/previous annotation index for the current viewport
function findNextAnnotationIdx(currentIdx: number, imageIds: string[], annotations: any[], direction: 1 | -1) {
if (!annotations.length || !imageIds.length) return null;
// Find all annotation indices in this stack
const annIndices = annotations
.map(ann => imageIds.findIndex(id => id === ann.metadata.referencedImageId))
.filter(idx => idx !== -1)
.sort((a, b) => a - b);
if (!annIndices.length) return null;
// Find the next/prev annotation index
if (direction === 1) {
// Next
for (let idx of annIndices) {
if (idx > currentIdx) return idx;
}
return annIndices[0]; // wrap around
} else {
// Previous
for (let i = annIndices.length - 1; i >= 0; i--) {
if (annIndices[i] < currentIdx) return annIndices[i];
}
return annIndices[annIndices.length - 1]; // wrap around
}
}