369 lines
12 KiB
TypeScript
369 lines
12 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import PropTypes from 'prop-types';
|
|
import { ExtensionManager, CommandsManager, DicomMetadataStore } from '@ohif/core';
|
|
import { MeasurementTable } from '@ohif/ui';
|
|
import { withTranslation, WithTranslation } from 'react-i18next';
|
|
import { EVENTS as MicroscopyEvents } from '../../services/MicroscopyService';
|
|
import dcmjs from 'dcmjs';
|
|
import callInputDialog from '../../utils/callInputDialog';
|
|
import constructSR from '../../utils/constructSR';
|
|
import { saveByteArray } from '../../utils/saveByteArray';
|
|
import { Separator } from '@ohif/ui-next';
|
|
|
|
let saving = false;
|
|
const { datasetToBuffer } = dcmjs.data;
|
|
|
|
const formatArea = area => {
|
|
let mult = 1;
|
|
let unit = 'mm';
|
|
if (area > 1000000) {
|
|
unit = 'm';
|
|
mult = 1 / 1000000;
|
|
} else if (area < 1) {
|
|
unit = 'μm';
|
|
mult = 1000000;
|
|
}
|
|
return `${(area * mult).toFixed(2)} ${unit}²`;
|
|
};
|
|
|
|
const formatLength = (length, unit) => {
|
|
let mult = 1;
|
|
if (unit == 'km' || (!unit && length > 1000000)) {
|
|
unit = 'km';
|
|
mult = 1 / 1000000;
|
|
} else if (unit == 'm' || (!unit && length > 1000)) {
|
|
unit = 'm';
|
|
mult = 1 / 1000;
|
|
} else if (unit == 'μm' || (!unit && length < 1)) {
|
|
unit = 'μm';
|
|
mult = 1000;
|
|
} else if (unit && unit != 'mm') {
|
|
throw new Error(`Unknown length unit ${unit}`);
|
|
} else {
|
|
unit = 'mm';
|
|
}
|
|
return `${(length * mult).toFixed(2)} ${unit}`;
|
|
};
|
|
|
|
interface IMicroscopyPanelProps extends WithTranslation {
|
|
viewports: PropTypes.array;
|
|
activeViewportId: PropTypes.string;
|
|
|
|
//
|
|
onSaveComplete?: PropTypes.func; // callback when successfully saved annotations
|
|
onRejectComplete?: PropTypes.func; // callback when rejected annotations
|
|
|
|
//
|
|
servicesManager: AppTypes.ServicesManager;
|
|
extensionManager: ExtensionManager;
|
|
commandsManager: CommandsManager;
|
|
renderHeader?: boolean;
|
|
getCloseIcon?: PropTypes.func;
|
|
tab: PropTypes.object;
|
|
}
|
|
|
|
/**
|
|
* Microscopy Measurements Panel Component
|
|
*
|
|
* @param props
|
|
* @returns
|
|
*/
|
|
function MicroscopyPanel(props: IMicroscopyPanelProps) {
|
|
const { microscopyService } = props.servicesManager.services;
|
|
|
|
const [studyInstanceUID, setStudyInstanceUID] = useState(null as string | null);
|
|
const [roiAnnotations, setRoiAnnotations] = useState([] as any[]);
|
|
const [selectedAnnotation, setSelectedAnnotation] = useState(null as any);
|
|
const { servicesManager, extensionManager } = props;
|
|
|
|
const { uiDialogService, displaySetService } = servicesManager.services;
|
|
|
|
useEffect(() => {
|
|
const viewport = props.viewports.get(props.activeViewportId);
|
|
if (viewport?.displaySetInstanceUIDs[0]) {
|
|
const displaySet = displaySetService.getDisplaySetByUID(viewport.displaySetInstanceUIDs[0]);
|
|
if (displaySet) {
|
|
setStudyInstanceUID(displaySet.StudyInstanceUID);
|
|
}
|
|
}
|
|
}, [props.viewports, props.activeViewportId]);
|
|
|
|
useEffect(() => {
|
|
const onAnnotationUpdated = () => {
|
|
const roiAnnotations = microscopyService.getAnnotationsForStudy(studyInstanceUID);
|
|
setRoiAnnotations(roiAnnotations);
|
|
};
|
|
|
|
const onAnnotationSelected = () => {
|
|
const selectedAnnotation = microscopyService.getSelectedAnnotation();
|
|
setSelectedAnnotation(selectedAnnotation);
|
|
};
|
|
|
|
const onAnnotationRemoved = () => {
|
|
onAnnotationUpdated();
|
|
};
|
|
|
|
const { unsubscribe: unsubscribeAnnotationUpdated } = microscopyService.subscribe(
|
|
MicroscopyEvents.ANNOTATION_UPDATED,
|
|
onAnnotationUpdated
|
|
);
|
|
const { unsubscribe: unsubscribeAnnotationSelected } = microscopyService.subscribe(
|
|
MicroscopyEvents.ANNOTATION_SELECTED,
|
|
onAnnotationSelected
|
|
);
|
|
const { unsubscribe: unsubscribeAnnotationRemoved } = microscopyService.subscribe(
|
|
MicroscopyEvents.ANNOTATION_REMOVED,
|
|
onAnnotationRemoved
|
|
);
|
|
onAnnotationUpdated();
|
|
onAnnotationSelected();
|
|
|
|
// on unload unsubscribe from events
|
|
return () => {
|
|
unsubscribeAnnotationUpdated();
|
|
unsubscribeAnnotationSelected();
|
|
unsubscribeAnnotationRemoved();
|
|
};
|
|
}, [studyInstanceUID]);
|
|
|
|
/**
|
|
* On clicking "Save Annotations" button, prompt an input modal for the
|
|
* new series' description, and continue to save.
|
|
*
|
|
* @returns
|
|
*/
|
|
const promptSave = () => {
|
|
const annotations = microscopyService.getAnnotationsForStudy(studyInstanceUID);
|
|
|
|
if (!annotations || saving) {
|
|
return;
|
|
}
|
|
|
|
callInputDialog({
|
|
uiDialogService,
|
|
title: 'Enter description of the Series',
|
|
defaultValue: '',
|
|
callback: (value: string, action: string) => {
|
|
switch (action) {
|
|
case 'save': {
|
|
saveFunction(value);
|
|
}
|
|
}
|
|
},
|
|
});
|
|
};
|
|
|
|
const getAllDisplaySets = (studyMetadata: any) => {
|
|
let allDisplaySets = [] as any[];
|
|
studyMetadata.series.forEach((series: any) => {
|
|
const displaySets = displaySetService.getDisplaySetsForSeries(series.SeriesInstanceUID);
|
|
allDisplaySets = allDisplaySets.concat(displaySets);
|
|
});
|
|
return allDisplaySets;
|
|
};
|
|
|
|
/**
|
|
* Save annotations as a series
|
|
*
|
|
* @param SeriesDescription - series description
|
|
* @returns
|
|
*/
|
|
const saveFunction = async (SeriesDescription: string) => {
|
|
const dataSource = extensionManager.getActiveDataSource()[0];
|
|
const { onSaveComplete } = props;
|
|
const annotations = microscopyService.getAnnotationsForStudy(studyInstanceUID);
|
|
|
|
saving = true;
|
|
|
|
// There is only one viewer possible for one study,
|
|
// Since once study contains multiple resolution levels (series) of one whole
|
|
// Slide image.
|
|
|
|
const studyMetadata = DicomMetadataStore.getStudy(studyInstanceUID);
|
|
const displaySets = getAllDisplaySets(studyMetadata);
|
|
const smDisplaySet = displaySets.find(ds => ds.Modality === 'SM');
|
|
|
|
// Get the next available series number after 4700.
|
|
|
|
const dsWithMetadata = displaySets.filter(
|
|
ds => ds.metadata && ds.metadata.SeriesNumber && typeof ds.metadata.SeriesNumber === 'number'
|
|
);
|
|
|
|
// Generate next series number
|
|
const seriesNumbers = dsWithMetadata.map(ds => ds.metadata.SeriesNumber);
|
|
const maxSeriesNumber = Math.max(...seriesNumbers, 4700);
|
|
const SeriesNumber = maxSeriesNumber + 1;
|
|
|
|
const { instance: metadata } = smDisplaySet;
|
|
|
|
// construct SR dataset
|
|
const dataset = constructSR(metadata, { SeriesDescription, SeriesNumber }, annotations);
|
|
|
|
// Save in DICOM format
|
|
try {
|
|
if (dataSource) {
|
|
if (dataSource.wadoRoot == 'saveDicom') {
|
|
// download as DICOM file
|
|
const part10Buffer = datasetToBuffer(dataset);
|
|
saveByteArray(part10Buffer, `sr-microscopy.dcm`);
|
|
} else {
|
|
// Save into Web Data source
|
|
const { StudyInstanceUID } = dataset;
|
|
await dataSource.store.dicom(dataset);
|
|
if (StudyInstanceUID) {
|
|
dataSource.deleteStudyMetadataPromise(StudyInstanceUID);
|
|
}
|
|
}
|
|
onSaveComplete({
|
|
title: 'SR Saved',
|
|
message: 'Measurements downloaded successfully',
|
|
type: 'success',
|
|
});
|
|
} else {
|
|
console.error('Server unspecified');
|
|
}
|
|
} catch (error) {
|
|
onSaveComplete({
|
|
title: 'SR Save Failed',
|
|
message: error.message || error.toString(),
|
|
type: 'error',
|
|
});
|
|
} finally {
|
|
saving = false;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* On clicking "Reject annotations" button
|
|
*/
|
|
const onDeleteCurrentSRHandler = async () => {
|
|
try {
|
|
const activeViewport = props.viewports[props.activeViewportId];
|
|
const { StudyInstanceUID } = activeViewport;
|
|
|
|
// TODO: studies?
|
|
const study = DicomMetadataStore.getStudy(StudyInstanceUID);
|
|
|
|
const lastDerivedDisplaySet = study.derivedDisplaySets.sort((ds1: any, ds2: any) => {
|
|
const dateTime1 = Number(`${ds1.SeriesDate}${ds1.SeriesTime}`);
|
|
const dateTime2 = Number(`${ds2.SeriesDate}${ds2.SeriesTime}`);
|
|
return dateTime1 > dateTime2;
|
|
})[study.derivedDisplaySets.length - 1];
|
|
|
|
// TODO: use dataSource.reject.dicom()
|
|
// await DICOMSR.rejectMeasurements(
|
|
// study.wadoRoot,
|
|
// lastDerivedDisplaySet.StudyInstanceUID,
|
|
// lastDerivedDisplaySet.SeriesInstanceUID
|
|
// );
|
|
props.onRejectComplete({
|
|
title: 'Report rejected',
|
|
message: 'Latest report rejected successfully',
|
|
type: 'success',
|
|
});
|
|
} catch (error) {
|
|
props.onRejectComplete({
|
|
title: 'Failed to reject report',
|
|
message: error.message,
|
|
type: 'error',
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Handler for clicking event of an annotation item.
|
|
*
|
|
* @param param0
|
|
*/
|
|
const onMeasurementItemClickHandler = ({ uid }: { uid: string }) => {
|
|
const roiAnnotation = microscopyService.getAnnotation(uid);
|
|
microscopyService.selectAnnotation(roiAnnotation);
|
|
microscopyService.focusAnnotation(roiAnnotation, props.activeViewportId);
|
|
};
|
|
|
|
/**
|
|
* Handler for "Edit" action of an annotation item
|
|
* @param param0
|
|
*/
|
|
const onMeasurementItemEditHandler = ({ uid, isActive }: { uid: string; isActive: boolean }) => {
|
|
props.commandsManager.runCommand('setLabel', { uid }, 'MICROSCOPY');
|
|
};
|
|
|
|
const onMeasurementDeleteHandler = ({ uid, isActive }: { uid: string; isActive: boolean }) => {
|
|
const roiAnnotation = microscopyService.getAnnotation(uid);
|
|
microscopyService.removeAnnotation(roiAnnotation);
|
|
};
|
|
|
|
// Convert ROI annotations managed by microscopyService into our
|
|
// own format for display
|
|
const data = roiAnnotations.map((roiAnnotation, index) => {
|
|
const label = roiAnnotation.getDetailedLabel();
|
|
const area = roiAnnotation.getArea();
|
|
const length = roiAnnotation.getLength();
|
|
const shortAxisLength = roiAnnotation.roiGraphic.properties.shortAxisLength;
|
|
const isSelected: boolean = selectedAnnotation === roiAnnotation;
|
|
|
|
// other events
|
|
const { uid } = roiAnnotation;
|
|
|
|
// display text
|
|
const displayText = [];
|
|
|
|
if (area !== undefined) {
|
|
displayText.push(formatArea(area));
|
|
} else if (length !== undefined) {
|
|
displayText.push(
|
|
shortAxisLength
|
|
? `${formatLength(length, 'μm')} x ${formatLength(shortAxisLength, 'μm')}`
|
|
: `${formatLength(length, 'μm')}`
|
|
);
|
|
}
|
|
|
|
// convert to measurementItem format compatible with <MeasurementTable /> component
|
|
return {
|
|
uid,
|
|
index,
|
|
label,
|
|
isActive: isSelected,
|
|
displayText,
|
|
roiAnnotation,
|
|
};
|
|
});
|
|
|
|
return (
|
|
<>
|
|
{props.renderHeader && (
|
|
<>
|
|
<div className="bg-primary-dark flex select-none rounded-t pt-1.5 pb-[2px]">
|
|
<div className="flex h-[24px] w-full cursor-pointer select-none justify-center self-center text-[14px]">
|
|
<div className="text-primary-active flex grow cursor-pointer select-none justify-center self-center text-[13px]">
|
|
<span>{props.tab.label}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{props.getCloseIcon()}
|
|
</div>
|
|
<Separator orientation="horizontal" className="bg-black" thickness="2px" />
|
|
</>
|
|
)}{' '}
|
|
<div
|
|
className="ohif-scrollbar overflow-y-auto overflow-x-hidden"
|
|
data-cy={'measurements-panel'}
|
|
>
|
|
<MeasurementTable
|
|
title="Measurements"
|
|
servicesManager={props.servicesManager}
|
|
data={data}
|
|
onClick={onMeasurementItemClickHandler}
|
|
onEdit={onMeasurementItemEditHandler}
|
|
onDelete={onMeasurementDeleteHandler}
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const connectedMicroscopyPanel = withTranslation(['MicroscopyTable', 'Common'])(MicroscopyPanel);
|
|
|
|
export default connectedMicroscopyPanel;
|