From 99b8dc475965a7dc74ebbc0c7535bb408d7ca699 Mon Sep 17 00:00:00 2001 From: Erik Ziegler Date: Fri, 28 May 2021 14:32:48 -0400 Subject: [PATCH] feat: Add White Labelling, Hanging Protocols, and tons of Extensions / Mode fixes * modified * new files in existing extensions/libraries/modes * fixed bugs and tests * Clean up * fix: viewport display fixed * Fixed configs for deployment * Removed unnecessary files and functions * Fixed default HP module * Added white labelling * Fixed hash routing * Removed unnecessary routers Co-authored-by: Alireza Sedghi --- .gitignore | 2 +- extensions/cornerstone/package.json | 4 +- .../src/OHIFCornerstoneViewport.js | 496 ++++++----- .../src/ViewportLoadingIndicator.js | 41 + extensions/cornerstone/src/ViewportOverlay.js | 83 ++ .../cornerstone/src/_shared/getTools.js | 36 + .../setActiveAndPassiveToolsForElement.js | 21 + .../setCornerstoneMeasurementActive.js | 39 + extensions/cornerstone/src/commandsModule.js | 89 +- extensions/cornerstone/src/id.js | 3 + extensions/cornerstone/src/index.js | 4 +- extensions/cornerstone/src/init.js | 91 +- extensions/cornerstone/src/state.js | 2 +- .../default/src/DicomJSONDataSource/index.js | 245 ++++++ .../default/src/DicomWebDataSource/index.js | 60 +- .../retrieveStudyMetadata.js | 8 +- .../DicomWebDataSource/utils/getImageId.js | 4 + .../src/DicomWebDataSource/utils/sortStudy.js | 6 +- .../wado/retrieveMetadata.js | 8 +- .../wado/retrieveMetadataLoader.js | 11 +- .../wado/retrieveMetadataLoaderAsync.js | 7 +- .../default/src/PanelMeasurementTable.js | 3 +- .../default/src/Panels/PanelStudyBrowser.jsx | 32 +- .../getStudiesForPatientByStudyInstanceUID.js | 1 + extensions/default/src/ViewerLayout/index.jsx | 12 +- extensions/default/src/commandsModule.js | 29 +- .../default/src/getDataSourcesModule.js | 6 + .../default/src/getHangingProtocolModule.js | 188 +++++ extensions/default/src/index.js | 2 + extensions/dicom-sr/package.json | 4 +- extensions/measurement-tracking/package.json | 2 +- .../TrackedMeasurementsContext.jsx | 2 +- .../_hydrateStructuredReport.js | 42 +- ...ornerstoneToolStateToMeasurementSchema.js} | 76 +- .../promptBeginTracking.js | 2 +- .../src/getViewportModule.js | 3 +- .../PanelMeasurementTableTracking/index.js | 5 +- .../PanelStudyBrowserTracking.jsx | 47 +- .../getStudiesForPatientByStudyInstanceUID.js | 1 + .../viewports/TrackedCornerstoneViewport.js | 357 ++------ modes/longitudinal/src/index.js | 34 +- package.json | 4 +- platform/core/package.json | 3 +- .../core/src/DataSources/IWebApiDataSource.js | 7 +- platform/core/src/classes/MetadataProvider.js | 111 ++- .../core/src/extensions/ExtensionManager.js | 50 +- .../src/extensions/ExtensionManager.test.js | 3 + platform/core/src/extensions/MODULE_TYPES.js | 1 + platform/core/src/index.js | 3 + platform/core/src/index.test.js | 31 +- .../core/src/measurements/tools/polygonRoi.js | 24 + .../DicomMetadataStore/DicomMetadataStore.js | 16 +- .../src/services/DicomMetadataStore/EVENTS.js | 1 + .../DicomMetadataStore/createStudyMetadata.js | 5 +- .../DisplaySetService/DisplaySetService.js | 23 +- .../HangingProtocolService/HPMatcher.js | 96 +++ .../HangingProtocolService.js | 526 ++++++++++-- .../HangingProtocolService/ProtocolEngine.js | 789 ++++++++++++++++++ .../services/HangingProtocolService/index.js | 7 +- .../HangingProtocolService/lib/comparators.js | 98 +++ .../lib/displayConstraint.js | 71 ++ .../lib/removeFromArray.js | 32 + .../HangingProtocolService/lib/sortByScore.js | 8 + .../HangingProtocolService/lib/validator.js | 39 + .../MeasurementService/MeasurementService.js | 17 +- .../services/ToolBarService/ToolBarService.js | 22 +- platform/core/src/services/index.js | 4 +- platform/core/src/utils/StackManager.js | 2 + platform/core/src/utils/sortBySeriesDate.js | 17 +- platform/ui/index.js | 3 - platform/ui/src/components/Header/Header.jsx | 11 +- .../ui/src/components/SidePanel/SidePanel.jsx | 2 +- .../ViewportActionBar/ViewportActionBar.jsx | 4 +- .../HangingProtocolProvider.jsx | 153 ---- .../contextProviders/ImageViewerProvider.jsx | 15 +- .../contextProviders/ViewportGridProvider.jsx | 18 +- platform/ui/src/contextProviders/index.js | 6 - .../OpenResty-Orthanc/docker-compose.yml | 4 +- .../.recipes/OpenResty-Orthanc/dockerfile | 33 +- platform/viewer/cypress/plugins/index.js | 21 + platform/viewer/package.json | 2 +- platform/viewer/public/config/default.js | 19 + .../public/config/docker_nginx-orthanc.js | 16 +- .../public/config/docker_openresty-orthanc.js | 21 +- .../viewer/public/config/local_dcm4chee.js | 18 +- platform/viewer/public/customLogo.svg | 65 ++ platform/viewer/src/App.jsx | 37 +- platform/viewer/src/appInit.js | 1 + .../viewer/src/components/ViewportGrid.jsx | 80 +- platform/viewer/src/history.js | 5 + platform/viewer/src/routes/Mode/Mode.jsx | 324 +++---- .../viewer/src/routes/WorkList/WorkList.jsx | 2 +- yarn.lock | 140 ++-- 93 files changed, 3825 insertions(+), 1293 deletions(-) create mode 100644 extensions/cornerstone/src/ViewportLoadingIndicator.js create mode 100644 extensions/cornerstone/src/ViewportOverlay.js create mode 100644 extensions/cornerstone/src/_shared/getTools.js create mode 100644 extensions/cornerstone/src/_shared/setActiveAndPassiveToolsForElement.js create mode 100644 extensions/cornerstone/src/_shared/setCornerstoneMeasurementActive.js create mode 100644 extensions/cornerstone/src/id.js create mode 100644 extensions/default/src/DicomJSONDataSource/index.js create mode 100644 extensions/default/src/getHangingProtocolModule.js rename extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/{getToolStateToCornerstoneMeasurementSchema.js => getCornerstoneToolStateToMeasurementSchema.js} (87%) create mode 100644 platform/core/src/measurements/tools/polygonRoi.js create mode 100644 platform/core/src/services/HangingProtocolService/HPMatcher.js create mode 100644 platform/core/src/services/HangingProtocolService/ProtocolEngine.js create mode 100644 platform/core/src/services/HangingProtocolService/lib/comparators.js create mode 100644 platform/core/src/services/HangingProtocolService/lib/displayConstraint.js create mode 100644 platform/core/src/services/HangingProtocolService/lib/removeFromArray.js create mode 100644 platform/core/src/services/HangingProtocolService/lib/sortByScore.js create mode 100644 platform/core/src/services/HangingProtocolService/lib/validator.js delete mode 100644 platform/ui/src/contextProviders/HangingProtocolProvider.jsx create mode 100644 platform/viewer/cypress/plugins/index.js create mode 100644 platform/viewer/public/customLogo.svg create mode 100644 platform/viewer/src/history.js diff --git a/.gitignore b/.gitignore index c37eab054..7ade4b40b 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,7 @@ coverage/ # YALC (for Erik) .yalc yalc.lock - +*.dcm # Logging, System files, misc. .idea/ .npm diff --git a/extensions/cornerstone/package.json b/extensions/cornerstone/package.json index fb63d6d2e..8c984fa88 100644 --- a/extensions/cornerstone/package.json +++ b/extensions/cornerstone/package.json @@ -32,7 +32,7 @@ "@ohif/core": "^0.50.0", "@ohif/ui": "^0.50.0", "cornerstone-core": "^2.3.0", - "cornerstone-math": "^0.1.8", + "cornerstone-math": "^0.1.9", "cornerstone-tools": "5.1.2", "cornerstone-wado-image-loader": "^3.1.2", "dcmjs": "0.16.1", @@ -47,6 +47,6 @@ "@babel/runtime": "7.7.6", "lodash.merge": "^4.6.2", "lodash.debounce": "4.0.8", - "react-cornerstone-viewport": "4.0.2" + "react-cornerstone-viewport": "4.0.5" } } diff --git a/extensions/cornerstone/src/OHIFCornerstoneViewport.js b/extensions/cornerstone/src/OHIFCornerstoneViewport.js index e06447784..c866e1716 100644 --- a/extensions/cornerstone/src/OHIFCornerstoneViewport.js +++ b/extensions/cornerstone/src/OHIFCornerstoneViewport.js @@ -1,107 +1,75 @@ -import React, { Component } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; +import PropTypes from 'prop-types'; +import cornerstone from 'cornerstone-core'; +import cornerstoneTools from 'cornerstone-tools'; import CornerstoneViewport from 'react-cornerstone-viewport'; import OHIF from '@ohif/core'; -import csTools from 'cornerstone-tools'; -import PropTypes from 'prop-types'; -import debounce from 'lodash.debounce'; -import getTools from './utils/getTools.js'; -import setActiveAndPassiveToolsForElement from './utils/setActiveAndPassiveToolsForElement'; +import ViewportLoadingIndicator from './ViewportLoadingIndicator'; +import setCornerstoneMeasurementActive from './_shared/setCornerstoneMeasurementActive'; +import ViewportOverlay from './ViewportOverlay'; -import { setEnabledElement } from './state'; +import { useCine, useViewportGrid } from '@ohif/ui'; + +const scrollToIndex = cornerstoneTools.importInternal('util/scrollToIndex'); const { StackManager } = OHIF.utils; -class OHIFCornerstoneViewport extends Component { - state = { - viewportData: null, - }; +function OHIFCornerstoneViewport({ + children, + dataSource, + displaySet, + onElementEnabled, + element, + viewportIndex, + servicesManager, + commandsManager, +}) { + const { + ToolBarService, + DisplaySetService, + MeasurementService, + HangingProtocolService, + } = servicesManager.services; + const [viewportData, setViewportData] = useState(null); + const [{ cines }, cineService] = useCine(); + const [{ viewports }, viewportGridService] = useViewportGrid(); - static defaultProps = { - customProps: {}, - }; + const isMounted = useRef(false); + const stageChangedRef = useRef(false); - static propTypes = { - displaySet: PropTypes.object, - viewportIndex: PropTypes.number, - dataSource: PropTypes.object, - children: PropTypes.node, - customProps: PropTypes.object, - ToolBarService: PropTypes.object, - }; - - static name = 'OHIFCornerstoneViewport'; - - static init() { - console.log('OHIFCornerstoneViewport init()'); - } - - static destroy() { - console.log('OHIFCornerstoneViewport destroy()'); - StackManager.clearStacks(); - } - - /** - * Obtain the CornerstoneTools Stack for the specified display set. - * - * @param {Object} displaySet - * @param {Object} dataSource - * @return {Object} CornerstoneTools Stack - */ - static getCornerstoneStack(displaySet, dataSource) { - const { imageIndex } = displaySet; - - // Get stack from Stack Manager - const storedStack = StackManager.findOrCreateStack(displaySet, dataSource); - - // Clone the stack here so we don't mutate it - const stack = Object.assign({}, storedStack); - - stack.currentImageIdIndex = imageIndex; - - // TODO -> Do we ever use this like this? - // if (SOPInstanceUID) { - // const index = stack.imageIds.findIndex(imageId => { - // const imageIdSOPInstanceUID = cornerstone.metaData.get( - // 'SOPInstanceUID', - // imageId - // ); - - // return imageIdSOPInstanceUID === SOPInstanceUID; - // }); - - // if (index > -1) { - // stack.currentImageIdIndex = index; - // } else { - // console.warn( - // 'SOPInstanceUID provided was not found in specified DisplaySet' - // ); - // } - // } - - return stack; - } - - getViewportData = async displaySet => { - let viewportData; - - const { dataSource } = this.props; - - const stack = OHIFCornerstoneViewport.getCornerstoneStack( - displaySet, - dataSource - ); - - viewportData = { - StudyInstanceUID: displaySet.StudyInstanceUID, - displaySetInstanceUID: displaySet.displaySetInstanceUID, - stack, + const onNewImage = (element, callback) => { + const handler = () => { + element.removeEventListener(cornerstone.EVENTS.IMAGE_RENDERED, handler); + callback(element, ToolBarService); }; - - return viewportData; + element.addEventListener(cornerstone.EVENTS.IMAGE_RENDERED, handler); }; - setStateFromProps() { - const { displaySet } = this.props; + useEffect(() => { + isMounted.current = true; + return () => { + isMounted.current = false; + StackManager.clearStacks(); + }; + }, []); + + useEffect(() => { + const { unsubscribe } = HangingProtocolService.subscribe( + HangingProtocolService.EVENTS.STAGE_CHANGE, + () => { + stageChangedRef.current = true; + } + ); + return () => { + unsubscribe(); + }; + }, []); + + useEffect(() => { + cineService.setCine({ id: viewportIndex }); + }, [viewportIndex]); + + useEffect(() => { const { StudyInstanceUID, displaySetInstanceUID, @@ -118,111 +86,267 @@ class OHIFCornerstoneViewport extends Component { ); } - this.getViewportData(displaySet).then(viewportData => { - this.setState({ - viewportData, + _getViewportData(dataSource, displaySet).then(data => { + if (isMounted.current) setViewportData(data); + }); + }, [dataSource, displaySet, viewportIndex]); + + useEffect(() => { + const unsubscribeFromJumpToMeasurementEvents = _subscribeToJumpToMeasurementEvents( + MeasurementService, + DisplaySetService, + element, + viewportIndex, + displaySet.displaySetInstanceUID, + viewportGridService + ); + + _checkForCachedJumpToMeasurementEvents( + MeasurementService, + DisplaySetService, + element, + viewportIndex, + displaySet.displaySetInstanceUID, + viewportGridService + ); + + // reseting the HP stage changed flag + if (element) { + onNewImage(element, () => { + stageChangedRef.current = false; }); + } + + // running HP-defined callbacks: invert, window level ... + if (element && displaySet.renderedCallback) { + onNewImage(element, displaySet.renderedCallback); + } + + return () => { + unsubscribeFromJumpToMeasurementEvents(); + }; + }, [element, displaySet]); + + let childrenWithProps = null; + + if (!viewportData) { + return null; + } + + const { + imageIds, + initialImageIdIndex, + // If this comes from the instance, would be a better default + // `FrameTime` in the instance + // frameRate = 0, + } = viewportData.stack; + + // TODO: Does it make more sense to use Context? + if (children && children.length) { + childrenWithProps = children.map((child, index) => { + return ( + child && + React.cloneElement(child, { + viewportIndex, + key: index, + }) + ); }); } - componentDidMount() { - this.setStateFromProps(); - } + // We have... + // StudyInstanceUid, DisplaySetInstanceUid + // Use displaySetInstanceUid --> SeriesInstanceUid + // Get meta for series, map to actionBar + // const displaySet = DisplaySetService.getDisplaySetByUID( + // dSet.displaySetInstanceUID + // ); + // TODO: This display contains the meta for all instances. + // That can't be right... + // console.log('DISPLAYSET', displaySet); + // const seriesMeta = DicomMetadataStore.getSeries(this.props.displaySet.StudyInstanceUID, ''); + // console.log(seriesMeta); - componentDidUpdate(prevProps) { - const { displaySet } = this.props; - const prevDisplaySet = prevProps.displaySet; + const cine = cines[viewportIndex]; + const isPlaying = (cine && cine.isPlaying) || false; + const frameRate = (cine && cine.frameRate) || 24; - if ( - displaySet.displaySetInstanceUID !== - prevDisplaySet.displaySetInstanceUID || - displaySet.SOPInstanceUID !== prevDisplaySet.SOPInstanceUID || - displaySet.imageIndex !== prevDisplaySet.imageIndex - ) { - this.setStateFromProps(); - } - } + return ( +
+ { + return ( + + ); + }} + /> + {childrenWithProps} +
+ ); +} - render() { - let childrenWithProps = null; +OHIFCornerstoneViewport.propTypes = { + displaySet: PropTypes.object, + viewportIndex: PropTypes.number, + dataSource: PropTypes.object, + children: PropTypes.node, + customProps: PropTypes.object, + ToolBarService: PropTypes.object, +}; - if (!this.state.viewportData) { - return null; - } - const { viewportIndex } = this.props; - const { - imageIds, - currentImageIdIndex, - // If this comes from the instance, would be a better default - // `FrameTime` in the instance - // frameRate = 0, - } = this.state.viewportData.stack; +OHIFCornerstoneViewport.defaultProps = { + customProps: {}, +}; - // TODO: Does it make more sense to use Context? - if (this.props.children && this.props.children.length) { - childrenWithProps = this.props.children.map((child, index) => { - return ( - child && - React.cloneElement(child, { - viewportIndex: this.props.viewportIndex, - key: index, - }) +const _viewportLabels = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I']; + +function _getCornerstoneStack(displaySet, dataSource) { + // Get stack from Stack Manager + const storedStack = StackManager.findOrCreateStack(displaySet, dataSource); + + // Clone the stack here so we don't mutate it + const stack = Object.assign({}, storedStack); + + return stack; +} + +async function _getViewportData(dataSource, displaySet) { + const stack = _getCornerstoneStack(displaySet, dataSource); + + const viewportData = { + StudyInstanceUID: displaySet.StudyInstanceUID, + displaySetInstanceUID: displaySet.displaySetInstanceUID, + stack, + }; + + return viewportData; +} + +function _subscribeToJumpToMeasurementEvents( + MeasurementService, + DisplaySetService, + element, + viewportIndex, + displaySetInstanceUID, + viewportGridService +) { + const { unsubscribe } = MeasurementService.subscribe( + MeasurementService.EVENTS.JUMP_TO_MEASUREMENT, + ({ measurement }) => { + if (!measurement) return; + // check if the correct viewport index. + // if (viewportIndex !== jumpToMeasurementViewportIndex) { + // // Event for a different viewport. + // return; + // } + + // Jump the the measurement if the displaySetInstanceUID matches + if (measurement.displaySetInstanceUID === displaySetInstanceUID) { + _jumpToMeasurement( + measurement, + element, + viewportIndex, + MeasurementService, + DisplaySetService, + viewportGridService ); - }); + } + } + ); + + return unsubscribe; +} + +function _checkForCachedJumpToMeasurementEvents( + MeasurementService, + DisplaySetService, + element, + viewportIndex, + displaySetInstanceUID, + viewportGridService +) { + // Check if there is a queued jumpToMeasurement event + const measurementIdToJumpTo = MeasurementService.getJumpToMeasurement( + viewportIndex + ); + + if (measurementIdToJumpTo && element) { + // Jump to measurement if the measurement exists + const measurement = MeasurementService.getMeasurement( + measurementIdToJumpTo + ); + + if (measurement.displaySetInstanceUID === displaySetInstanceUID) { + _jumpToMeasurement( + measurement, + element, + viewportIndex, + MeasurementService, + DisplaySetService, + viewportGridService + ); + } + } +} + +function _jumpToMeasurement( + measurement, + targetElement, + viewportIndex, + MeasurementService, + DisplaySetService, + viewportGridService +) { + const { displaySetInstanceUID, SOPInstanceUID } = measurement; + + const referencedDisplaySet = DisplaySetService.getDisplaySetByUID( + displaySetInstanceUID + ); + + const imageIndex = referencedDisplaySet.images.findIndex( + i => i.SOPInstanceUID === SOPInstanceUID + ); + + setCornerstoneMeasurementActive(measurement); + viewportGridService.setActiveViewportIndex(viewportIndex); + if (targetElement !== null) { + const enabledElement = cornerstone.getEnabledElement(targetElement); + + // Wait for the image to update or we get a race condition when the element has only just been enabled. + const scrollToHandler = evt => { + scrollToIndex(targetElement, imageIndex); + targetElement.removeEventListener( + 'cornerstoneimagerendered', + scrollToHandler + ); + }; + targetElement.addEventListener('cornerstoneimagerendered', scrollToHandler); + + if (enabledElement.image) { + cornerstone.updateImage(targetElement); } - const debouncedNewImageHandler = debounce( - ({ currentImageIdIndex, sopInstanceUid }) => { - const { displaySet } = this.props; - const { StudyInstanceUID } = displaySet; - if (currentImageIdIndex > 0) { - this.props.onNewImage({ - StudyInstanceUID, - SOPInstanceUID: sopInstanceUid, - imageIndex: currentImageIdIndex, - activeViewportIndex: viewportIndex, - }); - } - }, - 700 - ); - - // TODO -> We may still want a wrapped component to define all the measurement api stuff. - - return ( - <> - { - const enabledElement = evt.detail.element; - const tools = getTools(); - const toolAlias = ToolBarService.state.primaryToolId; - - setEnabledElement(viewportIndex, enabledElement); - setActiveAndPassiveToolsForElement(enabledElement, tools); - csTools.setToolActiveForElement(enabledElement, toolAlias, { - mouseButtonMask: 1, - }); - }} - // Sync resize throttle w/ sidepanel animation duration to prevent - // seizure inducing strobe blinking effect - resizeRefreshRateMs={150} - /> - {childrenWithProps} - - ); + // Jump to measurement consumed, remove. + MeasurementService.removeJumpToMeasurement(viewportIndex); } } diff --git a/extensions/cornerstone/src/ViewportLoadingIndicator.js b/extensions/cornerstone/src/ViewportLoadingIndicator.js new file mode 100644 index 000000000..46b6c7f72 --- /dev/null +++ b/extensions/cornerstone/src/ViewportLoadingIndicator.js @@ -0,0 +1,41 @@ +import React from 'react'; + +import PropTypes from 'prop-types'; + +const ViewportLoadingIndicator = ({ error }) => { + if (error) { + return ( + <> +
+
+

Error Loading Image

+

An error has occurred.

+

{error.message}

+
+ + ); + } + + return ( + <> +
+
+

+ Loading... +

+
+ + ); +}; + +ViewportLoadingIndicator.propTypes = { + percentComplete: PropTypes.number, + error: PropTypes.object, +}; + +ViewportLoadingIndicator.defaultProps = { + percentComplete: 0, + error: null, +}; + +export default ViewportLoadingIndicator; diff --git a/extensions/cornerstone/src/ViewportOverlay.js b/extensions/cornerstone/src/ViewportOverlay.js new file mode 100644 index 000000000..af93a2dd7 --- /dev/null +++ b/extensions/cornerstone/src/ViewportOverlay.js @@ -0,0 +1,83 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import cornerstone from 'cornerstone-core'; +import classnames from 'classnames'; + +const ViewportOverlay = ({ + imageId, + scale, + windowWidth, + windowCenter, + imageIndex, + stackSize, + activeTools, +}) => { + const topLeft = 'top-viewport left-viewport'; + const topRight = 'top-viewport right-viewport-scrollbar'; + const bottomRight = 'bottom-viewport right-viewport-scrollbar'; + const bottomLeft = 'bottom-viewport left-viewport'; + const overlay = 'absolute pointer-events-none'; + + const isZoomActive = activeTools.includes('Zoom'); + const isWwwcActive = activeTools.includes('Wwwc'); + + if (!imageId) { + return null; + } + + // TODO: this component should be presentational only. Right now it has a weird dependency on Cornerstone + const generalImageModule = + cornerstone.metaData.get('generalImageModule', imageId) || {}; + const { instanceNumber } = generalImageModule; + + return ( +
+
+ {isZoomActive && ( +
+ Zoom: + {scale.toFixed(2)}x +
+ )} + {isWwwcActive && ( +
+ W: + + {windowWidth.toFixed(0)} + + L: + {windowCenter.toFixed(0)} +
+ )} +
+
+ {stackSize > 1 && ( +
+ I: + + {`${instanceNumber} (${imageIndex}/${stackSize})`} + +
+ )} +
+
+
+
+ ); +}; + +ViewportOverlay.propTypes = { + scale: PropTypes.number.isRequired, + windowWidth: PropTypes.number.isRequired, + windowCenter: PropTypes.number.isRequired, + imageId: PropTypes.string.isRequired, + imageIndex: PropTypes.number.isRequired, + stackSize: PropTypes.number.isRequired, + activeTools: PropTypes.arrayOf(PropTypes.string), +}; + +ViewportOverlay.defaultProps = { + activeTools: [], +}; + +export default ViewportOverlay; diff --git a/extensions/cornerstone/src/_shared/getTools.js b/extensions/cornerstone/src/_shared/getTools.js new file mode 100644 index 000000000..c3d9b9e77 --- /dev/null +++ b/extensions/cornerstone/src/_shared/getTools.js @@ -0,0 +1,36 @@ +import csTools from 'cornerstone-tools'; + +const toolsGroupedByType = { + touch: [csTools.PanMultiTouchTool, csTools.ZoomTouchPinchTool], + annotations: [ + csTools.ArrowAnnotateTool, + csTools.BidirectionalTool, + csTools.LengthTool, + csTools.AngleTool, + csTools.FreehandRoiTool, + csTools.EllipticalRoiTool, + csTools.DragProbeTool, + csTools.RectangleRoiTool, + ], + other: [ + csTools.PanTool, + csTools.ZoomTool, + csTools.WwwcTool, + csTools.WwwcRegionTool, + csTools.MagnifyTool, + csTools.StackScrollTool, + csTools.StackScrollMouseWheelTool, + csTools.OverlayTool, + ], +}; + +export default function getTools() { + const tools = []; + Object.keys(toolsGroupedByType).forEach(toolsGroup => + tools.push(...toolsGroupedByType[toolsGroup]) + ); + + return tools; +} + +export { toolsGroupedByType }; diff --git a/extensions/cornerstone/src/_shared/setActiveAndPassiveToolsForElement.js b/extensions/cornerstone/src/_shared/setActiveAndPassiveToolsForElement.js new file mode 100644 index 000000000..b8348c990 --- /dev/null +++ b/extensions/cornerstone/src/_shared/setActiveAndPassiveToolsForElement.js @@ -0,0 +1,21 @@ +import csTools from 'cornerstone-tools'; + +export default function _setActiveAndPassiveToolsForElement(element, tools) { + const BaseAnnotationTool = csTools.importInternal('base/BaseAnnotationTool'); + + tools.forEach(tool => { + if (tool.prototype instanceof BaseAnnotationTool) { + // BaseAnnotationTool would likely come from csTools lib exports + const toolName = new tool().name; + csTools.setToolPassiveForElement(element, toolName); // there may be a better place to determine name; may not be on uninstantiated class + } + }); + + csTools.setToolActiveForElement(element, 'Pan', { mouseButtonMask: 4 }); + csTools.setToolActiveForElement(element, 'Zoom', { mouseButtonMask: 2 }); + csTools.setToolActiveForElement(element, 'Wwwc', { mouseButtonMask: 1 }); + csTools.setToolActiveForElement(element, 'StackScrollMouseWheel', {}); // TODO: Empty options should not be required + csTools.setToolActiveForElement(element, 'PanMultiTouch', { pointers: 2 }); // TODO: Better error if no options + csTools.setToolActiveForElement(element, 'ZoomTouchPinch', {}); + csTools.setToolEnabledForElement(element, 'Overlay', {}); +} diff --git a/extensions/cornerstone/src/_shared/setCornerstoneMeasurementActive.js b/extensions/cornerstone/src/_shared/setCornerstoneMeasurementActive.js new file mode 100644 index 000000000..b9b689f08 --- /dev/null +++ b/extensions/cornerstone/src/_shared/setCornerstoneMeasurementActive.js @@ -0,0 +1,39 @@ +import cornerstone from 'cornerstone-core'; +import cornerstoneTools from 'cornerstone-tools'; + +const { globalImageIdSpecificToolStateManager } = cornerstoneTools; + +export default function setCornerstoneMeasurementActive(measurement) { + const { id } = measurement; + + const toolState = globalImageIdSpecificToolStateManager.saveToolState(); + + Object.keys(toolState).forEach(imageId => { + const imageIdSpecificToolState = toolState[imageId]; + + Object.keys(imageIdSpecificToolState).forEach(toolType => { + const toolSpecificToolState = imageIdSpecificToolState[toolType]; + + const toolSpecificToolData = toolSpecificToolState.data; + + if (toolSpecificToolData && toolSpecificToolData.length) { + toolSpecificToolData.forEach(data => { + data.active = data.id === id ? true : false; + }); + } + }); + }); + + const enabledElements = cornerstoneTools.store.state.enabledElements; + + enabledElements.forEach(element => { + try { + cornerstone.updateImage(element); + } catch (ex) { + // https://github.com/cornerstonejs/cornerstone/blob/master/src/updateImage.js#L16 + // This fails if enabledElement.image is undefined and we have no layers + // Instead of throwing, it should _probably_ do nothing. + // We'll just swallow the exception + } + }); +} diff --git a/extensions/cornerstone/src/commandsModule.js b/extensions/cornerstone/src/commandsModule.js index 459405cc9..30c7c1dec 100644 --- a/extensions/cornerstone/src/commandsModule.js +++ b/extensions/cornerstone/src/commandsModule.js @@ -9,6 +9,30 @@ const scroll = cornerstoneTools.import('util/scroll'); const { studyMetadataManager } = OHIF.utils; +const imagePositionSynchronizer = new cornerstoneTools.Synchronizer( + cornerstone.EVENTS.NEW_IMAGE, + cornerstoneTools.stackImagePositionSynchronizer +); + +const panZoomSynchronizer = new cornerstoneTools.Synchronizer( + cornerstone.EVENTS.IMAGE_RENDERED, + cornerstoneTools.panZoomSynchronizer +); + +function onElementEnabledAddToSync(event) { + const { element } = event.detail; + + imagePositionSynchronizer.add(element); + // panZoomSynchronizer.add(element); +} + +function onElementDisabledRemoveFromSync(event) { + const { element } = event.detail; + + imagePositionSynchronizer.remove(element); + panZoomSynchronizer.remove(element); +} + const commandsModule = ({ servicesManager, commandsManager }) => { const { ViewportGridService } = servicesManager.services; @@ -70,8 +94,60 @@ const commandsModule = ({ servicesManager, commandsManager }) => { cornerstone.reset(enabledElement); } }, - invertViewport: () => { - const enabledElement = _getActiveViewportsEnabledElement(); + toggleSynchronizer: ({ toggledState }) => { + const synchronizers = [imagePositionSynchronizer]; + // Set synchronizer state when the command is run. + synchronizers.forEach(s => { + s.enabled = toggledState; + }); + + const unsubscribe = () => { + cornerstone.events.removeEventListener( + cornerstone.EVENTS.ELEMENT_ENABLED, + onElementEnabledAddToSync + ); + cornerstone.events.removeEventListener( + cornerstone.EVENTS.ELEMENT_DISABLED, + onElementDisabledRemoveFromSync + ); + }; + const subscribe = () => { + cornerstone.events.addEventListener( + cornerstone.EVENTS.ELEMENT_ENABLED, + onElementEnabledAddToSync + ); + cornerstone.events.addEventListener( + cornerstone.EVENTS.ELEMENT_DISABLED, + onElementDisabledRemoveFromSync + ); + }; + + // Add event handlers so that if the layout is changed, new elements + // are automatically added to the synchronizer while it is enabled. + if (toggledState === true) { + subscribe(); + } else { + // If the synchronizer is disabled, remove the event handlers + unsubscribe(); + } + + // Erase existing state and then set up all currently existing elements + cornerstone.getEnabledElements().map(e => { + synchronizers.forEach(s => { + s.remove(e.element); + s.add(e.element); + }); + }); + return unsubscribe; + }, + invertViewport: ({ element }) => { + let enabledElement; + + if (element === undefined) { + enabledElement = _getActiveViewportsEnabledElement(); + } else { + enabledElement = element; + } if (enabledElement) { let viewport = cornerstone.getViewport(enabledElement); @@ -111,11 +187,11 @@ const commandsModule = ({ servicesManager, commandsManager }) => { const viewportInfo = getEnabledElement(i); const hasCornerstoneContext = - viewportInfo.context == 'ACTIVE_VIEWPORT::CORNERSTONE'; + viewportInfo.context === 'ACTIVE_VIEWPORT::CORNERSTONE'; if (hasCornerstoneContext) { cornerstoneTools.setToolActiveForElement( - viewportInfo.enabledElement, + viewportInfo.element, toolName, { mouseButtonMask: 1 } ); @@ -333,6 +409,11 @@ const commandsModule = ({ servicesManager, commandsManager }) => { storeContexts: [], options: {}, }, + toggleSynchronizer: { + commandFn: actions.toggleSynchronizer, + storeContexts: [], + options: {}, + }, removeToolState: { commandFn: actions.removeToolState, storeContexts: [], diff --git a/extensions/cornerstone/src/id.js b/extensions/cornerstone/src/id.js new file mode 100644 index 000000000..6721cf5c4 --- /dev/null +++ b/extensions/cornerstone/src/id.js @@ -0,0 +1,3 @@ +const id = 'org.ohif.cornerstone'; + +export default id; diff --git a/extensions/cornerstone/src/index.js b/extensions/cornerstone/src/index.js index c85d2c7b7..bd070c2e9 100644 --- a/extensions/cornerstone/src/index.js +++ b/extensions/cornerstone/src/index.js @@ -38,12 +38,14 @@ export default { const onNewImageHandler = jumpData => { commandsManager.runCommand('jumpToImage', jumpData); }; - const { ToolBarService } = servicesManager; + const { ToolBarService } = servicesManager.services; return ( ); diff --git a/extensions/cornerstone/src/init.js b/extensions/cornerstone/src/init.js index 8263b374b..497140a1f 100644 --- a/extensions/cornerstone/src/init.js +++ b/extensions/cornerstone/src/init.js @@ -465,6 +465,9 @@ const _connectToolsToMeasurementService = ( } ); + // on display sets added, check if there are any measurements in measurement service that need to be + // put into cornerstone tools tooldata + const enabledElement = evt.detail.element; const completedEvt = csTools.EVENTS.MEASUREMENT_COMPLETED; const updatedEvt = csTools.EVENTS.MEASUREMENT_MODIFIED; @@ -480,9 +483,13 @@ const _connectToolsToMeasurementService = ( const _connectMeasurementServiceToTools = ( MeasurementService, - measurementSource + measurementSource, + dataSource ) => { - const { MEASUREMENT_REMOVED } = MeasurementService.EVENTS; + const { + MEASUREMENT_REMOVED, + RAW_MEASUREMENT_ADDED, + } = MeasurementService.EVENTS; const sourceId = measurementSource.id; // TODO: This is an unsafe delete @@ -495,6 +502,86 @@ const _connectMeasurementServiceToTools = ( // // Could potentially use "source" from event to determine tool type and skip some // iterations? + + const { + POLYLINE, + ELLIPSE, + POINT, + BIDIRECTIONAL, + } = MeasurementService.VALUE_TYPES; + + // TODO -> I get why this was attemped, but its not nearly flexible enough. + // A single measurement may have an ellipse + a bidirectional measurement, for instances. + // You can't define a bidirectional tool as a single type.. + // OHIF-230 + const TOOL_TYPE_TO_VALUE_TYPE = { + Length: POLYLINE, + EllipticalRoi: ELLIPSE, + Bidirectional: BIDIRECTIONAL, + ArrowAnnotate: POINT, + }; + + const VALUE_TYPE_TO_TOOL_TYPE = { + [POLYLINE]: 'Length', + [ELLIPSE]: 'EllipticalRoi', + [BIDIRECTIONAL]: 'Bidirectional', + [POINT]: 'ArrowAnnotate', + }; + + MeasurementService.subscribe( + RAW_MEASUREMENT_ADDED, + ({ source, measurement, data, dataSource }) => { + const { + referenceStudyUID: StudyInstanceUID, + referenceSeriesUID: SeriesInstanceUID, + SOPInstanceUID, + } = measurement; + + let toolType; + try { + toolType = VALUE_TYPE_TO_TOOL_TYPE[measurement.type]; + } catch { + throw Error('Cannot add tool to cornerstone tools'); + } + + let imageId; + if (data.imageId) { + // handle dicom json launch, since we cannot create image id from + // instance UIDs, each tool should embed the instance metadata + // TODO: handle multi instance case + imageId = data.imageId; + } else { + // handle general case of dicom web + const instance = { + StudyInstanceUID, + SeriesInstanceUID, + SOPInstanceUID, + }; + // TODO: handle multi frame + imageId = dataSource.getImageIdsForInstance({ instance }); + } + + const toolState = cornerstoneTools.globalImageIdSpecificToolStateManager.saveToolState(); + + if (toolState[imageId] === undefined) { + toolState[imageId] = {}; + } + + const imageIdToolState = toolState[imageId]; + + // If we don't have tool state for this type of tool, add an empty object + if (imageIdToolState[toolType] === undefined) { + imageIdToolState[toolType] = { + data: [], + }; + } + + const toolData = imageIdToolState[toolType]; + + toolData.data.push(data); + } + ); + MeasurementService.subscribe( MEASUREMENT_REMOVED, ({ source, measurement: removedMeasurementId }) => { diff --git a/extensions/cornerstone/src/state.js b/extensions/cornerstone/src/state.js index fa87917c8..e582b4bd5 100644 --- a/extensions/cornerstone/src/state.js +++ b/extensions/cornerstone/src/state.js @@ -10,7 +10,7 @@ const state = { * @return void */ const setEnabledElement = (viewportIndex, element, context) => { - const targetContext = context || DEFAULT_CONTEXT; + const targetContext = context || state.DEFAULT_CONTEXT; state.enabledElements[viewportIndex] = { element, diff --git a/extensions/default/src/DicomJSONDataSource/index.js b/extensions/default/src/DicomJSONDataSource/index.js new file mode 100644 index 000000000..b8f757225 --- /dev/null +++ b/extensions/default/src/DicomJSONDataSource/index.js @@ -0,0 +1,245 @@ +import { DicomMetadataStore, IWebApiDataSource } from '@ohif/core'; +import OHIF from '@ohif/core'; + +import getImageId from '../DicomWebDataSource/utils/getImageId'; + +const metadataProvider = OHIF.classes.metadataProvider; + +const mappings = { + studyInstanceUid: 'StudyInstanceUID', + patientId: 'PatientID', +}; + +let _store = { + urls: [], + // { + // url: url1 + // studies: [Study1, Study2], // if multiple studies + // } + // { + // url: url2 + // studies: [Study1], + // } + // } +}; + +const getMetaDataByURL = url => { + return _store.urls.find(metaData => metaData.url === url); +}; + +const findStudies = (key, value) => { + let studies = []; + _store.urls.map(metaData => { + metaData.studies.map(aStudy => { + if (aStudy[key] === value) { + studies.push(aStudy); + } + }); + }); + return studies; +}; + +function createDicomJSONApi(dicomJsonConfig) { + const { name } = dicomJsonConfig; + + const implementation = { + parseRouteParams: async ({ params, query, url }) => { + if (!url) url = query.get('url'); + let metaData = getMetaDataByURL(url); + + // if we have already cached the data from this specific url + // We are only handling one StudyInstanceUID to run; however, + // all studies for patientID will be put in the correct tab + if (metaData) { + return metaData.studies.map(aStudy => { + return aStudy.StudyInstanceUID; + }); + } + + const response = await fetch(url); + let data = await response.json(); + + const studyInstanceUIDs = data.studies.map( + study => study.StudyInstanceUID + ); + + let StudyInstanceUID; + let SeriesInstanceUID; + data.studies.forEach(study => { + StudyInstanceUID = study.StudyInstanceUID; + + study.series.forEach(series => { + SeriesInstanceUID = series.SeriesInstanceUID; + + series.instances.forEach(instance => { + const { url: imageId, metadata: naturalizedDicom } = instance; + + // Add imageId specific mapping to this data as the URL isn't necessarliy WADO-URI. + metadataProvider.addImageIdToUIDs(imageId, { + StudyInstanceUID, + SeriesInstanceUID, + SOPInstanceUID: naturalizedDicom.SOPInstanceUID, + }); + }); + }); + }); + + _store.urls.push({ + url, + studies: [...data.studies], + }); + + return studyInstanceUIDs; + }, + query: { + studies: { + mapParams: () => {}, + search: async param => { + const [key, value] = Object.entries(param)[0]; + const mappedParam = mappings[key]; + + const studies = findStudies(mappedParam, value); + + return studies.map(aStudy => { + return { + accession: aStudy.AccessionNumber, + date: aStudy.StudyDate, + description: aStudy.StudyDescription, + instances: aStudy.NumInstances, + modalities: aStudy.Modalities, + mrn: aStudy.PatientID, + patientName: aStudy.PatientName, + studyInstanceUid: aStudy.StudyInstanceUID, + NumInstances: aStudy.NumInstances, + time: aStudy.StudyTime, + }; + }); + }, + processResults: () => { + console.debug(' DICOMJson QUERY processResults'); + }, + }, + series: { + // mapParams: mapParams.bind(), + search: () => { + console.debug(' DICOMJson QUERY SERIES SEARCH'); + }, + }, + instances: { + search: () => { + console.debug(' DICOMJson QUERY instances SEARCH'); + }, + }, + }, + retrieve: { + series: { + metaData: () => { + console.debug(' DICOMJson retrieve series metadata'); + }, + }, + }, + store: { + dicom: () => { + console.debug(' DICOMJson store dicom'); + }, + }, + retrieveSeriesMetadata: ({ + StudyInstanceUID, + madeInClient = false, + customSort, + } = {}) => { + if (!StudyInstanceUID) { + throw new Error( + 'Unable to query for SeriesMetadata without StudyInstanceUID' + ); + } + + const study = findStudies('StudyInstanceUID', StudyInstanceUID)[0]; + let series; + + if (customSort) { + series = customSort(study.series); + } else { + series = study.series; + } + + const seriesSummaryMetadata = series.map(series => { + const seriesSummary = { + StudyInstanceUID: study.StudyInstanceUID, + ...series, + }; + delete seriesSummary.instances; + return seriesSummary; + }); + + // Async load series, store as retrieved + function storeInstances(naturalizedInstances) { + DicomMetadataStore.addInstances(naturalizedInstances, madeInClient); + } + + DicomMetadataStore.addSeriesMetadata(seriesSummaryMetadata, madeInClient); + + function setSuccessFlag() { + const study = DicomMetadataStore.getStudy( + StudyInstanceUID, + madeInClient + ); + study.isLoaded = true; + } + + const numberOfSeries = series.length; + series.forEach((series, index) => { + const instances = series.instances.map(instance => { + const obj = { + ...instance.metadata, + url: instance.url, + imageId: instance.url, + ...series, + }; + delete obj.instances; + return obj; + }); + storeInstances(instances); + if (index === numberOfSeries - 1) setSuccessFlag(); + }); + }, + getImageIdsForDisplaySet(displaySet) { + const images = displaySet.images; + const imageIds = []; + + if (!images) { + return imageIds; + } + + displaySet.images.forEach(instance => { + const NumberOfFrames = instance.NumberOfFrames; + + if (NumberOfFrames > 1) { + for (let i = 0; i < NumberOfFrames; i++) { + const imageId = getImageId({ + instance, + frame: i, + config: dicomJsonConfig, + }); + imageIds.push(imageId); + } + } else { + const imageId = getImageId({ instance, config: dicomJsonConfig }); + imageIds.push(imageId); + } + }); + + return imageIds; + }, + getImageIdsForInstance({ instance, frame }) { + const imageIds = getImageId({ + instance, + frame, + }); + return imageIds; + }, + }; + return IWebApiDataSource.create(implementation); +} + +export { createDicomJSONApi }; diff --git a/extensions/default/src/DicomWebDataSource/index.js b/extensions/default/src/DicomWebDataSource/index.js index c9c48a63b..30a8e3cab 100644 --- a/extensions/default/src/DicomWebDataSource/index.js +++ b/extensions/default/src/DicomWebDataSource/index.js @@ -63,10 +63,22 @@ function createDicomWebApi(dicomWebConfig) { const wadoDicomWebClient = new api.DICOMwebClient(wadoConfig); const implementation = { + parseRouteParams: ({ params, query }) => { + const { StudyInstanceUIDs: paramsStudyInstanceUIDs } = params; + const queryStudyInstanceUIDs = query.get('StudyInstanceUIDs'); + + const StudyInstanceUIDs = + queryStudyInstanceUIDs || paramsStudyInstanceUIDs; + const StudyInstanceUIDsAsArray = + StudyInstanceUIDs && Array.isArray(StudyInstanceUIDs) + ? StudyInstanceUIDs + : [StudyInstanceUIDs]; + return StudyInstanceUIDsAsArray; + }, query: { studies: { mapParams: mapParams.bind(), - search: async function (origParams) { + search: async function(origParams) { const { studyInstanceUid, seriesInstanceUid, ...mappedParams } = mapParams(origParams, { supportsFuzzyMatching, @@ -86,7 +98,7 @@ function createDicomWebApi(dicomWebConfig) { }, series: { // mapParams: mapParams.bind(), - search: async function (studyInstanceUid) { + search: async function(studyInstanceUid) { const results = await seriesInStudy( qidoDicomWebClient, studyInstanceUid @@ -129,7 +141,6 @@ function createDicomWebApi(dicomWebConfig) { DicomMetadataStore.addInstances(naturalizedInstances); callback(naturalizedInstances); }; - const studyPromises = StudyInstanceUIDs.map(StudyInstanceUID => retrieveStudyMetadata( wadoDicomWebClient, @@ -177,7 +188,14 @@ function createDicomWebApi(dicomWebConfig) { await wadoDicomWebClient.storeInstances(options); }, }, - retrieveSeriesMetadata: async ({ StudyInstanceUID } = {}) => { + // TODO: Rename this it makes no sense at all + retrieveSeriesMetadata: async ({ + StudyInstanceUID, + filters, + sortCriteria, + sortFunction, + madeInClient = false, + } = {}) => { if (!StudyInstanceUID) { throw new Error( 'Unable to query for SeriesMetadata without StudyInstanceUID' @@ -191,21 +209,34 @@ function createDicomWebApi(dicomWebConfig) { } = await retrieveStudyMetadata( wadoDicomWebClient, StudyInstanceUID, - enableStudyLazyLoad + enableStudyLazyLoad, + filters, + sortCriteria, + sortFunction ); // Async load series, store as retrieved function storeInstances(instances) { const naturalizedInstances = instances.map(naturalizeDataset); - DicomMetadataStore.addInstances(naturalizedInstances); + DicomMetadataStore.addInstances(naturalizedInstances, madeInClient); } - DicomMetadataStore.addSeriesMetadata(seriesSummaryMetadata); + function setSuccessFlag() { + const study = DicomMetadataStore.getStudy( + StudyInstanceUID, + madeInClient + ); + study.isLoaded = true; + } - seriesPromises.forEach(async seriesPromise => { + DicomMetadataStore.addSeriesMetadata(seriesSummaryMetadata, madeInClient); + + const numberOfSeries = seriesPromises.length; + seriesPromises.forEach(async (seriesPromise, index) => { const instances = await seriesPromise; storeInstances(instances); + if (index === numberOfSeries - 1) setSuccessFlag(); }); }, deleteStudyMetadataPromise, @@ -222,21 +253,28 @@ function createDicomWebApi(dicomWebConfig) { if (NumberOfFrames > 1) { for (let i = 0; i < NumberOfFrames; i++) { - const imageId = getImageId({ + const imageId = this.getImageIdsForInstance({ instance, frame: i, - config: dicomWebConfig, }); imageIds.push(imageId); } } else { - const imageId = getImageId({ instance, config: dicomWebConfig }); + const imageId = this.getImageIdsForInstance({ instance }); imageIds.push(imageId); } }); return imageIds; }, + getImageIdsForInstance({ instance, frame }) { + const imageIds = getImageId({ + instance, + frame, + config: dicomWebConfig, + }); + return imageIds; + }, }; if (supportsReject) { diff --git a/extensions/default/src/DicomWebDataSource/retrieveStudyMetadata.js b/extensions/default/src/DicomWebDataSource/retrieveStudyMetadata.js index eb612cc2e..faf5e2c90 100644 --- a/extensions/default/src/DicomWebDataSource/retrieveStudyMetadata.js +++ b/extensions/default/src/DicomWebDataSource/retrieveStudyMetadata.js @@ -19,7 +19,9 @@ export function retrieveStudyMetadata( dicomWebClient, StudyInstanceUID, enableStudyLazyLoad, - filters + filters, + sortCriteria, + sortFunction ) { // @TODO: Whenever a study metadata request has failed, its related promise will be rejected once and for all // and further requests for that metadata will always fail. On failure, we probably need to remove the @@ -47,7 +49,9 @@ export function retrieveStudyMetadata( dicomWebClient, StudyInstanceUID, enableStudyLazyLoad, - filters + filters, + sortCriteria, + sortFunction ).then(function(data) { resolve(data); }, reject); diff --git a/extensions/default/src/DicomWebDataSource/utils/getImageId.js b/extensions/default/src/DicomWebDataSource/utils/getImageId.js index 0e7325eb0..a493a3d3a 100644 --- a/extensions/default/src/DicomWebDataSource/utils/getImageId.js +++ b/extensions/default/src/DicomWebDataSource/utils/getImageId.js @@ -34,6 +34,10 @@ export default function getImageId({ return; } + if (instance.url) { + return instance.url; + } + const renderingAttr = thumbnail ? 'thumbnailRendering' : 'imageRendering'; if (!config[renderingAttr] || config[renderingAttr] === 'wadouri') { diff --git a/extensions/default/src/DicomWebDataSource/utils/sortStudy.js b/extensions/default/src/DicomWebDataSource/utils/sortStudy.js index e2af19bf8..cac0334aa 100644 --- a/extensions/default/src/DicomWebDataSource/utils/sortStudy.js +++ b/extensions/default/src/DicomWebDataSource/utils/sortStudy.js @@ -43,9 +43,11 @@ const sortingCriteria = { */ const sortStudySeries = ( series, - seriesSortingCriteria = seriesSortCriteria.default + seriesSortingCriteria = seriesSortCriteria.default, + sortFunction ) => { - return series.sort(seriesSortingCriteria); + if (typeof sortFunction === 'function') return sortFunction(series); + else return series.sort(seriesSortingCriteria); }; /** diff --git a/extensions/default/src/DicomWebDataSource/wado/retrieveMetadata.js b/extensions/default/src/DicomWebDataSource/wado/retrieveMetadata.js index 3b9ba2082..c6fcf6b92 100644 --- a/extensions/default/src/DicomWebDataSource/wado/retrieveMetadata.js +++ b/extensions/default/src/DicomWebDataSource/wado/retrieveMetadata.js @@ -15,7 +15,9 @@ async function RetrieveMetadata( dicomWebClient, studyInstanceUid, enableStudyLazyLoad, - filters = {} + filters = {}, + sortCriteria, + sortFunction ) { // const RetrieveMetadataLoader = // enableStudyLazyLoad !== false @@ -27,7 +29,9 @@ async function RetrieveMetadata( const retrieveMetadataLoader = new RetrieveMetadataLoader( dicomWebClient, studyInstanceUid, - filters + filters, + sortCriteria, + sortFunction ); const { preLoadData, promises } = await retrieveMetadataLoader.execLoad(); diff --git a/extensions/default/src/DicomWebDataSource/wado/retrieveMetadataLoader.js b/extensions/default/src/DicomWebDataSource/wado/retrieveMetadataLoader.js index 664e59808..49302f7a8 100644 --- a/extensions/default/src/DicomWebDataSource/wado/retrieveMetadataLoader.js +++ b/extensions/default/src/DicomWebDataSource/wado/retrieveMetadataLoader.js @@ -12,11 +12,20 @@ export default class RetrieveMetadataLoader { * @param {Array} studyInstanceUID Study instance ui to be retrieved * @param {Object} [filters] - Object containing filters to be applied on retrieve metadata process * @param {string} [filter.seriesInstanceUID] - series instance uid to filter results against + * @param {Function} [sortSeries] - Custom sort function for series */ - constructor(client, studyInstanceUID, filters = {}) { + constructor( + client, + studyInstanceUID, + filters = {}, + sortCriteria, + sortFunction + ) { this.client = client; this.studyInstanceUID = studyInstanceUID; this.filters = filters; + this.sortCriteria = sortCriteria; + this.sortFunction = sortFunction; } async execLoad() { diff --git a/extensions/default/src/DicomWebDataSource/wado/retrieveMetadataLoaderAsync.js b/extensions/default/src/DicomWebDataSource/wado/retrieveMetadataLoaderAsync.js index 0020f4bf6..86c195677 100644 --- a/extensions/default/src/DicomWebDataSource/wado/retrieveMetadataLoaderAsync.js +++ b/extensions/default/src/DicomWebDataSource/wado/retrieveMetadataLoaderAsync.js @@ -2,6 +2,7 @@ import dcmjs from 'dcmjs'; import { sortStudySeries, sortingCriteria } from '../utils/sortStudy'; import RetrieveMetadataLoader from './retrieveMetadataLoader'; + /** * Creates an immutable series loader object which loads each series sequentially using the iterator interface * @param {DICOMWebClient} dicomWebClient The DICOMWebClient instance to be used for series load @@ -62,13 +63,17 @@ export default class RetrieveMetadataLoaderAsync extends RetrieveMetadataLoader async preLoad() { const preLoaders = this.getPreLoaders(); const result = await this.runLoaders(preLoaders); + const sortCriteria = this.sortCriteria; + const sortFunction = this.sortFunction; const { naturalizeDataset } = dcmjs.data.DicomMetaDictionary; const naturalized = result.map(naturalizeDataset); return sortStudySeries( naturalized, - sortingCriteria.seriesSortCriteria.seriesInfoSortingCriteria + sortCriteria || + sortingCriteria.seriesSortCriteria.seriesInfoSortingCriteria, + sortFunction ); } diff --git a/extensions/default/src/PanelMeasurementTable.js b/extensions/default/src/PanelMeasurementTable.js index fb66d17fd..62f594298 100644 --- a/extensions/default/src/PanelMeasurementTable.js +++ b/extensions/default/src/PanelMeasurementTable.js @@ -21,12 +21,13 @@ export default function PanelMeasurementTable({ // ~~ Subscription const added = MeasurementService.EVENTS.MEASUREMENT_ADDED; + const addedRaw = MeasurementService.EVENTS.RAW_MEASUREMENT_ADDED; const updated = MeasurementService.EVENTS.MEASUREMENT_UPDATED; const removed = MeasurementService.EVENTS.MEASUREMENT_REMOVED; const cleared = MeasurementService.EVENTS.MEASUREMENTS_CLEARED; const subscriptions = []; - [added, updated, removed, cleared].forEach(evt => { + [added, addedRaw, updated, removed, cleared].forEach(evt => { subscriptions.push( MeasurementService.subscribe(evt, () => { debouncedSetDisplayMeasurements( diff --git a/extensions/default/src/Panels/PanelStudyBrowser.jsx b/extensions/default/src/Panels/PanelStudyBrowser.jsx index a37260cec..c5576458f 100644 --- a/extensions/default/src/Panels/PanelStudyBrowser.jsx +++ b/extensions/default/src/Panels/PanelStudyBrowser.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import PropTypes from 'prop-types'; import { StudyBrowser, useImageViewer } from '@ohif/ui'; import { utils } from '@ohif/core'; @@ -19,7 +19,7 @@ function PanelStudyBrowser({ // Normally you nest the components so the tree isn't so deep, and the data // doesn't have to have such an intense shape. This works well enough for now. // Tabs --> Studies --> DisplaySets --> Thumbnails - const [{ StudyInstanceUIDs }, dispatch] = useImageViewer(); + const { StudyInstanceUIDs } = useImageViewer(); const [activeTabName, setActiveTabName] = useState('primary'); const [expandedStudyInstanceUIDs, setExpandedStudyInstanceUIDs] = useState([ ...StudyInstanceUIDs, @@ -27,6 +27,7 @@ function PanelStudyBrowser({ const [studyDisplayList, setStudyDisplayList] = useState([]); const [displaySets, setDisplaySets] = useState([]); const [thumbnailImageSrcMap, setThumbnailImageSrcMap] = useState({}); + const isMounted = useRef(true); // ~~ studyDisplayList useEffect(() => { @@ -47,8 +48,9 @@ function PanelStudyBrowser({ // displaySets: [] }; }); - - setStudyDisplayList(actuallyMappedStudies); + if (isMounted.current) { + setStudyDisplayList(actuallyMappedStudies); + } } StudyInstanceUIDs.forEach(sid => fetchStudiesForPatient(sid)); @@ -72,11 +74,16 @@ function PanelStudyBrowser({ newImageSrcEntry[dSet.displaySetInstanceUID] = await getImageSrc( imageId ); - setThumbnailImageSrcMap(prevState => { - return { ...prevState, ...newImageSrcEntry }; - }); + if (isMounted.current) { + setThumbnailImageSrcMap(prevState => { + return { ...prevState, ...newImageSrcEntry }; + }); + } } }); + return () => { + isMounted.current = false; + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -112,11 +119,14 @@ function PanelStudyBrowser({ if (imageId) { // When the image arrives, render it and store the result in the thumbnailImgSrcMap newImageSrcEntry[dSet.displaySetInstanceUID] = await getImageSrc( - imageId + imageId, + dSet.initialViewport ); - setThumbnailImageSrcMap(prevState => { - return { ...prevState, ...newImageSrcEntry }; - }); + if (isMounted.current) { + setThumbnailImageSrcMap(prevState => { + return { ...prevState, ...newImageSrcEntry }; + }); + } } }); } diff --git a/extensions/default/src/Panels/getStudiesForPatientByStudyInstanceUID.js b/extensions/default/src/Panels/getStudiesForPatientByStudyInstanceUID.js index 9327383ee..0c2fdfdd2 100644 --- a/extensions/default/src/Panels/getStudiesForPatientByStudyInstanceUID.js +++ b/extensions/default/src/Panels/getStudiesForPatientByStudyInstanceUID.js @@ -2,6 +2,7 @@ async function getStudiesForPatientByStudyInstanceUID( dataSource, StudyInstanceUID ) { + if (StudyInstanceUID === undefined) return; // TODO: The `DicomMetadataStore` should short-circuit both of these requests // Data _could_ be here from route query, or if using JSON data source // We could also force this to "await" these values being available in the DICOMStore? diff --git a/extensions/default/src/ViewerLayout/index.jsx b/extensions/default/src/ViewerLayout/index.jsx index 2884e0e9d..8069a7ba9 100644 --- a/extensions/default/src/ViewerLayout/index.jsx +++ b/extensions/default/src/ViewerLayout/index.jsx @@ -10,6 +10,8 @@ import { useModal, } from '@ohif/ui'; +import { useAppConfig } from '@state'; + function Toolbar({ servicesManager }) { const { ToolBarService } = servicesManager.services; const [toolbarButtons, setToolbarButtons] = useState([]); @@ -45,7 +47,10 @@ function Toolbar({ servicesManager }) { // isActive if: // - id is primary? // - id is in list of "toggled on"? - + let isActive; + if (componentProps.type === 'toggle') { + isActive = buttonState.toggles[id]; + } // Also need... to filter list for splitButton, and set primary based on most recently clicked // Also need to kill the radioGroup button's magic logic // Everything should be reactive off these props, so commands can inform ToolbarService @@ -58,6 +63,7 @@ function Toolbar({ servicesManager }) { id={id} {...componentProps} bState={buttonState} + isActive={isActive} onInteraction={args => ToolBarService.recordInteraction(args)} /> ); @@ -78,6 +84,8 @@ function ViewerLayout({ viewports, ViewportGridComp, }) { + const [appConfig] = useAppConfig(); + const { t } = useTranslation(); const { show, hide } = useModal(); @@ -154,7 +162,7 @@ function ViewerLayout({ return (
-
+
diff --git a/extensions/default/src/commandsModule.js b/extensions/default/src/commandsModule.js index f1eb40d92..9556cc896 100644 --- a/extensions/default/src/commandsModule.js +++ b/extensions/default/src/commandsModule.js @@ -1,5 +1,11 @@ const commandsModule = ({ servicesManager, commandsManager }) => { - const { MeasurementService, ViewportGridService, ToolBarService, CineService } = servicesManager.services; + const { + MeasurementService, + ViewportGridService, + ToolBarService, + HangingProtocolService, + CineService, + } = servicesManager.services; const actions = { clearMeasurements: () => { @@ -10,7 +16,16 @@ const commandsModule = ({ servicesManager, commandsManager }) => { const { isCineEnabled } = CineService.getState(); CineService.setIsCineEnabled(!isCineEnabled); ToolBarService.setButton('Cine', { props: { isActive: !isCineEnabled } }); - viewports.forEach((_, index) => CineService.setCine({ id: index, isPlaying: false })); + viewports.forEach((_, index) => + CineService.setCine({ id: index, isPlaying: false }) + ); + }, + nextStage: () => { + // next stage in hanging protocols + HangingProtocolService.nextProtocolStage(); + }, + previousStage: () => { + HangingProtocolService.previousProtocolStage(); }, }; @@ -25,6 +40,16 @@ const commandsModule = ({ servicesManager, commandsManager }) => { storeContexts: [], options: {}, }, + nextStage: { + commandFn: actions.nextStage, + storeContexts: [], + options: {}, + }, + previousStage: { + commandFn: actions.previousStage, + storeContexts: [], + options: {}, + }, }; return { diff --git a/extensions/default/src/getDataSourcesModule.js b/extensions/default/src/getDataSourcesModule.js index b74d2baf0..d1d4601a5 100644 --- a/extensions/default/src/getDataSourcesModule.js +++ b/extensions/default/src/getDataSourcesModule.js @@ -3,6 +3,7 @@ // TODO: Use existing DICOMWeb configuration (previously, appConfig, to configure instance) import { createDicomWebApi } from './DicomWebDataSource/index.js'; +import { createDicomJSONApi } from './DicomJSONDataSource/index.js'; /** * @@ -14,6 +15,11 @@ function getDataSourcesModule() { type: 'webApi', createDataSource: createDicomWebApi, }, + { + name: 'dicomjson', + type: 'jsonApi', + createDataSource: createDicomJSONApi, + }, ]; } diff --git a/extensions/default/src/getHangingProtocolModule.js b/extensions/default/src/getHangingProtocolModule.js new file mode 100644 index 000000000..0d4e9b297 --- /dev/null +++ b/extensions/default/src/getHangingProtocolModule.js @@ -0,0 +1,188 @@ +const hangingProtocolName = 'petCT'; + +const petCTProtocol = { + id: 'PET/CT', + locked: true, + hasUpdatedPriorsInformation: false, + name: 'PET/CT', + createdDate: '2021-02-23T18:32:42.849Z', + modifiedDate: '2021-02-23T18:32:42.849Z', + availableTo: {}, + editableBy: {}, + protocolMatchingRules: [ + { + id: 'wauZK2QNEfDPwcAQo', + weight: 1, + attribute: 'StudyInstanceUID', + constraint: { + equals: { + value: '1.3.6.1.4.1.25403.345050719074.3824.20170125112931.11', + }, + }, + required: true, + }, + ], + stages: [ + { + id: 'hYbmMy3b7pz7GLiaT', + name: 'oneByTwo', + viewportStructure: { + type: 'grid', + properties: { + rows: 2, + columns: 2, + }, + }, + viewports: [ + { + viewportSettings: [ + { + options: { + voi: { + windowWidth: 500, + windowCenter: 500, + }, + }, + commandName: '', + // Type can be viewport or prop + // viewport: It is most suited for settings that + // should be applied before the first render + // prop: It is the type of command that can be applied + // after the image render, such as tool activations. + type: 'viewport', + }, + ], + imageMatchingRules: [], + seriesMatchingRules: [ + { + id: 'vSjk7NCYjtdS3XZAw', + weight: 1, + attribute: 'SeriesNumber', + constraint: { + equals: { + value: "4", + }, + }, + required: false, + }, + ], + studyMatchingRules: [], + }, + { + viewportSettings: [ + { + options: { + invert: true, + }, + commandName: '', + type: 'viewport', + }, + ], + imageMatchingRules: [ + + ], + seriesMatchingRules: [ + { + id: 'GPEYqFLv2dwzCM322', + weight: 1, + attribute: 'SeriesNumber', + constraint: { + equals: { + value: "1", + }, + }, + required: false, + }, + ], + studyMatchingRules: [ + + ], + }, + { + viewportSettings: [ + { + options: { + invert: true, + }, + commandName: '', + type: 'viewport', + }, + ], + imageMatchingRules: [ + + ], + seriesMatchingRules: [ + { + id: 'GPEYqFLv2dwzCM322', + weight: 1, + attribute: 'SeriesNumber', + constraint: { + equals: { + value: "6", + }, + }, + required: false, + }, + ], + studyMatchingRules: [ + + ], + }, + { + viewportSettings: [ + { + options: { + invert: true, + }, + commandName: '', + type: 'viewport', + }, + ], + imageMatchingRules: [ + + ], + seriesMatchingRules: [ + { + id: 'GPEYqFLv2dwzCM322', + weight: 1, + attribute: 'SeriesDescription', + constraint: { + contains: { + value: "Corrected", + }, + }, + required: false, + }, + { + id: 'GPEYqFLv2dwzCM322', + weight: 2, + attribute: 'SeriesDescription', + constraint: { + doesNotContain: { + value: "Uncorrected", + }, + }, + required: false, + }, + ], + studyMatchingRules: [ + + ], + }, + ], + createdDate: '2021-02-23T18:32:42.850Z', + }, + ], + numberOfPriorsReferenced: 0, +}; + +function getHangingProtocolModule() { + return [ + { + name: hangingProtocolName, + protocols: [petCTProtocol], + }, + ]; +} + +export default getHangingProtocolModule; diff --git a/extensions/default/src/index.js b/extensions/default/src/index.js index dcaf70eef..cc4b39ae3 100644 --- a/extensions/default/src/index.js +++ b/extensions/default/src/index.js @@ -3,6 +3,7 @@ import getDataSourcesModule from './getDataSourcesModule.js'; import getLayoutTemplateModule from './getLayoutTemplateModule.js'; import getPanelModule from './getPanelModule.js'; import getSopClassHandlerModule from './getSopClassHandlerModule.js'; +import getHangingProtocolModule from './getHangingProtocolModule.js'; import getToolbarModule from './getToolbarModule.js'; import commandsModule from './commandsModule'; import id from './id'; @@ -14,6 +15,7 @@ export default { id, getContextModule, getDataSourcesModule, + getHangingProtocolModule, getLayoutTemplateModule, getPanelModule, getSopClassHandlerModule, diff --git a/extensions/dicom-sr/package.json b/extensions/dicom-sr/package.json index ba35bbcb7..626dac009 100644 --- a/extensions/dicom-sr/package.json +++ b/extensions/dicom-sr/package.json @@ -32,7 +32,7 @@ "@ohif/core": "^0.50.0", "@ohif/ui": "^0.50.0", "cornerstone-core": "^2.3.0", - "cornerstone-math": "^0.1.8", + "cornerstone-math": "^0.1.9", "cornerstone-tools": "5.1.2", "cornerstone-wado-image-loader": "^3.1.2", "dcmjs": "0.16.1", @@ -40,7 +40,7 @@ "hammerjs": "^2.0.8", "prop-types": "^15.6.2", "react": "^16.11.0", - "react-cornerstone-viewport": "4.0.2" + "react-cornerstone-viewport": "4.0.5" }, "dependencies": { "@babel/runtime": "7.7.6", diff --git a/extensions/measurement-tracking/package.json b/extensions/measurement-tracking/package.json index 45f62bd2b..e94a365fc 100644 --- a/extensions/measurement-tracking/package.json +++ b/extensions/measurement-tracking/package.json @@ -34,7 +34,7 @@ "dcmjs": "0.16.1", "prop-types": "^15.6.2", "react": "^16.13.1", - "react-cornerstone-viewport": "^4.0.4", + "react-cornerstone-viewport": "^4.0.5", "react-dom": "^16.13.1", "webpack": "^4.0.0", "webpack-merge": "^5.7.3" diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/TrackedMeasurementsContext.jsx b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/TrackedMeasurementsContext.jsx index f8293766f..1cce041e0 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/TrackedMeasurementsContext.jsx +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/TrackedMeasurementsContext.jsx @@ -12,7 +12,6 @@ import promptTrackNewSeries from './promptTrackNewSeries'; import promptTrackNewStudy from './promptTrackNewStudy'; import promptSaveReport from './promptSaveReport'; import promptHydrateStructuredReport from './promptHydrateStructuredReport'; -import hydrateStructuredReport from './_hydrateStructuredReport.js'; const TrackedMeasurementsContext = React.createContext(); TrackedMeasurementsContext.displayName = 'TrackedMeasurementsContext'; @@ -99,6 +98,7 @@ function TrackedMeasurementsContextProvider( }), promptHydrateStructuredReport: promptHydrateStructuredReport.bind(null, { servicesManager, + extensionManager }), }); diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/_hydrateStructuredReport.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/_hydrateStructuredReport.js index 9d308b0c0..2baceb80a 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/_hydrateStructuredReport.js +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/_hydrateStructuredReport.js @@ -1,7 +1,7 @@ import cornerstoneTools from 'cornerstone-tools'; import OHIF, { DicomMetadataStore } from '@ohif/core'; import getLabelFromDCMJSImportedToolData from './utils/getLabelFromDCMJSImportedToolData'; -import getToolStateToCornerstoneMeasurementSchema from './getToolStateToCornerstoneMeasurementSchema'; +import getCornerstoneToolStateToMeasurementSchema from './getCornerstoneToolStateToMeasurementSchema'; import { adapters } from 'dcmjs'; const { guid } = OHIF.utils; @@ -15,6 +15,7 @@ export default function _hydrateStructuredReport( { servicesManager, extensionManager }, displaySetInstanceUID ) { + const dataSource = extensionManager.getActiveDataSource()[0]; const { MeasurementService, DisplaySetService } = servicesManager.services; const displaySet = DisplaySetService.getDisplaySetByUID( @@ -114,14 +115,23 @@ export default function _hydrateStructuredReport( data.id = guid(); - _addToolDataToCornerstoneTools(data, toolType, imageId); + const instance = cornerstone.metaData.get('instance', imageId); + const { + SOPInstanceUID, + FrameOfReferenceUID, + SeriesInstanceUID, + StudyInstanceUID, + } = instance; // Let the measurement service know we added to toolState - const toMeasurementSchema = getToolStateToCornerstoneMeasurementSchema( + const toMeasurementSchema = getCornerstoneToolStateToMeasurementSchema( toolType, MeasurementService, DisplaySetService, - imageId + SOPInstanceUID, + FrameOfReferenceUID, + SeriesInstanceUID, + StudyInstanceUID ); const source = MeasurementService.getSource('CornerstoneTools', '4'); @@ -132,7 +142,8 @@ export default function _hydrateStructuredReport( source, toolType, data, - toMeasurementSchema + toMeasurementSchema, + dataSource ); if (!imageIds.includes(imageId)) { @@ -148,24 +159,3 @@ export default function _hydrateStructuredReport( SeriesInstanceUIDs, }; } - -function _addToolDataToCornerstoneTools(data, toolType, imageId) { - const toolState = globalImageIdSpecificToolStateManager.saveToolState(); - - if (toolState[imageId] === undefined) { - toolState[imageId] = {}; - } - - const imageIdToolState = toolState[imageId]; - - // If we don't have tool state for this type of tool, add an empty object - if (imageIdToolState[toolType] === undefined) { - imageIdToolState[toolType] = { - data: [], - }; - } - - const toolData = imageIdToolState[toolType]; - - toolData.data.push(data); -} diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/getToolStateToCornerstoneMeasurementSchema.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/getCornerstoneToolStateToMeasurementSchema.js similarity index 87% rename from extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/getToolStateToCornerstoneMeasurementSchema.js rename to extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/getCornerstoneToolStateToMeasurementSchema.js index da8f12030..c548a7929 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/getToolStateToCornerstoneMeasurementSchema.js +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/getCornerstoneToolStateToMeasurementSchema.js @@ -1,8 +1,11 @@ -export default function getToolStateToCornerstoneMeasurementSchema( +export default function getCornerstoneToolStateToMeasurementSchema( toolType, MeasurementService, DisplaySetService, - imageId + SOPInstanceUID, + FrameOfReferenceUID, + SeriesInstanceUID, + StudyInstanceUID ) { const _getValueTypeFromToolType = toolType => { const { @@ -31,7 +34,10 @@ export default function getToolStateToCornerstoneMeasurementSchema( return measurementData => Length( measurementData, - imageId, + SOPInstanceUID, + FrameOfReferenceUID, + SeriesInstanceUID, + StudyInstanceUID, DisplaySetService, _getValueTypeFromToolType ); @@ -39,7 +45,10 @@ export default function getToolStateToCornerstoneMeasurementSchema( return measurementData => Bidirectional( measurementData, - imageId, + SOPInstanceUID, + FrameOfReferenceUID, + SeriesInstanceUID, + StudyInstanceUID, DisplaySetService, _getValueTypeFromToolType ); @@ -47,7 +56,10 @@ export default function getToolStateToCornerstoneMeasurementSchema( return measurementData => EllipticalRoi( measurementData, - imageId, + SOPInstanceUID, + FrameOfReferenceUID, + SeriesInstanceUID, + StudyInstanceUID, DisplaySetService, _getValueTypeFromToolType ); @@ -55,7 +67,10 @@ export default function getToolStateToCornerstoneMeasurementSchema( return measurementData => ArrowAnnotate( measurementData, - imageId, + SOPInstanceUID, + FrameOfReferenceUID, + SeriesInstanceUID, + StudyInstanceUID, DisplaySetService, _getValueTypeFromToolType ); @@ -64,18 +79,14 @@ export default function getToolStateToCornerstoneMeasurementSchema( function Length( measurementData, - imageId, + SOPInstanceUID, + FrameOfReferenceUID, + SeriesInstanceUID, + StudyInstanceUID, DisplaySetService, _getValueTypeFromToolType ) { const tool = measurementData.toolType || measurementData.toolName; - const instance = cornerstone.metaData.get('instance', imageId); - const { - SOPInstanceUID, - FrameOfReferenceUID, - SeriesInstanceUID, - StudyInstanceUID, - } = instance; const displaySetInstanceUID = _getDisplaySetInstanceUID( DisplaySetService, @@ -113,18 +124,15 @@ function Length( function Bidirectional( measurementData, - imageId, + SOPInstanceUID, + FrameOfReferenceUID, + SeriesInstanceUID, + StudyInstanceUID, DisplaySetService, _getValueTypeFromToolType ) { const tool = measurementData.toolType || measurementData.toolName; - const instance = cornerstone.metaData.get('instance', imageId); - const { - SOPInstanceUID, - FrameOfReferenceUID, - SeriesInstanceUID, - StudyInstanceUID, - } = instance; + const displaySetInstanceUID = _getDisplaySetInstanceUID( DisplaySetService, SeriesInstanceUID, @@ -155,18 +163,14 @@ function Bidirectional( function EllipticalRoi( measurementData, - imageId, + SOPInstanceUID, + FrameOfReferenceUID, + SeriesInstanceUID, + StudyInstanceUID, DisplaySetService, _getValueTypeFromToolType ) { const tool = measurementData.toolType || measurementData.toolName; - const instance = cornerstone.metaData.get('instance', imageId); - const { - SOPInstanceUID, - FrameOfReferenceUID, - SeriesInstanceUID, - StudyInstanceUID, - } = instance; const displaySetInstanceUID = _getDisplaySetInstanceUID( DisplaySetService, @@ -222,18 +226,14 @@ function EllipticalRoi( function ArrowAnnotate( measurementData, - imageId, + SOPInstanceUID, + FrameOfReferenceUID, + SeriesInstanceUID, + StudyInstanceUID, DisplaySetService, _getValueTypeFromToolType ) { const tool = measurementData.toolType || measurementData.toolName; - const instance = cornerstone.metaData.get('instance', imageId); - const { - SOPInstanceUID, - FrameOfReferenceUID, - SeriesInstanceUID, - StudyInstanceUID, - } = instance; const displaySetInstanceUID = _getDisplaySetInstanceUID( DisplaySetService, diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptBeginTracking.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptBeginTracking.js index ce4b17ef2..c44cae8ff 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptBeginTracking.js +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptBeginTracking.js @@ -6,7 +6,7 @@ const RESPONSE = { SET_STUDY_AND_SERIES: 3, }; -function promptUser({ servicesManager }, ctx, evt) { +function promptUser({ servicesManager, extensionManager }, ctx, evt) { const { UIViewportDialogService } = servicesManager.services; const { viewportIndex, StudyInstanceUID, SeriesInstanceUID } = evt; diff --git a/extensions/measurement-tracking/src/getViewportModule.js b/extensions/measurement-tracking/src/getViewportModule.js index e01118421..c9974fb9a 100644 --- a/extensions/measurement-tracking/src/getViewportModule.js +++ b/extensions/measurement-tracking/src/getViewportModule.js @@ -12,12 +12,13 @@ const OHIFCornerstoneViewport = props => { ); }; -function getViewportModule({ servicesManager, commandsManager }) { +function getViewportModule({ servicesManager, commandsManager, extensionManager }) { const ExtendedOHIFCornerstoneTrackingViewport = props => { return ( ); diff --git a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.js b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.js index 333f36b6a..924afbcb9 100644 --- a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.js +++ b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.js @@ -114,15 +114,16 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) { // Are we exposing the right API for measurementService? // This watches for ALL MeasurementService changes. It updates a timestamp, // which is debounced. After a brief period of inactivity, this triggers - // a re-render where we grab up-to-date measurements. + // a re-render where we grab up-to-date measurements useEffect(() => { const added = MeasurementService.EVENTS.MEASUREMENT_ADDED; + const addedRaw = MeasurementService.EVENTS.RAW_MEASUREMENT_ADDED; const updated = MeasurementService.EVENTS.MEASUREMENT_UPDATED; const removed = MeasurementService.EVENTS.MEASUREMENT_REMOVED; const cleared = MeasurementService.EVENTS.MEASUREMENTS_CLEARED; const subscriptions = []; - [added, updated, removed, cleared].forEach(evt => { + [added, addedRaw, updated, removed, cleared].forEach(evt => { subscriptions.push( MeasurementService.subscribe(evt, () => { setMeasurementsUpdated(Date.now().toString()); diff --git a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.jsx b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.jsx index fc4a21944..8b064d303 100644 --- a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.jsx +++ b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.jsx @@ -28,7 +28,7 @@ function PanelStudyBrowserTracking({ // Normally you nest the components so the tree isn't so deep, and the data // doesn't have to have such an intense shape. This works well enough for now. // Tabs --> Studies --> DisplaySets --> Thumbnails - const [{ StudyInstanceUIDs }, dispatchImageViewer] = useImageViewer(); + const { StudyInstanceUIDs } = useImageViewer(); const [ { activeViewportIndex, viewports, numCols, numRows }, viewportGridService, @@ -58,27 +58,34 @@ function PanelStudyBrowserTracking({ const isSingleViewport = numCols === 1 && numRows === 1; - // TODO: Should this be somewhere else? Feels more like a mode "lifecycle" setup/destroy? useEffect(() => { - const { unsubscribe } = MeasurementService.subscribe( - MeasurementService.EVENTS.MEASUREMENT_ADDED, - ({ source, measurement }) => { - const { - referenceSeriesUID: SeriesInstanceUID, - referenceStudyUID: StudyInstanceUID, - } = measurement; + const added = MeasurementService.EVENTS.MEASUREMENT_ADDED; + const addedRaw = MeasurementService.EVENTS.RAW_MEASUREMENT_ADDED; + const subscriptions = []; - sendTrackedMeasurementsEvent('SET_DIRTY', { SeriesInstanceUID }); - sendTrackedMeasurementsEvent('TRACK_SERIES', { - viewportIndex: activeViewportIndex, - StudyInstanceUID, - SeriesInstanceUID, - }); - } - ); + [added, addedRaw].forEach(evt => { + subscriptions.push( + MeasurementService.subscribe(evt, ({ source, measurement }) => { + const { + referenceSeriesUID: SeriesInstanceUID, + referenceStudyUID: StudyInstanceUID, + } = measurement; - return unsubscribe; - // eslint-disable-next-line react-hooks/exhaustive-deps + sendTrackedMeasurementsEvent('SET_DIRTY', { SeriesInstanceUID }); + sendTrackedMeasurementsEvent('TRACK_SERIES', { + viewportIndex: activeViewportIndex, + StudyInstanceUID, + SeriesInstanceUID, + }); + }).unsubscribe + ); + }); + + return () => { + subscriptions.forEach(unsub => { + unsub(); + }); + }; }, [MeasurementService, activeViewportIndex, sendTrackedMeasurementsEvent]); const { trackedStudy, trackedSeries } = trackedMeasurements.context; @@ -89,7 +96,6 @@ function PanelStudyBrowserTracking({ async function fetchStudiesForPatient(StudyInstanceUID) { const qidoStudiesForPatient = (await getStudiesForPatientByStudyInstanceUID(StudyInstanceUID)) || []; - // TODO: This should be "naturalized DICOM JSON" studies const mappedStudies = _mapDataSourceStudies(qidoStudiesForPatient); const actuallyMappedStudies = mappedStudies.map(qidoStudy => { @@ -182,6 +188,7 @@ function PanelStudyBrowserTracking({ const imageIds = dataSource.getImageIdsForDisplaySet(displaySet); const imageId = imageIds[Math.floor(imageIds.length / 2)]; + // TODO: Is it okay that imageIds are not returned here for SR displaysets? if (imageId) { // When the image arrives, render it and store the result in the thumbnailImgSrcMap diff --git a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/getStudiesForPatientByStudyInstanceUID.js b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/getStudiesForPatientByStudyInstanceUID.js index 9327383ee..0c2fdfdd2 100644 --- a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/getStudiesForPatientByStudyInstanceUID.js +++ b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/getStudiesForPatientByStudyInstanceUID.js @@ -2,6 +2,7 @@ async function getStudiesForPatientByStudyInstanceUID( dataSource, StudyInstanceUID ) { + if (StudyInstanceUID === undefined) return; // TODO: The `DicomMetadataStore` should short-circuit both of these requests // Data _could_ be here from route query, or if using JSON data source // We could also force this to "await" these values being available in the DICOMStore? diff --git a/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.js b/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.js index 2fd411e88..1476a3298 100644 --- a/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.js +++ b/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.js @@ -1,8 +1,7 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import PropTypes from 'prop-types'; import cornerstone from 'cornerstone-core'; import cornerstoneTools from 'cornerstone-tools'; -import CornerstoneViewport from 'react-cornerstone-viewport'; import OHIF, { utils } from '@ohif/core'; import { Notification, @@ -12,14 +11,10 @@ import { useViewportDialog, } from '@ohif/ui'; import { useTrackedMeasurements } from './../getContextModule'; - -import ViewportOverlay from './ViewportOverlay'; -import ViewportLoadingIndicator from './ViewportLoadingIndicator'; import setCornerstoneMeasurementActive from '../_shared/setCornerstoneMeasurementActive'; import setActiveAndPassiveToolsForElement from '../_shared/setActiveAndPassiveToolsForElement'; import getTools from '../_shared/getTools'; -const scrollToIndex = cornerstoneTools.importInternal('util/scrollToIndex'); const { formatDate } = utils; // TODO -> Get this list from the list of tracked measurements. @@ -40,19 +35,19 @@ const BaseAnnotationTool = cornerstoneTools.importInternal( const { StackManager } = OHIF.utils; -function TrackedCornerstoneViewport({ - children, - dataSource, - displaySet, - viewportIndex, - servicesManager, - commandsManager, -}) { +function TrackedCornerstoneViewport(props) { const { - ToolBarService, - DisplaySetService, - MeasurementService, - } = servicesManager.services; + children, + dataSource, + displaySet, + viewportIndex, + servicesManager, + extensionManager, + commandsManager, + } = props; + + const { ToolBarService } = servicesManager.services; + const [trackedMeasurements] = useTrackedMeasurements(); const [ { activeViewportIndex, viewports }, @@ -60,73 +55,9 @@ function TrackedCornerstoneViewport({ ] = useViewportGrid(); const [{ isCineEnabled, cines }, cineService] = useCine(); const [viewportDialogState, viewportDialogApi] = useViewportDialog(); - const [viewportData, setViewportData] = useState(null); - const [element, setElement] = useState(null); const [isTracked, setIsTracked] = useState(false); const [trackedMeasurementId, setTrackedMeasurementId] = useState(null); - - // TODO: Still needed? Better way than import `OHIF` and destructure? - // Why is this managed by `core`? - useEffect(() => { - return () => { - StackManager.clearStacks(); - }; - }, []); - - useEffect(() => { - cineService.setCine({ id: viewportIndex }); - }, [viewportIndex]); - - useEffect(() => { - const unsubscribeFromJumpToMeasurementEvents = _subscribeToJumpToMeasurementEvents( - MeasurementService, - DisplaySetService, - element, - viewportIndex, - displaySet.displaySetInstanceUID - ); - - _checkForCachedJumpToMeasurementEvents( - MeasurementService, - DisplaySetService, - element, - viewportIndex, - displaySet.displaySetInstanceUID - ); - - return () => { - unsubscribeFromJumpToMeasurementEvents(); - }; - }, [element, displaySet]); - - useEffect(() => { - if (!element) { - return; - } - const allTools = cornerstoneTools.store.state.tools; - const toolsForElement = allTools.filter(tool => tool.element === element); - - toolsForElement.forEach(tool => { - if ( - tool instanceof ArrowAnnotateTool || - tool instanceof BidirectionalTool || - tool instanceof EllipticalRoiTool || - tool instanceof LengthTool - ) { - const configuration = tool.configuration; - - configuration.renderDashed = !isTracked; - - tool.configuration = configuration; - } - }); - - const enabledElement = cornerstone.getEnabledElement(element); - - if (enabledElement.image) { - cornerstone.updateImage(element); - } - }, [isTracked]); + const [element, setElement] = useState(null); const onElementEnabled = evt => { const eventData = evt.detail; @@ -191,51 +122,33 @@ function TrackedCornerstoneViewport({ }; useEffect(() => { - const { - StudyInstanceUID, - displaySetInstanceUID, - sopClassUids, - } = displaySet; - - if (!StudyInstanceUID || !displaySetInstanceUID) { + if (!element) { return; } + const allTools = cornerstoneTools.store.state.tools; + const toolsForElement = allTools.filter(tool => tool.element === element); - if (sopClassUids && sopClassUids.length > 1) { - console.warn( - 'More than one SOPClassUID in the same series is not yet supported.' - ); - } + toolsForElement.forEach(tool => { + if ( + tool instanceof ArrowAnnotateTool || + tool instanceof BidirectionalTool || + tool instanceof EllipticalRoiTool || + tool instanceof LengthTool + ) { + const configuration = tool.configuration; - _getViewportData(dataSource, displaySet).then(setViewportData); - }, [dataSource, displaySet, viewports, viewportIndex]); + configuration.renderDashed = !isTracked; - // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - let childrenWithProps = null; - - if (!viewportData) { - return null; - } - - const { - imageIds, - currentImageIdIndex, - // If this comes from the instance, would be a better default - // `FrameTime` in the instance - // frameRate = 0, - } = viewportData.stack; - - if (children && children.length) { - childrenWithProps = children.map((child, index) => { - return ( - child && - React.cloneElement(child, { - viewportIndex, - key: index, - }) - ); + tool.configuration = configuration; + } }); - } + + const enabledElement = cornerstone.getEnabledElement(element); + + if (enabledElement.image) { + cornerstone.updateImage(element); + } + }, [isTracked]); // We have... // StudyInstanceUid, DisplaySetInstanceUid @@ -313,6 +226,19 @@ function TrackedCornerstoneViewport({ ); } + const renderViewport = () => { + const { component: Component } = extensionManager.getModuleEntry( + 'org.ohif.cornerstone.viewportModule.cornerstone' + ); + return ( + + ); + }; + const cine = cines[viewportIndex]; const isPlaying = (cine && cine.isPlaying) || false; const frameRate = (cine && cine.frameRate) || 24; @@ -341,10 +267,12 @@ function TrackedCornerstoneViewport({ patientSex: PatientSex || '', patientAge: PatientAge || '', MRN: PatientID || '', - thickness: SliceThickness ? `${SliceThickness.toFixed(2)}mm` : '', + thickness: SliceThickness + ? `${parseFloat(SliceThickness).toFixed(2)}mm` + : '', spacing: SpacingBetweenSlices !== undefined - ? `${SpacingBetweenSlices.toFixed(2)}mm` + ? `${parseFloat(SpacingBetweenSlices).toFixed(2)}mm` : '', scanner: ManufacturerModelName || '', }, @@ -362,29 +290,7 @@ function TrackedCornerstoneViewport({ /> {/* TODO: Viewport interface to accept stack or layers of content like this? */}
- { - return ( - - ); - }} - /> + {renderViewport()}
{viewportDialogState.viewportIndex === viewportIndex && ( )}
- {childrenWithProps}
); @@ -417,54 +322,6 @@ TrackedCornerstoneViewport.defaultProps = { const _viewportLabels = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I']; -/** - * Obtain the CornerstoneTools Stack for the specified display set. - * - * @param {Object} displaySet - * @param {Object} dataSource - * @return {Object} CornerstoneTools Stack - */ -function _getCornerstoneStack(displaySet, dataSource) { - // Get stack from Stack Manager - const storedStack = StackManager.findOrCreateStack(displaySet, dataSource); - - // Clone the stack here so we don't mutate it - const stack = Object.assign({}, storedStack); - - return stack; -} - -// TODO -> disabled double click for now: onDoubleClick={_onDoubleClick} -function _onDoubleClick() { - const cancelActiveManipulatorsForElement = cornerstoneTools.getModule( - 'manipulatorState' - ).setters.cancelActiveManipulatorsForElement; - const enabledElements = cornerstoneTools.store.state.enabledElements; - enabledElements.forEach(element => { - cancelActiveManipulatorsForElement(element); - }); -} - -/** - * Builds the viewport data from a datasource and a displayset. - * - * @param {Object} dataSource - * @param {Object} displaySet - * @return {Object} viewport data - */ - -async function _getViewportData(dataSource, displaySet) { - const stack = _getCornerstoneStack(displaySet, dataSource); - - const viewportData = { - StudyInstanceUID: displaySet.StudyInstanceUID, - displaySetInstanceUID: displaySet.displaySetInstanceUID, - stack, - }; - - return viewportData; -} - function _getNextMeasurementId( direction, servicesManager, @@ -518,112 +375,4 @@ function _getNextMeasurementId( return newTrackedMeasurementId; } -function _subscribeToJumpToMeasurementEvents( - MeasurementService, - DisplaySetService, - element, - viewportIndex, - displaySetInstanceUID -) { - const { unsubscribe } = MeasurementService.subscribe( - MeasurementService.EVENTS.JUMP_TO_MEASUREMENT, - ({ viewportIndex: jumpToMeasurementViewportIndex, measurement }) => { - // check if the correct viewport index. - if (viewportIndex !== jumpToMeasurementViewportIndex) { - // Event for a different viewport. - return; - } - - if (measurement.displaySetInstanceUID !== displaySetInstanceUID) { - // Not for this displaySet. - return; - } - - _jumpToMeasurement( - measurement, - element, - viewportIndex, - MeasurementService, - DisplaySetService - ); - } - ); - - return unsubscribe; -} - -function _checkForCachedJumpToMeasurementEvents( - MeasurementService, - DisplaySetService, - element, - viewportIndex, - displaySetInstanceUID -) { - // Check if there is a queued jumpToMeasurement event - const measurementIdToJumpTo = MeasurementService.getJumpToMeasurement( - viewportIndex - ); - - if (measurementIdToJumpTo && element) { - // Jump to measurement if the measurement exists - const measurement = MeasurementService.getMeasurement( - measurementIdToJumpTo - ); - - if (measurement.displaySetInstanceUID === displaySetInstanceUID) { - _jumpToMeasurement( - measurement, - element, - viewportIndex, - MeasurementService, - DisplaySetService - ); - } - } -} - -function _jumpToMeasurement( - measurement, - targetElement, - viewportIndex, - MeasurementService, - DisplaySetService -) { - const { displaySetInstanceUID, SOPInstanceUID } = measurement; - - const referencedDisplaySet = DisplaySetService.getDisplaySetByUID( - displaySetInstanceUID - ); - - const imageIndex = referencedDisplaySet.images.findIndex( - i => i.SOPInstanceUID === SOPInstanceUID - ); - - setCornerstoneMeasurementActive(measurement); - - if (targetElement !== null) { - const enabledElement = cornerstone.getEnabledElement(targetElement); - - // Wait for the image to update or we get a race condition when the element has only just been enabled. - const scrollToHandler = evt => { - scrollToIndex(targetElement, imageIndex); - targetElement.removeEventListener( - cornerstone.EVENTS.IMAGE_RENDERED, - scrollToHandler - ); - }; - targetElement.addEventListener( - cornerstone.EVENTS.IMAGE_RENDERED, - scrollToHandler - ); - - if (enabledElement.image) { - cornerstone.updateImage(targetElement); - } - - // Jump to measurement consumed, remove. - MeasurementService.removeJumpToMeasurement(viewportIndex); - } -} - export default TrackedCornerstoneViewport; diff --git a/modes/longitudinal/src/index.js b/modes/longitudinal/src/index.js index 6306fa19c..0bd9e4b81 100644 --- a/modes/longitudinal/src/index.js +++ b/modes/longitudinal/src/index.js @@ -4,6 +4,7 @@ import { hotkeys } from '@ohif/core'; const ohif = { layout: 'org.ohif.default.layoutTemplateModule.viewerLayout', sopClassHandler: 'org.ohif.default.sopClassHandlerModule.stack', + hangingProtocols: 'org.ohif.default.hangingProtocolModule.default', }; const tracked = { @@ -17,6 +18,7 @@ const dicomsr = { viewport: 'org.ohif.dicom-sr.viewportModule.dicom-sr', }; + export default function mode({ modeConfiguration }) { return { // TODO: We're using this as a route segment @@ -40,6 +42,18 @@ export default function mode({ modeConfiguration }) { }; ToolBarService.recordInteraction(interaction); + + ToolBarService.init(extensionManager); + ToolBarService.addButtons(toolbarButtons); + ToolBarService.createButtonSection('primary', [ + 'MeasurementTools', + 'Zoom', + 'WindowLevel', + 'Pan', + 'Capture', + 'Layout', + 'MoreTools', + ]); }, onModeExit: () => {}, validationTags: { @@ -53,21 +67,10 @@ export default function mode({ modeConfiguration }) { routes: [ { path: 'longitudinal', - init: ({ servicesManager, extensionManager }) => { - const { ToolBarService } = servicesManager.services; - ToolBarService.init(extensionManager); - ToolBarService.addButtons(toolbarButtons); - ToolBarService.createButtonSection('primary', [ - 'MeasurementTools', - 'Zoom', - 'WindowLevel', - 'Pan', - 'Capture', - 'Layout', - 'MoreTools', - ]); - }, - layoutTemplate: ({ routeProps }) => { + /*init: ({ servicesManager, extensionManager }) => { + //defaultViewerRouteInit + },*/ + layoutTemplate: ({ location, servicesManager }) => { return { id: ohif.layout, props: { @@ -95,6 +98,7 @@ export default function mode({ modeConfiguration }) { 'org.ohif.measurement-tracking', 'org.ohif.dicom-sr', ], + hangingProtocols: [ohif.hangingProtocols], sopClassHandlers: [ohif.sopClassHandler, dicomsr.sopClassHandler], hotkeys: [...hotkeys.defaults.hotkeyBindings], }; diff --git a/package.json b/package.json index bbf0a85a5..90b8c93fc 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "cross-env": "^5.2.0", "css-loader": "^3.2.0", "dotenv": "^8.1.0", - "eslint": "5.16.0", + "eslint": "6.8.0", "eslint-config-prettier": "^6.4.0", "eslint-config-react-app": "^5.2.0", "eslint-plugin-flowtype": "2.x", @@ -84,7 +84,7 @@ "eslint-plugin-prettier": "^3.1.1", "eslint-plugin-promise": "^4.2.1", "eslint-plugin-react": "7.x", - "eslint-plugin-react-hooks": "1.5.0", + "eslint-plugin-react-hooks": "4.2.0", "extract-css-chunks-webpack-plugin": "^4.5.4", "html-webpack-plugin": "^3.2.0", "husky": "^3.0.0", diff --git a/platform/core/package.json b/platform/core/package.json index a5a2cc6ca..a2071176e 100644 --- a/platform/core/package.json +++ b/platform/core/package.json @@ -45,7 +45,8 @@ "mousetrap": "^1.6.3", "query-string": "^6.14.0", "object-hash": "2.1.1", - "cornerstone-math": "0.1.9" + "cornerstone-math": "0.1.9", + "validate.js": "^0.12.0" }, "devDependencies": { "webpack-merge": "5.7.3" diff --git a/platform/core/src/DataSources/IWebApiDataSource.js b/platform/core/src/DataSources/IWebApiDataSource.js index 82c9facab..fda0fca22 100644 --- a/platform/core/src/DataSources/IWebApiDataSource.js +++ b/platform/core/src/DataSources/IWebApiDataSource.js @@ -17,9 +17,11 @@ function create({ retrieve, store, reject, + parseRouteParams, retrieveSeriesMetadata, deleteStudyMetadataPromise, getImageIdsForDisplaySet, + getImageIdsForInstance, }) { const defaultQuery = { studies: { @@ -56,15 +58,16 @@ function create({ }; const defaultReject = {}; - return { query: query || defaultQuery, retrieve: retrieve || defaultRetrieve, reject: reject || defaultReject, store: store || defaultStore, - getImageIdsForDisplaySet, + parseRouteParams, retrieveSeriesMetadata, deleteStudyMetadataPromise, + getImageIdsForDisplaySet, + getImageIdsForInstance, }; } diff --git a/platform/core/src/classes/MetadataProvider.js b/platform/core/src/classes/MetadataProvider.js index 7c4cc555e..e6f2c739f 100644 --- a/platform/core/src/classes/MetadataProvider.js +++ b/platform/core/src/classes/MetadataProvider.js @@ -189,7 +189,7 @@ class MetadataProvider { metadata = { modality: instance.Modality, seriesInstanceUID: instance.SeriesInstanceUID, - seriesNumber: instance.SeriesNumber, + seriesNumber: getNumberValues(instance.SeriesNumber), studyInstanceUID: instance.StudyInstanceUID, seriesDate, seriesTime, @@ -197,9 +197,9 @@ class MetadataProvider { break; case WADO_IMAGE_LOADER_TAGS.PATIENT_STUDY_MODULE: metadata = { - patientAge: instance.PatientAge, - patientSize: instance.PatientSize, - patientWeight: instance.PatientWeight, + patientAge: getNumberValues(instance.PatientAge), + patientSize: getNumberValues(instance.PatientSize), + patientWeight: getNumberValues(instance.PatientWeight), }; break; case WADO_IMAGE_LOADER_TAGS.IMAGE_PLANE_MODULE: @@ -228,51 +228,53 @@ class MetadataProvider { metadata = { frameOfReferenceUID: instance.FrameOfReferenceUID, - rows: instance.Rows, - columns: instance.Columns, - imageOrientationPatient: ImageOrientationPatient, - rowCosines, - columnCosines, - imagePositionPatient: instance.ImagePositionPatient, - sliceThickness: instance.SliceThickness, - sliceLocation: instance.SliceLocation, - pixelSpacing: PixelSpacing, - rowPixelSpacing, - columnPixelSpacing, + rows: getNumberValues(instance.Rows), + columns: getNumberValues(instance.Columns), + imageOrientationPatient: getNumberValues(ImageOrientationPatient), + rowCosines: getNumberValues(rowCosines), + columnCosines: getNumberValues(columnCosines), + imagePositionPatient: getNumberValues(instance.ImagePositionPatient), + sliceThickness: getNumberValues(instance.SliceThickness), + sliceLocation: getNumberValues(instance.SliceLocation), + pixelSpacing: getNumberValues(PixelSpacing), + rowPixelSpacing: getNumberValues(rowPixelSpacing), + columnPixelSpacing: getNumberValues(columnPixelSpacing), }; break; case WADO_IMAGE_LOADER_TAGS.IMAGE_PIXEL_MODULE: metadata = { - samplesPerPixel: instance.SamplesPerPixel, - photometricInterpretation: instance.PhotometricInterpretation, - rows: instance.Rows, - columns: instance.Columns, - bitsAllocated: instance.BitsAllocated, - bitsStored: instance.BitsStored, - highBit: instance.HighBit, - pixelRepresentation: instance.PixelRepresentation, - planarConfiguration: instance.PlanarConfiguration, - pixelAspectRatio: instance.PixelAspectRatio, - smallestPixelValue: instance.SmallestPixelValue, - largestPixelValue: instance.LargestPixelValue, + samplesPerPixel: getNumberValues(instance.SamplesPerPixel), + photometricInterpretation: getNumberValues(instance.PhotometricInterpretation), + rows: getNumberValues(instance.Rows), + columns: getNumberValues(instance.Columns), + bitsAllocated: getNumberValues(instance.BitsAllocated), + bitsStored: getNumberValues(instance.BitsStored), + highBit: getNumberValues(instance.HighBit), + pixelRepresentation: getNumberValues(instance.PixelRepresentation), + planarConfiguration: getNumberValues(instance.PlanarConfiguration), + pixelAspectRatio: getNumberValues(instance.PixelAspectRatio), + smallestPixelValue: getNumberValues(instance.SmallestPixelValue), + largestPixelValue: getNumberValues(instance.LargestPixelValue), redPaletteColorLookupTableDescriptor: - instance.RedPaletteColorLookupTableDescriptor, + getNumberValues(instance.RedPaletteColorLookupTableDescriptor), greenPaletteColorLookupTableDescriptor: - instance.GreenPaletteColorLookupTableDescriptor, + getNumberValues(instance.GreenPaletteColorLookupTableDescriptor), bluePaletteColorLookupTableDescriptor: - instance.BluePaletteColorLookupTableDescriptor, + getNumberValues(instance.BluePaletteColorLookupTableDescriptor), redPaletteColorLookupTableData: - instance.RedPaletteColorLookupTableData, + getNumberValues(instance.RedPaletteColorLookupTableData), greenPaletteColorLookupTableData: - instance.GreenPaletteColorLookupTableData, + getNumberValues(instance.GreenPaletteColorLookupTableData), bluePaletteColorLookupTableData: - instance.BluePaletteColorLookupTableData, + getNumberValues(instance.BluePaletteColorLookupTableData), }; break; case WADO_IMAGE_LOADER_TAGS.VOI_LUT_MODULE: const { WindowCenter, WindowWidth } = instance; - + if (WindowCenter === undefined || WindowWidth === undefined) { + return; + } const windowCenter = Array.isArray(WindowCenter) ? WindowCenter : [WindowCenter]; @@ -281,15 +283,20 @@ class MetadataProvider { : [WindowWidth]; metadata = { - windowCenter, - windowWidth, + windowCenter: getNumberValues(windowCenter), + windowWidth: getNumberValues(windowWidth), }; break; case WADO_IMAGE_LOADER_TAGS.MODALITY_LUT_MODULE: + const { RescaleIntercept, RescaleSlope } = instance; + if (RescaleIntercept === undefined || RescaleSlope === undefined) { + return; + } + metadata = { - rescaleIntercept: instance.RescaleIntercept, - rescaleSlope: instance.RescaleSlope, + rescaleIntercept: getNumberValues(instance.RescaleIntercept), + rescaleSlope: getNumberValues(instance.RescaleSlope), rescaleType: instance.RescaleType, }; break; @@ -401,7 +408,7 @@ class MetadataProvider { case WADO_IMAGE_LOADER_TAGS.GENERAL_IMAGE_MODULE: metadata = { sopInstanceUid: instance.SOPInstanceUID, - instanceNumber: instance.InstanceNumber, + instanceNumber: getNumberValues(instance.InstanceNumber), lossyImageCompression: instance.LossyImageCompression, lossyImageCompressionRatio: instance.LossyImageCompressionRatio, lossyImageCompressionMethod: instance.LossyImageCompressionMethod, @@ -457,6 +464,31 @@ class MetadataProvider { const metadataProvider = new MetadataProvider(); + +/** + * Returns the values as an array of javascript numbers + * + * @param element - The javascript object for the specified element in the metadata + * @returns {*} + */ +function getNumberValues(element) { + if (!element) { + return; + } + + if (Array.isArray(element)) { + const values = []; + for (let i = 0; i < element.length; i++) { + values.push(parseFloat(element[i])); + } + return values; + } + + return parseFloat(element) +} + + + export default metadataProvider; const WADO_IMAGE_LOADER_TAGS = { @@ -479,3 +511,4 @@ const WADO_IMAGE_LOADER_TAGS = { }; const INSTANCE = 'instance'; +const DICOMWEB = 'dicomweb'; diff --git a/platform/core/src/extensions/ExtensionManager.js b/platform/core/src/extensions/ExtensionManager.js index 0cba1a3d6..e9299fd3b 100644 --- a/platform/core/src/extensions/ExtensionManager.js +++ b/platform/core/src/extensions/ExtensionManager.js @@ -2,7 +2,12 @@ import MODULE_TYPES from './MODULE_TYPES.js'; import log from './../log.js'; export default class ExtensionManager { - constructor({ commandsManager, servicesManager, hotkeysManager, appConfig = {} }) { + constructor({ + commandsManager, + servicesManager, + hotkeysManager, + appConfig = {}, + }) { this.modules = {}; this.registeredExtensionIds = []; this.moduleTypeNames = Object.values(MODULE_TYPES); @@ -52,7 +57,7 @@ export default class ExtensionManager { onModeEnter({ servicesManager: _servicesManager, commandsManager: _commandsManager, - hotkeysManager: _hotkeysManager + hotkeysManager: _hotkeysManager, }); } }); @@ -115,9 +120,7 @@ export default class ExtensionManager { */ registerExtension = (extension, configuration = {}, dataSources = []) => { if (!extension) { - throw new Error( - 'Attempting to register a null/undefined extension.' - ); + throw new Error('Attempting to register a null/undefined extension.'); } let extensionId = extension.id; @@ -191,6 +194,13 @@ export default class ExtensionManager { ] = element; }); break; + case MODULE_TYPES.HANGING_PROTOCOL: + extensionModule.forEach(element => { + this.modulesMap[ + `${extensionId}.${moduleType}.${element.name}` + ] = element; + }); + break; default: throw new Error(`Module type invalid: ${moduleType}`); } @@ -224,6 +234,10 @@ export default class ExtensionManager { return this.dataSourceMap[this.activeDataSource]; }; + getDataSource = () => { + return this.dataSourceMap[this.activeDataSource]; + }; + /** * @private * @param {string} moduleType @@ -288,6 +302,32 @@ export default class ExtensionManager { }); } + _initHangingProtocolModule(extensionModule, extensionId) { + extensionModule.forEach(element => { + const namespace = `${extensionId}.${MODULE_TYPES.HANGING_PROTOCOL}.${element.name}`; + + dataSources.forEach(dataSource => { + if (dataSource.namespace === namespace) { + const dataSourceInstance = element.createDataSource( + dataSource.configuration + ); + + if (this.dataSourceMap[dataSource.sourceName]) { + this.dataSourceMap[dataSource.sourceName].push(dataSourceInstance); + } else { + this.dataSourceMap[dataSource.sourceName] = [dataSourceInstance]; + } + } + }); + }); + + extensionModule.forEach(element => { + this.modulesMap[ + `${extensionId}.${MODULE_TYPES.DATA_SOURCE}.${element.name}` + ] = element; + }); + } + /** * * @private diff --git a/platform/core/src/extensions/ExtensionManager.test.js b/platform/core/src/extensions/ExtensionManager.test.js index b5184127d..174a334e3 100644 --- a/platform/core/src/extensions/ExtensionManager.test.js +++ b/platform/core/src/extensions/ExtensionManager.test.js @@ -215,6 +215,9 @@ describe('ExtensionManager.js', () => { getDataSourcesModule: () => { return [{}]; }, + getHangingProtocolModule: () => { + return [{}]; + }, getContextModule: () => { return [{}]; } diff --git a/platform/core/src/extensions/MODULE_TYPES.js b/platform/core/src/extensions/MODULE_TYPES.js index 887347aba..d77cced16 100644 --- a/platform/core/src/extensions/MODULE_TYPES.js +++ b/platform/core/src/extensions/MODULE_TYPES.js @@ -7,4 +7,5 @@ export default { VIEWPORT: 'viewportModule', CONTEXT: 'contextModule', LAYOUT_TEMPLATE: 'layoutTemplateModule', + HANGING_PROTOCOL: 'hangingProtocolModule', }; diff --git a/platform/core/src/index.js b/platform/core/src/index.js index f695e6a24..ff33f36bc 100644 --- a/platform/core/src/index.js +++ b/platform/core/src/index.js @@ -25,6 +25,7 @@ import { MeasurementService, ViewportGridService, HangingProtocolService, + pubSubServiceInterface, } from './services'; import IWebApiDataSource from './DataSources/IWebApiDataSource'; @@ -67,6 +68,7 @@ const OHIF = { HangingProtocolService, IWebApiDataSource, DicomMetadataStore, + pubSubServiceInterface, }; export { @@ -101,6 +103,7 @@ export { HangingProtocolService, IWebApiDataSource, DicomMetadataStore, + pubSubServiceInterface, }; export { OHIF }; diff --git a/platform/core/src/index.test.js b/platform/core/src/index.test.js index a5ccfe6ac..ad97e3ff1 100644 --- a/platform/core/src/index.test.js +++ b/platform/core/src/index.test.js @@ -10,21 +10,6 @@ describe('Top level exports', () => { 'HotkeysManager', 'ServicesManager', // - 'DicomMetadataStore', - // - 'CineService', - 'DisplaySetService', - 'HangingProtocolService', - 'ToolBarService', - 'UINotificationService', - 'UIModalService', - 'UIDialogService', - 'UIViewportDialogService', - 'MeasurementService', - 'ViewportGridService', - // - 'IWebApiDataSource', - // 'defaults', 'utils', 'hotkeys', @@ -37,7 +22,21 @@ describe('Top level exports', () => { 'log', 'DICOMWeb', 'DICOMSR', - 'OHIF', // + 'OHIF', + // + 'CineService', + 'UIDialogService', + 'UIModalService', + 'UINotificationService', + 'UIViewportDialogService', + 'DisplaySetService', + 'MeasurementService', + 'ToolBarService', + 'ViewportGridService', + 'HangingProtocolService', + 'IWebApiDataSource', + 'DicomMetadataStore', + 'pubSubServiceInterface' ].sort(); const exports = Object.keys(OHIF).sort(); diff --git a/platform/core/src/measurements/tools/polygonRoi.js b/platform/core/src/measurements/tools/polygonRoi.js new file mode 100644 index 000000000..4ebdc4d97 --- /dev/null +++ b/platform/core/src/measurements/tools/polygonRoi.js @@ -0,0 +1,24 @@ +const displayFunction = data => { + let meanValue = ''; + const { cachedStats } = data; + if (cachedStats && cachedStats.mean && !isNaN(cachedStats.mean)) { + meanValue = cachedStats.mean.toFixed(2) + ' HU'; + } + return meanValue; +}; + +export const polygonRoi = { + id: 'PolygonRoi', + name: 'Polygon', + toolGroup: 'allTools', + cornerstoneToolType: 'FreehandRoiTool', + options: { + measurementTable: { + displayFunction, + }, + caseProgress: { + include: true, + evaluate: true, + }, + }, +}; diff --git a/platform/core/src/services/DicomMetadataStore/DicomMetadataStore.js b/platform/core/src/services/DicomMetadataStore/DicomMetadataStore.js index 6d5a353eb..3ee93ec85 100644 --- a/platform/core/src/services/DicomMetadataStore/DicomMetadataStore.js +++ b/platform/core/src/services/DicomMetadataStore/DicomMetadataStore.js @@ -54,6 +54,18 @@ function _getInstance(StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID) { ); } +function _getInstanceFromImageId(imageId) { + for (let study of _model.studies) { + for (let series of study.series) { + for (let instance of series.instances) { + if (instance.imageId === imageId) { + return instance; + } + } + } + } +} + const BaseImplementation = { EVENTS, listeners: {}, @@ -82,7 +94,7 @@ const BaseImplementation = { madeInClient, }); }, - addSeriesMetadata(seriesSummaryMetadata) { + addSeriesMetadata(seriesSummaryMetadata, madeInClient = false) { const { StudyInstanceUID } = seriesSummaryMetadata[0]; let study = _getStudy(StudyInstanceUID); if (!study) { @@ -98,6 +110,7 @@ const BaseImplementation = { this._broadcastEvent(EVENTS.SERIES_ADDED, { StudyInstanceUID, + madeInClient, }); }, addStudy(study) { @@ -124,6 +137,7 @@ const BaseImplementation = { getStudy: _getStudy, getSeries: _getSeries, getInstance: _getInstance, + getInstanceFromImageId: _getInstanceFromImageId, }; const DicomMetadataStore = Object.assign( diff --git a/platform/core/src/services/DicomMetadataStore/EVENTS.js b/platform/core/src/services/DicomMetadataStore/EVENTS.js index 7f30be511..09c1475da 100644 --- a/platform/core/src/services/DicomMetadataStore/EVENTS.js +++ b/platform/core/src/services/DicomMetadataStore/EVENTS.js @@ -1,4 +1,5 @@ const EVENTS = { + STUDY_ADDED: 'event::dicomMetadataStore:studyAdded', INSTANCES_ADDED: 'event::dicomMetadataStore:instancesAdded', SERIES_ADDED: 'event::dicomMetadataStore:seriesAdded', }; diff --git a/platform/core/src/services/DicomMetadataStore/createStudyMetadata.js b/platform/core/src/services/DicomMetadataStore/createStudyMetadata.js index 8407e2d26..3f00dd3c8 100644 --- a/platform/core/src/services/DicomMetadataStore/createStudyMetadata.js +++ b/platform/core/src/services/DicomMetadataStore/createStudyMetadata.js @@ -3,6 +3,7 @@ import createSeriesMetadata from './createSeriesMetadata'; function createStudyMetadata(StudyInstanceUID) { return { StudyInstanceUID, + isLoaded: false, series: [], /** * @@ -24,7 +25,7 @@ function createStudyMetadata(StudyInstanceUID) { } }, - setSeriesMetadata: function (SeriesInstanceUID, seriesMetadata) { + setSeriesMetadata: function(SeriesInstanceUID, seriesMetadata) { let existingSeries = this.series.find( s => s.SeriesInstanceUID === SeriesInstanceUID ); @@ -34,7 +35,7 @@ function createStudyMetadata(StudyInstanceUID) { } else { this.series.push(Object.assign({ instances: [] }, seriesMetadata)); } - } + }, }; } diff --git a/platform/core/src/services/DisplaySetService/DisplaySetService.js b/platform/core/src/services/DisplaySetService/DisplaySetService.js index 4229e37b0..9481559ad 100644 --- a/platform/core/src/services/DisplaySetService/DisplaySetService.js +++ b/platform/core/src/services/DisplaySetService/DisplaySetService.js @@ -96,10 +96,13 @@ export default class DisplaySetService { /** * * @param {*} input - * @param {*} param1 + * @param {*} param1: settings: initialViewportSettings by HP or callbacks after rendering * @returns {string[]} - added displaySetInstanceUIDs */ - makeDisplaySets = (input, { batch = false, madeInClient = false } = {}) => { + makeDisplaySets = ( + input, + { batch = false, madeInClient = false, settings = {} } = {} + ) => { if (!input || !input.length) { throw new Error('No instances were provided.'); } @@ -116,12 +119,15 @@ export default class DisplaySetService { if (batch) { for (let i = 0; i < input.length; i++) { const instances = input[i]; - const displaySets = this.makeDisplaySetForInstances(instances); + const displaySets = this.makeDisplaySetForInstances( + instances, + settings + ); displaySetsAdded = [...displaySetsAdded, displaySets]; } } else { - const displaySets = this.makeDisplaySetForInstances(input); + const displaySets = this.makeDisplaySetForInstances(input, settings); displaySetsAdded = displaySets; } @@ -145,7 +151,7 @@ export default class DisplaySetService { } }; - makeDisplaySetForInstances(instances) { + makeDisplaySetForInstances(instances, settings) { const instance = instances[0]; const existingDisplaySets = @@ -168,6 +174,13 @@ export default class DisplaySetService { } else { displaySets = handler.getDisplaySetsFromSeries(instances); + // applying hp-defined viewport settings to the displaysets + displaySets.forEach(ds => { + Object.keys(settings).forEach(key => { + ds[key] = settings[key]; + }); + }); + this._addDisplaySetsToCache(displaySets); this._addActiveDisplaySets(displaySets); } diff --git a/platform/core/src/services/HangingProtocolService/HPMatcher.js b/platform/core/src/services/HangingProtocolService/HPMatcher.js new file mode 100644 index 000000000..29b0e4a1c --- /dev/null +++ b/platform/core/src/services/HangingProtocolService/HPMatcher.js @@ -0,0 +1,96 @@ +import validate from './lib/validator'; + + +/** + * Match a Metadata instance against rules using Validate.js for validation. + * @param {InstanceMetadata} metadataInstance Metadata instance object + * @param {Array} rules Array of MatchingRules instances (StudyMatchingRule|SeriesMatchingRule|ImageMatchingRule) for the match + * @return {Object} Matching Object with score and details (which rule passed or failed) + */ +const match = (metadataInstance, rules, customAttributeRetrievalCallbacks) => { + const options = { + format: 'grouped', + }; + + const details = { + passed: [], + failed: [], + }; + + let requiredFailed = false; + let score = 0; + + rules.forEach(rule => { + const attribute = rule.attribute; + + // Do not use the custom attribute from the metadataInstance since it is subject to change + if (customAttributeRetrievalCallbacks.hasOwnProperty(attribute)) { + const customAttribute = customAttributeRetrievalCallbacks[attribute]; + metadataInstance[attribute] = customAttribute.callback(metadataInstance); + } + + // Format the constraint as required by Validate.js + const testConstraint = { + [attribute]: rule.constraint, + }; + + // Create a single attribute object to be validated, since metadataInstance is an + // instance of Metadata (StudyMetadata, SeriesMetadata or InstanceMetadata) + const attributeValue = metadataInstance[attribute]; + const attributeMap = { + [attribute]: attributeValue, + }; + + // Use Validate.js to evaluate the constraints on the specified metadataInstance + let errorMessages; + try { + errorMessages = validate(attributeMap, testConstraint, [options]); + } catch (e) { + errorMessages = ['Something went wrong during validation.', e]; + } + + if (!errorMessages) { + // If no errorMessages were returned, then validation passed. + + // Add the rule's weight to the total score + score += parseInt(rule.weight, 10); + + // Log that this rule passed in the matching details object + details.passed.push({ + rule, + }); + } else { + // If errorMessages were present, then validation failed + + // If the rule that failed validation was Required, then + // mark that a required Rule has failed + if (rule.required) { + requiredFailed = true; + } + + // Log that this rule failed in the matching details object + // and include any error messages + details.failed.push({ + rule, + errorMessages, + }); + } + }); + + // If a required Rule has failed Validation, set the matching score to zero + if (requiredFailed) { + score = 0; + } + + return { + score, + details, + requiredFailed, + }; +}; + +const HPMatcher = { + match, +}; + +export { HPMatcher }; diff --git a/platform/core/src/services/HangingProtocolService/HangingProtocolService.js b/platform/core/src/services/HangingProtocolService/HangingProtocolService.js index 1127c0a66..6d8af2189 100644 --- a/platform/core/src/services/HangingProtocolService/HangingProtocolService.js +++ b/platform/core/src/services/HangingProtocolService/HangingProtocolService.js @@ -1,74 +1,476 @@ -const name = 'HangingProtocolService'; +import { cloneDeep } from 'lodash'; +import pubSubServiceInterface from '../_shared/pubSubServiceInterface'; +import sortBy from '../../utils/sortBy.js'; +import ProtocolEngine from './ProtocolEngine'; -const publicAPI = { - name, - getState: _getState, - setHangingProtocol: _setHangingProtocol, - setHangingProtocolAppliedForViewport: _setHangingProtocolAppliedForViewport, - setServiceImplementation, - reset: _reset, - set: _set, +const EVENTS = { + STAGE_CHANGE: 'event::hanging_protocol_stage_change', + NEW_LAYOUT: 'event::hanging_protocol_new_layout', }; -const serviceImplementation = { - _getState: () => console.warn('getState() NOT IMPLEMENTED'), - _setHangingProtocol: () => - console.warn('_setHangingProtocol() NOT IMPLEMENTED'), - _setHangingProtocolAppliedForViewport: () => - console.warn('_setHangingProtocolAppliedForViewport() NOT IMPLEMENTED'), - _reset: () => console.warn('reset() NOT IMPLEMENTED'), - _set: () => console.warn('set() NOT IMPLEMENTED'), +const VIEWPORT_SETTING_TYPES = { + PROPS: 'props', + VIEWPORT: 'viewport', }; -function _getState() { - return serviceImplementation._getState(); -} - -function _setHangingProtocol(hangingProtocol) { - return serviceImplementation._setHangingProtocol(hangingProtocol); -} - -function _setHangingProtocolAppliedForViewport(hpAlreadyApplied) { - return serviceImplementation._setHangingProtocolAppliedForViewport( - hpAlreadyApplied - ); -} - -function _set(state) { - return serviceImplementation._set(state); -} - -function _reset() { - return serviceImplementation._reset({}); -} - -function setServiceImplementation({ - getState: getStateImplementation, - setHangingProtocol: setHangingProtocolImplementation, - setHangingProtocolAppliedForViewport: setHangingProtocolAppliedForViewportImplementation, - reset: resetImplementation, - set: setImplementation, -}) { - if (getStateImplementation) { - serviceImplementation._getState = getStateImplementation; +class HangingProtocolService { + constructor(commandsManager) { + this._commandsManager = commandsManager; + this.protocols = []; + this.ProtocolEngine = undefined; + this.protocol = undefined; + this.stage = undefined; + this.matchDetails = []; + this.hpAlreadyApplied = []; + this.studies = []; + this.customViewportSettings = []; + this.customAttributeRetrievalCallbacks = {}; + this.listeners = {}; + Object.defineProperty(this, 'EVENTS', { + value: EVENTS, + writable: false, + enumerable: true, + configurable: false, + }); + Object.assign(this, pubSubServiceInterface); } - if (setHangingProtocolImplementation) { - serviceImplementation._setHangingProtocol = setHangingProtocolImplementation; + + reset() { + this.studies = []; + this.protocols = []; + this.hpAlreadyApplied = []; + this.matchDetails = []; + // this.ProtocolEngine.reset() } - if (setHangingProtocolAppliedForViewportImplementation) { - serviceImplementation._setHangingProtocolAppliedForViewport = setHangingProtocolAppliedForViewportImplementation; + + getState() { + return [this.matchDetails, this.hpAlreadyApplied]; } - if (resetImplementation) { - serviceImplementation._reset = resetImplementation; + + getProtocols() { + return this.protocols; } - if (setImplementation) { - serviceImplementation._set = setImplementation; + + addProtocols(protocols) { + protocols.forEach(protocol => { + if (this.protocols.indexOf(protocol) === -1) { + this.protocols.push(protocol); + } + }); + } + + run(studyMetaData, protocol) { + if (!this.studies.includes(studyMetaData)) { + this.studies.push(studyMetaData); + } + // copy here so we don't mutate it + const metaData = Object.assign({}, studyMetaData); + + this.ProtocolEngine = new ProtocolEngine( + this.protocols, + this.customAttributeRetrievalCallbacks + ); + + // if there is no pre-defiend protocol + if (!protocol || protocol.id === undefined) { + const matchedProtocol = this.ProtocolEngine.run(metaData); + this._setProtocol(matchedProtocol); + return; + } + + this._setProtocol(protocol); + } + + setHangingProtocolAppliedForViewport(i) { + this.hpAlreadyApplied[i] = true; + } + + /** + * Adds a custom setting that can be chosen in the HangingProtocol UI and applied to a Viewport + * + * @param settingId The ID used to refer to the setting (e.g. 'displayCADMarkers') + * @param settingName The name of the setting to be displayed (e.g. 'Display CAD Markers') + * @param options + * @param callback A function to be run after a viewport is rendered with a series + */ + addCustomViewportSetting(...params) { + this.customViewportSettings.push(...params); + } + + /** + * 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. + * + * @param attributeId The ID used to refer to the attribute (e.g. 'timepointType') + * @param attributeName The name of the attribute to be displayed (e.g. 'Timepoint Type') + * @param callback The function used to calculate the attribute value from the other attributes at its level (e.g. study/series/image) + */ + addCustomAttribute(attributeId, attributeName, callback) { + this.customAttributeRetrievalCallbacks[attributeId] = { + name: attributeName, + callback: callback, + }; + } + + /** + * Switches to the next protocol stage in the display set sequence + */ + nextProtocolStage() { + console.log('ProtocolEngine::nextProtocolStage'); + + if (!this._setCurrentProtocolStage(1)) { + console.log('ProtocolEngine::nextProtocolStage failed'); + } + } + + /** + * Switches to the previous protocol stage in the display set sequence + */ + previousProtocolStage() { + console.log('ProtocolEngine::previousProtocolStage'); + + if (!this._setCurrentProtocolStage(-1)) { + console.log('ProtocolEngine::previousProtocolStage failed'); + } + } + + _setProtocol(protocol) { + // TODO: Add proper Protocol class to validate the protocols + // which are entered manually + this.stage = 0; + this.protocol = protocol; + this._updateViewports(protocol); + } + + /** + * Retrieves the number of Stages in the current Protocol or + * undefined if no protocol or stages are set + */ + _getNumProtocolStages() { + if ( + !this.protocol || + !this.protocol.stages || + !this.protocol.stages.length + ) { + return; + } + + return this.protocol.stages.length; + } + + /** + * Retrieves the current Stage from the current Protocol and stage index + * + * @returns {*} The Stage model for the currently displayed Stage + */ + _getCurrentStageModel() { + return this.protocol.stages[this.stage]; + } + + _updateViewports() { + // Make sure we have an active protocol with a non-empty array of display sets + if (!this._getNumProtocolStages()) { + return; + } + + // Retrieve the current stage + const stageModel = this._getCurrentStageModel(); + + // If the current stage does not fulfill the requirements to be displayed, + // stop here. + if ( + !stageModel || + !stageModel.viewportStructure || + !stageModel.viewports || + !stageModel.viewports.length + ) { + return; + } + + // Retrieve the layoutTemplate associated with the current display set's viewport structure + // If no such template name exists, stop here. + // const layoutTemplateName = stageModel.viewportStructure.getLayoutTemplateName(); + const layoutTemplateName = 'gridLayout'; + if (!layoutTemplateName) { + return; + } + + // Retrieve the properties associated with the current display set's viewport structure template + // If no such layout properties exist, stop here. + const layoutProps = stageModel.viewportStructure.properties; + if (!layoutProps) { + return; + } + + const { columns: numCols, rows: numRows } = layoutProps; + this._broadcastChange(this.EVENTS.NEW_LAYOUT, { + numRows, + numCols, + }); + + // Empty the matchDetails associated with the ProtocolEngine. + // This will be used to store the pass/fail details and score + // for each of the viewport matching procedures + + // Loop through each viewport + stageModel.viewports.forEach((viewport, viewportIndex) => { + this.hpAlreadyApplied.push(false); + const details = this._matchImages(viewport); + + let currentMatch = details.bestMatch; + + const currentViewportData = { + viewportIndex, + SeriesInstanceUID: currentMatch && currentMatch.SeriesInstanceUID, + }; + + // Viewport Settings + // + // protocol defined callback + const protocolCallbacks = viewport.viewportSettings.filter( + setting => setting.type === VIEWPORT_SETTING_TYPES.PROPS + ); + // manually added callback + const customCallbacks = this.customViewportSettings.filter( + setting => setting.type === VIEWPORT_SETTING_TYPES.PROPS + ); + const callbacks = protocolCallbacks.concat(customCallbacks); + + // if we have callbacks to applied at the app level or at the HP level + if (callbacks.length) { + currentViewportData.renderedCallback = (element, ToolBarService) => { + callbacks.forEach(setting => { + const { commandName, options } = setting; + options.viewportIndex = viewportIndex; + options.element = element; + // Toolbar service to handle tool activation + if (commandName === 'setToolActive') { + ToolBarService.recordInteraction(options); + return; + } + // other commands + this._commandsManager.runCommand(commandName, options); + }); + }; + } + + // initial viewport settings defined by protocol + const protocolInitialViewport = viewport.viewportSettings.filter( + setting => setting.type === VIEWPORT_SETTING_TYPES.VIEWPORT + ); + // custom added initial viewport settings + const customInitialViewport = this.customViewportSettings.filter( + setting => setting.type === VIEWPORT_SETTING_TYPES.VIEWPORT + ); + // TODO: conflict might happen between protocol and custom viewport settings + const viewportSettings = protocolInitialViewport.concat( + customInitialViewport + ); + + if (viewportSettings.length) { + const initialViewport = {}; + viewportSettings.forEach(setting => { + const { options } = setting; + if (!options) return; + // Do not manipulate the hp settings + const viewportOptions = cloneDeep(options); + Object.entries(viewportOptions).forEach(([key, value]) => { + initialViewport[key] = value; + }); + }); + currentViewportData.initialViewport = initialViewport; + } + + this.matchDetails[viewportIndex] = currentViewportData; + }); + } + + // Match images given a list of Studies and a Viewport's image matching reqs + _matchImages(viewport) { + console.log('ProtocolEngine::matchImages'); + + // TODO: matching is applied on study and series level, instance + // level matching needs to be added in future + + const { studyMatchingRules, seriesMatchingRules } = viewport; + + const matchingScores = []; + let highestStudyMatchingScore = 0; + let highestSeriesMatchingScore = 0; + + this.studies.forEach(study => { + const studyMatchDetails = this.ProtocolEngine.findMatch( + study, + studyMatchingRules + ); + + // Prevent bestMatch from being updated if the matchDetails' required attribute check has failed + if ( + studyMatchDetails.requiredFailed === true || + studyMatchDetails.score < highestStudyMatchingScore + ) { + return; + } + + highestStudyMatchingScore = studyMatchDetails.score; + + study.series.forEach(aSeries => { + const seriesMatchDetails = this.ProtocolEngine.findMatch( + aSeries, + seriesMatchingRules + ); + + // Prevent bestMatch from being updated if the matchDetails' required attribute check has failed + if ( + seriesMatchDetails.requiredFailed === true || + seriesMatchDetails.score < highestSeriesMatchingScore + ) { + return; + } + + highestSeriesMatchingScore = seriesMatchDetails.score; + + const matchDetails = { + passed: [], + failed: [], + }; + + matchDetails.passed = matchDetails.passed.concat( + seriesMatchDetails.details.passed + ); + matchDetails.passed = matchDetails.passed.concat( + studyMatchDetails.details.passed + ); + + matchDetails.failed = matchDetails.failed.concat( + seriesMatchDetails.details.failed + ); + matchDetails.failed = matchDetails.failed.concat( + studyMatchDetails.details.failed + ); + + const totalMatchScore = + seriesMatchDetails.score + studyMatchDetails.score; + + const imageDetails = { + StudyInstanceUID: study.StudyInstanceUID, + SeriesInstanceUID: aSeries.SeriesInstanceUID, + matchingScore: totalMatchScore, + matchDetails: matchDetails, + sortingInfo: { + score: totalMatchScore, + study: study.StudyInstanceUID, + series: parseInt(aSeries.SeriesNumber), + }, + }; + + matchingScores.push(imageDetails); + }); + }); + + // Sort the matchingScores + const sortingFunction = sortBy( + { + name: 'score', + reverse: true, + }, + { + name: 'study', + reverse: true, + }, + { + name: 'series', + } + ); + matchingScores.sort((a, b) => + sortingFunction(a.sortingInfo, b.sortingInfo) + ); + + const bestMatch = matchingScores[0]; + + console.log('ProtocolEngine::matchImages bestMatch', bestMatch); + + return { + bestMatch, + matchingScores, + }; + } + + /** + * Check if the next stage is available + * @return {Boolean} True if next stage is available or false otherwise + */ + _isNextStageAvailable() { + const numberOfStages = this._getNumProtocolStages(); + + return this.stage + 1 < numberOfStages; + } + + /** + * Check if the previous stage is available + * @return {Boolean} True if previous stage is available or false otherwise + */ + _isPreviousStageAvailable() { + return this.stage - 1 >= 0; + } + + /** + * Changes the current stage to a new stage index in the display set sequence. + * It checks if the next stage exists. + * + * @param {Integer} stageAction An integer value specifying wheater next (1) or previous (-1) stage + * @return {Boolean} True if new stage has set or false, otherwise + */ + _setCurrentProtocolStage(stageAction) { + //reseting the applied protocols + this.hpAlreadyApplied = []; + // Check if previous or next stage is available + if (stageAction === -1 && !this._isPreviousStageAvailable()) { + return false; + } else if (stageAction === 1 && !this._isNextStageAvailable()) { + return false; + } + + // Sets the new stage + this.stage += stageAction; + + // Log the new stage + console.log( + `ProtocolEngine::setCurrentProtocolStage stage = ${this.stage}` + ); + + // Since stage has changed, we need to update the viewports + // and redo matchings + this._updateViewports(); + + // Everything went well + this._broadcastChange(this.EVENTS.STAGE_CHANGE, { + matchDetails: this.matchDetails, + hpAlreadyApplied: this.hpAlreadyApplied, + }); + return true; + } + /** + * 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 + */ + _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); + }); + } } } -export default { - name, - create: ({ configuration = {} }) => { - return publicAPI; - }, -}; +export default HangingProtocolService; +export { EVENTS }; diff --git a/platform/core/src/services/HangingProtocolService/ProtocolEngine.js b/platform/core/src/services/HangingProtocolService/ProtocolEngine.js new file mode 100644 index 000000000..32d5c253c --- /dev/null +++ b/platform/core/src/services/HangingProtocolService/ProtocolEngine.js @@ -0,0 +1,789 @@ +import { HPMatcher } from './HPMatcher.js'; +import { sortByScore } from './lib/sortByScore'; + +const deafultProtocol = { + id: 'defaultProtocol', + locked: true, + hasUpdatedPriorsInformation: false, + name: 'Default', + createdDate: '2021-02-23T19:22:08.894Z', + modifiedDate: '2021-02-23T19:22:08.894Z', + availableTo: {}, + editableBy: {}, + protocolMatchingRules: [], + stages: [ + { + id: 'nwzau7jDkEkL8djfr', + name: 'oneByOne', + viewportStructure: { + type: 'grid', + properties: { + rows: 1, + columns: 1, + }, + }, + viewports: [ + { + viewportSettings: [], + imageMatchingRules: [], + seriesMatchingRules: [], + studyMatchingRules: [], + }, + ], + createdDate: '2021-02-23T19:22:08.894Z', + }, + ], + numberOfPriorsReferenced: -1, +}; + +export default class ProtocolEngine { + constructor(protocols, customAttributeRetrievalCallbacks) { + this.protocols = protocols; + this.customAttributeRetrievalCallbacks = customAttributeRetrievalCallbacks; + this.matchedProtocols = new Map(); + this.matchedProtocolScores = {}; + this.study = undefined; + } + + run(studyMetaData) { + this.study = studyMetaData; + return this.getBestProtocolMatch(); + } + + // /** + // * Resets the ProtocolEngine to the best match + // */ + // reset() { + // const protocol = this.getBestProtocolMatch(); + + // this.setHangingProtocol(protocol); + // } + + /** + * Return the best matched Protocol to the current study or set of studies + * @returns {*} + */ + getBestProtocolMatch() { + // Run the matching to populate matchedProtocols Set and Map + this.updateProtocolMatches(); + + // Retrieve the highest scoring Protocol + const bestMatch = this._getHighestScoringProtocol(); + + console.log('ProtocolEngine::getBestProtocolMatch bestMatch', bestMatch); + + return bestMatch; + } + + /** + * Populates the MatchedProtocols Collection by running the matching procedure + */ + updateProtocolMatches() { + console.log('ProtocolEngine::updateProtocolMatches'); + + // Clear all data currently in matchedProtocols + this._clearMatchedProtocols(); + + // TODO: handle more than one study + const study = this.study; + const matched = this.findMatchByStudy(study); + + // For each matched protocol, check if it is already in MatchedProtocols + matched.forEach(matchedDetail => { + const protocol = matchedDetail.protocol; + if (!protocol) { + return; + } + + // If it is not already in the MatchedProtocols Collection, insert it with its score + if (!this.matchedProtocols.has(protocol.id)) { + console.log( + 'ProtocolEngine::updateProtocolMatches inserting protocol match', + matchedDetail + ); + this.matchedProtocols.set(protocol.id, protocol); + this.matchedProtocolScores[protocol.id] = matchedDetail.score; + } + }); + } + + findMatch(metaData, rules) { + return HPMatcher.match( + metaData, + rules, + this.customAttributeRetrievalCallbacks + ); + } + + /** + * Finds the best protocols from Protocol Store, matching each protocol matching rules + * with the given study. The best protocol are orded by score and returned in an array + * @param {Object} study StudyMetadata instance object + * @return {Array} Array of match objects or an empty array if no match was found + * Each match object has the score of the matching and the matched + * protocol + */ + findMatchByStudy(study) { + const matched = []; + + this.protocols.forEach(protocol => { + // Clone the protocol's protocolMatchingRules array + // We clone it so that we don't accidentally add the + // numberOfPriorsReferenced rule to the Protocol itself. + let rules = protocol.protocolMatchingRules.slice(); + if (!rules) { + return; + } + + // Run the matcher and get matching details + const matchedDetails = this.findMatch(study, rules); + const score = matchedDetails.score; + + // The protocol matched some rule, add it to the matched list + if (score > 0) { + matched.push({ + score, + protocol, + }); + } + }); + + // If no matches were found, select the default protocol + if (!matched.length) { + return [ + { + score: 1, + protocol: deafultProtocol, + }, + ]; + } + + // Sort the matched list by score + sortByScore(matched); + + console.log('ProtocolEngine::findMatchByStudy matched', matched); + + return matched; + } + + _clearMatchedProtocols() { + this.matchedProtocols.clear(); + this.matchedProtocolScores = {}; + } + + _largestKeyByValue(obj) { + return Object.keys(obj).reduce((a, b) => (obj[a] > obj[b] ? a : b)); + } + + _getHighestScoringProtocol() { + if (!Object.keys(this.matchedProtocolScores).length) { + return; + } + const highestScoringProtocolId = this._largestKeyByValue( + this.matchedProtocolScores + ); + return this.matchedProtocols.get(highestScoringProtocolId); + } +} + +/** + * Resets the ProtocolEngine to the best match + */ + +/** + * Retrieves the current Stage from the current Protocol and stage index + * + * @returns {*} The Stage model for the currently displayed Stage + */ +// getCurrentStageModel() { +// return this.protocol.stages[this.stage]; +// } + +// /** +// * Get the number of prior studies supplied in the priorStudies map property. +// * +// * @param {String} studyObjectID The study object ID of the study whose priors are needed +// * @returns {number} The number of available prior studies with the same PatientID +// */ +// getNumberOfAvailablePriors(studyObjectID) { +// return this.getAvailableStudyPriors(studyObjectID).length; +// } + +// /** +// * Get the array of prior studies from a specific study. +// * +// * @param {String} studyObjectID The study object ID of the study whose priors are needed +// * @returns {Array} The array of available priors or an empty array +// */ +// getAvailableStudyPriors(studyObjectID) { +// const priors = this.priorStudies.get(studyObjectID); + +// return priors instanceof Array ? priors : []; +// } + +// // Match images given a list of Studies and a Viewport's image matching reqs +// matchImages(viewport, viewportIndex) { +// log.trace('ProtocolEngine::matchImages'); + +// const { +// studyMatchingRules, +// seriesMatchingRules, +// imageMatchingRules: instanceMatchingRules, +// } = viewport; + +// const matchingScores = []; +// const currentStudy = this.studies[0]; // @TODO: Should this be: this.studies[this.currentStudy] ??? +// const firstInstance = currentStudy.getFirstInstance(); + +// let highestStudyMatchingScore = 0; +// let highestSeriesMatchingScore = 0; + +// // Set custom attribute for study metadata and it's first instance +// currentStudy.setCustomAttribute(ABSTRACT_PRIOR_VALUE, 0); +// if (firstInstance instanceof InstanceMetadata) { +// firstInstance.setCustomAttribute(ABSTRACT_PRIOR_VALUE, 0); +// } + +// // Only used if study matching rules has abstract prior values defined... +// let priorStudies; + +// studyMatchingRules.forEach(rule => { +// if (rule.attribute === ABSTRACT_PRIOR_VALUE) { +// const validatorType = Object.keys(rule.constraint)[0]; +// const validator = Object.keys(rule.constraint[validatorType])[0]; + +// let abstractPriorValue = rule.constraint[validatorType][validator]; +// abstractPriorValue = parseInt(abstractPriorValue, 10); +// // TODO: Restrict or clarify validators for abstractPriorValue? + +// // No need to call it more than once... +// if (!priorStudies) { +// priorStudies = this.getAvailableStudyPriors( +// currentStudy.getObjectID() +// ); +// } + +// // TODO: Revisit this later: What about two studies with the same +// // study date? + +// let priorStudy; +// if (abstractPriorValue === -1) { +// priorStudy = priorStudies[priorStudies.length - 1]; +// } else { +// const studyIndex = Math.max(abstractPriorValue - 1, 0); +// priorStudy = priorStudies[studyIndex]; +// } + +// // Invalid data +// if (!priorStudy instanceof StudyMetadata) { +// return; +// } + +// const priorStudyObjectID = priorStudy.getObjectID(); + +// // Check if study metadata is already in studies list +// if ( +// this.studies.find(study => study.getObjectID() === priorStudyObjectID) +// ) { +// return; +// } + +// // Get study metadata if necessary and load study in the viewer (each viewer should provide it's own load study method) +// this.studyMetadataSource.loadStudy(priorStudy).then( +// studyMetadata => { +// // Set the custom attribute abstractPriorValue for the study metadata +// studyMetadata.setCustomAttribute( +// ABSTRACT_PRIOR_VALUE, +// abstractPriorValue +// ); + +// // Also add custom attribute +// const firstInstance = studyMetadata.getFirstInstance(); +// if (firstInstance instanceof InstanceMetadata) { +// firstInstance.setCustomAttribute( +// ABSTRACT_PRIOR_VALUE, +// abstractPriorValue +// ); +// } + +// // Insert the new study metadata +// this.studies.push(studyMetadata); + +// // Update the viewport to refresh layout manager with new study +// this.updateViewports(viewportIndex); +// }, +// error => { +// log.warn(error); +// throw new OHIFError( +// `ProtocolEngine::matchImages could not get study metadata for the Study with the following ObjectID: ${priorStudyObjectID}` +// ); +// } +// ); +// } +// // TODO: Add relative Date / time +// }); + +// this.studies.forEach(study => { +// const studyMatchDetails = HPMatcher.match( +// study.getFirstInstance(), +// studyMatchingRules +// ); + +// // Prevent bestMatch from being updated if the matchDetails' required attribute check has failed +// if ( +// studyMatchDetails.requiredFailed === true || +// studyMatchDetails.score < highestStudyMatchingScore +// ) { +// return; +// } + +// highestStudyMatchingScore = studyMatchDetails.score; + +// study.forEachSeries(series => { +// const seriesMatchDetails = HPMatcher.match( +// series.getFirstInstance(), +// seriesMatchingRules +// ); + +// // Prevent bestMatch from being updated if the matchDetails' required attribute check has failed +// if ( +// seriesMatchDetails.requiredFailed === true || +// seriesMatchDetails.score < highestSeriesMatchingScore +// ) { +// return; +// } + +// highestSeriesMatchingScore = seriesMatchDetails.score; + +// series.forEachInstance((instance, index) => { +// // This tests to make sure there is actually image data in this instance +// // TODO: Change this when we add PDF and MPEG support +// // See https://ohiforg.atlassian.net/browse/LT-227 +// if ( +// !isImage(instance.getTagValue('SOPClassUID')) && +// !instance.getTagValue('Rows') +// ) { +// return; +// } + +// const instanceMatchDetails = HPMatcher.match( +// instance, +// instanceMatchingRules +// ); + +// // Prevent bestMatch from being updated if the matchDetails' required attribute check has failed +// if (instanceMatchDetails.requiredFailed === true) { +// return; +// } + +// const matchDetails = { +// passed: [], +// failed: [], +// }; + +// matchDetails.passed = matchDetails.passed.concat( +// instanceMatchDetails.details.passed +// ); +// matchDetails.passed = matchDetails.passed.concat( +// seriesMatchDetails.details.passed +// ); +// matchDetails.passed = matchDetails.passed.concat( +// studyMatchDetails.details.passed +// ); + +// matchDetails.failed = matchDetails.failed.concat( +// instanceMatchDetails.details.failed +// ); +// matchDetails.failed = matchDetails.failed.concat( +// seriesMatchDetails.details.failed +// ); +// matchDetails.failed = matchDetails.failed.concat( +// studyMatchDetails.details.failed +// ); + +// const totalMatchScore = +// instanceMatchDetails.score + +// seriesMatchDetails.score + +// studyMatchDetails.score; +// const currentSOPInstanceUID = instance.getSOPInstanceUID(); + +// const imageDetails = { +// StudyInstanceUID: study.getStudyInstanceUID(), +// SeriesInstanceUID: series.getSeriesInstanceUID(), +// SOPInstanceUID: currentSOPInstanceUID, +// currentImageIdIndex: index, +// matchingScore: totalMatchScore, +// matchDetails: matchDetails, +// sortingInfo: { +// score: totalMatchScore, +// study: +// instance.getTagValue('StudyDate') + +// instance.getTagValue('StudyTime'), +// series: parseInt(instance.getTagValue('SeriesNumber')), // TODO: change for seriesDateTime +// instance: parseInt(instance.getTagValue('InstanceNumber')), // TODO: change for acquisitionTime +// }, +// }; + +// // Find the displaySet +// const displaySet = study.findDisplaySet(displaySet => +// displaySet.images.find( +// image => image.getSOPInstanceUID() === currentSOPInstanceUID +// ) +// ); + +// // If the instance was found, set the displaySet ID +// if (displaySet) { +// imageDetails.displaySetInstanceUID = displaySet.getUID(); +// imageDetails.imageId = instance.getImageId(); +// } + +// matchingScores.push(imageDetails); +// }); +// }); +// }); + +// // Sort the matchingScores +// const sortingFunction = sortBy( +// { +// name: 'score', +// reverse: true, +// }, +// { +// name: 'study', +// reverse: true, +// }, +// { +// name: 'instance', +// }, +// { +// name: 'series', +// } +// ); +// matchingScores.sort((a, b) => +// sortingFunction(a.sortingInfo, b.sortingInfo) +// ); + +// const bestMatch = matchingScores[0]; + +// log.trace('ProtocolEngine::matchImages bestMatch', bestMatch); + +// return { +// bestMatch, +// matchingScores, +// }; +// } + +/** + * Sets the current layout + * + * @param {number} numRows + * @param {number} numColumns + */ +// setLayout(numRows, numColumns) { +// if (numRows < 1 && numColumns < 1) { +// log.error(`Invalid layout ${numRows} x ${numColumns}`); +// return; +// } + +// if (typeof this.options.setLayout !== 'function') { +// log.error('Hanging Protocol Engine setLayout callback is not defined'); +// return; +// } + +// let viewports = []; +// const numViewports = numRows * numColumns; + +// for (let i = 0; i < numViewports; i++) { +// viewports.push({}); +// } + +// this.options.setLayout({ numRows, numColumns, viewports }); +// } + +// /** +// * Rerenders viewports that are part of the current layout manager +// * using the matching rules internal to each viewport. +// * +// * If this function is provided the index of a viewport, only the specified viewport +// * is rerendered. +// * +// * @param viewportIndex +// */ +// updateViewports(viewportIndex) { +// log.trace( +// `ProtocolEngine::updateViewports viewportIndex: ${viewportIndex}` +// ); + +// // Make sure we have an active protocol with a non-empty array of display sets +// if (!this.getNumProtocolStages()) { +// return; +// } + +// // Retrieve the current stage +// const stageModel = this.getCurrentStageModel(); + +// // If the current stage does not fulfill the requirements to be displayed, +// // stop here. +// if ( +// !stageModel || +// !stageModel.viewportStructure || +// !stageModel.viewports || +// !stageModel.viewports.length +// ) { +// return; +// } + +// // Retrieve the layoutTemplate associated with the current display set's viewport structure +// // If no such template name exists, stop here. +// const layoutTemplateName = stageModel.viewportStructure.getLayoutTemplateName(); +// if (!layoutTemplateName) { +// return; +// } + +// // Retrieve the properties associated with the current display set's viewport structure template +// // If no such layout properties exist, stop here. +// const layoutProps = stageModel.viewportStructure.properties; +// if (!layoutProps) { +// return; +// } + +// // Create an empty array to store the output viewportData +// const viewportData = []; + +// // Empty the matchDetails associated with the ProtocolEngine. +// // This will be used to store the pass/fail details and score +// // for each of the viewport matching procedures +// this.matchDetails = []; + +// // Loop through each viewport +// stageModel.viewports.forEach((viewport, viewportIndex) => { +// const details = this.matchImages(viewport, viewportIndex); + +// this.matchDetails[viewportIndex] = details; + +// // Convert any YES/NO values into true/false for Cornerstone +// const cornerstoneViewportParams = {}; + +// // Cache viewportSettings keys +// const viewportSettingsKeys = Object.keys(viewport.viewportSettings); + +// viewportSettingsKeys.forEach(key => { +// let value = viewport.viewportSettings[key]; +// if (value === 'YES') { +// value = true; +// } else if (value === 'NO') { +// value = false; +// } + +// cornerstoneViewportParams[key] = value; +// }); + +// // imageViewerViewports occasionally needs relevant layout data in order to set +// // the element style of the viewport in question +// const currentViewportData = { +// viewportIndex, +// viewport: cornerstoneViewportParams, +// ...layoutProps, +// }; + +// const customSettings = []; +// viewportSettingsKeys.forEach(id => { +// const setting = CustomViewportSettings[id]; +// if (!setting) { +// return; +// } + +// customSettings.push({ +// id: id, +// value: viewport.viewportSettings[id], +// }); +// }); + +// currentViewportData.renderedCallback = element => { +// //console.log('renderedCallback for ' + element.id); +// customSettings.forEach(customSetting => { +// log.trace( +// `ProtocolEngine::currentViewportData.renderedCallback Applying custom setting: ${customSetting.id}` +// ); +// log.trace( +// `ProtocolEngine::currentViewportData.renderedCallback with value: ${customSetting.value}` +// ); + +// const setting = CustomViewportSettings[customSetting.id]; +// setting.callback(element, customSetting.value); +// }); +// }; + +// let currentMatch = details.bestMatch; +// let currentPosition = 1; +// const scoresLength = details.matchingScores.length; +// while ( +// currentPosition < scoresLength && +// viewportData.find(a => a.imageId === currentMatch.imageId) +// ) { +// currentMatch = details.matchingScores[currentPosition]; +// currentPosition++; +// } + +// if (currentMatch && currentMatch.imageId) { +// currentViewportData.StudyInstanceUID = currentMatch.StudyInstanceUID; +// currentViewportData.SeriesInstanceUID = currentMatch.SeriesInstanceUID; +// currentViewportData.SOPInstanceUID = currentMatch.SOPInstanceUID; +// currentViewportData.currentImageIdIndex = +// currentMatch.currentImageIdIndex; +// currentViewportData.displaySetInstanceUID = +// currentMatch.displaySetInstanceUID; +// currentViewportData.imageId = currentMatch.imageId; +// } + +// // @TODO Why should we throw an exception when a best match is not found? This was aborting the whole process. +// // if (!currentViewportData.displaySetInstanceUID) { +// // throw new OHIFError('ProtocolEngine::updateViewports No matching display set found?'); +// // } + +// viewportData.push(currentViewportData); +// }); + +// this.setLayout(layoutProps.Rows, layoutProps.Columns); + +// if (typeof this.options.setViewportSpecificData !== 'function') { +// log.error( +// 'Hanging Protocol Engine setViewportSpecificData callback is not defined' +// ); +// return; +// } + +// // If viewportIndex is defined, then update only that viewport +// if (viewportIndex !== undefined && viewportData[viewportIndex]) { +// this.options.setViewportSpecificData( +// viewportIndex, +// viewportData[viewportIndex] +// ); +// return; +// } + +// // Update all viewports +// viewportData.forEach(viewportSpecificData => { +// this.options.setViewportSpecificData( +// viewportSpecificData.viewportIndex, +// viewportSpecificData +// ); +// }); +// } + +// /** +// * Sets the current Hanging Protocol to the specified Protocol +// * An optional argument can also be used to prevent the updating of the Viewports +// * +// * @param newProtocol +// * @param updateViewports +// */ +// setHangingProtocol(newProtocol, updateViewports = true) { +// log.trace('ProtocolEngine::setHangingProtocol newProtocol', newProtocol); +// log.trace( +// `ProtocolEngine::setHangingProtocol updateViewports = ${updateViewports}` +// ); + +// // Reset the array of newStageIds +// this.newStageIds = []; + +// if (Protocol.prototype.isPrototypeOf(newProtocol)) { +// this.protocol = newProtocol; +// } else { +// this.protocol = new Protocol(); +// this.protocol.fromObject(newProtocol); +// } + +// this.stage = 0; + +// // Update viewports by default +// if (updateViewports) { +// this.updateViewports(); +// } +// } + +// /** +// * Check if the next stage is available +// * @return {Boolean} True if next stage is available or false otherwise +// */ +// isNextStageAvailable() { +// const numberOfStages = this.getNumProtocolStages(); + +// return this.stage + 1 < numberOfStages; +// } + +// /** +// * Check if the previous stage is available +// * @return {Boolean} True if previous stage is available or false otherwise +// */ +// isPreviousStageAvailable() { +// return this.stage - 1 >= 0; +// } + +// /** +// * Changes the current stage to a new stage index in the display set sequence. +// * It checks if the next stage exists. +// * +// * @param {Integer} stageAction An integer value specifying wheater next (1) or previous (-1) stage +// * @return {Boolean} True if new stage has set or false, otherwise +// */ +// setCurrentProtocolStage(stageAction) { +// // Check if previous or next stage is available +// if (stageAction === -1 && !this.isPreviousStageAvailable()) { +// return false; +// } else if (stageAction === 1 && !this.isNextStageAvailable()) { +// return false; +// } + +// // Sets the new stage +// this.stage += stageAction; + +// // Log the new stage +// log.trace(`ProtocolEngine::setCurrentProtocolStage stage = ${this.stage}`); + +// // Since stage has changed, we need to update the viewports +// // and redo matchings +// this.updateViewports(); + +// // Everything went well +// return true; +// } + +// /** +// * Retrieves the number of Stages in the current Protocol or +// * undefined if no protocol or stages are set +// */ +// getNumProtocolStages() { +// if ( +// !this.protocol || +// !this.protocol.stages || +// !this.protocol.stages.length +// ) { +// return; +// } + +// return this.protocol.stages.length; +// } + +// /** +// * Switches to the next protocol stage in the display set sequence +// */ +// nextProtocolStage() { +// log.trace('ProtocolEngine::nextProtocolStage'); + +// if (!this.setCurrentProtocolStage(1)) { +// log.trace('ProtocolEngine::nextProtocolStage failed'); +// } +// } + +// /** +// * Switches to the previous protocol stage in the display set sequence +// */ +// previousProtocolStage() { +// log.trace('ProtocolEngine::previousProtocolStage'); + +// if (!this.setCurrentProtocolStage(-1)) { +// log.trace('ProtocolEngine::previousProtocolStage failed'); +// } +// } +// } diff --git a/platform/core/src/services/HangingProtocolService/index.js b/platform/core/src/services/HangingProtocolService/index.js index 2d5a41716..db50eaee3 100644 --- a/platform/core/src/services/HangingProtocolService/index.js +++ b/platform/core/src/services/HangingProtocolService/index.js @@ -1,3 +1,8 @@ import HangingProtocolService from './HangingProtocolService'; -export default HangingProtocolService; +export default { + name: 'HangingProtocolService', + create: ({ configuration = {}, commandsManager }) => { + return new HangingProtocolService(commandsManager); + }, +}; diff --git a/platform/core/src/services/HangingProtocolService/lib/comparators.js b/platform/core/src/services/HangingProtocolService/lib/comparators.js new file mode 100644 index 000000000..ce76a4d82 --- /dev/null +++ b/platform/core/src/services/HangingProtocolService/lib/comparators.js @@ -0,0 +1,98 @@ +const comparators = [ + { + id: 'equals', + name: '= (Equals)', + validator: 'equals', + validatorOption: 'value', + description: 'The attribute must equal this value.', + }, + { + id: 'doesNotEqual', + name: '!= (Does not equal)', + validator: 'doesNotEqual', + validatorOption: 'value', + description: 'The attribute must not equal this value.', + }, + { + id: 'contains', + name: 'Contains', + validator: 'contains', + validatorOption: 'value', + description: 'The attribute must contain this value.', + }, + { + id: 'doesNotContain', + name: 'Does not contain', + validator: 'doesNotContain', + validatorOption: 'value', + description: 'The attribute must not contain this value.', + }, + { + id: 'startsWith', + name: 'Starts with', + validator: 'startsWith', + validatorOption: 'value', + description: 'The attribute must start with this value.', + }, + { + id: 'endsWith', + name: 'Ends with', + validator: 'endsWith', + validatorOption: 'value', + description: 'The attribute must end with this value.', + }, + { + id: 'onlyInteger', + name: 'Only Integers', + validator: 'numericality', + validatorOption: 'onlyInteger', + description: "Real numbers won't be allowed.", + }, + { + id: 'greaterThan', + name: '> (Greater than)', + validator: 'numericality', + validatorOption: 'greaterThan', + description: 'The attribute has to be greater than this value.', + }, + { + id: 'greaterThanOrEqualTo', + name: '>= (Greater than or equal to)', + validator: 'numericality', + validatorOption: 'greaterThanOrEqualTo', + description: 'The attribute has to be at least this value.', + }, + { + id: 'lessThanOrEqualTo', + name: '<= (Less than or equal to)', + validator: 'numericality', + validatorOption: 'lessThanOrEqualTo', + description: 'The attribute can be this value at the most.', + }, + { + id: 'lessThan', + name: '< (Less than)', + validator: 'numericality', + validatorOption: 'lessThan', + description: 'The attribute has to be less than this value.', + }, + { + id: 'odd', + name: 'Odd', + validator: 'numericality', + validatorOption: 'odd', + description: 'The attribute has to be odd.', + }, + { + id: 'even', + name: 'Even', + validator: 'numericality', + validatorOption: 'even', + description: 'The attribute has to be even.', + }, +]; + +// Immutable object +Object.freeze(comparators); + +export { comparators }; diff --git a/platform/core/src/services/HangingProtocolService/lib/displayConstraint.js b/platform/core/src/services/HangingProtocolService/lib/displayConstraint.js new file mode 100644 index 000000000..17368c877 --- /dev/null +++ b/platform/core/src/services/HangingProtocolService/lib/displayConstraint.js @@ -0,0 +1,71 @@ +const attributeCache = Object.create(null); +const REGEXP = /^\([x0-9a-f]+\)/; + +const humanize = text => { + let humanized = text.replace(/([A-Z])/g, ' $1'); // insert a space before all caps + + humanized = humanized.replace(/^./, str => { + // uppercase the first character + return str.toUpperCase(); + }); + + return humanized; +}; + +/** + * Get the text of an attribute for a given attribute + * @param {String} attributeId The attribute ID + * @param {Array} attributes Array of attributes objects with id and text properties + * @return {String} If found return the attribute text or an empty string otherwise + */ +const getAttributeText = (attributeId, attributes) => { + // If the attribute is already in the cache, return it + if (attributeId in attributeCache) { + return attributeCache[attributeId]; + } + + // Find the attribute with given attributeId + const attribute = attributes.find(attribute => attribute.id === attributeId); + + let attributeText; + + // If attribute was found get its text and save it on the cache + if (attribute) { + attributeText = attribute.text.replace(REGEXP, ''); + attributeCache[attributeId] = attributeText; + } + + return attributeText || ''; +}; + +function displayConstraint(attributeId, constraint, attributes) { + if (!constraint || !attributeId) { + return; + } + + const validatorType = Object.keys(constraint)[0]; + if (!validatorType) { + return; + } + + const validator = Object.keys(constraint[validatorType])[0]; + if (!validator) { + return; + } + + const value = constraint[validatorType][validator]; + if (value === void 0) { + return; + } + + let comparator = validator; + if (validator === 'value') { + comparator = validatorType; + } + + const attributeText = getAttributeText(attributeId, attributes); + const constraintText = + attributeText + ' ' + humanize(comparator).toLowerCase() + ' ' + value; + + return constraintText; +} diff --git a/platform/core/src/services/HangingProtocolService/lib/removeFromArray.js b/platform/core/src/services/HangingProtocolService/lib/removeFromArray.js new file mode 100644 index 000000000..602cc4158 --- /dev/null +++ b/platform/core/src/services/HangingProtocolService/lib/removeFromArray.js @@ -0,0 +1,32 @@ +/** + * Removes the first instance of an element from an array, if an equal value exists + * + * @param array + * @param input + * + * @returns {boolean} Whether or not the element was found and removed + */ +const removeFromArray = (array, input) => { + // If the array is empty, stop here + if (!array || !array.length) { + return false; + } + + array.forEach((value, index) => { + // TODO: Double check whether or not this deep equality check is necessary + //if (_.isEqual(value, input)) { + if (value === input) { + indexToRemove = index; + return false; + } + }); + + if (indexToRemove === void 0) { + return false; + } + + array.splice(indexToRemove, 1); + return true; +}; + +export { removeFromArray }; diff --git a/platform/core/src/services/HangingProtocolService/lib/sortByScore.js b/platform/core/src/services/HangingProtocolService/lib/sortByScore.js new file mode 100644 index 000000000..902072591 --- /dev/null +++ b/platform/core/src/services/HangingProtocolService/lib/sortByScore.js @@ -0,0 +1,8 @@ +// Sorts an array by score +const sortByScore = arr => { + arr.sort((a, b) => { + return b.score - a.score; + }); +}; + +export { sortByScore }; diff --git a/platform/core/src/services/HangingProtocolService/lib/validator.js b/platform/core/src/services/HangingProtocolService/lib/validator.js new file mode 100644 index 000000000..454143a03 --- /dev/null +++ b/platform/core/src/services/HangingProtocolService/lib/validator.js @@ -0,0 +1,39 @@ +import validate from 'validate.js'; + +validate.validators.equals = function (value, options, key, attributes) { + if (options && value !== options.value) { + return key + 'must equal ' + options.value; + } +}; + +validate.validators.doesNotEqual = function (value, options, key) { + if (options && value === options.value) { + return key + 'cannot equal ' + options.value; + } +}; + +validate.validators.contains = function (value, options, key) { + if (options && value.indexOf && value.indexOf(options.value) === -1) { + return key + 'must contain ' + options.value; + } +}; + +validate.validators.doesNotContain = function (value, options, key) { + if (options && value.indexOf && value.indexOf(options.value) !== -1) { + return key + 'cannot contain ' + options.value; + } +}; + +validate.validators.startsWith = function (value, options, key) { + if (options && value.startsWith && !value.startsWith(options.value)) { + return key + 'must start with ' + options.value; + } +}; + +validate.validators.endsWith = function (value, options, key) { + if (options && value.endsWith && !value.endsWith(options.value)) { + return key + 'must end with ' + options.value; + } +}; + +export default validate; diff --git a/platform/core/src/services/MeasurementService/MeasurementService.js b/platform/core/src/services/MeasurementService/MeasurementService.js index e5d77f7a1..c4abff9fc 100644 --- a/platform/core/src/services/MeasurementService/MeasurementService.js +++ b/platform/core/src/services/MeasurementService/MeasurementService.js @@ -54,6 +54,7 @@ const EVENTS = { MEASUREMENT_UPDATED: 'event::measurement_updated', INTERNAL_MEASUREMENT_UPDATED: 'event:internal_measurement_updated', MEASUREMENT_ADDED: 'event::measurement_added', + RAW_MEASUREMENT_ADDED: 'event::raw_measurement_added', MEASUREMENT_REMOVED: 'event::measurement_removed', MEASUREMENTS_CLEARED: 'event::measurements_cleared', JUMP_TO_MEASUREMENT: 'event:jump_to_measurement', @@ -314,11 +315,17 @@ class MeasurementService { * Converted to/from annotation in the same way. E.g. import serialized data * Of the same form as the measurement source. * @param {MeasurementSource} source The measurement source instance. - * @param {string} definition The source definition you want to add the measuremnet to. + * @param {string} definition The source definition you want to add the measurement to. * @param {object} data The data you wish to add to the source. * @param {function} toMeasurementSchema A function to get the `data` into the same shape as the source definition. */ - addRawMeasurement(source, definition, data, toMeasurementSchema) { + addRawMeasurement( + source, + definition, + data, + toMeasurementSchema, + dataSource = {} + ) { if (!this._isValidSource(source)) { log.warn('Invalid source. Exiting early.'); return; @@ -342,7 +349,6 @@ class MeasurementService { try { /* Convert measurement */ measurement = toMeasurementSchema(data); - /* Assign measurement source instance */ measurement.source = source; } catch (error) { @@ -385,9 +391,11 @@ class MeasurementService { } else { log.info(`Measurement added.`, newMeasurement); this.measurements[internalId] = newMeasurement; - this._broadcastEvent(this.EVENTS.MEASUREMENT_ADDED, { + this._broadcastEvent(this.EVENTS.RAW_MEASUREMENT_ADDED, { source, measurement: newMeasurement, + data, + dataSource, }); } @@ -501,6 +509,7 @@ class MeasurementService { clearMeasurements() { this.measurements = {}; + this._jumpToMeasurementCache = {}; this._broadcastEvent(this.EVENTS.MEASUREMENTS_CLEARED); } diff --git a/platform/core/src/services/ToolBarService/ToolBarService.js b/platform/core/src/services/ToolBarService/ToolBarService.js index fcb061443..ce88492e8 100644 --- a/platform/core/src/services/ToolBarService/ToolBarService.js +++ b/platform/core/src/services/ToolBarService/ToolBarService.js @@ -13,6 +13,7 @@ export default class ToolBarService { this.EVENTS = EVENTS; this.listeners = {}; this.buttons = {}; + this.unsubscriptions = []; // if tools need to unsubscribe from events this.buttonSections = { /** * primary: ['Zoom', 'Wwwc'], @@ -39,6 +40,16 @@ export default class ToolBarService { this.extensionManager = extensionManager; } + reset() { + this.unsubscriptions.forEach(unsub => unsub()); + this.state = { + primaryToolId: 'Wwwc', + toggles: {}, + groups: {}, + }; + this.unsubscriptions = []; + } + /** * * @param {*} interaction @@ -63,6 +74,7 @@ export default class ToolBarService { this.state.toggles[itemId] === undefined ? true : !this.state.toggles[itemId]; + interaction.commandOptions.toggledState = this.state.toggles[itemId]; break; } default: @@ -73,13 +85,21 @@ export default class ToolBarService { // // NOTE: Should probably just do this for tools as well? // But would be nice if we could enforce at least the command name? + let unsubscribe; if (interaction.commandName) { - commandsManager.runCommand( + unsubscribe = commandsManager.runCommand( interaction.commandName, interaction.commandOptions ); } + // Storing the unsubscribe for later reseting + if (unsubscribe && typeof unsubscribe === 'function') { + if (this.unsubscriptions.indexOf(unsubscribe) === -1) { + this.unsubscriptions.push(unsubscribe); + } + } + // Track last touched id for each group if (groupId) { this.state.groups[groupId] = itemId; diff --git a/platform/core/src/services/index.js b/platform/core/src/services/index.js index 85eb748ac..2f60e2966 100644 --- a/platform/core/src/services/index.js +++ b/platform/core/src/services/index.js @@ -10,6 +10,7 @@ import ToolBarService from './ToolBarService'; import ViewportGridService from './ViewportGridService'; import CineService from './CineService'; import HangingProtocolService from './HangingProtocolService'; +import pubSubServiceInterface from './_shared/pubSubServiceInterface'; export { MeasurementService, @@ -23,5 +24,6 @@ export { ToolBarService, ViewportGridService, HangingProtocolService, - CineService + CineService, + pubSubServiceInterface, }; diff --git a/platform/core/src/utils/StackManager.js b/platform/core/src/utils/StackManager.js index 241f0f764..942928292 100644 --- a/platform/core/src/utils/StackManager.js +++ b/platform/core/src/utils/StackManager.js @@ -22,6 +22,7 @@ function createAndAddStack( StudyInstanceUID, frameRate, isClip, + initialImageIdIndex, } = displaySet; if (!images) { return; @@ -35,6 +36,7 @@ function createAndAddStack( imageIds, frameRate, isClip, + initialImageIdIndex, }; stackMap[displaySetInstanceUID] = stack; diff --git a/platform/core/src/utils/sortBySeriesDate.js b/platform/core/src/utils/sortBySeriesDate.js index 684a85e0e..1a694934c 100644 --- a/platform/core/src/utils/sortBySeriesDate.js +++ b/platform/core/src/utils/sortBySeriesDate.js @@ -2,15 +2,16 @@ * Sorting function * Sorts an array by seriesDate and seriesNumber if equal */ -const sortBySeriesDate = array => array.sort((a, b) => { - if (a.seriesNumber !== b.seriesNumber) { - return a.seriesNumber - b.seriesNumber; - } +const sortBySeriesDate = array => + array.sort((a, b) => { + if (a.seriesNumber !== b.seriesNumber) { + return a.seriesNumber - b.seriesNumber; + } - const seriesDateA = Date.parse(a.seriesDate); - const seriesDateB = Date.parse(b.seriesDate); + const seriesDateA = Date.parse(a.seriesDate); + const seriesDateB = Date.parse(b.seriesDate); - return seriesDateA - seriesDateB; -}); + return seriesDateA - seriesDateB; + }); export default sortBySeriesDate; diff --git a/platform/ui/index.js b/platform/ui/index.js index bd1ace9d0..77e5a7396 100644 --- a/platform/ui/index.js +++ b/platform/ui/index.js @@ -25,9 +25,6 @@ export { ViewportGridContext, ViewportGridProvider, useViewportGrid, - HangingProtocolContext, - HangingProtocolProvider, - useHangingProtocol, } from './src/contextProviders'; /** COMPONENTS */ diff --git a/platform/ui/src/components/Header/Header.jsx b/platform/ui/src/components/Header/Header.jsx index dae607334..34cc31213 100644 --- a/platform/ui/src/components/Header/Header.jsx +++ b/platform/ui/src/components/Header/Header.jsx @@ -6,7 +6,7 @@ import classNames from 'classnames'; import { useHistory } from 'react-router-dom'; import { NavBar, Svg, Icon, IconButton, Dropdown } from '@ohif/ui'; -function Header({ children, menuOptions, isReturnEnabled, isSticky }) { +function Header({ children, menuOptions, isReturnEnabled, isSticky, WhiteLabeling }) { const { t } = useTranslation(); const history = useHistory(); @@ -18,6 +18,10 @@ function Header({ children, menuOptions, isReturnEnabled, isSticky }) { } }; + const CustomLogo = (React) => { + return WhiteLabeling.createLogoComponentFn(React) + } + return (
@@ -29,7 +33,7 @@ function Header({ children, menuOptions, isReturnEnabled, isSticky }) { onClick={onReturnHandler} > {isReturnEnabled && } -
+
{WhiteLabeling ? CustomLogo(React) : }
{children}
@@ -73,7 +77,8 @@ Header.propTypes = { ), children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]), isReturnEnabled: PropTypes.bool, - isSticky: PropTypes.bool + isSticky: PropTypes.bool, + WhiteLabeling: PropTypes.element, }; Header.defaultProps = { diff --git a/platform/ui/src/components/SidePanel/SidePanel.jsx b/platform/ui/src/components/SidePanel/SidePanel.jsx index a858c98a4..5d6dd4626 100644 --- a/platform/ui/src/components/SidePanel/SidePanel.jsx +++ b/platform/ui/src/components/SidePanel/SidePanel.jsx @@ -143,7 +143,7 @@ const SidePanel = ({
- + ) : ( {getPanelButtons()} diff --git a/platform/ui/src/components/ViewportActionBar/ViewportActionBar.jsx b/platform/ui/src/components/ViewportActionBar/ViewportActionBar.jsx index b95c92a4d..7a3885019 100644 --- a/platform/ui/src/components/ViewportActionBar/ViewportActionBar.jsx +++ b/platform/ui/src/components/ViewportActionBar/ViewportActionBar.jsx @@ -14,6 +14,7 @@ const classes = { const ViewportActionBar = ({ studyData, showNavArrows, + showStatus, showCine, cineProps, showPatientInfo: patientInfoVisibility, @@ -255,7 +256,7 @@ const ViewportActionBar = ({
{label} - {renderIconStatus()} + {showStatus && renderIconStatus()}
@@ -352,6 +353,7 @@ ViewportActionBar.propTypes = { ViewportActionBar.defaultProps = { cineProps: {}, showCine: false, + showStatus: true, showNavArrows: true, showPatientInfo: false, }; diff --git a/platform/ui/src/contextProviders/HangingProtocolProvider.jsx b/platform/ui/src/contextProviders/HangingProtocolProvider.jsx deleted file mode 100644 index 544ff95c5..000000000 --- a/platform/ui/src/contextProviders/HangingProtocolProvider.jsx +++ /dev/null @@ -1,153 +0,0 @@ -import React, { - createContext, - useCallback, - useContext, - useEffect, - useReducer, -} from 'react'; -import PropTypes from 'prop-types'; - -const DEFAULT_STATE = { - hangingProtocol: null, - hpAlreadyApplied: {}, -}; - -export const HangingProtocolContext = createContext(DEFAULT_STATE); - -export function HangingProtocolProvider({ children, service }) { - const hangingProtocolReducer = (state, action) => { - switch (action.type) { - case 'SET_HANGING_PROTOCOL': { - return { - ...state, - ...{ hangingProtocol: action.payload.hangingProtocol }, - }; - } - case 'SET_HANGING_PROTOCOL_APPLIED_FOR_VIEWPORT': { - const index = action.payload.index; - const newHPAlreadyApplied = Object.assign({}, state.hpAlreadyApplied); - - newHPAlreadyApplied[index] = true; - - return { - ...state, - ...{ hpAlreadyApplied: newHPAlreadyApplied }, - }; - } - case 'RESET': { - return { - hangingProtocol: null, - hpAlreadyApplied: {}, - }; - } - - case 'SET': { - return { - ...state, - ...action.payload, - }; - } - - default: - return action.payload; - } - }; - - const [hangingProtocolState, dispatch] = useReducer( - hangingProtocolReducer, - DEFAULT_STATE - ); - - console.log('hangingProtocolState', hangingProtocolState); - - const getState = useCallback(() => hangingProtocolState, [ - hangingProtocolState, - ]); - - const setHangingProtocol = useCallback( - hangingProtocol => - dispatch({ - type: 'SET_HANGING_PROTOCOL', - payload: { - hangingProtocol, - }, - }), - [dispatch] - ); - - const setHangingProtocolAppliedForViewport = useCallback( - index => - dispatch({ - type: 'SET_HANGING_PROTOCOL_APPLIED_FOR_VIEWPORT', - payload: { - index, - }, - }), - [dispatch] - ); - - const reset = useCallback( - () => - dispatch({ - type: 'RESET', - payload: {}, - }), - [dispatch] - ); - - const set = useCallback( - payload => - dispatch({ - type: 'SET', - payload, - }), - [dispatch] - ); - - /** - * Sets the implementation of the HangingProtocolService that can be used by extensions. - * - * @returns void - */ - useEffect(() => { - if (service) { - service.setServiceImplementation({ - getState, - setHangingProtocol, - setHangingProtocolAppliedForViewport, - reset, - set, - }); - } - }, [ - getState, - service, - setHangingProtocol, - setHangingProtocolAppliedForViewport, - reset, - set, - ]); - - const api = { - // getState, - setHangingProtocol, - setHangingProtocolAppliedForViewport, - reset, - set, - }; - - return ( - - {children} - - ); -} - -HangingProtocolProvider.propTypes = { - children: PropTypes.any, - service: PropTypes.shape({ - setServiceImplementation: PropTypes.func, - }).isRequired, -}; - -export const useHangingProtocol = () => useContext(HangingProtocolContext); diff --git a/platform/ui/src/contextProviders/ImageViewerProvider.jsx b/platform/ui/src/contextProviders/ImageViewerProvider.jsx index a15c1c628..6164b2185 100644 --- a/platform/ui/src/contextProviders/ImageViewerProvider.jsx +++ b/platform/ui/src/contextProviders/ImageViewerProvider.jsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useReducer } from 'react'; +import React, { createContext, useContext, useReducer, useMemo } from 'react'; // export const IMAGE_VIEWER_DEFAULT_VALUE = { // StudyInstanceUIDs: [], @@ -7,9 +7,18 @@ import React, { createContext, useContext, useReducer } from 'react'; export const ImageViewerContext = createContext(); -export function ImageViewerProvider({ reducer, initialState, children }) { +export function ImageViewerProvider({ + StudyInstanceUIDs, + reducer, + initialState, + children, +}) { + const value = useMemo(() => { + return { StudyInstanceUIDs }; + }, [StudyInstanceUIDs]); + return ( - + {children} ); diff --git a/platform/ui/src/contextProviders/ViewportGridProvider.jsx b/platform/ui/src/contextProviders/ViewportGridProvider.jsx index 834fa5f73..c072de235 100644 --- a/platform/ui/src/contextProviders/ViewportGridProvider.jsx +++ b/platform/ui/src/contextProviders/ViewportGridProvider.jsx @@ -8,8 +8,10 @@ import React, { import PropTypes from 'prop-types'; const DEFAULT_STATE = { - numRows: 1, - numCols: 1, + // starting from null, hanging + // protocol will defined number of rows and cols + numRows: null, + numCols: null, viewports: [ /* * { @@ -58,12 +60,14 @@ export function ViewportGridProvider({ children, service }) { } case 'RESET': { return { - numCols: 1, - numRows: 1, + numCols: null, + numRows: null, activeViewportIndex: 0, - viewports: [{ - displaySetInstanceUID: null, - }], + viewports: [ + { + displaySetInstanceUID: null, + }, + ], cachedLayout: null, }; } diff --git a/platform/ui/src/contextProviders/index.js b/platform/ui/src/contextProviders/index.js index c89f2db71..cd1a958ea 100644 --- a/platform/ui/src/contextProviders/index.js +++ b/platform/ui/src/contextProviders/index.js @@ -41,9 +41,3 @@ export { ViewportGridProvider, useViewportGrid, } from './ViewportGridProvider'; - -export { - HangingProtocolContext, - HangingProtocolProvider, - useHangingProtocol, -} from './HangingProtocolProvider'; diff --git a/platform/viewer/.recipes/OpenResty-Orthanc/docker-compose.yml b/platform/viewer/.recipes/OpenResty-Orthanc/docker-compose.yml index 0b6487c8a..f6626027a 100644 --- a/platform/viewer/.recipes/OpenResty-Orthanc/docker-compose.yml +++ b/platform/viewer/.recipes/OpenResty-Orthanc/docker-compose.yml @@ -10,9 +10,9 @@ services: ohif_viewer: build: # Project root - context: ./../../ + context: ./../../../../ # Relative to context - dockerfile: ./docker/OpenResty-Orthanc/dockerfile + dockerfile: ./platform/viewer/.recipes/OpenResty-Orthanc/dockerfile image: webapp:latest container_name: webapp volumes: diff --git a/platform/viewer/.recipes/OpenResty-Orthanc/dockerfile b/platform/viewer/.recipes/OpenResty-Orthanc/dockerfile index 1a9c8ec59..cbad2bf5b 100644 --- a/platform/viewer/.recipes/OpenResty-Orthanc/dockerfile +++ b/platform/viewer/.recipes/OpenResty-Orthanc/dockerfile @@ -23,20 +23,39 @@ # Stage 1: Build the application -FROM node:11.2.0-slim as builder +FROM node:12.22.1-slim as builder RUN mkdir /usr/src/app WORKDIR /usr/src/app +# Copy Files +COPY .docker /usr/src/app/.docker +COPY .webpack /usr/src/app/.webpack +COPY extensions /usr/src/app/extensions +COPY modes /usr/src/app/modes +COPY platform /usr/src/app/platform +COPY .browserslistrc /usr/src/app/.browserslistrc +COPY aliases.config.js /usr/src/app/aliases.config.js +COPY babel.config.js /usr/src/app/babel.config.js +COPY lerna.json /usr/src/app/lerna.json +COPY package.json /usr/src/app/package.json +COPY postcss.config.js /usr/src/app/postcss.config.js +COPY yarn.lock /usr/src/app/yarn.lock + +# ADD . /usr/src/app/ +RUN yarn config set workspaces-experimental true +RUN yarn install + ENV APP_CONFIG=config/docker_openresty-orthanc.js ENV PATH /usr/src/app/node_modules/.bin:$PATH -COPY package.json /usr/src/app/package.json -COPY yarn.lock /usr/src/app/yarn.lock +ENV QUICK_BUILD true +RUN yarn run build + +# ADD . /usr/src/app/ +# RUN yarn install +# RUN yarn run build:web -ADD . /usr/src/app/ -RUN yarn install -RUN yarn run build:web # Stage 2: Bundle the built application into a Docker container # which runs openresty (nginx) using Alpine Linux @@ -60,6 +79,6 @@ RUN luarocks install lua-resty-openidc RUN luarocks install luacrypto # Copy build output to image -COPY --from=builder /usr/src/app/build /var/www/html +COPY --from=builder /usr/src/app/platform/viewer/dist /var/www/html ENTRYPOINT ["/usr/local/openresty/nginx/sbin/nginx", "-g", "daemon off;"] diff --git a/platform/viewer/cypress/plugins/index.js b/platform/viewer/cypress/plugins/index.js new file mode 100644 index 000000000..aa9918d21 --- /dev/null +++ b/platform/viewer/cypress/plugins/index.js @@ -0,0 +1,21 @@ +/// +// *********************************************************** +// This example plugins/index.js can be used to load plugins +// +// You can change the location of this file or turn off loading +// the plugins file with the 'pluginsFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/plugins-guide +// *********************************************************** + +// This function is called when a project is opened or re-opened (e.g. due to +// the project's config changing) + +/** + * @type {Cypress.PluginConfig} + */ +module.exports = (on, config) => { + // `on` is used to hook into various events Cypress emits + // `config` is the resolved Cypress config +} diff --git a/platform/viewer/package.json b/platform/viewer/package.json index cb85334ca..0db4aead4 100644 --- a/platform/viewer/package.json +++ b/platform/viewer/package.json @@ -55,7 +55,7 @@ "@types/react": "^16.0.0", "classnames": "^2.2.6", "core-js": "^3.2.1", - "cornerstone-math": "^0.1.8", + "cornerstone-math": "^0.1.9", "cornerstone-tools": "5.1.2", "cornerstone-wado-image-loader": "^3.1.2", "dcmjs": "0.16.1", diff --git a/platform/viewer/public/config/default.js b/platform/viewer/public/config/default.js index bae63f6ef..62e374551 100644 --- a/platform/viewer/public/config/default.js +++ b/platform/viewer/public/config/default.js @@ -25,5 +25,24 @@ window.config = { }, }, ], + // whiteLabeling: { + // /* Optional: Should return a React component to be rendered in the "Logo" section of the application's Top Navigation bar */ + // createLogoComponentFn: function (React) { + // return React.createElement( + // 'a', + // { + // target: '_self', + // rel: 'noopener noreferrer', + // className: 'text-purple-600 line-through', + // href: '/', + // }, + // React.createElement('img', + // { + // src: './customLogo.svg', + // className: 'w-8 h-8', + // } + // )) + // }, + // }, defaultDataSourceName: 'dicomweb', }; diff --git a/platform/viewer/public/config/docker_nginx-orthanc.js b/platform/viewer/public/config/docker_nginx-orthanc.js index 812972475..bc552947d 100644 --- a/platform/viewer/public/config/docker_nginx-orthanc.js +++ b/platform/viewer/public/config/docker_nginx-orthanc.js @@ -1,9 +1,14 @@ window.config = { routerBasename: '/', showStudyList: true, - servers: { - dicomWeb: [ - { + extensions: [], + modes: [], + dataSources: [ + { + friendlyName: 'Orthanc Server', + namespace: 'org.ohif.default.dataSourcesModule.dicomweb', + sourceName: 'dicomweb', + configuration: { name: 'Orthanc', wadoUriRoot: '/wado', qidoRoot: '/dicom-web', @@ -12,6 +17,7 @@ window.config = { imageRendering: 'wadors', thumbnailRendering: 'wadors', }, - ], - }, + }, + ], + defaultDataSourceName: 'dicomweb', }; diff --git a/platform/viewer/public/config/docker_openresty-orthanc.js b/platform/viewer/public/config/docker_openresty-orthanc.js index d23915fc7..2a60ba2fe 100644 --- a/platform/viewer/public/config/docker_openresty-orthanc.js +++ b/platform/viewer/public/config/docker_openresty-orthanc.js @@ -1,10 +1,14 @@ window.config = { routerBasename: '/', showStudyList: true, - servers: { - // This is an array, but we'll only use the first entry for now - dicomWeb: [ - { + extensions: [], + modes: [], + dataSources: [ + { + friendlyName: 'Orthanc Server', + namespace: 'org.ohif.default.dataSourcesModule.dicomweb', + sourceName: 'dicomweb', + configuration: { name: 'Orthanc', wadoUriRoot: 'http://127.0.0.1/pacs/wado', qidoRoot: 'http://127.0.0.1/pacs/dicom-web', @@ -12,11 +16,8 @@ window.config = { qidoSupportsIncludeField: false, imageRendering: 'wadors', thumbnailRendering: 'wadors', - // requestOptions: { - // undefined to use JWT + Bearer auth - // auth: 'orthanc:orthanc', - // }, }, - ], - }, + }, + ], + defaultDataSourceName: 'dicomweb', }; diff --git a/platform/viewer/public/config/local_dcm4chee.js b/platform/viewer/public/config/local_dcm4chee.js index 1dc87f528..09de4c2d1 100644 --- a/platform/viewer/public/config/local_dcm4chee.js +++ b/platform/viewer/public/config/local_dcm4chee.js @@ -1,11 +1,14 @@ window.config = { - // default: '/' routerBasename: '/', - // default: '' showStudyList: true, - servers: { - dicomWeb: [ - { + extensions: [], + modes: [], + dataSources: [ + { + friendlyName: 'DCM4CHEE Server', + namespace: 'org.ohif.default.dataSourcesModule.dicomweb', + sourceName: 'dicomweb', + configuration: { name: 'DCM4CHEE', wadoUriRoot: 'http://localhost:8080/dcm4chee-arc/aets/DCM4CHEE/wado', qidoRoot: 'http://localhost:8080/dcm4chee-arc/aets/DCM4CHEE/rs', @@ -17,7 +20,8 @@ window.config = { auth: 'admin:admin', }, }, - ], - }, + }, + ], studyListFunctionsEnabled: true, + defaultDataSourceName: 'dicomweb', }; diff --git a/platform/viewer/public/customLogo.svg b/platform/viewer/public/customLogo.svg new file mode 100644 index 000000000..7bc90681e --- /dev/null +++ b/platform/viewer/public/customLogo.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/platform/viewer/src/App.jsx b/platform/viewer/src/App.jsx index 33aed40ac..438e0c54f 100644 --- a/platform/viewer/src/App.jsx +++ b/platform/viewer/src/App.jsx @@ -3,7 +3,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import i18n from '@ohif/i18n'; import { I18nextProvider } from 'react-i18next'; -import { BrowserRouter, HashRouter } from 'react-router-dom'; +import { Router } from 'react-router-dom'; import { DialogProvider, Modal, @@ -12,7 +12,6 @@ import { ThemeWrapper, ViewportDialogProvider, ViewportGridProvider, - HangingProtocolProvider, CineProvider, } from '@ohif/ui'; // Viewer Project @@ -20,6 +19,7 @@ import { import { AppConfigProvider } from '@state'; import createRoutes from './routes'; import appInit from './appInit.js'; +import history from './history' // TODO: Temporarily for testing import '@ohif/mode-longitudinal'; @@ -27,9 +27,7 @@ import '@ohif/mode-longitudinal'; /** * ENV Variable to determine routing behavior */ -const Router = JSON.parse(process.env.USE_HASH_ROUTER) - ? HashRouter - : BrowserRouter; +const OHIFRouter = Router let commandsManager, extensionManager, servicesManager, hotkeysManager; @@ -59,32 +57,29 @@ function App({ config, defaultExtensions }) { UINotificationService, UIViewportDialogService, ViewportGridService, // TODO: Should this be a "UI" Service? - HangingProtocolService, CineService } = servicesManager.services; return ( - + - - - - - - - {appRoutes} - - - - - - + + + + + + {appRoutes} + + + + + - + ); diff --git a/platform/viewer/src/appInit.js b/platform/viewer/src/appInit.js index 016621449..513450374 100644 --- a/platform/viewer/src/appInit.js +++ b/platform/viewer/src/appInit.js @@ -81,6 +81,7 @@ function appInit(appConfigOrFunc, defaultExtensions) { // appConfig.modes.push(window.segmentationMode); } + return { appConfig, commandsManager, diff --git a/platform/viewer/src/components/ViewportGrid.jsx b/platform/viewer/src/components/ViewportGrid.jsx index 3d4a18b13..93e32a488 100644 --- a/platform/viewer/src/components/ViewportGrid.jsx +++ b/platform/viewer/src/components/ViewportGrid.jsx @@ -26,38 +26,38 @@ function ViewerViewportGrid(props) { HangingProtocolService, } = servicesManager.services; - // This is a placeholder for applying hanging protocols - // It probably shouldn't be done here - // For now it just hangs the first display set in the study in 1x1 - // as sorted by SeriesNumber + + // Using Hanging protocol engine to match the displaysets useEffect(() => { const { unsubscribe } = DisplaySetService.subscribe( DisplaySetService.EVENTS.DISPLAY_SETS_ADDED, eventData => { const { displaySetsAdded } = eventData; - const data = HangingProtocolService.getState(); - - // TODO: Sometimes this is undefined? - const { hpAlreadyApplied } = data; + const [ + matchDetails, + hpAlreadyApplied, + ] = HangingProtocolService.getState(); + if (!matchDetails.length) return; // Match each viewport individually - const numViewports = numRows * numCols; + + const numViewports = viewportGrid.numRows * viewportGrid.numCols; for (let i = 0; i < numViewports; i++) { if (hpAlreadyApplied[i] === true) { - return; + continue; } - // Temporary until matching is ported back over from the Meteor version. - const reqSeriesInstanceUID = - data.hangingProtocol.stages[0].viewports[0].seriesMatchingRules[0] - .constraint.equals.value; + // if current viewport doesn't have a match + if (matchDetails[i] === undefined) return + + const { SeriesInstanceUID } = matchDetails[i]; const matchingDisplaySet = displaySetsAdded.find(ds => { - return ds.SeriesInstanceUID === reqSeriesInstanceUID; + return ds.SeriesInstanceUID === SeriesInstanceUID; }); if (!matchingDisplaySet) { - return; + continue; } viewportGridService.setDisplaysetForViewport({ @@ -73,7 +73,23 @@ function ViewerViewportGrid(props) { return () => { unsubscribe(); }; - }, []); + }, [numRows, numCols]); + + + // Layout change based on hanging protocols + useEffect(() => { + const { unsubscribe } = HangingProtocolService.subscribe( + HangingProtocolService.EVENTS.NEW_LAYOUT, + ({ numRows, numCols }) => { + viewportGridService.setLayout({ numRows, numCols }); + } + ); + + return () => { + unsubscribe(); + }; + }, [viewports]); + useEffect(() => { const { unsubscribe } = MeasurementService.subscribe( @@ -82,16 +98,24 @@ function ViewerViewportGrid(props) { const referencedDisplaySetInstanceUID = measurement.displaySetInstanceUID; - // If the viewport does not contain the displaySet, then hang that displaySet. + const viewportsDisplaySetInstanceUIDs = viewports.map( + vp => vp.displaySetInstanceUID + ); + + // if we already have the displayset in one of the viewports if ( - viewports[viewportIndex].displaySetInstanceUID !== - referencedDisplaySetInstanceUID + viewportsDisplaySetInstanceUIDs.indexOf( + referencedDisplaySetInstanceUID + ) > -1 ) { - viewportGridService.setDisplaysetForViewport({ - viewportIndex, - displaySetInstanceUID: referencedDisplaySetInstanceUID, - }); + return; } + + // If not in any of the viewports, hang it inside the active viewport + viewportGridService.setDisplaysetForViewport({ + viewportIndex, + displaySetInstanceUID: referencedDisplaySetInstanceUID, + }); } ); @@ -208,11 +232,9 @@ function ViewerViewportGrid(props) { return viewportPanes; }; - // const ViewportPanes = React.useMemo(getViewportPanes, [ - // viewportComponents, - // activeViewportIndex, - // viewportGrid, - // ]); + if (!numCols || !numCols) { + return null; + } return ( diff --git a/platform/viewer/src/history.js b/platform/viewer/src/history.js new file mode 100644 index 000000000..56897cb9a --- /dev/null +++ b/platform/viewer/src/history.js @@ -0,0 +1,5 @@ +import { createBrowserHistory, createHashHistory } from 'history'; +const useHashRouting = JSON.parse(process.env.USE_HASH_ROUTER); +const router = useHashRouting ? createHashHistory() : createBrowserHistory(); + +export default router; diff --git a/platform/viewer/src/routes/Mode/Mode.jsx b/platform/viewer/src/routes/Mode/Mode.jsx index 19aec872c..43c46b275 100644 --- a/platform/viewer/src/routes/Mode/Mode.jsx +++ b/platform/viewer/src/routes/Mode/Mode.jsx @@ -1,14 +1,57 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { useParams } from 'react-router'; import PropTypes from 'prop-types'; // TODO: DicomMetadataStore should be injected? -import { DicomMetadataStore, utils } from '@ohif/core'; +import { DicomMetadataStore } from '@ohif/core'; import { DragAndDropProvider, ImageViewerProvider } from '@ohif/ui'; import { useQuery } from '@hooks'; import ViewportGrid from '@components/ViewportGrid'; import Compose from './Compose'; -const { isLowPriorityModality } = utils; +async function defaultRouteInit({ + servicesManager, + studyInstanceUIDs, + dataSource, +}) { + const { + DisplaySetService, + HangingProtocolService, + } = servicesManager.services; + + const unsubscriptions = []; + // TODO: This should be baked into core, not manuall? + // DisplaySetService would wire this up? + const { + unsubscribe: instanceAddedUnsubscribe, + } = DicomMetadataStore.subscribe( + DicomMetadataStore.EVENTS.INSTANCES_ADDED, + ({ StudyInstanceUID, SeriesInstanceUID, madeInClient = false }) => { + const seriesMetadata = DicomMetadataStore.getSeries( + StudyInstanceUID, + SeriesInstanceUID + ); + + DisplaySetService.makeDisplaySets(seriesMetadata.instances, madeInClient); + } + ); + + unsubscriptions.push(instanceAddedUnsubscribe); + + studyInstanceUIDs.forEach(StudyInstanceUID => { + dataSource.retrieveSeriesMetadata({ StudyInstanceUID }); + }); + + const { unsubscribe: seriesAddedUnsubscribe } = DicomMetadataStore.subscribe( + DicomMetadataStore.EVENTS.SERIES_ADDED, + ({ StudyInstanceUID }) => { + const studyMetadata = DicomMetadataStore.getStudy(StudyInstanceUID); + HangingProtocolService.run(studyMetadata); + } + ); + unsubscriptions.push(seriesAddedUnsubscribe); + + return unsubscriptions; +} export default function ModeRoute({ location, @@ -20,15 +63,25 @@ export default function ModeRoute({ }) { // Parse route params/querystring const query = useQuery(); - const queryStudyInstanceUIDs = query.get('StudyInstanceUIDs'); - const { StudyInstanceUIDs: paramsStudyInstanceUIDs } = useParams(); - const StudyInstanceUIDs = queryStudyInstanceUIDs || paramsStudyInstanceUIDs; - const StudyInstanceUIDsAsArray = - StudyInstanceUIDs && Array.isArray(StudyInstanceUIDs) - ? StudyInstanceUIDs - : [StudyInstanceUIDs]; + const params = useParams(); - const { extensions, sopClassHandlers, hotkeys } = mode; + const [studyInstanceUIDs, setStudyInstanceUIDs] = useState(); + + const [refresh, setRefresh] = useState(false); + const layoutTemplateData = useRef(false); + const locationRef = useRef(null); + const isMounted = useRef(false); + + if (location !== locationRef.current) { + layoutTemplateData.current = null; + locationRef.current = location; + } + + const { + DisplaySetService, + HangingProtocolService, + } = servicesManager.services; + const { extensions, sopClassHandlers, hotkeys, hangingProtocols } = mode; if (dataSourceName === undefined) { dataSourceName = extensionManager.defaultDataSourceName; @@ -43,19 +96,6 @@ export default function ModeRoute({ // Only handling one route per mode for now const route = mode.routes[0]; - const { - DisplaySetService, - MeasurementService, - ViewportGridService, - HangingProtocolService, - } = servicesManager.services; - - const layoutTemplateData = route.layoutTemplate({ location }); - const layoutTemplateModuleEntry = extensionManager.getModuleEntry( - layoutTemplateData.id - ); - const LayoutComponent = layoutTemplateModuleEntry.component; - // For each extension, look up their context modules // TODO: move to extension manager. let contextModules = []; @@ -81,9 +121,52 @@ export default function ModeRoute({ return ViewportGrid({ ...props, dataSource }); } + useEffect(() => { + // Preventing state update for unmounted component + isMounted.current = true; + return () => { + isMounted.current = false; + }; + }, []); + + useEffect(() => { + // Todo: this should not be here, data source should not care about params + const initializeDataSource = async (params, query) => { + const studyInstanceUIDs = await dataSource.parseRouteParams({ + params, + query, + }); + setStudyInstanceUIDs(studyInstanceUIDs); + }; + + initializeDataSource(params, query); + return () => { + layoutTemplateData.current = null; + }; + }, [location]); + + useEffect(() => { + const retrieveLayoutData = async () => { + const layoutData = await route.layoutTemplate({ + location, + servicesManager, + studyInstanceUIDs, + }); + if (isMounted.current) { + layoutTemplateData.current = layoutData; + setRefresh(!refresh); + } + }; + if (studyInstanceUIDs?.length && studyInstanceUIDs[0] !== undefined) { + retrieveLayoutData(); + } + return () => { + layoutTemplateData.current = null; + }; + }, [studyInstanceUIDs]); + useEffect(() => { if (!hotkeys) { - console.warn('[hotkeys] No bindings defined for hotkeys hook!'); return; } @@ -96,19 +179,61 @@ export default function ModeRoute({ }, []); useEffect(() => { + if (!layoutTemplateData.current) { + return; + } // TODO: For some reason this is running before the Providers - // are calling setServiceImplementation + // are calling setServiceImplementationf // TOOD -> iterate through services. // Extension + + // Add SOPClassHandlers to a new SOPClassManager. + DisplaySetService.init(extensionManager, sopClassHandlers); + extensionManager.onModeEnter(); mode?.onModeEnter({ servicesManager, extensionManager }); - // Mode - route.init({ servicesManager, extensionManager }); + + // Adding hanging protocols of extensions after onModeEnter since + // it will reset the protocols + hangingProtocols.forEach(extentionProtocols => { + const hangingProtocolModule = extensionManager.getModuleEntry(extentionProtocols); + if (hangingProtocolModule?.protocols) { + HangingProtocolService.addProtocols(hangingProtocolModule.protocols); + } + }); + + const setupRouteInit = async () => { + if (route.init) { + return await route.init({ + servicesManager, + extensionManager, + hotkeysManager, + studyInstanceUIDs, + dataSource, + }); + } + + return await defaultRouteInit({ + servicesManager, + extensionManager, + hotkeysManager, + studyInstanceUIDs, + dataSource, + }); + }; + + let unsubscriptions; + setupRouteInit().then(unsubs => { + unsubscriptions = unsubs; + }); return () => { extensionManager.onModeExit(); mode?.onModeExit({ servicesManager, extensionManager }); + unsubscriptions.forEach(unsub => { + unsub(); + }); }; }, [ mode, @@ -118,140 +243,35 @@ export default function ModeRoute({ servicesManager, extensionManager, hotkeysManager, + studyInstanceUIDs, + refresh, + hangingProtocols, ]); - // This queries for series, but... What does it do with them? - useEffect(() => { - // Add SOPClassHandlers to a new SOPClassManager. - DisplaySetService.init(extensionManager, sopClassHandlers); - - // TODO: This should be baked into core, not manuel? - // DisplaySetService would wire this up? - const { unsubscribe } = DicomMetadataStore.subscribe( - DicomMetadataStore.EVENTS.INSTANCES_ADDED, - ({ StudyInstanceUID, SeriesInstanceUID, madeInClient = false }) => { - const seriesMetadata = DicomMetadataStore.getSeries( - StudyInstanceUID, - SeriesInstanceUID - ); - - DisplaySetService.makeDisplaySets( - seriesMetadata.instances, - madeInClient - ); - } + const renderLayoutData = props => { + const layoutTemplateModuleEntry = extensionManager.getModuleEntry( + layoutTemplateData.current.id ); + const LayoutComponent = layoutTemplateModuleEntry.component; - StudyInstanceUIDsAsArray.forEach(StudyInstanceUID => { - dataSource.retrieveSeriesMetadata({ StudyInstanceUID }); - }); - - return unsubscribe; - }, [ - mode, - dataSourceName, - location, - DisplaySetService, - extensionManager, - sopClassHandlers, - StudyInstanceUIDsAsArray, - dataSource, - ]); - - useEffect(() => { - const { unsubscribe } = DicomMetadataStore.subscribe( - DicomMetadataStore.EVENTS.SERIES_ADDED, - ({ StudyInstanceUID }) => { - const studyMetadata = DicomMetadataStore.getStudy(StudyInstanceUID); - - const sortedSeries = studyMetadata.series.sort((a, b) => { - const aLowPriority = isLowPriorityModality(a.Modality); - const bLowPriority = isLowPriorityModality(b.Modality); - if (!aLowPriority && bLowPriority) { - return -1; - } - if (aLowPriority && !bLowPriority) { - return 1; - } - - return a.SeriesNumber - b.SeriesNumber; - }); - - const { SeriesInstanceUID } = sortedSeries[0]; - - HangingProtocolService.setHangingProtocol({ - /*protocolMatchingRules: [ - { - id: '7tmuq7KzDMCWFeapc', - weight: 2, - required: false, - attribute: 'x00081030', - constraint: { - contains: { - value: 'DFCI CT CHEST', - }, - }, - }, - ],*/ - stages: [ - { - /*id: 'v5PfGt9F6mffZPif5', - viewportStructure: { - type: 'grid', - properties: { - Rows: 1, - Columns: 1, - }, - layoutTemplateName: 'gridLayout', - },*/ - viewports: [ - { - viewportSettings: {}, - imageMatchingRules: [], - seriesMatchingRules: [ - { - id: 'mXnsCcNzZL56z7mTZ', - weight: 1, - required: true, - attribute: 'SeriesInstanceUID', - constraint: { - equals: { - value: SeriesInstanceUID, - }, - }, - }, - ], - studyMatchingRules: [], - }, - ], - }, - ], - }); - } - ); - return unsubscribe; - }, [ - mode, - dataSourceName, - location, - DisplaySetService, - extensionManager, - sopClassHandlers, - StudyInstanceUIDsAsArray, - dataSource, - ]); + return ; + }; return ( - + {layoutTemplateData.current && + studyInstanceUIDs?.length && + studyInstanceUIDs[0] !== undefined && + renderLayoutData({ + ...layoutTemplateData.current.props, + ViewportGridComp: ViewportGridWithDataSource, + })} diff --git a/platform/viewer/src/routes/WorkList/WorkList.jsx b/platform/viewer/src/routes/WorkList/WorkList.jsx index b7659bbc2..d8b7f8693 100644 --- a/platform/viewer/src/routes/WorkList/WorkList.jsx +++ b/platform/viewer/src/routes/WorkList/WorkList.jsx @@ -408,7 +408,7 @@ function WorkList({ 'h-screen': !hasStudies, })} > -
+
100 ? 101 : numOfStudies} filtersMeta={filtersMeta} diff --git a/yarn.lock b/yarn.lock index 1cb20bb28..daef02c6b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3729,10 +3729,10 @@ acorn-globals@^4.1.0: acorn "^6.0.1" acorn-walk "^6.0.1" -acorn-jsx@^5.0.0, acorn-jsx@^5.2.0: - version "5.3.1" - resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.1.tgz#fc8661e11b7ac1539c47dbfea2e72b3af34d267b" - integrity sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng== +acorn-jsx@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.2.0.tgz#4c66069173d6fdd68ed85239fc256226182b2ebe" + integrity sha512-HiUX/+K2YpkpJ+SzBffkM/AQ2YE03S0U1kjTLVpoJdhZMOWy8qvXVN9JdLqv2QsaQ6MPYQIuNmwD8zOiYUofLQ== acorn-node@^1.6.1: version "1.8.2" @@ -3758,10 +3758,10 @@ acorn@^5.0.3, acorn@^5.5.3: resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.4.tgz#3e8d8a9947d0599a1796d10225d7432f4a4acf5e" integrity sha512-1D++VG7BhrtvQpNbBzovKNc1FLGGEE/oGe7b9xJm/RFHMBeUaUGpluV9RLjZa47YFdPcDAenEYuq9pQPcMdLJg== -acorn@^6.0.1, acorn@^6.0.7, acorn@^6.4.1: - version "6.4.2" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.2.tgz#35866fd710528e92de10cf06016498e47e39e1e6" - integrity sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ== +acorn@^6.0.1, acorn@^6.4.1: + version "6.4.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.1.tgz#531e58ba3f51b9dacb9a6646ca4debf5b14ca474" + integrity sha512-ZVA9k326Nwrj3Cj9jlh3wGFutC2ZornPNARZwsNYqQYgN0EsV2d53w5RN/co65Ohn4sUAUtb1rSUAOD6XN9idA== acorn@^7.0.0, acorn@^7.1.0, acorn@^7.1.1: version "7.4.1" @@ -3857,7 +3857,17 @@ ajv@6.5.3: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.3, ajv@^6.12.4, ajv@^6.12.5, ajv@^6.9.1: +ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2: + version "6.12.2" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.2.tgz#c629c5eced17baf314437918d2da88c99d5958cd" + integrity sha512-k+V+hzjm5q/Mr8ef/1Y9goCmlsK4I6Sm74teeyGvFk1XrOsbsKLjEdrvny42CZ+a8sXbk8KWpY/bDwS+FLL2UQ== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ajv@^6.12.3, ajv@^6.12.4, ajv@^6.12.5: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== @@ -6388,7 +6398,7 @@ cornerstone-math@0.1.7: resolved "https://registry.yarnpkg.com/cornerstone-math/-/cornerstone-math-0.1.7.tgz#7c55536f02e7221b05fc49a4c780bb82c234ce39" integrity sha512-svsDSoqLNFM9niCV2AtV2DNmRkYztT1v80lVdbA5hkgdGdpWg5/h2On794zbqBK+aI3hNOHufERDsI4wdXGknA== -cornerstone-math@0.1.9, cornerstone-math@^0.1.8: +cornerstone-math@0.1.9, cornerstone-math@^0.1.9: version "0.1.9" resolved "https://registry.yarnpkg.com/cornerstone-math/-/cornerstone-math-0.1.9.tgz#7ce5509e8b9f465b01f7c548470725e7569859fc" integrity sha512-NxdooV73asEQgav1S+0e+a4K+W3CXJdLXyFkVN24qqCtmIpzZzwtw3F9KWPCekzSAJmbhtQ3HicOQj3d4vRtuw== @@ -8226,10 +8236,10 @@ eslint-plugin-promise@^4.2.1: resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-4.3.1.tgz#61485df2a359e03149fdafc0a68b0e030ad2ac45" integrity sha512-bY2sGqyptzFBDLh/GMbAxfdJC+b0f23ME63FOE4+Jao0oZ3E1LEwFtWJX/1pGMJLiTtrSSern2CRM/g+dfc0eQ== -eslint-plugin-react-hooks@1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-1.5.0.tgz#cdd958cfff55bd5fa4f84db90d1490fb5ca4ae2b" - integrity sha512-iwDuWR2ReRgvJsNm8fXPtTKdg78IVQF8I4+am3ntztPf/+nPnWZfArFu6aXpaC75/iCYRrkqI8nPCYkxJstmpA== +eslint-plugin-react-hooks@4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.2.0.tgz#8c229c268d468956334c943bb45fc860280f5556" + integrity sha512-623WEiZJqxR7VdxFCKLI6d6LLpwJkGPYKODnkH3D7WpOG5KM8yWueBd8TLsNAetEJNF5iJmolaAKO3F8yzyVBQ== eslint-plugin-react-hooks@^1.7.0: version "1.7.0" @@ -8277,7 +8287,7 @@ eslint-scope@^5.0.0, eslint-scope@^5.1.1: esrecurse "^4.3.0" estraverse "^4.1.1" -eslint-utils@^1.3.1, eslint-utils@^1.4.2, eslint-utils@^1.4.3: +eslint-utils@^1.4.2, eslint-utils@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.4.3.tgz#74fec7c54d0776b6f67e0251040b5806564e981f" integrity sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q== @@ -8296,49 +8306,7 @@ eslint-visitor-keys@^1.0.0, eslint-visitor-keys@^1.1.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz#30ebd1ef7c2fdff01c3a4f151044af25fab0523e" integrity sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ== -eslint@5.16.0: - version "5.16.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-5.16.0.tgz#a1e3ac1aae4a3fbd8296fcf8f7ab7314cbb6abea" - integrity sha512-S3Rz11i7c8AA5JPv7xAH+dOyq/Cu/VXHiHXBPOU1k/JAM5dXqQPt3qcrhpHSorXmrpu2g0gkIBVXAqCpzfoZIg== - dependencies: - "@babel/code-frame" "^7.0.0" - ajv "^6.9.1" - chalk "^2.1.0" - cross-spawn "^6.0.5" - debug "^4.0.1" - doctrine "^3.0.0" - eslint-scope "^4.0.3" - eslint-utils "^1.3.1" - eslint-visitor-keys "^1.0.0" - espree "^5.0.1" - esquery "^1.0.1" - esutils "^2.0.2" - file-entry-cache "^5.0.1" - functional-red-black-tree "^1.0.1" - glob "^7.1.2" - globals "^11.7.0" - ignore "^4.0.6" - import-fresh "^3.0.0" - imurmurhash "^0.1.4" - inquirer "^6.2.2" - js-yaml "^3.13.0" - json-stable-stringify-without-jsonify "^1.0.1" - levn "^0.3.0" - lodash "^4.17.11" - minimatch "^3.0.4" - mkdirp "^0.5.1" - natural-compare "^1.4.0" - optionator "^0.8.2" - path-is-inside "^1.0.2" - progress "^2.0.0" - regexpp "^2.0.1" - semver "^5.5.1" - strip-ansi "^4.0.0" - strip-json-comments "^2.0.1" - table "^5.2.3" - text-table "^0.2.0" - -eslint@^6.8.0: +eslint@6.8.0, eslint@^6.8.0: version "6.8.0" resolved "https://registry.yarnpkg.com/eslint/-/eslint-6.8.0.tgz#62262d6729739f9275723824302fb227c8c93ffb" integrity sha512-K+Iayyo2LtyYhDSYwz5D5QdWw0hCacNzyq1Y821Xna2xSJj7cijoLLYmLxTQgcgZ9mC61nryMy9S7GRbYpI5Ig== @@ -8381,15 +8349,6 @@ eslint@^6.8.0: text-table "^0.2.0" v8-compile-cache "^2.0.3" -espree@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/espree/-/espree-5.0.1.tgz#5d6526fa4fc7f0788a5cf75b15f30323e2f81f7a" - integrity sha512-qWAZcWh4XE/RwzLJejfcofscgMc9CamR6Tn1+XRXNzrvUSSbiAjGOI/fggztjIi7y9VLPqnICMIPiGyr8JaZ0A== - dependencies: - acorn "^6.0.7" - acorn-jsx "^5.0.0" - eslint-visitor-keys "^1.0.0" - espree@^6.1.2: version "6.2.1" resolved "https://registry.yarnpkg.com/espree/-/espree-6.2.1.tgz#77fc72e1fd744a2052c20f38a5b575832e82734a" @@ -10284,7 +10243,7 @@ global@^4.3.0: min-document "^2.19.0" process "^0.11.10" -globals@^11.1.0, globals@^11.7.0: +globals@^11.1.0: version "11.12.0" resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== @@ -11405,7 +11364,7 @@ inquirer@3.3.0: strip-ansi "^4.0.0" through "^2.3.6" -inquirer@^6.2.0, inquirer@^6.2.2: +inquirer@^6.2.0: version "6.5.2" resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.5.2.tgz#ad50942375d036d327ff528c08bd5fab089928ca" integrity sha512-cntlB5ghuB0iuO65Ovoi8ogLHiWGs/5yNrtUcKjFhSSiVeAIVpD7koaSU9RM8mpXw5YDi9RdYXGQMaOURB7ycQ== @@ -12591,10 +12550,10 @@ js-tokens@^3.0.1, js-tokens@^3.0.2: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls= -js-yaml@^3.11.0, js-yaml@^3.13.0, js-yaml@^3.13.1, js-yaml@^3.9.0: - version "3.14.1" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" - integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== +js-yaml@^3.11.0, js-yaml@^3.13.1, js-yaml@^3.9.0: + version "3.14.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.0.tgz#a7a34170f26a21bb162424d8adacb4113a69e482" + integrity sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A== dependencies: argparse "^1.0.7" esprima "^4.0.0" @@ -14893,7 +14852,7 @@ optimize-css-assets-webpack-plugin@^5.0.3: cssnano "^4.1.10" last-call-webpack-plugin "^3.0.0" -optionator@^0.8.1, optionator@^0.8.2, optionator@^0.8.3: +optionator@^0.8.1, optionator@^0.8.3: version "0.8.3" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" integrity sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA== @@ -16849,10 +16808,10 @@ react-ace@^7.0.2: lodash.isequal "^4.5.0" prop-types "^15.7.2" -react-cornerstone-viewport@4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/react-cornerstone-viewport/-/react-cornerstone-viewport-4.0.2.tgz#49bc8f8464164d8e779138dd9244d5c2524e0418" - integrity sha512-mF/piahoPG5U5TbxVMtc15bXrQZxcaPycbu4i4KD2rZkiWh1Zs24ryY1S+lFlDB/RcOAm0u+luEEt1tOg8F+UQ== +react-cornerstone-viewport@4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/react-cornerstone-viewport/-/react-cornerstone-viewport-4.0.4.tgz#0e4291d9b0fe55c003e275175b0902ced61b0c80" + integrity sha512-RjCqOtQep04dRoHtsju0USlxN/vGUI2WnEt1aN84iYU7eZ+AXnwJo1nAd0RMX2obcXaGCIqMDtMRkE/zm3oZHw== dependencies: classnames "^2.2.6" date-fns "^2.2.1" @@ -16860,10 +16819,10 @@ react-cornerstone-viewport@4.0.2: prop-types "^15.7.2" react-resize-detector "^4.2.1" -react-cornerstone-viewport@4.0.4: - version "4.0.4" - resolved "https://registry.yarnpkg.com/react-cornerstone-viewport/-/react-cornerstone-viewport-4.0.4.tgz#0e4291d9b0fe55c003e275175b0902ced61b0c80" - integrity sha512-RjCqOtQep04dRoHtsju0USlxN/vGUI2WnEt1aN84iYU7eZ+AXnwJo1nAd0RMX2obcXaGCIqMDtMRkE/zm3oZHw== +react-cornerstone-viewport@4.0.5: + version "4.0.5" + resolved "https://registry.yarnpkg.com/react-cornerstone-viewport/-/react-cornerstone-viewport-4.0.5.tgz#3de83cdd3643980e0330f3743f631dbbb24d98a7" + integrity sha512-uamDtFljWWEHe26kOBJJ1Bu//6hAgEra7BDVykOvtMC/W7kW34Q2fFX3ZwkL6TDdCQ94O6K98IXPS9HbrEJWYg== dependencies: classnames "^2.2.6" date-fns "^2.2.1" @@ -19549,16 +19508,16 @@ strip-indent@^3.0.0: dependencies: min-indent "^1.0.0" -strip-json-comments@^2.0.1, strip-json-comments@~2.0.1: +strip-json-comments@^3.0.1: + version "3.1.0" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.0.tgz#7638d31422129ecf4457440009fba03f9f9ac180" + integrity sha512-e6/d0eBu7gHtdCqFt0xJr642LdToM5/cN4Qb9DbHjVx1CP5RyeM+zH7pbecEmDv/lBqb0QH+6Uqq75rxFPkM0w== + +strip-json-comments@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= -strip-json-comments@^3.0.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" - integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== - strong-log-transformer@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/strong-log-transformer/-/strong-log-transformer-2.1.0.tgz#0f5ed78d325e0421ac6f90f7f10e691d6ae3ae10" @@ -20972,6 +20931,11 @@ validate-npm-package-name@^3.0.0: dependencies: builtins "^1.0.3" +validate.js@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/validate.js/-/validate.js-0.12.0.tgz#17f989e37c192ea2f826bbf19bf4e97e6e4be68f" + integrity sha512-/x2RJSvbqEyxKj0RPN4xaRquK+EggjeVXiDDEyrJzsJogjtiZ9ov7lj/svVb4DM5Q5braQF4cooAryQbUwOxlA== + value-equal@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c"