init
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,25 @@
|
||||
/*
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
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';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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: '',
|
||||
title: 'Study date',
|
||||
condition: ({ referenceInstance }) => referenceInstance?.StudyDate,
|
||||
contentF: ({ referenceInstance, formatters: { formatDate } }) =>
|
||||
formatDate(referenceInstance.StudyDate),
|
||||
};
|
||||
|
||||
const seriesDescriptionItem = {
|
||||
id: 'SeriesDescription',
|
||||
customizationType: 'ohif.overlayItem',
|
||||
label: '',
|
||||
title: 'Series description',
|
||||
condition: ({ referenceInstance }) => {
|
||||
return referenceInstance && referenceInstance.SeriesDescription;
|
||||
},
|
||||
contentF: ({ referenceInstance }) => referenceInstance.SeriesDescription,
|
||||
};
|
||||
|
||||
const topLeftItems = {
|
||||
id: 'cornerstoneOverlayTopLeft',
|
||||
items: [studyDateItem, seriesDescriptionItem],
|
||||
};
|
||||
|
||||
const topRightItems = { id: 'cornerstoneOverlayTopRight', items: [] };
|
||||
|
||||
const bottomLeftItems = {
|
||||
id: 'cornerstoneOverlayBottomLeft',
|
||||
items: [
|
||||
{
|
||||
id: 'WindowLevel',
|
||||
customizationType: 'ohif.overlayItem.windowLevel',
|
||||
},
|
||||
{
|
||||
id: 'ZoomLevel',
|
||||
customizationType: 'ohif.overlayItem.zoomLevel',
|
||||
condition: props => {
|
||||
const activeToolName = props.toolGroupService.getActiveToolForViewport(props.viewportId);
|
||||
return activeToolName === 'Zoom';
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const bottomRightItems = {
|
||||
id: 'cornerstoneOverlayBottomRight',
|
||||
items: [
|
||||
{
|
||||
id: 'InstanceNumber',
|
||||
customizationType: 'ohif.overlayItem.instanceNumber',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 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]);
|
||||
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.map((item, index) => (
|
||||
<div key={`${keyPrefix}_${index}`}>
|
||||
{((!item?.condition || item.condition(props)) && _renderOverlayItem(item, props)) ||
|
||||
null}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
},
|
||||
[_renderOverlayItem]
|
||||
);
|
||||
|
||||
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;
|
||||
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';
|
||||
}
|
||||
1466
extensions/cornerstone/src/commandsModule.ts
Normal file
1466
extensions/cornerstone/src/commandsModule.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
import React, { ReactElement } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useViewportGrid } from '@ohif/ui';
|
||||
import ViewportWindowLevel from '../ViewportWindowLevel/ViewportWindowLevel';
|
||||
|
||||
const ActiveViewportWindowLevel = ({ servicesManager }: withAppTypes): ReactElement => {
|
||||
const [viewportGrid] = useViewportGrid();
|
||||
const { activeViewportId } = viewportGrid;
|
||||
|
||||
return (
|
||||
<>
|
||||
{activeViewportId && (
|
||||
<ViewportWindowLevel
|
||||
servicesManager={servicesManager}
|
||||
viewportId={activeViewportId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ActiveViewportWindowLevel.propTypes = {
|
||||
servicesManager: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default ActiveViewportWindowLevel;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ActiveViewportWindowLevel';
|
||||
248
extensions/cornerstone/src/components/CinePlayer/CinePlayer.tsx
Normal file
248
extensions/cornerstone/src/components/CinePlayer/CinePlayer.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import React, { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { CinePlayer, useCine } from '@ohif/ui';
|
||||
import { Enums, eventTarget, cache } from '@cornerstonejs/core';
|
||||
import { useAppConfig } from '@state';
|
||||
|
||||
function WrappedCinePlayer({
|
||||
enabledVPElement,
|
||||
viewportId,
|
||||
servicesManager,
|
||||
}: withAppTypes<{
|
||||
enabledVPElement: HTMLElement;
|
||||
viewportId: string;
|
||||
}>) {
|
||||
const { customizationService, displaySetService, viewportGridService } = servicesManager.services;
|
||||
const [{ isCineEnabled, cines }, cineService] = useCine();
|
||||
const [newStackFrameRate, setNewStackFrameRate] = useState(24);
|
||||
const [dynamicInfo, setDynamicInfo] = useState(null);
|
||||
const [appConfig] = useAppConfig();
|
||||
const isMountedRef = useRef(null);
|
||||
|
||||
const cineHandler = () => {
|
||||
if (!cines?.[viewportId] || !enabledVPElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { isPlaying = false, frameRate = 24 } = cines[viewportId];
|
||||
const validFrameRate = Math.max(frameRate, 1);
|
||||
|
||||
return isPlaying
|
||||
? cineService.playClip(enabledVPElement, { framesPerSecond: validFrameRate, viewportId })
|
||||
: cineService.stopClip(enabledVPElement);
|
||||
};
|
||||
|
||||
const newDisplaySetHandler = useCallback(() => {
|
||||
if (!enabledVPElement || !isCineEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { viewports } = viewportGridService.getState();
|
||||
const { displaySetInstanceUIDs } = viewports.get(viewportId);
|
||||
let frameRate = 24;
|
||||
let isPlaying = cines[viewportId]?.isPlaying || false;
|
||||
displaySetInstanceUIDs.forEach(displaySetInstanceUID => {
|
||||
const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
|
||||
|
||||
if (displaySet.FrameRate) {
|
||||
// displaySet.FrameRate corresponds to DICOM tag (0018,1063) which is defined as the the frame time in milliseconds
|
||||
// So a bit of math to get the actual frame rate.
|
||||
frameRate = Math.round(1000 / displaySet.FrameRate);
|
||||
isPlaying ||= !!appConfig.autoPlayCine;
|
||||
}
|
||||
|
||||
// check if the displaySet is dynamic and set the dynamic info
|
||||
if (displaySet.isDynamicVolume) {
|
||||
const { dynamicVolumeInfo } = displaySet;
|
||||
const numTimePoints = dynamicVolumeInfo.timePoints.length;
|
||||
const label = dynamicVolumeInfo.splittingTag;
|
||||
const timePointIndex = dynamicVolumeInfo.timePointIndex || 0;
|
||||
setDynamicInfo({
|
||||
volumeId: displaySet.displaySetInstanceUID,
|
||||
timePointIndex,
|
||||
numTimePoints,
|
||||
label,
|
||||
});
|
||||
} else {
|
||||
setDynamicInfo(null);
|
||||
}
|
||||
});
|
||||
|
||||
if (isPlaying) {
|
||||
cineService.setIsCineEnabled(isPlaying);
|
||||
}
|
||||
cineService.setCine({ id: viewportId, isPlaying, frameRate });
|
||||
setNewStackFrameRate(frameRate);
|
||||
}, [displaySetService, viewportId, viewportGridService, cines, isCineEnabled, enabledVPElement]);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
newDisplaySetHandler();
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, [isCineEnabled, newDisplaySetHandler]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCineEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
cineHandler();
|
||||
}, [isCineEnabled, cineHandler, enabledVPElement]);
|
||||
|
||||
/**
|
||||
* Use effect for handling new display set
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!enabledVPElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
enabledVPElement.addEventListener(Enums.Events.VIEWPORT_NEW_IMAGE_SET, newDisplaySetHandler);
|
||||
// this doesn't makes sense that we are listening to this event on viewport element
|
||||
enabledVPElement.addEventListener(
|
||||
Enums.Events.VOLUME_VIEWPORT_NEW_VOLUME,
|
||||
newDisplaySetHandler
|
||||
);
|
||||
|
||||
return () => {
|
||||
cineService.setCine({ id: viewportId, isPlaying: false });
|
||||
|
||||
enabledVPElement.removeEventListener(
|
||||
Enums.Events.VIEWPORT_NEW_IMAGE_SET,
|
||||
newDisplaySetHandler
|
||||
);
|
||||
enabledVPElement.removeEventListener(
|
||||
Enums.Events.VOLUME_VIEWPORT_NEW_VOLUME,
|
||||
newDisplaySetHandler
|
||||
);
|
||||
};
|
||||
}, [enabledVPElement, newDisplaySetHandler, viewportId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cines || !cines[viewportId] || !enabledVPElement || !isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
cineHandler();
|
||||
|
||||
return () => {
|
||||
cineService.stopClip(enabledVPElement, { viewportId });
|
||||
};
|
||||
}, [cines, viewportId, cineService, enabledVPElement, cineHandler]);
|
||||
|
||||
if (!isCineEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cine = cines[viewportId];
|
||||
const isPlaying = cine?.isPlaying || false;
|
||||
|
||||
return (
|
||||
<RenderCinePlayer
|
||||
viewportId={viewportId}
|
||||
cineService={cineService}
|
||||
newStackFrameRate={newStackFrameRate}
|
||||
isPlaying={isPlaying}
|
||||
dynamicInfo={dynamicInfo}
|
||||
customizationService={customizationService}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RenderCinePlayer({
|
||||
viewportId,
|
||||
cineService,
|
||||
newStackFrameRate,
|
||||
isPlaying,
|
||||
dynamicInfo: dynamicInfoProp,
|
||||
customizationService,
|
||||
}) {
|
||||
const { component: CinePlayerComponent = CinePlayer } =
|
||||
customizationService.get('cinePlayer') ?? {};
|
||||
|
||||
const [dynamicInfo, setDynamicInfo] = useState(dynamicInfoProp);
|
||||
|
||||
useEffect(() => {
|
||||
setDynamicInfo(dynamicInfoProp);
|
||||
}, [dynamicInfoProp]);
|
||||
|
||||
/**
|
||||
* Use effect for handling 4D time index changed
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!dynamicInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleTimePointIndexChange = evt => {
|
||||
const { volumeId, timePointIndex, numTimePoints, splittingTag } = evt.detail;
|
||||
setDynamicInfo({ volumeId, timePointIndex, numTimePoints, label: splittingTag });
|
||||
};
|
||||
|
||||
eventTarget.addEventListener(
|
||||
Enums.Events.DYNAMIC_VOLUME_TIME_POINT_INDEX_CHANGED,
|
||||
handleTimePointIndexChange
|
||||
);
|
||||
|
||||
return () => {
|
||||
eventTarget.removeEventListener(
|
||||
Enums.Events.DYNAMIC_VOLUME_TIME_POINT_INDEX_CHANGED,
|
||||
handleTimePointIndexChange
|
||||
);
|
||||
};
|
||||
}, [dynamicInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dynamicInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { volumeId, timePointIndex, numTimePoints, splittingTag } = dynamicInfo || {};
|
||||
const volume = cache.getVolume(volumeId, true);
|
||||
volume.timePointIndex = timePointIndex;
|
||||
|
||||
setDynamicInfo({ volumeId, timePointIndex, numTimePoints, label: splittingTag });
|
||||
}, []);
|
||||
|
||||
const updateDynamicInfo = useCallback(props => {
|
||||
const { volumeId, timePointIndex } = props;
|
||||
const volume = cache.getVolume(volumeId, true);
|
||||
volume.timePointIndex = timePointIndex;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CinePlayerComponent
|
||||
className="absolute left-1/2 bottom-3 -translate-x-1/2"
|
||||
frameRate={newStackFrameRate}
|
||||
isPlaying={isPlaying}
|
||||
onClose={() => {
|
||||
// also stop the clip
|
||||
cineService.setCine({
|
||||
id: viewportId,
|
||||
isPlaying: false,
|
||||
});
|
||||
cineService.setIsCineEnabled(false);
|
||||
cineService.setViewportCineClosed(viewportId);
|
||||
}}
|
||||
onPlayPauseChange={isPlaying => {
|
||||
cineService.setCine({
|
||||
id: viewportId,
|
||||
isPlaying,
|
||||
});
|
||||
}}
|
||||
onFrameRateChange={frameRate =>
|
||||
cineService.setCine({
|
||||
id: viewportId,
|
||||
frameRate,
|
||||
})
|
||||
}
|
||||
dynamicInfo={dynamicInfo}
|
||||
updateDynamicInfo={updateDynamicInfo}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default WrappedCinePlayer;
|
||||
@@ -0,0 +1,3 @@
|
||||
import CinePlayer from './CinePlayer';
|
||||
|
||||
export default CinePlayer;
|
||||
@@ -0,0 +1,23 @@
|
||||
.dicom-upload-drop-area-border-dash {
|
||||
background-image: repeating-linear-gradient(
|
||||
to right,
|
||||
#7bb2ce 0%,
|
||||
#7bb2ce 50%,
|
||||
transparent 50%,
|
||||
transparent 100%
|
||||
),
|
||||
repeating-linear-gradient(to right, #7bb2ce 0%, #7bb2ce 50%, transparent 50%, transparent 100%),
|
||||
repeating-linear-gradient(to bottom, #7bb2ce 0%, #7bb2ce 50%, transparent 50%, transparent 100%),
|
||||
repeating-linear-gradient(to bottom, #7bb2ce 0%, #7bb2ce 50%, transparent 50%, transparent 100%);
|
||||
background-position:
|
||||
left top,
|
||||
left bottom,
|
||||
left top,
|
||||
right top;
|
||||
background-repeat: repeat-x, repeat-x, repeat-y, repeat-y;
|
||||
background-size:
|
||||
20px 3px,
|
||||
20px 3px,
|
||||
3px 20px,
|
||||
3px 20px;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { ReactElement } from 'react';
|
||||
import Dropzone from 'react-dropzone';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import DicomFileUploader from '../../utils/DicomFileUploader';
|
||||
import DicomUploadProgress from './DicomUploadProgress';
|
||||
import { Button, ButtonEnums } from '@ohif/ui';
|
||||
import './DicomUpload.css';
|
||||
|
||||
type DicomUploadProps = {
|
||||
dataSource;
|
||||
onComplete: () => void;
|
||||
onStarted: () => void;
|
||||
};
|
||||
|
||||
function DicomUpload({ dataSource, onComplete, onStarted }: DicomUploadProps): ReactElement {
|
||||
const baseClassNames = 'min-h-[480px] flex flex-col bg-black select-none';
|
||||
const [dicomFileUploaderArr, setDicomFileUploaderArr] = useState([]);
|
||||
|
||||
const onDrop = useCallback(async acceptedFiles => {
|
||||
onStarted();
|
||||
setDicomFileUploaderArr(acceptedFiles.map(file => new DicomFileUploader(file, dataSource)));
|
||||
}, []);
|
||||
|
||||
const getDropZoneComponent = (): ReactElement => {
|
||||
return (
|
||||
<Dropzone
|
||||
onDrop={acceptedFiles => {
|
||||
onDrop(acceptedFiles);
|
||||
}}
|
||||
noClick
|
||||
>
|
||||
{({ getRootProps }) => (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className="dicom-upload-drop-area-border-dash m-5 flex h-full flex-col items-center justify-center"
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<Dropzone
|
||||
onDrop={onDrop}
|
||||
noDrag
|
||||
>
|
||||
{({ getRootProps, getInputProps }) => (
|
||||
<div {...getRootProps()}>
|
||||
<Button
|
||||
disabled={false}
|
||||
onClick={() => {}}
|
||||
>
|
||||
{'Add files'}
|
||||
<input {...getInputProps()} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Dropzone>
|
||||
<Dropzone
|
||||
onDrop={onDrop}
|
||||
noDrag
|
||||
>
|
||||
{({ getRootProps, getInputProps }) => (
|
||||
<div {...getRootProps()}>
|
||||
<Button
|
||||
type={ButtonEnums.type.secondary}
|
||||
disabled={false}
|
||||
onClick={() => {}}
|
||||
>
|
||||
{'Add folder'}
|
||||
<input
|
||||
{...getInputProps()}
|
||||
webkitdirectory="true"
|
||||
mozdirectory="true"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Dropzone>
|
||||
</div>
|
||||
<div className="pt-5">or drag images or folders here</div>
|
||||
<div className="text-aqua-pale pt-3 text-lg">(DICOM files supported)</div>
|
||||
</div>
|
||||
)}
|
||||
</Dropzone>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{dicomFileUploaderArr.length ? (
|
||||
<div className={classNames('h-[calc(100vh-300px)]', baseClassNames)}>
|
||||
<DicomUploadProgress
|
||||
dicomFileUploaderArr={Array.from(dicomFileUploaderArr)}
|
||||
onComplete={onComplete}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={classNames('h-[480px]', baseClassNames)}>{getDropZoneComponent()}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
DicomUpload.propTypes = {
|
||||
dataSource: PropTypes.object.isRequired,
|
||||
onComplete: PropTypes.func.isRequired,
|
||||
onStarted: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default DicomUpload;
|
||||
@@ -0,0 +1,388 @@
|
||||
import React, { useCallback, useEffect, useRef, useState, ReactElement } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button, Icon, ProgressLoadingBar } from '@ohif/ui';
|
||||
import DicomFileUploader, {
|
||||
EVENTS,
|
||||
UploadStatus,
|
||||
DicomFileUploaderProgressEvent,
|
||||
UploadRejection,
|
||||
} from '../../utils/DicomFileUploader';
|
||||
import DicomUploadProgressItem from './DicomUploadProgressItem';
|
||||
import classNames from 'classnames';
|
||||
|
||||
type DicomUploadProgressProps = {
|
||||
dicomFileUploaderArr: DicomFileUploader[];
|
||||
onComplete: () => void;
|
||||
};
|
||||
|
||||
const ONE_SECOND = 1000;
|
||||
const ONE_MINUTE = ONE_SECOND * 60;
|
||||
const ONE_HOUR = ONE_MINUTE * 60;
|
||||
|
||||
// The base/initial interval time length used to calculate the
|
||||
// rate of the upload and in turn estimate the
|
||||
// the amount of time remaining for the upload. This is the length
|
||||
// of the very first interval to get a reasonable estimate on screen in
|
||||
// a reasonable amount of time. The length of each interval after the first
|
||||
// is based on the upload rate calculated. Faster rates use this base interval
|
||||
// length. Slower rates below UPLOAD_RATE_THRESHOLD get longer interval times
|
||||
// to obtain more accurate upload rates.
|
||||
const BASE_INTERVAL_TIME = 15000;
|
||||
|
||||
// The upload rate threshold to determine the length of the interval to
|
||||
// calculate the upload rate.
|
||||
const UPLOAD_RATE_THRESHOLD = 75;
|
||||
|
||||
const NO_WRAP_ELLIPSIS_CLASS_NAMES = 'text-ellipsis whitespace-nowrap overflow-hidden';
|
||||
|
||||
function DicomUploadProgress({
|
||||
dicomFileUploaderArr,
|
||||
onComplete,
|
||||
}: DicomUploadProgressProps): ReactElement {
|
||||
const [totalUploadSize] = useState(
|
||||
dicomFileUploaderArr.reduce((acc, fileUploader) => acc + fileUploader.getFileSize(), 0)
|
||||
);
|
||||
|
||||
const currentUploadSizeRef = useRef<number>(0);
|
||||
|
||||
const uploadRateRef = useRef(0);
|
||||
|
||||
const [timeRemaining, setTimeRemaining] = useState<number>(null);
|
||||
|
||||
const [percentComplete, setPercentComplete] = useState(0);
|
||||
|
||||
const [numFilesCompleted, setNumFilesCompleted] = useState(0);
|
||||
|
||||
const [numFails, setNumFails] = useState(0);
|
||||
|
||||
const [showFailedOnly, setShowFailedOnly] = useState(false);
|
||||
|
||||
const progressBarContainerRef = useRef<HTMLElement>();
|
||||
|
||||
/**
|
||||
* The effect for measuring and setting the current upload rate. This is
|
||||
* done by measuring the amount of data uploaded in a set interval time.
|
||||
*/
|
||||
useEffect(() => {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
|
||||
// The amount of data already uploaded at the start of the interval.
|
||||
let intervalStartUploadSize = 0;
|
||||
|
||||
// The starting time of the interval.
|
||||
let intervalStartTime = Date.now();
|
||||
|
||||
const setUploadRateRef = () => {
|
||||
const uploadSizeFromStartOfInterval = currentUploadSizeRef.current - intervalStartUploadSize;
|
||||
|
||||
const now = Date.now();
|
||||
const timeSinceStartOfInterval = now - intervalStartTime;
|
||||
|
||||
// Calculate and set the upload rate (ref)
|
||||
uploadRateRef.current = uploadSizeFromStartOfInterval / timeSinceStartOfInterval;
|
||||
|
||||
// Reset the interval starting values.
|
||||
intervalStartUploadSize = currentUploadSizeRef.current;
|
||||
intervalStartTime = now;
|
||||
|
||||
// Only start a new interval if there is more to upload.
|
||||
if (totalUploadSize - currentUploadSizeRef.current > 0) {
|
||||
if (uploadRateRef.current >= UPLOAD_RATE_THRESHOLD) {
|
||||
timeoutId = setTimeout(setUploadRateRef, BASE_INTERVAL_TIME);
|
||||
} else {
|
||||
// The current upload rate is relatively slow, so use a larger
|
||||
// time interval to get a better upload rate estimate.
|
||||
timeoutId = setTimeout(setUploadRateRef, BASE_INTERVAL_TIME * 2);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// The very first interval is just the base time interval length.
|
||||
timeoutId = setTimeout(setUploadRateRef, BASE_INTERVAL_TIME);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* The effect for: updating the overall percentage complete; setting the
|
||||
* estimated time remaining; updating the number of files uploaded; and
|
||||
* detecting if any error has occurred.
|
||||
*/
|
||||
useEffect(() => {
|
||||
let currentTimeRemaining = null;
|
||||
|
||||
// For each uploader, listen for the progress percentage complete and
|
||||
// add promise catch/finally callbacks to detect errors and count number
|
||||
// of uploads complete.
|
||||
const subscriptions = dicomFileUploaderArr.map(fileUploader => {
|
||||
let currentFileUploadSize = 0;
|
||||
|
||||
const updateProgress = (percentComplete: number) => {
|
||||
const previousFileUploadSize = currentFileUploadSize;
|
||||
|
||||
currentFileUploadSize = Math.round((percentComplete / 100) * fileUploader.getFileSize());
|
||||
|
||||
currentUploadSizeRef.current = Math.min(
|
||||
totalUploadSize,
|
||||
currentUploadSizeRef.current - previousFileUploadSize + currentFileUploadSize
|
||||
);
|
||||
|
||||
setPercentComplete((currentUploadSizeRef.current / totalUploadSize) * 100);
|
||||
|
||||
if (uploadRateRef.current !== 0) {
|
||||
const uploadSizeRemaining = totalUploadSize - currentUploadSizeRef.current;
|
||||
|
||||
const timeRemaining = Math.round(uploadSizeRemaining / uploadRateRef.current);
|
||||
|
||||
if (currentTimeRemaining === null) {
|
||||
currentTimeRemaining = timeRemaining;
|
||||
setTimeRemaining(currentTimeRemaining);
|
||||
return;
|
||||
}
|
||||
|
||||
// Do not show an increase in the time remaining by two seconds or minutes
|
||||
// so as to prevent jumping the time remaining up and down constantly
|
||||
// due to rounding, inaccuracies in the estimate and slight variations
|
||||
// in upload rates over time.
|
||||
if (timeRemaining < ONE_MINUTE) {
|
||||
const currentSecondsRemaining = Math.ceil(currentTimeRemaining / ONE_SECOND);
|
||||
const secondsRemaining = Math.ceil(timeRemaining / ONE_SECOND);
|
||||
const delta = secondsRemaining - currentSecondsRemaining;
|
||||
if (delta < 0 || delta > 2) {
|
||||
currentTimeRemaining = timeRemaining;
|
||||
setTimeRemaining(currentTimeRemaining);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (timeRemaining < ONE_HOUR) {
|
||||
const currentMinutesRemaining = Math.ceil(currentTimeRemaining / ONE_MINUTE);
|
||||
const minutesRemaining = Math.ceil(timeRemaining / ONE_MINUTE);
|
||||
const delta = minutesRemaining - currentMinutesRemaining;
|
||||
if (delta < 0 || delta > 2) {
|
||||
currentTimeRemaining = timeRemaining;
|
||||
setTimeRemaining(currentTimeRemaining);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Hours remaining...
|
||||
currentTimeRemaining = timeRemaining;
|
||||
setTimeRemaining(currentTimeRemaining);
|
||||
}
|
||||
};
|
||||
|
||||
const progressCallback = (progressEvent: DicomFileUploaderProgressEvent) => {
|
||||
updateProgress(progressEvent.percentComplete);
|
||||
};
|
||||
|
||||
// Use the uploader promise to flag any error and count the number of
|
||||
// uploads completed.
|
||||
fileUploader
|
||||
.load()
|
||||
.catch((rejection: UploadRejection) => {
|
||||
if (rejection.status === UploadStatus.Failed) {
|
||||
setNumFails(numFails => numFails + 1);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
// If any error occurred, the percent complete progress stops firing
|
||||
// but this call to updateProgress nicely puts all finished uploads at 100%.
|
||||
updateProgress(100);
|
||||
setNumFilesCompleted(numCompleted => numCompleted + 1);
|
||||
});
|
||||
|
||||
return fileUploader.subscribe(EVENTS.PROGRESS, progressCallback);
|
||||
});
|
||||
return () => {
|
||||
subscriptions.forEach(subscription => subscription.unsubscribe());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cancelAllUploads = useCallback(async () => {
|
||||
for (const dicomFileUploader of dicomFileUploaderArr) {
|
||||
// Important: we need a non-blocking way to cancel every upload,
|
||||
// otherwise the UI will freeze and the user will not be able
|
||||
// to interact with the app and progress will not be updated.
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
dicomFileUploader.cancel();
|
||||
resolve();
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getFormattedTimeRemaining = useCallback((): string => {
|
||||
if (timeRemaining == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (timeRemaining < ONE_MINUTE) {
|
||||
const secondsRemaining = Math.ceil(timeRemaining / ONE_SECOND);
|
||||
return `${secondsRemaining} ${secondsRemaining === 1 ? 'second' : 'seconds'}`;
|
||||
}
|
||||
|
||||
if (timeRemaining < ONE_HOUR) {
|
||||
const minutesRemaining = Math.ceil(timeRemaining / ONE_MINUTE);
|
||||
return `${minutesRemaining} ${minutesRemaining === 1 ? 'minute' : 'minutes'}`;
|
||||
}
|
||||
|
||||
const hoursRemaining = Math.ceil(timeRemaining / ONE_HOUR);
|
||||
return `${hoursRemaining} ${hoursRemaining === 1 ? 'hour' : 'hours'}`;
|
||||
}, [timeRemaining]);
|
||||
|
||||
const getPercentCompleteRounded = useCallback(
|
||||
() => Math.min(100, Math.round(percentComplete)),
|
||||
[percentComplete]
|
||||
);
|
||||
|
||||
/**
|
||||
* Determines if the progress bar should show the infinite animation or not.
|
||||
* Show the infinite animation for progress less than 1% AND if less than
|
||||
* one pixel of the progress bar would be displayed.
|
||||
*/
|
||||
const showInfiniteProgressBar = useCallback((): boolean => {
|
||||
return (
|
||||
getPercentCompleteRounded() < 1 &&
|
||||
(progressBarContainerRef?.current?.offsetWidth ?? 0) * (percentComplete / 100) < 1
|
||||
);
|
||||
}, [getPercentCompleteRounded, percentComplete]);
|
||||
|
||||
/**
|
||||
* Gets the css style for the 'n of m' (files completed) text. The only css attribute
|
||||
* of the style is width such that the 'n of m' is always a fixed width and thus
|
||||
* as each file completes uploading the text on screen does not constantly shift
|
||||
* left and right.
|
||||
*/
|
||||
const getNofMFilesStyle = useCallback(() => {
|
||||
// the number of digits accounts for the digits being on each side of the ' of '
|
||||
const numDigits = 2 * dicomFileUploaderArr.length.toString().length;
|
||||
// the number of digits + 2 spaces and 2 characters for ' of '
|
||||
const numChars = numDigits + 4;
|
||||
return { width: `${numChars}ch` };
|
||||
}, []);
|
||||
|
||||
const getNumCompletedAndTimeRemainingComponent = (): ReactElement => {
|
||||
return (
|
||||
<div className="bg-primary-dark flex h-14 items-center px-1 pb-4 text-lg">
|
||||
{numFilesCompleted === dicomFileUploaderArr.length ? (
|
||||
<>
|
||||
<span className={NO_WRAP_ELLIPSIS_CLASS_NAMES}>{`${dicomFileUploaderArr.length} ${
|
||||
dicomFileUploaderArr.length > 1 ? 'files' : 'file'
|
||||
} completed.`}</span>
|
||||
<Button
|
||||
disabled={false}
|
||||
className="ml-auto"
|
||||
onClick={onComplete}
|
||||
>
|
||||
{'Close'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
style={getNofMFilesStyle()}
|
||||
className={classNames(NO_WRAP_ELLIPSIS_CLASS_NAMES, 'text-end')}
|
||||
>
|
||||
{`${numFilesCompleted} of ${dicomFileUploaderArr.length}`}
|
||||
</span>
|
||||
<span className={NO_WRAP_ELLIPSIS_CLASS_NAMES}>{' files completed.'} </span>
|
||||
<span className={NO_WRAP_ELLIPSIS_CLASS_NAMES}>
|
||||
{timeRemaining ? `Less than ${getFormattedTimeRemaining()} remaining. ` : ''}
|
||||
</span>
|
||||
<span
|
||||
className={classNames(
|
||||
NO_WRAP_ELLIPSIS_CLASS_NAMES,
|
||||
'text-primary-active hover:text-primary-light active:text-aqua-pale ml-auto cursor-pointer'
|
||||
)}
|
||||
onClick={cancelAllUploads}
|
||||
>
|
||||
Cancel All Uploads
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getShowFailedOnlyIconComponent = (): ReactElement => {
|
||||
return (
|
||||
<div className="ml-auto flex w-6 justify-center">
|
||||
{numFails > 0 && (
|
||||
<div onClick={() => setShowFailedOnly(currentShowFailedOnly => !currentShowFailedOnly)}>
|
||||
<Icon
|
||||
className="cursor-pointer"
|
||||
name="icon-status-alert"
|
||||
></Icon>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getPercentCompleteComponent = (): ReactElement => {
|
||||
return (
|
||||
<div className="ohif-scrollbar border-secondary-light overflow-y-scroll border-b px-2">
|
||||
<div className="min-h-14 flex w-full items-center p-2.5">
|
||||
{numFilesCompleted === dicomFileUploaderArr.length ? (
|
||||
<>
|
||||
<div className="text-primary-light text-xl">
|
||||
{numFails > 0
|
||||
? `Completed with ${numFails} ${numFails > 1 ? 'errors' : 'error'}!`
|
||||
: 'Completed!'}
|
||||
</div>
|
||||
{getShowFailedOnlyIconComponent()}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
ref={progressBarContainerRef}
|
||||
className="flex-grow"
|
||||
>
|
||||
<ProgressLoadingBar
|
||||
progress={showInfiniteProgressBar() ? undefined : Math.min(100, percentComplete)}
|
||||
></ProgressLoadingBar>
|
||||
</div>
|
||||
<div className="ml-1 flex w-24 items-center">
|
||||
<div className="w-10 text-right">{`${getPercentCompleteRounded()}%`}</div>
|
||||
{getShowFailedOnlyIconComponent()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
{getNumCompletedAndTimeRemainingComponent()}
|
||||
<div className="flex grow flex-col overflow-hidden bg-black text-lg">
|
||||
{getPercentCompleteComponent()}
|
||||
<div className="ohif-scrollbar h-1 grow overflow-y-scroll px-2">
|
||||
{dicomFileUploaderArr
|
||||
.filter(
|
||||
dicomFileUploader =>
|
||||
!showFailedOnly || dicomFileUploader.getStatus() === UploadStatus.Failed
|
||||
)
|
||||
.map(dicomFileUploader => (
|
||||
<DicomUploadProgressItem
|
||||
key={dicomFileUploader.getFileId()}
|
||||
dicomFileUploader={dicomFileUploader}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DicomUploadProgress.propTypes = {
|
||||
dicomFileUploaderArr: PropTypes.arrayOf(PropTypes.instanceOf(DicomFileUploader)).isRequired,
|
||||
onComplete: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default DicomUploadProgress;
|
||||
@@ -0,0 +1,109 @@
|
||||
import React, { ReactElement, memo, useCallback, useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import DicomFileUploader, {
|
||||
DicomFileUploaderProgressEvent,
|
||||
EVENTS,
|
||||
UploadRejection,
|
||||
UploadStatus,
|
||||
} from '../../utils/DicomFileUploader';
|
||||
import { Icon } from '@ohif/ui';
|
||||
|
||||
type DicomUploadProgressItemProps = {
|
||||
dicomFileUploader: DicomFileUploader;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
const DicomUploadProgressItem = memo(
|
||||
({ dicomFileUploader }: DicomUploadProgressItemProps): ReactElement => {
|
||||
const [percentComplete, setPercentComplete] = useState(dicomFileUploader.getPercentComplete());
|
||||
const [failedReason, setFailedReason] = useState('');
|
||||
const [status, setStatus] = useState(dicomFileUploader.getStatus());
|
||||
|
||||
const isComplete = useCallback(() => {
|
||||
return (
|
||||
status === UploadStatus.Failed ||
|
||||
status === UploadStatus.Cancelled ||
|
||||
status === UploadStatus.Success
|
||||
);
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
const progressSubscription = dicomFileUploader.subscribe(
|
||||
EVENTS.PROGRESS,
|
||||
(dicomFileUploaderProgressEvent: DicomFileUploaderProgressEvent) => {
|
||||
setPercentComplete(dicomFileUploaderProgressEvent.percentComplete);
|
||||
}
|
||||
);
|
||||
|
||||
dicomFileUploader
|
||||
.load()
|
||||
.catch((reason: UploadRejection) => {
|
||||
setStatus(reason.status);
|
||||
setFailedReason(reason.message ?? '');
|
||||
})
|
||||
.finally(() => setStatus(dicomFileUploader.getStatus()));
|
||||
|
||||
return () => progressSubscription.unsubscribe();
|
||||
}, []);
|
||||
|
||||
const cancelUpload = useCallback(() => {
|
||||
dicomFileUploader.cancel();
|
||||
}, []);
|
||||
|
||||
const getStatusIcon = (): ReactElement => {
|
||||
switch (dicomFileUploader.getStatus()) {
|
||||
case UploadStatus.Success:
|
||||
return (
|
||||
<Icon
|
||||
name="status-tracked"
|
||||
className="text-primary-light"
|
||||
></Icon>
|
||||
);
|
||||
case UploadStatus.InProgress:
|
||||
return <Icon name="icon-transferring"></Icon>;
|
||||
case UploadStatus.Failed:
|
||||
return <Icon name="icon-alert-small"></Icon>;
|
||||
case UploadStatus.Cancelled:
|
||||
return <Icon name="icon-alert-outline"></Icon>;
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-14 border-secondary-light flex w-full items-center overflow-hidden border-b p-2.5 text-lg">
|
||||
<div className="self-top flex w-0 shrink grow flex-col gap-1">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex w-6 shrink-0 items-center justify-center">{getStatusIcon()}</div>
|
||||
<div className="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{dicomFileUploader.getFileName()}
|
||||
</div>
|
||||
</div>
|
||||
{failedReason && <div className="pl-10">{failedReason}</div>}
|
||||
</div>
|
||||
<div className="flex w-24 items-center">
|
||||
{!isComplete() && (
|
||||
<>
|
||||
{dicomFileUploader.getStatus() === UploadStatus.InProgress && (
|
||||
<div className="w-10 text-right">{percentComplete}%</div>
|
||||
)}
|
||||
<div className="ml-auto flex cursor-pointer">
|
||||
<Icon
|
||||
className="text-primary-active self-center"
|
||||
name="close"
|
||||
onClick={cancelUpload}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
DicomUploadProgressItem.propTypes = {
|
||||
dicomFileUploader: PropTypes.instanceOf(DicomFileUploader).isRequired,
|
||||
};
|
||||
|
||||
export default DicomUploadProgressItem;
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { useViewportActionCornersContext } from '../contextProviders/ViewportActionCornersProvider';
|
||||
import { ViewportActionCorners } from '@ohif/ui';
|
||||
|
||||
export type OHIFViewportActionCornersProps = {
|
||||
viewportId: string;
|
||||
};
|
||||
|
||||
function OHIFViewportActionCorners({ viewportId }: OHIFViewportActionCornersProps) {
|
||||
const [viewportActionCornersState] = useViewportActionCornersContext();
|
||||
|
||||
if (!viewportActionCornersState[viewportId]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ViewportActionCorners
|
||||
cornerComponents={viewportActionCornersState[viewportId]}
|
||||
></ViewportActionCorners>
|
||||
);
|
||||
}
|
||||
|
||||
export default OHIFViewportActionCorners;
|
||||
@@ -0,0 +1,136 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Icons, Separator } from '@ohif/ui-next';
|
||||
import { SegmentationRepresentations } from '@cornerstonejs/tools/enums';
|
||||
|
||||
function ViewportSegmentationMenu({
|
||||
viewportId,
|
||||
servicesManager,
|
||||
}: withAppTypes<{ viewportId: string }>) {
|
||||
const { segmentationService } = servicesManager.services;
|
||||
const [activeSegmentations, setActiveSegmentations] = useState([]);
|
||||
const [availableSegmentations, setAvailableSegmentations] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateSegmentations = () => {
|
||||
const active = segmentationService.getSegmentationRepresentations(viewportId);
|
||||
setActiveSegmentations(active);
|
||||
|
||||
const all = segmentationService.getSegmentations();
|
||||
const available = all.filter(
|
||||
seg => !active.some(activeSeg => activeSeg.segmentationId === seg.segmentationId)
|
||||
);
|
||||
setAvailableSegmentations(available);
|
||||
};
|
||||
|
||||
updateSegmentations();
|
||||
|
||||
const subscriptions = [
|
||||
segmentationService.EVENTS.SEGMENTATION_MODIFIED,
|
||||
segmentationService.EVENTS.SEGMENTATION_REMOVED,
|
||||
segmentationService.EVENTS.SEGMENTATION_REPRESENTATION_MODIFIED,
|
||||
].map(event => segmentationService.subscribe(event, updateSegmentations));
|
||||
|
||||
return () => {
|
||||
subscriptions.forEach(subscription => subscription.unsubscribe());
|
||||
};
|
||||
}, [segmentationService, viewportId]);
|
||||
|
||||
const toggleSegmentationRepresentationVisibility = (
|
||||
segmentationId,
|
||||
type = SegmentationRepresentations.Labelmap
|
||||
) => {
|
||||
segmentationService.toggleSegmentationRepresentationVisibility(viewportId, {
|
||||
segmentationId,
|
||||
type,
|
||||
});
|
||||
};
|
||||
|
||||
const addSegmentationToViewport = segmentationId => {
|
||||
segmentationService.addSegmentationRepresentation(viewportId, { segmentationId });
|
||||
};
|
||||
|
||||
const removeSegmentationFromViewport = segmentationId => {
|
||||
segmentationService.removeSegmentationRepresentations(viewportId, {
|
||||
segmentationId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-muted flex h-full w-[262px] flex-col rounded p-3">
|
||||
<span className="text-muted-foreground mb-2 text-xs font-semibold">Current Viewport</span>
|
||||
<ul className="space-y-1">
|
||||
{activeSegmentations.map(segmentation => (
|
||||
<li
|
||||
key={segmentation.id}
|
||||
className="flex items-center text-sm"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground mr-2"
|
||||
onClick={() => removeSegmentationFromViewport(segmentation.segmentationId)}
|
||||
>
|
||||
<Icons.Minus className="h-6 w-6" />
|
||||
</Button>
|
||||
<span className="text-foreground flex-grow">{segmentation.label}</span>
|
||||
{segmentation.visible ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground"
|
||||
onClick={() =>
|
||||
toggleSegmentationRepresentationVisibility(
|
||||
segmentation.segmentationId,
|
||||
segmentation.type
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icons.Hide className="h-6 w-6" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground"
|
||||
onClick={() =>
|
||||
toggleSegmentationRepresentationVisibility(
|
||||
segmentation.segmentationId,
|
||||
segmentation.type
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icons.Show className="h-6 w-6" />
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{availableSegmentations.length > 0 && (
|
||||
<>
|
||||
<Separator className="bg-input mb-3" />
|
||||
<span className="text-muted-foreground mb-2 text-xs font-semibold">Available</span>
|
||||
<ul className="space-y-1">
|
||||
{availableSegmentations.map(({segmentationId, label}) => (
|
||||
<li
|
||||
key={segmentationId}
|
||||
className="flex items-center text-sm"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground mr-2"
|
||||
onClick={() => addSegmentationToViewport(segmentationId)}
|
||||
>
|
||||
<Icons.Plus className="h-6 w-6" />
|
||||
</Button>
|
||||
<span className="text-foreground/60">{label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ViewportSegmentationMenu;
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { ReactNode, useEffect, useState } from 'react';
|
||||
import { Button, Icons, Popover, PopoverContent, PopoverTrigger } from '@ohif/ui-next';
|
||||
import ViewportSegmentationMenu from './ViewportSegmentationMenu';
|
||||
import classNames from 'classnames';
|
||||
import { useSegmentations } from '../../hooks/useSegmentations';
|
||||
|
||||
export function ViewportSegmentationMenuWrapper({
|
||||
viewportId,
|
||||
displaySets,
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
location,
|
||||
}: withAppTypes<{
|
||||
viewportId: string;
|
||||
element: HTMLElement;
|
||||
}>): ReactNode {
|
||||
const { viewportActionCornersService, viewportGridService } = servicesManager.services;
|
||||
|
||||
const segmentations = useSegmentations({ servicesManager });
|
||||
|
||||
const activeViewportId = viewportGridService.getActiveViewportId();
|
||||
const isActiveViewport = viewportId === activeViewportId;
|
||||
|
||||
const { align, side } = getAlignAndSide(viewportActionCornersService, location);
|
||||
|
||||
if (!segmentations?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
asChild
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<Icons.ViewportViews
|
||||
className={classNames(
|
||||
'text-highlight',
|
||||
isActiveViewport ? 'visible' : 'invisible group-hover/pane:visible'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="border-none bg-transparent p-0 shadow-none"
|
||||
side={side}
|
||||
align={align}
|
||||
alignOffset={-15}
|
||||
sideOffset={5}
|
||||
>
|
||||
<ViewportSegmentationMenu
|
||||
className="w-full"
|
||||
viewportId={viewportId}
|
||||
displaySets={displaySets}
|
||||
servicesManager={servicesManager}
|
||||
commandsManager={commandsManager}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const getAlignAndSide = (viewportActionCornersService, location) => {
|
||||
const ViewportActionCornersLocations = viewportActionCornersService.LOCATIONS;
|
||||
|
||||
switch (location) {
|
||||
case ViewportActionCornersLocations.topLeft:
|
||||
return { align: 'start', side: 'bottom' };
|
||||
case ViewportActionCornersLocations.topRight:
|
||||
return { align: 'end', side: 'bottom' };
|
||||
case ViewportActionCornersLocations.bottomLeft:
|
||||
return { align: 'start', side: 'top' };
|
||||
case ViewportActionCornersLocations.bottomRight:
|
||||
return { align: 'end', side: 'top' };
|
||||
default:
|
||||
console.debug('Unknown location, defaulting to bottom-start');
|
||||
return { align: 'start', side: 'bottom' };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { ViewportSegmentationMenuWrapper } from './ViewportSegmentationMenuWrapper';
|
||||
|
||||
export function getViewportDataOverlaySettingsMenu(
|
||||
props: withAppTypes<{
|
||||
viewportId: string;
|
||||
element: HTMLElement;
|
||||
}>
|
||||
): ReactNode {
|
||||
return <ViewportSegmentationMenuWrapper {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import React, { useEffect, useCallback, useState, ReactElement, useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import debounce from 'lodash.debounce';
|
||||
import { PanelSection, WindowLevel } from '@ohif/ui';
|
||||
import { Enums, eventTarget } from '@cornerstonejs/core';
|
||||
import { useActiveViewportDisplaySets } from '@ohif/core';
|
||||
import {
|
||||
getNodeOpacity,
|
||||
isPetVolumeWithDefaultOpacity,
|
||||
isVolumeWithConstantOpacity,
|
||||
getWindowLevelsData,
|
||||
} from './utils';
|
||||
|
||||
const { Events } = Enums;
|
||||
|
||||
const ViewportWindowLevel = ({
|
||||
servicesManager,
|
||||
viewportId,
|
||||
}: withAppTypes<{
|
||||
viewportId: string;
|
||||
}>): ReactElement => {
|
||||
const { cornerstoneViewportService } = servicesManager.services;
|
||||
const [windowLevels, setWindowLevels] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const displaySets = useActiveViewportDisplaySets({ servicesManager });
|
||||
|
||||
const getViewportsWithVolumeIds = useCallback(
|
||||
(volumeIds: string[]) => {
|
||||
const renderingEngine = cornerstoneViewportService.getRenderingEngine();
|
||||
const viewports = renderingEngine.getVolumeViewports();
|
||||
|
||||
return viewports.filter(vp => {
|
||||
const viewportVolumeIds = vp.getActors().map(actor => actor.referencedId);
|
||||
return (
|
||||
volumeIds.length === viewportVolumeIds.length &&
|
||||
volumeIds.every(volumeId => viewportVolumeIds.includes(volumeId))
|
||||
);
|
||||
});
|
||||
},
|
||||
[cornerstoneViewportService]
|
||||
);
|
||||
|
||||
const getVolumeOpacity = useCallback((viewport, volumeId) => {
|
||||
const volumeActor = viewport.getActors().find(actor => actor.referencedId === volumeId)?.actor;
|
||||
|
||||
if (isPetVolumeWithDefaultOpacity(volumeId, volumeActor)) {
|
||||
return getNodeOpacity(volumeActor, 1);
|
||||
} else if (isVolumeWithConstantOpacity(volumeActor)) {
|
||||
return getNodeOpacity(volumeActor, 0);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, []);
|
||||
|
||||
const updateViewportHistograms = useCallback(() => {
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId);
|
||||
|
||||
getWindowLevelsData(viewport, viewportInfo, getVolumeOpacity).then(data => {
|
||||
setWindowLevels(data);
|
||||
});
|
||||
}, [viewportId, cornerstoneViewportService, getVolumeOpacity]);
|
||||
|
||||
const handleCornerstoneVOIModified = useCallback(
|
||||
e => {
|
||||
const { detail } = e;
|
||||
const { volumeId, range } = detail;
|
||||
const oldWindowLevel = windowLevels.find(wl => wl.volumeId === volumeId);
|
||||
|
||||
if (!oldWindowLevel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldVOI = oldWindowLevel.voi;
|
||||
const windowWidth = range.upper - range.lower;
|
||||
const windowCenter = range.lower + windowWidth / 2;
|
||||
|
||||
if (windowWidth === oldVOI.windowWidth && windowCenter === oldVOI.windowCenter) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newWindowLevel = {
|
||||
...oldWindowLevel,
|
||||
voi: {
|
||||
windowWidth,
|
||||
windowCenter,
|
||||
},
|
||||
};
|
||||
|
||||
setWindowLevels(
|
||||
windowLevels.map(windowLevel =>
|
||||
windowLevel === oldWindowLevel ? newWindowLevel : windowLevel
|
||||
)
|
||||
);
|
||||
},
|
||||
[windowLevels]
|
||||
);
|
||||
|
||||
const debouncedHandleCornerstoneVOIModified = useMemo(
|
||||
() => debounce(handleCornerstoneVOIModified, 100),
|
||||
[handleCornerstoneVOIModified]
|
||||
);
|
||||
|
||||
const handleVOIChange = useCallback(
|
||||
(volumeId, voi) => {
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
|
||||
const newRange = {
|
||||
lower: voi.windowCenter - voi.windowWidth / 2,
|
||||
upper: voi.windowCenter + voi.windowWidth / 2,
|
||||
};
|
||||
|
||||
viewport.setProperties({ voiRange: newRange }, volumeId);
|
||||
viewport.render();
|
||||
},
|
||||
[cornerstoneViewportService, viewportId]
|
||||
);
|
||||
|
||||
const handleOpacityChange = useCallback(
|
||||
(viewportId, _volumeIndex, volumeId, opacity) => {
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportVolumeIds = viewport.getActors().map(actor => actor.referencedId);
|
||||
const viewports = getViewportsWithVolumeIds(viewportVolumeIds);
|
||||
|
||||
viewports.forEach(vp => {
|
||||
vp.setProperties({ colormap: { opacity } }, volumeId);
|
||||
vp.render();
|
||||
});
|
||||
},
|
||||
[getViewportsWithVolumeIds, cornerstoneViewportService]
|
||||
);
|
||||
|
||||
// New function to handle image volume loading completion
|
||||
const handleImageVolumeLoadingCompleted = useCallback(() => {
|
||||
setIsLoading(false);
|
||||
updateViewportHistograms();
|
||||
}, [updateViewportHistograms]);
|
||||
|
||||
// Listen to cornerstone events and set up interval for histogram updates
|
||||
useEffect(() => {
|
||||
document.addEventListener(Events.VOI_MODIFIED, debouncedHandleCornerstoneVOIModified, true);
|
||||
eventTarget.addEventListener(
|
||||
Events.IMAGE_VOLUME_LOADING_COMPLETED,
|
||||
handleImageVolumeLoadingCompleted
|
||||
);
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
if (isLoading) {
|
||||
updateViewportHistograms();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener(
|
||||
Events.VOI_MODIFIED,
|
||||
debouncedHandleCornerstoneVOIModified,
|
||||
true
|
||||
);
|
||||
eventTarget.removeEventListener(
|
||||
Events.IMAGE_VOLUME_LOADING_COMPLETED,
|
||||
handleImageVolumeLoadingCompleted
|
||||
);
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [
|
||||
updateViewportHistograms,
|
||||
debouncedHandleCornerstoneVOIModified,
|
||||
handleImageVolumeLoadingCompleted,
|
||||
isLoading,
|
||||
]);
|
||||
|
||||
// Create a memoized version of displaySet IDs for comparison
|
||||
const displaySetIds = useMemo(() => {
|
||||
return displaySets?.map(ds => ds.displaySetInstanceUID).sort() || [];
|
||||
}, [displaySets]);
|
||||
|
||||
useEffect(() => {
|
||||
const { unsubscribe } = cornerstoneViewportService.subscribe(
|
||||
cornerstoneViewportService.EVENTS.VIEWPORT_VOLUMES_CHANGED,
|
||||
({ viewportInfo }) => {
|
||||
if (viewportInfo.viewportId === viewportId) {
|
||||
updateViewportHistograms();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Only update if displaySets actually changed and are loaded
|
||||
if (displaySetIds.length && !isLoading) {
|
||||
updateViewportHistograms();
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [viewportId, cornerstoneViewportService, updateViewportHistograms, displaySetIds, isLoading]);
|
||||
|
||||
return (
|
||||
<PanelSection title="Window Level">
|
||||
{windowLevels.map((windowLevel, i) => {
|
||||
if (!windowLevel.histogram) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<WindowLevel
|
||||
key={windowLevel.volumeId}
|
||||
title={`${windowLevel.modality}`}
|
||||
histogram={windowLevel.histogram}
|
||||
voi={windowLevel.voi}
|
||||
step={windowLevel.step}
|
||||
showOpacitySlider={windowLevel.showOpacitySlider}
|
||||
colormap={windowLevel.colormap}
|
||||
onVOIChange={voi => handleVOIChange(windowLevel.volumeId, voi)}
|
||||
opacity={windowLevel.opacity}
|
||||
onOpacityChange={opacity =>
|
||||
handleOpacityChange(windowLevel.viewportId, i, windowLevel.volumeId, opacity)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</PanelSection>
|
||||
);
|
||||
};
|
||||
|
||||
ViewportWindowLevel.propTypes = {
|
||||
servicesManager: PropTypes.object.isRequired,
|
||||
viewportId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default ViewportWindowLevel;
|
||||
@@ -0,0 +1,72 @@
|
||||
import { getWebWorkerManager } from '@cornerstonejs/core';
|
||||
|
||||
const workerManager = getWebWorkerManager();
|
||||
|
||||
const WorkerOptions = {
|
||||
maxWorkerInstances: 1,
|
||||
autoTerminateOnIdle: {
|
||||
enabled: true,
|
||||
idleTimeThreshold: 1000,
|
||||
},
|
||||
};
|
||||
|
||||
// Register the task
|
||||
const workerFn = () => {
|
||||
return new Worker(new URL('./histogramWorker.js', import.meta.url), {
|
||||
name: 'histogram-worker', // name used by the browser to name the worker
|
||||
});
|
||||
};
|
||||
|
||||
const getViewportVolumeHistogram = async (viewport, volume, options?) => {
|
||||
workerManager.registerWorker('histogram-worker', workerFn, WorkerOptions);
|
||||
|
||||
const volumeImageData = viewport.getImageData(volume.volumeId);
|
||||
|
||||
if (!volumeImageData) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let scalarData = volume.scalarData;
|
||||
|
||||
if (volume.numTimePoints > 1) {
|
||||
const targetTimePoint = volume.numTimePoints - 1; // or any other time point you need
|
||||
scalarData = volume.voxelManager.getTimePointScalarData(targetTimePoint);
|
||||
} else {
|
||||
scalarData = volume.voxelManager.getCompleteScalarDataArray();
|
||||
}
|
||||
|
||||
if (!scalarData?.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { dimensions, origin, direction, spacing } = volume;
|
||||
|
||||
const range = await workerManager.executeTask('histogram-worker', 'getRange', {
|
||||
dimensions,
|
||||
origin,
|
||||
direction,
|
||||
spacing,
|
||||
scalarData,
|
||||
});
|
||||
|
||||
const { minimum: min, maximum: max } = range;
|
||||
|
||||
if (min === Infinity || max === -Infinity) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const calcHistOptions = {
|
||||
numBins: 256,
|
||||
min: Math.max(min, options?.min ?? min),
|
||||
max: Math.min(max, options?.max ?? max),
|
||||
};
|
||||
|
||||
const histogram = await workerManager.executeTask('histogram-worker', 'calcHistogram', {
|
||||
data: scalarData,
|
||||
options: calcHistOptions,
|
||||
});
|
||||
|
||||
return histogram;
|
||||
};
|
||||
|
||||
export { getViewportVolumeHistogram };
|
||||
@@ -0,0 +1,97 @@
|
||||
import { expose } from 'comlink';
|
||||
import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
|
||||
import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
|
||||
|
||||
/**
|
||||
* This object simulates a heavy task by implementing a sleep function and a recursive Fibonacci function.
|
||||
* It's used for testing or demonstrating purposes where a heavy or time-consuming task is needed.
|
||||
*/
|
||||
const obj = {
|
||||
getRange: ({ dimensions, origin, direction, spacing, scalarData }) => {
|
||||
const imageData = vtkImageData.newInstance();
|
||||
imageData.setDimensions(dimensions);
|
||||
imageData.setOrigin(origin);
|
||||
imageData.setDirection(direction);
|
||||
imageData.setSpacing(spacing);
|
||||
|
||||
const scalarArray = vtkDataArray.newInstance({
|
||||
name: 'Pixels',
|
||||
numberOfComponents: 1,
|
||||
values: scalarData,
|
||||
});
|
||||
|
||||
imageData.getPointData().setScalars(scalarArray);
|
||||
|
||||
imageData.modified();
|
||||
|
||||
const range = imageData.computeHistogram(imageData.getBounds());
|
||||
|
||||
return range;
|
||||
},
|
||||
calcHistogram: ({ data, options }) => {
|
||||
if (options === undefined) {
|
||||
options = {};
|
||||
}
|
||||
const histogram = {
|
||||
numBins: options.numBins || 256,
|
||||
range: { min: 0, max: 0 },
|
||||
bins: new Int32Array(1),
|
||||
maxBin: 0,
|
||||
maxBinValue: 0,
|
||||
};
|
||||
|
||||
let minToUse = options.min;
|
||||
let maxToUse = options.max;
|
||||
|
||||
if (minToUse === undefined || maxToUse === undefined) {
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
let index = data.length;
|
||||
|
||||
while (index--) {
|
||||
const value = data[index];
|
||||
if (value < min) {
|
||||
min = value;
|
||||
}
|
||||
if (value > max) {
|
||||
max = value;
|
||||
}
|
||||
}
|
||||
|
||||
minToUse = min;
|
||||
maxToUse = max;
|
||||
}
|
||||
|
||||
histogram.range = { min: minToUse, max: maxToUse };
|
||||
|
||||
const bins = new Int32Array(histogram.numBins);
|
||||
const binScale = histogram.numBins / (maxToUse - minToUse);
|
||||
|
||||
for (let index = 0; index < data.length; index++) {
|
||||
const value = data[index];
|
||||
if (value < minToUse) {
|
||||
continue;
|
||||
}
|
||||
if (value > maxToUse) {
|
||||
continue;
|
||||
}
|
||||
const bin = Math.floor((value - minToUse) * binScale);
|
||||
bins[bin] += 1;
|
||||
}
|
||||
|
||||
histogram.bins = bins;
|
||||
histogram.maxBin = 0;
|
||||
histogram.maxBinValue = 0;
|
||||
|
||||
for (let bin = 0; bin < histogram.numBins; bin++) {
|
||||
if (histogram.bins[bin] > histogram.maxBinValue) {
|
||||
histogram.maxBin = bin;
|
||||
histogram.maxBinValue = histogram.bins[bin];
|
||||
}
|
||||
}
|
||||
|
||||
return histogram;
|
||||
},
|
||||
};
|
||||
|
||||
expose(obj);
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ViewportWindowLevel';
|
||||
@@ -0,0 +1,153 @@
|
||||
import { cache as cs3DCache, Types } from '@cornerstonejs/core';
|
||||
import vtkColorMaps from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction/ColorMaps';
|
||||
import { utilities as csUtils } from '@cornerstonejs/core';
|
||||
import { getViewportVolumeHistogram } from './getViewportVolumeHistogram';
|
||||
|
||||
/**
|
||||
* Gets node opacity from volume actor
|
||||
*/
|
||||
export const getNodeOpacity = (volumeActor, nodeIndex) => {
|
||||
const volumeOpacity = volumeActor.getProperty().getScalarOpacity(0);
|
||||
const nodeValue = [];
|
||||
|
||||
volumeOpacity.getNodeValue(nodeIndex, nodeValue);
|
||||
|
||||
return nodeValue[1];
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the opacity applied to the PET volume follows a specific pattern
|
||||
*/
|
||||
export const isPetVolumeWithDefaultOpacity = (volumeId: string, volumeActor) => {
|
||||
const volume = cs3DCache.getVolume(volumeId);
|
||||
|
||||
if (!volume || volume.metadata.Modality !== 'PT') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const volumeOpacity = volumeActor.getProperty().getScalarOpacity(0);
|
||||
|
||||
if (volumeOpacity.getSize() < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const node1Value = [];
|
||||
const node2Value = [];
|
||||
|
||||
volumeOpacity.getNodeValue(0, node1Value);
|
||||
volumeOpacity.getNodeValue(1, node2Value);
|
||||
|
||||
if (node1Value[0] !== 0 || node1Value[1] !== 0 || node2Value[0] !== 0.1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedOpacity = node2Value[1];
|
||||
const opacitySize = volumeOpacity.getSize();
|
||||
const currentNodeValue = [];
|
||||
|
||||
for (let i = 2; i < opacitySize; i++) {
|
||||
volumeOpacity.getNodeValue(i, currentNodeValue);
|
||||
if (currentNodeValue[1] !== expectedOpacity) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if volume has constant opacity
|
||||
*/
|
||||
export const isVolumeWithConstantOpacity = volumeActor => {
|
||||
const volumeOpacity = volumeActor.getProperty().getScalarOpacity(0);
|
||||
const opacitySize = volumeOpacity.getSize();
|
||||
const firstNodeValue = [];
|
||||
|
||||
volumeOpacity.getNodeValue(0, firstNodeValue);
|
||||
const firstNodeOpacity = firstNodeValue[1];
|
||||
|
||||
for (let i = 0; i < opacitySize; i++) {
|
||||
const currentNodeValue = [];
|
||||
volumeOpacity.getNodeValue(0, currentNodeValue);
|
||||
if (currentNodeValue[1] !== firstNodeOpacity) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets window levels data for a viewport
|
||||
*/
|
||||
export const getWindowLevelsData = async (
|
||||
viewport: Types.IStackViewport | Types.IVolumeViewport,
|
||||
viewportInfo: any,
|
||||
getVolumeOpacity: (viewport: any, volumeId: string) => number | undefined
|
||||
) => {
|
||||
if (!viewport) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const volumeIds = (viewport as Types.IBaseVolumeViewport).getAllVolumeIds();
|
||||
const viewportProperties = viewport.getProperties();
|
||||
const { voiRange } = viewportProperties;
|
||||
const viewportVoi = voiRange
|
||||
? {
|
||||
windowWidth: voiRange.upper - voiRange.lower,
|
||||
windowCenter: voiRange.lower + (voiRange.upper - voiRange.lower) / 2,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const windowLevels = await Promise.all(
|
||||
volumeIds.map(async (volumeId, volumeIndex) => {
|
||||
const volume = cs3DCache.getVolume(volumeId);
|
||||
|
||||
const opacity = getVolumeOpacity(viewport, volumeId);
|
||||
const { metadata, scaling } = volume;
|
||||
const modality = metadata.Modality;
|
||||
|
||||
const options = {
|
||||
min: modality === 'PT' ? 0.1 : -999,
|
||||
max: modality === 'PT' ? 5 : 10000,
|
||||
};
|
||||
|
||||
const histogram = await getViewportVolumeHistogram(viewport, volume, options);
|
||||
|
||||
if (!histogram || histogram.range.min === histogram.range.max) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!viewportInfo.displaySetOptions || !viewportInfo.displaySetOptions[volumeIndex]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { voi: displaySetVOI, colormap: displaySetColormap } =
|
||||
viewportInfo.displaySetOptions[volumeIndex];
|
||||
|
||||
let colormap;
|
||||
if (displaySetColormap) {
|
||||
colormap =
|
||||
csUtils.colormap.getColormap(displaySetColormap.name) ??
|
||||
vtkColorMaps.getPresetByName(displaySetColormap.name);
|
||||
}
|
||||
|
||||
const voi = !volumeIndex ? (viewportVoi ?? displaySetVOI) : displaySetVOI;
|
||||
|
||||
return {
|
||||
viewportId: viewportInfo.viewportId,
|
||||
modality,
|
||||
volumeId,
|
||||
volumeIndex,
|
||||
voi,
|
||||
histogram,
|
||||
colormap,
|
||||
step: scaling?.PT ? 0.05 : 1,
|
||||
opacity,
|
||||
showOpacitySlider: volumeIndex === 1 && opacity !== undefined,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return windowLevels.filter(Boolean);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
|
||||
import { SwitchButton } from '@ohif/ui';
|
||||
import { StackViewport, VolumeViewport } from '@cornerstonejs/core';
|
||||
import { ColorbarProps } from '../../types/Colorbar';
|
||||
import { utilities } from '@cornerstonejs/core';
|
||||
|
||||
export function setViewportColorbar(
|
||||
viewportId,
|
||||
displaySets,
|
||||
commandsManager,
|
||||
servicesManager: AppTypes.ServicesManager,
|
||||
colorbarOptions
|
||||
) {
|
||||
const { cornerstoneViewportService } = servicesManager.services;
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
|
||||
const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId);
|
||||
const backgroundColor = viewportInfo.getViewportOptions().background;
|
||||
const isLight = backgroundColor ? utilities.isEqual(backgroundColor, [1, 1, 1]) : false;
|
||||
|
||||
if (isLight) {
|
||||
colorbarOptions.ticks = {
|
||||
position: 'left',
|
||||
style: {
|
||||
font: '12px Arial',
|
||||
color: '#000000',
|
||||
maxNumTicks: 8,
|
||||
tickSize: 5,
|
||||
tickWidth: 1,
|
||||
labelMargin: 3,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const displaySetInstanceUIDs = [];
|
||||
|
||||
if (viewport instanceof StackViewport) {
|
||||
displaySetInstanceUIDs.push(viewportId);
|
||||
}
|
||||
|
||||
if (viewport instanceof VolumeViewport) {
|
||||
displaySets.forEach(ds => {
|
||||
displaySetInstanceUIDs.push(ds.displaySetInstanceUID);
|
||||
});
|
||||
}
|
||||
|
||||
commandsManager.run({
|
||||
commandName: 'toggleViewportColorbar',
|
||||
commandOptions: {
|
||||
viewportId,
|
||||
options: colorbarOptions,
|
||||
displaySetInstanceUIDs,
|
||||
},
|
||||
context: 'CORNERSTONE',
|
||||
});
|
||||
}
|
||||
|
||||
export function Colorbar({
|
||||
viewportId,
|
||||
displaySets,
|
||||
commandsManager,
|
||||
servicesManager,
|
||||
colorbarProperties,
|
||||
}: withAppTypes<ColorbarProps>): ReactElement {
|
||||
const { colorbarService } = servicesManager.services;
|
||||
const {
|
||||
width: colorbarWidth,
|
||||
colorbarTickPosition,
|
||||
colorbarContainerPosition,
|
||||
colormaps,
|
||||
colorbarInitialColormap,
|
||||
} = colorbarProperties;
|
||||
const [showColorbar, setShowColorbar] = useState(colorbarService.hasColorbar(viewportId));
|
||||
|
||||
const onSetColorbar = useCallback(() => {
|
||||
setViewportColorbar(viewportId, displaySets, commandsManager, servicesManager, {
|
||||
viewportId,
|
||||
colormaps,
|
||||
ticks: {
|
||||
position: colorbarTickPosition,
|
||||
},
|
||||
width: colorbarWidth,
|
||||
position: colorbarContainerPosition,
|
||||
activeColormapName: colorbarInitialColormap,
|
||||
});
|
||||
}, [commandsManager]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateColorbarState = () => {
|
||||
setShowColorbar(colorbarService.hasColorbar(viewportId));
|
||||
};
|
||||
|
||||
const { unsubscribe } = colorbarService.subscribe(
|
||||
colorbarService.EVENTS.STATE_CHANGED,
|
||||
updateColorbarState
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [viewportId]);
|
||||
|
||||
return (
|
||||
<div className="all-in-one-menu-item flex w-full justify-center">
|
||||
<div className="mr-2 w-[28px]"></div>
|
||||
<SwitchButton
|
||||
label="Display Color bar"
|
||||
checked={showColorbar}
|
||||
onChange={() => {
|
||||
onSetColorbar();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import React, { ReactElement, useCallback, useEffect, useRef, useState, useMemo } from 'react';
|
||||
import { AllInOneMenu, ButtonGroup, SwitchButton } from '@ohif/ui';
|
||||
import { StackViewport, Types } from '@cornerstonejs/core';
|
||||
import { ColormapProps } from '../../types/Colormap';
|
||||
|
||||
export function Colormap({
|
||||
colormaps,
|
||||
viewportId,
|
||||
displaySets,
|
||||
commandsManager,
|
||||
servicesManager,
|
||||
}: ColormapProps): ReactElement {
|
||||
const { cornerstoneViewportService } = servicesManager.services;
|
||||
|
||||
const [activeDisplaySet, setActiveDisplaySet] = useState(displaySets[0]);
|
||||
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [prePreviewColormap, setPrePreviewColormap] = useState(null);
|
||||
|
||||
const showPreviewRef = useRef(showPreview);
|
||||
showPreviewRef.current = showPreview;
|
||||
const prePreviewColormapRef = useRef(prePreviewColormap);
|
||||
prePreviewColormapRef.current = prePreviewColormap;
|
||||
const activeDisplaySetRef = useRef(activeDisplaySet);
|
||||
activeDisplaySetRef.current = activeDisplaySet;
|
||||
|
||||
const onSetColorLUT = useCallback(
|
||||
props => {
|
||||
// TODO: Better way to check if it's a fusion
|
||||
const oneOpacityColormaps = ['Grayscale', 'X Ray'];
|
||||
const opacity =
|
||||
displaySets.length > 1 && !oneOpacityColormaps.includes(props.colormap.name) ? 0.5 : 1;
|
||||
commandsManager.run({
|
||||
commandName: 'setViewportColormap',
|
||||
commandOptions: {
|
||||
...props,
|
||||
opacity,
|
||||
immediate: true,
|
||||
},
|
||||
context: 'CORNERSTONE',
|
||||
});
|
||||
},
|
||||
[commandsManager]
|
||||
);
|
||||
|
||||
const getViewportColormap = (viewportId, displaySet) => {
|
||||
const { displaySetInstanceUID } = displaySet;
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
if (viewport instanceof StackViewport) {
|
||||
const { colormap } = viewport.getProperties();
|
||||
if (!colormap) {
|
||||
return colormaps.find(c => c.Name === 'Grayscale') || colormaps[0];
|
||||
}
|
||||
return colormap;
|
||||
}
|
||||
const actorEntries = viewport.getActors();
|
||||
const actorEntry = actorEntries?.find(entry =>
|
||||
entry.referencedId.includes(displaySetInstanceUID)
|
||||
);
|
||||
const { colormap } = (viewport as Types.IVolumeViewport).getProperties(actorEntry.referencedId);
|
||||
if (!colormap) {
|
||||
return colormaps.find(c => c.Name === 'Grayscale') || colormaps[0];
|
||||
}
|
||||
return colormap;
|
||||
};
|
||||
|
||||
const buttons = useMemo(() => {
|
||||
return displaySets.map((displaySet, index) => ({
|
||||
children: displaySet.Modality,
|
||||
key: index,
|
||||
style: {
|
||||
minWidth: `calc(100% / ${displaySets.length})`,
|
||||
fontSize: '0.8rem',
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
}));
|
||||
}, [displaySets]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveDisplaySet(displaySets[displaySets.length - 1]);
|
||||
}, [displaySets]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{buttons.length > 1 && (
|
||||
<div className="all-in-one-menu-item flex w-full justify-center">
|
||||
<ButtonGroup
|
||||
onActiveIndexChange={index => {
|
||||
setActiveDisplaySet(displaySets[index]);
|
||||
setPrePreviewColormap(null);
|
||||
}}
|
||||
activeIndex={
|
||||
displaySets.findIndex(
|
||||
ds => ds.displaySetInstanceUID === activeDisplaySetRef.current.displaySetInstanceUID
|
||||
) || 1
|
||||
}
|
||||
className="w-[70%] text-[10px]"
|
||||
>
|
||||
{buttons.map(({ children, key, style }) => (
|
||||
<div
|
||||
key={key}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
)}
|
||||
<div className="all-in-one-menu-item flex w-full justify-center">
|
||||
<SwitchButton
|
||||
label="Preview in viewport"
|
||||
checked={showPreview}
|
||||
onChange={checked => {
|
||||
setShowPreview(checked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<AllInOneMenu.DividerItem />
|
||||
<AllInOneMenu.ItemPanel>
|
||||
{colormaps.map((colormap, index) => (
|
||||
<AllInOneMenu.Item
|
||||
key={index}
|
||||
label={colormap.description}
|
||||
onClick={() => {
|
||||
onSetColorLUT({
|
||||
viewportId,
|
||||
colormap,
|
||||
displaySetInstanceUID: activeDisplaySetRef.current.displaySetInstanceUID,
|
||||
});
|
||||
setPrePreviewColormap(null);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (showPreviewRef.current) {
|
||||
setPrePreviewColormap(getViewportColormap(viewportId, activeDisplaySetRef.current));
|
||||
onSetColorLUT({
|
||||
viewportId,
|
||||
colormap,
|
||||
displaySetInstanceUID: activeDisplaySetRef.current.displaySetInstanceUID,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (showPreviewRef.current && prePreviewColormapRef.current) {
|
||||
onSetColorLUT({
|
||||
viewportId,
|
||||
colormap: prePreviewColormapRef.current,
|
||||
displaySetInstanceUID: activeDisplaySetRef.current.displaySetInstanceUID,
|
||||
});
|
||||
}
|
||||
}}
|
||||
></AllInOneMenu.Item>
|
||||
))}
|
||||
</AllInOneMenu.ItemPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import React, { ReactElement, useState, useEffect, useCallback } from 'react';
|
||||
import { VolumeLightingProps } from '../../types/ViewportPresets';
|
||||
|
||||
export function VolumeLighting({
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
viewportId,
|
||||
}: VolumeLightingProps): ReactElement {
|
||||
const { cornerstoneViewportService } = servicesManager.services;
|
||||
const [ambient, setAmbient] = useState(null);
|
||||
const [diffuse, setDiffuse] = useState(null);
|
||||
const [specular, setSpecular] = useState(null);
|
||||
|
||||
const onAmbientChange = useCallback(() => {
|
||||
commandsManager.runCommand('setVolumeLighting', { viewportId, options: { ambient } });
|
||||
}, [ambient, commandsManager, viewportId]);
|
||||
|
||||
const onDiffuseChange = useCallback(() => {
|
||||
commandsManager.runCommand('setVolumeLighting', { viewportId, options: { diffuse } });
|
||||
}, [diffuse, commandsManager, viewportId]);
|
||||
|
||||
const onSpecularChange = useCallback(() => {
|
||||
commandsManager.runCommand('setVolumeLighting', { viewportId, options: { specular } });
|
||||
}, [specular, commandsManager, viewportId]);
|
||||
|
||||
const calculateBackground = value => {
|
||||
const percentage = ((value - 0) / (1 - 0)) * 100;
|
||||
return `linear-gradient(to right, #5acce6 0%, #5acce6 ${percentage}%, #3a3f99 ${percentage}%, #3a3f99 100%)`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
const { actor } = viewport.getActors()[0];
|
||||
const ambient = actor.getProperty().getAmbient();
|
||||
const diffuse = actor.getProperty().getDiffuse();
|
||||
const specular = actor.getProperty().getSpecular();
|
||||
setAmbient(ambient);
|
||||
setDiffuse(diffuse);
|
||||
setSpecular(specular);
|
||||
}, [viewportId, cornerstoneViewportService]);
|
||||
return (
|
||||
<>
|
||||
<div className="all-in-one-menu-item flex w-full flex-row !items-center justify-between gap-[10px]">
|
||||
<label
|
||||
className="block text-white"
|
||||
htmlFor="ambient"
|
||||
>
|
||||
Ambient
|
||||
</label>
|
||||
{ambient !== null && (
|
||||
<input
|
||||
className="bg-inputfield-main h-2 w-[120px] cursor-pointer appearance-none rounded-lg"
|
||||
value={ambient}
|
||||
onChange={e => {
|
||||
setAmbient(e.target.value);
|
||||
onAmbientChange();
|
||||
}}
|
||||
id="ambient"
|
||||
max={1}
|
||||
min={0}
|
||||
type="range"
|
||||
step={0.1}
|
||||
style={{
|
||||
background: calculateBackground(ambient),
|
||||
'--thumb-inner-color': '#5acce6',
|
||||
'--thumb-outer-color': '#090c29',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="all-in-one-menu-item flex w-full flex-row !items-center justify-between gap-[10px]">
|
||||
<label
|
||||
className="block text-white"
|
||||
htmlFor="diffuse"
|
||||
>
|
||||
Diffuse
|
||||
</label>
|
||||
{diffuse !== null && (
|
||||
<input
|
||||
className="bg-inputfield-main h-2 w-[120px] cursor-pointer appearance-none rounded-lg"
|
||||
value={diffuse}
|
||||
onChange={e => {
|
||||
setDiffuse(e.target.value);
|
||||
onDiffuseChange();
|
||||
}}
|
||||
id="diffuse"
|
||||
max={1}
|
||||
min={0}
|
||||
type="range"
|
||||
step={0.1}
|
||||
style={{
|
||||
background: calculateBackground(diffuse),
|
||||
'--thumb-inner-color': '#5acce6',
|
||||
'--thumb-outer-color': '#090c29',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="all-in-one-menu-item flex w-full flex-row !items-center justify-between gap-[10px]">
|
||||
<label
|
||||
className="block text-white"
|
||||
htmlFor="specular"
|
||||
>
|
||||
Specular
|
||||
</label>
|
||||
{specular !== null && (
|
||||
<input
|
||||
className="bg-inputfield-main h-2 w-[120px] cursor-pointer appearance-none rounded-lg"
|
||||
value={specular}
|
||||
onChange={e => {
|
||||
setSpecular(e.target.value);
|
||||
onSpecularChange();
|
||||
}}
|
||||
id="specular"
|
||||
max={1}
|
||||
min={0}
|
||||
type="range"
|
||||
step={0.1}
|
||||
style={{
|
||||
background: calculateBackground(specular),
|
||||
'--thumb-inner-color': '#5acce6',
|
||||
'--thumb-outer-color': '#090c29',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import React, { ReactElement } from 'react';
|
||||
import { AllInOneMenu } from '@ohif/ui';
|
||||
import { VolumeRenderingOptionsProps } from '../../types/ViewportPresets';
|
||||
import { VolumeRenderingQuality } from './VolumeRenderingQuality';
|
||||
import { VolumeShift } from './VolumeShift';
|
||||
import { VolumeLighting } from './VolumeLighting';
|
||||
import { VolumeShade } from './VolumeShade';
|
||||
export function VolumeRenderingOptions({
|
||||
viewportId,
|
||||
commandsManager,
|
||||
volumeRenderingQualityRange,
|
||||
servicesManager,
|
||||
}: VolumeRenderingOptionsProps): ReactElement {
|
||||
return (
|
||||
<AllInOneMenu.ItemPanel>
|
||||
<VolumeRenderingQuality
|
||||
viewportId={viewportId}
|
||||
commandsManager={commandsManager}
|
||||
servicesManager={servicesManager}
|
||||
volumeRenderingQualityRange={volumeRenderingQualityRange}
|
||||
/>
|
||||
|
||||
<VolumeShift
|
||||
viewportId={viewportId}
|
||||
commandsManager={commandsManager}
|
||||
servicesManager={servicesManager}
|
||||
/>
|
||||
<div className="all-in-one-menu-item mt-2 flex !h-[20px] w-full justify-start">
|
||||
<div className="text-aqua-pale text-[13px]">LIGHTING</div>
|
||||
</div>
|
||||
<div className="bg-primary-dark mt-1 mb-1 h-[2px] w-full"></div>
|
||||
<div className="all-in-one-menu-item flex w-full justify-center">
|
||||
<VolumeShade
|
||||
commandsManager={commandsManager}
|
||||
servicesManager={servicesManager}
|
||||
viewportId={viewportId}
|
||||
/>
|
||||
</div>
|
||||
<VolumeLighting
|
||||
viewportId={viewportId}
|
||||
commandsManager={commandsManager}
|
||||
servicesManager={servicesManager}
|
||||
/>
|
||||
</AllInOneMenu.ItemPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { AllInOneMenu, Icon } from '@ohif/ui';
|
||||
import React, { ReactElement } from 'react';
|
||||
import { VolumeRenderingPresetsProps } from '../../types/ViewportPresets';
|
||||
import { VolumeRenderingPresetsContent } from './VolumeRenderingPresetsContent';
|
||||
|
||||
export function VolumeRenderingPresets({
|
||||
viewportId,
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
volumeRenderingPresets,
|
||||
}: VolumeRenderingPresetsProps): ReactElement {
|
||||
const { uiModalService } = servicesManager.services;
|
||||
|
||||
const onClickPresets = () => {
|
||||
uiModalService.show({
|
||||
content: VolumeRenderingPresetsContent,
|
||||
title: 'Rendering Presets',
|
||||
movable: true,
|
||||
contentProps: {
|
||||
onClose: uiModalService.hide,
|
||||
presets: volumeRenderingPresets,
|
||||
viewportId,
|
||||
commandsManager,
|
||||
},
|
||||
containerDimensions: 'h-[543px] w-[460px]',
|
||||
contentDimensions: 'h-[493px] w-[460px] pl-[12px] pr-[12px]',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AllInOneMenu.Item
|
||||
label="Rendering Presets"
|
||||
icon={<Icon name="VolumeRendering" />}
|
||||
rightIcon={<Icon name="action-new-dialog" />}
|
||||
onClick={onClickPresets}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Icon } from '@ohif/ui';
|
||||
import { ButtonEnums } from '@ohif/ui';
|
||||
import React, { ReactElement, useState, useCallback } from 'react';
|
||||
import { Button, InputFilterText } from '@ohif/ui';
|
||||
import { ViewportPreset, VolumeRenderingPresetsContentProps } from '../../types/ViewportPresets';
|
||||
|
||||
export function VolumeRenderingPresetsContent({
|
||||
presets,
|
||||
viewportId,
|
||||
commandsManager,
|
||||
onClose,
|
||||
}: VolumeRenderingPresetsContentProps): ReactElement {
|
||||
const [filteredPresets, setFilteredPresets] = useState(presets);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [selectedPreset, setSelectedPreset] = useState<ViewportPreset | null>(null);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearchValue(value);
|
||||
const filtered = value
|
||||
? presets.filter(preset => preset.name.toLowerCase().includes(value.toLowerCase()))
|
||||
: presets;
|
||||
setFilteredPresets(filtered);
|
||||
},
|
||||
[presets]
|
||||
);
|
||||
|
||||
const handleApply = useCallback(
|
||||
props => {
|
||||
commandsManager.runCommand('setViewportPreset', {
|
||||
...props,
|
||||
});
|
||||
},
|
||||
[commandsManager]
|
||||
);
|
||||
|
||||
const formatLabel = (label: string, maxChars: number) => {
|
||||
return label.length > maxChars ? `${label.slice(0, maxChars)}...` : label;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full w-full flex-col justify-between">
|
||||
<div className="border-secondary-light h-[433px] w-full overflow-hidden rounded border bg-black px-2.5">
|
||||
<div className="flex h-[46px] w-full items-center justify-start">
|
||||
<div className="h-[26px] w-[200px]">
|
||||
<InputFilterText
|
||||
value={searchValue}
|
||||
onDebounceChange={handleSearchChange}
|
||||
placeholder={'Search all'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ohif-scrollbar overflow h-[385px] w-full overflow-y-auto">
|
||||
<div className="grid grid-cols-4 gap-3 pt-2 pr-3">
|
||||
{filteredPresets.map((preset, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex cursor-pointer flex-col items-start"
|
||||
onClick={() => {
|
||||
setSelectedPreset(preset);
|
||||
handleApply({ preset: preset.name, viewportId });
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
name={preset.name}
|
||||
className={
|
||||
selectedPreset?.name === preset.name
|
||||
? 'border-primary-light h-[75px] w-[95px] max-w-none rounded border-2'
|
||||
: 'hover:border-primary-light h-[75px] w-[95px] max-w-none rounded border-2 border-black'
|
||||
}
|
||||
/>
|
||||
<label className="text-aqua-pale mt-2 text-left text-xs">
|
||||
{formatLabel(preset.name, 11)}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="flex h-[60px] w-full items-center justify-end">
|
||||
<div className="flex">
|
||||
<Button
|
||||
name="Cancel"
|
||||
size={ButtonEnums.size.medium}
|
||||
type={ButtonEnums.type.secondary}
|
||||
onClick={onClose}
|
||||
>
|
||||
{' '}
|
||||
Cancel{' '}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import React, { ReactElement, useCallback, useState, useEffect } from 'react';
|
||||
import { VolumeRenderingQualityProps } from '../../types/ViewportPresets';
|
||||
|
||||
export function VolumeRenderingQuality({
|
||||
volumeRenderingQualityRange,
|
||||
commandsManager,
|
||||
servicesManager,
|
||||
viewportId,
|
||||
}: VolumeRenderingQualityProps): ReactElement {
|
||||
const { cornerstoneViewportService } = servicesManager.services;
|
||||
const { min, max, step } = volumeRenderingQualityRange;
|
||||
const [quality, setQuality] = useState(null);
|
||||
|
||||
const onChange = useCallback(
|
||||
(value: number) => {
|
||||
commandsManager.runCommand('setVolumeRenderingQulaity', {
|
||||
viewportId,
|
||||
volumeQuality: value,
|
||||
});
|
||||
setQuality(value);
|
||||
},
|
||||
[commandsManager, viewportId]
|
||||
);
|
||||
|
||||
const calculateBackground = value => {
|
||||
const percentage = ((value - 0) / (1 - 0)) * 100;
|
||||
return `linear-gradient(to right, #5acce6 0%, #5acce6 ${percentage}%, #3a3f99 ${percentage}%, #3a3f99 100%)`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
const { actor } = viewport.getActors()[0];
|
||||
const mapper = actor.getMapper();
|
||||
const image = mapper.getInputData();
|
||||
const spacing = image.getSpacing();
|
||||
const sampleDistance = mapper.getSampleDistance();
|
||||
const averageSpacing = spacing.reduce((a, b) => a + b) / 3.0;
|
||||
if (sampleDistance === averageSpacing) {
|
||||
setQuality(1);
|
||||
} else {
|
||||
setQuality(Math.sqrt(averageSpacing / (sampleDistance * 0.5)));
|
||||
}
|
||||
}, [cornerstoneViewportService, viewportId]);
|
||||
return (
|
||||
<>
|
||||
<div className="all-in-one-menu-item flex w-full flex-row !items-center justify-between gap-[10px]">
|
||||
<label
|
||||
className="block text-white"
|
||||
htmlFor="volume"
|
||||
>
|
||||
Quality
|
||||
</label>
|
||||
{quality !== null && (
|
||||
<input
|
||||
className="bg-inputfield-main h-2 w-[120px] cursor-pointer appearance-none rounded-lg"
|
||||
value={quality}
|
||||
id="volume"
|
||||
max={max}
|
||||
min={min}
|
||||
type="range"
|
||||
step={step}
|
||||
onChange={e => onChange(parseInt(e.target.value, 10))}
|
||||
style={{
|
||||
background: calculateBackground((quality - min) / (max - min)),
|
||||
'--thumb-inner-color': '#5acce6',
|
||||
'--thumb-outer-color': '#090c29',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
|
||||
import { SwitchButton } from '@ohif/ui';
|
||||
import { VolumeShadeProps } from '../../types/ViewportPresets';
|
||||
|
||||
export function VolumeShade({
|
||||
commandsManager,
|
||||
viewportId,
|
||||
servicesManager,
|
||||
}: VolumeShadeProps): ReactElement {
|
||||
const { cornerstoneViewportService } = servicesManager.services;
|
||||
const [shade, setShade] = useState(true);
|
||||
const [key, setKey] = useState(0);
|
||||
|
||||
const onShadeChange = useCallback(
|
||||
(checked: boolean) => {
|
||||
commandsManager.runCommand('setVolumeLighting', { viewportId, options: { shade: checked } });
|
||||
},
|
||||
[commandsManager, viewportId]
|
||||
);
|
||||
useEffect(() => {
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
const { actor } = viewport.getActors()[0];
|
||||
const shade = actor.getProperty().getShade();
|
||||
setShade(shade);
|
||||
setKey(key + 1);
|
||||
}, [viewportId, cornerstoneViewportService]);
|
||||
|
||||
return (
|
||||
<SwitchButton
|
||||
key={key}
|
||||
label="Shade"
|
||||
checked={shade}
|
||||
onChange={() => {
|
||||
setShade(!shade);
|
||||
onShadeChange(!shade);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import React, { ReactElement, useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { VolumeShiftProps } from '../../types/ViewportPresets';
|
||||
|
||||
export function VolumeShift({
|
||||
viewportId,
|
||||
commandsManager,
|
||||
servicesManager,
|
||||
}: VolumeShiftProps): ReactElement {
|
||||
const { cornerstoneViewportService } = servicesManager.services;
|
||||
const [minShift, setMinShift] = useState<number | null>(null);
|
||||
const [maxShift, setMaxShift] = useState<number | null>(null);
|
||||
const [shift, setShift] = useState<number | null>(
|
||||
cornerstoneViewportService.getCornerstoneViewport(viewportId)?.shiftedBy || 0
|
||||
);
|
||||
const [step, setStep] = useState<number | null>(null);
|
||||
const [isBlocking, setIsBlocking] = useState(false);
|
||||
|
||||
const prevShiftRef = useRef<number>(shift);
|
||||
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
const { actor } = viewport.getActors()[0];
|
||||
const ofun = actor.getProperty().getScalarOpacity(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isBlocking) {
|
||||
return;
|
||||
}
|
||||
const range = ofun.getRange();
|
||||
|
||||
const transferFunctionWidth = range[1] - range[0];
|
||||
|
||||
const minShift = -transferFunctionWidth;
|
||||
const maxShift = transferFunctionWidth;
|
||||
|
||||
setMinShift(minShift);
|
||||
setMaxShift(maxShift);
|
||||
setStep(Math.pow(10, Math.floor(Math.log10(transferFunctionWidth / 500))));
|
||||
}, [cornerstoneViewportService, viewportId, actor, ofun, isBlocking]);
|
||||
|
||||
const onChangeRange = useCallback(
|
||||
newShift => {
|
||||
const shiftDifference = newShift - prevShiftRef.current;
|
||||
prevShiftRef.current = newShift;
|
||||
viewport.shiftedBy = newShift;
|
||||
commandsManager.runCommand('shiftVolumeOpacityPoints', {
|
||||
viewportId,
|
||||
shift: shiftDifference,
|
||||
});
|
||||
},
|
||||
[commandsManager, viewportId, viewport]
|
||||
);
|
||||
|
||||
const calculateBackground = value => {
|
||||
const percentage = ((value - 0) / (1 - 0)) * 100;
|
||||
return `linear-gradient(to right, #5acce6 0%, #5acce6 ${percentage}%, #3a3f99 ${percentage}%, #3a3f99 100%)`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="all-in-one-menu-item flex w-full flex-row !items-center justify-between gap-[10px]">
|
||||
<label
|
||||
className="block text-white"
|
||||
htmlFor="shift"
|
||||
>
|
||||
Shift
|
||||
</label>
|
||||
{step !== null && (
|
||||
<input
|
||||
className="bg-inputfield-main h-2 w-[120px] cursor-pointer appearance-none rounded-lg"
|
||||
value={shift}
|
||||
onChange={e => {
|
||||
const shiftValue = parseInt(e.target.value, 10);
|
||||
setShift(shiftValue);
|
||||
onChangeRange(shiftValue);
|
||||
}}
|
||||
id="shift"
|
||||
onMouseDown={() => setIsBlocking(true)}
|
||||
onMouseUp={() => setIsBlocking(false)}
|
||||
max={maxShift}
|
||||
min={minShift}
|
||||
type="range"
|
||||
step={step}
|
||||
style={{
|
||||
background: calculateBackground((shift - minShift) / (maxShift - minShift)),
|
||||
'--thumb-inner-color': '#5acce6',
|
||||
'--thumb-outer-color': '#090c29',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import React, { ReactElement, useCallback } from 'react';
|
||||
import { AllInOneMenu } from '@ohif/ui';
|
||||
import { WindowLevelPreset } from '../../types/WindowLevel';
|
||||
import { CommandsManager } from '@ohif/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type WindowLevelProps = {
|
||||
viewportId: string;
|
||||
presets: Array<Record<string, Array<WindowLevelPreset>>>;
|
||||
commandsManager: CommandsManager;
|
||||
};
|
||||
|
||||
export function WindowLevel({
|
||||
viewportId,
|
||||
commandsManager,
|
||||
presets,
|
||||
}: WindowLevelProps): ReactElement {
|
||||
const { t } = useTranslation('WindowLevelActionMenu');
|
||||
|
||||
const onSetWindowLevel = useCallback(
|
||||
props => {
|
||||
commandsManager.run({
|
||||
commandName: 'setViewportWindowLevel',
|
||||
commandOptions: {
|
||||
...props,
|
||||
viewportId,
|
||||
},
|
||||
context: 'CORNERSTONE',
|
||||
});
|
||||
},
|
||||
[commandsManager, viewportId]
|
||||
);
|
||||
|
||||
return (
|
||||
<AllInOneMenu.ItemPanel>
|
||||
{presets.map((modalityPresets, modalityIndex) => (
|
||||
<React.Fragment key={modalityIndex}>
|
||||
{Object.entries(modalityPresets).map(([modality, presetsArray]) => (
|
||||
<React.Fragment key={modality}>
|
||||
<AllInOneMenu.HeaderItem>
|
||||
{t('Modality Presets', { modality })}
|
||||
</AllInOneMenu.HeaderItem>
|
||||
{presetsArray.map((preset, index) => (
|
||||
<AllInOneMenu.Item
|
||||
key={`${modality}-${index}`}
|
||||
label={preset.description}
|
||||
secondaryLabel={`${preset.window} / ${preset.level}`}
|
||||
onClick={() => onSetWindowLevel(preset)}
|
||||
/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</AllInOneMenu.ItemPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import classNames from 'classnames';
|
||||
import { AllInOneMenu, useViewportGrid } from '@ohif/ui';
|
||||
import { Colormap } from './Colormap';
|
||||
import { Colorbar } from './Colorbar';
|
||||
import { setViewportColorbar } from './Colorbar';
|
||||
import { WindowLevelPreset } from '../../types/WindowLevel';
|
||||
import { ColorbarProperties } from '../../types/Colorbar';
|
||||
import { VolumeRenderingQualityRange } from '../../types/ViewportPresets';
|
||||
import { WindowLevel } from './WindowLevel';
|
||||
import { VolumeRenderingPresets } from './VolumeRenderingPresets';
|
||||
import { VolumeRenderingOptions } from './VolumeRenderingOptions';
|
||||
import { ViewportPreset } from '../../types/ViewportPresets';
|
||||
import { VolumeViewport3D } from '@cornerstonejs/core';
|
||||
import { utilities } from '@cornerstonejs/core';
|
||||
|
||||
export const nonWLModalities = ['SR', 'SEG', 'SM', 'RTSTRUCT', 'RTPLAN', 'RTDOSE'];
|
||||
|
||||
export type WindowLevelActionMenuProps = {
|
||||
viewportId: string;
|
||||
element: HTMLElement;
|
||||
presets: Array<Record<string, Array<WindowLevelPreset>>>;
|
||||
colorbarProperties: ColorbarProperties;
|
||||
displaySets: Array<any>;
|
||||
volumeRenderingPresets: Array<ViewportPreset>;
|
||||
volumeRenderingQualityRange: VolumeRenderingQualityRange;
|
||||
};
|
||||
|
||||
export function WindowLevelActionMenu({
|
||||
viewportId,
|
||||
element,
|
||||
presets,
|
||||
verticalDirection,
|
||||
horizontalDirection,
|
||||
commandsManager,
|
||||
servicesManager,
|
||||
colorbarProperties,
|
||||
displaySets,
|
||||
volumeRenderingPresets,
|
||||
volumeRenderingQualityRange,
|
||||
}: withAppTypes<WindowLevelActionMenuProps>): ReactElement {
|
||||
const {
|
||||
colormaps,
|
||||
colorbarContainerPosition,
|
||||
colorbarInitialColormap,
|
||||
colorbarTickPosition,
|
||||
width: colorbarWidth,
|
||||
} = colorbarProperties;
|
||||
const { colorbarService, cornerstoneViewportService } = servicesManager.services;
|
||||
const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId);
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
const backgroundColor = viewportInfo.getViewportOptions().background;
|
||||
const isLight = backgroundColor ? utilities.isEqual(backgroundColor, [1, 1, 1]) : false;
|
||||
|
||||
const { t } = useTranslation('WindowLevelActionMenu');
|
||||
|
||||
const [viewportGrid] = useViewportGrid();
|
||||
const { activeViewportId } = viewportGrid;
|
||||
|
||||
const [vpHeight, setVpHeight] = useState(element?.clientHeight);
|
||||
const [menuKey, setMenuKey] = useState(0);
|
||||
const [is3DVolume, setIs3DVolume] = useState(false);
|
||||
|
||||
const onSetColorbar = useCallback(() => {
|
||||
setViewportColorbar(viewportId, displaySets, commandsManager, servicesManager, {
|
||||
colormaps,
|
||||
ticks: {
|
||||
position: colorbarTickPosition,
|
||||
},
|
||||
width: colorbarWidth,
|
||||
position: colorbarContainerPosition,
|
||||
activeColormapName: colorbarInitialColormap,
|
||||
});
|
||||
}, [commandsManager]);
|
||||
|
||||
useEffect(() => {
|
||||
const newVpHeight = element?.clientHeight;
|
||||
if (vpHeight !== newVpHeight) {
|
||||
setVpHeight(newVpHeight);
|
||||
}
|
||||
}, [element, vpHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!colorbarService.hasColorbar(viewportId)) {
|
||||
return;
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
colorbarService.removeColorbar(viewportId);
|
||||
onSetColorbar();
|
||||
}, 0);
|
||||
}, [viewportId, displaySets, viewport]);
|
||||
|
||||
useEffect(() => {
|
||||
setMenuKey(menuKey + 1);
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
if (viewport instanceof VolumeViewport3D) {
|
||||
setIs3DVolume(true);
|
||||
} else {
|
||||
setIs3DVolume(false);
|
||||
}
|
||||
}, [
|
||||
displaySets,
|
||||
viewportId,
|
||||
presets,
|
||||
volumeRenderingQualityRange,
|
||||
volumeRenderingPresets,
|
||||
colorbarProperties,
|
||||
activeViewportId,
|
||||
viewportGrid,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AllInOneMenu.IconMenu
|
||||
icon="viewport-window-level"
|
||||
verticalDirection={verticalDirection}
|
||||
horizontalDirection={horizontalDirection}
|
||||
iconClassName={classNames(
|
||||
// Visible on hover and for the active viewport
|
||||
activeViewportId === viewportId ? 'visible' : 'invisible group-hover/pane:visible',
|
||||
'flex shrink-0 cursor-pointer rounded active:text-white text-primary-light',
|
||||
isLight ? ' hover:bg-secondary-dark' : 'hover:bg-secondary-light/60'
|
||||
)}
|
||||
menuStyle={{ maxHeight: vpHeight - 32, minWidth: 218 }}
|
||||
onVisibilityChange={() => {
|
||||
setVpHeight(element.clientHeight);
|
||||
}}
|
||||
menuKey={menuKey}
|
||||
>
|
||||
<AllInOneMenu.ItemPanel>
|
||||
{!is3DVolume && (
|
||||
<Colorbar
|
||||
viewportId={viewportId}
|
||||
displaySets={displaySets.filter(ds => !nonWLModalities.includes(ds.Modality))}
|
||||
commandsManager={commandsManager}
|
||||
servicesManager={servicesManager}
|
||||
colorbarProperties={colorbarProperties}
|
||||
/>
|
||||
)}
|
||||
|
||||
{colormaps && !is3DVolume && (
|
||||
<AllInOneMenu.SubMenu
|
||||
key="colorLUTPresets"
|
||||
itemLabel="Color LUT"
|
||||
itemIcon="icon-color-lut"
|
||||
>
|
||||
<Colormap
|
||||
colormaps={colormaps}
|
||||
viewportId={viewportId}
|
||||
displaySets={displaySets.filter(ds => !nonWLModalities.includes(ds.Modality))}
|
||||
commandsManager={commandsManager}
|
||||
servicesManager={servicesManager}
|
||||
/>
|
||||
</AllInOneMenu.SubMenu>
|
||||
)}
|
||||
|
||||
{presets && presets.length > 0 && !is3DVolume && (
|
||||
<AllInOneMenu.SubMenu
|
||||
key="windowLevelPresets"
|
||||
itemLabel={t('Modality Window Presets')}
|
||||
itemIcon="viewport-window-level"
|
||||
>
|
||||
<WindowLevel
|
||||
viewportId={viewportId}
|
||||
commandsManager={commandsManager}
|
||||
presets={presets}
|
||||
/>
|
||||
</AllInOneMenu.SubMenu>
|
||||
)}
|
||||
|
||||
{volumeRenderingPresets && is3DVolume && (
|
||||
<VolumeRenderingPresets
|
||||
servicesManager={servicesManager}
|
||||
viewportId={viewportId}
|
||||
commandsManager={commandsManager}
|
||||
volumeRenderingPresets={volumeRenderingPresets}
|
||||
/>
|
||||
)}
|
||||
|
||||
{volumeRenderingQualityRange && is3DVolume && (
|
||||
<AllInOneMenu.SubMenu itemLabel="Rendering Options">
|
||||
<VolumeRenderingOptions
|
||||
viewportId={viewportId}
|
||||
commandsManager={commandsManager}
|
||||
volumeRenderingQualityRange={volumeRenderingQualityRange}
|
||||
servicesManager={servicesManager}
|
||||
/>
|
||||
</AllInOneMenu.SubMenu>
|
||||
)}
|
||||
</AllInOneMenu.ItemPanel>
|
||||
</AllInOneMenu.IconMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// The following are the default window level presets and can be further
|
||||
// configured via the customization service.
|
||||
const defaultWindowLevelPresets = {
|
||||
CT: [
|
||||
{ description: 'Soft tissue', window: '400', level: '40' },
|
||||
{ description: 'Lung', window: '1500', level: '-600' },
|
||||
{ description: 'Liver', window: '150', level: '90' },
|
||||
{ description: 'Bone', window: '2500', level: '480' },
|
||||
{ description: 'Brain', window: '80', level: '40' },
|
||||
],
|
||||
|
||||
PT: [
|
||||
{ description: 'Default', window: '5', level: '2.5' },
|
||||
{ description: 'SUV', window: '0', level: '3' },
|
||||
{ description: 'SUV', window: '0', level: '5' },
|
||||
{ description: 'SUV', window: '0', level: '7' },
|
||||
{ description: 'SUV', window: '0', level: '8' },
|
||||
{ description: 'SUV', window: '0', level: '10' },
|
||||
{ description: 'SUV', window: '0', level: '15' },
|
||||
],
|
||||
};
|
||||
|
||||
export default defaultWindowLevelPresets;
|
||||
@@ -0,0 +1,54 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { nonWLModalities, WindowLevelActionMenu } from './WindowLevelActionMenu';
|
||||
|
||||
export function getWindowLevelActionMenu({
|
||||
viewportId,
|
||||
element,
|
||||
displaySets,
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
verticalDirection,
|
||||
horizontalDirection,
|
||||
}: withAppTypes<{
|
||||
viewportId: string;
|
||||
element: HTMLElement;
|
||||
displaySets: AppTypes.DisplaySet[];
|
||||
}>): ReactNode {
|
||||
const { customizationService } = servicesManager.services;
|
||||
|
||||
const { presets } = customizationService.get('cornerstone.windowLevelPresets');
|
||||
const colorbarProperties = customizationService.get('cornerstone.colorbar');
|
||||
const { volumeRenderingPresets, volumeRenderingQualityRange } = customizationService.get(
|
||||
'cornerstone.3dVolumeRendering'
|
||||
);
|
||||
|
||||
const displaySetPresets = displaySets
|
||||
.filter(displaySet => presets[displaySet.Modality])
|
||||
.map(displaySet => {
|
||||
return { [displaySet.Modality]: presets[displaySet.Modality] };
|
||||
});
|
||||
|
||||
const modalities = displaySets
|
||||
.map(displaySet => displaySet.Modality)
|
||||
.filter(modality => !nonWLModalities.includes(modality));
|
||||
|
||||
if (modalities.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<WindowLevelActionMenu
|
||||
viewportId={viewportId}
|
||||
element={element}
|
||||
presets={displaySetPresets}
|
||||
verticalDirection={verticalDirection}
|
||||
horizontalDirection={horizontalDirection}
|
||||
commandsManager={commandsManager}
|
||||
servicesManager={servicesManager}
|
||||
colorbarProperties={colorbarProperties}
|
||||
displaySets={displaySets}
|
||||
volumeRenderingPresets={volumeRenderingPresets}
|
||||
volumeRenderingQualityRange={volumeRenderingQualityRange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Types, ViewportActionCornersLocations } from '@ohif/ui';
|
||||
import ViewportActionCornersService, {
|
||||
ActionComponentInfo,
|
||||
} from '../services/ViewportActionCornersService/ViewportActionCornersService';
|
||||
|
||||
interface StateComponentInfo extends Types.ViewportActionCornersComponentInfo {
|
||||
indexPriority: number;
|
||||
}
|
||||
|
||||
type State = Record<string, Record<ViewportActionCornersLocations, Array<StateComponentInfo>>>;
|
||||
|
||||
const DEFAULT_STATE: State = {
|
||||
// default here is the viewportId of the default viewport
|
||||
default: {
|
||||
[ViewportActionCornersLocations.topLeft]: [],
|
||||
[ViewportActionCornersLocations.topRight]: [],
|
||||
[ViewportActionCornersLocations.bottomLeft]: [],
|
||||
[ViewportActionCornersLocations.bottomRight]: [],
|
||||
},
|
||||
// [anotherViewportId]: { ..... }
|
||||
};
|
||||
|
||||
export const ViewportActionCornersContext = createContext(DEFAULT_STATE);
|
||||
|
||||
export function ViewportActionCornersProvider({ children, service }) {
|
||||
const viewportActionCornersReducer = (state, action) => {
|
||||
switch (action.type) {
|
||||
case 'ADD_ACTION_COMPONENT': {
|
||||
const { viewportId, id, component, location, indexPriority } = action.payload;
|
||||
// Get the components at the specified location of the specified viewport.
|
||||
let locationComponents = state?.[viewportId]?.[location]
|
||||
? [...state[viewportId][location]]
|
||||
: [];
|
||||
|
||||
// If the component (id) already exists at the location specified in the payload,
|
||||
// then it must be replaced with the component in the payload so first
|
||||
// remove it from that location.
|
||||
const deletionIndex = locationComponents.findIndex(component => component.id === id);
|
||||
if (deletionIndex !== -1) {
|
||||
locationComponents = [
|
||||
...locationComponents.slice(0, deletionIndex),
|
||||
...locationComponents.slice(deletionIndex + 1),
|
||||
];
|
||||
}
|
||||
|
||||
// Insert the component from the payload but
|
||||
// do not insert an undefined or null component.
|
||||
if (component) {
|
||||
let insertionIndex;
|
||||
const isRightSide =
|
||||
location === ViewportActionCornersLocations.topRight ||
|
||||
location === ViewportActionCornersLocations.bottomRight;
|
||||
|
||||
if (indexPriority === undefined) {
|
||||
// If no indexPriority is provided, add it to the appropriate end
|
||||
insertionIndex = isRightSide ? 0 : locationComponents.length;
|
||||
} else {
|
||||
if (isRightSide) {
|
||||
insertionIndex = locationComponents.findIndex(
|
||||
component => indexPriority > component.indexPriority
|
||||
);
|
||||
} else {
|
||||
insertionIndex = locationComponents.findIndex(
|
||||
component => indexPriority <= component.indexPriority
|
||||
);
|
||||
}
|
||||
if (insertionIndex === -1) {
|
||||
// If no suitable position found, add to the appropriate end
|
||||
insertionIndex = isRightSide ? 0 : locationComponents.length;
|
||||
}
|
||||
}
|
||||
|
||||
const defaultPriority = isRightSide ? Number.MIN_SAFE_INTEGER : Number.MAX_SAFE_INTEGER;
|
||||
|
||||
locationComponents = [
|
||||
...locationComponents.slice(0, insertionIndex),
|
||||
{
|
||||
id,
|
||||
component,
|
||||
indexPriority: indexPriority ?? defaultPriority,
|
||||
},
|
||||
...locationComponents.slice(insertionIndex),
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
[viewportId]: {
|
||||
...state[viewportId],
|
||||
[location]: locationComponents,
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'CLEAR_ACTION_COMPONENTS': {
|
||||
const viewportId = action.payload;
|
||||
const nextState = { ...state };
|
||||
delete nextState[viewportId];
|
||||
return nextState;
|
||||
}
|
||||
default:
|
||||
return { ...state };
|
||||
}
|
||||
};
|
||||
|
||||
const [viewportActionCornersState, dispatch] = useReducer(
|
||||
viewportActionCornersReducer,
|
||||
DEFAULT_STATE
|
||||
);
|
||||
|
||||
const getState = useCallback(() => {
|
||||
return viewportActionCornersState;
|
||||
}, [viewportActionCornersState]);
|
||||
|
||||
const addComponent = useCallback(
|
||||
(actionComponentInfo: ActionComponentInfo) => {
|
||||
dispatch({ type: 'ADD_ACTION_COMPONENT', payload: actionComponentInfo });
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const addComponents = useCallback(
|
||||
(actionComponentInfos: Array<ActionComponentInfo>) => {
|
||||
actionComponentInfos.forEach(actionComponentInfo =>
|
||||
dispatch({ type: 'ADD_ACTION_COMPONENT', payload: actionComponentInfo })
|
||||
);
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const clear = useCallback(
|
||||
(viewportId: string) => dispatch({ type: 'CLEAR_ACTION_COMPONENTS', payload: viewportId }),
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (service) {
|
||||
service.setServiceImplementation({
|
||||
getState,
|
||||
addComponent,
|
||||
addComponents,
|
||||
clear,
|
||||
});
|
||||
}
|
||||
}, [getState, service, addComponent, addComponents, clear]);
|
||||
|
||||
const viewportCornerActions = {
|
||||
getState,
|
||||
addComponent: props => service.addComponent(props),
|
||||
addComponents: props => service.addComponents(props),
|
||||
clear: props => service.clear(props),
|
||||
};
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => [viewportActionCornersState, viewportCornerActions],
|
||||
[viewportActionCornersState, viewportCornerActions]
|
||||
);
|
||||
|
||||
return (
|
||||
<ViewportActionCornersContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</ViewportActionCornersContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
ViewportActionCornersProvider.propTypes = {
|
||||
children: PropTypes.node,
|
||||
service: PropTypes.instanceOf(ViewportActionCornersService).isRequired,
|
||||
};
|
||||
|
||||
export const useViewportActionCornersContext = () => useContext(ViewportActionCornersContext);
|
||||
9
extensions/cornerstone/src/enums.ts
Normal file
9
extensions/cornerstone/src/enums.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const CORNERSTONE_3D_TOOLS_SOURCE_NAME = 'Cornerstone3DTools';
|
||||
export const CORNERSTONE_3D_TOOLS_SOURCE_VERSION = '0.1';
|
||||
|
||||
const Enums = {
|
||||
CORNERSTONE_3D_TOOLS_SOURCE_NAME,
|
||||
CORNERSTONE_3D_TOOLS_SOURCE_VERSION,
|
||||
};
|
||||
|
||||
export default Enums;
|
||||
213
extensions/cornerstone/src/getCustomizationModule.ts
Normal file
213
extensions/cornerstone/src/getCustomizationModule.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { Enums } from '@cornerstonejs/tools';
|
||||
import { toolNames } from './initCornerstoneTools';
|
||||
import defaultWindowLevelPresets from './components/WindowLevelActionMenu/defaultWindowLevelPresets';
|
||||
import { colormaps } from './utils/colormaps';
|
||||
import { CONSTANTS } from '@cornerstonejs/core';
|
||||
import { CornerstoneOverlay } from './Viewport/Overlays/CustomizableViewportOverlay';
|
||||
|
||||
const DefaultColormap = 'Grayscale';
|
||||
const { VIEWPORT_PRESETS } = CONSTANTS;
|
||||
|
||||
const tools = {
|
||||
active: [
|
||||
{
|
||||
toolName: toolNames.WindowLevel,
|
||||
bindings: [{ mouseButton: Enums.MouseBindings.Primary }],
|
||||
},
|
||||
{
|
||||
toolName: toolNames.Pan,
|
||||
bindings: [{ mouseButton: Enums.MouseBindings.Auxiliary }],
|
||||
},
|
||||
{
|
||||
toolName: toolNames.Zoom,
|
||||
bindings: [{ mouseButton: Enums.MouseBindings.Secondary }],
|
||||
},
|
||||
{
|
||||
toolName: toolNames.StackScroll,
|
||||
bindings: [{ mouseButton: Enums.MouseBindings.Wheel }],
|
||||
},
|
||||
],
|
||||
enabled: [
|
||||
{
|
||||
toolName: toolNames.PlanarFreehandContourSegmentation,
|
||||
configuration: {
|
||||
displayOnePointAsCrosshairs: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function getCustomizationModule() {
|
||||
return [
|
||||
{
|
||||
name: 'default',
|
||||
value: [
|
||||
CornerstoneOverlay,
|
||||
{
|
||||
id: 'cornerstone.overlayViewportTools',
|
||||
tools,
|
||||
},
|
||||
{
|
||||
id: 'cornerstone.windowLevelPresets',
|
||||
presets: defaultWindowLevelPresets,
|
||||
},
|
||||
{
|
||||
id: 'cornerstone.colorbar',
|
||||
width: '16px',
|
||||
colorbarTickPosition: 'left',
|
||||
colormaps,
|
||||
colorbarContainerPosition: 'right',
|
||||
colorbarInitialColormap: DefaultColormap,
|
||||
},
|
||||
{
|
||||
id: 'cornerstone.3dVolumeRendering',
|
||||
volumeRenderingPresets: VIEWPORT_PRESETS,
|
||||
volumeRenderingQualityRange: {
|
||||
min: 1,
|
||||
max: 4,
|
||||
step: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'cornerstone.measurements',
|
||||
Angle: {
|
||||
displayText: [],
|
||||
report: [],
|
||||
},
|
||||
CobbAngle: {
|
||||
displayText: [],
|
||||
report: [],
|
||||
},
|
||||
ArrowAnnotate: {
|
||||
displayText: [],
|
||||
report: [],
|
||||
},
|
||||
RectangleROi: {
|
||||
displayText: [],
|
||||
report: [],
|
||||
},
|
||||
CircleROI: {
|
||||
displayText: [],
|
||||
report: [],
|
||||
},
|
||||
EllipticalROI: {
|
||||
displayText: [],
|
||||
report: [],
|
||||
},
|
||||
Bidirectional: {
|
||||
displayText: [],
|
||||
report: [],
|
||||
},
|
||||
Length: {
|
||||
displayText: [],
|
||||
report: [],
|
||||
},
|
||||
LivewireContour: {
|
||||
displayText: [],
|
||||
report: [],
|
||||
},
|
||||
SplineROI: {
|
||||
displayText: [
|
||||
{
|
||||
displayName: 'Area',
|
||||
value: 'area',
|
||||
type: 'value',
|
||||
},
|
||||
{
|
||||
value: 'areaUnits',
|
||||
for: ['area'],
|
||||
type: 'unit',
|
||||
},
|
||||
/**
|
||||
{
|
||||
displayName: 'Modality',
|
||||
value: 'Modality',
|
||||
type: 'value',
|
||||
},
|
||||
*/
|
||||
],
|
||||
report: [
|
||||
{
|
||||
displayName: 'Area',
|
||||
value: 'area',
|
||||
type: 'value',
|
||||
},
|
||||
{
|
||||
displayName: 'Unit',
|
||||
value: 'areaUnits',
|
||||
type: 'value',
|
||||
},
|
||||
],
|
||||
},
|
||||
PlanarFreehandROI: {
|
||||
displayTextOpen: [
|
||||
{
|
||||
displayName: 'Length',
|
||||
value: 'length',
|
||||
type: 'value',
|
||||
},
|
||||
],
|
||||
displayText: [
|
||||
{
|
||||
displayName: 'Mean',
|
||||
value: 'mean',
|
||||
type: 'value',
|
||||
},
|
||||
{
|
||||
displayName: 'Max',
|
||||
value: 'max',
|
||||
type: 'value',
|
||||
},
|
||||
{
|
||||
displayName: 'Area',
|
||||
value: 'area',
|
||||
type: 'value',
|
||||
},
|
||||
{
|
||||
value: 'pixelValueUnits',
|
||||
for: ['mean', 'max' /** 'stdDev **/],
|
||||
type: 'unit',
|
||||
},
|
||||
{
|
||||
value: 'areaUnits',
|
||||
for: ['area'],
|
||||
type: 'unit',
|
||||
},
|
||||
/**
|
||||
{
|
||||
displayName: 'Std Dev',
|
||||
value: 'stdDev',
|
||||
type: 'value',
|
||||
},
|
||||
*/
|
||||
],
|
||||
report: [
|
||||
{
|
||||
displayName: 'Mean',
|
||||
value: 'mean',
|
||||
type: 'value',
|
||||
},
|
||||
{
|
||||
displayName: 'Max',
|
||||
value: 'max',
|
||||
type: 'value',
|
||||
},
|
||||
{
|
||||
displayName: 'Area',
|
||||
value: 'area',
|
||||
type: 'value',
|
||||
},
|
||||
{
|
||||
displayName: 'Unit',
|
||||
value: 'unit',
|
||||
type: 'value',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default getCustomizationModule;
|
||||
47
extensions/cornerstone/src/getHangingProtocolModule.ts
Normal file
47
extensions/cornerstone/src/getHangingProtocolModule.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { fourUp } from './hps/fourUp';
|
||||
import { main3D } from './hps/main3D';
|
||||
import { mpr } from './hps/mpr';
|
||||
import { mprAnd3DVolumeViewport } from './hps/mprAnd3DVolumeViewport';
|
||||
import { only3D } from './hps/only3D';
|
||||
import { primary3D } from './hps/primary3D';
|
||||
import { primaryAxial } from './hps/primaryAxial';
|
||||
import { frameView } from './hps/frameView';
|
||||
|
||||
function getHangingProtocolModule() {
|
||||
return [
|
||||
{
|
||||
name: mpr.id,
|
||||
protocol: mpr,
|
||||
},
|
||||
{
|
||||
name: mprAnd3DVolumeViewport.id,
|
||||
protocol: mprAnd3DVolumeViewport,
|
||||
},
|
||||
{
|
||||
name: fourUp.id,
|
||||
protocol: fourUp,
|
||||
},
|
||||
{
|
||||
name: main3D.id,
|
||||
protocol: main3D,
|
||||
},
|
||||
{
|
||||
name: primaryAxial.id,
|
||||
protocol: primaryAxial,
|
||||
},
|
||||
{
|
||||
name: only3D.id,
|
||||
protocol: only3D,
|
||||
},
|
||||
{
|
||||
name: primary3D.id,
|
||||
protocol: primary3D,
|
||||
},
|
||||
{
|
||||
name: frameView.id,
|
||||
protocol: frameView,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default getHangingProtocolModule;
|
||||
111
extensions/cornerstone/src/getPanelModule.tsx
Normal file
111
extensions/cornerstone/src/getPanelModule.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Toolbox } from '@ohif/ui-next';
|
||||
import PanelSegmentation from './panels/PanelSegmentation';
|
||||
import ActiveViewportWindowLevel from './components/ActiveViewportWindowLevel';
|
||||
import PanelMeasurementTable from './panels/PanelMeasurement';
|
||||
|
||||
const getPanelModule = ({ commandsManager, servicesManager, extensionManager }: withAppTypes) => {
|
||||
const wrappedPanelSegmentation = ({ configuration }) => {
|
||||
return (
|
||||
<PanelSegmentation
|
||||
commandsManager={commandsManager}
|
||||
servicesManager={servicesManager}
|
||||
extensionManager={extensionManager}
|
||||
configuration={{
|
||||
...configuration,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const wrappedPanelSegmentationNoHeader = ({ configuration }) => {
|
||||
return (
|
||||
<PanelSegmentation
|
||||
commandsManager={commandsManager}
|
||||
servicesManager={servicesManager}
|
||||
extensionManager={extensionManager}
|
||||
configuration={{
|
||||
...configuration,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const wrappedPanelSegmentationWithTools = ({ configuration }) => {
|
||||
return (
|
||||
<>
|
||||
<Toolbox
|
||||
commandsManager={commandsManager}
|
||||
servicesManager={servicesManager}
|
||||
extensionManager={extensionManager}
|
||||
buttonSectionId="segmentationToolbox"
|
||||
title="Segmentation Tools"
|
||||
configuration={{
|
||||
...configuration,
|
||||
}}
|
||||
/>
|
||||
<PanelSegmentation
|
||||
commandsManager={commandsManager}
|
||||
servicesManager={servicesManager}
|
||||
extensionManager={extensionManager}
|
||||
configuration={{
|
||||
...configuration,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const wrappedPanelMeasurement = ({ configuration }) => {
|
||||
return (
|
||||
<PanelMeasurementTable
|
||||
commandsManager={commandsManager}
|
||||
servicesManager={servicesManager}
|
||||
extensionManager={extensionManager}
|
||||
configuration={{
|
||||
...configuration,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'activeViewportWindowLevel',
|
||||
component: () => {
|
||||
return <ActiveViewportWindowLevel servicesManager={servicesManager} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'panelMeasurement',
|
||||
iconName: 'tab-linear',
|
||||
iconLabel: 'Measure',
|
||||
label: 'Measurement',
|
||||
component: wrappedPanelMeasurement,
|
||||
},
|
||||
{
|
||||
name: 'panelSegmentation',
|
||||
iconName: 'tab-segmentation',
|
||||
iconLabel: 'Segmentation',
|
||||
label: 'Segmentation',
|
||||
component: wrappedPanelSegmentation,
|
||||
},
|
||||
{
|
||||
name: 'panelSegmentationNoHeader',
|
||||
iconName: 'tab-segmentation',
|
||||
iconLabel: 'Segmentation',
|
||||
label: 'Segmentation',
|
||||
component: wrappedPanelSegmentationNoHeader,
|
||||
},
|
||||
{
|
||||
name: 'panelSegmentationWithTools',
|
||||
iconName: 'tab-segmentation',
|
||||
iconLabel: 'Segmentation',
|
||||
label: 'Segmentation',
|
||||
component: wrappedPanelSegmentationWithTools,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export default getPanelModule;
|
||||
170
extensions/cornerstone/src/getSopClassHandlerModule.js
Normal file
170
extensions/cornerstone/src/getSopClassHandlerModule.js
Normal file
@@ -0,0 +1,170 @@
|
||||
import OHIF from '@ohif/core';
|
||||
import { utilities as csUtils, Enums as csEnums } from '@cornerstonejs/core';
|
||||
import dcmjs from 'dcmjs';
|
||||
import { dicomWebUtils } from '@ohif/extension-default';
|
||||
|
||||
const { MetadataModules } = csEnums;
|
||||
const { utils } = OHIF;
|
||||
const { denaturalizeDataset } = dcmjs.data.DicomMetaDictionary;
|
||||
const { transferDenaturalizedDataset, fixMultiValueKeys } = dicomWebUtils;
|
||||
|
||||
const SOP_CLASS_UIDS = {
|
||||
VL_WHOLE_SLIDE_MICROSCOPY_IMAGE_STORAGE: '1.2.840.10008.5.1.4.1.1.77.1.6',
|
||||
};
|
||||
|
||||
const SOPClassHandlerId =
|
||||
'@ohif/extension-cornerstone.sopClassHandlerModule.DicomMicroscopySopClassHandler';
|
||||
|
||||
function _getDisplaySetsFromSeries(instances, servicesManager, extensionManager) {
|
||||
// If the series has no instances, stop here
|
||||
if (!instances || !instances.length) {
|
||||
throw new Error('No instances were provided');
|
||||
}
|
||||
|
||||
const instance = instances[0];
|
||||
|
||||
let singleFrameInstance = instance;
|
||||
let currentFrames = +singleFrameInstance.NumberOfFrames || 1;
|
||||
for (const instanceI of instances) {
|
||||
const framesI = +instanceI.NumberOfFrames || 1;
|
||||
if (framesI < currentFrames) {
|
||||
singleFrameInstance = instanceI;
|
||||
currentFrames = framesI;
|
||||
}
|
||||
}
|
||||
let imageIdForThumbnail = null;
|
||||
const dataSource = extensionManager.getActiveDataSource()[0];
|
||||
if (singleFrameInstance) {
|
||||
if (currentFrames == 1) {
|
||||
// Not all DICOM server implementations support thumbnail service,
|
||||
// So if we have a single-frame image, we will prefer it.
|
||||
imageIdForThumbnail = singleFrameInstance.imageId;
|
||||
}
|
||||
if (!imageIdForThumbnail) {
|
||||
// use the thumbnail service provided by DICOM server
|
||||
imageIdForThumbnail = dataSource.getImageIdsForInstance({
|
||||
instance: singleFrameInstance,
|
||||
thumbnail: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
FrameOfReferenceUID,
|
||||
SeriesDescription,
|
||||
ContentDate,
|
||||
ContentTime,
|
||||
SeriesNumber,
|
||||
StudyInstanceUID,
|
||||
SeriesInstanceUID,
|
||||
SOPInstanceUID,
|
||||
SOPClassUID,
|
||||
} = instance;
|
||||
|
||||
instances = instances.map(inst => {
|
||||
// NOTE: According to DICOM standard a series should have a FrameOfReferenceUID
|
||||
// When the Microscopy file was built by certain tool from multiple image files,
|
||||
// each instance's FrameOfReferenceUID is sometimes different.
|
||||
// Even though this means the file was not well formatted DICOM VL Whole Slide Microscopy Image,
|
||||
// the case is so often, so let's override this value manually here.
|
||||
//
|
||||
// https://dicom.nema.org/medical/dicom/current/output/chtml/part03/sect_C.7.4.html#sect_C.7.4.1.1.1
|
||||
|
||||
inst.FrameOfReferenceUID = instance.FrameOfReferenceUID;
|
||||
|
||||
return inst;
|
||||
});
|
||||
|
||||
const othersFrameOfReferenceUID = instances
|
||||
.filter(v => v)
|
||||
.map(inst => inst.FrameOfReferenceUID)
|
||||
.filter((value, index, array) => array.indexOf(value) === index);
|
||||
if (othersFrameOfReferenceUID.length > 1) {
|
||||
console.warn(
|
||||
'Expected FrameOfReferenceUID of difference instances within a series to be the same, found multiple different values',
|
||||
othersFrameOfReferenceUID
|
||||
);
|
||||
}
|
||||
|
||||
const displaySet = {
|
||||
plugin: 'microscopy',
|
||||
Modality: 'SM',
|
||||
viewportType: csEnums.ViewportType.WHOLE_SLIDE,
|
||||
altImageText: 'Microscopy',
|
||||
displaySetInstanceUID: utils.guid(),
|
||||
SOPInstanceUID,
|
||||
SeriesInstanceUID,
|
||||
StudyInstanceUID,
|
||||
FrameOfReferenceUID,
|
||||
SOPClassHandlerId,
|
||||
SOPClassUID,
|
||||
SeriesDescription: SeriesDescription || 'Microscopy Data',
|
||||
// Map ContentDate/Time to SeriesTime for series list sorting.
|
||||
SeriesDate: ContentDate,
|
||||
SeriesTime: ContentTime,
|
||||
SeriesNumber,
|
||||
firstInstance: singleFrameInstance, // top level instance in the image Pyramid
|
||||
instance,
|
||||
numImageFrames: 0,
|
||||
numInstances: 1,
|
||||
imageIdForThumbnail, // thumbnail image
|
||||
others: instances, // all other level instances in the image Pyramid
|
||||
instances,
|
||||
othersFrameOfReferenceUID,
|
||||
imageIds: instances.map(instance => instance.imageId),
|
||||
};
|
||||
// The microscopy viewer directly accesses the metadata already loaded, and
|
||||
// uses the DICOMweb client library directly for loading, so it has to be
|
||||
// provided here.
|
||||
const dicomWebClient = dataSource.retrieve.getWadoDicomWebClient?.();
|
||||
const instanceMap = new Map();
|
||||
instances.forEach(instance => instanceMap.set(instance.imageId, instance));
|
||||
if (dicomWebClient) {
|
||||
const webClient = Object.create(dicomWebClient);
|
||||
// This replaces just the dicom web metadata call with one which retrieves
|
||||
// internally.
|
||||
webClient.getDICOMwebMetadata = getDICOMwebMetadata.bind(webClient, instanceMap);
|
||||
|
||||
csUtils.genericMetadataProvider.addRaw(displaySet.imageIds[0], {
|
||||
type: MetadataModules.WADO_WEB_CLIENT,
|
||||
metadata: webClient,
|
||||
});
|
||||
} else {
|
||||
// Might have some other way of getting the data in the future or internally?
|
||||
// throw new Error('Unable to provide a DICOMWeb client library, microscopy will fail to view');
|
||||
}
|
||||
|
||||
return [displaySet];
|
||||
}
|
||||
|
||||
/**
|
||||
* This method provides access to the internal DICOMweb metadata, used to avoid
|
||||
* refetching the DICOMweb data. It gets assigned as a member function to the
|
||||
* dicom web client.
|
||||
*/
|
||||
function getDICOMwebMetadata(instanceMap, imageId) {
|
||||
const instance = instanceMap.get(imageId);
|
||||
if (!instance) {
|
||||
console.warn('Metadata not already found for', imageId, 'in', instanceMap);
|
||||
return this.super.getDICOMwebMetadata(imageId);
|
||||
}
|
||||
return transferDenaturalizedDataset(
|
||||
denaturalizeDataset(fixMultiValueKeys(instanceMap.get(imageId)))
|
||||
);
|
||||
}
|
||||
|
||||
export function getDicomMicroscopySopClassHandler({ servicesManager, extensionManager }) {
|
||||
const getDisplaySetsFromSeries = instances => {
|
||||
return _getDisplaySetsFromSeries(instances, servicesManager, extensionManager);
|
||||
};
|
||||
|
||||
return {
|
||||
name: 'DicomMicroscopySopClassHandler',
|
||||
sopClassUids: [SOP_CLASS_UIDS.VL_WHOLE_SLIDE_MICROSCOPY_IMAGE_STORAGE],
|
||||
getDisplaySetsFromSeries,
|
||||
};
|
||||
}
|
||||
|
||||
export function getSopClassHandlerModule(params) {
|
||||
return [getDicomMicroscopySopClassHandler(params)];
|
||||
}
|
||||
309
extensions/cornerstone/src/getToolbarModule.tsx
Normal file
309
extensions/cornerstone/src/getToolbarModule.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import { Enums } from '@cornerstonejs/tools';
|
||||
|
||||
const getToggledClassName = (isToggled: boolean) => {
|
||||
return isToggled
|
||||
? '!text-primary-active'
|
||||
: '!text-common-bright hover:!bg-primary-dark hover:text-primary-light';
|
||||
};
|
||||
|
||||
const getDisabledState = (disabledText?: string) => ({
|
||||
disabled: true,
|
||||
className: '!text-common-bright ohif-disabled',
|
||||
disabledText: disabledText ?? 'Not available on the current viewport',
|
||||
});
|
||||
|
||||
export default function getToolbarModule({ commandsManager, servicesManager }: withAppTypes) {
|
||||
const {
|
||||
toolGroupService,
|
||||
toolbarService,
|
||||
syncGroupService,
|
||||
cornerstoneViewportService,
|
||||
hangingProtocolService,
|
||||
displaySetService,
|
||||
viewportGridService,
|
||||
} = servicesManager.services;
|
||||
|
||||
return [
|
||||
// functions/helpers to be used by the toolbar buttons to decide if they should
|
||||
// enabled or not
|
||||
{
|
||||
name: 'evaluate.viewport.supported',
|
||||
evaluate: ({ viewportId, unsupportedViewportTypes, disabledText }) => {
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
|
||||
if (viewport && unsupportedViewportTypes?.includes(viewport.type)) {
|
||||
return getDisabledState(disabledText);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'evaluate.modality.supported',
|
||||
evaluate: ({ viewportId, unsupportedModalities, supportedModalities, disabledText }) => {
|
||||
const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId);
|
||||
|
||||
if (!displaySetUIDs?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const displaySets = displaySetUIDs.map(displaySetService.getDisplaySetByUID);
|
||||
|
||||
// Check for unsupported modalities (exclusion)
|
||||
if (unsupportedModalities?.length) {
|
||||
const hasUnsupportedModality = displaySets.some(displaySet =>
|
||||
unsupportedModalities.includes(displaySet?.Modality)
|
||||
);
|
||||
|
||||
if (hasUnsupportedModality) {
|
||||
return getDisabledState(disabledText);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for supported modalities (inclusion)
|
||||
if (supportedModalities?.length) {
|
||||
const hasAnySupportedModality = displaySets.some(displaySet =>
|
||||
supportedModalities.includes(displaySet?.Modality)
|
||||
);
|
||||
|
||||
if (!hasAnySupportedModality) {
|
||||
return getDisabledState(disabledText || 'Tool not available for this modality');
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'evaluate.cornerstoneTool',
|
||||
evaluate: ({ viewportId, button, toolNames, disabledText }) => {
|
||||
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
|
||||
|
||||
if (!toolGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toolName = toolbarService.getToolNameForButton(button);
|
||||
|
||||
if (!toolGroup || (!toolGroup.hasTool(toolName) && !toolNames)) {
|
||||
return getDisabledState(disabledText);
|
||||
}
|
||||
|
||||
const isPrimaryActive = toolNames
|
||||
? toolNames.includes(toolGroup.getActivePrimaryMouseButtonTool())
|
||||
: toolGroup.getActivePrimaryMouseButtonTool() === toolName;
|
||||
|
||||
return {
|
||||
disabled: false,
|
||||
className: isPrimaryActive
|
||||
? '!text-black bg-primary-light rounded'
|
||||
: '!text-common-bright hover:!bg-primary-dark hover:!text-primary-light rounded',
|
||||
// Todo: isActive right now is used for nested buttons where the primary
|
||||
// button needs to be fully rounded (vs partial rounded) when active
|
||||
// otherwise it does not have any other use
|
||||
isActive: isPrimaryActive,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'evaluate.group.promoteToPrimaryIfCornerstoneToolNotActiveInTheList',
|
||||
evaluate: ({ viewportId, button, itemId }) => {
|
||||
const { items } = button.props;
|
||||
|
||||
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
|
||||
|
||||
if (!toolGroup) {
|
||||
return {
|
||||
primary: button.props.primary,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
const activeToolName = toolGroup.getActivePrimaryMouseButtonTool();
|
||||
|
||||
// check if the active toolName is part of the items then we need
|
||||
// to move it to the primary button
|
||||
const activeToolIndex = items.findIndex(item => {
|
||||
const toolName = toolbarService.getToolNameForButton(item);
|
||||
return toolName === activeToolName;
|
||||
});
|
||||
|
||||
// if there is an active tool in the items dropdown bound to the primary mouse/touch
|
||||
// we should show that no matter what
|
||||
if (activeToolIndex > -1) {
|
||||
return {
|
||||
primary: items[activeToolIndex],
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
if (!itemId) {
|
||||
return {
|
||||
primary: button.props.primary,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
// other wise we can move the clicked tool to the primary button
|
||||
const clickedItemProps = items.find(item => item.id === itemId || item.itemId === itemId);
|
||||
|
||||
return {
|
||||
primary: clickedItemProps,
|
||||
items,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'evaluate.action',
|
||||
evaluate: ({ viewportId, button }) => {
|
||||
return {
|
||||
className: '!text-common-bright hover:!bg-primary-dark hover:text-primary-light',
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'evaluate.cornerstoneTool.toggle.ifStrictlyDisabled',
|
||||
evaluate: ({ viewportId, button, disabledText }) =>
|
||||
_evaluateToggle({
|
||||
viewportId,
|
||||
button,
|
||||
toolbarService,
|
||||
disabledText,
|
||||
offModes: [Enums.ToolModes.Disabled],
|
||||
toolGroupService,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'evaluate.cornerstoneTool.toggle',
|
||||
evaluate: ({ viewportId, button, disabledText }) =>
|
||||
_evaluateToggle({
|
||||
viewportId,
|
||||
button,
|
||||
toolbarService,
|
||||
disabledText,
|
||||
offModes: [Enums.ToolModes.Disabled, Enums.ToolModes.Passive],
|
||||
toolGroupService,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'evaluate.cornerstone.synchronizer',
|
||||
evaluate: ({ viewportId, button }) => {
|
||||
let synchronizers = syncGroupService.getSynchronizersForViewport(viewportId);
|
||||
|
||||
if (!synchronizers?.length) {
|
||||
return {
|
||||
className: getToggledClassName(false),
|
||||
};
|
||||
}
|
||||
|
||||
const isArray = Array.isArray(button.commands);
|
||||
|
||||
const synchronizerType = isArray
|
||||
? button.commands?.[0].commandOptions.type
|
||||
: button.commands?.commandOptions.type;
|
||||
|
||||
synchronizers = syncGroupService.getSynchronizersOfType(synchronizerType);
|
||||
|
||||
if (!synchronizers?.length) {
|
||||
return {
|
||||
className: getToggledClassName(false),
|
||||
};
|
||||
}
|
||||
|
||||
// Todo: we need a better way to find the synchronizers based on their
|
||||
// type, but for now we just check the first one and see if it is
|
||||
// enabled
|
||||
const synchronizer = synchronizers[0];
|
||||
|
||||
const isEnabled = synchronizer?._enabled;
|
||||
|
||||
return {
|
||||
className: getToggledClassName(isEnabled),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'evaluate.viewportProperties.toggle',
|
||||
evaluate: ({ viewportId, button }) => {
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
|
||||
if (!viewport || viewport.isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const propId = button.id;
|
||||
|
||||
const properties = viewport.getProperties();
|
||||
const camera = viewport.getCamera();
|
||||
|
||||
const prop = camera?.[propId] || properties?.[propId];
|
||||
|
||||
if (!prop) {
|
||||
return {
|
||||
disabled: false,
|
||||
className: '!text-common-bright hover:!bg-primary-dark hover:text-primary-light',
|
||||
};
|
||||
}
|
||||
|
||||
const isToggled = prop;
|
||||
|
||||
return {
|
||||
className: getToggledClassName(isToggled),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'evaluate.mpr',
|
||||
evaluate: ({ viewportId, disabledText = 'Selected viewport is not reconstructable' }) => {
|
||||
const { protocol } = hangingProtocolService.getActiveProtocol();
|
||||
|
||||
const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId);
|
||||
|
||||
if (!displaySetUIDs?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const displaySets = displaySetUIDs.map(displaySetService.getDisplaySetByUID);
|
||||
|
||||
const areReconstructable = displaySets.every(displaySet => {
|
||||
return displaySet?.isReconstructable;
|
||||
});
|
||||
|
||||
if (!areReconstructable) {
|
||||
return getDisabledState(disabledText);
|
||||
}
|
||||
|
||||
const isMpr = protocol?.id === 'mpr';
|
||||
|
||||
return {
|
||||
disabled: false,
|
||||
className: getToggledClassName(isMpr),
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function _evaluateToggle({
|
||||
viewportId,
|
||||
toolbarService,
|
||||
button,
|
||||
disabledText,
|
||||
offModes,
|
||||
toolGroupService,
|
||||
}) {
|
||||
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
|
||||
|
||||
if (!toolGroup) {
|
||||
return;
|
||||
}
|
||||
const toolName = toolbarService.getToolNameForButton(button);
|
||||
|
||||
if (!toolGroup.hasTool(toolName)) {
|
||||
return getDisabledState(disabledText);
|
||||
}
|
||||
|
||||
const isOff = offModes.includes(toolGroup.getToolOptions(toolName).mode);
|
||||
|
||||
return {
|
||||
className: getToggledClassName(!isOff),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import debounce from 'lodash.debounce';
|
||||
import { roundNumber } from '@ohif/core/src/utils';
|
||||
import {
|
||||
SegmentationData,
|
||||
SegmentationRepresentation,
|
||||
} from '../services/SegmentationService/SegmentationService';
|
||||
|
||||
const excludedModalities = ['SM', 'OT', 'DOC', 'ECG'];
|
||||
|
||||
function mapSegmentationToDisplay(segmentation, customizationService) {
|
||||
const { label, segments } = segmentation;
|
||||
|
||||
// Get the readable text mapping once
|
||||
const { readableText: readableTextMap } = customizationService.getCustomization(
|
||||
'PanelSegmentation.readableText',
|
||||
{}
|
||||
);
|
||||
|
||||
// Helper function to recursively map cachedStats to readable display text
|
||||
function mapStatsToDisplay(stats, indent = 0) {
|
||||
const primary = [];
|
||||
const indentation = ' '.repeat(indent);
|
||||
|
||||
for (const key in stats) {
|
||||
if (Object.prototype.hasOwnProperty.call(stats, key)) {
|
||||
const value = stats[key];
|
||||
const readableText = readableTextMap?.[key];
|
||||
|
||||
if (!readableText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
// Add empty row before category (except for the first category)
|
||||
if (primary.length > 0) {
|
||||
primary.push('');
|
||||
}
|
||||
// Add category title
|
||||
primary.push(`${indentation}${readableText}`);
|
||||
// Recursively handle nested objects
|
||||
primary.push(...mapStatsToDisplay(value, indent + 1));
|
||||
} else {
|
||||
// For non-nested values, don't add empty rows
|
||||
primary.push(`${indentation}${readableText}: ${roundNumber(value, 2)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return primary;
|
||||
}
|
||||
|
||||
// Get customization for display text mapping
|
||||
const displayTextMapper = segment => {
|
||||
const defaultDisplay = {
|
||||
primary: [],
|
||||
secondary: [],
|
||||
};
|
||||
|
||||
// If the segment has cachedStats, map it to readable text
|
||||
if (segment.cachedStats) {
|
||||
const primary = mapStatsToDisplay(segment.cachedStats);
|
||||
defaultDisplay.primary = primary;
|
||||
}
|
||||
|
||||
return defaultDisplay;
|
||||
};
|
||||
|
||||
const updatedSegments = {};
|
||||
|
||||
Object.entries(segments).forEach(([segmentIndex, segment]) => {
|
||||
updatedSegments[segmentIndex] = {
|
||||
...segment,
|
||||
displayText: displayTextMapper(segment),
|
||||
};
|
||||
});
|
||||
|
||||
// Map the segments and apply the display text mapper
|
||||
return {
|
||||
...segmentation,
|
||||
label,
|
||||
segments: updatedSegments,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the combination of segmentation data and its representation in a viewport.
|
||||
*/
|
||||
type ViewportSegmentationRepresentation = {
|
||||
segmentationsWithRepresentations: {
|
||||
representation: SegmentationRepresentation;
|
||||
segmentation: SegmentationData;
|
||||
}[];
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook that provides segmentation data and their representations for the active viewport.
|
||||
* @param options - The options object.
|
||||
* @param options.servicesManager - The services manager object.
|
||||
* @param options.subscribeToDataModified - Whether to subscribe to segmentation data modifications.
|
||||
* @param options.debounceTime - Debounce time in milliseconds for updates.
|
||||
* @returns An array of segmentation data and their representations for the active viewport.
|
||||
*/
|
||||
export function useActiveViewportSegmentationRepresentations({
|
||||
servicesManager,
|
||||
subscribeToDataModified = false,
|
||||
debounceTime = 0,
|
||||
}: withAppTypes<{ debounceTime?: number }>): ViewportSegmentationRepresentation {
|
||||
const { segmentationService, viewportGridService, customizationService, displaySetService } =
|
||||
servicesManager.services;
|
||||
|
||||
const [segmentationsWithRepresentations, setSegmentationsWithRepresentations] =
|
||||
useState<ViewportSegmentationRepresentation>({
|
||||
segmentationsWithRepresentations: [],
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const viewportId = viewportGridService.getActiveViewportId();
|
||||
const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId);
|
||||
|
||||
if (!displaySetUIDs?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const displaySet = displaySetService.getDisplaySetByUID(displaySetUIDs[0]);
|
||||
|
||||
if (!displaySet) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (excludedModalities.includes(displaySet.Modality)) {
|
||||
setSegmentationsWithRepresentations(prev => ({
|
||||
segmentationsWithRepresentations: [],
|
||||
disabled: true,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const segmentations = segmentationService.getSegmentations();
|
||||
|
||||
if (!segmentations?.length) {
|
||||
setSegmentationsWithRepresentations(prev => ({
|
||||
segmentationsWithRepresentations: [],
|
||||
disabled: false,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const representations = segmentationService.getSegmentationRepresentations(viewportId);
|
||||
|
||||
const newSegmentationsWithRepresentations = representations.map(representation => {
|
||||
const segmentation = segmentationService.getSegmentation(representation.segmentationId);
|
||||
const mappedSegmentation = mapSegmentationToDisplay(segmentation, customizationService);
|
||||
return {
|
||||
representation,
|
||||
segmentation: mappedSegmentation,
|
||||
};
|
||||
});
|
||||
|
||||
setSegmentationsWithRepresentations({
|
||||
segmentationsWithRepresentations: newSegmentationsWithRepresentations,
|
||||
disabled: false,
|
||||
});
|
||||
};
|
||||
|
||||
const debouncedUpdate =
|
||||
debounceTime > 0 ? debounce(update, debounceTime, { leading: true, trailing: true }) : update;
|
||||
|
||||
update();
|
||||
|
||||
const subscriptions = [
|
||||
segmentationService.subscribe(
|
||||
segmentationService.EVENTS.SEGMENTATION_MODIFIED,
|
||||
debouncedUpdate
|
||||
),
|
||||
segmentationService.subscribe(
|
||||
segmentationService.EVENTS.SEGMENTATION_REMOVED,
|
||||
debouncedUpdate
|
||||
),
|
||||
segmentationService.subscribe(
|
||||
segmentationService.EVENTS.SEGMENTATION_REPRESENTATION_MODIFIED,
|
||||
debouncedUpdate
|
||||
),
|
||||
viewportGridService.subscribe(
|
||||
viewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED,
|
||||
debouncedUpdate
|
||||
),
|
||||
viewportGridService.subscribe(viewportGridService.EVENTS.GRID_STATE_CHANGED, debouncedUpdate),
|
||||
];
|
||||
|
||||
if (subscribeToDataModified) {
|
||||
subscriptions.push(
|
||||
segmentationService.subscribe(
|
||||
segmentationService.EVENTS.SEGMENTATION_DATA_MODIFIED,
|
||||
debouncedUpdate
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
subscriptions.forEach(subscription => subscription.unsubscribe());
|
||||
if (debounceTime > 0) {
|
||||
debouncedUpdate.cancel();
|
||||
}
|
||||
};
|
||||
}, [
|
||||
segmentationService,
|
||||
viewportGridService,
|
||||
customizationService,
|
||||
displaySetService,
|
||||
debounceTime,
|
||||
subscribeToDataModified,
|
||||
]);
|
||||
|
||||
return segmentationsWithRepresentations;
|
||||
}
|
||||
103
extensions/cornerstone/src/hooks/useMeasurements.ts
Normal file
103
extensions/cornerstone/src/hooks/useMeasurements.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import debounce from 'lodash.debounce';
|
||||
|
||||
function mapMeasurementToDisplay(measurement, displaySetService) {
|
||||
const { referenceSeriesUID } = measurement;
|
||||
|
||||
const displaySets = displaySetService.getDisplaySetsForSeries(referenceSeriesUID);
|
||||
|
||||
if (!displaySets[0]?.instances) {
|
||||
throw new Error('The tracked measurements panel should only be tracking "stack" displaySets.');
|
||||
}
|
||||
|
||||
const { findingSites, finding, label: baseLabel, displayText: baseDisplayText } = measurement;
|
||||
|
||||
const firstSite = findingSites?.[0];
|
||||
const label = baseLabel || finding?.text || firstSite?.text || '(empty)';
|
||||
|
||||
// Initialize displayText with the structure used in Length.ts and CobbAngle.ts
|
||||
const displayText = {
|
||||
primary: [],
|
||||
secondary: baseDisplayText?.secondary || [],
|
||||
};
|
||||
|
||||
// Add baseDisplayText to primary if it exists
|
||||
if (baseDisplayText) {
|
||||
displayText.primary.push(...baseDisplayText.primary);
|
||||
}
|
||||
|
||||
// Add finding sites to primary
|
||||
if (findingSites) {
|
||||
findingSites.forEach(site => {
|
||||
if (site?.text && site.text !== label) {
|
||||
displayText.primary.push(site.text);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add finding to primary if it's different from the label
|
||||
if (finding && finding.text && finding.text !== label) {
|
||||
displayText.primary.push(finding.text);
|
||||
}
|
||||
|
||||
return {
|
||||
...measurement,
|
||||
displayText,
|
||||
label,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A custom hook that provides mapped measurements based on the given services and filters.
|
||||
*
|
||||
* @param {Object} servicesManager - The services manager object.
|
||||
* @param {Object} options - The options for filtering and mapping measurements.
|
||||
* @param {Function} options.measurementFilter - Optional function to filter measurements.
|
||||
* @param {Object} options.valueTypes - The value types for mapping measurements.
|
||||
* @returns {Array} An array of mapped and filtered measurements.
|
||||
*/
|
||||
export function useMeasurements(servicesManager, { measurementFilter }) {
|
||||
const { measurementService, displaySetService } = servicesManager.services;
|
||||
const [displayMeasurements, setDisplayMeasurements] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateDisplayMeasurements = () => {
|
||||
let measurements = measurementService.getMeasurements();
|
||||
if (measurementFilter) {
|
||||
measurements = measurements.filter(measurementFilter);
|
||||
}
|
||||
const mappedMeasurements = measurements.map(m =>
|
||||
mapMeasurementToDisplay(m, displaySetService)
|
||||
);
|
||||
setDisplayMeasurements(prevMeasurements => {
|
||||
if (JSON.stringify(prevMeasurements) !== JSON.stringify(mappedMeasurements)) {
|
||||
return mappedMeasurements;
|
||||
}
|
||||
return prevMeasurements;
|
||||
});
|
||||
};
|
||||
|
||||
const debouncedUpdate = debounce(updateDisplayMeasurements, 100);
|
||||
|
||||
updateDisplayMeasurements();
|
||||
|
||||
const events = [
|
||||
measurementService.EVENTS.MEASUREMENT_ADDED,
|
||||
measurementService.EVENTS.RAW_MEASUREMENT_ADDED,
|
||||
measurementService.EVENTS.MEASUREMENT_UPDATED,
|
||||
measurementService.EVENTS.MEASUREMENT_REMOVED,
|
||||
measurementService.EVENTS.MEASUREMENTS_CLEARED,
|
||||
];
|
||||
|
||||
const subscriptions = events.map(
|
||||
evt => measurementService.subscribe(evt, debouncedUpdate).unsubscribe
|
||||
);
|
||||
|
||||
return () => {
|
||||
subscriptions.forEach(unsub => unsub());
|
||||
debouncedUpdate.cancel();
|
||||
};
|
||||
}, [measurementService, measurementFilter, displaySetService]);
|
||||
|
||||
return displayMeasurements;
|
||||
}
|
||||
148
extensions/cornerstone/src/hooks/useSegmentations.ts
Normal file
148
extensions/cornerstone/src/hooks/useSegmentations.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import debounce from 'lodash.debounce';
|
||||
import { roundNumber } from '@ohif/core/src/utils';
|
||||
import { SegmentationData } from '../services/SegmentationService/SegmentationService';
|
||||
|
||||
function mapSegmentationToDisplay(segmentation, customizationService) {
|
||||
const { label, segments } = segmentation;
|
||||
|
||||
// Get the readable text mapping once
|
||||
const { readableText: readableTextMap } = customizationService.getCustomization(
|
||||
'PanelSegmentation.readableText',
|
||||
{}
|
||||
);
|
||||
|
||||
// Helper function to recursively map cachedStats to readable display text
|
||||
function mapStatsToDisplay(stats, indent = 0) {
|
||||
const primary = [];
|
||||
const indentation = ' '.repeat(indent);
|
||||
|
||||
for (const key in stats) {
|
||||
if (Object.prototype.hasOwnProperty.call(stats, key)) {
|
||||
const value = stats[key];
|
||||
const readableText = readableTextMap?.[key];
|
||||
|
||||
if (!readableText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
// Add empty row before category (except for the first category)
|
||||
if (primary.length > 0) {
|
||||
primary.push('');
|
||||
}
|
||||
// Add category title
|
||||
primary.push(`${indentation}${readableText}`);
|
||||
// Recursively handle nested objects
|
||||
primary.push(...mapStatsToDisplay(value, indent + 1));
|
||||
} else {
|
||||
// For non-nested values, don't add empty rows
|
||||
primary.push(`${indentation}${readableText}: ${roundNumber(value, 2)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return primary;
|
||||
}
|
||||
|
||||
// Get customization for display text mapping
|
||||
const displayTextMapper = segment => {
|
||||
const defaultDisplay = {
|
||||
primary: [],
|
||||
secondary: [],
|
||||
};
|
||||
|
||||
// If the segment has cachedStats, map it to readable text
|
||||
if (segment.cachedStats) {
|
||||
const primary = mapStatsToDisplay(segment.cachedStats);
|
||||
defaultDisplay.primary = primary;
|
||||
}
|
||||
|
||||
return defaultDisplay;
|
||||
};
|
||||
|
||||
const updatedSegments = {};
|
||||
|
||||
Object.entries(segments).forEach(([segmentIndex, segment]) => {
|
||||
updatedSegments[segmentIndex] = {
|
||||
...segment,
|
||||
displayText: displayTextMapper(segment),
|
||||
};
|
||||
});
|
||||
|
||||
// Map the segments and apply the display text mapper
|
||||
return {
|
||||
...segmentation,
|
||||
label,
|
||||
segments: updatedSegments,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook that provides segmentation data.
|
||||
* @param options - The options object.
|
||||
* @param options.servicesManager - The services manager object.
|
||||
* @param options.subscribeToDataModified - Whether to subscribe to segmentation data modifications.
|
||||
* @param options.debounceTime - Debounce time in milliseconds for updates.
|
||||
* @returns An array of segmentation data.
|
||||
*/
|
||||
export function useSegmentations({
|
||||
servicesManager,
|
||||
subscribeToDataModified = false,
|
||||
debounceTime = 0,
|
||||
}: withAppTypes<{ debounceTime?: number }>): SegmentationData[] {
|
||||
const { segmentationService, customizationService } = servicesManager.services;
|
||||
|
||||
const [segmentations, setSegmentations] = useState<SegmentationData[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const segmentations = segmentationService.getSegmentations();
|
||||
|
||||
if (!segmentations?.length) {
|
||||
setSegmentations([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const mappedSegmentations = segmentations.map(segmentation =>
|
||||
mapSegmentationToDisplay(segmentation, customizationService)
|
||||
);
|
||||
|
||||
setSegmentations(mappedSegmentations);
|
||||
};
|
||||
|
||||
const debouncedUpdate =
|
||||
debounceTime > 0 ? debounce(update, debounceTime, { leading: true, trailing: true }) : update;
|
||||
|
||||
update();
|
||||
|
||||
const subscriptions = [
|
||||
segmentationService.subscribe(
|
||||
segmentationService.EVENTS.SEGMENTATION_MODIFIED,
|
||||
debouncedUpdate
|
||||
),
|
||||
segmentationService.subscribe(
|
||||
segmentationService.EVENTS.SEGMENTATION_REMOVED,
|
||||
debouncedUpdate
|
||||
),
|
||||
];
|
||||
|
||||
if (subscribeToDataModified) {
|
||||
subscriptions.push(
|
||||
segmentationService.subscribe(
|
||||
segmentationService.EVENTS.SEGMENTATION_DATA_MODIFIED,
|
||||
debouncedUpdate
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
subscriptions.forEach(subscription => subscription.unsubscribe());
|
||||
if (debounceTime > 0) {
|
||||
debouncedUpdate.cancel();
|
||||
}
|
||||
};
|
||||
}, [segmentationService, customizationService, debounceTime, subscribeToDataModified]);
|
||||
|
||||
return segmentations;
|
||||
}
|
||||
144
extensions/cornerstone/src/hps/fourUp.ts
Normal file
144
extensions/cornerstone/src/hps/fourUp.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
export const fourUp = {
|
||||
id: 'fourUp',
|
||||
locked: true,
|
||||
name: '3D four up',
|
||||
icon: 'layout-advanced-3d-four-up',
|
||||
isPreset: true,
|
||||
createdDate: '2023-03-15T10:29:44.894Z',
|
||||
modifiedDate: '2023-03-15T10:29:44.894Z',
|
||||
availableTo: {},
|
||||
editableBy: {},
|
||||
protocolMatchingRules: [],
|
||||
imageLoadStrategy: 'interleaveCenter',
|
||||
displaySetSelectors: {
|
||||
activeDisplaySet: {
|
||||
seriesMatchingRules: [
|
||||
{
|
||||
weight: 1,
|
||||
attribute: 'isReconstructable',
|
||||
constraint: {
|
||||
equals: {
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
stages: [
|
||||
{
|
||||
id: 'fourUpStage',
|
||||
name: 'fourUp',
|
||||
viewportStructure: {
|
||||
layoutType: 'grid',
|
||||
properties: {
|
||||
rows: 2,
|
||||
columns: 2,
|
||||
},
|
||||
},
|
||||
viewports: [
|
||||
{
|
||||
viewportOptions: {
|
||||
toolGroupId: 'mpr',
|
||||
viewportType: 'volume',
|
||||
orientation: 'axial',
|
||||
initialImageOptions: {
|
||||
preset: 'middle',
|
||||
},
|
||||
syncGroups: [
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'mpr',
|
||||
source: true,
|
||||
target: true,
|
||||
options: {
|
||||
syncColormap: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'activeDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
viewportOptions: {
|
||||
toolGroupId: 'volume3d',
|
||||
viewportType: 'volume3d',
|
||||
orientation: 'coronal',
|
||||
customViewportProps: {
|
||||
hideOverlays: true,
|
||||
},
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'activeDisplaySet',
|
||||
options: {
|
||||
displayPreset: {
|
||||
CT: 'CT-Bone',
|
||||
MR: 'MR-Default',
|
||||
default: 'CT-Bone',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
viewportOptions: {
|
||||
toolGroupId: 'mpr',
|
||||
viewportType: 'volume',
|
||||
orientation: 'coronal',
|
||||
initialImageOptions: {
|
||||
preset: 'middle',
|
||||
},
|
||||
syncGroups: [
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'mpr',
|
||||
source: true,
|
||||
target: true,
|
||||
options: {
|
||||
syncColormap: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'activeDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
viewportOptions: {
|
||||
toolGroupId: 'mpr',
|
||||
viewportType: 'volume',
|
||||
orientation: 'sagittal',
|
||||
initialImageOptions: {
|
||||
preset: 'middle',
|
||||
},
|
||||
syncGroups: [
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'mpr',
|
||||
source: true,
|
||||
target: true,
|
||||
options: {
|
||||
syncColormap: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'activeDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
1566
extensions/cornerstone/src/hps/frameView.ts
Normal file
1566
extensions/cornerstone/src/hps/frameView.ts
Normal file
File diff suppressed because it is too large
Load Diff
170
extensions/cornerstone/src/hps/main3D.ts
Normal file
170
extensions/cornerstone/src/hps/main3D.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
export const main3D = {
|
||||
id: 'main3D',
|
||||
locked: true,
|
||||
name: '3D main',
|
||||
icon: 'layout-advanced-3d-main',
|
||||
isPreset: true,
|
||||
createdDate: '2023-03-15T10:29:44.894Z',
|
||||
modifiedDate: '2023-03-15T10:29:44.894Z',
|
||||
availableTo: {},
|
||||
editableBy: {},
|
||||
protocolMatchingRules: [],
|
||||
imageLoadStrategy: 'interleaveCenter',
|
||||
displaySetSelectors: {
|
||||
activeDisplaySet: {
|
||||
seriesMatchingRules: [
|
||||
{
|
||||
weight: 1,
|
||||
attribute: 'isReconstructable',
|
||||
constraint: {
|
||||
equals: {
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
stages: [
|
||||
{
|
||||
id: 'main3DStage',
|
||||
name: 'main3D',
|
||||
viewportStructure: {
|
||||
layoutType: 'grid',
|
||||
properties: {
|
||||
rows: 2,
|
||||
columns: 3,
|
||||
layoutOptions: [
|
||||
{
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1,
|
||||
height: 1 / 2,
|
||||
},
|
||||
{
|
||||
x: 0,
|
||||
y: 1 / 2,
|
||||
width: 1 / 3,
|
||||
height: 1 / 2,
|
||||
},
|
||||
{
|
||||
x: 1 / 3,
|
||||
y: 1 / 2,
|
||||
width: 1 / 3,
|
||||
height: 1 / 2,
|
||||
},
|
||||
{
|
||||
x: 2 / 3,
|
||||
y: 1 / 2,
|
||||
width: 1 / 3,
|
||||
height: 1 / 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
viewports: [
|
||||
{
|
||||
viewportOptions: {
|
||||
toolGroupId: 'volume3d',
|
||||
viewportType: 'volume3d',
|
||||
orientation: 'coronal',
|
||||
customViewportProps: {
|
||||
hideOverlays: true,
|
||||
},
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'activeDisplaySet',
|
||||
options: {
|
||||
displayPreset: {
|
||||
CT: 'CT-Bone',
|
||||
MR: 'MR-Default',
|
||||
default: 'CT-Bone',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
viewportOptions: {
|
||||
toolGroupId: 'mpr',
|
||||
viewportType: 'volume',
|
||||
orientation: 'axial',
|
||||
initialImageOptions: {
|
||||
preset: 'middle',
|
||||
},
|
||||
syncGroups: [
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'mpr',
|
||||
source: true,
|
||||
target: true,
|
||||
options: {
|
||||
syncColormap: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'activeDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
viewportOptions: {
|
||||
toolGroupId: 'mpr',
|
||||
viewportType: 'volume',
|
||||
orientation: 'coronal',
|
||||
initialImageOptions: {
|
||||
preset: 'middle',
|
||||
},
|
||||
syncGroups: [
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'mpr',
|
||||
source: true,
|
||||
target: true,
|
||||
options: {
|
||||
syncColormap: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'activeDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
viewportOptions: {
|
||||
toolGroupId: 'mpr',
|
||||
viewportType: 'volume',
|
||||
orientation: 'sagittal',
|
||||
initialImageOptions: {
|
||||
preset: 'middle',
|
||||
},
|
||||
syncGroups: [
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'mpr',
|
||||
source: true,
|
||||
target: true,
|
||||
options: {
|
||||
syncColormap: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'activeDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
138
extensions/cornerstone/src/hps/mpr.ts
Normal file
138
extensions/cornerstone/src/hps/mpr.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { Types } from '@ohif/core';
|
||||
|
||||
const VOI_SYNC_GROUP = {
|
||||
type: 'voi',
|
||||
id: 'mpr',
|
||||
source: true,
|
||||
target: true,
|
||||
options: {
|
||||
syncColormap: true,
|
||||
},
|
||||
};
|
||||
|
||||
const HYDRATE_SEG_SYNC_GROUP = {
|
||||
type: 'hydrateseg',
|
||||
id: 'sameFORId',
|
||||
source: true,
|
||||
target: true,
|
||||
options: {
|
||||
matchingRules: ['sameFOR'],
|
||||
},
|
||||
};
|
||||
|
||||
export const mpr: Types.HangingProtocol.Protocol = {
|
||||
id: 'mpr',
|
||||
name: 'MPR',
|
||||
locked: true,
|
||||
icon: 'layout-advanced-mpr',
|
||||
isPreset: true,
|
||||
createdDate: '2021-02-23',
|
||||
modifiedDate: '2023-08-15',
|
||||
availableTo: {},
|
||||
editableBy: {},
|
||||
numberOfPriorsReferenced: 0,
|
||||
protocolMatchingRules: [],
|
||||
imageLoadStrategy: 'nth',
|
||||
callbacks: {},
|
||||
displaySetSelectors: {
|
||||
activeDisplaySet: {
|
||||
seriesMatchingRules: [
|
||||
{
|
||||
weight: 1,
|
||||
attribute: 'isReconstructable',
|
||||
constraint: {
|
||||
equals: {
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
stages: [
|
||||
{
|
||||
name: 'MPR 1x3',
|
||||
viewportStructure: {
|
||||
layoutType: 'grid',
|
||||
properties: {
|
||||
rows: 1,
|
||||
columns: 3,
|
||||
layoutOptions: [
|
||||
{
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1 / 3,
|
||||
height: 1,
|
||||
},
|
||||
{
|
||||
x: 1 / 3,
|
||||
y: 0,
|
||||
width: 1 / 3,
|
||||
height: 1,
|
||||
},
|
||||
{
|
||||
x: 2 / 3,
|
||||
y: 0,
|
||||
width: 1 / 3,
|
||||
height: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
viewports: [
|
||||
{
|
||||
viewportOptions: {
|
||||
viewportId: 'mpr-axial',
|
||||
toolGroupId: 'mpr',
|
||||
viewportType: 'volume',
|
||||
orientation: 'axial',
|
||||
initialImageOptions: {
|
||||
preset: 'middle',
|
||||
},
|
||||
syncGroups: [VOI_SYNC_GROUP, HYDRATE_SEG_SYNC_GROUP],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'activeDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
viewportOptions: {
|
||||
viewportId: 'mpr-sagittal',
|
||||
toolGroupId: 'mpr',
|
||||
viewportType: 'volume',
|
||||
orientation: 'sagittal',
|
||||
initialImageOptions: {
|
||||
preset: 'middle',
|
||||
},
|
||||
syncGroups: [VOI_SYNC_GROUP, HYDRATE_SEG_SYNC_GROUP],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'activeDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
viewportOptions: {
|
||||
viewportId: 'mpr-coronal',
|
||||
toolGroupId: 'mpr',
|
||||
viewportType: 'volume',
|
||||
orientation: 'coronal',
|
||||
initialImageOptions: {
|
||||
preset: 'middle',
|
||||
},
|
||||
syncGroups: [VOI_SYNC_GROUP, HYDRATE_SEG_SYNC_GROUP],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'activeDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
151
extensions/cornerstone/src/hps/mprAnd3DVolumeViewport.ts
Normal file
151
extensions/cornerstone/src/hps/mprAnd3DVolumeViewport.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
export const mprAnd3DVolumeViewport = {
|
||||
id: 'mprAnd3DVolumeViewport',
|
||||
locked: true,
|
||||
name: 'mpr',
|
||||
createdDate: '2023-03-15T10:29:44.894Z',
|
||||
modifiedDate: '2023-03-15T10:29:44.894Z',
|
||||
availableTo: {},
|
||||
editableBy: {},
|
||||
protocolMatchingRules: [],
|
||||
imageLoadStrategy: 'interleaveCenter',
|
||||
displaySetSelectors: {
|
||||
activeDisplaySet: {
|
||||
seriesMatchingRules: [
|
||||
{
|
||||
weight: 1,
|
||||
attribute: 'isReconstructable',
|
||||
constraint: {
|
||||
equals: {
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
attribute: 'Modality',
|
||||
constraint: {
|
||||
equals: {
|
||||
value: 'CT',
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
stages: [
|
||||
{
|
||||
id: 'mpr3Stage',
|
||||
name: 'mpr',
|
||||
viewportStructure: {
|
||||
layoutType: 'grid',
|
||||
properties: {
|
||||
rows: 2,
|
||||
columns: 2,
|
||||
},
|
||||
},
|
||||
viewports: [
|
||||
{
|
||||
viewportOptions: {
|
||||
toolGroupId: 'mpr',
|
||||
viewportType: 'volume',
|
||||
orientation: 'axial',
|
||||
initialImageOptions: {
|
||||
preset: 'middle',
|
||||
},
|
||||
syncGroups: [
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'mpr',
|
||||
source: true,
|
||||
target: true,
|
||||
options: {
|
||||
syncColormap: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'activeDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
viewportOptions: {
|
||||
toolGroupId: 'volume3d',
|
||||
viewportType: 'volume3d',
|
||||
orientation: 'coronal',
|
||||
customViewportProps: {
|
||||
hideOverlays: true,
|
||||
},
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'activeDisplaySet',
|
||||
options: {
|
||||
displayPreset: {
|
||||
CT: 'CT-Bone',
|
||||
MR: 'MR-Default',
|
||||
default: 'CT-Bone',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
viewportOptions: {
|
||||
toolGroupId: 'mpr',
|
||||
viewportType: 'volume',
|
||||
orientation: 'coronal',
|
||||
initialImageOptions: {
|
||||
preset: 'middle',
|
||||
},
|
||||
syncGroups: [
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'mpr',
|
||||
source: true,
|
||||
target: true,
|
||||
options: {
|
||||
syncColormap: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'activeDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
viewportOptions: {
|
||||
toolGroupId: 'mpr',
|
||||
viewportType: 'volume',
|
||||
orientation: 'sagittal',
|
||||
initialImageOptions: {
|
||||
preset: 'middle',
|
||||
},
|
||||
syncGroups: [
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'mpr',
|
||||
source: true,
|
||||
target: true,
|
||||
options: {
|
||||
syncColormap: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'activeDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
66
extensions/cornerstone/src/hps/only3D.ts
Normal file
66
extensions/cornerstone/src/hps/only3D.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
export const only3D = {
|
||||
id: 'only3D',
|
||||
locked: true,
|
||||
name: '3D only',
|
||||
icon: 'layout-advanced-3d-only',
|
||||
isPreset: true,
|
||||
createdDate: '2023-03-15T10:29:44.894Z',
|
||||
modifiedDate: '2023-03-15T10:29:44.894Z',
|
||||
availableTo: {},
|
||||
editableBy: {},
|
||||
protocolMatchingRules: [],
|
||||
imageLoadStrategy: 'interleaveCenter',
|
||||
displaySetSelectors: {
|
||||
activeDisplaySet: {
|
||||
seriesMatchingRules: [
|
||||
{
|
||||
weight: 1,
|
||||
attribute: 'isReconstructable',
|
||||
constraint: {
|
||||
equals: {
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
stages: [
|
||||
{
|
||||
id: 'only3DStage',
|
||||
name: 'only3D',
|
||||
viewportStructure: {
|
||||
layoutType: 'grid',
|
||||
properties: {
|
||||
rows: 1,
|
||||
columns: 1,
|
||||
},
|
||||
},
|
||||
viewports: [
|
||||
{
|
||||
viewportOptions: {
|
||||
toolGroupId: 'volume3d',
|
||||
viewportType: 'volume3d',
|
||||
orientation: 'coronal',
|
||||
customViewportProps: {
|
||||
hideOverlays: true,
|
||||
},
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'activeDisplaySet',
|
||||
options: {
|
||||
displayPreset: {
|
||||
CT: 'CT-Bone',
|
||||
MR: 'MR-Default',
|
||||
default: 'CT-Bone',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
170
extensions/cornerstone/src/hps/primary3D.ts
Normal file
170
extensions/cornerstone/src/hps/primary3D.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
export const primary3D = {
|
||||
id: 'primary3D',
|
||||
locked: true,
|
||||
name: '3D primary',
|
||||
icon: 'layout-advanced-3d-primary',
|
||||
isPreset: true,
|
||||
createdDate: '2023-03-15T10:29:44.894Z',
|
||||
modifiedDate: '2023-03-15T10:29:44.894Z',
|
||||
availableTo: {},
|
||||
editableBy: {},
|
||||
protocolMatchingRules: [],
|
||||
imageLoadStrategy: 'interleaveCenter',
|
||||
displaySetSelectors: {
|
||||
activeDisplaySet: {
|
||||
seriesMatchingRules: [
|
||||
{
|
||||
weight: 1,
|
||||
attribute: 'isReconstructable',
|
||||
constraint: {
|
||||
equals: {
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
stages: [
|
||||
{
|
||||
id: 'primary3DStage',
|
||||
name: 'primary3D',
|
||||
viewportStructure: {
|
||||
layoutType: 'grid',
|
||||
properties: {
|
||||
rows: 3,
|
||||
columns: 3,
|
||||
layoutOptions: [
|
||||
{
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 2 / 3,
|
||||
height: 1,
|
||||
},
|
||||
{
|
||||
x: 2 / 3,
|
||||
y: 0,
|
||||
width: 1 / 3,
|
||||
height: 1 / 3,
|
||||
},
|
||||
{
|
||||
x: 2 / 3,
|
||||
y: 1 / 3,
|
||||
width: 1 / 3,
|
||||
height: 1 / 3,
|
||||
},
|
||||
{
|
||||
x: 2 / 3,
|
||||
y: 2 / 3,
|
||||
width: 1 / 3,
|
||||
height: 1 / 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
viewports: [
|
||||
{
|
||||
viewportOptions: {
|
||||
toolGroupId: 'volume3d',
|
||||
viewportType: 'volume3d',
|
||||
orientation: 'coronal',
|
||||
customViewportProps: {
|
||||
hideOverlays: true,
|
||||
},
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'activeDisplaySet',
|
||||
options: {
|
||||
displayPreset: {
|
||||
CT: 'CT-Bone',
|
||||
MR: 'MR-Default',
|
||||
default: 'CT-Bone',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
viewportOptions: {
|
||||
toolGroupId: 'mpr',
|
||||
viewportType: 'volume',
|
||||
orientation: 'axial',
|
||||
initialImageOptions: {
|
||||
preset: 'middle',
|
||||
},
|
||||
syncGroups: [
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'mpr',
|
||||
source: true,
|
||||
target: true,
|
||||
options: {
|
||||
syncColormap: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'activeDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
viewportOptions: {
|
||||
toolGroupId: 'mpr',
|
||||
viewportType: 'volume',
|
||||
orientation: 'coronal',
|
||||
initialImageOptions: {
|
||||
preset: 'middle',
|
||||
},
|
||||
syncGroups: [
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'mpr',
|
||||
source: true,
|
||||
target: true,
|
||||
options: {
|
||||
syncColormap: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'activeDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
viewportOptions: {
|
||||
toolGroupId: 'mpr',
|
||||
viewportType: 'volume',
|
||||
orientation: 'sagittal',
|
||||
initialImageOptions: {
|
||||
preset: 'middle',
|
||||
},
|
||||
syncGroups: [
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'mpr',
|
||||
source: true,
|
||||
target: true,
|
||||
options: {
|
||||
syncColormap: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'activeDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
142
extensions/cornerstone/src/hps/primaryAxial.ts
Normal file
142
extensions/cornerstone/src/hps/primaryAxial.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
export const primaryAxial = {
|
||||
id: 'primaryAxial',
|
||||
locked: true,
|
||||
name: 'Axial Primary',
|
||||
icon: 'layout-advanced-axial-primary',
|
||||
isPreset: true,
|
||||
createdDate: '2023-03-15T10:29:44.894Z',
|
||||
modifiedDate: '2023-03-15T10:29:44.894Z',
|
||||
availableTo: {},
|
||||
editableBy: {},
|
||||
protocolMatchingRules: [],
|
||||
imageLoadStrategy: 'interleaveCenter',
|
||||
displaySetSelectors: {
|
||||
activeDisplaySet: {
|
||||
seriesMatchingRules: [
|
||||
{
|
||||
weight: 1,
|
||||
attribute: 'isReconstructable',
|
||||
constraint: {
|
||||
equals: {
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
stages: [
|
||||
{
|
||||
id: 'primaryAxialStage',
|
||||
name: 'primaryAxial',
|
||||
viewportStructure: {
|
||||
layoutType: 'grid',
|
||||
properties: {
|
||||
rows: 2,
|
||||
columns: 3,
|
||||
layoutOptions: [
|
||||
{
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 2 / 3,
|
||||
height: 1,
|
||||
},
|
||||
{
|
||||
x: 2 / 3,
|
||||
y: 0,
|
||||
width: 1 / 3,
|
||||
height: 1 / 2,
|
||||
},
|
||||
{
|
||||
x: 2 / 3,
|
||||
y: 1 / 2,
|
||||
width: 1 / 3,
|
||||
height: 1 / 2,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
viewports: [
|
||||
{
|
||||
viewportOptions: {
|
||||
toolGroupId: 'mpr',
|
||||
viewportType: 'volume',
|
||||
orientation: 'axial',
|
||||
initialImageOptions: {
|
||||
preset: 'middle',
|
||||
},
|
||||
syncGroups: [
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'mpr',
|
||||
source: true,
|
||||
target: true,
|
||||
options: {
|
||||
syncColormap: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'activeDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
viewportOptions: {
|
||||
toolGroupId: 'mpr',
|
||||
viewportType: 'volume',
|
||||
orientation: 'sagittal',
|
||||
initialImageOptions: {
|
||||
preset: 'middle',
|
||||
},
|
||||
syncGroups: [
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'mpr',
|
||||
source: true,
|
||||
target: true,
|
||||
options: {
|
||||
syncColormap: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'activeDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
viewportOptions: {
|
||||
toolGroupId: 'mpr',
|
||||
viewportType: 'volume',
|
||||
orientation: 'coronal',
|
||||
initialImageOptions: {
|
||||
preset: 'middle',
|
||||
},
|
||||
syncGroups: [
|
||||
{
|
||||
type: 'voi',
|
||||
id: 'mpr',
|
||||
source: true,
|
||||
target: true,
|
||||
options: {
|
||||
syncColormap: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
displaySets: [
|
||||
{
|
||||
id: 'activeDisplaySet',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
5
extensions/cornerstone/src/id.js
Normal file
5
extensions/cornerstone/src/id.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import packageJson from '../package.json';
|
||||
|
||||
const id = packageJson.name;
|
||||
|
||||
export { id };
|
||||
258
extensions/cornerstone/src/index.tsx
Normal file
258
extensions/cornerstone/src/index.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import React from 'react';
|
||||
import * as cornerstone from '@cornerstonejs/core';
|
||||
import * as cornerstoneTools from '@cornerstonejs/tools';
|
||||
import {
|
||||
Enums as cs3DEnums,
|
||||
imageLoadPoolManager,
|
||||
imageRetrievalPoolManager,
|
||||
} from '@cornerstonejs/core';
|
||||
import { Enums as cs3DToolsEnums } from '@cornerstonejs/tools';
|
||||
import { Types } from '@ohif/core';
|
||||
import Enums from './enums';
|
||||
|
||||
import init from './init';
|
||||
import getCustomizationModule from './getCustomizationModule';
|
||||
import getCommandsModule from './commandsModule';
|
||||
import getHangingProtocolModule from './getHangingProtocolModule';
|
||||
import getToolbarModule from './getToolbarModule';
|
||||
import ToolGroupService from './services/ToolGroupService';
|
||||
import SyncGroupService from './services/SyncGroupService';
|
||||
import SegmentationService from './services/SegmentationService';
|
||||
import CornerstoneCacheService from './services/CornerstoneCacheService';
|
||||
import CornerstoneViewportService from './services/ViewportService/CornerstoneViewportService';
|
||||
import ColorbarService from './services/ColorbarService';
|
||||
import * as CornerstoneExtensionTypes from './types';
|
||||
|
||||
import { toolNames } from './initCornerstoneTools';
|
||||
import { getEnabledElement, reset as enabledElementReset, setEnabledElement } from './state';
|
||||
import dicomLoaderService from './utils/dicomLoaderService';
|
||||
import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement';
|
||||
|
||||
import { id } from './id';
|
||||
import { measurementMappingUtils } from './utils/measurementServiceMappings';
|
||||
import type { PublicViewportOptions } from './services/ViewportService/Viewport';
|
||||
import ImageOverlayViewerTool from './tools/ImageOverlayViewerTool';
|
||||
import ViewportActionCornersService from './services/ViewportActionCornersService/ViewportActionCornersService';
|
||||
import { ViewportActionCornersProvider } from './contextProviders/ViewportActionCornersProvider';
|
||||
import getSOPInstanceAttributes from './utils/measurementServiceMappings/utils/getSOPInstanceAttributes';
|
||||
import { findNearbyToolData } from './utils/findNearbyToolData';
|
||||
import { createFrameViewSynchronizer } from './synchronizers/frameViewSynchronizer';
|
||||
import { getSopClassHandlerModule } from './getSopClassHandlerModule';
|
||||
import { getDynamicVolumeInfo } from '@cornerstonejs/core/utilities';
|
||||
import {
|
||||
useLutPresentationStore,
|
||||
usePositionPresentationStore,
|
||||
useSegmentationPresentationStore,
|
||||
useSynchronizersStore,
|
||||
} from './stores';
|
||||
import { useToggleOneUpViewportGridStore } from '../../default/src/stores/useToggleOneUpViewportGridStore';
|
||||
import { useActiveViewportSegmentationRepresentations } from './hooks/useActiveViewportSegmentationRepresentations';
|
||||
import { useMeasurements } from './hooks/useMeasurements';
|
||||
import getPanelModule from './getPanelModule';
|
||||
import PanelSegmentation from './panels/PanelSegmentation';
|
||||
import PanelMeasurement from './panels/PanelMeasurement';
|
||||
import DicomUpload from './components/DicomUpload/DicomUpload';
|
||||
import { useSegmentations } from './hooks/useSegmentations';
|
||||
|
||||
const { imageRetrieveMetadataProvider } = cornerstone.utilities;
|
||||
|
||||
const Component = React.lazy(() => {
|
||||
return import(/* webpackPrefetch: true */ './Viewport/OHIFCornerstoneViewport');
|
||||
});
|
||||
|
||||
const OHIFCornerstoneViewport = props => {
|
||||
return (
|
||||
<React.Suspense fallback={<div>Loading...</div>}>
|
||||
<Component {...props} />
|
||||
</React.Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
const stackRetrieveOptions = {
|
||||
retrieveOptions: {
|
||||
single: {
|
||||
streaming: true,
|
||||
decodeLevel: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
const cornerstoneExtension: Types.Extensions.Extension = {
|
||||
/**
|
||||
* Only required property. Should be a unique value across all extensions.
|
||||
*/
|
||||
id,
|
||||
|
||||
onModeEnter: ({ servicesManager }: withAppTypes): void => {
|
||||
const { cornerstoneViewportService, toolbarService, segmentationService } =
|
||||
servicesManager.services;
|
||||
toolbarService.registerEventForToolbarUpdate(cornerstoneViewportService, [
|
||||
cornerstoneViewportService.EVENTS.VIEWPORT_DATA_CHANGED,
|
||||
]);
|
||||
|
||||
toolbarService.registerEventForToolbarUpdate(segmentationService, [
|
||||
segmentationService.EVENTS.SEGMENTATION_REMOVED,
|
||||
segmentationService.EVENTS.SEGMENTATION_MODIFIED,
|
||||
]);
|
||||
|
||||
toolbarService.registerEventForToolbarUpdate(cornerstone.eventTarget, [
|
||||
cornerstoneTools.Enums.Events.TOOL_ACTIVATED,
|
||||
]);
|
||||
|
||||
// Configure the interleaved/HTJ2K loader
|
||||
imageRetrieveMetadataProvider.clear();
|
||||
// The default volume interleaved options are to interleave the
|
||||
// image retrieve, but don't perform progressive loading per image
|
||||
// This interleaves images and replicates them for low-resolution depth volume
|
||||
// reconstruction, which progressively improves
|
||||
imageRetrieveMetadataProvider.add(
|
||||
'volume',
|
||||
cornerstone.ProgressiveRetrieveImages.interleavedRetrieveStages
|
||||
);
|
||||
// The default stack loading option is to progressive load HTJ2K images
|
||||
// There are other possible options, but these need more thought about
|
||||
// how to define them.
|
||||
imageRetrieveMetadataProvider.add('stack', stackRetrieveOptions);
|
||||
},
|
||||
getPanelModule,
|
||||
onModeExit: ({ servicesManager }: withAppTypes): void => {
|
||||
const { cineService, segmentationService } = servicesManager.services;
|
||||
// Empty out the image load and retrieval pools to prevent memory leaks
|
||||
// on the mode exits
|
||||
Object.values(cs3DEnums.RequestType).forEach(type => {
|
||||
imageLoadPoolManager.clearRequestStack(type);
|
||||
imageRetrievalPoolManager.clearRequestStack(type);
|
||||
});
|
||||
|
||||
cineService.setIsCineEnabled(false);
|
||||
|
||||
enabledElementReset();
|
||||
|
||||
useLutPresentationStore.getState().clearLutPresentationStore();
|
||||
usePositionPresentationStore.getState().clearPositionPresentationStore();
|
||||
useSynchronizersStore.getState().clearSynchronizersStore();
|
||||
useToggleOneUpViewportGridStore.getState().clearToggleOneUpViewportGridStore();
|
||||
useSegmentationPresentationStore.getState().clearSegmentationPresentationStore();
|
||||
segmentationService.removeAllSegmentations();
|
||||
},
|
||||
|
||||
/**
|
||||
* Register the Cornerstone 3D services and set them up for use.
|
||||
*
|
||||
* @param configuration.csToolsConfig - Passed directly to `initCornerstoneTools`
|
||||
*/
|
||||
preRegistration: function (props: Types.Extensions.ExtensionParams): Promise<void> {
|
||||
const { servicesManager, serviceProvidersManager } = props;
|
||||
servicesManager.registerService(CornerstoneViewportService.REGISTRATION);
|
||||
servicesManager.registerService(ToolGroupService.REGISTRATION);
|
||||
servicesManager.registerService(SyncGroupService.REGISTRATION);
|
||||
servicesManager.registerService(SegmentationService.REGISTRATION);
|
||||
servicesManager.registerService(CornerstoneCacheService.REGISTRATION);
|
||||
servicesManager.registerService(ViewportActionCornersService.REGISTRATION);
|
||||
servicesManager.registerService(ColorbarService.REGISTRATION);
|
||||
|
||||
serviceProvidersManager.registerProvider(
|
||||
ViewportActionCornersService.REGISTRATION.name,
|
||||
ViewportActionCornersProvider
|
||||
);
|
||||
|
||||
const { syncGroupService } = servicesManager.services;
|
||||
syncGroupService.registerCustomSynchronizer('frameview', createFrameViewSynchronizer);
|
||||
servicesManager.services.customizationService.setGlobalCustomization('dicomUploadComponent', {
|
||||
component: props => <DicomUpload {...props} />,
|
||||
});
|
||||
return init.call(this, props);
|
||||
},
|
||||
getToolbarModule,
|
||||
getHangingProtocolModule,
|
||||
getViewportModule({ servicesManager, commandsManager }) {
|
||||
const ExtendedOHIFCornerstoneViewport = props => {
|
||||
// const onNewImageHandler = jumpData => {
|
||||
// commandsManager.runCommand('jumpToImage', jumpData);
|
||||
// };
|
||||
const { toolbarService } = servicesManager.services;
|
||||
|
||||
return (
|
||||
<OHIFCornerstoneViewport
|
||||
{...props}
|
||||
toolbarService={toolbarService}
|
||||
servicesManager={servicesManager}
|
||||
commandsManager={commandsManager}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'cornerstone',
|
||||
component: ExtendedOHIFCornerstoneViewport,
|
||||
},
|
||||
];
|
||||
},
|
||||
getCommandsModule,
|
||||
getCustomizationModule,
|
||||
getUtilityModule({ servicesManager }) {
|
||||
return [
|
||||
{
|
||||
name: 'common',
|
||||
exports: {
|
||||
getCornerstoneLibraries: () => {
|
||||
return { cornerstone, cornerstoneTools };
|
||||
},
|
||||
getEnabledElement,
|
||||
dicomLoaderService,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'core',
|
||||
exports: {
|
||||
Enums: cs3DEnums,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'tools',
|
||||
exports: {
|
||||
toolNames,
|
||||
Enums: cs3DToolsEnums,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'volumeLoader',
|
||||
exports: {
|
||||
getDynamicVolumeInfo,
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
getSopClassHandlerModule,
|
||||
};
|
||||
|
||||
export type { PublicViewportOptions };
|
||||
export {
|
||||
measurementMappingUtils,
|
||||
CornerstoneExtensionTypes as Types,
|
||||
toolNames,
|
||||
getActiveViewportEnabledElement,
|
||||
setEnabledElement,
|
||||
findNearbyToolData,
|
||||
getEnabledElement,
|
||||
ImageOverlayViewerTool,
|
||||
getSOPInstanceAttributes,
|
||||
dicomLoaderService,
|
||||
// Export all stores
|
||||
useLutPresentationStore,
|
||||
usePositionPresentationStore,
|
||||
useSegmentationPresentationStore,
|
||||
useSynchronizersStore,
|
||||
Enums,
|
||||
useMeasurements,
|
||||
useActiveViewportSegmentationRepresentations,
|
||||
useSegmentations,
|
||||
PanelSegmentation,
|
||||
PanelMeasurement,
|
||||
DicomUpload,
|
||||
};
|
||||
export default cornerstoneExtension;
|
||||
333
extensions/cornerstone/src/init.tsx
Normal file
333
extensions/cornerstone/src/init.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
import OHIF, { errorHandler } from '@ohif/core';
|
||||
import React from 'react';
|
||||
|
||||
import * as cornerstone from '@cornerstonejs/core';
|
||||
import * as cornerstoneTools from '@cornerstonejs/tools';
|
||||
import {
|
||||
init as cs3DInit,
|
||||
eventTarget,
|
||||
EVENTS,
|
||||
metaData,
|
||||
volumeLoader,
|
||||
imageLoadPoolManager,
|
||||
getEnabledElement,
|
||||
Settings,
|
||||
utilities as csUtilities,
|
||||
} from '@cornerstonejs/core';
|
||||
import {
|
||||
cornerstoneStreamingImageVolumeLoader,
|
||||
cornerstoneStreamingDynamicImageVolumeLoader,
|
||||
} from '@cornerstonejs/core/loaders';
|
||||
|
||||
import RequestTypes from '@cornerstonejs/core/enums/RequestType';
|
||||
|
||||
import initWADOImageLoader from './initWADOImageLoader';
|
||||
import initCornerstoneTools from './initCornerstoneTools';
|
||||
|
||||
import { connectToolsToMeasurementService } from './initMeasurementService';
|
||||
import initCineService from './initCineService';
|
||||
import initStudyPrefetcherService from './initStudyPrefetcherService';
|
||||
import interleaveCenterLoader from './utils/interleaveCenterLoader';
|
||||
import nthLoader from './utils/nthLoader';
|
||||
import interleaveTopToBottom from './utils/interleaveTopToBottom';
|
||||
import initContextMenu from './initContextMenu';
|
||||
import initDoubleClick from './initDoubleClick';
|
||||
import initViewTiming from './utils/initViewTiming';
|
||||
import { colormaps } from './utils/colormaps';
|
||||
import { SegmentationRepresentations } from '@cornerstonejs/tools/enums';
|
||||
import { useLutPresentationStore } from './stores/useLutPresentationStore';
|
||||
import { usePositionPresentationStore } from './stores/usePositionPresentationStore';
|
||||
import { useSegmentationPresentationStore } from './stores/useSegmentationPresentationStore';
|
||||
|
||||
const { registerColormap } = csUtilities.colormap;
|
||||
|
||||
// TODO: Cypress tests are currently grabbing this from the window?
|
||||
(window as any).cornerstone = cornerstone;
|
||||
(window as any).cornerstoneTools = cornerstoneTools;
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default async function init({
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
extensionManager,
|
||||
appConfig,
|
||||
}: withAppTypes): Promise<void> {
|
||||
// Note: this should run first before initializing the cornerstone
|
||||
// DO NOT CHANGE THE ORDER
|
||||
|
||||
await cs3DInit({
|
||||
peerImport: appConfig.peerImport,
|
||||
});
|
||||
|
||||
// For debugging e2e tests that are failing on CI
|
||||
cornerstone.setUseCPURendering(Boolean(appConfig.useCPURendering));
|
||||
|
||||
cornerstone.setConfiguration({
|
||||
...cornerstone.getConfiguration(),
|
||||
rendering: {
|
||||
...cornerstone.getConfiguration().rendering,
|
||||
strictZSpacingForVolumeViewport: appConfig.strictZSpacingForVolumeViewport,
|
||||
},
|
||||
});
|
||||
|
||||
// For debugging large datasets, otherwise prefer the defaults
|
||||
const { maxCacheSize } = appConfig;
|
||||
if (maxCacheSize) {
|
||||
cornerstone.cache.setMaxCacheSize(maxCacheSize);
|
||||
}
|
||||
|
||||
initCornerstoneTools();
|
||||
|
||||
Settings.getRuntimeSettings().set('useCursors', Boolean(appConfig.useCursors));
|
||||
|
||||
const {
|
||||
userAuthenticationService,
|
||||
customizationService,
|
||||
uiModalService,
|
||||
uiNotificationService,
|
||||
cornerstoneViewportService,
|
||||
hangingProtocolService,
|
||||
viewportGridService,
|
||||
} = servicesManager.services;
|
||||
|
||||
window.services = servicesManager.services;
|
||||
window.extensionManager = extensionManager;
|
||||
window.commandsManager = commandsManager;
|
||||
|
||||
if (appConfig.showCPUFallbackMessage && cornerstone.getShouldUseCPURendering()) {
|
||||
_showCPURenderingModal(uiModalService, hangingProtocolService);
|
||||
}
|
||||
const { getPresentationId: getLutPresentationId } = useLutPresentationStore.getState();
|
||||
|
||||
const { getPresentationId: getSegmentationPresentationId } =
|
||||
useSegmentationPresentationStore.getState();
|
||||
|
||||
const { getPresentationId: getPositionPresentationId } = usePositionPresentationStore.getState();
|
||||
|
||||
// register presentation id providers
|
||||
viewportGridService.addPresentationIdProvider(
|
||||
'positionPresentationId',
|
||||
getPositionPresentationId
|
||||
);
|
||||
viewportGridService.addPresentationIdProvider('lutPresentationId', getLutPresentationId);
|
||||
viewportGridService.addPresentationIdProvider(
|
||||
'segmentationPresentationId',
|
||||
getSegmentationPresentationId
|
||||
);
|
||||
|
||||
cornerstoneTools.segmentation.config.style.setStyle(
|
||||
{ type: SegmentationRepresentations.Contour },
|
||||
{
|
||||
renderFill: false,
|
||||
}
|
||||
);
|
||||
|
||||
const metadataProvider = OHIF.classes.MetadataProvider;
|
||||
|
||||
volumeLoader.registerVolumeLoader(
|
||||
'cornerstoneStreamingImageVolume',
|
||||
cornerstoneStreamingImageVolumeLoader
|
||||
);
|
||||
|
||||
volumeLoader.registerVolumeLoader(
|
||||
'cornerstoneStreamingDynamicImageVolume',
|
||||
cornerstoneStreamingDynamicImageVolumeLoader
|
||||
);
|
||||
|
||||
hangingProtocolService.registerImageLoadStrategy('interleaveCenter', interleaveCenterLoader);
|
||||
hangingProtocolService.registerImageLoadStrategy('interleaveTopToBottom', interleaveTopToBottom);
|
||||
hangingProtocolService.registerImageLoadStrategy('nth', nthLoader);
|
||||
|
||||
// add metadata providers
|
||||
metaData.addProvider(
|
||||
csUtilities.calibratedPixelSpacingMetadataProvider.get.bind(
|
||||
csUtilities.calibratedPixelSpacingMetadataProvider
|
||||
)
|
||||
); // this provider is required for Calibration tool
|
||||
metaData.addProvider(metadataProvider.get.bind(metadataProvider), 9999);
|
||||
|
||||
// These are set reasonably low to allow for interleaved retrieves and slower
|
||||
// connections.
|
||||
imageLoadPoolManager.maxNumRequests = {
|
||||
[RequestTypes.Interaction]: appConfig?.maxNumRequests?.interaction || 10,
|
||||
[RequestTypes.Thumbnail]: appConfig?.maxNumRequests?.thumbnail || 5,
|
||||
[RequestTypes.Prefetch]: appConfig?.maxNumRequests?.prefetch || 5,
|
||||
[RequestTypes.Compute]: appConfig?.maxNumRequests?.compute || 10,
|
||||
};
|
||||
|
||||
initWADOImageLoader(userAuthenticationService, appConfig, extensionManager);
|
||||
|
||||
/* Measurement Service */
|
||||
this.measurementServiceSource = connectToolsToMeasurementService(servicesManager);
|
||||
|
||||
initCineService(servicesManager);
|
||||
initStudyPrefetcherService(servicesManager);
|
||||
|
||||
// When a custom image load is performed, update the relevant viewports
|
||||
hangingProtocolService.subscribe(
|
||||
hangingProtocolService.EVENTS.CUSTOM_IMAGE_LOAD_PERFORMED,
|
||||
volumeInputArrayMap => {
|
||||
const { lutPresentationStore } = useLutPresentationStore.getState();
|
||||
const { segmentationPresentationStore } = useSegmentationPresentationStore.getState();
|
||||
const { positionPresentationStore } = usePositionPresentationStore.getState();
|
||||
|
||||
for (const entry of volumeInputArrayMap.entries()) {
|
||||
const [viewportId, volumeInputArray] = entry;
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
|
||||
const ohifViewport = cornerstoneViewportService.getViewportInfo(viewportId);
|
||||
|
||||
const { presentationIds } = ohifViewport.getViewportOptions();
|
||||
|
||||
const presentations = {
|
||||
positionPresentation: positionPresentationStore[presentationIds?.positionPresentationId],
|
||||
lutPresentation: lutPresentationStore[presentationIds?.lutPresentationId],
|
||||
segmentationPresentation:
|
||||
segmentationPresentationStore[presentationIds?.segmentationPresentationId],
|
||||
};
|
||||
|
||||
cornerstoneViewportService.setVolumesForViewport(viewport, volumeInputArray, presentations);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// resize the cornerstone viewport service when the grid size changes
|
||||
// IMPORTANT: this should happen outside of the OHIFCornerstoneViewport
|
||||
// since it will trigger a rerender of each viewport and each resizing
|
||||
// the offscreen canvas which would result in a performance hit, this should
|
||||
// done only once per grid resize here. Doing it once here, allows us to reduce
|
||||
// the refreshRage(in ms) to 10 from 50. I tried with even 1 or 5 ms it worked fine
|
||||
viewportGridService.subscribe(viewportGridService.EVENTS.GRID_SIZE_CHANGED, () => {
|
||||
cornerstoneViewportService.resize(true);
|
||||
});
|
||||
|
||||
initContextMenu({
|
||||
cornerstoneViewportService,
|
||||
customizationService,
|
||||
commandsManager,
|
||||
});
|
||||
|
||||
initDoubleClick({
|
||||
customizationService,
|
||||
commandsManager,
|
||||
});
|
||||
|
||||
/**
|
||||
* Runs error handler for failed requests.
|
||||
* @param event
|
||||
*/
|
||||
const imageLoadFailedHandler = ({ detail }) => {
|
||||
const handler = errorHandler.getHTTPErrorHandler();
|
||||
handler(detail.error);
|
||||
};
|
||||
|
||||
eventTarget.addEventListener(EVENTS.IMAGE_LOAD_FAILED, imageLoadFailedHandler);
|
||||
eventTarget.addEventListener(EVENTS.IMAGE_LOAD_ERROR, imageLoadFailedHandler);
|
||||
|
||||
function elementEnabledHandler(evt) {
|
||||
const { element } = evt.detail;
|
||||
|
||||
element.addEventListener(EVENTS.CAMERA_RESET, evt => {
|
||||
const { element } = evt.detail;
|
||||
const enabledElement = getEnabledElement(element);
|
||||
if (!enabledElement) {
|
||||
return;
|
||||
}
|
||||
const { viewportId } = enabledElement;
|
||||
commandsManager.runCommand('resetCrosshairs', { viewportId });
|
||||
});
|
||||
|
||||
initViewTiming({ element });
|
||||
}
|
||||
|
||||
eventTarget.addEventListener(EVENTS.ELEMENT_ENABLED, elementEnabledHandler.bind(null));
|
||||
|
||||
colormaps.forEach(registerColormap);
|
||||
|
||||
// Event listener
|
||||
eventTarget.addEventListenerDebounced(
|
||||
EVENTS.ERROR_EVENT,
|
||||
({ detail }) => {
|
||||
uiNotificationService.show({
|
||||
title: detail.type,
|
||||
message: detail.message,
|
||||
type: 'error',
|
||||
});
|
||||
},
|
||||
100
|
||||
);
|
||||
|
||||
// Call this function when initializing
|
||||
initializeWebWorkerProgressHandler(servicesManager.services.uiNotificationService);
|
||||
}
|
||||
|
||||
function initializeWebWorkerProgressHandler(uiNotificationService) {
|
||||
const activeToasts = new Map();
|
||||
|
||||
eventTarget.addEventListener(EVENTS.WEB_WORKER_PROGRESS, ({ detail }) => {
|
||||
const { progress, type, id } = detail;
|
||||
|
||||
const cacheKey = `${type}-${id}`;
|
||||
if (progress === 0 && !activeToasts.has(cacheKey)) {
|
||||
const progressPromise = new Promise((resolve, reject) => {
|
||||
activeToasts.set(cacheKey, { resolve, reject });
|
||||
});
|
||||
|
||||
uiNotificationService.show({
|
||||
id: cacheKey,
|
||||
title: `${type}`,
|
||||
message: `${type}: ${progress}%`,
|
||||
autoClose: false,
|
||||
promise: progressPromise,
|
||||
promiseMessages: {
|
||||
loading: `Computing...`,
|
||||
success: `Completed successfully`,
|
||||
error: 'Web Worker failed',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
if (progress === 100) {
|
||||
const { resolve } = activeToasts.get(cacheKey);
|
||||
resolve({ progress, type });
|
||||
activeToasts.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function CPUModal() {
|
||||
return (
|
||||
<div>
|
||||
<p>
|
||||
Your computer does not have enough GPU power to support the default GPU rendering mode. OHIF
|
||||
has switched to CPU rendering mode. Please note that CPU rendering does not support all
|
||||
features such as Volume Rendering, Multiplanar Reconstruction, and Segmentation Overlays.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function _showCPURenderingModal(uiModalService, hangingProtocolService) {
|
||||
const callback = progress => {
|
||||
if (progress === 100) {
|
||||
uiModalService.show({
|
||||
content: CPUModal,
|
||||
title: 'OHIF Fell Back to CPU Rendering',
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const { unsubscribe } = hangingProtocolService.subscribe(
|
||||
hangingProtocolService.EVENTS.PROTOCOL_CHANGED,
|
||||
() => {
|
||||
const done = callback(100);
|
||||
|
||||
if (done) {
|
||||
unsubscribe();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
70
extensions/cornerstone/src/initCineService.ts
Normal file
70
extensions/cornerstone/src/initCineService.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { cache, Types } from '@cornerstonejs/core';
|
||||
import { utilities } from '@cornerstonejs/tools';
|
||||
|
||||
function _getVolumeFromViewport(viewport: Types.IBaseVolumeViewport) {
|
||||
const volumeIds = viewport.getAllVolumeIds();
|
||||
const volumes = volumeIds.map(id => cache.getVolume(id));
|
||||
const dynamicVolume = volumes.find(volume => volume.isDynamicVolume());
|
||||
|
||||
return dynamicVolume ?? volumes[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all viewports that needs to be synchronized with the source
|
||||
* viewport passed as parameter when cine is updated.
|
||||
* @param servicesManager ServiceManager
|
||||
* @param srcViewportIndex Source viewport index
|
||||
* @returns array with viewport information.
|
||||
*/
|
||||
function _getSyncedViewports(servicesManager: AppTypes.ServicesManager, srcViewportId) {
|
||||
const { viewportGridService, cornerstoneViewportService } = servicesManager.services;
|
||||
|
||||
const { viewports: viewportsStates } = viewportGridService.getState();
|
||||
const srcViewportState = viewportsStates.get(srcViewportId);
|
||||
|
||||
if (srcViewportState?.viewportOptions?.viewportType !== 'volume') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const srcViewport = cornerstoneViewportService.getCornerstoneViewport(srcViewportId);
|
||||
|
||||
const srcVolume = srcViewport ? _getVolumeFromViewport(srcViewport) : null;
|
||||
|
||||
if (!srcVolume?.isDynamicVolume()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { volumeId: srcVolumeId } = srcVolume;
|
||||
|
||||
return Array.from(viewportsStates.values())
|
||||
.filter(({ viewportId }) => {
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
|
||||
return viewportId !== srcViewportId && viewport?.hasVolumeId(srcVolumeId);
|
||||
})
|
||||
.map(({ viewportId }) => ({ viewportId }));
|
||||
}
|
||||
|
||||
function initCineService(servicesManager: AppTypes.ServicesManager) {
|
||||
const { cineService } = servicesManager.services;
|
||||
|
||||
const getSyncedViewports = viewportId => {
|
||||
return _getSyncedViewports(servicesManager, viewportId);
|
||||
};
|
||||
|
||||
const playClip = (element, playClipOptions) => {
|
||||
return utilities.cine.playClip(element, playClipOptions);
|
||||
};
|
||||
|
||||
const stopClip = (element, stopClipOptions) => {
|
||||
return utilities.cine.stopClip(element, stopClipOptions);
|
||||
};
|
||||
|
||||
cineService.setServiceImplementation({
|
||||
getSyncedViewports,
|
||||
playClip,
|
||||
stopClip,
|
||||
});
|
||||
}
|
||||
|
||||
export default initCineService;
|
||||
113
extensions/cornerstone/src/initContextMenu.ts
Normal file
113
extensions/cornerstone/src/initContextMenu.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { eventTarget, EVENTS } from '@cornerstonejs/core';
|
||||
import { Enums } from '@cornerstonejs/tools';
|
||||
import { setEnabledElement } from './state';
|
||||
import { findNearbyToolData } from './utils/findNearbyToolData';
|
||||
|
||||
const cs3DToolsEvents = Enums.Events;
|
||||
|
||||
const DEFAULT_CONTEXT_MENU_CLICKS = {
|
||||
button1: {
|
||||
commands: [
|
||||
{
|
||||
commandName: 'closeContextMenu',
|
||||
},
|
||||
],
|
||||
},
|
||||
button3: {
|
||||
commands: [
|
||||
{
|
||||
commandName: 'showCornerstoneContextMenu',
|
||||
commandOptions: {
|
||||
requireNearbyToolData: true,
|
||||
menuId: 'measurementsContextMenu',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a name, consisting of:
|
||||
* * alt when the alt key is down
|
||||
* * ctrl when the cctrl key is down
|
||||
* * shift when the shift key is down
|
||||
* * 'button' followed by the button number (1 left, 3 right etc)
|
||||
*/
|
||||
function getEventName(evt) {
|
||||
const button = evt.detail.event.which;
|
||||
const nameArr = [];
|
||||
if (evt.detail.event.altKey) {
|
||||
nameArr.push('alt');
|
||||
}
|
||||
if (evt.detail.event.ctrlKey) {
|
||||
nameArr.push('ctrl');
|
||||
}
|
||||
if (evt.detail.event.shiftKey) {
|
||||
nameArr.push('shift');
|
||||
}
|
||||
nameArr.push('button');
|
||||
nameArr.push(button);
|
||||
return nameArr.join('');
|
||||
}
|
||||
|
||||
function initContextMenu({
|
||||
cornerstoneViewportService,
|
||||
customizationService,
|
||||
commandsManager,
|
||||
}): void {
|
||||
/*
|
||||
* Run the commands associated with the given button press,
|
||||
* defaults on button1 and button2
|
||||
*/
|
||||
const cornerstoneViewportHandleEvent = (name, evt) => {
|
||||
const customizations =
|
||||
customizationService.get('cornerstoneViewportClickCommands') || DEFAULT_CONTEXT_MENU_CLICKS;
|
||||
const toRun = customizations[name];
|
||||
|
||||
if (!toRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
// only find nearbyToolData if required, for the click (which closes the context menu
|
||||
// we don't need to find nearbyToolData)
|
||||
let nearbyToolData = null;
|
||||
if (toRun.commands.some(command => command.commandOptions?.requireNearbyToolData)) {
|
||||
nearbyToolData = findNearbyToolData(commandsManager, evt);
|
||||
}
|
||||
|
||||
const options = {
|
||||
nearbyToolData,
|
||||
event: evt,
|
||||
};
|
||||
commandsManager.run(toRun, options);
|
||||
};
|
||||
|
||||
const cornerstoneViewportHandleClick = evt => {
|
||||
const name = getEventName(evt);
|
||||
cornerstoneViewportHandleEvent(name, evt);
|
||||
};
|
||||
|
||||
function elementEnabledHandler(evt) {
|
||||
const { viewportId, element } = evt.detail;
|
||||
const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId);
|
||||
if (!viewportInfo) {
|
||||
return;
|
||||
}
|
||||
// TODO check update upstream
|
||||
setEnabledElement(viewportId, element);
|
||||
|
||||
element.addEventListener(cs3DToolsEvents.MOUSE_CLICK, cornerstoneViewportHandleClick);
|
||||
}
|
||||
|
||||
function elementDisabledHandler(evt) {
|
||||
const { element } = evt.detail;
|
||||
|
||||
element.removeEventListener(cs3DToolsEvents.MOUSE_CLICK, cornerstoneViewportHandleClick);
|
||||
}
|
||||
|
||||
eventTarget.addEventListener(EVENTS.ELEMENT_ENABLED, elementEnabledHandler.bind(null));
|
||||
|
||||
eventTarget.addEventListener(EVENTS.ELEMENT_DISABLED, elementDisabledHandler.bind(null));
|
||||
}
|
||||
|
||||
export default initContextMenu;
|
||||
142
extensions/cornerstone/src/initCornerstoneTools.js
Normal file
142
extensions/cornerstone/src/initCornerstoneTools.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
PanTool,
|
||||
WindowLevelTool,
|
||||
StackScrollTool,
|
||||
VolumeRotateTool,
|
||||
ZoomTool,
|
||||
MIPJumpToClickTool,
|
||||
LengthTool,
|
||||
RectangleROITool,
|
||||
RectangleROIThresholdTool,
|
||||
EllipticalROITool,
|
||||
CircleROITool,
|
||||
BidirectionalTool,
|
||||
ArrowAnnotateTool,
|
||||
DragProbeTool,
|
||||
ProbeTool,
|
||||
AngleTool,
|
||||
CobbAngleTool,
|
||||
MagnifyTool,
|
||||
CrosshairsTool,
|
||||
RectangleScissorsTool,
|
||||
SphereScissorsTool,
|
||||
CircleScissorsTool,
|
||||
BrushTool,
|
||||
PaintFillTool,
|
||||
init,
|
||||
addTool,
|
||||
annotation,
|
||||
ReferenceLinesTool,
|
||||
TrackballRotateTool,
|
||||
AdvancedMagnifyTool,
|
||||
UltrasoundDirectionalTool,
|
||||
PlanarFreehandROITool,
|
||||
PlanarFreehandContourSegmentationTool,
|
||||
SplineROITool,
|
||||
LivewireContourTool,
|
||||
OrientationMarkerTool,
|
||||
WindowLevelRegionTool,
|
||||
} from '@cornerstonejs/tools';
|
||||
|
||||
import CalibrationLineTool from './tools/CalibrationLineTool';
|
||||
import ImageOverlayViewerTool from './tools/ImageOverlayViewerTool';
|
||||
|
||||
export default function initCornerstoneTools(configuration = {}) {
|
||||
CrosshairsTool.isAnnotation = false;
|
||||
ReferenceLinesTool.isAnnotation = false;
|
||||
AdvancedMagnifyTool.isAnnotation = false;
|
||||
PlanarFreehandContourSegmentationTool.isAnnotation = false;
|
||||
|
||||
init(configuration);
|
||||
addTool(PanTool);
|
||||
addTool(WindowLevelTool);
|
||||
addTool(StackScrollTool);
|
||||
addTool(VolumeRotateTool);
|
||||
addTool(ZoomTool);
|
||||
addTool(ProbeTool);
|
||||
addTool(MIPJumpToClickTool);
|
||||
addTool(LengthTool);
|
||||
addTool(RectangleROITool);
|
||||
addTool(RectangleROIThresholdTool);
|
||||
addTool(EllipticalROITool);
|
||||
addTool(CircleROITool);
|
||||
addTool(BidirectionalTool);
|
||||
addTool(ArrowAnnotateTool);
|
||||
addTool(DragProbeTool);
|
||||
addTool(AngleTool);
|
||||
addTool(CobbAngleTool);
|
||||
addTool(MagnifyTool);
|
||||
addTool(CrosshairsTool);
|
||||
addTool(RectangleScissorsTool);
|
||||
addTool(SphereScissorsTool);
|
||||
addTool(CircleScissorsTool);
|
||||
addTool(BrushTool);
|
||||
addTool(PaintFillTool);
|
||||
addTool(ReferenceLinesTool);
|
||||
addTool(CalibrationLineTool);
|
||||
addTool(TrackballRotateTool);
|
||||
addTool(ImageOverlayViewerTool);
|
||||
addTool(AdvancedMagnifyTool);
|
||||
addTool(UltrasoundDirectionalTool);
|
||||
addTool(PlanarFreehandROITool);
|
||||
addTool(SplineROITool);
|
||||
addTool(LivewireContourTool);
|
||||
addTool(OrientationMarkerTool);
|
||||
addTool(WindowLevelRegionTool);
|
||||
addTool(PlanarFreehandContourSegmentationTool);
|
||||
|
||||
// Modify annotation tools to use dashed lines on SR
|
||||
const annotationStyle = {
|
||||
textBoxFontSize: '15px',
|
||||
lineWidth: '1.5',
|
||||
};
|
||||
|
||||
const defaultStyles = annotation.config.style.getDefaultToolStyles();
|
||||
annotation.config.style.setDefaultToolStyles({
|
||||
global: {
|
||||
...defaultStyles.global,
|
||||
...annotationStyle,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const toolNames = {
|
||||
Pan: PanTool.toolName,
|
||||
ArrowAnnotate: ArrowAnnotateTool.toolName,
|
||||
WindowLevel: WindowLevelTool.toolName,
|
||||
StackScroll: StackScrollTool.toolName,
|
||||
Zoom: ZoomTool.toolName,
|
||||
VolumeRotate: VolumeRotateTool.toolName,
|
||||
MipJumpToClick: MIPJumpToClickTool.toolName,
|
||||
Length: LengthTool.toolName,
|
||||
DragProbe: DragProbeTool.toolName,
|
||||
Probe: ProbeTool.toolName,
|
||||
RectangleROI: RectangleROITool.toolName,
|
||||
RectangleROIThreshold: RectangleROIThresholdTool.toolName,
|
||||
EllipticalROI: EllipticalROITool.toolName,
|
||||
CircleROI: CircleROITool.toolName,
|
||||
Bidirectional: BidirectionalTool.toolName,
|
||||
Angle: AngleTool.toolName,
|
||||
CobbAngle: CobbAngleTool.toolName,
|
||||
Magnify: MagnifyTool.toolName,
|
||||
Crosshairs: CrosshairsTool.toolName,
|
||||
Brush: BrushTool.toolName,
|
||||
PaintFill: PaintFillTool.toolName,
|
||||
ReferenceLines: ReferenceLinesTool.toolName,
|
||||
CalibrationLine: CalibrationLineTool.toolName,
|
||||
TrackballRotateTool: TrackballRotateTool.toolName,
|
||||
CircleScissors: CircleScissorsTool.toolName,
|
||||
RectangleScissors: RectangleScissorsTool.toolName,
|
||||
SphereScissors: SphereScissorsTool.toolName,
|
||||
ImageOverlayViewer: ImageOverlayViewerTool.toolName,
|
||||
AdvancedMagnify: AdvancedMagnifyTool.toolName,
|
||||
UltrasoundDirectional: UltrasoundDirectionalTool.toolName,
|
||||
SplineROI: SplineROITool.toolName,
|
||||
LivewireContour: LivewireContourTool.toolName,
|
||||
PlanarFreehandROI: PlanarFreehandROITool.toolName,
|
||||
OrientationMarker: OrientationMarkerTool.toolName,
|
||||
WindowLevelRegion: WindowLevelRegionTool.toolName,
|
||||
PlanarFreehandContourSegmentation: PlanarFreehandContourSegmentationTool.toolName,
|
||||
};
|
||||
|
||||
export { toolNames };
|
||||
88
extensions/cornerstone/src/initDoubleClick.ts
Normal file
88
extensions/cornerstone/src/initDoubleClick.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { eventTarget, EVENTS } from '@cornerstonejs/core';
|
||||
import { Enums } from '@cornerstonejs/tools';
|
||||
import { CommandsManager, CustomizationService, Types } from '@ohif/core';
|
||||
import { findNearbyToolData } from './utils/findNearbyToolData';
|
||||
|
||||
const cs3DToolsEvents = Enums.Events;
|
||||
|
||||
const DEFAULT_DOUBLE_CLICK = {
|
||||
doubleClick: {
|
||||
commandName: 'toggleOneUp',
|
||||
commandOptions: {},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a double click event name, consisting of:
|
||||
* * alt when the alt key is down
|
||||
* * ctrl when the cctrl key is down
|
||||
* * shift when the shift key is down
|
||||
* * 'doubleClick'
|
||||
*/
|
||||
function getDoubleClickEventName(evt: CustomEvent) {
|
||||
const nameArr = [];
|
||||
if (evt.detail.event.altKey) {
|
||||
nameArr.push('alt');
|
||||
}
|
||||
if (evt.detail.event.ctrlKey) {
|
||||
nameArr.push('ctrl');
|
||||
}
|
||||
if (evt.detail.event.shiftKey) {
|
||||
nameArr.push('shift');
|
||||
}
|
||||
nameArr.push('doubleClick');
|
||||
return nameArr.join('');
|
||||
}
|
||||
|
||||
export type initDoubleClickArgs = {
|
||||
customizationService: CustomizationService;
|
||||
commandsManager: CommandsManager;
|
||||
};
|
||||
|
||||
function initDoubleClick({ customizationService, commandsManager }: initDoubleClickArgs): void {
|
||||
const cornerstoneViewportHandleDoubleClick = (evt: CustomEvent) => {
|
||||
// Do not allow double click on a tool.
|
||||
const nearbyToolData = findNearbyToolData(commandsManager, evt);
|
||||
if (nearbyToolData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventName = getDoubleClickEventName(evt);
|
||||
|
||||
// Allows for the customization of the double click on a viewport.
|
||||
const customizations =
|
||||
customizationService.get('cornerstoneViewportClickCommands') || DEFAULT_DOUBLE_CLICK;
|
||||
|
||||
const toRun = customizations[eventName];
|
||||
|
||||
if (!toRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
commandsManager.run(toRun);
|
||||
};
|
||||
|
||||
function elementEnabledHandler(evt: CustomEvent) {
|
||||
const { element } = evt.detail;
|
||||
|
||||
element.addEventListener(
|
||||
cs3DToolsEvents.MOUSE_DOUBLE_CLICK,
|
||||
cornerstoneViewportHandleDoubleClick
|
||||
);
|
||||
}
|
||||
|
||||
function elementDisabledHandler(evt: CustomEvent) {
|
||||
const { element } = evt.detail;
|
||||
|
||||
element.removeEventListener(
|
||||
cs3DToolsEvents.MOUSE_DOUBLE_CLICK,
|
||||
cornerstoneViewportHandleDoubleClick
|
||||
);
|
||||
}
|
||||
|
||||
eventTarget.addEventListener(EVENTS.ELEMENT_ENABLED, elementEnabledHandler.bind(null));
|
||||
|
||||
eventTarget.addEventListener(EVENTS.ELEMENT_DISABLED, elementDisabledHandler.bind(null));
|
||||
}
|
||||
|
||||
export default initDoubleClick;
|
||||
462
extensions/cornerstone/src/initMeasurementService.ts
Normal file
462
extensions/cornerstone/src/initMeasurementService.ts
Normal file
@@ -0,0 +1,462 @@
|
||||
import { eventTarget, Types } from '@cornerstonejs/core';
|
||||
import { Enums, annotation } from '@cornerstonejs/tools';
|
||||
import { DicomMetadataStore } from '@ohif/core';
|
||||
|
||||
import * as CSExtensionEnums from './enums';
|
||||
import { toolNames } from './initCornerstoneTools';
|
||||
import { onCompletedCalibrationLine } from './tools/CalibrationLineTool';
|
||||
import measurementServiceMappingsFactory from './utils/measurementServiceMappings/measurementServiceMappingsFactory';
|
||||
import getSOPInstanceAttributes from './utils/measurementServiceMappings/utils/getSOPInstanceAttributes';
|
||||
import { triggerAnnotationRenderForViewportIds } from '@cornerstonejs/tools/utilities';
|
||||
|
||||
const { CORNERSTONE_3D_TOOLS_SOURCE_NAME, CORNERSTONE_3D_TOOLS_SOURCE_VERSION } = CSExtensionEnums;
|
||||
const { removeAnnotation } = annotation.state;
|
||||
const csToolsEvents = Enums.Events;
|
||||
|
||||
const initMeasurementService = (
|
||||
measurementService,
|
||||
displaySetService,
|
||||
cornerstoneViewportService,
|
||||
customizationService
|
||||
) => {
|
||||
/* Initialization */
|
||||
const {
|
||||
Length,
|
||||
Bidirectional,
|
||||
EllipticalROI,
|
||||
CircleROI,
|
||||
ArrowAnnotate,
|
||||
Angle,
|
||||
CobbAngle,
|
||||
RectangleROI,
|
||||
PlanarFreehandROI,
|
||||
SplineROI,
|
||||
LivewireContour,
|
||||
Probe,
|
||||
UltrasoundDirectional,
|
||||
} = measurementServiceMappingsFactory(
|
||||
measurementService,
|
||||
displaySetService,
|
||||
cornerstoneViewportService,
|
||||
customizationService
|
||||
);
|
||||
const csTools3DVer1MeasurementSource = measurementService.createSource(
|
||||
CORNERSTONE_3D_TOOLS_SOURCE_NAME,
|
||||
CORNERSTONE_3D_TOOLS_SOURCE_VERSION
|
||||
);
|
||||
|
||||
/* Mappings */
|
||||
measurementService.addMapping(
|
||||
csTools3DVer1MeasurementSource,
|
||||
'Length',
|
||||
Length.matchingCriteria,
|
||||
Length.toAnnotation,
|
||||
Length.toMeasurement
|
||||
);
|
||||
|
||||
measurementService.addMapping(
|
||||
csTools3DVer1MeasurementSource,
|
||||
'Crosshairs',
|
||||
Length.matchingCriteria,
|
||||
() => {
|
||||
console.warn('Crosshairs mapping not implemented.');
|
||||
},
|
||||
() => {
|
||||
console.warn('Crosshairs mapping not implemented.');
|
||||
}
|
||||
);
|
||||
|
||||
measurementService.addMapping(
|
||||
csTools3DVer1MeasurementSource,
|
||||
'Bidirectional',
|
||||
Bidirectional.matchingCriteria,
|
||||
Bidirectional.toAnnotation,
|
||||
Bidirectional.toMeasurement
|
||||
);
|
||||
|
||||
measurementService.addMapping(
|
||||
csTools3DVer1MeasurementSource,
|
||||
'EllipticalROI',
|
||||
EllipticalROI.matchingCriteria,
|
||||
EllipticalROI.toAnnotation,
|
||||
EllipticalROI.toMeasurement
|
||||
);
|
||||
|
||||
measurementService.addMapping(
|
||||
csTools3DVer1MeasurementSource,
|
||||
'CircleROI',
|
||||
CircleROI.matchingCriteria,
|
||||
CircleROI.toAnnotation,
|
||||
CircleROI.toMeasurement
|
||||
);
|
||||
|
||||
measurementService.addMapping(
|
||||
csTools3DVer1MeasurementSource,
|
||||
'ArrowAnnotate',
|
||||
ArrowAnnotate.matchingCriteria,
|
||||
ArrowAnnotate.toAnnotation,
|
||||
ArrowAnnotate.toMeasurement
|
||||
);
|
||||
|
||||
measurementService.addMapping(
|
||||
csTools3DVer1MeasurementSource,
|
||||
'CobbAngle',
|
||||
CobbAngle.matchingCriteria,
|
||||
CobbAngle.toAnnotation,
|
||||
CobbAngle.toMeasurement
|
||||
);
|
||||
|
||||
measurementService.addMapping(
|
||||
csTools3DVer1MeasurementSource,
|
||||
'Angle',
|
||||
Angle.matchingCriteria,
|
||||
Angle.toAnnotation,
|
||||
Angle.toMeasurement
|
||||
);
|
||||
|
||||
measurementService.addMapping(
|
||||
csTools3DVer1MeasurementSource,
|
||||
'RectangleROI',
|
||||
RectangleROI.matchingCriteria,
|
||||
RectangleROI.toAnnotation,
|
||||
RectangleROI.toMeasurement
|
||||
);
|
||||
|
||||
measurementService.addMapping(
|
||||
csTools3DVer1MeasurementSource,
|
||||
'PlanarFreehandROI',
|
||||
PlanarFreehandROI.matchingCriteria,
|
||||
PlanarFreehandROI.toAnnotation,
|
||||
PlanarFreehandROI.toMeasurement
|
||||
);
|
||||
|
||||
measurementService.addMapping(
|
||||
csTools3DVer1MeasurementSource,
|
||||
'SplineROI',
|
||||
SplineROI.matchingCriteria,
|
||||
SplineROI.toAnnotation,
|
||||
SplineROI.toMeasurement
|
||||
);
|
||||
|
||||
// On the UI side, the Calibration Line tool will work almost the same as the
|
||||
// Length tool
|
||||
measurementService.addMapping(
|
||||
csTools3DVer1MeasurementSource,
|
||||
'CalibrationLine',
|
||||
Length.matchingCriteria,
|
||||
Length.toAnnotation,
|
||||
Length.toMeasurement
|
||||
);
|
||||
|
||||
measurementService.addMapping(
|
||||
csTools3DVer1MeasurementSource,
|
||||
'LivewireContour',
|
||||
LivewireContour.matchingCriteria,
|
||||
LivewireContour.toAnnotation,
|
||||
LivewireContour.toMeasurement
|
||||
);
|
||||
|
||||
measurementService.addMapping(
|
||||
csTools3DVer1MeasurementSource,
|
||||
'Probe',
|
||||
Probe.matchingCriteria,
|
||||
Probe.toAnnotation,
|
||||
Probe.toMeasurement
|
||||
);
|
||||
|
||||
measurementService.addMapping(
|
||||
csTools3DVer1MeasurementSource,
|
||||
'UltrasoundDirectionalTool',
|
||||
UltrasoundDirectional.matchingCriteria,
|
||||
UltrasoundDirectional.toAnnotation,
|
||||
UltrasoundDirectional.toMeasurement
|
||||
);
|
||||
|
||||
return csTools3DVer1MeasurementSource;
|
||||
};
|
||||
|
||||
const connectToolsToMeasurementService = (servicesManager: AppTypes.ServicesManager) => {
|
||||
const {
|
||||
measurementService,
|
||||
displaySetService,
|
||||
cornerstoneViewportService,
|
||||
customizationService,
|
||||
} = servicesManager.services;
|
||||
const csTools3DVer1MeasurementSource = initMeasurementService(
|
||||
measurementService,
|
||||
displaySetService,
|
||||
cornerstoneViewportService,
|
||||
customizationService
|
||||
);
|
||||
connectMeasurementServiceToTools(measurementService, cornerstoneViewportService);
|
||||
const { annotationToMeasurement, remove } = csTools3DVer1MeasurementSource;
|
||||
|
||||
//
|
||||
function addMeasurement(csToolsEvent) {
|
||||
try {
|
||||
const annotationAddedEventDetail = csToolsEvent.detail;
|
||||
const {
|
||||
annotation: { metadata, annotationUID },
|
||||
} = annotationAddedEventDetail;
|
||||
const { toolName } = metadata;
|
||||
|
||||
if (csToolsEvent.type === completedEvt && toolName === toolNames.CalibrationLine) {
|
||||
// show modal to input the measurement (mm)
|
||||
onCompletedCalibrationLine(servicesManager, csToolsEvent)
|
||||
.then(
|
||||
() => {
|
||||
console.log('Calibration applied.');
|
||||
},
|
||||
() => true
|
||||
)
|
||||
.finally(() => {
|
||||
// we don't need the calibration line lingering around, remove the
|
||||
// annotation from the display
|
||||
removeAnnotation(annotationUID);
|
||||
removeMeasurement(csToolsEvent);
|
||||
// this will ensure redrawing of annotations
|
||||
cornerstoneViewportService.resize();
|
||||
});
|
||||
} else {
|
||||
// To force the measurementUID be the same as the annotationUID
|
||||
// Todo: this should be changed when a measurement can include multiple annotations
|
||||
// in the future
|
||||
annotationAddedEventDetail.uid = annotationUID;
|
||||
annotationToMeasurement(toolName, annotationAddedEventDetail);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to add measurement:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateMeasurement(csToolsEvent) {
|
||||
try {
|
||||
const annotationModifiedEventDetail = csToolsEvent.detail;
|
||||
|
||||
const {
|
||||
annotation: { metadata, annotationUID },
|
||||
} = annotationModifiedEventDetail;
|
||||
|
||||
// If the measurement hasn't been added, don't modify it
|
||||
const measurement = measurementService.getMeasurement(annotationUID);
|
||||
|
||||
if (!measurement) {
|
||||
return;
|
||||
}
|
||||
const { toolName } = metadata;
|
||||
|
||||
annotationModifiedEventDetail.uid = annotationUID;
|
||||
// Passing true to indicate this is an update and NOT a annotation (start) completion.
|
||||
annotationToMeasurement(toolName, annotationModifiedEventDetail, true);
|
||||
} catch (error) {
|
||||
console.warn('Failed to update measurement:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function selectMeasurement(csToolsEvent) {
|
||||
try {
|
||||
const annotationSelectionEventDetail = csToolsEvent.detail;
|
||||
|
||||
const { added: addedSelectedAnnotationUIDs, removed: removedSelectedAnnotationUIDs } =
|
||||
annotationSelectionEventDetail;
|
||||
|
||||
if (removedSelectedAnnotationUIDs) {
|
||||
removedSelectedAnnotationUIDs.forEach(annotationUID =>
|
||||
measurementService.setMeasurementSelected(annotationUID, false)
|
||||
);
|
||||
}
|
||||
|
||||
if (addedSelectedAnnotationUIDs) {
|
||||
addedSelectedAnnotationUIDs.forEach(annotationUID =>
|
||||
measurementService.setMeasurementSelected(annotationUID, true)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to select/unselect measurements:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When csTools fires a removed event, remove the same measurement
|
||||
* from the measurement service
|
||||
*
|
||||
* @param {*} csToolsEvent
|
||||
*/
|
||||
function removeMeasurement(csToolsEvent) {
|
||||
try {
|
||||
const annotationRemovedEventDetail = csToolsEvent.detail;
|
||||
const {
|
||||
annotation: { annotationUID },
|
||||
} = annotationRemovedEventDetail;
|
||||
const measurement = measurementService.getMeasurement(annotationUID);
|
||||
if (measurement) {
|
||||
remove(annotationUID, annotationRemovedEventDetail);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to remove measurement:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// on display sets added, check if there are any measurements in measurement service that need to be
|
||||
// put into cornerstone tools
|
||||
const addedEvt = csToolsEvents.ANNOTATION_ADDED;
|
||||
const completedEvt = csToolsEvents.ANNOTATION_COMPLETED;
|
||||
const updatedEvt = csToolsEvents.ANNOTATION_MODIFIED;
|
||||
const removedEvt = csToolsEvents.ANNOTATION_REMOVED;
|
||||
const selectionEvt = csToolsEvents.ANNOTATION_SELECTION_CHANGE;
|
||||
|
||||
eventTarget.addEventListener(addedEvt, addMeasurement);
|
||||
eventTarget.addEventListener(completedEvt, addMeasurement);
|
||||
eventTarget.addEventListener(updatedEvt, updateMeasurement);
|
||||
eventTarget.addEventListener(removedEvt, removeMeasurement);
|
||||
eventTarget.addEventListener(selectionEvt, selectMeasurement);
|
||||
|
||||
return csTools3DVer1MeasurementSource;
|
||||
};
|
||||
|
||||
const connectMeasurementServiceToTools = (measurementService, cornerstoneViewportService) => {
|
||||
const { MEASUREMENT_REMOVED, MEASUREMENTS_CLEARED, MEASUREMENT_UPDATED, RAW_MEASUREMENT_ADDED } =
|
||||
measurementService.EVENTS;
|
||||
|
||||
measurementService.subscribe(MEASUREMENTS_CLEARED, ({ measurements }) => {
|
||||
if (!Object.keys(measurements).length) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const measurement of Object.values(measurements)) {
|
||||
const { uid, source } = measurement;
|
||||
if (source.name !== CORNERSTONE_3D_TOOLS_SOURCE_NAME) {
|
||||
continue;
|
||||
}
|
||||
removeAnnotation(uid);
|
||||
}
|
||||
|
||||
// trigger a render
|
||||
cornerstoneViewportService.getRenderingEngine().render();
|
||||
});
|
||||
|
||||
measurementService.subscribe(
|
||||
MEASUREMENT_UPDATED,
|
||||
({ source, measurement, notYetUpdatedAtSource }) => {
|
||||
if (source.name !== CORNERSTONE_3D_TOOLS_SOURCE_NAME) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (notYetUpdatedAtSource === false) {
|
||||
// This event was fired by cornerstone telling the measurement service to sync.
|
||||
// Already in sync.
|
||||
return;
|
||||
}
|
||||
|
||||
const { uid, label, isLocked, isVisible } = measurement;
|
||||
const sourceAnnotation = annotation.state.getAnnotation(uid);
|
||||
const { data, metadata } = sourceAnnotation;
|
||||
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.label !== label) {
|
||||
data.label = label;
|
||||
}
|
||||
|
||||
if (metadata.toolName === 'ArrowAnnotate') {
|
||||
data.text = label;
|
||||
}
|
||||
|
||||
// update the isLocked state
|
||||
annotation.locking.setAnnotationLocked(uid, isLocked);
|
||||
|
||||
// update the isVisible state
|
||||
annotation.visibility.setAnnotationVisibility(uid, isVisible);
|
||||
|
||||
// annotation.config.style.setAnnotationStyles(uid, {
|
||||
// color: `rgb(${color[0]}, ${color[1]}, ${color[2]})`,
|
||||
// });
|
||||
|
||||
// I don't like this but will fix later
|
||||
const renderingEngine =
|
||||
cornerstoneViewportService.getRenderingEngine() as Types.IRenderingEngine;
|
||||
// Note: We could do a better job by triggering the render on the
|
||||
// viewport itself, but the removeAnnotation does not include that info...
|
||||
const viewportIds = renderingEngine.getViewports().map(viewport => viewport.id);
|
||||
triggerAnnotationRenderForViewportIds(viewportIds);
|
||||
}
|
||||
);
|
||||
|
||||
measurementService.subscribe(
|
||||
RAW_MEASUREMENT_ADDED,
|
||||
({ source, measurement, data, dataSource }) => {
|
||||
if (source.name !== CORNERSTONE_3D_TOOLS_SOURCE_NAME) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { referenceSeriesUID, referenceStudyUID, SOPInstanceUID } = measurement;
|
||||
|
||||
const instance = DicomMetadataStore.getInstance(
|
||||
referenceStudyUID,
|
||||
referenceSeriesUID,
|
||||
SOPInstanceUID
|
||||
);
|
||||
|
||||
let imageId;
|
||||
let frameNumber = 1;
|
||||
|
||||
if (measurement?.metadata?.referencedImageId) {
|
||||
imageId = measurement.metadata.referencedImageId;
|
||||
frameNumber = getSOPInstanceAttributes(measurement.metadata.referencedImageId).frameNumber;
|
||||
} else {
|
||||
imageId = dataSource.getImageIdsForInstance({ instance });
|
||||
}
|
||||
|
||||
/**
|
||||
* This annotation is used by the cornerstone viewport.
|
||||
* This is not the read-only annotation rendered by the SR viewport.
|
||||
*/
|
||||
const annotationManager = annotation.state.getAnnotationManager();
|
||||
annotationManager.addAnnotation({
|
||||
annotationUID: measurement.uid,
|
||||
highlighted: false,
|
||||
isLocked: false,
|
||||
invalidated: false,
|
||||
metadata: {
|
||||
toolName: measurement.toolName,
|
||||
FrameOfReferenceUID: measurement.FrameOfReferenceUID,
|
||||
referencedImageId: imageId,
|
||||
},
|
||||
data: {
|
||||
/**
|
||||
* Don't remove this destructuring of data here.
|
||||
* This is used to pass annotation specific data forward e.g. contour
|
||||
*/
|
||||
...(data.annotation.data || {}),
|
||||
text: data.annotation.data.text,
|
||||
handles: { ...data.annotation.data.handles },
|
||||
cachedStats: { ...data.annotation.data.cachedStats },
|
||||
label: data.annotation.data.label,
|
||||
frameNumber,
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
measurementService.subscribe(
|
||||
MEASUREMENT_REMOVED,
|
||||
({ source, measurement: removedMeasurementId }) => {
|
||||
if (source?.name && source.name !== CORNERSTONE_3D_TOOLS_SOURCE_NAME) {
|
||||
return;
|
||||
}
|
||||
removeAnnotation(removedMeasurementId);
|
||||
const renderingEngine = cornerstoneViewportService.getRenderingEngine();
|
||||
// Note: We could do a better job by triggering the render on the
|
||||
// viewport itself, but the removeAnnotation does not include that info...
|
||||
renderingEngine.render();
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
initMeasurementService,
|
||||
connectToolsToMeasurementService,
|
||||
connectMeasurementServiceToTools,
|
||||
};
|
||||
33
extensions/cornerstone/src/initStudyPrefetcherService.ts
Normal file
33
extensions/cornerstone/src/initStudyPrefetcherService.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { cache, imageLoadPoolManager, imageLoader, Enums, eventTarget, EVENTS as csEvents } from '@cornerstonejs/core';
|
||||
|
||||
function initStudyPrefetcherService(servicesManager: AppTypes.ServicesManager) {
|
||||
const { studyPrefetcherService } = servicesManager.services;
|
||||
|
||||
studyPrefetcherService.requestType = Enums.RequestType.Prefetch;
|
||||
studyPrefetcherService.imageLoadPoolManager = imageLoadPoolManager;
|
||||
studyPrefetcherService.imageLoader = imageLoader;
|
||||
|
||||
studyPrefetcherService.cache = {
|
||||
isImageCached(imageId: string): boolean {
|
||||
return !!cache.getImageLoadObject(imageId);
|
||||
}
|
||||
}
|
||||
|
||||
studyPrefetcherService.imageLoadEventsManager = {
|
||||
addEventListeners(onImageLoaded, onImageLoadFailed) {
|
||||
eventTarget.addEventListener(csEvents.IMAGE_LOADED, onImageLoaded);
|
||||
eventTarget.addEventListener(csEvents.IMAGE_LOAD_FAILED, onImageLoadFailed);
|
||||
|
||||
return [
|
||||
{
|
||||
unsubscribe: () => eventTarget.removeEventListener(csEvents.IMAGE_LOADED, onImageLoaded)
|
||||
},
|
||||
{
|
||||
unsubscribe: () => eventTarget.removeEventListener(csEvents.IMAGE_LOAD_FAILED, onImageLoadFailed)
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default initStudyPrefetcherService;
|
||||
56
extensions/cornerstone/src/initWADOImageLoader.js
Normal file
56
extensions/cornerstone/src/initWADOImageLoader.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { volumeLoader } from '@cornerstonejs/core';
|
||||
import {
|
||||
cornerstoneStreamingImageVolumeLoader,
|
||||
cornerstoneStreamingDynamicImageVolumeLoader,
|
||||
} from '@cornerstonejs/core/loaders';
|
||||
import dicomImageLoader from '@cornerstonejs/dicom-image-loader';
|
||||
import { errorHandler, utils } from '@ohif/core';
|
||||
|
||||
const { registerVolumeLoader } = volumeLoader;
|
||||
|
||||
export default function initWADOImageLoader(
|
||||
userAuthenticationService,
|
||||
appConfig,
|
||||
extensionManager
|
||||
) {
|
||||
registerVolumeLoader('cornerstoneStreamingImageVolume', cornerstoneStreamingImageVolumeLoader);
|
||||
|
||||
registerVolumeLoader(
|
||||
'cornerstoneStreamingDynamicImageVolume',
|
||||
cornerstoneStreamingDynamicImageVolumeLoader
|
||||
);
|
||||
|
||||
dicomImageLoader.init({
|
||||
maxWebWorkers: Math.min(
|
||||
Math.max(navigator.hardwareConcurrency - 1, 1),
|
||||
appConfig.maxNumberOfWebWorkers
|
||||
),
|
||||
beforeSend: function (xhr) {
|
||||
//TODO should be removed in the future and request emitted by DicomWebDataSource
|
||||
const sourceConfig = extensionManager.getActiveDataSource()?.[0].getConfig() ?? {};
|
||||
const headers = userAuthenticationService.getAuthorizationHeader();
|
||||
const acceptHeader = utils.generateAcceptHeader(
|
||||
sourceConfig.acceptHeader,
|
||||
sourceConfig.requestTransferSyntaxUID,
|
||||
sourceConfig.omitQuotationForMultipartRequest
|
||||
);
|
||||
|
||||
const xhrRequestHeaders = {
|
||||
Accept: acceptHeader,
|
||||
};
|
||||
|
||||
if (headers) {
|
||||
Object.assign(xhrRequestHeaders, headers);
|
||||
}
|
||||
|
||||
return xhrRequestHeaders;
|
||||
},
|
||||
errorInterceptor: error => {
|
||||
errorHandler.getHTTPErrorHandler(error);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function destroy() {
|
||||
console.debug('Destroying WADO Image Loader');
|
||||
}
|
||||
147
extensions/cornerstone/src/panels/PanelMeasurement.tsx
Normal file
147
extensions/cornerstone/src/panels/PanelMeasurement.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useViewportGrid } from '@ohif/ui';
|
||||
import { MeasurementTable } from '@ohif/ui-next';
|
||||
import debounce from 'lodash.debounce';
|
||||
import { useMeasurements } from '../hooks/useMeasurements';
|
||||
import { showLabelAnnotationPopup, colorPickerDialog } from '@ohif/extension-default';
|
||||
|
||||
export default function PanelMeasurementTable({
|
||||
servicesManager,
|
||||
customHeader,
|
||||
measurementFilter,
|
||||
}: withAppTypes): React.ReactNode {
|
||||
const measurementsPanelRef = useRef(null);
|
||||
|
||||
const [viewportGrid] = useViewportGrid();
|
||||
const { measurementService, customizationService, uiDialogService } = servicesManager.services;
|
||||
|
||||
const displayMeasurements = useMeasurements(servicesManager, {
|
||||
measurementFilter,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (displayMeasurements.length > 0) {
|
||||
debounce(() => {
|
||||
measurementsPanelRef.current.scrollTop = measurementsPanelRef.current.scrollHeight;
|
||||
}, 300)();
|
||||
}
|
||||
}, [displayMeasurements.length]);
|
||||
|
||||
const onMeasurementItemClickHandler = (uid: string, isActive: boolean) => {
|
||||
if (isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const measurements = [...displayMeasurements];
|
||||
const measurement = measurements.find(m => m.uid === uid);
|
||||
|
||||
measurements.forEach(m => (m.isActive = m.uid !== uid ? false : true));
|
||||
measurement.isActive = true;
|
||||
};
|
||||
|
||||
const jumpToImage = (uid: string) => {
|
||||
measurementService.jumpToMeasurement(viewportGrid.activeViewportId, uid);
|
||||
onMeasurementItemClickHandler(uid, true);
|
||||
};
|
||||
|
||||
const removeMeasurement = (uid: string) => {
|
||||
measurementService.remove(uid);
|
||||
};
|
||||
|
||||
const renameMeasurement = (uid: string) => {
|
||||
jumpToImage(uid);
|
||||
const labelConfig = customizationService.get('measurementLabels');
|
||||
const measurement = measurementService.getMeasurement(uid);
|
||||
showLabelAnnotationPopup(measurement, uiDialogService, labelConfig).then(val => {
|
||||
measurementService.update(
|
||||
uid,
|
||||
{
|
||||
...val,
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const changeColorMeasurement = (uid: string) => {
|
||||
const { color } = measurementService.getMeasurement(uid);
|
||||
const rgbaColor = {
|
||||
r: color[0],
|
||||
g: color[1],
|
||||
b: color[2],
|
||||
a: color[3] / 255.0,
|
||||
};
|
||||
colorPickerDialog(uiDialogService, rgbaColor, (newRgbaColor, actionId) => {
|
||||
if (actionId === 'cancel') {
|
||||
return;
|
||||
}
|
||||
|
||||
const color = [newRgbaColor.r, newRgbaColor.g, newRgbaColor.b, newRgbaColor.a * 255.0];
|
||||
// segmentationService.setSegmentColor(viewportId, segmentationId, segmentIndex, color);
|
||||
});
|
||||
};
|
||||
|
||||
const toggleLockMeasurement = (uid: string) => {
|
||||
measurementService.toggleLockMeasurement(uid);
|
||||
};
|
||||
|
||||
const toggleVisibilityMeasurement = (uid: string) => {
|
||||
measurementService.toggleVisibilityMeasurement(uid);
|
||||
};
|
||||
|
||||
const measurements = displayMeasurements.filter(
|
||||
dm => dm.measurementType !== measurementService.VALUE_TYPES.POINT && dm.referencedImageId
|
||||
);
|
||||
const additionalFindings = displayMeasurements.filter(
|
||||
dm => dm.measurementType === measurementService.VALUE_TYPES.POINT && dm.referencedImageId
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="invisible-scrollbar overflow-y-auto overflow-x-hidden"
|
||||
ref={measurementsPanelRef}
|
||||
data-cy={'trackedMeasurements-panel'}
|
||||
>
|
||||
<MeasurementTable
|
||||
title="Measurements"
|
||||
data={measurements}
|
||||
onClick={jumpToImage}
|
||||
onDelete={removeMeasurement}
|
||||
onToggleVisibility={toggleVisibilityMeasurement}
|
||||
onToggleLocked={toggleLockMeasurement}
|
||||
onRename={renameMeasurement}
|
||||
// onColor={changeColorMeasurement}
|
||||
>
|
||||
<MeasurementTable.Header>
|
||||
{customHeader && (
|
||||
<>
|
||||
{typeof customHeader === 'function'
|
||||
? customHeader({
|
||||
additionalFindings,
|
||||
measurements,
|
||||
})
|
||||
: customHeader}
|
||||
</>
|
||||
)}
|
||||
</MeasurementTable.Header>
|
||||
<MeasurementTable.Body />
|
||||
</MeasurementTable>
|
||||
{additionalFindings.length > 0 && (
|
||||
<MeasurementTable
|
||||
data={additionalFindings}
|
||||
title="Additional Findings"
|
||||
onClick={jumpToImage}
|
||||
onDelete={removeMeasurement}
|
||||
onToggleVisibility={toggleVisibilityMeasurement}
|
||||
onToggleLocked={toggleLockMeasurement}
|
||||
onRename={renameMeasurement}
|
||||
// onColor={changeColorMeasurement}
|
||||
>
|
||||
<MeasurementTable.Body />
|
||||
</MeasurementTable>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
236
extensions/cornerstone/src/panels/PanelSegmentation.tsx
Normal file
236
extensions/cornerstone/src/panels/PanelSegmentation.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import React from 'react';
|
||||
import { SegmentationTable } from '@ohif/ui-next';
|
||||
import { useActiveViewportSegmentationRepresentations } from '../hooks/useActiveViewportSegmentationRepresentations';
|
||||
import { metaData } from '@cornerstonejs/core';
|
||||
|
||||
export default function PanelSegmentation({
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
children,
|
||||
}: withAppTypes) {
|
||||
const { customizationService, viewportGridService, displaySetService } = servicesManager.services;
|
||||
|
||||
const { segmentationsWithRepresentations, disabled } =
|
||||
useActiveViewportSegmentationRepresentations({
|
||||
servicesManager,
|
||||
});
|
||||
|
||||
const handlers = {
|
||||
onSegmentationAdd: async () => {
|
||||
const viewportId = viewportGridService.getState().activeViewportId;
|
||||
commandsManager.run('createLabelmapForViewport', { viewportId });
|
||||
},
|
||||
|
||||
onSegmentationClick: (segmentationId: string) => {
|
||||
commandsManager.run('setActiveSegmentation', { segmentationId });
|
||||
},
|
||||
|
||||
onSegmentAdd: segmentationId => {
|
||||
commandsManager.run('addSegment', { segmentationId });
|
||||
},
|
||||
|
||||
onSegmentClick: (segmentationId, segmentIndex) => {
|
||||
commandsManager.run('setActiveSegmentAndCenter', { segmentationId, segmentIndex });
|
||||
},
|
||||
|
||||
onSegmentEdit: (segmentationId, segmentIndex) => {
|
||||
commandsManager.run('editSegmentLabel', { segmentationId, segmentIndex });
|
||||
},
|
||||
|
||||
onSegmentationEdit: segmentationId => {
|
||||
commandsManager.run('editSegmentationLabel', { segmentationId });
|
||||
},
|
||||
|
||||
onSegmentColorClick: (segmentationId, segmentIndex) => {
|
||||
commandsManager.run('editSegmentColor', { segmentationId, segmentIndex });
|
||||
},
|
||||
|
||||
onSegmentDelete: (segmentationId, segmentIndex) => {
|
||||
commandsManager.run('deleteSegment', { segmentationId, segmentIndex });
|
||||
},
|
||||
|
||||
onToggleSegmentVisibility: (segmentationId, segmentIndex, type) => {
|
||||
commandsManager.run('toggleSegmentVisibility', { segmentationId, segmentIndex, type });
|
||||
},
|
||||
|
||||
onToggleSegmentLock: (segmentationId, segmentIndex) => {
|
||||
commandsManager.run('toggleSegmentLock', { segmentationId, segmentIndex });
|
||||
},
|
||||
|
||||
onToggleSegmentationRepresentationVisibility: (segmentationId, type) => {
|
||||
commandsManager.run('toggleSegmentationVisibility', { segmentationId, type });
|
||||
},
|
||||
|
||||
onSegmentationDownload: segmentationId => {
|
||||
commandsManager.run('downloadSegmentation', { segmentationId });
|
||||
},
|
||||
|
||||
storeSegmentation: async segmentationId => {
|
||||
commandsManager.run('storeSegmentation', { segmentationId });
|
||||
},
|
||||
|
||||
onSegmentationDownloadRTSS: segmentationId => {
|
||||
commandsManager.run('downloadRTSS', { segmentationId });
|
||||
},
|
||||
|
||||
setStyle: (segmentationId, type, key, value) => {
|
||||
commandsManager.run('setSegmentationStyle', { segmentationId, type, key, value });
|
||||
},
|
||||
|
||||
toggleRenderInactiveSegmentations: () => {
|
||||
commandsManager.run('toggleRenderInactiveSegmentations');
|
||||
},
|
||||
|
||||
onSegmentationRemoveFromViewport: segmentationId => {
|
||||
commandsManager.run('removeSegmentationFromViewport', { segmentationId });
|
||||
},
|
||||
|
||||
onSegmentationDelete: segmentationId => {
|
||||
commandsManager.run('deleteSegmentation', { segmentationId });
|
||||
},
|
||||
|
||||
setFillAlpha: ({ type }, value) => {
|
||||
commandsManager.run('setFillAlpha', { type, value });
|
||||
},
|
||||
|
||||
setOutlineWidth: ({ type }, value) => {
|
||||
commandsManager.run('setOutlineWidth', { type, value });
|
||||
},
|
||||
|
||||
setRenderFill: ({ type }, value) => {
|
||||
commandsManager.run('setRenderFill', { type, value });
|
||||
},
|
||||
|
||||
setRenderOutline: ({ type }, value) => {
|
||||
commandsManager.run('setRenderOutline', { type, value });
|
||||
},
|
||||
|
||||
setFillAlphaInactive: ({ type }, value) => {
|
||||
commandsManager.run('setFillAlphaInactive', { type, value });
|
||||
},
|
||||
|
||||
getRenderInactiveSegmentations: () => {
|
||||
return commandsManager.run('getRenderInactiveSegmentations');
|
||||
},
|
||||
};
|
||||
|
||||
const { mode: SegmentationTableMode } = customizationService.getCustomization(
|
||||
'PanelSegmentation.tableMode',
|
||||
{
|
||||
id: 'default.segmentationTable.mode',
|
||||
mode: 'collapsed',
|
||||
}
|
||||
);
|
||||
|
||||
// custom onSegmentationAdd if provided
|
||||
const { onSegmentationAdd } = customizationService.getCustomization(
|
||||
'PanelSegmentation.onSegmentationAdd',
|
||||
{
|
||||
id: 'segmentation.onSegmentationAdd',
|
||||
onSegmentationAdd: handlers.onSegmentationAdd,
|
||||
}
|
||||
);
|
||||
|
||||
const { disableEditing } = customizationService.getCustomization(
|
||||
'PanelSegmentation.disableEditing',
|
||||
{
|
||||
id: 'default.disableEditing',
|
||||
disableEditing: false,
|
||||
}
|
||||
);
|
||||
|
||||
const { showAddSegment } = customizationService.getCustomization(
|
||||
'PanelSegmentation.showAddSegment',
|
||||
{
|
||||
id: 'default.showAddSegment',
|
||||
showAddSegment: true,
|
||||
}
|
||||
);
|
||||
|
||||
const exportOptions = segmentationsWithRepresentations.map(({ segmentation }) => {
|
||||
const { representationData, segmentationId } = segmentation;
|
||||
const { Labelmap } = representationData;
|
||||
|
||||
if (!Labelmap) {
|
||||
return {
|
||||
segmentationId,
|
||||
isExportable: true,
|
||||
};
|
||||
}
|
||||
|
||||
const referencedImageIds = Labelmap.referencedImageIds;
|
||||
const firstImageId = referencedImageIds[0];
|
||||
|
||||
const instance = metaData.get('instance', firstImageId);
|
||||
const { SOPInstanceUID, SeriesInstanceUID } = instance;
|
||||
|
||||
const displaySet = displaySetService.getDisplaySetForSOPInstanceUID(
|
||||
SOPInstanceUID,
|
||||
SeriesInstanceUID
|
||||
);
|
||||
const isExportable = displaySet.isReconstructable;
|
||||
|
||||
return {
|
||||
segmentationId,
|
||||
isExportable,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<SegmentationTable
|
||||
disabled={disabled}
|
||||
data={segmentationsWithRepresentations}
|
||||
mode={SegmentationTableMode}
|
||||
title="Segmentations"
|
||||
exportOptions={exportOptions}
|
||||
disableEditing={disableEditing}
|
||||
onSegmentationAdd={onSegmentationAdd}
|
||||
onSegmentationClick={handlers.onSegmentationClick}
|
||||
onSegmentationDelete={handlers.onSegmentationDelete}
|
||||
showAddSegment={showAddSegment}
|
||||
onSegmentAdd={handlers.onSegmentAdd}
|
||||
onSegmentClick={handlers.onSegmentClick}
|
||||
onSegmentEdit={handlers.onSegmentEdit}
|
||||
onSegmentationEdit={handlers.onSegmentationEdit}
|
||||
onSegmentColorClick={handlers.onSegmentColorClick}
|
||||
onSegmentDelete={handlers.onSegmentDelete}
|
||||
onToggleSegmentVisibility={handlers.onToggleSegmentVisibility}
|
||||
onToggleSegmentLock={handlers.onToggleSegmentLock}
|
||||
onToggleSegmentationRepresentationVisibility={
|
||||
handlers.onToggleSegmentationRepresentationVisibility
|
||||
}
|
||||
onSegmentationDownload={handlers.onSegmentationDownload}
|
||||
storeSegmentation={handlers.storeSegmentation}
|
||||
onSegmentationDownloadRTSS={handlers.onSegmentationDownloadRTSS}
|
||||
setStyle={handlers.setStyle}
|
||||
toggleRenderInactiveSegmentations={handlers.toggleRenderInactiveSegmentations}
|
||||
onSegmentationRemoveFromViewport={handlers.onSegmentationRemoveFromViewport}
|
||||
setFillAlpha={handlers.setFillAlpha}
|
||||
setOutlineWidth={handlers.setOutlineWidth}
|
||||
setRenderFill={handlers.setRenderFill}
|
||||
setRenderOutline={handlers.setRenderOutline}
|
||||
setFillAlphaInactive={handlers.setFillAlphaInactive}
|
||||
renderInactiveSegmentations={handlers.getRenderInactiveSegmentations()}
|
||||
>
|
||||
{children}
|
||||
<SegmentationTable.Config />
|
||||
<SegmentationTable.AddSegmentationRow />
|
||||
|
||||
{SegmentationTableMode === 'collapsed' ? (
|
||||
<SegmentationTable.Collapsed>
|
||||
<SegmentationTable.SelectorHeader />
|
||||
<SegmentationTable.AddSegmentRow />
|
||||
<SegmentationTable.Segments />
|
||||
</SegmentationTable.Collapsed>
|
||||
) : (
|
||||
<SegmentationTable.Expanded>
|
||||
<SegmentationTable.Header />
|
||||
{/* <SegmentationTable.AddSegmentRow /> */}
|
||||
<SegmentationTable.Segments />
|
||||
</SegmentationTable.Expanded>
|
||||
)}
|
||||
</SegmentationTable>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import { PubSubService } from '@ohif/core';
|
||||
import { RENDERING_ENGINE_ID } from '../ViewportService/constants';
|
||||
import { StackViewport, VolumeViewport, getRenderingEngine } from '@cornerstonejs/core';
|
||||
import { utilities } from '@cornerstonejs/tools';
|
||||
import { ColorbarOptions, ChangeTypes } from '../../types/Colorbar';
|
||||
const { ViewportColorbar } = utilities.voi.colorbar;
|
||||
|
||||
export default class ColorbarService extends PubSubService {
|
||||
static EVENTS = {
|
||||
STATE_CHANGED: 'event::ColorbarService:stateChanged',
|
||||
};
|
||||
|
||||
static defaultStyles = {
|
||||
position: 'absolute',
|
||||
boxSizing: 'border-box',
|
||||
border: 'solid 1px #555',
|
||||
cursor: 'initial',
|
||||
};
|
||||
|
||||
static positionStyles = {
|
||||
left: { left: '5%' },
|
||||
right: { right: '5%' },
|
||||
top: { top: '5%' },
|
||||
bottom: { bottom: '5%' },
|
||||
};
|
||||
|
||||
static defaultTickStyles = {
|
||||
position: 'left',
|
||||
style: {
|
||||
font: '12px Arial',
|
||||
color: '#fff',
|
||||
maxNumTicks: 8,
|
||||
tickSize: 5,
|
||||
tickWidth: 1,
|
||||
labelMargin: 3,
|
||||
},
|
||||
};
|
||||
|
||||
public static REGISTRATION = {
|
||||
name: 'colorbarService',
|
||||
create: () => {
|
||||
return new ColorbarService();
|
||||
},
|
||||
};
|
||||
colorbars = {};
|
||||
|
||||
constructor() {
|
||||
super(ColorbarService.EVENTS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the volume ID for a given identifier by searching through the viewport's volume IDs.
|
||||
* @param viewport - The viewport instance to search volumes in
|
||||
* @param searchId - The identifier to search for within volume IDs
|
||||
* @returns The matching volume ID if found, null otherwise
|
||||
*/
|
||||
private getVolumeIdForIdentifier(viewport, searchId: string): string | null {
|
||||
const volumeIds = viewport.getAllVolumeIds?.() || [];
|
||||
return volumeIds.length > 0 ? volumeIds.find(id => id.includes(searchId)) || null : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a colorbar to a specific viewport identified by `viewportId`, using the provided `displaySetInstanceUIDs` and `options`.
|
||||
* This method sets up the colorbar, associates it with the viewport, and applies initial configurations based on the provided options.
|
||||
*
|
||||
* @param viewportId The identifier for the viewport where the colorbar will be added.
|
||||
* @param displaySetInstanceUIDs An array of display set instance UIDs to associate with the colorbar.
|
||||
* @param options Configuration options for the colorbar, including position, colormaps, active colormap name, ticks, and width.
|
||||
*/
|
||||
public addColorbar(viewportId, displaySetInstanceUIDs, options = {} as ColorbarOptions) {
|
||||
const renderingEngine = getRenderingEngine(RENDERING_ENGINE_ID);
|
||||
const viewport = renderingEngine.getViewport(viewportId);
|
||||
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { element } = viewport;
|
||||
const actorEntries = viewport.getActors();
|
||||
|
||||
if (!actorEntries || actorEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { position, width: thickness, activeColormapName, colormaps } = options;
|
||||
|
||||
const numContainers = displaySetInstanceUIDs.length;
|
||||
|
||||
const containers = this.createContainers(
|
||||
numContainers,
|
||||
element,
|
||||
position,
|
||||
thickness,
|
||||
viewportId
|
||||
);
|
||||
|
||||
displaySetInstanceUIDs.forEach((displaySetInstanceUID, index) => {
|
||||
const volumeId = this.getVolumeIdForIdentifier(viewport, displaySetInstanceUID);
|
||||
const properties = viewport?.getProperties(volumeId);
|
||||
const colormap = properties?.colormap;
|
||||
if (activeColormapName && !colormap) {
|
||||
this.setViewportColormap(
|
||||
viewportId,
|
||||
displaySetInstanceUID,
|
||||
colormaps[activeColormapName],
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const colorbarContainer = containers[index];
|
||||
|
||||
const colorbar = new ViewportColorbar({
|
||||
id: `ctColorbar-${viewportId}-${index}`,
|
||||
element,
|
||||
colormaps: options.colormaps || {},
|
||||
// if there's an existing colormap set, we use it, otherwise we use the activeColormapName, otherwise, grayscale
|
||||
activeColormapName: colormap?.name || options?.activeColormapName || 'Grayscale',
|
||||
container: colorbarContainer,
|
||||
ticks: {
|
||||
...ColorbarService.defaultTickStyles,
|
||||
...options.ticks,
|
||||
},
|
||||
volumeId: viewport instanceof VolumeViewport ? volumeId : undefined,
|
||||
});
|
||||
if (this.colorbars[viewportId]) {
|
||||
this.colorbars[viewportId].push({ colorbar, container: colorbarContainer });
|
||||
} else {
|
||||
this.colorbars[viewportId] = [{ colorbar, container: colorbarContainer }];
|
||||
}
|
||||
});
|
||||
|
||||
this._broadcastEvent(ColorbarService.EVENTS.STATE_CHANGED, {
|
||||
viewportId,
|
||||
changeType: ChangeTypes.Added,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the colorbar associated with a given viewport ID. This involves cleaning up any created DOM elements and internal references.
|
||||
*
|
||||
* @param viewportId The identifier for the viewport from which the colorbar will be removed.
|
||||
*/
|
||||
public removeColorbar(viewportId) {
|
||||
const colorbarInfo = this.colorbars[viewportId];
|
||||
if (!colorbarInfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
colorbarInfo.forEach(({ colorbar, container }) => {
|
||||
container.parentNode.removeChild(container);
|
||||
});
|
||||
|
||||
delete this.colorbars[viewportId];
|
||||
|
||||
this._broadcastEvent(ColorbarService.EVENTS.STATE_CHANGED, {
|
||||
viewportId,
|
||||
changeType: ChangeTypes.Removed,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a colorbar is associated with a given viewport ID.
|
||||
*
|
||||
* @param viewportId The identifier for the viewport to check.
|
||||
* @returns `true` if a colorbar exists for the specified viewport, otherwise `false`.
|
||||
*/
|
||||
public hasColorbar(viewportId) {
|
||||
return this.colorbars[viewportId] ? true : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the current state of colorbars, including all active colorbars and their configurations.
|
||||
*
|
||||
* @returns An object representing the current state of all colorbars managed by this service.
|
||||
*/
|
||||
public getState() {
|
||||
return this.colorbars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves colorbar information for a specific viewport ID.
|
||||
*
|
||||
* @param viewportId The identifier for the viewport to retrieve colorbar information for.
|
||||
* @returns The colorbar information associated with the specified viewport, if available.
|
||||
*/
|
||||
public getViewportColorbar(viewportId) {
|
||||
return this.colorbars[viewportId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the cleanup and removal of all colorbars from the viewports. This is typically called
|
||||
* when exiting the mode or context in which the colorbars are used, ensuring that no DOM
|
||||
* elements or references are left behind.
|
||||
*/
|
||||
public onModeExit() {
|
||||
const viewportIds = Object.keys(this.colorbars);
|
||||
viewportIds.forEach(viewportId => {
|
||||
this.removeColorbar(viewportId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the colormap for a viewport. This function is used internally to update the colormap the viewport
|
||||
*
|
||||
* @param viewportId The identifier of the viewport to update.
|
||||
* @param displaySetInstanceUID The display set instance UID associated with the viewport.
|
||||
* @param colormap The colormap object to set on the viewport.
|
||||
* @param immediate A boolean indicating whether the viewport should be re-rendered immediately after setting the colormap.
|
||||
*/
|
||||
private setViewportColormap(viewportId, displaySetInstanceUID, colormap, immediate = false) {
|
||||
const renderingEngine = getRenderingEngine(RENDERING_ENGINE_ID);
|
||||
const viewport = renderingEngine.getViewport(viewportId);
|
||||
const actorEntries = viewport?.getActors();
|
||||
if (!viewport || !actorEntries || actorEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
const setViewportProperties = (viewport, uid) => {
|
||||
const volumeId = this.getVolumeIdForIdentifier(viewport, uid);
|
||||
viewport.setProperties({ colormap }, volumeId);
|
||||
};
|
||||
|
||||
if (viewport instanceof StackViewport) {
|
||||
setViewportProperties(viewport, viewportId);
|
||||
}
|
||||
|
||||
if (viewport instanceof VolumeViewport) {
|
||||
setViewportProperties(viewport, displaySetInstanceUID);
|
||||
}
|
||||
|
||||
if (immediate) {
|
||||
viewport.render();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the container elements for colorbars based on the specified parameters. This function dynamically
|
||||
* generates and styles DOM elements to host the colorbars, positioning them according to the specified options.
|
||||
*
|
||||
* @param numContainers The number of containers to create, typically corresponding to the number of colorbars.
|
||||
* @param element The DOM element within which the colorbar containers will be placed.
|
||||
* @param position The position of the colorbar containers (e.g., 'top', 'bottom', 'left', 'right').
|
||||
* @param thickness The thickness of the colorbar containers, affecting their width or height depending on their position.
|
||||
* @param viewportId The identifier of the viewport for which the containers are being created.
|
||||
* @returns An array of the created container DOM elements.
|
||||
*/
|
||||
private createContainers(numContainers, element, position, thickness, viewportId) {
|
||||
const containers = [];
|
||||
const dimensions = {
|
||||
1: 50,
|
||||
2: 33,
|
||||
};
|
||||
const dimension = dimensions[numContainers] || 50 / numContainers;
|
||||
|
||||
Array.from({ length: numContainers }).forEach((_, i) => {
|
||||
const colorbarContainer = document.createElement('div');
|
||||
colorbarContainer.id = `ctColorbarContainer-${viewportId}-${i + 1}`;
|
||||
|
||||
Object.assign(colorbarContainer.style, ColorbarService.defaultStyles);
|
||||
|
||||
if (['top', 'bottom'].includes(position)) {
|
||||
Object.assign(colorbarContainer.style, {
|
||||
width: `${dimension}%`,
|
||||
height: thickness || '2.5%',
|
||||
left: `${(i + 1) * dimension}%`,
|
||||
transform: 'translateX(-50%)',
|
||||
...ColorbarService.positionStyles[position],
|
||||
});
|
||||
} else if (['left', 'right'].includes(position)) {
|
||||
Object.assign(colorbarContainer.style, {
|
||||
height: `${dimension}%`,
|
||||
width: thickness || '2.5%',
|
||||
top: `${(i + 1) * dimension}%`,
|
||||
transform: 'translateY(-50%)',
|
||||
...ColorbarService.positionStyles[position],
|
||||
});
|
||||
}
|
||||
|
||||
element.appendChild(colorbarContainer);
|
||||
containers.push(colorbarContainer);
|
||||
});
|
||||
|
||||
return containers;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import ColorbarService from './ColorbarService';
|
||||
export default ColorbarService;
|
||||
@@ -0,0 +1,324 @@
|
||||
import { Types } from '@ohif/core';
|
||||
import { cache as cs3DCache, Enums, volumeLoader } from '@cornerstonejs/core';
|
||||
|
||||
import getCornerstoneViewportType from '../../utils/getCornerstoneViewportType';
|
||||
import { StackViewportData, VolumeViewportData } from '../../types/CornerstoneCacheService';
|
||||
|
||||
const VOLUME_LOADER_SCHEME = 'cornerstoneStreamingImageVolume';
|
||||
|
||||
class CornerstoneCacheService {
|
||||
static REGISTRATION = {
|
||||
name: 'cornerstoneCacheService',
|
||||
altName: 'CornerstoneCacheService',
|
||||
create: ({ servicesManager }: Types.Extensions.ExtensionParams): CornerstoneCacheService => {
|
||||
return new CornerstoneCacheService(servicesManager);
|
||||
},
|
||||
};
|
||||
|
||||
stackImageIds: Map<string, string[]> = new Map();
|
||||
volumeImageIds: Map<string, string[]> = new Map();
|
||||
readonly servicesManager: AppTypes.ServicesManager;
|
||||
|
||||
constructor(servicesManager: AppTypes.ServicesManager) {
|
||||
this.servicesManager = servicesManager;
|
||||
}
|
||||
|
||||
public getCacheSize() {
|
||||
return cs3DCache.getCacheSize();
|
||||
}
|
||||
|
||||
public getCacheFreeSpace() {
|
||||
return cs3DCache.getBytesAvailable();
|
||||
}
|
||||
|
||||
public async createViewportData(
|
||||
displaySets: Types.DisplaySet[],
|
||||
viewportOptions: AppTypes.ViewportGrid.GridViewportOptions,
|
||||
dataSource: unknown,
|
||||
initialImageIndex?: number
|
||||
): Promise<StackViewportData | VolumeViewportData> {
|
||||
const viewportType = viewportOptions.viewportType as string;
|
||||
|
||||
const cs3DViewportType = getCornerstoneViewportType(viewportType, displaySets);
|
||||
let viewportData: StackViewportData | VolumeViewportData;
|
||||
|
||||
if (
|
||||
cs3DViewportType === Enums.ViewportType.ORTHOGRAPHIC ||
|
||||
cs3DViewportType === Enums.ViewportType.VOLUME_3D
|
||||
) {
|
||||
viewportData = await this._getVolumeViewportData(dataSource, displaySets, cs3DViewportType);
|
||||
} else if (cs3DViewportType === Enums.ViewportType.STACK) {
|
||||
// Everything else looks like a stack
|
||||
viewportData = await this._getStackViewportData(
|
||||
dataSource,
|
||||
displaySets,
|
||||
initialImageIndex,
|
||||
cs3DViewportType
|
||||
);
|
||||
} else {
|
||||
viewportData = await this._getOtherViewportData(
|
||||
dataSource,
|
||||
displaySets,
|
||||
initialImageIndex,
|
||||
cs3DViewportType
|
||||
);
|
||||
}
|
||||
|
||||
viewportData.viewportType = cs3DViewportType;
|
||||
|
||||
return viewportData;
|
||||
}
|
||||
|
||||
public async invalidateViewportData(
|
||||
viewportData: VolumeViewportData | StackViewportData,
|
||||
invalidatedDisplaySetInstanceUID: string,
|
||||
dataSource,
|
||||
displaySetService
|
||||
): Promise<VolumeViewportData | StackViewportData> {
|
||||
if (viewportData.viewportType === Enums.ViewportType.STACK) {
|
||||
const displaySet = displaySetService.getDisplaySetByUID(invalidatedDisplaySetInstanceUID);
|
||||
const imageIds = this._getCornerstoneStackImageIds(displaySet, dataSource);
|
||||
|
||||
// remove images from the cache to be able to re-load them
|
||||
imageIds.forEach(imageId => {
|
||||
if (cs3DCache.getImageLoadObject(imageId)) {
|
||||
cs3DCache.removeImageLoadObject(imageId);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
viewportType: Enums.ViewportType.STACK,
|
||||
data: {
|
||||
StudyInstanceUID: displaySet.StudyInstanceUID,
|
||||
displaySetInstanceUID: invalidatedDisplaySetInstanceUID,
|
||||
imageIds,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Todo: grab the volume and get the id from the viewport itself
|
||||
const volumeId = `${VOLUME_LOADER_SCHEME}:${invalidatedDisplaySetInstanceUID}`;
|
||||
|
||||
const volume = cs3DCache.getVolume(volumeId);
|
||||
|
||||
if (volume) {
|
||||
if (volume.imageIds) {
|
||||
// also for each imageId in the volume, remove the imageId from the cache
|
||||
// since that will hold the old metadata as well
|
||||
|
||||
volume.imageIds.forEach(imageId => {
|
||||
if (cs3DCache.getImageLoadObject(imageId)) {
|
||||
cs3DCache.removeImageLoadObject(imageId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// this shouldn't be via removeVolumeLoadObject, since that will
|
||||
// remove the texture as well, but here we really just need a remove
|
||||
// from registry so that we load it again
|
||||
cs3DCache._volumeCache.delete(volumeId);
|
||||
this.volumeImageIds.delete(volumeId);
|
||||
}
|
||||
|
||||
const displaySets = viewportData.data.map(({ displaySetInstanceUID }) =>
|
||||
displaySetService.getDisplaySetByUID(displaySetInstanceUID)
|
||||
);
|
||||
|
||||
const newViewportData = await this._getVolumeViewportData(
|
||||
dataSource,
|
||||
displaySets,
|
||||
viewportData.viewportType
|
||||
);
|
||||
|
||||
return newViewportData;
|
||||
}
|
||||
|
||||
private async _getOtherViewportData(
|
||||
dataSource,
|
||||
displaySets,
|
||||
_initialImageIndex,
|
||||
viewportType: Enums.ViewportType
|
||||
): Promise<StackViewportData> {
|
||||
// TODO - handle overlays and secondary display sets, but for now assume
|
||||
// the 1st display set is the one of interest
|
||||
const [displaySet] = displaySets;
|
||||
if (!displaySet.imageIds) {
|
||||
displaySet.imagesIds = this._getCornerstoneStackImageIds(displaySet, dataSource);
|
||||
}
|
||||
const { imageIds: data, viewportType: dsViewportType } = displaySet;
|
||||
return {
|
||||
viewportType: dsViewportType || viewportType,
|
||||
data: displaySets,
|
||||
};
|
||||
}
|
||||
|
||||
private async _getStackViewportData(
|
||||
dataSource,
|
||||
displaySets,
|
||||
initialImageIndex,
|
||||
viewportType: Enums.ViewportType
|
||||
): Promise<StackViewportData> {
|
||||
const { uiNotificationService } = this.servicesManager.services;
|
||||
const overlayDisplaySets = displaySets.filter(ds => ds.isOverlayDisplaySet);
|
||||
for (const overlayDisplaySet of overlayDisplaySets) {
|
||||
if (overlayDisplaySet.load && overlayDisplaySet.load instanceof Function) {
|
||||
const { userAuthenticationService } = this.servicesManager.services;
|
||||
const headers = userAuthenticationService.getAuthorizationHeader();
|
||||
try {
|
||||
await overlayDisplaySet.load({ headers });
|
||||
} catch (e) {
|
||||
uiNotificationService.show({
|
||||
title: 'Error loading displaySet',
|
||||
message: e.message,
|
||||
type: 'error',
|
||||
});
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensuring the first non-overlay `displaySet` is always the primary one
|
||||
const StackViewportData = [];
|
||||
for (const displaySet of displaySets) {
|
||||
const { displaySetInstanceUID, StudyInstanceUID, isCompositeStack } = displaySet;
|
||||
|
||||
if (displaySet.load && displaySet.load instanceof Function) {
|
||||
const { userAuthenticationService } = this.servicesManager.services;
|
||||
const headers = userAuthenticationService.getAuthorizationHeader();
|
||||
try {
|
||||
await displaySet.load({ headers });
|
||||
} catch (e) {
|
||||
uiNotificationService.show({
|
||||
title: 'Error loading displaySet',
|
||||
message: e.message,
|
||||
type: 'error',
|
||||
});
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
let stackImageIds = this.stackImageIds.get(displaySet.displaySetInstanceUID);
|
||||
|
||||
if (!stackImageIds) {
|
||||
stackImageIds = this._getCornerstoneStackImageIds(displaySet, dataSource);
|
||||
// assign imageIds to the displaySet
|
||||
displaySet.imageIds = stackImageIds;
|
||||
this.stackImageIds.set(displaySet.displaySetInstanceUID, stackImageIds);
|
||||
}
|
||||
|
||||
StackViewportData.push({
|
||||
StudyInstanceUID,
|
||||
displaySetInstanceUID,
|
||||
isCompositeStack,
|
||||
imageIds: stackImageIds,
|
||||
initialImageIndex,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
viewportType,
|
||||
data: StackViewportData,
|
||||
};
|
||||
}
|
||||
|
||||
private async _getVolumeViewportData(
|
||||
dataSource,
|
||||
displaySets,
|
||||
viewportType: Enums.ViewportType
|
||||
): Promise<VolumeViewportData> {
|
||||
// Todo: Check the cache for multiple scenarios to see if we need to
|
||||
// decache the volume data from other viewports or not
|
||||
|
||||
const volumeData = [];
|
||||
|
||||
for (const displaySet of displaySets) {
|
||||
const { Modality } = displaySet;
|
||||
const isParametricMap = Modality === 'PMAP';
|
||||
const isSeg = Modality === 'SEG';
|
||||
|
||||
// Don't create volumes for the displaySets that have custom load
|
||||
// function (e.g., SEG, RT, since they rely on the reference volumes
|
||||
// and they take care of their own loading after they are created in their
|
||||
// getSOPClassHandler method
|
||||
|
||||
if (displaySet.load && displaySet.load instanceof Function) {
|
||||
const { userAuthenticationService } = this.servicesManager.services;
|
||||
const headers = userAuthenticationService.getAuthorizationHeader();
|
||||
|
||||
try {
|
||||
await displaySet.load({ headers });
|
||||
} catch (e) {
|
||||
const { uiNotificationService } = this.servicesManager.services;
|
||||
uiNotificationService.show({
|
||||
title: 'Error loading displaySet',
|
||||
message: e.message,
|
||||
type: 'error',
|
||||
});
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
// Parametric maps have a `load` method but it should not be loaded in the
|
||||
// same way as SEG and RTSTRUCT but like a normal volume
|
||||
if (!isParametricMap) {
|
||||
volumeData.push({
|
||||
studyInstanceUID: displaySet.StudyInstanceUID,
|
||||
displaySetInstanceUID: displaySet.displaySetInstanceUID,
|
||||
});
|
||||
|
||||
// Todo: do some cache check and empty the cache if needed
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const volumeLoaderSchema = displaySet.volumeLoaderSchema ?? VOLUME_LOADER_SCHEME;
|
||||
const volumeId = `${volumeLoaderSchema}:${displaySet.displaySetInstanceUID}`;
|
||||
let volumeImageIds = this.volumeImageIds.get(displaySet.displaySetInstanceUID);
|
||||
let volume = cs3DCache.getVolume(volumeId);
|
||||
|
||||
// Parametric maps do not have image ids but they already have volume data
|
||||
// therefore a new volume should not be created.
|
||||
if (!isParametricMap && !isSeg && (!volumeImageIds || !volume)) {
|
||||
volumeImageIds = this._getCornerstoneVolumeImageIds(displaySet, dataSource);
|
||||
|
||||
volume = await volumeLoader.createAndCacheVolume(volumeId, {
|
||||
imageIds: volumeImageIds,
|
||||
});
|
||||
|
||||
this.volumeImageIds.set(displaySet.displaySetInstanceUID, volumeImageIds);
|
||||
|
||||
// Add imageIds to the displaySet for volumes
|
||||
displaySet.imageIds = volumeImageIds;
|
||||
}
|
||||
|
||||
volumeData.push({
|
||||
StudyInstanceUID: displaySet.StudyInstanceUID,
|
||||
displaySetInstanceUID: displaySet.displaySetInstanceUID,
|
||||
volume,
|
||||
volumeId,
|
||||
imageIds: volumeImageIds,
|
||||
isDynamicVolume: displaySet.isDynamicVolume,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
viewportType,
|
||||
data: volumeData,
|
||||
};
|
||||
}
|
||||
|
||||
private _getCornerstoneStackImageIds(displaySet, dataSource): string[] {
|
||||
return dataSource.getImageIdsForDisplaySet(displaySet);
|
||||
}
|
||||
|
||||
private _getCornerstoneVolumeImageIds(displaySet, dataSource): string[] {
|
||||
if (displaySet.imageIds) {
|
||||
return displaySet.imageIds;
|
||||
}
|
||||
|
||||
const stackImageIds = this._getCornerstoneStackImageIds(displaySet, dataSource);
|
||||
|
||||
return stackImageIds;
|
||||
}
|
||||
}
|
||||
|
||||
export default CornerstoneCacheService;
|
||||
@@ -0,0 +1,3 @@
|
||||
import CornerstoneCacheService from './CornerstoneCacheService';
|
||||
|
||||
export default CornerstoneCacheService;
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Maps a DICOM RT Struct ROI Contour to a RTStruct data that can be used
|
||||
* in Segmentation Service
|
||||
*
|
||||
* @param structureSet - A DICOM RT Struct ROI Contour
|
||||
* @param rtDisplaySetUID - A CornerstoneTools DisplaySet UID
|
||||
* @returns An array of object that includes data, id, segmentIndex, color
|
||||
* and geometry Id
|
||||
*/
|
||||
export function mapROIContoursToRTStructData(structureSet: unknown, rtDisplaySetUID: unknown) {
|
||||
return structureSet.ROIContours.map(({ contourPoints, ROINumber, ROIName, colorArray }) => {
|
||||
const data = contourPoints.map(({ points, ...rest }) => {
|
||||
const newPoints = points.map(({ x, y, z }) => {
|
||||
return [x, y, z];
|
||||
});
|
||||
|
||||
return {
|
||||
...rest,
|
||||
points: newPoints,
|
||||
};
|
||||
});
|
||||
|
||||
const id = ROIName || ROINumber;
|
||||
|
||||
return {
|
||||
data,
|
||||
id,
|
||||
segmentIndex: ROINumber,
|
||||
color: colorArray,
|
||||
geometryId: `${rtDisplaySetUID}:${id}:segmentIndex-${ROINumber}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
import SegmentationService from './SegmentationService';
|
||||
|
||||
export default SegmentationService;
|
||||
@@ -0,0 +1,264 @@
|
||||
import { synchronizers, SynchronizerManager, Synchronizer } from '@cornerstonejs/tools';
|
||||
import { getRenderingEngines, utilities } from '@cornerstonejs/core';
|
||||
|
||||
import { pubSubServiceInterface, Types } from '@ohif/core';
|
||||
import createHydrateSegmentationSynchronizer from './createHydrateSegmentationSynchronizer';
|
||||
|
||||
const EVENTS = {
|
||||
TOOL_GROUP_CREATED: 'event::cornerstone::syncgroupservice:toolgroupcreated',
|
||||
};
|
||||
|
||||
/**
|
||||
* @params options - are an optional set of options associated with the first
|
||||
* sync group declared.
|
||||
*/
|
||||
export type SyncCreator = (id: string, options?: Record<string, unknown>) => Synchronizer;
|
||||
|
||||
export type SyncGroup = {
|
||||
type: string;
|
||||
id?: string;
|
||||
// Source and target default to true if not specified
|
||||
source?: boolean;
|
||||
target?: boolean;
|
||||
options?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const POSITION = 'cameraposition';
|
||||
const VOI = 'voi';
|
||||
const ZOOMPAN = 'zoompan';
|
||||
const STACKIMAGE = 'stackimage';
|
||||
const IMAGE_SLICE = 'imageslice';
|
||||
const HYDRATE_SEG = 'hydrateseg';
|
||||
|
||||
const asSyncGroup = (syncGroup: string | SyncGroup): SyncGroup =>
|
||||
typeof syncGroup === 'string' ? { type: syncGroup } : syncGroup;
|
||||
|
||||
export default class SyncGroupService {
|
||||
static REGISTRATION = {
|
||||
name: 'syncGroupService',
|
||||
altName: 'SyncGroupService',
|
||||
create: ({ servicesManager }: Types.Extensions.ExtensionParams): SyncGroupService => {
|
||||
return new SyncGroupService(servicesManager);
|
||||
},
|
||||
};
|
||||
|
||||
servicesManager: AppTypes.ServicesManager;
|
||||
listeners: { [key: string]: (...args: any[]) => void } = {};
|
||||
EVENTS: { [key: string]: string };
|
||||
synchronizerCreators: Record<string, SyncCreator> = {
|
||||
[POSITION]: synchronizers.createCameraPositionSynchronizer,
|
||||
[VOI]: synchronizers.createVOISynchronizer,
|
||||
[ZOOMPAN]: synchronizers.createZoomPanSynchronizer,
|
||||
// todo: remove stack image since it is legacy now and the image_slice
|
||||
// handles both stack and volume viewports
|
||||
[STACKIMAGE]: synchronizers.createImageSliceSynchronizer,
|
||||
[IMAGE_SLICE]: synchronizers.createImageSliceSynchronizer,
|
||||
[HYDRATE_SEG]: createHydrateSegmentationSynchronizer,
|
||||
};
|
||||
|
||||
synchronizersByType: { [key: string]: Synchronizer[] } = {};
|
||||
|
||||
constructor(servicesManager: AppTypes.ServicesManager) {
|
||||
this.servicesManager = servicesManager;
|
||||
this.listeners = {};
|
||||
this.EVENTS = EVENTS;
|
||||
//
|
||||
Object.assign(this, pubSubServiceInterface);
|
||||
}
|
||||
|
||||
private _createSynchronizer(type: string, id: string, options): Synchronizer | undefined {
|
||||
// Initialize if not already done
|
||||
this.synchronizersByType[type] = this.synchronizersByType[type] || [];
|
||||
const syncCreator = this.synchronizerCreators[type.toLowerCase()];
|
||||
|
||||
if (syncCreator) {
|
||||
// Pass the servicesManager along with other parameters
|
||||
const synchronizer = syncCreator(id, { ...options, servicesManager: this.servicesManager });
|
||||
|
||||
if (synchronizer) {
|
||||
this.synchronizersByType[type].push(synchronizer);
|
||||
return synchronizer;
|
||||
}
|
||||
} else {
|
||||
console.debug(`Unknown synchronizer type: ${type}, id: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
public getSyncCreatorForType(type: string): SyncCreator {
|
||||
return this.synchronizerCreators[type.toLowerCase()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a synchronizer type.
|
||||
* @param type is the type of the synchronizer to create
|
||||
* @param creator
|
||||
*/
|
||||
public addSynchronizerType(type: string, creator: SyncCreator): void {
|
||||
this.synchronizerCreators[type.toLowerCase()] = creator;
|
||||
}
|
||||
|
||||
public getSynchronizer(id: string): Synchronizer | void {
|
||||
return SynchronizerManager.getSynchronizer(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a custom synchronizer.
|
||||
* @param id - The id of the synchronizer.
|
||||
* @param createFunction - The function that creates the synchronizer.
|
||||
*/
|
||||
public registerCustomSynchronizer(id: string, createFunction: SyncCreator): void {
|
||||
this.synchronizerCreators[id] = createFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an array of synchronizers of a specific type.
|
||||
* @param type - The type of synchronizers to retrieve.
|
||||
* @returns An array of synchronizers of the specified type.
|
||||
*/
|
||||
public getSynchronizersOfType(type: string): Synchronizer[] {
|
||||
return this.synchronizersByType[type];
|
||||
}
|
||||
|
||||
protected _getOrCreateSynchronizer(
|
||||
type: string,
|
||||
id: string,
|
||||
options: Record<string, unknown>
|
||||
): Synchronizer | undefined {
|
||||
let synchronizer = SynchronizerManager.getSynchronizer(id);
|
||||
|
||||
if (!synchronizer) {
|
||||
synchronizer = this._createSynchronizer(type, id, options);
|
||||
}
|
||||
return synchronizer;
|
||||
}
|
||||
|
||||
public addViewportToSyncGroup(
|
||||
viewportId: string,
|
||||
renderingEngineId: string,
|
||||
syncGroups?: SyncGroup | string | SyncGroup[] | string[]
|
||||
): void {
|
||||
if (!syncGroups) {
|
||||
return;
|
||||
}
|
||||
|
||||
const syncGroupsArray = Array.isArray(syncGroups) ? syncGroups : [syncGroups];
|
||||
|
||||
syncGroupsArray.forEach(syncGroup => {
|
||||
const syncGroupObj = asSyncGroup(syncGroup);
|
||||
const { type, target = true, source = true, options = {}, id = type } = syncGroupObj;
|
||||
|
||||
const synchronizer = this._getOrCreateSynchronizer(type, id, options);
|
||||
|
||||
if (!synchronizer) {
|
||||
return;
|
||||
}
|
||||
|
||||
synchronizer.setOptions(viewportId, options);
|
||||
|
||||
const viewportInfo = { viewportId, renderingEngineId };
|
||||
if (target && source) {
|
||||
synchronizer.add(viewportInfo);
|
||||
return;
|
||||
} else if (source) {
|
||||
synchronizer.addSource(viewportInfo);
|
||||
} else if (target) {
|
||||
synchronizer.addTarget(viewportInfo);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
SynchronizerManager.destroy();
|
||||
}
|
||||
|
||||
public getSynchronizersForViewport(viewportId: string): Synchronizer[] {
|
||||
const renderingEngine =
|
||||
getRenderingEngines().find(re => {
|
||||
return re.getViewports().find(vp => vp.id === viewportId);
|
||||
}) || getRenderingEngines()[0];
|
||||
|
||||
const synchronizers = SynchronizerManager.getAllSynchronizers();
|
||||
return synchronizers.filter(
|
||||
s =>
|
||||
s.hasSourceViewport(renderingEngine.id, viewportId) ||
|
||||
s.hasTargetViewport(renderingEngine.id, viewportId)
|
||||
);
|
||||
}
|
||||
|
||||
public removeViewportFromSyncGroup(
|
||||
viewportId: string,
|
||||
renderingEngineId: string,
|
||||
syncGroupId?: string
|
||||
): void {
|
||||
const synchronizers = SynchronizerManager.getAllSynchronizers();
|
||||
|
||||
const filteredSynchronizers = syncGroupId
|
||||
? synchronizers.filter(s => s.id === syncGroupId)
|
||||
: synchronizers;
|
||||
|
||||
filteredSynchronizers.forEach(synchronizer => {
|
||||
if (!synchronizer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only image slice synchronizer register spatial registration
|
||||
if (this.isImageSliceSyncronizer(synchronizer)) {
|
||||
this.unRegisterSpatialRegistration(synchronizer);
|
||||
}
|
||||
|
||||
synchronizer.remove({
|
||||
viewportId,
|
||||
renderingEngineId,
|
||||
});
|
||||
|
||||
// check if any viewport is left in any of the sync groups, if not, delete that sync group
|
||||
const sourceViewports = synchronizer.getSourceViewports();
|
||||
const targetViewports = synchronizer.getTargetViewports();
|
||||
|
||||
if (!sourceViewports.length && !targetViewports.length) {
|
||||
SynchronizerManager.destroySynchronizer(synchronizer.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Clean up the spatial registration metadata created by synchronizer
|
||||
* This is needed to be able to re-sync images slices if needed
|
||||
* @param synchronizer
|
||||
*/
|
||||
unRegisterSpatialRegistration(synchronizer: Synchronizer) {
|
||||
const sourceViewports = synchronizer.getSourceViewports().map(vp => vp.viewportId);
|
||||
const targetViewports = synchronizer.getTargetViewports().map(vp => vp.viewportId);
|
||||
|
||||
// Create an array of pair of viewports to remove from spatialRegistrationMetadataProvider
|
||||
// All sourceViewports combined with all targetViewports
|
||||
const toUnregister = sourceViewports
|
||||
.map((sourceViewportId: string) => {
|
||||
return targetViewports.map(targetViewportId => [targetViewportId, sourceViewportId]);
|
||||
})
|
||||
.reduce((acc, c) => acc.concat(c), []);
|
||||
|
||||
toUnregister.forEach(viewportIdPair => {
|
||||
utilities.spatialRegistrationMetadataProvider.add(viewportIdPair, undefined);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Check if the synchronizer type is IMAGE_SLICE
|
||||
* Need to convert to lowercase here because the types are lowercase
|
||||
* e.g: synchronizerCreators
|
||||
* @param synchronizer
|
||||
*/
|
||||
isImageSliceSyncronizer(synchronizer: Synchronizer) {
|
||||
return this.getSynchronizerType(synchronizer).toLowerCase() === IMAGE_SLICE;
|
||||
}
|
||||
/**
|
||||
* Returns the syncronizer type
|
||||
* @param synchronizer
|
||||
*/
|
||||
getSynchronizerType(synchronizer: Synchronizer): string {
|
||||
const synchronizerTypes = Object.keys(this.synchronizersByType);
|
||||
const syncType = synchronizerTypes.find(syncType =>
|
||||
this.getSynchronizersOfType(syncType).includes(synchronizer)
|
||||
);
|
||||
return syncType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Types, getEnabledElementByViewportId } from '@cornerstonejs/core';
|
||||
import {
|
||||
SynchronizerManager,
|
||||
Synchronizer,
|
||||
Enums,
|
||||
Types as ToolsTypes,
|
||||
} from '@cornerstonejs/tools';
|
||||
|
||||
const { createSynchronizer } = SynchronizerManager;
|
||||
const { SEGMENTATION_REPRESENTATION_ADDED } = Enums.Events;
|
||||
|
||||
export default function createHydrateSegmentationSynchronizer(
|
||||
synchronizerName: string,
|
||||
{ servicesManager, ...options }: { servicesManager: AppTypes.ServicesManager; options }
|
||||
): Synchronizer {
|
||||
const stackImageSynchronizer = createSynchronizer(
|
||||
synchronizerName,
|
||||
SEGMENTATION_REPRESENTATION_ADDED,
|
||||
(synchronizerInstance, sourceViewport, targetViewport, sourceEvent) =>
|
||||
segmentationRepresentationModifiedCallback(
|
||||
synchronizerInstance,
|
||||
sourceViewport,
|
||||
targetViewport,
|
||||
sourceEvent,
|
||||
{ servicesManager, options }
|
||||
),
|
||||
{
|
||||
eventSource: 'eventTarget',
|
||||
}
|
||||
);
|
||||
|
||||
return stackImageSynchronizer;
|
||||
}
|
||||
|
||||
const segmentationRepresentationModifiedCallback = async (
|
||||
synchronizerInstance: Synchronizer,
|
||||
sourceViewport: Types.IViewportId,
|
||||
targetViewport: Types.IViewportId,
|
||||
sourceEvent: Event,
|
||||
{ servicesManager, options }: { servicesManager: AppTypes.ServicesManager; options: unknown }
|
||||
) => {
|
||||
const event = sourceEvent as ToolsTypes.EventTypes.SegmentationRepresentationModifiedEventType;
|
||||
|
||||
const { segmentationId, viewportId } = event.detail;
|
||||
const { segmentationService, hangingProtocolService } = servicesManager.services;
|
||||
|
||||
const targetViewportId = targetViewport.viewportId;
|
||||
|
||||
const { viewport } = getEnabledElementByViewportId(targetViewportId);
|
||||
|
||||
const targetFrameOfReferenceUID = viewport.getFrameOfReferenceUID();
|
||||
|
||||
if (!targetFrameOfReferenceUID) {
|
||||
console.debug('No frame of reference UID found for the target viewport');
|
||||
return;
|
||||
}
|
||||
|
||||
const targetViewportRepresentation = segmentationService.getSegmentationRepresentations(
|
||||
targetViewportId,
|
||||
{ segmentationId }
|
||||
);
|
||||
|
||||
if (targetViewportRepresentation.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// whatever type the source viewport has, we need to add that to the target viewport
|
||||
const sourceViewportRepresentation = segmentationService.getSegmentationRepresentations(
|
||||
sourceViewport.viewportId,
|
||||
{ segmentationId }
|
||||
);
|
||||
|
||||
const type = sourceViewportRepresentation[0].type;
|
||||
|
||||
await segmentationService.addSegmentationRepresentation(targetViewportId, {
|
||||
segmentationId,
|
||||
type,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import SyncGroupService from './SyncGroupService';
|
||||
|
||||
export default SyncGroupService;
|
||||
@@ -0,0 +1,321 @@
|
||||
import { ToolGroupManager, Enums, Types } from '@cornerstonejs/tools';
|
||||
import { eventTarget } from '@cornerstonejs/core';
|
||||
|
||||
import { Types as OhifTypes, pubSubServiceInterface } from '@ohif/core';
|
||||
import getActiveViewportEnabledElement from '../../utils/getActiveViewportEnabledElement';
|
||||
|
||||
const EVENTS = {
|
||||
VIEWPORT_ADDED: 'event::cornerstone::toolgroupservice:viewportadded',
|
||||
TOOLGROUP_CREATED: 'event::cornerstone::toolgroupservice:toolgroupcreated',
|
||||
TOOL_ACTIVATED: 'event::cornerstone::toolgroupservice:toolactivated',
|
||||
PRIMARY_TOOL_ACTIVATED: 'event::cornerstone::toolgroupservice:primarytoolactivated',
|
||||
};
|
||||
|
||||
type Tool = {
|
||||
toolName: string;
|
||||
bindings?: typeof Enums.MouseBindings | Enums.KeyboardBindings;
|
||||
};
|
||||
|
||||
type Tools = {
|
||||
active: Tool[];
|
||||
passive?: Tool[];
|
||||
enabled?: Tool[];
|
||||
disabled?: Tool[];
|
||||
};
|
||||
|
||||
export default class ToolGroupService {
|
||||
public static REGISTRATION = {
|
||||
name: 'toolGroupService',
|
||||
altName: 'ToolGroupService',
|
||||
create: ({ servicesManager }: OhifTypes.Extensions.ExtensionParams): ToolGroupService => {
|
||||
return new ToolGroupService(servicesManager);
|
||||
},
|
||||
};
|
||||
|
||||
servicesManager: AppTypes.ServicesManager;
|
||||
cornerstoneViewportService: any;
|
||||
viewportGridService: any;
|
||||
uiNotificationService: any;
|
||||
private toolGroupIds: Set<string> = new Set();
|
||||
/**
|
||||
* Service-specific
|
||||
*/
|
||||
listeners: { [key: string]: Function[] };
|
||||
EVENTS: { [key: string]: string };
|
||||
|
||||
constructor(servicesManager: AppTypes.ServicesManager) {
|
||||
const { cornerstoneViewportService, viewportGridService, uiNotificationService } =
|
||||
servicesManager.services;
|
||||
this.cornerstoneViewportService = cornerstoneViewportService;
|
||||
this.viewportGridService = viewportGridService;
|
||||
this.uiNotificationService = uiNotificationService;
|
||||
this.listeners = {};
|
||||
this.EVENTS = EVENTS;
|
||||
Object.assign(this, pubSubServiceInterface);
|
||||
|
||||
this._init();
|
||||
}
|
||||
|
||||
onModeExit() {
|
||||
this.destroy();
|
||||
}
|
||||
|
||||
private _init() {
|
||||
eventTarget.addEventListener(Enums.Events.TOOL_ACTIVATED, this._onToolActivated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a tool group from the ToolGroupManager by tool group ID.
|
||||
* If no tool group ID is provided, it retrieves the tool group of the active viewport.
|
||||
* @param toolGroupId - Optional ID of the tool group to retrieve.
|
||||
* @returns The tool group or undefined if it is not found.
|
||||
*/
|
||||
public getToolGroup(toolGroupId?: string): Types.IToolGroup | void {
|
||||
let toolGroupIdToUse = toolGroupId;
|
||||
|
||||
if (!toolGroupIdToUse) {
|
||||
// Use the active viewport's tool group if no tool group id is provided
|
||||
const enabledElement = getActiveViewportEnabledElement(this.viewportGridService);
|
||||
|
||||
if (!enabledElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { renderingEngineId, viewportId } = enabledElement;
|
||||
const toolGroup = ToolGroupManager.getToolGroupForViewport(viewportId, renderingEngineId);
|
||||
|
||||
if (!toolGroup) {
|
||||
console.warn(
|
||||
'No tool group found for viewportId:',
|
||||
viewportId,
|
||||
'and renderingEngineId:',
|
||||
renderingEngineId
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toolGroupIdToUse = toolGroup.id;
|
||||
}
|
||||
|
||||
const toolGroup = ToolGroupManager.getToolGroup(toolGroupIdToUse);
|
||||
return toolGroup;
|
||||
}
|
||||
|
||||
public getToolGroupIds(): string[] {
|
||||
return Array.from(this.toolGroupIds);
|
||||
}
|
||||
|
||||
public getToolGroupForViewport(viewportId: string): Types.IToolGroup | void {
|
||||
const renderingEngine = this.cornerstoneViewportService.getRenderingEngine();
|
||||
return ToolGroupManager.getToolGroupForViewport(viewportId, renderingEngine.id);
|
||||
}
|
||||
|
||||
public getActiveToolForViewport(viewportId: string): string {
|
||||
const toolGroup = this.getToolGroupForViewport(viewportId);
|
||||
if (!toolGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
return toolGroup.getActivePrimaryMouseButtonTool();
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
ToolGroupManager.destroy();
|
||||
this.toolGroupIds = new Set();
|
||||
|
||||
eventTarget.removeEventListener(Enums.Events.TOOL_ACTIVATED, this._onToolActivated);
|
||||
}
|
||||
|
||||
public destroyToolGroup(toolGroupId: string): void {
|
||||
ToolGroupManager.destroyToolGroup(toolGroupId);
|
||||
this.toolGroupIds.delete(toolGroupId);
|
||||
}
|
||||
|
||||
public removeViewportFromToolGroup(
|
||||
viewportId: string,
|
||||
renderingEngineId: string,
|
||||
deleteToolGroupIfEmpty?: boolean
|
||||
): void {
|
||||
const toolGroup = ToolGroupManager.getToolGroupForViewport(viewportId, renderingEngineId);
|
||||
|
||||
if (!toolGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
toolGroup.removeViewports(renderingEngineId, viewportId);
|
||||
|
||||
const viewportIds = toolGroup.getViewportIds();
|
||||
|
||||
if (viewportIds.length === 0 && deleteToolGroupIfEmpty) {
|
||||
ToolGroupManager.destroyToolGroup(toolGroup.id);
|
||||
}
|
||||
}
|
||||
|
||||
public addViewportToToolGroup(
|
||||
viewportId: string,
|
||||
renderingEngineId: string,
|
||||
toolGroupId?: string
|
||||
): void {
|
||||
if (!toolGroupId) {
|
||||
// If toolGroupId is not provided, add the viewport to all toolGroups
|
||||
const toolGroups = ToolGroupManager.getAllToolGroups();
|
||||
toolGroups.forEach(toolGroup => {
|
||||
toolGroup.addViewport(viewportId, renderingEngineId);
|
||||
});
|
||||
} else {
|
||||
let toolGroup = ToolGroupManager.getToolGroup(toolGroupId);
|
||||
if (!toolGroup) {
|
||||
toolGroup = this.createToolGroup(toolGroupId);
|
||||
}
|
||||
|
||||
toolGroup.addViewport(viewportId, renderingEngineId);
|
||||
}
|
||||
|
||||
this._broadcastEvent(EVENTS.VIEWPORT_ADDED, {
|
||||
viewportId,
|
||||
toolGroupId,
|
||||
});
|
||||
}
|
||||
|
||||
public createToolGroup(toolGroupId: string): Types.IToolGroup {
|
||||
if (this.getToolGroup(toolGroupId)) {
|
||||
throw new Error(`ToolGroup ${toolGroupId} already exists`);
|
||||
}
|
||||
|
||||
// if the toolGroup doesn't exist, create it
|
||||
const toolGroup = ToolGroupManager.createToolGroup(toolGroupId);
|
||||
this.toolGroupIds.add(toolGroupId);
|
||||
|
||||
this._broadcastEvent(EVENTS.TOOLGROUP_CREATED, {
|
||||
toolGroupId,
|
||||
});
|
||||
|
||||
return toolGroup;
|
||||
}
|
||||
|
||||
public addToolsToToolGroup(toolGroupId: string, tools: Array<Tool>, configs: any = {}): void {
|
||||
const toolGroup = ToolGroupManager.getToolGroup(toolGroupId);
|
||||
// this.changeConfigurationIfNecessary(toolGroup, volumeId);
|
||||
this._addTools(toolGroup, tools, configs);
|
||||
this._setToolsMode(toolGroup, tools);
|
||||
}
|
||||
|
||||
public createToolGroupAndAddTools(toolGroupId: string, tools: Array<Tool>): Types.IToolGroup {
|
||||
const toolGroup = this.createToolGroup(toolGroupId);
|
||||
this.addToolsToToolGroup(toolGroupId, tools);
|
||||
return toolGroup;
|
||||
}
|
||||
/**
|
||||
* Get the tool's configuration based on the tool name and tool group id
|
||||
* @param toolGroupId - The id of the tool group that the tool instance belongs to.
|
||||
* @param toolName - The name of the tool
|
||||
* @returns The configuration of the tool.
|
||||
*/
|
||||
public getToolConfiguration(toolGroupId: string, toolName: string) {
|
||||
const toolGroup = ToolGroupManager.getToolGroup(toolGroupId);
|
||||
if (!toolGroup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tool = toolGroup.getToolInstance(toolName);
|
||||
if (!tool) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return tool.configuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the tool instance configuration. This will update the tool instance configuration
|
||||
* on the toolGroup
|
||||
* @param toolGroupId - The id of the tool group that the tool instance belongs to.
|
||||
* @param toolName - The name of the tool
|
||||
* @param config - The configuration object that you want to set.
|
||||
*/
|
||||
public setToolConfiguration(toolGroupId, toolName, config) {
|
||||
const toolGroup = ToolGroupManager.getToolGroup(toolGroupId);
|
||||
const toolInstance = toolGroup.getToolInstance(toolName);
|
||||
toolInstance.configuration = config;
|
||||
}
|
||||
|
||||
public getActivePrimaryMouseButtonTool(toolGroupId?: string): string {
|
||||
return this.getToolGroup(toolGroupId)?.getActivePrimaryMouseButtonTool();
|
||||
}
|
||||
|
||||
private _setToolsMode(toolGroup, tools) {
|
||||
const { active, passive, enabled, disabled } = tools;
|
||||
|
||||
if (active) {
|
||||
active.forEach(({ toolName, bindings }) => {
|
||||
toolGroup.setToolActive(toolName, { bindings });
|
||||
});
|
||||
}
|
||||
|
||||
if (passive) {
|
||||
passive.forEach(({ toolName }) => {
|
||||
toolGroup.setToolPassive(toolName);
|
||||
});
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
enabled.forEach(({ toolName }) => {
|
||||
toolGroup.setToolEnabled(toolName);
|
||||
});
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
disabled.forEach(({ toolName }) => {
|
||||
toolGroup.setToolDisabled(toolName);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _addTools(toolGroup, tools) {
|
||||
const addTools = tools => {
|
||||
tools.forEach(({ toolName, parentTool, configuration }) => {
|
||||
if (parentTool) {
|
||||
toolGroup.addToolInstance(toolName, parentTool, {
|
||||
...configuration,
|
||||
});
|
||||
} else {
|
||||
toolGroup.addTool(toolName, { ...configuration });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (tools.active) {
|
||||
addTools(tools.active);
|
||||
}
|
||||
|
||||
if (tools.passive) {
|
||||
addTools(tools.passive);
|
||||
}
|
||||
|
||||
if (tools.enabled) {
|
||||
addTools(tools.enabled);
|
||||
}
|
||||
|
||||
if (tools.disabled) {
|
||||
addTools(tools.disabled);
|
||||
}
|
||||
}
|
||||
|
||||
private _onToolActivated = (evt: Types.EventTypes.ToolActivatedEventType) => {
|
||||
const { toolGroupId, toolName, toolBindingsOptions } = evt.detail;
|
||||
const isPrimaryTool = toolBindingsOptions.bindings?.some(
|
||||
binding => binding.mouseButton === Enums.MouseBindings.Primary
|
||||
);
|
||||
|
||||
const callbackProps = {
|
||||
toolGroupId,
|
||||
toolName,
|
||||
toolBindingsOptions,
|
||||
};
|
||||
|
||||
this._broadcastEvent(EVENTS.TOOL_ACTIVATED, callbackProps);
|
||||
|
||||
if (isPrimaryTool) {
|
||||
this._broadcastEvent(EVENTS.PRIMARY_TOOL_ACTIVATED, callbackProps);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import ToolGroupService from './ToolGroupService';
|
||||
|
||||
export default ToolGroupService;
|
||||
@@ -0,0 +1,71 @@
|
||||
import { PubSubService } from '@ohif/core';
|
||||
import { ViewportActionCornersLocations } from '@ohif/ui';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export type ActionComponentInfo = {
|
||||
viewportId: string;
|
||||
id: string;
|
||||
component: ReactNode;
|
||||
location: ViewportActionCornersLocations;
|
||||
indexPriority?: number;
|
||||
};
|
||||
|
||||
class ViewportActionCornersService extends PubSubService {
|
||||
public static readonly EVENTS = {};
|
||||
public static readonly LOCATIONS = ViewportActionCornersLocations;
|
||||
|
||||
public static REGISTRATION = {
|
||||
name: 'viewportActionCornersService',
|
||||
altName: 'ViewportActionCornersService',
|
||||
create: ({ configuration = {} }) => {
|
||||
return new ViewportActionCornersService();
|
||||
},
|
||||
};
|
||||
|
||||
serviceImplementation = {};
|
||||
|
||||
public LOCATIONS = ViewportActionCornersService.LOCATIONS;
|
||||
|
||||
constructor() {
|
||||
super(ViewportActionCornersService.EVENTS);
|
||||
this.serviceImplementation = {};
|
||||
}
|
||||
|
||||
public setServiceImplementation({
|
||||
getState: getStateImplementation,
|
||||
addComponent: addComponentImplementation,
|
||||
addComponents: addComponentsImplementation,
|
||||
clear: clearComponentsImplementation,
|
||||
}): void {
|
||||
if (getStateImplementation) {
|
||||
this.serviceImplementation._getState = getStateImplementation;
|
||||
}
|
||||
if (addComponentImplementation) {
|
||||
this.serviceImplementation._addComponent = addComponentImplementation;
|
||||
}
|
||||
if (addComponentsImplementation) {
|
||||
this.serviceImplementation._addComponents = addComponentsImplementation;
|
||||
}
|
||||
if (clearComponentsImplementation) {
|
||||
this.serviceImplementation._clear = clearComponentsImplementation;
|
||||
}
|
||||
}
|
||||
|
||||
public getState() {
|
||||
return this.serviceImplementation._getState();
|
||||
}
|
||||
|
||||
public addComponent(component: ActionComponentInfo) {
|
||||
this.serviceImplementation._addComponent(component);
|
||||
}
|
||||
|
||||
public addComponents(components: Array<ActionComponentInfo>) {
|
||||
this.serviceImplementation._addComponents(components);
|
||||
}
|
||||
|
||||
public clear(viewportId: string) {
|
||||
this.serviceImplementation._clear(viewportId);
|
||||
}
|
||||
}
|
||||
|
||||
export default ViewportActionCornersService;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
import { Types } from '@cornerstonejs/core';
|
||||
import { StackViewportData, VolumeViewportData } from '../../types/CornerstoneCacheService';
|
||||
import { DisplaySetOptions, PublicViewportOptions } from './Viewport';
|
||||
import { Presentations } from '../../types/Presentation';
|
||||
|
||||
/**
|
||||
* Handles cornerstone viewport logic including enabling, disabling, and
|
||||
* updating the viewport.
|
||||
*/
|
||||
export interface IViewportService {
|
||||
servicesManager: AppTypes.ServicesManager;
|
||||
hangingProtocolService: unknown;
|
||||
renderingEngine: unknown;
|
||||
viewportGridResizeObserver: unknown;
|
||||
viewportsInfo: unknown;
|
||||
sceneVolumeInputs: unknown;
|
||||
viewportDivElements: unknown;
|
||||
ViewportPropertiesMap: unknown;
|
||||
volumeUIDs: unknown;
|
||||
listeners: { [key: string]: {} };
|
||||
displaySetsNeedRerendering: unknown;
|
||||
viewportDisplaySets: unknown;
|
||||
EVENTS: { [key: string]: string };
|
||||
_broadcastEvent: unknown;
|
||||
/**
|
||||
* Adds the HTML element to the viewportService
|
||||
* @param {*} elementRef
|
||||
*/
|
||||
enableViewport(viewportId: string, elementRef: HTMLDivElement): void;
|
||||
/**
|
||||
* It retrieves the renderingEngine if it does exist, or creates one otherwise
|
||||
* @returns {RenderingEngine} rendering engine
|
||||
*/
|
||||
getRenderingEngine(): Types.IRenderingEngine;
|
||||
/**
|
||||
* It creates a resize observer for the viewport element, and observes
|
||||
* the element for resizing events
|
||||
* @param {*} elementRef
|
||||
*/
|
||||
resize(isGridResize: boolean): void;
|
||||
/**
|
||||
* Removes the viewport from cornerstone, and destroys the rendering engine
|
||||
*/
|
||||
destroy(): void;
|
||||
/**
|
||||
* Disables the viewport inside the renderingEngine, if no viewport is left
|
||||
* it destroys the renderingEngine.
|
||||
* @param viewportId
|
||||
*/
|
||||
disableElement(viewportId: string): void;
|
||||
/**
|
||||
* Uses the renderingEngine to enable the element for the given viewport index
|
||||
* and sets the displaySet data to the viewport
|
||||
* @param {*} displaySet
|
||||
* @param {*} dataSource
|
||||
* @returns
|
||||
*/
|
||||
setViewportData(
|
||||
viewportId: string,
|
||||
viewportData: StackViewportData | VolumeViewportData,
|
||||
publicViewportOptions: PublicViewportOptions,
|
||||
publicDisplaySetOptions: DisplaySetOptions[],
|
||||
presentations?: Presentations
|
||||
): void;
|
||||
}
|
||||
361
extensions/cornerstone/src/services/ViewportService/Viewport.ts
Normal file
361
extensions/cornerstone/src/services/ViewportService/Viewport.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import {
|
||||
Types,
|
||||
Enums,
|
||||
getEnabledElementByViewportId,
|
||||
VolumeViewport,
|
||||
utilities,
|
||||
} from '@cornerstonejs/core';
|
||||
import { StackViewportData, VolumeViewportData } from '../../types/CornerstoneCacheService';
|
||||
import getCornerstoneBlendMode from '../../utils/getCornerstoneBlendMode';
|
||||
import getCornerstoneOrientation from '../../utils/getCornerstoneOrientation';
|
||||
import getCornerstoneViewportType from '../../utils/getCornerstoneViewportType';
|
||||
import JumpPresets from '../../utils/JumpPresets';
|
||||
import { SyncGroup } from '../SyncGroupService/SyncGroupService';
|
||||
|
||||
export type InitialImageOptions = {
|
||||
index?: number;
|
||||
preset?: JumpPresets;
|
||||
useOnce?: boolean;
|
||||
};
|
||||
|
||||
export type ViewportOptions = {
|
||||
id?: string;
|
||||
viewportType: Enums.ViewportType;
|
||||
toolGroupId: string;
|
||||
viewportId: string;
|
||||
// Presentation ID to store/load presentation state from
|
||||
presentationIds?: AppTypes.PresentationIds;
|
||||
orientation?: Enums.OrientationAxis;
|
||||
background?: Types.Point3;
|
||||
displayArea?: Types.DisplayArea;
|
||||
syncGroups?: SyncGroup[];
|
||||
initialImageOptions?: InitialImageOptions;
|
||||
rotation?: number;
|
||||
flipHorizontal?: boolean;
|
||||
viewReference?: Types.ViewReference;
|
||||
customViewportProps?: Record<string, unknown>;
|
||||
/*
|
||||
* Allows drag and drop of display sets not matching viewport options, but
|
||||
* doesn't show them initially. Displays initially blank if no required match
|
||||
*/
|
||||
allowUnmatchedView?: boolean;
|
||||
};
|
||||
|
||||
export type PublicViewportOptions = {
|
||||
id?: string;
|
||||
viewportType?: string;
|
||||
toolGroupId?: string;
|
||||
presentationIds?: string[];
|
||||
viewportId?: string;
|
||||
orientation?: Enums.OrientationAxis;
|
||||
background?: Types.Point3;
|
||||
displayArea?: Types.DisplayArea;
|
||||
syncGroups?: SyncGroup[];
|
||||
rotation?: number;
|
||||
flipHorizontal?: boolean;
|
||||
initialImageOptions?: InitialImageOptions;
|
||||
customViewportProps?: Record<string, unknown>;
|
||||
allowUnmatchedView?: boolean;
|
||||
};
|
||||
|
||||
export type DisplaySetSelector = {
|
||||
id?: string;
|
||||
options?: PublicDisplaySetOptions;
|
||||
};
|
||||
|
||||
export type PublicDisplaySetOptions = {
|
||||
/** The display set options can have an id in order to distinguish
|
||||
* it from other similar items.
|
||||
*/
|
||||
id?: string;
|
||||
voi?: VOI;
|
||||
voiInverted?: boolean;
|
||||
blendMode?: string;
|
||||
slabThickness?: number;
|
||||
colormap?: string;
|
||||
displayPreset?: string;
|
||||
};
|
||||
|
||||
export type DisplaySetOptions = {
|
||||
id?: string;
|
||||
voi?: VOI;
|
||||
voiInverted: boolean;
|
||||
blendMode?: Enums.BlendModes;
|
||||
slabThickness?: number;
|
||||
colormap?: { name: string; opacity?: number };
|
||||
displayPreset?: string;
|
||||
};
|
||||
|
||||
type VOI = {
|
||||
windowWidth: number;
|
||||
windowCenter: number;
|
||||
};
|
||||
|
||||
export type DisplaySet = {
|
||||
displaySetInstanceUID: string;
|
||||
};
|
||||
|
||||
const STACK = 'stack';
|
||||
const DEFAULT_TOOLGROUP_ID = 'default';
|
||||
|
||||
// Return true if the data contains the given display set UID OR the imageId
|
||||
// if it is a composite object.
|
||||
const dataContains = ({ data, displaySetUID, imageId, viewport }): boolean => {
|
||||
if (imageId && data.isCompositeStack && data.imageIds) {
|
||||
return !!data.imageIds.find(dataId => dataId === imageId);
|
||||
}
|
||||
|
||||
if (imageId && (data.volumeId || viewport instanceof VolumeViewport)) {
|
||||
const isAcquisition = !!viewport.getCurrentImageId();
|
||||
|
||||
if (!isAcquisition) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const imageURI = utilities.imageIdToURI(imageId);
|
||||
const hasImageId = viewport.hasImageURI(imageURI);
|
||||
|
||||
if (hasImageId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.displaySetInstanceUID === displaySetUID) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
class ViewportInfo {
|
||||
private viewportId = '';
|
||||
private element: HTMLDivElement;
|
||||
private viewportOptions: ViewportOptions;
|
||||
private displaySetOptions: Array<DisplaySetOptions>;
|
||||
private viewportData: StackViewportData | VolumeViewportData;
|
||||
private renderingEngineId: string;
|
||||
private viewReference: Types.ViewReference;
|
||||
|
||||
constructor(viewportId: string) {
|
||||
this.viewportId = viewportId;
|
||||
this.setPublicViewportOptions({});
|
||||
this.setPublicDisplaySetOptions([{}]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the viewport contains the given display set UID,
|
||||
* OR if it is a composite stack and contains the given imageId
|
||||
*/
|
||||
public contains(displaySetUID: string, imageId: string): boolean {
|
||||
if (!this.viewportData?.data) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { viewport } = getEnabledElementByViewportId(this.viewportId) || {};
|
||||
|
||||
if (this.viewportData.data.length) {
|
||||
return !!this.viewportData.data.find(data =>
|
||||
dataContains({ data, displaySetUID, imageId, viewport })
|
||||
);
|
||||
}
|
||||
|
||||
return dataContains({
|
||||
data: this.viewportData.data,
|
||||
displaySetUID,
|
||||
imageId,
|
||||
viewport,
|
||||
});
|
||||
}
|
||||
|
||||
public destroy = (): void => {
|
||||
this.element = null;
|
||||
this.viewportData = null;
|
||||
this.viewportOptions = null;
|
||||
this.displaySetOptions = null;
|
||||
};
|
||||
|
||||
public setRenderingEngineId(renderingEngineId: string): void {
|
||||
this.renderingEngineId = renderingEngineId;
|
||||
}
|
||||
|
||||
public getRenderingEngineId(): string {
|
||||
return this.renderingEngineId;
|
||||
}
|
||||
|
||||
public setViewportId(viewportId: string): void {
|
||||
this.viewportId = viewportId;
|
||||
}
|
||||
|
||||
public setElement(element: HTMLDivElement): void {
|
||||
this.element = element;
|
||||
}
|
||||
|
||||
public setViewportData(viewportData: StackViewportData | VolumeViewportData): void {
|
||||
this.viewportData = viewportData;
|
||||
}
|
||||
|
||||
public getViewportData(): StackViewportData | VolumeViewportData {
|
||||
return this.viewportData;
|
||||
}
|
||||
|
||||
public getElement(): HTMLDivElement {
|
||||
return this.element;
|
||||
}
|
||||
|
||||
public getViewportId(): string {
|
||||
return this.viewportId;
|
||||
}
|
||||
|
||||
public getViewReference(): Types.ViewReference {
|
||||
return this.viewportOptions?.viewReference;
|
||||
}
|
||||
|
||||
public setPublicDisplaySetOptions(
|
||||
publicDisplaySetOptions: PublicDisplaySetOptions[] | DisplaySetSelector[]
|
||||
): Array<DisplaySetOptions> {
|
||||
// map the displaySetOptions and check if they are undefined then set them to default values
|
||||
const displaySetOptions = this.mapDisplaySetOptions(publicDisplaySetOptions);
|
||||
|
||||
this.setDisplaySetOptions(displaySetOptions);
|
||||
|
||||
return this.displaySetOptions;
|
||||
}
|
||||
|
||||
public hasDisplaySet(displaySetInstanceUID: string): boolean {
|
||||
// Todo: currently this does not work for non image & referenceImage displaySets.
|
||||
// Since SEG and other derived displaySets are loaded in a different way, and not
|
||||
// via cornerstoneViewportService
|
||||
let viewportData = this.getViewportData();
|
||||
|
||||
if (
|
||||
viewportData.viewportType === Enums.ViewportType.ORTHOGRAPHIC ||
|
||||
viewportData.viewportType === Enums.ViewportType.VOLUME_3D
|
||||
) {
|
||||
viewportData = viewportData as VolumeViewportData;
|
||||
return viewportData.data.some(
|
||||
({ displaySetInstanceUID: dsUID }) => dsUID === displaySetInstanceUID
|
||||
);
|
||||
}
|
||||
|
||||
viewportData = viewportData as StackViewportData;
|
||||
return viewportData.data.displaySetInstanceUID === displaySetInstanceUID;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param viewportOptionsEntry - the base values for the options
|
||||
* @param viewportTypeDisplaySet - allows overriding the viewport type
|
||||
*/
|
||||
public setPublicViewportOptions(
|
||||
viewportOptionsEntry: PublicViewportOptions,
|
||||
viewportTypeDisplaySet?: string
|
||||
): ViewportOptions {
|
||||
const ohifViewportType = viewportTypeDisplaySet || viewportOptionsEntry.viewportType || STACK;
|
||||
const { presentationIds } = viewportOptionsEntry;
|
||||
let { toolGroupId = DEFAULT_TOOLGROUP_ID } = viewportOptionsEntry;
|
||||
// Just assign the orientation for any viewport type and let the viewport deal with it
|
||||
const orientation = getCornerstoneOrientation(viewportOptionsEntry.orientation);
|
||||
|
||||
const viewportType = getCornerstoneViewportType(ohifViewportType);
|
||||
|
||||
if (!toolGroupId) {
|
||||
toolGroupId = DEFAULT_TOOLGROUP_ID;
|
||||
}
|
||||
|
||||
this.setViewportOptions({
|
||||
...viewportOptionsEntry,
|
||||
viewportId: this.viewportId,
|
||||
viewportType: viewportType as Enums.ViewportType,
|
||||
orientation,
|
||||
toolGroupId,
|
||||
presentationIds,
|
||||
});
|
||||
|
||||
return this.viewportOptions;
|
||||
}
|
||||
|
||||
public setViewportOptions(viewportOptions: ViewportOptions): void {
|
||||
this.viewportOptions = viewportOptions;
|
||||
}
|
||||
|
||||
public getViewportOptions(): ViewportOptions {
|
||||
return this.viewportOptions;
|
||||
}
|
||||
|
||||
public getPresentationIds(): AppTypes.PresentationIds | null {
|
||||
const { presentationIds } = this.viewportOptions;
|
||||
return presentationIds;
|
||||
}
|
||||
|
||||
public setDisplaySetOptions(displaySetOptions: Array<DisplaySetOptions>): void {
|
||||
this.displaySetOptions = displaySetOptions;
|
||||
}
|
||||
|
||||
public getSyncGroups(): SyncGroup[] {
|
||||
this.viewportOptions.syncGroups ||= [];
|
||||
return this.viewportOptions.syncGroups;
|
||||
}
|
||||
|
||||
public getDisplaySetOptions(): Array<DisplaySetOptions> {
|
||||
return this.displaySetOptions;
|
||||
}
|
||||
|
||||
public getViewportType(): Enums.ViewportType {
|
||||
return this.viewportOptions.viewportType || Enums.ViewportType.STACK;
|
||||
}
|
||||
|
||||
public getToolGroupId(): string {
|
||||
return this.viewportOptions.toolGroupId;
|
||||
}
|
||||
|
||||
public getBackground(): Types.Point3 {
|
||||
return this.viewportOptions.background || [0, 0, 0];
|
||||
}
|
||||
|
||||
public getOrientation(): Enums.OrientationAxis {
|
||||
return this.viewportOptions.orientation;
|
||||
}
|
||||
|
||||
public getDisplayArea(): Types.DisplayArea {
|
||||
return this.viewportOptions.displayArea;
|
||||
}
|
||||
|
||||
public getInitialImageOptions(): InitialImageOptions {
|
||||
return this.viewportOptions.initialImageOptions;
|
||||
}
|
||||
|
||||
// Handle incoming public display set options or a display set select
|
||||
// with a contained options.
|
||||
private mapDisplaySetOptions(
|
||||
options: PublicDisplaySetOptions[] | DisplaySetSelector[] = [{}]
|
||||
): Array<DisplaySetOptions> {
|
||||
const displaySetOptions: Array<DisplaySetOptions> = [];
|
||||
|
||||
options.forEach(item => {
|
||||
let option = item?.options || item;
|
||||
if (!option) {
|
||||
option = {
|
||||
blendMode: undefined,
|
||||
slabThickness: undefined,
|
||||
colormap: undefined,
|
||||
voi: {},
|
||||
voiInverted: false,
|
||||
};
|
||||
}
|
||||
const blendMode = getCornerstoneBlendMode(option.blendMode);
|
||||
|
||||
displaySetOptions.push({
|
||||
voi: option.voi,
|
||||
voiInverted: option.voiInverted,
|
||||
colormap: option.colormap,
|
||||
slabThickness: option.slabThickness,
|
||||
blendMode,
|
||||
displayPreset: option.displayPreset,
|
||||
});
|
||||
});
|
||||
|
||||
return displaySetOptions;
|
||||
}
|
||||
}
|
||||
|
||||
export default ViewportInfo;
|
||||
@@ -0,0 +1,3 @@
|
||||
const RENDERING_ENGINE_ID = 'OHIFCornerstoneRenderingEngine';
|
||||
|
||||
export { RENDERING_ENGINE_ID };
|
||||
34
extensions/cornerstone/src/state.ts
Normal file
34
extensions/cornerstone/src/state.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
const state = {
|
||||
// The `defaultContext` of an extension's commandsModule
|
||||
DEFAULT_CONTEXT: 'CORNERSTONE',
|
||||
enabledElements: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the enabled element `dom` reference for an active viewport.
|
||||
* @param {HTMLElement} dom Active viewport element.
|
||||
* @return void
|
||||
*/
|
||||
const setEnabledElement = (viewportId: string, element: HTMLElement, context?: string): void => {
|
||||
const targetContext = context || state.DEFAULT_CONTEXT;
|
||||
|
||||
state.enabledElements[viewportId] = {
|
||||
element,
|
||||
context: targetContext,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Grabs the enabled element `dom` reference of an active viewport.
|
||||
*
|
||||
* @return {HTMLElement} Active viewport element.
|
||||
*/
|
||||
const getEnabledElement = viewportId => {
|
||||
return state.enabledElements[viewportId];
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
state.enabledElements = {};
|
||||
};
|
||||
|
||||
export { setEnabledElement, getEnabledElement, reset };
|
||||
4
extensions/cornerstone/src/stores/index.ts
Normal file
4
extensions/cornerstone/src/stores/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { useLutPresentationStore } from './useLutPresentationStore';
|
||||
export { usePositionPresentationStore } from './usePositionPresentationStore';
|
||||
export { useSegmentationPresentationStore } from './useSegmentationPresentationStore';
|
||||
export { useSynchronizersStore } from './useSynchronizersStore';
|
||||
42
extensions/cornerstone/src/stores/presentationUtils.ts
Normal file
42
extensions/cornerstone/src/stores/presentationUtils.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
const JOIN_STR = '&';
|
||||
|
||||
// The default lut presentation id if none defined
|
||||
const DEFAULT_STR = 'default';
|
||||
|
||||
// This code finds the first unique index to add to the presentation id so that
|
||||
// two viewports containing the same display set in the same type of viewport
|
||||
// can have different presentation information. This allows comparison of
|
||||
// a single display set in two or more viewports, when the user has simply
|
||||
// dragged and dropped the view in twice. For example, it allows displaying
|
||||
// bone, brain and soft tissue views of a single display set, and to still
|
||||
// remember the specific changes to each viewport.
|
||||
const addUniqueIndex = (
|
||||
arr,
|
||||
key,
|
||||
viewports: AppTypes.ViewportGrid.Viewports,
|
||||
isUpdatingSameViewport
|
||||
) => {
|
||||
arr.push(0);
|
||||
|
||||
// If we are updating the viewport, we should not increment the index
|
||||
if (isUpdatingSameViewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The 128 is just a value that is larger than how many viewports we
|
||||
// display at once, used as an upper bound on how many unique presentation
|
||||
// ID's might exist for a single display set at once.
|
||||
for (let displayInstance = 0; displayInstance < 128; displayInstance++) {
|
||||
arr[arr.length - 1] = displayInstance;
|
||||
const testId = arr.join(JOIN_STR);
|
||||
if (
|
||||
!Array.from(viewports.values()).find(
|
||||
viewport => viewport.viewportOptions?.presentationIds?.[key] === testId
|
||||
)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export { addUniqueIndex, DEFAULT_STR, JOIN_STR };
|
||||
166
extensions/cornerstone/src/stores/useLutPresentationStore.ts
Normal file
166
extensions/cornerstone/src/stores/useLutPresentationStore.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import { LutPresentation } from '../types/Presentation';
|
||||
import { addUniqueIndex, DEFAULT_STR, JOIN_STR } from './presentationUtils';
|
||||
|
||||
/**
|
||||
* Identifier for the LUT Presentation store type.
|
||||
*/
|
||||
const PRESENTATION_TYPE_ID = 'lutPresentationId';
|
||||
|
||||
/**
|
||||
* Flag to enable or disable debug mode for the store.
|
||||
* Set to `true` to enable zustand devtools.
|
||||
*/
|
||||
const DEBUG_STORE = false;
|
||||
|
||||
/**
|
||||
* Represents the state and actions for managing LUT presentations.
|
||||
*/
|
||||
type LutPresentationState = {
|
||||
/**
|
||||
* Type identifier for the store.
|
||||
*/
|
||||
type: string;
|
||||
|
||||
/**
|
||||
* Stores LUT presentations indexed by their presentation ID.
|
||||
*/
|
||||
lutPresentationStore: Record<string, LutPresentation>;
|
||||
|
||||
/**
|
||||
* Sets the LUT presentation for a given key.
|
||||
*
|
||||
* @param key - The key identifying the LUT presentation.
|
||||
* @param value - The `LutPresentation` to associate with the key.
|
||||
*/
|
||||
setLutPresentation: (key: string, value: LutPresentation) => void;
|
||||
|
||||
/**
|
||||
* Clears all LUT presentations from the store.
|
||||
*/
|
||||
clearLutPresentationStore: () => void;
|
||||
|
||||
/**
|
||||
* Retrieves the presentation ID based on the provided parameters.
|
||||
*
|
||||
* @param id - The presentation ID to check.
|
||||
* @param options - Configuration options.
|
||||
* @param options.viewport - The current viewport in grid
|
||||
* @param options.viewports - All available viewports in grid
|
||||
* @param options.isUpdatingSameViewport - Indicates if the same viewport is being updated.
|
||||
* @returns The presentation ID or undefined.
|
||||
*/
|
||||
getPresentationId: (
|
||||
id: string,
|
||||
options: {
|
||||
viewport: AppTypes.ViewportGrid.Viewport;
|
||||
viewports: AppTypes.ViewportGrid.Viewports;
|
||||
isUpdatingSameViewport: boolean;
|
||||
}
|
||||
) => string | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a presentation ID for LUT based on the viewport configuration.
|
||||
*
|
||||
* @param id - The ID to check.
|
||||
* @param options - Configuration options.
|
||||
* @param options.viewport - The current viewport.
|
||||
* @param options.viewports - All available viewports.
|
||||
* @param options.isUpdatingSameViewport - Indicates if the same viewport is being updated.
|
||||
* @returns The LUT presentation ID or undefined.
|
||||
*/
|
||||
const getLutPresentationId = (
|
||||
id: string,
|
||||
{
|
||||
viewport,
|
||||
viewports,
|
||||
isUpdatingSameViewport,
|
||||
}: {
|
||||
viewport: AppTypes.ViewportGrid.Viewport;
|
||||
viewports: AppTypes.ViewportGrid.Viewports;
|
||||
isUpdatingSameViewport: boolean;
|
||||
}
|
||||
): string | undefined => {
|
||||
if (id !== PRESENTATION_TYPE_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
const getLutId = (ds): string => {
|
||||
if (!ds || !ds.options) {
|
||||
return DEFAULT_STR;
|
||||
}
|
||||
if (ds.options.id) {
|
||||
return ds.options.id;
|
||||
}
|
||||
const arr = Object.entries(ds.options).map(([key, val]) => `${key}=${val}`);
|
||||
if (!arr.length) {
|
||||
return DEFAULT_STR;
|
||||
}
|
||||
return arr.join(JOIN_STR);
|
||||
};
|
||||
|
||||
if (!viewport || !viewport.viewportOptions || !viewport.displaySetInstanceUIDs?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { displaySetOptions, displaySetInstanceUIDs } = viewport;
|
||||
const lutId = getLutId(displaySetOptions[0]);
|
||||
const lutPresentationArr = [lutId];
|
||||
|
||||
for (const uid of displaySetInstanceUIDs) {
|
||||
lutPresentationArr.push(uid);
|
||||
}
|
||||
|
||||
addUniqueIndex(lutPresentationArr, PRESENTATION_TYPE_ID, viewports, isUpdatingSameViewport);
|
||||
|
||||
return lutPresentationArr.join(JOIN_STR);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates the LUT Presentation store.
|
||||
*
|
||||
* @param set - The zustand set function.
|
||||
* @returns The LUT Presentation store state and actions.
|
||||
*/
|
||||
const createLutPresentationStore = (set): LutPresentationState => ({
|
||||
type: PRESENTATION_TYPE_ID,
|
||||
lutPresentationStore: {},
|
||||
|
||||
/**
|
||||
* Sets the LUT presentation for a given key.
|
||||
*/
|
||||
setLutPresentation: (key, value) =>
|
||||
set(
|
||||
state => ({
|
||||
lutPresentationStore: {
|
||||
...state.lutPresentationStore,
|
||||
[key]: value,
|
||||
},
|
||||
}),
|
||||
false,
|
||||
'setLutPresentation'
|
||||
),
|
||||
|
||||
/**
|
||||
* Clears all LUT presentations from the store.
|
||||
*/
|
||||
clearLutPresentationStore: () =>
|
||||
set({ lutPresentationStore: {} }, false, 'clearLutPresentationStore'),
|
||||
|
||||
/**
|
||||
* Retrieves the presentation ID based on the provided parameters.
|
||||
*/
|
||||
getPresentationId: getLutPresentationId,
|
||||
});
|
||||
|
||||
/**
|
||||
* Zustand store for managing LUT presentations.
|
||||
* Applies devtools middleware when DEBUG_STORE is enabled.
|
||||
*/
|
||||
export const useLutPresentationStore = create<LutPresentationState>()(
|
||||
DEBUG_STORE
|
||||
? devtools(createLutPresentationStore, { name: 'LutPresentationStore' })
|
||||
: createLutPresentationStore
|
||||
);
|
||||
@@ -0,0 +1,172 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import { PositionPresentation } from '../types/Presentation';
|
||||
import { addUniqueIndex, JOIN_STR } from './presentationUtils';
|
||||
|
||||
const PRESENTATION_TYPE_ID = 'positionPresentationId';
|
||||
const DEBUG_STORE = true;
|
||||
|
||||
/**
|
||||
* Represents the state and actions for managing position presentations.
|
||||
*/
|
||||
type PositionPresentationState = {
|
||||
/**
|
||||
* Type identifier for the store.
|
||||
*/
|
||||
type: string;
|
||||
|
||||
/**
|
||||
* Stores position presentations indexed by their presentation ID.
|
||||
*/
|
||||
positionPresentationStore: Record<string, PositionPresentation>;
|
||||
|
||||
/**
|
||||
* Sets the position presentation for a given key.
|
||||
*
|
||||
* @param key - The key identifying the position presentation.
|
||||
* @param value - The `PositionPresentation` to associate with the key.
|
||||
*/
|
||||
setPositionPresentation: (key: string, value: PositionPresentation) => void;
|
||||
|
||||
/**
|
||||
* Clears all position presentations from the store.
|
||||
*/
|
||||
clearPositionPresentationStore: () => void;
|
||||
|
||||
/**
|
||||
* Retrieves the presentation ID based on the provided parameters.
|
||||
*
|
||||
* @param id - The ID to check.
|
||||
* @param options - Configuration options.
|
||||
* @param options.viewport - The current viewport.
|
||||
* @param options.viewports - All available viewports.
|
||||
* @param options.isUpdatingSameViewport - Indicates if the same viewport is being updated.
|
||||
* @returns The position presentation ID or undefined.
|
||||
*/
|
||||
getPresentationId: (
|
||||
id: string,
|
||||
options: {
|
||||
viewport: any;
|
||||
viewports: any;
|
||||
isUpdatingSameViewport: boolean;
|
||||
}
|
||||
) => string | undefined;
|
||||
|
||||
getPositionPresentationId: (
|
||||
viewport: any,
|
||||
viewports?: any,
|
||||
isUpdatingSameViewport?: boolean
|
||||
) => string | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a position presentation ID based on the viewport configuration.
|
||||
*
|
||||
* @param id - The ID to check.
|
||||
* @param options - Configuration options.
|
||||
* @param options.viewport - The current viewport.
|
||||
* @param options.viewports - All available viewports.
|
||||
* @param options.isUpdatingSameViewport - Indicates if the same viewport is being updated.
|
||||
* @returns The position presentation ID or undefined.
|
||||
*/
|
||||
const getPresentationId = (
|
||||
id: string,
|
||||
{
|
||||
viewport,
|
||||
viewports,
|
||||
isUpdatingSameViewport,
|
||||
}: {
|
||||
viewport: any;
|
||||
viewports: any;
|
||||
isUpdatingSameViewport: boolean;
|
||||
}
|
||||
): string | undefined => {
|
||||
if (id !== PRESENTATION_TYPE_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!viewport?.viewportOptions || !viewport.displaySetInstanceUIDs?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
return getPositionPresentationId(viewport, viewports, isUpdatingSameViewport);
|
||||
};
|
||||
|
||||
function getPositionPresentationId(viewport, viewports, isUpdatingSameViewport) {
|
||||
const { viewportOptions = {}, displaySetInstanceUIDs = [], displaySetOptions = [] } = viewport;
|
||||
const { id: viewportOptionId, orientation } = viewportOptions;
|
||||
|
||||
const positionPresentationArr = [orientation || 'acquisition'];
|
||||
if (viewportOptionId) {
|
||||
positionPresentationArr.push(viewportOptionId);
|
||||
}
|
||||
|
||||
if (displaySetOptions?.some(ds => ds.options?.blendMode || ds.options?.displayPreset)) {
|
||||
positionPresentationArr.push(`custom`);
|
||||
}
|
||||
|
||||
for (const uid of displaySetInstanceUIDs) {
|
||||
positionPresentationArr.push(uid);
|
||||
}
|
||||
|
||||
if (viewports && viewports.length && isUpdatingSameViewport !== undefined) {
|
||||
addUniqueIndex(
|
||||
positionPresentationArr,
|
||||
PRESENTATION_TYPE_ID,
|
||||
viewports,
|
||||
isUpdatingSameViewport
|
||||
);
|
||||
} else {
|
||||
positionPresentationArr.push(0);
|
||||
}
|
||||
|
||||
return positionPresentationArr.join(JOIN_STR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the Position Presentation store.
|
||||
*
|
||||
* @param set - The zustand set function.
|
||||
* @returns The Position Presentation store state and actions.
|
||||
*/
|
||||
const createPositionPresentationStore = set => ({
|
||||
type: PRESENTATION_TYPE_ID,
|
||||
positionPresentationStore: {},
|
||||
|
||||
/**
|
||||
* Sets the position presentation for a given key.
|
||||
*/
|
||||
setPositionPresentation: (key, value) =>
|
||||
set(
|
||||
state => ({
|
||||
positionPresentationStore: {
|
||||
...state.positionPresentationStore,
|
||||
[key]: value,
|
||||
},
|
||||
}),
|
||||
false,
|
||||
'setPositionPresentation'
|
||||
),
|
||||
|
||||
/**
|
||||
* Clears all position presentations from the store.
|
||||
*/
|
||||
clearPositionPresentationStore: () =>
|
||||
set({ positionPresentationStore: {} }, false, 'clearPositionPresentationStore'),
|
||||
|
||||
/**
|
||||
* Retrieves the presentation ID based on the provided parameters.
|
||||
*/
|
||||
getPresentationId,
|
||||
getPositionPresentationId: getPositionPresentationId,
|
||||
});
|
||||
|
||||
/**
|
||||
* Zustand store for managing position presentations.
|
||||
* Applies devtools middleware when DEBUG_STORE is enabled.
|
||||
*/
|
||||
export const usePositionPresentationStore = create<PositionPresentationState>()(
|
||||
DEBUG_STORE
|
||||
? devtools(createPositionPresentationStore, { name: 'PositionPresentationStore' })
|
||||
: createPositionPresentationStore
|
||||
);
|
||||
@@ -0,0 +1,256 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import { SegmentationPresentation, SegmentationPresentationItem } from '../types/Presentation';
|
||||
import { JOIN_STR } from './presentationUtils';
|
||||
import { getViewportOrientationFromImageOrientationPatient } from '../utils/getViewportOrientationFromImageOrientationPatient';
|
||||
|
||||
const PRESENTATION_TYPE_ID = 'segmentationPresentationId';
|
||||
const DEBUG_STORE = false;
|
||||
|
||||
/**
|
||||
* The keys are the presentationId.
|
||||
*/
|
||||
type SegmentationPresentationStore = {
|
||||
/**
|
||||
* Type identifier for the store.
|
||||
*/
|
||||
type: string;
|
||||
|
||||
/**
|
||||
* Stores segmentation presentations indexed by their presentation ID.
|
||||
*/
|
||||
segmentationPresentationStore: Record<string, SegmentationPresentation>;
|
||||
|
||||
/**
|
||||
* Sets the segmentation presentation for a given segmentation ID.
|
||||
*
|
||||
* @param presentationId - The presentation ID.
|
||||
* @param value - The `SegmentationPresentation` to associate with the ID.
|
||||
*/
|
||||
setSegmentationPresentation: (presentationId: string, value: SegmentationPresentation) => void;
|
||||
|
||||
/**
|
||||
* Clears all segmentation presentations from the store.
|
||||
*/
|
||||
clearSegmentationPresentationStore: () => void;
|
||||
|
||||
/**
|
||||
* Retrieves the presentation ID based on the provided parameters.
|
||||
*
|
||||
* @param id - The ID to check.
|
||||
* @param options - Configuration options.
|
||||
* @param options.viewport - The current viewport.
|
||||
* @param options.viewports - All available viewports.
|
||||
* @param options.isUpdatingSameViewport - Indicates if the same viewport is being updated.
|
||||
* @param options.servicesManager - The services manager instance.
|
||||
* @returns The segmentation presentation ID or undefined.
|
||||
*/
|
||||
getPresentationId: (
|
||||
id: string,
|
||||
options: {
|
||||
viewport: AppTypes.ViewportGrid.Viewport;
|
||||
viewports: AppTypes.ViewportGrid.Viewports;
|
||||
isUpdatingSameViewport: boolean;
|
||||
servicesManager: AppTypes.ServicesManager;
|
||||
}
|
||||
) => string | undefined;
|
||||
|
||||
/**
|
||||
* Adds a new segmentation presentation state.
|
||||
*
|
||||
* @param presentationId - The presentation ID.
|
||||
* @param segmentationPresentation - The `SegmentationPresentation` to add.
|
||||
* @param servicesManager - The services manager instance.
|
||||
*/
|
||||
addSegmentationPresentationItem: (
|
||||
presentationId: string,
|
||||
segmentationPresentationItem: SegmentationPresentationItem
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Gets the current segmentation presentation ID.
|
||||
*
|
||||
* @param params - Parameters for retrieving the segmentation presentation ID.
|
||||
* @param params.viewport - The current viewport.
|
||||
* @param params.servicesManager - The services manager instance.
|
||||
* @returns The current segmentation presentation ID.
|
||||
*/
|
||||
getSegmentationPresentationId: ({
|
||||
viewport,
|
||||
servicesManager,
|
||||
}: {
|
||||
viewport: AppTypes.ViewportGrid.Viewport;
|
||||
servicesManager: AppTypes.ServicesManager;
|
||||
}) => string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a segmentation presentation ID based on the viewport configuration.
|
||||
*
|
||||
* @param id - The ID to check.
|
||||
* @param options - Configuration options.
|
||||
* @param options.viewport - The current viewport.
|
||||
* @param options.viewports - All available viewports.
|
||||
* @param options.isUpdatingSameViewport - Indicates if the same viewport is being updated.
|
||||
* @param options.servicesManager - The services manager instance.
|
||||
* @returns The segmentation presentation ID or undefined.
|
||||
*/
|
||||
const getPresentationId = (
|
||||
id: string,
|
||||
{
|
||||
viewport,
|
||||
viewports,
|
||||
isUpdatingSameViewport,
|
||||
servicesManager,
|
||||
}: {
|
||||
viewport: AppTypes.ViewportGrid.Viewport;
|
||||
viewports: AppTypes.ViewportGrid.Viewports;
|
||||
isUpdatingSameViewport: boolean;
|
||||
servicesManager: AppTypes.ServicesManager;
|
||||
}
|
||||
): string | undefined => {
|
||||
if (id !== PRESENTATION_TYPE_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
return _getSegmentationPresentationId({ viewport, servicesManager });
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to generate the segmentation presentation ID.
|
||||
*
|
||||
* @param params - Parameters for generating the segmentation presentation ID.
|
||||
* @param params.viewport - The current viewport.
|
||||
* @param params.servicesManager - The services manager instance.
|
||||
* @returns The segmentation presentation ID or undefined.
|
||||
*/
|
||||
const _getSegmentationPresentationId = ({
|
||||
viewport,
|
||||
servicesManager,
|
||||
}: {
|
||||
viewport: AppTypes.ViewportGrid.Viewport;
|
||||
servicesManager: AppTypes.ServicesManager;
|
||||
}) => {
|
||||
if (!viewport?.viewportOptions || !viewport.displaySetInstanceUIDs?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { displaySetInstanceUIDs, viewportOptions } = viewport;
|
||||
|
||||
let orientation = viewportOptions.orientation;
|
||||
|
||||
if (!orientation) {
|
||||
// Calculate orientation from the viewport sample image
|
||||
const displaySet = servicesManager.services.displaySetService.getDisplaySetByUID(
|
||||
displaySetInstanceUIDs[0]
|
||||
);
|
||||
const sampleImage = displaySet.images?.[0];
|
||||
const imageOrientationPatient = sampleImage?.ImageOrientationPatient;
|
||||
|
||||
orientation = getViewportOrientationFromImageOrientationPatient(imageOrientationPatient);
|
||||
}
|
||||
|
||||
const segmentationPresentationArr = [];
|
||||
|
||||
segmentationPresentationArr.push(...displaySetInstanceUIDs);
|
||||
|
||||
// Uncomment if unique indexing is needed
|
||||
// addUniqueIndex(
|
||||
// segmentationPresentationArr,
|
||||
// 'segmentationPresentationId',
|
||||
// viewports,
|
||||
// isUpdatingSameViewport
|
||||
// );
|
||||
|
||||
return segmentationPresentationArr.join(JOIN_STR);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates the Segmentation Presentation store.
|
||||
*
|
||||
* @param set - The zustand set function.
|
||||
* @returns The Segmentation Presentation store state and actions.
|
||||
*/
|
||||
const createSegmentationPresentationStore = set => ({
|
||||
type: PRESENTATION_TYPE_ID,
|
||||
segmentationPresentationStore: {},
|
||||
|
||||
/**
|
||||
* Clears all segmentation presentations from the store.
|
||||
*/
|
||||
clearSegmentationPresentationStore: () =>
|
||||
set({ segmentationPresentationStore: {} }, false, 'clearSegmentationPresentationStore'),
|
||||
|
||||
/**
|
||||
* Adds a new segmentation presentation item to the store.
|
||||
*
|
||||
* segmentationPresentationItem: {
|
||||
* segmentationId: string;
|
||||
* type: SegmentationRepresentations;
|
||||
* hydrated: boolean | null;
|
||||
* config?: unknown;
|
||||
* }
|
||||
*/
|
||||
addSegmentationPresentationItem: (
|
||||
presentationId: string,
|
||||
segmentationPresentationItem: SegmentationPresentationItem
|
||||
) =>
|
||||
set(
|
||||
state => ({
|
||||
segmentationPresentationStore: {
|
||||
...state.segmentationPresentationStore,
|
||||
[presentationId]: [
|
||||
...(state.segmentationPresentationStore[presentationId] || []),
|
||||
segmentationPresentationItem,
|
||||
],
|
||||
},
|
||||
}),
|
||||
false,
|
||||
'addSegmentationPresentationItem'
|
||||
),
|
||||
|
||||
/**
|
||||
* Sets the segmentation presentation for a given presentation ID. A segmentation
|
||||
* presentation is an array of SegmentationPresentationItem.
|
||||
*
|
||||
* segmentationPresentationItem: {
|
||||
* segmentationId: string;
|
||||
* type: SegmentationRepresentations;
|
||||
* hydrated: boolean | null;
|
||||
* config?: unknown;
|
||||
* }
|
||||
*
|
||||
* segmentationPresentation: SegmentationPresentationItem[]
|
||||
*/
|
||||
setSegmentationPresentation: (presentationId: string, values: SegmentationPresentation) =>
|
||||
set(
|
||||
state => ({
|
||||
segmentationPresentationStore: {
|
||||
...state.segmentationPresentationStore,
|
||||
[presentationId]: values,
|
||||
},
|
||||
}),
|
||||
false,
|
||||
'setSegmentationPresentation'
|
||||
),
|
||||
|
||||
/**
|
||||
* Retrieves the presentation ID based on the provided parameters.
|
||||
*/
|
||||
getPresentationId,
|
||||
|
||||
/**
|
||||
* Retrieves the current segmentation presentation ID.
|
||||
*/
|
||||
getSegmentationPresentationId: _getSegmentationPresentationId,
|
||||
});
|
||||
|
||||
/**
|
||||
* Zustand store for managing segmentation presentations.
|
||||
* Applies devtools middleware when DEBUG_STORE is enabled.
|
||||
*/
|
||||
export const useSegmentationPresentationStore = create<SegmentationPresentationStore>()(
|
||||
DEBUG_STORE
|
||||
? devtools(createSegmentationPresentationStore, { name: 'Segmentation Presentation Store' })
|
||||
: createSegmentationPresentationStore
|
||||
);
|
||||
84
extensions/cornerstone/src/stores/useSynchronizersStore.ts
Normal file
84
extensions/cornerstone/src/stores/useSynchronizersStore.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
|
||||
/**
|
||||
* Identifier for the synchronizers store type.
|
||||
*/
|
||||
const PRESENTATION_TYPE_ID = 'synchronizersStoreId';
|
||||
|
||||
/**
|
||||
* Flag to enable or disable debug mode for the store.
|
||||
* Set to `true` to enable zustand devtools.
|
||||
*/
|
||||
const DEBUG_STORE = false;
|
||||
|
||||
/**
|
||||
* Information about a single synchronizer.
|
||||
*/
|
||||
type SynchronizerInfo = {
|
||||
id: string;
|
||||
type: string;
|
||||
sourceViewports: Array<{ viewportId: string; renderingEngineId: string }>;
|
||||
targetViewports: Array<{ viewportId: string; renderingEngineId: string }>;
|
||||
};
|
||||
|
||||
/**
|
||||
* State shape for the Synchronizers store.
|
||||
*/
|
||||
type SynchronizersState = {
|
||||
/**
|
||||
* Stores synchronizer information indexed by a unique key.
|
||||
*/
|
||||
synchronizersStore: Record<string, SynchronizerInfo[]>;
|
||||
|
||||
/**
|
||||
* Sets the synchronizers for a specific viewport.
|
||||
*
|
||||
* @param viewportId - The ID of the viewport.
|
||||
* @param synchronizers - An array of SynchronizerInfo.
|
||||
*/
|
||||
setSynchronizers: (viewportId: string, synchronizers: SynchronizerInfo[]) => void;
|
||||
|
||||
/**
|
||||
* Clears the entire synchronizers store.
|
||||
*/
|
||||
clearSynchronizersStore: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates the Synchronizers store.
|
||||
*
|
||||
* @param set - The zustand set function.
|
||||
* @returns The synchronizers store state and actions.
|
||||
*/
|
||||
const createSynchronizersStore = (set): SynchronizersState => ({
|
||||
synchronizersStore: {},
|
||||
type: PRESENTATION_TYPE_ID,
|
||||
|
||||
setSynchronizers: (viewportId: string, synchronizers: SynchronizerInfo[]) => {
|
||||
set(
|
||||
state => ({
|
||||
synchronizersStore: {
|
||||
...state.synchronizersStore,
|
||||
[viewportId]: synchronizers,
|
||||
},
|
||||
}),
|
||||
false,
|
||||
'setSynchronizers'
|
||||
);
|
||||
},
|
||||
|
||||
clearSynchronizersStore: () => {
|
||||
set({ synchronizersStore: {} }, false, 'clearSynchronizersStore');
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Zustand store for managing synchronizers.
|
||||
* Applies devtools middleware when DEBUG_STORE is enabled.
|
||||
*/
|
||||
export const useSynchronizersStore = create<SynchronizersState>()(
|
||||
DEBUG_STORE
|
||||
? devtools(createSynchronizersStore, { name: 'SynchronizersStore' })
|
||||
: createSynchronizersStore
|
||||
);
|
||||
@@ -0,0 +1,51 @@
|
||||
import { SynchronizerManager, Synchronizer } from '@cornerstonejs/tools';
|
||||
import { EVENTS, getRenderingEngine, type Types, utilities } from '@cornerstonejs/core';
|
||||
|
||||
const frameViewSyncCallback = (
|
||||
synchronizerInstance: Synchronizer,
|
||||
sourceViewport: Types.IViewportId,
|
||||
targetViewport: Types.IViewportId
|
||||
) => {
|
||||
const renderingEngine = getRenderingEngine(targetViewport.renderingEngineId);
|
||||
if (!renderingEngine) {
|
||||
throw new Error(`No RenderingEngine for Id: ${targetViewport.renderingEngineId}`);
|
||||
}
|
||||
const sViewport = renderingEngine.getViewport(sourceViewport.viewportId) as Types.IStackViewport;
|
||||
|
||||
const { viewportIndex: targetViewportIndex } = synchronizerInstance.getOptions(
|
||||
targetViewport.viewportId
|
||||
);
|
||||
|
||||
const { viewportIndex: sourceViewportIndex } = synchronizerInstance.getOptions(
|
||||
sourceViewport.viewportId
|
||||
);
|
||||
|
||||
if (targetViewportIndex === undefined || sourceViewportIndex === undefined) {
|
||||
throw new Error('No viewportIndex provided');
|
||||
}
|
||||
|
||||
const tViewport = renderingEngine.getViewport(targetViewport.viewportId) as Types.IStackViewport;
|
||||
|
||||
const sourceSliceIndex = sViewport.getSliceIndex();
|
||||
const sliceDifference = Number(targetViewportIndex) - Number(sourceViewportIndex);
|
||||
const targetSliceIndex = sourceSliceIndex + sliceDifference;
|
||||
|
||||
if (targetSliceIndex === tViewport.getSliceIndex()) {
|
||||
return;
|
||||
}
|
||||
|
||||
utilities.jumpToSlice(tViewport.element, {
|
||||
imageIndex: targetSliceIndex,
|
||||
});
|
||||
};
|
||||
|
||||
const createFrameViewSynchronizer = (synchronizerName: string): Synchronizer => {
|
||||
const synchronizer = SynchronizerManager.createSynchronizer(
|
||||
synchronizerName,
|
||||
EVENTS.CAMERA_MODIFIED,
|
||||
frameViewSyncCallback
|
||||
);
|
||||
return synchronizer;
|
||||
};
|
||||
|
||||
export { createFrameViewSynchronizer };
|
||||
118
extensions/cornerstone/src/tools/CalibrationLineTool.ts
Normal file
118
extensions/cornerstone/src/tools/CalibrationLineTool.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { LengthTool, utilities } from '@cornerstonejs/tools';
|
||||
import { callInputDialog } from '@ohif/extension-default';
|
||||
import getActiveViewportEnabledElement from '../utils/getActiveViewportEnabledElement';
|
||||
|
||||
const { calibrateImageSpacing } = utilities;
|
||||
|
||||
/**
|
||||
* Calibration Line tool works almost the same as the
|
||||
*/
|
||||
class CalibrationLineTool extends LengthTool {
|
||||
static toolName = 'CalibrationLine';
|
||||
|
||||
_renderingViewport: any;
|
||||
_lengthToolRenderAnnotation = this.renderAnnotation;
|
||||
|
||||
renderAnnotation = (enabledElement, svgDrawingHelper) => {
|
||||
const { viewport } = enabledElement;
|
||||
this._renderingViewport = viewport;
|
||||
return this._lengthToolRenderAnnotation(enabledElement, svgDrawingHelper);
|
||||
};
|
||||
|
||||
_getTextLines(data, targetId) {
|
||||
const [canvasPoint1, canvasPoint2] = data.handles.points.map(p =>
|
||||
this._renderingViewport.worldToCanvas(p)
|
||||
);
|
||||
// for display, round to 2 decimal points
|
||||
const lengthPx = Math.round(calculateLength2(canvasPoint1, canvasPoint2) * 100) / 100;
|
||||
|
||||
const textLines = [`${lengthPx}px`];
|
||||
|
||||
return textLines;
|
||||
}
|
||||
}
|
||||
|
||||
function calculateLength2(point1, point2) {
|
||||
const dx = point1[0] - point2[0];
|
||||
const dy = point1[1] - point2[1];
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
function calculateLength3(pos1, pos2) {
|
||||
const dx = pos1[0] - pos2[0];
|
||||
const dy = pos1[1] - pos2[1];
|
||||
const dz = pos1[2] - pos2[2];
|
||||
|
||||
return Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
|
||||
export default CalibrationLineTool;
|
||||
|
||||
export function onCompletedCalibrationLine(
|
||||
servicesManager: AppTypes.ServicesManager,
|
||||
csToolsEvent
|
||||
) {
|
||||
const { uiDialogService, viewportGridService } = servicesManager.services;
|
||||
|
||||
// calculate length (mm) with the current Pixel Spacing
|
||||
const annotationAddedEventDetail = csToolsEvent.detail;
|
||||
const {
|
||||
annotation: { metadata, data: annotationData },
|
||||
} = annotationAddedEventDetail;
|
||||
const { referencedImageId: imageId } = metadata;
|
||||
const enabledElement = getActiveViewportEnabledElement(viewportGridService);
|
||||
const { viewport } = enabledElement;
|
||||
|
||||
const length =
|
||||
Math.round(
|
||||
calculateLength3(annotationData.handles.points[0], annotationData.handles.points[1]) * 100
|
||||
) / 100;
|
||||
|
||||
const adjustCalibration = newLength => {
|
||||
const spacingScale = newLength / length;
|
||||
|
||||
// trigger resize of the viewport to adjust the world/pixel mapping
|
||||
calibrateImageSpacing(imageId, viewport.getRenderingEngine(), {
|
||||
type: 'User',
|
||||
scale: 1 / spacingScale,
|
||||
});
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!uiDialogService) {
|
||||
reject('UIDialogService is not initiated');
|
||||
return;
|
||||
}
|
||||
|
||||
callInputDialog(
|
||||
uiDialogService,
|
||||
{
|
||||
text: '',
|
||||
label: `${length}`,
|
||||
},
|
||||
(value, id) => {
|
||||
if (id === 'save') {
|
||||
adjustCalibration(Number.parseFloat(value));
|
||||
resolve(true);
|
||||
} else {
|
||||
reject('cancel');
|
||||
}
|
||||
},
|
||||
false,
|
||||
{
|
||||
dialogTitle: 'Calibration',
|
||||
inputLabel: 'Actual Physical distance (mm)',
|
||||
|
||||
// the input value must be a number
|
||||
validateFunc: val => {
|
||||
try {
|
||||
const v = Number.parseFloat(val);
|
||||
return !isNaN(v) && v !== 0.0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
269
extensions/cornerstone/src/tools/ImageOverlayViewerTool.tsx
Normal file
269
extensions/cornerstone/src/tools/ImageOverlayViewerTool.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import { VolumeViewport, metaData, utilities } from '@cornerstonejs/core';
|
||||
import { IStackViewport, IVolumeViewport } from '@cornerstonejs/core/types';
|
||||
import { AnnotationDisplayTool, drawing } from '@cornerstonejs/tools';
|
||||
import { guid, b64toBlob } from '@ohif/core/src/utils';
|
||||
import OverlayPlaneModuleProvider from './OverlayPlaneModuleProvider';
|
||||
|
||||
interface CachedStat {
|
||||
color: number[]; // [r, g, b, a]
|
||||
overlays: {
|
||||
// ...overlayPlaneModule
|
||||
_id: string;
|
||||
type: 'G' | 'R'; // G for Graphics, R for ROI
|
||||
color?: number[]; // Rendered color [r, g, b, a]
|
||||
dataUrl?: string; // Rendered image in Data URL expression
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Image Overlay Viewer tool is not a traditional tool that requires user interactin.
|
||||
* But it is used to display Pixel Overlays. And it will provide toggling capability.
|
||||
*
|
||||
* The documentation for Overlay Plane Module of DICOM can be found in [C.9.2 of
|
||||
* Part-3 of DICOM standard](https://dicom.nema.org/medical/dicom/2018b/output/chtml/part03/sect_C.9.2.html)
|
||||
*
|
||||
* Image Overlay rendered by this tool can be toggled on and off using
|
||||
* toolGroup.setToolEnabled() and toolGroup.setToolDisabled()
|
||||
*/
|
||||
class ImageOverlayViewerTool extends AnnotationDisplayTool {
|
||||
static toolName = 'ImageOverlayViewer';
|
||||
|
||||
/**
|
||||
* The overlay plane module provider add method is exposed here to be used
|
||||
* when updating the overlay for this tool to use for displaying data.
|
||||
*/
|
||||
public static addOverlayPlaneModule = OverlayPlaneModuleProvider.add;
|
||||
|
||||
constructor(
|
||||
toolProps = {},
|
||||
defaultToolProps = {
|
||||
supportedInteractionTypes: [],
|
||||
configuration: {
|
||||
fillColor: [255, 127, 127, 255],
|
||||
},
|
||||
}
|
||||
) {
|
||||
super(toolProps, defaultToolProps);
|
||||
}
|
||||
|
||||
onSetToolDisabled = (): void => {};
|
||||
|
||||
protected getReferencedImageId(viewport: IStackViewport | IVolumeViewport): string {
|
||||
if (viewport instanceof VolumeViewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetId = this.getTargetId(viewport);
|
||||
return targetId.split('imageId:')[1];
|
||||
}
|
||||
|
||||
renderAnnotation = (enabledElement, svgDrawingHelper) => {
|
||||
const { viewport } = enabledElement;
|
||||
|
||||
const imageId = this.getReferencedImageId(viewport);
|
||||
if (!imageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const overlayMetadata = metaData.get('overlayPlaneModule', imageId);
|
||||
const overlays = overlayMetadata?.overlays;
|
||||
|
||||
// no overlays
|
||||
if (!overlays?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fix the x, y positions
|
||||
overlays.forEach(overlay => {
|
||||
overlay.x ||= 0;
|
||||
overlay.y ||= 0;
|
||||
});
|
||||
|
||||
// Will clear cached stat data when the overlay data changes
|
||||
ImageOverlayViewerTool.addOverlayPlaneModule(imageId, overlayMetadata);
|
||||
|
||||
this._getCachedStat(imageId, overlayMetadata, this.configuration.fillColor).then(cachedStat => {
|
||||
cachedStat.overlays.forEach(overlay => {
|
||||
this._renderOverlay(enabledElement, svgDrawingHelper, overlay);
|
||||
});
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Render to DOM
|
||||
*
|
||||
* @param enabledElement
|
||||
* @param svgDrawingHelper
|
||||
* @param overlayData
|
||||
* @returns
|
||||
*/
|
||||
private _renderOverlay(enabledElement, svgDrawingHelper, overlayData) {
|
||||
const { viewport } = enabledElement;
|
||||
const imageId = this.getReferencedImageId(viewport);
|
||||
if (!imageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Decide the rendering position of the overlay image on the current canvas
|
||||
const { _id, columns: width, rows: height, x, y } = overlayData;
|
||||
const overlayTopLeftWorldPos = utilities.imageToWorldCoords(imageId, [
|
||||
x - 1, // Remind that top-left corner's (x, y) is be (1, 1)
|
||||
y - 1,
|
||||
]);
|
||||
const overlayTopLeftOnCanvas = viewport.worldToCanvas(overlayTopLeftWorldPos);
|
||||
const overlayBottomRightWorldPos = utilities.imageToWorldCoords(imageId, [width, height]);
|
||||
const overlayBottomRightOnCanvas = viewport.worldToCanvas(overlayBottomRightWorldPos);
|
||||
|
||||
// add image to the annotations svg layer
|
||||
const svgns = 'http://www.w3.org/2000/svg';
|
||||
const svgNodeHash = `image-overlay-${_id}`;
|
||||
const existingImageElement = svgDrawingHelper.getSvgNode(svgNodeHash);
|
||||
|
||||
const attributes = {
|
||||
'data-id': svgNodeHash,
|
||||
width: overlayBottomRightOnCanvas[0] - overlayTopLeftOnCanvas[0],
|
||||
height: overlayBottomRightOnCanvas[1] - overlayTopLeftOnCanvas[1],
|
||||
x: overlayTopLeftOnCanvas[0],
|
||||
y: overlayTopLeftOnCanvas[1],
|
||||
href: overlayData.dataUrl,
|
||||
};
|
||||
|
||||
if (
|
||||
isNaN(attributes.x) ||
|
||||
isNaN(attributes.y) ||
|
||||
isNaN(attributes.width) ||
|
||||
isNaN(attributes.height)
|
||||
) {
|
||||
console.warn('Invalid rendering attribute for image overlay', attributes['data-id']);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (existingImageElement) {
|
||||
drawing.setAttributesIfNecessary(attributes, existingImageElement);
|
||||
svgDrawingHelper.setNodeTouched(svgNodeHash);
|
||||
} else {
|
||||
const newImageElement = document.createElementNS(svgns, 'image');
|
||||
drawing.setNewAttributesIfValid(attributes, newImageElement);
|
||||
svgDrawingHelper.appendNode(newImageElement, svgNodeHash);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async _getCachedStat(
|
||||
imageId: string,
|
||||
overlayMetadata,
|
||||
color: number[]
|
||||
): Promise<CachedStat> {
|
||||
const missingOverlay = overlayMetadata.overlays.filter(
|
||||
overlay => overlay.pixelData && !overlay.dataUrl
|
||||
);
|
||||
if (missingOverlay.length === 0) {
|
||||
return overlayMetadata;
|
||||
}
|
||||
|
||||
const overlays = await Promise.all(
|
||||
overlayMetadata.overlays
|
||||
.filter(overlay => overlay.pixelData)
|
||||
.map(async (overlay, idx) => {
|
||||
let pixelData = null;
|
||||
if (overlay.pixelData.Value) {
|
||||
pixelData = overlay.pixelData.Value;
|
||||
} else if (overlay.pixelData instanceof Array) {
|
||||
pixelData = overlay.pixelData[0];
|
||||
} else if (overlay.pixelData.retrieveBulkData) {
|
||||
pixelData = await overlay.pixelData.retrieveBulkData();
|
||||
} else if (overlay.pixelData.InlineBinary) {
|
||||
const blob = b64toBlob(overlay.pixelData.InlineBinary);
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
pixelData = arrayBuffer;
|
||||
}
|
||||
|
||||
if (!pixelData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dataUrl = this._renderOverlayToDataUrl(
|
||||
{ width: overlay.columns, height: overlay.rows },
|
||||
overlay.color || color,
|
||||
pixelData
|
||||
);
|
||||
|
||||
return {
|
||||
...overlay,
|
||||
_id: guid(),
|
||||
dataUrl, // this will be a data url expression of the rendered image
|
||||
color,
|
||||
};
|
||||
})
|
||||
);
|
||||
overlayMetadata.overlays = overlays;
|
||||
|
||||
return overlayMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* compare two RGBA expression of colors.
|
||||
*
|
||||
* @param color1
|
||||
* @param color2
|
||||
* @returns
|
||||
*/
|
||||
private _isSameColor(color1: number[], color2: number[]) {
|
||||
return (
|
||||
color1 &&
|
||||
color2 &&
|
||||
color1[0] === color2[0] &&
|
||||
color1[1] === color2[1] &&
|
||||
color1[2] === color2[2] &&
|
||||
color1[3] === color2[3]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* pixelData of overlayPlane module is an array of bits corresponding
|
||||
* to each of the underlying pixels of the image.
|
||||
* Let's create pixel data from bit array of overlay data
|
||||
*
|
||||
* @param pixelDataRaw
|
||||
* @param color
|
||||
* @returns
|
||||
*/
|
||||
private _renderOverlayToDataUrl({ width, height }, color, pixelDataRaw) {
|
||||
const pixelDataView = new DataView(pixelDataRaw);
|
||||
const totalBits = width * height;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, width, height); // make it transparent
|
||||
ctx.globalCompositeOperation = 'copy';
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
const data = imageData.data;
|
||||
for (let i = 0, bitIdx = 0, byteIdx = 0; i < totalBits; i++) {
|
||||
if (pixelDataView.getUint8(byteIdx) & (1 << bitIdx)) {
|
||||
data[i * 4] = color[0];
|
||||
data[i * 4 + 1] = color[1];
|
||||
data[i * 4 + 2] = color[2];
|
||||
data[i * 4 + 3] = color[3];
|
||||
}
|
||||
|
||||
// next bit, byte
|
||||
if (bitIdx >= 7) {
|
||||
bitIdx = 0;
|
||||
byteIdx++;
|
||||
} else {
|
||||
bitIdx++;
|
||||
}
|
||||
}
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
return canvas.toDataURL();
|
||||
}
|
||||
}
|
||||
|
||||
export default ImageOverlayViewerTool;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { metaData } from '@cornerstonejs/core';
|
||||
|
||||
const _cachedOverlayMetadata: Map<string, any[]> = new Map();
|
||||
|
||||
/**
|
||||
* Image Overlay Viewer tool is not a traditional tool that requires user interactin.
|
||||
* But it is used to display Pixel Overlays. And it will provide toggling capability.
|
||||
*
|
||||
* The documentation for Overlay Plane Module of DICOM can be found in [C.9.2 of
|
||||
* Part-3 of DICOM standard](https://dicom.nema.org/medical/dicom/2018b/output/chtml/part03/sect_C.9.2.html)
|
||||
*
|
||||
* Image Overlay rendered by this tool can be toggled on and off using
|
||||
* toolGroup.setToolEnabled() and toolGroup.setToolDisabled()
|
||||
*/
|
||||
const OverlayPlaneModuleProvider = {
|
||||
/** Adds the metadata for overlayPlaneModule */
|
||||
add: (imageId, metadata) => {
|
||||
if (_cachedOverlayMetadata.get(imageId) === metadata) {
|
||||
// This is a no-op here as the tool re-caches the data
|
||||
return;
|
||||
}
|
||||
_cachedOverlayMetadata.set(imageId, metadata);
|
||||
},
|
||||
|
||||
/** Standard getter for metadata */
|
||||
get: (type: string, query: string | string[]) => {
|
||||
if (Array.isArray(query)) {
|
||||
return;
|
||||
}
|
||||
if (type !== 'overlayPlaneModule') {
|
||||
return;
|
||||
}
|
||||
return _cachedOverlayMetadata.get(query);
|
||||
},
|
||||
};
|
||||
|
||||
// Needs to be higher priority than default provider
|
||||
metaData.addProvider(OverlayPlaneModuleProvider.get, 10_000);
|
||||
|
||||
export default OverlayPlaneModuleProvider;
|
||||
58
extensions/cornerstone/src/types/AppTypes.ts
Normal file
58
extensions/cornerstone/src/types/AppTypes.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/* eslint-disable @typescript-eslint/no-namespace */
|
||||
import CornerstoneCacheServiceType from '../services/CornerstoneCacheService';
|
||||
import CornerstoneViewportServiceType from '../services/ViewportService/CornerstoneViewportService';
|
||||
import SegmentationServiceType from '../services/SegmentationService';
|
||||
import SyncGroupServiceType from '../services/SyncGroupService';
|
||||
import ToolGroupServiceType from '../services/ToolGroupService';
|
||||
import ViewportActionCornersServiceType from '../services/ViewportActionCornersService/ViewportActionCornersService';
|
||||
import ColorbarServiceType from '../services/ColorbarService';
|
||||
import * as cornerstone from '@cornerstonejs/core';
|
||||
import * as cornerstoneTools from '@cornerstonejs/tools';
|
||||
|
||||
import type {
|
||||
SegmentRepresentation as SegmentRep,
|
||||
SegmentationData as SegData,
|
||||
SegmentationRepresentation as SegRep,
|
||||
SegmentationInfo as SegInfo,
|
||||
} from '../services/SegmentationService/SegmentationService';
|
||||
|
||||
declare global {
|
||||
namespace AppTypes {
|
||||
export type CornerstoneCacheService = CornerstoneCacheServiceType;
|
||||
export type CornerstoneViewportService = CornerstoneViewportServiceType;
|
||||
export type SegmentationService = SegmentationServiceType;
|
||||
export type SyncGroupService = SyncGroupServiceType;
|
||||
export type ToolGroupService = ToolGroupServiceType;
|
||||
export type ViewportActionCornersService = ViewportActionCornersServiceType;
|
||||
export type ColorbarService = ColorbarServiceType;
|
||||
|
||||
export interface Services {
|
||||
cornerstoneViewportService?: CornerstoneViewportServiceType;
|
||||
toolGroupService?: ToolGroupServiceType;
|
||||
syncGroupService?: SyncGroupServiceType;
|
||||
segmentationService?: SegmentationServiceType;
|
||||
cornerstoneCacheService?: CornerstoneCacheServiceType;
|
||||
viewportActionCornersService?: ViewportActionCornersServiceType;
|
||||
colorbarService?: ColorbarServiceType;
|
||||
}
|
||||
|
||||
export namespace Segmentation {
|
||||
export type SegmentRepresentation = SegmentRep;
|
||||
export type SegmentationData = SegData;
|
||||
export type SegmentationRepresentation = SegRep;
|
||||
export type SegmentationInfo = SegInfo;
|
||||
}
|
||||
|
||||
export interface PresentationIds {
|
||||
lutPresentationId: string;
|
||||
positionPresentationId: string;
|
||||
segmentationPresentationId: string;
|
||||
}
|
||||
|
||||
export interface Test {
|
||||
services?: Services;
|
||||
cornerstone?: typeof cornerstone;
|
||||
cornerstoneTools?: typeof cornerstoneTools;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user