diff --git a/extensions/cornerstone/babel.config.js b/extensions/cornerstone/babel.config.js new file mode 100644 index 00000000000..fed6f05fecd --- /dev/null +++ b/extensions/cornerstone/babel.config.js @@ -0,0 +1 @@ +module.exports = require("../../babel.config.js"); diff --git a/extensions/cornerstone/jest.config.js b/extensions/cornerstone/jest.config.js new file mode 100644 index 00000000000..9055db5b51e --- /dev/null +++ b/extensions/cornerstone/jest.config.js @@ -0,0 +1,13 @@ +const base = require('../../jest.config.base.js'); +const pkg = require('./package'); + +module.exports = { + ...base, + name: pkg.name, + displayName: pkg.name, + // rootDir: "../.." + // testMatch: [ + // //`/platform/${pack.name}/**/*.spec.js` + // "/platform/viewer/**/*.test.js" + // ] +}; diff --git a/extensions/cornerstone/package.json b/extensions/cornerstone/package.json index 13f85770588..9384c7e1c43 100644 --- a/extensions/cornerstone/package.json +++ b/extensions/cornerstone/package.json @@ -25,7 +25,9 @@ "build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js", "build:package": "yarn run build", "prepublishOnly": "yarn run build", - "start": "yarn run dev" + "start": "yarn run dev", + "test:unit": "jest --watchAll", + "test:unit:ci": "jest --ci --runInBand --collectCoverage" }, "peerDependencies": { "@ohif/core": "^0.50.0", diff --git a/extensions/cornerstone/src/init.js b/extensions/cornerstone/src/init.js index 77c499167ec..b9764f41ec8 100644 --- a/extensions/cornerstone/src/init.js +++ b/extensions/cornerstone/src/init.js @@ -5,6 +5,7 @@ import csTools from 'cornerstone-tools'; import merge from 'lodash.merge'; import queryString from 'query-string'; import initCornerstoneTools from './initCornerstoneTools.js'; +import measurementServiceMappingsFactory from './utils/measurementServiceMappings/measurementServiceMappingsFactory'; function fallbackMetaDataProvider(type, imageId) { if (!imageId.includes('wado?requestType=WADO')) { @@ -17,7 +18,7 @@ function fallbackMetaDataProvider(type, imageId) { const wadoRoot = window.store.getState().servers.servers[0].wadoRoot; const wadoRsImageId = `wadors:${wadoRoot}/studies/${qs.studyUID}/series/${ qs.seriesUID - }/instances/${qs.objectUID}/frames/${qs.frame || 1}`; + }/instances/${qs.objectUID}/frames/${qs.frame || 1}`; return cornerstone.metaData.get(type, wadoRsImageId); } @@ -32,9 +33,9 @@ cornerstone.metaData.addProvider(fallbackMetaDataProvider, -1); * @param {Object|Array} configuration.csToolsConfig */ export default function init({ servicesManager, configuration }) { - const callInputDialog = (data, event, callback) => { - const { UIDialogService } = servicesManager.services; + const { UIDialogService, MeasurementService } = servicesManager.services; + const callInputDialog = (data, event, callback) => { if (UIDialogService) { let dialogId = UIDialogService.create({ centralize: true, @@ -107,6 +108,9 @@ export default function init({ servicesManager, configuration }) { tools.push(...toolsGroupedByType[toolsGroup]) ); + /* Measurement Service */ + _connectToolsToMeasurementService(MeasurementService); + /* Add extension tools configuration here. */ const internalToolsConfig = { ArrowAnnotate: { @@ -182,3 +186,111 @@ export default function init({ servicesManager, configuration }) { csTools.setToolActive('ZoomTouchPinch', {}); csTools.setToolEnabled('Overlay', {}); } + +const _initMeasurementService = measurementService => { + /* Initialization */ + const { toAnnotation, toMeasurement } = 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 + ); + + return csToolsVer4MeasurementSource; +}; + +const _connectToolsToMeasurementService = measurementService => { + const csToolsVer4MeasurementSource = _initMeasurementService(measurementService); + const { + id: sourceId, + addOrUpdate, + getAnnotation, + } = csToolsVer4MeasurementSource; + + /* Measurement Service Events */ + cornerstone.events.addEventListener( + cornerstone.EVENTS.ELEMENT_ENABLED, + event => { + const { + MEASUREMENT_ADDED, + MEASUREMENT_UPDATED, + } = measurementService.EVENTS; + + measurementService.subscribe( + MEASUREMENT_ADDED, + ({ source, measurement }) => { + if (![sourceId].includes(source.id)) { + const annotation = getAnnotation('Length', measurement.id); + + console.log( + 'Measurement Service [Cornerstone]: Measurement added', + measurement + ); + console.log('Mapped annotation:', annotation); + } + }); + + measurementService.subscribe( + MEASUREMENT_UPDATED, + ({ source, measurement }) => { + if (![sourceId].includes(source.id)) { + const annotation = getAnnotation('Length', measurement.id); + + console.log( + 'Measurement Service [Cornerstone]: Measurement updated', + measurement + ); + console.log('Mapped annotation:', annotation); + } + } + ); + + const addOrUpdateMeasurement = csToolsAnnotation => { + try { + const { toolName, toolType, measurementData } = csToolsAnnotation; + const csTool = toolName || measurementData.toolType || toolType; + csToolsAnnotation.id = measurementData._measurementServiceId; + const measurementServiceId = addOrUpdate(csTool, csToolsAnnotation); + + if (!measurementData._measurementServiceId) { + addMeasurementServiceId(measurementServiceId, csToolsAnnotation); + } + } catch (error) { + console.warn('Failed to add or update measurement:', error); + } + }; + + const addMeasurementServiceId = (id, csToolsAnnotation) => { + const { measurementData } = csToolsAnnotation; + Object.assign(measurementData, { _measurementServiceId: id }); + }; + + [ + csTools.EVENTS.MEASUREMENT_ADDED, + csTools.EVENTS.MEASUREMENT_MODIFIED, + ].forEach(csToolsEvtName => { + event.detail.element.addEventListener( + csToolsEvtName, + ({ detail: csToolsAnnotation }) => { + console.log(`Cornerstone Element Event: ${csToolsEvtName}`); + addOrUpdateMeasurement(csToolsAnnotation); + } + ); + }); + } + ); +}; diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/measurementServiceMappingsFactory.js b/extensions/cornerstone/src/utils/measurementServiceMappings/measurementServiceMappingsFactory.js new file mode 100644 index 00000000000..46a409e2312 --- /dev/null +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/measurementServiceMappingsFactory.js @@ -0,0 +1,133 @@ +import cornerstone from 'cornerstone-core'; + +const SUPPORTED_TOOLS = ['Length', 'EllipticalRoi', 'RectangleRoi', 'ArrowAnnotate']; + +const measurementServiceMappingsFactory = measurementService => { + /** + * Maps measurement service format object to cornerstone annotation object. + * + * @param {Measurement} measurement The measurement instance + * @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, + } = _getAttributes(element); + + const points = []; + points.push(measurementData.handles); + + return { + id: measurementData._measurementServiceId, + sopInstanceUID: sopInstanceUid, + frameOfReferenceUID: frameOfReferenceUid, + referenceSeriesUID: seriesInstanceUid, + 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 sopInstance = cornerstone.metaData.get('instance', imageId); + const sopInstanceUid = sopInstance.sopInstanceUid; + const frameOfReferenceUid = sopInstance.frameOfReferenceUID; + const series = cornerstone.metaData.get('series', imageId); + const seriesInstanceUid = series.seriesInstanceUid; + + return { sopInstanceUid, frameOfReferenceUid, seriesInstanceUid }; + }; + + const _getValueTypeFromToolType = toolType => { + const { POLYLINE, ELLIPSE, POINT } = measurementService.VALUE_TYPES; + + /* TODO: Relocate static value types */ + const TOOL_TYPE_TO_VALUE_TYPE = { + Length: POLYLINE, + EllipticalRoi: ELLIPSE, + RectangleRoi: POLYLINE, + 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, + }; +}; + +export default measurementServiceMappingsFactory; diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/measurementServiceMappingsFactory.test.js b/extensions/cornerstone/src/utils/measurementServiceMappings/measurementServiceMappingsFactory.test.js new file mode 100644 index 00000000000..f17a432815f --- /dev/null +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/measurementServiceMappingsFactory.test.js @@ -0,0 +1,80 @@ +import measurementServiceMappingsFactory from './measurementServiceMappingsFactory'; + +jest.mock('cornerstone-core', () => ({ + ...jest.requireActual('cornerstone-core'), + getEnabledElement: () => ({ + image: { imageId: 123 }, + }), + metaData: { + ...jest.requireActual('cornerstone-core').metaData, + get: () => ({ + sopInstanceUid: "123", + frameOfReferenceUID: "123", + seriesInstanceUid: "123", + }), + }, +})); + +describe('measurementServiceMappings.js', () => { + let mappings; + let handles; + let points; + let csToolsAnnotation; + let measurement; + let measurementServiceMock; + let definition = 'Length'; + + beforeEach(() => { + measurementServiceMock = { + VALUE_TYPES: { + POLYLINE: 'value_type::polyline', + POINT: 'value_type::point', + ELLIPSE: 'value_type::ellipse', + MULTIPOINT: 'value_type::multipoint', + CIRCLE: 'value_type::circle', + }, + }; + mappings = measurementServiceMappingsFactory(measurementServiceMock); + handles = { start: { x: 1, y: 2 }, end: { x: 1, y: 2 } }; + points = [{ x: 1, y: 2 }, { x: 1, y: 2 }]; + csToolsAnnotation = { + toolName: definition, + measurementData: { + _measurementServiceId: 1, + sopInstanceUid: '123', + frameOfReferenceUid: '123', + seriesInstanceUid: '123', + handles, + text: 'Test', + description: 'Test', + unit: 'mm', + }, + }; + measurement = { + id: 1, + sopInstanceUID: '123', + frameOfReferenceUID: '123', + referenceSeriesUID: '123', + label: 'Test', + description: 'Test', + unit: 'mm', + type: measurementServiceMock.VALUE_TYPES.POLYLINE, + points: points, + }; + jest.clearAllMocks(); + }); + + describe('toAnnotation()', () => { + it('map measurement service format to annotation', async () => { + const mappedMeasurement = await mappings.toAnnotation(measurement, definition); + expect(mappedMeasurement).toEqual(csToolsAnnotation); + }); + }); + + describe('toMeasurement()', () => { + it('map annotation to measurement service format', async () => { + const mappedAnnotation = await mappings.toMeasurement(csToolsAnnotation); + expect(mappedAnnotation).toEqual(measurement); + }); + }); +}); diff --git a/jest.config.js b/jest.config.js index 549f14da80d..f309576702f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -10,7 +10,8 @@ module.exports = { // https://jestjs.io/docs/en/configuration#projects-array-string-projectconfig projects: [ // "/extensions/*/jest.config.js", - "/platform/*/jest.config.js" + "/platform/*/jest.config.js", + "/extensions/*/jest.config.js" ], coverageDirectory: "/coverage/" }; diff --git a/platform/core/src/index.js b/platform/core/src/index.js index 7f915003d13..f0b8dd2d44f 100644 --- a/platform/core/src/index.js +++ b/platform/core/src/index.js @@ -24,6 +24,7 @@ import { UINotificationService, UIModalService, UIDialogService, + MeasurementService, } from './services'; const OHIF = { @@ -55,6 +56,7 @@ const OHIF = { UINotificationService, UIModalService, UIDialogService, + MeasurementService, }; export { @@ -85,6 +87,7 @@ export { UINotificationService, UIModalService, UIDialogService, + MeasurementService, }; export { OHIF }; diff --git a/platform/core/src/index.test.js b/platform/core/src/index.test.js index b4a7026a07d..ac35a2543f5 100644 --- a/platform/core/src/index.test.js +++ b/platform/core/src/index.test.js @@ -13,6 +13,7 @@ describe('Top level exports', () => { 'UINotificationService', 'UIModalService', 'UIDialogService', + 'MeasurementService', // 'utils', 'studies', diff --git a/platform/core/src/services/MeasurementService/MeasurementService.js b/platform/core/src/services/MeasurementService/MeasurementService.js new file mode 100644 index 00000000000..1f4f734cda3 --- /dev/null +++ b/platform/core/src/services/MeasurementService/MeasurementService.js @@ -0,0 +1,481 @@ +import log from '../../log'; +import guid from '../../utils/guid'; + +/** + * Measurement source schema + * + * @typedef {Object} MeasurementSource + * @property {number} id - + * @property {string} name - + * @property {string} version - + */ + +/** + * Measurement schema + * + * @typedef {Object} Measurement + * @property {number} id - + * @property {string} sopInstanceUID - + * @property {string} frameOfReferenceUID - + * @property {string} referenceSeriesUID - + * @property {string} label - + * @property {string} description - + * @property {string} type - + * @property {string} unit - + * @property {number} area - + * @property {Array} points - + * @property {MeasurementSource} source - + */ + +/* Measurement schema keys for object validation. */ +const MEASUREMENT_SCHEMA_KEYS = [ + 'id', + 'sopInstanceUID', + 'frameOfReferenceUID', + 'referenceSeriesUID', + 'label', + 'description', + 'type', + 'unit', + 'area', // TODO: Add concept names instead (descriptor) + 'points', + 'source', +]; + +const EVENTS = { + MEASUREMENT_UPDATED: 'event::measurement_updated', + MEASUREMENT_ADDED: 'event::measurement_added', +}; + +const VALUE_TYPES = { + POLYLINE: 'value_type::polyline', + POINT: 'value_type::point', + ELLIPSE: 'value_type::ellipse', + MULTIPOINT: 'value_type::multipoint', + CIRCLE: 'value_type::circle', +}; + +class MeasurementService { + constructor() { + this.sources = {}; + this.mappings = {}; + this.measurements = {}; + this.listeners = {}; + Object.defineProperty(this, 'EVENTS', { + value: EVENTS, + writable: false, + enumerable: true, + configurable: false, + }); + Object.defineProperty(this, 'VALUE_TYPES', { + value: VALUE_TYPES, + writable: false, + enumerable: true, + configurable: false, + }); + } + + /** + * Get all measurements. + * + * @return {Measurement[]} Array of measurements + */ + getMeasurements() { + const measurements = this._arrayOfObjects(this.measurements); + return ( + measurements && + measurements.map(m => this.measurements[Object.keys(m)[0]]) + ); + } + + /** + * Get specific measurement by its id. + * + * @param {string} id If of the measurement + * @return {Measurement} Measurement instance + */ + getMeasurement(id) { + let measurement = null; + const measurements = this.measurements[id]; + + if (measurements && Object.keys(measurements).length > 0) { + measurement = this.measurements[id]; + } + + return measurement; + } + + /** + * Create a new source. + * + * @param {string} name Name of the source + * @param {string} version Source name + * @return {MeasurementSource} Measurement source instance + */ + createSource(name, version) { + if (!name) { + log.warn('Source name not provided. Exiting early.'); + return; + } + + if (!version) { + log.warn('Source version not provided. Exiting early.'); + return; + } + + const id = guid(); + const source = { + id, + name, + version, + }; + source.addOrUpdate = (definition, measurement) => { + return this.addOrUpdate(source, definition, measurement); + }; + source.getAnnotation = (definition, measurementId) => { + return this.getAnnotation(source, definition, measurementId); + }; + + log.info(`New '${name}@${version}' source added.`); + this.sources[id] = source; + + return source; + } + + /** + * Add a new measurement matching criteria along with mapping functions. + * + * @param {MeasurementSource} source Measurement source instance + * @param {string} definition Definition of the measurement (Annotation Type) + * @param {MatchingCriteria} matchingCriteria The matching criteria + * @param {Function} toSourceSchema Mapping function to source schema + * @param {Function} toMeasurementSchema Mapping function to measurement schema + * @return void + */ + addMapping( + source, + definition, + matchingCriteria, + toSourceSchema, + toMeasurementSchema + ) { + if (!this._isValidSource(source)) { + log.warn('Invalid source. Exiting early.'); + return; + } + + if (!matchingCriteria) { + log.warn('Matching criteria not provided. Exiting early.'); + return; + } + + if (!definition) { + log.warn('Definition not provided. Exiting early.'); + return; + } + + if (!toSourceSchema) { + log.warn('Source mapping function not provided. Exiting early.'); + return; + } + + if (!toMeasurementSchema) { + log.warn('Measurement mapping function not provided. Exiting early.'); + return; + } + + const mapping = { + matchingCriteria, + definition, + toSourceSchema, + toMeasurementSchema, + }; + + if (Array.isArray(this.mappings[source.id])) { + this.mappings[source.id].push(mapping); + } else { + this.mappings[source.id] = [mapping]; + } + + log.info(`New measurement mapping added to source '${this._getSourceInfo(source)}'.`); + } + + /** + * Get annotation for specific source. + * + * @param {MeasurementSource} source Measurement source instance + * @param {string} definition The source definition + * @param {string} measurementId The measurement service measurement id + * @return {Object} Source measurement schema + */ + getAnnotation(source, definition, measurementId) { + if (!this._isValidSource(source)) { + log.warn('Invalid source. Exiting early.'); + return; + } + + if (!definition) { + log.warn('No source definition provided. Exiting early.'); + return; + } + + const mapping = this._getMappingByMeasurementSource(measurementId, definition); + if (mapping) return mapping.toSourceSchema(measurement, definition); + + const measurement = this.getMeasurement(measurementId); + const matchingMapping = this._getMatchingMapping(source, definition, measurement); + + if (matchingMapping) { + log.info('Matching mapping found:', matchingMapping); + const { toSourceSchema, definition } = matchingMapping; + return toSourceSchema(measurement, definition); + } + } + + /** + * Adds or update persisted measurements. + * + * @param {MeasurementSource} source The measurement source instance + * @param {string} definition The source definition + * @param {Measurement} measurement The source measurement + * @return {string} A measurement id + */ + addOrUpdate(source, definition, sourceMeasurement) { + if (!this._isValidSource(source)) { + log.warn('Invalid source. Exiting early.'); + return; + } + + const sourceInfo = this._getSourceInfo(source); + + if (!definition) { + log.warn('No source definition provided. Exiting early.'); + return; + } + + if (!this._sourceHasMappings(source)) { + log.warn(`No measurement mappings found for '${sourceInfo}' source. Exiting early.`); + return; + } + + let measurement = {}; + try { + const sourceMappings = this.mappings[source.id]; + const { toMeasurementSchema } = sourceMappings.find( + mapping => mapping.definition === definition + ); + + /* Convert measurement */ + measurement = toMeasurementSchema(sourceMeasurement); + + /* Assign measurement source instance */ + measurement.source = source; + } catch (error) { + log.error(`Failed to map '${sourceInfo}' measurement for definition ${definition}:`, error.message); + return; + } + + if (!this._isValidMeasurement(measurement)) { + log.warn( + `Attempting to add or update a invalid measurement provided by '${sourceInfo}'. Exiting early.` + ); + return; + } + + let internalId = sourceMeasurement.id; + if (!internalId) { + internalId = guid(); + log.warn(`Measurement ID not found. Generating UID: ${internalId}`); + } + + const newMeasurement = { + ...measurement, + modifiedTimestamp: Math.floor(Date.now() / 1000), + id: internalId, + }; + + if (this.measurements[internalId]) { + log.info(`Measurement already defined. Updating measurement.`, newMeasurement); + this.measurements[internalId] = newMeasurement; + this._broadcastChange(this.EVENTS.MEASUREMENT_UPDATED, source, newMeasurement); + } else { + log.info(`Measurement added.`, newMeasurement); + this.measurements[internalId] = newMeasurement; + this._broadcastChange(this.EVENTS.MEASUREMENT_ADDED, source, newMeasurement); + } + + return newMeasurement.id; + } + + /** + * Subscribe to measurement updates. + * + * @param {string} eventName The name of the event + * @param {Function} callback Events callback + * @return {Object} Observable object with actions + */ + subscribe(eventName, callback) { + if (this._isValidEvent(eventName)) { + const listenerId = guid(); + const subscription = { id: listenerId, callback }; + + console.info(`Subscribing to '${eventName}'.`); + if (Array.isArray(this.listeners[eventName])) { + this.listeners[eventName].push(subscription); + } else { + this.listeners[eventName] = [subscription]; + } + + return { + unsubscribe: () => this._unsubscribe(eventName, listenerId), + }; + } else { + throw new Error(`Event ${eventName} not supported.`); + } + } + + _getMappingByMeasurementSource(measurementId, definition) { + const measurement = this.getMeasurement(measurementId); + if (this._isValidSource(measurement.source)) { + return this.mappings[measurement.source.id].find( + m => m.definition === definition + ); + } + } + + /** + * Get measurement mapping function if matching criteria. + * + * @param {MeasurementSource} source Measurement source instance + * @param {string} definition The source definition + * @param {string} measurement The measurement serice measurement + * @return {Object} The mapping based on matched criteria + */ + _getMatchingMapping(source, definition, measurement) { + const sourceMappings = this.mappings[source.id]; + + const sourceMappingsByDefinition = sourceMappings.filter( + mapping => mapping.definition === definition + ); + + /* Criteria Matching */ + return sourceMappingsByDefinition.find(({ matchingCriteria }) => { + return ( + measurement.points && + measurement.points.length === matchingCriteria.points + ); + }); + } + + /** + * Returns formatted string with source info. + * + * @param {MeasurementSource} source Measurement source + * @return {string} Source information + */ + _getSourceInfo(source) { + return `${source.name}@${source.version}`; + } + + /** + * Checks if given source is valid. + * + * @param {MeasurementSource} source Measurement source + * @return {boolean} Measurement source validation + */ + _isValidSource(source) { + return source && this.sources[source.id]; + } + + /** + * Checks if a given source has mappings. + * + * @param {MeasurementSource} source The measurement source + * @return {boolean} Validation if source has mappings + */ + _sourceHasMappings(source) { + return ( + Array.isArray(this.mappings[source.id]) && this.mappings[source.id].length + ); + } + + /** + * Broadcasts measurement changes. + * + * @param {string} measurementId The measurement id + * @param {MeasurementSource} source The measurement source + * @param {string} eventName The event name + * @return void + */ + _broadcastChange(eventName, source, measurement) { + const hasListeners = Object.keys(this.listeners).length > 0; + const hasCallbacks = Array.isArray(this.listeners[eventName]); + + if (hasListeners && hasCallbacks) { + this.listeners[eventName].forEach(listener => { + listener.callback({ source, measurement }); + }); + } + } + + /** + * Unsubscribe to measurement updates. + * + * @param {string} eventName The name of the event + * @param {string} listenerId The listeners id + * @return void + */ + _unsubscribe(eventName, listenerId) { + if (!this.listeners[eventName]) { + return; + } + + const listeners = this.listeners[eventName]; + if (Array.isArray(listeners)) { + this.listeners[eventName] = listeners.filter( + ({ id }) => id !== listenerId + ); + } else { + this.listeners[eventName] = undefined; + } + } + + /** + * Check if a given measurement data is valid. + * + * @param {Measurement} measurementData Measurement data + * @return {boolean} Measurement validation + */ + _isValidMeasurement(measurementData) { + Object.keys(measurementData).forEach(key => { + if (!MEASUREMENT_SCHEMA_KEYS.includes(key)) { + log.warn(`Invalid measurement key: ${key}`); + return false; + } + }); + + return true; + } + + /** + * Check if a given measurement service event is valid. + * + * @param {string} eventName The name of the event + * @return {boolean} Event name validation + */ + _isValidEvent(eventName) { + return Object.values(this.EVENTS).includes(eventName); + } + + /** + * Converts object of objects to array. + * + * @return {Array} Array of objects + */ + _arrayOfObjects = obj => { + return Object.entries(obj).map(e => ({ [e[0]]: e[1] })); + }; +} + +export default MeasurementService; +export { EVENTS, VALUE_TYPES }; diff --git a/platform/core/src/services/MeasurementService/MeasurementService.test.js b/platform/core/src/services/MeasurementService/MeasurementService.test.js new file mode 100644 index 00000000000..009ac40cdb9 --- /dev/null +++ b/platform/core/src/services/MeasurementService/MeasurementService.test.js @@ -0,0 +1,405 @@ +import MeasurementService from './MeasurementService.js'; +import log from '../../log'; + +jest.mock('../../log.js', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +})); + +describe('MeasurementService.js', () => { + let measurementService; + let measurement; + let source; + let definition; + let matchingCriteria; + let toAnnotation; + let toMeasurement; + let annotation; + + beforeEach(() => { + measurementService = new MeasurementService(); + source = measurementService.createSource('Test', '1'); + definition = 'Length'; + annotation = { + toolName: definition, + measurementData: {}, + }; + measurement = { + sopInstanceUID: '123', + frameOfReferenceUID: '1234', + referenceSeriesUID: '12345', + label: 'Label', + description: 'Description', + unit: 'mm', + area: 123, + type: measurementService.VALUE_TYPES.POLYLINE, + points: [{ x: 1, y: 2 }, { x: 1, y: 2 }], + source: source, + }; + toAnnotation = () => annotation; + toMeasurement = () => measurement; + matchingCriteria = { + valueType: measurementService.VALUE_TYPES.POLYLINE, + points: 2, + }; + log.warn.mockClear(); + jest.clearAllMocks(); + }); + + describe('createSource()', () => { + it('creates new source with name and version', () => { + measurementService.createSource('Testing', '1'); + }); + + it('logs warning and return early if no name provided', () => { + measurementService.createSource(null, '1'); + + expect(log.warn.mock.calls.length).toBe(1); + }); + + it('logs warning and return early if no version provided', () => { + measurementService.createSource('Testing', null); + + expect(log.warn.mock.calls.length).toBe(1); + }); + }); + + describe('addMapping()', () => { + it('adds new mapping', () => { + measurementService.addMapping( + source, + definition, + matchingCriteria, + toAnnotation, + toMeasurement + ); + }); + + it('logs warning and return early if no matching criteria provided', () => { + measurementService.addMapping( + source, + definition, + null, + toAnnotation, + toMeasurement + ); + + expect(log.warn.mock.calls.length).toBe(1); + }); + + it('logs warning and return early if invalid source provided', () => { + const invalidSoure = {}; + + measurementService.addMapping( + invalidSoure, + definition, + matchingCriteria, + toAnnotation, + toMeasurement + ); + + expect(log.warn.mock.calls.length).toBe(1); + }); + + it('logs warning and return early if no source provided', () => { + measurementService.addMapping( + null /* source */, + definition, + matchingCriteria, + toAnnotation, + toMeasurement + ); + + expect(log.warn.mock.calls.length).toBe(1); + }); + + it('logs warning and return early if no definition provided', () => { + measurementService.addMapping( + source, + null /* definition */, + matchingCriteria, + toAnnotation, + toMeasurement + ); + + expect(log.warn.mock.calls.length).toBe(1); + }); + + it('logs warning and return early if no measurement mapping function provided', () => { + measurementService.addMapping( + source, + definition, + matchingCriteria, + null /* toAnnotation */, + toMeasurement + ); + + expect(log.warn.mock.calls.length).toBe(1); + }); + + it('logs warning and return early if no annotation mapping function provided', () => { + measurementService.addMapping( + source, + definition, + matchingCriteria, + toAnnotation, + null /* toMeasurement */ + ); + + expect(log.warn.mock.calls.length).toBe(1); + }); + }); + + describe('getAnnotation()', () => { + it('get annotation based on matched criteria', () => { + measurementService.addMapping( + source, + definition, + matchingCriteria, + toAnnotation, + toMeasurement + ); + const measurementId = source.addOrUpdate(definition, annotation); + const mappedAnnotation = source.getAnnotation(definition, measurementId); + + expect(annotation).toBe(mappedAnnotation); + }); + + it('get annotation based on source and definition', () => { + measurementService.addMapping( + source, + definition, + {}, + toAnnotation, + toMeasurement + ); + const measurementId = source.addOrUpdate(definition, annotation); + const mappedAnnotation = source.getAnnotation(definition, measurementId); + + expect(annotation).toBe(mappedAnnotation); + }); + }); + + describe('getMeasurements()', () => { + it('return all measurement service measurements', () => { + const anotherMeasurement = { + ...measurement, + label: 'Label2', + unit: 'HU', + }; + + measurementService.addMapping( + source, + definition, + matchingCriteria, + toAnnotation, + toMeasurement + ); + + source.addOrUpdate(definition, measurement); + source.addOrUpdate(definition, anotherMeasurement); + + const measurements = measurementService.getMeasurements(); + + expect(measurements.length).toEqual(2); + expect(measurements.length).toEqual(2); + }); + }); + + describe('getMeasurement()', () => { + it('return measurement service measurement with given id', () => { + measurementService.addMapping( + source, + definition, + matchingCriteria, + toAnnotation, + toMeasurement + ); + + const id = source.addOrUpdate(definition, measurement); + const returnedMeasurement = measurementService.getMeasurement(id); + + /* Clear dynamic data */ + delete returnedMeasurement.modifiedTimestamp; + + expect({ id, ...measurement }).toEqual(returnedMeasurement); + }); + }); + + describe('addOrUpdate()', () => { + it('adds new measurements', () => { + measurementService.addMapping( + source, + definition, + matchingCriteria, + toAnnotation, + toMeasurement + ); + + source.addOrUpdate(definition, measurement); + source.addOrUpdate(definition, measurement); + + const measurements = measurementService.getMeasurements(); + + expect(measurements.length).toBe(2); + }); + + it('fails to add new measurements when no mapping', () => { + source.addOrUpdate(definition, measurement); + + expect(log.warn.mock.calls.length).toBe(1); + }); + + it('fails to add new measurements when invalid mapping function', () => { + measurementService.addMapping( + source, + definition, + matchingCriteria, + toAnnotation, + 1 /* Invalid */ + ); + + source.addOrUpdate(definition, measurement); + + expect(log.error.mock.calls.length).toBe(1); + }); + + it('adds new measurement with custom id', () => { + const newMeasurement = { id: 1, ...measurement }; + + measurementService.addMapping( + source, + definition, + matchingCriteria, + toAnnotation, + toMeasurement + ); + + /* Add new measurement */ + source.addOrUpdate(definition, newMeasurement); + const savedMeasurement = measurementService.getMeasurement(newMeasurement.id); + + /* Clear dynamic data */ + delete newMeasurement.modifiedTimestamp; + delete savedMeasurement.modifiedTimestamp; + + expect(newMeasurement).toEqual(savedMeasurement); + }); + + it('logs warning and return if adding invalid measurement', () => { + measurement.invalidProperty = {}; + + measurementService.addMapping( + source, + definition, + matchingCriteria, + toAnnotation, + toMeasurement + ); + + source.addOrUpdate(definition, measurement); + + expect(log.warn.mock.calls.length).toBe(2); + }); + + it('updates existent measurement', () => { + measurementService.addMapping( + source, + definition, + matchingCriteria, + toAnnotation, + toMeasurement + ); + + const id = source.addOrUpdate(definition, measurement); + + measurement.unit = 'HU'; + + source.addOrUpdate(definition, { id, ...measurement }); + const updatedMeasurement = measurementService.getMeasurement(id); + + expect(updatedMeasurement.unit).toBe('HU'); + }); + }); + + describe('subscribe()', () => { + it('subscribers receive broadcasted add event', () => { + measurementService.addMapping( + source, + definition, + matchingCriteria, + toAnnotation, + toMeasurement + ); + + const { MEASUREMENT_ADDED } = measurementService.EVENTS; + let addCallbackWasCalled = false; + + /* Subscribe to add event */ + measurementService.subscribe( + MEASUREMENT_ADDED, + () => (addCallbackWasCalled = true) + ); + + /* Add new measurement */ + source.addOrUpdate(definition, measurement); + + expect(addCallbackWasCalled).toBe(true); + }); + + it('subscribers receive broadcasted update event', () => { + measurementService.addMapping( + source, + definition, + matchingCriteria, + toAnnotation, + toMeasurement + ); + + const { MEASUREMENT_UPDATED } = measurementService.EVENTS; + let updateCallbackWasCalled = false; + + /* Subscribe to update event */ + measurementService.subscribe( + MEASUREMENT_UPDATED, + () => (updateCallbackWasCalled = true) + ); + + /* Create measurement */ + const id = source.addOrUpdate(definition, measurement); + + /* Update measurement */ + source.addOrUpdate(definition, { id, ...measurement }); + + expect(updateCallbackWasCalled).toBe(true); + }); + + it('unsubscribes a listener', () => { + measurementService.addMapping( + source, + definition, + matchingCriteria, + toAnnotation, + toMeasurement + ); + + let updateCallbackWasCalled = false; + const { MEASUREMENT_ADDED } = measurementService.EVENTS; + + /* Subscribe to Add event */ + const { unsubscribe } = measurementService.subscribe( + MEASUREMENT_ADDED, + () => (updateCallbackWasCalled = true) + ); + + /* Unsubscribe */ + unsubscribe(); + + /* Create measurement */ + source.addOrUpdate(definition, measurement); + + expect(updateCallbackWasCalled).toBe(false); + }); + }); +}); diff --git a/platform/core/src/services/MeasurementService/index.js b/platform/core/src/services/MeasurementService/index.js new file mode 100644 index 00000000000..308f7401d92 --- /dev/null +++ b/platform/core/src/services/MeasurementService/index.js @@ -0,0 +1,8 @@ +import MeasurementService from './MeasurementService'; + +export default { + name: 'MeasurementService', + create: ({ configuration = {} }) => { + return new MeasurementService(); + }, +}; diff --git a/platform/core/src/services/index.js b/platform/core/src/services/index.js index 9ac507c506e..a7fa17072f5 100644 --- a/platform/core/src/services/index.js +++ b/platform/core/src/services/index.js @@ -2,10 +2,12 @@ import ServicesManager from './ServicesManager.js'; import UINotificationService from './UINotificationService'; import UIModalService from './UIModalService'; import UIDialogService from './UIDialogService'; +import MeasurementService from './MeasurementService'; export { UINotificationService, UIModalService, UIDialogService, ServicesManager, + MeasurementService, }; diff --git a/platform/viewer/src/App.js b/platform/viewer/src/App.js index fc07a85669d..a835fbfa187 100644 --- a/platform/viewer/src/App.js +++ b/platform/viewer/src/App.js @@ -23,6 +23,7 @@ import { UINotificationService, UIModalService, UIDialogService, + MeasurementService, utils, redux as reduxOHIF, } from '@ohif/core'; @@ -122,7 +123,12 @@ class App extends Component { } = this._appConfig; this.initUserManager(oidc); - _initServices([UINotificationService, UIModalService, UIDialogService]); + _initServices([ + UINotificationService, + UIModalService, + UIDialogService, + MeasurementService, + ]); _initExtensions( [...defaultExtensions, ...extensions], cornerstoneExtensionConfig, @@ -144,6 +150,7 @@ class App extends Component { UINotificationService, UIDialogService, UIModalService, + MeasurementService, } = servicesManager.services; if (this._userManager) {