feat: Group findings table values with custom components/grouping functions (#4712)
This commit is contained in:
parent
0522d58d9b
commit
df8efba82c
@ -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;
|
||||
70
extensions/cornerstone/src/components/MeasurementItems.tsx
Normal file
70
extensions/cornerstone/src/components/MeasurementItems.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
81
extensions/cornerstone/src/components/MeasurementsMenu.tsx
Normal file
81
extensions/cornerstone/src/components/MeasurementsMenu.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
90
extensions/cornerstone/src/components/SeriesMeasurements.tsx
Normal file
90
extensions/cornerstone/src/components/SeriesMeasurements.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
80
extensions/cornerstone/src/components/StudyMeasurements.tsx
Normal file
80
extensions/cornerstone/src/components/StudyMeasurements.tsx
Normal file
@ -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;
|
||||
10
extensions/cornerstone/src/components/index.ts
Normal file
10
extensions/cornerstone/src/components/index.ts
Normal file
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
36
extensions/test-extension/src/getPanelModule.tsx
Normal file
36
extensions/test-extension/src/getPanelModule.tsx
Normal file
@ -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
|
||||
|
||||
@ -15,6 +15,10 @@ const ohif = {
|
||||
thumbnailList: '@ohif/extension-default.panelModule.seriesList',
|
||||
};
|
||||
|
||||
const testExtension = {
|
||||
measurements: '@ohif/extension-test.panelModule.panelMeasurementSeries',
|
||||
};
|
||||
|
||||
const tracked = {
|
||||
measurements: '@ohif/extension-measurement-tracking.panelModule.trackedMeasurements',
|
||||
thumbnailList: '@ohif/extension-measurement-tracking.panelModule.seriesList',
|
||||
@ -199,7 +203,7 @@ function modeFactory() {
|
||||
props: {
|
||||
leftPanels: [tracked.thumbnailList],
|
||||
leftPanelResizable: true,
|
||||
rightPanels: [cornerstone.panel, tracked.measurements, cornerstone.measurements],
|
||||
rightPanels: [cornerstone.panel, tracked.measurements, testExtension.measurements],
|
||||
rightPanelResizable: true,
|
||||
viewports: [
|
||||
{
|
||||
|
||||
@ -4,7 +4,7 @@ window.config = {
|
||||
name: 'config/kheops.js',
|
||||
routerBasename: '/',
|
||||
extensions: [],
|
||||
modes: [],
|
||||
modes: ['@ohif/mode-test'],
|
||||
customizationService: {},
|
||||
showStudyList: true,
|
||||
// some windows systems have issues with more than 3 web workers
|
||||
@ -26,7 +26,7 @@ window.config = {
|
||||
// filterQueryParam: false,
|
||||
// Uses the ohif datasource as the default - this requires that KHEOPS be
|
||||
// configured with an OHIF path to .../viewer/dicomwebproxy
|
||||
defaultDataSourceName: 'ohif3',
|
||||
defaultDataSourceName: 'dicomweb',
|
||||
/* Dynamic config allows user to pass "configUrl" query string this allows to load config without recompiling application. The regex will ensure valid configuration source */
|
||||
// dangerouslyUseDynamicConfig: {
|
||||
// enabled: true,
|
||||
|
||||
@ -9,9 +9,7 @@ import { DisplaySet } from '../types';
|
||||
* @returns Array of display sets for the active viewport
|
||||
*/
|
||||
const useActiveViewportDisplaySets = ({ servicesManager }): DisplaySet[] => {
|
||||
const [displaySets, setDisplaySets] = useState<DisplaySet[]>([]);
|
||||
const { displaySetService, viewportGridService } = servicesManager.services;
|
||||
|
||||
// Move this function outside useEffect and memoize it
|
||||
const getDisplaySetsForViewport = useCallback(
|
||||
(viewportId: string) => {
|
||||
@ -21,19 +19,22 @@ const useActiveViewportDisplaySets = ({ servicesManager }): DisplaySet[] => {
|
||||
[displaySetService, viewportGridService]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Get initial state
|
||||
const viewportId = viewportGridService.getActiveViewportId();
|
||||
setDisplaySets(getDisplaySetsForViewport(viewportId));
|
||||
// Get initial state
|
||||
const viewportId = viewportGridService.getActiveViewportId();
|
||||
const displaySetsNew = getDisplaySetsForViewport(viewportId) || [];
|
||||
const [displaySets, setDisplaySets] = useState<DisplaySet[]>(displaySetsNew);
|
||||
|
||||
useEffect(() => {
|
||||
const handleViewportChange = ({ viewportId }) => {
|
||||
setDisplaySets(getDisplaySetsForViewport(viewportId));
|
||||
const displaySetsNew = getDisplaySetsForViewport(viewportId);
|
||||
setDisplaySets(displaySetsNew);
|
||||
};
|
||||
|
||||
const handleGridStateChange = ({ state }) => {
|
||||
const activeViewportId = state.activeViewportId;
|
||||
if (activeViewportId) {
|
||||
setDisplaySets(getDisplaySetsForViewport(activeViewportId));
|
||||
const displaySetsNew = getDisplaySetsForViewport(activeViewportId);
|
||||
setDisplaySets(displaySetsNew);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -405,11 +405,14 @@ class MeasurementService extends PubSubService {
|
||||
let measurement = {};
|
||||
try {
|
||||
measurement = toMeasurementSchema(data);
|
||||
if (!measurement) {
|
||||
return;
|
||||
}
|
||||
measurement.source = source;
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`Failed to map '${sourceInfo}' measurement for annotationType ${annotationType}:`,
|
||||
error.message
|
||||
error
|
||||
);
|
||||
return;
|
||||
}
|
||||
@ -589,6 +592,30 @@ class MeasurementService extends PubSubService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove multiple measurements at once.
|
||||
*/
|
||||
removeMany(measurementUIDs: string[]): void {
|
||||
const measurements = [];
|
||||
for (const measurementUID of measurementUIDs) {
|
||||
const measurement =
|
||||
this.measurements.get(measurementUID) || this.unmappedMeasurements.get(measurementUID);
|
||||
|
||||
if (!measurementUID || !measurement) {
|
||||
console.debug(`No uid provided, or unable to find measurement by uid.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.unmappedMeasurements.delete(measurementUID);
|
||||
this.measurements.delete(measurementUID);
|
||||
measurements.push(measurement);
|
||||
}
|
||||
if (!measurements.length) {
|
||||
return;
|
||||
}
|
||||
this._broadcastEvent(this.EVENTS.MEASUREMENTS_CLEARED, { measurements });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears measurements that match the filter, defaulting to all of them.
|
||||
* That allows, for example, clearing all of a single studies measurements
|
||||
@ -769,7 +796,7 @@ class MeasurementService extends PubSubService {
|
||||
});
|
||||
}
|
||||
|
||||
public toggleVisibilityMeasurement(measurementUID: string): void {
|
||||
public toggleVisibilityMeasurement(measurementUID: string, visibility?: boolean): void {
|
||||
const measurement = this.measurements.get(measurementUID);
|
||||
|
||||
if (!measurement) {
|
||||
@ -777,7 +804,10 @@ class MeasurementService extends PubSubService {
|
||||
return;
|
||||
}
|
||||
|
||||
measurement.isVisible = !measurement.isVisible;
|
||||
if (measurement.isVisible === visibility && visibility !== undefined) {
|
||||
return;
|
||||
}
|
||||
measurement.isVisible = visibility !== undefined ? visibility : !measurement.isVisible;
|
||||
|
||||
this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, {
|
||||
source: measurement.source,
|
||||
@ -786,6 +816,10 @@ class MeasurementService extends PubSubService {
|
||||
});
|
||||
}
|
||||
|
||||
public toggleVisibilityMeasurementMany(measurementUIDs: string[], visibility?: boolean): void {
|
||||
return measurementUIDs.forEach(uid => this.toggleVisibilityMeasurement(uid, visibility));
|
||||
}
|
||||
|
||||
public updateColorMeasurement(measurementUID: string, color: number[]): void {
|
||||
const measurement = this.measurements.get(measurementUID);
|
||||
|
||||
|
||||
@ -130,6 +130,9 @@ class ViewportGridService extends PubSubService {
|
||||
}
|
||||
|
||||
public setActiveViewportId(id: string) {
|
||||
if (id === this.getActiveViewportId()) {
|
||||
return;
|
||||
}
|
||||
this.serviceImplementation._setActiveViewport(id);
|
||||
|
||||
// Use queueMicrotask to delay the event broadcast
|
||||
|
||||
@ -3,6 +3,7 @@ import { InstanceMetadata } from './StudyMetadata';
|
||||
export type DisplaySet = {
|
||||
displaySetInstanceUID: string;
|
||||
instances: InstanceMetadata[];
|
||||
isReconstructable?: boolean;
|
||||
StudyInstanceUID: string;
|
||||
SeriesInstanceUID?: string;
|
||||
SeriesNumber?: number;
|
||||
|
||||
@ -1,11 +1,19 @@
|
||||
import MeasurementService from '../services/MeasurementService';
|
||||
/**
|
||||
* Returns a filter function which filters for measurements belonging to both
|
||||
* the study and series.
|
||||
*/
|
||||
export function filterMeasurementsBySeriesUID(selectedSeries: string[]) {
|
||||
if (!selectedSeries) {
|
||||
return;
|
||||
}
|
||||
return measurement => selectedSeries.includes(measurement.referenceSeriesUID);
|
||||
}
|
||||
|
||||
export function filterMeasurementsByStudyUID(studyUID) {
|
||||
return measurement => measurement.referenceStudyUID == studyUID;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns true for measurements include referencedImageId (coplanar with an image)
|
||||
*/
|
||||
@ -13,6 +21,10 @@ export function filterPlanarMeasurement(measurement) {
|
||||
return measurement?.referencedImageId;
|
||||
}
|
||||
|
||||
export function filterTool(toolName: string) {
|
||||
return annotation => annotation.metadata?.toolName === toolName;
|
||||
}
|
||||
|
||||
/** A filter that always returns true */
|
||||
export function filterAny(_measurement) {
|
||||
return true;
|
||||
@ -30,6 +42,10 @@ export function filterNone(_measurement) {
|
||||
export function filterOr(...filters) {
|
||||
return function (item) {
|
||||
for (let filter of filters) {
|
||||
if (!filter) {
|
||||
// Un undefined filter means all, so return true for the or
|
||||
return true;
|
||||
}
|
||||
if (typeof filter === 'string') {
|
||||
filter = this[filter];
|
||||
}
|
||||
@ -44,13 +60,14 @@ export function filterOr(...filters) {
|
||||
};
|
||||
}
|
||||
|
||||
const { POINT } = MeasurementService.VALUE_TYPES;
|
||||
|
||||
/**
|
||||
* Filters for additional findings, that is, measurements with
|
||||
* a value of type point, and having a referenced image
|
||||
*/
|
||||
export function filterAdditionalFindings(measurementService) {
|
||||
const { POINT } = measurementService.VALUE_TYPES;
|
||||
return dm => dm.type === POINT && dm.referencedImageId;
|
||||
export function filterAdditionalFindings(dm) {
|
||||
return dm.type === POINT && dm.referencedImageId;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -70,8 +87,15 @@ const isString = s => typeof s === 'string' || s instanceof String;
|
||||
* called on the final filter call.
|
||||
*/
|
||||
export function filterAnd(...filters) {
|
||||
const nonNullFilters = filters.filter(filter => !!filter);
|
||||
if (!nonNullFilters.length) {
|
||||
return;
|
||||
}
|
||||
if (nonNullFilters.length === 1 && typeof nonNullFilters[0] === 'function') {
|
||||
return nonNullFilters[0];
|
||||
}
|
||||
return function (item) {
|
||||
for (const filter of filters) {
|
||||
for (const filter of nonNullFilters) {
|
||||
if (isString(filter)) {
|
||||
if (!this[filter](item)) {
|
||||
return false;
|
||||
@ -98,7 +122,7 @@ export function filterAnd(...filters) {
|
||||
*/
|
||||
export function filterNot(...filters) {
|
||||
if (filters.length !== 1) {
|
||||
return filterAnd.apply(null, filters.map(filterNot));
|
||||
return filterAnd(...filters.map(filter => filterNot(filter)));
|
||||
}
|
||||
const [filter] = filters;
|
||||
if (isString(filter)) {
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import React from 'react';
|
||||
import { DataRow } from '../../../../ui-next/src/components/DataRow';
|
||||
import { Button } from '../../../../ui-next/src/components/Button';
|
||||
import { Icons } from '../../../../ui-next/src/components/Icons';
|
||||
|
||||
// Mock data to demonstrate DataRow usage
|
||||
const mockData = [
|
||||
|
||||
@ -35,8 +35,14 @@ const AccordionTrigger = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-primary h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
{props.asChild ? (
|
||||
children
|
||||
) : (
|
||||
<>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-primary h-4 w-4 shrink-0 transition-transform duration-200" />
|
||||
</>
|
||||
)}
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
));
|
||||
|
||||
12
platform/ui-next/src/components/ColorCircle.tsx
Normal file
12
platform/ui-next/src/components/ColorCircle.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
export function ColorCircle({ colorHex, className = 'inline-flex' }) {
|
||||
return (
|
||||
<div className={`h-5 w-5 ${className} items-center justify-center`}>
|
||||
<span
|
||||
className="ml-2 h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: colorHex, marginLeft: 0 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -63,25 +63,25 @@ interface DataRowProps {
|
||||
details?: { primary: string[]; secondary: string[] };
|
||||
//
|
||||
isSelected?: boolean;
|
||||
onSelect?: () => void;
|
||||
onSelect?: (e) => void;
|
||||
//
|
||||
isVisible: boolean;
|
||||
onToggleVisibility: () => void;
|
||||
onToggleVisibility: (e) => void;
|
||||
//
|
||||
isLocked: boolean;
|
||||
onToggleLocked: () => void;
|
||||
onToggleLocked: (e) => void;
|
||||
//
|
||||
title: string;
|
||||
onRename: () => void;
|
||||
onRename: (e) => void;
|
||||
//
|
||||
onDelete: () => void;
|
||||
onDelete: (e) => void;
|
||||
//
|
||||
colorHex?: string;
|
||||
onColor: () => void;
|
||||
onColor: (e) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DataRow: React.FC<DataRowProps> = ({
|
||||
export const DataRow: React.FC<DataRowProps> = ({
|
||||
number,
|
||||
title,
|
||||
colorHex,
|
||||
@ -114,16 +114,16 @@ const DataRow: React.FC<DataRowProps> = ({
|
||||
e.stopPropagation();
|
||||
switch (action) {
|
||||
case 'Rename':
|
||||
onRename();
|
||||
onRename(e);
|
||||
break;
|
||||
case 'Lock':
|
||||
onToggleLocked();
|
||||
onToggleLocked(e);
|
||||
break;
|
||||
case 'Delete':
|
||||
onDelete();
|
||||
onDelete(e);
|
||||
break;
|
||||
case 'Color':
|
||||
onColor();
|
||||
onColor(e);
|
||||
break;
|
||||
}
|
||||
};
|
||||
@ -269,7 +269,7 @@ const DataRow: React.FC<DataRowProps> = ({
|
||||
aria-label={isVisible ? 'Hide' : 'Show'}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onToggleVisibility();
|
||||
onToggleVisibility(e);
|
||||
}}
|
||||
>
|
||||
{isVisible ? <Icons.Hide className="h-6 w-6" /> : <Icons.Show className="h-6 w-6" />}
|
||||
|
||||
@ -1,3 +1 @@
|
||||
import DataRow from './DataRow';
|
||||
|
||||
export { DataRow };
|
||||
export * from './DataRow';
|
||||
|
||||
@ -5,13 +5,9 @@ import { createContext } from '../../lib/createContext';
|
||||
|
||||
interface MeasurementTableContext {
|
||||
data?: any[];
|
||||
onClick?: (uid: string) => void;
|
||||
onDelete?: (uid: string) => void;
|
||||
onToggleVisibility?: (uid: string) => void;
|
||||
onToggleLocked?: (uid: string) => void;
|
||||
onRename?: (uid: string) => void;
|
||||
onColor?: (uid: string) => void;
|
||||
onAction?: (e, command: string | string[], uid: string) => void;
|
||||
disableEditing?: boolean;
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
||||
const [MeasurementTableProvider, useMeasurementTableContext] =
|
||||
@ -24,12 +20,8 @@ interface MeasurementDataProps extends MeasurementTableContext {
|
||||
|
||||
const MeasurementTable = ({
|
||||
data = [],
|
||||
onClick,
|
||||
onDelete,
|
||||
onToggleVisibility,
|
||||
onToggleLocked,
|
||||
onRename,
|
||||
onColor,
|
||||
onAction,
|
||||
isExpanded = true,
|
||||
title,
|
||||
children,
|
||||
disableEditing = false,
|
||||
@ -40,19 +32,18 @@ const MeasurementTable = ({
|
||||
return (
|
||||
<MeasurementTableProvider
|
||||
data={data}
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onToggleVisibility={onToggleVisibility}
|
||||
onToggleLocked={onToggleLocked}
|
||||
onRename={onRename}
|
||||
onColor={onColor}
|
||||
onAction={onAction}
|
||||
isExpanded={isExpanded}
|
||||
disableEditing={disableEditing}
|
||||
>
|
||||
<PanelSection defaultOpen={true}>
|
||||
<PanelSection.Header className="bg-secondary-dark">
|
||||
<PanelSection.Header
|
||||
key="measurementTableHeader"
|
||||
className="bg-secondary-dark"
|
||||
>
|
||||
<span>{`${t(title)} (${amount})`}</span>
|
||||
</PanelSection.Header>
|
||||
<PanelSection.Content>{children}</PanelSection.Content>
|
||||
<PanelSection.Content key="measurementTableContent">{children}</PanelSection.Content>
|
||||
</PanelSection>
|
||||
</MeasurementTableProvider>
|
||||
);
|
||||
@ -99,6 +90,7 @@ interface MeasurementItem {
|
||||
isVisible: boolean;
|
||||
isLocked: boolean;
|
||||
toolName: string;
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
@ -107,16 +99,10 @@ interface RowProps {
|
||||
}
|
||||
|
||||
const Row = ({ item, index }: RowProps) => {
|
||||
const {
|
||||
onClick,
|
||||
onDelete,
|
||||
onToggleVisibility,
|
||||
onToggleLocked,
|
||||
onRename,
|
||||
onColor,
|
||||
disableEditing,
|
||||
} = useMeasurementTableContext('MeasurementTable.Row');
|
||||
const { onAction, isExpanded, disableEditing } =
|
||||
useMeasurementTableContext('MeasurementTable.Row');
|
||||
|
||||
const { uid } = item;
|
||||
return (
|
||||
<DataRow
|
||||
key={item.uid}
|
||||
@ -126,15 +112,15 @@ const Row = ({ item, index }: RowProps) => {
|
||||
colorHex={item.colorHex}
|
||||
isSelected={item.isSelected}
|
||||
details={item.displayText}
|
||||
onSelect={() => onClick(item.uid)}
|
||||
onDelete={() => onDelete(item.uid)}
|
||||
onDelete={e => onAction(e, 'removeMeasurement', uid)}
|
||||
onSelect={e => onAction(e, 'jumpToMeasurement', uid)}
|
||||
onRename={e => onAction(e, 'renameMeasurement', uid)}
|
||||
onToggleVisibility={e => onAction(e, 'toggleVisibilityMeasurement', uid)}
|
||||
onToggleLocked={e => onAction(e, 'toggleLockMeasurement', uid)}
|
||||
disableEditing={disableEditing}
|
||||
isExpanded={isExpanded}
|
||||
isVisible={item.isVisible}
|
||||
isLocked={item.isLocked}
|
||||
onToggleVisibility={() => onToggleVisibility(item.uid)}
|
||||
onToggleLocked={() => onToggleLocked(item.uid)}
|
||||
onRename={() => onRename(item.uid)}
|
||||
// onColor={() => onColor(item.uid)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -14,7 +14,10 @@ interface StudySummaryProps {
|
||||
*/
|
||||
const StudySummary: React.FC<StudySummaryProps> = ({ date, description }) => {
|
||||
return (
|
||||
<div className="mx-2 my-0">
|
||||
<div
|
||||
className="mx-2 my-0"
|
||||
style={{ textAlign: 'left' }}
|
||||
>
|
||||
<div className="text-foreground text-sm">{date}</div>
|
||||
<div className="text-muted-foreground pb-1 text-sm">{description}</div>
|
||||
</div>
|
||||
|
||||
@ -79,8 +79,8 @@ import {
|
||||
} from './DropdownMenu';
|
||||
import { Onboarding } from './Onboarding';
|
||||
import { DoubleSlider } from './DoubleSlider';
|
||||
import { DataRow } from './DataRow';
|
||||
import { MeasurementTable } from './MeasurementTable';
|
||||
export { DataRow } from './DataRow';
|
||||
export { MeasurementTable } from './MeasurementTable';
|
||||
import {
|
||||
SegmentationTable,
|
||||
useSegmentationTableContext,
|
||||
@ -90,6 +90,7 @@ import {
|
||||
import { Toaster, toast } from './Sonner';
|
||||
import { StudySummary } from './StudySummary';
|
||||
import { ErrorBoundary } from './Errorboundary';
|
||||
export * from './ColorCircle';
|
||||
import { Header } from './Header';
|
||||
import { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from './Card';
|
||||
import {
|
||||
@ -208,8 +209,6 @@ export {
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
DataRow,
|
||||
MeasurementTable,
|
||||
Toaster,
|
||||
toast,
|
||||
SegmentationTable,
|
||||
|
||||
@ -1,254 +1,6 @@
|
||||
import {
|
||||
Button,
|
||||
buttonVariants,
|
||||
ThemeWrapper,
|
||||
Dialog,
|
||||
Command,
|
||||
Popover,
|
||||
Combobox,
|
||||
Calendar,
|
||||
DatePickerWithRange,
|
||||
Separator,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
Clipboard,
|
||||
TabsTrigger,
|
||||
Toggle,
|
||||
toggleVariants,
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Icons,
|
||||
SidePanel,
|
||||
StudyItem,
|
||||
StudyBrowser,
|
||||
StudyBrowserSort,
|
||||
StudyBrowserViewOptions,
|
||||
Thumbnail,
|
||||
ThumbnailList,
|
||||
PanelSection,
|
||||
DisplaySetMessageListTooltip,
|
||||
ToolboxUI,
|
||||
ToolSettings,
|
||||
DoubleSlider,
|
||||
Label,
|
||||
Slider,
|
||||
Input,
|
||||
Switch,
|
||||
Checkbox,
|
||||
Onboarding,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectValue,
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
ScrollArea,
|
||||
MeasurementTable,
|
||||
SegmentationTable,
|
||||
useSegmentationTableContext,
|
||||
useSegmentationExpanded,
|
||||
useSegmentStatistics,
|
||||
TooltipProvider,
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
StudySummary,
|
||||
ErrorBoundary,
|
||||
Header,
|
||||
ViewportActionButton,
|
||||
PatientInfo,
|
||||
ViewportActionBar,
|
||||
ViewportActionArrows,
|
||||
ViewportPane,
|
||||
ViewportActionCorners,
|
||||
ViewportActionCornersLocations,
|
||||
ViewportOverlay,
|
||||
ViewportGrid,
|
||||
ToolButton,
|
||||
ToolButtonList,
|
||||
ToolButtonListDefault,
|
||||
ToolButtonListDropDown,
|
||||
ToolButtonListItem,
|
||||
ToolButtonListDivider,
|
||||
Numeric,
|
||||
InputDialog,
|
||||
PresetDialog,
|
||||
Modal,
|
||||
AboutModal,
|
||||
ImageModal,
|
||||
UserPreferencesModal,
|
||||
FooterAction,
|
||||
InputFilter,
|
||||
} from './components';
|
||||
import { DataRow } from './components/DataRow';
|
||||
export * from './components';
|
||||
export * from './contextProviders';
|
||||
|
||||
import {
|
||||
useNotification,
|
||||
NotificationProvider,
|
||||
useModal,
|
||||
ModalProvider,
|
||||
DialogProvider,
|
||||
useDialog,
|
||||
ManagedDialog,
|
||||
} from './contextProviders';
|
||||
import { ViewportGridContext, ViewportGridProvider, useViewportGrid } from './contextProviders';
|
||||
import * as utils from './utils';
|
||||
|
||||
export {
|
||||
ErrorBoundary,
|
||||
// components
|
||||
Button,
|
||||
Dialog,
|
||||
Command,
|
||||
Popover,
|
||||
Combobox,
|
||||
Checkbox,
|
||||
DoubleSlider,
|
||||
buttonVariants,
|
||||
ThemeWrapper,
|
||||
Calendar,
|
||||
DatePickerWithRange,
|
||||
Clipboard,
|
||||
// contextProviders
|
||||
NotificationProvider,
|
||||
useNotification,
|
||||
ViewportGridContext,
|
||||
ViewportGridProvider,
|
||||
useViewportGrid,
|
||||
Separator,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
Toggle,
|
||||
toggleVariants,
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
Icons,
|
||||
SidePanel,
|
||||
StudyItem,
|
||||
StudyBrowser,
|
||||
StudyBrowserSort,
|
||||
StudyBrowserViewOptions,
|
||||
Thumbnail,
|
||||
ThumbnailList,
|
||||
PanelSection,
|
||||
DisplaySetMessageListTooltip,
|
||||
ToolboxUI,
|
||||
Label,
|
||||
Slider,
|
||||
Input,
|
||||
Switch,
|
||||
Onboarding,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectValue,
|
||||
DataRow,
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
ScrollArea,
|
||||
MeasurementTable,
|
||||
SegmentationTable,
|
||||
useSegmentationTableContext,
|
||||
useSegmentationExpanded,
|
||||
TooltipProvider,
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
StudySummary,
|
||||
Header,
|
||||
ViewportActionButton,
|
||||
PatientInfo,
|
||||
ViewportActionBar,
|
||||
ViewportActionArrows,
|
||||
ViewportPane,
|
||||
ViewportActionCorners,
|
||||
ViewportActionCornersLocations,
|
||||
ViewportOverlay,
|
||||
ViewportGrid,
|
||||
ToolButton,
|
||||
ToolButtonList,
|
||||
ToolButtonListDefault,
|
||||
ToolButtonListDropDown,
|
||||
ToolButtonListItem,
|
||||
ToolButtonListDivider,
|
||||
utils,
|
||||
Numeric,
|
||||
AboutModal,
|
||||
ImageModal,
|
||||
UserPreferencesModal,
|
||||
InputDialog,
|
||||
PresetDialog,
|
||||
Modal,
|
||||
useModal,
|
||||
ModalProvider,
|
||||
FooterAction,
|
||||
DialogProvider,
|
||||
useDialog,
|
||||
ManagedDialog,
|
||||
ToolSettings,
|
||||
useSegmentStatistics,
|
||||
InputFilter,
|
||||
};
|
||||
export { utils };
|
||||
|
||||
@ -31,7 +31,7 @@ class LabellingFlow extends Component<PropType> {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
const { label } = props.measurementData;
|
||||
const label = props.measurementData?.label;
|
||||
const className = props.componentClassName;
|
||||
|
||||
this.state = {
|
||||
|
||||
@ -33,8 +33,13 @@ const MeasurementTable = ({
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-secondary-main flex justify-between px-2 py-1">
|
||||
<span className="text-base font-bold uppercase tracking-widest text-white">{t(title)}</span>
|
||||
<span className="text-base font-bold text-white">{amount}</span>
|
||||
<div>
|
||||
<span className="text-base font-bold uppercase tracking-widest text-white">
|
||||
{t(title)}
|
||||
</span>
|
||||
<span className="text-base font-bold text-white"> {amount}</span>
|
||||
</div>
|
||||
<CustomizedToolbar servicesManager={servicesManager} />
|
||||
</div>
|
||||
<div className="ohif-scrollbar max-h-112 overflow-hidden">
|
||||
{data.length !== 0 &&
|
||||
|
||||
Loading…
Reference in New Issue
Block a user