This commit is contained in:
mario
2025-03-07 13:47:44 +07:00
commit c4efec5a14
3358 changed files with 303774 additions and 0 deletions

View File

@@ -0,0 +1,52 @@
/**
* Returns the specified element as a dicom attribute group/element.
*
* @param element - The group/element of the element (e.g. '00280009')
* @param [defaultValue] - The value to return if the element is not present
* @returns {*}
*/
export default function getAttribute(element, defaultValue) {
if (!element) {
return defaultValue;
}
// Value is not present if the attribute has a zero length value
if (!element.Value) {
return defaultValue;
}
// Sanity check to make sure we have at least one entry in the array.
if (!element.Value.length) {
return defaultValue;
}
return convertToInt(element.Value);
}
function convertToInt(input) {
function padFour(input) {
const l = input.length;
if (l === 0) {
return '0000';
}
if (l === 1) {
return '000' + input;
}
if (l === 2) {
return '00' + input;
}
if (l === 3) {
return '0' + input;
}
return input;
}
let output = '';
for (let i = 0; i < input.length; i++) {
for (let j = 0; j < input[i].length; j++) {
output += padFour(input[i].charCodeAt(j).toString(16));
}
}
return parseInt(output, 16);
}

View File

@@ -0,0 +1,65 @@
import getAttribute from './getAttribute';
describe('getAttribute', () => {
it('should return a default value if element is null or undefined', () => {
const defaultValue = '0000';
const nullElement = null;
const undefinedElement = undefined;
expect(getAttribute(nullElement, defaultValue)).toEqual(defaultValue);
expect(getAttribute(undefinedElement, defaultValue)).toEqual(defaultValue);
});
it('should return a default value if element.Value is null, undefined or not present', () => {
const defaultValue = '0000';
const nullElement = {
id: 0,
Value: null,
};
const undefinedElement = {
id: 0,
Value: undefined,
};
const noValuePresentElement = {
id: 0,
};
expect(getAttribute(nullElement, defaultValue)).toEqual(defaultValue);
expect(getAttribute(undefinedElement, defaultValue)).toEqual(defaultValue);
expect(getAttribute(noValuePresentElement, defaultValue)).toEqual(defaultValue);
});
it('should return 48 for element with value 0', () => {
const returnValue = 48;
const element = {
Value: '0',
};
expect(getAttribute(element, null)).toEqual(returnValue);
});
it('should return 3211313 for element with value 11', () => {
const returnValue = 3211313;
const element = {
Value: '11',
};
expect(getAttribute(element, null)).toEqual(returnValue);
});
it('should return 2.4923405222191973e+35 for element with value 00280009', () => {
const returnValue = 2.4923405222191973e35;
const element = {
id: 0,
Value: '00280009',
};
expect(getAttribute(element, null)).toEqual(returnValue);
});
it('should return 2949169 for element with value -1', () => {
const returnValue = 2949169;
const element = {
id: 0,
Value: '-1',
};
expect(getAttribute(element, null)).toEqual(returnValue);
});
});

View File

@@ -0,0 +1,35 @@
import 'isomorphic-base64';
import user from '../user';
/**
* Returns the Authorization header as part of an Object.
*
* @export
* @param {Object} [server={}]
* @param {Object} [server.requestOptions]
* @param {string|function} [server.requestOptions.auth]
* @returns {Object} { Authorization }
*/
export default function getAuthorizationHeader({ requestOptions } = {}, user) {
const headers = {};
// Check for OHIF.user since this can also be run on the server
const accessToken = user && user.getAccessToken && user.getAccessToken();
// Auth for a specific server
if (requestOptions && requestOptions.auth) {
if (typeof requestOptions.auth === 'function') {
// Custom Auth Header
headers.Authorization = requestOptions.auth(requestOptions);
} else {
// HTTP Basic Auth (user:password)
headers.Authorization = `Basic ${btoa(requestOptions.auth)}`;
}
}
// Auth for the user's default
else if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`;
}
return headers;
}

View File

@@ -0,0 +1,72 @@
import getAuthorizationHeader from './getAuthorizationHeader';
import user from './../user';
jest.mock('./../user.js');
describe('getAuthorizationHeader', () => {
it('should return a HTTP Basic Auth when server contains requestOptions.auth', () => {
const validServer = {
requestOptions: {
auth: 'dummy_user:dummy_password',
},
};
const expectedAuthorizationHeader = {
Authorization: `Basic ${btoa(validServer.requestOptions.auth)}`,
};
const authentication = getAuthorizationHeader(validServer);
expect(authentication).toEqual(expectedAuthorizationHeader);
});
it('should return a HTTP Basic Auth when server contains requestOptions.auth even though there is no password', () => {
const validServerWithoutPassword = {
requestOptions: {
auth: 'dummy_user',
},
};
const expectedAuthorizationHeader = {
Authorization: `Basic ${btoa(validServerWithoutPassword.requestOptions.auth)}`,
};
const authentication = getAuthorizationHeader(validServerWithoutPassword);
expect(authentication).toEqual(expectedAuthorizationHeader);
});
it('should return a HTTP Basic Auth when server contains requestOptions.auth custom function', () => {
const validServerCustomAuth = {
requestOptions: {
auth: options => `Basic ${options.token}`,
token: 'ZHVtbXlfdXNlcjpkdW1teV9wYXNzd29yZA==',
},
};
const expectedAuthorizationHeader = {
Authorization: `Basic ${validServerCustomAuth.requestOptions.token}`,
};
const authentication = getAuthorizationHeader(validServerCustomAuth);
expect(authentication).toEqual(expectedAuthorizationHeader);
});
it('should return an empty object when there is no either server.requestOptions.auth or accessToken', () => {
const authentication = getAuthorizationHeader({});
expect(authentication).toEqual({});
});
it('should return an Authorization with accessToken when server is not defined and there is an accessToken', () => {
user.getAccessToken.mockImplementationOnce(() => 'MOCKED_TOKEN');
const authentication = getAuthorizationHeader({}, user);
const expectedHeaderBasedOnUserAccessToken = {
Authorization: 'Bearer MOCKED_TOKEN',
};
expect(authentication).toEqual(expectedHeaderBasedOnUserAccessToken);
});
});

View File

@@ -0,0 +1,29 @@
export default function getModalities(Modality, ModalitiesInStudy) {
if (!Modality && !ModalitiesInStudy) {
return {};
}
const modalities = Modality || {
vr: 'CS',
Value: [],
};
// Rare case, depending on the DICOM server we are using, but sometimes,
// modalities.Value is undefined or null.
modalities.Value = modalities.Value || [];
if (ModalitiesInStudy) {
if (modalities.vr && modalities.vr === ModalitiesInStudy.vr) {
for (let i = 0; i < ModalitiesInStudy.Value.length; i++) {
const value = ModalitiesInStudy.Value[i];
if (modalities.Value.indexOf(value) === -1) {
modalities.Value.push(value);
}
}
} else {
return ModalitiesInStudy;
}
}
return modalities;
}

View File

@@ -0,0 +1,70 @@
import getModalities from './getModalities';
describe('getModalities', () => {
test('should return an empty object when Modality and ModalitiesInStudy are not present', () => {
const Modality = null;
const ModalitiesInStudy = null;
expect(getModalities(Modality, ModalitiesInStudy)).toEqual({});
});
test('should return an empty object when Modality and ModalitiesInStudy are not present', () => {
const Modality = null;
const ModalitiesInStudy = null;
expect(getModalities(Modality, ModalitiesInStudy)).toEqual({});
});
test('should return modalities in Study when Modality is not defined', () => {
const Modality = null;
const ModalitiesInStudy = {
Value: ['MOCKED_VALUE'],
vr: 'MOCKED_VALUE',
};
expect(getModalities(Modality, ModalitiesInStudy)).toEqual(ModalitiesInStudy);
});
test('should return only the modalitues that exists in ModalitiesInStudy', () => {
const Modality = {
Value: ['DESIRED_VALUE'],
vr: 'DESIRED_VR',
};
const ModalitiesInStudy = {
Value: ['DESIRED_VALUE', 'NOT_DESIRED_VALUE'],
vr: 'DESIRED_VR',
};
expect(getModalities(Modality, ModalitiesInStudy)).toEqual(Modality);
});
test('should return the seek Modality when the desired Modality does not exist in ModalitiesInStudy', () => {
const Modality = {
Value: ['DESIRED_VALUE'],
vr: 'DESIRED_VR',
};
const ModalitiesInStudy = {
Value: ['NOT_DESIRED_VALUE'],
vr: 'DESIRED_VR',
};
expect(getModalities(Modality, ModalitiesInStudy)).toEqual(Modality);
});
test('should return the seek Modality when the desired Modality does not exist in ModalitiesInStudy VR', () => {
const Modality = {
Value: ['DESIRED_VALUE'],
vr: 'DESIRED_VR',
};
const ModalitiesInStudy = {
Value: ['NOT_DESIRED_VALUE'],
vr: 'ANOTHER_VR',
};
expect(getModalities(Modality, ModalitiesInStudy)).toEqual(ModalitiesInStudy);
});
});

View File

@@ -0,0 +1,26 @@
/**
* Returns the Alphabetic version of a PN
*
* @param element - The group/element of the element (e.g. '00200013')
* @param [defaultValue] - The default value to return if the element is not found
* @returns {*}
*/
export default function getName(element, defaultValue) {
if (!element) {
return defaultValue;
}
// Value is not present if the attribute has a zero length value
if (!element.Value) {
return defaultValue;
}
// Sanity check to make sure we have at least one entry in the array.
if (!element.Value.length) {
return defaultValue;
}
// Return the Alphabetic component group
if (element.Value[0].Alphabetic) {
return element.Value[0].Alphabetic;
}
// Orthanc does not return PN properly so this is a temporary workaround
return element.Value[0];
}

View File

@@ -0,0 +1,57 @@
import getName from './getName';
describe('getName', () => {
it('should return a default value if element is null or undefined', () => {
const defaultValue = 'DEFAULT_NAME';
const nullElement = null;
const undefinedElement = undefined;
expect(getName(nullElement, defaultValue)).toEqual(defaultValue);
expect(getName(undefinedElement, defaultValue)).toEqual(defaultValue);
});
it('should return a default value if element.Value is null, undefined or not present', () => {
const defaultValue = 'DEFAULT_NAME';
const nullElement = {
id: 0,
Value: null,
};
const undefinedElement = {
id: 0,
Value: undefined,
};
const noValuePresentElement = {
id: 0,
};
expect(getName(nullElement, defaultValue)).toEqual(defaultValue);
expect(getName(undefinedElement, defaultValue)).toEqual(defaultValue);
expect(getName(noValuePresentElement, defaultValue)).toEqual(defaultValue);
});
it('should return A for element when Alphabetic is [A, B, C, D]', () => {
const returnValue = 'A';
const element = {
Value: [{ Alphabetic: 'A' }, { Alphabetic: 'B' }, { Alphabetic: 'C' }, { Alphabetic: 'D' }],
};
expect(getName(element, null)).toEqual(returnValue);
});
it('should return FIRST for element when Alphabetic is [FIRST, SECOND]', () => {
const returnValue = 'FIRST';
const element = {
Value: [{ Alphabetic: 'FIRST' }, { Alphabetic: 'SECOND' }],
};
expect(getName(element, null)).toEqual(returnValue);
});
it('should return element.value[0] for element with not Alphabetic and when there is at least on element.Value', () => {
const returnValue = {
anyOtherProperty: 'FIRST',
};
const element = {
Value: [{ anyOtherProperty: 'FIRST' }, { Alphabetic: 'SECOND' }],
};
expect(getName(element, null)).toEqual(returnValue);
});
});

View File

@@ -0,0 +1,21 @@
/**
* Returns the first string value as a Javascript Number
* @param element - The group/element of the element (e.g. '00200013')
* @param [defaultValue] - The default value to return if the element does not exist
* @returns {*}
*/
export default function getNumber(element, defaultValue) {
if (!element) {
return defaultValue;
}
// Value is not present if the attribute has a zero length value
if (!element.Value) {
return defaultValue;
}
// Sanity check to make sure we have at least one entry in the array.
if (!element.Value.length) {
return defaultValue;
}
return parseFloat(element.Value[0]);
}

View File

@@ -0,0 +1,55 @@
import getNumber from './getNumber';
describe('getNumber', () => {
it('should return a default value if element is null or undefined', () => {
const defaultValue = 1.0;
const nullElement = null;
const undefinedElement = undefined;
expect(getNumber(nullElement, defaultValue)).toEqual(defaultValue);
expect(getNumber(undefinedElement, defaultValue)).toEqual(defaultValue);
});
it('should return a default value if element.Value is null, undefined or not present', () => {
const defaultValue = 1.0;
const nullElement = {
id: 0,
Value: null,
};
const undefinedElement = {
id: 0,
Value: undefined,
};
const noValuePresentElement = {
id: 0,
};
expect(getNumber(nullElement, defaultValue)).toEqual(defaultValue);
expect(getNumber(undefinedElement, defaultValue)).toEqual(defaultValue);
expect(getNumber(noValuePresentElement, defaultValue)).toEqual(defaultValue);
});
it('should return 2.0 for element when element.Value[0] = 2', () => {
const returnValue = 2.0;
const element = {
Value: ['2'],
};
expect(getNumber(element, null)).toEqual(returnValue);
});
it('should return -1.0 for element when element.Value[0] is -1', () => {
const returnValue = -1.0;
const element = {
Value: ['-1'],
};
expect(getNumber(element, null)).toEqual(returnValue);
});
it('should return -1.0 for element when element.Value is [-1, 2, 5, -10] ', () => {
const returnValue = -1.0;
const element = {
Value: ['-1', '2', '5', '-10'],
};
expect(getNumber(element, null)).toEqual(returnValue);
});
});

View File

@@ -0,0 +1,23 @@
/**
* Returns the specified element as a string. Multi-valued elements will be separated by a backslash
*
* @param element - The group/element of the element (e.g. '00200013')
* @param [defaultValue] - The value to return if the element is not present
* @returns {*}
*/
export default function getString(element, defaultValue) {
if (!element) {
return defaultValue;
}
// Value is not present if the attribute has a zero length value
if (!element.Value) {
return defaultValue;
}
// Sanity check to make sure we have at least one entry in the array.
if (!element.Value.length) {
return defaultValue;
}
// Join the array together separated by backslash
// NOTE: Orthanc does not correctly split values into an array so the join is a no-op
return element.Value.join('\\');
}

View File

@@ -0,0 +1,55 @@
import getString from './getString';
describe('getString', () => {
it('should return a default value if element is null or undefined', () => {
const defaultValue = ['A', 'B', 'C'].join('\\');
const nullElement = null;
const undefinedElement = undefined;
expect(getString(nullElement, defaultValue)).toEqual(defaultValue);
expect(getString(undefinedElement, defaultValue)).toEqual(defaultValue);
});
it('should return a default value if element.Value is null, undefined or not present', () => {
const defaultValue = ['A', 'B', 'C'].join('\\');
const nullElement = {
id: 0,
Value: null,
};
const undefinedElement = {
id: 0,
Value: undefined,
};
const noValuePresentElement = {
id: 0,
};
expect(getString(nullElement, defaultValue)).toEqual(defaultValue);
expect(getString(undefinedElement, defaultValue)).toEqual(defaultValue);
expect(getString(noValuePresentElement, defaultValue)).toEqual(defaultValue);
});
it('should return A,B,C,D for element when element.Value[0] = [A, B, C, D]', () => {
const returnValue = ['A', 'B', 'C'].join('\\');
const element = {
Value: ['A', 'B', 'C'],
};
expect(getString(element, null)).toEqual(returnValue);
});
it('should return 1,4,5,6 for element when element.Value[0] is [1, 4, 5, 6]', () => {
const returnValue = [1, 4, 5, 6].join('\\');
const element = {
Value: [1, 4, 5, 6],
};
expect(getString(element, null)).toEqual(returnValue);
});
it('should return A,1,3,R,7,-1 for element when element.Value is [-1, 2, 5, -10] ', () => {
const returnValue = ['A', '1', '3', 'R', '7', '-1'].join('\\');
const element = {
Value: ['A', '1', '3', 'R', '7', '-1'],
};
expect(getString(element, null)).toEqual(returnValue);
});
});

View File

@@ -0,0 +1,19 @@
import getAttribute from './getAttribute.js';
import getAuthorizationHeader from './getAuthorizationHeader.js';
import getModalities from './getModalities.js';
import getName from './getName.js';
import getNumber from './getNumber.js';
import getString from './getString.js';
const DICOMWeb = {
getAttribute,
getAuthorizationHeader,
getModalities,
getName,
getNumber,
getString,
};
export { getAttribute, getAuthorizationHeader, getModalities, getName, getNumber, getString };
export default DICOMWeb;

View File

@@ -0,0 +1,18 @@
import * as DICOMWeb from './index.js';
describe('Top level exports', () => {
test('should export the modules getAttribute, getAuthorizationHeader, getModalities, getName, getNumber, getString', () => {
const expectedExports = [
'getAttribute',
'getAuthorizationHeader',
'getModalities',
'getName',
'getNumber',
'getString',
].sort();
const exports = Object.keys(DICOMWeb.default).sort();
expect(exports).toEqual(expectedExports);
});
});

View File

@@ -0,0 +1,85 @@
import { DicomMetadataStore } from '../services/DicomMetadataStore';
// TODO: Use above to inject so dependent datasources don't need to import or
// depend on @ohif/core?
/**
* Factory function that creates a new "Web API" data source.
* A "Web API" data source is any source that fetches data over
* HTTP. This function serves as an "adapter" to wrap those calls
* so that all "Web API" data sources have the same interface and can
* be used interchangeably.
*
* It's worth noting that a single implementation of this interface
* can define different underlying sources for "read" and "write" operations.
*/
function create({
query,
retrieve,
store,
reject,
initialize,
deleteStudyMetadataPromise,
getImageIdsForDisplaySet,
getImageIdsForInstance,
getConfig,
getStudyInstanceUIDs,
}) {
const defaultQuery = {
studies: {
/**
* @param {string} params.patientName
* @param {string} params.mrn
* @param {object} params.studyDate
* @param {string} params.description
* @param {string} params.modality
* @param {string} params.accession
* @param {string} params.sortBy
* @param {string} params.sortDirection -
* @param {number} params.page
* @param {number} params.resultsPerPage
*/
mapParams: params => params,
requestResults: () => {},
processResults: results => results,
},
series: {},
instances: {},
};
const defaultRetrieve = {
series: {},
};
const defaultStore = {
dicom: async naturalizedDataset => {
throw new Error(
'store.dicom(naturalizedDicom, StudyInstanceUID) not implemented for dataSource.'
);
},
};
const defaultReject = {};
const defaultGetConfig = () => {
return { dicomUploadEnabled: false };
};
return {
query: query || defaultQuery,
retrieve: retrieve || defaultRetrieve,
reject: reject || defaultReject,
store: store || defaultStore,
initialize,
deleteStudyMetadataPromise,
getImageIdsForDisplaySet,
getImageIdsForInstance,
getConfig: getConfig || defaultGetConfig,
getStudyInstanceUIDs: getStudyInstanceUIDs,
};
}
const IWebApiDataSource = {
create,
};
export default IWebApiDataSource;

View File

@@ -0,0 +1 @@
export default {};

View File

@@ -0,0 +1 @@
export default {};

View File

@@ -0,0 +1 @@
export default {};

View File

@@ -0,0 +1 @@
export default {};

View File

@@ -0,0 +1,17 @@
// import { api } from 'dicomweb-client'
const api = {
DICOMwebClient: jest.fn().mockImplementation(function () {
this.retrieveStudyMetadata = jest.fn().mockResolvedValue([]);
this.retrieveSeriesMetadata = jest.fn(function (options) {
const { studyInstanceUID, seriesInstanceUID } = options;
return Promise.resolve([{ studyInstanceUID, seriesInstanceUID }]);
});
}),
};
export default {
api,
};
export { api };

View File

@@ -0,0 +1,5 @@
export default {
warn: jest.fn(),
error: jest.fn(),
info: jest.fn(),
};

View File

@@ -0,0 +1,191 @@
import CommandsManager from './CommandsManager';
import log from './../log.js';
jest.mock('./../log.js');
describe('CommandsManager', () => {
let commandsManager,
contextName = 'VTK',
command = {
commandFn: jest.fn().mockReturnValue(true),
options: { passMeToCommandFn: ':wave:' },
},
commandsManagerConfig = {
getAppState: () => {
return {
viewers: 'Test',
};
},
};
beforeEach(() => {
commandsManager = new CommandsManager(commandsManagerConfig);
commandsManager.createContext('VIEWER');
commandsManager.createContext('ACTIVE_VIEWER::CORNERSTONE');
jest.clearAllMocks();
});
it('has a contexts property', () => {
const localCommandsManager = new CommandsManager(commandsManagerConfig);
expect(localCommandsManager).toHaveProperty('contexts');
expect(localCommandsManager.contexts).toEqual({});
});
describe('createContext()', () => {
it('creates a context', () => {
commandsManager.createContext(contextName);
expect(commandsManager.contexts).toHaveProperty(contextName);
});
it('clears the context if it already exists', () => {
commandsManager.createContext(contextName);
commandsManager.registerCommand(contextName, 'TestCommand', command);
commandsManager.registerCommand(contextName, 'TestCommand2', command);
commandsManager.createContext(contextName);
const registeredCommands = commandsManager.getContext(contextName);
expect(registeredCommands).toEqual({});
});
});
describe('getContext()', () => {
it('returns all registered commands for a context', () => {
commandsManager.createContext(contextName);
commandsManager.registerCommand(contextName, 'TestCommand', command);
const registeredCommands = commandsManager.getContext(contextName);
expect(registeredCommands).toHaveProperty('TestCommand');
expect(registeredCommands['TestCommand']).toEqual(command);
});
it('returns undefined if the context does not exist', () => {
const registeredCommands = commandsManager.getContext(contextName);
expect(registeredCommands).toBe(undefined);
});
});
describe('clearContext()', () => {
it('clears all registered commands for a context', () => {
commandsManager.createContext(contextName);
commandsManager.registerCommand(contextName, 'TestCommand', command);
commandsManager.registerCommand(contextName, 'TestCommand2', command);
commandsManager.clearContext(contextName);
const registeredCommands = commandsManager.getContext(contextName);
expect(registeredCommands).toEqual({});
});
});
describe('registerCommand()', () => {
it('registers commands to a context', () => {
commandsManager.createContext(contextName);
commandsManager.registerCommand(contextName, 'TestCommand', command);
const registeredCommands = commandsManager.getContext(contextName);
expect(registeredCommands).toHaveProperty('TestCommand');
expect(registeredCommands['TestCommand']).toEqual(command);
});
});
describe('getCommand()', () => {
it('returns undefined if context does not exist', () => {
const result = commandsManager.getCommand('TestCommand', 'NonExistentContext');
expect(result).toBe(undefined);
});
it('returns undefined if command does not exist in context', () => {
commandsManager.createContext(contextName);
const result = commandsManager.getCommand('TestCommand', contextName);
expect(result).toBe(undefined);
});
it('uses contextName param to get command', () => {
commandsManager.createContext('GLOBAL');
commandsManager.registerCommand('GLOBAL', 'TestCommand', command);
const foundCommand = commandsManager.getCommand('TestCommand', 'GLOBAL');
expect(foundCommand).toBe(command);
});
it('uses activeContexts, if contextName is not provided, to get command', () => {
commandsManager.registerCommand('VIEWER', 'TestCommand', command);
const foundCommand = commandsManager.getCommand('TestCommand');
expect(foundCommand).toBe(command);
});
it('returns the expected command', () => {
commandsManager.createContext(contextName);
commandsManager.registerCommand(contextName, 'TestCommand', command);
const result = commandsManager.getCommand('TestCommand', contextName);
expect(result).toEqual(command);
});
});
describe('runCommand()', () => {
it('Logs a warning if commandName not found in context', () => {
const result = commandsManager.runCommand('CommandThatDoesNotExistInAnyContext');
expect(result).toBe(undefined);
expect(log.warn.mock.calls[0][0]).toEqual(
'Command "CommandThatDoesNotExistInAnyContext" not found in current context'
);
});
it('Logs a warning if command definition does not have a commandFn', () => {
const commandWithNoCommmandFn = {
commandFn: undefined,
options: {},
};
commandsManager.createContext(contextName);
commandsManager.registerCommand(contextName, 'TestCommand', commandWithNoCommmandFn);
const result = commandsManager.runCommand('TestCommand', null, contextName);
expect(result).toBe(undefined);
expect(log.warn.mock.calls[0][0]).toEqual(
'No commandFn was defined for command "TestCommand"'
);
});
it('Calls commandFn', () => {
commandsManager.registerCommand('VIEWER', 'TestCommand', command);
commandsManager.runCommand('TestCommand', {}, 'VIEWER');
expect(command.commandFn.mock.calls.length).toBe(1);
});
it('Calls commandFn w/ command definition options', () => {
commandsManager.registerCommand('VIEWER', 'TestCommand', command);
commandsManager.runCommand('TestCommand', {}, 'VIEWER');
expect(command.commandFn.mock.calls.length).toBe(1);
expect(command.commandFn.mock.calls[0][0].passMeToCommandFn).toEqual(
command.options.passMeToCommandFn
);
});
it('Calls commandFn w/ runCommand "options" parameter', () => {
const runCommandOptions = {
test: ':+1:',
};
commandsManager.registerCommand('VIEWER', 'TestCommand', command);
commandsManager.runCommand('TestCommand', runCommandOptions, 'VIEWER');
expect(command.commandFn.mock.calls.length).toBe(1);
expect(command.commandFn.mock.calls[0][0].test).toEqual(runCommandOptions.test);
});
it('Returns the result of commandFn', () => {
commandsManager.registerCommand('VIEWER', 'TestCommand', command);
const result = commandsManager.runCommand('TestCommand', {}, 'VIEWER');
expect(command.commandFn.mock.calls.length).toBe(1);
expect(result).toBe(true);
});
});
});

View File

@@ -0,0 +1,231 @@
import log from '../log.js';
import { Command, Commands, ComplexCommand } from '../types/Command';
/**
* The definition of a command
*
* @typedef {Object} CommandDefinition
* @property {Function} commandFn - Command to call
* @property {Object} options - Object of params to pass action
*/
/**
* The Commands Manager tracks named commands (or functions) that are scoped to
* a context. When we attempt to run a command with a given name, we look for it
* in our active contexts. If found, we run the command, passing in any application
* or call specific data specified in the command's definition.
*
* NOTE: A more robust version of the CommandsManager lives in v1. If you're looking
* to extend this class, please check it's source before adding new methods.
*/
export class CommandsManager {
constructor({} = {}) {
this.contexts = {};
}
/**
* Allows us to create commands "per context". An example would be the "Cornerstone"
* context having a `SaveImage` command, and the "VTK" context having a `SaveImage`
* command. The distinction of a context allows us to call the command in either
* context, and have faith that the correct command will be run.
*
* @method
* @param {string} contextName - Namespace for commands
* @returns {undefined}
*/
createContext(contextName) {
if (!contextName) {
return;
}
if (this.contexts[contextName]) {
return this.clearContext(contextName);
}
this.contexts[contextName] = {};
}
/**
* Returns all command definitions for a given context
*
* @method
* @param {string} contextName - Namespace for commands
* @returns {Object} - the matched context
*/
getContext(contextName) {
const context = this.contexts[contextName];
if (!context) {
return;
}
return context;
}
/**
* Clears all registered commands for a given context.
*
* @param {string} contextName - Namespace for commands
* @returns {undefined}
*/
clearContext(contextName) {
if (!contextName) {
return;
}
this.contexts[contextName] = {};
}
/**
* Register a new command with the command manager. Scoped to a context, and
* with a definition to assist command callers w/ providing the necessary params
*
* @method
* @param {string} contextName - Namespace for command; often scoped to the extension that added it
* @param {string} commandName - Unique name identifying the command
* @param {CommandDefinition} definition - {@link CommandDefinition}
*/
registerCommand(contextName, commandName, definition) {
if (typeof definition !== 'object') {
return;
}
const context = this.getContext(contextName);
if (!context) {
return;
}
context[commandName] = definition;
}
/**
* Finds a command with the provided name if it exists in the specified context,
* or a currently active context.
*
* @method
* @param {String} commandName - Command to find
* @param {String} [contextName] - Specific command to look in. Defaults to current activeContexts
*/
getCommand = (commandName: string, contextName?: string) => {
const contexts = [];
if (contextName) {
const context = this.getContext(contextName);
if (context) {
contexts.push(context);
}
} else {
Object.keys(this.contexts).forEach(contextName => {
contexts.push(this.getContext(contextName));
});
}
if (contexts.length === 0) {
return;
}
let foundCommand;
contexts.forEach(context => {
if (context[commandName]) {
foundCommand = context[commandName];
}
});
return foundCommand;
};
/**
*
* @method
* @param {String} commandName
* @param {Object} [options={}] - Extra options to pass the command. Like a mousedown event
* @param {String} [contextName]
*/
public runCommand(commandName: string, options = {}, contextName?: string) {
const definition = this.getCommand(commandName, contextName);
if (!definition) {
log.warn(`Command "${commandName}" not found in current context`);
return;
}
const { commandFn } = definition;
const commandParams = Object.assign(
{},
definition.options, // "Command configuration"
options // "Time of call" info
);
if (typeof commandFn !== 'function') {
log.warn(`No commandFn was defined for command "${commandName}"`);
return;
} else {
return commandFn(commandParams);
}
}
/**
* Run one or more commands with specified extra options.
* Returns the result of the last command run.
*
* @param toRun - A specification of one or more commands,
* typically an object of { commandName, commandOptions, context }
* or an array of such objects. It can also be a single commandName as string
* if no options are needed.
* @param options - to include in the commands run beyond
* the commandOptions specified in the base.
*/
public run(
toRun: Command | Commands | Command[] | string | undefined,
options?: Record<string, unknown>
): unknown {
if (!toRun) {
return;
}
// Normalize `toRun` to an array of `ComplexCommand`
let commands: ComplexCommand[] = [];
if (typeof toRun === 'string') {
commands = [{ commandName: toRun }];
} else if ('commandName' in toRun) {
commands = [toRun as ComplexCommand];
} else if ('commands' in toRun) {
const commandsInput = (toRun as Commands).commands;
commands = Array.isArray(commandsInput)
? commandsInput.map(cmd => (typeof cmd === 'string' ? { commandName: cmd } : cmd))
: [{ commandName: commandsInput }];
} else if (Array.isArray(toRun)) {
commands = toRun.map(cmd => (typeof cmd === 'string' ? { commandName: cmd } : cmd));
}
if (commands.length === 0) {
console.log("Command isn't runnable", toRun);
return;
}
// Execute each command in the array
let result: unknown;
commands.forEach(command => {
const { commandName, commandOptions, context } = command;
if (commandName) {
result = this.runCommand(
commandName,
{
...commandOptions,
...options,
},
context
);
} else {
if (typeof command === 'function') {
result = command();
} else {
console.warn('No command name supplied in', toRun);
}
}
});
return result;
}
}
export default CommandsManager;

View File

@@ -0,0 +1,8 @@
export default interface Hotkey {
commandName: string;
commandOptions?: Record<string, unknown>;
context?: string;
keys: string[];
label: string;
isEditable?: boolean;
}

View File

@@ -0,0 +1,198 @@
import CommandsManager from './CommandsManager';
import HotkeysManager from './HotkeysManager';
import hotkeys from './../utils/hotkeys';
import log from './../log';
import objectHash from 'object-hash';
jest.mock('./CommandsManager');
jest.mock('./../utils/hotkeys');
jest.mock('./../log');
describe('HotkeysManager', () => {
let hotkeysManager, commandsManager;
beforeEach(() => {
commandsManager = new CommandsManager();
hotkeysManager = new HotkeysManager(commandsManager);
CommandsManager.mockClear();
hotkeys.mockClear();
log.warn.mockClear();
jest.clearAllMocks();
});
it('has expected properties', () => {
const allProperties = Object.keys(hotkeysManager);
const expectedProperties = ['hotkeyDefinitions', 'hotkeyDefaults', 'isEnabled'];
const containsAllExpectedProperties = expectedProperties.every(expected =>
allProperties.includes(expected)
);
expect(containsAllExpectedProperties).toBe(true);
});
it('throws Error if instantiated without a commandsManager', () => {
expect(() => {
new HotkeysManager();
}).toThrow(
'HotkeysManager instantiated without a commandsManager. Hotkeys will be unable to find and run commands.'
);
});
describe('disable()', () => {
beforeEach(() => hotkeys.pause.mockClear());
it('sets isEnabled property to false', () => {
hotkeysManager.disable();
expect(hotkeysManager.isEnabled).toBe(false);
});
it('calls hotkeys.pause()', () => {
hotkeysManager.disable();
expect(hotkeys.pause.mock.calls.length).toBe(1);
});
});
describe('enable()', () => {
beforeEach(() => {
hotkeys.unpause = jest.fn();
hotkeys.unpause.mockClear();
});
it('sets isEnabled property to true', () => {
hotkeysManager.disable();
hotkeysManager.enable();
expect(hotkeysManager.isEnabled).toBe(true);
});
it('calls hotkeys.unpause()', () => {
hotkeysManager.enable();
expect(hotkeys.unpause.mock.calls.length).toBe(1);
});
});
describe('setHotkeys()', () => {
it('calls registerHotkeys for each hotkeyDefinition', () => {
const hotkeyDefinitions = [
{ commandName: 'dance', label: 'dance dance', keys: '+' },
{ commandName: 'celebrate', label: 'celebrate everything', keys: 'q' },
];
hotkeysManager.registerHotkeys = jest.fn();
hotkeysManager.setHotkeys(hotkeyDefinitions);
const numberOfCalls = hotkeysManager.registerHotkeys.mock.calls.length;
const firstCallArgs = hotkeysManager.registerHotkeys.mock.calls[0][0];
const secondCallArgs = hotkeysManager.registerHotkeys.mock.calls[1][0];
expect(numberOfCalls).toBe(2);
expect(firstCallArgs).toEqual(hotkeyDefinitions[0]);
expect(secondCallArgs).toEqual(hotkeyDefinitions[1]);
});
it('does not set this.hotkeyDefaults when calling setHotKeys', () => {
const hotkeyDefinitions = [{ commandName: 'dance', keys: '+' }];
hotkeysManager.setHotkeys(hotkeyDefinitions);
expect(hotkeysManager.hotkeyDefaults).toEqual([]);
});
});
describe('setDefaultHotKeys()', () => {
it('it sets default hotkeys', () => {
const hotkeyDefinitions = [{ commandName: 'dance', keys: '+' }];
hotkeysManager.setDefaultHotKeys(hotkeyDefinitions);
expect(hotkeysManager.hotkeyDefaults).toEqual(hotkeyDefinitions);
});
});
describe('registerHotkeys()', () => {
it('throws an Error if a commandName is not provided', () => {
const definition = { commandName: undefined, keys: '+' };
expect(() => {
hotkeysManager.registerHotkeys(definition);
}).toThrow();
});
it('updates hotkeyDefinitions property with registered keys', () => {
const definition = {
commandName: 'dance',
commandOptions: {},
label: 'hello',
keys: '+',
};
hotkeysManager.registerHotkeys(definition);
const numOfHotkeyDefinitions = Object.keys(hotkeysManager.hotkeyDefinitions).length;
const commandHash = objectHash({
commandName: definition.commandName,
commandOptions: definition.commandOptions,
});
const hotkeyDefinitionForRegisteredCommand = hotkeysManager.hotkeyDefinitions[commandHash];
expect(numOfHotkeyDefinitions).toBe(1);
expect(Object.keys(hotkeysManager.hotkeyDefinitions)[0]).toEqual(commandHash);
expect(hotkeyDefinitionForRegisteredCommand).toEqual(definition);
});
it('calls hotkeys.bind for the group of keys', () => {
const definition = { commandName: 'dance', keys: ['shift', 'e'] };
hotkeysManager.registerHotkeys(definition);
expect(hotkeys.bind.mock.calls.length).toBe(1);
expect(hotkeys.bind.mock.calls[0][0]).toBe('shift+e');
});
it('calls hotkeys.unbind if commandName was previously registered, for each previously registered set of keys', () => {
const firstDefinition = {
commandName: 'dance',
keys: ['alt', 'e'],
};
const secondDefinition = { commandName: 'dance', keys: 'a' };
// First call
hotkeysManager.registerHotkeys(firstDefinition);
// Second call
hotkeysManager.registerHotkeys(secondDefinition);
expect(hotkeys.unbind.mock.calls.length).toBe(1);
expect(hotkeys.unbind.mock.calls[0][0]).toBe('alt+e');
});
});
describe('restoreDefaults()', () => {
it('calls setHotkeys with hotkey defaults', () => {
hotkeysManager.setHotkeys = jest.fn();
hotkeysManager.restoreDefaultBindings();
expect(hotkeysManager.setHotkeys.mock.calls[0][0]).toEqual(hotkeysManager.hotkeyDefaults);
});
});
describe('destroy()', () => {
it('clears default and definition properties', () => {
hotkeysManager.hotkeyDefaults = ['hotdog', 'jeremy', 'qasar'];
hotkeysManager.hotkeyDefinitions = {
hello: 'world',
};
hotkeysManager.destroy();
expect(hotkeysManager.hotkeyDefaults).toEqual([]);
expect(hotkeysManager.hotkeyDefinitions).toEqual({});
});
it('resets all hotkey bindings', () => {
hotkeysManager.destroy();
expect(hotkeys.reset.mock.calls.length).toEqual(1);
});
});
});

View File

@@ -0,0 +1,276 @@
import objectHash from 'object-hash';
import { hotkeys } from '../utils';
import isequal from 'lodash.isequal';
import Hotkey from './Hotkey';
/**
*
*
* @typedef {Object} HotkeyDefinition
* @property {String} commandName - Command to call
* @property {Object} commandOptions - Command options
* @property {String} label - Display name for hotkey
* @property {String[]} keys - Keys to bind; Follows Mousetrap.js binding syntax
*/
export class HotkeysManager {
private _servicesManager: AppTypes.ServicesManager;
constructor(commandsManager, servicesManager: AppTypes.ServicesManager) {
this.hotkeyDefinitions = {};
this.hotkeyDefaults = [];
this.isEnabled = true;
if (!commandsManager) {
throw new Error(
'HotkeysManager instantiated without a commandsManager. Hotkeys will be unable to find and run commands.'
);
}
this._servicesManager = servicesManager;
this._commandsManager = commandsManager;
}
/**
* Exposes Mousetrap.js's `.record` method, added by the record plugin.
*
* @param {*} event
*/
record(event) {
return hotkeys.record(event);
}
/**
* Disables all hotkeys. Hotkeys added while disabled will not listen for
* input.
*/
disable() {
this.isEnabled = false;
hotkeys.pause();
}
/**
* Enables all hotkeys.
*/
enable() {
this.isEnabled = true;
hotkeys.unpause();
}
/**
* Registers a list of hotkeydefinitions.
*
* @param {HotkeyDefinition[] | Object} [hotkeyDefinitions=[]] Contains hotkeys definitions
*/
setHotkeys(hotkeyDefinitions = [], name = 'hotkey-definitions') {
try {
const definitions = this.getValidDefinitions(hotkeyDefinitions);
if (isequal(definitions, this.hotkeyDefaults)) {
localStorage.removeItem(name);
} else {
localStorage.setItem(name, JSON.stringify(definitions));
}
definitions.forEach(definition => this.registerHotkeys(definition));
} catch (error) {
const { uiNotificationService } = this._servicesManager.services;
uiNotificationService.show({
title: 'Hotkeys Manager',
message: 'Error while setting hotkeys',
type: 'error',
});
}
}
/**
* Set default hotkey bindings. These
* values are used in `this.restoreDefaultBindings`.
*
* @param {HotkeyDefinition[] | Object} [hotkeyDefinitions=[]] Contains hotkeys definitions
*/
setDefaultHotKeys(hotkeyDefinitions = []) {
const definitions = this.getValidDefinitions(hotkeyDefinitions);
this.hotkeyDefaults = definitions;
}
/**
* Take hotkey definitions that can be an array or object and make sure that it
* returns an array of hotkeys
*
* @param {HotkeyDefinition[] | Object} [hotkeyDefinitions=[]] Contains hotkeys definitions
*/
getValidDefinitions(hotkeyDefinitions) {
const definitions = Array.isArray(hotkeyDefinitions)
? [...hotkeyDefinitions]
: this._parseToArrayLike(hotkeyDefinitions);
return definitions;
}
/**
* Take hotkey definitions that can be an array and make sure that it
* returns an object of hotkeys definitions
*
* @param {HotkeyDefinition[]} [hotkeyDefinitions=[]] Contains hotkeys definitions
* @returns {Object}
*/
getValidHotkeyDefinitions(hotkeyDefinitions) {
const definitions = this.getValidDefinitions(hotkeyDefinitions);
const objectDefinitions = {};
definitions.forEach(definition => {
const { commandName, commandOptions } = definition;
const commandHash = objectHash({ commandName, commandOptions });
objectDefinitions[commandHash] = definition;
});
return objectDefinitions;
}
/**
* It parses given object containing hotkeyDefinition to array like.
* Each property of given object will be mapped to an object of an array. And its property name will be the value of a property named as commandName
*
* @param {HotkeyDefinition[] | Object} [hotkeyDefinitions={}] Contains hotkeys definitions
* @returns {HotkeyDefinition[]}
*/
_parseToArrayLike(hotkeyDefinitionsObj = {}) {
const copy = { ...hotkeyDefinitionsObj };
return Object.entries(copy).map(entryValue =>
this._parseToHotKeyObj(entryValue[0], entryValue[1])
);
}
/**
* Return HotkeyDefinition object like based on given property name and property value
* @param {string} propertyName property name of hotkey definition object
* @param {object} propertyValue property value of hotkey definition object
*
* @example
*
* const hotKeyObj = {hotKeyDefA: {keys:[],....}}
*
* const parsed = _parseToHotKeyObj(Object.keys(hotKeyDefA)[0], hotKeyObj[hotKeyDefA]);
* {
* commandName: hotKeyDefA,
* keys: [],
* ....
* }
*
*/
_parseToHotKeyObj(propertyName, propertyValue) {
return {
commandName: propertyName,
...propertyValue,
};
}
/**
* (unbinds and) binds the specified command to one or more key combinations.
* When a hotkey combination is triggered, the command name and active contexts
* are used to locate the correct command to call.
*
* @param {HotkeyDefinition} command
* @param {String} extension
* @returns {undefined}
*/
registerHotkeys(
{ commandName, commandOptions = {}, context, keys, label, isEditable }: Hotkey = {},
extension
) {
if (!commandName) {
throw new Error(`No command was defined for hotkey "${keys}"`);
}
const commandHash = objectHash({ commandName, commandOptions });
const options = Object.keys(commandOptions).length ? JSON.stringify(commandOptions) : 'no';
const previouslyRegisteredDefinition = this.hotkeyDefinitions[commandHash];
if (previouslyRegisteredDefinition) {
const previouslyRegisteredKeys = previouslyRegisteredDefinition.keys;
this._unbindHotkeys(commandName, previouslyRegisteredKeys);
// log.info(
// `[hotkeys] Unbinding ${commandName} with ${options} options from ${previouslyRegisteredKeys}`
// );
}
// Set definition & bind
this.hotkeyDefinitions[commandHash] = {
commandName,
commandOptions,
keys,
label,
isEditable,
};
this._bindHotkeys(commandName, commandOptions, context, keys);
// log.info(
// `[hotkeys] Binding ${commandName} with ${options} from ${context ||
// 'default'} options to ${keys}`
// );
}
/**
* Uses most recent
*
* @returns {undefined}
*/
restoreDefaultBindings() {
this.setHotkeys(this.hotkeyDefaults);
}
/**
*
*/
destroy() {
this.hotkeyDefaults = [];
this.hotkeyDefinitions = {};
hotkeys.reset();
}
/**
* Binds one or more set of hotkey combinations for a given command
*
* @private
* @param {string} commandName - The name of the command to trigger when hotkeys are used
* @param {string[]} keys - One or more key combinations that should trigger command
* @returns {undefined}
*/
_bindHotkeys(commandName, commandOptions = {}, context, keys) {
const isKeyDefined = keys === '' || keys === undefined;
if (isKeyDefined) {
return;
}
const isKeyArray = keys instanceof Array;
const combinedKeys = isKeyArray ? keys.join('+') : keys;
hotkeys.bind(combinedKeys, evt => {
evt.preventDefault();
evt.stopPropagation();
this._commandsManager.runCommand(commandName, { evt, ...commandOptions }, context);
});
}
/**
* unbinds one or more set of hotkey combinations for a given command
*
* @private
* @param {string} commandName - The name of the previously bound command
* @param {string[]} keys - One or more sets of previously bound keys
* @returns {undefined}
*/
_unbindHotkeys(commandName, keys) {
const isKeyDefined = keys !== '' && keys !== undefined;
if (!isKeyDefined) {
return;
}
const isKeyArray = keys instanceof Array;
if (isKeyArray) {
const combinedKeys = keys.join('+');
this._unbindHotkeys(commandName, combinedKeys);
return;
}
hotkeys.unbind(keys);
}
}
export default HotkeysManager;

View File

@@ -0,0 +1,141 @@
import guid from '../utils/guid.js';
import { Vector3 } from 'cornerstone-math';
type Attributes = Record<string, unknown>;
type Image = {
StudyInstanceUID?: string;
getData(): {
metadata: {
ImagePositionPatient: number[];
ImageOrientationPatient: number[];
};
};
};
/**
* This class defines an ImageSet object which will be used across the viewer. This object represents
* a list of images that are associated by any arbitrary criteria being thus content agnostic. Besides the
* main attributes (images and uid) it allows additional attributes to be appended to it (currently
* indiscriminately, but this should be changed).
*/
class ImageSet {
images: Image[];
uid: string;
instances: Image[];
instance?: Image;
StudyInstanceUID?: string;
constructor(images: Image[]) {
if (!Array.isArray(images)) {
throw new Error('ImageSet expects an array of images');
}
// @property "images"
Object.defineProperty(this, 'images', {
enumerable: false,
configurable: false,
writable: false,
value: images,
});
// @property "uid"
Object.defineProperty(this, 'uid', {
enumerable: false,
configurable: false,
writable: false,
value: guid(), // Unique ID of the instance
});
this.instances = images;
this.instance = images[0];
this.StudyInstanceUID = this.instance?.StudyInstanceUID;
}
load: () => Promise<void>;
getUID(): string {
return this.uid;
}
setAttribute(attribute: string, value: unknown): void {
this[attribute] = value;
}
getAttribute(attribute: string): unknown {
return this[attribute];
}
setAttributes(attributes: Attributes): void {
if (typeof attributes === 'object' && attributes !== null) {
for (const [attribute, value] of Object.entries(attributes)) {
this[attribute] = value;
}
}
}
getNumImages = (): number => this.images.length;
getImage(index: number): Image {
return this.images[index];
}
sortBy(sortingCallback: (a: Image, b: Image) => number): Image[] {
return this.images.sort(sortingCallback);
}
sortByImagePositionPatient(): void {
const images = this.images;
const referenceImagePositionPatient = _getImagePositionPatient(images[0]);
const refIppVec = new Vector3(
referenceImagePositionPatient[0],
referenceImagePositionPatient[1],
referenceImagePositionPatient[2]
);
const ImageOrientationPatient = _getImageOrientationPatient(images[0]);
const scanAxisNormal = new Vector3(
ImageOrientationPatient[0],
ImageOrientationPatient[1],
ImageOrientationPatient[2]
).cross(
new Vector3(
ImageOrientationPatient[3],
ImageOrientationPatient[4],
ImageOrientationPatient[5]
)
);
const distanceImagePairs = images.map(function (image: Image) {
const ippVec = new Vector3(..._getImagePositionPatient(image));
const positionVector = refIppVec.clone().sub(ippVec);
const distance = positionVector.dot(scanAxisNormal);
return {
distance,
image,
};
});
distanceImagePairs.sort(function (a, b) {
return b.distance - a.distance;
});
const sortedImages = distanceImagePairs.map(a => a.image);
images.sort(function (a, b) {
return sortedImages.indexOf(a) - sortedImages.indexOf(b);
});
}
}
function _getImagePositionPatient(image) {
return image.getData().metadata.ImagePositionPatient;
}
function _getImageOrientationPatient(image) {
return image.getData().metadata.ImageOrientationPatient;
}
export default ImageSet;

View File

@@ -0,0 +1,589 @@
import queryString from 'query-string';
import dicomParser from 'dicom-parser';
import { imageIdToURI } from '../utils';
import getPixelSpacingInformation from '../utils/metadataProvider/getPixelSpacingInformation';
import DicomMetadataStore from '../services/DicomMetadataStore';
import fetchPaletteColorLookupTableData from '../utils/metadataProvider/fetchPaletteColorLookupTableData';
import toNumber from '../utils/toNumber';
import combineFrameInstance from '../utils/combineFrameInstance';
class MetadataProvider {
private readonly studies: Map<string, any> = new Map();
private readonly imageURIToUIDs: Map<string, any> = new Map();
private readonly imageUIDsByImageId: Map<string, any> = new Map();
// Can be used to store custom metadata for a specific type.
// For instance, the scaling metadata for PET can be stored here
// as type "scalingModule"
private readonly customMetadata: Map<string, any> = new Map();
addImageIdToUIDs(imageId, uids) {
// This method is a fallback for when you don't have WADO-URI or WADO-RS.
// You can add instances fetched by any method by calling addInstance, and hook an imageId to point at it here.
// An example would be dicom hosted at some random site.
const imageURI = imageIdToURI(imageId);
this.imageURIToUIDs.set(imageURI, uids);
this.imageUIDsByImageId.set(imageId, uids);
}
addCustomMetadata(imageId, type, metadata) {
const imageURI = imageIdToURI(imageId);
if (!this.customMetadata.has(type)) {
this.customMetadata.set(type, {});
}
this.customMetadata.get(type)[imageURI] = metadata;
}
_getInstance(imageId) {
const uids = this.getUIDsFromImageID(imageId);
if (!uids) {
return;
}
const { StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID, frameNumber } = uids;
const instance = DicomMetadataStore.getInstance(
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID
);
if (!instance) {
return;
}
return (frameNumber && combineFrameInstance(frameNumber, instance)) || instance;
}
get(query, imageId, options = { fallback: false }) {
if (Array.isArray(imageId)) {
return;
}
const instance = this._getInstance(imageId);
if (query === INSTANCE) {
return instance;
}
// check inside custom metadata
if (this.customMetadata.has(query)) {
const customMetadata = this.customMetadata.get(query);
const imageURI = imageIdToURI(imageId);
if (customMetadata[imageURI]) {
return customMetadata[imageURI];
}
}
return this.getTagFromInstance(query, instance, options);
}
getTag(query, imageId, options) {
return this.get(query, imageId, options);
}
getInstance(imageId) {
return this.get(INSTANCE, imageId);
}
getTagFromInstance(naturalizedTagOrWADOImageLoaderTag, instance, options = { fallback: false }) {
if (!instance) {
return;
}
// If its a naturalized dcmjs tag present on the instance, return.
if (instance[naturalizedTagOrWADOImageLoaderTag]) {
return instance[naturalizedTagOrWADOImageLoaderTag];
}
// Maybe its a legacy dicomImageLoader tag then:
return this._getCornerstoneDICOMImageLoaderTag(naturalizedTagOrWADOImageLoaderTag, instance);
}
/**
* Adds a new handler for the given tag. The handler will be provided an
* instance object that it can read values from.
*/
public addHandler(wadoImageLoaderTag: string, handler) {
WADO_IMAGE_LOADER[wadoImageLoaderTag] = handler;
}
_getCornerstoneDICOMImageLoaderTag(wadoImageLoaderTag, instance) {
let metadata = WADO_IMAGE_LOADER[wadoImageLoaderTag]?.(instance);
if (metadata) {
return metadata;
}
switch (wadoImageLoaderTag) {
case WADO_IMAGE_LOADER_TAGS.GENERAL_SERIES_MODULE:
const { SeriesDate, SeriesTime } = instance;
let seriesDate;
let seriesTime;
if (SeriesDate) {
seriesDate = dicomParser.parseDA(SeriesDate);
}
if (SeriesTime) {
seriesTime = dicomParser.parseTM(SeriesTime);
}
metadata = {
modality: instance.Modality,
seriesInstanceUID: instance.SeriesInstanceUID,
seriesNumber: toNumber(instance.SeriesNumber),
studyInstanceUID: instance.StudyInstanceUID,
seriesDate,
seriesTime,
};
break;
case WADO_IMAGE_LOADER_TAGS.PATIENT_STUDY_MODULE:
metadata = {
patientAge: toNumber(instance.PatientAge),
patientSize: toNumber(instance.PatientSize),
patientWeight: toNumber(instance.PatientWeight),
};
break;
case WADO_IMAGE_LOADER_TAGS.PATIENT_DEMOGRAPHIC_MODULE:
metadata = {
patientSex: instance.PatientSex,
};
break;
case WADO_IMAGE_LOADER_TAGS.IMAGE_PIXEL_MODULE:
metadata = {
samplesPerPixel: toNumber(instance.SamplesPerPixel),
photometricInterpretation: instance.PhotometricInterpretation,
rows: toNumber(instance.Rows),
columns: toNumber(instance.Columns),
bitsAllocated: toNumber(instance.BitsAllocated),
bitsStored: toNumber(instance.BitsStored),
highBit: toNumber(instance.HighBit),
pixelRepresentation: toNumber(instance.PixelRepresentation),
planarConfiguration: toNumber(instance.PlanarConfiguration),
pixelAspectRatio: toNumber(instance.PixelAspectRatio),
smallestPixelValue: toNumber(instance.SmallestPixelValue),
largestPixelValue: toNumber(instance.LargestPixelValue),
redPaletteColorLookupTableDescriptor: toNumber(
instance.RedPaletteColorLookupTableDescriptor
),
greenPaletteColorLookupTableDescriptor: toNumber(
instance.GreenPaletteColorLookupTableDescriptor
),
bluePaletteColorLookupTableDescriptor: toNumber(
instance.BluePaletteColorLookupTableDescriptor
),
redPaletteColorLookupTableData: fetchPaletteColorLookupTableData(
instance,
'RedPaletteColorLookupTableData',
'RedPaletteColorLookupTableDescriptor'
),
greenPaletteColorLookupTableData: fetchPaletteColorLookupTableData(
instance,
'GreenPaletteColorLookupTableData',
'GreenPaletteColorLookupTableDescriptor'
),
bluePaletteColorLookupTableData: fetchPaletteColorLookupTableData(
instance,
'BluePaletteColorLookupTableData',
'BluePaletteColorLookupTableDescriptor'
),
};
break;
case WADO_IMAGE_LOADER_TAGS.VOI_LUT_MODULE:
const { WindowCenter, WindowWidth, VOILUTFunction } = instance;
if (WindowCenter === undefined || WindowWidth === undefined) {
return;
}
const windowCenter = Array.isArray(WindowCenter) ? WindowCenter : [WindowCenter];
const windowWidth = Array.isArray(WindowWidth) ? WindowWidth : [WindowWidth];
metadata = {
windowCenter: toNumber(windowCenter),
windowWidth: toNumber(windowWidth),
voiLUTFunction: VOILUTFunction,
};
break;
case WADO_IMAGE_LOADER_TAGS.MODALITY_LUT_MODULE:
const { RescaleIntercept, RescaleSlope } = instance;
if (RescaleIntercept === undefined || RescaleSlope === undefined) {
return;
}
metadata = {
rescaleIntercept: toNumber(instance.RescaleIntercept),
rescaleSlope: toNumber(instance.RescaleSlope),
rescaleType: instance.RescaleType,
};
break;
case WADO_IMAGE_LOADER_TAGS.SOP_COMMON_MODULE:
metadata = {
sopClassUID: instance.SOPClassUID,
sopInstanceUID: instance.SOPInstanceUID,
};
break;
case WADO_IMAGE_LOADER_TAGS.PET_IMAGE_MODULE:
metadata = {
frameReferenceTime: instance.FrameReferenceTime,
actualFrameDuration: instance.ActualFrameDuration,
};
break;
case WADO_IMAGE_LOADER_TAGS.PET_ISOTOPE_MODULE:
const { RadiopharmaceuticalInformationSequence } = instance;
if (RadiopharmaceuticalInformationSequence) {
const RadiopharmaceuticalInformation = Array.isArray(
RadiopharmaceuticalInformationSequence
)
? RadiopharmaceuticalInformationSequence[0]
: RadiopharmaceuticalInformationSequence;
const { RadiopharmaceuticalStartTime, RadionuclideTotalDose, RadionuclideHalfLife } =
RadiopharmaceuticalInformation;
const radiopharmaceuticalInfo = {
radiopharmaceuticalStartTime: dicomParser.parseTM(RadiopharmaceuticalStartTime),
radionuclideTotalDose: RadionuclideTotalDose,
radionuclideHalfLife: RadionuclideHalfLife,
};
metadata = {
radiopharmaceuticalInfo,
};
}
break;
case WADO_IMAGE_LOADER_TAGS.OVERLAY_PLANE_MODULE:
const overlays = [];
for (let overlayGroup = 0x00; overlayGroup <= 0x1e; overlayGroup += 0x02) {
let groupStr = `60${overlayGroup.toString(16)}`;
if (groupStr.length === 3) {
groupStr = `600${overlayGroup.toString(16)}`;
}
const OverlayDataTag = `${groupStr}3000`;
const OverlayData = instance[OverlayDataTag];
if (!OverlayData) {
continue;
}
const OverlayRowsTag = `${groupStr}0010`;
const OverlayColumnsTag = `${groupStr}0011`;
const OverlayType = `${groupStr}0040`;
const OverlayOriginTag = `${groupStr}0050`;
const OverlayDescriptionTag = `${groupStr}0022`;
const OverlayLabelTag = `${groupStr}1500`;
const ROIAreaTag = `${groupStr}1301`;
const ROIMeanTag = `${groupStr}1302`;
const ROIStandardDeviationTag = `${groupStr}1303`;
const OverlayOrigin = instance[OverlayOriginTag];
let rows = 0;
if (instance[OverlayRowsTag] instanceof Array) {
// The DICOM VR for overlay rows is US (unsigned short).
const rowsInt16Array = new Uint16Array(instance[OverlayRowsTag][0]);
rows = rowsInt16Array[0];
} else {
rows = instance[OverlayRowsTag];
}
let columns = 0;
if (instance[OverlayColumnsTag] instanceof Array) {
// The DICOM VR for overlay columns is US (unsigned short).
const columnsInt16Array = new Uint16Array(instance[OverlayColumnsTag][0]);
columns = columnsInt16Array[0];
} else {
columns = instance[OverlayColumnsTag];
}
let x = 0;
let y = 0;
if (OverlayOrigin.length === 1) {
// The DICOM VR for overlay origin is SS (signed short) with a multiplicity of 2.
const originInt16Array = new Int16Array(OverlayOrigin[0]);
x = originInt16Array[0];
y = originInt16Array[1];
} else {
x = OverlayOrigin[0];
y = OverlayOrigin[1];
}
const overlay = {
rows: rows,
columns: columns,
type: instance[OverlayType],
x,
y,
pixelData: OverlayData,
description: instance[OverlayDescriptionTag],
label: instance[OverlayLabelTag],
roiArea: instance[ROIAreaTag],
roiMean: instance[ROIMeanTag],
roiStandardDeviation: instance[ROIStandardDeviationTag],
};
overlays.push(overlay);
}
metadata = {
overlays,
};
break;
case WADO_IMAGE_LOADER_TAGS.PATIENT_MODULE:
const { PatientName } = instance;
let patientName;
if (PatientName) {
patientName = PatientName.Alphabetic;
}
metadata = {
patientName,
patientId: instance.PatientID,
};
break;
case WADO_IMAGE_LOADER_TAGS.GENERAL_IMAGE_MODULE:
metadata = {
sopInstanceUID: instance.SOPInstanceUID,
instanceNumber: toNumber(instance.InstanceNumber),
lossyImageCompression: instance.LossyImageCompression,
lossyImageCompressionRatio: instance.LossyImageCompressionRatio,
lossyImageCompressionMethod: instance.LossyImageCompressionMethod,
};
break;
case WADO_IMAGE_LOADER_TAGS.GENERAL_STUDY_MODULE:
metadata = {
studyDescription: instance.StudyDescription,
studyDate: instance.StudyDate,
studyTime: instance.StudyTime,
accessionNumber: instance.AccessionNumber,
};
break;
case WADO_IMAGE_LOADER_TAGS.CINE_MODULE:
metadata = {
frameTime: instance.FrameTime,
numberOfFrames: instance.NumberOfFrames ? Number(instance.NumberOfFrames) : 1,
};
break;
case WADO_IMAGE_LOADER_TAGS.PER_SERIES_MODULE:
metadata = {
correctedImage: instance.CorrectedImage,
units: instance.Units,
decayCorrection: instance.DecayCorrection,
};
break;
case WADO_IMAGE_LOADER_TAGS.CALIBRATION_MODULE:
// map the DICOM tags to the cornerstone tags since cornerstone tags
// are camelCase and instance tags are all caps
metadata = {
sequenceOfUltrasoundRegions: instance.SequenceOfUltrasoundRegions?.map(region => {
return {
regionSpatialFormat: region.RegionSpatialFormat,
regionDataType: region.RegionDataType,
regionFlags: region.RegionFlags,
regionLocationMinX0: region.RegionLocationMinX0,
regionLocationMinY0: region.RegionLocationMinY0,
regionLocationMaxX1: region.RegionLocationMaxX1,
regionLocationMaxY1: region.RegionLocationMaxY1,
referencePixelX0: region.ReferencePixelX0,
referencePixelY0: region.ReferencePixelY0,
referencePixelPhysicalValueX: region.ReferencePixelPhysicalValueX,
referencePixelPhysicalValueY: region.ReferencePixelPhysicalValueY,
physicalUnitsXDirection: region.PhysicalUnitsXDirection,
physicalUnitsYDirection: region.PhysicalUnitsYDirection,
physicalDeltaX: region.PhysicalDeltaX,
physicalDeltaY: region.PhysicalDeltaY,
};
}),
};
break;
/**
* Below are the tags and not the modules since they are not really
* consistent with the modules above
*/
case 'temporalPositionIdentifier':
metadata = {
temporalPositionIdentifier: instance.TemporalPositionIdentifier,
};
break;
default:
return;
}
return metadata;
}
/**
* Retrieves the frameNumber information, depending on the url style
* wadors /frames/1
* wadouri &frame=1
* @param {*} imageId
* @returns
*/
getFrameInformationFromURL(imageId) {
function getInformationFromURL(informationString, separator) {
let result = '';
const splittedStr = imageId.split(informationString)[1];
if (splittedStr.includes(separator)) {
result = splittedStr.split(separator)[0];
} else {
result = splittedStr;
}
return result;
}
if (imageId.includes('/frames')) {
return getInformationFromURL('/frames', '/');
}
if (imageId.includes('&frame=')) {
return getInformationFromURL('&frame=', '&');
}
return;
}
getUIDsFromImageID(imageId) {
if (!imageId) {
throw new Error('MetadataProvider::Empty imageId');
}
// TODO: adding csiv here is not really correct. Probably need to use
// metadataProvider.addImageIdToUIDs(imageId, {
// StudyInstanceUID,
// SeriesInstanceUID,
// SOPInstanceUID,
// })
// somewhere else
const cachedUIDs = this.imageUIDsByImageId.get(imageId);
if (cachedUIDs) {
return cachedUIDs;
}
if (imageId.startsWith('wadors:')) {
const strippedImageId = imageId.split('/studies/')[1];
const splitImageId = strippedImageId.split('/');
return {
StudyInstanceUID: splitImageId[0], // Note: splitImageId[1] === 'series'
SeriesInstanceUID: splitImageId[2], // Note: splitImageId[3] === 'instances'
SOPInstanceUID: splitImageId[4],
frameNumber: splitImageId[6],
};
} else if (imageId.includes('?requestType=WADO')) {
const qs = queryString.parse(imageId);
return {
StudyInstanceUID: qs.studyUID,
SeriesInstanceUID: qs.seriesUID,
SOPInstanceUID: qs.objectUID,
frameNumber: qs.frameNumber,
};
}
// Maybe its a non-standard imageId
// check if the imageId starts with http:// or https:// using regex
// Todo: handle non http imageIds
let imageURI;
const urlRegex = /^(http|https|dicomfile):\/\//;
if (urlRegex.test(imageId)) {
imageURI = imageId;
} else {
imageURI = imageIdToURI(imageId);
}
// remove &frame=number from imageId
imageURI = imageURI.split('&frame=')[0];
const uids = this.imageURIToUIDs.get(imageURI);
const frameNumber = this.getFrameInformationFromURL(imageId) || '1';
if (uids && frameNumber !== undefined) {
return { ...uids, frameNumber };
}
return uids;
}
}
const metadataProvider = new MetadataProvider();
export default metadataProvider;
const WADO_IMAGE_LOADER = {
imagePlaneModule: instance => {
const { ImageOrientationPatient } = instance;
// Fallback for DX images.
// TODO: We should use the rest of the results of this function
// to update the UI somehow
const { PixelSpacing } = getPixelSpacingInformation(instance);
let rowPixelSpacing;
let columnPixelSpacing;
let rowCosines;
let columnCosines;
if (PixelSpacing) {
rowPixelSpacing = PixelSpacing[0];
columnPixelSpacing = PixelSpacing[1];
}
if (ImageOrientationPatient) {
rowCosines = ImageOrientationPatient.slice(0, 3);
columnCosines = ImageOrientationPatient.slice(3, 6);
}
return {
frameOfReferenceUID: instance.FrameOfReferenceUID,
rows: toNumber(instance.Rows),
columns: toNumber(instance.Columns),
imageOrientationPatient: toNumber(ImageOrientationPatient) || [0, 1, 0, 0, 0, -1],
rowCosines: toNumber(rowCosines || [0, 1, 0]),
isDefaultValueSetForRowCosine: toNumber(rowCosines) ? false : true,
columnCosines: toNumber(columnCosines || [0, 0, -1]),
isDefaultValueSetForColumnCosine: toNumber(columnCosines) ? false : true,
imagePositionPatient: toNumber(instance.ImagePositionPatient || [0, 0, 0]),
sliceThickness: toNumber(instance.SliceThickness),
sliceLocation: toNumber(instance.SliceLocation),
pixelSpacing: toNumber(PixelSpacing || 1),
rowPixelSpacing: rowPixelSpacing ? toNumber(rowPixelSpacing) : null,
columnPixelSpacing: columnPixelSpacing ? toNumber(columnPixelSpacing) : null,
};
},
};
const WADO_IMAGE_LOADER_TAGS = {
// dicomImageLoader specific
GENERAL_SERIES_MODULE: 'generalSeriesModule',
PATIENT_STUDY_MODULE: 'patientStudyModule',
IMAGE_PIXEL_MODULE: 'imagePixelModule',
VOI_LUT_MODULE: 'voiLutModule',
MODALITY_LUT_MODULE: 'modalityLutModule',
SOP_COMMON_MODULE: 'sopCommonModule',
PET_IMAGE_MODULE: 'petImageModule',
PET_ISOTOPE_MODULE: 'petIsotopeModule',
PER_SERIES_MODULE: 'petSeriesModule',
OVERLAY_PLANE_MODULE: 'overlayPlaneModule',
PATIENT_DEMOGRAPHIC_MODULE: 'patientDemographicModule',
// react-cornerstone-viewport specific
PATIENT_MODULE: 'patientModule',
GENERAL_IMAGE_MODULE: 'generalImageModule',
GENERAL_STUDY_MODULE: 'generalStudyModule',
CINE_MODULE: 'cineModule',
CALIBRATION_MODULE: 'calibrationModule',
};
const INSTANCE = 'instance';

View File

@@ -0,0 +1,15 @@
import CommandsManager from './CommandsManager';
import HotkeysManager from './HotkeysManager';
import ImageSet from './ImageSet';
import MetadataProvider from './MetadataProvider';
export { MetadataProvider, CommandsManager, HotkeysManager, ImageSet };
const classes = {
MetadataProvider,
CommandsManager,
HotkeysManager,
ImageSet,
};
export default classes;

View File

@@ -0,0 +1,203 @@
import windowLevelPresets from './windowLevelPresets';
/*
* Supported Keys: https://craig.is/killing/mice
*/
const bindings = [
{
commandName: 'setToolActive',
commandOptions: { toolName: 'Zoom' },
label: 'Zoom',
keys: ['z'],
isEditable: true,
},
{
commandName: 'scaleUpViewport',
label: 'Zoom In',
keys: ['+'],
isEditable: true,
},
{
commandName: 'scaleDownViewport',
label: 'Zoom Out',
keys: ['-'],
isEditable: true,
},
{
commandName: 'fitViewportToWindow',
label: 'Zoom to Fit',
keys: ['='],
isEditable: true,
},
{
commandName: 'rotateViewportCW',
label: 'Rotate Right',
keys: ['r'],
isEditable: true,
},
{
commandName: 'rotateViewportCCW',
label: 'Rotate Left',
keys: ['l'],
isEditable: true,
},
{
commandName: 'flipViewportHorizontal',
label: 'Flip Horizontally',
keys: ['h'],
isEditable: true,
},
{
commandName: 'flipViewportVertical',
label: 'Flip Vertically',
keys: ['v'],
isEditable: true,
},
{
commandName: 'toggleCine',
label: 'Cine',
keys: ['c'],
},
{
commandName: 'invertViewport',
label: 'Invert',
keys: ['i'],
isEditable: true,
},
{
commandName: 'incrementActiveViewport',
label: 'Next Image Viewport',
keys: ['right'],
isEditable: true,
},
{
commandName: 'decrementActiveViewport',
label: 'Previous Image Viewport',
keys: ['left'],
isEditable: true,
},
{
commandName: 'updateViewportDisplaySet',
commandOptions: {
direction: -1,
},
label: 'Previous Series',
keys: ['pageup'],
isEditable: true,
},
{
commandName: 'updateViewportDisplaySet',
commandOptions: {
direction: 1,
},
label: 'Next Series',
keys: ['pagedown'],
isEditable: true,
},
{
commandName: 'nextStage',
context: 'DEFAULT',
label: 'Next Stage',
keys: ['.'],
isEditable: true,
},
{
commandName: 'previousStage',
context: 'DEFAULT',
label: 'Previous Stage',
keys: [','],
isEditable: true,
},
{
commandName: 'nextImage',
label: 'Next Image',
keys: ['down'],
isEditable: true,
},
{
commandName: 'previousImage',
label: 'Previous Image',
keys: ['up'],
isEditable: true,
},
{
commandName: 'firstImage',
label: 'First Image',
keys: ['home'],
isEditable: true,
},
{
commandName: 'lastImage',
label: 'Last Image',
keys: ['end'],
isEditable: true,
},
{
commandName: 'resetViewport',
label: 'Reset',
keys: ['space'],
isEditable: true,
},
{
commandName: 'cancelMeasurement',
label: 'Cancel Cornerstone Measurement',
keys: ['esc'],
},
{
commandName: 'setWindowLevel',
commandOptions: windowLevelPresets[1],
label: 'W/L Preset 1',
keys: ['1'],
},
{
commandName: 'setWindowLevel',
commandOptions: windowLevelPresets[2],
label: 'W/L Preset 2',
keys: ['2'],
},
{
commandName: 'setWindowLevel',
commandOptions: windowLevelPresets[3],
label: 'W/L Preset 3',
keys: ['3'],
},
{
commandName: 'setWindowLevel',
commandOptions: windowLevelPresets[4],
label: 'W/L Preset 4',
keys: ['4'],
},
{
commandName: 'setWindowLevel',
commandOptions: windowLevelPresets[5],
label: 'W/L Preset 5',
keys: ['5'],
},
// These don't exist, so don't try applying them....
// {
// commandName: 'setWindowLevel',
// commandOptions: windowLevelPresets[6],
// label: 'W/L Preset 6',
// keys: ['6'],
// },
// {
// commandName: 'setWindowLevel',
// commandOptions: windowLevelPresets[7],
// label: 'W/L Preset 7',
// keys: ['7'],
// },
// {
// commandName: 'setWindowLevel',
// commandOptions: windowLevelPresets[8],
// label: 'W/L Preset 8',
// keys: ['8'],
// },
// {
// commandName: 'setWindowLevel',
// commandOptions: windowLevelPresets[9],
// label: 'W/L Preset 9',
// keys: ['9'],
// },
];
export default bindings;

View File

@@ -0,0 +1,4 @@
import hotkeyBindings from './hotkeyBindings';
import windowLevelPresets from './windowLevelPresets';
export { hotkeyBindings, windowLevelPresets };
export default { hotkeyBindings, windowLevelPresets };

View File

@@ -0,0 +1,12 @@
export default {
1: { description: 'Soft tissue', window: '400', level: '40' },
2: { description: 'Lung', window: '1500', level: '-600' },
3: { description: 'Liver', window: '150', level: '90' },
4: { description: 'Bone', window: '2500', level: '480' },
5: { description: 'Brain', window: '80', level: '40' },
6: { description: 'Trest', window: '1', level: '1' },
7: { description: 'Empty1', window: 'Empty1', level: 'Empty1' },
8: { description: 'Empty2', window: 'Empty2', level: 'Empty2' },
9: { description: 'Empty3', window: 'Empty3', level: 'Empty3' },
10: { description: 'Empty4', window: 'Empty4', level: 'Empty4' },
};

View File

@@ -0,0 +1,24 @@
export enum TimingEnum {
// The time from when the users selects a study until the study metadata
// is loaded (and the display sets are ready)
STUDY_TO_DISPLAY_SETS = 'studyToDisplaySetsLoaded',
// The time from when the user selects a study until any viewport renders
STUDY_TO_FIRST_IMAGE = 'studyToFirstImage',
// The time from when display sets are loaded until any viewport renders
// an image.
DISPLAY_SETS_TO_FIRST_IMAGE = 'displaySetsToFirstImage',
// The time from when display sets are loaded until all viewports have images
DISPLAY_SETS_TO_ALL_IMAGES = 'displaySetsToAllImages',
// The time from when the user hits search until the worklist is displayed
SEARCH_TO_LIST = 'searchToList',
// The time from when the html script first starts being evaluated (before
// any other scripts or CSS is loaded), until the time that the first image
// is viewed for viewer endpoints, or the time that the first search for studies
// completes.
SCRIPT_TO_VIEW = 'scriptToView',
}

View File

@@ -0,0 +1,3 @@
import { TimingEnum } from './TimingEnum';
export { TimingEnum };

View File

@@ -0,0 +1,6 @@
// These should be overridden by the implementation
const errorHandler = {
getHTTPErrorHandler: () => null,
};
export default errorHandler;

View File

@@ -0,0 +1,280 @@
import ExtensionManager from './ExtensionManager';
import MODULE_TYPES from './MODULE_TYPES';
import log from './../log.js';
jest.mock('./../log.js');
describe('ExtensionManager.ts', () => {
let extensionManager, commandsManager, servicesManager, appConfig;
beforeEach(() => {
commandsManager = {
createContext: jest.fn(),
getContext: jest.fn(),
registerCommand: jest.fn(),
};
servicesManager = {
registerService: jest.fn(),
services: {
// Required for DataSource Module initiation
UserAuthenticationService: jest.fn(),
HangingProtocolService: {
addProtocol: jest.fn(),
},
},
};
appConfig = {
testing: true,
};
extensionManager = new ExtensionManager({
servicesManager,
commandsManager,
appConfig,
});
log.warn.mockClear();
jest.clearAllMocks();
});
it('creates a module namespace for each module type', () => {
const moduleKeys = Object.keys(extensionManager.modules);
const moduleTypeValues = Object.values(MODULE_TYPES);
expect(moduleKeys.sort()).toEqual(moduleTypeValues.sort());
});
describe('registerExtensions()', () => {
it('calls registerExtension() for each extension', async () => {
extensionManager.registerExtension = jest.fn();
// SUT
const fakeExtensions = [{ one: '1' }, { two: '2' }, { three: '3 ' }];
await extensionManager.registerExtensions(fakeExtensions);
// Assert
expect(extensionManager.registerExtension.mock.calls.length).toBe(3);
});
it('calls registerExtension() for each extension passing its configuration if tuple', async () => {
const fakeConfiguration = { testing: true };
extensionManager.registerExtension = jest.fn();
// SUT
const fakeExtensions = [{ one: '1' }, [{ two: '2' }, fakeConfiguration], { three: '3 ' }];
await extensionManager.registerExtensions(fakeExtensions);
// Assert
expect(extensionManager.registerExtension.mock.calls[1][1]).toEqual(fakeConfiguration);
});
});
describe('registerExtension()', () => {
it('calls preRegistration() for extension', () => {
// SUT
const fakeExtension = { id: '1', preRegistration: jest.fn() };
extensionManager.registerExtension(fakeExtension);
// Assert
expect(fakeExtension.preRegistration.mock.calls.length).toBe(1);
});
it('calls preRegistration() passing dependencies and extension configuration to extension', () => {
const extensionConfiguration = { config: 'Some configuration' };
// SUT
const extension = { id: '1', preRegistration: jest.fn() };
extensionManager.registerExtension(extension, extensionConfiguration);
// Assert
expect(extension.preRegistration.mock.calls[0][0]).toEqual({
servicesManager,
commandsManager,
extensionManager,
appConfig,
configuration: extensionConfiguration,
});
});
it('logs a warning if the extension is null or undefined', async () => {
const undefinedExtension = undefined;
const nullExtension = null;
await expect(extensionManager.registerExtension(undefinedExtension)).rejects.toThrow(
new Error('Attempting to register a null/undefined extension.')
);
await expect(extensionManager.registerExtension(nullExtension)).rejects.toThrow(
new Error('Attempting to register a null/undefined extension.')
);
});
it('logs a warning if the extension does not have an id', async () => {
const extensionWithoutId = {};
await expect(extensionManager.registerExtension(extensionWithoutId)).rejects.toThrow(
new Error('Extension ID not set')
);
});
it('tracks which extensions have been registered', () => {
const extension = {
id: 'hello-world',
};
extensionManager.registerExtension(extension);
expect(extensionManager.registeredExtensionIds).toContain(extension.id);
});
it('logs a warning if the extension has an id that has already been registered', () => {
const extension = { id: 'hello-world' };
extensionManager.registerExtension(extension);
// SUT
extensionManager.registerExtension(extension);
expect(log.warn.mock.calls.length).toBe(1);
});
it('logs a warning if a defined module returns null or undefined', () => {
const extensionWithBadModule = {
id: 'hello-world',
getViewportModule: () => {
return null;
},
};
extensionManager.registerExtension(extensionWithBadModule);
expect(log.warn.mock.calls.length).toBe(1);
expect(log.warn.mock.calls[0][0]).toContain('Null or undefined returned when registering');
});
it('logs an error if an exception is thrown while retrieving a module', async () => {
const extensionWithBadModule = {
id: 'hello-world',
getViewportModule: () => {
throw new Error('Hello World');
},
};
await expect(extensionManager.registerExtension(extensionWithBadModule)).rejects.toThrow();
});
it('successfully passes dependencies to each module along with extension configuration', () => {
const extensionConfiguration = { testing: true };
const extension = {
id: 'hello-world',
getViewportModule: jest.fn(),
getSopClassHandlerModule: jest.fn(),
getPanelModule: jest.fn(),
getToolbarModule: jest.fn(),
getCommandsModule: jest.fn(),
};
extensionManager.registerExtension(extension, extensionConfiguration);
Object.keys(extension).forEach(module => {
if (typeof extension[module] === 'function') {
expect(extension[module].mock.calls[0][0]).toEqual({
servicesManager,
commandsManager,
hotkeysManager: undefined,
appConfig,
configuration: extensionConfiguration,
extensionManager,
});
}
});
});
it('successfully registers a module for each module type', async () => {
const extension = {
id: 'hello-world',
getViewportModule: () => {
return [{ name: 'test' }];
},
getSopClassHandlerModule: () => {
return [{ name: 'test' }];
},
getPanelModule: () => {
return [{ name: 'test' }];
},
getToolbarModule: () => {
return [{ name: 'test' }];
},
getCommandsModule: () => {
return [{ name: 'test' }];
},
getLayoutTemplateModule: () => {
return [{ name: 'test' }];
},
getDataSourcesModule: () => {
return [{ name: 'test' }];
},
getHangingProtocolModule: () => {
return [{ name: 'test' }];
},
getContextModule: () => {
return [{ name: 'test' }];
},
getUtilityModule: () => {
return [{ name: 'test' }];
},
getCustomizationModule: () => {
return [{ name: 'test' }];
},
getStateSyncModule: () => {
return [{ name: 'test' }];
},
};
await extensionManager.registerExtension(extension);
// Registers 1 module per module type
Object.keys(extensionManager.modules).forEach(moduleType => {
const modulesForType = extensionManager.modules[moduleType];
console.log('moduleType', moduleType);
expect(modulesForType.length).toBe(1);
});
});
it('calls commandsManager.registerCommand for each commandsModule command definition', () => {
const extension = {
id: 'hello-world',
getCommandsModule: () => {
return {
definitions: {
exampleDefinition: {
commandFn: () => {},
options: {},
},
},
};
},
};
// SUT
extensionManager.registerExtension(extension);
expect(commandsManager.registerCommand.mock.calls.length).toBe(1);
});
it('logs a warning if the commandsModule contains no command definitions', () => {
const extension = {
id: 'hello-world',
getCommandsModule: () => {
return {};
},
};
// SUT
extensionManager.registerExtension(extension);
expect(log.warn.mock.calls.length).toBe(1);
expect(log.warn.mock.calls[0][0]).toContain(
'Commands Module contains no command definitions'
);
});
});
});

View File

@@ -0,0 +1,626 @@
import MODULE_TYPES from './MODULE_TYPES';
import log from '../log';
import { PubSubService, ServiceProvidersManager } from '../services';
import { HotkeysManager, CommandsManager } from '../classes';
import type { DataSourceDefinition } from '../types';
import type AppTypes from '../types/AppTypes';
/**
* This is the arguments given to create the extension.
*/
export interface ExtensionConstructor {
servicesManager: AppTypes.ServicesManager;
serviceProvidersManager: ServiceProvidersManager;
commandsManager: CommandsManager;
hotkeysManager: HotkeysManager;
appConfig: AppTypes.Config;
}
/**
* The configuration of an extension.
* This uses type as the extension manager only knows that the configuration
* is an object of some sort, and doesn't know anything else about it.
*/
export type ExtensionConfiguration = Record<string, unknown>;
/**
* The parameters passed to the extension.
*/
export interface ExtensionParams extends ExtensionConstructor {
extensionManager: ExtensionManager;
servicesManager: AppTypes.ServicesManager;
serviceProvidersManager: ServiceProvidersManager;
configuration?: ExtensionConfiguration;
peerImport: (moduleId: string) => Promise<any>;
}
/**
* The type of an actual extension instance.
* This is an interface as it declares possible calls, but extensions can
* have more values than this.
*/
export interface Extension {
id: string;
preRegistration?: (p: ExtensionParams) => Promise<void> | void;
getHangingProtocolModule?: (p: ExtensionParams) => unknown;
getCommandsModule?: (p: ExtensionParams) => CommandsModule;
getViewportModule?: (p: ExtensionParams) => unknown;
getUtilityModule?: (p: ExtensionParams) => unknown;
getCustomizationModule?: (p: ExtensionParams) => unknown;
getSopClassHandlerModule?: (p: ExtensionParams) => unknown;
getToolbarModule?: (p: ExtensionParams) => unknown;
getPanelModule?: (p: ExtensionParams) => unknown;
onModeEnter?: (p: AppTypes) => void;
onModeExit?: (p: AppTypes) => void;
}
export type ExtensionRegister = {
id: string;
create: (p: ExtensionParams) => Extension;
};
export type CommandsModule = {
actions: Record<string, unknown>;
definitions: Record<string, unknown>;
defaultContext?: string;
};
export default class ExtensionManager extends PubSubService {
public static readonly EVENTS = {
ACTIVE_DATA_SOURCE_CHANGED: 'event::activedatasourcechanged',
};
public static readonly MODULE_TYPES = MODULE_TYPES;
private _commandsManager: CommandsManager;
private _servicesManager: AppTypes.ServicesManager;
private _hotkeysManager: HotkeysManager;
private _serviceProvidersManager: ServiceProvidersManager;
private modulesMap: Record<string, unknown>;
private modules: Record<string, any[]>;
private registeredExtensionIds: string[];
private moduleTypeNames: string[];
private _appConfig: any;
private _extensionLifeCycleHooks: {
onModeEnter: Record<string, any>;
onModeExit: Record<string, any>;
};
private dataSourceMap: Record<string, any>;
private dataSourceDefs: Record<string, any>;
private defaultDataSourceName: string;
private activeDataSource: string;
private peerImport: (moduleId) => Promise<any>;
constructor({
commandsManager,
servicesManager,
serviceProvidersManager,
hotkeysManager,
appConfig = {},
}: ExtensionConstructor) {
super(ExtensionManager.EVENTS);
this.modules = {};
this.registeredExtensionIds = [];
this.moduleTypeNames = Object.values(MODULE_TYPES);
//
this._commandsManager = commandsManager;
this._servicesManager = servicesManager;
this._serviceProvidersManager = serviceProvidersManager;
this._hotkeysManager = hotkeysManager;
this._appConfig = appConfig;
this.modulesMap = {};
this.moduleTypeNames.forEach(moduleType => {
this.modules[moduleType] = [];
});
this._extensionLifeCycleHooks = { onModeEnter: {}, onModeExit: {} };
this.dataSourceMap = {};
this.dataSourceDefs = {};
this.defaultDataSourceName = appConfig.defaultDataSourceName;
this.activeDataSource = appConfig.defaultDataSourceName;
this.peerImport = appConfig.peerImport;
}
public setActiveDataSource(dataSource: string): void {
if (this.activeDataSource === dataSource) {
return;
}
this.activeDataSource = dataSource;
this._broadcastEvent(
ExtensionManager.EVENTS.ACTIVE_DATA_SOURCE_CHANGED,
this.dataSourceDefs[this.activeDataSource]
);
}
public getRegisteredExtensionIds() {
return [...this.registeredExtensionIds];
}
private getUniqueServicesList(servicesManager: AppTypes.ServicesManager) {
// Make sure only one service instance is returned because almost all services are
// registered with different keys (eg: StudyPrefetcherService and studyPrefetcherService)
return Array.from(new Set(Object.values(servicesManager.services)));
}
/**
* Calls all the services and extension on mode enters.
* The service onModeEnter is called first
* Then registered extensions onModeEnter is called
* This is supposed to setup the extension for a standard entry.
*/
public onModeEnter(): void {
const {
registeredExtensionIds,
_servicesManager,
_commandsManager,
_hotkeysManager,
_extensionLifeCycleHooks,
} = this;
const services = this.getUniqueServicesList(_servicesManager);
// The onModeEnter of the service must occur BEFORE the extension
// onModeEnter in order to reset the state to a standard state
// before the extension restores and cached data.
for (const service of services) {
service?.onModeEnter?.();
}
registeredExtensionIds.forEach(extensionId => {
const onModeEnter = _extensionLifeCycleHooks.onModeEnter[extensionId];
if (typeof onModeEnter === 'function') {
onModeEnter({
servicesManager: _servicesManager,
commandsManager: _commandsManager,
hotkeysManager: _hotkeysManager,
});
}
});
}
public onModeExit(): void {
const { registeredExtensionIds, _servicesManager, _commandsManager, _extensionLifeCycleHooks } =
this;
const services = this.getUniqueServicesList(_servicesManager);
registeredExtensionIds.forEach(extensionId => {
const onModeExit = _extensionLifeCycleHooks.onModeExit[extensionId];
if (typeof onModeExit === 'function') {
onModeExit({
servicesManager: _servicesManager,
commandsManager: _commandsManager,
});
}
});
// The service onModeExit calls must occur after the extension ones
// so that extension ones can store/restore data.
for (const service of services) {
try {
service?.onModeExit?.();
} catch (e) {
console.warn('onModeExit caught', e);
}
}
}
/**
* An array of extensions, or an array of arrays that contains extension
* configuration pairs.
*
* @param {Object[]} extensions - Array of extensions
*/
public registerExtensions = async (
extensions: (ExtensionRegister | [ExtensionRegister, ExtensionConfiguration])[],
dataSources: unknown[] = []
): Promise<void> => {
// Todo: we ideally should be able to run registrations in parallel
// but currently since some extensions need to be registered before
// others, we need to run them sequentially. We need a postInit hook
// to avoid this sequential async registration
for (let i = 0; i < extensions.length; i++) {
const extension = extensions[i];
const hasConfiguration = Array.isArray(extension);
try {
if (hasConfiguration) {
// Important: for some reason in the line below the type
// of extension is not recognized as [ExtensionRegister,
// ExtensionConfiguration] by babel DON"T CHANGE IT
// Same for the for loop above don't use
// for (const extension of extensions)
const ohifExtension = extension[0];
const configuration = extension[1];
await this.registerExtension(ohifExtension, configuration, dataSources);
} else {
await this.registerExtension(extension, {}, dataSources);
}
} catch (error) {
console.error(error);
}
}
};
/**
*
* TODO: Id Management: SopClassHandlers currently refer to viewport module by id; setting the extension id as viewport module id is a workaround for now
* @param {Object} extension
* @param {Object} configuration
*/
public registerExtension = async (
extension: ExtensionRegister,
configuration = {},
dataSources = []
): Promise<void> => {
if (!extension) {
throw new Error('Attempting to register a null/undefined extension.');
}
const extensionId = extension.id;
if (!extensionId) {
// Note: Mode framework cannot function without IDs.
log.warn(extension);
throw new Error(`Extension ID not set`);
}
if (this.registeredExtensionIds.includes(extensionId)) {
log.warn(
`Extension ID ${extensionId} has already been registered. Exiting before duplicating modules.`
);
return;
}
// preRegistrationHook
if (extension.preRegistration) {
await extension.preRegistration({
servicesManager: this._servicesManager,
serviceProvidersManager: this._serviceProvidersManager,
commandsManager: this._commandsManager,
hotkeysManager: this._hotkeysManager,
extensionManager: this,
appConfig: this._appConfig,
configuration,
});
}
if (extension.onModeEnter) {
this._extensionLifeCycleHooks.onModeEnter[extensionId] = extension.onModeEnter;
}
if (extension.onModeExit) {
this._extensionLifeCycleHooks.onModeExit[extensionId] = extension.onModeExit;
}
// Register Modules
this.moduleTypeNames.forEach(moduleType => {
const extensionModule = this._getExtensionModule(
moduleType,
extension,
extensionId,
configuration
);
if (!extensionModule) {
return;
}
switch (moduleType) {
case MODULE_TYPES.COMMANDS:
this._initCommandsModule(extensionModule);
break;
case MODULE_TYPES.DATA_SOURCE:
this._initDataSourcesModule(extensionModule, extensionId, dataSources);
break;
case MODULE_TYPES.HANGING_PROTOCOL:
this._initHangingProtocolsModule(extensionModule, extensionId);
break;
case MODULE_TYPES.PANEL:
this._initPanelModule(extensionModule, extensionId);
break;
case MODULE_TYPES.TOOLBAR:
this._initToolbarModule(extensionModule, extensionId);
break;
case MODULE_TYPES.VIEWPORT:
case MODULE_TYPES.SOP_CLASS_HANDLER:
case MODULE_TYPES.CONTEXT:
case MODULE_TYPES.LAYOUT_TEMPLATE:
case MODULE_TYPES.CUSTOMIZATION:
case MODULE_TYPES.STATE_SYNC:
case MODULE_TYPES.UTILITY:
this.processExtensionModule(extensionModule, extensionId, moduleType);
break;
default:
throw new Error(`Module type invalid: ${moduleType}`);
}
this.modules[moduleType].push({
extensionId,
module: extensionModule,
});
});
// Track extension registration
this.registeredExtensionIds.push(extensionId);
};
/**
* Retrieves the module entry associated with the given string entry
* @param stringEntry - The string entry to retrieve the module entry for which is
* in the format of `${extensionId}.${moduleType}.${moduleName}`
* @returns The module entry associated with the given string entry.
*/
getModuleEntry = stringEntry => {
return this.modulesMap[stringEntry];
};
/**
* Retrieves all modules of a given type for all registered extensions.
*
* @param moduleType - The type of modules to retrieve.
* @returns An array of modules of the specified type.
*/
getModulesByType = (moduleType: string) => {
return this.modules[moduleType];
};
getDataSources = dataSourceName => {
if (dataSourceName === undefined) {
// Default to the activeDataSource
dataSourceName = this.activeDataSource;
}
// Note: this currently uses the data source name, which feels weird...
return this.dataSourceMap[dataSourceName];
};
getActiveDataSource = () => {
return this.dataSourceMap[this.activeDataSource];
};
/**
* Gets the data source definition for the given data source name.
* If no data source name is provided, the active data source definition is
* returned.
* @param dataSourceName the data source name
* @returns the data source definition
*/
getDataSourceDefinition = dataSourceName => {
if (dataSourceName === undefined) {
// Default to the activeDataSource
dataSourceName = this.activeDataSource;
}
return this.dataSourceDefs[dataSourceName];
};
/**
* Gets the data source definition for the active data source.
*/
getActiveDataSourceDefinition = () => {
return this.getDataSourceDefinition(this.activeDataSource);
};
/**
* @private
* @param {string} moduleType
* @param {Object} extension
* @param {string} extensionId - Used for logging warnings
*/
_getExtensionModule = (moduleType, extension, extensionId, configuration) => {
const getModuleFnName = 'get' + _capitalizeFirstCharacter(moduleType);
const getModuleFn = extension[getModuleFnName];
if (!getModuleFn) {
return;
}
try {
const extensionModule = extension[getModuleFnName]({
appConfig: this._appConfig,
commandsManager: this._commandsManager,
servicesManager: this._servicesManager,
hotkeysManager: this._hotkeysManager,
extensionManager: this,
configuration,
});
if (!extensionModule) {
log.warn(
`Null or undefined returned when registering the ${getModuleFnName} module for the ${extensionId} extension`
);
}
return extensionModule;
} catch (ex) {
console.error(ex);
throw new Error(
`Exception thrown while trying to call ${getModuleFnName} for the ${extensionId} extension`
);
}
};
_initHangingProtocolsModule = (extensionModule, extensionId) => {
const { hangingProtocolService } = this._servicesManager.services;
extensionModule.forEach(({ name, protocol }) => {
if (protocol) {
// Only auto-register if protocol specified, otherwise let mode register
hangingProtocolService.addProtocol(name, protocol);
}
});
};
_initPanelModule = (extensionModule, extensionId) => {
this.processExtensionModule(extensionModule, extensionId, MODULE_TYPES.PANEL);
};
_initToolbarModule = (extensionModule, extensionId) => {
// check if the toolbar module has a handler function for evaluation of
// the toolbar button state
const { toolbarService } = this._servicesManager.services;
extensionModule.forEach(toolbarButton => {
if (toolbarButton.evaluate) {
toolbarService.registerEvaluateFunction(toolbarButton.name, toolbarButton.evaluate);
}
});
};
/**
* Processes an extension module.
* @param extensionModule - The extension module to process.
* @param extensionId - The ID of the extension.
* @param moduleType - The type of the module.
*/
private processExtensionModule(extensionModule, extensionId: string, moduleType: string) {
extensionModule.forEach(element => {
if (!element.name) {
throw new Error(`Extension ID ${extensionId} module ${moduleType} element has no name`);
}
const id = `${extensionId}.${moduleType}.${element.name}`;
element.id = id;
this.modulesMap[id] = element;
});
}
/**
* Adds the given data source and optionally sets it as the active data source.
* The method does this by first creating the data source.
* @param dataSourceDef the data source definition to be added
* @param activate flag to indicate if the added data source should be set to the active data source
*/
addDataSource(dataSourceDef: DataSourceDefinition, options = { activate: false }) {
const existingDataSource = this.getDataSources(dataSourceDef.sourceName);
if (existingDataSource?.[0]) {
// The data source already exists and cannot be added.
return;
}
this._createDataSourceInstance(dataSourceDef);
if (options.activate) {
this.setActiveDataSource(dataSourceDef.sourceName);
}
}
/**
* Updates the configuration of the given data source name. It first creates a new data source with
* the existing definition and the new configuration passed in.
* @param dataSourceName the name of the data source to update
* @param dataSourceConfiguration the new configuration to update the data source with
*/
updateDataSourceConfiguration(dataSourceName: string, dataSourceConfiguration: any) {
const existingDataSource = this.getDataSources(dataSourceName);
if (!existingDataSource?.[0]) {
// Cannot update a non existent data source.
return;
}
const dataSourceDef = this.dataSourceDefs[dataSourceName];
// Update the configuration.
dataSourceDef.configuration = dataSourceConfiguration;
this._createDataSourceInstance(dataSourceDef);
if (this.activeDataSource === dataSourceName) {
// When the active data source is changed/set, fire an event to indicate that its configuration has changed.
this._broadcastEvent(ExtensionManager.EVENTS.ACTIVE_DATA_SOURCE_CHANGED, dataSourceDef);
}
}
/**
* Creates a data source instance from the given definition. The definition is
* added to dataSourceDefs and the created instance is added to dataSourceMap.
* @param dataSourceDef
* @returns
*/
_createDataSourceInstance(dataSourceDef: DataSourceDefinition) {
const module = this.getModuleEntry(dataSourceDef.namespace);
if (!module) {
return;
}
this.dataSourceDefs[dataSourceDef.sourceName] = dataSourceDef;
const dataSourceInstance = module.createDataSource(
dataSourceDef.configuration,
this._servicesManager,
this
);
this.dataSourceMap[dataSourceDef.sourceName] = [dataSourceInstance];
}
_initDataSourcesModule(
extensionModule,
extensionId,
dataSources: Array<DataSourceDefinition> = []
): void {
extensionModule.forEach(element => {
this.modulesMap[`${extensionId}.${MODULE_TYPES.DATA_SOURCE}.${element.name}`] = element;
});
extensionModule.forEach(element => {
const namespace = `${extensionId}.${MODULE_TYPES.DATA_SOURCE}.${element.name}`;
dataSources.forEach(dataSource => {
if (dataSource.namespace === namespace) {
this.addDataSource(dataSource);
}
});
});
}
/**
*
* @private
* @param {Object[]} commandDefinitions
*/
_initCommandsModule = extensionModule => {
let { definitions, defaultContext } = extensionModule;
if (!definitions || Object.keys(definitions).length === 0) {
log.warn('Commands Module contains no command definitions');
return;
}
defaultContext = defaultContext || 'VIEWER';
if (!this._commandsManager.getContext(defaultContext)) {
this._commandsManager.createContext(defaultContext);
}
Object.keys(definitions).forEach(commandName => {
const commandDefinition = definitions[commandName];
const commandHasContextThatDoesNotExist =
commandDefinition.context && !this._commandsManager.getContext(commandDefinition.context);
if (commandHasContextThatDoesNotExist) {
this._commandsManager.createContext(commandDefinition.context);
}
this._commandsManager.registerCommand(
commandDefinition.context || defaultContext,
commandName,
commandDefinition
);
});
};
public get appConfig() {
return this._appConfig;
}
}
/**
* @private
* @param {string} lower
*/
function _capitalizeFirstCharacter(lower) {
return lower.charAt(0).toUpperCase() + lower.substring(1);
}

View File

@@ -0,0 +1,14 @@
export default {
COMMANDS: 'commandsModule',
CUSTOMIZATION: 'customizationModule',
STATE_SYNC: 'stateSyncModule',
DATA_SOURCE: 'dataSourcesModule',
PANEL: 'panelModule',
SOP_CLASS_HANDLER: 'sopClassHandlerModule',
TOOLBAR: 'toolbarModule',
VIEWPORT: 'viewportModule',
CONTEXT: 'contextModule',
LAYOUT_TEMPLATE: 'layoutTemplateModule',
HANGING_PROTOCOL: 'hangingProtocolModule',
UTILITY: 'utilityModule',
};

View File

@@ -0,0 +1,11 @@
import ExtensionManager from './ExtensionManager';
import MODULE_TYPES from './MODULE_TYPES';
const DEFAULT_EXPORTS = {
ExtensionManager,
MODULE_TYPES,
};
export default DEFAULT_EXPORTS;
export { ExtensionManager, MODULE_TYPES };

View File

@@ -0,0 +1,60 @@
import { useEffect, useState, useCallback } from 'react';
import { DisplaySet } from '../types';
/**
* Hook that listens for changes in the active viewport and its display sets.
* It returns the display sets associated with the active viewport.
*
* @param servicesManager - Services manager instance
* @returns Array of display sets for the active viewport
*/
const useActiveViewportDisplaySets = ({ servicesManager }): DisplaySet[] => {
const [displaySets, setDisplaySets] = useState<DisplaySet[]>([]);
const { displaySetService, viewportGridService } = servicesManager.services;
// Move this function outside useEffect and memoize it
const getDisplaySetsForViewport = useCallback(
(viewportId: string) => {
const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId) || [];
return displaySetUIDs.map(uid => displaySetService.getDisplaySetByUID(uid)).filter(Boolean);
},
[displaySetService, viewportGridService]
);
useEffect(() => {
// Get initial state
const viewportId = viewportGridService.getActiveViewportId();
setDisplaySets(getDisplaySetsForViewport(viewportId));
const handleViewportChange = ({ viewportId }) => {
setDisplaySets(getDisplaySetsForViewport(viewportId));
};
const handleGridStateChange = ({ state }) => {
const activeViewportId = state.activeViewportId;
if (activeViewportId) {
setDisplaySets(getDisplaySetsForViewport(activeViewportId));
}
};
// Subscribe to viewport changes
const subscriptions = [
viewportGridService.subscribe(
viewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED,
handleViewportChange
),
viewportGridService.subscribe(
viewportGridService.EVENTS.GRID_STATE_CHANGED,
handleGridStateChange
),
];
return () => {
subscriptions.forEach(subscription => subscription.unsubscribe());
};
}, [viewportGridService, getDisplaySetsForViewport]); // Only depend on stable references
return displaySets;
};
export default useActiveViewportDisplaySets;

View File

@@ -0,0 +1,58 @@
import { useCallback, useEffect, useState } from 'react';
export function useToolbar({ servicesManager, buttonSection = 'primary' }: withAppTypes) {
const { toolbarService, viewportGridService } = servicesManager.services;
const { EVENTS } = toolbarService;
const [toolbarButtons, setToolbarButtons] = useState(
toolbarService.getButtonSection(buttonSection)
);
// Callback function for handling toolbar interactions
const onInteraction = useCallback(
args => {
const viewportId = viewportGridService.getActiveViewportId();
const refreshProps = {
viewportId,
};
toolbarService.recordInteraction(args, {
refreshProps,
});
},
[toolbarService, viewportGridService]
);
// Effect to handle toolbar modification events
useEffect(() => {
const handleToolbarModified = () => {
setToolbarButtons(toolbarService.getButtonSection(buttonSection));
};
const subs = [EVENTS.TOOL_BAR_MODIFIED, EVENTS.TOOL_BAR_STATE_MODIFIED].map(event => {
return toolbarService.subscribe(event, handleToolbarModified);
});
return () => {
subs.forEach(sub => sub.unsubscribe());
};
}, [toolbarService]);
// Effect to handle active viewportId change event
useEffect(() => {
const events = [
viewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED,
viewportGridService.EVENTS.VIEWPORTS_READY,
];
const subscriptions = events.map(event => {
return viewportGridService.subscribe(event, ({ viewportId }) => {
viewportId = viewportId || viewportGridService.getActiveViewportId();
toolbarService.refreshToolbarState({ viewportId });
});
});
return () => subscriptions.forEach(sub => sub.unsubscribe());
}, [viewportGridService, toolbarService]);
return { toolbarButtons, onInteraction };
}

9
platform/core/src/ie.js Normal file
View File

@@ -0,0 +1,9 @@
import writeScript from './lib/writeScript';
// Check if browser is IE and add the polyfill scripts
if (navigator && /MSIE \d|Trident.*rv:/.test(navigator.userAgent)) {
window.onload = () => {
// Fix SVG+USE issues by calling the SVG polyfill
writeScript('svgxuse.min.js');
};
}

142
platform/core/src/index.ts Normal file
View File

@@ -0,0 +1,142 @@
import { ExtensionManager, MODULE_TYPES } from './extensions';
import { ServiceProvidersManager, ServicesManager } from './services';
import classes, { CommandsManager, HotkeysManager } from './classes';
import DICOMWeb from './DICOMWeb';
import errorHandler from './errorHandler.js';
import log from './log.js';
import object from './object.js';
import string from './string.js';
import user from './user.js';
import utils from './utils';
import defaults from './defaults';
import * as Types from './types';
import * as Enums from './enums';
import { useToolbar } from './hooks/useToolbar';
import {
CineService,
UIDialogService,
UIModalService,
UINotificationService,
UIViewportDialogService,
//
DicomMetadataStore,
DisplaySetService,
ToolbarService,
MeasurementService,
ViewportGridService,
HangingProtocolService,
pubSubServiceInterface,
PubSubService,
UserAuthenticationService,
CustomizationService,
PanelService,
WorkflowStepsService,
StudyPrefetcherService,
} from './services';
import { DisplaySetMessage, DisplaySetMessageList } from './services/DisplaySetService';
import IWebApiDataSource from './DataSources/IWebApiDataSource';
import useActiveViewportDisplaySets from './hooks/useActiveViewportDisplaySets';
const hotkeys = {
...utils.hotkeys,
defaults: { hotkeyBindings: defaults.hotkeyBindings },
};
const OHIF = {
MODULE_TYPES,
//
CommandsManager,
ExtensionManager,
HotkeysManager,
ServicesManager,
ServiceProvidersManager,
//
defaults,
utils,
hotkeys,
classes,
string,
user,
errorHandler,
object,
log,
DICOMWeb,
viewer: {},
//
CineService,
CustomizationService,
UIDialogService,
UIModalService,
UINotificationService,
UIViewportDialogService,
DisplaySetService,
MeasurementService,
ToolbarService,
ViewportGridService,
HangingProtocolService,
UserAuthenticationService,
IWebApiDataSource,
DicomMetadataStore,
pubSubServiceInterface,
PubSubService,
PanelService,
useToolbar,
useActiveViewportDisplaySets,
WorkflowStepsService,
StudyPrefetcherService,
};
export {
MODULE_TYPES,
//
CommandsManager,
ExtensionManager,
HotkeysManager,
ServicesManager,
ServiceProvidersManager,
//
defaults,
utils,
hotkeys,
classes,
string,
user,
errorHandler,
object,
log,
DICOMWeb,
//
CineService,
CustomizationService,
UIDialogService,
UIModalService,
UINotificationService,
UIViewportDialogService,
DisplaySetService,
DisplaySetMessage,
DisplaySetMessageList,
MeasurementService,
ToolbarService,
ViewportGridService,
HangingProtocolService,
UserAuthenticationService,
IWebApiDataSource,
DicomMetadataStore,
pubSubServiceInterface,
PubSubService,
Enums,
PanelService,
WorkflowStepsService,
StudyPrefetcherService,
useToolbar,
useActiveViewportDisplaySets,
};
export { OHIF };
export type { Types };
export default OHIF;

28
platform/core/src/log.js Normal file
View File

@@ -0,0 +1,28 @@
const log = {
error: console.error,
warn: console.warn,
info: console.log,
trace: console.trace,
debug: console.debug,
time: key => {
log.timingKeys[key] = true;
console.time(key);
},
timeEnd: key => {
if (!log.timingKeys[key]) {
return;
}
log.timingKeys[key] = false;
console.timeEnd(key);
},
// Store the timing keys to allow knowing whether or not to log events
timingKeys: {
// script time values are added during the index.html initial load,
// before log (this file) is loaded, and the log
// can't depend on the enums, so for this case recreate the string.
// See TimingEnum for details
scriptToView: true,
},
};
export default log;

View File

@@ -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: 'PlanarFreehandROITool',
options: {
measurementTable: {
displayFunction,
},
caseProgress: {
include: true,
evaluate: true,
},
},
};

View File

@@ -0,0 +1,59 @@
// Transforms a shallow object with keys separated by "." into a nested object
function getNestedObject(shallowObject) {
const nestedObject = {};
for (let key in shallowObject) {
if (!shallowObject.hasOwnProperty(key)) {
continue;
}
const value = shallowObject[key];
const propertyArray = key.split('.');
let currentObject = nestedObject;
while (propertyArray.length) {
const currentProperty = propertyArray.shift();
if (!propertyArray.length) {
currentObject[currentProperty] = value;
} else {
if (!currentObject[currentProperty]) {
currentObject[currentProperty] = {};
}
currentObject = currentObject[currentProperty];
}
}
}
return nestedObject;
}
// Transforms a nested object into a shallowObject merging its keys with "." character
function getShallowObject(nestedObject) {
const shallowObject = {};
const putValues = (baseKey, nestedObject, resultObject) => {
for (let key in nestedObject) {
if (!nestedObject.hasOwnProperty(key)) {
continue;
}
let currentKey = baseKey ? `${baseKey}.${key}` : key;
const currentValue = nestedObject[key];
if (typeof currentValue === 'object') {
if (currentValue instanceof Array) {
currentKey += '[]';
}
putValues(currentKey, currentValue, resultObject);
} else {
resultObject[currentKey] = currentValue;
}
}
};
putValues('', nestedObject, shallowObject);
return shallowObject;
}
const object = {
getNestedObject,
getShallowObject,
};
export default object;

View File

@@ -0,0 +1,123 @@
import { PubSubService } from '../_shared/pubSubServiceInterface';
class CineService extends PubSubService {
public static readonly EVENTS = {
CINE_STATE_CHANGED: 'event::cineStateChanged',
};
public static REGISTRATION = {
name: 'cineService',
altName: 'CineService',
create: ({ configuration = {} }) => {
return new CineService();
},
};
serviceImplementation = {};
startedClips = new Map();
closedViewports = new Set();
constructor() {
super(CineService.EVENTS);
this.serviceImplementation = {};
}
public getState() {
return this.serviceImplementation._getState();
}
public setCine({ id, frameRate, isPlaying }) {
return this.serviceImplementation._setCine({ id, frameRate, isPlaying });
}
public setIsCineEnabled(isCineEnabled) {
this.serviceImplementation._setIsCineEnabled(isCineEnabled);
// Todo: for some reason i need to do this setTimeout since the
// reducer state does not get updated right away and if we publish the
// event and we use the cineService.getState() it will return the old state
if (isCineEnabled) {
this.closedViewports.forEach(viewportId => {
this.clearViewportCineClosed(viewportId);
});
}
queueMicrotask(() => {
this._broadcastEvent(this.EVENTS.CINE_STATE_CHANGED, { isCineEnabled });
});
}
public playClip(element, playClipOptions) {
const res = this.serviceImplementation._playClip(element, playClipOptions);
this.startedClips.set(element, playClipOptions);
this._broadcastEvent(this.EVENTS.CINE_STATE_CHANGED, { isPlaying: true });
return res;
}
public stopClip(element, stopClipOptions) {
const res = this.serviceImplementation._stopClip(element, stopClipOptions);
this._broadcastEvent(this.EVENTS.CINE_STATE_CHANGED, { isPlaying: false });
return res;
}
public onModeExit() {
this.setIsCineEnabled(false);
this.startedClips.forEach((value, key) => {
this.stopClip(key, value);
});
}
public getSyncedViewports(viewportId) {
return this.serviceImplementation._getSyncedViewports(viewportId);
}
public setViewportCineClosed(viewportId) {
this.closedViewports.add(viewportId);
}
public isViewportCineClosed(viewportId) {
// Todo: we should move towards per viewport cine closed in next release
return this.closedViewports.size > 0;
}
public clearViewportCineClosed(viewportId) {
this.closedViewports.delete(viewportId);
}
public setServiceImplementation({
getState: getStateImplementation,
setCine: setCineImplementation,
setIsCineEnabled: setIsCineEnabledImplementation,
playClip: playClipImplementation,
stopClip: stopClipImplementation,
getSyncedViewports: getSyncedViewportsImplementation,
}) {
if (getSyncedViewportsImplementation) {
this.serviceImplementation._getSyncedViewports = getSyncedViewportsImplementation;
}
if (getStateImplementation) {
this.serviceImplementation._getState = getStateImplementation;
}
if (setCineImplementation) {
this.serviceImplementation._setCine = setCineImplementation;
}
if (setIsCineEnabledImplementation) {
this.serviceImplementation._setIsCineEnabled = setIsCineEnabledImplementation;
}
if (playClipImplementation) {
this.serviceImplementation._playClip = playClipImplementation;
}
if (stopClipImplementation) {
this.serviceImplementation._stopClip = stopClipImplementation;
}
}
}
export default CineService;

View File

@@ -0,0 +1,2 @@
import CineService from './CineService';
export default CineService;

View File

@@ -0,0 +1,313 @@
import CustomizationService, { CustomizationType, MergeEnum } from './CustomizationService';
import log from '../../log';
jest.mock('../../log.js', () => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}));
const extensionManager = {
registeredExtensionIds: [],
moduleEntries: {},
getRegisteredExtensionIds: () => extensionManager.registeredExtensionIds,
getModuleEntry: function (id) {
return this.moduleEntries[id];
},
};
const commandsManager = {};
const ohifOverlayItem = {
id: 'ohif.overlayItem',
content: function (props) {
return {
label: this.label,
value: props[this.attribute],
ver: 'default',
};
},
};
const testItem = {
id: 'testItem',
customizationType: 'ohif.overlayItem',
attribute: 'testAttribute',
label: 'testItemLabel',
};
describe('CustomizationService.ts', () => {
let customizationService;
let configuration;
beforeEach(() => {
log.warn.mockClear();
jest.clearAllMocks();
configuration = {};
customizationService = new CustomizationService({
configuration,
commandsManager,
});
extensionManager.registeredExtensionIds = [];
extensionManager.moduleEntries = {};
});
describe('init', () => {
it('init succeeds', () => {
customizationService.init(extensionManager);
});
it('configurationRegistered', () => {
configuration.testItem = testItem;
customizationService.init(extensionManager);
expect(customizationService.getGlobalCustomization('testItem')).toBe(testItem);
});
it('defaultRegistered', () => {
extensionManager.registeredExtensionIds.push('@testExtension');
extensionManager.moduleEntries['@testExtension.customizationModule.default'] = {
name: 'default',
value: [testItem],
};
customizationService.init(extensionManager);
expect(customizationService.getGlobalCustomization('testItem')).toBe(testItem);
});
});
describe('customizationType', () => {
it('inherits type', () => {
extensionManager.registeredExtensionIds.push('@testExtension');
extensionManager.moduleEntries['@testExtension.customizationModule.default'] = {
name: 'default',
value: [ohifOverlayItem],
};
configuration.testItem = testItem;
customizationService.init(extensionManager);
const item = customizationService.getGlobalCustomization('testItem');
const props = { testAttribute: 'testAttrValue' };
const result = item.content(props);
expect(result.label).toBe(testItem.label);
expect(result.value).toBe(props.testAttribute);
expect(result.ver).toBe('default');
});
it('inline default inherits type', () => {
extensionManager.registeredExtensionIds.push('@testExtension');
extensionManager.moduleEntries['@testExtension.customizationModule.default'] = {
name: 'default',
value: [ohifOverlayItem],
};
configuration.testItem = testItem;
customizationService.init(extensionManager);
const item = customizationService.getCustomization('testItem2', {
id: 'testItem2',
customizationType: 'ohif.overlayItem',
label: 'otherLabel',
attribute: 'otherAttr',
});
// Customizes the default value, as this is testItem2
const props = { otherAttr: 'other attribute value' };
const result = item.content(props);
expect(result.label).toBe('otherLabel');
expect(result.value).toBe(props.otherAttr);
expect(result.ver).toBe('default');
});
});
describe('mode customization', () => {
it('onModeEnter can add extensions', () => {
extensionManager.registeredExtensionIds.push('@testExtension');
extensionManager.moduleEntries['@testExtension.customizationModule.default'] = {
name: 'default',
value: [ohifOverlayItem],
};
customizationService.init(extensionManager);
expect(customizationService.getModeCustomization('testItem')).toBeUndefined();
customizationService.addModeCustomizations([testItem]);
expect(customizationService.getGlobalCustomization('testItem')).toBeUndefined();
const item = customizationService.getModeCustomization('testItem');
expect(item).not.toBeUndefined();
const props = { testAttribute: 'testAttrValue' };
const result = item.content(props);
expect(result.label).toBe(testItem.label);
expect(result.value).toBe(props.testAttribute);
expect(result.ver).toBe('default');
});
it('global customizations override modes', () => {
extensionManager.registeredExtensionIds.push('@testExtension');
extensionManager.moduleEntries['@testExtension.customizationModule.global'] = {
name: 'default',
value: [ohifOverlayItem],
};
configuration.testItem = testItem;
customizationService.init(extensionManager);
// Add a mode customization that would otherwise fail below
customizationService.addModeCustomizations([{ ...testItem, label: 'other' }]);
const item = customizationService.getModeCustomization('testItem');
const props = { testAttribute: 'testAttrValue' };
const result = item.content(props);
expect(result.label).toBe(testItem.label);
expect(result.value).toBe(props.testAttribute);
});
it('mode customizations override default', () => {
extensionManager.registeredExtensionIds.push('@testExtension');
extensionManager.moduleEntries['@testExtension.customizationModule.default'] = {
name: 'default',
value: [ohifOverlayItem, testItem],
};
customizationService.init(extensionManager);
// Add a mode customization that would otherwise fail below
customizationService.addModeCustomizations([{ ...testItem, label: 'other' }]);
const item = customizationService.getCustomization('testItem');
const props = { testAttribute: 'testAttrValue' };
const result = item.content(props);
expect(result.label).toBe('other');
expect(result.value).toBe(props.testAttribute);
});
});
describe('merge', () => {
it('appends to global configuration', () => {
customizationService.init(extensionManager);
customizationService.setGlobalCustomization('appendSet', {
values: [{ id: 'one' }, { id: 'two' }],
});
const appendSet = customizationService.getCustomization('appendSet');
expect(appendSet.values.length).toBe(2);
customizationService.setGlobalCustomization(
'appendSet',
{
values: [{ id: 'three' }],
},
MergeEnum.Append
);
const appendSet2 = customizationService.getCustomization('appendSet');
expect(appendSet2.values.length).toBe(3);
});
it('appends mode to default without touching default', () => {
customizationService.init(extensionManager);
customizationService.setDefaultCustomization('appendSet', {
values: [{ id: 'one' }, { id: 'two' }],
});
const appendSet = customizationService.get('appendSet');
expect(appendSet.values.length).toBe(2);
customizationService.setModeCustomization(
'appendSet',
{
values: [{ id: 'three' }],
},
MergeEnum.Append
);
expect(appendSet.values.length).toBe(2);
const appendSet2 = customizationService.getModeCustomization('appendSet');
expect(appendSet2.values.length).toBe(3);
});
it('merges values by name/position', () => {
customizationService.init(extensionManager);
customizationService.setDefaultCustomization('appendSet', {
values: [{ id: 'one', obj: { v: '5' }, list: [1, 2, 3] }, { id: 'two' }],
});
const appendSet = customizationService.get('appendSet');
expect(appendSet.values.length).toBe(2);
customizationService.setModeCustomization(
'appendSet',
{
values: [{ id: 'three', obj: { v: 2 }, list: [3, 2, 1, 4] }],
},
MergeEnum.Merge,
);
const appendSet2 = customizationService.get('appendSet');
const [value0] = appendSet2.values;
expect(value0.id).toBe('three');
expect(value0.list).toEqual([3, 2, 1, 4]);
});
it('merges functions', () => {
customizationService.init(extensionManager);
customizationService.setDefaultCustomization('appendSet', {
values: [{ f: () => 0, id: '0' }, { f: () => 5, id: '5' }],
});
const appendSet = customizationService.get('appendSet');
expect(appendSet.values.length).toBe(2);
customizationService.setModeCustomization(
'appendSet',
{
values: [{ f: () => 2, id: '2' }]
},
MergeEnum.Merge,
);
const appendSet2 = customizationService.get('appendSet');
const [value0, value1] = appendSet2.values;
expect(value0.f()).toBe(2);
expect(value1.f()).toBe(5);
});
it('merges list with object', () => {
customizationService.init(extensionManager);
const destination = [
1,
{ id: 'two', value: 2, list: [5, 6], },
{ id: 'three', value: 3 }
];
const source = {
two: { value: 'updated2', list: { 0: 8 } },
1: { extraValue: 2, list: [7], },
1.0001: { id: 'inserted', value: 1.0001 },
'-1': {
value: -3
},
};
customizationService.setDefaultCustomization('appendSet', {
values: destination,
});
customizationService.setModeCustomization('appendSet', {
values: source,
}, MergeEnum.Append);
const { values } = customizationService.getCustomization('appendSet');
const [zero, one, two, three] = values;
expect(zero).toBe(1);
expect(one.value).toBe('updated2');
expect(one.extraValue).toBe(2);
expect(one.list).toEqual([8, 6, 7]);
expect(two.id).toBe('inserted');
expect(three.value).toBe(-3);
});
});
});

View File

@@ -0,0 +1,526 @@
import { mergeWith, cloneDeepWith } from 'lodash';
import { PubSubService } from '../_shared/pubSubServiceInterface';
import type { Customization, NestedStrings } from './types';
import type { CommandsManager } from '../../classes';
import type { ExtensionManager } from '../../extensions';
const EVENTS = {
MODE_CUSTOMIZATION_MODIFIED: 'event::CustomizationService:modeModified',
GLOBAL_CUSTOMIZATION_MODIFIED: 'event::CustomizationService:globalModified',
};
const flattenNestedStrings = (
strs: NestedStrings | string,
ret?: Record<string, string>
): Record<string, string> => {
if (!ret) {
ret = {};
}
if (!strs) {
return ret;
}
if (Array.isArray(strs)) {
for (const val of strs) {
flattenNestedStrings(val, ret);
}
} else {
ret[strs] = strs;
}
return ret;
};
export enum MergeEnum {
/**
* Append values in the nested arrays
*/
Append = 'Append',
/**
* Merge values, replacing arrays
*/
Merge = 'Merge',
/**
* Replace the given value - this is the default
*/
Replace = 'Replace',
}
export enum CustomizationType {
Global = 'Global',
Mode = 'Mode',
Default = 'Default',
}
/**
* The CustomizationService allows for retrieving of custom components
* and configuration for mode and global values.
* The intent of the items is to provide a react component. This can be
* done by straight out providing an entire react component or else can be
* done by configuring a react component, or configuring a part of a react
* component. These are intended to be fairly indistinguishable in use of
* it, although the internals of how that is implemented may need to know
* about the customization service.
*
* A customization value can be:
* 1. React function, taking (React, props) and returning a rendered component
* For example, createLogoComponentFn renders a component logo for display
* 2. Custom UI component configuration, as defined by the component which uses it.
* For example, context menus define a complex structure allowing site-determined
* context menus to be set.
* 3. A string name, being the extension id for retrieving one of the above.
*
* The default values for the extension come from the app_config value 'whiteLabeling',
* The whiteLabelling can have lists of extensions to load for the default global and
* mode extensions. These are:
* 'globalExtensions' which is a list of extension id's to load for global values
* 'modeExtensions' which is a list of extension id's to load for mode values
* They default to the list ['*'] if not otherwise provided, which means to check
* every module for the given id and to load it/add it to the extensions.
*/
export default class CustomizationService extends PubSubService {
public static REGISTRATION = {
name: 'customizationService',
create: ({ configuration = {}, commandsManager }) => {
return new CustomizationService({ configuration, commandsManager });
},
};
commandsManager: CommandsManager;
extensionManager: ExtensionManager;
/**
* mode customizations are changes to the default behaviour which are reset
* every time a new mode is entered. This allows the mode to define custom
* behaviour, and not interfere with other modes.
*/
private modeCustomizations = new Map<string, Customization>();
/**
* global customizations, are customizations which are set as a global default
* This allows changes across the board to be applied, essentially as a priority
* setting.
*/
private globalCustomizations = new Map<string, Customization>();
/**
* Default customizations allow applying default values. The intent is that
* there is only one customization of that type, and it is registered at setup
* time.
*/
private defaultCustomizations = new Map<string, Customization>();
/**
* Has the transformed/final customization value. This avoids needing to
* transform every time a customization is requested.
*/
private transformedCustomizations = new Map<string, Customization>();
configuration: any;
constructor({ configuration, commandsManager }) {
super(EVENTS);
this.commandsManager = commandsManager;
this.configuration = configuration || {};
}
public init(extensionManager: ExtensionManager): void {
this.extensionManager = extensionManager;
// Clear defaults as those are defined by the customization modules
this.defaultCustomizations.clear();
// Clear modes because those are defined in onModeEnter functions.
this.modeCustomizations.clear();
this.initDefaults();
this.addReferences(this.configuration);
}
initDefaults(): void {
this.extensionManager.getRegisteredExtensionIds().forEach(extensionId => {
const keyDefault = `${extensionId}.customizationModule.default`;
const defaultCustomizations = this.findExtensionValue(keyDefault);
if (defaultCustomizations) {
const { value } = defaultCustomizations;
this.addReference(value, CustomizationType.Default);
}
const keyGlobal = `${extensionId}.customizationModule.global`;
const globalCustomizations = this.findExtensionValue(keyGlobal);
if (globalCustomizations) {
const { value } = globalCustomizations;
this.addReference(value, CustomizationType.Global);
}
});
}
findExtensionValue(value: string) {
const entry = this.extensionManager.getModuleEntry(value);
return entry as { value: Customization };
}
public onModeEnter(): void {
super.reset();
const modeCustomizationKeys = Array.from(this.modeCustomizations.keys());
for (const key of modeCustomizationKeys) {
this.transformedCustomizations.delete(key);
}
this.modeCustomizations.clear();
}
public onModeExit(): void {
this.onModeEnter();
}
public getModeCustomizations(): Map<string, Customization> {
return this.modeCustomizations;
}
public setModeCustomization(
customizationId: string,
customization: Customization,
merge = MergeEnum.Merge
): void {
const defaultCustomization = this.defaultCustomizations.get(customizationId);
const modeCustomization = this.modeCustomizations.get(customizationId);
const globCustomization = this.globalCustomizations.get(customizationId);
const sourceCustomization =
modeCustomization ||
(globCustomization && cloneDeepWith(globCustomization, cloneCustomizer)) ||
defaultCustomization ||
{};
// use the source merge type if not provided then fallback to merge
this.modeCustomizations.set(
customizationId,
this.mergeValue(sourceCustomization, customization, sourceCustomization.merge ?? merge)
);
this.transformedCustomizations.clear();
this._broadcastEvent(this.EVENTS.CUSTOMIZATION_MODIFIED, {
buttons: this.modeCustomizations,
button: this.modeCustomizations.get(customizationId),
});
}
/**
* This is the preferred getter for all customizations,
* getting global (priority) customizations first,
* then mode customizations, and finally the default customization.
*
* @param customizationId - the customization id to look for
* @param defaultValue - is the default value to return.
* This value will be assigned as the default customization if there isn't
* currently a default customization, and thus, the first default provided
* will be used as the default - you cannot update this after or have it depend
* on changing values.
* Also, the value returned by the get customization has merges/updates applied,
* and is thus may be modified from the value provided, and may not be the original
* default provided. This allows applying the defaults for things like inheritance.
* @return A customization to use if one is found, or the default customization,
* both enhanced with any customizationType inheritance (see transform)
*/
public getCustomization(customizationId: string, defaultValue?: Customization): Customization {
const transformed = this.transformedCustomizations.get(customizationId);
if (transformed) {
return transformed;
}
if (defaultValue && !this.defaultCustomizations.has(customizationId)) {
this.setDefaultCustomization(customizationId, defaultValue);
}
const customization =
this.globalCustomizations.get(customizationId) ??
this.modeCustomizations.get(customizationId) ??
this.defaultCustomizations.get(customizationId);
const newTransformed = this.transform(customization);
if (newTransformed !== undefined) {
this.transformedCustomizations.set(customizationId, newTransformed);
}
return newTransformed;
}
/** Mode customizations are changes to the behavior of the extensions
* when running in a given mode. Reset clears mode customizations.
*
* Note that global customizations over-ride mode customizations
*
* @param defaultValue to return if no customization specified.
*/
public getModeCustomization = this.getCustomization;
/**
* Returns true if there is a mode customization. Doesn't include defaults, but
* does return global overrides.
*/
public hasModeCustomization(customizationId: string) {
return (
this.globalCustomizations.has(customizationId) || this.modeCustomizations.has(customizationId)
);
}
/**
* get is an alias for getModeCustomization, as it is the generic getter
* which will return both mode and global customizations, and should be
* used generally.
* Note that the second parameter, defaultValue, will be expanded to include
* any customizationType values defined in it, so it is not the same as doing:
* `customizationService.get('key') || defaultValue`
* unless the defaultValue does not contain any customizationType definitions.
*/
public get = this.getModeCustomization;
/**
* Applies any inheritance due to UI Type customization.
* This will look for customizationType in the customization object
* and if that is found, will assign all iterable values from that
* type into the new type, allowing default behaviour to be configured.
*/
public transform(customization: Customization): Customization {
if (!customization) {
return customization;
}
const { customizationType } = customization;
if (!customizationType) {
return customization;
}
const parent = this.getCustomization(customizationType);
const result = parent ? Object.assign(Object.create(parent), customization) : customization;
// Execute an nested type information
return result.transform?.(this) || result;
}
/**
* Helper method to easily add and retrieve customizations
* @param id The unique identifier for the customization
* @param defaultComponent The default component to use if no customization is set
* @param customComponent Optional custom component to set
* @returns The custom component if set, otherwise the default component
*/
public getCustomComponent(
id: string,
defaultComponent: React.ComponentType<any>,
customComponent?: React.ComponentType<any>
) {
const customization = this.getCustomization(id, {
id: `default-${id}`,
content: defaultComponent,
});
if (customComponent) {
this.setModeCustomization(id, { content: customComponent });
}
return customization.content;
}
public addModeCustomizations(modeCustomizations): void {
if (!modeCustomizations) {
return;
}
this.addReferences(modeCustomizations, CustomizationType.Mode);
this._broadcastModeCustomizationModified();
}
_broadcastModeCustomizationModified(): void {
this._broadcastEvent(EVENTS.MODE_CUSTOMIZATION_MODIFIED, {
modeCustomizations: this.modeCustomizations,
globalCustomizations: this.globalCustomizations,
});
}
/** Global customizations are those that affect parts of the GUI other than
* the modes. They include things like settings for the search screen.
* Reset does NOT clear global customizations.
*/
getGlobalCustomization(id: string, defaultValue?: Customization): Customization | void {
return this.transform(
this.globalCustomizations.get(id) ?? this.defaultCustomizations.get(id) ?? defaultValue
);
}
/**
* Performs a merge, creating a new instance value - that is, not referencing
* the old one. This only works if you run once for the merge, so in general,
* the source value should be global, while the appends should be mode based.
* However, you can append to a global value too, as long as you ensure it
* only gets merged once.
*/
private mergeValue(oldValue, newValue, mergeType = MergeEnum.Replace) {
if (mergeType === MergeEnum.Replace) {
return newValue;
}
const returnValue = mergeWith(
{},
oldValue,
newValue,
mergeType === MergeEnum.Append ? appendCustomizer : mergeCustomizer
);
return returnValue;
}
public setGlobalCustomization(id: string, value: Customization, merge = MergeEnum.Replace): void {
const defaultCustomization = this.defaultCustomizations.get(id);
const globCustomization = this.globalCustomizations.get(id);
const sourceCustomization =
(globCustomization && cloneDeepWith(globCustomization, cloneCustomizer)) ||
defaultCustomization ||
{};
this.globalCustomizations.set(
id,
this.mergeValue(sourceCustomization, value, value.merge ?? merge)
);
this.transformedCustomizations.clear();
this._broadcastGlobalCustomizationModified();
}
public setDefaultCustomization(
id: string,
value: Customization,
merge = MergeEnum.Replace
): void {
if (this.defaultCustomizations.has(id)) {
throw new Error(`Trying to update existing default for customization ${id}`);
}
this.transformedCustomizations.clear();
this.defaultCustomizations.set(
id,
this.mergeValue(this.defaultCustomizations.get(id), value, merge)
);
}
protected setConfigGlobalCustomization(configuration: AppConfigCustomization): void {
this.globalCustomizations.clear();
const keys = flattenNestedStrings(configuration.globalCustomizations);
this.readCustomizationTypes(v => keys[v.name] && v.customization, this.globalCustomizations);
// TODO - iterate over customizations, loading them from the extension
// manager.
this._broadcastGlobalCustomizationModified();
}
_broadcastGlobalCustomizationModified(): void {
this._broadcastEvent(EVENTS.GLOBAL_CUSTOMIZATION_MODIFIED, {
modeCustomizations: this.modeCustomizations,
globalCustomizations: this.globalCustomizations,
});
}
/**
* A single reference is either an string to be loaded from a module,
* or a customization itself.
*/
addReference(value?, type = CustomizationType.Global, id?: string, merge?: MergeEnum): void {
if (!value) {
return;
}
if (typeof value === 'string') {
const extensionValue = this.findExtensionValue(value);
// The child of a reference is only a set of references when an array,
// so call the addReference direct. It could be a secondary reference perhaps
this.addReference(extensionValue.value, type, extensionValue.name, extensionValue.merge);
} else if (Array.isArray(value)) {
this.addReferences(value, type);
} else {
const useId = value.id || id;
const setName =
(type === CustomizationType.Global && 'setGlobalCustomization') ||
(type === CustomizationType.Default && 'setDefaultCustomization') ||
'setModeCustomization';
this[setName](useId as string, value, merge);
}
}
/**
* Customizations can be specified as an array of strings or customizations,
* or as an object whose key is the reference id, and the value is the string
* or customization.
*/
addReferences(references?, type = CustomizationType.Global): void {
if (!references) {
return;
}
if (Array.isArray(references)) {
references.forEach(item => {
this.addReference(item, type);
});
} else {
for (const key of Object.keys(references)) {
const value = references[key];
this.addReference(value, type, key);
}
}
}
}
/**
* Custom merging function, to handle merging arrays and copying functions
*/
function appendCustomizer(obj, src) {
if (Array.isArray(obj)) {
const srcArray = Array.isArray(src);
if (srcArray) {
return obj.concat(...src);
}
if (typeof src === 'object') {
const newList = obj.map(value => cloneDeepWith(value, cloneCustomizer));
for (const [key, value] of Object.entries(src)) {
const { position, isMerge } = findPosition(key, value, newList);
if (isMerge) {
if (typeof obj[position] === 'object') {
newList[position] = mergeWith(
Array.isArray(newList[position]) ? [] : {},
newList[position],
value,
appendCustomizer
);
} else {
newList[position] = value;
}
} else {
newList.splice(position, 0, value);
}
}
return newList;
}
return obj.concat(src);
}
return cloneCustomizer(src);
}
function mergeCustomizer(obj, src) {
return cloneCustomizer(src);
}
function findPosition(key, value, newList) {
const numVal = Number(key);
const isNumeric = !isNaN(numVal);
const { length: len } = newList;
if (isNumeric) {
if (newList[numVal < 0 ? numVal + len : numVal]) {
return { isMerge: true, position: (numVal + len) % len };
}
const absPosition = Math.ceil(numVal < 0 ? len + numVal : numVal);
return { isMerge: false, position: Math.min(len, Math.max(absPosition, 0)) };
}
const findIndex = newList.findIndex(it => it.id === key);
if (findIndex !== -1) {
return { isMerge: true, position: findIndex };
}
const { _priority: priority } = value;
if (priority !== undefined) {
if (newList[(priority + len) % len]) {
return { isMerge: true, position: (priority + len) % len };
}
const absPosition = Math.ceil(priority < 0 ? len + priority : priority);
return { isMerge: false, position: Math.min(len, Math.max(absPosition, 0)) };
}
return { isMerge: false, position: len };
}
/**
* Custom cloning function to just copy function reference
*/
function cloneCustomizer(value) {
if (typeof value === 'function') {
return value;
}
}

View File

@@ -0,0 +1,3 @@
import CustomizationService from './CustomizationService';
export default CustomizationService;

View File

@@ -0,0 +1,45 @@
import { Command } from '../../types/Command';
import { ComponentType } from 'react';
export type Obj = Record<string, unknown>;
export interface BaseCustomization extends Obj {
id: string;
customizationType?: string;
description?: string;
label?: string;
commands?: Command[];
content?: (...props: any) => React.JSX.Element;
}
export interface LabelCustomization extends BaseCustomization {
label: string;
}
export interface CodeCustomization extends BaseCustomization {
code: string;
}
export interface CommandCustomization extends BaseCustomization {
commands: Command[];
}
export interface ComponentCustomization extends BaseCustomization {
content: (...props: any) => React.JSX.Element;
}
export type Customization =
| BaseCustomization
| LabelCustomization
| CommandCustomization
| CodeCustomization
| ComponentCustomization;
export default Customization;
export type ComponentReturn = {
component: ComponentType;
props?: Obj;
};
export type NestedStrings = string[] | NestedStrings[];

View File

@@ -0,0 +1,270 @@
import dcmjs from 'dcmjs';
import pubSubServiceInterface from '../_shared/pubSubServiceInterface';
import createStudyMetadata from './createStudyMetadata';
const EVENTS = {
STUDY_ADDED: 'event::dicomMetadataStore:studyAdded',
INSTANCES_ADDED: 'event::dicomMetadataStore:instancesAdded',
SERIES_ADDED: 'event::dicomMetadataStore:seriesAdded',
SERIES_UPDATED: 'event::dicomMetadataStore:seriesUpdated',
};
/**
* @example
* studies: [
* {
* StudyInstanceUID: string,
* isLoaded: boolean,
* series: [
* {
* Modality: string,
* SeriesInstanceUID: string,
* SeriesNumber: number,
* SeriesDescription: string,
* instances: [
* {
* // naturalized instance metadata
* SOPInstanceUID: string,
* SOPClassUID: string,
* Rows: number,
* Columns: number,
* PatientSex: string,
* Modality: string,
* InstanceNumber: string,
* },
* {
* // instance 2
* },
* ],
* },
* {
* // series 2
* },
* ],
* },
* ],
*/
const _model = {
studies: [],
};
function _getStudyInstanceUIDs() {
return _model.studies.map(aStudy => aStudy.StudyInstanceUID);
}
function _getStudy(StudyInstanceUID) {
return _model.studies.find(aStudy => aStudy.StudyInstanceUID === StudyInstanceUID);
}
function _getSeries(StudyInstanceUID, SeriesInstanceUID) {
const study = _getStudy(StudyInstanceUID);
if (!study) {
return;
}
return study.series.find(aSeries => aSeries.SeriesInstanceUID === SeriesInstanceUID);
}
function _getInstance(StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID) {
const series = _getSeries(StudyInstanceUID, SeriesInstanceUID);
if (!series) {
return;
}
return series.getInstance(SOPInstanceUID);
}
function _getInstanceByImageId(imageId) {
for (const study of _model.studies) {
for (const series of study.series) {
for (const instance of series.instances) {
if (instance.imageId === imageId) {
return instance;
}
}
}
}
}
/**
* Update the metadata of a specific series
* @param {*} StudyInstanceUID
* @param {*} SeriesInstanceUID
* @param {*} metadata metadata inform of key value pairs
* @returns
*/
function _updateMetadataForSeries(StudyInstanceUID, SeriesInstanceUID, metadata) {
const study = _getStudy(StudyInstanceUID);
if (!study) {
return;
}
const series = study.series.find(aSeries => aSeries.SeriesInstanceUID === SeriesInstanceUID);
const { instances } = series;
// update all instances metadata for this series with the new metadata
instances.forEach(instance => {
Object.keys(metadata).forEach(key => {
// if metadata[key] is an object, we need to merge it with the existing
// metadata of the instance
if (typeof metadata[key] === 'object') {
instance[key] = { ...instance[key], ...metadata[key] };
}
// otherwise, we just replace the existing metadata with the new one
else {
instance[key] = metadata[key];
}
});
});
// broadcast the series updated event
this._broadcastEvent(EVENTS.SERIES_UPDATED, {
SeriesInstanceUID,
StudyInstanceUID,
madeInClient: true,
});
}
const BaseImplementation = {
EVENTS,
listeners: {},
addInstance(dicomJSONDatasetOrP10ArrayBuffer) {
let dicomJSONDataset;
// If Arraybuffer, parse to DICOMJSON before naturalizing.
if (dicomJSONDatasetOrP10ArrayBuffer instanceof ArrayBuffer) {
const dicomData = dcmjs.data.DicomMessage.readFile(dicomJSONDatasetOrP10ArrayBuffer);
dicomJSONDataset = dicomData.dict;
} else {
dicomJSONDataset = dicomJSONDatasetOrP10ArrayBuffer;
}
let naturalizedDataset;
if (dicomJSONDataset['SeriesInstanceUID'] === undefined) {
naturalizedDataset = dcmjs.data.DicomMetaDictionary.naturalizeDataset(dicomJSONDataset);
} else {
naturalizedDataset = dicomJSONDataset;
}
const { StudyInstanceUID } = naturalizedDataset;
let study = _model.studies.find(study => study.StudyInstanceUID === StudyInstanceUID);
if (!study) {
_model.studies.push(createStudyMetadata(StudyInstanceUID));
study = _model.studies[_model.studies.length - 1];
}
study.addInstanceToSeries(naturalizedDataset);
},
addInstances(instances, madeInClient = false) {
const { StudyInstanceUID, SeriesInstanceUID } = instances[0];
let study = _model.studies.find(study => study.StudyInstanceUID === StudyInstanceUID);
if (!study) {
_model.studies.push(createStudyMetadata(StudyInstanceUID));
study = _model.studies[_model.studies.length - 1];
}
study.addInstancesToSeries(instances);
// Broadcast an event even if we used cached data.
// This is because the mode needs to listen to instances that are added to build up its active displaySets.
// It will see there are cached displaySets and end early if this Series has already been fired in this
// Mode session for some reason.
this._broadcastEvent(EVENTS.INSTANCES_ADDED, {
StudyInstanceUID,
SeriesInstanceUID,
madeInClient,
});
},
updateSeriesMetadata(seriesMetadata) {
const { StudyInstanceUID, SeriesInstanceUID } = seriesMetadata;
const series = _getSeries(StudyInstanceUID, SeriesInstanceUID);
if (!series) {
return;
}
const study = _getStudy(StudyInstanceUID);
if (study) {
study.setSeriesMetadata(SeriesInstanceUID, seriesMetadata);
}
},
addSeriesMetadata(seriesSummaryMetadata, madeInClient = false) {
if (!seriesSummaryMetadata || !seriesSummaryMetadata.length || !seriesSummaryMetadata[0]) {
return;
}
const { StudyInstanceUID } = seriesSummaryMetadata[0];
let study = _getStudy(StudyInstanceUID);
if (!study) {
study = createStudyMetadata(StudyInstanceUID);
// Will typically be undefined with a compliant DICOMweb server, reset later
study.StudyDescription = seriesSummaryMetadata[0].StudyDescription;
seriesSummaryMetadata.forEach(item => {
if (study.ModalitiesInStudy.indexOf(item.Modality) === -1) {
study.ModalitiesInStudy.push(item.Modality);
}
});
study.NumberOfStudyRelatedSeries = seriesSummaryMetadata.length;
_model.studies.push(study);
}
seriesSummaryMetadata.forEach(series => {
const { SeriesInstanceUID } = series;
study.setSeriesMetadata(SeriesInstanceUID, series);
});
this._broadcastEvent(EVENTS.SERIES_ADDED, {
StudyInstanceUID,
seriesSummaryMetadata,
madeInClient,
});
},
addStudy(study) {
const { StudyInstanceUID } = study;
const existingStudy = _model.studies.find(study => study.StudyInstanceUID === StudyInstanceUID);
if (!existingStudy) {
const newStudy = createStudyMetadata(StudyInstanceUID);
newStudy.PatientID = study.PatientID;
newStudy.PatientName = study.PatientName;
newStudy.StudyDate = study.StudyDate;
newStudy.ModalitiesInStudy = study.ModalitiesInStudy;
newStudy.StudyDescription = study.StudyDescription;
newStudy.AccessionNumber = study.AccessionNumber;
newStudy.NumInstances = study.NumInstances; // todo: Correct naming?
_model.studies.push(newStudy);
}
},
getStudyInstanceUIDs: _getStudyInstanceUIDs,
getStudy: _getStudy,
getSeries: _getSeries,
getInstance: _getInstance,
getInstanceByImageId: _getInstanceByImageId,
updateMetadataForSeries: _updateMetadataForSeries,
};
const DicomMetadataStore = Object.assign(
// get study
// iterate over all series
{},
BaseImplementation,
pubSubServiceInterface
);
export { DicomMetadataStore };
export default DicomMetadataStore;

View File

@@ -0,0 +1,27 @@
function createSeriesMetadata(SeriesInstanceUID) {
const instances = [];
const instancesMap = new Map();
return {
SeriesInstanceUID,
instances,
addInstance: function (newInstance) {
this.addInstances([newInstance]);
},
addInstances: function (newInstances) {
for (let i = 0, len = newInstances.length; i < len; i++) {
const instance = newInstances[i];
if (!instancesMap.has(instance.SOPInstanceUID)) {
instancesMap.set(instance.SOPInstanceUID, instance);
instances.push(instance);
}
}
},
getInstance: function (SOPInstanceUID) {
return instancesMap.get(SOPInstanceUID);
},
};
}
export default createSeriesMetadata;

View File

@@ -0,0 +1,49 @@
import createSeriesMetadata from './createSeriesMetadata';
function createStudyMetadata(StudyInstanceUID) {
return {
StudyInstanceUID,
StudyDescription: '',
ModalitiesInStudy: [],
isLoaded: false,
series: [],
/**
* @param {object} instance
*/
addInstanceToSeries: function (instance) {
this.addInstancesToSeries([instance]);
},
/**
* @param {object[]} instances
* @param {string} instances[].SeriesInstanceUID
* @param {string} instances[].StudyDescription
*/
addInstancesToSeries: function (instances) {
const { SeriesInstanceUID } = instances[0];
if (!this.StudyDescription) {
this.StudyDescription = instances[0].StudyDescription;
}
let series = this.series.find(s => s.SeriesInstanceUID === SeriesInstanceUID);
if (!series) {
series = createSeriesMetadata(SeriesInstanceUID);
this.series.push(series);
}
series.addInstances(instances);
},
setSeriesMetadata: function (SeriesInstanceUID, seriesMetadata) {
let existingSeries = this.series.find(s => s.SeriesInstanceUID === SeriesInstanceUID);
if (existingSeries) {
existingSeries = Object.assign(existingSeries, seriesMetadata);
} else {
const series = createSeriesMetadata(SeriesInstanceUID);
this.series.push(Object.assign(series, seriesMetadata));
}
},
};
}
export default createStudyMetadata;

View File

@@ -0,0 +1,4 @@
import DicomMetadataStore from './DicomMetadataStore';
export { DicomMetadataStore };
export default DicomMetadataStore;

View File

@@ -0,0 +1,50 @@
/**
* Defines a displaySet message, that could be any pf the potential problems of a displaySet
*/
class DisplaySetMessage {
id: number;
static CODES = {
NO_VALID_INSTANCES: 1,
NO_POSITION_INFORMATION: 2,
NOT_RECONSTRUCTABLE: 3,
MULTIFRAME_NO_PIXEL_MEASUREMENTS: 4,
MULTIFRAME_NO_ORIENTATION: 5,
MULTIFRAME_NO_POSITION_INFORMATION: 6,
MISSING_FRAMES: 7,
IRREGULAR_SPACING: 8,
INCONSISTENT_DIMENSIONS: 9,
INCONSISTENT_COMPONENTS: 10,
INCONSISTENT_ORIENTATIONS: 11,
INCONSISTENT_POSITION_INFORMATION: 12,
UNSUPPORTED_DISPLAYSET: 13,
};
constructor(id: number) {
this.id = id;
}
}
/**
* Defines a list of displaySet messages
*/
class DisplaySetMessageList {
messages = [];
public addMessage(messageId: number): void {
const message = new DisplaySetMessage(messageId);
this.messages.push(message);
}
public size(): number {
return this.messages.length;
}
public includesMessage(messageId: number): boolean {
return this.messages.some(message => message.id === messageId);
}
public includesAllMessages(messageIdList: number[]): boolean {
return messageIdList.every(messageId => this.include(messageId));
}
}
export { DisplaySetMessage, DisplaySetMessageList };

View File

@@ -0,0 +1,447 @@
import { ExtensionManager } from '../../extensions';
import { DisplaySet, InstanceMetadata } from '../../types';
import { PubSubService } from '../_shared/pubSubServiceInterface';
import EVENTS from './EVENTS';
const displaySetCache = new Map<string, DisplaySet>();
/**
* Filters the instances set by instances not in
* display sets. Done in O(n) time.
*/
const filterInstances = (
instances: InstanceMetadata[],
displaySets: DisplaySet[]
): InstanceMetadata[] => {
const dsInstancesSOP = new Set();
displaySets.forEach(ds => {
const dsInstances = ds.instances;
if (!dsInstances) {
console.warn('No instances in', ds);
} else {
dsInstances.forEach(instance => dsInstancesSOP.add(instance.SOPInstanceUID));
}
});
return instances.filter(instance => !dsInstancesSOP.has(instance.SOPInstanceUID));
};
export default class DisplaySetService extends PubSubService {
public static REGISTRATION = {
altName: 'DisplaySetService',
name: 'displaySetService',
create: ({ configuration = {} }) => {
return new DisplaySetService();
},
};
public activeDisplaySets = [];
public unsupportedSOPClassHandler;
extensionManager: ExtensionManager;
protected activeDisplaySetsMap = new Map<string, DisplaySet>();
// Record if the active display sets changed - used to group change events so
// that fewer events need to be fired when creating multiple display sets
protected activeDisplaySetsChanged = false;
constructor() {
super(EVENTS);
this.unsupportedSOPClassHandler =
'@ohif/extension-default.sopClassHandlerModule.not-supported-display-sets-handler';
}
public init(extensionManager, SOPClassHandlerIds): void {
this.extensionManager = extensionManager;
this.SOPClassHandlerIds = SOPClassHandlerIds;
this.activeDisplaySets = [];
this.activeDisplaySetsMap.clear();
}
_addDisplaySetsToCache(displaySets: DisplaySet[]) {
displaySets.forEach(displaySet => {
displaySetCache.set(displaySet.displaySetInstanceUID, displaySet);
});
}
_addActiveDisplaySets(displaySets: DisplaySet[]) {
const { activeDisplaySets, activeDisplaySetsMap } = this;
displaySets.forEach(displaySet => {
if (!activeDisplaySetsMap.has(displaySet.displaySetInstanceUID)) {
this.activeDisplaySetsChanged = true;
activeDisplaySets.push(displaySet);
activeDisplaySetsMap.set(displaySet.displaySetInstanceUID, displaySet);
}
});
}
/**
* Sets the handler for unsupported sop classes
* @param sopClassHandlerUID
*/
public setUnsuportedSOPClassHandler(sopClassHandler) {
this.unsupportedSOPClassHandler = sopClassHandler;
}
/**
* Adds new display sets directly, as specified.
* Use this function when the display sets are created externally directly
* rather than using the default sop class handlers to create display sets.
*/
public addDisplaySets(...displaySets: DisplaySet[]): string[] {
this._addDisplaySetsToCache(displaySets);
this._addActiveDisplaySets(displaySets);
// The activeDisplaySetsChanged flag is only seen if we add display sets
// so, don't broadcast the change if all the display sets were pre-existing.
this.activeDisplaySetsChanged = false;
this._broadcastEvent(EVENTS.DISPLAY_SETS_ADDED, {
displaySetsAdded: displaySets,
options: { madeInClient: displaySets[0].madeInClient },
});
return displaySets;
}
public getDisplaySetCache(): Map<string, DisplaySet> {
return displaySetCache;
}
public getMostRecentDisplaySet(): DisplaySet {
return this.activeDisplaySets[this.activeDisplaySets.length - 1];
}
public getActiveDisplaySets(): DisplaySet[] {
return this.activeDisplaySets;
}
public getDisplaySetsForSeries = (seriesInstanceUID: string): DisplaySet[] => {
return [...displaySetCache.values()].filter(
displaySet => displaySet.SeriesInstanceUID === seriesInstanceUID
);
};
public getDisplaySetForSOPInstanceUID(
sopInstanceUID: string,
seriesInstanceUID: string,
frameNumber?: number
): DisplaySet {
const displaySets = seriesInstanceUID
? this.getDisplaySetsForSeries(seriesInstanceUID)
: [...this.getDisplaySetCache().values()];
const displaySet = displaySets.find(ds => {
return ds.instances?.some(i => i.SOPInstanceUID === sopInstanceUID);
});
return displaySet;
}
public setDisplaySetMetadataInvalidated(
displaySetInstanceUID: string,
invalidateData = true
): void {
const displaySet = this.getDisplaySetByUID(displaySetInstanceUID);
if (!displaySet) {
return;
}
// broadcast event to update listeners with the new displaySets
this._broadcastEvent(EVENTS.DISPLAY_SET_SERIES_METADATA_INVALIDATED, {
displaySetInstanceUID,
invalidateData,
});
}
public deleteDisplaySet(displaySetInstanceUID) {
if (!displaySetInstanceUID) {
return;
}
const { activeDisplaySets, activeDisplaySetsMap } = this;
const activeDisplaySetsIndex = activeDisplaySets.findIndex(
ds => ds.displaySetInstanceUID === displaySetInstanceUID
);
displaySetCache.delete(displaySetInstanceUID);
activeDisplaySets.splice(activeDisplaySetsIndex, 1);
activeDisplaySetsMap.delete(displaySetInstanceUID);
this._broadcastEvent(EVENTS.DISPLAY_SETS_CHANGED, this.activeDisplaySets);
this._broadcastEvent(EVENTS.DISPLAY_SETS_REMOVED, {
displaySetInstanceUIDs: [displaySetInstanceUID],
});
}
/**
* @param {string} displaySetInstanceUID
* @returns {object} displaySet
*/
public getDisplaySetByUID = (displaySetInstanceUid: string): DisplaySet => {
if (typeof displaySetInstanceUid !== 'string') {
throw new Error(
`getDisplaySetByUID: displaySetInstanceUid must be a string, you passed ${displaySetInstanceUid}`
);
}
return displaySetCache.get(displaySetInstanceUid);
};
/**
*
* @param {*} input
* @param {*} param1: settings: initialViewportSettings by HP or callbacks after rendering
* @returns {string[]} - added displaySetInstanceUIDs
*/
makeDisplaySets = (input, { batch = false, madeInClient = false, settings = {} } = {}) => {
if (!input || !input.length) {
throw new Error('No instances were provided.');
}
if (batch && !input[0].length) {
throw new Error('Batch displaySet creation does not contain array of array of instances.');
}
// If array of instances => One instance.
const displaySetsAdded = new Array<DisplaySet>();
if (batch) {
for (let i = 0; i < input.length; i++) {
const instances = input[i];
const displaySets = this.makeDisplaySetForInstances(instances, settings);
displaySetsAdded.push(...displaySets);
}
} else {
const displaySets = this.makeDisplaySetForInstances(input, settings);
displaySetsAdded.push(...displaySets);
}
const options = {};
if (madeInClient) {
options.madeInClient = true;
}
if (this.activeDisplaySetsChanged) {
this.activeDisplaySetsChanged = false;
this._broadcastEvent(EVENTS.DISPLAY_SETS_CHANGED, this.activeDisplaySets);
}
if (displaySetsAdded?.length) {
// The response from displaySetsAdded will only contain newly added
// display sets.
this._broadcastEvent(EVENTS.DISPLAY_SETS_ADDED, {
displaySetsAdded,
options,
});
return displaySetsAdded;
}
};
/**
* The onModeExit returns the display set service to the initial state,
* that is without any display sets. To avoid recreating display sets,
* the mode specific onModeExit is called before this method and should
* store the active display sets and the cached data.
*/
public onModeExit(): void {
this.getDisplaySetCache().clear();
this.activeDisplaySets.length = 0;
this.activeDisplaySetsMap.clear();
}
/**
* This function hides the old makeDisplaySetForInstances function to first
* separate the instances by sopClassUID so each call have only instances
* with the same sopClassUID, to avoid a series composed by different
* sopClassUIDs be filtered inside one of the SOPClassHandler functions and
* didn't appear in the series list.
* @param instancesSrc
* @param settings
* @returns
*/
public makeDisplaySetForInstances(instancesSrc: InstanceMetadata[], settings): DisplaySet[] {
// creating a sopClassUID list and for each sopClass associate its respective
// instance list
const instancesForSetSOPClasses = instancesSrc.reduce((sopClassList, instance) => {
if (!(instance.SOPClassUID in sopClassList)) {
sopClassList[instance.SOPClassUID] = [];
}
sopClassList[instance.SOPClassUID].push(instance);
return sopClassList;
}, {});
// for each sopClassUID, call the old makeDisplaySetForInstances with a
// instance list composed only by instances with the same sopClassUID and
// accumulate the displaySets in the variable allDisplaySets
const sopClasses = Object.keys(instancesForSetSOPClasses);
let allDisplaySets = [];
sopClasses.forEach(sopClass => {
const displaySets = this._makeDisplaySetForInstances(
instancesForSetSOPClasses[sopClass],
settings
);
allDisplaySets = [...allDisplaySets, ...displaySets];
});
return allDisplaySets;
}
/**
* Creates new display sets for the instances contained in instancesSrc
* according to the sop class handlers registered.
* This is idempotent in that calling it a second time with the
* same set of instances will not result in new display sets added.
* However, the response for the subsequent call will be empty as the data
* is already present.
* Calling it with some new instances and some existing instances will
* result in the new instances being added to existing display sets if
* they support the addInstances call, OR to new instances otherwise.
* Only the new instances are returned - the others are updated.
*
* @param instancesSrc are instances to add
* @param settings are settings to add
* @returns Array of the display sets added.
*/
private _makeDisplaySetForInstances(instancesSrc: InstanceMetadata[], settings): DisplaySet[] {
// Some of the sop class handlers take a direct reference to instances
// so make sure it gets copied here so that they have their own ref
let instances = [...instancesSrc];
const instance = instances[0];
const existingDisplaySets = this.getDisplaySetsForSeries(instance.SeriesInstanceUID) || [];
const SOPClassHandlerIds = this.SOPClassHandlerIds;
const allDisplaySets = [];
// Iterate over the sop class handlers while there are still instances to add
for (let i = 0; i < SOPClassHandlerIds.length && instances.length; i++) {
const SOPClassHandlerId = SOPClassHandlerIds[i];
const handler = this.extensionManager.getModuleEntry(SOPClassHandlerId);
if (handler.sopClassUids.includes(instance.SOPClassUID)) {
// Check if displaySets are already created using this SeriesInstanceUID/SOPClassHandler pair.
let displaySets = existingDisplaySets.filter(
displaySet => displaySet.SOPClassHandlerId === SOPClassHandlerId
);
if (displaySets.length) {
// This case occurs when there are already display sets, so remove
// any instances in existing display sets.
instances = filterInstances(instances, displaySets);
// See if an existing display set can add this instance to it,
// for example, if it is a new image to be added to the existing set
for (const ds of displaySets) {
const addedDs = ds.addInstances?.(instances, this);
if (addedDs) {
this.activeDisplaySetsChanged = true;
instances = filterInstances(instances, [addedDs]);
this._addActiveDisplaySets([addedDs]);
this.setDisplaySetMetadataInvalidated(addedDs.displaySetInstanceUID);
}
// This means that all instances already existed or got added to
// existing display sets, and had an invalidated event fired
if (!instances.length) {
return allDisplaySets;
}
}
if (!instances.length) {
// Everything is already added - this is just an update caused
// by something else
this._addActiveDisplaySets(displaySets);
return allDisplaySets;
}
}
// The instances array still contains some instances, so try
// creating additional display sets using the sop class handler
displaySets = handler.getDisplaySetsFromSeries(instances);
if (!displaySets || !displaySets.length) {
continue;
}
// 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);
// It is possible that this SOP class handler handled some instances
// but there may need to be other instances handled by other handlers,
// so remove the handled instances
instances = filterInstances(instances, displaySets);
allDisplaySets.push(...displaySets);
}
}
// applying the default sopClassUID handler
if (allDisplaySets.length === 0) {
// applying hp-defined viewport settings to the displaysets
const handler = this.extensionManager.getModuleEntry(this.unsupportedSOPClassHandler);
const displaySets = handler.getDisplaySetsFromSeries(instances);
if (displaySets?.length) {
displaySets.forEach(ds => {
Object.keys(settings).forEach(key => {
ds[key] = settings[key];
});
});
this._addDisplaySetsToCache(displaySets);
this._addActiveDisplaySets(displaySets);
allDisplaySets.push(...displaySets);
}
}
return allDisplaySets;
}
/**
* Iterates over displaysets and invokes comparator for each element.
* It returns a list of items that has being succeed by comparator method.
*
* @param comparator - method to be used on the validation
* @returns list of displaysets
*/
public getDisplaySetsBy(comparator: (DisplaySet) => boolean): DisplaySet[] {
const result = [];
if (typeof comparator !== 'function') {
throw new Error(`The comparator ${comparator} was not a function`);
}
this.getActiveDisplaySets().forEach(displaySet => {
if (comparator(displaySet)) {
result.push(displaySet);
}
});
return result;
}
/**
*
* @param sortFn function to sort the display sets
* @param direction direction to sort the display sets
* @returns void
*/
public sortDisplaySets(
sortFn: (a: DisplaySet, b: DisplaySet) => number,
direction: string,
suppressEvent = false
): void {
this.activeDisplaySets.sort(sortFn);
if (direction === 'descending') {
this.activeDisplaySets.reverse();
}
if (!suppressEvent) {
this._broadcastEvent(EVENTS.DISPLAY_SETS_CHANGED, this.activeDisplaySets);
}
}
}

View File

@@ -0,0 +1,9 @@
const EVENTS = {
DISPLAY_SETS_ADDED: 'event::displaySetService:displaySetsAdded',
DISPLAY_SETS_CHANGED: 'event::displaySetService:displaySetsChanged',
DISPLAY_SETS_REMOVED: 'event::displaySetService:displaySetsRemoved',
DISPLAY_SET_SERIES_METADATA_INVALIDATED:
'event::displaySetService:displaySetSeriesMetadataInvalidated',
};
export default EVENTS;

View File

@@ -0,0 +1,5 @@
import DisplaySetService from './DisplaySetService';
import { DisplaySetMessage, DisplaySetMessageList } from './DisplaySetMessage';
export default DisplaySetService;
export { DisplaySetMessage, DisplaySetMessageList };

View File

@@ -0,0 +1,173 @@
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
* @param {object} options is an object containing additional information
* @param {object[]} options.studies is a list of all the studies
* @param {object[]} options.displaySets is a list of the display sets
* @return {Object} Matching Object with score and details (which rule passed or failed)
*/
const match = (metadataInstance, rules = [], customAttributeRetrievalCallbacks, options) => {
const validateOptions = {
format: 'grouped',
};
const details = {
passed: [],
failed: [],
};
const readValues = {};
let requiredFailed = false;
let score = 0;
// Allow for matching against current or prior specifically
const prior = options?.studies?.[1];
const current = options?.studies?.[0];
const instance = metadataInstance.instances?.[0];
const fromSrc = {
prior,
current,
instance,
...options,
options,
metadataInstance,
};
rules.forEach(rule => {
const { attribute, from = 'metadataInstance' } = rule;
// Do not use the custom attribute from the metadataInstance since it is subject to change
if (customAttributeRetrievalCallbacks.hasOwnProperty(attribute)) {
readValues[attribute] = customAttributeRetrievalCallbacks[attribute].callback.call(
rule,
metadataInstance,
options
);
} else {
readValues[attribute] = fromSrc[from]?.[attribute] ?? instance?.[attribute];
}
// handle cases where the constraint is also a custom attribute
const resolvedConstraint = resolveConstraintAttributes(
readValues,
rule.constraint,
customAttributeRetrievalCallbacks,
fromSrc
);
// Format the constraint as required by Validate.js
const testConstraint = {
[attribute]: resolvedConstraint,
};
// Create a single attribute object to be validated, since metadataInstance is an
// instance of Metadata (StudyMetadata, SeriesMetadata or InstanceMetadata)
const attributeMap = {
[attribute]: readValues[attribute],
};
// Use Validate.js to evaluate the constraints on the specified metadataInstance
let errorMessages;
try {
errorMessages = validate(attributeMap, testConstraint, [validateOptions]);
} catch (e) {
errorMessages = ['Something went wrong during validation.', e];
}
// TODO: move to a logger
// console.log(
// 'Test',
// `${from}.${attribute}`,
// readValues[attribute],
// JSON.stringify(rule.constraint),
// !errorMessages
// );
if (!errorMessages) {
// If no errorMessages were returned, then validation passed.
// Add the rule's weight to the total score
score += parseInt(rule.weight || 1, 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,
};
};
// New helper function to resolve constraint attributes
const resolveConstraintAttributes = (
readValues,
constraint,
customAttributeRetrievalCallbacks,
fromSrc
) => {
if (typeof constraint !== 'object' || constraint === null) {
return constraint;
}
const resolvedConstraint = {};
Object.entries(constraint).forEach(([key, value]) => {
if (typeof value === 'object' && Object.keys(value).length > 0 && 'attribute' in value) {
const attributeName = value.attribute;
const attributeFrom = value.from ?? 'metadataInstance';
if (customAttributeRetrievalCallbacks.hasOwnProperty(attributeName)) {
const value = customAttributeRetrievalCallbacks[attributeName].callback.call(
null,
fromSrc[attributeFrom],
fromSrc.options
);
resolvedConstraint[key] = {
value,
};
} else {
resolvedConstraint[key] = {
value:
fromSrc[attributeFrom]?.[attributeName] ?? fromSrc.metadataInstance?.[attributeName],
};
}
} else {
resolvedConstraint[key] = value;
}
}, {});
return resolvedConstraint;
};
const HPMatcher = {
match,
};
export { HPMatcher };

View File

@@ -0,0 +1,192 @@
import HangingProtocolService from './HangingProtocolService';
const testProtocol = {
id: 'test',
name: 'Default',
protocolMatchingRules: [
{
attribute: 'StudyDescription',
constraint: {
contains: 'PETCT',
},
},
],
displaySetSelectors: {
displaySetSelector: {
seriesMatchingRules: [
{
weight: 1,
attribute: 'Modality',
constraint: {
equals: 'CT',
},
required: true,
},
{
weight: 1,
attribute: 'numImageFrames',
constraint: {
greaterThan: 10,
},
},
],
studyMatchingRules: [],
},
},
stages: [
{
name: 'default',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 1,
columns: 1,
},
},
viewports: [
{
viewportOptions: {
viewportId: 'ctAXIAL',
viewportType: 'volume',
orientation: 'axial',
toolGroupId: 'ctToolGroup',
customViewportOptions: {
initialScale: 2.5,
},
initialImageOptions: {
// index: 5,
preset: 'first', // 'first', 'last', 'middle'
},
syncGroups: [
{
type: 'cameraPosition',
id: 'axialSync',
source: true,
target: true,
},
],
},
displaySets: [
{
id: 'displaySetSelector',
},
],
},
],
},
],
numberOfPriorsReferenced: -1,
};
function testProtocolGenerator({ servicesManager }) {
servicesManager.services.TestService.toCall();
return {
protocol: testProtocol,
};
}
const studyMatch = {
StudyInstanceUID: 'studyMatch',
StudyDescription: 'A PETCT study type',
};
const displaySet1 = {
...studyMatch,
SeriesInstanceUID: 'ds1',
displaySetInstanceUID: 'displaySet1',
numImageFrames: 11,
Modality: 'CT',
};
const displaySet2 = {
...displaySet1,
SeriesInstanceUID: 'ds2',
displaySetInstanceUID: 'displaySet2',
Modality: 'PT',
};
const displaySet3 = {
...displaySet1,
numImageFrames: 3,
displaySetInstanceUID: 'displaySet3',
};
const studyMatchDisplaySets = [displaySet3, displaySet2, displaySet1];
function checkHpsBestMatch(hps) {
hps.run({ studies: [studyMatch], displaySets: studyMatchDisplaySets });
const { viewportMatchDetails } = hps.getMatchDetails();
expect(viewportMatchDetails.size).toBe(1);
expect(viewportMatchDetails.get('ctAXIAL')).toMatchObject({
viewportOptions: {
viewportId: 'ctAXIAL',
viewportType: 'volume',
orientation: 'axial',
toolGroupId: 'ctToolGroup',
},
// Matches ds1 because it matches 2 rules, a required and an optional
// ds2 fails to match required and ds3 fails to match an optional.
displaySetsInfo: [
{
displaySetInstanceUID: 'displaySet1',
displaySetOptions: {
id: 'displaySetSelector',
options: {},
},
},
],
});
}
describe('HangingProtocolService', () => {
const mockedFunction = jest.fn();
const commandsManager = {
run: mockedFunction,
};
const servicesManager = {
services: {
TestService: {
toCall: mockedFunction,
},
},
};
const hangingProtocolService = new HangingProtocolService(commandsManager, servicesManager);
let initialScaling;
afterEach(() => {
mockedFunction.mockClear();
});
describe('with a static protocol', () => {
beforeAll(() => {
hangingProtocolService.addProtocol(testProtocol.id, testProtocol);
});
it('has one protocol', () => {
expect(hangingProtocolService.getProtocols().length).toBe(1);
});
describe('run', () => {
it('matches best image match', () => {
checkHpsBestMatch(hangingProtocolService);
});
});
});
describe('with protocol generator', () => {
beforeAll(() => {
hangingProtocolService.addProtocol(testProtocol.id, testProtocolGenerator);
});
it('has one protocol', () => {
expect(hangingProtocolService.getProtocols().length).toBe(1);
});
describe('run', () => {
it('matches best image match', () => {
checkHpsBestMatch(hangingProtocolService);
});
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,174 @@
import { HPMatcher } from './HPMatcher.js';
import { sortByScore } from './lib/sortByScore';
export default class ProtocolEngine {
constructor(protocols, customAttributeRetrievalCallbacks) {
this.protocols = protocols;
this.customAttributeRetrievalCallbacks = customAttributeRetrievalCallbacks;
this.matchedProtocols = new Map();
this.matchedProtocolScores = {};
this.study = undefined;
}
/** Evaluate the hanging protocol matches on the given:
* @param props.studies is a list of studies to compare against (for priors evaluation)
* @param props.activeStudy is the current metadata for the study to display.
* @param props.displaySets are the list of display sets which can be modified.
*/
run({ studies, displaySets, activeStudy }) {
this.studies = studies;
this.study = activeStudy || studies[0];
this.displaySets = displaySets;
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 - this.studies has the list of studies
const matched = this.findMatchByStudy(this.study, {
studies: this.studies,
displaySets: this.displaySets,
});
// 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;
}
});
}
/**
* finds the match results against the given display set or
* study instance by testing the given rules against this, and using
* the provided options for testing.
*
* @param {*} metaData to match against as primary value
* @param {*} rules to apply
* @param {*} options are additional values that can be used for matching
* @returns
*/
findMatch(metaData, rules, options) {
return HPMatcher.match(metaData, rules, this.customAttributeRetrievalCallbacks, options);
}
/**
* Finds the best protocols from Protocol Store, matching each protocol matching rules
* with the given study. The best protocol are ordered by score and returned in an array
* @param {Object} study StudyMetadata instance object
* @param {object} options containing additional matching data.
* @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, options) {
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 || !rules.length) {
console.warn(
'ProtocolEngine::findMatchByStudy no matching rules - specify protocolMatchingRules for',
protocol.id
);
return;
}
// Run the matcher and get matching details
const matchedDetails = this.findMatch(study, rules, options);
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 provided
// if not select the first protocol in the list
if (!matched.length) {
const protocol =
this.protocols.find(protocol => protocol.id === 'default') ?? this.protocols[0];
console.log('No protocol matches, defaulting to', protocol);
return [
{
score: 0,
protocol,
},
];
}
// 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);
}
}

View File

@@ -0,0 +1,48 @@
import { getSplitParam } from '../../../utils';
/** Indicates if the given display set is the one specified in the
* displaySet parameter in the URL
* The parameters are:
* initialSeriesInstanceUID
* initialSOPInstanceUID
*/
const isDisplaySetFromUrl = (displaySet): boolean => {
const params = new URLSearchParams(window.location.search);
const initialSeriesInstanceUID = getSplitParam('initialseriesinstanceuid', params);
const initialSOPInstanceUID = getSplitParam('initialsopinstanceuid', params);
if (!initialSeriesInstanceUID && !initialSOPInstanceUID) {
return false;
}
const isSeriesMatch = initialSeriesInstanceUID?.some(
seriesUID => displaySet.SeriesInstanceUID === seriesUID
);
const isSopMatch = initialSOPInstanceUID?.some(sopUID =>
displaySet.instances?.some(instance => sopUID === instance.SOPInstanceUID)
);
return isSeriesMatch || isSopMatch;
};
/** Returns the index location of the requested image, or the defaultValue in this.
* Returns undefined to fallback to the defaultValue
*/
function sopInstanceLocation(displaySets) {
const displaySet = displaySets?.[0];
if (!displaySet) {
return;
}
const initialSOPInstanceUID = getSplitParam('initialsopinstanceuid');
if (!initialSOPInstanceUID) {
return;
}
const index = displaySet.instances.findIndex(instance =>
initialSOPInstanceUID.includes(instance.SOPInstanceUID)
);
// Need to return in the initial position specified format.
return index === -1 ? undefined : { index };
}
export { isDisplaySetFromUrl, sopInstanceLocation };

View File

@@ -0,0 +1,5 @@
export default (study, extraData) => {
const ret = extraData?.displaySets?.filter(ds => ds.numImageFrames > 0)?.length;
console.log('number of display sets with images', ret);
return ret;
};

View File

@@ -0,0 +1 @@
export default (study, extraData) => extraData?.displaySets?.map(ds => ds.SeriesDescription);

View File

@@ -0,0 +1,356 @@
// This can be calculated by some formula probably, but for now we just use a constant since
// this might be objective
const GRID_MAPPINGS = {
// 1x2
'1x2:1x1': {
0: 0,
},
'1x2:1x3': {
0: 0,
1: 1,
},
'1x2:2x1': {
0: 0,
1: 1,
},
'1x2:2x2': {
0: 0,
1: 1,
},
'1x2:2x3': {
0: 0,
1: 1,
},
'1x2:3x1': {
0: 0,
1: 1,
},
'1x2:3x2': {
0: 0,
1: 1,
},
'1x2:3x3': {
0: 0,
1: 1,
},
// 1x3
'1x3:1x1': {
0: 0,
},
'1x3:1x2': {
0: 0,
1: 1,
},
'1x3:2x1': {
0: 0,
1: 1,
},
'1x3:2x2': {
0: 0,
1: 1,
2: 2,
},
'1x3:2x3': {
0: 0,
1: 1,
2: 2,
},
'1x3:3x1': {
0: 0,
1: 1,
2: 2,
},
'1x3:3x2': {
0: 0,
1: 1,
2: 2,
},
'1x3:3x3': {
0: 0,
1: 1,
2: 2,
},
// 2x1
'2x1:1x2': {
0: 0,
1: 1,
},
'2x1:1x3': {
0: 0,
1: 1,
},
'2x1:2x2': {
0: 0,
2: 1,
},
'2x1:2x3': {
0: 0,
3: 1,
},
'2x1:3x1': {
0: 0,
1: 1,
},
'2x1:3x2': {
0: 0,
2: 1,
},
'2x1:3x3': {
0: 0,
3: 1,
},
// 2x2
'2x2:1x2': {
0: 0,
1: 1,
},
'2x2:1x3': {
0: 0,
1: 1,
2: 2,
},
'2x2:2x1': {
0: 0,
1: 2,
},
'2x2:2x3': {
0: 0,
1: 1,
3: 2,
4: 3,
},
'2x2:3x1': {
0: 0,
1: 1,
2: 2,
},
'2x2:3x2': {
0: 0,
1: 1,
2: 2,
3: 3,
},
'2x2:3x3': {
0: 0,
1: 1,
3: 2,
4: 3,
},
// 2x3
'2x3:1x2': {
0: 0,
1: 1,
},
'2x3:1x3': {
0: 0,
1: 1,
2: 2,
},
'2x3:2x1': {
0: 0,
1: 3,
},
'2x3:2x2': {
0: 0,
1: 1,
2: 3,
3: 4,
},
'2x3:3x1': {
0: 0,
1: 1,
2: 2,
},
'2x3:3x2': {
0: 0,
1: 1,
2: 2,
3: 3,
4: 4,
5: 5,
},
'2x3:3x3': {
0: 0,
1: 1,
2: 2,
3: 3,
4: 4,
5: 5,
},
// 3x1
'3x1:1x2': {
0: 0,
1: 1,
},
'3x1:1x3': {
0: 0,
1: 1,
2: 2,
},
'3x1:2x1': {
0: 0,
1: 1,
},
// TODO: I'm not sure about the following
'3x1:2x2': {
0: 0,
1: 1,
2: 2,
},
'3x1:2x3': {
0: 0,
1: 1,
2: 2,
},
'3x1:3x2': {
0: 0,
2: 1,
4: 2,
},
'3x1:3x3': {
0: 0,
3: 1,
6: 2,
},
// 3x2
'3x2:1x2': {
0: 0,
1: 1,
},
'3x2:1x3': {
0: 0,
1: 1,
2: 2,
},
'3x2:2x1': {
0: 0,
1: 2,
},
'3x2:2x2': {
0: 0,
1: 1,
2: 2,
3: 3,
},
'3x2:2x3': {
0: 0,
1: 1,
2: 2,
3: 3,
4: 4,
5: 5,
},
'3x2:3x1': {
0: 0,
1: 2,
2: 4,
},
'3x2:3x3': {
0: 0,
1: 1,
3: 2,
4: 3,
6: 4,
7: 5,
},
// 3x3
'3x3:1x2': {
0: 0,
1: 1,
},
'3x3:1x3': {
0: 0,
1: 1,
2: 2,
},
'3x3:2x1': {
0: 0,
1: 3,
},
'3x3:2x2': {
0: 0,
1: 1,
2: 3,
3: 4,
},
'3x3:2x3': {
0: 0,
1: 1,
2: 2,
3: 3,
4: 4,
5: 5,
},
'3x3:3x1': {
0: 0,
1: 3,
2: 6,
},
'3x3:3x2': {
0: 0,
1: 1,
2: 3,
3: 4,
4: 6,
5: 7,
},
};
/**
* The purpose of this function is to convert a grid with numRows and numCols
* and index for each cell into another grid with different dimensions, but it should
* intelligently use the data from the original grid to fill the new grid at
* correct locations.
*
* For instance:
* if the old grid is a 2x2 (numRows = 2, numCols = 2) and the new grid is a 3x3
* it should intelligently insert the cells in the new grid so that the cells
* are added to the right most column. Then the mapping is as follows:
* 0 -> 0, 1 -> 1, 3 -> 2, 4 -> 3 (viewport 2 in the old grid can be used in
* the place of viewport 3 in the new grid)
*
* Or if the old grid is 2x2 and new grid is 2x4, the mapping is as follows:
* 0 -> 0, 1 -> 1, 4 -> 2 and 5 -> 3
*
* Or if the old grid is 2x2 and the new grid is 1x2, the mapping is as follows:
* 0 -> 0, 1 -> 2
*
* @param {Object} oldGrid
* @param {number} oldGrid.numRows
* @param {number} oldGrid.numCols
*
* @param {Object} newGrid
* @param {number} newGrid.numRows
* @param {number} newGrid.numCols
*
* @returns {Map} A map that maps the new indices to the old indices
*
*/
const getGridMapping = (oldGrid, newGrid) => {
const mapping = {};
const { numRows: oldNumRows, numCols: oldNumCols } = oldGrid;
const { numRows: newNumRows, numCols: newNumCols } = newGrid;
if (oldNumRows === 1 && oldNumCols === 1) {
// If the old grid is 1x1, then we can just return the first cell
mapping[0] = 0;
return mapping;
}
if (newNumRows === 1 && newNumCols === 1) {
// If the new grid is 1x1, then we can just return the first cell
mapping[0] = 0;
return mapping;
}
const key = `${oldNumRows}x${oldNumCols}:${newNumRows}x${newNumCols}`;
const map = GRID_MAPPINGS[key];
if (!map) {
throw new Error(`No mapping found for ${key}`);
}
return map;
};
export default getGridMapping;

View File

@@ -0,0 +1,3 @@
import HangingProtocolService from './HangingProtocolService';
export default HangingProtocolService;

View File

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

View File

@@ -0,0 +1,70 @@
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;
}

View File

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

View File

@@ -0,0 +1,8 @@
// Sorts an array by score
const sortByScore = arr => {
arr.sort((a, b) => {
return b.score - a.score;
});
};
export { sortByScore };

View File

@@ -0,0 +1,463 @@
import validate from 'validate.js';
/**
* check if the value is strictly equal to options
*
* @example
* value = ['abc', 'def', 'GHI']
* testValue = 'abc' (Fail)
* = ['abc'] (Fail)
* = ['abc', 'def', 'GHI'] (Valid)
* = ['abc', 'GHI', 'def'] (Fail)
* = ['abc', 'def'] (Fail)
*
* value = 'Attenuation Corrected'
* testValue = 'Attenuation Corrected' (Valid)
* testValue = 'Attenuation' (Fail)
*
* value = ['Attenuation Corrected']
* testValue = ['Attenuation Corrected'] (Valid)
* = 'Attenuation Corrected' (Valid)
* = 'Attenuation' (Fail)
*
* */
validate.validators.equals = function (value, options, key) {
const testValue = getTestValue(options);
const dicomArrayValue = dicomTagToArray(value);
// If options is an array, then we need to validate each element in the array
if (Array.isArray(testValue)) {
// If the array has only one element, then we need to compare the value to that element
if (testValue.length !== dicomArrayValue.length) {
return `${key} must be an array of length ${testValue.length}`;
} else {
for (let i = 0; i < testValue.length; i++) {
if (testValue[i] !== dicomArrayValue[i]) {
return `${key} ${testValue[i]} must equal ${dicomArrayValue[i]}`;
}
}
}
} else if (testValue !== dicomArrayValue[0]) {
return `${key} must equal ${testValue}`;
}
};
/**
* check if the value is not equal to options
*
* @example
* value = ['abc', 'def', 'GHI']
* testValue = 'abc' (Valid)
* = ['abc'] (Valid)
* = ['abc', 'def', 'GHI'] (Fail)
* = ['abc', 'GHI', 'def'] (Valid)
* = ['abc', 'def'] (Valid)
*
* value = 'Attenuation Corrected'
* = 'Attenuation Corrected' (Fail)
* = 'Attenuation' (Valid)
*
* value = ['Attenuation Corrected']
* testValue = ['Attenuation Corrected'] (Fail)
* = 'Attenuation Corrected' (Fail)
* = 'Attenuation' (Fail)
* */
validate.validators.doesNotEqual = function (value, options, key) {
const testValue = getTestValue(options);
const dicomArrayValue = dicomTagToArray(value);
if (Array.isArray(testValue)) {
if (testValue.length === dicomArrayValue.length) {
let score = 0;
testValue.forEach((x, i) => {
if (x === dicomArrayValue[i]) {
score++;
}
});
if (score === testValue.length) {
return `${key} must not equal to ${testValue}`;
}
}
} else if (testValue === dicomArrayValue[0]) {
console.log(dicomArrayValue, testValue);
return `${key} must not equal to ${testValue}`;
}
};
/**
* Check if a value includes one or more specified options.
*
* @example
* value = ['abc', 'def', 'GHI']
* testValue = abc (Fail)
* = dog (Fail)
* = [abc] (Valid)
* = [att, abc] (Valid)
* = ['abc', 'def', 'dog'] (Valid)
* = ['cat', 'dog'] (Fail)
*
* value = ['Attenuation Corrected']
* testValue = 'Attenuation Corrected' (Fail)
* = ['Attenuation Corrected', 'Corrected'] (Valid)
* = ['Attenuation', 'Corrected'] (Fail)
*
* value = 'Attenuation Corrected'
* testValue = ['Attenuation Corrected', 'Corrected'] (Valid)
* = ['Attenuation', 'Corrected'] (Fail)
* */
validate.validators.includes = function (value, options, key) {
const testValue = getTestValue(options);
const dicomArrayValue = dicomTagToArray(value);
if (Array.isArray(testValue)) {
const includedValues = testValue.filter(el => dicomArrayValue.includes(el));
if (includedValues.length === 0) {
return `${key} must include at least one of the following values: ${testValue.join(', ')}`;
}
} else {
return `${key} ${testValue} must be an array`;
}
// else if (!value.includes(testValue)) {
// return `${key} ${value} must include ${testValue}`;
// }
};
/**
* Check if a value does not include one or more specified options.
*
* @example
* value = ['abc', 'def', 'GHI']
* testValue = ['Corr'] (Valid)
* = 'abc' (Fail)
* = ['abc'] (Fail)
* = [att, cor] (Valid)
* = ['abc', 'def', 'dog'] (Fail)
*
* value = ['Attenuation Corrected']
* testValue = 'Attenuation Corrected' (Fail)
* = ['Attenuation Corrected', 'Corrected'] (Fail)
* = ['Attenuation', 'Corrected'] (Valid)
*
* value = 'Attenuation Corrected'
* testValue = ['Attenuation Corrected', 'Corrected'] (Fail)
* = ['Attenuation', 'Corrected'] (Valid)
* */
validate.validators.doesNotInclude = function (value, options, key) {
const testValue = getTestValue(options);
const dicomArrayValue = dicomTagToArray(value);
// if (!Array.isArray(value) || value.length === 1) {
// return `${key} is not allowed as a single value`;
// }
if (Array.isArray(testValue)) {
const includedValues = testValue.filter(el => dicomArrayValue.includes(el));
if (includedValues.length > 0) {
return `${key} must not include the following value: ${includedValues}`;
}
} else {
return `${key} ${testValue} must be an array`;
}
};
// Ignore case contains.
// options testValue MUST be in lower case already, otherwise it won't match
/**
* @example
* value = 'Attenuation Corrected'
* testValue = Corr (Valid)
* = corr (Valid)
* = [att, cor] (Valid)
* = [Att, Wall] (Valid)
* = [cat, dog] (Fail)
*
* value = ['abc', 'def', 'GHI']
* testValue = 'def' (Valid)
* = 'dog' (Fail)
* = ['gh', 'de'] (Valid)
* = ['cat', 'dog'] (Fail)
*
* */
validate.validators.containsI = function (value, options, key) {
const testValue = getTestValue(options);
if (Array.isArray(value)) {
if (value.some(item => !validate.validators.containsI(item.toLowerCase(), options, key))) {
return undefined;
}
return `No item of ${value.join(',')} contains ${JSON.stringify(testValue)}`;
}
if (Array.isArray(testValue)) {
if (
testValue.some(subTest => !validate.validators.containsI(value, subTest.toLowerCase(), key))
) {
return;
}
return `${key} must contain at least one of ${testValue.join(',')}`;
}
if (testValue && value.indexOf && value.toLowerCase().indexOf(testValue.toLowerCase()) === -1) {
return key + 'must contain any case of' + testValue;
}
};
/**
* @example
* value = 'Attenuation Corrected'
* testValue = Corr (Valid)
* = corr (Fail)
* = [att, cor] (Fail)
* = [Att, Wall] (Valid)
* = [cat, dog] (Fail)
*
* value = ['abc', 'def', 'GHI']
* testValue = 'def' (Valid)
* = 'dog' (Fail)
* = ['cat', 'de'] (Valid)
* = ['cat', 'dog'] (Fail)
*
* */
validate.validators.contains = function (value, options, key) {
const testValue = getTestValue(options);
if (Array.isArray(value)) {
if (value.some(item => !validate.validators.contains(item, options, key))) {
return undefined;
}
return `No item of ${value.join(',')} contains ${JSON.stringify(testValue)}`;
}
if (Array.isArray(testValue)) {
if (testValue.some(subTest => !validate.validators.contains(value, subTest, key))) {
return;
}
return `${key} must contain at least one of ${testValue.join(',')}`;
}
if (testValue && value.indexOf && value.indexOf(testValue) === -1) {
return key + 'must contain ' + testValue;
}
};
/**
* @example
* value = 'Attenuation Corrected'
* testValue = Corr (Fail)
* = corr (Valid)
* = [att, cor] (Valid)
* = [Att, Wall] (Fail)
* = [cat, dog] (Valid)
*
* value = ['abc', 'def', 'GHI']
* testValue = 'def' (Fail)
* = 'dog' (Valid)
* = ['cat', 'de'] (Fail)
* = ['cat', 'dog'] (Valid)
*
* */
validate.validators.doesNotContain = function (value, options, key) {
const containsResult = validate.validators.contains(value, options, key);
if (!containsResult) {
return `No item of ${value} should contain ${getTestValue(options)}`;
}
};
/**
* @example
* value = 'Attenuation Corrected'
* testValue = Corr (Fail)
* = corr (Fail)
* = [att, cor] (Fail)
* = [Att, Wall] (Fail)
* = [cat, dog] (Valid)
*
* value = ['abc', 'def', 'GHI']
* testValue = 'DEF' (Fail)
* = 'dog' (Valid)
* = ['cat', 'gh'] (Fail)
* = ['cat', 'dog'] (Valid)
*
* */
validate.validators.doesNotContainI = function (value, options, key) {
const containsResult = validate.validators.containsI(value, options, key);
if (!containsResult) {
return `No item of ${value} should not contain ${getTestValue(options)}`;
}
};
/**
* @example
* value = 'Attenuation Corrected'
* testValue = Corr (Fail)
* = Att (Fail)
* = ['cat', 'dog', 'Att'] (Valid)
* = [cat, dog] (Fail)
*
* value = ['abc', 'def', 'GHI']
* testValue = 'deg' (Valid)
* = ['cat', 'GH'] (Valid)
* = ['cat', 'gh'] (Fail)
* = ['cat', 'dog'] (Fail)
*
* */
validate.validators.startsWith = function (value, options, key) {
let testValues = getTestValue(options);
if (typeof testValues === 'string') {
testValues = [testValues];
}
if (typeof value === 'string') {
if (!testValues.some(testValue => value.startsWith(testValue))) {
return key + ' must start with any of these values: ' + testValues;
}
} else if (Array.isArray(value)) {
let valid = false;
for (let i = 0; i < value.length; i++) {
for (let j = 0; j < testValues.length; j++) {
if (value[i].startsWith(testValues[j])) {
valid = true; // set valid flag to true if a match is found
break;
}
}
if (valid) {
return undefined; // break out of loop if a match is found
}
}
if (!valid) {
return key + ' must start with any of these values: ' + testValues; // return undefined if no match is found
}
} else {
return 'Value must be a string or an array';
}
};
/**
* @example
* value = 'Attenuation Corrected'
* testValue = TED (Fail)
* = ted (Valid)
* = ['cat', 'dog', 'ted'] (Valid)
* = [cat, dog] (Fail)
*
* value = ['abc', 'def', 'GHI']
* testValue = 'deg' (Valid)
* = ['cat', 'HI'] (Valid)
* = ['cat', 'hi'] (Fail)
* = ['cat', 'dog'] (Fail)
*
* */
validate.validators.endsWith = function (value, options, key) {
let testValues = getTestValue(options);
if (typeof testValues === 'string') {
testValues = [testValues];
}
if (typeof value === 'string') {
if (!testValues.some(testValue => value.endsWith(testValue))) {
return key + ' must end with any of these values: ' + testValues;
}
} else if (Array.isArray(value)) {
let valid = false;
for (let i = 0; i < value.length; i++) {
for (let j = 0; j < testValues.length; j++) {
if (value[i].endsWith(testValues[j])) {
valid = true; // set valid flag to true if a match is found
break;
}
}
if (valid) {
return undefined; // break out of loop if a match is found
}
}
if (!valid) {
return key + ' must end with any of these values: ' + testValues; // return undefined if no match is found
}
} else {
return key + ' must be a string or an array';
}
};
/**
* @example
* value = 30
* testValue = 20 (Valid)
* = 40 (Fail)
*
* */
validate.validators.greaterThan = function (value, options, key) {
const testValue = getTestValue(options);
if (Array.isArray(value) || typeof value === 'string') {
return `${key} is not allowed as an array or string`;
}
if (Array.isArray(testValue)) {
if (testValue.length === 1) {
if (!(value >= testValue[0])) {
return `${key} must be greater than or equal to ${testValue[0]}, but was ${value}`;
}
} else if (testValue.length > 1) {
return key + ' must be an array of length 1';
}
} else {
if (!(value >= testValue)) {
return key + ' must be greater than ' + testValue;
}
}
};
/**
* @example
* value = 30
* testValue = 40 (Valid)
* = 20 (Fail)
*
* */
validate.validators.lessThan = function (value, options, key) {
const testValue = getTestValue(options);
if (Array.isArray(testValue)) {
if (testValue.length === 1) {
if (!(value <= testValue[0])) {
return `${key} must be less than or equal to ${testValue[0]}, but was ${value}`;
}
} else if (testValue.length > 1) {
return key + ' must be an array of length 1';
}
} else {
if (!(value <= testValue)) {
return key + ' must be less than ' + testValue;
}
}
};
/**
* @example
*
* value = 50
* testValue = [10,60] (Valid)
* = [60, 10] (Valid)
* = [0, 10] (Fail)
* = [70, 80] (Fail)
* = 45 (Fail)
* = [45] (Fail)
*
* */
validate.validators.range = function (value, options, key) {
const testValue = getTestValue(options);
if (Array.isArray(testValue) && testValue.length === 2) {
const min = Math.min(testValue[0], testValue[1]);
const max = Math.max(testValue[0], testValue[1]);
if (value === undefined || value < min || value > max) {
return `${key} with value ${value} must be between ${min} and ${max}`;
}
} else {
return `${key} must be an array of length 2`;
}
};
validate.validators.notNull = value =>
value === null || value === undefined ? 'Value is null' : undefined;
const getTestValue = options => {
if (Array.isArray(options)) {
return options.map(option => option?.value ?? option);
} else {
return options?.value ?? options;
}
};
const dicomTagToArray = value => {
let dicomArrayValue;
if (!Array.isArray(value)) {
dicomArrayValue = [value];
} else {
dicomArrayValue = [...value];
}
return dicomArrayValue;
};
export default validate;

View File

@@ -0,0 +1,358 @@
import validate from './validator.js';
describe('validator', () => {
const attributeMap = {
str: 'Attenuation Corrected',
upper: 'UPPER',
num: 3,
nullValue: null,
list: ['abc', 'def', 'GHI'],
listStr: ['Attenuation Corrected'],
};
const options = {
format: 'grouped',
};
describe('equals', () => {
it('returned undefined on strictly equals', () => {
expect(
validate(attributeMap, { listStr: { equals: ['Attenuation'] } }, [options])
).not.toBeUndefined();
expect(
validate(attributeMap, { listStr: { equals: 'Attenuation' } }, [options])
).not.toBeUndefined();
expect(
validate(attributeMap, { listStr: { equals: 'Attenuation Corrected' } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { listStr: { equals: ['Attenuation Corrected'] } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { str: { equals: 'Attenuation Corrected' } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { str: { equals: { value: 'Attenuation Corrected' } } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { str: { equals: ['Attenuation Corrected'] } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { str: { equals: ['Attenuation'] } }, [options])
).not.toBeUndefined();
expect(
validate(attributeMap, { list: { equals: ['abc', 'def', 'GHI'] } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { list: { equals: ['abc', 'GHI', 'def'] } }, [options])
).not.toBeUndefined();
expect(
validate(attributeMap, { list: { equals: { value: ['abc', 'def', 'GHI'] } } }, [options])
).toBeUndefined();
});
});
describe('doesNotEqual', () => {
it('returns undefined if value does not equal ', () => {
expect(
validate(attributeMap, { listStr: { doesNotEqual: 'Attenuation Corrected' } }, [options])
).not.toBeUndefined();
expect(
validate(attributeMap, { listStr: { doesNotEqual: ['Attenuation Corrected'] } }, [options])
).not.toBeUndefined();
expect(
validate(attributeMap, { listStr: { doesNotEqual: 'Attenuation' } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { str: { doesNotEqual: 'Attenuation' } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { str: { doesNotEqual: { value: 'Attenuation' } } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { str: { doesNotEqual: ['Attenuation'] } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { str: { doesNotEqual: ['abc', 'def'] } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { list: { doesNotEqual: ['abc', 'GHI', 'def'] } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { list: { doesNotEqual: ['abc', 'def', 'GHI'] } }, [options])
).not.toBeUndefined();
});
});
describe('includes', () => {
it('returns match any list includes', () => {
expect(
validate(attributeMap, { listStr: { includes: 'Attenuation Corrected' } }, [options])
).not.toBeUndefined();
expect(
validate(attributeMap, { listStr: { includes: ['Attenuation Corrected'] } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { listStr: { includes: ['Attenuation Corrected', 'Corrected'] } }, [
options,
])
).toBeUndefined();
expect(
validate(attributeMap, { listStr: { includes: ['Attenuation', 'Corrected'] } }, [options])
).not.toBeUndefined();
expect(
validate(attributeMap, { str: { includes: ['Attenuation Corrected', 'Corrected'] } }, [
options,
])
).toBeUndefined();
expect(
validate(attributeMap, { str: { includes: ['Attenuation', 'Corrected'] } }, [options])
).not.toBeUndefined();
expect(validate(attributeMap, { list: { includes: ['abc'] } }, [options])).toBeUndefined();
expect(
validate(attributeMap, { list: { includes: ['GHI', 'HI'] } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { list: { includes: ['HI', 'bye'] } }, [options])
).not.toBeUndefined();
});
});
describe('doesNotInclude', () => {
it('returns undefined if list does not includes', () => {
expect(
validate(attributeMap, { listStr: { doesNotInclude: 'Attenuation Corrected' } }, [options])
).not.toBeUndefined();
expect(
validate(
attributeMap,
{
listStr: { doesNotInclude: ['Attenuation Corrected', 'Corrected'] },
},
[options]
)
).not.toBeUndefined();
expect(
validate(attributeMap, { listStr: { doesNotInclude: ['Attenuation', 'Corrected'] } }, [
options,
])
).toBeUndefined();
expect(
validate(
attributeMap,
{ str: { doesNotInclude: ['Attenuation Corrected', 'Corrected'] } },
[options]
)
).not.toBeUndefined();
expect(
validate(attributeMap, { str: { doesNotInclude: ['Attenuation', 'Corrected'] } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { list: { doesNotInclude: ['Corr'] } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { list: { doesNotInclude: 'abc' } }, [options])
).not.toBeUndefined();
expect(
validate(attributeMap, { list: { doesNotInclude: { value: ['abc'] } } }, [options])
).not.toBeUndefined();
expect(
validate(attributeMap, { list: { doesNotInclude: { value: ['att', 'cor'] } } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { list: { doesNotInclude: { value: ['abc', 'def', 'dog'] } } }, [
options,
])
).not.toBeUndefined();
});
});
describe('containsI', () => {
it('returns match any list contains case insensitive', () => {
expect(
validate(attributeMap, { upper: { containsI: ['hi', 'pre'] } }, [options])
).not.toBeUndefined();
expect(validate(attributeMap, { list: { containsI: 'hi' } }, [options])).toBeUndefined();
expect(
validate(attributeMap, { list: { containsI: ['ghi', 'bye'] } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { list: { containsI: ['bye', 'hi'] } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { list: { containsI: ['ig', 'hi'] } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { upper: { containsI: ['bye', 'per'] } }, [options])
).toBeUndefined();
});
});
describe('contains', () => {
it('returns match any list contains', () => {
expect(validate(attributeMap, { str: { contains: 'Corr' } }, [options])).toBeUndefined();
expect(
validate(attributeMap, { str: { contains: { value: 'Corr' } } }, [options])
).toBeUndefined();
expect(validate(attributeMap, { str: { contains: ['Corr'] } }, [options])).toBeUndefined();
expect(
validate(attributeMap, { str: { contains: ['corr'] } }, [options])
).not.toBeUndefined();
expect(
validate(attributeMap, { str: { contains: ['Att', 'Wall'] } }, [options])
).toBeUndefined();
expect(validate(attributeMap, { list: { contains: 'GH' } }, [options])).toBeUndefined();
expect(validate(attributeMap, { list: { contains: ['ab'] } }, [options])).toBeUndefined();
expect(
validate(attributeMap, { list: { contains: ['z', 'bc'] } }, [options])
).toBeUndefined();
expect(validate(attributeMap, { list: { contains: ['z'] } }, [options])).not.toBeUndefined();
});
});
describe('doesNotContain', () => {
it('returns undefined if string does not contain specified value', () => {
expect(
validate(attributeMap, { str: { doesNotContain: ['att', 'wall'] } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { str: { doesNotContain: 'Corr' } }, [options])
).not.toBeUndefined();
expect(
validate(attributeMap, { str: { doesNotContain: 'corr' } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { str: { doesNotContain: { value: 'corr' } } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { str: { doesNotContain: ['att', 'cor'] } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { str: { doesNotContain: ['Att', 'cor'] } }, [options])
).not.toBeUndefined();
expect(
validate(attributeMap, { str: { doesNotContain: ['bye', 'hi'] } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { list: { doesNotContain: ['GHI', 'hi'] } }, [options])
).not.toBeUndefined();
expect(
validate(attributeMap, { list: { doesNotContain: ['hi'] } }, [options])
).toBeUndefined();
});
});
describe('doesNotContainI', () => {
it('returns undefined if string does not contain specified value', () => {
expect(
validate(attributeMap, { str: { doesNotContainI: 'corr' } }, [options])
).not.toBeUndefined();
expect(
validate(attributeMap, { str: { doesNotContainI: 'Corr' } }, [options])
).not.toBeUndefined();
expect(
validate(attributeMap, { str: { doesNotContainI: ['att', 'cor'] } }, [options])
).not.toBeUndefined();
expect(
validate(attributeMap, { str: { doesNotContainI: ['Att', 'wall'] } }, [options])
).not.toBeUndefined();
expect(
validate(attributeMap, { str: { doesNotContainI: ['bye', 'hi'] } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { list: { doesNotContainI: ['bye', 'ABC'] } }, [options])
).not.toBeUndefined();
expect(
validate(attributeMap, { list: { doesNotContainI: 'bye' } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { list: { doesNotContainI: ['bye', 'ABC'] } }, [options])
).not.toBeUndefined();
});
});
describe('startsWith', () => {
it('returns undefined if string starts with specified value', () => {
expect(
validate(attributeMap, { str: { startsWith: { value: 'Atte' } } }, [options])
).toBeUndefined();
expect(validate(attributeMap, { str: { startsWith: 'Att' } }, [options])).toBeUndefined();
expect(
validate(attributeMap, { str: { startsWith: ['cat', 'dog', 'Att'] } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { str: { startsWith: ['cat', 'dog'] } }, [options])
).not.toBeUndefined();
expect(validate(attributeMap, { list: { startsWith: ['GH'] } }, [options])).toBeUndefined();
expect(
validate(attributeMap, { list: { startsWith: ['de', 'bye'] } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { list: { startsWith: ['hi', 'bye'] } }, [options])
).not.toBeUndefined();
});
});
describe('endsWith', () => {
it('returns undefined if string ends with specified value', () => {
expect(validate(attributeMap, { str: { endsWith: 'ted' } }, [options])).toBeUndefined();
expect(
validate(attributeMap, { str: { endsWith: { value: 'ted' } } }, [options])
).toBeUndefined();
expect(validate(attributeMap, { str: { endsWith: ['ted'] } }, [options])).toBeUndefined();
expect(validate(attributeMap, { str: { endsWith: ['Att'] } }, [options])).not.toBeUndefined();
expect(
validate(attributeMap, { str: { endsWith: ['cat', 'dog', 'ted'] } }, [options])
).toBeUndefined();
expect(validate(attributeMap, { list: { endsWith: ['HI'] } }, [options])).toBeUndefined();
expect(
validate(attributeMap, { list: { endsWith: ['bc', 'dog', 'ted'] } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { list: { endsWith: ['bye', 'dog'] } }, [options])
).not.toBeUndefined();
});
});
describe('greaterThan', () => {
it('returns undefined on greaterThan', () => {
expect(
validate(attributeMap, { num: { greaterThan: { value: attributeMap.num - 1 } } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { num: { greaterThan: attributeMap.num - 1 } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { num: { greaterThan: [attributeMap.num - 1] } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { num: { greaterThan: [attributeMap.num + 1] } }, [options])
).not.toBeUndefined();
});
});
describe('lessThan', () => {
it('returns undefined on lessThan', () => {
expect(
validate(attributeMap, { num: { lessThan: { value: attributeMap.num + 1 } } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { num: { lessThan: attributeMap.num + 1 } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { num: { lessThan: [attributeMap.num + 1] } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { num: { lessThan: [attributeMap.num - 1] } }, [options])
).not.toBeUndefined();
});
});
describe('range', () => {
it('returns undefined if the value is between', () => {
expect(
validate(attributeMap, { num: { range: [attributeMap.num + 1, attributeMap.num - 1] } }, [
options,
])
).toBeUndefined();
expect(validate(attributeMap, { num: { range: [1, 4] } }, [options])).toBeUndefined();
expect(validate(attributeMap, { num: { range: [1, 2] } }, [options])).not.toBeUndefined();
expect(validate(attributeMap, { num: { range: [4, 5] } }, [options])).not.toBeUndefined();
expect(validate(attributeMap, { num: { range: [5] } }, [options])).not.toBeUndefined();
expect(validate(attributeMap, { num: { range: 5 } }, [options])).not.toBeUndefined();
});
});
});

View File

@@ -0,0 +1,505 @@
import MeasurementService from './MeasurementService';
import log from '../../log';
jest.mock('../../log', () => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}));
describe('MeasurementService.js', () => {
const unmappedMeasurementUID = 'unmappedMeasurementUId';
let measurementService;
let measurement;
let unmappedMeasurement;
let source;
let annotationType;
let matchingCriteria;
let toSourceSchema;
let toMeasurement;
let toMeasurementThrowsError;
let annotation;
beforeEach(() => {
measurementService = new MeasurementService();
source = measurementService.createSource('Test', '1');
annotationType = 'Length';
annotation = {
toolName: annotationType,
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,
};
// A measurement with various metadata missing (e.g. referenced SOPInstanceUID) that
// would not typically get mapped my the MeasurementService possibly because it was
// made in a non-acquisition plane of a volume.
unmappedMeasurement = {
uid: unmappedMeasurementUID,
SOPInstanceUID: undefined,
FrameOfReferenceUID: undefined,
referenceSeriesUID: undefined,
label: 'Label',
description: 'Description',
unit: 'mm',
area: 123,
type: measurementService.VALUE_TYPES.POLYLINE,
points: [
{ x: 1, y: 2 },
{ x: 1, y: 2 },
],
source: source,
};
toSourceSchema = () => annotation;
toMeasurement = () => {
if (Object.keys(measurement).includes('invalidProperty')) {
throw new Error('Measurement does not match schema');
}
return measurement;
};
toMeasurementThrowsError = () => {
throw new Error('Unmapped 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('throws Error if no name provided', () => {
expect(() => {
measurementService.createSource(null, '1');
}).toThrow(new Error('Source name not provided.'));
});
it('throws Error if no version provided', () => {
expect(() => {
measurementService.createSource('Testing', null);
}).toThrow(new Error('Source version not provided.'));
});
});
describe('addMapping()', () => {
it('adds new mapping', () => {
measurementService.addMapping(
source,
annotationType,
matchingCriteria,
toSourceSchema,
toMeasurement
);
});
it('throws Error if invalid source provided', () => {
expect(() => {
const invalidSource = {};
measurementService.addMapping(
invalidSource,
annotationType,
matchingCriteria,
toSourceSchema,
toMeasurement
);
}).toThrow(new Error('Invalid source.'));
});
it('throws Error if no matching criteria provided', () => {
expect(() => {
measurementService.addMapping(source, annotationType, null, toSourceSchema, toMeasurement);
}).toThrow(new Error('Matching criteria not provided.'));
});
it('throws Error if no source provided', () => {
expect(() => {
measurementService.addMapping(
null /* source */,
annotationType,
matchingCriteria,
toSourceSchema,
toMeasurement
);
}).toThrow(new Error('Invalid source.'));
});
it('logs warning and return early if no AnnotationType provided', () => {
expect(() => {
measurementService.addMapping(
source,
null /* AnnotationType */,
matchingCriteria,
toSourceSchema,
toMeasurement
);
}).toThrow(new Error('annotationType not provided.'));
});
it('throws Error if no measurement mapping function provided', () => {
expect(() => {
measurementService.addMapping(
source,
annotationType,
matchingCriteria,
null /* toSourceSchema */,
toMeasurement
);
}).toThrow(new Error('Mapping function to source schema not provided.'));
});
it('throws Error if no annotation mapping function provided', () => {
expect(() => {
measurementService.addMapping(
source,
annotationType,
matchingCriteria,
toSourceSchema,
null /* toMeasurement */
);
}).toThrow(new Error('Measurement mapping function not provided.'));
});
});
describe('getAnnotation()', () => {
it('get annotation based on matched criteria', () => {
measurementService.addMapping(
source,
annotationType,
matchingCriteria,
toSourceSchema,
toMeasurement
);
const measurementId = source.annotationToMeasurement(annotationType, annotation);
const mappedAnnotation = source.getAnnotation(annotationType, measurementId);
expect(annotation).toBe(mappedAnnotation);
});
it('get annotation based on source and annotationType', () => {
measurementService.addMapping(source, annotationType, {}, toSourceSchema, toMeasurement);
const measurementId = source.annotationToMeasurement(annotationType, annotation);
const mappedAnnotation = source.getAnnotation(annotationType, measurementId);
expect(annotation).toBe(mappedAnnotation);
});
});
describe('getMeasurements()', () => {
it('return all measurement service measurements', () => {
const anotherMeasurement = {
...measurement,
label: 'Label2',
unit: 'HU',
};
measurementService.addMapping(
source,
annotationType,
matchingCriteria,
toSourceSchema,
toMeasurement
);
source.annotationToMeasurement(annotationType, measurement);
source.annotationToMeasurement(annotationType, anotherMeasurement);
const measurements = measurementService.getMeasurements();
expect(measurements.length).toEqual(2);
});
});
describe('getMeasurement()', () => {
it('return measurement service measurement with given id', () => {
measurementService.addMapping(
source,
annotationType,
matchingCriteria,
toSourceSchema,
toMeasurement
);
const uid = source.annotationToMeasurement(annotationType, measurement);
const returnedMeasurement = measurementService.getMeasurement(uid);
/* Clear dynamic data */
delete returnedMeasurement.modifiedTimestamp;
expect({ uid, ...measurement }).toEqual(returnedMeasurement);
});
});
describe('annotationToMeasurement()', () => {
it('adds new measurements', () => {
measurementService.addMapping(
source,
annotationType,
matchingCriteria,
toSourceSchema,
toMeasurement
);
source.annotationToMeasurement(annotationType, measurement);
source.annotationToMeasurement(annotationType, measurement);
const measurements = measurementService.getMeasurements();
expect(measurements.length).toBe(2);
});
it('fails to add new measurements when no mapping', () => {
expect(() => {
source.annotationToMeasurement(annotationType, measurement);
}).toThrow();
});
it('fails to add new measurements when invalid mapping function', () => {
measurementService.addMapping(
source,
annotationType,
matchingCriteria,
toSourceSchema,
1 /* Invalid */
);
expect(() => {
source.annotationToMeasurement(annotationType, measurement);
}).toThrow();
});
it('adds new measurement with custom uid', () => {
const newMeasurement = { uid: 1, ...measurement };
measurementService.addMapping(
source,
annotationType,
matchingCriteria,
toSourceSchema,
toMeasurement
);
/* Add new measurement */
source.annotationToMeasurement(annotationType, newMeasurement);
const savedMeasurement = measurementService.getMeasurement(newMeasurement.uid);
/* Clear dynamic data */
delete newMeasurement.modifiedTimestamp;
delete savedMeasurement.modifiedTimestamp;
expect(newMeasurement).toEqual(savedMeasurement);
});
it('throws Error if adding invalid measurement', () => {
measurement.invalidProperty = {};
measurementService.addMapping(
source,
annotationType,
matchingCriteria,
toSourceSchema,
toMeasurement
);
expect(() => {
source.annotationToMeasurement(annotationType, measurement);
}).toThrow();
});
it('throws Error if adding measurement with unknown schema key', () => {
measurementService.addMapping(
source,
annotationType,
matchingCriteria,
toSourceSchema,
() => {
return {
...measurement,
invalidSchemaKey: 0,
};
}
);
expect(() => {
source.annotationToMeasurement(annotationType, measurement);
}).toThrow();
});
it('updates existing measurement', () => {
measurementService.addMapping(
source,
annotationType,
matchingCriteria,
toSourceSchema,
toMeasurement
);
const uid = source.annotationToMeasurement(annotationType, measurement);
measurement.unit = 'HU';
source.annotationToMeasurement(annotationType, { uid, ...measurement });
const updatedMeasurement = measurementService.getMeasurement(uid);
expect(updatedMeasurement.unit).toBe('HU');
});
});
describe('subscribe()', () => {
it('subscribers receive broadcasted add event', () => {
measurementService.addMapping(
source,
annotationType,
matchingCriteria,
toSourceSchema,
toMeasurement
);
const { MEASUREMENT_ADDED } = measurementService.EVENTS;
let addCallbackWasCalled = false;
/* Subscribe to add event */
measurementService.subscribe(MEASUREMENT_ADDED, () => (addCallbackWasCalled = true));
/* Add new measurement - two calls needed for the start and the other for the completed*/
const uid = source.annotationToMeasurement(annotationType, measurement);
source.annotationToMeasurement(annotationType, { uid, ...measurement });
expect(addCallbackWasCalled).toBe(true);
});
it('subscribers receive broadcasted update event', () => {
measurementService.addMapping(
source,
annotationType,
matchingCriteria,
toSourceSchema,
toMeasurement
);
const { MEASUREMENT_UPDATED } = measurementService.EVENTS;
let updateCallbackWasCalled = false;
/* Subscribe to update event */
measurementService.subscribe(MEASUREMENT_UPDATED, () => (updateCallbackWasCalled = true));
/* Create measurement */
const uid = source.annotationToMeasurement(annotationType, measurement);
/* Update measurement */
source.annotationToMeasurement(annotationType, { uid, ...measurement }, true);
expect(updateCallbackWasCalled).toBe(true);
});
it('unsubscribes a listener', () => {
measurementService.addMapping(
source,
annotationType,
matchingCriteria,
toSourceSchema,
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 - two calls needed one to start and one to complete */
const uid = source.annotationToMeasurement(annotationType, measurement);
source.annotationToMeasurement(annotationType, { uid, ...measurement });
expect(updateCallbackWasCalled).toBe(false);
});
it('subscribers do NOT receive add unmapped measurements event', () => {
measurementService.addMapping(
source,
annotationType,
matchingCriteria,
toSourceSchema,
toMeasurementThrowsError
);
const { MEASUREMENT_ADDED } = measurementService.EVENTS;
let addCallbackWasCalled = false;
/* Subscribe to add event */
measurementService.subscribe(MEASUREMENT_ADDED, () => (addCallbackWasCalled = true));
/* Add new measurement - two calls needed for the start and the other for the completed*/
// expect exceptions for unmapped measurements
expect(() => {
source.annotationToMeasurement(annotationType, unmappedMeasurement);
}).toThrow();
expect(() => {
source.annotationToMeasurement(annotationType, {
unmappedMeasurementUID,
...unmappedMeasurement,
});
}).toThrow();
expect(addCallbackWasCalled).toBe(false);
});
it('subscribers do receive remove unmapped measurements event', () => {
measurementService.addMapping(
source,
annotationType,
matchingCriteria,
toSourceSchema,
toMeasurementThrowsError
);
const { MEASUREMENT_REMOVED } = measurementService.EVENTS;
let removeCallbackWasCalled = false;
/* Subscribe to add event */
measurementService.subscribe(MEASUREMENT_REMOVED, () => (removeCallbackWasCalled = true));
/* Add new measurement - two calls needed for the start and the other for the completed*/
// expect exceptions for unmapped measurements
expect(() => {
source.annotationToMeasurement(annotationType, unmappedMeasurement);
}).toThrow();
expect(() => {
source.annotationToMeasurement(annotationType, {
unmappedMeasurementUID,
...unmappedMeasurement,
});
}).toThrow();
measurementService.remove(unmappedMeasurementUID);
expect(removeCallbackWasCalled).toBe(true);
});
});
});

View File

@@ -0,0 +1,792 @@
import log from '../../log';
import guid from '../../utils/guid';
import { PubSubService } from '../_shared/pubSubServiceInterface';
/**
* Measurement source schema
*
* @typedef {Object} MeasurementSource
* @property {number} id -
* @property {string} name -
* @property {string} version -
*/
/**
* Measurement schema
*
* @typedef {Object} Measurement
* @property {number} uid -
* @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 -
* @property {boolean} selected -
*/
/* Measurement schema keys for object validation. */
const MEASUREMENT_SCHEMA_KEYS = [
'uid',
'color',
'data',
'getReport',
'displayText',
'SOPInstanceUID',
'FrameOfReferenceUID',
'referenceStudyUID',
'referenceSeriesUID',
'frameNumber',
'displaySetInstanceUID',
'label',
'isLocked',
'isVisible',
'description',
'type',
'unit',
'points',
'source',
'toolName',
'metadata',
// Todo: we shouldn't need to have all these here.
'area', // TODO: Add concept names instead (descriptor)
'mean',
'stdDev',
'perimeter',
'length',
'shortestDiameter',
'longestDiameter',
'cachedStats',
'isSelected',
'textBox',
'referencedImageId',
];
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',
// Give the viewport a chance to jump to the measurement
JUMP_TO_MEASUREMENT_VIEWPORT: 'event:jump_to_measurement_viewport',
// Give the layout a chance to jump to the measurement
JUMP_TO_MEASUREMENT_LAYOUT: 'event:jump_to_measurement_layout',
};
const VALUE_TYPES = {
ANGLE: 'value_type::polyline',
POLYLINE: 'value_type::polyline',
POINT: 'value_type::point',
BIDIRECTIONAL: 'value_type::shortAxisLongAxis', // TODO -> Discuss with Danny. => just using SCOORD values isn't enough here.
ELLIPSE: 'value_type::ellipse',
RECTANGLE: 'value_type::rectangle',
MULTIPOINT: 'value_type::multipoint',
CIRCLE: 'value_type::circle',
ROI_THRESHOLD: 'value_type::roiThreshold',
ROI_THRESHOLD_MANUAL: 'value_type::roiThresholdManual',
};
/**
* MeasurementService class that supports source management and measurement management.
* Sources can be any library that can provide "annotations" (e.g. cornerstone-tools, cornerstone, etc.)
* The flow, is that by creating a source and mappings (annotation <-> measurement), we
* can convert back and forth between the two. MeasurementPanel in OHIF uses the measurement service
* to manage the measurements, and any edit to the measurements will be reflected back at the
* library level state (e.g. cornerstone-tools, cornerstone, etc.) by converting the
* edited measurements back to the original annotations and then updating the annotations.
*
* Note and Todo: We should be able to support measurements that are composed of multiple
* annotations, but that is not the case at the moment.
*/
class MeasurementService extends PubSubService {
public static REGISTRATION = {
name: 'measurementService',
altName: 'MeasurementService',
create: ({ configuration = {} }) => {
return new MeasurementService();
},
};
public static readonly EVENTS = EVENTS;
public static VALUE_TYPES = VALUE_TYPES;
public readonly VALUE_TYPES = VALUE_TYPES;
private measurements = new Map();
private unmappedMeasurements = new Map();
constructor() {
super(EVENTS);
this.sources = {};
this.mappings = {};
}
/**
* Adds the given schema to the measurement service schema list.
* This method should be used to add custom tool schema to the measurement service.
* @param {Array} schema schema for validation
*/
public addMeasurementSchemaKeys(schema): void {
if (!Array.isArray(schema)) {
schema = [schema];
}
MEASUREMENT_SCHEMA_KEYS.push(...schema);
}
/**
* Adds the given valueType to the measurement service valueType object.
* This method should be used to add custom valueType to the measurement service.
* @param {*} valueType
* @returns
*/
addValueType(valueType) {
if (VALUE_TYPES[valueType]) {
return;
}
// check if valuetype is valid , and if values are strings
if (!valueType || typeof valueType !== 'object') {
console.warn(`MeasurementService: addValueType: invalid valueType: ${valueType}`);
return;
}
Object.keys(valueType).forEach(key => {
if (!VALUE_TYPES[key]) {
VALUE_TYPES[key] = valueType[key];
}
});
}
/**
* Get all measurements.
*
* @return {Measurement[]} Array of measurements
*/
getMeasurements() {
return [...this.measurements.values()];
}
/**
* Get specific measurement by its uid.
*
* @param {string} uid measurement uid
* @return {Measurement} Measurement instance
*/
public getMeasurement(measurementUID: string) {
return this.measurements.get(measurementUID);
}
public setMeasurementSelected(measurementUID: string, selected: boolean): void {
const measurement = this.getMeasurement(measurementUID);
if (!measurement) {
return;
}
measurement.isSelected = selected;
this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, {
source: measurement.source,
measurement,
notYetUpdatedAtSource: false,
});
}
/**
* 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) {
throw new Error('Source name not provided.');
}
if (!version) {
throw new Error('Source version not provided.');
}
// Go over all the keys inside the sources and check if the source
// name and version matches with the existing sources.
const sourceKeys = Object.keys(this.sources);
for (let i = 0; i < sourceKeys.length; i++) {
const source = this.sources[sourceKeys[i]];
if (source.name === name && source.version === version) {
return source;
}
}
const uid = guid();
const source = {
uid,
name,
version,
};
source.annotationToMeasurement = (annotationType, annotation, isUpdate = false) => {
return this.annotationToMeasurement(source, annotationType, annotation, isUpdate);
};
source.remove = (measurementUID, eventDetails) => {
return this.remove(measurementUID, source, eventDetails);
};
source.getAnnotation = (annotationType, measurementId) => {
return this.getAnnotation(source, annotationType, measurementId);
};
log.info(`New '${name}@${version}' source added.`);
this.sources[uid] = source;
return source;
}
getSource(name, version) {
const { sources } = this;
const uid = this._getSourceUID(name, version);
return sources[uid];
}
getSourceMappings(name, version) {
const { mappings } = this;
const uid = this._getSourceUID(name, version);
return mappings[uid];
}
/**
* Add a new measurement matching criteria along with mapping functions.
*
* @param {MeasurementSource} source Measurement source instance
* @param {string} annotationType annotation type to match which can be e.g., Length, Bidirectional, etc.
* @param {MatchingCriteria} matchingCriteria The matching criteria
* @param {Function} toAnnotationSchema Mapping function to annotation schema
* @param {Function} toMeasurementSchema Mapping function to measurement schema
* @return void
*/
addMapping(source, annotationType, matchingCriteria, toAnnotationSchema, toMeasurementSchema) {
if (!this._isValidSource(source)) {
throw new Error('Invalid source.');
}
if (!matchingCriteria) {
throw new Error('Matching criteria not provided.');
}
if (!annotationType) {
throw new Error('annotationType not provided.');
}
if (!toAnnotationSchema) {
throw new Error('Mapping function to source schema not provided.');
}
if (!toMeasurementSchema) {
throw new Error('Measurement mapping function not provided.');
}
const mapping = {
matchingCriteria,
annotationType,
toAnnotationSchema,
toMeasurementSchema,
};
if (Array.isArray(this.mappings[source.uid])) {
this.mappings[source.uid].push(mapping);
} else {
this.mappings[source.uid] = [mapping];
}
log.info(`New measurement mapping added to source '${this._getSourceToString(source)}'.`);
}
/**
* Get annotation for specific source.
*
* @param {MeasurementSource} source Measurement source instance
* @param {string} annotationType The source annotationType
* @param {string} measurementUID The measurement service measurement uid
* @return {Object} Source measurement schema
*/
getAnnotation(source, annotationType, measurementUID) {
if (!this._isValidSource(source)) {
log.warn('Invalid source. Exiting early.');
return;
}
if (!annotationType) {
log.warn('No source annotationType provided. Exiting early.');
return;
}
const measurement = this.getMeasurement(measurementUID);
const mapping = this._getMappingByMeasurementSource(measurement, annotationType);
if (mapping) {
return mapping.toAnnotationSchema(measurement, annotationType);
}
const matchingMapping = this._getMatchingMapping(source, annotationType, measurement);
if (matchingMapping) {
log.info('Matching mapping found:', matchingMapping);
const { toAnnotationSchema, annotationType } = matchingMapping;
return toAnnotationSchema(measurement, annotationType);
}
}
update(measurementUID: string, measurement, notYetUpdatedAtSource = false) {
if (!this.measurements.has(measurementUID)) {
return;
}
const updatedMeasurement = {
...measurement,
modifiedTimestamp: Math.floor(Date.now() / 1000),
};
log.info(`Updating internal measurement representation...`, updatedMeasurement);
this.measurements.set(measurementUID, updatedMeasurement);
this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, {
source: measurement.source,
measurement: updatedMeasurement,
notYetUpdatedAtSource,
});
return updatedMeasurement.uid;
}
/**
* Add a raw measurement into a source so that it may be
* 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} annotationType The source annotationType 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 annotationType.
*/
addRawMeasurement(source, annotationType, data, toMeasurementSchema, dataSource = {}) {
if (!this._isValidSource(source)) {
log.warn('Invalid source. Exiting early.');
return;
}
const sourceInfo = this._getSourceToString(source);
if (!annotationType) {
log.warn('No source annotationType provided. Exiting early.');
return;
}
if (!this._sourceHasMappings(source)) {
log.warn(`No measurement mappings found for '${sourceInfo}' source. Exiting early.`);
return;
}
let measurement = {};
try {
measurement = toMeasurementSchema(data);
measurement.source = source;
} catch (error) {
log.warn(
`Failed to map '${sourceInfo}' measurement for annotationType ${annotationType}:`,
error.message
);
return;
}
if (!this._isValidMeasurement(measurement)) {
log.warn(
`Attempting to add or update a invalid measurement provided by '${sourceInfo}'. Exiting early.`
);
return;
}
let internalUID = data.id;
if (!internalUID) {
internalUID = guid();
log.warn(`Measurement ID not found. Generating UID: ${internalUID}`);
}
const annotationData = data.annotation.data;
const newMeasurement = {
finding: annotationData.finding,
findingSites: annotationData.findingSites,
site: annotationData.findingSites?.[0],
...measurement,
modifiedTimestamp: Math.floor(Date.now() / 1000),
uid: internalUID,
};
if (this.measurements.get(internalUID)) {
this.measurements.set(internalUID, newMeasurement);
this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, {
source,
measurement: newMeasurement,
});
} else {
log.info('Measurement added', newMeasurement);
this.measurements.set(internalUID, newMeasurement);
this._broadcastEvent(this.EVENTS.RAW_MEASUREMENT_ADDED, {
source,
measurement: newMeasurement,
data,
dataSource,
});
}
return newMeasurement.uid;
}
/**
* Adds or update persisted measurements.
*
* @param {MeasurementSource} source The measurement source instance
* @param {string} annotationType The source annotationType
* @param {EventDetail} sourceAnnotationDetail for the annotation event
* @param {boolean} isUpdate is this an update or an add/completed instead?
* @return {string} A measurement uid
*/
annotationToMeasurement(source, annotationType, sourceAnnotationDetail, isUpdate = false) {
if (!this._isValidSource(source)) {
throw new Error('Invalid source.');
}
if (!annotationType) {
throw new Error('No source annotationType provided.');
}
const sourceInfo = this._getSourceToString(source);
if (!this._sourceHasMappings(source)) {
throw new Error(`No measurement mappings found for '${sourceInfo}' source. Exiting early.`);
}
let measurement = {};
try {
const sourceMappings = this.mappings[source.uid];
const sourceMapping = sourceMappings.find(
mapping => mapping.annotationType === annotationType
);
if (!sourceMapping) {
console.log('No source mapping', source);
return;
}
const { toMeasurementSchema } = sourceMapping;
/* Convert measurement */
measurement = toMeasurementSchema(sourceAnnotationDetail);
if (!measurement) {
return;
}
measurement.source = source;
} catch (error) {
// Todo: handle other
this.unmappedMeasurements.set(sourceAnnotationDetail.uid, {
...sourceAnnotationDetail,
source: {
name: source.name,
version: source.version,
uid: source.uid,
},
});
console.log('Failed to map', error);
throw new Error(
`Failed to map '${sourceInfo}' measurement for annotationType ${annotationType}: ${error.message}`
);
}
if (!this._isValidMeasurement(measurement)) {
throw new Error(
`Attempting to add or update a invalid measurement provided by '${sourceInfo}'. Exiting early.`
);
}
// Todo: we are using uid on the eventDetail, it should be uid of annotation
let internalUID = sourceAnnotationDetail.uid;
if (!internalUID) {
internalUID = guid();
log.info(
`Annotation does not have UID, Generating UID for the created Measurement: ${internalUID}`
);
}
const oldMeasurement = this.measurements.get(internalUID);
const newMeasurement = {
...oldMeasurement,
...measurement,
modifiedTimestamp: Math.floor(Date.now() / 1000),
uid: internalUID,
};
if (oldMeasurement) {
// TODO: Ultimately, each annotation should have a selected flag right from the source.
// For now, it is just added in OHIF here and in setMeasurementSelected.
this.measurements.set(internalUID, newMeasurement);
if (isUpdate) {
this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, {
source,
measurement: newMeasurement,
notYetUpdatedAtSource: false,
});
} else {
log.info('Measurement added.', newMeasurement);
this._broadcastEvent(this.EVENTS.MEASUREMENT_ADDED, {
source,
measurement: newMeasurement,
});
}
} else {
log.info('Measurement started.', newMeasurement);
this.measurements.set(internalUID, newMeasurement);
}
return newMeasurement.uid;
}
/**
* Removes a measurement and broadcasts the removed event.
*
* @param {string} measurementUID The measurement uid
*/
remove(measurementUID: string): void {
const measurement =
this.measurements.get(measurementUID) || this.unmappedMeasurements.get(measurementUID);
if (!measurementUID || !measurement) {
console.debug(`No uid provided, or unable to find measurement by uid.`);
return;
}
const source = measurement.source;
this.unmappedMeasurements.delete(measurementUID);
this.measurements.delete(measurementUID);
this._broadcastEvent(this.EVENTS.MEASUREMENT_REMOVED, {
source,
measurement: measurementUID,
});
}
clearMeasurements() {
// Make a copy of the measurements
const measurements = [...this.measurements.values(), ...this.unmappedMeasurements.values()];
this.unmappedMeasurements.clear();
this.measurements.clear();
this._broadcastEvent(this.EVENTS.MEASUREMENTS_CLEARED, { measurements });
}
/**
* Called after the mode.onModeExit is called to reset the state.
* To store measurements for later use, store them in the mode.onModeExit
* and restore them in the mode onModeEnter.
*/
onModeExit() {
this.clearMeasurements();
}
/**
* This method calls the subscriptions for JUMP_TO_MEASUREMENT_VIEWPORT
* and JUMP_TO_MEASUREMENT_LAYOUT. There are two events which are
* fired because there are two different items which might want to handle
* the event. First, there might already be a viewport which can handle
* the event. If so, then the layout doesn't need to necessarily change.
* This is communicated by the isConsumed value on the event itself.
* Otherwise, the layout itself may need to be navigated to in order
* to provide a viewport which can show the given measurement.
*
* When a viewport decides to apply the event, it should call the consume()
* method on the event, so that other listeners know they do not need to
* navigate. This does NOT affect whether the layout event is fired, and
* merely causes it to fire the event with the isConsumed set to true.
*/
public jumpToMeasurement(viewportId: string, measurementUID: string): void {
const measurement = this.measurements.get(measurementUID);
if (!measurement) {
log.warn(`No measurement uid, or unable to find by uid.`);
return;
}
const consumableEvent = this.createConsumableEvent({
viewportId,
measurement,
});
this._broadcastEvent(EVENTS.JUMP_TO_MEASUREMENT_VIEWPORT, consumableEvent);
this._broadcastEvent(EVENTS.JUMP_TO_MEASUREMENT_LAYOUT, consumableEvent);
}
_getSourceUID(name, version) {
const { sources } = this;
const sourceUID = Object.keys(sources).find(sourceUID => {
const source = sources[sourceUID];
return source.name === name && source.version === version;
});
return sourceUID;
}
_getMappingByMeasurementSource(measurement, annotationType) {
if (this._isValidSource(measurement.source)) {
return this.mappings[measurement.source.uid].find(m => m.annotationType === annotationType);
}
}
/**
* Get measurement mapping function if matching criteria.
*
* @param {MeasurementSource} source Measurement source instance
* @param {string} annotationType The source annotationType
* @param {Measurement} measurement The measurement service measurement
* @return {Object} The mapping based on matched criteria
*/
_getMatchingMapping(source, annotationType, measurement) {
const sourceMappings = this.mappings[source.uid];
const sourceMappingsByDefinition = sourceMappings.filter(
mapping => mapping.annotationType === annotationType
);
/* 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
*/
_getSourceToString(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.uid];
}
/**
* 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.uid]) && this.mappings[source.uid].length;
}
/**
* Check if a given measurement data is valid.
*
* @param {Measurement} measurementData Measurement data
* @return {boolean} Measurement validation
*/
_isValidMeasurement(measurementData) {
return Object.keys(measurementData).every(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] }));
};
public toggleLockMeasurement(measurementUID: string): void {
const measurement = this.measurements.get(measurementUID);
if (!measurement) {
console.debug(`No measurement found for uid: ${measurementUID}`);
return;
}
measurement.isLocked = !measurement.isLocked;
this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, {
source: measurement.source,
measurement,
notYetUpdatedAtSource: true,
});
}
public toggleVisibilityMeasurement(measurementUID: string): void {
const measurement = this.measurements.get(measurementUID);
if (!measurement) {
console.debug(`No measurement found for uid: ${measurementUID}`);
return;
}
measurement.isVisible = !measurement.isVisible;
this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, {
source: measurement.source,
measurement,
notYetUpdatedAtSource: true,
});
}
public updateColorMeasurement(measurementUID: string, color: number[]): void {
const measurement = this.measurements.get(measurementUID);
if (!measurement) {
console.debug(`No measurement found for uid: ${measurementUID}`);
return;
}
measurement.color = color;
this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, {
source: measurement.source,
measurement,
notYetUpdatedAtSource: true,
});
}
}
export default MeasurementService;
export { EVENTS, VALUE_TYPES };

View File

@@ -0,0 +1,3 @@
import MeasurementService from './MeasurementService';
export default MeasurementService;

View File

@@ -0,0 +1,229 @@
import React from 'react';
import { ActivatePanelTriggers } from '../../types';
import { Subscription } from '../../types/IPubSub';
import { PubSubService } from '../_shared/pubSubServiceInterface';
import { ExtensionManager } from '../../extensions';
export const EVENTS = {
PANELS_CHANGED: 'event::panelService:panelsChanged',
ACTIVATE_PANEL: 'event::panelService:activatePanel',
};
type PanelData = {
id: string;
iconName: string;
iconLabel: string;
label: string;
name: string;
content: unknown;
};
export enum PanelPosition {
Left = 'left',
Right = 'right',
Bottom = 'bottom',
}
export default class PanelService extends PubSubService {
private _extensionManager: ExtensionManager;
public static REGISTRATION = {
name: 'panelService',
create: ({ extensionManager }): PanelService => {
return new PanelService(extensionManager);
},
};
private _panelsGroups: Map<PanelPosition, PanelData[]> = new Map();
constructor(extensionManager: ExtensionManager) {
super(EVENTS);
this._extensionManager = extensionManager;
}
public get PanelPosition(): typeof PanelPosition {
return PanelPosition;
}
private _getPanelComponent(panelId: string) {
const entry = this._extensionManager.getModuleEntry(panelId);
if (!entry) {
// Check for similar panel names
const similarPanels = this._getSimilarPanels(panelId);
if (similarPanels.length > 0) {
const suggestion = `Did you mean: ${similarPanels.join(', ')}?`;
throw new Error(
`${panelId} is not a valid entry for an extension module. ${suggestion} Please check your configuration or make sure the extension is registered.`
);
} else {
throw new Error(
`${panelId} is not a valid entry for an extension module, please check your configuration or make sure the extension is registered.`
);
}
}
if (!entry?.component) {
throw new Error(
`No component found from extension ${panelId}. Check the reference string to the extension in your Mode configuration`
);
}
const content = entry.component;
return { entry, content };
}
private _getSimilarPanels(panelId: string, threshold = 0.8): string[] {
const registeredPanels = Object.keys(this._extensionManager.modulesMap).filter(name =>
name.includes('panelModule')
);
const similarPanels = registeredPanels.filter(registeredPanelId => {
const similarity = this._calculateSimilarity(panelId, registeredPanelId);
return similarity >= threshold;
});
return similarPanels;
}
private _calculateSimilarity(str1: string, str2: string): number {
const set1 = new Set(str1.toLowerCase().split(''));
const set2 = new Set(str2.toLowerCase().split(''));
const intersection = new Set([...set1].filter(x => set2.has(x)));
const union = new Set([...set1, ...set2]);
return intersection.size / union.size;
}
public getPanelData(panelId): PanelData {
let content, entry;
if (Array.isArray(panelId)) {
const panelsData = panelId.map(id => this._getPanelComponent(id));
// use the first panel's entry for the combined panel
entry = panelsData[0].entry;
// stack the content of the panels in one react component
content = props => (
<>
{panelsData.map(({ content: PanelContent }, index) => (
<PanelContent
key={index}
{...props}
/>
))}
</>
);
} else {
({ content, entry } = this._getPanelComponent(panelId));
}
return {
id: entry.id,
iconName: entry.iconName,
iconLabel: entry.iconLabel,
label: entry.label,
name: entry.name,
content,
};
}
public addPanel(position: PanelPosition, panelId: string, options): void {
let panels = this._panelsGroups.get(position);
if (!panels) {
panels = [];
this._panelsGroups.set(position, panels);
}
const panelComponent = this.getPanelData(panelId);
panels.push(panelComponent);
this._broadcastEvent(EVENTS.PANELS_CHANGED, { position, options });
}
public addPanels(position: PanelPosition, panelsIds: string[], options): void {
if (!Array.isArray(panelsIds)) {
throw new Error('Invalid "panelsIds" array');
}
panelsIds.forEach(panelId => this.addPanel(position, panelId, options));
}
public setPanels(
panels: { [key in PanelPosition]: string[] },
options: {
rightPanelClosed?: boolean;
leftPanelClosed?: boolean;
}
): void {
this.reset();
Object.keys(panels).forEach((position: PanelPosition) => {
this.addPanels(position, panels[position], options);
});
}
public getPanels(position: PanelPosition): PanelData[] {
const panels = this._panelsGroups.get(position) ?? [];
// Return a new array to preserve the internal state
return [...panels];
}
public reset(): void {
const affectedPositions = Array.from(this._panelsGroups.keys());
this._panelsGroups.clear();
affectedPositions.forEach(position =>
this._broadcastEvent(EVENTS.PANELS_CHANGED, { position })
);
}
public onModeExit(): void {
this.reset();
}
/**5
* Activates the panel with the given id. If the forceActive flag is false
* then it is up to the component containing the panel whether to activate
* it immediately or not. For instance, the panel might not be activated when
* the forceActive flag is false in the case where the user might have
* activated/displayed and then closed the panel already.
* Note that this method simply fires a broadcast event: ActivatePanelEvent.
* @param panelId the panel's id
* @param forceActive optional flag indicating if the panel should be forced to be activated or not
*/
public activatePanel(panelId: string, forceActive = false): void {
this._broadcastEvent(EVENTS.ACTIVATE_PANEL, { panelId, forceActive });
}
/**
* Adds a mapping of events (activatePanelTriggers.sourceEvents) broadcast by
* activatePanelTrigger.sourcePubSubService that
* when fired/broadcasted must in turn activate the panel with the given id.
* The subscriptions created are returned such that they can be managed and unsubscribed
* as appropriate.
* @param panelId the id of the panel to activate
* @param activatePanelTriggers an array of triggers
* @param forceActive optional flag indicating if the panel should be forced to be activated or not
* @returns an array of the subscriptions subscribed to
*/
public addActivatePanelTriggers(
panelId: string,
activatePanelTriggers: ActivatePanelTriggers[],
forceActive = false
): Subscription[] {
return activatePanelTriggers
.map(trigger =>
trigger.sourceEvents.map(eventName =>
trigger.sourcePubSubService.subscribe(eventName, () =>
this.activatePanel(panelId, forceActive)
)
)
)
.flat();
}
}

View File

@@ -0,0 +1,3 @@
import PanelService from './PanelService';
export default PanelService;

View File

@@ -0,0 +1,31 @@
import log from './../log.js';
/**
* The ServiceProvidersManager allows for a React context provider class to be registered
* for a particular service. This allows for extensions to register services
* with context providers and the providers will be instantiated and added to the
* DOM dynamically.
*/
export default class ServiceProvidersManager {
public providers = {};
public constructor() {
this.providers = {};
}
registerProvider(serviceName, provider) {
if (!serviceName) {
log.warn(
'Attempting to register a provider to a null/undefined service name. Exiting early.'
);
return;
}
if (!provider) {
log.warn('Attempting to register a null/undefined provider. Exiting early.');
return;
}
this.providers[serviceName] = provider;
}
}

View File

@@ -0,0 +1,97 @@
import ServicesManager from './ServicesManager';
import log from '../log';
jest.mock('./../log');
describe('ServicesManager', () => {
let servicesManager, commandsManager;
beforeEach(() => {
commandsManager = {
createContext: jest.fn(),
getContext: jest.fn(),
registerCommand: jest.fn(),
};
servicesManager = new ServicesManager(commandsManager);
log.warn.mockClear();
jest.clearAllMocks();
});
describe('registerServices()', () => {
it('calls registerService() for each service', () => {
servicesManager.registerService = jest.fn();
servicesManager.registerServices([
{ name: 'UINotificationTestService', create: jest.fn() },
{ name: 'UIModalTestService', create: jest.fn() },
]);
expect(servicesManager.registerService.mock.calls.length).toBe(2);
});
it('calls registerService() for each service passing its configuration if tuple', () => {
servicesManager.registerService = jest.fn();
const fakeConfiguration = { testing: true };
servicesManager.registerServices([
{ name: 'UINotificationTestService', create: jest.fn() },
[{ name: 'UIModalTestService', create: jest.fn() }, fakeConfiguration],
]);
expect(servicesManager.registerService.mock.calls[1][1]).toEqual(fakeConfiguration);
});
});
describe('registerService()', () => {
const fakeService = { name: 'UINotificationService', create: jest.fn() };
it('logs a warning if the service is null or undefined', () => {
const undefinedService = undefined;
const nullService = null;
servicesManager.registerService(undefinedService);
servicesManager.registerService(nullService);
expect(log.warn.mock.calls.length).toBe(2);
});
it('logs a warning if the service does not have a name', () => {
const serviceWithEmptyName = { name: '', create: jest.fn() };
const serviceWithoutName = { create: jest.fn() };
servicesManager.registerService(serviceWithEmptyName);
servicesManager.registerService(serviceWithoutName);
expect(log.warn.mock.calls.length).toBe(2);
});
it('logs a warning if the service does not have a create factory function', () => {
const serviceWithoutCreate = { name: 'UINotificationService' };
servicesManager.registerService(serviceWithoutCreate);
expect(log.warn.mock.calls.length).toBe(1);
});
it('tracks which services have been registered', () => {
servicesManager.registerService(fakeService);
expect(servicesManager.registeredServiceNames).toContain(fakeService.name);
});
it('logs a warning if the service has an name that has already been registered', () => {
servicesManager.registerService(fakeService);
servicesManager.registerService(fakeService);
expect(log.warn.mock.calls.length).toBe(1);
});
it('pass dependencies and configuration to service create factory function', () => {
const configuration = { config: 'Some configuration' };
servicesManager.registerService(fakeService, configuration);
expect(fakeService.create.mock.calls[0][0].configuration.config).toBe(configuration.config);
});
});
});

View File

@@ -0,0 +1,85 @@
import log from './../log.js';
import CommandsManager from '../classes/CommandsManager';
import ExtensionManager from '../extensions/ExtensionManager';
export default class ServicesManager {
public services: AppTypes.Services = {};
public registeredServiceNames: string[] = [];
private _commandsManager: CommandsManager;
private _extensionManager: ExtensionManager;
constructor(commandsManager: CommandsManager) {
this._commandsManager = commandsManager;
this._extensionManager = null;
this.services = {};
this.registeredServiceNames = [];
}
setExtensionManager(extensionManager) {
this._extensionManager = extensionManager;
}
/**
* Registers a new service.
*
* @param {Object} service
* @param {Object} configuration
*/
registerService(service, configuration = {}) {
if (!service) {
log.warn('Attempting to register a null/undefined service. Exiting early.');
return;
}
if (!service.name) {
log.warn(`Service name not set. Exiting early.`);
return;
}
if (this.registeredServiceNames.includes(service.name)) {
log.warn(
`Service name ${service.name} has already been registered. Exiting before duplicating services.`
);
return;
}
if (service.create) {
this.services[service.name] = service.create({
configuration,
extensionManager: this._extensionManager,
commandsManager: this._commandsManager,
servicesManager: this,
extensionManager: this._extensionManager,
});
if (service.altName) {
// TODO - remove this registration
this.services[service.altName] = this.services[service.name];
}
} else {
log.warn(`Service create factory function not defined. Exiting early.`);
return;
}
/* Track service registration */
this.registeredServiceNames.push(service.name);
}
/**
* An array of services, or an array of arrays that contains service
* configuration pairs.
*
* @param {Object[]} services - Array of services
*/
registerServices(services) {
services.forEach(service => {
const hasConfiguration = Array.isArray(service);
if (hasConfiguration) {
const [ohifService, configuration] = service;
this.registerService(ohifService, configuration);
} else {
this.registerService(service);
}
});
}
}

View File

@@ -0,0 +1,698 @@
import { PubSubService } from '../_shared/pubSubServiceInterface';
import { ExtensionManager } from '../../extensions';
import ServicesManager from '../ServicesManager';
import ViewportGridService from '../ViewportGridService';
import { DisplaySet } from '../../types';
enum RequestType {
/** Highest priority for loading*/
Interaction = 'interaction',
/** Second highest priority for loading*/
Thumbnail = 'thumbnail',
/** Third highest priority for loading, usually used for image loading in the background*/
Prefetch = 'prefetch',
/** Lower priority, often used for background computations in the worker */
Compute = 'compute',
}
export const EVENTS = {
SERVICE_STARTED: 'event::studyPrefetcherService:started',
SERVICE_STOPPED: 'event::studyPrefetcherService:stopped',
DISPLAYSET_LOAD_PROGRESS: 'event::studyPrefetcherService:displaySetLoadProgress',
DISPLAYSET_LOAD_COMPLETE: 'event::studyPrefetcherService:displaySetLoadComplete',
};
/**
* Order used for prefetching display set
*/
enum StudyPrefetchOrder {
closest = 'closest',
downward = 'downward',
upward = 'upward',
}
/**
* Study Prefetcher configuration
*/
type StudyPrefetcherConfig = {
/* Enable/disable study prefetching service */
enabled: boolean;
/* Number of displaysets to be prefetched */
displaySetsCount: number;
/**
* Max number of concurrent prefetch requests
* High numbers may impact on the time to load a new dropped series because
* the browser will be busy with all prefetching requests. As soon as the
* prefetch requests get fulfilled the new ones from the new dropped series
* are sent to the server.
*
* TODO: abort all prefetch requests when a new series is loaded on a viewport.
* (need to add support for `AbortController` on Cornerstone)
* */
maxNumPrefetchRequests: number;
/* Display sets prefetching order (closest, downward and upward) */
order: StudyPrefetchOrder;
};
type DisplaySetLoadingState = {
displaySetInstanceUID: string;
numInstances: number;
pendingImageIds: Set<string>;
loadedImageIds: Set<string>;
failedImageIds: Set<string>;
loadingProgress: number;
};
type ImageRequest = {
displaySetInstanceUID: string;
imageId: string;
aborted: boolean;
};
type PubSubServiceSubscription = { unsubscribe: () => any };
interface ICache {
isImageCached(imageId: string): boolean;
}
interface IImageLoadPoolManager {
addRequest(
requestFn: () => Promise<any>,
type: string,
additionalDetails: Record<string, unknown>,
priority?: number
);
clearRequestStack(type: string): void;
}
interface IImageLoader {
loadAndCacheImage(imageId: string, options: any): Promise<any>;
}
type EventSubscription = {
unsubscribe: () => void;
};
interface IImageLoadEventsManager {
addEventListeners(
onImageLoaded: (evt: any) => void,
onImageLoadFailed: (evt: any) => void
): EventSubscription[];
}
class StudyPrefetcherService extends PubSubService {
private _extensionManager: ExtensionManager;
private _servicesManager: ServicesManager;
private _subscriptions: PubSubServiceSubscription[];
private _activeDisplaySetsInstanceUIDs: string[] = [];
private _pendingRequests: ImageRequest[] = [];
private _inflightRequests = new Map<string, ImageRequest>();
private _isRunning = false;
private _displaySetLoadingStates = new Map<string, DisplaySetLoadingState>();
private _imageIdsToDisplaySetsMap = new Map<string, Set<string>>();
private config: StudyPrefetcherConfig = {
/* Enable/disable study prefetching service */
enabled: false,
/* Number of displaysets to be prefetched */
displaySetsCount: 1,
/**
* Max number of concurrent prefetch requests
* High numbers may impact on the time to load a new dropped series because
* the browser will be busy with all prefetching requests. As soon as the
* prefetch requests get fulfilled the new ones from the new dropped series
* are sent to the server.
*
* TODO: abort all prefetch requests when a new series is loaded on a viewport.
* (need to add support for `AbortController` on Cornerstone)
* */
maxNumPrefetchRequests: 10,
/* Display sets prefetching order (closest, downward and upward) */
order: StudyPrefetchOrder.downward,
};
// Properties set by Cornerstone extension (initStudyPrefetcherService)
public requestType: string = RequestType.Prefetch;
public cache: ICache;
public imageLoadPoolManager: IImageLoadPoolManager;
public imageLoader: IImageLoader;
public imageLoadEventsManager: IImageLoadEventsManager;
public static REGISTRATION = {
name: 'studyPrefetcherService',
altName: 'StudyPrefetcherService',
create: ({ configuration, servicesManager, extensionManager }): StudyPrefetcherService => {
return new StudyPrefetcherService({
servicesManager,
extensionManager,
configuration,
});
},
};
constructor({
servicesManager,
extensionManager,
configuration,
}: {
servicesManager: ServicesManager;
extensionManager: ExtensionManager;
configuration: StudyPrefetcherConfig;
}) {
super(EVENTS);
this._servicesManager = servicesManager;
this._extensionManager = extensionManager;
this._subscriptions = [];
Object.assign(this.config, configuration);
}
public onModeEnter(): void {
this._addEventListeners();
}
/**
* The onModeExit returns the service to the initial state.
*/
public onModeExit(): void {
this._removeEventListeners();
this._stopPrefetching();
}
private _addImageLoadingEventsListeners() {
const fnOnImageLoadCompleted = (imageId: string) => {
// `sendNextRequests` must be called after image loaded/failed events
// to make sure prefetch requests shall be sent as soon as the active
// displaySets (active viewport) are loaded.
//
// PS: active display sets are not loaded by this service and that is why
// the requests shall not be in the inflight queue.
if (!this._inflightRequests.get(imageId)) {
this._sendNextRequests();
}
};
const fnImageLoadedEventListener = evt => {
const { image } = evt.detail;
const { imageId } = image;
this._moveImageIdToLoadedSet(imageId);
fnOnImageLoadCompleted(imageId);
};
const fnImageLoadFailedEventListener = evt => {
const { imageId } = evt.detail;
this._moveImageIdToFailedSet(imageId);
fnOnImageLoadCompleted(imageId);
};
return this.imageLoadEventsManager.addEventListeners(
fnImageLoadedEventListener,
fnImageLoadFailedEventListener
);
}
private _addServicesListeners() {
const { displaySetService, viewportGridService } = this._servicesManager.services;
// Restart the prefetcher after any change to the displaySets
// (eg: sorting the displaySets on StudyBrowser)
const displaySetsChangedSubscription = displaySetService.subscribe(
displaySetService.EVENTS.DISPLAY_SETS_CHANGED,
() => this._syncWithActiveViewport({ forceRestart: true })
);
// Loads new datasets when making a new viewport active
const viewportGridActiveViewportIdSubscription = viewportGridService.subscribe(
ViewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED,
({ viewportId }) => this._syncWithActiveViewport({ activeViewportId: viewportId })
);
// Continue loading datasets after changing the layout (eg: from 1x1 to 2x1)
const viewportGridLayoutChangedSubscription = viewportGridService.subscribe(
ViewportGridService.EVENTS.LAYOUT_CHANGED,
() => this._syncWithActiveViewport()
);
// Loads new datasets after loading a new display set on a viewport
const viewportGridStateChangedSubscription = viewportGridService.subscribe(
ViewportGridService.EVENTS.GRID_STATE_CHANGED,
() => this._syncWithActiveViewport()
);
// Loads the first datasets right after opening the viewer
const viewportGridViewportreadySubscription = viewportGridService.subscribe(
ViewportGridService.EVENTS.VIEWPORTS_READY,
() => {
this._syncWithActiveViewport();
this._startPrefetching();
}
);
return [
displaySetsChangedSubscription,
viewportGridActiveViewportIdSubscription,
viewportGridLayoutChangedSubscription,
viewportGridStateChangedSubscription,
viewportGridViewportreadySubscription,
];
}
private _addEventListeners() {
const imageLoadingEventsSubscriptions = this._addImageLoadingEventsListeners();
const servicesSubscriptions = this._addServicesListeners();
this._subscriptions.push(...imageLoadingEventsSubscriptions);
this._subscriptions.push(...servicesSubscriptions);
}
private _removeEventListeners() {
this._subscriptions.forEach(subscription => subscription.unsubscribe());
this._subscriptions = [];
}
private _syncWithActiveViewport({
activeViewportId,
forceRestart,
}: {
activeViewportId?: string;
forceRestart?: boolean;
} = {}) {
const { viewportGridService } = this._servicesManager.services;
const viewportGridServiceState = viewportGridService.getState();
const { viewports } = viewportGridServiceState;
activeViewportId = activeViewportId ?? viewportGridServiceState.activeViewportId;
// If may be null when the viewer is loaded
if (!activeViewportId) {
return;
}
const activeViewport = viewports.get(activeViewportId);
const displaySetUpdated = this._setActiveDisplaySetsUIDs(activeViewport.displaySetInstanceUIDs);
if (forceRestart || displaySetUpdated) {
this._restartPrefetching();
}
}
private _setActiveDisplaySetsUIDs(newActiveDisplaySetInstanceUIDs: string[]): boolean {
const sameDisplaySets =
newActiveDisplaySetInstanceUIDs.length === this._activeDisplaySetsInstanceUIDs.length &&
newActiveDisplaySetInstanceUIDs.every(uid =>
this._activeDisplaySetsInstanceUIDs.includes(uid)
);
if (sameDisplaySets) {
return false;
}
this._activeDisplaySetsInstanceUIDs = [...newActiveDisplaySetInstanceUIDs];
this._restartPrefetching();
return true;
}
private _areActiveDisplaySetsLoaded() {
const { _activeDisplaySetsInstanceUIDs: displaySetsInstanceUIDs } = this;
return (
displaySetsInstanceUIDs.length &&
displaySetsInstanceUIDs.every(
displaySetsInstanceUID =>
this._displaySetLoadingStates.get(displaySetsInstanceUID).loadingProgress >= 1
)
);
}
private _getClosestDisplaySets(displaySets: DisplaySet[], activeDisplaySetIndex: number) {
const sortedDisplaySets = [];
let previousIndex = activeDisplaySetIndex - 1;
let nextIndex = activeDisplaySetIndex + 1;
while (previousIndex >= 0 || nextIndex < displaySets.length) {
if (previousIndex >= 0) {
sortedDisplaySets.push(displaySets[previousIndex]);
previousIndex--;
}
if (nextIndex < displaySets.length) {
sortedDisplaySets.push(displaySets[nextIndex]);
nextIndex++;
}
}
return sortedDisplaySets;
}
private _getDownwardDisplaySets(displaySets: DisplaySet[], activeDisplaySetIndex: number) {
const sortedDisplaySets = [];
for (let i = activeDisplaySetIndex + 1; i < displaySets.length; i++) {
sortedDisplaySets.push(displaySets[i]);
}
return sortedDisplaySets;
}
private _getUpwardDisplaySets(displaySets: DisplaySet[], activeDisplaySetIndex: number) {
const sortedDisplaySets = [];
for (let i = activeDisplaySetIndex - 1; i >= 0 && i !== activeDisplaySetIndex; i--) {
sortedDisplaySets.push(displaySets[i]);
}
return sortedDisplaySets;
}
private _getSortedDisplaySetsToPrefetch(displaySets: DisplaySet[]): DisplaySet[] {
if (!this._activeDisplaySetsInstanceUIDs?.length) {
return [];
}
const { displaySetsCount } = this.config;
const activeDisplaySetsInstanceUIDs = this._activeDisplaySetsInstanceUIDs;
const [activeDisplaySetUID] = activeDisplaySetsInstanceUIDs;
const activeDisplaySetIndex = displaySets.findIndex(
ds => ds.displaySetInstanceUID === activeDisplaySetUID
);
const getDisplaySetsFunctionsMap = {
[StudyPrefetchOrder.closest]: this._getClosestDisplaySets,
[StudyPrefetchOrder.downward]: this._getDownwardDisplaySets,
[StudyPrefetchOrder.upward]: this._getUpwardDisplaySets,
};
const { order } = this.config;
const fnGetDisplaySets = getDisplaySetsFunctionsMap[order];
if (!fnGetDisplaySets) {
throw new Error(`Invalid order (${order})`);
}
// Creates a `Set` to look for UIDs in O(1) instead of O(n)
const uidsSet = new Set(activeDisplaySetsInstanceUIDs);
// Remove any active displaySet that may still be in the activeDisplaySetsInstanceUIDs.
// That may happen when activeDisplaySetsInstanceUIDs has more than one element.
return fnGetDisplaySets
.call(this, displaySets, activeDisplaySetIndex)
.filter(ds => !uidsSet.has(ds.displaySetInstanceUID))
.slice(0, displaySetsCount);
}
private _getDisplaySets() {
const { displaySetService } = this._servicesManager.services;
const displaySets = [...displaySetService.getActiveDisplaySets()];
const displaySetsToPrefetch = this._getSortedDisplaySetsToPrefetch(displaySets);
return { displaySets, displaySetsToPrefetch };
}
private _updateImageIdsDisplaySetMap(displaySetInstanceUID: string, imageIds: string[]): void {
for (const imageId of imageIds) {
let displaySetsInstanceUIDsMap = this._imageIdsToDisplaySetsMap.get(imageId);
if (!displaySetsInstanceUIDsMap) {
displaySetsInstanceUIDsMap = new Set();
this._imageIdsToDisplaySetsMap.set(imageId, displaySetsInstanceUIDsMap);
}
displaySetsInstanceUIDsMap.add(displaySetInstanceUID);
}
}
private _getImageIdsForDisplaySet(displaySet: DisplaySet): string[] {
const dataSource = this._extensionManager.getActiveDataSource()[0];
return dataSource.getImageIdsForDisplaySet(displaySet);
}
private _updateDisplaySetLoadingProgress(displaySetLoadingState: DisplaySetLoadingState) {
const { numInstances, loadedImageIds, failedImageIds } = displaySetLoadingState;
const loadingProgress = (loadedImageIds.size + failedImageIds.size) / numInstances;
displaySetLoadingState.loadingProgress = loadingProgress;
}
private _addDisplaySetLoadingState(displaySet: DisplaySet): void {
const { displaySetInstanceUID } = displaySet;
const imageIds = this._getImageIdsForDisplaySet(displaySet);
let displaySetLoadingState = this._displaySetLoadingStates.get(displaySetInstanceUID);
if (displaySetLoadingState) {
return;
}
const pendingImageIds = new Set<string>(imageIds);
const loadedImageIds = new Set<string>();
// Needs to check which image is already loaded to update the progress properly
// because some images may already be loaded (thumbnails and viewports).
for (const imageId of imageIds) {
if (this.cache.isImageCached(imageId)) {
loadedImageIds.add(imageId);
} else {
pendingImageIds.add(imageId);
}
}
displaySetLoadingState = {
displaySetInstanceUID,
numInstances: imageIds.length,
pendingImageIds,
loadedImageIds,
failedImageIds: new Set(),
loadingProgress: 0,
};
this._updateDisplaySetLoadingProgress(displaySetLoadingState);
this._displaySetLoadingStates.set(displaySetInstanceUID, displaySetLoadingState);
this._updateImageIdsDisplaySetMap(displaySetInstanceUID, imageIds);
// Notify the UI that something is already loaded (eg: update StudyBrowser)
if (loadedImageIds.size) {
this._triggerDisplaySetEvents(displaySetInstanceUID);
}
}
private _loadDisplaySets() {
const { displaySets, displaySetsToPrefetch } = this._getDisplaySets();
displaySets.forEach(displaySet => this._addDisplaySetLoadingState(displaySet));
displaySetsToPrefetch.forEach(displaySet => this._enqueueDisplaySetImagesRequests(displaySet));
}
private _moveImageIdToLoadedSet(imageId: string): boolean {
const displaySetsInstanceUIDs = this._imageIdsToDisplaySetsMap.get(imageId);
if (!displaySetsInstanceUIDs) {
return;
}
for (const displaySetInstanceUID of Array.from(displaySetsInstanceUIDs.values())) {
const displaySetLoadingState = this._displaySetLoadingStates.get(displaySetInstanceUID);
const { pendingImageIds, loadedImageIds } = displaySetLoadingState;
pendingImageIds.delete(imageId);
loadedImageIds.add(imageId);
this._updateDisplaySetLoadingProgress(displaySetLoadingState);
this._triggerDisplaySetEvents(displaySetInstanceUID);
}
return true;
}
private _moveImageIdToFailedSet(imageId: string): boolean {
const displaySetsInstanceUIDs = this._imageIdsToDisplaySetsMap.get(imageId);
if (!displaySetsInstanceUIDs) {
return;
}
for (const displaySetInstanceUID of Array.from(displaySetsInstanceUIDs.values())) {
const displaySetLoadingState = this._displaySetLoadingStates.get(displaySetInstanceUID);
const { pendingImageIds, failedImageIds } = displaySetLoadingState;
pendingImageIds.delete(imageId);
failedImageIds.add(imageId);
this._updateDisplaySetLoadingProgress(displaySetLoadingState);
this._triggerDisplaySetEvents(displaySetInstanceUID);
}
return true;
}
private _triggerDisplaySetEvents(displaySetInstanceUID: string) {
const displaySetLoadingState = this._displaySetLoadingStates.get(displaySetInstanceUID);
const { loadingProgress, numInstances } = displaySetLoadingState;
this._broadcastEvent(this.EVENTS.DISPLAYSET_LOAD_PROGRESS, {
displaySetInstanceUID,
numInstances,
loadingProgress,
});
if (loadingProgress >= 1) {
this._broadcastEvent(this.EVENTS.DISPLAYSET_LOAD_COMPLETE, {
displaySetInstanceUID,
});
}
}
private _onImagePrefetchSuccess(imageRequest: ImageRequest) {
if (imageRequest.aborted) {
return;
}
const { imageId } = imageRequest;
this._inflightRequests.delete(imageId);
this._moveImageIdToLoadedSet(imageId);
// `sendNextRequests` must be called after removing the request from the inflight
// queue otherwise it shall not be able to send the request (maxNumPrefetchRequests)
this._sendNextRequests();
}
private _onImagePrefetchFailed(imageRequest, error) {
if (imageRequest.aborted) {
return;
}
console.warn(`An error ocurred when trying to load "${imageRequest.imageId}"`, error);
const { imageId } = imageRequest;
this._inflightRequests.delete(imageId);
this._moveImageIdToFailedSet(imageId);
// `sendNextRequests` must be called after removing the request from the inflight
// queue otherwise it shall not be able to send the request (maxNumPrefetchRequests)
this._sendNextRequests();
}
private async _sendNextRequests() {
// If the service has stopped with async requests in progress this method may
// get called again when each of those requests are fulfilled.
if (!this._isRunning) {
return;
}
// Does not send any prefetch request until the active display sets are loaded
if (!this._areActiveDisplaySetsLoaded()) {
return;
}
const { _pendingRequests: pendingRequests, _inflightRequests: inflightRequests } = this;
const { maxNumPrefetchRequests } = this.config;
if (!pendingRequests.length || inflightRequests.size >= maxNumPrefetchRequests) {
return;
}
const numImageRequests = Math.min(
pendingRequests.length,
maxNumPrefetchRequests - inflightRequests.size
);
const imageRequests = this._pendingRequests.splice(0, numImageRequests);
imageRequests.forEach(imageRequest => {
const { imageId } = imageRequest;
const options = {
priority: -5,
requestType: this.requestType,
additionalDetails: { imageId },
preScale: {
enabled: true,
},
};
this.imageLoadPoolManager.addRequest(
async () =>
this.imageLoader.loadAndCacheImage(imageId, options).then(
_image => this._onImagePrefetchSuccess(imageRequest),
error => this._onImagePrefetchFailed(imageRequest, error)
),
this.requestType,
{ imageId }
);
inflightRequests.set(imageId, imageRequest);
});
}
private _enqueueDisplaySetImagesRequests(displaySet: DisplaySet) {
const { displaySetInstanceUID } = displaySet;
const imageIds = this._getImageIdsForDisplaySet(displaySet);
imageIds.forEach(imageId => {
if (this.cache.isImageCached(imageId)) {
this._moveImageIdToLoadedSet(imageId);
return;
}
this._pendingRequests.push({
displaySetInstanceUID,
imageId,
aborted: false,
});
});
}
/**
* Start prefetching the display sets based on the active viewport and app configuration.
*/
private _startPrefetching(): void {
if (this._isRunning) {
return;
}
if (!this.config.enabled) {
console.log('StudyPrefetcher is not enabled');
return;
}
this._isRunning = true;
this._loadDisplaySets();
this._sendNextRequests();
this._broadcastEvent(this.EVENTS.SERVICE_STARTED, {});
}
/**
* Stop prefetching the display sets.
* All internal variables are cleared but activeDisplaySetsInstanceUIDs otherwise restart would not work.
*/
private _stopPrefetching(): void {
if (!this._isRunning) {
return;
}
this._isRunning = false;
// Mark all inflight requests as aborted before clearing the map.
this._inflightRequests.forEach(inflightRequest => (inflightRequest.aborted = true));
this._pendingRequests = [];
this._displaySetLoadingStates.clear();
this._imageIdsToDisplaySetsMap.clear();
this._inflightRequests.clear();
this.imageLoadPoolManager.clearRequestStack(IMAGE_REQUEST_TYPE);
this._broadcastEvent(this.EVENTS.SERVICE_STOPPED, {});
}
/**
* Restart prefetching in case it is already running.
*/
private _restartPrefetching(): void {
if (this._isRunning) {
this._stopPrefetching();
this._startPrefetching();
}
}
}
export { StudyPrefetcherService as default, StudyPrefetcherService };

View File

@@ -0,0 +1,3 @@
import { StudyPrefetcherService } from './StudyPrefetcherService';
export { StudyPrefetcherService as default, StudyPrefetcherService };

View File

@@ -0,0 +1,567 @@
import { CommandsManager } from '../../classes';
import { ExtensionManager } from '../../extensions';
import { PubSubService } from '../_shared/pubSubServiceInterface';
import type { RunCommand } from '../../types/Command';
import { Button, ButtonProps, EvaluateFunction, EvaluatePublic, NestedButtonProps } from './types';
const EVENTS = {
TOOL_BAR_MODIFIED: 'event::toolBarService:toolBarModified',
TOOL_BAR_STATE_MODIFIED: 'event::toolBarService:toolBarStateModified',
};
export default class ToolbarService extends PubSubService {
public static REGISTRATION = {
name: 'toolbarService',
altName: 'ToolBarService',
create: ({ commandsManager, extensionManager, servicesManager }) => {
return new ToolbarService(commandsManager, extensionManager, servicesManager);
},
};
public static createButton(options: {
id: string;
label: string;
commands: RunCommand;
icon?: string;
tooltip?: string;
evaluate?: EvaluatePublic;
listeners?: Record<string, RunCommand>;
}): ButtonProps {
const { id, icon, label, commands, tooltip, evaluate, listeners } = options;
return {
id,
icon,
label,
commands,
tooltip: tooltip || label,
evaluate,
listeners,
};
}
state: {
// all buttons in the toolbar with their props
buttons: Record<string, Button>;
// the buttons in the toolbar, grouped by section, with their ids
buttonSections: Record<string, string[]>;
} = {
buttons: {},
buttonSections: {},
};
_commandsManager: CommandsManager;
_extensionManager: ExtensionManager;
_servicesManager: AppTypes.ServicesManager;
_evaluateFunction: Record<string, EvaluateFunction> = {};
_serviceSubscriptions = [];
constructor(
commandsManager: CommandsManager,
extensionManager: ExtensionManager,
servicesManager: AppTypes.ServicesManager
) {
super(EVENTS);
this._commandsManager = commandsManager;
this._extensionManager = extensionManager;
this._servicesManager = servicesManager;
}
public reset(): void {
// this.unsubscriptions.forEach(unsub => unsub());
this.state = {
buttons: {},
buttonSections: {},
};
this.unsubscriptions = [];
}
public onModeEnter(): void {
this.reset();
}
/**
* Registers an evaluate function with the specified name.
*
* @param name - The name of the evaluate function.
* @param handler - The evaluate function handler.
*/
public registerEvaluateFunction(name: string, handler: EvaluateFunction) {
this._evaluateFunction[name] = handler;
}
/**
* Registers a service and its event to listen for updates and refreshes the toolbar state when the event is triggered.
* @param service - The service to register.
* @param event - The event to listen for.
*/
public registerEventForToolbarUpdate(service, events) {
const { viewportGridService } = this._servicesManager.services;
const callback = () => {
const viewportId = viewportGridService.getActiveViewportId();
this.refreshToolbarState({ viewportId });
};
const unsubscriptions = events.map(event => {
if (service.subscribe) {
return service.subscribe(event, callback);
} else if (service.addEventListener) {
return service.addEventListener(event, callback);
}
});
unsubscriptions.forEach(unsub => this._serviceSubscriptions.push(unsub));
}
/**
* Removes buttons from the toolbar.
* @param buttonId - The button to be removed.
*/
public removeButton(buttonId: string) {
if (this.state.buttons[buttonId]) {
delete this.state.buttons[buttonId];
}
this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, {
...this.state,
});
}
/**
* Adds buttons to the toolbar.
* @param buttons - The buttons to be added.
*/
public addButtons(buttons: Button[]): void {
buttons.forEach(button => {
if (!this.state.buttons[button.id]) {
if (!button.props) {
button.props = {};
}
this.state.buttons[button.id] = button;
}
});
this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, {
...this.state,
});
}
/**
*
* @param {*} interaction - can be undefined to run nothing
* @param {*} options is an optional set of extra commandOptions
* used for calling the specified interaction. That is, the command is
* called with {...commandOptions,...options}
*/
public recordInteraction(
interaction,
options?: {
refreshProps: Record<string, unknown>;
[key: string]: unknown;
}
) {
// if interaction is a string, we can assume it is the itemId
// and get the props to get the other properties
if (typeof interaction === 'string') {
interaction = this.getButtonProps(interaction);
}
const itemId = interaction.itemId ?? interaction.id;
interaction.itemId = itemId;
let commands = Array.isArray(interaction.commands)
? interaction.commands
: [interaction.commands];
if (!commands?.length) {
this.refreshToolbarState({
...options?.refreshProps,
itemId,
interaction,
});
}
const commandOptions = { ...options, ...interaction };
commands = commands.map(command => {
if (typeof command === 'function') {
return () => {
command({
...commandOptions,
commandsManager: this._commandsManager,
servicesManager: this._servicesManager,
});
};
}
return command;
});
// if still no commands, return
commands = commands.filter(Boolean);
if (!commands.length) {
return;
}
// Loop through commands and run them with the combined options
this._commandsManager.run(commands, commandOptions);
this.refreshToolbarState({
...options?.refreshProps,
itemId,
interaction,
});
}
/**
* Consolidates the state of the toolbar after an interaction, it accepts
* props that get passed to the buttons
*
* @param refreshProps - The props that buttons need to get evaluated, they can be
* { viewportId, toolGroup} for cornerstoneTools.
*
* Todo: right now refreshToolbarState should be used in the context where
* we have access to the toolGroup and viewportId, but we should be able to
* pass the props to the toolbar service and it should be able to decide
* which buttons to evaluate based on the props
*/
public refreshToolbarState(refreshProps) {
const buttons = this.state.buttons;
// Tracks evaluated buttons to avoid re-evaluating them (this will
// cause issue for toggles where if the button is in primary
// and secondary it will be evaluated twice)
const evaluationResults = new Map();
const evaluateButtonProps = (button, props, refreshProps) => {
if (evaluationResults.has(button.id)) {
const { disabled, className, isActive } = evaluationResults.get(button.id);
return { ...props, disabled, className, isActive };
} else {
const evaluated = props.evaluate?.({ ...refreshProps, button });
const updatedProps = {
...props,
...evaluated,
disabled: evaluated?.disabled || false,
className: evaluated?.className || '',
isActive: evaluated?.isActive, // isActive will be undefined for buttons without this prop
};
evaluationResults.set(button.id, updatedProps);
return updatedProps;
}
};
const refreshedButtons = Object.values(buttons).reduce((acc, button: Button) => {
const isNested = (button.props as NestedButtonProps)?.groupId;
if (!isNested) {
this.handleEvaluate(button.props);
const buttonProps = button.props as ButtonProps;
const updatedProps = evaluateButtonProps(button, buttonProps, refreshProps);
acc[button.id] = {
...button,
props: updatedProps,
};
} else {
let buttonProps = button.props as NestedButtonProps;
// if it is nested we should perform evaluate on each item in the group
this.handleEvaluateNested(buttonProps);
const { evaluate: groupEvaluate } = buttonProps;
const groupEvaluated = groupEvaluate?.({ ...refreshProps, button });
// handle group evaluate function which might switch the primary
// item in the group
buttonProps = {
...buttonProps,
primary: groupEvaluated?.primary || buttonProps.primary,
};
const { primary, items } = buttonProps;
// primary and items evaluate functions
let updatedPrimary;
if (primary) {
updatedPrimary = evaluateButtonProps(primary, primary, refreshProps);
}
const updatedItems = items.map(item => evaluateButtonProps(item, item, refreshProps));
buttonProps = {
...buttonProps,
primary: updatedPrimary,
items: updatedItems,
};
acc[button.id] = {
...button,
props: buttonProps,
};
}
return acc;
}, {});
this.setButtons(refreshedButtons);
return this.state;
}
/**
* Sets the buttons for the toolbar, don't use this method to record an
* interaction, since it doesn't update the state of the buttons, use
* this if you know the buttons you want to set and you want to set them
* all at once.
* @param buttons - The buttons to set.
*/
public setButtons(buttons) {
this.state.buttons = buttons;
this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, {
buttons: this.state.buttons,
buttonSections: this.state.buttonSections,
});
}
/**
* Retrieves a button by its ID.
* @param id - The ID of the button to retrieve.
* @returns The button with the specified ID.
*/
public getButton(id: string): Button {
return this.state.buttons[id];
}
/**
* Retrieves the buttons from the toolbar service.
* @returns An array of buttons.
*/
public getButtons() {
return this.state.buttons;
}
/**
* Retrieves the button properties for the specified button ID.
* It prioritizes nested buttons over regular buttons if the ID is found
* in both.
*
* @param id - The ID of the button.
* @returns The button properties.
*/
public getButtonProps(id: string): ButtonProps {
for (const buttonId of Object.keys(this.state.buttons)) {
const { primary, items } = (this.state.buttons[buttonId].props as NestedButtonProps) || {};
if (primary?.id === id) {
return primary;
}
const found = items?.find(childButton => childButton.id === id);
if (found) {
return found;
}
}
// This should be checked after we checked the nested buttons, since
// we are checking based on the ids, the nested objects are higher priority
// and more specific
if (this.state.buttons[id]) {
return this.state.buttons[id].props as ButtonProps;
}
}
_getButtonUITypes() {
const registeredToolbarModules = this._extensionManager.modules['toolbarModule'];
if (!Array.isArray(registeredToolbarModules)) {
return {};
}
return registeredToolbarModules.reduce((buttonTypes, toolbarModule) => {
toolbarModule.module.forEach(def => {
buttonTypes[def.name] = def;
});
return buttonTypes;
}, {});
}
/**
* Creates a button section with the specified key and buttons.
* @param {string} key - The key of the button section.
* @param {Array} buttons - The buttons to be added to the section.
*/
createButtonSection(key, buttons) {
if (this.state.buttonSections[key]) {
this.state.buttonSections[key].push(...buttons);
} else {
this.state.buttonSections[key] = buttons;
}
this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, { ...this.state });
}
/**
* Retrieves the button section with the specified sectionId.
*
* @param sectionId - The ID of the button section to retrieve.
* @param props - Optional additional properties for mapping the button to display.
* @returns An array of buttons in the specified section, mapped to their display representation.
*/
getButtonSection(sectionId: string, props?: Record<string, unknown>) {
const buttonSectionIds = this.state.buttonSections[sectionId];
return (
buttonSectionIds?.map(btnId => {
const btn = this.state.buttons[btnId];
return this._mapButtonToDisplay(btn, props);
}) || []
);
}
/**
* Retrieves the tool name for a given button.
* @param button - The button object.
* @returns The tool name associated with the button.
*/
getToolNameForButton(button) {
const { props } = button;
const commands = props?.commands || button.commands;
const commandsArray = Array.isArray(commands) ? commands : [commands];
const firstCommand = commandsArray[0];
if (firstCommand?.commandOptions) {
return firstCommand.commandOptions.toolName ?? props?.id ?? button.id;
}
// use id as a fallback for toolName
return props?.id ?? button.id;
}
/**
*
* @param {*} btn
* @param {*} btnSection
* @param {*} metadata
* @param {*} props - Props set by the Viewer layer
*/
_mapButtonToDisplay(btn, props) {
if (!btn) {
return;
}
const { id, uiType, component } = btn;
const { groupId } = btn.props;
const buttonTypes = this._getButtonUITypes();
const buttonType = buttonTypes[uiType];
if (!buttonType) {
return;
}
!groupId ? this.handleEvaluate(btn.props) : this.handleEvaluateNested(btn.props);
return {
id,
Component: component || buttonType.defaultComponent,
componentProps: Object.assign({}, btn.props, props),
};
}
handleEvaluateNested = props => {
const { primary, items } = props;
// handle group evaluate function
this.handleEvaluate(props);
// primary and items evaluate functions
if (primary) {
this.handleEvaluate(primary);
}
items.forEach(item => this.handleEvaluate(item));
};
handleEvaluate = props => {
const { evaluate, options } = props;
if (typeof options === 'string') {
// get the custom option component from the extension manager and set it as the optionComponent
const buttonTypes = this._getButtonUITypes();
const optionComponent = buttonTypes[options]?.defaultComponent;
props.options = {
optionComponent,
};
}
if (typeof evaluate === 'function') {
return;
}
if (Array.isArray(evaluate)) {
const evaluators = evaluate.map(evaluator => {
const isObject = typeof evaluator === 'object';
const evaluatorName = isObject ? evaluator.name : evaluator;
const evaluateFunction = this._evaluateFunction[evaluatorName];
if (!evaluateFunction) {
throw new Error(
`Evaluate function not found for name: ${evaluatorName}, you can register an evaluate function with the getToolbarModule in your extensions`
);
}
if (isObject) {
return args => evaluateFunction({ ...args, ...evaluator });
}
return evaluateFunction;
});
props.evaluate = args => {
const results = evaluators.map(evaluator => evaluator(args));
const mergedResult = results.reduce((acc, result) => {
return {
...acc,
...result,
};
}, {});
return mergedResult;
};
return;
}
if (typeof evaluate === 'string') {
const evaluateFunction = this._evaluateFunction[evaluate];
if (evaluateFunction) {
props.evaluate = evaluateFunction;
return;
}
throw new Error(
`Evaluate function not found for name: ${evaluate}, you can register an evaluate function with the getToolbarModule in your extensions`
);
}
if (typeof evaluate === 'object') {
const { name, ...options } = evaluate;
const evaluateFunction = this._evaluateFunction[name];
if (evaluateFunction) {
props.evaluate = args => evaluateFunction({ ...args, ...options });
return;
}
throw new Error(
`Evaluate function not found for name: ${name}, you can register an evaluate function with the getToolbarModule in your extensions`
);
}
};
getButtonComponentForUIType(uiType: string) {
return uiType ? (this._getButtonUITypes()[uiType]?.defaultComponent ?? null) : null;
}
clearButtonSection(buttonSection: string) {
this.state.buttonSections[buttonSection] = [];
this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, { ...this.state });
}
}

View File

@@ -0,0 +1,3 @@
import ToolbarService from './ToolbarService';
export default ToolbarService;

View File

@@ -0,0 +1,53 @@
import type { RunCommand } from '../../types/Command';
export type EvaluatePublic =
| string
| EvaluateFunction
| EvaluateObject
| (string | EvaluateFunction | EvaluateObject)[];
export type EvaluateFunction = (props: Record<string, unknown>) => {
disabled: boolean;
className: string;
};
export type EvaluateObject = {
name: string;
// Allow any additional properties
[key: string]: unknown;
};
export type ButtonProps = {
id: string;
icon: string;
label: string;
tooltip?: string;
commands?: RunCommand;
disabled?: boolean;
className?: string;
evaluate?: EvaluatePublic;
listeners?: Record<string, RunCommand>;
};
export type NestedButtonProps = {
groupId: string;
// group evaluate which is different
// from the evaluate function for the primary and items
evaluate?: EvaluatePublic;
items: ButtonProps[];
primary: ButtonProps & {
// Todo: this is really ugly but really we don't have any other option
// the ui design requires this since the button should be rounded if
// active otherwise it should not be rounded
isActive?: boolean;
};
secondary: ButtonProps;
};
export type Button = {
id: string;
props: ButtonProps | NestedButtonProps;
// button ui type (e.g. 'ohif.splitButton', 'ohif.radioGroup')
// extensions can provide custom components for these types
uiType: string;
};

View File

@@ -0,0 +1,80 @@
import { PubSubService } from '../_shared/pubSubServiceInterface';
class UIDialogService extends PubSubService {
public static readonly EVENTS = {};
public static REGISTRATION = {
name: 'uiDialogService',
altName: 'UIDialogService',
create: ({ configuration = {} }) => {
return new UIDialogService();
},
};
serviceImplementation = {
_dismiss: () => console.warn('dismiss() NOT IMPLEMENTED'),
_dismissAll: () => console.warn('dismissAll() NOT IMPLEMENTED'),
_create: () => console.warn('create() NOT IMPLEMENTED'),
};
constructor() {
super(UIDialogService.EVENTS);
this.serviceImplementation = {
...this.serviceImplementation,
};
}
public create({
id,
content,
contentProps,
onStart = () => {},
onDrag = () => {},
onStop = () => {},
centralize = false,
preservePosition = true,
isDraggable = true,
showOverlay = false,
defaultPosition = { x: 0, y: 0 },
}) {
return this.serviceImplementation._create({
id,
content,
contentProps,
onStart,
onDrag,
onStop,
centralize,
preservePosition,
isDraggable,
showOverlay,
defaultPosition,
});
}
public dismiss({ id }) {
return this.serviceImplementation._dismiss({ id });
}
public dismissAll() {
return this.serviceImplementation._dismissAll();
}
public setServiceImplementation({
dismiss: dismissImplementation,
dismissAll: dismissAllImplementation,
create: createImplementation,
}) {
if (dismissImplementation) {
this.serviceImplementation._dismiss = dismissImplementation;
}
if (dismissAllImplementation) {
this.serviceImplementation._dismissAll = dismissAllImplementation;
}
if (createImplementation) {
this.serviceImplementation._create = createImplementation;
}
}
}
export default UIDialogService;

View File

@@ -0,0 +1,3 @@
import UIDialogService from './UIDialogService';
export default UIDialogService;

View File

@@ -0,0 +1,90 @@
/**
* UI Modal
*
* @typedef {Object} ModalProps
* @property {ReactElement|HTMLElement} [content=null] Modal content.
* @property {Object} [contentProps=null] Modal content props.
* @property {boolean} [shouldCloseOnEsc=false] Modal is dismissible via the esc key.
* @property {boolean} [isOpen=true] Make the Modal visible or hidden.
* @property {boolean} [closeButton=true] Should the modal body render the close button.
* @property {string} [title=null] Should the modal render the title independently of the body content.
* @property {string} [customClassName=null] The custom class to style the modal.
*/
const name = 'uiModalService';
const serviceImplementation = {
_hide: () => console.warn('hide() NOT IMPLEMENTED'),
_show: () => console.warn('show() NOT IMPLEMENTED'),
};
class UIModalService {
static REGISTRATION = {
name,
altName: 'UIModalService',
create: (): UIModalService => {
return new UIModalService();
},
};
readonly name = name;
/**
* Show a new UI modal;
*
* @param {ModalProps} props { content, contentProps, shouldCloseOnEsc, isOpen, closeButton, title, customClassName }
*/
show({
content = null,
contentProps = null,
shouldCloseOnEsc = true,
isOpen = true,
closeButton = true,
title = null,
customClassName = null,
movable = false,
containerDimensions = null,
contentDimensions = null,
}) {
return serviceImplementation._show({
content,
contentProps,
shouldCloseOnEsc,
isOpen,
closeButton,
title,
customClassName,
movable,
containerDimensions,
contentDimensions,
});
}
/**
* Hides/dismisses the modal, if currently shown
*
* @returns void
*/
hide() {
return serviceImplementation._hide();
}
/**
*
*
* @param {*} {
* hide: hideImplementation,
* show: showImplementation,
* }
*/
setServiceImplementation({ hide: hideImplementation, show: showImplementation }) {
if (hideImplementation) {
serviceImplementation._hide = hideImplementation;
}
if (showImplementation) {
serviceImplementation._show = showImplementation;
}
}
}
export default UIModalService;

View File

@@ -0,0 +1,152 @@
const serviceImplementation = {
_hide: () => console.debug('hide() NOT IMPLEMENTED'),
_show: showArguments => {
console.debug('show() NOT IMPLEMENTED');
return null;
},
};
type ToastType = 'success' | 'error' | 'info' | 'warning' | 'loading';
class UINotificationService {
static REGISTRATION = {
name: 'uiNotificationService',
altName: 'UINotificationService',
create: (): UINotificationService => {
return new UINotificationService();
},
};
/**
*
*
* @param {*} {
* hide: hideImplementation,
* show: showImplementation,
* }
*/
public setServiceImplementation({ hide: hideImplementation, show: showImplementation }): void {
if (hideImplementation) {
serviceImplementation._hide = hideImplementation;
}
if (showImplementation) {
serviceImplementation._show = showImplementation;
}
}
/**
* Hides/dismisses the notification, if currently shown
*
* @param {number} id - id of the notification to hide/dismiss
* @returns undefined
*/
public hide(id: string) {
return serviceImplementation._hide(id);
}
/**
* Create and show a new UI notification; returns the
* ID of the created notification. Can also handle promises for loading states.
*
* @param {object} notification - The notification object
* @param {string} notification.title - The title of the notification
* @param {string | function} notification.message - The message content of the notification or a function that returns a message
* @param {number} [notification.duration=5000] - The duration to show the notification (in milliseconds)
* @param {'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'top-center' | 'bottom-center'} [notification.position='bottom-right'] - The position of the notification
* @param {ToastType} [notification.type='info'] - The type of the notification
* @param {boolean} [notification.autoClose=true] - Whether the notification should auto-close
* @param {Promise} [notification.promise] - A promise to track for loading, success, and error states
* @param {object} [notification.promiseMessages] - Custom messages for promise states
* @param {string} [notification.promiseMessages.loading] - Message to show while promise is pending
* @param {string | function} [notification.promiseMessages.success] - Message to show when promise resolves
* @param {string | function} [notification.promiseMessages.error] - Message to show when promise rejects
* @returns {string} id - The ID of the created notification
*/
show({
title,
message,
duration = 5000,
position = 'bottom-right',
type = 'info',
autoClose = true,
promise,
promiseMessages,
}: {
title: string;
message: string | ((data?: any) => string);
duration?: number;
position?:
| 'top-left'
| 'top-right'
| 'bottom-left'
| 'bottom-right'
| 'top-center'
| 'bottom-center';
type?: ToastType;
autoClose?: boolean;
promise?: Promise<any>;
promiseMessages?: {
loading?: string;
success?: string | ((data: any) => string);
error?: string | ((error: any) => string);
};
}): string {
if (promise && promiseMessages) {
const loadingId = serviceImplementation._show({
title,
message: promiseMessages.loading || 'Loading...',
type: 'loading',
autoClose: false,
position,
});
promise.then(
data => {
const successMessage =
typeof promiseMessages.success === 'function'
? promiseMessages.success(data)
: promiseMessages.success || 'Success';
serviceImplementation._show({
title,
message: successMessage,
type: 'success',
duration,
position,
autoClose,
});
this.hide(loadingId);
},
error => {
const errorMessage =
typeof promiseMessages.error === 'function'
? promiseMessages.error(error)
: promiseMessages.error || 'Error';
serviceImplementation._show({
title,
message: errorMessage,
type: 'error',
duration,
position,
autoClose,
});
this.hide(loadingId);
}
);
return loadingId;
}
return serviceImplementation._show({
title,
message,
duration,
position,
type,
autoClose,
});
}
}
export default UINotificationService;

View File

@@ -0,0 +1,52 @@
import { PubSubService } from '../_shared/pubSubServiceInterface';
class UIViewportDialogService extends PubSubService {
public static readonly EVENTS = {};
public static REGISTRATION = {
name: 'uiViewportDialogService',
altName: 'UIViewportDialogService',
create: ({ configuration = {} }) => {
return new UIViewportDialogService();
},
};
serviceImplementation = {
_hide: () => console.warn('hide() NOT IMPLEMENTED'),
_show: () => console.warn('show() NOT IMPLEMENTED'),
};
constructor() {
super(UIViewportDialogService.EVENTS);
this.serviceImplementation = {
...this.serviceImplementation,
};
}
public show({ viewportId, id, type, message, actions, onSubmit, onOutsideClick, onKeyPress }) {
return this.serviceImplementation._show({
viewportId,
id,
type,
message,
actions,
onSubmit,
onOutsideClick,
onKeyPress,
});
}
public hide() {
return this.serviceImplementation._hide();
}
public setServiceImplementation({ hide: hideImplementation, show: showImplementation }) {
if (hideImplementation) {
this.serviceImplementation._hide = hideImplementation;
}
if (showImplementation) {
this.serviceImplementation._show = showImplementation;
}
}
}
export default UIViewportDialogService;

View File

@@ -0,0 +1,3 @@
import UIViewportDialogService from './UIViewportDialogService';
export default UIViewportDialogService;

View File

@@ -0,0 +1,92 @@
import { PubSubService } from '../_shared/pubSubServiceInterface';
class UserAuthenticationService extends PubSubService {
public static readonly EVENTS = {};
public static REGISTRATION = {
name: 'userAuthenticationService',
altName: 'UserAuthenticationService',
create: ({ configuration = {} }) => {
return new UserAuthenticationService();
},
};
serviceImplementation = {
_getState: () => console.warn('getState() NOT IMPLEMENTED'),
_setUser: () => console.warn('_setUser() NOT IMPLEMENTED'),
_getUser: () => console.warn('_getUser() NOT IMPLEMENTED'),
_getAuthorizationHeader: () => {}, // TODO: Implement this method
_handleUnauthenticated: () => console.warn('_handleUnauthenticated() NOT IMPLEMENTED'),
_reset: () => console.warn('reset() NOT IMPLEMENTED'),
_set: () => console.warn('set() NOT IMPLEMENTED'),
};
constructor() {
super(UserAuthenticationService.EVENTS);
this.serviceImplementation = {
...this.serviceImplementation,
};
}
public getState() {
return this.serviceImplementation._getState();
}
public setUser(user) {
return this.serviceImplementation._setUser(user);
}
public getUser() {
return this.serviceImplementation._getUser();
}
public getAuthorizationHeader() {
return this.serviceImplementation._getAuthorizationHeader();
}
public handleUnauthenticated() {
return this.serviceImplementation._handleUnauthenticated();
}
public reset() {
return this.serviceImplementation._reset();
}
public set(state) {
return this.serviceImplementation._set(state);
}
public setServiceImplementation({
getState: getStateImplementation,
setUser: setUserImplementation,
getUser: getUserImplementation,
getAuthorizationHeader: getAuthorizationHeaderImplementation,
handleUnauthenticated: handleUnauthenticatedImplementation,
reset: resetImplementation,
set: setImplementation,
}) {
if (getStateImplementation) {
this.serviceImplementation._getState = getStateImplementation;
}
if (setUserImplementation) {
this.serviceImplementation._setUser = setUserImplementation;
}
if (getUserImplementation) {
this.serviceImplementation._getUser = getUserImplementation;
}
if (getAuthorizationHeaderImplementation) {
this.serviceImplementation._getAuthorizationHeader = getAuthorizationHeaderImplementation;
}
if (handleUnauthenticatedImplementation) {
this.serviceImplementation._handleUnauthenticated = handleUnauthenticatedImplementation;
}
if (resetImplementation) {
this.serviceImplementation._reset = resetImplementation;
}
if (setImplementation) {
this.serviceImplementation._set = setImplementation;
}
}
}
export default UserAuthenticationService;

View File

@@ -0,0 +1,3 @@
import UserAuthenticationService from './UserAuthenticationService';
export default UserAuthenticationService;

View File

@@ -0,0 +1,321 @@
import { PubSubService } from '../_shared/pubSubServiceInterface';
class ViewportGridService extends PubSubService {
public static readonly EVENTS = {
ACTIVE_VIEWPORT_ID_CHANGED: 'event::activeviewportidchanged',
LAYOUT_CHANGED: 'event::layoutChanged',
GRID_STATE_CHANGED: 'event::gridStateChanged',
GRID_SIZE_CHANGED: 'event::gridSizeChanged',
VIEWPORTS_READY: 'event::viewportsReady',
};
public static REGISTRATION = {
name: 'viewportGridService',
altName: 'ViewportGridService',
create: ({ configuration = {}, servicesManager }) => {
return new ViewportGridService({ servicesManager });
},
};
serviceImplementation = {};
servicesManager: AppTypes.ServicesManager;
presentationIdProviders: Map<
string,
(id: string, { viewport, viewports, isUpdatingSameViewport, servicesManager }) => unknown
>;
constructor({ servicesManager }) {
super(ViewportGridService.EVENTS);
this.servicesManager = servicesManager;
this.serviceImplementation = {};
this.presentationIdProviders = new Map();
}
public addPresentationIdProvider(
id: string,
provider: (id: string, { viewport, viewports, isUpdatingSameViewport }) => unknown
): void {
this.presentationIdProviders.set(id, provider);
}
public getPresentationId(id: string, viewportId: string): string | null {
const state = this.getState();
const viewport = state.viewports.get(viewportId);
return this._getPresentationId(id, {
viewport,
viewports: state.viewports,
});
}
private _getPresentationId(id, { viewport, viewports }) {
const isUpdatingSameViewport = [...viewports.values()].some(
v =>
v.displaySetInstanceUIDs?.toString() === viewport.displaySetInstanceUIDs?.toString() &&
v.viewportId === viewport.viewportId
);
const provider = this.presentationIdProviders.get(id);
if (provider) {
const result = provider(id, {
viewport,
viewports,
isUpdatingSameViewport,
servicesManager: this.servicesManager,
});
return result;
}
return null;
}
public getPresentationIds({ viewport, viewports }) {
// Use the keys of the Map to get all registered provider IDs
const registeredPresentationProviders = Array.from(this.presentationIdProviders.keys());
return registeredPresentationProviders.reduce((acc, id) => {
const value = this._getPresentationId(id, {
viewport,
viewports,
});
if (value !== null) {
acc[id] = value;
}
return acc;
}, {});
}
public setServiceImplementation({
getState: getStateImplementation,
setActiveViewportId: setActiveViewportIdImplementation,
setDisplaySetsForViewports: setDisplaySetsForViewportsImplementation,
setLayout: setLayoutImplementation,
reset: resetImplementation,
onModeExit: onModeExitImplementation,
set: setImplementation,
getNumViewportPanes: getNumViewportPanesImplementation,
setViewportIsReady: setViewportIsReadyImplementation,
}): void {
if (getStateImplementation) {
this.serviceImplementation._getState = getStateImplementation;
}
if (setActiveViewportIdImplementation) {
this.serviceImplementation._setActiveViewport = setActiveViewportIdImplementation;
}
if (setDisplaySetsForViewportsImplementation) {
this.serviceImplementation._setDisplaySetsForViewports =
setDisplaySetsForViewportsImplementation;
}
if (setLayoutImplementation) {
this.serviceImplementation._setLayout = setLayoutImplementation;
}
if (resetImplementation) {
this.serviceImplementation._reset = resetImplementation;
}
if (onModeExitImplementation) {
this.serviceImplementation._onModeExit = onModeExitImplementation;
}
if (setImplementation) {
this.serviceImplementation._set = setImplementation;
}
if (getNumViewportPanesImplementation) {
this.serviceImplementation._getNumViewportPanes = getNumViewportPanesImplementation;
}
if (setViewportIsReadyImplementation) {
this.serviceImplementation._setViewportIsReady = setViewportIsReadyImplementation;
}
}
public publishViewportsReady() {
this._broadcastEvent(this.EVENTS.VIEWPORTS_READY, {});
}
public setActiveViewportId(id: string) {
this.serviceImplementation._setActiveViewport(id);
// Use queueMicrotask to delay the event broadcast
setTimeout(() => {
this._broadcastEvent(this.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED, {
viewportId: id,
});
}, 0);
}
public getState(): AppTypes.ViewportGrid.State {
return this.serviceImplementation._getState();
}
public getViewportState(viewportId: string) {
const state = this.getState();
return state.viewports.get(viewportId);
}
public setViewportIsReady(viewportId, callback) {
this.serviceImplementation._setViewportIsReady(viewportId, callback);
}
public getActiveViewportId() {
const state = this.getState();
return state.activeViewportId;
}
public setViewportGridSizeChanged() {
const state = this.getState();
this._broadcastEvent(this.EVENTS.GRID_SIZE_CHANGED, {
state,
});
}
public setDisplaySetsForViewport(props) {
// Just update a single viewport, but use the multi-viewport update for it.
this.setDisplaySetsForViewports([props]);
}
public async setDisplaySetsForViewports(props) {
await this.serviceImplementation._setDisplaySetsForViewports(props);
const state = this.getState();
const updatedViewports = [];
const removedViewportIds = [];
for (const viewport of props) {
const updatedViewport = state.viewports.get(viewport.viewportId);
if (updatedViewport) {
updatedViewports.push(updatedViewport);
const updatedDisplaySetUIDs = updatedViewport.displaySetInstanceUIDs || [];
const isCleared = updatedDisplaySetUIDs.length === 0;
if (isCleared) {
removedViewportIds.push(viewport.viewportId);
}
} else {
removedViewportIds.push(viewport.viewportId);
}
}
setTimeout(() => {
this._broadcastEvent(ViewportGridService.EVENTS.GRID_STATE_CHANGED, {
state,
viewports: updatedViewports,
removedViewportIds,
});
});
}
/**
* Retrieves the display set instance UIDs for a given viewport.
* @param viewportId The ID of the viewport.
* @returns An array of display set instance UIDs.
*/
public getDisplaySetsUIDsForViewport(viewportId: string) {
const state = this.getState();
const viewport = state.viewports.get(viewportId);
return viewport?.displaySetInstanceUIDs;
}
/**
*
* @param numCols, numRows - the number of columns and rows to apply
* @param findOrCreateViewport is a function which takes the
* index position of the viewport, the position id, and a set of
* options that is initially provided as {} (eg to store intermediate state)
* The function returns a viewport object to use at the given position.
*/
public async setLayout({
numCols,
numRows,
layoutOptions,
layoutType = 'grid',
activeViewportId = undefined,
findOrCreateViewport = undefined,
isHangingProtocolLayout = false,
}) {
// Get the previous state before the layout change
const prevState = this.getState();
const prevViewportIds = new Set(prevState.viewports.keys());
await this.serviceImplementation._setLayout({
numCols,
numRows,
layoutOptions,
layoutType,
activeViewportId,
findOrCreateViewport,
isHangingProtocolLayout,
});
// Use queueMicrotask to ensure the layout changed event is published after
setTimeout(() => {
// Get the new state after the layout change
const state = this.getState();
const currentViewportIds = new Set(state.viewports.keys());
// Determine which viewport IDs have been removed
const removedViewportIds = [...prevViewportIds].filter(id => !currentViewportIds.has(id));
this._broadcastEvent(this.EVENTS.LAYOUT_CHANGED, {
numCols,
numRows,
});
this._broadcastEvent(this.EVENTS.GRID_STATE_CHANGED, {
state,
removedViewportIds,
});
}, 0);
}
public reset() {
this.serviceImplementation._reset();
}
/**
* The onModeExit must set the state of the viewport grid to a standard/clean
* state. To implement store/recover of the viewport grid, perform
* a state store in the mode or extension onModeExit, and recover that
* data if appropriate in the onModeEnter of the mode or extension.
*/
public onModeExit(): void {
this.serviceImplementation._onModeExit();
}
public set(newState) {
const prevState = this.getState();
const prevViewportIds = new Set(prevState.viewports.keys());
this.serviceImplementation._set(newState);
const state = this.getState();
const currentViewportIds = new Set(state.viewports.keys());
const removedViewportIds = [...prevViewportIds].filter(id => !currentViewportIds.has(id));
setTimeout(() => {
this._broadcastEvent(this.EVENTS.GRID_STATE_CHANGED, {
state,
removedViewportIds,
});
}, 0);
}
public getNumViewportPanes() {
return this.serviceImplementation._getNumViewportPanes();
}
public getLayoutOptionsFromState(
state: any
): { x: number; y: number; width: number; height: number }[] {
return Array.from(state.viewports.entries()).map(([_, viewport]) => {
return {
x: viewport.x,
y: viewport.y,
width: viewport.width,
height: viewport.height,
};
});
}
}
export default ViewportGridService;

View File

@@ -0,0 +1,3 @@
import ViewportGridService from './ViewportGridService';
export default ViewportGridService;

View File

@@ -0,0 +1,238 @@
import { CommandsManager } from '../../classes';
import { ExtensionManager } from '../../extensions';
import { PubSubService } from '../_shared/pubSubServiceInterface';
export const EVENTS = {
ACTIVE_STEP_CHANGED: 'event::workflowStepsService:activateStepChanged',
STEPS_CHANGED: 'event::workflowStepsService:stepsChanged',
};
/*
A mode may define a workflow and each workflow may have one or more steps.
Each step may define a different set of tools, hanging protocol and panels
layout that will be applied to the viewer once it gets activated making the
viewer work in a more dynamic way.
Example:
All keys inside brackets are optionals.
workflow: {
[initialStepId]: 'step1',
steps: [
{
id: 'firstStep',
name: 'First Step',
[toolbar]: {
buttons: firstStepToolbarButtons,
sections: [
{
key: 'primary',
buttons: [ 'MeasurementTools', 'Zoom', ... ],
},
],
},
[layout]: {
[panels]: {
left: ['firstLeftPanelId', 'secondLeftPanelId'],
right: ['firstRightPanelId'],
},
},
[hangingProtocol]: {
protocolId: 'default',
[stepId]: 'firstStep',
},
},
{
id: 'secondStep',
name: 'Second Step',
...
},
]
}
If workflow steps are defined but `initialStepId` is not set then the first
step is set as active during mode initialization.
*/
type CommandCallback = {
commandName: string;
options: Record<string, unknown>;
};
export type WorkflowStep = {
id: string;
name: string;
toolbarButtons?: {
buttonSection: string;
buttons: string[];
}[];
hangingProtocol?: {
protocolId: string;
stageId?: string;
};
layout?: {
panels: {
left?: string[];
right?: string[];
};
};
onEnter: () => void | CommandCallback[];
};
class WorkflowStepsService extends PubSubService {
private _extensionManager: ExtensionManager;
private _servicesManager: AppTypes.ServicesManager;
private _commandsManager: CommandsManager;
private _workflowSteps: WorkflowStep[];
private _activeWorkflowStep: WorkflowStep;
constructor(
extensionManager: ExtensionManager,
commandsManager: CommandsManager,
servicesManager: AppTypes.ServicesManager
) {
super(EVENTS);
this._workflowSteps = [];
this._activeWorkflowStep = null;
this._extensionManager = extensionManager;
this._commandsManager = commandsManager;
this._servicesManager = servicesManager;
}
public get workflowSteps(): WorkflowStep[] {
return [...this._workflowSteps];
}
public get activeWorkflowStep(): WorkflowStep {
return this._activeWorkflowStep;
}
public addWorkflowSteps(workflowSteps: WorkflowStep[]): void {
let workflowStepAdded = false;
workflowSteps.forEach(newWorkflowStep => {
const workflowStepExists = this._workflowSteps.some(
workflowStep => workflowStep.id === newWorkflowStep.id
);
if (workflowStepExists) {
throw new Error(`Duplicated workflow step id (${newWorkflowStep.id})`);
}
this._workflowSteps.push(newWorkflowStep);
workflowStepAdded = true;
});
if (workflowStepAdded) {
this._broadcastEvent(EVENTS.STEPS_CHANGED, {});
}
}
private _updateToolBar(workflowStep: WorkflowStep) {
const { toolbarService } = this._servicesManager.services;
const { toolbarButtons } = workflowStep;
const toUse = Array.isArray(toolbarButtons) ? toolbarButtons : [toolbarButtons];
toUse.forEach(({ buttonSection, buttons }) => {
toolbarService.clearButtonSection(buttonSection);
toolbarService.createButtonSection(buttonSection, buttons);
});
}
private _updatePanels(workflowStep: WorkflowStep) {
const { panelService } = this._servicesManager.services;
const panels = workflowStep?.layout?.panels;
if (!panels) {
return;
}
panelService.setPanels(panels, workflowStep?.layout?.options);
}
private _updateHangingProtocol(workflowStep: WorkflowStep) {
const { hangingProtocol } = workflowStep;
if (!hangingProtocol) {
return;
}
this._commandsManager.runCommand('setHangingProtocol', {
protocolId: hangingProtocol.protocolId,
stageId: hangingProtocol.stageId,
stageIndex: hangingProtocol.stageIndex,
});
}
private _invokeCallbacks(callbacks) {
if (!callbacks) {
return;
}
const commandsManager = this._commandsManager;
if (!Array.isArray) {
callbacks = [callbacks];
}
// Invoke all callbacks which may be a function or an object like
// { commandName: string, options?: object }
callbacks.forEach(callback => {
let fn = callback;
if (callback?.commandName) {
const { commandName, options } = callback;
fn = () => commandsManager.runCommand(commandName, options);
}
fn();
});
}
public setActiveWorkflowStep(workflowStepId: string): void {
const previousWorkflowStep = this._activeWorkflowStep;
if (workflowStepId === previousWorkflowStep?.id) {
return;
}
const newWorkflowStep = this._workflowSteps.find(step => step.id === workflowStepId);
if (!newWorkflowStep) {
throw new Error(`Invalid workflowStepId (${workflowStepId})`);
}
// onEnter needs to be called before updating the Hanging Protocol because
// some displaySets need to be created before moving to the next HP stage
// (eg: convert segmentations into a chart displaySet). If needed we can
// change it to onBeforeEnter and onAfterEnter in the future.
this._invokeCallbacks(newWorkflowStep.onEnter);
this._activeWorkflowStep = newWorkflowStep;
this._updateToolBar(newWorkflowStep);
this._updatePanels(newWorkflowStep);
this._updateHangingProtocol(newWorkflowStep);
this._broadcastEvent(EVENTS.ACTIVE_STEP_CHANGED, {
activeWorkflowStep: newWorkflowStep,
});
}
public reset(): void {
this._activeWorkflowStep = null;
this._workflowSteps = [];
}
public onModeEnter(): void {
this.reset();
}
public static REGISTRATION = {
name: 'workflowStepsService',
create: ({ extensionManager, commandsManager, servicesManager }): WorkflowStepsService => {
return new WorkflowStepsService(extensionManager, commandsManager, servicesManager);
},
};
}
export { WorkflowStepsService as default, WorkflowStepsService };

View File

@@ -0,0 +1,3 @@
import WorkflowStepsService from './WorkflowStepsService';
export default WorkflowStepsService;

Some files were not shown because too many files have changed in this diff Show More