diff --git a/dicom-viewer/src/App.tsx b/dicom-viewer/src/App.tsx index 1e74a36..4b8e17e 100644 --- a/dicom-viewer/src/App.tsx +++ b/dicom-viewer/src/App.tsx @@ -474,6 +474,14 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat const [mobileSingleViewportMode, setMobileSingleViewportMode] = useState(true); const [mobileArmedStackIdx, setMobileArmedStackIdx] = useState(null); const [mobileArmedDropZone, setMobileArmedDropZone] = useState<"center" | "right" | "bottom">("center"); + const [mobilePointerDrag, setMobilePointerDrag] = useState<{ + stackIdx: number; + x: number; + y: number; + targetViewportIdx: number | null; + zone: ViewportDropZone; + } | null>(null); + const suppressStackRowClickUntilRef = useRef(0); const MAX_VIEWPORTS = 9; const renderingEngineId = "renderingEngine_" + container_id; @@ -492,7 +500,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat const [dropPreview, setDropPreview] = useState<{ viewportIdx: number; zone: ViewportDropZone } | null>(null); // After your useState for viewportImageIds: const [viewportImageIds, _setViewportImageIds] = useState( - () => Array(MAX_VIEWPORTS).fill([]) + () => Array(MAX_VIEWPORTS).fill(null).map(() => []) ); @@ -923,54 +931,40 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat studyInstanceUID: (d as any).studyInstanceUID, }; - // If the descriptor has pre-declared series groups, respect them - // (expand multiframe flat within each series, no further splitting) + // Avoid expensive startup preprocessing across all stacks. + // Keep descriptor-declared groups if provided, otherwise defer split/grouping until load. if (!isRawArray && Array.isArray((d as any).series)) { - const seriesExpanded = await Promise.all( - (d as any).series.map(async (seriesEntry: any, seriesIdx: number) => { + const declaredGroups: StackSeriesGroup[] = (d as any).series + .map((seriesEntry: any, seriesIdx: number) => { const rawIds = Array.isArray(seriesEntry?.imageIds) ? seriesEntry.imageIds : (Array.isArray(seriesEntry?.i) ? seriesEntry.i : []); - const expandedIds = await expandMultiframeImageIds(rawIds); + const normalizedIds = dedupeImageIds(rawIds.map(normalizeToWadouriOrExternal)); 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, + imageIds: normalizedIds, 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) { - const allImageIds = filtered.flatMap((g) => g.imageIds); - allStacks.push({ imageIds: allImageIds, seriesGroups: filtered, ...meta }); + .filter((group: StackSeriesGroup) => group.imageIds.length > 0); + + if (declaredGroups.length > 0) { + allStacks.push({ + imageIds: dedupeImageIds(declaredGroups.flatMap((g) => g.imageIds)), + seriesGroups: declaredGroups, + ...meta, + }); continue; } } - // Split multiframe files into separate stacks; single-frame IDs stay together - const { singleFrameIds, multiframeGroups } = await splitMultiframeToStacks(imageIdsRaw); - - if (singleFrameIds.length > 0) { - const seriesGroups = await deriveSeriesGroupsFromImageIds(singleFrameIds); - allStacks.push({ imageIds: singleFrameIds, seriesGroups, ...meta }); - } - - for (const mf of multiframeGroups) { - const baseName = stripWadouriPrefix(mf.baseImageId).split("/").pop()?.split("?")[0] ?? "multiframe"; - const mfName = meta.name ? `${meta.name} \u2014 ${baseName}` : baseName; - const seriesGroups = await deriveSeriesGroupsFromImageIds(mf.frameIds); - allStacks.push({ - imageIds: mf.frameIds, - seriesGroups, - name: mfName, - studyId: meta.studyId, - studyInstanceUID: meta.studyInstanceUID, - caseId: meta.caseId, - }); + const normalizedImageIds = dedupeImageIds(imageIdsRaw.map(normalizeToWadouriOrExternal)); + if (normalizedImageIds.length > 0) { + allStacks.push({ imageIds: normalizedImageIds, seriesGroups: [], ...meta }); } } @@ -980,19 +974,21 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat if (allStacks[0]) { const firstStack = allStacks[0]; let firstGroups = firstStack.seriesGroups || []; + let firstImageIds = firstStack.imageIds; if (firstGroups.length === 0) { - await setLoadedImageIdsAndCacheMeta(firstStack.imageIds, { force: true }); - firstGroups = await deriveSeriesGroupsFromImageIds(firstStack.imageIds); - allStacks[0] = { ...firstStack, seriesGroups: firstGroups }; + firstImageIds = await expandMultiframeImageIds(firstStack.imageIds); + await setLoadedImageIdsAndCacheMeta(firstImageIds, { force: true }); + firstGroups = await deriveSeriesGroupsFromImageIds(firstImageIds); + allStacks[0] = { ...firstStack, imageIds: firstImageIds, seriesGroups: firstGroups }; setAvailableStacks([...allStacks]); } - const firstSeries = firstGroups[0]?.imageIds || firstStack.imageIds; + const firstSeries = firstGroups[0]?.imageIds || firstImageIds; await setLoadedImageIdsAndCacheMeta(firstSeries); - setViewportImageIds(Array(MAX_VIEWPORTS).fill(firstSeries || [])); + setViewportImageIds(Array(MAX_VIEWPORTS).fill(null).map((_, idx) => (idx === 0 ? (firstSeries || []) : []))); setSeriesGroupsByViewport(Array(MAX_VIEWPORTS).fill(null).map(() => firstGroups)); setSelectedSeriesIdxByViewport(Array(MAX_VIEWPORTS).fill(0)); } else { - setViewportImageIds(Array(MAX_VIEWPORTS).fill([])); + setViewportImageIds(Array(MAX_VIEWPORTS).fill(null).map(() => [])); setSeriesGroupsByViewport(Array(MAX_VIEWPORTS).fill(null).map(() => [])); setSelectedSeriesIdxByViewport(Array(MAX_VIEWPORTS).fill(0)); } @@ -1000,7 +996,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat .catch(err => { appLogger.error("Failed to load imageStacks:", err); setAvailableStacks([]); - setViewportImageIds(Array(MAX_VIEWPORTS).fill([])); + setViewportImageIds(Array(MAX_VIEWPORTS).fill(null).map(() => [])); }); return () => { mounted = false; }; @@ -1009,9 +1005,8 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat const handleLoadStack = async (viewportIdx: number, stackIdx: number) => { const stackObj = availableStacks[stackIdx]; if (!stackObj) return; - // Stack imageIds are already correctly expanded at load time (single-frame IDs or - // per-frame query IDs for multiframe stacks). No expansion needed here. - const stack = stackObj.imageIds; + // Expand multiframe IDs lazily when the stack is actually loaded. + const stack = await expandMultiframeImageIds(stackObj.imageIds || []); appLogger.debug("[handleLoadStack] Loading stack:", stack.length, "ids. First:", stack[0]?.slice(0, 80)); await setLoadedImageIdsAndCacheMeta(stack, { force: true }); @@ -1273,12 +1268,13 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat } }, [container_id, stackThumbnails, setStackThumbnail]); - // Kick off thumbnail generation when stacks change + // Generate thumbnails lazily when the stack panel is open. useEffect(() => { + if (!stackPanelOpen) return; if (!availableStacks || availableStacks.length === 0) return; const renderingEngine = renderingEngineRef.current; - if (!renderingEngine) return; // wait until rendering engine exists - availableStacks.forEach((stack, idx) => { + if (!renderingEngine) return; + availableStacks.slice(0, 12).forEach((stack, idx) => { const imageIds = Array.isArray(stack.imageIds) ? stack.imageIds : []; const midIndex = Math.floor(imageIds.length / 2); const chosen = imageIds[midIndex] || imageIds[0] || null; @@ -1286,7 +1282,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat generateThumbnail(idx, chosen as string | null); } }); - }, [availableStacks, generateThumbnail, stackThumbnails]); + }, [availableStacks, generateThumbnail, stackPanelOpen, stackThumbnails]); // Wrap setLoadedImageIds to also cache DICOM metadata const setLoadedImageIdsAndCacheMeta = async (imageIds: string[], options?: { force?: boolean }) => { @@ -1389,33 +1385,21 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat } } catch (_) {} - // 3. Range request — first 64 KB is typically enough to find tag (0028,0008) + // 3. Range request — first 256 KB should include top-level NumberOfFrames if present. try { - const rangeResponse = await fetch(url, { headers: { Range: "bytes=0-65535" } }); + const rangeResponse = await fetch(url, { headers: { Range: "bytes=0-262143" } }); if (rangeResponse.ok || rangeResponse.status === 206) { const buffer = await rangeResponse.arrayBuffer(); const count = tryParseBytes(new Uint8Array(buffer)); - // If server returned full content (200) or we found frames, cache and return - if (rangeResponse.status === 200 || count > 1) { - multiframeCountCacheRef.current[cacheKey] = count; - return count; - } + // Treat range parse as authoritative to avoid N full-file fetches on single-frame studies. + multiframeCountCacheRef.current[cacheKey] = count; + return count; } } catch (_) {} - // 4. Full fetch fallback (only if range request not sufficient) - try { - appLogger.debug("[detectMultiframeFrameCount] Falling back to full fetch for:", url.slice(-60)); - const response = await fetch(url); - const buffer = await response.arrayBuffer(); - const count = tryParseBytes(new Uint8Array(buffer)); - multiframeCountCacheRef.current[cacheKey] = count; - return count; - } catch (e) { - appLogger.warn("[detectMultiframeFrameCount] Failed to detect frame count for:", url.slice(-60), e); - multiframeCountCacheRef.current[cacheKey] = 1; - return 1; - } + // 4. Conservative fallback: if detection failed, assume single-frame. + multiframeCountCacheRef.current[cacheKey] = 1; + return 1; }, []); /** @deprecated Use detectMultiframeFrameCount instead. */ @@ -1911,6 +1895,23 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat return distances[0].distance <= edgeThreshold ? distances[0].zone : "center"; }, []); + const getDropZoneFromClientPoint = useCallback((rect: DOMRect, clientX: number, clientY: number): ViewportDropZone => { + const x = clientX - rect.left; + const y = clientY - rect.top; + const w = rect.width; + const h = rect.height; + + const edgeThreshold = Math.min(w, h) * 0.24; + const distances = [ + { zone: "left" as const, distance: x }, + { zone: "right" as const, distance: w - x }, + { zone: "top" as const, distance: y }, + { zone: "bottom" as const, distance: h - y }, + ]; + distances.sort((a, b) => a.distance - b.distance); + return distances[0].distance <= edgeThreshold ? distances[0].zone : "center"; + }, []); + const resolveDropZone = useCallback((zone: ViewportDropZone): ViewportDropZone => { if (zone === "left" || zone === "right") { return viewportGrid.cols < 3 ? zone : "center"; @@ -2053,7 +2054,11 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat } }, [isPhoneLayout]); - + useEffect(() => { + if (!isPhoneLayout || !mobileInteractionActive || !mobileSingleViewportMode) return; + if (activeViewport === null) return; + setMobileFocusedViewport(activeViewport); + }, [activeViewport, isPhoneLayout, mobileInteractionActive, mobileSingleViewportMode]); const [metaModalOpen, setMetaModalOpen] = useState(false); const [metaModalContent, setMetaModalContent] = useState(null); @@ -3285,6 +3290,88 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat } }; + const beginMobilePointerStackDrag = useCallback((stackIdx: number, startEvent: React.PointerEvent) => { + if (!(isPhoneLayout && mobileInteractionActive)) return; + if ((startEvent.pointerType || "").toLowerCase() === "mouse") return; + + startEvent.preventDefault(); + startEvent.stopPropagation(); + suppressStackRowClickUntilRef.current = Date.now() + 400; + + const visibleCount = viewportGrid.rows * viewportGrid.cols; + let latestTargetViewport: number | null = null; + let latestZone: ViewportDropZone = "center"; + + setMobilePointerDrag({ + stackIdx, + x: startEvent.clientX, + y: startEvent.clientY, + targetViewportIdx: null, + zone: "center", + }); + + const onMove = (ev: PointerEvent) => { + let targetViewport: number | null = null; + let zone: ViewportDropZone = "center"; + + for (let i = 0; i < visibleCount; i++) { + const el = viewportRefs.current[i]; + if (!el) continue; + const rect = el.getBoundingClientRect(); + if (rect.width <= 1 || rect.height <= 1) continue; + if (ev.clientX < rect.left || ev.clientX > rect.right || ev.clientY < rect.top || ev.clientY > rect.bottom) continue; + targetViewport = i; + zone = resolveDropZone(getDropZoneFromClientPoint(rect, ev.clientX, ev.clientY)); + break; + } + + latestTargetViewport = targetViewport; + latestZone = zone; + + setMobilePointerDrag((prev) => prev ? { + ...prev, + x: ev.clientX, + y: ev.clientY, + targetViewportIdx: targetViewport, + zone, + } : prev); + + setDropPreview(targetViewport !== null ? { viewportIdx: targetViewport, zone } : null); + }; + + const finish = async () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onCancel); + + setDropPreview(null); + setMobilePointerDrag(null); + + if (latestTargetViewport !== null) { + try { + await applyStackDrop(latestTargetViewport, stackIdx, latestZone); + setStackPanelOpen(false); + setActiveViewport(latestTargetViewport); + setMobileFocusedViewport(latestTargetViewport); + } catch (err) { + appLogger.error("Failed mobile pointer stack drop:", err); + } + } + suppressStackRowClickUntilRef.current = Date.now() + 400; + }; + + const onUp = () => { + void finish(); + }; + const onCancel = () => { + void finish(); + }; + + window.addEventListener("pointermove", onMove, { passive: true }); + window.addEventListener("pointerup", onUp, { passive: true }); + window.addEventListener("pointercancel", onCancel, { passive: true }); + }, [applyStackDrop, getDropZoneFromClientPoint, isPhoneLayout, mobileInteractionActive, resolveDropZone, viewportGrid]); + useEffect(() => { const setup = async () => { appLogger.debug("setup") @@ -3355,23 +3442,6 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat if (!renderingEngine) { renderingEngine = new RenderingEngine(renderingEngineId); renderingEngineRef.current = renderingEngine; - // If stacks were already available before the engine was ready, kick off thumbnails now - try { - if (availableStacks && availableStacks.length > 0) { - availableStacks.forEach((stack, idx) => { - const imageIds = Array.isArray(stack.imageIds) ? stack.imageIds : []; - const midIndex = Math.floor(imageIds.length / 2); - const chosen = imageIds[midIndex] || imageIds[0] || null; - if (chosen && !stackThumbnails[idx] && !thumbRequestedRef.current[idx]) { - // fire-and-forget - // eslint-disable-next-line @typescript-eslint/no-floating-promises - generateThumbnail(idx, chosen as string | null); - } - }); - } - } catch (e) { - // ignore - } } // --- In your setup function, use the correct tool group per viewport --- @@ -4342,7 +4412,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat } setAvailableStacks([]); - setViewportImageIds(Array(MAX_VIEWPORTS).fill([])); + setViewportImageIds(Array(MAX_VIEWPORTS).fill(null).map(() => [])); setSelectedStackIdx(Array(MAX_VIEWPORTS).fill(0)); setSeriesGroupsByViewport(Array(MAX_VIEWPORTS).fill(null).map(() => [])); setSelectedSeriesIdxByViewport(Array(MAX_VIEWPORTS).fill(0)); @@ -5161,6 +5231,9 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat zIndex: activeViewport === i ? 10 : 1, }} onClick={() => { + if (mobilePointerDrag) { + return; + } if (isPhoneLayout && !mobileInteractionActive) { activateMobileInteractionForViewport(i); setViewportMenuOpen(false); @@ -6640,6 +6713,28 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat )} + {mobilePointerDrag && ( +
+ Drop on viewport {mobilePointerDrag.targetViewportIdx !== null ? mobilePointerDrag.targetViewportIdx + 1 : "-"} ({mobilePointerDrag.zone}) +
+ )} + {!hideChromeForMobileInteraction && (
{/* Side menu toggle button */} @@ -7281,8 +7376,6 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat // Prefer the middle image for a representative thumbnail, fallback to first const midIndex = Math.floor(imageIds.length / 2); const chosenImage = imageIds[midIndex] || imageIds[0]; - // Remove the wadouri: scheme if present. Use replace to avoid off-by-one slice errors. - const thumbnailUrl = chosenImage ? stripWadouriPrefix(chosenImage) : null; return (
{ + if (Date.now() < suppressStackRowClickUntilRef.current) return; if (invalid) return; const targetViewport = activeViewport ?? 0; handleLoadStack(targetViewport, idx); }} + onTouchStart={() => { + if (invalid) return; + if (stackThumbnails[idx] || thumbRequestedRef.current[idx]) return; + const mid = Math.floor(imageIds.length / 2); + const chosen = imageIds[mid] || imageIds[0] || null; + if (chosen) generateThumbnail(idx, chosen as string | null); + }} + onMouseEnter={() => { + if (invalid) return; + if (stackThumbnails[idx] || thumbRequestedRef.current[idx]) return; + const mid = Math.floor(imageIds.length / 2); + const chosen = imageIds[mid] || imageIds[0] || null; + if (chosen) generateThumbnail(idx, chosen as string | null); + }} style={{ display: 'flex', alignItems: 'center', @@ -7321,7 +7429,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat const generated = stackThumbnails[idx]; // If generation explicitly failed, treat as missing so placeholder is shown const generatedValid = generated && generated !== 'BROKEN' ? generated : null; - const src = generatedValid || thumbnailUrl || null; + const src = generatedValid || null; if (!src) { return (
No Img
); } @@ -7333,19 +7441,8 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat style={{ width: 56, height: 56, objectFit: 'cover', borderRadius: 4, background: '#111' }} onError={() => { appLogger.warn('Thumbnail failed to load for stack', idx, src); - const usedGeneratedImage = !!generatedValid; - - if (usedGeneratedImage) { - // Generated image failed: mark as broken to avoid endless reload attempts. - setStackThumbnail(idx, 'BROKEN'); - return; - } - - // Fallback URL failed (often raw DICOM). Do not mark BROKEN yet; - // request generation and keep the thumbnail pipeline alive. - if (!thumbRequestedRef.current[idx]) { - generateThumbnail(idx, chosenImage as string | null); - } + // Generated image failed: mark as broken to avoid endless reload attempts. + setStackThumbnail(idx, 'BROKEN'); }} /> ); @@ -7384,8 +7481,12 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat