This commit is contained in:
mario
2025-03-07 13:47:44 +07:00
commit c4efec5a14
3358 changed files with 303774 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { DicomMetadataStore } from '@ohif/core';
/**
*
* @param {*} servicesManager
*/
async function createReportAsync({
servicesManager,
getReport,
reportType = 'measurement',
}: withAppTypes) {
const { displaySetService, uiNotificationService, uiDialogService } = servicesManager.services;
const loadingDialogId = uiDialogService.create({
showOverlay: true,
isDraggable: false,
centralize: true,
content: Loading,
});
try {
const naturalizedReport = await getReport();
if (!naturalizedReport) return;
// The "Mode" route listens for DicomMetadataStore changes
// When a new instance is added, it listens and
// automatically calls makeDisplaySets
DicomMetadataStore.addInstances([naturalizedReport], true);
const displaySet = displaySetService.getMostRecentDisplaySet();
const displaySetInstanceUID = displaySet.displaySetInstanceUID;
uiNotificationService.show({
title: 'Create Report',
message: `${reportType} saved successfully`,
type: 'success',
});
return [displaySetInstanceUID];
} catch (error) {
uiNotificationService.show({
title: 'Create Report',
message: error.message || `Failed to store ${reportType}`,
type: 'error',
});
throw new Error(`Failed to store ${reportType}. Error: ${error.message || 'Unknown error'}`);
} finally {
uiDialogService.dismiss({ id: loadingDialogId });
}
}
function Loading() {
return <div className="text-primary-active">Loading...</div>;
}
export default createReportAsync;

View File

@@ -0,0 +1,116 @@
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon, useModal } from '@ohif/ui';
import { Types } from '@ohif/core';
import DataSourceConfigurationModalComponent from './DataSourceConfigurationModalComponent';
function DataSourceConfigurationComponent({
servicesManager,
extensionManager,
}: withAppTypes): ReactElement {
const { t } = useTranslation('DataSourceConfiguration');
const { show, hide } = useModal();
const { customizationService } = servicesManager.services;
const [configurationAPI, setConfigurationAPI] = useState<Types.BaseDataSourceConfigurationAPI>();
const [configuredItems, setConfiguredItems] =
useState<Array<Types.BaseDataSourceConfigurationAPIItem>>();
useEffect(() => {
let shouldUpdate = true;
const dataSourceChangedCallback = async () => {
const activeDataSourceDef = extensionManager.getActiveDataSourceDefinition();
if (!activeDataSourceDef.configuration.configurationAPI) {
return;
}
const { factory: configurationAPIFactory } =
customizationService.get(activeDataSourceDef.configuration.configurationAPI) ?? {};
if (!configurationAPIFactory) {
return;
}
const configAPI = configurationAPIFactory(activeDataSourceDef.sourceName);
setConfigurationAPI(configAPI);
// New configuration API means that the existing configured items must be cleared.
setConfiguredItems(null);
configAPI.getConfiguredItems().then(list => {
if (shouldUpdate) {
setConfiguredItems(list);
}
});
};
const sub = extensionManager.subscribe(
extensionManager.EVENTS.ACTIVE_DATA_SOURCE_CHANGED,
dataSourceChangedCallback
);
dataSourceChangedCallback();
return () => {
shouldUpdate = false;
sub.unsubscribe();
};
}, []);
const showConfigurationModal = useCallback(() => {
show({
content: DataSourceConfigurationModalComponent,
title: t('Configure Data Source'),
contentProps: {
configurationAPI,
configuredItems,
onHide: hide,
},
});
}, [configurationAPI, configuredItems]);
useEffect(() => {
if (!configurationAPI || !configuredItems) {
return;
}
if (configuredItems.length !== configurationAPI.getItemLabels().length) {
// Not the correct number of configured items, so show the modal to configure the data source.
showConfigurationModal();
}
}, [configurationAPI, configuredItems, showConfigurationModal]);
return configuredItems ? (
<div className="text-aqua-pale flex items-center overflow-hidden">
<Icon
name="settings"
className="mr-2.5 h-3.5 w-3.5 shrink-0 cursor-pointer"
onClick={showConfigurationModal}
></Icon>
{configuredItems.map((item, itemIndex) => {
return (
<div
key={itemIndex}
className="flex overflow-hidden"
>
<div
key={itemIndex}
className="overflow-hidden text-ellipsis whitespace-nowrap"
>
{item.name}
</div>
{itemIndex !== configuredItems.length - 1 && <div className="px-2.5">|</div>}
</div>
);
})}
</div>
) : (
<></>
);
}
export default DataSourceConfigurationComponent;

View File

@@ -0,0 +1,195 @@
import classNames from 'classnames';
import React, { ReactElement, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from '@ohif/ui';
import { Types } from '@ohif/core';
import ItemListComponent from './ItemListComponent';
const NO_WRAP_ELLIPSIS_CLASS_NAMES = 'text-ellipsis whitespace-nowrap overflow-hidden';
type DataSourceConfigurationModalComponentProps = {
configurationAPI: Types.BaseDataSourceConfigurationAPI;
configuredItems: Array<Types.BaseDataSourceConfigurationAPIItem>;
onHide: () => void;
};
function DataSourceConfigurationModalComponent({
configurationAPI,
configuredItems,
onHide,
}: DataSourceConfigurationModalComponentProps) {
const { t } = useTranslation('DataSourceConfiguration');
const [itemList, setItemList] = useState<Array<Types.BaseDataSourceConfigurationAPIItem>>();
const [selectedItems, setSelectedItems] = useState(configuredItems);
const [errorMessage, setErrorMessage] = useState<string>();
const [itemLabels] = useState(configurationAPI.getItemLabels());
// Determines whether to show the full/existing configuration for the data source.
// A full or complete configuration is one where the data source (path) has the
// maximum/required number of path items. Anything less is considered not complete and
// the configuration starts from scratch (i.e. as if no items are configured at all).
// TODO: consider configuration starting from a partial (i.e. non-empty) configuration
const [showFullConfig, setShowFullConfig] = useState(
itemLabels.length === configuredItems.length
);
/**
* The index of the selected item that is considered current and for which
* its sub-items should be displayed in the items list component. When the
* full/existing configuration for a data source is to be shown, the current
* selected item is the second to last in the `selectedItems` list.
*/
const currentSelectedItemIndex = showFullConfig
? selectedItems.length - 2
: selectedItems.length - 1;
useEffect(() => {
let shouldUpdate = true;
setErrorMessage(null);
// Clear out the former/old list while we fetch the next sub item list.
setItemList(null);
if (selectedItems.length === 0) {
configurationAPI
.initialize()
.then(items => {
if (shouldUpdate) {
setItemList(items);
}
})
.catch(error => setErrorMessage(error.message));
} else if (!showFullConfig && selectedItems.length === itemLabels.length) {
// The last item to configure the data source (path) has been selected.
configurationAPI.setCurrentItem(selectedItems[selectedItems.length - 1]);
// We can hide the modal dialog now.
onHide();
} else {
configurationAPI
.setCurrentItem(selectedItems[currentSelectedItemIndex])
.then(items => {
if (shouldUpdate) {
setItemList(items);
}
})
.catch(error => setErrorMessage(error.message));
}
return () => {
shouldUpdate = false;
};
}, [
selectedItems,
configurationAPI,
onHide,
itemLabels,
showFullConfig,
currentSelectedItemIndex,
]);
const getSelectedItemCursorClasses = itemIndex =>
itemIndex !== itemLabels.length - 1 && itemIndex < selectedItems.length
? 'cursor-pointer'
: 'cursor-auto';
const getSelectedItemBackgroundClasses = itemIndex =>
itemIndex < selectedItems.length
? classNames(
'bg-black/[.4]',
itemIndex !== itemLabels.length - 1 ? 'hover:bg-transparent active:bg-secondary-dark' : ''
)
: 'bg-transparent';
const getSelectedItemBorderClasses = itemIndex =>
itemIndex === currentSelectedItemIndex + 1
? classNames('border-2', 'border-solid', 'border-primary-light')
: itemIndex < selectedItems.length
? 'border border-solid border-primary-active hover:border-primary-light active:border-white'
: 'border border-dashed border-secondary-light';
const getSelectedItemTextClasses = itemIndex =>
itemIndex <= selectedItems.length ? 'text-primary-light' : 'text-primary-active';
const getErrorComponent = (): ReactElement => {
return (
<div className="flex min-h-[1px] grow flex-col gap-4">
<div className="text-primary-light text-[20px]">
{t(`Error fetching ${itemLabels[selectedItems.length]} list`)}
</div>
<div className="grow bg-black p-4 text-[14px]">{errorMessage}</div>
</div>
);
};
const getSelectedItemsComponent = (): ReactElement => {
return (
<div className="flex gap-4">
{itemLabels.map((itemLabel, itemLabelIndex) => {
return (
<div
key={itemLabel}
className={classNames(
'flex min-w-[1px] shrink basis-[200px] flex-col gap-1 rounded-md p-3.5',
getSelectedItemCursorClasses(itemLabelIndex),
getSelectedItemBackgroundClasses(itemLabelIndex),
getSelectedItemBorderClasses(itemLabelIndex),
getSelectedItemTextClasses(itemLabelIndex)
)}
onClick={
(showFullConfig && itemLabelIndex < currentSelectedItemIndex) ||
itemLabelIndex <= currentSelectedItemIndex
? () => {
setShowFullConfig(false);
setSelectedItems(theList => theList.slice(0, itemLabelIndex));
}
: undefined
}
>
<div className="text- flex items-center gap-2">
{itemLabelIndex < selectedItems.length ? (
<Icon name="status-tracked" />
) : (
<Icon name="status-untracked" />
)}
<div className={classNames(NO_WRAP_ELLIPSIS_CLASS_NAMES)}>{t(itemLabel)}</div>
</div>
{itemLabelIndex < selectedItems.length ? (
<div className={classNames('text-[14px] text-white', NO_WRAP_ELLIPSIS_CLASS_NAMES)}>
{selectedItems[itemLabelIndex].name}
</div>
) : (
<br></br>
)}
</div>
);
})}
</div>
);
};
return (
<div className="flex h-[calc(100vh-300px)] select-none flex-col gap-4 pt-0.5">
{getSelectedItemsComponent()}
<div className="h-0.5 w-full shrink-0 bg-black"></div>
{errorMessage ? (
getErrorComponent()
) : (
<ItemListComponent
itemLabel={itemLabels[currentSelectedItemIndex + 1]}
itemList={itemList}
onItemClicked={item => {
setShowFullConfig(false);
setSelectedItems(theList => [...theList.slice(0, currentSelectedItemIndex + 1), item]);
}}
></ItemListComponent>
)}
</div>
);
}
export default DataSourceConfigurationModalComponent;

View File

@@ -0,0 +1,86 @@
import classNames from 'classnames';
import React, { ReactElement, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Icon, InputFilterText, LoadingIndicatorProgress } from '@ohif/ui';
import { Types } from '@ohif/core';
type ItemListComponentProps = {
itemLabel: string;
itemList: Array<Types.BaseDataSourceConfigurationAPIItem>;
onItemClicked: (item: Types.BaseDataSourceConfigurationAPIItem) => void;
};
function ItemListComponent({
itemLabel,
itemList,
onItemClicked,
}: ItemListComponentProps): ReactElement {
const { t } = useTranslation('DataSourceConfiguration');
const [filterValue, setFilterValue] = useState('');
useEffect(() => {
setFilterValue('');
}, [itemList]);
return (
<div className="flex min-h-[1px] grow flex-col gap-4">
<div className="flex items-center justify-between">
<div className="text-primary-light text-[20px]">{t(`Select ${itemLabel}`)}</div>
<InputFilterText
className="max-w-[40%] grow"
value={filterValue}
onDebounceChange={setFilterValue}
placeholder={t(`Search ${itemLabel} list`)}
></InputFilterText>
</div>
<div className="relative flex min-h-[1px] grow flex-col bg-black text-[14px]">
{itemList == null ? (
<LoadingIndicatorProgress className={'h-full w-full'} />
) : itemList.length === 0 ? (
<div className="text-primary-light flex h-full flex-col items-center justify-center px-6 py-4">
<Icon
name="magnifier"
className="mb-4"
/>
<span>{t(`No ${itemLabel} available`)}</span>
</div>
) : (
<>
<div className="bg-secondary-dark px-3 py-1.5 text-white">{t(itemLabel)}</div>
<div className="ohif-scrollbar overflow-auto">
{itemList
.filter(
item =>
!filterValue || item.name.toLowerCase().includes(filterValue.toLowerCase())
)
.map(item => {
const border =
'rounded border-transparent border-b-secondary-light border-[1px] hover:border-primary-light';
return (
<div
className={classNames(
'hover:text-primary-light hover:bg-primary-dark group mx-2 flex items-center justify-between px-6 py-2',
border
)}
key={item.id}
>
<div>{item.name}</div>
<Button
onClick={() => onItemClicked(item)}
className="invisible group-hover:visible"
endIcon={<Icon name="arrow-left" />}
>
{t('Select')}
</Button>
</div>
);
})}
</div>
</>
)}
</div>
</div>
);
}
export default ItemListComponent;

View File

@@ -0,0 +1,32 @@
import React from 'react';
import { LineChart } from '@ohif/ui';
const LineChartViewport = ({ displaySets }) => {
const displaySet = displaySets[0];
const { axis: chartAxis, series: chartSeries } = displaySet.instance.chartData;
return (
<LineChart
showLegend={true}
legendWidth={150}
axis={{
x: {
label: chartAxis.x.label,
indexRef: 0,
type: 'x',
range: {
min: 0,
},
},
y: {
label: chartAxis.y.label,
indexRef: 1,
type: 'y',
},
}}
series={chartSeries}
/>
);
};
export { LineChartViewport as default };

View File

@@ -0,0 +1 @@
export { default } from './LineChartViewport';

View File

@@ -0,0 +1,102 @@
import React, { useEffect, useState, useCallback, ReactElement } from 'react';
import { ProgressDropdown } from '@ohif/ui';
const workflowStepsToDropdownOptions = (steps = []) =>
steps.map(step => ({
label: step.name,
value: step.id,
info: step.info,
activated: false,
completed: false,
}));
export function ProgressDropdownWithService({ servicesManager }: withAppTypes): ReactElement {
const { workflowStepsService } = servicesManager.services;
const [activeStepId, setActiveStepId] = useState(workflowStepsService.activeWorkflowStep?.id);
const [dropdownOptions, setDropdownOptions] = useState(
workflowStepsToDropdownOptions(workflowStepsService.workflowSteps)
);
const setCurrentAndPreviousOptionsAsCompleted = useCallback(currentOption => {
if (currentOption.completed) {
return;
}
setDropdownOptions(prevOptions => {
const newOptionsState = [...prevOptions];
const startIndex = newOptionsState.findIndex(option => option.value === currentOption.value);
for (let i = startIndex; i >= 0; i--) {
const option = newOptionsState[i];
if (option.completed) {
break;
}
newOptionsState[i] = {
...option,
completed: true,
};
}
return newOptionsState;
});
}, []);
const handleDropdownChange = useCallback(
({ selectedOption }) => {
if (!selectedOption) {
return;
}
// TODO: Steps should be marked as completed after user has
// completed some action when required (not implemented)
setCurrentAndPreviousOptionsAsCompleted(selectedOption);
setActiveStepId(selectedOption.value);
},
[setCurrentAndPreviousOptionsAsCompleted]
);
useEffect(() => {
let timeoutId;
if (activeStepId) {
// We've used setTimeout to give it more time to update the UI since
// create3DFilterableFromDataArray from Texture.js may take 600+ ms to run
// when there is a new series to load in the next step but that resulted
// in the followed React error when updating the content from left/right panels
// and all component states were being lost:
// Error: Can't perform a React state update on an unmounted component
workflowStepsService.setActiveWorkflowStep(activeStepId);
}
return () => clearTimeout(timeoutId);
}, [activeStepId, workflowStepsService]);
useEffect(() => {
const { unsubscribe: unsubStepsChanged } = workflowStepsService.subscribe(
workflowStepsService.EVENTS.STEPS_CHANGED,
() => setDropdownOptions(workflowStepsToDropdownOptions(workflowStepsService.workflowSteps))
);
const { unsubscribe: unsubActiveStepChanged } = workflowStepsService.subscribe(
workflowStepsService.EVENTS.ACTIVE_STEP_CHANGED,
() => setActiveStepId(workflowStepsService.activeWorkflowStep.id)
);
return () => {
unsubStepsChanged();
unsubActiveStepChanged();
};
}, [servicesManager, workflowStepsService]);
return (
<ProgressDropdown
options={dropdownOptions}
value={activeStepId}
onChange={handleDropdownChange}
/>
);
}

View File

@@ -0,0 +1,102 @@
import React, { useEffect, useState, useCallback } from 'react';
import { SidePanel } from '@ohif/ui-next';
import { Types } from '@ohif/core';
export type SidePanelWithServicesProps = {
servicesManager: AppTypes.ServicesManager;
side: 'left' | 'right';
className?: string;
activeTabIndex: number;
tabs: any;
expandedWidth?: number;
};
const SidePanelWithServices = ({
servicesManager,
side,
activeTabIndex: activeTabIndexProp,
tabs: tabsProp,
expandedWidth,
...props
}: SidePanelWithServicesProps) => {
const panelService = servicesManager?.services?.panelService;
// Tracks whether this SidePanel has been opened at least once since this SidePanel was inserted into the DOM.
// Thus going to the Study List page and back to the viewer resets this flag for a SidePanel.
const [sidePanelOpen, setSidePanelOpen] = useState(activeTabIndexProp !== null);
const [activeTabIndex, setActiveTabIndex] = useState(activeTabIndexProp);
const [tabs, setTabs] = useState(tabsProp ?? panelService.getPanels(side));
const handleActiveTabIndexChange = useCallback(({ activeTabIndex }) => {
setActiveTabIndex(activeTabIndex);
setSidePanelOpen(activeTabIndex !== null);
}, []);
const handleOpen = useCallback(() => {
setSidePanelOpen(true);
// If panel is being opened but no tab is active, set first tab as active
if (activeTabIndex === null && tabs.length > 0) {
setActiveTabIndex(0);
}
}, [activeTabIndex, tabs]);
const handleClose = useCallback(() => {
setSidePanelOpen(false);
setActiveTabIndex(null);
}, []);
/** update the active tab index from outside */
useEffect(() => {
setActiveTabIndex(activeTabIndexProp);
}, [activeTabIndexProp]);
useEffect(() => {
const { unsubscribe } = panelService.subscribe(
panelService.EVENTS.PANELS_CHANGED,
panelChangedEvent => {
if (panelChangedEvent.position !== side) {
return;
}
setTabs(panelService.getPanels(side));
}
);
return () => {
unsubscribe();
};
}, [panelService, side]);
useEffect(() => {
const activatePanelSubscription = panelService.subscribe(
panelService.EVENTS.ACTIVATE_PANEL,
(activatePanelEvent: Types.ActivatePanelEvent) => {
if (sidePanelOpen || activatePanelEvent.forceActive) {
const tabIndex = tabs.findIndex(tab => tab.id === activatePanelEvent.panelId);
if (tabIndex !== -1) {
setActiveTabIndex(tabIndex);
}
}
}
);
return () => {
activatePanelSubscription.unsubscribe();
};
}, [tabs, sidePanelOpen, panelService]);
return (
<SidePanel
{...props}
side={side}
tabs={tabs}
activeTabIndex={activeTabIndex}
onOpen={handleOpen}
onClose={handleClose}
onActiveTabIndexChange={handleActiveTabIndexChange}
expandedWidth={expandedWidth}
/>
);
};
export default SidePanelWithServices;

View File

@@ -0,0 +1,211 @@
import * as ContextMenuItemsBuilder from './ContextMenuItemsBuilder';
import ContextMenu from '../../../../platform/ui/src/components/ContextMenu/ContextMenu';
import { CommandsManager } from '@ohif/core';
import { annotation as CsAnnotation } from '@cornerstonejs/tools';
import { Menu, MenuItem, Point, ContextMenuProps } from './types';
/**
* The context menu controller is a helper class that knows how
* to manage context menus based on the UI Customization Service.
* There are a few parts to this:
* 1. Basic controls to manage displaying and hiding context menus
* 2. Menu selection services, which use the UI customization service
* to choose which menu to display
* 3. Menu item adapter services to convert menu items into displayable and actionable items.
*
* The format for a menu is defined in the exported type MenuItem
*/
export default class ContextMenuController {
commandsManager: CommandsManager;
services: AppTypes.Services;
menuItems: Menu[] | MenuItem[];
constructor(servicesManager: AppTypes.ServicesManager, commandsManager: CommandsManager) {
this.services = servicesManager.services;
this.commandsManager = commandsManager;
}
closeContextMenu() {
this.services.uiDialogService.dismiss({ id: 'context-menu' });
}
/**
* Figures out which context menu is appropriate to display and shows it.
*
* @param contextMenuProps - the context menu properties, see ./types.ts
* @param viewportElement - the DOM element this context menu is related to
* @param defaultPointsPosition - a default position to show the context menu
*/
showContextMenu(
contextMenuProps: ContextMenuProps,
viewportElement,
defaultPointsPosition
): void {
if (!this.services.uiDialogService) {
console.warn('Unable to show dialog; no UI Dialog Service available.');
return;
}
const { event, subMenu, menuId, menus, selectorProps } = contextMenuProps;
const annotationManager = CsAnnotation.state.getAnnotationManager();
const { locking } = CsAnnotation;
const targetAnnotationId = selectorProps?.nearbyToolData?.annotationUID as string;
const isLocked = locking.isAnnotationLocked(
annotationManager.getAnnotation(targetAnnotationId)
);
if (isLocked) {
console.warn('Annotation is locked.');
return;
}
const items = ContextMenuItemsBuilder.getMenuItems(
selectorProps || contextMenuProps,
event,
menus,
menuId
);
this.services.uiDialogService.dismiss({ id: 'context-menu' });
this.services.uiDialogService.create({
id: 'context-menu',
isDraggable: false,
preservePosition: false,
preventCutOf: true,
defaultPosition: ContextMenuController._getDefaultPosition(
defaultPointsPosition,
event?.detail,
viewportElement
),
event,
content: ContextMenu,
// This naming is part of the uiDialogService convention
// Clicking outside simply closes the dialog box.
onClickOutside: () => this.services.uiDialogService.dismiss({ id: 'context-menu' }),
contentProps: {
items,
selectorProps,
menus,
event,
subMenu,
eventData: event?.detail,
onClose: () => {
this.services.uiDialogService.dismiss({ id: 'context-menu' });
},
/**
* Displays a sub-menu, removing this menu
* @param {*} item
* @param {*} itemRef
* @param {*} subProps
*/
onShowSubMenu: (item, itemRef, subProps) => {
if (!itemRef.subMenu) {
console.warn('No submenu defined for', item, itemRef, subProps);
return;
}
this.showContextMenu(
{
...contextMenuProps,
menuId: itemRef.subMenu,
},
viewportElement,
defaultPointsPosition
);
},
// Default is to run the specified commands.
onDefault: (item, itemRef, subProps) => {
this.commandsManager.run(item, {
...selectorProps,
...itemRef,
subProps,
});
},
},
});
}
static getDefaultPosition = (): Point => {
return {
x: 0,
y: 0,
};
};
static _getEventDefaultPosition = eventDetail => ({
x: eventDetail && eventDetail.currentPoints.client[0],
y: eventDetail && eventDetail.currentPoints.client[1],
});
static _getElementDefaultPosition = element => {
if (element) {
const boundingClientRect = element.getBoundingClientRect();
return {
x: boundingClientRect.x,
y: boundingClientRect.y,
};
}
return {
x: undefined,
y: undefined,
};
};
static _getCanvasPointsPosition = (points = [], element) => {
const viewerPos = ContextMenuController._getElementDefaultPosition(element);
for (let pointIndex = 0; pointIndex < points.length; pointIndex++) {
const point = {
x: points[pointIndex][0] || points[pointIndex]['x'],
y: points[pointIndex][1] || points[pointIndex]['y'],
};
if (
ContextMenuController._isValidPosition(point) &&
ContextMenuController._isValidPosition(viewerPos)
) {
return {
x: point.x + viewerPos.x,
y: point.y + viewerPos.y,
};
}
}
};
static _isValidPosition = (source): boolean => {
return source && typeof source.x === 'number' && typeof source.y === 'number';
};
/**
* Returns the context menu default position. It look for the positions of: canvasPoints (got from selected), event that triggers it, current viewport element
*/
static _getDefaultPosition = (canvasPoints, eventDetail, viewerElement) => {
function* getPositionIterator() {
yield ContextMenuController._getCanvasPointsPosition(canvasPoints, viewerElement);
yield ContextMenuController._getEventDefaultPosition(eventDetail);
yield ContextMenuController._getElementDefaultPosition(viewerElement);
yield ContextMenuController.getDefaultPosition();
}
const positionIterator = getPositionIterator();
let current = positionIterator.next();
let position = current.value;
while (!current.done) {
position = current.value;
if (ContextMenuController._isValidPosition(position)) {
positionIterator.return();
}
current = positionIterator.next();
}
return position;
};
}

View File

@@ -0,0 +1,29 @@
import * as ContextMenuItemsBuilder from './ContextMenuItemsBuilder';
const menus = [
{
id: 'one',
selector: ({ value } = {}) => value === 'one',
items: [],
},
{
id: 'two',
selector: ({ value } = {}) => value === 'two',
items: [],
},
{
id: 'default',
items: [],
},
];
describe('ContextMenuItemsBuilder', () => {
test('findMenuDefault', () => {
expect(ContextMenuItemsBuilder.findMenuDefault(menus, {})).toBe(menus[2]);
expect(
ContextMenuItemsBuilder.findMenuDefault(menus, { selectorProps: { value: 'two' } })
).toBe(menus[1]);
expect(ContextMenuItemsBuilder.findMenuDefault([], {})).toBeUndefined();
expect(ContextMenuItemsBuilder.findMenuDefault(undefined, undefined)).toBeNull();
});
});

View File

@@ -0,0 +1,176 @@
import { Types } from '@ohif/ui';
import { Menu, SelectorProps, MenuItem, ContextMenuProps } from './types';
type ContextMenuItem = Types.ContextMenuItem;
/**
* Finds menu by menu id
*
* @returns Menu having the menuId
*/
export function findMenuById(menus: Menu[], menuId?: string): Menu {
if (!menuId) {
return;
}
return menus.find(menu => menu.id === menuId);
}
/**
* Default finding menu method. This method will go through
* the list of menus until it finds the first one which
* has no selector, OR has the selector, when applied to the
* check props, return true.
* The selectorProps are a set of provided properties which can be
* passed into the selector function to determine when to display a menu.
* For example, a selector function of:
* `({displayset}) => displaySet?.SeriesDescription?.indexOf?.('Left')!==-1
* would match series descriptions containing 'Left'.
*
* @param {Object[]} menus List of menus
* @param {*} subProps
* @returns
*/
export function findMenuDefault(menus: Menu[], subProps: Record<string, unknown>): Menu {
if (!menus) {
return null;
}
return menus.find(menu => !menu.selector || menu.selector(subProps.selectorProps));
}
/**
* Finds the menu to be used for different scenarios:
* This will first look for a subMenu with the specified subMenuId
* Next it will look for the first menu whose selector returns true.
*
* @param menus - List of menus
* @param props - root props
* @param menuIdFilter - menu id identifier (to be considered on selection)
* This is intended to support other types of filtering in the future.
*/
export function findMenu(menus: Menu[], props?: Types.IProps, menuIdFilter?: string) {
const { subMenu } = props;
function* findMenuIterator() {
yield findMenuById(menus, menuIdFilter || subMenu);
yield findMenuDefault(menus, props);
}
const findIt = findMenuIterator();
let current = findIt.next();
let menu = current.value;
while (!current.done) {
menu = current.value;
if (menu) {
findIt.return();
}
current = findIt.next();
}
return menu;
}
/**
* Returns the menu from a list of possible menus, based on the actual state of component props and tool data nearby.
* This uses the findMenu command above to first find the appropriate
* menu, and then it chooses the actual contents of that menu.
* A menu item can be optional by implementing the 'selector',
* which will be called with the selectorProps, and if it does not return true,
* then the item is excluded.
*
* Other menus can be delegated to by setting the delegating value to
* a string id for another menu. That menu's content will replace the
* current menu item (only if the item would be included).
*
* This allows single id menus to be chosen by id, but have varying contents
* based on the delegated menus.
*
* Finally, for each item, the adaptItem call is made. This allows
* items to modify themselves before being displayed, such as
* incorporating additional information from translation sources.
* See the `test-mode` examples for details.
*
* @param selectorProps
* @param {*} event event that originates the context menu
* @param {*} menus List of menus
* @param {*} menuIdFilter
* @returns
*/
export function getMenuItems(
selectorProps: SelectorProps,
event: Event,
menus: Menu[],
menuIdFilter?: string
): MenuItem[] | void {
// Include both the check props and the ...check props as one is used
// by the child menu and the other used by the selector function
const subProps = { selectorProps, event };
const menu = findMenu(menus, subProps, menuIdFilter);
if (!menu) {
return undefined;
}
if (!menu.items) {
console.warn('Must define items in menu', menu);
return [];
}
let menuItems = [];
menu.items.forEach(item => {
const { delegating, selector, subMenu } = item;
if (!selector || selector(selectorProps)) {
if (delegating) {
menuItems = [...menuItems, ...getMenuItems(selectorProps, event, menus, subMenu)];
} else {
const toAdd = adaptItem(item, subProps);
menuItems.push(toAdd);
}
}
});
return menuItems;
}
/**
* Returns item adapted to be consumed by ContextMenu component
* and then goes through the item to add action behaviour for clicking the item,
* making it compatible with the default ContextMenu display.
*
* @param {Object} item
* @param {Object} subProps
* @returns a MenuItem that is compatible with the base ContextMenu
* This requires having a label and set of actions to be called.
*/
export function adaptItem(item: MenuItem, subProps: ContextMenuProps): ContextMenuItem {
const newItem: ContextMenuItem = {
...item,
value: subProps.selectorProps?.value,
};
if (item.actionType === 'ShowSubMenu' && !newItem.iconRight) {
newItem.iconRight = 'chevron-menu';
}
if (!item.action) {
newItem.action = (itemRef, componentProps) => {
const { event = {} } = componentProps;
const { detail = {} } = event;
newItem.element = detail.element;
componentProps.onClose();
const action = componentProps[`on${itemRef.actionType || 'Default'}`];
if (action) {
action.call(componentProps, newItem, itemRef, subProps);
} else {
console.warn('No action defined for', itemRef);
}
};
}
return newItem;
}

View File

@@ -0,0 +1,34 @@
const defaultContextMenu = {
id: 'measurementsContextMenu',
customizationType: 'ohif.contextMenu',
menus: [
// Get the items from the UI Customization for the menu name (and have a custom name)
{
id: 'forExistingMeasurement',
selector: ({ nearbyToolData }) => !!nearbyToolData,
items: [
{
label: 'Delete measurement',
commands: [
{
commandName: 'deleteMeasurement',
// we only have support for cornerstoneTools context menu since
// they are svg based
context: 'CORNERSTONE',
},
],
},
{
label: 'Add Label',
commands: [
{
commandName: 'setMeasurementLabel',
},
],
},
],
},
],
};
export default defaultContextMenu;

View File

@@ -0,0 +1,11 @@
import ContextMenuController from './ContextMenuController';
import * as ContextMenuItemsBuilder from './ContextMenuItemsBuilder';
import defaultContextMenu from './defaultContextMenu';
import * as CustomizableContextMenuTypes from './types';
export {
ContextMenuController,
CustomizableContextMenuTypes,
ContextMenuItemsBuilder,
defaultContextMenu,
};

View File

@@ -0,0 +1,125 @@
import { Types } from '@ohif/core';
/**
* SelectorProps are properties used to decide whether to select a menu or
* menu item for display.
* An instance of SelectorProps is provided to the selector functions, which
* return true to include the item or false to exclude it.
* The point of this is to allow more specific context menus which hide
* non-relevant menu options, optimizing the speed of selection of menus
*/
export interface SelectorProps {
// If the context menu is invoked in the context of a measurement, then it
// will contain the nearby tool data.
nearbyToolData?: Record<string, unknown>;
// The tool name for the nearby tool
toolName?: string;
// An annotation UID - this will be present if nearbyToolData is present.
uid?: string;
// If the context menu is invoked on an active viewport, then it will contain
// the first display set.
displaySet?: Record<string, unknown>;
// The triggering event - can be used to determine key modifiers
event?: Event;
// Any other properties
[propertyName: string]: unknown;
}
/**
* The type of item actually required for the ContextMenu UI display
*/
export type UIMenuItem = {
label: string;
// Called when the item is selected
action?: (itemRef, componentProps) => void;
};
/**
* A MenuItem is a single line item within a menu, and specifies a selectable
* value for the menu.
*/
export interface MenuItem {
id?: string;
/** The customization type is used to apply preset values to this item
* when registered with the customization service.
*/
customizationType?: string;
// The label is the value to show in the menu for this item
label?: string;
// Delegating items are used to include other sub-menus inline within
// this menu. That allows sharing part of the menu structure, but also,
// more importantly to use a single selector function to include/exclude
// and entire section of sub-menu.
// See the `siteSelectionSubMenu` within the example `findingsMenu`
// for an example
delegating?: boolean;
// A sub-menu is shown when this item is selected or is delegating.
// This item gives the name of the sub-menu.
subMenu?: string;
// The selector is used to determine if this menu entry will be shown
// or more importantly, if the delegating subMenu will be included.
selector?: (props: SelectorProps) => boolean;
/** Adapts the item by filling in additional properties as required */
adaptItem?: (item: MenuItem, props: ContextMenuProps) => UIMenuItem;
/** List of commands to run when this item's action is taken. */
commands?: Types.Command[];
}
/**
* A menu is a list of menu items, plus a selector.
* The selector is used to determine whether the menu should be displayed
* in a given context. The parameters passed to the selector come from
* the 'selectorProps' value in the options, and are intended to be context
* specific values containing things like the selected object, the currently
* displayed study etc so that the context menu can dynamically choose which
* view to show.
*/
export interface Menu {
id: string;
/** The customization type is used to apply preset values to this item
* when registered with the customization service.
*/
customizationType?: string;
// Choose whether this menu applies.
selector?: Types.Predicate;
items: MenuItem[];
}
export type Point = {
x: number;
y: number;
};
/**
* ContextMenuProps is the top level argument used to invoke the context menu
* itself. It contains the menus available for display, as well as the event
* and selector props used to decide the menu.
*/
export type ContextMenuProps = {
event?: EventTarget;
menuCustomizationId?: string;
menuId: string;
element?: HTMLElement;
/** A set of menus to choose from for this context menu */
menus: Menu[];
/** The properties used to decide the menu type */
selectorProps: SelectorProps;
defaultPointsPosition?: [number, number] | [];
};

View File

@@ -0,0 +1,236 @@
import { ExtensionManager, Types } from '@ohif/core';
/**
* This file contains the implementations of BaseDataSourceConfigurationAPIItem
* and BaseDataSourceConfigurationAPI for the Google cloud healthcare API. To
* better understand this implementation and/or to implement custom implementations,
* see the platform\core\src\types\DataSourceConfigurationAPI.ts and its JS doc
* comments as a guide.
*/
/**
* The various Google Cloud Healthcare path item types.
*/
enum ItemType {
projects = 0,
locations = 1,
datasets = 2,
dicomStores = 3,
}
interface NamedItem {
name: string;
}
interface Project extends NamedItem {
projectId: string;
}
const initialUrl = 'https://cloudresourcemanager.googleapis.com/v1';
const baseHealthcareUrl = 'https://healthcare.googleapis.com/v1';
class GoogleCloudDataSourceConfigurationAPIItem
implements Types.BaseDataSourceConfigurationAPIItem
{
id: string;
name: string;
url: string;
itemType: ItemType;
}
class GoogleCloudDataSourceConfigurationAPI implements Types.BaseDataSourceConfigurationAPI {
private _extensionManager: ExtensionManager;
private _fetchOptions: { method: string; headers: unknown };
private _dataSourceName: string;
constructor(dataSourceName, servicesManager: AppTypes.ServicesManager, extensionManager) {
this._dataSourceName = dataSourceName;
this._extensionManager = extensionManager;
const userAuthenticationService = servicesManager.services.userAuthenticationService;
this._fetchOptions = {
method: 'GET',
headers: userAuthenticationService.getAuthorizationHeader(),
};
}
getItemLabels = () => ['Project', 'Location', 'Data set', 'DICOM store'];
async initialize(): Promise<Types.BaseDataSourceConfigurationAPIItem[]> {
const url = `${initialUrl}/projects`;
const projects = (await GoogleCloudDataSourceConfigurationAPI._doFetch(
url,
ItemType.projects,
this._fetchOptions
)) as Array<Project>;
if (!projects?.length) {
return [];
}
const projectItems = projects.map(project => {
return {
id: project.projectId,
name: project.name,
itemType: ItemType.projects,
url: `${baseHealthcareUrl}/projects/${project.projectId}`,
};
});
return projectItems;
}
async setCurrentItem(
anItem: Types.BaseDataSourceConfigurationAPIItem
): Promise<Types.BaseDataSourceConfigurationAPIItem[]> {
const googleCloudItem = anItem as GoogleCloudDataSourceConfigurationAPIItem;
if (googleCloudItem.itemType === ItemType.dicomStores) {
// Last configurable item, so update the data source configuration.
const url = `${googleCloudItem.url}/dicomWeb`;
const dataSourceDefCopy = JSON.parse(
JSON.stringify(this._extensionManager.getDataSourceDefinition(this._dataSourceName))
);
dataSourceDefCopy.configuration = {
...dataSourceDefCopy.configuration,
wadoUriRoot: url,
qidoRoot: url,
wadoRoot: url,
};
this._extensionManager.updateDataSourceConfiguration(
dataSourceDefCopy.sourceName,
dataSourceDefCopy.configuration
);
return [];
}
const subItemType = googleCloudItem.itemType + 1;
const subItemField = `${ItemType[subItemType]}`;
const url = `${googleCloudItem.url}/${subItemField}`;
const fetchedSubItems = await GoogleCloudDataSourceConfigurationAPI._doFetch(
url,
subItemType,
this._fetchOptions
);
if (!fetchedSubItems?.length) {
return [];
}
const subItems = fetchedSubItems.map(subItem => {
const nameSplit = subItem.name.split('/');
return {
id: subItem.name,
name: nameSplit[nameSplit.length - 1],
itemType: subItemType,
url: `${baseHealthcareUrl}/${subItem.name}`,
};
});
return subItems;
}
async getConfiguredItems(): Promise<Array<GoogleCloudDataSourceConfigurationAPIItem>> {
const dataSourceDefinition = this._extensionManager.getDataSourceDefinition(
this._dataSourceName
);
const url = dataSourceDefinition.configuration.wadoUriRoot;
const projectsIndex = url.indexOf('projects');
// Split the configured URL into (essentially) pairs (i.e. item type followed by item)
// Explicitly: ['projects','aProject','locations','aLocation','datasets','aDataSet','dicomStores','aDicomStore']
// Note that a partial configuration will have a subset of the above.
const urlSplit = url.substring(projectsIndex).split('/');
const configuredItems = [];
for (
let itemType = 0;
// the number of configured items is either the max (4) or the number extracted from the url split
itemType < 4 && (itemType + 1) * 2 < urlSplit.length;
itemType += 1
) {
if (itemType === ItemType.projects) {
const projectId = urlSplit[1];
const projectUrl = `${initialUrl}/projects/${projectId}`;
const data = await GoogleCloudDataSourceConfigurationAPI._doFetch(
projectUrl,
ItemType.projects,
this._fetchOptions
);
const project = data[0] as Project;
configuredItems.push({
id: project.projectId,
name: project.name,
itemType: itemType,
url: `${baseHealthcareUrl}/projects/${project.projectId}`,
});
} else {
const relativePath = urlSplit.slice(0, itemType * 2 + 2).join('/');
configuredItems.push({
id: relativePath,
name: urlSplit[itemType * 2 + 1],
itemType: itemType,
url: `${baseHealthcareUrl}/${relativePath}`,
});
}
}
return configuredItems;
}
/**
* Fetches an array of items the specified item type.
* @param urlStr the fetch url
* @param fetchItemType the type to fetch
* @param fetchOptions the header options for the fetch (e.g. authorization header)
* @param fetchSearchParams any search query params; currently only used for paging results
* @returns an array of items of the specified type
*/
private static async _doFetch(
urlStr: string,
fetchItemType: ItemType,
fetchOptions = {},
fetchSearchParams: Record<string, string> = {}
): Promise<Array<Project> | Array<NamedItem>> {
try {
const url = new URL(urlStr);
url.search = new URLSearchParams(fetchSearchParams).toString();
const response = await fetch(url, fetchOptions);
const data = await response.json();
if (response.status >= 200 && response.status < 300 && data != null) {
if (data.nextPageToken != null) {
fetchSearchParams.pageToken = data.nextPageToken;
const subPageData = await this._doFetch(
urlStr,
fetchItemType,
fetchOptions,
fetchSearchParams
);
data[ItemType[fetchItemType]] = data[ItemType[fetchItemType]].concat(subPageData);
}
if (data[ItemType[fetchItemType]]) {
return data[ItemType[fetchItemType]];
} else if (data.name) {
return [data];
} else {
return [];
}
} else {
const message =
data?.error?.message ||
`Error returned from Google Cloud Healthcare: ${response.status} - ${response.statusText}`;
throw new Error(message);
}
} catch (err) {
const message = err?.message || 'Error occurred during fetch request.';
throw new Error(message);
}
}
}
export { GoogleCloudDataSourceConfigurationAPI };

View File

@@ -0,0 +1,304 @@
import { DicomMetadataStore, IWebApiDataSource } from '@ohif/core';
import OHIF from '@ohif/core';
import qs from 'query-string';
import getImageId from '../DicomWebDataSource/utils/getImageId';
import getDirectURL from '../utils/getDirectURL';
const metadataProvider = OHIF.classes.MetadataProvider;
const mappings = {
studyInstanceUid: 'StudyInstanceUID',
patientId: 'PatientID',
};
let _store = {
urls: [],
studyInstanceUIDMap: new Map(), // map of urls to array of study instance UIDs
// {
// url: url1
// studies: [Study1, Study2], // if multiple studies
// }
// {
// url: url2
// studies: [Study1],
// }
// }
};
function wrapSequences(obj) {
return Object.keys(obj).reduce(
(acc, key) => {
if (typeof obj[key] === 'object' && obj[key] !== null) {
// Recursively wrap sequences for nested objects
acc[key] = wrapSequences(obj[key]);
} else {
acc[key] = obj[key];
}
if (key.endsWith('Sequence')) {
acc[key] = OHIF.utils.addAccessors(acc[key]);
}
return acc;
},
Array.isArray(obj) ? [] : {}
);
}
const getMetaDataByURL = url => {
return _store.urls.find(metaData => metaData.url === url);
};
const findStudies = (key, value) => {
let studies = [];
_store.urls.map(metaData => {
metaData.studies.map(aStudy => {
if (aStudy[key] === value) {
studies.push(aStudy);
}
});
});
return studies;
};
function createDicomJSONApi(dicomJsonConfig) {
const implementation = {
initialize: async ({ query, url }) => {
if (!url) {
url = query.get('url');
}
let metaData = getMetaDataByURL(url);
// if we have already cached the data from this specific url
// We are only handling one StudyInstanceUID to run; however,
// all studies for patientID will be put in the correct tab
if (metaData) {
return metaData.studies.map(aStudy => {
return aStudy.StudyInstanceUID;
});
}
const response = await fetch(url);
const data = await response.json();
let StudyInstanceUID;
let SeriesInstanceUID;
data.studies.forEach(study => {
StudyInstanceUID = study.StudyInstanceUID;
study.series.forEach(series => {
SeriesInstanceUID = series.SeriesInstanceUID;
series.instances.forEach(instance => {
const { metadata: naturalizedDicom } = instance;
const imageId = getImageId({ instance, config: dicomJsonConfig });
const { query } = qs.parseUrl(instance.url);
// Add imageId specific mapping to this data as the URL isn't necessarliy WADO-URI.
metadataProvider.addImageIdToUIDs(imageId, {
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID: naturalizedDicom.SOPInstanceUID,
frameNumber: query.frame ? parseInt(query.frame) : undefined,
});
});
});
});
_store.urls.push({
url,
studies: [...data.studies],
});
_store.studyInstanceUIDMap.set(
url,
data.studies.map(study => study.StudyInstanceUID)
);
},
query: {
studies: {
mapParams: () => {},
search: async param => {
const [key, value] = Object.entries(param)[0];
const mappedParam = mappings[key];
// todo: should fetch from dicomMetadataStore
const studies = findStudies(mappedParam, value);
return studies.map(aStudy => {
return {
accession: aStudy.AccessionNumber,
date: aStudy.StudyDate,
description: aStudy.StudyDescription,
instances: aStudy.NumInstances,
modalities: aStudy.Modalities,
mrn: aStudy.PatientID,
patientName: aStudy.PatientName,
studyInstanceUid: aStudy.StudyInstanceUID,
NumInstances: aStudy.NumInstances,
time: aStudy.StudyTime,
};
});
},
processResults: () => {
console.warn(' DICOMJson QUERY processResults not implemented');
},
},
series: {
// mapParams: mapParams.bind(),
search: () => {
console.warn(' DICOMJson QUERY SERIES SEARCH not implemented');
},
},
instances: {
search: () => {
console.warn(' DICOMJson QUERY instances SEARCH not implemented');
},
},
},
retrieve: {
/**
* Generates a URL that can be used for direct retrieve of the bulkdata
*
* @param {object} params
* @param {string} params.tag is the tag name of the URL to retrieve
* @param {string} params.defaultPath path for the pixel data url
* @param {object} params.instance is the instance object that the tag is in
* @param {string} params.defaultType is the mime type of the response
* @param {string} params.singlepart is the type of the part to retrieve
* @param {string} params.fetchPart unknown?
* @returns an absolute URL to the resource, if the absolute URL can be retrieved as singlepart,
* or is already retrieved, or a promise to a URL for such use if a BulkDataURI
*/
directURL: params => {
return getDirectURL(dicomJsonConfig, params);
},
series: {
metadata: async ({ filters, StudyInstanceUID, madeInClient = false, customSort } = {}) => {
if (!StudyInstanceUID) {
throw new Error('Unable to query for SeriesMetadata without StudyInstanceUID');
}
const study = findStudies('StudyInstanceUID', StudyInstanceUID)[0];
let series;
if (customSort) {
series = customSort(study.series);
} else {
series = study.series;
}
const seriesKeys = [
'SeriesInstanceUID',
'SeriesInstanceUIDs',
'seriesInstanceUID',
'seriesInstanceUIDs',
];
const seriesFilter = seriesKeys.find(key => filters[key]);
if (seriesFilter) {
const seriesUIDs = filters[seriesFilter];
series = series.filter(s => seriesUIDs.includes(s.SeriesInstanceUID));
}
const seriesSummaryMetadata = series.map(series => {
const seriesSummary = {
StudyInstanceUID: study.StudyInstanceUID,
...series,
};
delete seriesSummary.instances;
return seriesSummary;
});
// Async load series, store as retrieved
function storeInstances(naturalizedInstances) {
DicomMetadataStore.addInstances(naturalizedInstances, madeInClient);
}
DicomMetadataStore.addSeriesMetadata(seriesSummaryMetadata, madeInClient);
function setSuccessFlag() {
const study = DicomMetadataStore.getStudy(StudyInstanceUID, madeInClient);
study.isLoaded = true;
}
const numberOfSeries = series.length;
series.forEach((series, index) => {
const instances = series.instances.map(instance => {
// for instance.metadata if the key ends with sequence then
// we need to add a proxy to the first item in the sequence
// so that we can access the value of the sequence
// by using sequenceName.value
const modifiedMetadata = wrapSequences(instance.metadata);
const obj = {
...modifiedMetadata,
url: instance.url,
imageId: getImageId({ instance, config: dicomJsonConfig }),
...series,
...study,
};
delete obj.instances;
delete obj.series;
return obj;
});
storeInstances(instances);
if (index === numberOfSeries - 1) {
setSuccessFlag();
}
});
},
},
},
store: {
dicom: () => {
console.warn(' DICOMJson store dicom not implemented');
},
},
getImageIdsForDisplaySet(displaySet) {
const images = displaySet.images;
const imageIds = [];
if (!images) {
return imageIds;
}
const { StudyInstanceUID, SeriesInstanceUID } = displaySet;
const study = findStudies('StudyInstanceUID', StudyInstanceUID)[0];
const series = study.series.find(s => s.SeriesInstanceUID === SeriesInstanceUID) || [];
const instanceMap = new Map();
series.instances.forEach(instance => {
if (instance?.metadata?.SOPInstanceUID) {
const { metadata, url } = instance;
const existingInstances = instanceMap.get(metadata.SOPInstanceUID) || [];
existingInstances.push({ ...metadata, url });
instanceMap.set(metadata.SOPInstanceUID, existingInstances);
}
});
displaySet.images.forEach(instance => {
const NumberOfFrames = instance.NumberOfFrames || 1;
const instances = instanceMap.get(instance.SOPInstanceUID) || [instance];
for (let i = 0; i < NumberOfFrames; i++) {
const imageId = getImageId({
instance: instances[Math.min(i, instances.length - 1)],
frame: NumberOfFrames > 1 ? i : undefined,
config: dicomJsonConfig,
});
imageIds.push(imageId);
}
});
return imageIds;
},
getImageIdsForInstance({ instance, frame }) {
const imageIds = getImageId({ instance, frame });
return imageIds;
},
getStudyInstanceUIDs: ({ params, query }) => {
const url = query.get('url');
return _store.studyInstanceUIDMap.get(url);
},
};
return IWebApiDataSource.create(implementation);
}
export { createDicomJSONApi };

View File

@@ -0,0 +1,260 @@
import { DicomMetadataStore, IWebApiDataSource, utils } from '@ohif/core';
import OHIF from '@ohif/core';
import dcmjs from 'dcmjs';
const metadataProvider = OHIF.classes.MetadataProvider;
const { EVENTS } = DicomMetadataStore;
const END_MODALITIES = {
SR: true,
SEG: true,
DOC: true,
};
const compareValue = (v1, v2, def = 0) => {
if (v1 === v2) {
return def;
}
if (v1 < v2) {
return -1;
}
return 1;
};
// Sorting SR modalities to be at the end of series list
const customSort = (seriesA, seriesB) => {
const instanceA = seriesA.instances[0];
const instanceB = seriesB.instances[0];
const modalityA = instanceA.Modality;
const modalityB = instanceB.Modality;
const isEndA = END_MODALITIES[modalityA];
const isEndB = END_MODALITIES[modalityB];
if (isEndA && isEndB) {
// Compare by series date
return compareValue(instanceA.SeriesNumber, instanceB.SeriesNumber);
}
if (!isEndA && !isEndB) {
return compareValue(instanceB.SeriesNumber, instanceA.SeriesNumber);
}
return isEndA ? -1 : 1;
};
function createDicomLocalApi(dicomLocalConfig) {
const { name } = dicomLocalConfig;
const implementation = {
initialize: ({ params, query }) => {},
query: {
studies: {
mapParams: () => {},
search: params => {
const studyUIDs = DicomMetadataStore.getStudyInstanceUIDs();
return studyUIDs.map(StudyInstanceUID => {
let numInstances = 0;
const modalities = new Set();
// Calculating the number of instances in the study and modalities
// present in the study
const study = DicomMetadataStore.getStudy(StudyInstanceUID);
study.series.forEach(aSeries => {
numInstances += aSeries.instances.length;
modalities.add(aSeries.instances[0].Modality);
});
// first instance in the first series
const firstInstance = study?.series[0]?.instances[0];
if (firstInstance) {
return {
accession: firstInstance.AccessionNumber,
date: firstInstance.StudyDate,
description: firstInstance.StudyDescription,
mrn: firstInstance.PatientID,
patientName: utils.formatPN(firstInstance.PatientName),
studyInstanceUid: firstInstance.StudyInstanceUID,
time: firstInstance.StudyTime,
//
instances: numInstances,
modalities: Array.from(modalities).join('/'),
NumInstances: numInstances,
};
}
});
},
processResults: () => {
console.warn(' DICOMLocal QUERY processResults not implemented');
},
},
series: {
search: studyInstanceUID => {
const study = DicomMetadataStore.getStudy(studyInstanceUID);
return study.series.map(aSeries => {
const firstInstance = aSeries?.instances[0];
return {
studyInstanceUid: studyInstanceUID,
seriesInstanceUid: firstInstance.SeriesInstanceUID,
modality: firstInstance.Modality,
seriesNumber: firstInstance.SeriesNumber,
seriesDate: firstInstance.SeriesDate,
numSeriesInstances: aSeries.instances.length,
description: firstInstance.SeriesDescription,
};
});
},
},
instances: {
search: () => {
console.warn(' DICOMLocal QUERY instances SEARCH not implemented');
},
},
},
retrieve: {
directURL: params => {
const { instance, tag, defaultType } = params;
const value = instance[tag];
if (value instanceof Array && value[0] instanceof ArrayBuffer) {
return URL.createObjectURL(
new Blob([value[0]], {
type: defaultType,
})
);
}
},
series: {
metadata: async ({ StudyInstanceUID, madeInClient = false } = {}) => {
if (!StudyInstanceUID) {
throw new Error('Unable to query for SeriesMetadata without StudyInstanceUID');
}
// Instances metadata already added via local upload
const study = DicomMetadataStore.getStudy(StudyInstanceUID, madeInClient);
// Series metadata already added via local upload
DicomMetadataStore._broadcastEvent(EVENTS.SERIES_ADDED, {
StudyInstanceUID,
madeInClient,
});
study.series.forEach(aSeries => {
const { SeriesInstanceUID } = aSeries;
const isMultiframe = aSeries.instances[0].NumberOfFrames > 1;
aSeries.instances.forEach((instance, index) => {
const {
url: imageId,
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID,
} = instance;
instance.imageId = imageId;
// Add imageId specific mapping to this data as the URL isn't necessarily WADO-URI.
metadataProvider.addImageIdToUIDs(imageId, {
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID,
frameIndex: isMultiframe ? index : 1,
});
});
DicomMetadataStore._broadcastEvent(EVENTS.INSTANCES_ADDED, {
StudyInstanceUID,
SeriesInstanceUID,
madeInClient,
});
});
},
},
},
store: {
dicom: naturalizedReport => {
const reportBlob = dcmjs.data.datasetToBlob(naturalizedReport);
//Create a URL for the binary.
var objectUrl = URL.createObjectURL(reportBlob);
window.location.assign(objectUrl);
},
},
getImageIdsForDisplaySet(displaySet) {
const images = displaySet.images;
const imageIds = [];
if (!images) {
return imageIds;
}
displaySet.images.forEach(instance => {
const NumberOfFrames = instance.NumberOfFrames;
if (NumberOfFrames > 1) {
// in multiframe we start at frame 1
for (let i = 1; i <= NumberOfFrames; i++) {
const imageId = this.getImageIdsForInstance({
instance,
frame: i,
});
imageIds.push(imageId);
}
} else {
const imageId = this.getImageIdsForInstance({ instance });
imageIds.push(imageId);
}
});
return imageIds;
},
getImageIdsForInstance({ instance, frame }) {
if (instance.imageId) {
return instance.imageId;
}
const { StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID } = instance;
const storedInstance = DicomMetadataStore.getInstance(
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID
);
let imageId = storedInstance.url;
if (frame !== undefined) {
imageId += `&frame=${frame}`;
}
return imageId;
},
deleteStudyMetadataPromise() {
console.log('deleteStudyMetadataPromise not implemented');
},
getStudyInstanceUIDs: ({ params, query }) => {
const { StudyInstanceUIDs: paramsStudyInstanceUIDs } = params;
const queryStudyInstanceUIDs = query.getAll('StudyInstanceUIDs');
const StudyInstanceUIDs = queryStudyInstanceUIDs || paramsStudyInstanceUIDs;
const StudyInstanceUIDsAsArray =
StudyInstanceUIDs && Array.isArray(StudyInstanceUIDs)
? StudyInstanceUIDs
: [StudyInstanceUIDs];
// Put SRs at the end of series list to make sure images are loaded first
let isStudyInCache = false;
StudyInstanceUIDsAsArray.forEach(StudyInstanceUID => {
const study = DicomMetadataStore.getStudy(StudyInstanceUID);
if (study) {
study.series = study.series.sort(customSort);
isStudyInCache = true;
}
});
return isStudyInCache ? StudyInstanceUIDsAsArray : [];
},
};
return IWebApiDataSource.create(implementation);
}
export { createDicomLocalApi };

View File

@@ -0,0 +1,53 @@
.dicom-tag-browser-table {
margin-right: auto;
margin-left: auto;
}
.dicom-tag-browser-table-wrapper {
/* height: 500px;*/
/*overflow-y: scroll;*/
overflow-x: scroll;
}
.dicom-tag-browser-table tr {
padding-left: 10px;
padding-right: 10px;
color: #ffffff;
border-top: 1px solid #ddd;
white-space: nowrap;
}
.stick {
position: sticky;
overflow: clip;
}
.dicom-tag-browser-content {
overflow: hidden;
width: 100%;
padding-bottom: 50px;
/*height: 500px;*/
}
.dicom-tag-browser-instance-range .range {
height: 20px;
}
.dicom-tag-browser-instance-range {
padding: 20px 0 20px 0;
}
.dicom-tag-browser-table td.dicom-tag-browser-table-center {
text-align: center;
}
.dicom-tag-browser-table th {
padding-left: 10px;
padding-right: 10px;
text-align: center;
color: '#20A5D6';
}
.dicom-tag-browser-table th.dicom-tag-browser-table-left {
text-align: left;
}

View File

@@ -0,0 +1,356 @@
import dcmjs from 'dcmjs';
import moment from 'moment';
import React, { useState, useMemo, useEffect } from 'react';
import { classes } from '@ohif/core';
import { InputFilterText } from '@ohif/ui';
import debounce from 'lodash.debounce';
import { Select, SelectTrigger, SelectContent, SelectItem, Slider } from '@ohif/ui-next';
import DicomTagTable from './DicomTagTable';
import './DicomTagBrowser.css';
const { ImageSet } = classes;
const { DicomMetaDictionary } = dcmjs.data;
const { nameMap } = DicomMetaDictionary;
const DicomTagBrowser = ({ displaySets, displaySetInstanceUID }) => {
// The column indices that are to be excluded during a filter of the table.
// At present the column indices are:
// 0: DICOM tag
// 1: VR
// 2: Keyword
// 3: Value
const excludedColumnIndicesForFilter: Set<number> = new Set([1]);
const [selectedDisplaySetInstanceUID, setSelectedDisplaySetInstanceUID] =
useState(displaySetInstanceUID);
const [instanceNumber, setInstanceNumber] = useState(1);
const [filterValue, setFilterValue] = useState('');
const onSelectChange = value => {
setSelectedDisplaySetInstanceUID(value.value);
setInstanceNumber(1);
};
const activeDisplaySet = displaySets.find(
ds => ds.displaySetInstanceUID === selectedDisplaySetInstanceUID
);
const isImageStack = _isImageStack(activeDisplaySet);
const showInstanceList = isImageStack && activeDisplaySet.images.length > 1;
const displaySetList = useMemo(() => {
displaySets.sort((a, b) => a.SeriesNumber - b.SeriesNumber);
return displaySets.map(displaySet => {
const {
displaySetInstanceUID,
SeriesDate,
SeriesTime,
SeriesNumber,
SeriesDescription,
Modality,
} = displaySet;
/* Map to display representation */
const dateStr = `${SeriesDate}:${SeriesTime}`.split('.')[0];
const date = moment(dateStr, 'YYYYMMDD:HHmmss');
const displayDate = date.format('ddd, MMM Do YYYY');
return {
value: displaySetInstanceUID,
label: `${SeriesNumber} (${Modality}): ${SeriesDescription}`,
description: displayDate,
};
});
}, [displaySets]);
const rows = useMemo(() => {
let metadata;
if (isImageStack) {
metadata = activeDisplaySet.images[instanceNumber - 1];
} else {
metadata = activeDisplaySet.instance || activeDisplaySet;
}
const tags = getSortedTags(metadata);
return getFormattedRowsFromTags(tags, metadata);
}, [instanceNumber, selectedDisplaySetInstanceUID]);
const filteredRows = useMemo(() => {
if (!filterValue) {
return rows;
}
const filterValueLowerCase = filterValue.toLowerCase();
return rows.filter(row => {
return row.reduce((keepRow, col, colIndex) => {
if (keepRow) {
// We are already keeping the row, why do more work so return now.
return keepRow;
}
if (excludedColumnIndicesForFilter.has(colIndex)) {
return keepRow;
}
return keepRow || col.toLowerCase().includes(filterValueLowerCase);
}, false);
});
}, [rows, filterValue]);
const debouncedSetFilterValue = useMemo(() => {
return debounce(setFilterValue, 200);
}, []);
useEffect(() => {
return () => {
debouncedSetFilterValue?.cancel();
};
}, []);
return (
<div className="dicom-tag-browser-content bg-muted">
<div className="mb-6 flex flex-row items-start pl-1">
<div className="flex w-full flex-row items-start gap-4">
<div className="flex w-1/3 flex-col">
<span className="text-muted-foreground flex h-6 items-center text-xs">Series</span>
<Select
value={selectedDisplaySetInstanceUID}
onValueChange={value => onSelectChange({ value })}
>
<SelectTrigger>
{displaySetList.find(ds => ds.value === selectedDisplaySetInstanceUID)?.label ||
'Select Series'}
</SelectTrigger>
<SelectContent>
{displaySetList.map(item => {
return (
<SelectItem
key={item.value}
value={item.value}
>
{item.label}
<span className="text-muted-foreground ml-1 text-xs">{item.description}</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
{showInstanceList && (
<div className="mx-auto flex w-1/5 flex-col">
<span className="text-muted-foreground flex h-6 items-center text-xs">
Instance Number ({instanceNumber} of {activeDisplaySet.images.length})
</span>
<Slider
value={[instanceNumber]}
onValueChange={([value]) => {
setInstanceNumber(value);
}}
min={1}
max={activeDisplaySet.images.length}
step={1}
className="pt-4"
/>
</div>
)}
<div className="ml-auto flex w-1/3 flex-col">
<span className="text-muted-foreground flex h-6 items-center text-xs">
Search metadata
</span>
<InputFilterText
placeholder="Search metadata..."
onDebounceChange={setFilterValue}
/>
</div>
</div>
</div>
<DicomTagTable rows={filteredRows} />
</div>
);
};
function getFormattedRowsFromTags(tags, metadata) {
const rows = [];
tags.forEach(tagInfo => {
if (tagInfo.vr === 'SQ') {
rows.push([`${tagInfo.tagIndent}${tagInfo.tag}`, tagInfo.vr, tagInfo.keyword, '']);
const { values } = tagInfo;
values.forEach((item, index) => {
const formatedRowsFromTags = getFormattedRowsFromTags(item, metadata);
rows.push([`${item[0].tagIndent}(FFFE,E000)`, '', `Item #${index}`, '']);
rows.push(...formatedRowsFromTags);
});
} else {
if (tagInfo.vr === 'xs') {
try {
const tag = dcmjs.data.Tag.fromPString(tagInfo.tag).toCleanString();
const originalTagInfo = metadata[tag];
tagInfo.vr = originalTagInfo.vr;
} catch (error) {
console.error(`Failed to parse value representation for tag '${tagInfo.keyword}'`);
}
}
rows.push([`${tagInfo.tagIndent}${tagInfo.tag}`, tagInfo.vr, tagInfo.keyword, tagInfo.value]);
}
});
return rows;
}
function getSortedTags(metadata) {
const tagList = getRows(metadata);
// Sort top level tags, sequence groups are sorted when created.
_sortTagList(tagList);
return tagList;
}
function getRows(metadata, depth = 0) {
// Tag, Type, Value, Keyword
const keywords = Object.keys(metadata);
let tagIndent = '';
for (let i = 0; i < depth; i++) {
tagIndent += '>';
}
if (depth > 0) {
tagIndent += ' '; // If indented, add a space after the indents.
}
const rows = [];
for (let i = 0; i < keywords.length; i++) {
let keyword = keywords[i];
if (keyword === '_vrMap') {
continue;
}
const tagInfo = nameMap[keyword];
let value = metadata[keyword];
if (tagInfo && tagInfo.vr === 'SQ') {
const sequenceAsArray = toArray(value);
// Push line defining the sequence
const sequence = {
tag: tagInfo.tag,
tagIndent,
vr: tagInfo.vr,
keyword,
values: [],
};
rows.push(sequence);
if (value === null) {
// Type 2 Sequence
continue;
}
sequenceAsArray.forEach(item => {
const sequenceRows = getRows(item, depth + 1);
if (sequenceRows.length) {
// Sort the sequence group.
_sortTagList(sequenceRows);
sequence.values.push(sequenceRows);
}
});
continue;
}
if (Array.isArray(value)) {
if (value.length > 0 && typeof value[0] != 'object') {
value = value.join('\\');
}
}
if (typeof value === 'number') {
value = value.toString();
}
if (typeof value !== 'string') {
if (value === null) {
value = ' ';
} else {
if (typeof value === 'object') {
if (value.InlineBinary) {
value = 'Inline Binary';
} else if (value.BulkDataURI) {
value = `Bulk Data URI`; //: ${value.BulkDataURI}`;
} else if (value.Alphabetic) {
value = value.Alphabetic;
} else {
console.warn(`Unrecognised Value: ${value} for ${keyword}:`);
console.warn(value);
value = ' ';
}
} else {
console.warn(`Unrecognised Value: ${value} for ${keyword}:`);
value = ' ';
}
}
}
// tag / vr/ keyword/ value
// Remove retired tags
keyword = keyword.replace('RETIRED_', '');
if (tagInfo) {
rows.push({
tag: tagInfo.tag,
tagIndent,
vr: tagInfo.vr,
keyword,
value,
});
} else {
// skip properties without hex tag numbers
const regex = /[0-9A-Fa-f]{6}/g;
if (keyword.match(regex)) {
const tag = `(${keyword.substring(0, 4)},${keyword.substring(4, 8)})`;
rows.push({
tag,
tagIndent,
vr: '',
keyword: 'Private Tag',
value,
});
}
}
}
return rows;
}
function _isImageStack(displaySet) {
return displaySet instanceof ImageSet;
}
function toArray(objectOrArray) {
return Array.isArray(objectOrArray) ? objectOrArray : [objectOrArray];
}
function _sortTagList(tagList) {
tagList.sort((a, b) => {
if (a.tag < b.tag) {
return -1;
}
return 1;
});
}
export default DicomTagBrowser;

View File

@@ -0,0 +1,210 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { VariableSizeList as List } from 'react-window';
import classNames from 'classnames';
import debounce from 'lodash.debounce';
const lineHeightPx = 20;
const lineHeightClassName = `leading-[${lineHeightPx}px]`;
const rowVerticalPaddingPx = 10;
const rowBottomBorderPx = 1;
const rowVerticalPaddingStyle = { padding: `${rowVerticalPaddingPx}px 0` };
const rowStyle = {
borderBottomWidth: `${rowBottomBorderPx}px`,
...rowVerticalPaddingStyle,
};
function ColumnHeaders({ tagRef, vrRef, keywordRef, valueRef }) {
return (
<div
className={classNames(
'bg-secondary-dark ohif-scrollbar flex w-full flex-row overflow-y-scroll'
)}
style={rowVerticalPaddingStyle}
>
<div className="w-4/24 px-3">
<label
ref={tagRef}
className="flex flex-1 select-none flex-col pl-1 text-lg text-white"
>
<span className="flex flex-row items-center focus:outline-none">Tag</span>
</label>
</div>
<div className="w-2/24 px-3">
<label
ref={vrRef}
className="flex flex-1 select-none flex-col pl-1 text-lg text-white"
>
<span className="flex flex-row items-center focus:outline-none">VR</span>
</label>
</div>
<div className="w-6/24 px-3">
<label
ref={keywordRef}
className="flex flex-1 select-none flex-col pl-1 text-lg text-white"
>
<span className="flex flex-row items-center focus:outline-none">Keyword</span>
</label>
</div>
<div className="w-5/24 grow px-3">
<label
ref={valueRef}
className="flex flex-1 select-none flex-col pl-1 text-lg text-white"
>
<span className="flex flex-row items-center focus:outline-none">Value</span>
</label>
</div>
</div>
);
}
function DicomTagTable({ rows }) {
const listRef = useRef();
const canvasRef = useRef();
const [tagHeaderElem, setTagHeaderElem] = useState(null);
const [vrHeaderElem, setVrHeaderElem] = useState(null);
const [keywordHeaderElem, setKeywordHeaderElem] = useState(null);
const [valueHeaderElem, setValueHeaderElem] = useState(null);
// Here the refs are inturn stored in state to trigger a render of the table.
// This virtualized table does NOT render until the header is rendered because the header column widths are used to determine the row heights in the table.
// Therefore whenever the refs change (in particular the first time the refs are set), we want to trigger a render of the table.
const tagRef = elem => {
if (elem) {
setTagHeaderElem(elem);
}
};
const vrRef = elem => {
if (elem) {
setVrHeaderElem(elem);
}
};
const keywordRef = elem => {
if (elem) {
setKeywordHeaderElem(elem);
}
};
const valueRef = elem => {
if (elem) {
setValueHeaderElem(elem);
}
};
/**
* When new rows are set, scroll to the top and reset the virtualization.
*/
useEffect(() => {
if (!listRef?.current) {
return;
}
listRef.current.scrollTo(0);
listRef.current.resetAfterIndex(0);
}, [rows]);
/**
* When the browser window resizes, update the row virtualization (i.e. row heights)
*/
useEffect(() => {
const debouncedResize = debounce(() => listRef.current.resetAfterIndex(0), 100);
window.addEventListener('resize', debouncedResize);
return () => {
debouncedResize.cancel();
window.removeEventListener('resize', debouncedResize);
};
}, []);
const Row = useCallback(
({ index, style }) => {
const row = rows[index];
return (
<div
style={{ ...style, ...rowStyle }}
className={classNames(
'hover:bg-secondary-main border-secondary-light flex w-full flex-row items-center break-all bg-black text-base transition duration-300',
lineHeightClassName
)}
key={`DICOMTagRow-${index}`}
>
<div className="w-4/24 px-3">{row[0]}</div>
<div className="w-2/24 px-3">{row[1]}</div>
<div className="w-6/24 px-3">{row[2]}</div>
<div className="w-5/24 grow px-3">{row[3]}</div>
</div>
);
},
[rows]
);
/**
* Whenever any one of the column headers is set, then the header is rendered.
* Here we chose the tag header.
*/
const isHeaderRendered = useCallback(() => tagHeaderElem !== null, [tagHeaderElem]);
/**
* Get the item/row size. We use the header column widths to calculate the various row heights.
* @param index the row index
* @returns the row height
*/
const getItemSize = useCallback(
index => {
const headerWidths = [
tagHeaderElem.offsetWidth,
vrHeaderElem.offsetWidth,
keywordHeaderElem.offsetWidth,
valueHeaderElem.offsetWidth,
];
const context = canvasRef.current.getContext('2d');
context.font = getComputedStyle(canvasRef.current).font;
return rows[index]
.map((colText, index) => {
const colOneLineWidth = context.measureText(colText).width;
const numLines = Math.ceil(colOneLineWidth / headerWidths[index]);
return numLines * lineHeightPx + 2 * rowVerticalPaddingPx + rowBottomBorderPx;
})
.reduce((maxHeight, colHeight) => Math.max(maxHeight, colHeight));
},
[rows, keywordHeaderElem, tagHeaderElem, valueHeaderElem, vrHeaderElem]
);
return (
<div>
<canvas
style={{ visibility: 'hidden', position: 'absolute' }}
className="text-base"
ref={canvasRef}
/>
<ColumnHeaders
tagRef={tagRef}
vrRef={vrRef}
keywordRef={keywordRef}
valueRef={valueRef}
/>
<div
className="relative m-auto border-2 border-black bg-black"
style={{ height: '32rem' }}
>
{isHeaderRendered() && (
<List
ref={listRef}
height={500}
itemCount={rows.length}
itemSize={getItemSize}
width={'100%'}
className="ohif-scrollbar"
>
{Row}
</List>
)}
</div>
</div>
);
}
export default DicomTagTable;

View File

@@ -0,0 +1,35 @@
export default function (wadoRoot) {
return {
series: (StudyInstanceUID, SeriesInstanceUID) => {
return new Promise((resolve, reject) => {
// Reject because of Quality. (Seems the most sensible out of the options)
const CodeValueAndCodeSchemeDesignator = `113001%5EDCM`;
const url = `${wadoRoot}/studies/${StudyInstanceUID}/series/${SeriesInstanceUID}/reject/${CodeValueAndCodeSchemeDesignator}`;
const xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
//Send the proper header information along with the request
// TODO -> Auth when we re-add authorization.
console.log(xhr);
xhr.onreadystatechange = function () {
//Call a function when the state changes.
if (xhr.readyState == 4) {
switch (xhr.status) {
case 204:
resolve(xhr.responseText);
break;
case 404:
reject('Your dataSource does not support reject functionality');
}
}
};
xhr.send();
});
},
};
}

View File

@@ -0,0 +1,280 @@
export default [
{
'00080005': { vr: 'CS', Value: ['ISO_IR 100'] },
'00080008': { vr: 'CS', Value: ['ORIGINAL', 'PRIMARY', 'LOCALIZER'] },
'00080016': { vr: 'UI', Value: ['1.2.840.10008.5.1.4.1.1.2'] },
'00080018': {
vr: 'UI',
Value: ['1.3.6.1.4.1.25403.345050719074.3824.20170126082902.6'],
},
'00080020': { vr: 'DA', Value: ['20141125'] },
'00080021': { vr: 'DA', Value: ['20141125'] },
'00080022': { vr: 'DA', Value: ['20141125'] },
'00080023': { vr: 'DA', Value: ['20141125'] },
'00080030': { vr: 'TM', Value: ['094528.000'] },
'00080031': { vr: 'TM', Value: ['094604.688'] },
'00080032': { vr: 'TM', Value: ['094623.600'] },
'00080033': { vr: 'TM', Value: ['094623.600'] },
'00080050': { vr: 'SH', Value: ['000092218'] },
'00080060': { vr: 'CS', Value: ['CT'] },
'00080070': { vr: 'LO', Value: ['TOSHIBA'] },
'00080080': { vr: 'LO', Value: ['Precision Imaging Metrics'] },
'00080090': { vr: 'PN' },
'00081010': { vr: 'SH' },
'00081030': { vr: 'LO', Value: ['DFCI CT CHEST W CONTRAST 6023'] },
'00081032': {
vr: 'SQ',
Value: [
{
'00080100': { vr: 'SH', Value: ['6023'] },
'00080102': { vr: 'SH', Value: ['GEIIS'] },
'00080103': { vr: 'SH', Value: ['0'] },
'00080104': { vr: 'LO', Value: ['DFCI CT CHEST W CONTRAST 6023'] },
},
],
},
'0008103E': { vr: 'LO', Value: ['2.0'] },
'00081040': { vr: 'LO' },
'00081070': { vr: 'PN' },
'00081090': { vr: 'LO', Value: ['Aquilion'] },
'00081110': {
vr: 'SQ',
Value: [
{
'00081150': { vr: 'UI', Value: ['1.2.840.100008.3.1.2.3.1'] },
'00081155': {
vr: 'UI',
Value: ['1.3.6.1.4.1.25403.345050719074.3824.20170126082902.4'],
},
},
],
},
'00100010': { vr: 'PN', Value: [{ Alphabetic: 'Venus' }] },
'00100020': { vr: 'LO', Value: ['0000005'] },
'00100021': { vr: 'LO', Value: ['001R74:20050625:205502036:195212'] },
'00100030': { vr: 'DA' },
'00100040': { vr: 'CS', Value: ['F'] },
'00101000': { vr: 'LO' },
'00101010': { vr: 'AS' },
'00101020': { vr: 'DS' },
'00101030': { vr: 'DS' },
'00104000': { vr: 'LT' },
'00180015': { vr: 'CS', Value: ['CHEST_TO_PELVIS'] },
'00180022': { vr: 'CS', Value: ['SCANOSCOPE'] },
'00180050': { vr: 'DS', Value: [2.0] },
'00180060': { vr: 'DS', Value: [120.0] },
'00180090': { vr: 'DS', Value: [1000.0] },
'00181000': { vr: 'LO' },
'00181020': { vr: 'LO', Value: ['V4.86ER003'] },
'00181030': { vr: 'LO', Value: ['Chest / Abdomen/Pelvis 5mm'] },
'00181100': { vr: 'DS', Value: [1000.0] },
'00181120': { vr: 'DS', Value: [0.0] },
'00181130': { vr: 'DS', Value: [102.0] },
'00181140': { vr: 'CS', Value: ['CW'] },
'00181150': { vr: 'IS', Value: [6840] },
'00181151': { vr: 'IS', Value: [100] },
'00181152': { vr: 'IS', Value: [600] },
'00181160': { vr: 'SH', Value: ['LARGE'] },
'00181170': { vr: 'IS', Value: [12] },
'00181190': { vr: 'DS', Value: [1.6, 1.4] },
'00181210': { vr: 'SH', Value: ['FL03'] },
'00185100': { vr: 'CS', Value: ['FFS'] },
'0020000D': {
vr: 'UI',
Value: ['1.3.6.1.4.1.25403.345050719074.3824.20170126082902.1'],
},
'0020000E': {
vr: 'UI',
Value: ['1.3.6.1.4.1.25403.345050719074.3824.20170126082902.2'],
},
'00200010': { vr: 'SH' },
'00200011': { vr: 'IS', Value: [1] },
'00200012': { vr: 'IS', Value: [2] },
'00200013': { vr: 'IS', Value: [2] },
'00200020': { vr: 'CS', Value: ['F', 'P'] },
'00200032': { vr: 'DS', Value: [-1.7e-4, -512.0, 1925.0] },
'00200037': { vr: 'DS', Value: [0.0, 0.0, -1.0, 0.0, 1.0, -0.0] },
'00200052': {
vr: 'UI',
Value: ['1.3.6.1.4.1.25403.345050719074.3824.20170126082902.5'],
},
'00201040': { vr: 'LO' },
'00201041': { vr: 'DS', Value: [342.0] },
'00280002': { vr: 'US', Value: [1] },
'00280004': { vr: 'CS', Value: ['MONOCHROME2'] },
'00280010': { vr: 'US', Value: [512] },
'00280011': { vr: 'US', Value: [512] },
'00280030': { vr: 'DS', Value: [2.0, 2.0] },
'00280100': { vr: 'US', Value: [16] },
'00280101': { vr: 'US', Value: [16] },
'00280102': { vr: 'US', Value: [15] },
'00280103': { vr: 'US', Value: [1] },
'00281050': { vr: 'DS', Value: [110.0] },
'00281051': { vr: 'DS', Value: [320.0] },
'00281052': { vr: 'DS', Value: [0.0] },
'00281053': { vr: 'DS', Value: [1.0] },
'00321033': { vr: 'LO', Value: ['OUTDFRAD'] },
'00400002': { vr: 'DA', Value: ['20141125'] },
'00400003': { vr: 'TM', Value: ['091000'] },
'00400004': { vr: 'DA', Value: ['20141125'] },
'00400005': { vr: 'TM', Value: ['094000.000'] },
'00400244': { vr: 'DA', Value: ['20141125'] },
'00400245': { vr: 'TM', Value: ['094528.000'] },
'00400253': { vr: 'SH', Value: ['3708'] },
'00400260': {
vr: 'SQ',
Value: [
{
'00080100': { vr: 'SH', Value: ['6035'] },
'00080102': { vr: 'SH', Value: ['CCG_CSTemp'] },
'00080104': { vr: 'LO', Value: ['6035/DFCT2 CT 3-SITES W/OC'] },
},
],
},
'00402017': { vr: 'LO', Value: ['14159097'] },
'7FE00010': {
vr: 'OW',
BulkDataURI:
'http://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs/studies/1.3.6.1.4.1.25403.345050719074.3824.20170126082902.1/series/1.3.6.1.4.1.25403.345050719074.3824.20170126082902.2/instances/1.3.6.1.4.1.25403.345050719074.3824.20170126082902.6',
},
},
{
'00080005': { vr: 'CS', Value: ['ISO_IR 100'] },
'00080008': { vr: 'CS', Value: ['ORIGINAL', 'PRIMARY', 'LOCALIZER'] },
'00080016': { vr: 'UI', Value: ['1.2.840.10008.5.1.4.1.1.2'] },
'00080018': {
vr: 'UI',
Value: ['1.3.6.1.4.1.25403.345050719074.3824.20170126082902.3'],
},
'00080020': { vr: 'DA', Value: ['20141125'] },
'00080021': { vr: 'DA', Value: ['20141125'] },
'00080022': { vr: 'DA', Value: ['20141125'] },
'00080023': { vr: 'DA', Value: ['20141125'] },
'00080030': { vr: 'TM', Value: ['094528.000'] },
'00080031': { vr: 'TM', Value: ['094604.688'] },
'00080032': { vr: 'TM', Value: ['094557.250'] },
'00080033': { vr: 'TM', Value: ['094557.250'] },
'00080050': { vr: 'SH', Value: ['000092218'] },
'00080060': { vr: 'CS', Value: ['CT'] },
'00080070': { vr: 'LO', Value: ['TOSHIBA'] },
'00080080': { vr: 'LO', Value: ['Precision Imaging Metrics'] },
'00080090': { vr: 'PN' },
'00081010': { vr: 'SH' },
'00081030': { vr: 'LO', Value: ['DFCI CT CHEST W CONTRAST 6023'] },
'00081032': {
vr: 'SQ',
Value: [
{
'00080100': { vr: 'SH', Value: ['6023'] },
'00080102': { vr: 'SH', Value: ['GEIIS'] },
'00080103': { vr: 'SH', Value: ['0'] },
'00080104': { vr: 'LO', Value: ['DFCI CT CHEST W CONTRAST 6023'] },
},
],
},
'0008103E': { vr: 'LO', Value: ['2.0'] },
'00081040': { vr: 'LO' },
'00081070': { vr: 'PN' },
'00081090': { vr: 'LO', Value: ['Aquilion'] },
'00081110': {
vr: 'SQ',
Value: [
{
'00081150': { vr: 'UI', Value: ['1.2.840.100008.3.1.2.3.1'] },
'00081155': {
vr: 'UI',
Value: ['1.3.6.1.4.1.25403.345050719074.3824.20170126082902.4'],
},
},
],
},
'00100010': { vr: 'PN', Value: [{ Alphabetic: 'Venus' }] },
'00100020': { vr: 'LO', Value: ['0000005'] },
'00100021': { vr: 'LO', Value: ['001R74:20050625:205502036:195212'] },
'00100030': { vr: 'DA' },
'00100040': { vr: 'CS', Value: ['F'] },
'00101000': { vr: 'LO' },
'00101010': { vr: 'AS' },
'00101020': { vr: 'DS' },
'00101030': { vr: 'DS' },
'00104000': { vr: 'LT' },
'00180015': { vr: 'CS', Value: ['CHEST_TO_PELVIS'] },
'00180022': { vr: 'CS', Value: ['SCANOSCOPE'] },
'00180050': { vr: 'DS', Value: [2.0] },
'00180060': { vr: 'DS', Value: [120.0] },
'00180090': { vr: 'DS', Value: [1000.0] },
'00181000': { vr: 'LO' },
'00181020': { vr: 'LO', Value: ['V4.86ER003'] },
'00181030': { vr: 'LO', Value: ['Chest / Abdomen/Pelvis 5mm'] },
'00181100': { vr: 'DS', Value: [1000.0] },
'00181120': { vr: 'DS', Value: [0.0] },
'00181130': { vr: 'DS', Value: [102.0] },
'00181140': { vr: 'CS', Value: ['CW'] },
'00181150': { vr: 'IS', Value: [6857] },
'00181151': { vr: 'IS', Value: [50] },
'00181152': { vr: 'IS', Value: [300] },
'00181160': { vr: 'SH', Value: ['LARGE'] },
'00181170': { vr: 'IS', Value: [6] },
'00181190': { vr: 'DS', Value: [1.6, 1.4] },
'00181210': { vr: 'SH', Value: ['FL03'] },
'00185100': { vr: 'CS', Value: ['FFS'] },
'0020000D': {
vr: 'UI',
Value: ['1.3.6.1.4.1.25403.345050719074.3824.20170126082902.1'],
},
'0020000E': {
vr: 'UI',
Value: ['1.3.6.1.4.1.25403.345050719074.3824.20170126082902.2'],
},
'00200010': { vr: 'SH' },
'00200011': { vr: 'IS', Value: [1] },
'00200012': { vr: 'IS', Value: [1] },
'00200013': { vr: 'IS', Value: [1] },
'00200020': { vr: 'CS', Value: ['L', 'F'] },
'00200032': { vr: 'DS', Value: [-512.0, 1.7e-4, 1925.0] },
'00200037': { vr: 'DS', Value: [1.0, 0.0, 0.0, 0.0, 0.0, -1.0] },
'00200052': {
vr: 'UI',
Value: ['1.3.6.1.4.1.25403.345050719074.3824.20170126082902.5'],
},
'00201040': { vr: 'LO' },
'00201041': { vr: 'DS', Value: [342.0] },
'00280002': { vr: 'US', Value: [1] },
'00280004': { vr: 'CS', Value: ['MONOCHROME2'] },
'00280010': { vr: 'US', Value: [512] },
'00280011': { vr: 'US', Value: [512] },
'00280030': { vr: 'DS', Value: [2.0, 2.0] },
'00280100': { vr: 'US', Value: [16] },
'00280101': { vr: 'US', Value: [16] },
'00280102': { vr: 'US', Value: [15] },
'00280103': { vr: 'US', Value: [1] },
'00281050': { vr: 'DS', Value: [100.0] },
'00281051': { vr: 'DS', Value: [230.0] },
'00281052': { vr: 'DS', Value: [0.0] },
'00281053': { vr: 'DS', Value: [1.0] },
'00321033': { vr: 'LO', Value: ['OUTDFRAD'] },
'00400002': { vr: 'DA', Value: ['20141125'] },
'00400003': { vr: 'TM', Value: ['091000'] },
'00400004': { vr: 'DA', Value: ['20141125'] },
'00400005': { vr: 'TM', Value: ['094000.000'] },
'00400244': { vr: 'DA', Value: ['20141125'] },
'00400245': { vr: 'TM', Value: ['094528.000'] },
'00400253': { vr: 'SH', Value: ['3708'] },
'00400260': {
vr: 'SQ',
Value: [
{
'00080100': { vr: 'SH', Value: ['6035'] },
'00080102': { vr: 'SH', Value: ['CCG_CSTemp'] },
'00080104': { vr: 'LO', Value: ['6035/DFCT2 CT 3-SITES W/OC'] },
},
],
},
'00402017': { vr: 'LO', Value: ['14159097'] },
'7FE00010': {
vr: 'OW',
BulkDataURI:
'http://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs/studies/1.3.6.1.4.1.25403.345050719074.3824.20170126082902.1/series/1.3.6.1.4.1.25403.345050719074.3824.20170126082902.2/instances/1.3.6.1.4.1.25403.345050719074.3824.20170126082902.3',
},
},
];

View File

@@ -0,0 +1,585 @@
import { api } from 'dicomweb-client';
import { DicomMetadataStore, IWebApiDataSource, utils, errorHandler, classes } from '@ohif/core';
import {
mapParams,
search as qidoSearch,
seriesInStudy,
processResults,
processSeriesResults,
} from './qido.js';
import dcm4cheeReject from './dcm4cheeReject.js';
import getImageId from './utils/getImageId.js';
import dcmjs from 'dcmjs';
import { retrieveStudyMetadata, deleteStudyMetadataPromise } from './retrieveStudyMetadata.js';
import StaticWadoClient from './utils/StaticWadoClient';
import getDirectURL from '../utils/getDirectURL';
import { fixBulkDataURI } from './utils/fixBulkDataURI';
const { DicomMetaDictionary, DicomDict } = dcmjs.data;
const { naturalizeDataset, denaturalizeDataset } = DicomMetaDictionary;
const ImplementationClassUID = '2.25.270695996825855179949881587723571202391.2.0.0';
const ImplementationVersionName = 'OHIF-VIEWER-2.0.0';
const EXPLICIT_VR_LITTLE_ENDIAN = '1.2.840.10008.1.2.1';
const metadataProvider = classes.MetadataProvider;
/**
* Creates a DICOM Web API based on the provided configuration.
*
* @param {object} dicomWebConfig - Configuration for the DICOM Web API
* @param {string} dicomWebConfig.name - Data source name
* @param {string} dicomWebConfig.wadoUriRoot - Legacy? (potentially unused/replaced)
* @param {string} dicomWebConfig.qidoRoot - Base URL to use for QIDO requests
* @param {string} dicomWebConfig.wadoRoot - Base URL to use for WADO requests
* @param {string} dicomWebConfig.wadoUri - Base URL to use for WADO URI requests
* @param {boolean} dicomWebConfig.qidoSupportsIncludeField - Whether QIDO supports the "Include" option to request additional fields in response
* @param {string} dicomWebConfig.imageRendering - wadors | ? (unsure of where/how this is used)
* @param {string} dicomWebConfig.thumbnailRendering - wadors | ? (unsure of where/how this is used)
* @param {boolean} dicomWebConfig.supportsReject - Whether the server supports reject calls (i.e. DCM4CHEE)
* @param {boolean} dicomWebConfig.lazyLoadStudy - "enableStudyLazyLoad"; Request series meta async instead of blocking
* @param {string|boolean} dicomWebConfig.singlepart - indicates if the retrieves can fetch singlepart. Options are bulkdata, video, image, or boolean true
* @param {string} dicomWebConfig.requestTransferSyntaxUID - Transfer syntax to request from the server
* @param {object} dicomWebConfig.acceptHeader - Accept header to use for requests
* @param {boolean} dicomWebConfig.omitQuotationForMultipartRequest - Whether to omit quotation marks for multipart requests
* @param {boolean} dicomWebConfig.supportsFuzzyMatching - Whether the server supports fuzzy matching
* @param {boolean} dicomWebConfig.supportsWildcard - Whether the server supports wildcard matching
* @param {boolean} dicomWebConfig.supportsNativeDICOMModel - Whether the server supports the native DICOM model
* @param {boolean} dicomWebConfig.enableStudyLazyLoad - Whether to enable study lazy loading
* @param {boolean} dicomWebConfig.enableRequestTag - Whether to enable request tag
* @param {boolean} dicomWebConfig.enableStudyLazyLoad - Whether to enable study lazy loading
* @param {boolean} dicomWebConfig.bulkDataURI - Whether to enable bulkDataURI
* @param {function} dicomWebConfig.onConfiguration - Function that is called after the configuration is initialized
* @param {boolean} dicomWebConfig.staticWado - Whether to use the static WADO client
* @param {object} userAuthenticationService - User authentication service
* @param {object} userAuthenticationService.getAuthorizationHeader - Function that returns the authorization header
* @returns {object} - DICOM Web API object
*/
function createDicomWebApi(dicomWebConfig, servicesManager) {
const { userAuthenticationService, customizationService } = servicesManager.services;
let dicomWebConfigCopy,
qidoConfig,
wadoConfig,
qidoDicomWebClient,
wadoDicomWebClient,
getAuthrorizationHeader,
generateWadoHeader;
const implementation = {
initialize: ({ params, query }) => {
if (dicomWebConfig.onConfiguration && typeof dicomWebConfig.onConfiguration === 'function') {
dicomWebConfig = dicomWebConfig.onConfiguration(dicomWebConfig, {
params,
query,
});
}
dicomWebConfigCopy = JSON.parse(JSON.stringify(dicomWebConfig));
getAuthrorizationHeader = () => {
const xhrRequestHeaders = {};
const authHeaders = userAuthenticationService.getAuthorizationHeader();
if (authHeaders && authHeaders.Authorization) {
xhrRequestHeaders.Authorization = authHeaders.Authorization;
}
return xhrRequestHeaders;
};
generateWadoHeader = () => {
const authorizationHeader = getAuthrorizationHeader();
//Generate accept header depending on config params
const formattedAcceptHeader = utils.generateAcceptHeader(
dicomWebConfig.acceptHeader,
dicomWebConfig.requestTransferSyntaxUID,
dicomWebConfig.omitQuotationForMultipartRequest
);
return {
...authorizationHeader,
Accept: formattedAcceptHeader,
};
};
qidoConfig = {
url: dicomWebConfig.qidoRoot,
staticWado: dicomWebConfig.staticWado,
singlepart: dicomWebConfig.singlepart,
headers: userAuthenticationService.getAuthorizationHeader(),
errorInterceptor: errorHandler.getHTTPErrorHandler(),
};
wadoConfig = {
url: dicomWebConfig.wadoRoot,
staticWado: dicomWebConfig.staticWado,
singlepart: dicomWebConfig.singlepart,
headers: userAuthenticationService.getAuthorizationHeader(),
errorInterceptor: errorHandler.getHTTPErrorHandler(),
};
// TODO -> Two clients sucks, but its better than 1000.
// TODO -> We'll need to merge auth later.
qidoDicomWebClient = dicomWebConfig.staticWado
? new StaticWadoClient(qidoConfig)
: new api.DICOMwebClient(qidoConfig);
wadoDicomWebClient = dicomWebConfig.staticWado
? new StaticWadoClient(wadoConfig)
: new api.DICOMwebClient(wadoConfig);
},
query: {
studies: {
mapParams: mapParams.bind(),
search: async function (origParams) {
qidoDicomWebClient.headers = getAuthrorizationHeader();
const { studyInstanceUid, seriesInstanceUid, ...mappedParams } =
mapParams(origParams, {
supportsFuzzyMatching: dicomWebConfig.supportsFuzzyMatching,
supportsWildcard: dicomWebConfig.supportsWildcard,
}) || {};
const results = await qidoSearch(qidoDicomWebClient, undefined, undefined, mappedParams);
return processResults(results);
},
processResults: processResults.bind(),
},
series: {
// mapParams: mapParams.bind(),
search: async function (studyInstanceUid) {
qidoDicomWebClient.headers = getAuthrorizationHeader();
const results = await seriesInStudy(qidoDicomWebClient, studyInstanceUid);
return processSeriesResults(results);
},
// processResults: processResults.bind(),
},
instances: {
search: (studyInstanceUid, queryParameters) => {
qidoDicomWebClient.headers = getAuthrorizationHeader();
return qidoSearch.call(
undefined,
qidoDicomWebClient,
studyInstanceUid,
null,
queryParameters
);
},
},
},
retrieve: {
/**
* Generates a URL that can be used for direct retrieve of the bulkdata
*
* @param {object} params
* @param {string} params.tag is the tag name of the URL to retrieve
* @param {object} params.instance is the instance object that the tag is in
* @param {string} params.defaultType is the mime type of the response
* @param {string} params.singlepart is the type of the part to retrieve
* @returns an absolute URL to the resource, if the absolute URL can be retrieved as singlepart,
* or is already retrieved, or a promise to a URL for such use if a BulkDataURI
*/
directURL: params => {
return getDirectURL(
{
wadoRoot: dicomWebConfig.wadoRoot,
singlepart: dicomWebConfig.singlepart,
},
params
);
},
/**
* Provide direct access to the dicom web client for certain use cases
* where the dicom web client is used by an external library such as the
* microscopy viewer.
* Note this instance only needs to support the wado queries, and may not
* support any QIDO or STOW operations.
*/
getWadoDicomWebClient: () => wadoDicomWebClient,
bulkDataURI: async ({ StudyInstanceUID, BulkDataURI }) => {
qidoDicomWebClient.headers = getAuthrorizationHeader();
const options = {
multipart: false,
BulkDataURI,
StudyInstanceUID,
};
return qidoDicomWebClient.retrieveBulkData(options).then(val => {
const ret = (val && val[0]) || undefined;
return ret;
});
},
series: {
metadata: async ({
StudyInstanceUID,
filters,
sortCriteria,
sortFunction,
madeInClient = false,
returnPromises = false,
} = {}) => {
if (!StudyInstanceUID) {
throw new Error('Unable to query for SeriesMetadata without StudyInstanceUID');
}
if (dicomWebConfig.enableStudyLazyLoad) {
return implementation._retrieveSeriesMetadataAsync(
StudyInstanceUID,
filters,
sortCriteria,
sortFunction,
madeInClient,
returnPromises
);
}
return implementation._retrieveSeriesMetadataSync(
StudyInstanceUID,
filters,
sortCriteria,
sortFunction,
madeInClient
);
},
},
},
store: {
dicom: async (dataset, request, dicomDict) => {
wadoDicomWebClient.headers = getAuthrorizationHeader();
if (dataset instanceof ArrayBuffer) {
const options = {
datasets: [dataset],
request,
};
await wadoDicomWebClient.storeInstances(options);
} else {
let effectiveDicomDict = dicomDict;
if (!dicomDict) {
const meta = {
FileMetaInformationVersion: dataset._meta?.FileMetaInformationVersion?.Value,
MediaStorageSOPClassUID: dataset.SOPClassUID,
MediaStorageSOPInstanceUID: dataset.SOPInstanceUID,
TransferSyntaxUID: EXPLICIT_VR_LITTLE_ENDIAN,
ImplementationClassUID,
ImplementationVersionName,
};
const denaturalized = denaturalizeDataset(meta);
const defaultDicomDict = new DicomDict(denaturalized);
defaultDicomDict.dict = denaturalizeDataset(dataset);
effectiveDicomDict = defaultDicomDict;
}
const part10Buffer = effectiveDicomDict.write();
const options = {
datasets: [part10Buffer],
request,
};
await wadoDicomWebClient.storeInstances(options);
}
},
},
_retrieveSeriesMetadataSync: async (
StudyInstanceUID,
filters,
sortCriteria,
sortFunction,
madeInClient
) => {
const enableStudyLazyLoad = false;
wadoDicomWebClient.headers = generateWadoHeader();
// data is all SOPInstanceUIDs
const data = await retrieveStudyMetadata(
wadoDicomWebClient,
StudyInstanceUID,
enableStudyLazyLoad,
filters,
sortCriteria,
sortFunction,
dicomWebConfig
);
// first naturalize the data
const naturalizedInstancesMetadata = data.map(naturalizeDataset);
const seriesSummaryMetadata = {};
const instancesPerSeries = {};
naturalizedInstancesMetadata.forEach(instance => {
if (!seriesSummaryMetadata[instance.SeriesInstanceUID]) {
seriesSummaryMetadata[instance.SeriesInstanceUID] = {
StudyInstanceUID: instance.StudyInstanceUID,
StudyDescription: instance.StudyDescription,
SeriesInstanceUID: instance.SeriesInstanceUID,
SeriesDescription: instance.SeriesDescription,
SeriesNumber: instance.SeriesNumber,
SeriesTime: instance.SeriesTime,
SOPClassUID: instance.SOPClassUID,
ProtocolName: instance.ProtocolName,
Modality: instance.Modality,
};
}
if (!instancesPerSeries[instance.SeriesInstanceUID]) {
instancesPerSeries[instance.SeriesInstanceUID] = [];
}
const imageId = implementation.getImageIdsForInstance({
instance,
});
instance.imageId = imageId;
instance.wadoRoot = dicomWebConfig.wadoRoot;
instance.wadoUri = dicomWebConfig.wadoUri;
metadataProvider.addImageIdToUIDs(imageId, {
StudyInstanceUID,
SeriesInstanceUID: instance.SeriesInstanceUID,
SOPInstanceUID: instance.SOPInstanceUID,
});
instancesPerSeries[instance.SeriesInstanceUID].push(instance);
});
// grab all the series metadata
const seriesMetadata = Object.values(seriesSummaryMetadata);
DicomMetadataStore.addSeriesMetadata(seriesMetadata, madeInClient);
Object.keys(instancesPerSeries).forEach(seriesInstanceUID =>
DicomMetadataStore.addInstances(instancesPerSeries[seriesInstanceUID], madeInClient)
);
return seriesSummaryMetadata;
},
_retrieveSeriesMetadataAsync: async (
StudyInstanceUID,
filters,
sortCriteria,
sortFunction,
madeInClient = false,
returnPromises = false
) => {
const enableStudyLazyLoad = true;
wadoDicomWebClient.headers = generateWadoHeader();
// Get Series
const { preLoadData: seriesSummaryMetadata, promises: seriesPromises } =
await retrieveStudyMetadata(
wadoDicomWebClient,
StudyInstanceUID,
enableStudyLazyLoad,
filters,
sortCriteria,
sortFunction,
dicomWebConfig
);
/**
* Adds the retrieve bulkdata function to naturalized DICOM data.
* This is done recursively, for sub-sequences.
*/
const addRetrieveBulkDataNaturalized = (naturalized, instance = naturalized) => {
for (const key of Object.keys(naturalized)) {
const value = naturalized[key];
if (Array.isArray(value) && typeof value[0] === 'object') {
// Fix recursive values
value.forEach(child => addRetrieveBulkDataNaturalized(child, instance));
continue;
}
// The value.Value will be set with the bulkdata read value
// in which case it isn't necessary to re-read this.
if (value && value.BulkDataURI && !value.Value) {
// handle the scenarios where bulkDataURI is relative path
fixBulkDataURI(value, instance, dicomWebConfig);
// Provide a method to fetch bulkdata
value.retrieveBulkData = retrieveBulkData.bind(qidoDicomWebClient, value);
}
}
return naturalized;
};
/**
* naturalizes the dataset, and adds a retrieve bulkdata method
* to any values containing BulkDataURI.
* @param {*} instance
* @returns naturalized dataset, with retrieveBulkData methods
*/
const addRetrieveBulkData = instance => {
const naturalized = naturalizeDataset(instance);
// if we know the server doesn't use bulkDataURI, then don't
if (!dicomWebConfig.bulkDataURI?.enabled) {
return naturalized;
}
return addRetrieveBulkDataNaturalized(naturalized);
};
// Async load series, store as retrieved
function storeInstances(instances) {
const naturalizedInstances = instances.map(addRetrieveBulkData);
// Adding instanceMetadata to OHIF MetadataProvider
naturalizedInstances.forEach(instance => {
instance.wadoRoot = dicomWebConfig.wadoRoot;
instance.wadoUri = dicomWebConfig.wadoUri;
const imageId = implementation.getImageIdsForInstance({
instance,
});
// Adding imageId to each instance
// Todo: This is not the best way I can think of to let external
// metadata handlers know about the imageId that is stored in the store
instance.imageId = imageId;
// Adding UIDs to metadataProvider
// Note: storing imageURI in metadataProvider since stack viewports
// will use the same imageURI
metadataProvider.addImageIdToUIDs(imageId, {
StudyInstanceUID,
SeriesInstanceUID: instance.SeriesInstanceUID,
SOPInstanceUID: instance.SOPInstanceUID,
});
});
DicomMetadataStore.addInstances(naturalizedInstances, madeInClient);
}
function setSuccessFlag() {
const study = DicomMetadataStore.getStudy(StudyInstanceUID);
if (!study) {
return;
}
study.isLoaded = true;
}
// Google Cloud Healthcare doesn't return StudyInstanceUID, so we need to add
// it manually here
seriesSummaryMetadata.forEach(aSeries => {
aSeries.StudyInstanceUID = StudyInstanceUID;
});
DicomMetadataStore.addSeriesMetadata(seriesSummaryMetadata, madeInClient);
const seriesDeliveredPromises = seriesPromises.map(promise => {
if (!returnPromises) {
promise?.start();
}
return promise.then(instances => {
storeInstances(instances);
});
});
if (returnPromises) {
Promise.all(seriesDeliveredPromises).then(() => setSuccessFlag());
return seriesPromises;
} else {
await Promise.all(seriesDeliveredPromises);
setSuccessFlag();
}
return seriesSummaryMetadata;
},
deleteStudyMetadataPromise,
getImageIdsForDisplaySet(displaySet) {
const images = displaySet.images;
const imageIds = [];
if (!images) {
return imageIds;
}
displaySet.images.forEach(instance => {
const NumberOfFrames = instance.NumberOfFrames;
if (NumberOfFrames > 1) {
for (let frame = 1; frame <= NumberOfFrames; frame++) {
const imageId = this.getImageIdsForInstance({
instance,
frame,
});
imageIds.push(imageId);
}
} else {
const imageId = this.getImageIdsForInstance({ instance });
imageIds.push(imageId);
}
});
return imageIds;
},
getImageIdsForInstance({ instance, frame = undefined }) {
const imageIds = getImageId({
instance,
frame,
config: dicomWebConfig,
});
return imageIds;
},
getConfig() {
return dicomWebConfigCopy;
},
getStudyInstanceUIDs({ params, query }) {
const { StudyInstanceUIDs: paramsStudyInstanceUIDs } = params;
const queryStudyInstanceUIDs = utils.splitComma(query.getAll('StudyInstanceUIDs'));
const StudyInstanceUIDs =
(queryStudyInstanceUIDs.length && queryStudyInstanceUIDs) || paramsStudyInstanceUIDs;
const StudyInstanceUIDsAsArray =
StudyInstanceUIDs && Array.isArray(StudyInstanceUIDs)
? StudyInstanceUIDs
: [StudyInstanceUIDs];
return StudyInstanceUIDsAsArray;
},
};
if (dicomWebConfig.supportsReject) {
implementation.reject = dcm4cheeReject(dicomWebConfig.wadoRoot);
}
return IWebApiDataSource.create(implementation);
}
/**
* A bindable function that retrieves the bulk data against this as the
* dicomweb client, and on the given value element.
*
* @param value - a bind value that stores the retrieve value to short circuit the
* next retrieve instance.
* @param options - to allow specifying the content type.
*/
function retrieveBulkData(value, options = {}) {
const { mediaType } = options;
const useOptions = {
// The bulkdata fetches work with either multipart or
// singlepart, so set multipart to false to let the server
// decide which type to respond with.
multipart: false,
BulkDataURI: value.BulkDataURI,
mediaTypes: mediaType ? [{ mediaType }, { mediaType: 'application/octet-stream' }] : undefined,
...options,
};
return this.retrieveBulkData(useOptions).then(val => {
// There are DICOM PDF cases where the first ArrayBuffer in the array is
// the bulk data and DICOM video cases where the second ArrayBuffer is
// the bulk data. Here we play it safe and do a find.
const ret =
(val instanceof Array && val.find(arrayBuffer => arrayBuffer?.byteLength)) || undefined;
value.Value = ret;
return ret;
});
}
export { createDicomWebApi };

View File

@@ -0,0 +1,215 @@
/**
* QIDO - Query based on ID for DICOM Objects
* search for studies, series and instances by patient ID, and receive their
* unique identifiers for further usage.
*
* Quick: https://www.dicomstandard.org/dicomweb/query-qido-rs/
* Standard: http://dicom.nema.org/medical/dicom/current/output/html/part18.html#sect_10.6
*
* Routes:
* ==========
* /studies?
* /studies/{studyInstanceUid}/series?
* /studies/{studyInstanceUid}/series/{seriesInstanceUid}/instances?
*
* Query Parameters:
* ================
* | KEY | VALUE |
* |------------------|--------------------|
* | {attributeId} | {value} |
* | includeField | {attribute} or all |
* | fuzzymatching | true OR false |
* | limit | {number} |
* | offset | {number} |
*/
import { DICOMWeb, utils } from '@ohif/core';
import { sortStudySeries } from '@ohif/core/src/utils/sortStudy';
const { getString, getName, getModalities } = DICOMWeb;
/**
* Parses resulting data from a QIDO call into a set of Study MetaData
*
* @param {Array} qidoStudies - An array of study objects. Each object contains a keys for DICOM tags.
* @param {object} qidoStudies[0].qidoStudy - An object where each key is the DICOM Tag group+element
* @param {object} qidoStudies[0].qidoStudy[dicomTag] - Optional object that represents DICOM Tag
* @param {string} qidoStudies[0].qidoStudy[dicomTag].vr - Value Representation
* @param {string[]} qidoStudies[0].qidoStudy[dicomTag].Value - Optional string array representation of the DICOM Tag's value
* @returns {Array} An array of Study MetaData objects
*/
function processResults(qidoStudies) {
if (!qidoStudies || !qidoStudies.length) {
return [];
}
const studies = [];
qidoStudies.forEach(qidoStudy =>
studies.push({
studyInstanceUid: getString(qidoStudy['0020000D']),
date: getString(qidoStudy['00080020']), // YYYYMMDD
time: getString(qidoStudy['00080030']), // HHmmss.SSS (24-hour, minutes, seconds, fractional seconds)
accession: getString(qidoStudy['00080050']) || '', // short string, probably a number?
mrn: getString(qidoStudy['00100020']) || '', // medicalRecordNumber
patientName: utils.formatPN(getName(qidoStudy['00100010'])) || '',
instances: Number(getString(qidoStudy['00201208'])) || 0, // number
description: getString(qidoStudy['00081030']) || '',
modalities: getString(getModalities(qidoStudy['00080060'], qidoStudy['00080061'])) || '',
})
);
return studies;
}
/**
* Parses resulting data from a QIDO call into a set of Study MetaData
*
* @param {Array} qidoSeries - An array of study objects. Each object contains a keys for DICOM tags.
* @param {object} qidoSeries[0].qidoSeries - An object where each key is the DICOM Tag group+element
* @param {object} qidoSeries[0].qidoSeries[dicomTag] - Optional object that represents DICOM Tag
* @param {string} qidoSeries[0].qidoSeries[dicomTag].vr - Value Representation
* @param {string[]} qidoSeries[0].qidoSeries[dicomTag].Value - Optional string array representation of the DICOM Tag's value
* @returns {Array} An array of Study MetaData objects
*/
export function processSeriesResults(qidoSeries) {
const series = [];
if (qidoSeries && qidoSeries.length) {
qidoSeries.forEach(qidoSeries =>
series.push({
studyInstanceUid: getString(qidoSeries['0020000D']),
seriesInstanceUid: getString(qidoSeries['0020000E']),
modality: getString(qidoSeries['00080060']),
seriesNumber: getString(qidoSeries['00200011']),
seriesDate: utils.formatDate(getString(qidoSeries['00080021'])),
numSeriesInstances: Number(getString(qidoSeries['00201209'])),
description: getString(qidoSeries['0008103E']),
})
);
}
sortStudySeries(series);
return series;
}
/**
*
* @param {object} dicomWebClient - Client similar to what's provided by `dicomweb-client` library
* @param {function} dicomWebClient.searchForStudies -
* @param {string} [studyInstanceUid]
* @param {string} [seriesInstanceUid]
* @param {string} [queryParamaters]
* @returns {Promise<results>} - Promise that resolves results
*/
async function search(dicomWebClient, studyInstanceUid, seriesInstanceUid, queryParameters) {
let searchResult = await dicomWebClient.searchForStudies({
studyInstanceUid: undefined,
queryParams: queryParameters,
});
return searchResult;
}
/**
*
* @param {string} studyInstanceUID - ID of study to return a list of series for
* @returns {Promise} - Resolves SeriesMetadata[] in study
*/
export function seriesInStudy(dicomWebClient, studyInstanceUID) {
// Series Description
// Already included?
const commaSeparatedFields = ['0008103E', '00080021'].join(',');
const queryParams = {
includefield: commaSeparatedFields,
};
return dicomWebClient.searchForSeries({ studyInstanceUID, queryParams });
}
export default function searchStudies(server, filter) {
const queryParams = getQIDOQueryParams(filter, server.qidoSupportsIncludeField);
const options = {
queryParams,
};
return dicomWeb.searchForStudies(options).then(resultDataToStudies);
}
/**
* Produces a QIDO URL given server details and a set of specified search filter
* items
*
* @param filter
* @param serverSupportsQIDOIncludeField
* @returns {string} The URL with encoded filter query data
*/
function mapParams(params, options = {}) {
if (!params) {
return;
}
const commaSeparatedFields = [
'00081030', // Study Description
'00080060', // Modality
// Add more fields here if you want them in the result
].join(',');
const useWildcard =
params?.disableWildcard !== undefined ? !params.disableWildcard : options.supportsWildcard;
const withWildcard = value => {
return useWildcard && value ? `*${value}*` : value;
};
const parameters = {
// Named
PatientName: withWildcard(params.patientName),
//PatientID: withWildcard(params.patientId),
'00100020': withWildcard(params.patientId), // Temporarily to make the tests pass with dicomweb-server.. Apparently it's broken?
AccessionNumber: withWildcard(params.accessionNumber),
StudyDescription: withWildcard(params.studyDescription),
ModalitiesInStudy: params.modalitiesInStudy,
// Other
limit: params.limit || 101,
offset: params.offset || 0,
fuzzymatching: options.supportsFuzzyMatching === true,
includefield: commaSeparatedFields, // serverSupportsQIDOIncludeField ? commaSeparatedFields : 'all',
};
// build the StudyDate range parameter
if (params.startDate && params.endDate) {
parameters.StudyDate = `${params.startDate}-${params.endDate}`;
} else if (params.startDate) {
const today = new Date();
const DD = String(today.getDate()).padStart(2, '0');
const MM = String(today.getMonth() + 1).padStart(2, '0'); //January is 0!
const YYYY = today.getFullYear();
const todayStr = `${YYYY}${MM}${DD}`;
parameters.StudyDate = `${params.startDate}-${todayStr}`;
} else if (params.endDate) {
const oldDateStr = `19700102`;
parameters.StudyDate = `${oldDateStr}-${params.endDate}`;
}
// Build the StudyInstanceUID parameter
if (params.studyInstanceUid) {
let studyUids = params.studyInstanceUid;
studyUids = Array.isArray(studyUids) ? studyUids.join() : studyUids;
studyUids = studyUids.replace(/[^0-9.]+/g, '\\');
parameters.StudyInstanceUID = studyUids;
}
// Clean query params of undefined values.
const final = {};
Object.keys(parameters).forEach(key => {
if (parameters[key] !== undefined && parameters[key] !== '') {
final[key] = parameters[key];
}
});
return final;
}
export { mapParams, search, processResults };

View File

@@ -0,0 +1,91 @@
import retrieveMetadataFiltered from './utils/retrieveMetadataFiltered.js';
import RetrieveMetadata from './wado/retrieveMetadata.js';
const moduleName = 'RetrieveStudyMetadata';
// Cache for promises. Prevents unnecessary subsequent calls to the server
const StudyMetaDataPromises = new Map();
/**
* Retrieves study metadata.
*
* @param {Object} dicomWebClient The DICOMWebClient instance to be used for series load
* @param {string} StudyInstanceUID The UID of the Study to be retrieved
* @param {boolean} enableStudyLazyLoad Whether the study metadata should be loaded asynchronously.
* @param {Object} [filters] Object containing filters to be applied on retrieve metadata process
* @param {string} [filters.seriesInstanceUID] Series instance uid to filter results against
* @param {function} [sortCriteria] Sort criteria function
* @param {function} [sortFunction] Sort function
*
* @returns {Promise} that will be resolved with the metadata or rejected with the error
*/
export function retrieveStudyMetadata(
dicomWebClient,
StudyInstanceUID,
enableStudyLazyLoad,
filters,
sortCriteria,
sortFunction,
dicomWebConfig = {}
) {
// @TODO: Whenever a study metadata request has failed, its related promise will be rejected once and for all
// and further requests for that metadata will always fail. On failure, we probably need to remove the
// corresponding promise from the "StudyMetaDataPromises" map...
if (!dicomWebClient) {
throw new Error(`${moduleName}: Required 'dicomWebClient' parameter not provided.`);
}
if (!StudyInstanceUID) {
throw new Error(`${moduleName}: Required 'StudyInstanceUID' parameter not provided.`);
}
const promiseId = `${dicomWebConfig.name}:${StudyInstanceUID}`;
// Already waiting on result? Return cached promise
if (StudyMetaDataPromises.has(promiseId)) {
return StudyMetaDataPromises.get(promiseId);
}
let promise;
if (filters && filters.seriesInstanceUID && Array.isArray(filters.seriesInstanceUID)) {
promise = retrieveMetadataFiltered(
dicomWebClient,
StudyInstanceUID,
enableStudyLazyLoad,
filters,
sortCriteria,
sortFunction
);
} else {
// Create a promise to handle the data retrieval
promise = new Promise((resolve, reject) => {
RetrieveMetadata(
dicomWebClient,
StudyInstanceUID,
enableStudyLazyLoad,
filters,
sortCriteria,
sortFunction
).then(function (data) {
resolve(data);
}, reject);
});
}
// Store the promise in cache
StudyMetaDataPromises.set(promiseId, promise);
return promise;
}
/**
* Delete the cached study metadata retrieval promise to ensure that the browser will
* re-retrieve the study metadata when it is next requested.
*
* @param {String} StudyInstanceUID The UID of the Study to be removed from cache
*/
export function deleteStudyMetadataPromise(StudyInstanceUID) {
if (StudyMetaDataPromises.has(StudyInstanceUID)) {
StudyMetaDataPromises.delete(StudyInstanceUID);
}
}

View File

@@ -0,0 +1,241 @@
import { api } from 'dicomweb-client';
import fixMultipart from './fixMultipart';
const { DICOMwebClient } = api;
const anyDicomwebClient = DICOMwebClient as any;
// Ugly over-ride, but the internals aren't otherwise accessible.
if (!anyDicomwebClient._orig_buildMultipartAcceptHeaderFieldValue) {
anyDicomwebClient._orig_buildMultipartAcceptHeaderFieldValue =
anyDicomwebClient._buildMultipartAcceptHeaderFieldValue;
anyDicomwebClient._buildMultipartAcceptHeaderFieldValue = function (mediaTypes, acceptableTypes) {
if (mediaTypes.length === 1 && mediaTypes[0].mediaType.endsWith('/*')) {
return '*/*';
} else {
return anyDicomwebClient._orig_buildMultipartAcceptHeaderFieldValue(
mediaTypes,
acceptableTypes
);
}
};
}
/**
* An implementation of the static wado client, that fetches data from
* a static response rather than actually doing real queries. This allows
* fast encoding of test data, but because it is static, anything actually
* performing searches doesn't work. This version fixes the query issue
* by manually implementing a query option.
*/
export default class StaticWadoClient extends api.DICOMwebClient {
static studyFilterKeys = {
studyinstanceuid: '0020000D',
patientname: '00100010',
'00100020': 'mrn',
studydescription: '00081030',
studydate: '00080020',
modalitiesinstudy: '00080061',
accessionnumber: '00080050',
};
static seriesFilterKeys = {
seriesinstanceuid: '0020000E',
seriesnumber: '00200011',
modality: '00080060',
};
protected config;
protected staticWado;
constructor(config) {
super(config);
this.staticWado = config.staticWado;
this.config = config;
}
/**
* Handle improperly specified multipart/related return type.
* Note if the response is SUPPOSED to be multipart encoded already, then this
* will double-decode it.
*
* @param options
* @returns De-multiparted response data.
*
*/
public retrieveBulkData(options): Promise<any[]> {
const shouldFixMultipart = this.config.fixBulkdataMultipart !== false;
const useOptions = {
...options,
};
if (this.staticWado) {
useOptions.mediaTypes = [{ mediaType: 'application/*' }];
}
return super
.retrieveBulkData(useOptions)
.then(result => (shouldFixMultipart ? fixMultipart(result) : result));
}
/**
* Retrieves instance frames using the image/* media type when configured
* to do so (static wado back end).
*/
public retrieveInstanceFrames(options) {
if (this.staticWado) {
return super.retrieveInstanceFrames({
...options,
mediaTypes: [{ mediaType: 'image/*' }],
});
} else {
return super.retrieveInstanceFrames(options);
}
}
/**
* Replace the search for studies remote query with a local version which
* retrieves a complete query list and then sub-selects from it locally.
* @param {*} options
* @returns
*/
async searchForStudies(options) {
if (!this.staticWado) {
return super.searchForStudies(options);
}
const searchResult = await super.searchForStudies(options);
const { queryParams } = options;
if (!queryParams) {
return searchResult;
}
const lowerParams = this.toLowerParams(queryParams);
const filtered = searchResult.filter(study => {
for (const key of Object.keys(StaticWadoClient.studyFilterKeys)) {
if (!this.filterItem(key, lowerParams, study, StaticWadoClient.studyFilterKeys)) {
return false;
}
}
return true;
});
return filtered;
}
async searchForSeries(options) {
if (!this.staticWado) {
return super.searchForSeries(options);
}
const searchResult = await super.searchForSeries(options);
const { queryParams } = options;
if (!queryParams) {
return searchResult;
}
const lowerParams = this.toLowerParams(queryParams);
const filtered = searchResult.filter(series => {
for (const key of Object.keys(StaticWadoClient.seriesFilterKeys)) {
if (!this.filterItem(key, lowerParams, series, StaticWadoClient.seriesFilterKeys)) {
return false;
}
}
return true;
});
return filtered;
}
/**
* Compares values, matching any instance of desired to any instance of
* actual by recursively go through the paired set of values. That is,
* this is O(m*n) where m is how many items in desired and n is the length of actual
* Then, at the individual item node, compares the Alphabetic name if present,
* and does a sub-string matching on string values, and otherwise does an
* exact match comparison.
*
* @param {*} desired
* @param {*} actual
* @returns true if the values match
*/
compareValues(desired, actual) {
if (Array.isArray(desired)) {
return desired.find(item => this.compareValues(item, actual));
}
if (Array.isArray(actual)) {
return actual.find(actualItem => this.compareValues(desired, actualItem));
}
if (actual?.Alphabetic) {
actual = actual.Alphabetic;
}
if (typeof actual == 'string') {
if (actual.length === 0) {
return true;
}
if (desired.length === 0 || desired === '*') {
return true;
}
if (desired[0] === '*' && desired[desired.length - 1] === '*') {
// console.log(`Comparing ${actual} to ${desired.substring(1, desired.length - 1)}`)
return actual.indexOf(desired.substring(1, desired.length - 1)) != -1;
} else if (desired[desired.length - 1] === '*') {
return actual.indexOf(desired.substring(0, desired.length - 1)) != -1;
} else if (desired[0] === '*') {
return actual.indexOf(desired.substring(1)) === actual.length - desired.length + 1;
}
}
return desired === actual;
}
/** Compares a pair of dates to see if the value is within the range */
compareDateRange(range, value) {
if (!value) {
return true;
}
const dash = range.indexOf('-');
if (dash === -1) {
return this.compareValues(range, value);
}
const start = range.substring(0, dash);
const end = range.substring(dash + 1);
return (!start || value >= start) && (!end || value <= end);
}
/**
* Filters the return list by the query parameters.
*
* @param anyCaseKey - a possible search key
* @param queryParams -
* @param {*} study
* @param {*} sourceFilterMap
* @returns
*/
filterItem(key: string, queryParams, study, sourceFilterMap) {
const altKey = sourceFilterMap[key] || key;
if (!queryParams) {
return true;
}
const testValue = queryParams[key] || queryParams[altKey];
if (!testValue) {
return true;
}
const valueElem = study[key] || study[altKey];
if (!valueElem) {
return false;
}
if (valueElem.vr === 'DA' && valueElem.Value?.[0]) {
return this.compareDateRange(testValue, valueElem.Value[0]);
}
const value = valueElem.Value;
return this.compareValues(testValue, value);
}
/** Converts the query parameters to lower case query parameters */
toLowerParams(queryParams: Record<string, unknown>): Record<string, unknown> {
const lowerParams = {};
Object.entries(queryParams).forEach(([key, value]) => {
lowerParams[key.toLowerCase()] = value;
});
return lowerParams;
}
}

View File

@@ -0,0 +1,78 @@
import { fixBulkDataURI } from './fixBulkDataURI';
function isPrimitive(v: any) {
return !(typeof v == 'object' || Array.isArray(v));
}
const vrNumerics = new Set([
'DS',
'FL',
'FD',
'IS',
'OD',
'OF',
'OL',
'OV',
'SL',
'SS',
'SV',
'UL',
'US',
'UV',
]);
/**
* Specialized for DICOM JSON format dataset cleaning.
* @param obj
* @returns
*/
export function cleanDenaturalizedDataset(
obj: any,
options?: {
StudyInstanceUID: string;
SeriesInstanceUID: string;
dataSourceConfig: unknown;
}
): any {
if (Array.isArray(obj)) {
const newAry = obj.map(o => (isPrimitive(o) ? o : cleanDenaturalizedDataset(o, options)));
return newAry;
}
if (isPrimitive(obj)) {
return obj;
}
Object.keys(obj).forEach(key => {
if (obj[key].Value === null && obj[key].vr) {
delete obj[key].Value;
} else if (Array.isArray(obj[key].Value) && obj[key].vr) {
if (obj[key].Value.length === 1 && obj[key].Value[0].BulkDataURI) {
if (options?.dataSourceConfig) {
// Not needed unless data source is directly used for loading data.
fixBulkDataURI(obj[key].Value[0], options, options.dataSourceConfig);
}
obj[key].BulkDataURI = obj[key].Value[0].BulkDataURI;
// prevent mixed-content blockage
if (window.location.protocol === 'https:' && obj[key].BulkDataURI.startsWith('http:')) {
obj[key].BulkDataURI = obj[key].BulkDataURI.replace('http:', 'https:');
}
delete obj[key].Value;
} else if (vrNumerics.has(obj[key].vr)) {
obj[key].Value = obj[key].Value.map(v => +v);
} else {
obj[key].Value = obj[key].Value.map(entry => cleanDenaturalizedDataset(entry, options));
}
}
});
return obj;
}
/**
* This is required to make the denaturalized data transferrable when it has
* added proxy values.
*/
export function transferDenaturalizedDataset(dataset) {
const noNull = cleanDenaturalizedDataset(dataset);
return JSON.parse(JSON.stringify(noNull));
}

View File

@@ -0,0 +1,47 @@
function checkToken(token, data, dataOffset): boolean {
if (dataOffset + token.length > data.length) {
return false;
}
let endIndex = dataOffset;
for (let i = 0; i < token.length; i++) {
if (token[i] !== data[endIndex++]) {
return false;
}
}
return true;
}
function stringToUint8Array(str: string): Uint8Array {
const uint = new Uint8Array(str.length);
for (let i = 0, j = str.length; i < j; i++) {
uint[i] = str.charCodeAt(i);
}
return uint;
}
function findIndexOfString(
data: Uint8Array,
str: string,
offset?: number
): number {
offset = offset || 0;
const token = stringToUint8Array(str);
for (let i = offset; i < data.length; i++) {
if (token[0] === data[i]) {
// console.log('match @', i);
if (checkToken(token, data, i)) {
return i;
}
}
}
return -1;
}
export default findIndexOfString;

View File

@@ -0,0 +1,71 @@
/**
* Modifies a bulkDataURI to ensure it is absolute based on the DICOMWeb configuration and
* instance data. The modification is in-place.
*
* If the bulkDataURI is relative to the series or study (according to the DICOM standard),
* it is made absolute by prepending the relevant paths.
*
* In scenarios where the bulkDataURI is a server-relative path (starting with '/'), the function
* handles two cases:
*
* 1. If the wado root is absolute (starts with 'http'), it prepends the wado root to the bulkDataURI.
* 2. If the wado root is relative, no changes are needed as the bulkDataURI is already correctly relative to the server root.
*
* @param value - The object containing BulkDataURI to be fixed.
* @param instance - The object (DICOM instance data) containing StudyInstanceUID and SeriesInstanceUID.
* @param dicomWebConfig - The DICOMWeb configuration object, containing wadoRoot and potentially bulkDataURI.relativeResolution.
* @returns The function modifies `value` in-place, it does not return a value.
*/
function fixBulkDataURI(value, instance, dicomWebConfig) {
// in case of the relative path, make it absolute. The current DICOM standard says
// the bulkdataURI is relative to the series. However, there are situations where
// it can be relative to the study too
let { BulkDataURI } = value;
const { bulkDataURI: uriConfig = {} } = dicomWebConfig;
BulkDataURI = uriConfig.transform?.(BulkDataURI) || BulkDataURI;
// Handle incorrectly prefixed origins
const { startsWith, prefixWith = '' } = uriConfig;
if (startsWith && BulkDataURI.startsWith(startsWith)) {
BulkDataURI = prefixWith + BulkDataURI.substring(startsWith.length);
value.BulkDataURI = BulkDataURI;
}
if (!BulkDataURI.startsWith('http') && !value.BulkDataURI.startsWith('/')) {
const { StudyInstanceUID, SeriesInstanceUID } = instance;
const isInstanceStart = BulkDataURI.startsWith('instances/') || BulkDataURI.startsWith('../');
if (
BulkDataURI.startsWith('series/') ||
BulkDataURI.startsWith('bulkdata/') ||
(uriConfig.relativeResolution === 'studies' && !isInstanceStart)
) {
value.BulkDataURI = `${dicomWebConfig.wadoRoot}/studies/${StudyInstanceUID}/${BulkDataURI}`;
} else if (
isInstanceStart ||
uriConfig.relativeResolution === 'series' ||
!uriConfig.relativeResolution
) {
value.BulkDataURI = `${dicomWebConfig.wadoRoot}/studies/${StudyInstanceUID}/series/${SeriesInstanceUID}/${BulkDataURI}`;
}
return;
}
// in case it is relative path but starts at the server (e.g., /bulk/1e, note the missing http
// in the beginning and the first character is /) There are two scenarios, whether the wado root
// is absolute or relative. In case of absolute, we need to prepend the wado root to the bulkdata
// uri (e.g., bulkData: /bulk/1e, wado root: http://myserver.com/dicomweb, output: http://myserver.com/bulk/1e)
// and in case of relative wado root, we need to prepend the bulkdata uri to the wado root (e.g,. bulkData: /bulk/1e
// wado root: /dicomweb, output: /bulk/1e)
if (BulkDataURI[0] === '/') {
if (dicomWebConfig.wadoRoot.startsWith('http')) {
// Absolute wado root
const url = new URL(dicomWebConfig.wadoRoot);
value.BulkDataURI = `${url.origin}${BulkDataURI}`;
} else {
// Relative wado root, we don't need to do anything, bulkdata uri is already correct
}
}
}
export { fixBulkDataURI };

View File

@@ -0,0 +1,12 @@
/**
* Fix multi-valued keys so that those which are strings split by
* a backslash are returned as arrays.
*/
export function fixMultiValueKeys(naturalData, keys = ['ImageType']) {
for (const key of keys) {
if (typeof naturalData[key] === 'string') {
naturalData[key] = naturalData[key].split('\\');
}
}
return naturalData;
}

View File

@@ -0,0 +1,70 @@
import findIndexOfString from './findIndexOfString';
/**
* Fix multipart data coming back from the retrieve bulkdata request, but
* incorrectly tagged as application/octet-stream. Some servers don't handle
* the response type correctly, and this method is relatively robust about
* detecting multipart data correctly. It will only extract one value.
*/
export default function fixMultipart(arrayData) {
const data = new Uint8Array(arrayData[0]);
// Don't know the exact minimum length, but it is at least 25 to encode multipart
if (data.length < 25) {
return arrayData;
}
const dashIndex = findIndexOfString(data, '--');
if (dashIndex > 6) {
return arrayData;
}
const tokenIndex = findIndexOfString(data, '\r\n\r\n', dashIndex);
if (tokenIndex > 512) {
// Allow for 512 characters in the header - there is no apriori limit, but
// this seems ok for now as we only expect it to have content type in it.
return arrayData;
}
const header = uint8ArrayToString(data, 0, tokenIndex);
// Now find the boundary marker
const responseHeaders = header.split('\r\n');
const boundary = findBoundary(responseHeaders);
if (!boundary) {
return arrayData;
}
// Start of actual data is 4 characters after the token
const offset = tokenIndex + 4;
const endIndex = findIndexOfString(data, boundary, offset);
if (endIndex === -1) {
return arrayData;
}
return [data.slice(offset, endIndex - 2).buffer];
}
export function findBoundary(header: string[]): string {
for (let i = 0; i < header.length; i++) {
if (header[i].substr(0, 2) === '--') {
return header[i];
}
}
}
export function findContentType(header: string[]): string {
for (let i = 0; i < header.length; i++) {
if (header[i].substr(0, 13) === 'Content-Type:') {
return header[i].substr(13).trim();
}
}
}
export function uint8ArrayToString(data, offset, length) {
offset = offset || 0;
length = length || data.length - offset;
let str = '';
for (let i = offset; i < offset + length; i++) {
str += String.fromCharCode(data[i]);
}
return str;
}

View File

@@ -0,0 +1,54 @@
import getWADORSImageId from './getWADORSImageId';
function buildInstanceWadoUrl(config, instance) {
const { StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID } = instance;
const params = [];
params.push('requestType=WADO');
params.push(`studyUID=${StudyInstanceUID}`);
params.push(`seriesUID=${SeriesInstanceUID}`);
params.push(`objectUID=${SOPInstanceUID}`);
params.push('contentType=application/dicom');
params.push('transferSyntax=*');
const paramString = params.join('&');
return `${config.wadoUriRoot}?${paramString}`;
}
/**
* Obtain an imageId for Cornerstone from an image instance
*
* @param instance
* @param frame
* @param thumbnail
* @returns {string} The imageId to be used by Cornerstone
*/
export default function getImageId({ instance, frame, config, thumbnail = false }) {
if (!instance) {
return;
}
if (instance.imageId && frame === undefined) {
return instance.imageId;
}
if (instance.url) {
return instance.url;
}
const renderingAttr = thumbnail ? 'thumbnailRendering' : 'imageRendering';
if (!config[renderingAttr] || config[renderingAttr] === 'wadouri') {
const wadouri = buildInstanceWadoUrl(config, instance);
let imageId = 'dicomweb:' + wadouri;
if (frame !== undefined) {
imageId += '&frame=' + frame;
}
return imageId;
} else {
return getWADORSImageId(instance, config, frame); // WADO-RS Retrieve Frame
}
}

View File

@@ -0,0 +1,51 @@
function buildInstanceWadoRsUri(instance, config) {
const { StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID } = instance;
return `${config.wadoRoot}/studies/${StudyInstanceUID}/series/${SeriesInstanceUID}/instances/${SOPInstanceUID}`;
}
function buildInstanceFrameWadoRsUri(instance, config, frame) {
const baseWadoRsUri = buildInstanceWadoRsUri(instance, config);
frame = frame || 1;
return `${baseWadoRsUri}/frames/${frame}`;
}
// function getWADORSImageUrl(instance, frame) {
// const wadorsuri = buildInstanceFrameWadoRsUri(instance, config, frame);
// if (!wadorsuri) {
// return;
// }
// // Use null to obtain an imageId which represents the instance
// if (frame === null) {
// wadorsuri = wadorsuri.replace(/frames\/(\d+)/, '');
// } else {
// // We need to sum 1 because WADO-RS frame number is 1-based
// frame = frame ? parseInt(frame) + 1 : 1;
// // Replaces /frame/1 by /frame/{frame}
// wadorsuri = wadorsuri.replace(/frames\/(\d+)/, `frames/${frame}`);
// }
// return wadorsuri;
// }
/**
* Obtain an imageId for Cornerstone based on the WADO-RS scheme
*
* @param {object} instanceMetada metadata object (InstanceMetadata)
* @param {(string\|number)} [frame] the frame number
* @returns {string} The imageId to be used by Cornerstone
*/
export default function getWADORSImageId(instance, config, frame) {
//const uri = getWADORSImageUrl(instance, frame);
const uri = buildInstanceFrameWadoRsUri(instance, config, frame);
if (!uri) {
return;
}
return `wadors:${uri}`;
}

View File

@@ -0,0 +1,9 @@
import { fixBulkDataURI } from './fixBulkDataURI';
import {
cleanDenaturalizedDataset,
transferDenaturalizedDataset,
} from './cleanDenaturalizedDataset';
export { fixMultiValueKeys } from './fixMultiValueKeys';
export { fixBulkDataURI, cleanDenaturalizedDataset, transferDenaturalizedDataset };

View File

@@ -0,0 +1,61 @@
import RetrieveMetadata from '../wado/retrieveMetadata';
/**
* Retrieve metadata filtered.
*
* @param {*} dicomWebClient The DICOMWebClient instance to be used for series load
* @param {*} StudyInstanceUID The UID of the Study to be retrieved
* @param {*} enableStudyLazyLoad Whether the study metadata should be loaded asynchronously
* @param {object} filters Object containing filters to be applied on retrieve metadata process
* @param {string} [filters.seriesInstanceUID] Series instance uid to filter results against
* @param {function} [sortCriteria] Sort criteria function
* @param {function} [sortFunction] Sort function
*
* @returns
*/
function retrieveMetadataFiltered(
dicomWebClient,
StudyInstanceUID,
enableStudyLazyLoad,
filters,
sortCriteria,
sortFunction
) {
const { seriesInstanceUID } = filters;
return new Promise((resolve, reject) => {
const promises = seriesInstanceUID.map(uid => {
const seriesSpecificFilters = Object.assign({}, filters, {
seriesInstanceUID: uid,
});
return RetrieveMetadata(
dicomWebClient,
StudyInstanceUID,
enableStudyLazyLoad,
seriesSpecificFilters,
sortCriteria,
sortFunction
);
});
if (enableStudyLazyLoad === true) {
Promise.all(promises).then(results => {
const aggregatedResult = { preLoadData: [], promises: [] };
results.forEach(({ preLoadData, promises }) => {
aggregatedResult.preLoadData = aggregatedResult.preLoadData.concat(preLoadData);
aggregatedResult.promises = aggregatedResult.promises.concat(promises);
});
resolve(aggregatedResult);
}, reject);
} else {
Promise.all(promises).then(results => {
resolve(results.flat());
}, reject);
}
});
}
export default retrieveMetadataFiltered;

View File

@@ -0,0 +1,41 @@
import RetrieveMetadataLoaderSync from './retrieveMetadataLoaderSync';
import RetrieveMetadataLoaderAsync from './retrieveMetadataLoaderAsync';
/**
* Retrieve Study metadata from a DICOM server. If the server is configured to use lazy load, only the first series
* will be loaded and the property "studyLoader" will be set to let consumer load remaining series as needed.
*
* @param {*} dicomWebClient The DICOMWebClient instance to be used for series load
* @param {*} StudyInstanceUID The UID of the Study to be retrieved
* @param {*} enableStudyLazyLoad Whether the study metadata should be loaded asynchronously
* @param {object} filters Object containing filters to be applied on retrieve metadata process
* @param {string} [filters.seriesInstanceUID] Series instance uid to filter results against
* @param {function} [sortCriteria] Sort criteria function
* @param {function} [sortFunction] Sort function
*
* @returns {Promise} A promises that resolves the study descriptor object
*/
async function RetrieveMetadata(
dicomWebClient,
StudyInstanceUID,
enableStudyLazyLoad,
filters = {},
sortCriteria,
sortFunction
) {
const RetrieveMetadataLoader =
enableStudyLazyLoad !== false ? RetrieveMetadataLoaderAsync : RetrieveMetadataLoaderSync;
const retrieveMetadataLoader = new RetrieveMetadataLoader(
dicomWebClient,
StudyInstanceUID,
filters,
sortCriteria,
sortFunction
);
const data = await retrieveMetadataLoader.execLoad();
return data;
}
export default RetrieveMetadata;

View File

@@ -0,0 +1,64 @@
/**
* Class to define inheritance of load retrieve strategy.
* The process can be async load (lazy) or sync load
*
* There are methods that must be implemented at consumer level
* To retrieve study call execLoad
*/
export default class RetrieveMetadataLoader {
/**
* @constructor
* @param {Object} client The dicomweb-client.
* @param {Array} studyInstanceUID Study instance ui to be retrieved
* @param {Object} [filters] - Object containing filters to be applied on retrieve metadata process
* @param {string} [filters.seriesInstanceUID] - series instance uid to filter results against
* @param {Object} [sortCriteria] - Custom sort criteria used for series
* @param {Function} [sortFunction] - Custom sort function for series
*/
constructor(
client,
studyInstanceUID,
filters = {},
sortCriteria = undefined,
sortFunction = undefined
) {
this.client = client;
this.studyInstanceUID = studyInstanceUID;
this.filters = filters;
this.sortCriteria = sortCriteria;
this.sortFunction = sortFunction;
}
async execLoad() {
const preLoadData = await this.preLoad();
const loadData = await this.load(preLoadData);
const postLoadData = await this.posLoad(loadData);
return postLoadData;
}
/**
* It iterates over given loaders running each one. Loaders parameters must be bind when getting it.
* @param {Array} loaders - array of loader to retrieve data.
*/
async runLoaders(loaders) {
let result;
for (const loader of loaders) {
result = await loader();
if (result && result.length) {
break; // closes iterator in case data is retrieved successfully
}
}
if (loaders.next().done && !result) {
throw new Error('RetrieveMetadataLoader failed');
}
return result;
}
// Methods to be overwrite
async configLoad() {}
async preLoad() {}
async load(preLoadData) {}
async posLoad(loadData) {}
}

View File

@@ -0,0 +1,159 @@
import dcmjs from 'dcmjs';
import { sortStudySeries } from '@ohif/core/src/utils/sortStudy';
import RetrieveMetadataLoader from './retrieveMetadataLoader';
// Series Date, Series Time, Series Description and Series Number to be included
// in the series metadata query result
const includeField = ['00080021', '00080031', '0008103E', '00200011'].join(',');
export class DeferredPromise {
metadata = undefined;
processFunction = undefined;
internalPromise = undefined;
thenFunction = undefined;
rejectFunction = undefined;
setMetadata(metadata) {
this.metadata = metadata;
}
setProcessFunction(func) {
this.processFunction = func;
}
getPromise() {
return this.start();
}
start() {
if (this.internalPromise) {
return this.internalPromise;
}
this.internalPromise = this.processFunction();
// in case then and reject functions called before start
if (this.thenFunction) {
this.then(this.thenFunction);
this.thenFunction = undefined;
}
if (this.rejectFunction) {
this.reject(this.rejectFunction);
this.rejectFunction = undefined;
}
return this.internalPromise;
}
then(func) {
if (this.internalPromise) {
return this.internalPromise.then(func);
} else {
this.thenFunction = func;
}
}
reject(func) {
if (this.internalPromise) {
return this.internalPromise.reject(func);
} else {
this.rejectFunction = func;
}
}
}
/**
* Creates an immutable series loader object which loads each series sequentially using the iterator interface.
*
* @param {DICOMWebClient} dicomWebClient The DICOMWebClient instance to be used for series load
* @param {string} studyInstanceUID The Study Instance UID from which series will be loaded
* @param {Array} seriesInstanceUIDList A list of Series Instance UIDs
*
* @returns {Object} Returns an object which supports loading of instances from each of given Series Instance UID
*/
function makeSeriesAsyncLoader(client, studyInstanceUID, seriesInstanceUIDList) {
return Object.freeze({
hasNext() {
return seriesInstanceUIDList.length > 0;
},
next() {
const { seriesInstanceUID, metadata } = seriesInstanceUIDList.shift();
const promise = new DeferredPromise();
promise.setMetadata(metadata);
promise.setProcessFunction(() => {
return client.retrieveSeriesMetadata({
studyInstanceUID,
seriesInstanceUID,
});
});
return promise;
},
});
}
/**
* Class for async load of study metadata.
* It inherits from RetrieveMetadataLoader
*
* It loads the one series and then append to seriesLoader the others to be consumed/loaded
*/
export default class RetrieveMetadataLoaderAsync extends RetrieveMetadataLoader {
/**
* @returns {Array} Array of preLoaders. To be consumed as queue
*/
*getPreLoaders() {
const preLoaders = [];
const { studyInstanceUID, filters: { seriesInstanceUID } = {}, client } = this;
// asking to include Series Date, Series Time, Series Description
// and Series Number in the series metadata returned to better sort series
// in preLoad function
let options = {
studyInstanceUID,
queryParams: {
includefield: includeField,
},
};
if (seriesInstanceUID) {
options.queryParams.SeriesInstanceUID = seriesInstanceUID;
preLoaders.push(client.searchForSeries.bind(client, options));
}
// Fallback preloader
preLoaders.push(client.searchForSeries.bind(client, options));
yield* preLoaders;
}
async preLoad() {
const preLoaders = this.getPreLoaders();
const result = await this.runLoaders(preLoaders);
const sortCriteria = this.sortCriteria;
const sortFunction = this.sortFunction;
const { naturalizeDataset } = dcmjs.data.DicomMetaDictionary;
const naturalized = result.map(naturalizeDataset);
return sortStudySeries(naturalized, sortCriteria, sortFunction);
}
async load(preLoadData) {
const { client, studyInstanceUID } = this;
const seriesInstanceUIDs = preLoadData.map(seriesMetadata => {
return { seriesInstanceUID: seriesMetadata.SeriesInstanceUID, metadata: seriesMetadata };
});
const seriesAsyncLoader = makeSeriesAsyncLoader(client, studyInstanceUID, seriesInstanceUIDs);
const promises = [];
while (seriesAsyncLoader.hasNext()) {
const promise = seriesAsyncLoader.next();
promises.push(promise);
}
return {
preLoadData,
promises,
};
}
async posLoad({ preLoadData, promises }) {
return {
preLoadData,
promises,
};
}
}

View File

@@ -0,0 +1,59 @@
// import { api } from 'dicomweb-client';
// import DICOMWeb from '../../../DICOMWeb/';
import { createStudyFromSOPInstanceList } from './studyInstanceHelpers';
import RetrieveMetadataLoader from './retrieveMetadataLoader';
/**
* Class for sync load of study metadata.
* It inherits from RetrieveMetadataLoader
*
* A list of loaders (getLoaders) can be created so, it will be applied a fallback load strategy.
* I.e Retrieve metadata using all loaders possibilities.
*/
export default class RetrieveMetadataLoaderSync extends RetrieveMetadataLoader {
getOptions() {
const { studyInstanceUID, filters } = this;
const options = {
studyInstanceUID,
};
const { seriesInstanceUID } = filters;
if (seriesInstanceUID) {
options['seriesInstanceUID'] = seriesInstanceUID;
}
return options;
}
/**
* @returns {Array} Array of loaders. To be consumed as queue
*/
*getLoaders() {
const loaders = [];
const { studyInstanceUID, filters: { seriesInstanceUID } = {}, client } = this;
if (seriesInstanceUID) {
loaders.push(
client.retrieveSeriesMetadata.bind(client, {
studyInstanceUID,
seriesInstanceUID,
})
);
}
loaders.push(client.retrieveStudyMetadata.bind(client, { studyInstanceUID }));
yield* loaders;
}
async load(preLoadData) {
const loaders = this.getLoaders();
const result = this.runLoaders(loaders);
return result;
}
async posLoad(loadData) {
return loadData;
}
}

View File

@@ -0,0 +1,76 @@
import { IWebApiDataSource } from '@ohif/core';
import { createDicomWebApi } from '../DicomWebDataSource/index';
/**
* This datasource is initialized with a url that returns a JSON object with a
* dicomWeb datasource configuration array present in a "servers" object.
*
* Only the first array item is parsed, if there are multiple items in the
* dicomWeb configuration array
*
*/
function createDicomWebProxyApi(dicomWebProxyConfig, servicesManager: AppTypes.ServicesManager) {
const { name } = dicomWebProxyConfig;
let dicomWebDelegate = undefined;
const implementation = {
initialize: async ({ params, query }) => {
const url = query.get('url');
if (!url) {
throw new Error(`No url for '${name}'`);
} else {
const response = await fetch(url);
const data = await response.json();
if (!data.servers?.dicomWeb?.[0]) {
throw new Error('Invalid configuration returned by url');
}
dicomWebDelegate = createDicomWebApi(
data.servers.dicomWeb[0].configuration || data.servers.dicomWeb[0],
servicesManager
);
dicomWebDelegate.initialize({ params, query });
}
},
query: {
studies: {
search: params => dicomWebDelegate.query.studies.search(params),
},
series: {
search: (...args) => dicomWebDelegate.query.series.search(...args),
},
instances: {
search: (studyInstanceUid, queryParameters) =>
dicomWebDelegate.query.instances.search(studyInstanceUid, queryParameters),
},
},
retrieve: {
directURL: (...args) => dicomWebDelegate.retrieve.directURL(...args),
series: {
metadata: async (...args) => dicomWebDelegate.retrieve.series.metadata(...args),
},
},
store: {
dicom: (...args) => dicomWebDelegate.store(...args),
},
deleteStudyMetadataPromise: (...args) => dicomWebDelegate.deleteStudyMetadataPromise(...args),
getImageIdsForDisplaySet: (...args) => dicomWebDelegate.getImageIdsForDisplaySet(...args),
getImageIdsForInstance: (...args) => dicomWebDelegate.getImageIdsForInstance(...args),
getStudyInstanceUIDs({ params, query }) {
let studyInstanceUIDs = [];
// there seem to be a couple of variations of the case for this parameter
const queryStudyInstanceUIDs =
query.get('studyInstanceUIDs') || query.get('studyInstanceUids');
if (!queryStudyInstanceUIDs) {
throw new Error(`No studyInstanceUids in request for '${name}'`);
}
studyInstanceUIDs = queryStudyInstanceUIDs.split(';');
return studyInstanceUIDs;
},
};
return IWebApiDataSource.create(implementation);
}
export { createDicomWebProxyApi };

View File

@@ -0,0 +1,203 @@
import { DicomMetadataStore, IWebApiDataSource } from '@ohif/core';
import {
mergeMap,
callForAllDataSourcesAsync,
callForAllDataSources,
callForDefaultDataSource,
callByRetrieveAETitle,
createMergeDataSourceApi,
} from './index';
jest.mock('@ohif/core');
describe('MergeDataSource', () => {
let path,
sourceName,
mergeConfig,
extensionManager,
series1,
series2,
series3,
series4,
mergeKey,
tagFunc,
dataSourceAndSeriesMap,
dataSourceAndUIDsMap,
dataSourceAndDSMap,
pathSync;
beforeAll(() => {
path = 'query.series.search';
pathSync = 'getImageIdsForInstance';
tagFunc = jest.fn((data, sourceName) =>
data.map(item => ({ ...item, RetrieveAETitle: sourceName }))
);
sourceName = 'dicomweb1';
mergeKey = 'seriesInstanceUid';
series1 = { [mergeKey]: '123' };
series2 = { [mergeKey]: '234' };
series3 = { [mergeKey]: '345' };
series4 = { [mergeKey]: '456' };
mergeConfig = {
seriesMerge: {
dataSourceNames: ['dicomweb1', 'dicomweb2'],
defaultDataSourceName: 'dicomweb1',
},
};
dataSourceAndSeriesMap = {
dataSource1: series1,
dataSource2: series2,
dataSource3: series3,
};
dataSourceAndUIDsMap = {
dataSource1: ['123'],
dataSource2: ['234'],
dataSource3: ['345'],
};
dataSourceAndDSMap = {
dataSource1: {
displaySet: {
StudyInstanceUID: '123',
SeriesInstanceUID: '123',
},
},
dataSource2: {
displaySet: {
StudyInstanceUID: '234',
SeriesInstanceUID: '234',
},
},
dataSource3: {
displaySet: {
StudyInstanceUID: '345',
SeriesInstanceUID: '345',
},
},
};
extensionManager = {
dataSourceDefs: {
dataSource1: {
sourceName: 'dataSource1',
configuration: {},
},
dataSource2: {
sourceName: 'dataSource2',
configuration: {},
},
dataSource3: {
sourceName: 'dataSource3',
configuration: {},
},
},
getDataSources: jest.fn(dataSourceName => [
{
[path]: jest.fn().mockResolvedValue([dataSourceAndSeriesMap[dataSourceName]]),
},
]),
};
});
afterEach(() => {
jest.clearAllMocks();
});
describe('callForAllDataSourcesAsync', () => {
it('should call the correct functions and return the merged data', async () => {
/** Arrange */
extensionManager.getDataSources = jest.fn(dataSourceName => [
{
[path]: jest.fn().mockResolvedValue([dataSourceAndSeriesMap[dataSourceName]]),
},
]);
/** Act */
const data = await callForAllDataSourcesAsync({
mergeMap,
path,
args: [],
extensionManager,
dataSourceNames: ['dataSource1', 'dataSource2'],
});
/** Assert */
expect(extensionManager.getDataSources).toHaveBeenCalledTimes(2);
expect(extensionManager.getDataSources).toHaveBeenCalledWith('dataSource1');
expect(extensionManager.getDataSources).toHaveBeenCalledWith('dataSource2');
expect(data).toEqual([series1, series2]);
});
});
describe('callForAllDataSources', () => {
it('should call the correct functions and return the merged data', () => {
/** Arrange */
extensionManager.getDataSources = jest.fn(dataSourceName => [
{
[pathSync]: () => dataSourceAndUIDsMap[dataSourceName],
},
]);
/** Act */
const data = callForAllDataSources({
path: pathSync,
args: [],
extensionManager,
dataSourceNames: ['dataSource2', 'dataSource3'],
});
/** Assert */
expect(extensionManager.getDataSources).toHaveBeenCalledTimes(2);
expect(extensionManager.getDataSources).toHaveBeenCalledWith('dataSource2');
expect(extensionManager.getDataSources).toHaveBeenCalledWith('dataSource3');
expect(data).toEqual(['234', '345']);
});
});
describe('callForDefaultDataSource', () => {
it('should call the correct function and return the data', () => {
/** Arrange */
extensionManager.getDataSources = jest.fn(dataSourceName => [
{
[pathSync]: () => dataSourceAndUIDsMap[dataSourceName],
},
]);
/** Act */
const data = callForDefaultDataSource({
path: pathSync,
args: [],
extensionManager,
defaultDataSourceName: 'dataSource2',
});
/** Assert */
expect(extensionManager.getDataSources).toHaveBeenCalledTimes(1);
expect(extensionManager.getDataSources).toHaveBeenCalledWith('dataSource2');
expect(data).toEqual(['234']);
});
});
describe('callByRetrieveAETitle', () => {
it('should call the correct function and return the data', () => {
/** Arrange */
DicomMetadataStore.getSeries.mockImplementationOnce(() => [series2]);
extensionManager.getDataSources = jest.fn(dataSourceName => [
{
[pathSync]: () => dataSourceAndUIDsMap[dataSourceName],
},
]);
/** Act */
const data = callByRetrieveAETitle({
path: pathSync,
args: [dataSourceAndDSMap['dataSource2']],
extensionManager,
defaultDataSourceName: 'dataSource2',
});
/** Assert */
expect(extensionManager.getDataSources).toHaveBeenCalledTimes(1);
expect(extensionManager.getDataSources).toHaveBeenCalledWith('dataSource2');
expect(data).toEqual(['234']);
});
});
});

View File

@@ -0,0 +1,293 @@
import { DicomMetadataStore, IWebApiDataSource } from '@ohif/core';
import { get, uniqBy } from 'lodash';
import {
MergeConfig,
CallForAllDataSourcesAsyncOptions,
CallForAllDataSourcesOptions,
CallForDefaultDataSourceOptions,
CallByRetrieveAETitleOptions,
MergeMap,
} from './types';
export const mergeMap: MergeMap = {
'query.studies.search': {
mergeKey: 'studyInstanceUid',
tagFunc: x => x,
},
'query.series.search': {
mergeKey: 'seriesInstanceUid',
tagFunc: (series, sourceName) => {
series.forEach(series => {
series.RetrieveAETitle = sourceName;
DicomMetadataStore.updateSeriesMetadata(series);
});
return series;
},
},
};
/**
* Calls all data sources asynchronously and merges the results.
* @param {CallForAllDataSourcesAsyncOptions} options - The options for calling all data sources.
* @param {string} options.path - The path to the function to be called on each data source.
* @param {unknown[]} options.args - The arguments to be passed to the function.
* @param {ExtensionManager} options.extensionManager - The extension manager.
* @param {string[]} options.dataSourceNames - The names of the data sources to be called.
* @param {string} options.defaultDataSourceName - The name of the default data source.
* @returns {Promise<unknown[]>} - A promise that resolves to the merged data from all data sources.
*/
export const callForAllDataSourcesAsync = async ({
mergeMap,
path,
args,
extensionManager,
dataSourceNames,
defaultDataSourceName,
}: CallForAllDataSourcesAsyncOptions) => {
const { mergeKey, tagFunc } = mergeMap[path] || { tagFunc: x => x };
/** Sort by default data source */
const defs = Object.values(extensionManager.dataSourceDefs);
const defaultDataSourceDef = defs.find(def => def.sourceName === defaultDataSourceName);
const dataSourceDefs = defs.filter(def => def.sourceName !== defaultDataSourceName);
if (defaultDataSourceDef) {
dataSourceDefs.unshift(defaultDataSourceDef);
}
const promises = [];
const sourceNames = [];
for (const dataSourceDef of dataSourceDefs) {
const { configuration, sourceName } = dataSourceDef;
if (!!configuration && dataSourceNames.includes(sourceName)) {
const [dataSource] = extensionManager.getDataSources(sourceName);
const func = get(dataSource, path);
const promise = func.apply(dataSource, args);
promises.push(promise);
sourceNames.push(sourceName);
}
}
const data = await Promise.allSettled(promises);
const mergedData = data.map((data, i) => tagFunc(data.value, sourceNames[i]));
let results = [];
if (mergeKey) {
results = uniqBy(mergedData.flat(), obj => get(obj, mergeKey));
} else {
results = mergedData.flat();
}
return results;
};
/**
* Calls all data sources that match the provided names and merges their data.
* @param options - The options for calling all data sources.
* @param options.path - The path to the function to be called on each data source.
* @param options.args - The arguments to be passed to the function.
* @param options.extensionManager - The extension manager instance.
* @param options.dataSourceNames - The names of the data sources to be called.
* @param options.defaultDataSourceName - The name of the default data source.
* @returns The merged data from all the matching data sources.
*/
export const callForAllDataSources = ({
path,
args,
extensionManager,
dataSourceNames,
defaultDataSourceName,
}: CallForAllDataSourcesOptions) => {
/** Sort by default data source */
const defs = Object.values(extensionManager.dataSourceDefs);
const defaultDataSourceDef = defs.find(def => def.sourceName === defaultDataSourceName);
const dataSourceDefs = defs.filter(def => def.sourceName !== defaultDataSourceName);
if (defaultDataSourceDef) {
dataSourceDefs.unshift(defaultDataSourceDef);
}
const mergedData = [];
for (const dataSourceDef of dataSourceDefs) {
const { configuration, sourceName } = dataSourceDef;
if (!!configuration && dataSourceNames.includes(sourceName)) {
const [dataSource] = extensionManager.getDataSources(sourceName);
const func = get(dataSource, path);
const data = func.apply(dataSource, args);
mergedData.push(data);
}
}
return mergedData.flat();
};
/**
* Calls the default data source function specified by the given path with the provided arguments.
* @param {CallForDefaultDataSourceOptions} options - The options for calling the default data source.
* @param {string} options.path - The path to the function within the default data source.
* @param {unknown[]} options.args - The arguments to pass to the function.
* @param {string} options.defaultDataSourceName - The name of the default data source.
* @param {ExtensionManager} options.extensionManager - The extension manager instance.
* @returns {unknown} - The result of calling the default data source function.
*/
export const callForDefaultDataSource = ({
path,
args,
defaultDataSourceName,
extensionManager,
}: CallForDefaultDataSourceOptions) => {
const [dataSource] = extensionManager.getDataSources(defaultDataSourceName);
const func = get(dataSource, path);
return func.apply(dataSource, args);
};
/**
* Calls the data source specified by the RetrieveAETitle of the given display set.
* @typedef {Object} CallByRetrieveAETitleOptions
* @property {string} path - The path of the method to call on the data source.
* @property {any[]} args - The arguments to pass to the method.
* @property {string} defaultDataSourceName - The name of the default data source.
* @property {ExtensionManager} extensionManager - The extension manager.
*/
export const callByRetrieveAETitle = ({
path,
args,
defaultDataSourceName,
extensionManager,
}: CallByRetrieveAETitleOptions) => {
const [displaySet] = args;
const seriesMetadata = DicomMetadataStore.getSeries(
displaySet.StudyInstanceUID,
displaySet.SeriesInstanceUID
);
const [dataSource] = extensionManager.getDataSources(
seriesMetadata.RetrieveAETitle || defaultDataSourceName
);
return dataSource[path](...args);
};
function createMergeDataSourceApi(
mergeConfig: MergeConfig,
servicesManager: AppTypes.ServicesManager,
extensionManager
) {
const { seriesMerge } = mergeConfig;
const { dataSourceNames, defaultDataSourceName } = seriesMerge;
const implementation = {
initialize: (...args: unknown[]) =>
callForAllDataSources({
path: 'initialize',
args,
extensionManager,
dataSourceNames,
defaultDataSourceName,
}),
query: {
studies: {
search: (...args: unknown[]) =>
callForAllDataSourcesAsync({
mergeMap,
path: 'query.studies.search',
args,
extensionManager,
dataSourceNames,
defaultDataSourceName,
}),
},
series: {
search: (...args: unknown[]) =>
callForAllDataSourcesAsync({
mergeMap,
path: 'query.series.search',
args,
extensionManager,
dataSourceNames,
defaultDataSourceName,
}),
},
instances: {
search: (...args: unknown[]) =>
callForAllDataSourcesAsync({
mergeMap,
path: 'query.instances.search',
args,
extensionManager,
dataSourceNames,
defaultDataSourceName,
}),
},
},
retrieve: {
bulkDataURI: (...args: unknown[]) =>
callForAllDataSourcesAsync({
mergeMap,
path: 'retrieve.bulkDataURI',
args,
extensionManager,
dataSourceNames,
defaultDataSourceName,
}),
directURL: (...args: unknown[]) =>
callForDefaultDataSource({
path: 'retrieve.directURL',
args,
defaultDataSourceName,
extensionManager,
}),
series: {
metadata: (...args: unknown[]) =>
callForAllDataSourcesAsync({
mergeMap,
path: 'retrieve.series.metadata',
args,
extensionManager,
dataSourceNames,
defaultDataSourceName,
}),
},
},
store: {
dicom: (...args: unknown[]) =>
callForDefaultDataSource({
path: 'store.dicom',
args,
defaultDataSourceName,
extensionManager,
}),
},
deleteStudyMetadataPromise: (...args: unknown[]) =>
callForAllDataSources({
path: 'deleteStudyMetadataPromise',
args,
extensionManager,
dataSourceNames,
defaultDataSourceName,
}),
getImageIdsForDisplaySet: (...args: unknown[]) =>
callByRetrieveAETitle({
path: 'getImageIdsForDisplaySet',
args,
defaultDataSourceName,
extensionManager,
}),
getImageIdsForInstance: (...args: unknown[]) =>
callByRetrieveAETitle({
path: 'getImageIdsForDisplaySet',
args,
defaultDataSourceName,
extensionManager,
}),
getStudyInstanceUIDs: (...args: unknown[]) =>
callForAllDataSources({
path: 'getStudyInstanceUIDs',
args,
extensionManager,
dataSourceNames,
defaultDataSourceName,
}),
};
return IWebApiDataSource.create(implementation);
}
export { createMergeDataSourceApi };

View File

@@ -0,0 +1,46 @@
import { ExtensionManager } from '@ohif/core';
export type MergeMap = {
[key: string]: {
mergeKey: string;
tagFunc: (data: unknown[], sourceName: string) => unknown[];
};
};
export type CallForAllDataSourcesAsyncOptions = {
mergeMap: object;
path: string;
args: unknown[];
dataSourceNames: string[];
extensionManager: ExtensionManager;
defaultDataSourceName: string;
};
export type CallForAllDataSourcesOptions = {
path: string;
args: unknown[];
dataSourceNames: string[];
extensionManager: ExtensionManager;
defaultDataSourceName: string;
};
export type CallForDefaultDataSourceOptions = {
path: string;
args: unknown[];
defaultDataSourceName: string;
extensionManager: ExtensionManager;
};
export type CallByRetrieveAETitleOptions = {
path: string;
args: unknown[];
defaultDataSourceName: string;
extensionManager: ExtensionManager;
};
export type MergeConfig = {
seriesMerge: {
dataSourceNames: string[];
defaultDataSourceName: string;
};
};

View File

@@ -0,0 +1,55 @@
import React from 'react';
import classnames from 'classnames';
import { useNavigate } from 'react-router-dom';
import { useAppConfig } from '@state';
import { Button, ButtonEnums } from '@ohif/ui';
function DataSourceSelector() {
const [appConfig] = useAppConfig();
const navigate = useNavigate();
// This is frowned upon, but the raw config is needed here to provide
// the selector
const dsConfigs = appConfig.dataSources;
return (
<div style={{ width: '100%', height: '100%' }}>
<div className="flex h-screen w-screen items-center justify-center ">
<div className="bg-secondary-dark mx-auto space-y-2 rounded-lg py-8 px-8 drop-shadow-md">
<img
className="mx-auto block h-14"
src="./ohif-logo.svg"
alt="OHIF"
/>
<div className="space-y-2 pt-4 text-center">
{dsConfigs
.filter(it => it.sourceName !== 'dicomjson' && it.sourceName !== 'dicomlocal')
.map(ds => (
<div key={ds.sourceName}>
<h1 className="text-white">
{ds.configuration?.friendlyName || ds.friendlyName}
</h1>
<Button
type={ButtonEnums.type.primary}
className={classnames('ml-2')}
onClick={() => {
navigate({
pathname: '/',
search: `datasources=${ds.sourceName}`,
});
}}
>
{ds.sourceName}
</Button>
<br />
</div>
))}
</div>
</div>
</div>
</div>
);
}
export default DataSourceSelector;

View File

@@ -0,0 +1,392 @@
import React, { useState, useEffect } from 'react';
import { useImageViewer, useViewportGrid } from '@ohif/ui';
import { StudyBrowser } from '@ohif/ui-next';
import { utils } from '@ohif/core';
import { useNavigate } from 'react-router-dom';
import { Separator } from '@ohif/ui-next';
import { PanelStudyBrowserHeader } from './PanelStudyBrowserHeader';
import { defaultActionIcons, defaultViewPresets } from './constants';
const { sortStudyInstances, formatDate, createStudyBrowserTabs } = utils;
/**
*
* @param {*} param0
*/
function PanelStudyBrowser({
servicesManager,
getImageSrc,
getStudiesForPatientByMRN,
requestDisplaySetCreationForStudy,
dataSource,
commandsManager,
}: withAppTypes) {
const { hangingProtocolService, displaySetService, uiNotificationService, customizationService } =
servicesManager.services;
const navigate = useNavigate();
// Normally you nest the components so the tree isn't so deep, and the data
// doesn't have to have such an intense shape. This works well enough for now.
// Tabs --> Studies --> DisplaySets --> Thumbnails
const { StudyInstanceUIDs } = useImageViewer();
const [{ activeViewportId, viewports, isHangingProtocolLayout }, viewportGridService] =
useViewportGrid();
const [activeTabName, setActiveTabName] = useState('all');
const [expandedStudyInstanceUIDs, setExpandedStudyInstanceUIDs] = useState([
...StudyInstanceUIDs,
]);
const [hasLoadedViewports, setHasLoadedViewports] = useState(false);
const [studyDisplayList, setStudyDisplayList] = useState([]);
const [displaySets, setDisplaySets] = useState([]);
const [thumbnailImageSrcMap, setThumbnailImageSrcMap] = useState({});
const [viewPresets, setViewPresets] = useState(
customizationService.getCustomization('studyBrowser.viewPresets')?.value || defaultViewPresets
);
const [actionIcons, setActionIcons] = useState(defaultActionIcons);
// multiple can be true or false
const updateActionIconValue = actionIcon => {
actionIcon.value = !actionIcon.value;
const newActionIcons = [...actionIcons];
setActionIcons(newActionIcons);
};
// only one is true at a time
const updateViewPresetValue = viewPreset => {
if (!viewPreset) {
return;
}
const newViewPresets = viewPresets.map(preset => {
preset.selected = preset.id === viewPreset.id;
return preset;
});
setViewPresets(newViewPresets);
};
const onDoubleClickThumbnailHandler = displaySetInstanceUID => {
let updatedViewports = [];
const viewportId = activeViewportId;
try {
updatedViewports = hangingProtocolService.getViewportsRequireUpdate(
viewportId,
displaySetInstanceUID,
isHangingProtocolLayout
);
} catch (error) {
console.warn(error);
uiNotificationService.show({
title: 'Thumbnail Double Click',
message: 'The selected display sets could not be added to the viewport.',
type: 'error',
duration: 3000,
});
}
viewportGridService.setDisplaySetsForViewports(updatedViewports);
};
// ~~ studyDisplayList
useEffect(() => {
// Fetch all studies for the patient in each primary study
async function fetchStudiesForPatient(StudyInstanceUID) {
// current study qido
const qidoForStudyUID = await dataSource.query.studies.search({
studyInstanceUid: StudyInstanceUID,
});
if (!qidoForStudyUID?.length) {
navigate('/notfoundstudy', '_self');
throw new Error('Invalid study URL');
}
let qidoStudiesForPatient = qidoForStudyUID;
// try to fetch the prior studies based on the patientID if the
// server can respond.
try {
qidoStudiesForPatient = await getStudiesForPatientByMRN(qidoForStudyUID);
} catch (error) {
console.warn(error);
}
const mappedStudies = _mapDataSourceStudies(qidoStudiesForPatient);
const actuallyMappedStudies = mappedStudies.map(qidoStudy => {
return {
studyInstanceUid: qidoStudy.StudyInstanceUID,
date: formatDate(qidoStudy.StudyDate),
description: qidoStudy.StudyDescription,
modalities: qidoStudy.ModalitiesInStudy,
numInstances: qidoStudy.NumInstances,
};
});
setStudyDisplayList(prevArray => {
const ret = [...prevArray];
for (const study of actuallyMappedStudies) {
if (!prevArray.find(it => it.studyInstanceUid === study.studyInstanceUid)) {
ret.push(study);
}
}
return ret;
});
}
StudyInstanceUIDs.forEach(sid => fetchStudiesForPatient(sid));
}, [StudyInstanceUIDs, dataSource, getStudiesForPatientByMRN, navigate]);
// // ~~ Initial Thumbnails
useEffect(() => {
if (!hasLoadedViewports) {
if (activeViewportId) {
// Once there is an active viewport id, it means the layout is ready
// so wait a bit of time to allow the viewports preferential loading
// which improves user experience of responsiveness significantly on slower
// systems.
window.setTimeout(() => setHasLoadedViewports(true), 250);
}
return;
}
const currentDisplaySets = displaySetService.activeDisplaySets;
currentDisplaySets.forEach(async dSet => {
const newImageSrcEntry = {};
const displaySet = displaySetService.getDisplaySetByUID(dSet.displaySetInstanceUID);
const imageIds = dataSource.getImageIdsForDisplaySet(displaySet);
const imageId = imageIds[Math.floor(imageIds.length / 2)];
// TODO: Is it okay that imageIds are not returned here for SR displaySets?
if (!imageId || displaySet?.unsupported) {
return;
}
// When the image arrives, render it and store the result in the thumbnailImgSrcMap
newImageSrcEntry[dSet.displaySetInstanceUID] = await getImageSrc(imageId);
setThumbnailImageSrcMap(prevState => {
return { ...prevState, ...newImageSrcEntry };
});
});
}, [
StudyInstanceUIDs,
dataSource,
displaySetService,
getImageSrc,
hasLoadedViewports,
activeViewportId,
]);
// ~~ displaySets
useEffect(() => {
// TODO: Are we sure `activeDisplaySets` will always be accurate?
const currentDisplaySets = displaySetService.activeDisplaySets;
const mappedDisplaySets = _mapDisplaySets(currentDisplaySets, thumbnailImageSrcMap);
sortStudyInstances(mappedDisplaySets);
setDisplaySets(mappedDisplaySets);
}, [StudyInstanceUIDs, thumbnailImageSrcMap, displaySetService]);
// ~~ subscriptions --> displaySets
useEffect(() => {
// DISPLAY_SETS_ADDED returns an array of DisplaySets that were added
const SubscriptionDisplaySetsAdded = displaySetService.subscribe(
displaySetService.EVENTS.DISPLAY_SETS_ADDED,
data => {
// for some reason this breaks thumbnail loading
// if (!hasLoadedViewports) {
// return;
// }
const { displaySetsAdded } = data;
displaySetsAdded.forEach(async dSet => {
const newImageSrcEntry = {};
const displaySet = displaySetService.getDisplaySetByUID(dSet.displaySetInstanceUID);
if (displaySet?.unsupported) {
return;
}
const imageIds = dataSource.getImageIdsForDisplaySet(displaySet);
const imageId = imageIds[Math.floor(imageIds.length / 2)];
// TODO: Is it okay that imageIds are not returned here for SR displaysets?
if (!imageId) {
return;
}
// When the image arrives, render it and store the result in the thumbnailImgSrcMap
newImageSrcEntry[dSet.displaySetInstanceUID] = await getImageSrc(
imageId,
dSet.initialViewport
);
setThumbnailImageSrcMap(prevState => {
return { ...prevState, ...newImageSrcEntry };
});
});
}
);
return () => {
SubscriptionDisplaySetsAdded.unsubscribe();
};
}, [getImageSrc, dataSource, displaySetService]);
useEffect(() => {
// TODO: Will this always hold _all_ the displaySets we care about?
// DISPLAY_SETS_CHANGED returns `DisplaySerService.activeDisplaySets`
const SubscriptionDisplaySetsChanged = displaySetService.subscribe(
displaySetService.EVENTS.DISPLAY_SETS_CHANGED,
changedDisplaySets => {
const mappedDisplaySets = _mapDisplaySets(changedDisplaySets, thumbnailImageSrcMap);
setDisplaySets(mappedDisplaySets);
}
);
const SubscriptionDisplaySetMetaDataInvalidated = displaySetService.subscribe(
displaySetService.EVENTS.DISPLAY_SET_SERIES_METADATA_INVALIDATED,
() => {
const mappedDisplaySets = _mapDisplaySets(
displaySetService.getActiveDisplaySets(),
thumbnailImageSrcMap
);
setDisplaySets(mappedDisplaySets);
}
);
return () => {
SubscriptionDisplaySetsChanged.unsubscribe();
SubscriptionDisplaySetMetaDataInvalidated.unsubscribe();
};
}, [StudyInstanceUIDs, thumbnailImageSrcMap, displaySetService]);
const tabs = createStudyBrowserTabs(StudyInstanceUIDs, studyDisplayList, displaySets);
// TODO: Should not fire this on "close"
function _handleStudyClick(StudyInstanceUID) {
const shouldCollapseStudy = expandedStudyInstanceUIDs.includes(StudyInstanceUID);
const updatedExpandedStudyInstanceUIDs = shouldCollapseStudy
? // eslint-disable-next-line prettier/prettier
[...expandedStudyInstanceUIDs.filter(stdyUid => stdyUid !== StudyInstanceUID)]
: [...expandedStudyInstanceUIDs, StudyInstanceUID];
setExpandedStudyInstanceUIDs(updatedExpandedStudyInstanceUIDs);
if (!shouldCollapseStudy) {
const madeInClient = true;
requestDisplaySetCreationForStudy(displaySetService, StudyInstanceUID, madeInClient);
}
}
const activeDisplaySetInstanceUIDs = viewports.get(activeViewportId)?.displaySetInstanceUIDs;
const onThumbnailContextMenu = (commandName, options) => {
commandsManager.runCommand(commandName, options);
};
return (
<>
<>
<PanelStudyBrowserHeader
viewPresets={viewPresets}
updateViewPresetValue={updateViewPresetValue}
actionIcons={actionIcons}
updateActionIconValue={updateActionIconValue}
/>
<Separator
orientation="horizontal"
className="bg-black"
thickness="2px"
/>
</>
<StudyBrowser
tabs={tabs}
servicesManager={servicesManager}
activeTabName={activeTabName}
onDoubleClickThumbnail={onDoubleClickThumbnailHandler}
activeDisplaySetInstanceUIDs={activeDisplaySetInstanceUIDs}
expandedStudyInstanceUIDs={expandedStudyInstanceUIDs}
onClickStudy={_handleStudyClick}
onClickTab={clickedTabName => {
setActiveTabName(clickedTabName);
}}
showSettings={actionIcons.find(icon => icon.id === 'settings').value}
viewPresets={viewPresets}
onThumbnailContextMenu={onThumbnailContextMenu}
/>
</>
);
}
export default PanelStudyBrowser;
/**
* Maps from the DataSource's format to a naturalized object
*
* @param {*} studies
*/
function _mapDataSourceStudies(studies) {
return studies.map(study => {
// TODO: Why does the data source return in this format?
return {
AccessionNumber: study.accession,
StudyDate: study.date,
StudyDescription: study.description,
NumInstances: study.instances,
ModalitiesInStudy: study.modalities,
PatientID: study.mrn,
PatientName: study.patientName,
StudyInstanceUID: study.studyInstanceUid,
StudyTime: study.time,
};
});
}
function _mapDisplaySets(displaySets, thumbnailImageSrcMap) {
const thumbnailDisplaySets = [];
const thumbnailNoImageDisplaySets = [];
displaySets
.filter(ds => !ds.excludeFromThumbnailBrowser)
.forEach(ds => {
const imageSrc = thumbnailImageSrcMap[ds.displaySetInstanceUID];
const componentType = _getComponentType(ds);
const array =
componentType === 'thumbnail' ? thumbnailDisplaySets : thumbnailNoImageDisplaySets;
array.push({
displaySetInstanceUID: ds.displaySetInstanceUID,
description: ds.SeriesDescription || '',
seriesNumber: ds.SeriesNumber,
modality: ds.Modality,
seriesDate: ds.SeriesDate,
seriesTime: ds.SeriesTime,
numInstances: ds.numImageFrames,
countIcon: ds.countIcon,
StudyInstanceUID: ds.StudyInstanceUID,
messages: ds.messages,
componentType,
imageSrc,
dragData: {
type: 'displayset',
displaySetInstanceUID: ds.displaySetInstanceUID,
// .. Any other data to pass
},
isHydratedForDerivedDisplaySet: ds.isHydrated,
});
});
return [...thumbnailDisplaySets, ...thumbnailNoImageDisplaySets];
}
const thumbnailNoImageModalities = ['SR', 'SEG', 'SM', 'RTSTRUCT', 'RTPLAN', 'RTDOSE'];
function _getComponentType(ds) {
if (thumbnailNoImageModalities.includes(ds.Modality) || ds?.unsupported) {
// TODO probably others.
return 'thumbnailNoImage';
}
return 'thumbnail';
}

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { ToggleGroup, ToggleGroupItem } from '@ohif/ui-next';
import { Icons } from '@ohif/ui-next';
import { actionIcon, viewPreset } from './types';
function PanelStudyBrowserHeader({
viewPresets,
updateViewPresetValue,
actionIcons,
updateActionIconValue,
}: {
viewPresets: viewPreset[];
updateViewPresetValue: (viewPreset: viewPreset) => void;
actionIcons: actionIcon[];
updateActionIconValue: (actionIcon: actionIcon) => void;
}) {
return (
<>
<div className="bg-muted flex h-[40px] select-none rounded-t p-2">
<div className={'flex h-[24px] w-full select-none justify-center self-center text-[14px]'}>
<div className="flex w-full items-center gap-[10px]">
<div className="flex items-center justify-center">
<div className="text-primary-active flex items-center space-x-1">
{actionIcons.map((icon: actionIcon, index) =>
React.createElement(Icons[icon.iconName] || Icons.MissingIcon, {
key: index,
onClick: () => updateActionIconValue(icon),
className: `cursor-pointer`,
})
)}
</div>
</div>
<div className="ml-auto flex h-full items-center justify-center">
<ToggleGroup
type="single"
value={viewPresets.filter(preset => preset.selected)[0].id}
onValueChange={value => {
const selectedViewPreset = viewPresets.find(preset => preset.id === value);
updateViewPresetValue(selectedViewPreset);
}}
>
{viewPresets.map((viewPreset: viewPreset, index) => (
<ToggleGroupItem
key={index}
aria-label={viewPreset.id}
value={viewPreset.id}
className="text-actions-primary"
>
{React.createElement(Icons[viewPreset.iconName] || Icons.MissingIcon)}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
</div>
</div>
</div>
</>
);
}
export { PanelStudyBrowserHeader };

View File

@@ -0,0 +1,11 @@
import type { actionIcon } from '../types/actionsIcon';
const defaultActionIcons = [
{
id: 'settings',
iconName: 'Settings',
value: false,
},
] as actionIcon[];
export { defaultActionIcons };

View File

@@ -0,0 +1,4 @@
import { defaultActionIcons } from './actionIcons';
import { defaultViewPresets } from './viewPresets';
export { defaultActionIcons, defaultViewPresets };

View File

@@ -0,0 +1,16 @@
import type { viewPreset } from '../types/viewPreset';
const defaultViewPresets = [
{
id: 'list',
iconName: 'ListView',
selected: false,
},
{
id: 'thumbnails',
iconName: 'ThumbnailView',
selected: true,
},
] as viewPreset[];
export { defaultViewPresets };

View File

@@ -0,0 +1,7 @@
type actionIcon = {
id: string;
iconName: string;
value: boolean;
};
export type { actionIcon };

View File

@@ -0,0 +1,4 @@
import type { actionIcon } from './actionIcon';
import type { viewPreset } from './viewPreset';
export type { actionIcon, viewPreset };

View File

@@ -0,0 +1,7 @@
type viewPreset = {
id: string;
iconName: string;
selected: boolean;
};
export type { viewPreset };

View File

@@ -0,0 +1,69 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
//
import PanelStudyBrowser from './StudyBrowser/PanelStudyBrowser';
import getImageSrcFromImageId from './getImageSrcFromImageId';
import getStudiesForPatientByMRN from './getStudiesForPatientByMRN';
import requestDisplaySetCreationForStudy from './requestDisplaySetCreationForStudy';
/**
* Wraps the PanelStudyBrowser and provides features afforded by managers/services
*
* @param {object} params
* @param {object} commandsManager
* @param {object} extensionManager
*/
function WrappedPanelStudyBrowser({ commandsManager, extensionManager, servicesManager }) {
// TODO: This should be made available a different way; route should have
// already determined our datasource
const dataSource = extensionManager.getDataSources()[0];
const _getStudiesForPatientByMRN = getStudiesForPatientByMRN.bind(null, dataSource);
const _getImageSrcFromImageId = useCallback(
_createGetImageSrcFromImageIdFn(extensionManager),
[]
);
const _requestDisplaySetCreationForStudy = requestDisplaySetCreationForStudy.bind(
null,
dataSource
);
return (
<PanelStudyBrowser
servicesManager={servicesManager}
dataSource={dataSource}
getImageSrc={_getImageSrcFromImageId}
getStudiesForPatientByMRN={_getStudiesForPatientByMRN}
requestDisplaySetCreationForStudy={_requestDisplaySetCreationForStudy}
/>
);
}
/**
* Grabs cornerstone library reference using a dependent command from
* the @ohif/extension-cornerstone extension. Then creates a helper function
* that can take an imageId and return an image src.
*
* @param {func} getCommand - CommandManager's getCommand method
* @returns {func} getImageSrcFromImageId - A utility function powered by
* cornerstone
*/
function _createGetImageSrcFromImageIdFn(extensionManager) {
const utilities = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.utilityModule.common'
);
try {
const { cornerstone } = utilities.exports.getCornerstoneLibraries();
return getImageSrcFromImageId.bind(null, cornerstone);
} catch (ex) {
throw new Error('Required command not found');
}
}
WrappedPanelStudyBrowser.propTypes = {
commandsManager: PropTypes.object.isRequired,
extensionManager: PropTypes.object.isRequired,
servicesManager: PropTypes.object.isRequired,
};
export default WrappedPanelStudyBrowser;

View File

@@ -0,0 +1,134 @@
import React from 'react';
import { ButtonEnums, Dialog, Input, Select } from '@ohif/ui';
import PROMPT_RESPONSES from '../utils/_shared/PROMPT_RESPONSES';
export default function CreateReportDialogPrompt(uiDialogService, { extensionManager }) {
return new Promise(function (resolve, reject) {
let dialogId = undefined;
const _handleClose = () => {
// Dismiss dialog
uiDialogService.dismiss({ id: dialogId });
// Notify of cancel action
resolve({
action: PROMPT_RESPONSES.CANCEL,
value: undefined,
dataSourceName: undefined,
});
};
/**
*
* @param {string} param0.action - value of action performed
* @param {string} param0.value - value from input field
*/
const _handleFormSubmit = ({ action, value }) => {
uiDialogService.dismiss({ id: dialogId });
switch (action.id) {
case 'save':
resolve({
action: PROMPT_RESPONSES.CREATE_REPORT,
value: value.label,
dataSourceName: value.dataSourceName,
});
break;
case 'cancel':
resolve({
action: PROMPT_RESPONSES.CANCEL,
value: undefined,
dataSourceName: undefined,
});
break;
}
};
const dataSourcesOpts = Object.keys(extensionManager.dataSourceMap)
.filter(ds => {
const configuration = extensionManager.dataSourceDefs[ds]?.configuration;
const supportsStow = configuration?.supportsStow ?? configuration?.wadoRoot;
return supportsStow;
})
.map(ds => {
return {
value: ds,
label: ds,
placeHolder: ds,
};
});
dialogId = uiDialogService.create({
centralize: true,
isDraggable: false,
content: Dialog,
useLastPosition: false,
showOverlay: true,
contentProps: {
title: 'Create Report',
value: {
label: '',
dataSourceName: extensionManager.activeDataSource,
},
noCloseButton: true,
onClose: _handleClose,
actions: [
{ id: 'cancel', text: 'Cancel', type: ButtonEnums.type.secondary },
{ id: 'save', text: 'Save', type: ButtonEnums.type.primary },
],
// TODO: Should be on button press...
onSubmit: _handleFormSubmit,
body: ({ value, setValue }) => {
const onChangeHandler = event => {
event.persist();
setValue(value => ({ ...value, label: event.target.value }));
};
const onKeyPressHandler = event => {
if (event.key === 'Enter') {
uiDialogService.dismiss({ id: dialogId });
resolve({
action: PROMPT_RESPONSES.CREATE_REPORT,
value: value.label,
});
}
};
return (
<>
{dataSourcesOpts.length > 1 && window.config?.allowMultiSelectExport && (
<div>
<label className="text-[14px] leading-[1.2] text-white">Data Source</label>
<Select
closeMenuOnSelect={true}
className="border-primary-main mt-2 bg-black"
options={dataSourcesOpts}
placeholder={
dataSourcesOpts.find(option => option.value === value.dataSourceName)
.placeHolder
}
value={value.dataSourceName}
onChange={evt => {
setValue(v => ({ ...v, dataSourceName: evt.value }));
}}
isClearable={false}
/>
</div>
)}
<div className="mt-3">
<Input
autoFocus
label="Enter the report name"
labelClassName="text-white text-[14px] leading-[1.2]"
className="border-primary-main bg-black"
type="text"
value={value.label}
onChange={onChangeHandler}
onKeyPress={onKeyPressHandler}
required
/>
</div>
</>
);
},
},
});
});
}

View File

@@ -0,0 +1,25 @@
// Returns a function, that, as long as it continues to be invoked, will not
// be triggered. The function will be called after it stops being called for
// N milliseconds. If `immediate` is passed, trigger the function on the
// leading edge, instead of the trailing.
function debounce(func, wait, immediate) {
var timeout;
return function () {
var context = this,
args = arguments;
var later = function () {
timeout = null;
if (!immediate) {
func.apply(context, args);
}
};
var callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) {
func.apply(context, args);
}
};
}
export default debounce;

View File

@@ -0,0 +1,16 @@
/**
* @param {*} cornerstone
* @param {*} imageId
*/
function getImageSrcFromImageId(cornerstone, imageId) {
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas');
cornerstone.utilities
.loadImageToCanvas({ canvas, imageId, thumbnail: true })
.then(imageId => {
resolve(canvas.toDataURL());
})
.catch(reject);
});
}
export default getImageSrcFromImageId;

View File

@@ -0,0 +1,12 @@
async function getStudiesForPatientByMRN(dataSource, qidoForStudyUID) {
if (qidoForStudyUID && qidoForStudyUID.length && qidoForStudyUID[0].mrn) {
return dataSource.query.studies.search({
patientId: qidoForStudyUID[0].mrn,
disableWildcard: true,
});
}
console.log('No mrn found for', qidoForStudyUID);
return qidoForStudyUID;
}
export default getStudiesForPatientByMRN;

View File

@@ -0,0 +1,5 @@
import PanelStudyBrowser from './StudyBrowser/PanelStudyBrowser';
import WrappedPanelStudyBrowser from './WrappedPanelStudyBrowser';
import createReportDialogPrompt from './createReportDialogPrompt';
export { PanelStudyBrowser, WrappedPanelStudyBrowser, createReportDialogPrompt };

View File

@@ -0,0 +1,19 @@
function requestDisplaySetCreationForStudy(
dataSource,
displaySetService,
StudyInstanceUID,
madeInClient
) {
// TODO: is this already short-circuited by the map of Retrieve promises?
if (
displaySetService.activeDisplaySets.some(
displaySet => displaySet.StudyInstanceUID === StudyInstanceUID
)
) {
return;
}
dataSource.retrieve.series.metadata({ StudyInstanceUID, madeInClient });
}
export default requestDisplaySetCreationForStudy;

View File

@@ -0,0 +1,96 @@
import { Types, DisplaySetService, utils } from '@ohif/core';
import { id } from '../id';
type InstanceMetadata = Types.InstanceMetadata;
const SOPClassHandlerName = 'chart';
const CHART_MODALITY = 'CHT';
// Private SOPClassUid for chart data
const ChartDataSOPClassUid = '1.9.451.13215.7.3.2.7.6.1';
const sopClassUids = [ChartDataSOPClassUid];
const makeChartDataDisplaySet = (instance, sopClassUids) => {
const {
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID,
SeriesDescription,
SeriesNumber,
SeriesDate,
SOPClassUID,
} = instance;
return {
Modality: CHART_MODALITY,
loading: false,
isReconstructable: false,
displaySetInstanceUID: utils.guid(),
SeriesDescription,
SeriesNumber,
SeriesDate,
SOPInstanceUID,
SeriesInstanceUID,
StudyInstanceUID,
SOPClassHandlerId: `${id}.sopClassHandlerModule.${SOPClassHandlerName}`,
SOPClassUID,
isDerivedDisplaySet: true,
isLoaded: true,
sopClassUids,
instance,
instances: [instance],
/**
* Adds instances to the chart displaySet, rather than creating a new one
* when user moves to a different workflow step and gets back to a step that
* recreates the chart
*/
addInstances: function (instances: InstanceMetadata[], _displaySetService: DisplaySetService) {
this.instances.push(...instances);
this.instance = this.instances[this.instances.length - 1];
return this;
},
};
};
function getSopClassUids(instances) {
const uniqueSopClassUidsInSeries = new Set();
instances.forEach(instance => {
uniqueSopClassUidsInSeries.add(instance.SOPClassUID);
});
const sopClassUids = Array.from(uniqueSopClassUidsInSeries);
return sopClassUids;
}
function _getDisplaySetsFromSeries(instances) {
// If the series has no instances, stop here
if (!instances || !instances.length) {
throw new Error('No instances were provided');
}
const sopClassUids = getSopClassUids(instances);
const displaySets = instances.map(instance => {
if (instance.Modality === CHART_MODALITY) {
return makeChartDataDisplaySet(instance, sopClassUids);
}
throw new Error('Unsupported modality');
});
return displaySets;
}
const chartHandler = {
name: SOPClassHandlerName,
sopClassUids,
getDisplaySetsFromSeries: instances => {
return _getDisplaySetsFromSeries(instances);
},
};
export { chartHandler };

View File

@@ -0,0 +1,87 @@
import React, { useEffect, useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import { LayoutSelector as OHIFLayoutSelector, ToolbarButton } from '@ohif/ui';
function LegacyLayoutSelectorWithServices({
servicesManager,
rows = 3,
columns = 3,
onLayoutChange = () => {},
...props
}) {
const { toolbarService } = servicesManager.services;
const onSelection = useCallback(
props => {
toolbarService.recordInteraction({
interactionType: 'action',
commands: [
{
commandName: 'setViewportGridLayout',
commandOptions: { ...props },
context: 'DEFAULT',
},
],
});
},
[toolbarService]
);
return (
<LayoutSelector
{...props}
onSelection={onSelection}
/>
);
}
function LayoutSelector({ rows, columns, className, onSelection, ...rest }) {
const [isOpen, setIsOpen] = useState(false);
const closeOnOutsideClick = () => {
if (isOpen) {
setIsOpen(false);
}
};
useEffect(() => {
window.addEventListener('click', closeOnOutsideClick);
return () => {
window.removeEventListener('click', closeOnOutsideClick);
};
}, [isOpen]);
const onInteractionHandler = () => setIsOpen(!isOpen);
const DropdownContent = isOpen ? OHIFLayoutSelector : null;
return (
<ToolbarButton
id="Layout"
label="Grid Layout"
icon="tool-layout"
onInteraction={onInteractionHandler}
className={className}
rounded={rest.rounded}
dropdownContent={
DropdownContent !== null && (
<DropdownContent
rows={rows}
columns={columns}
onSelection={onSelection}
/>
)
}
isActive={isOpen}
type="toggle"
/>
);
}
LayoutSelector.propTypes = {
rows: PropTypes.number,
columns: PropTypes.number,
onLayoutChange: PropTypes.func,
servicesManager: PropTypes.object.isRequired,
};
export default LegacyLayoutSelectorWithServices;

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { Tooltip } from '@ohif/ui';
import classnames from 'classnames';
import { useToolbar } from '@ohif/core';
export function Toolbar({ servicesManager, buttonSection = 'primary' }) {
const { toolbarButtons, onInteraction } = useToolbar({
servicesManager,
buttonSection,
});
if (!toolbarButtons.length) {
return null;
}
return (
<>
{toolbarButtons.map(toolDef => {
if (!toolDef) {
return null;
}
const { id, Component, componentProps } = toolDef;
const tool = (
<Component
key={id}
id={id}
onInteraction={onInteraction}
servicesManager={servicesManager}
{...componentProps}
/>
);
return <div key={id}>{tool}</div>;
})}
</>
);
}

View File

@@ -0,0 +1,36 @@
import { ToolbarButton, ButtonGroup } from '@ohif/ui';
import React, { useCallback } from 'react';
function ToolbarButtonGroupWithServices({ groupId, items, onInteraction, size }) {
const getSplitButtonItems = useCallback(
items =>
items.map((item, index) => (
<ToolbarButton
key={item.id}
icon={item.icon}
label={item.label}
disabled={item.disabled}
className={item.className}
disabledText={item.disabledText}
id={item.id}
size={size}
onClick={() => {
onInteraction({
groupId,
itemId: item.id,
commands: item.commands,
});
}}
// Note: this is necessary since tooltip will add
// default styles to the tooltip container which
// we don't want for groups
toolTipClassName=""
/>
)),
[onInteraction, groupId]
);
return <ButtonGroup>{getSplitButtonItems(items)}</ButtonGroup>;
}
export default ToolbarButtonGroupWithServices;

View File

@@ -0,0 +1,5 @@
import React from 'react';
export default function ToolbarDivider() {
return <span className="border-common-dark mx-2 h-8 w-4 self-center border-l" />;
}

View File

@@ -0,0 +1,246 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import PropTypes from 'prop-types';
import { LayoutSelector as OHIFLayoutSelector, ToolbarButton, LayoutPreset } from '@ohif/ui';
const defaultCommonPresets = [
{
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,
},
},
];
const _areSelectorsValid = (hp, displaySets, hangingProtocolService) => {
if (!hp.displaySetSelectors || Object.values(hp.displaySetSelectors).length === 0) {
return true;
}
return hangingProtocolService.areRequiredSelectorsValid(
Object.values(hp.displaySetSelectors),
displaySets[0]
);
};
const generateAdvancedPresets = ({ servicesManager }: withAppTypes) => {
const { hangingProtocolService, viewportGridService, displaySetService } =
servicesManager.services;
const hangingProtocols = Array.from(hangingProtocolService.protocols.values());
const viewportId = viewportGridService.getActiveViewportId();
if (!viewportId) {
return [];
}
const displaySetInsaneUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId);
if (!displaySetInsaneUIDs) {
return [];
}
const displaySets = displaySetInsaneUIDs.map(uid => displaySetService.getDisplaySetByUID(uid));
return hangingProtocols
.map(hp => {
if (!hp.isPreset) {
return null;
}
const areValid = _areSelectorsValid(hp, displaySets, hangingProtocolService);
return {
icon: hp.icon,
title: hp.name,
commandOptions: {
protocolId: hp.id,
},
disabled: !areValid,
};
})
.filter(preset => preset !== null);
};
function ToolbarLayoutSelectorWithServices({
commandsManager,
servicesManager,
...props
}: withAppTypes) {
const [isDisabled, setIsDisabled] = useState(false);
const handleMouseEnter = () => {
setIsDisabled(false);
};
const onSelection = useCallback(props => {
commandsManager.run({
commandName: 'setViewportGridLayout',
commandOptions: { ...props },
});
setIsDisabled(true);
}, []);
const onSelectionPreset = useCallback(props => {
commandsManager.run({
commandName: 'setHangingProtocol',
commandOptions: { ...props },
});
setIsDisabled(true);
}, []);
return (
<div onMouseEnter={handleMouseEnter}>
<LayoutSelector
{...props}
onSelection={onSelection}
onSelectionPreset={onSelectionPreset}
servicesManager={servicesManager}
tooltipDisabled={isDisabled}
/>
</div>
);
}
function LayoutSelector({
rows = 3,
columns = 4,
onLayoutChange = () => {},
className,
onSelection,
onSelectionPreset,
servicesManager,
tooltipDisabled,
...rest
}: withAppTypes) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef(null);
const { customizationService } = servicesManager.services;
const commonPresets = customizationService.get('commonPresets') || defaultCommonPresets;
const advancedPresets =
customizationService.get('advancedPresets') || generateAdvancedPresets({ servicesManager });
const closeOnOutsideClick = event => {
if (isOpen && dropdownRef.current) {
setIsOpen(false);
}
};
useEffect(() => {
if (!isOpen) {
return;
}
setTimeout(() => {
window.addEventListener('click', closeOnOutsideClick);
}, 0);
return () => {
window.removeEventListener('click', closeOnOutsideClick);
dropdownRef.current = null;
};
}, [isOpen]);
const onInteractionHandler = () => {
setIsOpen(!isOpen);
};
const DropdownContent = isOpen ? OHIFLayoutSelector : null;
return (
<ToolbarButton
id="Layout"
label="Layout"
icon="tool-layout"
onInteraction={onInteractionHandler}
className={className}
rounded={rest.rounded}
disableToolTip={tooltipDisabled}
dropdownContent={
DropdownContent !== null && (
<div
className="flex"
ref={dropdownRef}
>
<div className="bg-secondary-dark flex flex-col gap-2.5 p-2">
<div className="text-aqua-pale text-xs">Common</div>
<div className="flex gap-4">
{commonPresets.map((preset, index) => (
<LayoutPreset
key={index}
classNames="hover:bg-primary-dark group p-1 cursor-pointer"
icon={preset.icon}
commandOptions={preset.commandOptions}
onSelection={onSelection}
/>
))}
</div>
<div className="h-[2px] bg-black"></div>
<div className="text-aqua-pale text-xs">Advanced</div>
<div className="flex flex-col gap-2.5">
{advancedPresets.map((preset, index) => (
<LayoutPreset
key={index + commonPresets.length}
classNames="hover:bg-primary-dark group flex gap-2 p-1 cursor-pointer"
icon={preset.icon}
title={preset.title}
disabled={preset.disabled}
commandOptions={preset.commandOptions}
onSelection={onSelectionPreset}
/>
))}
</div>
</div>
<div className="bg-primary-dark flex flex-col gap-2.5 border-l-2 border-solid border-black p-2">
<div className="text-aqua-pale text-xs">Custom</div>
<DropdownContent
rows={rows}
columns={columns}
onSelection={onSelection}
/>
<p className="text-aqua-pale text-xs leading-tight">
Hover to select <br></br>rows and columns <br></br> Click to apply
</p>
</div>
</div>
)
}
isActive={isOpen}
type="toggle"
/>
);
}
LayoutSelector.propTypes = {
rows: PropTypes.number,
columns: PropTypes.number,
onLayoutChange: PropTypes.func,
servicesManager: PropTypes.object.isRequired,
};
export default ToolbarLayoutSelectorWithServices;

View File

@@ -0,0 +1,89 @@
import { SplitButton, ToolbarButton } from '@ohif/ui';
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
function ToolbarSplitButtonWithServices({
groupId,
primary,
secondary,
items,
renderer,
onInteraction,
servicesManager,
}: withAppTypes) {
const { toolbarService } = servicesManager?.services;
/* Bubbles up individual item clicks */
const getSplitButtonItems = useCallback(
items =>
items.map((item, index) => ({
...item,
index,
onClick: () => {
onInteraction({
groupId,
itemId: item.id,
commands: item.commands,
});
},
})),
[groupId, onInteraction]
);
const PrimaryButtonComponent =
toolbarService?.getButtonComponentForUIType(primary.uiType) ?? ToolbarButton;
const listItemRenderer = renderer;
return (
<SplitButton
primary={primary}
secondary={secondary}
items={getSplitButtonItems(items)}
groupId={groupId}
renderer={listItemRenderer}
onInteraction={onInteraction}
Component={props => (
<PrimaryButtonComponent
{...props}
servicesManager={servicesManager}
/>
)}
/>
);
}
ToolbarSplitButtonWithServices.propTypes = {
groupId: PropTypes.string,
primary: PropTypes.shape({
id: PropTypes.string.isRequired,
uiType: PropTypes.string,
}),
secondary: PropTypes.shape({
id: PropTypes.string,
icon: PropTypes.string.isRequired,
label: PropTypes.string,
tooltip: PropTypes.string.isRequired,
disabled: PropTypes.bool,
className: PropTypes.string,
}),
items: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,
icon: PropTypes.string,
label: PropTypes.string,
tooltip: PropTypes.string,
disabled: PropTypes.bool,
className: PropTypes.string,
})
),
renderer: PropTypes.func,
onInteraction: PropTypes.func.isRequired,
servicesManager: PropTypes.shape({
services: PropTypes.shape({
toolbarService: PropTypes.object,
}),
}),
};
export default ToolbarSplitButtonWithServices;

View File

@@ -0,0 +1,74 @@
import React, { useState, useEffect } from 'react';
import usePatientInfo from '../../hooks/usePatientInfo';
import { Icons } from '@ohif/ui-next';
export enum PatientInfoVisibility {
VISIBLE = 'visible',
VISIBLE_COLLAPSED = 'visibleCollapsed',
DISABLED = 'disabled',
VISIBLE_READONLY = 'visibleReadOnly',
}
const formatWithEllipsis = (str, maxLength) => {
if (str?.length > maxLength) {
return str.substring(0, maxLength) + '...';
}
return str;
};
function HeaderPatientInfo({ servicesManager, appConfig }: withAppTypes) {
const initialExpandedState =
appConfig.showPatientInfo === PatientInfoVisibility.VISIBLE ||
appConfig.showPatientInfo === PatientInfoVisibility.VISIBLE_READONLY;
const [expanded, setExpanded] = useState(initialExpandedState);
const { patientInfo, isMixedPatients } = usePatientInfo(servicesManager);
useEffect(() => {
if (isMixedPatients && expanded) {
setExpanded(false);
}
}, [isMixedPatients, expanded]);
const handleOnClick = () => {
if (!isMixedPatients && appConfig.showPatientInfo !== PatientInfoVisibility.VISIBLE_READONLY) {
setExpanded(!expanded);
}
};
const formattedPatientName = formatWithEllipsis(patientInfo.PatientName, 27);
const formattedPatientID = formatWithEllipsis(patientInfo.PatientID, 15);
return (
<div
className="hover:bg-primary-dark flex cursor-pointer items-center justify-center gap-1 rounded-lg"
onClick={handleOnClick}
>
{isMixedPatients ? (
<Icons.MultiplePatients className="text-primary-active" />
) : (
<Icons.Patient className="text-primary-active" />
)}
<div className="flex flex-col justify-center">
{expanded ? (
<>
<div className="self-start text-[13px] font-bold text-white">
{formattedPatientName}
</div>
<div className="text-aqua-pale flex gap-2 text-[11px]">
<div>{formattedPatientID}</div>
<div>{patientInfo.PatientSex}</div>
<div>{patientInfo.PatientDOB}</div>
</div>
</>
) : (
<div className="text-primary-active self-center text-[13px]">
{isMixedPatients ? 'Multiple Patients' : 'Patient'}
</div>
)}
</div>
<Icons.ChevronPatient className={`text-primary-active ${expanded ? 'rotate-180' : ''}`} />
</div>
);
}
export default HeaderPatientInfo;

View File

@@ -0,0 +1,3 @@
import HeaderPatientInfo from './HeaderPatientInfo';
export default HeaderPatientInfo;

View File

@@ -0,0 +1,42 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { ToolbarButton } from '@ohif/ui';
function NestedMenu({ children, label = 'More', icon = 'tool-more-menu', isActive }) {
const [isOpen, setIsOpen] = useState(false);
const toggleNestedMenu = () => setIsOpen(!isOpen);
const closeNestedMenu = () => {
if (isOpen) {
setIsOpen(false);
}
};
useEffect(() => {
window.addEventListener('click', closeNestedMenu);
return () => {
window.removeEventListener('click', closeNestedMenu);
};
}, [isOpen]);
return (
<ToolbarButton
id="NestedMenu"
label={label}
icon={icon}
onClick={toggleNestedMenu}
dropdownContent={isOpen && children}
isActive={isActive || isOpen}
type="primary"
/>
);
}
NestedMenu.propTypes = {
children: PropTypes.any.isRequired,
icon: PropTypes.string,
label: PropTypes.string,
};
export default NestedMenu;

View File

@@ -0,0 +1,138 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router';
import { UserPreferences, AboutModal, useModal } from '@ohif/ui';
import { Header } from '@ohif/ui-next';
import i18n from '@ohif/i18n';
import { hotkeys } from '@ohif/core';
import { Toolbar } from '../Toolbar/Toolbar';
import HeaderPatientInfo from './HeaderPatientInfo';
import { PatientInfoVisibility } from './HeaderPatientInfo/HeaderPatientInfo';
const { availableLanguages, defaultLanguage, currentLanguage } = i18n;
function ViewerHeader({
hotkeysManager,
extensionManager,
servicesManager,
appConfig,
}: withAppTypes<{ appConfig: AppTypes.Config }>) {
const navigate = useNavigate();
const location = useLocation();
const onClickReturnButton = () => {
const { pathname } = location;
const dataSourceIdx = pathname.indexOf('/', 1);
const query = new URLSearchParams(window.location.search);
const configUrl = query.get('configUrl');
const dataSourceName = pathname.substring(dataSourceIdx + 1);
const existingDataSource = extensionManager.getDataSources(dataSourceName);
const searchQuery = new URLSearchParams();
if (dataSourceIdx !== -1 && existingDataSource) {
searchQuery.append('datasources', pathname.substring(dataSourceIdx + 1));
}
if (configUrl) {
searchQuery.append('configUrl', configUrl);
}
navigate({
pathname: '/',
search: decodeURIComponent(searchQuery.toString()),
});
};
const { t } = useTranslation();
const { show, hide } = useModal();
const { hotkeyDefinitions, hotkeyDefaults } = hotkeysManager;
const versionNumber = process.env.VERSION_NUMBER;
const commitHash = process.env.COMMIT_HASH;
const menuOptions = [
{
title: t('Header:About'),
icon: 'info',
onClick: () =>
show({
content: AboutModal,
title: t('AboutModal:About OHIF Viewer'),
contentProps: { versionNumber, commitHash },
containerDimensions: 'max-w-4xl max-h-4xl',
}),
},
{
title: t('Header:Preferences'),
icon: 'settings',
onClick: () =>
show({
title: t('UserPreferencesModal:User preferences'),
content: UserPreferences,
containerDimensions: 'w-[70%] max-w-[900px]',
contentProps: {
hotkeyDefaults: hotkeysManager.getValidHotkeyDefinitions(hotkeyDefaults),
hotkeyDefinitions,
currentLanguage: currentLanguage(),
availableLanguages,
defaultLanguage,
onCancel: () => {
hotkeys.stopRecord();
hotkeys.unpause();
hide();
},
onSubmit: ({ hotkeyDefinitions, language }) => {
if (language.value !== currentLanguage().value) {
i18n.changeLanguage(language.value);
}
hotkeysManager.setHotkeys(hotkeyDefinitions);
hide();
},
onReset: () => hotkeysManager.restoreDefaultBindings(),
hotkeysModule: hotkeys,
},
}),
},
];
if (appConfig.oidc) {
menuOptions.push({
title: t('Header:Logout'),
icon: 'power-off',
onClick: async () => {
navigate(`/logout?redirect_uri=${encodeURIComponent(window.location.href)}`);
},
});
}
return (
<Header
menuOptions={menuOptions}
isReturnEnabled={!!appConfig.showStudyList}
onClickReturnButton={onClickReturnButton}
WhiteLabeling={appConfig.whiteLabeling}
Secondary={
<Toolbar
servicesManager={servicesManager}
buttonSection="secondary"
/>
}
PatientInfo={
appConfig.showPatientInfo !== PatientInfoVisibility.DISABLED && (
<HeaderPatientInfo
servicesManager={servicesManager}
appConfig={appConfig}
/>
)
}
>
<div className="relative flex justify-center gap-[4px]">
<Toolbar servicesManager={servicesManager} />
</div>
</Header>
);
}
export default ViewerHeader;

View File

@@ -0,0 +1,176 @@
import React, { useEffect, useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import { LoadingIndicatorProgress, InvestigationalUseDialog } from '@ohif/ui';
import { HangingProtocolService, CommandsManager } from '@ohif/core';
import { useAppConfig } from '@state';
import ViewerHeader from './ViewerHeader';
import SidePanelWithServices from '../Components/SidePanelWithServices';
import { Onboarding } from '@ohif/ui-next';
function ViewerLayout({
// From Extension Module Params
extensionManager,
servicesManager,
hotkeysManager,
commandsManager,
// From Modes
viewports,
ViewportGridComp,
leftPanelClosed = false,
rightPanelClosed = false,
}: withAppTypes): React.FunctionComponent {
const [appConfig] = useAppConfig();
const { panelService, hangingProtocolService } = servicesManager.services;
const [showLoadingIndicator, setShowLoadingIndicator] = useState(appConfig.showLoadingIndicator);
const hasPanels = useCallback(
(side): boolean => !!panelService.getPanels(side).length,
[panelService]
);
const [hasRightPanels, setHasRightPanels] = useState(hasPanels('right'));
const [hasLeftPanels, setHasLeftPanels] = useState(hasPanels('left'));
const [leftPanelClosedState, setLeftPanelClosed] = useState(leftPanelClosed);
const [rightPanelClosedState, setRightPanelClosed] = useState(rightPanelClosed);
/**
* Set body classes (tailwindcss) that don't allow vertical
* or horizontal overflow (no scrolling). Also guarantee window
* is sized to our viewport.
*/
useEffect(() => {
document.body.classList.add('bg-black');
document.body.classList.add('overflow-hidden');
return () => {
document.body.classList.remove('bg-black');
document.body.classList.remove('overflow-hidden');
};
}, []);
const getComponent = id => {
const entry = extensionManager.getModuleEntry(id);
if (!entry || !entry.component) {
throw new Error(
`${id} is not valid for an extension module or no component found from extension ${id}. Please verify your configuration or ensure that the extension is properly registered. It's also possible that your mode is utilizing a module from an extension that hasn't been included in its dependencies (add the extension to the "extensionDependencies" array in your mode's index.js file). Check the reference string to the extension in your Mode configuration`
);
}
return { entry, content: entry.component };
};
useEffect(() => {
const { unsubscribe } = hangingProtocolService.subscribe(
HangingProtocolService.EVENTS.PROTOCOL_CHANGED,
// Todo: right now to set the loading indicator to false, we need to wait for the
// hangingProtocolService to finish applying the viewport matching to each viewport,
// however, this might not be the only approach to set the loading indicator to false. we need to explore this further.
() => {
setShowLoadingIndicator(false);
}
);
return () => {
unsubscribe();
};
}, [hangingProtocolService]);
const getViewportComponentData = viewportComponent => {
const { entry } = getComponent(viewportComponent.namespace);
return {
component: entry.component,
displaySetsToDisplay: viewportComponent.displaySetsToDisplay,
};
};
useEffect(() => {
const { unsubscribe } = panelService.subscribe(
panelService.EVENTS.PANELS_CHANGED,
({ options }) => {
setHasLeftPanels(hasPanels('left'));
setHasRightPanels(hasPanels('right'));
if (options?.leftPanelClosed !== undefined) {
setLeftPanelClosed(options.leftPanelClosed);
}
if (options?.rightPanelClosed !== undefined) {
setRightPanelClosed(options.rightPanelClosed);
}
}
);
return () => {
unsubscribe();
};
}, [panelService, hasPanels]);
const viewportComponents = viewports.map(getViewportComponentData);
return (
<div>
<ViewerHeader
hotkeysManager={hotkeysManager}
extensionManager={extensionManager}
servicesManager={servicesManager}
appConfig={appConfig}
/>
<div
className="relative flex w-full flex-row flex-nowrap items-stretch overflow-hidden bg-black"
style={{ height: 'calc(100vh - 52px' }}
>
<React.Fragment>
{showLoadingIndicator && <LoadingIndicatorProgress className="h-full w-full bg-black" />}
{/* LEFT SIDEPANELS */}
{hasLeftPanels ? (
<SidePanelWithServices
side="left"
activeTabIndex={leftPanelClosedState ? null : 0}
servicesManager={servicesManager}
/>
) : null}
{/* TOOLBAR + GRID */}
<div className="flex h-full flex-1 flex-col">
<div className="relative flex h-full flex-1 items-center justify-center overflow-hidden bg-black">
<ViewportGridComp
servicesManager={servicesManager}
viewportComponents={viewportComponents}
commandsManager={commandsManager}
/>
</div>
</div>
{hasRightPanels ? (
<SidePanelWithServices
side="right"
activeTabIndex={rightPanelClosedState ? null : 0}
servicesManager={servicesManager}
/>
) : null}
</React.Fragment>
</div>
<Onboarding />
<InvestigationalUseDialog dialogConfiguration={appConfig?.investigationalUseDialog} />
</div>
);
}
ViewerLayout.propTypes = {
// From extension module params
extensionManager: PropTypes.shape({
getModuleEntry: PropTypes.func.isRequired,
}).isRequired,
commandsManager: PropTypes.instanceOf(CommandsManager),
servicesManager: PropTypes.object.isRequired,
// From modes
leftPanels: PropTypes.array,
rightPanels: PropTypes.array,
leftPanelClosed: PropTypes.bool.isRequired,
rightPanelClosed: PropTypes.bool.isRequired,
/** Responsible for rendering our grid of viewports; provided by consuming application */
children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]).isRequired,
viewports: PropTypes.array,
};
export default ViewerLayout;

View File

@@ -0,0 +1,607 @@
import { Types } from '@ohif/core';
import { ContextMenuController, defaultContextMenu } from './CustomizableContextMenu';
import DicomTagBrowser from './DicomTagBrowser/DicomTagBrowser';
import reuseCachedLayouts from './utils/reuseCachedLayouts';
import findViewportsByPosition, {
findOrCreateViewport as layoutFindOrCreate,
} from './findViewportsByPosition';
import { ContextMenuProps } from './CustomizableContextMenu/types';
import { NavigateHistory } from './types/commandModuleTypes';
import { history } from '@ohif/app';
import { useViewportGridStore } from './stores/useViewportGridStore';
import { useDisplaySetSelectorStore } from './stores/useDisplaySetSelectorStore';
import { useHangingProtocolStageIndexStore } from './stores/useHangingProtocolStageIndexStore';
import { useToggleHangingProtocolStore } from './stores/useToggleHangingProtocolStore';
import { useViewportsByPositionStore } from './stores/useViewportsByPositionStore';
import { useToggleOneUpViewportGridStore } from './stores/useToggleOneUpViewportGridStore';
export type HangingProtocolParams = {
protocolId?: string;
stageIndex?: number;
activeStudyUID?: string;
stageId?: string;
reset?: false;
};
export type UpdateViewportDisplaySetParams = {
direction: number;
excludeNonImageModalities?: boolean;
};
const commandsModule = ({
servicesManager,
commandsManager,
}: Types.Extensions.ExtensionParams): Types.Extensions.CommandsModule => {
const {
customizationService,
measurementService,
hangingProtocolService,
uiNotificationService,
viewportGridService,
displaySetService,
} = servicesManager.services;
// Define a context menu controller for use with any context menus
const contextMenuController = new ContextMenuController(servicesManager, commandsManager);
const actions = {
/**
* Show the context menu.
* @param options.menuId defines the menu name to lookup, from customizationService
* @param options.defaultMenu contains the default menu set to use
* @param options.element is the element to show the menu within
* @param options.event is the event that caused the context menu
* @param options.selectorProps is the set of selection properties to use
*/
showContextMenu: (options: ContextMenuProps) => {
const {
menuCustomizationId,
element,
event,
selectorProps,
defaultPointsPosition = [],
} = options;
const optionsToUse = { ...options };
if (menuCustomizationId) {
Object.assign(
optionsToUse,
customizationService.get(menuCustomizationId, defaultContextMenu)
);
}
// TODO - make the selectorProps richer by including the study metadata and display set.
const { protocol, stage } = hangingProtocolService.getActiveProtocol();
optionsToUse.selectorProps = {
event,
protocol,
stage,
...selectorProps,
};
contextMenuController.showContextMenu(optionsToUse, element, defaultPointsPosition);
},
/** Close a context menu currently displayed */
closeContextMenu: () => {
contextMenuController.closeContextMenu();
},
displayNotification: ({ text, title, type }) => {
uiNotificationService.show({
title: title,
message: text,
type: type,
});
},
clearMeasurements: () => {
measurementService.clear();
},
/**
* Sets the specified protocol
* 1. Records any existing state using the viewport grid service
* 2. Finds the destination state - this can be one of:
* a. The specified protocol stage
* b. An alternate (toggled or restored) protocol stage
* c. A restored custom layout
* 3. Finds the parameters for the specified state
* a. Gets the displaySetSelectorMap
* b. Gets the map by position
* c. Gets any toggle mapping to map position to/from current view
* 4. If restore, then sets layout
* a. Maps viewport position by currently displayed viewport map id
* b. Uses toggle information to map display set id
* 5. Else applies the hanging protocol
* a. HP Service is provided displaySetSelectorMap
* b. HP Service will throw an exception if it isn't applicable
* @param options - contains information on the HP to apply
* @param options.activeStudyUID - the updated study to apply the HP to
* @param options.protocolId - the protocol ID to change to
* @param options.stageId - the stageId to apply
* @param options.stageIndex - the index of the stage to go to.
* @param options.reset - flag to indicate if the HP should be reset to its original and not restored to a previous state
*/
setHangingProtocol: ({
activeStudyUID = '',
protocolId,
stageId,
stageIndex,
reset = false,
}: HangingProtocolParams): boolean => {
try {
// Stores in the state the display set selector id to displaySetUID mapping
// Pass in viewportId for the active viewport. This item will get set as
// the activeViewportId
const state = viewportGridService.getState();
const hpInfo = hangingProtocolService.getState();
reuseCachedLayouts(state, hangingProtocolService);
const { hangingProtocolStageIndexMap } = useHangingProtocolStageIndexStore.getState();
const { displaySetSelectorMap } = useDisplaySetSelectorStore.getState();
if (!protocolId) {
// Reuse the previous protocol id, and optionally stage
protocolId = hpInfo.protocolId;
if (stageId === undefined && stageIndex === undefined) {
stageIndex = hpInfo.stageIndex;
}
} else if (stageIndex === undefined && stageId === undefined) {
// Re-set the same stage as was previously used
const hangingId = `${activeStudyUID || hpInfo.activeStudyUID}:${protocolId}`;
stageIndex = hangingProtocolStageIndexMap[hangingId]?.stageIndex;
}
const useStageIdx =
stageIndex ??
hangingProtocolService.getStageIndex(protocolId, {
stageId,
stageIndex,
});
if (activeStudyUID) {
hangingProtocolService.setActiveStudyUID(activeStudyUID);
}
const storedHanging = `${hangingProtocolService.getState().activeStudyUID}:${protocolId}:${
useStageIdx || 0
}`;
const { viewportGridState } = useViewportGridStore.getState();
const restoreProtocol = !reset && viewportGridState[storedHanging];
if (
protocolId === hpInfo.protocolId &&
useStageIdx === hpInfo.stageIndex &&
!activeStudyUID
) {
// Clear the HP setting to reset them
hangingProtocolService.setProtocol(protocolId, {
stageId,
stageIndex: useStageIdx,
});
} else {
hangingProtocolService.setProtocol(protocolId, {
displaySetSelectorMap,
stageId,
stageIndex: useStageIdx,
restoreProtocol,
});
if (restoreProtocol) {
viewportGridService.set(viewportGridState[storedHanging]);
}
}
// Do this after successfully applying the update
const { setDisplaySetSelector } = useDisplaySetSelectorStore.getState();
setDisplaySetSelector(
`${activeStudyUID || hpInfo.activeStudyUID}:activeDisplaySet:0`,
null
);
return true;
} catch (e) {
console.error(e);
uiNotificationService.show({
title: 'Apply Hanging Protocol',
message: 'The hanging protocol could not be applied.',
type: 'error',
duration: 3000,
});
return false;
}
},
toggleHangingProtocol: ({ protocolId, stageIndex }: HangingProtocolParams): boolean => {
const {
protocol,
stageIndex: desiredStageIndex,
activeStudy,
} = hangingProtocolService.getActiveProtocol();
const { toggleHangingProtocol, setToggleHangingProtocol } =
useToggleHangingProtocolStore.getState();
const storedHanging = `${activeStudy.StudyInstanceUID}:${protocolId}:${stageIndex | 0}`;
if (
protocol.id === protocolId &&
(stageIndex === undefined || stageIndex === desiredStageIndex)
) {
// Toggling off - restore to previous state
const previousState = toggleHangingProtocol[storedHanging] || {
protocolId: 'default',
};
return actions.setHangingProtocol(previousState);
} else {
setToggleHangingProtocol(storedHanging, {
protocolId: protocol.id,
stageIndex: desiredStageIndex,
});
return actions.setHangingProtocol({
protocolId,
stageIndex,
reset: true,
});
}
},
deltaStage: ({ direction }) => {
const { protocolId, stageIndex: oldStageIndex } = hangingProtocolService.getState();
const { protocol } = hangingProtocolService.getActiveProtocol();
for (
let stageIndex = oldStageIndex + direction;
stageIndex >= 0 && stageIndex < protocol.stages.length;
stageIndex += direction
) {
if (protocol.stages[stageIndex].status !== 'disabled') {
return actions.setHangingProtocol({
protocolId,
stageIndex,
});
}
}
uiNotificationService.show({
title: 'Change Stage',
message: 'The hanging protocol has no more applicable stages',
type: 'info',
duration: 3000,
});
},
/**
* Changes the viewport grid layout in terms of the MxN layout.
*/
setViewportGridLayout: ({ numRows, numCols, isHangingProtocolLayout = false }) => {
const { protocol } = hangingProtocolService.getActiveProtocol();
const onLayoutChange = protocol.callbacks?.onLayoutChange;
if (commandsManager.run(onLayoutChange, { numRows, numCols }) === false) {
console.log('setViewportGridLayout running', onLayoutChange, numRows, numCols);
// Don't apply the layout if the run command returns false
return;
}
const completeLayout = () => {
const state = viewportGridService.getState();
findViewportsByPosition(state, { numRows, numCols });
const { viewportsByPosition, initialInDisplay } = useViewportsByPositionStore.getState();
const findOrCreateViewport = layoutFindOrCreate.bind(
null,
hangingProtocolService,
isHangingProtocolLayout,
{ ...viewportsByPosition, initialInDisplay }
);
viewportGridService.setLayout({
numRows,
numCols,
findOrCreateViewport,
isHangingProtocolLayout,
});
};
// Need to finish any work in the callback
window.setTimeout(completeLayout, 0);
},
toggleOneUp() {
const viewportGridState = viewportGridService.getState();
const { activeViewportId, viewports, layout, isHangingProtocolLayout } = viewportGridState;
const { displaySetInstanceUIDs, displaySetOptions, viewportOptions } =
viewports.get(activeViewportId);
if (layout.numCols === 1 && layout.numRows === 1) {
// The viewer is in one-up. Check if there is a state to restore/toggle back to.
const { toggleOneUpViewportGridStore } = useToggleOneUpViewportGridStore.getState();
if (!toggleOneUpViewportGridStore) {
return;
}
// There is a state to toggle back to. The viewport that was
// originally toggled to one up was the former active viewport.
const viewportIdToUpdate = toggleOneUpViewportGridStore.activeViewportId;
// We are restoring the previous layout but taking into the account that
// the current one up viewport might have a new displaySet dragged and dropped on it.
// updatedViewportsViaHP below contains the viewports applicable to the HP that existed
// prior to the toggle to one-up - including the updated viewports if a display
// set swap were to have occurred.
const updatedViewportsViaHP =
displaySetInstanceUIDs.length > 1
? []
: displaySetInstanceUIDs
.map(displaySetInstanceUID =>
hangingProtocolService.getViewportsRequireUpdate(
viewportIdToUpdate,
displaySetInstanceUID,
isHangingProtocolLayout
)
)
.flat();
// findOrCreateViewport returns either one of the updatedViewportsViaHP
// returned from the HP service OR if there is not one from the HP service then
// simply returns what was in the previous state for a given position in the layout.
const findOrCreateViewport = (position: number, positionId: string) => {
// Find the viewport for the given position prior to the toggle to one-up.
const preOneUpViewport = Array.from(toggleOneUpViewportGridStore.viewports.values()).find(
viewport => viewport.positionId === positionId
);
// Use the viewport id from before the toggle to one-up to find any updates to the viewport.
const viewport = updatedViewportsViaHP.find(
viewport => viewport.viewportId === preOneUpViewport.viewportId
);
return viewport
? // Use the applicable viewport from the HP updated viewports
{ viewportOptions, displaySetOptions, ...viewport }
: // Use the previous viewport for the given position
preOneUpViewport;
};
const layoutOptions = viewportGridService.getLayoutOptionsFromState(
toggleOneUpViewportGridStore
);
// Restore the previous layout including the active viewport.
viewportGridService.setLayout({
numRows: toggleOneUpViewportGridStore.layout.numRows,
numCols: toggleOneUpViewportGridStore.layout.numCols,
activeViewportId: viewportIdToUpdate,
layoutOptions,
findOrCreateViewport,
isHangingProtocolLayout: true,
});
// Reset crosshairs after restoring the layout
setTimeout(() => {
commandsManager.runCommand('resetCrosshairs');
}, 0);
} else {
// We are not in one-up, so toggle to one up.
// Store the current viewport grid state so we can toggle it back later.
const { setToggleOneUpViewportGridStore } = useToggleOneUpViewportGridStore.getState();
setToggleOneUpViewportGridStore(viewportGridState);
// one being toggled to one up.
const findOrCreateViewport = () => {
return {
displaySetInstanceUIDs,
displaySetOptions,
viewportOptions,
};
};
// Set the layout to be 1x1/one-up.
viewportGridService.setLayout({
numRows: 1,
numCols: 1,
findOrCreateViewport,
isHangingProtocolLayout: true,
});
}
},
/**
* Exposes the browser history navigation used by OHIF. This command can be used to either replace or
* push a new entry into the browser history. For example, the following will replace the current
* browser history entry with the specified relative URL which changes the study displayed to the
* study with study instance UID 1.2.3. Note that as a result of using `options.replace = true`, the
* page prior to invoking this command cannot be returned to via the browser back button.
*
* navigateHistory({
* to: 'viewer?StudyInstanceUIDs=1.2.3',
* options: { replace: true },
* });
*
* @param historyArgs - arguments for the history function;
* the `to` property is the URL;
* the `options.replace` is a boolean indicating if the current browser history entry
* should be replaced or a new entry pushed onto the history (stack); the default value
* for `replace` is false
*/
navigateHistory(historyArgs: NavigateHistory) {
history.navigate(historyArgs.to, historyArgs.options);
},
openDICOMTagViewer({ displaySetInstanceUID }: { displaySetInstanceUID?: string }) {
const { activeViewportId, viewports } = viewportGridService.getState();
const activeViewportSpecificData = viewports.get(activeViewportId);
const { displaySetInstanceUIDs } = activeViewportSpecificData;
const displaySets = displaySetService.activeDisplaySets;
const { UIModalService } = servicesManager.services;
const defaultDisplaySetInstanceUID = displaySetInstanceUID || displaySetInstanceUIDs[0];
UIModalService.show({
content: DicomTagBrowser,
contentProps: {
displaySets,
displaySetInstanceUID: defaultDisplaySetInstanceUID,
onClose: UIModalService.hide,
},
containerDimensions: 'w-[70%] max-w-[900px]',
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');
}
},
scrollActiveThumbnailIntoView: () => {
const { activeViewportId, viewports } = viewportGridService.getState();
const activeViewport = viewports.get(activeViewportId);
const activeDisplaySetInstanceUID = activeViewport.displaySetInstanceUIDs[0];
const thumbnailList = document.querySelector('#ohif-thumbnail-list');
if (!thumbnailList) {
return;
}
const thumbnailListBounds = thumbnailList.getBoundingClientRect();
const thumbnail = document.querySelector(`#thumbnail-${activeDisplaySetInstanceUID}`);
if (!thumbnail) {
return;
}
const thumbnailBounds = thumbnail.getBoundingClientRect();
// This only handles a vertical thumbnail list.
if (
thumbnailBounds.top >= thumbnailListBounds.top &&
thumbnailBounds.top <= thumbnailListBounds.bottom
) {
return;
}
thumbnail.scrollIntoView({ behavior: 'smooth' });
},
updateViewportDisplaySet: ({
direction,
excludeNonImageModalities,
}: UpdateViewportDisplaySetParams) => {
const nonImageModalities = ['SR', 'SEG', 'SM', 'RTSTRUCT', 'RTPLAN', 'RTDOSE'];
const currentDisplaySets = [...displaySetService.activeDisplaySets];
const { activeViewportId, viewports, isHangingProtocolLayout } =
viewportGridService.getState();
const { displaySetInstanceUIDs } = viewports.get(activeViewportId);
const activeDisplaySetIndex = currentDisplaySets.findIndex(displaySet =>
displaySetInstanceUIDs.includes(displaySet.displaySetInstanceUID)
);
let displaySetIndexToShow: number;
for (
displaySetIndexToShow = activeDisplaySetIndex + direction;
displaySetIndexToShow > -1 && displaySetIndexToShow < currentDisplaySets.length;
displaySetIndexToShow += direction
) {
if (
!excludeNonImageModalities ||
!nonImageModalities.includes(currentDisplaySets[displaySetIndexToShow].Modality)
) {
break;
}
}
if (displaySetIndexToShow < 0 || displaySetIndexToShow >= currentDisplaySets.length) {
return;
}
const { displaySetInstanceUID } = currentDisplaySets[displaySetIndexToShow];
let updatedViewports = [];
try {
updatedViewports = hangingProtocolService.getViewportsRequireUpdate(
activeViewportId,
displaySetInstanceUID,
isHangingProtocolLayout
);
} catch (error) {
console.warn(error);
uiNotificationService.show({
title: 'Navigate Viewport Display Set',
message:
'The requested display sets could not be added to the viewport due to a mismatch in the Hanging Protocol rules.',
type: 'info',
duration: 3000,
});
}
viewportGridService.setDisplaySetsForViewports(updatedViewports);
setTimeout(() => actions.scrollActiveThumbnailIntoView(), 0);
},
};
const definitions = {
showContextMenu: {
commandFn: actions.showContextMenu,
},
closeContextMenu: {
commandFn: actions.closeContextMenu,
},
clearMeasurements: {
commandFn: actions.clearMeasurements,
},
displayNotification: {
commandFn: actions.displayNotification,
},
setHangingProtocol: {
commandFn: actions.setHangingProtocol,
},
toggleHangingProtocol: {
commandFn: actions.toggleHangingProtocol,
},
navigateHistory: {
commandFn: actions.navigateHistory,
},
nextStage: {
commandFn: actions.deltaStage,
options: { direction: 1 },
},
previousStage: {
commandFn: actions.deltaStage,
options: { direction: -1 },
},
setViewportGridLayout: {
commandFn: actions.setViewportGridLayout,
},
toggleOneUp: {
commandFn: actions.toggleOneUp,
},
openDICOMTagViewer: {
commandFn: actions.openDICOMTagViewer,
},
updateViewportDisplaySet: {
commandFn: actions.updateViewportDisplaySet,
},
};
return {
actions,
definitions,
defaultContext: 'DEFAULT',
};
};
export default commandsModule;

View File

@@ -0,0 +1,102 @@
import { useViewportsByPositionStore } from './stores/useViewportsByPositionStore';
/**
* This find or create viewport is paired with the reduce results from
* below, and the action of this viewport is to look for previously filled
* viewports, and to reuse by position id. If there is no filled viewport,
* then one can be re-used from the display set if it isn't going to be displayed.
* @param hangingProtocolService - bound parameter supplied before using this
* @param viewportsByPosition - bound parameter supplied before using this
* @param position - the position in the grid to retrieve
* @param positionId - the current position on screen to retrieve
* @param options - the set of options used, so that subsequent calls can
* store state that is reset by the setLayout.
* This class uses the options to store the already viewed
* display sets, filling it initially with the pre-existing viewports.
*/
export const findOrCreateViewport = (
hangingProtocolService,
isHangingProtocolLayout,
viewportsByPosition,
position: number,
positionId: string,
options: Record<string, unknown>
) => {
const byPositionViewport = viewportsByPosition?.[positionId];
if (byPositionViewport) {
return { ...byPositionViewport };
}
const { protocolId, stageIndex } = hangingProtocolService.getState();
// Setup the initial in display correctly for initial view/select
if (!options.inDisplay) {
options.inDisplay = [...viewportsByPosition.initialInDisplay];
}
// See if there is a default viewport for new views
const missing = hangingProtocolService.getMissingViewport(
isHangingProtocolLayout ? protocolId : 'default',
stageIndex,
options
);
if (missing) {
const displaySetInstanceUIDs = missing.displaySetsInfo.map(it => it.displaySetInstanceUID);
options.inDisplay.push(...displaySetInstanceUIDs);
return {
displaySetInstanceUIDs,
displaySetOptions: missing.displaySetsInfo.map(it => it.displaySetOptions),
viewportOptions: {
...missing.viewportOptions,
},
};
}
// and lastly if there is no default viewport, then we see if we can grab the
// viewportsByPosition at the position index and use that
// const candidate = Object.values(viewportsByPosition)[position];
// // if it has something to display, then we can use it
// return candidate?.displaySetInstanceUIDs ? candidate : {};
return {};
};
/**
* Records the information on what viewports are displayed in which position.
* Also records what instances from the existing positions are going to be in
* view initially.
* @param state is the viewport grid state
* @param syncService is the state sync service to use for getting existing state
* @returns Set of states that can be applied to the state sync to remember
* the current view state.
*/
const findViewportsByPosition = (state, { numRows, numCols }) => {
const { viewports } = state;
const { setViewportsByPosition, addInitialInDisplay } = useViewportsByPositionStore.getState();
const initialInDisplay = [];
const viewportsByPosition = {};
viewports.forEach(viewport => {
if (viewport.positionId) {
const storedViewport = {
...viewport,
viewportOptions: { ...viewport.viewportOptions },
};
viewportsByPosition[viewport.positionId] = storedViewport;
setViewportsByPosition(viewport.positionId, storedViewport);
}
});
for (let row = 0; row < numRows; row++) {
for (let col = 0; col < numCols; col++) {
const positionId = `${col}-${row}`;
const viewport = viewportsByPosition[positionId];
if (viewport?.displaySetInstanceUIDs) {
initialInDisplay.push(...viewport.displaySetInstanceUIDs);
}
}
}
initialInDisplay.forEach(displaySetInstanceUID => addInitialInDisplay(displaySetInstanceUID));
};
export default findViewportsByPosition;

View File

@@ -0,0 +1,206 @@
import { CustomizationService } from '@ohif/core';
import React from 'react';
import DataSourceSelector from './Panels/DataSourceSelector';
import { ProgressDropdownWithService } from './Components/ProgressDropdownWithService';
import DataSourceConfigurationComponent from './Components/DataSourceConfigurationComponent';
import { GoogleCloudDataSourceConfigurationAPI } from './DataSourceConfigurationAPI/GoogleCloudDataSourceConfigurationAPI';
import { utils } from '@ohif/core';
const formatDate = utils.formatDate;
/**
*
* 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({ servicesManager, extensionManager }) {
return [
{
name: 'helloPage',
merge: 'Append',
value: {
id: 'customRoutes',
routes: [
{
path: '/custom',
children: () => <h1 style={{ color: 'white' }}>Hello Custom Route</h1>,
},
],
},
},
// Example customization to list a set of datasources
{
name: 'datasources',
merge: 'Append',
value: {
id: 'customRoutes',
routes: [
{
path: '/datasources',
children: DataSourceSelector,
},
],
},
},
{
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',
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>
);
},
},
{
id: 'ohif.contextMenu',
/** Applies the customizationType to all the menu items.
* This function clones the object and child objects to prevent
* changes to the original customization object.
*/
transform: function (customizationService: CustomizationService) {
// Don't modify the children, as those are copied by reference
const clonedObject = { ...this };
clonedObject.menus = this.menus.map(menu => ({ ...menu }));
for (const menu of clonedObject.menus) {
const { items: originalItems } = menu;
menu.items = [];
for (const item of originalItems) {
menu.items.push(customizationService.transform(item));
}
}
return clonedObject;
},
},
{
// the generic GUI component to configure a data source using an instance of a BaseDataSourceConfigurationAPI
id: 'ohif.dataSourceConfigurationComponent',
component: DataSourceConfigurationComponent.bind(null, {
servicesManager,
extensionManager,
}),
},
{
// The factory for creating an instance of a BaseDataSourceConfigurationAPI for Google Cloud Healthcare
id: 'ohif.dataSourceConfigurationAPI.google',
factory: (dataSourceName: string) =>
new GoogleCloudDataSourceConfigurationAPI(
dataSourceName,
servicesManager,
extensionManager
),
},
{
id: 'progressDropdownWithServiceComponent',
component: ProgressDropdownWithService,
},
{
id: 'studyBrowser.sortFunctions',
values: [
{
label: 'Series Number',
sortFunction: (a, b) => {
return a?.SeriesNumber - b?.SeriesNumber;
},
},
{
label: 'Series Date',
sortFunction: (a, b) => {
const dateA = new Date(formatDate(a?.SeriesDate));
const dateB = new Date(formatDate(b?.SeriesDate));
return dateB.getTime() - dateA.getTime();
},
},
],
},
{
id: 'studyBrowser.viewPresets',
// change your default selected preset here
value: [
{
id: 'list',
iconName: 'ListView',
selected: false,
},
{
id: 'thumbnails',
iconName: 'ThumbnailView',
selected: true,
},
],
},
],
},
];
}

View File

@@ -0,0 +1,44 @@
// TODO: Pull in IWebClientApi from @ohif/core
// TODO: Use constructor to create an instance of IWebClientApi
// TODO: Use existing DICOMWeb configuration (previously, appConfig, to configure instance)
import { createDicomWebApi } from './DicomWebDataSource/index.js';
import { createDicomJSONApi } from './DicomJSONDataSource/index.js';
import { createDicomLocalApi } from './DicomLocalDataSource/index.js';
import { createDicomWebProxyApi } from './DicomWebProxyDataSource/index';
import { createMergeDataSourceApi } from './MergeDataSource/index';
/**
*
*/
function getDataSourcesModule() {
return [
{
name: 'dicomweb',
type: 'webApi',
createDataSource: createDicomWebApi,
},
{
name: 'dicomwebproxy',
type: 'webApi',
createDataSource: createDicomWebProxyApi,
},
{
name: 'dicomjson',
type: 'jsonApi',
createDataSource: createDicomJSONApi,
},
{
name: 'dicomlocal',
type: 'localApi',
createDataSource: createDicomLocalApi,
},
{
name: 'merge',
type: 'mergeApi',
createDataSource: createMergeDataSourceApi,
},
];
}
export default getDataSourcesModule;

View File

@@ -0,0 +1,54 @@
import sortInstancesByPosition from '@ohif/core/src/utils/sortInstancesByPosition';
import { constructableModalities } from '@ohif/core/src/utils/isDisplaySetReconstructable';
import { DisplaySetMessage, DisplaySetMessageList } from '@ohif/core';
import checkMultiFrame from './utils/validations/checkMultiframe';
import checkSingleFrames from './utils/validations/checkSingleFrames';
/**
* Checks if a series is reconstructable to a 3D volume.
*
* @param {Object[]} instances An array of `OHIFInstanceMetadata` objects.
*/
export default function getDisplaySetMessages(
instances: Array<any>,
isReconstructable: boolean,
isDynamicVolume: boolean
): DisplaySetMessageList {
const messages = new DisplaySetMessageList();
if (isDynamicVolume) {
return messages;
}
if (!instances.length) {
messages.addMessage(DisplaySetMessage.CODES.NO_VALID_INSTANCES);
return;
}
const firstInstance = instances[0];
const { Modality, ImageType, NumberOfFrames } = firstInstance;
// Due to current requirements, LOCALIZER series doesn't have any messages
if (ImageType?.includes('LOCALIZER')) {
return messages;
}
if (!constructableModalities.includes(Modality)) {
return messages;
}
const isMultiframe = NumberOfFrames > 1;
// Can't reconstruct if all instances don't have the ImagePositionPatient.
if (!isMultiframe && !instances.every(instance => instance.ImagePositionPatient)) {
messages.addMessage(DisplaySetMessage.CODES.NO_POSITION_INFORMATION);
}
const sortedInstances = sortInstancesByPosition(instances);
isMultiframe
? checkMultiFrame(sortedInstances[0], messages)
: checkSingleFrames(sortedInstances, messages);
if (!isReconstructable) {
messages.addMessage(DisplaySetMessage.CODES.NOT_RECONSTRUCTABLE);
}
return messages;
}

View File

@@ -0,0 +1,30 @@
import ImageSet from '@ohif/core/src/classes/ImageSet';
import { DisplaySetMessage, DisplaySetMessageList } from '@ohif/core';
/**
* Default handler for a instance list with an unsupported sopClassUID
*/
export default function getDisplaySetsFromUnsupportedSeries(instances) {
const imageSet = new ImageSet(instances);
const messages = new DisplaySetMessageList();
messages.addMessage(DisplaySetMessage.CODES.UNSUPPORTED_DISPLAYSET);
const instance = instances[0];
imageSet.setAttributes({
displaySetInstanceUID: imageSet.uid, // create a local alias for the imageSet UID
SeriesDate: instance.SeriesDate,
SeriesTime: instance.SeriesTime,
SeriesInstanceUID: instance.SeriesInstanceUID,
StudyInstanceUID: instance.StudyInstanceUID,
SeriesNumber: instance.SeriesNumber || 0,
FrameRate: instance.FrameTime,
SOPClassUID: instance.SOPClassUID,
SeriesDescription: instance.SeriesDescription || '',
Modality: instance.Modality,
numImageFrames: instances.length,
unsupported: true,
SOPClassHandlerId: 'unsupported',
isReconstructable: false,
messages,
});
return [imageSet];
}

View File

@@ -0,0 +1,151 @@
import hpMNGrid from './hangingprotocols/hpMNGrid';
import hpMNCompare from './hangingprotocols/hpCompare';
import hpMammography from './hangingprotocols/hpMammo';
import hpScale from './hangingprotocols/hpScale';
const defaultProtocol = {
id: 'default',
locked: true,
// Don't store this hanging protocol as it applies to the currently active
// display set by default
// cacheId: null,
name: 'Default',
createdDate: '2021-02-23T19:22:08.894Z',
modifiedDate: '2023-04-01',
availableTo: {},
editableBy: {},
protocolMatchingRules: [],
toolGroupIds: ['default'],
// -1 would be used to indicate active only, whereas other values are
// the number of required priors referenced - so 0 means active with
// 0 or more priors.
numberOfPriorsReferenced: 0,
// Default viewport is used to define the viewport when
// additional viewports are added using the layout tool
defaultViewport: {
viewportOptions: {
viewportType: 'stack',
toolGroupId: 'default',
allowUnmatchedView: true,
syncGroups: [
{
type: 'hydrateseg',
id: 'sameFORId',
source: true,
target: true,
options: {
matchingRules: ['sameFOR'],
},
},
],
},
displaySets: [
{
id: 'defaultDisplaySetId',
matchedDisplaySetsIndex: -1,
},
],
},
displaySetSelectors: {
defaultDisplaySetId: {
// Matches displaysets, NOT series
seriesMatchingRules: [
// Try to match series with images by default, to prevent weird display
// on SEG/SR containing studies
{
weight: 10,
attribute: 'numImageFrames',
constraint: {
greaterThan: { value: 0 },
},
},
// This display set will select the specified items by preference
// It has no affect if nothing is specified in the URL.
{
attribute: 'isDisplaySetFromUrl',
weight: 10,
constraint: {
equals: true,
},
},
],
},
},
stages: [
{
name: 'default',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 1,
columns: 1,
},
},
viewports: [
{
viewportOptions: {
viewportType: 'stack',
viewportId: 'default',
toolGroupId: 'default',
// This will specify the initial image options index if it matches in the URL
// and will otherwise not specify anything.
initialImageOptions: {
custom: 'sopInstanceLocation',
},
// Other options for initialImageOptions, which can be included in the default
// custom attribute, or can be provided directly.
// index: 180,
// preset: 'middle', // 'first', 'last', 'middle'
// },
syncGroups: [
{
type: 'hydrateseg',
id: 'sameFORId',
source: true,
target: true,
// options: {
// matchingRules: ['sameFOR'],
// },
},
],
},
displaySets: [
{
id: 'defaultDisplaySetId',
},
],
},
],
createdDate: '2021-02-23T18:32:42.850Z',
},
],
};
function getHangingProtocolModule() {
return [
{
name: defaultProtocol.id,
protocol: defaultProtocol,
},
// Create a MxN comparison hanging protocol available by default
{
name: hpMNCompare.id,
protocol: hpMNCompare,
},
{
name: hpMammography.id,
protocol: hpMammography,
},
{
name: hpScale.id,
protocol: hpScale,
},
// Create a MxN hanging protocol available by default
{
name: hpMNGrid.id,
protocol: hpMNGrid,
},
];
}
export default getHangingProtocolModule;

View File

@@ -0,0 +1,28 @@
import ViewerLayout from './ViewerLayout';
/*
- Define layout for the viewer in mode configuration.
- Pass in the viewport types that can populate the viewer.
- Init layout based on the displaySets and the objects.
*/
export default function ({ servicesManager, extensionManager, commandsManager, hotkeysManager }) {
function ViewerLayoutWithServices(props) {
return ViewerLayout({
servicesManager,
extensionManager,
commandsManager,
hotkeysManager,
...props,
});
}
return [
// Layout Template Definition
// TODO: this is weird naming
{
name: 'viewerLayout',
id: 'viewerLayout',
component: ViewerLayoutWithServices,
},
];
}

View File

@@ -0,0 +1,109 @@
import OHIF from '@ohif/core';
import { InstanceMetadata, PhilipsPETPrivateGroup } from '@cornerstonejs/calculate-suv/src/types';
const metadataProvider = OHIF.classes.MetadataProvider;
export default function getPTImageIdInstanceMetadata(imageId: string): InstanceMetadata {
const dicomMetaData = metadataProvider.get('instance', imageId);
if (!dicomMetaData) {
throw new Error('dicom metadata are required');
}
if (
dicomMetaData.SeriesDate === undefined ||
dicomMetaData.SeriesTime === undefined ||
dicomMetaData.CorrectedImage === undefined ||
dicomMetaData.Units === undefined ||
!dicomMetaData.RadiopharmaceuticalInformationSequence ||
dicomMetaData.RadiopharmaceuticalInformationSequence.RadionuclideHalfLife === undefined ||
dicomMetaData.RadiopharmaceuticalInformationSequence.RadionuclideTotalDose === undefined ||
dicomMetaData.DecayCorrection === undefined ||
dicomMetaData.AcquisitionDate === undefined ||
dicomMetaData.AcquisitionTime === undefined ||
(dicomMetaData.RadiopharmaceuticalInformationSequence.RadiopharmaceuticalStartDateTime ===
undefined &&
dicomMetaData.RadiopharmaceuticalInformationSequence.RadiopharmaceuticalStartTime ===
undefined)
) {
throw new Error('required metadata are missing');
}
if (dicomMetaData.PatientWeight === undefined) {
console.warn('PatientWeight missing from PT instance metadata');
}
const instanceMetadata: InstanceMetadata = {
CorrectedImage: dicomMetaData.CorrectedImage,
Units: dicomMetaData.Units,
RadionuclideHalfLife: dicomMetaData.RadiopharmaceuticalInformationSequence.RadionuclideHalfLife,
RadionuclideTotalDose:
dicomMetaData.RadiopharmaceuticalInformationSequence.RadionuclideTotalDose,
RadiopharmaceuticalStartDateTime:
dicomMetaData.RadiopharmaceuticalInformationSequence.RadiopharmaceuticalStartDateTime,
RadiopharmaceuticalStartTime:
dicomMetaData.RadiopharmaceuticalInformationSequence.RadiopharmaceuticalStartTime,
DecayCorrection: dicomMetaData.DecayCorrection,
PatientWeight: dicomMetaData.PatientWeight,
SeriesDate: dicomMetaData.SeriesDate,
SeriesTime: dicomMetaData.SeriesTime,
AcquisitionDate: dicomMetaData.AcquisitionDate,
AcquisitionTime: dicomMetaData.AcquisitionTime,
};
if (
dicomMetaData['70531000'] ||
dicomMetaData['70531000'] !== undefined ||
dicomMetaData['70531009'] ||
dicomMetaData['70531009'] !== undefined
) {
const philipsPETPrivateGroup: PhilipsPETPrivateGroup = {
SUVScaleFactor: dicomMetaData['70531000'],
ActivityConcentrationScaleFactor: dicomMetaData['70531009'],
};
instanceMetadata.PhilipsPETPrivateGroup = philipsPETPrivateGroup;
}
if (dicomMetaData['0009100d'] && dicomMetaData['0009100d'] !== undefined) {
instanceMetadata.GEPrivatePostInjectionDateTime = dicomMetaData['0009100d'];
}
if (dicomMetaData.FrameReferenceTime && dicomMetaData.FrameReferenceTime !== undefined) {
instanceMetadata.FrameReferenceTime = dicomMetaData.FrameReferenceTime;
}
if (dicomMetaData.ActualFrameDuration && dicomMetaData.ActualFrameDuration !== undefined) {
instanceMetadata.ActualFrameDuration = dicomMetaData.ActualFrameDuration;
}
if (dicomMetaData.PatientSex && dicomMetaData.PatientSex !== undefined) {
instanceMetadata.PatientSex = dicomMetaData.PatientSex;
}
if (dicomMetaData.PatientSize && dicomMetaData.PatientSize !== undefined) {
instanceMetadata.PatientSize = dicomMetaData.PatientSize;
}
return instanceMetadata;
}
function convertInterfaceTimeToString(time): string {
const hours = `${time.hours || '00'}`.padStart(2, '0');
const minutes = `${time.minutes || '00'}`.padStart(2, '0');
const seconds = `${time.seconds || '00'}`.padStart(2, '0');
const fractionalSeconds = `${time.fractionalSeconds || '000000'}`.padEnd(6, '0');
const timeString = `${hours}${minutes}${seconds}.${fractionalSeconds}`;
return timeString;
}
function convertInterfaceDateToString(date): string {
const month = `${date.month}`.padStart(2, '0');
const day = `${date.day}`.padStart(2, '0');
const dateString = `${date.year}${month}${day}`;
return dateString;
}
export { getPTImageIdInstanceMetadata };

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { WrappedPanelStudyBrowser } from './Panels';
import i18n from 'i18next';
// TODO:
// - No loading UI exists yet
// - cancel promises when component is destroyed
// - show errors in UI for thumbnails if promise fails
function getPanelModule({ commandsManager, extensionManager, servicesManager }) {
return [
{
name: 'seriesList',
iconName: 'tab-studies',
iconLabel: 'Studies',
label: i18n.t('SidePanel:Studies'),
component: props => (
<WrappedPanelStudyBrowser
{...props}
commandsManager={commandsManager}
extensionManager={extensionManager}
servicesManager={servicesManager}
/>
),
},
];
}
export default getPanelModule;

View File

@@ -0,0 +1,287 @@
import { utils, classes } from '@ohif/core';
import { id } from './id';
import getDisplaySetMessages from './getDisplaySetMessages';
import getDisplaySetsFromUnsupportedSeries from './getDisplaySetsFromUnsupportedSeries';
import { chartHandler } from './SOPClassHandlers/chartSOPClassHandler';
const { isImage, sopClassDictionary, isDisplaySetReconstructable } = utils;
const { ImageSet } = classes;
const DEFAULT_VOLUME_LOADER_SCHEME = 'cornerstoneStreamingImageVolume';
const DYNAMIC_VOLUME_LOADER_SCHEME = 'cornerstoneStreamingDynamicImageVolume';
const sopClassHandlerName = 'stack';
let appContext = {};
const getDynamicVolumeInfo = instances => {
const { extensionManager } = appContext;
if (!extensionManager) {
throw new Error('extensionManager is not available');
}
const imageIds = instances.map(({ imageId }) => imageId);
const volumeLoaderUtility = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.utilityModule.volumeLoader'
);
const { getDynamicVolumeInfo: csGetDynamicVolumeInfo } = volumeLoaderUtility.exports;
return csGetDynamicVolumeInfo(imageIds);
};
const isMultiFrame = instance => {
return instance.NumberOfFrames > 1;
};
function getDisplaySetInfo(instances) {
const dynamicVolumeInfo = getDynamicVolumeInfo(instances);
const { isDynamicVolume, timePoints } = dynamicVolumeInfo;
let displaySetInfo;
const { appConfig } = appContext;
if (isDynamicVolume) {
const timePoint = timePoints[0];
const instancesMap = new Map();
// O(n) to convert it into a map and O(1) to find each instance
instances.forEach(instance => instancesMap.set(instance.imageId, instance));
const firstTimePointInstances = timePoint.map(imageId => instancesMap.get(imageId));
displaySetInfo = isDisplaySetReconstructable(firstTimePointInstances, appConfig);
} else {
displaySetInfo = isDisplaySetReconstructable(instances, appConfig);
}
return {
isDynamicVolume,
...displaySetInfo,
dynamicVolumeInfo,
};
}
const makeDisplaySet = instances => {
const instance = instances[0];
const imageSet = new ImageSet(instances);
const {
isDynamicVolume,
value: isReconstructable,
averageSpacingBetweenFrames,
dynamicVolumeInfo,
} = getDisplaySetInfo(instances);
const volumeLoaderSchema = isDynamicVolume
? DYNAMIC_VOLUME_LOADER_SCHEME
: DEFAULT_VOLUME_LOADER_SCHEME;
// set appropriate attributes to image set...
const messages = getDisplaySetMessages(instances, isReconstructable, isDynamicVolume);
imageSet.setAttributes({
volumeLoaderSchema,
displaySetInstanceUID: imageSet.uid, // create a local alias for the imageSet UID
SeriesDate: instance.SeriesDate,
SeriesTime: instance.SeriesTime,
SeriesInstanceUID: instance.SeriesInstanceUID,
StudyInstanceUID: instance.StudyInstanceUID,
SeriesNumber: instance.SeriesNumber || 0,
FrameRate: instance.FrameTime,
SOPClassUID: instance.SOPClassUID,
SeriesDescription: instance.SeriesDescription || '',
Modality: instance.Modality,
isMultiFrame: isMultiFrame(instance),
countIcon: isReconstructable ? 'icon-mpr' : undefined,
numImageFrames: instances.length,
SOPClassHandlerId: `${id}.sopClassHandlerModule.${sopClassHandlerName}`,
isReconstructable,
messages,
averageSpacingBetweenFrames: averageSpacingBetweenFrames || null,
isDynamicVolume,
dynamicVolumeInfo,
});
// Sort the images in this series if needed
const shallSort = true; //!OHIF.utils.ObjectPath.get(Meteor, 'settings.public.ui.sortSeriesByIncomingOrder');
if (shallSort) {
imageSet.sortBy((a, b) => {
// Sort by InstanceNumber (0020,0013)
return (parseInt(a.InstanceNumber) || 0) - (parseInt(b.InstanceNumber) || 0);
});
}
// Include the first image instance number (after sorted)
/*imageSet.setAttribute(
'instanceNumber',
imageSet.getImage(0).InstanceNumber
);*/
/*const isReconstructable = isDisplaySetReconstructable(series, instances);
imageSet.isReconstructable = isReconstructable.value;
if (isReconstructable.missingFrames) {
// TODO -> This is currently unused, but may be used for reconstructing
// Volumes with gaps later on.
imageSet.missingFrames = isReconstructable.missingFrames;
}*/
return imageSet;
};
const isSingleImageModality = modality => {
return modality === 'CR' || modality === 'MG' || modality === 'DX';
};
function getSopClassUids(instances) {
const uniqueSopClassUidsInSeries = new Set();
instances.forEach(instance => {
uniqueSopClassUidsInSeries.add(instance.SOPClassUID);
});
const sopClassUids = Array.from(uniqueSopClassUidsInSeries);
return sopClassUids;
}
/**
* Basic SOPClassHandler:
* - For all Image types that are stackable, create
* a displaySet with a stack of images
*
* @param {SeriesMetadata} series The series metadata object from which the display sets will be created
* @returns {Array} The list of display sets created for the given series object
*/
function getDisplaySetsFromSeries(instances) {
// If the series has no instances, stop here
if (!instances || !instances.length) {
throw new Error('No instances were provided');
}
const displaySets = [];
const sopClassUids = getSopClassUids(instances);
// Search through the instances (InstanceMetadata object) of this series
// Split Multi-frame instances and Single-image modalities
// into their own specific display sets. Place the rest of each
// series into another display set.
const stackableInstances = [];
instances.forEach(instance => {
// All imaging modalities must have a valid value for sopClassUid (x00080016) or rows (x00280010)
if (!isImage(instance.SOPClassUID) && !instance.Rows) {
return;
}
let displaySet;
if (isMultiFrame(instance)) {
displaySet = makeDisplaySet([instance]);
displaySet.setAttributes({
sopClassUids,
numImageFrames: instance.NumberOfFrames,
instanceNumber: instance.InstanceNumber,
acquisitionDatetime: instance.AcquisitionDateTime,
});
displaySets.push(displaySet);
} else if (isSingleImageModality(instance.Modality)) {
displaySet = makeDisplaySet([instance]);
displaySet.setAttributes({
sopClassUids,
instanceNumber: instance.InstanceNumber,
acquisitionDatetime: instance.AcquisitionDateTime,
});
displaySets.push(displaySet);
} else {
stackableInstances.push(instance);
}
});
if (stackableInstances.length) {
const displaySet = makeDisplaySet(stackableInstances);
displaySet.setAttribute('studyInstanceUid', instances[0].StudyInstanceUID);
displaySet.setAttributes({
sopClassUids,
});
displaySets.push(displaySet);
}
return displaySets;
}
const sopClassUids = [
sopClassDictionary.ComputedRadiographyImageStorage,
sopClassDictionary.DigitalXRayImageStorageForPresentation,
sopClassDictionary.DigitalXRayImageStorageForProcessing,
sopClassDictionary.DigitalMammographyXRayImageStorageForPresentation,
sopClassDictionary.DigitalMammographyXRayImageStorageForProcessing,
sopClassDictionary.DigitalIntraOralXRayImageStorageForPresentation,
sopClassDictionary.DigitalIntraOralXRayImageStorageForProcessing,
sopClassDictionary.CTImageStorage,
sopClassDictionary.EnhancedCTImageStorage,
sopClassDictionary.LegacyConvertedEnhancedCTImageStorage,
sopClassDictionary.UltrasoundMultiframeImageStorage,
sopClassDictionary.MRImageStorage,
sopClassDictionary.EnhancedMRImageStorage,
sopClassDictionary.EnhancedMRColorImageStorage,
sopClassDictionary.LegacyConvertedEnhancedMRImageStorage,
sopClassDictionary.UltrasoundImageStorage,
sopClassDictionary.UltrasoundImageStorageRET,
sopClassDictionary.SecondaryCaptureImageStorage,
sopClassDictionary.MultiframeSingleBitSecondaryCaptureImageStorage,
sopClassDictionary.MultiframeGrayscaleByteSecondaryCaptureImageStorage,
sopClassDictionary.MultiframeGrayscaleWordSecondaryCaptureImageStorage,
sopClassDictionary.MultiframeTrueColorSecondaryCaptureImageStorage,
sopClassDictionary.XRayAngiographicImageStorage,
sopClassDictionary.EnhancedXAImageStorage,
sopClassDictionary.XRayRadiofluoroscopicImageStorage,
sopClassDictionary.EnhancedXRFImageStorage,
sopClassDictionary.XRay3DAngiographicImageStorage,
sopClassDictionary.XRay3DCraniofacialImageStorage,
sopClassDictionary.BreastTomosynthesisImageStorage,
sopClassDictionary.BreastProjectionXRayImageStorageForPresentation,
sopClassDictionary.BreastProjectionXRayImageStorageForProcessing,
sopClassDictionary.IntravascularOpticalCoherenceTomographyImageStorageForPresentation,
sopClassDictionary.IntravascularOpticalCoherenceTomographyImageStorageForProcessing,
sopClassDictionary.NuclearMedicineImageStorage,
sopClassDictionary.VLEndoscopicImageStorage,
sopClassDictionary.VideoEndoscopicImageStorage,
sopClassDictionary.VLMicroscopicImageStorage,
sopClassDictionary.VideoMicroscopicImageStorage,
sopClassDictionary.VLSlideCoordinatesMicroscopicImageStorage,
sopClassDictionary.VLPhotographicImageStorage,
sopClassDictionary.VideoPhotographicImageStorage,
sopClassDictionary.OphthalmicPhotography8BitImageStorage,
sopClassDictionary.OphthalmicPhotography16BitImageStorage,
sopClassDictionary.OphthalmicTomographyImageStorage,
// Handled by another sop class module
// sopClassDictionary.VLWholeSlideMicroscopyImageStorage,
sopClassDictionary.PositronEmissionTomographyImageStorage,
sopClassDictionary.EnhancedPETImageStorage,
sopClassDictionary.LegacyConvertedEnhancedPETImageStorage,
sopClassDictionary.RTImageStorage,
sopClassDictionary.EnhancedUSVolumeStorage,
];
function getSopClassHandlerModule(appContextParam) {
appContext = appContextParam;
return [
{
name: sopClassHandlerName,
sopClassUids,
getDisplaySetsFromSeries,
},
{
name: 'not-supported-display-sets-handler',
sopClassUids: [],
getDisplaySetsFromSeries: getDisplaySetsFromUnsupportedSeries,
},
{
name: chartHandler.name,
sopClassUids: chartHandler.sopClassUids,
getDisplaySetsFromSeries: chartHandler.getDisplaySetsFromSeries,
},
];
}
export default getSopClassHandlerModule;

View File

@@ -0,0 +1,73 @@
import ToolbarDivider from './Toolbar/ToolbarDivider';
import ToolbarLayoutSelectorWithServices from './Toolbar/ToolbarLayoutSelector';
import ToolbarSplitButtonWithServices from './Toolbar/ToolbarSplitButtonWithServices';
import ToolbarButtonGroupWithServices from './Toolbar/ToolbarButtonGroupWithServices';
import { ToolbarButton } from '@ohif/ui';
import { ProgressDropdownWithService } from './Components/ProgressDropdownWithService';
const getClassName = isToggled => {
return {
className: isToggled
? '!text-primary-active'
: '!text-common-bright hover:!bg-primary-dark hover:text-primary-light',
};
};
export default function getToolbarModule({ commandsManager, servicesManager }: withAppTypes) {
const { cineService } = servicesManager.services;
return [
{
name: 'ohif.radioGroup',
defaultComponent: ToolbarButton,
},
{
name: 'ohif.divider',
defaultComponent: ToolbarDivider,
},
{
name: 'ohif.splitButton',
defaultComponent: ToolbarSplitButtonWithServices,
},
{
name: 'ohif.layoutSelector',
defaultComponent: props =>
ToolbarLayoutSelectorWithServices({ ...props, commandsManager, servicesManager }),
},
{
name: 'ohif.buttonGroup',
defaultComponent: ToolbarButtonGroupWithServices,
},
{
name: 'ohif.progressDropdown',
defaultComponent: ProgressDropdownWithService,
},
{
name: 'evaluate.group.promoteToPrimary',
evaluate: ({ viewportId, button, itemId }) => {
const { items } = button.props;
if (!itemId) {
return {
primary: button.props.primary,
items,
};
}
// other wise we can move the clicked tool to the primary button
const clickedItemProps = items.find(item => item.id === itemId || item.itemId === itemId);
return {
primary: clickedItemProps,
items,
};
},
},
{
name: 'evaluate.cine',
evaluate: () => {
const isToggled = cineService.getState().isCineEnabled;
return getClassName(isToggled);
},
},
];
}

View File

@@ -0,0 +1,21 @@
import { CommandsManager, ExtensionManager } from '@ohif/core';
import LineChartViewport from './Components/LineChartViewport/index';
const getViewportModule = ({
servicesManager,
commandsManager,
extensionManager,
}: {
servicesManager: AppTypes.ServicesManager;
commandsManager: CommandsManager;
extensionManager: ExtensionManager;
}) => {
return [
{
name: 'chartViewport',
component: LineChartViewport,
},
];
};
export { getViewportModule as default };

View File

@@ -0,0 +1,188 @@
import { Types } from '@ohif/core';
const defaultDisplaySetSelector = {
studyMatchingRules: [
{
// The priorInstance is a study counter that indicates what position this study is in
// and the value comes from the options parameter.
attribute: 'studyInstanceUIDsIndex',
from: 'options',
required: true,
constraint: {
equals: { value: 0 },
},
},
],
seriesMatchingRules: [
{
attribute: 'numImageFrames',
constraint: {
greaterThan: { value: 0 },
},
},
// This display set will select the specified items by preference
// It has no affect if nothing is specified in the URL.
{
attribute: 'isDisplaySetFromUrl',
weight: 10,
constraint: {
equals: true,
},
},
],
};
const priorDisplaySetSelector = {
studyMatchingRules: [
{
// The priorInstance is a study counter that indicates what position this study is in
// and the value comes from the options parameter.
attribute: 'studyInstanceUIDsIndex',
from: 'options',
required: true,
constraint: {
equals: { value: 1 },
},
},
],
seriesMatchingRules: [
{
attribute: 'numImageFrames',
constraint: {
greaterThan: { value: 0 },
},
},
// This display set will select the specified items by preference
// It has no affect if nothing is specified in the URL.
{
attribute: 'isDisplaySetFromUrl',
weight: 10,
constraint: {
equals: true,
},
},
],
};
const currentDisplaySet = {
id: 'defaultDisplaySetId',
};
const priorDisplaySet = {
id: 'priorDisplaySetId',
};
const currentViewport0 = {
viewportOptions: {
toolGroupId: 'default',
allowUnmatchedView: true,
},
displaySets: [currentDisplaySet],
};
const currentViewport1 = {
...currentViewport0,
displaySets: [
{
...currentDisplaySet,
matchedDisplaySetsIndex: 1,
},
],
};
const priorViewport0 = {
...currentViewport0,
displaySets: [priorDisplaySet],
};
const priorViewport1 = {
...priorViewport0,
displaySets: [
{
...priorDisplaySet,
matchedDisplaySetsIndex: 1,
},
],
};
/**
* This hanging protocol can be activated on the primary mode by directly
* referencing it in a URL or by directly including it within a mode, e.g.:
* `&hangingProtocolId=@ohif/mnGrid` added to the viewer URL
* It is not included in the viewer mode by default.
*/
const hpMNCompare: Types.HangingProtocol.Protocol = {
id: '@ohif/hpCompare',
description: 'Compare two studies in various layouts',
name: 'Compare Two Studies',
numberOfPriorsReferenced: 1,
protocolMatchingRules: [
{
id: 'Two Studies',
weight: 1000,
// is there a second study or in another work the attribute
// studyInstanceUIDsIndex that we get from prior should not be null
attribute: 'StudyInstanceUID',
from: 'prior',
required: true,
constraint: {
notNull: true,
},
},
],
toolGroupIds: ['default'],
displaySetSelectors: {
defaultDisplaySetId: defaultDisplaySetSelector,
priorDisplaySetId: priorDisplaySetSelector,
},
defaultViewport: {
viewportOptions: {
viewportType: 'stack',
toolGroupId: 'default',
allowUnmatchedView: true,
},
displaySets: [
{
id: 'defaultDisplaySetId',
matchedDisplaySetsIndex: -1,
},
],
},
stages: [
{
name: '2x2',
stageActivation: {
enabled: {
minViewportsMatched: 4,
},
},
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 2,
columns: 2,
},
},
viewports: [currentViewport0, priorViewport0, currentViewport1, priorViewport1],
},
{
name: '2x1',
stageActivation: {
enabled: {
minViewportsMatched: 2,
},
},
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 1,
columns: 2,
},
},
viewports: [currentViewport0, priorViewport0],
},
],
};
export default hpMNCompare;

View File

@@ -0,0 +1,312 @@
import { Types } from '@ohif/core';
/**
* Sync group configuration for hydrating segmentations across viewports
* that share the same frame of reference
* @type {Types.HangingProtocol.SyncGroup}
*/
export const HYDRATE_SEG_SYNC_GROUP = {
type: 'hydrateseg',
id: 'sameFORId',
source: true,
target: true,
options: {
matchingRules: ['sameFOR'],
},
} as const;
/**
* This hanging protocol can be activated on the primary mode by directly
* referencing it in a URL or by directly including it within a mode, e.g.:
* `&hangingProtocolId=@ohif/mnGrid` added to the viewer URL
* It is not included in the viewer mode by default.
*/
const hpMN: Types.HangingProtocol.Protocol = {
id: '@ohif/mnGrid',
description: 'Has various hanging protocol grid layouts',
name: '2x2',
protocolMatchingRules: [
{
id: 'OneOrMoreSeries',
weight: 25,
attribute: 'numberOfDisplaySetsWithImages',
constraint: {
greaterThan: 0,
},
},
],
toolGroupIds: ['default'],
displaySetSelectors: {
defaultDisplaySetId: {
seriesMatchingRules: [
{
attribute: 'numImageFrames',
constraint: {
greaterThan: { value: 0 },
},
required: true,
},
// This display set will select the specified items by preference
// It has no affect if nothing is specified in the URL.
{
attribute: 'isDisplaySetFromUrl',
weight: 10,
constraint: {
equals: true,
},
},
],
},
},
defaultViewport: {
viewportOptions: {
viewportType: 'stack',
toolGroupId: 'default',
allowUnmatchedView: true,
syncGroups: [HYDRATE_SEG_SYNC_GROUP],
},
displaySets: [
{
id: 'defaultDisplaySetId',
matchedDisplaySetsIndex: -1,
},
],
},
stages: [
{
id: '2x2',
name: '2x2',
stageActivation: {
enabled: {
minViewportsMatched: 4,
},
},
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 2,
columns: 2,
},
},
viewports: [
{
viewportOptions: {
toolGroupId: 'default',
allowUnmatchedView: true,
syncGroups: [
{
type: 'hydrateseg',
id: 'sameFORId',
source: true,
target: true,
options: {
matchingRules: ['sameFOR'],
},
},
],
},
displaySets: [
{
id: 'defaultDisplaySetId',
},
],
},
{
viewportOptions: {
toolGroupId: 'default',
allowUnmatchedView: true,
},
displaySets: [
{
matchedDisplaySetsIndex: 1,
id: 'defaultDisplaySetId',
},
],
},
{
viewportOptions: {
toolGroupId: 'default',
allowUnmatchedView: true,
syncGroups: [
{
type: 'hydrateseg',
id: 'sameFORId',
source: true,
target: true,
// options: {
// matchingRules: ['sameFOR'],
// },
},
],
},
displaySets: [
{
matchedDisplaySetsIndex: 2,
id: 'defaultDisplaySetId',
},
],
},
{
viewportOptions: {
toolGroupId: 'default',
allowUnmatchedView: true,
syncGroups: [
{
type: 'hydrateseg',
id: 'sameFORId',
source: true,
target: true,
// options: {
// matchingRules: ['sameFOR'],
// },
},
],
},
displaySets: [
{
matchedDisplaySetsIndex: 3,
id: 'defaultDisplaySetId',
},
],
},
],
},
// 3x1 stage
{
id: '3x1',
// Obsolete settings:
requiredViewports: 1,
preferredViewports: 3,
// New equivalent:
stageActivation: {
enabled: {
minViewportsMatched: 3,
},
},
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 1,
columns: 3,
},
},
viewports: [
{
viewportOptions: {
toolGroupId: 'default',
allowUnmatchedView: true,
},
displaySets: [
{
id: 'defaultDisplaySetId',
},
],
},
{
viewportOptions: {
toolGroupId: 'default',
allowUnmatchedView: true,
},
displaySets: [
{
id: 'defaultDisplaySetId',
matchedDisplaySetsIndex: 1,
},
],
},
{
viewportOptions: {
toolGroupId: 'default',
allowUnmatchedView: true,
},
displaySets: [
{
id: 'defaultDisplaySetId',
matchedDisplaySetsIndex: 2,
},
],
},
],
},
// A 2x1 stage
{
id: '2x1',
requiredViewports: 1,
preferredViewports: 2,
stageActivation: {
enabled: {
minViewportsMatched: 2,
},
},
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 1,
columns: 2,
},
},
viewports: [
{
viewportOptions: {
toolGroupId: 'default',
allowUnmatchedView: true,
},
displaySets: [
{
id: 'defaultDisplaySetId',
},
],
},
{
viewportOptions: {
toolGroupId: 'default',
allowUnmatchedView: true,
},
displaySets: [
{
matchedDisplaySetsIndex: 1,
id: 'defaultDisplaySetId',
},
],
},
],
},
// A 1x1 stage - should be automatically activated if there is only 1 viewable instance
{
id: '1x1',
requiredViewports: 1,
preferredViewports: 1,
stageActivation: {
enabled: {
minViewportsMatched: 1,
},
},
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 1,
columns: 1,
},
},
viewports: [
{
viewportOptions: {
toolGroupId: 'default',
allowUnmatchedView: true,
},
displaySets: [
{
id: 'defaultDisplaySetId',
},
],
},
],
},
],
numberOfPriorsReferenced: -1,
};
export default hpMN;

View File

@@ -0,0 +1,201 @@
import {
RCC,
RMLO,
LCC,
LMLO,
RCCPrior,
LCCPrior,
RMLOPrior,
LMLOPrior,
} from './mammoDisplaySetSelector';
const rightDisplayArea = {
storeAsInitialCamera: true,
imageArea: [0.8, 0.8],
imageCanvasPoint: {
imagePoint: [0, 0.5],
canvasPoint: [0, 0.5],
},
};
const leftDisplayArea = {
storeAsInitialCamera: true,
imageArea: [0.8, 0.8],
imageCanvasPoint: {
imagePoint: [1, 0.5],
canvasPoint: [1, 0.5],
},
};
const hpMammography = {
id: '@ohif/hpMammo',
hasUpdatedPriorsInformation: false,
name: 'Mammography Breast Screening',
protocolMatchingRules: [
{
id: 'Mammography',
weight: 150,
attribute: 'ModalitiesInStudy',
constraint: {
contains: 'MG',
},
required: true,
},
{
id: 'numberOfImages',
attribute: 'numberOfDisplaySetsWithImages',
constraint: {
greaterThan: 2,
},
required: true,
},
],
toolGroupIds: ['default'],
displaySetSelectors: {
RCC,
LCC,
RMLO,
LMLO,
RCCPrior,
LCCPrior,
RMLOPrior,
LMLOPrior,
},
stages: [
{
name: 'CC/MLO',
viewportStructure: {
type: 'grid',
layoutType: 'grid',
properties: {
rows: 2,
columns: 2,
},
},
viewports: [
{
viewportOptions: {
toolGroupId: 'default',
displayArea: leftDisplayArea,
// flipHorizontal: true,
// rotation: 180,
allowUnmatchedView: true,
},
displaySets: [
{
id: 'RCC',
},
],
},
{
viewportOptions: {
toolGroupId: 'default',
// flipHorizontal: true,
displayArea: rightDisplayArea,
allowUnmatchedView: true,
},
displaySets: [
{
id: 'LCC',
},
],
},
{
viewportOptions: {
toolGroupId: 'default',
displayArea: leftDisplayArea,
// rotation: 180,
// flipHorizontal: true,
allowUnmatchedView: true,
},
displaySets: [
{
id: 'RMLO',
},
],
},
{
viewportOptions: {
toolGroupId: 'default',
displayArea: rightDisplayArea,
// flipHorizontal: true,
allowUnmatchedView: true,
},
displaySets: [
{
id: 'LMLO',
},
],
},
],
},
// Compare CC current/prior top/bottom
{
name: 'CC compare',
viewportStructure: {
type: 'grid',
layoutType: 'grid',
properties: {
rows: 2,
columns: 2,
},
},
viewports: [
{
viewportOptions: {
toolGroupId: 'default',
displayArea: leftDisplayArea,
flipHorizontal: true,
rotation: 180,
},
displaySets: [
{
id: 'RCC',
},
],
},
{
viewportOptions: {
toolGroupId: 'default',
flipHorizontal: true,
displayArea: rightDisplayArea,
},
displaySets: [
{
id: 'LCC',
},
],
},
{
viewportOptions: {
toolGroupId: 'default',
displayArea: leftDisplayArea,
flipHorizontal: true,
},
displaySets: [
{
id: 'RCCPrior',
},
],
},
{
viewportOptions: {
toolGroupId: 'default',
displayArea: rightDisplayArea,
},
displaySets: [
{
id: 'LCCPrior',
},
],
},
],
},
],
// Indicates it is prior aware, but will work with no priors
numberOfPriorsReferenced: 0,
};
export default hpMammography;

View File

@@ -0,0 +1,132 @@
import { Types } from '@ohif/core';
const displayAreaScale1: Types.HangingProtocol.DisplayArea = {
type: 'SCALE',
scale: 1,
storeAsInitialCamera: true,
};
const displayAreaScale15: Types.HangingProtocol.DisplayArea = { ...displayAreaScale1, scale: 15 };
/**
* This hanging protocol can be activated on the primary mode by directly
* referencing it in a URL or by directly including it within a mode, e.g.:
* `&hangingProtocolId=@ohif/mnGrid` added to the viewer URL
* It is not included in the viewer mode by default.
*/
const hpScale: Types.HangingProtocol.Protocol = {
id: '@ohif/hpScale',
description: 'Has various hanging protocol grid layouts',
name: 'Scale Images',
protocolMatchingRules: [
{
id: 'OneOrMoreSeries',
weight: 25,
attribute: 'numberOfDisplaySetsWithImages',
constraint: {
greaterThan: 0,
},
},
],
toolGroupIds: ['default'],
displaySetSelectors: {
defaultDisplaySetId: {
seriesMatchingRules: [
{
weight: 1,
attribute: 'numImageFrames',
constraint: {
greaterThan: { value: 0 },
},
required: true,
},
// This display set will select the specified items by preference
// It has no affect if nothing is specified in the URL.
{
attribute: 'isDisplaySetFromUrl',
weight: 10,
constraint: {
equals: true,
},
},
],
},
},
defaultViewport: {
viewportOptions: {
viewportType: 'stack',
toolGroupId: 'default',
displayArea: displayAreaScale1,
allowUnmatchedView: true,
},
displaySets: [
{
id: 'defaultDisplaySetId',
matchedDisplaySetsIndex: -1,
},
],
},
stages: [
// A 1x1 stage - should be automatically activated if there is only 1 viewable instance
{
name: 'Scale 1:1',
stageActivation: {
enabled: {
minViewportsMatched: 1,
},
},
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 1,
columns: 1,
},
},
viewports: [
{
viewportOptions: {
toolGroupId: 'default',
allowUnmatchedView: true,
displayArea: displayAreaScale1,
},
displaySets: [
{
id: 'defaultDisplaySetId',
},
],
},
],
},
{
name: 'Scale 1:15',
stageActivation: {
enabled: {
minViewportsMatched: 1,
},
},
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 1,
columns: 1,
},
},
viewports: [
{
viewportOptions: {
toolGroupId: 'default',
allowUnmatchedView: true,
displayArea: displayAreaScale15,
},
displaySets: [
{
id: 'defaultDisplaySetId',
},
],
},
],
},
],
numberOfPriorsReferenced: -1,
};
export default hpScale;

View File

@@ -0,0 +1,15 @@
import viewCodeAttribute from './viewCode';
import lateralityAttribute from './laterality';
import registerHangingProtocolAttributes from './registerHangingProtocolAttributes';
import hpMammography from './hpMammo';
import hpMNGrid from './hpMNGrid';
import hpCompare from './hpCompare';
export {
viewCodeAttribute,
lateralityAttribute,
hpMammography as hpMammo,
hpMNGrid,
hpCompare,
registerHangingProtocolAttributes,
};

View File

@@ -0,0 +1,9 @@
export default displaySet => {
const frameAnatomy =
displaySet?.images?.[0]?.SharedFunctionalGroupsSequence?.[0]?.FrameAnatomySequence?.[0];
if (!frameAnatomy) {
return undefined;
}
const laterality = frameAnatomy?.FrameLaterality;
return laterality;
};

View File

@@ -0,0 +1,214 @@
const priorStudyMatchingRules = [
{
// The priorInstance is a study counter that indicates what position this study is in
// and the value comes from the options parameter.
attribute: 'studyInstanceUIDsIndex',
from: 'options',
required: true,
constraint: {
equals: { value: 1 },
},
},
];
const currentStudyMatchingRules = [
{
// The priorInstance is a study counter that indicates what position this study is in
// and the value comes from the options parameter.
attribute: 'studyInstanceUIDsIndex',
from: 'options',
required: true,
constraint: {
equals: { value: 0 },
},
},
];
const LCCSeriesMatchingRules = [
{
weight: 10,
attribute: 'ViewCode',
constraint: {
contains: 'SCT:399162004',
},
},
{
weight: 5,
attribute: 'PatientOrientation',
constraint: {
contains: 'L',
},
},
{
weight: 20,
attribute: 'SeriesDescription',
constraint: {
contains: 'L CC',
},
},
];
const RCCSeriesMatchingRules = [
{
weight: 10,
attribute: 'ViewCode',
constraint: {
contains: 'SCT:399162004',
},
},
{
weight: 5,
attribute: 'PatientOrientation',
constraint: {
equals: ['P', 'L'],
},
},
{
attribute: 'PatientOrientation',
constraint: {
doesNotEqual: ['A', 'R'],
},
required: true,
},
{
weight: 20,
attribute: 'SeriesDescription',
constraint: {
contains: 'CC',
},
},
];
const LMLOSeriesMatchingRules = [
{
weight: 10,
attribute: 'ViewCode',
constraint: {
contains: 'SCT:399368009',
},
},
{
weight: 0,
attribute: 'ViewCode',
constraint: {
doesNotEqual: 'SCT:399162004',
},
required: true,
},
{
weight: 5,
attribute: 'PatientOrientation',
constraint: {
equals: ['A', 'R'],
},
},
{
weight: 20,
attribute: 'SeriesDescription',
constraint: {
contains: 'L MLO',
},
},
];
const RMLOSeriesMatchingRules = [
{
weight: 10,
attribute: 'ViewCode',
constraint: {
contains: 'SCT:399368009',
},
},
{
attribute: 'ViewCode',
constraint: {
doesNotEqual: 'SCT:399162004',
},
required: true,
},
{
attribute: 'PatientOrientation',
constraint: {
doesNotContain: ['P', 'FL'],
},
required: true,
},
{
weight: 5,
attribute: 'PatientOrientation',
constraint: {
equals: ['P', 'L'],
},
},
{
weight: 5,
attribute: 'PatientOrientation',
constraint: {
equals: ['A', 'FR'],
},
},
{
weight: 20,
attribute: 'SeriesDescription',
constraint: {
contains: 'R MLO',
},
},
{
attribute: 'SeriesDescription',
required: true,
constraint: {
doesNotContain: 'CC',
},
},
{
attribute: 'SeriesDescription',
required: true,
constraint: {
doesNotEqual: 'L MLO',
},
required: true,
},
];
const RCC = {
seriesMatchingRules: RCCSeriesMatchingRules,
studyMatchingRules: currentStudyMatchingRules,
};
const RCCPrior = {
seriesMatchingRules: RCCSeriesMatchingRules,
studyMatchingRules: priorStudyMatchingRules,
};
const LCC = {
seriesMatchingRules: LCCSeriesMatchingRules,
studyMatchingRules: currentStudyMatchingRules,
};
const LCCPrior = {
seriesMatchingRules: LCCSeriesMatchingRules,
studyMatchingRules: priorStudyMatchingRules,
};
const RMLO = {
seriesMatchingRules: RMLOSeriesMatchingRules,
studyMatchingRules: currentStudyMatchingRules,
};
const RMLOPrior = {
seriesMatchingRules: RMLOSeriesMatchingRules,
studyMatchingRules: priorStudyMatchingRules,
};
const LMLO = {
seriesMatchingRules: LMLOSeriesMatchingRules,
studyMatchingRules: currentStudyMatchingRules,
};
const LMLOPrior = {
seriesMatchingRules: LMLOSeriesMatchingRules,
studyMatchingRules: priorStudyMatchingRules,
};
export { RCC, LCC, RMLO, LMLO, RCCPrior, LCCPrior, RMLOPrior, LMLOPrior };

View File

@@ -0,0 +1,8 @@
import viewCode from './viewCode';
import laterality from './laterality';
export default function registerHangingProtocolAttributes({ servicesManager }) {
const { hangingProtocolService } = servicesManager.services;
hangingProtocolService.addCustomAttribute('ViewCode', 'View Code Designator:Value', viewCode);
hangingProtocolService.addCustomAttribute('Laterality', 'Laterality of object', laterality);
}

View File

@@ -0,0 +1,11 @@
export default displaySet => {
const ViewCodeSequence = displaySet?.images[0]?.ViewCodeSequence[0];
if (!ViewCodeSequence) {
return undefined;
}
const { CodingSchemeDesignator, CodeValue } = ViewCodeSequence;
if (!CodingSchemeDesignator || !CodeValue) {
return undefined;
}
return `${CodingSchemeDesignator}:${CodeValue}`;
};

View File

@@ -0,0 +1,63 @@
import { useState, useEffect } from 'react';
import { utils } from '@ohif/core';
const { formatPN, formatDate } = utils;
function usePatientInfo(servicesManager: AppTypes.ServicesManager) {
const { displaySetService } = servicesManager.services;
const [patientInfo, setPatientInfo] = useState({
PatientName: '',
PatientID: '',
PatientSex: '',
PatientDOB: '',
});
const [isMixedPatients, setIsMixedPatients] = useState(false);
const displaySets = displaySetService.getActiveDisplaySets();
const checkMixedPatients = PatientID => {
const displaySets = displaySetService.getActiveDisplaySets();
let isMixedPatients = false;
displaySets.forEach(displaySet => {
const instance = displaySet?.instances?.[0] || displaySet?.instance;
if (!instance) {
return;
}
if (instance.PatientID !== PatientID) {
isMixedPatients = true;
}
});
setIsMixedPatients(isMixedPatients);
};
const updatePatientInfo = () => {
const displaySet = displaySets[0];
const instance = displaySet?.instances?.[0] || displaySet?.instance;
if (!instance) {
return;
}
setPatientInfo({
PatientID: instance.PatientID || null,
PatientName: instance.PatientName ? formatPN(instance.PatientName.Alphabetic) : null,
PatientSex: instance.PatientSex || null,
PatientDOB: formatDate(instance.PatientBirthDate) || null,
});
checkMixedPatients(instance.PatientID || null);
};
useEffect(() => {
const subscription = displaySetService.subscribe(
displaySetService.EVENTS.DISPLAY_SETS_ADDED,
() => updatePatientInfo()
);
return () => subscription.unsubscribe();
}, []);
useEffect(() => {
updatePatientInfo();
}, [displaySets]);
return { patientInfo, isMixedPatients };
}
export default usePatientInfo;

View File

@@ -0,0 +1,5 @@
import packageJson from '../package.json';
const id = packageJson.name;
export { id };

View File

@@ -0,0 +1,105 @@
import { Types } from '@ohif/core';
import getDataSourcesModule from './getDataSourcesModule.js';
import getLayoutTemplateModule from './getLayoutTemplateModule.js';
import getPanelModule from './getPanelModule';
import getSopClassHandlerModule from './getSopClassHandlerModule.js';
import getToolbarModule from './getToolbarModule';
import getCommandsModule from './commandsModule';
import getHangingProtocolModule from './getHangingProtocolModule';
import getStudiesForPatientByMRN from './Panels/getStudiesForPatientByMRN';
import getCustomizationModule from './getCustomizationModule';
import getViewportModule from './getViewportModule';
import { id } from './id.js';
import preRegistration from './init';
import { ContextMenuController, CustomizableContextMenuTypes } from './CustomizableContextMenu';
import * as dicomWebUtils from './DicomWebDataSource/utils';
import { createReportDialogPrompt } from './Panels';
import createReportAsync from './Actions/createReportAsync';
import StaticWadoClient from './DicomWebDataSource/utils/StaticWadoClient';
import { cleanDenaturalizedDataset } from './DicomWebDataSource/utils';
import { useViewportsByPositionStore } from './stores/useViewportsByPositionStore';
import { useViewportGridStore } from './stores/useViewportGridStore';
import { useUIStateStore } from './stores/useUIStateStore';
import { useDisplaySetSelectorStore } from './stores/useDisplaySetSelectorStore';
import { useHangingProtocolStageIndexStore } from './stores/useHangingProtocolStageIndexStore';
import { useToggleHangingProtocolStore } from './stores/useToggleHangingProtocolStore';
import { useToggleOneUpViewportGridStore } from './stores/useToggleOneUpViewportGridStore';
import {
callLabelAutocompleteDialog,
showLabelAnnotationPopup,
callInputDialog,
} from './utils/callInputDialog';
import colorPickerDialog from './utils/colorPickerDialog';
import promptSaveReport from './utils/promptSaveReport';
import promptLabelAnnotation from './utils/promptLabelAnnotation';
import usePatientInfo from './hooks/usePatientInfo';
import { PanelStudyBrowserHeader } from './Panels/StudyBrowser/PanelStudyBrowserHeader';
import * as utils from './utils';
const defaultExtension: Types.Extensions.Extension = {
/**
* Only required property. Should be a unique value across all extensions.
*/
id,
preRegistration,
onModeExit() {
useViewportGridStore.getState().clearViewportGridState();
useUIStateStore.getState().clearUIState();
useDisplaySetSelectorStore.getState().clearDisplaySetSelectorMap();
useHangingProtocolStageIndexStore.getState().clearHangingProtocolStageIndexMap();
useToggleHangingProtocolStore.getState().clearToggleHangingProtocol();
useViewportsByPositionStore.getState().clearViewportsByPosition();
},
getDataSourcesModule,
getViewportModule,
getLayoutTemplateModule,
getPanelModule,
getHangingProtocolModule,
getSopClassHandlerModule,
getToolbarModule,
getCommandsModule,
getUtilityModule({ servicesManager }) {
return [
{
name: 'common',
exports: {
getStudiesForPatientByMRN,
},
},
];
},
getCustomizationModule,
};
export default defaultExtension;
export {
ContextMenuController,
CustomizableContextMenuTypes,
getStudiesForPatientByMRN,
dicomWebUtils,
createReportDialogPrompt,
createReportAsync,
StaticWadoClient,
cleanDenaturalizedDataset,
// Export all stores
useDisplaySetSelectorStore,
useHangingProtocolStageIndexStore,
useToggleHangingProtocolStore,
useToggleOneUpViewportGridStore,
useUIStateStore,
useViewportGridStore,
useViewportsByPositionStore,
showLabelAnnotationPopup,
callLabelAutocompleteDialog,
callInputDialog,
promptSaveReport,
promptLabelAnnotation,
colorPickerDialog,
usePatientInfo,
PanelStudyBrowserHeader,
utils,
};

View File

@@ -0,0 +1,113 @@
import { DicomMetadataStore, classes } from '@ohif/core';
import { calculateSUVScalingFactors } from '@cornerstonejs/calculate-suv';
import getPTImageIdInstanceMetadata from './getPTImageIdInstanceMetadata';
import { registerHangingProtocolAttributes } from './hangingprotocols';
const metadataProvider = classes.MetadataProvider;
/**
*
* @param {Object} servicesManager
* @param {Object} configuration
*/
export default function init({
servicesManager,
configuration = {},
commandsManager,
}: withAppTypes): void {
const { toolbarService, cineService, viewportGridService } = servicesManager.services;
toolbarService.registerEventForToolbarUpdate(cineService, [
cineService.EVENTS.CINE_STATE_CHANGED,
]);
// Add
DicomMetadataStore.subscribe(DicomMetadataStore.EVENTS.INSTANCES_ADDED, handlePETImageMetadata);
// If the metadata for PET has changed by the user (e.g. manually changing the PatientWeight)
// we need to recalculate the SUV Scaling Factors
DicomMetadataStore.subscribe(DicomMetadataStore.EVENTS.SERIES_UPDATED, handlePETImageMetadata);
// Adds extra custom attributes for use by hanging protocols
registerHangingProtocolAttributes({ servicesManager });
// Function to process and subscribe to events for a given set of commands and listeners
const subscribeToEvents = listeners => {
Object.entries(listeners).forEach(([event, commands]) => {
const supportedEvents = [
viewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED,
viewportGridService.EVENTS.VIEWPORTS_READY,
];
if (supportedEvents.includes(event)) {
viewportGridService.subscribe(event, eventData => {
const viewportId = eventData?.viewportId ?? viewportGridService.getActiveViewportId();
commandsManager.run(commands, { viewportId });
});
}
});
};
toolbarService.subscribe(toolbarService.EVENTS.TOOL_BAR_MODIFIED, state => {
const { buttons } = state;
for (const [id, button] of Object.entries(buttons)) {
const { groupId, items, listeners } = button.props || {};
// Handle group items' listeners
if (groupId && items) {
items.forEach(item => {
if (item.listeners) {
subscribeToEvents(item.listeners);
}
});
}
// Handle button listeners
if (listeners) {
subscribeToEvents(listeners);
}
}
});
}
const handlePETImageMetadata = ({ SeriesInstanceUID, StudyInstanceUID }) => {
const { instances } = DicomMetadataStore.getSeries(StudyInstanceUID, SeriesInstanceUID);
if (!instances?.length) {
return;
}
const modality = instances[0].Modality;
if (!modality || modality !== 'PT') {
return;
}
const imageIds = instances.map(instance => instance.imageId);
const instanceMetadataArray = [];
// try except block to prevent errors when the metadata is not correct
try {
imageIds.forEach(imageId => {
const instanceMetadata = getPTImageIdInstanceMetadata(imageId);
if (instanceMetadata) {
instanceMetadataArray.push(instanceMetadata);
}
});
if (!instanceMetadataArray.length) {
return;
}
const suvScalingFactors = calculateSUVScalingFactors(instanceMetadataArray);
instanceMetadataArray.forEach((instanceMetadata, index) => {
metadataProvider.addCustomMetadata(
imageIds[index],
'scalingModule',
suvScalingFactors[index]
);
});
} catch (error) {
console.log(error);
}
};

View File

@@ -0,0 +1,7 @@
export { useDisplaySetSelectorStore } from './useDisplaySetSelectorStore';
export { useHangingProtocolStageIndexStore } from './useHangingProtocolStageIndexStore';
export { useToggleHangingProtocolStore } from './useToggleHangingProtocolStore';
export { useToggleOneUpViewportGridStore } from './useToggleOneUpViewportGridStore';
export { useUIStateStore } from './useUIStateStore';
export { useViewportGridStore } from './useViewportGridStore';
export { useViewportsByPositionStore } from './useViewportsByPositionStore';

View File

@@ -0,0 +1,83 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
/**
* Identifier for the display set selector store type.
*/
const PRESENTATION_TYPE_ID = 'displaySetSelectorId';
/**
* Flag to enable or disable debug mode for the store.
* Set to `true` to enable zustand devtools.
*/
const DEBUG_STORE = false;
/**
* State shape for the Display Set Selector store.
*/
type DisplaySetSelectorState = {
/**
* Type identifier for the store.
*/
type: string;
/**
* Stores a mapping from `<activeStudyUID>:<displaySetSelectorId>:<matchOffset>` to `displaySetInstanceUID`.
*/
displaySetSelectorMap: Record<string, string>;
/**
* Sets the display set selector for a given key.
*
* @param key - The key.
* @param value - The `displaySetInstanceUID` to associate with the key.
*/
setDisplaySetSelector: (key: string, value: string) => void;
/**
* Clears the entire display set selector map.
*/
clearDisplaySetSelectorMap: () => void;
};
/**
* Creates the Display Set Selector store.
*
* @param set - The zustand set function.
* @returns The display set selector store state and actions.
*/
const createDisplaySetSelectorStore = (set): DisplaySetSelectorState => ({
type: PRESENTATION_TYPE_ID,
displaySetSelectorMap: {},
/**
* Sets the display set selector for a given key.
*/
setDisplaySetSelector: (key: string, value: string) =>
set(
state => ({
displaySetSelectorMap: {
...state.displaySetSelectorMap,
[key]: value,
},
}),
false,
'setDisplaySetSelector'
),
/**
* Clears the entire display set selector map.
*/
clearDisplaySetSelectorMap: () =>
set({ displaySetSelectorMap: {} }, false, 'clearDisplaySetSelectorMap'),
});
/**
* Zustand store for managing display set selectors.
* Applies devtools middleware when DEBUG_STORE is enabled.
*/
export const useDisplaySetSelectorStore = create<DisplaySetSelectorState>()(
DEBUG_STORE
? devtools(createDisplaySetSelectorStore, { name: 'DisplaySetSelectorStore' })
: createDisplaySetSelectorStore
);

View File

@@ -0,0 +1,76 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { Types } from '@ohif/core';
const PRESENTATION_TYPE_ID = 'hangingProtocolStageIndexId';
const DEBUG_STORE = false;
/**
* Represents the state and actions for managing hanging protocol stage indexes.
*/
type HangingProtocolStageIndexState = {
/**
* Stores a mapping from key to `HPInfo`.
*/
hangingProtocolStageIndexMap: Record<string, Types.HangingProtocol.HPInfo>;
/**
* Sets the hanging protocol stage index for a given key.
*
* @param key - The key.
* @param value - The `HPInfo` to associate with the key.
*/
setHangingProtocolStageIndex: (key: string, value: Types.HangingProtocol.HPInfo) => void;
/**
* Clears all hanging protocol stage indexes.
*/
clearHangingProtocolStageIndexMap: () => void;
/**
* Type identifier for the store.
*/
type: string;
};
/**
* Creates the Hanging Protocol Stage Index store.
*
* @param set - The zustand set function.
* @returns The hanging protocol stage index store state and actions.
*/
const createHangingProtocolStageIndexStore = (set): HangingProtocolStageIndexState => ({
hangingProtocolStageIndexMap: {},
type: PRESENTATION_TYPE_ID,
/**
* Sets the hanging protocol stage index for a given key.
*/
setHangingProtocolStageIndex: (key, value) =>
set(
state => ({
hangingProtocolStageIndexMap: {
...state.hangingProtocolStageIndexMap,
[key]: value,
},
}),
false,
'setHangingProtocolStageIndex'
),
/**
* Clears all hanging protocol stage indexes.
*/
clearHangingProtocolStageIndexMap: () =>
set({ hangingProtocolStageIndexMap: {} }, false, 'clearHangingProtocolStageIndexMap'),
});
/**
* Zustand store for managing hanging protocol stage indexes.
* Applies devtools middleware when DEBUG_STORE is enabled.
*/
export const useHangingProtocolStageIndexStore = create<HangingProtocolStageIndexState>()(
DEBUG_STORE
? devtools(createHangingProtocolStageIndexStore, { name: 'HangingProtocolStageIndexStore' })
: createHangingProtocolStageIndexStore
);

Some files were not shown because too many files have changed in this diff Show More