ohif-viewer/platform/app/src/components/ViewportGrid.tsx

489 lines
17 KiB
TypeScript

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] = (
<ViewportPane
// Note: It is highly important that the key is the viewportId here,
// since it is used to determine if the component should be re-rendered
// by React, and also in the hanging protocol and stage changes if the
// same viewportId is used, React, by default, will only move (not re-render)
// those components. For instance, if we have a 2x3 layout, and we move
// from 2x3 to 1x1 (second viewport), if the key is the viewportIndex,
// React will RE-RENDER the resulting viewport as the key will be different.
// however, if the key is the viewportId, React will only move the component
// and not re-render it.
key={viewportId}
acceptDropsFor="displayset"
onDrop={onDropHandler.bind(null, viewportId)}
onInteraction={onInteractionHandler}
customStyle={{
position: 'absolute',
top: viewportY * 100 + '%',
left: viewportX * 100 + '%',
width: viewportWidth * 100 + '%',
height: viewportHeight * 100 + '%',
...getBorderStyle(i),
}}
isActive={isActive}
>
<div
data-cy="viewport-pane"
className="flex h-full w-full min-w-[5px] flex-col"
>
<ViewportComponent
displaySets={displaySets}
viewportLabel={viewports.size > 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);
}
}}
/>
</div>
</ViewportPane>
);
}
return viewportPanes;
}, [viewports, activeViewportId, viewportComponents, dataSource, initializeViewportCorners]);
/**
* Loading indicator until numCols and numRows are gotten from the HangingProtocolService
*/
if (!numRows || !numCols) {
return null;
}
return (
<div className="border-input h-[calc(100%-0.25rem)] w-full border">
<ViewportGrid
numRows={numRows}
numCols={numCols}
>
{getViewportPanes()}
</ViewportGrid>
</div>
);
}
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;