diff --git a/extensions/cornerstone/package.json b/extensions/cornerstone/package.json index 106137d77..37b303da6 100644 --- a/extensions/cornerstone/package.json +++ b/extensions/cornerstone/package.json @@ -34,7 +34,7 @@ "cornerstone-math": "^0.1.8", "cornerstone-tools": "^4.8.0", "cornerstone-wado-image-loader": "^3.0.0", - "dcmjs": "^0.6.1", + "dcmjs": "^0.8.2", "dicom-parser": "^1.8.3", "hammerjs": "^2.0.8", "prop-types": "^15.6.2", diff --git a/extensions/cornerstone/src/init.js b/extensions/cornerstone/src/init.js index 37d47e5e8..e3077a905 100644 --- a/extensions/cornerstone/src/init.js +++ b/extensions/cornerstone/src/init.js @@ -123,6 +123,16 @@ export default function init({ servicesManager, configuration }) { csTools.addTool(tool, props); }); + // TODO -> We need a better way to do this with maybe global tool state setting all tools passive. + 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.setToolPassive(toolName); // there may be a better place to determine name; may not be on uninstantiated class + } + }); + csTools.setToolActive('Pan', { mouseButtonMask: 4 }); csTools.setToolActive('Zoom', { mouseButtonMask: 2 }); csTools.setToolActive('Wwwc', { mouseButtonMask: 1 }); diff --git a/extensions/dicom-html/package.json b/extensions/dicom-html/package.json index bf495ddcf..f74a3fa07 100644 --- a/extensions/dicom-html/package.json +++ b/extensions/dicom-html/package.json @@ -29,7 +29,7 @@ }, "peerDependencies": { "@ohif/core": "^0.50.0", - "dcmjs": "^0.6.1", + "dcmjs": "^0.8.2", "prop-types": "^15.6.2", "react": "^16.8.6", "react-dom": "^16.8.6" diff --git a/extensions/vtk/package.json b/extensions/vtk/package.json index 4fc0b6b1b..d7ec691bb 100644 --- a/extensions/vtk/package.json +++ b/extensions/vtk/package.json @@ -34,7 +34,7 @@ "@ohif/ui": "^0.50.0", "cornerstone-core": "^2.2.8", "cornerstone-wado-image-loader": "^3.0.0", - "dcmjs": "^0.6.1", + "dcmjs": "^0.8.2", "dicom-parser": "^1.8.3", "i18next": "^17.0.3", "i18next-browser-languagedetector": "^3.0.1", @@ -56,7 +56,6 @@ "@ohif/ui": "^1.0.1", "cornerstone-tools": "^4.8.0", "cornerstone-wado-image-loader": "^3.0.0", - "dcmjs": "^0.6.1", "dicom-parser": "^1.8.3", "gh-pages": "^2.0.1", "i18next": "^17.0.3", diff --git a/platform/core/src/measurements/classes/MeasurementApi.js b/platform/core/src/measurements/classes/MeasurementApi.js index 60f3dea7f..04db3592f 100644 --- a/platform/core/src/measurements/classes/MeasurementApi.js +++ b/platform/core/src/measurements/classes/MeasurementApi.js @@ -1,4 +1,5 @@ import cornerstoneTools from 'cornerstone-tools'; +import cornerstone from 'cornerstone-core'; import log from '../../log'; import getLabel from '../lib/getLabel'; import getDescription from '../lib/getDescription'; @@ -7,13 +8,32 @@ import guid from '../../utils/guid'; import studyMetadataManager from '../../utils/studyMetadataManager'; import { measurementApiDefaultConfig } from './../configuration.js'; + const configuration = { - ...measurementApiDefaultConfig, + ...measurementApiDefaultConfig }; export default class MeasurementApi { static Instance; + /** + * Set configuration: It should merge default configuration with any new one + * + * @static + * @param {Object} config + * @param {Object} config.server + * @param {string} config.server.type - The server type + * @param {string} config.server.wadoRoot - The server wado URL root + * @param {Array} config.measurementTools + * @param {string} config.measurementTools[].id - The tool group id + * @param {string} config.measurementTools[].name - The tool group name + * @param {Array} config.measurementTools[].childTools - The child tool's configuration + * @param {Object} config.dataExchange + * @param {Function} config.dataExchange.store - Function that store measurement data + * @param {Function} config.dataExchange.retrieve - Function that retrieves measurement data + * + * @memberof MeasurementApi + */ static setConfiguration(config) { Object.assign(configuration, config); } @@ -203,13 +223,14 @@ export default class MeasurementApi { retrieveMeasurements(patientId, timepointIds) { const retrievalFn = configuration.dataExchange.retrieve; + const { server } = configuration; if (typeof retrievalFn !== 'function') { log.error('Measurement retrieval function has not been configured.'); return; } return new Promise((resolve, reject) => { - retrievalFn(patientId, timepointIds).then(measurementData => { + retrievalFn(server).then(measurementData => { if (measurementData) { log.info('Measurement data retrieval'); log.info(measurementData); @@ -230,6 +251,10 @@ export default class MeasurementApi { // Synchronize the new tool data this.syncMeasurementsAndToolData(); + cornerstone.getEnabledElements().forEach(enabledElement => { + cornerstone.updateImage(enabledElement.element); + }); + // Let others know that the measurements are updated this.onMeasurementsUpdated(); }, reject); @@ -237,6 +262,7 @@ export default class MeasurementApi { } storeMeasurements(timepointId) { + const { server } = configuration; const storeFn = configuration.dataExchange.store; if (typeof storeFn !== 'function') { log.error('Measurement store function has not been configured.'); @@ -278,8 +304,9 @@ export default class MeasurementApi { }; log.info('Saving Measurements for timepoints:', timepoints); - return storeFn(measurementData, filter).then(() => { + return storeFn(measurementData, filter, server).then(result => { log.info('Measurement storage completed'); + return result; }); } diff --git a/platform/ui/src/components/measurementTable/MeasurementTable.js b/platform/ui/src/components/measurementTable/MeasurementTable.js index 9ea4aa86b..494f2e8c5 100644 --- a/platform/ui/src/components/measurementTable/MeasurementTable.js +++ b/platform/ui/src/components/measurementTable/MeasurementTable.js @@ -22,8 +22,9 @@ class MeasurementTable extends Component { onDeleteClick: PropTypes.func, onEditDescriptionClick: PropTypes.func, selectedMeasurementNumber: PropTypes.number, - overwallWarnings: PropTypes.object, t: PropTypes.func, + saveFunction: PropTypes.func, + onSaveComplete: PropTypes.func, }; static defaultProps = { @@ -38,8 +39,9 @@ class MeasurementTable extends Component { }; render() { - const hasOverallWarnings = - this.props.overallWarnings.warningList.length > 0; + const { overallWarnings, saveFunction, t } = this.props; + const hasOverallWarnings = overallWarnings.warningList.length > 0; + return (
@@ -55,7 +57,7 @@ class MeasurementTable extends Component { style={{}} >
- {this.props.t('Criteria nonconformities')} + {t('Criteria nonconformities')}
{this.getWarningContent()} @@ -75,10 +77,42 @@ class MeasurementTable extends Component {
{this.getMeasurementsGroups()}
+
+ {saveFunction && ( + + )} +
); } + saveFunction = async event => { + const { saveFunction, onSaveComplete } = this.props; + if (saveFunction) { + try { + const result = await saveFunction(); + if (onSaveComplete) { + onSaveComplete({ + title: 'STOW SR', + message: result.message, + type: 'success', + }); + } + } catch (error) { + if (onSaveComplete) { + onSaveComplete({ + title: 'STOW SR', + message: error.message, + type: 'error', + }); + } + } + } + }; + getMeasurementsGroups = () => { return this.props.measurementCollection.map((measureGroup, index) => { return ( @@ -157,7 +191,7 @@ class MeasurementTable extends Component { }; getWarningContent = () => { - const { warningList = '' } = this.props.overwallWarnings; + const { warningList = '' } = this.props.overallWarnings; if (Array.isArray(warningList)) { const listedWarnings = warningList.map((warn, index) => { diff --git a/platform/ui/src/components/measurementTable/MeasurementTable.styl b/platform/ui/src/components/measurementTable/MeasurementTable.styl index 14cf50e4d..82a80c99e 100644 --- a/platform/ui/src/components/measurementTable/MeasurementTable.styl +++ b/platform/ui/src/components/measurementTable/MeasurementTable.styl @@ -1,92 +1,117 @@ .measurementTable background-color: var(--primary-background-color) - height: 100%; - width: 100%; - display: flex; - flex-direction: column; + height: 100% + width: 100% + display: flex + flex-direction: column .maxMeasurements - border-radius: 3px; - color: var(--primary-background-color); - font-size: 12px; - font-weight: 500; - height: 19px; - line-height: 17px; - margin-top: 22px; - margin-right: 15px; - padding: 2px 6px 0; - text-transform: uppercase; - transition: all 0.3s ease; - background-color: var(--default-color); + border-radius: 3px + color: var(--primary-background-color) + font-size: 12px + font-weight: 500 + height: 19px + line-height: 17px + margin-top: 22px + margin-right: 15px + padding: 2px 6px 0 + text-transform: uppercase + transition: all 0.3s ease + background-color: var(--default-color) .measurementTableHeader - padding-left: 45px; + padding-left: 45px .measurementTableHeaderItem - display: inline-block; - margin-top: 9px; - margin-left: 9px; - padding-left: 9px; - width: 90px; - font-size: 14px; - line-height: 20px; - border-left: 1px solid var(--text-secondary-color); - color: var(--text-primary-color); + display: inline-block + margin-top: 9px + margin-left: 9px + padding-left: 9px + width: 90px + font-size: 14px + line-height: 20px + border-left: 1px solid var(--text-secondary-color) + color: var(--text-primary-color) .timepointLabel - color: var(--text-secondary-color); - font-size: 12px; + color: var(--text-secondary-color) + font-size: 12px .warning-status - display: block; - position: absolute; - width: 50px; - height: 40px; - padding-left: 7px; - left: 15px; - top: 7px; + display: block + position: absolute + width: 50px + height: 40px + padding-left: 7px + left: 15px + top: 7px .warning-border - display: block; - border: 2px solid #e29e4a; - border-radius: 36px; - padding: 6px 7px; - width: 40px; - height: 40px; + display: block + border: 2px solid #e29e4a + border-radius: 36px + padding: 6px 7px + width: 40px + height: 40px svg - color: #e29e4a; - width: 22px; - height: 20px; - pointer-events: inherit; + color: #e29e4a + width: 22px + height: 20px + pointer-events: inherit + + .measurementTableFooter + padding: 10px + color: #fff + text-align: center + z-index: 1 + + .saveBtn + border: 1px solid var(--ui-border-color-active) + border-radius: 25px + background-color: var(--active-color) + color: var(--ui-gray-dark) + line-height: 25px + padding: 0 20px + outline: none + cursor: pointer + + &:hover, &:active + background-color: var(--ui-sky-blue) + + svg + margin-right: 4px + position: relative + top: 2px + .tooltip-warning &.in - opacity 1; + opacity 1 .tooltip-inner padding: 0 .warningTitle text-align: left - background-color: var(--ui-gray-darkest); - padding: 7px 15px; - border-top-left-radius: 7px; - border-top-right-radius: 7px; + background-color: var(--ui-gray-darkest) + padding: 7px 15px + border-top-left-radius: 7px + border-top-right-radius: 7px .warningContent text-align center - background-color: var(--ui-gray); - border-bottom-left-radius: 7px; - border-bottom-right-radius: 7px; + background-color: var(--ui-gray) + border-bottom-left-radius: 7px + border-bottom-right-radius: 7px ol - margin-top: auto; - padding-inline-start: 20px; - padding-top: 10px; - padding-bottom: 10px; - padding-left: 25px; - text-align: left; + margin-top: auto + padding-inline-start: 20px + padding-top: 10px + padding-bottom: 10px + padding-left: 25px + text-align: left li - padding-right: 10px; + padding-right: 10px diff --git a/platform/ui/src/elements/Icon/getIcon.js b/platform/ui/src/elements/Icon/getIcon.js index 9651bff5e..ce6bbf173 100644 --- a/platform/ui/src/elements/Icon/getIcon.js +++ b/platform/ui/src/elements/Icon/getIcon.js @@ -62,6 +62,7 @@ import powerOff from './icons/power-off.svg'; import reset from './icons/reset.svg'; import rotate from './icons/rotate.svg'; import rotateRight from './icons/rotate-right.svg'; +import saveRegular from './icons/save-regular.svg'; import search from './icons/search.svg'; import searchPlus from './icons/search-plus.svg'; import softTissue from './icons/soft-tissue.svg'; @@ -164,6 +165,7 @@ const ICONS = { 'soft-tissue': softTissue, lung, liver, + save: saveRegular, }; /** diff --git a/platform/ui/src/elements/Icon/icons/save-regular.svg b/platform/ui/src/elements/Icon/icons/save-regular.svg new file mode 100644 index 000000000..cd3eb9592 --- /dev/null +++ b/platform/ui/src/elements/Icon/icons/save-regular.svg @@ -0,0 +1,11 @@ + diff --git a/platform/viewer/package.json b/platform/viewer/package.json index 91eca158b..76d54016a 100644 --- a/platform/viewer/package.json +++ b/platform/viewer/package.json @@ -60,7 +60,7 @@ "cornerstone-math": "^0.1.8", "cornerstone-tools": "^4.8.0", "cornerstone-wado-image-loader": "^3.0.0", - "dcmjs": "^0.6.1", + "dcmjs": "^0.8.2", "dicom-parser": "^1.8.3", "dicomweb-client": "^0.4.4", "hammerjs": "^2.0.8", diff --git a/platform/viewer/src/appExtensions/MeasurementsPanel/ConnectedMeasurementTable.js b/platform/viewer/src/appExtensions/MeasurementsPanel/ConnectedMeasurementTable.js index 265a2e585..ebf7b7313 100644 --- a/platform/viewer/src/appExtensions/MeasurementsPanel/ConnectedMeasurementTable.js +++ b/platform/viewer/src/appExtensions/MeasurementsPanel/ConnectedMeasurementTable.js @@ -3,12 +3,20 @@ import { MeasurementTable } from '@ohif/ui'; import OHIF from '@ohif/core'; import moment from 'moment'; import cornerstone from 'cornerstone-core'; +import DICOMSR from './../../lib/DICOMSR'; import jumpToRowItem from './jumpToRowItem.js'; const { setViewportSpecificData } = OHIF.redux.actions; const { MeasurementApi } = OHIF.measurements; +/** + * Takes a list of objects and a property and return the list grouped by the property + * + * @param {Array} list - The objects to be grouped by + * @param {string} props - The property to group the objects + * @returns {Object} + */ function groupBy(list, props) { return list.reduce((a, b) => { (a[b[props]] = a[b[props]] || []).push(b); @@ -16,16 +24,29 @@ function groupBy(list, props) { }, {}); } -function getAllTools() { - const config = OHIF.measurements.MeasurementApi.getConfiguration(); +/** + * Takes a list of tools grouped and return all tools separately + * + * @param {Array} [toolGroups=[]] - The grouped tools + * @returns {Array} - The list of all tools on all groups + */ +function getAllTools(toolGroups = []) { let tools = []; - config.measurementTools.forEach( + toolGroups.forEach( toolGroup => (tools = tools.concat(toolGroup.childTools)) ); return tools; } +/** + * Takes measurementData and build the measurement text to be used into the table + * + * @param {Object} [measurementData={}] + * @param {string} measurementData.location - The measurement location + * @param {string} measurementData.description - The measurement description + * @returns {string} + */ function getMeasurementText(measurementData = {}) { const defaultText = '...'; const { location = '', description = '' } = measurementData; @@ -34,6 +55,14 @@ function getMeasurementText(measurementData = {}) { return result || defaultText; } +/** + * Takes a list of measurements grouped by measurement numbers and return each measurement data by available timepoint + * + * @param {Array} measurementNumberList - The list of measurements + * @param {Array} timepoints - The list of available timepoints + * @param {Function} displayFunction - The function that builds the display text by each tool + * @returns + */ function getDataForEachMeasurementNumber( measurementNumberList, timepoints, @@ -56,10 +85,35 @@ function getDataForEachMeasurementNumber( return data; } +/** + * Take a measurement toolName and return if any warnings + * + * @param {string} toolName - The tool name + * @returns {string} + */ +function getWarningsForMeasurement(toolName) { + const isToolSupported = DICOMSR.isToolSupported(toolName); + + return { + hasWarnings: !isToolSupported, + warningTitle: isToolSupported ? '' : 'Unsupported Tool', + warningList: isToolSupported + ? [] + : [`${toolName} cannot be persisted at this time`], + }; +} + +/** + * Take measurements from MeasurementAPI structure and convert into a measurementTable structure + * + * @param {Object} toolCollections - The list of all measurement grouped by groupTool and toolName + * @param {Array} timepoints - The list of available timepoints + * @returns + */ function convertMeasurementsToTableData(toolCollections, timepoints) { const config = OHIF.measurements.MeasurementApi.getConfiguration(); const toolGroups = config.measurementTools; - const tools = getAllTools(); + const tools = getAllTools(toolGroups); const tableMeasurements = toolGroups.map(toolGroup => { return { @@ -88,6 +142,12 @@ function convertMeasurementsToTableData(toolCollections, timepoints) { } = measurementData; const measurementId = measurementData._id; + const { + hasWarnings, + warningTitle, + warningList, + } = getWarningsForMeasurement(toolType); + //check if all measurements with same measurementNumber will have same LABEL const tableMeasurement = { itemNumber: lesionNamingNumber, @@ -96,10 +156,10 @@ function convertMeasurementsToTableData(toolCollections, timepoints) { measurementNumber, lesionNamingNumber, toolType, - hasWarnings: false, //TODO - warningTitle: '', //TODO + hasWarnings, + warningTitle, + warningList, isSplitLesion: false, //TODO - warningList: [], //TODO data: getDataForEachMeasurementNumber( measurementNumberList, timepoints, @@ -126,6 +186,13 @@ function convertMeasurementsToTableData(toolCollections, timepoints) { return tableMeasurements; } +/** + * Take a list of available timepoints and return a list header information for each timepoint + * + * @param {Array} timepoints - The list of available timepoints + * @param {string} timepoints[].latestDate - The date of the last study taken on the timepoint + * @returns {{label: string, key: string, date: string}[]} + */ function convertTimepointsToTableData(timepoints) { if (!timepoints || !timepoints.length) { return []; @@ -140,8 +207,28 @@ function convertTimepointsToTableData(timepoints) { ]; } +/** + * Takes server type and return a function or undefined + * + * @param {string} serverType - The server type + * @returns {undefined|Function} + */ +function getSaveFunction(serverType) { + if (serverType === 'dicomWeb') { + return () => { + const measurementApi = OHIF.measurements.MeasurementApi.Instance; + const promise = measurementApi.storeMeasurements(); + return promise; + }; + } +} + const mapStateToProps = state => { - const { timepoints, measurements } = state.timepointManager; + const { timepointManager, servers } = state; + const { timepoints, measurements } = timepointManager; + const activeServer = servers.servers.find(a => a.active === true); + const saveFunction = getSaveFunction(activeServer.type); + return { timepoints: convertTimepointsToTableData(timepoints), measurementCollection: convertMeasurementsToTableData( @@ -150,6 +237,7 @@ const mapStateToProps = state => { ), timepointManager: state.timepointManager, viewports: state.viewports, + saveFunction, }; }; @@ -265,10 +353,15 @@ const mapDispatchToProps = (dispatch, ownProps) => { }; const mergeProps = (propsFromState, propsFromDispatch, ownProps) => { + const { timepoints, saveFunction, measurementCollection } = propsFromState; + const { onSaveComplete, selectedMeasurementNumber } = ownProps; + return { - timepoints: propsFromState.timepoints, - measurementCollection: propsFromState.measurementCollection, - selectedMeasurementNumber: ownProps.selectedMeasurementNumber, + timepoints, + saveFunction, + measurementCollection, + onSaveComplete, + selectedMeasurementNumber, ...propsFromDispatch, onItemClick: (event, measurementData) => { // TODO: Add timepointId to .data for measurementData? diff --git a/platform/viewer/src/appExtensions/MeasurementsPanel/index.js b/platform/viewer/src/appExtensions/MeasurementsPanel/index.js index d240971da..cfa471964 100644 --- a/platform/viewer/src/appExtensions/MeasurementsPanel/index.js +++ b/platform/viewer/src/appExtensions/MeasurementsPanel/index.js @@ -11,8 +11,9 @@ export default { preRegistration({ servicesManager, commandsManager, configuration = {} }) { init({ servicesManager, commandsManager, configuration }); }, + getPanelModule({ servicesManager, commandsManager }) { - const { UILabellingFlowService } = servicesManager.services; + const { UILabellingFlowService, UINotificationService } = servicesManager.services; const ExtendedConnectedMeasurementTable = () => ( { @@ -38,6 +39,11 @@ export default { }); } }} + onSaveComplete={message => { + if (UINotificationService) { + UINotificationService.show(message); + } + }} /> ); return { diff --git a/platform/viewer/src/connectedComponents/ConnectedViewer.js b/platform/viewer/src/connectedComponents/ConnectedViewer.js index 3f51b03d8..a2373d670 100644 --- a/platform/viewer/src/connectedComponents/ConnectedViewer.js +++ b/platform/viewer/src/connectedComponents/ConnectedViewer.js @@ -1,13 +1,20 @@ -import { connect } from "react-redux"; -import Viewer from "./Viewer.js"; -import OHIF from "@ohif/core"; +import { connect } from 'react-redux'; +import Viewer from './Viewer.js'; +import OHIF from '@ohif/core'; const { setTimepoints, setMeasurements } = OHIF.redux.actions; -const mapStateToProps = (state, ownProps) => { +const getActiveServer = servers => { + const isActive = a => a.active === true; + return servers.servers.find(isActive); +}; + +const mapStateToProps = state => { + const { viewports, servers } = state; return { - viewports: state.viewports.viewportSpecificData, - activeViewportIndex: state.viewports.activeViewportIndex + viewports: viewports.viewportSpecificData, + activeViewportIndex: viewports.activeViewportIndex, + activeServer: getActiveServer(servers), }; }; @@ -18,7 +25,7 @@ const mapDispatchToProps = dispatch => { }, onMeasurementsUpdated: measurements => { dispatch(setMeasurements(measurements)); - } + }, }; }; diff --git a/platform/viewer/src/connectedComponents/Viewer.js b/platform/viewer/src/connectedComponents/Viewer.js index d112ac502..5a58e1ca4 100644 --- a/platform/viewer/src/connectedComponents/Viewer.js +++ b/platform/viewer/src/connectedComponents/Viewer.js @@ -11,6 +11,7 @@ import ConnectedStudyBrowser from './ConnectedStudyBrowser.js'; import ConnectedViewerMain from './ConnectedViewerMain.js'; import SidePanel from './../components/SidePanel.js'; import { extensionManager } from './../App.js'; +import DICOMSR from '../lib/DICOMSR'; // Contexts import WhiteLabellingContext from '../context/WhiteLabellingContext.js'; @@ -57,21 +58,31 @@ class Viewer extends Component { static propTypes = { studies: PropTypes.array, studyInstanceUids: PropTypes.array, + activeServer: PropTypes.shape({ + type: PropTypes.string, + wadoRoot: PropTypes.string, + }), onTimepointsUpdated: PropTypes.func, onMeasurementsUpdated: PropTypes.func, // window.store.getState().viewports.viewportSpecificData viewports: PropTypes.object.isRequired, // window.store.getState().viewports.activeViewportIndex activeViewportIndex: PropTypes.number.isRequired, + isStudyLoaded: PropTypes.bool, }; constructor(props) { super(props); + + const { activeServer } = this.props; + const server = Object.assign({}, activeServer); + OHIF.measurements.MeasurementApi.setConfiguration({ dataExchange: { - retrieve: this.retrieveMeasurements, - store: this.storeMeasurements, + retrieve: DICOMSR.retrieveMeasurements, + store: DICOMSR.storeMeasurements, }, + server, }); OHIF.measurements.TimepointApi.setConfiguration({ @@ -93,18 +104,6 @@ class Viewer extends Component { thumbnails: [], }; - retrieveMeasurements = (patientId, timepointIds) => { - OHIF.log.info('retrieveMeasurements'); - // TODO: Retrieve the measurements from the latest available SR - return Promise.resolve(); - }; - - storeMeasurements = (measurementData, timepointIds) => { - OHIF.log.info('storeMeasurements'); - // TODO: Store the measurements into a new SR sent to the active server - return Promise.resolve(); - }; - retrieveTimepoints = filter => { OHIF.log.info('retrieveTimepoints'); @@ -171,7 +170,7 @@ class Viewer extends Component { }; componentDidMount() { - const { studies } = this.props; + const { studies, isStudyLoaded } = this.props; const { TimepointApi, MeasurementApi } = OHIF.measurements; const currentTimepointId = 'TimepointId'; @@ -191,8 +190,11 @@ class Viewer extends Component { const patientId = studies[0] && studies[0].patientId; timepointApi.retrieveTimepoints({ patientId }); - measurementApi.retrieveMeasurements(patientId, [currentTimepointId]); - + if (isStudyLoaded) { + this.measurementApi.retrieveMeasurements(patientId, [ + currentTimepointId, + ]); + } this.setState({ thumbnails: _mapStudiesToThumbnails(studies), }); @@ -200,18 +202,19 @@ class Viewer extends Component { } componentDidUpdate(prevProps) { - if (this.props.studies !== prevProps.studies) { - const { studies } = this.props; - const patientId = studies[0] && studies[0].patientId; - const currentTimepointId = this.currentTimepointId; - - this.timepointApi.retrieveTimepoints({ patientId }); - this.measurementApi.retrieveMeasurements(patientId, [currentTimepointId]); - + const { studies, isStudyLoaded } = this.props; + if (studies !== prevProps.studies) { this.setState({ thumbnails: _mapStudiesToThumbnails(studies), }); } + if (isStudyLoaded && isStudyLoaded !== prevProps.isStudyLoaded) { + const patientId = studies[0] && studies[0].patientId; + const { currentTimepointId } = this; + + this.timepointApi.retrieveTimepoints({ patientId }); + this.measurementApi.retrieveMeasurements(patientId, [currentTimepointId]); + } } render() { @@ -294,11 +297,11 @@ class Viewer extends Component { activeIndex={this.props.activeViewportIndex} /> ) : ( - - )} + + )} {/* MAIN */} @@ -334,7 +337,7 @@ export default Viewer; * @param {Study[]} studies * @param {DisplaySet[]} studies[].displaySets */ -const _mapStudiesToThumbnails = function(studies) { +const _mapStudiesToThumbnails = function (studies) { return studies.map(study => { const { studyInstanceUid } = study; diff --git a/platform/viewer/src/connectedComponents/ViewerRetrieveStudyData.js b/platform/viewer/src/connectedComponents/ViewerRetrieveStudyData.js index 1b32e018a..b18ca1fa8 100644 --- a/platform/viewer/src/connectedComponents/ViewerRetrieveStudyData.js +++ b/platform/viewer/src/connectedComponents/ViewerRetrieveStudyData.js @@ -178,6 +178,7 @@ function ViewerRetrieveStudyData({ // hooks const [error, setError] = useState(false); const [studies, setStudies] = useState([]); + const [isStudyLoaded, setIsStudyLoaded] = useState(false); const snackbarContext = useSnackbarContext(); const { appConfig = {} } = useContext(AppContext); const { filterQueryParam: isFilterStrategy = false } = appConfig; @@ -218,6 +219,7 @@ function ViewerRetrieveStudyData({ ); setStudies([...studies, study]); + setIsStudyLoaded(true); }; /** @@ -332,7 +334,11 @@ function ViewerRetrieveStudyData({ } return ( - + ); } diff --git a/platform/viewer/src/lib/DICOMSR/dataExchange.js b/platform/viewer/src/lib/DICOMSR/dataExchange.js new file mode 100644 index 000000000..344183b85 --- /dev/null +++ b/platform/viewer/src/lib/DICOMSR/dataExchange.js @@ -0,0 +1,73 @@ +import { log, studies, utils } from '@ohif/core'; +import { retrieveMeasurementFromSR, stowSRFromMeasurements } from './handleStructuredReport'; +import findMostRecentStructuredReport from './utils/findMostRecentStructuredReport'; + +/** + * + * @typedef serverType + * @property {string} type - type of the server + * @property {string} wadoRoot - server wado root url + * + */ + +/** + * Function to be registered into MeasurementAPI to retrieve measurements from DICOM Structured Reports + * + * @param {serverType} server + * @returns {Promise} Should resolve with OHIF measurementData object + */ +const retrieveMeasurements = server => { + log.info('[DICOMSR] retrieveMeasurements'); + + if (!server || server.type !== 'dicomWeb') { + log.error('[DICOMSR] DicomWeb server is required!'); + return Promise.reject({}); + } + + const serverUrl = server.wadoRoot; + const studies = utils.studyMetadataManager.all(); + const latestSeries = findMostRecentStructuredReport(studies); + + if (!latestSeries) return Promise.resolve({}); + + return retrieveMeasurementFromSR(latestSeries, studies, serverUrl); +}; + +/** + * Function to be registered into MeasurementAPI to store measurements into DICOM Structured Reports + * + * @param {Object} measurementData - OHIF measurementData object + * @param {Object} filter + * @param {serverType} server + * @returns {Object} With message to be displayed on success + */ +const storeMeasurements = async (measurementData, filter, server) => { + log.info('[DICOMSR] storeMeasurements'); + + if (!server || server.type !== 'dicomWeb') { + log.error('[DICOMSR] DicomWeb server is required!'); + return Promise.reject({}); + } + + const serverUrl = server.wadoRoot; + const firstMeasurementKey = Object.keys(measurementData)[0]; + const firstMeasurement = measurementData[firstMeasurementKey][0]; + const studyInstanceUid = + firstMeasurement && firstMeasurement.studyInstanceUid; + + try { + await stowSRFromMeasurements(measurementData, serverUrl); + if (studyInstanceUid) { + studies.deleteStudyMetadataPromise(studyInstanceUid); + } + + return { + message: 'Measurements were saved with success', + }; + } catch (error) { + log.error(`[DICOMSR] Error while saving the measurements: ${error.message}`); + throw new Error('Error while saving the measurements.'); + } +}; + +export { retrieveMeasurements, storeMeasurements }; diff --git a/platform/viewer/src/lib/DICOMSR/handleStructuredReport.js b/platform/viewer/src/lib/DICOMSR/handleStructuredReport.js new file mode 100644 index 000000000..d72e6f685 --- /dev/null +++ b/platform/viewer/src/lib/DICOMSR/handleStructuredReport.js @@ -0,0 +1,84 @@ +import * as dcmjs from 'dcmjs'; +import { DICOMWeb } from '@ohif/core'; +import parseDicomStructuredReport from './parseDicomStructuredReport'; +import parseMeasurementsData from './parseMeasurementsData'; +import getAllDisplaySets from './utils/getAllDisplaySets'; + +import { api } from 'dicomweb-client'; + +const VERSION_NAME = 'dcmjs-0.0'; +const TRANSFER_SYNTAX_UID = '1.2.840.10008.1.2.1'; + +/** + * Function to retrieve measurements from DICOM Structured Reports coming from determined server + * + * @param {Array} series - List of all series metaData loaded + * @param {Array} studies - List of all studies metaData loaded + * @param {string} serverUrl - Server URL to be used on request + * @returns {Object} MeasurementData + */ +const retrieveMeasurementFromSR = async (series, studies, serverUrl) => { + const config = { + url: serverUrl, + headers: DICOMWeb.getAuthorizationHeader(), + }; + + const dicomWeb = new api.DICOMwebClient(config); + + const instance = series.getFirstInstance(); + const options = { + studyInstanceUID: instance.getStudyInstanceUID(), + seriesInstanceUID: instance.getSeriesInstanceUID(), + sopInstanceUID: instance.getSOPInstanceUID(), + }; + + const part10SRArrayBuffer = await dicomWeb.retrieveInstance(options); + const displaySets = getAllDisplaySets(studies); + const measurementsData = parseDicomStructuredReport(part10SRArrayBuffer, displaySets); + + return measurementsData; +}; + +/** + * Function to store measurements to DICOM Structured Reports in determined server + * + * @param {Object} measurements - OHIF measurementData object + * @param {string} serverUrl - Server URL to be used on request + * @returns {Promise} + */ +const stowSRFromMeasurements = async (measurements, serverUrl) => { + const { dataset } = parseMeasurementsData( + measurements + ); + const { DicomMetaDictionary, DicomDict } = dcmjs.data; + const meta = { + FileMetaInformationVersion: + dataset._meta.FileMetaInformationVersion.Value, + MediaStorageSOPClassUID: dataset.SOPClassUID, + MediaStorageSOPInstanceUID: dataset.SOPInstanceUID, + TransferSyntaxUID: TRANSFER_SYNTAX_UID, + ImplementationClassUID: DicomMetaDictionary.uid(), + ImplementationVersionName: VERSION_NAME, + }; + + const denaturalized = DicomMetaDictionary.denaturalizeDataset(meta); + const dicomDict = new DicomDict(denaturalized); + + dicomDict.dict = DicomMetaDictionary.denaturalizeDataset(dataset); + + const part10Buffer = dicomDict.write(); + + const config = { + url: serverUrl, + headers: DICOMWeb.getAuthorizationHeader(), + }; + + const dicomWeb = new api.DICOMwebClient(config); + const options = { + datasets: [part10Buffer], + }; + + await dicomWeb.storeInstances(options); +}; + +export { retrieveMeasurementFromSR, stowSRFromMeasurements }; diff --git a/platform/viewer/src/lib/DICOMSR/index.js b/platform/viewer/src/lib/DICOMSR/index.js new file mode 100644 index 000000000..786202642 --- /dev/null +++ b/platform/viewer/src/lib/DICOMSR/index.js @@ -0,0 +1,10 @@ +import { retrieveMeasurements, storeMeasurements } from './dataExchange'; +import isToolSupported from './utils/isToolSupported'; + +const DICOMSR = { + retrieveMeasurements, + storeMeasurements, + isToolSupported, +}; + +export default DICOMSR; diff --git a/platform/viewer/src/lib/DICOMSR/parseDicomStructuredReport.js b/platform/viewer/src/lib/DICOMSR/parseDicomStructuredReport.js new file mode 100644 index 000000000..2f5796316 --- /dev/null +++ b/platform/viewer/src/lib/DICOMSR/parseDicomStructuredReport.js @@ -0,0 +1,82 @@ +import * as dcmjs from 'dcmjs'; +import findInstanceMetadataBySopInstanceUid from './utils/findInstanceMetadataBySopInstanceUid'; + +/** + * Function to parse the part10 array buffer that comes from a DICOM Structured report into measurementData + * measurementData format is a viewer specific format to be stored into the redux and consumed by other components + * (e.g. measurement table) + * + * @param {ArrayBuffer} part10SRArrayBuffer + * @param {Array} displaySets + * @returns + */ +const parseDicomStructuredReport = (part10SRArrayBuffer, displaySets) => { + // Get the dicom data as an Object + const dicomData = dcmjs.data.DicomMessage.readFile(part10SRArrayBuffer); + const dataset = dcmjs.data.DicomMetaDictionary.naturalizeDataset( + dicomData.dict + ); + + const { MeasurementReport } = dcmjs.adapters.Cornerstone; + const storedMeasurementByToolType = MeasurementReport.generateToolState( + dataset + ); + const measurementData = {}; + let measurementNumber = 0; + + Object.keys(storedMeasurementByToolType).forEach(toolName => { + const measurements = storedMeasurementByToolType[toolName]; + measurementData[toolName] = []; + + measurements.forEach(measurement => { + const instanceMetadata = findInstanceMetadataBySopInstanceUid( + displaySets, + measurement.sopInstanceUid + ); + const { _study: study, _series: series } = instanceMetadata; + const { studyInstanceUid, patientId } = study; + const { seriesInstanceUid } = series; + const { sopInstanceUid, frameIndex } = measurement; + const imagePath = getImagePath(studyInstanceUid, seriesInstanceUid, sopInstanceUid, frameIndex); + + const imageId = instanceMetadata.getImageId(); + if (!imageId) { + return; + } + + // TODO: We need the currentTimepointID set into the viewer + const currentTimepointId = 'TimepointId'; + + const toolData = Object.assign({}, measurement, { + imageId, + imagePath, + seriesInstanceUid, + studyInstanceUid, + patientId, + measurementNumber: ++measurementNumber, + timepointId: currentTimepointId, + toolType: toolName, + _id: imageId + measurementNumber, + }); + + measurementData[toolName].push(toolData); + }); + }); + + return measurementData; +}; + +/** + * Function to create imagePath with all imageData related + * + * @param {string} studyInstanceUid + * @param {string} seriesInstanceUid + * @param {string} sopInstanceUid + * @param {string} frameIndex + * @returns + */ +const getImagePath = (studyInstanceUid, seriesInstanceUid, sopInstanceUid, frameIndex) => { + return [studyInstanceUid, seriesInstanceUid, sopInstanceUid, frameIndex].join('_'); +} + +export default parseDicomStructuredReport; diff --git a/platform/viewer/src/lib/DICOMSR/parseMeasurementsData.js b/platform/viewer/src/lib/DICOMSR/parseMeasurementsData.js new file mode 100644 index 000000000..fe72bdcb3 --- /dev/null +++ b/platform/viewer/src/lib/DICOMSR/parseMeasurementsData.js @@ -0,0 +1,55 @@ +import * as dcmjs from 'dcmjs'; +import { log, measurements } from '@ohif/core'; +import cornerstone from 'cornerstone-core'; +import isToolSupported from './utils/isToolSupported'; + +/** + * Function to parse OHIF viewer measurementData into a dcmjs MeasurementReport + * + * @param {Object} measurementsData - OHIF measurementData object + * @returns {Object} Dataset: measurement report from dcmjs + */ +const parseMeasurementsData = measurementsData => { + const { MeasurementReport } = dcmjs.adapters.Cornerstone; + const { getImageIdForImagePath } = measurements; + + const toolState = {}; + const unsupportedTools = []; + + Object.keys(measurementsData).forEach(measurementType => { + const annotations = measurementsData[measurementType]; + + annotations.forEach(annotation => { + const { toolType, imagePath } = annotation; + + if (isToolSupported(toolType)) { + const imageId = getImageIdForImagePath(imagePath); + toolState[imageId] = toolState[imageId] || {}; + toolState[imageId][toolType] = toolState[imageId][toolType] || { + data: [], + }; + + toolState[imageId][toolType].data.push(annotation); + } else { + unsupportedTools.push(toolType); + } + }); + }); + + if (unsupportedTools.length > 0) { + log.warn( + `[DICOMSR] Tooltypes not supported: ${unsupportedTools.join(', ')}` + ); + } + + const report = MeasurementReport.generateReport( + toolState, + cornerstone.metaData + ); + + return { + dataset: report.dataset, + }; +}; + +export default parseMeasurementsData; diff --git a/platform/viewer/src/lib/DICOMSR/utils/findInstanceMetadataBySopInstanceUid.js b/platform/viewer/src/lib/DICOMSR/utils/findInstanceMetadataBySopInstanceUid.js new file mode 100644 index 000000000..3911c67ff --- /dev/null +++ b/platform/viewer/src/lib/DICOMSR/utils/findInstanceMetadataBySopInstanceUid.js @@ -0,0 +1,22 @@ +/** + * Should Find the requested instance metadata into the displaySets and return + * + * @param {Array} displaySets - List of displaySets + * @param {string} sopInstanceUid - sopInstanceUID to look for + * @returns {Object} - instance metadata found + */ +const findInstanceMetadataBySopInstanceUid = (displaySets, sopInstanceUid) => { + let instanceFound; + + displaySets.find(displaySet => { + if (!displaySet.images) return false; + + instanceFound = displaySet.images.find(instanceMetadata => instanceMetadata._sopInstanceUID === sopInstanceUid); + + return !!instanceFound; + }); + + return instanceFound; +}; + +export default findInstanceMetadataBySopInstanceUid; diff --git a/platform/viewer/src/lib/DICOMSR/utils/findMostRecentStructuredReport.js b/platform/viewer/src/lib/DICOMSR/utils/findMostRecentStructuredReport.js new file mode 100644 index 000000000..44dd1ffc0 --- /dev/null +++ b/platform/viewer/src/lib/DICOMSR/utils/findMostRecentStructuredReport.js @@ -0,0 +1,57 @@ +/** + * Should find the most recent Structured Report metadata + * + * @param {Array} studies + * @returns {Object} Series + */ +const findMostRecentStructuredReport = (studies) => { + let mostRecentStructuredReport; + + studies.forEach(study => { + const allSeries = study.getSeries ? study.getSeries() : []; + allSeries.forEach(series => { + + if (isStructuredReportSeries(series)) { + if (!mostRecentStructuredReport || compareSeriesDate(series, mostRecentStructuredReport)) { + mostRecentStructuredReport = series; + } + } + }); + }); + + return mostRecentStructuredReport; +}; + +/** + * Checks if series sopClassUID matches with the supported Structured Reports sopClassUID + * + * @param {Object} series - Series metadata + * @returns {boolean} + */ +const isStructuredReportSeries = (series) => { + const supportedSopClassUIDs = [ + '1.2.840.10008.5.1.4.1.1.88.22', + '1.2.840.10008.5.1.4.1.1.11.1', + ]; + + const firstInstance = series.getFirstInstance(); + const sopClassUid = firstInstance._instance.sopClassUid; + + return supportedSopClassUIDs.includes(sopClassUid); +} + +/** + * Checkes if series1 is newer than series2 + * + * @param {Object} series1 - Series Metadata 1 + * @param {Object} series2 - Series Metadata 2 + * @returns {boolean} true/false if series1 is newer than series2 + */ +const compareSeriesDate = (series1, series2) => { + return series1._data.seriesDate > series2._data.seriesDate || + (series1._data.seriesDate === series2._data.seriesDate && + series1._data.seriesTime > series2._data.seriesTime); +} + + +export default findMostRecentStructuredReport; diff --git a/platform/viewer/src/lib/DICOMSR/utils/getAllDisplaySets.js b/platform/viewer/src/lib/DICOMSR/utils/getAllDisplaySets.js new file mode 100644 index 000000000..4ea29323e --- /dev/null +++ b/platform/viewer/src/lib/DICOMSR/utils/getAllDisplaySets.js @@ -0,0 +1,19 @@ +/** + * Retrieve a list of all displaySets of all studies + * + * @param {Object} studies - List of studies loaded into the viewer + * @returns {Object} List of DisplaySets + */ +const getAllDisplaySets = (studies) => { + let allDisplaySets = []; + + studies.forEach(study => { + if (study.getDisplaySets) { + allDisplaySets = allDisplaySets.concat(study.getDisplaySets()); + } + }); + + return allDisplaySets; +}; + +export default getAllDisplaySets; diff --git a/platform/viewer/src/lib/DICOMSR/utils/isToolSupported.js b/platform/viewer/src/lib/DICOMSR/utils/isToolSupported.js new file mode 100644 index 000000000..5fc611115 --- /dev/null +++ b/platform/viewer/src/lib/DICOMSR/utils/isToolSupported.js @@ -0,0 +1,14 @@ +import * as dcmjs from 'dcmjs'; + +/** + * Checks if dcmjs has support to determined tool + * + * @param {string} toolName + * @returns {boolean} + */ +const isToolSupported = toolName => { + const adapter = dcmjs.adapters.Cornerstone; + return !!adapter[toolName]; +}; + +export default isToolSupported; diff --git a/yarn.lock b/yarn.lock index 571bf8e5b..9beec7890 100644 --- a/yarn.lock +++ b/yarn.lock @@ -876,7 +876,7 @@ core-js "^2.6.5" regenerator-runtime "^0.13.2" -"@babel/polyfill@^7.0.0", "@babel/polyfill@^7.2.5": +"@babel/polyfill@^7.0.0", "@babel/polyfill@^7.6.0": version "7.6.0" resolved "https://registry.yarnpkg.com/@babel/polyfill/-/polyfill-7.6.0.tgz#6d89203f8b6cd323e8d946e47774ea35dc0619cc" integrity sha512-q5BZJI0n/B10VaQQvln1IlDK3BTBJFbADx7tv+oXDPIDZuTo37H5Adb9jhlXm/fEN4Y7/64qD9mnrJJG7rmaTw== @@ -6535,14 +6535,14 @@ dateformat@^3.0.0: resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae" integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q== -dcmjs@^0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/dcmjs/-/dcmjs-0.6.1.tgz#453f764bca2cd8e3430126ba8385ef4c77071bcd" - integrity sha512-sJ0cRVZY02GSaAW5S9JPcv8+7iDtgrM0L/niYWAvu2piSa4bFSXwKi+t4XevAfkm+mF0sXZtaD15bw+/oiCYkw== +dcmjs@^0.8.2: + version "0.8.2" + resolved "https://registry.yarnpkg.com/dcmjs/-/dcmjs-0.8.2.tgz#e1107273764de0a3052244b79d999fd870e58650" + integrity sha512-HEd1Rq12qxZXVXr5B4xgl/foCWaXxioO5v42FLgiUjKrbQR6iKIVcZsmE+3/AvAurcHiQFfW5vk5K4AY7eAp5A== dependencies: - "@babel/polyfill" "^7.2.5" - "@babel/runtime" "^7.3.1" - loglevelnext "^3.0.0" + "@babel/polyfill" "^7.6.0" + "@babel/runtime" "^7.6.3" + loglevelnext "^3.0.1" ndarray "^1.0.18" debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.1, debug@^2.6.3, debug@^2.6.6, debug@^2.6.8, debug@^2.6.9: @@ -12373,7 +12373,7 @@ loglevelnext@^1.0.1, loglevelnext@^1.0.2: es6-symbol "^3.1.1" object.assign "^4.1.0" -loglevelnext@^3.0.0: +loglevelnext@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/loglevelnext/-/loglevelnext-3.0.1.tgz#e3e4659c4061c09264f6812c33586dc55a009a04" integrity sha512-JpjaJhIN1reaSb26SIxDGtE0uc67gPl19OMVHrr+Ggt6b/Vy60jmCtKgQBrygAH0bhRA2nkxgDvM+8QvR8r0YA==