feat: Automate tracking prompts for a less disruptive annotation workflow (#4822)

Co-authored-by: Devu Jayalekshmi <devu.jayalekshmi@trenser.com>
This commit is contained in:
nithin-trenserandDevu Jayalekshmi authored and GitHub committed 2025-04-04 12:02:13 -04:00
1 parent c0391cea1b
commit 54fded554e
42 files changed
+948 -70

No files matched your search

@@ -13,13 +13,13 @@ function promptHydrateRT({
preHydrateCallbacks,
hydrateRTDisplaySet,
}: withAppTypes) {
const { uiViewportDialogService } = servicesManager.services;
const { uiViewportDialogService, customizationService } = servicesManager.services;
const extensionManager = servicesManager._extensionManager;
const appConfig = extensionManager._appConfig;
return new Promise(async function (resolve, reject) {
const promptResult = appConfig?.disableConfirmationPrompts
? RESPONSE.HYDRATE_SEG
: await _askHydrate(uiViewportDialogService, viewportId);
: await _askHydrate(uiViewportDialogService, customizationService, viewportId);
if (promptResult === RESPONSE.HYDRATE_SEG) {
preHydrateCallbacks?.forEach(callback => {
@@ -37,9 +37,13 @@ function promptHydrateRT({
});
}
function _askHydrate(uiViewportDialogService: AppTypes.UIViewportDialogService, viewportId) {
function _askHydrate(
uiViewportDialogService: AppTypes.UIViewportDialogService,
customizationService: AppTypes.CustomizationService,
viewportId
) {
return new Promise(function (resolve, reject) {
const message = 'Do you want to open this Segmentation?';
const message = customizationService.getCustomization('viewportNotification.hydrateRTMessage');
const actions = [
{
id: 'no-hydrate',
@@ -13,14 +13,14 @@ function promptHydrateSEG({
preHydrateCallbacks,
hydrateCallback,
}: withAppTypes) {
const { uiViewportDialogService } = servicesManager.services;
const { uiViewportDialogService, customizationService } = servicesManager.services;
const extensionManager = servicesManager._extensionManager;
const appConfig = extensionManager._appConfig;
return new Promise(async function (resolve, reject) {
const promptResult = appConfig?.disableConfirmationPrompts
? RESPONSE.HYDRATE_SEG
: await _askHydrate(uiViewportDialogService, viewportId);
: await _askHydrate(uiViewportDialogService, customizationService, viewportId);
if (promptResult === RESPONSE.HYDRATE_SEG) {
preHydrateCallbacks?.forEach(callback => {
@@ -39,9 +39,13 @@ function promptHydrateSEG({
});
}
function _askHydrate(uiViewportDialogService, viewportId) {
function _askHydrate(
uiViewportDialogService: AppTypes.UIViewportDialogService,
customizationService: AppTypes.CustomizationService,
viewportId
) {
return new Promise(function (resolve, reject) {
const message = 'Do you want to open this Segmentation?';
const message = customizationService.getCustomization('viewportNotification.hydrateSEGMessage');
const actions = [
{
id: 'no-hydrate',
@@ -179,7 +179,10 @@ function Trigger(props) {
>
<div>
{React.cloneElement(children, childProps)}
<ChevronDownIcon key="chevronDown" className="text-primary h-4 w-4 shrink-0 transition-transform duration-200" />
<ChevronDownIcon
key="chevronDown"
className="text-primary h-4 w-4 shrink-0 transition-transform duration-200"
/>
</div>
</AccordionTrigger>
);
@@ -54,7 +54,7 @@ export const groupByNamedSets = (items, grouping) => {
};
export function MeasurementsOrAdditionalFindings(props): React.ReactNode {
const { items, children, grouping = {}, customHeader, group } = props;
const { items, children, grouping = {}, customHeader, group, actions } = props;
return (
<AccordionGroup
@@ -72,6 +72,7 @@ export function MeasurementsOrAdditionalFindings(props): React.ReactNode {
<MeasurementTableNested
customHeader={customHeader}
allItems={items}
actions={actions}
/>
</AccordionGroup.Accordion>
</AccordionGroup>
@@ -53,6 +53,10 @@ export function StudyMeasurementsActions({ items, StudyInstanceUID, measurementF
className="pl-0.5"
onClick={e => {
e.stopPropagation();
if (actions?.onDelete) {
actions.onDelete();
return;
}
commandsManager.runCommand('clearMeasurements', {
measurementFilter,
});
@@ -7,7 +7,11 @@ import { toolNames } from './initCornerstoneTools';
import { onCompletedCalibrationLine } from './tools/CalibrationLineTool';
import measurementServiceMappingsFactory from './utils/measurementServiceMappings/measurementServiceMappingsFactory';
import getSOPInstanceAttributes from './utils/measurementServiceMappings/utils/getSOPInstanceAttributes';
import { triggerAnnotationRenderForViewportIds } from '@cornerstonejs/tools/utilities';
import {
setAnnotationLabel,
triggerAnnotationRenderForViewportIds,
} from '@cornerstonejs/tools/utilities';
import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement';
const { CORNERSTONE_3D_TOOLS_SOURCE_NAME, CORNERSTONE_3D_TOOLS_SOURCE_VERSION } = CSExtensionEnums;
const { removeAnnotation } = annotation.state;
@@ -190,6 +194,7 @@ const connectToolsToMeasurementService = (servicesManager: AppTypes.ServicesMana
displaySetService,
cornerstoneViewportService,
customizationService,
viewportGridService,
} = servicesManager.services;
const csTools3DVer1MeasurementSource = initMeasurementService(
measurementService,
@@ -197,7 +202,11 @@ const connectToolsToMeasurementService = (servicesManager: AppTypes.ServicesMana
cornerstoneViewportService,
customizationService
);
connectMeasurementServiceToTools(measurementService, cornerstoneViewportService);
connectMeasurementServiceToTools(
measurementService,
cornerstoneViewportService,
viewportGridService
);
const { annotationToMeasurement, remove } = csTools3DVer1MeasurementSource;
//
@@ -323,7 +332,11 @@ const connectToolsToMeasurementService = (servicesManager: AppTypes.ServicesMana
return csTools3DVer1MeasurementSource;
};
const connectMeasurementServiceToTools = (measurementService, cornerstoneViewportService) => {
const connectMeasurementServiceToTools = (
measurementService,
cornerstoneViewportService,
viewportGridService
) => {
const { MEASUREMENT_REMOVED, MEASUREMENTS_CLEARED, MEASUREMENT_UPDATED, RAW_MEASUREMENT_ADDED } =
measurementService.EVENTS;
@@ -366,7 +379,8 @@ const connectMeasurementServiceToTools = (measurementService, cornerstoneViewpor
}
if (data.label !== label) {
data.label = label;
const element = getActiveViewportEnabledElement(viewportGridService)?.viewport.element;
setAnnotationLabel(sourceAnnotation, element, label);
}
if (metadata.toolName === 'ArrowAnnotate') {
@@ -34,8 +34,9 @@ function PanelStudyBrowser({
dataSource,
customMapDisplaySets,
onClickUntrack,
onDoubleClickThumbnailHandlerCallBack,
}) {
const { servicesManager, commandsManager } = useSystem();
const { servicesManager, commandsManager, extensionManager } = useSystem();
const { displaySetService, customizationService } = servicesManager.services;
const navigate = useNavigate();
const studyMode = customizationService.getCustomization('studyBrowser.studyMode') || 'all';
@@ -93,6 +94,7 @@ function PanelStudyBrowser({
commandsManager,
servicesManager,
isHangingProtocolLayout,
appConfig: extensionManager._appConfig,
};
const handlers = customHandler?.callbacks.map(callback => callback(setupArgs));
@@ -100,6 +102,7 @@ function PanelStudyBrowser({
for (const handler of handlers) {
await handler(displaySetInstanceUID);
}
onDoubleClickThumbnailHandlerCallBack?.(displaySetInstanceUID);
},
[
activeViewportId,
@@ -1,5 +1,29 @@
import i18n from '@ohif/i18n';
import { Notification } from '@ohif/ui';
const beginTrackingMessage = i18n.t('MeasurementTable:Track measurements for this series?');
const trackNewSeriesMessage = i18n.t('Do you want to add this measurement to the existing report?');
const discardSeriesMessage = i18n.t(
'You have existing tracked measurements. What would you like to do with your existing tracked measurements?'
);
const trackNewStudyMessage = i18n.t('MeasurementTable:Track measurements for this series?');
const discardStudyMessage = i18n.t(
'Measurements cannot span across multiple studies. Do you want to save your tracked measurements?'
);
const hydrateSRMessage = i18n.t('Do you want to continue tracking measurements for this study?');
const hydrateRTMessage = i18n.t('Do you want to open this Segmentation?');
const hydrateSEGMessage = i18n.t('Do you want to open this Segmentation?');
const discardDirtyMessage = i18n.t('There are unsaved measurements. Do you want to save it?');
export default {
'ui.notificationComponent': Notification,
'viewportNotification.beginTrackingMessage': beginTrackingMessage,
'viewportNotification.trackNewSeriesMessage': trackNewSeriesMessage,
'viewportNotification.discardSeriesMessage': discardSeriesMessage,
'viewportNotification.trackNewStudyMessage': trackNewStudyMessage,
'viewportNotification.discardStudyMessage': discardStudyMessage,
'viewportNotification.hydrateSRMessage': hydrateSRMessage,
'viewportNotification.hydrateRTMessage': hydrateRTMessage,
'viewportNotification.hydrateSEGMessage': hydrateSEGMessage,
'viewportNotification.discardDirtyMessage': discardDirtyMessage,
};
@@ -1,4 +1,6 @@
import React from 'react';
import { setAnnotationLabel } from '@cornerstonejs/tools/utilities';
import { annotation } from '@cornerstonejs/tools';
import { LabellingFlow } from '@ohif/ui';
import { InputDialog } from '@ohif/ui-next';
@@ -89,6 +91,7 @@ export async function callInputDialogAutoComplete({
uiDialogService,
labelConfig,
renderContent = LabellingFlow,
element,
}) {
const exclusive = labelConfig ? labelConfig.exclusive : false;
const dropDownItems = labelConfig ? labelConfig.items : [];
@@ -97,7 +100,8 @@ export async function callInputDialogAutoComplete({
const labellingDoneCallback = newValue => {
uiDialogService.hide('select-annotation');
if (measurement && typeof newValue === 'string') {
measurement.label = newValue;
const sourceAnnotation = annotation.state.getAnnotation(measurement.uid);
setAnnotationLabel(sourceAnnotation, element, newValue);
}
resolve(newValue);
};
@@ -18,6 +18,7 @@ async function promptSaveReport({ servicesManager, commandsManager, extensionMan
const isBackupSave = evt.isBackupSave === undefined ? evt.data.isBackupSave : evt.isBackupSave;
const StudyInstanceUID = evt?.data?.StudyInstanceUID || ctx.trackedStudy;
const SeriesInstanceUID = evt?.data?.SeriesInstanceUID;
const { displaySetInstanceUID } = evt.data ?? evt;
const {
trackedSeries,
@@ -76,6 +77,7 @@ async function promptSaveReport({ servicesManager, commandsManager, extensionMan
SeriesInstanceUID,
viewportId,
isBackupSave,
displaySetInstanceUID,
};
} catch (error) {
console.warn('Unable to save report', error);
@@ -3,14 +3,19 @@ import PropTypes from 'prop-types';
import { Machine } from 'xstate';
import { useMachine } from '@xstate/react';
import { useViewportGrid } from '@ohif/ui-next';
import { promptLabelAnnotation, promptSaveReport } from '@ohif/extension-default';
import { machineConfiguration, defaultOptions, RESPONSE } from './measurementTrackingMachine';
import promptBeginTracking from './promptBeginTracking';
import promptTrackNewSeries from './promptTrackNewSeries';
import promptTrackNewStudy from './promptTrackNewStudy';
import promptHydrateStructuredReport from './promptHydrateStructuredReport';
import { measurementTrackingMode } from './promptBeginTracking';
import hydrateStructuredReport from './hydrateStructuredReport';
import { useAppConfig } from '@state';
import {
promptBeginTrackingWrapper,
promptHydrateStructuredReportWrapper,
promptTrackNewSeriesWrapper,
promptTrackNewStudyWrapper,
promptLabelAnnotationWrapper,
promptSaveReportWrapper,
promptHasDirtyAnnotationsWrapper,
} from './promptWrapperFunctions';
const TrackedMeasurementsContext = React.createContext();
TrackedMeasurementsContext.displayName = 'TrackedMeasurementsContext';
@@ -35,7 +40,8 @@ function TrackedMeasurementsContextProvider(
const machineOptions = Object.assign({}, defaultOptions);
machineOptions.actions = Object.assign({}, machineOptions.actions, {
jumpToFirstMeasurementInActiveViewport: (ctx, evt) => {
const { trackedStudy, trackedSeries, activeViewportId } = ctx;
const { trackedStudy, trackedSeries } = ctx;
const { viewportId: activeViewportId } = evt.data;
const measurements = measurementService.getMeasurements();
const trackedMeasurements = measurements.filter(
m => trackedStudy === m.referenceStudyUID && trackedSeries.includes(m.referenceSeriesUID)
@@ -82,7 +88,8 @@ function TrackedMeasurementsContextProvider(
},
jumpToSameImageInActiveViewport: (ctx, evt) => {
const { trackedStudy, trackedSeries, activeViewportId } = ctx;
const { trackedStudy, trackedSeries } = ctx;
const { viewportId: activeViewportId } = evt.data;
const measurements = measurementService.getMeasurements();
const trackedMeasurements = measurements.filter(
m => trackedStudy === m.referenceStudyUID && trackedSeries.includes(m.referenceSeriesUID)
@@ -143,31 +150,64 @@ function TrackedMeasurementsContextProvider(
for (let i = 0; i < measurementIds.length; i++) {
measurementService.remove(measurementIds[i]);
}
measurementService.setIsMeasurementDeletedIndividually(false);
},
clearDisplaySetHydratedState: (ctx, evt) => {
const { displaySetInstanceUID } = evt.data ?? evt;
const displaysets = displaySetService.getActiveDisplaySets();
displaysets?.forEach(displayset => {
if (
displayset.Modality === 'SR' &&
displayset.displaySetInstanceUID !== displaySetInstanceUID &&
displayset.isHydrated
) {
displayset.isHydrated = false;
displayset.isLoaded = false;
}
});
},
updatedViewports: (ctx, evt) => {
const { hangingProtocolService } = servicesManager.services;
const { displaySetInstanceUID, viewportId } = evt.data ?? evt;
const updatedViewports = hangingProtocolService.getViewportsRequireUpdate(
viewportId,
displaySetInstanceUID
);
viewportGridService.setDisplaySetsForViewports(updatedViewports);
},
});
machineOptions.services = Object.assign({}, machineOptions.services, {
promptBeginTracking: promptBeginTracking.bind(null, {
promptBeginTracking: promptBeginTrackingWrapper.bind(null, {
servicesManager,
extensionManager,
appConfig,
}),
promptTrackNewSeries: promptTrackNewSeries.bind(null, {
promptTrackNewSeries: promptTrackNewSeriesWrapper.bind(null, {
servicesManager,
extensionManager,
appConfig,
}),
promptTrackNewStudy: promptTrackNewStudy.bind(null, {
promptTrackNewStudy: promptTrackNewStudyWrapper.bind(null, {
servicesManager,
extensionManager,
appConfig,
}),
promptSaveReport: promptSaveReport.bind(null, {
promptSaveReport: promptSaveReportWrapper.bind(null, {
servicesManager,
commandsManager,
extensionManager,
appConfig,
}),
promptHydrateStructuredReport: promptHydrateStructuredReport.bind(null, {
promptHydrateStructuredReport: promptHydrateStructuredReportWrapper.bind(null, {
servicesManager,
extensionManager,
commandsManager,
appConfig,
}),
promptHasDirtyAnnotations: promptHasDirtyAnnotationsWrapper.bind(null, {
servicesManager,
extensionManager,
commandsManager,
@@ -179,7 +219,7 @@ function TrackedMeasurementsContextProvider(
commandsManager,
appConfig,
}),
promptLabelAnnotation: promptLabelAnnotation.bind(null, {
promptLabelAnnotation: promptLabelAnnotationWrapper.bind(null, {
servicesManager,
extensionManager,
commandsManager,
@@ -194,6 +234,25 @@ function TrackedMeasurementsContextProvider(
const labelConfig = customizationService.getCustomization('measurementLabels');
return evt.data && evt.data.userResponse === RESPONSE.NO_NEVER && labelConfig?.labelOnMeasure;
},
isSimplifiedConfig: (ctx, evt, condMeta) => {
return appConfig?.measurementTrackingMode === measurementTrackingMode.SIMPLIFIED;
},
simplifiedAndLoadSR: (ctx, evt, condMeta) => {
return (
appConfig?.measurementTrackingMode === measurementTrackingMode.SIMPLIFIED &&
evt.data.isBackupSave === false
);
},
hasDirtyAndSimplified: (ctx, evt, condMeta) => {
const measurements = measurementService.getMeasurements();
const hasDirtyMeasurements =
measurements.some(measurement => measurement.isDirty) ||
(measurements.length && measurementService.getIsMeasurementDeletedIndividually());
return (
appConfig?.measurementTrackingMode === measurementTrackingMode.SIMPLIFIED &&
hasDirtyMeasurements
);
},
});
// TODO: IMPROVE
@@ -266,7 +325,8 @@ function TrackedMeasurementsContextProvider(
// load function added by our sopClassHandler module
if (
displaySet.SOPClassHandlerId === SR_SOPCLASSHANDLERID &&
displaySet.isRehydratable === true
displaySet.isRehydratable === true &&
!displaySet.isHydrated
) {
const params = {
displaySetInstanceUID: displaySet.displaySetInstanceUID,
@@ -122,13 +122,27 @@ const machineConfiguration = {
UNTRACK_SERIES: [
{
target: 'tracking',
actions: ['removeTrackedSeries', 'setIsDirty'],
actions: ['removeTrackedSeries', 'setIsDirty', 'clearDisplaySetHydratedState'],
cond: 'hasRemainingTrackedSeries',
},
{
target: 'idle',
},
],
UNTRACK_ALL: [
{
target: 'tracking',
actions: [
'clearContext',
'setIsDirtyToClean',
'clearDisplaySetHydratedState',
'clearAllMeasurements',
],
},
{
target: 'idle',
},
],
SET_TRACKED_SERIES: [
{
target: 'tracking',
@@ -146,6 +160,15 @@ const machineConfiguration = {
target: 'tracking',
},
],
CHECK_DIRTY: {
target: 'promptHasDirtyAnnotations',
cond: 'hasDirtyAndSimplified',
},
PROMPT_HYDRATE_SR: {
target: 'promptHydrateStructuredReport',
cond: 'isSimplifiedConfig',
actions: ['clearAllMeasurements', 'clearDisplaySetHydratedState'],
},
},
},
promptTrackNewSeries: {
@@ -214,6 +237,16 @@ const machineConfiguration = {
invoke: {
src: 'promptSaveReport',
onDone: [
{
target: 'tracking',
actions: [
'clearAllMeasurements',
'clearDisplaySetHydratedState',
'setIsDirty',
'updatedViewports',
],
cond: 'simplifiedAndLoadSR',
},
// "clicked the save button"
// - should clear all measurements
// - show DICOM SR
@@ -312,6 +345,28 @@ const machineConfiguration = {
],
},
},
promptHasDirtyAnnotations: {
invoke: {
src: 'promptHasDirtyAnnotations',
onDone: [
{
target: 'tracking',
actions: [
'clearAllMeasurements',
'clearDisplaySetHydratedState',
'setIsDirty',
'updatedViewports',
],
cond: 'shouldSetStudyAndSeries',
},
{
target: 'promptSaveReport',
cond: 'shouldPromptSaveReport',
},
{ target: 'tracking' },
],
},
},
},
strict: true,
};
@@ -9,17 +9,29 @@ const RESPONSE = {
SET_STUDY_AND_SERIES: 3,
};
export const measurementTrackingMode = {
STANDARD: 'standard',
SIMPLIFIED: 'simplified',
NONE: 'none',
};
function promptBeginTracking({ servicesManager, extensionManager }, ctx, evt) {
const { uiViewportDialogService } = servicesManager.services;
const { uiViewportDialogService, customizationService } = servicesManager.services;
const appConfig = extensionManager._appConfig;
// When the state change happens after a promise, the state machine sends the retult in evt.data;
// In case of direct transition to the state, the state machine sends the data in evt;
const { viewportId, StudyInstanceUID, SeriesInstanceUID } = evt.data || evt;
return new Promise(async function (resolve, reject) {
let promptResult = appConfig?.disableConfirmationPrompts
? RESPONSE.SET_STUDY_AND_SERIES
: await _askTrackMeasurements(uiViewportDialogService, viewportId);
const standardMode = appConfig?.measurementTrackingMode === measurementTrackingMode.STANDARD;
const noTrackingMode = appConfig?.measurementTrackingMode === measurementTrackingMode.NONE;
let promptResult;
promptResult = noTrackingMode
? RESPONSE.NO_NEVER
: standardMode
? await _askTrackMeasurements(uiViewportDialogService, customizationService, viewportId)
: RESPONSE.SET_STUDY_AND_SERIES;
resolve({
userResponse: promptResult,
@@ -30,9 +42,11 @@ function promptBeginTracking({ servicesManager, extensionManager }, ctx, evt) {
});
}
function _askTrackMeasurements(uiViewportDialogService, viewportId) {
function _askTrackMeasurements(uiViewportDialogService, customizationService, viewportId) {
return new Promise(function (resolve, reject) {
const message = i18n.t('MeasurementTable:Track measurements for this series?');
const message = customizationService.getCustomization(
'viewportNotification.beginTrackingMessage'
);
const actions = [
{
id: 'prompt-begin-tracking-cancel',
@@ -0,0 +1,81 @@
const RESPONSE = {
NO_NEVER: -1,
CANCEL: 0,
CREATE_REPORT: 1,
ADD_SERIES: 2,
SET_STUDY_AND_SERIES: 3,
NO_NOT_FOR_SERIES: 4,
};
function promptHasDirtyAnnotations({ servicesManager }: withAppTypes, ctx, evt) {
const { viewportId, displaySetInstanceUID } = evt.data || evt;
return new Promise(async function (resolve, reject) {
const { uiViewportDialogService, customizationService } = servicesManager.services;
const promptResult = await _askSaveDiscardOrCancel(
uiViewportDialogService,
customizationService,
viewportId
);
resolve({
displaySetInstanceUID,
userResponse: promptResult,
viewportId,
isBackupSave: false,
});
});
}
function _askSaveDiscardOrCancel(
UIViewportDialogService: AppTypes.UIViewportDialogService,
customizationService: AppTypes.CustomizationService,
viewportId
) {
return new Promise(function (resolve, reject) {
const message = customizationService.getCustomization(
'viewportNotification.discardDirtyMessage'
);
const actions = [
{ id: 'cancel', type: 'cancel', text: 'Cancel', value: RESPONSE.CANCEL },
{
id: 'discard-existing',
type: 'secondary',
text: 'No, discard existing',
value: RESPONSE.SET_STUDY_AND_SERIES,
},
{
id: 'save-existing',
type: 'primary',
text: 'Yes',
value: RESPONSE.CREATE_REPORT,
},
];
const onSubmit = result => {
UIViewportDialogService.hide();
resolve(result);
};
UIViewportDialogService.show({
viewportId,
id: 'measurement-tracking-prompt-dirty-measurement',
type: 'info',
message,
actions,
onSubmit,
onOutsideClick: () => {
UIViewportDialogService.hide();
resolve(RESPONSE.CANCEL);
},
onKeyPress: event => {
if (event.key === 'Enter') {
const action = actions.find(action => action.id === 'save-existing');
onSubmit(action.value);
}
},
});
});
}
export default promptHasDirtyAnnotations;
@@ -1,5 +1,6 @@
import { hydrateStructuredReport } from '@ohif/extension-cornerstone-dicom-sr';
import { ButtonEnums } from '@ohif/ui';
import { measurementTrackingMode } from './promptBeginTracking';
const RESPONSE = {
NO_NEVER: -1,
@@ -16,13 +17,16 @@ function promptHydrateStructuredReport(
ctx,
evt
) {
const { uiViewportDialogService, displaySetService } = servicesManager.services;
const { uiViewportDialogService, displaySetService, customizationService } =
servicesManager.services;
const { viewportId, displaySetInstanceUID } = evt;
const srDisplaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
return new Promise(async function (resolve, reject) {
const promptResult = appConfig?.disableConfirmationPrompts
? RESPONSE.HYDRATE_REPORT
: await _askTrackMeasurements(uiViewportDialogService, viewportId);
const standardMode = appConfig?.measurementTrackingMode === measurementTrackingMode.STANDARD;
const promptResult = standardMode
? await _askTrackMeasurements(uiViewportDialogService, customizationService, viewportId)
: RESPONSE.HYDRATE_REPORT;
// Need to do action here... So we can set state...
let StudyInstanceUID, SeriesInstanceUIDs;
@@ -49,9 +53,9 @@ function promptHydrateStructuredReport(
});
}
function _askTrackMeasurements(uiViewportDialogService, viewportId) {
function _askTrackMeasurements(uiViewportDialogService, customizationService, viewportId) {
return new Promise(function (resolve, reject) {
const message = 'Do you want to continue tracking measurements for this study?';
const message = customizationService.getCustomization('viewportNotification.hydrateSRMessage');
const actions = [
{
id: 'no-hydrate',
@@ -1,4 +1,5 @@
import { ButtonEnums } from '@ohif/ui';
import { measurementTrackingMode } from './promptBeginTracking';
const RESPONSE = {
NO_NEVER: -1,
@@ -10,17 +11,22 @@ const RESPONSE = {
};
function promptTrackNewSeries({ servicesManager, extensionManager }, ctx, evt) {
const { UIViewportDialogService } = servicesManager.services;
const { UIViewportDialogService, customizationService } = servicesManager.services;
// When the state change happens after a promise, the state machine sends the retult in evt.data;
// In case of direct transition to the state, the state machine sends the data in evt;
const { viewportId, StudyInstanceUID, SeriesInstanceUID } = evt.data || evt;
return new Promise(async function (resolve, reject) {
let promptResult = await _askShouldAddMeasurements(UIViewportDialogService, viewportId);
const appConfig = extensionManager._appConfig;
const showPrompt = appConfig?.measurementTrackingMode === measurementTrackingMode.STANDARD;
let promptResult = showPrompt
? await _askShouldAddMeasurements(UIViewportDialogService, customizationService, viewportId)
: RESPONSE.ADD_SERIES;
if (promptResult === RESPONSE.CREATE_REPORT) {
promptResult = ctx.isDirty
? await _askSaveDiscardOrCancel(UIViewportDialogService, viewportId)
? await _askSaveDiscardOrCancel(UIViewportDialogService, customizationService, viewportId)
: RESPONSE.SET_STUDY_AND_SERIES;
}
@@ -34,9 +40,11 @@ function promptTrackNewSeries({ servicesManager, extensionManager }, ctx, evt) {
});
}
function _askShouldAddMeasurements(uiViewportDialogService, viewportId) {
function _askShouldAddMeasurements(uiViewportDialogService, customizationService, viewportId) {
return new Promise(function (resolve, reject) {
const message = 'Do you want to add this measurement to the existing report?';
const message = customizationService.getCustomization(
'viewportNotification.trackNewSeriesMessage'
);
const actions = [
{
type: ButtonEnums.type.secondary,
@@ -73,10 +81,12 @@ function _askShouldAddMeasurements(uiViewportDialogService, viewportId) {
});
}
function _askSaveDiscardOrCancel(UIViewportDialogService, viewportId) {
function _askSaveDiscardOrCancel(UIViewportDialogService, customizationService, viewportId) {
return new Promise(function (resolve, reject) {
const message =
'You have existing tracked measurements. What would you like to do with your existing tracked measurements?';
const message = customizationService.getCustomization(
'viewportNotification.discardSeriesMessage'
);
const actions = [
{ type: 'cancel', text: 'Cancel', value: RESPONSE.CANCEL },
{
@@ -1,4 +1,5 @@
import i18n from 'i18next';
import { measurementTrackingMode } from './promptBeginTracking';
const RESPONSE = {
NO_NEVER: -1,
@@ -10,18 +11,26 @@ const RESPONSE = {
};
function promptTrackNewStudy({ servicesManager, extensionManager }: withAppTypes, ctx, evt) {
const { uiViewportDialogService } = servicesManager.services;
const { uiViewportDialogService, customizationService } = servicesManager.services;
// When the state change happens after a promise, the state machine sends the retult in evt.data;
// In case of direct transition to the state, the state machine sends the data in evt;
const { viewportId, StudyInstanceUID, SeriesInstanceUID } = evt.data || evt;
return new Promise(async function (resolve, reject) {
let promptResult = await _askTrackMeasurements(uiViewportDialogService, viewportId);
const appConfig = extensionManager._appConfig;
const standardMode = appConfig?.measurementTrackingMode === measurementTrackingMode.STANDARD;
const simplifiedMode =
appConfig?.measurementTrackingMode === measurementTrackingMode.SIMPLIFIED;
let promptResult = standardMode
? await _askTrackMeasurements(uiViewportDialogService, customizationService, viewportId)
: RESPONSE.SET_STUDY_AND_SERIES;
if (promptResult === RESPONSE.SET_STUDY_AND_SERIES) {
promptResult = ctx.isDirty
? await _askSaveDiscardOrCancel(uiViewportDialogService, viewportId)
: RESPONSE.SET_STUDY_AND_SERIES;
promptResult =
ctx.isDirty && (standardMode || simplifiedMode)
? await _askSaveDiscardOrCancel(uiViewportDialogService, customizationService, viewportId)
: RESPONSE.SET_STUDY_AND_SERIES;
}
resolve({
@@ -36,10 +45,13 @@ function promptTrackNewStudy({ servicesManager, extensionManager }: withAppTypes
function _askTrackMeasurements(
UIViewportDialogService: AppTypes.UIViewportDialogService,
customizationService: AppTypes.CustomizationService,
viewportId
) {
return new Promise(function (resolve, reject) {
const message = i18n.t('MeasurementTable:Track measurements for this series?');
const message = customizationService.getCustomization(
'viewportNotification.trackNewStudyMessage'
);
const actions = [
{ type: 'cancel', text: i18n.t('MeasurementTable:No'), value: RESPONSE.CANCEL },
{
@@ -80,11 +92,13 @@ function _askTrackMeasurements(
function _askSaveDiscardOrCancel(
UIViewportDialogService: AppTypes.UIViewportDialogService,
customizationService: AppTypes.CustomizationService,
viewportId
) {
return new Promise(function (resolve, reject) {
const message =
'Measurements cannot span across multiple studies. Do you want to save your tracked measurements?';
const message = customizationService.getCustomization(
'viewportNotification.discardStudyMessage'
);
const actions = [
{ type: 'cancel', text: 'Cancel', value: RESPONSE.CANCEL },
{
@@ -0,0 +1,83 @@
const promptBeginTrackingWrapper = ({ servicesManager, extensionManager }, ctx, evt) => {
const { customizationService } = servicesManager.services;
const promptBeginTracking = customizationService.getCustomization(
'measurement.promptBeginTracking'
);
return promptBeginTracking({ servicesManager, extensionManager }, ctx, evt);
};
const promptHydrateStructuredReportWrapper = (
{ servicesManager, extensionManager, commandsManager, appConfig },
ctx,
evt
) => {
const { customizationService } = servicesManager.services;
const promptHydrateStructuredReport = customizationService.getCustomization(
'measurement.promptHydrateStructuredReport'
);
return promptHydrateStructuredReport(
{ servicesManager, extensionManager, commandsManager, appConfig },
ctx,
evt
);
};
const promptTrackNewSeriesWrapper = ({ servicesManager, extensionManager }, ctx, evt) => {
const { customizationService } = servicesManager.services;
const promptTrackNewSeries = customizationService.getCustomization(
'measurement.promptTrackNewSeries'
);
return promptTrackNewSeries({ servicesManager, extensionManager }, ctx, evt);
};
const promptTrackNewStudyWrapper = ({ servicesManager, extensionManager }, ctx, evt) => {
const { customizationService } = servicesManager.services;
const promptTrackNewStudy = customizationService.getCustomization(
'measurement.promptTrackNewStudy'
);
return promptTrackNewStudy({ servicesManager, extensionManager }, ctx, evt);
};
const promptLabelAnnotationWrapper = ({ servicesManager }, ctx, evt) => {
const { customizationService } = servicesManager.services;
const promptLabelAnnotation = customizationService.getCustomization(
'measurement.promptLabelAnnotation'
);
return promptLabelAnnotation({ servicesManager }, ctx, evt);
};
const promptSaveReportWrapper = (
{ servicesManager, commandsManager, extensionManager },
ctx,
evt
) => {
const { customizationService } = servicesManager.services;
const promptSaveReport = customizationService.getCustomization('measurement.promptSaveReport');
return promptSaveReport({ servicesManager, commandsManager, extensionManager }, ctx, evt);
};
const promptHasDirtyAnnotationsWrapper = (
{ servicesManager, commandsManager, extensionManager },
ctx,
evt
) => {
const { customizationService } = servicesManager.services;
const promptHasDirtyAnnotations = customizationService.getCustomization(
'measurement.promptHasDirtyAnnotations'
);
return promptHasDirtyAnnotations(
{ servicesManager, commandsManager, extensionManager },
ctx,
evt
);
};
export {
promptBeginTrackingWrapper,
promptHydrateStructuredReportWrapper,
promptTrackNewSeriesWrapper,
promptTrackNewStudyWrapper,
promptLabelAnnotationWrapper,
promptSaveReportWrapper,
promptHasDirtyAnnotationsWrapper,
};
@@ -0,0 +1,16 @@
import promptBeginTracking from '../contexts/TrackedMeasurementsContext/promptBeginTracking';
import promptHasDirtyAnnotations from '../contexts/TrackedMeasurementsContext/promptHasDirtyAnnotations';
import promptHydrateStructuredReport from '../contexts/TrackedMeasurementsContext/promptHydrateStructuredReport';
import promptTrackNewSeries from '../contexts/TrackedMeasurementsContext/promptTrackNewSeries';
import promptTrackNewStudy from '../contexts/TrackedMeasurementsContext/promptTrackNewStudy';
import { promptLabelAnnotation, promptSaveReport } from '@ohif/extension-default';
export default {
'measurement.promptBeginTracking': promptBeginTracking,
'measurement.promptHydrateStructuredReport': promptHydrateStructuredReport,
'measurement.promptTrackNewSeries': promptTrackNewSeries,
'measurement.promptTrackNewStudy': promptTrackNewStudy,
'measurement.promptLabelAnnotation': promptLabelAnnotation,
'measurement.promptSaveReport': promptSaveReport,
'measurement.promptHasDirtyAnnotations': promptHasDirtyAnnotations,
};
@@ -0,0 +1,64 @@
import { measurementTrackingMode } from '../contexts/TrackedMeasurementsContext/promptBeginTracking';
type CheckHasDirtyAndSimplifiedModeProps = {
servicesManager: AppTypes.ServicesManager;
appConfig: AppTypes.Config;
displaySetInstanceUID: string;
};
const onDoubleClickHandler = {
callbacks: [
({ activeViewportId, servicesManager, isHangingProtocolLayout, appConfig }) =>
async displaySetInstanceUID => {
const { hangingProtocolService, viewportGridService, uiNotificationService } =
servicesManager.services;
let updatedViewports = [];
const viewportId = activeViewportId;
const haveDirtyMeasurementsInSimplifiedMode = checkHasDirtyAndSimplifiedMode({
servicesManager,
appConfig,
displaySetInstanceUID,
});
try {
if (!haveDirtyMeasurementsInSimplifiedMode) {
updatedViewports = hangingProtocolService.getViewportsRequireUpdate(
viewportId,
displaySetInstanceUID,
isHangingProtocolLayout
);
viewportGridService.setDisplaySetsForViewports(updatedViewports);
}
} 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,
});
}
},
],
};
const customOnDropHandlerCallback = async props => {
const handled = checkHasDirtyAndSimplifiedMode(props);
return Promise.resolve({ handled });
};
const checkHasDirtyAndSimplifiedMode = (props: CheckHasDirtyAndSimplifiedModeProps) => {
const { servicesManager, appConfig, displaySetInstanceUID } = props;
const simplifiedMode = appConfig.measurementTrackingMode === measurementTrackingMode.SIMPLIFIED;
const { measurementService, displaySetService } = servicesManager.services;
const measurements = measurementService.getMeasurements();
const haveDirtyMeasurements =
measurements.some(m => m.isDirty) ||
(measurements.length && measurementService.getIsMeasurementDeletedIndividually());
const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
const hasDirtyAndSimplifiedMode =
displaySet.Modality === 'SR' && simplifiedMode && haveDirtyMeasurements;
return hasDirtyAndSimplifiedMode;
};
export { onDoubleClickHandler, customOnDropHandlerCallback };
@@ -0,0 +1,10 @@
import measurementTrackingPrompts from './customizations/measurementTrackingPrompts';
export default function getCustomizationModule() {
return [
{
name: 'default',
value: { ...measurementTrackingPrompts },
},
];
}
+18 -2
View File
@@ -6,6 +6,12 @@ import getViewportModule from './getViewportModule';
import { id } from './id.js';
import { ViewportActionButton } from '@ohif/ui';
import i18n from '@ohif/i18n';
import { measurementTrackingMode } from './contexts/TrackedMeasurementsContext/promptBeginTracking';
import getCustomizationModule from './getCustomizationModule';
import {
onDoubleClickHandler,
customOnDropHandlerCallback,
} from './customizations/studyBrowserCustomization';
const measurementTrackingExtension = {
/**
@@ -18,8 +24,15 @@ const measurementTrackingExtension = {
getViewportModule,
onModeEnter({ servicesManager }) {
const { toolbarService } = servicesManager.services;
const { toolbarService, customizationService } = servicesManager.services;
customizationService.setCustomizations({
'studyBrowser.thumbnailDoubleClickCallback': {
$set: onDoubleClickHandler,
},
customOnDropHandler: {
$set: customOnDropHandlerCallback,
},
});
toolbarService.addButtons(
[
{
@@ -38,6 +51,9 @@ const measurementTrackingExtension = {
true // replace the button if it is already defined
);
},
getCustomizationModule,
};
export default measurementTrackingExtension;
export { measurementTrackingMode };
@@ -1,4 +1,5 @@
import React from 'react';
import { Dialog, ButtonEnums } from '@ohif/ui';
import { utils } from '@ohif/core';
import { AccordionTrigger, MeasurementTable, useViewportGrid } from '@ohif/ui-next';
import {
@@ -11,18 +12,40 @@ import {
} from '@ohif/extension-cornerstone';
import { useTrackedMeasurements } from '../getContextModule';
import { UntrackSeriesModal } from './PanelStudyBrowserTracking/untrackSeriesModal';
const { filterAnd, filterPlanarMeasurement, filterMeasurementsBySeriesUID } =
utils.MeasurementFilters;
function PanelMeasurementTableTracking(props) {
const [viewportGrid] = useViewportGrid();
const { measurementService, uiModalService } = props.servicesManager.services;
const [trackedMeasurements, sendTrackedMeasurementsEvent] = useTrackedMeasurements();
const { trackedStudy, trackedSeries } = trackedMeasurements.context;
const measurementFilter = trackedStudy
? filterAnd(filterPlanarMeasurement, filterMeasurementsBySeriesUID(trackedSeries))
: filterPlanarMeasurement;
const onUntrackConfirm = () => {
sendTrackedMeasurementsEvent('UNTRACK_ALL', {});
};
const onDelete = () => {
const hasDirtyMeasurements = measurementService
.getMeasurements()
.some(measurement => measurement.isDirty);
hasDirtyMeasurements
? uiModalService.show({
title: 'Untrack Study',
content: UntrackSeriesModal,
contentProps: {
onConfirm: onUntrackConfirm,
message: 'Are you sure you want to untrack study and delete all measurements?',
},
})
: onUntrackConfirm();
};
const EmptyComponent = () => (
<div data-cy="trackedMeasurements-panel">
<MeasurementTable
@@ -43,6 +66,7 @@ function PanelMeasurementTableTracking(props) {
measurementFilter,
});
},
onDelete,
};
const Header = props => (
@@ -51,7 +75,10 @@ function PanelMeasurementTableTracking(props) {
className="px-0"
>
<div data-cy="TrackingHeader">
<StudySummaryFromMetadata {...props} />
<StudySummaryFromMetadata
{...props}
actions={actions}
/>
</div>
</AccordionTrigger>
);
@@ -68,7 +95,7 @@ function PanelMeasurementTableTracking(props) {
key="trackingMeasurementsHeader"
asChild={true}
>
<Header key="trackingHeadChild" />
<Header key="trackingHeadChild" />
</AccordionGroup.Trigger>
<MeasurementsOrAdditionalFindings
key="measurementsOrAdditionalFindings"
@@ -1,9 +1,10 @@
import React from 'react';
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { useSystem } from '@ohif/core';
import PanelStudyBrowser from '@ohif/extension-default/src/Panels/StudyBrowser/PanelStudyBrowser';
import { UntrackSeriesModal } from './untrackSeriesModal';
import { useTrackedMeasurements } from '../../getContextModule';
import { useViewportGrid } from '@ohif/ui-next';
const thumbnailNoImageModalities = [
'SR',
@@ -27,10 +28,34 @@ export default function PanelStudyBrowserTracking({
dataSource,
}) {
const { servicesManager } = useSystem();
const { displaySetService, uiModalService, measurementService } = servicesManager.services;
const { displaySetService, uiModalService, measurementService, viewportGridService } =
servicesManager.services;
const [trackedMeasurements, sendTrackedMeasurementsEvent] = useTrackedMeasurements();
const { trackedSeries } = trackedMeasurements.context;
const checkDirtyMeasurements = displaySetInstanceUID => {
const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
if (displaySet.Modality === 'SR') {
const activeViewportId = viewportGridService.getActiveViewportId();
sendTrackedMeasurementsEvent('CHECK_DIRTY', {
viewportId: activeViewportId,
displaySetInstanceUID: displaySetInstanceUID,
});
}
};
useEffect(() => {
const subscriptionOndropFired = viewportGridService.subscribe(
viewportGridService.EVENTS.VIEWPORT_ONDROP_HANDLED,
({ eventData }) => {
checkDirtyMeasurements(eventData.displaySetInstanceUID);
}
);
return () => {
subscriptionOndropFired.unsubscribe();
};
}, []);
const onClickUntrack = displaySetInstanceUID => {
const onConfirm = () => {
const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
@@ -50,6 +75,7 @@ export default function PanelStudyBrowserTracking({
content: UntrackSeriesModal,
contentProps: {
onConfirm,
message: 'Are you sure you want to untrack this series?',
},
});
};
@@ -115,6 +141,7 @@ export default function PanelStudyBrowserTracking({
dataSource={dataSource}
customMapDisplaySets={mapDisplaySetsWithTracking}
onClickUntrack={onClickUntrack}
onDoubleClickThumbnailHandlerCallBack={checkDirtyMeasurements}
/>
);
}
@@ -1,11 +1,11 @@
import React from 'react';
import { FooterAction } from '@ohif/ui-next';
export function UntrackSeriesModal({ hide, onConfirm }) {
export function UntrackSeriesModal({ hide, onConfirm, message }) {
return (
<div className="text-foreground">
<div>
<p>Are you sure you want to untrack this series?</p>
<p>{message}</p>
<p className="mt-2">
This action cannot be undone and will delete all your existing measurements.
</p>