feat: Add White Labelling, Hanging Protocols, and tons of Extensions / Mode fixes

* modified

* new files in existing extensions/libraries/modes

* fixed bugs and tests

* Clean up

* fix: viewport display fixed

* Fixed configs for deployment

* Removed unnecessary files and functions

* Fixed default HP module

* Added white labelling

* Fixed hash routing

* Removed unnecessary routers

Co-authored-by: Alireza Sedghi <ar.sedghi@gmail.com>
This commit is contained in:
Erik ZieglerandAlireza Sedghi authored and GitHub committed 2021-05-28 14:32:48 -04:00
1 parent 0db81b30f3
commit 99b8dc4759
93 files changed
+3825 -1293

No files matched your search

+2 -1
View File
@@ -45,7 +45,8 @@
"mousetrap": "^1.6.3",
"query-string": "^6.14.0",
"object-hash": "2.1.1",
"cornerstone-math": "0.1.9"
"cornerstone-math": "0.1.9",
"validate.js": "^0.12.0"
},
"devDependencies": {
"webpack-merge": "5.7.3"
@@ -17,9 +17,11 @@ function create({
retrieve,
store,
reject,
parseRouteParams,
retrieveSeriesMetadata,
deleteStudyMetadataPromise,
getImageIdsForDisplaySet,
getImageIdsForInstance,
}) {
const defaultQuery = {
studies: {
@@ -56,15 +58,16 @@ function create({
};
const defaultReject = {};
return {
query: query || defaultQuery,
retrieve: retrieve || defaultRetrieve,
reject: reject || defaultReject,
store: store || defaultStore,
getImageIdsForDisplaySet,
parseRouteParams,
retrieveSeriesMetadata,
deleteStudyMetadataPromise,
getImageIdsForDisplaySet,
getImageIdsForInstance,
};
}
+72 -39
View File
@@ -189,7 +189,7 @@ class MetadataProvider {
metadata = {
modality: instance.Modality,
seriesInstanceUID: instance.SeriesInstanceUID,
seriesNumber: instance.SeriesNumber,
seriesNumber: getNumberValues(instance.SeriesNumber),
studyInstanceUID: instance.StudyInstanceUID,
seriesDate,
seriesTime,
@@ -197,9 +197,9 @@ class MetadataProvider {
break;
case WADO_IMAGE_LOADER_TAGS.PATIENT_STUDY_MODULE:
metadata = {
patientAge: instance.PatientAge,
patientSize: instance.PatientSize,
patientWeight: instance.PatientWeight,
patientAge: getNumberValues(instance.PatientAge),
patientSize: getNumberValues(instance.PatientSize),
patientWeight: getNumberValues(instance.PatientWeight),
};
break;
case WADO_IMAGE_LOADER_TAGS.IMAGE_PLANE_MODULE:
@@ -228,51 +228,53 @@ class MetadataProvider {
metadata = {
frameOfReferenceUID: instance.FrameOfReferenceUID,
rows: instance.Rows,
columns: instance.Columns,
imageOrientationPatient: ImageOrientationPatient,
rowCosines,
columnCosines,
imagePositionPatient: instance.ImagePositionPatient,
sliceThickness: instance.SliceThickness,
sliceLocation: instance.SliceLocation,
pixelSpacing: PixelSpacing,
rowPixelSpacing,
columnPixelSpacing,
rows: getNumberValues(instance.Rows),
columns: getNumberValues(instance.Columns),
imageOrientationPatient: getNumberValues(ImageOrientationPatient),
rowCosines: getNumberValues(rowCosines),
columnCosines: getNumberValues(columnCosines),
imagePositionPatient: getNumberValues(instance.ImagePositionPatient),
sliceThickness: getNumberValues(instance.SliceThickness),
sliceLocation: getNumberValues(instance.SliceLocation),
pixelSpacing: getNumberValues(PixelSpacing),
rowPixelSpacing: getNumberValues(rowPixelSpacing),
columnPixelSpacing: getNumberValues(columnPixelSpacing),
};
break;
case WADO_IMAGE_LOADER_TAGS.IMAGE_PIXEL_MODULE:
metadata = {
samplesPerPixel: instance.SamplesPerPixel,
photometricInterpretation: instance.PhotometricInterpretation,
rows: instance.Rows,
columns: instance.Columns,
bitsAllocated: instance.BitsAllocated,
bitsStored: instance.BitsStored,
highBit: instance.HighBit,
pixelRepresentation: instance.PixelRepresentation,
planarConfiguration: instance.PlanarConfiguration,
pixelAspectRatio: instance.PixelAspectRatio,
smallestPixelValue: instance.SmallestPixelValue,
largestPixelValue: instance.LargestPixelValue,
samplesPerPixel: getNumberValues(instance.SamplesPerPixel),
photometricInterpretation: getNumberValues(instance.PhotometricInterpretation),
rows: getNumberValues(instance.Rows),
columns: getNumberValues(instance.Columns),
bitsAllocated: getNumberValues(instance.BitsAllocated),
bitsStored: getNumberValues(instance.BitsStored),
highBit: getNumberValues(instance.HighBit),
pixelRepresentation: getNumberValues(instance.PixelRepresentation),
planarConfiguration: getNumberValues(instance.PlanarConfiguration),
pixelAspectRatio: getNumberValues(instance.PixelAspectRatio),
smallestPixelValue: getNumberValues(instance.SmallestPixelValue),
largestPixelValue: getNumberValues(instance.LargestPixelValue),
redPaletteColorLookupTableDescriptor:
instance.RedPaletteColorLookupTableDescriptor,
getNumberValues(instance.RedPaletteColorLookupTableDescriptor),
greenPaletteColorLookupTableDescriptor:
instance.GreenPaletteColorLookupTableDescriptor,
getNumberValues(instance.GreenPaletteColorLookupTableDescriptor),
bluePaletteColorLookupTableDescriptor:
instance.BluePaletteColorLookupTableDescriptor,
getNumberValues(instance.BluePaletteColorLookupTableDescriptor),
redPaletteColorLookupTableData:
instance.RedPaletteColorLookupTableData,
getNumberValues(instance.RedPaletteColorLookupTableData),
greenPaletteColorLookupTableData:
instance.GreenPaletteColorLookupTableData,
getNumberValues(instance.GreenPaletteColorLookupTableData),
bluePaletteColorLookupTableData:
instance.BluePaletteColorLookupTableData,
getNumberValues(instance.BluePaletteColorLookupTableData),
};
break;
case WADO_IMAGE_LOADER_TAGS.VOI_LUT_MODULE:
const { WindowCenter, WindowWidth } = instance;
if (WindowCenter === undefined || WindowWidth === undefined) {
return;
}
const windowCenter = Array.isArray(WindowCenter)
? WindowCenter
: [WindowCenter];
@@ -281,15 +283,20 @@ class MetadataProvider {
: [WindowWidth];
metadata = {
windowCenter,
windowWidth,
windowCenter: getNumberValues(windowCenter),
windowWidth: getNumberValues(windowWidth),
};
break;
case WADO_IMAGE_LOADER_TAGS.MODALITY_LUT_MODULE:
const { RescaleIntercept, RescaleSlope } = instance;
if (RescaleIntercept === undefined || RescaleSlope === undefined) {
return;
}
metadata = {
rescaleIntercept: instance.RescaleIntercept,
rescaleSlope: instance.RescaleSlope,
rescaleIntercept: getNumberValues(instance.RescaleIntercept),
rescaleSlope: getNumberValues(instance.RescaleSlope),
rescaleType: instance.RescaleType,
};
break;
@@ -401,7 +408,7 @@ class MetadataProvider {
case WADO_IMAGE_LOADER_TAGS.GENERAL_IMAGE_MODULE:
metadata = {
sopInstanceUid: instance.SOPInstanceUID,
instanceNumber: instance.InstanceNumber,
instanceNumber: getNumberValues(instance.InstanceNumber),
lossyImageCompression: instance.LossyImageCompression,
lossyImageCompressionRatio: instance.LossyImageCompressionRatio,
lossyImageCompressionMethod: instance.LossyImageCompressionMethod,
@@ -457,6 +464,31 @@ class MetadataProvider {
const metadataProvider = new MetadataProvider();
/**
* Returns the values as an array of javascript numbers
*
* @param element - The javascript object for the specified element in the metadata
* @returns {*}
*/
function getNumberValues(element) {
if (!element) {
return;
}
if (Array.isArray(element)) {
const values = [];
for (let i = 0; i < element.length; i++) {
values.push(parseFloat(element[i]));
}
return values;
}
return parseFloat(element)
}
export default metadataProvider;
const WADO_IMAGE_LOADER_TAGS = {
@@ -479,3 +511,4 @@ const WADO_IMAGE_LOADER_TAGS = {
};
const INSTANCE = 'instance';
const DICOMWEB = 'dicomweb';
@@ -2,7 +2,12 @@ import MODULE_TYPES from './MODULE_TYPES.js';
import log from './../log.js';
export default class ExtensionManager {
constructor({ commandsManager, servicesManager, hotkeysManager, appConfig = {} }) {
constructor({
commandsManager,
servicesManager,
hotkeysManager,
appConfig = {},
}) {
this.modules = {};
this.registeredExtensionIds = [];
this.moduleTypeNames = Object.values(MODULE_TYPES);
@@ -52,7 +57,7 @@ export default class ExtensionManager {
onModeEnter({
servicesManager: _servicesManager,
commandsManager: _commandsManager,
hotkeysManager: _hotkeysManager
hotkeysManager: _hotkeysManager,
});
}
});
@@ -115,9 +120,7 @@ export default class ExtensionManager {
*/
registerExtension = (extension, configuration = {}, dataSources = []) => {
if (!extension) {
throw new Error(
'Attempting to register a null/undefined extension.'
);
throw new Error('Attempting to register a null/undefined extension.');
}
let extensionId = extension.id;
@@ -191,6 +194,13 @@ export default class ExtensionManager {
] = element;
});
break;
case MODULE_TYPES.HANGING_PROTOCOL:
extensionModule.forEach(element => {
this.modulesMap[
`${extensionId}.${moduleType}.${element.name}`
] = element;
});
break;
default:
throw new Error(`Module type invalid: ${moduleType}`);
}
@@ -224,6 +234,10 @@ export default class ExtensionManager {
return this.dataSourceMap[this.activeDataSource];
};
getDataSource = () => {
return this.dataSourceMap[this.activeDataSource];
};
/**
* @private
* @param {string} moduleType
@@ -288,6 +302,32 @@ export default class ExtensionManager {
});
}
_initHangingProtocolModule(extensionModule, extensionId) {
extensionModule.forEach(element => {
const namespace = `${extensionId}.${MODULE_TYPES.HANGING_PROTOCOL}.${element.name}`;
dataSources.forEach(dataSource => {
if (dataSource.namespace === namespace) {
const dataSourceInstance = element.createDataSource(
dataSource.configuration
);
if (this.dataSourceMap[dataSource.sourceName]) {
this.dataSourceMap[dataSource.sourceName].push(dataSourceInstance);
} else {
this.dataSourceMap[dataSource.sourceName] = [dataSourceInstance];
}
}
});
});
extensionModule.forEach(element => {
this.modulesMap[
`${extensionId}.${MODULE_TYPES.DATA_SOURCE}.${element.name}`
] = element;
});
}
/**
*
* @private
@@ -215,6 +215,9 @@ describe('ExtensionManager.js', () => {
getDataSourcesModule: () => {
return [{}];
},
getHangingProtocolModule: () => {
return [{}];
},
getContextModule: () => {
return [{}];
}
@@ -7,4 +7,5 @@ export default {
VIEWPORT: 'viewportModule',
CONTEXT: 'contextModule',
LAYOUT_TEMPLATE: 'layoutTemplateModule',
HANGING_PROTOCOL: 'hangingProtocolModule',
};
+3
View File
@@ -25,6 +25,7 @@ import {
MeasurementService,
ViewportGridService,
HangingProtocolService,
pubSubServiceInterface,
} from './services';
import IWebApiDataSource from './DataSources/IWebApiDataSource';
@@ -67,6 +68,7 @@ const OHIF = {
HangingProtocolService,
IWebApiDataSource,
DicomMetadataStore,
pubSubServiceInterface,
};
export {
@@ -101,6 +103,7 @@ export {
HangingProtocolService,
IWebApiDataSource,
DicomMetadataStore,
pubSubServiceInterface,
};
export { OHIF };
+15 -16
View File
@@ -10,21 +10,6 @@ describe('Top level exports', () => {
'HotkeysManager',
'ServicesManager',
//
'DicomMetadataStore',
//
'CineService',
'DisplaySetService',
'HangingProtocolService',
'ToolBarService',
'UINotificationService',
'UIModalService',
'UIDialogService',
'UIViewportDialogService',
'MeasurementService',
'ViewportGridService',
//
'IWebApiDataSource',
//
'defaults',
'utils',
'hotkeys',
@@ -37,7 +22,21 @@ describe('Top level exports', () => {
'log',
'DICOMWeb',
'DICOMSR',
'OHIF', //
'OHIF',
//
'CineService',
'UIDialogService',
'UIModalService',
'UINotificationService',
'UIViewportDialogService',
'DisplaySetService',
'MeasurementService',
'ToolBarService',
'ViewportGridService',
'HangingProtocolService',
'IWebApiDataSource',
'DicomMetadataStore',
'pubSubServiceInterface'
].sort();
const exports = Object.keys(OHIF).sort();
@@ -0,0 +1,24 @@
const displayFunction = data => {
let meanValue = '';
const { cachedStats } = data;
if (cachedStats && cachedStats.mean && !isNaN(cachedStats.mean)) {
meanValue = cachedStats.mean.toFixed(2) + ' HU';
}
return meanValue;
};
export const polygonRoi = {
id: 'PolygonRoi',
name: 'Polygon',
toolGroup: 'allTools',
cornerstoneToolType: 'FreehandRoiTool',
options: {
measurementTable: {
displayFunction,
},
caseProgress: {
include: true,
evaluate: true,
},
},
};
@@ -54,6 +54,18 @@ function _getInstance(StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID) {
);
}
function _getInstanceFromImageId(imageId) {
for (let study of _model.studies) {
for (let series of study.series) {
for (let instance of series.instances) {
if (instance.imageId === imageId) {
return instance;
}
}
}
}
}
const BaseImplementation = {
EVENTS,
listeners: {},
@@ -82,7 +94,7 @@ const BaseImplementation = {
madeInClient,
});
},
addSeriesMetadata(seriesSummaryMetadata) {
addSeriesMetadata(seriesSummaryMetadata, madeInClient = false) {
const { StudyInstanceUID } = seriesSummaryMetadata[0];
let study = _getStudy(StudyInstanceUID);
if (!study) {
@@ -98,6 +110,7 @@ const BaseImplementation = {
this._broadcastEvent(EVENTS.SERIES_ADDED, {
StudyInstanceUID,
madeInClient,
});
},
addStudy(study) {
@@ -124,6 +137,7 @@ const BaseImplementation = {
getStudy: _getStudy,
getSeries: _getSeries,
getInstance: _getInstance,
getInstanceFromImageId: _getInstanceFromImageId,
};
const DicomMetadataStore = Object.assign(
@@ -1,4 +1,5 @@
const EVENTS = {
STUDY_ADDED: 'event::dicomMetadataStore:studyAdded',
INSTANCES_ADDED: 'event::dicomMetadataStore:instancesAdded',
SERIES_ADDED: 'event::dicomMetadataStore:seriesAdded',
};
@@ -3,6 +3,7 @@ import createSeriesMetadata from './createSeriesMetadata';
function createStudyMetadata(StudyInstanceUID) {
return {
StudyInstanceUID,
isLoaded: false,
series: [],
/**
*
@@ -24,7 +25,7 @@ function createStudyMetadata(StudyInstanceUID) {
}
},
setSeriesMetadata: function (SeriesInstanceUID, seriesMetadata) {
setSeriesMetadata: function(SeriesInstanceUID, seriesMetadata) {
let existingSeries = this.series.find(
s => s.SeriesInstanceUID === SeriesInstanceUID
);
@@ -34,7 +35,7 @@ function createStudyMetadata(StudyInstanceUID) {
} else {
this.series.push(Object.assign({ instances: [] }, seriesMetadata));
}
}
},
};
}
@@ -96,10 +96,13 @@ export default class DisplaySetService {
/**
*
* @param {*} input
* @param {*} param1
* @param {*} param1: settings: initialViewportSettings by HP or callbacks after rendering
* @returns {string[]} - added displaySetInstanceUIDs
*/
makeDisplaySets = (input, { batch = false, madeInClient = false } = {}) => {
makeDisplaySets = (
input,
{ batch = false, madeInClient = false, settings = {} } = {}
) => {
if (!input || !input.length) {
throw new Error('No instances were provided.');
}
@@ -116,12 +119,15 @@ export default class DisplaySetService {
if (batch) {
for (let i = 0; i < input.length; i++) {
const instances = input[i];
const displaySets = this.makeDisplaySetForInstances(instances);
const displaySets = this.makeDisplaySetForInstances(
instances,
settings
);
displaySetsAdded = [...displaySetsAdded, displaySets];
}
} else {
const displaySets = this.makeDisplaySetForInstances(input);
const displaySets = this.makeDisplaySetForInstances(input, settings);
displaySetsAdded = displaySets;
}
@@ -145,7 +151,7 @@ export default class DisplaySetService {
}
};
makeDisplaySetForInstances(instances) {
makeDisplaySetForInstances(instances, settings) {
const instance = instances[0];
const existingDisplaySets =
@@ -168,6 +174,13 @@ export default class DisplaySetService {
} else {
displaySets = handler.getDisplaySetsFromSeries(instances);
// applying hp-defined viewport settings to the displaysets
displaySets.forEach(ds => {
Object.keys(settings).forEach(key => {
ds[key] = settings[key];
});
});
this._addDisplaySetsToCache(displaySets);
this._addActiveDisplaySets(displaySets);
}
@@ -0,0 +1,96 @@
import validate from './lib/validator';
/**
* Match a Metadata instance against rules using Validate.js for validation.
* @param {InstanceMetadata} metadataInstance Metadata instance object
* @param {Array} rules Array of MatchingRules instances (StudyMatchingRule|SeriesMatchingRule|ImageMatchingRule) for the match
* @return {Object} Matching Object with score and details (which rule passed or failed)
*/
const match = (metadataInstance, rules, customAttributeRetrievalCallbacks) => {
const options = {
format: 'grouped',
};
const details = {
passed: [],
failed: [],
};
let requiredFailed = false;
let score = 0;
rules.forEach(rule => {
const attribute = rule.attribute;
// Do not use the custom attribute from the metadataInstance since it is subject to change
if (customAttributeRetrievalCallbacks.hasOwnProperty(attribute)) {
const customAttribute = customAttributeRetrievalCallbacks[attribute];
metadataInstance[attribute] = customAttribute.callback(metadataInstance);
}
// Format the constraint as required by Validate.js
const testConstraint = {
[attribute]: rule.constraint,
};
// Create a single attribute object to be validated, since metadataInstance is an
// instance of Metadata (StudyMetadata, SeriesMetadata or InstanceMetadata)
const attributeValue = metadataInstance[attribute];
const attributeMap = {
[attribute]: attributeValue,
};
// Use Validate.js to evaluate the constraints on the specified metadataInstance
let errorMessages;
try {
errorMessages = validate(attributeMap, testConstraint, [options]);
} catch (e) {
errorMessages = ['Something went wrong during validation.', e];
}
if (!errorMessages) {
// If no errorMessages were returned, then validation passed.
// Add the rule's weight to the total score
score += parseInt(rule.weight, 10);
// Log that this rule passed in the matching details object
details.passed.push({
rule,
});
} else {
// If errorMessages were present, then validation failed
// If the rule that failed validation was Required, then
// mark that a required Rule has failed
if (rule.required) {
requiredFailed = true;
}
// Log that this rule failed in the matching details object
// and include any error messages
details.failed.push({
rule,
errorMessages,
});
}
});
// If a required Rule has failed Validation, set the matching score to zero
if (requiredFailed) {
score = 0;
}
return {
score,
details,
requiredFailed,
};
};
const HPMatcher = {
match,
};
export { HPMatcher };
@@ -1,74 +1,476 @@
const name = 'HangingProtocolService';
import { cloneDeep } from 'lodash';
import pubSubServiceInterface from '../_shared/pubSubServiceInterface';
import sortBy from '../../utils/sortBy.js';
import ProtocolEngine from './ProtocolEngine';
const publicAPI = {
name,
getState: _getState,
setHangingProtocol: _setHangingProtocol,
setHangingProtocolAppliedForViewport: _setHangingProtocolAppliedForViewport,
setServiceImplementation,
reset: _reset,
set: _set,
const EVENTS = {
STAGE_CHANGE: 'event::hanging_protocol_stage_change',
NEW_LAYOUT: 'event::hanging_protocol_new_layout',
};
const serviceImplementation = {
_getState: () => console.warn('getState() NOT IMPLEMENTED'),
_setHangingProtocol: () =>
console.warn('_setHangingProtocol() NOT IMPLEMENTED'),
_setHangingProtocolAppliedForViewport: () =>
console.warn('_setHangingProtocolAppliedForViewport() NOT IMPLEMENTED'),
_reset: () => console.warn('reset() NOT IMPLEMENTED'),
_set: () => console.warn('set() NOT IMPLEMENTED'),
const VIEWPORT_SETTING_TYPES = {
PROPS: 'props',
VIEWPORT: 'viewport',
};
function _getState() {
return serviceImplementation._getState();
}
function _setHangingProtocol(hangingProtocol) {
return serviceImplementation._setHangingProtocol(hangingProtocol);
}
function _setHangingProtocolAppliedForViewport(hpAlreadyApplied) {
return serviceImplementation._setHangingProtocolAppliedForViewport(
hpAlreadyApplied
);
}
function _set(state) {
return serviceImplementation._set(state);
}
function _reset() {
return serviceImplementation._reset({});
}
function setServiceImplementation({
getState: getStateImplementation,
setHangingProtocol: setHangingProtocolImplementation,
setHangingProtocolAppliedForViewport: setHangingProtocolAppliedForViewportImplementation,
reset: resetImplementation,
set: setImplementation,
}) {
if (getStateImplementation) {
serviceImplementation._getState = getStateImplementation;
class HangingProtocolService {
constructor(commandsManager) {
this._commandsManager = commandsManager;
this.protocols = [];
this.ProtocolEngine = undefined;
this.protocol = undefined;
this.stage = undefined;
this.matchDetails = [];
this.hpAlreadyApplied = [];
this.studies = [];
this.customViewportSettings = [];
this.customAttributeRetrievalCallbacks = {};
this.listeners = {};
Object.defineProperty(this, 'EVENTS', {
value: EVENTS,
writable: false,
enumerable: true,
configurable: false,
});
Object.assign(this, pubSubServiceInterface);
}
if (setHangingProtocolImplementation) {
serviceImplementation._setHangingProtocol = setHangingProtocolImplementation;
reset() {
this.studies = [];
this.protocols = [];
this.hpAlreadyApplied = [];
this.matchDetails = [];
// this.ProtocolEngine.reset()
}
if (setHangingProtocolAppliedForViewportImplementation) {
serviceImplementation._setHangingProtocolAppliedForViewport = setHangingProtocolAppliedForViewportImplementation;
getState() {
return [this.matchDetails, this.hpAlreadyApplied];
}
if (resetImplementation) {
serviceImplementation._reset = resetImplementation;
getProtocols() {
return this.protocols;
}
if (setImplementation) {
serviceImplementation._set = setImplementation;
addProtocols(protocols) {
protocols.forEach(protocol => {
if (this.protocols.indexOf(protocol) === -1) {
this.protocols.push(protocol);
}
});
}
run(studyMetaData, protocol) {
if (!this.studies.includes(studyMetaData)) {
this.studies.push(studyMetaData);
}
// copy here so we don't mutate it
const metaData = Object.assign({}, studyMetaData);
this.ProtocolEngine = new ProtocolEngine(
this.protocols,
this.customAttributeRetrievalCallbacks
);
// if there is no pre-defiend protocol
if (!protocol || protocol.id === undefined) {
const matchedProtocol = this.ProtocolEngine.run(metaData);
this._setProtocol(matchedProtocol);
return;
}
this._setProtocol(protocol);
}
setHangingProtocolAppliedForViewport(i) {
this.hpAlreadyApplied[i] = true;
}
/**
* Adds a custom setting that can be chosen in the HangingProtocol UI and applied to a Viewport
*
* @param settingId The ID used to refer to the setting (e.g. 'displayCADMarkers')
* @param settingName The name of the setting to be displayed (e.g. 'Display CAD Markers')
* @param options
* @param callback A function to be run after a viewport is rendered with a series
*/
addCustomViewportSetting(...params) {
this.customViewportSettings.push(...params);
}
/**
* Adds a custom attribute to be used in the HangingProtocol UI and matching rules, including a
* callback that will be used to calculate the attribute value.
*
* @param attributeId The ID used to refer to the attribute (e.g. 'timepointType')
* @param attributeName The name of the attribute to be displayed (e.g. 'Timepoint Type')
* @param callback The function used to calculate the attribute value from the other attributes at its level (e.g. study/series/image)
*/
addCustomAttribute(attributeId, attributeName, callback) {
this.customAttributeRetrievalCallbacks[attributeId] = {
name: attributeName,
callback: callback,
};
}
/**
* Switches to the next protocol stage in the display set sequence
*/
nextProtocolStage() {
console.log('ProtocolEngine::nextProtocolStage');
if (!this._setCurrentProtocolStage(1)) {
console.log('ProtocolEngine::nextProtocolStage failed');
}
}
/**
* Switches to the previous protocol stage in the display set sequence
*/
previousProtocolStage() {
console.log('ProtocolEngine::previousProtocolStage');
if (!this._setCurrentProtocolStage(-1)) {
console.log('ProtocolEngine::previousProtocolStage failed');
}
}
_setProtocol(protocol) {
// TODO: Add proper Protocol class to validate the protocols
// which are entered manually
this.stage = 0;
this.protocol = protocol;
this._updateViewports(protocol);
}
/**
* Retrieves the number of Stages in the current Protocol or
* undefined if no protocol or stages are set
*/
_getNumProtocolStages() {
if (
!this.protocol ||
!this.protocol.stages ||
!this.protocol.stages.length
) {
return;
}
return this.protocol.stages.length;
}
/**
* Retrieves the current Stage from the current Protocol and stage index
*
* @returns {*} The Stage model for the currently displayed Stage
*/
_getCurrentStageModel() {
return this.protocol.stages[this.stage];
}
_updateViewports() {
// Make sure we have an active protocol with a non-empty array of display sets
if (!this._getNumProtocolStages()) {
return;
}
// Retrieve the current stage
const stageModel = this._getCurrentStageModel();
// If the current stage does not fulfill the requirements to be displayed,
// stop here.
if (
!stageModel ||
!stageModel.viewportStructure ||
!stageModel.viewports ||
!stageModel.viewports.length
) {
return;
}
// Retrieve the layoutTemplate associated with the current display set's viewport structure
// If no such template name exists, stop here.
// const layoutTemplateName = stageModel.viewportStructure.getLayoutTemplateName();
const layoutTemplateName = 'gridLayout';
if (!layoutTemplateName) {
return;
}
// Retrieve the properties associated with the current display set's viewport structure template
// If no such layout properties exist, stop here.
const layoutProps = stageModel.viewportStructure.properties;
if (!layoutProps) {
return;
}
const { columns: numCols, rows: numRows } = layoutProps;
this._broadcastChange(this.EVENTS.NEW_LAYOUT, {
numRows,
numCols,
});
// Empty the matchDetails associated with the ProtocolEngine.
// This will be used to store the pass/fail details and score
// for each of the viewport matching procedures
// Loop through each viewport
stageModel.viewports.forEach((viewport, viewportIndex) => {
this.hpAlreadyApplied.push(false);
const details = this._matchImages(viewport);
let currentMatch = details.bestMatch;
const currentViewportData = {
viewportIndex,
SeriesInstanceUID: currentMatch && currentMatch.SeriesInstanceUID,
};
// Viewport Settings
//
// protocol defined callback
const protocolCallbacks = viewport.viewportSettings.filter(
setting => setting.type === VIEWPORT_SETTING_TYPES.PROPS
);
// manually added callback
const customCallbacks = this.customViewportSettings.filter(
setting => setting.type === VIEWPORT_SETTING_TYPES.PROPS
);
const callbacks = protocolCallbacks.concat(customCallbacks);
// if we have callbacks to applied at the app level or at the HP level
if (callbacks.length) {
currentViewportData.renderedCallback = (element, ToolBarService) => {
callbacks.forEach(setting => {
const { commandName, options } = setting;
options.viewportIndex = viewportIndex;
options.element = element;
// Toolbar service to handle tool activation
if (commandName === 'setToolActive') {
ToolBarService.recordInteraction(options);
return;
}
// other commands
this._commandsManager.runCommand(commandName, options);
});
};
}
// initial viewport settings defined by protocol
const protocolInitialViewport = viewport.viewportSettings.filter(
setting => setting.type === VIEWPORT_SETTING_TYPES.VIEWPORT
);
// custom added initial viewport settings
const customInitialViewport = this.customViewportSettings.filter(
setting => setting.type === VIEWPORT_SETTING_TYPES.VIEWPORT
);
// TODO: conflict might happen between protocol and custom viewport settings
const viewportSettings = protocolInitialViewport.concat(
customInitialViewport
);
if (viewportSettings.length) {
const initialViewport = {};
viewportSettings.forEach(setting => {
const { options } = setting;
if (!options) return;
// Do not manipulate the hp settings
const viewportOptions = cloneDeep(options);
Object.entries(viewportOptions).forEach(([key, value]) => {
initialViewport[key] = value;
});
});
currentViewportData.initialViewport = initialViewport;
}
this.matchDetails[viewportIndex] = currentViewportData;
});
}
// Match images given a list of Studies and a Viewport's image matching reqs
_matchImages(viewport) {
console.log('ProtocolEngine::matchImages');
// TODO: matching is applied on study and series level, instance
// level matching needs to be added in future
const { studyMatchingRules, seriesMatchingRules } = viewport;
const matchingScores = [];
let highestStudyMatchingScore = 0;
let highestSeriesMatchingScore = 0;
this.studies.forEach(study => {
const studyMatchDetails = this.ProtocolEngine.findMatch(
study,
studyMatchingRules
);
// Prevent bestMatch from being updated if the matchDetails' required attribute check has failed
if (
studyMatchDetails.requiredFailed === true ||
studyMatchDetails.score < highestStudyMatchingScore
) {
return;
}
highestStudyMatchingScore = studyMatchDetails.score;
study.series.forEach(aSeries => {
const seriesMatchDetails = this.ProtocolEngine.findMatch(
aSeries,
seriesMatchingRules
);
// Prevent bestMatch from being updated if the matchDetails' required attribute check has failed
if (
seriesMatchDetails.requiredFailed === true ||
seriesMatchDetails.score < highestSeriesMatchingScore
) {
return;
}
highestSeriesMatchingScore = seriesMatchDetails.score;
const matchDetails = {
passed: [],
failed: [],
};
matchDetails.passed = matchDetails.passed.concat(
seriesMatchDetails.details.passed
);
matchDetails.passed = matchDetails.passed.concat(
studyMatchDetails.details.passed
);
matchDetails.failed = matchDetails.failed.concat(
seriesMatchDetails.details.failed
);
matchDetails.failed = matchDetails.failed.concat(
studyMatchDetails.details.failed
);
const totalMatchScore =
seriesMatchDetails.score + studyMatchDetails.score;
const imageDetails = {
StudyInstanceUID: study.StudyInstanceUID,
SeriesInstanceUID: aSeries.SeriesInstanceUID,
matchingScore: totalMatchScore,
matchDetails: matchDetails,
sortingInfo: {
score: totalMatchScore,
study: study.StudyInstanceUID,
series: parseInt(aSeries.SeriesNumber),
},
};
matchingScores.push(imageDetails);
});
});
// Sort the matchingScores
const sortingFunction = sortBy(
{
name: 'score',
reverse: true,
},
{
name: 'study',
reverse: true,
},
{
name: 'series',
}
);
matchingScores.sort((a, b) =>
sortingFunction(a.sortingInfo, b.sortingInfo)
);
const bestMatch = matchingScores[0];
console.log('ProtocolEngine::matchImages bestMatch', bestMatch);
return {
bestMatch,
matchingScores,
};
}
/**
* Check if the next stage is available
* @return {Boolean} True if next stage is available or false otherwise
*/
_isNextStageAvailable() {
const numberOfStages = this._getNumProtocolStages();
return this.stage + 1 < numberOfStages;
}
/**
* Check if the previous stage is available
* @return {Boolean} True if previous stage is available or false otherwise
*/
_isPreviousStageAvailable() {
return this.stage - 1 >= 0;
}
/**
* Changes the current stage to a new stage index in the display set sequence.
* It checks if the next stage exists.
*
* @param {Integer} stageAction An integer value specifying wheater next (1) or previous (-1) stage
* @return {Boolean} True if new stage has set or false, otherwise
*/
_setCurrentProtocolStage(stageAction) {
//reseting the applied protocols
this.hpAlreadyApplied = [];
// Check if previous or next stage is available
if (stageAction === -1 && !this._isPreviousStageAvailable()) {
return false;
} else if (stageAction === 1 && !this._isNextStageAvailable()) {
return false;
}
// Sets the new stage
this.stage += stageAction;
// Log the new stage
console.log(
`ProtocolEngine::setCurrentProtocolStage stage = ${this.stage}`
);
// Since stage has changed, we need to update the viewports
// and redo matchings
this._updateViewports();
// Everything went well
this._broadcastChange(this.EVENTS.STAGE_CHANGE, {
matchDetails: this.matchDetails,
hpAlreadyApplied: this.hpAlreadyApplied,
});
return true;
}
/**
* Broadcasts hanging protocols changes.
*
* @param {string} eventName The event name.add
* @param {object} eventData.source The measurement source.
* @param {object} eventData.measurement The measurement.
* @param {boolean} eventData.notYetUpdatedAtSource True if the measurement was edited
* within the measurement service and the source needs to update.
* @return void
*/
_broadcastChange(eventName, eventData) {
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(eventData);
});
}
}
}
export default {
name,
create: ({ configuration = {} }) => {
return publicAPI;
},
};
export default HangingProtocolService;
export { EVENTS };
@@ -0,0 +1,789 @@
import { HPMatcher } from './HPMatcher.js';
import { sortByScore } from './lib/sortByScore';
const deafultProtocol = {
id: 'defaultProtocol',
locked: true,
hasUpdatedPriorsInformation: false,
name: 'Default',
createdDate: '2021-02-23T19:22:08.894Z',
modifiedDate: '2021-02-23T19:22:08.894Z',
availableTo: {},
editableBy: {},
protocolMatchingRules: [],
stages: [
{
id: 'nwzau7jDkEkL8djfr',
name: 'oneByOne',
viewportStructure: {
type: 'grid',
properties: {
rows: 1,
columns: 1,
},
},
viewports: [
{
viewportSettings: [],
imageMatchingRules: [],
seriesMatchingRules: [],
studyMatchingRules: [],
},
],
createdDate: '2021-02-23T19:22:08.894Z',
},
],
numberOfPriorsReferenced: -1,
};
export default class ProtocolEngine {
constructor(protocols, customAttributeRetrievalCallbacks) {
this.protocols = protocols;
this.customAttributeRetrievalCallbacks = customAttributeRetrievalCallbacks;
this.matchedProtocols = new Map();
this.matchedProtocolScores = {};
this.study = undefined;
}
run(studyMetaData) {
this.study = studyMetaData;
return this.getBestProtocolMatch();
}
// /**
// * Resets the ProtocolEngine to the best match
// */
// reset() {
// const protocol = this.getBestProtocolMatch();
// this.setHangingProtocol(protocol);
// }
/**
* Return the best matched Protocol to the current study or set of studies
* @returns {*}
*/
getBestProtocolMatch() {
// Run the matching to populate matchedProtocols Set and Map
this.updateProtocolMatches();
// Retrieve the highest scoring Protocol
const bestMatch = this._getHighestScoringProtocol();
console.log('ProtocolEngine::getBestProtocolMatch bestMatch', bestMatch);
return bestMatch;
}
/**
* Populates the MatchedProtocols Collection by running the matching procedure
*/
updateProtocolMatches() {
console.log('ProtocolEngine::updateProtocolMatches');
// Clear all data currently in matchedProtocols
this._clearMatchedProtocols();
// TODO: handle more than one study
const study = this.study;
const matched = this.findMatchByStudy(study);
// For each matched protocol, check if it is already in MatchedProtocols
matched.forEach(matchedDetail => {
const protocol = matchedDetail.protocol;
if (!protocol) {
return;
}
// If it is not already in the MatchedProtocols Collection, insert it with its score
if (!this.matchedProtocols.has(protocol.id)) {
console.log(
'ProtocolEngine::updateProtocolMatches inserting protocol match',
matchedDetail
);
this.matchedProtocols.set(protocol.id, protocol);
this.matchedProtocolScores[protocol.id] = matchedDetail.score;
}
});
}
findMatch(metaData, rules) {
return HPMatcher.match(
metaData,
rules,
this.customAttributeRetrievalCallbacks
);
}
/**
* Finds the best protocols from Protocol Store, matching each protocol matching rules
* with the given study. The best protocol are orded by score and returned in an array
* @param {Object} study StudyMetadata instance object
* @return {Array} Array of match objects or an empty array if no match was found
* Each match object has the score of the matching and the matched
* protocol
*/
findMatchByStudy(study) {
const matched = [];
this.protocols.forEach(protocol => {
// Clone the protocol's protocolMatchingRules array
// We clone it so that we don't accidentally add the
// numberOfPriorsReferenced rule to the Protocol itself.
let rules = protocol.protocolMatchingRules.slice();
if (!rules) {
return;
}
// Run the matcher and get matching details
const matchedDetails = this.findMatch(study, rules);
const score = matchedDetails.score;
// The protocol matched some rule, add it to the matched list
if (score > 0) {
matched.push({
score,
protocol,
});
}
});
// If no matches were found, select the default protocol
if (!matched.length) {
return [
{
score: 1,
protocol: deafultProtocol,
},
];
}
// Sort the matched list by score
sortByScore(matched);
console.log('ProtocolEngine::findMatchByStudy matched', matched);
return matched;
}
_clearMatchedProtocols() {
this.matchedProtocols.clear();
this.matchedProtocolScores = {};
}
_largestKeyByValue(obj) {
return Object.keys(obj).reduce((a, b) => (obj[a] > obj[b] ? a : b));
}
_getHighestScoringProtocol() {
if (!Object.keys(this.matchedProtocolScores).length) {
return;
}
const highestScoringProtocolId = this._largestKeyByValue(
this.matchedProtocolScores
);
return this.matchedProtocols.get(highestScoringProtocolId);
}
}
/**
* Resets the ProtocolEngine to the best match
*/
/**
* Retrieves the current Stage from the current Protocol and stage index
*
* @returns {*} The Stage model for the currently displayed Stage
*/
// getCurrentStageModel() {
// return this.protocol.stages[this.stage];
// }
// /**
// * Get the number of prior studies supplied in the priorStudies map property.
// *
// * @param {String} studyObjectID The study object ID of the study whose priors are needed
// * @returns {number} The number of available prior studies with the same PatientID
// */
// getNumberOfAvailablePriors(studyObjectID) {
// return this.getAvailableStudyPriors(studyObjectID).length;
// }
// /**
// * Get the array of prior studies from a specific study.
// *
// * @param {String} studyObjectID The study object ID of the study whose priors are needed
// * @returns {Array} The array of available priors or an empty array
// */
// getAvailableStudyPriors(studyObjectID) {
// const priors = this.priorStudies.get(studyObjectID);
// return priors instanceof Array ? priors : [];
// }
// // Match images given a list of Studies and a Viewport's image matching reqs
// matchImages(viewport, viewportIndex) {
// log.trace('ProtocolEngine::matchImages');
// const {
// studyMatchingRules,
// seriesMatchingRules,
// imageMatchingRules: instanceMatchingRules,
// } = viewport;
// const matchingScores = [];
// const currentStudy = this.studies[0]; // @TODO: Should this be: this.studies[this.currentStudy] ???
// const firstInstance = currentStudy.getFirstInstance();
// let highestStudyMatchingScore = 0;
// let highestSeriesMatchingScore = 0;
// // Set custom attribute for study metadata and it's first instance
// currentStudy.setCustomAttribute(ABSTRACT_PRIOR_VALUE, 0);
// if (firstInstance instanceof InstanceMetadata) {
// firstInstance.setCustomAttribute(ABSTRACT_PRIOR_VALUE, 0);
// }
// // Only used if study matching rules has abstract prior values defined...
// let priorStudies;
// studyMatchingRules.forEach(rule => {
// if (rule.attribute === ABSTRACT_PRIOR_VALUE) {
// const validatorType = Object.keys(rule.constraint)[0];
// const validator = Object.keys(rule.constraint[validatorType])[0];
// let abstractPriorValue = rule.constraint[validatorType][validator];
// abstractPriorValue = parseInt(abstractPriorValue, 10);
// // TODO: Restrict or clarify validators for abstractPriorValue?
// // No need to call it more than once...
// if (!priorStudies) {
// priorStudies = this.getAvailableStudyPriors(
// currentStudy.getObjectID()
// );
// }
// // TODO: Revisit this later: What about two studies with the same
// // study date?
// let priorStudy;
// if (abstractPriorValue === -1) {
// priorStudy = priorStudies[priorStudies.length - 1];
// } else {
// const studyIndex = Math.max(abstractPriorValue - 1, 0);
// priorStudy = priorStudies[studyIndex];
// }
// // Invalid data
// if (!priorStudy instanceof StudyMetadata) {
// return;
// }
// const priorStudyObjectID = priorStudy.getObjectID();
// // Check if study metadata is already in studies list
// if (
// this.studies.find(study => study.getObjectID() === priorStudyObjectID)
// ) {
// return;
// }
// // Get study metadata if necessary and load study in the viewer (each viewer should provide it's own load study method)
// this.studyMetadataSource.loadStudy(priorStudy).then(
// studyMetadata => {
// // Set the custom attribute abstractPriorValue for the study metadata
// studyMetadata.setCustomAttribute(
// ABSTRACT_PRIOR_VALUE,
// abstractPriorValue
// );
// // Also add custom attribute
// const firstInstance = studyMetadata.getFirstInstance();
// if (firstInstance instanceof InstanceMetadata) {
// firstInstance.setCustomAttribute(
// ABSTRACT_PRIOR_VALUE,
// abstractPriorValue
// );
// }
// // Insert the new study metadata
// this.studies.push(studyMetadata);
// // Update the viewport to refresh layout manager with new study
// this.updateViewports(viewportIndex);
// },
// error => {
// log.warn(error);
// throw new OHIFError(
// `ProtocolEngine::matchImages could not get study metadata for the Study with the following ObjectID: ${priorStudyObjectID}`
// );
// }
// );
// }
// // TODO: Add relative Date / time
// });
// this.studies.forEach(study => {
// const studyMatchDetails = HPMatcher.match(
// study.getFirstInstance(),
// studyMatchingRules
// );
// // Prevent bestMatch from being updated if the matchDetails' required attribute check has failed
// if (
// studyMatchDetails.requiredFailed === true ||
// studyMatchDetails.score < highestStudyMatchingScore
// ) {
// return;
// }
// highestStudyMatchingScore = studyMatchDetails.score;
// study.forEachSeries(series => {
// const seriesMatchDetails = HPMatcher.match(
// series.getFirstInstance(),
// seriesMatchingRules
// );
// // Prevent bestMatch from being updated if the matchDetails' required attribute check has failed
// if (
// seriesMatchDetails.requiredFailed === true ||
// seriesMatchDetails.score < highestSeriesMatchingScore
// ) {
// return;
// }
// highestSeriesMatchingScore = seriesMatchDetails.score;
// series.forEachInstance((instance, index) => {
// // This tests to make sure there is actually image data in this instance
// // TODO: Change this when we add PDF and MPEG support
// // See https://ohiforg.atlassian.net/browse/LT-227
// if (
// !isImage(instance.getTagValue('SOPClassUID')) &&
// !instance.getTagValue('Rows')
// ) {
// return;
// }
// const instanceMatchDetails = HPMatcher.match(
// instance,
// instanceMatchingRules
// );
// // Prevent bestMatch from being updated if the matchDetails' required attribute check has failed
// if (instanceMatchDetails.requiredFailed === true) {
// return;
// }
// const matchDetails = {
// passed: [],
// failed: [],
// };
// matchDetails.passed = matchDetails.passed.concat(
// instanceMatchDetails.details.passed
// );
// matchDetails.passed = matchDetails.passed.concat(
// seriesMatchDetails.details.passed
// );
// matchDetails.passed = matchDetails.passed.concat(
// studyMatchDetails.details.passed
// );
// matchDetails.failed = matchDetails.failed.concat(
// instanceMatchDetails.details.failed
// );
// matchDetails.failed = matchDetails.failed.concat(
// seriesMatchDetails.details.failed
// );
// matchDetails.failed = matchDetails.failed.concat(
// studyMatchDetails.details.failed
// );
// const totalMatchScore =
// instanceMatchDetails.score +
// seriesMatchDetails.score +
// studyMatchDetails.score;
// const currentSOPInstanceUID = instance.getSOPInstanceUID();
// const imageDetails = {
// StudyInstanceUID: study.getStudyInstanceUID(),
// SeriesInstanceUID: series.getSeriesInstanceUID(),
// SOPInstanceUID: currentSOPInstanceUID,
// currentImageIdIndex: index,
// matchingScore: totalMatchScore,
// matchDetails: matchDetails,
// sortingInfo: {
// score: totalMatchScore,
// study:
// instance.getTagValue('StudyDate') +
// instance.getTagValue('StudyTime'),
// series: parseInt(instance.getTagValue('SeriesNumber')), // TODO: change for seriesDateTime
// instance: parseInt(instance.getTagValue('InstanceNumber')), // TODO: change for acquisitionTime
// },
// };
// // Find the displaySet
// const displaySet = study.findDisplaySet(displaySet =>
// displaySet.images.find(
// image => image.getSOPInstanceUID() === currentSOPInstanceUID
// )
// );
// // If the instance was found, set the displaySet ID
// if (displaySet) {
// imageDetails.displaySetInstanceUID = displaySet.getUID();
// imageDetails.imageId = instance.getImageId();
// }
// matchingScores.push(imageDetails);
// });
// });
// });
// // Sort the matchingScores
// const sortingFunction = sortBy(
// {
// name: 'score',
// reverse: true,
// },
// {
// name: 'study',
// reverse: true,
// },
// {
// name: 'instance',
// },
// {
// name: 'series',
// }
// );
// matchingScores.sort((a, b) =>
// sortingFunction(a.sortingInfo, b.sortingInfo)
// );
// const bestMatch = matchingScores[0];
// log.trace('ProtocolEngine::matchImages bestMatch', bestMatch);
// return {
// bestMatch,
// matchingScores,
// };
// }
/**
* Sets the current layout
*
* @param {number} numRows
* @param {number} numColumns
*/
// setLayout(numRows, numColumns) {
// if (numRows < 1 && numColumns < 1) {
// log.error(`Invalid layout ${numRows} x ${numColumns}`);
// return;
// }
// if (typeof this.options.setLayout !== 'function') {
// log.error('Hanging Protocol Engine setLayout callback is not defined');
// return;
// }
// let viewports = [];
// const numViewports = numRows * numColumns;
// for (let i = 0; i < numViewports; i++) {
// viewports.push({});
// }
// this.options.setLayout({ numRows, numColumns, viewports });
// }
// /**
// * Rerenders viewports that are part of the current layout manager
// * using the matching rules internal to each viewport.
// *
// * If this function is provided the index of a viewport, only the specified viewport
// * is rerendered.
// *
// * @param viewportIndex
// */
// updateViewports(viewportIndex) {
// log.trace(
// `ProtocolEngine::updateViewports viewportIndex: ${viewportIndex}`
// );
// // Make sure we have an active protocol with a non-empty array of display sets
// if (!this.getNumProtocolStages()) {
// return;
// }
// // Retrieve the current stage
// const stageModel = this.getCurrentStageModel();
// // If the current stage does not fulfill the requirements to be displayed,
// // stop here.
// if (
// !stageModel ||
// !stageModel.viewportStructure ||
// !stageModel.viewports ||
// !stageModel.viewports.length
// ) {
// return;
// }
// // Retrieve the layoutTemplate associated with the current display set's viewport structure
// // If no such template name exists, stop here.
// const layoutTemplateName = stageModel.viewportStructure.getLayoutTemplateName();
// if (!layoutTemplateName) {
// return;
// }
// // Retrieve the properties associated with the current display set's viewport structure template
// // If no such layout properties exist, stop here.
// const layoutProps = stageModel.viewportStructure.properties;
// if (!layoutProps) {
// return;
// }
// // Create an empty array to store the output viewportData
// const viewportData = [];
// // Empty the matchDetails associated with the ProtocolEngine.
// // This will be used to store the pass/fail details and score
// // for each of the viewport matching procedures
// this.matchDetails = [];
// // Loop through each viewport
// stageModel.viewports.forEach((viewport, viewportIndex) => {
// const details = this.matchImages(viewport, viewportIndex);
// this.matchDetails[viewportIndex] = details;
// // Convert any YES/NO values into true/false for Cornerstone
// const cornerstoneViewportParams = {};
// // Cache viewportSettings keys
// const viewportSettingsKeys = Object.keys(viewport.viewportSettings);
// viewportSettingsKeys.forEach(key => {
// let value = viewport.viewportSettings[key];
// if (value === 'YES') {
// value = true;
// } else if (value === 'NO') {
// value = false;
// }
// cornerstoneViewportParams[key] = value;
// });
// // imageViewerViewports occasionally needs relevant layout data in order to set
// // the element style of the viewport in question
// const currentViewportData = {
// viewportIndex,
// viewport: cornerstoneViewportParams,
// ...layoutProps,
// };
// const customSettings = [];
// viewportSettingsKeys.forEach(id => {
// const setting = CustomViewportSettings[id];
// if (!setting) {
// return;
// }
// customSettings.push({
// id: id,
// value: viewport.viewportSettings[id],
// });
// });
// currentViewportData.renderedCallback = element => {
// //console.log('renderedCallback for ' + element.id);
// customSettings.forEach(customSetting => {
// log.trace(
// `ProtocolEngine::currentViewportData.renderedCallback Applying custom setting: ${customSetting.id}`
// );
// log.trace(
// `ProtocolEngine::currentViewportData.renderedCallback with value: ${customSetting.value}`
// );
// const setting = CustomViewportSettings[customSetting.id];
// setting.callback(element, customSetting.value);
// });
// };
// let currentMatch = details.bestMatch;
// let currentPosition = 1;
// const scoresLength = details.matchingScores.length;
// while (
// currentPosition < scoresLength &&
// viewportData.find(a => a.imageId === currentMatch.imageId)
// ) {
// currentMatch = details.matchingScores[currentPosition];
// currentPosition++;
// }
// if (currentMatch && currentMatch.imageId) {
// currentViewportData.StudyInstanceUID = currentMatch.StudyInstanceUID;
// currentViewportData.SeriesInstanceUID = currentMatch.SeriesInstanceUID;
// currentViewportData.SOPInstanceUID = currentMatch.SOPInstanceUID;
// currentViewportData.currentImageIdIndex =
// currentMatch.currentImageIdIndex;
// currentViewportData.displaySetInstanceUID =
// currentMatch.displaySetInstanceUID;
// currentViewportData.imageId = currentMatch.imageId;
// }
// // @TODO Why should we throw an exception when a best match is not found? This was aborting the whole process.
// // if (!currentViewportData.displaySetInstanceUID) {
// // throw new OHIFError('ProtocolEngine::updateViewports No matching display set found?');
// // }
// viewportData.push(currentViewportData);
// });
// this.setLayout(layoutProps.Rows, layoutProps.Columns);
// if (typeof this.options.setViewportSpecificData !== 'function') {
// log.error(
// 'Hanging Protocol Engine setViewportSpecificData callback is not defined'
// );
// return;
// }
// // If viewportIndex is defined, then update only that viewport
// if (viewportIndex !== undefined && viewportData[viewportIndex]) {
// this.options.setViewportSpecificData(
// viewportIndex,
// viewportData[viewportIndex]
// );
// return;
// }
// // Update all viewports
// viewportData.forEach(viewportSpecificData => {
// this.options.setViewportSpecificData(
// viewportSpecificData.viewportIndex,
// viewportSpecificData
// );
// });
// }
// /**
// * Sets the current Hanging Protocol to the specified Protocol
// * An optional argument can also be used to prevent the updating of the Viewports
// *
// * @param newProtocol
// * @param updateViewports
// */
// setHangingProtocol(newProtocol, updateViewports = true) {
// log.trace('ProtocolEngine::setHangingProtocol newProtocol', newProtocol);
// log.trace(
// `ProtocolEngine::setHangingProtocol updateViewports = ${updateViewports}`
// );
// // Reset the array of newStageIds
// this.newStageIds = [];
// if (Protocol.prototype.isPrototypeOf(newProtocol)) {
// this.protocol = newProtocol;
// } else {
// this.protocol = new Protocol();
// this.protocol.fromObject(newProtocol);
// }
// this.stage = 0;
// // Update viewports by default
// if (updateViewports) {
// this.updateViewports();
// }
// }
// /**
// * Check if the next stage is available
// * @return {Boolean} True if next stage is available or false otherwise
// */
// isNextStageAvailable() {
// const numberOfStages = this.getNumProtocolStages();
// return this.stage + 1 < numberOfStages;
// }
// /**
// * Check if the previous stage is available
// * @return {Boolean} True if previous stage is available or false otherwise
// */
// isPreviousStageAvailable() {
// return this.stage - 1 >= 0;
// }
// /**
// * Changes the current stage to a new stage index in the display set sequence.
// * It checks if the next stage exists.
// *
// * @param {Integer} stageAction An integer value specifying wheater next (1) or previous (-1) stage
// * @return {Boolean} True if new stage has set or false, otherwise
// */
// setCurrentProtocolStage(stageAction) {
// // Check if previous or next stage is available
// if (stageAction === -1 && !this.isPreviousStageAvailable()) {
// return false;
// } else if (stageAction === 1 && !this.isNextStageAvailable()) {
// return false;
// }
// // Sets the new stage
// this.stage += stageAction;
// // Log the new stage
// log.trace(`ProtocolEngine::setCurrentProtocolStage stage = ${this.stage}`);
// // Since stage has changed, we need to update the viewports
// // and redo matchings
// this.updateViewports();
// // Everything went well
// return true;
// }
// /**
// * Retrieves the number of Stages in the current Protocol or
// * undefined if no protocol or stages are set
// */
// getNumProtocolStages() {
// if (
// !this.protocol ||
// !this.protocol.stages ||
// !this.protocol.stages.length
// ) {
// return;
// }
// return this.protocol.stages.length;
// }
// /**
// * Switches to the next protocol stage in the display set sequence
// */
// nextProtocolStage() {
// log.trace('ProtocolEngine::nextProtocolStage');
// if (!this.setCurrentProtocolStage(1)) {
// log.trace('ProtocolEngine::nextProtocolStage failed');
// }
// }
// /**
// * Switches to the previous protocol stage in the display set sequence
// */
// previousProtocolStage() {
// log.trace('ProtocolEngine::previousProtocolStage');
// if (!this.setCurrentProtocolStage(-1)) {
// log.trace('ProtocolEngine::previousProtocolStage failed');
// }
// }
// }
@@ -1,3 +1,8 @@
import HangingProtocolService from './HangingProtocolService';
export default HangingProtocolService;
export default {
name: 'HangingProtocolService',
create: ({ configuration = {}, commandsManager }) => {
return new HangingProtocolService(commandsManager);
},
};
@@ -0,0 +1,98 @@
const comparators = [
{
id: 'equals',
name: '= (Equals)',
validator: 'equals',
validatorOption: 'value',
description: 'The attribute must equal this value.',
},
{
id: 'doesNotEqual',
name: '!= (Does not equal)',
validator: 'doesNotEqual',
validatorOption: 'value',
description: 'The attribute must not equal this value.',
},
{
id: 'contains',
name: 'Contains',
validator: 'contains',
validatorOption: 'value',
description: 'The attribute must contain this value.',
},
{
id: 'doesNotContain',
name: 'Does not contain',
validator: 'doesNotContain',
validatorOption: 'value',
description: 'The attribute must not contain this value.',
},
{
id: 'startsWith',
name: 'Starts with',
validator: 'startsWith',
validatorOption: 'value',
description: 'The attribute must start with this value.',
},
{
id: 'endsWith',
name: 'Ends with',
validator: 'endsWith',
validatorOption: 'value',
description: 'The attribute must end with this value.',
},
{
id: 'onlyInteger',
name: 'Only Integers',
validator: 'numericality',
validatorOption: 'onlyInteger',
description: "Real numbers won't be allowed.",
},
{
id: 'greaterThan',
name: '> (Greater than)',
validator: 'numericality',
validatorOption: 'greaterThan',
description: 'The attribute has to be greater than this value.',
},
{
id: 'greaterThanOrEqualTo',
name: '>= (Greater than or equal to)',
validator: 'numericality',
validatorOption: 'greaterThanOrEqualTo',
description: 'The attribute has to be at least this value.',
},
{
id: 'lessThanOrEqualTo',
name: '<= (Less than or equal to)',
validator: 'numericality',
validatorOption: 'lessThanOrEqualTo',
description: 'The attribute can be this value at the most.',
},
{
id: 'lessThan',
name: '< (Less than)',
validator: 'numericality',
validatorOption: 'lessThan',
description: 'The attribute has to be less than this value.',
},
{
id: 'odd',
name: 'Odd',
validator: 'numericality',
validatorOption: 'odd',
description: 'The attribute has to be odd.',
},
{
id: 'even',
name: 'Even',
validator: 'numericality',
validatorOption: 'even',
description: 'The attribute has to be even.',
},
];
// Immutable object
Object.freeze(comparators);
export { comparators };
@@ -0,0 +1,71 @@
const attributeCache = Object.create(null);
const REGEXP = /^\([x0-9a-f]+\)/;
const humanize = text => {
let humanized = text.replace(/([A-Z])/g, ' $1'); // insert a space before all caps
humanized = humanized.replace(/^./, str => {
// uppercase the first character
return str.toUpperCase();
});
return humanized;
};
/**
* Get the text of an attribute for a given attribute
* @param {String} attributeId The attribute ID
* @param {Array} attributes Array of attributes objects with id and text properties
* @return {String} If found return the attribute text or an empty string otherwise
*/
const getAttributeText = (attributeId, attributes) => {
// If the attribute is already in the cache, return it
if (attributeId in attributeCache) {
return attributeCache[attributeId];
}
// Find the attribute with given attributeId
const attribute = attributes.find(attribute => attribute.id === attributeId);
let attributeText;
// If attribute was found get its text and save it on the cache
if (attribute) {
attributeText = attribute.text.replace(REGEXP, '');
attributeCache[attributeId] = attributeText;
}
return attributeText || '';
};
function displayConstraint(attributeId, constraint, attributes) {
if (!constraint || !attributeId) {
return;
}
const validatorType = Object.keys(constraint)[0];
if (!validatorType) {
return;
}
const validator = Object.keys(constraint[validatorType])[0];
if (!validator) {
return;
}
const value = constraint[validatorType][validator];
if (value === void 0) {
return;
}
let comparator = validator;
if (validator === 'value') {
comparator = validatorType;
}
const attributeText = getAttributeText(attributeId, attributes);
const constraintText =
attributeText + ' ' + humanize(comparator).toLowerCase() + ' ' + value;
return constraintText;
}
@@ -0,0 +1,32 @@
/**
* Removes the first instance of an element from an array, if an equal value exists
*
* @param array
* @param input
*
* @returns {boolean} Whether or not the element was found and removed
*/
const removeFromArray = (array, input) => {
// If the array is empty, stop here
if (!array || !array.length) {
return false;
}
array.forEach((value, index) => {
// TODO: Double check whether or not this deep equality check is necessary
//if (_.isEqual(value, input)) {
if (value === input) {
indexToRemove = index;
return false;
}
});
if (indexToRemove === void 0) {
return false;
}
array.splice(indexToRemove, 1);
return true;
};
export { removeFromArray };
@@ -0,0 +1,8 @@
// Sorts an array by score
const sortByScore = arr => {
arr.sort((a, b) => {
return b.score - a.score;
});
};
export { sortByScore };
@@ -0,0 +1,39 @@
import validate from 'validate.js';
validate.validators.equals = function (value, options, key, attributes) {
if (options && value !== options.value) {
return key + 'must equal ' + options.value;
}
};
validate.validators.doesNotEqual = function (value, options, key) {
if (options && value === options.value) {
return key + 'cannot equal ' + options.value;
}
};
validate.validators.contains = function (value, options, key) {
if (options && value.indexOf && value.indexOf(options.value) === -1) {
return key + 'must contain ' + options.value;
}
};
validate.validators.doesNotContain = function (value, options, key) {
if (options && value.indexOf && value.indexOf(options.value) !== -1) {
return key + 'cannot contain ' + options.value;
}
};
validate.validators.startsWith = function (value, options, key) {
if (options && value.startsWith && !value.startsWith(options.value)) {
return key + 'must start with ' + options.value;
}
};
validate.validators.endsWith = function (value, options, key) {
if (options && value.endsWith && !value.endsWith(options.value)) {
return key + 'must end with ' + options.value;
}
};
export default validate;
@@ -54,6 +54,7 @@ const EVENTS = {
MEASUREMENT_UPDATED: 'event::measurement_updated',
INTERNAL_MEASUREMENT_UPDATED: 'event:internal_measurement_updated',
MEASUREMENT_ADDED: 'event::measurement_added',
RAW_MEASUREMENT_ADDED: 'event::raw_measurement_added',
MEASUREMENT_REMOVED: 'event::measurement_removed',
MEASUREMENTS_CLEARED: 'event::measurements_cleared',
JUMP_TO_MEASUREMENT: 'event:jump_to_measurement',
@@ -314,11 +315,17 @@ class MeasurementService {
* Converted to/from annotation in the same way. E.g. import serialized data
* Of the same form as the measurement source.
* @param {MeasurementSource} source The measurement source instance.
* @param {string} definition The source definition you want to add the measuremnet to.
* @param {string} definition The source definition you want to add the measurement to.
* @param {object} data The data you wish to add to the source.
* @param {function} toMeasurementSchema A function to get the `data` into the same shape as the source definition.
*/
addRawMeasurement(source, definition, data, toMeasurementSchema) {
addRawMeasurement(
source,
definition,
data,
toMeasurementSchema,
dataSource = {}
) {
if (!this._isValidSource(source)) {
log.warn('Invalid source. Exiting early.');
return;
@@ -342,7 +349,6 @@ class MeasurementService {
try {
/* Convert measurement */
measurement = toMeasurementSchema(data);
/* Assign measurement source instance */
measurement.source = source;
} catch (error) {
@@ -385,9 +391,11 @@ class MeasurementService {
} else {
log.info(`Measurement added.`, newMeasurement);
this.measurements[internalId] = newMeasurement;
this._broadcastEvent(this.EVENTS.MEASUREMENT_ADDED, {
this._broadcastEvent(this.EVENTS.RAW_MEASUREMENT_ADDED, {
source,
measurement: newMeasurement,
data,
dataSource,
});
}
@@ -501,6 +509,7 @@ class MeasurementService {
clearMeasurements() {
this.measurements = {};
this._jumpToMeasurementCache = {};
this._broadcastEvent(this.EVENTS.MEASUREMENTS_CLEARED);
}
@@ -13,6 +13,7 @@ export default class ToolBarService {
this.EVENTS = EVENTS;
this.listeners = {};
this.buttons = {};
this.unsubscriptions = []; // if tools need to unsubscribe from events
this.buttonSections = {
/**
* primary: ['Zoom', 'Wwwc'],
@@ -39,6 +40,16 @@ export default class ToolBarService {
this.extensionManager = extensionManager;
}
reset() {
this.unsubscriptions.forEach(unsub => unsub());
this.state = {
primaryToolId: 'Wwwc',
toggles: {},
groups: {},
};
this.unsubscriptions = [];
}
/**
*
* @param {*} interaction
@@ -63,6 +74,7 @@ export default class ToolBarService {
this.state.toggles[itemId] === undefined
? true
: !this.state.toggles[itemId];
interaction.commandOptions.toggledState = this.state.toggles[itemId];
break;
}
default:
@@ -73,13 +85,21 @@ export default class ToolBarService {
//
// NOTE: Should probably just do this for tools as well?
// But would be nice if we could enforce at least the command name?
let unsubscribe;
if (interaction.commandName) {
commandsManager.runCommand(
unsubscribe = commandsManager.runCommand(
interaction.commandName,
interaction.commandOptions
);
}
// Storing the unsubscribe for later reseting
if (unsubscribe && typeof unsubscribe === 'function') {
if (this.unsubscriptions.indexOf(unsubscribe) === -1) {
this.unsubscriptions.push(unsubscribe);
}
}
// Track last touched id for each group
if (groupId) {
this.state.groups[groupId] = itemId;
+3 -1
View File
@@ -10,6 +10,7 @@ import ToolBarService from './ToolBarService';
import ViewportGridService from './ViewportGridService';
import CineService from './CineService';
import HangingProtocolService from './HangingProtocolService';
import pubSubServiceInterface from './_shared/pubSubServiceInterface';
export {
MeasurementService,
@@ -23,5 +24,6 @@ export {
ToolBarService,
ViewportGridService,
HangingProtocolService,
CineService
CineService,
pubSubServiceInterface,
};
+2
View File
@@ -22,6 +22,7 @@ function createAndAddStack(
StudyInstanceUID,
frameRate,
isClip,
initialImageIdIndex,
} = displaySet;
if (!images) {
return;
@@ -35,6 +36,7 @@ function createAndAddStack(
imageIds,
frameRate,
isClip,
initialImageIdIndex,
};
stackMap[displaySetInstanceUID] = stack;
+9 -8
View File
@@ -2,15 +2,16 @@
* Sorting function
* Sorts an array by seriesDate and seriesNumber if equal
*/
const sortBySeriesDate = array => array.sort((a, b) => {
if (a.seriesNumber !== b.seriesNumber) {
return a.seriesNumber - b.seriesNumber;
}
const sortBySeriesDate = array =>
array.sort((a, b) => {
if (a.seriesNumber !== b.seriesNumber) {
return a.seriesNumber - b.seriesNumber;
}
const seriesDateA = Date.parse(a.seriesDate);
const seriesDateB = Date.parse(b.seriesDate);
const seriesDateA = Date.parse(a.seriesDate);
const seriesDateB = Date.parse(b.seriesDate);
return seriesDateA - seriesDateB;
});
return seriesDateA - seriesDateB;
});
export default sortBySeriesDate;