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

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

No files matched your search

+2 -2
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,
@@ -9,9 +9,7 @@ import { DisplaySet } from '../types';
* @returns Array of display sets for the active viewport
*/
const useActiveViewportDisplaySets = ({ servicesManager }): DisplaySet[] => {
const [displaySets, setDisplaySets] = useState<DisplaySet[]>([]);
const { displaySetService, viewportGridService } = servicesManager.services;
// Move this function outside useEffect and memoize it
const getDisplaySetsForViewport = useCallback(
(viewportId: string) => {
@@ -21,19 +19,22 @@ const useActiveViewportDisplaySets = ({ servicesManager }): DisplaySet[] => {
[displaySetService, viewportGridService]
);
useEffect(() => {
// Get initial state
const viewportId = viewportGridService.getActiveViewportId();
setDisplaySets(getDisplaySetsForViewport(viewportId));
// Get initial state
const viewportId = viewportGridService.getActiveViewportId();
const displaySetsNew = getDisplaySetsForViewport(viewportId) || [];
const [displaySets, setDisplaySets] = useState<DisplaySet[]>(displaySetsNew);
useEffect(() => {
const handleViewportChange = ({ viewportId }) => {
setDisplaySets(getDisplaySetsForViewport(viewportId));
const displaySetsNew = getDisplaySetsForViewport(viewportId);
setDisplaySets(displaySetsNew);
};
const handleGridStateChange = ({ state }) => {
const activeViewportId = state.activeViewportId;
if (activeViewportId) {
setDisplaySets(getDisplaySetsForViewport(activeViewportId));
const displaySetsNew = getDisplaySetsForViewport(activeViewportId);
setDisplaySets(displaySetsNew);
}
};
@@ -405,11 +405,14 @@ class MeasurementService extends PubSubService {
let measurement = {};
try {
measurement = toMeasurementSchema(data);
if (!measurement) {
return;
}
measurement.source = source;
} catch (error) {
log.warn(
`Failed to map '${sourceInfo}' measurement for annotationType ${annotationType}:`,
error.message
error
);
return;
}
@@ -589,6 +592,30 @@ class MeasurementService extends PubSubService {
});
}
/**
* Remove multiple measurements at once.
*/
removeMany(measurementUIDs: string[]): void {
const measurements = [];
for (const measurementUID of measurementUIDs) {
const measurement =
this.measurements.get(measurementUID) || this.unmappedMeasurements.get(measurementUID);
if (!measurementUID || !measurement) {
console.debug(`No uid provided, or unable to find measurement by uid.`);
continue;
}
this.unmappedMeasurements.delete(measurementUID);
this.measurements.delete(measurementUID);
measurements.push(measurement);
}
if (!measurements.length) {
return;
}
this._broadcastEvent(this.EVENTS.MEASUREMENTS_CLEARED, { measurements });
}
/**
* Clears measurements that match the filter, defaulting to all of them.
* That allows, for example, clearing all of a single studies measurements
@@ -769,7 +796,7 @@ class MeasurementService extends PubSubService {
});
}
public toggleVisibilityMeasurement(measurementUID: string): void {
public toggleVisibilityMeasurement(measurementUID: string, visibility?: boolean): void {
const measurement = this.measurements.get(measurementUID);
if (!measurement) {
@@ -777,7 +804,10 @@ class MeasurementService extends PubSubService {
return;
}
measurement.isVisible = !measurement.isVisible;
if (measurement.isVisible === visibility && visibility !== undefined) {
return;
}
measurement.isVisible = visibility !== undefined ? visibility : !measurement.isVisible;
this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, {
source: measurement.source,
@@ -786,6 +816,10 @@ class MeasurementService extends PubSubService {
});
}
public toggleVisibilityMeasurementMany(measurementUIDs: string[], visibility?: boolean): void {
return measurementUIDs.forEach(uid => this.toggleVisibilityMeasurement(uid, visibility));
}
public updateColorMeasurement(measurementUID: string, color: number[]): void {
const measurement = this.measurements.get(measurementUID);
@@ -130,6 +130,9 @@ class ViewportGridService extends PubSubService {
}
public setActiveViewportId(id: string) {
if (id === this.getActiveViewportId()) {
return;
}
this.serviceImplementation._setActiveViewport(id);
// Use queueMicrotask to delay the event broadcast
+1
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;
+29 -5
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)) {
@@ -1,7 +1,5 @@
import React from 'react';
import { DataRow } from '../../../../ui-next/src/components/DataRow';
import { Button } from '../../../../ui-next/src/components/Button';
import { Icons } from '../../../../ui-next/src/components/Icons';
// Mock data to demonstrate DataRow usage
const mockData = [
@@ -35,8 +35,14 @@ const AccordionTrigger = React.forwardRef<
)}
{...props}
>
{children}
<ChevronDownIcon className="text-primary h-4 w-4 shrink-0 transition-transform duration-200" />
{props.asChild ? (
children
) : (
<>
{children}
<ChevronDownIcon className="text-primary h-4 w-4 shrink-0 transition-transform duration-200" />
</>
)}
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
@@ -0,0 +1,12 @@
import React from 'react';
export function ColorCircle({ colorHex, className = 'inline-flex' }) {
return (
<div className={`h-5 w-5 ${className} items-center justify-center`}>
<span
className="ml-2 h-2 w-2 rounded-full"
style={{ backgroundColor: colorHex, marginLeft: 0 }}
/>
</div>
);
}
@@ -63,25 +63,25 @@ interface DataRowProps {
details?: { primary: string[]; secondary: string[] };
//
isSelected?: boolean;
onSelect?: () => void;
onSelect?: (e) => void;
//
isVisible: boolean;
onToggleVisibility: () => void;
onToggleVisibility: (e) => void;
//
isLocked: boolean;
onToggleLocked: () => void;
onToggleLocked: (e) => void;
//
title: string;
onRename: () => void;
onRename: (e) => void;
//
onDelete: () => void;
onDelete: (e) => void;
//
colorHex?: string;
onColor: () => void;
onColor: (e) => void;
className?: string;
}
const DataRow: React.FC<DataRowProps> = ({
export const DataRow: React.FC<DataRowProps> = ({
number,
title,
colorHex,
@@ -114,16 +114,16 @@ const DataRow: React.FC<DataRowProps> = ({
e.stopPropagation();
switch (action) {
case 'Rename':
onRename();
onRename(e);
break;
case 'Lock':
onToggleLocked();
onToggleLocked(e);
break;
case 'Delete':
onDelete();
onDelete(e);
break;
case 'Color':
onColor();
onColor(e);
break;
}
};
@@ -269,7 +269,7 @@ const DataRow: React.FC<DataRowProps> = ({
aria-label={isVisible ? 'Hide' : 'Show'}
onClick={e => {
e.stopPropagation();
onToggleVisibility();
onToggleVisibility(e);
}}
>
{isVisible ? <Icons.Hide className="h-6 w-6" /> : <Icons.Show className="h-6 w-6" />}
@@ -1,3 +1 @@
import DataRow from './DataRow';
export { DataRow };
export * from './DataRow';
@@ -5,13 +5,9 @@ import { createContext } from '../../lib/createContext';
interface MeasurementTableContext {
data?: any[];
onClick?: (uid: string) => void;
onDelete?: (uid: string) => void;
onToggleVisibility?: (uid: string) => void;
onToggleLocked?: (uid: string) => void;
onRename?: (uid: string) => void;
onColor?: (uid: string) => void;
onAction?: (e, command: string | string[], uid: string) => void;
disableEditing?: boolean;
isExpanded: boolean;
}
const [MeasurementTableProvider, useMeasurementTableContext] =
@@ -24,12 +20,8 @@ interface MeasurementDataProps extends MeasurementTableContext {
const MeasurementTable = ({
data = [],
onClick,
onDelete,
onToggleVisibility,
onToggleLocked,
onRename,
onColor,
onAction,
isExpanded = true,
title,
children,
disableEditing = false,
@@ -40,19 +32,18 @@ const MeasurementTable = ({
return (
<MeasurementTableProvider
data={data}
onClick={onClick}
onDelete={onDelete}
onToggleVisibility={onToggleVisibility}
onToggleLocked={onToggleLocked}
onRename={onRename}
onColor={onColor}
onAction={onAction}
isExpanded={isExpanded}
disableEditing={disableEditing}
>
<PanelSection defaultOpen={true}>
<PanelSection.Header className="bg-secondary-dark">
<PanelSection.Header
key="measurementTableHeader"
className="bg-secondary-dark"
>
<span>{`${t(title)} (${amount})`}</span>
</PanelSection.Header>
<PanelSection.Content>{children}</PanelSection.Content>
<PanelSection.Content key="measurementTableContent">{children}</PanelSection.Content>
</PanelSection>
</MeasurementTableProvider>
);
@@ -99,6 +90,7 @@ interface MeasurementItem {
isVisible: boolean;
isLocked: boolean;
toolName: string;
isExpanded: boolean;
}
interface RowProps {
@@ -107,16 +99,10 @@ interface RowProps {
}
const Row = ({ item, index }: RowProps) => {
const {
onClick,
onDelete,
onToggleVisibility,
onToggleLocked,
onRename,
onColor,
disableEditing,
} = useMeasurementTableContext('MeasurementTable.Row');
const { onAction, isExpanded, disableEditing } =
useMeasurementTableContext('MeasurementTable.Row');
const { uid } = item;
return (
<DataRow
key={item.uid}
@@ -126,15 +112,15 @@ const Row = ({ item, index }: RowProps) => {
colorHex={item.colorHex}
isSelected={item.isSelected}
details={item.displayText}
onSelect={() => onClick(item.uid)}
onDelete={() => onDelete(item.uid)}
onDelete={e => onAction(e, 'removeMeasurement', uid)}
onSelect={e => onAction(e, 'jumpToMeasurement', uid)}
onRename={e => onAction(e, 'renameMeasurement', uid)}
onToggleVisibility={e => onAction(e, 'toggleVisibilityMeasurement', uid)}
onToggleLocked={e => onAction(e, 'toggleLockMeasurement', uid)}
disableEditing={disableEditing}
isExpanded={isExpanded}
isVisible={item.isVisible}
isLocked={item.isLocked}
onToggleVisibility={() => onToggleVisibility(item.uid)}
onToggleLocked={() => onToggleLocked(item.uid)}
onRename={() => onRename(item.uid)}
// onColor={() => onColor(item.uid)}
/>
);
};
@@ -14,7 +14,10 @@ interface StudySummaryProps {
*/
const StudySummary: React.FC<StudySummaryProps> = ({ date, description }) => {
return (
<div className="mx-2 my-0">
<div
className="mx-2 my-0"
style={{ textAlign: 'left' }}
>
<div className="text-foreground text-sm">{date}</div>
<div className="text-muted-foreground pb-1 text-sm">{description}</div>
</div>
+3 -4
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,
+3 -251
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 };
@@ -31,7 +31,7 @@ class LabellingFlow extends Component<PropType> {
constructor(props) {
super(props);
const { label } = props.measurementData;
const label = props.measurementData?.label;
const className = props.componentClassName;
this.state = {
@@ -33,8 +33,13 @@ const MeasurementTable = ({
return (
<div>
<div className="bg-secondary-main flex justify-between px-2 py-1">
<span className="text-base font-bold uppercase tracking-widest text-white">{t(title)}</span>
<span className="text-base font-bold text-white">{amount}</span>
<div>
<span className="text-base font-bold uppercase tracking-widest text-white">
{t(title)}
</span>
<span className="text-base font-bold text-white">&nbsp; {amount}</span>
</div>
<CustomizedToolbar servicesManager={servicesManager} />
</div>
<div className="ohif-scrollbar max-h-112 overflow-hidden">
{data.length !== 0 &&