Enhance stack management by adding series groups support and improving multiframe handling

This commit is contained in:
Ross
2026-05-18 16:31:15 +01:00
parent 13361a30a5
commit 68248737ec
+592 -33
View File
@@ -71,6 +71,15 @@ type StackEntry = {
name?: string;
caseId?: 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";
@@ -175,6 +184,36 @@ function stripWadouriPrefix(imageId?: string): string {
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 {
try {
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[]>(
() => 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
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(
stacksRaw.map(async (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)
let studyInstanceUID: string | undefined = (d as any).studyInstanceUID;
// 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 {
imageIds,
seriesGroups,
studyInstanceUID,
studyId: (d as any).studyId,
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)
if (normalized[0]) {
await setLoadedImageIdsAndCacheMeta(normalized[0].imageIds);
setViewportImageIds(Array(MAX_VIEWPORTS).fill(normalized[0].imageIds || []));
const firstStack = normalized[0];
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 {
setViewportImageIds(Array(MAX_VIEWPORTS).fill([]));
setSeriesGroupsByViewport(Array(MAX_VIEWPORTS).fill(null).map(() => []));
setSelectedSeriesIdxByViewport(Array(MAX_VIEWPORTS).fill(0));
}
})
.catch(err => {
@@ -756,8 +844,22 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
const handleLoadStack = async (viewportIdx: number, stackIdx: number) => {
const stackObj = availableStacks[stackIdx];
if (!stackObj) return;
let seriesGroups = stackObj.seriesGroups;
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");
try {
// Always revert to stack mode when loading a new stack
@@ -769,7 +871,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
setViewportImageIds(prev => {
const next = [...prev];
next[viewportIdx] = stack;
next[viewportIdx] = primarySeries;
return next;
});
setSelectedStackIdx(prev => {
@@ -777,9 +879,19 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
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(stack);
await setLoadedImageIdsAndCacheMeta(primarySeries);
} finally {
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 [viewportMenuOpen, setViewportMenuOpen] = useState(false)
@@ -1644,6 +1897,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
let seriesInstanceUID: string | undefined = undefined;
let instanceNumber: number | undefined = undefined;
let sliceLocation: number | undefined = undefined;
let numberOfFrames = 1;
let parsedSuccessfully = false;
if (!isImage) {
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);
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
@@ -1662,7 +1923,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
}
}
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)
@@ -1690,8 +1951,28 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
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.map(e => e.imageId);
stacks.push({ imageIds, studyInstanceUID: g.studyInstanceUID, seriesInstanceUID: g.seriesInstanceUID });
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;
});
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
@@ -1728,11 +2009,22 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
setViewportImageIds(() => {
const arr = Array(MAX_VIEWPORTS).fill([] as string[]);
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;
});
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
@@ -1745,7 +2037,8 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
// set first viewport to the last appended stack
setViewportImageIds(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;
});
setSelectedStackIdx(selPrev => {
@@ -1753,6 +2046,16 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
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
@@ -1972,16 +2275,17 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
name: string,
requestedZone: ViewportDropZone,
) => {
const imageIds = rawImageIds.map(url =>
url.startsWith('wadouri:') ? url : `wadouri:${url}`
);
const imageIds = await expandMultiframeImageIds(rawImageIds.map(normalizeToWadouriOrExternal));
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.
let newStackIdx = 0;
setAvailableStacks(prev => {
newStackIdx = prev.length;
return [...prev, { imageIds, name }];
return [...prev, { imageIds, name, seriesGroups }];
});
const resolvedZone = resolveDropZone(requestedZone);
@@ -1989,10 +2293,20 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
setLoadingState('displayset');
try {
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; });
setSeriesGroupsByViewport(prev => {
const n = [...prev];
n[viewportIdx] = seriesGroups;
return n;
});
setSelectedSeriesIdxByViewport(prev => {
const n = [...prev];
n[viewportIdx] = 0;
return n;
});
setActiveViewport(viewportIdx);
await setLoadedImageIdsAndCacheMeta(imageIds);
await setLoadedImageIdsAndCacheMeta(primarySeries);
} finally {
setLoadingState(null);
}
@@ -2028,6 +2342,8 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
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);
for (let oldIdx = 0; oldIdx < oldRows * oldCols; oldIdx++) {
const oldRow = Math.floor(oldIdx / oldCols);
@@ -2048,11 +2364,15 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
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] = imageIds;
nextImageIds[insertedViewportIndex] = primarySeries;
nextModes[insertedViewportIndex] = 'stack';
nextSelected[insertedViewportIndex] = newStackIdx;
nextSeriesGroups[insertedViewportIndex] = seriesGroups;
nextSeriesIdx[insertedViewportIndex] = 0;
setLoadingState('displayset');
try {
@@ -2061,8 +2381,10 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
setViewportImageIds(nextImageIds);
setViewportModes(nextModes);
setSelectedStackIdx(nextSelected);
setSeriesGroupsByViewport(nextSeriesGroups);
setSelectedSeriesIdxByViewport(nextSeriesIdx);
setActiveViewport(insertedViewportIndex);
await setLoadedImageIdsAndCacheMeta(imageIds);
await setLoadedImageIdsAndCacheMeta(primarySeries);
} finally {
setLoadingState(null);
}
@@ -2071,6 +2393,20 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
const applyStackDrop = async (viewportIdx: number, stackIdx: number, requestedZone: ViewportDropZone) => {
const stackObj = availableStacks[stackIdx];
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);
if (resolvedZone === "center") {
@@ -2096,6 +2432,8 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
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;
@@ -2135,11 +2473,15 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
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] = stackObj.imageIds;
nextImageIds[insertedViewportIndex] = primarySeries;
nextModes[insertedViewportIndex] = "stack";
nextSelected[insertedViewportIndex] = stackIdx;
nextSeriesGroups[insertedViewportIndex] = seriesGroups || [];
nextSeriesIdx[insertedViewportIndex] = 0;
setLoadingState("displayset");
try {
@@ -2148,8 +2490,10 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
setViewportImageIds(nextImageIds);
setViewportModes(nextModes);
setSelectedStackIdx(nextSelected);
setSeriesGroupsByViewport(nextSeriesGroups);
setSelectedSeriesIdxByViewport(nextSeriesIdx);
setActiveViewport(insertedViewportIndex);
await setLoadedImageIdsAndCacheMeta(stackObj.imageIds);
await setLoadedImageIdsAndCacheMeta(primarySeries);
} finally {
setLoadingState(null);
}
@@ -2788,6 +3132,82 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
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}`);
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 root = appRootRef.current;
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)
window.loadDicomStackFromWadouriList = async (wadouriImageIds: string[]) => {
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
let studyInstanceUID: string | undefined = undefined;
try {
const firstId = wadouriImageIds[0];
const firstId = expandedImageIds[0];
if (firstId && firstId.startsWith("wadouri:")) {
const url = stripWadouriPrefix(firstId);
const response = await fetch(url);
@@ -2919,10 +3343,10 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
}
setAvailableStacks(prev => {
const next = [...prev, { imageIds: wadouriImageIds, studyInstanceUID }];
const next = [...prev, { imageIds: expandedImageIds, studyInstanceUID, seriesGroups }];
setViewportImageIds(vpPrev => {
const vpNext = [...vpPrev];
vpNext[0] = wadouriImageIds;
vpNext[0] = primarySeries;
return vpNext;
});
setSelectedStackIdx(selPrev => {
@@ -2930,7 +3354,17 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
selNext[0] = next.length - 1;
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;
});
};
@@ -2939,7 +3373,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
return () => {
window.loadDicomStackFromWadouriList = undefined;
};
}, [setAvailableStacks, setViewportImageIds, setSelectedStackIdx, setLoadedImageIdsAndCacheMeta]);
}, [deriveSeriesGroupsFromImageIds, expandMultiframeImageIds, setAvailableStacks, setLoadedImageIdsAndCacheMeta, setSelectedStackIdx, setViewportImageIds]);
useEffect(() => {
if (!orderBySliceLocation) return;
@@ -3059,6 +3493,9 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
setAvailableStacks([]);
setViewportImageIds(Array(MAX_VIEWPORTS).fill([]));
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'));
Object.values(stackThumbnailsRef.current).forEach((thumbnail) => {
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) => {
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
let uid = studyInstanceUID;
if (!uid && imageIds[0]?.startsWith("wadouri:")) {
if (!uid && expandedImageIds[0]?.startsWith("wadouri:")) {
try {
const url = stripWadouriPrefix(imageIds[0]);
const url = stripWadouriPrefix(expandedImageIds[0]);
const response = await fetch(url);
const arrayBuffer = await response.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
}
}
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
setViewportImageIds(prev => {
const next = [...prev];
const emptyIdx = next.findIndex(ids => !ids || ids.length === 0);
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;
});
setLoadedImageIdsAndCacheMeta(imageIds);
setLoadedImageIdsAndCacheMeta(primarySeries);
};
/**
@@ -3233,7 +3684,8 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
const stackIdx = selectedStackIdx.slice(0, numActive);
const stacks = availableStacks.map(s => ({
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("modes", modes);
@@ -3306,6 +3758,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
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(
@@ -3313,6 +3766,16 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
);
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 ---
@@ -4450,6 +4913,102 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
)}
</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 */}
{viewportModes[i] === "stack" && viewportImageIds[i] && (
<div
@@ -4680,7 +5239,7 @@ function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewer
position: "absolute",
left: 0,
right: 0,
bottom: 8,
bottom: viewportModes[i] === "stack" ? 56 : 8,
zIndex: 20,
display: "flex",
alignItems: "center",