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

View File

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

View File

@@ -0,0 +1,132 @@
import guid from '../../utils/guid';
/**
* Consumer must implement:
* this.listeners = {}
* this.EVENTS = { "EVENT_KEY": "EVENT_VALUE" }
*/
export default {
subscribe,
_broadcastEvent,
_unsubscribe,
_isValidEvent,
};
/**
* Subscribe to updates.
*
* @param {string} eventName The name of the event
* @param {Function} callback Events callback
* @return {Object} Observable object with actions
*/
function subscribe(eventName, callback) {
if (this._isValidEvent(eventName)) {
const listenerId = guid();
const subscription = { id: listenerId, callback };
// console.info(`Subscribing to '${eventName}'.`);
if (Array.isArray(this.listeners[eventName])) {
this.listeners[eventName].push(subscription);
} else {
this.listeners[eventName] = [subscription];
}
return {
unsubscribe: () => this._unsubscribe(eventName, listenerId),
};
} else {
throw new Error(`Event ${eventName} not supported.`);
}
}
/**
* Unsubscribe to measurement updates.
*
* @param {string} eventName The name of the event
* @param {string} listenerId The listeners id
* @return void
*/
function _unsubscribe(eventName, listenerId) {
if (!this.listeners[eventName]) {
return;
}
const listeners = this.listeners[eventName];
if (Array.isArray(listeners)) {
this.listeners[eventName] = listeners.filter(({ id }) => id !== listenerId);
} else {
this.listeners[eventName] = undefined;
}
}
/**
* Check if a given event is valid.
*
* @param {string} eventName The name of the event
* @return {boolean} Event name validation
*/
function _isValidEvent(eventName) {
return Object.values(this.EVENTS).includes(eventName);
}
/**
* Broadcasts changes.
*
* @param {string} eventName - The event name
* @param {func} callbackProps - Properties to pass callback
* @return void
*/
function _broadcastEvent(eventName, callbackProps) {
const hasListeners = Object.keys(this.listeners).length > 0;
const hasCallbacks = Array.isArray(this.listeners[eventName]);
const event = new CustomEvent(eventName, { detail: callbackProps });
document.body.dispatchEvent(event);
if (hasListeners && hasCallbacks) {
this.listeners[eventName].forEach(listener => {
listener.callback(callbackProps);
});
}
}
/** Export a PubSubService class to be used instead of the individual items */
export class PubSubService {
EVENTS: any;
subscribe: (eventName: string, callback: Function) => { unsubscribe: () => any };
_broadcastEvent: (eventName: string, callbackProps: any) => void;
_unsubscribe: (eventName: string, listenerId: string) => void;
_isValidEvent: (eventName: string) => boolean;
listeners: {};
unsubscriptions: any[];
constructor(EVENTS) {
this.EVENTS = EVENTS;
this.subscribe = subscribe;
this._broadcastEvent = _broadcastEvent;
this._unsubscribe = _unsubscribe;
this._isValidEvent = _isValidEvent;
this.listeners = {};
this.unsubscriptions = [];
}
reset() {
this.unsubscriptions.forEach(unsub => unsub());
this.unsubscriptions = [];
}
/**
* Creates an event that records whether or not someone
* has consumed it. Call eventData.consume() to consume the event.
* Check eventData.isConsumed to see if it is consumed or not.
* @param props - to include in the event
*/
protected createConsumableEvent(props) {
return {
...props,
isConsumed: false,
consume: function Consume() {
this.isConsumed = true;
},
};
}
}

View File

@@ -0,0 +1,45 @@
import MeasurementService from './MeasurementService';
import ServicesManager from './ServicesManager';
import ServiceProvidersManager from './ServiceProvidersManager';
import UIDialogService from './UIDialogService';
import UIModalService from './UIModalService';
import UINotificationService from './UINotificationService';
import UIViewportDialogService from './UIViewportDialogService';
import DicomMetadataStore from './DicomMetadataStore';
import DisplaySetService from './DisplaySetService';
import ToolbarService from './ToolBarService';
import ViewportGridService from './ViewportGridService';
import CineService from './CineService';
import HangingProtocolService from './HangingProtocolService';
import pubSubServiceInterface, { PubSubService } from './_shared/pubSubServiceInterface';
import UserAuthenticationService from './UserAuthenticationService';
import CustomizationService from './CustomizationService';
import PanelService from './PanelService';
import WorkflowStepsService from './WorkflowStepsService';
import StudyPrefetcherService from './StudyPrefetcherService';
import type Services from '../types/Services';
export {
Services,
MeasurementService,
ServicesManager,
ServiceProvidersManager,
CustomizationService,
UIDialogService,
UIModalService,
UINotificationService,
UIViewportDialogService,
DicomMetadataStore,
DisplaySetService,
ToolbarService,
ViewportGridService,
HangingProtocolService,
CineService,
pubSubServiceInterface,
PubSubService,
UserAuthenticationService,
PanelService,
WorkflowStepsService,
StudyPrefetcherService,
};