{
setInstanceNumber(parseInt(value));
}}
diff --git a/extensions/default/src/Panels/PanelStudyBrowser.tsx b/extensions/default/src/Panels/PanelStudyBrowser.tsx
index 3129bb265..49f84f751 100644
--- a/extensions/default/src/Panels/PanelStudyBrowser.tsx
+++ b/extensions/default/src/Panels/PanelStudyBrowser.tsx
@@ -234,6 +234,7 @@ function PanelStudyBrowser({
return (
diff --git a/extensions/default/src/getCustomizationModule.tsx b/extensions/default/src/getCustomizationModule.tsx
new file mode 100644
index 000000000..be0c6ac88
--- /dev/null
+++ b/extensions/default/src/getCustomizationModule.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+
+/**
+ *
+ * Note: this is an example of how the customization module can be used
+ * using the customization module. Below, we are adding a new custom route
+ * to the application at the path /custom and rendering a custom component
+ * Real world use cases of the having a custom route would be to add a
+ * custom page for the user to view their profile, or to add a custom
+ * page for login etc.
+ */
+export default function getCustomizationModule() {
+ return [
+ {
+ name: 'helloPage',
+ value: {
+ id: 'customRoutes',
+ routes: [
+ {
+ path: '/custom',
+ children: () => (
+
Hello Custom Route
+ ),
+ },
+ ],
+ },
+ },
+ ];
+}
diff --git a/extensions/default/src/index.js b/extensions/default/src/index.js
index ecbce1582..bb76b0afc 100644
--- a/extensions/default/src/index.js
+++ b/extensions/default/src/index.js
@@ -6,6 +6,7 @@ import getToolbarModule from './getToolbarModule';
import commandsModule from './commandsModule';
import getHangingProtocolModule from './getHangingProtocolModule';
import getStudiesForPatientByStudyInstanceUID from './Panels/getStudiesForPatientByStudyInstanceUID';
+import getCustomizationModule from './getCustomizationModule';
import { id } from './id.js';
import init from './init';
@@ -36,6 +37,8 @@ const defaultExtension = {
},
];
},
+
+ getCustomizationModule,
};
export default defaultExtension;
diff --git a/extensions/measurement-tracking/package.json b/extensions/measurement-tracking/package.json
index b070e4067..99ce2f3cc 100644
--- a/extensions/measurement-tracking/package.json
+++ b/extensions/measurement-tracking/package.json
@@ -32,8 +32,8 @@
"peerDependencies": {
"@ohif/core": "^3.0.0",
"classnames": "^2.2.6",
- "@cornerstonejs/core": "^0.21.5",
- "@cornerstonejs/tools": "^0.29.8",
+ "@cornerstonejs/core": "^0.22.3",
+ "@cornerstonejs/tools": "^0.30.6",
"@ohif/extension-cornerstone-dicom-sr": "^3.0.0",
"dcmjs": "^0.28.3",
"prop-types": "^15.6.2",
diff --git a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx
index ea79c6be5..84bcfddc9 100644
--- a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx
+++ b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx
@@ -349,6 +349,7 @@ function PanelStudyBrowserTracking({
return (
{
- const { ToolBarService, ToolGroupService } = servicesManager.services;
+ const {
+ MeasurementService,
+ ToolBarService,
+ ToolGroupService,
+ } = servicesManager.services;
+
+ MeasurementService.clearMeasurements();
// Init Default and SR ToolGroups
initToolGroups(extensionManager, ToolGroupService, commandsManager);
@@ -120,20 +126,16 @@ function modeFactory() {
const {
ToolGroupService,
SyncGroupService,
- MeasurementService,
ToolBarService,
SegmentationService,
CornerstoneViewportService,
- HangingProtocolService,
} = servicesManager.services;
ToolBarService.reset();
- MeasurementService.clearMeasurements();
ToolGroupService.destroy();
SyncGroupService.destroy();
SegmentationService.destroy();
CornerstoneViewportService.destroy();
- HangingProtocolService.reset();
},
validationTags: {
study: [],
diff --git a/modes/tmtv/src/index.js b/modes/tmtv/src/index.js
index fdbf697ed..cb927d6e2 100644
--- a/modes/tmtv/src/index.js
+++ b/modes/tmtv/src/index.js
@@ -136,21 +136,17 @@ function modeFactory({ modeConfiguration }) {
const {
ToolGroupService,
SyncGroupService,
- MeasurementService,
ToolBarService,
SegmentationService,
CornerstoneViewportService,
- HangingProtocolService,
} = servicesManager.services;
unsubscriptions.forEach(unsubscribe => unsubscribe());
ToolBarService.reset();
- MeasurementService.clearMeasurements();
ToolGroupService.destroy();
SyncGroupService.destroy();
SegmentationService.destroy();
CornerstoneViewportService.destroy();
- HangingProtocolService.reset();
},
validationTags: {
study: [],
diff --git a/modes/tmtv/src/utils/setFusionActiveVolume.js b/modes/tmtv/src/utils/setFusionActiveVolume.js
index c7dbe20d4..44790eebe 100644
--- a/modes/tmtv/src/utils/setFusionActiveVolume.js
+++ b/modes/tmtv/src/utils/setFusionActiveVolume.js
@@ -32,14 +32,17 @@ export default function setFusionActiveVolume(
toolNames.EllipticalROI
);
+ // Todo: this should not take into account the loader id
+ const volumeId = `cornerstoneStreamingImageVolume:${displaySets[0].displaySetInstanceUID}`;
+
const windowLevelConfig = {
...wlToolConfig,
- volumeId: displaySets[0].displaySetInstanceUID,
+ volumeId,
};
const ellipticalROIConfig = {
...ellipticalToolConfig,
- volumeId: displaySets[0].displaySetInstanceUID,
+ volumeId,
};
ToolGroupService.setToolConfiguration(
diff --git a/platform/core/src/extensions/ExtensionManager.js b/platform/core/src/extensions/ExtensionManager.js
index d7c93fe32..e72c72233 100644
--- a/platform/core/src/extensions/ExtensionManager.js
+++ b/platform/core/src/extensions/ExtensionManager.js
@@ -40,13 +40,12 @@ export default class ExtensionManager {
_extensionLifeCycleHooks,
} = this;
- const {
- MeasurementService,
- ViewportGridService,
- } = _servicesManager.services;
-
- MeasurementService.clearMeasurements();
- ViewportGridService.reset();
+ // The onModeEnter of the service must occur BEFORE the extension
+ // onModeEnter in order to reset the state to a standard state
+ // before the extension restores and cached data.
+ for (const service of Object.values(_servicesManager.services)) {
+ service?.onModeEnter?.();
+ }
registeredExtensionIds.forEach(extensionId => {
const onModeEnter = _extensionLifeCycleHooks.onModeEnter[extensionId];
@@ -69,14 +68,6 @@ export default class ExtensionManager {
_extensionLifeCycleHooks,
} = this;
- const {
- MeasurementService,
- ViewportGridService,
- } = _servicesManager.services;
-
- MeasurementService.clearMeasurements();
- ViewportGridService.reset();
-
registeredExtensionIds.forEach(extensionId => {
const onModeExit = _extensionLifeCycleHooks.onModeExit[extensionId];
@@ -87,6 +78,16 @@ export default class ExtensionManager {
});
}
});
+
+ // The service onModeExit calls must occur after the extension ones
+ // so that extension ones can store/restore data.
+ for (const service of Object.values(_servicesManager.services)) {
+ try {
+ service?.onModeExit?.();
+ } catch (e) {
+ console.warn('onModeExit caught', e);
+ }
+ }
}
/**
@@ -200,6 +201,7 @@ export default class ExtensionManager {
case MODULE_TYPES.SOP_CLASS_HANDLER:
case MODULE_TYPES.CONTEXT:
case MODULE_TYPES.LAYOUT_TEMPLATE:
+ case MODULE_TYPES.CUSTOMIZATION:
case MODULE_TYPES.UTILITY:
// Default for most extension points,
// Just adds each entry ready for consumption by mode.
diff --git a/platform/core/src/extensions/ExtensionManager.test.js b/platform/core/src/extensions/ExtensionManager.test.js
index 1ecf778ad..47c737518 100644
--- a/platform/core/src/extensions/ExtensionManager.test.js
+++ b/platform/core/src/extensions/ExtensionManager.test.js
@@ -235,6 +235,9 @@ describe('ExtensionManager.js', () => {
getUtilityModule: () => {
return [{}];
},
+ getCustomizationModule: () => {
+ return [{}];
+ },
};
await extensionManager.registerExtension(extension);
diff --git a/platform/core/src/extensions/MODULE_TYPES.js b/platform/core/src/extensions/MODULE_TYPES.js
index a0627c9ba..c96c3f1fb 100644
--- a/platform/core/src/extensions/MODULE_TYPES.js
+++ b/platform/core/src/extensions/MODULE_TYPES.js
@@ -1,5 +1,6 @@
export default {
COMMANDS: 'commandsModule',
+ CUSTOMIZATION: 'customizationModule',
DATA_SOURCE: 'dataSourcesModule',
PANEL: 'panelModule',
SOP_CLASS_HANDLER: 'sopClassHandlerModule',
diff --git a/platform/core/src/index.test.js b/platform/core/src/index.test.js
index fdc3b60a5..cd07dfd60 100644
--- a/platform/core/src/index.test.js
+++ b/platform/core/src/index.test.js
@@ -24,6 +24,7 @@ describe('Top level exports', () => {
'OHIF',
//
'CineService',
+ 'CustomizationServiceRegistration',
'UIDialogService',
'UIModalService',
'UINotificationService',
diff --git a/platform/core/src/index.ts b/platform/core/src/index.ts
index c5adb427e..9abc4e1e3 100644
--- a/platform/core/src/index.ts
+++ b/platform/core/src/index.ts
@@ -27,6 +27,7 @@ import {
HangingProtocolService,
pubSubServiceInterface,
UserAuthenticationService,
+ CustomizationServiceRegistration,
} from './services';
import IWebApiDataSource from './DataSources/IWebApiDataSource';
@@ -57,6 +58,7 @@ const OHIF = {
viewer: {},
//
CineService,
+ CustomizationServiceRegistration,
UIDialogService,
UIModalService,
UINotificationService,
@@ -92,6 +94,7 @@ export {
DICOMWeb,
//
CineService,
+ CustomizationServiceRegistration,
UIDialogService,
UIModalService,
UINotificationService,
diff --git a/platform/core/src/services/CustomizationService/CustomizationService.test.js b/platform/core/src/services/CustomizationService/CustomizationService.test.js
new file mode 100644
index 000000000..959816ea5
--- /dev/null
+++ b/platform/core/src/services/CustomizationService/CustomizationService.test.js
@@ -0,0 +1,169 @@
+import CustomizationService from './CustomizationService';
+import log from '../../log';
+
+jest.mock('../../log.js', () => ({
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+}));
+
+const extensionManager = {
+ registeredExtensionIds: [],
+ moduleEntries: {},
+
+ 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,
+ });
+ });
+
+ 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.getGlobalCustomization('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');
+
+ 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.default'
+ ] = { 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);
+ });
+ });
+});
diff --git a/platform/core/src/services/CustomizationService/CustomizationService.ts b/platform/core/src/services/CustomizationService/CustomizationService.ts
new file mode 100644
index 000000000..ee8a55ef4
--- /dev/null
+++ b/platform/core/src/services/CustomizationService/CustomizationService.ts
@@ -0,0 +1,264 @@
+import merge from 'lodash.merge';
+import { PubSubService } from '../_shared/pubSubServiceInterface';
+import { Customization, NestedStrings, Obj } from './types';
+
+const EVENTS = {
+ MODE_CUSTOMIZATION_MODIFIED: 'event::CustomizationService:modeModified',
+ GLOBAL_CUSTOMIZATION_MODIFIED: 'event::CustomizationService:globalModified',
+};
+
+const flattenNestedStrings = (
+ strs: NestedStrings | string,
+ ret?: Record
+): Record => {
+ 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;
+};
+
+/**
+ * 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 {
+ commandsManager: Record;
+ extensionManager: Record;
+
+ modeCustomizations: Record = {};
+ globalCustomizations: Record = {};
+ configuration: UICustomizationConfiguration;
+
+ constructor({ configuration, commandsManager }) {
+ super(EVENTS);
+ this.commandsManager = commandsManager;
+ this.configuration = configuration || {};
+ }
+
+ public init(extensionManager: ExtensionManager): void {
+ this.extensionManager = extensionManager;
+ this.initDefaults();
+ this.addReferences(this.configuration);
+ }
+
+ initDefaults(): void {
+ this.extensionManager.registeredExtensionIds.forEach(extensionId => {
+ const key = `${extensionId}.customizationModule.default`;
+ const defaultCustomizations = this.findExtensionValue(key);
+ if (!defaultCustomizations) return;
+ const { value } = defaultCustomizations;
+ this.addReference(value, true);
+ });
+ }
+
+ findExtensionValue(value: string): Obj | void {
+ const entry = this.extensionManager.getModuleEntry(value);
+ return entry;
+ }
+
+ public onModeEnter(): void {
+ super.reset();
+ this.modeCustomizations = {};
+ }
+
+ /**
+ *
+ * @param {*} interaction - can be undefined to run nothing
+ * @param {*} extraOptions to include in the commands run
+ */
+ recordInteraction(
+ interaction: Customization | void,
+ extraOptions?: Record
+ ): void {
+ if (!interaction) return;
+ const commandsManager = this.commandsManager;
+ const { commands = [] } = interaction;
+
+ commands.forEach(({ commandName, commandOptions, context }) => {
+ if (commandName) {
+ commandsManager.runCommand(
+ commandName,
+ {
+ interaction,
+ ...commandOptions,
+ ...extraOptions,
+ },
+ context
+ );
+ } else {
+ console.warn('No command name supplied in', interaction);
+ }
+ });
+ }
+
+ public getModeCustomizations(): Record {
+ return this.modeCustomizations;
+ }
+
+ public setModeCustomization(
+ customizationId: string,
+ customization: Customization
+ ): void {
+ this.modeCustomizations[customizationId] = merge(
+ this.modeCustomizations[customizationId] || {},
+ customization
+ );
+ this._broadcastEvent(this.EVENTS.CUSTOMIZATION_MODIFIED, {
+ buttons: this.modeCustomizations,
+ button: this.modeCustomizations[customizationId],
+ });
+ }
+
+ /** Mode customizations are changes to the behaviour of the extensions
+ * when running in a given mode. Reset clears mode customizations.
+ * Note that global customizations over-ride mode customizations.
+ * @param defautlValue to return if no customization specified.
+ */
+ public getModeCustomization(
+ customizationId: string,
+ defaultValue?: Customization
+ ): Customization | void {
+ const customization =
+ this.globalCustomizations[customizationId] ??
+ this.modeCustomizations[customizationId] ??
+ defaultValue;
+ return this.applyType(customization);
+ }
+
+ /** Applies any inheritance due to UI Type customization */
+ public applyType(customization: Customization): Customization {
+ if (!customization) return customization;
+ const { customizationType } = customization;
+ if (!customizationType) return customization;
+ const parent = this.getModeCustomization(customizationType);
+ return parent
+ ? Object.assign(Object.create(parent), customization)
+ : customization;
+ }
+
+ public addModeCustomizations(modeCustomizations): void {
+ if (!modeCustomizations) {
+ return;
+ }
+ this.addReferences(modeCustomizations, false);
+
+ 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.applyType(this.globalCustomizations[id] ?? defaultValue);
+ }
+
+ setGlobalCustomization(id: string, value: Customization): void {
+ this.globalCustomizations[id] = value;
+ this._broadcastGlobalCustomizationModified();
+ }
+
+ protected setConfigGlobalCustomization(
+ configuration: AppConfigCustomization
+ ): void {
+ this.globalCustomizations = {};
+ 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 an array, or a single customization value,
+ * whose id is the id in the object, or the parent id.
+ * This allows for general use to register customizationModule entries.
+ */
+ addReference(
+ value?: Obj | Obj[] | string,
+ isGlobal = true,
+ id?: string
+ ): void {
+ if (!value) return;
+ if (typeof value === 'string') {
+ const extensionValue = this.findExtensionValue(value);
+ this.addReferences(extensionValue);
+ } else if (Array.isArray(value)) {
+ this.addReferences(value, isGlobal);
+ } else {
+ const useId = value.id || id;
+ this[isGlobal ? 'setGlobalCustomization' : 'setModeCustomization'](
+ useId as string,
+ value
+ );
+ }
+ }
+
+ /** References are:
+ * list of customizations, added in order
+ * object containing a customization id and value
+ * This format allows for the original whitelist format.
+ */
+ addReferences(references?: Obj | Obj[], isGlobal = true): void {
+ if (!references) return;
+ if (Array.isArray(references)) {
+ references.forEach(item => {
+ this.addReference(item, isGlobal);
+ });
+ } else {
+ for (const key of Object.keys(references)) {
+ const value = references[key];
+ this.addReference(value, isGlobal, key);
+ }
+ }
+ }
+}
diff --git a/platform/core/src/services/CustomizationService/index.ts b/platform/core/src/services/CustomizationService/index.ts
new file mode 100644
index 000000000..d2eec2824
--- /dev/null
+++ b/platform/core/src/services/CustomizationService/index.ts
@@ -0,0 +1,11 @@
+import CustomizationService from './CustomizationService';
+
+const CustomizationServiceRegistration = {
+ name: 'customizationService',
+ create: ({ configuration = {}, commandsManager }) => {
+ return new CustomizationService({ configuration, commandsManager });
+ },
+};
+
+export default CustomizationServiceRegistration;
+export { CustomizationService, CustomizationServiceRegistration };
diff --git a/platform/core/src/services/CustomizationService/types.ts b/platform/core/src/services/CustomizationService/types.ts
new file mode 100644
index 000000000..b9c382619
--- /dev/null
+++ b/platform/core/src/services/CustomizationService/types.ts
@@ -0,0 +1,39 @@
+import Command from '../../types/Command';
+import { ComponentType } from 'react';
+
+export type Obj = Record;
+
+export interface BaseCustomization extends Obj {
+ id: string;
+ customizationType?: string;
+ description?: string;
+ label?: string;
+ commands?: Command[];
+}
+
+export interface LabelCustomization extends BaseCustomization {
+ label: string;
+}
+
+export interface CodeCustomization extends BaseCustomization {
+ code: string;
+}
+
+export interface CommandCustomization extends BaseCustomization {
+ commands: Command[];
+}
+
+export type Customization =
+ | BaseCustomization
+ | LabelCustomization
+ | CommandCustomization
+ | CodeCustomization;
+
+export default Customization;
+
+export type ComponentReturn = {
+ component: ComponentType;
+ props?: Obj;
+};
+
+export type NestedStrings = string[] | NestedStrings[];
diff --git a/platform/core/src/services/DicomMetadataStore/createStudyMetadata.js b/platform/core/src/services/DicomMetadataStore/createStudyMetadata.js
index ce59aff66..4822b616c 100644
--- a/platform/core/src/services/DicomMetadataStore/createStudyMetadata.js
+++ b/platform/core/src/services/DicomMetadataStore/createStudyMetadata.js
@@ -49,7 +49,15 @@ function createStudyMetadata(StudyInstanceUID) {
);
if (existingSeries) {
- existingSeries.instances.push(...instances);
+ // Only add instances not already present, so generate a map
+ // of existing instances and filter the to add by things
+ // already present.
+ const sopMap = {};
+ existingSeries.instances.forEach(
+ it => (sopMap[it.SOPInstanceUID] = it)
+ );
+ const newInstances = instances.filter(it => !sopMap[it.SOPInstanceUID]);
+ existingSeries.instances.push(...newInstances);
} else {
const series = createSeriesMetadata(instances);
this.series.push(series);
diff --git a/platform/core/src/services/DisplaySetService/DisplaySetService.js b/platform/core/src/services/DisplaySetService/DisplaySetService.js
index 2f4357bd9..da3813a3b 100644
--- a/platform/core/src/services/DisplaySetService/DisplaySetService.js
+++ b/platform/core/src/services/DisplaySetService/DisplaySetService.js
@@ -53,7 +53,12 @@ export default class DisplaySetService {
const activeDisplaySets = this.activeDisplaySets;
displaySets.forEach(displaySet => {
- activeDisplaySets.push(displaySet);
+ // This test makes adding display sets an N^2 operation, so it might
+ // become important to do this in an efficient manner for large
+ // numbers of display sets.
+ if (!activeDisplaySets.includes(displaySet)) {
+ activeDisplaySets.push(displaySet);
+ }
});
}
@@ -194,6 +199,17 @@ export default class DisplaySetService {
}
};
+ /**
+ * 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.
+ */
+ onModeExit() {
+ this.getDisplaySetCache().length = 0;
+ this.activeDisplaySets.length = 0;
+ }
+
makeDisplaySetForInstances(instancesSrc, settings) {
let instances = instancesSrc;
const instance = instances[0];
diff --git a/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts b/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts
index 216bf80bd..2a1a7230f 100644
--- a/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts
+++ b/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts
@@ -122,6 +122,11 @@ class HangingProtocolService {
this.displaySetMatchDetails = new Map();
}
+ /** Leave the hanging protocol in the initialized state */
+ public onModeExit() {
+ this.reset();
+ }
+
public getActiveProtocol(): {
protocol: HangingProtocol.Protocol;
stage: number;
diff --git a/platform/core/src/services/MeasurementService/MeasurementService.js b/platform/core/src/services/MeasurementService/MeasurementService.js
index f7258941d..a0955e86a 100644
--- a/platform/core/src/services/MeasurementService/MeasurementService.js
+++ b/platform/core/src/services/MeasurementService/MeasurementService.js
@@ -570,6 +570,15 @@ class MeasurementService {
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();
+ }
+
jumpToMeasurement(viewportIndex, measurementUID) {
const measurement = this.measurements[measurementUID];
diff --git a/platform/core/src/services/ToolBarService/ToolBarService.js b/platform/core/src/services/ToolBarService/ToolBarService.ts
similarity index 91%
rename from platform/core/src/services/ToolBarService/ToolBarService.js
rename to platform/core/src/services/ToolBarService/ToolBarService.ts
index 29f2e9f2b..908899f21 100644
--- a/platform/core/src/services/ToolBarService/ToolBarService.js
+++ b/platform/core/src/services/ToolBarService/ToolBarService.ts
@@ -52,11 +52,19 @@ export default class ToolBarService {
this.buttons = {};
}
+ onModeEnter() {
+ this.reset();
+ }
+
/**
*
- * @param {*} interaction
+ * @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}
*/
- recordInteraction(interaction) {
+ recordInteraction(interaction, options) {
+ if (!interaction) return;
const commandsManager = this._commandsManager;
const { groupId, itemId, interactionType, commands } = interaction;
@@ -64,7 +72,14 @@ export default class ToolBarService {
case 'action': {
commands.forEach(({ commandName, commandOptions, context }) => {
if (commandName) {
- commandsManager.runCommand(commandName, commandOptions, context);
+ commandsManager.runCommand(
+ commandName,
+ {
+ ...commandOptions,
+ ...options,
+ },
+ context
+ );
}
});
break;
@@ -171,6 +186,10 @@ export default class ToolBarService {
}
}
+ getButton(id) {
+ return this.buttons[id];
+ }
+
setButtons(buttons) {
this.buttons = buttons;
this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, {
diff --git a/platform/core/src/services/ToolBarService/index.js b/platform/core/src/services/ToolBarService/index.ts
similarity index 100%
rename from platform/core/src/services/ToolBarService/index.js
rename to platform/core/src/services/ToolBarService/index.ts
diff --git a/platform/core/src/services/ViewportGridService/ViewportGridService.ts b/platform/core/src/services/ViewportGridService/ViewportGridService.ts
index 2cc193847..0700afa00 100644
--- a/platform/core/src/services/ViewportGridService/ViewportGridService.ts
+++ b/platform/core/src/services/ViewportGridService/ViewportGridService.ts
@@ -24,6 +24,7 @@ class ViewportGridService {
restoreCachedLayout: restoreCachedLayoutImplementation,
setLayout: setLayoutImplementation,
reset: resetImplementation,
+ onModeExit: onModeExitImplementation,
set: setImplementation,
}) {
if (getStateImplementation) {
@@ -50,6 +51,9 @@ class ViewportGridService {
if (restoreCachedLayoutImplementation) {
this.serviceImplementation._restoreCachedLayout = restoreCachedLayoutImplementation;
}
+ if (onModeExitImplementation) {
+ this.serviceImplementation._onModeExit = onModeExitImplementation;
+ }
if (setImplementation) {
this.serviceImplementation._set = setImplementation;
}
@@ -92,6 +96,16 @@ class ViewportGridService {
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 setCachedLayout({ cacheId, cachedLayout }) {
this.serviceImplementation._setCachedLayout({ cacheId, cachedLayout });
}
diff --git a/platform/core/src/services/_shared/pubSubServiceInterface.js b/platform/core/src/services/_shared/pubSubServiceInterface.js
index 9a22a3ccb..855f07280 100644
--- a/platform/core/src/services/_shared/pubSubServiceInterface.js
+++ b/platform/core/src/services/_shared/pubSubServiceInterface.js
@@ -1,4 +1,5 @@
import guid from '../../utils/guid';
+import * as Types from '../../Types';
/**
* Consumer must implement:
@@ -24,7 +25,7 @@ function subscribe(eventName, callback) {
const listenerId = guid();
const subscription = { id: listenerId, callback };
- console.info(`Subscribing to '${eventName}'.`);
+ // console.info(`Subscribing to '${eventName}'.`);
if (Array.isArray(this.listeners[eventName])) {
this.listeners[eventName].push(subscription);
} else {
@@ -86,3 +87,21 @@ function _broadcastEvent(eventName, callbackProps) {
});
}
}
+
+/** Export a PubSubService class to be used instead of the individual items */
+export class PubSubService {
+ 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 = [];
+ }
+}
diff --git a/platform/core/src/services/index.js b/platform/core/src/services/index.js
index 87f714815..01605f407 100644
--- a/platform/core/src/services/index.js
+++ b/platform/core/src/services/index.js
@@ -10,12 +10,20 @@ import ToolBarService from './ToolBarService';
import ViewportGridService from './ViewportGridService';
import CineService from './CineService';
import HangingProtocolService from './HangingProtocolService';
-import pubSubServiceInterface from './_shared/pubSubServiceInterface';
+import pubSubServiceInterface, {
+ PubSubService,
+} from './_shared/pubSubServiceInterface';
import UserAuthenticationService from './UserAuthenticationService';
+import {
+ CustomizationService,
+ CustomizationServiceRegistration,
+} from './CustomizationService';
export {
MeasurementService,
ServicesManager,
+ CustomizationService,
+ CustomizationServiceRegistration,
UIDialogService,
UIModalService,
UINotificationService,
@@ -27,5 +35,6 @@ export {
HangingProtocolService,
CineService,
pubSubServiceInterface,
+ PubSubService,
UserAuthenticationService,
};
diff --git a/platform/core/src/types/Command.ts b/platform/core/src/types/Command.ts
new file mode 100644
index 000000000..42d75f63b
--- /dev/null
+++ b/platform/core/src/types/Command.ts
@@ -0,0 +1,7 @@
+export interface Command {
+ commandName: string;
+ commandOptions?: Record;
+ context?: string;
+}
+
+export default Command;
diff --git a/platform/core/src/types/index.ts b/platform/core/src/types/index.ts
index 80351c953..9e1f79557 100644
--- a/platform/core/src/types/index.ts
+++ b/platform/core/src/types/index.ts
@@ -5,13 +5,21 @@ import {
} from './StudyMetadata';
import Consumer from './Consumer';
-
+import { ExtensionManager } from '../extensions';
+import { CustomizationService, PubSubService } from '../services';
import * as HangingProtocol from './HangingProtocol';
+import Command from './Command';
+
+export * from '../services/CustomizationService/types';
export type {
+ ExtensionManager,
HangingProtocol,
StudyMetadata,
SeriesMetadata,
InstanceMetadata,
Consumer,
+ PubSubService,
+ CustomizationService,
+ Command,
};
diff --git a/platform/core/src/utils/metadataProvider/fetchPaletteColorLookupTableData.js b/platform/core/src/utils/metadataProvider/fetchPaletteColorLookupTableData.js
index e79749958..54cdff170 100644
--- a/platform/core/src/utils/metadataProvider/fetchPaletteColorLookupTableData.js
+++ b/platform/core/src/utils/metadataProvider/fetchPaletteColorLookupTableData.js
@@ -12,16 +12,16 @@
* Returns undefined if the palette data is absent.
*/
export default function fetchPaletteColorLookupTableData(
- item, tag, descriptorTag
+ item,
+ tag,
+ descriptorTag
) {
const { PaletteColorLookupTableUID } = item;
const paletteData = item[tag];
- if (paletteData === undefined && PaletteColorLookupTableUID === undefined) return;
+ if (paletteData === undefined && PaletteColorLookupTableUID === undefined)
+ return;
// performance optimization - read UID and cache by UID
- return _getPaletteColor(
- item[tag],
- item[descriptorTag]
- )
+ return _getPaletteColor(item[tag], item[descriptorTag]);
}
function _getPaletteColor(paletteColorLookupTableData, lutDescriptor) {
@@ -36,13 +36,12 @@ function _getPaletteColor(paletteColorLookupTableData, lutDescriptor) {
if (bits === 16) {
let j = 0;
for (let i = 0; i < numLutEntries; i++) {
- lut[i] = arraybuffer[j++] + arraybuffer[j++] << 8;
+ lut[i] = (arraybuffer[j++] + arraybuffer[j++]) << 8;
}
} else {
for (let i = 0; i < numLutEntries; i++) {
lut[i] = byteArray[i];
}
-
}
return lut;
};
@@ -53,20 +52,33 @@ function _getPaletteColor(paletteColorLookupTableData, lutDescriptor) {
if (paletteColorLookupTableData.InlineBinary) {
try {
- const arraybuffer = Uint8Array.from(atob(paletteColorLookupTableData.InlineBinary), c =>
- c.charCodeAt(0)
+ const arraybuffer = Uint8Array.from(
+ atob(paletteColorLookupTableData.InlineBinary),
+ c => c.charCodeAt(0)
);
- return (paletteColorLookupTableData.palette = arrayBufferToPaletteColorLUT(arraybuffer));
+ return (paletteColorLookupTableData.palette = arrayBufferToPaletteColorLUT(
+ arraybuffer
+ ));
} catch (e) {
- console.log("Couldn't decode", paletteColorLookupTableData.InlineBinary, e);
+ console.log(
+ "Couldn't decode",
+ paletteColorLookupTableData.InlineBinary,
+ e
+ );
return undefined;
}
}
if (paletteColorLookupTableData.retrieveBulkData) {
- return paletteColorLookupTableData.retrieveBulkData().then(val =>
- (paletteColorLookupTableData.palette = arrayBufferToPaletteColorLUT(val)));
+ return paletteColorLookupTableData
+ .retrieveBulkData()
+ .then(
+ val =>
+ (paletteColorLookupTableData.palette = arrayBufferToPaletteColorLUT(
+ val
+ ))
+ );
}
- throw new Error(`No data found for ${paletteColorLookupTableData} palette`)
+ console.error(`No data found for ${paletteColorLookupTableData} palette`);
}
diff --git a/platform/docs/docs/deployment/docker.md b/platform/docs/docs/deployment/docker.md
new file mode 100644
index 000000000..986a5a5e4
--- /dev/null
+++ b/platform/docs/docs/deployment/docker.md
@@ -0,0 +1,48 @@
+---
+sidebar_position: 4
+---
+
+# Docker
+
+The OHIF source code provides a Dockerfile to create and run a Docker image that containerizes an [nginx](https://www.nginx.com/) web server serving the OHIF Viewer.
+
+:::info Good to Know
+The OHIF Viewer Docker image for the `v3-stable` branch is not yet published. The available image in [Docker Hub](https://hub.docker.com/r/ohif/viewer) is based on the `master` branch.
+:::
+
+## Prequisites
+The machine on which to build and run the Docker container must have:
+1. All of the [requirements](./build-for-production.md#build-for-production) for building a production version of OHIF.
+2. A checked out branch of the OHIF Viewer.
+3. [Docker](https://docs.docker.com/get-docker/) installed.
+
+## Building the Docker Image
+The docker image can be built from a terminal window as such:
+1. Switch to the OHIF Viewer code root directory.
+2. Issue the following docker command. Note that what follows `-t` flag is the `{name}:{tag}` for the Docker image and is arbitrary when creating a local Docker image.
+
+ ```sh
+ docker build . -t ohif-viewer-image
+ ```
+
+## Running the Docker Container
+Once the Docker image has been built, it can be run as a container from the command line as in the block below. Note that the last argument to the command is the name of the Docker image and the table below describes the other arguments.
+
+|Flag|Description|
+|----|-----------|
+|-d|Run the container in the background and print the container ID|
+|-p {host-port}:{nginx-port}/tcp|Publish the `nginx` listen port on the given host port|
+|--name|An arbitrary name for the container.|
+
+
+```sh
+docker run -d -p 3000:80/tcp --name ohif-viewer-container ohif-viewer-image
+```
+
+### Configuring the `nginx` Listen Port
+
+The Dockerfile and entry point use the `${PORT}` environment variable as the port that the `nginx` server uses to serve the web server. The default value for `${PORT}` is `80`. One way to set this environment variable is to use the `-e` switch when running the container with `docker run`. The block below gives an example where the listen port is set to `8080` and publised on the host as `3000`.
+
+```sh
+docker run -d -e PORT=8080 -p 3000:8080/tcp --name ohif-viewer-container ohif-viewer-image
+```
diff --git a/platform/docs/docs/deployment/google-cloud-healthcare.md b/platform/docs/docs/deployment/google-cloud-healthcare.md
index 532bf4cc6..0484bbdfb 100644
--- a/platform/docs/docs/deployment/google-cloud-healthcare.md
+++ b/platform/docs/docs/deployment/google-cloud-healthcare.md
@@ -1,5 +1,5 @@
---
-sidebar_position: 6
+sidebar_position: 7
---
# Google Cloud Healthcare
diff --git a/platform/docs/docs/deployment/nginx--image-archive.md b/platform/docs/docs/deployment/nginx--image-archive.md
index 79f729d2f..29ce7a914 100644
--- a/platform/docs/docs/deployment/nginx--image-archive.md
+++ b/platform/docs/docs/deployment/nginx--image-archive.md
@@ -1,5 +1,5 @@
---
-sidebar_position: 4
+sidebar_position: 5
---
# Nginx + Image Archive
diff --git a/platform/docs/docs/deployment/user-account-control.md b/platform/docs/docs/deployment/user-account-control.md
index e411cc7d5..3f8a50a77 100644
--- a/platform/docs/docs/deployment/user-account-control.md
+++ b/platform/docs/docs/deployment/user-account-control.md
@@ -1,5 +1,5 @@
---
-sidebar_position: 5
+sidebar_position: 6
---
# User Account Control
diff --git a/platform/docs/docs/platform/extensions/index.md b/platform/docs/docs/platform/extensions/index.md
index 97a7e7647..d4637fde1 100644
--- a/platform/docs/docs/platform/extensions/index.md
+++ b/platform/docs/docs/platform/extensions/index.md
@@ -206,8 +206,10 @@ used to initialize data.
[`onModeExit`](./lifecycle#onModeExit): Similarly to onModeEnter, this hook is
called when navigating away from a mode, or before a mode’s data or datasource
-is changed. This can be used to clean up data (e.g. remove annotations that do
-not need to be persisted)
+is changed. This can be used to cache data for re-use later, but since it
+isn't known which mode will be entered next, the state after exiting should be
+clean, that is, the same as the state on a clean start. This is called BEFORE
+service clean up, and after mode specific onModeExit handling.
## Modules
diff --git a/platform/docs/docs/platform/managers/service.md b/platform/docs/docs/platform/managers/service.md
index 2cacda412..8307d1c68 100644
--- a/platform/docs/docs/platform/managers/service.md
+++ b/platform/docs/docs/platform/managers/service.md
@@ -51,6 +51,7 @@ By default, `OHIF-v3` registers the following services in the `appInit`.
```js title="platform/viewer/src/appInit.js"
servicesManager.registerServices([
+ CustomizationService,
UINotificationService,
UIModalService,
UIDialogService,
@@ -86,9 +87,16 @@ export default {
and the implementation of `ToolBarService` lies in the same folder at
`./ToolbarSerivce.js`.
-> Note, the create method is critical for any custom service that you write and
+> Note: The create method is critical for any custom service that you write and
> want to add to the list of services
+> Note: For typescript definitions, the service type should be exported
+> as part of the Types export on the module. This is recommended going forward
+> and existing services will be migrated. As well, the capitalization of the
+> name should be lower camel case, with the type being upper camel case. In
+> the above example, the service instance should be `toolBarService` with the
+> class being `ToolBarService`.
+
## Accessing Services
Throughout the app you can use `services` property of the service manager to
@@ -135,13 +143,15 @@ export default {
and the logic for your service shall be
```js title="extensions/customExtension/src/services/backEndService/index.js"
-import backEndService from './backEndService';
+// Canonical name of upper camel case BackEndService for the class
+import BackEndService from './BackEndService';
export default function WrappedBackEndService(serviceManager) {
return {
- name: 'myService',
+ // Note the canonical name of lower camel case backEndService
+ name: 'backEndService',
create: ({ configuration = {} }) => {
- return new backEndService(serviceManager);
+ return new BackEndService(serviceManager);
},
};
}
@@ -149,8 +159,8 @@ export default function WrappedBackEndService(serviceManager) {
with implementation of
-```js
-export default class backEndService {
+```ts
+export default class BackEndService {
constructor(serviceManager) {
this.serviceManager = serviceManager;
}
@@ -160,3 +170,35 @@ export default class backEndService {
}
}
```
+
+with a registration of
+
+```ts title="types/index.ts"
+import BackEndService from "../services/BackEndService/BackEndService";
+
+export { BackEndService };
+```
+
+# Service Mode Lifecycle
+Services may implement initialization and cleanup for mode specific data.
+In order to prevent defects where there are differences between initial
+and subsequent displays of a study, the contract of the service is that the
+state the service is in on mode entry shall be the same whether the mode was
+entered or was exited and entered again.
+
+To implement storage/recovery of state, the mode must store the data on
+exiting the mode, and restore the data in it's onModeEnter. For example,
+the mode may decide to preserve measurement data in the onModeExit, and
+to restore it in the onModeEnter. This does not violate the contract since
+it is the mode's decision to apply the stored state, and to cache it.
+
+## onModeEnter
+A service may implement an onModeEnter call to initialize the service to
+be ready for entering a mode.
+This is called before the mode `onModeEnter` is called.
+
+## onModeExit
+When entering a mode, the service contract states that the service needs to
+be in the same state whether it is a fresh load or has previously entered the mode.
+The onModeExit allows a service to clean itself up after the mode 'onModeExit'
+has stored any persistent data.
diff --git a/platform/docs/docs/platform/modes/lifecycle.md b/platform/docs/docs/platform/modes/lifecycle.md
index 4993c30f5..92edeed5d 100644
--- a/platform/docs/docs/platform/modes/lifecycle.md
+++ b/platform/docs/docs/platform/modes/lifecycle.md
@@ -16,7 +16,11 @@ Currently, there are two hooks that are called for modes:
This hook gets run after the defined route has been entered by the mode. This
hook can be used to initialize the data, services and appearance of the viewer
-upon the first render.
+upon the first render, in any way that is custom to the mode.
+
+This is called after service `onModeEnter` calls so that the entry into a mode
+is done in a predefined/fixed state. That allows any restoring of existing state
+to be performed.
For instance, in `longitudinal` mode we are using this hook to initialize the
`ToolBarService` and set the window level/width tool to be active and add
@@ -64,9 +68,13 @@ function modeFactory() {
## onModeExit
-This hook is called when the viewer navigate away from the route in the url.
-This is the place for cleaning up data, and services by unsubscribing to the
-events.
+This hook is called when the viewer navigates away from the route in the url.
+It is called BEFORE the service specific onModeExit calls are performed, and
+thus still has access to stateful data which can be cached or stored before
+the services clean themselves up.
+This is the place for cleaning up NON-service specific data, and services
+by unsubscribing to the events. The cleanup of the service itself is intended
+to occur in the service `onModeEnter`.
For instance, it can be used to reset the `ToolBarService` which reset the
toggled buttons.
diff --git a/platform/docs/docs/platform/services/data/index.md b/platform/docs/docs/platform/services/data/index.md
index fa9128ae4..ae1589276 100644
--- a/platform/docs/docs/platform/services/data/index.md
+++ b/platform/docs/docs/platform/services/data/index.md
@@ -19,6 +19,7 @@ We maintain the following non-ui Services:
- [Hanging Protocol Service](../data/HangingProtocolService.md)
- [Toolbar Service](../data/ToolBarService.md)
- [Measurement Service](../data/MeasurementService.md)
+- [Customization Service](customization-service.md)
## Service Architecture
diff --git a/platform/docs/docs/platform/services/index.md b/platform/docs/docs/platform/services/index.md
index 6fb2f8295..c2190e9a9 100644
--- a/platform/docs/docs/platform/services/index.md
+++ b/platform/docs/docs/platform/services/index.md
@@ -125,6 +125,17 @@ The following services is available in the `OHIF-v3`.
cine
+