feat: Group findings table values with custom components/grouping functions (#4712)

This commit is contained in:
Bill Wallace authored and GitHub committed 2025-03-20 09:22:45 -04:00
1 parent 0522d58d9b
commit df8efba82c
48 files changed
+1256 -631

No files matched your search

@@ -262,7 +262,6 @@ function _getInstanceFromSegmentations(segmentations, { servicesManager }) {
}
function updateSegmentationsChartDisplaySet({ servicesManager }: withAppTypes): void {
debugger;
const { segmentationService } = servicesManager.services;
const segmentations = segmentationService.getSegmentations();
const { seriesMetadata, instance } =
+24 -20
View File
@@ -6,6 +6,8 @@ import {
Types as CoreTypes,
cache,
BaseVolumeViewport,
triggerEvent,
eventTarget,
} from '@cornerstonejs/core';
import {
ToolGroupManager,
@@ -13,6 +15,7 @@ import {
utilities as cstUtils,
ReferenceLinesTool,
annotation,
Types as ToolTypes,
} from '@cornerstonejs/tools';
import * as cornerstoneTools from '@cornerstonejs/tools';
import * as labelmapInterpolation from '@cornerstonejs/labelmap-interpolation';
@@ -35,6 +38,7 @@ import { toolNames } from './initCornerstoneTools';
import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm';
import { updateSegmentBidirectionalStats } from './utils/updateSegmentationStats';
import { generateSegmentationCSVReport } from './utils/generateSegmentationCSVReport';
const { DefaultHistoryMemo } = csUtils.HistoryMemo;
const toggleSyncFunctions = {
imageSlice: toggleImageSliceSync,
@@ -436,25 +440,26 @@ function commandsModule({
},
removeMeasurement: ({ uid }) => {
measurementService.remove(uid);
if (Array.isArray(uid)) {
measurementService.removeMany(uid);
} else {
measurementService.remove(uid);
}
},
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
);
toggleVisibilityMeasurement: ({ uid, items, visibility }) => {
if (visibility === undefined && items?.length) {
visibility = !items[0].isVisible;
}
if (Array.isArray(uid)) {
measurementService.toggleVisibilityMeasurementMany(uid, visibility);
} else {
measurementService.toggleVisibilityMeasurement(uid, visibility);
}
},
/**
@@ -514,16 +519,18 @@ function commandsModule({
viewportGridService.setActiveViewportId(viewportId);
},
arrowTextCallback: ({ callback }) => {
arrowTextCallback: async ({ callback }) => {
const labelConfig = customizationService.getCustomization('measurementLabels');
const renderContent = customizationService.getCustomization('ui.labellingComponent');
callInputDialogAutoComplete({
const value = await callInputDialogAutoComplete({
uiDialogService,
labelConfig,
renderContent,
});
callback?.(value);
},
toggleCine: () => {
const { viewports } = viewportGridService.getState();
const { isCineEnabled } = cineService.getState();
@@ -879,7 +886,7 @@ function commandsModule({
const options = { imageIndex: jumpIndex };
csUtils.jumpToSlice(viewport.element, options);
},
scroll: ({ direction }) => {
scroll: (options: ToolTypes.ScrollOptions) => {
const enabledElement = _getActiveViewportEnabledElement();
if (!enabledElement) {
@@ -887,7 +894,6 @@ function commandsModule({
}
const { viewport } = enabledElement;
const options = { delta: direction };
csUtils.scroll(viewport, options);
},
@@ -1490,6 +1496,7 @@ function commandsModule({
viewportGridService.getActiveViewportId()
);
},
deleteActiveAnnotation: () => {
const activeAnnotationsUID = cornerstoneTools.annotation.selection.getAnnotationsSelected();
activeAnnotationsUID.forEach(activeAnnotationUID => {
@@ -1577,9 +1584,6 @@ function commandsModule({
updateMeasurement: {
commandFn: actions.updateMeasurement,
},
clearMeasurements: {
commandFn: actions.clearMeasurements,
},
jumpToMeasurement: {
commandFn: actions.jumpToMeasurement,
},
@@ -0,0 +1,192 @@
import React from 'react';
import { useSystem } from '@ohif/core';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@ohif/ui-next';
import { ChevronDownIcon } from '@radix-ui/react-icons';
export type AccordionGrouping = {
[name: string]: unknown;
};
export type AccordionGroupProps = {
grouping: AccordionGrouping;
};
/**
* Searches for the required type from the provided allChildren list and
* renders them.
*/
export const CloneChildren = props => {
const { group, allChildren, children, childType, defaultTypes } = props;
const subType = group?.subType;
for (const child of allChildren) {
if (subType && child.props?.subType !== subType) {
continue;
}
if (childType && child.type === childType) {
return React.cloneElement(child, { ...props, children: child.props.children });
}
if (defaultTypes?.indexOf(child.type) === -1) {
return childType({ ...props, children: child });
}
}
if (!children) {
throw new Error(`No children defined for ${props.name} CloneChildren in group ${group?.name}`);
}
return React.cloneElement(children, props);
};
/** Used to exclude defaults */
const DEFAULT_TYPES = [GroupAccordion, Content, Trigger];
/**
* An AccordionGroup is a component that splits a set of items into different
* groups according to a set of grouping rules. It then puts the groups
* into a set of accordion folds selected from the body of the accordion group,
* looking for matching trigger/content sections according to the type definition
* in the group with first one found being used.
*
* This design allows for easy customization of the component by declaring grouping
* functions with default grouping setups and then only overriding the specific
* children needing to be changed. See the PanelMeasurement for some example
* possibilities of how to modify the default grouping, or the test-extension
* measurements panel for a practical, working example.
*/
export function AccordionGroup(props) {
const { grouping, items, children, sourceChildren, type } = props;
const childProps = useSystem();
let defaultValue = props.defaultValue;
const groups = grouping.groupingFunction(items, grouping, childProps);
if (!defaultValue) {
const defaultGroup = groups.values().find(group => group.isSelected);
defaultValue = defaultGroup?.key || defaultGroup?.title;
}
const valueArr =
(Array.isArray(defaultValue) && defaultValue) || (defaultValue && [defaultValue]) || [];
const sourceChildrenArr = sourceChildren ? React.Children.toArray(sourceChildren) : [];
const childrenArr = children ? React.Children.toArray(children) : [];
const allChildren = sourceChildrenArr.concat(childrenArr);
return (
<CloneChildren
allChildren={allChildren}
groups={groups}
childType={GroupAccordion}
grouping={grouping}
defaultValue={valueArr}
name={'grouping ' + grouping.name}
>
<DefaultAccordion name="DefaultAccordion" />
</CloneChildren>
);
}
function DefaultAccordion(props) {
const { groups, defaultValue, grouping, allChildren, asChild } = props;
if (!allChildren || !groups) {
return null;
}
if (Boolean(asChild)) {
return React.cloneElement(props.children, props);
}
return (
<Accordion
type={grouping.type || 'multiple'}
className="text-white"
defaultValue={defaultValue}
>
{[...groups.entries()].map(([key, group]) => {
return (
<AccordionItem
key={group.key + '-i'}
value={group.key}
>
<CloneChildren
allChildren={allChildren}
group={group}
childType={Trigger}
name="AccordionGroup.Trigger"
/>
<CloneChildren
allChildren={allChildren}
group={group}
childType={Content}
defaultTypes={DEFAULT_TYPES}
name="AccordionGroup.Content"
/>
</AccordionItem>
);
})}
</Accordion>
);
}
function GroupAccordion(props) {
const { groups, children } = props;
if (!groups) {
return null;
}
return [...groups.values()].map(group =>
React.cloneElement(children, {
...props,
children: children.props.children,
group,
...group,
key: group.title,
})
);
}
function Content(props) {
const { children, asChild, ...childProps } = props;
const { group } = props;
Object.assign(childProps, group);
if (!group) {
return null;
}
if (asChild) {
return React.cloneElement(children, { ...group, ...props, children: children.props.children });
}
return (
<AccordionContent>
{React.cloneElement(children, { ...group, ...props, children: children.props.children })}
</AccordionContent>
);
}
function Trigger(props) {
const { children, asChild, ...childProps } = props;
const { group } = props;
Object.assign(childProps, group);
if (!group) {
return null;
}
if (asChild) {
return React.cloneElement(children, childProps);
}
return (
<AccordionTrigger
value={group.value}
asChild={true}
>
<div>
{React.cloneElement(children, childProps)}
<ChevronDownIcon key="chevronDown" className="text-primary h-4 w-4 shrink-0 transition-transform duration-200" />
</div>
</AccordionTrigger>
);
}
AccordionGroup.Content = Content;
AccordionGroup.Trigger = Trigger;
AccordionGroup.Accordion = GroupAccordion;
export default AccordionGroup;
@@ -0,0 +1,4 @@
export * from './AccordionGroup';
import AccordionGroup from './AccordionGroup';
export default AccordionGroup;
@@ -0,0 +1,70 @@
import React from 'react';
import { Accordion, AccordionContent, AccordionItem } from '@ohif/ui-next';
import PanelAccordionTrigger from './PanelAccordionTrigger';
import MeasurementsMenu from './MeasurementsMenu';
import { useSystem } from '@ohif/core';
export function MeasurementItem(props) {
const { index, item } = props;
return (
<PanelAccordionTrigger
count={index + 1}
text={item.toolName || item.label || item.title}
colorHex="#f00"
isActive={item.isSelected}
menu={MeasurementsMenu}
group={{ items: [item], onClick: props.onClick }}
/>
);
}
export default function MeasurementAccordion(props) {
const { items } = props;
const system = useSystem();
const onClick = (e, group) => {
const { items } = group;
// Just jump to the first measurement in the set, and mark that one as active
// with the set of items.
system.commandsManager.run('jumpToMeasurement', {
uid: items[0].uid,
displayMeasurements: items,
group,
});
};
return (
<Accordion
type="multiple"
className="flex-shrink-0 overflow-hidden"
>
{items.map((item, index) => {
const { displayText: details = {} } = item;
return (
<AccordionItem
key={`measurementAccordion:${item.uid}`}
value={item.uid}
>
<MeasurementItem
item={item}
key={`measurementItem:${item.uid}`}
index={index}
onClick={onClick}
/>
<AccordionContent key={`measurementContent:${item.uid}`}>
<div className="ml-7 px-2 py-2">
<div className="text-secondary-foreground flex items-center gap-1 text-base leading-normal">
{details.primary?.length > 0 &&
details.primary.map((detail, index) => (
<span key={`details:${item.uid}:${index}`}>{detail}</span>
))}
</div>
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
);
}
@@ -0,0 +1,30 @@
import React from 'react';
import { MeasurementTable } from '@ohif/ui-next';
import { useSystem } from '@ohif/core';
/**
* This is a measurement table that is designed to be nested inside
* the accordion groups.
*/
export default function MeasurementTableNested(props) {
const { title, items, group, customHeader } = props;
const { commandsManager } = useSystem();
const onAction = (e, command, uid) => {
commandsManager.run(command, { uid, annotationUID: uid, displayMeasurements: items });
};
return (
<MeasurementTable
title={title ? title : `Measurements`}
data={items}
onAction={onAction}
{...group}
key={group.key}
>
<MeasurementTable.Header key="measurementTableHeader">
{customHeader && group.isFirst && customHeader({ ...props, items: props.allItems })}
</MeasurementTable.Header>
<MeasurementTable.Body key="measurementTableBody" />
</MeasurementTable>
);
}
@@ -0,0 +1,81 @@
import React, { useState } from 'react';
import { useSystem } from '@ohif/core';
import {
Button,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
Icons,
} from '@ohif/ui-next';
export function MeasumentsMenu(props) {
const { group, classNames } = props;
if (!group.items?.length) {
console.log('No items to iterate', group.items);
return null;
}
const { items } = group;
const [item] = items;
const { isSelected, isVisible } = item;
const system = useSystem();
const onAction = (event, command, args?) => {
const uid = items.map(item => item.uid);
// Some commands use displayMeasurements and some use items
system.commandsManager.run(command, {
...args,
uid,
displayMeasurements: items,
items,
event,
});
};
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
return (
<div className={`relative ml-2 inline-flex items-center space-x-1 ${classNames}`}>
{/* Visibility Toggle Icon */}
<Button
size="icon"
variant="ghost"
className={`h-6 w-6 transition-opacity ${
isSelected || !isVisible ? 'opacity-100' : 'opacity-50 group-hover:opacity-100'
}`}
aria-label={isVisible ? 'Hide' : 'Show'}
onClick={e => {
e.stopPropagation();
onAction(e, ['jumpToMeasurement', 'toggleVisibilityMeasurement']);
}}
>
{isVisible ? <Icons.Hide className="h-6 w-6" /> : <Icons.Show className="h-6 w-6" />}
</Button>
{/* Actions Dropdown Menu */}
<DropdownMenu onOpenChange={open => setIsDropdownOpen(open)}>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className={`h-6 w-6 transition-opacity ${
isSelected || isDropdownOpen ? 'opacity-100' : 'opacity-50 group-hover:opacity-100'
}`}
aria-label="Actions"
onClick={e => e.stopPropagation()} // Prevent row selection on button click
>
<Icons.More className="h-6 w-6" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={e => onAction(e, 'removeMeasurement')}>
<Icons.Delete className="text-foreground" />
<span className="pl-2">Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
export default MeasumentsMenu;
@@ -0,0 +1,81 @@
import React from 'react';
import AccordionGroup from './AccordionGroup';
import { utils } from '@ohif/core';
import MeasurementTableNested from './MeasurementTableNested';
const { filterNot, filterAdditionalFindings } = utils.MeasurementFilters;
export const MeasurementOrAdditionalFindingSets = [
{
title: 'Measurements',
filter: filterNot(filterAdditionalFindings),
},
{
title: 'Additional Findings',
filter: filterAdditionalFindings,
},
];
/**
* Groups measurements by study in order to allow display and saving by study
* @param {Object} servicesManager
*/
export const groupByNamedSets = (items, grouping) => {
const groups = new Map();
const { namedSets } = grouping;
for (const namedSet of namedSets) {
const name = namedSet.id || namedSet.title;
groups.set(name, {
...grouping,
...namedSet,
items: [],
isFirst: groups.size === 0,
title: name,
key: name,
});
}
items.forEach(item => {
for (const namedSet of namedSets) {
if (namedSet.filter(item)) {
const name = namedSet.id || namedSet.title;
groups.get(name).items.push(item);
return;
}
}
});
for (const namedSet of namedSets) {
const name = namedSet.id || namedSet.title;
if (!groups.get(name).items.length) {
groups.delete(name);
}
}
return groups;
};
export function MeasurementsOrAdditionalFindings(props): React.ReactNode {
const { items, children, grouping = {}, customHeader, group } = props;
return (
<AccordionGroup
grouping={{
groupingFunction: groupByNamedSets,
name: 'measurementsOrAdditional',
namedSets: MeasurementOrAdditionalFindingSets,
StudyInstanceUID: group?.StudyInstanceUID,
...grouping,
}}
items={items}
sourceChildren={children}
>
<AccordionGroup.Accordion noWrapper="true">
<MeasurementTableNested
customHeader={customHeader}
allItems={items}
/>
</AccordionGroup.Accordion>
</AccordionGroup>
);
}
export default MeasurementsOrAdditionalFindings;
@@ -0,0 +1,48 @@
import React from 'react';
import { AccordionTrigger, ColorCircle } from '@ohif/ui-next';
import { ChevronDownIcon } from '@radix-ui/react-icons';
function onClickDefault(e) {
const { group, onClick = group?.onClick } = this;
if (!onClick) {
console.log('No onClick function', group);
return;
}
console.log('onClickDefault');
e.preventDefault();
e.stopPropagation();
onClick(e, group);
return false;
}
export default function PanelAccordionTrigger(props) {
const { marginLeft = 8, isActive = false, colorHex, count, text, menu: Menu = null } = props;
return (
<AccordionTrigger
style={{ marginLeft: `${marginLeft}px`, padding: 0 }}
asChild={true}
>
<div className={`inline-flex text-base ${isActive ? 'bg-popover' : 'bg-muted'} flex-grow`}>
<button onClick={onClickDefault.bind(props)}>
<span
className={`inline-flex rounded-l border-r border-black ${isActive ? 'bg-highlight' : 'bg-muted'}`}
>
{count !== undefined ? <span className="px-2">{count}</span> : null}
{colorHex && <ColorCircle colorHex={colorHex} />}
</span>
<span>{text}</span>
</button>
{Menu && (
<Menu
{...props}
classNames="justify-end flex-grow"
/>
)}
<ChevronDownIcon className="text-primary h-4 w-4 shrink-0 transition-transform duration-200" />
</div>
</AccordionTrigger>
);
}
@@ -0,0 +1,90 @@
import React from 'react';
import { useActiveViewportDisplaySets, useSystem } from '@ohif/core';
import AccordionGroup from './AccordionGroup';
import PanelAccordionTrigger from './PanelAccordionTrigger';
import MeasurementItems from './MeasurementItems';
import MeasurementsMenu from './MeasurementsMenu';
/**
* Groups measurements by study in order to allow display and saving by study
* @param {Object} servicesManager
*/
export const groupByDisplaySet = (items, grouping, childProps) => {
const groups = new Map();
const { displaySetService } = childProps.servicesManager.services;
const { activeDisplaySetInstanceUID } = grouping;
items.forEach(item => {
const { displaySetInstanceUID } = item;
if (!groups.has(displaySetInstanceUID)) {
const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
groups.set(displaySetInstanceUID, {
header: null,
isSelected: displaySetInstanceUID == activeDisplaySetInstanceUID,
...grouping,
items: [],
key: displaySetInstanceUID,
title: 'Series Measurements',
displaySet,
});
}
groups.get(displaySetInstanceUID).items.push(item);
});
return groups;
};
export function SeriesMeasurementTrigger(props) {
const { group, isSelected, displaySet, menu } = props;
const { SeriesNumber = 1, SeriesDescription } = displaySet;
return (
<PanelAccordionTrigger
text={`Series #${SeriesNumber} ${SeriesDescription}`}
count={group.items.length}
isActive={isSelected}
group={group}
menu={menu}
marginLeft="0"
/>
);
}
export function SeriesMeasurements(props): React.ReactNode {
const { items, grouping = {}, children } = props;
const system = useSystem();
const activeDisplaySets = useActiveViewportDisplaySets(system);
const activeDisplaySetInstanceUID = activeDisplaySets?.[0]?.displaySetInstanceUID;
const onClick = (_e, group) => {
const { items } = group;
system.commandsManager.run('jumpToMeasurement', {
uid: items[0].uid,
displayMeasurements: items,
group,
});
};
// The content of the accordion group will default to the children of the
// parent declaration if present, otherwise to MeasurementItems
return (
<AccordionGroup
grouping={{
groupingFunction: groupByDisplaySet,
activeDisplaySetInstanceUID,
...grouping,
onClick,
}}
items={items}
sourceChildren={children}
>
<AccordionGroup.Trigger asChild={true}>
<SeriesMeasurementTrigger menu={MeasurementsMenu} />
</AccordionGroup.Trigger>
<MeasurementItems />
</AccordionGroup>
);
}
export default SeriesMeasurements;
@@ -0,0 +1,21 @@
import React from 'react';
import { utils } from '@ohif/core';
const { formatDate } = utils;
export function SeriesSummaryFromDisplaySet({ displaySet }) {
if (!displaySet) {
return null;
}
const { SeriesDate, SeriesDescription, SeriesNumber = 1 } = displaySet;
return (
<div className="mx-2 my-0">
<div className="text-foreground pb-1 text-sm">
Series #{SeriesNumber} {SeriesDescription}
</div>
<div className="text-muted-foreground text-sm">{formatDate(SeriesDate)}</div>
</div>
);
}
@@ -0,0 +1,80 @@
import React from 'react';
import { useActiveViewportDisplaySets, useSystem } from '@ohif/core';
// import { AccordionContent, AccordionItem, AccordionTrigger } from '@ohif/ui-next';
import { AccordionGroup } from './AccordionGroup';
import MeasurementsOrAdditionalFindings from './MeasurementsOrAdditionalFindings';
import StudySummaryWithActions from './StudySummaryWithActions';
/**
* Groups measurements by study in order to allow display and saving by study
* @param {Object} servicesManager
*/
export const groupByStudy = (items, grouping, childProps) => {
const groups = new Map();
const { activeStudyUID } = grouping;
const { displaySetService } = childProps.servicesManager.services;
const getItemStudyInstanceUID = item => {
const displaySet = displaySetService.getDisplaySetByUID(item.displaySetInstanceUID);
return displaySet.instances[0].StudyInstanceUID;
};
let firstSelected, firstGroup;
items.forEach(item => {
const studyUID = getItemStudyInstanceUID(item);
if (!groups.has(studyUID)) {
const items = [];
const group = {
...grouping,
items,
displayMeasurements: items,
key: studyUID,
isSelected: studyUID === activeStudyUID,
StudyInstanceUID: activeStudyUID,
};
if (group.isSelected && !firstSelected) {
firstSelected = group;
}
firstGroup ||= group;
groups.set(studyUID, group);
}
if (!firstSelected && firstGroup) {
firstGroup.isSelected = true;
}
const group = groups.get(studyUID);
group.items.push(item);
});
return groups;
};
export function StudyMeasurements(props): React.ReactNode {
const { items, grouping = {}, children } = props;
const system = useSystem();
const activeDisplaySets = useActiveViewportDisplaySets(system);
const activeStudyUID = activeDisplaySets?.[0]?.StudyInstanceUID;
return (
<AccordionGroup
grouping={{
name: 'groupByStudy',
groupingFunction: groupByStudy,
activeStudyUID,
...grouping,
}}
items={items}
value={[activeStudyUID]}
sourceChildren={children}
>
<AccordionGroup.Trigger>
<StudySummaryWithActions />
</AccordionGroup.Trigger>
<MeasurementsOrAdditionalFindings activeStudyUID={activeStudyUID} />
</AccordionGroup>
);
}
export default StudyMeasurements;
@@ -0,0 +1,69 @@
import React from 'react';
import { Button, Icons } from '@ohif/ui-next';
import { useSystem } from '@ohif/core';
export function StudyMeasurementsActions({ items, StudyInstanceUID, measurementFilter, actions }) {
const { commandsManager } = useSystem();
const disabled = !items?.length;
if (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={() => {
commandsManager.runCommand('downloadCSVMeasurementsReport', {
StudyInstanceUID,
measurementFilter,
});
}}
>
<Icons.Download className="h-5 w-5" />
<span className="pl-1">CSV</span>
</Button>
<Button
size="sm"
variant="ghost"
className="pl-0.5"
onClick={e => {
e.stopPropagation();
if (actions?.createSR) {
actions.createSR({ StudyInstanceUID, measurementFilter });
return;
}
commandsManager.run('promptSaveReport', {
StudyInstanceUID,
measurementFilter,
});
}}
>
<Icons.Add />
Create SR
</Button>
<Button
size="sm"
variant="ghost"
className="pl-0.5"
onClick={e => {
e.stopPropagation();
commandsManager.runCommand('clearMeasurements', {
measurementFilter,
});
}}
>
<Icons.Delete />
Delete
</Button>
</div>
</div>
);
}
export default StudyMeasurementsActions;
@@ -4,7 +4,8 @@ import { StudySummary } from '@ohif/ui-next';
const { formatDate } = utils;
export function StudySummaryFromMetadata({ StudyInstanceUID }) {
export function StudySummaryFromMetadata(props) {
const { StudyInstanceUID } = props;
if (!StudyInstanceUID) {
return null;
}
@@ -20,6 +21,6 @@ export function StudySummaryFromMetadata({ StudyInstanceUID }) {
<StudySummary
date={formatDate(StudyDate)}
description={StudyDescription}
></StudySummary>
/>
);
}
@@ -0,0 +1,14 @@
import React from 'react';
import { StudySummaryFromMetadata } from './StudySummaryFromMetadata';
import StudyMeasurementsActions from './StudyMeasurementsActions';
export function StudySummaryWithActions(props) {
return (
<div>
<StudySummaryFromMetadata {...props} />
<StudyMeasurementsActions {...props} />
</div>
);
}
export default StudySummaryWithActions;
@@ -0,0 +1,10 @@
export * from './AccordionGroup';
export * from './MeasurementTableNested';
export * from './StudyMeasurements';
export * from './MeasurementsMenu';
export * from './SeriesMeasurements';
export * from './StudyMeasurementsActions';
export * from './MeasurementsOrAdditionalFindings';
import DicomUpload from './DicomUpload/DicomUpload';
export { DicomUpload };
+1 -14
View File
@@ -51,19 +51,6 @@ const getPanelModule = ({ commandsManager, servicesManager, extensionManager }:
);
};
const wrappedPanelMeasurement = ({ configuration }) => {
return (
<PanelMeasurement
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
configuration={{
...configuration,
}}
/>
);
};
return [
{
name: 'activeViewportWindowLevel',
@@ -76,7 +63,7 @@ const getPanelModule = ({ commandsManager, servicesManager, extensionManager }:
iconName: 'tab-linear',
iconLabel: 'Measure',
label: 'Measurement',
component: wrappedPanelMeasurement,
component: PanelMeasurement,
},
{
name: 'panelSegmentation',
+5 -2
View File
@@ -30,6 +30,8 @@ import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledEle
import { id } from './id';
import { measurementMappingUtils } from './utils/measurementServiceMappings';
import PlanarFreehandROI from './utils/measurementServiceMappings/PlanarFreehandROI';
import RectangleROI from './utils/measurementServiceMappings/RectangleROI';
import type { PublicViewportOptions } from './services/ViewportService/Viewport';
import ImageOverlayViewerTool from './tools/ImageOverlayViewerTool';
import ViewportActionCornersService from './services/ViewportActionCornersService/ViewportActionCornersService';
@@ -51,11 +53,11 @@ import { useMeasurements } from './hooks/useMeasurements';
import getPanelModule from './getPanelModule';
import PanelSegmentation from './panels/PanelSegmentation';
import PanelMeasurement from './panels/PanelMeasurement';
import DicomUpload from './components/DicomUpload/DicomUpload';
import { useSegmentations } from './hooks/useSegmentations';
import { StudySummaryFromMetadata } from './components/StudySummaryFromMetadata';
import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm';
import utils from './utils';
export * from './components';
const { imageRetrieveMetadataProvider } = cornerstone.utilities;
@@ -234,6 +236,8 @@ const cornerstoneExtension: Types.Extensions.Extension = {
export type { PublicViewportOptions };
export {
measurementMappingUtils,
PlanarFreehandROI,
RectangleROI,
CornerstoneExtensionTypes as Types,
toolNames,
getActiveViewportEnabledElement,
@@ -254,7 +258,6 @@ export {
useSegmentations,
PanelSegmentation,
PanelMeasurement,
DicomUpload,
StudySummaryFromMetadata,
CornerstoneViewportDownloadForm,
utils,
@@ -1,107 +1,103 @@
import React, { useEffect, useRef } from 'react';
import { utils } from '@ohif/core';
import { MeasurementTable } from '@ohif/ui-next';
import debounce from 'lodash.debounce';
import React from 'react';
import { useSystem } from '@ohif/core';
import { useMeasurements } from '../hooks/useMeasurements';
import StudyMeasurements from '../components/StudyMeasurements';
/**
* The PanelMeasurement is a fairly simple wrapper that gets the filtered
* measurements and then passes it on to the children component, default to
* the StudyMeasurements sub-component if no children are specified.
* Some example customizations that could work:
*
*
* Creates a default study measurements panel with default children:
* ```
* <PanelMEasurement>
* <StudyMeasurements />
* </PanelMeasurement>
* ```
*
* A study measurements with body replacement
* ```
* <StudyMeasurements>
* <SeriesMeasurements />
* </StudyMeasurements>
* ```
*
* A study measurements replacing just the trigger, leaving the default body
* ```
* <StudyMeasurements>
* <AccordionGroup.Trigger>
* This is a new custom trigger
* </AccordionGroup.Trigger>
*</StudyMeasurements>
* ```
*
* A study measurements with the trigger and body replaced
* ```
* <StudyMeasurements>
* <AccordionGroup.Trigger>
* This is a new custom trigger
* </AccordionGroup.Trigger>
* <SeriesMeasurements />
* </StudyMeasurements>
* ```
*
* A study measurements with a custom header for the additional findings
* ```
* <StudyMeasurements>
* <MeasurementOrAdditionalFindings>
* <AccordionGroup.Trigger groupName="additionalFindings">
* <CustomAdditionalFindingsHeader />
* </AccordionGroup.Trigger>
* <AccordionGroup.Trigger groupName="measurements">
* <CustomMeasurementsHeader />
* </AccordionGroup.Trigger>
* </MeasurementOrAdditionalFindings>
* </StudyMeasurements>
*```
*/
export default function PanelMeasurement(props): React.ReactNode {
const {
measurementFilter,
emptyComponent: EmptyComponent,
key = 'PanelMeasurement',
children,
} = props;
const { filterAdditionalFindings: filterAdditionalFinding, filterAny } = utils.MeasurementFilters;
export type withAppAndFilters = withAppTypes & {
measurementFilter: (item) => boolean;
};
export default function PanelMeasurement({
servicesManager,
commandsManager,
customHeader,
measurementFilter = filterAny,
}: withAppAndFilters): React.ReactNode {
const measurementsPanelRef = useRef(null);
const { measurementService } = servicesManager.services;
const displayMeasurements = useMeasurements(servicesManager, {
const system = useSystem();
const displayMeasurements = useMeasurements(system.servicesManager, {
measurementFilter,
});
useEffect(() => {
if (displayMeasurements.length > 0) {
debounce(() => {
measurementsPanelRef.current.scrollTop = measurementsPanelRef.current.scrollHeight;
}, 300)();
}
}, [displayMeasurements.length]);
const bindCommand = (name: string | string[], options?) => {
return (uid: string) => {
commandsManager.run(name, { ...options, uid });
};
};
const jumpToImage = bindCommand('jumpToMeasurement', { displayMeasurements });
const removeMeasurement = bindCommand('removeMeasurement');
const renameMeasurement = bindCommand(['jumpToMeasurement', 'renameMeasurement'], {
displayMeasurements,
});
const toggleLockMeasurement = bindCommand('toggleLockMeasurement');
const toggleVisibilityMeasurement = bindCommand('toggleVisibilityMeasurement');
const additionalFilter = filterAdditionalFinding(measurementService);
const measurements = displayMeasurements.filter(
item => !additionalFilter(item) && measurementFilter(item)
);
const additionalFindings = displayMeasurements.filter(
item => additionalFilter(item) && measurementFilter(item)
);
const onArgs = {
onClick: jumpToImage,
onDelete: removeMeasurement,
onToggleVisibility: toggleVisibilityMeasurement,
onToggleLocked: toggleLockMeasurement,
onRename: renameMeasurement,
};
if (!displayMeasurements.length) {
return EmptyComponent ? (
<EmptyComponent
key={key}
data-cy={key}
items={displayMeasurements}
/>
) : (
<span className="text-white">No Measurements</span>
);
}
if (children) {
const cloned = React.Children.map(children, child =>
React.cloneElement(child, {
items: displayMeasurements,
filter: measurementFilter,
'data-cy': key,
})
);
return cloned;
}
// Need to merge defaults on the content props to ensure they get passed to children
return (
<>
<div
className="invisible-scrollbar overflow-y-auto overflow-x-hidden"
ref={measurementsPanelRef}
data-cy={'trackedMeasurements-panel'}
>
<MeasurementTable
key="tracked"
title="Measurements"
data={measurements}
{...onArgs}
// onColor={changeColorMeasurement}
>
<MeasurementTable.Header>
{customHeader && (
<>
{typeof customHeader === 'function'
? customHeader({
additionalFindings,
measurements,
})
: customHeader}
</>
)}
</MeasurementTable.Header>
<MeasurementTable.Body />
</MeasurementTable>
{additionalFindings.length > 0 && (
<MeasurementTable
key="additional"
data={additionalFindings}
title="Additional Findings"
{...onArgs}
>
<MeasurementTable.Body />
</MeasurementTable>
)}
</div>
</>
<StudyMeasurements
key={key}
data-cy={key}
items={displayMeasurements}
/>
);
}
+2 -1
View File
@@ -1,4 +1,5 @@
import * as CornerstoneCacheService from './CornerstoneCacheService';
import CornerstoneServices from './CornerstoneServices';
import type CornerstoneServices from './CornerstoneServices';
export { type SyncGroup } from '../services/SyncGroupService';
export type { CornerstoneCacheService, CornerstoneServices };
@@ -16,7 +16,6 @@ const SegmentBidirectional = {
) => {
const { annotation } = csToolsEventDetail;
const { metadata, data, annotationUID } = annotation;
debugger;
const isLocked = getIsLocked(annotationUID);
const isVisible = getIsVisible(annotationUID);
@@ -24,7 +24,7 @@ function DataSourceConfigurationComponent({
const dataSourceChangedCallback = async () => {
const activeDataSourceDef = extensionManager.getActiveDataSourceDefinition();
if (!activeDataSourceDef.configuration.configurationAPI) {
if (!activeDataSourceDef?.configuration?.configurationAPI) {
return;
}
@@ -36,6 +36,7 @@ export default function CreateReportDialogPrompt({
dataSourceName: undefined,
});
},
defaultValue: title,
},
});
});
+13 -3
View File
@@ -17,6 +17,7 @@ import { useToggleHangingProtocolStore } from './stores/useToggleHangingProtocol
import { useViewportsByPositionStore } from './stores/useViewportsByPositionStore';
import { useToggleOneUpViewportGridStore } from './stores/useToggleOneUpViewportGridStore';
import requestDisplaySetCreationForStudy from './Panels/requestDisplaySetCreationForStudy';
import promptSaveReport from './utils/promptSaveReport';
export type HangingProtocolParams = {
protocolId?: string;
@@ -75,6 +76,14 @@ const commandsModule = ({
}
},
/** Displays a prompt and then save the report if relevant */
promptSaveReport: props => {
const { StudyInstanceUID } = props;
promptSaveReport({ servicesManager, commandsManager, extensionManager }, props, {
data: { StudyInstanceUID },
});
},
/**
* Ensures that the specified study is available for display
* Then, if commands is specified, runs the given commands list/instance
@@ -140,8 +149,9 @@ const commandsModule = ({
type: type,
});
},
clearMeasurements: () => {
measurementService.clearMeasurements();
clearMeasurements: options => {
measurementService.clearMeasurements(options.measurementFilter);
},
/**
@@ -340,7 +350,6 @@ const commandsModule = ({
const { protocol } = hangingProtocolService.getActiveProtocol();
const onLayoutChange = protocol.callbacks?.onLayoutChange;
if (commandsManager.run(onLayoutChange, { numRows, numCols }) === false) {
console.log('setViewportGridLayout running', onLayoutChange, numRows, numCols);
// Don't apply the layout if the run command returns false
return;
}
@@ -621,6 +630,7 @@ const commandsModule = ({
const definitions = {
multimonitor: actions.multimonitor,
promptSaveReport: actions.promptSaveReport,
loadStudy: actions.loadStudy,
showContextMenu: actions.showContextMenu,
closeContextMenu: actions.closeContextMenu,
@@ -94,12 +94,12 @@ export async function callInputDialogAutoComplete({
const dropDownItems = labelConfig ? labelConfig.items : [];
const value = await new Promise<Map<string, string>>((resolve, reject) => {
const labellingDoneCallback = value => {
const labellingDoneCallback = newValue => {
uiDialogService.hide('select-annotation');
if (typeof value === 'string') {
measurement.label = value;
if (measurement && typeof newValue === 'string') {
measurement.label = newValue;
}
resolve(measurement);
resolve(newValue);
};
uiDialogService.show({
@@ -1,58 +1,76 @@
import { utils } from '@ohif/core';
import createReportAsync from '../Actions/createReportAsync';
import { createReportDialogPrompt } from '../Panels';
import getNextSRSeriesNumber from './getNextSRSeriesNumber';
import PROMPT_RESPONSES from './_shared/PROMPT_RESPONSES';
import createReportDialogPrompt from '../Panels/createReportDialogPrompt';
/**
* Prompts the user to save a report and handles the report creation process
* @param services - Object containing required services and managers
* @param ctx - The current context containing tracked study and series information
* @param evt - The event object containing viewport and save-related data
*/
async function promptSaveReport(services, ctx, evt) {
const { servicesManager, extensionManager, commandsManager } = services;
const {
filterAnd,
filterMeasurementsByStudyUID,
filterMeasurementsBySeriesUID,
filterPlanarMeasurement,
} = utils.MeasurementFilters;
async function promptSaveReport({ servicesManager, commandsManager, extensionManager }, ctx, evt) {
const { measurementService, displaySetService } = servicesManager.services;
const viewportId = evt.viewportId ?? evt.data?.viewportId;
const isBackupSave = evt.isBackupSave ?? evt.data?.isBackupSave;
const { StudyInstanceUID, SeriesInstanceUID } = evt?.data ?? {};
const { trackedStudy, trackedSeries } = ctx;
const dataSources = extensionManager.getDataSources();
const viewportId = evt.viewportId === undefined ? evt.data.viewportId : evt.viewportId;
const isBackupSave = evt.isBackupSave === undefined ? evt.data.isBackupSave : evt.isBackupSave;
const StudyInstanceUID = evt?.data?.StudyInstanceUID || ctx.trackedStudy;
const SeriesInstanceUID = evt?.data?.SeriesInstanceUID;
const {
value: reportName,
dataSourceName: dataSource,
action,
} = await createReportDialogPrompt({
servicesManager,
extensionManager,
});
trackedSeries,
measurementFilter = filterAnd(
filterMeasurementsByStudyUID(StudyInstanceUID),
filterMeasurementsBySeriesUID(trackedSeries),
filterPlanarMeasurement
),
defaultSaveTitle = 'Research Derived Series',
} = ctx;
let displaySetInstanceUIDs;
try {
if (action === PROMPT_RESPONSES.CREATE_REPORT) {
const selectedDataSource = dataSource ?? dataSources[0];
const trackedMeasurements = getTrackedMeasurements(
measurementService,
trackedStudy,
trackedSeries
);
const promptResult = await createReportDialogPrompt({
title: defaultSaveTitle,
extensionManager,
servicesManager,
});
if (promptResult.action === PROMPT_RESPONSES.CREATE_REPORT) {
const dataSources = extensionManager.getDataSources();
const dataSource = dataSources[0];
const measurementData = measurementService.getMeasurements(measurementFilter);
const SeriesDescription = promptResult.value || defaultSaveTitle;
const SeriesNumber = getNextSRSeriesNumber(displaySetService);
displaySetInstanceUIDs = await handleReportCreation({
const getReport = async () => {
return commandsManager.runCommand(
'storeMeasurements',
{
measurementData,
dataSource,
additionalFindingTypes: ['ArrowAnnotate'],
options: {
SeriesDescription,
SeriesNumber,
},
},
'CORNERSTONE_STRUCTURED_REPORT'
);
};
displaySetInstanceUIDs = await createReportAsync({
servicesManager,
commandsManager,
trackedMeasurements,
selectedDataSource,
reportName,
SeriesNumber,
getReport,
});
} else if (promptResult.action === RESPONSE.CANCEL) {
// Do nothing
}
return {
userResponse: action,
userResponse: promptResult.action,
createdDisplaySetInstanceUIDs: displaySetInstanceUIDs,
StudyInstanceUID,
SeriesInstanceUID,
@@ -60,50 +78,9 @@ async function promptSaveReport(services, ctx, evt) {
isBackupSave,
};
} catch (error) {
console.warn('Unable to save report', error);
return null;
}
}
/**
* Gets tracked measurements based on study and series criteria
*/
function getTrackedMeasurements(measurementService, trackedStudy, trackedSeries) {
return measurementService
.getMeasurements()
.filter(
m => trackedStudy === m.referenceStudyUID && trackedSeries.includes(m.referenceSeriesUID)
)
.filter(m => m.referencedImageId != null);
}
/**
* Handles the creation of the report using the measurement service
*/
async function handleReportCreation({
servicesManager,
commandsManager,
trackedMeasurements,
selectedDataSource,
reportName,
SeriesNumber,
}) {
return createReportAsync({
servicesManager,
getReport: () =>
commandsManager.runCommand(
'storeMeasurements',
{
measurementData: trackedMeasurements,
dataSource: selectedDataSource,
additionalFindingTypes: ['ArrowAnnotate'],
options: {
SeriesDescription: reportName,
SeriesNumber,
},
},
'CORNERSTONE_STRUCTURED_REPORT'
),
});
}
export default promptSaveReport;
@@ -25,6 +25,7 @@ function getPanelModule({ commandsManager, extensionManager, servicesManager }):
component: props => (
<PanelMeasurementTableTracking
{...props}
key="trackedMeasurements-panel"
commandsManager={commandsManager}
extensionManager={extensionManager}
servicesManager={servicesManager}
@@ -1,90 +1,81 @@
import React, { useEffect, useState } from 'react';
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 React from 'react';
import { utils } from '@ohif/core';
import { AccordionTrigger, MeasurementTable, useViewportGrid } from '@ohif/ui-next';
import {
PanelMeasurement,
StudyMeasurements,
StudyMeasurementsActions,
StudySummaryFromMetadata,
AccordionGroup,
MeasurementsOrAdditionalFindings,
} from '@ohif/extension-cornerstone';
import { useTrackedMeasurements } from '../getContextModule';
const { filterAnd, filterPlanarMeasurement, filterMeasurementsBySeriesUID } =
utils.MeasurementFilters;
function PanelMeasurementTableTracking({
servicesManager,
extensionManager,
commandsManager,
}: withAppTypes) {
function PanelMeasurementTableTracking(props) {
const [viewportGrid] = useViewportGrid();
const { customizationService } = servicesManager.services;
const [trackedMeasurements, sendTrackedMeasurementsEvent] = useTrackedMeasurements();
const { trackedStudy, trackedSeries } = trackedMeasurements.context;
const measurementFilter = trackedStudy
? filterAnd(filterPlanarMeasurement, filterMeasurementsBySeriesUID(trackedSeries))
: filterPlanarMeasurement;
const disableEditing = customizationService.getCustomization('panelMeasurement.disableEditing');
const EmptyComponent = () => (
<MeasurementTable
title="Measurements"
isExpanded={false}
>
<MeasurementTable.Body />
</MeasurementTable>
);
const actions = {
createSR: ({ StudyInstanceUID }) => {
sendTrackedMeasurementsEvent('SAVE_REPORT', {
viewportId: viewportGrid.activeViewportId,
isBackupSave: true,
StudyInstanceUID,
measurementFilter,
});
},
};
const Header = props => (
<AccordionTrigger
asChild={true}
className="px-0"
>
<div data-cy="TrackingHeader">
<StudySummaryFromMetadata {...props} />
</div>
</AccordionTrigger>
);
return (
<>
<StudySummaryFromMetadata StudyInstanceUID={trackedStudy} />
<PanelMeasurement
servicesManager={servicesManager}
extensionManager={extensionManager}
commandsManager={commandsManager}
measurementFilter={measurementFilter}
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={() => {
commandsManager.runCommand('downloadCSVMeasurementsReport', {
measurementFilter,
});
}}
>
<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={() => {
commandsManager.runCommand('clearMeasurements', { measurementFilter });
}}
>
<Icons.Delete />
Delete All
</Button>
</div>
</div>
);
}}
></PanelMeasurement>
</>
<PanelMeasurement
measurementFilter={measurementFilter}
emptyComponent={EmptyComponent}
sourceChildren={props.children}
>
<StudyMeasurements grouping={props.grouping}>
<AccordionGroup.Trigger
key="trackingMeasurementsHeader"
asChild={true}
>
<Header key="trackingHeadChild" />
</AccordionGroup.Trigger>
<MeasurementsOrAdditionalFindings
key="measurementsOrAdditionalFindings"
activeStudyUID={trackedStudy}
customHeader={StudyMeasurementsActions}
measurementFilter={measurementFilter}
actions={actions}
/>
</StudyMeasurements>
</PanelMeasurement>
);
}
@@ -0,0 +1,36 @@
import React from 'react';
import {
PanelMeasurement,
SeriesMeasurements,
StudyMeasurements,
} from '@ohif/extension-cornerstone';
export default function getPanelModule({
commandsManager,
servicesManager,
extensionManager,
}: withAppTypes) {
const childProps = {
commandsManager,
servicesManager,
extensionManager,
};
const wrappedPanelMeasurementSeries = () => {
return (
<PanelMeasurement {...childProps}>
<StudyMeasurements>
<SeriesMeasurements />
</StudyMeasurements>
</PanelMeasurement>
);
};
return [
{
name: 'panelMeasurementSeries',
iconName: 'tool-freehand-roi',
iconLabel: 'Measure Series',
label: 'Measurement Series',
component: wrappedPanelMeasurementSeries,
},
];
}
+3
View File
@@ -8,6 +8,7 @@ import getCustomizationModule from './getCustomizationModule';
import sameAs from './custom-attribute/sameAs';
import numberOfDisplaySets from './custom-attribute/numberOfDisplaySets';
import maxNumImageFrames from './custom-attribute/maxNumImageFrames';
import getPanelModule from './getPanelModule';
/**
* The test extension provides additional behavior for testing various
@@ -50,6 +51,8 @@ const testExtension: Types.Extensions.Extension = {
/** Registers some customizations */
getCustomizationModule,
getPanelModule,
getHangingProtocolModule: () => {
return [
// Create a MxN hanging protocol available by default