Enhance stack management by adding series groups support and improving multiframe handling
This commit is contained in:
+592
-33
@@ -71,6 +71,15 @@ type StackEntry = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
caseId?: string;
|
caseId?: string;
|
||||||
studyId?: string;
|
studyId?: string;
|
||||||
|
seriesGroups?: StackSeriesGroup[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type StackSeriesGroup = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
imageIds: string[];
|
||||||
|
seriesInstanceUID?: string;
|
||||||
|
bValue?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ViewportDropZone = "center" | "left" | "right" | "top" | "bottom";
|
type ViewportDropZone = "center" | "left" | "right" | "top" | "bottom";
|
||||||
@@ -175,6 +184,36 @@ function stripWadouriPrefix(imageId?: string): string {
|
|||||||
return imageId.replace(/^wadouri:+/, "").replace(/^:+/, "");
|
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}`);
|
||||||
|
}
|
||||||
|
return `${imageId}${imageId.includes("?") ? "&" : "?"}frame=${frame}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
function isCanvasLikelyReadyForThumbnail(canvas: HTMLCanvasElement): boolean {
|
||||||
try {
|
try {
|
||||||
const ctx = canvas.getContext('2d', { willReadFrequently: true } as CanvasRenderingContext2DSettings);
|
const ctx = canvas.getContext('2d', { willReadFrequently: true } as CanvasRenderingContext2DSettings);
|
||||||
@@ -325,6 +364,19 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
const [selectedStackIdx, setSelectedStackIdx] = useState<number[]>(
|
const [selectedStackIdx, setSelectedStackIdx] = useState<number[]>(
|
||||||
() => Array(MAX_VIEWPORTS).fill(0)
|
() => 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 multiframeCountCacheRef = useRef<Record<string, number>>({});
|
||||||
|
|
||||||
// Client-side generated thumbnails cache: stackIndex -> dataURL
|
// Client-side generated thumbnails cache: stackIndex -> dataURL
|
||||||
const [stackThumbnails, setStackThumbnails] = useState<Record<number, string>>({});
|
const [stackThumbnails, setStackThumbnails] = useState<Record<number, string>>({});
|
||||||
@@ -704,9 +756,38 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
const normalized: Array<StackEntry> = await Promise.all(
|
const normalized: Array<StackEntry> = await Promise.all(
|
||||||
stacksRaw.map(async (d) => {
|
stacksRaw.map(async (d) => {
|
||||||
if (Array.isArray(d)) {
|
if (Array.isArray(d)) {
|
||||||
return { imageIds: d as string[] };
|
const expanded = await expandMultiframeImageIds(d as string[]);
|
||||||
|
return { imageIds: expanded };
|
||||||
}
|
}
|
||||||
const imageIds = Array.isArray((d as any).imageIds) ? (d as any).imageIds : (Array.isArray((d as any).i) ? (d as any).i : []);
|
const imageIdsRaw = Array.isArray((d as any).imageIds) ? (d as any).imageIds : (Array.isArray((d as any).i) ? (d as any).i : []);
|
||||||
|
const imageIds = await expandMultiframeImageIds(imageIdsRaw);
|
||||||
|
let seriesGroups: StackSeriesGroup[] | undefined;
|
||||||
|
|
||||||
|
if (Array.isArray((d as any).series)) {
|
||||||
|
const seriesExpanded = await Promise.all(
|
||||||
|
(d as any).series.map(async (seriesEntry: any, seriesIdx: number) => {
|
||||||
|
const rawIds = Array.isArray(seriesEntry?.imageIds)
|
||||||
|
? seriesEntry.imageIds
|
||||||
|
: (Array.isArray(seriesEntry?.i) ? seriesEntry.i : []);
|
||||||
|
const expandedIds = await expandMultiframeImageIds(rawIds);
|
||||||
|
const rawLabel = typeof seriesEntry?.name === "string" ? seriesEntry.name : "";
|
||||||
|
const label = rawLabel.trim() || `Series ${seriesIdx + 1}`;
|
||||||
|
return {
|
||||||
|
key: String(seriesEntry?.seriesInstanceUID || seriesEntry?.seriesId || `SERIES_${seriesIdx}`),
|
||||||
|
label,
|
||||||
|
imageIds: expandedIds,
|
||||||
|
seriesInstanceUID: seriesEntry?.seriesInstanceUID,
|
||||||
|
bValue: Number.isFinite(Number(seriesEntry?.bValue)) ? Number(seriesEntry.bValue) : null,
|
||||||
|
} as StackSeriesGroup;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const filtered = seriesExpanded.filter((group) => group.imageIds.length > 0);
|
||||||
|
if (filtered.length > 0) {
|
||||||
|
seriesGroups = filtered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// try to preserve old studyInstanceUID extraction if needed (best-effort)
|
// try to preserve old studyInstanceUID extraction if needed (best-effort)
|
||||||
let studyInstanceUID: string | undefined = (d as any).studyInstanceUID;
|
let studyInstanceUID: string | undefined = (d as any).studyInstanceUID;
|
||||||
// optional: attempt to fetch first file and extract StudyInstanceUID if dicomParser available
|
// optional: attempt to fetch first file and extract StudyInstanceUID if dicomParser available
|
||||||
@@ -726,6 +807,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
imageIds,
|
imageIds,
|
||||||
|
seriesGroups,
|
||||||
studyInstanceUID,
|
studyInstanceUID,
|
||||||
studyId: (d as any).studyId,
|
studyId: (d as any).studyId,
|
||||||
name: (d as any).name,
|
name: (d as any).name,
|
||||||
@@ -738,10 +820,16 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
|
|
||||||
// Seed viewports with first stack if present (preserve old behavior)
|
// Seed viewports with first stack if present (preserve old behavior)
|
||||||
if (normalized[0]) {
|
if (normalized[0]) {
|
||||||
await setLoadedImageIdsAndCacheMeta(normalized[0].imageIds);
|
const firstStack = normalized[0];
|
||||||
setViewportImageIds(Array(MAX_VIEWPORTS).fill(normalized[0].imageIds || []));
|
const firstSeries = firstStack.seriesGroups?.[0]?.imageIds || firstStack.imageIds;
|
||||||
|
await setLoadedImageIdsAndCacheMeta(firstSeries);
|
||||||
|
setViewportImageIds(Array(MAX_VIEWPORTS).fill(firstSeries || []));
|
||||||
|
setSeriesGroupsByViewport(Array(MAX_VIEWPORTS).fill(null).map(() => firstStack.seriesGroups || []));
|
||||||
|
setSelectedSeriesIdxByViewport(Array(MAX_VIEWPORTS).fill(0));
|
||||||
} else {
|
} else {
|
||||||
setViewportImageIds(Array(MAX_VIEWPORTS).fill([]));
|
setViewportImageIds(Array(MAX_VIEWPORTS).fill([]));
|
||||||
|
setSeriesGroupsByViewport(Array(MAX_VIEWPORTS).fill(null).map(() => []));
|
||||||
|
setSelectedSeriesIdxByViewport(Array(MAX_VIEWPORTS).fill(0));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
@@ -756,8 +844,22 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
const handleLoadStack = async (viewportIdx: number, stackIdx: number) => {
|
const handleLoadStack = async (viewportIdx: number, stackIdx: number) => {
|
||||||
const stackObj = availableStacks[stackIdx];
|
const stackObj = availableStacks[stackIdx];
|
||||||
if (!stackObj) return;
|
if (!stackObj) return;
|
||||||
|
let seriesGroups = stackObj.seriesGroups;
|
||||||
const stack = stackObj.imageIds;
|
const stack = stackObj.imageIds;
|
||||||
|
|
||||||
|
await setLoadedImageIdsAndCacheMeta(stack, { force: true });
|
||||||
|
if (!seriesGroups || seriesGroups.length === 0) {
|
||||||
|
seriesGroups = deriveSeriesGroupsFromImageIds(stack);
|
||||||
|
setAvailableStacks(prev => {
|
||||||
|
const next = [...prev];
|
||||||
|
if (!next[stackIdx]) return prev;
|
||||||
|
next[stackIdx] = { ...next[stackIdx], seriesGroups };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const primarySeries = seriesGroups[0]?.imageIds || stack;
|
||||||
|
|
||||||
setLoadingState("displayset");
|
setLoadingState("displayset");
|
||||||
try {
|
try {
|
||||||
// Always revert to stack mode when loading a new stack
|
// Always revert to stack mode when loading a new stack
|
||||||
@@ -769,7 +871,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
|
|
||||||
setViewportImageIds(prev => {
|
setViewportImageIds(prev => {
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
next[viewportIdx] = stack;
|
next[viewportIdx] = primarySeries;
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
setSelectedStackIdx(prev => {
|
setSelectedStackIdx(prev => {
|
||||||
@@ -777,9 +879,19 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
next[viewportIdx] = stackIdx;
|
next[viewportIdx] = stackIdx;
|
||||||
return next;
|
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
|
// Cache imageIds and load metadata for this stack
|
||||||
await setLoadedImageIdsAndCacheMeta(stack);
|
await setLoadedImageIdsAndCacheMeta(primarySeries);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingState(null);
|
setLoadingState(null);
|
||||||
}
|
}
|
||||||
@@ -1047,6 +1159,147 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getNumberOfFramesForImageId = 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (e) {
|
||||||
|
// Ignore metadata lookup failures.
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cacheManager = cornerstoneDICOMImageLoader?.wadouri?.dataSetCacheManager;
|
||||||
|
if (cacheManager?.get) {
|
||||||
|
const dataSet = cacheManager.get(url);
|
||||||
|
const fromCache = readFramesFromDataSet(dataSet);
|
||||||
|
multiframeCountCacheRef.current[cacheKey] = fromCache;
|
||||||
|
return fromCache;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore cache manager failures.
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
|
||||||
|
const fromFetch = readFramesFromDataSet(dataSet);
|
||||||
|
multiframeCountCacheRef.current[cacheKey] = fromFetch;
|
||||||
|
return fromFetch;
|
||||||
|
} catch (e) {
|
||||||
|
multiframeCountCacheRef.current[cacheKey] = 1;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const expandMultiframeImageIds = useCallback(async (rawImageIds: string[]) => {
|
||||||
|
const expanded: string[] = [];
|
||||||
|
|
||||||
|
for (const raw of rawImageIds || []) {
|
||||||
|
const imageId = normalizeToWadouriOrExternal(raw);
|
||||||
|
if (!imageId.startsWith("wadouri:") || hasFrameQuery(imageId)) {
|
||||||
|
expanded.push(imageId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frameCount = await getNumberOfFramesForImageId(imageId);
|
||||||
|
if (frameCount <= 1) {
|
||||||
|
expanded.push(imageId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let frame = 1; frame <= frameCount; frame++) {
|
||||||
|
expanded.push(withFrameQuery(imageId, frame));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dedupeImageIds(expanded);
|
||||||
|
}, [getNumberOfFramesForImageId]);
|
||||||
|
|
||||||
|
const deriveSeriesGroupsFromImageIds = useCallback((imageIds: string[]): StackSeriesGroup[] => {
|
||||||
|
if (!Array.isArray(imageIds) || imageIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = new Map<string, { imageIds: string[]; seriesInstanceUID?: string; seriesDescription?: string; seriesNumber?: number; bValue?: number | null }>();
|
||||||
|
|
||||||
|
imageIds.forEach((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();
|
||||||
|
const seriesDescription = (seriesMeta.seriesDescription || "").trim();
|
||||||
|
const seriesNumber = Number(seriesMeta.seriesNumber);
|
||||||
|
const temporalPosition = Number(imageMeta.temporalPositionIdentifier);
|
||||||
|
const triggerTime = Number(imageMeta.triggerTime);
|
||||||
|
const bValueRaw = Number(mrMeta.diffusionBValue ?? mrMeta.diffusionbValue ?? mrMeta.bValue);
|
||||||
|
const bValue = Number.isFinite(bValueRaw) ? bValueRaw : null;
|
||||||
|
|
||||||
|
const temporalKey = Number.isFinite(temporalPosition) ? `t${temporalPosition}` : "";
|
||||||
|
const triggerKey = Number.isFinite(triggerTime) ? `tr${Math.round(triggerTime)}` : "";
|
||||||
|
const bKey = bValue !== null ? `b${Math.round(bValue)}` : "";
|
||||||
|
|
||||||
|
const keyParts = [seriesInstanceUID || "NO_UID", bKey, temporalKey, triggerKey].filter(Boolean);
|
||||||
|
const groupKey = keyParts.join("|") || "SERIES_0";
|
||||||
|
|
||||||
|
if (!groups.has(groupKey)) {
|
||||||
|
groups.set(groupKey, {
|
||||||
|
imageIds: [],
|
||||||
|
seriesInstanceUID: seriesInstanceUID || undefined,
|
||||||
|
seriesDescription: seriesDescription || undefined,
|
||||||
|
seriesNumber: Number.isFinite(seriesNumber) ? seriesNumber : undefined,
|
||||||
|
bValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.get(groupKey)!.imageIds.push(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (groups.size <= 1) {
|
||||||
|
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.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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return ordered;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
const [viewportGrid, setViewportGrid] = useState({ rows: 1, cols: 1 })
|
const [viewportGrid, setViewportGrid] = useState({ rows: 1, cols: 1 })
|
||||||
const [viewportMenuOpen, setViewportMenuOpen] = useState(false)
|
const [viewportMenuOpen, setViewportMenuOpen] = useState(false)
|
||||||
@@ -1644,6 +1897,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
let seriesInstanceUID: string | undefined = undefined;
|
let seriesInstanceUID: string | undefined = undefined;
|
||||||
let instanceNumber: number | undefined = undefined;
|
let instanceNumber: number | undefined = undefined;
|
||||||
let sliceLocation: number | undefined = undefined;
|
let sliceLocation: number | undefined = undefined;
|
||||||
|
let numberOfFrames = 1;
|
||||||
let parsedSuccessfully = false;
|
let parsedSuccessfully = false;
|
||||||
if (!isImage) {
|
if (!isImage) {
|
||||||
try {
|
try {
|
||||||
@@ -1655,6 +1909,13 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
if (instStr !== undefined && instStr !== null && instStr !== '') instanceNumber = parseInt(instStr as string, 10);
|
if (instStr !== undefined && instStr !== null && instStr !== '') instanceNumber = parseInt(instStr as string, 10);
|
||||||
const sliceStr = dataSet.string('x00201041') || dataSet.string('x00201040');
|
const sliceStr = dataSet.string('x00201041') || dataSet.string('x00201040');
|
||||||
if (sliceStr !== undefined && sliceStr !== null && sliceStr !== '') sliceLocation = parseFloat(sliceStr as string);
|
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;
|
parsedSuccessfully = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Not a DICOM or failed to parse
|
// Not a DICOM or failed to parse
|
||||||
@@ -1662,7 +1923,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const imageId = isImage ? `external:${URL.createObjectURL(file)}` : `wadouri:${URL.createObjectURL(file)}`;
|
const imageId = isImage ? `external:${URL.createObjectURL(file)}` : `wadouri:${URL.createObjectURL(file)}`;
|
||||||
return { file, imageId, studyInstanceUID, seriesInstanceUID, instanceNumber, sliceLocation, isImage, parsedSuccessfully };
|
return { file, imageId, studyInstanceUID, seriesInstanceUID, instanceNumber, sliceLocation, numberOfFrames, isImage, parsedSuccessfully };
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Group parsed entries by SeriesInstanceUID (fallback to StudyInstanceUID or filename folder)
|
// Group parsed entries by SeriesInstanceUID (fallback to StudyInstanceUID or filename folder)
|
||||||
@@ -1690,8 +1951,28 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
if (a.instanceNumber !== undefined && b.instanceNumber !== undefined) return (a.instanceNumber - b.instanceNumber);
|
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);
|
return (a.file.webkitRelativePath || a.file.name).localeCompare(b.file.webkitRelativePath || b.file.name);
|
||||||
});
|
});
|
||||||
const imageIds = entries.map(e => e.imageId);
|
const imageIds = entries.flatMap((e) => {
|
||||||
stacks.push({ imageIds, studyInstanceUID: g.studyInstanceUID, seriesInstanceUID: g.seriesInstanceUID });
|
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;
|
||||||
|
});
|
||||||
|
stacks.push({
|
||||||
|
imageIds,
|
||||||
|
studyInstanceUID: g.studyInstanceUID,
|
||||||
|
seriesInstanceUID: g.seriesInstanceUID,
|
||||||
|
seriesGroups: [{
|
||||||
|
key: String(g.seriesInstanceUID || key),
|
||||||
|
label: `Series ${stacks.length + 1}`,
|
||||||
|
imageIds,
|
||||||
|
seriesInstanceUID: g.seriesInstanceUID,
|
||||||
|
bValue: null,
|
||||||
|
}],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stacks.length === 0) return; // nothing to do
|
if (stacks.length === 0) return; // nothing to do
|
||||||
@@ -1728,11 +2009,22 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
setViewportImageIds(() => {
|
setViewportImageIds(() => {
|
||||||
const arr = Array(MAX_VIEWPORTS).fill([] as string[]);
|
const arr = Array(MAX_VIEWPORTS).fill([] as string[]);
|
||||||
for (let i = 0; i < MAX_VIEWPORTS; i++) {
|
for (let i = 0; i < MAX_VIEWPORTS; i++) {
|
||||||
arr[i] = stacks[i] ? stacks[i].imageIds : stacks[0].imageIds;
|
const stackForViewport = stacks[i] || stacks[0];
|
||||||
|
const firstSeries = stackForViewport?.seriesGroups?.[0]?.imageIds || stackForViewport?.imageIds || [];
|
||||||
|
arr[i] = firstSeries;
|
||||||
}
|
}
|
||||||
return arr;
|
return arr;
|
||||||
});
|
});
|
||||||
setSelectedStackIdx(Array(MAX_VIEWPORTS).fill(0));
|
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)
|
// Cache metadata for all stacks (fire-and-forget)
|
||||||
for (const s of stacks) {
|
for (const s of stacks) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
@@ -1745,7 +2037,8 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
// set first viewport to the last appended stack
|
// set first viewport to the last appended stack
|
||||||
setViewportImageIds(vpPrev => {
|
setViewportImageIds(vpPrev => {
|
||||||
const vpNext = [...vpPrev];
|
const vpNext = [...vpPrev];
|
||||||
vpNext[0] = stacks[stacks.length - 1].imageIds;
|
const latest = stacks[stacks.length - 1];
|
||||||
|
vpNext[0] = latest.seriesGroups?.[0]?.imageIds || latest.imageIds;
|
||||||
return vpNext;
|
return vpNext;
|
||||||
});
|
});
|
||||||
setSelectedStackIdx(selPrev => {
|
setSelectedStackIdx(selPrev => {
|
||||||
@@ -1753,6 +2046,16 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
selNext[0] = next.length - 1;
|
selNext[0] = next.length - 1;
|
||||||
return selNext;
|
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
|
// Cache metadata for appended stacks
|
||||||
for (const s of stacks) {
|
for (const s of stacks) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
@@ -1972,16 +2275,17 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
name: string,
|
name: string,
|
||||||
requestedZone: ViewportDropZone,
|
requestedZone: ViewportDropZone,
|
||||||
) => {
|
) => {
|
||||||
const imageIds = rawImageIds.map(url =>
|
const imageIds = await expandMultiframeImageIds(rawImageIds.map(normalizeToWadouriOrExternal));
|
||||||
url.startsWith('wadouri:') ? url : `wadouri:${url}`
|
|
||||||
);
|
|
||||||
if (imageIds.length === 0) return;
|
if (imageIds.length === 0) return;
|
||||||
|
await setLoadedImageIdsAndCacheMeta(imageIds, { force: true });
|
||||||
|
const seriesGroups = deriveSeriesGroupsFromImageIds(imageIds);
|
||||||
|
const primarySeries = seriesGroups[0]?.imageIds || imageIds;
|
||||||
|
|
||||||
// Add the incoming stack to the panel and capture its index.
|
// Add the incoming stack to the panel and capture its index.
|
||||||
let newStackIdx = 0;
|
let newStackIdx = 0;
|
||||||
setAvailableStacks(prev => {
|
setAvailableStacks(prev => {
|
||||||
newStackIdx = prev.length;
|
newStackIdx = prev.length;
|
||||||
return [...prev, { imageIds, name }];
|
return [...prev, { imageIds, name, seriesGroups }];
|
||||||
});
|
});
|
||||||
|
|
||||||
const resolvedZone = resolveDropZone(requestedZone);
|
const resolvedZone = resolveDropZone(requestedZone);
|
||||||
@@ -1989,10 +2293,20 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
setLoadingState('displayset');
|
setLoadingState('displayset');
|
||||||
try {
|
try {
|
||||||
setViewportModes(prev => { const n = [...prev]; n[viewportIdx] = 'stack'; return n; });
|
setViewportModes(prev => { const n = [...prev]; n[viewportIdx] = 'stack'; return n; });
|
||||||
setViewportImageIds(prev => { const n = [...prev]; n[viewportIdx] = imageIds; return n; });
|
setViewportImageIds(prev => { const n = [...prev]; n[viewportIdx] = primarySeries; return n; });
|
||||||
setSelectedStackIdx(prev => { const n = [...prev]; n[viewportIdx] = newStackIdx; return n; });
|
setSelectedStackIdx(prev => { const n = [...prev]; n[viewportIdx] = newStackIdx; return n; });
|
||||||
|
setSeriesGroupsByViewport(prev => {
|
||||||
|
const n = [...prev];
|
||||||
|
n[viewportIdx] = seriesGroups;
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
setSelectedSeriesIdxByViewport(prev => {
|
||||||
|
const n = [...prev];
|
||||||
|
n[viewportIdx] = 0;
|
||||||
|
return n;
|
||||||
|
});
|
||||||
setActiveViewport(viewportIdx);
|
setActiveViewport(viewportIdx);
|
||||||
await setLoadedImageIdsAndCacheMeta(imageIds);
|
await setLoadedImageIdsAndCacheMeta(primarySeries);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingState(null);
|
setLoadingState(null);
|
||||||
}
|
}
|
||||||
@@ -2028,6 +2342,8 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
const nextImageIds = Array(MAX_VIEWPORTS).fill([] as string[]);
|
const nextImageIds = Array(MAX_VIEWPORTS).fill([] as string[]);
|
||||||
const nextModes = Array(MAX_VIEWPORTS).fill('stack') as Array<'stack' | 'volume'>;
|
const nextModes = Array(MAX_VIEWPORTS).fill('stack') as Array<'stack' | 'volume'>;
|
||||||
const nextSelected = Array(MAX_VIEWPORTS).fill(0);
|
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++) {
|
for (let oldIdx = 0; oldIdx < oldRows * oldCols; oldIdx++) {
|
||||||
const oldRow = Math.floor(oldIdx / oldCols);
|
const oldRow = Math.floor(oldIdx / oldCols);
|
||||||
@@ -2048,11 +2364,15 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
nextImageIds[mappedIdx] = viewportImageIds[oldIdx] || [];
|
nextImageIds[mappedIdx] = viewportImageIds[oldIdx] || [];
|
||||||
nextModes[mappedIdx] = viewportModes[oldIdx] || 'stack';
|
nextModes[mappedIdx] = viewportModes[oldIdx] || 'stack';
|
||||||
nextSelected[mappedIdx] = selectedStackIdx[oldIdx] ?? 0;
|
nextSelected[mappedIdx] = selectedStackIdx[oldIdx] ?? 0;
|
||||||
|
nextSeriesGroups[mappedIdx] = seriesGroupsByViewport[oldIdx] || [];
|
||||||
|
nextSeriesIdx[mappedIdx] = selectedSeriesIdxByViewport[oldIdx] ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
nextImageIds[insertedViewportIndex] = imageIds;
|
nextImageIds[insertedViewportIndex] = primarySeries;
|
||||||
nextModes[insertedViewportIndex] = 'stack';
|
nextModes[insertedViewportIndex] = 'stack';
|
||||||
nextSelected[insertedViewportIndex] = newStackIdx;
|
nextSelected[insertedViewportIndex] = newStackIdx;
|
||||||
|
nextSeriesGroups[insertedViewportIndex] = seriesGroups;
|
||||||
|
nextSeriesIdx[insertedViewportIndex] = 0;
|
||||||
|
|
||||||
setLoadingState('displayset');
|
setLoadingState('displayset');
|
||||||
try {
|
try {
|
||||||
@@ -2061,8 +2381,10 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
setViewportImageIds(nextImageIds);
|
setViewportImageIds(nextImageIds);
|
||||||
setViewportModes(nextModes);
|
setViewportModes(nextModes);
|
||||||
setSelectedStackIdx(nextSelected);
|
setSelectedStackIdx(nextSelected);
|
||||||
|
setSeriesGroupsByViewport(nextSeriesGroups);
|
||||||
|
setSelectedSeriesIdxByViewport(nextSeriesIdx);
|
||||||
setActiveViewport(insertedViewportIndex);
|
setActiveViewport(insertedViewportIndex);
|
||||||
await setLoadedImageIdsAndCacheMeta(imageIds);
|
await setLoadedImageIdsAndCacheMeta(primarySeries);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingState(null);
|
setLoadingState(null);
|
||||||
}
|
}
|
||||||
@@ -2071,6 +2393,20 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
const applyStackDrop = async (viewportIdx: number, stackIdx: number, requestedZone: ViewportDropZone) => {
|
const applyStackDrop = async (viewportIdx: number, stackIdx: number, requestedZone: ViewportDropZone) => {
|
||||||
const stackObj = availableStacks[stackIdx];
|
const stackObj = availableStacks[stackIdx];
|
||||||
if (!stackObj) return;
|
if (!stackObj) return;
|
||||||
|
let seriesGroups = stackObj.seriesGroups;
|
||||||
|
const stackImageIds = stackObj.imageIds || [];
|
||||||
|
|
||||||
|
await setLoadedImageIdsAndCacheMeta(stackImageIds, { force: true });
|
||||||
|
if (!seriesGroups || seriesGroups.length === 0) {
|
||||||
|
seriesGroups = deriveSeriesGroupsFromImageIds(stackImageIds);
|
||||||
|
setAvailableStacks(prev => {
|
||||||
|
const next = [...prev];
|
||||||
|
if (!next[stackIdx]) return prev;
|
||||||
|
next[stackIdx] = { ...next[stackIdx], seriesGroups };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const primarySeries = seriesGroups[0]?.imageIds || stackImageIds;
|
||||||
|
|
||||||
const resolvedZone = resolveDropZone(requestedZone);
|
const resolvedZone = resolveDropZone(requestedZone);
|
||||||
if (resolvedZone === "center") {
|
if (resolvedZone === "center") {
|
||||||
@@ -2096,6 +2432,8 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
const nextImageIds = Array(MAX_VIEWPORTS).fill([] as string[]);
|
const nextImageIds = Array(MAX_VIEWPORTS).fill([] as string[]);
|
||||||
const nextModes = Array(MAX_VIEWPORTS).fill("stack") as Array<'stack' | 'volume'>;
|
const nextModes = Array(MAX_VIEWPORTS).fill("stack") as Array<'stack' | 'volume'>;
|
||||||
const nextSelected = Array(MAX_VIEWPORTS).fill(0);
|
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 targetNewRow = resolvedZone === "top" ? row : resolvedZone === "bottom" ? row + 1 : row;
|
||||||
const targetNewCol = resolvedZone === "left" ? col : resolvedZone === "right" ? col + 1 : col;
|
const targetNewCol = resolvedZone === "left" ? col : resolvedZone === "right" ? col + 1 : col;
|
||||||
@@ -2135,11 +2473,15 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
nextImageIds[mappedIdx] = viewportImageIds[oldIdx] || [];
|
nextImageIds[mappedIdx] = viewportImageIds[oldIdx] || [];
|
||||||
nextModes[mappedIdx] = viewportModes[oldIdx] || "stack";
|
nextModes[mappedIdx] = viewportModes[oldIdx] || "stack";
|
||||||
nextSelected[mappedIdx] = selectedStackIdx[oldIdx] ?? 0;
|
nextSelected[mappedIdx] = selectedStackIdx[oldIdx] ?? 0;
|
||||||
|
nextSeriesGroups[mappedIdx] = seriesGroupsByViewport[oldIdx] || [];
|
||||||
|
nextSeriesIdx[mappedIdx] = selectedSeriesIdxByViewport[oldIdx] ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
nextImageIds[insertedViewportIndex] = stackObj.imageIds;
|
nextImageIds[insertedViewportIndex] = primarySeries;
|
||||||
nextModes[insertedViewportIndex] = "stack";
|
nextModes[insertedViewportIndex] = "stack";
|
||||||
nextSelected[insertedViewportIndex] = stackIdx;
|
nextSelected[insertedViewportIndex] = stackIdx;
|
||||||
|
nextSeriesGroups[insertedViewportIndex] = seriesGroups || [];
|
||||||
|
nextSeriesIdx[insertedViewportIndex] = 0;
|
||||||
|
|
||||||
setLoadingState("displayset");
|
setLoadingState("displayset");
|
||||||
try {
|
try {
|
||||||
@@ -2148,8 +2490,10 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
setViewportImageIds(nextImageIds);
|
setViewportImageIds(nextImageIds);
|
||||||
setViewportModes(nextModes);
|
setViewportModes(nextModes);
|
||||||
setSelectedStackIdx(nextSelected);
|
setSelectedStackIdx(nextSelected);
|
||||||
|
setSeriesGroupsByViewport(nextSeriesGroups);
|
||||||
|
setSelectedSeriesIdxByViewport(nextSeriesIdx);
|
||||||
setActiveViewport(insertedViewportIndex);
|
setActiveViewport(insertedViewportIndex);
|
||||||
await setLoadedImageIdsAndCacheMeta(stackObj.imageIds);
|
await setLoadedImageIdsAndCacheMeta(primarySeries);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingState(null);
|
setLoadingState(null);
|
||||||
}
|
}
|
||||||
@@ -2788,6 +3132,82 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
return () => window.removeEventListener("keydown", handlePresetHotkey);
|
return () => window.removeEventListener("keydown", handlePresetHotkey);
|
||||||
}, [activeViewport, applyPresetOption, imageInfos]);
|
}, [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}`);
|
||||||
|
|
||||||
|
let mappedIndex = 0;
|
||||||
|
if (viewport && typeof viewport.getCurrentImageIdIndex === "function") {
|
||||||
|
const oldIds = viewportImageIds[viewportIdx] || [];
|
||||||
|
const oldTotal = oldIds.length;
|
||||||
|
const currentIdx = viewport.getCurrentImageIdIndex();
|
||||||
|
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))));
|
||||||
|
}
|
||||||
|
|
||||||
|
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: true });
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const vp = renderingEngineRef.current?.getViewport(`CT_${viewportIdx}`);
|
||||||
|
if (!vp || typeof vp.setImageIdIndex !== "function") return;
|
||||||
|
csUtilities.jumpToSlice(vp.element, { imageIndex: mappedIndex });
|
||||||
|
vp.render();
|
||||||
|
updateOverlay(viewportIdx, vp);
|
||||||
|
}, 25);
|
||||||
|
}, [seriesGroupsByViewport, setLoadedImageIdsAndCacheMeta, updateOverlay, viewportImageIds]);
|
||||||
|
|
||||||
|
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 toggleViewerFullscreen = useCallback(() => {
|
||||||
const root = appRootRef.current;
|
const root = appRootRef.current;
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
@@ -2902,11 +3322,15 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
// API: Load a stack from a list of wadouri imageIds (strings)
|
// API: Load a stack from a list of wadouri imageIds (strings)
|
||||||
window.loadDicomStackFromWadouriList = async (wadouriImageIds: string[]) => {
|
window.loadDicomStackFromWadouriList = async (wadouriImageIds: string[]) => {
|
||||||
if (!wadouriImageIds || wadouriImageIds.length === 0) return;
|
if (!wadouriImageIds || wadouriImageIds.length === 0) return;
|
||||||
|
const expandedImageIds = await expandMultiframeImageIds(wadouriImageIds);
|
||||||
|
await setLoadedImageIdsAndCacheMeta(expandedImageIds, { force: true });
|
||||||
|
const seriesGroups = deriveSeriesGroupsFromImageIds(expandedImageIds);
|
||||||
|
const primarySeries = seriesGroups[0]?.imageIds || expandedImageIds;
|
||||||
|
|
||||||
// Try to extract StudyInstanceUID from the first image if possible
|
// Try to extract StudyInstanceUID from the first image if possible
|
||||||
let studyInstanceUID: string | undefined = undefined;
|
let studyInstanceUID: string | undefined = undefined;
|
||||||
try {
|
try {
|
||||||
const firstId = wadouriImageIds[0];
|
const firstId = expandedImageIds[0];
|
||||||
if (firstId && firstId.startsWith("wadouri:")) {
|
if (firstId && firstId.startsWith("wadouri:")) {
|
||||||
const url = stripWadouriPrefix(firstId);
|
const url = stripWadouriPrefix(firstId);
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
@@ -2919,10 +3343,10 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
}
|
}
|
||||||
|
|
||||||
setAvailableStacks(prev => {
|
setAvailableStacks(prev => {
|
||||||
const next = [...prev, { imageIds: wadouriImageIds, studyInstanceUID }];
|
const next = [...prev, { imageIds: expandedImageIds, studyInstanceUID, seriesGroups }];
|
||||||
setViewportImageIds(vpPrev => {
|
setViewportImageIds(vpPrev => {
|
||||||
const vpNext = [...vpPrev];
|
const vpNext = [...vpPrev];
|
||||||
vpNext[0] = wadouriImageIds;
|
vpNext[0] = primarySeries;
|
||||||
return vpNext;
|
return vpNext;
|
||||||
});
|
});
|
||||||
setSelectedStackIdx(selPrev => {
|
setSelectedStackIdx(selPrev => {
|
||||||
@@ -2930,7 +3354,17 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
selNext[0] = next.length - 1;
|
selNext[0] = next.length - 1;
|
||||||
return selNext;
|
return selNext;
|
||||||
});
|
});
|
||||||
setLoadedImageIdsAndCacheMeta(wadouriImageIds);
|
setSeriesGroupsByViewport((prevSeries) => {
|
||||||
|
const nextSeries = [...prevSeries];
|
||||||
|
nextSeries[0] = seriesGroups;
|
||||||
|
return nextSeries;
|
||||||
|
});
|
||||||
|
setSelectedSeriesIdxByViewport((prevIdx) => {
|
||||||
|
const nextIdx = [...prevIdx];
|
||||||
|
nextIdx[0] = 0;
|
||||||
|
return nextIdx;
|
||||||
|
});
|
||||||
|
setLoadedImageIdsAndCacheMeta(primarySeries);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -2939,7 +3373,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
return () => {
|
return () => {
|
||||||
window.loadDicomStackFromWadouriList = undefined;
|
window.loadDicomStackFromWadouriList = undefined;
|
||||||
};
|
};
|
||||||
}, [setAvailableStacks, setViewportImageIds, setSelectedStackIdx, setLoadedImageIdsAndCacheMeta]);
|
}, [deriveSeriesGroupsFromImageIds, expandMultiframeImageIds, setAvailableStacks, setLoadedImageIdsAndCacheMeta, setSelectedStackIdx, setViewportImageIds]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!orderBySliceLocation) return;
|
if (!orderBySliceLocation) return;
|
||||||
@@ -3059,6 +3493,9 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
setAvailableStacks([]);
|
setAvailableStacks([]);
|
||||||
setViewportImageIds(Array(MAX_VIEWPORTS).fill([]));
|
setViewportImageIds(Array(MAX_VIEWPORTS).fill([]));
|
||||||
setSelectedStackIdx(Array(MAX_VIEWPORTS).fill(0));
|
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));
|
||||||
setViewportModes(Array(MAX_VIEWPORTS).fill('stack'));
|
setViewportModes(Array(MAX_VIEWPORTS).fill('stack'));
|
||||||
Object.values(stackThumbnailsRef.current).forEach((thumbnail) => {
|
Object.values(stackThumbnailsRef.current).forEach((thumbnail) => {
|
||||||
if (typeof thumbnail === 'string' && thumbnail.startsWith('blob:')) {
|
if (typeof thumbnail === 'string' && thumbnail.startsWith('blob:')) {
|
||||||
@@ -3173,11 +3610,15 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
|
|
||||||
window[`loadAdditionalStack_${apiKey}`] = async (imageIds: string[], studyInstanceUID?: string) => {
|
window[`loadAdditionalStack_${apiKey}`] = async (imageIds: string[], studyInstanceUID?: string) => {
|
||||||
if (!Array.isArray(imageIds) || imageIds.length === 0) return;
|
if (!Array.isArray(imageIds) || imageIds.length === 0) return;
|
||||||
|
const expandedImageIds = await expandMultiframeImageIds(imageIds);
|
||||||
|
await setLoadedImageIdsAndCacheMeta(expandedImageIds, { force: true });
|
||||||
|
const seriesGroups = deriveSeriesGroupsFromImageIds(expandedImageIds);
|
||||||
|
const primarySeries = seriesGroups[0]?.imageIds || expandedImageIds;
|
||||||
// Optionally extract StudyInstanceUID if not provided
|
// Optionally extract StudyInstanceUID if not provided
|
||||||
let uid = studyInstanceUID;
|
let uid = studyInstanceUID;
|
||||||
if (!uid && imageIds[0]?.startsWith("wadouri:")) {
|
if (!uid && expandedImageIds[0]?.startsWith("wadouri:")) {
|
||||||
try {
|
try {
|
||||||
const url = stripWadouriPrefix(imageIds[0]);
|
const url = stripWadouriPrefix(expandedImageIds[0]);
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
|
const dataSet = dicomParser.parseDicom(new Uint8Array(arrayBuffer));
|
||||||
@@ -3186,17 +3627,27 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
// Ignore errors, fallback to undefined
|
// Ignore errors, fallback to undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setAvailableStacks(prev => [...prev, { imageIds, studyInstanceUID: uid }]);
|
setAvailableStacks(prev => [...prev, { imageIds: expandedImageIds, studyInstanceUID: uid, seriesGroups }]);
|
||||||
// Optionally, auto-populate a viewport with the new stack if there is an empty viewport
|
// Optionally, auto-populate a viewport with the new stack if there is an empty viewport
|
||||||
setViewportImageIds(prev => {
|
setViewportImageIds(prev => {
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
const emptyIdx = next.findIndex(ids => !ids || ids.length === 0);
|
const emptyIdx = next.findIndex(ids => !ids || ids.length === 0);
|
||||||
if (emptyIdx !== -1) {
|
if (emptyIdx !== -1) {
|
||||||
next[emptyIdx] = imageIds;
|
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;
|
return next;
|
||||||
});
|
});
|
||||||
setLoadedImageIdsAndCacheMeta(imageIds);
|
setLoadedImageIdsAndCacheMeta(primarySeries);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3233,7 +3684,8 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
const stackIdx = selectedStackIdx.slice(0, numActive);
|
const stackIdx = selectedStackIdx.slice(0, numActive);
|
||||||
const stacks = availableStacks.map(s => ({
|
const stacks = availableStacks.map(s => ({
|
||||||
i: s.imageIds, // imageIds
|
i: s.imageIds, // imageIds
|
||||||
u: s.studyInstanceUID // studyInstanceUID
|
u: s.studyInstanceUID, // studyInstanceUID
|
||||||
|
g: s.seriesGroups,
|
||||||
}));
|
}));
|
||||||
console.log("Exporting viewer state with", numActive, "active viewports");
|
console.log("Exporting viewer state with", numActive, "active viewports");
|
||||||
console.log("modes", modes);
|
console.log("modes", modes);
|
||||||
@@ -3306,6 +3758,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
if (state.stacks) setAvailableStacks(state.stacks.map((s: any) => ({
|
if (state.stacks) setAvailableStacks(state.stacks.map((s: any) => ({
|
||||||
imageIds: s.i,
|
imageIds: s.i,
|
||||||
studyInstanceUID: s.u,
|
studyInstanceUID: s.u,
|
||||||
|
seriesGroups: Array.isArray(s.g) ? s.g : undefined,
|
||||||
})));
|
})));
|
||||||
if (state.stackIdx && state.stacks) {
|
if (state.stackIdx && state.stacks) {
|
||||||
const reconstructed = state.stackIdx.map(
|
const reconstructed = state.stackIdx.map(
|
||||||
@@ -3313,6 +3766,16 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
);
|
);
|
||||||
setViewportImageIds(reconstructed);
|
setViewportImageIds(reconstructed);
|
||||||
setSelectedStackIdx(state.stackIdx);
|
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 ---
|
// --- Delay the start of tryRestore until after the next paint ---
|
||||||
@@ -4450,6 +4913,102 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{viewportModes[i] === "stack" && (
|
||||||
|
<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()}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(seriesGroupsByViewport[i] || []).length > 1 && (
|
||||||
|
<>
|
||||||
|
<span style={{ color: "#fff", fontSize: 12, whiteSpace: "nowrap" }}>Series</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 || ""}
|
||||||
|
>
|
||||||
|
{seriesGroupsByViewport[i]?.[selectedSeriesIdxByViewport[i] || 0]?.label || "Series"}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Position bar inside each viewport */}
|
{/* Position bar inside each viewport */}
|
||||||
{viewportModes[i] === "stack" && viewportImageIds[i] && (
|
{viewportModes[i] === "stack" && viewportImageIds[i] && (
|
||||||
<div
|
<div
|
||||||
@@ -4680,7 +5239,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
|
|||||||
position: "absolute",
|
position: "absolute",
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 8,
|
bottom: viewportModes[i] === "stack" ? 56 : 8,
|
||||||
zIndex: 20,
|
zIndex: 20,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
Reference in New Issue
Block a user