feat: Group findings table values with custom components/grouping functions (#4712)
This commit is contained in:
1 parent
0522d58d9b
commit
df8efba82c
48 files changed
+1256
-631
No files matched your search
-1
@@ -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 } =
|
||||
|
||||
@@ -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 };
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user