From a5c530a907d9a0a36be612782fdf24427c35b0ee Mon Sep 17 00:00:00 2001 From: Ross Date: Tue, 19 May 2026 14:26:28 +0100 Subject: [PATCH] feat(mobile): enhance mobile interaction with new touch controls and layout adjustments --- dicom-viewer/src/App.tsx | 658 ++++++++++++++++++++++++++++++++------- 1 file changed, 552 insertions(+), 106 deletions(-) diff --git a/dicom-viewer/src/App.tsx b/dicom-viewer/src/App.tsx index d7e3b07..1e74a36 100644 --- a/dicom-viewer/src/App.tsx +++ b/dicom-viewer/src/App.tsx @@ -31,6 +31,11 @@ declare global { cornerstone3dTools: typeof cornerstoneTools; cornerstone3d: typeof cornerstone3d; cornerstoneDICOMImageLoader: typeof cornerstoneDICOMImageLoader; + importAnnotations?: (json: string) => void; + importViewerState?: (json: string) => void; + loadDicomStackFromFiles?: (files: FileList | File[]) => void; + loadDicomStackFromWadouriList?: (wadouriImageIds: string[]) => Promise; + [key: string]: any; } } @@ -42,47 +47,21 @@ function pixelToWorld(imageId: string, point: { x: number, y: number }) { // Default to 1 if spacing is missing const dx = columnPixelSpacing || 1; const dy = rowPixelSpacing || 1; - // Convert cosines to vectors - const rc = rowCosines; - const cc = columnCosines; - const ipp = imagePositionPatient; - // World = IPP + x*col*cos*spacing + y*row*cos*spacing - return [ - ipp[0] + point.x * cc[0] * dx + point.y * rc[0] * dy, - ipp[1] + point.x * cc[1] * dx + point.y * rc[1] * dy, - ipp[2] + point.x * cc[2] * dx + point.y * rc[2] * dy, - ]; + return { + x: imagePositionPatient[0] + rowCosines[0] * point.y * dy + columnCosines[0] * point.x * dx, + y: imagePositionPatient[1] + rowCosines[1] * point.y * dy + columnCosines[1] * point.x * dx, + z: imagePositionPatient[2] + rowCosines[2] * point.y * dy + columnCosines[2] * point.x * dx, + }; } -// Add to global types: -declare global { - interface Window { - exportViewerState?: () => string; - importViewerState?: (json: string) => void; - exportAnnotations?: () => string; - importAnnotations?: (json: string) => void; - importLegacyAnnotations?: (json: string) => void; - importLegacyViewport?: (json: string) => void; - } -} - -type StackEntry = { - imageIds: string[]; - studyInstanceUID?: string; - name?: string; - caseId?: string; - studyId?: string; - seriesGroups?: StackSeriesGroup[]; +type ViewportDiv = HTMLDivElement & { + _cornerstoneEnabled?: boolean; + _lastViewportType?: Enums.ViewportType; + _resizeObserver?: ResizeObserver; + _overlayListener?: EventListener; }; -type StackSeriesGroup = { - key: string; - label: string; - imageIds: string[]; - seriesInstanceUID?: string; - bValue?: number | null; - groupValues?: Partial>; -}; +type ViewportDropZone = "left" | "right" | "top" | "bottom" | "center"; type GroupingRuleId = | "dwiBValue" @@ -100,32 +79,22 @@ type GroupingRuleDefinition = { defaultEnabled: boolean; }; -type ViewportDropZone = "center" | "left" | "right" | "top" | "bottom"; +type StackSeriesGroup = { + key: string; + label: string; + imageIds: string[]; + seriesInstanceUID?: string; + bValue?: number | null; + groupValues?: Partial>; +}; - -// Add this type for clarity -declare global { - interface Window { - loadDicomStackFromFiles?: (files: FileList | File[]) => void; - } -} - -declare global { - interface Window { - loadDicomStackFromWadouriList?: (wadouriImageIds: string[]) => void; - } -} - -window.cornerstone3dTools = cornerstoneTools; -window.cornerstone3d = cornerstone3d -window.cornerstoneDICOMImageLoader = cornerstoneDICOMImageLoader; - - -type ViewportDiv = HTMLDivElement & { - _cornerstoneEnabled?: boolean; - _lastViewportType?: any; - _resizeObserver?: ResizeObserver; - _overlayListener?: EventListener; +type StackEntry = { + imageIds: string[]; + name?: string; + studyId?: string; + studyInstanceUID?: string; + caseId?: string | number; + seriesGroups?: StackSeriesGroup[]; }; const { MouseBindings, KeyboardBindings } = csToolsEnums; @@ -499,6 +468,12 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat const [fullscreenHoverIdx, setFullscreenHoverIdx] = useState(null); const [openDropdownIdx, setOpenDropdownIdx] = useState(null); const [topControlsPeek, setTopControlsPeek] = useState(false); + const [isPhoneLayout, setIsPhoneLayout] = useState(false); + const [mobileInteractionActive, setMobileInteractionActive] = useState(false); + const [mobileFocusedViewport, setMobileFocusedViewport] = useState(null); + const [mobileSingleViewportMode, setMobileSingleViewportMode] = useState(true); + const [mobileArmedStackIdx, setMobileArmedStackIdx] = useState(null); + const [mobileArmedDropZone, setMobileArmedDropZone] = useState<"center" | "right" | "bottom">("center"); const MAX_VIEWPORTS = 9; const renderingEngineId = "renderingEngine_" + container_id; @@ -1644,12 +1619,30 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat fallbackBByBase.set(base, values); })); + const getSliceKey = (id: string): string => { + const planeMeta = (metaData.get("imagePlaneModule", id) || {}) as any; + const ipp = Array.isArray(planeMeta?.imagePositionPatient) ? planeMeta.imagePositionPatient : null; + if (ipp && ipp.length >= 3) { + const nums = ipp.slice(0, 3).map((v: any) => Number(v)); + if (nums.every((v: number) => Number.isFinite(v))) { + return `IPP:${nums.map((v: number) => v.toFixed(3)).join(",")}`; + } + } + const sliceLocation = Number(planeMeta?.sliceLocation); + if (Number.isFinite(sliceLocation)) { + return `SL:${sliceLocation.toFixed(3)}`; + } + return ""; + }; + type GroupCandidate = { id: string; seriesInstanceUID?: string; seriesDescription?: string; seriesNumber?: number; + instanceNumber?: number; orientationKey: string; + sliceKey: string; bValue: number | null; ruleValues: Partial>; }; @@ -1662,7 +1655,9 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat const seriesInstanceUID = (seriesMeta.seriesInstanceUID || "").trim() || undefined; const seriesDescription = (seriesMeta.seriesDescription || "").trim() || undefined; const seriesNumber = Number(seriesMeta.seriesNumber); + const instanceNumber = Number(imageMeta.instanceNumber); const orientationKey = getOrientationKey(id); + const sliceKey = getSliceKey(id); let bValueRaw = Number(mrMeta.diffusionBValue ?? mrMeta.diffusionbValue ?? mrMeta.bValue); let bValue = Number.isFinite(bValueRaw) ? bValueRaw : null; @@ -1717,7 +1712,9 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat seriesInstanceUID, seriesDescription, seriesNumber: Number.isFinite(seriesNumber) ? seriesNumber : undefined, + instanceNumber: Number.isFinite(instanceNumber) ? instanceNumber : undefined, orientationKey, + sliceKey, bValue, ruleValues, }; @@ -1790,6 +1787,56 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat }); if (groups.size <= 1) { + // Fallback for missing diffusion/temporal tags: + // If slices repeat uniformly (e.g. 56 images / 28 unique positions), + // split by the occurrence index per slice position. + if (activeRuleIds.length === 0 && candidates.length >= 4) { + const sliceCounts = new Map(); + candidates.forEach((c) => { + if (!c.sliceKey) return; + sliceCounts.set(c.sliceKey, (sliceCounts.get(c.sliceKey) || 0) + 1); + }); + + const uniqueSliceCount = sliceCounts.size; + if (uniqueSliceCount >= 2 && uniqueSliceCount < candidates.length) { + const repeats = candidates.length / uniqueSliceCount; + const repeatsRounded = Math.round(repeats); + const hasCleanRepeats = Math.abs(repeats - repeatsRounded) < 1e-6 && repeatsRounded >= 2 && repeatsRounded <= 8; + const uniformPerSlice = hasCleanRepeats + && Array.from(sliceCounts.values()).every((count) => count === repeatsRounded); + + if (uniformPerSlice) { + const groupsByCycle = Array.from({ length: repeatsRounded }, () => [] as string[]); + const seenPerSlice = new Map(); + const orderedCandidates = [...candidates] + .map((c, idx) => ({ c, idx })) + .sort((a, b) => { + const ia = a.c.instanceNumber; + const ib = b.c.instanceNumber; + if (Number.isFinite(ia) && Number.isFinite(ib)) return (ia as number) - (ib as number); + return a.idx - b.idx; + }); + + orderedCandidates.forEach(({ c }) => { + if (!c.sliceKey) return; + const seen = seenPerSlice.get(c.sliceKey) || 0; + const cycle = Math.min(repeatsRounded - 1, seen); + groupsByCycle[cycle].push(c.id); + seenPerSlice.set(c.sliceKey, seen + 1); + }); + + const validGroups = groupsByCycle.filter((ids) => ids.length > 0); + if (validGroups.length > 1) { + return validGroups.map((ids, idx) => ({ + key: `SERIES_FALLBACK_${idx}`, + label: `Series ${idx + 1}`, + imageIds: dedupeImageIds(ids), + })); + } + } + } + } + return [{ key: "SERIES_0", label: "Series 1", imageIds: dedupeImageIds(imageIds) }]; } @@ -1911,13 +1958,12 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat }) ); - const [viewedIndicesByViewport, setViewedIndicesByViewport] = useState( - () => Array(MAX_VIEWPORTS).fill(null).map(() => []) - ); + const [viewedImageIds, setViewedImageIds] = useState>({}); const viewportStackSignaturesRef = useRef(Array(MAX_VIEWPORTS).fill("")); const defaultVoiRangeByViewportRef = useRef>(Array(MAX_VIEWPORTS).fill(null)); const activePresetByViewportRef = useRef>(Array(MAX_VIEWPORTS).fill(null)); const lastRenderedImageIdByViewportRef = useRef>(Array(MAX_VIEWPORTS).fill(null)); + const pendingSliceIndexByViewportRef = useRef(Array(MAX_VIEWPORTS).fill(0)); const [viewportModes, setViewportModes] = useState>( () => Array(MAX_VIEWPORTS).fill('stack') @@ -1960,6 +2006,53 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat } }, []) + useEffect(() => { + const media = window.matchMedia("(max-width: 768px), (pointer: coarse)"); + const update = () => { + const nextIsPhone = media.matches; + setIsPhoneLayout(nextIsPhone); + if (!nextIsPhone) { + setMobileInteractionActive(false); + setMobileFocusedViewport(null); + } + }; + update(); + if (typeof media.addEventListener === "function") { + media.addEventListener("change", update); + return () => media.removeEventListener("change", update); + } + media.addListener(update); + return () => media.removeListener(update); + }, []); + + const activateMobileInteractionForViewport = useCallback((viewportIdx: number) => { + if (!isPhoneLayout) return; + setActiveViewport(viewportIdx); + setMobileFocusedViewport(viewportIdx); + setMobileInteractionActive(true); + setMobileSingleViewportMode(true); + setStackPanelOpen(false); + + const root = appRootRef.current; + if (root && document.fullscreenElement !== root) { + const request = (root.requestFullscreen + || (root as any).webkitRequestFullscreen + || (root as any).msRequestFullscreen) as (() => Promise | void) | undefined; + if (request) { + try { + const result = request.call(root); + if (result && typeof (result as Promise).catch === "function") { + (result as Promise).catch(() => { + // Ignore fullscreen rejections (browser policy or user settings). + }); + } + } catch (_) { + // Ignore fullscreen failures. + } + } + } + }, [isPhoneLayout]); + const [metaModalOpen, setMetaModalOpen] = useState(false); @@ -2370,6 +2463,21 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat toolGroup.setToolActive(tool, { bindings }); }); + // Add touch bindings so tools are usable on phones/tablets. + const primaryTool = mouseToolBindings.Primary; + if (primaryTool) { + const primaryBindings = [ + ...(toolBindings[primaryTool] || []), + { numTouchPoints: 1 }, + ]; + toolGroup.setToolActive(primaryTool, { bindings: primaryBindings }); + } + const panBindings = [ + ...(toolBindings[PanTool.toolName] || []), + { numTouchPoints: 2 }, + ]; + toolGroup.setToolActive(PanTool.toolName, { bindings: panBindings }); + // Also update external image handlers (pan/zoom) to respect selected tools try { const primaryTool = mouseToolBindings.Primary; @@ -2800,16 +2908,10 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat } } - if (currentIndex >= 0) { - setViewedIndicesByViewport((prev) => { - const next = [...prev]; - const existing = new Set(next[viewportIdx] || []); - if (!existing.has(currentIndex)) { - existing.add(currentIndex); - next[viewportIdx] = Array.from(existing).sort((a, b) => a - b); - return next; - } - return prev; + if (currentImageId) { + setViewedImageIds((prev) => { + if (prev[currentImageId]) return prev; + return { ...prev, [currentImageId]: true }; }); } @@ -2908,24 +3010,15 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat }, [viewportModes, viewportImageIds]); useEffect(() => { - setViewedIndicesByViewport((prev) => { - let changed = false; - const next = [...prev]; - - for (let i = 0; i < MAX_VIEWPORTS; i++) { - const ids = viewportImageIds[i] || []; - const signature = `${ids.length}:${ids[0] || ""}:${ids[ids.length - 1] || ""}`; - if (viewportStackSignaturesRef.current[i] !== signature) { - viewportStackSignaturesRef.current[i] = signature; - defaultVoiRangeByViewportRef.current[i] = null; - lastRenderedImageIdByViewportRef.current[i] = null; - next[i] = []; - changed = true; - } + for (let i = 0; i < MAX_VIEWPORTS; i++) { + const ids = viewportImageIds[i] || []; + const signature = `${ids.length}:${ids[0] || ""}:${ids[ids.length - 1] || ""}`; + if (viewportStackSignaturesRef.current[i] !== signature) { + viewportStackSignaturesRef.current[i] = signature; + defaultVoiRangeByViewportRef.current[i] = null; + lastRenderedImageIdByViewportRef.current[i] = null; } - - return changed ? next : prev; - }); + } }, [viewportImageIds]); function saveVisibleViewportState() { @@ -3420,6 +3513,12 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat const volume = await volumeLoader.createAndCacheVolume(volumeId, { imageIds: imageIds }); volume.load(); viewport.setVolumes([{ volumeId }]); + if (typeof viewport.getNumberOfSlices === "function") { + const totalSlices = viewport.getNumberOfSlices(); + const pending = pendingSliceIndexByViewportRef.current[i] || 0; + const targetSlice = Math.max(0, Math.min(Math.max(0, totalSlices - 1), pending)); + csUtilities.jumpToSlice(viewport.element, { imageIndex: targetSlice }); + } viewport.render(); // Always (re-)attach overlay update for each viewport @@ -3835,6 +3934,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat const renderingEngine = renderingEngineRef.current; const viewport = renderingEngine?.getViewport(`CT_${viewportIdx}`); + const isVolumeMode = viewportModes[viewportIdx] === "volume"; const sliceScalarForImageId = (imageId?: string): number | null => { if (!imageId) return null; @@ -3883,8 +3983,15 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat const ratio = oldTotal > 1 ? currentIdx / (oldTotal - 1) : 0; mappedIndex = Math.max(0, Math.min(selectedGroup.imageIds.length - 1, Math.round(ratio * Math.max(0, selectedGroup.imageIds.length - 1)))); } + } else if (isVolumeMode && viewport && typeof viewport.getSliceIndex === "function" && typeof viewport.getNumberOfSlices === "function") { + const oldTotal = viewport.getNumberOfSlices(); + const currentIdx = viewport.getSliceIndex(); + const ratio = oldTotal > 1 ? currentIdx / (oldTotal - 1) : 0; + mappedIndex = Math.max(0, Math.min(selectedGroup.imageIds.length - 1, Math.round(ratio * Math.max(0, selectedGroup.imageIds.length - 1)))); } + pendingSliceIndexByViewportRef.current[viewportIdx] = mappedIndex; + setSelectedSeriesIdxByViewport(prev => { const next = [...prev]; next[viewportIdx] = boundedIdx; @@ -3899,12 +4006,22 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat 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); + if (!vp) return; + if (viewportModes[viewportIdx] === "stack" && typeof vp.setImageIdIndex === "function") { + csUtilities.jumpToSlice(vp.element, { imageIndex: mappedIndex }); + vp.render(); + updateOverlay(viewportIdx, vp); + return; + } + if (viewportModes[viewportIdx] === "volume" && typeof vp.getNumberOfSlices === "function") { + const total = vp.getNumberOfSlices(); + const target = Math.max(0, Math.min(Math.max(0, total - 1), mappedIndex)); + csUtilities.jumpToSlice(vp.element, { imageIndex: target }); + vp.render(); + updateOverlay(viewportIdx, vp); + } }, 25); - }, [seriesGroupsByViewport, setLoadedImageIdsAndCacheMeta, updateOverlay, viewportImageIds]); + }, [seriesGroupsByViewport, setLoadedImageIdsAndCacheMeta, updateOverlay, viewportImageIds, viewportModes]); useEffect(() => { const timers: number[] = []; @@ -5012,6 +5129,10 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat for (let i = 0; i < numVisible; i++) { // Determine if this viewport should be visible in the current grid const isVisible = i < viewportGrid.rows * viewportGrid.cols; + const isFocusedMobileViewport = !isPhoneLayout + || !mobileInteractionActive + || !mobileSingleViewportMode + || mobileFocusedViewport === i; const currentPreviewZone = dropPreview?.viewportIdx === i ? dropPreview.zone : null; const resolvedPreviewZone = currentPreviewZone ? resolveDropZone(currentPreviewZone) : null; @@ -5022,6 +5143,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat ref={el => (viewportRefs.current[i] = el)} style={{ flex: 1, + display: isVisible && isFocusedMobileViewport ? "flex" : "none", border: isVisible ? "1px solid #222" : "none", // Only show border if visible background: "#000", minWidth: 0, @@ -5032,12 +5154,28 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat gridRow: Math.floor(i / viewportGrid.cols) + 1, gridColumn: (i % viewportGrid.cols) + 1, height: "100%", + touchAction: (isPhoneLayout && mobileInteractionActive) ? "none" : "pan-y", boxShadow: activeViewport === i ? "0 0 0 4px #1976d2, 0 0 16px 4px #1976d2cc" : undefined, zIndex: activeViewport === i ? 10 : 1, }} onClick={() => { + if (isPhoneLayout && !mobileInteractionActive) { + activateMobileInteractionForViewport(i); + setViewportMenuOpen(false); + setTopControlsPeek(false); + setSideMenuOpen(false); + return; + } + if (isPhoneLayout && mobileInteractionActive && mobileArmedStackIdx !== null) { + applyStackDrop(i, mobileArmedStackIdx, mobileArmedDropZone).catch((err) => { + appLogger.error("Failed to apply mobile stack drop:", err); + }); + setMobileArmedStackIdx(null); + setStackPanelOpen(false); + return; + } setActiveViewport(i); setViewportMenuOpen(false); }} @@ -5100,6 +5238,47 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat setExternalDragActive(false); }} > + {isPhoneLayout && !mobileInteractionActive && ( +
+ Tap to activate fullscreen interaction +
+ )} + {isPhoneLayout && mobileInteractionActive && mobileArmedStackIdx !== null && ( +
+ Tap to drop stack ({mobileArmedDropZone}) +
+ )} {(draggedStackIdx !== null || externalDragActive) && currentPreviewZone && (
(viewedImageIds[id] ? idx : -1)) + .filter((idx) => idx >= 0) + ); const loadedFlags = imageIds.map((id) => { if (id.startsWith("external:")) return true; return !!metaData.get("imagePlaneModule", id) || !!metaData.get("generalSeriesModule", id); @@ -6237,6 +6420,19 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat + const mobileQuickTools = [ + StackScrollTool.toolName, + WindowLevelTool.toolName, + PanTool.toolName, + ZoomTool.toolName, + ]; + const mobileGridLayouts: Array<{ rows: number; cols: number }> = [ + { rows: 1, cols: 1 }, + { rows: 1, cols: 2 }, + { rows: 2, cols: 2 }, + ]; + const hideChromeForMobileInteraction = isPhoneLayout && mobileInteractionActive; + return (
{/* Loading overlay */} @@ -6314,7 +6510,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat {/* Right-side stack panel is implemented in the main content flex container below */} {/* Open stacks button: compact and lower z-index so it does not cover modals/controls */} - {!stackPanelOpen && ( + {!stackPanelOpen && !hideChromeForMobileInteraction && ( )} + {hideChromeForMobileInteraction && ( + + )} + + {hideChromeForMobileInteraction && ( +
+ + + +
+ )} + + {!hideChromeForMobileInteraction && (
{/* Side menu toggle button */}
+ )} {/* Viewport grid menu at top center */} + {!hideChromeForMobileInteraction && (
+ )} {/* Main content area: viewports and right-side panel in a row so panel pushes content */} -
+
{/* Viewport grid container — flex:1 so it shrinks when panel takes space */}
{viewports} {/* Settings button positioned relative to the viewport area so it doesn't shift when the right panel opens */} + {!hideChromeForMobileInteraction && ( + )} + + {hideChromeForMobileInteraction && ( +
+
+ {mobileQuickTools.map((toolName) => { + const isActive = mouseToolBindings.Primary === toolName; + const label = TOOL_OPTIONS.find((opt) => opt.value === toolName)?.label || toolName; + return ( + + ); + })} +
+
+ )}
{/* Right-side panel occupies layout space when visible; when closed width is 0 and it doesn't catch pointer events */} -
- {stackPanelOpen && ( +
+ {stackPanelOpen && !hideChromeForMobileInteraction && (
+ {hideChromeForMobileInteraction && ( + + )} + +
+ )}
); @@ -6988,8 +7428,10 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat background: "rgba(0,0,0,0.4)", zIndex: 1000, display: "flex", - alignItems: "center", + alignItems: "flex-start", justifyContent: "center", + overflowY: "auto", + padding: "16px 12px", }} onClick={() => setSettingsOpen(false)} > @@ -7000,9 +7442,13 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat borderRadius: "8px", minWidth: "320px", minHeight: "180px", + maxWidth: "min(960px, 96vw)", + maxHeight: "calc(100vh - 32px)", padding: "24px", boxShadow: "0 4px 24px rgba(0,0,0,0.4)", position: "relative", + overflowY: "auto", + margin: "0 auto", }} onClick={e => e.stopPropagation()} >