feat: Start using group filtering to define measurements table layout (#4501)

This commit is contained in:
Bill Wallace 2025-01-09 22:01:42 -05:00 committed by GitHub
parent bc73de9710
commit 82440e88d5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 357 additions and 210 deletions

View File

@ -1,8 +1,9 @@
import { metaData, utilities } from '@cornerstonejs/core'; import { metaData, utilities } from '@cornerstonejs/core';
import OHIF, { DicomMetadataStore } from '@ohif/core'; import OHIF, { DicomMetadataStore, utils } from '@ohif/core';
import dcmjs from 'dcmjs'; import dcmjs from 'dcmjs';
import { adaptersSR } from '@cornerstonejs/adapters'; import { adaptersSR } from '@cornerstonejs/adapters';
import { showLabelAnnotationPopup, colorPickerDialog } from '@ohif/extension-default';
import getFilteredCornerstoneToolState from './utils/getFilteredCornerstoneToolState'; import getFilteredCornerstoneToolState from './utils/getFilteredCornerstoneToolState';
import hydrateStructuredReport from './utils/hydrateStructuredReport'; import hydrateStructuredReport from './utils/hydrateStructuredReport';
@ -11,7 +12,6 @@ const { MeasurementReport } = adaptersSR.Cornerstone3D;
const { log } = OHIF; const { log } = OHIF;
/** /**
*
* @param measurementData An array of measurements from the measurements service * @param measurementData An array of measurements from the measurements service
* that you wish to serialize. * that you wish to serialize.
* @param additionalFindingTypes toolTypes that should be stored with labels as Findings * @param additionalFindingTypes toolTypes that should be stored with labels as Findings
@ -43,8 +43,30 @@ const _generateReport = (measurementData, additionalFindingTypes, options = {})
const commandsModule = (props: withAppTypes) => { const commandsModule = (props: withAppTypes) => {
const { servicesManager, extensionManager, commandsManager } = props; const { servicesManager, extensionManager, commandsManager } = props;
const { customizationService, displaySetService, viewportGridService } = servicesManager.services; const { customizationService, measurementService, viewportGridService, uiDialogService } =
servicesManager.services;
const actions = { const actions = {
changeColorMeasurement: ({ uid }) => {
// When this gets supported, it probably belongs in cornerstone, not sr
throw new Error('Unsupported operation: changeColorMeasurement');
// const { color } = measurementService.getMeasurement(uid);
// const rgbaColor = {
// r: color[0],
// g: color[1],
// b: color[2],
// a: color[3] / 255.0,
// };
// colorPickerDialog(uiDialogService, rgbaColor, (newRgbaColor, actionId) => {
// if (actionId === 'cancel') {
// return;
// }
// const color = [newRgbaColor.r, newRgbaColor.g, newRgbaColor.b, newRgbaColor.a * 255.0];
// segmentationService.setSegmentColor(viewportId, segmentationId, segmentIndex, color);
// });
},
/** /**
* *
* @param measurementData An array of measurements from the measurements service * @param measurementData An array of measurements from the measurements service

View File

@ -14,7 +14,7 @@ import {
annotation, annotation,
} from '@cornerstonejs/tools'; } from '@cornerstonejs/tools';
import { Types as OhifTypes } from '@ohif/core'; import { Types as OhifTypes, utils } from '@ohif/core';
import i18n from '@ohif/i18n'; import i18n from '@ohif/i18n';
import { import {
callLabelAutocompleteDialog, callLabelAutocompleteDialog,
@ -295,6 +295,60 @@ function commandsModule({
measurementService.update(updatedMeasurement.uid, updatedMeasurement, true); measurementService.update(updatedMeasurement.uid, updatedMeasurement, true);
}, },
/**
* Jumps to the specified (by uid) measurement in the active viewport.
* Also marks any provided display measurements isActive value
*/
jumpToMeasurement: ({ uid, displayMeasurements = [] }) => {
measurementService.jumpToMeasurement(viewportGridService.getActiveViewportId(), uid);
for (const measurement of displayMeasurements) {
measurement.isActive = measurement.uid === uid;
}
},
removeMeasurement: ({ uid }) => {
measurementService.remove(uid);
},
renameMeasurement: ({ uid }) => {
const labelConfig = customizationService.get('measurementLabels');
const measurement = measurementService.getMeasurement(uid);
showLabelAnnotationPopup(measurement, uiDialogService, labelConfig).then(val => {
measurementService.update(
uid,
{
...val,
},
true
);
});
},
toggleLockMeasurement: ({ uid }) => {
measurementService.toggleLockMeasurement(uid);
},
toggleVisibilityMeasurement: ({ uid }) => {
measurementService.toggleVisibilityMeasurement(uid);
},
/**
* Clear the measurements
*/
clearMeasurements: options => {
const { measurementFilter } = options;
measurementService.clearMeasurements(
measurementFilter ? measurementFilter.bind(options) : null
);
},
/**
* Download the CSV report for the measurements.
*/
downloadCSVMeasurementsReport: ({ measurementFilter }) => {
utils.downloadCSVReport(measurementService.getMeasurements(measurementFilter));
},
// Retrieve value commands // Retrieve value commands
getActiveViewportEnabledElement: _getActiveViewportEnabledElement, getActiveViewportEnabledElement: _getActiveViewportEnabledElement,
@ -1281,6 +1335,27 @@ function commandsModule({
updateMeasurement: { updateMeasurement: {
commandFn: actions.updateMeasurement, commandFn: actions.updateMeasurement,
}, },
clearMeasurements: {
commandFn: actions.clearMeasurements,
},
jumpToMeasurement: {
commandFn: actions.jumpToMeasurement,
},
removeMeasurement: {
commandFn: actions.removeMeasurement,
},
renameMeasurement: {
commandFn: actions.renameMeasurement,
},
toggleLockMeasurement: {
commandFn: actions.toggleLockMeasurement,
},
toggleVisibilityMeasurement: {
commandFn: actions.toggleVisibilityMeasurement,
},
downloadCSVMeasurementsReport: {
commandFn: actions.downloadCSVMeasurementsReport,
},
setViewportWindowLevel: { setViewportWindowLevel: {
commandFn: actions.setViewportWindowLevel, commandFn: actions.setViewportWindowLevel,
}, },

View File

@ -0,0 +1,25 @@
import React from 'react';
import { DicomMetadataStore, utils } from '@ohif/core';
import { StudySummary } from '@ohif/ui-next';
const { formatDate } = utils;
export function StudySummaryFromMetadata({ StudyInstanceUID }) {
if (!StudyInstanceUID) {
return null;
}
const studyMeta = DicomMetadataStore.getStudy(StudyInstanceUID);
if (!studyMeta?.series?.length) {
return null;
}
const instanceMeta = studyMeta.series[0].instances[0];
const { StudyDate, StudyDescription } = instanceMeta;
return (
<StudySummary
date={formatDate(StudyDate)}
description={StudyDescription}
></StudySummary>
);
}

View File

@ -3,7 +3,7 @@ import React from 'react';
import { Toolbox } from '@ohif/ui-next'; import { Toolbox } from '@ohif/ui-next';
import PanelSegmentation from './panels/PanelSegmentation'; import PanelSegmentation from './panels/PanelSegmentation';
import ActiveViewportWindowLevel from './components/ActiveViewportWindowLevel'; import ActiveViewportWindowLevel from './components/ActiveViewportWindowLevel';
import PanelMeasurementTable from './panels/PanelMeasurement'; import PanelMeasurement from './panels/PanelMeasurement';
const getPanelModule = ({ commandsManager, servicesManager, extensionManager }: withAppTypes) => { const getPanelModule = ({ commandsManager, servicesManager, extensionManager }: withAppTypes) => {
const wrappedPanelSegmentation = ({ configuration }) => { const wrappedPanelSegmentation = ({ configuration }) => {
@ -59,7 +59,7 @@ const getPanelModule = ({ commandsManager, servicesManager, extensionManager }:
const wrappedPanelMeasurement = ({ configuration }) => { const wrappedPanelMeasurement = ({ configuration }) => {
return ( return (
<PanelMeasurementTable <PanelMeasurement
commandsManager={commandsManager} commandsManager={commandsManager}
servicesManager={servicesManager} servicesManager={servicesManager}
extensionManager={extensionManager} extensionManager={extensionManager}

View File

@ -62,10 +62,7 @@ export function useMeasurements(servicesManager, { measurementFilter }) {
useEffect(() => { useEffect(() => {
const updateDisplayMeasurements = () => { const updateDisplayMeasurements = () => {
let measurements = measurementService.getMeasurements(); let measurements = measurementService.getMeasurements(measurementFilter);
if (measurementFilter) {
measurements = measurements.filter(measurementFilter);
}
const mappedMeasurements = measurements.map(m => const mappedMeasurements = measurements.map(m =>
mapMeasurementToDisplay(m, displaySetService) mapMeasurementToDisplay(m, displaySetService)
); );

View File

@ -53,6 +53,7 @@ import PanelSegmentation from './panels/PanelSegmentation';
import PanelMeasurement from './panels/PanelMeasurement'; import PanelMeasurement from './panels/PanelMeasurement';
import DicomUpload from './components/DicomUpload/DicomUpload'; import DicomUpload from './components/DicomUpload/DicomUpload';
import { useSegmentations } from './hooks/useSegmentations'; import { useSegmentations } from './hooks/useSegmentations';
import { StudySummaryFromMetadata } from './components/StudySummaryFromMetadata';
const { imageRetrieveMetadataProvider } = cornerstone.utilities; const { imageRetrieveMetadataProvider } = cornerstone.utilities;
@ -254,5 +255,6 @@ export {
PanelSegmentation, PanelSegmentation,
PanelMeasurement, PanelMeasurement,
DicomUpload, DicomUpload,
StudySummaryFromMetadata,
}; };
export default cornerstoneExtension; export default cornerstoneExtension;

View File

@ -1,19 +1,29 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { utils } from '@ohif/core';
import { useViewportGrid } from '@ohif/ui-next'; import { useViewportGrid } from '@ohif/ui-next';
import { MeasurementTable } from '@ohif/ui-next'; import { MeasurementTable } from '@ohif/ui-next';
import debounce from 'lodash.debounce'; import debounce from 'lodash.debounce';
import { useMeasurements } from '../hooks/useMeasurements'; import { useMeasurements } from '../hooks/useMeasurements';
import { showLabelAnnotationPopup, colorPickerDialog } from '@ohif/extension-default';
export default function PanelMeasurementTable({ const {
filterAdditionalFindings: filterAdditionalFinding,
filterOr,
filterAny,
} = utils.MeasurementFilters;
export type withAppAndFilters = withAppTypes & {
measurementFilter: (item) => boolean;
};
export default function PanelMeasurement({
servicesManager, servicesManager,
commandsManager,
customHeader, customHeader,
measurementFilter, measurementFilter = filterAny,
}: withAppTypes): React.ReactNode { }: withAppAndFilters): React.ReactNode {
const measurementsPanelRef = useRef(null); const measurementsPanelRef = useRef(null);
const [viewportGrid] = useViewportGrid(); const { measurementService } = servicesManager.services;
const { measurementService, customizationService, uiDialogService } = servicesManager.services;
const displayMeasurements = useMeasurements(servicesManager, { const displayMeasurements = useMeasurements(servicesManager, {
measurementFilter, measurementFilter,
@ -27,75 +37,37 @@ export default function PanelMeasurementTable({
} }
}, [displayMeasurements.length]); }, [displayMeasurements.length]);
const onMeasurementItemClickHandler = (uid: string, isActive: boolean) => { const bindCommand = (name: string | string[], options?) => {
if (isActive) { return (uid: string) => {
return; commandsManager.run(name, { ...options, uid });
} };
const measurements = [...displayMeasurements];
const measurement = measurements.find(m => m.uid === uid);
measurements.forEach(m => (m.isActive = m.uid !== uid ? false : true));
measurement.isActive = true;
}; };
const jumpToImage = (uid: string) => { const jumpToImage = bindCommand('jumpToMeasurement', { displayMeasurements });
measurementService.jumpToMeasurement(viewportGrid.activeViewportId, uid); const removeMeasurement = bindCommand('removeMeasurement');
onMeasurementItemClickHandler(uid, true); const renameMeasurement = bindCommand(['jumpToMeasurement', 'renameMeasurement'], {
}; displayMeasurements,
const removeMeasurement = (uid: string) => {
measurementService.remove(uid);
};
const renameMeasurement = (uid: string) => {
jumpToImage(uid);
const labelConfig = customizationService.get('measurementLabels');
const measurement = measurementService.getMeasurement(uid);
showLabelAnnotationPopup(measurement, uiDialogService, labelConfig).then(val => {
measurementService.update(
uid,
{
...val,
},
true
);
}); });
}; const toggleLockMeasurement = bindCommand('toggleLockMeasurement');
const toggleVisibilityMeasurement = bindCommand('toggleVisibilityMeasurement');
const changeColorMeasurement = (uid: string) => { const additionalFilter = filterAdditionalFinding(measurementService);
const { color } = measurementService.getMeasurement(uid);
const rgbaColor = {
r: color[0],
g: color[1],
b: color[2],
a: color[3] / 255.0,
};
colorPickerDialog(uiDialogService, rgbaColor, (newRgbaColor, actionId) => {
if (actionId === 'cancel') {
return;
}
const color = [newRgbaColor.r, newRgbaColor.g, newRgbaColor.b, newRgbaColor.a * 255.0];
// segmentationService.setSegmentColor(viewportId, segmentationId, segmentIndex, color);
});
};
const toggleLockMeasurement = (uid: string) => {
measurementService.toggleLockMeasurement(uid);
};
const toggleVisibilityMeasurement = (uid: string) => {
measurementService.toggleVisibilityMeasurement(uid);
};
const measurements = displayMeasurements.filter( const measurements = displayMeasurements.filter(
dm => dm.measurementType !== measurementService.VALUE_TYPES.POINT && dm.referencedImageId item => !additionalFilter(item) && measurementFilter(item)
); );
const additionalFindings = displayMeasurements.filter( const additionalFindings = displayMeasurements.filter(
dm => dm.measurementType === measurementService.VALUE_TYPES.POINT && dm.referencedImageId item => additionalFilter(item) && measurementFilter(item)
); );
const onArgs = {
onClick: jumpToImage,
onDelete: removeMeasurement,
onToggleVisibility: toggleVisibilityMeasurement,
onToggleLocked: toggleLockMeasurement,
onRename: renameMeasurement,
};
return ( return (
<> <>
<div <div
@ -104,13 +76,10 @@ export default function PanelMeasurementTable({
data-cy={'trackedMeasurements-panel'} data-cy={'trackedMeasurements-panel'}
> >
<MeasurementTable <MeasurementTable
key="tracked"
title="Measurements" title="Measurements"
data={measurements} data={measurements}
onClick={jumpToImage} {...onArgs}
onDelete={removeMeasurement}
onToggleVisibility={toggleVisibilityMeasurement}
onToggleLocked={toggleLockMeasurement}
onRename={renameMeasurement}
// onColor={changeColorMeasurement} // onColor={changeColorMeasurement}
> >
<MeasurementTable.Header> <MeasurementTable.Header>
@ -129,14 +98,10 @@ export default function PanelMeasurementTable({
</MeasurementTable> </MeasurementTable>
{additionalFindings.length > 0 && ( {additionalFindings.length > 0 && (
<MeasurementTable <MeasurementTable
key="additional"
data={additionalFindings} data={additionalFindings}
title="Additional Findings" title="Additional Findings"
onClick={jumpToImage} {...onArgs}
onDelete={removeMeasurement}
onToggleVisibility={toggleVisibilityMeasurement}
onToggleLocked={toggleLockMeasurement}
onRename={renameMeasurement}
// onColor={changeColorMeasurement}
> >
<MeasurementTable.Body /> <MeasurementTable.Body />
</MeasurementTable> </MeasurementTable>

View File

@ -562,27 +562,13 @@ const commandsModule = ({
}; };
const definitions = { const definitions = {
showContextMenu: { showContextMenu: actions.showContextMenu,
commandFn: actions.showContextMenu, closeContextMenu: actions.closeContextMenu,
}, clearMeasurements: actions.clearMeasurements,
closeContextMenu: { displayNotification: actions.displayNotification,
commandFn: actions.closeContextMenu, setHangingProtocol: actions.setHangingProtocol,
}, toggleHangingProtocol: actions.toggleHangingProtocol,
clearMeasurements: { navigateHistory: actions.navigateHistory,
commandFn: actions.clearMeasurements,
},
displayNotification: {
commandFn: actions.displayNotification,
},
setHangingProtocol: {
commandFn: actions.setHangingProtocol,
},
toggleHangingProtocol: {
commandFn: actions.toggleHangingProtocol,
},
navigateHistory: {
commandFn: actions.navigateHistory,
},
nextStage: { nextStage: {
commandFn: actions.deltaStage, commandFn: actions.deltaStage,
options: { direction: 1 }, options: { direction: 1 },
@ -591,18 +577,10 @@ const commandsModule = ({
commandFn: actions.deltaStage, commandFn: actions.deltaStage,
options: { direction: -1 }, options: { direction: -1 },
}, },
setViewportGridLayout: { setViewportGridLayout: actions.setViewportGridLayout,
commandFn: actions.setViewportGridLayout, toggleOneUp: actions.toggleOneUp,
}, openDICOMTagViewer: actions.openDICOMTagViewer,
toggleOneUp: { updateViewportDisplaySet: actions.updateViewportDisplaySet,
commandFn: actions.toggleOneUp,
},
openDICOMTagViewer: {
commandFn: actions.openDICOMTagViewer,
},
updateViewportDisplaySet: {
commandFn: actions.updateViewportDisplaySet,
},
}; };
return { return {

View File

@ -1,20 +1,12 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { PanelMeasurement } from '@ohif/extension-cornerstone';
import { useViewportGrid } from '@ohif/ui-next';
import { StudySummary } from '@ohif/ui-next';
import { Button, Icons } from '@ohif/ui-next';
import { DicomMetadataStore, utils } from '@ohif/core'; import { DicomMetadataStore, utils } from '@ohif/core';
import { useViewportGrid } from '@ohif/ui-next';
import { Button, Icons } from '@ohif/ui-next';
import { PanelMeasurement, StudySummaryFromMetadata } from '@ohif/extension-cornerstone';
import { useTrackedMeasurements } from '../getContextModule'; import { useTrackedMeasurements } from '../getContextModule';
import { useTranslation } from 'react-i18next';
const { downloadCSVReport, formatDate } = utils; const { filterAnd, filterPlanarMeasurement, filterAny, filterMeasurementsBySeriesUID } =
utils.MeasurementFilters;
const DISPLAY_STUDY_SUMMARY_INITIAL_VALUE = {
key: undefined, //
date: '', // '07-Sep-2010',
modality: '', // 'CT',
description: '', // 'CHEST/ABD/PELVIS W CONTRAST',
};
function PanelMeasurementTableTracking({ function PanelMeasurementTableTracking({
servicesManager, servicesManager,
@ -22,52 +14,12 @@ function PanelMeasurementTableTracking({
commandsManager, commandsManager,
}: withAppTypes) { }: withAppTypes) {
const [viewportGrid] = useViewportGrid(); const [viewportGrid] = useViewportGrid();
const { t } = useTranslation('MeasurementTable'); const { customizationService } = servicesManager.services;
const { measurementService, customizationService } = servicesManager.services;
const [trackedMeasurements, sendTrackedMeasurementsEvent] = useTrackedMeasurements(); const [trackedMeasurements, sendTrackedMeasurementsEvent] = useTrackedMeasurements();
const { trackedStudy, trackedSeries } = trackedMeasurements.context; const { trackedStudy, trackedSeries } = trackedMeasurements.context;
const [displayStudySummary, setDisplayStudySummary] = useState( const measurementFilter = trackedStudy
DISPLAY_STUDY_SUMMARY_INITIAL_VALUE ? filterAnd(filterPlanarMeasurement, filterMeasurementsBySeriesUID(trackedSeries))
); : filterPlanarMeasurement;
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( const { disableEditing } = customizationService.getCustomization(
'PanelMeasurement.disableEditing', 'PanelMeasurement.disableEditing',
@ -79,20 +31,12 @@ function PanelMeasurementTableTracking({
return ( return (
<> <>
{displayStudySummary.key && ( <StudySummaryFromMetadata StudyInstanceUID={trackedStudy} />
<StudySummary
date={formatDate(displayStudySummary.date)}
description={displayStudySummary.description}
/>
)}
<PanelMeasurement <PanelMeasurement
servicesManager={servicesManager} servicesManager={servicesManager}
extensionManager={extensionManager} extensionManager={extensionManager}
commandsManager={commandsManager} commandsManager={commandsManager}
measurementFilter={measurement => measurementFilter={measurementFilter}
trackedStudy === measurement.referenceStudyUID &&
trackedSeries.includes(measurement.referenceSeriesUID)
}
customHeader={({ additionalFindings, measurements }) => { customHeader={({ additionalFindings, measurements }) => {
const disabled = additionalFindings.length === 0 && measurements.length === 0; const disabled = additionalFindings.length === 0 && measurements.length === 0;
@ -108,14 +52,9 @@ function PanelMeasurementTableTracking({
variant="ghost" variant="ghost"
className="pl-1.5" className="pl-1.5"
onClick={() => { onClick={() => {
const measurements = measurementService.getMeasurements(); commandsManager.runCommand('downloadCSVMeasurementsReport', {
const trackedMeasurements = measurements.filter( measurementFilter,
m => });
trackedStudy === m.referenceStudyUID &&
trackedSeries.includes(m.referenceSeriesUID)
);
downloadCSVReport(trackedMeasurements);
}} }}
> >
<Icons.Download className="h-5 w-5" /> <Icons.Download className="h-5 w-5" />
@ -140,7 +79,7 @@ function PanelMeasurementTableTracking({
variant="ghost" variant="ghost"
className="pl-0.5" className="pl-0.5"
onClick={() => { onClick={() => {
measurementService.clearMeasurements(); commandsManager.runCommand('clearMeasurements', { measurementFilter });
}} }}
> >
<Icons.Delete /> <Icons.Delete />

View File

@ -15,7 +15,6 @@ const ohif = {
wsiSopClassHandler: wsiSopClassHandler:
'@ohif/extension-cornerstone.sopClassHandlerModule.DicomMicroscopySopClassHandler', '@ohif/extension-cornerstone.sopClassHandlerModule.DicomMicroscopySopClassHandler',
thumbnailList: '@ohif/extension-default.panelModule.seriesList', thumbnailList: '@ohif/extension-default.panelModule.seriesList',
measurements: '@ohif/extension-default.panelModule.measurements',
}; };
const tracked = { const tracked = {
@ -46,6 +45,7 @@ const dicomSeg = {
const cornerstone = { const cornerstone = {
panel: '@ohif/extension-cornerstone.panelModule.panelSegmentation', panel: '@ohif/extension-cornerstone.panelModule.panelSegmentation',
measurements: '@ohif/extension-cornerstone.panelModule.panelMeasurement',
}; };
const dicomPmap = { const dicomPmap = {
@ -150,7 +150,8 @@ function modeFactory() {
// leftPanels: [ohif.thumbnailList], // leftPanels: [ohif.thumbnailList],
// rightPanels: [dicomSeg.panel, ohif.measurements], // rightPanels: [dicomSeg.panel, ohif.measurements],
leftPanels: [tracked.thumbnailList], leftPanels: [tracked.thumbnailList],
rightPanels: [cornerstone.panel, tracked.measurements], // Can use cornerstone.measurements for all measurements
rightPanels: [cornerstone.panel, tracked.measurements, cornerstone.measurements],
// rightPanelClosed: true, // optional prop to start with collapse panels // rightPanelClosed: true, // optional prop to start with collapse panels
viewports: [ viewports: [
{ {

View File

@ -161,6 +161,16 @@ export class CommandsManager {
* Run one or more commands with specified extra options. * Run one or more commands with specified extra options.
* Returns the result of the last command run. * Returns the result of the last command run.
* *
* Example commands to run are:
* * 'updateMeasurement'
* * `{commandName: 'displayWhatever'}`
* * `['updateMeasurement', {commandName: 'displayWhatever'}]`
* * `{ commands: 'updateMeasurement' }`
* * `{ commands: ['updateMeasurement', {commandName: 'displayWhatever'}]}`
*
* Note how the various styles can be mixed, simplifying the declaration of
* sets of commands.
*
* @param toRun - A specification of one or more commands, * @param toRun - A specification of one or more commands,
* typically an object of { commandName, commandOptions, context } * typically an object of { commandName, commandOptions, context }
* or an array of such objects. It can also be a single commandName as string * or an array of such objects. It can also be a single commandName as string
@ -169,7 +179,7 @@ export class CommandsManager {
* the commandOptions specified in the base. * the commandOptions specified in the base.
*/ */
public run( public run(
toRun: Command | Commands | Command[] | string | undefined, toRun: Command | Commands | (Command | string)[] | string | undefined,
options?: Record<string, unknown> options?: Record<string, unknown>
): unknown { ): unknown {
if (!toRun) { if (!toRun) {

View File

@ -597,7 +597,10 @@ export default class ExtensionManager extends PubSubService {
} }
Object.keys(definitions).forEach(commandName => { Object.keys(definitions).forEach(commandName => {
const commandDefinition = definitions[commandName]; let commandDefinition = definitions[commandName];
if (typeof commandDefinition === 'function') {
commandDefinition = { commandFn: commandDefinition };
}
const commandHasContextThatDoesNotExist = const commandHasContextThatDoesNotExist =
commandDefinition.context && !this._commandsManager.getContext(commandDefinition.context); commandDefinition.context && !this._commandsManager.getContext(commandDefinition.context);

View File

@ -92,6 +92,8 @@ const VALUE_TYPES = {
ROI_THRESHOLD_MANUAL: 'value_type::roiThresholdManual', ROI_THRESHOLD_MANUAL: 'value_type::roiThresholdManual',
}; };
export type MeasurementFilter = (measurement) => boolean;
/** /**
* MeasurementService class that supports source management and measurement management. * MeasurementService class that supports source management and measurement management.
* Sources can be any library that can provide "annotations" (e.g. cornerstone-tools, cornerstone, etc.) * Sources can be any library that can provide "annotations" (e.g. cornerstone-tools, cornerstone, etc.)
@ -108,7 +110,7 @@ class MeasurementService extends PubSubService {
public static REGISTRATION = { public static REGISTRATION = {
name: 'measurementService', name: 'measurementService',
altName: 'MeasurementService', altName: 'MeasurementService',
create: ({ configuration = {} }) => { create: _options => {
return new MeasurementService(); return new MeasurementService();
}, },
}; };
@ -120,10 +122,11 @@ class MeasurementService extends PubSubService {
private measurements = new Map(); private measurements = new Map();
private unmappedMeasurements = new Map(); private unmappedMeasurements = new Map();
private sources = {};
private mappings = {};
constructor() { constructor() {
super(EVENTS); super(EVENTS);
this.sources = {};
this.mappings = {};
} }
/** /**
@ -164,12 +167,16 @@ class MeasurementService extends PubSubService {
} }
/** /**
* Get all measurements. * Gets measurements, optionally filtered by the filter
* function.
* *
* @return {Measurement[]} Array of measurements * @return {Measurement[]} Array of measurements
*/ */
getMeasurements() { public getMeasurements(filter?: MeasurementFilter) {
return [...this.measurements.values()]; const measurements = [...this.measurements.values()];
return filter
? measurements.filter(measurement => filter.call(this, measurement))
: measurements;
} }
/** /**
@ -582,11 +589,20 @@ class MeasurementService extends PubSubService {
}); });
} }
clearMeasurements() { /**
* Clears measurements that match the filter, defaulting to all of them.
* That allows, for example, clearing all of a single studies measurements
* without needing to clear other measurements.
*/
public clearMeasurements(filter?: MeasurementFilter) {
// Make a copy of the measurements // Make a copy of the measurements
const measurements = [...this.measurements.values(), ...this.unmappedMeasurements.values()]; const toClear = this.getMeasurements(filter);
this.unmappedMeasurements.clear(); const unmappedClear = filter
this.measurements.clear(); ? [...this.unmappedMeasurements.values()].filter(filter)
: this.unmappedMeasurements;
const measurements = [...toClear, ...unmappedClear];
unmappedClear.forEach(measurement => this.unmappedMeasurements.delete(measurement.uid));
toClear.forEach(measurement => this.measurements.delete(measurement.uid));
this._broadcastEvent(this.EVENTS.MEASUREMENTS_CLEARED, { measurements }); this._broadcastEvent(this.EVENTS.MEASUREMENTS_CLEARED, { measurements });
} }

View File

@ -43,6 +43,7 @@ describe('Top level exports', () => {
'progressTrackingUtils', 'progressTrackingUtils',
'uuidv4', 'uuidv4',
'addAccessors', 'addAccessors',
'MeasurementFilters',
].sort(); ].sort();
const exports = Object.keys(utils.default).sort(); const exports = Object.keys(utils.default).sort();

View File

@ -39,6 +39,7 @@ import {
import { splitComma, getSplitParam } from './splitComma'; import { splitComma, getSplitParam } from './splitComma';
import { createStudyBrowserTabs } from './createStudyBrowserTabs'; import { createStudyBrowserTabs } from './createStudyBrowserTabs';
import { sopClassDictionary } from './sopClassDictionary'; import { sopClassDictionary } from './sopClassDictionary';
import * as MeasurementFilters from './measurementFilters';
// Commented out unused functionality. // Commented out unused functionality.
// Need to implement new mechanism for derived displaySets using the displaySetManager. // Need to implement new mechanism for derived displaySets using the displaySetManager.
@ -84,6 +85,7 @@ const utils = {
getSplitParam, getSplitParam,
generateAcceptHeader, generateAcceptHeader,
createStudyBrowserTabs, createStudyBrowserTabs,
MeasurementFilters,
}; };
export { export {
@ -117,6 +119,7 @@ export {
getSplitParam, getSplitParam,
generateAcceptHeader, generateAcceptHeader,
createStudyBrowserTabs, createStudyBrowserTabs,
MeasurementFilters,
}; };
export default utils; export default utils;

View File

@ -0,0 +1,110 @@
/**
* Returns a filter function which filters for measurements belonging to both
* the study and series.
*/
export function filterMeasurementsBySeriesUID(selectedSeries: string[]) {
return measurement => selectedSeries.includes(measurement.referenceSeriesUID);
}
/**
* @returns true for measurements include referencedImageId (coplanar with an image)
*/
export function filterPlanarMeasurement(measurement) {
return measurement?.referencedImageId;
}
/** A filter that always returns true */
export function filterAny(_measurement) {
return true;
}
/** A filter that excludes everything */
export function filterNone(_measurement) {
return false;
}
/**
* Filters the measurements which are found in any of the specified
* filters. Strings will be looked up by name.
*/
export function filterOr(...filters) {
return function (item) {
for (let filter of filters) {
if (typeof filter === 'string') {
filter = this[filter];
}
if (typeof filter !== 'function') {
continue;
}
if (filter.call(this, item)) {
return true;
}
}
return false;
};
}
/**
* Filters for additional findings, that is, measurements with
* a value of type point, and having a referenced image
*/
export function filterAdditionalFindings(measurementService) {
const { POINT } = measurementService.VALUE_TYPES;
return dm => dm.type === POINT && dm.referencedImageId;
}
/**
* Returns a filter that applies the second filter unless the first filter would
* include the given measurement.
* That is, (!filterUnless) && filterThen
*/
export function filterUnless(filterUnless, filterThen) {
return item => (filterUnless(item) ? false : filterThen(item));
}
const isString = s => typeof s === 'string' || s instanceof String;
/**
* Returns true if all the filters return true.
* Any filter can be a string name of a filter on the "this" object
* called on the final filter call.
*/
export function filterAnd(...filters) {
return function (item) {
for (const filter of filters) {
if (isString(filter)) {
if (!this[filter](item)) {
return false;
}
} else if (!filter.call(this, item)) {
return false;
}
}
return true;
};
}
/**
* Returns a filter that returns true if none of the filters supplied return true.
* Any filter supplied can be a name, in which case hte filter will be retrieved
* from "this" object on the call.
*
* For example, for filterNot("otherFilterName"), if that is called on
* `{ otherFilterName: filterNone }`
* then otherFilterName will be called, returning false in this case and
* filterNot will return true.
*
*
*/
export function filterNot(...filters) {
if (filters.length !== 1) {
return filterAnd.apply(null, filters.map(filterNot));
}
const [filter] = filters;
if (isString(filter)) {
return function (item) {
return !this[filter](item);
};
}
return item => !filter(item);
}