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:
parent
e8d56c11f0
commit
0c37a406d9
1
extensions/cornerstone/babel.config.js
Normal file
1
extensions/cornerstone/babel.config.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
module.exports = require("../../babel.config.js");
|
||||||
13
extensions/cornerstone/jest.config.js
Normal file
13
extensions/cornerstone/jest.config.js
Normal 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"
|
||||||
|
// ]
|
||||||
|
};
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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/"
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 };
|
||||||
|
|||||||
@ -13,6 +13,7 @@ describe('Top level exports', () => {
|
|||||||
'UINotificationService',
|
'UINotificationService',
|
||||||
'UIModalService',
|
'UIModalService',
|
||||||
'UIDialogService',
|
'UIDialogService',
|
||||||
|
'MeasurementService',
|
||||||
//
|
//
|
||||||
'utils',
|
'utils',
|
||||||
'studies',
|
'studies',
|
||||||
|
|||||||
@ -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 };
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
8
platform/core/src/services/MeasurementService/index.js
Normal file
8
platform/core/src/services/MeasurementService/index.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import MeasurementService from './MeasurementService';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'MeasurementService',
|
||||||
|
create: ({ configuration = {} }) => {
|
||||||
|
return new MeasurementService();
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user