From 803f6384017b61d8029ba553faa584dab6d92834 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Wed, 15 Mar 2023 12:41:41 -0400 Subject: [PATCH 01/19] feat: state sync service and hanging protocol updates to preserve state (#3131) * feat: Add state sync and use it to remember viewport grid info fix: Version updates Fixes for toggling MPR mode Fix the display when the interleaved load module fails Fix the memory of the state to restore correctly PR fixes for the state sync service PR fixes PR fixes PR fixes Added a hack warning to remove volumeDeactivate Fixes for TMTV colormap setting Fix the casing Missed renames fix: tests not running due to variance in ordering Reverting some fixes to change case PR changes - mostly comments and minor improvements fix: All display sets were being updated on drag and drop PR fixes - mostly renames PR fixes Test support for OHIF, for HP branch test: Add at least a minimal set of automated tests for hanging protocols Docs PR fixes Merge fixes DOCS updates Add an example of the mn hanging protocol PR fixes PR fixes PR fixes * Fix the drag and drop PR fixes * PR changes - update default keys for next/previous stage * fix: Was storing the custom viewport grid too aggressively Caused by a PR change misspelling a variable --- .../viewports/OHIFCornerstoneSRViewport.tsx | 7 +- .../src/Viewport/OHIFCornerstoneViewport.tsx | 33 +- .../Overlays/ViewportOrientationMarkers.tsx | 4 + extensions/cornerstone/src/commandsModule.ts | 45 +- .../src/getHangingProtocolModule.ts | 49 +- extensions/cornerstone/src/index.tsx | 7 +- extensions/cornerstone/src/init.tsx | 15 +- .../CornerstoneViewportService.ts | 198 ++- .../src/services/ViewportService/Viewport.ts | 27 +- .../cornerstone/src/types/Presentation.ts | 23 + .../src/utils/getCornerstoneViewportType.ts | 6 +- .../src/utils/mpr/toggleMPRHangingProtocol.ts | 301 ----- .../default/src/Panels/PanelStudyBrowser.tsx | 1 + .../src/Toolbar/ToolbarLayoutSelector.tsx | 54 +- extensions/default/src/ViewerLayout/index.tsx | 20 +- extensions/default/src/commandsModule.js | 97 -- extensions/default/src/commandsModule.ts | 386 ++++++ .../default/src/findViewportsByPosition.ts | 106 ++ .../default/src/getHangingProtocolModule.js | 156 ++- .../default/src/getSopClassHandlerModule.js | 1 + extensions/default/src/init.js | 29 + .../default/src/utils/reuseCachedLayouts.ts | 75 + .../promptBeginTracking.js | 12 +- .../promptHydrateStructuredReport.js | 12 +- .../promptTrackNewSeries.js | 8 +- .../PanelStudyBrowserTracking.tsx | 1 + .../src/custom-attribute/maxNumImageFrames.ts | 1 + .../custom-attribute/numberOfDisplaySets.ts | 1 + .../numberOfDisplaySetsWithImages.ts | 5 + .../src/custom-attribute/sameAs.ts | 33 + .../seriesDescriptionsFromDisplaySets.ts | 1 + extensions/test-extension/src/hp/hpMN.ts | 259 ++++ extensions/test-extension/src/hp/index.ts | 17 + extensions/test-extension/src/index.tsx | 55 +- .../tmtv/src/getHangingProtocolModule.js | 4 - modes/basic-dev-mode/src/index.js | 1 - modes/basic-test-mode/src/index.js | 1 - modes/basic-test-mode/src/toolbarButtons.js | 100 +- modes/longitudinal/src/index.js | 1 - modes/longitudinal/src/toolbarButtons.js | 22 +- modes/tmtv/src/index.js | 2 - modes/tmtv/src/toolbarButtons.js | 8 +- platform/core/src/classes/CommandsManager.ts | 49 +- platform/core/src/defaults/hotkeyBindings.js | 14 + platform/core/src/extensions/MODULE_TYPES.js | 1 + platform/core/src/index.test.js | 1 + platform/core/src/index.ts | 3 + .../CustomizationService.ts | 58 +- .../HangingProtocolService/HPMatcher.js | 36 +- .../HangingProtocolService.test.js | 17 +- .../HangingProtocolService.ts | 1204 ++++++++--------- .../HangingProtocolService/ProtocolEngine.js | 12 +- .../HangingProtocolService/lib/validator.js | 35 + .../lib/validator.test.js | 34 +- .../MeasurementService/MeasurementService.ts | 7 +- platform/core/src/services/ServicesManager.ts | 3 +- .../StateSyncService/StateSyncService.test.js | 31 + .../StateSyncService/StateSyncService.ts | 80 ++ .../src/services/StateSyncService/index.ts | 3 + .../services/ToolBarService/ToolbarService.ts | 55 +- .../ViewportGridService.ts | 36 +- platform/core/src/services/index.ts | 2 + platform/core/src/types/Command.ts | 7 + platform/core/src/types/HangingProtocol.ts | 263 +++- platform/core/src/types/Services.ts | 2 + .../services/data/HangingProtocolService.md | 138 +- .../services/data/StateSyncService.md | 72 + .../platform/services/data/ToolbarService.md | 1 + .../docs/docs/platform/services/data/index.md | 3 +- .../components/SplitButton/SplitButton.tsx | 8 +- .../ui/src/components/Thumbnail/Thumbnail.tsx | 8 +- .../ThumbnailList/ThumbnailList.tsx | 4 +- .../ThumbnailTracked/ThumbnailTracked.tsx | 2 + .../ToolbarButton/ToolbarButton.tsx | 7 +- .../ui/src/components/Viewport/Viewport.tsx | 13 +- .../contextProviders/ViewportGridProvider.tsx | 291 ++-- .../src/contextProviders/getPresentationId.ts | 73 + .../customization/HangingProtocol.spec.js | 31 + .../OHIFStudyBrowser.spec.js | 17 +- platform/viewer/cypress/support/commands.js | 35 +- platform/viewer/public/config/multiple.js | 113 +- platform/viewer/src/App.tsx | 32 +- platform/viewer/src/appInit.js | 2 + .../viewer/src/components/ViewportGrid.tsx | 251 ++-- platform/viewer/src/routes/Mode/Mode.tsx | 21 +- .../viewer/src/routes/Mode/studiesList.ts | 65 + .../viewer/src/routes/WorkList/WorkList.tsx | 8 +- 87 files changed, 3413 insertions(+), 1919 deletions(-) create mode 100644 extensions/cornerstone/src/types/Presentation.ts delete mode 100644 extensions/cornerstone/src/utils/mpr/toggleMPRHangingProtocol.ts delete mode 100644 extensions/default/src/commandsModule.js create mode 100644 extensions/default/src/commandsModule.ts create mode 100644 extensions/default/src/findViewportsByPosition.ts create mode 100644 extensions/default/src/utils/reuseCachedLayouts.ts create mode 100644 extensions/test-extension/src/custom-attribute/maxNumImageFrames.ts create mode 100644 extensions/test-extension/src/custom-attribute/numberOfDisplaySets.ts create mode 100644 extensions/test-extension/src/custom-attribute/numberOfDisplaySetsWithImages.ts create mode 100644 extensions/test-extension/src/custom-attribute/sameAs.ts create mode 100644 extensions/test-extension/src/custom-attribute/seriesDescriptionsFromDisplaySets.ts create mode 100644 extensions/test-extension/src/hp/hpMN.ts create mode 100644 extensions/test-extension/src/hp/index.ts create mode 100644 platform/core/src/services/StateSyncService/StateSyncService.test.js create mode 100644 platform/core/src/services/StateSyncService/StateSyncService.ts create mode 100644 platform/core/src/services/StateSyncService/index.ts create mode 100644 platform/docs/docs/platform/services/data/StateSyncService.md create mode 100644 platform/ui/src/contextProviders/getPresentationId.ts create mode 100644 platform/viewer/cypress/integration/customization/HangingProtocol.spec.js create mode 100644 platform/viewer/src/routes/Mode/studiesList.ts diff --git a/extensions/cornerstone-dicom-sr/src/viewports/OHIFCornerstoneSRViewport.tsx b/extensions/cornerstone-dicom-sr/src/viewports/OHIFCornerstoneSRViewport.tsx index 61068726c..c16b6a334 100644 --- a/extensions/cornerstone-dicom-sr/src/viewports/OHIFCornerstoneSRViewport.tsx +++ b/extensions/cornerstone-dicom-sr/src/viewports/OHIFCornerstoneSRViewport.tsx @@ -1,7 +1,7 @@ import PropTypes from 'prop-types'; import React, { useCallback, useContext, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import OHIF, { utils } from '@ohif/core'; +import OHIF, { utils, ServicesManager, ExtensionManager } from '@ohif/core'; import { setTrackingUniqueIdentifiersForElement } from '../tools/modules/dicomSRModule'; import { @@ -27,6 +27,7 @@ function OHIFCornerstoneSRViewport(props) { dataSource, displaySets, viewportIndex, + viewportOptions, viewportLabel, servicesManager, extensionManager, @@ -215,6 +216,7 @@ function OHIFCornerstoneSRViewport(props) { // override the activeImageDisplaySetData displaySets={[activeImageDisplaySetData]} viewportOptions={{ + ...viewportOptions, toolGroupId: `${SR_TOOLGROUP_BASE_NAME}`, }} onElementEnabled={onElementEnabled} @@ -419,6 +421,9 @@ OHIFCornerstoneSRViewport.propTypes = { dataSource: PropTypes.object, children: PropTypes.node, customProps: PropTypes.object, + viewportOptions: PropTypes.object, + servicesManager: PropTypes.instanceOf(ServicesManager).isRequired, + extensionManager: PropTypes.instanceOf(ExtensionManager).isRequired, }; OHIFCornerstoneSRViewport.defaultProps = { diff --git a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx index e1a6bbb1b..72c41eeae 100644 --- a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx +++ b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx @@ -10,6 +10,7 @@ import { utilities as csUtils, CONSTANTS, } from '@cornerstonejs/core'; +import { Services } from '@ohif/core'; import { setEnabledElement } from '../state'; @@ -22,6 +23,9 @@ import { import getSOPInstanceAttributes from '../utils/measurementServiceMappings/utils/getSOPInstanceAttributes'; import { CinePlayer, useCine, useViewportGrid } from '@ohif/ui'; +import { CornerstoneViewportService } from '../services/ViewportService/CornerstoneViewportService'; +import Presentation from '../types/Presentation'; + const STACK = 'stack'; function areEqual(prevProps, nextProps) { @@ -128,6 +132,7 @@ const OHIFCornerstoneViewport = React.memo(props => { cornerstoneViewportService, cornerstoneCacheService, viewportGridService, + stateSyncService, } = servicesManager.services; const cineHandler = () => { @@ -211,6 +216,21 @@ const OHIFCornerstoneViewport = React.memo(props => { } }, [elementRef]); + const storePresentation = () => { + const currentPresentation = cornerstoneViewportService.getPresentation( + viewportIndex + ); + const { presentationSync } = stateSyncService.getState(); + if (currentPresentation) { + stateSyncService.store({ + presentationSync: { + ...presentationSync, + [currentPresentation.id]: currentPresentation, + }, + }); + } + }; + const cleanUpServices = useCallback(() => { const viewportInfo = cornerstoneViewportService.getViewportInfoByIndex( viewportIndex @@ -288,6 +308,8 @@ const OHIFCornerstoneViewport = React.memo(props => { setImageScrollBarHeight(); return () => { + storePresentation(); + cleanUpServices(); cornerstoneViewportService.disableElement(viewportIndex); @@ -360,11 +382,20 @@ const OHIFCornerstoneViewport = React.memo(props => { initialImageIndex ); + storePresentation(); + + const { presentationSync } = stateSyncService.getState(); + const { presentationId } = viewportOptions; + const presentation = presentationId + ? (presentationSync[presentationId] as Presentation) + : null; + cornerstoneViewportService.setViewportData( viewportIndex, viewportData, viewportOptions, - displaySetOptions + displaySetOptions, + presentation ); }; diff --git a/extensions/cornerstone/src/Viewport/Overlays/ViewportOrientationMarkers.tsx b/extensions/cornerstone/src/Viewport/Overlays/ViewportOrientationMarkers.tsx index c974bce45..8e431ac52 100644 --- a/extensions/cornerstone/src/Viewport/Overlays/ViewportOrientationMarkers.tsx +++ b/extensions/cornerstone/src/Viewport/Overlays/ViewportOrientationMarkers.tsx @@ -118,6 +118,10 @@ function ViewportOrientationMarkers({ viewportIndex ); + if (!ohifViewport) { + console.log('ViewportOrientationMarkers::No viewport'); + return null; + } const backgroundColor = ohifViewport.getViewportOptions().background; // Todo: probably this can be done in a better way in which we identify bright diff --git a/extensions/cornerstone/src/commandsModule.ts b/extensions/cornerstone/src/commandsModule.ts index 45726c0bb..878c174d7 100644 --- a/extensions/cornerstone/src/commandsModule.ts +++ b/extensions/cornerstone/src/commandsModule.ts @@ -15,11 +15,14 @@ import { ServicesManager } from '@ohif/core'; import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm'; import callInputDialog from './utils/callInputDialog'; import { setColormap } from './utils/colormap/transferFunctionHelpers'; -import toggleMPRHangingProtocol from './utils/mpr/toggleMPRHangingProtocol'; import toggleStackImageSync from './utils/stackSync/toggleStackImageSync'; import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement'; -const commandsModule = ({ servicesManager }) => { +const commandsModule = ({ + servicesManager, +}: { + servicesManager: ServicesManager; +}): React.FunctionComponent => { const { viewportGridService, toolGroupService, @@ -27,9 +30,8 @@ const commandsModule = ({ servicesManager }) => { toolbarService, uiDialogService, cornerstoneViewportService, - hangingProtocolService, uiNotificationService, - } = (servicesManager as ServicesManager).services; + } = servicesManager.services; function _getActiveViewportEnabledElement() { return getActiveViewportEnabledElement(viewportGridService); @@ -128,6 +130,14 @@ const commandsModule = ({ servicesManager }) => { }); viewport.render(); }, + + // Just call the toolbar service record interaction - allows + // executing a toolbar command as a full toolbar command with side affects + // coming from the ToolbarService itself. + toolbarServiceRecordInteraction: props => { + toolbarService.recordInteraction(props); + }, + setToolActive: ({ toolName, toolGroupId = null }) => { if (toolName === 'Crosshairs') { const activeViewportToolGroup = _getToolGroup(null); @@ -150,7 +160,7 @@ const commandsModule = ({ servicesManager }) => { }; const toolGroup = _getToolGroup(toolGroupId); - const toolGroupViewportIds = toolGroup.getViewportIds(); + const toolGroupViewportIds = toolGroup?.getViewportIds?.(); // if toolGroup has been destroyed, or its viewports have been removed if (!toolGroupViewportIds || !toolGroupViewportIds.length) { @@ -404,16 +414,6 @@ const commandsModule = ({ servicesManager }) => { (activeViewportIndex - 1 + viewports.length) % viewports.length; viewportGridService.setActiveViewportIndex(nextViewportIndex); }, - setHangingProtocol: ({ protocolId }) => { - hangingProtocolService.setProtocol(protocolId); - }, - toggleMPR: ({ toggledState }) => { - toggleMPRHangingProtocol({ - toggledState, - servicesManager, - getToolGroup: _getToolGroup, - }); - }, toggleStackImageSync: ({ toggledState }) => { toggleStackImageSync({ getEnabledElement, @@ -451,6 +451,11 @@ const commandsModule = ({ servicesManager }) => { storeContexts: [], options: {}, }, + toolbarServiceRecordInteraction: { + commandFn: actions.toolbarServiceRecordInteraction, + storeContexts: [], + options: {}, + }, setToolActive: { commandFn: actions.setToolActive, storeContexts: [], @@ -554,16 +559,6 @@ const commandsModule = ({ servicesManager }) => { storeContexts: [], options: {}, }, - setHangingProtocol: { - commandFn: actions.setHangingProtocol, - storeContexts: [], - options: {}, - }, - toggleMPR: { - commandFn: actions.toggleMPR, - storeContexts: [], - options: {}, - }, toggleStackImageSync: { commandFn: actions.toggleStackImageSync, storeContexts: [], diff --git a/extensions/cornerstone/src/getHangingProtocolModule.ts b/extensions/cornerstone/src/getHangingProtocolModule.ts index c13a45d50..20a962b44 100644 --- a/extensions/cornerstone/src/getHangingProtocolModule.ts +++ b/extensions/cornerstone/src/getHangingProtocolModule.ts @@ -1,16 +1,46 @@ -const mpr = { - id: 'mpr', +import { Types } from '@ohif/core'; + +const mpr: Types.HangingProtocol.Protocol = { locked: true, hasUpdatedPriorsInformation: false, name: 'mpr', createdDate: '2021-02-23T19:22:08.894Z', - modifiedDate: '2022-10-04T19:22:08.894Z', + modifiedDate: '2023-02-17', availableTo: {}, editableBy: {}, + // Unknown number of priors referenced - so just match any study + numberOfPriorsReferenced: 0, protocolMatchingRules: [], - imageLoadStrategy: 'nth', + // imageLoadStrategy: 'nth', + callbacks: { + // Switches out of MPR mode when the layout change button is used + onLayoutChange: [ + { + commandName: 'toggleHangingProtocol', + commandOptions: { protocolId: 'mpr' }, + context: 'DEFAULT', + }, + ], + // Turns off crosshairs when switching out of MPR mode + onProtocolExit: [ + { + commandName: 'toolbarServiceRecordInteraction', + commandOptions: { + interactionType: 'tool', + commands: [ + { + commandOptions: { + toolName: 'WindowLevel', + }, + context: 'CORNERSTONE', + }, + ], + }, + }, + ], + }, displaySetSelectors: { - mprDisplaySet: { + activeDisplaySet: { seriesMatchingRules: [ { weight: 1, @@ -27,8 +57,7 @@ const mpr = { }, stages: [ { - id: 'mpr3Stage', - name: 'mpr', + name: 'MPR 1x3', viewportStructure: { layoutType: 'grid', properties: { @@ -76,7 +105,7 @@ const mpr = { }, displaySets: [ { - id: 'mprDisplaySet', + id: 'activeDisplaySet', }, ], }, @@ -99,7 +128,7 @@ const mpr = { }, displaySets: [ { - id: 'mprDisplaySet', + id: 'activeDisplaySet', }, ], }, @@ -122,7 +151,7 @@ const mpr = { }, displaySets: [ { - id: 'mprDisplaySet', + id: 'activeDisplaySet', }, ], }, diff --git a/extensions/cornerstone/src/index.tsx b/extensions/cornerstone/src/index.tsx index 5259b267c..20b8695c6 100644 --- a/extensions/cornerstone/src/index.tsx +++ b/extensions/cornerstone/src/index.tsx @@ -26,6 +26,7 @@ import { registerColormap } from './utils/colormap/transferFunctionHelpers'; import { id } from './id'; import * as csWADOImageLoader from './initWADOImageLoader.js'; import { measurementMappingUtils } from './utils/measurementServiceMappings'; +import { PublicViewportOptions } from './services/ViewportService/Viewport'; const Component = React.lazy(() => { return import( @@ -94,12 +95,12 @@ const cornerstoneExtension: Types.Extensions.Extension = { // const onNewImageHandler = jumpData => { // commandsManager.runCommand('jumpToImage', jumpData); // }; - const { ToolbarService } = servicesManager.services; + const { toolbarService } = (servicesManager as ServicesManager).services; return ( @@ -151,4 +152,4 @@ const cornerstoneExtension: Types.Extensions.Extension = { }; export default cornerstoneExtension; -export { measurementMappingUtils }; +export { measurementMappingUtils, PublicViewportOptions }; diff --git a/extensions/cornerstone/src/init.tsx b/extensions/cornerstone/src/init.tsx index 8752baefe..b969787cf 100644 --- a/extensions/cornerstone/src/init.tsx +++ b/extensions/cornerstone/src/init.tsx @@ -74,6 +74,7 @@ export default async function init({ hangingProtocolService, toolGroupService, viewportGridService, + stateSyncService, } = servicesManager.services; window.services = servicesManager.services; @@ -97,6 +98,10 @@ export default async function init({ _showCPURenderingModal(uiModalService, hangingProtocolService); } + // Stores a map from `presentationId` to a Presentation object so that + // an OHIFCornerstoneViewport can be redisplayed with the same attributes + stateSyncService.register('presentationSync', { clearOnModeExit: true }); + const labelmapRepresentation = cornerstoneTools.Enums.SegmentationRepresentations.Labelmap; @@ -369,8 +374,8 @@ export default async function init({ viewportGridService.subscribe( viewportGridService.EVENTS.ACTIVE_VIEWPORT_INDEX_CHANGED, - ({ viewportIndex }) => { - const viewportId = `viewport-${viewportIndex}`; + ({ viewportIndex, viewportId }) => { + viewportId = viewportId || `viewport-${viewportIndex}`; const toolGroup = toolGroupService.getToolGroupForViewport(viewportId); if (!toolGroup || !toolGroup._toolInstances?.['ReferenceLines']) { @@ -427,9 +432,9 @@ function _showCPURenderingModal(uiModalService, hangingProtocolService) { }; const { unsubscribe } = hangingProtocolService.subscribe( - hangingProtocolService.EVENTS.HANGING_PROTOCOL_APPLIED_FOR_VIEWPORT, - ({ progress }) => { - const done = callback(progress); + hangingProtocolService.EVENTS.PROTOCOL_CHANGED, + () => { + const done = callback(100); if (done) { unsubscribe(); diff --git a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts index 05e4e382c..37e0b2721 100644 --- a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts +++ b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts @@ -1,4 +1,4 @@ -import { pubSubServiceInterface } from '@ohif/core'; +import { PubSubService } from '@ohif/core'; import { RenderingEngine, StackViewport, @@ -21,6 +21,11 @@ import { StackViewportData, VolumeViewportData, } from '../../types/CornerstoneCacheService'; +import { + Presentation, + StackPresentation, + VolumePresentation, +} from '../../types/Presentation'; import { setColormap, setLowerUpperColorTransferFunction, @@ -37,18 +42,14 @@ const EVENTS = { * Handles cornerstone viewport logic including enabling, disabling, and * updating the viewport. */ -class CornerstoneViewportService implements IViewportService { +class CornerstoneViewportService extends PubSubService + implements IViewportService { renderingEngine: Types.IRenderingEngine | null; - viewportsInfo: Map; + viewportsInfo: Map = new Map(); + viewportsById: Map = new Map(); viewportGridResizeObserver: ResizeObserver | null; viewportsDisplaySets: Map = new Map(); - /** - * Service-specific - */ - EVENTS: { [key: string]: string }; - listeners: { [key: string]: Array<(...args: any[]) => void> }; - _broadcastEvent: unknown; // we should be able to extend the PubSub class to get this // Some configs enableResizeDetector: true; resizeRefreshRateMs: 200; @@ -56,15 +57,10 @@ class CornerstoneViewportService implements IViewportService { servicesManager = null; constructor(servicesManager) { + super(EVENTS); this.renderingEngine = null; this.viewportGridResizeObserver = null; - this.viewportsInfo = new Map(); - // - this.listeners = {}; - this.EVENTS = EVENTS; this.servicesManager = servicesManager; - Object.assign(this, pubSubServiceInterface); - // } /** @@ -77,12 +73,23 @@ class CornerstoneViewportService implements IViewportService { viewportOptions: PublicViewportOptions, elementRef: HTMLDivElement ) { - const viewportInfo = new ViewportInfo( - viewportIndex, - this.getViewportId(viewportIndex) - ); + // Use the provided viewportId + // Not providing a viewportId is frowned upon because it does weird things + // on moving them around, but it does mostly work. + if (!viewportOptions.viewportId) { + console.warn('Should provide viewport id externally', viewportOptions); + viewportOptions.viewportId = + this.getViewportId(viewportIndex) || `viewport-${viewportIndex}`; + } + const { viewportId } = viewportOptions; + const viewportInfo = new ViewportInfo(viewportIndex, viewportId); + if (!viewportInfo.viewportId) { + throw new Error('Should have viewport ID afterwards'); + } + viewportInfo.setElement(elementRef); this.viewportsInfo.set(viewportIndex, viewportInfo); + this.viewportsById.set(viewportId, viewportInfo); } public getViewportIds(): string[] { @@ -96,7 +103,7 @@ class CornerstoneViewportService implements IViewportService { } public getViewportId(viewportIndex: number): string { - return `viewport-${viewportIndex}`; + return this.viewportsInfo[viewportIndex]?.viewportId; } /** @@ -163,6 +170,32 @@ class CornerstoneViewportService implements IViewportService { this.viewportsInfo.get(viewportIndex).destroy(); this.viewportsInfo.delete(viewportIndex); + this.viewportsById.delete(viewportId); + } + + public getPresentation(viewportIndex: number): Presentation { + const viewportInfo = this.viewportsInfo.get(viewportIndex); + if (!viewportInfo) return; + const { + presentationId: id, + viewportType, + } = viewportInfo.getViewportOptions(); + if (!id) return; + + const csViewport = this.getCornerstoneViewportByIndex(viewportIndex); + if (!csViewport) return; + + const properties = csViewport.getProperties(); + const initialImageIndex = csViewport.getCurrentImageIdIndex(); + const camera = csViewport.getCamera(); + return { + id, + viewportType: + !viewportType || viewportType === 'stack' ? 'stack' : 'volume', + properties, + initialImageIndex, + camera, + }; } /** @@ -177,29 +210,27 @@ class CornerstoneViewportService implements IViewportService { viewportIndex: number, viewportData: StackViewportData | VolumeViewportData, publicViewportOptions: PublicViewportOptions, - publicDisplaySetOptions: DisplaySetOptions[] + publicDisplaySetOptions: DisplaySetOptions[], + presentation?: Presentation ): void { const renderingEngine = this.getRenderingEngine(); - const viewportInfo = this.viewportsInfo.get(viewportIndex); - - if (!publicViewportOptions.viewportId) { - publicViewportOptions.viewportId = this.getViewportId(viewportIndex); + const viewportId = + publicViewportOptions.viewportId || this.getViewportId(viewportIndex); + if (!viewportId) { + throw new Error('Must define viewportId externally'); } - let viewportId = viewportInfo.getViewportId(); + const viewportInfo = this.viewportsById.get(viewportId); - // if currently there is a viewport with the viewportId, but it is not the same - // as the one we are trying to set, we need to disable the old one - // and enable the new one, we could ideally change the name of the viewportId - // but the viewportId is an integral part in renderers map, tools svg cache - // etc. which would require a lot of refactoring, for now we will just disable - // the old one and enable the new one at the end of this function - let newViewportId = null; - if (publicViewportOptions?.viewportId !== viewportId) { - newViewportId = publicViewportOptions.viewportId; - viewportInfo.setViewportId(newViewportId); + if (!viewportInfo) { + throw new Error('Viewport info not defined'); + } - renderingEngine.disableElement(viewportId); + // If the viewport has moved index, then record the new index + if (viewportInfo.viewportIndex !== viewportIndex) { + this.viewportsInfo.delete(viewportInfo.viewportIndex); + this.viewportsInfo.set(viewportIndex, viewportInfo); + viewportInfo.viewportIndex = viewportIndex; } viewportInfo.setRenderingEngineId(renderingEngine.id); @@ -220,9 +251,9 @@ class CornerstoneViewportService implements IViewportService { this._broadcastEvent(this.EVENTS.VIEWPORT_DATA_CHANGED, { viewportData, viewportIndex, + viewportId, }); - viewportId = viewportInfo.getViewportId(); const element = viewportInfo.getElement(); const type = viewportInfo.getViewportType(); const background = viewportInfo.getBackground(); @@ -245,7 +276,7 @@ class CornerstoneViewportService implements IViewportService { renderingEngine.enableElement(viewportInput); const viewport = renderingEngine.getViewport(viewportId); - this._setDisplaySets(viewport, viewportData, viewportInfo); + this._setDisplaySets(viewport, viewportData, viewportInfo, presentation); } public getCornerstoneViewport( @@ -308,8 +339,9 @@ class CornerstoneViewportService implements IViewportService { _setStackViewport( viewport: Types.IStackViewport, viewportData: StackViewportData, - viewportInfo: ViewportInfo - ) { + viewportInfo: ViewportInfo, + presentation?: StackPresentation + ): void { const displaySetOptions = viewportInfo.getDisplaySetOptions(); const { @@ -320,29 +352,40 @@ class CornerstoneViewportService implements IViewportService { this.viewportsDisplaySets.set(viewport.id, [displaySetInstanceUID]); - let initialImageIndexToUse = initialImageIndex; + let initialImageIndexToUse = + presentation?.initialImageIndex ?? initialImageIndex; - if (!initialImageIndexToUse) { + if ( + initialImageIndexToUse === undefined || + initialImageIndexToUse === null + ) { initialImageIndexToUse = this._getInitialImageIndexForStackViewport(viewportInfo, imageIds) || 0; } - const { voi, voiInverted } = displaySetOptions[0]; - const properties = {}; - if (voi && (voi.windowWidth || voi.windowCenter)) { - const { lower, upper } = csUtils.windowLevel.toLowHighRange( - voi.windowWidth, - voi.windowCenter - ); - properties.voiRange = { lower, upper }; + const properties = presentation?.properties || {}; + if (!presentation?.properties) { + const { voi, voiInverted } = displaySetOptions[0]; + if (voi && (voi.windowWidth || voi.windowCenter)) { + const { lower, upper } = csUtils.windowLevel.toLowHighRange( + voi.windowWidth, + voi.windowCenter + ); + properties.voiRange = { lower, upper }; + } + + if (voiInverted !== undefined) { + properties.invert = voiInverted; + } } - if (voiInverted !== undefined) { - properties.invert = voiInverted; - } - - viewport.setStack(imageIds, initialImageIndexToUse).then(() => { + // There is a bug in CS3D that the setStack does not + // navigate to the desired image. + viewport.setStack(imageIds, 0).then(() => { + // The scroll, however, works fine in CS3D + viewport.scroll(initialImageIndexToUse); viewport.setProperties(properties); + if (presentation?.camera) viewport.setCamera(presentation.camera); }); } @@ -397,7 +440,8 @@ class CornerstoneViewportService implements IViewportService { async _setVolumeViewport( viewport: Types.IVolumeViewport, viewportData: VolumeViewportData, - viewportInfo: ViewportInfo + viewportInfo: ViewportInfo, + presentation: VolumePresentation ): Promise { // TODO: We need to overhaul the way data sources work so requests can be made // async. I think we should follow the image loader pattern which is async and @@ -423,6 +467,7 @@ class CornerstoneViewportService implements IViewportService { displaySetInstanceUIDs.push(displaySetInstanceUID); if (!volume) { + console.log('Volume display set not found'); continue; } @@ -453,10 +498,15 @@ class CornerstoneViewportService implements IViewportService { !hangingProtocolService.customImageLoadPerformed ) { // delegate the volume loading to the hanging protocol service if it has a custom image load strategy - return hangingProtocolService.runImageLoadStrategy({ - viewportId: viewport.id, - volumeInputArray, - }); + if ( + hangingProtocolService.runImageLoadStrategy({ + viewportId: viewport.id, + volumeInputArray, + }) + ) { + // Fallback to the default strategy if the custom one fails + return; + } } volumeToLoad.forEach(volume => { @@ -464,10 +514,10 @@ class CornerstoneViewportService implements IViewportService { }); // This returns the async continuation only - return this.setVolumesForViewport(viewport, volumeInputArray); + return this.setVolumesForViewport(viewport, volumeInputArray, presentation); } - public async setVolumesForViewport(viewport, volumeInputArray) { + public async setVolumesForViewport(viewport, volumeInputArray, presentation) { const { displaySetService, segmentationService, @@ -475,6 +525,9 @@ class CornerstoneViewportService implements IViewportService { } = this.servicesManager.services; await viewport.setVolumes(volumeInputArray); + const { properties, camera } = presentation || {}; + if (properties) viewport.setProperties(properties); + if (camera) viewport.setCamera(camera); // load any secondary displaySets const displaySetInstanceUIDs = this.viewportsDisplaySets.get(viewport.id); @@ -589,7 +642,11 @@ class CornerstoneViewportService implements IViewportService { // Todo: keepCamera is an interim solution until we have a better solution for // keeping the camera position when the viewport data is changed - public updateViewport(viewportIndex, viewportData, keepCamera = false) { + public updateViewport( + viewportIndex: number, + viewportData, + keepCamera = false + ) { const viewportInfo = this.getViewportInfoByIndex(viewportIndex); const viewportId = viewportInfo.getViewportId(); @@ -645,19 +702,22 @@ class CornerstoneViewportService implements IViewportService { _setDisplaySets( viewport: StackViewport | VolumeViewport, viewportData: StackViewportData | VolumeViewportData, - viewportInfo: ViewportInfo + viewportInfo: ViewportInfo, + presentation?: Presentation ): void { if (viewport instanceof StackViewport) { this._setStackViewport( viewport, viewportData as StackViewportData, - viewportInfo + viewportInfo, + presentation as StackPresentation ); } else if (viewport instanceof VolumeViewport) { this._setVolumeViewport( viewport, viewportData as VolumeViewportData, - viewportInfo + viewportInfo, + presentation as VolumePresentation ); } else { throw new Error('Unknown viewport type'); @@ -758,7 +818,7 @@ class CornerstoneViewportService implements IViewportService { } } -export default function ExtendedCornerstoneViewportService(serviceManager) { +export default function CornerstoneViewportServiceRegistration(serviceManager) { return { name: 'cornerstoneViewportService', altName: 'CornerstoneViewportService', @@ -767,3 +827,5 @@ export default function ExtendedCornerstoneViewportService(serviceManager) { }, }; } + +export { CornerstoneViewportService, CornerstoneViewportServiceRegistration }; diff --git a/extensions/cornerstone/src/services/ViewportService/Viewport.ts b/extensions/cornerstone/src/services/ViewportService/Viewport.ts index 03fce0312..51b97bf91 100644 --- a/extensions/cornerstone/src/services/ViewportService/Viewport.ts +++ b/extensions/cornerstone/src/services/ViewportService/Viewport.ts @@ -18,6 +18,8 @@ export type ViewportOptions = { viewportType: Enums.ViewportType; toolGroupId: string; viewportId: string; + // Presentation ID to store/load presentation state from + presentationId?: string; orientation?: Types.Orientation; background?: Types.Point3; syncGroups?: SyncGroup[]; @@ -33,6 +35,7 @@ export type ViewportOptions = { export type PublicViewportOptions = { viewportType?: string; toolGroupId?: string; + presentationId?: string; viewportId?: string; orientation?: string; background?: Types.Point3; @@ -42,6 +45,11 @@ export type PublicViewportOptions = { allowUnmatchedView?: boolean; }; +export type DisplaySetSelector = { + id?: string; + options?: PublicDisplaySetOptions; +}; + export type PublicDisplaySetOptions = { voi?: VOI; voiInverted?: boolean; @@ -136,7 +144,7 @@ class ViewportInfo { } public setPublicDisplaySetOptions( - publicDisplaySetOptions: Array + publicDisplaySetOptions: PublicDisplaySetOptions[] | DisplaySetSelector[] ): void { // map the displaySetOptions and check if they are undefined then set them to default values const displaySetOptions = this.mapDisplaySetOptions( @@ -167,7 +175,10 @@ class ViewportInfo { viewportOptionsEntry: PublicViewportOptions ): void { let viewportType = viewportOptionsEntry.viewportType; - let toolGroupId = viewportOptionsEntry.toolGroupId; + const { + toolGroupId = DEFAULT_TOOLGROUP_ID, + presentationId, + } = viewportOptionsEntry; let orientation; if (!viewportType) { @@ -185,16 +196,13 @@ class ViewportInfo { orientation = Enums.OrientationAxis.AXIAL; } - if (!toolGroupId) { - toolGroupId = DEFAULT_TOOLGROUP_ID; - } - this.setViewportOptions({ ...viewportOptionsEntry, viewportId: this.viewportId, viewportType: viewportType as Enums.ViewportType, orientation, toolGroupId, + presentationId, }); } @@ -240,12 +248,15 @@ class ViewportInfo { return this.viewportOptions.initialImageOptions; } + // Handle incoming public display set options or a display set select + // with a contained options. private mapDisplaySetOptions( - publicDisplaySetOptions: Array + options: PublicDisplaySetOptions[] | DisplaySetSelector[] = [{}] ): Array { const displaySetOptions: Array = []; - publicDisplaySetOptions.forEach(option => { + options.forEach(item => { + let option = item?.options || item; if (!option) { option = { blendMode: undefined, diff --git a/extensions/cornerstone/src/types/Presentation.ts b/extensions/cornerstone/src/types/Presentation.ts new file mode 100644 index 000000000..4520f3b11 --- /dev/null +++ b/extensions/cornerstone/src/types/Presentation.ts @@ -0,0 +1,23 @@ +/** Store presentation data for either stack viewports or volume viewports */ +import { Types } from '@cornerstonejs/core'; + +export interface BasePresentation { + id: string; + properties: Record; + initialImageIndex?: number; + camera: Types.ICamera; +} + +export interface StackPresentation extends BasePresentation { + viewportType: 'stack'; +} + +export interface VolumePresentation extends BasePresentation { + viewportType: 'volume'; +} + +// Currently it seems like the entire presentation state can be shared between +// Stack and Volume, but is setup to allow differences +export type Presentation = StackPresentation | VolumePresentation; + +export default Presentation; diff --git a/extensions/cornerstone/src/utils/getCornerstoneViewportType.ts b/extensions/cornerstone/src/utils/getCornerstoneViewportType.ts index 2e19312b6..88f386eeb 100644 --- a/extensions/cornerstone/src/utils/getCornerstoneViewportType.ts +++ b/extensions/cornerstone/src/utils/getCornerstoneViewportType.ts @@ -2,15 +2,17 @@ import { Enums } from '@cornerstonejs/core'; const STACK = 'stack'; const VOLUME = 'volume'; +const ORTHOGRAPHIC = 'orthographic'; export default function getCornerstoneViewportType( viewportType: string ): Enums.ViewportType { - if (viewportType.toLowerCase() === STACK) { + const lowerViewportType = viewportType.toLowerCase(); + if (lowerViewportType === STACK) { return Enums.ViewportType.STACK; } - if (viewportType.toLowerCase() === VOLUME) { + if (lowerViewportType === VOLUME || lowerViewportType === ORTHOGRAPHIC) { return Enums.ViewportType.ORTHOGRAPHIC; } diff --git a/extensions/cornerstone/src/utils/mpr/toggleMPRHangingProtocol.ts b/extensions/cornerstone/src/utils/mpr/toggleMPRHangingProtocol.ts deleted file mode 100644 index cd6b556a2..000000000 --- a/extensions/cornerstone/src/utils/mpr/toggleMPRHangingProtocol.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { Enums } from '@cornerstonejs/tools'; -import removeToolGroupSegmentationRepresentations from '../removeToolGroupSegmentationRepresentations'; - -const MPR_TOOLGROUP_ID = 'mpr'; - -const cachedState = { - protocol: null, - stage: null, - viewportMatchDetails: null, - viewportStructure: null, - toolOptions: null, -}; - -const setCachedState = ( - protocol, - stage, - viewportMatchDetails, - viewportStructure, - toolOptions -) => { - cachedState.protocol = protocol; - cachedState.stage = stage; - cachedState.viewportMatchDetails = viewportMatchDetails; - cachedState.viewportStructure = viewportStructure; - cachedState.toolOptions = JSON.parse(JSON.stringify(toolOptions)); -}; - -const resetCachedState = () => { - cachedState.protocol = null; - cachedState.stage = null; - cachedState.viewportMatchDetails = null; - cachedState.viewportStructure = null; - cachedState.toolOptions = null; -}; - -export default function toggleMPRHangingProtocol({ - toggledState, - servicesManager, - getToolGroup, -}) { - const { - uiNotificationService, - hangingProtocolService, - viewportGridService, - toolbarService, - } = servicesManager.services; - - // TODO Introduce a service to persist the state of the current hanging protocol/app. - // So all of the code to persist the state here will no longer be needed. Perhaps - // just the id of the current hanging protocol to toggle MPR off is needed. - - const { - activeViewportIndex, - viewports, - numRows, - numCols, - } = viewportGridService.getState(); - const viewportDisplaySetInstanceUIDs = - viewports[activeViewportIndex].displaySetInstanceUIDs; - - // What is the current active protocol and stage number to restore later - const { protocol, stage } = hangingProtocolService.getActiveProtocol(); - - const restoreErrorCallback = error => { - console.error(error); - uiNotificationService.show({ - title: 'Multiplanar reconstruction (MPR) ', - message: - 'Something went wrong while trying to restore the previous layout.', - type: 'info', - duration: 3000, - }); - }; - - if (toggledState) { - resetCachedState(); - - const { - viewportMatchDetails, - viewportStructure, - toolOptions, - } = _getViewportsInfo({ - protocol, - stage, - viewports, - servicesManager, - }); - - setCachedState( - protocol, - stage, - viewportMatchDetails, - viewportStructure, - toolOptions - ); - - const matchDetails = { - displaySetInstanceUIDs: viewportDisplaySetInstanceUIDs, - }; - - _disableCrosshairs( - toolOptions.map(({ toolGroupId }) => toolGroupId), - getToolGroup - ); - - const errorCallback = error => { - // Unable to create MPR, so be sure to return to the cached/original protocol. - hangingProtocolService.setProtocol( - cachedState.protocol.id, - viewportMatchDetails, - restoreErrorCallback - ); - - uiNotificationService.show({ - title: 'Multiplanar reconstruction (MPR) ', - message: - 'Cannot create MPR for this DisplaySet since it is not reconstructable.', - type: 'info', - duration: 3000, - }); - }; - - hangingProtocolService.setProtocol( - MPR_TOOLGROUP_ID, - matchDetails, - errorCallback - ); - return; - } - - _disableCrosshairs([MPR_TOOLGROUP_ID], getToolGroup); - - const { layoutType, properties } = cachedState.viewportStructure; - const { viewportMatchDetails } = cachedState; - - // The reason we split the flow here is that we don't allow viewport grid - // change in the non default hanging protocol, so we can just apply the - // cached protocol and stage. However, for the default protocol, we need - // to also apply the layout type and properties. - if (cachedState.protocol.id !== 'default') { - hangingProtocolService.setProtocol( - cachedState.protocol.id, - viewportMatchDetails, - restoreErrorCallback - ); - - return; - } - - hangingProtocolService.setProtocol( - 'default', - viewportMatchDetails, - restoreErrorCallback - ); - - if (numRows !== properties.rows || numCols !== properties.columns) { - viewportGridService.setLayout({ - numRows: properties.rows, - numCols: properties.columns, - layoutType, - layoutOptions: properties.layoutOptions, - }); - } - - const numViewports = - properties.layoutOptions.length || properties.rows * properties.columns; - - // loop inside viewportMatchDetails map - // and set the viewportOptions for each viewport - [...Array(numViewports).keys()].forEach(viewportIndex => { - const viewportMatchDetailsForViewport = viewportMatchDetails.get( - viewportIndex - ); - - if (viewportMatchDetailsForViewport) { - const { - viewportOptions, - displaySetsInfo, - } = viewportMatchDetailsForViewport; - viewportGridService.setDisplaySetsForViewport({ - viewportIndex, - displaySetInstanceUIDs: displaySetsInfo.map( - displaySetInfo => displaySetInfo.displaySetInstanceUID - ), - viewportOptions, - }); - } else { - viewportGridService.setDisplaySetsForViewport({ - viewportIndex, - displaySetInstanceUIDs: [], - viewportOptions: {}, - }); - } - }); - - toolbarService.recordInteraction({ - groupId: 'WindowLevel', - itemId: 'WindowLevel', - interactionType: 'tool', - commands: [ - { - commandName: 'setToolActive', - commandOptions: { - toolName: 'WindowLevel', - }, - context: 'CORNERSTONE', - }, - ], - }); - - //clear segmentations if they exist - removeToolGroupSegmentationRepresentations(MPR_TOOLGROUP_ID); -} - -function _disableCrosshairs(toolGroupIds, getToolGroup) { - toolGroupIds.forEach(toolGroupId => { - const toolGroup = getToolGroup(toolGroupId); - if ( - toolGroup.getToolInstance('Crosshairs')?.mode === Enums.ToolModes.Active - ) { - toolGroup.setToolDisabled('Crosshairs'); - } - }); -} - -function _getViewportsInfo({ protocol, stage, viewports, servicesManager }) { - // here we need to use the viewports and try to map it into the - // viewportMatchDetails and displaySetMatch that hangingProtocolService - // expects - const { - viewportGridService, - hangingProtocolService, - toolGroupService, - } = servicesManager.services; - - const { numRows, numCols } = viewportGridService.getState(); - - let viewportMatchDetails = new Map(); - - const viewportStructure = { - layoutType: 'grid', - properties: { - rows: numRows, - columns: numCols, - layoutOptions: [], - }, - }; - - viewports.forEach((viewport, viewportIndex) => { - viewportStructure.properties.layoutOptions.push({ - x: viewport.x, - y: viewport.y, - width: viewport.width, - height: viewport.height, - }); - }); - - if (protocol.id === 'default') { - viewports.forEach((viewport, viewportIndex) => { - if (viewport.displaySetInstanceUIDs) { - viewportMatchDetails.set(viewportIndex, { - displaySetsInfo: viewport.displaySetInstanceUIDs.map( - displaySetInstanceUID => { - return { displaySetInstanceUID }; - } - ), - viewportOptions: viewport.viewportOptions, - }); - } - }); - } else { - ({ viewportMatchDetails } = hangingProtocolService.getMatchDetails()); - } - - // get the toolGroup state for viewports - let toolOptions = []; - const viewportIds = viewports - .map( - viewport => - viewport.displaySetInstanceUIDs && - viewport.displaySetInstanceUIDs.length > 0 && - viewport.viewportOptions?.viewportId - ) - .filter(Boolean); - - if (viewportIds.length) { - toolOptions = viewportIds - .map(viewportId => { - const toolGroup = toolGroupService.getToolGroupForViewport(viewportId); - return toolGroup - ? { - toolGroupId: toolGroup.id, - toolOptions: toolGroup.toolOptions, - } - : null; - }) - .filter(Boolean); - } - - return { viewportMatchDetails, viewportStructure, toolOptions }; -} diff --git a/extensions/default/src/Panels/PanelStudyBrowser.tsx b/extensions/default/src/Panels/PanelStudyBrowser.tsx index 05f32fefd..9eb75aa95 100644 --- a/extensions/default/src/Panels/PanelStudyBrowser.tsx +++ b/extensions/default/src/Panels/PanelStudyBrowser.tsx @@ -303,6 +303,7 @@ function _mapDisplaySets(displaySets, thumbnailImageSrcMap) { seriesDate: ds.SeriesDate, seriesTime: ds.SeriesTime, numInstances: ds.numImageFrames, + countIcon: ds.countIcon, StudyInstanceUID: ds.StudyInstanceUID, componentType, imageSrc, diff --git a/extensions/default/src/Toolbar/ToolbarLayoutSelector.tsx b/extensions/default/src/Toolbar/ToolbarLayoutSelector.tsx index 6080de195..b8e3723c9 100644 --- a/extensions/default/src/Toolbar/ToolbarLayoutSelector.tsx +++ b/extensions/default/src/Toolbar/ToolbarLayoutSelector.tsx @@ -1,10 +1,6 @@ import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; -import { - LayoutSelector as OHIFLayoutSelector, - ToolbarButton, - useViewportGrid, -} from '@ohif/ui'; +import { LayoutSelector as OHIFLayoutSelector, ToolbarButton } from '@ohif/ui'; import { ServicesManager } from '@ohif/core'; @@ -16,8 +12,6 @@ function LayoutSelector({ ...rest }) { const [isOpen, setIsOpen] = useState(false); - const [disableSelector, setDisableSelector] = useState(false); - const [viewportGridState, viewportGridService] = useViewportGrid(); const { hangingProtocolService, @@ -50,43 +44,19 @@ function LayoutSelector({ }; }, [isOpen]); - useEffect(() => { - /* Reset to default layout when component unmounts */ - return () => { - viewportGridService.setLayout({ numCols: 1, numRows: 1 }); - }; - }, []); - const onInteractionHandler = () => setIsOpen(!isOpen); const DropdownContent = isOpen ? OHIFLayoutSelector : null; - const onSelectionHandler = ({ numRows, numCols }) => { - // TODO Introduce a service to persist the state of the current hanging protocol/app. - - // TODO Here the layout change will amount to a change of hanging protocol as specified by the extension for this layout selector tool - // followed by the change of the grid itself. - if (hangingProtocolService.getActiveProtocol().protocol.id === 'mpr') { - toolbarService.recordInteraction({ - groupId: 'MPR', - itemId: 'MPR', - interactionType: 'toggle', - commands: [ - { - commandName: 'toggleMPR', - commandOptions: {}, - context: 'CORNERSTONE', - }, - ], - }); - } - - // When a new layout is selected, keep any extra/offscreen viewports - // so that if any of those viewports were populated via the UI then they - // will be maintained in case those viewports are redisplayed later. - viewportGridService.setLayout({ - numRows, - numCols, - keepExtraViewports: true, + const onSelectionHandler = props => { + toolbarService.recordInteraction({ + interactionType: 'action', + commands: [ + { + commandName: 'setViewportGridLayout', + commandOptions: { ...props }, + context: 'DEFAULT', + }, + ], }); }; @@ -107,7 +77,7 @@ function LayoutSelector({ /> ) } - isActive={disableSelector ? false : isOpen} + isActive={isOpen} type="toggle" /> ); diff --git a/extensions/default/src/ViewerLayout/index.tsx b/extensions/default/src/ViewerLayout/index.tsx index 04776be4c..30b8932a5 100644 --- a/extensions/default/src/ViewerLayout/index.tsx +++ b/extensions/default/src/ViewerLayout/index.tsx @@ -14,7 +14,12 @@ import { LoadingIndicatorProgress, } from '@ohif/ui'; import i18n from '@ohif/i18n'; -import { hotkeys } from '@ohif/core'; +import { + ServicesManager, + HangingProtocolService, + hotkeys, + CommandsManager, +} from '@ohif/core'; import { useAppConfig } from '@state'; import Toolbar from '../Toolbar/Toolbar'; @@ -33,7 +38,7 @@ function ViewerLayout({ rightPanels = [], leftPanelDefaultClosed = false, rightPanelDefaultClosed = false, -}) { +}): React.FunctionComponent { const [appConfig] = useAppConfig(); const navigate = useNavigate(); const location = useLocation(); @@ -169,15 +174,13 @@ function ViewerLayout({ useEffect(() => { const { unsubscribe } = hangingProtocolService.subscribe( - hangingProtocolService.EVENTS.HANGING_PROTOCOL_APPLIED_FOR_VIEWPORT, + HangingProtocolService.EVENTS.PROTOCOL_CHANGED, // Todo: right now to set the loading indicator to false, we need to wait for the // hangingProtocolService to finish applying the viewport matching to each viewport, // however, this might not be the only approach to set the loading indicator to false. we need to explore this further. - ({ progress }) => { - if (progress === 100) { - setShowLoadingIndicator(false); - } + () => { + setShowLoadingIndicator(false); } ); @@ -265,7 +268,8 @@ ViewerLayout.propTypes = { extensionManager: PropTypes.shape({ getModuleEntry: PropTypes.func.isRequired, }).isRequired, - commandsManager: PropTypes.object, + commandsManager: PropTypes.instanceOf(CommandsManager), + servicesManager: PropTypes.instanceOf(ServicesManager), // From modes leftPanels: PropTypes.array, rightPanels: PropTypes.array, diff --git a/extensions/default/src/commandsModule.js b/extensions/default/src/commandsModule.js deleted file mode 100644 index a39b78670..000000000 --- a/extensions/default/src/commandsModule.js +++ /dev/null @@ -1,97 +0,0 @@ -import DicomTagBrowser from './DicomTagBrowser/DicomTagBrowser'; -import React from 'react'; - -const commandsModule = ({ servicesManager, commandsManager }) => { - const { - measurementService, - hangingProtocolService, - uiNotificationService, - viewportGridService, - displaySetService, - } = servicesManager.services; - - const actions = { - displayNotification: ({ text, title, type }) => { - uiNotificationService.show({ - title: title, - message: text, - type: type, - }); - }, - clearMeasurements: () => { - measurementService.clear(); - }, - nextStage: () => { - // next stage in hanging protocols - hangingProtocolService.nextProtocolStage(); - }, - previousStage: () => { - hangingProtocolService.previousProtocolStage(); - }, - openDICOMTagViewer() { - const { activeViewportIndex, viewports } = viewportGridService.getState(); - const activeViewportSpecificData = viewports[activeViewportIndex]; - const { displaySetInstanceUIDs } = activeViewportSpecificData; - - const displaySets = displaySetService.activeDisplaySets; - const { uiModalService } = servicesManager.services; - - const displaySetInstanceUID = displaySetInstanceUIDs[0]; - uiModalService.show({ - content: DicomTagBrowser, - contentProps: { - displaySets, - displaySetInstanceUID, - onClose: uiModalService.hide, - }, - title: 'DICOM Tag Browser', - }); - }, - - /** - * Toggle viewport overlay (the information panel shown on the four corners - * of the viewport) - * @see ViewportOverlay and CustomizableViewportOverlay components - */ - toggleOverlays: () => { - const overlays = document.getElementsByClassName('viewport-overlay'); - for (let i = 0; i < overlays.length; i++) { - overlays.item(i).classList.toggle('hidden'); - } - }, - }; - - const definitions = { - clearMeasurements: { - commandFn: actions.clearMeasurements, - storeContexts: [], - options: {}, - }, - displayNotification: { - commandFn: actions.displayNotification, - storeContexts: [], - options: {}, - }, - nextStage: { - commandFn: actions.nextStage, - storeContexts: [], - options: {}, - }, - previousStage: { - commandFn: actions.previousStage, - storeContexts: [], - options: {}, - }, - openDICOMTagViewer: { - commandFn: actions.openDICOMTagViewer, - }, - }; - - return { - actions, - definitions, - defaultContext: 'DEFAULT', - }; -}; - -export default commandsModule; diff --git a/extensions/default/src/commandsModule.ts b/extensions/default/src/commandsModule.ts new file mode 100644 index 000000000..7d26048dc --- /dev/null +++ b/extensions/default/src/commandsModule.ts @@ -0,0 +1,386 @@ +import { DicomMetadataStore, ServicesManager } from '@ohif/core'; + +import DicomTagBrowser from './DicomTagBrowser/DicomTagBrowser'; +import reuseCachedLayouts from './utils/reuseCachedLayouts'; +import findViewportsByPosition, { + findOrCreateViewport as layoutFindOrCreate, +} from './findViewportsByPosition'; + +export type HangingProtocolParams = { + protocolId?: string; + stageIndex?: number; + activeStudyUID?: string; + stageId?: string; +}; + +/** + * Determine if a command is a hanging protocol one. + * For now, just use the two hanging protocol commands that are in this + * commands module, but if others get added elsewhere this may need enhancing. + */ +const isHangingProtocolCommand = command => + command && + (command.commandName === 'setHangingProtocol' || + command.commandName === 'toggleHangingProtocol'); + +const commandsModule = ({ servicesManager, commandsManager }) => { + const { + measurementService, + hangingProtocolService, + uiNotificationService, + viewportGridService, + displaySetService, + stateSyncService, + toolbarService, + } = (servicesManager as ServicesManager).services; + + const actions = { + displayNotification: ({ text, title, type }) => { + uiNotificationService.show({ + title: title, + message: text, + type: type, + }); + }, + clearMeasurements: () => { + measurementService.clear(); + }, + + /** + * Toggles off all tools which contain a commandName of setHangingProtocol + * or toggleHangingProtocol, and which match/don't match the protocol id/stage + */ + toggleHpTools: () => { + const { + protocol, + stageIndex: toggleStageIndex, + stage, + } = hangingProtocolService.getActiveProtocol(); + const enableListener = button => { + if (!button.id) return; + const { commands, items } = button.props || button; + if (items) { + items.forEach(enableListener); + } + const hpCommand = commands?.find?.(isHangingProtocolCommand); + if (!hpCommand) return; + const { protocolId, stageIndex, stageId } = hpCommand.commandOptions; + const isActive = + (!protocolId || protocolId === protocol.id) && + (stageIndex === undefined || stageIndex === toggleStageIndex) && + (!stageId || stageId === stage.id); + toolbarService.setActive(button.id, isActive); + }; + Object.values(toolbarService.getButtons()).forEach(enableListener); + }, + + /** + * Sets the specified protocol + * 1. Records any existing state using the viewport grid service + * 2. Finds the destination state - this can be one of: + * a. The specified protocol stage + * b. An alternate (toggled or restored) protocol stage + * c. A restored custom layout + * 3. Finds the parameters for the specified state + * a. Gets the displaySetSelectorMap + * b. Gets the map by position + * c. Gets any toggle mapping to map position to/from current view + * 4. If restore, then sets layout + * a. Maps viewport position by currently displayed viewport map id + * b. Uses toggle information to map display set id + * 5. Else applies the hanging protocol + * a. HP Service is provided displaySetSelectorMap + * b. HP Service will throw an exception if it isn't applicable + * @param options - contains information on the HP to apply + * @param options.activeStudyUID - the updated study to apply the HP to + * @param options.protocolId - the protocol ID to change to + * @param options.stageId - the stageId to apply + * @param options.stageIndex - the index of the stage to go to. + */ + setHangingProtocol: ({ + activeStudyUID = '', + protocolId, + stageId, + stageIndex, + }: HangingProtocolParams): boolean => { + try { + // Stores in the state the reuseID to displaySetUID mapping + // Pass in viewportId for the active viewport. This item will get set as + // the activeViewportId + const state = viewportGridService.getState(); + const hpInfo = hangingProtocolService.getState(); + const { + protocol: oldProtocol, + } = hangingProtocolService.getActiveProtocol(); + const stateSyncReduce = reuseCachedLayouts( + state, + hangingProtocolService, + stateSyncService + ); + const { + hangingProtocolStageIndexMap, + viewportGridStore, + displaySetSelectorMap, + } = stateSyncReduce; + + if (!protocolId) { + // Re-use the previous protocol id, and optionally stage + protocolId = hpInfo.protocolId; + if (stageId === undefined && stageIndex === undefined) { + stageIndex = hpInfo.stageIndex; + } + } else if (stageIndex === undefined && stageId === undefined) { + // Re-set the same stage as was previously used + const hangingId = `${activeStudyUID || + hpInfo.activeStudyUID}:${protocolId}`; + stageIndex = hangingProtocolStageIndexMap[hangingId]?.stageIndex; + } + + const useStageIdx = + stageIndex ?? + hangingProtocolService.getStageIndex(protocolId, { + stageId, + stageIndex, + }); + + if (activeStudyUID) { + hangingProtocolService.setActiveStudyUID(activeStudyUID); + } + + const storedHanging = `${hangingProtocolService.getState().activeStudyUID + }:${protocolId}:${useStageIdx || 0}`; + + const restoreProtocol = !!viewportGridStore[storedHanging]; + + if ( + protocolId === hpInfo.hangingProtocolId && + useStageIdx === hpInfo.stageIdx && + !activeStudyUID + ) { + // Clear the HP setting to reset them + hangingProtocolService.setProtocol(protocolId, { + stageId, + stageIndex: useStageIdx, + }); + } else { + hangingProtocolService.setProtocol(protocolId, { + displaySetSelectorMap, + stageId, + stageIndex: useStageIdx, + restoreProtocol, + }); + if (restoreProtocol) { + viewportGridService.set(viewportGridStore[storedHanging]); + } + } + // Do this after successfully applying the update + stateSyncService.store(stateSyncReduce); + // This is a default action applied + actions.toggleHpTools(hangingProtocolService.getActiveProtocol()); + // Send the notification about updating the state + if (protocolId !== hpInfo.protocolId) { + const { protocol } = hangingProtocolService.getActiveProtocol(); + // The old protocol callbacks are used for turning off things + // like crosshairs when moving to the new HP + commandsManager.run(oldProtocol.callbacks?.onProtocolExit); + // The new protocol callback is used for things like + // activating modes etc. + commandsManager.run(protocol.callbacks?.onProtocolEnter); + } + return true; + } catch (e) { + actions.toggleHpTools(hangingProtocolService.getActiveProtocol()); + uiNotificationService.show({ + title: 'Apply Hanging Protocol', + message: `The hanging protocol could not be applied due to ${e}`, + type: 'error', + duration: 3000, + }); + return false; + } + }, + + toggleHangingProtocol: ({ + protocolId, + stageIndex, + }: HangingProtocolParams): boolean => { + const { + protocol, + stageIndex: desiredStageIndex, + activeStudy, + } = hangingProtocolService.getActiveProtocol(); + const { toggleHangingProtocol } = stateSyncService.getState(); + const storedHanging = `${activeStudy.StudyInstanceUID + }:${protocolId}:${stageIndex | 0}`; + if ( + protocol.id === protocolId && + (stageIndex === undefined || stageIndex === desiredStageIndex) + ) { + // Toggling off - restore to previous state + const previousState = toggleHangingProtocol[storedHanging] || { + protocolId: 'default', + }; + return actions.setHangingProtocol(previousState); + } else { + stateSyncService.store({ + toggleHangingProtocol: { + ...toggleHangingProtocol, + [storedHanging]: { + protocolId: protocol.id, + stageIndex: desiredStageIndex, + }, + }, + }); + return actions.setHangingProtocol({ protocolId, stageIndex }); + } + }, + + deltaStage: ({ direction }) => { + const { + protocolId, + stageIndex: oldStageIndex, + } = hangingProtocolService.getState(); + const { protocol } = hangingProtocolService.getActiveProtocol(); + for ( + let stageIndex = oldStageIndex + direction; + stageIndex >= 0 && stageIndex < protocol.stages.length; + stageIndex += direction + ) { + if (protocol.stages[stageIndex].status !== 'disabled') { + return actions.setHangingProtocol({ + protocolId, + stageIndex, + }); + } + } + uiNotificationService.show({ + title: 'Change Stage', + message: 'The hanging protocol has no more applicable stages', + type: 'error', + duration: 3000, + }); + }, + + /** + * Changes the viewport grid layout in terms of the MxN layout. + */ + setViewportGridLayout: ({ numRows, numCols }) => { + const { protocol } = hangingProtocolService.getActiveProtocol(); + const onLayoutChange = protocol.callbacks?.onLayoutChange; + if (commandsManager.run(onLayoutChange, { numRows, numCols }) === false) { + console.log( + 'setViewportGridLayout running', + onLayoutChange, + numRows, + numCols + ); + // Don't apply the layout if the run command returns false + return; + } + + const completeLayout = () => { + const state = viewportGridService.getState(); + const stateReduce = findViewportsByPosition( + state, + { numRows, numCols }, + stateSyncService + ); + const findOrCreateViewport = layoutFindOrCreate.bind( + null, + hangingProtocolService, + stateReduce.viewportsByPosition + ); + + viewportGridService.setLayout({ + numRows, + numCols, + findOrCreateViewport, + }); + stateSyncService.store(stateReduce); + }; + // Need to finish any work in the callback + window.setTimeout(completeLayout, 0); + }, + + openDICOMTagViewer() { + const { activeViewportIndex, viewports } = viewportGridService.getState(); + const activeViewportSpecificData = viewports[activeViewportIndex]; + const { displaySetInstanceUIDs } = activeViewportSpecificData; + + const displaySets = displaySetService.activeDisplaySets; + const { UIModalService } = servicesManager.services; + + const displaySetInstanceUID = displaySetInstanceUIDs[0]; + UIModalService.show({ + content: DicomTagBrowser, + contentProps: { + displaySets, + displaySetInstanceUID, + onClose: UIModalService.hide, + }, + title: 'DICOM Tag Browser', + }); + }, + + /** + * Toggle viewport overlay (the information panel shown on the four corners + * of the viewport) + * @see ViewportOverlay and CustomizableViewportOverlay components + */ + toggleOverlays: () => { + const overlays = document.getElementsByClassName('viewport-overlay'); + for (let i = 0; i < overlays.length; i++) { + overlays.item(i).classList.toggle('hidden'); + } + }, + }; + + const definitions = { + clearMeasurements: { + commandFn: actions.clearMeasurements, + storeContexts: [], + options: {}, + }, + displayNotification: { + commandFn: actions.displayNotification, + storeContexts: [], + options: {}, + }, + setHangingProtocol: { + commandFn: actions.setHangingProtocol, + storeContexts: [], + options: {}, + }, + toggleHangingProtocol: { + commandFn: actions.toggleHangingProtocol, + storeContexts: [], + options: {}, + }, + nextStage: { + commandFn: actions.deltaStage, + storeContexts: [], + options: { direction: 1 }, + }, + previousStage: { + commandFn: actions.deltaStage, + storeContexts: [], + options: { direction: -1 }, + }, + setViewportGridLayout: { + commandFn: actions.setViewportGridLayout, + storeContexts: [], + options: {}, + }, + openDICOMTagViewer: { + commandFn: actions.openDICOMTagViewer, + }, + }; + + return { + actions, + definitions, + defaultContext: 'DEFAULT', + }; +}; + +export default commandsModule; diff --git a/extensions/default/src/findViewportsByPosition.ts b/extensions/default/src/findViewportsByPosition.ts new file mode 100644 index 000000000..76c488146 --- /dev/null +++ b/extensions/default/src/findViewportsByPosition.ts @@ -0,0 +1,106 @@ +import { StateSyncService, Types } from '@ohif/core'; + +/** + * This find or create viewport is paired with the reduce results from + * below, and the action of this viewport is to look for previously filled + * viewports, and to re-use by position id. If there is no filled viewport, + * then one can be re-used from the display set if it isn't going to be displayed. + * @param hangingProtocolService - bound parameter supplied before using this + * @param viewportsByPosition - bound parameter supplied before using this + * @param viewportIndex - the index to retrieve + * @param positionId - the current position on screen to retrieve + * @param options - the set of options used, so that subsequent calls can + * store state that is reset by the setLayout. + * This class uses the options to store the already viewed + * display sets, filling it initially with the pre-existing viewports. + */ +export const findOrCreateViewport = ( + hangingProtocolService, + viewportsByPosition, + viewportIndex: number, + positionId: string, + options: Record +) => { + const byPositionViewport = viewportsByPosition?.[positionId]; + if (byPositionViewport) return { ...byPositionViewport }; + const { protocolId, stageIndex } = hangingProtocolService.getState(); + + // Setup the initial in display correctly for initial view/select + if (!options.inDisplay) { + options.inDisplay = [...viewportsByPosition.initialInDisplay]; + } + // See if there is a default viewport for new views. + const missing = hangingProtocolService.getMissingViewport( + protocolId, + stageIndex, + options + ); + if (missing) { + const displaySetInstanceUIDs = missing.displaySetsInfo.map( + it => it.displaySetInstanceUID + ); + options.inDisplay.push(...displaySetInstanceUIDs); + return { + displaySetInstanceUIDs, + displaySetOptions: missing.displaySetsInfo.map( + it => it.displaySetOptions + ), + viewportOptions: { + ...missing.viewportOptions, + }, + }; + } + return {}; +}; + +/** + * Records the information on what viewports are displayed in which position. + * Also records what instances from the existing positions are going to be in + * view initially. + * @param state is the viewport grid state + * @param syncService is the state sync service to use for getting existing state + * @returns Set of states that can be applied to the state sync to remember + * the current view state. + */ +const findViewportsByPosition = ( + state, + { numRows, numCols }, + syncService: StateSyncService +): Record> => { + const { viewports } = state; + const syncState = syncService.getState(); + const viewportsByPosition = { ...syncState.viewportsByPosition }; + const initialInDisplay = []; + + for (const viewport of viewports) { + if (viewport.positionId) { + const storedViewport = { + ...viewport, + viewportOptions: { ...viewport.viewportOptions }, + }; + viewportsByPosition[viewport.positionId] = storedViewport; + // The cache doesn't store the viewport options - it is only useful + // for remembering the type of viewport and UIDs + delete storedViewport.viewportId; + delete storedViewport.viewportOptions.viewportId; + } + } + + for (let row = 0; row < numRows; row++) { + for (let col = 0; col < numCols; col++) { + const pos = col + row * numCols; + const positionId = viewports?.[pos]?.positionId || `${col}-${row}`; + const viewport = viewportsByPosition[positionId]; + if (viewport?.displaySetInstanceUIDs) { + initialInDisplay.push(...viewport.displaySetInstanceUIDs); + } + } + } + + // Store the initially displayed elements + viewportsByPosition.initialInDisplay = initialInDisplay; + + return { viewportsByPosition }; +}; + +export default findViewportsByPosition; diff --git a/extensions/default/src/getHangingProtocolModule.js b/extensions/default/src/getHangingProtocolModule.js index cdcf242c3..42b7bde9d 100644 --- a/extensions/default/src/getHangingProtocolModule.js +++ b/extensions/default/src/getHangingProtocolModule.js @@ -1,6 +1,9 @@ const defaultProtocol = { id: 'default', locked: true, + // Don't store this hanging protocol as it applies to the currently active + // display set by default + // cacheId: null, hasUpdatedPriorsInformation: false, name: 'Default', createdDate: '2021-02-23T19:22:08.894Z', @@ -9,6 +12,25 @@ const defaultProtocol = { editableBy: {}, protocolMatchingRules: [], toolGroupIds: ['default'], + // -1 would be used to indicate active only, whereas other values are + // the number of required priors referenced - so 0 means active with + // 0 or more priors. + numberOfPriorsReferenced: 0, + // Default viewport is used to define the viewport when + // additional viewports are added using the layout tool + defaultViewport: { + viewportOptions: { + viewportType: 'stack', + toolGroupId: 'default', + allowUnmatchedView: true, + }, + displaySets: [ + { + id: 'defaultDisplaySetId', + matchedDisplaySetsIndex: -1, + }, + ], + }, displaySetSelectors: { defaultDisplaySetId: { // Unused currently @@ -24,12 +46,12 @@ const defaultProtocol = { }, }, ], - studyMatchingRules: [], + // Can be used to select matching studies + // studyMatchingRules: [], }, }, stages: [ { - id: 'hYbmMy3b7pz7GLiaT', name: 'default', viewportStructure: { layoutType: 'grid', @@ -38,6 +60,119 @@ const defaultProtocol = { columns: 1, }, }, + viewports: [ + { + viewportOptions: { + viewportType: 'stack', + toolGroupId: 'default', + // initialImageOptions: { + // index: 180, + // preset: 'middle', // 'first', 'last', 'middle' + // }, + }, + displaySets: [ + { + id: 'defaultDisplaySetId', + }, + ], + }, + ], + createdDate: '2021-02-23T18:32:42.850Z', + }, + + // This is an example of a 2x2 layout that requires at least 2 viewports + // filled to be navigatable to + { + name: '2x2', + // Indicate that the number of viewports needed is 2 filled viewports, + // but that 4 viewports are preferred. + stageActivation: { + enabled: { + minViewportsMatched: 4, + }, + passive: { + minViewportsMatched: 2, + }, + }, + + viewportStructure: { + layoutType: 'grid', + properties: { + rows: 1, + columns: 2, + }, + }, + viewports: [ + { + viewportOptions: { + toolGroupId: 'default', + }, + displaySets: [ + { + id: 'defaultDisplaySetId', + matchedDisplaySetsIndex: 3, + }, + ], + }, + { + viewportOptions: { + toolGroupId: 'default', + }, + displaySets: [ + { + id: 'defaultDisplaySetId', + matchedDisplaySetsIndex: 2, + }, + ], + }, + { + viewportOptions: { + toolGroupId: 'default', + }, + displaySets: [ + { + id: 'defaultDisplaySetId', + matchedDisplaySetsIndex: 1, + }, + ], + }, + { + viewportOptions: { + toolGroupId: 'default', + // initialImageOptions: { + // index: 180, + // preset: 'middle', // 'first', 'last', 'middle' + // }, + }, + displaySets: [ + { + id: 'defaultDisplaySetId', + matchedDisplaySetsIndex: 0, + }, + ], + }, + ], + }, + + // This is an example of a layout with more than one element in it + // It can be navigated to using , and . (prev/next stage) + { + name: '1x2', + // Indicate that the number of viewports needed is 1 filled viewport, + // but that 2 viewports are preferred. + stageActivation: { + enabled: { + minViewportsMatched: 3, + }, + }, + + viewportStructure: { + layoutType: 'grid', + properties: { + rows: 1, + columns: 2, + }, + }, viewports: [ { viewportOptions: { @@ -53,11 +188,26 @@ const defaultProtocol = { }, ], }, + { + viewportOptions: { + toolGroupId: 'default', + // initialImageOptions: { + // index: 180, + // preset: 'middle', // 'first', 'last', 'middle' + // }, + }, + displaySets: [ + { + id: 'defaultDisplaySetId', + // Shows the second index of this image set + matchedDisplaySetsIndex: 1, + }, + ], + }, ], createdDate: '2021-02-23T18:32:42.850Z', }, ], - numberOfPriorsReferenced: -1, }; function getHangingProtocolModule() { diff --git a/extensions/default/src/getSopClassHandlerModule.js b/extensions/default/src/getSopClassHandlerModule.js index 317ffdd64..f00018187 100644 --- a/extensions/default/src/getSopClassHandlerModule.js +++ b/extensions/default/src/getSopClassHandlerModule.js @@ -29,6 +29,7 @@ const makeDisplaySet = instances => { SeriesDescription: instance.SeriesDescription || '', Modality: instance.Modality, isMultiFrame: isMultiFrame(instance), + countIcon: displayReconstructableInfo.value ? 'icon-mpr' : undefined, numImageFrames: instances.length, SOPClassHandlerId: `${id}.sopClassHandlerModule.${sopClassHandlerName}`, isReconstructable: displayReconstructableInfo.value, diff --git a/extensions/default/src/init.js b/extensions/default/src/init.js index 1bfc3aa8e..7c7d487dc 100644 --- a/extensions/default/src/init.js +++ b/extensions/default/src/init.js @@ -11,6 +11,7 @@ const metadataProvider = classes.MetadataProvider; * @param {Object} configuration */ export default function init({ servicesManager, configuration }) { + const { stateSyncService } = servicesManager.services; // Add DicomMetadataStore.subscribe( DicomMetadataStore.EVENTS.INSTANCES_ADDED, @@ -23,6 +24,34 @@ export default function init({ servicesManager, configuration }) { DicomMetadataStore.EVENTS.SERIES_UPDATED, handlePETImageMetadata ); + + // viewportGridStore is a sync state which stores the entire + // ViewportGridService getState, by the keys `::` + // Used to recover manual changes to the layout of a stage. + stateSyncService.register('viewportGridStore', { clearOnModeExit: true }); + + // displaySetSelectorMap stores a map from + // `::` to + // a displaySetInstanceUID, used to display named display sets in + // specific spots within a hanging protocol and be able to remember what the + // user did with those named spots between stages and protocols. + stateSyncService.register('displaySetSelectorMap', { clearOnModeExit: true }); + + // Stores a map from `:${protocolId}` to the getHPInfo results + // in order to recover the correct stage when returning to a Hanging Protocol. + stateSyncService.register('hangingProtocolStageIndexMap', { + clearOnModeExit: true, + }); + + // Stores a map from the to be applied hanging protocols `:` + // to the previously applied hanging protolStageIndexMap key, in order to toggle + // off the applied protocol and remember the old state. + stateSyncService.register('toggleHangingProtocol', { clearOnModeExit: true }); + + // Stores the viewports by `rows-cols` position so that when the layout + // changes numRows and numCols, the viewports can be remembers and then replaced + // afterwards. + stateSyncService.register('viewportsByPosition', { clearOnModeExit: true }); } const handlePETImageMetadata = ({ SeriesInstanceUID, StudyInstanceUID }) => { diff --git a/extensions/default/src/utils/reuseCachedLayouts.ts b/extensions/default/src/utils/reuseCachedLayouts.ts new file mode 100644 index 000000000..09e1a9fef --- /dev/null +++ b/extensions/default/src/utils/reuseCachedLayouts.ts @@ -0,0 +1,75 @@ +import { HangingProtocolService, StateSyncService, Types } from '@ohif/core'; + +export type ReturnType = { + hangingProtocolStageIndexMap: Record; + viewportGridStore: Record; + displaySetSelectorMap: Record; +}; + +/** + * Calculates a set of state information for hanging protocols and viewport grid + * which defines the currently applied hanging protocol state. + * @param state is the viewport grid state + * @param syncService is the state sync service to use for getting existing state + * @returns Set of states that can be applied to the state sync to remember + * the current view state. + */ +const reuseCachedLayout = ( + state, + hangingProtocolService: HangingProtocolService, + syncService: StateSyncService +): ReturnType => { + const { activeViewportIndex, viewports, layout } = state; + const hpInfo = hangingProtocolService.getState(); + const { protocolId, stageIndex, activeStudyUID } = hpInfo; + const { protocol } = hangingProtocolService.getActiveProtocol(); + const stage = protocol.stages[stageIndex]; + const storeId = `${activeStudyUID}:${protocolId}:${stageIndex}`; + const syncState = syncService.getState(); + const cacheId = `${activeStudyUID}:${protocolId}`; + const viewportGridStore = { ...syncState.viewportGridStore }; + const hangingProtocolStageIndexMap = { + ...syncState.hangingProtocolStageIndexMap, + }; + const displaySetSelectorMap = { ...syncState.displaySetSelectorMap }; + const { rows, columns } = stage.viewportStructure.properties; + const custom = + stage.viewports.length !== state.viewports.length || + state.layout.numRows !== rows || + state.layout.numCols !== columns; + + hangingProtocolStageIndexMap[cacheId] = hpInfo; + + if (storeId && custom) { + viewportGridStore[storeId] = { ...state }; + } + + for (let idx = 0; idx < state.viewports.length; idx++) { + const viewport = state.viewports[idx]; + const { displaySetOptions, displaySetInstanceUIDs } = viewport; + if (!displaySetOptions) continue; + for (let i = 0; i < displaySetOptions.length; i++) { + const displaySetUID = displaySetInstanceUIDs[i]; + if (!displaySetUID) continue; + if (idx === activeViewportIndex && i === 0) { + displaySetSelectorMap[ + `${activeStudyUID}:activeDisplaySet:0` + ] = displaySetUID; + } + if (displaySetOptions[i]?.id) { + displaySetSelectorMap[ + `${activeStudyUID}: ${displaySetOptions[i].id}: ${displaySetOptions[i] + .matchedDisplaySetsIndex || 0}` + ] = displaySetUID; + } + } + } + + return { + hangingProtocolStageIndexMap, + viewportGridStore, + displaySetSelectorMap, + }; +}; + +export default reuseCachedLayout; diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptBeginTracking.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptBeginTracking.js index 81419827c..75d7db2d7 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptBeginTracking.js +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptBeginTracking.js @@ -7,12 +7,12 @@ const RESPONSE = { }; function promptBeginTracking({ servicesManager, extensionManager }, ctx, evt) { - const { UIViewportDialogService } = servicesManager.services; + const { uiViewportDialogService } = servicesManager.services; const { viewportIndex, StudyInstanceUID, SeriesInstanceUID } = evt; return new Promise(async function(resolve, reject) { let promptResult = await _askTrackMeasurements( - UIViewportDialogService, + uiViewportDialogService, viewportIndex ); @@ -25,7 +25,7 @@ function promptBeginTracking({ servicesManager, extensionManager }, ctx, evt) { }); } -function _askTrackMeasurements(UIViewportDialogService, viewportIndex) { +function _askTrackMeasurements(uiViewportDialogService, viewportIndex) { return new Promise(function(resolve, reject) { const message = 'Track measurements for this series?'; const actions = [ @@ -49,11 +49,11 @@ function _askTrackMeasurements(UIViewportDialogService, viewportIndex) { }, ]; const onSubmit = result => { - UIViewportDialogService.hide(); + uiViewportDialogService.hide(); resolve(result); }; - UIViewportDialogService.show({ + uiViewportDialogService.show({ viewportIndex, id: 'measurement-tracking-prompt-begin-tracking', type: 'info', @@ -61,7 +61,7 @@ function _askTrackMeasurements(UIViewportDialogService, viewportIndex) { actions, onSubmit, onOutsideClick: () => { - UIViewportDialogService.hide(); + uiViewportDialogService.hide(); resolve(RESPONSE.CANCEL); }, }); diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptHydrateStructuredReport.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptHydrateStructuredReport.js index bc889bfc7..52d139c55 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptHydrateStructuredReport.js +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptHydrateStructuredReport.js @@ -16,7 +16,7 @@ function promptHydrateStructuredReport( evt ) { const { - UIViewportDialogService, + uiViewportDialogService, displaySetService, } = servicesManager.services; const { viewportIndex, displaySetInstanceUID } = evt; @@ -26,7 +26,7 @@ function promptHydrateStructuredReport( return new Promise(async function(resolve, reject) { const promptResult = await _askTrackMeasurements( - UIViewportDialogService, + uiViewportDialogService, viewportIndex ); @@ -55,7 +55,7 @@ function promptHydrateStructuredReport( }); } -function _askTrackMeasurements(UIViewportDialogService, viewportIndex) { +function _askTrackMeasurements(uiViewportDialogService, viewportIndex) { return new Promise(function(resolve, reject) { const message = 'Do you want to continue tracking measurements for this study?'; @@ -72,18 +72,18 @@ function _askTrackMeasurements(UIViewportDialogService, viewportIndex) { }, ]; const onSubmit = result => { - UIViewportDialogService.hide(); + uiViewportDialogService.hide(); resolve(result); }; - UIViewportDialogService.show({ + uiViewportDialogService.show({ viewportIndex, type: 'info', message, actions, onSubmit, onOutsideClick: () => { - UIViewportDialogService.hide(); + uiViewportDialogService.hide(); resolve(RESPONSE.CANCEL); }, }); diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewSeries.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewSeries.js index af4662821..1016212a9 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewSeries.js +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewSeries.js @@ -33,7 +33,7 @@ function promptTrackNewSeries({ servicesManager, extensionManager }, ctx, evt) { }); } -function _askShouldAddMeasurements(UIViewportDialogService, viewportIndex) { +function _askShouldAddMeasurements(uiViewportDialogService, viewportIndex) { return new Promise(function(resolve, reject) { const message = 'Do you want to add this measurement to the existing report?'; @@ -51,18 +51,18 @@ function _askShouldAddMeasurements(UIViewportDialogService, viewportIndex) { }, ]; const onSubmit = result => { - UIViewportDialogService.hide(); + uiViewportDialogService.hide(); resolve(result); }; - UIViewportDialogService.show({ + uiViewportDialogService.show({ viewportIndex, type: 'info', message, actions, onSubmit, onOutsideClick: () => { - UIViewportDialogService.hide(); + uiViewportDialogService.hide(); resolve(RESPONSE.CANCEL); }, }); diff --git a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx index a04989f0b..ec92ce6a3 100644 --- a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx +++ b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx @@ -453,6 +453,7 @@ function _mapDisplaySets( modality: ds.Modality, seriesDate: formatDate(ds.SeriesDate), numInstances: ds.numImageFrames, + countIcon: ds.countIcon, StudyInstanceUID: ds.StudyInstanceUID, componentType, imageSrc, diff --git a/extensions/test-extension/src/custom-attribute/maxNumImageFrames.ts b/extensions/test-extension/src/custom-attribute/maxNumImageFrames.ts new file mode 100644 index 000000000..13293f3d0 --- /dev/null +++ b/extensions/test-extension/src/custom-attribute/maxNumImageFrames.ts @@ -0,0 +1 @@ +export default (study, extraData) => Math.max(...(extraData?.displaySets?.map?.(ds => (ds.numImageFrames ?? 0))) || [0]); \ No newline at end of file diff --git a/extensions/test-extension/src/custom-attribute/numberOfDisplaySets.ts b/extensions/test-extension/src/custom-attribute/numberOfDisplaySets.ts new file mode 100644 index 000000000..1fdbc17e4 --- /dev/null +++ b/extensions/test-extension/src/custom-attribute/numberOfDisplaySets.ts @@ -0,0 +1 @@ +export default (study, extraData) => extraData?.displaySets?.length; \ No newline at end of file diff --git a/extensions/test-extension/src/custom-attribute/numberOfDisplaySetsWithImages.ts b/extensions/test-extension/src/custom-attribute/numberOfDisplaySetsWithImages.ts new file mode 100644 index 000000000..75c3d969c --- /dev/null +++ b/extensions/test-extension/src/custom-attribute/numberOfDisplaySetsWithImages.ts @@ -0,0 +1,5 @@ +export default (study, extraData) => { + const ret = extraData?.displaySets?.filter(ds => ds.numImageFrames>0)?.length; + console.log("number of display sets with images", ret); + return ret; +}; \ No newline at end of file diff --git a/extensions/test-extension/src/custom-attribute/sameAs.ts b/extensions/test-extension/src/custom-attribute/sameAs.ts new file mode 100644 index 000000000..da836c635 --- /dev/null +++ b/extensions/test-extension/src/custom-attribute/sameAs.ts @@ -0,0 +1,33 @@ +/** + * This function extracts an attribute from the already matched display sets, and + * compares it to the attribute in the current display set, and indicates if they match. + * From 'this', it uses: + * `sameAttribute` as the attribute name to look for + * `sameDisplaySetId` as the display set id to look for + * From `options`, it looks for + */ +export default function (displaySet, options) { + const { sameAttribute, sameDisplaySetId } = this; + if( !sameAttribute ) { + console.log("sameAttribute not defined in", this); + return `sameAttribute not defined in ${this.id}`; + } + if( !sameDisplaySetId ) { + console.log("sameDisplaySetId not defined in", this); + return `sameDisplaySetId not defined in ${this.id}`; + } + const { displaySetMatchDetails, displaySets } = options; + const match = displaySetMatchDetails.get(sameDisplaySetId); + if( !match ) { + console.log("No match for display set", sameDisplaySetId); + return false; + } + const { displaySetInstanceUID } = match; + const altDisplaySet = displaySets.find(it => it.displaySetInstanceUID==displaySetInstanceUID); + if( !altDisplaySet ) { + console.log("No display set found with", displaySetInstanceUID, "in", displaySets); + return false; + } + const testValue = altDisplaySet[sameAttribute]; + return testValue===displaySet[sameAttribute]; +} \ No newline at end of file diff --git a/extensions/test-extension/src/custom-attribute/seriesDescriptionsFromDisplaySets.ts b/extensions/test-extension/src/custom-attribute/seriesDescriptionsFromDisplaySets.ts new file mode 100644 index 000000000..445fb6dd2 --- /dev/null +++ b/extensions/test-extension/src/custom-attribute/seriesDescriptionsFromDisplaySets.ts @@ -0,0 +1 @@ +export default (study, extraData) => extraData?.displaySets?.map(ds => ds.SeriesDescription); \ No newline at end of file diff --git a/extensions/test-extension/src/hp/hpMN.ts b/extensions/test-extension/src/hp/hpMN.ts new file mode 100644 index 000000000..70bc365bb --- /dev/null +++ b/extensions/test-extension/src/hp/hpMN.ts @@ -0,0 +1,259 @@ +import { Types } from '@ohif/core'; + +/** + * This hanging protocol has multiple stages, which are enabled when + * there are enough display sets with images to fill the stage, and + * are passive when there is at least one display set. + * Enabled display sets are navigated to by default, while passive ones + * are navigated to manually using the ctrl+end keyboard shortcut. + */ +const hpMN: Types.HangingProtocol.Protocol = { + hasUpdatedPriorsInformation: false, + id: '@ohif/hp-extension.mn', + description: 'Has various hanging protocol layouts for use in testing', + name: '2x2', + protocolMatchingRules: [ + { + id: 'OneOrMoreSeries', + weight: 1, + attribute: 'numberOfDisplaySetsWithImages', + constraint: { + greaterThan: 1, + }, + }, + ], + toolGroupIds: ['default'], + displaySetSelectors: { + defaultDisplaySetId: { + seriesMatchingRules: [ + { + attribute: 'numImageFrames', + constraint: { + greaterThan: { value: 0 }, + }, + }, + ], + }, + }, + defaultViewport: { + viewportOptions: { + viewportType: 'stack', + toolGroupId: 'default', + allowUnmatchedView: true, + }, + displaySets: [ + { + id: 'defaultDisplaySetId', + matchedDisplaySetsIndex: -1, + }, + ], + }, + stages: [ + { + id: '2x2', + stageActivation: { + enabled: { + minViewportsMatched: 4, + }, + }, + viewportStructure: { + layoutType: 'grid', + properties: { + rows: 2, + columns: 2, + }, + }, + viewports: [ + { + viewportOptions: { + toolGroupId: 'default', + allowUnmatchedView: true, + }, + displaySets: [ + { + id: 'defaultDisplaySetId', + }, + ], + }, + { + viewportOptions: { + toolGroupId: 'default', + allowUnmatchedView: true, + }, + displaySets: [ + { + matchedDisplaySetsIndex: 1, + id: 'defaultDisplaySetId', + }, + ], + }, + { + viewportOptions: { + toolGroupId: 'default', + allowUnmatchedView: true, + }, + displaySets: [ + { + matchedDisplaySetsIndex: 2, + id: 'defaultDisplaySetId', + }, + ], + }, + { + viewportOptions: { + toolGroupId: 'default', + allowUnmatchedView: true, + }, + displaySets: [ + { + matchedDisplaySetsIndex: 3, + id: 'defaultDisplaySetId', + }, + ], + }, + ], + }, + + // 3x1 stage + { + id: '3x1', + // Obsolete settings: + requiredViewports: 1, + preferredViewports: 3, + // New equivalent: + stageActivation: { + enabled: { + minViewportsMatched: 3, + }, + }, + viewportStructure: { + layoutType: 'grid', + properties: { + rows: 1, + columns: 3, + }, + }, + viewports: [ + { + viewportOptions: { + toolGroupId: 'default', + allowUnmatchedView: true, + }, + displaySets: [ + { + id: 'defaultDisplaySetId', + reuseId: '0-0', + }, + ], + }, + { + viewportOptions: { + toolGroupId: 'default', + allowUnmatchedView: true, + }, + displaySets: [ + { + matchedDisplaySetsIndex: 1, + id: 'defaultDisplaySetId', + reuseId: '1-0', + }, + ], + }, + { + viewportOptions: { + toolGroupId: 'default', + allowUnmatchedView: true, + }, + displaySets: [ + { + matchedDisplaySetsIndex: 2, + id: 'defaultDisplaySetId', + reuseId: '0-1', + }, + ], + }, + ], + }, + + // A 2x1 stage + { + id: '2x1', + requiredViewports: 1, + preferredViewports: 2, + stageActivation: { + enabled: { + minViewportsMatched: 2, + }, + }, + viewportStructure: { + layoutType: 'grid', + layoutType: 'grid', + properties: { + rows: 1, + columns: 2, + }, + }, + viewports: [ + { + viewportOptions: { + toolGroupId: 'default', + allowUnmatchedView: true, + }, + displaySets: [ + { + id: 'defaultDisplaySetId', + }, + ], + }, + { + viewportOptions: { + toolGroupId: 'default', + allowUnmatchedView: true, + }, + displaySets: [ + { + matchedDisplaySetsIndex: 1, + id: 'defaultDisplaySetId', + }, + ], + }, + ], + }, + + // A 1x1 stage - should be automatically activated if there is only 1 viewable instance + { + id: '1x1', + requiredViewports: 1, + preferredViewports: 1, + stageActivation: { + enabled: { + minViewportsMatched: 1, + }, + }, + viewportStructure: { + layoutType: 'grid', + properties: { + rows: 1, + columns: 1, + }, + }, + viewports: [ + { + viewportOptions: { + toolGroupId: 'default', + allowUnmatchedView: true, + }, + displaySets: [ + { + matchedDisplaySetsIndex: 1, + id: 'defaultDisplaySetId', + }, + ], + }, + ], + }, + ], + numberOfPriorsReferenced: -1, +}; + +export default hpMN; diff --git a/extensions/test-extension/src/hp/index.ts b/extensions/test-extension/src/hp/index.ts new file mode 100644 index 000000000..a24aebb59 --- /dev/null +++ b/extensions/test-extension/src/hp/index.ts @@ -0,0 +1,17 @@ +import hpMN from './hpMN'; + +const hangingProtocols = [ + { + id: '@ohif/hp-extension.mn', + protocol: hpMN, + }, +]; + +/** + * Registers a single study hanging protocol which can be referenced as + * `@ohif/hp-exgtension.mn`, that has initial layouts which show images + * only display sets, up to a 2x2 view. + */ +export default function getHangingProtocolModule() { + return hangingProtocols; +} diff --git a/extensions/test-extension/src/index.tsx b/extensions/test-extension/src/index.tsx index 5c07074c3..bbd34d639 100644 --- a/extensions/test-extension/src/index.tsx +++ b/extensions/test-extension/src/index.tsx @@ -1,17 +1,64 @@ -import { id } from './id'; import { Types } from '@ohif/core'; +import { id } from './id'; + +import getHangingProtocolModule from './hp'; +// import {setViewportZoomPan, storeViewportZoomPan } from './custom-viewport/setViewportZoomPan'; +import sameAs from './custom-attribute/sameAs'; +import numberOfDisplaySets from './custom-attribute/numberOfDisplaySets'; +import numberOfDisplaySetsWithImages from './custom-attribute/numberOfDisplaySetsWithImages'; +import maxNumImageFrames from './custom-attribute/maxNumImageFrames'; +import seriesDescriptionsFromDisplaySets from './custom-attribute/seriesDescriptionsFromDisplaySets'; + /** - * + * The test extension provides additional behaviour for testing various + * customizations and settings for OHIF. */ const testExtension: Types.Extensions.Extension = { /** * Only required property. Should be a unique value across all extensions. */ id, - preRegistration() { - console.debug('hello from test-extension init.js'); + + /** Register additional behaviour: + * * HP custom attribute seriesDescriptions to retrieve an array of all series descriptions + * * HP custom attribute numberOfDisplaySets to retrieve the number of display sets + * * HP custom attribute numberOfDisplaySetsWithImages to retrieve the number of display sets containing images + * * HP custom attribute to return a boolean true, when the attribute sameAttribute has the same + * value as another series description in an already matched display set selector named with the value + * in `sameDisplaySetId` + */ + preRegistration: ({ servicesManager }: Types.Extensions.ExtensionParams) => { + const { hangingProtocolService } = servicesManager.services; + hangingProtocolService.addCustomAttribute( + 'seriesDescriptions', + 'Series Descriptions', + seriesDescriptionsFromDisplaySets + ); + hangingProtocolService.addCustomAttribute( + 'numberOfDisplaySets', + 'Number of displays sets', + numberOfDisplaySets + ); + hangingProtocolService.addCustomAttribute( + 'numberOfDisplaySetsWithImages', + 'Number of displays sets with images', + numberOfDisplaySetsWithImages + ); + hangingProtocolService.addCustomAttribute( + 'maxNumImageFrames', + 'Maximum of number of image frames', + maxNumImageFrames + ); + hangingProtocolService.addCustomAttribute( + 'sameAs', + 'Match an attribute in an existing display set', + sameAs + ); }, + + /** Registers some additional hanging protocols. See hp/index.tsx for more details */ + getHangingProtocolModule, }; export default testExtension; diff --git a/extensions/tmtv/src/getHangingProtocolModule.js b/extensions/tmtv/src/getHangingProtocolModule.js index 34a28489e..b58e55d42 100644 --- a/extensions/tmtv/src/getHangingProtocolModule.js +++ b/extensions/tmtv/src/getHangingProtocolModule.js @@ -32,7 +32,6 @@ const ptCT = { ctDisplaySet: { seriesMatchingRules: [ { - weight: 1, attribute: 'Modality', constraint: { equals: { @@ -42,7 +41,6 @@ const ptCT = { required: true, }, { - weight: 1, attribute: 'isReconstructable', constraint: { equals: { @@ -75,7 +73,6 @@ const ptCT = { required: true, }, { - weight: 1, attribute: 'isReconstructable', constraint: { equals: { @@ -105,7 +102,6 @@ const ptCT = { stages: [ { - id: 'hYbmMy3b7pz7GLiaT', name: 'default', viewportStructure: { layoutType: 'grid', diff --git a/modes/basic-dev-mode/src/index.js b/modes/basic-dev-mode/src/index.js index f3368e412..d74ee60a4 100644 --- a/modes/basic-dev-mode/src/index.js +++ b/modes/basic-dev-mode/src/index.js @@ -140,7 +140,6 @@ function modeFactory({ modeConfiguration }) { toolbarService, } = servicesManager.services; - toolbarService.reset(); toolGroupService.destroy(); }, validationTags: { diff --git a/modes/basic-test-mode/src/index.js b/modes/basic-test-mode/src/index.js index 129aa6834..a6b5b21a5 100644 --- a/modes/basic-test-mode/src/index.js +++ b/modes/basic-test-mode/src/index.js @@ -132,7 +132,6 @@ function modeFactory() { cornerstoneViewportService, } = servicesManager.services; - toolbarService.reset(); toolGroupService.destroy(); syncGroupService.destroy(); segmentationService.destroy(); diff --git a/modes/basic-test-mode/src/toolbarButtons.js b/modes/basic-test-mode/src/toolbarButtons.js index a8c8a8535..9a04581c5 100644 --- a/modes/basic-test-mode/src/toolbarButtons.js +++ b/modes/basic-test-mode/src/toolbarButtons.js @@ -297,10 +297,96 @@ const toolbarButtons = [ }, { id: 'Layout', - type: 'ohif.layoutSelector', + type: 'ohif.splitButton', props: { - rows: 3, - columns: 3, + groupId: 'LayoutTools', + isRadio: false, + primary: { + id: 'Layout', + type: 'action', + uiType: 'ohif.layoutSelector', + icon: 'tool-layout', + label: 'Grid Layout', + props: { + rows: 4, + columns: 4, + commands: [ + { + commandName: 'setLayout', + commandOptions: {}, + context: 'CORNERSTONE', + }, + ], + }, + }, + secondary: { + icon: 'chevron-down', + label: '', + isActive: true, + tooltip: 'Hanging Protocols', + }, + items: [ + { + id: '2x2', + type: 'action', + label: '2x2', + commands: [ + { + commandName: 'setHangingProtocol', + commandOptions: { + protocolId: '@ohif/hp-extension.mn', + stageId: '2x2', + }, + context: 'DEFAULT', + }, + ], + }, + { + id: '3x1', + type: 'action', + label: '3x1', + commands: [ + { + commandName: 'setHangingProtocol', + commandOptions: { + protocolId: '@ohif/hp-extension.mn', + stageId: '3x1', + }, + context: 'DEFAULT', + }, + ], + }, + { + id: '2x1', + type: 'action', + label: '2x1', + commands: [ + { + commandName: 'setHangingProtocol', + commandOptions: { + protocolId: '@ohif/hp-extension.mn', + stageId: '2x1', + }, + context: 'DEFAULT', + }, + ], + }, + { + id: '1x1', + type: 'action', + label: '1x1', + commands: [ + { + commandName: 'setHangingProtocol', + commandOptions: { + protocolId: '@ohif/hp-extension.mn', + stageId: '1x1', + }, + context: 'DEFAULT', + }, + ], + }, + ], }, }, { @@ -312,9 +398,11 @@ const toolbarButtons = [ label: 'MPR', commands: [ { - commandName: 'toggleMPR', - commandOptions: {}, - context: 'CORNERSTONE', + commandName: 'toggleHangingProtocol', + commandOptions: { + protocolId: 'mpr', + }, + context: 'DEFAULT', }, ], }, diff --git a/modes/longitudinal/src/index.js b/modes/longitudinal/src/index.js index ab6887420..6e2d8a2da 100644 --- a/modes/longitudinal/src/index.js +++ b/modes/longitudinal/src/index.js @@ -159,7 +159,6 @@ function modeFactory() { _activatePanelTriggersSubscriptions.forEach(sub => sub.unsubscribe()); _activatePanelTriggersSubscriptions = []; - toolbarService.reset(); toolGroupService.destroy(); syncGroupService.destroy(); segmentationService.destroy(); diff --git a/modes/longitudinal/src/toolbarButtons.js b/modes/longitudinal/src/toolbarButtons.js index 3addb750b..30b68a761 100644 --- a/modes/longitudinal/src/toolbarButtons.js +++ b/modes/longitudinal/src/toolbarButtons.js @@ -27,18 +27,6 @@ function _createButton(type, id, icon, label, commands, tooltip, uiType) { }; } -function _createCommands(commandName, toolName, toolGroupIds) { - return toolGroupIds.map(toolGroupId => ({ - /* It's a command that is being run when the button is clicked. */ - commandName, - commandOptions: { - toolName, - toolGroupId, - }, - context: 'CORNERSTONE', - })); -} - const _createActionButton = _createButton.bind(null, 'action'); const _createToggleButton = _createButton.bind(null, 'toggle'); const _createToolButton = _createButton.bind(null, 'tool'); @@ -312,9 +300,11 @@ const toolbarButtons = [ label: 'MPR', commands: [ { - commandName: 'toggleMPR', - commandOptions: {}, - context: 'CORNERSTONE', + commandName: 'toggleHangingProtocol', + commandOptions: { + protocolId: 'mpr', + }, + context: 'DEFAULT', }, ], }, @@ -330,8 +320,8 @@ const toolbarButtons = [ { commandName: 'setToolActive', commandOptions: { - toolGroupId: 'mpr', toolName: 'Crosshairs', + toolGroupId: 'mpr', }, context: 'CORNERSTONE', }, diff --git a/modes/tmtv/src/index.js b/modes/tmtv/src/index.js index d9a4e885a..c0f8659ae 100644 --- a/modes/tmtv/src/index.js +++ b/modes/tmtv/src/index.js @@ -136,13 +136,11 @@ function modeFactory({ modeConfiguration }) { const { toolGroupService, syncGroupService, - toolbarService, segmentationService, cornerstoneViewportService, } = servicesManager.services; unsubscriptions.forEach(unsubscribe => unsubscribe()); - toolbarService.reset(); toolGroupService.destroy(); syncGroupService.destroy(); segmentationService.destroy(); diff --git a/modes/tmtv/src/toolbarButtons.js b/modes/tmtv/src/toolbarButtons.js index 890ff0416..8fd347737 100644 --- a/modes/tmtv/src/toolbarButtons.js +++ b/modes/tmtv/src/toolbarButtons.js @@ -203,9 +203,11 @@ const toolbarButtons = [ label: 'MPR', commands: [ { - commandName: 'toggleMPR', - commandOptions: {}, - context: 'CORNERSTONE', + commandName: 'toggleHangingProtocol', + commandOptions: { + protocolId: 'mpr', + }, + context: 'DEFAULT', }, ], }, diff --git a/platform/core/src/classes/CommandsManager.ts b/platform/core/src/classes/CommandsManager.ts index 2ff961230..7525f4f7d 100644 --- a/platform/core/src/classes/CommandsManager.ts +++ b/platform/core/src/classes/CommandsManager.ts @@ -1,4 +1,5 @@ import log from '../log.js'; +import { Command, Commands } from '../types/Command'; /** * The definition of a command @@ -106,7 +107,7 @@ export class CommandsManager { * @param {String} [contextName] - Specific command to look in. Defaults to current activeContexts */ getCommand = (commandName, contextName) => { - let contexts = []; + const contexts = []; if (contextName) { const context = this.getContext(contextName); @@ -140,7 +141,7 @@ export class CommandsManager { * @param {Object} [options={}] - Extra options to pass the command. Like a mousedown event * @param {String} [contextName] */ - runCommand(commandName, options = {}, contextName) { + public runCommand(commandName: string, options = {}, contextName?: string) { const definition = this.getCommand(commandName, contextName); if (!definition) { log.warn(`Command "${commandName}" not found in current context`); @@ -161,6 +162,50 @@ export class CommandsManager { return commandFn(commandParams); } } + + /** + * Run one or more commands with specified extra options. + * Returns the result of the last command run. + * + * @param toRun - A specification of one or more commands + * @param options - to include in the commands run beyond + * the commandOptions specified in the base. + */ + public run( + toRun: Command | Commands | Command[] | undefined, + options?: Record + ): unknown { + if (!toRun) return; + const commands = + (Array.isArray(toRun) && toRun) || + ((toRun as Command).commandName && [toRun]) || + (Array.isArray((toRun as Commands).commands) && + (toRun as Commands).commands); + if (!commands) { + console.log("Command isn't runnable", toRun); + return; + } + + let result; + (commands as Command[]).forEach( + ({ commandName, commandOptions, context }) => { + if (commandName) { + result = this.runCommand( + commandName, + { + ...commandOptions, + ...options, + }, + context + ); + } else { + console.warn('No command name supplied in', toRun); + } + } + ); + + return result; + } } export default CommandsManager; diff --git a/platform/core/src/defaults/hotkeyBindings.js b/platform/core/src/defaults/hotkeyBindings.js index cd38c43c5..75023247c 100644 --- a/platform/core/src/defaults/hotkeyBindings.js +++ b/platform/core/src/defaults/hotkeyBindings.js @@ -88,6 +88,20 @@ const bindings = [ // keys: ['pagedown'], // isEditable: true, // }, + { + commandName: 'nextStage', + context: 'DEFAULT', + label: 'Next Stage', + keys: ['.'], + isEditable: true, + }, + { + commandName: 'previousStage', + context: 'DEFAULT', + label: 'Previous Stage', + keys: [','], + isEditable: true, + }, { commandName: 'nextImage', label: 'Next Image', diff --git a/platform/core/src/extensions/MODULE_TYPES.js b/platform/core/src/extensions/MODULE_TYPES.js index c96c3f1fb..8260f77a1 100644 --- a/platform/core/src/extensions/MODULE_TYPES.js +++ b/platform/core/src/extensions/MODULE_TYPES.js @@ -1,6 +1,7 @@ export default { COMMANDS: 'commandsModule', CUSTOMIZATION: 'customizationModule', + STATE_SYNC: 'stateSyncModule', DATA_SOURCE: 'dataSourcesModule', PANEL: 'panelModule', SOP_CLASS_HANDLER: 'sopClassHandlerModule', diff --git a/platform/core/src/index.test.js b/platform/core/src/index.test.js index 3c058f961..3a62a5141 100644 --- a/platform/core/src/index.test.js +++ b/platform/core/src/index.test.js @@ -25,6 +25,7 @@ describe('Top level exports', () => { // 'CineService', 'CustomizationService', + 'StateSyncService', 'UIDialogService', 'UIModalService', 'UINotificationService', diff --git a/platform/core/src/index.ts b/platform/core/src/index.ts index 5732d33f3..458c7a3ab 100644 --- a/platform/core/src/index.ts +++ b/platform/core/src/index.ts @@ -29,6 +29,7 @@ import { PubSubService, UserAuthenticationService, CustomizationService, + StateSyncService, PanelService, } from './services'; @@ -61,6 +62,7 @@ const OHIF = { // CineService, CustomizationService, + StateSyncService, UIDialogService, UIModalService, UINotificationService, @@ -99,6 +101,7 @@ export { // CineService, CustomizationService, + StateSyncService, UIDialogService, UIModalService, UINotificationService, diff --git a/platform/core/src/services/CustomizationService/CustomizationService.ts b/platform/core/src/services/CustomizationService/CustomizationService.ts index a133cfdb5..1b3442742 100644 --- a/platform/core/src/services/CustomizationService/CustomizationService.ts +++ b/platform/core/src/services/CustomizationService/CustomizationService.ts @@ -63,7 +63,7 @@ export default class CustomizationService extends PubSubService { modeCustomizations: Record = {}; globalCustomizations: Record = {}; - configuration: UICustomizationConfiguration; + configuration: CustomizationConfiguration; constructor({ configuration, commandsManager }) { super(EVENTS); @@ -97,36 +97,6 @@ export default class CustomizationService extends PubSubService { this.modeCustomizations = {}; } - /** - * - * @param {*} interaction - can be undefined to run nothing - * @param {*} extraOptions to include in the commands run - */ - recordInteraction( - interaction: Customization | void, - extraOptions?: Record - ): void { - if (!interaction) return; - const commandsManager = this.commandsManager; - const { commands = [] } = interaction; - - commands.forEach(({ commandName, commandOptions, context }) => { - if (commandName) { - commandsManager.runCommand( - commandName, - { - interaction, - ...commandOptions, - ...extraOptions, - }, - context - ); - } else { - console.warn('No command name supplied in', interaction); - } - }); - } - public getModeCustomizations(): Record { return this.modeCustomizations; } @@ -145,6 +115,23 @@ export default class CustomizationService extends PubSubService { }); } + /** This is the preferred getter for all customizations, + * getting mode customizations first and otherwise global customizations. + * + * @param customizationId - the customization id to look for + * @param defaultValue - is the default value to return. Note this value + * may have been extended with any customizationType extensions provided, + * so you cannot just use `|| defaultValue` + * @return A customization to use if one is found, or the default customization, + * both enhanced with any customizationType inheritance (see applyType) + */ + public getCustomization( + customizationId: string, + defaultValue?: Customization + ): Customization | void { + return this.getModeCustomization(customizationId, defaultValue); + } + /** Mode customizations are changes to the behaviour of the extensions * when running in a given mode. Reset clears mode customizations. * Note that global customizations over-ride mode customizations. @@ -168,12 +155,17 @@ export default class CustomizationService extends PubSubService { ); } - /** Applies any inheritance due to UI Type customization */ + /** + * Applies any inheritance due to UI Type customization. + * This will look for customizationType in the customization object + * and if that is found, will assign all iterable values from that + * type into the new type, allowing default behaviour to be configured. + */ public applyType(customization: Customization): Customization { if (!customization) return customization; const { customizationType } = customization; if (!customizationType) return customization; - const parent = this.getModeCustomization(customizationType); + const parent = this.getCustomization(customizationType); return parent ? Object.assign(Object.create(parent), customization) : customization; diff --git a/platform/core/src/services/HangingProtocolService/HPMatcher.js b/platform/core/src/services/HangingProtocolService/HPMatcher.js index 854dab807..a369f2460 100644 --- a/platform/core/src/services/HangingProtocolService/HPMatcher.js +++ b/platform/core/src/services/HangingProtocolService/HPMatcher.js @@ -29,27 +29,31 @@ const match = ( let requiredFailed = false; let score = 0; + // Allow for matching against current or prior specifically + const prior = options?.studies?.[1]; + const current = options?.studies?.[0]; + const instance = (metadataInstance.images || metadataInstance.others)?.[0]; + const fromSrc = { + prior, + current, + instance, + ...options, + options, + metadataInstance, + }; + rules.forEach(rule => { - const { attribute } = rule; + const { attribute, from = 'metadataInstance' } = rule; // Do not use the custom attribute from the metadataInstance since it is subject to change if (customAttributeRetrievalCallbacks.hasOwnProperty(attribute)) { readValues[attribute] = customAttributeRetrievalCallbacks[ attribute - ].callback(metadataInstance, options); + ].callback.call(rule, metadataInstance, options); } else { readValues[attribute] = - metadataInstance[attribute] ?? - ((metadataInstance.images || metadataInstance.others || [])[0] || {})[ - attribute - ]; + fromSrc[from]?.[attribute] ?? instance?.[attribute]; } - console.log( - 'Test', - attribute, - readValues[attribute], - JSON.stringify(rule.constraint) - ); // Format the constraint as required by Validate.js const testConstraint = { [attribute]: rule.constraint, @@ -70,6 +74,14 @@ const match = ( errorMessages = ['Something went wrong during validation.', e]; } + console.log( + 'Test', + `${from}.${attribute}`, + readValues[attribute], + JSON.stringify(rule.constraint), + !errorMessages + ); + if (!errorMessages) { // If no errorMessages were returned, then validation passed. diff --git a/platform/core/src/services/HangingProtocolService/HangingProtocolService.test.js b/platform/core/src/services/HangingProtocolService/HangingProtocolService.test.js index 19fbf8906..1af19950b 100644 --- a/platform/core/src/services/HangingProtocolService/HangingProtocolService.test.js +++ b/platform/core/src/services/HangingProtocolService/HangingProtocolService.test.js @@ -117,8 +117,7 @@ const studyMatchDisplaySets = [displaySet3, displaySet2, displaySet1]; function checkHpsBestMatch(hps) { hps.run({ studies: [studyMatch], displaySets: studyMatchDisplaySets }); - const { hpAlreadyApplied, viewportMatchDetails } = hps.getMatchDetails(); - expect(hpAlreadyApplied).toMatchObject(new Map([[0, false]])); + const { viewportMatchDetails } = hps.getMatchDetails(); expect(viewportMatchDetails.size).toBe(1); expect(viewportMatchDetails.get(0)).toMatchObject({ viewportOptions: { @@ -131,9 +130,11 @@ function checkHpsBestMatch(hps) { // ds2 fails to match required and ds3 fails to match an optional. displaySetsInfo: [ { - SeriesInstanceUID: 'ds1', displaySetInstanceUID: 'displaySet1', - displaySetOptions: {}, + displaySetOptions: { + id: 'displaySetSelector', + options: {}, + }, }, ], }); @@ -191,14 +192,6 @@ describe('HangingProtocolService', () => { it('matches best image match', () => { checkHpsBestMatch(hangingProtocolService); }); - - it('uses services manager', () => { - hangingProtocolService.run({ - studies: [studyMatch], - displaySets: studyMatchDisplaySets, - }); - expect(mockedFunction).toHaveBeenCalledTimes(1); }); }); }); -}); diff --git a/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts b/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts index f85ea5905..01e8b0e0f 100644 --- a/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts +++ b/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts @@ -1,24 +1,37 @@ -import uuidv4 from '../../utils/uuidv4'; -import pubSubServiceInterface from '../_shared/pubSubServiceInterface'; +import { PubSubService } from '../_shared/pubSubServiceInterface'; import sortBy from '../../utils/sortBy'; import ProtocolEngine from './ProtocolEngine'; -import StudyMetadata from '../../types/StudyMetadata'; +import { StudyMetadata } from '../../types/StudyMetadata'; import IDisplaySet from '../DisplaySetService/IDisplaySet'; -import { HangingProtocol } from '../../types'; - -const EVENTS = { - STAGE_CHANGE: 'event::hanging_protocol_stage_change', - PROTOCOL_CHANGED: 'event::hanging_protocol_changed', - NEW_LAYOUT: 'event::hanging_protocol_new_layout', - CUSTOM_IMAGE_LOAD_PERFORMED: - 'event::hanging_protocol_custom_image_load_performed', - HANGING_PROTOCOL_APPLIED_FOR_VIEWPORT: - 'event::hanging_protocol_applied_for_viewport', -}; +import { CommandsManager } from '../../classes'; +import ServicesManager from '../ServicesManager'; +import * as HangingProtocol from '../../types/HangingProtocol'; type Protocol = HangingProtocol.Protocol | HangingProtocol.ProtocolGenerator; -class HangingProtocolService { +const DEFAULT_VIEWPORT_OPTIONS: HangingProtocol.ViewportOptions = { + toolGroupId: 'default', + viewportType: 'stack', +}; + +export default class HangingProtocolService extends PubSubService { + static EVENTS = { + // The PROTOCOL_CHANGED event is fired when the protocol changes + // and should be immediately applied + PROTOCOL_CHANGED: 'event::hanging_protocol_changed', + // The PROTOCOL_RESTORED event is fired instead of a changed event to indicate + // that an earlier state has been restored as part of a state update, but + // is not being directly re-applied, but just restored. + PROTOCOL_RESTORED: 'event::hanging_protocol_restore', + // The layout has been decided for the hanging protocol - deprecated + NEW_LAYOUT: 'event::hanging_protocol_new_layout', + // Fired when the stages within the current protocol are known to have + // the status set - that is, they are activated (or deactivated). + STAGE_ACTIVATION: 'event::hanging_protocol_stage_activation', + CUSTOM_IMAGE_LOAD_PERFORMED: + 'event::hanging_protocol_custom_image_load_performed', + }; + public static REGISTRATION = { name: 'hangingProtocolService', altName: 'HangingProtocolService', @@ -34,15 +47,14 @@ class HangingProtocolService { activeProtocolIds: string[]; // the current protocol that is being applied to the viewports in object format protocol: HangingProtocol.Protocol; - stage: number; - _commandsManager: Record; - _servicesManager: Record; + stageIndex = 0; + _commandsManager: CommandsManager; + _servicesManager: ServicesManager; protocolEngine: ProtocolEngine; customViewportSettings = []; displaySets: IDisplaySet[] = []; - activeStudy: Record; + activeStudy: StudyMetadata; debugLogging: false; - EVENTS: { [key: string]: string }; customAttributeRetrievalCallbacks = { NumberOfStudyRelatedSeries: { @@ -75,12 +87,6 @@ class HangingProtocolService { activeImageLoadStrategyName = null; customImageLoadPerformed = false; - /** - * Whether the hanging protocol is applied for the given viewport index, - * applying means that the displayset(s) is set for the viewport to be shown - */ - hpAlreadyApplied: Map = new Map(); - /** * displaySetMatchDetails = * DisplaySetId is the id defined in the hangingProtocol object itself @@ -100,57 +106,90 @@ class HangingProtocolService { HangingProtocol.ViewportMatchDetails > = new Map(); - constructor(commandsManager, servicesManager) { + constructor(commandsManager: CommandsManager, servicesManager) { + super(HangingProtocolService.EVENTS); this._commandsManager = commandsManager; this._servicesManager = servicesManager; this.protocols = new Map(); this.protocolEngine = undefined; this.protocol = undefined; - this.stage = undefined; + this.stageIndex = undefined; this.studies = []; - Object.defineProperty(this, 'EVENTS', { - value: EVENTS, - writable: false, - enumerable: true, - configurable: false, - }); - Object.assign(this, pubSubServiceInterface); } - public destroy() { + public destroy(): void { this.reset(); this.protocols = new Map(); } - public reset() { + public reset(): void { this.studies = []; - this.hpAlreadyApplied = new Map(); this.viewportMatchDetails = new Map(); this.displaySetMatchDetails = new Map(); } /** Leave the hanging protocol in the initialized state */ - public onModeExit() { + public onModeEnter(): void { this.reset(); } + /** + * Gets the active protocol information directly, including the direct + * protocol, stage and active study objects. + * Should NOT be stored longer term as the protocol + * object can change internally or be regenerated. + * Can be used to store the state to recover from exceptions. + * + * @returns protocol, stage, activeStudy + */ public getActiveProtocol(): { protocol: HangingProtocol.Protocol; - stage: number; + stage: HangingProtocol.ProtocolStage; + stageIndex: number; + activeStudy?: StudyMetadata; + viewportMatchDetails: Map; + displaySetMatchDetails: Map; + activeImageLoadStrategyName: string; } { - return { protocol: this.protocol, stage: this.stage }; + return { + protocol: this.protocol, + stage: this.protocol?.stages?.[this.stageIndex], + stageIndex: this.stageIndex, + activeStudy: this.activeStudy, + viewportMatchDetails: this.viewportMatchDetails, + displaySetMatchDetails: this.displaySetMatchDetails, + activeImageLoadStrategyName: this.activeImageLoadStrategyName, + }; } + /** Gets the hanging protocol state information, which is a storable + * state information for the hanging protocol consisting of the: + * protocolId, stageIndex, stageId and activeStudyUID + */ + public getState(): HangingProtocol.HPInfo { + if (!this.protocol) return; + return { + protocolId: this.protocol.id, + stageIndex: this.stageIndex, + stageId: this.protocol.stages[this.stageIndex].id, + activeStudyUID: this.activeStudy?.StudyInstanceUID, + }; + } + + /** Gets the protocol with id 'default' */ public getDefaultProtocol(): HangingProtocol.Protocol { return this.getProtocolById('default'); } + /** Gets the viewport match details. + * @deprecated because this method is expected to go away as the HP service + * becomes more stateless. + */ public getMatchDetails(): HangingProtocol.HangingProtocolMatchDetails { return { viewportMatchDetails: this.viewportMatchDetails, displaySetMatchDetails: this.displaySetMatchDetails, - hpAlreadyApplied: this.hpAlreadyApplied, }; } @@ -185,13 +224,14 @@ class HangingProtocolService { * @param protocolId - the id of the protocol * @returns protocol - the protocol with the given id */ - public getProtocolById(id: string): HangingProtocol.Protocol | undefined { - if (!id) { - return; + public getProtocolById(protocolId: string): HangingProtocol.Protocol { + if (!protocolId) return; + if (protocolId === this.protocol?.id) return this.protocol; + const protocol = this.protocols.get(protocolId); + if (!protocol) { + throw new Error(`No protocol ${protocolId} found`); } - const protocol = this.protocols.get(id); - if (protocol instanceof Function) { try { const { protocol: generatedProtocol } = this._getProtocolFromGenerator( @@ -201,7 +241,7 @@ class HangingProtocolService { return generatedProtocol; } catch (error) { console.warn( - `Error while executing protocol generator for protocol ${id}: ${error}` + `Error while executing protocol generator for protocol ${protocolId}: ${error}` ); } } else { @@ -264,23 +304,40 @@ class HangingProtocolService { this.activeProtocolIds = [...protocolId]; } + /** + * Sets the active study. + * This is the study that the hanging protocol will consider active and + * may or may not be the study that is being shown by the protocol currently, + * for example, a prior view hanging protocol will NOT show the active study + * specifically, but will show another study instead. + */ + public setActiveStudyUID(activeStudyUID: string): void { + this.activeStudy = this.studies.find( + it => it.StudyInstanceUID === activeStudyUID + ); + } + /** * Run the hanging protocol decisions tree on the active study, - * studies list and display sets, firing a hanging protocol event when - * complete to indicate the hanging protocol is ready. + * studies list and display sets, firing a PROTOCOL_CHANGED event when + * complete to indicate the hanging protocol is ready, and which stage + * got applied/activated. + * + * Also fires a STAGES_ACTIVE event to indicate which stages are able to be + * activated. * * @param params is the dataset to run the hanging protocol on. * @param params.activeStudy is the "primary" study to hang This may or may * not be displayed by the actual viewports. - * @param params.studies is the list of studies to hang + * @param params.studies is the list of studies to hang. If absent, will re-use the previous set. * @param params.displaySets is the list of display sets associated with * the studies to display in viewports. * @param protocol is a specific protocol to apply. */ public run({ studies, displaySets, activeStudy }, protocolId) { - this.studies = [...studies]; + this.studies = [...(studies || this.studies)]; this.displaySets = displaySets; - this.activeStudy = activeStudy || studies[0]; + this.setActiveStudyUID((activeStudy || studies[0])?.StudyInstanceUID); this.protocolEngine = new ProtocolEngine( this.getProtocols(), @@ -330,23 +387,6 @@ class HangingProtocolService { } } - setHangingProtocolAppliedForViewport(i, status, suppressEvent = false) { - this.hpAlreadyApplied.set(i, status); - - const numberOfViewports = this.viewportMatchDetails.size; - const numberOfViewportsApplied = Array.from( - this.hpAlreadyApplied.values() - ).filter(applied => applied).length; - - const progress = Math.round( - (numberOfViewportsApplied / numberOfViewports) * 100 - ); - - this._broadcastChange(this.EVENTS.HANGING_PROTOCOL_APPLIED_FOR_VIEWPORT, { - progress: progress, - }); - } - /** * Adds a custom attribute to be used in the HangingProtocol UI and matching rules, including a * callback that will be used to calculate the attribute value. @@ -359,8 +399,11 @@ class HangingProtocolService { public addCustomAttribute( attributeId: string, attributeName: string, - callback: (metadata: any) => any, - options: Record = {} + callback: ( + metadata: Record, + extraData?: Record + ) => unknown, + options: Record = {} ): void { this.customAttributeRetrievalCallbacks[attributeId] = { ...options, @@ -370,33 +413,11 @@ class HangingProtocolService { }; } - /** - * Switches to the next protocol stage in the display set sequence - */ - public nextProtocolStage(): void { - console.log('ProtocolEngine::nextProtocolStage'); - - if (!this._setCurrentProtocolStage(1)) { - console.log('ProtocolEngine::nextProtocolStage failed'); - } - } - - /** - * Switches to the previous protocol stage in the display set sequence - */ - public previousProtocolStage(): void { - console.log('ProtocolEngine::previousProtocolStage'); - - if (!this._setCurrentProtocolStage(-1)) { - console.log('ProtocolEngine::previousProtocolStage failed'); - } - } - /** * Executes the callback function for the custom loading strategy for the images * if no strategy is set, the default strategy is used */ - runImageLoadStrategy(data): void { + runImageLoadStrategy(data): boolean { const loader = this.registeredImageLoadStrategies[ this.activeImageLoadStrategyName ]; @@ -409,11 +430,13 @@ class HangingProtocolService { // if loader successfully re-arranged the data with the custom strategy // and returned the new props, then broadcast them if (!loadedData) { - return; + console.warn('Not able to load data with custom strategy'); + return false; } this.customImageLoadPerformed = true; - this._broadcastChange(this.EVENTS.CUSTOM_IMAGE_LOAD_PERFORMED, loadedData); + this._broadcastEvent(this.EVENTS.CUSTOM_IMAGE_LOAD_PERFORMED, loadedData); + return true; } _validateProtocol( @@ -429,9 +452,25 @@ class HangingProtocolService { protocol.name = protocol.name || protocol.id; const { stages } = protocol; + if (!stages) { + console.warn('Protocol has not stages:', protocol.id, protocol); + return; + } + + for (const id of Object.keys(protocol.displaySetSelectors)) { + const selector = protocol.displaySetSelectors[id]; + selector.id = id; + const { seriesMatchingRules } = selector; + if (!seriesMatchingRules) { + console.warn('Selector has no series matching rules', protocol.id, id); + return; + } + } + // Generate viewports automatically as required. stages.forEach(stage => { if (!stage.viewports) { + stage.name = stage.name || stage.id; stage.viewports = []; const { rows, columns } = stage.viewportStructure.properties; @@ -479,7 +518,7 @@ class HangingProtocolService { getViewportsRequireUpdate(viewportIndex, displaySetInstanceUID) { const newDisplaySetInstanceUID = displaySetInstanceUID; const protocol = this.protocol; - const protocolStage = protocol.stages[this.stage]; + const protocolStage = protocol.stages[this.stageIndex]; const protocolViewports = protocolStage.viewports; const protocolViewport = protocolViewports[viewportIndex]; @@ -518,7 +557,10 @@ class HangingProtocolService { // if the viewport is not empty, then we check the displaySets it is showing // currently, which means we need to check if the requested updated displaySet // follow the same rules as the current displaySets - const displaySetSelectorId = protocolViewport.displaySets[0].id; + const { + id: displaySetSelectorId, + matchedDisplaySetsIndex = 0, + } = protocolViewport.displaySets[0]; const displaySetSelector = protocol.displaySetSelectors[displaySetSelectorId]; @@ -558,7 +600,10 @@ class HangingProtocolService { protocolViewports.forEach((viewport, index) => { let viewportNeedsUpdate; for (const displaySet of viewport.displaySets) { - if (displaySet.id === displaySetSelectorId) { + if ( + displaySet.id === displaySetSelectorId && + (displaySet.matchedDisplaySetsIndex || 0) === matchedDisplaySetsIndex + ) { viewportNeedsUpdate = true; break; } @@ -573,7 +618,7 @@ class HangingProtocolService { displaySetOptions, } = viewport.displaySets.reduce( (acc, displaySet) => { - const { id, options } = displaySet; + const { id } = displaySet; let { displaySetInstanceUID: displaySetInstanceUIDToUse, @@ -584,7 +629,7 @@ class HangingProtocolService { } acc.displaySetInstanceUIDs.push(displaySetInstanceUIDToUse); - acc.displaySetOptions.push(options); + acc.displaySetOptions.push(displaySet); return acc; }, @@ -630,16 +675,16 @@ class HangingProtocolService { return; } - const protocol = this._validateProtocol(foundProtocol); - - if (options) { - this._validateOptions(options); - } - try { + const protocol = this._validateProtocol(foundProtocol); + + if (options) { + this._validateOptions(options); + } + this._setProtocol(protocol, options); } catch (error) { - console.error(error); + console.log(error); if (errorCallback) { errorCallback(error); @@ -649,38 +694,171 @@ class HangingProtocolService { } } + protected matchActivation( + matchedViewports: number, + activation: HangingProtocol.StageActivation = {}, + minViewportsMatched: number + ): boolean { + const { displaySetSelectors } = this.protocol; + + const { displaySetSelectorsMatched = [] } = activation; + for (const dsName of displaySetSelectorsMatched) { + const displaySetSelector = displaySetSelectors[dsName]; + if (!displaySetSelector) { + console.warn('No display set selector for', dsName); + return false; + } + const { bestMatch } = this._matchImages(displaySetSelector); + if (!bestMatch) { + return false; + } + } + const min = activation.minViewportsMatched ?? minViewportsMatched; + + return matchedViewports >= min; + } + /** + * Updates the stage activation, setting the stageActivation values to + * 'disabled', 'active', 'passive' where: + * * disabled means there are insufficient viewports filled to show this + * * passive means there aren't enough preferred viewports filled to show + * this stage by default, but it can be manually selected + * * enabled means there are enough viewports to select this viewport by default + * + * The logic is currently simple, just count how many viewports would be + * filled, and compare to the required/preferred count, but the intent is + * to allow more complex rules in the future as required. + * + * @returns the stage number to apply initially, given the options. + */ + private _updateStageStatus( + options = null as HangingProtocol.SetProtocolOptions + ) { + const stages = this.protocol.stages; + for (let i = 0; i < stages.length; i++) { + const stage = stages[i]; + + const { matchedViewports } = this._matchAllViewports( + stage, + options, + new Map() + ); + const activation = stage.stageActivation || {}; + if (this.matchActivation(matchedViewports, activation.passive, 0)) { + if (this.matchActivation(matchedViewports, activation.enabled, 1)) { + stage.status = 'enabled'; + } else { + stage.status = 'passive'; + } + } else { + stage.status = 'disabled'; + } + } + + this._broadcastEvent(this.EVENTS.STAGE_ACTIVATION, { + protocol: this.protocol, + stages: this.protocol.stages, + }); + } + + private _findStageIndex( + options = null as HangingProtocol.SetProtocolOptions + ): number | void { + const stageId = options?.stageId; + const protocol = this.protocol; + const stages = protocol.stages; + + if (stageId) { + for (let i = 0; i < stages.length; i++) { + const stage = stages[i]; + if (stage.id === stageId && stage.status !== 'disabled') return i; + } + return; + } + + const stageIndex = options?.stageIndex; + if (stageIndex !== undefined) { + return stages[stageIndex]?.status !== 'disabled' ? stageIndex : undefined; + } + + let firstNotDisabled: number; + + for (let i = 0; i < stages.length; i++) { + if (stages[i].status === 'enabled') return i; + if (firstNotDisabled === undefined && stages[i].status !== 'disabled') { + firstNotDisabled = i; + } + } + + return firstNotDisabled; + } + private _setProtocol( protocol: HangingProtocol.Protocol, options = null as HangingProtocol.SetProtocolOptions ): void { - this.stage = 0; - const oldProtocol = this.protocol; - this.protocol = this._copyProtocol(protocol); - - const { imageLoadStrategy } = protocol; - if (imageLoadStrategy) { - // check if the imageLoadStrategy is a valid strategy - if ( - this.registeredImageLoadStrategies[imageLoadStrategy] instanceof - Function - ) { - this.activeImageLoadStrategyName = imageLoadStrategy; - } - } + const old = this.getActiveProtocol(); try { + if (!this.protocol || this.protocol.id !== protocol.id) { + this.stageIndex = options?.stageIndex || 0; + this.protocol = this._copyProtocol(protocol); + + const { imageLoadStrategy } = protocol; + if (imageLoadStrategy) { + // check if the imageLoadStrategy is a valid strategy + if ( + this.registeredImageLoadStrategies[imageLoadStrategy] instanceof + Function + ) { + this.activeImageLoadStrategyName = imageLoadStrategy; + } + } + + this._updateStageStatus(options); + } + + const stage = this._findStageIndex(options); + if (stage === undefined) { + throw new Error( + `Can't find applicable stage ${protocol.id} ${options?.stageIndex}` + ); + } + this.stageIndex = stage as number; this._updateViewports(options); } catch (error) { - this.protocol = oldProtocol; + console.log(error); + Object.assign(this, old); throw new Error(error); } - this._broadcastChange(this.EVENTS.PROTOCOL_CHANGED, { - viewportMatchDetails: this.viewportMatchDetails, - displaySetMatchDetails: this.displaySetMatchDetails, - hpAlreadyApplied: this.hpAlreadyApplied, - protocol: this.protocol, - }); + if (options?.restoreProtocol !== true) { + this._broadcastEvent(HangingProtocolService.EVENTS.PROTOCOL_CHANGED, { + viewportMatchDetails: this.viewportMatchDetails, + displaySetMatchDetails: this.displaySetMatchDetails, + protocol: this.protocol, + stageIdx: this.stageIndex, + stage: this.protocol.stages[this.stageIndex], + activeStudyUID: this.activeStudy?.StudyInstanceUID, + }); + } else { + this._broadcastEvent(HangingProtocolService.EVENTS.PROTOCOL_RESTORED, { + protocol: this.protocol, + stageIdx: this.stageIndex, + stage: this.protocol.stages[this.stageIndex], + activeStudyUID: this.activeStudy?.StudyInstanceUID, + }); + } + } + + public getStageIndex(protocolId: string, options): number { + const protocol = this.getProtocolById(protocolId); + const { stageId, stageIndex } = options; + if (stageId !== undefined) { + return protocol.stages.findIndex(it => it.id === stageId); + } + if (stageIndex !== undefined) return stageIndex; + return 0; } /** @@ -705,7 +883,35 @@ class HangingProtocolService { * @returns {*} The Stage model for the currently displayed Stage */ _getCurrentStageModel() { - return this.protocol.stages[this.stage]; + return this.protocol.stages[this.stageIndex]; + } + + /** + * Gets a new viewport object for missing viewports. Used to fill + * new viewports. + * Looks first for the stage, to see if there is a missingViewport defined, + * and secondly looks to the overall protocol. + * + * Returns a matchInfo object, which can be used to create the actual + * viewport object (which this class knows nothing about). + */ + public getMissingViewport( + protocolId: string, + stageIdx: number, + options + ): HangingProtocol.ViewportMatchDetails { + if (this.protocol.id !== protocolId) { + throw new Error( + `Currently applied protocol ${this.protocol.id} is different from ${protocolId}` + ); + } + const protocol = this.protocol; + const stage = protocol.stages[stageIdx]; + const defaultViewport = stage.defaultViewport || protocol.defaultViewport; + if (!defaultViewport) return; + + const useViewport = { ...defaultViewport }; + return this._matchViewport(useViewport, options); } /** @@ -719,35 +925,10 @@ class HangingProtocolService { // each time we are updating the viewports, we need to reset the // matching applied - // Todo: we can have more intelligent invalidation of the hpAlreadyApplied - // since sometimes we are just updating some viewports and the rest are - // already applied (e.g. when we are using the drag and drop) - this.hpAlreadyApplied = new Map(); this.viewportMatchDetails = new Map(); this.displaySetMatchDetails = new Map(); this.customImageLoadPerformed = false; - if (options) { - // if the options are defined, we can fill in the displaySetMatchDetails - // but we need to also check that any displaySetInstanceUIDs that are - // provided either at viewport level or at the protocol level SATISFIES - // the required seriesMatching criteria species in the protocol. Otherwise - // we need to throw an error. This way protocols become more strict and - // for instance don't allow drag and drop of displaySets that don't match - // (in MPR protocol we specify in the displaysetSelector that the displaySet - // to be used should be reconstructable. Or you can specify that the displaySet - // should be a localizer only for a protocol) - - // options can be either an object with { displaySetInstanceUIDs, viewportOptions, displaySetOptions } - // options (global options), or an object of objects with viewportIndex - // as the key and the { displaySetInstanceUIDs, viewportOptions, displaySetOptions } as the value - - // The following function will update the displaySetMatchDetails in place - this._updateMatchByOptions(this.protocol, options); - } - - const { displaySetSelectors = {} } = this.protocol; - // Retrieve the current stage const stageModel = this._getCurrentStageModel(); @@ -774,234 +955,195 @@ class HangingProtocolService { const { columns: numCols, rows: numRows, layoutOptions = [] } = layoutProps; - this._broadcastChange(this.EVENTS.NEW_LAYOUT, { + this._broadcastEvent(this.EVENTS.NEW_LAYOUT, { layoutType, numRows, numCols, layoutOptions, }); - // Matching the displaySets - for ( - let viewportIndex = 0; - viewportIndex < numCols * numRows; - viewportIndex++ - ) { - if (viewportIndex >= stageModel.viewports.length) { - // If we have more viewports than display sets, stop here. - break; + // Loop through each viewport + this._matchAllViewports(this.protocol.stages[this.stageIndex], options); + } + + private _matchAllViewports( + stageModel: HangingProtocol.ProtocolStage, + options?: HangingProtocol.SetProtocolOptions, + viewportMatchDetails = this.viewportMatchDetails, + displaySetMatchDetails = this.displaySetMatchDetails + ): { + matchedViewports: number; + viewportMatchDetails: Map; + displaySetMatchDetails: Map; + } { + let matchedViewports = 0; + stageModel.viewports.forEach((viewport, viewportIndex) => { + const matchDetails = this._matchViewport( + viewport, + options, + viewportMatchDetails, + displaySetMatchDetails + ); + if (matchDetails) { + if ( + matchDetails.displaySetsInfo?.length && + matchDetails.displaySetsInfo[0].displaySetInstanceUID + ) { + matchedViewports++; + } else { + console.log( + 'Adding an empty set of display sets for mapping purposes' + ); + matchDetails.displaySetsInfo = viewport.displaySets.map(it => ({ + displaySetOptions: it, + })); + } + viewportMatchDetails.set(viewportIndex, matchDetails); } + }); + return { matchedViewports, viewportMatchDetails, displaySetMatchDetails }; + } - const viewport = stageModel.viewports[viewportIndex]; - - for (const displaySet of viewport.displaySets) { - const { id: displaySetId } = displaySet; - // skip matching if already matched (e.g. by options above) - if (this.displaySetMatchDetails.has(displaySetId)) { - continue; + protected findDeduplicatedMatchDetails( + matchDetails: HangingProtocol.DisplaySetMatchDetails, + offset: number, + options: HangingProtocol.SetProtocolOptions = {} + ): HangingProtocol.DisplaySetMatchDetails { + if (!matchDetails) return; + if (offset === 0) return matchDetails; + if (offset === -1) { + const { inDisplay } = options; + if (!inDisplay) return matchDetails; + for (let i = 0; i < matchDetails.matchingScores.length; i++) { + if ( + inDisplay.indexOf( + matchDetails.matchingScores[i].displaySetInstanceUID + ) === -1 + ) { + const match = matchDetails.matchingScores[i]; + return match.matchingScore > 0 + ? matchDetails.matchingScores[i] + : null; } - const displaySetSelector = displaySetSelectors[displaySetId]; + } + return; + } + return matchDetails.matchingScores[offset]; + } - if (!displaySetSelector) { - console.warn('No display set selector for', displaySetId); - continue; - } - const { bestMatch, matchingScores } = this._matchImages( - displaySetSelector - ); - this.displaySetMatchDetails.set(displaySetId, bestMatch); + protected validateDisplaySetSelectMatch( + match: HangingProtocol.DisplaySetMatchDetails, + id: string, + displaySetUID: string + ): void { + if (match.displaySetInstanceUID === displaySetUID) return; + for (const subMatch of match.matchingScores) { + if (subMatch.displaySetInstanceUID === displaySetUID) return; + } + throw new Error( + `Reused viewport details ${id} with ds ${displaySetUID} not valid` + ); + } - if (bestMatch) { - bestMatch.matchingScores = matchingScores; - } + protected _matchViewport( + viewport: HangingProtocol.Viewport, + options: HangingProtocol.SetProtocolOptions, + viewportMatchDetails = this.viewportMatchDetails, + displaySetMatchDetails = this.displaySetMatchDetails + ): HangingProtocol.ViewportMatchDetails { + const displaySetSelectorMap = options?.displaySetSelectorMap || {}; + const { displaySetSelectors = {} } = this.protocol; + + // Matching the displaySets + for (const displaySet of viewport.displaySets) { + const { id: displaySetId } = displaySet; + + const displaySetSelector = displaySetSelectors[displaySetId]; + + if (!displaySetSelector) { + console.warn('No display set selector for', displaySetId); + continue; + } + const { bestMatch, matchingScores } = this._matchImages( + displaySetSelector + ); + displaySetMatchDetails.set(displaySetId, bestMatch); + + if (bestMatch) { + bestMatch.matchingScores = matchingScores; } } // Loop through each viewport - stageModel.viewports.forEach((viewport, viewportIndex) => { - const { viewportOptions = {} } = viewport; - this.hpAlreadyApplied.set(viewportIndex, false); - // DisplaySets for the viewport, Note: this is not the actual displaySet, - // but it is a info to locate the displaySet from the displaySetService - const displaySetsInfo = []; - viewport.displaySets.forEach( - ({ id, displaySetIndex = 0, options: displaySetOptions }) => { - const viewportDisplaySetMain = this.displaySetMatchDetails.get(id); - // Use the display set index to allow getting the "next" match, eg - // matching all display sets, and get the displaySetIndex'th item - const viewportDisplaySet = - !viewportDisplaySetMain || displaySetIndex === 0 - ? viewportDisplaySetMain - : viewportDisplaySetMain.matchingScores[displaySetIndex]; + const { viewportOptions = DEFAULT_VIEWPORT_OPTIONS } = viewport; + // DisplaySets for the viewport, Note: this is not the actual displaySet, + // but it is a info to locate the displaySet from the displaySetService + const displaySetsInfo = []; + const { StudyInstanceUID: activeStudyUID } = this.activeStudy; + viewport.displaySets.forEach(displaySetOptions => { + const { id, matchedDisplaySetsIndex = 0 } = displaySetOptions; + const reuseDisplaySetUID = + id && + displaySetSelectorMap[ + `${activeStudyUID}:${id}:${matchedDisplaySetsIndex || 0}` + ]; + const viewportDisplaySetMain = this.displaySetMatchDetails.get(id); - if (viewportDisplaySet) { - const { - SeriesInstanceUID, - displaySetInstanceUID, - } = viewportDisplaySet; + const viewportDisplaySet = this.findDeduplicatedMatchDetails( + viewportDisplaySetMain, + matchedDisplaySetsIndex, + options + ); - const displaySetInfo: HangingProtocol.DisplaySetInfo = { - SeriesInstanceUID, - displaySetInstanceUID, - displaySetOptions, - }; + // Use the display set provided instead + if (reuseDisplaySetUID) { + if (viewportOptions.allowUnmatchedView !== true) { + this.validateDisplaySetSelectMatch( + viewportDisplaySet, + id, + reuseDisplaySetUID + ); + } + const displaySetInfo: HangingProtocol.DisplaySetInfo = { + displaySetInstanceUID: reuseDisplaySetUID, + displaySetOptions, + }; - displaySetsInfo.push(displaySetInfo); - } else { - console.warn( - ` + displaySetsInfo.push(displaySetInfo); + return; + } + + // Use the display set index to allow getting the "next" match, eg + // matching all display sets, and get the matchedDisplaySetsIndex'th item + if (viewportDisplaySet) { + const { displaySetInstanceUID } = viewportDisplaySet; + + const displaySetInfo: HangingProtocol.DisplaySetInfo = { + displaySetInstanceUID, + displaySetOptions, + }; + + displaySetsInfo.push(displaySetInfo); + } else { + console.warn( + ` The hanging protocol viewport is requesting to display ${id} displaySet that is not matched based on the provided criteria (e.g. matching rules). ` - ); - } - } - ); - - this.viewportMatchDetails.set(viewportIndex, { - viewportOptions, - displaySetsInfo, - }); - }); - } - - _updateMatchByOptions( - protocol: Protocol, - options: HangingProtocol.SetProtocolOptions - ) { - const { displaySetService } = this._servicesManager.services; - - if (options.displaySetInstanceUIDs) { - this._updateGlobalMatchByOptions( - options as HangingProtocol.GlobalProtocolOptions, - protocol, - displaySetService - ); - return; - } - - // Todo: I don't think we need the following anymore, since the drag and - // drop has been reworked to ask HangingProtocolService to check - // which viewports need to be updated. I don't think there is other use cases - // other than drag and drop and thumbnails double click that will specify - // options at viewport level. if there are the following code will need to be - // uncommented and tested. - this._updateViewportSpecificMatchByOptions( - options as HangingProtocol.ViewportSpecificProtocolOptions, - protocol - ); - } - - private _updateViewportSpecificMatchByOptions( - options: HangingProtocol.ViewportSpecificProtocolOptions, - protocol: HangingProtocol.Protocol - ) { - const { displaySetService } = this._servicesManager.services; - const { displaySetSelectors = {} } = protocol; - const protocolViewports = protocol.stages[this.stage].viewports; - - // if we get here, we can fill in the displaySetMatchDetails - for (const viewportIndex in options) { - const displaySetAndViewportOptions = options[viewportIndex]; - - const protocolViewport = protocolViewports[viewportIndex]; - // if the protocol already has the viewport - if (protocolViewport) { - // if the protocol has a viewport with specific displaySets, we need to check if the - // displaySetInstanceUIDs are allowed by the protocol - this._validateViewportSpecificMatch( - displaySetAndViewportOptions, - protocolViewport, - displaySetSelectors ); - - displaySetAndViewportOptions.displaySetInstanceUIDs.forEach( - (displaySetInstanceUID, index) => { - const displaySet = displaySetService.getDisplaySetByUID( - displaySetInstanceUID - ); - - const displaySetId = protocolViewport.displaySets[index].id; - - // since we are setting the displaySetMatchDetails for each viewport - // directly, a side effect is that any other viewport that is referencing - // the same displaySetId will also be updated with the new - // displaySetMatchDetails, but sometimes this is not a desired behavior - // which is why we are using the syncDataForViewports to determine if - // we should update the displaySetMatchDetails for all the viewports - // that are affected by the syncDataForViewports - if (protocol.syncDataForViewports) { - this.displaySetMatchDetails.set(displaySetId, { - SeriesInstanceUID: displaySet.SeriesInstanceUID, - StudyInstanceUID: displaySet.StudyInstanceUID, - displaySetInstanceUID: displaySet.displaySetInstanceUID, - matchDetails: {}, - matchingScores: [], - sortingInfo: {}, - }); - } else { - // if the protocol does not have the syncDataForViewports, we need to - // update the displaySetMatchDetails by introducing the displaySetIndex - // to the displaySetMatchDetails. This way we can match the displaySetInstanceUIDs - // for the viewportIndex, but also for the other viewports that are affected - // by the syncDataForViewports - - const displaySetSelectorId = `${displaySetId}_${uuidv4()}`; - - // update the displaySetId at the viewport - protocolViewport.displaySets[index].id = displaySetSelectorId; - - this.displaySetMatchDetails.set(displaySetSelectorId, { - SeriesInstanceUID: displaySet.SeriesInstanceUID, - StudyInstanceUID: displaySet.StudyInstanceUID, - displaySetInstanceUID: displaySet.displaySetInstanceUID, - matchDetails: {}, - matchingScores: [], - sortingInfo: {}, - }); - } - } - ); - } else { - // if the protocol does not have the viewport, we need to create it - const newViewport = { - displaySets: [], - viewportOptions: {}, - }; - - displaySetAndViewportOptions?.displaySetInstanceUIDs?.forEach( - (displaySetInstanceUID, index) => { - const displaySet = displaySetService.getDisplaySetByUID( - displaySetInstanceUID - ); - - const displaySetId = `viewport_${viewportIndex}_displaySet_${index}`; - - newViewport.displaySets.push({ - id: displaySetId, - }); - - this.displaySetMatchDetails.set(displaySetId, { - SeriesInstanceUID: displaySet.SeriesInstanceUID, - StudyInstanceUID: displaySet.StudyInstanceUID, - displaySetInstanceUID: displaySet.displaySetInstanceUID, - matchDetails: {}, - matchingScores: [], - sortingInfo: {}, - }); - } - ); - - protocolViewports[viewportIndex] = newViewport; } - } + }); + return { + viewportOptions, + displaySetsInfo, + }; } private _validateViewportSpecificMatch( displaySetAndViewportOptions: HangingProtocol.DisplaySetAndViewportOptions, protocolViewport: HangingProtocol.Viewport, displaySetSelectors: Record - ) { + ): void { const { displaySetService } = this._servicesManager.services; const protocolViewportDisplaySets = protocolViewport.displaySets; const numDisplaySetsToSet = @@ -1036,110 +1178,6 @@ class HangingProtocolService { ); } - private _updateGlobalMatchByOptions( - options: HangingProtocol.GlobalProtocolOptions, - protocol: Protocol, - displaySetService: any - ) { - const { displaySetSelectors = {} } = protocol; - const protocolViewports = protocol.stages[this.stage].viewports; - - options = options as HangingProtocol.GlobalProtocolOptions; - // we need to check each displaySetInstanceUIDs to see if it satisfies the - // seriesMatching criteria - options.displaySetInstanceUIDs.forEach(displaySetInstanceUID => { - const displaySet = displaySetService.getDisplaySetByUID( - displaySetInstanceUID - ); - - // match against all the displaySetSelectors defined in the protocol - for (const displaySetId in displaySetSelectors) { - const displaySetSelector = displaySetSelectors[displaySetId]; - this._validateRequiredSelectors(displaySetSelector, displaySet); - } - }); - - // if we get here, we can fill in the displaySetMatchDetails - // however, there might be a case where we are asked to - // render multiple displaySets for a single viewport - // so we need to go back to viewports and start from there - const newDisplaySetIds: Set = new Set(); - - // Todo: this currently work for current stage only - protocolViewports.forEach(viewport => { - viewport.displaySets.forEach(displaySetInfo => { - const { id: displaySetId } = displaySetInfo; - if (!this.displaySetMatchDetails.has(displaySetId)) { - newDisplaySetIds.add(displaySetId); - } - }); - }); - - // Todo: handle override of the viewport and displaySet options - Array.from(newDisplaySetIds).forEach((displaySetId, index) => { - const displaySetInstanceUID = options.displaySetInstanceUIDs[index]; - - const displaySet = displaySetService.getDisplaySetByUID( - displaySetInstanceUID - ); - - this.displaySetMatchDetails.set(displaySetId, { - SeriesInstanceUID: displaySet.SeriesInstanceUID, - StudyInstanceUID: displaySet.StudyInstanceUID, - displaySetInstanceUID: displaySet.displaySetInstanceUID, - matchDetails: {}, - matchingScores: [], - sortingInfo: {}, - }); - }); - - /* - Todo: make it work for the case where the number of displaySets - to set is not equal to the number of displaySets in the protocol, we can modify - the protocol to have the same number of displaySets for each viewport - - if (newDisplaySetIds.size !== numberOfDisplaySetsToSet) { - // the remaining ones to set - const remainingDisplaySetMatches = options.slice( - newDisplaySetIds.size, - numberOfDisplaySetsToSet - ); - - protocol[this.stage].viewports.forEach(viewport => { - const { displaySets } = viewport; - - // push the displaySetsToSet to the end of the displaySets array - - remainingDisplaySetMatches.forEach(({ displaySetInstanceUID }) => { - displaySets.push({ - id: displaySetInstanceUID, - }); - }); - }); - - remainingDisplaySetMatches.forEach(({ displaySetInstanceUID }) => { - const displaySet = displaySetService.getDisplaySetByUID( - displaySetInstanceUID - ); - - this.displaySetMatchDetails.set(displaySetInstanceUID, { - SeriesInstanceUID: displaySet.SeriesInstanceUID, - StudyInstanceUID: displaySet.StudyInstanceUID, - displaySetInstanceUID: displaySet.displaySetInstanceUID, - matchDetails: {}, - matchingScores: [], - sortingInfo: {}, - }); - }); - } - - // Todo: this has a bug where if the protocol defines only one displaySet, - // but renders a fusion, it cannot go back to one displaySet since its - // viewport.displaySets.length is 2 and it should remove the second one - // later - */ - } - private _validateRequiredSelectors( displaySetSelector: HangingProtocol.DisplaySetSelector, displaySet: any @@ -1159,36 +1197,39 @@ class HangingProtocolService { } } - _validateOptions(options: HangingProtocol.SetProtocolOptions) { + _validateOptions(options: HangingProtocol.SetProtocolOptions): void { const { displaySetService } = this._servicesManager.services; - - if (options.displaySetInstanceUIDs) { - options = options as HangingProtocol.GlobalProtocolOptions; - - options.displaySetInstanceUIDs.forEach(displaySetInstanceUID => { - const displaySet = displaySetService.getDisplaySetByUID( - displaySetInstanceUID - ); - - if (!displaySet) { - throw new Error( - `The displaySetInstanceUID ${displaySetInstanceUID} is not found in the displaySetService` + const { displaySetSelectorMap } = options; + if (displaySetSelectorMap) { + Object.entries(displaySetSelectorMap).forEach( + ([key, displaySetInstanceUID]) => { + const displaySet = displaySetService.getDisplaySetByUID( + displaySetInstanceUID ); + + if (!displaySet) { + throw new Error( + `The displaySetInstanceUID ${displaySetInstanceUID} is not found in the displaySetService` + ); + } } - }); + ); } } // Match images given a list of Studies and a Viewport's image matching reqs - _matchImages(displaySetRules) { + protected _matchImages(displaySetRules) { // TODO: matching is applied on study and series level, instance // level matching needs to be added in future // Todo: handle fusion viewports by not taking the first displaySet rule for the viewport - const { studyMatchingRules = [], seriesMatchingRules } = displaySetRules; + const { + id, + studyMatchingRules = [], + seriesMatchingRules, + } = displaySetRules; const matchingScores = []; - let highestStudyMatchingScore = 0; let highestSeriesMatchingScore = 0; console.log( @@ -1196,7 +1237,10 @@ class HangingProtocolService { studyMatchingRules, seriesMatchingRules ); + const matchActiveOnly = this.protocol.numberOfPriorsReferenced === -1; this.studies.forEach(study => { + // Skip non-active if active only + if (matchActiveOnly && this.activeStudy !== study) return; const studyDisplaySets = this.displaySets.filter( it => it.StudyInstanceUID === study.StudyInstanceUID ); @@ -1211,21 +1255,18 @@ class HangingProtocolService { return; } - highestStudyMatchingScore = studyMatchDetails.score; - this.debug( 'study', study.StudyInstanceUID, 'display sets #', - this.displaySets.length + studyDisplaySets.length ); - this.displaySets.forEach(displaySet => { + studyDisplaySets.forEach(displaySet => { const { StudyInstanceUID, SeriesInstanceUID, displaySetInstanceUID, } = displaySet; - if (StudyInstanceUID !== study.StudyInstanceUID) return; const seriesMatchDetails = this.protocolEngine.findMatch( displaySet, seriesMatchingRules, @@ -1290,7 +1331,7 @@ class HangingProtocolService { }); if (matchingScores.length === 0) { - console.log('No match found'); + console.log('No match found', id); } // Sort the matchingScores @@ -1332,7 +1373,7 @@ class HangingProtocolService { _isNextStageAvailable() { const numberOfStages = this._getNumProtocolStages(); - return this.stage + 1 < numberOfStages; + return this.stageIndex + 1 < numberOfStages; } /** @@ -1340,7 +1381,7 @@ class HangingProtocolService { * @return {Boolean} True if previous stage is available or false otherwise */ _isPreviousStageAvailable(): boolean { - return this.stage - 1 >= 0; + return this.stageIndex - 1 >= 0; } /** @@ -1350,31 +1391,45 @@ class HangingProtocolService { * @param {Integer} stageAction An integer value specifying whether next (1) or previous (-1) stage * @return {Boolean} True if new stage has set or false, otherwise */ - _setCurrentProtocolStage(stageAction): boolean { - //resetting the applied protocols - this.hpAlreadyApplied = new Map(); + _setCurrentProtocolStage( + stageAction: number, + options: HangingProtocol.SetProtocolOptions + ): boolean { // Check if previous or next stage is available - if (stageAction === -1 && !this._isPreviousStageAvailable()) { - return false; - } else if (stageAction === 1 && !this._isNextStageAvailable()) { + let i; + for ( + i = this.stageIndex + stageAction; + i >= 0 && i < this.protocol.stages.length; + i += stageAction + ) { + if (this.protocol.stages[i].status !== 'disabled') { + break; + } + } + if (i < 0 || i >= this.protocol.stages.length) { return false; } // Sets the new stage - this.stage += stageAction; + this.stageIndex = i; // Log the new stage - this.debug(`ProtocolEngine::setCurrentProtocolStage stage = ${this.stage}`); + this.debug( + `ProtocolEngine::setCurrentProtocolStage stage = ${this.stageIndex}` + ); // Since stage has changed, we need to update the viewports // and redo matchings - this._updateViewports(); + this._updateViewports(options); - // Everything went well - this._broadcastChange(this.EVENTS.STAGE_CHANGE, { + // Everything went well, broadcast the update, exactly identical to + // HP applied + this._broadcastEvent(this.EVENTS.PROTOCOL_CHANGED, { viewportMatchDetails: this.viewportMatchDetails, - hpAlreadyApplied: this.hpAlreadyApplied, displaySetMatchDetails: this.displaySetMatchDetails, + protocol: this.protocol, + stageIdx: this.stageIndex, + stage: this.protocol.stages[this.stageIndex], }); return true; } @@ -1388,164 +1443,7 @@ class HangingProtocolService { } } - /** - * Broadcasts hanging protocols changes. - * - * @param {string} eventName The event name.add - * @param {object} eventData.source The measurement source. - * @param {object} eventData.measurement The measurement. - * @param {boolean} eventData.notYetUpdatedAtSource True if the measurement was edited - * within the measurement service and the source needs to update. - * @return void - */ - // Todo: why do we have a separate broadcastChange function here? - _broadcastChange(eventName, eventData) { - const hasListeners = Object.keys(this.listeners).length > 0; - const hasCallbacks = Array.isArray(this.listeners[eventName]); - - if (hasListeners && hasCallbacks) { - this.listeners[eventName].forEach(listener => { - listener.callback(eventData); - }); - } - } - _copyProtocol(protocol: Protocol) { return JSON.parse(JSON.stringify(protocol)); } - - /** - _setProtocolLayoutOptions(protocol: Protocol, { numRows, numCols }) { - const layoutOptions = []; - const protocolStage = protocol.stages[this.stage]; - const numViewports = protocolStage.viewports.length; - - for (let i = 0; i < numViewports; i++) { - const { row, col } = unravelIndex(i, numRows, numCols); - const w = 1 / numCols; - const h = 1 / numRows; - const xPos = col * w; - const yPos = row * h; - - layoutOptions[i] = { - width: w, - height: h, - x: xPos, - y: yPos, - }; - } - - // Todo: handle the case where the viewportStructure is not a grid - protocolStage.viewportStructure.properties.rows = numRows; - protocolStage.viewportStructure.properties.columns = numCols; - protocolStage.viewportStructure.properties.layoutOptions = { - ...layoutOptions, - }; - } - - _getUpdatedProtocol({ - numRows, - numCols, - protocol: oldProtocol, - }: { - numRows: number; - numCols: number; - protocol: Protocol; - }): Protocol { - let newProtocol = this._copyProtocol(oldProtocol); - - const protocolStage = newProtocol.stages[this.stage]; - - // The following commented code is a potential improvements to the - // hanging protocols to intelligently switch between number of rows - // and columns based on the old state of the protocol. For instance, - // changing from 2x2 to 2x3 (adding a column) right now reorders the viewports - // as well, however, it should just add one empty column to the right - // and leave the rest of the viewports in place. This sounds amazing, - // but comes at a cost (which we need to tackle later). The cost is that - // the viewportIndex will change during this smart change of layout. In - // the example above the viewport at index (2) bottom left, will now be - // at index (3) bottom left, and since react will re-render the viewport - // it will reset the viewport's state such as (zoom, pan, windowLevel, imageIndex) - // and the user will lose their current state. In addition, all our viewportIds - // are dependent on the viewportIndex, so we will need to update all the viewportIds - // as well, and you can see how this can get out of hand. Later, we should - // tackle this problem and make the smart change of layout work. - - // const { rows: oldNumRows, columns: oldNumCols } = newProtocol.stages[ - // this.stage - // ].viewportStructure.properties; - - // const oldToNewViewportIndices = getGridMapping( - // { - // numRows: oldNumRows, - // numCols: oldNumCols, - // }, - // { - // numRows, - // numCols, - // } - // ); - - const protocolViewports = protocolStage.viewports; - - if (protocolViewports.length < numRows * numCols) { - const newViewports = []; - - for (let i = protocolViewports.length; i < numRows * numCols; i++) { - newViewports.push({ - viewportOptions: { - toolGroupId: 'default', - viewportType: 'stack', - }, - displaySets: [ - { - id: `viewport-${i}`, - }, - ], - }); - } - - protocolStage.viewports = [...protocolViewports, ...newViewports]; - } else if (protocolViewports.length > numRows * numCols) { - // remove viewports that are not needed - protocolStage.viewports = protocolViewports.slice(0, numRows * numCols); - } - - // update the displaySetMatchDetails to reflect the new viewports - const toRemove = []; - this.displaySetMatchDetails.forEach( - (displaySetMatchDetail, displaySetId) => { - // if the displaySetId is not referenced in the protocolStage viewports - // we can remove it - const found = protocolStage.viewports.find(viewport => { - return viewport.displaySets.find(displaySet => { - return displaySet.id === displaySetId; - }); - }); - - if (!found) { - toRemove.push(displaySetId); - } - } - ); - - toRemove.forEach(displaySetId => { - this.displaySetMatchDetails.delete(displaySetId); - }); - - this._setProtocolLayoutOptions(newProtocol, { numRows, numCols }); - newProtocol = this._validateProtocol(newProtocol); - - // Todo: not sure if we need to reset here, or we can smartly update the - // viewportMatchDetails and hpAlreadyApplied maps - this.hpAlreadyApplied = new Map(); - this.viewportMatchDetails = new Map(); - - return newProtocol; - } - */ } - -export default HangingProtocolService; -export { EVENTS }; diff --git a/platform/core/src/services/HangingProtocolService/ProtocolEngine.js b/platform/core/src/services/HangingProtocolService/ProtocolEngine.js index 5cfdf1264..b3c18ab40 100644 --- a/platform/core/src/services/HangingProtocolService/ProtocolEngine.js +++ b/platform/core/src/services/HangingProtocolService/ProtocolEngine.js @@ -81,6 +81,16 @@ export default class ProtocolEngine { }); } + /** + * finds the match results against the given display set or + * study instance by testing the given rules against this, and using + * the provided options for testing. + * + * @param {*} metaData to match against as primary value + * @param {*} rules to apply + * @param {*} options are additional values that can be used for matching + * @returns + */ findMatch(metaData, rules, options) { return HPMatcher.match( metaData, @@ -109,7 +119,7 @@ export default class ProtocolEngine { let rules = protocol.protocolMatchingRules.slice(); if (!rules || !rules.length) { console.warn( - 'ProtocolEngine::findMatchByStudy no matching rules - specify protocolMatchingRules', + 'ProtocolEngine::findMatchByStudy no matching rules - specify protocolMatchingRules for', protocol.id ); return; diff --git a/platform/core/src/services/HangingProtocolService/lib/validator.js b/platform/core/src/services/HangingProtocolService/lib/validator.js index 711ff01b9..498ae3201 100644 --- a/platform/core/src/services/HangingProtocolService/lib/validator.js +++ b/platform/core/src/services/HangingProtocolService/lib/validator.js @@ -14,6 +14,41 @@ validate.validators.doesNotEqual = function(value, options, key) { } }; +// Ignore case contains. +// options testValue MUST be in lower case already, otherwise it won't match +validate.validators.containsI = function (value, options, key) { + const testValue = options?.value ?? options; + if (Array.isArray(value)) { + if ( + value.some( + item => !validate.validators.containsI(item.toLowerCase(), options, key) + ) + ) { + return undefined; + } + return `No item of ${value.join(',')} contains ${JSON.stringify( + testValue + )}`; + } + if (Array.isArray(testValue)) { + if ( + testValue.some( + subTest => !validate.validators.containsI(value, subTest, key) + ) + ) { + return; + } + return `${key} must contain at least one of ${testValue.join(',')}`; + } + if ( + testValue && + value.indexOf && + value.toLowerCase().indexOf(testValue) === -1 + ) { + return key + 'must contain any case of' + testValue; + } +}; + validate.validators.contains = function(value, options, key) { const testValue = options?.value ?? options; if (Array.isArray(value)) { diff --git a/platform/core/src/services/HangingProtocolService/lib/validator.test.js b/platform/core/src/services/HangingProtocolService/lib/validator.test.js index 5566e97a7..67b7ac52e 100644 --- a/platform/core/src/services/HangingProtocolService/lib/validator.test.js +++ b/platform/core/src/services/HangingProtocolService/lib/validator.test.js @@ -3,9 +3,10 @@ import validate from './validator.js'; describe('validator', () => { const attributeMap = { str: 'string', + upper: 'UPPER', num: 3, nullValue: null, - list: ['abc', 'def'], + list: ['abc', 'def', 'GHI'], }; const options = { @@ -35,6 +36,37 @@ describe('validator', () => { }); }); + describe('containsI', () => { + it('returns match any list contains case insensitive', () => { + expect( + validate(attributeMap, { upper: { containsI: ['bye', 'pre'] } }, [ + options, + ]) + ).not.toBeUndefined(); + expect( + validate(attributeMap, { list: { containsI: 'hi' } }, [options]) + ).toBeUndefined(); + expect( + validate(attributeMap, { list: { containsI: ['hi', 'bye'] } }, [ + options, + ]) + ).toBeUndefined(); + expect( + validate(attributeMap, { list: { containsI: ['bye', 'hi'] } }, [ + options, + ]) + ).toBeUndefined(); + expect( + validate(attributeMap, { list: { containsI: ['ig', 'hi'] } }, [options]) + ).toBeUndefined(); + expect( + validate(attributeMap, { upper: { containsI: ['bye', 'per'] } }, [ + options, + ]) + ).toBeUndefined(); + }); + }); + describe('equals', () => { it('returned undefined on equals', () => { expect( diff --git a/platform/core/src/services/MeasurementService/MeasurementService.ts b/platform/core/src/services/MeasurementService/MeasurementService.ts index 1fb9550f2..e734e5488 100644 --- a/platform/core/src/services/MeasurementService/MeasurementService.ts +++ b/platform/core/src/services/MeasurementService/MeasurementService.ts @@ -519,9 +519,14 @@ class MeasurementService extends PubSubService { let measurement = {}; try { const sourceMappings = this.mappings[source.uid]; - const { toMeasurementSchema } = sourceMappings.find( + const sourceMapping = sourceMappings.find( mapping => mapping.annotationType === annotationType ); + if (!sourceMapping) { + console.log('No source mapping', source); + return; + } + const { toMeasurementSchema } = sourceMapping; /* Convert measurement */ measurement = toMeasurementSchema(sourceAnnotationDetail); diff --git a/platform/core/src/services/ServicesManager.ts b/platform/core/src/services/ServicesManager.ts index ab6b34728..3d2843dbf 100644 --- a/platform/core/src/services/ServicesManager.ts +++ b/platform/core/src/services/ServicesManager.ts @@ -1,10 +1,11 @@ import log from './../log.js'; import Services from '../types/Services'; +import CommandsManager from '../classes/CommandsManager'; export default class ServicesManager { public services: Services = {}; - constructor(commandsManager) { + constructor(commandsManager: CommandsManager) { this._commandsManager = commandsManager; this.services = {}; this.registeredServiceNames = []; diff --git a/platform/core/src/services/StateSyncService/StateSyncService.test.js b/platform/core/src/services/StateSyncService/StateSyncService.test.js new file mode 100644 index 000000000..77c23069a --- /dev/null +++ b/platform/core/src/services/StateSyncService/StateSyncService.test.js @@ -0,0 +1,31 @@ +import StateSyncService from './StateSyncService'; +import log from '../../log'; + +jest.mock('../../log.js', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +})); + +const extensionManager = {}; + +describe('StateSyncService.ts', () => { + let stateSyncService; + + let configuration; + + beforeEach(() => { + log.warn.mockClear(); + jest.clearAllMocks(); + configuration = {}; + stateSyncService = new StateSyncService({ + configuration, + }); + }); + + describe('init', () => { + it('init succeeds', () => { + stateSyncService.init(extensionManager); + }); + }); +}); diff --git a/platform/core/src/services/StateSyncService/StateSyncService.ts b/platform/core/src/services/StateSyncService/StateSyncService.ts new file mode 100644 index 000000000..b2d91c0ce --- /dev/null +++ b/platform/core/src/services/StateSyncService/StateSyncService.ts @@ -0,0 +1,80 @@ +import { PubSubService } from '../_shared/pubSubServiceInterface'; +import { ExtensionManager } from '../../extensions'; + +const EVENTS = {}; + +type Obj = Record; + +type StateConfig = { + /** clearOnModeExit defines state configuraion that is cleared automatically on + * exiting a mode. This clearing occurs after the mode onModeExit, + * so it is possible to preserve desired state during exit to be restored + * later. + */ + clearOnModeExit?: boolean; +}; + +type States = { + [key: string]: Obj; +}; + +/** + */ +export default class StateSyncService extends PubSubService { + public static REGISTRATION = { + name: 'stateSyncService', + create: ({ configuration = {}, commandsManager }) => { + return new StateSyncService({ configuration, commandsManager }); + }, + }; + + extensionManager: ExtensionManager; + configuration: Obj; + registeredStateSets: { + [id: string]: StateConfig; + } = {}; + state: States = {}; + + constructor({ configuration }) { + super(EVENTS); + this.configuration = configuration || {}; + } + + public init(extensionManager: ExtensionManager): void { } + + public register(id: string, config: StateConfig): void { + this.registeredStateSets[id] = config; + this.store({ [id]: {} }); + } + + public getState(): Record { + // TODO - return a proxy to this which is not writable in dev mode + return this.state; + } + + /** + * Stores all the new state values contained in states. + * + * @param states - is an object containing replacement values to store + * @returns + */ + public store(states: States): States { + Object.keys(states).forEach(stateKey => { + if (!this.registeredStateSets[stateKey]) { + throw new Error(`No state ${stateKey} registered`); + } + }); + this.state = { ...this.state, ...states }; + return states; + } + + public onModeExit(): void { + const toReduce = {}; + for (const [key, value] of Object.entries(this.registeredStateSets)) { + if (value.clearOnModeExit) { + toReduce[key] = {}; + } + } + this.store(toReduce); + } +} diff --git a/platform/core/src/services/StateSyncService/index.ts b/platform/core/src/services/StateSyncService/index.ts new file mode 100644 index 000000000..91cc430e5 --- /dev/null +++ b/platform/core/src/services/StateSyncService/index.ts @@ -0,0 +1,3 @@ +import StateSyncService from './StateSyncService'; + +export default StateSyncService; diff --git a/platform/core/src/services/ToolBarService/ToolbarService.ts b/platform/core/src/services/ToolBarService/ToolbarService.ts index e9df3ccfa..4a3cf2429 100644 --- a/platform/core/src/services/ToolBarService/ToolbarService.ts +++ b/platform/core/src/services/ToolBarService/ToolbarService.ts @@ -1,4 +1,6 @@ import merge from 'lodash.merge'; +import { CommandsManager } from '../../classes'; +import { ExtensionManager } from '../../extensions'; import { PubSubService } from '../_shared/pubSubServiceInterface'; const EVENTS = { @@ -16,37 +18,31 @@ export default class ToolbarService extends PubSubService { }, }; - constructor(commandsManager) { + buttons: Record = {}; + state: { + primaryToolId: string; + toggles: Record; + groups: Record; + } = { primaryToolId: 'WindowLevel', toggles: {}, groups: {} }; + buttonSections: Record = { + /** + * primary: ['Zoom', 'Wwwc'], + * secondary: ['Length', 'RectangleRoi'] + */ + }; + _commandsManager: CommandsManager; + extensionManager: ExtensionManager; + + constructor(commandsManager: CommandsManager) { super(EVENTS); this._commandsManager = commandsManager; - // - this.buttons = {}; - this.unsubscriptions = []; // if tools need to unsubscribe from events - this.buttonSections = { - /** - * primary: ['Zoom', 'Wwwc'], - * secondary: ['Length', 'RectangleRoi'] - */ - }; - - // TODO: Do we need to track per context? Or do we allow for a mixed - // definition that adapts based on context? - this.state = { - primaryToolId: 'WindowLevel', - toggles: { - /* id: true/false */ - }, - groups: { - /* track most recent click per group...? */ - }, - }; } - init(extensionManager) { + public init(extensionManager: ExtensionManager): void { this.extensionManager = extensionManager; } - reset() { + public reset(): void { this.unsubscriptions.forEach(unsub => unsub()); this.state = { primaryToolId: 'WindowLevel', @@ -69,7 +65,7 @@ export default class ToolbarService extends PubSubService { * used for calling the specified interaction. That is, the command is * called with {...commandOptions,...options} */ - recordInteraction(interaction, options) { + recordInteraction(interaction, options?: Record) { if (!interaction) return; const commandsManager = this._commandsManager; const { groupId, itemId, interactionType, commands } = interaction; @@ -181,6 +177,15 @@ export default class ToolbarService extends PubSubService { return [this.state.primaryToolId, ...Object.keys(this.state.toggles)]; } + /** Sets the toggle state of a button to the isActive state */ + public setActive(id: string, isActive: boolean): void { + if (isActive) { + this.state.toggles[id] = true; + } else { + delete this.state.toggles[id]; + } + } + setButton(id, button) { if (this.buttons[id]) { this.buttons[id] = merge(this.buttons[id], button); diff --git a/platform/core/src/services/ViewportGridService/ViewportGridService.ts b/platform/core/src/services/ViewportGridService/ViewportGridService.ts index c35cc34c3..cfa5a5bc5 100644 --- a/platform/core/src/services/ViewportGridService/ViewportGridService.ts +++ b/platform/core/src/services/ViewportGridService/ViewportGridService.ts @@ -12,6 +12,7 @@ class ViewportGridService extends PubSubService { return new ViewportGridService(); }, }; + public static EVENTS = EVENTS; serviceImplementation = {}; @@ -25,8 +26,6 @@ class ViewportGridService extends PubSubService { setActiveViewportIndex: setActiveViewportIndexImplementation, setDisplaySetsForViewport: setDisplaySetsForViewportImplementation, setDisplaySetsForViewports: setDisplaySetsForViewportsImplementation, - setCachedLayout: setCachedLayoutImplementation, - restoreCachedLayout: restoreCachedLayoutImplementation, setLayout: setLayoutImplementation, reset: resetImplementation, onModeExit: onModeExitImplementation, @@ -51,12 +50,6 @@ class ViewportGridService extends PubSubService { if (resetImplementation) { this.serviceImplementation._reset = resetImplementation; } - if (setCachedLayoutImplementation) { - this.serviceImplementation._setCachedLayout = setCachedLayoutImplementation; - } - if (restoreCachedLayoutImplementation) { - this.serviceImplementation._restoreCachedLayout = restoreCachedLayoutImplementation; - } if (onModeExitImplementation) { this.serviceImplementation._onModeExit = onModeExitImplementation; } @@ -70,8 +63,11 @@ class ViewportGridService extends PubSubService { public setActiveViewportIndex(index) { this.serviceImplementation._setActiveViewportIndex(index); + const state = this.getState(); + const viewportId = state.viewports[index]?.viewportOptions?.viewportId; this._broadcastEvent(this.EVENTS.ACTIVE_VIEWPORT_INDEX_CHANGED, { viewportIndex: index, + viewportId, }); } @@ -97,8 +93,20 @@ class ViewportGridService extends PubSubService { this.serviceImplementation._setDisplaySetsForViewports(viewports); } - public setLayout({ numCols, numRows }) { - this.serviceImplementation._setLayout({ numCols, numRows }); + /** + * + * @param numCols, numRows - the number of columns and rows to apply + * @param findOrCreateViewport is a function which takes the + * index position of the viewport, the position id, and a set of + * options that is initially provided as {} (eg to store intermediate state) + * The function returns a viewport object to use at the given position. + */ + public setLayout({ numCols, numRows, findOrCreateViewport = undefined }) { + this.serviceImplementation._setLayout({ + numCols, + numRows, + findOrCreateViewport, + }); } public reset() { @@ -115,14 +123,6 @@ class ViewportGridService extends PubSubService { this.serviceImplementation._onModeExit(); } - public setCachedLayout({ cacheId, cachedLayout }) { - this.serviceImplementation._setCachedLayout({ cacheId, cachedLayout }); - } - - public restoreCachedLayout(cacheId) { - this.serviceImplementation._restoreCachedLayout(cacheId); - } - public set(state) { this.serviceImplementation._set(state); } diff --git a/platform/core/src/services/index.ts b/platform/core/src/services/index.ts index 6a3507992..b34e028d8 100644 --- a/platform/core/src/services/index.ts +++ b/platform/core/src/services/index.ts @@ -17,6 +17,7 @@ import UserAuthenticationService from './UserAuthenticationService'; import CustomizationService from './CustomizationService'; import Services from '../types/Services'; +import StateSyncService from './StateSyncService'; import PanelService from './PanelService'; export { @@ -24,6 +25,7 @@ export { MeasurementService, ServicesManager, CustomizationService, + StateSyncService, UIDialogService, UIModalService, UINotificationService, diff --git a/platform/core/src/types/Command.ts b/platform/core/src/types/Command.ts index 83c774379..7e71976e2 100644 --- a/platform/core/src/types/Command.ts +++ b/platform/core/src/types/Command.ts @@ -3,3 +3,10 @@ export interface Command { commandOptions?: Record; context?: string; } + +/** + * This is the format used within many items for multiple commands + */ +export interface Commands { + commands: []; +} diff --git a/platform/core/src/types/HangingProtocol.ts b/platform/core/src/types/HangingProtocol.ts index a513e308e..e7e7eddee 100644 --- a/platform/core/src/types/HangingProtocol.ts +++ b/platform/core/src/types/HangingProtocol.ts @@ -1,68 +1,112 @@ -type DisplaySetInfo = { - SeriesInstanceUID: string; - displaySetInstanceUID: string; - displaySetOptions: Record; +import { Command } from './Command'; + +export type DisplaySetInfo = { + displaySetInstanceUID?: string; + displaySetOptions: DisplaySetOptions; }; -type ViewportMatchDetails = { +export type ViewportMatchDetails = { viewportOptions: ViewportOptions; displaySetsInfo: DisplaySetInfo[]; }; -type DisplaySetMatchDetails = { - SeriesInstanceUID: string; - StudyInstanceUID: string; +export type DisplaySetMatchDetails = { + StudyInstanceUID?: string; displaySetInstanceUID: string; matchDetails?: any; - matchingScores?: any[]; + matchingScores?: DisplaySetMatchDetails[]; sortingInfo?: any; }; - -type DisplaySetAndViewportOptions = { +export type DisplaySetAndViewportOptions = { displaySetInstanceUIDs: string[]; viewportOptions: ViewportOptions; displaySetOptions: DisplaySetOptions; -} +}; -type ViewportSpecificProtocolOptions = { - [viewportIndex: string]: DisplaySetAndViewportOptions -} +export type SetProtocolOptions = { + /** Used to provide a mapping of what keys are provided for which viewport. + * For example, a Chest XRay might use have the display set selector id of + * "ChestXRay", then the user might drag an alternate chest xray from the initially chosen one, + * and then navigate to another stage or protocol. If that new stage/protocol + * uses the name "ChestXRay", then that selection will be used instead of + * matching the display set selectors. That allows remembering the + * user selected views by name. + * Note the keys are not simple display set selector values, but are: + * `${activeStudyUID}:${displaySetSelectorId}:${matchingDisplaySetIndex || 0}` + * This is normally transparent to the user of this, but in order to specify + * specific instances, they can be added like that. + */ + displaySetSelectorMap?: Record; -type GlobalProtocolOptions = DisplaySetAndViewportOptions + /** Used to define the display sets already in view, in order to allow + * filling empty viewports with other instances. + * Only used when the -1 value for matchedDisplaySetsIndex is provided. + * List of display set instance UID's already displayed. + */ + inDisplay?: string[]; + /** Select the given stage, either by ID or position. + * Don't forget that name is used as the ID if ID not provided. + */ + stageId?: string; + stageIndex?: number; -type SetProtocolOptions = - ViewportSpecificProtocolOptions | GlobalProtocolOptions; + /** Indicates to setup the protocol and fire the PROTOCOL_RESTORED event + * but don't fire the protocol changed event. Used to restore the + * HP service to a previous state. + */ + restoreProtocol?: boolean; +}; - -type HangingProtocolMatchDetails = { +export type HangingProtocolMatchDetails = { displaySetMatchDetails: Map; viewportMatchDetails: Map; - hpAlreadyApplied: Map; }; -type MatchingRule = { - id: string; - weight: number; +export type ConstraintValue = + | string + | number + | boolean + | [] + | { + value: string | number | boolean | []; + }; + +export type Constraint = { + // This value exactly + equals?: ConstraintValue; + notEquals?: ConstraintValue; + // A caseless contains + containsI?: string; + contains?: ConstraintValue; + greaterThan?: ConstraintValue; +}; + +export type MatchingRule = { + // No real use for the id + id?: string; + // Defaults to 1 + weight?: number; attribute: string; - constraint: Record; - required: boolean; + constraint: Constraint; + // Not required by default + required?: boolean; }; -type ViewportLayoutOptions = { +export type ViewportLayoutOptions = { x: number; y: number; width: number; height: number; }; -type ViewportStructure = { +export type ViewportStructure = { layoutType: string; properties: { rows: number; columns: number; - layoutOptions: ViewportLayoutOptions[]; + layoutOptions?: ViewportLayoutOptions[]; }; }; @@ -74,7 +118,8 @@ type ViewportStructure = { * The matches are done lazily, so if a stage doesn't need a given match, * it won't be selected. */ -type DisplaySetSelector = { +export type DisplaySetSelector = { + id?: string; // The image matching rule (not currently implemented) selects which image to // display initially, only for stack views. imageMatchingRules?: MatchingRule[]; @@ -83,19 +128,19 @@ type DisplaySetSelector = { studyMatchingRules?: MatchingRule[]; }; -type SyncGroup = { +export type SyncGroup = { type: string; id: string; - source?: boolean - target?: boolean -} + source?: boolean; + target?: boolean; +}; -type initialImageOptions = { +export type initialImageOptions = { index?: number; - preset? : string; // todo: type more -} + preset?: string; // todo: type more +}; -type ViewportOptions = { +export type ViewportOptions = { toolGroupId: string; viewportType: string; id?: string; @@ -104,37 +149,116 @@ type ViewportOptions = { initialImageOptions?: initialImageOptions; syncGroups?: SyncGroup[]; customViewportProps?: Record; + // Set to true to allow non-matching drag and drop or options provided + // from options.displaySetSelectorsMap + allowUnmatchedView?: boolean; }; -type DisplaySetOptions = { +// The options here includes both the display set selector and matching index +// as well as actual options to apply to the individual viewports. +export type DisplaySetOptions = { // The id is used to choose which display set selector to apply here id: string; - // An offset to allow display secondary series, for example - // to display the second matching series (displaySetIndex==1) - // This cannot easily be done with the matching rules directly. - displaySetIndex?: number; + /** The offset to allow display secondary series, for example + * to display the second matching series, use `matchedDisplaySetsIndex==1` */ + matchedDisplaySetsIndex?: number; + // The options to apply to the display set. options?: Record; }; -type Viewport = { +export type Viewport = { viewportOptions: ViewportOptions; displaySets: DisplaySetOptions[]; }; -type ProtocolStage = { - id: string; +/** + * disabled stages are missing display sets required in order to view them. + * enabled stages have all the requiredDisplaySets and at least preferredViewports + * filled. + * passive stages have the requiredDisplaySets and at least requiredViewports filled. + */ +export type StageStatus = 'disabled' | 'enabled' | 'passive'; + +/** Controls whether a stage is activated or not, at the given level, by + * controlling the status of the stage. + */ +export type StageActivation = { + // The minimum number of viewports to be NON-blank to activate this level of the stage + minViewportsMatched?: number; + // The required set of display set selectors to have at least 1 match to activate + displaySetSelectorsMatched?: string[]; +}; + +/** + * Protocol stages are a set of different views which can be applied, for + * example, a 2x1 and a 1x1 view might be both applied (see default extension + * for this example). + */ +export type ProtocolStage = { + /** The id defaults to the name of the protocol if not otherwise specified */ + id?: string; + /** + * The display name used for this stage when shown to the user. This can + * differ from the id, for example, to use the same name for different + * stages, only one of which ends up being active. + */ name: string; + /** Indicate if the stage can be applied or not */ + status?: StageStatus; + viewportStructure: ViewportStructure; + stageActivation?: { + // The enabled activation is provided for fully active stages, + // participating in automatic stage selection and navigation + enabled?: StageActivation; + // The passive activation is provided to allow stages to manually + // be activated, but not navigated to by default, or used on initial view + passive?: StageActivation; + }; + + /** A viewport definition used for to fill in manually selected viewports. + * This allows changing the layout definition for additional viewports without + * needing to define layouts for each of the 1x1, 2x2 etc modes. + */ + defaultViewport?: Viewport; + viewports: Viewport[]; + + // Unused. createdDate?: string; }; -type Protocol = { +// Add notifications for various types of events. +export type ProtocolNotifications = { + // This set of commands is executed after the protocol is exited and the new one applied + onProtocolExit?: Command[]; + + // This set of commands is executed after the protocol is entered and applied + onProtocolEnter?: Command[]; + + // This set of commands is executed before the layout change is started. + // If it returns false, the layout change will be aborted. + // The numRows and numCols is included in the command params, so it is possible + // to apply a specific hanging protocol + onLayoutChange?: Command[]; +}; + +/** + * A protocol is the top level definition for a hanging protocol. + * It is a set of rules about when the protocol can be applied at all, + * as well as a set of stages that represent indivividual views. + * Additionally, the display set selectors are used to choose from the existing + * display sets. The hanging protcol definition here does NOT allow + * redefining the display sets to use, but only selects the views to show. + */ +export type Protocol = { // Mandatory id: string; - // Selects which display sets are given a specific name. + /** Maps ids to display set selectors to choose display sets */ displaySetSelectors: Record; + /** A default viewport to use for any stage to select new viewport layouts. */ + defaultViewport?: Viewport; stages: ProtocolStage[]; // Optional locked?: boolean; @@ -145,35 +269,34 @@ type Protocol = { availableTo?: Record; editableBy?: Record; toolGroupIds?: string[]; + // A set of callbacks relevant to entering and exiting the protocol + callbacks?: ProtocolNotifications; imageLoadStrategy?: string; // Todo: this should be types specifically protocolMatchingRules?: MatchingRule[]; + /* The number of priors required for this hanging protocol. + * -1 means that NO priors are referenced, and thus this HP matches + * only the active study, whereas 0 means that an unknown number of + * priors is matched. + */ numberOfPriorsReferenced?: number; syncDataForViewports?: boolean; }; -type ProtocolGenerator = ({ servicesManager: any, commandsManager: any }) => { +/** Used to dynamically generate protocols. + * Try to avoid this as it is difficult to provide active/disabled settings + * to the GUI when this is used, and it can be expensive to apply. + * Alternatives include using the custom attributes where possible. + */ +export type ProtocolGenerator = ({ + servicesManager: any, + commandsManager: any, +}) => { protocol: Protocol; }; -export type { - SetProtocolOptions, - ViewportOptions, - ViewportMatchDetails, - DisplaySetMatchDetails, - HangingProtocolMatchDetails, - Protocol, - ProtocolStage, - Viewport, - DisplaySetSelector, - ViewportStructure, - ViewportLayoutOptions, - DisplaySetOptions, - MatchingRule, - SyncGroup, - initialImageOptions, - DisplaySetInfo, - GlobalProtocolOptions, - ViewportSpecificProtocolOptions, - DisplaySetAndViewportOptions, - ProtocolGenerator, +export type HPInfo = { + protocolId: string; + stageId: string; + stageIndex: number; + activeStudyUID: string; }; diff --git a/platform/core/src/types/Services.ts b/platform/core/src/types/Services.ts index ab58b0ddd..ca73361ca 100644 --- a/platform/core/src/types/Services.ts +++ b/platform/core/src/types/Services.ts @@ -5,6 +5,7 @@ import { ViewportGridService, ToolbarService, DisplaySetService, + StateSyncService, } from '../services'; /** @@ -28,5 +29,6 @@ export default interface Services { syncGroupService?: Record; cornerstoneCacheService?: Record; segmentationService?: Record; + stateSyncService?: StateSyncService; panelService?: Record; } diff --git a/platform/docs/docs/platform/services/data/HangingProtocolService.md b/platform/docs/docs/platform/services/data/HangingProtocolService.md index c43fd6fd8..71dc86f49 100644 --- a/platform/docs/docs/platform/services/data/HangingProtocolService.md +++ b/platform/docs/docs/platform/services/data/HangingProtocolService.md @@ -27,6 +27,27 @@ registered automatically to the HangingProtocolService. All protocols are stored in the `HangingProtocolService` using their `id` as the key, and the protocol itself as the value. +## Protocol Definition +Protocols are defined in a getHangingProtocolModule inside an extension. As such, +they are defined with a module structure that starts with an id, and has field protocol +that is the actual protocol definition. This setup allows defining more than +one protocol within a module, each one needing it's own definition file. + +```javascript +import MyProtocol from './MyProtocol'; +export default function getHangingProtocolModule() { + return [ + { + id: MyProtocol.id, + protocol: MyProtocol, + }, + ]; +} +``` + +Within the protocol itself, the structure is layed out as described in the HangingProtocol.ts +type definition, starting with `Protocol`. See the type definition for more details. + ## Events There are two events that get publish in `HangingProtocolService`: @@ -34,31 +55,101 @@ There are two events that get publish in `HangingProtocolService`: | Event | Description | | ------------ | -------------------------------------------------------------------- | | NEW_LAYOUT | Fires when a new layout is requested by the `HangingProtocolService` | -| STAGE_CHANGE | Fires when the the stage is changed in the hanging protocols | -| PROTOCOL_CHANGED | Fires when the the protocol is changed in the hanging protocols | -| HANGING_PROTOCOL_APPLIED_FOR_VIEWPORT | Fires when the hanging protocol applies for a viewport (sets its displaySets) | +| PROTOCOL_CHANGED | Fires when the the protocol is changed in the hanging protocols, or when the applied stage is changed. | +| RESTORE_PROTOCOL | Fires when the protocol or stage is restored, for example, after turning off MPR mode | +| STAGE_ACTIVATION | Fires when the stages are known to have stage.status set. | +## Stage Activation and Status +Sometimes a hanging protocol can be applicable generally, but not all stages +should be shown by default, or should be shown at all. This can be handled by +using the stage activation to control whether the stage is shown by default (`enabled`), +whether it can be navigated to (`passive`) or whether it should not be shown +at all (`disabled`). +The `stage.status` is used to control this, and the status is controlled by +the stage activate. The status values are: + +* enabled - meaning that the stage is fully applicable +* passive - meaning that the stage can be applied, but might be missing details +* disabled - meaning that the study has insuffient information for this stage + +The default values for no `stageActivation` are to assume that `enabled` has `minViewports` of 1, +and `passive` has `minViewports=0`. That is, enable the stage if at least one +viewport is filled, and make it passive if no viewports are filled. + +The setting for these are controlled by the stageActivation property, for example +the following: + +```javascript +stageActivation: { + // The enabled activation specifies requirements to enable the stage, that is, + // make it preferred. + enabled: { + // The default value here is 1, and indicates how many non-blank viewports + // are required. + minViewportsMatched: 3, + // This enables specifying cross cutting concerns, such as having a stage + // only apply to males or females, and is a list of display set selector ids + displaySetSelectorsMatched: ['dsMale'], + }, + // The passive check is performed first. If it fails, the enabled is NOT + // checked, but the status set to disabled. The default passive check + // should always be passed, so it is fine to just define enabled if desired. + passive: { + // The default is 0, which means allow the stage even if no viewports are + // filled. This allows dragging and dropping into the viewports to + // make matches manually, which can then be re-used for other stages. + minViewportsMatched: 0, + displaySetSelectorsMatched: [...], + }, +} +``` ## API +- `destroy`: Destroys the HP service + +- `reset` and `onModeEnter`: Resets the HP service to not have any active + hanging protocols + +- `getActiveProtocol`: Returns an object of the internal state of the HP service, + useful for storing said state, as well as for getting direct access to the + protocol and stage objects. Users of this should count on it being not completely + stable as to exactly what this returns, as internal details can change. + +- `getState`: Returns the currently applied protocol ID, stage index and active study UID. + This information is storable/useable as state information to be used elsewhere. + +- `getDefaultProtocol`: Returns the default protocol to apply. + - `getMatchDetails`: returns an object which contains the details of the matching for the viewports, displaySets and whether the protocol is - applied to the viewport or not yet. + applied to the viewport or not yet. This is deprecated as it is expected + to be communicated by events instead. + +- `getProtocols`: Returns a list of the currently active protocols. + +- `getProtocolById`: Gets the protocol with the given id. - `addProtocol`: adds provided protocol to the list of registered protocols - for matching + for matching. Will replacing any protocol with the same id, allowing, for example, + to replace the default protocol. - `setActiveProtocols`: Choose the protocols which are active. Can take a single protocol id or a list. When a single one is provided, that one will be applied whether or not the required rules match. Called automatically on mode init. +- `setActiveStudyUID`: Sets the given study UID as active, which has significance + in terms of the matching rules being able to match against the active study. + - `run({studies, activeStudy, displaySets }, protocolId)`: runs the HPService with the provided studyMetaData and optional protocolId. If protocol is not given, HP Matching engine will search all the registered protocols for the best matching one based on the constraints. +- `registerImageLoadStrategy`: Adds a custom image load strategy. + - `addCustomAttribute`: adding a custom attribute for matching. (see below) - `setProtocol`: applies a protocol to the current studies, it can be used for instance to apply a @@ -68,6 +159,12 @@ init. used for the protocol. If no options are provided, all displaySets will be used to match the protocol. +- `getStageIndex`: Finds the stage index for a given set of match keys. Currently + only works on the currently active protocol, but is supposed to be able to work + with other protocols as well. + +- `getMissingViewport`: Returns a viewport object to be used as the missing + viewport instance. This is used to fill out new viewports. Default initialization of the modes handles running the `HangingProtocolService` @@ -78,7 +175,7 @@ do not overlap, with the suggested id being `${moduleId}.${simpleName}`. The 'default' name is used as the hanging protocol id when no other protocol applies, and can be set as the last module listed containing 'default'. -A hanging protocol can also be defined with a generator. +A hanging protocol can also be defined with a generator. A generator is a function we can write this way: ```ts @@ -93,6 +190,33 @@ function protocolGenerator({ servicesManager, commandsManager }) { See the typescript definitions for more details on the structure of protocols. +## Additional viewports for layout - `defaultViewport` +Sometimes the user manually selects a layout of a given size, say `2x3`. The +hanging protocol can define what viewport options to use for this viewport by +defining an extra viewport option in `defaultViewport`. For example: + +```javascript + defaultViewport: { + viewportOptions: { + viewportType: 'stack', + toolGroupId: 'default', + allowUnmatchedView: true, + }, + displaySets: [ + { + id: 'defaultDisplaySetId', + matchedDisplaySetsIndex: -1, + }, + ], + }, +``` + +This allows defining the type of additional viewports, what tool group etc they +are allowed in, and which display set is used to fill them. In the above case, +the display set is the same as the other viewports, but the +`matchedDisplaySetsIndex=-1`, so that means find the next matching display set +from the display set selector which isn't already filling a view. + ## Custom Attribute In some situations, you might want to match based on a custom attribute and not the DICOM tags. For instance, if you have assigned a `timepointId` to each study, and you want to match based on it. @@ -102,7 +226,7 @@ There are various ways that you can let `HangingProtocolService` know of you custom attribute. We will show how to add it inside the mode configuration. ```js -const deafultProtocol = { +const defaultProtocol = { id: 'defaultProtocol', /** ... **/ protocolMatchingRules: [ diff --git a/platform/docs/docs/platform/services/data/StateSyncService.md b/platform/docs/docs/platform/services/data/StateSyncService.md new file mode 100644 index 000000000..98d9e82ad --- /dev/null +++ b/platform/docs/docs/platform/services/data/StateSyncService.md @@ -0,0 +1,72 @@ +--- +sidebar_position: 8 +sidebar_label: State Sync Service +--- + +# State Sync Service + +## Overview +The state sync service is designed to allow short and long term memory of things such as +annotations applied, last annotation state, hanging protocol viewport state, +window level etc. This allows for better interaction with things like navigation +between hanging protocols, ensuring that the previously displayed layouts +can be redisplayed after returning to a given hanging protocol. + +Currently, all the state sync service configurations have one of the following two +lifetimes. See the mode description for general information on the mode lifetime. + +* Application load - when the application is restarted, the state is lost +* `clearOnModeExit` - which stores state until the mode onModeExit is called, and then throws away the remaining state. This is useful for mode specific information. + +### TODO work - add more storage locations +It is expected to add a few more storage locations, which will store to various +locations on updates: + +* User specific server store - to store things between application restarts at the user level +* Browser state store - to store things in the browser local state, to recover after crashing. +* Study specific server store - to store things relevant to a given study between application restarts, on the server. + +## Events + +Currently the service does not fire events. + +## API + +- `register`: to create a new named state storage +- `reduce`: to apply a set of changes to several states at once +- `getState`: to retrieve the current state +- `onModeExit`: clears the states configured as clearOnModeExit states + +### register +The register call is typically added to an extension to create a new +syncable state. A typical call is shown below, registering the viewport +grid store state as a modal state. + +```javascript + stateSyncService.register('viewportGridStore', { clearOnModeExit: true }); +``` + +### getState +The `getState` call returns an object containing all of the reigstered states, +by id. The values can be read directly, but should not be modified. + +### reduce +The `reduce` call is used to apply a set of updates to various states. The +updates are performed for every state as a simply "set" call. + +### onModeExit +When the Mode is exited, the onModeExit is called on the sync state, and this +clears all states registered with `clearOnModeExit: true`. +To avoid clearing the state, the mode definition should store any transient +state in the mode onModeExit and recover it in the `mode.onModeEnter`. + +## OHIF Registered State +There are a number of defined states here. It is recommended to update this +list as states are added: + +* `viewportGridStore` has viewport grid restore information for returning to an earlier grid layout. +* `reuseIdMap` has a map of names to display sets for preserving user changes to hp display set selections. +* `hanging` has a map of the hanging protocol stage information applied (HPInfo) +* `presentationSync` has the cornerstone presentation state information +* `toggleHangingProtocol` has the previously applied hanging protocol, to toggle an HP off. +* `querySync` has the previously applied query information. Not fully implemented yet. diff --git a/platform/docs/docs/platform/services/data/ToolbarService.md b/platform/docs/docs/platform/services/data/ToolbarService.md index 18e7b7afb..f92cc385f 100644 --- a/platform/docs/docs/platform/services/data/ToolbarService.md +++ b/platform/docs/docs/platform/services/data/ToolbarService.md @@ -33,6 +33,7 @@ button is clicked by the user. presets. - `commandName`: if tool has a command attached to run - `commandOptions`: arguments for the command. + - `setActive`: Sets a given tool active (not as primary but as secondary) - `reset`: reset the state of the toolbarService, set the primary tool to be `Wwwc` and unsubscribe tools that have registered their functions. diff --git a/platform/docs/docs/platform/services/data/index.md b/platform/docs/docs/platform/services/data/index.md index ee3ac507e..cdeb44c77 100644 --- a/platform/docs/docs/platform/services/data/index.md +++ b/platform/docs/docs/platform/services/data/index.md @@ -19,7 +19,8 @@ We maintain the following non-ui Services: - [Hanging Protocol Service](../data/HangingProtocolService.md) - [Toolbar Service](../data/ToolBarService.md) - [Measurement Service](../data/MeasurementService.md) -- [Customization Service](customization-service.md) +- [Customization Service](../data/customization-service.md) +- [State Sync Service](../data/StateSyncService.md) - [Panel Service](../data/PanelService.md) ## Service Architecture diff --git a/platform/ui/src/components/SplitButton/SplitButton.tsx b/platform/ui/src/components/SplitButton/SplitButton.tsx index 1aec782d5..e991b12cc 100644 --- a/platform/ui/src/components/SplitButton/SplitButton.tsx +++ b/platform/ui/src/components/SplitButton/SplitButton.tsx @@ -168,9 +168,11 @@ const SplitButton = ({ : 'text-common-bright hover:bg-primary-dark hover:text-primary-light' )} > - - - + {icon && ( + + + + )} {t(label)} ); diff --git a/platform/ui/src/components/Thumbnail/Thumbnail.tsx b/platform/ui/src/components/Thumbnail/Thumbnail.tsx index 73dcc67e4..23b080c20 100644 --- a/platform/ui/src/components/Thumbnail/Thumbnail.tsx +++ b/platform/ui/src/components/Thumbnail/Thumbnail.tsx @@ -6,7 +6,7 @@ import { Icon } from '../'; import { StringNumber } from '../../types'; /** - * + * Display a thumbnail for a display set. */ const Thumbnail = ({ displaySetInstanceUID, @@ -16,11 +16,12 @@ const Thumbnail = ({ description, seriesNumber, numInstances, + countIcon, dragData, isActive, onClick, onDoubleClick, -}) => { +}): React.ReactNode => { // TODO: We should wrap our thumbnail to create a "DraggableThumbnail", as // this will still allow for "drag", even if there is no drop target for the // specified item. @@ -73,7 +74,8 @@ const Thumbnail = ({ {seriesNumber}
- {numInstances} + + {` ${numInstances}`}
{description}
diff --git a/platform/ui/src/components/ThumbnailList/ThumbnailList.tsx b/platform/ui/src/components/ThumbnailList/ThumbnailList.tsx index f739cf281..df2047cad 100644 --- a/platform/ui/src/components/ThumbnailList/ThumbnailList.tsx +++ b/platform/ui/src/components/ThumbnailList/ThumbnailList.tsx @@ -23,6 +23,7 @@ const ThumbnailList = ({ modality, componentType, seriesDate, + countIcon, viewportIdentificator, isTracked, canReject, @@ -33,7 +34,6 @@ const ThumbnailList = ({ const isActive = activeDisplaySetInstanceUIDs.includes( displaySetInstanceUID ); - switch (componentType) { case 'thumbnail': return ( @@ -44,6 +44,7 @@ const ThumbnailList = ({ description={description} seriesNumber={seriesNumber} numInstances={numInstances} + countIcon={countIcon} imageSrc={imageSrc} imageAltText={imageAltText} viewportIdentificator={viewportIdentificator} @@ -63,6 +64,7 @@ const ThumbnailList = ({ description={description} seriesNumber={seriesNumber} numInstances={numInstances} + countIcon={countIcon} imageSrc={imageSrc} imageAltText={imageAltText} viewportIdentificator={viewportIdentificator} diff --git a/platform/ui/src/components/ThumbnailTracked/ThumbnailTracked.tsx b/platform/ui/src/components/ThumbnailTracked/ThumbnailTracked.tsx index 1c750c353..060b6c664 100644 --- a/platform/ui/src/components/ThumbnailTracked/ThumbnailTracked.tsx +++ b/platform/ui/src/components/ThumbnailTracked/ThumbnailTracked.tsx @@ -13,6 +13,7 @@ const ThumbnailTracked = ({ description, seriesNumber, numInstances, + countIcon, dragData, onClick, onDoubleClick, @@ -117,6 +118,7 @@ const ThumbnailTracked = ({ description={description} seriesNumber={seriesNumber} numInstances={numInstances} + countIcon={countIcon} isActive={isActive} onClick={onClick} onDoubleClick={onDoubleClick} diff --git a/platform/ui/src/components/ToolbarButton/ToolbarButton.tsx b/platform/ui/src/components/ToolbarButton/ToolbarButton.tsx index 5e9a2c141..b8dcee0e4 100644 --- a/platform/ui/src/components/ToolbarButton/ToolbarButton.tsx +++ b/platform/ui/src/components/ToolbarButton/ToolbarButton.tsx @@ -39,6 +39,11 @@ const ToolbarButton = ({ const activeClass = isActive ? 'active' : ''; const shouldShowDropdown = !!isActive && !!dropdownContent; + const iconEl = icon ? ( + + ) : ( +
{label || 'Missing icon and label'}
+ ); return (
@@ -64,7 +69,7 @@ const ToolbarButton = ({ id={id} {...rest} > - + {iconEl}
diff --git a/platform/ui/src/components/Viewport/Viewport.tsx b/platform/ui/src/components/Viewport/Viewport.tsx index a9576c1c9..bcd2f5a2e 100644 --- a/platform/ui/src/components/Viewport/Viewport.tsx +++ b/platform/ui/src/components/Viewport/Viewport.tsx @@ -2,7 +2,16 @@ import React from 'react'; import PropTypes from 'prop-types'; import { LegacyViewportActionBar, Notification } from '../'; -const Viewport = ({ viewportIndex, onArrowsClick, studyData, children }) => { +const Viewport = ({ + viewportId, + viewportIndex, + onArrowsClick, + studyData, + children, +}) => { + if (!viewportId) { + viewportId = `viewport-${viewportIndex}`; + } return (
@@ -39,7 +48,7 @@ const Viewport = ({ viewportIndex, onArrowsClick, studyData, children }) => {
{/* STUDY IMAGE */} -
+
{children}
diff --git a/platform/ui/src/contextProviders/ViewportGridProvider.tsx b/platform/ui/src/contextProviders/ViewportGridProvider.tsx index 80a1647d9..b11f1d301 100644 --- a/platform/ui/src/contextProviders/ViewportGridProvider.tsx +++ b/platform/ui/src/contextProviders/ViewportGridProvider.tsx @@ -6,17 +6,22 @@ import React, { useReducer, } from 'react'; import PropTypes from 'prop-types'; - +import isEqual from 'lodash.isequal'; import viewportLabels from '../utils/viewportLabels'; +import getPresentationId from './getPresentationId'; const DEFAULT_STATE = { - numRows: null, - numCols: null, - layoutType: 'grid', + activeViewportIndex: 0, + layout: { + numRows: 0, + numCols: 0, + layoutType: 'grid', + }, viewports: [ { displaySetInstanceUIDs: [], viewportOptions: {}, + displaySetSelectors: [], displaySetOptions: [{}], x: 0, // left y: 0, // top @@ -25,20 +30,58 @@ const DEFAULT_STATE = { viewportLabel: null, }, ], - activeViewportIndex: 0, - cachedLayout: {}, }; export const ViewportGridContext = createContext(DEFAULT_STATE); /** - * Given the flatten index, and rows and column, it returns the - * row and column index + * Find a viewport to re-use, and then set the viewportId + * + * @param idSet + * @param viewport + * @param stateViewports + * @returns */ -const unravelIndex = (index, numRows, numCols) => { - const row = Math.floor(index / numCols); - const col = index % numCols; - return { row, col }; +const reuseViewport = (idSet, viewport, stateViewports) => { + const oldIds = {}; + for (const oldViewport of stateViewports) { + const { viewportId: oldId } = oldViewport; + oldIds[oldId] = true; + if (!oldId || idSet[oldId]) continue; + if ( + !isEqual( + oldViewport.displaySetInstanceUIDs, + viewport.displaySetInstanceUIDs + ) + ) { + continue; + } + idSet[oldId] = true; + // TODO re-use viewports once the flickering/wrong size redraw is fixed + // return { + // ...oldViewport, + // ...viewport, + // viewportOptions: { + // ...oldViewport.viewportOptions, + + // viewportId: oldViewport.viewportId, + // }, + // }; + } + // Find a viewport instance number different from earlier viewports having + // the same presentationId as this one would - will be less than 10k + // viewports hopefully :-) + for (let i = 0; i < 10000; i++) { + const viewportId = 'viewport-' + i; + if (idSet[viewportId] || oldIds[viewportId]) continue; + idSet[viewportId] = true; + return { + ...viewport, + viewportId, + viewportOptions: { ...viewport.viewportOptions, viewportId }, + }; + } + throw new Error('No ID found'); }; export function ViewportGridProvider({ children, service }) { @@ -59,26 +102,40 @@ export function ViewportGridProvider({ children, service }) { // which might have been a PDF Viewport. The viewport itself // will deal with inheritance if required. Here is just a simple // provider. - const viewportOptions = payload.viewportOptions || {}; - const displaySetOptions = payload.displaySetOptions || [{}]; + const viewport = state.viewports[viewportIndex] || {}; + const viewportOptions = { ...payload.viewportOptions }; + + const displaySetOptions = payload.displaySetOptions || []; + if (displaySetOptions.length === 0) { + // Only copy index 0, as that is all that is currently supported by this + // method call. + displaySetOptions.push({ ...viewport.displaySetOptions?.[0] }); + } const viewports = state.viewports.slice(); - if (!viewportOptions.viewportId) { - viewportOptions.viewportId = `viewport-${viewportIndex}`; - } - - // merge the displaySetOptions and viewportOptions and displaySetInstanceUIDs - // into the viewport object at the given index - viewports[viewportIndex] = { - ...viewports[viewportIndex], + let newView = { + ...viewport, displaySetInstanceUIDs, viewportOptions, displaySetOptions, viewportLabel: viewportLabels[viewportIndex], }; + viewportOptions.presentationId = getPresentationId(newView, viewports); - return { ...state, ...{ viewports } }; + // Make sure we assign a viewport id + newView = reuseViewport({}, newView, state.viewports); + console.log( + 'Creating new viewport', + viewportIndex, + newView.viewportOptions.viewportId, + displaySetInstanceUIDs, + displaySetOptions + ); + + viewports[viewportIndex] = newView; + + return { ...state, viewports }; } case 'SET_LAYOUT': { const { @@ -86,108 +143,96 @@ export function ViewportGridProvider({ children, service }) { numRows, layoutOptions, layoutType = 'grid', - keepExtraViewports = false, + findOrCreateViewport, } = action.payload; // If empty viewportOptions, we use numRow and numCols to calculate number of viewports - const numPanes = layoutOptions.length || numRows * numCols; - const viewports = state.viewports.slice(); - const activeViewportIndex = - state.activeViewportIndex >= numPanes ? 0 : state.activeViewportIndex; + const hasOptions = layoutOptions?.length; + const viewports = []; - while (viewports.length < numPanes) { - viewports.push({}); - } + // Options is a temporary state store which can be used by the + // findOrCreate to store state about already found viewports. Typically, + // it will be used to store the display set UID's which are already + // in view so that the find or create can decide which display sets + // haven't been viewed yet, and add them in the appropriate order. + const options = {}; - // Extra viewports are kept when the grid layout is changed in the UI - // because the user populated those viewports and if the viewports were to - // return on screen their contents should be maintained. - if (!keepExtraViewports) { - while (viewports.length > numPanes) { - viewports.pop(); + let activeViewportIndex; + for (let row = 0; row < numRows; row++) { + for (let col = 0; col < numCols; col++) { + const pos = col + row * numCols; + const layoutOption = layoutOptions[pos]; + const positionId = layoutOption?.positionId || `${col}-${row}`; + if (hasOptions && pos >= layoutOptions.length) { + continue; + } + if ( + !activeViewportIndex || + state.viewports[pos]?.positionId === positionId + ) { + activeViewportIndex = pos; + } + const viewport = findOrCreateViewport(pos, positionId, options); + if (!viewport) continue; + viewport.positionId = positionId; + // Create a new viewport object as it is getting updated here + // and it is part of the read only state + viewports.push(viewport); + let xPos, yPos, w, h; + + if (layoutOptions && layoutOptions[pos]) { + ({ x: xPos, y: yPos, width: w, height: h } = layoutOptions[pos]); + } else { + w = 1 / numCols; + h = 1 / numRows; + xPos = col * w; + yPos = row * h; + } + + viewport.width = w; + viewport.height = h; + viewport.x = xPos; + viewport.y = yPos; } } - for (let i = 0; i < numPanes; i++) { - let xPos, yPos, w, h; - - if (layoutOptions && layoutOptions[i]) { - ({ x: xPos, y: yPos, width: w, height: h } = layoutOptions[i]); - } else { - const { row, col } = unravelIndex(i, numRows, numCols); - w = 1 / numCols; - h = 1 / numRows; - xPos = col * w; - yPos = row * h; + const viewportIdSet = {}; + for ( + let viewportIndex = 0; + viewportIndex < viewports.length; + viewportIndex++ + ) { + const viewport = reuseViewport( + viewportIdSet, + viewports[viewportIndex], + state.viewports + ); + if (!viewport.viewportOptions.presentationId) { + viewport.viewportOptions.presentationId = getPresentationId( + viewport, + viewports + ); } - - viewports[i].width = w; - viewports[i].height = h; - viewports[i].x = xPos; - viewports[i].y = yPos; + viewport.viewportIndex = viewportIndex; + viewport.viewportLabel = viewportLabels[viewportIndex]; + viewports[viewportIndex] = viewport; } - return { + const ret = { ...state, - ...{ - activeViewportIndex, + activeViewportIndex, + layout: { + ...state.layout, numCols, numRows, layoutType, - viewports, }, + viewports, }; + return ret; } case 'RESET': { - return { - numCols: null, - numRows: null, - layoutType: 'grid', - activeViewportIndex: 0, - viewports: [ - { - displaySetInstanceUIDs: [], - displaySetOptions: [], - viewportOptions: {}, - x: 0, // left - y: 0, // top - width: 100, - height: 100, - }, - ], - cachedLayout: {}, - }; - } - - // The SET_CACHE_LAYOUT action can be used for caching a layout - // for instance double clicking a viewport to maximize it. - // and then restoring the previous layout when the viewport is - // double clicked again. - case 'SET_CACHED_LAYOUT': { - const { cacheId, cachedLayout } = action.payload; - - // deep copy the cachedLayout into the state - return { - ...state, - cachedLayout: { - ...state.cachedLayout, - [cacheId]: JSON.parse(JSON.stringify(cachedLayout)), - }, - }; - } - - case 'RESTORE_CACHED_LAYOUT': { - const cacheId = action.payload; - - if (!state.cachedLayout[cacheId]) { - console.warn( - `No cached layout found for cacheId: ${cacheId}. Ignoring...` - ); - return state; - } - - const cachedLayout = state.cachedLayout; - return { ...state.cachedLayout[cacheId], cachedLayout }; + return DEFAULT_STATE; } case 'SET': { @@ -221,6 +266,7 @@ export function ViewportGridProvider({ children, service }) { viewportIndex, displaySetInstanceUIDs, viewportOptions, + displaySetSelectors, displaySetOptions, }) => dispatch({ @@ -229,6 +275,7 @@ export function ViewportGridProvider({ children, service }) { viewportIndex, displaySetInstanceUIDs, viewportOptions, + displaySetSelectors, displaySetOptions, }, }), @@ -250,7 +297,7 @@ export function ViewportGridProvider({ children, service }) { numRows, numCols, layoutOptions = [], - keepExtraViewports = false, + findOrCreateViewport, }) => dispatch({ type: 'SET_LAYOUT', @@ -259,7 +306,7 @@ export function ViewportGridProvider({ children, service }) { numRows, numCols, layoutOptions, - keepExtraViewports, + findOrCreateViewport, }, }), [dispatch] @@ -274,25 +321,6 @@ export function ViewportGridProvider({ children, service }) { [dispatch] ); - const setCachedLayout = useCallback( - payload => - dispatch({ - type: 'SET_CACHED_LAYOUT', - payload, - }), - [dispatch] - ); - - const restoreCachedLayout = useCallback( - cacheId => { - dispatch({ - type: 'RESTORE_CACHED_LAYOUT', - payload: cacheId, - }); - }, - [dispatch] - ); - const set = useCallback( payload => dispatch({ @@ -303,7 +331,8 @@ export function ViewportGridProvider({ children, service }) { ); const getNumViewportPanes = useCallback(() => { - const { numCols, numRows, viewports } = viewportGridState; + const { layout, viewports } = viewportGridState; + const { numRows, numCols } = layout; return Math.min(viewports.length, numCols * numRows); }, [viewportGridState]); @@ -322,8 +351,6 @@ export function ViewportGridProvider({ children, service }) { setLayout, reset, onModeExit: reset, - setCachedLayout, - restoreCachedLayout, set, getNumViewportPanes, }); @@ -336,8 +363,6 @@ export function ViewportGridProvider({ children, service }) { setDisplaySetsForViewports, setLayout, reset, - setCachedLayout, - restoreCachedLayout, set, getNumViewportPanes, ]); @@ -348,8 +373,6 @@ export function ViewportGridProvider({ children, service }) { setDisplaySetsForViewport, setDisplaySetsForViewports, setLayout, - setCachedLayout, - restoreCachedLayout, reset, set, getNumViewportPanes, diff --git a/platform/ui/src/contextProviders/getPresentationId.ts b/platform/ui/src/contextProviders/getPresentationId.ts new file mode 100644 index 000000000..7b41eaf8a --- /dev/null +++ b/platform/ui/src/contextProviders/getPresentationId.ts @@ -0,0 +1,73 @@ +/** + * Selects a presentation ID to use for this viewport. + * This is done to allow the same display set to be displayed more than once + * on screen, with different attributes such as window level and initial position. + * Then, when redisplaying that, the nearest/most common attribute is re-used. + * + * For example, for display set , in a viewport of type volume, + * the generated presentationID might be + * `volume:axial:`. This can then be used to store and retrieve + * presentation information in state sync service 'presentationSync' state. + * + * The generated value attempts to generate a unique value for every type + * of viewport which should have it's own presentation information. Thus, the + * following values are used for presentation ID: + * + * 1. viewportType - since the presentation information for a volume is different than for a stack + * 2. orientation - since the camera is different for different orientations + * 3. display set instance UID - since different display sets should get displayed differently + * 4. instance count - since displaying the same series twice should allow applying different window level etc + * + * @param viewport requiring a presentation Id + * @param viewports is the list of viewports being shown. Any presentation ID's + * among them must not be re-used in order to have each viewport have it's own presentation ID. + * @returns Presentation ID id, or undefined if nothing displayed + */ +const getPresentationId = (viewport, viewports): string => { + if (!viewport) return; + const { viewportOptions, displaySetInstanceUIDs } = viewport; + if (!viewportOptions || !displaySetInstanceUIDs?.length) { + console.log('No viewport type or display sets in', viewport); + return; + } + + const viewportType = viewportOptions.viewportType || 'stack'; + const idArr = [viewportType, 0, ...displaySetInstanceUIDs]; + if (viewportOptions.orientation) { + idArr.splice(2, 0, viewportOptions.orientation); + } + + // Allow setting a custom presentation prefix in the hanging protocol + // This allows defining new + // presentation groups to be set automatically when one knows that the + // same display set will be displayed in different ways. + // This is the recommended way to manage a hanging protocol which displays + // multiple views of a single display set, eg to display brain, bone, soft + // tissue views in different viewports. + if (viewportOptions.presentationPrefix) { + idArr.push(viewportOptions.presentationPrefix); + } + if (!viewports) { + console.log('viewports not defined', idArr.join(',')); + return idArr.join('&'); + } + + // 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. + for (let displayInstance = 0; displayInstance < 128; displayInstance++) { + idArr[1] = displayInstance; + const testId = idArr.join('&'); + if (!viewports.find(it => it.viewportOptions?.presentationId === testId)) { + break; + } + } + const id = idArr.join('&'); + return id; +}; + +export default getPresentationId; diff --git a/platform/viewer/cypress/integration/customization/HangingProtocol.spec.js b/platform/viewer/cypress/integration/customization/HangingProtocol.spec.js new file mode 100644 index 000000000..c7b8a8417 --- /dev/null +++ b/platform/viewer/cypress/integration/customization/HangingProtocol.spec.js @@ -0,0 +1,31 @@ +describe('OHIF HP', () => { + beforeEach(() => { + cy.checkStudyRouteInViewer( + '1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1', + '&hangingProtocolId=@ohif/hp-extension.mn' + ); + cy.expectMinimumThumbnails(3); + cy.initCornerstoneToolsAliases(); + cy.initCommonElementsAliases(); + }); + + it('Should display 3 up', () => { + cy.get('[data-cy="viewport-pane"]') + .its('length') + .should('be.eq', 3); + }); + + it('Should navigate next/previous stage', () => { + cy.get('body').type(','); + cy.wait(250); + cy.get('[data-cy="viewport-pane"]') + .its('length') + .should('be.eq', 4); + + cy.get('body').type('..'); + cy.wait(250); + cy.get('[data-cy="viewport-pane"]') + .its('length') + .should('be.eq', 2); + }); +}); diff --git a/platform/viewer/cypress/integration/measurement-tracking/OHIFStudyBrowser.spec.js b/platform/viewer/cypress/integration/measurement-tracking/OHIFStudyBrowser.spec.js index 764413acc..8264c9635 100644 --- a/platform/viewer/cypress/integration/measurement-tracking/OHIFStudyBrowser.spec.js +++ b/platform/viewer/cypress/integration/measurement-tracking/OHIFStudyBrowser.spec.js @@ -17,8 +17,21 @@ describe('OHIF Study Viewer Page', function() { }); it('drags and drop a series thumbnail into viewport', function() { - cy.get('[data-cy="study-browser-thumbnail"]:nth-child(2)') //element to be dragged - .drag('.cornerstone-canvas'); //dropzone element + // Can't use the native drag version as the element should be rerendered + // cy.get('[data-cy="study-browser-thumbnail"]:nth-child(2)') //element to be dragged + // .drag('.cornerstone-canvas'); //dropzone element + + const dataTransfer = new DataTransfer(); + + cy.get('[data-cy="study-browser-thumbnail"]:nth-child(2)') + .first() + .trigger('mousedown', { which: 1, button: 0 }) + .trigger('dragstart', { dataTransfer }) + .trigger('drag', {}); + cy.get('.cornerstone-canvas') + .trigger('mousemove', 'center') + .trigger('dragover', { dataTransfer, force: true }) + .trigger('drop', { dataTransfer, force: true }); //const expectedText = // 'Ser: 2Img: 1 1/13512 x 512Loc: -17.60 mm Thick: 3.00 mm'; diff --git a/platform/viewer/cypress/support/commands.js b/platform/viewer/cypress/support/commands.js index eadd001bb..df760db10 100644 --- a/platform/viewer/cypress/support/commands.js +++ b/platform/viewer/cypress/support/commands.js @@ -54,20 +54,29 @@ Cypress.Commands.add('openStudy', PatientName => { .click({ force: true }); }); -Cypress.Commands.add('checkStudyRouteInViewer', StudyInstanceUID => { - cy.location('pathname').then($url => { - cy.log($url); - if ($url == 'blank' || !$url.includes(`/basic-test/${StudyInstanceUID}`)) { - cy.openStudyInViewer(StudyInstanceUID); - cy.waitDicomImage(); - cy.wait(2000); - } - }); -}); +Cypress.Commands.add( + 'checkStudyRouteInViewer', + (StudyInstanceUID, otherParams = '') => { + cy.location('pathname').then($url => { + cy.log($url); + if ( + $url == 'blank' || + !$url.includes(`/basic-test/${StudyInstanceUID}${otherParams}`) + ) { + cy.openStudyInViewer(StudyInstanceUID, otherParams); + cy.waitDicomImage(); + cy.wait(2000); + } + }); + } +); -Cypress.Commands.add('openStudyInViewer', StudyInstanceUID => { - cy.visit(`/basic-test?StudyInstanceUIDs=${StudyInstanceUID}`); -}); +Cypress.Commands.add( + 'openStudyInViewer', + (StudyInstanceUID, otherParams = '') => { + cy.visit(`/basic-test?StudyInstanceUIDs=${StudyInstanceUID}${otherParams}`); + } +); /** * Command to search for a Modality and open the study. diff --git a/platform/viewer/public/config/multiple.js b/platform/viewer/public/config/multiple.js index ca5c9d848..7179bb74b 100644 --- a/platform/viewer/public/config/multiple.js +++ b/platform/viewer/public/config/multiple.js @@ -1,4 +1,7 @@ window.config = { + // Activate the new HP mode.... + isNewHP: true, + routerBasename: '/', customizationService: [ '@ohif/extension-default.customizationModule.datasources', @@ -59,25 +62,6 @@ window.config = { singlepart: 'bulkdata,video,pdf', }, }, - { - friendlyName: 'dcmjs DICOMWeb Server', - namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', - sourceName: 'shared', - configuration: { - name: 'shared', - qidoRoot: 'https://domvja9iplmyu.cloudfront.net/dicomweb', - wadoRoot: 'https://domvja9iplmyu.cloudfront.net/dicomweb', - qidoSupportsIncludeField: false, - supportsReject: false, - imageRendering: 'wadors', - thumbnailRendering: 'wadors', - enableStudyLazyLoad: true, - supportsFuzzyMatching: false, - supportsWildcard: true, - staticWado: true, - singlepart: 'bulkdata,video,pdf', - }, - }, { friendlyName: 'E2E Test Data', namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', @@ -122,92 +106,7 @@ window.config = { console.warn('test, navigate to https://ohif.org/'); }, defaultDataSourceName: 'default', - hotkeys: [ - { - commandName: 'incrementActiveViewport', - label: 'Next Viewport', - keys: ['right'], - }, - { - commandName: 'decrementActiveViewport', - label: 'Previous Viewport', - keys: ['left'], - }, - { commandName: 'rotateViewportCW', label: 'Rotate Right', keys: ['r'] }, - { commandName: 'rotateViewportCCW', label: 'Rotate Left', keys: ['l'] }, - { commandName: 'invertViewport', label: 'Invert', keys: ['i'] }, - { - commandName: 'flipViewportHorizontal', - label: 'Flip Horizontally', - keys: ['h'], - }, - { - commandName: 'flipViewportVertical', - label: 'Flip Vertically', - keys: ['v'], - }, - { commandName: 'scaleUpViewport', label: 'Zoom In', keys: ['+'] }, - { commandName: 'scaleDownViewport', label: 'Zoom Out', keys: ['-'] }, - { commandName: 'fitViewportToWindow', label: 'Zoom to Fit', keys: ['='] }, - { commandName: 'resetViewport', label: 'Reset', keys: ['space'] }, - { commandName: 'nextImage', label: 'Next Image', keys: ['down'] }, - { commandName: 'previousImage', label: 'Previous Image', keys: ['up'] }, - // { - // commandName: 'previousViewportDisplaySet', - // label: 'Previous Series', - // keys: ['pagedown'], - // }, - // { - // commandName: 'nextViewportDisplaySet', - // label: 'Next Series', - // keys: ['pageup'], - // }, - { commandName: 'setZoomTool', label: 'Zoom', keys: ['z'] }, - // ~ Window level presets - { - commandName: 'windowLevelPreset1', - label: 'W/L Preset 1', - keys: ['1'], - }, - { - commandName: 'windowLevelPreset2', - label: 'W/L Preset 2', - keys: ['2'], - }, - { - commandName: 'windowLevelPreset3', - label: 'W/L Preset 3', - keys: ['3'], - }, - { - commandName: 'windowLevelPreset4', - label: 'W/L Preset 4', - keys: ['4'], - }, - { - commandName: 'windowLevelPreset5', - label: 'W/L Preset 5', - keys: ['5'], - }, - { - commandName: 'windowLevelPreset6', - label: 'W/L Preset 6', - keys: ['6'], - }, - { - commandName: 'windowLevelPreset7', - label: 'W/L Preset 7', - keys: ['7'], - }, - { - commandName: 'windowLevelPreset8', - label: 'W/L Preset 8', - keys: ['8'], - }, - { - commandName: 'windowLevelPreset9', - label: 'W/L Preset 9', - keys: ['9'], - }, - ], + + // Only list the unique hotkeys + hotkeys: [], }; diff --git a/platform/viewer/src/App.tsx b/platform/viewer/src/App.tsx index f0f6d669d..44c3fd754 100644 --- a/platform/viewer/src/App.tsx +++ b/platform/viewer/src/App.tsx @@ -5,7 +5,12 @@ import i18n from '@ohif/i18n'; import { I18nextProvider } from 'react-i18next'; import { BrowserRouter } from 'react-router-dom'; import Compose from './routes/Mode/Compose'; - +import { + ServicesManager, + ExtensionManager, + CommandsManager, + HotkeysManager, +} from '@ohif/core'; import { DialogProvider, Modal, @@ -24,7 +29,10 @@ import createRoutes from './routes'; import appInit from './appInit.js'; import OpenIdConnectRoutes from './utils/OpenIdConnectRoutes'; -let commandsManager, extensionManager, servicesManager, hotkeysManager; +let commandsManager: CommandsManager, + extensionManager: ExtensionManager, + servicesManager: ServicesManager, + hotkeysManager: HotkeysManager; function App({ config, defaultExtensions, defaultModes }) { const [init, setInit] = useState(null); @@ -59,12 +67,12 @@ function App({ config, defaultExtensions, defaultModes }) { } = appConfigState; const { - UIDialogService, + uiDialogService, uiModalService, - UINotificationService, - UIViewportDialogService, - ViewportGridService, - CineService, + uiNotificationService, + uiViewportDialogService, + viewportGridService, + cineService, userAuthenticationService, customizationService, } = servicesManager.services; @@ -74,11 +82,11 @@ function App({ config, defaultExtensions, defaultModes }) { [UserAuthenticationProvider, { service: userAuthenticationService }], [I18nextProvider, { i18n }], [ThemeWrapper], - [ViewportGridProvider, { service: ViewportGridService }], - [ViewportDialogProvider, { service: UIViewportDialogService }], - [CineProvider, { service: CineService }], - [SnackbarProvider, { service: UINotificationService }], - [DialogProvider, { service: UIDialogService }], + [ViewportGridProvider, { service: viewportGridService }], + [ViewportDialogProvider, { service: uiViewportDialogService }], + [CineProvider, { service: cineService }], + [SnackbarProvider, { service: uiNotificationService }], + [DialogProvider, { service: uiDialogService }], [ModalProvider, { service: uiModalService, modal: Modal }], ]; const CombinedProviders = ({ children }) => diff --git a/platform/viewer/src/appInit.js b/platform/viewer/src/appInit.js index dbe623b6c..3fd86e1ce 100644 --- a/platform/viewer/src/appInit.js +++ b/platform/viewer/src/appInit.js @@ -8,6 +8,7 @@ import { UIDialogService, UIViewportDialogService, MeasurementService, + StateSyncService, DisplaySetService, ToolbarService, ViewportGridService, @@ -60,6 +61,7 @@ async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) { CineService.REGISTRATION, UserAuthenticationService.REGISTRATION, PanelService.REGISTRATION, + StateSyncService.REGISTRATION, ]); errorHandler.getHTTPErrorHandler = () => { diff --git a/platform/viewer/src/components/ViewportGrid.tsx b/platform/viewer/src/components/ViewportGrid.tsx index 0c3d09fb9..70a9ed018 100644 --- a/platform/viewer/src/components/ViewportGrid.tsx +++ b/platform/viewer/src/components/ViewportGrid.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useCallback } from 'react'; import PropTypes from 'prop-types'; +import { ServicesManager } from '@ohif/core'; import { ViewportGrid, ViewportPane, useViewportGrid } from '@ohif/ui'; import { utils } from '@ohif/core'; import EmptyViewport from './EmptyViewport'; @@ -22,11 +23,28 @@ const ORIENTATION_MAP = { }, }; +const createHpInfo = (protocol, stage, activeStudyUID) => { + return { + hangingProtocolId: protocol.id, + stageId: stage.stageId, + stageIdx: protocol.stages.findIndex(it => it === stage), + activeStudyUID, + }; +}; + +const compareViewportOptions = (opts1, opts2) => { + if ((opts1.viewportType || 'stack') != opts2.viewportType) { + return false; + } + return true; +}; + function ViewerViewportGrid(props) { const { servicesManager, viewportComponents, dataSource } = props; const [viewportGrid, viewportGridService] = useViewportGrid(); - const { numCols, numRows, activeViewportIndex, viewports } = viewportGrid; + const { layout, activeViewportIndex, viewports } = viewportGrid; + const { numCols, numRows } = layout; // TODO -> Need some way of selecting which displaySets hit the viewports. const { @@ -34,124 +52,77 @@ function ViewerViewportGrid(props) { measurementService, hangingProtocolService, uiNotificationService, - } = servicesManager.services; + } = (servicesManager as ServicesManager).services; /** - * This callback runs only after displaySets have changed (created and added or modified) + * This callback runs after the viewports structure has changed in any way. + * On initial display, that means if it has changed by applying a HangingProtocol, + * while subsequently it may mean by changing the stage or by manually adjusting + * the layout. + */ - const updateDisplaySetsForViewports = useCallback( - availableDisplaySets => { - if (!availableDisplaySets.length) { + const updateDisplaySetsFromProtocol = ( + protocol, + stage, + activeStudyUID, + viewportMatchDetails + ) => { + const availableDisplaySets = displaySetService.getActiveDisplaySets(); + + if (!availableDisplaySets.length) { + console.log('No available display sets', availableDisplaySets); + return; + } + + // Match each viewport individually + const { layoutType } = stage.viewportStructure; + const stageProps = stage.viewportStructure.properties; + const { columns: numCols, rows: numRows, layoutOptions = [] } = stageProps; + + /** + * This find or create viewport uses the hanging protocol results to + * specify the viewport match details, which specifies the size and + * setup of the various viewports. + */ + const findOrCreateViewport = viewportIndex => { + const details = viewportMatchDetails.get(viewportIndex); + if (!details) { + console.log('No match details for viewport', viewportIndex); return; } - const { - viewportMatchDetails, - hpAlreadyApplied, - } = hangingProtocolService.getMatchDetails(); - - if (!viewportMatchDetails.size) { - return; - } - - const gridDisplaySetUIDs = []; - const blankViewportIndices = []; - - // Match each viewport individually. - const numViewports = viewportGridService.getNumViewportPanes(); - - for ( - let viewportIndex = 0; - viewportIndex < numViewports; - viewportIndex++ - ) { - const viewportDisplaySetUIDs = - viewports[viewportIndex]?.displaySetInstanceUIDs ?? []; - - if (hpAlreadyApplied.get(viewportIndex)) { - gridDisplaySetUIDs.push(...viewportDisplaySetUIDs); - continue; - } - - // if current viewport doesn't have a match - if (viewportMatchDetails.get(viewportIndex) === undefined) { - // if the current viewport is empty/blank - if (viewportDisplaySetUIDs.length === 0) { - blankViewportIndices.push(viewportIndex); - } else { - gridDisplaySetUIDs.push(...viewportDisplaySetUIDs); - } - - continue; - } - - const { displaySetsInfo, viewportOptions } = viewportMatchDetails.get( - viewportIndex - ); - - const displaySetUIDsToHang = []; - const displaySetUIDsToHangOptions = []; - displaySetsInfo.forEach( - ({ displaySetInstanceUID, displaySetOptions }) => { - if (!displaySetInstanceUID) { - return; - } + const { displaySetsInfo, viewportOptions } = details; + const displaySetUIDsToHang = []; + const displaySetUIDsToHangOptions = []; + displaySetsInfo.forEach( + ({ displaySetInstanceUID, displaySetOptions }) => { + if (displaySetInstanceUID) { displaySetUIDsToHang.push(displaySetInstanceUID); - displaySetUIDsToHangOptions.push(displaySetOptions); } - ); - gridDisplaySetUIDs.push(...displaySetUIDsToHang); - - viewportGridService.setDisplaySetsForViewport({ - viewportIndex: viewportIndex, - displaySetInstanceUIDs: displaySetUIDsToHang, - viewportOptions, - displaySetOptions: displaySetUIDsToHangOptions, - }); - - // During setting displaySets for viewport, we need to update the hanging protocol - // but some viewports contain more than one display set (fusion), and their displaySet - // will not be available at the time of setting displaySets for viewport. So we need to - // update the hanging protocol after making sure all the matched display sets are available - // and set on the viewport - if (displaySetUIDsToHang.length === displaySetsInfo.length) { - // The following will set the viewportsDisplaySetsMatched state - - const suppressEvent = true; - const applied = true; - hangingProtocolService.setHangingProtocolAppliedForViewport( - viewportIndex, - applied, - suppressEvent - ); + displaySetUIDsToHangOptions.push(displaySetOptions); } - } + ); - blankViewportIndices.forEach((blankVPIndex: number) => { - // try to fill the empty viewport with a display set not already in the grid - const displaySetsNotInGrid = availableDisplaySets.filter( - displaySet => - gridDisplaySetUIDs.indexOf(displaySet.displaySetInstanceUID) === - -1 && - ['SEG', 'SR', 'RTSTRUCT'].indexOf(displaySet.Modality) === -1 - ); + return { + displaySetInstanceUIDs: displaySetUIDsToHang, + displaySetOptions: displaySetUIDsToHangOptions, + viewportOptions: { + ...viewportOptions, + }, + }; + }; - if (displaySetsNotInGrid.length > 0) { - const displaySetUIDToAdd = - displaySetsNotInGrid[0].displaySetInstanceUID; - gridDisplaySetUIDs.push(displaySetUIDToAdd); - - viewportGridService.setDisplaySetsForViewport({ - viewportIndex: blankVPIndex, - displaySetInstanceUIDs: [displaySetUIDToAdd], - }); - } - }); - }, - [viewportGrid, numRows, numCols] - ); + viewportGridService.setLayout({ + numRows, + numCols, + layoutType, + layoutOptions, + hpInfo: createHpInfo(protocol, stage, activeStudyUID), + findOrCreateViewport, + }); + }; const _getUpdatedViewports = useCallback( (viewportIndex, displaySetInstanceUID) => { @@ -177,22 +148,17 @@ function ViewerViewportGrid(props) { [hangingProtocolService, uiNotificationService] ); - useEffect(() => { - const displaySets = displaySetService.getActiveDisplaySets(); - updateDisplaySetsForViewports(displaySets); - }, [numRows, numCols]); - - // Layout change based on hanging protocols + // Using Hanging protocol engine to match the displaySets useEffect(() => { const { unsubscribe } = hangingProtocolService.subscribe( - hangingProtocolService.EVENTS.NEW_LAYOUT, - ({ layoutType, numRows, numCols, layoutOptions }) => { - viewportGridService.setLayout({ - numRows, - numCols, - layoutType, - layoutOptions, - }); + hangingProtocolService.EVENTS.PROTOCOL_CHANGED, + ({ protocol, stage, activeStudyUID, viewportMatchDetails }) => { + updateDisplaySetsFromProtocol( + protocol, + stage, + activeStudyUID, + viewportMatchDetails + ); } ); @@ -201,35 +167,6 @@ function ViewerViewportGrid(props) { }; }, []); - // Using Hanging protocol engine to match the displaySets - useEffect(() => { - const { unsubscribe } = hangingProtocolService.subscribe( - hangingProtocolService.EVENTS.PROTOCOL_CHANGED, - () => { - const displaySets = displaySetService.getActiveDisplaySets(); - updateDisplaySetsForViewports(displaySets); - } - ); - - return () => { - unsubscribe(); - }; - }, [viewports]); - - useEffect(() => { - const { unsubscribe } = hangingProtocolService.subscribe( - hangingProtocolService.EVENTS.STAGE_CHANGE, - () => { - const displaySets = DisplaySetService.getActiveDisplaySets(); - updateDisplaySetsForViewports(displaySets); - } - ); - - return () => { - unsubscribe(); - }; - }, [viewports]); - useEffect(() => { const { unsubscribe } = measurementService.subscribe( measurementService.EVENTS.JUMP_TO_MEASUREMENT, @@ -366,11 +303,15 @@ function ViewerViewportGrid(props) { const getViewportPanes = useCallback(() => { const viewportPanes = []; - const numViewports = viewportGridService.getNumViewportPanes(); - for (let i = 0; i < numViewports; i++) { + const numViewportPanes = viewportGridService.getNumViewportPanes(); + for (let i = 0; i < numViewportPanes; i++) { const viewportIndex = i; const isActive = activeViewportIndex === viewportIndex; const paneMetadata = viewports[i] || {}; + const viewportId = paneMetadata.viewportId || `viewport-${i}`; + if (!paneMetadata.viewportId) { + paneMetadata.viewportId = viewportId; + } const { displaySetInstanceUIDs, viewportOptions, @@ -420,7 +361,7 @@ function ViewerViewportGrid(props) { viewportPanes[i] = (
1 ? viewportLabel : ''} + viewportLabel={viewports.length > 1 ? viewportLabel : ''} dataSource={dataSource} viewportOptions={viewportOptions} displaySetOptions={displaySetOptions} @@ -456,7 +398,7 @@ function ViewerViewportGrid(props) { }, [viewports, activeViewportIndex, viewportComponents, dataSource]); /** - * Loading indicator until numCols and numRows are gotten from the hangingProtocolService + * Loading indicator until numCols and numRows are gotten from the HangingProtocolService */ if (!numRows || !numCols) { return null; @@ -472,6 +414,7 @@ function ViewerViewportGrid(props) { ViewerViewportGrid.propTypes = { viewportComponents: PropTypes.array.isRequired, + servicesManager: PropTypes.instanceOf(ServicesManager), }; ViewerViewportGrid.defaultProps = { diff --git a/platform/viewer/src/routes/Mode/Mode.tsx b/platform/viewer/src/routes/Mode/Mode.tsx index ad110902c..0eb3f5a0a 100644 --- a/platform/viewer/src/routes/Mode/Mode.tsx +++ b/platform/viewer/src/routes/Mode/Mode.tsx @@ -8,6 +8,7 @@ import { DragAndDropProvider, ImageViewerProvider } from '@ohif/ui'; import { useQuery, useSearchParams } from '@hooks'; import ViewportGrid from '@components/ViewportGrid'; import Compose from './Compose'; +import getStudies from './studiesList'; /** * Initialize the route. @@ -64,21 +65,9 @@ function defaultRouteInit( return; } - const studyMap = {}; + // Gets the studies list to use + const studies = getStudies(studyInstanceUIDs, displaySets); - // Prior studies don't quite work properly yet, but the studies list - // is at least being generated and passed in. - const studies = displaySets.reduce((prev, curr) => { - const { StudyInstanceUID } = curr; - if (!studyMap[StudyInstanceUID]) { - const study = DicomMetadataStore.getStudy(StudyInstanceUID); - studyMap[StudyInstanceUID] = study; - prev.push(study); - } - return prev; - }, []); - - // The assumption is that the display set at position 0 is the first // study being displayed, and is thus the "active" study. const activeStudy = studies[0]; @@ -133,10 +122,8 @@ export default function ModeRoute({ extensionManager.setActiveDataSource(dataSourceName); - const dataSources = extensionManager.getActiveDataSource(); + const dataSource = extensionManager.getActiveDataSource()[0]; - // Only handling one instance of the datasource type (E.g. one DICOMWeb server) - const dataSource = dataSources[0]; // Only handling one route per mode for now const route = mode.routes[0]; diff --git a/platform/viewer/src/routes/Mode/studiesList.ts b/platform/viewer/src/routes/Mode/studiesList.ts new file mode 100644 index 000000000..ff45f8cc0 --- /dev/null +++ b/platform/viewer/src/routes/Mode/studiesList.ts @@ -0,0 +1,65 @@ +import { DicomMetadataStore, Types } from '@ohif/core'; + +type StudyMetadata = Types.StudyMetadata; + +/** + * Compare function for sorting + * + * @param a - some simple value (string, number, timestamp) + * @param b - some simple value + * @param defaultCompare - default return value as a fallback when a===b + * @returns - compare a and b, returning 1 if ab and defaultCompare otherwise + */ +const compare = (a, b, defaultCompare = 0): number => { + if (a === b) return defaultCompare; + if (a < b) return 1; + return -1; +}; + +/** + * The studies from display sets gets the studies in study date + * order or in study instance UID order - not very useful, but + * if not specifically specified then at least making it consistent is useful. + */ +const getStudiesfromDisplaySets = (displaysets): StudyMetadata[] => { + const studyMap = {}; + + const ret = displaySets.reduce((prev, curr) => { + const { StudyInstanceUID } = curr; + if (!studyMap[StudyInstanceUID]) { + const study = DicomMetadataStore.getStudy(StudyInstanceUID); + studyMap[StudyInstanceUID] = study; + prev.push(study); + } + return prev; + }, []); + // Return the sorted studies, first on study date and second on study instance UID + ret.sort((a, b) => { + return compare( + a.StudyDate, + b.StudyDate, + compare(a.StudyInstanceUID, b.StudyInstanceUID) + ); + }); + return ret; +}; + +/** + * The studies retrieve from the Uids is faster and gets the studies + * in the original order, as specified. + */ +const getStudiesFromUIDs = (studyUids: string[]): StudyMetadata[] => { + if (!studyUids?.length) return; + return studyUids.map(uid => DicomMetadataStore.getStudy(uid)); +}; + +/** Gets the array of studies */ +const getStudies = (studyUids?: string[], displaySets): StudyMetadata[] => { + return ( + getStudiesFromUIDs(studyUids) || getStudiesfromDisplaySets(displaySets) + ); +}; + +export default getStudies; + +export { getStudies, getStudiesFromUIDs, getStudiesfromDisplaySets, compare }; diff --git a/platform/viewer/src/routes/WorkList/WorkList.tsx b/platform/viewer/src/routes/WorkList/WorkList.tsx index 2a6912d4b..e2e1fde2c 100644 --- a/platform/viewer/src/routes/WorkList/WorkList.tsx +++ b/platform/viewer/src/routes/WorkList/WorkList.tsx @@ -501,11 +501,9 @@ const defaultFilterValues = { function _tryParseInt(str, defaultValue) { let retValue = defaultValue; - if (str != null) { - if (str.length > 0) { - if (!isNaN(str)) { - retValue = parseInt(str); - } + if (str && str.length > 0) { + if (!isNaN(str)) { + retValue = parseInt(str); } } return retValue; From 544bf55a4fb33400a25929c34ced0d1b95b2e125 Mon Sep 17 00:00:00 2001 From: Edwin Chebii <50355842+edcheyjr@users.noreply.github.com> Date: Wed, 15 Mar 2023 22:44:34 +0300 Subject: [PATCH 02/19] docs: Update sop-class-handler md (#3217) i don't know if it was intentional but should their be a closing square brackets for sopClassUids --- .../docs/docs/platform/extensions/modules/sop-class-handler.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platform/docs/docs/platform/extensions/modules/sop-class-handler.md b/platform/docs/docs/platform/extensions/modules/sop-class-handler.md index eb1b7a464..607ac00e2 100644 --- a/platform/docs/docs/platform/extensions/modules/sop-class-handler.md +++ b/platform/docs/docs/platform/extensions/modules/sop-class-handler.md @@ -53,7 +53,7 @@ const sopClassDictionary = { const sopClassUids = [ sopClassDictionary.CTImageStorage, sopClassDictionary.MRImageStorage, -; +]; const makeDisplaySet = (instances) => { const instance = instances[0]; From ee4e8a4105aa7a1c6f2cf661c098ae649376b710 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Mon, 20 Mar 2023 15:11:21 -0400 Subject: [PATCH 03/19] fix: Hanging protocol state fixes (#3242) * fix: Some residual issues with hanging protocol after state sync fix: Some issues introduced by the state syncing changes * fix: PR fixes, mostly code cleanup * Improve a race condition in an automated test * Remove obsolete code * PR fixes --- .../src/Viewport/OHIFCornerstoneViewport.tsx | 47 +++++-- extensions/cornerstone/src/init.tsx | 12 +- .../SegmentationService.ts | 2 +- .../CornerstoneViewportService.ts | 65 ++++++---- .../src/services/ViewportService/Viewport.ts | 20 ++- .../src/tools/CalibrationLineTool.ts | 5 +- .../cornerstone/src/types/Presentation.ts | 30 ++--- extensions/default/src/commandsModule.ts | 4 +- .../default/src/utils/reuseCachedLayouts.ts | 2 +- extensions/test-extension/src/hp/hpMN.ts | 2 - .../StateSyncService/StateSyncService.ts | 6 + platform/core/src/types/HangingProtocol.ts | 4 +- .../services/data/StateSyncService.md | 15 ++- .../contextProviders/ViewportGridProvider.tsx | 13 +- .../src/contextProviders/getPresentationId.ts | 73 ----------- .../contextProviders/getPresentationIds.ts | 120 ++++++++++++++++++ platform/ui/src/types/index.ts | 3 +- .../OHIFStudyBrowser.spec.js | 2 + .../viewer/src/components/ViewportGrid.tsx | 14 +- 19 files changed, 268 insertions(+), 171 deletions(-) delete mode 100644 platform/ui/src/contextProviders/getPresentationId.ts create mode 100644 platform/ui/src/contextProviders/getPresentationIds.ts diff --git a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx index 72c41eeae..411e17a10 100644 --- a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx +++ b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx @@ -220,15 +220,27 @@ const OHIFCornerstoneViewport = React.memo(props => { const currentPresentation = cornerstoneViewportService.getPresentation( viewportIndex ); - const { presentationSync } = stateSyncService.getState(); - if (currentPresentation) { - stateSyncService.store({ - presentationSync: { - ...presentationSync, - [currentPresentation.id]: currentPresentation, - }, - }); + if (!currentPresentation || !currentPresentation.presentationIds) return; + const { + lutPresentationStore, + positionPresentationStore, + } = stateSyncService.getState(); + const { presentationIds } = currentPresentation; + const { lutPresentationId, positionPresentationId } = presentationIds || {}; + const storeState = {}; + if (lutPresentationId) { + storeState.lutPresentationStore = { + ...lutPresentationStore, + [lutPresentationId]: currentPresentation, + }; } + if (positionPresentationId) { + storeState.positionPresentationStore = { + ...positionPresentationStore, + [positionPresentationId]: currentPresentation, + }; + } + stateSyncService.store(storeState); }; const cleanUpServices = useCallback(() => { @@ -384,18 +396,25 @@ const OHIFCornerstoneViewport = React.memo(props => { storePresentation(); - const { presentationSync } = stateSyncService.getState(); - const { presentationId } = viewportOptions; - const presentation = presentationId - ? (presentationSync[presentationId] as Presentation) - : null; + const { + lutPresentationStore, + positionPresentationStore, + } = stateSyncService.getState(); + const { presentationIds } = viewportOptions; + const presentations = { + positionPresentation: + positionPresentationStore[presentationIds?.positionPresentationId], + lutPresentation: + lutPresentationStore[presentationIds?.lutPresentationId], + }; + console.log('Using presentations', presentations); cornerstoneViewportService.setViewportData( viewportIndex, viewportData, viewportOptions, displaySetOptions, - presentation + presentations ); }; diff --git a/extensions/cornerstone/src/init.tsx b/extensions/cornerstone/src/init.tsx index b969787cf..858ac41f1 100644 --- a/extensions/cornerstone/src/init.tsx +++ b/extensions/cornerstone/src/init.tsx @@ -98,9 +98,15 @@ export default async function init({ _showCPURenderingModal(uiModalService, hangingProtocolService); } - // Stores a map from `presentationId` to a Presentation object so that - // an OHIFCornerstoneViewport can be redisplayed with the same attributes - stateSyncService.register('presentationSync', { clearOnModeExit: true }); + // Stores a map from `lutPresentationId` to a Presentation object so that + // an OHIFCornerstoneViewport can be redisplayed with the same LUT + stateSyncService.register('lutPresentationStore', { clearOnModeExit: true }); + + // Stores a map from `positionPresentationId` to a Presentation object so that + // an OHIFCornerstoneViewport can be redisplayed with the same position + stateSyncService.register('positionPresentationStore', { + clearOnModeExit: true, + }); const labelmapRepresentation = cornerstoneTools.Enums.SegmentationRepresentations.Labelmap; diff --git a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts index 56ff73781..949d54c90 100644 --- a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts +++ b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts @@ -1591,7 +1591,7 @@ class SegmentationService { segmentInfo.isVisible = isVisible; - cstSegmentation.config.visibility.setVisibilityForSegmentIndex( + cstSegmentation.config.visibility.setSegmentVisibility( toolGroupId, segmentationRepresentationUID, segmentIndex, diff --git a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts index 37e0b2721..81623a738 100644 --- a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts +++ b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts @@ -21,11 +21,7 @@ import { StackViewportData, VolumeViewportData, } from '../../types/CornerstoneCacheService'; -import { - Presentation, - StackPresentation, - VolumePresentation, -} from '../../types/Presentation'; +import { Presentation, Presentations } from '../../types/Presentation'; import { setColormap, setLowerUpperColorTransferFunction, @@ -173,23 +169,30 @@ class CornerstoneViewportService extends PubSubService this.viewportsById.delete(viewportId); } + public setPresentations(viewport, presentations?: Presentations): void { + const properties = presentations.lutPresentation?.properties; + if (properties) viewport.setProperties(properties); + const camera = presentations.positionPresentation?.camera; + if (camera) viewport.setCamera(camera); + } + public getPresentation(viewportIndex: number): Presentation { const viewportInfo = this.viewportsInfo.get(viewportIndex); if (!viewportInfo) return; - const { - presentationId: id, - viewportType, - } = viewportInfo.getViewportOptions(); - if (!id) return; + const { viewportType, presentationIds } = viewportInfo.getViewportOptions(); const csViewport = this.getCornerstoneViewportByIndex(viewportIndex); if (!csViewport) return; const properties = csViewport.getProperties(); + if (properties.isComputedVOI) { + delete properties.voiRange; + delete properties.VOILUTFunction; + } const initialImageIndex = csViewport.getCurrentImageIdIndex(); const camera = csViewport.getCamera(); return { - id, + presentationIds, viewportType: !viewportType || viewportType === 'stack' ? 'stack' : 'volume', properties, @@ -211,7 +214,7 @@ class CornerstoneViewportService extends PubSubService viewportData: StackViewportData | VolumeViewportData, publicViewportOptions: PublicViewportOptions, publicDisplaySetOptions: DisplaySetOptions[], - presentation?: Presentation + presentations?: Presentations ): void { const renderingEngine = this.getRenderingEngine(); const viewportId = @@ -276,7 +279,7 @@ class CornerstoneViewportService extends PubSubService renderingEngine.enableElement(viewportInput); const viewport = renderingEngine.getViewport(viewportId); - this._setDisplaySets(viewport, viewportData, viewportInfo, presentation); + this._setDisplaySets(viewport, viewportData, viewportInfo, presentations); } public getCornerstoneViewport( @@ -340,7 +343,7 @@ class CornerstoneViewportService extends PubSubService viewport: Types.IStackViewport, viewportData: StackViewportData, viewportInfo: ViewportInfo, - presentation?: StackPresentation + presentations: Presentations ): void { const displaySetOptions = viewportInfo.getDisplaySetOptions(); @@ -353,7 +356,8 @@ class CornerstoneViewportService extends PubSubService this.viewportsDisplaySets.set(viewport.id, [displaySetInstanceUID]); let initialImageIndexToUse = - presentation?.initialImageIndex ?? initialImageIndex; + presentations?.positionPresentation?.initialImageIndex ?? + initialImageIndex; if ( initialImageIndexToUse === undefined || @@ -363,8 +367,8 @@ class CornerstoneViewportService extends PubSubService this._getInitialImageIndexForStackViewport(viewportInfo, imageIds) || 0; } - const properties = presentation?.properties || {}; - if (!presentation?.properties) { + const properties = { ...presentations.lutPresentation?.properties }; + if (!presentations.lutPresentation?.properties) { const { voi, voiInverted } = displaySetOptions[0]; if (voi && (voi.windowWidth || voi.windowCenter)) { const { lower, upper } = csUtils.windowLevel.toLowHighRange( @@ -385,7 +389,8 @@ class CornerstoneViewportService extends PubSubService // The scroll, however, works fine in CS3D viewport.scroll(initialImageIndexToUse); viewport.setProperties(properties); - if (presentation?.camera) viewport.setCamera(presentation.camera); + const camera = presentations.positionPresentation?.camera; + if (camera) viewport.setCamera(camera); }); } @@ -441,7 +446,7 @@ class CornerstoneViewportService extends PubSubService viewport: Types.IVolumeViewport, viewportData: VolumeViewportData, viewportInfo: ViewportInfo, - presentation: VolumePresentation + presentations: Presentations ): Promise { // TODO: We need to overhaul the way data sources work so requests can be made // async. I think we should follow the image loader pattern which is async and @@ -514,10 +519,18 @@ class CornerstoneViewportService extends PubSubService }); // This returns the async continuation only - return this.setVolumesForViewport(viewport, volumeInputArray, presentation); + return this.setVolumesForViewport( + viewport, + volumeInputArray, + presentations + ); } - public async setVolumesForViewport(viewport, volumeInputArray, presentation) { + public async setVolumesForViewport( + viewport, + volumeInputArray, + presentations + ) { const { displaySetService, segmentationService, @@ -525,9 +538,7 @@ class CornerstoneViewportService extends PubSubService } = this.servicesManager.services; await viewport.setVolumes(volumeInputArray); - const { properties, camera } = presentation || {}; - if (properties) viewport.setProperties(properties); - if (camera) viewport.setCamera(camera); + this.setPresentations(viewport, presentations); // load any secondary displaySets const displaySetInstanceUIDs = this.viewportsDisplaySets.get(viewport.id); @@ -703,21 +714,21 @@ class CornerstoneViewportService extends PubSubService viewport: StackViewport | VolumeViewport, viewportData: StackViewportData | VolumeViewportData, viewportInfo: ViewportInfo, - presentation?: Presentation + presentations: Presentations = {} ): void { if (viewport instanceof StackViewport) { this._setStackViewport( viewport, viewportData as StackViewportData, viewportInfo, - presentation as StackPresentation + presentations ); } else if (viewport instanceof VolumeViewport) { this._setVolumeViewport( viewport, viewportData as VolumeViewportData, viewportInfo, - presentation as VolumePresentation + presentations ); } else { throw new Error('Unknown viewport type'); diff --git a/extensions/cornerstone/src/services/ViewportService/Viewport.ts b/extensions/cornerstone/src/services/ViewportService/Viewport.ts index 51b97bf91..aefbc07ce 100644 --- a/extensions/cornerstone/src/services/ViewportService/Viewport.ts +++ b/extensions/cornerstone/src/services/ViewportService/Viewport.ts @@ -1,4 +1,5 @@ import { Types, Enums } from '@cornerstonejs/core'; +import { Types as UITypes } from '@ohif/ui'; import getCornerstoneBlendMode from '../../utils/getCornerstoneBlendMode'; import getCornerstoneOrientation from '../../utils/getCornerstoneOrientation'; import getCornerstoneViewportType from '../../utils/getCornerstoneViewportType'; @@ -15,12 +16,13 @@ export type InitialImageOptions = { }; export type ViewportOptions = { + id?: string; viewportType: Enums.ViewportType; toolGroupId: string; viewportId: string; // Presentation ID to store/load presentation state from - presentationId?: string; - orientation?: Types.Orientation; + presentationIds?: UITypes.PresentationIds; + orientation?: Enums.OrientationAxis; background?: Types.Point3; syncGroups?: SyncGroup[]; initialImageOptions?: InitialImageOptions; @@ -33,11 +35,12 @@ export type ViewportOptions = { }; export type PublicViewportOptions = { + id?: string; viewportType?: string; toolGroupId?: string; - presentationId?: string; + presentationIds?: UITypes.PresentationIds; viewportId?: string; - orientation?: string; + orientation?: Enums.OrientationAxis; background?: Types.Point3; syncGroups?: SyncGroup[]; initialImageOptions?: InitialImageOptions; @@ -51,6 +54,10 @@ export type DisplaySetSelector = { }; 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; @@ -59,6 +66,7 @@ export type PublicDisplaySetOptions = { }; export type DisplaySetOptions = { + id?: string; voi?: VOI; voiInverted: boolean; blendMode?: Enums.BlendModes; @@ -177,7 +185,7 @@ class ViewportInfo { let viewportType = viewportOptionsEntry.viewportType; const { toolGroupId = DEFAULT_TOOLGROUP_ID, - presentationId, + presentationIds, } = viewportOptionsEntry; let orientation; @@ -202,7 +210,7 @@ class ViewportInfo { viewportType: viewportType as Enums.ViewportType, orientation, toolGroupId, - presentationId, + presentationIds, }); } diff --git a/extensions/cornerstone/src/tools/CalibrationLineTool.ts b/extensions/cornerstone/src/tools/CalibrationLineTool.ts index 27e521d46..1db404034 100644 --- a/extensions/cornerstone/src/tools/CalibrationLineTool.ts +++ b/extensions/cornerstone/src/tools/CalibrationLineTool.ts @@ -1,9 +1,10 @@ import { metaData } from '@cornerstonejs/core'; -import { LengthTool } from '@cornerstonejs/tools'; -import { calibrateImageSpacing } from '@cornerstonejs/tools/dist/esm/utilities'; +import { LengthTool, utilities } from '@cornerstonejs/tools'; import callInputDialog from '../utils/callInputDialog'; import getActiveViewportEnabledElement from '../utils/getActiveViewportEnabledElement'; +const { calibrateImageSpacing } = utilities; + /** * Calibration Line tool works almost the same as the */ diff --git a/extensions/cornerstone/src/types/Presentation.ts b/extensions/cornerstone/src/types/Presentation.ts index 4520f3b11..82f13f745 100644 --- a/extensions/cornerstone/src/types/Presentation.ts +++ b/extensions/cornerstone/src/types/Presentation.ts @@ -1,23 +1,23 @@ /** Store presentation data for either stack viewports or volume viewports */ import { Types } from '@cornerstonejs/core'; +import { Types as UITypes } from '@ohif/ui'; -export interface BasePresentation { - id: string; - properties: Record; - initialImageIndex?: number; +/** + * Has information on the presentation of the viewport. + */ +export interface Presentation extends Types.StackViewportProperties { + presentationIds: UITypes.PresentationIds; + viewportType: string; + initialImageIndex: number; camera: Types.ICamera; + properties: Types.StackViewportProperties | Types.VolumeViewportProperties; + zoom?: number; + pan?: [number, number]; } -export interface StackPresentation extends BasePresentation { - viewportType: 'stack'; -} - -export interface VolumePresentation extends BasePresentation { - viewportType: 'volume'; -} - -// Currently it seems like the entire presentation state can be shared between -// Stack and Volume, but is setup to allow differences -export type Presentation = StackPresentation | VolumePresentation; +export type Presentations = { + positionPresentation?: Presentation; + lutPresentation?: Presentation; +}; export default Presentation; diff --git a/extensions/default/src/commandsModule.ts b/extensions/default/src/commandsModule.ts index 7d26048dc..5582dd5a8 100644 --- a/extensions/default/src/commandsModule.ts +++ b/extensions/default/src/commandsModule.ts @@ -153,8 +153,8 @@ const commandsModule = ({ servicesManager, commandsManager }) => { const restoreProtocol = !!viewportGridStore[storedHanging]; if ( - protocolId === hpInfo.hangingProtocolId && - useStageIdx === hpInfo.stageIdx && + protocolId === hpInfo.protocolId && + useStageIdx === hpInfo.stageIndex && !activeStudyUID ) { // Clear the HP setting to reset them diff --git a/extensions/default/src/utils/reuseCachedLayouts.ts b/extensions/default/src/utils/reuseCachedLayouts.ts index 09e1a9fef..38ecf8298 100644 --- a/extensions/default/src/utils/reuseCachedLayouts.ts +++ b/extensions/default/src/utils/reuseCachedLayouts.ts @@ -58,7 +58,7 @@ const reuseCachedLayout = ( } if (displaySetOptions[i]?.id) { displaySetSelectorMap[ - `${activeStudyUID}: ${displaySetOptions[i].id}: ${displaySetOptions[i] + `${activeStudyUID}:${displaySetOptions[i].id}:${displaySetOptions[i] .matchedDisplaySetsIndex || 0}` ] = displaySetUID; } diff --git a/extensions/test-extension/src/hp/hpMN.ts b/extensions/test-extension/src/hp/hpMN.ts index 70bc365bb..fc8fe4538 100644 --- a/extensions/test-extension/src/hp/hpMN.ts +++ b/extensions/test-extension/src/hp/hpMN.ts @@ -186,7 +186,6 @@ const hpMN: Types.HangingProtocol.Protocol = { }, }, viewportStructure: { - layoutType: 'grid', layoutType: 'grid', properties: { rows: 1, @@ -245,7 +244,6 @@ const hpMN: Types.HangingProtocol.Protocol = { }, displaySets: [ { - matchedDisplaySetsIndex: 1, id: 'defaultDisplaySetId', }, ], diff --git a/platform/core/src/services/StateSyncService/StateSyncService.ts b/platform/core/src/services/StateSyncService/StateSyncService.ts index b2d91c0ce..5a28bc041 100644 --- a/platform/core/src/services/StateSyncService/StateSyncService.ts +++ b/platform/core/src/services/StateSyncService/StateSyncService.ts @@ -42,6 +42,12 @@ export default class StateSyncService extends PubSubService { public init(extensionManager: ExtensionManager): void { } + /** Registers a new sync store called `id`. The state + * defines how the state is stored, and any default clearing of the + * state. + * A default store has the lifetime of the application. + * The other available store is cleared `onModeExit` + */ public register(id: string, config: StateConfig): void { this.registeredStateSets[id] = config; this.store({ [id]: {} }); diff --git a/platform/core/src/types/HangingProtocol.ts b/platform/core/src/types/HangingProtocol.ts index e7e7eddee..5cb26e20c 100644 --- a/platform/core/src/types/HangingProtocol.ts +++ b/platform/core/src/types/HangingProtocol.ts @@ -141,8 +141,8 @@ export type initialImageOptions = { }; export type ViewportOptions = { - toolGroupId: string; - viewportType: string; + toolGroupId?: string; + viewportType?: string; id?: string; orientation?: string; viewportId?: string; diff --git a/platform/docs/docs/platform/services/data/StateSyncService.md b/platform/docs/docs/platform/services/data/StateSyncService.md index 98d9e82ad..4e6964d17 100644 --- a/platform/docs/docs/platform/services/data/StateSyncService.md +++ b/platform/docs/docs/platform/services/data/StateSyncService.md @@ -60,13 +60,18 @@ clears all states registered with `clearOnModeExit: true`. To avoid clearing the state, the mode definition should store any transient state in the mode onModeExit and recover it in the `mode.onModeEnter`. -## OHIF Registered State -There are a number of defined states here. It is recommended to update this -list as states are added: +## OHIF Registered State Sync Stores +There are a number of defined stores here. It is recommended to update this +list as state stores are added: + +### Default Extension Stores * `viewportGridStore` has viewport grid restore information for returning to an earlier grid layout. * `reuseIdMap` has a map of names to display sets for preserving user changes to hp display set selections. * `hanging` has a map of the hanging protocol stage information applied (HPInfo) -* `presentationSync` has the cornerstone presentation state information * `toggleHangingProtocol` has the previously applied hanging protocol, to toggle an HP off. -* `querySync` has the previously applied query information. Not fully implemented yet. + +### Cornerstone Extension Stores + +* `lutPresentationStore` has the cornerstone LUT (window level) presentation state information +* `positionPresentationStore` has the cornerstone viewport position (camera, initial image) information diff --git a/platform/ui/src/contextProviders/ViewportGridProvider.tsx b/platform/ui/src/contextProviders/ViewportGridProvider.tsx index b11f1d301..c3bff44df 100644 --- a/platform/ui/src/contextProviders/ViewportGridProvider.tsx +++ b/platform/ui/src/contextProviders/ViewportGridProvider.tsx @@ -8,7 +8,7 @@ import React, { import PropTypes from 'prop-types'; import isEqual from 'lodash.isequal'; import viewportLabels from '../utils/viewportLabels'; -import getPresentationId from './getPresentationId'; +import getPresentationIds from './getPresentationIds'; const DEFAULT_STATE = { activeViewportIndex: 0, @@ -69,7 +69,7 @@ const reuseViewport = (idSet, viewport, stateViewports) => { // }; } // Find a viewport instance number different from earlier viewports having - // the same presentationId as this one would - will be less than 10k + // the same presentationIds as this one would - will be less than 10k // viewports hopefully :-) for (let i = 0; i < 10000; i++) { const viewportId = 'viewport-' + i; @@ -121,7 +121,10 @@ export function ViewportGridProvider({ children, service }) { displaySetOptions, viewportLabel: viewportLabels[viewportIndex], }; - viewportOptions.presentationId = getPresentationId(newView, viewports); + viewportOptions.presentationIds = getPresentationIds( + newView, + viewports + ); // Make sure we assign a viewport id newView = reuseViewport({}, newView, state.viewports); @@ -207,8 +210,8 @@ export function ViewportGridProvider({ children, service }) { viewports[viewportIndex], state.viewports ); - if (!viewport.viewportOptions.presentationId) { - viewport.viewportOptions.presentationId = getPresentationId( + if (!viewport.viewportOptions.presentationIds) { + viewport.viewportOptions.presentationIds = getPresentationIds( viewport, viewports ); diff --git a/platform/ui/src/contextProviders/getPresentationId.ts b/platform/ui/src/contextProviders/getPresentationId.ts deleted file mode 100644 index 7b41eaf8a..000000000 --- a/platform/ui/src/contextProviders/getPresentationId.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Selects a presentation ID to use for this viewport. - * This is done to allow the same display set to be displayed more than once - * on screen, with different attributes such as window level and initial position. - * Then, when redisplaying that, the nearest/most common attribute is re-used. - * - * For example, for display set , in a viewport of type volume, - * the generated presentationID might be - * `volume:axial:`. This can then be used to store and retrieve - * presentation information in state sync service 'presentationSync' state. - * - * The generated value attempts to generate a unique value for every type - * of viewport which should have it's own presentation information. Thus, the - * following values are used for presentation ID: - * - * 1. viewportType - since the presentation information for a volume is different than for a stack - * 2. orientation - since the camera is different for different orientations - * 3. display set instance UID - since different display sets should get displayed differently - * 4. instance count - since displaying the same series twice should allow applying different window level etc - * - * @param viewport requiring a presentation Id - * @param viewports is the list of viewports being shown. Any presentation ID's - * among them must not be re-used in order to have each viewport have it's own presentation ID. - * @returns Presentation ID id, or undefined if nothing displayed - */ -const getPresentationId = (viewport, viewports): string => { - if (!viewport) return; - const { viewportOptions, displaySetInstanceUIDs } = viewport; - if (!viewportOptions || !displaySetInstanceUIDs?.length) { - console.log('No viewport type or display sets in', viewport); - return; - } - - const viewportType = viewportOptions.viewportType || 'stack'; - const idArr = [viewportType, 0, ...displaySetInstanceUIDs]; - if (viewportOptions.orientation) { - idArr.splice(2, 0, viewportOptions.orientation); - } - - // Allow setting a custom presentation prefix in the hanging protocol - // This allows defining new - // presentation groups to be set automatically when one knows that the - // same display set will be displayed in different ways. - // This is the recommended way to manage a hanging protocol which displays - // multiple views of a single display set, eg to display brain, bone, soft - // tissue views in different viewports. - if (viewportOptions.presentationPrefix) { - idArr.push(viewportOptions.presentationPrefix); - } - if (!viewports) { - console.log('viewports not defined', idArr.join(',')); - return idArr.join('&'); - } - - // 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. - for (let displayInstance = 0; displayInstance < 128; displayInstance++) { - idArr[1] = displayInstance; - const testId = idArr.join('&'); - if (!viewports.find(it => it.viewportOptions?.presentationId === testId)) { - break; - } - } - const id = idArr.join('&'); - return id; -}; - -export default getPresentationId; diff --git a/platform/ui/src/contextProviders/getPresentationIds.ts b/platform/ui/src/contextProviders/getPresentationIds.ts new file mode 100644 index 000000000..e5a41d7c9 --- /dev/null +++ b/platform/ui/src/contextProviders/getPresentationIds.ts @@ -0,0 +1,120 @@ +const JOIN_STR = '&'; + +// The default lut presentation id if none defined +const DEFAULT = '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) => { + arr.push(0); + // 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 ( + !viewports.find( + viewport => viewport.viewportOptions?.presentationIds?.[key] === testId + ) + ) { + break; + } + } +}; + +const getLutId = (ds): string => { + if (!ds || !ds.options) return DEFAULT; + if (ds.options.id) return ds.options.id; + const arr = Object.entries(ds.options).map(([key, val]) => `${key}=${value}`); + if (!arr.length) return DEFAULT; + return arr.join(JOIN_STR); +}; + +export type PresentationIds = { + positionPresentationId?: string; + lutPresentationId?: string; +}; + +/** + * Gets a set of presentation IDs for a viewport. The presentation IDs are + * used to remember the presentation state of the viewport when it is navigated + * to different layouts. + * + * The design of this is setup to allow preserving the view information in the + * following cases: + * + * + * * If a set of display sets was previously displayed in the same initial + * position as it is currently being asked to be displayed, + * then remember the camera position as previously displayed + * + * * If a set of display sets was previously displayed with the same initial + * LUT conditions, then remember the last LUT displayed for that display set + * and re-apply it. + * + * * Otherwise, apply the initial hanging protocol specified LUT and camera + * position to new display sets. + * + * This means generating two presentationId keys: + * + * `positionPresentationId` + * + * Used for getting the camera/initial position state sync values. + * This is a combination of: + * * `viewportOptions.id` + * * `viewportOptions.orientation` + * * display set UID's - as displayed for this viewport, excluding seg + * * a unique index number if the previous key is already displayed + * + * `lutPresentationId` + * + * Used for getting the voi LUT information. Generated from: + * + * * `displaySetOption[0].options` - including the id if present + * * displaySetUID's + * * a unique index number if the previously generated key is already + * displayed. + * + * @param viewport requiring a presentation Id + * @param viewports is the list of viewports being shown. Any presentation ID's + * among them must not be re-used in order to have each viewport have it's own presentation ID. + * @returns PresentationIds + */ +const getPresentationIds = (viewport, viewports): PresentationIds => { + if (!viewport) return; + const { + viewportOptions, + displaySetInstanceUIDs, + displaySetOptions, + } = viewport; + if (!viewportOptions || !displaySetInstanceUIDs?.length) { + return; + } + + const { id, orientation } = viewportOptions; + const lutId = getLutId(displaySetOptions[0]); + const lutPresentationArr = [lutId]; + + const positionPresentationArr = [orientation || 'acquisition']; + if (id) positionPresentationArr.push(id); + + for (const uid of displaySetInstanceUIDs) { + positionPresentationArr.push(uid); + lutPresentationArr.push(uid); + } + + addUniqueIndex(positionPresentationArr, 'positionPresentationId', viewports); + addUniqueIndex(lutPresentationArr, 'lutPresentationId', viewports); + + const lutPresentationId = lutPresentationArr.join(JOIN_STR); + const positionPresentationId = positionPresentationArr.join(JOIN_STR); + return { lutPresentationId, positionPresentationId }; +}; + +export default getPresentationIds; diff --git a/platform/ui/src/types/index.ts b/platform/ui/src/types/index.ts index 6fd59a088..f74bbecb9 100644 --- a/platform/ui/src/types/index.ts +++ b/platform/ui/src/types/index.ts @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import ThumbnailType from './ThumbnailType'; +import { PresentationIds } from '../contextProviders/getPresentationIds'; // A few miscellaneous types declared inline here. @@ -14,4 +15,4 @@ const StringNumber = PropTypes.oneOfType([PropTypes.string, PropTypes.number]); */ const StringArray = PropTypes.oneOfType([PropTypes.string, PropTypes.array]); -export { StringNumber, StringArray, ThumbnailType }; +export { StringNumber, StringArray, ThumbnailType, PresentationIds }; diff --git a/platform/viewer/cypress/integration/measurement-tracking/OHIFStudyBrowser.spec.js b/platform/viewer/cypress/integration/measurement-tracking/OHIFStudyBrowser.spec.js index 8264c9635..c7f1dadba 100644 --- a/platform/viewer/cypress/integration/measurement-tracking/OHIFStudyBrowser.spec.js +++ b/platform/viewer/cypress/integration/measurement-tracking/OHIFStudyBrowser.spec.js @@ -51,6 +51,8 @@ describe('OHIF Study Viewer Page', function() { }); it('performs double-click to load thumbnail in active viewport', () => { + // Have to finish rendering the image before this works + cy.wait(250); cy.get('[data-cy="study-browser-thumbnail"]:nth-child(2)').dblclick(); //cy.get('@viewportInfoBottomLeft').should('contains.text', expectedText); diff --git a/platform/viewer/src/components/ViewportGrid.tsx b/platform/viewer/src/components/ViewportGrid.tsx index 70a9ed018..2eea5be09 100644 --- a/platform/viewer/src/components/ViewportGrid.tsx +++ b/platform/viewer/src/components/ViewportGrid.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useCallback } from 'react'; import PropTypes from 'prop-types'; -import { ServicesManager } from '@ohif/core'; +import { ServicesManager, Types } from '@ohif/core'; import { ViewportGrid, ViewportPane, useViewportGrid } from '@ohif/ui'; import { utils } from '@ohif/core'; import EmptyViewport from './EmptyViewport'; @@ -23,15 +23,6 @@ const ORIENTATION_MAP = { }, }; -const createHpInfo = (protocol, stage, activeStudyUID) => { - return { - hangingProtocolId: protocol.id, - stageId: stage.stageId, - stageIdx: protocol.stages.findIndex(it => it === stage), - activeStudyUID, - }; -}; - const compareViewportOptions = (opts1, opts2) => { if ((opts1.viewportType || 'stack') != opts2.viewportType) { return false; @@ -62,7 +53,7 @@ function ViewerViewportGrid(props) { */ const updateDisplaySetsFromProtocol = ( - protocol, + protocol: Types.HangingProtocol.Protocol, stage, activeStudyUID, viewportMatchDetails @@ -119,7 +110,6 @@ function ViewerViewportGrid(props) { numCols, layoutType, layoutOptions, - hpInfo: createHpInfo(protocol, stage, activeStudyUID), findOrCreateViewport, }); }; From 5ad5bd232d6d73a428b4b7d3b84a1de52267a3d3 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Tue, 21 Mar 2023 14:25:56 -0400 Subject: [PATCH 04/19] fix(tmtv): hanging protocol state sync (#3269) * fix: TMTV mode and message on drag and drop/toggle MPR * Tweaked some packages to force a rebuild --- .../default/src/Panels/PanelStudyBrowser.tsx | 2 +- extensions/default/src/commandsModule.ts | 2 +- package.json | 2 +- platform/ui/package.json | 2 +- .../contextProviders/getPresentationIds.ts | 2 +- yarn.lock | 34 +++++++------------ 6 files changed, 18 insertions(+), 26 deletions(-) diff --git a/extensions/default/src/Panels/PanelStudyBrowser.tsx b/extensions/default/src/Panels/PanelStudyBrowser.tsx index 9eb75aa95..3e7a072cd 100644 --- a/extensions/default/src/Panels/PanelStudyBrowser.tsx +++ b/extensions/default/src/Panels/PanelStudyBrowser.tsx @@ -51,7 +51,7 @@ function PanelStudyBrowser({ uiNotificationService.show({ title: 'Thumbnail Double Click', message: - 'The selected display sets could not be added to the viewport due to a mismatch in the Hanging Protocol rules.', + 'The selected display sets could not be added to the viewport.', type: 'info', duration: 3000, }); diff --git a/extensions/default/src/commandsModule.ts b/extensions/default/src/commandsModule.ts index 5582dd5a8..58c3e3797 100644 --- a/extensions/default/src/commandsModule.ts +++ b/extensions/default/src/commandsModule.ts @@ -192,7 +192,7 @@ const commandsModule = ({ servicesManager, commandsManager }) => { actions.toggleHpTools(hangingProtocolService.getActiveProtocol()); uiNotificationService.show({ title: 'Apply Hanging Protocol', - message: `The hanging protocol could not be applied due to ${e}`, + message: 'The hanging protocol could not be applied.', type: 'error', duration: 3000, }); diff --git a/package.json b/package.json index 3b5918b6f..c5287de03 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "@types/jest": "^27.5.0", "@typescript-eslint/eslint-plugin": "^4.19.0", "@typescript-eslint/parser": "^4.19.0", - "autoprefixer": "10.4.4", + "autoprefixer": "^10.4.4", "babel-eslint": "9.x", "babel-loader": "^8.2.4", "babel-plugin-inline-react-svg": "1.1.0", diff --git a/platform/ui/package.json b/platform/ui/package.json index 9f11e280f..68d73cd20 100644 --- a/platform/ui/package.json +++ b/platform/ui/package.json @@ -57,7 +57,7 @@ "@storybook/manager-webpack5": "^6.4.9", "@storybook/react": "^6.4.9", "@storybook/source-loader": "^6.4.9", - "autoprefixer": "9.7.4", + "autoprefixer": ">=9.7.4", "babel-loader": "^8.2.2", "dotenv-webpack": "6.0.4", "postcss": "^8.3.5", diff --git a/platform/ui/src/contextProviders/getPresentationIds.ts b/platform/ui/src/contextProviders/getPresentationIds.ts index e5a41d7c9..b34b6badc 100644 --- a/platform/ui/src/contextProviders/getPresentationIds.ts +++ b/platform/ui/src/contextProviders/getPresentationIds.ts @@ -31,7 +31,7 @@ const addUniqueIndex = (arr, key, viewports) => { const getLutId = (ds): string => { if (!ds || !ds.options) return DEFAULT; if (ds.options.id) return ds.options.id; - const arr = Object.entries(ds.options).map(([key, val]) => `${key}=${value}`); + const arr = Object.entries(ds.options).map(([key, val]) => `${key}=${val}`); if (!arr.length) return DEFAULT; return arr.join(JOIN_STR); }; diff --git a/yarn.lock b/yarn.lock index 8d7ec3330..dbc9e8400 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6638,31 +6638,18 @@ attr-accept@^2.0.0: resolved "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz#646613809660110749e92f2c10833b70968d929b" integrity sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg== -autoprefixer@10.4.4: - version "10.4.4" - resolved "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.4.tgz#3e85a245b32da876a893d3ac2ea19f01e7ea5a1e" - integrity sha512-Tm8JxsB286VweiZ5F0anmbyGiNI3v3wGv3mz9W+cxEDYB/6jbnj6GM9H9mK3wIL8ftgl+C07Lcwb8PG5PCCPzA== +autoprefixer@>=9.7.4, autoprefixer@^10.4.4: + version "10.4.14" + resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.14.tgz#e28d49902f8e759dd25b153264e862df2705f79d" + integrity sha512-FQzyfOsTlwVzjHxKEqRIAdJx9niO6VCBCoEwax/VLSoQF29ggECcPuBqUMZ+u8jCZOPSy8b8/8KnuFbp0SaFZQ== dependencies: - browserslist "^4.20.2" - caniuse-lite "^1.0.30001317" + browserslist "^4.21.5" + caniuse-lite "^1.0.30001464" fraction.js "^4.2.0" normalize-range "^0.1.2" picocolors "^1.0.0" postcss-value-parser "^4.2.0" -autoprefixer@9.7.4: - version "9.7.4" - resolved "https://registry.npmjs.org/autoprefixer/-/autoprefixer-9.7.4.tgz#f8bf3e06707d047f0641d87aee8cfb174b2a5378" - integrity sha512-g0Ya30YrMBAEZk60lp+qfX5YQllG+S5W3GYCFvyHTvhOki0AEQJLPEcIuGRsqVwLi8FvXPVtwTGhfr38hVpm0g== - dependencies: - browserslist "^4.8.3" - caniuse-lite "^1.0.30001020" - chalk "^2.4.2" - normalize-range "^0.1.2" - num2fraction "^1.2.2" - postcss "^7.0.26" - postcss-value-parser "^4.0.2" - autoprefixer@^10.4.12, autoprefixer@^10.4.13, autoprefixer@^10.4.7: version "10.4.13" resolved "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.13.tgz#b5136b59930209a321e9fa3dca2e7c4d223e83a8" @@ -7314,7 +7301,7 @@ browserify-zlib@^0.2.0: dependencies: pako "~1.0.5" -browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.14.5, browserslist@^4.18.1, browserslist@^4.20.2, browserslist@^4.21.3, browserslist@^4.21.4, browserslist@^4.21.5, browserslist@^4.8.3: +browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.14.5, browserslist@^4.18.1, browserslist@^4.21.3, browserslist@^4.21.4, browserslist@^4.21.5: version "4.21.5" resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.21.5.tgz#75c5dae60063ee641f977e00edd3cfb2fb7af6a7" integrity sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w== @@ -7622,11 +7609,16 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001020, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001317, caniuse-lite@^1.0.30001426, caniuse-lite@^1.0.30001449: +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001426, caniuse-lite@^1.0.30001449: version "1.0.30001455" resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001455.tgz#3a2c24bcbbb5ff73e3f347e11d71a22d023798cc" integrity sha512-h5n7WkDmyHlvHhVFDMC1OFUuWKoht7xuom/kL8b8uJzfMmB068adJgj3B0/n5PtnrK6rEqY8FE/D9m38aRdWhw== +caniuse-lite@^1.0.30001464: + version "1.0.30001469" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001469.tgz#3dd505430c8522fdc9f94b4a19518e330f5c945a" + integrity sha512-Rcp7221ScNqQPP3W+lVOYDyjdR6dC+neEQCttoNr5bAyz54AboB4iwpnWgyi8P4YUsPybVzT4LgWiBbI3drL4g== + capture-exit@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/capture-exit/-/capture-exit-2.0.0.tgz#fb953bfaebeb781f62898239dabb426d08a509a4" From 550918dd3122af849e3284f138136026d42251cc Mon Sep 17 00:00:00 2001 From: Joe Boccanfuso <109477394+jbocce@users.noreply.github.com> Date: Tue, 21 Mar 2023 16:09:50 -0400 Subject: [PATCH 05/19] feat(VolumeViewport3D): add volumeViewport3Dd sample hp (#3241) * feat(VolumeViewport3D) - importing and using Cornerstone 3D's VolumeViewport3D viewport - added mprAnd3DVolumeViewport hanging protocol - allowed for W/L preset to be applied to a viewport * Updated cornerstone core dependency * Removed pan and zoom from the volume3d tool group so that tackballrotate is never dropped for that viewport. Removed overlays from 3D volume viewport. Throw an error and display a message whenever an unapplicable tool is selected. * The default/initial orientation for a 3D volume viewport can now be set. Using the 'interleaveTopToBottom' image load strategy for the 'mprAnd3DVolumeViewport' hanging protocol. * Do not set orientation for stack viewport. --- extensions/cornerstone-dicom-sr/package.json | 2 +- extensions/cornerstone/package.json | 2 +- extensions/cornerstone/src/commandsModule.ts | 11 ++ .../src/getHangingProtocolModule.ts | 135 ++++++++++++++++++ .../cornerstone/src/initCornerstoneTools.js | 3 + .../CornerstoneCacheService.ts | 27 ++-- .../CornerstoneViewportService.ts | 25 +++- .../src/services/ViewportService/Viewport.ts | 8 +- .../src/utils/getCornerstoneViewportType.ts | 5 + extensions/measurement-tracking/package.json | 2 +- modes/longitudinal/src/initToolGroups.js | 19 +++ yarn.lock | 8 ++ 12 files changed, 230 insertions(+), 17 deletions(-) diff --git a/extensions/cornerstone-dicom-sr/package.json b/extensions/cornerstone-dicom-sr/package.json index ab7ddb077..3bae4e3c1 100644 --- a/extensions/cornerstone-dicom-sr/package.json +++ b/extensions/cornerstone-dicom-sr/package.json @@ -46,7 +46,7 @@ "@babel/runtime": "^7.20.13", "classnames": "^2.3.2", "@cornerstonejs/adapters": "^0.4.1", - "@cornerstonejs/core": "^0.33.2", + "@cornerstonejs/core": "^0.36.0", "@cornerstonejs/tools": "^0.50.2" } } diff --git a/extensions/cornerstone/package.json b/extensions/cornerstone/package.json index b981ec98c..cf4b42d94 100644 --- a/extensions/cornerstone/package.json +++ b/extensions/cornerstone/package.json @@ -44,7 +44,7 @@ "dependencies": { "@babel/runtime": "^7.20.13", "@cornerstonejs/adapters": "^0.4.1", - "@cornerstonejs/core": "^0.33.2", + "@cornerstonejs/core": "^0.36.0", "@cornerstonejs/streaming-image-volume-loader": "^0.14.1", "@cornerstonejs/tools": "^0.50.2", "@kitware/vtk.js": "26.5.6", diff --git a/extensions/cornerstone/src/commandsModule.ts b/extensions/cornerstone/src/commandsModule.ts index 878c174d7..6298792c9 100644 --- a/extensions/cornerstone/src/commandsModule.ts +++ b/extensions/cornerstone/src/commandsModule.ts @@ -181,6 +181,17 @@ const commandsModule = ({ return; } + if (!toolGroup.getToolInstance(toolName)) { + uiNotificationService.show({ + title: `${toolName} tool`, + message: `The ${toolName} tool is not available in this viewport.`, + type: 'info', + duration: 3000, + }); + + throw new Error(`ToolGroup ${toolGroup.id} does not have this tool.`); + } + const activeToolName = toolGroup.getActivePrimaryMouseButtonTool(); if (activeToolName) { diff --git a/extensions/cornerstone/src/getHangingProtocolModule.ts b/extensions/cornerstone/src/getHangingProtocolModule.ts index 20a962b44..755bf030e 100644 --- a/extensions/cornerstone/src/getHangingProtocolModule.ts +++ b/extensions/cornerstone/src/getHangingProtocolModule.ts @@ -160,12 +160,147 @@ const mpr: Types.HangingProtocol.Protocol = { ], }; +const mprAnd3DVolumeViewport = { + id: 'mprAnd3DVolumeViewport', + locked: true, + hasUpdatedPriorsInformation: false, + name: 'mpr', + createdDate: '2023-03-15T10:29:44.894Z', + modifiedDate: '2023-03-15T10:29:44.894Z', + availableTo: {}, + editableBy: {}, + protocolMatchingRules: [], + imageLoadStrategy: 'interleaveTopToBottom', + displaySetSelectors: { + mprDisplaySet: { + seriesMatchingRules: [ + { + weight: 1, + attribute: 'isReconstructable', + constraint: { + equals: { + value: true, + }, + }, + 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, + }, + ], + }, + displaySets: [ + { + id: 'mprDisplaySet', + }, + ], + }, + { + viewportOptions: { + toolGroupId: 'volume3d', + viewportType: 'volume3d', + orientation: 'coronal', + customViewportProps: { + hideOverlays: true, + }, + }, + displaySets: [ + { + id: 'mprDisplaySet', + options: { + presetName: 'CT-Bone', + }, + }, + ], + }, + { + viewportOptions: { + toolGroupId: 'mpr', + viewportType: 'volume', + orientation: 'coronal', + initialImageOptions: { + preset: 'middle', + }, + syncGroups: [ + { + type: 'voi', + id: 'mpr', + source: true, + target: true, + }, + ], + }, + displaySets: [ + { + id: 'mprDisplaySet', + }, + ], + }, + { + viewportOptions: { + toolGroupId: 'mpr', + viewportType: 'volume', + orientation: 'sagittal', + initialImageOptions: { + preset: 'middle', + }, + syncGroups: [ + { + type: 'voi', + id: 'mpr', + source: true, + target: true, + }, + ], + }, + displaySets: [ + { + id: 'mprDisplaySet', + }, + ], + }, + ], + }, + ], +}; + function getHangingProtocolModule() { return [ { id: 'mpr', protocol: mpr, }, + { + id: mprAnd3DVolumeViewport.id, + protocol: mprAnd3DVolumeViewport, + }, ]; } diff --git a/extensions/cornerstone/src/initCornerstoneTools.js b/extensions/cornerstone/src/initCornerstoneTools.js index 291e02924..2d305af8a 100644 --- a/extensions/cornerstone/src/initCornerstoneTools.js +++ b/extensions/cornerstone/src/initCornerstoneTools.js @@ -23,6 +23,7 @@ import { addTool, annotation, ReferenceLinesTool, + TrackballRotateTool, } from '@cornerstonejs/tools'; import CalibrationLineTool from './tools/CalibrationLineTool'; @@ -51,6 +52,7 @@ export default function initCornerstoneTools(configuration = {}) { addTool(SegmentationDisplayTool); addTool(ReferenceLinesTool); addTool(CalibrationLineTool); + addTool(TrackballRotateTool); // Modify annotation tools to use dashed lines on SR const annotationStyle = { @@ -90,6 +92,7 @@ const toolNames = { SegmentationDisplay: SegmentationDisplayTool.toolName, ReferenceLines: ReferenceLinesTool.toolName, CalibrationLine: CalibrationLineTool.toolName, + TrackballRotateTool: TrackballRotateTool.toolName, }; export { toolNames }; diff --git a/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts b/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts index 01a359368..8a29bcab4 100644 --- a/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts +++ b/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts @@ -62,12 +62,20 @@ class CornerstoneCacheService { viewportData = await this._getStackViewportData( dataSource, displaySets, - initialImageIndex + initialImageIndex, + cs3DViewportType ); } - if (cs3DViewportType === Enums.ViewportType.ORTHOGRAPHIC) { - viewportData = await this._getVolumeViewportData(dataSource, displaySets); + if ( + cs3DViewportType === Enums.ViewportType.ORTHOGRAPHIC || + cs3DViewportType === Enums.ViewportType.VOLUME_3D + ) { + viewportData = await this._getVolumeViewportData( + dataSource, + displaySets, + cs3DViewportType + ); } viewportData.viewportType = cs3DViewportType; @@ -100,7 +108,8 @@ class CornerstoneCacheService { const newViewportData = await this._getVolumeViewportData( dataSource, - displaySets + displaySets, + viewportData.viewportType ); return newViewportData; @@ -109,7 +118,8 @@ class CornerstoneCacheService { private _getStackViewportData( dataSource, displaySets, - initialImageIndex + initialImageIndex, + viewportType: Enums.ViewportType ): StackViewportData { // For Stack Viewport we don't have fusion currently const displaySet = displaySets[0]; @@ -126,7 +136,7 @@ class CornerstoneCacheService { const { displaySetInstanceUID, StudyInstanceUID } = displaySet; const StackViewportData: StackViewportData = { - viewportType: Enums.ViewportType.STACK, + viewportType, data: { StudyInstanceUID, displaySetInstanceUID, @@ -143,7 +153,8 @@ class CornerstoneCacheService { private async _getVolumeViewportData( dataSource, - displaySets + displaySets, + viewportType: Enums.ViewportType ): Promise { // Todo: Check the cache for multiple scenarios to see if we need to // decache the volume data from other viewports or not @@ -207,7 +218,7 @@ class CornerstoneCacheService { } return { - viewportType: Enums.ViewportType.ORTHOGRAPHIC, + viewportType, data: volumeData, }; } diff --git a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts index 81623a738..0729c783d 100644 --- a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts +++ b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts @@ -6,7 +6,10 @@ import { getRenderingEngine, utilities as csUtils, VolumeViewport, + VolumeViewport3D, cache, + utilities, + CONSTANTS, } from '@cornerstonejs/core'; import { utilities as csToolsUtils } from '@cornerstonejs/tools'; @@ -682,7 +685,12 @@ class CornerstoneViewportService extends PubSubService } _getVOICallbacks(volumeId, displaySetOptions) { - const { voi, voiInverted: inverted, colormap } = displaySetOptions; + const { + voi, + voiInverted: inverted, + colormap, + presetName, + } = displaySetOptions; const voiCallbackArray = []; @@ -707,6 +715,16 @@ class CornerstoneViewportService extends PubSubService ); } + if (presetName) { + voiCallbackArray.push(volumeActor => { + utilities.applyPreset( + volumeActor, + CONSTANTS.VIEWPORT_PRESETS.find(preset => { + return preset.name === presetName; + }) + ); + }); + } return voiCallbackArray; } @@ -723,7 +741,10 @@ class CornerstoneViewportService extends PubSubService viewportInfo, presentations ); - } else if (viewport instanceof VolumeViewport) { + } else if ( + viewport instanceof VolumeViewport || + viewport instanceof VolumeViewport3D + ) { this._setVolumeViewport( viewport, viewportData as VolumeViewportData, diff --git a/extensions/cornerstone/src/services/ViewportService/Viewport.ts b/extensions/cornerstone/src/services/ViewportService/Viewport.ts index aefbc07ce..5221827ce 100644 --- a/extensions/cornerstone/src/services/ViewportService/Viewport.ts +++ b/extensions/cornerstone/src/services/ViewportService/Viewport.ts @@ -63,6 +63,7 @@ export type PublicDisplaySetOptions = { blendMode?: string; slabThickness?: number; colormap?: string; + presetName?: string; }; export type DisplaySetOptions = { @@ -72,6 +73,7 @@ export type DisplaySetOptions = { blendMode?: Enums.BlendModes; slabThickness?: number; colormap?: string; + presetName?: string; }; type VOI = { @@ -84,7 +86,6 @@ export type DisplaySet = { }; const STACK = 'stack'; -const VOLUME = 'volume'; const DEFAULT_TOOLGROUP_ID = 'default'; class ViewportInfo { @@ -198,10 +199,8 @@ class ViewportInfo { } // map SAGITTAL, AXIAL, CORONAL orientation to be used by cornerstone - if (viewportOptionsEntry.viewportType?.toLowerCase() === VOLUME) { + if (viewportOptionsEntry.viewportType?.toLowerCase() !== STACK) { orientation = getCornerstoneOrientation(viewportOptionsEntry.orientation); - } else { - orientation = Enums.OrientationAxis.AXIAL; } this.setViewportOptions({ @@ -282,6 +281,7 @@ class ViewportInfo { colormap: option.colormap, slabThickness: option.slabThickness, blendMode, + presetName: option.presetName, }); }); diff --git a/extensions/cornerstone/src/utils/getCornerstoneViewportType.ts b/extensions/cornerstone/src/utils/getCornerstoneViewportType.ts index 88f386eeb..47a5ede85 100644 --- a/extensions/cornerstone/src/utils/getCornerstoneViewportType.ts +++ b/extensions/cornerstone/src/utils/getCornerstoneViewportType.ts @@ -3,6 +3,7 @@ import { Enums } from '@cornerstonejs/core'; const STACK = 'stack'; const VOLUME = 'volume'; const ORTHOGRAPHIC = 'orthographic'; +const VOLUME_3D = 'volume3d'; export default function getCornerstoneViewportType( viewportType: string @@ -16,6 +17,10 @@ export default function getCornerstoneViewportType( return Enums.ViewportType.ORTHOGRAPHIC; } + if (lowerViewportType === VOLUME_3D) { + return Enums.ViewportType.VOLUME_3D; + } + throw new Error( `Invalid viewport type: ${viewportType}. Valid types are: stack, volume` ); diff --git a/extensions/measurement-tracking/package.json b/extensions/measurement-tracking/package.json index 01c45ccdc..0facf190a 100644 --- a/extensions/measurement-tracking/package.json +++ b/extensions/measurement-tracking/package.json @@ -32,7 +32,7 @@ "peerDependencies": { "@ohif/core": "^3.0.0", "classnames": "^2.3.2", - "@cornerstonejs/core": "^0.33.2", + "@cornerstonejs/core": "^0.36.0", "@cornerstonejs/tools": "^0.50.2", "@ohif/extension-cornerstone-dicom-sr": "^3.0.0", "dcmjs": "^0.29.4", diff --git a/modes/longitudinal/src/initToolGroups.js b/modes/longitudinal/src/initToolGroups.js index fe77aa52f..261b37090 100644 --- a/modes/longitudinal/src/initToolGroups.js +++ b/modes/longitudinal/src/initToolGroups.js @@ -215,6 +215,24 @@ function initMPRToolGroup(extensionManager, toolGroupService, commandsManager) { toolGroupService.createToolGroupAndAddTools('mpr', tools, toolsConfig); } +function initVolume3DToolGroup(extensionManager, toolGroupService) { + const utilityModule = extensionManager.getModuleEntry( + '@ohif/extension-cornerstone.utilityModule.tools' + ); + + const { toolNames, Enums } = utilityModule.exports; + + const tools = { + active: [ + { + toolName: toolNames.TrackballRotateTool, + bindings: [{ mouseButton: Enums.MouseBindings.Primary }], + }, + ], + }; + + toolGroupService.createToolGroupAndAddTools('volume3d', tools); +} function initToolGroups(extensionManager, toolGroupService, commandsManager) { initDefaultToolGroup( @@ -225,6 +243,7 @@ function initToolGroups(extensionManager, toolGroupService, commandsManager) { ); initSRToolGroup(extensionManager, toolGroupService, commandsManager); initMPRToolGroup(extensionManager, toolGroupService, commandsManager); + initVolume3DToolGroup(extensionManager, toolGroupService); } export default initToolGroups; diff --git a/yarn.lock b/yarn.lock index dbc9e8400..5f948c92d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1459,6 +1459,14 @@ detect-gpu "^4.0.45" lodash.clonedeep "4.5.0" +"@cornerstonejs/core@^0.36.0": + version "0.36.0" + resolved "https://registry.yarnpkg.com/@cornerstonejs/core/-/core-0.36.0.tgz#b8e798e04cfceb6106ce4700b2aef14532d814f0" + integrity sha512-vU1wYhezq4x99MT3nuuNS2YVTUn1UkMJ5B9PPSyayQeWxuC0pV0KWBllUAjJA3VR3YDmDUKAfLh9K10u6javYg== + dependencies: + detect-gpu "^4.0.45" + lodash.clonedeep "4.5.0" + "@cornerstonejs/streaming-image-volume-loader@^0.14.1": version "0.14.1" resolved "https://registry.yarnpkg.com/@cornerstonejs/streaming-image-volume-loader/-/streaming-image-volume-loader-0.14.1.tgz#9830e1cbb65aa0e050336b7f5cb9c81d23e09fc5" From 3b6822e7b64fac78cde0d26ac4e0e21410a15776 Mon Sep 17 00:00:00 2001 From: Braden Morley <63816946+bradenjmorley@users.noreply.github.com> Date: Wed, 22 Mar 2023 01:36:53 +0000 Subject: [PATCH 06/19] fix(ui): autoscroll to new measurement on measurement table (#3223) Fixed scrolling for measurement clicks changed import --- extensions/measurement-tracking/package.json | 1 + .../src/panels/PanelMeasurementTableTracking/index.tsx | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/extensions/measurement-tracking/package.json b/extensions/measurement-tracking/package.json index 0facf190a..4f57ab622 100644 --- a/extensions/measurement-tracking/package.json +++ b/extensions/measurement-tracking/package.json @@ -36,6 +36,7 @@ "@cornerstonejs/tools": "^0.50.2", "@ohif/extension-cornerstone-dicom-sr": "^3.0.0", "dcmjs": "^0.29.4", + "lodash.debounce": "^4.17.21", "prop-types": "^15.6.2", "react": "^17.0.2", "react-dom": "^17.0.2", diff --git a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.tsx b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.tsx index ced5c7450..8c7d3df03 100644 --- a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.tsx +++ b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import PropTypes from 'prop-types'; import { StudySummary, @@ -11,6 +11,7 @@ import { DicomMetadataStore, utils } from '@ohif/core'; import { useDebounce } from '@hooks'; import ActionButtons from './ActionButtons'; import { useTrackedMeasurements } from '../../getContextModule'; +import debounce from 'lodash.debounce'; const { downloadCSVReport } = utils; const { formatDate } = utils; @@ -45,6 +46,7 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) { DISPLAY_STUDY_SUMMARY_INITIAL_VALUE ); const [displayMeasurements, setDisplayMeasurements] = useState([]); + const measurementsPanelRef = useRef(null); useEffect(() => { const measurements = measurementService.getMeasurements(); @@ -125,6 +127,11 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) { subscriptions.push( measurementService.subscribe(evt, () => { setMeasurementsUpdated(Date.now().toString()); + if (evt === added) { + debounce(() => { + measurementsPanelRef.current.scrollTop = measurementsPanelRef.current.scrollHeight; + }, 300)(); + } }).unsubscribe ); }); @@ -241,6 +248,7 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) { <>
{displayStudySummary.key && ( From 4e94b588a77dd9a84db7b99107d5d35e6d4469e6 Mon Sep 17 00:00:00 2001 From: Alireza Date: Wed, 22 Mar 2023 12:44:52 -0400 Subject: [PATCH 07/19] fix(SegmentationPanel): should be able to hide and only show one segment (#3270) * fix(SegmentationPanel): should be able to hide and only show one segment * fix imports * bump package versions --- extensions/cornerstone-dicom-sr/package.json | 4 +- extensions/cornerstone/package.json | 6 +- extensions/cornerstone/src/index.tsx | 3 +- .../SegmentationService.ts | 64 ++++++++++--------- .../src/services/ViewportService/Viewport.ts | 10 +-- .../src/utils/getCornerstoneOrientation.ts | 1 - extensions/measurement-tracking/package.json | 4 +- .../SegmentationGroup.tsx | 4 +- yarn.lock | 50 ++++++--------- 9 files changed, 71 insertions(+), 75 deletions(-) diff --git a/extensions/cornerstone-dicom-sr/package.json b/extensions/cornerstone-dicom-sr/package.json index 3bae4e3c1..af5d3c57a 100644 --- a/extensions/cornerstone-dicom-sr/package.json +++ b/extensions/cornerstone-dicom-sr/package.json @@ -46,7 +46,7 @@ "@babel/runtime": "^7.20.13", "classnames": "^2.3.2", "@cornerstonejs/adapters": "^0.4.1", - "@cornerstonejs/core": "^0.36.0", - "@cornerstonejs/tools": "^0.50.2" + "@cornerstonejs/core": "^0.36.2", + "@cornerstonejs/tools": "^0.55.1" } } diff --git a/extensions/cornerstone/package.json b/extensions/cornerstone/package.json index cf4b42d94..0a6802edf 100644 --- a/extensions/cornerstone/package.json +++ b/extensions/cornerstone/package.json @@ -44,9 +44,9 @@ "dependencies": { "@babel/runtime": "^7.20.13", "@cornerstonejs/adapters": "^0.4.1", - "@cornerstonejs/core": "^0.36.0", - "@cornerstonejs/streaming-image-volume-loader": "^0.14.1", - "@cornerstonejs/tools": "^0.50.2", + "@cornerstonejs/core": "^0.36.2", + "@cornerstonejs/streaming-image-volume-loader": "^0.15.1", + "@cornerstonejs/tools": "^0.55.1", "@kitware/vtk.js": "26.5.6", "html2canvas": "^1.4.1", "lodash.debounce": "4.0.8", diff --git a/extensions/cornerstone/src/index.tsx b/extensions/cornerstone/src/index.tsx index 20b8695c6..f4cf97070 100644 --- a/extensions/cornerstone/src/index.tsx +++ b/extensions/cornerstone/src/index.tsx @@ -151,5 +151,6 @@ const cornerstoneExtension: Types.Extensions.Extension = { }, }; +export type { PublicViewportOptions }; +export { measurementMappingUtils }; export default cornerstoneExtension; -export { measurementMappingUtils, PublicViewportOptions }; diff --git a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts index 949d54c90..658ee816f 100644 --- a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts +++ b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts @@ -1,22 +1,22 @@ import cloneDeep from 'lodash.clonedeep'; -import { pubSubServiceInterface } from '@ohif/core'; import { - utilities as cstUtils, - segmentation as cstSegmentation, - CONSTANTS as cstConstants, - Enums as csToolsEnums, - Types as cstTypes, -} from '@cornerstonejs/tools'; -import { - eventTarget, cache, + eventTarget, + getEnabledElementByIds, + metaData, + Types, utilities as csUtils, volumeLoader, - Types, - metaData, - getEnabledElementByIds, } from '@cornerstonejs/core'; +import { + CONSTANTS as cstConstants, + Enums as csToolsEnums, + segmentation as cstSegmentation, + Types as cstTypes, + utilities as cstUtils, +} from '@cornerstonejs/tools'; +import { pubSubServiceInterface } from '@ohif/core'; import isEqual from 'lodash.isequal'; import { easeInOutBell } from '../../utils/transitions'; import { @@ -202,7 +202,7 @@ class SegmentationService { this._setActiveSegment(segmentationId, segmentIndex, suppressEvents); } - // Todo: this includes nonhydrated segmentations which might not be + // Todo: this includes non-hydrated segmentations which might not be // persisted in the store this._broadcastEvent(this.EVENTS.SEGMENTATION_UPDATED, { segmentation, @@ -1598,6 +1598,13 @@ class SegmentationService { isVisible ); + // make sure to update the isVisible flag on the segmentation + // if a segment becomes invisible then the segmentation should be invisible + // in the status as well, and show correct icon + segmentation.isVisible = segmentation.segments + .filter(Boolean) + .every(segment => segment.isVisible); + if (suppressEvents === false) { this._broadcastEvent(this.EVENTS.SEGMENTATION_UPDATED, { segmentation, @@ -1917,28 +1924,27 @@ class SegmentationService { representation => representation.segmentationId === segmentationId ); - const visibility = cstSegmentation.config.visibility.getSegmentationVisibility( - toolGroupId, - representation.segmentationRepresentationUID - ); + const { segmentsHidden } = representation; + + const currentVisibility = segmentsHidden.size === 0 ? true : false; + const newVisibility = !currentVisibility; cstSegmentation.config.visibility.setSegmentationVisibility( toolGroupId, representation.segmentationRepresentationUID, - !visibility + newVisibility ); - // set all segments to visible as well - const segments = this.getSegmentation(segmentationId).segments; - Object.keys(segments).forEach(segmentIndex => { - if (segmentIndex !== '0') { - this._setSegmentVisibility( - segmentationId, - Number(segmentIndex), - !visibility, - toolGroupId - ); - } + // update segments visibility + const { segmentation } = this._getSegmentationInfo( + segmentationId, + toolGroupId + ); + + const segments = segmentation.segments.filter(Boolean); + + segments.forEach(segment => { + segment.isVisible = newVisibility; }); }); }; diff --git a/extensions/cornerstone/src/services/ViewportService/Viewport.ts b/extensions/cornerstone/src/services/ViewportService/Viewport.ts index 5221827ce..4f43aff23 100644 --- a/extensions/cornerstone/src/services/ViewportService/Viewport.ts +++ b/extensions/cornerstone/src/services/ViewportService/Viewport.ts @@ -1,14 +1,14 @@ import { Types, Enums } from '@cornerstonejs/core'; import { Types as UITypes } from '@ohif/ui'; +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'; -import { - StackViewportData, - VolumeViewportData, -} from '../../types/CornerstoneCacheService'; export type InitialImageOptions = { index?: number; @@ -247,7 +247,7 @@ class ViewportInfo { return this.viewportOptions.background || [0, 0, 0]; } - public getOrientation(): Types.Orientation { + public getOrientation(): Enums.OrientationAxis { return this.viewportOptions.orientation; } diff --git a/extensions/cornerstone/src/utils/getCornerstoneOrientation.ts b/extensions/cornerstone/src/utils/getCornerstoneOrientation.ts index e37177d55..8ac7b2cb5 100644 --- a/extensions/cornerstone/src/utils/getCornerstoneOrientation.ts +++ b/extensions/cornerstone/src/utils/getCornerstoneOrientation.ts @@ -1,5 +1,4 @@ import { Enums } from '@cornerstonejs/core'; -import { log } from '@ohif/core'; const AXIAL = 'axial'; const SAGITTAL = 'sagittal'; diff --git a/extensions/measurement-tracking/package.json b/extensions/measurement-tracking/package.json index 4f57ab622..cf6ddae2a 100644 --- a/extensions/measurement-tracking/package.json +++ b/extensions/measurement-tracking/package.json @@ -32,8 +32,8 @@ "peerDependencies": { "@ohif/core": "^3.0.0", "classnames": "^2.3.2", - "@cornerstonejs/core": "^0.36.0", - "@cornerstonejs/tools": "^0.50.2", + "@cornerstonejs/core": "^0.36.2", + "@cornerstonejs/tools": "^0.55.1", "@ohif/extension-cornerstone-dicom-sr": "^3.0.0", "dcmjs": "^0.29.4", "lodash.debounce": "^4.17.21", diff --git a/platform/ui/src/components/SegmentationGroupTable/SegmentationGroup.tsx b/platform/ui/src/components/SegmentationGroupTable/SegmentationGroup.tsx index bbff6bffb..34cb510ec 100644 --- a/platform/ui/src/components/SegmentationGroupTable/SegmentationGroup.tsx +++ b/platform/ui/src/components/SegmentationGroupTable/SegmentationGroup.tsx @@ -171,9 +171,7 @@ const SegmentationGroup = ({ id={id} showAddSegment={showAddSegment} /> -
+
{!!segments.length && segments.map(segment => { if (segment === undefined || segment === null) { diff --git a/yarn.lock b/yarn.lock index 5f948c92d..d72f1d1e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1443,44 +1443,36 @@ resolved "https://registry.npmjs.org/@cornerstonejs/codec-openjph/-/codec-openjph-2.4.2.tgz#e96721d56f6ec96f7f95c16321d88cc8467d8d81" integrity sha512-lgdvBvvNezleY+4pIe2ceUsJzlZe/0PipdeubQ3vZZOz3xxtHHMR1XFCl4fgd8gosR8COHuD7h6q+MwgrwBsng== -"@cornerstonejs/core@^0.33.1": - version "0.33.1" - resolved "https://registry.yarnpkg.com/@cornerstonejs/core/-/core-0.33.1.tgz#dfbcad81239141c2702fd2ab0a3edbe408e7bfad" - integrity sha512-QXrnKZBEMharA/FIYqtvkTXD8JeEf742yHybNmehB1cJU1hrsjRJ1JuqXXu6IHWJFx3cAsCaWk4oZyA09OB7OQ== +"@cornerstonejs/core@^0.35.1": + version "0.35.1" + resolved "https://registry.npmjs.org/@cornerstonejs/core/-/core-0.35.1.tgz#fd5e9f878b51807366d9d0c6659ac91b43efed87" + integrity sha512-n6nFo3XVkMKmhNsF20yPznO3jo3MpOl8X+3k/HyHVlZujIsBT/jz0X4dEg/3fzA0aLPdWdZ/kkVw8TBanVKTGA== dependencies: detect-gpu "^4.0.45" lodash.clonedeep "4.5.0" -"@cornerstonejs/core@^0.33.2": - version "0.33.2" - resolved "https://registry.yarnpkg.com/@cornerstonejs/core/-/core-0.33.2.tgz#63aea3eb9787622be0c75052696119a0e58117e6" - integrity sha512-iRHq7WIcZOUxOIMV9KEY7iJjTpcpk7vJPAIZM5tqVvTIbcfaACmxpTlpc2NPqT52o0shRQImIkMFiotUKx0Pug== +"@cornerstonejs/core@^0.36.2": + version "0.36.2" + resolved "https://registry.npmjs.org/@cornerstonejs/core/-/core-0.36.2.tgz#205573bfd75a273fa6bca587662b4dd1f5e46167" + integrity sha512-jJYawOjLGop18O426YxyBepkiaYT/xHMa3mqhToIO5KRxK/UYSGHt0RS8wSHeFR8pNkCAHepcdfhyIVkAln66g== dependencies: detect-gpu "^4.0.45" lodash.clonedeep "4.5.0" -"@cornerstonejs/core@^0.36.0": - version "0.36.0" - resolved "https://registry.yarnpkg.com/@cornerstonejs/core/-/core-0.36.0.tgz#b8e798e04cfceb6106ce4700b2aef14532d814f0" - integrity sha512-vU1wYhezq4x99MT3nuuNS2YVTUn1UkMJ5B9PPSyayQeWxuC0pV0KWBllUAjJA3VR3YDmDUKAfLh9K10u6javYg== +"@cornerstonejs/streaming-image-volume-loader@^0.15.1": + version "0.15.1" + resolved "https://registry.npmjs.org/@cornerstonejs/streaming-image-volume-loader/-/streaming-image-volume-loader-0.15.1.tgz#b9fd0efbebbd232119ef0ae7b63bf8b3498bf539" + integrity sha512-xtBG2RlhjOX/Cd4qGLYGKXjF7oLhRtksmXpPB5nOa1BBBgrGOQHFjNuyh3uhVHg30e5po9oCfomSPHwovhexzw== dependencies: - detect-gpu "^4.0.45" - lodash.clonedeep "4.5.0" - -"@cornerstonejs/streaming-image-volume-loader@^0.14.1": - version "0.14.1" - resolved "https://registry.yarnpkg.com/@cornerstonejs/streaming-image-volume-loader/-/streaming-image-volume-loader-0.14.1.tgz#9830e1cbb65aa0e050336b7f5cb9c81d23e09fc5" - integrity sha512-4m2efDbv00pFQfL/ZjnWjo4RJ3yo4ibv4J/8oro7ZrzhXUJl9L4mwdA1HYCAkS+rZQndZba138AO6b+g4nrmXQ== - dependencies: - "@cornerstonejs/core" "^0.33.1" + "@cornerstonejs/core" "^0.35.1" cornerstone-wado-image-loader "^4.10.0" -"@cornerstonejs/tools@^0.50.2": - version "0.50.2" - resolved "https://registry.yarnpkg.com/@cornerstonejs/tools/-/tools-0.50.2.tgz#0134b21016ec64290b8f9a43ac1a485654ed86eb" - integrity sha512-MedGsiisDjd2eOewhojwPpWReKM13x1krKIhPNL3hi8rZjV57T8RTpWfztxSjuUpOvapmlJXutrDPuLSUXs01g== +"@cornerstonejs/tools@^0.55.1": + version "0.55.1" + resolved "https://registry.npmjs.org/@cornerstonejs/tools/-/tools-0.55.1.tgz#cde00881c35a43f2d10c634350e46f45e8bf9a7b" + integrity sha512-fYRNqnS9WXWBrkOd++nocKqjlD6p3BvFtPCJeaA8M2bdgEHEHK+KbM999GG5YTmWV88xLW4mjSl+wJvbdWUwEQ== dependencies: - "@cornerstonejs/core" "^0.33.2" + "@cornerstonejs/core" "^0.36.2" lodash.clonedeep "4.5.0" lodash.get "^4.4.2" @@ -8528,9 +8520,9 @@ cornerstone-math@^0.1.9: integrity sha512-23XSAyP7t70ANvhFyqwvva+zFd1bQ2d5GL7tg9qKE932WmImjA2Y9tiy5n0iTtnf51W/78Png8Lia2o4dCdJaQ== cornerstone-wado-image-loader@^4.10.0: - version "4.10.0" - resolved "https://registry.npmjs.org/cornerstone-wado-image-loader/-/cornerstone-wado-image-loader-4.10.0.tgz#25c367cfc54a2c92ebbb5c64dba4fe38439112a1" - integrity sha512-XZcgB8DpUxnsTA3vU/zbPtB2uFLL4ght70BPrQHykkX/Jlg/r6Ob7ztX2dEEAAb4ELE/d4icfGuSy10Acg/kyw== + version "4.10.2" + resolved "https://registry.npmjs.org/cornerstone-wado-image-loader/-/cornerstone-wado-image-loader-4.10.2.tgz#139956654324fd2b01fe5b4900d0f4ed8c52633d" + integrity sha512-qj9dThELqYCm3jAZfg9qnUl8d76gngOl55kYJabY5lh/dFeVIxno/hYxy3ydE7RtG2c/TUGXb+EMUl0CJSqKBQ== dependencies: "@babel/eslint-parser" "^7.19.1" "@cornerstonejs/codec-charls" "^1.2.3" From bc642fd2b688c390633c0322aa239fca46367127 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Wed, 22 Mar 2023 17:45:28 -0400 Subject: [PATCH 08/19] feat: Allow configurable context menus (#2894) * feat: Context menu feat: Custom context menu Adding documentation PR updates * fix: Capture tool exception * PR updates * Add fully worked out examples in the basic test mode/extension * Fix the menu display * fix: Make the commands on clicks much more configurable * Wait for load before double clicking * docs * PR changes - nothing functional, just moving things endlessly * PR comments * PR changes - rename the default context menu * Renamed the cornerstoneContextMenu to measurementsContextMenu * Add chevron right to the sub-menus --- .../Overlays/CustomizableViewportOverlay.tsx | 21 +- extensions/cornerstone/src/commandsModule.ts | 223 +++++++++++++++++- extensions/cornerstone/src/index.tsx | 25 +- extensions/cornerstone/src/init.tsx | 152 +----------- extensions/cornerstone/src/initContextMenu.ts | 128 ++++++++++ extensions/default/package.json | 2 +- .../ContextMenuController.tsx | 208 ++++++++++++++++ .../ContextMenuItemsBuilder.test.js | 29 +++ .../ContextMenuItemsBuilder.ts | 193 +++++++++++++++ .../defaultContextMenu.ts | 31 +++ .../src/CustomizeableContextMenu/index.ts | 11 + .../src/CustomizeableContextMenu/types.ts | 123 ++++++++++ extensions/default/src/commandsModule.ts | 71 +++++- .../default/src/getCustomizationModule.tsx | 26 +- extensions/default/src/{index.js => index.ts} | 22 +- extensions/default/src/{init.js => init.ts} | 2 +- .../src/custom-context-menu/codingValues.ts | 80 +++++++ .../contextMenuCodeItem.ts | 27 +++ .../findingsContextMenu.ts | 100 ++++++++ .../src/custom-context-menu/index.ts | 5 + .../src/getCustomizationModule.ts | 14 ++ extensions/test-extension/src/index.tsx | 4 + modes/basic-test-mode/src/index.js | 7 + platform/core/src/classes/CommandsManager.ts | 2 +- .../core/src/extensions/ExtensionManager.ts | 14 +- .../CustomizationService.ts | 24 +- platform/core/src/types/Command.ts | 6 +- .../services/ui/customization-service.md | 32 ++- .../components/ContextMenu/ContextMenu.tsx | 21 +- .../ContextMenu/{index.js => index.ts} | 0 .../ContextMenuMeasurements.tsx | 44 ---- .../ContextMenuMeasurements/index.js | 1 - platform/ui/src/components/Header/Header.tsx | 46 ++-- platform/ui/src/components/index.js | 2 - platform/ui/src/index.js | 1 - platform/ui/src/types/ContextMenuItem.ts | 7 + platform/ui/src/types/Predicate.ts | 1 + platform/ui/src/types/index.ts | 3 + .../OHIFContextMenuCustomization.spec.js | 42 ++++ .../OHIFStudyBrowser.spec.js | 2 +- 40 files changed, 1472 insertions(+), 280 deletions(-) create mode 100644 extensions/cornerstone/src/initContextMenu.ts create mode 100644 extensions/default/src/CustomizeableContextMenu/ContextMenuController.tsx create mode 100644 extensions/default/src/CustomizeableContextMenu/ContextMenuItemsBuilder.test.js create mode 100644 extensions/default/src/CustomizeableContextMenu/ContextMenuItemsBuilder.ts create mode 100644 extensions/default/src/CustomizeableContextMenu/defaultContextMenu.ts create mode 100644 extensions/default/src/CustomizeableContextMenu/index.ts create mode 100644 extensions/default/src/CustomizeableContextMenu/types.ts rename extensions/default/src/{index.js => index.ts} (73%) rename extensions/default/src/{init.js => init.ts} (97%) create mode 100644 extensions/test-extension/src/custom-context-menu/codingValues.ts create mode 100644 extensions/test-extension/src/custom-context-menu/contextMenuCodeItem.ts create mode 100644 extensions/test-extension/src/custom-context-menu/findingsContextMenu.ts create mode 100644 extensions/test-extension/src/custom-context-menu/index.ts create mode 100644 extensions/test-extension/src/getCustomizationModule.ts rename platform/ui/src/components/ContextMenu/{index.js => index.ts} (100%) delete mode 100644 platform/ui/src/components/ContextMenuMeasurements/ContextMenuMeasurements.tsx delete mode 100644 platform/ui/src/components/ContextMenuMeasurements/index.js create mode 100644 platform/ui/src/types/ContextMenuItem.ts create mode 100644 platform/ui/src/types/Predicate.ts create mode 100644 platform/viewer/cypress/integration/measurement-tracking/OHIFContextMenuCustomization.spec.js diff --git a/extensions/cornerstone/src/Viewport/Overlays/CustomizableViewportOverlay.tsx b/extensions/cornerstone/src/Viewport/Overlays/CustomizableViewportOverlay.tsx index 38f1d206a..bf7665200 100644 --- a/extensions/cornerstone/src/Viewport/Overlays/CustomizableViewportOverlay.tsx +++ b/extensions/cornerstone/src/Viewport/Overlays/CustomizableViewportOverlay.tsx @@ -117,8 +117,11 @@ function CustomizableViewportOverlay({ viewportIndex, servicesManager, }) { - const { toolbarService, cornerstoneViewportService, customizationService } = - servicesManager.services; + const { + toolbarService, + cornerstoneViewportService, + customizationService, + } = servicesManager.services; const [voi, setVOI] = useState({ windowCenter: null, windowWidth: null }); const [scale, setScale] = useState(1); const [activeTools, setActiveTools] = useState([]); @@ -202,10 +205,9 @@ function CustomizableViewportOverlay({ previousCamera.parallelScale !== camera.parallelScale || previousCamera.scale !== camera.scale ) { - const viewport = - cornerstoneViewportService.getCornerstoneViewportByIndex( - viewportIndex - ); + const viewport = cornerstoneViewportService.getCornerstoneViewportByIndex( + viewportIndex + ); if (!viewport) { return; @@ -283,7 +285,7 @@ function CustomizableViewportOverlay({ } else if (item.customizationType === 'ohif.overlayItem.instanceNumber') { return ; } else { - const renderItem = customizationService.applyType(item); + const renderItem = customizationService.transform(item); if (typeof renderItem.content === 'function') { return renderItem.content(overlayItemProps); @@ -450,8 +452,9 @@ function _getInstanceNumberFromVolume( const volume = volumes[0]; const { direction, imageIds } = volume; - const cornerstoneViewport = - cornerstoneViewportService.getCornerstoneViewportByIndex(viewportIndex); + const cornerstoneViewport = cornerstoneViewportService.getCornerstoneViewportByIndex( + viewportIndex + ); if (!cornerstoneViewport) { return; diff --git a/extensions/cornerstone/src/commandsModule.ts b/extensions/cornerstone/src/commandsModule.ts index 6298792c9..55c212446 100644 --- a/extensions/cornerstone/src/commandsModule.ts +++ b/extensions/cornerstone/src/commandsModule.ts @@ -16,13 +16,10 @@ import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownload import callInputDialog from './utils/callInputDialog'; import { setColormap } from './utils/colormap/transferFunctionHelpers'; import toggleStackImageSync from './utils/stackSync/toggleStackImageSync'; +import { getFirstAnnotationSelected } from './utils/measurementServiceMappings/utils/selection'; import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement'; -const commandsModule = ({ - servicesManager, -}: { - servicesManager: ServicesManager; -}): React.FunctionComponent => { +function commandsModule({ servicesManager, commandsManager }) { const { viewportGridService, toolGroupService, @@ -31,7 +28,12 @@ const commandsModule = ({ uiDialogService, cornerstoneViewportService, uiNotificationService, - } = servicesManager.services; + customizationService, + measurementService, + hangingProtocolService, + } = (servicesManager as ServicesManager).services; + + const { measurementServiceSource } = this; function _getActiveViewportEnabledElement() { return getActiveViewportEnabledElement(viewportGridService); @@ -72,9 +74,175 @@ const commandsModule = ({ } const actions = { - getActiveViewportEnabledElement: () => { - return _getActiveViewportEnabledElement(); + /** + * Generates the selector props for the context menu, specific to + * the cornerstone viewport, and then runs the context menu. + */ + showCornerstoneContextMenu: options => { + const element = _getActiveViewportEnabledElement()?.viewport?.element; + + const optionsToUse = { ...options, element }; + const { useSelectedAnnotation, nearbyToolData, event } = optionsToUse; + + // This code is used to invoke the context menu via keyboard shortcuts + if (useSelectedAnnotation && !nearbyToolData) { + const firstAnnotationSelected = getFirstAnnotationSelected(element); + // filter by allowed selected tools from config property (if there is any) + const isToolAllowed = + !optionsToUse.allowedSelectedTools || + optionsToUse.allowedSelectedTools.includes( + firstAnnotationSelected?.metadata?.toolName + ); + if (isToolAllowed) { + optionsToUse.nearbyToolData = firstAnnotationSelected; + } else { + return; + } + } + + optionsToUse.defaultPointsPosition = []; + // if (optionsToUse.nearbyToolData) { + // optionsToUse.defaultPointsPosition = commandsManager.runCommand( + // 'getToolDataActiveCanvasPoints', + // { toolData: optionsToUse.nearbyToolData } + // ); + // } + + // TODO - make the selectorProps richer by including the study metadata and display set. + optionsToUse.selectorProps = { + toolName: optionsToUse.nearbyToolData?.metadata?.toolName, + value: optionsToUse.nearbyToolData, + uid: optionsToUse.nearbyToolData?.annotationUID, + nearbyToolData: optionsToUse.nearbyToolData, + event, + ...optionsToUse.selectorProps, + }; + + commandsManager.run(options, optionsToUse); }, + + getNearbyToolData({ nearbyToolData, element, canvasCoordinates }) { + return ( + nearbyToolData ?? + cstUtils.getAnnotationNearPoint(element, canvasCoordinates) + ); + }, + + // Measurement tool commands: + + /** Delete the given measurement */ + deleteMeasurement: ({ uid }) => { + if (uid) { + measurementServiceSource.remove(uid); + } + }, + + /** + * Show the measurement labelling input dialog and update the label + * on the measurement with a response if not cancelled. + */ + setMeasurementLabel: ({ uid }) => { + const measurement = measurementService.getMeasurement(uid); + + callInputDialog( + uiDialogService, + measurement, + (label, actionId) => { + if (actionId === 'cancel') { + return; + } + + const updatedMeasurement = Object.assign({}, measurement, { + label, + }); + + measurementService.update( + updatedMeasurement.uid, + updatedMeasurement, + true + ); + }, + false + ); + }, + + /** + * + * @param props - containing the updates to apply + * @param props.measurementKey - chooses the measurement key to apply the + * code to. This will typically be finding or site to apply a + * finind code or a findingSites code. + * @param props.code - A coding scheme value from DICOM, including: + * * CodeValue - the language independent code, for example '1234' + * * CodingSchemeDesignator - the issue of the code value + * * CodeMeaning - the text value shown to the user + * * ref - a string reference in the form `:` + * * Other fields + * Note it is a valid option to remove the finding or site values by + * supplying null for the code. + * @param props.uid - the measurement UID to find it with + * @param props.label - the text value for the code. Has NOTHING to do with + * the measurement label, which can be set with textLabel + * @param props.textLabel is the measurement label to apply. Set to null to + * delete. + * + * If the measurementKey is `site`, then the code will also be added/replace + * the 0 element of findingSites. This behaviour is expected to be enhanced + * in the future with ability to set other site information. + */ + updateMeasurement: props => { + const { code, uid, textLabel, label } = props; + const measurement = measurementService.getMeasurement(uid); + const updatedMeasurement = { + ...measurement, + }; + // Call it textLabel as the label value + // TODO - remove the label setting when direct rendering of findingSites is enabled + if (textLabel !== undefined) { + updatedMeasurement.label = textLabel; + } + if (code !== undefined) { + const measurementKey = code.type || 'finding'; + + if (code.ref && !code.CodeValue) { + const split = code.ref.indexOf(':'); + code.CodeValue = code.ref.substring(split + 1); + code.CodeMeaning = code.text || label; + code.CodingSchemeDesignator = code.ref.substring(0, split); + } + updatedMeasurement[measurementKey] = code; + // TODO - remove this line once the measurements table customizations are in + if (measurementKey !== 'finding') { + if (updatedMeasurement.findingSites) { + updatedMeasurement.findingSites = updatedMeasurement.findingSites.filter( + it => it.type !== measurementKey + ); + updatedMeasurement.findingSites.push(code); + } else { + updatedMeasurement.findingSites = [code]; + } + } + // TODO - remove this once measurement items customization is ready + const allCodes = []; + if (textLabel) allCodes.push(textLabel); + if (updatedMeasurement.finding) { + allCodes.push(updatedMeasurement.finding.CodeMeaning); + } + (updatedMeasurement.findingSites || []).forEach(it => + allCodes.push(it.CodeMeaning) + ); + updatedMeasurement.label = allCodes.join(', '); + } + measurementService.update( + updatedMeasurement.uid, + updatedMeasurement, + true + ); + }, + + // Retrieve value commands + getActiveViewportEnabledElement: _getActiveViewportEnabledElement, + setViewportActive: ({ viewportId }) => { const viewportInfo = cornerstoneViewportService.getViewportInfo( viewportId @@ -457,6 +625,43 @@ const commandsModule = ({ }; const definitions = { + // The command here is to show the viewer context menu, as being the + // context menu + showCornerstoneContextMenu: { + commandFn: actions.showCornerstoneContextMenu, + storeContexts: [], + options: { + menuCustomizationId: 'measurementsContextMenu', + commands: [ + { + commandName: 'showContextMenu', + }, + ], + }, + }, + + getNearbyToolData: { + commandFn: actions.getNearbyToolData, + storeContexts: [], + options: {}, + }, + + deleteMeasurement: { + commandFn: actions.deleteMeasurement, + storeContexts: [], + options: {}, + }, + setMeasurementLabel: { + commandFn: actions.setMeasurementLabel, + storeContexts: [], + options: {}, + }, + updateMeasurement: { + commandFn: actions.updateMeasurement, + storeContexts: [], + options: {}, + }, + setWindowLevel: { commandFn: actions.setWindowLevel, storeContexts: [], @@ -587,6 +792,6 @@ const commandsModule = ({ definitions, defaultContext: 'CORNERSTONE', }; -}; +} export default commandsModule; diff --git a/extensions/cornerstone/src/index.tsx b/extensions/cornerstone/src/index.tsx index f4cf97070..f1ec22f16 100644 --- a/extensions/cornerstone/src/index.tsx +++ b/extensions/cornerstone/src/index.tsx @@ -10,7 +10,7 @@ import { Enums as cs3DToolsEnums } from '@cornerstonejs/tools'; import { Types } from '@ohif/core'; import init from './init'; -import commandsModule from './commandsModule'; +import getCommandsModule from './commandsModule'; import getHangingProtocolModule from './getHangingProtocolModule'; import ToolGroupService from './services/ToolGroupService'; import SyncGroupService from './services/SyncGroupService'; @@ -51,7 +51,7 @@ const cornerstoneExtension: Types.Extensions.Extension = { */ id, - onModeExit: () => { + onModeExit: (): void => { // Empty out the image load and retrieval pools to prevent memory leaks // on the mode exits Object.values(cs3DEnums.RequestType).forEach(type => { @@ -68,12 +68,10 @@ const cornerstoneExtension: Types.Extensions.Extension = { * * @param configuration.csToolsConfig - Passed directly to `initCornerstoneTools` */ - async preRegistration({ - servicesManager, - commandsManager, - configuration = {}, - appConfig, - }) { + preRegistration: function ( + props: Types.Extensions.ExtensionParams + ): Promise { + const { servicesManager } = props; // Todo: we should be consistent with how services get registered. Use REGISTRATION static method for all servicesManager.registerService( CornerstoneViewportService(servicesManager) @@ -87,8 +85,9 @@ const cornerstoneExtension: Types.Extensions.Extension = { CornerstoneCacheService.REGISTRATION(servicesManager) ); - await init({ servicesManager, commandsManager, configuration, appConfig }); + return init.call(this, props); }, + getHangingProtocolModule, getViewportModule({ servicesManager, commandsManager }) { const ExtendedOHIFCornerstoneViewport = props => { @@ -114,13 +113,7 @@ const cornerstoneExtension: Types.Extensions.Extension = { }, ]; }, - getCommandsModule({ servicesManager, commandsManager, extensionManager }) { - return commandsModule({ - servicesManager, - commandsManager, - extensionManager, - }); - }, + getCommandsModule, getUtilityModule({ servicesManager }) { return [ { diff --git a/extensions/cornerstone/src/init.tsx b/extensions/cornerstone/src/init.tsx index 858ac41f1..c44ec812d 100644 --- a/extensions/cornerstone/src/init.tsx +++ b/extensions/cornerstone/src/init.tsx @@ -1,6 +1,5 @@ import OHIF from '@ohif/core'; import React from 'react'; -import { ContextMenuMeasurements } from '@ohif/ui'; import * as cornerstone from '@cornerstonejs/core'; import * as cornerstoneTools from '@cornerstonejs/tools'; @@ -21,15 +20,11 @@ import initWADOImageLoader from './initWADOImageLoader'; import initCornerstoneTools from './initCornerstoneTools'; import { connectToolsToMeasurementService } from './initMeasurementService'; -import callInputDialog from './utils/callInputDialog'; import initCineService from './initCineService'; import interleaveCenterLoader from './utils/interleaveCenterLoader'; import nthLoader from './utils/nthLoader'; import interleaveTopToBottom from './utils/interleaveTopToBottom'; - -const cs3DToolsEvents = Enums.Events; - -let CONTEXT_MENU_OPEN = false; +import initContextMenu from './initContextMenu'; // TODO: Cypress tests are currently grabbing this from the window? window.cornerstone = cornerstone; @@ -42,7 +37,7 @@ export default async function init({ commandsManager, configuration, appConfig, -}) { +}: Types.Extensions.ExtensionParams): Promise { await cs3DInit(); // For debugging e2e tests that are failing on CI @@ -65,6 +60,7 @@ export default async function init({ const { userAuthenticationService, measurementService, + customizationService, displaySetService, uiDialogService, uiModalService, @@ -155,118 +151,12 @@ export default async function init({ initWADOImageLoader(userAuthenticationService, appConfig); /* Measurement Service */ - const measurementServiceSource = connectToolsToMeasurementService( + this.measurementServiceSource = connectToolsToMeasurementService( servicesManager ); initCineService(cineService); - const _getDefaultPosition = event => ({ - x: (event && event.currentPoints.client[0]) || 0, - y: (event && event.currentPoints.client[1]) || 0, - }); - - const onRightClick = event => { - if (!uiDialogService) { - console.warn('Unable to show dialog; no UI Dialog Service available.'); - return; - } - - const onGetMenuItems = defaultMenuItems => { - const { element, currentPoints } = event.detail; - - const nearbyToolData = utilities.getAnnotationNearPoint( - element, - currentPoints.canvas - ); - - const menuItems = []; - if (nearbyToolData && nearbyToolData.metadata.toolName !== 'Crosshairs') { - defaultMenuItems.forEach(item => { - item.value = nearbyToolData; - item.element = element; - menuItems.push(item); - }); - } - - return menuItems; - }; - - CONTEXT_MENU_OPEN = true; - - uiDialogService.dismiss({ id: 'context-menu' }); - uiDialogService.create({ - id: 'context-menu', - isDraggable: false, - preservePosition: false, - defaultPosition: _getDefaultPosition(event.detail), - content: ContextMenuMeasurements, - onClickOutside: () => { - uiDialogService.dismiss({ id: 'context-menu' }); - CONTEXT_MENU_OPEN = false; - }, - contentProps: { - onGetMenuItems, - eventData: event.detail, - onDelete: item => { - const { annotationUID } = item.value; - - const uid = annotationUID; - // Sync'd w/ Measurement Service - if (uid) { - measurementServiceSource.remove(uid, { - element: item.element, - }); - } - CONTEXT_MENU_OPEN = false; - }, - onClose: () => { - CONTEXT_MENU_OPEN = false; - uiDialogService.dismiss({ id: 'context-menu' }); - }, - onSetLabel: item => { - const { annotationUID } = item.value; - - const measurement = measurementService.getMeasurement(annotationUID); - - callInputDialog( - uiDialogService, - measurement, - (label, actionId) => { - if (actionId === 'cancel') { - return; - } - - const updatedMeasurement = Object.assign({}, measurement, { - label, - }); - - measurementService.update( - updatedMeasurement.uid, - updatedMeasurement, - true - ); - }, - false - ); - - CONTEXT_MENU_OPEN = false; - }, - }, - }); - }; - - const resetContextMenu = () => { - if (!uiDialogService) { - console.warn('Unable to show dialog; no UI Dialog Service available.'); - return; - } - - CONTEXT_MENU_OPEN = false; - - uiDialogService.dismiss({ id: 'context-menu' }); - }; - // When a custom image load is performed, update the relevant viewports hangingProtocolService.subscribe( hangingProtocolService.EVENTS.CUSTOM_IMAGE_LOAD_PERFORMED, @@ -285,24 +175,11 @@ export default async function init({ } ); - /* - * Because click gives us the native "mouse up", buttons will always be `0` - * Need to fallback to event.which; - * - */ - const contextMenuHandleClick = evt => { - const mouseUpEvent = evt.detail.event; - const isRightClick = mouseUpEvent.which === 3; - - const clickMethodHandler = isRightClick ? onRightClick : resetContextMenu; - clickMethodHandler(evt); - }; - - // const cancelContextMenuIfOpen = evt => { - // if (CONTEXT_MENU_OPEN) { - // resetContextMenu(); - // } - // }; + initContextMenu({ + cornerstoneViewportService, + customizationService, + commandsManager, + }); const newStackCallback = evt => { const { element } = evt.detail; @@ -337,12 +214,6 @@ export default async function init({ function elementEnabledHandler(evt) { const { element } = evt.detail; - - element.addEventListener( - cs3DToolsEvents.MOUSE_CLICK, - contextMenuHandleClick - ); - element.addEventListener(EVENTS.CAMERA_RESET, resetCrosshairs); eventTarget.addEventListener( @@ -354,11 +225,6 @@ export default async function init({ function elementDisabledHandler(evt) { const { element } = evt.detail; - element.removeEventListener( - cs3DToolsEvents.MOUSE_CLICK, - contextMenuHandleClick - ); - element.removeEventListener(EVENTS.CAMERA_RESET, resetCrosshairs); // TODO - consider removing the callback when all elements are gone diff --git a/extensions/cornerstone/src/initContextMenu.ts b/extensions/cornerstone/src/initContextMenu.ts new file mode 100644 index 000000000..a9f69db51 --- /dev/null +++ b/extensions/cornerstone/src/initContextMenu.ts @@ -0,0 +1,128 @@ +import { eventTarget, EVENTS } from '@cornerstonejs/core'; +import { Enums } from '@cornerstonejs/tools'; +import { setEnabledElement } from './state'; + +const cs3DToolsEvents = Enums.Events; + +const DEFAULT_CONTEXT_MENU_CLICKS = { + button1: { + commands: [ + { + commandName: 'closeContextMenu', + }, + ], + }, + button3: { + commands: [ + { + commandName: 'showCornerstoneContextMenu', + commandOptions: { + 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 { + /** + * Finds tool nearby event position triggered. + * + * @param {Object} commandsManager mannager of commands + * @param {Object} event that has being triggered + * @returns cs toolData or undefined if not found. + */ + const findNearbyToolData = evt => { + if (!evt?.detail) { + return; + } + const { element, currentPoints } = evt.detail; + return commandsManager.runCommand( + 'getNearbyToolData', + { + element, + canvasCoordinates: currentPoints?.canvas, + }, + 'CORNERSTONE' + ); + }; + + /* + * 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]; + console.log('initContextMenu::cornerstoneViewportHandleEvent', name, toRun); + const options = { + nearbyToolData: findNearbyToolData(evt), + 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; + const viewportIndex = viewportInfo.getViewportIndex(); + // TODO check update upstream + setEnabledElement(viewportIndex, 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; diff --git a/extensions/default/package.json b/extensions/default/package.json index 80332ef34..0ca309f62 100644 --- a/extensions/default/package.json +++ b/extensions/default/package.json @@ -6,7 +6,7 @@ "license": "MIT", "repository": "OHIF/Viewers", "main": "dist/index.umd.js", - "module": "src/index.js", + "module": "src/index.ts", "publishConfig": { "access": "public" }, diff --git a/extensions/default/src/CustomizeableContextMenu/ContextMenuController.tsx b/extensions/default/src/CustomizeableContextMenu/ContextMenuController.tsx new file mode 100644 index 000000000..82879bf02 --- /dev/null +++ b/extensions/default/src/CustomizeableContextMenu/ContextMenuController.tsx @@ -0,0 +1,208 @@ +import * as ContextMenuItemsBuilder from './ContextMenuItemsBuilder'; +import ContextMenu from '../../../../platform/ui/src/components/ContextMenu/ContextMenu'; +import { CommandsManager, ServicesManager, Types } from '@ohif/core'; +import { Menu, MenuItem, Point, ContextMenuProps } from './types'; + +/** + * The context menu controller is a helper class that knows how + * to manage context menus based on the UI Customization Service. + * There are a few parts to this: + * 1. Basic controls to manage displaying and hiding context menus + * 2. Menu selection services, which use the UI customization service + * to choose which menu to display + * 3. Menu item adapter services to convert menu items into displayable and actionable items. + * + * The format for a menu is defined in the exported type MenuItem + */ +export default class ContextMenuController { + commandsManager: CommandsManager; + services: Types.Services; + menuItems: Menu[] | MenuItem[]; + + constructor( + servicesManager: ServicesManager, + commandsManager: CommandsManager + ) { + this.services = servicesManager.services as Obj; + this.commandsManager = commandsManager; + } + + closeContextMenu() { + this.services.uiDialogService.dismiss({ id: 'context-menu' }); + } + + /** + * Figures out which context menu is appropriate to display and shows it. + * + * @param contextMenuProps - the context menu properties, see ./types.ts + * @param viewportElement - the DOM element this context menu is related to + * @param defaultPointsPosition - a default position to show the context menu + */ + showContextMenu( + contextMenuProps: ContextMenuProps, + viewportElement, + defaultPointsPosition + ): void { + if (!this.services.uiDialogService) { + console.warn('Unable to show dialog; no UI Dialog Service available.'); + return; + } + + const { event, subMenu, menuId, menus, selectorProps } = contextMenuProps; + + console.log('Getting items from', menus); + const items = ContextMenuItemsBuilder.getMenuItems( + selectorProps || contextMenuProps, + event, + menus, + menuId + ); + + this.services.uiDialogService.dismiss({ id: 'context-menu' }); + this.services.uiDialogService.create({ + id: 'context-menu', + isDraggable: false, + preservePosition: false, + preventCutOf: true, + defaultPosition: ContextMenuController._getDefaultPosition( + defaultPointsPosition, + event?.detail, + viewportElement + ), + event, + content: ContextMenu, + + // This naming is part of hte uiDialogService convention + // Clicking outside simpy closes the dialog box. + onClickOutside: () => + this.services.uiDialogService.dismiss({ id: 'context-menu' }), + + contentProps: { + items, + selectorProps, + menus, + event, + subMenu, + eventData: event?.detail, + + onClose: () => { + this.services.uiDialogService.dismiss({ id: 'context-menu' }); + }, + + /** + * Displays a sub-menu, removing this menu + * @param {*} item + * @param {*} itemRef + * @param {*} subProps + */ + onShowSubMenu: (item, itemRef, subProps) => { + if (!itemRef.subMenu) { + console.warn('No submenu defined for', item, itemRef, subProps); + return; + } + this.showContextMenu( + { + ...contextMenuProps, + menuId: itemRef.subMenu, + }, + viewportElement, + defaultPointsPosition + ); + }, + + // Default is to run the specified commands. + onDefault: (item, itemRef, subProps) => { + this.commandsManager.run(item, { + ...selectorProps, + ...itemRef, + subProps, + }); + }, + }, + }); + } + + static getDefaultPosition = (): Point => { + return { + x: 0, + y: 0, + }; + }; + + static _getEventDefaultPosition = eventDetail => ({ + x: eventDetail && eventDetail.currentPoints.client[0], + y: eventDetail && eventDetail.currentPoints.client[1], + }); + + static _getElementDefaultPosition = element => { + if (element) { + const boundingClientRect = element.getBoundingClientRect(); + return { + x: boundingClientRect.x, + y: boundingClientRect.y, + }; + } + + return { + x: undefined, + y: undefined, + }; + }; + + static _getCanvasPointsPosition = (points = [], element) => { + const viewerPos = ContextMenuController._getElementDefaultPosition(element); + + for (let pointIndex = 0; pointIndex < points.length; pointIndex++) { + const point = { + x: points[pointIndex][0] || points[pointIndex]['x'], + y: points[pointIndex][1] || points[pointIndex]['y'], + }; + if ( + ContextMenuController._isValidPosition(point) && + ContextMenuController._isValidPosition(viewerPos) + ) { + return { + x: point.x + viewerPos.x, + y: point.y + viewerPos.y, + }; + } + } + }; + + static _isValidPosition = (source): boolean => { + return ( + source && typeof source.x === 'number' && typeof source.y === 'number' + ); + }; + + /** + * Returns the context menu default position. It look for the positions of: canvasPoints (got from selected), event that triggers it, current viewport element + */ + static _getDefaultPosition = (canvasPoints, eventDetail, viewerElement) => { + function* getPositionIterator() { + yield ContextMenuController._getCanvasPointsPosition( + canvasPoints, + viewerElement + ); + yield ContextMenuController._getEventDefaultPosition(eventDetail); + yield ContextMenuController._getElementDefaultPosition(viewerElement); + yield ContextMenuController.getDefaultPosition(); + } + + const positionIterator = getPositionIterator(); + + let current = positionIterator.next(); + let position = current.value; + + while (!current.done) { + position = current.value; + + if (ContextMenuController._isValidPosition(position)) { + positionIterator.return(); + } + current = positionIterator.next(); + } + + return position; + }; +} diff --git a/extensions/default/src/CustomizeableContextMenu/ContextMenuItemsBuilder.test.js b/extensions/default/src/CustomizeableContextMenu/ContextMenuItemsBuilder.test.js new file mode 100644 index 000000000..b5555f71f --- /dev/null +++ b/extensions/default/src/CustomizeableContextMenu/ContextMenuItemsBuilder.test.js @@ -0,0 +1,29 @@ +import ContextMenuItemsBuilder from "./ContextMenuItemsBuilder"; + +const menus = [ + { + id: 'one', + selector: ({ value }) => value === 'one', + items: [], + }, + { + id: 'two', + selector: ({ value }) => value === 'two', + items: [], + }, + { + id: 'default', + items: [], + }, +]; + +const menuBuilder = new ContextMenuItemsBuilder(); + +describe('ContextMenuItemsBuilder', () => { + test('findMenuDefault', () => { + expect(menuBuilder.findMenuDefault(menus, {})).toBe(menus[2]); + expect(menuBuilder.findMenuDefault(menus, { value: 'two' })).toBe(menus[1]); + expect(menuBuilder.findMenuDefault([], {})).toBeUndefined(); + expect(menuBuilder.findMenuDefault(undefined, undefined)).toBeNull(); + }); +}); diff --git a/extensions/default/src/CustomizeableContextMenu/ContextMenuItemsBuilder.ts b/extensions/default/src/CustomizeableContextMenu/ContextMenuItemsBuilder.ts new file mode 100644 index 000000000..ad5bc7380 --- /dev/null +++ b/extensions/default/src/CustomizeableContextMenu/ContextMenuItemsBuilder.ts @@ -0,0 +1,193 @@ +import { Types } from '@ohif/ui'; +import { Menu, SelectorProps, MenuItem, ContextMenuProps } from './types'; + +type ContextMenuItem = Types.ContextMenuItem; + +/** + * Finds menu by menu id + * + * @returns Menu having the menuId + */ +export function findMenuById(menus: Menu[], menuId?: string): Menu { + if (!menuId) { + return; + } + + return menus.find(menu => menu.id === menuId); +} + +/** + * Default finding menu method. This method will go through + * the list of menus until it finds the first one which + * has no selector, OR has the selector, when applied to the + * check props, return true. + * The selectorProps are a set of provided properties which can be + * passed into the selector function to determine when to display a menu. + * For example, a selector function of: + * `({displayset}) => displaySet?.SeriesDescription?.indexOf?.('Left')!==-1 + * would match series descriptions containing 'Left'. + * + * @param {Object[]} menus List of menus + * @param {*} subProps + * @returns + */ +export function findMenuDefault( + menus: Menu[], + subProps: Record +): Menu { + if (!menus) { + return null; + } + return menus.find( + menu => !menu.selector || menu.selector(subProps.selectorProps) + ); +} + +/** + * Finds the menu to be used for different scenarios: + * This will first look for a subMenu with the specified subMenuId + * Next it will look for the first menu whose selector returns true. + * + * @param menus - List of menus + * @param props - root props + * @param menuIdFilter - menu id identifier (to be considered on selection) + * This is intended to support other types of filtering in the future. + */ +export function findMenu( + menus: Menu[], + props?: Types.IProps, + menuIdFilter?: string +) { + const { subMenu } = props; + + function* findMenuIterator() { + yield findMenuById(menus, menuIdFilter || subMenu); + yield findMenuDefault(menus, props); + } + + const findIt = findMenuIterator(); + + let current = findIt.next(); + let menu = current.value; + + while (!current.done) { + menu = current.value; + + if (menu) { + findIt.return(); + } + current = findIt.next(); + } + + console.log('Menu chosen', menu?.id || 'NONE'); + + return menu; +} + +/** + * Returns the menu from a list of possible menus, based on the actual state of component props and tool data nearby. + * This uses the findMenu command above to first find the appropriate + * menu, and then it chooses the actual contents of that menu. + * A menu item can be optional by implementing the 'selector', + * which will be called with the selectorProps, and if it does not return true, + * then the item is excluded. + * + * Other menus can be delegated to by setting the delegating value to + * a string id for another menu. That menu's content will replace the + * current menu item (only if the item would be included). + * + * This allows single id menus to be chosen by id, but have varying contents + * based on the delegated menus. + * + * Finally, for each item, the adaptItem call is made. This allows + * items to modify themselves before being displayed, such as + * incorporating additional information from translation sources. + * See the `test-mode` examples for details. + * + * @param selectorProps + * @param {*} event event that originates the context menu + * @param {*} menus List of menus + * @param {*} menuIdFilter + * @returns + */ +export function getMenuItems( + selectorProps: Types.IProps, + event: Event, + menus: Menu[], + menuIdFilter?: string +): MenuItem[] | void { + // Include both the check props and the ...check props as one is used + // by the child menu and the other used by the selector function + const subProps = { selectorProps, event }; + + const menu = findMenu(menus, subProps, menuIdFilter); + + if (!menu) { + return undefined; + } + + if (!menu.items) { + console.warn('Must define items in menu', menu); + return []; + } + + let menuItems = []; + menu.items.forEach(item => { + const { delegating, selector, subMenu } = item; + + if (!selector || selector(selectorProps)) { + if (delegating) { + menuItems = [ + ...menuItems, + ...getMenuItems(selectorProps, event, menus, subMenu), + ]; + } else { + const toAdd = adaptItem(item, subProps); + menuItems.push(toAdd); + } + } + }); + + return menuItems; +} + +/** + * Returns item adapted to be consumed by ContextMenu component + * and then goes through the item to add action behaviour for clicking the item, + * making it compatible with the default ContextMenu display. + * + * @param {Object} item + * @param {Object} subProps + * @returns a MenuItem that is compatible with the base ContextMenu + * This requires having a label and set of actions to be called. + */ +export function adaptItem( + item: MenuItem, + subProps: ContextMenuProps +): ContextMenuItem { + const newItem: ContextMenuItem = { + ...item, + value: subProps.selectorProps?.value, + }; + + if (item.actionType === 'ShowSubMenu' && !newItem.iconRight) { + newItem.iconRight = 'chevron-right'; + } + if (!item.action) { + newItem.action = (itemRef, componentProps) => { + const { event = {} } = componentProps; + const { detail = {} } = event; + newItem.element = detail.element; + + componentProps.onClose(); + const action = componentProps[`on${itemRef.actionType || 'Default'}`]; + if (action) { + action.call(componentProps, newItem, itemRef, subProps); + } else { + console.warn('No action defined for', itemRef); + } + }; + } + + return newItem; +} diff --git a/extensions/default/src/CustomizeableContextMenu/defaultContextMenu.ts b/extensions/default/src/CustomizeableContextMenu/defaultContextMenu.ts new file mode 100644 index 000000000..29a760c79 --- /dev/null +++ b/extensions/default/src/CustomizeableContextMenu/defaultContextMenu.ts @@ -0,0 +1,31 @@ +const defaultContextMenu = { + id: 'measurementsContextMenu', + customizationType: 'ohif.contextMenu', + menus: [ + // Get the items from the UI Customization for the menu name (and have a custom name) + { + id: 'forExistingMeasurement', + selector: ({ nearbyToolData }) => !!nearbyToolData, + items: [ + { + label: 'Delete measurement', + commands: [ + { + commandName: 'deleteMeasurement', + }, + ], + }, + { + label: 'Add Label', + commands: [ + { + commandName: 'setMeasurementLabel', + }, + ], + }, + ], + }, + ], +}; + +export default defaultContextMenu; diff --git a/extensions/default/src/CustomizeableContextMenu/index.ts b/extensions/default/src/CustomizeableContextMenu/index.ts new file mode 100644 index 000000000..d630bcad9 --- /dev/null +++ b/extensions/default/src/CustomizeableContextMenu/index.ts @@ -0,0 +1,11 @@ +import ContextMenuController from './ContextMenuController'; +import ContextMenuItemsBuilder from './ContextMenuItemsBuilder'; +import defaultContextMenu from './defaultContextMenu'; +import * as CustomizeableContextMenuTypes from './types'; + +export { + ContextMenuController, + CustomizeableContextMenuTypes, + ContextMenuItemsBuilder, + defaultContextMenu, +}; diff --git a/extensions/default/src/CustomizeableContextMenu/types.ts b/extensions/default/src/CustomizeableContextMenu/types.ts new file mode 100644 index 000000000..d0ffb4787 --- /dev/null +++ b/extensions/default/src/CustomizeableContextMenu/types.ts @@ -0,0 +1,123 @@ +import { Types } from '@ohif/core'; + +/** + * SelectorProps are properties used to decide whether to select a manu or + * menu item for display. + * An instance of SelectorProps is provided to the selector functions, which + * return true to include the item or false to exclude it. + * The point of this is to allow more specific conext menus which hide + * non-relevant menu options, optimizing the speed of selection of menus + * (See Bill Wallace's masters thesis for selection time versus complexity of user menus). + */ +export interface SelectorProps { + // If the context menu is invoked in the context of a measurement, then it + // will contain the nearby tool data. + nearbyToolData?: Record; + + // The tool name for the nearby tool + toolName?: string; + + // An annotation UID - this will be present if nearyToolData is present. + uid?: string; + + // If the context menu is invoked on an active viewport, then it will contain + // the first display set. + displaySet?: Record; + + // The triggering event - can be used to determine key modifiers + event?: Event; + + // Any other properties + [propertyName: string]: unknown; +} + +/** + * The type of item actually required for the ContextMenu UI display + */ +export type UIMenuItem = { + label: string; + // Called when the item is selected + action?: (itemRef, componentProps) => void; +}; + +/** + * A MenuItem is a single line item within a menu, and specifies a selectable + * value for the menu. + */ +export interface MenuItem { + id?: string; + /** The customization type is used to apply preset values to this item + * when registered with the customization service. + */ + customizationType?: string; + + // The label is the value to show in the menu for this item + label?: string; + + // Delegating items are used to include other sub-menus inline within + // this menu. That allows sharing part of the menu structure, but also, + // more importantly to use a single selector function to include/exclude + // and entire section of sub-menu. + // See the `siteSelectionSubMenu` within the example `findingsMenu` + // for an example + delegating?: boolean; + + // A sub-menu is shown when this item is selected or is delegating. + // This item gives the name of the sub-menu. + subMenu?: string; + + // The selector is used to determine if this menu entry will be shown + // or more importantly, if the delegating subMenu will be included. + selector?: (props: SelectorProps) => boolean; + + /** Adapts the item by filling in additional properties as requried */ + adaptItem?: (item: MenuItem, props: ContextMenuProps) => UIMenuItem; + + /** List of commands to run when this item's action is taken. */ + commands?: Types.Command[]; +} + +/** + * A menu is a list of menu items, plus a selector. + * The selector is used to determine whether the menu should be displayed + * in a given context. The parameters passed to the selector come from + * the 'selectorProps' value in the options, and are intended to be context + * specific values containing things like the selected object, the currently + * displayed study etc so that the context menu can dynamically choose which + * view to show. + */ +export interface Menu { + id: string; + + /** The customization type is used to apply preset values to this item + * when registered with the customization service. + */ + customizationType?: string; + + // Choose whether this menu applies. + selector?: Types.Predicate; + + items: MenuItem[]; +} + +export type Point = { + x: number; + y: number; +}; + +/** + * ContextMenuProps is the top level argument used to invoke the context menu + * itself. It contains the menus available for display, as well as the event + * and selector props used to decide the menu. + */ +export type ContextMenuProps = { + event?: EventTarget; + subMenu?: string; + menuId: string; + + /** A set of menus to choose from for this context menu */ + menus: Menu[]; + + /** The properties used to decide the menu type */ + selectorProps: SelectorProps; +}; diff --git a/extensions/default/src/commandsModule.ts b/extensions/default/src/commandsModule.ts index 58c3e3797..524db2812 100644 --- a/extensions/default/src/commandsModule.ts +++ b/extensions/default/src/commandsModule.ts @@ -1,5 +1,9 @@ -import { DicomMetadataStore, ServicesManager } from '@ohif/core'; +import { ServicesManager, Types } from '@ohif/core'; +import { + ContextMenuController, + defaultContextMenu, +} from './CustomizeableContextMenu'; import DicomTagBrowser from './DicomTagBrowser/DicomTagBrowser'; import reuseCachedLayouts from './utils/reuseCachedLayouts'; import findViewportsByPosition, { @@ -23,8 +27,12 @@ const isHangingProtocolCommand = command => (command.commandName === 'setHangingProtocol' || command.commandName === 'toggleHangingProtocol'); -const commandsModule = ({ servicesManager, commandsManager }) => { +const commandsModule = ({ + servicesManager, + commandsManager, +}: Types.Extensions.ExtensionParams): Types.Extensions.CommandsModule => { const { + customizationService, measurementService, hangingProtocolService, uiNotificationService, @@ -34,7 +42,60 @@ const commandsModule = ({ servicesManager, commandsManager }) => { toolbarService, } = (servicesManager as ServicesManager).services; + // Define a context menu controller for use with any context menus + const contextMenuController = new ContextMenuController( + servicesManager, + commandsManager + ); + const actions = { + /** + * Show the context menu. + * @param options.menuId defines the menu name to lookup, from customizationService + * @param options.defaultMenu contains the default menu set to use + * @param options.element is the element to show the menu within + * @param options.event is the event that caused the context menu + * @param options.selectorProps is the set of selection properties to use + */ + showContextMenu: options => { + const { + menuCustomizationId, + element, + event, + selectorProps, + defaultPointsPosition = [], + } = options; + + const optionsToUse = { ...options }; + + if (menuCustomizationId) { + Object.assign( + optionsToUse, + customizationService.get(menuCustomizationId, defaultContextMenu) + ); + } + + // TODO - make the selectorProps richer by including the study metadata and display set. + const { protocol, stage } = hangingProtocolService.getActiveProtocol(); + optionsToUse.selectorProps = { + event, + protocol, + stage, + ...selectorProps, + }; + + contextMenuController.showContextMenu( + optionsToUse, + element, + defaultPointsPosition + ); + }, + + /** Close a context menu currently displayed */ + closeContextMenu: () => { + contextMenuController.closeContextMenu(); + }, + displayNotification: ({ text, title, type }) => { uiNotificationService.show({ title: title, @@ -336,6 +397,12 @@ const commandsModule = ({ servicesManager, commandsManager }) => { }; const definitions = { + showContextMenu: { + commandFn: actions.showContextMenu, + }, + closeContextMenu: { + commandFn: actions.closeContextMenu, + }, clearMeasurements: { commandFn: actions.clearMeasurements, storeContexts: [], diff --git a/extensions/default/src/getCustomizationModule.tsx b/extensions/default/src/getCustomizationModule.tsx index 47d5d1718..babc47250 100644 --- a/extensions/default/src/getCustomizationModule.tsx +++ b/extensions/default/src/getCustomizationModule.tsx @@ -1,3 +1,4 @@ +import { CustomizationService } from '@ohif/core'; import React from 'react'; import DataSourceSelector from './Panels/DataSourceSelector'; @@ -82,7 +83,6 @@ export default function getCustomizationModule() { */ { id: 'ohif.overlayItem', - uiType: 'uiType', content: function (props) { if (this.condition && !this.condition(props)) return null; @@ -91,8 +91,8 @@ export default function getCustomizationModule() { instance && this.attribute ? instance[this.attribute] : this.contentF && typeof this.contentF === 'function' - ? this.contentF(props) - : null; + ? this.contentF(props) + : null; if (!value) return null; return ( @@ -109,6 +109,26 @@ export default function getCustomizationModule() { ); }, }, + + { + id: 'ohif.contextMenu', + + /** Applies the customizationType to all the menu items */ + transform: function (customizationService: CustomizationService) { + // Don't modify the children, as those are copied by reference + const clonedObject = { ...this }; + clonedObject.menus = this.menus.map(it => ({ ...it })); + + for (const menu of clonedObject.menus) { + const { items: originalItems } = menu; + menu.items = []; + for (const item of originalItems) { + menu.items.push(customizationService.transform(item)); + } + } + return clonedObject; + }, + }, ], }, ]; diff --git a/extensions/default/src/index.js b/extensions/default/src/index.ts similarity index 73% rename from extensions/default/src/index.js rename to extensions/default/src/index.ts index bb76b0afc..bb4a63c52 100644 --- a/extensions/default/src/index.js +++ b/extensions/default/src/index.ts @@ -1,32 +1,34 @@ +import { Types } from '@ohif/core'; + import getDataSourcesModule from './getDataSourcesModule.js'; import getLayoutTemplateModule from './getLayoutTemplateModule.js'; import getPanelModule from './getPanelModule'; import getSopClassHandlerModule from './getSopClassHandlerModule.js'; import getToolbarModule from './getToolbarModule'; -import commandsModule from './commandsModule'; +import getCommandsModule from './commandsModule'; import getHangingProtocolModule from './getHangingProtocolModule'; import getStudiesForPatientByStudyInstanceUID from './Panels/getStudiesForPatientByStudyInstanceUID'; import getCustomizationModule from './getCustomizationModule'; import { id } from './id.js'; -import init from './init'; +import preRegistration from './init'; +import { + ContextMenuController, + CustomizeableContextMenuTypes, +} from './CustomizeableContextMenu'; -const defaultExtension = { +const defaultExtension: Types.Extensions.Extension = { /** * Only required property. Should be a unique value across all extensions. */ id, - preRegistration: ({ servicesManager, configuration = {} }) => { - init({ servicesManager, configuration }); - }, + preRegistration, getDataSourcesModule, getLayoutTemplateModule, getPanelModule, getHangingProtocolModule, getSopClassHandlerModule, getToolbarModule, - getCommandsModule({ servicesManager, commandsManager }) { - return commandsModule({ servicesManager, commandsManager }); - }, + getCommandsModule, getUtilityModule({ servicesManager }) { return [ { @@ -42,3 +44,5 @@ const defaultExtension = { }; export default defaultExtension; + +export { ContextMenuController, CustomizeableContextMenuTypes }; diff --git a/extensions/default/src/init.js b/extensions/default/src/init.ts similarity index 97% rename from extensions/default/src/init.js rename to extensions/default/src/init.ts index 7c7d487dc..b979bb4f6 100644 --- a/extensions/default/src/init.js +++ b/extensions/default/src/init.ts @@ -10,7 +10,7 @@ const metadataProvider = classes.MetadataProvider; * @param {Object} servicesManager * @param {Object} configuration */ -export default function init({ servicesManager, configuration }) { +export default function init({ servicesManager, configuration = {} }): void { const { stateSyncService } = servicesManager.services; // Add DicomMetadataStore.subscribe( diff --git a/extensions/test-extension/src/custom-context-menu/codingValues.ts b/extensions/test-extension/src/custom-context-menu/codingValues.ts new file mode 100644 index 000000000..d5c4743c1 --- /dev/null +++ b/extensions/test-extension/src/custom-context-menu/codingValues.ts @@ -0,0 +1,80 @@ +/** + * Coding values is a map of simple string coding values to a set of + * attributes associated with the coding value. + * + * The simple string is in the format `:` + * That allows extracting the DICOM attributes from the designator/value, and + * allows for passing around the simple string. + * The additional attributes contained in the object include: + * * text - this is the coding scheme text display value, and may be language specific + * * type - this defines a named type, typically 'site'. Different names can be used + * to allow setting different findingSites values in order to define a hierarchy. + * * color - used to apply annotation color + * It is also possible to define additional attributes here, used by custom + * extensions. + * + * See https://dicom.nema.org/medical/dicom/current/output/html/part16.html + * for definitions of SCT and other code values. + */ +const codingValues = { + id: 'codingValues', + + // Sites + 'SCT:69536005': { + text: 'Head', + type: 'site', + }, + 'SCT:45048000': { + text: 'Neck', + type: 'site', + }, + 'SCT:818981001': { + text: 'Abdomen', + type: 'site', + }, + 'SCT:816092008': { + text: 'Pelvis', + type: 'site', + }, + + // Findings + 'SCT:371861004': { + text: 'Mild intimal coronary irregularities', + color: 'green', + }, + 'SCT:194983005': { + text: 'Aortic insufficiency', + color: 'darkred', + }, + 'SCT:399232001': { + text: '2-chamber', + }, + 'SCT:103340004': { + text: 'SAX', + }, + 'SCT:91134007': { + text: 'MV', + }, + 'SCT:122972007': { + text: 'PV', + }, + + // Orientations + 'SCT:24422004': { + text: 'Axial', + color: '#000000', + type: 'orientation', + }, + 'SCT:81654009': { + text: 'Coronal', + color: '#000000', + type: 'orientation', + }, + 'SCT:30730003': { + text: 'Sagittal', + color: '#000000', + type: 'orientation', + }, +}; + +export default codingValues; diff --git a/extensions/test-extension/src/custom-context-menu/contextMenuCodeItem.ts b/extensions/test-extension/src/custom-context-menu/contextMenuCodeItem.ts new file mode 100644 index 000000000..4e054e4f1 --- /dev/null +++ b/extensions/test-extension/src/custom-context-menu/contextMenuCodeItem.ts @@ -0,0 +1,27 @@ +import codingValues from './codingValues'; + +const codeMenuItem = { + id: '@ohif/contextMenuAnnotationCode', + codingValues, + + /** Applies the code value setup for this item */ + transform: function (customizationService) { + const { code: codeRef } = this; + if (!codeRef) throw new Error(`item ${this} has no code ref`); + const codingValues = customizationService.get('codingValues'); + const code = codingValues[codeRef]; + return { + ...this, + codeRef, + code: { ref: codeRef, ...code }, + label: code.text, + commands: [ + { + commandName: 'updateMeasurement', + }, + ], + }; + }, +}; + +export default codeMenuItem; diff --git a/extensions/test-extension/src/custom-context-menu/findingsContextMenu.ts b/extensions/test-extension/src/custom-context-menu/findingsContextMenu.ts new file mode 100644 index 000000000..ccfc0fb54 --- /dev/null +++ b/extensions/test-extension/src/custom-context-menu/findingsContextMenu.ts @@ -0,0 +1,100 @@ +const findingsContextMenu = { + id: 'measurementsContextMenu', + customizationType: 'ohif.contextMenu', + menus: [ + { + id: 'forExistingMeasurement', + // selector restricts context menu to when there is nearbyToolData + selector: ({ nearbyToolData }) => !!nearbyToolData, + items: [ + { + customizationType: 'ohif.contextSubMenu', + label: 'Site', + actionType: 'ShowSubMenu', + subMenu: 'siteSelectionSubMenu', + }, + { + customizationType: 'ohif.contextSubMenu', + label: 'Finding', + actionType: 'ShowSubMenu', + subMenu: 'findingSelectionSubMenu', + }, + { + // customizationType is implicit here in the configuration setup + label: 'Delete Measurement', + commands: [ + { + commandName: 'deleteMeasurement', + }, + ], + }, + { + label: 'Add Label', + commands: [ + { + commandName: 'setMeasurementLabel', + }, + ], + }, + + // The example below shows how to include a delegating sub-menu, + // Only available on the @ohif/hp-extension.mn hanging protocol + // To demonstrate, select the 3x1 layout from the protocol menu + // and right click on a measurement. + { + label: 'IncludeSubMenu', + selector: ({ protocol }) => protocol?.id === '@ohif/hp-extension.mn', + delegating: true, + subMenu: 'orientationSelectionSubMenu', + }, + ], + }, + + { + id: 'orientationSelectionSubMenu', + selector: ({ nearbyToolData }) => !!nearbyToolData, + items: [ + { + customizationType: '@ohif/contextMenuAnnotationCode', + code: 'SCT:24422004', + }, + { + customizationType: '@ohif/contextMenuAnnotationCode', + code: 'SCT:81654009', + }, + ], + }, + + { + id: 'findingSelectionSubMenu', + selector: ({ nearbyToolData }) => !!nearbyToolData, + items: [ + { + customizationType: '@ohif/contextMenuAnnotationCode', + code: 'SCT:371861004', + }, + { + customizationType: '@ohif/contextMenuAnnotationCode', + code: 'SCT:194983005', + }, + ], + }, + + { + id: 'siteSelectionSubMenu', + selector: ({ nearbyToolData }) => !!nearbyToolData, + items: [ + { + customizationType: '@ohif/contextMenuAnnotationCode', + code: 'SCT:69536005', + }, + { + customizationType: '@ohif/contextMenuAnnotationCode', + code: 'SCT:45048000', + }, + ], + }, + ], +}; + +export default findingsContextMenu; diff --git a/extensions/test-extension/src/custom-context-menu/index.ts b/extensions/test-extension/src/custom-context-menu/index.ts new file mode 100644 index 000000000..800e31f06 --- /dev/null +++ b/extensions/test-extension/src/custom-context-menu/index.ts @@ -0,0 +1,5 @@ +import codingValues from './codingValues'; +import contextMenuCodeItem from './contextMenuCodeItem'; +import findingsContextMenu from './findingsContextMenu'; + +export { codingValues, contextMenuCodeItem, findingsContextMenu }; diff --git a/extensions/test-extension/src/getCustomizationModule.ts b/extensions/test-extension/src/getCustomizationModule.ts new file mode 100644 index 000000000..03df13ed0 --- /dev/null +++ b/extensions/test-extension/src/getCustomizationModule.ts @@ -0,0 +1,14 @@ +import { + codingValues, + contextMenuCodeItem, + findingsContextMenu, +} from './custom-context-menu'; + +export default function getCustomizationModule() { + return [ + { + name: 'custom-context-menu', + value: [codingValues, contextMenuCodeItem, findingsContextMenu], + }, + ]; +} diff --git a/extensions/test-extension/src/index.tsx b/extensions/test-extension/src/index.tsx index bbd34d639..589d87ca5 100644 --- a/extensions/test-extension/src/index.tsx +++ b/extensions/test-extension/src/index.tsx @@ -3,6 +3,7 @@ import { Types } from '@ohif/core'; import { id } from './id'; import getHangingProtocolModule from './hp'; +import getCustomizationModule from './getCustomizationModule'; // import {setViewportZoomPan, storeViewportZoomPan } from './custom-viewport/setViewportZoomPan'; import sameAs from './custom-attribute/sameAs'; import numberOfDisplaySets from './custom-attribute/numberOfDisplaySets'; @@ -59,6 +60,9 @@ const testExtension: Types.Extensions.Extension = { /** Registers some additional hanging protocols. See hp/index.tsx for more details */ getHangingProtocolModule, + + /** Registers some customizations */ + getCustomizationModule, }; export default testExtension; diff --git a/modes/basic-test-mode/src/index.js b/modes/basic-test-mode/src/index.js index a6b5b21a5..ca6dc25fd 100644 --- a/modes/basic-test-mode/src/index.js +++ b/modes/basic-test-mode/src/index.js @@ -72,6 +72,7 @@ function modeFactory() { measurementService, toolbarService, toolGroupService, + customizationService, } = servicesManager.services; measurementService.clearMeasurements(); @@ -79,6 +80,12 @@ function modeFactory() { // Init Default and SR ToolGroups initToolGroups(extensionManager, toolGroupService, commandsManager); + // init customizations + console.log('* Adding mode customizations'); + customizationService.addModeCustomizations([ + '@ohif/extension-test.customizationModule.custom-context-menu', + ]); + let unsubscribe; const activateTool = () => { diff --git a/platform/core/src/classes/CommandsManager.ts b/platform/core/src/classes/CommandsManager.ts index 7525f4f7d..34a825c4d 100644 --- a/platform/core/src/classes/CommandsManager.ts +++ b/platform/core/src/classes/CommandsManager.ts @@ -106,7 +106,7 @@ export class CommandsManager { * @param {String} commandName - Command to find * @param {String} [contextName] - Specific command to look in. Defaults to current activeContexts */ - getCommand = (commandName, contextName) => { + getCommand = (commandName: string, contextName?: string) => { const contexts = []; if (contextName) { diff --git a/platform/core/src/extensions/ExtensionManager.ts b/platform/core/src/extensions/ExtensionManager.ts index ca05871af..f4f629224 100644 --- a/platform/core/src/extensions/ExtensionManager.ts +++ b/platform/core/src/extensions/ExtensionManager.ts @@ -36,7 +36,10 @@ export interface ExtensionParams extends ExtensionConstructor { */ export interface Extension { id: string; - preRegistration?: (p: ExtensionParams) => void; + preRegistration?: (p: ExtensionParams) => Promise | void; + onModeExit?: () => void; + getHangingProtocolModule?: (p: ExtensionParams) => unknown; + getCommandsModule?: (p: ExtensionParams) => CommandsModule; } export type ExtensionRegister = { @@ -44,6 +47,12 @@ export type ExtensionRegister = { create: (p: ExtensionParams) => Extension; }; +export type CommandsModule = { + actions: Record; + definitions: Record; + defaultContext?: string; +}; + export default class ExtensionManager { private _commandsManager: CommandsManager; private _servicesManager: ServicesManager; @@ -331,7 +340,7 @@ export default class ExtensionManager { } try { - const extensionModule = getModuleFn({ + const extensionModule = extension[getModuleFnName]({ appConfig: this._appConfig, commandsManager: this._commandsManager, servicesManager: this._servicesManager, @@ -348,6 +357,7 @@ export default class ExtensionManager { return extensionModule; } catch (ex) { + console.log(ex); throw new Error( `Exception thrown while trying to call ${getModuleFnName} for the ${extensionId} extension` ); diff --git a/platform/core/src/services/CustomizationService/CustomizationService.ts b/platform/core/src/services/CustomizationService/CustomizationService.ts index 1b3442742..91353a59c 100644 --- a/platform/core/src/services/CustomizationService/CustomizationService.ts +++ b/platform/core/src/services/CustomizationService/CustomizationService.ts @@ -123,7 +123,7 @@ export default class CustomizationService extends PubSubService { * may have been extended with any customizationType extensions provided, * so you cannot just use `|| defaultValue` * @return A customization to use if one is found, or the default customization, - * both enhanced with any customizationType inheritance (see applyType) + * both enhanced with any customizationType inheritance (see transform) */ public getCustomization( customizationId: string, @@ -145,7 +145,7 @@ export default class CustomizationService extends PubSubService { this.globalCustomizations[customizationId] ?? this.modeCustomizations[customizationId] ?? defaultValue; - return this.applyType(customization); + return this.transform(customization); } public hasModeCustomization(customizationId: string) { @@ -154,6 +154,16 @@ export default class CustomizationService extends PubSubService { this.modeCustomizations[customizationId] ); } + /** + * get is an alias for getModeCustomization, as it is the generic getter + * which will return both mode and global customizations, and should be + * used generally. + * Note that the second parameter, defaultValue, will be expanded to include + * any customizationType values defined in it, so it is not the same as doing: + * `customizationService.get('key') || defaultValue` + * unless the defaultValue does not contain any customizationType definitions. + */ + public get = this.getModeCustomization; /** * Applies any inheritance due to UI Type customization. @@ -161,14 +171,16 @@ export default class CustomizationService extends PubSubService { * and if that is found, will assign all iterable values from that * type into the new type, allowing default behaviour to be configured. */ - public applyType(customization: Customization): Customization { + public transform(customization: Customization): Customization { if (!customization) return customization; const { customizationType } = customization; if (!customizationType) return customization; const parent = this.getCustomization(customizationType); - return parent + const result = parent ? Object.assign(Object.create(parent), customization) : customization; + // Execute an nested type information + return result.transform?.(this) || result; } public addModeCustomizations(modeCustomizations): void { @@ -195,7 +207,7 @@ export default class CustomizationService extends PubSubService { id: string, defaultValue?: Customization ): Customization | void { - return this.applyType(this.globalCustomizations[id] ?? defaultValue); + return this.transform(this.globalCustomizations[id] ?? defaultValue); } setGlobalCustomization(id: string, value: Customization): void { @@ -235,7 +247,7 @@ export default class CustomizationService extends PubSubService { const extensionValue = this.findExtensionValue(value); // The child of a reference is only a set of references when an array, // so call the addReference direct. It could be a secondary reference perhaps - this.addReference(extensionValue); + this.addReference(extensionValue.value, isGlobal, extensionValue.name); } else if (Array.isArray(value)) { this.addReferences(value, isGlobal); } else { diff --git a/platform/core/src/types/Command.ts b/platform/core/src/types/Command.ts index 7e71976e2..0b6129d0c 100644 --- a/platform/core/src/types/Command.ts +++ b/platform/core/src/types/Command.ts @@ -4,9 +4,7 @@ export interface Command { context?: string; } -/** - * This is the format used within many items for multiple commands - */ +/** A set of commands, typically contained in a tool item or other configuration */ export interface Commands { - commands: []; + commands: Commands[]; } diff --git a/platform/docs/docs/platform/services/ui/customization-service.md b/platform/docs/docs/platform/services/ui/customization-service.md index 996f11b93..d1e81de60 100644 --- a/platform/docs/docs/platform/services/ui/customization-service.md +++ b/platform/docs/docs/platform/services/ui/customization-service.md @@ -210,8 +210,8 @@ example (this example comes from the context menu customizations as that one uses commands lists): ```ts - cornerstoneContextMenu = uiConfigurationService.getModeCustomization("cornerstoneContextMenu", defaultMenu); - uiConfigurationService.recordInteraction(cornerstoneContextMenu, extraProps); + cornerstoneContextMenu = uiConfigurationService.get("cornerstoneContextMenu", defaultMenu); + commandsManager.run(cornerstoneContextMenu, extraProps); ``` ### Global Customizations @@ -229,7 +229,7 @@ This allows for having strong typing when declaring customizations, for example: ```ts import { Types } from '@ohif/ui'; -const customContextMenu: Types.UIContextMenu = +const customContextMenu: Types.ContextMenu.Menu = { id: 'cornerstoneContextMenu', customizationType: 'ohif.contextMenu', @@ -260,7 +260,7 @@ getCustomizationModule = () => ([ ``` defines an overlay item which has a React content object as the render value. -This can then be used by specifying a customizationType of `ohif.overlayItem`, for example: +This can then be used by specifying a `customizationType` of `ohif.overlayItem`, for example: ```js const overlayItem: Types.UIOverlayItem = { @@ -275,7 +275,6 @@ const overlayItem: Types.UIOverlayItem = { This section can be used to specify various customization capabilities. - ## Text color for StudyBrowser tabs This is the recommended pattern for deep customization of class attributes, @@ -478,6 +477,29 @@ window.config = { +## Context Menus + +Context menus can be created by defining the menu structure and click +interaction, as defined in the `ContextMenu/types`. There are examples +below specific to the cornerstone context, because the actual click +handler and attributes used to decide when and how to display the menu +are specific to the context used for where the menu is displayed. + +## Cornerstone Context Menu + +The default cornerstone context menu can be customized by setting the +`cornerstoneContextMenu`. For a full example, see `findingsContextMenu`. + +## Customizeable Cornerstone Viewport Click Behaviour + +The behaviour on clicking on the cornerstone viewport can be customized +by setting the `cornerstoneViewportClickCommands`. This is intended to +support both the cornerstone 3D internal commands as well as things like +context menus. Currently it supports buttons 1-3, as well as modifier keys +by associated a commands list with the button to click. See `initContextMenu` +for more details. + +## Please add additional customizations above this section > 3rd Party implementers may be added to this table via pull requests.

OHIF Medical Imaging Viewer

-

The OHIF Viewer is a zero-footprint medical image viewer provided by the Open Health Imaging Foundation (OHIF). It is a configurable and extensible progressive web application with out-of-the-box support for image archives which support DICOMweb.

+

The OHIF Viewer is a zero-footprint medical image viewer +provided by the Open Health Imaging Foundation (OHIF). It is a configurable and extensible progressive web application with out-of-the-box support for image archives which support DICOMweb.

diff --git a/extensions/default/src/getHangingProtocolModule.js b/extensions/default/src/getHangingProtocolModule.js index 42b7bde9d..3df2c9a56 100644 --- a/extensions/default/src/getHangingProtocolModule.js +++ b/extensions/default/src/getHangingProtocolModule.js @@ -98,7 +98,7 @@ const defaultProtocol = { viewportStructure: { layoutType: 'grid', properties: { - rows: 1, + rows: 2, columns: 2, }, }, @@ -137,13 +137,7 @@ const defaultProtocol = { ], }, { - viewportOptions: { - toolGroupId: 'default', - // initialImageOptions: { - // index: 180, - // preset: 'middle', // 'first', 'last', 'middle' - // }, - }, + viewportOptions: {}, displaySets: [ { id: 'defaultDisplaySetId', @@ -154,10 +148,61 @@ const defaultProtocol = { ], }, + { + name: '3x1', + // Indicate that the number of viewports needed is 2 filled viewports, + // but that 4 viewports are preferred. + stageActivation: { + enabled: { + minViewportsMatched: 3, + }, + }, + + viewportStructure: { + layoutType: 'grid', + properties: { + rows: 1, + columns: 3, + }, + }, + viewports: [ + { + viewportOptions: { + toolGroupId: 'default', + }, + displaySets: [ + { + id: 'defaultDisplaySetId', + matchedDisplaySetsIndex: 2, + }, + ], + }, + { + viewportOptions: { + toolGroupId: 'default', + }, + displaySets: [ + { + id: 'defaultDisplaySetId', + matchedDisplaySetsIndex: 1, + }, + ], + }, + { + viewportOptions: {}, + displaySets: [ + { + id: 'defaultDisplaySetId', + }, + ], + }, + ], + }, + // This is an example of a layout with more than one element in it // It can be navigated to using , and . (prev/next stage) { - name: '1x2', + name: '2x1', // Indicate that the number of viewports needed is 1 filled viewport, // but that 2 viewports are preferred. stageActivation: { @@ -185,17 +230,42 @@ const defaultProtocol = { displaySets: [ { id: 'defaultDisplaySetId', + // Shows the second index of this image set + matchedDisplaySetsIndex: 1, }, ], }, { - viewportOptions: { - toolGroupId: 'default', - // initialImageOptions: { - // index: 180, - // preset: 'middle', // 'first', 'last', 'middle' - // }, - }, + viewportOptions: {}, + displaySets: [ + { + id: 'defaultDisplaySetId', + }, + ], + }, + ], + }, + + { + name: '2x1', + // Indicate that the number of viewports needed is 1 filled viewport, + // but that 2 viewports are preferred. + stageActivation: { + enabled: { + minViewportsMatched: 3, + }, + }, + + viewportStructure: { + layoutType: 'grid', + properties: { + rows: 2, + columns: 1, + }, + }, + viewports: [ + { + viewportOptions: {}, displaySets: [ { id: 'defaultDisplaySetId', @@ -204,8 +274,15 @@ const defaultProtocol = { }, ], }, + { + viewportOptions: {}, + displaySets: [ + { + id: 'defaultDisplaySetId', + }, + ], + }, ], - createdDate: '2021-02-23T18:32:42.850Z', }, ], }; diff --git a/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts b/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts index 01e8b0e0f..b366450d9 100644 --- a/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts +++ b/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts @@ -1011,6 +1011,7 @@ export default class HangingProtocolService extends PubSubService { ): HangingProtocol.DisplaySetMatchDetails { if (!matchDetails) return; if (offset === 0) return matchDetails; + const { matchingScores = [] } = matchDetails; if (offset === -1) { const { inDisplay } = options; if (!inDisplay) return matchDetails; @@ -1022,13 +1023,14 @@ export default class HangingProtocolService extends PubSubService { ) { const match = matchDetails.matchingScores[i]; return match.matchingScore > 0 - ? matchDetails.matchingScores[i] + ? { matchingScores, ...matchDetails.matchingScores[i] } : null; } } return; } - return matchDetails.matchingScores[offset]; + const matchFound = matchingScores[offset]; + return matchFound ? { ...matchFound, matchingScores } : undefined; } protected validateDisplaySetSelectMatch( @@ -1037,6 +1039,9 @@ export default class HangingProtocolService extends PubSubService { displaySetUID: string ): void { if (match.displaySetInstanceUID === displaySetUID) return; + if (!match.matchingScores) { + throw new Error('No matchingScores found in ' + match); + } for (const subMatch of match.matchingScores) { if (subMatch.displaySetInstanceUID === displaySetUID) return; } @@ -1085,7 +1090,7 @@ export default class HangingProtocolService extends PubSubService { const reuseDisplaySetUID = id && displaySetSelectorMap[ - `${activeStudyUID}:${id}:${matchedDisplaySetsIndex || 0}` + `${activeStudyUID}:${id}:${matchedDisplaySetsIndex || 0}` ]; const viewportDisplaySetMain = this.displaySetMatchDetails.get(id); From 2f33e4200400bbd8daa5c598c8e91db960aca691 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Tue, 28 Mar 2023 12:15:55 -0400 Subject: [PATCH 14/19] fix: Store hotkeys to specified name (#3280) * fix: Store hotkeys to specified name * fix: Move hotkey name into a new hotkey object --- modes/basic-test-mode/src/index.js | 8 ++++++-- platform/core/src/classes/HotkeysManager.ts | 10 ++++------ platform/docs/docs/platform/modes/index.md | 12 ++++++++++-- platform/viewer/src/routes/Mode/Mode.tsx | 18 ++++++++++++------ 4 files changed, 32 insertions(+), 16 deletions(-) diff --git a/modes/basic-test-mode/src/index.js b/modes/basic-test-mode/src/index.js index ca6dc25fd..11e06ec24 100644 --- a/modes/basic-test-mode/src/index.js +++ b/modes/basic-test-mode/src/index.js @@ -81,7 +81,6 @@ function modeFactory() { initToolGroups(extensionManager, toolGroupService, commandsManager); // init customizations - console.log('* Adding mode customizations'); customizationService.addModeCustomizations([ '@ohif/extension-test.customizationModule.custom-context-menu', ]); @@ -211,7 +210,12 @@ function modeFactory() { dicompdf.sopClassHandler, dicomsr.sopClassHandler, ], - hotkeys: [...hotkeys.defaults.hotkeyBindings], + hotkeys: { + // Don't store the hotkeys for basic-test-mode under the same key + // because they get customized by tests + name: 'basic-test-hotkeys', + hotkeys: [...hotkeys.defaults.hotkeyBindings], + }, }; } diff --git a/platform/core/src/classes/HotkeysManager.ts b/platform/core/src/classes/HotkeysManager.ts index ddb2757ec..50ccc0fe5 100644 --- a/platform/core/src/classes/HotkeysManager.ts +++ b/platform/core/src/classes/HotkeysManager.ts @@ -64,19 +64,17 @@ export class HotkeysManager { * * @param {HotkeyDefinition[] | Object} [hotkeyDefinitions=[]] Contains hotkeys definitions */ - setHotkeys(hotkeyDefinitions = [], key = 'hotkey-definitions') { + setHotkeys(hotkeyDefinitions = [], name = 'hotkey-definitions') { try { const definitions = this.getValidDefinitions(hotkeyDefinitions); if (isequal(definitions, this.hotkeyDefaults)) { - console.log('hotkeys REMOVING unused definition', key); - localStorage.removeItem(key); + localStorage.removeItem(name); } else { - console.log('hotkeys setting local storage', key); - localStorage.setItem(key, JSON.stringify(definitions)); + localStorage.setItem(name, JSON.stringify(definitions)); } definitions.forEach(definition => this.registerHotkeys(definition)); } catch (error) { - const { uiNotificationService, } = this._servicesManager.services; + const { uiNotificationService } = this._servicesManager.services; uiNotificationService.show({ title: 'Hotkeys Manager', message: 'Error while setting hotkeys', diff --git a/platform/docs/docs/platform/modes/index.md b/platform/docs/docs/platform/modes/index.md index 44a58fde1..aa3f0cb0d 100644 --- a/platform/docs/docs/platform/modes/index.md +++ b/platform/docs/docs/platform/modes/index.md @@ -315,7 +315,9 @@ handles creation of the displaySets. ### Hotkeys `hotkeys` is another property in the configuration of a mode that can be defined -to add the specific hotkeys to the viewer at all routes. +to add the specific hotkeys to the viewer on the mode route. Additionally, the +name under which the hotkeys are stored can be configured as `hotkeyName`. +This allows user customization of the mode specific hotkeys. ```js // default hotkeys @@ -347,7 +349,13 @@ function modeFactory() { /* ... */ - hotkeys: [..hotkeys.defaults.hotkeyBindings, ...myHotkeys], + hotkeys: { + // The name in preferences to use for this set of hotkeys + // Allows defining different sets for different modes + name: 'custom-hotkey-name', + // And the actual custom values here. + hotkeys:[..hotkeys.defaults.hotkeyBindings, ...myHotkeys] + }, } } diff --git a/platform/viewer/src/routes/Mode/Mode.tsx b/platform/viewer/src/routes/Mode/Mode.tsx index 0eb3f5a0a..a48f05500 100644 --- a/platform/viewer/src/routes/Mode/Mode.tsx +++ b/platform/viewer/src/routes/Mode/Mode.tsx @@ -114,7 +114,15 @@ export default function ModeRoute({ hangingProtocolService, } = (servicesManager as ServicesManager).services; - const { extensions, sopClassHandlers, hotkeys, hangingProtocol } = mode; + const { + extensions, + sopClassHandlers, + hotkeys: hotkeyObj, + hangingProtocol, + } = mode; + // Preserve the old array interface for hotkeys + const hotkeys = Array.isArray(hotkeyObj) ? hotkeyObj : hotkeyObj?.hotkeys; + const hotkeyName = hotkeyObj?.name || 'hotkey-definitions-v2'; if (dataSourceName === undefined) { dataSourceName = extensionManager.defaultDataSourceName; @@ -204,14 +212,12 @@ export default function ModeRoute({ hotkeysManager.setDefaultHotKeys(hotkeys); - const userPreferredHotkeys = JSON.parse( - localStorage.getItem('hotkey-definitions') - ); + const userPreferredHotkeys = JSON.parse(localStorage.getItem(hotkeyName)); if (userPreferredHotkeys?.length) { - hotkeysManager.setHotkeys(userPreferredHotkeys); + hotkeysManager.setHotkeys(userPreferredHotkeys, hotkeyName); } else { - hotkeysManager.setHotkeys(hotkeys); + hotkeysManager.setHotkeys(hotkeys, hotkeyName); } return () => { From 4734b3bac6fcb167eac883c5e62ccb56320175e8 Mon Sep 17 00:00:00 2001 From: Alireza Date: Wed, 29 Mar 2023 09:40:22 -0400 Subject: [PATCH 15/19] fix(volumeLoad): should not have missing slices when loading (#3287) * fix(volumeLoad): should not have missing slices when loading * add review comments --- extensions/cornerstone-dicom-sr/package.json | 6 +- extensions/cornerstone/package.json | 8 +-- .../src/getHangingProtocolModule.ts | 13 +++- extensions/cornerstone/src/init.tsx | 19 ++++- .../CornerstoneViewportService.ts | 17 ++--- extensions/measurement-tracking/package.json | 4 +- platform/viewer/public/config/default.js | 4 +- yarn.lock | 70 +++++++++---------- 8 files changed, 81 insertions(+), 60 deletions(-) diff --git a/extensions/cornerstone-dicom-sr/package.json b/extensions/cornerstone-dicom-sr/package.json index e302b690c..c130b436f 100644 --- a/extensions/cornerstone-dicom-sr/package.json +++ b/extensions/cornerstone-dicom-sr/package.json @@ -45,8 +45,8 @@ "dependencies": { "@babel/runtime": "^7.20.13", "classnames": "^2.3.2", - "@cornerstonejs/adapters": "^0.4.1", - "@cornerstonejs/core": "^0.36.5", - "@cornerstonejs/tools": "^0.55.1" + "@cornerstonejs/adapters": "^0.6.0", + "@cornerstonejs/core": "^0.38.0", + "@cornerstonejs/tools": "^0.58.0" } } diff --git a/extensions/cornerstone/package.json b/extensions/cornerstone/package.json index 4aceecc65..bb7b58480 100644 --- a/extensions/cornerstone/package.json +++ b/extensions/cornerstone/package.json @@ -43,10 +43,10 @@ }, "dependencies": { "@babel/runtime": "^7.20.13", - "@cornerstonejs/adapters": "^0.4.1", - "@cornerstonejs/core": "^0.36.5", - "@cornerstonejs/streaming-image-volume-loader": "^0.15.1", - "@cornerstonejs/tools": "^0.55.1", + "@cornerstonejs/adapters": "^0.6.0", + "@cornerstonejs/core": "^0.38.0", + "@cornerstonejs/streaming-image-volume-loader": "^0.15.13", + "@cornerstonejs/tools": "^0.58.0", "@kitware/vtk.js": "26.5.6", "html2canvas": "^1.4.1", "lodash.debounce": "4.0.8", diff --git a/extensions/cornerstone/src/getHangingProtocolModule.ts b/extensions/cornerstone/src/getHangingProtocolModule.ts index 755bf030e..17190e8e5 100644 --- a/extensions/cornerstone/src/getHangingProtocolModule.ts +++ b/extensions/cornerstone/src/getHangingProtocolModule.ts @@ -11,7 +11,7 @@ const mpr: Types.HangingProtocol.Protocol = { // Unknown number of priors referenced - so just match any study numberOfPriorsReferenced: 0, protocolMatchingRules: [], - // imageLoadStrategy: 'nth', + imageLoadStrategy: 'interleaveTopToBottom', callbacks: { // Switches out of MPR mode when the layout change button is used onLayoutChange: [ @@ -170,7 +170,7 @@ const mprAnd3DVolumeViewport = { availableTo: {}, editableBy: {}, protocolMatchingRules: [], - imageLoadStrategy: 'interleaveTopToBottom', + imageLoadStrategy: 'interleaveCenter', displaySetSelectors: { mprDisplaySet: { seriesMatchingRules: [ @@ -184,6 +184,15 @@ const mprAnd3DVolumeViewport = { }, required: true, }, + { + attribute: 'Modality', + constraint: { + equals: { + value: 'CT', + }, + }, + required: true, + }, ], }, }, diff --git a/extensions/cornerstone/src/init.tsx b/extensions/cornerstone/src/init.tsx index c44ec812d..7a5b59a3f 100644 --- a/extensions/cornerstone/src/init.tsx +++ b/extensions/cornerstone/src/init.tsx @@ -167,9 +167,26 @@ export default async function init({ viewportId ); + const ohifViewport = cornerstoneViewportService.getViewportInfo( + viewportId + ); + + const { + lutPresentationStore, + positionPresentationStore, + } = stateSyncService.getState(); + const { presentationIds } = ohifViewport.getViewportOptions(); + const presentations = { + positionPresentation: + positionPresentationStore[presentationIds?.positionPresentationId], + lutPresentation: + lutPresentationStore[presentationIds?.lutPresentationId], + }; + cornerstoneViewportService.setVolumesForViewport( viewport, - volumeInputArray + volumeInputArray, + presentations ); } } diff --git a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts index 26fb92cdc..363c82985 100644 --- a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts +++ b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts @@ -506,19 +506,16 @@ class CornerstoneViewportService extends PubSubService !hangingProtocolService.customImageLoadPerformed ) { // delegate the volume loading to the hanging protocol service if it has a custom image load strategy - if ( - hangingProtocolService.runImageLoadStrategy({ - viewportId: viewport.id, - volumeInputArray, - }) - ) { - // Fallback to the default strategy if the custom one fails - return; - } + return hangingProtocolService.runImageLoadStrategy({ + viewportId: viewport.id, + volumeInputArray, + }); } volumeToLoad.forEach(volume => { - volume.load(); + if (!volume.loadStatus.loaded && !volume.loadStatus.loading) { + volume.load(); + } }); // This returns the async continuation only diff --git a/extensions/measurement-tracking/package.json b/extensions/measurement-tracking/package.json index 1582d8784..135b10e71 100644 --- a/extensions/measurement-tracking/package.json +++ b/extensions/measurement-tracking/package.json @@ -32,8 +32,8 @@ "peerDependencies": { "@ohif/core": "^3.0.0", "classnames": "^2.3.2", - "@cornerstonejs/core": "^0.36.5", - "@cornerstonejs/tools": "^0.55.1", + "@cornerstonejs/core": "^0.38.0", + "@cornerstonejs/tools": "^0.58.0", "@ohif/extension-cornerstone-dicom-sr": "^3.0.0", "dcmjs": "^0.29.4", "lodash.debounce": "^4.17.21", diff --git a/platform/viewer/public/config/default.js b/platform/viewer/public/config/default.js index c0de3708a..ab9117367 100644 --- a/platform/viewer/public/config/default.js +++ b/platform/viewer/public/config/default.js @@ -18,7 +18,9 @@ window.config = { maxNumRequests: { interaction: 100, thumbnail: 75, - prefetch: 10, + // Prefetch number is dependent on the http protocol. For http 2 or + // above, the number of requests can be go a lot higher. + prefetch: 25, }, // filterQueryParam: false, dataSources: [ diff --git a/yarn.lock b/yarn.lock index ca74f1038..53ac17d7f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1407,13 +1407,13 @@ resolved "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9" integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ== -"@cornerstonejs/adapters@^0.4.1": - version "0.4.1" - resolved "https://registry.yarnpkg.com/@cornerstonejs/adapters/-/adapters-0.4.1.tgz#fedab0d9cfea609f5219950c86e546198ad0f3c8" - integrity sha512-DQabSBdTN/A5tsncnDLdsb6LI5I/FTG3gSmNpDAkc9fAZY6Ljj0lCLt9KHB5OA1Gbvrlcab/DGJ/2YW6Z7ifRg== +"@cornerstonejs/adapters@^0.6.0": + version "0.6.0" + resolved "https://registry.npmjs.org/@cornerstonejs/adapters/-/adapters-0.6.0.tgz#9b2efdabb0d596d53ae4854556956b668cca7535" + integrity sha512-bzOwtOX0EfJ/PufPq1mONPU+HmVQf+pA/78+mbHuV8bvznM1IkSzc2h0WYsLXTdVEkZrGwgWCwrOiYGfsNW1tQ== dependencies: "@babel/runtime-corejs2" "^7.17.8" - dcmjs "^0.29.4" + dcmjs "^0.29.5" gl-matrix "^3.4.3" lodash.clonedeep "^4.5.0" ndarray "^1.0.19" @@ -1443,44 +1443,28 @@ resolved "https://registry.npmjs.org/@cornerstonejs/codec-openjph/-/codec-openjph-2.4.2.tgz#e96721d56f6ec96f7f95c16321d88cc8467d8d81" integrity sha512-lgdvBvvNezleY+4pIe2ceUsJzlZe/0PipdeubQ3vZZOz3xxtHHMR1XFCl4fgd8gosR8COHuD7h6q+MwgrwBsng== -"@cornerstonejs/core@^0.35.1": - version "0.35.1" - resolved "https://registry.npmjs.org/@cornerstonejs/core/-/core-0.35.1.tgz#fd5e9f878b51807366d9d0c6659ac91b43efed87" - integrity sha512-n6nFo3XVkMKmhNsF20yPznO3jo3MpOl8X+3k/HyHVlZujIsBT/jz0X4dEg/3fzA0aLPdWdZ/kkVw8TBanVKTGA== +"@cornerstonejs/core@^0.38.0": + version "0.38.0" + resolved "https://registry.npmjs.org/@cornerstonejs/core/-/core-0.38.0.tgz#6ee3341f38f78e98da85d2511ac16f25e9800f18" + integrity sha512-4/+qDEtGQRwV6AN5Ze4S6oOFiNH48oT5Mp/YQEzJHO+Zn9ceRHZ6ReTwccVUJ5QY2d5d89CoWJ2sKhdsLFZsNw== dependencies: detect-gpu "^4.0.45" lodash.clonedeep "4.5.0" -"@cornerstonejs/core@^0.36.2": - version "0.36.2" - resolved "https://registry.npmjs.org/@cornerstonejs/core/-/core-0.36.2.tgz#205573bfd75a273fa6bca587662b4dd1f5e46167" - integrity sha512-jJYawOjLGop18O426YxyBepkiaYT/xHMa3mqhToIO5KRxK/UYSGHt0RS8wSHeFR8pNkCAHepcdfhyIVkAln66g== +"@cornerstonejs/streaming-image-volume-loader@^0.15.13": + version "0.15.13" + resolved "https://registry.npmjs.org/@cornerstonejs/streaming-image-volume-loader/-/streaming-image-volume-loader-0.15.13.tgz#d4c6651f5edac0adbc2c4059b78945fb70c38eb5" + integrity sha512-o0e+vEr08GmMztjabPs7vHKYhofW0dQvSI8Dy7tb1wKEnf05Tis/5s9sCt6PORNHXweK+Cylja3xy8b469ZuVw== dependencies: - detect-gpu "^4.0.45" - lodash.clonedeep "4.5.0" + "@cornerstonejs/core" "^0.38.0" + cornerstone-wado-image-loader "^4.10.2" -"@cornerstonejs/core@^0.36.5": - version "0.36.5" - resolved "https://registry.yarnpkg.com/@cornerstonejs/core/-/core-0.36.5.tgz#2e8c2fc2f9d00c2b5a0f1666aa575ab5024f3616" - integrity sha512-5Z4GEjpWaYbEbSpbgNc92oiwTcD8TUpVw1TUQ5UC4OkzxBMN4THmkGifRq5kJpxJTahGVsoU4W1S7of+YsULlg== +"@cornerstonejs/tools@^0.58.0": + version "0.58.0" + resolved "https://registry.npmjs.org/@cornerstonejs/tools/-/tools-0.58.0.tgz#c9a2ba6a96491a565fe03a2ef1c1403bd70992e4" + integrity sha512-sVsX6WGLmHg5UUg7p9iwvwjb8AswUypO2jARyIVtpa3JM3dP16Y0uh1jTWJFG2xWLZgEXDXcFCIhw5nOSohy7A== dependencies: - detect-gpu "^4.0.45" - lodash.clonedeep "4.5.0" - -"@cornerstonejs/streaming-image-volume-loader@^0.15.1": - version "0.15.1" - resolved "https://registry.npmjs.org/@cornerstonejs/streaming-image-volume-loader/-/streaming-image-volume-loader-0.15.1.tgz#b9fd0efbebbd232119ef0ae7b63bf8b3498bf539" - integrity sha512-xtBG2RlhjOX/Cd4qGLYGKXjF7oLhRtksmXpPB5nOa1BBBgrGOQHFjNuyh3uhVHg30e5po9oCfomSPHwovhexzw== - dependencies: - "@cornerstonejs/core" "^0.35.1" - cornerstone-wado-image-loader "^4.10.0" - -"@cornerstonejs/tools@^0.55.1": - version "0.55.1" - resolved "https://registry.npmjs.org/@cornerstonejs/tools/-/tools-0.55.1.tgz#cde00881c35a43f2d10c634350e46f45e8bf9a7b" - integrity sha512-fYRNqnS9WXWBrkOd++nocKqjlD6p3BvFtPCJeaA8M2bdgEHEHK+KbM999GG5YTmWV88xLW4mjSl+wJvbdWUwEQ== - dependencies: - "@cornerstonejs/core" "^0.36.2" + "@cornerstonejs/core" "^0.38.0" lodash.clonedeep "4.5.0" lodash.get "^4.4.2" @@ -8527,7 +8511,7 @@ cornerstone-math@^0.1.9: resolved "https://registry.npmjs.org/cornerstone-math/-/cornerstone-math-0.1.10.tgz#a3f99db64d73c5adee61ae0d570128eca1682d07" integrity sha512-23XSAyP7t70ANvhFyqwvva+zFd1bQ2d5GL7tg9qKE932WmImjA2Y9tiy5n0iTtnf51W/78Png8Lia2o4dCdJaQ== -cornerstone-wado-image-loader@^4.10.0, cornerstone-wado-image-loader@^4.10.2: +cornerstone-wado-image-loader@^4.10.2: version "4.10.2" resolved "https://registry.yarnpkg.com/cornerstone-wado-image-loader/-/cornerstone-wado-image-loader-4.10.2.tgz#139956654324fd2b01fe5b4900d0f4ed8c52633d" integrity sha512-qj9dThELqYCm3jAZfg9qnUl8d76gngOl55kYJabY5lh/dFeVIxno/hYxy3ydE7RtG2c/TUGXb+EMUl0CJSqKBQ== @@ -9259,6 +9243,18 @@ dcmjs@^0.29.4: ndarray "^1.0.19" pako "^2.0.4" +dcmjs@^0.29.5: + version "0.29.5" + resolved "https://registry.npmjs.org/dcmjs/-/dcmjs-0.29.5.tgz#3e2311fc47aafc70f21e236ad89e2ff9ba6589fd" + integrity sha512-CcLo3pwitf9JhhvW3/icCraxUhaIKJRyOys9XJlBBDn5TQskyJzxgveLKejcryHn4DooqlJPeMuM4ixdPFEERQ== + dependencies: + "@babel/runtime-corejs2" "^7.17.8" + gl-matrix "^3.1.0" + lodash.clonedeep "^4.5.0" + loglevelnext "^3.0.1" + ndarray "^1.0.19" + pako "^2.0.4" + debug-log@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/debug-log/-/debug-log-1.0.1.tgz#2307632d4c04382b8df8a32f70b895046d52745f" From d5ff590dfc4b55508b5620ea63db2ed3b32bb765 Mon Sep 17 00:00:00 2001 From: Joe Boccanfuso <109477394+jbocce@users.noreply.github.com> Date: Wed, 29 Mar 2023 15:39:51 -0400 Subject: [PATCH 16/19] feat(DoubleClick): double click a viewport to one up and back (#3285) * feat(DoubleClick): double click a viewport to one up and back Added a toggleOneUp command that puts the active viewport into a 1x1 grid layout and it toggles out of 'one-up' by restoring its saved 'toggleOneUpViewportGridStore' from the StateSyncService. Added double click customization for the Cornerstone extension with the default double click handling being the toggleOneUp command. Added a cypress test for the double click functionality. * PR feedback: - tracked viewport measurements no longer show as dashed when toggling one up - disallowed double clicking near a measurement - updated cornerstone3D dependencies to fix double click of TMTV and volume viewport 3D - created ViewportGridService.getLayoutOptionsFromState * Updated the ViewportGridService docs. * Switched to using 'cornerstoneViewportClickCommands' and consistency with the context menu clicks. --- extensions/cornerstone/src/init.tsx | 12 ++ extensions/cornerstone/src/initContextMenu.ts | 25 +--- extensions/cornerstone/src/initDoubleClick.ts | 92 ++++++++++++ .../src/utils/findNearbyToolData.ts | 21 +++ extensions/default/src/commandsModule.ts | 132 +++++++++++++++++- .../viewports/TrackedCornerstoneViewport.tsx | 2 +- .../ViewportGridService.ts | 32 ++++- platform/core/src/types/Command.ts | 2 +- platform/core/src/utils/index.js | 2 + platform/core/src/utils/index.test.js | 1 + .../subscribeToNextViewportGridChange.ts | 37 +++++ .../services/ui/viewport-grid-service.md | 12 +- .../contextProviders/ViewportGridProvider.tsx | 20 ++- .../customization/OHIFDoubleClick.spec.js | 52 +++++++ 14 files changed, 402 insertions(+), 40 deletions(-) create mode 100644 extensions/cornerstone/src/initDoubleClick.ts create mode 100644 extensions/cornerstone/src/utils/findNearbyToolData.ts create mode 100644 platform/core/src/utils/subscribeToNextViewportGridChange.ts create mode 100644 platform/viewer/cypress/integration/customization/OHIFDoubleClick.spec.js diff --git a/extensions/cornerstone/src/init.tsx b/extensions/cornerstone/src/init.tsx index 7a5b59a3f..479953b68 100644 --- a/extensions/cornerstone/src/init.tsx +++ b/extensions/cornerstone/src/init.tsx @@ -25,6 +25,7 @@ import interleaveCenterLoader from './utils/interleaveCenterLoader'; import nthLoader from './utils/nthLoader'; import interleaveTopToBottom from './utils/interleaveTopToBottom'; import initContextMenu from './initContextMenu'; +import initDoubleClick from './initDoubleClick'; // TODO: Cypress tests are currently grabbing this from the window? window.cornerstone = cornerstone; @@ -104,6 +105,12 @@ export default async function init({ clearOnModeExit: true, }); + // Stores the entire ViewportGridService getState when toggling to one up + // (e.g. via a double click) so that it can be restored when toggling back. + stateSyncService.register('toggleOneUpViewportGridStore', { + clearOnModeExit: true, + }); + const labelmapRepresentation = cornerstoneTools.Enums.SegmentationRepresentations.Labelmap; @@ -198,6 +205,11 @@ export default async function init({ commandsManager, }); + initDoubleClick({ + customizationService, + commandsManager, + }); + const newStackCallback = evt => { const { element } = evt.detail; utilities.stackPrefetch.enable(element); diff --git a/extensions/cornerstone/src/initContextMenu.ts b/extensions/cornerstone/src/initContextMenu.ts index a9f69db51..c46e5053e 100644 --- a/extensions/cornerstone/src/initContextMenu.ts +++ b/extensions/cornerstone/src/initContextMenu.ts @@ -1,6 +1,7 @@ import { eventTarget, EVENTS } from '@cornerstonejs/core'; import { Enums } from '@cornerstonejs/tools'; import { setEnabledElement } from './state'; +import { findNearbyToolData } from './utils/findNearbyToolData'; const cs3DToolsEvents = Enums.Events; @@ -47,28 +48,6 @@ function initContextMenu({ customizationService, commandsManager, }): void { - /** - * Finds tool nearby event position triggered. - * - * @param {Object} commandsManager mannager of commands - * @param {Object} event that has being triggered - * @returns cs toolData or undefined if not found. - */ - const findNearbyToolData = evt => { - if (!evt?.detail) { - return; - } - const { element, currentPoints } = evt.detail; - return commandsManager.runCommand( - 'getNearbyToolData', - { - element, - canvasCoordinates: currentPoints?.canvas, - }, - 'CORNERSTONE' - ); - }; - /* * Run the commands associated with the given button press, * defaults on button1 and button2 @@ -80,7 +59,7 @@ function initContextMenu({ const toRun = customizations[name]; console.log('initContextMenu::cornerstoneViewportHandleEvent', name, toRun); const options = { - nearbyToolData: findNearbyToolData(evt), + nearbyToolData: findNearbyToolData(commandsManager, evt), event: evt, }; commandsManager.run(toRun, options); diff --git a/extensions/cornerstone/src/initDoubleClick.ts b/extensions/cornerstone/src/initDoubleClick.ts new file mode 100644 index 000000000..da8fb8fa4 --- /dev/null +++ b/extensions/cornerstone/src/initDoubleClick.ts @@ -0,0 +1,92 @@ +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; diff --git a/extensions/cornerstone/src/utils/findNearbyToolData.ts b/extensions/cornerstone/src/utils/findNearbyToolData.ts new file mode 100644 index 000000000..ab57a67d2 --- /dev/null +++ b/extensions/cornerstone/src/utils/findNearbyToolData.ts @@ -0,0 +1,21 @@ +/** + * Finds tool nearby event position triggered. + * + * @param {Object} commandsManager mannager of commands + * @param {Object} event that has being triggered + * @returns cs toolData or undefined if not found. + */ +export const findNearbyToolData = (commandsManager, evt) => { + if (!evt?.detail) { + return; + } + const { element, currentPoints } = evt.detail; + return commandsManager.runCommand( + 'getNearbyToolData', + { + element, + canvasCoordinates: currentPoints?.canvas, + }, + 'CORNERSTONE' + ); +}; diff --git a/extensions/default/src/commandsModule.ts b/extensions/default/src/commandsModule.ts index e44651101..b2a80cbfe 100644 --- a/extensions/default/src/commandsModule.ts +++ b/extensions/default/src/commandsModule.ts @@ -1,4 +1,4 @@ -import { ServicesManager, Types } from '@ohif/core'; +import { ServicesManager, utils } from '@ohif/core'; import { ContextMenuController, @@ -12,6 +12,8 @@ import findViewportsByPosition, { import { ContextMenuProps } from './CustomizeableContextMenu/types'; +const { subscribeToNextViewportGridChange } = utils; + export type HangingProtocolParams = { protocolId?: string; stageIndex?: number; @@ -159,12 +161,14 @@ const commandsModule = ({ * @param options.protocolId - the protocol ID to change to * @param options.stageId - the stageId to apply * @param options.stageIndex - the index of the stage to go to. + * @param options.reset - flag to indicate if the HP should be reset to its original and not restored to a previous state */ setHangingProtocol: ({ activeStudyUID = '', protocolId, stageId, stageIndex, + reset = false, }: HangingProtocolParams): boolean => { try { // Stores in the state the reuseID to displaySetUID mapping @@ -210,10 +214,11 @@ const commandsModule = ({ hangingProtocolService.setActiveStudyUID(activeStudyUID); } - const storedHanging = `${hangingProtocolService.getState().activeStudyUID + const storedHanging = `${ + hangingProtocolService.getState().activeStudyUID }:${protocolId}:${useStageIdx || 0}`; - const restoreProtocol = !!viewportGridStore[storedHanging]; + const restoreProtocol = !reset && viewportGridStore[storedHanging]; if ( protocolId === hpInfo.protocolId && @@ -273,8 +278,9 @@ const commandsModule = ({ activeStudy, } = hangingProtocolService.getActiveProtocol(); const { toggleHangingProtocol } = stateSyncService.getState(); - const storedHanging = `${activeStudy.StudyInstanceUID - }:${protocolId}:${stageIndex | 0}`; + const storedHanging = `${ + activeStudy.StudyInstanceUID + }:${protocolId}:${stageIndex | 0}`; if ( protocol.id === protocolId && (stageIndex === undefined || stageIndex === desiredStageIndex) @@ -294,7 +300,11 @@ const commandsModule = ({ }, }, }); - return actions.setHangingProtocol({ protocolId, stageIndex }); + return actions.setHangingProtocol({ + protocolId, + stageIndex, + reset: true, + }); } }, @@ -365,6 +375,111 @@ const commandsModule = ({ window.setTimeout(completeLayout, 0); }, + toggleOneUp() { + const viewportGridState = viewportGridService.getState(); + const { activeViewportIndex, viewports, layout } = viewportGridState; + const { + displaySetInstanceUIDs, + displaySetOptions, + viewportOptions, + } = viewports[activeViewportIndex]; + + if (layout.numCols === 1 && layout.numRows === 1) { + // The viewer is in one-up. Check if there is a state to restore/toggle back to. + const { toggleOneUpViewportGridStore } = stateSyncService.getState(); + + if (!toggleOneUpViewportGridStore.layout) { + return; + } + // There is a state to toggle back to. The viewport that was + // originally toggled to one up was the former active viewport. + const viewportIndexToUpdate = + toggleOneUpViewportGridStore.activeViewportIndex; + + // Determine which viewports need to be updated. This is particularly + // important when MPR is toggled to one up and a different reconstructable + // is swapped in. Note that currently HangingProtocolService.getViewportsRequireUpdate + // does not support viewport with multiple display sets. + const updatedViewports = + displaySetInstanceUIDs.length > 1 + ? [] + : displaySetInstanceUIDs + .map(displaySetInstanceUID => + hangingProtocolService.getViewportsRequireUpdate( + viewportIndexToUpdate, + displaySetInstanceUID + ) + ) + .flat(); + + // This findOrCreateViewport returns either one of the updatedViewports + // returned from the HP service OR if there is not one from the HP service then + // simply returns what was in the previous state. + const findOrCreateViewport = (viewportIndex: number) => { + const viewport = updatedViewports.find( + viewport => viewport.viewportIndex === viewportIndex + ); + + return viewport + ? { viewportOptions, displaySetOptions, ...viewport } + : toggleOneUpViewportGridStore.viewports[viewportIndex]; + }; + + const layoutOptions = viewportGridService.getLayoutOptionsFromState( + toggleOneUpViewportGridStore + ); + + // Restore the previous layout including the active viewport. + viewportGridService.setLayout({ + numRows: toggleOneUpViewportGridStore.layout.numRows, + numCols: toggleOneUpViewportGridStore.layout.numCols, + activeViewportIndex: viewportIndexToUpdate, + layoutOptions, + findOrCreateViewport, + }); + } else { + // We are not in one-up, so toggle to one up. + + // Store the current viewport grid state so we can toggle it back later. + stateSyncService.store({ + toggleOneUpViewportGridStore: viewportGridState, + }); + + // This findOrCreateViewport only return one viewport - the active + // one being toggled to one up. + const findOrCreateViewport = () => { + return { + displaySetInstanceUIDs, + displaySetOptions, + viewportOptions, + }; + }; + + // Set the layout to be 1x1/one-up. + viewportGridService.setLayout({ + numRows: 1, + numCols: 1, + findOrCreateViewport, + }); + + // Subscribe to ANY (i.e. manual and hanging protocol) layout changes so that + // any grid layout state to toggle to from one up is cleared. This is performed on + // a timeout to avoid clearing the state for the actual to one up change. + // Whenever the next layout change event is fired, the subscriptions are unsubscribed. + const clearToggleOneUpViewportGridStore = () => { + const toggleOneUpViewportGridStore = {}; + stateSyncService.store({ + toggleOneUpViewportGridStore, + }); + }; + + subscribeToNextViewportGridChange( + viewportGridService, + clearToggleOneUpViewportGridStore + ); + } + }, + openDICOMTagViewer() { const { activeViewportIndex, viewports } = viewportGridService.getState(); const activeViewportSpecificData = viewports[activeViewportIndex]; @@ -440,6 +555,11 @@ const commandsModule = ({ storeContexts: [], options: {}, }, + toggleOneUp: { + commandFn: actions.toggleOneUp, + storeContexts: [], + options: {}, + }, openDICOMTagViewer: { commandFn: actions.openDICOMTagViewer, }, diff --git a/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx b/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx index 97ac7affc..5e5393f53 100644 --- a/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx +++ b/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx @@ -80,7 +80,7 @@ function TrackedCornerstoneViewport(props) { return; } - annotation.config.style.setViewportToolStyles(`viewport-${viewportIndex}`, { + annotation.config.style.setViewportToolStyles(viewportId, { global: { lineDash: '4,4', }, diff --git a/platform/core/src/services/ViewportGridService/ViewportGridService.ts b/platform/core/src/services/ViewportGridService/ViewportGridService.ts index cfa5a5bc5..be9875670 100644 --- a/platform/core/src/services/ViewportGridService/ViewportGridService.ts +++ b/platform/core/src/services/ViewportGridService/ViewportGridService.ts @@ -2,6 +2,8 @@ import { PubSubService } from '../_shared/pubSubServiceInterface'; const EVENTS = { ACTIVE_VIEWPORT_INDEX_CHANGED: 'event::activeviewportindexchanged', + LAYOUT_CHANGED: 'event::layoutChanged', + GRID_STATE_CHANGED: 'event::gridStateChanged', }; class ViewportGridService extends PubSubService { @@ -101,12 +103,26 @@ class ViewportGridService extends PubSubService { * options that is initially provided as {} (eg to store intermediate state) * The function returns a viewport object to use at the given position. */ - public setLayout({ numCols, numRows, findOrCreateViewport = undefined }) { + public setLayout({ + numCols, + numRows, + layoutOptions, + layoutType = 'grid', + activeViewportIndex = undefined, + findOrCreateViewport = undefined, + }) { this.serviceImplementation._setLayout({ numCols, numRows, + layoutOptions, + layoutType, + activeViewportIndex, findOrCreateViewport, }); + this._broadcastEvent(this.EVENTS.LAYOUT_CHANGED, { + numCols, + numRows, + }); } public reset() { @@ -125,11 +141,25 @@ class ViewportGridService extends PubSubService { public set(state) { this.serviceImplementation._set(state); + this._broadcastEvent(this.EVENTS.GRID_STATE_CHANGED, { + state, + }); } public getNumViewportPanes() { return this.serviceImplementation._getNumViewportPanes(); } + + public getLayoutOptionsFromState(state) { + return state.viewports.map(viewport => { + return { + x: viewport.x, + y: viewport.y, + width: viewport.width, + height: viewport.height, + }; + }); + } } export default ViewportGridService; diff --git a/platform/core/src/types/Command.ts b/platform/core/src/types/Command.ts index 0b6129d0c..837c83590 100644 --- a/platform/core/src/types/Command.ts +++ b/platform/core/src/types/Command.ts @@ -6,5 +6,5 @@ export interface Command { /** A set of commands, typically contained in a tool item or other configuration */ export interface Commands { - commands: Commands[]; + commands: Command[]; } diff --git a/platform/core/src/utils/index.js b/platform/core/src/utils/index.js index d428a6648..4c54c32ef 100644 --- a/platform/core/src/utils/index.js +++ b/platform/core/src/utils/index.js @@ -32,6 +32,7 @@ import { sortingCriteria, seriesSortCriteria, } from './sortStudy'; +import { subscribeToNextViewportGridChange } from './subscribeToNextViewportGridChange'; // Commented out unused functionality. // Need to implement new mechanism for derived displaySets using the displaySetManager. @@ -69,6 +70,7 @@ const utils = { debounce, roundNumber, downloadCSVReport, + subscribeToNextViewportGridChange, }; export { diff --git a/platform/core/src/utils/index.test.js b/platform/core/src/utils/index.test.js index 9b83f8db5..cae769191 100644 --- a/platform/core/src/utils/index.test.js +++ b/platform/core/src/utils/index.test.js @@ -35,6 +35,7 @@ describe('Top level exports', () => { 'resolveObjectPath', 'hierarchicalListUtils', 'progressTrackingUtils', + 'subscribeToNextViewportGridChange', ].sort(); const exports = Object.keys(utils.default).sort(); diff --git a/platform/core/src/utils/subscribeToNextViewportGridChange.ts b/platform/core/src/utils/subscribeToNextViewportGridChange.ts new file mode 100644 index 000000000..e6c72d048 --- /dev/null +++ b/platform/core/src/utils/subscribeToNextViewportGridChange.ts @@ -0,0 +1,37 @@ +import { ViewportGridService } from '../services'; + +/** + * Subscribes to the very next LAYOUT_CHANGED or GRID_STATE_CHANGED event that + * is not currently on the event queue. The subscriptions are made on a 'zero' + * timeout so as to avoid responding to any of those events currently on the event queue. + * The subscription persists only for a single invocation of either event. + * Once either event is fired, the subscriptions are unsubscribed. + * @param viewportGridService the viewport grid service to subscribe to + * @param gridChangeCallback the callback + */ +function subscribeToNextViewportGridChange( + viewportGridService: ViewportGridService, + gridChangeCallback: (arg: unknown) => void +): void { + const subscriber = () => { + const callback = (callbackProps: unknown) => { + subscriptions.forEach(subscription => subscription.unsubscribe()); + gridChangeCallback(callbackProps); + }; + + const subscriptions = [ + viewportGridService.subscribe( + viewportGridService.EVENTS.LAYOUT_CHANGED, + callback + ), + viewportGridService.subscribe( + viewportGridService.EVENTS.GRID_STATE_CHANGED, + callback + ), + ]; + }; + + window.setTimeout(subscriber, 0); +} + +export { subscribeToNextViewportGridChange }; diff --git a/platform/docs/docs/platform/services/ui/viewport-grid-service.md b/platform/docs/docs/platform/services/ui/viewport-grid-service.md index b6806343e..d523a7415 100644 --- a/platform/docs/docs/platform/services/ui/viewport-grid-service.md +++ b/platform/docs/docs/platform/services/ui/viewport-grid-service.md @@ -9,6 +9,15 @@ sidebar_label: Viewport Grid Service This is a new UI service, that handles the grid layout of the viewer. +## Events + +There are seven events that get publish in `ViewportGridService `: + +| Event | Description | +| ----------------------------- | --------------------------------------------------| +| ACTIVE_VIEWPORT_INDEX_CHANGED | Fires the index of the active viewport is changed | +| LAYOUT_CHANGED | Fires the layout is changed | +| GRID_STATE_CHANGED | Fires when the entire grid state is changed | ## Interface For a more detailed look on the options and return values each of these methods @@ -19,9 +28,10 @@ is expected to support, [check out it's interface in `@ohif/core`][interface] | `setActiveViewportIndex(index)` | Sets the active viewport index in the app | | `getState()` | Gets the states of the viewport (see below) | | `setDisplaySetsForViewport({ viewportIndex, displaySetInstanceUID })` | Sets displaySet for viewport based on displaySet Id | -| `setLayout({numCols, numRows, keepExtraViewports})` | Sets rows and columns. When the total number of viewports decreases, optionally keep the extra/offscreen viewports. | +| `setLayout({numCols, numRows, keepExtraViewports})` | Sets rows and columns. When the total number of viewports decreases, optionally keep the extra/offscreen viewports. | | `reset()` | Resets the default states | | `getNumViewportPanes()` | Gets the number of visible viewport panes | +| `getLayoutOptionsFromState(gridState)` | Utility method that produces a `ViewportLayoutOptions` based on the passed in state| ## Implementations diff --git a/platform/ui/src/contextProviders/ViewportGridProvider.tsx b/platform/ui/src/contextProviders/ViewportGridProvider.tsx index c3bff44df..d74a6d715 100644 --- a/platform/ui/src/contextProviders/ViewportGridProvider.tsx +++ b/platform/ui/src/contextProviders/ViewportGridProvider.tsx @@ -146,6 +146,7 @@ export function ViewportGridProvider({ children, service }) { numRows, layoutOptions, layoutType = 'grid', + activeViewportIndex, findOrCreateViewport, } = action.payload; @@ -160,7 +161,7 @@ export function ViewportGridProvider({ children, service }) { // haven't been viewed yet, and add them in the appropriate order. const options = {}; - let activeViewportIndex; + let activeViewportIndexToSet = activeViewportIndex; for (let row = 0; row < numRows; row++) { for (let col = 0; col < numCols; col++) { const pos = col + row * numCols; @@ -170,10 +171,11 @@ export function ViewportGridProvider({ children, service }) { continue; } if ( - !activeViewportIndex || - state.viewports[pos]?.positionId === positionId + activeViewportIndexToSet == null && + state.viewports[state.activeViewportIndex]?.positionId === + positionId ) { - activeViewportIndex = pos; + activeViewportIndexToSet = pos; } const viewport = findOrCreateViewport(pos, positionId, options); if (!viewport) continue; @@ -199,6 +201,8 @@ export function ViewportGridProvider({ children, service }) { } } + activeViewportIndexToSet = activeViewportIndexToSet ?? 0; + const viewportIdSet = {}; for ( let viewportIndex = 0; @@ -223,7 +227,7 @@ export function ViewportGridProvider({ children, service }) { const ret = { ...state, - activeViewportIndex, + activeViewportIndex: activeViewportIndexToSet, layout: { ...state.layout, numCols, @@ -300,6 +304,7 @@ export function ViewportGridProvider({ children, service }) { numRows, numCols, layoutOptions = [], + activeViewportIndex, findOrCreateViewport, }) => dispatch({ @@ -309,6 +314,7 @@ export function ViewportGridProvider({ children, service }) { numRows, numCols, layoutOptions, + activeViewportIndex, findOrCreateViewport, }, }), @@ -375,9 +381,9 @@ export function ViewportGridProvider({ children, service }) { setActiveViewportIndex: index => service.setActiveViewportIndex(index), // run it through the service itself since we want to publish events setDisplaySetsForViewport, setDisplaySetsForViewports, - setLayout, + setLayout: layout => service.setLayout(layout), // run it through the service itself since we want to publish events reset, - set, + set: gridLayoutState => service.setState(gridLayoutState), // run it through the service itself since we want to publish events getNumViewportPanes, }; diff --git a/platform/viewer/cypress/integration/customization/OHIFDoubleClick.spec.js b/platform/viewer/cypress/integration/customization/OHIFDoubleClick.spec.js new file mode 100644 index 000000000..d60c1be91 --- /dev/null +++ b/platform/viewer/cypress/integration/customization/OHIFDoubleClick.spec.js @@ -0,0 +1,52 @@ +describe('OHIF Double Click', () => { + beforeEach(() => { + cy.checkStudyRouteInViewer( + '1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1', + '&hangingProtocolId=@ohif/hp-extension.mn' + ); + cy.expectMinimumThumbnails(3); + cy.initCornerstoneToolsAliases(); + cy.initCommonElementsAliases(); + }); + + it('Should double click each viewport to one up and back', () => { + const numExpectedViewports = 3; + cy.get('[data-cy="viewport-pane"]') + .its('length') + .should('be.eq', numExpectedViewports); + + for (let i = 0; i < numExpectedViewports; i += 1) { + // For whatever reason, with Cypress tests, we have to activate the + // viewport we are double clicking first. + cy.get('[data-cy="viewport-pane"]') + .eq(i) + .trigger('mousedown', 'center', { force: true }) + .trigger('mouseup', 'center', { force: true }); + + // Wait for the viewport to be 'active'. + // TODO Is there a better way to do this? + cy.get('[data-cy="viewport-pane"]') + .eq(i) + .parent() + .find('[data-cy="viewport-pane"]') + .not('.pointer-events-none'); + + // The actual double click. + cy.get('[data-cy="viewport-pane"]') + .eq(i) + .trigger('dblclick', 'center'); + + cy.get('[data-cy="viewport-pane"]') + .its('length') + .should('be.eq', 1); + + cy.get('[data-cy="viewport-pane"]') + .eq(0) + .trigger('dblclick', 'center'); + + cy.get('[data-cy="viewport-pane"]') + .its('length') + .should('be.eq', numExpectedViewports); + } + }); +}); From ca3b83b2b645f8ac8e7ad3a378ae4468d5df0858 Mon Sep 17 00:00:00 2001 From: Alireza Date: Thu, 30 Mar 2023 10:01:48 -0400 Subject: [PATCH 17/19] feat(tmtv): add more stages to pt/ct (#3290) * feat(tmtv): add more stages to pt/ct * make error stage change to info * apply review comments --- extensions/default/src/commandsModule.ts | 2 +- .../tmtv/src/getHangingProtocolModule.js | 711 ++++++------------ extensions/tmtv/src/utils/hpViewports.ts | 438 +++++++++++ 3 files changed, 656 insertions(+), 495 deletions(-) create mode 100644 extensions/tmtv/src/utils/hpViewports.ts diff --git a/extensions/default/src/commandsModule.ts b/extensions/default/src/commandsModule.ts index b2a80cbfe..a14563e93 100644 --- a/extensions/default/src/commandsModule.ts +++ b/extensions/default/src/commandsModule.ts @@ -329,7 +329,7 @@ const commandsModule = ({ uiNotificationService.show({ title: 'Change Stage', message: 'The hanging protocol has no more applicable stages', - type: 'error', + type: 'info', duration: 3000, }); }, diff --git a/extensions/tmtv/src/getHangingProtocolModule.js b/extensions/tmtv/src/getHangingProtocolModule.js index b58e55d42..63a9ba055 100644 --- a/extensions/tmtv/src/getHangingProtocolModule.js +++ b/extensions/tmtv/src/getHangingProtocolModule.js @@ -1,3 +1,219 @@ +import { + ctAXIAL, + ctCORONAL, + ctSAGITTAL, + fusionAXIAL, + fusionCORONAL, + fusionSAGITTAL, + mipSAGITTAL, + ptAXIAL, + ptCORONAL, + ptSAGITTAL, +} from './utils/hpViewports'; + +/** + * represents a 3x4 viewport layout configuration. The layout displays CT axial, sagittal, and coronal + * images in the first row, PT axial, sagittal, and coronal images in the second row, and fusion axial, + * sagittal, and coronal images in the third row. The fourth column is fully spanned by a MIP sagittal + * image, covering all three rows. It has synchronizers for windowLevel for all CT and PT images, and + * also camera synchronizer for each orientation + */ +const stage1 = { + name: 'default', + viewportStructure: { + layoutType: 'grid', + properties: { + rows: 3, + columns: 4, + layoutOptions: [ + { + x: 0, + y: 0, + width: 1 / 4, + height: 1 / 3, + }, + { + x: 1 / 4, + y: 0, + width: 1 / 4, + height: 1 / 3, + }, + { + x: 2 / 4, + y: 0, + width: 1 / 4, + height: 1 / 3, + }, + { + x: 0, + y: 1 / 3, + width: 1 / 4, + height: 1 / 3, + }, + { + x: 1 / 4, + y: 1 / 3, + width: 1 / 4, + height: 1 / 3, + }, + { + x: 2 / 4, + y: 1 / 3, + width: 1 / 4, + height: 1 / 3, + }, + { + x: 0, + y: 2 / 3, + width: 1 / 4, + height: 1 / 3, + }, + { + x: 1 / 4, + y: 2 / 3, + width: 1 / 4, + height: 1 / 3, + }, + { + x: 2 / 4, + y: 2 / 3, + width: 1 / 4, + height: 1 / 3, + }, + { + x: 3 / 4, + y: 0, + width: 1 / 4, + height: 1, + }, + ], + }, + }, + viewports: [ + ctAXIAL, + ctSAGITTAL, + ctCORONAL, + ptAXIAL, + ptSAGITTAL, + ptCORONAL, + fusionAXIAL, + fusionSAGITTAL, + fusionCORONAL, + mipSAGITTAL, + ], + createdDate: '2021-02-23T18:32:42.850Z', +}; + +/** + * The layout displays CT axial image in the top-left viewport, fusion axial image + * in the top-right viewport, PT axial image in the bottom-left viewport, and MIP + * sagittal image in the bottom-right viewport. The layout follows a simple grid + * pattern with 2 rows and 2 columns. It includes synchronizers as well. + */ +const stage2 = { + name: 'Fusion 2x2', + viewportStructure: { + layoutType: 'grid', + properties: { + rows: 2, + columns: 2, + }, + }, + viewports: [ctAXIAL, fusionAXIAL, ptAXIAL, mipSAGITTAL], +}; + +/** + * The top row displays CT images in axial, sagittal, and coronal orientations from + * left to right, respectively. The bottom row displays PT images in axial, sagittal, + * and coronal orientations from left to right, respectively. + * The layout follows a simple grid pattern with 2 rows and 3 columns. + * It includes synchronizers as well. + */ +const stage3 = { + name: '2x3-layout', + viewportStructure: { + layoutType: 'grid', + properties: { + rows: 2, + columns: 3, + }, + }, + viewports: [ctAXIAL, ctSAGITTAL, ctCORONAL, ptAXIAL, ptSAGITTAL, ptCORONAL], +}; + +/** + * In this layout, the top row displays PT images in coronal, sagittal, and axial + * orientations from left to right, respectively, followed by a MIP sagittal image + * that spans both rows on the rightmost side. The bottom row displays fusion images + * in coronal, sagittal, and axial orientations from left to right, respectively. + * There is no viewport in the bottom row's rightmost position, as the MIP sagittal viewport + * from the top row spans the full height of both rows. + * It includes synchronizers as well. + */ +const stage4 = { + name: '2x4-layout', + viewportStructure: { + layoutType: 'grid', + properties: { + rows: 2, + columns: 4, + layoutOptions: [ + { + x: 0, + y: 0, + width: 1 / 4, + height: 1 / 2, + }, + { + x: 1 / 4, + y: 0, + width: 1 / 4, + height: 1 / 2, + }, + { + x: 2 / 4, + y: 0, + width: 1 / 4, + height: 1 / 2, + }, + { + x: 3 / 4, + y: 0, + width: 1 / 4, + height: 1, + }, + { + x: 0, + y: 1 / 2, + width: 1 / 4, + height: 1 / 2, + }, + { + x: 1 / 4, + y: 1 / 2, + width: 1 / 4, + height: 1 / 2, + }, + { + x: 2 / 4, + y: 1 / 2, + width: 1 / 4, + height: 1 / 2, + }, + ], + }, + }, + viewports: [ + ptCORONAL, + ptSAGITTAL, + ptAXIAL, + mipSAGITTAL, + fusionCORONAL, + fusionSAGITTAL, + fusionAXIAL, + ], +}; + const ptCT = { id: '@ohif/extension-tmtv.hangingProtocolModule.ptCT', locked: true, @@ -100,500 +316,7 @@ const ptCT = { }, }, - stages: [ - { - name: 'default', - viewportStructure: { - layoutType: 'grid', - properties: { - rows: 3, - columns: 4, - layoutOptions: [ - { - x: 0, - y: 0, - width: 1 / 4, - height: 1 / 3, - }, - { - x: 1 / 4, - y: 0, - width: 1 / 4, - height: 1 / 3, - }, - { - x: 2 / 4, - y: 0, - width: 1 / 4, - height: 1 / 3, - }, - { - x: 0, - y: 1 / 3, - width: 1 / 4, - height: 1 / 3, - }, - { - x: 1 / 4, - y: 1 / 3, - width: 1 / 4, - height: 1 / 3, - }, - { - x: 2 / 4, - y: 1 / 3, - width: 1 / 4, - height: 1 / 3, - }, - { - x: 0, - y: 2 / 3, - width: 1 / 4, - height: 1 / 3, - }, - { - x: 1 / 4, - y: 2 / 3, - width: 1 / 4, - height: 1 / 3, - }, - { - x: 2 / 4, - y: 2 / 3, - width: 1 / 4, - height: 1 / 3, - }, - { - x: 3 / 4, - y: 0, - width: 1 / 4, - height: 1, - }, - ], - }, - }, - viewports: [ - { - viewportOptions: { - viewportId: 'ctAXIAL', - viewportType: 'volume', - orientation: 'axial', - toolGroupId: 'ctToolGroup', - initialImageOptions: { - // index: 5, - preset: 'first', // 'first', 'last', 'middle' - }, - syncGroups: [ - { - type: 'cameraPosition', - id: 'axialSync', - source: true, - target: true, - }, - { - type: 'voi', - id: 'ctWLSync', - source: true, - target: true, - }, - ], - }, - displaySets: [ - { - id: 'ctDisplaySet', - }, - ], - }, - { - viewportOptions: { - viewportId: 'ctSAGITTAL', - viewportType: 'volume', - orientation: 'sagittal', - toolGroupId: 'ctToolGroup', - syncGroups: [ - { - type: 'cameraPosition', - id: 'sagittalSync', - source: true, - target: true, - }, - { - type: 'voi', - id: 'ctWLSync', - source: true, - target: true, - }, - ], - }, - displaySets: [ - { - id: 'ctDisplaySet', - }, - ], - }, - { - viewportOptions: { - viewportId: 'ctCORONAL', - viewportType: 'volume', - orientation: 'coronal', - toolGroupId: 'ctToolGroup', - syncGroups: [ - { - type: 'cameraPosition', - id: 'coronalSync', - source: true, - target: true, - }, - { - type: 'voi', - id: 'ctWLSync', - source: true, - target: true, - }, - ], - }, - displaySets: [ - { - id: 'ctDisplaySet', - }, - ], - }, - { - viewportOptions: { - viewportId: 'ptAXIAL', - viewportType: 'volume', - background: [1, 1, 1], - orientation: 'axial', - toolGroupId: 'ptToolGroup', - initialImageOptions: { - // index: 5, - preset: 'first', // 'first', 'last', 'middle' - }, - syncGroups: [ - { - type: 'cameraPosition', - id: 'axialSync', - source: true, - target: true, - }, - { - type: 'voi', - id: 'ptWLSync', - source: true, - target: true, - }, - { - type: 'voi', - id: 'ptFusionWLSync', - source: true, - target: false, - }, - ], - }, - displaySets: [ - { - options: { - voi: { - windowWidth: 5, - windowCenter: 2.5, - }, - voiInverted: true, - }, - id: 'ptDisplaySet', - }, - ], - }, - { - viewportOptions: { - viewportId: 'ptSAGITTAL', - viewportType: 'volume', - orientation: 'sagittal', - background: [1, 1, 1], - toolGroupId: 'ptToolGroup', - syncGroups: [ - { - type: 'cameraPosition', - id: 'sagittalSync', - source: true, - target: true, - }, - { - type: 'voi', - id: 'ptWLSync', - source: true, - target: true, - }, - { - type: 'voi', - id: 'ptFusionWLSync', - source: true, - target: false, - }, - ], - }, - displaySets: [ - { - options: { - voi: { - windowWidth: 5, - windowCenter: 2.5, - }, - voiInverted: true, - }, - id: 'ptDisplaySet', - }, - ], - }, - { - viewportOptions: { - viewportId: 'ptCORONAL', - viewportType: 'volume', - orientation: 'coronal', - background: [1, 1, 1], - toolGroupId: 'ptToolGroup', - syncGroups: [ - { - type: 'cameraPosition', - id: 'coronalSync', - source: true, - target: true, - }, - { - type: 'voi', - id: 'ptWLSync', - source: true, - target: true, - }, - { - type: 'voi', - id: 'ptFusionWLSync', - source: true, - target: false, - }, - ], - }, - displaySets: [ - { - options: { - voi: { - windowWidth: 5, - windowCenter: 2.5, - }, - voiInverted: true, - }, - id: 'ptDisplaySet', - }, - ], - }, - { - viewportOptions: { - viewportId: 'fusionAXIAL', - viewportType: 'volume', - orientation: 'axial', - toolGroupId: 'fusionToolGroup', - initialImageOptions: { - // index: 5, - preset: 'first', // 'first', 'last', 'middle' - }, - syncGroups: [ - { - type: 'cameraPosition', - id: 'axialSync', - source: true, - target: true, - }, - { - type: 'voi', - id: 'ctWLSync', - source: false, - target: true, - }, - { - type: 'voi', - id: 'fusionWLSync', - source: true, - target: true, - }, - { - type: 'voi', - id: 'ptFusionWLSync', - source: false, - target: true, - }, - ], - }, - displaySets: [ - { - id: 'ctDisplaySet', - }, - { - options: { - colormap: 'hsv', - voi: { - windowWidth: 5, - windowCenter: 2.5, - }, - }, - id: 'ptDisplaySet', - }, - ], - }, - { - viewportOptions: { - viewportId: 'fusionSAGITTAL', - viewportType: 'volume', - orientation: 'sagittal', - toolGroupId: 'fusionToolGroup', - // initialImageOptions: { - // index: 180, - // preset: 'middle', // 'first', 'last', 'middle' - // }, - syncGroups: [ - { - type: 'cameraPosition', - id: 'sagittalSync', - source: true, - target: true, - }, - { - type: 'voi', - id: 'ctWLSync', - source: false, - target: true, - }, - { - type: 'voi', - id: 'fusionWLSync', - source: true, - target: true, - }, - { - type: 'voi', - id: 'ptFusionWLSync', - source: false, - target: true, - }, - ], - }, - displaySets: [ - { - id: 'ctDisplaySet', - }, - { - options: { - colormap: 'hsv', - voi: { - windowWidth: 5, - windowCenter: 2.5, - }, - }, - id: 'ptDisplaySet', - }, - ], - }, - { - viewportOptions: { - viewportId: 'fusionCoronal', - viewportType: 'volume', - orientation: 'coronal', - toolGroupId: 'fusionToolGroup', - // initialImageOptions: { - // index: 180, - // preset: 'middle', // 'first', 'last', 'middle' - // }, - syncGroups: [ - { - type: 'cameraPosition', - id: 'coronalSync', - source: true, - target: true, - }, - { - type: 'voi', - id: 'ctWLSync', - source: false, - target: true, - }, - { - type: 'voi', - id: 'fusionWLSync', - source: true, - target: true, - }, - { - type: 'voi', - id: 'ptFusionWLSync', - source: false, - target: true, - }, - ], - }, - displaySets: [ - { - id: 'ctDisplaySet', - }, - { - options: { - colormap: 'hsv', - voi: { - windowWidth: 5, - windowCenter: 2.5, - }, - }, - id: 'ptDisplaySet', - }, - ], - }, - { - viewportOptions: { - viewportId: 'mipSagittal', - viewportType: 'volume', - orientation: 'sagittal', - background: [1, 1, 1], - toolGroupId: 'mipToolGroup', - syncGroups: [ - { - type: 'voi', - id: 'ptWLSync', - source: true, - target: true, - }, - { - type: 'voi', - id: 'ptFusionWLSync', - source: true, - target: false, - }, - ], - - // Custom props can be used to set custom properties which extensions - // can react on. - customViewportProps: { - // We use viewportDisplay to filter the viewports which are displayed - // in mip and we set the scrollbar according to their rotation index - // in the cornerstone extension. - hideOverlays: true, - }, - }, - displaySets: [ - { - options: { - blendMode: 'MIP', - slabThickness: 'fullVolume', - voi: { - windowWidth: 5, - windowCenter: 2.5, - }, - voiInverted: true, - }, - id: 'ptDisplaySet', - }, - ], - }, - ], - createdDate: '2021-02-23T18:32:42.850Z', - }, - ], + stages: [stage1, stage2, stage3, stage4], numberOfPriorsReferenced: -1, }; diff --git a/extensions/tmtv/src/utils/hpViewports.ts b/extensions/tmtv/src/utils/hpViewports.ts new file mode 100644 index 000000000..0efc354ec --- /dev/null +++ b/extensions/tmtv/src/utils/hpViewports.ts @@ -0,0 +1,438 @@ +const ctAXIAL = { + viewportOptions: { + viewportId: 'ctAXIAL', + viewportType: 'volume', + orientation: 'axial', + toolGroupId: 'ctToolGroup', + initialImageOptions: { + // index: 5, + preset: 'first', // 'first', 'last', 'middle' + }, + syncGroups: [ + { + type: 'cameraPosition', + id: 'axialSync', + source: true, + target: true, + }, + { + type: 'voi', + id: 'ctWLSync', + source: true, + target: true, + }, + ], + }, + displaySets: [ + { + id: 'ctDisplaySet', + }, + ], +}; + +const ctSAGITTAL = { + viewportOptions: { + viewportId: 'ctSAGITTAL', + viewportType: 'volume', + orientation: 'sagittal', + toolGroupId: 'ctToolGroup', + syncGroups: [ + { + type: 'cameraPosition', + id: 'sagittalSync', + source: true, + target: true, + }, + { + type: 'voi', + id: 'ctWLSync', + source: true, + target: true, + }, + ], + }, + displaySets: [ + { + id: 'ctDisplaySet', + }, + ], +}; +const ctCORONAL = { + viewportOptions: { + viewportId: 'ctCORONAL', + viewportType: 'volume', + orientation: 'coronal', + toolGroupId: 'ctToolGroup', + syncGroups: [ + { + type: 'cameraPosition', + id: 'coronalSync', + source: true, + target: true, + }, + { + type: 'voi', + id: 'ctWLSync', + source: true, + target: true, + }, + ], + }, + displaySets: [ + { + id: 'ctDisplaySet', + }, + ], +}; + +const ptAXIAL = { + viewportOptions: { + viewportId: 'ptAXIAL', + viewportType: 'volume', + background: [1, 1, 1], + orientation: 'axial', + toolGroupId: 'ptToolGroup', + initialImageOptions: { + // index: 5, + preset: 'first', // 'first', 'last', 'middle' + }, + syncGroups: [ + { + type: 'cameraPosition', + id: 'axialSync', + source: true, + target: true, + }, + { + type: 'voi', + id: 'ptWLSync', + source: true, + target: true, + }, + { + type: 'voi', + id: 'ptFusionWLSync', + source: true, + target: false, + }, + ], + }, + displaySets: [ + { + options: { + voi: { + windowWidth: 5, + windowCenter: 2.5, + }, + voiInverted: true, + }, + id: 'ptDisplaySet', + }, + ], +}; + +const ptSAGITTAL = { + viewportOptions: { + viewportId: 'ptSAGITTAL', + viewportType: 'volume', + orientation: 'sagittal', + background: [1, 1, 1], + toolGroupId: 'ptToolGroup', + syncGroups: [ + { + type: 'cameraPosition', + id: 'sagittalSync', + source: true, + target: true, + }, + { + type: 'voi', + id: 'ptWLSync', + source: true, + target: true, + }, + { + type: 'voi', + id: 'ptFusionWLSync', + source: true, + target: false, + }, + ], + }, + displaySets: [ + { + options: { + voi: { + windowWidth: 5, + windowCenter: 2.5, + }, + voiInverted: true, + }, + id: 'ptDisplaySet', + }, + ], +}; + +const ptCORONAL = { + viewportOptions: { + viewportId: 'ptCORONAL', + viewportType: 'volume', + orientation: 'coronal', + background: [1, 1, 1], + toolGroupId: 'ptToolGroup', + syncGroups: [ + { + type: 'cameraPosition', + id: 'coronalSync', + source: true, + target: true, + }, + { + type: 'voi', + id: 'ptWLSync', + source: true, + target: true, + }, + { + type: 'voi', + id: 'ptFusionWLSync', + source: true, + target: false, + }, + ], + }, + displaySets: [ + { + options: { + voi: { + windowWidth: 5, + windowCenter: 2.5, + }, + voiInverted: true, + }, + id: 'ptDisplaySet', + }, + ], +}; + +const fusionAXIAL = { + viewportOptions: { + viewportId: 'fusionAXIAL', + viewportType: 'volume', + orientation: 'axial', + toolGroupId: 'fusionToolGroup', + initialImageOptions: { + // index: 5, + preset: 'first', // 'first', 'last', 'middle' + }, + syncGroups: [ + { + type: 'cameraPosition', + id: 'axialSync', + source: true, + target: true, + }, + { + type: 'voi', + id: 'ctWLSync', + source: false, + target: true, + }, + { + type: 'voi', + id: 'fusionWLSync', + source: true, + target: true, + }, + { + type: 'voi', + id: 'ptFusionWLSync', + source: false, + target: true, + }, + ], + }, + displaySets: [ + { + id: 'ctDisplaySet', + }, + { + options: { + colormap: 'hsv', + voi: { + windowWidth: 5, + windowCenter: 2.5, + }, + }, + id: 'ptDisplaySet', + }, + ], +}; + +const fusionSAGITTAL = { + viewportOptions: { + viewportId: 'fusionSAGITTAL', + viewportType: 'volume', + orientation: 'sagittal', + toolGroupId: 'fusionToolGroup', + // initialImageOptions: { + // index: 180, + // preset: 'middle', // 'first', 'last', 'middle' + // }, + syncGroups: [ + { + type: 'cameraPosition', + id: 'sagittalSync', + source: true, + target: true, + }, + { + type: 'voi', + id: 'ctWLSync', + source: false, + target: true, + }, + { + type: 'voi', + id: 'fusionWLSync', + source: true, + target: true, + }, + { + type: 'voi', + id: 'ptFusionWLSync', + source: false, + target: true, + }, + ], + }, + displaySets: [ + { + id: 'ctDisplaySet', + }, + { + options: { + colormap: 'hsv', + voi: { + windowWidth: 5, + windowCenter: 2.5, + }, + }, + id: 'ptDisplaySet', + }, + ], +}; + +const fusionCORONAL = { + viewportOptions: { + viewportId: 'fusionCoronal', + viewportType: 'volume', + orientation: 'coronal', + toolGroupId: 'fusionToolGroup', + // initialImageOptions: { + // index: 180, + // preset: 'middle', // 'first', 'last', 'middle' + // }, + syncGroups: [ + { + type: 'cameraPosition', + id: 'coronalSync', + source: true, + target: true, + }, + { + type: 'voi', + id: 'ctWLSync', + source: false, + target: true, + }, + { + type: 'voi', + id: 'fusionWLSync', + source: true, + target: true, + }, + { + type: 'voi', + id: 'ptFusionWLSync', + source: false, + target: true, + }, + ], + }, + displaySets: [ + { + id: 'ctDisplaySet', + }, + { + options: { + colormap: 'hsv', + voi: { + windowWidth: 5, + windowCenter: 2.5, + }, + }, + id: 'ptDisplaySet', + }, + ], +}; + +const mipSAGITTAL = { + viewportOptions: { + viewportId: 'mipSagittal', + viewportType: 'volume', + orientation: 'sagittal', + background: [1, 1, 1], + toolGroupId: 'mipToolGroup', + syncGroups: [ + { + type: 'voi', + id: 'ptWLSync', + source: true, + target: true, + }, + { + type: 'voi', + id: 'ptFusionWLSync', + source: true, + target: false, + }, + ], + + // Custom props can be used to set custom properties which extensions + // can react on. + customViewportProps: { + // We use viewportDisplay to filter the viewports which are displayed + // in mip and we set the scrollbar according to their rotation index + // in the cornerstone extension. + hideOverlays: true, + }, + }, + displaySets: [ + { + options: { + blendMode: 'MIP', + slabThickness: 'fullVolume', + voi: { + windowWidth: 5, + windowCenter: 2.5, + }, + voiInverted: true, + }, + id: 'ptDisplaySet', + }, + ], +}; + +export { + ctAXIAL, + ctSAGITTAL, + ctCORONAL, + ptAXIAL, + ptSAGITTAL, + ptCORONAL, + fusionAXIAL, + fusionSAGITTAL, + fusionCORONAL, + mipSAGITTAL, +}; From 5e42a42b5f2c5eaee132f965055820c3272f729b Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Fri, 31 Mar 2023 11:58:48 -0400 Subject: [PATCH 18/19] fix(viewports): The display of linked viewports during drag and drop has a race (#3286) * fix: The display of linked viewports during drag and drop has a race * PR review comments * fix: Segmentation display two up * Removing console logs * Fix the blank viewport can have stuff added to it * Fix the null name on HP module * Fix the navigate to initial image * Fix the nth interleave loader * Fix the unit tests * PR comments - docs mostly * fix: Exception thrown on change displayset after double click --- .../src/utils/_hydrateSEG.ts | 7 +- .../src/Viewport/OHIFCornerstoneViewport.tsx | 5 - .../src/getHangingProtocolModule.ts | 6 +- .../CornerstoneViewportService.ts | 32 ++- extensions/cornerstone/src/utils/nthLoader.ts | 29 +-- .../default/src/getHangingProtocolModule.js | 2 +- extensions/test-extension/src/hp/index.ts | 2 +- .../tmtv/src/getHangingProtocolModule.js | 2 +- .../src/extensions/ExtensionManager.test.js | 24 +- .../core/src/extensions/ExtensionManager.ts | 9 +- .../ViewportGridService.ts | 23 +- .../contextProviders/ViewportGridProvider.tsx | 244 ++++++++++-------- platform/viewer/public/config/multiple.js | 25 ++ 13 files changed, 215 insertions(+), 195 deletions(-) diff --git a/extensions/cornerstone-dicom-seg/src/utils/_hydrateSEG.ts b/extensions/cornerstone-dicom-seg/src/utils/_hydrateSEG.ts index 343f583b1..f6b1522be 100644 --- a/extensions/cornerstone-dicom-seg/src/utils/_hydrateSEG.ts +++ b/extensions/cornerstone-dicom-seg/src/utils/_hydrateSEG.ts @@ -31,8 +31,6 @@ async function _hydrateSEGDisplaySet({ displaySetInstanceUID ); - viewportGridService.setDisplaySetsForViewports(updatedViewports); - // Todo: fix this after we have a better way for stack viewport segmentations // check every viewport in the viewports to see if the displaySetInstanceUID @@ -50,7 +48,7 @@ async function _hydrateSEGDisplaySet({ ); if (shouldDisplaySeg) { - viewportGridService.setDisplaySetsForViewport({ + updatedViewports.push({ viewportIndex: index, displaySetInstanceUIDs: viewport.displaySetInstanceUIDs, viewportOptions: { @@ -62,6 +60,9 @@ async function _hydrateSEGDisplaySet({ } }); + // Do the entire update at once + viewportGridService.setDisplaySetsForViewports(updatedViewports); + return true; } diff --git a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx index 411e17a10..83d012ef2 100644 --- a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx +++ b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx @@ -10,7 +10,6 @@ import { utilities as csUtils, CONSTANTS, } from '@cornerstonejs/core'; -import { Services } from '@ohif/core'; import { setEnabledElement } from '../state'; @@ -23,9 +22,6 @@ import { import getSOPInstanceAttributes from '../utils/measurementServiceMappings/utils/getSOPInstanceAttributes'; import { CinePlayer, useCine, useViewportGrid } from '@ohif/ui'; -import { CornerstoneViewportService } from '../services/ViewportService/CornerstoneViewportService'; -import Presentation from '../types/Presentation'; - const STACK = 'stack'; function areEqual(prevProps, nextProps) { @@ -407,7 +403,6 @@ const OHIFCornerstoneViewport = React.memo(props => { lutPresentation: lutPresentationStore[presentationIds?.lutPresentationId], }; - console.log('Using presentations', presentations); cornerstoneViewportService.setViewportData( viewportIndex, diff --git a/extensions/cornerstone/src/getHangingProtocolModule.ts b/extensions/cornerstone/src/getHangingProtocolModule.ts index 17190e8e5..2c21e0ab1 100644 --- a/extensions/cornerstone/src/getHangingProtocolModule.ts +++ b/extensions/cornerstone/src/getHangingProtocolModule.ts @@ -11,7 +11,7 @@ const mpr: Types.HangingProtocol.Protocol = { // Unknown number of priors referenced - so just match any study numberOfPriorsReferenced: 0, protocolMatchingRules: [], - imageLoadStrategy: 'interleaveTopToBottom', + imageLoadStrategy: 'nth', callbacks: { // Switches out of MPR mode when the layout change button is used onLayoutChange: [ @@ -303,11 +303,11 @@ const mprAnd3DVolumeViewport = { function getHangingProtocolModule() { return [ { - id: 'mpr', + name: 'mpr', protocol: mpr, }, { - id: mprAnd3DVolumeViewport.id, + name: mprAnd3DVolumeViewport.id, protocol: mprAnd3DVolumeViewport, }, ]; diff --git a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts index 363c82985..946dee124 100644 --- a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts +++ b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts @@ -155,9 +155,14 @@ class CornerstoneViewportService extends PubSubService /** * Disables the viewport inside the renderingEngine, if no viewport is left * it destroys the renderingEngine. + * + * This is called when the element goes away entirely - with new viewportId's + * created for every new viewport, this will be called whenever the set of + * viewports is changed, but NOT when the viewport position changes only. + * * @param viewportIndex */ - public disableElement(viewportIndex: number) { + public disableElement(viewportIndex: number): void { const viewportInfo = this.viewportsInfo.get(viewportIndex); if (!viewportInfo) { return; @@ -254,12 +259,6 @@ class CornerstoneViewportService extends PubSubService viewportInfo.setDisplaySetOptions(displaySetOptions); viewportInfo.setViewportData(viewportData); - this._broadcastEvent(this.EVENTS.VIEWPORT_DATA_CHANGED, { - viewportData, - viewportIndex, - viewportId, - }); - const element = viewportInfo.getElement(); const type = viewportInfo.getViewportType(); const background = viewportInfo.getBackground(); @@ -283,6 +282,15 @@ class CornerstoneViewportService extends PubSubService const viewport = renderingEngine.getViewport(viewportId); this._setDisplaySets(viewport, viewportData, viewportInfo, presentations); + + // The broadcast event here ensures that listeners have a valid, up to date + // viewport to access. Doing it too early can result in exceptions or + // invalid data. + this._broadcastEvent(this.EVENTS.VIEWPORT_DATA_CHANGED, { + viewportData, + viewportIndex, + viewportId, + }); } public getCornerstoneViewport( @@ -386,11 +394,7 @@ class CornerstoneViewportService extends PubSubService } } - // There is a bug in CS3D that the setStack does not - // navigate to the desired image. - viewport.setStack(imageIds, 0).then(() => { - // The scroll, however, works fine in CS3D - viewport.scroll(initialImageIndexToUse); + viewport.setStack(imageIds, initialImageIndexToUse).then(() => { viewport.setProperties(properties); const camera = presentations.positionPresentation?.camera; if (camera) viewport.setCamera(camera); @@ -621,6 +625,10 @@ class CornerstoneViewportService extends PubSubService const viewportInfo = this.getViewportInfo(viewport.id); + if (!viewportInfo) { + console.warn('Viewport info not defined for', viewport.id); + } + const toolGroup = toolGroupService.getToolGroupForViewport(viewport.id); csToolsUtils.segmentation.triggerSegmentationRender(toolGroup.id); diff --git a/extensions/cornerstone/src/utils/nthLoader.ts b/extensions/cornerstone/src/utils/nthLoader.ts index a34066eb2..bce0d22f5 100644 --- a/extensions/cornerstone/src/utils/nthLoader.ts +++ b/extensions/cornerstone/src/utils/nthLoader.ts @@ -18,7 +18,6 @@ const viewportIdVolumeInputArrayMap = new Map(); export default function interleaveNthLoader({ data: { viewportId, volumeInputArray }, displaySetsMatchDetails, - viewportMatchDetails: matchDetails, }) { viewportIdVolumeInputArrayMap.set(viewportId, volumeInputArray); @@ -29,6 +28,7 @@ export default function interleaveNthLoader({ const volume = cache.getVolume(volumeId); if (!volume) { + console.log("interleaveNthLoader::No volume, can't load it"); return; } @@ -39,33 +39,6 @@ export default function interleaveNthLoader({ } } - /** - * The following is checking if all the viewports that were matched in the HP has been - * successfully created their cornerstone viewport or not. Todo: This can be - * improved by not checking it, and as soon as the matched DisplaySets have their - * volume loaded, we start the loading, but that comes at the cost of viewports - * not being created yet (e.g., in a 10 viewport ptCT fusion, when one ct viewport and one - * pt viewport are created we have a guarantee that the volumes are created in the cache - * but the rest of the viewports (fusion, mip etc.) are not created yet. So - * we can't initiate setting the volumes for those viewports. One solution can be - * to add an event when a viewport is created (not enabled element event) and then - * listen to it and as the other viewports are created we can set the volumes for them - * since volumes are already started loading. - */ - if (matchDetails.size !== viewportIdVolumeInputArrayMap.size) { - return; - } - - // Check if all the matched volumes are loaded - for (const [_, details] of displaySetsMatchDetails.entries()) { - const { SeriesInstanceUID } = details; - - // HangingProtocol has matched, but don't have all the volumes created yet, so return - if (!Array.from(volumeIdMapsToLoad.values()).includes(SeriesInstanceUID)) { - return; - } - } - const volumeIds = Array.from(volumeIdMapsToLoad.keys()).slice(); // get volumes from cache const volumes = volumeIds.map(volumeId => { diff --git a/extensions/default/src/getHangingProtocolModule.js b/extensions/default/src/getHangingProtocolModule.js index 3df2c9a56..a975a8968 100644 --- a/extensions/default/src/getHangingProtocolModule.js +++ b/extensions/default/src/getHangingProtocolModule.js @@ -290,7 +290,7 @@ const defaultProtocol = { function getHangingProtocolModule() { return [ { - id: defaultProtocol.id, + name: defaultProtocol.id, protocol: defaultProtocol, }, ]; diff --git a/extensions/test-extension/src/hp/index.ts b/extensions/test-extension/src/hp/index.ts index a24aebb59..a1b0c22c2 100644 --- a/extensions/test-extension/src/hp/index.ts +++ b/extensions/test-extension/src/hp/index.ts @@ -2,7 +2,7 @@ import hpMN from './hpMN'; const hangingProtocols = [ { - id: '@ohif/hp-extension.mn', + name: '@ohif/hp-extension.mn', protocol: hpMN, }, ]; diff --git a/extensions/tmtv/src/getHangingProtocolModule.js b/extensions/tmtv/src/getHangingProtocolModule.js index 63a9ba055..45720c10d 100644 --- a/extensions/tmtv/src/getHangingProtocolModule.js +++ b/extensions/tmtv/src/getHangingProtocolModule.js @@ -323,7 +323,7 @@ const ptCT = { function getHangingProtocolModule() { return [ { - id: ptCT.id, + name: ptCT.id, protocol: ptCT, }, ]; diff --git a/platform/core/src/extensions/ExtensionManager.test.js b/platform/core/src/extensions/ExtensionManager.test.js index 4177deab7..2af3ce52a 100644 --- a/platform/core/src/extensions/ExtensionManager.test.js +++ b/platform/core/src/extensions/ExtensionManager.test.js @@ -206,40 +206,40 @@ describe('ExtensionManager.ts', () => { const extension = { id: 'hello-world', getViewportModule: () => { - return [{}]; + return [{ name: 'test' }]; }, getSopClassHandlerModule: () => { - return [{}]; + return [{ name: 'test' }]; }, getPanelModule: () => { - return [{}]; + return [{ name: 'test' }]; }, getToolbarModule: () => { - return [{}]; + return [{ name: 'test' }]; }, getCommandsModule: () => { - return [{}]; + return [{ name: 'test' }]; }, getLayoutTemplateModule: () => { - return [{}]; + return [{ name: 'test' }]; }, getDataSourcesModule: () => { - return [{}]; + return [{ name: 'test' }]; }, getHangingProtocolModule: () => { - return [{}]; + return [{ name: 'test' }]; }, getContextModule: () => { - return [{}]; + return [{ name: 'test' }]; }, getUtilityModule: () => { - return [{}]; + return [{ name: 'test' }]; }, getCustomizationModule: () => { - return [{}]; + return [{ name: 'test' }]; }, getStateSyncModule: () => { - return [{}]; + return [{ name: 'test' }]; }, }; diff --git a/platform/core/src/extensions/ExtensionManager.ts b/platform/core/src/extensions/ExtensionManager.ts index f4f629224..188da5add 100644 --- a/platform/core/src/extensions/ExtensionManager.ts +++ b/platform/core/src/extensions/ExtensionManager.ts @@ -283,6 +283,11 @@ export default class ExtensionManager { // Default for most extension points, // Just adds each entry ready for consumption by mode. extensionModule.forEach(element => { + if (!element.name) { + throw new Error( + `Extension ID ${extensionId} module ${moduleType} element has no name` + ); + } const id = `${extensionId}.${moduleType}.${element.name}`; element.id = id; this.modulesMap[id] = element; @@ -366,10 +371,10 @@ export default class ExtensionManager { _initHangingProtocolsModule = (extensionModule, extensionId) => { const { hangingProtocolService } = this._servicesManager.services; - extensionModule.forEach(({ id, protocol }) => { + extensionModule.forEach(({ name, protocol }) => { if (protocol) { // Only auto-register if protocol specified, otherwise let mode register - hangingProtocolService.addProtocol(id, protocol); + hangingProtocolService.addProtocol(name, protocol); } }); }; diff --git a/platform/core/src/services/ViewportGridService/ViewportGridService.ts b/platform/core/src/services/ViewportGridService/ViewportGridService.ts index be9875670..239db4f88 100644 --- a/platform/core/src/services/ViewportGridService/ViewportGridService.ts +++ b/platform/core/src/services/ViewportGridService/ViewportGridService.ts @@ -26,7 +26,6 @@ class ViewportGridService extends PubSubService { public setServiceImplementation({ getState: getStateImplementation, setActiveViewportIndex: setActiveViewportIndexImplementation, - setDisplaySetsForViewport: setDisplaySetsForViewportImplementation, setDisplaySetsForViewports: setDisplaySetsForViewportsImplementation, setLayout: setLayoutImplementation, reset: resetImplementation, @@ -40,9 +39,6 @@ class ViewportGridService extends PubSubService { if (setActiveViewportIndexImplementation) { this.serviceImplementation._setActiveViewportIndex = setActiveViewportIndexImplementation; } - if (setDisplaySetsForViewportImplementation) { - this.serviceImplementation._setDisplaySetsForViewport = setDisplaySetsForViewportImplementation; - } if (setDisplaySetsForViewportsImplementation) { this.serviceImplementation._setDisplaySetsForViewports = setDisplaySetsForViewportsImplementation; } @@ -77,22 +73,13 @@ class ViewportGridService extends PubSubService { return this.serviceImplementation._getState(); } - public setDisplaySetsForViewport({ - viewportIndex, - displaySetInstanceUIDs, - viewportOptions, - displaySetOptions, - }) { - this.serviceImplementation._setDisplaySetsForViewport({ - viewportIndex, - displaySetInstanceUIDs, - viewportOptions, - displaySetOptions, - }); + public setDisplaySetsForViewport(props) { + // Just update a single viewport, but use the multi-viewport update for it. + this.serviceImplementation._setDisplaySetsForViewports([props]); } - public setDisplaySetsForViewports(viewports) { - this.serviceImplementation._setDisplaySetsForViewports(viewports); + public setDisplaySetsForViewports(props) { + this.serviceImplementation._setDisplaySetsForViewports(props); } /** diff --git a/platform/ui/src/contextProviders/ViewportGridProvider.tsx b/platform/ui/src/contextProviders/ViewportGridProvider.tsx index d74a6d715..85fbb45db 100644 --- a/platform/ui/src/contextProviders/ViewportGridProvider.tsx +++ b/platform/ui/src/contextProviders/ViewportGridProvider.tsx @@ -34,54 +34,85 @@ const DEFAULT_STATE = { export const ViewportGridContext = createContext(DEFAULT_STATE); +/** A viewport is reuseable if it is the same size as the old + * one and has the same display sets, in the same position. + * It SHOULD be possible to re-use them at different positions, but + * this causes problems with segmentation. + */ +const isReuseableViewport = (oldViewport, newViewport) => { + const sameDiplaySets = isEqual( + oldViewport.displaySetInstanceUIDs, + newViewport.displaySetInstanceUIDs + ); + return ( + oldViewport.viewportIndex === newViewport.viewportIndex && + sameDiplaySets && + oldViewport.height === newViewport.height && + oldViewport.width === newViewport.width + ); +}; + +// Holds a global viewport counter - used to assign new id's to viewports +// Starts at a value above zero so that any of the old viewport id's are +// immediately obvious and if we get any index generated viewports, they are +// definitely distinct from these ones. +let viewportCounter = 5000; + /** - * Find a viewport to re-use, and then set the viewportId + * Find a viewportId to re-use if possible, preserving the existing + * viewport information, OR create a new one if the viewport given isn't + * compatible with what was there before. * - * @param idSet + * @param viewportIdSet * @param viewport * @param stateViewports * @returns */ -const reuseViewport = (idSet, viewport, stateViewports) => { - const oldIds = {}; +const reuseViewportId = (viewportIdSet: Set, viewport, stateViewports) => { for (const oldViewport of stateViewports) { const { viewportId: oldId } = oldViewport; - oldIds[oldId] = true; - if (!oldId || idSet[oldId]) continue; - if ( - !isEqual( - oldViewport.displaySetInstanceUIDs, - viewport.displaySetInstanceUIDs - ) - ) { + if (!oldId) { + // This occurs on startup, so skip re-using it continue; } - idSet[oldId] = true; - // TODO re-use viewports once the flickering/wrong size redraw is fixed - // return { - // ...oldViewport, - // ...viewport, - // viewportOptions: { - // ...oldViewport.viewportOptions, + if (viewportIdSet.has(oldId)) { + // oldId is already used - we can't reuse it + continue; + } + if (isReuseableViewport(oldViewport, viewport)) { + viewportIdSet.add(oldId); + // This means the old and the new viewport are compatible, and + // since we have gotten here, the viewport ID isn't used, so we + // are good to reuse it. + // This will remember the old viewport options, assuming they are unchanging. + return { + ...oldViewport, + ...viewport, + id: oldId, + viewportId: oldId, + viewportOptions: { + // Update any viewport options from new + ...viewport.viewportOptions, + viewportId: oldId, + }, + }; + } + } - // viewportId: oldViewport.viewportId, - // }, - // }; - } - // Find a viewport instance number different from earlier viewports having - // the same presentationIds as this one would - will be less than 10k - // viewports hopefully :-) - for (let i = 0; i < 10000; i++) { - const viewportId = 'viewport-' + i; - if (idSet[viewportId] || oldIds[viewportId]) continue; - idSet[viewportId] = true; - return { - ...viewport, - viewportId, - viewportOptions: { ...viewport.viewportOptions, viewportId }, - }; - } - throw new Error('No ID found'); + // There wasn't an old id found to be reused, so create a new one + // Find a viewport instance number different from earlier viewports + const viewportId = 'viewport-' + viewportCounter; + viewportIdSet.add(viewportId); + // Loop over viewport counters in case of a really long lived display + viewportCounter = (viewportCounter + 1) % 100000; + // viewportOptions is already a copy, so can just update direct + viewport.viewportOptions.viewportId = viewportId; + + return { + ...viewport, + id: viewportId, + viewportId, + }; }; export function ViewportGridProvider({ children, service }) { @@ -90,53 +121,72 @@ export function ViewportGridProvider({ children, service }) { case 'SET_ACTIVE_VIEWPORT_INDEX': { return { ...state, ...{ activeViewportIndex: action.payload } }; } - case 'SET_DISPLAYSET_FOR_VIEWPORT': { - const payload = action.payload; - const { viewportIndex, displaySetInstanceUIDs } = payload; - - // Note: there should be no inheritance happening at this level, - // we can't assume the new displaySet can inherit the previous - // displaySet's or viewportOptions at all. For instance, dragging - // and dropping a SEG/RT displaySet without any viewportOptions - // or displaySetOptions should not inherit the previous displaySet's - // which might have been a PDF Viewport. The viewport itself - // will deal with inheritance if required. Here is just a simple - // provider. - const viewport = state.viewports[viewportIndex] || {}; - const viewportOptions = { ...payload.viewportOptions }; - - const displaySetOptions = payload.displaySetOptions || []; - if (displaySetOptions.length === 0) { - // Only copy index 0, as that is all that is currently supported by this - // method call. - displaySetOptions.push({ ...viewport.displaySetOptions?.[0] }); - } + /** + * Sets the display sets for multiple viewports. + * This is a replacement for the older set display set for viewport (single) + * because the old one had race conditions wherein the viewports could + * render partially in various ways causing exceptions. + */ + case 'SET_DISPLAYSETS_FOR_VIEWPORTS': { + const { payload } = action; const viewports = state.viewports.slice(); - let newView = { - ...viewport, - displaySetInstanceUIDs, - viewportOptions, - displaySetOptions, - viewportLabel: viewportLabels[viewportIndex], - }; - viewportOptions.presentationIds = getPresentationIds( - newView, - viewports - ); + // Have the initial id set contain all viewports not updated here + const viewportIdSet = new Set(); + viewports.forEach((viewport, index) => { + if (!viewport.viewportId) return; + const isUpdated = payload.find( + newViewport => newViewport.viewportIndex === index + ); + if (isUpdated) { + return; + } + viewportIdSet.add(viewport.viewportId); + }); - // Make sure we assign a viewport id - newView = reuseViewport({}, newView, state.viewports); - console.log( - 'Creating new viewport', - viewportIndex, - newView.viewportOptions.viewportId, - displaySetInstanceUIDs, - displaySetOptions - ); + for (const updatedViewport of payload) { + // Use the newly provide viewportOptions and display set options + // when provided, and otherwise fall back to the previous ones. + // That allows for easy updates of just the display set. + const { viewportIndex, displaySetInstanceUIDs } = updatedViewport; + const previousViewport = viewports[viewportIndex] || {}; + const viewportOptions = { + ...(updatedViewport.viewportOptions || + previousViewport.viewportOptions), + }; - viewports[viewportIndex] = newView; + const displaySetOptions = updatedViewport.displaySetOptions || []; + if (!displaySetOptions.length) { + // Copy all the display set options, assuming a full set of displa + // set UID's is provided. + displaySetOptions.push(...previousViewport.displaySetOptions); + if (!displaySetOptions.length) { + displaySetOptions.push({}); + } + } + + let newViewport = { + ...previousViewport, + displaySetInstanceUIDs, + viewportOptions, + displaySetOptions, + viewportLabel: viewportLabels[viewportIndex], + }; + viewportOptions.presentationIds = getPresentationIds( + newViewport, + viewports + ); + + newViewport = reuseViewportId( + viewportIdSet, + newViewport, + state.viewports + ); + newViewport.viewportIndex = previousViewport.viewportIndex; + + viewports[viewportIndex] = newViewport; + } return { ...state, viewports }; } @@ -203,13 +253,13 @@ export function ViewportGridProvider({ children, service }) { activeViewportIndexToSet = activeViewportIndexToSet ?? 0; - const viewportIdSet = {}; + const viewportIdSet = new Set(); for ( let viewportIndex = 0; viewportIndex < viewports.length; viewportIndex++ ) { - const viewport = reuseViewport( + const viewport = reuseViewportId( viewportIdSet, viewports[viewportIndex], state.viewports @@ -268,36 +318,15 @@ export function ViewportGridProvider({ children, service }) { [dispatch] ); - const setDisplaySetsForViewport = useCallback( - ({ - viewportIndex, - displaySetInstanceUIDs, - viewportOptions, - displaySetSelectors, - displaySetOptions, - }) => + const setDisplaySetsForViewports = useCallback( + viewports => dispatch({ - type: 'SET_DISPLAYSET_FOR_VIEWPORT', - payload: { - viewportIndex, - displaySetInstanceUIDs, - viewportOptions, - displaySetSelectors, - displaySetOptions, - }, + type: 'SET_DISPLAYSETS_FOR_VIEWPORTS', + payload: viewports, }), [dispatch] ); - const setDisplaySetsForViewports = useCallback( - viewports => { - viewports.forEach(data => { - setDisplaySetsForViewport(data); - }); - }, - [setDisplaySetsForViewport] - ); - const setLayout = useCallback( ({ layoutType, @@ -355,7 +384,6 @@ export function ViewportGridProvider({ children, service }) { service.setServiceImplementation({ getState, setActiveViewportIndex, - setDisplaySetsForViewport, setDisplaySetsForViewports, setLayout, reset, @@ -368,7 +396,6 @@ export function ViewportGridProvider({ children, service }) { getState, service, setActiveViewportIndex, - setDisplaySetsForViewport, setDisplaySetsForViewports, setLayout, reset, @@ -379,7 +406,6 @@ export function ViewportGridProvider({ children, service }) { const api = { getState, setActiveViewportIndex: index => service.setActiveViewportIndex(index), // run it through the service itself since we want to publish events - setDisplaySetsForViewport, setDisplaySetsForViewports, setLayout: layout => service.setLayout(layout), // run it through the service itself since we want to publish events reset, diff --git a/platform/viewer/public/config/multiple.js b/platform/viewer/public/config/multiple.js index 7179bb74b..f456575af 100644 --- a/platform/viewer/public/config/multiple.js +++ b/platform/viewer/public/config/multiple.js @@ -42,6 +42,31 @@ window.config = { singlepart: 'bulkdata,video,pdf', }, }, + { + friendlyName: 'dcmjs DICOMWeb Server', + namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', + sourceName: 'ohif', + configuration: { + name: 'aws', + // old server + // wadoUriRoot: 'https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/wado', + // qidoRoot: 'https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs', + // wadoRoot: 'https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs', + // new server + wadoUriRoot: 'https://domvja9iplmyu.cloudfront.net/dicomweb', + qidoRoot: 'https://domvja9iplmyu.cloudfront.net/dicomweb', + wadoRoot: 'https://domvja9iplmyu.cloudfront.net/dicomweb', + qidoSupportsIncludeField: false, + supportsReject: false, + imageRendering: 'wadors', + thumbnailRendering: 'wadors', + enableStudyLazyLoad: true, + supportsFuzzyMatching: false, + supportsWildcard: true, + staticWado: true, + singlepart: 'bulkdata,video,pdf', + }, + }, { friendlyName: 'AWS S3 OHIF', namespace: '@ohif/extension-default.dataSourcesModule.dicomweb', From 226244a26cc0810302eb7b1ad3d020540fde08fe Mon Sep 17 00:00:00 2001 From: rodrigobasilio2022 <114958722+rodrigobasilio2022@users.noreply.github.com> Date: Fri, 31 Mar 2023 23:21:16 -0300 Subject: [PATCH 19/19] feat(multiframe): enhanced support for multiframe dicom (#3164) * Changes in cswil version and multiframe * Minor changes * wip * Adding support for NM multiframe images * Applying PR suggestions * fixing package versions * Restoring default.js config file * Check if NM subtype is reconstructable * Restore default.js values * refactore code * feat: add flag for strict zspacing --------- Co-authored-by: Alireza --- extensions/cornerstone-dicom-sr/package.json | 4 +- extensions/cornerstone/package.json | 8 +- extensions/cornerstone/src/init.tsx | 8 ++ .../default/src/DicomLocalDataSource/index.js | 7 +- .../default/src/DicomWebDataSource/index.js | 4 +- extensions/measurement-tracking/package.json | 4 +- platform/core/package.json | 2 +- platform/core/src/classes/MetadataProvider.js | 32 ++++- .../DicomMetadataStore/DicomMetadataStore.ts | 8 +- .../core/src/utils/combineFrameInstance.ts | 35 +++++- .../src/utils/isDisplaySetReconstructable.js | 114 ++++++++++++------ platform/viewer/package.json | 2 +- platform/viewer/public/config/aws.js | 1 + platform/viewer/public/config/default.js | 3 + platform/viewer/public/config/demo.js | 1 + .../viewer/public/config/dicomweb-server.js | 15 +++ .../viewer/public/config/dicomweb_relative.js | 1 + .../public/config/docker_nginx-orthanc.js | 15 +++ .../docker_openresty-orthanc-keycloak.js | 1 + .../public/config/docker_openresty-orthanc.js | 15 +++ platform/viewer/public/config/e2e.js | 1 + platform/viewer/public/config/google.js | 1 + platform/viewer/public/config/idc.js | 1 + .../viewer/public/config/local_dcm4chee.js | 15 +++ .../viewer/public/config/local_orthanc.js | 1 + platform/viewer/public/config/local_static.js | 1 + platform/viewer/public/config/multiple.js | 1 + platform/viewer/public/config/netlify.js | 1 + .../viewer/public/config/public_dicomweb.js | 1 + .../viewer/src/routes/buildModeRoutes.tsx | 7 -- yarn.lock | 44 ++++--- 31 files changed, 268 insertions(+), 86 deletions(-) diff --git a/extensions/cornerstone-dicom-sr/package.json b/extensions/cornerstone-dicom-sr/package.json index c130b436f..923385eda 100644 --- a/extensions/cornerstone-dicom-sr/package.json +++ b/extensions/cornerstone-dicom-sr/package.json @@ -46,7 +46,7 @@ "@babel/runtime": "^7.20.13", "classnames": "^2.3.2", "@cornerstonejs/adapters": "^0.6.0", - "@cornerstonejs/core": "^0.38.0", - "@cornerstonejs/tools": "^0.58.0" + "@cornerstonejs/core": "^0.40.0", + "@cornerstonejs/tools": "^0.60.1" } } diff --git a/extensions/cornerstone/package.json b/extensions/cornerstone/package.json index bb7b58480..2eab62700 100644 --- a/extensions/cornerstone/package.json +++ b/extensions/cornerstone/package.json @@ -29,7 +29,7 @@ "peerDependencies": { "@ohif/core": "^3.0.0", "@ohif/ui": "^2.0.0", - "cornerstone-wado-image-loader": "^4.10.2", + "cornerstone-wado-image-loader": "^4.13.0", "dcmjs": "^0.29.4", "dicom-parser": "^1.8.9", "hammerjs": "^2.0.8", @@ -44,9 +44,9 @@ "dependencies": { "@babel/runtime": "^7.20.13", "@cornerstonejs/adapters": "^0.6.0", - "@cornerstonejs/core": "^0.38.0", - "@cornerstonejs/streaming-image-volume-loader": "^0.15.13", - "@cornerstonejs/tools": "^0.58.0", + "@cornerstonejs/core": "^0.40.0", + "@cornerstonejs/streaming-image-volume-loader": "^0.16.0", + "@cornerstonejs/tools": "^0.60.1", "@kitware/vtk.js": "26.5.6", "html2canvas": "^1.4.1", "lodash.debounce": "4.0.8", diff --git a/extensions/cornerstone/src/init.tsx b/extensions/cornerstone/src/init.tsx index 479953b68..27ca02ee7 100644 --- a/extensions/cornerstone/src/init.tsx +++ b/extensions/cornerstone/src/init.tsx @@ -43,6 +43,14 @@ export default async function init({ // 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 const MAX_CACHE_SIZE_1GB = 1073741824; diff --git a/extensions/default/src/DicomLocalDataSource/index.js b/extensions/default/src/DicomLocalDataSource/index.js index fc6f366bb..f10d427ba 100644 --- a/extensions/default/src/DicomLocalDataSource/index.js +++ b/extensions/default/src/DicomLocalDataSource/index.js @@ -138,7 +138,9 @@ function createDicomLocalApi(dicomLocalConfig) { study.series.forEach(aSeries => { const { SeriesInstanceUID } = aSeries; - aSeries.instances.forEach(instance => { + const isMultiframe = aSeries.instances[0].NumberOfFrames > 1; + + aSeries.instances.forEach((instance, index) => { const { url: imageId, StudyInstanceUID, @@ -153,6 +155,7 @@ function createDicomLocalApi(dicomLocalConfig) { StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID, + frameIndex: isMultiframe ? index : 1, }); }); @@ -185,7 +188,7 @@ function createDicomLocalApi(dicomLocalConfig) { displaySet.images.forEach(instance => { const NumberOfFrames = instance.NumberOfFrames; if (NumberOfFrames > 1) { - for (let i = 0; i < NumberOfFrames; i++) { + for (let i = 1; i <= NumberOfFrames; i++) { const imageId = this.getImageIdsForInstance({ instance, frame: i, diff --git a/extensions/default/src/DicomWebDataSource/index.js b/extensions/default/src/DicomWebDataSource/index.js index ccb61f620..1af67936d 100644 --- a/extensions/default/src/DicomWebDataSource/index.js +++ b/extensions/default/src/DicomWebDataSource/index.js @@ -104,7 +104,7 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) { query: { studies: { mapParams: mapParams.bind(), - search: async function (origParams) { + search: async function(origParams) { const headers = userAuthenticationService.getAuthorizationHeader(); if (headers) { qidoDicomWebClient.headers = headers; @@ -129,7 +129,7 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) { }, series: { // mapParams: mapParams.bind(), - search: async function (studyInstanceUid) { + search: async function(studyInstanceUid) { const headers = userAuthenticationService.getAuthorizationHeader(); if (headers) { qidoDicomWebClient.headers = headers; diff --git a/extensions/measurement-tracking/package.json b/extensions/measurement-tracking/package.json index 135b10e71..332d84253 100644 --- a/extensions/measurement-tracking/package.json +++ b/extensions/measurement-tracking/package.json @@ -32,8 +32,8 @@ "peerDependencies": { "@ohif/core": "^3.0.0", "classnames": "^2.3.2", - "@cornerstonejs/core": "^0.38.0", - "@cornerstonejs/tools": "^0.58.0", + "@cornerstonejs/core": "^0.40.0", + "@cornerstonejs/tools": "^0.60.1", "@ohif/extension-cornerstone-dicom-sr": "^3.0.0", "dcmjs": "^0.29.4", "lodash.debounce": "^4.17.21", diff --git a/platform/core/package.json b/platform/core/package.json index 2ccc18696..42c973065 100644 --- a/platform/core/package.json +++ b/platform/core/package.json @@ -31,7 +31,7 @@ }, "peerDependencies": { "cornerstone-math": "0.1.9", - "cornerstone-wado-image-loader": "^4.10.2", + "cornerstone-wado-image-loader": "^4.13.0", "dicom-parser": "^1.8.9", "@ohif/ui": "^2.0.0" }, diff --git a/platform/core/src/classes/MetadataProvider.js b/platform/core/src/classes/MetadataProvider.js index fc17ac294..32f482e74 100644 --- a/platform/core/src/classes/MetadataProvider.js +++ b/platform/core/src/classes/MetadataProvider.js @@ -412,6 +412,34 @@ class MetadataProvider { return metadata; } + /** + * Retrieves the frameNumber information, depending on the url style + * wadors /frames/1 + * wadouri &frame=1 + * @param {*} imageId + * @returns + */ + getFrameInformationFromURL(imageId) { + function getInformationFromURL(informationString, separator) { + let result = ''; + const splittedStr = imageId.split(informationString)[1]; + if (splittedStr.includes(separator)) { + result = splittedStr.split(separator)[0]; + } else { + result = splittedStr; + } + return result; + } + + if (imageId.includes('/frames')) { + return getInformationFromURL('/frames', '/'); + } + if (imageId.includes('&frame=')) { + return getInformationFromURL('&frame=', '&'); + } + return; + } + getUIDsFromImageID(imageId) { // TODO: adding csiv here is not really correct. Probably need to use // metadataProvider.addImageIdToUIDs(imageId, { @@ -445,7 +473,7 @@ class MetadataProvider { // check if the imageId starts with http:// or https:// using regex // Todo: handle non http imageIds let imageURI; - const urlRegex = /^(http|https):\/\//; + const urlRegex = /^(http|https|dicomfile):\/\//; if (urlRegex.test(imageId)) { imageURI = imageId; } else { @@ -453,7 +481,7 @@ class MetadataProvider { } const uids = this.imageURIToUIDs.get(imageURI); - const frameNumber = imageId.split(/\/frames\//)[1]; + let frameNumber = this.getFrameInformationFromURL(imageId) || '1'; if (uids && frameNumber !== undefined) { return { ...uids, frameNumber }; diff --git a/platform/core/src/services/DicomMetadataStore/DicomMetadataStore.ts b/platform/core/src/services/DicomMetadataStore/DicomMetadataStore.ts index bf1aab1b6..f90dd2039 100644 --- a/platform/core/src/services/DicomMetadataStore/DicomMetadataStore.ts +++ b/platform/core/src/services/DicomMetadataStore/DicomMetadataStore.ts @@ -84,9 +84,9 @@ function _getInstance(StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID) { } function _getInstanceByImageId(imageId) { - for (let study of _model.studies) { - for (let series of study.series) { - for (let instance of series.instances) { + for (const study of _model.studies) { + for (const series of study.series) { + for (const instance of series.instances) { if (instance.imageId === imageId) { return instance; } @@ -236,7 +236,7 @@ const BaseImplementation = { addStudy(study) { const { StudyInstanceUID } = study; - let existingStudy = _model.studies.find( + const existingStudy = _model.studies.find( study => study.StudyInstanceUID === StudyInstanceUID ); diff --git a/platform/core/src/utils/combineFrameInstance.ts b/platform/core/src/utils/combineFrameInstance.ts index b5678a2d6..933437a24 100644 --- a/platform/core/src/utils/combineFrameInstance.ts +++ b/platform/core/src/utils/combineFrameInstance.ts @@ -30,12 +30,35 @@ const combineFrameInstance = (frame, instance) => { .map(it => it[0]) .filter(it => it !== undefined && typeof it === 'object'); - return Object.assign( - { frameNumber: frameNumber }, - instance, - ...Object.values(shared), - ...Object.values(perFrame) - ); + // this is to fix NM multiframe datasets with position and orientation + // information inside DetectorInformationSequence + if ( + !instance.ImageOrientationPatient && + instance.DetectorInformationSequence + ) { + instance.ImageOrientationPatient = + instance.DetectorInformationSequence[0].ImageOrientationPatient; + } + if ( + !instance.ImagePositionPatient && + instance.DetectorInformationSequence + ) { + instance.ImagePositionPatient = + instance.DetectorInformationSequence[0].ImagePositionPatient; + } + + const newInstance = Object.assign(instance, { frameNumber: frameNumber }); + + // merge the shared first then the per frame to override + [...shared, ...perFrame].forEach(item => { + Object.entries(item).forEach(([key, value]) => { + newInstance[key] = value; + }); + }); + + // Todo: we should cache this combined instance somewhere, maybe add it + // back to the dicomMetaStore so we don't have to do this again. + return newInstance; } else { return instance; } diff --git a/platform/core/src/utils/isDisplaySetReconstructable.js b/platform/core/src/utils/isDisplaySetReconstructable.js index 6ab7bf2fd..373da994a 100644 --- a/platform/core/src/utils/isDisplaySetReconstructable.js +++ b/platform/core/src/utils/isDisplaySetReconstructable.js @@ -30,62 +30,98 @@ export default function isDisplaySetReconstructable(instances) { } // Can't reconstruct if all instances don't have the ImagePositionPatient. - if (!instances.every(instance => !!instance.ImagePositionPatient)) { + if ( + !isMultiframe && + !instances.every(instance => instance.ImagePositionPatient) + ) { return { value: false }; } const sortedInstances = sortInstancesByPosition(instances); - if (isMultiframe) { - return processMultiframe(sortedInstances[0]); - } else { - return processSingleframe(sortedInstances); - } + return isMultiframe + ? processMultiframe(sortedInstances[0]) + : processSingleframe(sortedInstances); +} + +function hasPixelMeasurements(multiFrameInstance) { + const perFrameSequence = + multiFrameInstance.PerFrameFunctionalGroupsSequence?.[0]; + const sharedSequence = multiFrameInstance.SharedFunctionalGroupsSequence; + + return ( + Boolean(perFrameSequence?.PixelMeasuresSequence) || + Boolean(sharedSequence?.PixelMeasuresSequence) || + Boolean( + multiFrameInstance.PixelSpacing && + (multiFrameInstance.SliceThickness || + multiFrameInstance.SpacingBetweenFrames) + ) + ); +} + +function hasOrientation(multiFrameInstance) { + const sharedSequence = multiFrameInstance.SharedFunctionalGroupsSequence; + const perFrameSequence = + multiFrameInstance.PerFrameFunctionalGroupsSequence?.[0]; + + return ( + Boolean(sharedSequence?.PlaneOrientationSequence) || + Boolean(perFrameSequence?.PlaneOrientationSequence) || + Boolean( + multiFrameInstance.ImageOrientationPatient || + multiFrameInstance.DetectorInformationSequence?.[0] + ?.ImageOrientationPatient + ) + ); +} + +function hasPosition(multiFrameInstance) { + const perFrameSequence = + multiFrameInstance.PerFrameFunctionalGroupsSequence?.[0]; + + return ( + Boolean(perFrameSequence?.PlanePositionSequence) || + Boolean(perFrameSequence?.CTPositionSequence) || + Boolean( + multiFrameInstance.ImagePositionPatient || + multiFrameInstance.DetectorInformationSequence?.[0] + ?.ImagePositionPatient + ) + ); +} + +function isNMReconstructable(multiFrameInstance) { + const imageSubType = multiFrameInstance.ImageType?.[2]; + return imageSubType === 'RECON TOMO' || imageSubType === 'RECON GATED TOMO'; } function processMultiframe(multiFrameInstance) { - const { - PerFrameFunctionalGroupsSequence, - SharedFunctionalGroupsSequence, - } = multiFrameInstance; - // If we don't have the PixelMeasuresSequence, then the pixel spacing and // slice thickness isn't specified or is changing and we can't reconstruct // the dataset. + if (!hasPixelMeasurements(multiFrameInstance)) { + return { value: false }; + } + + if (!hasOrientation(multiFrameInstance)) { + console.log('No image orientation information, not reconstructable'); + return { value: false }; + } + + if (!hasPosition(multiFrameInstance)) { + console.log('No image position information, not reconstructable'); + return { value: false }; + } + if ( - !SharedFunctionalGroupsSequence || - !SharedFunctionalGroupsSequence[0].PixelMeasuresSequence + multiFrameInstance.Modality.includes('NM') && + !isNMReconstructable(multiFrameInstance) ) { return { value: false }; } - // Check that the orientation is either shared or with the allowed - // difference amount - const { - PlaneOrientationSequence: sharedOrientation, - } = SharedFunctionalGroupsSequence; - - if (!sharedOrientation) { - const { - PlaneOrientationSequence: firstOrientation, - } = PerFrameFunctionalGroupsSequence[0]; - - if (!firstOrientation) { - console.log('No orientation information'); - return { value: false }; - } - // TODO - check orientation consistency - } - - const frame0 = PerFrameFunctionalGroupsSequence[0]; - const firstPosition = - frame0.PlanePositionSequence || frame0.CTPositionSequence; - if (!firstPosition) { - console.log('No image position information, not reconstructable'); - return { value: false }; - } // TODO - check spacing consistency - return { value: true }; } diff --git a/platform/viewer/package.json b/platform/viewer/package.json index bb15b5b1e..60bbf2f42 100644 --- a/platform/viewer/package.json +++ b/platform/viewer/package.json @@ -65,7 +65,7 @@ "config-point": "^0.4.8", "core-js": "^3.16.1", "cornerstone-math": "^0.1.9", - "cornerstone-wado-image-loader": "^4.10.2", + "cornerstone-wado-image-loader": "^4.13.0", "dcmjs": "^0.29.4", "detect-gpu": "^4.0.16", "dicom-parser": "^1.8.9", diff --git a/platform/viewer/public/config/aws.js b/platform/viewer/public/config/aws.js index 5a8fdaafc..b6a2649cc 100644 --- a/platform/viewer/public/config/aws.js +++ b/platform/viewer/public/config/aws.js @@ -9,6 +9,7 @@ window.config = { showWarningMessageForCrossOrigin: true, showCPUFallbackMessage: true, showLoadingIndicator: true, + strictZSpacingForVolumeViewport: true, // filterQueryParam: false, dataSources: [ { diff --git a/platform/viewer/public/config/default.js b/platform/viewer/public/config/default.js index ab9117367..7b432830b 100644 --- a/platform/viewer/public/config/default.js +++ b/platform/viewer/public/config/default.js @@ -15,6 +15,7 @@ window.config = { showWarningMessageForCrossOrigin: true, showCPUFallbackMessage: true, showLoadingIndicator: true, + strictZSpacingForVolumeViewport: true, maxNumRequests: { interaction: 100, thumbnail: 75, @@ -34,10 +35,12 @@ window.config = { // wadoUriRoot: 'https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/wado', // qidoRoot: 'https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs', // wadoRoot: 'https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs', + // new server wadoUriRoot: 'https://domvja9iplmyu.cloudfront.net/dicomweb', qidoRoot: 'https://domvja9iplmyu.cloudfront.net/dicomweb', wadoRoot: 'https://domvja9iplmyu.cloudfront.net/dicomweb', + qidoSupportsIncludeField: false, supportsReject: false, imageRendering: 'wadors', diff --git a/platform/viewer/public/config/demo.js b/platform/viewer/public/config/demo.js index e7dccb101..5c37f5b48 100644 --- a/platform/viewer/public/config/demo.js +++ b/platform/viewer/public/config/demo.js @@ -5,6 +5,7 @@ window.config = { // below flag is for performance reasons, but it might not work for all servers omitQuotationForMultipartRequest: true, showWarningMessageForCrossOrigin: true, + strictZSpacingForVolumeViewport: true, showCPUFallbackMessage: true, servers: { dicomWeb: [ diff --git a/platform/viewer/public/config/dicomweb-server.js b/platform/viewer/public/config/dicomweb-server.js index c7639df91..770d94af3 100644 --- a/platform/viewer/public/config/dicomweb-server.js +++ b/platform/viewer/public/config/dicomweb-server.js @@ -9,6 +9,7 @@ window.config = { showWarningMessageForCrossOrigin: true, showCPUFallbackMessage: true, showLoadingIndicator: true, + strictZSpacingForVolumeViewport: true, // filterQueryParam: false, dataSources: [ { @@ -29,6 +30,20 @@ window.config = { supportsWildcard: false, }, }, + { + friendlyName: 'dicom json', + namespace: '@ohif/extension-default.dataSourcesModule.dicomjson', + sourceName: 'dicomjson', + configuration: { + name: 'json', + }, + }, + { + friendlyName: 'dicom local', + namespace: '@ohif/extension-default.dataSourcesModule.dicomlocal', + sourceName: 'dicomlocal', + configuration: {}, + }, ], defaultDataSourceName: 'dicomweb', }; diff --git a/platform/viewer/public/config/dicomweb_relative.js b/platform/viewer/public/config/dicomweb_relative.js index 08fdf81f7..1e0111975 100644 --- a/platform/viewer/public/config/dicomweb_relative.js +++ b/platform/viewer/public/config/dicomweb_relative.js @@ -10,6 +10,7 @@ window.config = { showWarningMessageForCrossOrigin: true, showCPUFallbackMessage: true, showLoadingIndicator: true, + strictZSpacingForVolumeViewport: true, // filterQueryParam: false, dataSources: [ { diff --git a/platform/viewer/public/config/docker_nginx-orthanc.js b/platform/viewer/public/config/docker_nginx-orthanc.js index 3ecf61559..d33fcfcb0 100644 --- a/platform/viewer/public/config/docker_nginx-orthanc.js +++ b/platform/viewer/public/config/docker_nginx-orthanc.js @@ -8,6 +8,7 @@ window.config = { showWarningMessageForCrossOrigin: true, showCPUFallbackMessage: true, showLoadingIndicator: true, + strictZSpacingForVolumeViewport: true, dataSources: [ { friendlyName: 'Orthanc Server', @@ -23,6 +24,20 @@ window.config = { thumbnailRendering: 'wadors', }, }, + { + friendlyName: 'dicom json', + namespace: '@ohif/extension-default.dataSourcesModule.dicomjson', + sourceName: 'dicomjson', + configuration: { + name: 'json', + }, + }, + { + friendlyName: 'dicom local', + namespace: '@ohif/extension-default.dataSourcesModule.dicomlocal', + sourceName: 'dicomlocal', + configuration: {}, + }, ], defaultDataSourceName: 'dicomweb', }; diff --git a/platform/viewer/public/config/docker_openresty-orthanc-keycloak.js b/platform/viewer/public/config/docker_openresty-orthanc-keycloak.js index d455815da..07709a7eb 100644 --- a/platform/viewer/public/config/docker_openresty-orthanc-keycloak.js +++ b/platform/viewer/public/config/docker_openresty-orthanc-keycloak.js @@ -6,6 +6,7 @@ window.config = { showWarningMessageForCrossOrigin: true, showCPUFallbackMessage: true, showLoadingIndicator: true, + strictZSpacingForVolumeViewport: true, servers: { // This is an array, but we'll only use the first entry for now dicomWeb: [ diff --git a/platform/viewer/public/config/docker_openresty-orthanc.js b/platform/viewer/public/config/docker_openresty-orthanc.js index 986a613cd..39cfebd68 100644 --- a/platform/viewer/public/config/docker_openresty-orthanc.js +++ b/platform/viewer/public/config/docker_openresty-orthanc.js @@ -8,6 +8,7 @@ window.config = { showWarningMessageForCrossOrigin: true, showCPUFallbackMessage: true, showLoadingIndicator: true, + strictZSpacingForVolumeViewport: true, dataSources: [ { friendlyName: 'Orthanc Server', @@ -23,6 +24,20 @@ window.config = { thumbnailRendering: 'wadors', }, }, + { + friendlyName: 'dicom json', + namespace: '@ohif/extension-default.dataSourcesModule.dicomjson', + sourceName: 'dicomjson', + configuration: { + name: 'json', + }, + }, + { + friendlyName: 'dicom local', + namespace: '@ohif/extension-default.dataSourcesModule.dicomlocal', + sourceName: 'dicomlocal', + configuration: {}, + }, ], defaultDataSourceName: 'dicomweb', }; diff --git a/platform/viewer/public/config/e2e.js b/platform/viewer/public/config/e2e.js index 2c090b9e7..3ec6fa559 100644 --- a/platform/viewer/public/config/e2e.js +++ b/platform/viewer/public/config/e2e.js @@ -8,6 +8,7 @@ window.config = { maxNumberOfWebWorkers: 3, showWarningMessageForCrossOrigin: false, showCPUFallbackMessage: false, + strictZSpacingForVolumeViewport: true, // filterQueryParam: false, dataSources: [ { diff --git a/platform/viewer/public/config/google.js b/platform/viewer/public/config/google.js index ef9925997..2c50a5ecc 100644 --- a/platform/viewer/public/config/google.js +++ b/platform/viewer/public/config/google.js @@ -6,6 +6,7 @@ window.config = { showWarningMessageForCrossOrigin: true, showCPUFallbackMessage: true, showLoadingIndicator: true, + strictZSpacingForVolumeViewport: true, // This is an array, but we'll only use the first entry for now oidc: [ { diff --git a/platform/viewer/public/config/idc.js b/platform/viewer/public/config/idc.js index 202c31cf2..6056962bd 100644 --- a/platform/viewer/public/config/idc.js +++ b/platform/viewer/public/config/idc.js @@ -6,6 +6,7 @@ window.config = { showWarningMessageForCrossOrigin: true, showCPUFallbackMessage: true, showLoadingIndicator: true, + strictZSpacingForVolumeViewport: true, servers: { // This is an array, but we'll only use the first entry for now dicomWeb: [], diff --git a/platform/viewer/public/config/local_dcm4chee.js b/platform/viewer/public/config/local_dcm4chee.js index 853ef229e..fca1f2a71 100644 --- a/platform/viewer/public/config/local_dcm4chee.js +++ b/platform/viewer/public/config/local_dcm4chee.js @@ -8,6 +8,7 @@ window.config = { showWarningMessageForCrossOrigin: true, showCPUFallbackMessage: true, showLoadingIndicator: true, + strictZSpacingForVolumeViewport: true, dataSources: [ { friendlyName: 'DCM4CHEE Server', @@ -27,6 +28,20 @@ window.config = { }, }, }, + { + friendlyName: 'dicom json', + namespace: '@ohif/extension-default.dataSourcesModule.dicomjson', + sourceName: 'dicomjson', + configuration: { + name: 'json', + }, + }, + { + friendlyName: 'dicom local', + namespace: '@ohif/extension-default.dataSourcesModule.dicomlocal', + sourceName: 'dicomlocal', + configuration: {}, + }, ], studyListFunctionsEnabled: true, defaultDataSourceName: 'dicomweb', diff --git a/platform/viewer/public/config/local_orthanc.js b/platform/viewer/public/config/local_orthanc.js index 7b31a3123..5165cb7e5 100644 --- a/platform/viewer/public/config/local_orthanc.js +++ b/platform/viewer/public/config/local_orthanc.js @@ -8,6 +8,7 @@ window.config = { showLoadingIndicator: true, showWarningMessageForCrossOrigin: true, showCPUFallbackMessage: true, + strictZSpacingForVolumeViewport: true, // filterQueryParam: false, dataSources: [ { diff --git a/platform/viewer/public/config/local_static.js b/platform/viewer/public/config/local_static.js index b4383dd24..fde3c563c 100644 --- a/platform/viewer/public/config/local_static.js +++ b/platform/viewer/public/config/local_static.js @@ -12,6 +12,7 @@ window.config = { showWarningMessageForCrossOrigin: true, showCPUFallbackMessage: true, showLoadingIndicator: true, + strictZSpacingForVolumeViewport: true, // filterQueryParam: false, dataSources: [ { diff --git a/platform/viewer/public/config/multiple.js b/platform/viewer/public/config/multiple.js index f456575af..2aa6252dd 100644 --- a/platform/viewer/public/config/multiple.js +++ b/platform/viewer/public/config/multiple.js @@ -20,6 +20,7 @@ window.config = { showWarningMessageForCrossOrigin: true, showCPUFallbackMessage: true, showLoadingIndicator: true, + strictZSpacingForVolumeViewport: true, // filterQueryParam: false, dataSources: [ { diff --git a/platform/viewer/public/config/netlify.js b/platform/viewer/public/config/netlify.js index a312d5ce8..405c276e5 100644 --- a/platform/viewer/public/config/netlify.js +++ b/platform/viewer/public/config/netlify.js @@ -9,6 +9,7 @@ window.config = { showWarningMessageForCrossOrigin: true, showCPUFallbackMessage: true, showLoadingIndicator: true, + strictZSpacingForVolumeViewport: true, // filterQueryParam: false, dataSources: [ { diff --git a/platform/viewer/public/config/public_dicomweb.js b/platform/viewer/public/config/public_dicomweb.js index 7ceb9afbd..7d310a330 100644 --- a/platform/viewer/public/config/public_dicomweb.js +++ b/platform/viewer/public/config/public_dicomweb.js @@ -6,6 +6,7 @@ window.config = { showWarningMessageForCrossOrigin: true, showCPUFallbackMessage: true, showLoadingIndicator: true, + strictZSpacingForVolumeViewport: true, servers: { dicomWeb: [ { diff --git a/platform/viewer/src/routes/buildModeRoutes.tsx b/platform/viewer/src/routes/buildModeRoutes.tsx index 76a9f3198..e87eb186e 100644 --- a/platform/viewer/src/routes/buildModeRoutes.tsx +++ b/platform/viewer/src/routes/buildModeRoutes.tsx @@ -31,11 +31,6 @@ export default function buildModeRoutes({ hotkeysManager, }) { const routes = []; - - // const dataSources = Object.keys(extensionManager.dataSourceMap).map(a => - // extensionManager.getDataSources(a) - // ); - const dataSourceNames = []; dataSources.forEach(dataSource => { @@ -61,7 +56,6 @@ export default function buildModeRoutes({ servicesManager={servicesManager} commandsManager={commandsManager} hotkeysManager={hotkeysManager} - commandsManager={commandsManager} /> ); @@ -86,7 +80,6 @@ export default function buildModeRoutes({ servicesManager={servicesManager} commandsManager={commandsManager} hotkeysManager={hotkeysManager} - commandsManager={commandsManager} /> ); diff --git a/yarn.lock b/yarn.lock index 53ac17d7f..e5d1f8dde 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1443,28 +1443,28 @@ resolved "https://registry.npmjs.org/@cornerstonejs/codec-openjph/-/codec-openjph-2.4.2.tgz#e96721d56f6ec96f7f95c16321d88cc8467d8d81" integrity sha512-lgdvBvvNezleY+4pIe2ceUsJzlZe/0PipdeubQ3vZZOz3xxtHHMR1XFCl4fgd8gosR8COHuD7h6q+MwgrwBsng== -"@cornerstonejs/core@^0.38.0": - version "0.38.0" - resolved "https://registry.npmjs.org/@cornerstonejs/core/-/core-0.38.0.tgz#6ee3341f38f78e98da85d2511ac16f25e9800f18" - integrity sha512-4/+qDEtGQRwV6AN5Ze4S6oOFiNH48oT5Mp/YQEzJHO+Zn9ceRHZ6ReTwccVUJ5QY2d5d89CoWJ2sKhdsLFZsNw== +"@cornerstonejs/core@^0.40.0": + version "0.40.0" + resolved "https://registry.npmjs.org/@cornerstonejs/core/-/core-0.40.0.tgz#5b6409277362b26c6ddb55b54025ecf26b304f84" + integrity sha512-tjUGFyXuRNRSybpKpd/mP4tKMshc48n/TIt9x5mXU+zqywBPGmojkXOj/v+pG02XopKLg5XPSI4LPysZNVscsg== dependencies: detect-gpu "^4.0.45" lodash.clonedeep "4.5.0" -"@cornerstonejs/streaming-image-volume-loader@^0.15.13": - version "0.15.13" - resolved "https://registry.npmjs.org/@cornerstonejs/streaming-image-volume-loader/-/streaming-image-volume-loader-0.15.13.tgz#d4c6651f5edac0adbc2c4059b78945fb70c38eb5" - integrity sha512-o0e+vEr08GmMztjabPs7vHKYhofW0dQvSI8Dy7tb1wKEnf05Tis/5s9sCt6PORNHXweK+Cylja3xy8b469ZuVw== +"@cornerstonejs/streaming-image-volume-loader@^0.16.0": + version "0.16.0" + resolved "https://registry.npmjs.org/@cornerstonejs/streaming-image-volume-loader/-/streaming-image-volume-loader-0.16.0.tgz#513f868c285963fd2f2dbf54ed7840a2dc05e33a" + integrity sha512-+bbQ6/FN7ryCDdsuyheIPeRa5MO8mTDUDZusNIU97Q8fdmw1hB+eiy9fNThdGfCENd/GPANNsXe6K5olUX7QhQ== dependencies: - "@cornerstonejs/core" "^0.38.0" + "@cornerstonejs/core" "^0.40.0" cornerstone-wado-image-loader "^4.10.2" -"@cornerstonejs/tools@^0.58.0": - version "0.58.0" - resolved "https://registry.npmjs.org/@cornerstonejs/tools/-/tools-0.58.0.tgz#c9a2ba6a96491a565fe03a2ef1c1403bd70992e4" - integrity sha512-sVsX6WGLmHg5UUg7p9iwvwjb8AswUypO2jARyIVtpa3JM3dP16Y0uh1jTWJFG2xWLZgEXDXcFCIhw5nOSohy7A== +"@cornerstonejs/tools@^0.60.1": + version "0.60.1" + resolved "https://registry.npmjs.org/@cornerstonejs/tools/-/tools-0.60.1.tgz#88cf32fe79c7fb714a99c1a63c25822233a4d231" + integrity sha512-0GsxN8INh8/1/uKH4pU1ZT8XLNn4Xi2GJug9CQBT6plfnppru//+P+xl+LEQbYVpsydD1iYoWzLe8ykPuIeQCA== dependencies: - "@cornerstonejs/core" "^0.38.0" + "@cornerstonejs/core" "^0.40.0" lodash.clonedeep "4.5.0" lodash.get "^4.4.2" @@ -8527,6 +8527,22 @@ cornerstone-wado-image-loader@^4.10.2: pako "^2.0.4" uuid "^9.0.0" +cornerstone-wado-image-loader@^4.13.0: + version "4.13.0" + resolved "https://registry.npmjs.org/cornerstone-wado-image-loader/-/cornerstone-wado-image-loader-4.13.0.tgz#277173973b1d5ec90a2d10557df1f667065953da" + integrity sha512-pv3Ic1wDXUopa3DuSJ/IVmnrdUzEsva8ARB5w/I2JPnH3GaO80yZB6TtCqP8BUf3bl33O15k7EsGSICd2KoeCw== + dependencies: + "@babel/eslint-parser" "^7.19.1" + "@cornerstonejs/codec-charls" "^1.2.3" + "@cornerstonejs/codec-libjpeg-turbo-8bit" "^1.2.2" + "@cornerstonejs/codec-openjpeg" "^1.2.2" + "@cornerstonejs/codec-openjph" "^2.4.2" + coverage-istanbul-loader "^3.0.5" + date-format "^4.0.14" + dicom-parser "^1.8.9" + pako "^2.0.4" + uuid "^9.0.0" + cosmiconfig@^5.0.0, cosmiconfig@^5.1.0, cosmiconfig@^5.2.1: version "5.2.1" resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz#040f726809c591e77a17c0a3626ca45b4f168b1a"