diff --git a/Dockerfile b/Dockerfile index f989e6881..854b42faa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,7 @@ # Stage 1: Build the application # docker build -t ohif/viewer:latest . -FROM node:10.16.3-slim as builder +FROM node:14.3.0-slim as builder RUN mkdir /usr/src/app WORKDIR /usr/src/app @@ -30,6 +30,7 @@ WORKDIR /usr/src/app COPY .docker /usr/src/app/.docker COPY .webpack /usr/src/app/.webpack COPY extensions /usr/src/app/extensions +COPY modes /usr/src/app/modes COPY platform /usr/src/app/platform COPY .browserslistrc /usr/src/app/.browserslistrc COPY aliases.config.js /usr/src/app/aliases.config.js diff --git a/extensions/cornerstone/package.json b/extensions/cornerstone/package.json index 4dd657d40..6865c3beb 100644 --- a/extensions/cornerstone/package.json +++ b/extensions/cornerstone/package.json @@ -33,8 +33,8 @@ "@ohif/ui": "^0.50.0", "cornerstone-core": "^2.3.0", "cornerstone-math": "^0.1.8", - "cornerstone-tools": "4.15.1", - "dcmjs": "^0.12.3", + "cornerstone-tools": "4.16.0", + "dcmjs": "0.14.0", "cornerstone-wado-image-loader": "^3.1.2", "dicom-parser": "^1.8.3", "hammerjs": "^2.0.8", diff --git a/extensions/cornerstone/src/init.js b/extensions/cornerstone/src/init.js index 48809b2b4..5f80a9d6f 100644 --- a/extensions/cornerstone/src/init.js +++ b/extensions/cornerstone/src/init.js @@ -90,6 +90,7 @@ export default function init({ servicesManager, configuration }) { /* Add extension tools configuration here. */ const internalToolsConfig = { + /* TODO ArrowAnnotate input ArrowAnnotate: { configuration: { getTextCallback: (callback, eventDetails) => @@ -98,6 +99,7 @@ export default function init({ servicesManager, configuration }) { callInputDialog(data, eventDetails, callback), }, }, + */ }; /* Abstract tools configuration using extension configuration. */ @@ -166,27 +168,48 @@ export default function init({ servicesManager, configuration }) { const _initMeasurementService = measurementService => { /* Initialization */ - const { toAnnotation, toMeasurement } = measurementServiceMappingsFactory( - measurementService - ); + const { + Length, + Bidirectional, + EllipticalRoi, + ArrowAnnotate, + } = measurementServiceMappingsFactory(measurementService); const csToolsVer4MeasurementSource = measurementService.createSource( 'CornerstoneTools', '4' ); - /* Matching Criterias */ - const matchingCriteria = { - valueType: measurementService.VALUE_TYPES.POLYLINE, - points: 2, - }; - /* Mappings */ measurementService.addMapping( csToolsVer4MeasurementSource, 'Length', - matchingCriteria, - toAnnotation, - toMeasurement + Length.matchingCriteria, + Length.toAnnotation, + Length.toMeasurement + ); + + measurementService.addMapping( + csToolsVer4MeasurementSource, + 'Bidirectional', + Bidirectional.matchingCriteria, + Bidirectional.toAnnotation, + Bidirectional.toMeasurement + ); + + measurementService.addMapping( + csToolsVer4MeasurementSource, + 'EllipticalRoi', + EllipticalRoi.matchingCriteria, + EllipticalRoi.toAnnotation, + EllipticalRoi.toMeasurement + ); + + measurementService.addMapping( + csToolsVer4MeasurementSource, + 'ArrowAnnotate', + ArrowAnnotate.matchingCriteria, + ArrowAnnotate.toAnnotation, + ArrowAnnotate.toMeasurement ); return csToolsVer4MeasurementSource; diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/ArrowAnnotate.js b/extensions/cornerstone/src/utils/measurementServiceMappings/ArrowAnnotate.js new file mode 100644 index 000000000..7d2c1c3b2 --- /dev/null +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/ArrowAnnotate.js @@ -0,0 +1,48 @@ +import SUPPORTED_TOOLS from './constants/supportedTools'; +import getPointsFromHandles from './utils/getPointsFromHandles'; +import getSOPInstanceAttributes from './utils/getSOPInstanceAttributes'; + +const ArrowAnnotate = { + toAnnotation: (measurement, definition) => { + // TODO -> Implement when this is needed. + }, + toMeasurement: (csToolsAnnotation, getValueTypeFromToolType) => { + const { element, measurementData } = csToolsAnnotation; + const tool = + csToolsAnnotation.toolType || + csToolsAnnotation.toolName || + measurementData.toolType; + + const validToolType = toolName => SUPPORTED_TOOLS.includes(toolName); + + if (!validToolType(tool)) { + throw new Error('Tool not supported'); + } + + const { + SOPInstanceUID, + FrameOfReferenceUID, + SeriesInstanceUID, + StudyInstanceUID, + } = getSOPInstanceAttributes(element); + + const points = []; + points.push(measurementData.handles); + + return { + id: measurementData._measurementServiceId, + SOPInstanceUID: SOPInstanceUID, + FrameOfReferenceUID, + referenceSeriesUID: SeriesInstanceUID, + referenceStudyUID: StudyInstanceUID, + label: measurementData.text, + description: measurementData.description, + unit: measurementData.unit, + text: measurementData.text, + type: getValueTypeFromToolType(tool), + points: getPointsFromHandles(measurementData.handles), + }; + }, +}; + +export default ArrowAnnotate; diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/Bidirectional.js b/extensions/cornerstone/src/utils/measurementServiceMappings/Bidirectional.js new file mode 100644 index 000000000..5b6f3e893 --- /dev/null +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/Bidirectional.js @@ -0,0 +1,50 @@ +import SUPPORTED_TOOLS from './constants/supportedTools'; +import getSOPInstanceAttributes from './utils/getSOPInstanceAttributes'; + +const Bidirectional = { + toAnnotation: (measurement, definition) => { + // TODO -> Implement when this is needed. + }, + toMeasurement: (csToolsAnnotation, getValueTypeFromToolType) => { + const { element, measurementData } = csToolsAnnotation; + const tool = + csToolsAnnotation.toolType || + csToolsAnnotation.toolName || + measurementData.toolType; + + const validToolType = toolName => SUPPORTED_TOOLS.includes(toolName); + + if (!validToolType(tool)) { + throw new Error('Tool not supported'); + } + + const { + SOPInstanceUID, + FrameOfReferenceUID, + SeriesInstanceUID, + StudyInstanceUID, + } = getSOPInstanceAttributes(element); + + const { handles } = measurementData; + + const longAxis = [handles.start, handles.end]; + const shortAxis = [handles.perpendicularStart, handles.perpendicularEnd]; + + return { + id: measurementData._measurementServiceId, + SOPInstanceUID: SOPInstanceUID, + FrameOfReferenceUID, + referenceSeriesUID: SeriesInstanceUID, + referenceStudyUID: StudyInstanceUID, + label: measurementData.text, + description: measurementData.description, + unit: measurementData.unit, + shortestDiameter: measurementData.shortestDiameter, + longestDiameter: measurementData.longestDiameter, + type: getValueTypeFromToolType(tool), + points: { longAxis, shortAxis }, + }; + }, +}; + +export default Bidirectional; diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/EllipticalRoi.js b/extensions/cornerstone/src/utils/measurementServiceMappings/EllipticalRoi.js new file mode 100644 index 000000000..dbd4d2403 --- /dev/null +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/EllipticalRoi.js @@ -0,0 +1,74 @@ +import SUPPORTED_TOOLS from './constants/supportedTools'; +import getSOPInstanceAttributes from './utils/getSOPInstanceAttributes'; + +const EllipticalRoi = { + toAnnotation: (measurement, definition) => { + // TODO -> Implement when this is needed. + }, + toMeasurement: (csToolsAnnotation, getValueTypeFromToolType) => { + const { element, measurementData } = csToolsAnnotation; + const tool = + csToolsAnnotation.toolType || + csToolsAnnotation.toolName || + measurementData.toolType; + + const validToolType = toolName => SUPPORTED_TOOLS.includes(toolName); + + if (!validToolType(tool)) { + throw new Error('Tool not supported'); + } + + const { + SOPInstanceUID, + FrameOfReferenceUID, + SeriesInstanceUID, + StudyInstanceUID, + } = getSOPInstanceAttributes(element); + + const { start, end } = measurementData.handles; + + const halfXLength = Math.abs(start.x - end.x) / 2; + const halfYLength = Math.abs(start.y - end.y) / 2; + + const points = []; + const center = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 }; + + // To store similar to SR. + if (halfXLength > halfYLength) { + // X-axis major + // Major axis + points.push({ x: center.x - halfXLength, y: center.y }); + points.push({ x: center.x + halfXLength, y: center.y }); + // Minor axis + points.push({ x: center.x, y: center.y - halfYLength }); + points.push({ x: center.x, y: center.y + halfYLength }); + } else { + // Y-axis major + // Major axis + points.push({ x: center.x, y: center.y - halfYLength }); + points.push({ x: center.x, y: center.y + halfYLength }); + // Minor axis + points.push({ x: center.x - halfXLength, y: center.y }); + points.push({ x: center.x + halfXLength, y: center.y }); + } + + return { + id: measurementData._measurementServiceId, + SOPInstanceUID: SOPInstanceUID, + FrameOfReferenceUID, + referenceSeriesUID: SeriesInstanceUID, + referenceStudyUID: StudyInstanceUID, + label: measurementData.text, + description: measurementData.description, + unit: measurementData.unit, + area: + measurementData.cachedStats && + measurementData.cachedStats + .area /* TODO: Add concept names instead (descriptor) */, + type: getValueTypeFromToolType(tool), + points, + }; + }, +}; + +export default EllipticalRoi; diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/Length.js b/extensions/cornerstone/src/utils/measurementServiceMappings/Length.js new file mode 100644 index 000000000..5f9eda649 --- /dev/null +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/Length.js @@ -0,0 +1,79 @@ +import SUPPORTED_TOOLS from './constants/supportedTools'; +import getHandlesFromPoints from './utils/getHandlesFromPoints'; +import getPointsFromHandles from './utils/getPointsFromHandles'; +import getSOPInstanceAttributes from './utils/getSOPInstanceAttributes'; + +const Length = { + toAnnotation: (measurement, definition) => { + const { + id, + label, + description, + points, + unit, + SOPInstanceUID, + FrameOfReferenceUID, + referenceSeriesUID, + } = measurement; + + return { + toolName: definition, + measurementData: { + sopInstanceUid: SOPInstanceUID, + frameOfReferenceUID: FrameOfReferenceUID, + SeriesInstanceUID: referenceSeriesUID, + unit, + text: label, + description, + handles: getHandlesFromPoints(points), + _measurementServiceId: id, + }, + }; + }, + + /** + * Maps cornerstone annotation event data to measurement service format. + * + * @param {Object} cornerstone Cornerstone event data + * @return {Measurement} Measurement instance + */ + toMeasurement: (csToolsAnnotation, getValueTypeFromToolType) => { + const { element, measurementData } = csToolsAnnotation; + const tool = + csToolsAnnotation.toolType || + csToolsAnnotation.toolName || + measurementData.toolType; + + const validToolType = toolName => SUPPORTED_TOOLS.includes(toolName); + + if (!validToolType(tool)) { + throw new Error('Tool not supported'); + } + + const { + SOPInstanceUID, + FrameOfReferenceUID, + SeriesInstanceUID, + StudyInstanceUID, + } = getSOPInstanceAttributes(element); + + const points = []; + points.push(measurementData.handles); + + return { + id: measurementData._measurementServiceId, + SOPInstanceUID: SOPInstanceUID, + FrameOfReferenceUID, + referenceSeriesUID: SeriesInstanceUID, + referenceStudyUID: StudyInstanceUID, + label: measurementData.text, + description: measurementData.description, + unit: measurementData.unit, + length: measurementData.length, + type: getValueTypeFromToolType(tool), + points: getPointsFromHandles(measurementData.handles), + }; + }, +}; + +export default Length; diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/constants/supportedTools.js b/extensions/cornerstone/src/utils/measurementServiceMappings/constants/supportedTools.js new file mode 100644 index 000000000..a1ca5e83e --- /dev/null +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/constants/supportedTools.js @@ -0,0 +1 @@ +export default ['Length', 'EllipticalRoi', 'Bidirectional', 'ArrowAnnotate']; diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/measurementServiceMappingsFactory.js b/extensions/cornerstone/src/utils/measurementServiceMappings/measurementServiceMappingsFactory.js index f279eaec0..6e325fb19 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/measurementServiceMappingsFactory.js +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/measurementServiceMappingsFactory.js @@ -1,11 +1,7 @@ -import cornerstone from 'cornerstone-core'; - -const SUPPORTED_TOOLS = [ - 'Length', - 'EllipticalRoi', - 'RectangleRoi', - 'ArrowAnnotate', -]; +import Length from './Length'; +import Bidirectional from './Bidirectional'; +import ArrowAnnotate from './ArrowAnnotate'; +import EllipticalRoi from './EllipticalRoi'; const measurementServiceMappingsFactory = measurementService => { /** @@ -15,129 +11,87 @@ const measurementServiceMappingsFactory = measurementService => { * @param {string} definition The source definition * @return {Object} Cornerstone annotation data */ - const toAnnotation = (measurement, definition) => { - const { - id, - label, - description, - points, - unit, - SOPInstanceUID, - FrameOfReferenceUID, - referenceSeriesUID, - } = measurement; - - return { - toolName: definition, - measurementData: { - sopInstanceUid: SOPInstanceUID, - frameOfReferenceUID: FrameOfReferenceUID, - SeriesInstanceUID: referenceSeriesUID, - unit, - text: label, - description, - handles: _getHandlesFromPoints(points), - _measurementServiceId: id, - }, - }; - }; - - /** - * Maps cornerstone annotation event data to measurement service format. - * - * @param {Object} cornerstone Cornerstone event data - * @return {Measurement} Measurement instance - */ - const toMeasurement = csToolsAnnotation => { - const { element, measurementData } = csToolsAnnotation; - const tool = - csToolsAnnotation.toolType || - csToolsAnnotation.toolName || - measurementData.toolType; - - const validToolType = toolName => SUPPORTED_TOOLS.includes(toolName); - - if (!validToolType(tool)) { - throw new Error('Tool not supported'); - } - - const { - SOPInstanceUID, - FrameOfReferenceUID, - SeriesInstanceUID, - StudyInstanceUID, - } = _getAttributes(element); - - const points = []; - points.push(measurementData.handles); - - return { - id: measurementData._measurementServiceId, - SOPInstanceUID: SOPInstanceUID, - FrameOfReferenceUID, - referenceSeriesUID: SeriesInstanceUID, - referenceStudyUID: StudyInstanceUID, - label: measurementData.text, - description: measurementData.description, - unit: measurementData.unit, - area: - measurementData.cachedStats && - measurementData.cachedStats - .area /* TODO: Add concept names instead (descriptor) */, - type: _getValueTypeFromToolType(tool), - points: _getPointsFromHandles(measurementData.handles), - }; - }; - - const _getAttributes = element => { - const enabledElement = cornerstone.getEnabledElement(element); - const imageId = enabledElement.image.imageId; - const instance = cornerstone.metaData.get('instance', imageId); - - return { - SOPInstanceUID: instance.SOPInstanceUID, - FrameOfReferenceUID: instance.FrameOfReferenceUID, - SeriesInstanceUID: instance.SeriesInstanceUID, - StudyInstanceUID: instance.StudyInstanceUID, - }; - }; const _getValueTypeFromToolType = toolType => { - const { POLYLINE, ELLIPSE, POINT } = measurementService.VALUE_TYPES; + const { + POLYLINE, + ELLIPSE, + POINT, + BIDIRECTIONAL, + } = measurementService.VALUE_TYPES; - /* TODO: Relocate static value types */ + // TODO -> I get why this was attemped, but its not nearly flexible enough. + // A single measurement may have an ellipse + a bidirectional measurement, for instances. + // You can't define a bidirectional tool as a single type.. const TOOL_TYPE_TO_VALUE_TYPE = { Length: POLYLINE, EllipticalRoi: ELLIPSE, - RectangleRoi: POLYLINE, + Bidirectional: BIDIRECTIONAL, ArrowAnnotate: POINT, }; return TOOL_TYPE_TO_VALUE_TYPE[toolType]; }; - const _getPointsFromHandles = handles => { - let points = []; - Object.keys(handles).map(handle => { - if (['start', 'end'].includes(handle)) { - let point = {}; - if (handles[handle].x) point.x = handles[handle].x; - if (handles[handle].y) point.y = handles[handle].y; - points.push(point); - } - }); - return points; - }; - - const _getHandlesFromPoints = points => { - return points - .map((p, i) => (i % 10 === 0 ? { start: p } : { end: p })) - .reduce((obj, item) => Object.assign(obj, { ...item }), {}); - }; - return { - toAnnotation, - toMeasurement, + Length: { + toAnnotation: Length.toAnnotation, + toMeasurement: csToolsAnnotation => + Length.toMeasurement(csToolsAnnotation, _getValueTypeFromToolType), + matchingCriteria: [ + { + valueType: measurementService.VALUE_TYPES.POLYLINE, + points: 2, + }, + ], + }, + Bidirectional: { + toAnnotation: Bidirectional.toAnnotation, + toMeasurement: csToolsAnnotation => + Bidirectional.toMeasurement( + csToolsAnnotation, + _getValueTypeFromToolType + ), + matchingCriteria: [ + // TODO -> We should eventually do something like shortAxis + longAxis, + // But its still a little unclear how these automatic interpretations will work. + { + valueType: measurementService.VALUE_TYPES.POLYLINE, + points: 2, + }, + { + valueType: measurementService.VALUE_TYPES.POLYLINE, + points: 2, + }, + ], + }, + ArrowAnnotate: { + toAnnotation: ArrowAnnotate.toAnnotation, + toMeasurement: csToolsAnnotation => + ArrowAnnotate.toMeasurement( + csToolsAnnotation, + _getValueTypeFromToolType + ), + matchingCriteria: [ + { + valueType: measurementService.VALUE_TYPES.POINT, + points: 1, + }, + ], + }, + EllipticalRoi: { + toAnnotation: EllipticalRoi.toAnnotation, + toMeasurement: csToolsAnnotation => + EllipticalRoi.toMeasurement( + csToolsAnnotation, + _getValueTypeFromToolType + ), + matchingCriteria: [ + { + valueType: measurementService.VALUE_TYPES.ELLIPSE, + }, + ], + }, }; }; diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/utils/getHandlesFromPoints.js b/extensions/cornerstone/src/utils/measurementServiceMappings/utils/getHandlesFromPoints.js new file mode 100644 index 000000000..1440405bc --- /dev/null +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/utils/getHandlesFromPoints.js @@ -0,0 +1,5 @@ +export default function getHandlesFromPoints(points) { + return points + .map((p, i) => (i % 10 === 0 ? { start: p } : { end: p })) + .reduce((obj, item) => Object.assign(obj, { ...item }), {}); +} diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/utils/getPointsFromHandles.js b/extensions/cornerstone/src/utils/measurementServiceMappings/utils/getPointsFromHandles.js new file mode 100644 index 000000000..dfd4d5092 --- /dev/null +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/utils/getPointsFromHandles.js @@ -0,0 +1,12 @@ +export default function getPointsFromHandles(handles) { + let points = []; + Object.keys(handles).map(handle => { + if (['start', 'end'].includes(handle)) { + let point = {}; + if (handles[handle].x) point.x = handles[handle].x; + if (handles[handle].y) point.y = handles[handle].y; + points.push(point); + } + }); + return points; +} diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/utils/getSOPInstanceAttributes.js b/extensions/cornerstone/src/utils/measurementServiceMappings/utils/getSOPInstanceAttributes.js new file mode 100644 index 000000000..01ca8ffd9 --- /dev/null +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/utils/getSOPInstanceAttributes.js @@ -0,0 +1,14 @@ +import cornerstone from 'cornerstone-core'; + +export default function getSOPInstanceAttributes(element) { + const enabledElement = cornerstone.getEnabledElement(element); + const imageId = enabledElement.image.imageId; + const instance = cornerstone.metaData.get('instance', imageId); + + return { + SOPInstanceUID: instance.SOPInstanceUID, + FrameOfReferenceUID: instance.FrameOfReferenceUID, + SeriesInstanceUID: instance.SeriesInstanceUID, + StudyInstanceUID: instance.StudyInstanceUID, + }; +} diff --git a/extensions/default/package.json b/extensions/default/package.json index f47b06d1d..74e36c504 100644 --- a/extensions/default/package.json +++ b/extensions/default/package.json @@ -34,7 +34,7 @@ "react": "^16.13.1", "react-dom": "^16.13.1", "webpack": "^4.0.0", - "dcmjs": "^0.12.4" + "dcmjs": "0.14.0" }, "dependencies": { "@babel/runtime": "7.7.6" diff --git a/extensions/default/src/DicomWebDataSource/index.js b/extensions/default/src/DicomWebDataSource/index.js index c832ee4a9..93a7b1b2f 100644 --- a/extensions/default/src/DicomWebDataSource/index.js +++ b/extensions/default/src/DicomWebDataSource/index.js @@ -12,9 +12,16 @@ import getImageId from './utils/getImageId'; import * as dcmjs from 'dcmjs'; import { retrieveStudyMetadata } from './retrieveStudyMetadata.js'; -const { naturalizeDataset } = dcmjs.data.DicomMetaDictionary; +const { DicomMetaDictionary, DicomDict } = dcmjs.data; + +const { naturalizeDataset, denaturalizeDataset } = DicomMetaDictionary; const { urlUtil } = utils; +const ImplementationClassUID = + '2.25.270695996825855179949881587723571202391.2.0.0'; +const ImplementationVersionName = 'OHIF-VIEWER-2.0.0'; +const EXPLICIT_VR_LITTLE_ENDIAN = '1.2.840.10008.1.2.1'; + /** * * @param {string} name - Data source name @@ -128,6 +135,32 @@ function createDicomWebApi(dicomWebConfig) { }, }, }, + store: { + dicom: async dataset => { + const meta = { + FileMetaInformationVersion: + dataset._meta.FileMetaInformationVersion.Value, + MediaStorageSOPClassUID: dataset.SOPClassUID, + MediaStorageSOPInstanceUID: dataset.SOPInstanceUID, + TransferSyntaxUID: EXPLICIT_VR_LITTLE_ENDIAN, + ImplementationClassUID, + ImplementationVersionName, + }; + + const denaturalized = denaturalizeDataset(meta); + const dicomDict = new DicomDict(denaturalized); + + dicomDict.dict = denaturalizeDataset(dataset); + + const part10Buffer = dicomDict.write(); + + const options = { + datasets: [part10Buffer], + }; + + await wadoDicomWebClient.storeInstances(options); + }, + }, retrieveSeriesMetadata: async ({ StudyInstanceUID } = {}) => { if (!StudyInstanceUID) { throw new Error( diff --git a/extensions/default/src/DicomWebDataSource/qido.js b/extensions/default/src/DicomWebDataSource/qido.js index 83613dc0c..00a390424 100644 --- a/extensions/default/src/DicomWebDataSource/qido.js +++ b/extensions/default/src/DicomWebDataSource/qido.js @@ -22,7 +22,7 @@ * | limit | {number} | * | offset | {number} | */ -import { DICOMWeb } from '@ohif/core'; +import { DICOMWeb, utils } from '@ohif/core'; const { getString, getName, getModalities } = DICOMWeb; @@ -50,7 +50,7 @@ function processResults(qidoStudies) { time: getString(qidoStudy['00080030']), // HHmmss.SSS (24-hour, minutes, seconds, fractional seconds) accession: getString(qidoStudy['00080050']) || '', // short string, probably a number? mrn: getString(qidoStudy['00100020']) || '', // medicalRecordNumber - patientName: getName(qidoStudy['00100010']) || '', + patientName: utils.formatPN(getName(qidoStudy['00100010'])) || '', instances: Number(getString(qidoStudy['00201208'])) || 0, // number description: getString(qidoStudy['00081030']) || '', modalities: diff --git a/extensions/default/src/Panels/PanelStudyBrowser.jsx b/extensions/default/src/Panels/PanelStudyBrowser.jsx index e2647aca6..221395926 100644 --- a/extensions/default/src/Panels/PanelStudyBrowser.jsx +++ b/extensions/default/src/Panels/PanelStudyBrowser.jsx @@ -127,7 +127,6 @@ function PanelStudyBrowser({ changedDisplaySets, thumbnailImageSrcMap ); - setDisplaySets(mappedDisplaySets); } ); @@ -152,11 +151,11 @@ function PanelStudyBrowser({ ); const updatedExpandedStudyInstanceUIDs = shouldCollapseStudy ? // eslint-disable-next-line prettier/prettier - [ - ...expandedStudyInstanceUIDs.filter( - stdyUid => stdyUid !== StudyInstanceUID - ), - ] + [ + ...expandedStudyInstanceUIDs.filter( + stdyUid => stdyUid !== StudyInstanceUID + ), + ] : [...expandedStudyInstanceUIDs, StudyInstanceUID]; setExpandedStudyInstanceUIDs(updatedExpandedStudyInstanceUIDs); diff --git a/extensions/default/src/Panels/WrappedPanelStudyBrowser.jsx b/extensions/default/src/Panels/WrappedPanelStudyBrowser.jsx index c3bb27d01..e79deea68 100644 --- a/extensions/default/src/Panels/WrappedPanelStudyBrowser.jsx +++ b/extensions/default/src/Panels/WrappedPanelStudyBrowser.jsx @@ -20,7 +20,7 @@ function WrappedPanelStudyBrowser({ }) { // TODO: This should be made available a different way; route should have // already determined our datasource - const dataSource = extensionManager.getDataSources('dicomweb')[0]; + const dataSource = extensionManager.getDataSources()[0]; const _getStudiesForPatientByStudyInstanceUID = getStudiesForPatientByStudyInstanceUID.bind( null, dataSource diff --git a/extensions/default/src/Toolbar/ToolbarLayoutSelector.jsx b/extensions/default/src/Toolbar/ToolbarLayoutSelector.jsx index 36236b93b..b5a4253ab 100644 --- a/extensions/default/src/Toolbar/ToolbarLayoutSelector.jsx +++ b/extensions/default/src/Toolbar/ToolbarLayoutSelector.jsx @@ -5,22 +5,40 @@ import { useViewportGrid, } from '@ohif/ui'; +const DEFAULT_LAYOUT = { + type: 'SET_LAYOUT', + payload: { + numCols: 1, + numRows: 1, + }, +}; + function LayoutSelector() { const [isOpen, setIsOpen] = useState(false); const [viewportGridState, dispatch] = useViewportGrid(); - useEffect(() => { - function closeOnOutsideClick() { - if (isOpen) { - setIsOpen(false); - } + const closeOnOutsideClick = () => { + if (isOpen) { + setIsOpen(false); } + }; + + useEffect(() => { window.addEventListener('click', closeOnOutsideClick); return () => { window.removeEventListener('click', closeOnOutsideClick); }; }, [isOpen]); + useEffect(() => { + /* Reset to default layout when component unmounts */ + return () => { + dispatch(DEFAULT_LAYOUT); + }; + }, []); + + const onClickHandler = () => setIsOpen(!isOpen); + const DropdownContent = isOpen ? OHIFLayoutSelector : null; return ( @@ -28,9 +46,7 @@ function LayoutSelector() { id="Layout" label="Grid Layout" icon="tool-layout" - onClick={() => { - setIsOpen(!isOpen); - }} + onClick={onClickHandler} dropdownContent={ DropdownContent !== null && ( { - function closeNestedMenu() { - if (isOpen) { - setIsOpen(false); - } + const toggleNestedMenu = () => setIsOpen(!isOpen); + + const closeNestedMenu = () => { + if (isOpen) { + setIsOpen(false); } + }; + + useEffect(() => { window.addEventListener('click', closeNestedMenu); return () => { window.removeEventListener('click', closeNestedMenu); }; }, [isOpen]); - const dropdownContent = isOpen ? children : undefined; - return ( { - setIsOpen(!isOpen); - }} - dropdownContent={dropdownContent} - isActive={isOpen} + id="NestedMenu" + label={label} + icon={icon} + onClick={toggleNestedMenu} + dropdownContent={isOpen && children} + isActive={isActive || isOpen} type="primary" /> ); @@ -36,6 +35,13 @@ function NestedMenu({ children }) { NestedMenu.propTypes = { children: PropTypes.any.isRequired, + icon: PropTypes.string, + label: PropTypes.string, +}; + +NestedMenu.defaultProps = { + icon: "tool-more-menu", + label: "More", }; export default NestedMenu; diff --git a/extensions/default/src/ViewerLayout/index.jsx b/extensions/default/src/ViewerLayout/index.jsx index 730ee515b..cf2c11826 100644 --- a/extensions/default/src/ViewerLayout/index.jsx +++ b/extensions/default/src/ViewerLayout/index.jsx @@ -55,7 +55,20 @@ function ViewerLayout({ }; }; + const defaultTool = { icon: 'tool-more-menu', label: 'More', isActive: false }; const [toolbars, setToolbars] = useState({ primary: [], secondary: [] }); + const [activeTool, setActiveTool] = useState(defaultTool); + + const setActiveToolHandler = (tool, isNested) => { + setActiveTool(isNested ? tool : defaultTool); + }; + + const onPrimaryClickHandler = (evt, btn) => { + if (btn.props && btn.props.commands && evt.value && btn.props.commands[evt.value]) { + const { commandName, commandOptions } = btn.props.commands[evt.value]; + commandsManager.runCommand(commandName, commandOptions); + } + }; useEffect(() => { const { unsubscribe } = ToolBarService.subscribe( @@ -63,8 +76,8 @@ function ViewerLayout({ () => { console.warn('~~~ TOOL BAR MODIFIED EVENT CAUGHT'); const updatedToolbars = { - primary: ToolBarService.getButtonSection('primary'), - secondary: ToolBarService.getButtonSection('secondary'), + primary: ToolBarService.getButtonSection('primary', { onClick: onPrimaryClickHandler, setActiveTool: setActiveToolHandler }), + secondary: ToolBarService.getButtonSection('secondary', { setActiveTool: setActiveToolHandler }), }; setToolbars(updatedToolbars); } @@ -86,11 +99,10 @@ function ViewerLayout({ if (!isNested) { const { id, Component, componentProps } = toolDef; - return ; } else { return ( - +
{toolDef.map(x => { const { id, Component, componentProps } = x; diff --git a/extensions/default/src/getToolbarModule.js b/extensions/default/src/getToolbarModule.js index 4cc66fda3..293e5cc43 100644 --- a/extensions/default/src/getToolbarModule.js +++ b/extensions/default/src/getToolbarModule.js @@ -9,7 +9,7 @@ export default function getToolbarModule({ commandsManager, servicesManager }) { { name: 'ohif.divider', defaultComponent: ToolbarDivider, - clickHandler: () => {}, + clickHandler: () => { }, }, { name: 'ohif.action', @@ -30,7 +30,7 @@ export default function getToolbarModule({ commandsManager, servicesManager }) { optionalConfig: [], requiredProps: [], optionalProps: [], - clickHandler: (evt, clickedBtn, btnSectionName) => { + clickHandler: (evt, clickedBtn, btnSectionName, metadata, viewerProps) => { const { props } = clickedBtn; const allButtons = toolbarService.getButtons(); @@ -47,6 +47,10 @@ export default function getToolbarModule({ commandsManager, servicesManager }) { clickedBtn.config.groupName === btn.config.groupName ) { btn.props.isActive = false; + + if (viewerProps.setActiveTool) { + viewerProps.setActiveTool(props, metadata.isNested); + } } }); @@ -63,7 +67,7 @@ export default function getToolbarModule({ commandsManager, servicesManager }) { { name: 'ohif.layoutSelector', defaultComponent: ToolbarLayoutSelector, - clickHandler: (evt, clickedBtn, btnSectionName) => {}, + clickHandler: (evt, clickedBtn, btnSectionName) => { }, }, { name: 'ohif.toggle', diff --git a/extensions/dicom-html/package.json b/extensions/dicom-html/package.json index d76e6fa11..eca2b16a3 100644 --- a/extensions/dicom-html/package.json +++ b/extensions/dicom-html/package.json @@ -28,7 +28,7 @@ }, "peerDependencies": { "@ohif/core": "^0.50.0", - "dcmjs": "^0.12.3", + "dcmjs": "0.14.0", "prop-types": "^15.6.2", "react": "^16.11.0", "react-dom": "^16.11.0" diff --git a/extensions/dicom-rt/package.json b/extensions/dicom-rt/package.json index fb67f9acf..9b7fc3c38 100644 --- a/extensions/dicom-rt/package.json +++ b/extensions/dicom-rt/package.json @@ -30,8 +30,8 @@ "peerDependencies": { "@ohif/core": "^0.50.0", "cornerstone-core": "^2.2.8", - "cornerstone-tools": "4.15.1", - "dcmjs": "^0.12.3", + "cornerstone-tools": "4.16.0", + "dcmjs": "0.14.0", "prop-types": "^15.6.2", "react": "^16.8.6", "react-dom": "^16.8.6" diff --git a/extensions/dicom-segmentation/package.json b/extensions/dicom-segmentation/package.json index ede02d636..e9214776f 100644 --- a/extensions/dicom-segmentation/package.json +++ b/extensions/dicom-segmentation/package.json @@ -31,7 +31,7 @@ "@ohif/core": "^0.50.0", "cornerstone-core": "^2.2.8", "cornerstone-tools": "4.15.1", - "dcmjs": "^0.12.2", + "dcmjs": "0.14.0", "prop-types": "^15.6.2", "react": "^16.8.6", "react-dom": "^16.8.6" diff --git a/extensions/dicom-sr/package.json b/extensions/dicom-sr/package.json index 6b14984c9..7ea514c52 100644 --- a/extensions/dicom-sr/package.json +++ b/extensions/dicom-sr/package.json @@ -33,8 +33,8 @@ "@ohif/ui": "^0.50.0", "cornerstone-core": "^2.3.0", "cornerstone-math": "^0.1.8", - "cornerstone-tools": "4.15.1", - "dcmjs": "^0.12.3", + "cornerstone-tools": "4.16.0", + "dcmjs": "0.14.0", "cornerstone-wado-image-loader": "^3.1.2", "dicom-parser": "^1.8.3", "hammerjs": "^2.0.8", diff --git a/extensions/dicom-sr/src/OHIFCornerstoneSRViewport.js b/extensions/dicom-sr/src/OHIFCornerstoneSRViewport.js index dfc685e43..c54203427 100644 --- a/extensions/dicom-sr/src/OHIFCornerstoneSRViewport.js +++ b/extensions/dicom-sr/src/OHIFCornerstoneSRViewport.js @@ -203,9 +203,11 @@ function OHIFCornerstoneSRViewport({ PatientSex, PatientAge, SliceThickness, + ManufacturerModelName, StudyDate, SeriesDescription, SeriesInstanceUID, + PixelSpacing, SeriesNumber, } = activeDisplaySetData; @@ -233,13 +235,10 @@ function OHIFCornerstoneSRViewport({ updateViewport(newMeasurementSelected); }; - console.log(currentImageIdIndex); - return ( <> diff --git a/extensions/measurement-tracking/package.json b/extensions/measurement-tracking/package.json index 7d08ecdbd..86bad62c0 100644 --- a/extensions/measurement-tracking/package.json +++ b/extensions/measurement-tracking/package.json @@ -28,11 +28,12 @@ }, "peerDependencies": { "@ohif/core": "^0.50.0", - "dcmjs": "^0.12.4", + "dcmjs": "0.14.0", "prop-types": "^15.6.2", "react": "^16.13.1", "react-dom": "^16.13.1", - "webpack": "^4.0.0" + "webpack": "^4.0.0", + "cornerstone-tools": "4.15.1" }, "dependencies": { "@babel/runtime": "7.7.6", diff --git a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/ActionButtons.jsx b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/ActionButtons.jsx index 0e3e8a3b6..6d239cf81 100644 --- a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/ActionButtons.jsx +++ b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/ActionButtons.jsx @@ -2,10 +2,10 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Button, ButtonGroup, Icon, IconButton } from '@ohif/ui'; -function ActionButtons() { +function ActionButtons({ onExportClick, onCreateReportClick }) { return ( - alert('Export')}> + @@ -34,4 +34,14 @@ function ActionButtons() { ); } +ActionButtons.propTypes = { + onExportClick: PropTypes.func, + onCreateReportClick: PropTypes.func, +}; + +ActionButtons.defaultProps = { + onExportClick: () => alert('Export'), + onCreateReportClick: () => alert('Create Report'), +}; + export default ActionButtons; diff --git a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.js b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.js index ece666e80..10184832a 100644 --- a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.js +++ b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.js @@ -1,10 +1,13 @@ import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { StudySummary, MeasurementTable } from '@ohif/ui'; -import { DicomMetadataStore } from '@ohif/core'; +import { DicomMetadataStore, DICOMSR } from '@ohif/core'; import { useDebounce } from '@hooks'; import ActionButtons from './ActionButtons'; import { useTrackedMeasurements } from '../../getContextModule'; +import cornerstoneTools from 'cornerstone-tools'; +import cornerstone from 'cornerstone-core'; +import dcmjs from 'dcmjs'; const DISPLAY_STUDY_SUMMARY_INITIAL_VALUE = { key: undefined, // @@ -13,7 +16,7 @@ const DISPLAY_STUDY_SUMMARY_INITIAL_VALUE = { description: undefined, // 'CHEST/ABD/PELVIS W CONTRAST', }; -function PanelMeasurementTableTracking({ servicesManager, commandsManager }) { +function PanelMeasurementTableTracking({ servicesManager, extensionManager }) { const [measurementChangeTimestamp, setMeasurementsUpdated] = useState( Date.now().toString() ); @@ -42,7 +45,7 @@ function PanelMeasurementTableTracking({ servicesManager, commandsManager }) { trackedSeries.includes(m.referenceSeriesUID) ); const mappedMeasurements = filteredMeasurements.map((m, index) => - _mapMeasurementToDisplay(m, index) + _mapMeasurementToDisplay(m, index, MeasurementService.VALUE_TYPES) ); setDisplayMeasurements(mappedMeasurements); // eslint-ignore-next-line @@ -102,6 +105,35 @@ function PanelMeasurementTableTracking({ servicesManager, commandsManager }) { const activeMeasurementItem = 0; + const onExportClick = () => { + const measurements = MeasurementService.getMeasurements(); + const trackedMeasurements = measurements.filter( + m => + trackedStudy === m.referenceStudyUID && + trackedSeries.includes(m.referenceSeriesUID) + ); + + // TODO -> local download. + DICOMSR.downloadReport(trackedMeasurements, dataSource); + }; + + const onCreateReportClick = () => { + const measurements = MeasurementService.getMeasurements(); + const trackedMeasurements = measurements.filter( + m => + trackedStudy === m.referenceStudyUID && + trackedSeries.includes(m.referenceSeriesUID) + ); + + + const dataSources = extensionManager.getDataSources(); + // TODO -> Eventually deal with multiple dataSources. + // Would need some way of saying which one is the "push" dataSource + const dataSource = dataSources[0]; + + DICOMSR.storeMeasurements(trackedMeasurements, dataSource); + }; + return ( <>
@@ -121,7 +153,10 @@ function PanelMeasurementTableTracking({ servicesManager, commandsManager }) { />
- +
); @@ -130,7 +165,7 @@ function PanelMeasurementTableTracking({ servicesManager, commandsManager }) { PanelMeasurementTableTracking.propTypes = {}; // TODO: This could be a MeasurementService mapper -function _mapMeasurementToDisplay(measurement, index) { +function _mapMeasurementToDisplay(measurement, index, types) { const { id, label, @@ -153,12 +188,14 @@ function _mapMeasurementToDisplay(measurement, index) { return { id: index + 1, label: '(empty)', // 'Label short description', - displayText: _getDisplayText( - measurement.points, - PixelSpacing, - SeriesNumber, - InstanceNumber - ), + displayText: + _getDisplayText( + measurement, + PixelSpacing, + SeriesNumber, + InstanceNumber, + types + ) || [], // TODO: handle one layer down isActive: false, // activeMeasurementItem === i + 1, }; @@ -169,7 +206,13 @@ function _mapMeasurementToDisplay(measurement, index) { * @param {*} points * @param {*} pixelSpacing */ -function _getDisplayText(points, pixelSpacing, seriesNumber, instanceNumber) { +function _getDisplayText( + measurement, + pixelSpacing, + seriesNumber, + instanceNumber, + types +) { // TODO: determination of shape influences text // Length: 'xx.x unit (S:x, I:x)' // Rectangle: 'xx.x x xx.x unit (S:x, I:x)', @@ -177,6 +220,8 @@ function _getDisplayText(points, pixelSpacing, seriesNumber, instanceNumber) { // Bidirectional? // Freehand? + const { type, points } = measurement; + const hasPixelSpacing = pixelSpacing !== undefined && Array.isArray(pixelSpacing) && @@ -186,13 +231,37 @@ function _getDisplayText(points, pixelSpacing, seriesNumber, instanceNumber) { : [1, 1]; const unit = hasPixelSpacing ? 'mm' : 'px'; - const { x: x1, y: y1 } = points[0]; - const { x: x2, y: y2 } = points[1]; - const dx = (x2 - x1) * colPixelSpacing; - const dy = (y2 - y1) * rowPixelSpacing; - const length = _round(Math.sqrt(dx * dx + dy * dy), 1); + switch (type) { + case types.POLYLINE: + const { length } = measurement; - return `${length} ${unit} (S:${seriesNumber}, I:${instanceNumber})`; + const roundedLength = _round(length, 1); + + return [ + `${roundedLength} ${unit} (S:${seriesNumber}, I:${instanceNumber})`, + ]; + + case types.BIDIRECTIONAL: + const { shortestDiameter, longestDiameter } = measurement; + + const roundedShortestDiameter = _round(shortestDiameter, 1); + const roundedLongestDiameter = _round(longestDiameter, 1); + + return [ + `l: ${roundedLongestDiameter} ${unit} (S:${seriesNumber}, I:${instanceNumber})`, + `s: ${roundedShortestDiameter} ${unit}`, + ]; + case types.ELLIPSE: + const { area } = measurement; + + const roundedArea = _round(area, 1); + return [ + `${roundedArea} ${unit}2 (S:${seriesNumber}, I:${instanceNumber})`, + ]; + case types.POINT: + const { text } = measurement; + return [`${text} (S:${seriesNumber}, I:${instanceNumber})`]; + } } function _round(value, decimals) { diff --git a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.jsx b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.jsx index d33724702..2e9f45429 100644 --- a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.jsx +++ b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.jsx @@ -201,10 +201,10 @@ function PanelStudyBrowserTracking({ ); const updatedExpandedStudyInstanceUIDs = shouldCollapseStudy ? [ - ...expandedStudyInstanceUIDs.filter( - stdyUid => stdyUid !== StudyInstanceUID - ), - ] + ...expandedStudyInstanceUIDs.filter( + stdyUid => stdyUid !== StudyInstanceUID + ), + ] : [...expandedStudyInstanceUIDs, StudyInstanceUID]; setExpandedStudyInstanceUIDs(updatedExpandedStudyInstanceUIDs); @@ -285,7 +285,6 @@ function _mapDisplaySets( ) { const thumbnailDisplaySets = []; const thumbnailNoImageDisplaySets = []; - displaySets.forEach(ds => { const imageSrc = thumbnailImageSrcMap[ds.displaySetInstanceUID]; const componentType = _getComponentType(ds.Modality); diff --git a/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.js b/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.js index 327c49a77..eae34e279 100644 --- a/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.js +++ b/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.js @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import cornerstone from 'cornerstone-core'; +import cornerstoneTools from 'cornerstone-tools'; import CornerstoneViewport from 'react-cornerstone-viewport'; import OHIF, { DicomMetadataStore } from '@ohif/core'; import { @@ -9,10 +10,20 @@ import { useViewportGrid, useViewportDialog, } from '@ohif/ui'; -import debounce from 'lodash.debounce'; -import throttle from 'lodash.throttle'; import { useTrackedMeasurements } from './../getContextModule'; +// TODO -> Get this list from the list of tracked measurements. +const { + ArrowAnnotateTool, + BidirectionalTool, + EllipticalRoiTool, + LengthTool, +} = cornerstoneTools; + +const BaseAnnotationTool = cornerstoneTools.importInternal( + 'base/BaseAnnotationTool' +); + // const cine = viewportSpecificData.cine; // isPlaying = cine.isPlaying === true; @@ -27,13 +38,15 @@ function TrackedCornerstoneViewport({ viewportIndex, }) { const [trackedMeasurements] = useTrackedMeasurements(); + const [ { activeViewportIndex, viewports }, - dispatchViewportGrid, ] = useViewportGrid(); // viewportIndex, onSubmit const [viewportDialogState, viewportDialogApi] = useViewportDialog(); const [viewportData, setViewportData] = useState(null); + const [element, setElement] = useState(null); + const [isTracked, setIsTracked] = useState(false); // TODO: Still needed? Better way than import `OHIF` and destructure? // Why is this managed by `core`? useEffect(() => { @@ -42,6 +55,75 @@ function TrackedCornerstoneViewport({ }; }, []); + useEffect(() => { + if (!element) { + return; + } + const allTools = cornerstoneTools.store.state.tools; + const toolsForElement = allTools.filter(tool => tool.element === element); + + toolsForElement.forEach(tool => { + if ( + tool instanceof ArrowAnnotateTool || + tool instanceof BidirectionalTool || + tool instanceof EllipticalRoiTool || + tool instanceof LengthTool + ) { + const configuration = tool.configuration; + + configuration.renderDashed = !isTracked; + + tool.configuration = configuration; + } + }); + + const enabledElement = cornerstone.getEnabledElement(element); + + if (enabledElement.image) { + cornerstone.updateImage(element); + } + }, [isTracked]); + + const onElementEnabled = evt => { + const eventData = evt.detail; + const targetElement = eventData.element; + + const allTools = cornerstoneTools.store.state.tools; + + const toolsForElement = allTools.filter( + tool => tool.element === targetElement + ); + + toolsForElement.forEach(tool => { + if ( + tool instanceof ArrowAnnotateTool || + tool instanceof BidirectionalTool || + tool instanceof EllipticalRoiTool || + tool instanceof LengthTool + ) { + const configuration = tool.configuration; + + configuration.renderDashed = !isTracked; + + tool.configuration = configuration; + } else if (tool instanceof BaseAnnotationTool) { + const configuration = tool.configuration; + + configuration.renderDashed = true; + + tool.configuration = configuration; + } + }); + + const enabledElement = cornerstone.getEnabledElement(targetElement); + + if (enabledElement.image) { + cornerstone.updateImage(targetElement); + } + + setElement(targetElement); + }; + useEffect(() => { const { StudyInstanceUID, @@ -116,6 +198,7 @@ function TrackedCornerstoneViewport({ vp => vp.displaySetInstanceUID === displaySet.displaySetInstanceUID ); const { trackedSeries } = trackedMeasurements.context; + const { Modality, SeriesDate, @@ -123,19 +206,25 @@ function TrackedCornerstoneViewport({ SeriesInstanceUID, SeriesNumber, } = displaySet; + const { PatientID, PatientName, PatientSex, PatientAge, SliceThickness, + PixelSpacing, + ManufacturerModelName } = displaySet.images[0]; + if (trackedSeries.includes(SeriesInstanceUID) !== isTracked) { + setIsTracked(!isTracked); + } + return ( <> alert(`Series ${direction}`)} - showPatientInfo={viewportIndex === activeViewportIndex} showNavArrows={viewportIndex === activeViewportIndex} studyData={{ label: _viewportLabels[firstViewportIndexWithMatchingDisplaySetUid], @@ -146,19 +235,20 @@ function TrackedCornerstoneViewport({ seriesDescription: SeriesDescription, modality: Modality, patientInformation: { - patientName: PatientName ? PatientName.Alphabetic || '' : '', + patientName: PatientName ? OHIF.utils.formatPN(PatientName.Alphabetic) : '', patientSex: PatientSex || '', patientAge: PatientAge || '', MRN: PatientID || '', thickness: `${SliceThickness}mm`, - spacing: '', - scanner: '', + spacing: PixelSpacing && PixelSpacing.length ? `${PixelSpacing[0].toFixed(2)}mm x ${PixelSpacing[1].toFixed(2)}mm` : '', + scanner: ManufacturerModelName || '', }, }} /> {/* TODO: Viewport interface to accept stack or layers of content like this? */}
( + <> +
+ + {title} + + + {subtitle} + +
+ {index + 1} + + ) + } }, }, { diff --git a/platform/core/package.json b/platform/core/package.json index 83320ad2f..a14313b13 100644 --- a/platform/core/package.json +++ b/platform/core/package.json @@ -31,14 +31,14 @@ }, "peerDependencies": { "cornerstone-core": "^2.3.0", - "cornerstone-tools": "^4.12.0", + "cornerstone-tools": "4.16.0", "cornerstone-wado-image-loader": "^3.1.2", "dicom-parser": "^1.8.3" }, "dependencies": { "@babel/runtime": "7.7.6", "ajv": "^6.10.0", - "dcmjs": "^0.12.4", + "dcmjs": "0.14.0", "dicomweb-client": "^0.6.0", "immer": "6.0.2", "isomorphic-base64": "^1.0.2", diff --git a/platform/core/src/DICOMSR/dataExchange.js b/platform/core/src/DICOMSR/dataExchange.js index aab594da3..2b3cfe172 100644 --- a/platform/core/src/DICOMSR/dataExchange.js +++ b/platform/core/src/DICOMSR/dataExchange.js @@ -6,6 +6,10 @@ import { stowSRFromMeasurements, } from './handleStructuredReport'; import findMostRecentStructuredReport from './utils/findMostRecentStructuredReport'; +import cornerstoneTools from 'cornerstone-tools'; +import dcmjs from 'dcmjs'; + +const { MeasurementReport } = dcmjs.adapters.Cornerstone; /** * @@ -47,7 +51,7 @@ const retrieveMeasurements = server => { * @param {serverType} server * @returns {Object} With message to be displayed on success */ -const storeMeasurements = async (measurementData, filter, server) => { +const storeMeasurementsOld = async (measurementData, filter, server) => { log.info('[DICOMSR] storeMeasurements'); if (!server || server.type !== 'dicomWeb') { @@ -78,4 +82,128 @@ const storeMeasurements = async (measurementData, filter, server) => { } }; -export { retrieveMeasurements, storeMeasurements }; +/** + * + * @param {object[]} measurementData An array of measurements from the measurements service + * that you wish to serialize. + */ +const downloadReport = measurementData => { + const srDataset = generateReport(measurementData); + const reportBlob = dcmjs.data.datasetToBlob(srDataset); + + //Create a URL for the binary. + var objectUrl = URL.createObjectURL(reportBlob); + window.location.assign(objectUrl); +}; + +/** + * + * @param {object[]} measurementData An array of measurements from the measurements service + * that you wish to serialize. + */ +const generateReport = measurementData => { + const ids = measurementData.map(md => md.id); + const filteredToolState = _getFilteredCornerstoneToolState(ids); + + const report = MeasurementReport.generateReport( + filteredToolState, + cornerstone.metaData + ); + + return report.dataset; +}; + +/** + * + * @param {object[]} measurementData An array of measurements from the measurements service + * that you wish to serialize. + * @param {object} dataSource The dataSource that you wish to use to persist the data. + */ +const storeMeasurements = async (measurementData, dataSource) => { + // 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. + log.info('[DICOMSR] storeMeasurements'); + + if (!dataSource || !dataSource.store || !dataSource.store.dicom) { + log.error('[DICOMSR] datasource has no dataSource.store.dicom endpoint!'); + return Promise.reject({}); + } + + const naturalizedReport = generateReport(measurementData); + const { StudyInstanceUID } = naturalizedReport; + + try { + await dataSource.store.dicom(naturalizedReport); + + if (StudyInstanceUID) { + studies.deleteStudyMetadataPromise(StudyInstanceUID); + } + + return { + message: 'Measurements saved successfully', + }; + } catch (error) { + log.error( + `[DICOMSR] Error while saving the measurements: ${error.message}` + ); + throw new Error('Error while saving the measurements.'); + } +}; + +function _getFilteredCornerstoneToolState(uidFilter) { + const globalToolState = cornerstoneTools.globalImageIdSpecificToolStateManager.saveToolState(); + const filteredToolState = {}; + + function addToFilteredToolState(imageId, toolType, toolDataI) { + if (!filteredToolState[imageId]) { + filteredToolState[imageId] = {}; + } + + const imageIdSpecificToolState = filteredToolState[imageId]; + + if (!imageIdSpecificToolState[toolType]) { + imageIdSpecificToolState[toolType] = { + data: [], + }; + } + + const toolData = imageIdSpecificToolState[toolType].data; + + toolData.push(toolDataI); + } + + const uids = uidFilter.slice(); + const imageIds = Object.keys(globalToolState); + + for (let i = 0; i < imageIds.length; i++) { + const imageId = imageIds[i]; + const imageIdSpecificToolState = globalToolState[imageId]; + + const toolTypes = Object.keys(imageIdSpecificToolState); + + for (let j = 0; j < toolTypes.length; j++) { + const toolType = toolTypes[j]; + const toolData = imageIdSpecificToolState[toolType].data; + + if (toolData) { + for (let k = 0; k < toolData.length; k++) { + const toolDataK = toolData[k]; + const uidIndex = uids.findIndex(uid => uid === toolDataK.id); + + if (uidIndex !== -1) { + addToFilteredToolState(imageId, toolType, toolDataK); + uids.splice(uidIndex, 1); + + if (!uids.length) { + return filteredToolState; + } + } + } + } + } + } + + return filteredToolState; +} + +export { retrieveMeasurements, storeMeasurements, downloadReport }; diff --git a/platform/core/src/DICOMSR/index.js b/platform/core/src/DICOMSR/index.js index 786202642..759f26cb1 100644 --- a/platform/core/src/DICOMSR/index.js +++ b/platform/core/src/DICOMSR/index.js @@ -1,9 +1,14 @@ -import { retrieveMeasurements, storeMeasurements } from './dataExchange'; +import { + retrieveMeasurements, + storeMeasurements, + downloadReport, +} from './dataExchange'; import isToolSupported from './utils/isToolSupported'; const DICOMSR = { retrieveMeasurements, storeMeasurements, + downloadReport, isToolSupported, }; diff --git a/platform/core/src/DataSources/IWebApiDataSource.js b/platform/core/src/DataSources/IWebApiDataSource.js index e10914e9e..10285d364 100644 --- a/platform/core/src/DataSources/IWebApiDataSource.js +++ b/platform/core/src/DataSources/IWebApiDataSource.js @@ -15,6 +15,7 @@ import { DicomMetadataStore } from '@ohif/core'; function create({ query, retrieve, + store, retrieveSeriesMetadata, getImageIdsForDisplaySet, }) { @@ -44,13 +45,20 @@ function create({ series: {}, }; + const defaultStore = { + dicom: async naturalizedDataset => { + throw new Error( + 'store.dicom(naturalizedDicom, StudyInstanceUID) not implemented for dataSource.' + ); + }, + }; + return { query: query || defaultQuery, retrieve: retrieve || defaultRetrieve, + store: store || defaultStore, getImageIdsForDisplaySet, retrieveSeriesMetadata, - // then go get all series level metadata. - // Store this in the DICOM MetadataStore. }; } diff --git a/platform/core/src/extensions/ExtensionManager.js b/platform/core/src/extensions/ExtensionManager.js index 14dfde227..0ce25d6b6 100644 --- a/platform/core/src/extensions/ExtensionManager.js +++ b/platform/core/src/extensions/ExtensionManager.js @@ -137,6 +137,7 @@ export default class ExtensionManager { getDataSources = dataSourceName => { if (dataSourceName === undefined) { + // Default to the activeDataSource dataSourceName = this.activeDataSource; } @@ -144,6 +145,10 @@ export default class ExtensionManager { return this.dataSourceMap[dataSourceName]; }; + getActiveDataSource = () => { + return this.activeDataSource; + }; + /** * @private * @param {string} moduleType diff --git a/platform/core/src/services/MeasurementService/MeasurementService.js b/platform/core/src/services/MeasurementService/MeasurementService.js index 4688a9849..ba9ddcf61 100644 --- a/platform/core/src/services/MeasurementService/MeasurementService.js +++ b/platform/core/src/services/MeasurementService/MeasurementService.js @@ -40,6 +40,10 @@ const MEASUREMENT_SCHEMA_KEYS = [ 'type', 'unit', 'area', // TODO: Add concept names instead (descriptor) + 'length', + 'shortestDiameter', + 'longestDiameter', + 'text', // NOTE: There is nothing like this in SR. 'points', 'source', ]; @@ -53,6 +57,7 @@ const EVENTS = { const VALUE_TYPES = { POLYLINE: 'value_type::polyline', POINT: 'value_type::point', + BIDIRECTIONAL: 'value_type::shortAxisLongAxis', // TODO -> Discuss with Danny. => just using SCOORD values isn't enough here. ELLIPSE: 'value_type::ellipse', MULTIPOINT: 'value_type::multipoint', CIRCLE: 'value_type::circle', diff --git a/platform/core/src/services/ToolBarService/ToolBarService.js b/platform/core/src/services/ToolBarService/ToolBarService.js index 95cc1b431..2cabbe54d 100644 --- a/platform/core/src/services/ToolBarService/ToolBarService.js +++ b/platform/core/src/services/ToolBarService/ToolBarService.js @@ -62,7 +62,7 @@ export default class ToolBarService { this._broadcastChange(this.EVENTS.TOOL_BAR_MODIFIED, {}); } - getButtonSection(key) { + getButtonSection(key, props) { const buttonSectionIds = this.buttonSections[key]; const buttonsInSection = []; @@ -79,7 +79,8 @@ export default class ToolBarService { btnIds.forEach(nestedBtnId => { const nestedBtn = this.buttons[nestedBtnId]; - const mappedNestedBtn = this._mapButtonToDisplay(nestedBtn, key); + const metadata = { isNested: true }; + const mappedNestedBtn = this._mapButtonToDisplay(nestedBtn, key, metadata, props); nestedButtons.push(mappedNestedBtn); }); @@ -90,7 +91,8 @@ export default class ToolBarService { } else { const btnId = btnIdOrArray; const btn = this.buttons[btnId]; - const mappedBtn = this._mapButtonToDisplay(btn, key); + const metadata = { isNested: false }; + const mappedBtn = this._mapButtonToDisplay(btn, key, metadata, props); buttonsInSection.push(mappedBtn); } @@ -136,8 +138,8 @@ export default class ToolBarService { * @param {*} btn * @param {*} btnSection */ - _mapButtonToDisplay(btn, btnSection) { - const { id, type, component, props } = btn; + _mapButtonToDisplay(btn, btnSection, metadata, props) { + const { id, type, component } = btn; const buttonType = this._buttonTypes()[type]; if (!buttonType) { @@ -146,7 +148,7 @@ export default class ToolBarService { const onClick = evt => { if (buttonType.clickHandler) { - buttonType.clickHandler(evt, btn, btnSection); + buttonType.clickHandler(evt, btn, btnSection, metadata, props); } if (btn.props.onClick) { btn.onClick(evt, btn, btnSection); @@ -154,12 +156,15 @@ export default class ToolBarService { if (btn.props.clickHandler) { btn.clickHandler(evt, btn, btnSection); } + if (props && props.onClick) { + props.onClick(evt, btn, btnSection, props); + } }; return { id, Component: component || buttonType.defaultComponent, - componentProps: Object.assign({}, props, { onClick }), // + componentProps: Object.assign({}, btn.props, { onClick }), // }; } } diff --git a/platform/core/src/utils/formatPN.js b/platform/core/src/utils/formatPN.js new file mode 100644 index 000000000..fb019ab5e --- /dev/null +++ b/platform/core/src/utils/formatPN.js @@ -0,0 +1,18 @@ +/** + * Formats a patient name for display purposes + */ +export default function formatPN(name) { + if (!name) { + return; + } + + // Convert the first ^ to a ', '. String.replace() only affects + // the first appearance of the character. + const commaBetweenFirstAndLast = name.replace('^', ', '); + + // Replace any remaining '^' characters with spaces + const cleaned = commaBetweenFirstAndLast.replace(/\^/g, ' '); + + // Trim any extraneous whitespace + return cleaned.trim(); +} diff --git a/platform/core/src/utils/index.js b/platform/core/src/utils/index.js index 6e9ea3140..a69889262 100644 --- a/platform/core/src/utils/index.js +++ b/platform/core/src/utils/index.js @@ -15,6 +15,7 @@ import makeCancelable from './makeCancelable'; import hotkeys from './hotkeys'; import Queue from './Queue'; import isDicomUid from './isDicomUid'; +import formatPN from './formatPN'; import resolveObjectPath from './resolveObjectPath'; import * as hierarchicalListUtils from './hierarchicalListUtils'; import * as progressTrackingUtils from './progressTrackingUtils'; @@ -26,6 +27,7 @@ const utils = { addServers, sortBy, writeScript, + formatPN, b64toBlob, StackManager, studyMetadataManager, diff --git a/platform/ui/index.js b/platform/ui/index.js index 0d7fe423e..5032f0271 100644 --- a/platform/ui/index.js +++ b/platform/ui/index.js @@ -33,6 +33,8 @@ export { Dialog, Dropdown, EmptyStudies, + ExpandableToolbarButton, + ListMenu, Icon, IconButton, Input, diff --git a/platform/ui/src/assets/icons/close.svg b/platform/ui/src/assets/icons/close.svg new file mode 100644 index 000000000..83b259fca --- /dev/null +++ b/platform/ui/src/assets/icons/close.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/platform/ui/src/components/EmptyStudies/EmptyStudies.js b/platform/ui/src/components/EmptyStudies/EmptyStudies.js index 48e3baaa6..893ecf3e3 100644 --- a/platform/ui/src/components/EmptyStudies/EmptyStudies.js +++ b/platform/ui/src/components/EmptyStudies/EmptyStudies.js @@ -3,12 +3,13 @@ import PropTypes from 'prop-types'; import classnames from 'classnames'; import { Icon, Typography } from '@ohif/ui'; -const EmptyStudies = ({ className }) => { +// TODO: Add loading spinner to OHIF + use it here. +const EmptyStudies = ({ className, isLoading }) => { return (
- No studies available + {!isLoading ? 'No studies available' : 'Loading...'}
); diff --git a/platform/ui/src/components/ExpandableToolbarButton/ExpandableToolbarButton.css b/platform/ui/src/components/ExpandableToolbarButton/ExpandableToolbarButton.css new file mode 100644 index 000000000..3a5a6c8be --- /dev/null +++ b/platform/ui/src/components/ExpandableToolbarButton/ExpandableToolbarButton.css @@ -0,0 +1,16 @@ +.ExpandableToolbarButton:hover .ExpandableToolbarButton__arrow:after { + content: ""; + position: absolute; + bottom: -10px; + border-width: 10px 10px 0; + border-style: solid; + border-color:#5acce6 transparent; +} + +.ExpandableToolbarButton .ExpandableToolbarButton__content { + display: none; +} + +.ExpandableToolbarButton:hover .ExpandableToolbarButton__content { + display: block; +} diff --git a/platform/ui/src/components/ExpandableToolbarButton/ExpandableToolbarButton.jsx b/platform/ui/src/components/ExpandableToolbarButton/ExpandableToolbarButton.jsx new file mode 100644 index 000000000..b343d9ed7 --- /dev/null +++ b/platform/ui/src/components/ExpandableToolbarButton/ExpandableToolbarButton.jsx @@ -0,0 +1,86 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; + +import { IconButton, Icon } from '@ohif/ui'; + +import './ExpandableToolbarButton.css'; + +const ExpandableToolbarButton = ({ + type, + id, + isActive, + onClick, + icon, + className, + content: Content, + contentProps +}) => { + const classes = { + type: { + primary: isActive + ? 'text-black' + : 'text-common-bright hover:bg-primary-dark hover:text-primary-light', + secondary: isActive + ? 'text-black' + : 'text-white hover:bg-secondary-dark focus:bg-secondary-dark', + }, + }; + + const onChildClickHandler = (...args) => { + onClick(...args); + + if (contentProps.onClick) { + contentProps.onClick(...args); + } + }; + + const onClickHandler = (...args) => { + onClick(...args); + }; + + return ( +
+ + + +
+
+ +
+
+
+ ); +}; + +const noop = () => { }; + +ExpandableToolbarButton.defaultProps = { + isActive: false, + type: 'primary', + content: null, + onClick: noop, +}; + +ExpandableToolbarButton.propTypes = { + /* Influences background/hover styling */ + type: PropTypes.oneOf(['primary', 'secondary']), + id: PropTypes.string.isRequired, + isActive: PropTypes.bool, + onClick: PropTypes.func.isRequired, + icon: PropTypes.string.isRequired, + /** Expandable toolbar button content can be replaced for a customized content by passing a node to this value. */ + content: PropTypes.oneOfType([PropTypes.node, PropTypes.func]), + contentProps: PropTypes.object, +}; + +export default ExpandableToolbarButton; diff --git a/platform/ui/src/components/ExpandableToolbarButton/ExpandableToolbarButton.mdx b/platform/ui/src/components/ExpandableToolbarButton/ExpandableToolbarButton.mdx new file mode 100644 index 000000000..b6867f370 --- /dev/null +++ b/platform/ui/src/components/ExpandableToolbarButton/ExpandableToolbarButton.mdx @@ -0,0 +1,58 @@ +--- +name: Expandable Toolbar Button +menu: General +route: components/expandableToolbarButton +--- + +import { useState } from 'react'; +import { Playground, Props } from 'docz'; +import { ExpandableToolbarButton, ListMenu } from '@ohif/ui'; + +# Toolbar Button + +Expandable Toolbar Buttons are used to populate the Toolbar. + +## Import + +```javascript +import { ExpandableToolbarButton, ListMenu } from '@ohif/ui'; +``` + + + {() => { + const props = { + content: ListMenu, + contentProps: { + options: [ + { value: 'windowLevelPreset1', title: 'Soft tissue', subtitle: '400 / 40' }, + { value: 'windowLevelPreset2', title: 'Lung', subtitle: '1500 / -600' }, + { value: 'windowLevelPreset3', title: 'Liver', subtitle: '150 / 90' }, + { value: 'windowLevelPreset4', title: 'Bone', subtitle: '80 / 40' }, + { value: 'windowLevelPreset5', title: 'Brain', subtitle: '2500 / 480' }, + ], + renderer: ({ title, subtitle, isActive, index }) => ( + <> +
+ + {title} + + + {subtitle} + +
+ {index + 1} + + ) + } + }; + return ( +
+ +
+ ); + }} +
+ +## Properties + + diff --git a/platform/ui/src/components/ExpandableToolbarButton/index.js b/platform/ui/src/components/ExpandableToolbarButton/index.js new file mode 100644 index 000000000..2a5e6032c --- /dev/null +++ b/platform/ui/src/components/ExpandableToolbarButton/index.js @@ -0,0 +1,2 @@ +import ExpandableToolbarButton from './ExpandableToolbarButton'; +export default ExpandableToolbarButton; diff --git a/platform/ui/src/components/Icon/getIcon.jsx b/platform/ui/src/components/Icon/getIcon.jsx index 7b1fc4dc2..8f0a7a534 100644 --- a/platform/ui/src/components/Icon/getIcon.jsx +++ b/platform/ui/src/components/Icon/getIcon.jsx @@ -4,6 +4,7 @@ import React from 'react'; import arrowDown from './../../assets/icons/arrow-down.svg'; import calendar from './../../assets/icons/calendar.svg'; import cancel from './../../assets/icons/cancel.svg'; +import close from './../../assets/icons/close.svg'; import dottedCircle from './../../assets/icons/dotted-circle.svg'; import circledCheckmark from './../../assets/icons/circled-checkmark.svg'; import chevronDown from './../../assets/icons/chevron-down.svg'; @@ -49,6 +50,7 @@ const ICONS = { 'arrow-down': arrowDown, calendar: calendar, cancel: cancel, + close: close, 'dotted-circle': dottedCircle, 'circled-checkmark': circledCheckmark, 'chevron-down': chevronDown, diff --git a/platform/ui/src/components/IconButton/IconButton.jsx b/platform/ui/src/components/IconButton/IconButton.jsx index 475e61620..290bfa273 100644 --- a/platform/ui/src/components/IconButton/IconButton.jsx +++ b/platform/ui/src/components/IconButton/IconButton.jsx @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import classnames from 'classnames'; const baseClasses = - 'text-center items-center justify-center outline-none transition duration-300 ease-in-out font-bold focus:outline-none'; + 'text-center items-center justify-center transition duration-300 ease-in-out outline-none font-bold focus:outline-none'; const roundedClasses = { none: '', @@ -84,7 +84,7 @@ const IconButton = ({ }) => { const buttonElement = useRef(null); - const handleOnClick = (e) => { + const handleOnClick = e => { buttonElement.current.blur(); onClick(e); }; diff --git a/platform/ui/src/components/Input/Input.jsx b/platform/ui/src/components/Input/Input.jsx index 51b40f0e5..8f26ad4e7 100644 --- a/platform/ui/src/components/Input/Input.jsx +++ b/platform/ui/src/components/Input/Input.jsx @@ -4,11 +4,11 @@ import Label from '../Label'; import classnames from 'classnames'; const baseInputClasses = - 'shadow transition duration-300 appearance-none border rounded w-full py-2 px-3 text-sm text-white hover:border-gray-500 leading-tight focus:border-gray-500 focus:outline-none'; + 'shadow transition duration-300 appearance-none border border-primary-main hover:border-gray-500 focus:border-gray-500 focus:outline-none rounded w-full py-2 px-3 mt-2 text-sm text-white leading-tight focus:outline-none'; const transparentClasses = { true: 'bg-transparent', - false: '', + false: 'bg-black', }; const Input = ({ @@ -16,7 +16,7 @@ const Input = ({ containerClassName = '', labelClassName = '', className = '', - transparent = true, + transparent = false, type = 'text', value, onChange, diff --git a/platform/ui/src/components/InputText/InputText.jsx b/platform/ui/src/components/InputText/InputText.jsx index fc62e3cdd..2f6fbe70b 100644 --- a/platform/ui/src/components/InputText/InputText.jsx +++ b/platform/ui/src/components/InputText/InputText.jsx @@ -43,7 +43,7 @@ InputText.propTypes = { isSortable: PropTypes.bool, sortDirection: PropTypes.oneOf(['ascending', 'descending', 'none']), onLabelClick: PropTypes.func, - value: PropTypes.string, + value: PropTypes.any, onChange: PropTypes.func.isRequired, }; diff --git a/platform/ui/src/components/ListMenu/ListMenu.jsx b/platform/ui/src/components/ListMenu/ListMenu.jsx new file mode 100644 index 000000000..56a693740 --- /dev/null +++ b/platform/ui/src/components/ListMenu/ListMenu.jsx @@ -0,0 +1,65 @@ +import React, { useState } from 'react'; +import classnames from 'classnames'; +import PropTypes from 'prop-types'; + +const ListMenu = ({ options = [], renderer, onClick }) => { + const [selectedIndex, setSelectedIndex] = useState(null); + + const ListItem = (props) => { + const flex = 'flex flex-row justify-between items-center'; + const theme = 'bg-indigo-dark'; + const hover = 'hover:bg-primary-dark'; + const spacing = 'p-3 h-8'; + + return ( +
+ {renderer && renderer(props)} +
+ ); + }; + + return ( +
+ {options.map((option, index) => { + const onClickHandler = () => { + setSelectedIndex(index); + onClick({ ...option, index }); + }; + + return ( + + ); + })} +
+ ); +}; + +const noop = () => { }; + +ListMenu.propTypes = { + options: PropTypes.array.isRequired, + renderer: PropTypes.func.isRequired, + onClick: PropTypes.func +}; + +ListMenu.defaultProps = { + onClick: noop +}; + +export default ListMenu; diff --git a/platform/ui/src/components/ListMenu/ListMenu.mdx b/platform/ui/src/components/ListMenu/ListMenu.mdx new file mode 100644 index 000000000..195361e21 --- /dev/null +++ b/platform/ui/src/components/ListMenu/ListMenu.mdx @@ -0,0 +1,52 @@ +--- +name: List Menu +menu: General +route: components/listMenu +--- + +import { useState } from 'react'; +import { Playground, Props } from 'docz'; +import { ListMenu } from '@ohif/ui'; + +# List Menu + +List Menus are used to populate expandable Toolbar. + +## Import + +```javascript +import { ListMenu } from '@ohif/ui'; +``` + + + {() => { + return ( + ( + <> +
+ + {title} + + + {subtitle} + +
+ {index + 1} + + )} + /> + ); + }} +
+ +## Properties + + diff --git a/platform/ui/src/components/ListMenu/index.js b/platform/ui/src/components/ListMenu/index.js new file mode 100644 index 000000000..b9d95c885 --- /dev/null +++ b/platform/ui/src/components/ListMenu/index.js @@ -0,0 +1,2 @@ +import ListMenu from './ListMenu'; +export default ListMenu; diff --git a/platform/ui/src/components/MeasurementTable/MeasurementTable.jsx b/platform/ui/src/components/MeasurementTable/MeasurementTable.jsx index 95ba02982..9898c504d 100644 --- a/platform/ui/src/components/MeasurementTable/MeasurementTable.jsx +++ b/platform/ui/src/components/MeasurementTable/MeasurementTable.jsx @@ -14,7 +14,7 @@ const MeasurementTable = ({ data, title, amount, onClick, onEdit }) => {
{!!data.length && - data.map((measurementItem) => { + data.map(measurementItem => { const { id, label, displayText, isActive } = measurementItem; return (
{ {label} - - {displayText} - + {displayText.map(line => ( + + {line} + + ))} { right: 4, transform: isActive ? '' : 'translateX(100%)', }} - onClick={(e) => { + onClick={e => { // stopPropagation needed to avoid disable the current active item e.stopPropagation(); onEdit(id); @@ -108,7 +110,7 @@ MeasurementTable.propTypes = { PropTypes.shape({ id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), label: PropTypes.string, - displayText: PropTypes.string, + displayText: PropTypes.arrayOf(PropTypes.string), isActive: PropTypes.bool, }) ), diff --git a/platform/ui/src/components/Modal/Modal.css b/platform/ui/src/components/Modal/Modal.css new file mode 100644 index 000000000..f67d31616 --- /dev/null +++ b/platform/ui/src/components/Modal/Modal.css @@ -0,0 +1,3 @@ +.modal-content { + max-height: calc(100vh - theme('spacing.250px')); +} diff --git a/platform/ui/src/components/Modal/Modal.jsx b/platform/ui/src/components/Modal/Modal.jsx index 1ce7e3cd7..3a245f8aa 100644 --- a/platform/ui/src/components/Modal/Modal.jsx +++ b/platform/ui/src/components/Modal/Modal.jsx @@ -1,25 +1,14 @@ import React from 'react'; import PropTypes from 'prop-types'; import ReactModal from 'react-modal'; -import classNames from 'classnames'; -import { Typography, useModal } from '@ohif/ui'; +import './Modal.css'; -const customStyle = { - overlay: { - zIndex: 1071, - backgroundColor: 'rgb(0, 0, 0, 0.8)', - display: 'flex', - alignItems: 'flex-start', - justifyContent: 'center', - padding: '40px 0', - }, -}; +import { Typography, useModal, IconButton, Icon } from '@ohif/ui'; ReactModal.setAppElement(document.getElementById('root')); const Modal = ({ - className, closeButton, shouldCloseOnEsc, isOpen, @@ -39,13 +28,15 @@ const Modal = ({
{title} {closeButton && ( - + + )}
) @@ -54,24 +45,17 @@ const Modal = ({ return ( - <> -
{renderHeader()}
-
- {children} -
- +
{renderHeader()}
+
+ {children} +
); }; @@ -81,7 +65,6 @@ Modal.defaultProps = { }; Modal.propTypes = { - className: PropTypes.string, closeButton: PropTypes.bool, shouldCloseOnEsc: PropTypes.bool, isOpen: PropTypes.bool, diff --git a/platform/ui/src/components/Select/Select.jsx b/platform/ui/src/components/Select/Select.jsx index b51b64056..0cc410a46 100644 --- a/platform/ui/src/components/Select/Select.jsx +++ b/platform/ui/src/components/Select/Select.jsx @@ -76,15 +76,9 @@ const Select = ({ options={options} value={selectedOptions} onChange={(selectedOptions, { action }) => { - if (!isMulti) { - onChange(selectedOptions, action); - return; - } - - const newSelection = selectedOptions.reduce( - (acc, curr) => acc.concat([curr.value]), - [] - ); + const newSelection = !selectedOptions.length + ? selectedOptions + : selectedOptions.reduce((acc, curr) => acc.concat([curr.value]), []); onChange(newSelection, action); }} > diff --git a/platform/ui/src/components/StudyListFilter/StudyListFilter.jsx b/platform/ui/src/components/StudyListFilter/StudyListFilter.jsx index 7c7a777b1..a4d5c0589 100644 --- a/platform/ui/src/components/StudyListFilter/StudyListFilter.jsx +++ b/platform/ui/src/components/StudyListFilter/StudyListFilter.jsx @@ -48,6 +48,7 @@ const StudyListFilter = ({ color="inherit" className="text-primary-active" startIcon={} + onClick={showLearnMoreContent} > Learn more diff --git a/platform/ui/src/components/ToolbarButton/ToolbarButton.jsx b/platform/ui/src/components/ToolbarButton/ToolbarButton.jsx index 904d0dcf3..3ce1b20e1 100644 --- a/platform/ui/src/components/ToolbarButton/ToolbarButton.jsx +++ b/platform/ui/src/components/ToolbarButton/ToolbarButton.jsx @@ -25,6 +25,7 @@ const ToolbarButton = ({ }; const shouldShowDropdown = !!isActive && !!dropdownContent; + return (
{ +const Tooltip = ({ content, isSticky, position, tight, children, isDisabled }) => { const [isActive, setIsActive] = useState(false); const handleMouseOver = () => { @@ -39,7 +39,7 @@ const Tooltip = ({ content, isSticky, position, tight, children }) => { } }; - const isOpen = isSticky || isActive; + const isOpen = (isSticky || isActive) && !isDisabled; return (
{ + const [showPatientInfo, setShowPatientInfo] = useState(patientInfoVisibility); + const { label, isTracked, @@ -37,6 +39,8 @@ const ViewportActionBar = ({ scanner, } = patientInformation; + const onPatientInfoClick = () => setShowPatientInfo(!showPatientInfo) + const renderIconStatus = () => { if (modality === 'SR') { return ( @@ -60,29 +64,30 @@ const ViewportActionBar = ({ {!isTracked ? ( ) : ( - -
- -
-
- - Series is + +
+ +
+
+ + Series is tracked and can be viewed
in the measurement panel
+
-
- } - > - -
- )} + } + > + + + )}
); }; + return (
@@ -131,19 +136,18 @@ const ViewportActionBar = ({
)} - {showPatientInfo && ( -
- -
- )} +
+ +
); }; @@ -174,7 +178,7 @@ ViewportActionBar.propTypes = { ViewportActionBar.defaultProps = { showNavArrows: true, - showPatientInfo: true, + showPatientInfo: false, }; function PatientInfo({ @@ -185,11 +189,14 @@ function PatientInfo({ thickness, spacing, scanner, + isOpen, }) { return (
@@ -236,7 +243,7 @@ function PatientInfo({
- } + )} >
diff --git a/platform/ui/src/components/ViewportDownloadForm/ViewportDownloadForm.jsx b/platform/ui/src/components/ViewportDownloadForm/ViewportDownloadForm.jsx index 3f1283441..06c682502 100644 --- a/platform/ui/src/components/ViewportDownloadForm/ViewportDownloadForm.jsx +++ b/platform/ui/src/components/ViewportDownloadForm/ViewportDownloadForm.jsx @@ -6,10 +6,11 @@ import React, { useRef, } from 'react'; +import classnames from 'classnames'; + import { Typography, - InputText, - InputNumber, + Input, Tooltip, IconButton, Icon, @@ -169,7 +170,10 @@ const ViewportDownloadForm = ({ ); }; - const validSize = value => (value >= minimumSize ? value : minimumSize); + const validSize = useCallback( + value => (value >= minimumSize ? value : minimumSize), + [minimumSize] + ); const loadAndUpdateViewports = useCallback(async () => { const { width: scaledWidth, height: scaledHeight } = await loadImage( @@ -274,7 +278,7 @@ const ViewportDownloadForm = ({
- setFilename(value)} @@ -286,7 +290,8 @@ const ViewportDownloadForm = ({
-
-
setViewportElement(ref)} > Need some way of selecting which displaySets hit the viewports. const { DisplaySetService } = servicesManager.services; + useEffect(() => { + const { unsubscribe } = DisplaySetService.subscribe( + DisplaySetService.EVENTS.DISPLAY_SETS_CHANGED, + displaySets => { + displaySets.sort((a, b) => { + const isImageSet = x => x instanceof ImageSet; + return (isImageSet(a) === isImageSet(b)) ? 0 : isImageSet(a) ? -1 : 1; + }); + dispatch({ + type: 'SET_DISPLAYSET_FOR_VIEWPORT', + payload: { + viewportIndex: 0, + displaySetInstanceUID: displaySets[0].displaySetInstanceUID, + }, + }); + }, + ); + + return () => { + unsubscribe(); + }; + }, []); + // TODO -> Make a HangingProtocolService const HangingProtocolService = displaySets => { let displaySetInstanceUID; diff --git a/platform/viewer/src/routes/DataSourceWrapper.jsx b/platform/viewer/src/routes/DataSourceWrapper.jsx index d0a0711e3..158594128 100644 --- a/platform/viewer/src/routes/DataSourceWrapper.jsx +++ b/platform/viewer/src/routes/DataSourceWrapper.jsx @@ -46,14 +46,17 @@ function DataSourceWrapper(props) { // studies.processResults --> // But only for LayoutTemplate type of 'list'? // Or no data fetching here, and just hand down my source - const [data, setData] = useState(); + const [data, setData] = useState([]); + const [isLoading, setIsLoading] = useState(false); useEffect(() => { // 204: no content async function getData() { + setIsLoading(true); const searchResults = await dataSource.query.studies.search( queryFilterValues ); setData(searchResults); + setIsLoading(false); } try { @@ -61,23 +64,19 @@ function DataSourceWrapper(props) { } catch (ex) { console.warn(ex); } - console.log('DataSourceWrapper: useEffect'); // eslint-disable-next-line react-hooks/exhaustive-deps }, [history.location.search]); // queryFilterValues // TODO: Better way to pass DataSource? return ( - - {data && ( - - )} - + ); } diff --git a/platform/viewer/src/routes/WorkList/WorkList.jsx b/platform/viewer/src/routes/WorkList/WorkList.jsx index d32ef7a73..f7edc664d 100644 --- a/platform/viewer/src/routes/WorkList/WorkList.jsx +++ b/platform/viewer/src/routes/WorkList/WorkList.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { Suspense, useState, useEffect } from 'react'; import classnames from 'classnames'; import PropTypes from 'prop-types'; import { Link } from 'react-router-dom'; @@ -29,15 +29,16 @@ const seriesInStudiesMap = new Map(); * TODO: * - debounce `setFilterValues` (150ms?) */ -function WorkList({ history, data: studies, dataSource }) { +function WorkList({ history, data: studies, isLoadingData, dataSource }) { // ~ Modes const [appConfig] = useAppConfig(); // ~ Filters const query = useQuery(); const queryFilterValues = _getQueryFilterValues(query); - const [filterValues, _setFilterValues] = useState( - Object.assign({}, defaultFilterValues, queryFilterValues) - ); + const [filterValues, _setFilterValues] = useState({ + ...defaultFilterValues, + ...queryFilterValues, + }); const debouncedFilterValues = useDebounce(filterValues, 200); const { resultsPerPage, pageNumber, sortBy, sortDirection } = filterValues; @@ -81,6 +82,7 @@ function WorkList({ history, data: studies, dataSource }) { return 0; }); + // ~ Rows & Studies const [expandedRows, setExpandedRows] = useState([]); const [studiesWithSeriesData, setStudiesWithSeriesData] = useState([]); @@ -191,6 +193,7 @@ function WorkList({ history, data: studies, dataSource }) { return filterValues[name] !== defaultFilterValues[name]; }); }; + const tableDataSource = sortedStudies.map((study, key) => { const rowKey = key + 1; const isExpanded = expandedRows.some(k => k === rowKey); @@ -395,7 +398,7 @@ function WorkList({ history, data: studies, dataSource }) { ) : (
- +
)}
diff --git a/yarn.lock b/yarn.lock index eb88b95ac..1b0b1ec12 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6755,10 +6755,10 @@ cornerstone-math@^0.1.8: resolved "https://registry.yarnpkg.com/cornerstone-math/-/cornerstone-math-0.1.8.tgz#68ab1f9e4fdcd7c5cb23a0d2eb4263f9f894f1c5" integrity sha512-x7NEQHBtVG7j1yeyj/aRoKTpXv1Vh2/H9zNLMyqYJDtJkNng8C4Q8M3CgZ1qer0Yr7eVq2x+Ynmj6kfOm5jXKw== -cornerstone-tools@4.15.1: - version "4.15.1" - resolved "https://registry.yarnpkg.com/cornerstone-tools/-/cornerstone-tools-4.15.1.tgz#f0b1026da9c7758defc088bb2f1e78426a23d91e" - integrity sha512-fJuTUJW/NDSD520jPB++tX//Kq80jPDngChHZrhx2xjj/9dCnexD9kmTX30Z0WUq0a2JEHZ6FlcOX38kSmw1hQ== +cornerstone-tools@4.16.0: + version "4.16.0" + resolved "https://registry.yarnpkg.com/cornerstone-tools/-/cornerstone-tools-4.16.0.tgz#af3d32d13722b97bec258492642e622312280196" + integrity sha512-kUhuSb2Ixpd2hgbdem+740rnN4hmoxzcOBNaUcsizFRWjMAsgc0yUxBFwLl0mIs811mefq79JOL+juwRA8T3Tg== dependencies: "@babel/runtime" "7.1.2" cornerstone-math "0.1.7" @@ -7353,10 +7353,10 @@ dateformat@^3.0.0: resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae" integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q== -dcmjs@^0.12.2, dcmjs@^0.12.4: - version "0.12.4" - resolved "https://registry.yarnpkg.com/dcmjs/-/dcmjs-0.12.4.tgz#82c24abdc357ea5281b78eb2cae8b781f7392aa3" - integrity sha512-N1ZsXqZIysirqdytb7h572TyIjmxpvCjrzdjtQsuPN8gC2EpxsUHQ598CPzaJBpBy9i1kfKuq4h2Jwt99cr/QQ== +dcmjs@0.14.0: + version "0.14.0" + resolved "https://registry.yarnpkg.com/dcmjs/-/dcmjs-0.14.0.tgz#0dc6cb2d15ddcff759bc9002f2a9704537735d1c" + integrity sha512-VL/Ibxe5RDsc5j5SEv3aEqdlKuBXz81/bBuW59Or0cos9vgK3XnVl3rr0ct6DWXJGK8vGIUMBOguyd/NRvlN0w== dependencies: "@babel/polyfill" "^7.8.3" "@babel/runtime" "^7.8.4"