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

This commit is contained in:
Bill Wallace 2025-03-20 09:22:45 -04:00 committed by GitHub
parent 0522d58d9b
commit df8efba82c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 1256 additions and 631 deletions

View File

@ -262,7 +262,6 @@ function _getInstanceFromSegmentations(segmentations, { servicesManager }) {
} }
function updateSegmentationsChartDisplaySet({ servicesManager }: withAppTypes): void { function updateSegmentationsChartDisplaySet({ servicesManager }: withAppTypes): void {
debugger;
const { segmentationService } = servicesManager.services; const { segmentationService } = servicesManager.services;
const segmentations = segmentationService.getSegmentations(); const segmentations = segmentationService.getSegmentations();
const { seriesMetadata, instance } = const { seriesMetadata, instance } =

View File

@ -6,6 +6,8 @@ import {
Types as CoreTypes, Types as CoreTypes,
cache, cache,
BaseVolumeViewport, BaseVolumeViewport,
triggerEvent,
eventTarget,
} from '@cornerstonejs/core'; } from '@cornerstonejs/core';
import { import {
ToolGroupManager, ToolGroupManager,
@ -13,6 +15,7 @@ import {
utilities as cstUtils, utilities as cstUtils,
ReferenceLinesTool, ReferenceLinesTool,
annotation, annotation,
Types as ToolTypes,
} from '@cornerstonejs/tools'; } from '@cornerstonejs/tools';
import * as cornerstoneTools from '@cornerstonejs/tools'; import * as cornerstoneTools from '@cornerstonejs/tools';
import * as labelmapInterpolation from '@cornerstonejs/labelmap-interpolation'; import * as labelmapInterpolation from '@cornerstonejs/labelmap-interpolation';
@ -35,6 +38,7 @@ import { toolNames } from './initCornerstoneTools';
import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm'; import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm';
import { updateSegmentBidirectionalStats } from './utils/updateSegmentationStats'; import { updateSegmentBidirectionalStats } from './utils/updateSegmentationStats';
import { generateSegmentationCSVReport } from './utils/generateSegmentationCSVReport'; import { generateSegmentationCSVReport } from './utils/generateSegmentationCSVReport';
const { DefaultHistoryMemo } = csUtils.HistoryMemo; const { DefaultHistoryMemo } = csUtils.HistoryMemo;
const toggleSyncFunctions = { const toggleSyncFunctions = {
imageSlice: toggleImageSliceSync, imageSlice: toggleImageSliceSync,
@ -436,25 +440,26 @@ function commandsModule({
}, },
removeMeasurement: ({ uid }) => { removeMeasurement: ({ uid }) => {
measurementService.remove(uid); if (Array.isArray(uid)) {
measurementService.removeMany(uid);
} else {
measurementService.remove(uid);
}
}, },
toggleLockMeasurement: ({ uid }) => { toggleLockMeasurement: ({ uid }) => {
measurementService.toggleLockMeasurement(uid); measurementService.toggleLockMeasurement(uid);
}, },
toggleVisibilityMeasurement: ({ uid }) => { toggleVisibilityMeasurement: ({ uid, items, visibility }) => {
measurementService.toggleVisibilityMeasurement(uid); if (visibility === undefined && items?.length) {
}, visibility = !items[0].isVisible;
}
/** if (Array.isArray(uid)) {
* Clear the measurements measurementService.toggleVisibilityMeasurementMany(uid, visibility);
*/ } else {
clearMeasurements: options => { measurementService.toggleVisibilityMeasurement(uid, visibility);
const { measurementFilter } = options; }
measurementService.clearMeasurements(
measurementFilter ? measurementFilter.bind(options) : null
);
}, },
/** /**
@ -514,16 +519,18 @@ function commandsModule({
viewportGridService.setActiveViewportId(viewportId); viewportGridService.setActiveViewportId(viewportId);
}, },
arrowTextCallback: ({ callback }) => { arrowTextCallback: async ({ callback }) => {
const labelConfig = customizationService.getCustomization('measurementLabels'); const labelConfig = customizationService.getCustomization('measurementLabels');
const renderContent = customizationService.getCustomization('ui.labellingComponent'); const renderContent = customizationService.getCustomization('ui.labellingComponent');
callInputDialogAutoComplete({ const value = await callInputDialogAutoComplete({
uiDialogService, uiDialogService,
labelConfig, labelConfig,
renderContent, renderContent,
}); });
callback?.(value);
}, },
toggleCine: () => { toggleCine: () => {
const { viewports } = viewportGridService.getState(); const { viewports } = viewportGridService.getState();
const { isCineEnabled } = cineService.getState(); const { isCineEnabled } = cineService.getState();
@ -879,7 +886,7 @@ function commandsModule({
const options = { imageIndex: jumpIndex }; const options = { imageIndex: jumpIndex };
csUtils.jumpToSlice(viewport.element, options); csUtils.jumpToSlice(viewport.element, options);
}, },
scroll: ({ direction }) => { scroll: (options: ToolTypes.ScrollOptions) => {
const enabledElement = _getActiveViewportEnabledElement(); const enabledElement = _getActiveViewportEnabledElement();
if (!enabledElement) { if (!enabledElement) {
@ -887,7 +894,6 @@ function commandsModule({
} }
const { viewport } = enabledElement; const { viewport } = enabledElement;
const options = { delta: direction };
csUtils.scroll(viewport, options); csUtils.scroll(viewport, options);
}, },
@ -1490,6 +1496,7 @@ function commandsModule({
viewportGridService.getActiveViewportId() viewportGridService.getActiveViewportId()
); );
}, },
deleteActiveAnnotation: () => { deleteActiveAnnotation: () => {
const activeAnnotationsUID = cornerstoneTools.annotation.selection.getAnnotationsSelected(); const activeAnnotationsUID = cornerstoneTools.annotation.selection.getAnnotationsSelected();
activeAnnotationsUID.forEach(activeAnnotationUID => { activeAnnotationsUID.forEach(activeAnnotationUID => {
@ -1577,9 +1584,6 @@ function commandsModule({
updateMeasurement: { updateMeasurement: {
commandFn: actions.updateMeasurement, commandFn: actions.updateMeasurement,
}, },
clearMeasurements: {
commandFn: actions.clearMeasurements,
},
jumpToMeasurement: { jumpToMeasurement: {
commandFn: actions.jumpToMeasurement, commandFn: actions.jumpToMeasurement,
}, },

View File

@ -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;

View File

@ -0,0 +1,4 @@
export * from './AccordionGroup';
import AccordionGroup from './AccordionGroup';
export default AccordionGroup;

View 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>
);
}

View File

@ -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>
);
}

View 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;

View File

@ -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;

View File

@ -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>
);
}

View 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;

View File

@ -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>
);
}

View 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;

View File

@ -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;

View File

@ -4,7 +4,8 @@ import { StudySummary } from '@ohif/ui-next';
const { formatDate } = utils; const { formatDate } = utils;
export function StudySummaryFromMetadata({ StudyInstanceUID }) { export function StudySummaryFromMetadata(props) {
const { StudyInstanceUID } = props;
if (!StudyInstanceUID) { if (!StudyInstanceUID) {
return null; return null;
} }
@ -20,6 +21,6 @@ export function StudySummaryFromMetadata({ StudyInstanceUID }) {
<StudySummary <StudySummary
date={formatDate(StudyDate)} date={formatDate(StudyDate)}
description={StudyDescription} description={StudyDescription}
></StudySummary> />
); );
} }

View File

@ -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;

View 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 };

View File

@ -51,19 +51,6 @@ const getPanelModule = ({ commandsManager, servicesManager, extensionManager }:
); );
}; };
const wrappedPanelMeasurement = ({ configuration }) => {
return (
<PanelMeasurement
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
configuration={{
...configuration,
}}
/>
);
};
return [ return [
{ {
name: 'activeViewportWindowLevel', name: 'activeViewportWindowLevel',
@ -76,7 +63,7 @@ const getPanelModule = ({ commandsManager, servicesManager, extensionManager }:
iconName: 'tab-linear', iconName: 'tab-linear',
iconLabel: 'Measure', iconLabel: 'Measure',
label: 'Measurement', label: 'Measurement',
component: wrappedPanelMeasurement, component: PanelMeasurement,
}, },
{ {
name: 'panelSegmentation', name: 'panelSegmentation',

View File

@ -30,6 +30,8 @@ import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledEle
import { id } from './id'; import { id } from './id';
import { measurementMappingUtils } from './utils/measurementServiceMappings'; 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 type { PublicViewportOptions } from './services/ViewportService/Viewport';
import ImageOverlayViewerTool from './tools/ImageOverlayViewerTool'; import ImageOverlayViewerTool from './tools/ImageOverlayViewerTool';
import ViewportActionCornersService from './services/ViewportActionCornersService/ViewportActionCornersService'; import ViewportActionCornersService from './services/ViewportActionCornersService/ViewportActionCornersService';
@ -51,11 +53,11 @@ import { useMeasurements } from './hooks/useMeasurements';
import getPanelModule from './getPanelModule'; import getPanelModule from './getPanelModule';
import PanelSegmentation from './panels/PanelSegmentation'; import PanelSegmentation from './panels/PanelSegmentation';
import PanelMeasurement from './panels/PanelMeasurement'; import PanelMeasurement from './panels/PanelMeasurement';
import DicomUpload from './components/DicomUpload/DicomUpload';
import { useSegmentations } from './hooks/useSegmentations'; import { useSegmentations } from './hooks/useSegmentations';
import { StudySummaryFromMetadata } from './components/StudySummaryFromMetadata'; import { StudySummaryFromMetadata } from './components/StudySummaryFromMetadata';
import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm'; import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm';
import utils from './utils'; import utils from './utils';
export * from './components';
const { imageRetrieveMetadataProvider } = cornerstone.utilities; const { imageRetrieveMetadataProvider } = cornerstone.utilities;
@ -234,6 +236,8 @@ const cornerstoneExtension: Types.Extensions.Extension = {
export type { PublicViewportOptions }; export type { PublicViewportOptions };
export { export {
measurementMappingUtils, measurementMappingUtils,
PlanarFreehandROI,
RectangleROI,
CornerstoneExtensionTypes as Types, CornerstoneExtensionTypes as Types,
toolNames, toolNames,
getActiveViewportEnabledElement, getActiveViewportEnabledElement,
@ -254,7 +258,6 @@ export {
useSegmentations, useSegmentations,
PanelSegmentation, PanelSegmentation,
PanelMeasurement, PanelMeasurement,
DicomUpload,
StudySummaryFromMetadata, StudySummaryFromMetadata,
CornerstoneViewportDownloadForm, CornerstoneViewportDownloadForm,
utils, utils,

View File

@ -1,107 +1,103 @@
import React, { useEffect, useRef } from 'react'; import React from 'react';
import { utils } from '@ohif/core'; import { useSystem } from '@ohif/core';
import { MeasurementTable } from '@ohif/ui-next';
import debounce from 'lodash.debounce';
import { useMeasurements } from '../hooks/useMeasurements'; 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; const system = useSystem();
const displayMeasurements = useMeasurements(system.servicesManager, {
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, {
measurementFilter, measurementFilter,
}); });
useEffect(() => { if (!displayMeasurements.length) {
if (displayMeasurements.length > 0) { return EmptyComponent ? (
debounce(() => { <EmptyComponent
measurementsPanelRef.current.scrollTop = measurementsPanelRef.current.scrollHeight; key={key}
}, 300)(); data-cy={key}
} items={displayMeasurements}
}, [displayMeasurements.length]); />
) : (
const bindCommand = (name: string | string[], options?) => { <span className="text-white">No Measurements</span>
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 (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 ( return (
<> <StudyMeasurements
<div key={key}
className="invisible-scrollbar overflow-y-auto overflow-x-hidden" data-cy={key}
ref={measurementsPanelRef} items={displayMeasurements}
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>
</>
); );
} }

View File

@ -1,4 +1,5 @@
import * as CornerstoneCacheService from './CornerstoneCacheService'; import * as CornerstoneCacheService from './CornerstoneCacheService';
import CornerstoneServices from './CornerstoneServices'; import type CornerstoneServices from './CornerstoneServices';
export { type SyncGroup } from '../services/SyncGroupService';
export type { CornerstoneCacheService, CornerstoneServices }; export type { CornerstoneCacheService, CornerstoneServices };

View File

@ -16,7 +16,6 @@ const SegmentBidirectional = {
) => { ) => {
const { annotation } = csToolsEventDetail; const { annotation } = csToolsEventDetail;
const { metadata, data, annotationUID } = annotation; const { metadata, data, annotationUID } = annotation;
debugger;
const isLocked = getIsLocked(annotationUID); const isLocked = getIsLocked(annotationUID);
const isVisible = getIsVisible(annotationUID); const isVisible = getIsVisible(annotationUID);

View File

@ -24,7 +24,7 @@ function DataSourceConfigurationComponent({
const dataSourceChangedCallback = async () => { const dataSourceChangedCallback = async () => {
const activeDataSourceDef = extensionManager.getActiveDataSourceDefinition(); const activeDataSourceDef = extensionManager.getActiveDataSourceDefinition();
if (!activeDataSourceDef.configuration.configurationAPI) { if (!activeDataSourceDef?.configuration?.configurationAPI) {
return; return;
} }

View File

@ -36,6 +36,7 @@ export default function CreateReportDialogPrompt({
dataSourceName: undefined, dataSourceName: undefined,
}); });
}, },
defaultValue: title,
}, },
}); });
}); });

View File

@ -17,6 +17,7 @@ import { useToggleHangingProtocolStore } from './stores/useToggleHangingProtocol
import { useViewportsByPositionStore } from './stores/useViewportsByPositionStore'; import { useViewportsByPositionStore } from './stores/useViewportsByPositionStore';
import { useToggleOneUpViewportGridStore } from './stores/useToggleOneUpViewportGridStore'; import { useToggleOneUpViewportGridStore } from './stores/useToggleOneUpViewportGridStore';
import requestDisplaySetCreationForStudy from './Panels/requestDisplaySetCreationForStudy'; import requestDisplaySetCreationForStudy from './Panels/requestDisplaySetCreationForStudy';
import promptSaveReport from './utils/promptSaveReport';
export type HangingProtocolParams = { export type HangingProtocolParams = {
protocolId?: string; 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 * Ensures that the specified study is available for display
* Then, if commands is specified, runs the given commands list/instance * Then, if commands is specified, runs the given commands list/instance
@ -140,8 +149,9 @@ const commandsModule = ({
type: type, type: type,
}); });
}, },
clearMeasurements: () => {
measurementService.clearMeasurements(); clearMeasurements: options => {
measurementService.clearMeasurements(options.measurementFilter);
}, },
/** /**
@ -340,7 +350,6 @@ const commandsModule = ({
const { protocol } = hangingProtocolService.getActiveProtocol(); const { protocol } = hangingProtocolService.getActiveProtocol();
const onLayoutChange = protocol.callbacks?.onLayoutChange; const onLayoutChange = protocol.callbacks?.onLayoutChange;
if (commandsManager.run(onLayoutChange, { numRows, numCols }) === false) { 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 // Don't apply the layout if the run command returns false
return; return;
} }
@ -621,6 +630,7 @@ const commandsModule = ({
const definitions = { const definitions = {
multimonitor: actions.multimonitor, multimonitor: actions.multimonitor,
promptSaveReport: actions.promptSaveReport,
loadStudy: actions.loadStudy, loadStudy: actions.loadStudy,
showContextMenu: actions.showContextMenu, showContextMenu: actions.showContextMenu,
closeContextMenu: actions.closeContextMenu, closeContextMenu: actions.closeContextMenu,

View File

@ -94,12 +94,12 @@ export async function callInputDialogAutoComplete({
const dropDownItems = labelConfig ? labelConfig.items : []; const dropDownItems = labelConfig ? labelConfig.items : [];
const value = await new Promise<Map<string, string>>((resolve, reject) => { const value = await new Promise<Map<string, string>>((resolve, reject) => {
const labellingDoneCallback = value => { const labellingDoneCallback = newValue => {
uiDialogService.hide('select-annotation'); uiDialogService.hide('select-annotation');
if (typeof value === 'string') { if (measurement && typeof newValue === 'string') {
measurement.label = value; measurement.label = newValue;
} }
resolve(measurement); resolve(newValue);
}; };
uiDialogService.show({ uiDialogService.show({

View File

@ -1,58 +1,76 @@
import { utils } from '@ohif/core';
import createReportAsync from '../Actions/createReportAsync'; import createReportAsync from '../Actions/createReportAsync';
import { createReportDialogPrompt } from '../Panels';
import getNextSRSeriesNumber from './getNextSRSeriesNumber'; import getNextSRSeriesNumber from './getNextSRSeriesNumber';
import PROMPT_RESPONSES from './_shared/PROMPT_RESPONSES'; import PROMPT_RESPONSES from './_shared/PROMPT_RESPONSES';
import createReportDialogPrompt from '../Panels/createReportDialogPrompt';
/** const {
* Prompts the user to save a report and handles the report creation process filterAnd,
* @param services - Object containing required services and managers filterMeasurementsByStudyUID,
* @param ctx - The current context containing tracked study and series information filterMeasurementsBySeriesUID,
* @param evt - The event object containing viewport and save-related data filterPlanarMeasurement,
*/ } = utils.MeasurementFilters;
async function promptSaveReport(services, ctx, evt) {
const { servicesManager, extensionManager, commandsManager } = services;
async function promptSaveReport({ servicesManager, commandsManager, extensionManager }, ctx, evt) {
const { measurementService, displaySetService } = servicesManager.services; const { measurementService, displaySetService } = servicesManager.services;
const viewportId = evt.viewportId === undefined ? evt.data.viewportId : evt.viewportId;
const viewportId = evt.viewportId ?? evt.data?.viewportId; const isBackupSave = evt.isBackupSave === undefined ? evt.data.isBackupSave : evt.isBackupSave;
const isBackupSave = evt.isBackupSave ?? evt.data?.isBackupSave; const StudyInstanceUID = evt?.data?.StudyInstanceUID || ctx.trackedStudy;
const { StudyInstanceUID, SeriesInstanceUID } = evt?.data ?? {}; const SeriesInstanceUID = evt?.data?.SeriesInstanceUID;
const { trackedStudy, trackedSeries } = ctx;
const dataSources = extensionManager.getDataSources();
const { const {
value: reportName, trackedSeries,
dataSourceName: dataSource, measurementFilter = filterAnd(
action, filterMeasurementsByStudyUID(StudyInstanceUID),
} = await createReportDialogPrompt({ filterMeasurementsBySeriesUID(trackedSeries),
servicesManager, filterPlanarMeasurement
extensionManager, ),
}); defaultSaveTitle = 'Research Derived Series',
} = ctx;
let displaySetInstanceUIDs; let displaySetInstanceUIDs;
try { try {
if (action === PROMPT_RESPONSES.CREATE_REPORT) { const promptResult = await createReportDialogPrompt({
const selectedDataSource = dataSource ?? dataSources[0]; title: defaultSaveTitle,
const trackedMeasurements = getTrackedMeasurements( extensionManager,
measurementService, servicesManager,
trackedStudy, });
trackedSeries
); 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); 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, servicesManager,
commandsManager, getReport,
trackedMeasurements,
selectedDataSource,
reportName,
SeriesNumber,
}); });
} else if (promptResult.action === RESPONSE.CANCEL) {
// Do nothing
} }
return { return {
userResponse: action, userResponse: promptResult.action,
createdDisplaySetInstanceUIDs: displaySetInstanceUIDs, createdDisplaySetInstanceUIDs: displaySetInstanceUIDs,
StudyInstanceUID, StudyInstanceUID,
SeriesInstanceUID, SeriesInstanceUID,
@ -60,50 +78,9 @@ async function promptSaveReport(services, ctx, evt) {
isBackupSave, isBackupSave,
}; };
} catch (error) { } catch (error) {
console.warn('Unable to save report', error);
return null; 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; export default promptSaveReport;

View File

@ -25,6 +25,7 @@ function getPanelModule({ commandsManager, extensionManager, servicesManager }):
component: props => ( component: props => (
<PanelMeasurementTableTracking <PanelMeasurementTableTracking
{...props} {...props}
key="trackedMeasurements-panel"
commandsManager={commandsManager} commandsManager={commandsManager}
extensionManager={extensionManager} extensionManager={extensionManager}
servicesManager={servicesManager} servicesManager={servicesManager}

View File

@ -1,90 +1,81 @@
import React, { useEffect, useState } from 'react'; import React from 'react';
import { DicomMetadataStore, utils } from '@ohif/core'; import { utils } from '@ohif/core';
import { useViewportGrid } from '@ohif/ui-next'; import { AccordionTrigger, MeasurementTable, useViewportGrid } from '@ohif/ui-next';
import { Button, Icons } from '@ohif/ui-next'; import {
import { PanelMeasurement, StudySummaryFromMetadata } from '@ohif/extension-cornerstone'; PanelMeasurement,
StudyMeasurements,
StudyMeasurementsActions,
StudySummaryFromMetadata,
AccordionGroup,
MeasurementsOrAdditionalFindings,
} from '@ohif/extension-cornerstone';
import { useTrackedMeasurements } from '../getContextModule'; import { useTrackedMeasurements } from '../getContextModule';
const { filterAnd, filterPlanarMeasurement, filterMeasurementsBySeriesUID } = const { filterAnd, filterPlanarMeasurement, filterMeasurementsBySeriesUID } =
utils.MeasurementFilters; utils.MeasurementFilters;
function PanelMeasurementTableTracking({ function PanelMeasurementTableTracking(props) {
servicesManager,
extensionManager,
commandsManager,
}: withAppTypes) {
const [viewportGrid] = useViewportGrid(); const [viewportGrid] = useViewportGrid();
const { customizationService } = servicesManager.services;
const [trackedMeasurements, sendTrackedMeasurementsEvent] = useTrackedMeasurements(); const [trackedMeasurements, sendTrackedMeasurementsEvent] = useTrackedMeasurements();
const { trackedStudy, trackedSeries } = trackedMeasurements.context; const { trackedStudy, trackedSeries } = trackedMeasurements.context;
const measurementFilter = trackedStudy const measurementFilter = trackedStudy
? filterAnd(filterPlanarMeasurement, filterMeasurementsBySeriesUID(trackedSeries)) ? filterAnd(filterPlanarMeasurement, filterMeasurementsBySeriesUID(trackedSeries))
: filterPlanarMeasurement; : 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 ( return (
<> <PanelMeasurement
<StudySummaryFromMetadata StudyInstanceUID={trackedStudy} /> measurementFilter={measurementFilter}
<PanelMeasurement emptyComponent={EmptyComponent}
servicesManager={servicesManager} sourceChildren={props.children}
extensionManager={extensionManager} >
commandsManager={commandsManager} <StudyMeasurements grouping={props.grouping}>
measurementFilter={measurementFilter} <AccordionGroup.Trigger
customHeader={({ additionalFindings, measurements }) => { key="trackingMeasurementsHeader"
const disabled = additionalFindings.length === 0 && measurements.length === 0; asChild={true}
>
if (disableEditing || disabled) { <Header key="trackingHeadChild" />
return null; </AccordionGroup.Trigger>
} <MeasurementsOrAdditionalFindings
key="measurementsOrAdditionalFindings"
return ( activeStudyUID={trackedStudy}
<div className="bg-background flex h-9 w-full items-center rounded pr-0.5"> customHeader={StudyMeasurementsActions}
<div className="flex space-x-1"> measurementFilter={measurementFilter}
<Button actions={actions}
size="sm" />
variant="ghost" </StudyMeasurements>
className="pl-1.5" </PanelMeasurement>
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>
</>
); );
} }

View 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,
},
];
}

View File

@ -8,6 +8,7 @@ import getCustomizationModule from './getCustomizationModule';
import sameAs from './custom-attribute/sameAs'; import sameAs from './custom-attribute/sameAs';
import numberOfDisplaySets from './custom-attribute/numberOfDisplaySets'; import numberOfDisplaySets from './custom-attribute/numberOfDisplaySets';
import maxNumImageFrames from './custom-attribute/maxNumImageFrames'; import maxNumImageFrames from './custom-attribute/maxNumImageFrames';
import getPanelModule from './getPanelModule';
/** /**
* The test extension provides additional behavior for testing various * The test extension provides additional behavior for testing various
@ -50,6 +51,8 @@ const testExtension: Types.Extensions.Extension = {
/** Registers some customizations */ /** Registers some customizations */
getCustomizationModule, getCustomizationModule,
getPanelModule,
getHangingProtocolModule: () => { getHangingProtocolModule: () => {
return [ return [
// Create a MxN hanging protocol available by default // Create a MxN hanging protocol available by default

View File

@ -15,6 +15,10 @@ const ohif = {
thumbnailList: '@ohif/extension-default.panelModule.seriesList', thumbnailList: '@ohif/extension-default.panelModule.seriesList',
}; };
const testExtension = {
measurements: '@ohif/extension-test.panelModule.panelMeasurementSeries',
};
const tracked = { const tracked = {
measurements: '@ohif/extension-measurement-tracking.panelModule.trackedMeasurements', measurements: '@ohif/extension-measurement-tracking.panelModule.trackedMeasurements',
thumbnailList: '@ohif/extension-measurement-tracking.panelModule.seriesList', thumbnailList: '@ohif/extension-measurement-tracking.panelModule.seriesList',
@ -199,7 +203,7 @@ function modeFactory() {
props: { props: {
leftPanels: [tracked.thumbnailList], leftPanels: [tracked.thumbnailList],
leftPanelResizable: true, leftPanelResizable: true,
rightPanels: [cornerstone.panel, tracked.measurements, cornerstone.measurements], rightPanels: [cornerstone.panel, tracked.measurements, testExtension.measurements],
rightPanelResizable: true, rightPanelResizable: true,
viewports: [ viewports: [
{ {

View File

@ -4,7 +4,7 @@ window.config = {
name: 'config/kheops.js', name: 'config/kheops.js',
routerBasename: '/', routerBasename: '/',
extensions: [], extensions: [],
modes: [], modes: ['@ohif/mode-test'],
customizationService: {}, customizationService: {},
showStudyList: true, showStudyList: true,
// some windows systems have issues with more than 3 web workers // some windows systems have issues with more than 3 web workers
@ -26,7 +26,7 @@ window.config = {
// filterQueryParam: false, // filterQueryParam: false,
// Uses the ohif datasource as the default - this requires that KHEOPS be // Uses the ohif datasource as the default - this requires that KHEOPS be
// configured with an OHIF path to .../viewer/dicomwebproxy // 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 */ /* 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: { // dangerouslyUseDynamicConfig: {
// enabled: true, // enabled: true,

View File

@ -9,9 +9,7 @@ import { DisplaySet } from '../types';
* @returns Array of display sets for the active viewport * @returns Array of display sets for the active viewport
*/ */
const useActiveViewportDisplaySets = ({ servicesManager }): DisplaySet[] => { const useActiveViewportDisplaySets = ({ servicesManager }): DisplaySet[] => {
const [displaySets, setDisplaySets] = useState<DisplaySet[]>([]);
const { displaySetService, viewportGridService } = servicesManager.services; const { displaySetService, viewportGridService } = servicesManager.services;
// Move this function outside useEffect and memoize it // Move this function outside useEffect and memoize it
const getDisplaySetsForViewport = useCallback( const getDisplaySetsForViewport = useCallback(
(viewportId: string) => { (viewportId: string) => {
@ -21,19 +19,22 @@ const useActiveViewportDisplaySets = ({ servicesManager }): DisplaySet[] => {
[displaySetService, viewportGridService] [displaySetService, viewportGridService]
); );
useEffect(() => { // Get initial state
// Get initial state const viewportId = viewportGridService.getActiveViewportId();
const viewportId = viewportGridService.getActiveViewportId(); const displaySetsNew = getDisplaySetsForViewport(viewportId) || [];
setDisplaySets(getDisplaySetsForViewport(viewportId)); const [displaySets, setDisplaySets] = useState<DisplaySet[]>(displaySetsNew);
useEffect(() => {
const handleViewportChange = ({ viewportId }) => { const handleViewportChange = ({ viewportId }) => {
setDisplaySets(getDisplaySetsForViewport(viewportId)); const displaySetsNew = getDisplaySetsForViewport(viewportId);
setDisplaySets(displaySetsNew);
}; };
const handleGridStateChange = ({ state }) => { const handleGridStateChange = ({ state }) => {
const activeViewportId = state.activeViewportId; const activeViewportId = state.activeViewportId;
if (activeViewportId) { if (activeViewportId) {
setDisplaySets(getDisplaySetsForViewport(activeViewportId)); const displaySetsNew = getDisplaySetsForViewport(activeViewportId);
setDisplaySets(displaySetsNew);
} }
}; };

View File

@ -405,11 +405,14 @@ class MeasurementService extends PubSubService {
let measurement = {}; let measurement = {};
try { try {
measurement = toMeasurementSchema(data); measurement = toMeasurementSchema(data);
if (!measurement) {
return;
}
measurement.source = source; measurement.source = source;
} catch (error) { } catch (error) {
log.warn( log.warn(
`Failed to map '${sourceInfo}' measurement for annotationType ${annotationType}:`, `Failed to map '${sourceInfo}' measurement for annotationType ${annotationType}:`,
error.message error
); );
return; 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. * Clears measurements that match the filter, defaulting to all of them.
* That allows, for example, clearing all of a single studies measurements * 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); const measurement = this.measurements.get(measurementUID);
if (!measurement) { if (!measurement) {
@ -777,7 +804,10 @@ class MeasurementService extends PubSubService {
return; 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, { this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, {
source: measurement.source, 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 { public updateColorMeasurement(measurementUID: string, color: number[]): void {
const measurement = this.measurements.get(measurementUID); const measurement = this.measurements.get(measurementUID);

View File

@ -130,6 +130,9 @@ class ViewportGridService extends PubSubService {
} }
public setActiveViewportId(id: string) { public setActiveViewportId(id: string) {
if (id === this.getActiveViewportId()) {
return;
}
this.serviceImplementation._setActiveViewport(id); this.serviceImplementation._setActiveViewport(id);
// Use queueMicrotask to delay the event broadcast // Use queueMicrotask to delay the event broadcast

View File

@ -3,6 +3,7 @@ import { InstanceMetadata } from './StudyMetadata';
export type DisplaySet = { export type DisplaySet = {
displaySetInstanceUID: string; displaySetInstanceUID: string;
instances: InstanceMetadata[]; instances: InstanceMetadata[];
isReconstructable?: boolean;
StudyInstanceUID: string; StudyInstanceUID: string;
SeriesInstanceUID?: string; SeriesInstanceUID?: string;
SeriesNumber?: number; SeriesNumber?: number;

View File

@ -1,11 +1,19 @@
import MeasurementService from '../services/MeasurementService';
/** /**
* Returns a filter function which filters for measurements belonging to both * Returns a filter function which filters for measurements belonging to both
* the study and series. * the study and series.
*/ */
export function filterMeasurementsBySeriesUID(selectedSeries: string[]) { export function filterMeasurementsBySeriesUID(selectedSeries: string[]) {
if (!selectedSeries) {
return;
}
return measurement => selectedSeries.includes(measurement.referenceSeriesUID); 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) * @returns true for measurements include referencedImageId (coplanar with an image)
*/ */
@ -13,6 +21,10 @@ export function filterPlanarMeasurement(measurement) {
return measurement?.referencedImageId; return measurement?.referencedImageId;
} }
export function filterTool(toolName: string) {
return annotation => annotation.metadata?.toolName === toolName;
}
/** A filter that always returns true */ /** A filter that always returns true */
export function filterAny(_measurement) { export function filterAny(_measurement) {
return true; return true;
@ -30,6 +42,10 @@ export function filterNone(_measurement) {
export function filterOr(...filters) { export function filterOr(...filters) {
return function (item) { return function (item) {
for (let filter of filters) { for (let filter of filters) {
if (!filter) {
// Un undefined filter means all, so return true for the or
return true;
}
if (typeof filter === 'string') { if (typeof filter === 'string') {
filter = this[filter]; filter = this[filter];
} }
@ -44,13 +60,14 @@ export function filterOr(...filters) {
}; };
} }
const { POINT } = MeasurementService.VALUE_TYPES;
/** /**
* Filters for additional findings, that is, measurements with * Filters for additional findings, that is, measurements with
* a value of type point, and having a referenced image * a value of type point, and having a referenced image
*/ */
export function filterAdditionalFindings(measurementService) { export function filterAdditionalFindings(dm) {
const { POINT } = measurementService.VALUE_TYPES; return dm.type === POINT && dm.referencedImageId;
return dm => dm.type === POINT && dm.referencedImageId;
} }
/** /**
@ -70,8 +87,15 @@ const isString = s => typeof s === 'string' || s instanceof String;
* called on the final filter call. * called on the final filter call.
*/ */
export function filterAnd(...filters) { 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) { return function (item) {
for (const filter of filters) { for (const filter of nonNullFilters) {
if (isString(filter)) { if (isString(filter)) {
if (!this[filter](item)) { if (!this[filter](item)) {
return false; return false;
@ -98,7 +122,7 @@ export function filterAnd(...filters) {
*/ */
export function filterNot(...filters) { export function filterNot(...filters) {
if (filters.length !== 1) { if (filters.length !== 1) {
return filterAnd.apply(null, filters.map(filterNot)); return filterAnd(...filters.map(filter => filterNot(filter)));
} }
const [filter] = filters; const [filter] = filters;
if (isString(filter)) { if (isString(filter)) {

View File

@ -1,7 +1,5 @@
import React from 'react'; import React from 'react';
import { DataRow } from '../../../../ui-next/src/components/DataRow'; 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 // Mock data to demonstrate DataRow usage
const mockData = [ const mockData = [

View File

@ -35,8 +35,14 @@ const AccordionTrigger = React.forwardRef<
)} )}
{...props} {...props}
> >
{children} {props.asChild ? (
<ChevronDownIcon className="text-primary h-4 w-4 shrink-0 transition-transform duration-200" /> children
) : (
<>
{children}
<ChevronDownIcon className="text-primary h-4 w-4 shrink-0 transition-transform duration-200" />
</>
)}
</AccordionPrimitive.Trigger> </AccordionPrimitive.Trigger>
</AccordionPrimitive.Header> </AccordionPrimitive.Header>
)); ));

View 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>
);
}

View File

@ -63,25 +63,25 @@ interface DataRowProps {
details?: { primary: string[]; secondary: string[] }; details?: { primary: string[]; secondary: string[] };
// //
isSelected?: boolean; isSelected?: boolean;
onSelect?: () => void; onSelect?: (e) => void;
// //
isVisible: boolean; isVisible: boolean;
onToggleVisibility: () => void; onToggleVisibility: (e) => void;
// //
isLocked: boolean; isLocked: boolean;
onToggleLocked: () => void; onToggleLocked: (e) => void;
// //
title: string; title: string;
onRename: () => void; onRename: (e) => void;
// //
onDelete: () => void; onDelete: (e) => void;
// //
colorHex?: string; colorHex?: string;
onColor: () => void; onColor: (e) => void;
className?: string; className?: string;
} }
const DataRow: React.FC<DataRowProps> = ({ export const DataRow: React.FC<DataRowProps> = ({
number, number,
title, title,
colorHex, colorHex,
@ -114,16 +114,16 @@ const DataRow: React.FC<DataRowProps> = ({
e.stopPropagation(); e.stopPropagation();
switch (action) { switch (action) {
case 'Rename': case 'Rename':
onRename(); onRename(e);
break; break;
case 'Lock': case 'Lock':
onToggleLocked(); onToggleLocked(e);
break; break;
case 'Delete': case 'Delete':
onDelete(); onDelete(e);
break; break;
case 'Color': case 'Color':
onColor(); onColor(e);
break; break;
} }
}; };
@ -269,7 +269,7 @@ const DataRow: React.FC<DataRowProps> = ({
aria-label={isVisible ? 'Hide' : 'Show'} aria-label={isVisible ? 'Hide' : 'Show'}
onClick={e => { onClick={e => {
e.stopPropagation(); e.stopPropagation();
onToggleVisibility(); onToggleVisibility(e);
}} }}
> >
{isVisible ? <Icons.Hide className="h-6 w-6" /> : <Icons.Show className="h-6 w-6" />} {isVisible ? <Icons.Hide className="h-6 w-6" /> : <Icons.Show className="h-6 w-6" />}

View File

@ -1,3 +1 @@
import DataRow from './DataRow'; export * from './DataRow';
export { DataRow };

View File

@ -5,13 +5,9 @@ import { createContext } from '../../lib/createContext';
interface MeasurementTableContext { interface MeasurementTableContext {
data?: any[]; data?: any[];
onClick?: (uid: string) => void; onAction?: (e, command: string | string[], uid: string) => void;
onDelete?: (uid: string) => void;
onToggleVisibility?: (uid: string) => void;
onToggleLocked?: (uid: string) => void;
onRename?: (uid: string) => void;
onColor?: (uid: string) => void;
disableEditing?: boolean; disableEditing?: boolean;
isExpanded: boolean;
} }
const [MeasurementTableProvider, useMeasurementTableContext] = const [MeasurementTableProvider, useMeasurementTableContext] =
@ -24,12 +20,8 @@ interface MeasurementDataProps extends MeasurementTableContext {
const MeasurementTable = ({ const MeasurementTable = ({
data = [], data = [],
onClick, onAction,
onDelete, isExpanded = true,
onToggleVisibility,
onToggleLocked,
onRename,
onColor,
title, title,
children, children,
disableEditing = false, disableEditing = false,
@ -40,19 +32,18 @@ const MeasurementTable = ({
return ( return (
<MeasurementTableProvider <MeasurementTableProvider
data={data} data={data}
onClick={onClick} onAction={onAction}
onDelete={onDelete} isExpanded={isExpanded}
onToggleVisibility={onToggleVisibility}
onToggleLocked={onToggleLocked}
onRename={onRename}
onColor={onColor}
disableEditing={disableEditing} disableEditing={disableEditing}
> >
<PanelSection defaultOpen={true}> <PanelSection defaultOpen={true}>
<PanelSection.Header className="bg-secondary-dark"> <PanelSection.Header
key="measurementTableHeader"
className="bg-secondary-dark"
>
<span>{`${t(title)} (${amount})`}</span> <span>{`${t(title)} (${amount})`}</span>
</PanelSection.Header> </PanelSection.Header>
<PanelSection.Content>{children}</PanelSection.Content> <PanelSection.Content key="measurementTableContent">{children}</PanelSection.Content>
</PanelSection> </PanelSection>
</MeasurementTableProvider> </MeasurementTableProvider>
); );
@ -99,6 +90,7 @@ interface MeasurementItem {
isVisible: boolean; isVisible: boolean;
isLocked: boolean; isLocked: boolean;
toolName: string; toolName: string;
isExpanded: boolean;
} }
interface RowProps { interface RowProps {
@ -107,16 +99,10 @@ interface RowProps {
} }
const Row = ({ item, index }: RowProps) => { const Row = ({ item, index }: RowProps) => {
const { const { onAction, isExpanded, disableEditing } =
onClick, useMeasurementTableContext('MeasurementTable.Row');
onDelete,
onToggleVisibility,
onToggleLocked,
onRename,
onColor,
disableEditing,
} = useMeasurementTableContext('MeasurementTable.Row');
const { uid } = item;
return ( return (
<DataRow <DataRow
key={item.uid} key={item.uid}
@ -126,15 +112,15 @@ const Row = ({ item, index }: RowProps) => {
colorHex={item.colorHex} colorHex={item.colorHex}
isSelected={item.isSelected} isSelected={item.isSelected}
details={item.displayText} details={item.displayText}
onSelect={() => onClick(item.uid)} onDelete={e => onAction(e, 'removeMeasurement', uid)}
onDelete={() => onDelete(item.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} disableEditing={disableEditing}
isExpanded={isExpanded}
isVisible={item.isVisible} isVisible={item.isVisible}
isLocked={item.isLocked} isLocked={item.isLocked}
onToggleVisibility={() => onToggleVisibility(item.uid)}
onToggleLocked={() => onToggleLocked(item.uid)}
onRename={() => onRename(item.uid)}
// onColor={() => onColor(item.uid)}
/> />
); );
}; };

View File

@ -14,7 +14,10 @@ interface StudySummaryProps {
*/ */
const StudySummary: React.FC<StudySummaryProps> = ({ date, description }) => { const StudySummary: React.FC<StudySummaryProps> = ({ date, description }) => {
return ( 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-foreground text-sm">{date}</div>
<div className="text-muted-foreground pb-1 text-sm">{description}</div> <div className="text-muted-foreground pb-1 text-sm">{description}</div>
</div> </div>

View File

@ -79,8 +79,8 @@ import {
} from './DropdownMenu'; } from './DropdownMenu';
import { Onboarding } from './Onboarding'; import { Onboarding } from './Onboarding';
import { DoubleSlider } from './DoubleSlider'; import { DoubleSlider } from './DoubleSlider';
import { DataRow } from './DataRow'; export { DataRow } from './DataRow';
import { MeasurementTable } from './MeasurementTable'; export { MeasurementTable } from './MeasurementTable';
import { import {
SegmentationTable, SegmentationTable,
useSegmentationTableContext, useSegmentationTableContext,
@ -90,6 +90,7 @@ import {
import { Toaster, toast } from './Sonner'; import { Toaster, toast } from './Sonner';
import { StudySummary } from './StudySummary'; import { StudySummary } from './StudySummary';
import { ErrorBoundary } from './Errorboundary'; import { ErrorBoundary } from './Errorboundary';
export * from './ColorCircle';
import { Header } from './Header'; import { Header } from './Header';
import { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from './Card'; import { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from './Card';
import { import {
@ -208,8 +209,6 @@ export {
TooltipTrigger, TooltipTrigger,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
DataRow,
MeasurementTable,
Toaster, Toaster,
toast, toast,
SegmentationTable, SegmentationTable,

View File

@ -1,254 +1,6 @@
import { export * from './components';
Button, export * from './contextProviders';
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';
import {
useNotification,
NotificationProvider,
useModal,
ModalProvider,
DialogProvider,
useDialog,
ManagedDialog,
} from './contextProviders';
import { ViewportGridContext, ViewportGridProvider, useViewportGrid } from './contextProviders';
import * as utils from './utils'; import * as utils from './utils';
export { export { utils };
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,
};

View File

@ -31,7 +31,7 @@ class LabellingFlow extends Component<PropType> {
constructor(props) { constructor(props) {
super(props); super(props);
const { label } = props.measurementData; const label = props.measurementData?.label;
const className = props.componentClassName; const className = props.componentClassName;
this.state = { this.state = {

View File

@ -33,8 +33,13 @@ const MeasurementTable = ({
return ( return (
<div> <div>
<div className="bg-secondary-main flex justify-between px-2 py-1"> <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> <div>
<span className="text-base font-bold text-white">{amount}</span> <span className="text-base font-bold uppercase tracking-widest text-white">
{t(title)}
</span>
<span className="text-base font-bold text-white">&nbsp; {amount}</span>
</div>
<CustomizedToolbar servicesManager={servicesManager} />
</div> </div>
<div className="ohif-scrollbar max-h-112 overflow-hidden"> <div className="ohif-scrollbar max-h-112 overflow-hidden">
{data.length !== 0 && {data.length !== 0 &&