@@ -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==