Files
dv3d/dicom-viewer/src/App.tsx
T

8151 lines
315 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useCallback, useState } from "react"
import {
RenderingEngine,
Enums,
imageLoader,
utilities as csUtilities,
} from "@cornerstonejs/core"
import { init as csRenderInit } 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 { getLoggerConfig, logger, parseLogFormat, parseLogLevel, setLoggerConfig, type LogFormat, type LogLevel } from "./lib/logger";
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;
importAnnotations?: (json: string) => void;
importViewerState?: (json: string) => void;
loadDicomStackFromFiles?: (files: FileList | File[]) => void;
loadDicomStackFromWadouriList?: (wadouriImageIds: string[]) => Promise<void>;
[key: string]: any;
}
}
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;
return {
x: imagePositionPatient[0] + rowCosines[0] * point.y * dy + columnCosines[0] * point.x * dx,
y: imagePositionPatient[1] + rowCosines[1] * point.y * dy + columnCosines[1] * point.x * dx,
z: imagePositionPatient[2] + rowCosines[2] * point.y * dy + columnCosines[2] * point.x * dx,
};
}
type ViewportDiv = HTMLDivElement & {
_cornerstoneEnabled?: boolean;
_lastViewportType?: Enums.ViewportType;
_resizeObserver?: ResizeObserver;
_overlayListener?: EventListener;
};
type ViewportDropZone = "left" | "right" | "top" | "bottom" | "center";
type GroupingRuleId =
| "dwiBValue"
| "dwiDirection"
| "temporalPosition"
| "triggerTime"
| "contentTime"
| "echoTime"
| "echoNumber";
type GroupingRuleDefinition = {
id: GroupingRuleId;
label: string;
description: string;
defaultEnabled: boolean;
};
type StackSeriesGroup = {
key: string;
label: string;
imageIds: string[];
seriesInstanceUID?: string;
bValue?: number | null;
groupValues?: Partial<Record<GroupingRuleId, string>>;
};
type StackEntry = {
imageIds: string[];
name?: string;
studyId?: string;
studyInstanceUID?: string;
caseId?: string | number;
seriesGroups?: StackSeriesGroup[];
groupingSource?: "declared" | "derived";
};
const { MouseBindings, KeyboardBindings } = csToolsEnums;
const { IMAGE_RENDERED } = Enums.Events;
// CT-specific presets. Other modalities use reset + autolevel only.
const CT_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 },
]
type PresetOption =
| { name: string; action: "reset" | "autolevel" }
| { name: string; action: "preset"; center: number; width: number };
const getPresetOptionsForModality = (modality?: string): PresetOption[] => {
const normalized = (modality || "").toUpperCase();
const common: PresetOption[] = [
{ name: "Reset to default", action: "reset" },
{ name: "Auto level", action: "autolevel" },
];
if (normalized !== "CT") {
return common;
}
return [
...common,
...CT_WINDOW_LEVEL_PRESETS.map((preset) => ({
name: preset.name,
action: "preset" as const,
center: preset.center,
width: preset.width,
})),
];
}
// Tool options for dropdowns (icons for compact UI)
const TOOL_OPTIONS = [
{ label: "Window/Level", value: WindowLevelTool.toolName, icon: "🌓" },
{ label: "Pan", value: PanTool.toolName, icon: "✋" },
{ label: "Zoom", value: ZoomTool.toolName, icon: "🔍" },
{ label: "Stack Scroll", value: StackScrollTool.toolName, icon: "📚" },
{ label: "Rotate", value: PlanarRotateTool.toolName, icon: "↺" },
{ label: "Length", value: LengthTool.toolName, icon: "📏" },
{ label: "Probe", value: ProbeTool.toolName, icon: "🔎" },
{ label: "Arrow (No Label)", value: NoLabelArrowAnnotateTool.toolName, icon: "➡️" },
{ label: "Arrow Annotate", value: ArrowAnnotateTool.toolName, icon: "✈️" },
{ label: "Rectangle ROI", value: RectangleROITool.toolName, icon: "▭" },
{ label: "Ellipse ROI", value: EllipticalROITool.toolName, icon: "◯" },
{ label: "Freehand ROI", value: PlanarFreehandROITool.toolName, icon: "✏️" },
{ label: "Freehand Contour Segmentation", value: PlanarFreehandContourSegmentationTool.toolName, icon: "🖊️" },
{ label: "Sculptor", value: SculptorTool.toolName, icon: "🔧" },
{ label: "Reference Cursors", value: ReferenceCursors.toolName, icon: "⦿" },
];
const THUMB_CAPTURE_MAX_RETRIES = 3;
const THUMB_CAPTURE_RETRY_BASE_MS = 220;
const LOG_SETTINGS_KEY = "dv3d_logging";
const LOG_FORMAT_OPTIONS: Array<{ label: string; value: LogFormat }> = [
{ label: "Compact", value: "compact" },
{ label: "Verbose", value: "verbose" },
{ label: "JSON", value: "json" },
];
const LOG_LEVEL_OPTIONS: Array<{ label: string; value: LogLevel }> = [
{ label: "Off", value: "silent" },
{ label: "Error", value: "error" },
{ label: "Warn", value: "warn" },
{ label: "Info", value: "info" },
{ label: "Debug", value: "debug" },
];
const GROUPING_SETTINGS_KEY = "dv3d_grouping";
const GROUPING_RULE_DEFINITIONS: GroupingRuleDefinition[] = [
{
id: "dwiBValue",
label: "DWI b-value",
description: "DiffusionBValue (0018,9087) + known private fallbacks",
defaultEnabled: true,
},
{
id: "dwiDirection",
label: "DWI direction",
description: "Diffusion Gradient Direction (0018,9089)",
defaultEnabled: true,
},
{
id: "temporalPosition",
label: "Temporal position",
description: "Temporal Position Identifier (0020,0100)",
defaultEnabled: true,
},
{
id: "triggerTime",
label: "Trigger time",
description: "Trigger Time (0018,1060)",
defaultEnabled: true,
},
{
id: "contentTime",
label: "Content/Acquisition time",
description: "Content Time (0008,0033) / Acquisition Time (0008,0032)",
defaultEnabled: false,
},
{
id: "echoTime",
label: "Echo time",
description: "Echo Time (0018,0081)",
defaultEnabled: true,
},
{
id: "echoNumber",
label: "Echo number",
description: "Echo Number (0018,0086)",
defaultEnabled: true,
},
];
function createDefaultGroupingRuleState(): Record<GroupingRuleId, boolean> {
return GROUPING_RULE_DEFINITIONS.reduce((acc, rule) => {
acc[rule.id] = rule.defaultEnabled;
return acc;
}, {} as Record<GroupingRuleId, boolean>);
}
const appLogger = logger.child("app");
function getToolIcon(toolName?: string) {
if (!toolName) return '🔖';
const found = TOOL_OPTIONS.find(o => o.value === toolName || o.label === toolName);
return found?.icon || '🔖';
}
function stripWadouriPrefix(imageId?: string): string {
if (!imageId) return "";
if (!imageId.startsWith("wadouri:")) return imageId;
return imageId.replace(/^wadouri:+/, "").replace(/^:+/, "");
}
function hasFrameQuery(imageId: string): boolean {
return /([?&])frame=\d+/i.test(imageId);
}
function withFrameQuery(imageId: string, frameNumber: number): string {
const frame = Math.max(1, Math.floor(frameNumber));
if (hasFrameQuery(imageId)) {
return imageId.replace(/([?&])frame=\d+/i, `$1frame=${frame}`);
}
// Cornerstone's wadouri multiframe path expects '&frame=' specifically.
return `${imageId}&frame=${frame}`;
}
function getFrameNumberFromImageId(imageId: string): number | null {
const match = imageId.match(/[?&]frame=(\d+)/i);
if (!match) return null;
const parsed = Number(match[1]);
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null;
}
function parseNumericValueList(raw?: string | null): number[] {
if (!raw) return [];
return raw
.split(/\\|,/) // DICOM VM separator is '\\'; logs often show comma-separated values.
.map((part) => Number(part.trim()))
.filter((value) => Number.isFinite(value));
}
/**
* Extract b-value(s) from a dicomParser DataSet. Handles multiple VR encodings:
* - (0018,9087) DiffusionBValue — VR FD (double) in Enhanced MR, DS (string) in some classic MR
* - (0019,100C) GE private — VR DS
* - (0043,1039) GE legacy — VR IS/DS
* - (2001,1003) Philips private — VR FL (32-bit float)
*/
function parseBValuesFromDataSet(dataSet: any): number[] {
if (!dataSet || typeof dataSet !== "object") return [];
const tryNumeric = (tagId: string): number | null => {
// DS/IS string form
if (typeof dataSet.string === "function") {
const raw = dataSet.string(tagId);
if (raw != null && raw !== "") {
const vals = parseNumericValueList(raw);
if (vals.length > 0 && Number.isFinite(vals[0]) && vals[0] >= 0) return vals[0];
}
}
// FD — 64-bit double (standard DiffusionBValue in Enhanced MR)
if (typeof dataSet.double === "function") {
try {
const v = dataSet.double(tagId);
if (Number.isFinite(v) && v >= 0) return v;
} catch (_) {}
}
// FL — 32-bit float (Philips private (2001,1003))
if (typeof dataSet.float === "function") {
try {
const v = dataSet.float(tagId);
if (Number.isFinite(v) && v >= 0) return v;
} catch (_) {}
}
return null;
};
// Standard (0018,9087) DiffusionBValue
const v1 = tryNumeric("x00189087");
if (v1 !== null) return [v1];
// GE private (0019,100C)
const v2 = tryNumeric("x0019100c");
if (v2 !== null) return [v2];
// GE legacy (0043,1039) — IS format, first value is b-value
if (typeof dataSet.string === "function") {
const raw = dataSet.string("x00431039");
if (raw) {
const parts = parseNumericValueList(raw);
if (parts.length > 0 && Number.isFinite(parts[0]) && parts[0] >= 0) return [parts[0]];
}
}
// Philips private (2001,1003) — VR FL
const v3 = tryNumeric("x20011003");
if (v3 !== null) return [v3];
return [];
}
function normalizeToWadouriOrExternal(imageId: string): string {
if (imageId.startsWith("wadouri:") || imageId.startsWith("external:")) {
return imageId;
}
return `wadouri:${imageId}`;
}
function dedupeImageIds(imageIds: string[]): string[] {
const seen = new Set<string>();
const next: string[] = [];
for (const id of imageIds) {
if (!id || seen.has(id)) continue;
seen.add(id);
next.push(id);
}
return next;
}
function isCanvasLikelyReadyForThumbnail(canvas: HTMLCanvasElement): boolean {
try {
const ctx = canvas.getContext('2d', { willReadFrequently: true } as CanvasRenderingContext2DSettings);
if (!ctx) return true;
const width = canvas.width || 160;
const height = canvas.height || 160;
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
let minLum = 255;
let maxLum = 0;
let nonDarkCount = 0;
let sampleCount = 0;
for (let y = 0; y < height; y += 8) {
for (let x = 0; x < width; x += 8) {
const i = (y * width + x) * 4;
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3];
const lum = (r + g + b) / 3;
if (a > 0 && lum > 12) {
nonDarkCount += 1;
}
minLum = Math.min(minLum, lum);
maxLum = Math.max(maxLum, lum);
sampleCount += 1;
}
}
if (sampleCount === 0) return true;
const nonDarkRatio = nonDarkCount / sampleCount;
const dynamicRange = maxLum - minLum;
return nonDarkRatio > 0.015 || dynamicRange > 8;
} catch (e) {
// If pixel inspection fails, do not block thumbnail generation.
return true;
}
}
// 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;
initialLoggerConfig?: {
minLevel?: LogLevel;
/** @deprecated Use minLevel instead. */
debug?: boolean;
format?: LogFormat;
};
// ...other props if needed
};
// App definintion
export default function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewerState, initialLoggerConfig }: 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";
const initialLogMinLevel = (initialLoggerConfig?.minLevel as LogLevel | undefined)
?? (initialLoggerConfig?.debug !== undefined ? (initialLoggerConfig.debug ? "debug" : "warn") : undefined)
?? getLoggerConfig().minLevel;
const initialLogFormat = parseLogFormat(initialLoggerConfig?.format ?? getLoggerConfig().format);
const [loggingMinLevel, setLoggingMinLevel] = useState<LogLevel>(initialLogMinLevel);
const [loggingFormat, setLoggingFormat] = useState<LogFormat>(initialLogFormat);
const [advancedGroupingEnabled, setAdvancedGroupingEnabled] = useState(true);
const [groupingRuleEnabled, setGroupingRuleEnabled] = useState<Record<GroupingRuleId, boolean>>(
() => createDefaultGroupingRuleState()
);
const [settingsOpen, setSettingsOpen] = useState(false);
// Settings open state
// (top bar removed; stack browser moved to right-side panel)
// Right-side stack panel open state (toggled by button)
const [stackPanelOpen, setStackPanelOpen] = useState(false);
const [stackPanelWidth, setStackPanelWidth] = useState(320);
const STACK_PANEL_WIDTH_KEY = `dv3d_stackPanelWidth_${container_id || 'default'}`;
const stackPanelResizeRef = useRef<{ resizing: boolean; startX: number; startWidth: number }>({
resizing: false,
startX: 0,
startWidth: 320,
});
const STACK_PANEL_MIN_WIDTH = 240;
const STACK_PANEL_MAX_WIDTH = 720;
const SIDE_MENU_WIDTH = 220;
const SIDE_MENU_BUTTON_SIZE = 40;
const SIDE_MENU_BUTTON_PEEK = 14;
const topControlsRef = useRef<HTMLDivElement | null>(null);
const [fullscreenHoverIdx, setFullscreenHoverIdx] = useState<number | null>(null);
const [openDropdownIdx, setOpenDropdownIdx] = useState<number | null>(null);
const [topControlsPeek, setTopControlsPeek] = useState(false);
const [isPhoneLayout, setIsPhoneLayout] = useState(false);
const [mobileInteractionActive, setMobileInteractionActive] = useState(false);
const [mobileFocusedViewport, setMobileFocusedViewport] = useState<number | null>(null);
const [mobileSingleViewportMode, setMobileSingleViewportMode] = useState(true);
const [mobileArmedStackIdx, setMobileArmedStackIdx] = useState<number | null>(null);
const [mobileArmedDropZone, setMobileArmedDropZone] = useState<"center" | "right" | "bottom">("center");
const [mobilePointerDrag, setMobilePointerDrag] = useState<{
stackIdx: number;
x: number;
y: number;
targetViewportIdx: number | null;
zone: ViewportDropZone;
} | null>(null);
const suppressStackRowClickUntilRef = useRef(0);
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 [volumeDiagnosticsEnabled, setVolumeDiagnosticsEnabled] = useState(false);
const [availableStacks, setAvailableStacks] = useState<StackEntry[]>([]);
const [activeViewport, setActiveViewport] = useState<number | null>(0);
const [draggedStackIdx, setDraggedStackIdx] = useState<number | null>(null);
const [externalDragActive, setExternalDragActive] = useState(false);
const [dropPreview, setDropPreview] = useState<{ viewportIdx: number; zone: ViewportDropZone } | null>(null);
// After your useState for viewportImageIds:
const [viewportImageIds, _setViewportImageIds] = useState<string[][]>(
() => Array(MAX_VIEWPORTS).fill(null).map(() => [])
);
// 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);
appLogger.debug("setViewportImageIds (fn):", next);
return next;
});
} else {
appLogger.debug("setViewportImageIds:", value);
_setViewportImageIds(value);
}
};
const [selectedStackIdx, setSelectedStackIdx] = useState<number[]>(
() => Array(MAX_VIEWPORTS).fill(0)
);
const [seriesGroupsByViewport, setSeriesGroupsByViewport] = useState<StackSeriesGroup[][]>(
() => Array(MAX_VIEWPORTS).fill(null).map(() => [])
);
const [selectedSeriesIdxByViewport, setSelectedSeriesIdxByViewport] = useState<number[]>(
() => Array(MAX_VIEWPORTS).fill(0)
);
const [cinePlayingByViewport, setCinePlayingByViewport] = useState<boolean[]>(
() => Array(MAX_VIEWPORTS).fill(false)
);
const [cineFpsByViewport, setCineFpsByViewport] = useState<number[]>(
() => Array(MAX_VIEWPORTS).fill(12)
);
const [cineControlsOpenByViewport, setCineControlsOpenByViewport] = useState<boolean[]>(
() => Array(MAX_VIEWPORTS).fill(false)
);
const multiframeCountCacheRef = useRef<Record<string, number>>({});
const bValuesByBaseImageRef = useRef<Record<string, number[]>>({});
// Client-side generated thumbnails cache: stackIndex -> dataURL
const [stackThumbnails, setStackThumbnails] = useState<Record<number, string>>({});
const stackThumbnailsRef = useRef<Record<number, string>>({});
const thumbRequestedRef = useRef<Record<number, boolean>>({});
const setStackThumbnail = useCallback((stackIdx: number, value: string) => {
setStackThumbnails(prev => {
const previous = prev[stackIdx];
if (previous && previous !== value && previous.startsWith('blob:')) {
try {
URL.revokeObjectURL(previous);
} catch (e) {
// ignore revoke failures
}
}
const next = { ...prev, [stackIdx]: value };
stackThumbnailsRef.current = next;
return next;
});
}, []);
useEffect(() => {
stackThumbnailsRef.current = stackThumbnails;
}, [stackThumbnails]);
useEffect(() => {
return () => {
Object.values(stackThumbnailsRef.current).forEach((thumbnail) => {
if (typeof thumbnail === 'string' && thumbnail.startsWith('blob:')) {
try {
URL.revokeObjectURL(thumbnail);
} catch (e) {
// ignore revoke failures
}
}
});
};
}, []);
useEffect(() => {
try {
const saved = localStorage.getItem(STACK_PANEL_WIDTH_KEY);
if (saved) {
const parsed = Number(saved);
if (!Number.isNaN(parsed)) {
setStackPanelWidth(Math.max(STACK_PANEL_MIN_WIDTH, Math.min(STACK_PANEL_MAX_WIDTH, Math.round(parsed))));
}
}
} catch (e) {
// ignore storage access errors
}
}, [STACK_PANEL_WIDTH_KEY]);
useEffect(() => {
try {
localStorage.setItem(STACK_PANEL_WIDTH_KEY, String(Math.round(stackPanelWidth)));
} catch (e) {
// ignore storage access errors
}
}, [STACK_PANEL_WIDTH_KEY, stackPanelWidth]);
// Helper to find a canvas element under a given node (recursive)
function findCanvasIn(node: ParentNode | null): HTMLCanvasElement | null {
if (!node) return null;
try {
if ((node as Element).tagName && (node as Element).tagName.toLowerCase() === 'canvas') {
return node as unknown as HTMLCanvasElement;
}
} catch (e) {
// ignore
}
try {
const q = (node as Element).querySelector && (node as Element).querySelector('canvas');
if (q) return q as HTMLCanvasElement;
} catch (e) {
// ignore
}
try {
const children = (node as Element).children || [];
for (let i = 0; i < children.length; i++) {
const found = findCanvasIn(children[i]);
if (found) return found;
}
} catch (e) {
// ignore
}
return null;
}
// Helper to detect external (non-DICOM) image ids
function isExternalImageId(imageId: string | null | undefined) {
if (!imageId) return false;
// Treat wadouri: as DICOM (our existing loader)
if (imageId.startsWith('wadouri:')) return false;
if (imageId.startsWith('external:')) return true;
// blob: or data:image/ or common extensions
if (imageId.startsWith('blob:') || imageId.startsWith('data:image/')) return true;
const lower = imageId.toLowerCase();
if (lower.endsWith('.png') || lower.endsWith('.jpg') || lower.endsWith('.jpeg') || lower.endsWith('.gif')) return true;
return false;
}
// Enable simple pan & zoom interactions for an <img> placed inside a viewport element.
// Attaches event listeners and returns a cleanup function.
function enableImagePanZoom(container: HTMLElement, img: HTMLImageElement, options?: { allowPan?: boolean, allowZoom?: boolean }) {
const allowPanInit = options?.allowPan ?? true;
const allowZoomInit = options?.allowZoom ?? true;
let scale = 1;
let translateX = 0;
let translateY = 0;
let isPanning = false;
let startX = 0;
let startY = 0;
let lastClientX = 0;
let lastClientY = 0;
img.style.transformOrigin = '0 0';
img.style.willChange = 'transform';
img.style.cursor = 'grab';
const clamp = (v: number, a = 0.1, b = 10) => Math.min(b, Math.max(a, v));
const apply = () => {
img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
};
let allowPan = allowPanInit;
let allowZoom = allowZoomInit;
const onWheel = (e: WheelEvent) => {
if (!allowZoom) return;
// Zoom on wheel
e.preventDefault();
const rect = img.getBoundingClientRect();
const offsetX = e.clientX - rect.left;
const offsetY = e.clientY - rect.top;
const delta = -e.deltaY;
const zoomFactor = 1 + (delta > 0 ? 0.1 : -0.1) * Math.min(4, Math.abs(delta) / 100);
const newScale = clamp(scale * zoomFactor, 0.1, 10);
// Keep point under cursor stationary
translateX = (translateX - offsetX) * (newScale / scale) + offsetX;
translateY = (translateY - offsetY) * (newScale / scale) + offsetY;
scale = newScale;
apply();
};
const onMouseDown = (e: MouseEvent) => {
if (e.button !== 0) return;
if (!allowPan) return;
isPanning = true;
startX = translateX;
startY = translateY;
lastClientX = e.clientX;
lastClientY = e.clientY;
img.style.cursor = 'grabbing';
e.preventDefault();
};
const onMouseMove = (e: MouseEvent) => {
if (!isPanning) return;
const dx = e.clientX - lastClientX;
const dy = e.clientY - lastClientY;
translateX += dx;
translateY += dy;
lastClientX = e.clientX;
lastClientY = e.clientY;
apply();
};
const onMouseUp = (e: MouseEvent) => {
if (!isPanning) return;
isPanning = false;
img.style.cursor = 'grab';
};
const onDblClick = (e: MouseEvent) => {
// reset
scale = 1;
translateX = 0;
translateY = 0;
apply();
};
// Touch support: simple panning (single touch) and pinch-to-zoom (two touches)
let lastTouchDist = 0;
const getTouchDist = (t1: Touch, t2: Touch) => Math.hypot(t2.clientX - t1.clientX, t2.clientY - t1.clientY);
const onTouchStart = (e: TouchEvent) => {
if (e.touches.length === 1) {
if (allowPan) {
isPanning = true;
lastClientX = e.touches[0].clientX;
lastClientY = e.touches[0].clientY;
}
} else if (e.touches.length === 2) {
lastTouchDist = getTouchDist(e.touches[0], e.touches[1]);
}
};
const onTouchMove = (e: TouchEvent) => {
if (e.touches.length === 1 && isPanning) {
const dx = e.touches[0].clientX - lastClientX;
const dy = e.touches[0].clientY - lastClientY;
translateX += dx;
translateY += dy;
lastClientX = e.touches[0].clientX;
lastClientY = e.touches[0].clientY;
apply();
} else if (e.touches.length === 2 && allowZoom) {
const curDist = getTouchDist(e.touches[0], e.touches[1]);
if (lastTouchDist > 0) {
const zoomFactor = curDist / lastTouchDist;
const rect = img.getBoundingClientRect();
const centerX = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
const centerY = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
const newScale = clamp(scale * zoomFactor, 0.1, 10);
translateX = (translateX - centerX) * (newScale / scale) + centerX;
translateY = (translateY - centerY) * (newScale / scale) + centerY;
scale = newScale;
apply();
}
lastTouchDist = curDist;
}
e.preventDefault();
};
const onTouchEnd = (e: TouchEvent) => {
if (e.touches.length === 0) {
isPanning = false;
lastTouchDist = 0;
}
};
// Attach listeners to the container so we get events even if the img is not full-size
container.addEventListener('wheel', onWheel, { passive: false });
container.addEventListener('mousedown', onMouseDown as any);
window.addEventListener('mousemove', onMouseMove as any);
window.addEventListener('mouseup', onMouseUp as any);
img.addEventListener('dblclick', onDblClick as any);
container.addEventListener('touchstart', onTouchStart as any, { passive: false });
container.addEventListener('touchmove', onTouchMove as any, { passive: false });
container.addEventListener('touchend', onTouchEnd as any);
const cleanup = () => {
container.removeEventListener('wheel', onWheel as any);
container.removeEventListener('mousedown', onMouseDown as any);
window.removeEventListener('mousemove', onMouseMove as any);
window.removeEventListener('mouseup', onMouseUp as any);
img.removeEventListener('dblclick', onDblClick as any);
container.removeEventListener('touchstart', onTouchStart as any);
container.removeEventListener('touchmove', onTouchMove as any);
container.removeEventListener('touchend', onTouchEnd as any);
// reset transform
img.style.transform = '';
img.style.cursor = '';
img.style.willChange = '';
};
// Return control functions so we can update allowed interactions later
return {
cleanup,
setAllowPan: (v: boolean) => { allowPan = v; },
setAllowZoom: (v: boolean) => { allowZoom = v; },
};
}
const [stackOrderModified, setStackOrderModified] = useState(false);
const [loadingState, setLoadingState] = useState<null | "annotations" | "viewerState" | "displayset">(null);
// Show a warning when a WebGL/graphics resource error (e.g. releaseGraphicsResources) occurs
const [graphicsErrorVisible, setGraphicsErrorVisible] = useState(false);
const [graphicsErrorMessage, setGraphicsErrorMessage] = useState<string | null>(null);
useEffect(() => {
const onError = (ev: ErrorEvent) => {
try {
const msg = ev && ev.message ? String(ev.message) : '';
// Detect known rendering/WebGL-related errors and actor/colormap issues
if (
msg.includes('releaseGraphicsResources') ||
msg.includes('openGLTexture') ||
msg.includes('getDefaultActor') ||
msg.includes('setColormapGPU') ||
msg.includes("can't access property \"actor\"") ||
// generic rendering failure
msg.toLowerCase().includes('webgl') ||
msg.toLowerCase().includes('colormap')
) {
appLogger.error('Detected graphics/rendering error:', ev);
setGraphicsErrorMessage(msg || null);
setGraphicsErrorVisible(true);
}
} catch (e) {
// ignore
}
};
const onRejection = (ev: PromiseRejectionEvent) => {
try {
const reason = (ev && (ev as any).reason) || '';
const text = typeof reason === 'string' ? reason : (reason && reason.message) ? reason.message : String(reason);
if (
text.includes('releaseGraphicsResources') ||
text.includes('openGLTexture') ||
text.includes('getDefaultActor') ||
text.includes('setColormapGPU') ||
text.includes("can't access property \"actor\"") ||
text.toLowerCase().includes('webgl') ||
text.toLowerCase().includes('colormap')
) {
appLogger.error('Detected graphics/rendering rejection:', ev);
setGraphicsErrorMessage(text || null);
setGraphicsErrorVisible(true);
}
} catch (e) {
// ignore
}
};
window.addEventListener('error', onError as EventListener);
window.addEventListener('unhandledrejection', onRejection as EventListener);
return () => {
window.removeEventListener('error', onError as EventListener);
window.removeEventListener('unhandledrejection', onRejection as EventListener);
};
}, []);
// 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) {
appLogger.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) {
appLogger.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 allStacks: StackEntry[] = [];
for (const d of stacksRaw) {
const isRawArray = Array.isArray(d);
const imageIdsRaw: string[] = isRawArray
? (d as string[])
: (Array.isArray((d as any).imageIds) ? (d as any).imageIds : (Array.isArray((d as any).i) ? (d as any).i : []));
const meta: Partial<StackEntry> = isRawArray ? {} : {
studyId: (d as any).studyId,
name: (d as any).name,
caseId: (d as any).caseId,
studyInstanceUID: (d as any).studyInstanceUID,
};
// Avoid expensive startup preprocessing across all stacks.
// Keep descriptor-declared groups if provided, otherwise defer split/grouping until load.
if (!isRawArray && Array.isArray((d as any).series)) {
const declaredGroups: StackSeriesGroup[] = (d as any).series
.map((seriesEntry: any, seriesIdx: number) => {
const rawIds = Array.isArray(seriesEntry?.imageIds)
? seriesEntry.imageIds
: (Array.isArray(seriesEntry?.i) ? seriesEntry.i : []);
const normalizedIds = dedupeImageIds(rawIds.map(normalizeToWadouriOrExternal));
const rawLabel = typeof seriesEntry?.label === "string"
? seriesEntry.label
: (typeof seriesEntry?.name === "string" ? seriesEntry.name : "");
const label = rawLabel.trim() || `Series ${seriesIdx + 1}`;
return {
key: String(seriesEntry?.key || seriesEntry?.seriesInstanceUID || seriesEntry?.seriesId || `SERIES_${seriesIdx}`),
label,
imageIds: normalizedIds,
seriesInstanceUID: seriesEntry?.seriesInstanceUID,
bValue: Number.isFinite(Number(seriesEntry?.bValue)) ? Number(seriesEntry.bValue) : null,
groupValues: seriesEntry?.groupValues,
} as StackSeriesGroup;
})
.filter((group: StackSeriesGroup) => group.imageIds.length > 0);
if (declaredGroups.length > 0) {
allStacks.push({
imageIds: dedupeImageIds(declaredGroups.flatMap((g) => g.imageIds)),
seriesGroups: declaredGroups,
groupingSource: "declared",
...meta,
});
continue;
}
}
const normalizedImageIds = dedupeImageIds(imageIdsRaw.map(normalizeToWadouriOrExternal));
if (normalizedImageIds.length > 0) {
allStacks.push({ imageIds: normalizedImageIds, seriesGroups: [], groupingSource: "derived", ...meta });
}
}
setAvailableStacks(allStacks);
// Seed viewports with first stack if present (preserve old behaviour)
if (allStacks[0]) {
const firstStack = allStacks[0];
let firstGroups = firstStack.seriesGroups || [];
let firstImageIds = firstStack.imageIds;
if (firstGroups.length === 0) {
if (autoCacheStack) {
appLogger.debug("[init] autoCacheStack true: expanding multiframe and fetching metadata");
firstImageIds = await expandMultiframeImageIds(firstStack.imageIds);
await setLoadedImageIdsAndCacheMeta(firstImageIds, { force: true });
firstGroups = await deriveSeriesGroupsFromImageIds(firstImageIds);
allStacks[0] = { ...firstStack, imageIds: firstImageIds, seriesGroups: firstGroups, groupingSource: "derived" };
setAvailableStacks([...allStacks]);
} else {
appLogger.debug("[init] autoCacheStack false: skipping all probes/expansion, relying on server grouping");
}
}
const firstSeries = firstGroups[0]?.imageIds || firstImageIds;
if (autoCacheStack) {
await setLoadedImageIdsAndCacheMeta(firstSeries, { force: true });
} else {
appLogger.debug("[init] autoCacheStack false: not fetching metadata for firstSeries");
}
setViewportImageIds(Array(MAX_VIEWPORTS).fill(null).map((_, idx) => (idx === 0 ? (firstSeries || []) : [])));
setSeriesGroupsByViewport(Array(MAX_VIEWPORTS).fill(null).map(() => firstGroups));
setSelectedSeriesIdxByViewport(Array(MAX_VIEWPORTS).fill(0));
} else {
setViewportImageIds(Array(MAX_VIEWPORTS).fill(null).map(() => []));
setSeriesGroupsByViewport(Array(MAX_VIEWPORTS).fill(null).map(() => []));
setSelectedSeriesIdxByViewport(Array(MAX_VIEWPORTS).fill(0));
}
})
.catch(err => {
appLogger.error("Failed to load imageStacks:", err);
setAvailableStacks([]);
setViewportImageIds(Array(MAX_VIEWPORTS).fill(null).map(() => []));
});
return () => { mounted = false; };
}, []);
const handleLoadStack = async (viewportIdx: number, stackIdx: number) => {
const stackObj = availableStacks[stackIdx];
if (!stackObj) return;
const hasDeclaredGroups = stackObj.groupingSource === "declared" && (stackObj.seriesGroups || []).length > 0;
if (!autoCacheStack && hasDeclaredGroups && (stackObj.imageIds || []).length > 1) {
// Strict lazy mode: never probe, expand, or fetch metadata if grouping is declared and autoCacheStack is false
appLogger.debug("[handleLoadStack] autoCacheStack false & declared groups: skipping all probes/expansion, using server grouping only");
const stack = dedupeImageIds((stackObj.imageIds || []).map(normalizeToWadouriOrExternal));
const seriesGroups = stackObj.seriesGroups || [];
setAvailableStacks(prev => {
const next = [...prev];
if (!next[stackIdx]) return prev;
next[stackIdx] = {
...next[stackIdx],
seriesGroups,
imageIds: stack,
groupingSource: "declared",
};
return next;
});
const primarySeries = seriesGroups[0]?.imageIds || stack;
setLoadingState("displayset");
try {
setViewportModes(prev => {
const next = [...prev];
next[viewportIdx] = 'stack';
return next;
});
setViewportImageIds(prev => {
const next = [...prev];
next[viewportIdx] = primarySeries;
return next;
});
setSelectedStackIdx(prev => {
const next = [...prev];
next[viewportIdx] = stackIdx;
return next;
});
setSeriesGroupsByViewport(prev => {
const next = [...prev];
next[viewportIdx] = seriesGroups || [];
return next;
});
setSelectedSeriesIdxByViewport(prev => {
const next = [...prev];
next[viewportIdx] = 0;
return next;
});
appLogger.debug("[handleLoadStack] autoCacheStack false: not fetching metadata for primarySeries");
} finally {
setLoadingState(null);
}
return;
}
// Default: allow expansion/probe if autoCacheStack is true or grouping is not declared
const shouldExpandMultiframe = !(hasDeclaredGroups && (stackObj.imageIds || []).length > 1);
const stack = shouldExpandMultiframe
? await expandMultiframeImageIds(stackObj.imageIds || [])
: dedupeImageIds((stackObj.imageIds || []).map(normalizeToWadouriOrExternal));
appLogger.debug("[handleLoadStack] Loading stack:", stack.length, "ids. First:", stack[0]?.slice(0, 80));
await setLoadedImageIdsAndCacheMeta(stack, { force: Boolean(autoCacheStack) });
const seriesGroups = hasDeclaredGroups
? (stackObj.seriesGroups || [])
: await deriveSeriesGroupsFromImageIds(stack);
appLogger.debug("[handleLoadStack] Derived series groups:", seriesGroups.length, "groups. First group has", seriesGroups[0]?.imageIds.length, "images");
setAvailableStacks(prev => {
const next = [...prev];
if (!next[stackIdx]) return prev;
next[stackIdx] = {
...next[stackIdx],
seriesGroups,
imageIds: stack,
groupingSource: hasDeclaredGroups ? "declared" : "derived",
};
return next;
});
const primarySeries = seriesGroups[0]?.imageIds || stack;
appLogger.debug("[handleLoadStack] Setting viewport to primarySeries with", primarySeries.length, "ids. First:", primarySeries[0]?.slice(0, 80));
setLoadingState("displayset");
try {
// 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] = primarySeries;
return next;
});
setSelectedStackIdx(prev => {
const next = [...prev];
next[viewportIdx] = stackIdx;
return next;
});
setSeriesGroupsByViewport(prev => {
const next = [...prev];
next[viewportIdx] = seriesGroups || [];
return next;
});
setSelectedSeriesIdxByViewport(prev => {
const next = [...prev];
next[viewportIdx] = 0;
return next;
});
// Cache imageIds and load metadata for this stack
await setLoadedImageIdsAndCacheMeta(primarySeries);
} finally {
setLoadingState(null);
}
};
const [crossHairsEnabled, setCrossHairsEnabled] = useState(false);
const [referenceLinesEnabled, setReferenceLinesEnabled] = useState(true);
const [showAdvancedMouseBindings, setShowAdvancedMouseBindings] = useState(false);
const [showAdvancedCtrlMouseBindings, setShowAdvancedCtrlMouseBindings] = useState(false);
const sortViewportImageIdsBySliceLocation = () => {
appLogger.debug("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;
});
};
const generateThumbnail = useCallback(async (stackIdx: number, chosenImageId: string | null, retryAttempt = 0) => {
if (!chosenImageId) {
return;
}
if (stackThumbnails[stackIdx]) {
return;
}
if (thumbRequestedRef.current[stackIdx]) {
return;
}
if (isExternalImageId(chosenImageId)) {
try {
const img = new Image();
img.crossOrigin = 'anonymous';
const src = (chosenImageId as string).replace(/^external:/, '');
img.src = src;
await new Promise((resolve, reject) => {
img.onload = () => resolve(true);
img.onerror = (e) => reject(e);
});
const canvas = document.createElement('canvas');
const size = 160;
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (ctx) {
const ratio = Math.min(size / img.width, size / img.height);
const w = img.width * ratio;
const h = img.height * ratio;
const x = (size - w) / 2;
const y = (size - h) / 2;
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, size, size);
ctx.drawImage(img, x, y, w, h);
setStackThumbnail(stackIdx, canvas.toDataURL('image/png'));
} else {
setStackThumbnail(stackIdx, 'BROKEN');
}
} catch (err) {
setStackThumbnail(stackIdx, 'BROKEN');
}
return;
}
const renderingEngine = renderingEngineRef.current;
if (!renderingEngine) {
return;
}
thumbRequestedRef.current[stackIdx] = true;
const viewportId = `THUMB_${container_id || 'main'}_${stackIdx}`;
const el = document.createElement('div');
el.style.width = '160px';
el.style.height = '160px';
el.style.position = 'absolute';
el.style.left = '-9999px';
el.style.top = '0';
document.body.appendChild(el);
try {
renderingEngine.enableElement({ viewportId, type: Enums.ViewportType.STACK, element: el });
const vp = renderingEngine.getViewport(viewportId);
if (!vp) {
return;
}
try {
vp.setStack([chosenImageId]);
} catch (err) {
// ignore setStack failures for fallback behavior
}
await new Promise<boolean>(resolve => {
let resolved = false;
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
resolve(false);
}
}, 2000);
const onRendered = () => {
if (resolved) return;
resolved = true;
clearTimeout(timeout);
resolve(true);
};
try {
if (vp && vp.element) {
vp.element.addEventListener(IMAGE_RENDERED, onRendered, { once: true } as any);
}
if (typeof vp.render === 'function') {
try {
const res = vp.render();
if (res && typeof (res as any).then === 'function') {
(res as any).catch(() => undefined);
}
} catch (err) {
// ignore render errors
}
}
} catch (err) {
clearTimeout(timeout);
resolve(false);
}
});
let canvas = findCanvasIn(el) || (vp && vp.element ? findCanvasIn(vp.element) : null);
if (!canvas) {
await new Promise(r => setTimeout(r, 150));
canvas = findCanvasIn(el) || (vp && vp.element ? findCanvasIn(vp.element) : null);
}
if (canvas) {
const readyForCapture = isCanvasLikelyReadyForThumbnail(canvas);
if (!readyForCapture && retryAttempt < THUMB_CAPTURE_MAX_RETRIES) {
const retryDelayMs = THUMB_CAPTURE_RETRY_BASE_MS * (retryAttempt + 1);
thumbRequestedRef.current[stackIdx] = false;
setTimeout(() => {
generateThumbnail(stackIdx, chosenImageId, retryAttempt + 1);
}, retryDelayMs);
return;
}
try {
if (typeof canvas.toBlob === 'function') {
const blobUrl = await new Promise<string | null>((resolve) => {
canvas.toBlob((blob) => {
if (!blob) {
resolve(null);
return;
}
resolve(URL.createObjectURL(blob));
}, 'image/png');
});
if (blobUrl) {
setStackThumbnail(stackIdx, blobUrl);
} else {
setStackThumbnail(stackIdx, canvas.toDataURL('image/png'));
}
} else {
setStackThumbnail(stackIdx, canvas.toDataURL('image/png'));
}
} catch (err) {
setStackThumbnail(stackIdx, 'BROKEN');
}
} else {
setStackThumbnail(stackIdx, 'BROKEN');
}
} catch (err) {
try { setStackThumbnail(stackIdx, 'BROKEN'); } catch (e) { /* ignore */ }
} finally {
try { renderingEngine.disableElement(viewportId); } catch (e) { /* ignore */ }
if (el && el.parentNode) el.parentNode.removeChild(el);
}
}, [container_id, stackThumbnails, setStackThumbnail]);
// Generate thumbnails lazily when the stack panel is open.
useEffect(() => {
if (!stackPanelOpen) return;
if (!availableStacks || availableStacks.length === 0) return;
const renderingEngine = renderingEngineRef.current;
if (!renderingEngine) return;
availableStacks.slice(0, 12).forEach((stack, idx) => {
const imageIds = Array.isArray(stack.imageIds) ? stack.imageIds : [];
const midIndex = Math.floor(imageIds.length / 2);
const chosen = imageIds[midIndex] || imageIds[0] || null;
if (!stackThumbnails[idx] && !thumbRequestedRef.current[idx]) {
generateThumbnail(idx, chosen as string | null);
}
});
}, [availableStacks, generateThumbnail, stackPanelOpen, stackThumbnails]);
// Wrap setLoadedImageIds to also cache DICOM metadata
const setLoadedImageIdsAndCacheMeta = async (imageIds: string[], options?: { force?: boolean }) => {
appLogger.debug("Setting loaded imageIds:", imageIds);
appLogger.debug("autoCacheStack:", autoCacheStack);
const loadPromises: Promise<any>[] = [];
const shouldCache = Boolean(autoCacheStack || options?.force);
if (shouldCache) {
const urlsToLoad = new Set<string>();
for (const imageId of imageIds) {
if (imageId.startsWith("wadouri:")) {
try {
const baseUrl = stripWadouriPrefix(imageId).replace(/[?&]frame=\d+/gi, "");
if (baseUrl) {
urlsToLoad.add(baseUrl);
}
} catch (e) {
appLogger.warn(`Failed to cache metadata for ${imageId}:`, e);
}
}
}
for (const url of urlsToLoad) {
if (cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager) {
// @ts-ignore
const result = cornerstoneDICOMImageLoader.wadouri.dataSetCacheManager.load(url);
if (result && typeof result.then === "function") {
loadPromises.push(result);
}
} else {
appLogger.warn("MetaDataManager not found. Unable to cache metadata.");
}
}
// Wait for all metadata loads to finish
await Promise.all(loadPromises);
// Trigger sort if enabled
if (orderBySliceLocation) {
appLogger.debug("Sorting viewport imageIds by slice location");
sortViewportImageIdsBySliceLocation();
}
}
};
const detectMultiframeFrameCount = useCallback(async (imageId: string): Promise<number> => {
if (!imageId.startsWith("wadouri:") || hasFrameQuery(imageId)) {
return 1;
}
const normalized = normalizeToWadouriOrExternal(imageId);
const url = stripWadouriPrefix(normalized);
const cacheKey = `wadouri:${url}`;
const cached = multiframeCountCacheRef.current[cacheKey];
if (cached !== undefined) {
return cached;
}
const readFramesFromDataSet = (dataSet: any): number => {
if (!dataSet || typeof dataSet.intString !== "function") return 1;
const value = Number(dataSet.intString("x00280008"));
return Number.isFinite(value) && value > 1 ? Math.floor(value) : 1;
};
const tryParseBytes = (bytes: Uint8Array): number => {
try {
const ds = dicomParser.parseDicom(bytes);
return readFramesFromDataSet(ds);
} catch (e: any) {
// dicomParser throws on truncated data but may have a partial dataset
if (e?.dataSet) {
const frames = readFramesFromDataSet(e.dataSet);
if (frames > 1) return frames;
}
return 1;
}
};
// 1. Cornerstone metadata (populated after first load — free)
try {
const multiframeMeta = metaData.get("multiframeModule", normalized) as any;
const fromMeta = Number(multiframeMeta?.numberOfFrames);
if (Number.isFinite(fromMeta) && fromMeta > 1) {
multiframeCountCacheRef.current[cacheKey] = Math.floor(fromMeta);
return Math.floor(fromMeta);
}
} catch (_) {}
// 2. wadouri dataSetCacheManager (populated after first load — free)
try {
const cacheManager = cornerstoneDICOMImageLoader?.wadouri?.dataSetCacheManager;
if (cacheManager?.get) {
const dataSet = cacheManager.get(url);
if (dataSet) {
const fromCache = readFramesFromDataSet(dataSet);
multiframeCountCacheRef.current[cacheKey] = fromCache;
return fromCache;
}
}
} catch (_) {}
// 3. Range request — first 256 KB should include top-level NumberOfFrames if present.
try {
const rangeResponse = await fetch(url, { headers: { Range: "bytes=0-262143" } });
if (rangeResponse.ok || rangeResponse.status === 206) {
const buffer = await rangeResponse.arrayBuffer();
const count = tryParseBytes(new Uint8Array(buffer));
// Treat range parse as authoritative to avoid N full-file fetches on single-frame studies.
multiframeCountCacheRef.current[cacheKey] = count;
return count;
}
} catch (_) {}
// 4. Conservative fallback: if detection failed, assume single-frame.
multiframeCountCacheRef.current[cacheKey] = 1;
return 1;
}, []);
/** @deprecated Use detectMultiframeFrameCount instead. */
const getNumberOfFramesForImageId = detectMultiframeFrameCount;
const splitMultiframeToStacks = useCallback(async (rawImageIds: string[]): Promise<{
singleFrameIds: string[];
multiframeGroups: Array<{ baseImageId: string; frameIds: string[] }>;
}> => {
const singleFrameIds: string[] = [];
const multiframeGroups: Array<{ baseImageId: string; frameIds: string[] }> = [];
for (const raw of rawImageIds || []) {
const imageId = normalizeToWadouriOrExternal(raw);
// Already has a frame query, or not a wadouri — treat as single frame
if (!imageId.startsWith("wadouri:") || hasFrameQuery(imageId)) {
singleFrameIds.push(imageId);
continue;
}
const frameCount = await detectMultiframeFrameCount(imageId);
if (frameCount <= 1) {
singleFrameIds.push(imageId);
} else {
const frameIds = Array.from({ length: frameCount }, (_, i) => withFrameQuery(imageId, i + 1));
multiframeGroups.push({ baseImageId: imageId, frameIds });
appLogger.debug(
"[splitMultiframeToStacks]",
stripWadouriPrefix(imageId).split("/").pop()?.split("?")[0] ?? imageId,
"->",
frameCount,
"frames (separate stack)"
);
}
}
return { singleFrameIds: dedupeImageIds(singleFrameIds), multiframeGroups };
}, [detectMultiframeFrameCount]);
const expandMultiframeImageIds = useCallback(async (rawImageIds: string[]) => {
appLogger.debug("[expandMultiframeImageIds] Input:", rawImageIds.length, "imageIds");
if (!autoCacheStack) {
const passthrough = dedupeImageIds((rawImageIds || []).map(normalizeToWadouriOrExternal));
appLogger.debug("[expandMultiframeImageIds] autoCacheStack false: skipping multiframe probe/expansion");
appLogger.debug("[expandMultiframeImageIds] Output:", passthrough.length, "total imageIds");
return passthrough;
}
const { singleFrameIds, multiframeGroups } = await splitMultiframeToStacks(rawImageIds);
const result = dedupeImageIds([
...singleFrameIds,
...multiframeGroups.flatMap((g) => g.frameIds),
]);
appLogger.debug("[expandMultiframeImageIds] Output:", result.length, "total imageIds");
return result;
}, [autoCacheStack, splitMultiframeToStacks]);
const getFallbackBValuesForImageId = useCallback(async (imageId: string): Promise<number[]> => {
if (!autoCacheStack) return [];
if (!imageId.startsWith("wadouri:")) return [];
const normalized = normalizeToWadouriOrExternal(imageId);
const baseUrl = stripWadouriPrefix(normalized).replace(/[?&]frame=\d+/i, "");
const cached = bValuesByBaseImageRef.current[baseUrl];
if (cached) return cached;
try {
const cacheManager = cornerstoneDICOMImageLoader?.wadouri?.dataSetCacheManager;
if (cacheManager?.get) {
const existing = cacheManager.get(baseUrl);
const fromExisting = parseBValuesFromDataSet(existing);
if (fromExisting.length > 0) {
bValuesByBaseImageRef.current[baseUrl] = fromExisting;
return fromExisting;
}
}
} catch (e) {
// Continue with fetch fallback.
}
// Helper: parse bytes tolerating truncation (header-only range requests)
const tryParseBytes = (bytes: Uint8Array): number[] => {
try {
return parseBValuesFromDataSet(dicomParser.parseDicom(bytes));
} catch (e: any) {
// dicomParser throws on truncation but attaches partial dataSet
if (e?.dataSet) {
const partial = parseBValuesFromDataSet(e.dataSet);
if (partial.length > 0) return partial;
}
return [];
}
};
// Range request — first 64 KB is enough to find b-value tags in the header
try {
const rangeResponse = await fetch(baseUrl, { headers: { Range: "bytes=0-65535" } });
if (rangeResponse.ok || rangeResponse.status === 206) {
const buf = await rangeResponse.arrayBuffer();
const result = tryParseBytes(new Uint8Array(buf));
if (result.length > 0 || rangeResponse.status === 200) {
bValuesByBaseImageRef.current[baseUrl] = result;
return result;
}
}
} catch (e) {
// Fall through to full fetch.
}
// Full fetch fallback (only if range request failed or server doesn't support Range)
try {
const response = await fetch(baseUrl);
const buf = await response.arrayBuffer();
const result = tryParseBytes(new Uint8Array(buf));
bValuesByBaseImageRef.current[baseUrl] = result;
return result;
} catch (e) {
bValuesByBaseImageRef.current[baseUrl] = [];
return [];
}
}, [autoCacheStack]);
const deriveSeriesGroupsFromImageIds = useCallback(async (imageIds: string[]): Promise<StackSeriesGroup[]> => {
if (!Array.isArray(imageIds) || imageIds.length === 0) {
return [];
}
if (!autoCacheStack) {
// Strict lazy mode: do not probe metadata/tags client-side for grouping.
return [{
key: "SERIES_0",
label: "Series 1",
imageIds: dedupeImageIds(imageIds),
bValue: null,
groupValues: {},
}];
}
const formatVec = (arr?: number[]) => {
if (!Array.isArray(arr) || arr.length === 0) return "";
return arr.map((v) => {
const n = Number(v);
if (!Number.isFinite(n)) return "0.000";
return n.toFixed(3);
}).join(",");
};
const parseDirection = (value: any): string => {
if (Array.isArray(value) && value.length > 0) {
const vec = value.map((v) => Number(v)).filter((v) => Number.isFinite(v));
if (vec.length >= 3) return formatVec(vec.slice(0, 3));
}
if (typeof value === "string") {
const vec = parseNumericValueList(value);
if (vec.length >= 3) return formatVec(vec.slice(0, 3));
const trimmed = value.trim();
if (trimmed) return trimmed;
}
if (value && typeof value === "object") {
const maybe = [value.x, value.y, value.z].map((v) => Number(v)).filter((v) => Number.isFinite(v));
if (maybe.length >= 3) return formatVec(maybe.slice(0, 3));
}
return "";
};
const normalizeTimeValue = (value: unknown): string => {
if (value === undefined || value === null) return "";
const text = String(value).trim();
if (!text) return "";
const numeric = Number(text);
if (Number.isFinite(numeric)) {
// Keep one decimal for better grouping stability with very close values.
return numeric.toFixed(1);
}
return text;
};
const getOrientationKey = (id: string): string => {
const planeMeta = (metaData.get("imagePlaneModule", id) || {}) as any;
const imageMeta = (metaData.get("generalImageModule", id) || {}) as any;
const row = Array.isArray(planeMeta?.rowCosines) ? planeMeta.rowCosines : null;
const col = Array.isArray(planeMeta?.columnCosines) ? planeMeta.columnCosines : null;
const fromPlane = row && col ? `${formatVec(row)}|${formatVec(col)}` : "";
if (fromPlane) return fromPlane;
const iop = imageMeta?.imageOrientationPatient;
if (Array.isArray(iop) && iop.length >= 6) {
return `${formatVec(iop.slice(0, 3))}|${formatVec(iop.slice(3, 6))}`;
}
if (typeof iop === "string") {
const nums = parseNumericValueList(iop);
if (nums.length >= 6) {
return `${formatVec(nums.slice(0, 3))}|${formatVec(nums.slice(3, 6))}`;
}
}
return "ORI_UNKNOWN";
};
const frameOrderByBase = new Map<string, string[]>();
imageIds.forEach((id) => {
const base = stripWadouriPrefix(normalizeToWadouriOrExternal(id)).replace(/[?&]frame=\d+/i, "");
const arr = frameOrderByBase.get(base) || [];
arr.push(id);
frameOrderByBase.set(base, arr);
});
frameOrderByBase.forEach((arr, base) => {
arr.sort((a, b) => {
const fa = getFrameNumberFromImageId(a) || 1;
const fb = getFrameNumberFromImageId(b) || 1;
return fa - fb;
});
frameOrderByBase.set(base, arr);
});
const fallbackBByBase = new Map<string, number[]>();
await Promise.all(Array.from(frameOrderByBase.keys()).map(async (base) => {
const firstId = `wadouri:${base}`;
const values = await getFallbackBValuesForImageId(firstId);
fallbackBByBase.set(base, values);
}));
const getSliceKey = (id: string): string => {
const planeMeta = (metaData.get("imagePlaneModule", id) || {}) as any;
const ipp = Array.isArray(planeMeta?.imagePositionPatient) ? planeMeta.imagePositionPatient : null;
if (ipp && ipp.length >= 3) {
const nums = ipp.slice(0, 3).map((v: any) => Number(v));
if (nums.every((v: number) => Number.isFinite(v))) {
return `IPP:${nums.map((v: number) => v.toFixed(3)).join(",")}`;
}
}
const sliceLocation = Number(planeMeta?.sliceLocation);
if (Number.isFinite(sliceLocation)) {
return `SL:${sliceLocation.toFixed(3)}`;
}
return "";
};
type GroupCandidate = {
id: string;
seriesInstanceUID?: string;
seriesDescription?: string;
seriesNumber?: number;
instanceNumber?: number;
orientationKey: string;
sliceKey: string;
bValue: number | null;
ruleValues: Partial<Record<GroupingRuleId, string>>;
};
const candidates: GroupCandidate[] = imageIds.map((id) => {
const seriesMeta = (metaData.get("generalSeriesModule", id) || {}) as any;
const imageMeta = (metaData.get("generalImageModule", id) || {}) as any;
const mrMeta = (metaData.get("mrImageModule", id) || {}) as any;
const seriesInstanceUID = (seriesMeta.seriesInstanceUID || "").trim() || undefined;
const seriesDescription = (seriesMeta.seriesDescription || "").trim() || undefined;
const seriesNumber = Number(seriesMeta.seriesNumber);
const instanceNumber = Number(imageMeta.instanceNumber);
const orientationKey = getOrientationKey(id);
const sliceKey = getSliceKey(id);
let bValueRaw = Number(mrMeta.diffusionBValue ?? mrMeta.diffusionbValue ?? mrMeta.bValue);
let bValue = Number.isFinite(bValueRaw) ? bValueRaw : null;
if (bValue === null) {
const base = stripWadouriPrefix(normalizeToWadouriOrExternal(id)).replace(/[?&]frame=\d+/i, "");
const fallbackValues = fallbackBByBase.get(base) || [];
if (fallbackValues.length > 0) {
const orderedIds = frameOrderByBase.get(base) || [id];
const framePos = Math.max(0, orderedIds.indexOf(id));
const totalForBase = Math.max(1, orderedIds.length);
if (fallbackValues.length === 1) {
bValue = fallbackValues[0];
} else if (fallbackValues.length === totalForBase) {
bValue = fallbackValues[Math.min(fallbackValues.length - 1, framePos)];
} else if (fallbackValues.length === 2 && totalForBase % 2 === 0) {
bValue = framePos < totalForBase / 2 ? fallbackValues[0] : fallbackValues[1];
} else {
const bucket = Math.min(
fallbackValues.length - 1,
Math.floor((framePos / totalForBase) * fallbackValues.length)
);
bValue = fallbackValues[bucket];
}
}
}
const direction = parseDirection(
mrMeta.diffusionGradientDirection
?? mrMeta.diffusionGradientOrientation
?? mrMeta.diffusionGradientDirectionSequence
);
const temporalPositionValue = Number(imageMeta.temporalPositionIdentifier);
const triggerTimeValue = Number(imageMeta.triggerTime);
const contentTime = normalizeTimeValue(imageMeta.contentTime ?? imageMeta.acquisitionTime);
const echoTimeValue = Number(mrMeta.echoTime ?? imageMeta.echoTime);
const echoNumberValue = Number(mrMeta.echoNumbers ?? mrMeta.echoNumber ?? imageMeta.echoNumbers ?? imageMeta.echoNumber);
const ruleValues: Partial<Record<GroupingRuleId, string>> = {
dwiBValue: bValue !== null ? String(Math.round(bValue)) : "",
dwiDirection: direction,
temporalPosition: Number.isFinite(temporalPositionValue) ? String(Math.round(temporalPositionValue)) : "",
triggerTime: Number.isFinite(triggerTimeValue) ? String(Math.round(triggerTimeValue)) : "",
contentTime,
echoTime: Number.isFinite(echoTimeValue) ? echoTimeValue.toFixed(1) : "",
echoNumber: Number.isFinite(echoNumberValue) ? String(Math.round(echoNumberValue)) : "",
};
return {
id,
seriesInstanceUID,
seriesDescription,
seriesNumber: Number.isFinite(seriesNumber) ? seriesNumber : undefined,
instanceNumber: Number.isFinite(instanceNumber) ? instanceNumber : undefined,
orientationKey,
sliceKey,
bValue,
ruleValues,
};
});
const activeRuleIds: GroupingRuleId[] = advancedGroupingEnabled
? GROUPING_RULE_DEFINITIONS
.filter((rule) => groupingRuleEnabled[rule.id])
.map((rule) => rule.id)
.filter((ruleId) => {
const values = candidates
.map((candidate) => candidate.ruleValues[ruleId])
.filter((value): value is string => !!value);
const distinct = new Set(values);
// Only activate when:
// 1. Multiple distinct values (not constant across the series)
// 2. Not effectively unique per-image (would produce a group of 1 per image)
// 3. At least 70% of images have a value for this rule — avoids fragmenting when
// only a handful of images returned metadata (e.g., b-value fetch failed for most)
const coverage = values.length / candidates.length;
return distinct.size > 1
&& distinct.size < Math.max(2, candidates.length)
&& coverage >= 0.7;
})
: [];
// When metadata is only partially available, avoid splitting by base keys
// (series UID/orientation) because missing tags can create phantom groups.
const candidatesWithSeriesUid = candidates.filter((c) => !!c.seriesInstanceUID).length;
const hasMissingSeriesUid = candidatesWithSeriesUid < candidates.length;
const distinctSeriesUidCount = new Set(
candidates
.map((c) => c.seriesInstanceUID)
.filter((value): value is string => !!value)
).size;
const useSeriesUidInBaseKey = !hasMissingSeriesUid;
const candidatesWithOrientation = candidates.filter((c) => c.orientationKey !== "ORI_UNKNOWN").length;
const hasUnknownOrientation = candidatesWithOrientation < candidates.length;
const distinctOrientationCount = new Set(
candidates
.map((c) => c.orientationKey)
.filter((value) => value && value !== "ORI_UNKNOWN")
).size;
const useOrientationInBaseKey = !hasUnknownOrientation;
if (
(distinctSeriesUidCount > 1 && !useSeriesUidInBaseKey)
|| (distinctOrientationCount > 1 && !useOrientationInBaseKey)
) {
appLogger.debug(
"[deriveSeriesGroupsFromImageIds] partial metadata detected; suppressing base-key grouping",
{
candidates: candidates.length,
distinctSeriesUidCount,
distinctOrientationCount,
hasMissingSeriesUid,
hasUnknownOrientation,
}
);
}
const groups = new Map<string, {
imageIds: string[];
seriesInstanceUID?: string;
seriesDescription?: string;
seriesNumber?: number;
bValue?: number | null;
groupValues?: Partial<Record<GroupingRuleId, string>>;
}>();
candidates.forEach((candidate) => {
const baseKey = [
useSeriesUidInBaseKey ? (candidate.seriesInstanceUID || "NO_UID") : "SERIES_ANY",
useOrientationInBaseKey ? candidate.orientationKey : "ORI_ANY",
].join("|");
const advancedKey = activeRuleIds
.map((ruleId) => {
const value = candidate.ruleValues[ruleId];
return value ? `${ruleId}:${value}` : "";
})
.filter(Boolean)
.join("|");
const groupKey = advancedKey ? `${baseKey}|${advancedKey}` : baseKey;
if (!groups.has(groupKey)) {
const groupValues: Partial<Record<GroupingRuleId, string>> = {};
activeRuleIds.forEach((ruleId) => {
const value = candidate.ruleValues[ruleId];
if (value) groupValues[ruleId] = value;
});
groups.set(groupKey, {
imageIds: [],
seriesInstanceUID: candidate.seriesInstanceUID,
seriesDescription: candidate.seriesDescription,
seriesNumber: candidate.seriesNumber,
bValue: candidate.bValue,
groupValues,
});
}
groups.get(groupKey)!.imageIds.push(candidate.id);
});
if (groups.size <= 1) {
// Fallback for missing diffusion/temporal tags:
// If slices repeat uniformly (e.g. 56 images / 28 unique positions),
// split by the occurrence index per slice position.
if (activeRuleIds.length === 0 && candidates.length >= 4) {
const candidatesWithSliceKey = candidates.filter((c) => !!c.sliceKey).length;
const sliceKeyCoverage = candidatesWithSliceKey / candidates.length;
if (sliceKeyCoverage < 0.9) {
return [{ key: "SERIES_0", label: "Series 1", imageIds: dedupeImageIds(imageIds) }];
}
const sliceCounts = new Map<string, number>();
candidates.forEach((c) => {
if (!c.sliceKey) return;
sliceCounts.set(c.sliceKey, (sliceCounts.get(c.sliceKey) || 0) + 1);
});
const uniqueSliceCount = sliceCounts.size;
if (uniqueSliceCount >= 2 && uniqueSliceCount < candidates.length) {
const repeats = candidates.length / uniqueSliceCount;
const repeatsRounded = Math.round(repeats);
const hasCleanRepeats = Math.abs(repeats - repeatsRounded) < 1e-6 && repeatsRounded >= 2 && repeatsRounded <= 8;
const uniformPerSlice = hasCleanRepeats
&& Array.from(sliceCounts.values()).every((count) => count === repeatsRounded);
if (uniformPerSlice) {
const groupsByCycle = Array.from({ length: repeatsRounded }, () => [] as string[]);
const seenPerSlice = new Map<string, number>();
const orderedCandidates = [...candidates]
.map((c, idx) => ({ c, idx }))
.sort((a, b) => {
const ia = a.c.instanceNumber;
const ib = b.c.instanceNumber;
if (Number.isFinite(ia) && Number.isFinite(ib)) return (ia as number) - (ib as number);
return a.idx - b.idx;
});
orderedCandidates.forEach(({ c }) => {
if (!c.sliceKey) return;
const seen = seenPerSlice.get(c.sliceKey) || 0;
const cycle = Math.min(repeatsRounded - 1, seen);
groupsByCycle[cycle].push(c.id);
seenPerSlice.set(c.sliceKey, seen + 1);
});
const validGroups = groupsByCycle.filter((ids) => ids.length > 0);
if (validGroups.length > 1) {
return validGroups.map((ids, idx) => ({
key: `SERIES_FALLBACK_${idx}`,
label: `Series ${idx + 1}`,
imageIds: dedupeImageIds(ids),
}));
}
}
}
}
return [{ key: "SERIES_0", label: "Series 1", imageIds: dedupeImageIds(imageIds) }];
}
const ordered = Array.from(groups.entries()).map(([key, group], idx) => {
const details: string[] = [];
if (group.seriesDescription) details.push(group.seriesDescription);
if (group.seriesNumber !== undefined) details.push(`#${group.seriesNumber}`);
if (group.groupValues) {
GROUPING_RULE_DEFINITIONS.forEach((rule) => {
const value = group.groupValues?.[rule.id];
if (!value) return;
if (rule.id === "dwiBValue") details.push(`b=${value}`);
else if (rule.id === "dwiDirection") details.push(`dir=${value}`);
else if (rule.id === "temporalPosition") details.push(`tp=${value}`);
else if (rule.id === "triggerTime") details.push(`tr=${value}`);
else if (rule.id === "contentTime") details.push(`t=${value}`);
else if (rule.id === "echoTime") details.push(`TE=${value}`);
else if (rule.id === "echoNumber") details.push(`echo=${value}`);
});
}
if (details.length === 0 && group.bValue !== null && group.bValue !== undefined) {
details.push(`b=${Math.round(group.bValue)}`);
}
const label = details.length > 0 ? details.join(" • ") : `Series ${idx + 1}`;
return {
key,
label,
imageIds: dedupeImageIds(group.imageIds),
seriesInstanceUID: group.seriesInstanceUID,
bValue: group.bValue,
groupValues: group.groupValues,
};
});
return ordered;
}, [advancedGroupingEnabled, autoCacheStack, getFallbackBValuesForImageId, groupingRuleEnabled]);
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);
const getDropZoneFromPointer = useCallback((e: React.DragEvent<HTMLDivElement>): ViewportDropZone => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const w = rect.width;
const h = rect.height;
const edgeThreshold = Math.min(w, h) * 0.24;
const distances = [
{ zone: "left" as const, distance: x },
{ zone: "right" as const, distance: w - x },
{ zone: "top" as const, distance: y },
{ zone: "bottom" as const, distance: h - y },
];
distances.sort((a, b) => a.distance - b.distance);
return distances[0].distance <= edgeThreshold ? distances[0].zone : "center";
}, []);
const getDropZoneFromClientPoint = useCallback((rect: DOMRect, clientX: number, clientY: number): ViewportDropZone => {
const x = clientX - rect.left;
const y = clientY - rect.top;
const w = rect.width;
const h = rect.height;
const edgeThreshold = Math.min(w, h) * 0.24;
const distances = [
{ zone: "left" as const, distance: x },
{ zone: "right" as const, distance: w - x },
{ zone: "top" as const, distance: y },
{ zone: "bottom" as const, distance: h - y },
];
distances.sort((a, b) => a.distance - b.distance);
return distances[0].distance <= edgeThreshold ? distances[0].zone : "center";
}, []);
const resolveDropZone = useCallback((zone: ViewportDropZone): ViewportDropZone => {
if (zone === "left" || zone === "right") {
return viewportGrid.cols < 3 ? zone : "center";
}
if (zone === "top" || zone === "bottom") {
return viewportGrid.rows < 3 ? zone : "center";
}
return "center";
}, [viewportGrid]);
// 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,
modality: string,
orientation: string,
resolution: string,
spacing: string,
focalPoint: string,
frameIndex?: number,
totalFrames?: number,
}>>(() =>
Array(viewportGrid.rows * viewportGrid.cols).fill({
index: 0,
total: 0,
windowCenter: "",
windowWidth: "",
slicePosition: "",
modality: "",
orientation: "",
resolution: "",
spacing: "",
focalPoint: "",
})
);
const [viewedImageIds, setViewedImageIds] = useState<Record<string, true>>({});
const viewportStackSignaturesRef = useRef<string[]>(Array(MAX_VIEWPORTS).fill(""));
const defaultVoiRangeByViewportRef = useRef<Array<{ lower: number; upper: number } | null>>(Array(MAX_VIEWPORTS).fill(null));
const activePresetByViewportRef = useRef<Array<PresetOption | null>>(Array(MAX_VIEWPORTS).fill(null));
const lastRenderedImageIdByViewportRef = useRef<Array<string | null>>(Array(MAX_VIEWPORTS).fill(null));
const pendingSliceIndexByViewportRef = useRef<number[]>(Array(MAX_VIEWPORTS).fill(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)
}
}, [])
useEffect(() => {
const media = window.matchMedia("(max-width: 768px), (pointer: coarse)");
const update = () => {
const nextIsPhone = media.matches;
setIsPhoneLayout(nextIsPhone);
if (!nextIsPhone) {
setMobileInteractionActive(false);
setMobileFocusedViewport(null);
}
};
update();
if (typeof media.addEventListener === "function") {
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}
media.addListener(update);
return () => media.removeListener(update);
}, []);
const activateMobileInteractionForViewport = useCallback((viewportIdx: number) => {
if (!isPhoneLayout) return;
setActiveViewport(viewportIdx);
setMobileFocusedViewport(viewportIdx);
setMobileInteractionActive(true);
setMobileSingleViewportMode(true);
setStackPanelOpen(false);
const root = appRootRef.current;
if (root && document.fullscreenElement !== root) {
const request = (root.requestFullscreen
|| (root as any).webkitRequestFullscreen
|| (root as any).msRequestFullscreen) as (() => Promise<void> | void) | undefined;
if (request) {
try {
const result = request.call(root);
if (result && typeof (result as Promise<void>).catch === "function") {
(result as Promise<void>).catch(() => {
// Ignore fullscreen rejections (browser policy or user settings).
});
}
} catch (_) {
// Ignore fullscreen failures.
}
}
}
}, [isPhoneLayout]);
useEffect(() => {
if (!isPhoneLayout || !mobileInteractionActive || !mobileSingleViewportMode) return;
if (activeViewport === null) return;
setMobileFocusedViewport(activeViewport);
}, [activeViewport, isPhoneLayout, mobileInteractionActive, mobileSingleViewportMode]);
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));
const rawLogging = localStorage.getItem(LOG_SETTINGS_KEY);
if (rawLogging) {
const parsed = JSON.parse(rawLogging);
if (typeof parsed?.minLevel === "string") {
setLoggingMinLevel(parseLogLevel(parsed.minLevel));
} else if (typeof parsed?.debug === "boolean") {
// Backward compat: migrate old debug flag
setLoggingMinLevel(parsed.debug ? "debug" : "warn");
}
if (typeof parsed?.format === "string") {
setLoggingFormat(parseLogFormat(parsed.format));
}
}
const rawGrouping = localStorage.getItem(GROUPING_SETTINGS_KEY);
if (rawGrouping) {
const parsedGrouping = JSON.parse(rawGrouping);
if (typeof parsedGrouping?.advancedGroupingEnabled === "boolean") {
setAdvancedGroupingEnabled(parsedGrouping.advancedGroupingEnabled);
}
if (parsedGrouping?.groupingRuleEnabled && typeof parsedGrouping.groupingRuleEnabled === "object") {
const merged = createDefaultGroupingRuleState();
GROUPING_RULE_DEFINITIONS.forEach((rule) => {
if (typeof parsedGrouping.groupingRuleEnabled[rule.id] === "boolean") {
merged[rule.id] = parsedGrouping.groupingRuleEnabled[rule.id];
}
});
setGroupingRuleEnabled(merged);
}
}
} 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(() => {
setLoggerConfig({
minLevel: loggingMinLevel,
format: loggingFormat,
});
try {
localStorage.setItem(
LOG_SETTINGS_KEY,
JSON.stringify({
minLevel: loggingMinLevel,
format: loggingFormat,
})
);
} catch (e) {
// Ignore storage access errors
}
}, [loggingMinLevel, loggingFormat]);
useEffect(() => {
try {
localStorage.setItem(
GROUPING_SETTINGS_KEY,
JSON.stringify({
advancedGroupingEnabled,
groupingRuleEnabled,
})
);
} catch (e) {
// Ignore storage access errors
}
}, [advancedGroupingEnabled, groupingRuleEnabled]);
useEffect(() => {
if (availableStacks.length === 0) return;
let cancelled = false;
const recomputeGroups = async () => {
const recomputed = await Promise.all(
availableStacks.map(async (stack) => {
if (stack.groupingSource === "declared" && (stack.seriesGroups || []).length > 0) {
return stack;
}
const seriesGroups = await deriveSeriesGroupsFromImageIds(stack.imageIds);
return { ...stack, seriesGroups, groupingSource: "derived" as const };
})
);
if (cancelled) return;
setAvailableStacks(recomputed);
setSeriesGroupsByViewport((prev) => {
const next = [...prev];
for (let viewportIdx = 0; viewportIdx < MAX_VIEWPORTS; viewportIdx++) {
const stackIdx = selectedStackIdx[viewportIdx] ?? 0;
next[viewportIdx] = recomputed[stackIdx]?.seriesGroups || [];
}
return next;
});
setSelectedSeriesIdxByViewport((prev) => {
const next = [...prev];
for (let viewportIdx = 0; viewportIdx < MAX_VIEWPORTS; viewportIdx++) {
const stackIdx = selectedStackIdx[viewportIdx] ?? 0;
const groups = recomputed[stackIdx]?.seriesGroups || [];
const current = next[viewportIdx] ?? 0;
next[viewportIdx] = groups.length === 0 ? 0 : Math.max(0, Math.min(groups.length - 1, current));
}
return next;
});
setViewportImageIds((prev) => {
const next = [...prev];
for (let viewportIdx = 0; viewportIdx < MAX_VIEWPORTS; viewportIdx++) {
const stackIdx = selectedStackIdx[viewportIdx] ?? 0;
const groups = recomputed[stackIdx]?.seriesGroups || [];
const selectedIdx = Math.max(0, Math.min(groups.length - 1, selectedSeriesIdxByViewport[viewportIdx] ?? 0));
const imageIds = groups[selectedIdx]?.imageIds || recomputed[stackIdx]?.imageIds || [];
next[viewportIdx] = imageIds;
}
return next;
});
};
void recomputeGroups();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [advancedGroupingEnabled, groupingRuleEnabled]);
useEffect(() => {
window.loadDicomStackFromFiles = (files: FileList | File[]) => {
const fileArray = Array.from(files);
handleFilesSelected({ target: { files: fileArray } } as any);
};
return () => {
window.loadDicomStackFromFiles = undefined;
};
}, []);
// Clean up external drag state if the drag ends outside any viewer viewport.
useEffect(() => {
const onDocDragEnd = () => {
setExternalDragActive(false);
setDropPreview(null);
setDraggedStackIdx(null);
};
document.addEventListener('dragend', onDocDragEnd);
return () => document.removeEventListener('dragend', onDocDragEnd);
}, []);
const [altPressed, setAltPressed] = useState(false);
const [shiftPressed, setShiftPressed] = 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);
if (e.shiftKey) setShiftPressed(true);
};
const handleKeyUp = (e: KeyboardEvent) => {
if (!e.altKey) setAltPressed(false);
if (!e.shiftKey) setShiftPressed(false);
};
const handleBlur = () => {
setAltPressed(false);
setShiftPressed(false);
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
window.addEventListener("blur", handleBlur);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("blur", handleBlur);
};
}, []);
useEffect(() => {
const mainToolGroup = ToolGroupManager.getToolGroup(MAIN_TOOL_GROUP_ID);
if (!mainToolGroup) return;
const modifierActive = shiftPressed || (altEnablesReferenceCursors && altPressed);
if (modifierActive) {
// Force metadata warm-up for visible stack viewports so nearest-slice lookup
// can consider the entire stack (including images not yet viewed/cached).
const visibleCount = viewportGrid.rows * viewportGrid.cols;
const visibleStackImageIds = viewportImageIds
.slice(0, visibleCount)
.filter((ids, idx) => viewportModes[idx] === 'stack' && Array.isArray(ids) && ids.length > 0);
visibleStackImageIds.forEach((ids) => {
setLoadedImageIdsAndCacheMeta(ids, { force: true }).catch((err) => {
appLogger.warn('Reference cursor metadata warm-up failed:', err);
});
});
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,
shiftPressed,
altEnablesReferenceCursors,
viewportGrid.rows,
viewportGrid.cols,
viewportImageIds,
viewportModes,
setLoadedImageIdsAndCacheMeta,
]);
// 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 });
});
// Add touch bindings so tools are usable on phones/tablets.
const primaryTool = mouseToolBindings.Primary;
if (primaryTool) {
const primaryBindings = [
...(toolBindings[primaryTool] || []),
{ numTouchPoints: 1 },
];
toolGroup.setToolActive(primaryTool, { bindings: primaryBindings });
}
const panBindings = [
...(toolBindings[PanTool.toolName] || []),
{ numTouchPoints: 2 },
];
toolGroup.setToolActive(PanTool.toolName, { bindings: panBindings });
// Also update external image handlers (pan/zoom) to respect selected tools
try {
const primaryTool = mouseToolBindings.Primary;
const wheelTool = mouseToolBindings.Wheel;
const auxTool = mouseToolBindings.Auxiliary;
// Determine whether pan/zoom should be enabled for primary/wheel interactions
const allowPan = primaryTool === PanTool.toolName || auxTool === PanTool.toolName;
const allowZoom = primaryTool === ZoomTool.toolName || wheelTool === ZoomTool.toolName || mouseToolBindings.Primary_and_Secondary === ZoomTool.toolName;
// Iterate viewports and update external handlers
viewportRefs.current.forEach((el) => {
if (!el) return;
const ext = (el as any)._externalPanZoom;
if (ext) {
if (typeof ext.setAllowPan === 'function') ext.setAllowPan(!!allowPan);
if (typeof ext.setAllowZoom === 'function') ext.setAllowZoom(!!allowZoom);
}
});
} catch (e) {
// ignore
}
//// 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);
const [folderLoadMode, setFolderLoadMode] = useState<'add' | 'replace'>('add');
const folderInputRef = useRef<HTMLInputElement | null>(null);
const beginStackPanelResize = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
if (!stackPanelOpen) return;
event.preventDefault();
event.stopPropagation();
stackPanelResizeRef.current = {
resizing: true,
startX: event.clientX,
startWidth: stackPanelWidth,
};
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}, [stackPanelOpen, stackPanelWidth]);
useEffect(() => {
const onMouseMove = (event: MouseEvent) => {
if (!stackPanelResizeRef.current.resizing) return;
const delta = stackPanelResizeRef.current.startX - event.clientX;
const nextWidth = Math.max(
STACK_PANEL_MIN_WIDTH,
Math.min(STACK_PANEL_MAX_WIDTH, stackPanelResizeRef.current.startWidth + delta)
);
setStackPanelWidth(nextWidth);
};
const onMouseUp = () => {
if (!stackPanelResizeRef.current.resizing) return;
stackPanelResizeRef.current.resizing = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
return () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
}, []);
const sideMenuButtonLeft = sideMenuOpen
? SIDE_MENU_WIDTH - (SIDE_MENU_BUTTON_SIZE / 2)
: -SIDE_MENU_BUTTON_SIZE + SIDE_MENU_BUTTON_PEEK;
useEffect(() => {
// Some browsers don't accept non-standard props via JSX typings; set attributes directly.
try {
if (folderInputRef.current) {
folderInputRef.current.setAttribute('webkitdirectory', 'true');
folderInputRef.current.setAttribute('directory', '');
folderInputRef.current.setAttribute('mozdirectory', '');
}
} catch (e) {
// ignore
}
}, []);
// Recursively collect files from a DirectoryHandle (File System Access API)
async function collectFilesFromDirectoryHandle(dirHandle: any, pathPrefix = ''): Promise<File[]> {
const files: File[] = [];
// Prefer entries() which yields [name, handle] pairs — more explicit
const entriesIter = (dirHandle as any).entries ? (dirHandle as any).entries() : (dirHandle as any).values();
for await (const entryPair of entriesIter) {
try {
// entryPair may be [name, handle] or a handle directly
let entry: any;
let entryName: string | undefined;
if (Array.isArray(entryPair) && entryPair.length >= 2) {
entryName = entryPair[0];
entry = entryPair[1];
} else {
entry = entryPair;
entryName = entry.name;
}
if (!entry) continue;
if (entry.kind === 'file') {
const f = await entry.getFile();
try {
Object.defineProperty(f, 'webkitRelativePath', { value: pathPrefix + (entryName || entry.name), configurable: true });
} catch (e) {
// ignore if not possible
}
files.push(f);
} else if (entry.kind === 'directory') {
// Recurse into subdirectory
const nested = await collectFilesFromDirectoryHandle(entry, pathPrefix + (entryName || entry.name) + '/');
files.push(...nested);
}
} catch (e) {
// ignore individual entry errors
appLogger.debug('collectFilesFromDirectoryHandle: skipping entry due to error', e);
}
}
return files;
}
// Try to open the native directory picker when available, otherwise fall back to the hidden input
const handleFolderPick = async () => {
if ((window as any).showDirectoryPicker) {
try {
const dirHandle = await (window as any).showDirectoryPicker();
const files = await collectFilesFromDirectoryHandle(dirHandle);
if (files && files.length > 0) {
// Call existing file handler with our array
await handleFilesSelected({ target: { files } } as any);
} else {
alert('No files found in selected folder.');
}
return;
} catch (e) {
appLogger.warn('showDirectoryPicker failed or cancelled', e);
// fallthrough to input click
}
}
// Fallback: click the folder input (webkitdirectory attribute should be set at runtime)
folderInputRef.current?.click();
};
// 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));
// Parse each file to extract DICOM tags for grouping (SeriesInstanceUID / StudyInstanceUID)
const parsedEntries = await Promise.all(fileArray.map(async (file) => {
const isImage = file.type && file.type.startsWith('image/');
let studyInstanceUID: string | undefined = undefined;
let seriesInstanceUID: string | undefined = undefined;
let instanceNumber: number | undefined = undefined;
let sliceLocation: number | undefined = undefined;
let numberOfFrames = 1;
let parsedSuccessfully = false;
if (!isImage) {
try {
const arrayBuffer = await file.arrayBuffer();
const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
studyInstanceUID = dataSet.string('x0020000d');
seriesInstanceUID = dataSet.string('x0020000e');
const instStr = dataSet.string('x00200013');
if (instStr !== undefined && instStr !== null && instStr !== '') instanceNumber = parseInt(instStr as string, 10);
const sliceStr = dataSet.string('x00201041') || dataSet.string('x00201040');
if (sliceStr !== undefined && sliceStr !== null && sliceStr !== '') sliceLocation = parseFloat(sliceStr as string);
const frameStr = dataSet.string('x00280008');
if (frameStr !== undefined && frameStr !== null && frameStr !== '') {
const parsedFrames = Number(frameStr);
if (Number.isFinite(parsedFrames) && parsedFrames > 1) {
numberOfFrames = Math.floor(parsedFrames);
}
}
parsedSuccessfully = true;
} catch (e) {
// Not a DICOM or failed to parse
parsedSuccessfully = false;
}
}
const imageId = isImage ? `external:${URL.createObjectURL(file)}` : `wadouri:${URL.createObjectURL(file)}`;
return { file, imageId, studyInstanceUID, seriesInstanceUID, instanceNumber, sliceLocation, numberOfFrames, isImage, parsedSuccessfully };
}));
// Group parsed entries by SeriesInstanceUID (fallback to StudyInstanceUID or filename folder)
const groups: Map<string, any> = new Map();
for (const entry of parsedEntries) {
// Skip files that are neither images nor successfully parsed DICOMs
if (!entry.isImage && !entry.parsedSuccessfully) {
appLogger.debug('Skipping non-DICOM/non-image file:', entry.file.name);
continue;
}
const key = entry.seriesInstanceUID || entry.studyInstanceUID || (entry.file.webkitRelativePath ? entry.file.webkitRelativePath.split('/')[0] : entry.file.name);
if (!groups.has(key)) {
groups.set(key, { entries: [], studyInstanceUID: entry.studyInstanceUID, seriesInstanceUID: entry.seriesInstanceUID });
}
groups.get(key).entries.push(entry);
}
// Build stacks from groups, sorting within each group by requested order
const stacks: Array<any> = [];
for (const [key, g] of groups.entries()) {
const entries = g.entries as Array<any>;
// Sort: prefer sliceLocation (if ordering requested), then instanceNumber, then filename
entries.sort((a, b) => {
if (orderBySliceLocation && a.sliceLocation !== undefined && b.sliceLocation !== undefined) return (a.sliceLocation - b.sliceLocation);
if (a.instanceNumber !== undefined && b.instanceNumber !== undefined) return (a.instanceNumber - b.instanceNumber);
return (a.file.webkitRelativePath || a.file.name).localeCompare(b.file.webkitRelativePath || b.file.name);
});
const imageIds = entries.flatMap((e) => {
if (e.isImage || !Number.isFinite(e.numberOfFrames) || e.numberOfFrames <= 1) {
return [e.imageId];
}
const frames: string[] = [];
for (let frame = 1; frame <= e.numberOfFrames; frame++) {
frames.push(withFrameQuery(e.imageId, frame));
}
return frames;
});
const seriesGroups = await deriveSeriesGroupsFromImageIds(imageIds);
stacks.push({
imageIds,
studyInstanceUID: g.studyInstanceUID,
seriesInstanceUID: g.seriesInstanceUID,
seriesGroups,
});
}
if (stacks.length === 0) return; // nothing to do
if (folderLoadMode === 'replace') {
// Replace everything currently loaded with this new stack
try {
const renderingEngine = renderingEngineRef.current;
if (renderingEngine) {
for (let i = 0; i < MAX_VIEWPORTS; i++) {
const viewportId = `CT_${i}`;
try {
if (renderingEngine.getViewport(viewportId)) {
renderingEngine.disableElement(viewportId);
}
} catch (e) { /* ignore */ }
try {
const el = viewportRefs.current[i];
if (el) {
try { if ((el as any)._externalPanZoom && (el as any)._externalPanZoom.cleanup) (el as any)._externalPanZoom.cleanup(); } catch (e) {}
try { if ((el as any)._resizeObserver) { (el as any)._resizeObserver.disconnect(); (el as any)._resizeObserver = undefined; } } catch (e) {}
el.innerHTML = '';
(el as any)._cornerstoneEnabled = false;
(el as any)._lastViewportType = undefined;
}
} catch (e) { /* ignore */ }
}
}
} catch (e) { /* ignore */ }
// Replace with the grouped stacks
setAvailableStacks(stacks);
// Populate viewports with the first N stacks (repeat first if fewer stacks than viewports)
setViewportImageIds(() => {
const arr = Array(MAX_VIEWPORTS).fill([] as string[]);
for (let i = 0; i < MAX_VIEWPORTS; i++) {
const stackForViewport = stacks[i] || stacks[0];
const firstSeries = stackForViewport?.seriesGroups?.[0]?.imageIds || stackForViewport?.imageIds || [];
arr[i] = firstSeries;
}
return arr;
});
setSelectedStackIdx(Array(MAX_VIEWPORTS).fill(0));
setSeriesGroupsByViewport(() => {
const arr = Array(MAX_VIEWPORTS).fill(null).map(() => [] as StackSeriesGroup[]);
for (let i = 0; i < MAX_VIEWPORTS; i++) {
const stackForViewport = stacks[i] || stacks[0];
arr[i] = stackForViewport?.seriesGroups || [];
}
return arr;
});
setSelectedSeriesIdxByViewport(Array(MAX_VIEWPORTS).fill(0));
// Cache metadata for all stacks (fire-and-forget)
for (const s of stacks) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
setLoadedImageIdsAndCacheMeta(s.imageIds);
}
} else {
// Append (add) all grouped stacks
setAvailableStacks(prev => {
const next = [...prev, ...stacks];
// set first viewport to the last appended stack
setViewportImageIds(vpPrev => {
const vpNext = [...vpPrev];
const latest = stacks[stacks.length - 1];
vpNext[0] = latest.seriesGroups?.[0]?.imageIds || latest.imageIds;
return vpNext;
});
setSelectedStackIdx(selPrev => {
const selNext = [...selPrev];
selNext[0] = next.length - 1;
return selNext;
});
setSeriesGroupsByViewport((prevSeries) => {
const nextSeries = [...prevSeries];
nextSeries[0] = stacks[stacks.length - 1]?.seriesGroups || [];
return nextSeries;
});
setSelectedSeriesIdxByViewport((prevIdx) => {
const nextIdx = [...prevIdx];
nextIdx[0] = 0;
return nextIdx;
});
// Cache metadata for appended stacks
for (const s of stacks) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
setLoadedImageIdsAndCacheMeta(s.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 = "";
let modality = "";
let orientation = "";
let resolution = "";
let spacing = "";
let focalPoint = "";
let frameIndex = undefined;
let totalFrames = undefined;
if (viewportModes[viewportIdx] === "stack") {
currentIndex = typeof viewport.getCurrentImageIdIndex === "function"
? viewport.getCurrentImageIdIndex()
: 0;
const imageIds = typeof viewport.getImageIds === "function"
? viewport.getImageIds()
: [];
total = imageIds.length;
const currentImageId = imageIds[currentIndex];
if (currentImageId) {
appLogger.debug("[updateOverlay] Current image:", currentImageId.slice(0, 80), "frameIndex status:", frameIndex, "totalFrames:", totalFrames);
// Detect frame index from imageId query
if (currentImageId.includes('&frame=')) {
const match = currentImageId.match(/&frame=(\d+)/);
if (match) {
frameIndex = parseInt(match[1], 10);
appLogger.debug("[updateOverlay] Frame detected:", frameIndex);
// Try to get total frames from metadata
const baseImageId = currentImageId.split('&frame=')[0];
const multiframeMeta = metaData.get("multiframeModule", baseImageId) as any;
if (multiframeMeta?.numberOfFrames) {
totalFrames = multiframeMeta.numberOfFrames;
appLogger.debug("[updateOverlay] Total frames from metadata:", totalFrames);
} else {
// Fallback: count unique frame indices in imageIds
const frameNumbers = imageIds.map(id => {
const m = id.match(/&frame=(\d+)/);
return m ? parseInt(m[1], 10) : null;
}).filter(n => n !== null && typeof n === 'number');
if (frameNumbers.length > 0) {
totalFrames = Math.max(...frameNumbers as number[]);
appLogger.debug("[updateOverlay] Total frames from imageIds:", totalFrames);
}
}
}
} else {
appLogger.debug("[updateOverlay] No frame query in imageId. imageIds.length:", imageIds.length);
}
const seriesMeta = metaData.get("generalSeriesModule", currentImageId) as any;
const planeMeta = metaData.get("imagePlaneModule", currentImageId) as any;
const pixelMeta = metaData.get("imagePixelModule", currentImageId) as any;
modality = seriesMeta?.modality || "";
if (pixelMeta?.rows && pixelMeta?.columns) {
resolution = `${pixelMeta.columns} x ${pixelMeta.rows}`;
}
const spacingParts = [
planeMeta?.columnPixelSpacing,
planeMeta?.rowPixelSpacing,
planeMeta?.sliceThickness,
].filter((value) => value !== undefined && value !== null && value !== "");
if (spacingParts.length > 0) {
spacing = spacingParts.join(" x ");
}
}
if (typeof viewport.getCamera === "function") {
const camera = viewport.getCamera();
if (camera?.focalPoint && Array.isArray(camera.focalPoint)) {
focalPoint = camera.focalPoint.map((value: number) => value.toFixed(1)).join(", ");
}
}
if (currentImageId) {
setViewedImageIds((prev) => {
if (prev[currentImageId]) return prev;
return { ...prev, [currentImageId]: true };
});
}
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);
if (defaultVoiRangeByViewportRef.current[viewportIdx] === null) {
defaultVoiRangeByViewportRef.current[viewportIdx] = { lower, upper };
}
}
}
} 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);
if (defaultVoiRangeByViewportRef.current[viewportIdx] === null) {
defaultVoiRangeByViewportRef.current[viewportIdx] = { lower, upper };
}
}
}
// 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";
}
}
const firstImageId = viewportImageIds[viewportIdx]?.[0];
if (firstImageId) {
const seriesMeta = metaData.get("generalSeriesModule", firstImageId) as any;
const planeMeta = metaData.get("imagePlaneModule", firstImageId) as any;
const pixelMeta = metaData.get("imagePixelModule", firstImageId) as any;
modality = seriesMeta?.modality || "";
orientation = String(viewport.viewportProperties?.orientation || "").toUpperCase();
if (pixelMeta?.rows && pixelMeta?.columns) {
resolution = `${pixelMeta.columns} x ${pixelMeta.rows}`;
}
const spacingParts = [
planeMeta?.columnPixelSpacing,
planeMeta?.rowPixelSpacing,
planeMeta?.sliceThickness,
].filter((value) => value !== undefined && value !== null && value !== "");
if (spacingParts.length > 0) {
spacing = spacingParts.join(" x ");
}
}
if (typeof viewport.getCamera === "function") {
const camera = viewport.getCamera();
if (camera?.focalPoint && Array.isArray(camera.focalPoint)) {
focalPoint = camera.focalPoint.map((value: number) => value.toFixed(1)).join(", ");
}
}
}
setImageInfos(prev => {
const next = [...prev];
next[viewportIdx] = {
index: currentIndex + 1,
total,
windowCenter,
windowWidth,
slicePosition, // add this field
modality,
orientation,
resolution,
spacing,
focalPoint,
frameIndex,
totalFrames,
};
return next;
});
}, [viewportModes, viewportImageIds]);
useEffect(() => {
for (let i = 0; i < MAX_VIEWPORTS; i++) {
const ids = viewportImageIds[i] || [];
const signature = `${ids.length}:${ids[0] || ""}:${ids[ids.length - 1] || ""}`;
if (viewportStackSignaturesRef.current[i] !== signature) {
viewportStackSignaturesRef.current[i] = signature;
defaultVoiRangeByViewportRef.current[i] = null;
lastRenderedImageIdByViewportRef.current[i] = null;
}
}
}, [viewportImageIds]);
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();
}
}
}
}
/**
* Load an external stack (from outside the viewer, e.g. a series block dragged
* from the page) into the given viewport, optionally splitting the grid.
* The stack is added to availableStacks so it appears in the side panel.
*/
const applyExternalDrop = async (
viewportIdx: number,
rawImageIds: string[],
name: string,
requestedZone: ViewportDropZone,
) => {
const normalizedRawImageIds = dedupeImageIds(rawImageIds.map(normalizeToWadouriOrExternal));
if (normalizedRawImageIds.length === 0) return;
const existingStackIdx = availableStacks.findIndex((stackObj) => {
const existing = dedupeImageIds((stackObj.imageIds || []).map(normalizeToWadouriOrExternal));
return (
existing.length === normalizedRawImageIds.length
&& existing.every((id, idx) => id === normalizedRawImageIds[idx])
);
});
if (existingStackIdx !== -1) {
// Reuse existing stack definitions whenever possible to avoid expensive
// expansion/probing and unnecessary network requests.
await applyStackDrop(viewportIdx, existingStackIdx, requestedZone);
setActiveViewport(viewportIdx);
return;
}
const imageIds = await expandMultiframeImageIds(normalizedRawImageIds);
if (imageIds.length === 0) return;
await setLoadedImageIdsAndCacheMeta(imageIds, { force: Boolean(autoCacheStack) });
const seriesGroups = await deriveSeriesGroupsFromImageIds(imageIds);
const primarySeries = seriesGroups[0]?.imageIds || imageIds;
let newStackIdx = -1;
setAvailableStacks(prev => {
newStackIdx = prev.length;
return [...prev, { imageIds, name, seriesGroups, groupingSource: "derived" }];
});
const stackIdxToUse = newStackIdx;
const resolvedZone = resolveDropZone(requestedZone);
if (resolvedZone === 'center') {
setLoadingState('displayset');
try {
setViewportModes(prev => { const n = [...prev]; n[viewportIdx] = 'stack'; return n; });
setViewportImageIds(prev => { const n = [...prev]; n[viewportIdx] = primarySeries; return n; });
setSelectedStackIdx(prev => { const n = [...prev]; n[viewportIdx] = stackIdxToUse; return n; });
setSeriesGroupsByViewport(prev => {
const n = [...prev];
n[viewportIdx] = seriesGroups;
return n;
});
setSelectedSeriesIdxByViewport(prev => {
const n = [...prev];
n[viewportIdx] = 0;
return n;
});
setActiveViewport(viewportIdx);
await setLoadedImageIdsAndCacheMeta(primarySeries);
} finally {
setLoadingState(null);
}
return;
}
const oldRows = viewportGrid.rows;
const oldCols = viewportGrid.cols;
const row = Math.floor(viewportIdx / oldCols);
const col = viewportIdx % oldCols;
const newRows = (resolvedZone === 'top' || resolvedZone === 'bottom') ? oldRows + 1 : oldRows;
const newCols = (resolvedZone === 'left' || resolvedZone === 'right') ? oldCols + 1 : oldCols;
if (newRows > 3 || newCols > 3 || newRows * newCols > MAX_VIEWPORTS) {
// Grid already full — just load into the target viewport.
setLoadingState('displayset');
try {
setViewportModes(prev => { const n = [...prev]; n[viewportIdx] = 'stack'; return n; });
setViewportImageIds(prev => { const n = [...prev]; n[viewportIdx] = imageIds; return n; });
setSelectedStackIdx(prev => { const n = [...prev]; n[viewportIdx] = stackIdxToUse; return n; });
setActiveViewport(viewportIdx);
await setLoadedImageIdsAndCacheMeta(imageIds);
} finally {
setLoadingState(null);
}
return;
}
const targetNewRow = resolvedZone === 'top' ? row : resolvedZone === 'bottom' ? row + 1 : row;
const targetNewCol = resolvedZone === 'left' ? col : resolvedZone === 'right' ? col + 1 : col;
const insertedViewportIndex = targetNewRow * newCols + targetNewCol;
const nextImageIds = Array(MAX_VIEWPORTS).fill([] as string[]);
// If you use newStackIdx below, ensure it is only used if a new stack was added
const nextModes = Array(MAX_VIEWPORTS).fill('stack') as Array<'stack' | 'volume'>;
const nextSelected = Array(MAX_VIEWPORTS).fill(0);
const nextSeriesGroups = Array(MAX_VIEWPORTS).fill(null).map(() => [] as StackSeriesGroup[]);
const nextSeriesIdx = Array(MAX_VIEWPORTS).fill(0);
for (let oldIdx = 0; oldIdx < oldRows * oldCols; oldIdx++) {
const oldRow = Math.floor(oldIdx / oldCols);
const oldCol = oldIdx % oldCols;
let mappedRow = oldRow;
let mappedCol = oldCol;
if (resolvedZone === 'left' || resolvedZone === 'right') {
if (oldRow === row) {
mappedCol = oldCol < col ? oldCol : oldCol > col ? oldCol + 1 : (resolvedZone === 'left' ? oldCol + 1 : oldCol);
}
}
if (resolvedZone === 'top' || resolvedZone === 'bottom') {
if (oldCol === col) {
mappedRow = oldRow < row ? oldRow : oldRow > row ? oldRow + 1 : (resolvedZone === 'top' ? oldRow + 1 : oldRow);
}
}
const mappedIdx = mappedRow * newCols + mappedCol;
nextImageIds[mappedIdx] = viewportImageIds[oldIdx] || [];
nextModes[mappedIdx] = viewportModes[oldIdx] || 'stack';
nextSelected[mappedIdx] = selectedStackIdx[oldIdx] ?? 0;
nextSeriesGroups[mappedIdx] = seriesGroupsByViewport[oldIdx] || [];
nextSeriesIdx[mappedIdx] = selectedSeriesIdxByViewport[oldIdx] ?? 0;
}
nextImageIds[insertedViewportIndex] = primarySeries;
nextModes[insertedViewportIndex] = 'stack';
nextSelected[insertedViewportIndex] = newStackIdx;
nextSeriesGroups[insertedViewportIndex] = seriesGroups;
nextSeriesIdx[insertedViewportIndex] = 0;
setLoadingState('displayset');
try {
saveVisibleViewportState();
setViewportGrid({ rows: newRows, cols: newCols });
setViewportImageIds(nextImageIds);
setViewportModes(nextModes);
setSelectedStackIdx(nextSelected);
setSeriesGroupsByViewport(nextSeriesGroups);
setSelectedSeriesIdxByViewport(nextSeriesIdx);
setActiveViewport(insertedViewportIndex);
await setLoadedImageIdsAndCacheMeta(primarySeries);
} finally {
setLoadingState(null);
}
};
const applyStackDrop = async (viewportIdx: number, stackIdx: number, requestedZone: ViewportDropZone) => {
const stackObj = availableStacks[stackIdx];
if (!stackObj) return;
let stackImageIds = stackObj.imageIds || [];
const hasDeclaredGroups = stackObj.groupingSource === "declared" && (stackObj.seriesGroups || []).length > 0;
const shouldExpandMultiframe = !(hasDeclaredGroups && stackImageIds.length > 1);
// IMPORTANT: Expand multiframe images BEFORE caching metadata
const expandedImageIds = shouldExpandMultiframe
? await expandMultiframeImageIds(stackImageIds)
: dedupeImageIds((stackImageIds || []).map(normalizeToWadouriOrExternal));
stackImageIds = expandedImageIds;
await setLoadedImageIdsAndCacheMeta(stackImageIds, { force: Boolean(autoCacheStack) });
const seriesGroups = hasDeclaredGroups
? (stackObj.seriesGroups || [])
: await deriveSeriesGroupsFromImageIds(stackImageIds);
setAvailableStacks(prev => {
const next = [...prev];
if (!next[stackIdx]) return prev;
next[stackIdx] = {
...next[stackIdx],
seriesGroups,
imageIds: stackImageIds,
groupingSource: hasDeclaredGroups ? "declared" : "derived",
};
return next;
});
const primarySeries = seriesGroups[0]?.imageIds || stackImageIds;
const resolvedZone = resolveDropZone(requestedZone);
if (resolvedZone === "center") {
await handleLoadStack(viewportIdx, stackIdx);
setActiveViewport(viewportIdx);
return;
}
const oldRows = viewportGrid.rows;
const oldCols = viewportGrid.cols;
const row = Math.floor(viewportIdx / oldCols);
const col = viewportIdx % oldCols;
const newRows = (resolvedZone === "top" || resolvedZone === "bottom") ? oldRows + 1 : oldRows;
const newCols = (resolvedZone === "left" || resolvedZone === "right") ? oldCols + 1 : oldCols;
if (newRows > 3 || newCols > 3 || newRows * newCols > MAX_VIEWPORTS) {
await handleLoadStack(viewportIdx, stackIdx);
setActiveViewport(viewportIdx);
return;
}
const nextImageIds = Array(MAX_VIEWPORTS).fill([] as string[]);
const nextModes = Array(MAX_VIEWPORTS).fill("stack") as Array<'stack' | 'volume'>;
const nextSelected = Array(MAX_VIEWPORTS).fill(0);
const nextSeriesGroups = Array(MAX_VIEWPORTS).fill(null).map(() => [] as StackSeriesGroup[]);
const nextSeriesIdx = Array(MAX_VIEWPORTS).fill(0);
const targetNewRow = resolvedZone === "top" ? row : resolvedZone === "bottom" ? row + 1 : row;
const targetNewCol = resolvedZone === "left" ? col : resolvedZone === "right" ? col + 1 : col;
const insertedViewportIndex = targetNewRow * newCols + targetNewCol;
for (let oldIdx = 0; oldIdx < oldRows * oldCols; oldIdx++) {
const oldRow = Math.floor(oldIdx / oldCols);
const oldCol = oldIdx % oldCols;
let mappedRow = oldRow;
let mappedCol = oldCol;
if (resolvedZone === "left" || resolvedZone === "right") {
if (oldRow === row) {
if (oldCol < col) {
mappedCol = oldCol;
} else if (oldCol > col) {
mappedCol = oldCol + 1;
} else {
mappedCol = resolvedZone === "left" ? oldCol + 1 : oldCol;
}
}
}
if (resolvedZone === "top" || resolvedZone === "bottom") {
if (oldCol === col) {
if (oldRow < row) {
mappedRow = oldRow;
} else if (oldRow > row) {
mappedRow = oldRow + 1;
} else {
mappedRow = resolvedZone === "top" ? oldRow + 1 : oldRow;
}
}
}
const mappedIdx = mappedRow * newCols + mappedCol;
nextImageIds[mappedIdx] = viewportImageIds[oldIdx] || [];
nextModes[mappedIdx] = viewportModes[oldIdx] || "stack";
nextSelected[mappedIdx] = selectedStackIdx[oldIdx] ?? 0;
nextSeriesGroups[mappedIdx] = seriesGroupsByViewport[oldIdx] || [];
nextSeriesIdx[mappedIdx] = selectedSeriesIdxByViewport[oldIdx] ?? 0;
}
nextImageIds[insertedViewportIndex] = primarySeries;
nextModes[insertedViewportIndex] = "stack";
nextSelected[insertedViewportIndex] = stackIdx;
nextSeriesGroups[insertedViewportIndex] = seriesGroups || [];
nextSeriesIdx[insertedViewportIndex] = 0;
setLoadingState("displayset");
try {
saveVisibleViewportState();
setViewportGrid({ rows: newRows, cols: newCols });
setViewportImageIds(nextImageIds);
setViewportModes(nextModes);
setSelectedStackIdx(nextSelected);
setSeriesGroupsByViewport(nextSeriesGroups);
setSelectedSeriesIdxByViewport(nextSeriesIdx);
setActiveViewport(insertedViewportIndex);
await setLoadedImageIdsAndCacheMeta(primarySeries);
} finally {
setLoadingState(null);
}
};
const beginMobilePointerStackDrag = useCallback((stackIdx: number, startEvent: React.PointerEvent) => {
if (!(isPhoneLayout && mobileInteractionActive)) return;
if ((startEvent.pointerType || "").toLowerCase() === "mouse") return;
startEvent.preventDefault();
startEvent.stopPropagation();
suppressStackRowClickUntilRef.current = Date.now() + 400;
const visibleCount = viewportGrid.rows * viewportGrid.cols;
let latestTargetViewport: number | null = null;
let latestZone: ViewportDropZone = "center";
setMobilePointerDrag({
stackIdx,
x: startEvent.clientX,
y: startEvent.clientY,
targetViewportIdx: null,
zone: "center",
});
const onMove = (ev: PointerEvent) => {
let targetViewport: number | null = null;
let zone: ViewportDropZone = "center";
for (let i = 0; i < visibleCount; i++) {
const el = viewportRefs.current[i];
if (!el) continue;
const rect = el.getBoundingClientRect();
if (rect.width <= 1 || rect.height <= 1) continue;
if (ev.clientX < rect.left || ev.clientX > rect.right || ev.clientY < rect.top || ev.clientY > rect.bottom) continue;
targetViewport = i;
zone = resolveDropZone(getDropZoneFromClientPoint(rect, ev.clientX, ev.clientY));
break;
}
latestTargetViewport = targetViewport;
latestZone = zone;
setMobilePointerDrag((prev) => prev ? {
...prev,
x: ev.clientX,
y: ev.clientY,
targetViewportIdx: targetViewport,
zone,
} : prev);
setDropPreview(targetViewport !== null ? { viewportIdx: targetViewport, zone } : null);
};
const finish = async () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onCancel);
setDropPreview(null);
setMobilePointerDrag(null);
if (latestTargetViewport !== null) {
try {
await applyStackDrop(latestTargetViewport, stackIdx, latestZone);
setStackPanelOpen(false);
setActiveViewport(latestTargetViewport);
setMobileFocusedViewport(latestTargetViewport);
} catch (err) {
appLogger.error("Failed mobile pointer stack drop:", err);
}
}
suppressStackRowClickUntilRef.current = Date.now() + 400;
};
const onUp = () => {
void finish();
};
const onCancel = () => {
void finish();
};
window.addEventListener("pointermove", onMove, { passive: true });
window.addEventListener("pointerup", onUp, { passive: true });
window.addEventListener("pointercancel", onCancel, { passive: true });
}, [applyStackDrop, getDropZoneFromClientPoint, isPhoneLayout, mobileInteractionActive, resolveDropZone, viewportGrid]);
useEffect(() => {
const setup = async () => {
appLogger.debug("setup")
// 1. Initialize Cornerstone3D and tools ONCE
if (!running.current) {
running.current = true;
await csRenderInit();
await csToolsInit(
);
dicomImageLoaderInit();
// Register client-side image loader for external images (JPEG/PNG/blob/data)
try {
imageLoader.registerImageLoader('external', (imageId: string) => {
const promise = (async () => {
// imageId expected as 'external:<url>'
const url = imageId.replace(/^external:/, '');
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = url;
await new Promise((resolve, reject) => {
img.onload = () => resolve(true);
img.onerror = (e) => reject(e);
});
const w = img.naturalWidth || img.width;
const h = img.naturalHeight || img.height;
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Unable to create canvas context');
ctx.drawImage(img, 0, 0, w, h);
const im = ctx.getImageData(0, 0, w, h);
const image = {
imageId,
minPixelValue: 0,
maxPixelValue: 255,
slope: 1.0,
intercept: 0,
rows: h,
columns: w,
height: h,
width: w,
color: true,
rgba: true,
sizeInBytes: im.data.length,
getPixelData: () => im.data,
// Some Cornerstone APIs expect `getImageData` or `getCanvas` - provide canvas and ImageData
getImageData: () => im,
getCanvas: () => canvas,
} as any;
return image;
})();
return { promise };
});
} catch (e) {
appLogger.warn('Failed to register external image loader', e);
}
}
// 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) {
// If this is an external (non-DICOM) image stack, render an <img> element as a fallback
const firstId = imageIds[0];
if (isExternalImageId(firstId)) {
try {
// Cleanup previous external handlers if any
try {
const prev = (element as any)._externalPanZoom;
if (prev && typeof prev.cleanup === 'function') prev.cleanup();
} catch (e) { /* ignore */ }
// Clear existing content and insert an image element
element.innerHTML = '';
const img = document.createElement('img');
img.style.width = '100%';
img.style.height = '100%';
img.style.objectFit = 'contain';
img.style.display = 'block';
img.src = (firstId as string).replace(/^external:/, '');
// allow drag to open in new tab etc.
img.setAttribute('draggable', 'false');
element.appendChild(img);
// Attach pan/zoom interactions to this external image
const ext = enableImagePanZoom(element, img);
(element as any)._externalPanZoom = ext;
// Do not attach DICOM-specific tools to external images
mainToolGroup.removeViewports(renderingEngineId, viewportId);
} catch (err) {
appLogger.warn('Failed to render external image in viewport', err);
}
} else {
try {
viewport.setStack(imageIds);
} catch (err) {
appLogger.warn('viewport.setStack failed', err);
}
}
}
// 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 }]);
if (typeof viewport.getNumberOfSlices === "function") {
const totalSlices = viewport.getNumberOfSlices();
const pending = pendingSliceIndexByViewportRef.current[i] || 0;
const targetSlice = Math.max(0, Math.min(Math.max(0, totalSlices - 1), pending));
csUtilities.jumpToSlice(viewport.element, { imageIndex: targetSlice });
}
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(() => {
appLogger.debug("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();
appLogger.debug("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") {
appLogger.debug(`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]);
const applyDefaultWindowLevel = useCallback((viewportIdx: number, resetCamera = false) => {
const viewportId = `CT_${viewportIdx}`;
try {
const renderingEngine = renderingEngineRef.current;
const viewport = renderingEngine?.getViewport(viewportId);
if (!viewport) return false;
const ids = typeof viewport.getImageIds === "function"
? viewport.getImageIds()
: (viewportImageIds[viewportIdx] || []);
const currentIndex = typeof viewport.getCurrentImageIdIndex === "function"
? viewport.getCurrentImageIdIndex()
: 0;
const imageId = ids[currentIndex] || ids[0];
const defaultVoi = defaultVoiRangeByViewportRef.current[viewportIdx];
const voiMeta = imageId ? (metaData.get("voiLutModule", imageId) || {}) as any : {};
const pixelMeta = imageId ? (metaData.get("imagePixelModule", imageId) || {}) as any : {};
const lutMeta = imageId ? (metaData.get("modalityLutModule", imageId) || {}) as any : {};
const centerMeta = Array.isArray(voiMeta.windowCenter) ? voiMeta.windowCenter[0] : voiMeta.windowCenter;
const widthMeta = Array.isArray(voiMeta.windowWidth) ? voiMeta.windowWidth[0] : voiMeta.windowWidth;
const minRaw = Number(pixelMeta.smallestPixelValue ?? pixelMeta.smallestImagePixelValue);
const maxRaw = Number(pixelMeta.largestPixelValue ?? pixelMeta.largestImagePixelValue);
const slope = Number(lutMeta.rescaleSlope ?? 1);
const intercept = Number(lutMeta.rescaleIntercept ?? 0);
if (defaultVoi) {
viewport.setProperties({ voiRange: defaultVoi });
} else if (Number.isFinite(Number(centerMeta)) && Number.isFinite(Number(widthMeta)) && Number(widthMeta) > 0) {
const center = Number(centerMeta);
const width = Number(widthMeta);
viewport.setProperties({
voiRange: {
lower: center - width / 2,
upper: center + width / 2,
},
});
} else if (Number.isFinite(minRaw) && Number.isFinite(maxRaw) && maxRaw > minRaw) {
viewport.setProperties({
voiRange: {
lower: minRaw * slope + intercept,
upper: maxRaw * slope + intercept,
},
});
} else if (typeof viewport.resetProperties === "function") {
viewport.resetProperties();
}
if (resetCamera && typeof viewport.resetCamera === "function") {
viewport.resetCamera();
}
viewport.render();
updateOverlay(viewportIdx, viewport);
return true;
} catch (err) {
appLogger.warn("Failed to apply default window level", err);
return false;
}
}, [updateOverlay, viewportImageIds]);
const handleAutoLevel = useCallback((viewportIdx: number) => {
const viewportId = `CT_${viewportIdx}`;
void (async () => {
const renderingEngine = renderingEngineRef.current;
if (!renderingEngine) return;
const viewport = renderingEngine.getViewport(viewportId);
if (!viewport) return;
const ids = typeof viewport.getImageIds === "function"
? viewport.getImageIds()
: (viewportImageIds[viewportIdx] || []);
const currentIndex = typeof viewport.getCurrentImageIdIndex === "function"
? viewport.getCurrentImageIdIndex()
: 0;
const imageId = ids[currentIndex] || ids[0];
if (!imageId) {
applyDefaultWindowLevel(viewportIdx);
return;
}
const pixelMeta = (metaData.get("imagePixelModule", imageId) || {}) as any;
const lutMeta = (metaData.get("modalityLutModule", imageId) || {}) as any;
const voiMeta = (metaData.get("voiLutModule", imageId) || {}) as any;
const defaultVoi = defaultVoiRangeByViewportRef.current[viewportIdx];
let minRaw = Number(pixelMeta.smallestPixelValue ?? pixelMeta.smallestImagePixelValue);
let maxRaw = Number(pixelMeta.largestPixelValue ?? pixelMeta.largestImagePixelValue);
let slope = Number(lutMeta.rescaleSlope ?? 1);
let intercept = Number(lutMeta.rescaleIntercept ?? 0);
if (!Number.isFinite(minRaw) || !Number.isFinite(maxRaw) || maxRaw <= minRaw) {
try {
const image = await imageLoader.loadAndCacheImage(imageId);
minRaw = Number((image as any)?.minPixelValue);
maxRaw = Number((image as any)?.maxPixelValue);
slope = Number((image as any)?.slope ?? slope);
intercept = Number((image as any)?.intercept ?? intercept);
} catch (err) {
const fallbackIds = ids.filter((id): id is string => !!id).slice(0, 6);
for (const fallbackId of fallbackIds) {
try {
const fallbackImage = await imageLoader.loadAndCacheImage(fallbackId);
minRaw = Number((fallbackImage as any)?.minPixelValue);
maxRaw = Number((fallbackImage as any)?.maxPixelValue);
slope = Number((fallbackImage as any)?.slope ?? slope);
intercept = Number((fallbackImage as any)?.intercept ?? intercept);
if (Number.isFinite(minRaw) && Number.isFinite(maxRaw) && maxRaw > minRaw) {
break;
}
} catch (innerErr) {
// Keep trying additional images in the stack.
}
}
}
}
const centerMeta = Array.isArray(voiMeta.windowCenter) ? voiMeta.windowCenter[0] : voiMeta.windowCenter;
const widthMeta = Array.isArray(voiMeta.windowWidth) ? voiMeta.windowWidth[0] : voiMeta.windowWidth;
if (Number.isFinite(minRaw) && Number.isFinite(maxRaw) && maxRaw > minRaw) {
viewport.setProperties({
voiRange: {
lower: minRaw * slope + intercept,
upper: maxRaw * slope + intercept,
},
});
} else if (Number.isFinite(Number(centerMeta)) && Number.isFinite(Number(widthMeta)) && Number(widthMeta) > 0) {
const center = Number(centerMeta);
const width = Number(widthMeta);
viewport.setProperties({
voiRange: {
lower: center - width / 2,
upper: center + width / 2,
},
});
} else if (defaultVoi) {
viewport.setProperties({ voiRange: defaultVoi });
} else {
applyDefaultWindowLevel(viewportIdx);
return;
}
viewport.render();
updateOverlay(viewportIdx, viewport);
})().catch(() => {
applyDefaultWindowLevel(viewportIdx);
});
}, [applyDefaultWindowLevel, updateOverlay, viewportImageIds]);
const applyPresetOption = useCallback((viewportIdx: number, preset: PresetOption, persistSelection = true, resetCameraOnReset = true) => {
if (persistSelection) {
activePresetByViewportRef.current[viewportIdx] = preset;
}
if (preset.action === "reset") {
applyDefaultWindowLevel(viewportIdx, resetCameraOnReset);
return;
}
if (preset.action === "autolevel") {
handleAutoLevel(viewportIdx);
return;
}
const wlPreset = preset as Extract<PresetOption, { action: "preset" }>;
handleApplyPreset(viewportIdx, wlPreset.center, wlPreset.width);
}, [applyDefaultWindowLevel, handleApplyPreset, handleAutoLevel]);
useEffect(() => {
const applyPresetOnImageChange = () => {
const renderingEngine = renderingEngineRef.current;
if (!renderingEngine) return;
const visibleCount = viewportGrid.rows * viewportGrid.cols;
for (let i = 0; i < visibleCount; i++) {
if (viewportModes[i] !== "stack") continue;
const preset = activePresetByViewportRef.current[i];
if (!preset) continue;
const viewport = renderingEngine.getViewport(`CT_${i}`);
if (!viewport || typeof viewport.getImageIds !== "function") continue;
const ids = viewport.getImageIds() || [];
if (!ids.length) continue;
const idx = typeof viewport.getCurrentImageIdIndex === "function"
? viewport.getCurrentImageIdIndex()
: 0;
const currentImageId = ids[idx] || ids[0];
if (!currentImageId) continue;
if (lastRenderedImageIdByViewportRef.current[i] === currentImageId) {
continue;
}
lastRenderedImageIdByViewportRef.current[i] = currentImageId;
applyPresetOption(i, preset, false, false);
}
};
const timer = window.setTimeout(applyPresetOnImageChange, 0);
return () => window.clearTimeout(timer);
}, [applyPresetOption, imageInfos, viewportGrid, viewportModes]);
useEffect(() => {
const handlePresetHotkey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
if (target) {
const tag = target.tagName?.toLowerCase();
const editable = target.getAttribute("contenteditable") === "true";
if (tag === "input" || tag === "textarea" || tag === "select" || editable) return;
}
if (e.ctrlKey || e.altKey || e.metaKey) return;
if (activeViewport === null) return;
const modality = imageInfos[activeViewport]?.modality;
const options = getPresetOptionsForModality(modality);
if (e.key === "0") {
const resetOption = options.find((option) => option.action === "reset");
if (resetOption) {
applyPresetOption(activeViewport, resetOption);
e.preventDefault();
}
return;
}
if (!/^[1-9]$/.test(e.key)) return;
const presetIndex = Number(e.key) - 1;
const properPresets = options.filter((option): option is Extract<PresetOption, { action: "preset" }> => option.action === "preset");
if (presetIndex >= properPresets.length) return;
applyPresetOption(activeViewport, properPresets[presetIndex]);
e.preventDefault();
};
window.addEventListener("keydown", handlePresetHotkey);
return () => window.removeEventListener("keydown", handlePresetHotkey);
}, [activeViewport, applyPresetOption, imageInfos]);
const handleSeriesIndexChange = useCallback((viewportIdx: number, nextSeriesIdx: number) => {
const groups = seriesGroupsByViewport[viewportIdx] || [];
if (groups.length <= 1) return;
const boundedIdx = Math.max(0, Math.min(groups.length - 1, nextSeriesIdx));
const selectedGroup = groups[boundedIdx];
if (!selectedGroup || selectedGroup.imageIds.length === 0) return;
const renderingEngine = renderingEngineRef.current;
const viewport = renderingEngine?.getViewport(`CT_${viewportIdx}`);
const isVolumeMode = viewportModes[viewportIdx] === "volume";
const sliceScalarForImageId = (imageId?: string): number | null => {
if (!imageId) return null;
const plane = (metaData.get("imagePlaneModule", imageId) || {}) as any;
const ipp = Array.isArray(plane?.imagePositionPatient) ? plane.imagePositionPatient : null;
const row = Array.isArray(plane?.rowCosines) ? plane.rowCosines : null;
const col = Array.isArray(plane?.columnCosines) ? plane.columnCosines : null;
if (ipp && row && col && row.length >= 3 && col.length >= 3) {
const normal = [
row[1] * col[2] - row[2] * col[1],
row[2] * col[0] - row[0] * col[2],
row[0] * col[1] - row[1] * col[0],
];
const mag = Math.hypot(normal[0], normal[1], normal[2]);
if (mag > 1e-6) {
const unit = [normal[0] / mag, normal[1] / mag, normal[2] / mag];
return ipp[0] * unit[0] + ipp[1] * unit[1] + ipp[2] * unit[2];
}
}
const fallback = Number(plane?.sliceLocation);
return Number.isFinite(fallback) ? fallback : null;
};
let mappedIndex = 0;
if (viewport && typeof viewport.getCurrentImageIdIndex === "function") {
const oldIds = viewportImageIds[viewportIdx] || [];
const oldTotal = oldIds.length;
const currentIdx = viewport.getCurrentImageIdIndex();
const currentImageId = oldIds[currentIdx] || oldIds[0];
const currentSliceScalar = sliceScalarForImageId(currentImageId);
if (currentSliceScalar !== null && selectedGroup.imageIds.length > 0) {
let bestIdx = 0;
let bestDistance = Number.POSITIVE_INFINITY;
selectedGroup.imageIds.forEach((id, idx) => {
const scalar = sliceScalarForImageId(id);
if (scalar === null) return;
const distance = Math.abs(scalar - currentSliceScalar);
if (distance < bestDistance) {
bestDistance = distance;
bestIdx = idx;
}
});
mappedIndex = bestIdx;
} else {
const ratio = oldTotal > 1 ? currentIdx / (oldTotal - 1) : 0;
mappedIndex = Math.max(0, Math.min(selectedGroup.imageIds.length - 1, Math.round(ratio * Math.max(0, selectedGroup.imageIds.length - 1))));
}
} else if (isVolumeMode && viewport && typeof viewport.getSliceIndex === "function" && typeof viewport.getNumberOfSlices === "function") {
const oldTotal = viewport.getNumberOfSlices();
const currentIdx = viewport.getSliceIndex();
const ratio = oldTotal > 1 ? currentIdx / (oldTotal - 1) : 0;
mappedIndex = Math.max(0, Math.min(selectedGroup.imageIds.length - 1, Math.round(ratio * Math.max(0, selectedGroup.imageIds.length - 1))));
}
pendingSliceIndexByViewportRef.current[viewportIdx] = mappedIndex;
setSelectedSeriesIdxByViewport(prev => {
const next = [...prev];
next[viewportIdx] = boundedIdx;
return next;
});
setViewportImageIds(prev => {
const next = [...prev];
next[viewportIdx] = selectedGroup.imageIds;
return next;
});
void setLoadedImageIdsAndCacheMeta(selectedGroup.imageIds, { force: Boolean(autoCacheStack) });
window.setTimeout(() => {
const vp = renderingEngineRef.current?.getViewport(`CT_${viewportIdx}`);
if (!vp) return;
if (viewportModes[viewportIdx] === "stack" && typeof vp.setImageIdIndex === "function") {
csUtilities.jumpToSlice(vp.element, { imageIndex: mappedIndex });
vp.render();
updateOverlay(viewportIdx, vp);
return;
}
if (viewportModes[viewportIdx] === "volume" && typeof vp.getNumberOfSlices === "function") {
const total = vp.getNumberOfSlices();
const target = Math.max(0, Math.min(Math.max(0, total - 1), mappedIndex));
csUtilities.jumpToSlice(vp.element, { imageIndex: target });
vp.render();
updateOverlay(viewportIdx, vp);
}
}, 25);
}, [seriesGroupsByViewport, setLoadedImageIdsAndCacheMeta, updateOverlay, viewportImageIds, viewportModes]);
useEffect(() => {
const timers: number[] = [];
const visibleCount = viewportGrid.rows * viewportGrid.cols;
for (let i = 0; i < visibleCount; i++) {
if (!cinePlayingByViewport[i]) continue;
if (viewportModes[i] !== "stack") continue;
const ids = viewportImageIds[i] || [];
if (ids.length < 2) continue;
const fps = Math.max(1, Math.min(60, Math.round(cineFpsByViewport[i] || 12)));
const intervalMs = Math.max(16, Math.round(1000 / fps));
const timer = window.setInterval(() => {
const viewport = renderingEngineRef.current?.getViewport(`CT_${i}`);
if (!viewport || typeof viewport.getCurrentImageIdIndex !== "function") return;
if (typeof viewport.setImageIdIndex !== "function") return;
const stackIds = viewport.getImageIds?.() || ids;
const total = stackIds.length;
if (total < 2) return;
const currentIdx = viewport.getCurrentImageIdIndex();
const nextIdx = (currentIdx + 1) % total;
csUtilities.jumpToSlice(viewport.element, { imageIndex: nextIdx });
viewport.render();
}, intervalMs);
timers.push(timer);
}
return () => {
timers.forEach((timer) => window.clearInterval(timer));
};
}, [cineFpsByViewport, cinePlayingByViewport, viewportGrid, viewportImageIds, viewportModes]);
const toggleViewerFullscreen = useCallback(() => {
const root = appRootRef.current;
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();
}
}, []);
// Update overlay and preset state arrays when grid changes
useEffect(() => {
setImageInfos(Array(viewportGrid.rows * viewportGrid.cols).fill({
index: 0,
total: 0,
windowCenter: "",
windowWidth: "",
slicePosition: "",
modality: "",
orientation: "",
resolution: "",
spacing: "",
focalPoint: "",
}));
setPresetsOpenArr(Array(viewportGrid.rows * viewportGrid.cols).fill(false));
setCineControlsOpenByViewport(Array(MAX_VIEWPORTS).fill(false));
}, [viewportGrid]);
// Re-apply bindings when tool sets or ctrl changes
useEffect(() => {
applyMouseToolBindings()
}, [mouseToolBindings, ctrlMouseToolBindings, ctrlPressed, applyMouseToolBindings])
// Close preset menus when clicking outside
useEffect(() => {
if (!presetsOpenArr.some(Boolean)) return;
const handleClick = (e: MouseEvent) => {
const menu = document.querySelector(".preset-menu");
if (menu && !menu.contains(e.target as Node)) {
setPresetsOpenArr(prev => prev.map(() => false));
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [presetsOpenArr]);
useEffect(() => {
if (!viewportMenuOpen) return;
const handleOutsideClick = (e: MouseEvent) => {
const host = topControlsRef.current;
if (host && !host.contains(e.target as Node)) {
setViewportMenuOpen(false);
setTopControlsPeek(false);
}
};
document.addEventListener("mousedown", handleOutsideClick);
return () => document.removeEventListener("mousedown", handleOutsideClick);
}, [viewportMenuOpen]);
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;
const expandedImageIds = await expandMultiframeImageIds(wadouriImageIds);
await setLoadedImageIdsAndCacheMeta(expandedImageIds, { force: Boolean(autoCacheStack) });
const seriesGroups = await deriveSeriesGroupsFromImageIds(expandedImageIds);
const primarySeries = seriesGroups[0]?.imageIds || expandedImageIds;
// Try to extract StudyInstanceUID from the first image if possible
let studyInstanceUID: string | undefined = undefined;
try {
const firstId = expandedImageIds[0];
if (Boolean(autoCacheStack) && firstId && firstId.startsWith("wadouri:")) {
const url = stripWadouriPrefix(firstId);
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: expandedImageIds, studyInstanceUID, seriesGroups, groupingSource: "derived" as const }];
setViewportImageIds(vpPrev => {
const vpNext = [...vpPrev];
vpNext[0] = primarySeries;
return vpNext;
});
setSelectedStackIdx(selPrev => {
const selNext = [...selPrev];
selNext[0] = next.length - 1;
return selNext;
});
setSeriesGroupsByViewport((prevSeries) => {
const nextSeries = [...prevSeries];
nextSeries[0] = seriesGroups;
return nextSeries;
});
setSelectedSeriesIdxByViewport((prevIdx) => {
const nextIdx = [...prevIdx];
nextIdx[0] = 0;
return nextIdx;
});
setLoadedImageIdsAndCacheMeta(primarySeries);
return next;
});
};
// Cleanup
return () => {
window.loadDicomStackFromWadouriList = undefined;
};
}, [deriveSeriesGroupsFromImageIds, expandMultiframeImageIds, setAvailableStacks, setLoadedImageIdsAndCacheMeta, setSelectedStackIdx, setViewportImageIds]);
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 clearViewer() {
const renderingEngine = renderingEngineRef.current;
// Disable viewports so stale frames are removed from the DOM/canvas.
if (renderingEngine) {
for (let i = 0; i < MAX_VIEWPORTS; i++) {
const viewportId = `CT_${i}`;
try {
const viewport = renderingEngine.getViewport(viewportId);
if (viewport) {
renderingEngine.disableElement(viewportId);
}
} catch (e) {
// Ignore missing/disabled viewport errors.
}
}
}
// Clear per-viewport DOM state so they can be re-enabled cleanly later.
for (let i = 0; i < MAX_VIEWPORTS; i++) {
const element = viewportRefs.current[i];
if (!element) continue;
if (element._resizeObserver) {
try {
element._resizeObserver.disconnect();
} catch (e) {
// Ignore observer teardown issues.
}
element._resizeObserver = undefined;
}
element._cornerstoneEnabled = false;
element._lastViewportType = undefined;
element._overlayListener = undefined;
element.innerHTML = '';
}
if (cornerstoneTools?.annotation?.state?.removeAllAnnotations) {
cornerstoneTools.annotation.state.removeAllAnnotations();
}
setAvailableStacks([]);
setViewportImageIds(Array(MAX_VIEWPORTS).fill(null).map(() => []));
setSelectedStackIdx(Array(MAX_VIEWPORTS).fill(0));
setSeriesGroupsByViewport(Array(MAX_VIEWPORTS).fill(null).map(() => []));
setSelectedSeriesIdxByViewport(Array(MAX_VIEWPORTS).fill(0));
setCinePlayingByViewport(Array(MAX_VIEWPORTS).fill(false));
setCineControlsOpenByViewport(Array(MAX_VIEWPORTS).fill(false));
setViewportModes(Array(MAX_VIEWPORTS).fill('stack'));
Object.values(stackThumbnailsRef.current).forEach((thumbnail) => {
if (typeof thumbnail === 'string' && thumbnail.startsWith('blob:')) {
try {
URL.revokeObjectURL(thumbnail);
} catch (e) {
// ignore revoke failures
}
}
});
stackThumbnailsRef.current = {};
setStackThumbnails({});
thumbRequestedRef.current = {};
setStackOrderModified(false);
setActiveViewport(0);
}
function truncateStack(lower: number, upper: number) {
// Only allow when 1 viewport and 1 stack is loaded
appLogger.debug("Truncating stack from", lower, "to", upper);
appLogger.debug(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 => stripWadouriPrefix(id));
const normTarget = stripWadouriPrefix(imageId);
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[`clearViewer_${apiKey}`] = clearViewer;
window[`truncateStack_${apiKey}`] = truncateStack;
window[`getCurrentStackPosition_${apiKey}`] = getCurrentStackPosition;
window[`loadAdditionalStack_${apiKey}`] = async (
imageIds: string[],
studyInstanceUID?: string,
declaredSeries?: Array<{
key?: string;
label?: string;
imageIds?: string[];
i?: string[];
name?: string;
seriesInstanceUID?: string;
bValue?: number | null;
groupValues?: Partial<Record<GroupingRuleId, string>>;
}>,
) => {
if (!Array.isArray(imageIds) || imageIds.length === 0) return;
const normalizedImageIds = dedupeImageIds((imageIds || []).map(normalizeToWadouriOrExternal));
const hasDeclaredSeries = Array.isArray(declaredSeries) && declaredSeries.length > 0;
const expandedImageIds = hasDeclaredSeries
? normalizedImageIds
: await expandMultiframeImageIds(normalizedImageIds);
await setLoadedImageIdsAndCacheMeta(expandedImageIds, { force: Boolean(autoCacheStack) });
const serverSeriesGroups: StackSeriesGroup[] = Array.isArray(declaredSeries)
? declaredSeries
.map((entry, idx) => {
const rawIds = Array.isArray(entry?.imageIds)
? entry.imageIds
: (Array.isArray(entry?.i) ? entry.i : []);
const normalizedIds = dedupeImageIds(rawIds.map(normalizeToWadouriOrExternal));
if (normalizedIds.length === 0) {
return null;
}
return {
key: String(entry?.key || entry?.seriesInstanceUID || `SERVER_SERIES_${idx}`),
label:
(typeof entry?.label === "string" && entry.label.trim())
|| (typeof entry?.name === "string" && entry.name.trim())
|| `Series ${idx + 1}`,
imageIds: normalizedIds,
seriesInstanceUID: entry?.seriesInstanceUID,
bValue: Number.isFinite(Number(entry?.bValue)) ? Number(entry?.bValue) : null,
groupValues: entry?.groupValues,
} as StackSeriesGroup;
})
.filter((group): group is StackSeriesGroup => Boolean(group))
: [];
const seriesGroups = serverSeriesGroups.length > 0
? serverSeriesGroups
: await deriveSeriesGroupsFromImageIds(expandedImageIds);
const primarySeries = seriesGroups[0]?.imageIds || expandedImageIds;
// Optionally extract StudyInstanceUID if not provided
let uid = studyInstanceUID;
if (!uid && Boolean(autoCacheStack) && expandedImageIds[0]?.startsWith("wadouri:")) {
try {
const url = stripWadouriPrefix(expandedImageIds[0]);
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: expandedImageIds,
studyInstanceUID: uid,
seriesGroups,
groupingSource: serverSeriesGroups.length > 0 ? "declared" : "derived",
}]);
// 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] = primarySeries;
setSeriesGroupsByViewport((prevSeries) => {
const nextSeries = [...prevSeries];
nextSeries[emptyIdx] = seriesGroups;
return nextSeries;
});
setSelectedSeriesIdxByViewport((prevIdx) => {
const nextIdx = [...prevIdx];
nextIdx[emptyIdx] = 0;
return nextIdx;
});
}
return next;
});
setLoadedImageIdsAndCacheMeta(primarySeries);
};
/**
* Load an external stack (raw media URLs or wadouri: IDs) into the active
* viewport of this viewer, adding it to the side-panel stack list.
* This is the preferred API for series-block drag-to-viewer interactions.
*/
window[`loadExternalStack_${apiKey}`] = async (rawImageIds: string[], name?: string) => {
if (!Array.isArray(rawImageIds) || rawImageIds.length === 0) return;
await applyExternalDrop(
activeViewport ?? 0,
rawImageIds,
name || 'Series',
'center',
);
};
/**
* Load an external stack into a new split viewport. If the grid is already
* at its maximum size the stack is loaded into the active viewport instead.
*/
window[`loadExternalStackNewViewport_${apiKey}`] = async (rawImageIds: string[], name?: string) => {
if (!Array.isArray(rawImageIds) || rawImageIds.length === 0) return;
const vp = activeViewport ?? 0;
// Use 'right' as the default split direction; applyExternalDrop will fall
// back to center if the grid is already full.
await applyExternalDrop(vp, rawImageIds, name || 'Series', 'right');
};
// 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
g: s.seriesGroups,
}));
appLogger.debug("Exporting viewer state with", numActive, "active viewports");
appLogger.debug("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;
appLogger.debug("Exporting state for viewport", i);
if (renderingEngine) {
appLogger.debug("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();
}
appLogger.debug("viewport", viewportId, "properties", p);
// --- Volume orientation and slice index ---
if (modes[i] === "volume") {
appLogger.debug("Getting orientation for viewport", i);
appLogger.debug("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,
seriesGroups: Array.isArray(s.g) ? s.g : undefined,
})));
if (state.stackIdx && state.stacks) {
const reconstructed = state.stackIdx.map(
(idx: number) => state.stacks[idx]?.i || []
);
setViewportImageIds(reconstructed);
setSelectedStackIdx(state.stackIdx);
setSeriesGroupsByViewport(() => {
const next = Array(MAX_VIEWPORTS).fill(null).map(() => [] as StackSeriesGroup[]);
for (let i = 0; i < Math.min(MAX_VIEWPORTS, state.stackIdx.length); i++) {
const idx = state.stackIdx[i];
const stack = state.stacks[idx];
next[i] = Array.isArray(stack?.g) ? stack.g : [];
}
return next;
});
setSelectedSeriesIdxByViewport(Array(MAX_VIEWPORTS).fill(0));
}
// --- 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++) {
appLogger.debug("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])
) {
appLogger.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;
}
// First pass: set stacks/volumes and stack positions so viewports have image resources
for (let i = 0; i < numActive; i++) {
appLogger.debug("Setting stack/volume 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;
appLogger.debug("mode", state.modes?.[i]);
if (state.modes?.[i] === "volume") {
try {
const volumeId = `myVolumeId_${i}`;
const volume = await volumeLoader.createAndCacheVolume(volumeId, { imageIds: importedImageIds });
volume.load();
viewport.setVolumes([{ volumeId }]);
viewport.render();
} catch (e) {
appLogger.warn("Failed to create/load volume for viewport", i, e);
}
// Best-effort orientation restore
if (state.orientations && state.orientations[i] && typeof viewport.setOrientation === "function") {
try { viewport.setOrientation(state.orientations[i]); viewport.render(); } catch (e) { /* ignore */ }
}
if (state.volumeSliceIndices) {
setTimeout(() => { try { csUtilities.jumpToSlice(viewport.element, { imageIndex: state.volumeSliceIndices[i] }); } catch (e) { } }, 100);
}
} else {
try { if (viewport.setStack) { viewport.setStack(importedImageIds); viewport.render(); } } catch (e) { appLogger.warn('Failed to set stack for viewport', i, e); }
if (state.stackPos && typeof state.stackPos[i] === "number" && typeof viewport.setImageIdIndex === "function") {
try { if (csUtilities && csUtilities.jumpToSlice) csUtilities.jumpToSlice(viewport.element, { imageIndex: state.stackPos[i] }); else viewport.setImageIdIndex(state.stackPos[i]); } catch (e) { /* ignore */ }
}
}
}
// Wait briefly to allow viewports to initialize GPU/actor resources
const readyDelayMs = 1500;
appLogger.debug(`Waiting ${readyDelayMs}ms before applying properties/camera to viewports`);
await new Promise((res) => setTimeout(res, readyDelayMs));
// Second pass: apply renderer-sensitive properties (colormap, VOI) and camera
for (let i = 0; i < numActive; i++) {
appLogger.debug("Applying props/camera 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;
// Restore properties with sanitization + retry
if (state.props && state.props[i] && typeof viewport.setProperties === "function") {
appLogger.debug("Restoring properties for viewport", i, state.props[i]);
const propsToRestore = { ...state.props[i] };
if (propsToRestore.colormap !== undefined && propsToRestore.colormap !== null) {
const cm = propsToRestore.colormap;
if (typeof cm === 'string') {
// keep
} else if (typeof cm === 'object') {
const safe: any = {};
if (typeof cm.name === 'string') safe.name = cm.name;
if (cm.opacity !== undefined && typeof cm.opacity === 'number') safe.opacity = cm.opacity;
if (Object.keys(safe).length === 0) delete propsToRestore.colormap; else propsToRestore.colormap = safe;
} else {
delete propsToRestore.colormap;
}
}
try {
viewport.setProperties(propsToRestore);
viewport.render();
} catch (err) {
appLogger.warn('viewport.setProperties failed, retrying without colormap', err);
try { const noColormap = { ...propsToRestore }; delete noColormap.colormap; viewport.setProperties(noColormap); viewport.render(); } catch (err2) { appLogger.error('Retrying viewport.setProperties without colormap also failed', err2); }
}
}
// Restore camera (best-effort)
if (state.cam && state.cam[i] && typeof viewport.setCamera === 'function') {
try { viewport.setCamera(state.cam[i]); viewport.render(); } catch (e) { /* ignore */ }
}
}
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();
appLogger.debug("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()
// Add defensive guards and queueing so callers can call importAnnotations before the
// viewer/annotation state is fully initialized in rare race conditions.
(window as any).__pendingImportAnnotations = (window as any).__pendingImportAnnotations || {};
const flushPending = (key: string) => {
try {
const pending = (window as any).__pendingImportAnnotations && (window as any).__pendingImportAnnotations[key];
if (!pending || !pending.length) return;
const annotationState = cornerstoneTools?.annotation?.state;
if (!annotationState || typeof annotationState.addAnnotation !== 'function') return;
// Drain the queue
while (((window as any).__pendingImportAnnotations[key] || []).length) {
const annotationData = ((window as any).__pendingImportAnnotations[key] || []).shift();
if (!annotationData) continue;
if (Array.isArray(annotationData)) {
annotationData.forEach(ann => {
try { annotationState.addAnnotation(ann, ann.annotationUID); } catch (err) { appLogger.error('Failed to add queued annotation', err, ann); }
});
}
}
if (renderingEngineRef.current) renderingEngineRef.current.render();
} catch (err) {
appLogger.error('Error flushing pending annotations:', err);
}
};
window[`importAnnotations_${apiKey}`] = (json: string) => {
try {
const annotationData = typeof json === "string" ? JSON.parse(json) : json;
const annotationState = cornerstoneTools?.annotation?.state;
if (!annotationState || typeof annotationState.addAnnotation !== 'function') {
// Queue the annotations until the annotation state is ready
(window as any).__pendingImportAnnotations[apiKey] = (window as any).__pendingImportAnnotations[apiKey] || [];
(window as any).__pendingImportAnnotations[apiKey].push(annotationData);
appLogger.warn('importAnnotations: annotation state not ready, queued import', apiKey);
// schedule a flush attempt
setTimeout(() => flushPending(apiKey), 500);
return;
}
// Remove all existing annotations if needed
if (annotationState.removeAllAnnotations) {
try { annotationState.removeAllAnnotations(); } catch (err) { appLogger.warn('removeAllAnnotations failed', err); }
}
if (Array.isArray(annotationData)) {
annotationData.forEach(ann => {
try { annotationState.addAnnotation(ann, ann.annotationUID); } catch (err) { appLogger.error('Failed to add annotation', err, ann); }
});
}
if (renderingEngineRef.current) {
renderingEngineRef.current.render();
}
} catch (e) {
appLogger.error("Failed to import annotations:", e, { apiKey, cornerstoneTools: !!cornerstoneTools, renderingEngine: !!renderingEngineRef.current });
try { alert("Failed to import annotations: " + e); } catch (ignored) {}
}
// Attempt to flush any queued imports (in case multiple imports were queued)
try { flushPending(apiKey); } catch (ignored) {}
};
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]) => {
appLogger.debug("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 });
appLogger.debug("Importing legacy annotation:")
appLogger.debug(" Image ID:", imageId);
appLogger.debug(" Start World:", startWorld);
appLogger.debug(" End World:", endWorld);
// Get spatial metadata
const plane = metaData.get("imagePlaneModule", imageId);
appLogger.debug(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;
}
appLogger.debug("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[`clearViewer_${apiKey}`] = undefined;
window[`truncateStack_${apiKey}`] = undefined;
window[`getCurrentStackPosition_${apiKey}`] = undefined;
window[`loadAdditionalStack_${apiKey}`] = undefined;
window[`loadExternalStack_${apiKey}`] = undefined;
window[`loadExternalStackNewViewport_${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) {
appLogger.debug("Handling arrow key navigation", e.key);
appLogger.debug("activeViewport", activeViewport);
if (activeViewport === null) return;
const renderingEngine = renderingEngineRef.current;
if (!renderingEngine) return;
const viewport = renderingEngine.getViewport(`CT_${activeViewport}`);
if (!viewport) return;
appLogger.debug(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;
const isFocusedMobileViewport = !isPhoneLayout
|| !mobileInteractionActive
|| !mobileSingleViewportMode
|| mobileFocusedViewport === i;
const currentPreviewZone = dropPreview?.viewportIdx === i ? dropPreview.zone : null;
const resolvedPreviewZone = currentPreviewZone ? resolveDropZone(currentPreviewZone) : null;
viewports.push(
<div
key={i}
ref={el => (viewportRefs.current[i] = el)}
style={{
flex: 1,
display: isVisible && isFocusedMobileViewport ? "flex" : "none",
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%",
touchAction: (isPhoneLayout && mobileInteractionActive) ? "none" : "pan-y",
boxShadow: activeViewport === i
? "0 0 0 4px #1976d2, 0 0 16px 4px #1976d2cc"
: undefined,
zIndex: activeViewport === i ? 10 : 1,
}}
onClick={() => {
if (mobilePointerDrag) {
return;
}
if (isPhoneLayout && !mobileInteractionActive) {
activateMobileInteractionForViewport(i);
setViewportMenuOpen(false);
setTopControlsPeek(false);
setSideMenuOpen(false);
return;
}
if (isPhoneLayout && mobileInteractionActive && mobileArmedStackIdx !== null) {
applyStackDrop(i, mobileArmedStackIdx, mobileArmedDropZone).catch((err) => {
appLogger.error("Failed to apply mobile stack drop:", err);
});
setMobileArmedStackIdx(null);
setStackPanelOpen(false);
return;
}
setActiveViewport(i);
setViewportMenuOpen(false);
}}
onDoubleClick={() => handleDoubleClick(i)}
onDragEnter={e => {
const types = e.dataTransfer.types;
const hasInternal = types.includes('text/stack-idx');
const hasExternal = types.includes('text/dv3d-imageids');
if (!hasInternal && !hasExternal) return;
e.preventDefault();
if (hasExternal) setExternalDragActive(true);
const zone = getDropZoneFromPointer(e);
setDropPreview({ viewportIdx: i, zone });
}}
onDragOver={e => {
const types = e.dataTransfer.types;
const hasInternal = types.includes('text/stack-idx');
const hasExternal = types.includes('text/dv3d-imageids');
if (!hasInternal && !hasExternal) return;
e.preventDefault();
const zone = getDropZoneFromPointer(e);
const resolved = resolveDropZone(zone);
setDropPreview({ viewportIdx: i, zone });
e.dataTransfer.dropEffect = resolved === zone ? 'copy' : 'move';
}}
onDragLeave={e => {
if (dropPreview?.viewportIdx !== i) return;
const nextTarget = e.relatedTarget as Node | null;
if (nextTarget && e.currentTarget.contains(nextTarget)) return;
setDropPreview(null);
}}
onDrop={e => {
e.preventDefault();
const externalImageIdsStr = e.dataTransfer.getData('text/dv3d-imageids');
if (externalImageIdsStr) {
const stackName = e.dataTransfer.getData('text/dv3d-stack-name') || 'Series';
try {
const rawIds: string[] = JSON.parse(externalImageIdsStr);
const zone = getDropZoneFromPointer(e);
applyExternalDrop(i, rawIds, stackName, zone).catch(err => {
appLogger.error('Failed to apply external drop:', err);
});
} catch (err) {
appLogger.error('Invalid text/dv3d-imageids payload:', err);
}
setDropPreview(null);
setExternalDragActive(false);
return;
}
const idxStr = e.dataTransfer.getData('text/stack-idx');
const stackIdx = parseInt(idxStr, 10);
if (!isNaN(stackIdx)) {
const zone = getDropZoneFromPointer(e);
applyStackDrop(i, stackIdx, zone).catch((err) => {
appLogger.error("Failed to apply dropped stack:", err);
});
}
setDropPreview(null);
setDraggedStackIdx(null);
setExternalDragActive(false);
}}
>
{isPhoneLayout && !mobileInteractionActive && (
<div
style={{
position: "absolute",
inset: 0,
zIndex: 60,
pointerEvents: "none",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(to top, rgba(0,0,0,0.55), rgba(0,0,0,0.2))",
color: "#fff",
fontSize: 14,
fontWeight: 700,
letterSpacing: 0.2,
textAlign: "center",
padding: "18px",
}}
>
Tap to activate fullscreen interaction
</div>
)}
{isPhoneLayout && mobileInteractionActive && mobileArmedStackIdx !== null && (
<div
style={{
position: "absolute",
top: 8,
right: 8,
zIndex: 62,
background: "rgba(25, 118, 210, 0.92)",
color: "#fff",
borderRadius: 6,
padding: "6px 8px",
fontSize: 11,
fontWeight: 700,
pointerEvents: "none",
}}
>
Tap to drop stack ({mobileArmedDropZone})
</div>
)}
{(draggedStackIdx !== null || externalDragActive) && currentPreviewZone && (
<div
style={{
position: "absolute",
inset: 0,
zIndex: 19,
pointerEvents: "none",
background: "rgba(0,0,0,0.08)",
}}
>
<div
style={{
position: "absolute",
left: resolvedPreviewZone === "left" ? 0 : resolvedPreviewZone === "right" ? "50%" : "20%",
top: resolvedPreviewZone === "top" ? 0 : resolvedPreviewZone === "bottom" ? "50%" : "20%",
width: resolvedPreviewZone === "left" || resolvedPreviewZone === "right" ? "50%" : "60%",
height: resolvedPreviewZone === "top" || resolvedPreviewZone === "bottom" ? "50%" : "60%",
border: "2px solid #90caf9",
boxShadow: "0 0 0 9999px rgba(17, 29, 45, 0.22)",
background: "rgba(25, 118, 210, 0.28)",
borderRadius: 6,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
fontSize: 12,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: 0.4,
}}
>
{resolvedPreviewZone === "center" ? "Load Here" : `Split ${resolvedPreviewZone}`}
</div>
</div>
)}
{loadingState === "displayset" && (
<div
style={{
position: "absolute",
inset: 0,
zIndex: 58,
pointerEvents: "none",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "rgba(255,255,255,0.78)",
fontSize: 14,
fontWeight: 600,
letterSpacing: 0.2,
textShadow: "0 1px 3px rgba(0,0,0,0.55)",
}}
>
Loading images...
</div>
)}
<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>
<button
style={{
position: "absolute",
top: 78,
left: 8,
zIndex: 20,
background: cineControlsOpenByViewport[i] ? "rgba(30,120,200,0.95)" : "#111",
color: "#fff",
border: "none",
borderRadius: "4px",
padding: "4px 8px",
cursor: "pointer",
fontSize: "16px",
lineHeight: 1,
opacity: 0.9,
}}
title={cineControlsOpenByViewport[i] ? "Hide cine controls" : "Show cine controls"}
onClick={(e) => {
e.stopPropagation();
setCineControlsOpenByViewport((prev) => {
const next = [...prev];
const nextOpen = !next[i];
next[i] = nextOpen;
if (!nextOpen) {
setCinePlayingByViewport((playing) => {
const p = [...playing];
p[i] = false;
return p;
});
}
return next;
});
}}
>
<span aria-hidden="true"></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 }} title={ann.metadata.toolName || 'Annotation'}>
{selectedAnnUID === ann.annotationUID ? "★ " : ""}
<span style={{ fontSize: 14, lineHeight: 1 }}>{getToolIcon(ann.metadata.toolName)}</span>
</span>
<button
style={{
background: "transparent",
color: "#f44336",
border: "1px solid #f44336",
borderRadius: 3,
width: 16,
height: 16,
fontSize: 10,
lineHeight: 1,
cursor: "pointer",
marginLeft: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
opacity: 0.95,
padding: 0,
}}
title="Delete this annotation"
aria-label="Delete annotation"
onClick={e => {
e.stopPropagation();
handleDeleteAnnotation(i, ann.annotationUID, currentAnnotations);
}}
>
<span aria-hidden="true"></span>
</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}
{imageInfos[i]?.frameIndex && imageInfos[i]?.totalFrames ? (
<> &nbsp; [Frame: {imageInfos[i]?.frameIndex} / {imageInfos[i]?.totalFrames}]</>
) : null}
</div>
<div>
WC: {imageInfos[i]?.windowCenter} &nbsp; WW: {imageInfos[i]?.windowWidth}
</div>
{volumeDiagnosticsEnabled && (
<>
<div>Ori: {imageInfos[i]?.orientation || "N/A"}</div>
{imageInfos[i]?.resolution && <div>Res: {imageInfos[i]?.resolution}</div>}
{imageInfos[i]?.spacing && <div>Spacing: {imageInfos[i]?.spacing}</div>}
{imageInfos[i]?.focalPoint && <div>Pos: {imageInfos[i]?.focalPoint}</div>}
</>
)}
</>
) : (
<div>
Slice: {imageInfos[i]?.index} / {imageInfos[i]?.total}
{imageInfos[i]?.slicePosition && (
<span> &nbsp; ({imageInfos[i]?.slicePosition})</span>
)}
<br />
WC: {imageInfos[i]?.windowCenter} &nbsp; WW: {imageInfos[i]?.windowWidth}
{volumeDiagnosticsEnabled && (
<>
<br />
Ori: {imageInfos[i]?.orientation || "N/A"}
{imageInfos[i]?.resolution && (
<>
<br />
Res: {imageInfos[i]?.resolution}
</>
)}
{imageInfos[i]?.spacing && (
<>
<br />
Spacing: {imageInfos[i]?.spacing}
</>
)}
{imageInfos[i]?.focalPoint && (
<>
<br />
Pos: {imageInfos[i]?.focalPoint}
</>
)}
</>
)}
</div>
)}
</div>
{/* Preset menu (bottom right of viewport) */}
<div
style={{
position: "absolute",
right: 8,
bottom: 8,
zIndex: 110, // above the bottom study selector (zIndex:20)
pointerEvents: "auto", // <-- ensure pointer events are enabled
display: "flex",
alignItems: "flex-end",
gap: 6,
}}
>
<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 ({imageInfos[i]?.modality || "Unknown"})
<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: 111,
pointerEvents: "auto", // <-- ensure pointer events are enabled
}}
onClick={e => e.stopPropagation()} // <-- prevent bubbling to viewport
>
{(() => {
const options = getPresetOptionsForModality(imageInfos[i]?.modality);
let presetOrdinal = 0;
return options.map((preset) => {
let hotkeyHint = "";
if (preset.action === "reset") {
hotkeyHint = "0";
} else if (preset.action === "preset") {
presetOrdinal += 1;
hotkeyHint = String(presetOrdinal);
}
return (
<button
key={preset.name}
title={hotkeyHint ? `Shortcut: ${hotkeyHint}` : undefined}
style={{
display: "block",
width: "100%",
margin: "2px 0",
background: preset.action === "preset" ? "#333" : "#444",
color: "#fff",
border: "none",
borderRadius: "3px",
cursor: "pointer",
padding: preset.action === "preset" ? "4px 0" : "6px 0",
fontSize: "13px",
opacity: 0.9,
fontWeight: preset.action === "preset" ? "normal" : "600",
}}
onClick={e => {
e.stopPropagation(); // <-- prevent bubbling to viewport
applyPresetOption(i, preset);
setPresetsOpenArr(prev => {
const next = [...prev];
next[i] = false;
return next;
});
}}
>
{preset.name}{hotkeyHint ? ` (${hotkeyHint})` : ""}
</button>
);
});
})()}
</div>
)}
</div>
</div>
{viewportModes[i] === "stack" && (cineControlsOpenByViewport[i] || (seriesGroupsByViewport[i] || []).length > 1 || (imageInfos[i]?.frameIndex && imageInfos[i]?.totalFrames)) && (
<div
style={{
position: "absolute",
left: "50%",
bottom: 8,
transform: "translateX(-50%)",
zIndex: 36,
background: "rgba(15,15,15,0.82)",
border: "1px solid rgba(255,255,255,0.18)",
borderRadius: 8,
padding: "6px 10px",
display: "flex",
alignItems: "center",
gap: 8,
pointerEvents: "auto",
minWidth: 200,
maxWidth: "72%",
}}
onClick={(e) => e.stopPropagation()}
>
{cineControlsOpenByViewport[i] && imageInfos[i]?.total > 1 && (
<>
<button
style={{
background: cinePlayingByViewport[i] ? "#d32f2f" : "#2e7d32",
color: "#fff",
border: "none",
borderRadius: 4,
fontSize: 12,
padding: "4px 8px",
cursor: "pointer",
}}
title={cinePlayingByViewport[i] ? "Pause cine" : "Play cine"}
onClick={() => {
setCinePlayingByViewport((prev) => {
const next = [...prev];
next[i] = !next[i];
return next;
});
}}
>
{cinePlayingByViewport[i] ? "Pause" : "Play"}
</button>
<label style={{ color: "#ddd", fontSize: 11, display: "flex", alignItems: "center", gap: 4 }}>
FPS
<input
type="range"
min={1}
max={30}
step={1}
value={Math.max(1, Math.min(30, Math.round(cineFpsByViewport[i] || 12)))}
onChange={(e) => {
const value = Number(e.target.value);
setCineFpsByViewport((prev) => {
const next = [...prev];
next[i] = value;
return next;
});
}}
/>
<span style={{ minWidth: 22, textAlign: "right" }}>{Math.round(cineFpsByViewport[i] || 12)}</span>
</label>
</>
)}
{imageInfos[i]?.frameIndex && imageInfos[i]?.totalFrames && imageInfos[i]?.totalFrames > 1 && (
<>
<span style={{ color: "#fff", fontSize: 12, whiteSpace: "nowrap" }}>Frame</span>
<input
type="range"
min={1}
max={imageInfos[i]?.totalFrames || 1}
step={1}
value={Math.max(1, Math.min(imageInfos[i]?.totalFrames || 1, imageInfos[i]?.frameIndex || 1))}
onChange={(e) => {
const targetFrame = Number(e.target.value);
const imageIds = viewportImageIds[i] || [];
const targetIdx = imageIds.findIndex(id => {
const m = id.match(/&frame=(\d+)/);
return m && parseInt(m[1], 10) === targetFrame;
});
if (targetIdx >= 0) {
const viewport = renderingEngineRef.current?.getViewport(`CT_${i}`);
if (viewport && typeof csUtilities.jumpToSlice === "function") {
csUtilities.jumpToSlice(viewport.element, { imageIndex: targetIdx });
}
}
}}
style={{ width: 100 }}
/>
<span
style={{
color: "#e0e0e0",
fontSize: 11,
minWidth: 50,
textAlign: "right",
}}
>
{imageInfos[i]?.frameIndex} / {imageInfos[i]?.totalFrames}
</span>
</>
)}
{(seriesGroupsByViewport[i] || []).length > 1 && (
<>
<span style={{ color: "#fff", fontSize: 12, whiteSpace: "nowrap" }}>Group</span>
<input
type="range"
min={0}
max={Math.max(0, (seriesGroupsByViewport[i] || []).length - 1)}
step={1}
value={Math.max(0, Math.min((seriesGroupsByViewport[i] || []).length - 1, selectedSeriesIdxByViewport[i] || 0))}
onChange={(e) => {
handleSeriesIndexChange(i, Number(e.target.value));
}}
style={{ width: 140 }}
/>
<span
style={{
color: "#e0e0e0",
fontSize: 11,
maxWidth: 180,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={seriesGroupsByViewport[i]?.[selectedSeriesIdxByViewport[i] || 0]?.label || "Group"}
>
{seriesGroupsByViewport[i]?.[selectedSeriesIdxByViewport[i] || 0]?.label || "Group"}
</span>
</>
)}
</div>
)}
{/* Position bar inside each viewport */}
{viewportModes[i] === "stack" && viewportImageIds[i] && (
<div
style={{
position: "absolute",
top: "10%",
right: 8,
width: "14px",
height: "80%",
maxHeight: "100%",
minHeight: 0,
zIndex: 35,
display: viewportImageIds[i].length > 0 ? "flex" : "none",
alignItems: "flex-start",
justifyContent: "center",
pointerEvents: "auto",
flexDirection: "column",
padding: 0,
}}
>
{(() => {
const imageIds = viewportImageIds[i] || [];
const total = imageIds.length;
if (total === 0) return null;
const renderingEngine = renderingEngineRef.current;
const viewport = renderingEngine?.getViewport(`CT_${i}`);
const currentIdx = viewport && typeof viewport.getCurrentImageIdIndex === "function"
? viewport.getCurrentImageIdIndex()
: -1;
const viewedSet = new Set(
imageIds
.map((id, idx) => (viewedImageIds[id] ? idx : -1))
.filter((idx) => idx >= 0)
);
const loadedFlags = imageIds.map((id) => {
if (id.startsWith("external:")) return true;
return !!metaData.get("imagePlaneModule", id) || !!metaData.get("generalSeriesModule", id);
});
const loadedCount = loadedFlags.reduce((sum, loaded) => sum + (loaded ? 1 : 0), 0);
const viewedCount = imageIds.reduce((sum, _id, idx) => sum + (viewedSet.has(idx) ? 1 : 0), 0);
const segmentCount = Math.min(total, 180);
const segments = Array.from({ length: segmentCount }, () => ({ loaded: false, viewed: false, current: false }));
for (let idx = 0; idx < total; idx++) {
const segmentIdx = total === 1
? 0
: Math.min(segmentCount - 1, Math.floor((idx / (total - 1)) * (segmentCount - 1)));
if (loadedFlags[idx]) segments[segmentIdx].loaded = true;
if (viewedSet.has(idx)) segments[segmentIdx].viewed = true;
if (idx === currentIdx) segments[segmentIdx].current = true;
}
return (
<div
style={{
width: "10px",
height: "100%",
maxHeight: "100%",
minHeight: 0,
borderRadius: "6px",
background: "#191919",
position: "relative",
display: "flex",
flexDirection: "column",
justifyContent: "flex-start",
boxShadow: "0 0 8px #2196f3cc",
overflow: "hidden",
cursor: "pointer",
}}
title={`Loaded ${loadedCount}/${total} • Viewed ${viewedCount}/${total}`}
onClick={(e) => {
const bar = e.currentTarget as HTMLDivElement;
const rect = bar.getBoundingClientRect();
const y = Math.min(rect.height, Math.max(0, e.clientY - rect.top));
const ratio = rect.height > 0 ? y / rect.height : 0;
const targetIndex = Math.min(total - 1, Math.max(0, Math.round(ratio * (total - 1))));
const targetViewport = renderingEngineRef.current?.getViewport(`CT_${i}`);
if (
targetViewport &&
typeof targetViewport.setImageIdIndex === "function" &&
viewportModes[i] === "stack"
) {
csUtilities.jumpToSlice(targetViewport.element, { imageIndex: targetIndex });
updateOverlay(i, targetViewport);
}
}}
>
{segments.map((segment, idx) => {
let background = "#3f3f3f";
if (segment.loaded) background = "#2e7d32";
if (segment.viewed) background = "#1565c0";
if (segment.current) background = "#fbc02d";
return (
<div
key={idx}
style={{
width: "100%",
height: `${100 / segmentCount}%`,
minHeight: 1,
background,
opacity: segment.loaded || segment.viewed || segment.current ? 0.95 : 0.45,
borderBottom: idx < segmentCount - 1 ? "1px solid #222" : "none",
pointerEvents: "none",
}}
/>
);
})}
{currentIdx >= 0 && total > segmentCount && (
<div
style={{
position: "absolute",
left: 0,
right: 0,
top: `${(currentIdx / Math.max(1, total - 1)) * 100}%`,
height: 2,
background: "#ffeb3b",
boxShadow: "0 0 4px #ffeb3b",
transform: "translateY(-1px)",
pointerEvents: "none",
}}
/>
)}
</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: viewportModes[i] === "stack" && (cineControlsOpenByViewport[i] || (seriesGroupsByViewport[i] || []).length > 1) ? 56 : 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) => {
// Prevent the wheel from bubbling up to the viewport (which listens for wheel
// to perform stack scrolling). Instead, scroll the dropdown itself so the
// user can use the mouse wheel to navigate the list.
try {
e.stopPropagation();
} catch (err) {
// ignore
}
// Prevent page/parent default scrolling and manually adjust scrollTop so we
// don't rely on the parent's wheel handlers.
try {
e.preventDefault();
} catch (err) {
// ignore
}
const el = e.currentTarget as HTMLElement | null;
if (el) {
// React's WheelEvent type differs from the DOM WheelEvent; safely
// read deltaY via any to avoid casting issues.
const delta = (e as any).deltaY ?? ((e.nativeEvent as any)?.deltaY ?? 0);
el.scrollTop += delta;
}
}}
onBlur={e => {
const blurHost = e.currentTarget as HTMLElement | null;
setTimeout(() => {
if (!blurHost) {
setOpenDropdownIdx(null);
return;
}
if (!blurHost.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) {
appLogger.warn(`Attempted to select invalid stack at index ${idx}`, stack);
return;
}
handleLoadStack(i, idx).catch(err => appLogger.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 >
);
}
const mobileQuickTools = [
StackScrollTool.toolName,
WindowLevelTool.toolName,
PanTool.toolName,
ZoomTool.toolName,
];
const mobileGridLayouts: Array<{ rows: number; cols: number }> = [
{ rows: 1, cols: 1 },
{ rows: 1, cols: 2 },
{ rows: 2, cols: 2 },
];
const hideChromeForMobileInteraction = isPhoneLayout && mobileInteractionActive;
return (
<div
ref={appRootRef}
style={{
position: "relative",
inset: 0,
width: "100%",
height: "100%",
boxSizing: "border-box",
background: hideChromeForMobileInteraction ? "#000" : "#222",
padding: hideChromeForMobileInteraction ? "0px" : (isPhoneLayout ? "6px" : "12px"),
overflow: "hidden",
}}>
{/* Loading overlay */}
{loadingState && loadingState !== "displayset" && (
<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>
)}
{/* Graphics error banner (shows when we detect a problematic WebGL release error) */}
{graphicsErrorVisible && (
<div
style={{
position: 'fixed',
top: 12,
left: 12,
right: 12,
zIndex: 10000,
background: '#fff3cd',
color: '#856404',
border: '1px solid #ffeeba',
padding: '10px 14px',
borderRadius: 6,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
}}
>
<div style={{ flex: 1, fontSize: 14 }}>
<strong>Graphics error:</strong> The viewer encountered a WebGL/graphics error and may be unstable. Please reload the page.
{graphicsErrorMessage ? (<div style={{ marginTop: 6, fontSize: 12, color: '#6b4f00' }}>{graphicsErrorMessage}</div>) : null}
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button onClick={() => window.location.reload()} style={{ padding: '6px 10px' }}>Reload</button>
<button onClick={() => setGraphicsErrorVisible(false)} style={{ padding: '6px 10px' }}>Dismiss</button>
</div>
</div>
)}
{/* top stack bar removed; use right-side panel instead */}
{/* Right-side stack panel is implemented in the main content flex container below */}
{/* Open stacks button: compact and lower z-index so it does not cover modals/controls */}
{!stackPanelOpen && !hideChromeForMobileInteraction && (
<button
onClick={() => setStackPanelOpen(true)}
aria-pressed={false}
title="Open stacks"
style={{
position: 'absolute',
right: 8,
top: 76,
zIndex: 120,
width: 24,
height: 42,
background: '#1976d2',
color: '#fff',
border: 'none',
borderRadius: 5,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
pointerEvents: 'auto'
}}
>
</button>
)}
{hideChromeForMobileInteraction && (
<button
onClick={() => {
setMobileInteractionActive(false);
setMobileFocusedViewport(null);
setMobileSingleViewportMode(false);
setStackPanelOpen(false);
setMobileArmedStackIdx(null);
const root = appRootRef.current;
if (root && document.fullscreenElement === root && document.exitFullscreen) {
document.exitFullscreen().catch(() => {
// Ignore fullscreen exit failures.
});
}
}}
style={{
position: "absolute",
top: 10,
left: 10,
zIndex: 3500,
background: "rgba(0,0,0,0.78)",
color: "#fff",
border: "1px solid rgba(255,255,255,0.25)",
borderRadius: 8,
padding: "8px 10px",
fontSize: 13,
fontWeight: 700,
}}
>
Exit Viewer
</button>
)}
{hideChromeForMobileInteraction && (
<div
style={{
position: "absolute",
top: 10,
right: 10,
zIndex: 3500,
display: "flex",
gap: 8,
}}
>
<button
onClick={() => setStackPanelOpen((open) => !open)}
style={{
background: "rgba(0,0,0,0.78)",
color: "#fff",
border: "1px solid rgba(255,255,255,0.25)",
borderRadius: 8,
padding: "8px 10px",
fontSize: 13,
fontWeight: 700,
}}
>
Stacks
</button>
<button
onClick={() => {
if (mobileSingleViewportMode) {
setMobileSingleViewportMode(false);
} else {
setMobileSingleViewportMode(true);
setMobileFocusedViewport(activeViewport ?? 0);
}
}}
style={{
background: "rgba(0,0,0,0.78)",
color: "#fff",
border: "1px solid rgba(255,255,255,0.25)",
borderRadius: 8,
padding: "8px 10px",
fontSize: 13,
fontWeight: 700,
}}
>
{mobileSingleViewportMode ? "Grid" : "Focus"}
</button>
<button
onClick={() => {
setViewportGrid((prev) => {
const idx = mobileGridLayouts.findIndex((g) => g.rows === prev.rows && g.cols === prev.cols);
const next = mobileGridLayouts[(idx + 1 + mobileGridLayouts.length) % mobileGridLayouts.length];
return next;
});
}}
style={{
background: "rgba(0,0,0,0.78)",
color: "#fff",
border: "1px solid rgba(255,255,255,0.25)",
borderRadius: 8,
padding: "8px 10px",
fontSize: 13,
fontWeight: 700,
}}
>
Layout
</button>
</div>
)}
{mobilePointerDrag && (
<div
style={{
position: "fixed",
left: mobilePointerDrag.x + 14,
top: mobilePointerDrag.y - 18,
zIndex: 5000,
background: "rgba(21,101,192,0.92)",
color: "#fff",
border: "1px solid rgba(255,255,255,0.35)",
borderRadius: 8,
padding: "5px 8px",
fontSize: 11,
fontWeight: 700,
pointerEvents: "none",
whiteSpace: "nowrap",
}}
>
Drop on viewport {mobilePointerDrag.targetViewportIdx !== null ? mobilePointerDrag.targetViewportIdx + 1 : "-"} ({mobilePointerDrag.zone})
</div>
)}
{!hideChromeForMobileInteraction && (
<div>
{/* Side menu toggle button */}
<button
style={{
position: "absolute",
top: "50%",
transform: "translateY(-50%)",
left: sideMenuButtonLeft,
zIndex: 200,
width: SIDE_MENU_BUTTON_SIZE,
height: SIDE_MENU_BUTTON_SIZE,
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: SIDE_MENU_WIDTH,
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>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 8 }}>
<label style={{ fontSize: 13 }}><input type="radio" name="folderLoadMode" value="add" checked={folderLoadMode==='add'} onChange={()=>setFolderLoadMode('add')} /> Add (append)</label>
<label style={{ fontSize: 13 }}><input type="radio" name="folderLoadMode" value="replace" checked={folderLoadMode==='replace'} onChange={()=>setFolderLoadMode('replace')} /> Replace all</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={handleFolderPick}
>
Load DICOM Folder
</button>
<input
ref={folderInputRef}
type="file"
style={{ display: "none" }}
multiple
// Directory attribute for recursive folder selection (webkit based browsers)
// Note: keep accept off here so folder picker works reliably; filtering happens after selection
onChange={handleFilesSelected}
/>
{/* Keep original file input for non-directory file selection if used elsewhere */}
<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 (Shift always works)"
: "Enable Alt Key for Reference Cursors (Shift always works)"}
</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>
<button
style={{
padding: "10px 18px",
background: volumeDiagnosticsEnabled ? "#1976d2" : "#333",
color: "#fff",
border: "none",
borderRadius: "4px",
fontWeight: "bold",
cursor: "pointer",
fontSize: "15px",
marginBottom: "18px",
width: "100%",
}}
onClick={() => setVolumeDiagnosticsEnabled((prev) => !prev)}
>
{volumeDiagnosticsEnabled ? "Disable" : "Enable"} Volume Diagnostics
</button>
{/* Add more menu items here if needed */}
</div>
</div>
)}
</div>
)}
{/* Viewport grid menu at top center */}
{!hideChromeForMobileInteraction && (
<div
ref={topControlsRef}
className="grid-menu-hover-container"
style={{
position: "absolute",
top: viewportMenuOpen || topControlsPeek ? 8 : -18,
left: "50%",
transform: "translateX(-50%)",
zIndex: 100,
display: "flex",
flexDirection: "row",
alignItems: "flex-start",
gap: 8,
width: "auto",
pointerEvents: "auto",
transition: "top 180ms ease",
paddingTop: 28,
marginTop: -28,
paddingBottom: 12,
}}
onMouseEnter={() => setTopControlsPeek(true)}
onMouseLeave={() => {
if (!viewportMenuOpen) setTopControlsPeek(false);
}}
>
<button
style={{
height: 40,
padding: "6px 12px 6px 10px",
background: "#222",
color: "#fff",
border: "none",
borderRadius: "0 0 4px 4px",
fontWeight: "bold",
cursor: "pointer",
fontSize: "14px",
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
display: "inline-flex",
alignItems: "center",
gap: 8,
}}
title="Toggle viewer fullscreen"
onFocus={() => setTopControlsPeek(true)}
onClick={() => {
setTopControlsPeek(true);
toggleViewerFullscreen();
}}
>
<span style={{ fontSize: 16, lineHeight: 1 }}></span>
<span>Fullscreen</span>
</button>
<div style={{ position: "relative", display: "flex", flexDirection: "column", alignItems: "stretch" }}>
<button
className="grid-menu-btn"
style={{
height: 40,
padding: "6px 14px 6px 10px",
background: "#222",
color: "#fff",
border: "none",
borderRadius: "0 0 4px 4px",
fontWeight: "bold",
cursor: "pointer",
fontSize: "14px",
boxShadow: "0 2px 8px rgba(0,0,0,0.2)",
display: "inline-flex",
alignItems: "center",
gap: 10,
justifyContent: "space-between",
minWidth: 136,
}}
onFocus={() => setTopControlsPeek(true)}
onClick={() => {
setTopControlsPeek(true);
setViewportMenuOpen(open => !open);
}}
tabIndex={0}
>
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
<span
style={{
display: "grid",
gridTemplateColumns: `repeat(${viewportGrid.cols}, 5px)`,
gridTemplateRows: `repeat(${viewportGrid.rows}, 5px)`,
gap: 2,
padding: 1,
}}
>
{Array.from({ length: viewportGrid.rows * viewportGrid.cols }).map((_, idx) => (
<span
key={idx}
style={{
width: 5,
height: 5,
borderRadius: 1,
background: "#90caf9",
display: "block",
}}
/>
))}
</span>
<span>{viewportGrid.rows}x{viewportGrid.cols}</span>
</span>
<span style={{ opacity: 0.8 }}>{viewportMenuOpen ? "▲" : "▼"}</span>
</button>
{viewportMenuOpen && (
<div
style={{
position: "absolute",
top: "calc(100% + 8px)",
right: 0,
background: "#222",
color: "#fff",
borderRadius: "6px",
boxShadow: "0 2px 12px rgba(0,0,0,0.4)",
padding: "12px",
display: "grid",
gridTemplateColumns: "repeat(3, minmax(68px, 1fr))",
gap: "8px",
zIndex: 200,
pointerEvents: "auto",
}}
onMouseDown={(e) => e.stopPropagation()}
>
{gridOptions.map(opt => (
<button
key={`${opt.rows}x${opt.cols}`}
style={{
minWidth: "68px",
height: "54px",
background: (opt.rows === viewportGrid.rows && opt.cols === viewportGrid.cols) ? "#444" : "#333",
color: "#fff",
border: "none",
borderRadius: "4px",
fontWeight: "bold",
cursor: "pointer",
fontSize: "13px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 6,
}}
onClick={() => {
saveVisibleViewportState();
setViewportGrid({ rows: opt.rows, cols: opt.cols })
setViewportMenuOpen(false)
setTopControlsPeek(false)
}}
>
<span
style={{
display: "grid",
gridTemplateColumns: `repeat(${opt.cols}, 6px)`,
gridTemplateRows: `repeat(${opt.rows}, 6px)`,
gap: 2,
}}
>
{Array.from({ length: opt.rows * opt.cols }).map((_, idx) => (
<span
key={idx}
style={{
width: 6,
height: 6,
borderRadius: 1,
background: "#90caf9",
display: "block",
}}
/>
))}
</span>
<span>{opt.rows}x{opt.cols}</span>
</button>
))}
</div>
)}
</div>
</div>
)}
{/* Main content area: viewports and right-side panel in a row so panel pushes content */}
<div style={{ display: 'flex', flexDirection: 'row', height: hideChromeForMobileInteraction ? '100%' : 'calc(100% - 48px)', gap: hideChromeForMobileInteraction ? 0 : 8 }}>
{/* Viewport grid container — flex:1 so it shrinks when panel takes space */}
<div
style={{
flex: 1,
display: 'grid',
margin: hideChromeForMobileInteraction ? 0 : 3,
gridTemplateRows: `repeat(${viewportGrid.rows}, 1fr)`,
gridTemplateColumns: `repeat(${viewportGrid.cols}, 1fr)`,
gap: '5px',
minHeight: 0,
minWidth: 0,
zIndex: 1,
position: 'relative',
}}
>
{viewports}
{/* Settings button positioned relative to the viewport area so it doesn't shift when the right panel opens */}
{!hideChromeForMobileInteraction && (
<button
style={{
position: 'absolute',
top: 8,
right: 8,
zIndex: 2600,
padding: '6px 12px',
background: '#222',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
opacity: 0.85,
userSelect: 'none',
}}
onClick={() => setSettingsOpen(true)}
>
Settings
</button>
)}
{hideChromeForMobileInteraction && (
<div
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 10,
zIndex: 3200,
display: "flex",
justifyContent: "center",
pointerEvents: "none",
}}
>
<div
style={{
pointerEvents: "auto",
display: "flex",
alignItems: "center",
gap: 6,
background: "rgba(0,0,0,0.74)",
border: "1px solid rgba(255,255,255,0.2)",
borderRadius: 10,
padding: "8px 8px",
maxWidth: "95%",
overflowX: "auto",
}}
>
{mobileQuickTools.map((toolName) => {
const isActive = mouseToolBindings.Primary === toolName;
const label = TOOL_OPTIONS.find((opt) => opt.value === toolName)?.label || toolName;
return (
<button
key={toolName}
title={label}
onClick={() => handleToolChange("Primary", toolName)}
style={{
minWidth: 46,
height: 42,
borderRadius: 8,
border: isActive ? "1px solid #90caf9" : "1px solid rgba(255,255,255,0.22)",
background: isActive ? "#1565c0" : "#1f1f1f",
color: "#fff",
fontSize: 18,
fontWeight: 700,
padding: "0 8px",
}}
>
{getToolIcon(toolName)}
</button>
);
})}
</div>
</div>
)}
</div>
{/* Right-side panel occupies layout space when visible; when closed width is 0 and it doesn't catch pointer events */}
<div style={hideChromeForMobileInteraction
? {
position: 'absolute',
left: 8,
right: 8,
bottom: stackPanelOpen ? 62 : -420,
height: 'min(48vh, 420px)',
transition: 'bottom 220ms ease',
display: 'flex',
flexDirection: 'column',
pointerEvents: stackPanelOpen ? 'auto' : 'none',
overflow: 'hidden',
zIndex: 3450,
borderRadius: 12,
border: '1px solid #2f3336',
boxShadow: '0 6px 24px rgba(0,0,0,0.45)',
background: '#1f2428',
}
: {
width: stackPanelOpen ? stackPanelWidth : 0,
transition: 'width 220ms ease',
display: 'flex',
flexDirection: 'column',
pointerEvents: stackPanelOpen ? 'auto' : 'none',
overflow: 'hidden',
position: 'relative',
}}>
{stackPanelOpen && !hideChromeForMobileInteraction && (
<div
onMouseDown={beginStackPanelResize}
title="Resize stacks panel"
style={{
position: 'absolute',
left: 0,
top: 0,
width: 8,
height: '100%',
cursor: 'col-resize',
zIndex: 2800,
background: 'linear-gradient(to right, rgba(100,181,246,0.25), rgba(100,181,246,0))',
}}
/>
)}
{/* Reuse the existing right-side panel markup but ensure pointerEvents are enabled inside */}
<div style={{ pointerEvents: 'auto', width: '100%', height: '100%' }}>
{/* Panel (same content as before) */}
<div
// panel open/close is controlled via the small toggle button and pin state
style={{
height: '100%',
width: '100%',
background: '#1f2428',
color: '#fff',
boxShadow: '0 2px 14px rgba(0,0,0,0.45)',
borderLeft: '1px solid #2f3336',
transform: 'translateX(0)',
transition: 'transform 220ms ease',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', borderBottom: '1px solid #2b2f31' }}>
<div style={{ fontWeight: 700 }}>Stacks</div>
<div style={{ fontSize: 12, color: '#9bb4c8' }}>
Active VP: {activeViewport !== null ? activeViewport + 1 : '-'}
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{hideChromeForMobileInteraction && (
<button
title="Toggle stack drop mode"
onClick={() => {
setMobileArmedDropZone((prev) => {
if (prev === "center") return "right";
if (prev === "right") return "bottom";
return "center";
});
}}
style={{ background: 'transparent', color: '#90caf9', border: '1px solid #335', borderRadius: 6, padding: '4px 8px', cursor: 'pointer', fontSize: 12 }}
>
Drop: {mobileArmedDropZone}
</button>
)}
<button
title="Close"
onClick={() => { setStackPanelOpen(false); }}
style={{ background: 'transparent', color: '#fff', border: 'none', cursor: 'pointer', fontSize: 16 }}
>
×
</button>
</div>
</div>
<div style={{ overflowY: 'auto', padding: 8, flex: 1 }}>
{availableStacks.length === 0 ? (
<div style={{ color: '#999', padding: 12 }}>No stacks available</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{availableStacks.map((stack, idx) => {
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;
const isSelected = selectedStackIdx.includes(idx);
const isActiveViewportStack = activeViewport !== null && selectedStackIdx[activeViewport] === idx;
// Prefer the middle image for a representative thumbnail, fallback to first
const midIndex = Math.floor(imageIds.length / 2);
const chosenImage = imageIds[midIndex] || imageIds[0];
return (
<div
key={idx}
draggable={!invalid}
onDragStart={e => {
if (invalid) return;
setDraggedStackIdx(idx);
setDropPreview(null);
e.dataTransfer.setData('text/stack-idx', String(idx));
e.dataTransfer.effectAllowed = 'copy';
}}
onDragEnd={() => {
setDraggedStackIdx(null);
setDropPreview(null);
}}
onClick={() => {
if (Date.now() < suppressStackRowClickUntilRef.current) return;
if (invalid) return;
const targetViewport = activeViewport ?? 0;
handleLoadStack(targetViewport, idx);
}}
onTouchStart={() => {
if (invalid) return;
if (stackThumbnails[idx] || thumbRequestedRef.current[idx]) return;
const mid = Math.floor(imageIds.length / 2);
const chosen = imageIds[mid] || imageIds[0] || null;
if (chosen) generateThumbnail(idx, chosen as string | null);
}}
onMouseEnter={() => {
if (invalid) return;
if (stackThumbnails[idx] || thumbRequestedRef.current[idx]) return;
const mid = Math.floor(imageIds.length / 2);
const chosen = imageIds[mid] || imageIds[0] || null;
if (chosen) generateThumbnail(idx, chosen as string | null);
}}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 10px',
background: isActiveViewportStack ? '#0d47a1' : (isSelected ? '#1976d2' : 'transparent'),
color: isSelected ? '#fff' : '#ddd',
border: isActiveViewportStack ? '1px solid #64b5f6' : '1px solid transparent',
borderRadius: 6,
cursor: invalid ? 'not-allowed' : 'pointer',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{(() => {
const generated = stackThumbnails[idx];
// If generation explicitly failed, treat as missing so placeholder is shown
const generatedValid = generated && generated !== 'BROKEN' ? generated : null;
const src = generatedValid || null;
if (!src) {
return (<div style={{ width: 56, height: 56, borderRadius: 4, background: '#111', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#777' }}>No Img</div>);
}
return (
<img
data-thumb-stack={idx}
src={src}
alt={label}
style={{ width: 56, height: 56, objectFit: 'cover', borderRadius: 4, background: '#111' }}
onError={() => {
appLogger.warn('Thumbnail <img> failed to load for stack', idx, src);
// Generated image failed: mark as broken to avoid endless reload attempts.
setStackThumbnail(idx, 'BROKEN');
}}
/>
);
})()}
<div style={{ display: 'flex', flexDirection: 'column' }}>
<div style={{ fontWeight: 600 }}>{label}</div>
<div style={{ fontSize: 12, opacity: 0.7 }}>{count} img{count === 1 ? '' : 's'}</div>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 4 }}>
<div style={{ fontSize: 12, opacity: 0.8 }}>{stack.studyId || ''}</div>
{isActiveViewportStack && (
<div style={{ fontSize: 11, color: '#bbdefb', fontWeight: 700 }}>ACTIVE</div>
)}
{hideChromeForMobileInteraction && !invalid && (
<div style={{ display: 'flex', gap: 6 }}>
<button
onClick={(e) => {
e.stopPropagation();
const targetViewport = activeViewport ?? 0;
handleLoadStack(targetViewport, idx);
setStackPanelOpen(false);
}}
style={{
background: '#1565c0',
color: '#fff',
border: 'none',
borderRadius: 4,
padding: '3px 7px',
fontSize: 11,
cursor: 'pointer',
}}
>
Load
</button>
<button
onClick={(e) => {
e.stopPropagation();
if (Date.now() < suppressStackRowClickUntilRef.current) return;
setMobileArmedStackIdx(idx);
}}
onPointerDown={(e) => {
beginMobilePointerStackDrag(idx, e);
}}
style={{
background: mobileArmedStackIdx === idx ? '#2e7d32' : '#444',
color: '#fff',
border: 'none',
borderRadius: 4,
padding: '3px 7px',
fontSize: 11,
cursor: 'pointer',
}}
>
{mobileArmedStackIdx === idx ? 'Armed' : 'Drag'}
</button>
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
</div>
</div>
{/* duplicate Settings button removed; the Settings button is now placed inside the viewport area */}
{/* 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: "flex-start",
justifyContent: "center",
overflowY: "auto",
padding: "16px 12px",
}}
onClick={() => setSettingsOpen(false)}
>
<div
style={{
background: "#222",
color: "#fff",
borderRadius: "8px",
minWidth: "320px",
minHeight: "180px",
maxWidth: "min(960px, 96vw)",
maxHeight: "calc(100vh - 32px)",
padding: "24px",
boxShadow: "0 4px 24px rgba(0,0,0,0.4)",
position: "relative",
overflowY: "auto",
margin: "0 auto",
}}
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} title={opt.label}>{`${opt.icon ? `${opt.icon} ` : ""}${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} title={opt.label}>{`${opt.icon ? `${opt.icon} ` : ""}${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} title={opt.label}>{`${opt.icon ? `${opt.icon} ` : ""}${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} title={opt.label}>{`${opt.icon ? `${opt.icon} ` : ""}${opt.label}`}</option>
))}
</select>
</div>
))}
</div>
)}
</div>
</div>
<div style={{ marginTop: 8, paddingTop: 16, borderTop: "1px solid #333" }}>
<div style={{ marginBottom: 10, fontWeight: "bold" }}>Stack Grouping</div>
<label style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer", marginBottom: 10 }}>
<input
type="checkbox"
checked={advancedGroupingEnabled}
onChange={(e) => setAdvancedGroupingEnabled(e.target.checked)}
/>
Enable advanced DWI/time/echo grouping
</label>
<div style={{ display: "flex", flexDirection: "column", gap: 6, opacity: advancedGroupingEnabled ? 1 : 0.6 }}>
{GROUPING_RULE_DEFINITIONS.map((rule) => (
<label key={rule.id} style={{ display: "flex", alignItems: "center", gap: 8, cursor: advancedGroupingEnabled ? "pointer" : "default" }} title={rule.description}>
<input
type="checkbox"
disabled={!advancedGroupingEnabled}
checked={!!groupingRuleEnabled[rule.id]}
onChange={(e) => {
const checked = e.target.checked;
setGroupingRuleEnabled((prev) => ({ ...prev, [rule.id]: checked }));
}}
/>
{rule.label}
</label>
))}
</div>
</div>
{/* Logging — placed at bottom of settings panel */}
<div style={{ marginTop: 8, paddingTop: 16, borderTop: "1px solid #333" }}>
<div style={{ marginBottom: 10, fontWeight: "bold" }}>Logging</div>
<div style={{ display: "flex", alignItems: "center", gap: 16, marginBottom: 10, flexWrap: "wrap" }}>
<div>
<label style={{ marginRight: 8 }}>Level:</label>
<select
value={loggingMinLevel}
onChange={(e) => setLoggingMinLevel(parseLogLevel(e.target.value))}
style={{
background: "#333",
color: "#fff",
border: "1px solid #444",
borderRadius: "4px",
padding: "4px 8px",
fontSize: "14px",
}}
>
{LOG_LEVEL_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div>
<label style={{ marginRight: 8 }}>Format:</label>
<select
value={loggingFormat}
onChange={(e) => setLoggingFormat(parseLogFormat(e.target.value))}
style={{
background: "#333",
color: "#fff",
border: "1px solid #444",
borderRadius: "4px",
padding: "4px 8px",
fontSize: "14px",
}}
>
{LOG_FORMAT_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</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 >
)
}
function setupTools(MAIN_TOOL_GROUP_ID) {
appLogger.debug('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
}
}