init
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
import React, { useContext, useEffect, useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Machine } from 'xstate';
|
||||
import { useMachine } from '@xstate/react';
|
||||
import { useViewportGrid } from '@ohif/ui';
|
||||
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 hydrateStructuredReport from './hydrateStructuredReport';
|
||||
import { useAppConfig } from '@state';
|
||||
|
||||
const TrackedMeasurementsContext = React.createContext();
|
||||
TrackedMeasurementsContext.displayName = 'TrackedMeasurementsContext';
|
||||
const useTrackedMeasurements = () => useContext(TrackedMeasurementsContext);
|
||||
|
||||
const SR_SOPCLASSHANDLERID = '@ohif/extension-cornerstone-dicom-sr.sopClassHandlerModule.dicom-sr';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} param0
|
||||
*/
|
||||
function TrackedMeasurementsContextProvider(
|
||||
{ servicesManager, commandsManager, extensionManager }: withAppTypes, // Bound by consumer
|
||||
{ children } // Component props
|
||||
) {
|
||||
const [appConfig] = useAppConfig();
|
||||
|
||||
const [viewportGrid, viewportGridService] = useViewportGrid();
|
||||
const { activeViewportId, viewports } = viewportGrid;
|
||||
const { measurementService, displaySetService, customizationService } = servicesManager.services;
|
||||
|
||||
const machineOptions = Object.assign({}, defaultOptions);
|
||||
machineOptions.actions = Object.assign({}, machineOptions.actions, {
|
||||
jumpToFirstMeasurementInActiveViewport: (ctx, evt) => {
|
||||
const { trackedStudy, trackedSeries, activeViewportId } = ctx;
|
||||
const measurements = measurementService.getMeasurements();
|
||||
const trackedMeasurements = measurements.filter(
|
||||
m => trackedStudy === m.referenceStudyUID && trackedSeries.includes(m.referenceSeriesUID)
|
||||
);
|
||||
|
||||
console.log(
|
||||
'jumping to measurement reset viewport',
|
||||
activeViewportId,
|
||||
trackedMeasurements[0]
|
||||
);
|
||||
|
||||
const referencedDisplaySetUID = trackedMeasurements[0].displaySetInstanceUID;
|
||||
const referencedDisplaySet = displaySetService.getDisplaySetByUID(referencedDisplaySetUID);
|
||||
|
||||
const referencedImages = referencedDisplaySet.images;
|
||||
const isVolumeIdReferenced = referencedImages[0].imageId.startsWith('volumeId');
|
||||
|
||||
const measurementData = trackedMeasurements[0].data;
|
||||
|
||||
let imageIndex = 0;
|
||||
if (!isVolumeIdReferenced && measurementData) {
|
||||
// if it is imageId referenced find the index of the imageId, we don't have
|
||||
// support for volumeId referenced images yet
|
||||
imageIndex = referencedImages.findIndex(image => {
|
||||
const imageIdToUse = Object.keys(measurementData)[0].substring(8);
|
||||
return image.imageId === imageIdToUse;
|
||||
});
|
||||
|
||||
if (imageIndex === -1) {
|
||||
console.warn('Could not find image index for tracked measurement, using 0');
|
||||
imageIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
viewportGridService.setDisplaySetsForViewport({
|
||||
viewportId: activeViewportId,
|
||||
displaySetInstanceUIDs: [referencedDisplaySetUID],
|
||||
viewportOptions: {
|
||||
initialImageOptions: {
|
||||
index: imageIndex,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
jumpToSameImageInActiveViewport: (ctx, evt) => {
|
||||
const { trackedStudy, trackedSeries, activeViewportId } = ctx;
|
||||
const measurements = measurementService.getMeasurements();
|
||||
const trackedMeasurements = measurements.filter(
|
||||
m => trackedStudy === m.referenceStudyUID && trackedSeries.includes(m.referenceSeriesUID)
|
||||
);
|
||||
|
||||
const trackedMeasurement = trackedMeasurements[0];
|
||||
const referencedDisplaySetUID = trackedMeasurement.displaySetInstanceUID;
|
||||
|
||||
// update the previously stored positionPresentation with the new viewportId
|
||||
// presentation so that when we put the referencedDisplaySet back in the viewport
|
||||
// it will be in the correct position zoom and pan
|
||||
commandsManager.runCommand('updateStoredPositionPresentation', {
|
||||
viewportId: activeViewportId,
|
||||
displaySetInstanceUID: referencedDisplaySetUID,
|
||||
});
|
||||
|
||||
viewportGridService.setDisplaySetsForViewport({
|
||||
viewportId: activeViewportId,
|
||||
displaySetInstanceUIDs: [referencedDisplaySetUID],
|
||||
});
|
||||
},
|
||||
showStructuredReportDisplaySetInActiveViewport: (ctx, evt) => {
|
||||
if (evt.data.createdDisplaySetInstanceUIDs.length > 0) {
|
||||
const StructuredReportDisplaySetInstanceUID = evt.data.createdDisplaySetInstanceUIDs[0];
|
||||
|
||||
viewportGridService.setDisplaySetsForViewport({
|
||||
viewportId: evt.data.viewportId,
|
||||
displaySetInstanceUIDs: [StructuredReportDisplaySetInstanceUID],
|
||||
});
|
||||
}
|
||||
},
|
||||
discardPreviouslyTrackedMeasurements: (ctx, evt) => {
|
||||
const measurements = measurementService.getMeasurements();
|
||||
const filteredMeasurements = measurements.filter(ms =>
|
||||
ctx.prevTrackedSeries.includes(ms.referenceSeriesUID)
|
||||
);
|
||||
const measurementIds = filteredMeasurements.map(fm => fm.id);
|
||||
|
||||
for (let i = 0; i < measurementIds.length; i++) {
|
||||
measurementService.remove(measurementIds[i]);
|
||||
}
|
||||
},
|
||||
clearAllMeasurements: (ctx, evt) => {
|
||||
const measurements = measurementService.getMeasurements();
|
||||
const measurementIds = measurements.map(fm => fm.uid);
|
||||
|
||||
for (let i = 0; i < measurementIds.length; i++) {
|
||||
measurementService.remove(measurementIds[i]);
|
||||
}
|
||||
},
|
||||
});
|
||||
machineOptions.services = Object.assign({}, machineOptions.services, {
|
||||
promptBeginTracking: promptBeginTracking.bind(null, {
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
appConfig,
|
||||
}),
|
||||
promptTrackNewSeries: promptTrackNewSeries.bind(null, {
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
appConfig,
|
||||
}),
|
||||
promptTrackNewStudy: promptTrackNewStudy.bind(null, {
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
appConfig,
|
||||
}),
|
||||
promptSaveReport: promptSaveReport.bind(null, {
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
extensionManager,
|
||||
appConfig,
|
||||
}),
|
||||
promptHydrateStructuredReport: promptHydrateStructuredReport.bind(null, {
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
appConfig,
|
||||
}),
|
||||
hydrateStructuredReport: hydrateStructuredReport.bind(null, {
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
appConfig,
|
||||
}),
|
||||
promptLabelAnnotation: promptLabelAnnotation.bind(null, {
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
}),
|
||||
});
|
||||
machineOptions.guards = Object.assign({}, machineOptions.guards, {
|
||||
isLabelOnMeasure: (ctx, evt, condMeta) => {
|
||||
const labelConfig = customizationService.get('measurementLabels');
|
||||
return labelConfig?.labelOnMeasure;
|
||||
},
|
||||
isLabelOnMeasureAndShouldKillMachine: (ctx, evt, condMeta) => {
|
||||
const labelConfig = customizationService.get('measurementLabels');
|
||||
return evt.data && evt.data.userResponse === RESPONSE.NO_NEVER && labelConfig?.labelOnMeasure;
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: IMPROVE
|
||||
// - Add measurement_updated to cornerstone; debounced? (ext side, or consumption?)
|
||||
// - Friendlier transition/api in front of measurementTracking machine?
|
||||
// - Blocked: viewport overlay shouldn't clip when resized
|
||||
// TODO: PRIORITY
|
||||
// - Fix "ellipses" series description dynamic truncate length
|
||||
// - Fix viewport border resize
|
||||
// - created/destroyed hooks for extensions (cornerstone measurement subscriptions in it's `init`)
|
||||
|
||||
const measurementTrackingMachine = useMemo(() => {
|
||||
return Machine(machineConfiguration, machineOptions);
|
||||
}, []); // Empty dependency array ensures this is only created once
|
||||
|
||||
const [trackedMeasurements, sendTrackedMeasurementsEvent] = useMachine(
|
||||
measurementTrackingMachine
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Update the state machine with the active viewport ID
|
||||
sendTrackedMeasurementsEvent('UPDATE_ACTIVE_VIEWPORT_ID', {
|
||||
activeViewportId,
|
||||
});
|
||||
}, [activeViewportId, sendTrackedMeasurementsEvent]);
|
||||
|
||||
// ~~ Listen for changes to ViewportGrid for potential SRs hung in panes when idle
|
||||
useEffect(() => {
|
||||
const triggerPromptHydrateFlow = async () => {
|
||||
if (viewports.size > 0) {
|
||||
const activeViewport = viewports.get(activeViewportId);
|
||||
|
||||
if (!activeViewport || !activeViewport?.displaySetInstanceUIDs?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Todo: Getting the first displaySetInstanceUID is wrong, but we don't have
|
||||
// tracking fusion viewports yet. This should change when we do.
|
||||
const { displaySetService } = servicesManager.services;
|
||||
const displaySet = displaySetService.getDisplaySetByUID(
|
||||
activeViewport.displaySetInstanceUIDs[0]
|
||||
);
|
||||
|
||||
if (!displaySet) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If this is an SR produced by our SR SOPClassHandler,
|
||||
// and it hasn't been loaded yet, do that now so we
|
||||
// can check if it can be rehydrated or not.
|
||||
//
|
||||
// Note: This happens:
|
||||
// - If the viewport is not currently an OHIFCornerstoneSRViewport
|
||||
// - If the displaySet has never been hung
|
||||
//
|
||||
// Otherwise, the displaySet will be loaded by the useEffect handler
|
||||
// listening to displaySet changes inside OHIFCornerstoneSRViewport.
|
||||
// The issue here is that this handler in TrackedMeasurementsContext
|
||||
// ends up occurring before the Viewport is created, so the displaySet
|
||||
// is not loaded yet, and isRehydratable is undefined unless we call load().
|
||||
if (
|
||||
displaySet.SOPClassHandlerId === SR_SOPCLASSHANDLERID &&
|
||||
!displaySet.isLoaded &&
|
||||
displaySet.load
|
||||
) {
|
||||
await displaySet.load();
|
||||
}
|
||||
|
||||
// Magic string
|
||||
// load function added by our sopClassHandler module
|
||||
if (
|
||||
displaySet.SOPClassHandlerId === SR_SOPCLASSHANDLERID &&
|
||||
displaySet.isRehydratable === true
|
||||
) {
|
||||
console.log('sending event...', trackedMeasurements);
|
||||
sendTrackedMeasurementsEvent('PROMPT_HYDRATE_SR', {
|
||||
displaySetInstanceUID: displaySet.displaySetInstanceUID,
|
||||
SeriesInstanceUID: displaySet.SeriesInstanceUID,
|
||||
viewportId: activeViewportId,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
triggerPromptHydrateFlow();
|
||||
}, [
|
||||
trackedMeasurements,
|
||||
activeViewportId,
|
||||
sendTrackedMeasurementsEvent,
|
||||
servicesManager.services,
|
||||
viewports,
|
||||
]);
|
||||
|
||||
return (
|
||||
<TrackedMeasurementsContext.Provider
|
||||
value={[trackedMeasurements, sendTrackedMeasurementsEvent]}
|
||||
>
|
||||
{children}
|
||||
</TrackedMeasurementsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
TrackedMeasurementsContextProvider.propTypes = {
|
||||
children: PropTypes.oneOf([PropTypes.func, PropTypes.node]),
|
||||
servicesManager: PropTypes.object.isRequired,
|
||||
commandsManager: PropTypes.object.isRequired,
|
||||
extensionManager: PropTypes.object.isRequired,
|
||||
appConfig: PropTypes.object,
|
||||
};
|
||||
|
||||
export { TrackedMeasurementsContext, TrackedMeasurementsContextProvider, useTrackedMeasurements };
|
||||
@@ -0,0 +1,31 @@
|
||||
import { hydrateStructuredReport as baseHydrateStructuredReport } from '@ohif/extension-cornerstone-dicom-sr';
|
||||
|
||||
function hydrateStructuredReport(
|
||||
{ servicesManager, extensionManager, appConfig }: withAppTypes,
|
||||
ctx,
|
||||
evt
|
||||
) {
|
||||
const { displaySetService } = servicesManager.services;
|
||||
const { viewportId, displaySetInstanceUID } = evt;
|
||||
const srDisplaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const hydrationResult = baseHydrateStructuredReport(
|
||||
{ servicesManager, extensionManager, appConfig },
|
||||
displaySetInstanceUID
|
||||
);
|
||||
|
||||
const StudyInstanceUID = hydrationResult.StudyInstanceUID;
|
||||
const SeriesInstanceUIDs = hydrationResult.SeriesInstanceUIDs;
|
||||
|
||||
resolve({
|
||||
displaySetInstanceUID: evt.displaySetInstanceUID,
|
||||
srSeriesInstanceUID: srDisplaySet.SeriesInstanceUID,
|
||||
viewportId,
|
||||
StudyInstanceUID,
|
||||
SeriesInstanceUIDs,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default hydrateStructuredReport;
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
TrackedMeasurementsContext,
|
||||
TrackedMeasurementsContextProvider,
|
||||
useTrackedMeasurements,
|
||||
} from './TrackedMeasurementsContext.tsx';
|
||||
@@ -0,0 +1,490 @@
|
||||
import { assign } from 'xstate';
|
||||
|
||||
const RESPONSE = {
|
||||
NO_NEVER: -1,
|
||||
CANCEL: 0,
|
||||
CREATE_REPORT: 1,
|
||||
ADD_SERIES: 2,
|
||||
SET_STUDY_AND_SERIES: 3,
|
||||
NO_NOT_FOR_SERIES: 4,
|
||||
HYDRATE_REPORT: 5,
|
||||
};
|
||||
|
||||
const machineConfiguration = {
|
||||
id: 'measurementTracking',
|
||||
initial: 'idle',
|
||||
context: {
|
||||
activeViewportId: null,
|
||||
trackedStudy: '',
|
||||
trackedSeries: [],
|
||||
ignoredSeries: [],
|
||||
//
|
||||
prevTrackedStudy: '',
|
||||
prevTrackedSeries: [],
|
||||
prevIgnoredSeries: [],
|
||||
//
|
||||
ignoredSRSeriesForHydration: [],
|
||||
isDirty: false,
|
||||
},
|
||||
states: {
|
||||
off: {
|
||||
type: 'final',
|
||||
},
|
||||
labellingOnly: {
|
||||
on: {
|
||||
TRACK_SERIES: [
|
||||
{
|
||||
target: 'promptLabelAnnotation',
|
||||
actions: ['setPreviousState'],
|
||||
},
|
||||
{
|
||||
target: 'off',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
idle: {
|
||||
entry: 'clearContext',
|
||||
on: {
|
||||
TRACK_SERIES: [
|
||||
{
|
||||
target: 'promptLabelAnnotation',
|
||||
cond: 'isLabelOnMeasure',
|
||||
actions: ['setPreviousState'],
|
||||
},
|
||||
{
|
||||
target: 'promptBeginTracking',
|
||||
actions: ['setPreviousState'],
|
||||
},
|
||||
],
|
||||
// Unused? We may only do PROMPT_HYDRATE_SR now?
|
||||
SET_TRACKED_SERIES: [
|
||||
{
|
||||
target: 'tracking',
|
||||
actions: ['setTrackedStudyAndMultipleSeries', 'setIsDirtyToClean'],
|
||||
},
|
||||
],
|
||||
PROMPT_HYDRATE_SR: {
|
||||
target: 'promptHydrateStructuredReport',
|
||||
cond: 'hasNotIgnoredSRSeriesForHydration',
|
||||
},
|
||||
RESTORE_PROMPT_HYDRATE_SR: 'promptHydrateStructuredReport',
|
||||
HYDRATE_SR: 'hydrateStructuredReport',
|
||||
UPDATE_ACTIVE_VIEWPORT_ID: {
|
||||
actions: assign({
|
||||
activeViewportId: (_, event) => event.activeViewportId,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
promptBeginTracking: {
|
||||
invoke: {
|
||||
src: 'promptBeginTracking',
|
||||
onDone: [
|
||||
{
|
||||
target: 'tracking',
|
||||
actions: ['setTrackedStudyAndSeries', 'setIsDirty'],
|
||||
cond: 'shouldSetStudyAndSeries',
|
||||
},
|
||||
{
|
||||
target: 'labellingOnly',
|
||||
cond: 'isLabelOnMeasureAndShouldKillMachine',
|
||||
},
|
||||
{
|
||||
target: 'off',
|
||||
cond: 'shouldKillMachine',
|
||||
},
|
||||
{
|
||||
target: 'idle',
|
||||
},
|
||||
],
|
||||
onError: {
|
||||
target: 'idle',
|
||||
},
|
||||
},
|
||||
},
|
||||
tracking: {
|
||||
on: {
|
||||
TRACK_SERIES: [
|
||||
{
|
||||
target: 'promptLabelAnnotation',
|
||||
cond: 'isLabelOnMeasure',
|
||||
actions: ['setPreviousState'],
|
||||
},
|
||||
{
|
||||
target: 'promptTrackNewStudy',
|
||||
cond: 'isNewStudy',
|
||||
},
|
||||
{
|
||||
target: 'promptTrackNewSeries',
|
||||
cond: 'isNewSeries',
|
||||
},
|
||||
],
|
||||
UNTRACK_SERIES: [
|
||||
{
|
||||
target: 'tracking',
|
||||
actions: ['removeTrackedSeries', 'setIsDirty'],
|
||||
cond: 'hasRemainingTrackedSeries',
|
||||
},
|
||||
{
|
||||
target: 'idle',
|
||||
},
|
||||
],
|
||||
SET_TRACKED_SERIES: [
|
||||
{
|
||||
target: 'tracking',
|
||||
actions: ['setTrackedStudyAndMultipleSeries'],
|
||||
},
|
||||
],
|
||||
SAVE_REPORT: 'promptSaveReport',
|
||||
SET_DIRTY: [
|
||||
{
|
||||
target: 'tracking',
|
||||
actions: ['setIsDirty'],
|
||||
cond: 'shouldSetDirty',
|
||||
},
|
||||
{
|
||||
target: 'tracking',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
promptTrackNewSeries: {
|
||||
invoke: {
|
||||
src: 'promptTrackNewSeries',
|
||||
onDone: [
|
||||
{
|
||||
target: 'tracking',
|
||||
actions: ['addTrackedSeries', 'setIsDirty'],
|
||||
cond: 'shouldAddSeries',
|
||||
},
|
||||
{
|
||||
target: 'tracking',
|
||||
actions: [
|
||||
'discardPreviouslyTrackedMeasurements',
|
||||
'setTrackedStudyAndSeries',
|
||||
'setIsDirty',
|
||||
],
|
||||
cond: 'shouldSetStudyAndSeries',
|
||||
},
|
||||
{
|
||||
target: 'promptSaveReport',
|
||||
cond: 'shouldPromptSaveReport',
|
||||
},
|
||||
{
|
||||
target: 'tracking',
|
||||
},
|
||||
],
|
||||
onError: {
|
||||
target: 'idle',
|
||||
},
|
||||
},
|
||||
},
|
||||
promptTrackNewStudy: {
|
||||
invoke: {
|
||||
src: 'promptTrackNewStudy',
|
||||
onDone: [
|
||||
{
|
||||
target: 'tracking',
|
||||
actions: [
|
||||
'discardPreviouslyTrackedMeasurements',
|
||||
'setTrackedStudyAndSeries',
|
||||
'setIsDirty',
|
||||
],
|
||||
cond: 'shouldSetStudyAndSeries',
|
||||
},
|
||||
{
|
||||
target: 'tracking',
|
||||
actions: ['ignoreSeries'],
|
||||
cond: 'shouldAddIgnoredSeries',
|
||||
},
|
||||
{
|
||||
target: 'promptSaveReport',
|
||||
cond: 'shouldPromptSaveReport',
|
||||
},
|
||||
{
|
||||
target: 'tracking',
|
||||
},
|
||||
],
|
||||
onError: {
|
||||
target: 'idle',
|
||||
},
|
||||
},
|
||||
},
|
||||
promptSaveReport: {
|
||||
invoke: {
|
||||
src: 'promptSaveReport',
|
||||
onDone: [
|
||||
// "clicked the save button"
|
||||
// - should clear all measurements
|
||||
// - show DICOM SR
|
||||
{
|
||||
target: 'idle',
|
||||
actions: ['clearAllMeasurements', 'showStructuredReportDisplaySetInActiveViewport'],
|
||||
cond: 'shouldSaveAndContinueWithSameReport',
|
||||
},
|
||||
// "starting a new report"
|
||||
// - remove "just saved" measurements
|
||||
// - start tracking a new study + report
|
||||
{
|
||||
target: 'tracking',
|
||||
actions: ['discardPreviouslyTrackedMeasurements', 'setTrackedStudyAndSeries'],
|
||||
cond: 'shouldSaveAndStartNewReport',
|
||||
},
|
||||
// Cancel, back to tracking
|
||||
{
|
||||
target: 'tracking',
|
||||
},
|
||||
],
|
||||
onError: {
|
||||
target: 'idle',
|
||||
},
|
||||
},
|
||||
},
|
||||
promptHydrateStructuredReport: {
|
||||
invoke: {
|
||||
src: 'promptHydrateStructuredReport',
|
||||
onDone: [
|
||||
{
|
||||
target: 'tracking',
|
||||
actions: [
|
||||
'setTrackedStudyAndMultipleSeries',
|
||||
'jumpToSameImageInActiveViewport',
|
||||
'setIsDirtyToClean',
|
||||
],
|
||||
cond: 'shouldHydrateStructuredReport',
|
||||
},
|
||||
{
|
||||
target: 'idle',
|
||||
actions: ['ignoreHydrationForSRSeries'],
|
||||
cond: 'shouldIgnoreHydrationForSR',
|
||||
},
|
||||
],
|
||||
onError: {
|
||||
target: 'idle',
|
||||
},
|
||||
},
|
||||
},
|
||||
hydrateStructuredReport: {
|
||||
invoke: {
|
||||
src: 'hydrateStructuredReport',
|
||||
onDone: [
|
||||
{
|
||||
target: 'tracking',
|
||||
actions: [
|
||||
'setTrackedStudyAndMultipleSeries',
|
||||
'jumpToSameImageInActiveViewport',
|
||||
'setIsDirtyToClean',
|
||||
],
|
||||
},
|
||||
],
|
||||
onError: {
|
||||
target: 'idle',
|
||||
},
|
||||
},
|
||||
},
|
||||
promptLabelAnnotation: {
|
||||
invoke: {
|
||||
src: 'promptLabelAnnotation',
|
||||
onDone: [
|
||||
{
|
||||
target: 'labellingOnly',
|
||||
cond: 'wasLabellingOnly',
|
||||
},
|
||||
{
|
||||
target: 'promptBeginTracking',
|
||||
cond: 'wasIdle',
|
||||
},
|
||||
{
|
||||
target: 'promptTrackNewStudy',
|
||||
cond: 'wasTrackingAndIsNewStudy',
|
||||
},
|
||||
{
|
||||
target: 'promptTrackNewSeries',
|
||||
cond: 'wasTrackingAndIsNewSeries',
|
||||
},
|
||||
{
|
||||
target: 'tracking',
|
||||
cond: 'wasTracking',
|
||||
},
|
||||
{
|
||||
target: 'off',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
strict: true,
|
||||
};
|
||||
|
||||
const defaultOptions = {
|
||||
services: {
|
||||
promptBeginTracking: (ctx, evt) => {
|
||||
// return { userResponse, StudyInstanceUID, SeriesInstanceUID }
|
||||
},
|
||||
promptTrackNewStudy: (ctx, evt) => {
|
||||
// return { userResponse, StudyInstanceUID, SeriesInstanceUID }
|
||||
},
|
||||
promptTrackNewSeries: (ctx, evt) => {
|
||||
// return { userResponse, StudyInstanceUID, SeriesInstanceUID }
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
discardPreviouslyTrackedMeasurements: (ctx, evt) => {
|
||||
console.log('discardPreviouslyTrackedMeasurements: not implemented');
|
||||
},
|
||||
clearAllMeasurements: (ctx, evt) => {
|
||||
console.log('clearAllMeasurements: not implemented');
|
||||
},
|
||||
jumpToFirstMeasurementInActiveViewport: (ctx, evt) => {
|
||||
console.warn('jumpToFirstMeasurementInActiveViewport: not implemented');
|
||||
},
|
||||
showStructuredReportDisplaySetInActiveViewport: (ctx, evt) => {
|
||||
console.warn('showStructuredReportDisplaySetInActiveViewport: not implemented');
|
||||
},
|
||||
clearContext: assign({
|
||||
trackedStudy: '',
|
||||
trackedSeries: [],
|
||||
ignoredSeries: [],
|
||||
prevTrackedStudy: '',
|
||||
prevTrackedSeries: [],
|
||||
prevIgnoredSeries: [],
|
||||
}),
|
||||
// Promise resolves w/ `evt.data.*`
|
||||
setTrackedStudyAndSeries: assign((ctx, evt) => ({
|
||||
prevTrackedStudy: ctx.trackedStudy,
|
||||
prevTrackedSeries: ctx.trackedSeries.slice(),
|
||||
prevIgnoredSeries: ctx.ignoredSeries.slice(),
|
||||
//
|
||||
trackedStudy: evt.data.StudyInstanceUID,
|
||||
trackedSeries: [evt.data.SeriesInstanceUID],
|
||||
ignoredSeries: [],
|
||||
})),
|
||||
setTrackedStudyAndMultipleSeries: assign((ctx, evt) => {
|
||||
const studyInstanceUID = evt.StudyInstanceUID || evt.data.StudyInstanceUID;
|
||||
const seriesInstanceUIDs = evt.SeriesInstanceUIDs || evt.data.SeriesInstanceUIDs;
|
||||
|
||||
return {
|
||||
prevTrackedStudy: ctx.trackedStudy,
|
||||
prevTrackedSeries: ctx.trackedSeries.slice(),
|
||||
prevIgnoredSeries: ctx.ignoredSeries.slice(),
|
||||
//
|
||||
trackedStudy: studyInstanceUID,
|
||||
trackedSeries: [...ctx.trackedSeries, ...seriesInstanceUIDs],
|
||||
ignoredSeries: [],
|
||||
};
|
||||
}),
|
||||
setIsDirtyToClean: assign((ctx, evt) => ({
|
||||
isDirty: false,
|
||||
})),
|
||||
setIsDirty: assign((ctx, evt) => ({
|
||||
isDirty: true,
|
||||
})),
|
||||
ignoreSeries: assign((ctx, evt) => ({
|
||||
prevIgnoredSeries: [...ctx.ignoredSeries],
|
||||
ignoredSeries: [...ctx.ignoredSeries, evt.data.SeriesInstanceUID],
|
||||
})),
|
||||
ignoreHydrationForSRSeries: assign((ctx, evt) => ({
|
||||
ignoredSRSeriesForHydration: [
|
||||
...ctx.ignoredSRSeriesForHydration,
|
||||
evt.data.srSeriesInstanceUID,
|
||||
],
|
||||
})),
|
||||
addTrackedSeries: assign((ctx, evt) => ({
|
||||
prevTrackedSeries: [...ctx.trackedSeries],
|
||||
trackedSeries: [...ctx.trackedSeries, evt.data.SeriesInstanceUID],
|
||||
})),
|
||||
removeTrackedSeries: assign((ctx, evt) => ({
|
||||
prevTrackedSeries: ctx.trackedSeries.slice().filter(ser => ser !== evt.SeriesInstanceUID),
|
||||
trackedSeries: ctx.trackedSeries.slice().filter(ser => ser !== evt.SeriesInstanceUID),
|
||||
})),
|
||||
setPreviousState: assign((ctx, evt, meta) => {
|
||||
return {
|
||||
prevState: meta.state.value,
|
||||
};
|
||||
}),
|
||||
},
|
||||
guards: {
|
||||
// We set dirty any time we performan an action that:
|
||||
// - Tracks a new study
|
||||
// - Tracks a new series
|
||||
// - Adds a measurement to an already tracked study/series
|
||||
//
|
||||
// We set clean any time we restore from an SR
|
||||
//
|
||||
// This guard/condition is specific to "new measurements"
|
||||
// to make sure we only track dirty when the new measurement is specific
|
||||
// to a series we're already tracking
|
||||
//
|
||||
// tl;dr
|
||||
// Any report change, that is not a hydration of an existing report, should
|
||||
// result in a "dirty" report
|
||||
//
|
||||
// Where dirty means there would be "loss of data" if we blew away measurements
|
||||
// without creating a new SR.
|
||||
shouldSetDirty: (ctx, evt) => {
|
||||
return (
|
||||
// When would this happen?
|
||||
evt.SeriesInstanceUID === undefined || ctx.trackedSeries.includes(evt.SeriesInstanceUID)
|
||||
);
|
||||
},
|
||||
wasLabellingOnly: (ctx, evt, condMeta) => {
|
||||
return ctx.prevState === 'labellingOnly';
|
||||
},
|
||||
wasIdle: (ctx, evt, condMeta) => {
|
||||
return ctx.prevState === 'idle';
|
||||
},
|
||||
wasTracking: (ctx, evt, condMeta) => {
|
||||
return ctx.prevState === 'tracking';
|
||||
},
|
||||
wasTrackingAndIsNewStudy: (ctx, evt, condMeta) => {
|
||||
return (
|
||||
ctx.prevState === 'tracking' &&
|
||||
!ctx.ignoredSeries.includes(evt.data.SeriesInstanceUID) &&
|
||||
ctx.trackedStudy !== evt.data.StudyInstanceUID
|
||||
);
|
||||
},
|
||||
wasTrackingAndIsNewSeries: (ctx, evt, condMeta) => {
|
||||
return (
|
||||
ctx.prevState === 'tracking' &&
|
||||
!ctx.ignoredSeries.includes(evt.data.SeriesInstanceUID) &&
|
||||
!ctx.trackedSeries.includes(evt.data.SeriesInstanceUID)
|
||||
);
|
||||
},
|
||||
|
||||
shouldKillMachine: (ctx, evt) => evt.data && evt.data.userResponse === RESPONSE.NO_NEVER,
|
||||
shouldAddSeries: (ctx, evt) => evt.data && evt.data.userResponse === RESPONSE.ADD_SERIES,
|
||||
shouldSetStudyAndSeries: (ctx, evt) =>
|
||||
evt.data && evt.data.userResponse === RESPONSE.SET_STUDY_AND_SERIES,
|
||||
shouldAddIgnoredSeries: (ctx, evt) =>
|
||||
evt.data && evt.data.userResponse === RESPONSE.NO_NOT_FOR_SERIES,
|
||||
shouldPromptSaveReport: (ctx, evt) =>
|
||||
evt.data && evt.data.userResponse === RESPONSE.CREATE_REPORT,
|
||||
shouldIgnoreHydrationForSR: (ctx, evt) => evt.data && evt.data.userResponse === RESPONSE.CANCEL,
|
||||
shouldSaveAndContinueWithSameReport: (ctx, evt) =>
|
||||
evt.data &&
|
||||
evt.data.userResponse === RESPONSE.CREATE_REPORT &&
|
||||
evt.data.isBackupSave === true,
|
||||
shouldSaveAndStartNewReport: (ctx, evt) =>
|
||||
evt.data &&
|
||||
evt.data.userResponse === RESPONSE.CREATE_REPORT &&
|
||||
evt.data.isBackupSave === false,
|
||||
shouldHydrateStructuredReport: (ctx, evt) =>
|
||||
evt.data && evt.data.userResponse === RESPONSE.HYDRATE_REPORT,
|
||||
// Has more than 1, or SeriesInstanceUID is not in list
|
||||
// --> Post removal would have non-empty trackedSeries array
|
||||
hasRemainingTrackedSeries: (ctx, evt) =>
|
||||
ctx.trackedSeries.length > 1 || !ctx.trackedSeries.includes(evt.SeriesInstanceUID),
|
||||
hasNotIgnoredSRSeriesForHydration: (ctx, evt) => {
|
||||
return !ctx.ignoredSRSeriesForHydration.includes(evt.SeriesInstanceUID);
|
||||
},
|
||||
isNewStudy: (ctx, evt) =>
|
||||
!ctx.ignoredSeries.includes(evt.SeriesInstanceUID) &&
|
||||
ctx.trackedStudy !== evt.StudyInstanceUID,
|
||||
isNewSeries: (ctx, evt) =>
|
||||
!ctx.ignoredSeries.includes(evt.SeriesInstanceUID) &&
|
||||
!ctx.trackedSeries.includes(evt.SeriesInstanceUID),
|
||||
},
|
||||
};
|
||||
|
||||
export { defaultOptions, machineConfiguration, RESPONSE };
|
||||
@@ -0,0 +1,82 @@
|
||||
import { ButtonEnums } from '@ohif/ui';
|
||||
import i18n from 'i18next';
|
||||
|
||||
const RESPONSE = {
|
||||
NO_NEVER: -1,
|
||||
CANCEL: 0,
|
||||
CREATE_REPORT: 1,
|
||||
ADD_SERIES: 2,
|
||||
SET_STUDY_AND_SERIES: 3,
|
||||
};
|
||||
|
||||
function promptBeginTracking({ servicesManager, extensionManager }, ctx, evt) {
|
||||
const { uiViewportDialogService } = 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);
|
||||
|
||||
resolve({
|
||||
userResponse: promptResult,
|
||||
StudyInstanceUID,
|
||||
SeriesInstanceUID,
|
||||
viewportId,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _askTrackMeasurements(uiViewportDialogService, viewportId) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
const message = i18n.t('MeasurementTable:Track measurements for this series?');
|
||||
const actions = [
|
||||
{
|
||||
id: 'prompt-begin-tracking-cancel',
|
||||
type: ButtonEnums.type.secondary,
|
||||
text: i18n.t('Common:No'),
|
||||
value: RESPONSE.CANCEL,
|
||||
},
|
||||
{
|
||||
id: 'prompt-begin-tracking-no-do-not-ask-again',
|
||||
type: ButtonEnums.type.secondary,
|
||||
text: i18n.t('MeasurementTable:No, do not ask again'),
|
||||
value: RESPONSE.NO_NEVER,
|
||||
},
|
||||
{
|
||||
id: 'prompt-begin-tracking-yes',
|
||||
type: ButtonEnums.type.primary,
|
||||
text: i18n.t('Common:Yes'),
|
||||
value: RESPONSE.SET_STUDY_AND_SERIES,
|
||||
},
|
||||
];
|
||||
const onSubmit = result => {
|
||||
uiViewportDialogService.hide();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
uiViewportDialogService.show({
|
||||
viewportId,
|
||||
id: 'measurement-tracking-prompt-begin-tracking',
|
||||
type: 'info',
|
||||
message,
|
||||
actions,
|
||||
onSubmit,
|
||||
onOutsideClick: () => {
|
||||
uiViewportDialogService.hide();
|
||||
resolve(RESPONSE.CANCEL);
|
||||
},
|
||||
onKeyPress: event => {
|
||||
if (event.key === 'Enter') {
|
||||
const action = actions.find(action => action.id === 'prompt-begin-tracking-yes');
|
||||
onSubmit(action.value);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default promptBeginTracking;
|
||||
@@ -0,0 +1,90 @@
|
||||
import { hydrateStructuredReport } from '@ohif/extension-cornerstone-dicom-sr';
|
||||
import { ButtonEnums } from '@ohif/ui';
|
||||
|
||||
const RESPONSE = {
|
||||
NO_NEVER: -1,
|
||||
CANCEL: 0,
|
||||
CREATE_REPORT: 1,
|
||||
ADD_SERIES: 2,
|
||||
SET_STUDY_AND_SERIES: 3,
|
||||
NO_NOT_FOR_SERIES: 4,
|
||||
HYDRATE_REPORT: 5,
|
||||
};
|
||||
|
||||
function promptHydrateStructuredReport({ servicesManager, extensionManager, appConfig }, ctx, evt) {
|
||||
const { uiViewportDialogService, displaySetService } = 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);
|
||||
|
||||
// Need to do action here... So we can set state...
|
||||
let StudyInstanceUID, SeriesInstanceUIDs;
|
||||
|
||||
if (promptResult === RESPONSE.HYDRATE_REPORT) {
|
||||
console.warn('!! HYDRATING STRUCTURED REPORT');
|
||||
const hydrationResult = hydrateStructuredReport(
|
||||
{ servicesManager, extensionManager, appConfig },
|
||||
displaySetInstanceUID
|
||||
);
|
||||
|
||||
StudyInstanceUID = hydrationResult.StudyInstanceUID;
|
||||
SeriesInstanceUIDs = hydrationResult.SeriesInstanceUIDs;
|
||||
}
|
||||
|
||||
resolve({
|
||||
userResponse: promptResult,
|
||||
displaySetInstanceUID: evt.displaySetInstanceUID,
|
||||
srSeriesInstanceUID: srDisplaySet.SeriesInstanceUID,
|
||||
viewportId,
|
||||
StudyInstanceUID,
|
||||
SeriesInstanceUIDs,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _askTrackMeasurements(uiViewportDialogService, viewportId) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
const message = 'Do you want to continue tracking measurements for this study?';
|
||||
const actions = [
|
||||
{
|
||||
id: 'no-hydrate',
|
||||
type: ButtonEnums.type.secondary,
|
||||
text: 'No',
|
||||
value: RESPONSE.CANCEL,
|
||||
},
|
||||
{
|
||||
id: 'yes-hydrate',
|
||||
type: ButtonEnums.type.primary,
|
||||
text: 'Yes',
|
||||
value: RESPONSE.HYDRATE_REPORT,
|
||||
},
|
||||
];
|
||||
const onSubmit = result => {
|
||||
uiViewportDialogService.hide();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
uiViewportDialogService.show({
|
||||
viewportId,
|
||||
type: 'info',
|
||||
message,
|
||||
actions,
|
||||
onSubmit,
|
||||
onOutsideClick: () => {
|
||||
uiViewportDialogService.hide();
|
||||
resolve(RESPONSE.CANCEL);
|
||||
},
|
||||
onKeyPress: event => {
|
||||
if (event.key === 'Enter') {
|
||||
const action = actions.find(action => action.value === RESPONSE.HYDRATE_REPORT);
|
||||
onSubmit(action.value);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default promptHydrateStructuredReport;
|
||||
@@ -0,0 +1,112 @@
|
||||
import { ButtonEnums } from '@ohif/ui';
|
||||
|
||||
const RESPONSE = {
|
||||
NO_NEVER: -1,
|
||||
CANCEL: 0,
|
||||
CREATE_REPORT: 1,
|
||||
ADD_SERIES: 2,
|
||||
SET_STUDY_AND_SERIES: 3,
|
||||
NO_NOT_FOR_SERIES: 4,
|
||||
};
|
||||
|
||||
function promptTrackNewSeries({ servicesManager, extensionManager }, ctx, evt) {
|
||||
const { UIViewportDialogService } = 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);
|
||||
|
||||
if (promptResult === RESPONSE.CREATE_REPORT) {
|
||||
promptResult = ctx.isDirty
|
||||
? await _askSaveDiscardOrCancel(UIViewportDialogService, viewportId)
|
||||
: RESPONSE.SET_STUDY_AND_SERIES;
|
||||
}
|
||||
|
||||
resolve({
|
||||
userResponse: promptResult,
|
||||
StudyInstanceUID,
|
||||
SeriesInstanceUID,
|
||||
viewportId,
|
||||
isBackupSave: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _askShouldAddMeasurements(uiViewportDialogService, viewportId) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
const message = 'Do you want to add this measurement to the existing report?';
|
||||
const actions = [
|
||||
{
|
||||
type: ButtonEnums.type.secondary,
|
||||
text: 'Cancel',
|
||||
value: RESPONSE.CANCEL,
|
||||
},
|
||||
{
|
||||
type: ButtonEnums.type.primary,
|
||||
text: 'Create new report',
|
||||
value: RESPONSE.CREATE_REPORT,
|
||||
},
|
||||
{
|
||||
type: ButtonEnums.type.primary,
|
||||
text: 'Add to existing report',
|
||||
value: RESPONSE.ADD_SERIES,
|
||||
},
|
||||
];
|
||||
const onSubmit = result => {
|
||||
uiViewportDialogService.hide();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
uiViewportDialogService.show({
|
||||
viewportId,
|
||||
type: 'info',
|
||||
message,
|
||||
actions,
|
||||
onSubmit,
|
||||
onOutsideClick: () => {
|
||||
uiViewportDialogService.hide();
|
||||
resolve(RESPONSE.CANCEL);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _askSaveDiscardOrCancel(UIViewportDialogService, 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 actions = [
|
||||
{ type: 'cancel', text: 'Cancel', value: RESPONSE.CANCEL },
|
||||
{
|
||||
type: 'secondary',
|
||||
text: 'Save',
|
||||
value: RESPONSE.CREATE_REPORT,
|
||||
},
|
||||
{
|
||||
type: 'primary',
|
||||
text: 'Discard',
|
||||
value: RESPONSE.SET_STUDY_AND_SERIES,
|
||||
},
|
||||
];
|
||||
const onSubmit = result => {
|
||||
UIViewportDialogService.hide();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
UIViewportDialogService.show({
|
||||
viewportId,
|
||||
type: 'warning',
|
||||
message,
|
||||
actions,
|
||||
onSubmit,
|
||||
onOutsideClick: () => {
|
||||
UIViewportDialogService.hide();
|
||||
resolve(RESPONSE.CANCEL);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default promptTrackNewSeries;
|
||||
@@ -0,0 +1,120 @@
|
||||
import i18n from 'i18next';
|
||||
|
||||
const RESPONSE = {
|
||||
NO_NEVER: -1,
|
||||
CANCEL: 0,
|
||||
CREATE_REPORT: 1,
|
||||
ADD_SERIES: 2,
|
||||
SET_STUDY_AND_SERIES: 3,
|
||||
NO_NOT_FOR_SERIES: 4,
|
||||
};
|
||||
|
||||
function promptTrackNewStudy({ servicesManager, extensionManager }: withAppTypes, ctx, evt) {
|
||||
const { uiViewportDialogService } = 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);
|
||||
|
||||
if (promptResult === RESPONSE.SET_STUDY_AND_SERIES) {
|
||||
promptResult = ctx.isDirty
|
||||
? await _askSaveDiscardOrCancel(uiViewportDialogService, viewportId)
|
||||
: RESPONSE.SET_STUDY_AND_SERIES;
|
||||
}
|
||||
|
||||
resolve({
|
||||
userResponse: promptResult,
|
||||
StudyInstanceUID,
|
||||
SeriesInstanceUID,
|
||||
viewportId,
|
||||
isBackupSave: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _askTrackMeasurements(
|
||||
UIViewportDialogService: AppTypes.UIViewportDialogService,
|
||||
viewportId
|
||||
) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
const message = i18n.t('MeasurementTable:Track measurements for this series?');
|
||||
const actions = [
|
||||
{ type: 'cancel', text: i18n.t('MeasurementTable:No'), value: RESPONSE.CANCEL },
|
||||
{
|
||||
type: 'secondary',
|
||||
text: i18n.t('MeasurementTable:No, do not ask again'),
|
||||
value: RESPONSE.NO_NOT_FOR_SERIES,
|
||||
},
|
||||
{
|
||||
type: 'primary',
|
||||
text: i18n.t('MeasurementTable:Yes'),
|
||||
value: RESPONSE.SET_STUDY_AND_SERIES,
|
||||
},
|
||||
];
|
||||
const onSubmit = result => {
|
||||
UIViewportDialogService.hide();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
UIViewportDialogService.show({
|
||||
viewportId,
|
||||
type: 'info',
|
||||
message,
|
||||
actions,
|
||||
onSubmit,
|
||||
onOutsideClick: () => {
|
||||
UIViewportDialogService.hide();
|
||||
resolve(RESPONSE.CANCEL);
|
||||
},
|
||||
onKeyPress: event => {
|
||||
if (event.key === 'Enter') {
|
||||
const action = actions.find(action => action.value === RESPONSE.SET_STUDY_AND_SERIES);
|
||||
onSubmit(action.value);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _askSaveDiscardOrCancel(
|
||||
UIViewportDialogService: AppTypes.UIViewportDialogService,
|
||||
viewportId
|
||||
) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
const message =
|
||||
'Measurements cannot span across multiple studies. Do you want to save your tracked measurements?';
|
||||
const actions = [
|
||||
{ type: 'cancel', text: 'Cancel', value: RESPONSE.CANCEL },
|
||||
{
|
||||
type: 'secondary',
|
||||
text: 'No, discard previously tracked series & measurements',
|
||||
value: RESPONSE.SET_STUDY_AND_SERIES,
|
||||
},
|
||||
{
|
||||
type: 'primary',
|
||||
text: 'Yes',
|
||||
value: RESPONSE.CREATE_REPORT,
|
||||
},
|
||||
];
|
||||
const onSubmit = result => {
|
||||
UIViewportDialogService.hide();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
UIViewportDialogService.show({
|
||||
viewportId,
|
||||
type: 'warning',
|
||||
message,
|
||||
actions,
|
||||
onSubmit,
|
||||
onOutsideClick: () => {
|
||||
UIViewportDialogService.hide();
|
||||
resolve(RESPONSE.CANCEL);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default promptTrackNewStudy;
|
||||
5
extensions/measurement-tracking/src/contexts/index.js
Normal file
5
extensions/measurement-tracking/src/contexts/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
TrackedMeasurementsContext,
|
||||
TrackedMeasurementsContextProvider,
|
||||
useTrackedMeasurements,
|
||||
} from './TrackedMeasurementsContext';
|
||||
24
extensions/measurement-tracking/src/getContextModule.tsx
Normal file
24
extensions/measurement-tracking/src/getContextModule.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
TrackedMeasurementsContext,
|
||||
TrackedMeasurementsContextProvider,
|
||||
useTrackedMeasurements,
|
||||
} from './contexts';
|
||||
|
||||
function getContextModule({ servicesManager, extensionManager, commandsManager }) {
|
||||
const BoundTrackedMeasurementsContextProvider = TrackedMeasurementsContextProvider.bind(null, {
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
commandsManager,
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'TrackedMeasurementsContext',
|
||||
context: TrackedMeasurementsContext,
|
||||
provider: BoundTrackedMeasurementsContextProvider,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export { useTrackedMeasurements };
|
||||
export default getContextModule;
|
||||
44
extensions/measurement-tracking/src/getPanelModule.tsx
Normal file
44
extensions/measurement-tracking/src/getPanelModule.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Types } from '@ohif/core';
|
||||
import { PanelMeasurementTableTracking, PanelStudyBrowserTracking } from './panels';
|
||||
import i18n from 'i18next';
|
||||
import React from 'react';
|
||||
|
||||
// TODO:
|
||||
// - No loading UI exists yet
|
||||
// - cancel promises when component is destroyed
|
||||
// - show errors in UI for thumbnails if promise fails
|
||||
|
||||
function getPanelModule({ commandsManager, extensionManager, servicesManager }): Types.Panel[] {
|
||||
return [
|
||||
{
|
||||
name: 'seriesList',
|
||||
iconName: 'tab-studies',
|
||||
iconLabel: 'Studies',
|
||||
label: i18n.t('SidePanel:Studies'),
|
||||
component: props => (
|
||||
<PanelStudyBrowserTracking
|
||||
{...props}
|
||||
commandsManager={commandsManager}
|
||||
extensionManager={extensionManager}
|
||||
servicesManager={servicesManager}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'trackedMeasurements',
|
||||
iconName: 'tab-linear',
|
||||
iconLabel: 'Measure',
|
||||
label: i18n.t('SidePanel:Measurements'),
|
||||
component: props => (
|
||||
<PanelMeasurementTableTracking
|
||||
{...props}
|
||||
commandsManager={commandsManager}
|
||||
extensionManager={extensionManager}
|
||||
servicesManager={servicesManager}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default getPanelModule;
|
||||
35
extensions/measurement-tracking/src/getViewportModule.tsx
Normal file
35
extensions/measurement-tracking/src/getViewportModule.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
|
||||
const Component = React.lazy(() => {
|
||||
return import(/* webpackPrefetch: true */ './viewports/TrackedCornerstoneViewport');
|
||||
});
|
||||
|
||||
const OHIFCornerstoneViewport = props => {
|
||||
return (
|
||||
<React.Suspense fallback={<div>Loading...</div>}>
|
||||
<Component {...props} />
|
||||
</React.Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
function getViewportModule({ servicesManager, commandsManager, extensionManager }) {
|
||||
const ExtendedOHIFCornerstoneTrackingViewport = props => {
|
||||
return (
|
||||
<OHIFCornerstoneViewport
|
||||
servicesManager={servicesManager}
|
||||
commandsManager={commandsManager}
|
||||
extensionManager={extensionManager}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'cornerstone-tracked',
|
||||
component: ExtendedOHIFCornerstoneTrackingViewport,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default getViewportModule;
|
||||
5
extensions/measurement-tracking/src/id.js
Normal file
5
extensions/measurement-tracking/src/id.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import packageJson from '../package.json';
|
||||
|
||||
const id = packageJson.name;
|
||||
|
||||
export { id };
|
||||
17
extensions/measurement-tracking/src/index.tsx
Normal file
17
extensions/measurement-tracking/src/index.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import getContextModule from './getContextModule';
|
||||
import getPanelModule from './getPanelModule';
|
||||
import getViewportModule from './getViewportModule';
|
||||
import { id } from './id.js';
|
||||
|
||||
const measurementTrackingExtension = {
|
||||
/**
|
||||
* Only required property. Should be a unique value across all extensions.
|
||||
*/
|
||||
id,
|
||||
|
||||
getContextModule,
|
||||
getPanelModule,
|
||||
getViewportModule,
|
||||
};
|
||||
|
||||
export default measurementTrackingExtension;
|
||||
@@ -0,0 +1,198 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { PanelMeasurement } from '@ohif/extension-cornerstone';
|
||||
import { useViewportGrid, ButtonEnums, Dialog } from '@ohif/ui';
|
||||
import { StudySummary } from '@ohif/ui-next';
|
||||
import { Button, Icons } from '@ohif/ui-next';
|
||||
import { DicomMetadataStore, utils } from '@ohif/core';
|
||||
import { useTrackedMeasurements } from '../getContextModule';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { downloadCSVReport, formatDate } = utils;
|
||||
|
||||
const DISPLAY_STUDY_SUMMARY_INITIAL_VALUE = {
|
||||
key: undefined, //
|
||||
date: '', // '07-Sep-2010',
|
||||
modality: '', // 'CT',
|
||||
description: '', // 'CHEST/ABD/PELVIS W CONTRAST',
|
||||
};
|
||||
|
||||
function PanelMeasurementTableTracking({
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
commandsManager,
|
||||
}: withAppTypes) {
|
||||
const [viewportGrid] = useViewportGrid();
|
||||
const { t } = useTranslation('MeasurementTable');
|
||||
const { measurementService, customizationService, uiDialogService } = servicesManager.services;
|
||||
const [trackedMeasurements, sendTrackedMeasurementsEvent] = useTrackedMeasurements();
|
||||
const { trackedStudy, trackedSeries } = trackedMeasurements.context;
|
||||
const [displayStudySummary, setDisplayStudySummary] = useState(
|
||||
DISPLAY_STUDY_SUMMARY_INITIAL_VALUE
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const updateDisplayStudySummary = async () => {
|
||||
if (trackedMeasurements.matches('tracking') && trackedStudy) {
|
||||
const studyMeta = DicomMetadataStore.getStudy(trackedStudy);
|
||||
if (!studyMeta || !studyMeta.series || studyMeta.series.length === 0) {
|
||||
console.debug('Study metadata not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const instanceMeta = studyMeta.series[0].instances[0];
|
||||
const { StudyDate, StudyDescription } = instanceMeta;
|
||||
|
||||
const modalities = new Set();
|
||||
studyMeta.series.forEach(series => {
|
||||
if (trackedSeries.includes(series.SeriesInstanceUID)) {
|
||||
modalities.add(series.instances[0].Modality);
|
||||
}
|
||||
});
|
||||
const modality = Array.from(modalities).join('/');
|
||||
|
||||
setDisplayStudySummary(prevSummary => {
|
||||
if (prevSummary.key !== trackedStudy) {
|
||||
return {
|
||||
key: trackedStudy,
|
||||
date: StudyDate,
|
||||
modality,
|
||||
description: StudyDescription,
|
||||
};
|
||||
}
|
||||
return prevSummary;
|
||||
});
|
||||
} else if (!trackedStudy) {
|
||||
setDisplayStudySummary(DISPLAY_STUDY_SUMMARY_INITIAL_VALUE);
|
||||
}
|
||||
};
|
||||
|
||||
updateDisplayStudySummary();
|
||||
}, [trackedMeasurements, trackedStudy, trackedSeries]);
|
||||
|
||||
const { disableEditing } = customizationService.getCustomization(
|
||||
'PanelMeasurement.disableEditing',
|
||||
{
|
||||
id: 'default.disableEditing',
|
||||
disableEditing: false,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{displayStudySummary.key && (
|
||||
<StudySummary
|
||||
date={formatDate(displayStudySummary.date)}
|
||||
description={displayStudySummary.description}
|
||||
/>
|
||||
)}
|
||||
<PanelMeasurement
|
||||
servicesManager={servicesManager}
|
||||
extensionManager={extensionManager}
|
||||
commandsManager={commandsManager}
|
||||
measurementFilter={measurement =>
|
||||
trackedStudy === measurement.referenceStudyUID &&
|
||||
trackedSeries.includes(measurement.referenceSeriesUID)
|
||||
}
|
||||
customHeader={({ additionalFindings, measurements }) => {
|
||||
const disabled = additionalFindings.length === 0 && measurements.length === 0;
|
||||
|
||||
if (disableEditing || disabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background flex h-9 w-full items-center rounded pr-0.5">
|
||||
<div className="flex space-x-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="pl-1.5"
|
||||
onClick={() => {
|
||||
const measurements = measurementService.getMeasurements();
|
||||
const trackedMeasurements = measurements.filter(
|
||||
m =>
|
||||
trackedStudy === m.referenceStudyUID &&
|
||||
trackedSeries.includes(m.referenceSeriesUID)
|
||||
);
|
||||
|
||||
downloadCSVReport(trackedMeasurements);
|
||||
}}
|
||||
>
|
||||
<Icons.Download className="h-5 w-5" />
|
||||
<span className="pl-1">CSV</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="pl-0.5"
|
||||
onClick={() => {
|
||||
sendTrackedMeasurementsEvent('SAVE_REPORT', {
|
||||
viewportId: viewportGrid.activeViewportId,
|
||||
isBackupSave: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icons.Add />
|
||||
Create SR
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="pl-0.5"
|
||||
onClick={() => {
|
||||
uiDialogService.create({
|
||||
id: 'delete-all-measurements',
|
||||
centralize: true,
|
||||
isDraggable: false,
|
||||
showOverlay: true,
|
||||
content: Dialog,
|
||||
contentProps: {
|
||||
title: 'Delete All Measurements',
|
||||
body: () => (
|
||||
<div className="bg-primary-dark text-white">
|
||||
<p>Are you sure you want to delete all measurements?</p>
|
||||
<p className="mt-2">This action cannot be undone.</p>
|
||||
</div>
|
||||
),
|
||||
actions: [
|
||||
{
|
||||
id: 'cancel',
|
||||
text: 'Cancel',
|
||||
type: ButtonEnums.type.secondary,
|
||||
},
|
||||
{
|
||||
id: 'yes',
|
||||
text: 'Delete All',
|
||||
type: ButtonEnums.type.primary,
|
||||
classes: ['delete-all-yes-button'],
|
||||
},
|
||||
],
|
||||
onClose: () => uiDialogService.dismiss({ id: 'delete-all-measurements' }),
|
||||
onSubmit: async ({ action }) => {
|
||||
switch (action.id) {
|
||||
case 'yes':
|
||||
measurementService.clearMeasurements();
|
||||
uiDialogService.dismiss({ id: 'delete-all-measurements' });
|
||||
break;
|
||||
case 'cancel':
|
||||
uiDialogService.dismiss({ id: 'delete-all-measurements' });
|
||||
break;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Icons.Delete />
|
||||
Delete All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
></PanelMeasurement>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelMeasurementTableTracking;
|
||||
@@ -0,0 +1,721 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PropTypes from 'prop-types';
|
||||
import { utils } from '@ohif/core';
|
||||
import { useImageViewer, useViewportGrid, Dialog, ButtonEnums } from '@ohif/ui';
|
||||
import { StudyBrowser } from '@ohif/ui-next';
|
||||
|
||||
import { useTrackedMeasurements } from '../../getContextModule';
|
||||
import { Separator } from '@ohif/ui-next';
|
||||
import { PanelStudyBrowserHeader } from '@ohif/extension-default';
|
||||
import { defaultActionIcons, defaultViewPresets } from './constants';
|
||||
|
||||
const { formatDate, createStudyBrowserTabs } = utils;
|
||||
const thumbnailNoImageModalities = [
|
||||
'SR',
|
||||
'SEG',
|
||||
'SM',
|
||||
'RTSTRUCT',
|
||||
'RTPLAN',
|
||||
'RTDOSE',
|
||||
'DOC',
|
||||
'OT',
|
||||
'PMAP',
|
||||
];
|
||||
/**
|
||||
*
|
||||
* @param {*} param0
|
||||
*/
|
||||
function PanelStudyBrowserTracking({
|
||||
servicesManager,
|
||||
getImageSrc,
|
||||
getStudiesForPatientByMRN,
|
||||
requestDisplaySetCreationForStudy,
|
||||
dataSource,
|
||||
commandsManager,
|
||||
}: withAppTypes) {
|
||||
const {
|
||||
displaySetService,
|
||||
uiDialogService,
|
||||
hangingProtocolService,
|
||||
uiNotificationService,
|
||||
measurementService,
|
||||
studyPrefetcherService,
|
||||
customizationService,
|
||||
} = servicesManager.services;
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { t } = useTranslation('Common');
|
||||
|
||||
// Normally you nest the components so the tree isn't so deep, and the data
|
||||
// doesn't have to have such an intense shape. This works well enough for now.
|
||||
// Tabs --> Studies --> DisplaySets --> Thumbnails
|
||||
const { StudyInstanceUIDs } = useImageViewer();
|
||||
const [{ activeViewportId, viewports, isHangingProtocolLayout }, viewportGridService] =
|
||||
useViewportGrid();
|
||||
const [trackedMeasurements, sendTrackedMeasurementsEvent] = useTrackedMeasurements();
|
||||
const [activeTabName, setActiveTabName] = useState('all');
|
||||
const [expandedStudyInstanceUIDs, setExpandedStudyInstanceUIDs] = useState([
|
||||
...StudyInstanceUIDs,
|
||||
]);
|
||||
const [studyDisplayList, setStudyDisplayList] = useState([]);
|
||||
const [hasLoadedViewports, setHasLoadedViewports] = useState(false);
|
||||
const [displaySets, setDisplaySets] = useState([]);
|
||||
const [displaySetsLoadingState, setDisplaySetsLoadingState] = useState({});
|
||||
const [thumbnailImageSrcMap, setThumbnailImageSrcMap] = useState({});
|
||||
const [jumpToDisplaySet, setJumpToDisplaySet] = useState(null);
|
||||
|
||||
const [viewPresets, setViewPresets] = useState(
|
||||
customizationService.getCustomization('studyBrowser.viewPresets')?.value || defaultViewPresets
|
||||
);
|
||||
|
||||
const [actionIcons, setActionIcons] = useState(defaultActionIcons);
|
||||
|
||||
const updateActionIconValue = actionIcon => {
|
||||
actionIcon.value = !actionIcon.value;
|
||||
const newActionIcons = [...actionIcons];
|
||||
setActionIcons(newActionIcons);
|
||||
};
|
||||
|
||||
const updateViewPresetValue = viewPreset => {
|
||||
if (!viewPreset) {
|
||||
return;
|
||||
}
|
||||
const newViewPresets = viewPresets.map(preset => {
|
||||
preset.selected = preset.id === viewPreset.id;
|
||||
return preset;
|
||||
});
|
||||
setViewPresets(newViewPresets);
|
||||
};
|
||||
|
||||
const onDoubleClickThumbnailHandler = displaySetInstanceUID => {
|
||||
let updatedViewports = [];
|
||||
const viewportId = activeViewportId;
|
||||
try {
|
||||
updatedViewports = hangingProtocolService.getViewportsRequireUpdate(
|
||||
viewportId,
|
||||
displaySetInstanceUID,
|
||||
isHangingProtocolLayout
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
uiNotificationService.show({
|
||||
title: 'Thumbnail Double Click',
|
||||
message:
|
||||
'The selected display sets could not be added to the viewport due to a mismatch in the Hanging Protocol rules.',
|
||||
type: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
|
||||
viewportGridService.setDisplaySetsForViewports(updatedViewports);
|
||||
};
|
||||
|
||||
const activeViewportDisplaySetInstanceUIDs =
|
||||
viewports.get(activeViewportId)?.displaySetInstanceUIDs;
|
||||
|
||||
const { trackedSeries } = trackedMeasurements.context;
|
||||
|
||||
// ~~ studyDisplayList
|
||||
useEffect(() => {
|
||||
// Fetch all studies for the patient in each primary study
|
||||
async function fetchStudiesForPatient(StudyInstanceUID) {
|
||||
// current study qido
|
||||
const qidoForStudyUID = await dataSource.query.studies.search({
|
||||
studyInstanceUid: StudyInstanceUID,
|
||||
});
|
||||
|
||||
if (!qidoForStudyUID?.length) {
|
||||
navigate('/notfoundstudy', '_self');
|
||||
throw new Error('Invalid study URL');
|
||||
}
|
||||
|
||||
let qidoStudiesForPatient = qidoForStudyUID;
|
||||
|
||||
// try to fetch the prior studies based on the patientID if the
|
||||
// server can respond.
|
||||
try {
|
||||
qidoStudiesForPatient = await getStudiesForPatientByMRN(qidoForStudyUID);
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
}
|
||||
|
||||
const mappedStudies = _mapDataSourceStudies(qidoStudiesForPatient);
|
||||
const actuallyMappedStudies = mappedStudies.map(qidoStudy => {
|
||||
return {
|
||||
studyInstanceUid: qidoStudy.StudyInstanceUID,
|
||||
date: formatDate(qidoStudy.StudyDate) || t('NoStudyDate'),
|
||||
description: qidoStudy.StudyDescription,
|
||||
modalities: qidoStudy.ModalitiesInStudy,
|
||||
numInstances: qidoStudy.NumInstances,
|
||||
};
|
||||
});
|
||||
|
||||
setStudyDisplayList(prevArray => {
|
||||
const ret = [...prevArray];
|
||||
for (const study of actuallyMappedStudies) {
|
||||
if (!prevArray.find(it => it.studyInstanceUid === study.studyInstanceUid)) {
|
||||
ret.push(study);
|
||||
}
|
||||
}
|
||||
return ret;
|
||||
});
|
||||
}
|
||||
|
||||
StudyInstanceUIDs.forEach(sid => fetchStudiesForPatient(sid));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [StudyInstanceUIDs, getStudiesForPatientByMRN]);
|
||||
|
||||
// ~~ Initial Thumbnails
|
||||
useEffect(() => {
|
||||
if (!hasLoadedViewports) {
|
||||
if (activeViewportId) {
|
||||
// Once there is an active viewport id, it means the layout is ready
|
||||
// so wait a bit of time to allow the viewports preferential loading
|
||||
// which improves user experience of responsiveness significantly on slower
|
||||
// systems.
|
||||
window.setTimeout(() => setHasLoadedViewports(true), 250);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let currentDisplaySets = displaySetService.activeDisplaySets;
|
||||
// filter non based on the list of modalities that are supported by cornerstone
|
||||
currentDisplaySets = currentDisplaySets.filter(
|
||||
ds => !thumbnailNoImageModalities.includes(ds.Modality)
|
||||
);
|
||||
|
||||
if (!currentDisplaySets.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentDisplaySets.forEach(async dSet => {
|
||||
const newImageSrcEntry = {};
|
||||
const displaySet = displaySetService.getDisplaySetByUID(dSet.displaySetInstanceUID);
|
||||
const imageIds = dataSource.getImageIdsForDisplaySet(displaySet);
|
||||
|
||||
const imageId = getImageIdForThumbnail(displaySet, imageIds);
|
||||
|
||||
// TODO: Is it okay that imageIds are not returned here for SR displaySets?
|
||||
if (!imageId || displaySet?.unsupported) {
|
||||
return;
|
||||
}
|
||||
// When the image arrives, render it and store the result in the thumbnailImgSrcMap
|
||||
newImageSrcEntry[dSet.displaySetInstanceUID] = await getImageSrc(imageId);
|
||||
|
||||
setThumbnailImageSrcMap(prevState => {
|
||||
return { ...prevState, ...newImageSrcEntry };
|
||||
});
|
||||
});
|
||||
}, [displaySetService, dataSource, getImageSrc, activeViewportId, hasLoadedViewports]);
|
||||
|
||||
// ~~ displaySets
|
||||
useEffect(() => {
|
||||
const currentDisplaySets = displaySetService.activeDisplaySets;
|
||||
|
||||
if (!currentDisplaySets.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mappedDisplaySets = _mapDisplaySets(
|
||||
currentDisplaySets,
|
||||
displaySetsLoadingState,
|
||||
thumbnailImageSrcMap,
|
||||
trackedSeries,
|
||||
viewports,
|
||||
viewportGridService,
|
||||
dataSource,
|
||||
displaySetService,
|
||||
uiDialogService,
|
||||
uiNotificationService
|
||||
);
|
||||
|
||||
setDisplaySets(mappedDisplaySets);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
displaySetService.activeDisplaySets,
|
||||
displaySetsLoadingState,
|
||||
trackedSeries,
|
||||
viewports,
|
||||
dataSource,
|
||||
thumbnailImageSrcMap,
|
||||
]);
|
||||
|
||||
// -- displaySetsLoadingState
|
||||
useEffect(() => {
|
||||
const { unsubscribe } = studyPrefetcherService.subscribe(
|
||||
studyPrefetcherService.EVENTS.DISPLAYSET_LOAD_PROGRESS,
|
||||
updatedDisplaySetLoadingState => {
|
||||
const { displaySetInstanceUID, loadingProgress } = updatedDisplaySetLoadingState;
|
||||
|
||||
setDisplaySetsLoadingState(prevState => ({
|
||||
...prevState,
|
||||
[displaySetInstanceUID]: loadingProgress,
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [studyPrefetcherService]);
|
||||
|
||||
// ~~ subscriptions --> displaySets
|
||||
useEffect(() => {
|
||||
// DISPLAY_SETS_ADDED returns an array of DisplaySets that were added
|
||||
const SubscriptionDisplaySetsAdded = displaySetService.subscribe(
|
||||
displaySetService.EVENTS.DISPLAY_SETS_ADDED,
|
||||
data => {
|
||||
if (!hasLoadedViewports) {
|
||||
return;
|
||||
}
|
||||
const { displaySetsAdded, options } = data;
|
||||
displaySetsAdded.forEach(async dSet => {
|
||||
const displaySetInstanceUID = dSet.displaySetInstanceUID;
|
||||
|
||||
const newImageSrcEntry = {};
|
||||
const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
|
||||
if (displaySet?.unsupported) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.madeInClient) {
|
||||
setJumpToDisplaySet(displaySetInstanceUID);
|
||||
}
|
||||
|
||||
const imageIds = dataSource.getImageIdsForDisplaySet(displaySet);
|
||||
const imageId = getImageIdForThumbnail(displaySet, imageIds);
|
||||
|
||||
// TODO: Is it okay that imageIds are not returned here for SR displaysets?
|
||||
if (!imageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// When the image arrives, render it and store the result in the thumbnailImgSrcMap
|
||||
newImageSrcEntry[displaySetInstanceUID] = await getImageSrc(imageId);
|
||||
setThumbnailImageSrcMap(prevState => {
|
||||
return { ...prevState, ...newImageSrcEntry };
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
SubscriptionDisplaySetsAdded.unsubscribe();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [displaySetService, dataSource, getImageSrc, thumbnailImageSrcMap, trackedSeries, viewports]);
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: Will this always hold _all_ the displaySets we care about?
|
||||
// DISPLAY_SETS_CHANGED returns `DisplaySerService.activeDisplaySets`
|
||||
const SubscriptionDisplaySetsChanged = displaySetService.subscribe(
|
||||
displaySetService.EVENTS.DISPLAY_SETS_CHANGED,
|
||||
changedDisplaySets => {
|
||||
const mappedDisplaySets = _mapDisplaySets(
|
||||
changedDisplaySets,
|
||||
displaySetsLoadingState,
|
||||
thumbnailImageSrcMap,
|
||||
trackedSeries,
|
||||
viewports,
|
||||
viewportGridService,
|
||||
dataSource,
|
||||
displaySetService,
|
||||
uiDialogService,
|
||||
uiNotificationService
|
||||
);
|
||||
|
||||
setDisplaySets(mappedDisplaySets);
|
||||
}
|
||||
);
|
||||
|
||||
const SubscriptionDisplaySetMetaDataInvalidated = displaySetService.subscribe(
|
||||
displaySetService.EVENTS.DISPLAY_SET_SERIES_METADATA_INVALIDATED,
|
||||
() => {
|
||||
const mappedDisplaySets = _mapDisplaySets(
|
||||
displaySetService.getActiveDisplaySets(),
|
||||
displaySetsLoadingState,
|
||||
thumbnailImageSrcMap,
|
||||
trackedSeries,
|
||||
viewports,
|
||||
viewportGridService,
|
||||
dataSource,
|
||||
displaySetService,
|
||||
uiDialogService,
|
||||
uiNotificationService
|
||||
);
|
||||
|
||||
setDisplaySets(mappedDisplaySets);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
SubscriptionDisplaySetsChanged.unsubscribe();
|
||||
SubscriptionDisplaySetMetaDataInvalidated.unsubscribe();
|
||||
};
|
||||
}, [
|
||||
displaySetsLoadingState,
|
||||
thumbnailImageSrcMap,
|
||||
trackedSeries,
|
||||
viewports,
|
||||
dataSource,
|
||||
displaySetService,
|
||||
]);
|
||||
|
||||
const tabs = createStudyBrowserTabs(StudyInstanceUIDs, studyDisplayList, displaySets);
|
||||
|
||||
// TODO: Should not fire this on "close"
|
||||
function _handleStudyClick(StudyInstanceUID) {
|
||||
const shouldCollapseStudy = expandedStudyInstanceUIDs.includes(StudyInstanceUID);
|
||||
const updatedExpandedStudyInstanceUIDs = shouldCollapseStudy
|
||||
? [...expandedStudyInstanceUIDs.filter(stdyUid => stdyUid !== StudyInstanceUID)]
|
||||
: [...expandedStudyInstanceUIDs, StudyInstanceUID];
|
||||
|
||||
setExpandedStudyInstanceUIDs(updatedExpandedStudyInstanceUIDs);
|
||||
|
||||
if (!shouldCollapseStudy) {
|
||||
const madeInClient = true;
|
||||
requestDisplaySetCreationForStudy(displaySetService, StudyInstanceUID, madeInClient);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (jumpToDisplaySet) {
|
||||
// Get element by displaySetInstanceUID
|
||||
const displaySetInstanceUID = jumpToDisplaySet;
|
||||
const element = document.getElementById(`thumbnail-${displaySetInstanceUID}`);
|
||||
|
||||
if (element && typeof element.scrollIntoView === 'function') {
|
||||
// TODO: Any way to support IE here?
|
||||
element.scrollIntoView({ behavior: 'smooth' });
|
||||
|
||||
setJumpToDisplaySet(null);
|
||||
}
|
||||
}
|
||||
}, [jumpToDisplaySet, expandedStudyInstanceUIDs, activeTabName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!jumpToDisplaySet) {
|
||||
return;
|
||||
}
|
||||
|
||||
const displaySetInstanceUID = jumpToDisplaySet;
|
||||
// Set the activeTabName and expand the study
|
||||
const thumbnailLocation = _findTabAndStudyOfDisplaySet(displaySetInstanceUID, tabs);
|
||||
if (!thumbnailLocation) {
|
||||
console.warn('jumpToThumbnail: displaySet thumbnail not found.');
|
||||
|
||||
return;
|
||||
}
|
||||
const { tabName, StudyInstanceUID } = thumbnailLocation;
|
||||
setActiveTabName(tabName);
|
||||
const studyExpanded = expandedStudyInstanceUIDs.includes(StudyInstanceUID);
|
||||
if (!studyExpanded) {
|
||||
const updatedExpandedStudyInstanceUIDs = [...expandedStudyInstanceUIDs, StudyInstanceUID];
|
||||
setExpandedStudyInstanceUIDs(updatedExpandedStudyInstanceUIDs);
|
||||
}
|
||||
}, [expandedStudyInstanceUIDs, jumpToDisplaySet, tabs]);
|
||||
|
||||
const onClickUntrack = displaySetInstanceUID => {
|
||||
const onConfirm = () => {
|
||||
const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
|
||||
sendTrackedMeasurementsEvent('UNTRACK_SERIES', {
|
||||
SeriesInstanceUID: displaySet.SeriesInstanceUID,
|
||||
});
|
||||
const measurements = measurementService.getMeasurements();
|
||||
measurements.forEach(m => {
|
||||
if (m.referenceSeriesUID === displaySet.SeriesInstanceUID) {
|
||||
measurementService.remove(m.uid);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
uiDialogService.create({
|
||||
id: 'untrack-series',
|
||||
centralize: true,
|
||||
isDraggable: false,
|
||||
showOverlay: true,
|
||||
content: Dialog,
|
||||
contentProps: {
|
||||
title: 'Untrack Series',
|
||||
body: () => (
|
||||
<div className="bg-primary-dark p-4 text-white">
|
||||
<p>Are you sure you want to untrack this series?</p>
|
||||
<p className="mt-2">
|
||||
This action cannot be undone and will delete all your existing measurements.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
actions: [
|
||||
{
|
||||
id: 'cancel',
|
||||
text: 'Cancel',
|
||||
type: ButtonEnums.type.secondary,
|
||||
},
|
||||
{
|
||||
id: 'yes',
|
||||
text: 'Yes',
|
||||
type: ButtonEnums.type.primary,
|
||||
classes: ['untrack-yes-button'],
|
||||
},
|
||||
],
|
||||
onClose: () => uiDialogService.dismiss({ id: 'untrack-series' }),
|
||||
onSubmit: async ({ action }) => {
|
||||
switch (action.id) {
|
||||
case 'yes':
|
||||
onConfirm();
|
||||
uiDialogService.dismiss({ id: 'untrack-series' });
|
||||
break;
|
||||
case 'cancel':
|
||||
uiDialogService.dismiss({ id: 'untrack-series' });
|
||||
break;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onThumbnailContextMenu = (commandName, options) => {
|
||||
commandsManager.runCommand(commandName, options);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<>
|
||||
<PanelStudyBrowserHeader
|
||||
viewPresets={viewPresets}
|
||||
updateViewPresetValue={updateViewPresetValue}
|
||||
actionIcons={actionIcons}
|
||||
updateActionIconValue={updateActionIconValue}
|
||||
/>
|
||||
<Separator
|
||||
orientation="horizontal"
|
||||
className="bg-black"
|
||||
thickness="2px"
|
||||
/>
|
||||
</>
|
||||
|
||||
<StudyBrowser
|
||||
tabs={tabs}
|
||||
servicesManager={servicesManager}
|
||||
activeTabName={activeTabName}
|
||||
expandedStudyInstanceUIDs={expandedStudyInstanceUIDs}
|
||||
onClickStudy={_handleStudyClick}
|
||||
onClickTab={clickedTabName => {
|
||||
setActiveTabName(clickedTabName);
|
||||
}}
|
||||
onClickUntrack={displaySetInstanceUID => {
|
||||
onClickUntrack(displaySetInstanceUID);
|
||||
}}
|
||||
onClickThumbnail={() => {}}
|
||||
onDoubleClickThumbnail={onDoubleClickThumbnailHandler}
|
||||
activeDisplaySetInstanceUIDs={activeViewportDisplaySetInstanceUIDs}
|
||||
showSettings={actionIcons.find(icon => icon.id === 'settings').value}
|
||||
viewPresets={viewPresets}
|
||||
onThumbnailContextMenu={onThumbnailContextMenu}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
PanelStudyBrowserTracking.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 PanelStudyBrowserTracking;
|
||||
|
||||
function getImageIdForThumbnail(displaySet: any, imageIds: any) {
|
||||
let imageId;
|
||||
if (displaySet.isDynamicVolume) {
|
||||
const timePoints = displaySet.dynamicVolumeInfo.timePoints;
|
||||
const middleIndex = Math.floor(timePoints.length / 2);
|
||||
const middleTimePointImageIds = timePoints[middleIndex];
|
||||
imageId = middleTimePointImageIds[Math.floor(middleTimePointImageIds.length / 2)];
|
||||
} else {
|
||||
imageId = imageIds[Math.floor(imageIds.length / 2)];
|
||||
}
|
||||
return imageId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps from the DataSource's format to a naturalized object
|
||||
*
|
||||
* @param {*} studies
|
||||
*/
|
||||
function _mapDataSourceStudies(studies) {
|
||||
return studies.map(study => {
|
||||
// TODO: Why does the data source return in this format?
|
||||
return {
|
||||
AccessionNumber: study.accession,
|
||||
StudyDate: study.date,
|
||||
StudyDescription: study.description,
|
||||
NumInstances: study.instances,
|
||||
ModalitiesInStudy: study.modalities,
|
||||
PatientID: study.mrn,
|
||||
PatientName: study.patientName,
|
||||
StudyInstanceUID: study.studyInstanceUid,
|
||||
StudyTime: study.time,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function _mapDisplaySets(
|
||||
displaySets,
|
||||
displaySetLoadingState,
|
||||
thumbnailImageSrcMap,
|
||||
trackedSeriesInstanceUIDs,
|
||||
viewports, // TODO: make array of `displaySetInstanceUIDs`?
|
||||
viewportGridService,
|
||||
dataSource,
|
||||
displaySetService,
|
||||
uiDialogService,
|
||||
uiNotificationService
|
||||
) {
|
||||
const thumbnailDisplaySets = [];
|
||||
const thumbnailNoImageDisplaySets = [];
|
||||
displaySets
|
||||
.filter(ds => !ds.excludeFromThumbnailBrowser)
|
||||
.forEach(ds => {
|
||||
const imageSrc = thumbnailImageSrcMap[ds.displaySetInstanceUID];
|
||||
const componentType = _getComponentType(ds);
|
||||
const numPanes = viewportGridService.getNumViewportPanes();
|
||||
|
||||
const array =
|
||||
componentType === 'thumbnailTracked' ? thumbnailDisplaySets : thumbnailNoImageDisplaySets;
|
||||
|
||||
const { displaySetInstanceUID } = ds;
|
||||
const loadingProgress = displaySetLoadingState?.[displaySetInstanceUID];
|
||||
|
||||
const thumbnailProps = {
|
||||
displaySetInstanceUID,
|
||||
description: ds.SeriesDescription,
|
||||
seriesNumber: ds.SeriesNumber,
|
||||
modality: ds.Modality,
|
||||
seriesDate: formatDate(ds.SeriesDate),
|
||||
numInstances: ds.numImageFrames,
|
||||
loadingProgress,
|
||||
countIcon: ds.countIcon,
|
||||
messages: ds.messages,
|
||||
StudyInstanceUID: ds.StudyInstanceUID,
|
||||
componentType,
|
||||
imageSrc,
|
||||
dragData: {
|
||||
type: 'displayset',
|
||||
displaySetInstanceUID,
|
||||
// .. Any other data to pass
|
||||
},
|
||||
isTracked: trackedSeriesInstanceUIDs.includes(ds.SeriesInstanceUID),
|
||||
isHydratedForDerivedDisplaySet: ds.isHydrated,
|
||||
};
|
||||
|
||||
if (componentType === 'thumbnailNoImage') {
|
||||
if (dataSource.reject && dataSource.reject.series) {
|
||||
thumbnailProps.canReject = !ds?.unsupported;
|
||||
thumbnailProps.onReject = () => {
|
||||
uiDialogService.create({
|
||||
id: 'ds-reject-sr',
|
||||
centralize: true,
|
||||
isDraggable: false,
|
||||
showOverlay: true,
|
||||
content: Dialog,
|
||||
contentProps: {
|
||||
title: 'Delete Report',
|
||||
body: () => (
|
||||
<div className="bg-primary-dark p-4 text-white">
|
||||
<p>Are you sure you want to delete this report?</p>
|
||||
<p className="mt-2">This action cannot be undone.</p>
|
||||
</div>
|
||||
),
|
||||
actions: [
|
||||
{
|
||||
id: 'cancel',
|
||||
text: 'Cancel',
|
||||
type: ButtonEnums.type.secondary,
|
||||
},
|
||||
{
|
||||
id: 'yes',
|
||||
text: 'Yes',
|
||||
type: ButtonEnums.type.primary,
|
||||
classes: ['reject-yes-button'],
|
||||
},
|
||||
],
|
||||
onClose: () => uiDialogService.dismiss({ id: 'ds-reject-sr' }),
|
||||
onShow: () => {
|
||||
const yesButton = document.querySelector('.reject-yes-button');
|
||||
|
||||
yesButton.focus();
|
||||
},
|
||||
onSubmit: async ({ action }) => {
|
||||
switch (action.id) {
|
||||
case 'yes':
|
||||
try {
|
||||
await dataSource.reject.series(ds.StudyInstanceUID, ds.SeriesInstanceUID);
|
||||
displaySetService.deleteDisplaySet(displaySetInstanceUID);
|
||||
uiDialogService.dismiss({ id: 'ds-reject-sr' });
|
||||
uiNotificationService.show({
|
||||
title: 'Delete Report',
|
||||
message: 'Report deleted successfully',
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
uiDialogService.dismiss({ id: 'ds-reject-sr' });
|
||||
uiNotificationService.show({
|
||||
title: 'Delete Report',
|
||||
message: 'Failed to delete report',
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'cancel':
|
||||
uiDialogService.dismiss({ id: 'ds-reject-sr' });
|
||||
break;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
} else {
|
||||
thumbnailProps.canReject = false;
|
||||
}
|
||||
}
|
||||
|
||||
array.push(thumbnailProps);
|
||||
});
|
||||
|
||||
return [...thumbnailDisplaySets, ...thumbnailNoImageDisplaySets];
|
||||
}
|
||||
|
||||
function _getComponentType(ds) {
|
||||
if (thumbnailNoImageModalities.includes(ds.Modality) || ds?.unsupported) {
|
||||
return 'thumbnailNoImage';
|
||||
}
|
||||
|
||||
return 'thumbnailTracked';
|
||||
}
|
||||
|
||||
function _findTabAndStudyOfDisplaySet(displaySetInstanceUID, tabs) {
|
||||
for (let t = 0; t < tabs.length; t++) {
|
||||
const { studies } = tabs[t];
|
||||
|
||||
for (let s = 0; s < studies.length; s++) {
|
||||
const { displaySets } = studies[s];
|
||||
|
||||
for (let d = 0; d < displaySets.length; d++) {
|
||||
const displaySet = displaySets[d];
|
||||
|
||||
if (displaySet.displaySetInstanceUID === displaySetInstanceUID) {
|
||||
return {
|
||||
tabName: tabs[t].name,
|
||||
StudyInstanceUID: studies[s].studyInstanceUid,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { actionIcon } from '../PanelStudyBrowserTracking/types/actionsIcon';
|
||||
|
||||
const defaultActionIcons = [
|
||||
{
|
||||
id: 'settings',
|
||||
iconName: 'Settings',
|
||||
value: false,
|
||||
},
|
||||
] as actionIcon[];
|
||||
|
||||
export { defaultActionIcons };
|
||||
@@ -0,0 +1,4 @@
|
||||
import { defaultActionIcons } from './actionIcons';
|
||||
import { defaultViewPresets } from './viewPresets';
|
||||
|
||||
export { defaultActionIcons, defaultViewPresets };
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { viewPreset } from '../PanelStudyBrowserTracking/types/viewPreset';
|
||||
|
||||
const defaultViewPresets = [
|
||||
{
|
||||
id: 'list',
|
||||
iconName: 'ListView',
|
||||
selected: false,
|
||||
},
|
||||
{
|
||||
id: 'thumbnails',
|
||||
iconName: 'ThumbnailView',
|
||||
selected: true,
|
||||
},
|
||||
] as viewPreset[];
|
||||
|
||||
export { defaultViewPresets };
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* @param {*} cornerstone
|
||||
* @param {*} imageId
|
||||
*/
|
||||
function getImageSrcFromImageId(cornerstone, imageId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
cornerstone.utilities
|
||||
.loadImageToCanvas({ canvas, imageId, thumbnail: true })
|
||||
.then(imageId => {
|
||||
resolve(canvas.toDataURL());
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
export default getImageSrcFromImageId;
|
||||
@@ -0,0 +1,82 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
//
|
||||
import PanelStudyBrowserTracking from './PanelStudyBrowserTracking';
|
||||
import getImageSrcFromImageId from './getImageSrcFromImageId';
|
||||
import requestDisplaySetCreationForStudy from './requestDisplaySetCreationForStudy';
|
||||
|
||||
function _getStudyForPatientUtility(extensionManager) {
|
||||
const utilityModule = extensionManager.getModuleEntry(
|
||||
'@ohif/extension-default.utilityModule.common'
|
||||
);
|
||||
|
||||
const { getStudiesForPatientByMRN } = utilityModule.exports;
|
||||
return getStudiesForPatientByMRN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the PanelStudyBrowser and provides features afforded by managers/services
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {object} commandsManager
|
||||
* @param {object} extensionManager
|
||||
*/
|
||||
function WrappedPanelStudyBrowserTracking({
|
||||
commandsManager,
|
||||
extensionManager,
|
||||
servicesManager,
|
||||
}: withAppTypes) {
|
||||
const dataSource = extensionManager.getActiveDataSource()[0];
|
||||
|
||||
const getStudiesForPatientByMRN = _getStudyForPatientUtility(extensionManager);
|
||||
const _getStudiesForPatientByMRN = getStudiesForPatientByMRN.bind(null, dataSource);
|
||||
const _getImageSrcFromImageId = useCallback(
|
||||
_createGetImageSrcFromImageIdFn(extensionManager),
|
||||
[]
|
||||
);
|
||||
const _requestDisplaySetCreationForStudy = requestDisplaySetCreationForStudy.bind(
|
||||
null,
|
||||
dataSource
|
||||
);
|
||||
|
||||
return (
|
||||
<PanelStudyBrowserTracking
|
||||
servicesManager={servicesManager}
|
||||
commandsManager={commandsManager}
|
||||
dataSource={dataSource}
|
||||
getImageSrc={_getImageSrcFromImageId}
|
||||
getStudiesForPatientByMRN={_getStudiesForPatientByMRN}
|
||||
requestDisplaySetCreationForStudy={_requestDisplaySetCreationForStudy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grabs cornerstone library reference using a dependent command from
|
||||
* the @ohif/extension-cornerstone extension. Then creates a helper function
|
||||
* that can take an imageId and return an image src.
|
||||
*
|
||||
* @param {func} getCommand - CommandManager's getCommand method
|
||||
* @returns {func} getImageSrcFromImageId - A utility function powered by
|
||||
* cornerstone
|
||||
*/
|
||||
function _createGetImageSrcFromImageIdFn(extensionManager) {
|
||||
const utilities = extensionManager.getModuleEntry(
|
||||
'@ohif/extension-cornerstone.utilityModule.common'
|
||||
);
|
||||
|
||||
try {
|
||||
const { cornerstone } = utilities.exports.getCornerstoneLibraries();
|
||||
return getImageSrcFromImageId.bind(null, cornerstone);
|
||||
} catch (ex) {
|
||||
throw new Error('Required command not found');
|
||||
}
|
||||
}
|
||||
|
||||
WrappedPanelStudyBrowserTracking.propTypes = {
|
||||
commandsManager: PropTypes.object.isRequired,
|
||||
extensionManager: PropTypes.object.isRequired,
|
||||
servicesManager: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default WrappedPanelStudyBrowserTracking;
|
||||
@@ -0,0 +1,18 @@
|
||||
function requestDisplaySetCreationForStudy(
|
||||
dataSource,
|
||||
displaySetService,
|
||||
StudyInstanceUID,
|
||||
madeInClient
|
||||
) {
|
||||
if (
|
||||
displaySetService.activeDisplaySets.some(
|
||||
displaySet => displaySet.StudyInstanceUID === StudyInstanceUID
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
dataSource.retrieve.series.metadata({ StudyInstanceUID, madeInClient });
|
||||
}
|
||||
|
||||
export default requestDisplaySetCreationForStudy;
|
||||
@@ -0,0 +1,7 @@
|
||||
type actionIcon = {
|
||||
id: string;
|
||||
iconName: string;
|
||||
value: boolean;
|
||||
};
|
||||
|
||||
export type { actionIcon };
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { actionIcon } from './actionIcon';
|
||||
import type { viewPreset } from './viewPreset';
|
||||
|
||||
export type { actionIcon, viewPreset };
|
||||
@@ -0,0 +1,7 @@
|
||||
type viewPreset = {
|
||||
id: string;
|
||||
iconName: string;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
export type { viewPreset };
|
||||
4
extensions/measurement-tracking/src/panels/index.js
Normal file
4
extensions/measurement-tracking/src/panels/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
import PanelStudyBrowserTracking from './PanelStudyBrowserTracking';
|
||||
import PanelMeasurementTableTracking from './PanelMeasurementTableTracking';
|
||||
|
||||
export { PanelMeasurementTableTracking, PanelStudyBrowserTracking };
|
||||
@@ -0,0 +1,360 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Tooltip, Icon, ViewportActionArrows, useViewportGrid } from '@ohif/ui';
|
||||
|
||||
import { annotation } from '@cornerstonejs/tools';
|
||||
import { useTrackedMeasurements } from './../getContextModule';
|
||||
import { BaseVolumeViewport, Enums } from '@cornerstonejs/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function TrackedCornerstoneViewport(
|
||||
props: withAppTypes<{ viewportId: string; displaySets: AppTypes.DisplaySet[] }>
|
||||
) {
|
||||
const { displaySets, viewportId, servicesManager, extensionManager } = props;
|
||||
|
||||
const {
|
||||
measurementService,
|
||||
cornerstoneViewportService,
|
||||
viewportGridService,
|
||||
viewportActionCornersService,
|
||||
} = servicesManager.services;
|
||||
|
||||
// Todo: handling more than one displaySet on the same viewport
|
||||
const displaySet = displaySets[0];
|
||||
const { t } = useTranslation('Common');
|
||||
|
||||
const [viewportGrid] = useViewportGrid();
|
||||
const { activeViewportId } = viewportGrid;
|
||||
|
||||
const [trackedMeasurements, sendTrackedMeasurementsEvent] = useTrackedMeasurements();
|
||||
|
||||
const [isTracked, setIsTracked] = useState(false);
|
||||
const [trackedMeasurementUID, setTrackedMeasurementUID] = useState(null);
|
||||
const [viewportElem, setViewportElem] = useState(null);
|
||||
|
||||
const { trackedSeries } = trackedMeasurements.context;
|
||||
|
||||
const { SeriesInstanceUID } = displaySet;
|
||||
|
||||
const updateIsTracked = useCallback(() => {
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
|
||||
if (viewport instanceof BaseVolumeViewport) {
|
||||
// A current image id will only exist for volume viewports that can have measurements tracked.
|
||||
// Typically these are those volume viewports for the series of acquisition.
|
||||
const currentImageId = viewport?.getCurrentImageId();
|
||||
|
||||
if (!currentImageId) {
|
||||
if (isTracked) {
|
||||
setIsTracked(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (trackedSeries.includes(SeriesInstanceUID) !== isTracked) {
|
||||
setIsTracked(!isTracked);
|
||||
}
|
||||
}, [isTracked, trackedMeasurements, viewportId, SeriesInstanceUID]);
|
||||
|
||||
const onElementEnabled = useCallback(
|
||||
evt => {
|
||||
if (evt.detail.element !== viewportElem) {
|
||||
// The VOLUME_VIEWPORT_NEW_VOLUME event allows updateIsTracked to reliably fetch the image id for a volume viewport.
|
||||
evt.detail.element?.addEventListener(
|
||||
Enums.Events.VOLUME_VIEWPORT_NEW_VOLUME,
|
||||
updateIsTracked
|
||||
);
|
||||
setViewportElem(evt.detail.element);
|
||||
}
|
||||
},
|
||||
[updateIsTracked, viewportElem]
|
||||
);
|
||||
|
||||
const onElementDisabled = useCallback(() => {
|
||||
viewportElem?.removeEventListener(Enums.Events.VOLUME_VIEWPORT_NEW_VOLUME, updateIsTracked);
|
||||
}, [updateIsTracked, viewportElem]);
|
||||
|
||||
useEffect(updateIsTracked, [updateIsTracked]);
|
||||
|
||||
useEffect(() => {
|
||||
const { unsubscribe } = cornerstoneViewportService.subscribe(
|
||||
cornerstoneViewportService.EVENTS.VIEWPORT_DATA_CHANGED,
|
||||
props => {
|
||||
if (props.viewportId !== viewportId) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateIsTracked();
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [updateIsTracked, viewportId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isTracked) {
|
||||
annotation.config.style.setViewportToolStyles(viewportId, {
|
||||
ReferenceLines: {
|
||||
lineDash: '4,4',
|
||||
},
|
||||
global: {
|
||||
lineDash: '',
|
||||
},
|
||||
});
|
||||
|
||||
cornerstoneViewportService.getRenderingEngine().renderViewport(viewportId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
annotation.config.style.setViewportToolStyles(viewportId, {
|
||||
global: {
|
||||
lineDash: '4,4',
|
||||
},
|
||||
});
|
||||
|
||||
cornerstoneViewportService.getRenderingEngine().renderViewport(viewportId);
|
||||
|
||||
return () => {
|
||||
annotation.config.style.setViewportToolStyles(viewportId, {});
|
||||
};
|
||||
}, [isTracked]);
|
||||
|
||||
/**
|
||||
* The effect for listening to measurement service measurement added events
|
||||
* and in turn firing an event to update the measurement tracking state machine.
|
||||
* The TrackedCornerstoneViewport is the best place for this because when
|
||||
* a measurement is added, at least one TrackedCornerstoneViewport will be in
|
||||
* the DOM and thus can react to the events fired.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const added = measurementService.EVENTS.MEASUREMENT_ADDED;
|
||||
const addedRaw = measurementService.EVENTS.RAW_MEASUREMENT_ADDED;
|
||||
const subscriptions = [];
|
||||
|
||||
[added, addedRaw].forEach(evt => {
|
||||
subscriptions.push(
|
||||
measurementService.subscribe(evt, ({ source, measurement }) => {
|
||||
const { activeViewportId } = viewportGridService.getState();
|
||||
|
||||
// Each TrackedCornerstoneViewport receives the MeasurementService's events.
|
||||
// Only send the tracked measurements event for the active viewport to avoid
|
||||
// sending it more than once.
|
||||
if (viewportId === activeViewportId) {
|
||||
const {
|
||||
referenceStudyUID: StudyInstanceUID,
|
||||
referenceSeriesUID: SeriesInstanceUID,
|
||||
uid: measurementId,
|
||||
} = measurement;
|
||||
|
||||
sendTrackedMeasurementsEvent('SET_DIRTY', { SeriesInstanceUID });
|
||||
sendTrackedMeasurementsEvent('TRACK_SERIES', {
|
||||
viewportId,
|
||||
StudyInstanceUID,
|
||||
SeriesInstanceUID,
|
||||
measurementId,
|
||||
});
|
||||
}
|
||||
}).unsubscribe
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscriptions.forEach(unsub => {
|
||||
unsub();
|
||||
});
|
||||
};
|
||||
}, [measurementService, sendTrackedMeasurementsEvent, viewportId, viewportGridService]);
|
||||
|
||||
const switchMeasurement = useCallback(
|
||||
direction => {
|
||||
const newTrackedMeasurementUID = _getNextMeasurementUID(
|
||||
direction,
|
||||
servicesManager,
|
||||
trackedMeasurementUID,
|
||||
trackedMeasurements
|
||||
);
|
||||
|
||||
if (!newTrackedMeasurementUID) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTrackedMeasurementUID(newTrackedMeasurementUID);
|
||||
|
||||
measurementService.jumpToMeasurement(viewportId, newTrackedMeasurementUID);
|
||||
},
|
||||
[measurementService, servicesManager, trackedMeasurementUID, trackedMeasurements, viewportId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const statusComponent = _getStatusComponent(isTracked, t);
|
||||
const arrowsComponent = _getArrowsComponent(
|
||||
isTracked,
|
||||
switchMeasurement,
|
||||
viewportId === activeViewportId
|
||||
);
|
||||
|
||||
viewportActionCornersService.addComponents([
|
||||
{
|
||||
viewportId,
|
||||
id: 'viewportStatusComponent',
|
||||
component: statusComponent,
|
||||
indexPriority: -100,
|
||||
location: viewportActionCornersService.LOCATIONS.topLeft,
|
||||
},
|
||||
{
|
||||
viewportId,
|
||||
id: 'viewportActionArrowsComponent',
|
||||
component: arrowsComponent,
|
||||
indexPriority: 0,
|
||||
location: viewportActionCornersService.LOCATIONS.topRight,
|
||||
},
|
||||
]);
|
||||
}, [activeViewportId, isTracked, switchMeasurement, viewportActionCornersService, viewportId]);
|
||||
|
||||
const getCornerstoneViewport = () => {
|
||||
const { component: Component } = extensionManager.getModuleEntry(
|
||||
'@ohif/extension-cornerstone.viewportModule.cornerstone'
|
||||
);
|
||||
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
onElementEnabled={evt => {
|
||||
props.onElementEnabled?.(evt);
|
||||
onElementEnabled(evt);
|
||||
}}
|
||||
onElementDisabled={onElementDisabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex h-full w-full flex-row overflow-hidden">
|
||||
{getCornerstoneViewport()}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
TrackedCornerstoneViewport.propTypes = {
|
||||
displaySets: PropTypes.arrayOf(PropTypes.object.isRequired).isRequired,
|
||||
viewportId: PropTypes.string.isRequired,
|
||||
dataSource: PropTypes.object,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
function _getNextMeasurementUID(
|
||||
direction,
|
||||
servicesManager: AppTypes.ServicesManager,
|
||||
trackedMeasurementId,
|
||||
trackedMeasurements
|
||||
) {
|
||||
const { measurementService, viewportGridService } = servicesManager.services;
|
||||
const measurements = measurementService.getMeasurements();
|
||||
|
||||
const { activeViewportId, viewports } = viewportGridService.getState();
|
||||
const { displaySetInstanceUIDs: activeViewportDisplaySetInstanceUIDs } =
|
||||
viewports.get(activeViewportId);
|
||||
|
||||
const { trackedSeries } = trackedMeasurements.context;
|
||||
|
||||
// Get the potentially trackable measurements for the series of the
|
||||
// active viewport.
|
||||
// The measurements to jump between are the same
|
||||
// regardless if this series is tracked or not.
|
||||
|
||||
const filteredMeasurements = measurements.filter(
|
||||
m =>
|
||||
trackedSeries.includes(m.referenceSeriesUID) &&
|
||||
activeViewportDisplaySetInstanceUIDs.includes(m.displaySetInstanceUID)
|
||||
);
|
||||
|
||||
if (!filteredMeasurements.length) {
|
||||
// No measurements on this series.
|
||||
return;
|
||||
}
|
||||
|
||||
const measurementCount = filteredMeasurements.length;
|
||||
|
||||
const uids = filteredMeasurements.map(fm => fm.uid);
|
||||
let measurementIndex = uids.findIndex(uid => uid === trackedMeasurementId);
|
||||
|
||||
if (measurementIndex === -1) {
|
||||
// Not tracking a measurement, or previous measurement now deleted, revert to 0.
|
||||
measurementIndex = 0;
|
||||
} else {
|
||||
measurementIndex += direction;
|
||||
if (measurementIndex < 0) {
|
||||
measurementIndex = measurementCount - 1;
|
||||
} else if (measurementIndex === measurementCount) {
|
||||
measurementIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const newTrackedMeasurementId = uids[measurementIndex];
|
||||
|
||||
return newTrackedMeasurementId;
|
||||
}
|
||||
|
||||
const _getArrowsComponent = (isTracked, switchMeasurement, isActiveViewport) => {
|
||||
if (!isTracked) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ViewportActionArrows
|
||||
onArrowsClick={direction => switchMeasurement(direction)}
|
||||
className={isActiveViewport ? 'visible' : 'invisible group-hover/pane:visible'}
|
||||
></ViewportActionArrows>
|
||||
);
|
||||
};
|
||||
|
||||
function _getStatusComponent(isTracked, t) {
|
||||
if (!isTracked) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Tooltip
|
||||
position="bottom-left"
|
||||
content={
|
||||
<div className="flex py-2">
|
||||
<div className="flex pt-1">
|
||||
<Icon
|
||||
name="info-link"
|
||||
className="text-primary-main w-4"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-4 flex">
|
||||
<span className="text-common-light text-base">
|
||||
{isTracked ? (
|
||||
<>{t('Series is tracked and can be viewed in the measurement panel')}</>
|
||||
) : (
|
||||
<>
|
||||
{t(
|
||||
'Measurements for untracked series will not be shown in the measurements panel'
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
name={'viewport-status-tracked'}
|
||||
className="text-aqua-pale"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TrackedCornerstoneViewport;
|
||||
Reference in New Issue
Block a user