diff --git a/extensions/cornerstone/package.json b/extensions/cornerstone/package.json index 4dd657d40..d1b8588ad 100644 --- a/extensions/cornerstone/package.json +++ b/extensions/cornerstone/package.json @@ -33,7 +33,7 @@ "@ohif/ui": "^0.50.0", "cornerstone-core": "^2.3.0", "cornerstone-math": "^0.1.8", - "cornerstone-tools": "4.15.1", + "cornerstone-tools": "4.16.0", "dcmjs": "^0.12.3", "cornerstone-wado-image-loader": "^3.1.2", "dicom-parser": "^1.8.3", 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..b1d9f2f44 --- /dev/null +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/ArrowAnnotate.js @@ -0,0 +1,49 @@ +import SUPPORTED_TOOLS from './constants/supportedTools'; +import getHandlesFromPoints from './utils/getHandlesFromPoints'; +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/src/ViewerLayout/index.jsx b/extensions/default/src/ViewerLayout/index.jsx index 0e9a58904..e82352c57 100644 --- a/extensions/default/src/ViewerLayout/index.jsx +++ b/extensions/default/src/ViewerLayout/index.jsx @@ -63,13 +63,20 @@ function ViewerLayout({ 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( ToolBarService.EVENTS.TOOL_BAR_MODIFIED, () => { console.warn('~~~ TOOL BAR MODIFIED EVENT CAUGHT'); const updatedToolbars = { - primary: ToolBarService.getButtonSection('primary', { setActiveTool: setActiveToolHandler }), + primary: ToolBarService.getButtonSection('primary', { onClick: onPrimaryClickHandler, setActiveTool: setActiveToolHandler }), secondary: ToolBarService.getButtonSection('secondary', { setActiveTool: setActiveToolHandler }), }; setToolbars(updatedToolbars); diff --git a/extensions/dicom-rt/package.json b/extensions/dicom-rt/package.json index fb67f9acf..ddd9bfac0 100644 --- a/extensions/dicom-rt/package.json +++ b/extensions/dicom-rt/package.json @@ -30,7 +30,7 @@ "peerDependencies": { "@ohif/core": "^0.50.0", "cornerstone-core": "^2.2.8", - "cornerstone-tools": "4.15.1", + "cornerstone-tools": "4.16.0", "dcmjs": "^0.12.3", "prop-types": "^15.6.2", "react": "^16.8.6", diff --git a/extensions/dicom-sr/package.json b/extensions/dicom-sr/package.json index 6b14984c9..0757083bf 100644 --- a/extensions/dicom-sr/package.json +++ b/extensions/dicom-sr/package.json @@ -33,7 +33,7 @@ "@ohif/ui": "^0.50.0", "cornerstone-core": "^2.3.0", "cornerstone-math": "^0.1.8", - "cornerstone-tools": "4.15.1", + "cornerstone-tools": "4.16.0", "dcmjs": "^0.12.3", "cornerstone-wado-image-loader": "^3.1.2", "dicom-parser": "^1.8.3", diff --git a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.js b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.js index ece666e80..edfd44aa1 100644 --- a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.js +++ b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.js @@ -42,7 +42,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 @@ -130,7 +130,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 +153,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 +171,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 +185,8 @@ function _getDisplayText(points, pixelSpacing, seriesNumber, instanceNumber) { // Bidirectional? // Freehand? + const { type, points } = measurement; + const hasPixelSpacing = pixelSpacing !== undefined && Array.isArray(pixelSpacing) && @@ -186,13 +196,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/viewports/TrackedCornerstoneViewport.js b/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.js index 327c49a77..c582bd929 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 { @@ -13,6 +14,18 @@ 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,6 +40,7 @@ function TrackedCornerstoneViewport({ viewportIndex, }) { const [trackedMeasurements] = useTrackedMeasurements(); + const [ { activeViewportIndex, viewports }, dispatchViewportGrid, @@ -34,6 +48,8 @@ function TrackedCornerstoneViewport({ // 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 +58,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 +201,7 @@ function TrackedCornerstoneViewport({ vp => vp.displaySetInstanceUID === displaySet.displaySetInstanceUID ); const { trackedSeries } = trackedMeasurements.context; + const { Modality, SeriesDate, @@ -131,6 +217,11 @@ function TrackedCornerstoneViewport({ SliceThickness, } = displaySet.images[0]; + + if (trackedSeries.includes(SeriesInstanceUID) !== isTracked) { + setIsTracked(!isTracked); + } + return ( <> ( + <> +
+ + {title} + + + {subtitle} + +
+ {index + 1} + + ) + } }, }, { diff --git a/platform/core/package.json b/platform/core/package.json index 83320ad2f..71dc6b6d7 100644 --- a/platform/core/package.json +++ b/platform/core/package.json @@ -31,7 +31,7 @@ }, "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" }, 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 08cc1e3cb..2cabbe54d 100644 --- a/platform/core/src/services/ToolBarService/ToolBarService.js +++ b/platform/core/src/services/ToolBarService/ToolBarService.js @@ -156,6 +156,9 @@ export default class ToolBarService { if (btn.props.clickHandler) { btn.clickHandler(evt, btn, btnSection); } + if (props && props.onClick) { + props.onClick(evt, btn, btnSection, props); + } }; return { diff --git a/platform/ui/index.js b/platform/ui/index.js index db85039d9..2025cfb3b 100644 --- a/platform/ui/index.js +++ b/platform/ui/index.js @@ -32,6 +32,8 @@ export { DateRange, Dialog, EmptyStudies, + ExpandableToolbarButton, + ListMenu, Icon, IconButton, Input, 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/IconButton/IconButton.jsx b/platform/ui/src/components/IconButton/IconButton.jsx index 475e61620..d92a6b9ca 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 outline-none font-bold focus:outline-none'; const roundedClasses = { none: '', @@ -113,7 +113,7 @@ const IconButton = ({ }; IconButton.defaultProps = { - onClick: () => {}, + onClick: () => { }, color: 'default', disabled: false, fullWidth: false, 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/Select/Select.jsx b/platform/ui/src/components/Select/Select.jsx index 3a47d8a6a..399edd541 100644 --- a/platform/ui/src/components/Select/Select.jsx +++ b/platform/ui/src/components/Select/Select.jsx @@ -76,7 +76,7 @@ const Select = ({ options={options} value={selectedOptions} onChange={(selectedOptions, { action }) => { - const newSelection = selectedOptions.reduce( + const newSelection = !selectedOptions.length ? selectedOptions : selectedOptions.reduce( (acc, curr) => acc.concat([curr.value]), [] ); diff --git a/platform/ui/src/components/Tooltip/Tooltip.jsx b/platform/ui/src/components/Tooltip/Tooltip.jsx index fabfa18f6..42ef4498b 100644 --- a/platform/ui/src/components/Tooltip/Tooltip.jsx +++ b/platform/ui/src/components/Tooltip/Tooltip.jsx @@ -24,7 +24,7 @@ const arrowPositionStyle = { }, }; -const Tooltip = ({ content, isSticky, position, tight, children }) => { +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 (