import React, { useEffect, useCallback, useRef } from 'react'; import { Types, MeasurementService } from '@ohif/core'; import { ViewportGrid, ViewportPane } from '@ohif/ui-next'; import { useViewportGrid } from '@ohif/ui-next'; import EmptyViewport from './EmptyViewport'; import { useAppConfig } from '@state'; import { useViewportActionCornersWithGrid } from '../hooks'; function ViewerViewportGrid(props: withAppTypes) { const { servicesManager, viewportComponents = [], dataSource, commandsManager } = props; const [viewportGrid, viewportGridService] = useViewportGrid(); const [appConfig] = useAppConfig(); const { initializeViewportCorners } = useViewportActionCornersWithGrid(); const { layout, activeViewportId, viewports, isHangingProtocolLayout } = viewportGrid; const { numCols, numRows } = layout; const layoutHash = useRef(null); const { displaySetService, measurementService, hangingProtocolService, uiNotificationService, customizationService, } = servicesManager.services; const generateLayoutHash = () => `${numCols}-${numRows}`; /** * This callback runs after the viewports structure has changed in any way. * On initial display, that means if it has changed by applying a HangingProtocol, * while subsequently it may mean by changing the stage or by manually adjusting * the layout. */ const updateDisplaySetsFromProtocol = ( protocol: Types.HangingProtocol.Protocol, stage, activeStudyUID, viewportMatchDetails ) => { const availableDisplaySets = displaySetService.getActiveDisplaySets(); if (!availableDisplaySets.length) { console.log('No available display sets', availableDisplaySets); return; } // Match each viewport individually const { layoutType } = stage.viewportStructure; const stageProps = stage.viewportStructure.properties; const { columns: numCols, rows: numRows, layoutOptions = [] } = stageProps; /** * This find or create viewport uses the hanging protocol results to * specify the viewport match details, which specifies the size and * setup of the various viewports. */ const findOrCreateViewport = pos => { const viewportId = Array.from(viewportMatchDetails.keys())[pos]; const details = viewportMatchDetails.get(viewportId); if (!details) { console.log('No match details for viewport', viewportId); return; } const { displaySetsInfo, viewportOptions } = details; const displaySetUIDsToHang = []; const displaySetUIDsToHangOptions = []; displaySetsInfo.forEach(({ displaySetInstanceUID, displaySetOptions }) => { if (displaySetInstanceUID) { displaySetUIDsToHang.push(displaySetInstanceUID); } displaySetUIDsToHangOptions.push(displaySetOptions); }); const computedViewportOptions = hangingProtocolService.getComputedOptions( viewportOptions, displaySetUIDsToHang ); const computedDisplaySetOptions = hangingProtocolService.getComputedOptions( displaySetUIDsToHangOptions, displaySetUIDsToHang ); return { displaySetInstanceUIDs: displaySetUIDsToHang, displaySetOptions: computedDisplaySetOptions, viewportOptions: computedViewportOptions, }; }; viewportGridService.setLayout({ numRows, numCols, layoutType, layoutOptions, findOrCreateViewport, isHangingProtocolLayout: true, }); }; const _getUpdatedViewports = useCallback( (viewportId, displaySetInstanceUID) => { if (!displaySetInstanceUID) { return []; } let updatedViewports = []; try { updatedViewports = hangingProtocolService.getViewportsRequireUpdate( viewportId, displaySetInstanceUID, isHangingProtocolLayout ); } catch (error) { console.warn(error); uiNotificationService.show({ title: 'Drag and Drop', message: 'The selected display sets could not be added to the viewport due to a mismatch in the Hanging Protocol rules.', type: 'error', duration: 3000, }); } return updatedViewports; }, [hangingProtocolService, uiNotificationService, isHangingProtocolLayout] ); // Using Hanging protocol engine to match the displaySets useEffect(() => { const { unsubscribe } = hangingProtocolService.subscribe( hangingProtocolService.EVENTS.PROTOCOL_CHANGED, ({ protocol, stage, activeStudyUID, viewportMatchDetails }) => { updateDisplaySetsFromProtocol(protocol, stage, activeStudyUID, viewportMatchDetails); } ); return () => { unsubscribe(); }; }, []); // Check viewport readiness in useEffect useEffect(() => { const allReady = viewportGridService.getGridViewportsReady(); const sameLayoutHash = layoutHash.current === generateLayoutHash(); if (allReady && !sameLayoutHash) { layoutHash.current = generateLayoutHash(); viewportGridService.publishViewportsReady(); } }, [viewportGridService, generateLayoutHash]); useEffect(() => { const { unsubscribe } = measurementService.subscribe( MeasurementService.EVENTS.JUMP_TO_MEASUREMENT_LAYOUT, event => { const { viewportId, measurement, isConsumed } = event; if (isConsumed) { return; } const { displaySetInstanceUID: referencedDisplaySetInstanceUID } = measurement; const { viewports } = viewportGridService.getState(); // Check if any viewport can display this measurement let canAnyViewportDisplayMeasurement = false; viewports.forEach((viewport, id) => { const displaySetInstanceUIDs = viewport.displaySetInstanceUIDs || []; const viewportHasDisplaySet = displaySetInstanceUIDs.includes( referencedDisplaySetInstanceUID ); // Extract metadata and prepare reference const { FrameOfReferenceUID, ...metadataRest } = measurement.metadata; const reference = { ...(viewportHasDisplaySet ? measurement.metadata : metadataRest), displaySetInstanceUID: referencedDisplaySetInstanceUID, }; // Check if viewport can display the reference if ( viewport.isReferenceViewable?.({ viewportId: id, reference, }) ) { canAnyViewportDisplayMeasurement = true; } }); if (canAnyViewportDisplayMeasurement) { // Let the viewports handle the jump return; } // Need to change layouts since no viewport consumed the event const updatedViewports = _getUpdatedViewports(viewportId, referencedDisplaySetInstanceUID); if (!updatedViewports?.[0]) { console.warn( 'ViewportGrid::Unable to navigate to viewport containing', referencedDisplaySetInstanceUID ); return; } // Find the viewport that can display the measurement const viewport = updatedViewports.find(viewport => { const gridViewport = viewportGridService.getViewportState(viewport.viewportId); return gridViewport.isReferenceViewable?.({ viewportId: viewport.viewportId, reference: { ...measurement.metadata, displaySetInstanceUID: referencedDisplaySetInstanceUID, }, viewportOptions: gridViewport.viewportOptions || {}, }); }); if (!viewport) { console.warn('No suitable viewport found for displaying measurement'); return; } // Update stored position presentation commandsManager.run('updateStoredPositionPresentation', { viewportId: viewport.viewportId, displaySetInstanceUIDs: [referencedDisplaySetInstanceUID], referencedImageId: measurement.referencedImageId, options: { ...measurement.metadata, }, }); event.consume(); commandsManager.run('setDisplaySetsForViewports', { viewportsToUpdate: updatedViewports }); } ); return () => { unsubscribe(); }; }, [viewports, _getUpdatedViewports]); const onDropHandler = (viewportId, { displaySetInstanceUID }) => { const { viewportGridService } = servicesManager.services; const customOnDropHandler = customizationService.getCustomization('customOnDropHandler'); const dropHandlerPromise = customOnDropHandler({ ...props, viewportId, displaySetInstanceUID, appConfig, }); dropHandlerPromise.then(({ handled }) => { if (!handled) { const updatedViewports = _getUpdatedViewports(viewportId, displaySetInstanceUID); commandsManager.run('setDisplaySetsForViewports', { viewportsToUpdate: updatedViewports }); } }); viewportGridService.publishViewportOnDropHandled({ displaySetInstanceUID }); }; // Store previous isReferenceViewable values to avoid infinite loops const prevReferenceViewableMap = useRef(new Map()); // Track viewports that need isReferenceViewable updates const viewportsToUpdate = useRef(new Map()); // Apply isReferenceViewable updates in an effect, not during render useEffect(() => { const updates = viewportsToUpdate.current; if (updates.size > 0) { updates.forEach((isReferenceViewable, viewportId) => { viewportGridService.setIsReferenceViewable(viewportId, isReferenceViewable); prevReferenceViewableMap.current.set(viewportId, isReferenceViewable); }); viewportsToUpdate.current.clear(); } }); const getViewportPanes = useCallback(() => { const viewportPanes = []; const numViewportPanes = viewportGridService.getNumViewportPanes(); for (let i = 0; i < numViewportPanes; i++) { const paneMetadata = Array.from(viewports.values())[i] || {}; const { displaySetInstanceUIDs, viewportOptions, displaySetOptions, // array of options for each display set in the viewport x: viewportX, y: viewportY, width: viewportWidth, height: viewportHeight, viewportLabel, } = paneMetadata; const viewportId = viewportOptions.viewportId; const isActive = activeViewportId === viewportId; const displaySetInstanceUIDsToUse = displaySetInstanceUIDs || []; // This is causing the viewport components re-render when the activeViewportId changes const displaySets = displaySetInstanceUIDsToUse .map(displaySetInstanceUID => { return displaySetService.getDisplaySetByUID(displaySetInstanceUID) || {}; }) .filter(displaySet => { return !displaySet?.unsupported; }); const { component: ViewportComponent, isReferenceViewable } = _getViewportComponent( displaySets, viewportComponents, uiNotificationService ); // Only queue isReferenceViewable updates if it's changed to avoid render loops // We need to handle both function and non-function values if (viewportId) { const prevValue = prevReferenceViewableMap.current.get(viewportId); const isFunction = typeof isReferenceViewable === 'function'; const isSameFunction = isFunction && typeof prevValue === 'function'; // For non-functions, compare directly. For functions, we treat them as always different // (this is conservative but safe) if (!isSameFunction && prevValue !== isReferenceViewable) { // Queue the update instead of doing it during render viewportsToUpdate.current.set(viewportId, isReferenceViewable); } } // look inside displaySets to see if they need reRendering const displaySetsNeedsRerendering = displaySets.some(displaySet => { return displaySet.needsRerendering; }); const onInteractionHandler = event => { if (isActive) { return; } if (event && (appConfig?.activateViewportBeforeInteraction ?? true)) { event.preventDefault(); event.stopPropagation(); } viewportGridService.setActiveViewportId(viewportId); }; const getBorderStyle = viewportIndex => { const style = {} as any; const layoutOptions = viewportGridService.getLayoutOptionsFromState( viewportGridService.getState() ); const vp = layoutOptions[viewportIndex]; if (!vp) { return style; } const { x, y, width, height } = vp; const tolerance = 0.01; if (x + width < 1 - tolerance) { style.borderRight = '1px solid hsl(var(--input))'; } if (y + height < 1 - tolerance) { style.borderBottom = '1px solid hsl(var(--input))'; } return style; }; viewportPanes[i] = (
1 ? viewportLabel : ''} viewportId={viewportId} dataSource={dataSource} viewportOptions={viewportOptions} displaySetOptions={displaySetOptions} needsRerendering={displaySetsNeedsRerendering} isHangingProtocolLayout={isHangingProtocolLayout} onElementEnabled={evt => { viewportGridService.setViewportIsReady(viewportId, true); // Initialize the viewport action corners for this viewport if (evt?.detail?.element) { const elementRef = { current: evt.detail.element }; initializeViewportCorners(viewportId, elementRef, displaySets, commandsManager); } }} />
); } return viewportPanes; }, [viewports, activeViewportId, viewportComponents, dataSource, initializeViewportCorners]); /** * Loading indicator until numCols and numRows are gotten from the HangingProtocolService */ if (!numRows || !numCols) { return null; } return (
{getViewportPanes()}
); } function _getViewportComponent(displaySets, viewportComponents, uiNotificationService) { if (!displaySets || !displaySets.length) { return { component: EmptyViewport, isReferenceViewable: () => false }; } // Todo: Do we have a viewport that has two different SOPClassHandlerIds? const SOPClassHandlerId = displaySets[0].SOPClassHandlerId; for (let i = 0; i < viewportComponents.length; i++) { if (!viewportComponents[i]) { throw new Error('viewport components not defined'); } if (!viewportComponents[i].displaySetsToDisplay) { throw new Error('displaySetsToDisplay is null'); } if (viewportComponents[i].displaySetsToDisplay.includes(SOPClassHandlerId)) { const { component, isReferenceViewable } = viewportComponents[i]; return { component, isReferenceViewable }; } } console.log("Can't show displaySet", SOPClassHandlerId, displaySets[0]); uiNotificationService.show({ title: 'Viewport Not Supported Yet', message: `Cannot display SOPClassUID of ${displaySets[0].SOPClassUID} yet`, type: 'error', }); return { component: EmptyViewport, isReferenceViewable: () => false }; } export default ViewerViewportGrid;