OHIF with Cornerstone3D 2.0 (#4347)

This commit is contained in:
Alireza authored and GitHub committed 2024-11-06 07:15:27 -05:00
1 parent 8981a61b8c
commit 7ce7127d7c
803 files changed
+32760 -15893

No files matched your search

@@ -5,7 +5,7 @@ import { Types } from '@ohif/core';
export type SidePanelWithServicesProps = {
servicesManager: AppTypes.ServicesManager;
side: 'left' | 'right';
className: string;
className?: string;
activeTabIndex: number;
tabs: any;
expandedWidth?: number;
@@ -209,6 +209,10 @@ function createDicomLocalApi(dicomLocalConfig) {
return imageIds;
},
getImageIdsForInstance({ instance, frame }) {
if (instance.imageId) {
return instance.imageId;
}
const { StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID } = instance;
const storedInstance = DicomMetadataStore.getInstance(
StudyInstanceUID,
@@ -2,8 +2,9 @@ import dcmjs from 'dcmjs';
import moment from 'moment';
import React, { useState, useMemo, useEffect } from 'react';
import { classes } from '@ohif/core';
import { InputRange, Select, Typography, InputFilterText } from '@ohif/ui';
import { InputFilterText } from '@ohif/ui';
import debounce from 'lodash.debounce';
import { Select, SelectTrigger, SelectContent, SelectItem, Slider } from '@ohif/ui-next';
import DicomTagTable from './DicomTagTable';
import './DicomTagBrowser.css';
@@ -57,7 +58,7 @@ const DicomTagBrowser = ({ displaySets, displaySetInstanceUID }) => {
return {
value: displaySetInstanceUID,
label: `${SeriesNumber} (${Modality}): ${SeriesDescription}`,
label: `${SeriesNumber} (${Modality}): ${SeriesDescription}`,
description: displayDate,
};
});
@@ -107,62 +108,62 @@ const DicomTagBrowser = ({ displaySets, displaySetInstanceUID }) => {
}, []);
return (
<div className="dicom-tag-browser-content">
<div className="mb-6 flex flex-row items-center pl-1">
<div className="flex w-1/2 flex-row items-center">
<Typography
variant="subtitle"
className="mr-4"
>
Series
</Typography>
<div className="mr-8 grow">
<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
id="display-set-selector"
isClearable={false}
onChange={onSelectChange}
options={displaySetList}
value={displaySetList.find(ds => ds.value === selectedDisplaySetInstanceUID)}
className="text-white"
/>
</div>
</div>
<div className="flex w-1/2 flex-row items-center">
{showInstanceList && (
<Typography
variant="subtitle"
className="mr-4"
value={selectedDisplaySetInstanceUID}
onValueChange={value => onSelectChange({ value })}
>
Instance Number
</Typography>
)}
<SelectTrigger>
{displaySetList.find(ds => ds.value === selectedDisplaySetInstanceUID)?.label ||
'Select Series'}
</SelectTrigger>
<SelectContent>
{displaySetList.map(item => {
return (
<SelectItem
key={item.value}
value={item.value}
>
{item.label}
<span className="text-muted-foreground ml-1 text-xs">{item.description}</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
{showInstanceList && (
<div className="grow">
<InputRange
value={instanceNumber}
key={selectedDisplaySetInstanceUID}
onChange={value => {
setInstanceNumber(parseInt(value));
<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);
}}
minValue={1}
maxValue={activeDisplaySet.images.length}
min={1}
max={activeDisplaySet.images.length}
step={1}
inputClassName="w-full"
labelPosition="left"
trackColor={'#3a3f99'}
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>
<div className="h-1 w-full bg-black"></div>
<div className="my-3 flex w-1/2 flex-row">
<InputFilterText
className="mr-8 block w-full"
placeholder="Search metadata..."
onDebounceChange={setFilterValue}
></InputFilterText>
</div>
<DicomTagTable rows={filteredRows} />
</div>
);
@@ -154,9 +154,11 @@ function mapParams(params, options = {}) {
// Add more fields here if you want them in the result
].join(',');
const { supportsWildcard } = options;
const useWildcard =
params?.disableWildcard !== undefined ? !params.disableWildcard : options.supportsWildcard;
const withWildcard = value => {
return supportsWildcard && value ? `*${value}*` : value;
return useWildcard && value ? `*${value}*` : value;
};
const parameters = {
@@ -29,6 +29,10 @@ export default function getImageId({ instance, frame, config, thumbnail = false
return;
}
if (instance.imageId && frame === undefined) {
return instance.imageId;
}
if (instance.url) {
return instance.url;
}
@@ -1,305 +0,0 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { utils } from '@ohif/core';
import {
MeasurementTable,
Dialog,
Input,
useViewportGrid,
ButtonEnums,
ActionButtons,
} from '@ohif/ui';
import debounce from 'lodash.debounce';
import createReportDialogPrompt, {
CREATE_REPORT_DIALOG_RESPONSE,
} from './createReportDialogPrompt';
import createReportAsync from '../Actions/createReportAsync';
import findSRWithSameSeriesDescription from '../utils/findSRWithSameSeriesDescription';
import { Separator } from '@ohif/ui-next';
const { downloadCSVReport } = utils;
export default function PanelMeasurementTable({
servicesManager,
commandsManager,
extensionManager,
}: withAppTypes): React.FunctionComponent {
const { t } = useTranslation('MeasurementTable');
const [viewportGrid, viewportGridService] = useViewportGrid();
const { activeViewportId, viewports } = viewportGrid;
const { measurementService, uiDialogService, uiNotificationService, displaySetService } =
servicesManager.services;
const [displayMeasurements, setDisplayMeasurements] = useState([]);
useEffect(() => {
const debouncedSetDisplayMeasurements = debounce(setDisplayMeasurements, 100);
// ~~ Initial
setDisplayMeasurements(_getMappedMeasurements(measurementService));
// ~~ Subscription
const added = measurementService.EVENTS.MEASUREMENT_ADDED;
const addedRaw = measurementService.EVENTS.RAW_MEASUREMENT_ADDED;
const updated = measurementService.EVENTS.MEASUREMENT_UPDATED;
const removed = measurementService.EVENTS.MEASUREMENT_REMOVED;
const cleared = measurementService.EVENTS.MEASUREMENTS_CLEARED;
const subscriptions = [];
[added, addedRaw, updated, removed, cleared].forEach(evt => {
subscriptions.push(
measurementService.subscribe(evt, () => {
debouncedSetDisplayMeasurements(_getMappedMeasurements(measurementService));
}).unsubscribe
);
});
return () => {
subscriptions.forEach(unsub => {
unsub();
});
debouncedSetDisplayMeasurements.cancel();
};
}, []);
async function exportReport() {
const measurements = measurementService.getMeasurements();
downloadCSVReport(measurements, measurementService);
}
async function clearMeasurements() {
measurementService.clearMeasurements();
}
async function createReport(): Promise<any> {
// filter measurements that are added to the active study
const activeViewport = viewports.get(activeViewportId);
const measurements = measurementService.getMeasurements();
const displaySet = displaySetService.getDisplaySetByUID(
activeViewport.displaySetInstanceUIDs[0]
);
const trackedMeasurements = measurements.filter(
m => displaySet.StudyInstanceUID === m.referenceStudyUID
);
if (trackedMeasurements.length <= 0) {
uiNotificationService.show({
title: 'No Measurements',
message: 'No Measurements are added to the current Study.',
type: 'info',
duration: 3000,
});
return;
}
const promptResult = await createReportDialogPrompt(uiDialogService, {
extensionManager,
});
if (promptResult.action === CREATE_REPORT_DIALOG_RESPONSE.CREATE_REPORT) {
const dataSources = extensionManager.getDataSources(promptResult.dataSourceName);
const dataSource = dataSources[0];
const SeriesDescription =
// isUndefinedOrEmpty
promptResult.value === undefined || promptResult.value === ''
? 'Research Derived Series' // default
: promptResult.value; // provided value
// Reuse an existing series having the same series description to avoid
// creating too many series instances.
const options = findSRWithSameSeriesDescription(SeriesDescription, displaySetService);
const getReport = async () => {
return commandsManager.runCommand(
'storeMeasurements',
{
measurementData: trackedMeasurements,
dataSource,
additionalFindingTypes: ['ArrowAnnotate'],
options,
},
'CORNERSTONE_STRUCTURED_REPORT'
);
};
return createReportAsync({ servicesManager, getReport });
}
}
const jumpToImage = ({ uid, isActive }) => {
measurementService.jumpToMeasurement(viewportGrid.activeViewportId, uid);
onMeasurementItemClickHandler({ uid, isActive });
};
const onMeasurementItemEditHandler = ({ uid, isActive }) => {
const measurement = measurementService.getMeasurement(uid);
//Todo: why we are jumping to image?
// jumpToImage({ id, isActive });
const onSubmitHandler = ({ action, value }) => {
switch (action.id) {
case 'save': {
measurementService.update(
uid,
{
...measurement,
...value,
},
true
);
}
}
uiDialogService.dismiss({ id: 'enter-annotation' });
};
uiDialogService.create({
id: 'enter-annotation',
centralize: true,
isDraggable: false,
showOverlay: true,
content: Dialog,
contentProps: {
title: 'Annotation',
noCloseButton: true,
value: { label: measurement.label || '' },
body: ({ value, setValue }) => {
const onChangeHandler = event => {
event.persist();
setValue(value => ({ ...value, label: event.target.value }));
};
const onKeyPressHandler = event => {
if (event.key === 'Enter') {
onSubmitHandler({ value, action: { id: 'save' } });
}
};
return (
<Input
label="Enter your annotation"
labelClassName="text-white text-[14px] leading-[1.2]"
autoFocus
id="annotation"
className="border-primary-main bg-black"
type="text"
value={value.label}
onChange={onChangeHandler}
onKeyPress={onKeyPressHandler}
/>
);
},
actions: [
{ id: 'cancel', text: 'Cancel', type: ButtonEnums.type.secondary },
{ id: 'save', text: 'Save', type: ButtonEnums.type.primary },
],
onSubmit: onSubmitHandler,
},
});
};
const onMeasurementItemClickHandler = ({ uid, isActive }) => {
if (!isActive) {
const measurements = [...displayMeasurements];
const measurement = measurements.find(m => m.uid === uid);
measurements.forEach(m => (m.isActive = m.uid !== uid ? false : true));
measurement.isActive = true;
setDisplayMeasurements(measurements);
}
};
return (
<>
<div
className="ohif-scrollbar overflow-y-auto overflow-x-hidden"
data-cy={'measurements-panel'}
>
<MeasurementTable
title={t('Measurements')}
servicesManager={servicesManager}
data={displayMeasurements}
onClick={jumpToImage}
onEdit={onMeasurementItemEditHandler}
/>
</div>
<div className="flex justify-center p-4">
<ActionButtons
t={t}
actions={[
{
label: 'Export',
onClick: exportReport,
},
{
label: 'Create Report',
onClick: createReport,
},
]}
/>
</div>
</>
);
}
PanelMeasurementTable.propTypes = {
servicesManager: PropTypes.object.isRequired,
};
function _getMappedMeasurements(measurementService) {
const measurements = measurementService.getMeasurements();
const mappedMeasurements = measurements.map((m, index) =>
_mapMeasurementToDisplay(m, index, measurementService.VALUE_TYPES)
);
return mappedMeasurements;
}
/**
* Map the measurements to the display text.
* Adds finding and site information to the displayText and/or label,
* and provides as 'displayText' and 'label', while providing the original
* values as baseDisplayText and baseLabel
*/
function _mapMeasurementToDisplay(measurement, index, types) {
const {
displayText: baseDisplayText,
uid,
label: baseLabel,
type,
selected,
findingSites,
finding,
} = measurement;
const firstSite = findingSites?.[0];
const label = baseLabel || finding?.text || firstSite?.text || '(empty)';
let displayText = baseDisplayText || [];
if (findingSites) {
const siteText = [];
findingSites.forEach(site => {
if (site?.text !== label) {
siteText.push(site.text);
}
});
displayText = [...siteText, ...displayText];
}
if (finding && finding?.text !== label) {
displayText = [finding.text, ...displayText];
}
return {
uid,
label,
baseLabel,
measurementType: type,
displayText,
baseDisplayText,
isActive: selected,
finding,
findingSites,
};
}
@@ -1,5 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import React, { useState, useEffect } from 'react';
import { useImageViewer, useViewportGrid } from '@ohif/ui';
import { StudyBrowser } from '@ohif/ui-next';
import { utils } from '@ohif/core';
@@ -80,7 +79,7 @@ function PanelStudyBrowser({
uiNotificationService.show({
title: 'Thumbnail Double Click',
message: 'The selected display sets could not be added to the viewport.',
type: 'info',
type: 'error',
duration: 3000,
});
}
@@ -198,7 +197,7 @@ function PanelStudyBrowser({
// if (!hasLoadedViewports) {
// return;
// }
const { displaySetsAdded, options } = data;
const { displaySetsAdded } = data;
displaySetsAdded.forEach(async dSet => {
const newImageSrcEntry = {};
const displaySet = displaySetService.getDisplaySetByUID(dSet.displaySetInstanceUID);
@@ -319,16 +318,6 @@ function PanelStudyBrowser({
);
}
PanelStudyBrowser.propTypes = {
servicesManager: PropTypes.object.isRequired,
dataSource: PropTypes.shape({
getImageIdsForDisplaySet: PropTypes.func.isRequired,
}).isRequired,
getImageSrc: PropTypes.func.isRequired,
getStudiesForPatientByMRN: PropTypes.func.isRequired,
requestDisplaySetCreationForStudy: PropTypes.func.isRequired,
};
export default PanelStudyBrowser;
/**
@@ -1,11 +1,7 @@
import React from 'react';
import { ButtonEnums, Dialog, Input, Select } from '@ohif/ui';
export const CREATE_REPORT_DIALOG_RESPONSE = {
CANCEL: 0,
CREATE_REPORT: 1,
};
import PROMPT_RESPONSES from '../utils/_shared/PROMPT_RESPONSES';
export default function CreateReportDialogPrompt(uiDialogService, { extensionManager }) {
return new Promise(function (resolve, reject) {
@@ -16,7 +12,7 @@ export default function CreateReportDialogPrompt(uiDialogService, { extensionMan
uiDialogService.dismiss({ id: dialogId });
// Notify of cancel action
resolve({
action: CREATE_REPORT_DIALOG_RESPONSE.CANCEL,
action: PROMPT_RESPONSES.CANCEL,
value: undefined,
dataSourceName: undefined,
});
@@ -32,14 +28,14 @@ export default function CreateReportDialogPrompt(uiDialogService, { extensionMan
switch (action.id) {
case 'save':
resolve({
action: CREATE_REPORT_DIALOG_RESPONSE.CREATE_REPORT,
action: PROMPT_RESPONSES.CREATE_REPORT,
value: value.label,
dataSourceName: value.dataSourceName,
});
break;
case 'cancel':
resolve({
action: CREATE_REPORT_DIALOG_RESPONSE.CANCEL,
action: PROMPT_RESPONSES.CANCEL,
value: undefined,
dataSourceName: undefined,
});
@@ -90,7 +86,7 @@ export default function CreateReportDialogPrompt(uiDialogService, { extensionMan
if (event.key === 'Enter') {
uiDialogService.dismiss({ id: dialogId });
resolve({
action: CREATE_REPORT_DIALOG_RESPONSE.CREATE_REPORT,
action: PROMPT_RESPONSES.CREATE_REPORT,
value: value.label,
});
}
@@ -102,7 +98,7 @@ export default function CreateReportDialogPrompt(uiDialogService, { extensionMan
<label className="text-[14px] leading-[1.2] text-white">Data Source</label>
<Select
closeMenuOnSelect={true}
className="border-primary-main mt-2 bg-black"
className="border-primary-main mt-2 bg-black"
options={dataSourcesOpts}
placeholder={
dataSourcesOpts.find(option => option.value === value.dataSourceName)
@@ -2,6 +2,7 @@ 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);
+1 -7
View File
@@ -1,11 +1,5 @@
import PanelStudyBrowser from './StudyBrowser/PanelStudyBrowser';
import WrappedPanelStudyBrowser from './WrappedPanelStudyBrowser';
import PanelMeasurementTable from './PanelMeasurementTable';
import createReportDialogPrompt from './createReportDialogPrompt';
export {
PanelStudyBrowser,
WrappedPanelStudyBrowser,
PanelMeasurementTable,
createReportDialogPrompt,
};
export { PanelStudyBrowser, WrappedPanelStudyBrowser, createReportDialogPrompt };
@@ -0,0 +1,74 @@
import React, { useState, useEffect } from 'react';
import usePatientInfo from '../../hooks/usePatientInfo';
import { Icons } from '@ohif/ui-next';
export enum PatientInfoVisibility {
VISIBLE = 'visible',
VISIBLE_COLLAPSED = 'visibleCollapsed',
DISABLED = 'disabled',
VISIBLE_READONLY = 'visibleReadOnly',
}
const formatWithEllipsis = (str, maxLength) => {
if (str?.length > maxLength) {
return str.substring(0, maxLength) + '...';
}
return str;
};
function HeaderPatientInfo({ servicesManager, appConfig }: withAppTypes) {
const initialExpandedState =
appConfig.showPatientInfo === PatientInfoVisibility.VISIBLE ||
appConfig.showPatientInfo === PatientInfoVisibility.VISIBLE_READONLY;
const [expanded, setExpanded] = useState(initialExpandedState);
const { patientInfo, isMixedPatients } = usePatientInfo(servicesManager);
useEffect(() => {
if (isMixedPatients && expanded) {
setExpanded(false);
}
}, [isMixedPatients, expanded]);
const handleOnClick = () => {
if (!isMixedPatients && appConfig.showPatientInfo !== PatientInfoVisibility.VISIBLE_READONLY) {
setExpanded(!expanded);
}
};
const formattedPatientName = formatWithEllipsis(patientInfo.PatientName, 27);
const formattedPatientID = formatWithEllipsis(patientInfo.PatientID, 15);
return (
<div
className="hover:bg-primary-dark flex cursor-pointer items-center justify-center gap-1 rounded-lg"
onClick={handleOnClick}
>
{isMixedPatients ? (
<Icons.MultiplePatients className="text-primary-active" />
) : (
<Icons.Patient className="text-primary-active" />
)}
<div className="flex flex-col justify-center">
{expanded ? (
<>
<div className="self-start text-[13px] font-bold text-white">
{formattedPatientName}
</div>
<div className="text-aqua-pale flex gap-2 text-[11px]">
<div>{formattedPatientID}</div>
<div>{patientInfo.PatientSex}</div>
<div>{patientInfo.PatientDOB}</div>
</div>
</>
) : (
<div className="text-primary-active self-center text-[13px]">
{isMixedPatients ? 'Multiple Patients' : 'Patient'}
</div>
)}
</div>
<Icons.ChevronPatient className={`text-primary-active ${expanded ? 'rotate-180' : ''}`} />
</div>
);
}
export default HeaderPatientInfo;
@@ -0,0 +1,3 @@
import HeaderPatientInfo from './HeaderPatientInfo';
export default HeaderPatientInfo;
@@ -3,10 +3,13 @@ import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router';
import { ErrorBoundary, UserPreferences, AboutModal, Header, useModal } from '@ohif/ui';
import { UserPreferences, AboutModal, useModal } from '@ohif/ui';
import { Header } from '@ohif/ui-next';
import i18n from '@ohif/i18n';
import { hotkeys } from '@ohif/core';
import { Toolbar } from '../Toolbar/Toolbar';
import HeaderPatientInfo from './HeaderPatientInfo';
import { PatientInfoVisibility } from './HeaderPatientInfo/HeaderPatientInfo';
const { availableLanguages, defaultLanguage, currentLanguage } = i18n;
@@ -15,7 +18,7 @@ function ViewerHeader({
extensionManager,
servicesManager,
appConfig,
}: withAppTypes) {
}: withAppTypes<{ appConfig: AppTypes.Config }>) {
const navigate = useNavigate();
const location = useLocation();
@@ -110,21 +113,24 @@ function ViewerHeader({
isReturnEnabled={!!appConfig.showStudyList}
onClickReturnButton={onClickReturnButton}
WhiteLabeling={appConfig.whiteLabeling}
showPatientInfo={appConfig.showPatientInfo}
servicesManager={servicesManager}
Secondary={
<Toolbar
servicesManager={servicesManager}
buttonSection="secondary"
/>
}
appConfig={appConfig}
PatientInfo={
appConfig.showPatientInfo !== PatientInfoVisibility.DISABLED && (
<HeaderPatientInfo
servicesManager={servicesManager}
appConfig={appConfig}
/>
)
}
>
<ErrorBoundary context="Primary Toolbar">
<div className="relative flex justify-center gap-[4px]">
<Toolbar servicesManager={servicesManager} />
</div>
</ErrorBoundary>
<div className="relative flex justify-center gap-[4px]">
<Toolbar servicesManager={servicesManager} />
</div>
</Header>
);
}
+16 -22
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useState, useCallback } from 'react';
import PropTypes from 'prop-types';
import { ErrorBoundary, LoadingIndicatorProgress, InvestigationalUseDialog } from '@ohif/ui';
import { LoadingIndicatorProgress, InvestigationalUseDialog } from '@ohif/ui';
import { HangingProtocolService, CommandsManager } from '@ohif/core';
import { useAppConfig } from '@state';
import ViewerHeader from './ViewerHeader';
@@ -125,34 +125,28 @@ function ViewerLayout({
{showLoadingIndicator && <LoadingIndicatorProgress className="h-full w-full bg-black" />}
{/* LEFT SIDEPANELS */}
{hasLeftPanels ? (
<ErrorBoundary context="Left Panel">
<SidePanelWithServices
side="left"
activeTabIndex={leftPanelClosedState ? null : 0}
servicesManager={servicesManager}
/>
</ErrorBoundary>
<SidePanelWithServices
side="left"
activeTabIndex={leftPanelClosedState ? null : 0}
servicesManager={servicesManager}
/>
) : null}
{/* TOOLBAR + GRID */}
<div className="flex h-full flex-1 flex-col">
<div className="relative flex h-full flex-1 items-center justify-center overflow-hidden bg-black">
<ErrorBoundary context="Grid">
<ViewportGridComp
servicesManager={servicesManager}
viewportComponents={viewportComponents}
commandsManager={commandsManager}
/>
</ErrorBoundary>
<ViewportGridComp
servicesManager={servicesManager}
viewportComponents={viewportComponents}
commandsManager={commandsManager}
/>
</div>
</div>
{hasRightPanels ? (
<ErrorBoundary context="Right Panel">
<SidePanelWithServices
side="right"
activeTabIndex={rightPanelClosedState ? null : 0}
servicesManager={servicesManager}
/>
</ErrorBoundary>
<SidePanelWithServices
side="right"
activeTabIndex={rightPanelClosedState ? null : 0}
servicesManager={servicesManager}
/>
) : null}
</React.Fragment>
</div>
+32 -46
View File
@@ -1,4 +1,4 @@
import { utils, Types } from '@ohif/core';
import { Types } from '@ohif/core';
import { ContextMenuController, defaultContextMenu } from './CustomizableContextMenu';
import DicomTagBrowser from './DicomTagBrowser/DicomTagBrowser';
@@ -10,8 +10,13 @@ import 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';
const { subscribeToNextViewportGridChange } = utils;
export type HangingProtocolParams = {
protocolId?: string;
stageIndex?: number;
@@ -36,7 +41,6 @@ const commandsModule = ({
uiNotificationService,
viewportGridService,
displaySetService,
stateSyncService,
} = servicesManager.services;
// Define a context menu controller for use with any context menus
@@ -134,9 +138,9 @@ const commandsModule = ({
// the activeViewportId
const state = viewportGridService.getState();
const hpInfo = hangingProtocolService.getState();
const stateSyncReduce = reuseCachedLayouts(state, hangingProtocolService, stateSyncService);
const { hangingProtocolStageIndexMap, viewportGridStore, displaySetSelectorMap } =
stateSyncReduce;
reuseCachedLayouts(state, hangingProtocolService);
const { hangingProtocolStageIndexMap } = useHangingProtocolStageIndexStore.getState();
const { displaySetSelectorMap } = useDisplaySetSelectorStore.getState();
if (!protocolId) {
// Reuse the previous protocol id, and optionally stage
@@ -165,7 +169,8 @@ const commandsModule = ({
useStageIdx || 0
}`;
const restoreProtocol = !reset && viewportGridStore[storedHanging];
const { viewportGridState } = useViewportGridStore.getState();
const restoreProtocol = !reset && viewportGridState[storedHanging];
if (
protocolId === hpInfo.protocolId &&
@@ -185,17 +190,15 @@ const commandsModule = ({
restoreProtocol,
});
if (restoreProtocol) {
viewportGridService.set(viewportGridStore[storedHanging]);
viewportGridService.set(viewportGridState[storedHanging]);
}
}
// Do this after successfully applying the update
// Note, don't store the active display set - it is only needed while
// changing display sets. This causes jump to measurement to fail on
// multi-study display.
delete displaySetSelectorMap[
`${activeStudyUID || hpInfo.activeStudyUID}:activeDisplaySet:0`
];
stateSyncService.store(stateSyncReduce);
const { setDisplaySetSelector } = useDisplaySetSelectorStore.getState();
setDisplaySetSelector(
`${activeStudyUID || hpInfo.activeStudyUID}:activeDisplaySet:0`,
null
);
return true;
} catch (e) {
console.error(e);
@@ -215,7 +218,8 @@ const commandsModule = ({
stageIndex: desiredStageIndex,
activeStudy,
} = hangingProtocolService.getActiveProtocol();
const { toggleHangingProtocol } = stateSyncService.getState();
const { toggleHangingProtocol, setToggleHangingProtocol } =
useToggleHangingProtocolStore.getState();
const storedHanging = `${activeStudy.StudyInstanceUID}:${protocolId}:${stageIndex | 0}`;
if (
protocol.id === protocolId &&
@@ -227,14 +231,9 @@ const commandsModule = ({
};
return actions.setHangingProtocol(previousState);
} else {
stateSyncService.store({
toggleHangingProtocol: {
...toggleHangingProtocol,
[storedHanging]: {
protocolId: protocol.id,
stageIndex: desiredStageIndex,
},
},
setToggleHangingProtocol(storedHanging, {
protocolId: protocol.id,
stageIndex: desiredStageIndex,
});
return actions.setHangingProtocol({
protocolId,
@@ -281,12 +280,15 @@ const commandsModule = ({
const completeLayout = () => {
const state = viewportGridService.getState();
const stateReduce = findViewportsByPosition(state, { numRows, numCols }, stateSyncService);
findViewportsByPosition(state, { numRows, numCols });
const { viewportsByPosition, initialInDisplay } = useViewportsByPositionStore.getState();
const findOrCreateViewport = layoutFindOrCreate.bind(
null,
hangingProtocolService,
isHangingProtocolLayout,
stateReduce.viewportsByPosition
{ ...viewportsByPosition, initialInDisplay }
);
viewportGridService.setLayout({
@@ -295,7 +297,6 @@ const commandsModule = ({
findOrCreateViewport,
isHangingProtocolLayout,
});
stateSyncService.store(stateReduce);
};
// Need to finish any work in the callback
window.setTimeout(completeLayout, 0);
@@ -309,9 +310,9 @@ const commandsModule = ({
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 } = stateSyncService.getState();
const { toggleOneUpViewportGridStore } = useToggleOneUpViewportGridStore.getState();
if (!toggleOneUpViewportGridStore.layout) {
if (!toggleOneUpViewportGridStore) {
return;
}
// There is a state to toggle back to. The viewport that was
@@ -374,11 +375,9 @@ const commandsModule = ({
// We are not in one-up, so toggle to one up.
// Store the current viewport grid state so we can toggle it back later.
stateSyncService.store({
toggleOneUpViewportGridStore: viewportGridState,
});
const { setToggleOneUpViewportGridStore } = useToggleOneUpViewportGridStore.getState();
setToggleOneUpViewportGridStore(viewportGridState);
// This findOrCreateViewport only return one viewport - the active
// one being toggled to one up.
const findOrCreateViewport = () => {
return {
@@ -395,19 +394,6 @@ const commandsModule = ({
findOrCreateViewport,
isHangingProtocolLayout: true,
});
// Subscribe to ANY (i.e. manual and hanging protocol) layout changes so that
// any grid layout state to toggle to from one up is cleared. This is performed on
// a timeout to avoid clearing the state for the actual to one up change.
// Whenever the next layout change event is fired, the subscriptions are unsubscribed.
const clearToggleOneUpViewportGridStore = () => {
const toggleOneUpViewportGridStore = {};
stateSyncService.store({
toggleOneUpViewportGridStore,
});
};
subscribeToNextViewportGridChange(viewportGridService, clearToggleOneUpViewportGridStore);
}
},
@@ -1,4 +1,4 @@
import { StateSyncService } from '@ohif/core';
import { useViewportsByPositionStore } from './stores/useViewportsByPositionStore';
/**
* This find or create viewport is paired with the reduce results from
@@ -69,16 +69,12 @@ export const findOrCreateViewport = (
* @returns Set of states that can be applied to the state sync to remember
* the current view state.
*/
const findViewportsByPosition = (
state,
{ numRows, numCols },
syncService: StateSyncService
): Record<string, Record<string, unknown>> => {
const findViewportsByPosition = (state, { numRows, numCols }) => {
const { viewports } = state;
const syncState = syncService.getState();
const viewportsByPosition = { ...syncState.viewportsByPosition };
const { setViewportsByPosition, addInitialInDisplay } = useViewportsByPositionStore.getState();
const initialInDisplay = [];
const viewportsByPosition = {};
viewports.forEach(viewport => {
if (viewport.positionId) {
const storedViewport = {
@@ -86,6 +82,7 @@ const findViewportsByPosition = (
viewportOptions: { ...viewport.viewportOptions },
};
viewportsByPosition[viewport.positionId] = storedViewport;
setViewportsByPosition(viewport.positionId, storedViewport);
}
});
@@ -99,10 +96,7 @@ const findViewportsByPosition = (
}
}
// Store the initially displayed elements
viewportsByPosition.initialInDisplay = initialInDisplay;
return { viewportsByPosition };
initialInDisplay.forEach(displaySetInstanceUID => addInitialInDisplay(displaySetInstanceUID));
};
export default findViewportsByPosition;
@@ -27,6 +27,17 @@ const defaultProtocol = {
viewportType: 'stack',
toolGroupId: 'default',
allowUnmatchedView: true,
syncGroups: [
{
type: 'hydrateseg',
id: 'sameFORId',
source: true,
target: true,
options: {
matchingRules: ['sameFOR'],
},
},
],
},
displaySets: [
{
@@ -58,8 +69,6 @@ const defaultProtocol = {
},
},
],
// Can be used to select matching studies
// studyMatchingRules: [],
},
},
stages: [
@@ -88,6 +97,17 @@ const defaultProtocol = {
// index: 180,
// preset: 'middle', // 'first', 'last', 'middle'
// },
syncGroups: [
{
type: 'hydrateseg',
id: 'sameFORId',
source: true,
target: true,
// options: {
// matchingRules: ['sameFOR'],
// },
},
],
},
displaySets: [
{
+1 -20
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { WrappedPanelStudyBrowser, PanelMeasurementTable } from './Panels';
import { WrappedPanelStudyBrowser } from './Panels';
import i18n from 'i18next';
// TODO:
@@ -8,17 +8,6 @@ import i18n from 'i18next';
// - show errors in UI for thumbnails if promise fails
function getPanelModule({ commandsManager, extensionManager, servicesManager }) {
const wrappedMeasurementPanel = ({ tab }) => {
return (
<PanelMeasurementTable
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
tab={tab}
/>
);
};
return [
{
name: 'seriesList',
@@ -34,14 +23,6 @@ function getPanelModule({ commandsManager, extensionManager, servicesManager })
/>
),
},
{
name: 'measurements',
iconName: 'tab-linear',
iconLabel: 'Measure',
label: i18n.t('SidePanel:Measurements'),
secondaryLabel: i18n.t('SidePanel:Measurements'),
component: wrappedMeasurementPanel,
},
];
}
@@ -1,5 +1,20 @@
import { Types } from '@ohif/core';
/**
* Sync group configuration for hydrating segmentations across viewports
* that share the same frame of reference
* @type {Types.HangingProtocol.SyncGroup}
*/
export const HYDRATE_SEG_SYNC_GROUP = {
type: 'hydrateseg',
id: 'sameFORId',
source: true,
target: true,
options: {
matchingRules: ['sameFOR'],
},
} as const;
/**
* This hanging protocol can be activated on the primary mode by directly
* referencing it in a URL or by directly including it within a mode, e.g.:
@@ -48,6 +63,7 @@ const hpMN: Types.HangingProtocol.Protocol = {
viewportType: 'stack',
toolGroupId: 'default',
allowUnmatchedView: true,
syncGroups: [HYDRATE_SEG_SYNC_GROUP],
},
displaySets: [
{
@@ -59,6 +75,7 @@ const hpMN: Types.HangingProtocol.Protocol = {
stages: [
{
id: '2x2',
name: '2x2',
stageActivation: {
enabled: {
minViewportsMatched: 4,
@@ -76,6 +93,17 @@ const hpMN: Types.HangingProtocol.Protocol = {
viewportOptions: {
toolGroupId: 'default',
allowUnmatchedView: true,
syncGroups: [
{
type: 'hydrateseg',
id: 'sameFORId',
source: true,
target: true,
options: {
matchingRules: ['sameFOR'],
},
},
],
},
displaySets: [
{
@@ -99,6 +127,17 @@ const hpMN: Types.HangingProtocol.Protocol = {
viewportOptions: {
toolGroupId: 'default',
allowUnmatchedView: true,
syncGroups: [
{
type: 'hydrateseg',
id: 'sameFORId',
source: true,
target: true,
// options: {
// matchingRules: ['sameFOR'],
// },
},
],
},
displaySets: [
{
@@ -111,6 +150,17 @@ const hpMN: Types.HangingProtocol.Protocol = {
viewportOptions: {
toolGroupId: 'default',
allowUnmatchedView: true,
syncGroups: [
{
type: 'hydrateseg',
id: 'sameFORId',
source: true,
target: true,
// options: {
// matchingRules: ['sameFOR'],
// },
},
],
},
displaySets: [
{
@@ -0,0 +1,63 @@
import { useState, useEffect } from 'react';
import { utils } from '@ohif/core';
const { formatPN, formatDate } = utils;
function usePatientInfo(servicesManager: AppTypes.ServicesManager) {
const { displaySetService } = servicesManager.services;
const [patientInfo, setPatientInfo] = useState({
PatientName: '',
PatientID: '',
PatientSex: '',
PatientDOB: '',
});
const [isMixedPatients, setIsMixedPatients] = useState(false);
const displaySets = displaySetService.getActiveDisplaySets();
const checkMixedPatients = PatientID => {
const displaySets = displaySetService.getActiveDisplaySets();
let isMixedPatients = false;
displaySets.forEach(displaySet => {
const instance = displaySet?.instances?.[0] || displaySet?.instance;
if (!instance) {
return;
}
if (instance.PatientID !== PatientID) {
isMixedPatients = true;
}
});
setIsMixedPatients(isMixedPatients);
};
const updatePatientInfo = () => {
const displaySet = displaySets[0];
const instance = displaySet?.instances?.[0] || displaySet?.instance;
if (!instance) {
return;
}
setPatientInfo({
PatientID: instance.PatientID || null,
PatientName: instance.PatientName ? formatPN(instance.PatientName.Alphabetic) : null,
PatientSex: instance.PatientSex || null,
PatientDOB: formatDate(instance.PatientBirthDate) || null,
});
checkMixedPatients(instance.PatientID || null);
};
useEffect(() => {
const subscription = displaySetService.subscribe(
displaySetService.EVENTS.DISPLAY_SETS_ADDED,
() => updatePatientInfo()
);
return () => subscription.unsubscribe();
}, []);
useEffect(() => {
updatePatientInfo();
}, [displaySets]);
return { patientInfo, isMixedPatients };
}
export default usePatientInfo;
+40
View File
@@ -18,6 +18,23 @@ import { createReportDialogPrompt } from './Panels';
import createReportAsync from './Actions/createReportAsync';
import StaticWadoClient from './DicomWebDataSource/utils/StaticWadoClient';
import { cleanDenaturalizedDataset } from './DicomWebDataSource/utils';
import { useViewportsByPositionStore } from './stores/useViewportsByPositionStore';
import { useViewportGridStore } from './stores/useViewportGridStore';
import { useUIStateStore } from './stores/useUIStateStore';
import { useDisplaySetSelectorStore } from './stores/useDisplaySetSelectorStore';
import { useHangingProtocolStageIndexStore } from './stores/useHangingProtocolStageIndexStore';
import { useToggleHangingProtocolStore } from './stores/useToggleHangingProtocolStore';
import { useToggleOneUpViewportGridStore } from './stores/useToggleOneUpViewportGridStore';
import {
callLabelAutocompleteDialog,
showLabelAnnotationPopup,
callInputDialog,
} from './utils/callInputDialog';
import colorPickerDialog from './utils/colorPickerDialog';
import promptSaveReport from './utils/promptSaveReport';
import promptLabelAnnotation from './utils/promptLabelAnnotation';
import usePatientInfo from './hooks/usePatientInfo';
const defaultExtension: Types.Extensions.Extension = {
/**
@@ -25,6 +42,14 @@ const defaultExtension: Types.Extensions.Extension = {
*/
id,
preRegistration,
onModeExit() {
useViewportGridStore.getState().clearViewportGridState();
useUIStateStore.getState().clearUIState();
useDisplaySetSelectorStore.getState().clearDisplaySetSelectorMap();
useHangingProtocolStageIndexStore.getState().clearHangingProtocolStageIndexMap();
useToggleHangingProtocolStore.getState().clearToggleHangingProtocol();
useViewportsByPositionStore.getState().clearViewportsByPosition();
},
getDataSourcesModule,
getViewportModule,
getLayoutTemplateModule,
@@ -58,4 +83,19 @@ export {
createReportAsync,
StaticWadoClient,
cleanDenaturalizedDataset,
// Export all stores
useDisplaySetSelectorStore,
useHangingProtocolStageIndexStore,
useToggleHangingProtocolStore,
useToggleOneUpViewportGridStore,
useUIStateStore,
useViewportGridStore,
useViewportsByPositionStore,
showLabelAnnotationPopup,
callLabelAutocompleteDialog,
callInputDialog,
promptSaveReport,
promptLabelAnnotation,
colorPickerDialog,
usePatientInfo,
};
+1 -35
View File
@@ -16,8 +16,7 @@ export default function init({
configuration = {},
commandsManager,
}: withAppTypes): void {
const { stateSyncService, toolbarService, cineService, viewportGridService } =
servicesManager.services;
const { toolbarService, cineService, viewportGridService } = servicesManager.services;
toolbarService.registerEventForToolbarUpdate(cineService, [
cineService.EVENTS.CINE_STATE_CHANGED,
@@ -29,38 +28,6 @@ export default function init({
// we need to recalculate the SUV Scaling Factors
DicomMetadataStore.subscribe(DicomMetadataStore.EVENTS.SERIES_UPDATED, handlePETImageMetadata);
// viewportGridStore is a sync state which stores the entire
// ViewportGridService getState, by the keys `<activeStudyUID>:<protocolId>:<stageIndex>`
// Used to recover manual changes to the layout of a stage.
stateSyncService.register('viewportGridStore', { clearOnModeExit: true });
// uiStateStore is a sync state which stores the relevant
// UI state for the viewer
stateSyncService.register('uiStateStore', { clearOnModeExit: true });
// displaySetSelectorMap stores a map from
// `<activeStudyUID>:<displaySetSelectorId>:<matchOffset>` to
// a displaySetInstanceUID, used to display named display sets in
// specific spots within a hanging protocol and be able to remember what the
// user did with those named spots between stages and protocols.
stateSyncService.register('displaySetSelectorMap', { clearOnModeExit: true });
// Stores a map from `<activeStudyUID>:${protocolId}` to the getHPInfo results
// in order to recover the correct stage when returning to a Hanging Protocol.
stateSyncService.register('hangingProtocolStageIndexMap', {
clearOnModeExit: true,
});
// Stores a map from the to be applied hanging protocols `<activeStudyUID>:<protocolId>`
// to the previously applied hanging protocolStageIndexMap key, in order to toggle
// off the applied protocol and remember the old state.
stateSyncService.register('toggleHangingProtocol', { clearOnModeExit: true });
// Stores the viewports by `rows-cols` position so that when the layout
// changes numRows and numCols, the viewports can be remembers and then replaced
// afterwards.
stateSyncService.register('viewportsByPosition', { clearOnModeExit: true });
// Adds extra custom attributes for use by hanging protocols
registerHangingProtocolAttributes({ servicesManager });
@@ -119,7 +86,6 @@ const handlePETImageMetadata = ({ SeriesInstanceUID, StudyInstanceUID }) => {
const imageIds = instances.map(instance => instance.imageId);
const instanceMetadataArray = [];
// try except block to prevent errors when the metadata is not correct
try {
imageIds.forEach(imageId => {
+7
View File
@@ -0,0 +1,7 @@
export { useDisplaySetSelectorStore } from './useDisplaySetSelectorStore';
export { useHangingProtocolStageIndexStore } from './useHangingProtocolStageIndexStore';
export { useToggleHangingProtocolStore } from './useToggleHangingProtocolStore';
export { useToggleOneUpViewportGridStore } from './useToggleOneUpViewportGridStore';
export { useUIStateStore } from './useUIStateStore';
export { useViewportGridStore } from './useViewportGridStore';
export { useViewportsByPositionStore } from './useViewportsByPositionStore';
@@ -0,0 +1,83 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
/**
* Identifier for the display set selector store type.
*/
const PRESENTATION_TYPE_ID = 'displaySetSelectorId';
/**
* Flag to enable or disable debug mode for the store.
* Set to `true` to enable zustand devtools.
*/
const DEBUG_STORE = false;
/**
* State shape for the Display Set Selector store.
*/
type DisplaySetSelectorState = {
/**
* Type identifier for the store.
*/
type: string;
/**
* Stores a mapping from `<activeStudyUID>:<displaySetSelectorId>:<matchOffset>` to `displaySetInstanceUID`.
*/
displaySetSelectorMap: Record<string, string>;
/**
* Sets the display set selector for a given key.
*
* @param key - The key.
* @param value - The `displaySetInstanceUID` to associate with the key.
*/
setDisplaySetSelector: (key: string, value: string) => void;
/**
* Clears the entire display set selector map.
*/
clearDisplaySetSelectorMap: () => void;
};
/**
* Creates the Display Set Selector store.
*
* @param set - The zustand set function.
* @returns The display set selector store state and actions.
*/
const createDisplaySetSelectorStore = (set): DisplaySetSelectorState => ({
type: PRESENTATION_TYPE_ID,
displaySetSelectorMap: {},
/**
* Sets the display set selector for a given key.
*/
setDisplaySetSelector: (key: string, value: string) =>
set(
state => ({
displaySetSelectorMap: {
...state.displaySetSelectorMap,
[key]: value,
},
}),
false,
'setDisplaySetSelector'
),
/**
* Clears the entire display set selector map.
*/
clearDisplaySetSelectorMap: () =>
set({ displaySetSelectorMap: {} }, false, 'clearDisplaySetSelectorMap'),
});
/**
* Zustand store for managing display set selectors.
* Applies devtools middleware when DEBUG_STORE is enabled.
*/
export const useDisplaySetSelectorStore = create<DisplaySetSelectorState>()(
DEBUG_STORE
? devtools(createDisplaySetSelectorStore, { name: 'DisplaySetSelectorStore' })
: createDisplaySetSelectorStore
);
@@ -0,0 +1,76 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { Types } from '@ohif/core';
const PRESENTATION_TYPE_ID = 'hangingProtocolStageIndexId';
const DEBUG_STORE = false;
/**
* Represents the state and actions for managing hanging protocol stage indexes.
*/
type HangingProtocolStageIndexState = {
/**
* Stores a mapping from key to `HPInfo`.
*/
hangingProtocolStageIndexMap: Record<string, Types.HangingProtocol.HPInfo>;
/**
* Sets the hanging protocol stage index for a given key.
*
* @param key - The key.
* @param value - The `HPInfo` to associate with the key.
*/
setHangingProtocolStageIndex: (key: string, value: Types.HangingProtocol.HPInfo) => void;
/**
* Clears all hanging protocol stage indexes.
*/
clearHangingProtocolStageIndexMap: () => void;
/**
* Type identifier for the store.
*/
type: string;
};
/**
* Creates the Hanging Protocol Stage Index store.
*
* @param set - The zustand set function.
* @returns The hanging protocol stage index store state and actions.
*/
const createHangingProtocolStageIndexStore = (set): HangingProtocolStageIndexState => ({
hangingProtocolStageIndexMap: {},
type: PRESENTATION_TYPE_ID,
/**
* Sets the hanging protocol stage index for a given key.
*/
setHangingProtocolStageIndex: (key, value) =>
set(
state => ({
hangingProtocolStageIndexMap: {
...state.hangingProtocolStageIndexMap,
[key]: value,
},
}),
false,
'setHangingProtocolStageIndex'
),
/**
* Clears all hanging protocol stage indexes.
*/
clearHangingProtocolStageIndexMap: () =>
set({ hangingProtocolStageIndexMap: {} }, false, 'clearHangingProtocolStageIndexMap'),
});
/**
* Zustand store for managing hanging protocol stage indexes.
* Applies devtools middleware when DEBUG_STORE is enabled.
*/
export const useHangingProtocolStageIndexStore = create<HangingProtocolStageIndexState>()(
DEBUG_STORE
? devtools(createHangingProtocolStageIndexStore, { name: 'HangingProtocolStageIndexStore' })
: createHangingProtocolStageIndexStore
);
@@ -0,0 +1,76 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { Types } from '@ohif/core';
const PRESENTATION_TYPE_ID = 'toggleHangingProtocolId';
const DEBUG_STORE = false;
/**
* Represents the state and actions for managing toggle hanging protocols.
*/
type ToggleHangingProtocolState = {
/**
* Stores a mapping from key to `HPInfo`.
*/
toggleHangingProtocol: Record<string, Types.HangingProtocol.HPInfo>;
/**
* Sets the toggle hanging protocol for a given key.
*
* @param key - The key .
* @param value - The `HPInfo` to associate with the key.
*/
setToggleHangingProtocol: (key: string, value: Types.HangingProtocol.HPInfo) => void;
/**
* Clears all toggle hanging protocols.
*/
clearToggleHangingProtocol: () => void;
/**
* Type identifier for the store.
*/
type: string;
};
/**
* Creates the Toggle Hanging Protocol store.
*
* @param set - The zustand set function.
* @returns The toggle hanging protocol store state and actions.
*/
const createToggleHangingProtocolStore = (set): ToggleHangingProtocolState => ({
toggleHangingProtocol: {},
type: PRESENTATION_TYPE_ID,
/**
* Sets the toggle hanging protocol for a given key.
*/
setToggleHangingProtocol: (key, value) =>
set(
state => ({
toggleHangingProtocol: {
...state.toggleHangingProtocol,
[key]: value,
},
}),
false,
'setToggleHangingProtocol'
),
/**
* Clears all toggle hanging protocols.
*/
clearToggleHangingProtocol: () =>
set({ toggleHangingProtocol: {} }, false, 'clearToggleHangingProtocol'),
});
/**
* Zustand store for managing toggle hanging protocols.
* Applies devtools middleware when DEBUG_STORE is enabled.
*/
export const useToggleHangingProtocolStore = create<ToggleHangingProtocolState>()(
DEBUG_STORE
? devtools(createToggleHangingProtocolStore, { name: 'ToggleHangingProtocolStore' })
: createToggleHangingProtocolStore
);
@@ -0,0 +1,19 @@
import { create } from 'zustand';
const PRESENTATION_TYPE_ID = 'toggleOneUpViewportGridId';
type ToggleOneUpViewportGridState = {
toggleOneUpViewportGridStore: any | null;
setToggleOneUpViewportGridStore: (state: any) => void;
clearToggleOneUpViewportGridStore: () => void;
type: string;
};
// Stores the entire ViewportGridService getState when toggling to one up
// (e.g. via a double click) so that it can be restored when toggling back.
export const useToggleOneUpViewportGridStore = create<ToggleOneUpViewportGridState>(set => ({
toggleOneUpViewportGridStore: null,
type: PRESENTATION_TYPE_ID,
setToggleOneUpViewportGridStore: state => set({ toggleOneUpViewportGridStore: state }),
clearToggleOneUpViewportGridStore: () => set({ toggleOneUpViewportGridStore: null }),
}));
@@ -0,0 +1,87 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
/**
* Identifier for the UI State store type.
*/
const PRESENTATION_TYPE_ID = 'uiStateId';
/**
* Flag to enable or disable debug mode for the store.
* Set to `true` to enable zustand devtools.
*/
const DEBUG_STORE = false;
/**
* Represents the UI state.
*/
type UIState = {
[key: string]: unknown;
};
/**
* State shape for the UI State store.
*/
type UIStateStore = {
/**
* Type identifier for the store.
*/
type: string;
/**
* Stores the UI state as a key-value mapping.
*/
uiState: UIState;
/**
* Sets the UI state for a given key.
*
* @param key - The key to set in the UI state.
* @param value - The value to associate with the key.
*/
setUIState: (key: string, value: unknown) => void;
/**
* Clears all UI state.
*/
clearUIState: () => void;
};
/**
* Creates the UI State store.
*
* @param set - The zustand set function.
* @returns The UI State store state and actions.
*/
const createUIStateStore = (set): UIStateStore => ({
type: PRESENTATION_TYPE_ID,
uiState: {},
/**
* Sets the UI state for a given key.
*/
setUIState: (key, value) =>
set(
state => ({
uiState: {
...state.uiState,
[key]: value,
},
}),
false,
'setUIState'
),
/**
* Clears all UI state.
*/
clearUIState: () => set({ uiState: {} }, false, 'clearUIState'),
});
/**
* Zustand store for managing UI state.
* Applies devtools middleware when DEBUG_STORE is enabled.
*/
export const useUIStateStore = create<UIStateStore>()(
DEBUG_STORE ? devtools(createUIStateStore, { name: 'UIStateStore' }) : createUIStateStore
);
@@ -0,0 +1,89 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
/**
* Identifier for the viewport grid store type.
*/
const PRESENTATION_TYPE_ID = 'viewportGridId';
/**
* Flag to enable or disable debug mode for the store.
* Set to `true` to enable zustand devtools.
*/
const DEBUG_STORE = false;
/**
* Represents the state of the viewport grid.
*/
type ViewportGridState = {
[key: string]: unknown;
};
/**
* State shape for the Viewport Grid store.
*/
type ViewportGridStore = {
/**
* Type identifier for the store.
*/
type: string;
/**
* Stores the viewport grid state as a key-value mapping.
*/
viewportGridState: ViewportGridState;
/**
* Sets the viewport grid state for a given key.
*
* @param key - The key to set in the viewport grid state.
* @param value - The value to associate with the key.
*/
setViewportGridState: (key: string, value: unknown) => void;
/**
* Clears the entire viewport grid state.
*/
clearViewportGridState: () => void;
};
/**
* Creates the Viewport Grid store.
*
* @param set - The zustand set function.
* @returns The Viewport Grid store state and actions.
*/
const createViewportGridStore = (set): ViewportGridStore => ({
type: PRESENTATION_TYPE_ID,
viewportGridState: {},
/**
* Sets the viewport grid state for a given key.
*/
setViewportGridState: (key, value) =>
set(
state => ({
viewportGridState: {
...state.viewportGridState,
[key]: value,
},
}),
false,
'setViewportGridState'
),
/**
* Clears the entire viewport grid state.
*/
clearViewportGridState: () => set({ viewportGridState: {} }, false, 'clearViewportGridState'),
});
/**
* Zustand store for managing viewport grid state.
* Applies devtools middleware when DEBUG_STORE is enabled.
*/
export const useViewportGridStore = create<ViewportGridStore>()(
DEBUG_STORE
? devtools(createViewportGridStore, { name: 'ViewportGridStore' })
: createViewportGridStore
);
@@ -0,0 +1,100 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
const PRESENTATION_TYPE_ID = 'viewportsByPositionId';
const DEBUG_STORE = true;
/**
* Represents the state and actions for managing viewports by position.
*/
type ViewportsByPositionState = {
/**
* Type identifier for the store.
*/
type: string;
/**
* Stores viewports indexed by their position.
*/
viewportsByPosition: Record<string, unknown>;
/**
* Stores initial display viewports as an array of strings.
*/
initialInDisplay: string[];
/**
* Sets the viewport for a given key.
*
* @param key - The key identifying the viewport position.
* @param value - The viewport data to associate with the key.
*/
setViewportsByPosition: (key: string, value: unknown) => void;
/**
* Clears all viewports by position.
*/
clearViewportsByPosition: () => void;
/**
* Adds an initial display viewport.
*
* @param value - The viewport identifier to add.
*/
addInitialInDisplay: (value: string) => void;
};
/**
* Creates the Viewports By Position store.
*
* @param set - The zustand set function.
* @returns The Viewports By Position store state and actions.
*/
const createViewportsByPositionStore = (set): ViewportsByPositionState => ({
type: PRESENTATION_TYPE_ID,
viewportsByPosition: {},
initialInDisplay: [],
/**
* Sets the viewport for a given key.
*/
setViewportsByPosition: (key, value) =>
set(
state => ({
viewportsByPosition: {
...state.viewportsByPosition,
[key]: value,
},
}),
false,
'setViewportsByPosition'
),
/**
* Clears all viewports by position.
*/
clearViewportsByPosition: () =>
set({ viewportsByPosition: {} }, false, 'clearViewportsByPosition'),
/**
* Adds an initial display viewport.
*/
addInitialInDisplay: value =>
set(
state => ({
initialInDisplay: [...state.initialInDisplay, value],
}),
false,
'addInitialInDisplay'
),
});
/**
* Zustand store for managing viewports by position.
* Applies devtools middleware when DEBUG_STORE is enabled.
*/
export const useViewportsByPositionStore = create<ViewportsByPositionState>()(
DEBUG_STORE
? devtools(createViewportsByPositionStore, { name: 'ViewportsByPositionStore' })
: createViewportsByPositionStore
);
@@ -0,0 +1,10 @@
const RESPONSE = {
NO_NEVER: -1,
CANCEL: 0,
CREATE_REPORT: 1,
ADD_SERIES: 2,
SET_STUDY_AND_SERIES: 3,
NO_NOT_FOR_SERIES: 4,
};
export default RESPONSE;
@@ -0,0 +1,10 @@
const MIN_SR_SERIES_NUMBER = 4700;
export default function getNextSRSeriesNumber(displaySetService) {
const activeDisplaySets = displaySetService.getActiveDisplaySets();
const srDisplaySets = activeDisplaySets.filter(ds => ds.Modality === 'SR');
const srSeriesNumbers = srDisplaySets.map(ds => ds.SeriesNumber);
const maxSeriesNumber = Math.max(...srSeriesNumbers, MIN_SR_SERIES_NUMBER);
return maxSeriesNumber + 1;
}
@@ -0,0 +1,158 @@
import React from 'react';
import { Input, Dialog, ButtonEnums, LabellingFlow } from '@ohif/ui';
/**
*
* @param {*} data
* @param {*} data.text
* @param {*} data.label
* @param {*} event
* @param {*} callback
* @param {*} isArrowAnnotateInputDialog
* @param {*} dialogConfig
* @param {string?} dialogConfig.dialogTitle - title of the input dialog
* @param {string?} dialogConfig.inputLabel - show label above the input
*/
export function callInputDialog(
uiDialogService,
data,
callback,
isArrowAnnotateInputDialog = true,
dialogConfig: any = {}
) {
const dialogId = 'dialog-enter-annotation';
const label = data ? (isArrowAnnotateInputDialog ? data.text : data.label) : '';
const {
dialogTitle = 'Annotation',
inputLabel = 'Enter your annotation',
validateFunc = value => true,
} = dialogConfig;
const onSubmitHandler = ({ action, value }) => {
switch (action.id) {
case 'save':
if (typeof validateFunc === 'function' && !validateFunc(value.label)) {
return;
}
callback(value.label, action.id);
break;
case 'cancel':
callback('', action.id);
break;
}
uiDialogService.dismiss({ id: dialogId });
};
if (uiDialogService) {
uiDialogService.create({
id: dialogId,
centralize: true,
isDraggable: false,
showOverlay: true,
content: Dialog,
contentProps: {
title: dialogTitle,
value: { label },
noCloseButton: true,
onClose: () => uiDialogService.dismiss({ id: dialogId }),
actions: [
{ id: 'cancel', text: 'Cancel', type: ButtonEnums.type.secondary },
{ id: 'save', text: 'Save', type: ButtonEnums.type.primary },
],
onSubmit: onSubmitHandler,
body: ({ value, setValue }) => {
return (
<Input
autoFocus
className="border-primary-main bg-black"
type="text"
id="annotation"
label={inputLabel}
labelClassName="text-white text-[14px] leading-[1.2]"
value={value.label}
onChange={event => {
event.persist();
setValue(value => ({ ...value, label: event.target.value }));
}}
onKeyPress={event => {
if (event.key === 'Enter') {
onSubmitHandler({ value, action: { id: 'save' } });
}
}}
/>
);
},
},
});
}
}
export function callLabelAutocompleteDialog(uiDialogService, callback, dialogConfig, labelConfig) {
const exclusive = labelConfig ? labelConfig.exclusive : false;
const dropDownItems = labelConfig ? labelConfig.items : [];
const { validateFunc = value => true } = dialogConfig;
const labellingDoneCallback = value => {
if (typeof value === 'string') {
if (typeof validateFunc === 'function' && !validateFunc(value)) {
return;
}
callback(value, 'save');
} else {
callback('', 'cancel');
}
uiDialogService.dismiss({ id: 'select-annotation' });
};
uiDialogService.create({
id: 'select-annotation',
centralize: true,
isDraggable: false,
showOverlay: true,
content: LabellingFlow,
contentProps: {
labellingDoneCallback: labellingDoneCallback,
measurementData: { label: '' },
componentClassName: {},
labelData: dropDownItems,
exclusive: exclusive,
},
});
}
export function showLabelAnnotationPopup(measurement, uiDialogService, labelConfig) {
const exclusive = labelConfig ? labelConfig.exclusive : false;
const dropDownItems = labelConfig ? labelConfig.items : [];
return new Promise<Map<any, any>>((resolve, reject) => {
const labellingDoneCallback = value => {
uiDialogService.dismiss({ id: 'select-annotation' });
if (typeof value === 'string') {
measurement.label = value;
}
resolve(measurement);
};
uiDialogService.create({
id: 'select-annotation',
isDraggable: false,
showOverlay: true,
content: LabellingFlow,
defaultPosition: {
x: window.innerWidth / 2,
y: window.innerHeight / 2,
},
contentProps: {
labellingDoneCallback: labellingDoneCallback,
measurementData: measurement,
componentClassName: {},
labelData: dropDownItems,
exclusive: exclusive,
},
});
});
}
export default callInputDialog;
@@ -0,0 +1,3 @@
.chrome-picker {
background: #090c29 !important;
}
@@ -0,0 +1,58 @@
import React from 'react';
import { Dialog } from '@ohif/ui';
import { ChromePicker } from 'react-color';
import './colorPickerDialog.css';
function colorPickerDialog(uiDialogService, rgbaColor, callback) {
const dialogId = 'pick-color';
const onSubmitHandler = ({ action, value }) => {
switch (action.id) {
case 'save':
callback(value.rgbaColor, action.id);
break;
case 'cancel':
callback('', action.id);
break;
}
uiDialogService.dismiss({ id: dialogId });
};
if (uiDialogService) {
uiDialogService.create({
id: dialogId,
centralize: true,
isDraggable: false,
showOverlay: true,
content: Dialog,
contentProps: {
title: 'Segment Color',
value: { rgbaColor },
noCloseButton: true,
onClose: () => uiDialogService.dismiss({ id: dialogId }),
actions: [
{ id: 'cancel', text: 'Cancel', type: 'primary' },
{ id: 'save', text: 'Save', type: 'secondary' },
],
onSubmit: onSubmitHandler,
body: ({ value, setValue }) => {
const handleChange = color => {
setValue({ rgbaColor: color.rgb });
};
return (
<ChromePicker
color={value.rgbaColor}
onChange={handleChange}
presetColors={[]}
width={300}
/>
);
},
},
});
}
}
export default colorPickerDialog;
@@ -0,0 +1,31 @@
import { showLabelAnnotationPopup } from './callInputDialog';
function promptLabelAnnotation({ servicesManager }, ctx, evt) {
const { measurementService, customizationService } = servicesManager.services;
const { viewportId, StudyInstanceUID, SeriesInstanceUID, measurementId } = evt;
return new Promise(async function (resolve) {
const labelConfig = customizationService.get('measurementLabels');
const measurement = measurementService.getMeasurement(measurementId);
const value = await showLabelAnnotationPopup(
measurement,
servicesManager.services.uiDialogService,
labelConfig
);
measurementService.update(
measurementId,
{
...value,
},
true
);
resolve({
StudyInstanceUID,
SeriesInstanceUID,
viewportId,
});
});
}
export default promptLabelAnnotation;
@@ -0,0 +1,75 @@
import createReportAsync from '../Actions/createReportAsync';
import { createReportDialogPrompt } from '../Panels';
import getNextSRSeriesNumber from './getNextSRSeriesNumber';
import PROMPT_RESPONSES from './_shared/PROMPT_RESPONSES';
async function promptSaveReport({ servicesManager, commandsManager, extensionManager }, ctx, evt) {
const { uiDialogService, measurementService, displaySetService } = servicesManager.services;
const viewportId = evt.viewportId === undefined ? evt.data.viewportId : evt.viewportId;
const isBackupSave = evt.isBackupSave === undefined ? evt.data.isBackupSave : evt.isBackupSave;
const StudyInstanceUID = evt?.data?.StudyInstanceUID;
const SeriesInstanceUID = evt?.data?.SeriesInstanceUID;
const { trackedStudy, trackedSeries } = ctx;
let displaySetInstanceUIDs;
try {
const promptResult = await createReportDialogPrompt(uiDialogService, {
extensionManager,
});
if (promptResult.action === PROMPT_RESPONSES.CREATE_REPORT) {
const dataSources = extensionManager.getDataSources();
const dataSource = dataSources[0];
const measurements = measurementService.getMeasurements();
const trackedMeasurements = measurements
.filter(
m => trackedStudy === m.referenceStudyUID && trackedSeries.includes(m.referenceSeriesUID)
)
.filter(m => m.referencedImageId != null);
const SeriesDescription =
// isUndefinedOrEmpty
promptResult.value === undefined || promptResult.value === ''
? 'Research Derived Series' // default
: promptResult.value; // provided value
const SeriesNumber = getNextSRSeriesNumber(displaySetService);
const getReport = async () => {
return commandsManager.runCommand(
'storeMeasurements',
{
measurementData: trackedMeasurements,
dataSource,
additionalFindingTypes: ['ArrowAnnotate'],
options: {
SeriesDescription,
SeriesNumber,
},
},
'CORNERSTONE_STRUCTURED_REPORT'
);
};
displaySetInstanceUIDs = await createReportAsync({
servicesManager,
getReport,
});
} else if (promptResult.action === RESPONSE.CANCEL) {
// Do nothing
}
return {
userResponse: promptResult.action,
createdDisplaySetInstanceUIDs: displaySetInstanceUIDs,
StudyInstanceUID,
SeriesInstanceUID,
viewportId,
isBackupSave,
};
} catch (error) {
return null;
}
}
export default promptSaveReport;
@@ -1,4 +1,7 @@
import { HangingProtocolService, StateSyncService, Types } from '@ohif/core';
import { HangingProtocolService, Types } from '@ohif/core';
import { useViewportGridStore } from '../stores/useViewportGridStore';
import { useDisplaySetSelectorStore } from '../stores/useDisplaySetSelectorStore';
import { useHangingProtocolStageIndexStore } from '../stores/useHangingProtocolStageIndexStore';
export type ReturnType = {
hangingProtocolStageIndexMap: Record<string, Types.HangingProtocol.HPInfo>;
@@ -14,11 +17,7 @@ export type ReturnType = {
* @returns Set of states that can be applied to the state sync to remember
* the current view state.
*/
const reuseCachedLayout = (
state,
hangingProtocolService: HangingProtocolService,
syncService: StateSyncService
): ReturnType => {
const reuseCachedLayout = (state, hangingProtocolService: HangingProtocolService): ReturnType => {
const { activeViewportId } = state;
const { protocol } = hangingProtocolService.getActiveProtocol();
@@ -29,16 +28,14 @@ const reuseCachedLayout = (
const hpInfo = hangingProtocolService.getState();
const { protocolId, stageIndex, activeStudyUID } = hpInfo;
const syncState = syncService.getState();
const viewportGridStore = { ...syncState.viewportGridStore };
const displaySetSelectorMap = { ...syncState.displaySetSelectorMap };
const { viewportGridState, setViewportGridState } = useViewportGridStore.getState();
const { displaySetSelectorMap, setDisplaySetSelector } = useDisplaySetSelectorStore.getState();
const { hangingProtocolStageIndexMap, setHangingProtocolStageIndex } =
useHangingProtocolStageIndexStore.getState();
const stage = protocol.stages[stageIndex];
const storeId = `${activeStudyUID}:${protocolId}:${stageIndex}`;
const cacheId = `${activeStudyUID}:${protocolId}`;
const hangingProtocolStageIndexMap = {
...syncState.hangingProtocolStageIndexMap,
};
const { rows, columns } = stage.viewportStructure.properties;
const custom =
stage.viewports.length !== state.viewports.size ||
@@ -48,7 +45,7 @@ const reuseCachedLayout = (
hangingProtocolStageIndexMap[cacheId] = hpInfo;
if (storeId && custom) {
viewportGridStore[storeId] = { ...state };
setViewportGridState(storeId, { ...state });
}
state.viewports.forEach((viewport, viewportId) => {
@@ -62,21 +59,24 @@ const reuseCachedLayout = (
continue;
}
if (viewportId === activeViewportId && i === 0) {
displaySetSelectorMap[`${activeStudyUID}:activeDisplaySet:0`] = displaySetUID;
setDisplaySetSelector(`${activeStudyUID}:activeDisplaySet:0`, displaySetUID);
}
if (displaySetOptions[i]?.id) {
displaySetSelectorMap[
setDisplaySetSelector(
`${activeStudyUID}:${displaySetOptions[i].id}:${
displaySetOptions[i].matchedDisplaySetsIndex || 0
}`
] = displaySetUID;
}`,
displaySetUID
);
}
}
});
setHangingProtocolStageIndex(cacheId, hpInfo);
return {
hangingProtocolStageIndexMap,
viewportGridStore,
viewportGridStore: viewportGridState,
displaySetSelectorMap,
};
};