From 0c4da6e022a5bfcab85adb324b5c4f87a2923cf2 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 22 Jun 2026 09:37:08 +0100 Subject: [PATCH] multiuser sync updates --- dicom-viewer/src/App.tsx | 688 ++++++++++++++++++++++++++++++++++++-- dicom-viewer/src/main.tsx | 15 +- 2 files changed, 664 insertions(+), 39 deletions(-) diff --git a/dicom-viewer/src/App.tsx b/dicom-viewer/src/App.tsx index 3b9fa5d..e92d739 100644 --- a/dicom-viewer/src/App.tsx +++ b/dicom-viewer/src/App.tsx @@ -440,11 +440,13 @@ type AppProps = { debug?: boolean; format?: LogFormat; }; - // ...other props if needed + multiuserRoom?: string; + username?: string; + isStaff?: boolean; }; // App definintion -export default function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewerState, initialLoggerConfig }: AppProps) { +export default function App({ container_id, imageStacks, autoCacheStack, annotationJson, viewerState, initialLoggerConfig, multiuserRoom, username, isStaff }: AppProps) { const appRootRef = useRef(null); const elementRef = useRef(null) const running = useRef(false) @@ -511,6 +513,304 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat const restoringStateRef = useRef(false); + // --- Multiuser Sync --- + const [wsConnected, setWsConnected] = useState(false); + const [wsConnecting, setWsConnecting] = useState(false); + const [wsError, setWsError] = useState(null); + const [roomUsers, setRoomUsers] = useState>({}); + const [controllerChannel, setControllerChannel] = useState(null); + const [controllerUsername, setControllerUsername] = useState(null); + const [customRoomName, setCustomRoomName] = useState(multiuserRoom || ""); + const [customUsername, setCustomUsername] = useState(username || ""); + const [myChannelName, setMyChannelName] = useState(null); + const [controlRequestNotification, setControlRequestNotification] = useState<{ senderName: string, senderChannel: string } | null>(null); + + const webSocketRef = useRef(null); + + const wsStateRef = useRef({ + connected: false, + isController: false, + containerId: container_id, + myChannelName: null as string | null, + isStaff: isStaff || false + }); + + useEffect(() => { + const isCtrl = wsConnected && controllerChannel !== null && myChannelName !== null && controllerChannel === myChannelName; + wsStateRef.current = { + connected: wsConnected, + isController: isCtrl, + containerId: container_id, + myChannelName: myChannelName, + isStaff: isStaff || false + }; + }, [wsConnected, controllerChannel, myChannelName, container_id, isStaff]); + + const connectMultiuser = useCallback((roomName: string, userDisplayName: string) => { + if (webSocketRef.current) { + webSocketRef.current.close(); + } + if (!roomName.trim()) { + setWsError("Room name cannot be empty"); + return; + } + setWsConnecting(true); + setWsError(null); + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const host = window.location.host || "localhost:8080"; + const wsUrl = `${protocol}//${host}/ws/multiuser/${roomName.trim()}/?client_type=viewer`; + + appLogger.info("Connecting viewer WS to:", wsUrl); + try { + const ws = new WebSocket(wsUrl); + webSocketRef.current = ws; + + ws.onopen = () => { + appLogger.info("Viewer WS connected successfully"); + setWsConnected(true); + setWsConnecting(false); + }; + + ws.onclose = (event) => { + appLogger.info("Viewer WS closed. code:", event.code); + setWsConnected(false); + setWsConnecting(false); + setMyChannelName(null); + setRoomUsers({}); + setControllerChannel(null); + setControllerUsername(null); + setControlRequestNotification(null); + if (event.code !== 1000) { + setWsError(`Disconnected (Code: ${event.code})`); + } + }; + + ws.onerror = (err) => { + appLogger.error("Viewer WS error occurred:", err); + setWsError("Failed to connect to real-time server"); + setWsConnecting(false); + }; + + ws.onmessage = (messageEvent) => { + try { + const content = JSON.parse(messageEvent.data); + const type = content.type; + appLogger.info("Viewer WS event received (type=" + type + "):", content); + + if (type === "welcome") { + setMyChannelName(content.channel_name); + } else if (type === "room_status") { + setRoomUsers(content.users || {}); + setControllerChannel(content.controller_channel); + setControllerUsername(content.controller_username); + } else if (type === "control_request") { + const state = wsStateRef.current; + if (state.isController || state.isStaff) { + setControlRequestNotification({ + senderName: content.sender_name, + senderChannel: content.sender_channel + }); + } + } else if (type === "viewer_state") { + const state = wsStateRef.current; + if (!state.isController) { + const exportFnName = `importViewerState_${container_id || "default"}`; + if (typeof window[exportFnName] === "function") { + appLogger.debug("Applying external viewer state synchronization:", content.data); + window[exportFnName](content.data); + } + } + } else if (type === "annotations") { + const state = wsStateRef.current; + if (!state.isController) { + const importAnnFnName = `importAnnotations_${container_id || "default"}`; + if (typeof window[importAnnFnName] === "function") { + appLogger.debug("Applying external annotations synchronization:", content.data); + window[importAnnFnName](content.data); + } + } + } + } catch (e) { + appLogger.error("Failed to parse websocket message", e); + } + }; + + } catch (e: any) { + appLogger.error("Error initializing viewer WS connection:", e); + setWsError(`Error initializing connection: ${e.message}`); + setWsConnecting(false); + } + }, [container_id]); + + const disconnectMultiuser = useCallback(() => { + appLogger.info("Disconnecting viewer WS connection"); + if (webSocketRef.current) { + webSocketRef.current.close(); + webSocketRef.current = null; + } + setWsConnected(false); + setWsConnecting(false); + setMyChannelName(null); + setRoomUsers({}); + setControllerChannel(null); + setControllerUsername(null); + setControlRequestNotification(null); + }, []); + + const requestControl = useCallback(() => { + if (webSocketRef.current && wsConnected) { + appLogger.info("Sending request_control via WS"); + webSocketRef.current.send(JSON.stringify({ type: "request_control" })); + } + }, [wsConnected]); + + const takeControl = useCallback(() => { + if (webSocketRef.current && wsConnected && isStaff) { + appLogger.info("Sending take_control via WS"); + webSocketRef.current.send(JSON.stringify({ type: "take_control" })); + } + }, [wsConnected, isStaff]); + + const grantControl = useCallback((targetChannel: string, targetUsername: string) => { + if (webSocketRef.current && wsConnected) { + appLogger.info("Sending grant_control via WS to:", targetUsername, targetChannel); + webSocketRef.current.send(JSON.stringify({ + type: "grant_control", + target_channel: targetChannel, + target_username: targetUsername + })); + setControlRequestNotification(null); + } + }, [wsConnected]); + + const releaseControl = useCallback(() => { + if (webSocketRef.current && wsConnected) { + appLogger.info("Sending release_control via WS"); + webSocketRef.current.send(JSON.stringify({ type: "release_control" })); + } + }, [wsConnected]); + + useEffect(() => { + if (multiuserRoom) { + connectMultiuser(multiuserRoom, username || "User"); + } + return () => { + if (webSocketRef.current) { + webSocketRef.current.close(); + } + }; + }, [multiuserRoom, username, connectMultiuser]); + + // Throttled view state send + const lastStateSentRef = useRef(""); + const stateThrottleTimeoutRef = useRef(null); + + const throttleSendViewerState = useCallback(() => { + if (stateThrottleTimeoutRef.current !== null) return; + + stateThrottleTimeoutRef.current = window.setTimeout(() => { + stateThrottleTimeoutRef.current = null; + + const state = wsStateRef.current; + if (!state.connected || !state.isController || restoringStateRef.current) return; + + const exportFnName = `exportViewerState_${container_id || "default"}`; + if (typeof window[exportFnName] === "function") { + try { + const jsonState = window[exportFnName](); + if (jsonState && jsonState !== lastStateSentRef.current) { + lastStateSentRef.current = jsonState; + if (webSocketRef.current && webSocketRef.current.readyState === WebSocket.OPEN) { + appLogger.info("Sending viewer_state via WS:", jsonState); + webSocketRef.current.send(JSON.stringify({ + type: "viewer_state", + data: jsonState + })); + } + } + } catch (err) { + appLogger.error("Failed to export/send viewer state", err); + } + } + }, 120); + }, [container_id]); + + // Throttled annotations send + const lastAnnotationsSentRef = useRef(""); + const annotationsThrottleTimeoutRef = useRef(null); + + const throttleSendAnnotations = useCallback(() => { + if (annotationsThrottleTimeoutRef.current !== null) return; + + annotationsThrottleTimeoutRef.current = window.setTimeout(() => { + annotationsThrottleTimeoutRef.current = null; + + const state = wsStateRef.current; + if (!state.connected || !state.isController || restoringStateRef.current) return; + + const exportFnName = `exportAnnotations_${container_id || "default"}`; + if (typeof window[exportFnName] === "function") { + try { + const jsonAnn = window[exportFnName](); + if (jsonAnn && jsonAnn !== lastAnnotationsSentRef.current) { + lastAnnotationsSentRef.current = jsonAnn; + if (webSocketRef.current && webSocketRef.current.readyState === WebSocket.OPEN) { + appLogger.info("Sending annotations via WS:", jsonAnn); + webSocketRef.current.send(JSON.stringify({ + type: "annotations", + data: jsonAnn + })); + } + } + } catch (err) { + appLogger.error("Failed to export/send annotations", err); + } + } + }, 300); + }, [container_id]); + + useEffect(() => { + const handleCornerstoneViewportEvent = () => { + const state = wsStateRef.current; + if (state.connected && state.isController && !restoringStateRef.current) { + throttleSendViewerState(); + } + }; + + const handleCornerstoneAnnotationEvent = () => { + const state = wsStateRef.current; + if (state.connected && state.isController && !restoringStateRef.current) { + throttleSendAnnotations(); + } + }; + + cornerstone3d.eventTarget.addEventListener(cornerstone3d.Enums.Events.CAMERA_MODIFIED, handleCornerstoneViewportEvent); + cornerstone3d.eventTarget.addEventListener(cornerstone3d.Enums.Events.VOI_MODIFIED, handleCornerstoneViewportEvent); + cornerstone3d.eventTarget.addEventListener(cornerstone3d.Enums.Events.IMAGE_RENDERED, handleCornerstoneViewportEvent); + cornerstone3d.eventTarget.addEventListener(cornerstone3d.Enums.Events.VOLUME_NEW_IMAGE, handleCornerstoneViewportEvent); + + cornerstone3d.eventTarget.addEventListener(cornerstoneTools.Enums.Events.ANNOTATION_ADDED, handleCornerstoneAnnotationEvent); + cornerstone3d.eventTarget.addEventListener(cornerstoneTools.Enums.Events.ANNOTATION_MODIFIED, handleCornerstoneAnnotationEvent); + cornerstone3d.eventTarget.addEventListener(cornerstoneTools.Enums.Events.ANNOTATION_REMOVED, handleCornerstoneAnnotationEvent); + cornerstone3d.eventTarget.addEventListener(cornerstoneTools.Enums.Events.ANNOTATION_COMPLETED, handleCornerstoneAnnotationEvent); + + return () => { + cornerstone3d.eventTarget.removeEventListener(cornerstone3d.Enums.Events.CAMERA_MODIFIED, handleCornerstoneViewportEvent); + cornerstone3d.eventTarget.removeEventListener(cornerstone3d.Enums.Events.VOI_MODIFIED, handleCornerstoneViewportEvent); + cornerstone3d.eventTarget.removeEventListener(cornerstone3d.Enums.Events.IMAGE_RENDERED, handleCornerstoneViewportEvent); + cornerstone3d.eventTarget.removeEventListener(cornerstone3d.Enums.Events.VOLUME_NEW_IMAGE, handleCornerstoneViewportEvent); + + cornerstone3d.eventTarget.removeEventListener(cornerstoneTools.Enums.Events.ANNOTATION_ADDED, handleCornerstoneAnnotationEvent); + cornerstone3d.eventTarget.removeEventListener(cornerstoneTools.Enums.Events.ANNOTATION_MODIFIED, handleCornerstoneAnnotationEvent); + cornerstone3d.eventTarget.removeEventListener(cornerstoneTools.Enums.Events.ANNOTATION_REMOVED, handleCornerstoneAnnotationEvent); + cornerstone3d.eventTarget.removeEventListener(cornerstoneTools.Enums.Events.ANNOTATION_COMPLETED, handleCornerstoneAnnotationEvent); + + if (stateThrottleTimeoutRef.current !== null) window.clearTimeout(stateThrottleTimeoutRef.current); + if (annotationsThrottleTimeoutRef.current !== null) window.clearTimeout(annotationsThrottleTimeoutRef.current); + }; + }, [throttleSendViewerState, throttleSendAnnotations]); + const [annotationNavEnabled, setAnnotationNavEnabled] = useState(true); const [volumeDiagnosticsEnabled, setVolumeDiagnosticsEnabled] = useState(false); @@ -5282,16 +5582,36 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat try { restoringStateRef.current = true; const state = typeof json === "string" ? JSON.parse(json) : json; + + const rewriteImageId = (id: string) => { + if (!id) return id; + const schemeMatch = id.match(/^((?:wadouri|wadors|dicomweb):)?(.*)$/); + if (!schemeMatch) return id; + const scheme = schemeMatch[1] || ""; + const urlPart = schemeMatch[2]; + if (urlPart.startsWith("http://") || urlPart.startsWith("https://")) { + try { + const url = new URL(urlPart); + url.protocol = window.location.protocol; + url.host = window.location.host; + return scheme + url.toString(); + } catch (e) { + return id; + } + } + return id; + }; + if (state.grid) setViewportGrid(state.grid); if (state.modes) setViewportModes(state.modes); if (state.stacks) setAvailableStacks(state.stacks.map((s: any) => ({ - imageIds: s.i, + imageIds: (s.i || []).map(rewriteImageId), studyInstanceUID: s.u, seriesGroups: Array.isArray(s.g) ? s.g : undefined, }))); if (state.stackIdx && state.stacks) { const reconstructed = state.stackIdx.map( - (idx: number) => state.stacks[idx]?.i || [] + (idx: number) => (state.stacks[idx]?.i || []).map(rewriteImageId) ); setViewportImageIds(reconstructed); setSelectedStackIdx(state.stackIdx); @@ -5326,7 +5646,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat if (!viewport) { allReady = false; break; } const stackIdx = state.stackIdx?.[i]; const stackObj = state.stacks?.[stackIdx]; - const importedImageIds = stackObj?.i || []; + const importedImageIds = (stackObj?.i || []).map(rewriteImageId); if (!importedImageIds.length) continue; try { const viewportImageIds = viewport.getImageIds?.() || []; @@ -5334,16 +5654,11 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat viewportImageIds.length !== importedImageIds.length || !viewportImageIds.every((id, idx) => id === importedImageIds[idx]) ) { - appLogger.warn( - `Viewport ${i}: importedImageIds do not match viewportImageIds`, - { importedImageIds, viewportImageIds } - ); allReady = false; break; } } catch (e) { allReady = false; - } } if (!allReady && attempts < maxAttempts) { @@ -5352,6 +5667,8 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat return; } + let anyStackReloaded = false; + // First pass: set stacks/volumes and stack positions so viewports have image resources for (let i = 0; i < numActive; i++) { appLogger.debug("Setting stack/volume for viewport", i); @@ -5359,51 +5676,69 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat if (!viewport) continue; const stackIdx = state.stackIdx?.[i]; const stackObj = state.stacks?.[stackIdx]; - const importedImageIds = stackObj?.i || []; + const importedImageIds = (stackObj?.i || []).map(rewriteImageId); if (!importedImageIds.length) continue; appLogger.debug("mode", state.modes?.[i]); if (state.modes?.[i] === "volume") { - try { - // Force cache metadata for the volume to prevent undefined extent errors - await setLoadedImageIdsAndCacheMeta(importedImageIds, { force: true }); + const volumeId = `myVolumeId_${i}`; + const hasActor = viewport.getActorUIDs().includes(volumeId); + if (!hasActor) { + anyStackReloaded = true; + try { + // Force cache metadata for the volume to prevent undefined extent errors + await setLoadedImageIdsAndCacheMeta(importedImageIds, { force: true }); - const volumeId = `myVolumeId_${i}`; - try { - if (viewport.getActorUIDs().includes(volumeId)) { - viewport.removeActors([volumeId]); - } - } catch (e) { /* ignore */ } - try { - cornerstone3d.cache.removeVolumeLoadObject(volumeId); - } catch (e) { /* ignore */ } - const volume = await volumeLoader.createAndCacheVolume(volumeId, { imageIds: importedImageIds }); - volume.load(); - await viewport.setVolumes([{ volumeId }]); - viewport.render(); - } catch (e) { - appLogger.warn("Failed to create/load volume for viewport", i, e); + try { + if (viewport.getActorUIDs().includes(volumeId)) { + viewport.removeActors([volumeId]); + } + } catch (e) { /* ignore */ } + try { + cornerstone3d.cache.removeVolumeLoadObject(volumeId); + } catch (e) { /* ignore */ } + const volume = await volumeLoader.createAndCacheVolume(volumeId, { imageIds: importedImageIds }); + volume.load(); + await viewport.setVolumes([{ volumeId }]); + viewport.render(); + } catch (e) { + appLogger.warn("Failed to create/load volume for viewport", i, e); + } } // Best-effort orientation restore if (state.orientations && state.orientations[i] && typeof viewport.setOrientation === "function") { try { viewport.setOrientation(state.orientations[i]); viewport.render(); } catch (e) { /* ignore */ } } if (state.volumeSliceIndices) { - setTimeout(() => { try { csUtilities.jumpToSlice(viewport.element, { imageIndex: state.volumeSliceIndices[i] }); } catch (e) { } }, 100); + try { csUtilities.jumpToSlice(viewport.element, { imageIndex: state.volumeSliceIndices[i] }); } catch (e) { } } } else { - try { if (viewport.setStack) { await viewport.setStack(importedImageIds); viewport.render(); } } catch (e) { appLogger.warn('Failed to set stack for viewport', i, e); } + const currentIds = viewport.getImageIds?.() || []; + const isMatch = currentIds.length === importedImageIds.length && + currentIds.every((id, idx) => id === importedImageIds[idx]); + if (!isMatch) { + anyStackReloaded = true; + try { if (viewport.setStack) { await viewport.setStack(importedImageIds); viewport.render(); } } catch (e) { appLogger.warn('Failed to set stack for viewport', i, e); } + } if (state.stackPos && typeof state.stackPos[i] === "number" && typeof viewport.setImageIdIndex === "function") { - try { if (csUtilities && csUtilities.jumpToSlice) csUtilities.jumpToSlice(viewport.element, { imageIndex: state.stackPos[i] }); else viewport.setImageIdIndex(state.stackPos[i]); } catch (e) { /* ignore */ } + try { + if (csUtilities && csUtilities.jumpToSlice) { + csUtilities.jumpToSlice(viewport.element, { imageIndex: state.stackPos[i] }); + } else { + viewport.setImageIdIndex(state.stackPos[i]); + } + } catch (e) { /* ignore */ } } } } - // Wait briefly to allow viewports to initialize GPU/actor resources - const readyDelayMs = 1500; - appLogger.debug(`Waiting ${readyDelayMs}ms before applying properties/camera to viewports`); - await new Promise((res) => setTimeout(res, readyDelayMs)); + // Only delay if we actually reloaded any stacks/volumes to let GPU catch up + if (anyStackReloaded) { + const readyDelayMs = 1500; + appLogger.debug(`Waiting ${readyDelayMs}ms before applying properties/camera to viewports`); + await new Promise((res) => setTimeout(res, readyDelayMs)); + } // Second pass: apply renderer-sensitive properties (colormap, VOI) and camera for (let i = 0; i < numActive; i++) { @@ -5412,7 +5747,7 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat if (!viewport) continue; const stackIdx = state.stackIdx?.[i]; const stackObj = state.stacks?.[stackIdx]; - const importedImageIds = stackObj?.i || []; + const importedImageIds = (stackObj?.i || []).map(rewriteImageId); if (!importedImageIds.length) continue; // Restore properties with sanitization + retry @@ -7450,6 +7785,285 @@ export default function App({ container_id, imageStacks, autoCacheStack, annotat × + + {/* Multiuser Sync Control Card */} +
+
+ 👥 Multiuser Sync + +
+ + {!wsConnected ? ( +
+
+ + setCustomRoomName(e.target.value)} + placeholder="e.g. case_123" + disabled={wsConnecting} + style={{ + padding: "6px 8px", + background: "#1e1e1e", + border: "1px solid #444", + borderRadius: "4px", + color: "#fff", + fontSize: "13px", + }} + /> +
+
+ + setCustomUsername(e.target.value)} + placeholder="e.g. Dr. Smith" + disabled={wsConnecting} + style={{ + padding: "6px 8px", + background: "#1e1e1e", + border: "1px solid #444", + borderRadius: "4px", + color: "#fff", + fontSize: "13px", + }} + /> +
+ + {wsError && ( +
+ ⚠️ {wsError} +
+ )} +
+ ) : ( +
+
+ Connected to {customRoomName} +
+ +
+ ACTIVE CONTROLLER: + + {controllerChannel === myChannelName ? "👑 You (In Control)" : `👤 ${controllerUsername || "Unknown"}`} + +
+ +
+ {controllerChannel === myChannelName ? ( + + ) : ( + + )} + + {isStaff && controllerChannel !== myChannelName && ( + + )} +
+ +
+
PARTICIPANTS ({Object.keys(roomUsers).length}):
+
+ {Object.entries(roomUsers).map(([channel, user]) => { + const isSelf = channel === myChannelName; + const isController = channel === controllerChannel; + return ( +
+ + {user.username} {user.is_staff && staff} + +
+ {isController && 👑} + {!isController && (controllerChannel === myChannelName || isStaff) && ( + + )} +
+
+ ); + })} +
+
+ + +
+ )} +
+ + {/* Control Request Banner/Alert */} + {controlRequestNotification && ( +
+
+ {controlRequestNotification.senderName} requests control. +
+
+ + +
+
+ )}
diff --git a/dicom-viewer/src/main.tsx b/dicom-viewer/src/main.tsx index 8f3cab5..46c20c2 100644 --- a/dicom-viewer/src/main.tsx +++ b/dicom-viewer/src/main.tsx @@ -176,13 +176,21 @@ const shouldMountContainer = (container: HTMLElement, options?: MountOptions): b }; const getContainerLogConfig = (container: HTMLElement, options?: MountOptions) => { + const urlParams = new URLSearchParams(window.location.search); + const urlDebug = urlParams.get("debug"); + const localSyncDebug = typeof window !== "undefined" && window.localStorage ? window.localStorage.getItem("multiuser_sync_debug_mode") : null; + const attrLevel = container.getAttribute("data-log-level"); const attrDebug = container.getAttribute("data-log-debug"); const attrFormat = container.getAttribute("data-log-format"); - // Resolve minLevel: data-log-level > options.minLevel > data-log-debug (legacy) > options.debug (legacy) + // Resolve minLevel: LocalStorage debug > URL debug parameter > data-log-level > options.minLevel > data-log-debug (legacy) let minLevel: LogLevel; - if (attrLevel !== null) { + if (localSyncDebug === "true") { + minLevel = "debug"; + } else if (urlDebug !== null) { + minLevel = parseDebugFlag(urlDebug); + } else if (attrLevel !== null) { minLevel = parseLogLevel(attrLevel); } else if (options?.logging?.minLevel) { minLevel = options.logging.minLevel; @@ -223,6 +231,9 @@ const mountContainer = (container: HTMLElement, imageStacks: () => Promise );