Initial commit from prod-batam
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
.viewport-wrapper {
|
||||
width: 100%;
|
||||
height: 100%; /* MUST have `height` to prevent resize infinite loop */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cornerstone-viewport-element {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
background-color: black;
|
||||
|
||||
/* Prevent the blue outline in Chrome when a viewport is selected */
|
||||
outline: 0 !important;
|
||||
|
||||
/* Prevents the entire page from getting larger
|
||||
when the magnify tool is near the sides/corners of the page */
|
||||
overflow: hidden;
|
||||
}
|
||||
664
extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx
Normal file
664
extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx
Normal file
@@ -0,0 +1,664 @@
|
||||
import React, { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useResizeDetector } from 'react-resize-detector';
|
||||
import * as cs3DTools from '@cornerstonejs/tools';
|
||||
import { Enums, eventTarget, getEnabledElement } from '@cornerstonejs/core';
|
||||
import { MeasurementService } from '@ohif/core';
|
||||
import { AllInOneMenu, Notification, useViewportDialog } from '@ohif/ui';
|
||||
import type { Types as csTypes } from '@cornerstonejs/core';
|
||||
|
||||
import { setEnabledElement } from '../state';
|
||||
|
||||
import './OHIFCornerstoneViewport.css';
|
||||
import CornerstoneOverlays from './Overlays/CornerstoneOverlays';
|
||||
import CinePlayer from '../components/CinePlayer';
|
||||
import type { Types } from '@ohif/core';
|
||||
|
||||
import OHIFViewportActionCorners from '../components/OHIFViewportActionCorners';
|
||||
import { getWindowLevelActionMenu } from '../components/WindowLevelActionMenu/getWindowLevelActionMenu';
|
||||
import { useAppConfig } from '@state';
|
||||
import { getViewportDataOverlaySettingsMenu } from '../components/ViewportDataOverlaySettingMenu';
|
||||
import { getViewportPresentations } from '../utils/presentations/getViewportPresentations';
|
||||
import { useSynchronizersStore } from '../stores/useSynchronizersStore';
|
||||
import ActiveViewportBehavior from '../utils/ActiveViewportBehavior';
|
||||
|
||||
const STACK = 'stack';
|
||||
|
||||
/**
|
||||
* Caches the jump to measurement operation, so that if display set is shown,
|
||||
* it can jump to the measurement.
|
||||
*/
|
||||
let cacheJumpToMeasurementEvent;
|
||||
|
||||
// Todo: This should be done with expose of internal API similar to react-vtkjs-viewport
|
||||
// Then we don't need to worry about the re-renders if the props change.
|
||||
const OHIFCornerstoneViewport = React.memo(
|
||||
(
|
||||
props: withAppTypes<{
|
||||
viewportId: string;
|
||||
displaySets: AppTypes.DisplaySet[];
|
||||
viewportOptions: AppTypes.ViewportGrid.GridViewportOptions;
|
||||
initialImageIndex: number;
|
||||
}>
|
||||
) => {
|
||||
const {
|
||||
displaySets,
|
||||
dataSource,
|
||||
viewportOptions,
|
||||
displaySetOptions,
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
onElementEnabled,
|
||||
// eslint-disable-next-line react/prop-types
|
||||
onElementDisabled,
|
||||
isJumpToMeasurementDisabled = false,
|
||||
// Note: you SHOULD NOT use the initialImageIdOrIndex for manipulation
|
||||
// of the imageData in the OHIFCornerstoneViewport. This prop is used
|
||||
// to set the initial state of the viewport's first image to render
|
||||
// eslint-disable-next-line react/prop-types
|
||||
initialImageIndex,
|
||||
// if the viewport is part of a hanging protocol layout
|
||||
// we should not really rely on the old synchronizers and
|
||||
// you see below we only rehydrate the synchronizers if the viewport
|
||||
// is not part of the hanging protocol layout. HPs should
|
||||
// define their own synchronizers. Since the synchronizers are
|
||||
// viewportId dependent and
|
||||
// eslint-disable-next-line react/prop-types
|
||||
isHangingProtocolLayout,
|
||||
} = props;
|
||||
const viewportId = viewportOptions.viewportId;
|
||||
|
||||
if (!viewportId) {
|
||||
throw new Error('Viewport ID is required');
|
||||
}
|
||||
|
||||
// Make sure displaySetOptions has one object per displaySet
|
||||
while (displaySetOptions.length < displaySets.length) {
|
||||
displaySetOptions.push({});
|
||||
}
|
||||
|
||||
// Since we only have support for dynamic data in volume viewports, we should
|
||||
// handle this case here and set the viewportType to volume if any of the
|
||||
// displaySets are dynamic volumes
|
||||
viewportOptions.viewportType = displaySets.some(
|
||||
ds => ds.isDynamicVolume && ds.isReconstructable
|
||||
)
|
||||
? 'volume'
|
||||
: viewportOptions.viewportType;
|
||||
|
||||
const [scrollbarHeight, setScrollbarHeight] = useState('100px');
|
||||
const [enabledVPElement, setEnabledVPElement] = useState(null);
|
||||
const elementRef = useRef() as React.MutableRefObject<HTMLDivElement>;
|
||||
const [appConfig] = useAppConfig();
|
||||
|
||||
const {
|
||||
displaySetService,
|
||||
toolbarService,
|
||||
toolGroupService,
|
||||
syncGroupService,
|
||||
cornerstoneViewportService,
|
||||
segmentationService,
|
||||
cornerstoneCacheService,
|
||||
viewportActionCornersService,
|
||||
} = servicesManager.services;
|
||||
|
||||
const [viewportDialogState] = useViewportDialog();
|
||||
// useCallback for scroll bar height calculation
|
||||
const setImageScrollBarHeight = useCallback(() => {
|
||||
const scrollbarHeight = `${elementRef.current.clientHeight - 40}px`;
|
||||
setScrollbarHeight(scrollbarHeight);
|
||||
}, [elementRef]);
|
||||
|
||||
// useCallback for onResize
|
||||
const onResize = useCallback(() => {
|
||||
if (elementRef.current) {
|
||||
cornerstoneViewportService.resize();
|
||||
setImageScrollBarHeight();
|
||||
}
|
||||
}, [elementRef]);
|
||||
|
||||
const cleanUpServices = useCallback(
|
||||
viewportInfo => {
|
||||
const renderingEngineId = viewportInfo.getRenderingEngineId();
|
||||
const syncGroups = viewportInfo.getSyncGroups();
|
||||
|
||||
toolGroupService.removeViewportFromToolGroup(viewportId, renderingEngineId);
|
||||
syncGroupService.removeViewportFromSyncGroup(viewportId, renderingEngineId, syncGroups);
|
||||
|
||||
segmentationService.clearSegmentationRepresentations(viewportId);
|
||||
|
||||
viewportActionCornersService.clear(viewportId);
|
||||
},
|
||||
[
|
||||
viewportId,
|
||||
segmentationService,
|
||||
syncGroupService,
|
||||
toolGroupService,
|
||||
viewportActionCornersService,
|
||||
]
|
||||
);
|
||||
|
||||
const elementEnabledHandler = useCallback(
|
||||
evt => {
|
||||
// check this is this element reference and return early if doesn't match
|
||||
if (evt.detail.element !== elementRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { viewportId, element } = evt.detail;
|
||||
const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId);
|
||||
setEnabledElement(viewportId, element);
|
||||
setEnabledVPElement(element);
|
||||
|
||||
const renderingEngineId = viewportInfo.getRenderingEngineId();
|
||||
const toolGroupId = viewportInfo.getToolGroupId();
|
||||
const syncGroups = viewportInfo.getSyncGroups();
|
||||
|
||||
toolGroupService.addViewportToToolGroup(viewportId, renderingEngineId, toolGroupId);
|
||||
|
||||
syncGroupService.addViewportToSyncGroup(viewportId, renderingEngineId, syncGroups);
|
||||
|
||||
// we don't need reactivity here so just use state
|
||||
const { synchronizersStore } = useSynchronizersStore.getState();
|
||||
if (synchronizersStore?.[viewportId]?.length && !isHangingProtocolLayout) {
|
||||
// If the viewport used to have a synchronizer, re apply it again
|
||||
_rehydrateSynchronizers(viewportId, syncGroupService);
|
||||
}
|
||||
|
||||
if (onElementEnabled && typeof onElementEnabled === 'function') {
|
||||
onElementEnabled(evt);
|
||||
}
|
||||
},
|
||||
[viewportId, onElementEnabled, toolGroupService]
|
||||
);
|
||||
|
||||
// disable the element upon unmounting
|
||||
useEffect(() => {
|
||||
cornerstoneViewportService.enableViewport(viewportId, elementRef.current);
|
||||
|
||||
eventTarget.addEventListener(Enums.Events.ELEMENT_ENABLED, elementEnabledHandler);
|
||||
|
||||
setImageScrollBarHeight();
|
||||
|
||||
return () => {
|
||||
const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId);
|
||||
|
||||
if (!viewportInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
cornerstoneViewportService.storePresentation({ viewportId });
|
||||
|
||||
// This should be done after the store presentation since synchronizers
|
||||
// will get cleaned up and they need the viewportInfo to be present
|
||||
cleanUpServices(viewportInfo);
|
||||
|
||||
if (onElementDisabled && typeof onElementDisabled === 'function') {
|
||||
onElementDisabled(viewportInfo);
|
||||
}
|
||||
|
||||
cornerstoneViewportService.disableElement(viewportId);
|
||||
|
||||
eventTarget.removeEventListener(Enums.Events.ELEMENT_ENABLED, elementEnabledHandler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// subscribe to displaySet metadata invalidation (updates)
|
||||
// Currently, if the metadata changes we need to re-render the display set
|
||||
// for it to take effect in the viewport. As we deal with scaling in the loading,
|
||||
// we need to remove the old volume from the cache, and let the
|
||||
// viewport to re-add it which will use the new metadata. Otherwise, the
|
||||
// viewport will use the cached volume and the new metadata will not be used.
|
||||
// Note: this approach does not actually end of sending network requests
|
||||
// and it uses the network cache
|
||||
useEffect(() => {
|
||||
const { unsubscribe } = displaySetService.subscribe(
|
||||
displaySetService.EVENTS.DISPLAY_SET_SERIES_METADATA_INVALIDATED,
|
||||
async ({
|
||||
displaySetInstanceUID: invalidatedDisplaySetInstanceUID,
|
||||
invalidateData,
|
||||
}: Types.DisplaySetSeriesMetadataInvalidatedEvent) => {
|
||||
if (!invalidateData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId);
|
||||
|
||||
if (viewportInfo.hasDisplaySet(invalidatedDisplaySetInstanceUID)) {
|
||||
const viewportData = viewportInfo.getViewportData();
|
||||
const newViewportData = await cornerstoneCacheService.invalidateViewportData(
|
||||
viewportData,
|
||||
invalidatedDisplaySetInstanceUID,
|
||||
dataSource,
|
||||
displaySetService
|
||||
);
|
||||
|
||||
const keepCamera = true;
|
||||
cornerstoneViewportService.updateViewport(viewportId, newViewportData, keepCamera);
|
||||
}
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [viewportId]);
|
||||
|
||||
useEffect(() => {
|
||||
// handle the default viewportType to be stack
|
||||
if (!viewportOptions.viewportType) {
|
||||
viewportOptions.viewportType = STACK;
|
||||
}
|
||||
|
||||
const loadViewportData = async () => {
|
||||
const viewportData = await cornerstoneCacheService.createViewportData(
|
||||
displaySets,
|
||||
viewportOptions,
|
||||
dataSource,
|
||||
initialImageIndex
|
||||
);
|
||||
|
||||
const presentations = getViewportPresentations(viewportId, viewportOptions);
|
||||
|
||||
let measurement;
|
||||
if (cacheJumpToMeasurementEvent?.viewportId === viewportId) {
|
||||
measurement = cacheJumpToMeasurementEvent.measurement;
|
||||
// Delete the position presentation so that viewport navigates direct
|
||||
presentations.positionPresentation = null;
|
||||
cacheJumpToMeasurementEvent = null;
|
||||
}
|
||||
|
||||
// Note: This is a hack to get the grid to re-render the OHIFCornerstoneViewport component
|
||||
// Used for segmentation hydration right now, since the logic to decide whether
|
||||
// a viewport needs to render a segmentation lives inside the CornerstoneViewportService
|
||||
// so we need to re-render (force update via change of the needsRerendering) so that React
|
||||
// does the diffing and decides we should render this again (although the id and element has not changed)
|
||||
// so that the CornerstoneViewportService can decide whether to render the segmentation or not. Not that we reached here we can turn it off.
|
||||
if (viewportOptions.needsRerendering) {
|
||||
viewportOptions.needsRerendering = false;
|
||||
}
|
||||
|
||||
cornerstoneViewportService.setViewportData(
|
||||
viewportId,
|
||||
viewportData,
|
||||
viewportOptions,
|
||||
displaySetOptions,
|
||||
presentations
|
||||
);
|
||||
|
||||
if (measurement) {
|
||||
cs3DTools.annotation.selection.setAnnotationSelected(measurement.uid);
|
||||
}
|
||||
};
|
||||
|
||||
loadViewportData();
|
||||
}, [viewportOptions, displaySets, dataSource]);
|
||||
|
||||
/**
|
||||
* There are two scenarios for jump to click
|
||||
* 1. Current viewports contain the displaySet that the annotation was drawn on
|
||||
* 2. Current viewports don't contain the displaySet that the annotation was drawn on
|
||||
* and we need to change the viewports displaySet for jumping.
|
||||
* Since measurement_jump happens via events and listeners, the former case is handled
|
||||
* by the measurement_jump direct callback, but the latter case is handled first by
|
||||
* the viewportGrid to set the correct displaySet on the viewport, AND THEN we check
|
||||
* the cache for jumping to see if there is any jump queued, then we jump to the correct slice.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isJumpToMeasurementDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribeFromJumpToMeasurementEvents = _subscribeToJumpToMeasurementEvents(
|
||||
elementRef,
|
||||
viewportId,
|
||||
servicesManager
|
||||
);
|
||||
|
||||
_checkForCachedJumpToMeasurementEvents(elementRef, viewportId, displaySets, servicesManager);
|
||||
|
||||
return () => {
|
||||
unsubscribeFromJumpToMeasurementEvents();
|
||||
};
|
||||
}, [displaySets, elementRef, viewportId, isJumpToMeasurementDisabled, servicesManager]);
|
||||
|
||||
// Set up the window level action menu in the viewport action corners.
|
||||
useEffect(() => {
|
||||
// Doing an === check here because the default config value when not set is true
|
||||
if (appConfig.addWindowLevelActionMenu === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const location = viewportActionCornersService.LOCATIONS.topRight;
|
||||
|
||||
// TODO: In the future we should consider using the customization service
|
||||
// to determine if and in which corner various action components should go.
|
||||
viewportActionCornersService.addComponent({
|
||||
viewportId,
|
||||
id: 'windowLevelActionMenu',
|
||||
component: getWindowLevelActionMenu({
|
||||
viewportId,
|
||||
element: elementRef.current,
|
||||
displaySets,
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
location,
|
||||
verticalDirection: AllInOneMenu.VerticalDirection.TopToBottom,
|
||||
horizontalDirection: AllInOneMenu.HorizontalDirection.RightToLeft,
|
||||
}),
|
||||
location,
|
||||
});
|
||||
|
||||
viewportActionCornersService.addComponent({
|
||||
viewportId,
|
||||
id: 'segmentation',
|
||||
component: getViewportDataOverlaySettingsMenu({
|
||||
viewportId,
|
||||
element: elementRef.current,
|
||||
displaySets,
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
location,
|
||||
}),
|
||||
location,
|
||||
});
|
||||
}, [
|
||||
displaySets,
|
||||
viewportId,
|
||||
viewportActionCornersService,
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
appConfig,
|
||||
]);
|
||||
|
||||
const { ref: resizeRef } = useResizeDetector({
|
||||
onResize,
|
||||
});
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div className="viewport-wrapper">
|
||||
<div
|
||||
className="cornerstone-viewport-element"
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
onContextMenu={e => e.preventDefault()}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
ref={el => {
|
||||
resizeRef.current = el;
|
||||
elementRef.current = el;
|
||||
}}
|
||||
></div>
|
||||
<CornerstoneOverlays
|
||||
viewportId={viewportId}
|
||||
toolBarService={toolbarService}
|
||||
element={elementRef.current}
|
||||
scrollbarHeight={scrollbarHeight}
|
||||
servicesManager={servicesManager}
|
||||
/>
|
||||
<CinePlayer
|
||||
enabledVPElement={enabledVPElement}
|
||||
viewportId={viewportId}
|
||||
servicesManager={servicesManager}
|
||||
/>
|
||||
<ActiveViewportBehavior
|
||||
viewportId={viewportId}
|
||||
servicesManager={servicesManager}
|
||||
/>
|
||||
</div>
|
||||
{/* top offset of 24px to account for ViewportActionCorners. */}
|
||||
<div className="absolute top-[24px] w-full">
|
||||
{viewportDialogState.viewportId === viewportId && (
|
||||
<Notification
|
||||
id="viewport-notification"
|
||||
message={viewportDialogState.message}
|
||||
type={viewportDialogState.type}
|
||||
actions={viewportDialogState.actions}
|
||||
onSubmit={viewportDialogState.onSubmit}
|
||||
onOutsideClick={viewportDialogState.onOutsideClick}
|
||||
onKeyPress={viewportDialogState.onKeyPress}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* The OHIFViewportActionCorners follows the viewport in the DOM so that it is naturally at a higher z-index.*/}
|
||||
<OHIFViewportActionCorners viewportId={viewportId} />
|
||||
</React.Fragment>
|
||||
);
|
||||
},
|
||||
areEqual
|
||||
);
|
||||
|
||||
function _subscribeToJumpToMeasurementEvents(elementRef, viewportId, servicesManager) {
|
||||
const { measurementService, cornerstoneViewportService } = servicesManager.services;
|
||||
|
||||
const { unsubscribe } = measurementService.subscribe(
|
||||
MeasurementService.EVENTS.JUMP_TO_MEASUREMENT_VIEWPORT,
|
||||
props => {
|
||||
cacheJumpToMeasurementEvent = props;
|
||||
const { viewportId: jumpId, measurement, isConsumed } = props;
|
||||
if (!measurement || isConsumed) {
|
||||
return;
|
||||
}
|
||||
if (cacheJumpToMeasurementEvent.cornerstoneViewport === undefined) {
|
||||
// Decide on which viewport should handle this
|
||||
cacheJumpToMeasurementEvent.cornerstoneViewport =
|
||||
cornerstoneViewportService.getViewportIdToJump(jumpId, {
|
||||
displaySetInstanceUID: measurement.displaySetInstanceUID,
|
||||
...measurement.metadata,
|
||||
referencedImageId:
|
||||
measurement.referencedImageId || measurement.metadata?.referencedImageId,
|
||||
});
|
||||
}
|
||||
if (cacheJumpToMeasurementEvent.cornerstoneViewport !== viewportId) {
|
||||
return;
|
||||
}
|
||||
_jumpToMeasurement(measurement, elementRef, viewportId, servicesManager);
|
||||
}
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
}
|
||||
|
||||
// Check if there is a queued jumpToMeasurement event
|
||||
function _checkForCachedJumpToMeasurementEvents(
|
||||
elementRef,
|
||||
viewportId,
|
||||
displaySets,
|
||||
servicesManager
|
||||
) {
|
||||
if (!cacheJumpToMeasurementEvent) {
|
||||
return;
|
||||
}
|
||||
if (cacheJumpToMeasurementEvent.isConsumed) {
|
||||
cacheJumpToMeasurementEvent = null;
|
||||
return;
|
||||
}
|
||||
const displaysUIDs = displaySets.map(displaySet => displaySet.displaySetInstanceUID);
|
||||
if (!displaysUIDs?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Jump to measurement if the measurement exists
|
||||
const { measurement } = cacheJumpToMeasurementEvent;
|
||||
if (measurement && elementRef) {
|
||||
if (displaysUIDs.includes(measurement?.displaySetInstanceUID)) {
|
||||
_jumpToMeasurement(measurement, elementRef, viewportId, servicesManager);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _jumpToMeasurement(measurement, targetElementRef, viewportId, servicesManager) {
|
||||
const { viewportGridService } = servicesManager.services;
|
||||
|
||||
const targetElement = targetElementRef.current;
|
||||
|
||||
// Todo: setCornerstoneMeasurementActive should be handled by the toolGroupManager
|
||||
// to set it properly
|
||||
// setCornerstoneMeasurementActive(measurement);
|
||||
|
||||
viewportGridService.setActiveViewportId(viewportId);
|
||||
|
||||
const enabledElement = getEnabledElement(targetElement);
|
||||
|
||||
if (enabledElement) {
|
||||
// See how the jumpToSlice() of Cornerstone3D deals with imageIdx param.
|
||||
const viewport = enabledElement.viewport as csTypes.IStackViewport | csTypes.IVolumeViewport;
|
||||
|
||||
const { metadata } = measurement;
|
||||
if (!viewport.isReferenceViewable(metadata, { withNavigation: true, withOrientation: true })) {
|
||||
console.log("Reference isn't viewable, postponing until updated");
|
||||
return;
|
||||
}
|
||||
|
||||
viewport.setViewReference(metadata);
|
||||
|
||||
cs3DTools.annotation.selection.setAnnotationSelected(measurement.uid);
|
||||
// Jump to measurement consumed, remove.
|
||||
cacheJumpToMeasurementEvent?.consume?.();
|
||||
cacheJumpToMeasurementEvent = null;
|
||||
}
|
||||
}
|
||||
|
||||
function _rehydrateSynchronizers(viewportId: string, syncGroupService: any) {
|
||||
const { synchronizersStore } = useSynchronizersStore.getState();
|
||||
const synchronizers = synchronizersStore[viewportId];
|
||||
|
||||
if (!synchronizers) {
|
||||
return;
|
||||
}
|
||||
|
||||
synchronizers.forEach(synchronizerObj => {
|
||||
if (!synchronizerObj.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { id, sourceViewports, targetViewports } = synchronizerObj;
|
||||
|
||||
const synchronizer = syncGroupService.getSynchronizer(id);
|
||||
|
||||
if (!synchronizer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceViewportInfo = sourceViewports.find(
|
||||
sourceViewport => sourceViewport.viewportId === viewportId
|
||||
);
|
||||
|
||||
const targetViewportInfo = targetViewports.find(
|
||||
targetViewport => targetViewport.viewportId === viewportId
|
||||
);
|
||||
|
||||
const isSourceViewportInSynchronizer = synchronizer
|
||||
.getSourceViewports()
|
||||
.find(sourceViewport => sourceViewport.viewportId === viewportId);
|
||||
|
||||
const isTargetViewportInSynchronizer = synchronizer
|
||||
.getTargetViewports()
|
||||
.find(targetViewport => targetViewport.viewportId === viewportId);
|
||||
|
||||
// if the viewport was previously a source viewport, add it again
|
||||
if (sourceViewportInfo && !isSourceViewportInSynchronizer) {
|
||||
synchronizer.addSource({
|
||||
viewportId: sourceViewportInfo.viewportId,
|
||||
renderingEngineId: sourceViewportInfo.renderingEngineId,
|
||||
});
|
||||
}
|
||||
|
||||
// if the viewport was previously a target viewport, add it again
|
||||
if (targetViewportInfo && !isTargetViewportInSynchronizer) {
|
||||
synchronizer.addTarget({
|
||||
viewportId: targetViewportInfo.viewportId,
|
||||
renderingEngineId: targetViewportInfo.renderingEngineId,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Component displayName
|
||||
OHIFCornerstoneViewport.displayName = 'OHIFCornerstoneViewport';
|
||||
|
||||
function areEqual(prevProps, nextProps) {
|
||||
if (nextProps.needsRerendering) {
|
||||
console.debug('OHIFCornerstoneViewport: Rerender caused by: needsRerendering');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prevProps.displaySets.length !== nextProps.displaySets.length) {
|
||||
console.debug('OHIFCornerstoneViewport: Rerender caused by: displaySets length change');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prevProps.viewportOptions.orientation !== nextProps.viewportOptions.orientation) {
|
||||
console.debug('OHIFCornerstoneViewport: Rerender caused by: orientation change');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prevProps.viewportOptions.toolGroupId !== nextProps.viewportOptions.toolGroupId) {
|
||||
console.debug('OHIFCornerstoneViewport: Rerender caused by: toolGroupId change');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
nextProps.viewportOptions.viewportType &&
|
||||
prevProps.viewportOptions.viewportType !== nextProps.viewportOptions.viewportType
|
||||
) {
|
||||
console.debug('OHIFCornerstoneViewport: Rerender caused by: viewportType change');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nextProps.viewportOptions.needsRerendering) {
|
||||
console.debug('OHIFCornerstoneViewport: Rerender caused by: viewportOptions.needsRerendering');
|
||||
return false;
|
||||
}
|
||||
|
||||
const prevDisplaySets = prevProps.displaySets;
|
||||
const nextDisplaySets = nextProps.displaySets;
|
||||
|
||||
if (prevDisplaySets.length !== nextDisplaySets.length) {
|
||||
console.debug('OHIFCornerstoneViewport: Rerender caused by: displaySets length mismatch');
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < prevDisplaySets.length; i++) {
|
||||
const prevDisplaySet = prevDisplaySets[i];
|
||||
|
||||
const foundDisplaySet = nextDisplaySets.find(
|
||||
nextDisplaySet =>
|
||||
nextDisplaySet.displaySetInstanceUID === prevDisplaySet.displaySetInstanceUID
|
||||
);
|
||||
|
||||
if (!foundDisplaySet) {
|
||||
console.debug('OHIFCornerstoneViewport: Rerender caused by: displaySet not found');
|
||||
return false;
|
||||
}
|
||||
|
||||
// check they contain the same image
|
||||
if (foundDisplaySet.images?.length !== prevDisplaySet.images?.length) {
|
||||
console.debug('OHIFCornerstoneViewport: Rerender caused by: images length mismatch');
|
||||
return false;
|
||||
}
|
||||
|
||||
// check if their imageIds are the same
|
||||
if (foundDisplaySet.images?.length) {
|
||||
for (let j = 0; j < foundDisplaySet.images.length; j++) {
|
||||
if (foundDisplaySet.images[j].imageId !== prevDisplaySet.images[j].imageId) {
|
||||
console.debug('OHIFCornerstoneViewport: Rerender caused by: imageId mismatch');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Helper function to check if display sets have changed
|
||||
function haveDisplaySetsChanged(prevDisplaySets, currentDisplaySets) {
|
||||
if (prevDisplaySets.length !== currentDisplaySets.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return currentDisplaySets.some((currentDS, index) => {
|
||||
const prevDS = prevDisplaySets[index];
|
||||
return currentDS.displaySetInstanceUID !== prevDS.displaySetInstanceUID;
|
||||
});
|
||||
}
|
||||
|
||||
export default OHIFCornerstoneViewport;
|
||||
@@ -0,0 +1,82 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import ViewportImageScrollbar from './ViewportImageScrollbar';
|
||||
import CustomizableViewportOverlay from './CustomizableViewportOverlay';
|
||||
import ViewportOrientationMarkers from './ViewportOrientationMarkers';
|
||||
import ViewportImageSliceLoadingIndicator from './ViewportImageSliceLoadingIndicator';
|
||||
|
||||
function CornerstoneOverlays(props: withAppTypes) {
|
||||
const { viewportId, element, scrollbarHeight, servicesManager } = props;
|
||||
const { cornerstoneViewportService } = servicesManager.services;
|
||||
const [imageSliceData, setImageSliceData] = useState({
|
||||
imageIndex: 0,
|
||||
numberOfSlices: 0,
|
||||
});
|
||||
const [viewportData, setViewportData] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const { unsubscribe } = cornerstoneViewportService.subscribe(
|
||||
cornerstoneViewportService.EVENTS.VIEWPORT_DATA_CHANGED,
|
||||
props => {
|
||||
if (props.viewportId !== viewportId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setViewportData(props.viewportData);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [viewportId]);
|
||||
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (viewportData) {
|
||||
const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId);
|
||||
|
||||
if (viewportInfo?.viewportOptions?.customViewportProps?.hideOverlays) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="noselect">
|
||||
<ViewportImageScrollbar
|
||||
viewportId={viewportId}
|
||||
viewportData={viewportData}
|
||||
element={element}
|
||||
imageSliceData={imageSliceData}
|
||||
setImageSliceData={setImageSliceData}
|
||||
scrollbarHeight={scrollbarHeight}
|
||||
servicesManager={servicesManager}
|
||||
/>
|
||||
|
||||
<CustomizableViewportOverlay
|
||||
imageSliceData={imageSliceData}
|
||||
viewportData={viewportData}
|
||||
viewportId={viewportId}
|
||||
servicesManager={servicesManager}
|
||||
element={element}
|
||||
/>
|
||||
|
||||
<ViewportImageSliceLoadingIndicator
|
||||
viewportData={viewportData}
|
||||
element={element}
|
||||
/>
|
||||
|
||||
<ViewportOrientationMarkers
|
||||
imageSliceData={imageSliceData}
|
||||
element={element}
|
||||
viewportData={viewportData}
|
||||
servicesManager={servicesManager}
|
||||
viewportId={viewportId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CornerstoneOverlays;
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
custom overlay panels: top-left, top-right, bottom-left and bottom-right
|
||||
If any text to be displayed on the overlay is too long to hold on a single
|
||||
line, it will be truncated with ellipsis in the end.
|
||||
*/
|
||||
.viewport-overlay {
|
||||
max-width: 40%;
|
||||
}
|
||||
.viewport-overlay span {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.viewport-overlay.left-viewport {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.viewport-overlay.right-viewport-scrollbar {
|
||||
text-align: right;
|
||||
}
|
||||
.viewport-overlay.right-viewport-scrollbar .flex.flex-row {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.expertise-overlay {
|
||||
max-width: 300px; /* Atur sesuai kebutuhan */
|
||||
word-wrap: break-word;
|
||||
white-space: pre-line; /* Untuk mempertahankan format newline dari API */
|
||||
background: rgba(0, 0, 0, 0.7); /* Background semi-transparan */
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { vec3 } from 'gl-matrix';
|
||||
import PropTypes from 'prop-types';
|
||||
import { metaData, Enums, utilities } from '@cornerstonejs/core';
|
||||
import type { ImageSliceData } from '@cornerstonejs/core/types';
|
||||
import { ViewportOverlay } from '@ohif/ui';
|
||||
import type { InstanceMetadata } from '@ohif/core/src/types';
|
||||
import { formatPN, formatDICOMDate, formatDICOMTime, formatNumberPrecision } from './utils';
|
||||
import { StackViewportData, VolumeViewportData } from '../../types/CornerstoneCacheService';
|
||||
|
||||
import './CustomizableViewportOverlay.css';
|
||||
import { studyDataForOverlayItem } from './studyDataForOverlayItem';
|
||||
|
||||
const EPSILON = 1e-4;
|
||||
|
||||
type ViewportData = StackViewportData | VolumeViewportData;
|
||||
|
||||
interface OverlayItemProps {
|
||||
element: HTMLElement;
|
||||
viewportData: ViewportData;
|
||||
imageSliceData: ImageSliceData;
|
||||
servicesManager: AppTypes.ServicesManager;
|
||||
viewportId: string;
|
||||
instance: InstanceMetadata;
|
||||
customization: any;
|
||||
formatters: {
|
||||
formatPN: (val) => string;
|
||||
formatDate: (val) => string;
|
||||
formatTime: (val) => string;
|
||||
formatNumberPrecision: (val, number) => string;
|
||||
};
|
||||
|
||||
// calculated values
|
||||
voi: {
|
||||
windowWidth: number;
|
||||
windowCenter: number;
|
||||
};
|
||||
instanceNumber?: number;
|
||||
scale?: number;
|
||||
displaySetProps?: {
|
||||
referenceInstance?: {
|
||||
StudyInstanceUID?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const OverlayItemComponents = {
|
||||
'ohif.overlayItem': OverlayItem,
|
||||
'ohif.overlayItem.windowLevel': VOIOverlayItem,
|
||||
'ohif.overlayItem.zoomLevel': ZoomOverlayItem,
|
||||
'ohif.overlayItem.instanceNumber': InstanceNumberOverlayItem,
|
||||
};
|
||||
|
||||
const studyDateItem = {
|
||||
id: 'StudyDate',
|
||||
customizationType: 'ohif.overlayItem',
|
||||
label: 'Study Dttm: ',
|
||||
title: 'Study date and time',
|
||||
condition: ({ referenceInstance, studyApiData }) =>
|
||||
referenceInstance?.StudyDate || studyApiData?.studyTime,
|
||||
contentF: ({ referenceInstance, formatters: { formatDate }, studyApiData }) => {
|
||||
const date = referenceInstance?.StudyDate ? formatDate(referenceInstance.StudyDate) : '';
|
||||
const time = studyApiData?.studyTime ? studyApiData.studyTime : '';
|
||||
return `${date} ${time}`;
|
||||
},
|
||||
};
|
||||
|
||||
const seriesDescriptionItem = {
|
||||
id: 'SeriesDescription',
|
||||
customizationType: 'ohif.overlayItem',
|
||||
label: 'Series Desc: ',
|
||||
title: 'Series description',
|
||||
condition: ({ referenceInstance }) => {
|
||||
return referenceInstance && referenceInstance.SeriesDescription;
|
||||
},
|
||||
contentF: ({ referenceInstance }) => referenceInstance.SeriesDescription,
|
||||
};
|
||||
|
||||
const patientNameItem = {
|
||||
id: 'PatientName',
|
||||
customizationType: 'ohif.overlayItem',
|
||||
label: '',
|
||||
title: 'Patient Name',
|
||||
condition: ({ instance }) => instance?.PatientName,
|
||||
contentF: ({ instance, formatters: { formatPN } }) => formatPN(instance.PatientName.Alphabetic),
|
||||
};
|
||||
|
||||
const mrnItem = {
|
||||
id: 'MRN',
|
||||
customizationType: 'ohif.overlayItem',
|
||||
label: '',
|
||||
title: 'Medical Record Number',
|
||||
condition: ({ referenceInstance }) => referenceInstance?.PatientID,
|
||||
contentF: ({ referenceInstance }) => referenceInstance.PatientID,
|
||||
};
|
||||
|
||||
const sexAndAgeItem = {
|
||||
id: 'SexAndAge',
|
||||
customizationType: 'ohif.overlayItem',
|
||||
label: '',
|
||||
title: 'Patient Sex and Age',
|
||||
condition: ({ studyApiData }) => studyApiData?.patientSex || studyApiData?.formattedAge,
|
||||
contentF: ({ studyApiData }) => {
|
||||
const sex = studyApiData?.patientSex || '';
|
||||
const age = studyApiData?.formattedAge || '';
|
||||
return sex && age ? `${sex}, ${age}` : sex || age;
|
||||
},
|
||||
};
|
||||
|
||||
const studyDescriptionItem = {
|
||||
id: 'StudyDescription',
|
||||
customizationType: 'ohif.overlayItem',
|
||||
label: 'Study Desc: ',
|
||||
title: 'Study Description',
|
||||
condition: ({ studyApiData }) => studyApiData?.studyDescription,
|
||||
contentF: ({ studyApiData }) => studyApiData.studyDescription,
|
||||
};
|
||||
|
||||
const accessionNumberItem = {
|
||||
id: 'AccessionNumber',
|
||||
customizationType: 'ohif.overlayItem',
|
||||
label: '',
|
||||
title: 'Accession Number',
|
||||
condition: ({ studyApiData }) => studyApiData?.accessionNumber,
|
||||
contentF: ({ studyApiData }) => studyApiData.accessionNumber,
|
||||
};
|
||||
|
||||
const topLeftItems = {
|
||||
id: 'cornerstoneOverlayTopLeft',
|
||||
items: [patientNameItem, mrnItem, sexAndAgeItem],
|
||||
};
|
||||
|
||||
const topRightItems = { id: 'cornerstoneOverlayTopRight', items: [] };
|
||||
|
||||
const bottomLeftItems = {
|
||||
id: 'cornerstoneOverlayBottomLeft',
|
||||
items: [accessionNumberItem, studyDescriptionItem, studyDateItem, seriesDescriptionItem],
|
||||
};
|
||||
|
||||
const bottomRightItems = {
|
||||
id: 'cornerstoneOverlayBottomRight',
|
||||
items: [
|
||||
{
|
||||
id: 'InstanceNumber',
|
||||
customizationType: 'ohif.overlayItem.instanceNumber',
|
||||
},
|
||||
seriesDescriptionItem,
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* The @ohif/cornerstoneOverlay is a default value for a customization
|
||||
* for the cornerstone overlays. The intent is to allow it to be extended
|
||||
* without needing to re-write the individual overlays by using the append
|
||||
* mechanism. Individual attributes can be modified individually without
|
||||
* affecting the other items by using the append as well, with position
|
||||
* based replacement.
|
||||
* This is used as a default in the getCustomizationModule so that it
|
||||
* is available early for additional customization extensions.
|
||||
*/
|
||||
const CornerstoneOverlay = {
|
||||
id: '@ohif/cornerstoneOverlay',
|
||||
topLeftItems,
|
||||
topRightItems,
|
||||
bottomLeftItems,
|
||||
bottomRightItems,
|
||||
};
|
||||
|
||||
/**
|
||||
* Customizable Viewport Overlay
|
||||
*/
|
||||
function CustomizableViewportOverlay({
|
||||
element,
|
||||
viewportData,
|
||||
imageSliceData,
|
||||
viewportId,
|
||||
servicesManager,
|
||||
}: {
|
||||
element: HTMLElement;
|
||||
viewportData: ViewportData;
|
||||
imageSliceData: ImageSliceData;
|
||||
viewportId: string;
|
||||
servicesManager: AppTypes.ServicesManager;
|
||||
}) {
|
||||
const { cornerstoneViewportService, customizationService, toolGroupService, displaySetService } =
|
||||
servicesManager.services;
|
||||
const [voi, setVOI] = useState({ windowCenter: null, windowWidth: null });
|
||||
const [scale, setScale] = useState(1);
|
||||
const { imageIndex } = imageSliceData;
|
||||
|
||||
// The new customization is 'cornerstoneOverlay', with an append or replace
|
||||
// on the individual items rather than defining individual items.
|
||||
const cornerstoneOverlay = customizationService.getCustomization('@ohif/cornerstoneOverlay');
|
||||
|
||||
// Historical usage defined the overlays as separate items due to lack of
|
||||
// append functionality. This code enables the historical usage, but
|
||||
// the recommended functionality is to append to the default values in
|
||||
// cornerstoneOverlay rather than defining individual items.
|
||||
const topLeftCustomization =
|
||||
customizationService.getCustomization('cornerstoneOverlayTopLeft') ||
|
||||
cornerstoneOverlay?.topLeftItems;
|
||||
const topRightCustomization =
|
||||
customizationService.getCustomization('cornerstoneOverlayTopRight') ||
|
||||
cornerstoneOverlay?.topRightItems;
|
||||
const bottomLeftCustomization =
|
||||
customizationService.getCustomization('cornerstoneOverlayBottomLeft') ||
|
||||
cornerstoneOverlay?.bottomLeftItems;
|
||||
const bottomRightCustomization =
|
||||
customizationService.getCustomization('cornerstoneOverlayBottomRight') ||
|
||||
cornerstoneOverlay?.bottomRightItems;
|
||||
|
||||
const instanceNumber = useMemo(
|
||||
() =>
|
||||
viewportData
|
||||
? getInstanceNumber(viewportData, viewportId, imageIndex, cornerstoneViewportService)
|
||||
: null,
|
||||
[viewportData, viewportId, imageIndex, cornerstoneViewportService]
|
||||
);
|
||||
|
||||
const displaySetProps = useMemo(() => {
|
||||
const displaySets = getDisplaySets(viewportData, displaySetService);
|
||||
if (!displaySets) {
|
||||
return null;
|
||||
}
|
||||
const [displaySet] = displaySets;
|
||||
const { instances, instance: referenceInstance } = displaySet;
|
||||
|
||||
return {
|
||||
displaySets,
|
||||
displaySet,
|
||||
instance: instances?.[imageIndex],
|
||||
instances,
|
||||
referenceInstance,
|
||||
};
|
||||
}, [viewportData, viewportId, instanceNumber, cornerstoneViewportService]);
|
||||
|
||||
// FEAT: Edit overlays item [2025-09-04]
|
||||
const studyInstanceUID = displaySetProps?.referenceInstance?.StudyInstanceUID;
|
||||
const { studyData: studyApiData, loading, error } = studyDataForOverlayItem(studyInstanceUID);
|
||||
|
||||
/**
|
||||
* Updating the VOI when the viewport changes its voi
|
||||
*/
|
||||
useEffect(() => {
|
||||
const updateVOI = eventDetail => {
|
||||
const { range } = eventDetail.detail;
|
||||
|
||||
if (!range) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { lower, upper } = range;
|
||||
const { windowWidth, windowCenter } = utilities.windowLevel.toWindowLevel(lower, upper);
|
||||
|
||||
setVOI({ windowCenter, windowWidth });
|
||||
};
|
||||
|
||||
element.addEventListener(Enums.Events.VOI_MODIFIED, updateVOI);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener(Enums.Events.VOI_MODIFIED, updateVOI);
|
||||
};
|
||||
}, [viewportId, viewportData, voi, element]);
|
||||
|
||||
/**
|
||||
* Updating the scale when the viewport changes its zoom
|
||||
*/
|
||||
useEffect(() => {
|
||||
const updateScale = eventDetail => {
|
||||
const { previousCamera, camera } = eventDetail.detail;
|
||||
|
||||
if (
|
||||
previousCamera.parallelScale !== camera.parallelScale ||
|
||||
previousCamera.scale !== camera.scale
|
||||
) {
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scale = viewport.getZoom();
|
||||
|
||||
setScale(scale);
|
||||
}
|
||||
};
|
||||
|
||||
element.addEventListener(Enums.Events.CAMERA_MODIFIED, updateScale);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener(Enums.Events.CAMERA_MODIFIED, updateScale);
|
||||
};
|
||||
}, [viewportId, viewportData, cornerstoneViewportService, element]);
|
||||
|
||||
const _renderOverlayItem = useCallback(
|
||||
(item, props) => {
|
||||
const overlayItemProps = {
|
||||
...props,
|
||||
element,
|
||||
viewportData,
|
||||
imageSliceData,
|
||||
viewportId,
|
||||
servicesManager,
|
||||
customization: item,
|
||||
formatters: {
|
||||
formatPN,
|
||||
formatDate: formatDICOMDate,
|
||||
formatTime: formatDICOMTime,
|
||||
formatNumberPrecision,
|
||||
},
|
||||
displaySetProps,
|
||||
};
|
||||
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { customizationType } = item;
|
||||
const OverlayItemComponent = OverlayItemComponents[customizationType];
|
||||
|
||||
if (OverlayItemComponent) {
|
||||
return <OverlayItemComponent {...overlayItemProps} />;
|
||||
} else {
|
||||
const renderItem = customizationService.transform(item);
|
||||
|
||||
if (typeof renderItem.contentF === 'function') {
|
||||
return renderItem.contentF(overlayItemProps);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
element,
|
||||
viewportData,
|
||||
imageSliceData,
|
||||
viewportId,
|
||||
servicesManager,
|
||||
customizationService,
|
||||
displaySetProps,
|
||||
voi,
|
||||
scale,
|
||||
instanceNumber,
|
||||
]
|
||||
);
|
||||
|
||||
const getContent = useCallback(
|
||||
(customization, keyPrefix) => {
|
||||
if (!customization?.items) {
|
||||
return null;
|
||||
}
|
||||
const { items } = customization;
|
||||
const props = {
|
||||
...displaySetProps,
|
||||
formatters: { formatDate: formatDICOMDate },
|
||||
voi,
|
||||
scale,
|
||||
instanceNumber,
|
||||
viewportId,
|
||||
toolGroupService,
|
||||
studyApiData, // Pass the API data to the overlay items
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.map((item, index) => (
|
||||
<div key={`${keyPrefix}_${index}`}>
|
||||
{((!item?.condition || item.condition(props)) && _renderOverlayItem(item, props)) ||
|
||||
null}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
},
|
||||
[_renderOverlayItem, displaySetProps, studyApiData]
|
||||
);
|
||||
|
||||
return (
|
||||
<ViewportOverlay
|
||||
topLeft={getContent(topLeftCustomization, 'topLeftOverlayItem')}
|
||||
topRight={getContent(topRightCustomization, 'topRightOverlayItem')}
|
||||
bottomLeft={getContent(bottomLeftCustomization, 'bottomLeftOverlayItem')}
|
||||
bottomRight={getContent(bottomRightCustomization, 'bottomRightOverlayItem')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an array of display sets for the given viewport, based on the viewport data.
|
||||
* Returns null if none found.
|
||||
*/
|
||||
function getDisplaySets(viewportData, displaySetService) {
|
||||
if (!viewportData?.data?.length) {
|
||||
return null;
|
||||
}
|
||||
const displaySets = viewportData.data
|
||||
.map(datum => displaySetService.getDisplaySetByUID(datum.displaySetInstanceUID))
|
||||
.filter(it => !!it);
|
||||
if (!displaySets.length) {
|
||||
return null;
|
||||
}
|
||||
return displaySets;
|
||||
}
|
||||
|
||||
const getInstanceNumber = (viewportData, viewportId, imageIndex, cornerstoneViewportService) => {
|
||||
let instanceNumber;
|
||||
|
||||
switch (viewportData.viewportType) {
|
||||
case Enums.ViewportType.STACK:
|
||||
instanceNumber = _getInstanceNumberFromStack(viewportData, imageIndex);
|
||||
break;
|
||||
case Enums.ViewportType.ORTHOGRAPHIC:
|
||||
instanceNumber = _getInstanceNumberFromVolume(
|
||||
viewportData,
|
||||
viewportId,
|
||||
cornerstoneViewportService,
|
||||
imageIndex
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return instanceNumber ?? null;
|
||||
};
|
||||
|
||||
function _getInstanceNumberFromStack(viewportData, imageIndex) {
|
||||
const imageIds = viewportData.data[0].imageIds;
|
||||
const imageId = imageIds[imageIndex];
|
||||
|
||||
if (!imageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const generalImageModule = metaData.get('generalImageModule', imageId) || {};
|
||||
const { instanceNumber } = generalImageModule;
|
||||
|
||||
const stackSize = imageIds.length;
|
||||
|
||||
if (stackSize <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
return parseInt(instanceNumber);
|
||||
}
|
||||
|
||||
// Since volume viewports can be in any view direction, they can render
|
||||
// a reconstructed image which don't have imageIds; therefore, no instance and instanceNumber
|
||||
// Here we check if viewport is in the acquisition direction and if so, we get the instanceNumber
|
||||
function _getInstanceNumberFromVolume(
|
||||
viewportData,
|
||||
viewportId,
|
||||
cornerstoneViewportService,
|
||||
imageIndex
|
||||
) {
|
||||
const volumes = viewportData.data;
|
||||
|
||||
if (!volumes) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Todo: support fusion of acquisition plane which has instanceNumber
|
||||
const { volume } = volumes[0];
|
||||
|
||||
if (!volume) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { direction, imageIds } = volume;
|
||||
|
||||
const cornerstoneViewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
|
||||
if (!cornerstoneViewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const camera = cornerstoneViewport.getCamera();
|
||||
const { viewPlaneNormal } = camera;
|
||||
// checking if camera is looking at the acquisition plane (defined by the direction on the volume)
|
||||
|
||||
const scanAxisNormal = direction.slice(6, 9);
|
||||
|
||||
// check if viewPlaneNormal is parallel to scanAxisNormal
|
||||
const cross = vec3.cross(vec3.create(), viewPlaneNormal, scanAxisNormal);
|
||||
const isAcquisitionPlane = vec3.length(cross) < EPSILON;
|
||||
|
||||
if (isAcquisitionPlane) {
|
||||
const imageId = imageIds[imageIndex];
|
||||
|
||||
if (!imageId) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const { instanceNumber } = metaData.get('generalImageModule', imageId) || {};
|
||||
return parseInt(instanceNumber);
|
||||
}
|
||||
}
|
||||
|
||||
function OverlayItem(props) {
|
||||
const { instance, customization = {} } = props;
|
||||
const { color, attribute, title, label, background } = customization;
|
||||
const value = customization.contentF?.(props, customization) ?? instance?.[attribute];
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="overlay-item flex flex-row"
|
||||
style={{ color, background }}
|
||||
title={title}
|
||||
>
|
||||
{label ? <span className="mr-1 shrink-0">{label}</span> : null}
|
||||
<span className="ml-1 mr-2 shrink-0">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Window Level / Center Overlay item
|
||||
*/
|
||||
function VOIOverlayItem({ voi, customization }: OverlayItemProps) {
|
||||
const { windowWidth, windowCenter } = voi;
|
||||
if (typeof windowCenter !== 'number' || typeof windowWidth !== 'number') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="overlay-item flex flex-row"
|
||||
style={{ color: customization?.color }}
|
||||
>
|
||||
<span className="mr-1 shrink-0">W:</span>
|
||||
<span className="ml-1 mr-2 shrink-0">{windowWidth.toFixed(0)}</span>
|
||||
<span className="mr-1 shrink-0">L:</span>
|
||||
<span className="ml-1 shrink-0">{windowCenter.toFixed(0)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zoom Level Overlay item
|
||||
*/
|
||||
function ZoomOverlayItem({ scale, customization }: OverlayItemProps) {
|
||||
return (
|
||||
<div
|
||||
className="overlay-item flex flex-row"
|
||||
style={{ color: (customization && customization.color) || undefined }}
|
||||
>
|
||||
<span className="mr-1 shrink-0">Zoom:</span>
|
||||
<span>{scale.toFixed(2)}x</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Instance Number Overlay Item
|
||||
*/
|
||||
function InstanceNumberOverlayItem({
|
||||
instanceNumber,
|
||||
imageSliceData,
|
||||
customization,
|
||||
}: OverlayItemProps) {
|
||||
const { imageIndex, numberOfSlices } = imageSliceData;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="overlay-item flex flex-row"
|
||||
style={{ color: (customization && customization.color) || undefined }}
|
||||
>
|
||||
<span>
|
||||
{instanceNumber !== undefined && instanceNumber !== null ? (
|
||||
<>
|
||||
<span className="mr-1 shrink-0">I:</span>
|
||||
<span>{`${instanceNumber} (${imageIndex + 1}/${numberOfSlices})`}</span>
|
||||
</>
|
||||
) : (
|
||||
`${imageIndex + 1}/${numberOfSlices}`
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CustomizableViewportOverlay.propTypes = {
|
||||
viewportData: PropTypes.object,
|
||||
imageIndex: PropTypes.number,
|
||||
viewportId: PropTypes.string,
|
||||
};
|
||||
|
||||
export default CustomizableViewportOverlay;
|
||||
|
||||
export { CustomizableViewportOverlay, CornerstoneOverlay };
|
||||
@@ -0,0 +1,107 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Enums, VolumeViewport3D, utilities as csUtils } from '@cornerstonejs/core';
|
||||
import { ImageScrollbar } from '@ohif/ui';
|
||||
|
||||
function CornerstoneImageScrollbar({
|
||||
viewportData,
|
||||
viewportId,
|
||||
element,
|
||||
imageSliceData,
|
||||
setImageSliceData,
|
||||
scrollbarHeight,
|
||||
servicesManager,
|
||||
}: withAppTypes<{
|
||||
element: HTMLElement;
|
||||
}>) {
|
||||
const { cineService, cornerstoneViewportService } = servicesManager.services;
|
||||
|
||||
const onImageScrollbarChange = (imageIndex, viewportId) => {
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
|
||||
const { isCineEnabled } = cineService.getState();
|
||||
|
||||
if (isCineEnabled) {
|
||||
// on image scrollbar change, stop the CINE if it is playing
|
||||
cineService.stopClip(element, { viewportId });
|
||||
cineService.setCine({ id: viewportId, isPlaying: false });
|
||||
}
|
||||
|
||||
csUtils.jumpToSlice(viewport.element, {
|
||||
imageIndex,
|
||||
debounceLoading: true,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewportData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
|
||||
if (!viewport || viewport instanceof VolumeViewport3D) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageIndex = viewport.getCurrentImageIdIndex();
|
||||
const numberOfSlices = viewport.getNumberOfSlices();
|
||||
|
||||
setImageSliceData({
|
||||
imageIndex: imageIndex,
|
||||
numberOfSlices,
|
||||
});
|
||||
}, [viewportId, viewportData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!viewportData) {
|
||||
return;
|
||||
}
|
||||
const { viewportType } = viewportData;
|
||||
const eventId =
|
||||
(viewportType === Enums.ViewportType.STACK && Enums.Events.STACK_VIEWPORT_SCROLL) ||
|
||||
(viewportType === Enums.ViewportType.ORTHOGRAPHIC && Enums.Events.VOLUME_NEW_IMAGE) ||
|
||||
Enums.Events.IMAGE_RENDERED;
|
||||
|
||||
const updateIndex = event => {
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
if (!viewport || viewport instanceof VolumeViewport3D) {
|
||||
return;
|
||||
}
|
||||
const { imageIndex, newImageIdIndex = imageIndex } = event.detail;
|
||||
const numberOfSlices = viewport.getNumberOfSlices();
|
||||
// find the index of imageId in the imageIds
|
||||
setImageSliceData({
|
||||
imageIndex: newImageIdIndex,
|
||||
numberOfSlices,
|
||||
});
|
||||
};
|
||||
|
||||
element.addEventListener(eventId, updateIndex);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener(eventId, updateIndex);
|
||||
};
|
||||
}, [viewportData, element]);
|
||||
|
||||
return (
|
||||
<ImageScrollbar
|
||||
onChange={evt => onImageScrollbarChange(evt, viewportId)}
|
||||
max={imageSliceData.numberOfSlices ? imageSliceData.numberOfSlices - 1 : 0}
|
||||
height={scrollbarHeight}
|
||||
value={imageSliceData.imageIndex || 0}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
CornerstoneImageScrollbar.propTypes = {
|
||||
viewportData: PropTypes.object,
|
||||
viewportId: PropTypes.string.isRequired,
|
||||
element: PropTypes.instanceOf(Element),
|
||||
scrollbarHeight: PropTypes.string,
|
||||
imageSliceData: PropTypes.object.isRequired,
|
||||
setImageSliceData: PropTypes.func.isRequired,
|
||||
servicesManager: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default CornerstoneImageScrollbar;
|
||||
@@ -0,0 +1,85 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Enums } from '@cornerstonejs/core';
|
||||
|
||||
function ViewportImageSliceLoadingIndicator({ viewportData, element }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const loadIndicatorRef = useRef(null);
|
||||
const imageIdToBeLoaded = useRef(null);
|
||||
|
||||
const setLoadingState = evt => {
|
||||
clearTimeout(loadIndicatorRef.current);
|
||||
|
||||
loadIndicatorRef.current = setTimeout(() => {
|
||||
setLoading(true);
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const setFinishLoadingState = evt => {
|
||||
clearTimeout(loadIndicatorRef.current);
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const setErrorState = evt => {
|
||||
clearTimeout(loadIndicatorRef.current);
|
||||
|
||||
if (imageIdToBeLoaded.current === evt.detail.imageId) {
|
||||
setError(evt.detail.error);
|
||||
imageIdToBeLoaded.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
element.addEventListener(Enums.Events.STACK_VIEWPORT_SCROLL, setLoadingState);
|
||||
element.addEventListener(Enums.Events.IMAGE_LOAD_ERROR, setErrorState);
|
||||
element.addEventListener(Enums.Events.STACK_NEW_IMAGE, setFinishLoadingState);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener(Enums.Events.STACK_VIEWPORT_SCROLL, setLoadingState);
|
||||
|
||||
element.removeEventListener(Enums.Events.STACK_NEW_IMAGE, setFinishLoadingState);
|
||||
|
||||
element.removeEventListener(Enums.Events.IMAGE_LOAD_ERROR, setErrorState);
|
||||
};
|
||||
}, [element, viewportData]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<div className="absolute top-0 left-0 h-full w-full bg-black opacity-50">
|
||||
<div className="transparent flex h-full w-full items-center justify-center">
|
||||
<p className="text-primary-light text-xl font-light">
|
||||
<h4>Error Loading Image</h4>
|
||||
<p>An error has occurred.</p>
|
||||
<p>{error}</p>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
// IMPORTANT: we need to use the pointer-events-none class to prevent the loading indicator from
|
||||
// interacting with the mouse, since scrolling should propagate to the viewport underneath
|
||||
<div className="pointer-events-none absolute top-0 left-0 h-full w-full bg-black opacity-50">
|
||||
<div className="transparent flex h-full w-full items-center justify-center">
|
||||
<p className="text-primary-light text-xl font-light">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
ViewportImageSliceLoadingIndicator.propTypes = {
|
||||
error: PropTypes.object,
|
||||
element: PropTypes.object,
|
||||
};
|
||||
|
||||
export default ViewportImageSliceLoadingIndicator;
|
||||
@@ -0,0 +1,38 @@
|
||||
.ViewportOrientationMarkers {
|
||||
--marker-width: 100px;
|
||||
--marker-height: 100px;
|
||||
--scrollbar-width: 20px;
|
||||
pointer-events: none;
|
||||
font-size: 15px;
|
||||
line-height: 18px;
|
||||
}
|
||||
.ViewportOrientationMarkers .orientation-marker {
|
||||
position: absolute;
|
||||
}
|
||||
.ViewportOrientationMarkers .top-mid {
|
||||
top: 0.6rem;
|
||||
left: 50%;
|
||||
}
|
||||
.ViewportOrientationMarkers .left-mid {
|
||||
top: 47%;
|
||||
left: 5px;
|
||||
}
|
||||
.ViewportOrientationMarkers .right-mid {
|
||||
top: 47%;
|
||||
left: calc(100% - var(--marker-width) - var(--scrollbar-width));
|
||||
}
|
||||
.ViewportOrientationMarkers .bottom-mid {
|
||||
top: calc(100% - var(--marker-height) - 0.6rem);
|
||||
left: 47%;
|
||||
}
|
||||
.ViewportOrientationMarkers .right-mid .orientation-marker-value {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
min-width: var(--marker-width);
|
||||
}
|
||||
.ViewportOrientationMarkers .bottom-mid .orientation-marker-value {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
min-height: var(--marker-height);
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { metaData, Enums, Types, getEnabledElement } from '@cornerstonejs/core';
|
||||
import { utilities } from '@cornerstonejs/tools';
|
||||
import { vec3 } from 'gl-matrix';
|
||||
|
||||
import './ViewportOrientationMarkers.css';
|
||||
|
||||
const { getOrientationStringLPS, invertOrientationStringLPS } = utilities.orientation;
|
||||
|
||||
function ViewportOrientationMarkers({
|
||||
element,
|
||||
viewportData,
|
||||
imageSliceData,
|
||||
viewportId,
|
||||
servicesManager,
|
||||
orientationMarkers = ['top', 'left'],
|
||||
}: withAppTypes) {
|
||||
// Rotation is in degrees
|
||||
const [rotation, setRotation] = useState(0);
|
||||
const [flipHorizontal, setFlipHorizontal] = useState(false);
|
||||
const [flipVertical, setFlipVertical] = useState(false);
|
||||
const { cornerstoneViewportService } = servicesManager.services;
|
||||
|
||||
useEffect(() => {
|
||||
const cameraModifiedListener = (evt: Types.EventTypes.CameraModifiedEvent) => {
|
||||
const { previousCamera, camera } = evt.detail;
|
||||
|
||||
const { rotation } = camera;
|
||||
if (rotation !== undefined) {
|
||||
setRotation(rotation);
|
||||
}
|
||||
|
||||
if (
|
||||
camera.flipHorizontal !== undefined &&
|
||||
previousCamera.flipHorizontal !== camera.flipHorizontal
|
||||
) {
|
||||
setFlipHorizontal(camera.flipHorizontal);
|
||||
}
|
||||
|
||||
if (
|
||||
camera.flipVertical !== undefined &&
|
||||
previousCamera.flipVertical !== camera.flipVertical
|
||||
) {
|
||||
setFlipVertical(camera.flipVertical);
|
||||
}
|
||||
};
|
||||
|
||||
element.addEventListener(Enums.Events.CAMERA_MODIFIED, cameraModifiedListener);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener(Enums.Events.CAMERA_MODIFIED, cameraModifiedListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const markers = useMemo(() => {
|
||||
if (!viewportData) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let rowCosines, columnCosines, isDefaultValueSetForRowCosine, isDefaultValueSetForColumnCosine;
|
||||
if (viewportData.viewportType === 'stack') {
|
||||
const imageIndex = imageSliceData.imageIndex;
|
||||
const imageId = viewportData.data[0].imageIds?.[imageIndex];
|
||||
|
||||
// Workaround for below TODO stub
|
||||
if (!imageId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
({
|
||||
rowCosines,
|
||||
columnCosines,
|
||||
isDefaultValueSetForColumnCosine,
|
||||
isDefaultValueSetForColumnCosine,
|
||||
} = metaData.get('imagePlaneModule', imageId) || {});
|
||||
} else {
|
||||
if (!element || !getEnabledElement(element)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const { viewport } = getEnabledElement(element);
|
||||
const { viewUp, viewPlaneNormal } = viewport.getCamera();
|
||||
|
||||
const viewRight = vec3.create();
|
||||
vec3.cross(viewRight, viewUp, viewPlaneNormal);
|
||||
|
||||
columnCosines = [-viewUp[0], -viewUp[1], -viewUp[2]];
|
||||
rowCosines = viewRight;
|
||||
}
|
||||
|
||||
if (
|
||||
!rowCosines ||
|
||||
!columnCosines ||
|
||||
rotation === undefined ||
|
||||
isDefaultValueSetForRowCosine ||
|
||||
isDefaultValueSetForColumnCosine
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const markers = _getOrientationMarkers(
|
||||
rowCosines,
|
||||
columnCosines,
|
||||
rotation,
|
||||
flipVertical,
|
||||
flipHorizontal
|
||||
);
|
||||
|
||||
const ohifViewport = cornerstoneViewportService.getViewportInfo(viewportId);
|
||||
|
||||
if (!ohifViewport) {
|
||||
console.log('ViewportOrientationMarkers::No viewport');
|
||||
return null;
|
||||
}
|
||||
|
||||
return orientationMarkers.map((m, index) => (
|
||||
<div
|
||||
className={classNames(
|
||||
'overlay-text',
|
||||
`${m}-mid orientation-marker`,
|
||||
'text-aqua-pale',
|
||||
'text-[13px]',
|
||||
'leading-5'
|
||||
)}
|
||||
key={`${m}-mid orientation-marker`}
|
||||
>
|
||||
<div className="orientation-marker-value">{markers[m]}</div>
|
||||
</div>
|
||||
));
|
||||
}, [
|
||||
viewportData,
|
||||
imageSliceData,
|
||||
rotation,
|
||||
flipVertical,
|
||||
flipHorizontal,
|
||||
orientationMarkers,
|
||||
element,
|
||||
]);
|
||||
|
||||
return <div className="ViewportOrientationMarkers select-none">{markers}</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* Computes the orientation labels on a Cornerstone-enabled Viewport element
|
||||
* when the viewport settings change (e.g. when a horizontal flip or a rotation occurs)
|
||||
*
|
||||
* @param {*} rowCosines
|
||||
* @param {*} columnCosines
|
||||
* @param {*} rotation in degrees
|
||||
* @returns
|
||||
*/
|
||||
function _getOrientationMarkers(rowCosines, columnCosines, rotation, flipVertical, flipHorizontal) {
|
||||
const rowString = getOrientationStringLPS(rowCosines);
|
||||
const columnString = getOrientationStringLPS(columnCosines);
|
||||
const oppositeRowString = invertOrientationStringLPS(rowString);
|
||||
const oppositeColumnString = invertOrientationStringLPS(columnString);
|
||||
|
||||
const markers = {
|
||||
top: oppositeColumnString,
|
||||
left: oppositeRowString,
|
||||
right: rowString,
|
||||
bottom: columnString,
|
||||
};
|
||||
|
||||
// If any vertical or horizontal flips are applied, change the orientation strings ahead of
|
||||
// the rotation applications
|
||||
if (flipVertical) {
|
||||
markers.top = invertOrientationStringLPS(markers.top);
|
||||
markers.bottom = invertOrientationStringLPS(markers.bottom);
|
||||
}
|
||||
|
||||
if (flipHorizontal) {
|
||||
markers.left = invertOrientationStringLPS(markers.left);
|
||||
markers.right = invertOrientationStringLPS(markers.right);
|
||||
}
|
||||
|
||||
// Swap the labels accordingly if the viewport has been rotated
|
||||
// This could be done in a more complex way for intermediate rotation values (e.g. 45 degrees)
|
||||
if (rotation === 90 || rotation === -270) {
|
||||
return {
|
||||
top: markers.left,
|
||||
left: invertOrientationStringLPS(markers.top),
|
||||
right: invertOrientationStringLPS(markers.bottom),
|
||||
bottom: markers.right, // left
|
||||
};
|
||||
} else if (rotation === -90 || rotation === 270) {
|
||||
return {
|
||||
top: invertOrientationStringLPS(markers.left),
|
||||
left: markers.top,
|
||||
bottom: markers.left,
|
||||
right: markers.bottom,
|
||||
};
|
||||
} else if (rotation === 180 || rotation === -180) {
|
||||
return {
|
||||
top: invertOrientationStringLPS(markers.top),
|
||||
left: invertOrientationStringLPS(markers.left),
|
||||
bottom: invertOrientationStringLPS(markers.bottom),
|
||||
right: invertOrientationStringLPS(markers.right),
|
||||
};
|
||||
}
|
||||
|
||||
return markers;
|
||||
}
|
||||
|
||||
export default ViewportOrientationMarkers;
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Hook kustom untuk mengambil dan mengelola data studi berdasarkan StudyInstanceUID.
|
||||
*
|
||||
* Hook ini mengambil informasi terkait studi dari endpoint layanan QIDO
|
||||
* dan memprosesnya untuk digunakan dalam overlay atau komponen UI lainnya.
|
||||
* Ini menangani status pemuatan, kesalahan, dan ekstraksi data dari field metadata DICOM.
|
||||
*
|
||||
* @param studyInstanceUID - Identifier unik untuk studi yang datanya akan diambil.
|
||||
* @returns Sebuah objek yang berisi:
|
||||
* - `studyData`: Data studi yang telah diekstrak (atau `null` jika belum dimuat).
|
||||
* - `loading`: Boolean yang menunjukkan apakah data sedang diambil.
|
||||
* - `error`: Objek error jika proses pengambilan data gagal, atau `null` jika berhasil.
|
||||
*
|
||||
* @catatan
|
||||
* Hook ini bergantung pada fungsi `getQidoRootUrl` untuk menentukan endpoint layanan QIDO.
|
||||
* Pastikan fungsi ini dikonfigurasi dengan benar di aplikasi Anda.
|
||||
*
|
||||
* Field DICOM berikut akan diekstrak dan diproses:
|
||||
* - Study Description (0008,1030)
|
||||
* - Accession Number (0008,0050)
|
||||
* - Study Time (0008,0030)
|
||||
* - Patient Age (0010,1010)
|
||||
* - Patient Sex (0010,0040)
|
||||
* - Patient Birth Date (0010,0030)
|
||||
*
|
||||
* Jika tanggal lahir pasien tersedia, umur akan dihitung dan diformat.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface StudyData {
|
||||
studyDescription?: string;
|
||||
accessionNumber?: string;
|
||||
studyTime?: string;
|
||||
patientAge?: string;
|
||||
patientSex?: string;
|
||||
patientBirthDate?: string;
|
||||
formattedAge?: string;
|
||||
}
|
||||
|
||||
const calculateAge = (birthDateString: string): string => {
|
||||
if (!birthDateString) return '';
|
||||
|
||||
// DICOM date format is YYYYMMDD
|
||||
const year = parseInt(birthDateString.substring(0, 4));
|
||||
const month = parseInt(birthDateString.substring(4, 6)) - 1; // JS months are 0-indexed
|
||||
const day = parseInt(birthDateString.substring(6, 8));
|
||||
|
||||
const birthDate = new Date(year, month, day);
|
||||
const today = new Date();
|
||||
|
||||
let ageYears = today.getFullYear() - birthDate.getFullYear();
|
||||
let ageMonths = today.getMonth() - birthDate.getMonth();
|
||||
let ageDays = today.getDate() - birthDate.getDate();
|
||||
|
||||
// Adjust for negative months or days
|
||||
if (ageDays < 0) {
|
||||
ageMonths--;
|
||||
// Get days in the previous month
|
||||
const prevMonthDate = new Date(today.getFullYear(), today.getMonth(), 0);
|
||||
ageDays += prevMonthDate.getDate();
|
||||
}
|
||||
|
||||
if (ageMonths < 0) {
|
||||
ageYears--;
|
||||
ageMonths += 12;
|
||||
}
|
||||
|
||||
return `${ageYears}Y ${ageMonths}M ${ageDays}D`;
|
||||
};
|
||||
|
||||
const getQidoRootUrl = (): string => {
|
||||
const { config } = window as any;
|
||||
|
||||
if (!config || !config.dataSources || !config.defaultDataSourceName) {
|
||||
console.warn('Configuration not found, using fallback URL');
|
||||
return;
|
||||
}
|
||||
|
||||
const dataSource = config.dataSources.find(
|
||||
(ds: any) => ds.sourceName === config.defaultDataSourceName
|
||||
);
|
||||
|
||||
if (!dataSource || !dataSource.configuration || !dataSource.configuration.qidoRoot) {
|
||||
console.warn('Data source configuration not found, return nothing');
|
||||
return;
|
||||
}
|
||||
|
||||
return dataSource.configuration.qidoRoot;
|
||||
};
|
||||
|
||||
export const studyDataForOverlayItem = (studyInstanceUID: string) => {
|
||||
const [studyData, setStudyData] = useState<StudyData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!studyInstanceUID) return;
|
||||
|
||||
const fetchStudyData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const qidoRootUrl = getQidoRootUrl();
|
||||
|
||||
const response = await fetch(
|
||||
`${qidoRootUrl}/studies?limit=101&offset=0&fuzzymatching=false&includefield=00080050,00081030,00101010,0010004&StudyInstanceUID=${studyInstanceUID}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch study data');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data && data.length > 0) {
|
||||
const studyInfo = data[0];
|
||||
|
||||
const birthDate = studyInfo['00100030']?.Value?.[0] || '';
|
||||
const formattedAge = calculateAge(birthDate);
|
||||
|
||||
const extractedData: StudyData = {
|
||||
studyDescription: studyInfo['00081030']?.Value?.[0] || '',
|
||||
accessionNumber: studyInfo['00080050']?.Value?.[0] || '',
|
||||
studyTime: studyInfo['00080030']?.Value?.[0] || '',
|
||||
patientAge: studyInfo['00101010']?.Value?.[0] || '',
|
||||
patientSex: studyInfo['00100040']?.Value?.[0] || '',
|
||||
patientBirthDate: birthDate,
|
||||
formattedAge: formattedAge,
|
||||
};
|
||||
console.log('Extracted Study Data:', extractedData);
|
||||
setStudyData(extractedData);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching study data:', err);
|
||||
setError(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStudyData();
|
||||
}, [studyInstanceUID]);
|
||||
|
||||
return { studyData, loading, error };
|
||||
};
|
||||
98
extensions/cornerstone/src/Viewport/Overlays/utils.ts
Normal file
98
extensions/cornerstone/src/Viewport/Overlays/utils.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import moment from 'moment';
|
||||
import { metaData } from '@cornerstonejs/core';
|
||||
|
||||
/**
|
||||
* Checks if value is valid.
|
||||
*
|
||||
* @param {number} value
|
||||
* @returns {boolean} is valid.
|
||||
*/
|
||||
export function isValidNumber(value) {
|
||||
return typeof value === 'number' && !isNaN(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats number precision.
|
||||
*
|
||||
* @param {number} number
|
||||
* @param {number} precision
|
||||
* @returns {number} formatted number.
|
||||
*/
|
||||
export function formatNumberPrecision(number, precision = 0) {
|
||||
if (number !== null) {
|
||||
return parseFloat(number).toFixed(precision);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats DICOM date.
|
||||
*
|
||||
* @param {string} date
|
||||
* @param {string} strFormat
|
||||
* @returns {string} formatted date.
|
||||
*/
|
||||
export function formatDICOMDate(date, strFormat = 'MMM D, YYYY') {
|
||||
return moment(date, 'YYYYMMDD').format(strFormat);
|
||||
}
|
||||
|
||||
/**
|
||||
* DICOM Time is stored as HHmmss.SSS, where:
|
||||
* HH 24 hour time:
|
||||
* m mm 0..59 Minutes
|
||||
* s ss 0..59 Seconds
|
||||
* S SS SSS 0..999 Fractional seconds
|
||||
*
|
||||
* Goal: '24:12:12'
|
||||
*
|
||||
* @param {*} time
|
||||
* @param {string} strFormat
|
||||
* @returns {string} formatted name.
|
||||
*/
|
||||
export function formatDICOMTime(time, strFormat = 'HH:mm:ss') {
|
||||
return moment(time, 'HH:mm:ss').format(strFormat);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a patient name for display purposes
|
||||
*
|
||||
* @param {string} name
|
||||
* @returns {string} formatted name.
|
||||
*/
|
||||
export function formatPN(name) {
|
||||
if (!name) {
|
||||
return '';
|
||||
}
|
||||
if (typeof name === 'object') {
|
||||
name = name.Alphabetic;
|
||||
if (!name) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const cleaned = name
|
||||
.split('^')
|
||||
.filter(s => !!s)
|
||||
.join(', ')
|
||||
.trim();
|
||||
return cleaned === ',' || cleaned === '' ? '' : cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets compression type
|
||||
*
|
||||
* @param {number} imageId
|
||||
* @returns {string} compression type.
|
||||
*/
|
||||
export function getCompression(imageId) {
|
||||
const generalImageModule = metaData.get('generalImageModule', imageId) || {};
|
||||
const { lossyImageCompression, lossyImageCompressionRatio, lossyImageCompressionMethod } =
|
||||
generalImageModule;
|
||||
|
||||
if (lossyImageCompression === '01' && lossyImageCompressionRatio !== '') {
|
||||
const compressionMethod = lossyImageCompressionMethod || 'Lossy: ';
|
||||
const compressionRatio = formatNumberPrecision(lossyImageCompressionRatio, 2);
|
||||
return compressionMethod + compressionRatio + ' : 1';
|
||||
}
|
||||
|
||||
return 'Lossless / Uncompressed';
|
||||
}
|
||||
Reference in New Issue
Block a user