feat: 🎸 MeasurementService (#1314)

* feat: 🎸 MeasurementService

Work in progress...

* Remove cornerstone tools import

* Second iteration

* CR Update: Add update / added events example

* Add new props to measurements

* Update event log

* Add new measurementid to annotation

* Add context support

* Add value types

* Add area

* Add todo

* Wip measurement to annotation map

* Change points representation

* Add props to annotation mapping

* Add tests

* Extract formatter from init and add tests

* Sketch matchers

* Fix events and valuetypes imports

* Remove context support

* Rename formatter to mappings

* Sketching source and source definitions

* Adjust matching criteria in addOrUpdate

* CR Updates: Extract private functions and rename variables

* Fix broken tests

* Add more measurement service tests

* Update broken mapping tests

* Update test description

* Update getAnnotation to get mapping based on def and source

Co-authored-by: Danny Brown <danny.ri.brown@gmail.com>
This commit is contained in:
Igor Octaviano 2020-02-10 16:15:05 -03:00 committed by GitHub
parent e8d56c11f0
commit 0c37a406d9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 1255 additions and 6 deletions

View File

@ -0,0 +1 @@
module.exports = require("../../babel.config.js");

View File

@ -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: [
// //`<rootDir>/platform/${pack.name}/**/*.spec.js`
// "<rootDir>/platform/viewer/**/*.test.js"
// ]
};

View File

@ -25,7 +25,9 @@
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js", "build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
"build:package": "yarn run build", "build:package": "yarn run build",
"prepublishOnly": "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": { "peerDependencies": {
"@ohif/core": "^0.50.0", "@ohif/core": "^0.50.0",

View File

@ -5,6 +5,7 @@ import csTools from 'cornerstone-tools';
import merge from 'lodash.merge'; import merge from 'lodash.merge';
import queryString from 'query-string'; import queryString from 'query-string';
import initCornerstoneTools from './initCornerstoneTools.js'; import initCornerstoneTools from './initCornerstoneTools.js';
import measurementServiceMappingsFactory from './utils/measurementServiceMappings/measurementServiceMappingsFactory';
function fallbackMetaDataProvider(type, imageId) { function fallbackMetaDataProvider(type, imageId) {
if (!imageId.includes('wado?requestType=WADO')) { if (!imageId.includes('wado?requestType=WADO')) {
@ -17,7 +18,7 @@ function fallbackMetaDataProvider(type, imageId) {
const wadoRoot = window.store.getState().servers.servers[0].wadoRoot; const wadoRoot = window.store.getState().servers.servers[0].wadoRoot;
const wadoRsImageId = `wadors:${wadoRoot}/studies/${qs.studyUID}/series/${ const wadoRsImageId = `wadors:${wadoRoot}/studies/${qs.studyUID}/series/${
qs.seriesUID qs.seriesUID
}/instances/${qs.objectUID}/frames/${qs.frame || 1}`; }/instances/${qs.objectUID}/frames/${qs.frame || 1}`;
return cornerstone.metaData.get(type, wadoRsImageId); return cornerstone.metaData.get(type, wadoRsImageId);
} }
@ -32,9 +33,9 @@ cornerstone.metaData.addProvider(fallbackMetaDataProvider, -1);
* @param {Object|Array} configuration.csToolsConfig * @param {Object|Array} configuration.csToolsConfig
*/ */
export default function init({ servicesManager, configuration }) { export default function init({ servicesManager, configuration }) {
const callInputDialog = (data, event, callback) => { const { UIDialogService, MeasurementService } = servicesManager.services;
const { UIDialogService } = servicesManager.services;
const callInputDialog = (data, event, callback) => {
if (UIDialogService) { if (UIDialogService) {
let dialogId = UIDialogService.create({ let dialogId = UIDialogService.create({
centralize: true, centralize: true,
@ -107,6 +108,9 @@ export default function init({ servicesManager, configuration }) {
tools.push(...toolsGroupedByType[toolsGroup]) tools.push(...toolsGroupedByType[toolsGroup])
); );
/* Measurement Service */
_connectToolsToMeasurementService(MeasurementService);
/* Add extension tools configuration here. */ /* Add extension tools configuration here. */
const internalToolsConfig = { const internalToolsConfig = {
ArrowAnnotate: { ArrowAnnotate: {
@ -182,3 +186,111 @@ export default function init({ servicesManager, configuration }) {
csTools.setToolActive('ZoomTouchPinch', {}); csTools.setToolActive('ZoomTouchPinch', {});
csTools.setToolEnabled('Overlay', {}); 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);
}
);
});
}
);
};

View File

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

View File

@ -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);
});
});
});

View File

@ -10,7 +10,8 @@ module.exports = {
// https://jestjs.io/docs/en/configuration#projects-array-string-projectconfig // https://jestjs.io/docs/en/configuration#projects-array-string-projectconfig
projects: [ projects: [
// "<rootDir>/extensions/*/jest.config.js", // "<rootDir>/extensions/*/jest.config.js",
"<rootDir>/platform/*/jest.config.js" "<rootDir>/platform/*/jest.config.js",
"<rootDir>/extensions/*/jest.config.js"
], ],
coverageDirectory: "<rootDir>/coverage/" coverageDirectory: "<rootDir>/coverage/"
}; };

View File

@ -24,6 +24,7 @@ import {
UINotificationService, UINotificationService,
UIModalService, UIModalService,
UIDialogService, UIDialogService,
MeasurementService,
} from './services'; } from './services';
const OHIF = { const OHIF = {
@ -55,6 +56,7 @@ const OHIF = {
UINotificationService, UINotificationService,
UIModalService, UIModalService,
UIDialogService, UIDialogService,
MeasurementService,
}; };
export { export {
@ -85,6 +87,7 @@ export {
UINotificationService, UINotificationService,
UIModalService, UIModalService,
UIDialogService, UIDialogService,
MeasurementService,
}; };
export { OHIF }; export { OHIF };

View File

@ -13,6 +13,7 @@ describe('Top level exports', () => {
'UINotificationService', 'UINotificationService',
'UIModalService', 'UIModalService',
'UIDialogService', 'UIDialogService',
'MeasurementService',
// //
'utils', 'utils',
'studies', 'studies',

View File

@ -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 };

View File

@ -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);
});
});
});

View File

@ -0,0 +1,8 @@
import MeasurementService from './MeasurementService';
export default {
name: 'MeasurementService',
create: ({ configuration = {} }) => {
return new MeasurementService();
},
};

View File

@ -2,10 +2,12 @@ import ServicesManager from './ServicesManager.js';
import UINotificationService from './UINotificationService'; import UINotificationService from './UINotificationService';
import UIModalService from './UIModalService'; import UIModalService from './UIModalService';
import UIDialogService from './UIDialogService'; import UIDialogService from './UIDialogService';
import MeasurementService from './MeasurementService';
export { export {
UINotificationService, UINotificationService,
UIModalService, UIModalService,
UIDialogService, UIDialogService,
ServicesManager, ServicesManager,
MeasurementService,
}; };

View File

@ -23,6 +23,7 @@ import {
UINotificationService, UINotificationService,
UIModalService, UIModalService,
UIDialogService, UIDialogService,
MeasurementService,
utils, utils,
redux as reduxOHIF, redux as reduxOHIF,
} from '@ohif/core'; } from '@ohif/core';
@ -122,7 +123,12 @@ class App extends Component {
} = this._appConfig; } = this._appConfig;
this.initUserManager(oidc); this.initUserManager(oidc);
_initServices([UINotificationService, UIModalService, UIDialogService]); _initServices([
UINotificationService,
UIModalService,
UIDialogService,
MeasurementService,
]);
_initExtensions( _initExtensions(
[...defaultExtensions, ...extensions], [...defaultExtensions, ...extensions],
cornerstoneExtensionConfig, cornerstoneExtensionConfig,
@ -144,6 +150,7 @@ class App extends Component {
UINotificationService, UINotificationService,
UIDialogService, UIDialogService,
UIModalService, UIModalService,
MeasurementService,
} = servicesManager.services; } = servicesManager.services;
if (this._userManager) { if (this._userManager) {