Files
ohif-viewer/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.tsx
T

315 lines
10 KiB
TypeScript

import React, { useEffect, useState, useRef } from 'react';
import PropTypes from 'prop-types';
import { StudySummary, MeasurementTable, useViewportGrid, ActionButtons } from '@ohif/ui';
import { DicomMetadataStore, utils } from '@ohif/core';
import { useDebounce } from '@hooks';
import { useAppConfig } from '@state';
import debounce from 'lodash.debounce';
import { useTranslation } from 'react-i18next';
import { Separator } from '@ohif/ui-next';
import { useTrackedMeasurements } from '../../getContextModule';
const { downloadCSVReport } = utils;
const { 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 }: withAppTypes) {
const [viewportGrid] = useViewportGrid();
const { t } = useTranslation('MeasurementTable');
const [measurementChangeTimestamp, setMeasurementsUpdated] = useState(Date.now().toString());
const debouncedMeasurementChangeTimestamp = useDebounce(measurementChangeTimestamp, 200);
const { measurementService, uiDialogService, displaySetService, customizationService } =
servicesManager.services;
const [trackedMeasurements, sendTrackedMeasurementsEvent] = useTrackedMeasurements();
const { trackedStudy, trackedSeries } = trackedMeasurements.context;
const [displayStudySummary, setDisplayStudySummary] = useState(
DISPLAY_STUDY_SUMMARY_INITIAL_VALUE
);
const [displayMeasurements, setDisplayMeasurements] = useState([]);
const measurementsPanelRef = useRef(null);
const [appConfig] = useAppConfig();
useEffect(() => {
const measurements = measurementService.getMeasurements();
const mappedMeasurements = measurements.map(m =>
_mapMeasurementToDisplay(m, displaySetService)
);
setDisplayMeasurements(mappedMeasurements);
// eslint-ignore-next-line
}, [
measurementService,
displaySetService,
trackedStudy,
trackedSeries,
debouncedMeasurementChangeTimestamp,
]);
// ~~ DisplayStudySummary
useEffect(() => {
const updateDisplayStudySummary = async () => {
if (trackedMeasurements.matches('tracking')) {
const StudyInstanceUID = trackedStudy;
const studyMeta = DicomMetadataStore.getStudy(StudyInstanceUID);
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('/');
if (displayStudySummary.key !== StudyInstanceUID) {
setDisplayStudySummary({
key: StudyInstanceUID,
date: StudyDate, // TODO: Format: '07-Sep-2010'
modality,
description: StudyDescription,
});
}
} else if (trackedStudy === '' || trackedStudy === undefined) {
setDisplayStudySummary(DISPLAY_STUDY_SUMMARY_INITIAL_VALUE);
}
};
updateDisplayStudySummary();
}, [displayStudySummary.key, trackedSeries, trackedMeasurements, trackedStudy]);
// TODO: Better way to consolidated, debounce, check on change?
// Are we exposing the right API for measurementService?
// This watches for ALL measurementService changes. It updates a timestamp,
// which is debounced. After a brief period of inactivity, this triggers
// a re-render where we grab up-to-date measurements
useEffect(() => {
const added = measurementService.EVENTS.MEASUREMENT_ADDED;
const addedRaw = measurementService.EVENTS.RAW_MEASUREMENT_ADDED;
const updated = measurementService.EVENTS.MEASUREMENT_UPDATED;
const removed = measurementService.EVENTS.MEASUREMENT_REMOVED;
const cleared = measurementService.EVENTS.MEASUREMENTS_CLEARED;
const subscriptions = [];
[added, addedRaw, updated, removed, cleared].forEach(evt => {
subscriptions.push(
measurementService.subscribe(evt, () => {
setMeasurementsUpdated(Date.now().toString());
if (evt === added) {
debounce(() => {
measurementsPanelRef.current.scrollTop = measurementsPanelRef.current.scrollHeight;
}, 300)();
}
}).unsubscribe
);
});
return () => {
subscriptions.forEach(unsub => {
unsub();
});
};
}, [measurementService, sendTrackedMeasurementsEvent]);
async function exportReport() {
const measurements = measurementService.getMeasurements();
const trackedMeasurements = measurements.filter(
m => trackedStudy === m.referenceStudyUID && trackedSeries.includes(m.referenceSeriesUID)
);
downloadCSVReport(trackedMeasurements);
}
const jumpToImage = ({ uid, isActive }) => {
measurementService.jumpToMeasurement(viewportGrid.activeViewportId, uid);
onMeasurementItemClickHandler({ uid, isActive });
};
const onMeasurementItemEditHandler = ({ uid, isActive }) => {
jumpToImage({ uid, isActive });
const labelConfig = customizationService.get('measurementLabels');
const measurement = measurementService.getMeasurement(uid);
const utilityModule = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.utilityModule.common'
);
const { showLabelAnnotationPopup } = utilityModule.exports;
showLabelAnnotationPopup(measurement, uiDialogService, labelConfig).then(
(val: Map<any, any>) => {
measurementService.update(
uid,
{
...val,
},
true
);
}
);
};
const onMeasurementItemClickHandler = ({ uid, isActive }) => {
if (!isActive) {
const measurements = [...displayMeasurements];
const measurement = measurements.find(m => m.uid === uid);
measurements.forEach(m => (m.isActive = m.uid !== uid ? false : true));
measurement.isActive = true;
setDisplayMeasurements(measurements);
}
};
const displayMeasurementsWithoutFindings = displayMeasurements.filter(
dm =>
dm.measurementType !== measurementService.VALUE_TYPES.POINT &&
dm.referencedImageId &&
trackedStudy === dm.referenceStudyUID &&
trackedSeries.includes(dm.referenceSeriesUID)
);
const additionalFindings = displayMeasurements.filter(
dm =>
dm.measurementType === measurementService.VALUE_TYPES.POINT &&
dm.referencedImageId &&
trackedStudy === dm.referenceStudyUID &&
trackedSeries.includes(dm.referenceSeriesUID)
);
const nonAcquisitionMeasurements = displayMeasurements.filter(
dm =>
dm.referencedImageId == null ||
trackedStudy !== dm.referenceStudyUID ||
trackedSeries.includes(dm.referenceSeriesUID)
);
const disabled =
additionalFindings.length === 0 &&
displayMeasurementsWithoutFindings.length === 0 &&
nonAcquisitionMeasurements.length === 0;
return (
<>
<div
className="invisible-scrollbar overflow-y-auto overflow-x-hidden"
ref={measurementsPanelRef}
data-cy={'trackedMeasurements-panel'}
>
{displayStudySummary.key && (
<StudySummary
date={formatDate(displayStudySummary.date)}
modality={displayStudySummary.modality}
description={displayStudySummary.description}
/>
)}
<MeasurementTable
title="Measurements"
data={displayMeasurementsWithoutFindings}
servicesManager={servicesManager}
onClick={jumpToImage}
onEdit={onMeasurementItemEditHandler}
/>
{additionalFindings.length !== 0 && (
<MeasurementTable
title="Additional Findings"
data={additionalFindings}
servicesManager={servicesManager}
onClick={jumpToImage}
onEdit={onMeasurementItemEditHandler}
/>
)}
{nonAcquisitionMeasurements.length !== 0 && (
<MeasurementTable
title="Non-tracked"
data={nonAcquisitionMeasurements}
servicesManager={servicesManager}
onClick={jumpToImage}
onEdit={onMeasurementItemEditHandler}
/>
)}
</div>
{!appConfig?.disableEditing && (
<div className="flex justify-center p-4">
<ActionButtons
t={t}
actions={[
{
label: 'Download CSV',
onClick: exportReport,
},
{
label: 'Create Report',
onClick: () => {
sendTrackedMeasurementsEvent('SAVE_REPORT', {
viewportId: viewportGrid.activeViewportId,
isBackupSave: true,
});
},
},
]}
disabled={disabled}
/>
</div>
)}
</>
);
}
PanelMeasurementTableTracking.propTypes = {
servicesManager: PropTypes.shape({
services: PropTypes.shape({
measurementService: PropTypes.shape({
getMeasurements: PropTypes.func.isRequired,
VALUE_TYPES: PropTypes.object.isRequired,
}).isRequired,
}).isRequired,
}).isRequired,
};
// TODO: This could be a measurementService mapper
function _mapMeasurementToDisplay(measurement, displaySetService) {
const { referenceSeriesUID } = measurement;
// TODO: We don't deal with multiframe well yet, would need to update
// This in OHIF-312 when we add FrameIndex to measurements
const {
displayText: baseDisplayText,
uid,
label: baseLabel,
type,
selected,
findingSites,
finding,
referencedImageId,
} = measurement;
const firstSite = findingSites?.[0];
const label = baseLabel || finding?.text || firstSite?.text || '(empty)';
let displayText = baseDisplayText || [];
if (findingSites) {
const siteText = [];
findingSites.forEach(site => {
if (site?.text !== label) {
siteText.push(site.text);
}
});
displayText = [...siteText, ...displayText];
}
if (finding && finding?.text !== label) {
displayText = [finding.text, ...displayText];
}
return {
uid,
label,
baseLabel,
measurementType: type,
displayText,
baseDisplayText,
isActive: selected,
finding,
findingSites,
referencedImageId,
};
}
export default PanelMeasurementTableTracking;