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 {
debugger;
const { segmentationService } = servicesManager.services;
const segmentations = segmentationService.getSegmentations();
const { seriesMetadata, instance } =

View File

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

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

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 [
{
name: 'activeViewportWindowLevel',
@ -76,7 +63,7 @@ const getPanelModule = ({ commandsManager, servicesManager, extensionManager }:
iconName: 'tab-linear',
iconLabel: 'Measure',
label: 'Measurement',
component: wrappedPanelMeasurement,
component: PanelMeasurement,
},
{
name: 'panelSegmentation',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,6 +17,7 @@ import { useToggleHangingProtocolStore } from './stores/useToggleHangingProtocol
import { useViewportsByPositionStore } from './stores/useViewportsByPositionStore';
import { useToggleOneUpViewportGridStore } from './stores/useToggleOneUpViewportGridStore';
import requestDisplaySetCreationForStudy from './Panels/requestDisplaySetCreationForStudy';
import promptSaveReport from './utils/promptSaveReport';
export type HangingProtocolParams = {
protocolId?: string;
@ -75,6 +76,14 @@ const commandsModule = ({
}
},
/** Displays a prompt and then save the report if relevant */
promptSaveReport: props => {
const { StudyInstanceUID } = props;
promptSaveReport({ servicesManager, commandsManager, extensionManager }, props, {
data: { StudyInstanceUID },
});
},
/**
* Ensures that the specified study is available for display
* Then, if commands is specified, runs the given commands list/instance
@ -140,8 +149,9 @@ const commandsModule = ({
type: type,
});
},
clearMeasurements: () => {
measurementService.clearMeasurements();
clearMeasurements: options => {
measurementService.clearMeasurements(options.measurementFilter);
},
/**
@ -340,7 +350,6 @@ const commandsModule = ({
const { protocol } = hangingProtocolService.getActiveProtocol();
const onLayoutChange = protocol.callbacks?.onLayoutChange;
if (commandsManager.run(onLayoutChange, { numRows, numCols }) === false) {
console.log('setViewportGridLayout running', onLayoutChange, numRows, numCols);
// Don't apply the layout if the run command returns false
return;
}
@ -621,6 +630,7 @@ const commandsModule = ({
const definitions = {
multimonitor: actions.multimonitor,
promptSaveReport: actions.promptSaveReport,
loadStudy: actions.loadStudy,
showContextMenu: actions.showContextMenu,
closeContextMenu: actions.closeContextMenu,

View File

@ -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({

View File

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

View File

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

View File

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

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

View File

@ -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: [
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)) {

View File

@ -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 = [

View File

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

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[] };
//
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" />}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = {

View File

@ -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">&nbsp; {amount}</span>
</div>
<CustomizedToolbar servicesManager={servicesManager} />
</div>
<div className="ohif-scrollbar max-h-112 overflow-hidden">
{data.length !== 0 &&