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;
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user