feat(customizableOverlay): Add customizable overlay info (#3061)

* overlay customization

* custom overlay definition and examples

* minor fix - import statement difference between cornerstone and cornerstone3D

* move "VOI" and "Zoom" calulation to the ViewportOverlay component, make OverlayItem fairly dull

* type specifications

* follow up fixes for CustomizationService changes on upstream

* [fix] various fixes related to custom overlays (formatting, toggling)

* [fix] various fixes related to custom overlays (formatting, toggling)

* right side overlay panels - align right

* [fix] remove "notification" from toggleOverlay command

* comments

* [refactor] remove unused imports

* documentation of customizable overlay configuration

* prettify

* review of documentation of customization service

* fix useCallback() dependencies

* fix useCallback dependencies

* documentation for customization service

* documentation for customization service
This commit is contained in:
md-prog 2023-03-09 17:12:08 -05:00 committed by GitHub
parent b9dbc5b3a5
commit e4e62e9e14
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 992 additions and 120 deletions

View File

@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import ViewportImageScrollbar from './ViewportImageScrollbar';
import ViewportOverlay from './ViewportOverlay';
import CustomizableViewportOverlay from './CustomizableViewportOverlay';
import ViewportOrientationMarkers from './ViewportOrientationMarkers';
import ViewportImageSliceLoadingIndicator from './ViewportImageSliceLoadingIndicator';
@ -36,9 +36,8 @@ function CornerstoneOverlays(props) {
}
if (viewportData) {
const viewportInfo = cornerstoneViewportService.getViewportInfoByIndex(
viewportIndex
);
const viewportInfo =
cornerstoneViewportService.getViewportInfoByIndex(viewportIndex);
if (viewportInfo?.viewportOptions?.customViewportProps?.hideOverlays) {
return null;
@ -56,17 +55,20 @@ function CornerstoneOverlays(props) {
scrollbarHeight={scrollbarHeight}
servicesManager={servicesManager}
/>
<ViewportOverlay
<CustomizableViewportOverlay
imageSliceData={imageSliceData}
viewportData={viewportData}
viewportIndex={viewportIndex}
servicesManager={servicesManager}
element={element}
/>
<ViewportImageSliceLoadingIndicator
viewportData={viewportData}
element={element}
/>
<ViewportOrientationMarkers
imageSliceData={imageSliceData}
element={element}

View File

@ -0,0 +1,25 @@
/*
custom overlay panels: top-left, top-right, bottom-left and bottom-right
If any text to be displayed on the overlay is too long to hold on a single
line, it will be truncated with ellipsis in the end.
*/
.viewport-overlay {
max-width: 40%;
}
.viewport-overlay span {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.viewport-overlay.left-viewport {
text-align: left;
}
.viewport-overlay.right-viewport-scrollbar {
text-align: right;
}
.viewport-overlay.right-viewport-scrollbar .flex.flex-row {
justify-content: flex-end;
}

View File

@ -0,0 +1,489 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { vec3 } from 'gl-matrix';
import PropTypes from 'prop-types';
import { metaData, Enums, utilities } from '@cornerstonejs/core';
import { ViewportOverlay } from '@ohif/ui';
import {
formatPN,
formatDICOMDate,
formatDICOMTime,
formatNumberPrecision,
} from './utils';
import { InstanceMetadata } from 'platform/core/src/types';
import { ServicesManager } from '@ohif/core';
import { ImageSliceData } from '@cornerstonejs/core/dist/esm/types';
import './CustomizableViewportOverlay.css';
const EPSILON = 1e-4;
interface OverlayItemProps {
element: any;
viewportData: any;
imageSliceData: ImageSliceData;
viewportIndex: number | null;
servicesManager: ServicesManager;
instance: InstanceMetadata;
customization: any;
formatters: {
formatPN: (val) => string;
formatDate: (val) => string;
formatTime: (val) => string;
formatNumberPrecision: (val, number) => string;
};
// calculated values
voi: {
windowWidth: number;
windowCenter: number;
};
instanceNumber?: number;
scale?: number;
}
/**
* Window Level / Center Overlay item
*/
function VOIOverlayItem({ voi, customization }: OverlayItemProps) {
const { windowWidth, windowCenter } = voi;
if (typeof windowCenter !== 'number' || typeof windowWidth !== 'number') {
return null;
}
return (
<div
className="overlay-item flex flex-row"
style={{ color: (customization && customization.color) || undefined }}
>
<span className="mr-1 shrink-0">W:</span>
<span className="ml-1 mr-2 font-light shrink-0">
{windowWidth.toFixed(0)}
</span>
<span className="mr-1 shrink-0">L:</span>
<span className="ml-1 font-light shrink-0">
{windowCenter.toFixed(0)}
</span>
</div>
);
}
/**
* Zoom Level Overlay item
*/
function ZoomOverlayItem({ scale, customization }: OverlayItemProps) {
return (
<div
className="overlay-item flex flex-row"
style={{ color: (customization && customization.color) || undefined }}
>
<span className="mr-1 shrink-0">Zoom:</span>
<span className="font-light">{scale.toFixed(2)}x</span>
</div>
);
}
/**
* Instance Number Overlay Item
*/
function InstanceNumberOverlayItem({
instanceNumber,
imageSliceData,
customization,
}: OverlayItemProps) {
const { imageIndex, numberOfSlices } = imageSliceData;
return (
<div
className="overlay-item flex flex-row"
style={{ color: (customization && customization.color) || undefined }}
>
<span className="mr-1 shrink-0">I:</span>
<span className="font-light">
{instanceNumber !== undefined && instanceNumber !== null
? `${instanceNumber} (${imageIndex + 1}/${numberOfSlices})`
: `${imageIndex + 1}/${numberOfSlices}`}
</span>
</div>
);
}
/**
* Customizable Viewport Overlay
*/
function CustomizableViewportOverlay({
element,
viewportData,
imageSliceData,
viewportIndex,
servicesManager,
}) {
const { toolbarService, cornerstoneViewportService, customizationService } =
servicesManager.services;
const [voi, setVOI] = useState({ windowCenter: null, windowWidth: null });
const [scale, setScale] = useState(1);
const [activeTools, setActiveTools] = useState([]);
const { imageIndex } = imageSliceData;
const topLeftCustomization = customizationService.getModeCustomization(
'cornerstoneOverlayTopLeft'
);
const topRightCustomization = customizationService.getModeCustomization(
'cornerstoneOverlayTopRight'
);
const bottomLeftCustomization = customizationService.getModeCustomization(
'cornerstoneOverlayBottomLeft'
);
const bottomRightCustomization = customizationService.getModeCustomization(
'cornerstoneOverlayBottomRight'
);
const instance = useMemo(() => {
if (viewportData != null) {
return _getViewportInstance(viewportData, imageIndex);
} else {
return null;
}
}, [viewportData, imageIndex]);
const instanceNumber = useMemo(() => {
if (viewportData != null) {
return _getInstanceNumber(
viewportData,
viewportIndex,
imageIndex,
cornerstoneViewportService
);
}
return null;
}, [viewportData, viewportIndex, imageIndex, cornerstoneViewportService]);
/**
* Initial toolbar state
*/
useEffect(() => {
setActiveTools(toolbarService.getActiveTools());
}, []);
/**
* Updating the VOI when the viewport changes its voi
*/
useEffect(() => {
const updateVOI = eventDetail => {
const { range } = eventDetail.detail;
if (!range) {
return;
}
const { lower, upper } = range;
const { windowWidth, windowCenter } = utilities.windowLevel.toWindowLevel(
lower,
upper
);
setVOI({ windowCenter, windowWidth });
};
element.addEventListener(Enums.Events.VOI_MODIFIED, updateVOI);
return () => {
element.removeEventListener(Enums.Events.VOI_MODIFIED, updateVOI);
};
}, [viewportIndex, viewportData, voi, element]);
/**
* Updating the scale when the viewport changes its zoom
*/
useEffect(() => {
const updateScale = eventDetail => {
const { previousCamera, camera } = eventDetail.detail;
if (
previousCamera.parallelScale !== camera.parallelScale ||
previousCamera.scale !== camera.scale
) {
const viewport =
cornerstoneViewportService.getCornerstoneViewportByIndex(
viewportIndex
);
if (!viewport) {
return;
}
const imageData = viewport.getImageData();
if (!imageData) {
return;
}
if (camera.scale) {
setScale(camera.scale);
return;
}
const { spacing } = imageData;
// convert parallel scale to scale
const scale =
(element.clientHeight * spacing[0] * 0.5) / camera.parallelScale;
setScale(scale);
}
};
element.addEventListener(Enums.Events.CAMERA_MODIFIED, updateScale);
return () => {
element.removeEventListener(Enums.Events.CAMERA_MODIFIED, updateScale);
};
}, [viewportIndex, viewportData, cornerstoneViewportService, element]);
/**
* Updating the active tools when the toolbar changes
*/
// Todo: this should act on the toolGroups instead of the toolbar state
useEffect(() => {
const { unsubscribe } = toolbarService.subscribe(
toolbarService.EVENTS.TOOL_BAR_STATE_MODIFIED,
() => {
setActiveTools(toolbarService.getActiveTools());
}
);
return () => {
unsubscribe();
};
}, [toolbarService]);
const _renderOverlayItem = useCallback(
item => {
const overlayItemProps: OverlayItemProps = {
element,
viewportData,
imageSliceData,
viewportIndex,
servicesManager,
customization: item,
formatters: {
formatPN: formatPN,
formatDate: formatDICOMDate,
formatTime: formatDICOMTime,
formatNumberPrecision: formatNumberPrecision,
},
instance,
// calculated
voi,
scale,
instanceNumber,
};
if (item.customizationType === 'ohif.overlayItem.windowLevel') {
return <VOIOverlayItem {...overlayItemProps} />;
} else if (item.customizationType === 'ohif.overlayItem.zoomLevel') {
return <ZoomOverlayItem {...overlayItemProps} />;
} else if (item.customizationType === 'ohif.overlayItem.instanceNumber') {
return <InstanceNumberOverlayItem {...overlayItemProps} />;
} else {
const renderItem = customizationService.applyType(item);
if (typeof renderItem.content === 'function') {
return renderItem.content(overlayItemProps);
}
}
},
[
element,
viewportData,
imageSliceData,
viewportIndex,
servicesManager,
customizationService,
instance,
voi,
scale,
instanceNumber,
]
);
const getTopLeftContent = useCallback(() => {
const items = topLeftCustomization?.items || [
{
id: 'WindowLevel',
customizationType: 'ohif.overlayItem.windowLevel',
},
];
return (
<>
{items.map((item, i) => (
<div key={`topLeftOverlayItem_${i}`}>{_renderOverlayItem(item)}</div>
))}
</>
);
}, [topLeftCustomization, _renderOverlayItem]);
const getTopRightContent = useCallback(() => {
const items = topRightCustomization?.items || [
{
id: 'InstanceNmber',
customizationType: 'ohif.overlayItem.instanceNumber',
},
];
return (
<>
{items.map((item, i) => (
<div key={`topRightOverlayItem_${i}`}>{_renderOverlayItem(item)}</div>
))}
</>
);
}, [topRightCustomization, _renderOverlayItem]);
const getBottomLeftContent = useCallback(() => {
const items = bottomLeftCustomization?.items || [];
return (
<>
{items.map((item, i) => (
<div key={`bottomLeftOverlayItem_${i}`}>
{_renderOverlayItem(item)}
</div>
))}
</>
);
}, [bottomLeftCustomization, _renderOverlayItem]);
const getBottomRightContent = useCallback(() => {
const items = bottomRightCustomization?.items || [];
return (
<>
{items.map((item, i) => (
<div key={`bottomRightOverlayItem_${i}`}>
{_renderOverlayItem(item)}
</div>
))}
</>
);
}, [bottomRightCustomization, _renderOverlayItem]);
return (
<ViewportOverlay
topLeft={getTopLeftContent()}
topRight={getTopRightContent()}
bottomLeft={getBottomLeftContent()}
bottomRight={getBottomRightContent()}
/>
);
}
function _getViewportInstance(viewportData, imageIndex) {
let imageId = null;
if (viewportData.viewportType === Enums.ViewportType.STACK) {
imageId = viewportData.data.imageIds[imageIndex];
} else if (viewportData.viewportType === Enums.ViewportType.ORTHOGRAPHIC) {
const volumes = viewportData.volumes;
if (volumes && volumes.length == 1) {
const volume = volumes[0];
imageId = volume.imageIds[imageIndex];
}
}
return imageId ? metaData.get('instance', imageId) || {} : {};
}
function _getInstanceNumber(
viewportData,
viewportIndex,
imageIndex,
cornerstoneViewportService
) {
let instanceNumber;
if (viewportData.viewportType === Enums.ViewportType.STACK) {
instanceNumber = _getInstanceNumberFromStack(viewportData, imageIndex);
if (!instanceNumber && instanceNumber !== 0) {
return null;
}
} else if (viewportData.viewportType === Enums.ViewportType.ORTHOGRAPHIC) {
instanceNumber = _getInstanceNumberFromVolume(
viewportData,
imageIndex,
viewportIndex,
cornerstoneViewportService
);
}
return instanceNumber;
}
function _getInstanceNumberFromStack(viewportData, imageIndex) {
const imageIds = viewportData.data.imageIds;
const imageId = imageIds[imageIndex];
if (!imageId) {
return;
}
const generalImageModule = metaData.get('generalImageModule', imageId) || {};
const { instanceNumber } = generalImageModule;
const stackSize = imageIds.length;
if (stackSize <= 1) {
return;
}
return parseInt(instanceNumber);
}
// Since volume viewports can be in any view direction, they can render
// a reconstructed image which don't have imageIds; therefore, no instance and instanceNumber
// Here we check if viewport is in the acquisition direction and if so, we get the instanceNumber
function _getInstanceNumberFromVolume(
viewportData,
imageIndex,
viewportIndex,
cornerstoneViewportService
) {
const volumes = viewportData.volumes;
// Todo: support fusion of acquisition plane which has instanceNumber
if (!volumes || volumes.length > 1) {
return;
}
const volume = volumes[0];
const { direction, imageIds } = volume;
const cornerstoneViewport =
cornerstoneViewportService.getCornerstoneViewportByIndex(viewportIndex);
if (!cornerstoneViewport) {
return;
}
const camera = cornerstoneViewport.getCamera();
const { viewPlaneNormal } = camera;
// checking if camera is looking at the acquisition plane (defined by the direction on the volume)
const scanAxisNormal = direction.slice(6, 9);
// check if viewPlaneNormal is parallel to scanAxisNormal
const cross = vec3.cross(vec3.create(), viewPlaneNormal, scanAxisNormal);
const isAcquisitionPlane = vec3.length(cross) < EPSILON;
if (isAcquisitionPlane) {
const imageId = imageIds[imageIndex];
if (!imageId) {
return {};
}
const { instanceNumber } =
metaData.get('generalImageModule', imageId) || {};
return parseInt(instanceNumber);
}
}
CustomizableViewportOverlay.propTypes = {
viewportData: PropTypes.object,
imageIndex: PropTypes.number,
viewportIndex: PropTypes.number,
};
export default CustomizableViewportOverlay;

View File

@ -0,0 +1,98 @@
import moment from 'moment';
import { metaData } from '@cornerstonejs/core';
/**
* Checks if value is valid.
*
* @param {number} value
* @returns {boolean} is valid.
*/
export function isValidNumber(value) {
return typeof value === 'number' && !isNaN(value);
}
/**
* Formats number precision.
*
* @param {number} number
* @param {number} precision
* @returns {number} formatted number.
*/
export function formatNumberPrecision(number, precision = 0) {
if (number !== null) {
return parseFloat(number).toFixed(precision);
}
}
/**
* Formats DICOM date.
*
* @param {string} date
* @param {string} strFormat
* @returns {string} formatted date.
*/
export function formatDICOMDate(date, strFormat = 'MMM D, YYYY') {
return moment(date, 'YYYYMMDD').format(strFormat);
}
/**
* DICOM Time is stored as HHmmss.SSS, where:
* HH 24 hour time:
* m mm 0..59 Minutes
* s ss 0..59 Seconds
* S SS SSS 0..999 Fractional seconds
*
* Goal: '24:12:12'
*
* @param {*} time
* @param {string} strFormat
* @returns {string} formatted name.
*/
export function formatDICOMTime(time, strFormat = 'HH:mm:ss') {
return moment(time, 'HH:mm:ss').format(strFormat);
}
/**
* Formats a patient name for display purposes
*
* @param {string} name
* @returns {string} formatted name.
*/
export function formatPN(name) {
if (!name) {
return '';
}
const cleaned = name
.split('^')
.filter(s => !!s)
.join(', ')
.trim();
return cleaned === ',' || cleaned === '' ? '' : cleaned;
}
/**
* Gets compression type
*
* @param {number} imageId
* @returns {string} comrpession type.
*/
export function getCompression(imageId) {
const generalImageModule = metaData.get('generalImageModule', imageId) || {};
const {
lossyImageCompression,
lossyImageCompressionRatio,
lossyImageCompressionMethod,
} = generalImageModule;
if (lossyImageCompression === '01' && lossyImageCompressionRatio !== '') {
const compressionMethod = lossyImageCompressionMethod || 'Lossy: ';
const compressionRatio = formatNumberPrecision(
lossyImageCompressionRatio,
2
);
return compressionMethod + compressionRatio + ' : 1';
}
return 'Lossless / Uncompressed';
}

View File

@ -47,6 +47,18 @@ const commandsModule = ({ servicesManager, commandsManager }) => {
title: 'DICOM Tag Browser',
});
},
/**
* Toggle viewport overlay (the information panel shown on the four corners
* of the viewport)
* @see ViewportOverlay and CustomizableViewportOverlay components
*/
toggleOverlays: () => {
const overlays = document.getElementsByClassName('viewport-overlay');
for (let i = 0; i < overlays.length; i++) {
overlays.item(i).classList.toggle('hidden');
}
},
};
const definitions = {

View File

@ -40,5 +40,76 @@ export default function getCustomizationModule() {
],
},
},
{
name: 'default',
value: [
/**
* Customization Component Type definition for overlay items.
* Overlay items are texts (or other components) that will be displayed
* on a Viewport Overlay, which contains the information panels on the
* four corners of a viewport.
*
* @definition of a overlay item using this type
* The value to be displayed is defined by
* - setting DICOM image instance's property to this field,
* - or defining contentF()
*
* {
* id: string - unique id for the overlay item
* customizationType: string - indicates customization type definition to this
* label: string - Label, to be displayed for the item
* title: string - Tooltip, for the item
* color: string - Color of the text
* condition: ({ instance }) => boolean - decides whether to display the overlay item or not
* attribute: string - property name of the DICOM image instance
* contentF: ({ instance, formatters }) => string | component,
* }
*
* @example
* {
* id: 'PatientNameOverlay',
* customizationType: 'ohif.overlayItem',
* label: 'PN:',
* title: 'Patient Name',
* color: 'yellow',
* condition: ({ instance }) => instance && instance.PatientName && instance.PatientName.Alphabetic,
* attribute: 'PatientName',
* contentF: ({ instance, formatters: { formatPN } }) => `${formatPN(instance.PatientName.Alphabetic)} ${(instance.PatientSex ? '(' + instance.PatientSex + ')' : '')}`,
* },
*
* @see CustomizableViewportOverlay
*/
{
id: 'ohif.overlayItem',
uiType: 'uiType',
content: function (props) {
if (this.condition && !this.condition(props)) return null;
const { instance } = props;
const value =
instance && this.attribute
? instance[this.attribute]
: this.contentF && typeof this.contentF === 'function'
? this.contentF(props)
: null;
if (!value) return null;
return (
<span
className="overlay-item flex flex-row"
style={{ color: this.color || undefined }}
title={this.title || ''}
>
{this.label && (
<span className="mr-1 shrink-0">{this.label}</span>
)}
<span className="font-light">{value}</span>
</span>
);
},
},
],
},
];
}

View File

@ -161,6 +161,13 @@ export default class CustomizationService extends PubSubService {
return this.applyType(customization);
}
public hasModeCustomization(customizationId: string) {
return (
this.globalCustomizations[customizationId] ||
this.modeCustomizations[customizationId]
);
}
/** Applies any inheritance due to UI Type customization */
public applyType(customization: Customization): Customization {
if (!customization) return customization;

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@ -17,135 +17,149 @@ This service is a UI service in that part of the registration allows for registe
UI components and types to deal with, but it does not directly provide an UI
displayable elements unless customized to do so.
## Registering Customizations
<b>Note:</b> Customization Service 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. (for example, `CustomizableViewportOverlay` component uses
`CustomizationService` to implement viewport overlay that is easily customizable
from configuration.)
## Registering customizable modules (or defining customization prototypes)
Extensions and Modes can register customization templates they support.
It is done by adding `getCustomizationModule()` in the extension or mode definition.
Below is the protocol of the `getCustomizationModule()`, if defined in Typescript.
```typescript
getCustomizationModule() : { name: string, value: any }[]
```
If the name is 'default', it is the Default customization, which is loaded
automatically when the extension or mode is loaded.
In the `value` of each customizations, you will define customization prototype(s).
These customization prototype(s) can be considered like "Prototype" in Javascript.
These can be used to extend the customization definitions from configurations.
Default cutomizations will be often used to define all the customization prototypes,
as they will be loaded automatically along with the defining extension or mode.
For example, the `@ohif/extension-default` extension defines,
```js
getCustomizationModule: () => [
//...
{
name: 'default',
value: [
{
id: 'ohif.overlayItem',
uiType: 'uiType',
content: function (props) {
if (this.condition && !this.condition(props)) return null;
const { instance } = props;
const value =
instance && this.attribute
? instance[this.attribute]
: this.contentF && typeof this.contentF === 'function'
? this.contentF(props)
: null;
if (!value) return null;
return (
<span
className="overlay-item flex flex-row"
style={{ color: this.color || undefined }}
title={this.title || ''}
>
{this.label && (
<span className="mr-1 shrink-0">{this.label}</span>
)}
<span className="font-light">{value}</span>
</span>
);
},
},
],
},
//...
],
```
And this `ohif.overlayItem` object will be used as a prototype to define items
to be displayed on `CustomizableViewportOverlay`. See the next section.
## Configuring customizations
There are several ways to register customizations. The
`APP_CONFIG.customizationService`
field is used as a per-configuration entry. This object can list single
configurations by id, or it can list sets of customizations by referring to
the `customizationModule` in an extension. For example, the fictitious
customization 'customIcons' might be defined as below in the APP_CONFIG:
the `customizationModule` in an extension.
NOTE that these definitions from APP_CONFIG will be loaded by default, just like
extension/modes default customization.
Below is the example configuration for `CustomizableViewportOverlay` component
customization, using the customization prototype `ohif.overlayItem` defined in
`ohif/extension-defaul` extension.:
```js
window.config = {
...,
customizationService: [
{
id: 'customIcons',
backArrow: 'https://customIcons.org/backArrow.svg',
//...
// in the APP_CONFIG file set the top right area to show the patient name
// using PN: as a prefix when the study has a non-empty patient name.
customizationService: {
cornerstoneOverlayTopRight: {
id: 'cornerstoneOverlayTopRight',
customizationType: 'ohif.cornerstoneOverlay',
items: [
{
id: 'PatientNameOverlay',
// Note the overlayItem as a parent type - this provides the
// rendering functionality to read the attribute and use the label.
customizationType: 'ohif.overlayItem',
attribute: 'PatientName',
label: 'PN:',
title: 'Patient Name',
color: 'yellow',
condition: ({ instance }) =>
instance &&
instance.PatientName &&
instance.PatientName.Alphabetic,
contentF: ({ instance, formatters: { formatPN } }) =>
formatPN(instance.PatientName.Alphabetic) +
' ' +
(instance.PatientSex ? '(' + instance.PatientSex + ')' : ''),
},
],
},
],
...
},
//...
}
```
As well, extensions can register default customizations by providing a 'default'
name key within the extension. These are simply customizations loaded when
the extension is loaded. For example, the previous customization could have
been added in an extension as:
In the customization configuration, you can use `customizationType` fields to
define the prototype that customization object should inherit from.
The `customizationType` field is simply the id of another customization object.
```js
getCustomizationModule: () => [
{
name: 'default',
value: {
id: 'customIcons',
backArrow: 'https://customIcons.org/backArrow.svg',
},
},
],
```
Note the name of this is default (thus loaded automatically instead of by
reference), and the value is a customization of customIcons.
## Implementing customization using CustomizationService
The type and parameters of a customization are defined by the user of the
customization, based on the customization id. For example, `cornerstoneOverlay`
is a customization that is a React component, so it requires a react content,
and optionally contentProps which are used to supply values to the content.
The extension can also supply a default parent instance to inherit values from.
This allows the content or other parameters to be pre-filled, and only the
required values changed. The parent to use is specified by the `customizationType` field,
and is simply the id of another customization object. An example of this might
be a demographics overlay field, where the base version needs an actual component,
while the typed version just needs the attribute and label to use.
```js
getCustomizationModule: () => [
{
name: 'default',
value: [
// This first value defines the base type
{
id: imageDemographicOverlay,
content: function({image}) {
return (<p>{image[this.attribute]}</p>);
}
},
// The second one defines an instance.
// It may or may not use the previous type definition - it will use it
// if nothing replaces the previous definition, otherwise it will use the new one.
{
id: PatientIDOverlayItem,
customizationType: 'imageDemographicOverlay',
attribute: 'PatientID',
},
]
}
]
```
### Mode Customizations
Mode-specific customizations are no different from the global ones,
except that the mode customizations are cleared before the mode `onModeEnter`
is called, and they can have new values registered in the `onModeEnter`
The following example shows first the registration of the default instances,
and then shows how they might be used.
```js
// In the cornerstone extension getCustomizationModule:
const getCustomizationModule = () => ([
{
name: 'default',
value: [
{
id: 'ohif.cornerstoneOverlay',
content: CornerstoneOverlay,
// Requires items on instances
},
{
id: 'ohif.overlayItem',
content: CornerstoneOverlayItem,
// Requires attribute and label on instances
},
],
},
]);
```
Then, in the configuration file one might have a custom overlay definition:
```js
// in the APP_CONFIG file set the top right area to show the patient name
// using PN: as a prefix when the study has a non-empty patient name.
customizationService: {
cornerstoneOverlayTopRight: {
id: 'cornerstoneOverlayTopRight',
customizationType: 'ohif.cornerstoneOverlay',
items: [
{
id: 'PatientNameOverlay',
// Note the ohif.overlayItem is a prototype instance for this object
// The ohif.overlayItem is defined up above
customizationType: 'ohif.overlayItem',
attribute: 'PatientName',
label: 'PN:',
},
],
},
},
```
In the mode customization, the overlay is then further customized
with a bottom-right overlay, which extends the customizationService configuration.
@ -170,7 +184,6 @@ onModeEnter() {
customizationService.addModeCustomizations(bottomRight);
```
## Mode Customizations
The mode customizations are retrieved via the `getModeCustomization` function,
providing an id, and optionally a default value. The retrieval will return,
in order:
@ -201,11 +214,13 @@ uses commands lists):
uiConfigurationService.recordInteraction(cornerstoneContextMenu, extraProps);
```
## Global Customizations
### Global Customizations
Global customizations are retrieved in the same was as mode customizations, except
that the `getGlobalCustomization` is called instead of the mode call.
## Types
### Types
Some types for the customization service are provided by the `@ohif/ui` types
export. Additionally, extensions can provide a Types export with custom
typing, allowing for better typing for the extension specific capabilities.
@ -223,7 +238,8 @@ const customContextMenu: Types.UIContextMenu =
},
```
## Inheritance
### Inheritance
JavaScript property inheritance can be supplied by defining customizations
with id corresponding to the customizationType value. For example:
@ -256,10 +272,12 @@ const overlayItem: Types.UIOverlayItem = {
```
# Customizations
This section can be used to specify various customization capabilities.
## Text color for StudyBrowser tabs
This is the recommended pattern for deep customization of class attributes,
making it fine grained, and have it apply a set of attributes, mostly from
tailwind. In this case it is a double indirection, as the buttons class
@ -310,6 +328,156 @@ customizationService: [
],
```
## Customizable Viewport Overlay
Below is the full example configuration of the customizable viewport overlay and the screenshot of the result overlay.
```javascript
// this is one of the configuration files in `platform/viewer/public/config/*.js`
window.config = {
// ...
customizationService: {
cornerstoneOverlayTopLeft: {
id: 'cornerstoneOverlayTopLeft',
customizationType: 'ohif.cornerstoneOverlay',
items: [
{
id: 'WindowLevel',
customizationType: 'ohif.overlayItem.windowLevel',
},
{
id: 'PatientName',
customizationType: 'ohif.overlayItem',
label: '',
color: 'green',
background: 'white',
condition: ({ instance }) =>
instance && instance.PatientName && instance.PatientName.Alphabetic,
contentF: ({ instance, formatters: { formatPN } }) =>
formatPN(instance.PatientName.Alphabetic) +
' ' +
(instance.PatientSex ? '(' + instance.PatientSex + ')' : ''),
},
{
id: 'Species',
customizationType: 'ohif.overlayItem',
label: 'Species:',
condition: ({ instance }) =>
instance && instance.PatientSpeciesDescription,
contentF: ({ instance }) =>
instance.PatientSpeciesDescription +
'/' +
instance.PatientBreedDescription,
},
{
id: 'PID',
customizationType: 'ohif.overlayItem',
label: 'PID:',
title: 'Patient PID',
condition: ({ instance }) => instance && instance.PatientID,
contentF: ({ instance }) => instance.PatientID,
},
{
id: 'PatientBirthDate',
customizationType: 'ohif.overlayItem',
label: 'DOB:',
title: "Patient's Date of birth",
condition: ({ instance }) => instance && instance.PatientBirthDate,
contentF: ({ instance }) => instance.PatientBirthDate,
},
{
id: 'OtherPid',
customizationType: 'ohif.overlayItem',
label: 'Other PID:',
title: 'Other Patient IDs',
condition: ({ instance }) => instance && instance.OtherPatientIDs,
contentF: ({ instance, formatters: { formatPN } }) =>
formatPN(instance.OtherPatientIDs),
},
],
},
cornerstoneOverlayTopRight: {
id: 'cornerstoneOverlayTopRight',
customizationType: 'ohif.cornerstoneOverlay',
items: [
{
id: 'InstanceNmber',
customizationType: 'ohif.overlayItem.instanceNumber',
},
{
id: 'StudyDescription',
customizationType: 'ohif.overlayItem',
label: '',
title: ({ instance }) =>
instance &&
instance.StudyDescription &&
`Study Description: ${instance.StudyDescription}`,
condition: ({ instance }) => instance && instance.StudyDescription,
contentF: ({ instance }) => instance.StudyDescription,
},
{
id: 'StudyDate',
customizationType: 'ohif.overlayItem',
label: '',
title: 'Study date',
condition: ({ instance }) => instance && instance.StudyDate,
contentF: ({ instance, formatters: { formatDate } }) =>
formatDate(instance.StudyDate),
},
{
id: 'StudyTime',
customizationType: 'ohif.overlayItem',
label: '',
title: 'Study time',
condition: ({ instance }) => instance && instance.StudyTime,
contentF: ({ instance, formatters: { formatTime } }) =>
formatTime(instance.StudyTime),
},
],
},
cornerstoneOverlayBottomLeft: {
id: 'cornerstoneOverlayBottomLeft',
customizationType: 'ohif.cornerstoneOverlay',
items: [
{
id: 'SeriesNumber',
customizationType: 'ohif.overlayItem',
label: 'Ser:',
title: 'Series Number',
condition: ({ instance }) => instance && instance.SeriesNumber,
contentF: ({ instance }) => instance.SeriesNumber,
},
{
id: 'SliceLocation',
customizationType: 'ohif.overlayItem',
label: 'Loc:',
title: 'Slice Location',
condition: ({ instance }) => instance && instance.SliceLocation,
contentF: ({ instance, formatters: { formatNumberPrecision } }) =>
formatNumberPrecision(instance.SliceLocation, 2) + ' mm',
},
{
id: 'SliceThickness',
customizationType: 'ohif.overlayItem',
label: 'Thick:',
title: 'Slice Thickness',
condition: ({ instance }) => instance && instance.SliceThickness,
contentF: ({ instance, formatters: { formatNumberPrecision } }) =>
formatNumberPrecision(instance.SliceThickness, 2) + ' mm',
},
],
},
},
// ...
}
```
<img src="../../../assets/img/customizable-overlay.png" />
> 3rd Party implementers may be added to this table via pull requests.
<!--

View File

@ -17,7 +17,7 @@ const ViewportOverlay = ({
bottomLeft,
color,
}) => {
const overlay = 'absolute pointer-events-none';
const overlay = 'absolute pointer-events-none viewport-overlay';
return (
<div className={classnames(color ? color : 'text-primary-light')}>
<div