init
This commit is contained in:
123
platform/core/src/services/CineService/CineService.ts
Normal file
123
platform/core/src/services/CineService/CineService.ts
Normal 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;
|
||||
2
platform/core/src/services/CineService/index.ts
Normal file
2
platform/core/src/services/CineService/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import CineService from './CineService';
|
||||
export default CineService;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
3
platform/core/src/services/CustomizationService/index.ts
Normal file
3
platform/core/src/services/CustomizationService/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import CustomizationService from './CustomizationService';
|
||||
|
||||
export default CustomizationService;
|
||||
45
platform/core/src/services/CustomizationService/types.ts
Normal file
45
platform/core/src/services/CustomizationService/types.ts
Normal 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[];
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
4
platform/core/src/services/DicomMetadataStore/index.ts
Normal file
4
platform/core/src/services/DicomMetadataStore/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import DicomMetadataStore from './DicomMetadataStore';
|
||||
|
||||
export { DicomMetadataStore };
|
||||
export default DicomMetadataStore;
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
9
platform/core/src/services/DisplaySetService/EVENTS.js
Normal file
9
platform/core/src/services/DisplaySetService/EVENTS.js
Normal 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;
|
||||
5
platform/core/src/services/DisplaySetService/index.ts
Normal file
5
platform/core/src/services/DisplaySetService/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import DisplaySetService from './DisplaySetService';
|
||||
import { DisplaySetMessage, DisplaySetMessageList } from './DisplaySetMessage';
|
||||
|
||||
export default DisplaySetService;
|
||||
export { DisplaySetMessage, DisplaySetMessageList };
|
||||
173
platform/core/src/services/HangingProtocolService/HPMatcher.js
Normal file
173
platform/core/src/services/HangingProtocolService/HPMatcher.js
Normal 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 };
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export default (study, extraData) => extraData?.displaySets?.map(ds => ds.SeriesDescription);
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
import HangingProtocolService from './HangingProtocolService';
|
||||
|
||||
export default HangingProtocolService;
|
||||
@@ -0,0 +1,98 @@
|
||||
const comparators = [
|
||||
{
|
||||
id: 'equals',
|
||||
name: '= (Equals)',
|
||||
validator: 'equals',
|
||||
validatorOption: 'value',
|
||||
description: 'The attribute must equal this value.',
|
||||
},
|
||||
{
|
||||
id: 'doesNotEqual',
|
||||
name: '!= (Does not equal)',
|
||||
validator: 'doesNotEqual',
|
||||
validatorOption: 'value',
|
||||
description: 'The attribute must not equal this value.',
|
||||
},
|
||||
{
|
||||
id: 'contains',
|
||||
name: 'Contains',
|
||||
validator: 'contains',
|
||||
validatorOption: 'value',
|
||||
description: 'The attribute must contain this value.',
|
||||
},
|
||||
{
|
||||
id: 'doesNotContain',
|
||||
name: 'Does not contain',
|
||||
validator: 'doesNotContain',
|
||||
validatorOption: 'value',
|
||||
description: 'The attribute must not contain this value.',
|
||||
},
|
||||
{
|
||||
id: 'startsWith',
|
||||
name: 'Starts with',
|
||||
validator: 'startsWith',
|
||||
validatorOption: 'value',
|
||||
description: 'The attribute must start with this value.',
|
||||
},
|
||||
{
|
||||
id: 'endsWith',
|
||||
name: 'Ends with',
|
||||
validator: 'endsWith',
|
||||
validatorOption: 'value',
|
||||
description: 'The attribute must end with this value.',
|
||||
},
|
||||
{
|
||||
id: 'onlyInteger',
|
||||
name: 'Only Integers',
|
||||
validator: 'numericality',
|
||||
validatorOption: 'onlyInteger',
|
||||
description: "Real numbers won't be allowed.",
|
||||
},
|
||||
{
|
||||
id: 'greaterThan',
|
||||
name: '> (Greater than)',
|
||||
validator: 'numericality',
|
||||
validatorOption: 'greaterThan',
|
||||
description: 'The attribute has to be greater than this value.',
|
||||
},
|
||||
{
|
||||
id: 'greaterThanOrEqualTo',
|
||||
name: '>= (Greater than or equal to)',
|
||||
validator: 'numericality',
|
||||
validatorOption: 'greaterThanOrEqualTo',
|
||||
description: 'The attribute has to be at least this value.',
|
||||
},
|
||||
{
|
||||
id: 'lessThanOrEqualTo',
|
||||
name: '<= (Less than or equal to)',
|
||||
validator: 'numericality',
|
||||
validatorOption: 'lessThanOrEqualTo',
|
||||
description: 'The attribute can be this value at the most.',
|
||||
},
|
||||
{
|
||||
id: 'lessThan',
|
||||
name: '< (Less than)',
|
||||
validator: 'numericality',
|
||||
validatorOption: 'lessThan',
|
||||
description: 'The attribute has to be less than this value.',
|
||||
},
|
||||
{
|
||||
id: 'odd',
|
||||
name: 'Odd',
|
||||
validator: 'numericality',
|
||||
validatorOption: 'odd',
|
||||
description: 'The attribute has to be odd.',
|
||||
},
|
||||
{
|
||||
id: 'even',
|
||||
name: 'Even',
|
||||
validator: 'numericality',
|
||||
validatorOption: 'even',
|
||||
description: 'The attribute has to be even.',
|
||||
},
|
||||
];
|
||||
|
||||
// Immutable object
|
||||
Object.freeze(comparators);
|
||||
|
||||
export { comparators };
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Removes the first instance of an element from an array, if an equal value exists
|
||||
*
|
||||
* @param array
|
||||
* @param input
|
||||
*
|
||||
* @returns {boolean} Whether or not the element was found and removed
|
||||
*/
|
||||
const removeFromArray = (array, input) => {
|
||||
// If the array is empty, stop here
|
||||
if (!array || !array.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
array.forEach((value, index) => {
|
||||
// TODO: Double check whether or not this deep equality check is necessary
|
||||
//if (_.isEqual(value, input)) {
|
||||
if (value === input) {
|
||||
indexToRemove = index;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (indexToRemove === void 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
array.splice(indexToRemove, 1);
|
||||
return true;
|
||||
};
|
||||
|
||||
export { removeFromArray };
|
||||
@@ -0,0 +1,8 @@
|
||||
// Sorts an array by score
|
||||
const sortByScore = arr => {
|
||||
arr.sort((a, b) => {
|
||||
return b.score - a.score;
|
||||
});
|
||||
};
|
||||
|
||||
export { sortByScore };
|
||||
@@ -0,0 +1,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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
3
platform/core/src/services/MeasurementService/index.ts
Normal file
3
platform/core/src/services/MeasurementService/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import MeasurementService from './MeasurementService';
|
||||
|
||||
export default MeasurementService;
|
||||
229
platform/core/src/services/PanelService/PanelService.tsx
Normal file
229
platform/core/src/services/PanelService/PanelService.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
3
platform/core/src/services/PanelService/index.ts
Normal file
3
platform/core/src/services/PanelService/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import PanelService from './PanelService';
|
||||
|
||||
export default PanelService;
|
||||
31
platform/core/src/services/ServiceProvidersManager.ts
Normal file
31
platform/core/src/services/ServiceProvidersManager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
97
platform/core/src/services/ServicesManager.test.js
Normal file
97
platform/core/src/services/ServicesManager.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
85
platform/core/src/services/ServicesManager.ts
Normal file
85
platform/core/src/services/ServicesManager.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { StudyPrefetcherService } from './StudyPrefetcherService';
|
||||
|
||||
export { StudyPrefetcherService as default, StudyPrefetcherService };
|
||||
567
platform/core/src/services/ToolBarService/ToolbarService.ts
Normal file
567
platform/core/src/services/ToolBarService/ToolbarService.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
3
platform/core/src/services/ToolBarService/index.ts
Normal file
3
platform/core/src/services/ToolBarService/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import ToolbarService from './ToolbarService';
|
||||
|
||||
export default ToolbarService;
|
||||
53
platform/core/src/services/ToolBarService/types.ts
Normal file
53
platform/core/src/services/ToolBarService/types.ts
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
3
platform/core/src/services/UIDialogService/index.ts
Normal file
3
platform/core/src/services/UIDialogService/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import UIDialogService from './UIDialogService';
|
||||
|
||||
export default UIDialogService;
|
||||
90
platform/core/src/services/UIModalService/index.ts
Normal file
90
platform/core/src/services/UIModalService/index.ts
Normal 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;
|
||||
152
platform/core/src/services/UINotificationService/index.ts
Normal file
152
platform/core/src/services/UINotificationService/index.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
import UIViewportDialogService from './UIViewportDialogService';
|
||||
|
||||
export default UIViewportDialogService;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
import UserAuthenticationService from './UserAuthenticationService';
|
||||
|
||||
export default UserAuthenticationService;
|
||||
@@ -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;
|
||||
3
platform/core/src/services/ViewportGridService/index.ts
Normal file
3
platform/core/src/services/ViewportGridService/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import ViewportGridService from './ViewportGridService';
|
||||
|
||||
export default ViewportGridService;
|
||||
@@ -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 };
|
||||
3
platform/core/src/services/WorkflowStepsService/index.ts
Normal file
3
platform/core/src/services/WorkflowStepsService/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import WorkflowStepsService from './WorkflowStepsService';
|
||||
|
||||
export default WorkflowStepsService;
|
||||
3
platform/core/src/services/_shared/index.js
Normal file
3
platform/core/src/services/_shared/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import pubSubServiceInterface from './pubSubServiceInterface';
|
||||
|
||||
export { pubSubServiceInterface };
|
||||
132
platform/core/src/services/_shared/pubSubServiceInterface.ts
Normal file
132
platform/core/src/services/_shared/pubSubServiceInterface.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
45
platform/core/src/services/index.ts
Normal file
45
platform/core/src/services/index.ts
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user