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

View File

@@ -0,0 +1,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 };

View File

@@ -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;

View File

@@ -0,0 +1,5 @@
export {
TrackedMeasurementsContext,
TrackedMeasurementsContextProvider,
useTrackedMeasurements,
} from './TrackedMeasurementsContext.tsx';

View File

@@ -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 };

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,5 @@
export {
TrackedMeasurementsContext,
TrackedMeasurementsContextProvider,
useTrackedMeasurements,
} from './TrackedMeasurementsContext';

View 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;

View 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;

View 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;

View File

@@ -0,0 +1,5 @@
import packageJson from '../package.json';
const id = packageJson.name;
export { id };

View 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;

View File

@@ -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;

View File

@@ -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,
};
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
import type { actionIcon } from '../PanelStudyBrowserTracking/types/actionsIcon';
const defaultActionIcons = [
{
id: 'settings',
iconName: 'Settings',
value: false,
},
] as actionIcon[];
export { defaultActionIcons };

View File

@@ -0,0 +1,4 @@
import { defaultActionIcons } from './actionIcons';
import { defaultViewPresets } from './viewPresets';
export { defaultActionIcons, defaultViewPresets };

View File

@@ -0,0 +1,16 @@
import type { viewPreset } from '../PanelStudyBrowserTracking/types/viewPreset';
const defaultViewPresets = [
{
id: 'list',
iconName: 'ListView',
selected: false,
},
{
id: 'thumbnails',
iconName: 'ThumbnailView',
selected: true,
},
] as viewPreset[];
export { defaultViewPresets };

View File

@@ -0,0 +1,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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,7 @@
type actionIcon = {
id: string;
iconName: string;
value: boolean;
};
export type { actionIcon };

View File

@@ -0,0 +1,4 @@
import type { actionIcon } from './actionIcon';
import type { viewPreset } from './viewPreset';
export type { actionIcon, viewPreset };

View File

@@ -0,0 +1,7 @@
type viewPreset = {
id: string;
iconName: string;
selected: boolean;
};
export type { viewPreset };

View File

@@ -0,0 +1,4 @@
import PanelStudyBrowserTracking from './PanelStudyBrowserTracking';
import PanelMeasurementTableTracking from './PanelMeasurementTableTracking';
export { PanelMeasurementTableTracking, PanelStudyBrowserTracking };

View File

@@ -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;