8151 lines
315 KiB
TypeScript
8151 lines
315 KiB
TypeScript
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 ? (
|
||
<> [Frame: {imageInfos[i]?.frameIndex} / {imageInfos[i]?.totalFrames}]</>
|
||
) : null}
|
||
</div>
|
||
<div>
|
||
WC: {imageInfos[i]?.windowCenter} 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> ({imageInfos[i]?.slicePosition})</span>
|
||
)}
|
||
<br />
|
||
WC: {imageInfos[i]?.windowCenter} 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
|
||
}
|
||
} |