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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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