3807 lines
140 KiB
TypeScript
3807 lines
140 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 * as cornerstone3d from '@cornerstonejs/core';
|
||
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;
|
||
cornerstone3d: typeof cornerstone3d;
|
||
cornerstoneDICOMImageLoader: typeof cornerstoneDICOMImageLoader;
|
||
|
||
}
|
||
}
|
||
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;
|
||
name?: string;
|
||
caseId?: string;
|
||
studyId?: 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;
|
||
window.cornerstone3d = cornerstone3d
|
||
window.cornerstoneDICOMImageLoader = cornerstoneDICOMImageLoader;
|
||
|
||
|
||
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<Array<string[] | { imageIds: string[], name?: string, caseId?: string }>>;
|
||
autoCacheStack?: boolean; // <-- new prop
|
||
annotationJson?: string;
|
||
viewerState?: string;
|
||
// ...other props if needed
|
||
};
|
||
|
||
// App definintion
|
||
function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewerState, ...props }: AppProps) {
|
||
const appRootRef = useRef<HTMLDivElement>(null);
|
||
const elementRef = useRef<HTMLDivElement>(null)
|
||
const running = useRef(false)
|
||
|
||
|
||
const MOUSE_SETTINGS_KEY = "dv3d_mouseToolBindings";
|
||
const CTRL_MOUSE_SETTINGS_KEY = "dv3d_ctrlMouseToolBindings";
|
||
|
||
// 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 [annotationNavEnabled, setAnnotationNavEnabled] = useState(true);
|
||
|
||
const [availableStacks, setAvailableStacks] = useState<StackEntry[]>([]);
|
||
const [activeViewport, setActiveViewport] = useState<number | null>(0);
|
||
// After your useState for viewportImageIds:
|
||
const [viewportImageIds, _setViewportImageIds] = useState<string[][]>(
|
||
() => Array(MAX_VIEWPORTS).fill([])
|
||
);
|
||
|
||
|
||
|
||
// Replace all setViewportImageIds calls with this function:
|
||
const setViewportImageIds = (value: React.SetStateAction<string[][]>) => {
|
||
// Log every call
|
||
if (typeof value === "function") {
|
||
// If called with a function, log the result
|
||
_setViewportImageIds(prev => {
|
||
const next = (value as (prev: string[][]) => string[][])(prev);
|
||
console.log("setViewportImageIds (fn):", next);
|
||
return next;
|
||
});
|
||
} else {
|
||
console.log("setViewportImageIds:", value);
|
||
_setViewportImageIds(value);
|
||
}
|
||
};
|
||
const [selectedStackIdx, setSelectedStackIdx] = useState<number[]>(
|
||
() => Array(MAX_VIEWPORTS).fill(0)
|
||
);
|
||
|
||
const [stackOrderModified, setStackOrderModified] = useState(false);
|
||
const [loadingState, setLoadingState] = useState<null | "annotations" | "viewerState">(null);
|
||
// Load annotations and viewer state from props if provided
|
||
useEffect(() => {
|
||
let didCancel = false;
|
||
// Wait until availableStacks and viewportImageIds are loaded (i.e., app is ready)
|
||
if (annotationJson || viewerState) {
|
||
setLoadingState(annotationJson ? "annotations" : "viewerState");
|
||
// Use a short delay to ensure Cornerstone is initialized and viewports are ready
|
||
const timeout = setTimeout(() => {
|
||
if (didCancel) return;
|
||
const apiKey = container_id || "default";
|
||
if (annotationJson) {
|
||
try {
|
||
const fn = window[`importAnnotations_${apiKey}`] || window.importAnnotations;
|
||
if (typeof fn === "function") {
|
||
fn(annotationJson);
|
||
}
|
||
} catch (e) {
|
||
console.error("Failed to import annotations from prop:", e);
|
||
}
|
||
}
|
||
if (viewerState) {
|
||
try {
|
||
const fn = window[`importViewerState_${apiKey}`] || window.importViewerState;
|
||
if (typeof fn === "function") {
|
||
fn(viewerState);
|
||
}
|
||
} catch (e) {
|
||
console.error("Failed to import viewer state from prop:", e);
|
||
}
|
||
}
|
||
setLoadingState(null);
|
||
}, 2000); // 500ms delay, adjust as needed
|
||
|
||
return () => {
|
||
didCancel = true;
|
||
clearTimeout(timeout);
|
||
setLoadingState(null);
|
||
};
|
||
}
|
||
}, [annotationJson, viewerState, container_id]);
|
||
|
||
// Load available stacks on mount
|
||
useEffect(() => {
|
||
let mounted = true;
|
||
|
||
imageStacks()
|
||
.then(async (descriptors) => {
|
||
if (!mounted) return;
|
||
const stacksRaw = Array.isArray(descriptors) ? descriptors : [];
|
||
const normalized: Array<StackEntry> = await Promise.all(
|
||
stacksRaw.map(async (d) => {
|
||
if (Array.isArray(d)) {
|
||
return { imageIds: d as string[] };
|
||
}
|
||
const imageIds = Array.isArray((d as any).imageIds) ? (d as any).imageIds : (Array.isArray((d as any).i) ? (d as any).i : []);
|
||
// try to preserve old studyInstanceUID extraction if needed (best-effort)
|
||
let studyInstanceUID: string | undefined = (d as any).studyInstanceUID;
|
||
// optional: attempt to fetch first file and extract StudyInstanceUID if dicomParser available
|
||
if (!studyInstanceUID && imageIds.length > 0) {
|
||
try {
|
||
const first = imageIds[0];
|
||
if (typeof fetch === 'function' && typeof (window as any).dicomParser !== 'undefined' && first.startsWith('wadouri:')) {
|
||
const url = first.slice(7);
|
||
const resp = await fetch(url);
|
||
const buffer = await resp.arrayBuffer();
|
||
const ds = (window as any).dicomParser.parseDicom(new Uint8Array(buffer));
|
||
studyInstanceUID = ds.string('x0020000d');
|
||
}
|
||
} catch (e) {
|
||
// ignore UID extraction failures
|
||
}
|
||
}
|
||
return {
|
||
imageIds,
|
||
studyInstanceUID,
|
||
studyId: (d as any).studyId,
|
||
name: (d as any).name,
|
||
caseId: (d as any).caseId,
|
||
};
|
||
})
|
||
);
|
||
|
||
setAvailableStacks(normalized);
|
||
|
||
// Seed viewports with first stack if present (preserve old behavior)
|
||
if (normalized[0]) {
|
||
await setLoadedImageIdsAndCacheMeta(normalized[0].imageIds);
|
||
setViewportImageIds(Array(MAX_VIEWPORTS).fill(normalized[0].imageIds || []));
|
||
} else {
|
||
setViewportImageIds(Array(MAX_VIEWPORTS).fill([]));
|
||
}
|
||
})
|
||
.catch(err => {
|
||
console.error("Failed to load imageStacks:", err);
|
||
setAvailableStacks([]);
|
||
setViewportImageIds(Array(MAX_VIEWPORTS).fill([]));
|
||
});
|
||
|
||
return () => { mounted = false; };
|
||
}, []);
|
||
|
||
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;
|
||
});
|
||
setStackOrderModified(true);
|
||
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:")) {
|
||
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(false);
|
||
|
||
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,
|
||
slicePosition: string,
|
||
}>>(() =>
|
||
Array(viewportGrid.rows * viewportGrid.cols).fill({
|
||
index: 0,
|
||
total: 0,
|
||
windowCenter: "",
|
||
windowWidth: "",
|
||
slicePosition: 0,
|
||
})
|
||
);
|
||
|
||
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: NoLabelArrowAnnotateTool.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);
|
||
};
|
||
|
||
|
||
// --- Load from localStorage on mount ---
|
||
useEffect(() => {
|
||
try {
|
||
const mouse = localStorage.getItem(MOUSE_SETTINGS_KEY);
|
||
const ctrl = localStorage.getItem(CTRL_MOUSE_SETTINGS_KEY);
|
||
if (mouse) setMouseToolBindings(JSON.parse(mouse));
|
||
if (ctrl) setCtrlMouseToolBindings(JSON.parse(ctrl));
|
||
} catch (e) {
|
||
// Ignore parse errors
|
||
}
|
||
}, []);
|
||
|
||
// --- Save to localStorage when changed ---
|
||
useEffect(() => {
|
||
try {
|
||
localStorage.setItem(MOUSE_SETTINGS_KEY, JSON.stringify(mouseToolBindings));
|
||
} catch (e) { }
|
||
}, [mouseToolBindings]);
|
||
|
||
useEffect(() => {
|
||
try {
|
||
localStorage.setItem(CTRL_MOUSE_SETTINGS_KEY, JSON.stringify(ctrlMouseToolBindings));
|
||
} catch (e) { }
|
||
}, [ctrlMouseToolBindings]);
|
||
|
||
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;
|
||
|
||
let currentIndex = 0;
|
||
let total = 0;
|
||
let windowCenter = "";
|
||
let windowWidth = "";
|
||
let slicePosition = "";
|
||
|
||
if (viewportModes[viewportIdx] === "stack") {
|
||
currentIndex = typeof viewport.getCurrentImageIdIndex === "function"
|
||
? viewport.getCurrentImageIdIndex()
|
||
: 0;
|
||
const imageIds = typeof viewport.getImageIds === "function"
|
||
? viewport.getImageIds()
|
||
: [];
|
||
total = imageIds.length;
|
||
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);
|
||
}
|
||
}
|
||
} else if (viewportModes[viewportIdx] === "volume") {
|
||
// For volume, get slice index and number of slices if available
|
||
if (typeof viewport.getSliceIndex === "function") {
|
||
currentIndex = viewport.getSliceIndex();
|
||
}
|
||
if (typeof viewport.getNumberOfSlices === "function") {
|
||
total = viewport.getNumberOfSlices();
|
||
}
|
||
// Get VOI from getProperties (may need to pass volumeId if multiple volumes)
|
||
if (typeof viewport.getProperties === "function") {
|
||
let props;
|
||
if (viewport.volumeIds && viewport.volumeIds.size > 0) {
|
||
// If multiple volumes, get the first one
|
||
const volumeId = Array.from(viewport.volumeIds)[0];
|
||
props = viewport.getProperties(volumeId);
|
||
} else {
|
||
props = viewport.getProperties();
|
||
}
|
||
if (props && props.voiRange) {
|
||
const { lower, upper } = props.voiRange;
|
||
windowCenter = ((upper + lower) / 2).toFixed(0);
|
||
windowWidth = (upper - lower).toFixed(0);
|
||
}
|
||
}
|
||
// Try to get slice position in mm (distance along viewPlaneNormal from origin)
|
||
if (typeof viewport.getSlicePlaneCoordinates === "function") {
|
||
const coords = viewport.getSlicePlaneCoordinates();
|
||
if (coords && typeof coords.position === "number") {
|
||
slicePosition = coords.position.toFixed(2) + " mm";
|
||
}
|
||
}
|
||
}
|
||
|
||
setImageInfos(prev => {
|
||
const next = [...prev];
|
||
next[viewportIdx] = {
|
||
index: currentIndex + 1,
|
||
total,
|
||
windowCenter,
|
||
windowWidth,
|
||
slicePosition, // add this field
|
||
};
|
||
return next;
|
||
});
|
||
}, [viewportModes]);
|
||
|
||
|
||
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(
|
||
);
|
||
dicomImageLoaderInit();
|
||
}
|
||
|
||
|
||
// 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 && imageIds.length > 0) {
|
||
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();
|
||
|
||
// 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);
|
||
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 preventExtraButtonDefault = (e: MouseEvent) => {
|
||
// 4th button: button === 3, 5th button: button === 4
|
||
if (e.button === 3 || e.button === 4) {
|
||
e.preventDefault();
|
||
//e.stopPropagation();
|
||
return false;
|
||
}
|
||
};
|
||
window.addEventListener("mousedown", preventExtraButtonDefault, true);
|
||
window.addEventListener("mouseup", preventExtraButtonDefault, true);
|
||
window.addEventListener("auxclick", preventExtraButtonDefault, true);
|
||
return () => {
|
||
window.removeEventListener("mousedown", preventExtraButtonDefault, true);
|
||
window.removeEventListener("mouseup", preventExtraButtonDefault, true);
|
||
window.removeEventListener("auxclick", preventExtraButtonDefault, true);
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const el = appRootRef.current;
|
||
if (!el) return;
|
||
const handleContextMenu = (e: MouseEvent) => {
|
||
e.preventDefault();
|
||
};
|
||
el.addEventListener("contextmenu", handleContextMenu);
|
||
return () => {
|
||
el.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 => {
|
||
let modified = false;
|
||
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)) {
|
||
modified = true;
|
||
return sortedIds;
|
||
}
|
||
return imageIds;
|
||
});
|
||
if (modified) setStackOrderModified(true);
|
||
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)
|
||
}
|
||
}
|
||
|
||
|
||
// Inside App component
|
||
function resetViewer() {
|
||
const renderingEngine = renderingEngineRef.current;
|
||
if (renderingEngine) {
|
||
for (let i = 0; i < MAX_VIEWPORTS; i++) {
|
||
const viewport = renderingEngine.getViewport(`CT_${i}`);
|
||
if (viewport) {
|
||
viewport.resetCamera?.();
|
||
viewport.resetProperties?.();
|
||
viewport.render?.();
|
||
}
|
||
}
|
||
}
|
||
if (cornerstoneTools?.annotation?.state?.removeAllAnnotations) {
|
||
cornerstoneTools.annotation.state.removeAllAnnotations();
|
||
}
|
||
}
|
||
|
||
function truncateStack(lower: number, upper: number) {
|
||
// Only allow when 1 viewport and 1 stack is loaded
|
||
console.log("Truncating stack from", lower, "to", upper);
|
||
console.log(stackOrderModified)
|
||
if (
|
||
viewportGrid.rows !== 1 ||
|
||
viewportGrid.cols !== 1 ||
|
||
availableStacks.length !== 1 ||
|
||
!viewportImageIds[0] ||
|
||
viewportImageIds[0].length === 0 ||
|
||
stackOrderModified
|
||
) {
|
||
alert("Truncation is only allowed when a single stack is loaded in a single viewport (and the order has not been modified in the viewer).");
|
||
return false;
|
||
}
|
||
|
||
const stack = availableStacks[0];
|
||
const imageIds = stack.imageIds;
|
||
|
||
// Validate bounds
|
||
if (
|
||
lower < 0 ||
|
||
upper >= imageIds.length ||
|
||
lower >= upper
|
||
) {
|
||
alert("Invalid truncation bounds.");
|
||
return false;
|
||
}
|
||
|
||
// Truncate the stack and update state
|
||
const truncatedImageIds = imageIds.slice(lower, upper + 1);
|
||
setAvailableStacks([{ ...stack, imageIds: truncatedImageIds }]);
|
||
setViewportImageIds([truncatedImageIds]);
|
||
setSelectedStackIdx([0]);
|
||
// Optionally reset scroll position, overlays, etc.
|
||
|
||
// Optionally, force a re-render or reset the viewport
|
||
const renderingEngine = renderingEngineRef.current;
|
||
if (renderingEngine) {
|
||
const viewport = renderingEngine.getViewport("CT_0");
|
||
if (viewport && typeof viewport.setStack === "function") {
|
||
viewport.setStack(truncatedImageIds);
|
||
viewport.setImageIdIndex?.(0);
|
||
viewport.render?.();
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
function getCurrentStackPosition(viewportIdx: number = 0): number | null {
|
||
const renderingEngine = renderingEngineRef.current;
|
||
if (!renderingEngine) return null;
|
||
const viewport = renderingEngine.getViewport(`CT_${viewportIdx}`);
|
||
if (!viewport || typeof viewport.getCurrentImageIdIndex !== "function") return null;
|
||
return viewport.getCurrentImageIdIndex();
|
||
}
|
||
|
||
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[`getViewport_${apiKey}`] = (idx: number) => {
|
||
const renderingEngine = renderingEngineRef.current;
|
||
if (!renderingEngine) return undefined;
|
||
return renderingEngine.getViewport(`CT_${idx}`);
|
||
};
|
||
window[`getAllViewports_${apiKey}`] = () => {
|
||
const renderingEngine = renderingEngineRef.current;
|
||
if (!renderingEngine) return [];
|
||
// Return all currently active viewports as an array
|
||
return Array.from({ length: MAX_VIEWPORTS }, (_, i) => renderingEngine.getViewport(`CT_${i}`)).filter(Boolean);
|
||
};
|
||
|
||
window[`jumpToSliceByImageId_${apiKey}`] = jumpToSliceByImageId;
|
||
window[`resetViewer_${apiKey}`] = resetViewer;
|
||
window[`truncateStack_${apiKey}`] = truncateStack;
|
||
window[`getCurrentStackPosition_${apiKey}`] = getCurrentStackPosition;
|
||
|
||
window[`loadAdditionalStack_${apiKey}`] = async (imageIds: string[], studyInstanceUID?: string) => {
|
||
if (!Array.isArray(imageIds) || imageIds.length === 0) return;
|
||
// Optionally extract StudyInstanceUID if not provided
|
||
let uid = studyInstanceUID;
|
||
if (!uid && imageIds[0]?.startsWith("wadouri:")) {
|
||
try {
|
||
const url = imageIds[0].slice(8);
|
||
const response = await fetch(url);
|
||
const arrayBuffer = await response.arrayBuffer();
|
||
const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
|
||
uid = dataSet.string('x0020000d');
|
||
} catch (e) {
|
||
// Ignore errors, fallback to undefined
|
||
}
|
||
}
|
||
setAvailableStacks(prev => [...prev, { imageIds, studyInstanceUID: uid }]);
|
||
// Optionally, auto-populate a viewport with the new stack if there is an empty viewport
|
||
setViewportImageIds(prev => {
|
||
const next = [...prev];
|
||
const emptyIdx = next.findIndex(ids => !ids || ids.length === 0);
|
||
if (emptyIdx !== -1) {
|
||
next[emptyIdx] = imageIds;
|
||
}
|
||
return next;
|
||
});
|
||
setLoadedImageIdsAndCacheMeta(imageIds);
|
||
};
|
||
|
||
// 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
|
||
}));
|
||
console.log("Exporting viewer state with", numActive, "active viewports");
|
||
console.log("modes", modes);
|
||
|
||
// Only keep minimal properties
|
||
const renderingEngine = renderingEngineRef.current;
|
||
const props: any[] = [];
|
||
const stackPos: number[] = [];
|
||
const cam: any[] = [];
|
||
const orientations: (string | null)[] = [];
|
||
const volumeSliceIndices: (number | null)[] = [];
|
||
for (let i = 0; i < numActive; i++) {
|
||
let p = null, s = null, c = null, o = null, vIdx = null;
|
||
console.log("Exporting state for viewport", i);
|
||
if (renderingEngine) {
|
||
console.log("Rendering engine found, exporting state for viewport", i);
|
||
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();
|
||
}
|
||
console.log("viewport", viewportId, "properties", p);
|
||
// --- Volume orientation and slice index ---
|
||
if (modes[i] === "volume") {
|
||
console.log("Getting orientation for viewport", i);
|
||
console.log("viewport.viewportProperties", viewport.viewportProperties);
|
||
o = viewport.viewportProperties?.orientation || null;
|
||
if (typeof viewport.getSliceIndex === "function") {
|
||
vIdx = viewport.getSliceIndex();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
props.push(p);
|
||
stackPos.push(s);
|
||
cam.push(c);
|
||
orientations.push(o);
|
||
volumeSliceIndices.push(vIdx);
|
||
}
|
||
|
||
// Use short keys for compactness
|
||
const state = {
|
||
grid: viewportGrid,
|
||
modes,
|
||
stacks,
|
||
stackIdx,
|
||
stackPos,
|
||
props,
|
||
cam,
|
||
orientations, // <-- add this
|
||
volumeSliceIndices, // <-- add this
|
||
};
|
||
return JSON.stringify(state);
|
||
};
|
||
|
||
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);
|
||
}
|
||
|
||
// --- Delay the start of tryRestore until after the next paint ---
|
||
const maxAttempts = 30;
|
||
let attempts = 0;
|
||
|
||
// eslint-disable-next-line no-inner-declarations
|
||
async function tryRestore() {
|
||
const renderingEngine = renderingEngineRef.current;
|
||
if (!renderingEngine) {
|
||
restoringStateRef.current = false;
|
||
return;
|
||
}
|
||
const numActive = state.grid?.rows * state.grid?.cols || 1;
|
||
let allReady = true;
|
||
for (let i = 0; i < numActive; i++) {
|
||
console.log("Checking viewport", i);
|
||
const viewport = renderingEngine.getViewport(`CT_${i}`);
|
||
if (!viewport) { allReady = false; break; }
|
||
const stackIdx = state.stackIdx?.[i];
|
||
const stackObj = state.stacks?.[stackIdx];
|
||
const importedImageIds = stackObj?.i || [];
|
||
if (!importedImageIds.length) continue;
|
||
try {
|
||
const viewportImageIds = viewport.getImageIds?.() || [];
|
||
if (
|
||
viewportImageIds.length !== importedImageIds.length ||
|
||
!viewportImageIds.every((id, idx) => id === importedImageIds[idx])
|
||
) {
|
||
console.warn(
|
||
`Viewport ${i}: importedImageIds do not match viewportImageIds`,
|
||
{ importedImageIds, viewportImageIds }
|
||
);
|
||
allReady = false;
|
||
break;
|
||
}
|
||
} catch (e) {
|
||
allReady = false;
|
||
|
||
}
|
||
}
|
||
if (!allReady && attempts < maxAttempts) {
|
||
attempts++;
|
||
setTimeout(() => { tryRestore(); }, 500);
|
||
return;
|
||
}
|
||
|
||
// Now safe to restore state for ALL viewports
|
||
for (let i = 0; i < numActive; i++) {
|
||
console.log("Restoring state for viewport", i);
|
||
const viewport = renderingEngine.getViewport(`CT_${i}`);
|
||
if (!viewport) continue;
|
||
const stackIdx = state.stackIdx?.[i];
|
||
const stackObj = state.stacks?.[stackIdx];
|
||
const importedImageIds = stackObj?.i || [];
|
||
if (!importedImageIds.length) continue;
|
||
|
||
console.log("mode", state.modes?.[i]);
|
||
|
||
if (state.modes?.[i] === "volume") {
|
||
// --- Restore volume ---
|
||
console.log("Restoring volume for viewport", i, importedImageIds);
|
||
const volumeId = `myVolumeId_${i}`;
|
||
const volume = await volumeLoader.createAndCacheVolume(volumeId, { imageIds: importedImageIds });
|
||
volume.load();
|
||
viewport.setVolumes([{ volumeId }]);
|
||
// --- Restore orientation if present ---
|
||
if (state.orientations && state.orientations[i] && typeof viewport.setOrientation === "function") {
|
||
try {
|
||
viewport.setOrientation(state.orientations[i]);
|
||
viewport.render();
|
||
} catch (e) {
|
||
console.warn("Failed to restore orientation for viewport", i, e);
|
||
}
|
||
}
|
||
if (
|
||
state.volumeSliceIndices
|
||
) {
|
||
console.log("Restoring volume slice index for viewport", i, state.volumeSliceIndices[i]);
|
||
setTimeout(() => {
|
||
csUtilities.jumpToSlice(viewport.element, { imageIndex: state.volumeSliceIndices[i] });
|
||
}, 100);
|
||
}
|
||
|
||
viewport.render();
|
||
} else {
|
||
// --- Restore stack ---
|
||
if (viewport.setStack) {
|
||
viewport.setStack(importedImageIds);
|
||
viewport.render();
|
||
}
|
||
// 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 (defensive colormap check)
|
||
if (
|
||
state.props &&
|
||
state.props[i] &&
|
||
typeof viewport.setProperties === "function"
|
||
) {
|
||
console.log("Restoring properties for viewport", i, state.props[i]);
|
||
const propsToRestore = { ...state.props[i] };
|
||
if (
|
||
propsToRestore.colormap !== undefined &&
|
||
propsToRestore.colormap !== null &&
|
||
typeof propsToRestore.colormap !== "string" &&
|
||
(typeof propsToRestore.colormap !== "object" || !("name" in propsToRestore.colormap))
|
||
) {
|
||
delete propsToRestore.colormap;
|
||
}
|
||
setTimeout(() => {
|
||
viewport.setProperties(propsToRestore);
|
||
viewport.render();
|
||
}, 100);
|
||
} else {
|
||
console.warn("No properties to restore for viewport", i);
|
||
}
|
||
|
||
// Restore camera
|
||
if (
|
||
state.cam &&
|
||
state.cam[i] &&
|
||
typeof viewport.setCamera === "function"
|
||
) {
|
||
console.log("Restoring camera for viewport", i, state.cam[i]);
|
||
viewport.setCamera(state.cam[i]);
|
||
viewport.render();
|
||
}
|
||
// --- Restore slice index if present ---
|
||
}
|
||
restoringStateRef.current = false;
|
||
}
|
||
|
||
// Delay start until after next paint
|
||
setTimeout(() => { tryRestore(); }, 100);
|
||
|
||
} 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;
|
||
window[`resetViewer_${apiKey}`] = undefined;
|
||
window[`truncateStack_${apiKey}`] = undefined;
|
||
window[`getCurrentStackPosition_${apiKey}`] = undefined;
|
||
window[`loadAdditionalStack_${apiKey}`] = undefined;
|
||
window[`getViewport_${apiKey}`] = undefined;
|
||
window[`getAllViewports_${apiKey}`] = undefined;
|
||
};
|
||
}, [
|
||
viewportGrid,
|
||
viewportModes,
|
||
selectedStackIdx,
|
||
availableStacks,
|
||
viewportImageIds,
|
||
setViewportGrid,
|
||
setViewportModes,
|
||
setSelectedStackIdx,
|
||
setAvailableStacks,
|
||
setViewportImageIds,
|
||
setAvailableStacks,
|
||
setViewportImageIds,
|
||
setLoadedImageIdsAndCacheMeta,
|
||
]);
|
||
|
||
|
||
useEffect(() => {
|
||
const el = appRootRef.current;
|
||
if (!el) return;
|
||
function handleArrowKeyNavigation(e: KeyboardEvent) {
|
||
console.log("Handling arrow key navigation", e.key);
|
||
console.log("activeViewport", activeViewport);
|
||
if (activeViewport === null) return;
|
||
const renderingEngine = renderingEngineRef.current;
|
||
if (!renderingEngine) return;
|
||
const viewport = renderingEngine.getViewport(`CT_${activeViewport}`);
|
||
if (!viewport) return;
|
||
console.log(viewport)
|
||
|
||
// --- Stack mode navigation ---
|
||
if (viewportModes[activeViewport] === "stack") {
|
||
if (
|
||
typeof viewport.getCurrentImageIdIndex !== "function" ||
|
||
typeof viewport.setImageIdIndex !== "function"
|
||
) return;
|
||
const imageIds = viewportImageIds[activeViewport];
|
||
if (!imageIds || imageIds.length === 0) return;
|
||
|
||
if (e.key === "ArrowUp" || e.key === "ArrowRight") {
|
||
csUtilities.scroll(viewport, { delta: -1 });
|
||
e.preventDefault();
|
||
} else if (e.key === "ArrowDown" || e.key === "ArrowLeft") {
|
||
csUtilities.scroll(viewport, { delta: 1 });
|
||
e.preventDefault();
|
||
}
|
||
}
|
||
|
||
// --- Volume mode navigation ---
|
||
if (viewportModes[activeViewport] === "volume") {
|
||
// Try to scroll through slices in the current orientation
|
||
if (
|
||
typeof viewport.getSliceIndex === "function" &&
|
||
typeof viewport.getNumberOfSlices === "function"
|
||
) {
|
||
const numSlices = viewport.getNumberOfSlices();
|
||
if (numSlices > 1) {
|
||
if (e.key === "ArrowUp" || e.key === "ArrowRight") {
|
||
csUtilities.scroll(viewport, { delta: -1 });
|
||
e.preventDefault();
|
||
} else if (e.key === "ArrowDown" || e.key === "ArrowLeft") {
|
||
csUtilities.scroll(viewport, { delta: 1 });
|
||
e.preventDefault();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
el.addEventListener("keydown", handleArrowKeyNavigation);
|
||
|
||
return () => {
|
||
el.removeEventListener("keydown", handleArrowKeyNavigation);
|
||
};
|
||
}, [activeViewport, viewportModes, viewportImageIds, updateOverlay]);
|
||
|
||
|
||
|
||
// 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 = [];
|
||
// 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%",
|
||
boxShadow: activeViewport === i
|
||
? "0 0 0 4px #1976d2, 0 0 16px 4px #1976d2cc"
|
||
: undefined,
|
||
zIndex: activeViewport === i ? 10 : 1,
|
||
}}
|
||
onClick={() => {
|
||
setActiveViewport(i);
|
||
setViewportMenuOpen(false);
|
||
}}
|
||
onDoubleClick={() => handleDoubleClick(i)}
|
||
onDragOver={e => {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = 'copy';
|
||
}}
|
||
onDrop={e => {
|
||
e.preventDefault();
|
||
const idxStr = e.dataTransfer.getData('text/stack-idx');
|
||
const stackIdx = parseInt(idxStr, 10);
|
||
if (!isNaN(stackIdx)) {
|
||
handleLoadStack(i, stackIdx);
|
||
}
|
||
}}
|
||
>
|
||
<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>
|
||
{viewportModes[i] === "stack" && annotationNavEnabled && (() => {
|
||
// 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>
|
||
);
|
||
})()}
|
||
{/* 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>
|
||
</>
|
||
) : (
|
||
<div>
|
||
Slice: {imageInfos[i]?.index} / {imageInfos[i]?.total}
|
||
{imageInfos[i]?.slicePosition && (
|
||
<span> ({imageInfos[i]?.slicePosition})</span>
|
||
)}
|
||
<br />
|
||
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,
|
||
pointerEvents: "auto", // <-- ensure pointer events are enabled
|
||
}}
|
||
>
|
||
<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,
|
||
pointerEvents: "auto", // <-- ensure pointer events are enabled
|
||
}}
|
||
onClick={e => e.stopPropagation()} // <-- prevent bubbling to viewport
|
||
>
|
||
<div
|
||
style={{
|
||
padding: "6px 12px",
|
||
fontWeight: "bold",
|
||
userSelect: "none",
|
||
}}
|
||
onClick={e => {
|
||
e.stopPropagation(); // <-- prevent bubbling to viewport
|
||
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,
|
||
pointerEvents: "auto", // <-- ensure pointer events are enabled
|
||
}}
|
||
onClick={e => e.stopPropagation()} // <-- prevent bubbling to viewport
|
||
>
|
||
{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={e => {
|
||
e.stopPropagation(); // <-- prevent bubbling to viewport
|
||
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>
|
||
)}
|
||
{viewportModes[i] === "volume" && (() => {
|
||
const renderingEngine = renderingEngineRef.current;
|
||
const viewport = renderingEngine?.getViewport(`CT_${i}`);
|
||
let numSlices = 0;
|
||
let currentSlice = 0;
|
||
if (viewport && typeof viewport.getNumberOfSlices === "function" && typeof viewport.getSliceIndex === "function") {
|
||
numSlices = viewport.getNumberOfSlices();
|
||
currentSlice = viewport.getSliceIndex();
|
||
}
|
||
if (numSlices > 1) {
|
||
return (
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
top: "10%",
|
||
right: 8,
|
||
width: "12px",
|
||
height: "80%",
|
||
maxHeight: "100%",
|
||
minHeight: 0,
|
||
zIndex: 30,
|
||
display: "flex",
|
||
alignItems: "flex-start",
|
||
justifyContent: "center",
|
||
pointerEvents: "auto", // Enable pointer events for click
|
||
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",
|
||
cursor: "pointer",
|
||
}}
|
||
onClick={e => {
|
||
// Calculate the clicked slice index
|
||
const bar = e.currentTarget as HTMLDivElement;
|
||
const rect = bar.getBoundingClientRect();
|
||
const y = e.clientY - rect.top;
|
||
const sliceIdx = Math.floor((y / rect.height) * numSlices);
|
||
const renderingEngine = renderingEngineRef.current;
|
||
const viewport = renderingEngine?.getViewport(`CT_${i}`);
|
||
if (
|
||
viewport
|
||
) {
|
||
csUtilities.jumpToSlice(viewport.element, { imageIndex: sliceIdx });
|
||
viewport.render();
|
||
updateOverlay(i, viewport);
|
||
}
|
||
}}
|
||
>
|
||
{Array.from({ length: numSlices }).map((_, idx) => {
|
||
const isCurrent = idx === currentSlice;
|
||
return (
|
||
<div
|
||
key={idx}
|
||
style={{
|
||
width: "100%",
|
||
height: `${100 / numSlices}%`,
|
||
minHeight: isCurrent ? 1 : 0,
|
||
background: isCurrent
|
||
? "linear-gradient(to right, #ffeb3b 60%, #ff9800 100%)"
|
||
: "#444",
|
||
opacity: isCurrent ? 0.95 : 0.4,
|
||
transition: "background 0.3s, opacity 0.3s",
|
||
borderBottom:
|
||
idx < numSlices - 1
|
||
? "1px solid #222"
|
||
: "none",
|
||
pointerEvents: "none", // Prevent child from blocking parent click
|
||
cursor: "pointer",
|
||
}}
|
||
title={
|
||
isCurrent
|
||
? `Current slice #${idx + 1}`
|
||
: `Go to slice #${idx + 1}`
|
||
}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
return null;
|
||
})()}
|
||
{isVisible && (availableStacks.length > 1 || !viewportImageIds[i] || viewportImageIds[i].length === 0) && (
|
||
<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" }}>
|
||
{/* Stack selector trigger button */}
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setOpenDropdownIdx(prev => (prev === i ? null : i));
|
||
}}
|
||
style={{
|
||
padding: "6px 12px",
|
||
background: "#333",
|
||
color: "#fff",
|
||
border: "1px solid #444",
|
||
borderRadius: "4px",
|
||
cursor: "pointer",
|
||
pointerEvents: "auto",
|
||
fontSize: 13,
|
||
}}
|
||
title="Choose stack"
|
||
>
|
||
{(() => {
|
||
// Determine label for currently selected stack in this viewport
|
||
const selIdx = (Array.isArray(selectedStackIdx) ? selectedStackIdx[i] : undefined);
|
||
const stackObj = typeof selIdx === "number" && availableStacks[selIdx] ? availableStacks[selIdx] : undefined;
|
||
const imageIds = stackObj && Array.isArray((stackObj as any).imageIds) ? (stackObj as any).imageIds : [];
|
||
const label = stackObj
|
||
? ((stackObj as any).name || ((stackObj as any).caseId ? `Case: ${(stackObj as any).caseId}` : undefined))
|
||
: undefined;
|
||
if (label) return label;
|
||
if (imageIds.length > 0) return `Stack ${(typeof selIdx === 'number') ? (selIdx + 1) : '1'}`;
|
||
// fallback: if viewport already has images, show count
|
||
const currentImages = viewportImageIds[i] || [];
|
||
if (currentImages.length > 0) return `Stack (${currentImages.length} images)`;
|
||
return "No stack";
|
||
})()}
|
||
<span style={{ marginLeft: 8, opacity: 0.8 }}>{openDropdownIdx === i ? "▲" : "▼"}</span>
|
||
</button>
|
||
|
||
{/* Dropdown */}
|
||
{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: 220,
|
||
maxHeight: 260,
|
||
overflowY: "auto",
|
||
overscrollBehavior: "contain",
|
||
scrollbarWidth: "thin",
|
||
padding: 6,
|
||
}}
|
||
role="listbox"
|
||
onMouseDown={e => e.stopPropagation()}
|
||
onWheel={e => { /* trap handled elsewhere */ }}
|
||
onBlur={e => {
|
||
setTimeout(() => {
|
||
if (!(e.currentTarget as HTMLElement).contains(document.activeElement)) {
|
||
setOpenDropdownIdx(null);
|
||
}
|
||
}, 0);
|
||
}}
|
||
>
|
||
{/* Group stacks by studyId (use 'Unspecified' for missing) */}
|
||
{(() => {
|
||
const groups: Record<string, { idx: number; stack: any }[]> = {};
|
||
availableStacks.forEach((stackObj, idx) => {
|
||
const studyKey = (stackObj as any).studyId || (stackObj as any).studyInstanceUID || "Unspecified";
|
||
if (!groups[studyKey]) groups[studyKey] = [];
|
||
groups[studyKey].push({ idx, stack: stackObj });
|
||
});
|
||
|
||
return Object.keys(groups).map(studyKey => (
|
||
<div key={studyKey} style={{ marginBottom: 6 }}>
|
||
<div style={{ fontSize: 12, color: "#aaa", padding: "4px 8px", fontWeight: 700 }}>
|
||
{studyKey === "Unspecified" ? "Unspecified study" : `Study: ${studyKey}`}
|
||
</div>
|
||
<div>
|
||
{groups[studyKey].map(({ idx, stack }) => {
|
||
const imageIds = Array.isArray((stack as any).imageIds) ? (stack as any).imageIds : [];
|
||
const label = (stack && ((stack as any).name || (stack as any).caseId))
|
||
? ((stack as any).name || `Case: ${(stack as any).caseId}`)
|
||
: `Stack ${idx + 1}`;
|
||
const count = imageIds.length;
|
||
const invalid = !Array.isArray(imageIds) || count === 0;
|
||
|
||
return (
|
||
<button
|
||
key={idx}
|
||
role="option"
|
||
aria-selected={false}
|
||
title={invalid ? `Invalid stack descriptor` : `${label} — ${count} image${count === 1 ? '' : 's'}${(stack as any)?.caseId ? ` — case ${(stack as any).caseId}` : ''}`}
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
alignItems: "flex-start",
|
||
width: "100%",
|
||
padding: "8px 12px",
|
||
background: "transparent",
|
||
color: "#fff",
|
||
border: "none",
|
||
textAlign: "left",
|
||
cursor: invalid ? "not-allowed" : "pointer",
|
||
}}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
if (invalid) {
|
||
console.warn(`Attempted to select invalid stack at index ${idx}`, stack);
|
||
return;
|
||
}
|
||
handleLoadStack(i, idx).catch(err => console.error("Failed to load stack:", err));
|
||
setOpenDropdownIdx(null);
|
||
}}
|
||
>
|
||
<div style={{ fontWeight: "600", fontSize: 13 }}>{label}</div>
|
||
<div style={{ fontSize: 12, opacity: 0.7 }}>
|
||
{count} image{count === 1 ? '' : 's'}{(stack as any)?.caseId ? ` • case ${(stack as any).caseId}` : ''}
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
));
|
||
})()}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
</div >
|
||
);
|
||
}
|
||
|
||
|
||
|
||
|
||
return (
|
||
<div
|
||
ref={appRootRef}
|
||
style={{
|
||
position: "relative",
|
||
inset: 0,
|
||
width: "100%",
|
||
height: "100%",
|
||
boxSizing: "border-box",
|
||
background: "#222",
|
||
padding: "12px",
|
||
overflow: "hidden",
|
||
}}>
|
||
{/* Loading overlay */}
|
||
{loadingState && (
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
zIndex: 3000,
|
||
top: 0,
|
||
left: 0,
|
||
width: "100%",
|
||
height: "100%",
|
||
background: "rgba(0,0,0,0.7)",
|
||
color: "#fff",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
fontSize: 28,
|
||
fontWeight: "bold",
|
||
letterSpacing: 1,
|
||
}}
|
||
>
|
||
{loadingState === "annotations"
|
||
? "Loading Annotations..."
|
||
: "Loading Viewer State..."}
|
||
</div>
|
||
)}
|
||
|
||
{/* Top bar stack browser */}
|
||
<div
|
||
style={{
|
||
position: "absolute",
|
||
top: 0,
|
||
left: 0,
|
||
width: "100%",
|
||
minHeight: 54,
|
||
background: "#23272bE6", // slightly transparent for layering
|
||
color: "#fff",
|
||
zIndex: 1001, // higher than most overlays but below modals
|
||
display: "flex",
|
||
alignItems: "center",
|
||
borderBottom: "2px solid #444",
|
||
padding: "0 16px",
|
||
gap: 18,
|
||
overflowX: "auto",
|
||
userSelect: "none",
|
||
pointerEvents: "auto",
|
||
}}
|
||
>
|
||
{/* Group stacks by studyId or studyInstanceUID or 'Unspecified' */}
|
||
{(() => {
|
||
const groups: Record<string, { idx: number; stack: StackEntry }[]> = {};
|
||
availableStacks.forEach((stackObj, idx) => {
|
||
const studyKey = stackObj.studyId || stackObj.studyInstanceUID || 'Unspecified';
|
||
if (!groups[studyKey]) groups[studyKey] = [];
|
||
groups[studyKey].push({ idx, stack: stackObj });
|
||
});
|
||
return Object.keys(groups).map(studyKey => (
|
||
<div key={studyKey} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<span style={{ color: '#aaa', fontSize: 13, fontWeight: 700, marginRight: 6 }}>
|
||
{studyKey === 'Unspecified' ? 'Unspecified study' : `Study: ${studyKey}`}
|
||
</span>
|
||
{groups[studyKey].map(({ idx, stack }) => {
|
||
const imageIds = Array.isArray(stack.imageIds) ? stack.imageIds : [];
|
||
const label = stack.name || (stack.caseId ? `Case: ${stack.caseId}` : `Stack ${idx + 1}`);
|
||
const count = imageIds.length;
|
||
const invalid = !Array.isArray(imageIds) || count === 0;
|
||
// Highlight if this stack is selected in any viewport
|
||
const isSelected = selectedStackIdx.includes(idx);
|
||
return (
|
||
<div
|
||
key={idx}
|
||
draggable={!invalid}
|
||
onDragStart={e => {
|
||
if (invalid) return;
|
||
e.dataTransfer.setData('text/stack-idx', String(idx));
|
||
e.dataTransfer.effectAllowed = 'copy';
|
||
}}
|
||
style={{
|
||
display: 'flex',
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
minWidth: 90,
|
||
maxWidth: 220,
|
||
padding: '7px 14px',
|
||
margin: '0 2px',
|
||
background: isSelected ? '#1976d2' : '#333',
|
||
color: isSelected ? '#fff' : '#eee',
|
||
border: isSelected ? '2px solid #90caf9' : 'none',
|
||
borderRadius: '4px',
|
||
fontWeight: isSelected ? 'bold' : 'normal',
|
||
fontSize: 15,
|
||
cursor: invalid ? 'not-allowed' : 'grab',
|
||
opacity: invalid ? 0.5 : 1,
|
||
transition: 'background 0.2s, border 0.2s',
|
||
boxShadow: isSelected ? '0 2px 8px #1976d2aa' : undefined,
|
||
}}
|
||
title={invalid ? 'Invalid stack descriptor' : `${label} — ${count} image${count === 1 ? '' : 's'}`}
|
||
>
|
||
<span>{label}</span>
|
||
<span style={{ fontSize: 12, opacity: 0.7, marginLeft: 8 }}>{count} img{count === 1 ? '' : 's'}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
));
|
||
})()}
|
||
</div>
|
||
|
||
<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>
|
||
<button
|
||
style={{
|
||
padding: "10px 18px",
|
||
background: annotationNavEnabled ? "#1976d2" : "#333",
|
||
color: "#fff",
|
||
border: "none",
|
||
borderRadius: "4px",
|
||
fontWeight: "bold",
|
||
cursor: "pointer",
|
||
fontSize: "15px",
|
||
marginBottom: "18px",
|
||
width: "100%",
|
||
}}
|
||
onClick={() => setAnnotationNavEnabled((prev) => !prev)}
|
||
>
|
||
{annotationNavEnabled ? "Hide" : "Show"} Annotation Navigation
|
||
</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}
|
||
/>
|
||
{/* --- Fullscreen button next to grid button --- */}
|
||
<button
|
||
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",
|
||
marginLeft: "4px",
|
||
marginRight: "8px",
|
||
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
|
||
pointerEvents: gridBtnActive ? "auto" : "none",
|
||
zIndex: 2,
|
||
display: gridBtnActive ? "block" : "none",
|
||
}}
|
||
title="Toggle Fullscreen"
|
||
onClick={() => {
|
||
const root = document.querySelector(".dicom-viewer-root") as HTMLElement;
|
||
if (!root) return;
|
||
if (document.fullscreenElement === root) {
|
||
document.exitFullscreen();
|
||
} else if (root.requestFullscreen) {
|
||
root.requestFullscreen();
|
||
} else if ((root as any).webkitRequestFullscreen) {
|
||
(root as any).webkitRequestFullscreen();
|
||
} else if ((root as any).msRequestFullscreen) {
|
||
(root as any).msRequestFullscreen();
|
||
}
|
||
}}
|
||
>
|
||
⛶ Fullscreen
|
||
</button>
|
||
<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
|
||
}
|
||
} |