init: sudah ganti logo, hilangin setting, dan investigational use dialog

This commit is contained in:
one
2025-03-06 11:32:45 +07:00
commit 8f31d4ed41
2857 changed files with 355646 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
const path = require('path');
const webpackCommon = require('./../../../.webpack/webpack.base.js');
const SRC_DIR = path.join(__dirname, '../src');
const DIST_DIR = path.join(__dirname, '../dist');
const ENTRY = {
app: `${SRC_DIR}/index.tsx`,
};
module.exports = (env, argv) => {
return webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY });
};

View File

@@ -0,0 +1,54 @@
const webpack = require('webpack');
const { merge } = require('webpack-merge');
const path = require('path');
const webpackCommon = require('./../../../.webpack/webpack.base.js');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const pkg = require('./../package.json');
const ROOT_DIR = path.join(__dirname, '../');
const SRC_DIR = path.join(__dirname, '../src');
const DIST_DIR = path.join(__dirname, '../dist');
const ENTRY = {
app: `${SRC_DIR}/index.ts`,
};
const outputName = `ohif-${pkg.name.split('/').pop()}`;
module.exports = (env, argv) => {
const commonConfig = webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY });
return merge(commonConfig, {
stats: {
colors: true,
hash: true,
timings: true,
assets: true,
chunks: false,
chunkModules: false,
modules: false,
children: false,
warnings: true,
},
optimization: {
minimize: true,
sideEffects: true,
},
output: {
path: ROOT_DIR,
library: 'ohif-extension-default',
libraryTarget: 'umd',
filename: pkg.main,
},
externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@ohif/, /^@cornerstonejs/],
plugins: [
new webpack.optimize.LimitChunkCountPlugin({
maxChunks: 1,
}),
new MiniCssExtractPlugin({
filename: `./dist/${outputName}.css`,
chunkFilename: `./dist/${outputName}.css`,
}),
],
});
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
module.exports = require('../../babel.config.js');

View File

@@ -0,0 +1,17 @@
const base = require('../../jest.config.base.js');
const pkg = require('./package');
module.exports = {
...base,
name: pkg.name,
displayName: pkg.name,
moduleNameMapper: {
...base.moduleNameMapper,
'@ohif/(.*)': '<rootDir>/../../platform/$1/src',
},
// rootDir: "../.."
// testMatch: [
// //`<rootDir>/platform/${pack.name}/**/*.spec.js`
// "<rootDir>/platform/app/**/*.test.js"
// ]
};

View File

@@ -0,0 +1,53 @@
{
"name": "@ohif/extension-default",
"version": "3.10.0-beta.111",
"description": "Common/default features and functionality for basic image viewing",
"author": "OHIF Core Team",
"license": "MIT",
"repository": "OHIF/Viewers",
"main": "dist/ohif-extension-default.umd.js",
"module": "src/index.ts",
"publishConfig": {
"access": "public"
},
"engines": {
"node": ">=14",
"npm": ">=6",
"yarn": ">=1.18.0"
},
"files": [
"dist",
"README.md"
],
"keywords": [
"ohif-extension"
],
"scripts": {
"clean": "shx rm -rf dist",
"clean:deep": "yarn run clean && shx rm -rf node_modules",
"dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --output-pathinfo",
"dev:dicom-pdf": "yarn run dev",
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
"build:package-1": "yarn run build",
"start": "yarn run dev"
},
"peerDependencies": {
"@ohif/core": "3.10.0-beta.111",
"@ohif/i18n": "3.10.0-beta.111",
"dcmjs": "*",
"dicomweb-client": "^0.10.4",
"prop-types": "^15.6.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^12.2.2",
"react-window": "^1.8.9",
"webpack": "5.89.0",
"webpack-merge": "^5.7.3"
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/calculate-suv": "^1.1.0",
"lodash.get": "^4.4.2",
"lodash.uniqby": "^4.7.0"
}
}

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,117 @@
import React, { ReactElement, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useModal } from '@ohif/ui';
import { Icons } from '@ohif/ui-next';
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.getCustomization(
activeDataSourceDef.configuration.configurationAPI
) ?? { factory: () => null };
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">
<Icons.Settings
className="mr-2.5 h-3.5 w-3.5 shrink-0 cursor-pointer"
onClick={showConfigurationModal}
/>
{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 { Icons } from '@ohif/ui-next';
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 ? (
<Icons.ByName name="status-tracked" />
) : (
<Icons.ByName 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,90 @@
import classNames from 'classnames';
import React, { ReactElement, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSystem } from '@ohif/core';
import { Button, InputFilterText } from '@ohif/ui';
import { Icons } from '@ohif/ui-next';
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 { servicesManager } = useSystem();
const { t } = useTranslation('DataSourceConfiguration');
const [filterValue, setFilterValue] = useState('');
useEffect(() => {
setFilterValue('');
}, [itemList]);
const LoadingIndicatorProgress = servicesManager.services.customizationService.getCustomization(
'ui.loadingIndicatorProgress'
);
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">
<Icons.ToolMagnify 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={<Icons.ByName 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,119 @@
import React from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuItem,
Icons,
Button,
} from '@ohif/ui-next';
/**
* The default sub-menu appearance and setup is defined here, but this can be
* replaced by
*/
const getMenuItemsDefault = ({
commandsManager,
items,
servicesManager,
...props
}: withAppTypes) => {
const { customizationService } = servicesManager.services;
// This allows replacing the default child item for menus, whereas the entire
// getMenuItems can also be replaced by providing it to the MoreDropdownMenu
const menuContent = customizationService.getCustomization('ohif.menuContent');
// Default menu item component if none is provided through customization
const DefaultMenuItem = ({
item,
}: {
item: {
id: string;
label: string;
iconName: string;
onClick: ({ commandsManager, ...props }: withAppTypes) => () => void;
};
}) => (
<DropdownMenuItem onClick={() => item.onClick({ commandsManager, ...props })}>
<div className="flex items-center gap-2">
{item.iconName && <Icons.ByName name={item.iconName} />}
<span>{item.label}</span>
</div>
</DropdownMenuItem>
);
const MenuItemComponent = menuContent?.content || DefaultMenuItem;
return (
<DropdownMenuContent
hideWhenDetached
align="start"
onClick={e => {
e.stopPropagation();
e.preventDefault();
}}
>
{items?.map((item, index) => (
<MenuItemComponent
key={item.id || `menu-item-${index}`}
item={item}
commandsManager={commandsManager}
servicesManager={servicesManager}
{...props}
/>
))}
</DropdownMenuContent>
);
};
/**
* The component provides a ... sub-menu for various components which appears
* on hover over the main component.
*
* @param bindProps - properties to define the sub-menu
* @returns Component bound to the bindProps
*/
export default function MoreDropdownMenu(bindProps) {
const {
menuItemsKey,
getMenuItems = getMenuItemsDefault,
commandsManager,
servicesManager,
} = bindProps;
const { customizationService } = servicesManager.services;
const items = customizationService.getCustomization(menuItemsKey);
if (!items?.length) {
return null;
}
function BoundMoreDropdownMenu(props) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="hidden group-hover:inline-flex data-[state=open]:inline-flex"
onClick={e => {
e.preventDefault();
e.stopPropagation();
}}
>
<Icons.More />
</Button>
</DropdownMenuTrigger>
{getMenuItems({
...props,
commandsManager: commandsManager,
servicesManager: servicesManager,
items,
})}
</DropdownMenu>
);
}
return BoundMoreDropdownMenu;
}

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,116 @@
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;
onClose: () => void;
onOpen: () => void;
isExpanded: boolean;
collapsedWidth?: number;
expandedInsideBorderSize?: number;
collapsedInsideBorderSize?: number;
collapsedOutsideBorderSize?: number;
};
const SidePanelWithServices = ({
servicesManager,
side,
activeTabIndex: activeTabIndexProp,
isExpanded,
tabs: tabsProp,
onOpen,
onClose,
...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 [sidePanelExpanded, setSidePanelExpanded] = useState(isExpanded);
const [activeTabIndex, setActiveTabIndex] = useState(activeTabIndexProp ?? 0);
const [closedManually, setClosedManually] = useState(false);
const [tabs, setTabs] = useState(tabsProp ?? panelService.getPanels(side));
const handleActiveTabIndexChange = useCallback(({ activeTabIndex }) => {
setActiveTabIndex(activeTabIndex);
}, []);
const handleOpen = useCallback(() => {
setSidePanelExpanded(true);
onOpen?.();
}, [onOpen]);
const handleClose = useCallback(() => {
setSidePanelExpanded(false);
setClosedManually(true);
onClose?.();
}, [onClose]);
useEffect(() => {
setSidePanelExpanded(isExpanded);
}, [isExpanded]);
/** update the active tab index from outside */
useEffect(() => {
setActiveTabIndex(activeTabIndexProp ?? 0);
}, [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 (sidePanelExpanded || activatePanelEvent.forceActive) {
const tabIndex = tabs.findIndex(tab => tab.id === activatePanelEvent.panelId);
if (tabIndex !== -1) {
if (!closedManually) {
setSidePanelExpanded(true);
}
setActiveTabIndex(tabIndex);
}
}
}
);
return () => {
activatePanelSubscription.unsubscribe();
};
}, [tabs, sidePanelExpanded, panelService, closedManually]);
return (
<SidePanel
{...props}
side={side}
tabs={tabs}
activeTabIndex={activeTabIndex}
isExpanded={sidePanelExpanded}
onOpen={handleOpen}
onClose={handleClose}
onActiveTabIndexChange={handleActiveTabIndexChange}
/>
);
};
export default SidePanelWithServices;

View File

@@ -0,0 +1,217 @@
import * as ContextMenuItemsBuilder from './ContextMenuItemsBuilder';
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;
if (!menus) {
console.warn('No menus found for', menuId);
return;
}
const { locking, visibility } = CsAnnotation;
const targetAnnotationId = selectorProps?.nearbyToolData?.annotationUID as string;
if (targetAnnotationId) {
const isLocked = locking.isAnnotationLocked(targetAnnotationId);
const isVisible = visibility.isAnnotationVisible(targetAnnotationId);
if (isLocked || !isVisible) {
console.warn(`Annotation is ${isLocked ? 'locked' : 'not visible'}.`);
return;
}
}
const items = ContextMenuItemsBuilder.getMenuItems(
selectorProps || contextMenuProps,
event,
menus,
menuId
);
const ContextMenu = this.services.customizationService.getCustomization('ui.contextMenu');
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 || event,
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 || event,
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?.currentPoints?.client[0] ?? eventDetail?.pageX,
y: eventDetail?.currentPoints?.client[1] ?? eventDetail?.pageY,
});
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-down';
}
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,5 @@
import ContextMenuController from './ContextMenuController';
import * as ContextMenuItemsBuilder from './ContextMenuItemsBuilder';
import * as CustomizableContextMenuTypes from './types';
export { ContextMenuController, CustomizableContextMenuTypes, ContextMenuItemsBuilder };

View File

@@ -0,0 +1,126 @@
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.
*/
inheritsFrom?: 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.
*/
inheritsFrom?: 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 necessarily 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,263 @@
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 }) {
// Important: Never use instance.imageId because it might be multiframe,
// which would make it an invalid imageId.
// if (instance.imageId) {
// return instance.imageId;
// }
const { StudyInstanceUID, SeriesInstanceUID } = instance;
const SOPInstanceUID = instance.SOPInstanceUID || instance.SopInstanceUID;
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,376 @@
import dcmjs from 'dcmjs';
import moment from 'moment';
import React, { useState, useMemo, useCallback } from 'react';
import { classes, Types } from '@ohif/core';
import { InputFilterText } from '@ohif/ui';
import { Select, SelectTrigger, SelectContent, SelectItem, Slider } from '@ohif/ui-next';
import DicomTagTable from './DicomTagTable';
import './DicomTagBrowser.css';
export type Row = {
uid: string;
tag: string;
valueRepresentation: string;
keyword: string;
value: string;
isVisible: boolean;
depth: number;
parents?: string[];
children?: string[];
areChildrenVisible?: true;
};
let rowCounter = 0;
const generateRowId = () => `row_${++rowCounter}`;
const { ImageSet } = classes;
const { DicomMetaDictionary } = dcmjs.data;
const { nameMap } = DicomMetaDictionary;
const DicomTagBrowser = ({
displaySets,
displaySetInstanceUID,
}: {
displaySets: Types.DisplaySet[];
displaySetInstanceUID: string;
}) => {
const [selectedDisplaySetInstanceUID, setSelectedDisplaySetInstanceUID] =
useState(displaySetInstanceUID);
const [instanceNumber, setInstanceNumber] = useState(1);
const [shouldShowInstanceList, setShouldShowInstanceList] = useState(false);
const [filterValue, setFilterValue] = useState('');
const onSelectChange = value => {
setSelectedDisplaySetInstanceUID(value.value);
setInstanceNumber(1);
};
const activeDisplaySet = displaySets.find(
ds => ds.displaySetInstanceUID === selectedDisplaySetInstanceUID
);
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 getMetadata = useCallback(
isImageStack => {
if (isImageStack) {
return activeDisplaySet.images[instanceNumber - 1];
}
return activeDisplaySet.instance || activeDisplaySet;
},
[activeDisplaySet, instanceNumber]
);
const rows = useMemo(() => {
const isImageStack = activeDisplaySet instanceof ImageSet;
const metadata = getMetadata(isImageStack);
setShouldShowInstanceList(isImageStack && activeDisplaySet.images.length > 1);
const tags = getSortedTags(metadata);
const rows = getFormattedRowsFromTags({ tags, metadata, depth: 0 });
return rows;
}, [getMetadata, activeDisplaySet]);
const filteredRows = useMemo(() => {
if (!filterValue) {
return rows;
}
const matchedRowIds = new Set();
const propertiesToCheck = ['tag', 'valueRepresentation', 'keyword', 'value'];
const setIsMatched = row => {
const isDirectMatch = propertiesToCheck.some(propertyName =>
row[propertyName]?.toLowerCase().includes(filterValueLowerCase)
);
if (!isDirectMatch) {
return;
}
matchedRowIds.add(row.uid);
[...(row.parents ?? []), ...(row.children ?? [])].forEach(uid => matchedRowIds.add(uid));
};
const filterValueLowerCase = filterValue.toLowerCase();
rows.forEach(setIsMatched);
return rows.filter(row => matchedRowIds.has(row.uid));
}, [rows, filterValue]);
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>
{shouldShowInstanceList && (
<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, depth, parents }) {
const rows: Row[] = [];
tags.forEach(tagInfo => {
const uid = generateRowId();
if (tagInfo.vr === 'SQ') {
const children = tagInfo.values.flatMap(value =>
getFormattedRowsFromTags({
tags: value,
metadata,
depth: depth + 1,
parents: parents ? [...parents, uid] : [uid],
})
);
const row: Row = {
uid,
tag: tagInfo.tag,
valueRepresentation: tagInfo.vr,
keyword: tagInfo.keyword,
value: '',
depth,
isVisible: true,
areChildrenVisible: true,
children: children.map(child => child.uid),
parents,
};
rows.push(row, ...children);
} 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.warn(`Failed to parse value representation for tag '${tagInfo.keyword}'`);
}
}
const row: Row = {
uid,
tag: tagInfo.tag,
valueRepresentation: tagInfo.vr,
keyword: tagInfo.keyword,
value: tagInfo.value,
depth,
isVisible: true,
parents,
};
rows.push(row);
}
});
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);
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,
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,
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,
vr: '',
keyword: 'Private Tag',
value,
});
}
}
}
return rows;
}
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,304 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { VariableSizeList as List } from 'react-window';
import classNames from 'classnames';
import debounce from 'lodash.debounce';
import { Row } from './DicomTagBrowser';
import { Icons } from '@ohif/ui-next';
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,
};
const indentationPadding = 8;
const RowComponent = ({
row,
style,
keyPrefix,
onToggle,
}: {
row: Row;
style: any;
keyPrefix: string;
onToggle?: (areChildrenVisible: boolean) => void;
}) => {
const handleToggle = useCallback(() => {
onToggle(!row.areChildrenVisible);
}, [row.areChildrenVisible, onToggle]);
const hasChildren = row.children && row.children.length > 0;
const isChildOrParent = hasChildren || row.depth > 0;
const padding = indentationPadding * (1 + 2 * row.depth);
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={keyPrefix}
>
{isChildOrParent && (
<div style={{ paddingLeft: `${padding}px`, opacity: onToggle ? 1 : 0 }}>
{row.areChildrenVisible ? (
<div
className="cursor-pointer p-1"
onClick={handleToggle}
>
<Icons.ChevronDown />
</div>
) : (
<div
className="cursor-pointer p-1"
onClick={handleToggle}
>
<Icons.ChevronRight />
</div>
)}
</div>
)}
<div className="w-4/24 px-3">{row.tag}</div>
<div className="w-2/24 px-3">{row.valueRepresentation}</div>
<div className="w-6/24 px-3">{row.keyword}</div>
<div className="w-5/24 grow px-3">{row.value}</div>
</div>
);
};
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 }: { rows: Row[] }) {
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);
const [internalRows, setInternalRows] = useState(rows);
// 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);
}
};
useEffect(() => {
setInternalRows(rows);
}, [rows]);
const visibleRows = useMemo(() => {
return internalRows.filter(row => row.isVisible);
}, [internalRows]);
/**
* 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 getOneRowHeight = useCallback(
row => {
const headerWidths = [
tagHeaderElem.offsetWidth,
vrHeaderElem.offsetWidth,
keywordHeaderElem.offsetWidth,
valueHeaderElem.offsetWidth,
];
const context = canvasRef.current.getContext('2d');
context.font = getComputedStyle(canvasRef.current).font;
const propertiesToCheck = ['tag', 'valueRepresentation', 'keyword', 'value'];
return Object.entries(row)
.filter(([key]) => propertiesToCheck.includes(key))
.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), 0);
},
[keywordHeaderElem, tagHeaderElem, valueHeaderElem, vrHeaderElem]
);
/**
* 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(
rows => index => {
const row = rows[index];
const height = getOneRowHeight(row);
return height;
},
[getOneRowHeight]
);
const onToggle = useCallback(
sourceRow => {
if (!sourceRow.children) {
return undefined;
}
return areChildrenVisible => {
const newInternalRows = internalRows.map(internalRow => {
if (sourceRow.uid === internalRow.uid) {
return { ...internalRow, areChildrenVisible };
}
if (sourceRow.children.includes(internalRow.uid)) {
return { ...internalRow, isVisible: areChildrenVisible, areChildrenVisible };
}
return internalRow;
});
setInternalRows(newInternalRows);
};
},
[internalRows]
);
const getRowComponent = useCallback(
({ rows }: { rows: Row[] }) =>
function RowList({ index, style }) {
const row = useMemo(() => rows[index], [index]);
return (
<RowComponent
style={style}
row={row}
keyPrefix={`DICOMTagRow-${index}`}
onToggle={onToggle(row)}
/>
);
},
[onToggle]
);
/**
* 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]);
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={visibleRows.length}
itemSize={getItemSize(visibleRows)}
width={'100%'}
className="ohif-scrollbar"
>
{getRowComponent({ rows: visibleRows })}
</List>
)}
</div>
</div>
);
}
export default React.memo(DicomTagTable);

View File

@@ -0,0 +1,41 @@
export default function (wadoRoot, getAuthrorizationHeader) {
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);
const headers = getAuthrorizationHeader();
for (const key in headers) {
xhr.setRequestHeader(key, headers[key]);
}
//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,641 @@
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;
export type DicomWebConfig = {
/** Data source name */
name: string;
// wadoUriRoot - Legacy? (potentially unused/replaced)
/** Base URL to use for QIDO requests */
qidoRoot?: string;
wadoRoot?: string; // - Base URL to use for WADO requests
wadoUri?: string; // - Base URL to use for WADO URI requests
qidoSupportsIncludeField?: boolean; // - Whether QIDO supports the "Include" option to request additional fields in response
imageRendering?: string; // - wadors | ? (unsure of where/how this is used)
thumbnailRendering?: string; // - wadors | ? (unsure of where/how this is used)
/** Whether the server supports reject calls (i.e. DCM4CHEE) */
supportsReject?: boolean;
/** Request series meta async instead of blocking */
lazyLoadStudy?: boolean;
/** indicates if the retrieves can fetch singlepart. Options are bulkdata, video, image, or true */
singlepart?: boolean | string;
/** Transfer syntax to request from the server */
requestTransferSyntaxUID?: string;
acceptHeader?: string[]; // - Accept header to use for requests
/** Whether to omit quotation marks for multipart requests */
omitQuotationForMultipartRequest?: boolean;
/** Whether the server supports fuzzy matching */
supportsFuzzyMatching?: boolean;
/** Whether the server supports wildcard matching */
supportsWildcard?: boolean;
/** Whether the server supports the native DICOM model */
supportsNativeDICOMModel?: boolean;
/** Whether to enable request tag */
enableRequestTag?: boolean;
/** Whether to enable study lazy loading */
enableStudyLazyLoad?: boolean;
/** Whether to enable bulkDataURI */
bulkDataURI?: BulkDataURIConfig;
/** Function that is called after the configuration is initialized */
onConfiguration: (config: DicomWebConfig, params) => DicomWebConfig;
/** Whether to use the static WADO client */
staticWado?: boolean;
/** User authentication service */
userAuthenticationService: Record<string, unknown>;
};
export type BulkDataURIConfig = {
/** Enable bulkdata uri configuration */
enabled?: boolean;
/**
* Remove the startsWith string.
* This is used to correct reverse proxied URLs by removing the startsWith path
*/
startsWith?: string;
/**
* Adds this prefix path. Only used if the startsWith is defined and has
* been removed. This allows replacing the base path.
*/
prefixWith?: string;
/** Transform the bulkdata path. Used to replace a portion of the path */
transform?: (uri: string) => string;
/**
* Adds relative resolution to the path handling.
* series is the default, as the metadata retrieved is series level.
*/
relativeResolution?: 'studies' | 'series';
};
/**
* Creates a DICOM Web API based on the provided configuration.
*
* @param dicomWebConfig - Configuration for the DICOM Web API
* @returns DICOM Web API object
*/
function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
const { userAuthenticationService } = servicesManager.services;
let dicomWebConfigCopy,
qidoConfig,
wadoConfig,
qidoDicomWebClient,
wadoDicomWebClient,
getAuthorizationHeader,
generateWadoHeader;
// Default to enabling bulk data retrieves, with no other customization as
// this is part of hte base standard.
dicomWebConfig.bulkDataURI ||= { enabled: true };
const implementation = {
initialize: ({ params, query }) => {
if (dicomWebConfig.onConfiguration && typeof dicomWebConfig.onConfiguration === 'function') {
dicomWebConfig = dicomWebConfig.onConfiguration(dicomWebConfig, {
params,
query,
});
}
dicomWebConfigCopy = JSON.parse(JSON.stringify(dicomWebConfig));
getAuthorizationHeader = () => {
const xhrRequestHeaders = {};
const authHeaders = userAuthenticationService.getAuthorizationHeader();
if (authHeaders && authHeaders.Authorization) {
xhrRequestHeaders.Authorization = authHeaders.Authorization;
}
return xhrRequestHeaders;
};
generateWadoHeader = () => {
const authorizationHeader = getAuthorizationHeader();
//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(),
supportsFuzzyMatching: dicomWebConfig.supportsFuzzyMatching,
};
wadoConfig = {
url: dicomWebConfig.wadoRoot,
staticWado: dicomWebConfig.staticWado,
singlepart: dicomWebConfig.singlepart,
headers: userAuthenticationService.getAuthorizationHeader(),
errorInterceptor: errorHandler.getHTTPErrorHandler(),
supportsFuzzyMatching: dicomWebConfig.supportsFuzzyMatching,
};
// 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 = getAuthorizationHeader();
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 = getAuthorizationHeader();
const results = await seriesInStudy(qidoDicomWebClient, studyInstanceUid);
return processSeriesResults(results);
},
// processResults: processResults.bind(),
},
instances: {
search: (studyInstanceUid, queryParameters) => {
qidoDicomWebClient.headers = getAuthorizationHeader();
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 = getAuthorizationHeader();
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 = getAuthorizationHeader();
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
const validValues = value.filter(Boolean);
validValues.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 { StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID } = instance;
const numberOfFrames = instance.NumberOfFrames || 1;
// Process all frames consistently, whether single or multiframe
for (let i = 0; i < numberOfFrames; i++) {
const frameNumber = i + 1;
const frameImageId = implementation.getImageIdsForInstance({
instance,
frame: frameNumber,
});
// Add imageId specific mapping to this data as the URL isn't necessarily WADO-URI.
metadataProvider.addImageIdToUIDs(frameImageId, {
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID,
frameNumber: numberOfFrames > 1 ? frameNumber : undefined,
});
}
// 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
const imageId = implementation.getImageIdsForInstance({
instance,
});
instance.imageId = imageId;
});
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 paramsStudyInstanceUIDs = params.StudyInstanceUIDs || params.studyInstanceUIDs;
const queryStudyInstanceUIDs = utils.splitComma(
query.getAll('StudyInstanceUIDs').concat(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, getAuthorizationHeader);
}
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,272 @@
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
* @param {*} options - fuzzyMatching: if true, then do a sub-string match
* @returns true if the values match
*/
compareValues(desired, actual, options) {
const { fuzzyMatching } = options;
if (Array.isArray(desired)) {
return desired.find(item => this.compareValues(item, actual, options));
}
if (Array.isArray(actual)) {
return actual.find(actualItem => this.compareValues(desired, actualItem, options));
}
if (actual?.Alphabetic) {
actual = actual.Alphabetic;
}
if (fuzzyMatching && typeof actual === 'string' && typeof desired === 'string') {
const normalizeValue = str => {
return str.toLowerCase();
};
const normalizedDesired = normalizeValue(desired);
const normalizedActual = normalizeValue(actual);
const tokenizeAndNormalize = str => str.split(/[\s^]+/).filter(Boolean);
const desiredTokens = tokenizeAndNormalize(normalizedDesired);
const actualTokens = tokenizeAndNormalize(normalizedActual);
return desiredTokens.every(desiredToken =>
actualTokens.some(actualToken => actualToken.startsWith(desiredToken))
);
}
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 isName = (key: string) => key.indexOf('name') !== -1;
const { supportsFuzzyMatching = false } = this.config;
const options = {
fuzzyMatching: isName(key) && supportsFuzzyMatching,
};
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, options);
}
/** 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,58 @@
// import { api } from 'dicomweb-client';
// import DICOMWeb from '../../../DICOMWeb/';
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,294 @@
import { DicomMetadataStore, IWebApiDataSource } from '@ohif/core';
import get from 'lodash.get';
import uniqBy from 'lodash.uniqby';
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,399 @@
import React, { useState, useEffect } from 'react';
import { useImageViewer } from '@ohif/ui';
import { useViewportGrid } from '@ohif/ui-next';
import { StudyBrowser } from '@ohif/ui-next';
import { useSystem, utils } from '@ohif/core';
import { useNavigate } from 'react-router-dom';
import { Separator } from '@ohif/ui-next';
import { PanelStudyBrowserHeader } from './PanelStudyBrowserHeader';
import { defaultActionIcons } from './constants';
import MoreDropdownMenu from '../../Components/MoreDropdownMenu';
const { sortStudyInstances, formatDate, createStudyBrowserTabs } = utils;
/**
*
* @param {*} param0
*/
function PanelStudyBrowser({
getImageSrc,
getStudiesForPatientByMRN,
requestDisplaySetCreationForStudy,
dataSource,
}) {
const { servicesManager, commandsManager } = useSystem();
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')
);
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;
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}
expandedStudyInstanceUIDs={expandedStudyInstanceUIDs}
onClickStudy={_handleStudyClick}
onClickTab={clickedTabName => {
setActiveTabName(clickedTabName);
}}
onClickThumbnail={() => {}}
onDoubleClickThumbnail={onDoubleClickThumbnailHandler}
activeDisplaySetInstanceUIDs={activeDisplaySetInstanceUIDs}
showSettings={actionIcons.find(icon => icon.id === 'settings').value}
viewPresets={viewPresets}
ThumbnailMenuItems={MoreDropdownMenu({
commandsManager,
servicesManager,
menuItemsKey: 'studyBrowser.thumbnailMenuItems',
})}
StudyMenuItems={MoreDropdownMenu({
commandsManager,
servicesManager,
menuItemsKey: 'studyBrowser.studyMenuItems',
})}
/>
</>
);
}
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,64 @@
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';
import { useSystem } from '@ohif/core';
/**
* Wraps the PanelStudyBrowser and provides features afforded by managers/services
*
* @param {object} params
* @param {object} commandsManager
* @param {object} extensionManager
*/
function WrappedPanelStudyBrowser() {
const { extensionManager } = useSystem();
// TODO: This should be made available a different way; route should have
// already determined our datasource
const [dataSource] = extensionManager.getActiveDataSource();
const _getStudiesForPatientByMRN = getStudiesForPatientByMRN.bind(null, dataSource);
const _getImageSrcFromImageId = useCallback(
_createGetImageSrcFromImageIdFn(extensionManager),
[]
);
const _requestDisplaySetCreationForStudy = requestDisplaySetCreationForStudy.bind(
null,
dataSource
);
return (
<PanelStudyBrowser
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');
}
}
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;
}
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,44 @@
import React from 'react';
import classNames from 'classnames';
import { ToolButton } from '@ohif/ui-next';
/**
* Wraps the ToolButtonList component to handle the OHIF toolbar button structure
* @param props - Component props
* @returns Component
*/
export function ToolBoxButtonGroupWrapper({ groupId, items, onInteraction, ...props }) {
if (!items || !groupId) {
return null;
}
return (
<div className="bg-popover flex flex-row space-x-1 rounded-md px-0 py-0">
{items.map(item => (
<ToolButton
{...item}
key={item.id}
size="small"
className={props.disabled && 'text-primary'}
onInteraction={() =>
onInteraction?.({ groupId, itemId: item.id, commands: item.commands })
}
/>
))}
</div>
);
}
export function ToolBoxButtonWrapper({ onInteraction, ...props }) {
return (
<div className="bg-popover flex flex-row rounded-md px-0 py-0">
<ToolButton
{...props}
id={props.id}
size="small"
className={classNames(props.disabled && 'text-primary')}
onInteraction={() => onInteraction?.({ itemId: props.id, commands: props.commands })}
/>
</div>
);
}

View File

@@ -0,0 +1,84 @@
import React from 'react';
import {
ToolButtonList,
ToolButton,
ToolButtonListDefault,
ToolButtonListDropDown,
ToolButtonListItem,
ToolButtonListDivider,
} from '@ohif/ui-next';
interface ButtonItem {
id: string;
icon?: string;
label?: string;
tooltip?: string;
isActive?: boolean;
disabledText?: string;
commands?: Record<string, unknown>;
disabled?: boolean;
className?: string;
}
interface ToolButtonListWrapperProps {
groupId: string;
primary: ButtonItem;
items: ButtonItem[];
onInteraction?: (details: {
groupId: string;
itemId: string;
commands?: Record<string, unknown>;
}) => void;
}
/**
* Wraps the ToolButtonList component to handle the OHIF toolbar button structure
* @param props - Component props
* @returns Component
* // test
*/
export default function ToolButtonListWrapper({
groupId,
primary,
items,
onInteraction,
}: ToolButtonListWrapperProps) {
return (
<ToolButtonList>
<ToolButtonListDefault>
<div
data-cy={`${groupId}-split-button-primary`}
data-tool={primary.id}
data-active={primary.isActive}
>
<ToolButton
{...primary}
onInteraction={({ itemId }) =>
onInteraction?.({ groupId, itemId, commands: primary.commands })
}
className={primary.className}
/>
</div>
</ToolButtonListDefault>
<ToolButtonListDivider className={primary.isActive ? 'opacity-0' : 'opacity-100'} />
<div data-cy={`${groupId}-split-button-secondary`}>
<ToolButtonListDropDown>
{items.map(item => (
<ToolButtonListItem
key={item.id}
{...item}
data-cy={item.id}
data-tool={item.id}
data-active={item.isActive}
onSelect={() =>
onInteraction?.({ groupId, itemId: item.id, commands: item.commands })
}
>
<span className="pl-1">{item.label || item.tooltip || item.id}</span>
</ToolButtonListItem>
))}
</ToolButtonListDropDown>
</div>
</ToolButtonList>
);
}

View File

@@ -0,0 +1,36 @@
import React from 'react';
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 React, { useCallback } from 'react';
import { ToolbarButton, ButtonGroup } from '@ohif/ui';
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,169 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import PropTypes from 'prop-types';
import { LayoutSelector as OHIFLayoutSelector, ToolbarButton, LayoutPreset } from '@ohif/ui';
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.getCustomization('layoutSelector.commonPresets');
const advancedPresetsGenerator = customizationService.getCustomization(
'layoutSelector.advancedPresetGenerator'
);
const advancedPresets = advancedPresetsGenerator({ 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.ArrowLeft 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,316 @@
import { useState, useCallback, useLayoutEffect, useRef } from 'react';
import { getPanelElement, getPanelGroupElement } from 'react-resizable-panels';
import { panelGroupDefinition } from './constants/panels';
/**
* Set the minimum and maximum css style width attributes for the given element.
* The two style attributes are cleared whenever the width
* argument is undefined.
* <p>
* This utility is used as part of a HACK throughout the ViewerLayout component as
* the means of restricting the side panel widths during the resizing of the
* browser window. In general, the widths are always set unless the resize
* handle for either side panel is being dragged (i.e. a side panel is being resized).
*
* @param elem the element
* @param width the max and min width to set on the element
*/
const setMinMaxWidth = (elem, width?) => {
elem.style.minWidth = width === undefined ? '' : `${width}px`;
elem.style.maxWidth = elem.style.minWidth;
};
const useResizablePanels = (
leftPanelClosed,
setLeftPanelClosed,
rightPanelClosed,
setRightPanelClosed
) => {
const [leftPanelExpandedWidth, setLeftPanelExpandedWidth] = useState(
panelGroupDefinition.left.initialExpandedWidth
);
const [rightPanelExpandedWidth, setRightPanelExpandedWidth] = useState(
panelGroupDefinition.right.initialExpandedWidth
);
const [leftResizablePanelMinimumSize, setLeftResizablePanelMinimumSize] = useState(0);
const [rightResizablePanelMinimumSize, setRightResizablePanelMinimumSize] = useState(0);
const [leftResizablePanelCollapsedSize, setLeftResizePanelCollapsedSize] = useState(0);
const [rightResizePanelCollapsedSize, setRightResizePanelCollapsedSize] = useState(0);
const resizablePanelGroupElemRef = useRef(null);
const resizableLeftPanelElemRef = useRef(null);
const resizableRightPanelElemRef = useRef(null);
const resizableLeftPanelAPIRef = useRef(null);
const resizableRightPanelAPIRef = useRef(null);
const isResizableHandleDraggingRef = useRef(false);
// The total width of both handles.
const resizableHandlesWidth = useRef(null);
// This useLayoutEffect is used to...
// - Grab a reference to the various resizable panel elements needed for
// converting between percentages and pixels in various callbacks.
// - Expand those panels that are initially expanded.
useLayoutEffect(() => {
const panelGroupElem = getPanelGroupElement(panelGroupDefinition.groupId);
resizablePanelGroupElemRef.current = panelGroupElem;
const leftPanelElem = getPanelElement(panelGroupDefinition.left.panelId);
resizableLeftPanelElemRef.current = leftPanelElem;
const rightPanelElem = getPanelElement(panelGroupDefinition.right.panelId);
resizableRightPanelElemRef.current = rightPanelElem;
// Calculate and set the width of both handles combined.
const resizeHandles = document.querySelectorAll('[data-panel-resize-handle-id]');
resizableHandlesWidth.current = 0;
resizeHandles.forEach(resizeHandle => {
resizableHandlesWidth.current += resizeHandle.offsetWidth;
});
// Since both resizable panels are collapsed by default (i.e. their default size is zero),
// on the very first render check if either/both side panels should be expanded.
// we use the initialExpandedOffsetWidth on the first render incase the panel has min width but we want the initial state to be larger than that
if (!leftPanelClosed) {
const leftResizablePanelExpandedSize = getPercentageSize(
panelGroupDefinition.left.initialExpandedOffsetWidth
);
resizableLeftPanelAPIRef?.current?.expand(leftResizablePanelExpandedSize);
setMinMaxWidth(leftPanelElem, panelGroupDefinition.left.initialExpandedOffsetWidth);
}
if (!rightPanelClosed) {
const rightResizablePanelExpandedSize = getPercentageSize(
panelGroupDefinition.right.initialExpandedOffsetWidth
);
resizableRightPanelAPIRef?.current?.expand(rightResizablePanelExpandedSize);
setMinMaxWidth(rightPanelElem, panelGroupDefinition.right.initialExpandedOffsetWidth);
}
}, []); // no dependencies because this useLayoutEffect is only needed on the very first render
// This useLayoutEffect follows the pattern prescribed by the react-resizable-panels
// readme for converting between pixel values and percentages. An example of
// the pattern can be found here:
// https://github.com/bvaughn/react-resizable-panels/issues/46#issuecomment-1368108416
// This useLayoutEffect is used to...
// - Ensure that the percentage size is up-to-date with the pixel sizes
// - Add a resize observer to the resizable panel group to reset various state
// values whenever the resizable panel group is resized (e.g. whenever the
// browser window is resized).
useLayoutEffect(() => {
// Ensure the side panels' percentage size is in synch with the pixel width of the
// expanded side panels. In general the two get out-of-sync during a browser
// window resize. Note that this code is here and NOT in the ResizeObserver
// because it has to be done AFTER the minimum percentage size for a panel is
// updated which occurs only AFTER the render following a browser window resize.
// And by virtue of the dependency on the minimum size state variables, this code
// is executed on the render following an update of the minimum percentage sizes
// for a panel.
if (!resizableLeftPanelAPIRef.current?.isCollapsed()) {
const leftSize = getPercentageSize(
leftPanelExpandedWidth + panelGroupDefinition.shared.expandedInsideBorderSize
);
resizableLeftPanelAPIRef.current?.resize(leftSize);
}
if (!resizableRightPanelAPIRef?.current?.isCollapsed()) {
const rightSize = getPercentageSize(
rightPanelExpandedWidth + panelGroupDefinition.shared.expandedInsideBorderSize
);
resizableRightPanelAPIRef?.current?.resize(rightSize);
}
// This observer kicks in when the ViewportLayout resizable panel group
// component is resized. This typically occurs when the browser window resizes.
const observer = new ResizeObserver(() => {
const minimumLeftSize = getPercentageSize(
panelGroupDefinition.left.minimumExpandedOffsetWidth
);
const minimumRightSize = getPercentageSize(
panelGroupDefinition.right.minimumExpandedOffsetWidth
);
// Set the new minimum and collapsed resizable panel sizes.
setLeftResizablePanelMinimumSize(minimumLeftSize);
setRightResizablePanelMinimumSize(minimumRightSize);
setLeftResizePanelCollapsedSize(
getPercentageSize(panelGroupDefinition.left.collapsedOffsetWidth)
);
setRightResizePanelCollapsedSize(
getPercentageSize(panelGroupDefinition.right.collapsedOffsetWidth)
);
});
observer.observe(resizablePanelGroupElemRef.current);
return () => {
observer.disconnect();
};
}, [
leftPanelExpandedWidth,
rightPanelExpandedWidth,
leftResizablePanelMinimumSize,
rightResizablePanelMinimumSize,
]);
/**
* Handles dragging of either side panel resize handle.
*/
const onHandleDragging = useCallback(
isStartDrag => {
if (isStartDrag) {
isResizableHandleDraggingRef.current = true;
setMinMaxWidth(resizableLeftPanelElemRef.current);
setMinMaxWidth(resizableRightPanelElemRef.current);
} else {
isResizableHandleDraggingRef.current = false;
if (resizableLeftPanelAPIRef?.current?.isExpanded()) {
setMinMaxWidth(
resizableLeftPanelElemRef.current,
leftPanelExpandedWidth + panelGroupDefinition.shared.expandedInsideBorderSize
);
}
if (resizableRightPanelAPIRef?.current?.isExpanded()) {
setMinMaxWidth(
resizableRightPanelElemRef.current,
rightPanelExpandedWidth + panelGroupDefinition.shared.expandedInsideBorderSize
);
}
}
},
[leftPanelExpandedWidth, rightPanelExpandedWidth]
);
const onLeftPanelClose = useCallback(() => {
setLeftPanelClosed(true);
setMinMaxWidth(resizableLeftPanelElemRef.current);
resizableLeftPanelAPIRef?.current?.collapse();
}, [setLeftPanelClosed]);
const onLeftPanelOpen = useCallback(() => {
resizableLeftPanelAPIRef?.current?.expand(
getPercentageSize(panelGroupDefinition.left.initialExpandedOffsetWidth)
);
setLeftPanelClosed(false);
}, [setLeftPanelClosed]);
const onLeftPanelResize = useCallback(size => {
if (!resizablePanelGroupElemRef?.current || resizableLeftPanelAPIRef.current?.isCollapsed()) {
return;
}
const newExpandedWidth = getExpandedPixelWidth(size);
setLeftPanelExpandedWidth(newExpandedWidth);
if (!isResizableHandleDraggingRef.current) {
// This typically gets executed when the left panel is expanded via one of the UI
// buttons. It is done here instead of in the onLeftPanelOpen method
// because here we know the size of the expanded panel.
setMinMaxWidth(resizableLeftPanelElemRef.current, newExpandedWidth);
}
}, []);
const onRightPanelClose = useCallback(() => {
setRightPanelClosed(true);
setMinMaxWidth(resizableRightPanelElemRef.current);
resizableRightPanelAPIRef?.current?.collapse();
}, [setRightPanelClosed]);
const onRightPanelOpen = useCallback(() => {
resizableRightPanelAPIRef?.current?.expand(
getPercentageSize(panelGroupDefinition.right.initialExpandedOffsetWidth)
);
setRightPanelClosed(false);
}, [setRightPanelClosed]);
const onRightPanelResize = useCallback(size => {
if (!resizablePanelGroupElemRef?.current || resizableRightPanelAPIRef?.current?.isCollapsed()) {
return;
}
const newExpandedWidth = getExpandedPixelWidth(size);
setRightPanelExpandedWidth(newExpandedWidth);
if (!isResizableHandleDraggingRef.current) {
// This typically gets executed when the right panel is expanded via one of the UI
// buttons. It is done here instead of in the onRightPanelOpen method
// because here we know the size of the expanded panel.
setMinMaxWidth(resizableRightPanelElemRef.current, newExpandedWidth);
}
}, []);
/**
* Gets the percentage size corresponding to the given pixel size.
* Note that the width attributed to the handles must be taken into account.
*/
const getPercentageSize = pixelSize => {
const { width: panelGroupWidth } = resizablePanelGroupElemRef.current?.getBoundingClientRect();
return (pixelSize / (panelGroupWidth - resizableHandlesWidth.current)) * 100;
};
/**
* Gets the width in pixels for an expanded panel given its percentage size/width.
* Note that the width attributed to the handles must be taken into account.
*/
const getExpandedPixelWidth = percentageSize => {
const { width: panelGroupWidth } = resizablePanelGroupElemRef.current?.getBoundingClientRect();
const expandedWidth =
(percentageSize / 100) * (panelGroupWidth - resizableHandlesWidth.current) -
panelGroupDefinition.shared.expandedInsideBorderSize;
return expandedWidth;
};
return [
{
expandedWidth: leftPanelExpandedWidth,
collapsedWidth: panelGroupDefinition.shared.collapsedWidth,
collapsedInsideBorderSize: panelGroupDefinition.shared.collapsedInsideBorderSize,
collapsedOutsideBorderSize: panelGroupDefinition.shared.collapsedOutsideBorderSize,
expandedInsideBorderSize: panelGroupDefinition.shared.expandedInsideBorderSize,
onClose: onLeftPanelClose,
onOpen: onLeftPanelOpen,
},
{
expandedWidth: rightPanelExpandedWidth,
collapsedWidth: panelGroupDefinition.shared.collapsedWidth,
collapsedInsideBorderSize: panelGroupDefinition.shared.collapsedInsideBorderSize,
collapsedOutsideBorderSize: panelGroupDefinition.shared.collapsedOutsideBorderSize,
expandedInsideBorderSize: panelGroupDefinition.shared.expandedInsideBorderSize,
onClose: onRightPanelClose,
onOpen: onRightPanelOpen,
},
{ direction: 'horizontal', id: panelGroupDefinition.groupId },
{
defaultSize: leftResizablePanelMinimumSize,
minSize: leftResizablePanelMinimumSize,
onResize: onLeftPanelResize,
collapsible: true,
collapsedSize: leftResizablePanelCollapsedSize,
onCollapse: () => setLeftPanelClosed(true),
onExpand: () => setLeftPanelClosed(false),
ref: resizableLeftPanelAPIRef,
order: 0,
id: panelGroupDefinition.left.panelId,
},
{ order: 1, id: 'viewerLayoutResizableViewportGridPanel' },
{
defaultSize: rightResizablePanelMinimumSize,
minSize: rightResizablePanelMinimumSize,
onResize: onRightPanelResize,
collapsible: true,
collapsedSize: rightResizePanelCollapsedSize,
onCollapse: () => setRightPanelClosed(true),
onExpand: () => setRightPanelClosed(false),
ref: resizableRightPanelAPIRef,
order: 2,
id: panelGroupDefinition.right.panelId,
},
onHandleDragging,
];
};
export default useResizablePanels;

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,133 @@
import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
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';
import { preserveQueryParameters, publicUrl } from '@ohif/app';
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 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));
}
preserveQueryParameters(searchQuery);
navigate({
pathname: publicUrl,
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,38 @@
const expandedInsideBorderSize = 0;
const collapsedInsideBorderSize = 4;
const collapsedOutsideBorderSize = 4;
const collapsedWidth = 25;
const rightPanelInitialExpandedWidth = 280;
const leftPanelInitialExpandedWidth = 282;
const panelGroupDefinition = {
groupId: 'viewerLayoutResizablePanelGroup',
shared: {
expandedInsideBorderSize,
collapsedInsideBorderSize,
collapsedOutsideBorderSize,
collapsedWidth,
},
left: {
// id
panelId: 'viewerLayoutResizableLeftPanel',
// expanded width
initialExpandedWidth: leftPanelInitialExpandedWidth,
// expanded width + expanded inside border
minimumExpandedOffsetWidth: 145 + expandedInsideBorderSize,
// initial expanded width
initialExpandedOffsetWidth: leftPanelInitialExpandedWidth + expandedInsideBorderSize,
// collapsed width + collapsed inside border + collapsed outside border
collapsedOffsetWidth: collapsedWidth + collapsedInsideBorderSize + collapsedOutsideBorderSize,
},
right: {
panelId: 'viewerLayoutResizableRightPanel',
initialExpandedWidth: rightPanelInitialExpandedWidth,
minimumExpandedOffsetWidth: rightPanelInitialExpandedWidth + expandedInsideBorderSize,
initialExpandedOffsetWidth: rightPanelInitialExpandedWidth + expandedInsideBorderSize,
collapsedOffsetWidth: collapsedWidth + collapsedInsideBorderSize + collapsedOutsideBorderSize,
},
};
export { panelGroupDefinition };

View File

@@ -0,0 +1,225 @@
import React, { useEffect, useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import { 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, ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@ohif/ui-next';
import useResizablePanels from './ResizablePanelsHook';
const resizableHandleClassName = 'mt-[1px] bg-black';
function ViewerLayout({
// From Extension Module Params
extensionManager,
servicesManager,
hotkeysManager,
commandsManager,
// From Modes
viewports,
ViewportGridComp,
leftPanelClosed = false,
rightPanelClosed = false,
leftPanelResizable = false,
rightPanelResizable = false,
}: withAppTypes): React.FunctionComponent {
const [appConfig] = useAppConfig();
const { panelService, hangingProtocolService, customizationService } = 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);
const [
leftPanelProps,
rightPanelProps,
resizablePanelGroupProps,
resizableLeftPanelProps,
resizableViewportGridPanelProps,
resizableRightPanelProps,
onHandleDragging,
] = useResizablePanels(
leftPanelClosed,
setLeftPanelClosed,
rightPanelClosed,
setRightPanelClosed
);
const LoadingIndicatorProgress = customizationService.getCustomization(
'ui.loadingIndicatorProgress'
);
/**
* 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" />}
<ResizablePanelGroup {...resizablePanelGroupProps}>
{/* LEFT SIDEPANELS */}
{hasLeftPanels ? (
<>
<ResizablePanel {...resizableLeftPanelProps}>
<SidePanelWithServices
side="left"
isExpanded={!leftPanelClosedState}
servicesManager={servicesManager}
{...leftPanelProps}
/>
</ResizablePanel>
<ResizableHandle
onDragging={onHandleDragging}
disabled={!leftPanelResizable}
className={resizableHandleClassName}
/>
</>
) : null}
{/* TOOLBAR + GRID */}
<ResizablePanel {...resizableViewportGridPanelProps}>
<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>
</ResizablePanel>
{hasRightPanels ? (
<>
<ResizableHandle
onDragging={onHandleDragging}
disabled={!rightPanelResizable}
className={resizableHandleClassName}
/>
<ResizablePanel {...resizableRightPanelProps}>
<SidePanelWithServices
side="right"
isExpanded={!rightPanelClosedState}
servicesManager={servicesManager}
{...rightPanelProps}
/>
</ResizablePanel>
</>
) : null}
</ResizablePanelGroup>
</React.Fragment>
</div>
<Onboarding tours={customizationService.getCustomization('ohif.tours')} />
<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,654 @@
import { Types, DicomMetadataStore } from '@ohif/core';
import { ContextMenuController } 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';
import requestDisplaySetCreationForStudy from './Panels/requestDisplaySetCreationForStudy';
export type HangingProtocolParams = {
protocolId?: string;
stageIndex?: number;
activeStudyUID?: string;
stageId?: string;
reset?: false;
};
export type UpdateViewportDisplaySetParams = {
direction: number;
excludeNonImageModalities?: boolean;
};
const commandsModule = ({
servicesManager,
commandsManager,
extensionManager,
}: Types.Extensions.ExtensionParams): Types.Extensions.CommandsModule => {
const {
customizationService,
measurementService,
hangingProtocolService,
uiNotificationService,
viewportGridService,
displaySetService,
multiMonitorService,
} = servicesManager.services;
// Define a context menu controller for use with any context menus
const contextMenuController = new ContextMenuController(servicesManager, commandsManager);
const actions = {
/**
* Runs a command in multi-monitor mode. No-op if not multi-monitor.
*/
multimonitor: async options => {
const { screenDelta, StudyInstanceUID, commands, hashParams } = options;
if (multiMonitorService.numberOfScreens < 2) {
return options.fallback?.(options);
}
const newWindow = await multiMonitorService.launchWindow(
StudyInstanceUID,
screenDelta,
hashParams
);
// Only run commands if we successfully got a window with a commands manager
if (newWindow && commands) {
// Todo: fix this properly, but it takes time for the new window to load
// and then the commandsManager is available for it
setTimeout(() => {
multiMonitorService.run(screenDelta, commands, options);
}, 1000);
}
},
/**
* Ensures that the specified study is available for display
* Then, if commands is specified, runs the given commands list/instance
*/
loadStudy: async options => {
const { StudyInstanceUID } = options;
const displaySets = displaySetService.getActiveDisplaySets();
const isActive = displaySets.find(ds => ds.StudyInstanceUID === StudyInstanceUID);
if (isActive) {
return;
}
const [dataSource] = extensionManager.getActiveDataSource();
await requestDisplaySetCreationForStudy(dataSource, displaySetService, StudyInstanceUID);
const study = DicomMetadataStore.getStudy(StudyInstanceUID);
hangingProtocolService.addStudy(study);
},
/**
* 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.getCustomization(menuCustomizationId));
}
// 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.clearMeasurements();
},
/**
* 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
*
* commandsManager.run('setHangingProtocol', {
* activeStudyUID: '1.2.3',
* protocolId: 'myProtocol',
* stageId: 'myStage',
* stageIndex: 0,
* reset: false,
* });
*/
setHangingProtocol: ({
activeStudyUID = '',
StudyInstanceUID = '',
protocolId,
stageId,
stageIndex,
reset = false,
}: HangingProtocolParams): boolean => {
const toUseStudyInstanceUID = activeStudyUID || StudyInstanceUID;
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 = `${toUseStudyInstanceUID || hpInfo.activeStudyUID}:${protocolId}`;
stageIndex = hangingProtocolStageIndexMap[hangingId]?.stageIndex;
}
const useStageIdx =
stageIndex ??
hangingProtocolService.getStageIndex(protocolId, {
stageId,
stageIndex,
});
const activeStudyChanged = hangingProtocolService.setActiveStudyUID(toUseStudyInstanceUID);
const storedHanging = `${toUseStudyInstanceUID || hangingProtocolService.getState().activeStudyUID}:${protocolId}:${
useStageIdx || 0
}`;
const { viewportGridState } = useViewportGridStore.getState();
const restoreProtocol = !reset && viewportGridState[storedHanging];
if (
reset ||
(activeStudyChanged &&
!viewportGridState[storedHanging] &&
stageIndex === undefined &&
stageId === undefined)
) {
// Run the hanging protocol fresh, re-using the existing study data
// This is done on reset or when the study changes and we haven't yet
// applied it, and don't specify exact stage to use.
const displaySets = displaySetService.getActiveDisplaySets();
const activeStudy = {
StudyInstanceUID: toUseStudyInstanceUID,
displaySets,
};
hangingProtocolService.run(activeStudy, protocolId);
} else if (
protocolId === hpInfo.protocolId &&
useStageIdx === hpInfo.stageIndex &&
!toUseStudyInstanceUID
) {
// 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(
`${toUseStudyInstanceUID || 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 = {
multimonitor: actions.multimonitor,
loadStudy: actions.loadStudy,
showContextMenu: actions.showContextMenu,
closeContextMenu: actions.closeContextMenu,
clearMeasurements: actions.clearMeasurements,
displayNotification: actions.displayNotification,
setHangingProtocol: actions.setHangingProtocol,
toggleHangingProtocol: actions.toggleHangingProtocol,
navigateHistory: actions.navigateHistory,
nextStage: {
commandFn: actions.deltaStage,
options: { direction: 1 },
},
previousStage: {
commandFn: actions.deltaStage,
options: { direction: -1 },
},
setViewportGridLayout: actions.setViewportGridLayout,
toggleOneUp: actions.toggleOneUp,
openDICOMTagViewer: actions.openDICOMTagViewer,
updateViewportDisplaySet: actions.updateViewportDisplaySet,
};
return {
actions,
definitions,
defaultContext: 'DEFAULT',
};
};
export default commandsModule;

View File

@@ -0,0 +1,25 @@
import { CustomizationService } from '@ohif/core';
export default {
'ohif.contextMenu': {
$transform: function (customizationService: CustomizationService) {
/**
* Applies the inheritsFrom to all the menu items.
* This function clones the object and child objects to prevent
* changes to the original customization object.
*/
// 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;
},
},
};

View File

@@ -0,0 +1,5 @@
import { ContextMenu } from '@ohif/ui';
export default {
'ui.contextMenu': ContextMenu,
};

View File

@@ -0,0 +1,6 @@
export default {
'routes.customRoutes': {
routes: [],
notFoundRoute: null,
},
};

View File

@@ -0,0 +1,19 @@
import DataSourceConfigurationComponent from '../Components/DataSourceConfigurationComponent';
import { GoogleCloudDataSourceConfigurationAPI } from '../DataSourceConfigurationAPI/GoogleCloudDataSourceConfigurationAPI';
export default function getDataSourceConfigurationCustomization({
servicesManager,
extensionManager,
}) {
return {
// the generic GUI component to configure a data source using an instance of a BaseDataSourceConfigurationAPI
'ohif.dataSourceConfigurationComponent': DataSourceConfigurationComponent.bind(null, {
servicesManager,
extensionManager,
}),
// The factory for creating an instance of a BaseDataSourceConfigurationAPI for Google Cloud Healthcare
'ohif.dataSourceConfigurationAPI.google': (dataSourceName: string) =>
new GoogleCloudDataSourceConfigurationAPI(dataSourceName, servicesManager, extensionManager),
};
}

View File

@@ -0,0 +1,14 @@
import DataSourceSelector from '../Panels/DataSourceSelector';
export default {
'routes.customRoutes': {
routes: {
$push: [
{
path: '/datasources',
children: DataSourceSelector,
},
],
},
},
};

View File

@@ -0,0 +1,22 @@
export default {
measurementsContextMenu: {
inheritsFrom: '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: 'deleteMeasurement',
},
{
label: 'Add Label',
commands: 'setMeasurementLabel',
},
],
},
],
},
};

View File

@@ -0,0 +1,14 @@
import React from 'react';
export default {
'routes.customRoutes': {
routes: {
$push: [
{
path: '/custom',
children: () => <h1 style={{ color: 'white' }}>Hello Custom Route</h1>,
},
],
},
},
};

View File

@@ -0,0 +1,5 @@
import { defaults } from '@ohif/core';
export default {
'ohif.hotkeyBindings': defaults.hotkeyBindings,
};

View File

@@ -0,0 +1,5 @@
import { LabellingFlow } from '@ohif/ui';
export default {
'ui.labellingComponent': LabellingFlow,
};

View File

@@ -0,0 +1,5 @@
import { LoadingIndicatorProgress } from '@ohif/ui';
export default {
'ui.loadingIndicatorProgress': LoadingIndicatorProgress,
};

View File

@@ -0,0 +1,5 @@
import { LoadingIndicatorTotalPercent } from '@ohif/ui';
export default {
'ui.loadingIndicatorTotalPercent': LoadingIndicatorTotalPercent,
};

View File

@@ -0,0 +1,67 @@
import React from 'react';
import {
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuPortal,
DropdownMenuSubContent,
DropdownMenuItem,
Icons,
} from '@ohif/ui-next';
export default {
'ohif.menuContent': function (props) {
const { item: topLevelItem, commandsManager, servicesManager, ...rest } = props;
const content = function (subProps) {
const { item: subItem } = subProps;
// Regular menu item
const isDisabled = subItem.selector && !subItem.selector({ servicesManager });
return (
<DropdownMenuItem
disabled={isDisabled}
onSelect={() => {
commandsManager.runAsync(subItem.commands, {
...subItem.commandOptions,
...rest,
});
}}
className="gap-[6px]"
>
{subItem.iconName && (
<Icons.ByName
name={subItem.iconName}
className="-ml-1"
/>
)}
{subItem.label}
</DropdownMenuItem>
);
};
// If item has sub-items, render a submenu
if (topLevelItem.items) {
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="gap-[6px]">
{topLevelItem.iconName && (
<Icons.ByName
name={topLevelItem.iconName}
className="-ml-1"
/>
)}
{topLevelItem.label}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{topLevelItem.items.map(subItem => content({ ...props, item: subItem }))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
);
}
return content({ ...props, item: topLevelItem });
},
};

View File

@@ -0,0 +1,63 @@
export default {
'studyBrowser.studyMenuItems': {
$push: [
{
id: 'applyHangingProtocol',
label: 'Apply Hanging Protocol',
iconName: 'ViewportViews',
items: [
{
id: 'applyDefaultProtocol',
label: 'Default',
commands: [
'loadStudy',
{
commandName: 'setHangingProtocol',
commandOptions: {
protocolId: 'default',
},
},
],
},
{
id: 'applyMPRProtocol',
label: '2x2 Grid',
commands: [
'loadStudy',
{
commandName: 'setHangingProtocol',
commandOptions: {
protocolId: '@ohif/mnGrid',
},
},
],
},
],
},
{
id: 'showInOtherMonitor',
label: 'Launch On Second Monitor',
iconName: 'DicomTagBrowser',
selector: ({ servicesManager }) => {
const { multiMonitorService } = servicesManager.services;
return multiMonitorService.isMultimonitor;
},
commands: {
commandName: 'multimonitor',
commandOptions: {
hashParams: '&hangingProtocolId=@ohif/mnGrid8',
commands: [
'loadStudy',
{
commandName: 'setHangingProtocol',
commandOptions: {
protocolId: '@ohif/mnGrid8',
},
},
],
},
},
},
],
},
};

View File

@@ -0,0 +1,5 @@
import { Notification } from '@ohif/ui';
export default {
'ui.notificationComponent': Notification,
};

View File

@@ -0,0 +1,5 @@
export default {
customOnDropHandler: () => {
return Promise.resolve({ handled: false });
},
};

View File

@@ -0,0 +1,210 @@
function waitForElement(selector, maxAttempts = 20, interval = 25) {
return new Promise(resolve => {
let attempts = 0;
const checkForElement = setInterval(() => {
const element = document.querySelector(selector);
if (element || attempts >= maxAttempts) {
clearInterval(checkForElement);
resolve();
}
attempts++;
}, interval);
});
}
export default {
'ohif.tours': [
{
id: 'basicViewerTour',
route: '/viewer',
steps: [
{
id: 'scroll',
title: 'Scrolling Through Images',
text: 'You can scroll through the images using the mouse wheel or scrollbar.',
attachTo: {
element: '.viewport-element',
on: 'top',
},
advanceOn: {
selector: '.cornerstone-viewport-element',
event: 'CORNERSTONE_TOOLS_MOUSE_WHEEL',
},
beforeShowPromise: () => waitForElement('.viewport-element'),
},
{
id: 'zoom',
title: 'Zooming In and Out',
text: 'You can zoom the images using the right click.',
attachTo: {
element: '.viewport-element',
on: 'left',
},
advanceOn: {
selector: '.cornerstone-viewport-element',
event: 'CORNERSTONE_TOOLS_MOUSE_UP',
},
beforeShowPromise: () => waitForElement('.viewport-element'),
},
{
id: 'pan',
title: 'Panning the Image',
text: 'You can pan the images using the middle click.',
attachTo: {
element: '.viewport-element',
on: 'top',
},
advanceOn: {
selector: '.cornerstone-viewport-element',
event: 'CORNERSTONE_TOOLS_MOUSE_UP',
},
beforeShowPromise: () => waitForElement('.viewport-element'),
},
{
id: 'windowing',
title: 'Adjusting Window Level',
text: 'You can modify the window level using the left click.',
attachTo: {
element: '.viewport-element',
on: 'left',
},
advanceOn: {
selector: '.cornerstone-viewport-element',
event: 'CORNERSTONE_TOOLS_MOUSE_UP',
},
beforeShowPromise: () => waitForElement('.viewport-element'),
},
{
id: 'length',
title: 'Using the Measurement Tools',
text: 'You can measure the length of a region using the Length tool.',
attachTo: {
element: '[data-cy="MeasurementTools-split-button-primary"]',
on: 'bottom',
},
advanceOn: {
selector: '[data-cy="MeasurementTools-split-button-primary"]',
event: 'click',
},
beforeShowPromise: () =>
waitForElement('[data-cy="MeasurementTools-split-button-primary]'),
},
{
id: 'drawAnnotation',
title: 'Drawing Length Annotations',
text: 'Use the length tool on the viewport to measure the length of a region.',
attachTo: {
element: '.viewport-element',
on: 'right',
},
advanceOn: {
selector: 'body',
event: 'event::measurement_added',
},
beforeShowPromise: () => waitForElement('.viewport-element'),
},
{
id: 'trackMeasurement',
title: 'Tracking Measurements in the Panel',
text: 'Click yes to track the measurements in the measurement panel.',
attachTo: {
element: '[data-cy="prompt-begin-tracking-yes-btn"]',
on: 'bottom',
},
advanceOn: {
selector: '[data-cy="prompt-begin-tracking-yes-btn"]',
event: 'click',
},
beforeShowPromise: () => waitForElement('[data-cy="prompt-begin-tracking-yes-btn"]'),
},
{
id: 'openMeasurementPanel',
title: 'Opening the Measurements Panel',
text: 'Click the measurements button to open the measurements panel.',
attachTo: {
element: '#trackedMeasurements-btn',
on: 'left-start',
},
advanceOn: {
selector: '#trackedMeasurements-btn',
event: 'click',
},
beforeShowPromise: () => waitForElement('#trackedMeasurements-btn'),
},
{
id: 'scrollAwayFromMeasurement',
title: 'Scrolling Away from a Measurement',
text: 'Scroll the images using the mouse wheel away from the measurement.',
attachTo: {
element: '.viewport-element',
on: 'left',
},
advanceOn: {
selector: '.cornerstone-viewport-element',
event: 'CORNERSTONE_TOOLS_MOUSE_WHEEL',
},
beforeShowPromise: () => waitForElement('.viewport-element'),
},
{
id: 'jumpToMeasurement',
title: 'Jumping to Measurements in the Panel',
text: 'Click the measurement in the measurement panel to jump to it.',
attachTo: {
element: '[data-cy="data-row"]',
on: 'left-start',
},
advanceOn: {
selector: '[data-cy="data-row"]',
event: 'click',
},
beforeShowPromise: () => waitForElement('[data-cy="data-row"]'),
},
{
id: 'changeLayout',
title: 'Changing Layout',
text: 'You can change the layout of the viewer using the layout button.',
attachTo: {
element: '[data-cy="Layout"]',
on: 'bottom',
},
advanceOn: {
selector: '[data-cy="Layout"]',
event: 'click',
},
beforeShowPromise: () => waitForElement('[data-cy="Layout"]'),
},
{
id: 'selectLayout',
title: 'Selecting the MPR Layout',
text: 'Select the MPR layout to view the images in MPR mode.',
attachTo: {
element: '[data-cy="MPR"]',
on: 'left-start',
},
advanceOn: {
selector: '[data-cy="MPR"]',
event: 'click',
},
beforeShowPromise: () => waitForElement('[data-cy="MPR"]'),
},
],
tourOptions: {
useModalOverlay: true,
defaultStepOptions: {
buttons: [
{
text: 'Skip all',
action() {
this.complete();
},
secondary: true,
},
],
},
},
},
],
};

View File

@@ -0,0 +1,31 @@
import React from 'react';
export default {
'ohif.overlayItem': function (props) {
if (this.condition && !this.condition(props)) {
return null;
}
const { instance } = props;
const value =
instance && this.attribute
? instance[this.attribute]
: this.contentF && typeof this.contentF === 'function'
? this.contentF(props)
: null;
if (!value) {
return null;
}
return (
<span
className="overlay-item flex flex-row"
style={{ color: this.color || undefined }}
title={this.title || ''}
>
{this.label && <span className="mr-1 shrink-0">{this.label}</span>}
<span className="font-light">{value}</span>
</span>
);
},
};

View File

@@ -0,0 +1,5 @@
import { ProgressDropdownWithService } from '../Components/ProgressDropdownWithService';
export default {
progressDropdownWithServiceComponent: ProgressDropdownWithService,
};

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