Customization Service
There are a lot of places where users may want to configure certain elements differently between different modes or for different deployments. A mode example might be the use of a custom overlay showing mode related DICOM header information such as radiation dose or patient age.
The use of customizationService
enables these to be defined in a typed fashion by
providing an easy way to set default values for this, but to allow a
non-default value to be specified by the configuration or mode.
customizationService
itself doesn't implement the actual customization,
but rather just provide mechanism to register reusable prototypes, to configure
those prototypes with actual configurations, and to use the configured objects
(components, data, whatever).
Actual implementation of the customization is totally up to the component that supports customization.
General Overviewโ
This framework allows you to configure many features, or "slots," through customization modules. Extensions can choose to offer their own module, which outlines which values can be changed. By looking at each extension's getCustomizationModule(), you can see which objects or components are open to customization.
Below is a high-level example of how you might define a default customization and then consume and override it:
-
Defining a Customizable Default
In your extension, you might export a set of default configurations (for instance, a list that appears in a panel). Here, you provide an identifier and store the default list under that identifier. This makes the item discoverable by the customization service:
// Inside your extensionโs customization module
export default function getCustomizationModule() {
return [
{
name: 'default',
value: {
defaultList: ['Item A', 'Item B'],
},
},
];
}By naming it
default
, it is automatically registered.infoYou might want to have customizations ready to use in your application without actually applying them. In such cases, you can name them something other than
default
. For example, in your mode, you can do this:customizationService.setCustomizations([
'@ohif/extension-cornerstone-dicom-seg.customizationModule.dicom-seg-sorts',
]);This is really useful when you want to apply a set of customizations as a pack, kind of like a bundle.
-
Retrieving the Default Customization In the panel or component (or whatever) that needs the list, you retrieve it using
getCustomization
:const myList = customizationService.getCustomization('defaultList');
// If unmodified, this returns ['Item A', 'Item B']This allows your component to always fetch the most current version (original default or overridden).
-
Overriding from Outside To customize this list outside your extension, call
setCustomizations
with the identifier ('defaultList'
). For example, a mode can modify the list to add or change items:// From within a mode (or globally)
customizationService.setCustomizations({
'defaultList': {
$set: ['New Item 1', 'New Item 2'],
},
});The next time any panel calls
getCustomization('defaultList')
, it will get the updated list.Don't worry we will go over the
$set
syntax in more detail later.
Scope of Customizationโ
Customizations can be declared at three different scopes, each with its own priority and lifecycle. These scopes determine how and when customizations are applied.
1. Default Scopeโ
- Purpose: Establish baseline or "fallback" values that extensions provide.
- Options:
- Via Extensions:
- Implement a
getCustomizationModule
function in your extension and name itdefault
.
function getCustomizationModule() {
return [
{
name: 'default',
value: {
'studyBrowser.sortFunctions': {
$set: [
{
label: 'Default Sort Function',
sortFunction: (a, b) => a.SeriesDate - b.SeriesDate,
},
],
},
},
},
];
} - Implement a
- Using the
setCustomizations
Method:- Call
setCustomizations
in your application and specifyCustomizationScope.Default
as the second argument:
customizationService.setCustomizations(
{
'studyBrowser.sortFunctions': {
$set: [
{
label: 'Default Sort Function',
sortFunction: (a, b) => a.SeriesDate - b.SeriesDate,
},
],
},
},
CustomizationScope.Default
); - Call
- Via Extensions:
2. Mode Scopeโ
- Purpose: Apply customizations specific to a particular mode.
- Lifecycle: These customizations are cleared or reset when switching between modes.
- Example: Use the
setCustomizations
method to define mode-specific behavior.customizationService.setCustomizations({
'studyBrowser.sortFunctions': {
$set: [
{
label: 'Mode-Specific Sort Function',
sortFunction: (a, b) => b.SeriesDate - a.SeriesDate,
},
],
},
});
3. Global Scopeโ
- Purpose: Apply system-wide customizations that override both default and mode-scoped values.
- How to Configure:
-
Add global customizations directly to the application's configuration file:
window.config = {
name: 'config/default.js',
routerBasename: '/',
customizationService: [
{
'studyBrowser.sortFunctions': {
$push: [
{
label: 'Global Sort Function',
sortFunction: (a, b) => b.SeriesDate - a.SeriesDate,
},
],
},
},
],
}; -
Use Namespaced Extensions:
- Instead of directly specifying customizations in the configuration, you can refer to a predefined customization module within an extension:
window.config = {
name: 'config/default.js',
routerBasename: '/',
customizationService: [
'@ohif/extension-cornerstone.customizationModule.newCustomization',
],
};-
In this example, the
newCustomization
module within the@ohif/extension-cornerstone
extension contains the global customizations. The application will load and apply these settings globally.function getCustomizationModule() {
return [
{
name: 'newCustomization',
value: {
'studyBrowser.sortFunctions': {
$push: [
{
label: 'Global Namespace Sort Function',
sortFunction: (a, b) => b.SeriesDate - a.SeriesDate,
},
],
},
},
},
];
}
-
Priority of Scopesโ
When a customization is retrieved:
- Global Scope: Takes precedence if defined.
- Mode Scope: Used if no global customization is defined.
- Default Scope: Fallback when neither global nor mode-specific values are available.
As you have guessed the .setCustomizations
accept a second argument which is the scope. By default it is set to mode
.
Customization Syntaxโ
The customization syntax is designed to offer flexibility when modifying configurations. Instead of simply replacing values, you can perform granular updates like appending items to arrays, inserting at specific indices, updating deeply nested fields, or applying filters. This flexibility ensures that updates are efficient, targeted, and suitable for complex data structures.
Why a Special Syntax?
Traditional value replacement might not be ideal in scenarios such as:
- Appending or prepending to an existing list instead of overwriting it.
- Selective updates for specific fields in an object without affecting other fields.
- Filtering or merging nested items in arrays or objects while preserving other parts.
To address these needs, the customization service uses a special syntax inspired by immutability-helper commands. Below are examples of each operation.
1. Replace a Value ($set
)โ
Use $set
to entirely replace a value. This is the simplest operation which would replace the entire value.
// Before: someKey = 'Old Value'
customizationService.setCustomizations({
someKey: { $set: 'New Value' },
});
// After: someKey = 'New Value'
Example with study browser:
// Before: studyBrowser.sortFunctions = []
customizationService.setCustomizations({
'studyBrowser.sortFunctions': {
$set: [
{
label: 'Sort by Patient ID',
sortFunction: (a, b) => a.PatientID.localeCompare(b.PatientID),
},
],
},
});
// After: studyBrowser.sortFunctions = [{label: 'Sort by Patient ID', sortFunction: ...}]
2. Add to an Array ($push
and $unshift
)โ
$push
: Appends items to the end of an array.$unshift
: Adds items to the beginning of an array.
// Before: NumbersList = [1, 2, 3]
// Push items to the end
customizationService.setCustomizations({
'NumbersList': { $push: [5, 6] },
});
// After: NumbersList = [1, 2, 3, 5, 6]
// Unshift items to the front
customizationService.setCustomizations({
'NumbersList': { $unshift: [0] },
});
// After: NumbersList = [0, 1, 2, 3, 5, 6]
3. Insert at Specific Index ($splice
)โ
Use $splice
to insert, replace, or remove items at a specific index in an array.
// Before: NumbersList = [1, 2, 3]
customizationService.setCustomizations({
'NumbersList': {
$splice: [
[2, 0, 99], // Insert 99 at index 2
],
},
});
// After: NumbersList = [1, 2, 99, 3]
4. Merge Object Properties ($merge
)โ
Use $merge
to update specific fields in an object without affecting other fields.
// Before: SeriesInfo = { label: 'Original Label', sortFunction: oldFunc }
customizationService.setCustomizations({
'SeriesInfo': {
$merge: {
label: 'Updated Label',
extraField: true,
},
},
});
// After: SeriesInfo = { label: 'Updated Label', sortFunction: oldFunc, extraField: true }
Example with nested merge:
// Before: SeriesInfo = { advanced: { subKey: 'oldValue' } }
customizationService.setCustomizations({
'SeriesInfo': {
advanced: {
$merge: {
subKey: 'updatedSubValue',
},
},
},
});
// After: SeriesInfo = { advanced: { subKey: 'updatedSubValue' } }
5. Apply a Function ($apply
)โ
Use $apply
when you need to compute the new value dynamically.
// Before: SeriesInfo = { label: 'Old Label', data: 123 }
customizationService.setCustomizations({
'SeriesInfo': {
$apply: oldValue => ({
...oldValue,
label: 'Computed Label',
}),
},
});
// After: SeriesInfo = { label: 'Computed Label', data: 123 }
6. Filter and Modify ($filter
)โ
Use $filter
to find specific items in arrays (or objects) and apply changes.
// Before: advanced = {
// functions: [
// { id: 'seriesDate', label: 'Original Label' },
// { id: 'other', label: 'Other Label' }
// ]
// }
customizationService.setCustomizations({
'advanced': {
$filter: {
match: { id: 'seriesDate' },
$merge: {
label: 'Updated via Filter',
},
},
},
});
// After: advanced = {
// functions: [
// { id: 'seriesDate', label: 'Updated via Filter' },
// { id: 'other', label: 'Other Label' }
// ]
// }
Note $filter
will look recursively for
an object that matches the match
criteria and then apply the $merge
or $set
operation to it.
Note in the example above we are not doing anything with the functions
array.
Example with deeply nested filter:
// Before: advanced = {
// functions: [{
// id: 'seriesDate',
// viewFunctions: [
// { id: 'axial', label: 'Original Axial' }
// ]
// }]
// }
customizationService.setCustomizations({
'advanced': {
$filter: {
match: { id: 'axial' },
$merge: {
label: 'Axial (via Filter)',
},
},
},
});
// After: advanced = {
// functions: [{
// id: 'seriesDate',
// viewFunctions: [
// { id: 'axial', label: 'Axial (via Filter)' }
// ]
// }]
// }
Summary of Commandsโ
Command | Purpose | Example |
---|---|---|
$set | Replace a value entirely | Replace a list or object |
$push | Append items to an array | Add to the end of a list |
$unshift | Prepend items to an array | Add to the start of a list |
$splice | Insert, remove, or replace at specific index | Modify specific indices in a list |
$merge | Update specific fields in an object | Change a subset of fields |
$apply | Compute the new value dynamically | Apply a function to transform values |
$filter | Find and update specific items in arrays | Target nested structures |
$transform | Apply a function to transform the customization | Apply a function to transform values |
Building Customizations Across Multiple Extensionsโ
Sometimes it is useful to build customizations across multiple extensions. For example, you may want to build a default list of tools inside a vieweport. But then each extension may want to add their own tools to the list.
Lets say i have one default sorting function in my default extension.
function getCustomizationModule() {
return [
{
name: 'default',
value: {
'studyBrowser.sortFunctions': [
{
label: 'Series Number',
sortFunction: (a, b) => {
return a?.SeriesNumber - b?.SeriesNumber;
},
},
],
},
},
];
}
This will result in having only series number as the default sorting function.
but now in another extension let's say dicom-seg extension we can add another sorting function.
function getCustomizationModule() {
return [
{
name: "dicom-seg-sorts",
value: {
"studyBrowser.sortFunctions": {
$push: [
{
label: "Series Date",
sortFunction: (a, b) => {
return a?.SeriesDate - b?.SeriesDate;
},
},
],
},
},
},
];
}
But since the module is not default
it will not get applied, but in my segmentation mode i can do
onModeEnter() {
customizationService.setCustomizations([
'@ohif/extension-cornerstone-dicom-seg.customizationModule.dicom-seg-sorts',
]);
}
needless to say if you opted to choose name: default
in the getCustomizationModule
it was applied globally.
Customizable Parts of OHIFโ
Below we are providing the example configuration for global scenario (using the configuration file), however, you can also use the setCustomizations
method to set the customizations.
measurementLabels
ID | measurementLabels |
---|---|
Description | Labels for measurement tools in the viewer that are automatically asked for. |
Default Value | [] |
Example |
|
cornerstoneViewportClickCommands
ID | cornerstoneViewportClickCommands |
---|---|
Description | Defines the viewport event handlers such as button1, button2, doubleClick, etc. |
Default Value | { "doubleClick": { "commandName": "toggleOneUp", "commandOptions": {} }, "button1": { "commands": [ { "commandName": "closeContextMenu" } ] }, "button3": { "commands": [ { "commandName": "showCornerstoneContextMenu", "commandOptions": { "requireNearbyToolData": true, "menuId": "measurementsContextMenu" } } ] } } |
Example |
|
cinePlayer
ID | cinePlayer |
---|---|
Description | Customizes the cine player component. |
Default Value | The CinePlayer component in the UI |
Example |
cornerstone.windowLevelActionMenu
ID | cornerstone.windowLevelActionMenu |
---|---|
Description | Window level action menu for the cornerstone viewport. |
Default Value | null |
Example |
|
cornerstone.windowLevelPresets
ID | cornerstone.windowLevelPresets |
---|---|
Description | Window level presets for the cornerstone viewport. |
Default Value | { "CT": [ { "description": "Soft tissue", "window": "400", "level": "40" }, { "description": "Lung", "window": "1500", "level": "-600" }, { "description": "Liver", "window": "150", "level": "90" }, { "description": "Bone", "window": "2500", "level": "480" }, { "description": "Brain", "window": "80", "level": "40" } ], "PT": [ { "description": "Default", "window": "5", "level": "2.5" }, { "description": "SUV", "window": "0", "level": "3" }, { "description": "SUV", "window": "0", "level": "5" }, { "description": "SUV", "window": "0", "level": "7" }, { "description": "SUV", "window": "0", "level": "8" }, { "description": "SUV", "window": "0", "level": "10" }, { "description": "SUV", "window": "0", "level": "15" } ] } |
Example |
|
cornerstone.colorbar
ID | cornerstone.colorbar |
---|---|
Description | Customizes the appearance and behavior of the cornerstone colorbar. |
Default Value | { width: '16px', colorbarTickPosition: 'left', colormaps, colorbarContainerPosition: 'right', colorbarInitialColormap: DefaultColormap, } |
Example |
|
cornerstone.3dVolumeRendering
ID | cornerstone.3dVolumeRendering |
---|---|
Description | Customizes the settings for 3D volume rendering in the cornerstone viewport, including presets and rendering quality range. |
Default Value | { volumeRenderingPresets: VIEWPORT_PRESETS, volumeRenderingQualityRange: { min: 1, max: 4, step: 1, }, } |
Example |
|
autoCineModalities
ID | autoCineModalities |
---|---|
Description | Specifies the modalities for which the cine player automatically starts. |
Default Value | [ "OT", "US" ] |
Example |
|
cornerstone.overlayViewportTools
ID | cornerstone.overlayViewportTools |
---|---|
Description | Configures the tools available in the cornerstone SEG and RT tool groups. |
Default Value | { active: [ { toolName: toolNames.WindowLevel, bindings: [{ mouseButton: Enums.MouseBindings.Primary }], }, { toolName: toolNames.Pan, bindings: [{ mouseButton: Enums.MouseBindings.Auxiliary }], }, { toolName: toolNames.Zoom, bindings: [{ mouseButton: Enums.MouseBindings.Secondary }], }, { toolName: toolNames.StackScroll, bindings: [{ mouseButton: Enums.MouseBindings.Wheel }], }, ], enabled: [ { toolName: toolNames.PlanarFreehandContourSegmentation, configuration: { displayOnePointAsCrosshairs: true, }, }, ], } |
Example |
|
layoutSelector.commonPresets
ID | layoutSelector.commonPresets |
---|---|
Description | Defines the default layout presets available in the application. |
Default Value | [ { "icon": "layout-common-1x1", "commandOptions": { "numRows": 1, "numCols": 1 } }, { "icon": "layout-common-1x2", "commandOptions": { "numRows": 1, "numCols": 2 } }, { "icon": "layout-common-2x2", "commandOptions": { "numRows": 2, "numCols": 2 } }, { "icon": "layout-common-2x3", "commandOptions": { "numRows": 2, "numCols": 3 } } ] |
Example |
|
layoutSelector.advancedPresetGenerator
ID | layoutSelector.advancedPresetGenerator |
---|---|
Description | Generates advanced layout presets based on hanging protocols. |
Default Value | ({ servicesManager }) => { // by default any hanging protocol that has isPreset set to true will be included // a function that returns an array of presets // of form { // icon: 'layout-common-1x1', // title: 'Custom Protocol', // commandOptions: { // protocolId: 'customProtocolId', // }, // disabled: false, // } } |
Example |
|
dicomUploadComponent
ID | dicomUploadComponent |
---|---|
Description | Customizes the appearance and behavior of the dicom upload component. |
Default Value | The DicomUpload component in the UI |
Example |
onBeforeSRAddMeasurement
ID | onBeforeSRAddMeasurement |
---|---|
Description | Customizes the behavior of the SR measurement before it is added to the viewer. |
Default Value | |
Example |
|
onBeforeDicomStore
ID | onBeforeDicomStore |
---|---|
Description | A hook that modifies the DICOM dictionary before it is stored. The customization should return the modified DICOM dictionary. |
Default Value | |
Example |
|
sortingCriteria
ID | sortingCriteria |
---|---|
Description | Defines the series sorting criteria for hanging protocols. Note that this does not affect the order in which series are displayed in the study browser. |
Default Value | function seriesInfoSortingCriteria(firstSeries, secondSeries) { const aLowPriority = isLowPriorityModality(firstSeries.Modality ?? firstSeries.modality); const bLowPriority = isLowPriorityModality(secondSeries.Modality ?? secondSeries.modality); if (aLowPriority) { // Use the reverse sort order for low priority modalities so that the // most recent one comes up first as usually that is the one of interest. return bLowPriority ? defaultSeriesSort(secondSeries, firstSeries) : 1; } else if (bLowPriority) { return -1; } return defaultSeriesSort(firstSeries, secondSeries); } |
Example |
|
customOnDropHandler
ID | customOnDropHandler |
---|---|
Description | CustomOnDropHandler in the viewport grid enables users to handle additional functionalities during the onDrop event in the viewport. |
Default Value | |
Example |
|