diff --git a/.eslintrc.json b/.eslintrc.json index 329eb380b..c8ec9056f 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -12,10 +12,11 @@ "settings": { "react": { "version": "detect" - } + }, "rules": { - "react/jsx-props-no-spreading": "error" + "react/jsx-props-no-spreading": "error", + "react-hooks/exhaustive-deps": "false" }, "globals": { "cy": true, diff --git a/extensions/cornerstone/src/init.js b/extensions/cornerstone/src/init.js index 792c5fc22..2d1470bdc 100644 --- a/extensions/cornerstone/src/init.js +++ b/extensions/cornerstone/src/init.js @@ -334,6 +334,12 @@ const _connectToolsToMeasurementService = ( } } + /** + * When csTools fires a removed event, remove the same measurement + * from the measurement service + * + * @param {*} csToolsEvent + */ function removeMeasurement(csToolsEvent) { console.log('~~ removeEvt', csToolsEvent); try { @@ -381,15 +387,42 @@ const _connectMeasurementServiceToTools = ( }); }); - /* TODO: Remove per measurement - MeasurementService.subscribe(MEASUREMENT_REMOVED, - ({ source, measurement }) => { - if ([sourceId].includes(source.id)) { - // const annotation = getAnnotation('Length', measurement.id); - // iterate tool state - } + // TODO: This is an unsafe delete + // Cornerstone-tools should probably expose a more generic "delete by id" + // And have toolState managers expose a method to find any of their toolState by ID + // --> csTools.deleteById --> internally checks all registered modules/managers? + // + // This implementation assumes a single globalImageIdSpecificToolStateManager + // It iterates all toolState for all toolTypes, and deletes any with a matching id + // + // Could potentially use "source" from event to determine tool type and skip some + // iterations? + MeasurementService.subscribe( + MEASUREMENT_REMOVED, + ({ source, measurement: removedMeasurementId }) => { + // THIS POINTS TO ORIGINAL; Not a copy + const imageIdSpecificToolState = cornerstoneTools.globalImageIdSpecificToolStateManager.saveToolState(); + + // ImageId --> + Object.keys(imageIdSpecificToolState).forEach(imageId => { + // ImageId --> Tool --> + Object.keys(imageIdSpecificToolState[imageId]).forEach(toolName => { + const toolState = imageIdSpecificToolState[imageId][toolName]; + + let annotationIndex = toolState.data.length - 1; + while (annotationIndex >= 0) { + const annotation = toolState.data[annotationIndex]; + + if (annotation.id === removedMeasurementId) { + toolState.data.splice(annotationIndex, 1); + } + + annotationIndex--; + } + }); + }); } - ); */ + ); }; // const { diff --git a/extensions/dicom-sr/src/OHIFCornerstoneSRViewport.js b/extensions/dicom-sr/src/OHIFCornerstoneSRViewport.js index 43be17461..22ddc0ec2 100644 --- a/extensions/dicom-sr/src/OHIFCornerstoneSRViewport.js +++ b/extensions/dicom-sr/src/OHIFCornerstoneSRViewport.js @@ -4,11 +4,15 @@ import cornerstoneTools from 'cornerstone-tools'; import cornerstone from 'cornerstone-core'; import CornerstoneViewport from 'react-cornerstone-viewport'; import OHIF, { DicomMetadataStore, utils } from '@ohif/core'; -import { ViewportActionBar, useViewportGrid } from '@ohif/ui'; +import { + Notification, + ViewportActionBar, + useViewportGrid, + useViewportDialog, +} from '@ohif/ui'; import TOOL_NAMES from './constants/toolNames'; import { adapters } from 'dcmjs'; -import getToolStateToCornerstoneMeasurementSchema from './utils/getToolStateToCornerstoneMeasurementSchema'; -import getLabelFromDCMJSImportedToolData from './utils/getLabelFromDCMJSImportedToolData'; +// import getToolStateToCornerstoneMeasurementSchema from './utils/getToolStateToCornerstoneMeasurementSchema'; import id from './id'; const { formatDate } = utils; @@ -30,6 +34,7 @@ function OHIFCornerstoneSRViewport({ }) { const { DisplaySetService, MeasurementService } = servicesManager.services; const [viewportGrid, viewportGridService] = useViewportGrid(); + const [viewportDialogState, viewportDialogApi] = useViewportDialog(); const [measurementSelected, setMeasurementSelected] = useState(0); const [measurementCount, setMeasurementCount] = useState(1); const [viewportData, setViewportData] = useState(null); @@ -141,14 +146,9 @@ function OHIFCornerstoneSRViewport({ const numMeasurements = displaySet.measurements.length; setMeasurementCount(numMeasurements); - }, [ - dataSource, - displaySet, - displaySet.StudyInstanceUID, - displaySet.displaySetInstanceUID, - ]); + }, [dataSource, displaySet]); - const updateViewport = newMeasurementSelected => { + const updateViewport = useCallback(newMeasurementSelected => { const { StudyInstanceUID, displaySetInstanceUID, @@ -181,28 +181,25 @@ function OHIFCornerstoneSRViewport({ cornerstone.updateImage(element); } }); - }; + }); - useEffect(() => { - if (element !== null) { - setTrackingUniqueIdentifiersForElement(element); - } - }, [ - dataSource, - displaySet, - displaySet.StudyInstanceUID, - displaySet.displaySetInstanceUID, - ]); + useEffect( + () => { + if (element !== null) { + setTrackingUniqueIdentifiersForElement(element); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [dataSource, displaySet] + ); - useEffect(() => { - updateViewport(measurementSelected); - }, [ - dataSource, - displaySet, - displaySet.StudyInstanceUID, - displaySet.displaySetInstanceUID, - element, - ]); + useEffect( + () => { + updateViewport(measurementSelected); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [dataSource, displaySet, element] + ); const firstViewportIndexWithMatchingDisplaySetUid = viewports.findIndex( vp => vp.displaySetInstanceUID === displaySet.displaySetInstanceUID @@ -273,147 +270,6 @@ function OHIFCornerstoneSRViewport({ updateViewport(newMeasurementSelected); }; - function hydrateMeasurementService() { - // TODO -> We should define a strict versioning somewhere. - const mappings = MeasurementService.getSourceMappings( - 'CornerstoneTools', - '4' - ); - - if (!mappings || !mappings.length) { - throw new Error( - `Attempting to hydrate measurements service when no mappings present. This shouldn't be reached.` - ); - } - - const instance = DicomMetadataStore.getInstance( - displaySet.StudyInstanceUID, - displaySet.SeriesInstanceUID, - displaySet.SOPInstanceUID - ); - - const { MeasurementReport } = adapters.Cornerstone; - - const sopInstanceUIDToImageId = {}; - - displaySet.measurements.forEach(measurement => { - const { ReferencedSOPInstanceUID, imageId } = measurement; - if (!sopInstanceUIDToImageId[ReferencedSOPInstanceUID]) { - sopInstanceUIDToImageId[ReferencedSOPInstanceUID] = imageId; - } - }); - - // Use dcmjs to generate toolState. - const storedMeasurementByToolType = MeasurementReport.generateToolState( - instance - ); - - // Filter what is found by DICOM SR to measurements we support. - const mappingDefinitions = mappings.map(m => m.definition); - const hydratableMeasurementsInSR = {}; - - Object.keys(storedMeasurementByToolType).forEach(key => { - if (mappingDefinitions.includes(key)) { - hydratableMeasurementsInSR[key] = storedMeasurementByToolType[key]; - } - }); - - if ( - extensionManager.registeredExtensionIds.includes( - MEASUREMENT_TRACKING_EXTENSION_ID - ) - ) { - // Set the series touched as tracked. - const imageIds = []; - - Object.keys(hydratableMeasurementsInSR).forEach(toolType => { - const toolDataForToolType = hydratableMeasurementsInSR[toolType]; - - toolDataForToolType.forEach(data => { - // Add the measurement to toolState - const imageId = sopInstanceUIDToImageId[data.sopInstanceUid]; - - if (!imageIds.includes(imageId)) { - imageIds.push(imageId); - } - }); - }); - - let targetStudyInstanceUID; - const SeriesInstanceUIDs = []; - - for (let i = 0; i < imageIds.length; i++) { - const imageId = imageIds[0]; - const { - SeriesInstanceUID, - StudyInstanceUID, - } = cornerstone.metaData.get('instance', imageId); - - if (!SeriesInstanceUIDs.includes(SeriesInstanceUID)) { - SeriesInstanceUIDs.push(SeriesInstanceUID); - } - - if (!targetStudyInstanceUID) { - targetStudyInstanceUID = StudyInstanceUID; - } else if (targetStudyInstanceUID !== StudyInstanceUID) { - console.warn( - 'NO SUPPORT FOR SRs THAT HAVE MEASUREMENTS FROM MULTIPLE STUDIES.' - ); - } - } - - sendTrackedMeasurementsEvent('SET_TRACKED_SERIES', { - StudyInstanceUID: targetStudyInstanceUID, - SeriesInstanceUIDs, - }); - } - - Object.keys(hydratableMeasurementsInSR).forEach(toolType => { - const toolDataForToolType = hydratableMeasurementsInSR[toolType]; - - toolDataForToolType.forEach(data => { - // Add the measurement to toolState - const imageId = sopInstanceUIDToImageId[data.sopInstanceUid]; - - data.id = guid(); - - _addToolDataToCornerstoneTools(data, toolType, imageId); - - // Let the measurement service know we added to toolState - const toMeasurementSchema = getToolStateToCornerstoneMeasurementSchema( - toolType, - MeasurementService, - DisplaySetService, - imageId - ); - - const source = MeasurementService.getSource('CornerstoneTools', '4'); - - data.label = getLabelFromDCMJSImportedToolData(data); - - MeasurementService.addRawMeasurement( - source, - toolType, - data, - toMeasurementSchema - ); - - if (!imageIds.includes(imageId)) { - imageIds.push(imageId); - } - }); - }); - - displaySet.isHydrated = true; - - setIsHydrated(true); - - viewportGridService.setDisplaysetForViewport({ - viewportIndex: activeViewportIndex, - displaySetInstanceUID: activeDisplaySetData.displaySetInstanceUID, - }); - } - const label = viewports.length > 1 ? _viewportLabels[viewportIndex] : ''; // TODO -> disabled double click for now: onDoubleClick={_onDoubleClick} @@ -426,7 +282,6 @@ function OHIFCornerstoneSRViewport({ evt.preventDefault(); }} onSeriesChange={onMeasurementChange} - onHydrationClick={hydrateMeasurementService} studyData={{ label, isTracked: false, @@ -447,8 +302,8 @@ function OHIFCornerstoneSRViewport({ spacing: PixelSpacing && PixelSpacing.length ? `${PixelSpacing[0].toFixed(2)}mm x ${PixelSpacing[1].toFixed( - 2 - )}mm` + 2 + )}mm` : '', scanner: ManufacturerModelName || '', }, @@ -471,6 +326,17 @@ function OHIFCornerstoneSRViewport({ // seizure inducing strobe blinking effect resizeRefreshRateMs={150} /> +
+ {viewportDialogState.viewportIndex === viewportIndex && ( + + )} +
{childrenWithProps} @@ -582,27 +448,6 @@ async function _getViewportAndActiveDisplaySetData( return { viewportData, activeDisplaySetData }; } -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); -} - function _onDoubleClick() { const cancelActiveManipulatorsForElement = cornerstoneTools.getModule( 'manipulatorState' diff --git a/extensions/dicom-sr/src/init.js b/extensions/dicom-sr/src/init.js index ca8a9610b..03d203167 100644 --- a/extensions/dicom-sr/src/init.js +++ b/extensions/dicom-sr/src/init.js @@ -14,7 +14,7 @@ const defaultConfig = { /** * @param {object} configuration */ -export default function init({ configuration = {}, servicesManager }) { +export default function init({ configuration = {} }) { const conifg = Object.assign({}, defaultConfig, configuration); TOOL_NAMES.DICOM_SR_DISPLAY_TOOL = conifg.TOOL_NAMES.DICOM_SR_DISPLAY_TOOL; @@ -22,22 +22,4 @@ export default function init({ configuration = {}, servicesManager }) { cornerstoneTools.register('module', id, dicomSRModule); cornerstoneTools.addTool(DICOMSRDisplayTool); cornerstoneTools.setToolEnabled(TOOL_NAMES.DICOM_SR_DISPLAY_TOOL); - - const { DisplaySetService } = servicesManager.services; - - DisplaySetService.subscribe( - DisplaySetService.EVENTS.DISPLAY_SETS_ADDED, - data => { - const { displaySetsAdded, options } = data; - displaySetsAdded.forEach(dSet => { - if (options.madeInClient) { - // Set just made displaySets as hydrated. - if (!dSet.isLoaded) { - dSet.load(); - } - dSet.isHydrated = true; - } - }); - } - ); } diff --git a/extensions/measurement-tracking/src/_shared/PROMPT_RESPONSES.js b/extensions/measurement-tracking/src/_shared/PROMPT_RESPONSES.js new file mode 100644 index 000000000..39e2e61f1 --- /dev/null +++ b/extensions/measurement-tracking/src/_shared/PROMPT_RESPONSES.js @@ -0,0 +1,10 @@ +const RESPONSE = { + NO_NEVER: -1, + CANCEL: 0, + CREATE_REPORT: 1, + ADD_SERIES: 2, + SET_STUDY_AND_SERIES: 3, + NO_NOT_FOR_SERIES: 4, +}; + +export default RESPONSE; diff --git a/extensions/measurement-tracking/src/_shared/createReportAsync.js b/extensions/measurement-tracking/src/_shared/createReportAsync.js index 5aca672db..b6115b57a 100644 --- a/extensions/measurement-tracking/src/_shared/createReportAsync.js +++ b/extensions/measurement-tracking/src/_shared/createReportAsync.js @@ -1,11 +1,24 @@ import React from 'react'; -import { DICOMSR } from '@ohif/core'; +import { DICOMSR, DicomMetadataStore } from '@ohif/core'; -async function createReportAsync(servicesManager, dataSource, measurements) { +/** + * + * @param {*} servicesManager + * @param {*} dataSource + * @param {*} measurements + * @param {*} options + * @returns {string[]} displaySetInstanceUIDs + */ +async function createReportAsync( + servicesManager, + dataSource, + measurements, + options +) { const { + DisplaySetService, UINotificationService, UIDialogService, - DisplaySetService, } = servicesManager.services; const loadingDialogId = UIDialogService.create({ showOverlay: true, @@ -19,17 +32,24 @@ async function createReportAsync(servicesManager, dataSource, measurements) { const naturalizedReport = await DICOMSR.storeMeasurements( measurements, dataSource, - ['ArrowAnnotate'] + ['ArrowAnnotate'], + options ); - DisplaySetService.makeDisplaySets([naturalizedReport], { - madeInClient: true, - }); + // The "Mode" route listens for DicomMetadataStore changes + // When a new instance is added, it listens and + // automatically calls makeDisplaySets + DicomMetadataStore.addInstances([naturalizedReport], true); + + const displaySetInstanceUID = DisplaySetService.getMostRecentDisplaySet(); + UINotificationService.show({ title: 'Create Report', message: 'Measurements saved successfully', type: 'success', }); + + return [displaySetInstanceUID]; } catch (error) { UINotificationService.show({ title: 'Create Report', diff --git a/extensions/measurement-tracking/src/_shared/createReportDialogPrompt.js b/extensions/measurement-tracking/src/_shared/createReportDialogPrompt.js new file mode 100644 index 000000000..b0c13a666 --- /dev/null +++ b/extensions/measurement-tracking/src/_shared/createReportDialogPrompt.js @@ -0,0 +1,79 @@ +/* eslint-disable react/display-name */ +import React from 'react'; +import { Dialog, Input } from '@ohif/ui'; +import RESPONSE from './PROMPT_RESPONSES'; + +export default function createReportDialogPrompt(UIDialogService) { + return new Promise(function(resolve, reject) { + let dialogId = undefined; + + const _handleClose = () => { + // Dismiss dialog + UIDialogService.dismiss({ id: dialogId }); + // Notify of cancel action + resolve({ action: RESPONSE.CANCEL, value: undefined }); + }; + + /** + * + * @param {string} param0.action - value of action performed + * @param {string} param0.value - value from input field + */ + const _handleFormSubmit = ({ action, value }) => { + UIDialogService.dismiss({ id: dialogId }); + switch (action.id) { + case 'save': + resolve({ action: RESPONSE.CREATE_REPORT, value: value.label }); + break; + case 'cancel': + resolve({ action: RESPONSE.CANCEL, value: undefined }); + break; + } + }; + + dialogId = UIDialogService.create({ + centralize: true, + isDraggable: false, + content: Dialog, + useLastPosition: false, + showOverlay: true, + contentProps: { + title: 'Provide a name for your report', + value: { label: '' }, + noCloseButton: true, + onClose: _handleClose, + actions: [ + { id: 'cancel', text: 'Cancel', type: 'secondary' }, + { id: 'save', text: 'Save', type: 'primary' }, + ], + // TODO: Should be on button press... + onSubmit: _handleFormSubmit, + body: ({ value, setValue }) => { + const onChangeHandler = event => { + event.persist(); + setValue(value => ({ ...value, label: event.target.value })); + }; + const onKeyPressHandler = event => { + if (event.key === 'Enter') { + UIDialogService.dismiss({ id: dialogId }); + resolve({ action: RESPONSE.CREATE_REPORT, value: value.label }); + } + }; + return ( +
+ +
+ ); + }, + }, + }); + }); +} diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/TrackedMeasurementsContext.jsx b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/TrackedMeasurementsContext.jsx index 3ff87f663..c3079b54c 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/TrackedMeasurementsContext.jsx +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/TrackedMeasurementsContext.jsx @@ -1,7 +1,8 @@ -import React, { useContext } from 'react'; +import React, { useCallback, useContext, useEffect } from 'react'; import PropTypes from 'prop-types'; import { Machine } from 'xstate'; import { useMachine } from '@xstate/react'; +import { useViewportGrid } from '@ohif/ui'; import { machineConfiguration, defaultOptions, @@ -9,6 +10,9 @@ import { import promptBeginTracking from './promptBeginTracking'; 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'; @@ -22,7 +26,60 @@ function TrackedMeasurementsContextProvider( { servicesManager, extensionManager }, // Bound by consumer { children } // Component props ) { + const [viewportGrid, viewportGridService] = useViewportGrid(); + const { activeViewportIndex, viewports } = viewportGrid; + const machineOptions = Object.assign({}, defaultOptions); + machineOptions.actions = Object.assign({}, machineOptions.actions, { + showSeriesInActiveViewport: (ctx, evt) => { + const { DisplaySetService } = servicesManager.services; + const displaySetsForHydratedSeries = DisplaySetService.getDisplaySetsForSeries( + ctx.trackedSeries[0] + ); + + if (displaySetsForHydratedSeries.length > 0) { + const firstDisplaySetInstanceUID = + displaySetsForHydratedSeries[0].displaySetInstanceUID; + + viewportGridService.setDisplaysetForViewport({ + viewportIndex: evt.data.viewportIndex, + displaySetInstanceUID: firstDisplaySetInstanceUID, + }); + } + }, + showStructuredReportDisplaySetInActiveViewport: (ctx, evt) => { + if (evt.data.createdDisplaySetInstanceUIDs.length > 0) { + const StructuredReportDisplaySetInstanceUID = + evt.data.createdDisplaySetInstanceUIDs[0].displaySetInstanceUID; + + viewportGridService.setDisplaysetForViewport({ + viewportIndex: evt.data.viewportIndex, + displaySetInstanceUID: StructuredReportDisplaySetInstanceUID, + }); + } + }, + discardPreviouslyTrackedMeasurements: (ctx, evt) => { + const { MeasurementService } = servicesManager.services; + const measurements = MeasurementService.getMeasurements(); + const filteredMeasurements = measurements.filter(ms => + ctx.prevTrackedSeries.includes(ms.referenceSeriesUID) + ); + const measurementIds = filteredMeasurements.map(fm => fm.id); + + for (let i = 0; i < measurementIds.length; i++) { + MeasurementService.remove('app-source', measurementIds[i]); + } + }, + clearAllMeasurements: (ctx, evt) => { + const { MeasurementService } = servicesManager.services; + const measurements = MeasurementService.getMeasurements(); + const measurementIds = measurements.map(fm => fm.id); + + for (let i = 0; i < measurementIds.length; i++) { + MeasurementService.remove('app-source', measurementIds[i]); + } + }, + }); machineOptions.services = Object.assign({}, machineOptions.services, { promptBeginTracking: promptBeginTracking.bind(null, { servicesManager, @@ -36,6 +93,13 @@ function TrackedMeasurementsContextProvider( servicesManager, extensionManager, }), + promptSaveReport: promptSaveReport.bind(null, { + servicesManager, + extensionManager, + }), + promptHydrateStructuredReport: promptHydrateStructuredReport.bind(null, { + servicesManager, + }), }); // TODO: IMPROVE @@ -58,6 +122,41 @@ function TrackedMeasurementsContextProvider( trackedMeasurementsService, ] = useMachine(measurementTrackingMachine); + // ~~ Listen for changes to ViewportGrid for potential SRs hung in panes when idle + useEffect(() => { + if (viewports.length > 0) { + const activeViewport = viewports[activeViewportIndex]; + + if (!activeViewport || !activeViewport.displaySetInstanceUID) { + return; + } + + const { DisplaySetService } = servicesManager.services; + const displaySet = DisplaySetService.getDisplaySetByUID( + activeViewport.displaySetInstanceUID + ); + + // Magic string + // load function added by our sopClassHandler module + if ( + displaySet.SOPClassHandlerId === + 'org.ohif.dicom-sr.sopClassHandlerModule.dicom-sr' && + !displaySet.isLocked + ) { + console.log('sending event...', trackedMeasurements); + sendTrackedMeasurementsEvent('PROMPT_HYDRATE_SR', { + displaySetInstanceUID: displaySet.displaySetInstanceUID, + viewportIndex: activeViewportIndex, + }); + } + } + }, [ + activeViewportIndex, + sendTrackedMeasurementsEvent, + servicesManager.services, + viewports, + ]); + return ( We should define a strict versioning somewhere. + const mappings = MeasurementService.getSourceMappings( + 'CornerstoneTools', + '4' + ); + + if (!mappings || !mappings.length) { + throw new Error( + `Attempting to hydrate measurements service when no mappings present. This shouldn't be reached.` + ); + } + + const instance = DicomMetadataStore.getInstance( + displaySet.StudyInstanceUID, + displaySet.SeriesInstanceUID, + displaySet.SOPInstanceUID + ); + + const { MeasurementReport } = adapters.Cornerstone; + + const sopInstanceUIDToImageId = {}; + + displaySet.measurements.forEach(measurement => { + const { ReferencedSOPInstanceUID, imageId } = measurement; + if (!sopInstanceUIDToImageId[ReferencedSOPInstanceUID]) { + sopInstanceUIDToImageId[ReferencedSOPInstanceUID] = imageId; + } + }); + + // Use dcmjs to generate toolState. + const storedMeasurementByToolType = MeasurementReport.generateToolState( + instance + ); + + // Filter what is found by DICOM SR to measurements we support. + const mappingDefinitions = mappings.map(m => m.definition); + const hydratableMeasurementsInSR = {}; + + Object.keys(storedMeasurementByToolType).forEach(key => { + if (mappingDefinitions.includes(key)) { + hydratableMeasurementsInSR[key] = storedMeasurementByToolType[key]; + } + }); + + // Set the series touched as tracked. + const imageIds = []; + + // TODO: notification if no hydratable? + Object.keys(hydratableMeasurementsInSR).forEach(toolType => { + const toolDataForToolType = hydratableMeasurementsInSR[toolType]; + + toolDataForToolType.forEach(data => { + // Add the measurement to toolState + const imageId = sopInstanceUIDToImageId[data.sopInstanceUid]; + + if (!imageIds.includes(imageId)) { + imageIds.push(imageId); + } + }); + }); + + let targetStudyInstanceUID; + const SeriesInstanceUIDs = []; + + for (let i = 0; i < imageIds.length; i++) { + const imageId = imageIds[i]; + const { SeriesInstanceUID, StudyInstanceUID } = cornerstone.metaData.get( + 'instance', + imageId + ); + + if (!SeriesInstanceUIDs.includes(SeriesInstanceUID)) { + SeriesInstanceUIDs.push(SeriesInstanceUID); + } + + if (!targetStudyInstanceUID) { + targetStudyInstanceUID = StudyInstanceUID; + } else if (targetStudyInstanceUID !== StudyInstanceUID) { + console.warn( + 'NO SUPPORT FOR SRs THAT HAVE MEASUREMENTS FROM MULTIPLE STUDIES.' + ); + } + } + + Object.keys(hydratableMeasurementsInSR).forEach(toolType => { + const toolDataForToolType = hydratableMeasurementsInSR[toolType]; + + toolDataForToolType.forEach(data => { + // Add the measurement to toolState + const imageId = sopInstanceUIDToImageId[data.sopInstanceUid]; + + data.id = guid(); + + _addToolDataToCornerstoneTools(data, toolType, imageId); + + // Let the measurement service know we added to toolState + const toMeasurementSchema = getToolStateToCornerstoneMeasurementSchema( + toolType, + MeasurementService, + DisplaySetService, + imageId + ); + + const source = MeasurementService.getSource('CornerstoneTools', '4'); + + data.label = getLabelFromDCMJSImportedToolData(data); + + MeasurementService.addRawMeasurement( + source, + toolType, + data, + toMeasurementSchema + ); + + if (!imageIds.includes(imageId)) { + imageIds.push(imageId); + } + }); + }); + + displaySet.isHydrated = true; + + return { + StudyInstanceUID: targetStudyInstanceUID, + 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/dicom-sr/src/utils/getToolStateToCornerstoneMeasurementSchema.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/getToolStateToCornerstoneMeasurementSchema.js similarity index 100% rename from extensions/dicom-sr/src/utils/getToolStateToCornerstoneMeasurementSchema.js rename to extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/getToolStateToCornerstoneMeasurementSchema.js diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/measurementTrackingMachine.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/measurementTrackingMachine.js index a69e92835..1e2c5e6d3 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/measurementTrackingMachine.js +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/measurementTrackingMachine.js @@ -6,6 +6,8 @@ const RESPONSE = { CREATE_REPORT: 1, ADD_SERIES: 2, SET_STUDY_AND_SERIES: 3, + NO_NOT_FOR_SERIES: 4, + HYDRATE_REPORT: 5, }; const machineConfiguration = { @@ -14,6 +16,11 @@ const machineConfiguration = { context: { trackedStudy: '', trackedSeries: [], + ignoredSeries: [], + // + prevTrackedStudy: '', + prevTrackedSeries: [], + prevIgnoredSeries: [], }, states: { off: { @@ -29,6 +36,7 @@ const machineConfiguration = { actions: ['setTrackedStudyAndMultipleSeries'], }, ], + PROMPT_HYDRATE_SR: 'promptHydrateStructuredReport', }, }, promptBeginTracking: { @@ -65,7 +73,6 @@ const machineConfiguration = { cond: 'isNewSeries', }, ], - UNTRACK_SERIES: [ { target: 'tracking', @@ -82,6 +89,7 @@ const machineConfiguration = { actions: ['setTrackedStudyAndMultipleSeries'], }, ], + SAVE_REPORT: 'promptSaveReport', }, }, promptTrackNewSeries: { @@ -95,10 +103,16 @@ const machineConfiguration = { }, { target: 'tracking', - actions: ['setTrackedStudyAndSeries'], + actions: [ + 'discardPreviouslyTrackedMeasurements', + 'setTrackedStudyAndSeries', + ], cond: 'shouldSetStudyAndSeries', }, - // CREATE_REPORT && CANCEL + { + target: 'promptSaveReport', + cond: 'shouldPromptSaveReport', + }, { target: 'tracking', }, @@ -114,11 +128,80 @@ const machineConfiguration = { onDone: [ { target: 'tracking', - actions: ['setTrackedStudyAndSeries'], + actions: [ + 'discardPreviouslyTrackedMeasurements', + 'setTrackedStudyAndSeries', + ], cond: 'shouldSetStudyAndSeries', }, { target: 'tracking', + actions: ['ignoreSeries'], + cond: 'shouldAddIgnoredSeries', + }, + { + target: 'promptSaveReport', + cond: 'shouldPromptSaveReport', + }, + { + target: 'tracking', + }, + ], + onError: { + target: 'idle', + }, + }, + }, + promptSaveReport: { + invoke: { + src: 'promptSaveReport', + onDone: [ + // "clicked the save button" + // - should clear all measurements + // - show DICOM SR + { + target: 'idle', + actions: [ + 'clearAllMeasurements', + 'showStructuredReportDisplaySetInActiveViewport', + ], + cond: 'shouldSaveAndContinueWithSameReport', + }, + // "starting a new report" + // - remove "just saved" measurements + // - start tracking a new study + report + { + target: 'tracking', + actions: [ + 'discardPreviouslyTrackedMeasurements', + 'setTrackedStudyAndSeries', + ], + cond: 'shouldSaveAndStartNewReport', + }, + // Cancel, back to tracking + { + target: 'tracking', + }, + ], + onError: { + target: 'idle', + }, + }, + }, + promptHydrateStructuredReport: { + invoke: { + src: 'promptHydrateStructuredReport', + onDone: [ + { + target: 'tracking', + actions: [ + 'setTrackedStudyAndMultipleSeries', + 'showSeriesInActiveViewport', + ], + cond: 'shouldHydrateStructuredReport', + }, + { + target: 'idle', }, ], onError: { @@ -143,23 +226,66 @@ const defaultOptions = { }, }, actions: { + discardPreviouslyTrackedMeasurements: (ctx, evt) => { + console.log('discardPreviouslyTrackedMeasurements: not implemented'); + }, + clearAllMeasurements: (ctx, evt) => { + console.log('clearAllMeasurements: not implemented'); + }, + showSeriesInActiveViewport: (ctx, evt) => { + console.warn('showSeriesInActiveViewport: not implemented'); + }, + showStructuredReportDisplaySetInActiveViewport: (ctx, evt) => { + console.warn( + 'showStructuredReportDisplaySetInActiveViewport: not implemented' + ); + }, clearContext: assign({ trackedStudy: '', trackedSeries: [], + ignoredSeries: [], + prevTrackedStudy: '', + prevTrackedSeries: [], + prevIgnoredSeries: [], }), // Promise resolves w/ `evt.data.*` setTrackedStudyAndSeries: assign((ctx, evt) => ({ + prevTrackedStudy: ctx.trackedStudy, + prevTrackedSeries: ctx.trackedSeries.slice(), + prevIgnoredSeries: ctx.ignoredSeries.slice(), + // trackedStudy: evt.data.StudyInstanceUID, trackedSeries: [evt.data.SeriesInstanceUID], + ignoredSeries: [], })), - setTrackedStudyAndMultipleSeries: assign((ctx, evt) => ({ - trackedStudy: evt.StudyInstanceUID, - trackedSeries: [...ctx.trackedSeries, ...evt.SeriesInstanceUIDs], + setTrackedStudyAndMultipleSeries: assign((ctx, evt) => { + const studyInstanceUID = + evt.StudyInstanceUID || evt.data.StudyInstanceUID; + const seriesInstanceUIDs = + evt.SeriesInstanceUIDs || evt.data.SeriesInstanceUIDs; + + return { + prevTrackedStudy: ctx.trackedStudy, + prevTrackedSeries: ctx.trackedSeries.slice(), + prevIgnoredSeries: ctx.ignoredSeries.slice(), + // + trackedStudy: studyInstanceUID, + trackedSeries: [...ctx.trackedSeries, ...seriesInstanceUIDs], + ignoredSeries: [], + }; + }), + ignoreSeries: assign((ctx, evt) => ({ + prevIgnoredSeries: [...ctx.ignoredSeries], + ignoredSeries: [...ctx.ignoredSeries, evt.data.SeriesInstanceUID], })), addTrackedSeries: assign((ctx, evt) => ({ + prevTrackedSeries: [...ctx.trackedSeries], trackedSeries: [...ctx.trackedSeries, evt.data.SeriesInstanceUID], })), removeTrackedSeries: assign((ctx, evt) => ({ + prevTrackedSeries: ctx.trackedSeries + .slice() + .filter(ser => ser !== evt.SeriesInstanceUID), trackedSeries: ctx.trackedSeries .slice() .filter(ser => ser !== evt.SeriesInstanceUID), @@ -172,13 +298,30 @@ const defaultOptions = { evt.data && evt.data.userResponse === RESPONSE.ADD_SERIES, shouldSetStudyAndSeries: (ctx, evt) => evt.data && evt.data.userResponse === RESPONSE.SET_STUDY_AND_SERIES, + shouldAddIgnoredSeries: (ctx, evt) => + evt.data && evt.data.userResponse === RESPONSE.NO_NOT_FOR_SERIES, + shouldPromptSaveReport: (ctx, evt) => + evt.data && evt.data.userResponse === RESPONSE.CREATE_REPORT, + shouldSaveAndContinueWithSameReport: (ctx, evt) => + evt.data && + evt.data.userResponse === RESPONSE.CREATE_REPORT && + evt.data.isBackupSave === true, + shouldSaveAndStartNewReport: (ctx, evt) => + evt.data && + evt.data.userResponse === RESPONSE.CREATE_REPORT && + evt.data.isBackupSave === false, + shouldHydrateStructuredReport: (ctx, evt) => + evt.data && evt.data.userResponse === RESPONSE.HYDRATE_REPORT, // Has more than 1, or SeriesInstanceUID is not in list // --> Post removal would have non-empty trackedSeries array hasRemainingTrackedSeries: (ctx, evt) => ctx.trackedSeries.length > 1 || !ctx.trackedSeries.includes(evt.SeriesInstanceUID), - isNewStudy: (ctx, evt) => ctx.trackedStudy !== evt.StudyInstanceUID, + isNewStudy: (ctx, evt) => + !ctx.ignoredSeries.includes(evt.SeriesInstanceUID) && + ctx.trackedStudy !== evt.StudyInstanceUID, isNewSeries: (ctx, evt) => + !ctx.ignoredSeries.includes(evt.SeriesInstanceUID) && !ctx.trackedSeries.includes(evt.SeriesInstanceUID), }, }; diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptBeginTracking.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptBeginTracking.js index 57a6188fd..b4a66ba73 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptBeginTracking.js +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptBeginTracking.js @@ -20,6 +20,7 @@ function promptUser({ servicesManager }, ctx, evt) { userResponse: promptResult, StudyInstanceUID, SeriesInstanceUID, + viewportIndex, }); }); } diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptHydrateStructuredReport.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptHydrateStructuredReport.js new file mode 100644 index 000000000..4d017cf10 --- /dev/null +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptHydrateStructuredReport.js @@ -0,0 +1,82 @@ +import hydrateStructuredReport from './_hydrateStructuredReport.js'; + +const RESPONSE = { + NO_NEVER: -1, + CANCEL: 0, + CREATE_REPORT: 1, + ADD_SERIES: 2, + SET_STUDY_AND_SERIES: 3, + NO_NOT_FOR_SERIES: 4, + HYDRATE_REPORT: 5, +}; + +function promptUser({ servicesManager, extensionManager }, ctx, evt) { + const { UIViewportDialogService } = servicesManager.services; + const { viewportIndex, displaySetInstanceUID } = evt; + + return new Promise(async function(resolve, reject) { + const promptResult = await _askTrackMeasurements( + UIViewportDialogService, + viewportIndex + ); + + // Need to do action here... So we can set state... + let StudyInstanceUID, SeriesInstanceUIDs; + + if (promptResult === RESPONSE.HYDRATE_REPORT) { + console.warn('!! HYDRATING STRUCTURED REPORT'); + const hydrationResult = hydrateStructuredReport( + { servicesManager, extensionManager }, + displaySetInstanceUID + ); + + StudyInstanceUID = hydrationResult.StudyInstanceUID; + SeriesInstanceUIDs = hydrationResult.SeriesInstanceUIDs; + } + + resolve({ + userResponse: promptResult, + displaySetInstanceUID: evt.displaySetInstanceUID, + viewportIndex, + StudyInstanceUID, + SeriesInstanceUIDs, + }); + }); +} + +function _askTrackMeasurements(UIViewportDialogService, viewportIndex) { + return new Promise(function(resolve, reject) { + const message = + 'Do you want to continue tracking measurements for this study?'; + const actions = [ + { + type: 'secondary', + text: 'No', + value: RESPONSE.CANCEL, + }, + { + type: 'primary', + text: 'Yes', + value: RESPONSE.HYDRATE_REPORT, + }, + ]; + const onSubmit = result => { + UIViewportDialogService.hide(); + resolve(result); + }; + + UIViewportDialogService.show({ + viewportIndex, + type: 'info', + message, + actions, + onSubmit, + onOutsideClick: () => { + UIViewportDialogService.hide(); + resolve(RESPONSE.CANCEL); + }, + }); + }); +} + +export default promptUser; diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptSaveReport.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptSaveReport.js new file mode 100644 index 000000000..e1afa48d8 --- /dev/null +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptSaveReport.js @@ -0,0 +1,62 @@ +import createReportAsync from './../../_shared/createReportAsync.js'; +import createReportDialogPrompt from '../../_shared/createReportDialogPrompt'; +import RESPONSE from '../../_shared/PROMPT_RESPONSES'; + +function promptUser({ servicesManager, extensionManager }, ctx, evt) { + const { UIDialogService, MeasurementService } = servicesManager.services; + const viewportIndex = + evt.viewportIndex === undefined + ? evt.data.viewportIndex + : evt.viewportIndex; + const isBackupSave = + evt.isBackupSave === undefined ? evt.data.isBackupSave : evt.isBackupSave; + const StudyInstanceUID = evt?.data?.StudyInstanceUID; + const SeriesInstanceUID = evt?.data?.SeriesInstanceUID; + + const { trackedStudy, trackedSeries } = ctx; + let displaySetInstanceUIDs; + + return new Promise(async function(resolve, reject) { + // TODO: Fallback if (UIDialogService) { + const promptResult = await createReportDialogPrompt(UIDialogService); + + if (promptResult.action === RESPONSE.CREATE_REPORT) { + const dataSources = extensionManager.getDataSources(); + const dataSource = dataSources[0]; + const measurements = MeasurementService.getMeasurements(); + const trackedMeasurements = measurements.filter( + m => + trackedStudy === m.referenceStudyUID && + trackedSeries.includes(m.referenceSeriesUID) + ); + + const SeriesDescription = + // isUndefinedOrEmpty + promptResult.value === undefined || promptResult.value === '' + ? 'Research Derived Series' // default + : promptResult.value; // provided value + + displaySetInstanceUIDs = await createReportAsync( + servicesManager, + dataSource, + trackedMeasurements, + { + SeriesDescription, + } + ); + } else if (promptResult.action === RESPONSE.CANCEL) { + // Do nothing + } + + resolve({ + userResponse: promptResult.action, + createdDisplaySetInstanceUIDs: displaySetInstanceUIDs, + StudyInstanceUID, + SeriesInstanceUID, + viewportIndex, + isBackupSave, + }); + }); +} + +export default promptUser; diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewSeries.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewSeries.js index 2370ef65d..bbadc45ff 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewSeries.js +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewSeries.js @@ -1,20 +1,15 @@ -import createReportAsync from './../../_shared/createReportAsync.js'; - const RESPONSE = { NO_NEVER: -1, CANCEL: 0, CREATE_REPORT: 1, ADD_SERIES: 2, SET_STUDY_AND_SERIES: 3, + NO_NOT_FOR_SERIES: 4, }; function promptUser({ servicesManager, extensionManager }, ctx, evt) { - const { - UIViewportDialogService, - MeasurementService, - } = servicesManager.services; + const { UIViewportDialogService } = servicesManager.services; const { viewportIndex, StudyInstanceUID, SeriesInstanceUID } = evt; - const { trackedStudy, trackedSeries } = ctx; return new Promise(async function(resolve, reject) { let promptResult = await _askShouldAddMeasurements( @@ -29,25 +24,12 @@ function promptUser({ servicesManager, extensionManager }, ctx, evt) { ); } - if (promptResult === RESPONSE.CREATE_REPORT) { - // TODO -> Eventually deal with multiple dataSources. - // Would need some way of saying which one is the "push" dataSource - const dataSources = extensionManager.getDataSources(); - const dataSource = dataSources[0]; - const measurements = MeasurementService.getMeasurements(); - const trackedMeasurements = measurements.filter( - m => - trackedStudy === m.referenceStudyUID && - trackedSeries.includes(m.referenceSeriesUID) - ); - - createReportAsync(servicesManager, dataSource, trackedMeasurements); - } - resolve({ userResponse: promptResult, StudyInstanceUID, SeriesInstanceUID, + viewportIndex, + isBackupSave: false, }); }); } @@ -96,7 +78,7 @@ function _askSaveDiscardOrCancel(UIViewportDialogService, viewportIndex) { { type: 'cancel', text: 'Cancel', value: RESPONSE.CANCEL }, { type: 'secondary', - text: 'Save in report', + text: 'Save', value: RESPONSE.CREATE_REPORT, }, { diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewStudy.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewStudy.js index e99da9179..cfcbd79e7 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewStudy.js +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewStudy.js @@ -1,22 +1,17 @@ -import createReportAsync from './../../_shared/createReportAsync.js'; - const RESPONSE = { NO_NEVER: -1, CANCEL: 0, CREATE_REPORT: 1, ADD_SERIES: 2, SET_STUDY_AND_SERIES: 3, + NO_NOT_FOR_SERIES: 4, }; function promptUser({ servicesManager, extensionManager }, ctx, evt) { - const { - UIViewportDialogService, - MeasurementService, - } = servicesManager.services; + const { UIViewportDialogService } = servicesManager.services; const { viewportIndex, StudyInstanceUID, SeriesInstanceUID } = evt; - const { trackedStudy, trackedSeries } = ctx; - return new Promise(async function (resolve, reject) { + return new Promise(async function(resolve, reject) { let promptResult = await _askTrackMeasurements( UIViewportDialogService, viewportIndex @@ -29,34 +24,26 @@ function promptUser({ servicesManager, extensionManager }, ctx, evt) { ); } - if (promptResult === RESPONSE.CREATE_REPORT) { - // TODO -> Eventually deal with multiple dataSources. - // Would need some way of saying which one is the "push" dataSource - const dataSources = extensionManager.getDataSources(); - const dataSource = dataSources[0]; - const measurements = MeasurementService.getMeasurements(); - const trackedMeasurements = measurements.filter( - m => - trackedStudy === m.referenceStudyUID && - trackedSeries.includes(m.referenceSeriesUID) - ); - - createReportAsync(servicesManager, dataSource, trackedMeasurements); - } - resolve({ userResponse: promptResult, StudyInstanceUID, SeriesInstanceUID, + viewportIndex, + isBackupSave: false, }); }); } function _askTrackMeasurements(UIViewportDialogService, viewportIndex) { - return new Promise(function (resolve, reject) { - const message = 'Track measurements for this series?'; + return new Promise(function(resolve, reject) { + const message = 'Track measurements for this seies?'; const actions = [ { type: 'cancel', text: 'No', value: RESPONSE.CANCEL }, + { + type: 'secondary', + text: 'No, do not ask again for this series', + value: RESPONSE.NO_NOT_FOR_SERIES, + }, { type: 'primary', text: 'Yes', @@ -83,7 +70,7 @@ function _askTrackMeasurements(UIViewportDialogService, viewportIndex) { } function _askSaveDiscardOrCancel(UIViewportDialogService, viewportIndex) { - return new Promise(function (resolve, reject) { + return new Promise(function(resolve, reject) { const message = 'Measurements cannot span across multiple studies. Do you want to save your tracked measurements?'; const actions = [ diff --git a/extensions/dicom-sr/src/utils/getLabelFromDCMJSImportedToolData.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/utils/getLabelFromDCMJSImportedToolData.js similarity index 100% rename from extensions/dicom-sr/src/utils/getLabelFromDCMJSImportedToolData.js rename to extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/utils/getLabelFromDCMJSImportedToolData.js diff --git a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.js b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.js index af89f39c7..614231ff4 100644 --- a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.js +++ b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.js @@ -11,8 +11,8 @@ import { DicomMetadataStore, DICOMSR, utils } from '@ohif/core'; import { useDebounce } from '@hooks'; import ActionButtons from './ActionButtons'; import { useTrackedMeasurements } from '../../getContextModule'; -import createReportAsync from './../../_shared/createReportAsync.js'; -import setCornerstoneMeasurementActive from '../../_shared/setCornerstoneMeasurementActive'; +import createReportDialogPrompt from '../../_shared/createReportDialogPrompt'; +import RESPONSES from '../../_shared/PROMPT_RESPONSES'; const { formatDate } = utils; @@ -32,11 +32,7 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) { measurementChangeTimestamp, 200 ); - const { - MeasurementService, - UIDialogService, - DisplaySetService, - } = servicesManager.services; + const { MeasurementService, UIDialogService } = servicesManager.services; const [ trackedMeasurements, sendTrackedMeasurementsEvent, @@ -132,11 +128,7 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) { }; }, [MeasurementService, sendTrackedMeasurementsEvent]); - function createReport() { - // TODO -> Eventually deal with multiple dataSources. - // Would need some way of saying which one is the "push" dataSource - const dataSources = extensionManager.getDataSources(); - const dataSource = dataSources[0]; + async function exportReport() { const measurements = MeasurementService.getMeasurements(); const trackedMeasurements = measurements.filter( m => @@ -144,20 +136,21 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) { trackedSeries.includes(m.referenceSeriesUID) ); - return createReportAsync(servicesManager, dataSource, trackedMeasurements); - } + const promptResult = await createReportDialogPrompt(UIDialogService); - function exportReport() { - const measurements = MeasurementService.getMeasurements(); - const trackedMeasurements = measurements.filter( - m => - trackedStudy === m.referenceStudyUID && - trackedSeries.includes(m.referenceSeriesUID) - ); + if (promptResult.action === RESPONSES.CREATE_REPORT) { + const additionalFindings = ['ArrowAnnotate']; - const additionalFindings = ['ArrowAnnotate']; + const SeriesDescription = + // isUndefinedOrEmpty + promptResult.value === undefined || promptResult.value === '' + ? 'Research Derived Series' // default + : promptResult.value; // provided value - DICOMSR.downloadReport(trackedMeasurements, additionalFindings); + DICOMSR.downloadReport(trackedMeasurements, additionalFindings, { + SeriesDescription, + }); + } } const jumpToImage = ({ id, isActive }) => { @@ -273,7 +266,12 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) {
{ + sendTrackedMeasurementsEvent('SAVE_REPORT', { + viewportIndex: viewportGrid.activeViewportIndex, + isBackupSave: true, + }); + }} />
diff --git a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.jsx b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.jsx index 515ab7c71..3a8b6b93f 100644 --- a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.jsx +++ b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.jsx @@ -448,7 +448,7 @@ function _createStudyBrowserTabs( ds => ds.StudyInstanceUID === study.studyInstanceUid ); - displaySetsForStudy.sort((a,b) => { + displaySetsForStudy.sort((a, b) => { if (a.seriesNumber !== b.seriesNumber) { return a.seriesNumber - b.seriesNumber; } diff --git a/platform/core/src/DICOMSR/dataExchange.js b/platform/core/src/DICOMSR/dataExchange.js index 817d80482..cce0f881b 100644 --- a/platform/core/src/DICOMSR/dataExchange.js +++ b/platform/core/src/DICOMSR/dataExchange.js @@ -6,6 +6,7 @@ import { stowSRFromMeasurements, } from './handleStructuredReport'; import findMostRecentStructuredReport from './utils/findMostRecentStructuredReport'; +import cornerstone from 'cornerstone-core'; import cornerstoneTools from 'cornerstone-tools'; import dcmjs from 'dcmjs'; @@ -47,11 +48,20 @@ const retrieveMeasurements = server => { * * @param {object[]} measurementData An array of measurements from the measurements service * @param {string[]} additionalFindingTypes toolTypes that should be stored with labels as Findings + * @param {object} options Naturalized DICOM JSON headers to merge into the displaySet. * as opposed to Finding Sites. * that you wish to serialize. */ -const downloadReport = (measurementData, additionalFindingTypes = []) => { - const srDataset = generateReport(measurementData, additionalFindingTypes); +const downloadReport = ( + measurementData, + additionalFindingTypes, + options = {} +) => { + const srDataset = generateReport( + measurementData, + additionalFindingTypes, + options + ); const reportBlob = dcmjs.data.datasetToBlob(srDataset); //Create a URL for the binary. @@ -64,19 +74,29 @@ const downloadReport = (measurementData, additionalFindingTypes = []) => { * @param {object[]} measurementData An array of measurements from the measurements service * that you wish to serialize. * @param {string[]} additionalFindingTypes toolTypes that should be stored with labels as Findings + * @param {object} options Naturalized DICOM JSON headers to merge into the displaySet. + * */ -const generateReport = (measurementData, additionalFindingTypes) => { +const generateReport = ( + measurementData, + additionalFindingTypes, + options = {} +) => { const filteredToolState = _getFilteredCornerstoneToolState( measurementData, additionalFindingTypes ); - const report = MeasurementReport.generateReport( filteredToolState, cornerstone.metaData ); - return report.dataset; + const { dataset } = report; + + // Add in top level series options + Object.assign(dataset, options); + + return dataset; }; /** @@ -85,13 +105,14 @@ const generateReport = (measurementData, additionalFindingTypes) => { * that you wish to serialize. * @param {object} dataSource The dataSource that you wish to use to persist the data. * @param {string[]} additionalFindingTypes toolTypes that should be stored with labels as Findings - * as opposed to Finding Sites. + * @param {object} options Naturalized DICOM JSON headers to merge into the displaySet. * @return {object} The naturalized report */ const storeMeasurements = async ( measurementData, dataSource, - additionalFindingTypes = [] + additionalFindingTypes, + options = {} ) => { // TODO -> Eventually use the measurements directly and not the dcmjs adapter, // But it is good enough for now whilst we only have cornerstone as a datasource. @@ -105,7 +126,8 @@ const storeMeasurements = async ( try { const naturalizedReport = generateReport( measurementData, - additionalFindingTypes + additionalFindingTypes, + options ); const { StudyInstanceUID } = naturalizedReport; @@ -117,6 +139,7 @@ const storeMeasurements = async ( return naturalizedReport; } catch (error) { + console.warn(error); log.error( `[DICOMSR] Error while saving the measurements: ${error.message}` ); @@ -124,6 +147,8 @@ const storeMeasurements = async ( } }; +// _getFilteredCornerstoneToolState +// DIFFERENT IMPLEMENTATION HERE! What's up? function _getFilteredCornerstoneToolState( measurementData, additionalFindingTypes @@ -147,7 +172,6 @@ function _getFilteredCornerstoneToolState( } const measurmentDataI = measurementData.find(md => md.id === toolDataI.id); - const toolData = imageIdSpecificToolState[toolType].data; let finding; diff --git a/platform/core/src/extensions/ExtensionManager.js b/platform/core/src/extensions/ExtensionManager.js index 0993e31ef..07249389b 100644 --- a/platform/core/src/extensions/ExtensionManager.js +++ b/platform/core/src/extensions/ExtensionManager.js @@ -16,7 +16,7 @@ export default class ExtensionManager { this.moduleTypeNames.forEach(moduleType => { this.modules[moduleType] = []; }); - this._extensionLifeCycleHooks = { onModeEnter: {} }; + this._extensionLifeCycleHooks = { onModeEnter: {}, onModeExit: {} }; this.dataSourceMap = {}; this.defaultDataSourceName = appConfig.defaultDataSourceName; this.activeDataSource = undefined; @@ -29,12 +29,21 @@ export default class ExtensionManager { onModeEnter() { const { registeredExtensionIds, - getModuleEntry, _servicesManager, _commandsManager, _extensionLifeCycleHooks, } = this; + const { + MeasurementService, + ViewportGridService, + HangingProtocolService, + } = _servicesManager.services; + + MeasurementService.clearMeasurements(); + ViewportGridService.reset(); + HangingProtocolService.reset(); + registeredExtensionIds.forEach(extensionId => { const onModeEnter = _extensionLifeCycleHooks.onModeEnter[extensionId]; @@ -47,6 +56,36 @@ export default class ExtensionManager { }); } + onModeExit() { + const { + registeredExtensionIds, + _servicesManager, + _commandsManager, + _extensionLifeCycleHooks, + } = this; + + const { + MeasurementService, + ViewportGridService, + HangingProtocolService, + } = _servicesManager.services; + + MeasurementService.clearMeasurements(); + ViewportGridService.reset(); + HangingProtocolService.reset(); + + registeredExtensionIds.forEach(extensionId => { + const onModeExit = _extensionLifeCycleHooks.onModeExit[extensionId]; + + if (typeof onModeExit === 'function') { + onModeExit({ + servicesManager: _servicesManager, + commandsManager: _commandsManager, + }); + } + }); + } + /** * An array of extensions, or an array of arrays that contains extension * configuration pairs. @@ -110,6 +149,11 @@ export default class ExtensionManager { extension.onModeEnter; } + if (extension.onModeExit) { + this._extensionLifeCycleHooks.onModeExit[extensionId] = + extension.onModeExit; + } + // Register Modules this.moduleTypeNames.forEach(moduleType => { const extensionModule = this._getExtensionModule( diff --git a/platform/core/src/measurements/conformance/ConformanceCriteria.js b/platform/core/src/measurements/conformance/ConformanceCriteria.js index f600207eb..5fd817eca 100644 --- a/platform/core/src/measurements/conformance/ConformanceCriteria.js +++ b/platform/core/src/measurements/conformance/ConformanceCriteria.js @@ -77,10 +77,10 @@ class ConformanceCriteria { this.nonconformities = nonconformities; this.groupedNonConformities = groupedNonConformities; - console.warn('nonconformities'); - console.warn(nonconformities); - console.warn('groupedNonConformities'); - console.warn(groupedNonConformities); + // console.warn('nonconformities'); + // console.warn(nonconformities); + // console.warn('groupedNonConformities'); + // console.warn(groupedNonConformities); return nonconformities; } diff --git a/platform/core/src/services/DicomMetadataStore/DicomMetadataStore.js b/platform/core/src/services/DicomMetadataStore/DicomMetadataStore.js index 58eee20f3..0a74ab380 100644 --- a/platform/core/src/services/DicomMetadataStore/DicomMetadataStore.js +++ b/platform/core/src/services/DicomMetadataStore/DicomMetadataStore.js @@ -57,9 +57,7 @@ function _getInstance(StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID) { const BaseImplementation = { EVENTS, listeners: {}, - // TODO: The assumption is that this is called per Study per Series - // We should do more to verify/clarify that - addInstances(instances) { + addInstances(instances, madeInClient = false) { const { StudyInstanceUID, SeriesInstanceUID } = instances[0]; let study = _model.studies.find( @@ -81,6 +79,7 @@ const BaseImplementation = { this._broadcastEvent(EVENTS.INSTANCES_ADDED, { StudyInstanceUID, SeriesInstanceUID, + madeInClient, }); }, addSeriesMetadata(seriesSummaryMetadata) { diff --git a/platform/core/src/services/DisplaySetService/DisplaySetService.js b/platform/core/src/services/DisplaySetService/DisplaySetService.js index 08026fe46..6db68a128 100644 --- a/platform/core/src/services/DisplaySetService/DisplaySetService.js +++ b/platform/core/src/services/DisplaySetService/DisplaySetService.js @@ -36,6 +36,10 @@ export default class DisplaySetService { return displaySetCache; } + getMostRecentDisplaySet() { + return this.activeDisplaySets[this.activeDisplaySets.length - 1]; + } + getActiveDisplaySets() { return this.activeDisplaySets; } @@ -69,6 +73,12 @@ export default class DisplaySetService { displaySet => displaySet.displaySetInstanceUID === displaySetInstanceUid ); + /** + * + * @param {*} input + * @param {*} param1 + * @returns {string[]} - added displaySetInstanceUIDs + */ makeDisplaySets = (input, { batch = false, madeInClient = false } = {}) => { if (!input || !input.length) { throw new Error('No instances were provided.'); @@ -84,11 +94,12 @@ export default class DisplaySetService { let displaySetsAdded = []; if (batch) { - input.forEach(instances => { + for (let i = 0; i < input.length; i++) { + const instances = input[i]; const displaySets = this.makeDisplaySetForInstances(instances); displaySetsAdded = [...displaySetsAdded, displaySets]; - }); + } } else { const displaySets = this.makeDisplaySetForInstances(input); @@ -109,6 +120,8 @@ export default class DisplaySetService { displaySetsAdded, options, }); + + return displaySetsAdded; } }; diff --git a/platform/ui/src/components/ViewportActionBar/ViewportActionBar.jsx b/platform/ui/src/components/ViewportActionBar/ViewportActionBar.jsx index 0d58f5343..b31fd2e4c 100644 --- a/platform/ui/src/components/ViewportActionBar/ViewportActionBar.jsx +++ b/platform/ui/src/components/ViewportActionBar/ViewportActionBar.jsx @@ -16,7 +16,6 @@ const ViewportActionBar = ({ showNavArrows, showPatientInfo: patientInfoVisibility, onSeriesChange, - onHydrationClick, onDoubleClick, }) => { const [showPatientInfo, setShowPatientInfo] = useState(patientInfoVisibility); @@ -32,7 +31,6 @@ const ViewportActionBar = ({ label, isTracked, isLocked, - isHydrated, modality, studyDate, currentSeries, @@ -78,13 +76,7 @@ const ViewportActionBar = ({ Measurements cannot be duplicated. ) - : () => ( -
- This SR is unlocked.
- You can duplicate measurements on your current report
by - clicking 'Edit'. -
- ); + : () =>
This SR is unlocked.
; return ( <> } position="bottom-left"> @@ -101,16 +93,6 @@ const ViewportActionBar = ({ )} - {!isLocked && !isHydrated && ( -
- - Edit - -
- )} ); } diff --git a/platform/viewer/src/routes/Mode/Mode.jsx b/platform/viewer/src/routes/Mode/Mode.jsx index 64581131e..a851c0f24 100644 --- a/platform/viewer/src/routes/Mode/Mode.jsx +++ b/platform/viewer/src/routes/Mode/Mode.jsx @@ -104,14 +104,15 @@ export default function ModeRoute({ // TODO: For some reason this is running before the Providers // are calling setServiceImplementation // TOOD -> iterate through services. - MeasurementService.clearMeasurements(); - ViewportGridService.reset(); - HangingProtocolService.reset(); // Extension extensionManager.onModeEnter(); // Mode route.init({ servicesManager, extensionManager }); + + return () => { + extensionManager.onModeExit(); + }; }, [ mode, dataSourceName, @@ -131,13 +132,16 @@ export default function ModeRoute({ // DisplaySetService would wire this up? const { unsubscribe } = DicomMetadataStore.subscribe( DicomMetadataStore.EVENTS.INSTANCES_ADDED, - ({ StudyInstanceUID, SeriesInstanceUID }) => { + ({ StudyInstanceUID, SeriesInstanceUID, madeInClient = false }) => { const seriesMetadata = DicomMetadataStore.getSeries( StudyInstanceUID, SeriesInstanceUID ); - DisplaySetService.makeDisplaySets(seriesMetadata.instances); + DisplaySetService.makeDisplaySets( + seriesMetadata.instances, + madeInClient + ); } ); @@ -161,9 +165,7 @@ export default function ModeRoute({ const { unsubscribe } = DicomMetadataStore.subscribe( DicomMetadataStore.EVENTS.SERIES_ADDED, ({ StudyInstanceUID }) => { - const studyMetadata = DicomMetadataStore.getStudy( - StudyInstanceUID, - ); + const studyMetadata = DicomMetadataStore.getStudy(StudyInstanceUID); const sortedSeries = studyMetadata.series.sort((a, b) => { const aLowPriority = isLowPriorityModality(a.Modality); @@ -176,13 +178,12 @@ export default function ModeRoute({ } return a.SeriesNumber - b.SeriesNumber; - }) + }); - const { SeriesInstanceUID } = sortedSeries[0] + const { SeriesInstanceUID } = sortedSeries[0]; - HangingProtocolService.setHangingProtocol( - { - /*protocolMatchingRules: [ + HangingProtocolService.setHangingProtocol({ + /*protocolMatchingRules: [ { id: '7tmuq7KzDMCWFeapc', weight: 2, @@ -195,9 +196,9 @@ export default function ModeRoute({ }, }, ],*/ - stages: [ - { - /*id: 'v5PfGt9F6mffZPif5', + stages: [ + { + /*id: 'v5PfGt9F6mffZPif5', viewportStructure: { type: 'grid', properties: { @@ -206,30 +207,29 @@ export default function ModeRoute({ }, layoutTemplateName: 'gridLayout', },*/ - viewports: [ - { - viewportSettings: {}, - imageMatchingRules: [], - seriesMatchingRules: [ - { - id: 'mXnsCcNzZL56z7mTZ', - weight: 1, - required: true, - attribute: 'SeriesInstanceUID', - constraint: { - equals: { - value: SeriesInstanceUID, - }, + viewports: [ + { + viewportSettings: {}, + imageMatchingRules: [], + seriesMatchingRules: [ + { + id: 'mXnsCcNzZL56z7mTZ', + weight: 1, + required: true, + attribute: 'SeriesInstanceUID', + constraint: { + equals: { + value: SeriesInstanceUID, }, }, - ], - studyMatchingRules: [], - }, - ], - } - ] - } - ) + }, + ], + studyMatchingRules: [], + }, + ], + }, + ], + }); } ); return unsubscribe;