2997 lines
108 KiB
TypeScript
2997 lines
108 KiB
TypeScript
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} WW: {imageInfos[i]?.windowWidth}
|
||
</div>
|
||
</>
|
||
) : (
|
||
// Volume mode: do not show image position
|
||
<div>
|
||
WC: {imageInfos[i]?.windowCenter} 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
|
||
}
|
||
} |