feat(MeasurementService): add rendering of unmapped measurements (#5416)
This commit is contained in:
1 parent
639863a8ef
commit
851e74d7b8
13 files changed
+345
-92
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import log from '../../log';
|
||||
import guid from '../../utils/guid';
|
||||
import { PubSubService } from '../_shared/pubSubServiceInterface';
|
||||
import { DicomMetadataStore } from '../DicomMetadataStore/DicomMetadataStore';
|
||||
|
||||
/**
|
||||
* Measurement source schema
|
||||
@@ -127,7 +128,6 @@ class MeasurementService extends PubSubService {
|
||||
public readonly VALUE_TYPES = VALUE_TYPES;
|
||||
|
||||
private measurements = new Map();
|
||||
private unmappedMeasurements = new Map();
|
||||
private isMeasurementDeletedIndividually: boolean;
|
||||
|
||||
private sources = {};
|
||||
@@ -495,6 +495,7 @@ class MeasurementService extends PubSubService {
|
||||
);
|
||||
if (!sourceMapping) {
|
||||
console.log('No source mapping', source.uid, annotationType, source);
|
||||
this.addUnmappedMeasurement(sourceAnnotationDetail, source);
|
||||
return;
|
||||
}
|
||||
const { toMeasurementSchema } = sourceMapping;
|
||||
@@ -507,16 +508,7 @@ class MeasurementService extends PubSubService {
|
||||
|
||||
measurement.source = source;
|
||||
} catch (error) {
|
||||
// Todo: handle other
|
||||
this.unmappedMeasurements.set(sourceAnnotationDetail.uid, {
|
||||
...sourceAnnotationDetail,
|
||||
source: {
|
||||
name: source.name,
|
||||
version: source.version,
|
||||
uid: source.uid,
|
||||
},
|
||||
});
|
||||
|
||||
this.addUnmappedMeasurement(sourceAnnotationDetail, source);
|
||||
console.log('Failed to map', error);
|
||||
throw new Error(
|
||||
`Failed to map '${sourceInfo}' measurement for annotationType ${annotationType}: ${error.message}`
|
||||
@@ -577,14 +569,98 @@ class MeasurementService extends PubSubService {
|
||||
return newMeasurement.uid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively searches for any attribute at any level in the object
|
||||
* @param {any} obj The object to search
|
||||
* @param {string} attributeName The name of the attribute to find
|
||||
* @returns {any} The attribute value if found, undefined otherwise
|
||||
*/
|
||||
private findAttributeRecursively(obj: any, attributeName: string): any {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
if (obj[attributeName]) {
|
||||
return obj[attributeName];
|
||||
}
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
const result = this.findAttributeRecursively(obj[key], attributeName);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an unmapped measurement to the measurement service.
|
||||
*
|
||||
* @param {any} sourceAnnotationDetail The source annotation detail
|
||||
* @param {any} source The source
|
||||
*/
|
||||
private addUnmappedMeasurement(sourceAnnotationDetail: any, source: any) {
|
||||
if (sourceAnnotationDetail.annotation?.invalidated === true) {
|
||||
console.log('Measurement is invalidated, skipping...', sourceAnnotationDetail);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sourceAnnotationDetail.annotation?.isPreview === true) {
|
||||
console.log('Measurement is preview, skipping...', sourceAnnotationDetail);
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = this.findAttributeRecursively(sourceAnnotationDetail, 'metadata');
|
||||
const label = this.findAttributeRecursively(sourceAnnotationDetail, 'label');
|
||||
const referencedImageId = this.findAttributeRecursively(
|
||||
sourceAnnotationDetail,
|
||||
'referencedImageId'
|
||||
);
|
||||
const displaySetInstanceUID = this.findAttributeRecursively(
|
||||
sourceAnnotationDetail,
|
||||
'displaySetInstanceUID'
|
||||
);
|
||||
|
||||
const measurement = {
|
||||
...sourceAnnotationDetail,
|
||||
isUnmapped: true,
|
||||
statusTooltip: 'This measurement is not compatible with this application',
|
||||
source: {
|
||||
name: source.name,
|
||||
version: source.version,
|
||||
uid: source.uid,
|
||||
},
|
||||
};
|
||||
|
||||
if (metadata) {
|
||||
measurement.metadata = metadata;
|
||||
}
|
||||
|
||||
if (label) {
|
||||
measurement.label = label;
|
||||
}
|
||||
|
||||
if (referencedImageId) {
|
||||
measurement.referencedImageId = referencedImageId;
|
||||
const instance = DicomMetadataStore.getInstanceByImageId(referencedImageId);
|
||||
measurement.referenceStudyUID = instance.StudyInstanceUID;
|
||||
measurement.referenceSeriesUID = instance.SeriesInstanceUID;
|
||||
}
|
||||
|
||||
if (displaySetInstanceUID) {
|
||||
measurement.displaySetInstanceUID = displaySetInstanceUID;
|
||||
}
|
||||
|
||||
this.measurements.set(sourceAnnotationDetail.uid, measurement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a measurement and broadcasts the removed event.
|
||||
*
|
||||
* @param {string} measurementUID The measurement uid
|
||||
*/
|
||||
remove(measurementUID: string): void {
|
||||
const measurement =
|
||||
this.measurements.get(measurementUID) || this.unmappedMeasurements.get(measurementUID);
|
||||
const measurement = this.measurements.get(measurementUID);
|
||||
|
||||
if (!measurementUID || !measurement) {
|
||||
console.debug(`No uid provided, or unable to find measurement by uid.`);
|
||||
@@ -593,7 +669,6 @@ class MeasurementService extends PubSubService {
|
||||
|
||||
const source = measurement.source;
|
||||
|
||||
this.unmappedMeasurements.delete(measurementUID);
|
||||
this.measurements.delete(measurementUID);
|
||||
this.isMeasurementDeletedIndividually = true;
|
||||
this._broadcastEvent(this.EVENTS.MEASUREMENT_REMOVED, {
|
||||
@@ -609,14 +684,13 @@ class MeasurementService extends PubSubService {
|
||||
const measurements = [];
|
||||
for (const measurementUID of measurementUIDs) {
|
||||
const measurement =
|
||||
this.measurements.get(measurementUID) || this.unmappedMeasurements.get(measurementUID);
|
||||
this.measurements.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);
|
||||
}
|
||||
@@ -634,11 +708,7 @@ class MeasurementService extends PubSubService {
|
||||
public clearMeasurements(filter?: MeasurementFilter) {
|
||||
// Make a copy of the measurements
|
||||
const toClear = this.getMeasurements(filter);
|
||||
const unmappedClear = filter
|
||||
? [...this.unmappedMeasurements.values()].filter(filter)
|
||||
: this.unmappedMeasurements;
|
||||
const measurements = [...toClear, ...unmappedClear];
|
||||
unmappedClear.forEach(measurement => this.unmappedMeasurements.delete(measurement.uid));
|
||||
const measurements = [...toClear];
|
||||
toClear.forEach(measurement => this.measurements.delete(measurement.uid));
|
||||
this._broadcastEvent(this.EVENTS.MEASUREMENTS_CLEARED, { measurements });
|
||||
}
|
||||
@@ -657,7 +727,8 @@ class MeasurementService extends PubSubService {
|
||||
*/
|
||||
|
||||
public jumpToMeasurement(viewportId: string, measurementUID: string): void {
|
||||
const measurement = this.measurements.get(measurementUID);
|
||||
const measurement =
|
||||
this.measurements.get(measurementUID)
|
||||
|
||||
if (!measurement) {
|
||||
log.warn(`No measurement uid, or unable to find by uid.`);
|
||||
@@ -779,7 +850,8 @@ class MeasurementService extends PubSubService {
|
||||
};
|
||||
|
||||
public toggleLockMeasurement(measurementUID: string): void {
|
||||
const measurement = this.measurements.get(measurementUID);
|
||||
const measurement =
|
||||
this.measurements.get(measurementUID)
|
||||
|
||||
if (!measurement) {
|
||||
console.debug(`No measurement found for uid: ${measurementUID}`);
|
||||
@@ -796,7 +868,8 @@ class MeasurementService extends PubSubService {
|
||||
}
|
||||
|
||||
public toggleVisibilityMeasurement(measurementUID: string, visibility?: boolean): void {
|
||||
const measurement = this.measurements.get(measurementUID);
|
||||
const measurement =
|
||||
this.measurements.get(measurementUID)
|
||||
|
||||
if (!measurement) {
|
||||
console.debug(`No measurement found for uid: ${measurementUID}`);
|
||||
@@ -820,7 +893,8 @@ class MeasurementService extends PubSubService {
|
||||
}
|
||||
|
||||
public updateColorMeasurement(measurementUID: string, color: number[]): void {
|
||||
const measurement = this.measurements.get(measurementUID);
|
||||
const measurement =
|
||||
this.measurements.get(measurementUID)
|
||||
|
||||
if (!measurement) {
|
||||
console.debug(`No measurement found for uid: ${measurementUID}`);
|
||||
|
||||
@@ -10,6 +10,7 @@ export function filterMeasurementsBySeriesUID(selectedSeries: string[]) {
|
||||
return measurement => selectedSeries.includes(measurement.referenceSeriesUID);
|
||||
}
|
||||
|
||||
/** A filter that filters for measurements belonging to the study */
|
||||
export function filterMeasurementsByStudyUID(studyUID) {
|
||||
return measurement => measurement.referenceStudyUID == studyUID;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { cn } from '../../lib/utils';
|
||||
* @component
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Basic usage without status
|
||||
* <DataRow
|
||||
* number={1}
|
||||
* title="My Item"
|
||||
@@ -33,6 +34,29 @@ import { cn } from '../../lib/utils';
|
||||
* onDelete={() => {}}
|
||||
* onColor={() => {}}
|
||||
* />
|
||||
*
|
||||
* // With warning status using composite pattern
|
||||
* <DataRow
|
||||
* // ... other props
|
||||
* >
|
||||
* <DataRow.Status.Warning tooltip="This structured report is not compatible with this application" />
|
||||
* </DataRow>
|
||||
*
|
||||
* // With success status using composite pattern
|
||||
* <DataRow
|
||||
* // ... other props
|
||||
* >
|
||||
* <DataRow.Status.Success tooltip="Measurement completed successfully" />
|
||||
* </DataRow>
|
||||
*
|
||||
* // Multiple status indicators
|
||||
* <DataRow
|
||||
* // ... other props
|
||||
* >
|
||||
* <DataRow.Status.Warning tooltip="Warning message" />
|
||||
* <DataRow.Status.Info tooltip="Additional info" />
|
||||
* </DataRow>
|
||||
*
|
||||
* ```
|
||||
*/
|
||||
|
||||
@@ -55,6 +79,7 @@ import { cn } from '../../lib/utils';
|
||||
* @property {() => void} onRename - Callback when rename is requested
|
||||
* @property {() => void} onDelete - Callback when delete is requested
|
||||
* @property {() => void} onColor - Callback when color change is requested
|
||||
* @property {React.ReactNode} children - Optional children, including Status components
|
||||
*/
|
||||
interface DataRowProps {
|
||||
number: number | null;
|
||||
@@ -79,9 +104,10 @@ interface DataRowProps {
|
||||
colorHex?: string;
|
||||
onColor: (e) => void;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DataRow: React.FC<DataRowProps> = ({
|
||||
const DataRowComponent: React.FC<DataRowProps> = ({
|
||||
number,
|
||||
title,
|
||||
colorHex,
|
||||
@@ -97,11 +123,20 @@ export const DataRow: React.FC<DataRowProps> = ({
|
||||
isVisible = true,
|
||||
disableEditing = false,
|
||||
className,
|
||||
children,
|
||||
}) => {
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const isTitleLong = title?.length > 25;
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Extract Status components from children
|
||||
const statusComponents = React.Children.toArray(children).filter(
|
||||
child =>
|
||||
React.isValidElement(child) &&
|
||||
child.type &&
|
||||
(child.type as any).displayName?.startsWith('DataRow.Status')
|
||||
);
|
||||
|
||||
// useEffect(() => {
|
||||
// if (isSelected && rowRef.current) {
|
||||
// setTimeout(() => {
|
||||
@@ -281,6 +316,9 @@ export const DataRow: React.FC<DataRowProps> = ({
|
||||
{/* Lock Icon (if needed) */}
|
||||
{isLocked && !disableEditing && <Icons.Lock className="text-muted-foreground h-6 w-6" />}
|
||||
|
||||
{/* Status Components */}
|
||||
{statusComponents}
|
||||
|
||||
{/* Actions Dropdown Menu */}
|
||||
{disableEditing && <div className="h-6 w-6"></div>}
|
||||
{!disableEditing && (
|
||||
@@ -369,4 +407,94 @@ export const DataRow: React.FC<DataRowProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface StatusProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface StatusIndicatorProps {
|
||||
tooltip?: string;
|
||||
icon: React.ReactNode;
|
||||
defaultTooltip: string;
|
||||
}
|
||||
|
||||
const StatusIndicator: React.FC<StatusIndicatorProps> = ({ tooltip, icon, defaultTooltip }) => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex h-6 w-6 items-center justify-center">{icon}</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<div>{tooltip || defaultTooltip}</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const Status: React.FC<StatusProps> & {
|
||||
Warning: React.FC<{ tooltip?: string }>;
|
||||
Success: React.FC<{ tooltip?: string }>;
|
||||
Error: React.FC<{ tooltip?: string }>;
|
||||
Info: React.FC<{ tooltip?: string }>;
|
||||
} = ({ children }) => {
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const StatusWarning: React.FC<{ tooltip?: string }> = ({ tooltip }) => (
|
||||
<StatusIndicator
|
||||
tooltip={tooltip}
|
||||
icon={
|
||||
<Icons.ByName
|
||||
name="status-alert"
|
||||
className="h-4 w-4 text-yellow-500"
|
||||
/>
|
||||
}
|
||||
defaultTooltip="Warning"
|
||||
/>
|
||||
);
|
||||
|
||||
const StatusSuccess: React.FC<{ tooltip?: string }> = ({ tooltip }) => (
|
||||
<StatusIndicator
|
||||
tooltip={tooltip}
|
||||
icon={<Icons.Checked className="h-4 w-4 text-green-500" />}
|
||||
defaultTooltip="Success"
|
||||
/>
|
||||
);
|
||||
|
||||
const StatusError: React.FC<{ tooltip?: string }> = ({ tooltip }) => (
|
||||
<StatusIndicator
|
||||
tooltip={tooltip}
|
||||
icon={
|
||||
<Icons.ByName
|
||||
name="status-error"
|
||||
className="h-4 w-4 text-red-500"
|
||||
/>
|
||||
}
|
||||
defaultTooltip="Error"
|
||||
/>
|
||||
);
|
||||
|
||||
const StatusInfo: React.FC<{ tooltip?: string }> = ({ tooltip }) => (
|
||||
<StatusIndicator
|
||||
tooltip={tooltip}
|
||||
icon={<Icons.Info className="h-4 w-4 text-blue-500" />}
|
||||
defaultTooltip="Info"
|
||||
/>
|
||||
);
|
||||
|
||||
Status.displayName = 'DataRow.Status';
|
||||
StatusWarning.displayName = 'DataRow.Status.Warning';
|
||||
StatusSuccess.displayName = 'DataRow.Status.Success';
|
||||
StatusError.displayName = 'DataRow.Status.Error';
|
||||
StatusInfo.displayName = 'DataRow.Status.Info';
|
||||
|
||||
Status.Warning = StatusWarning;
|
||||
Status.Success = StatusSuccess;
|
||||
Status.Error = StatusError;
|
||||
Status.Info = StatusInfo;
|
||||
|
||||
const DataRow = DataRowComponent as React.FC<DataRowProps> & {
|
||||
Status: typeof Status;
|
||||
};
|
||||
|
||||
DataRow.Status = Status;
|
||||
|
||||
export default DataRow;
|
||||
export { DataRow };
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DataRow, PanelSection } from '../../index';
|
||||
import { Icons, PanelSection, Tooltip, TooltipContent, TooltipTrigger } from '../../index';
|
||||
import DataRow from '../DataRow/DataRow';
|
||||
import { createContext } from '../../lib/createContext';
|
||||
|
||||
interface MeasurementTableContext {
|
||||
@@ -11,7 +12,7 @@ interface MeasurementTableContext {
|
||||
}
|
||||
|
||||
const [MeasurementTableProvider, useMeasurementTableContext] =
|
||||
createContext<MeasurementTableContext>('MeasurementTable', { data: [] });
|
||||
createContext<MeasurementTableContext>('MeasurementTable', { data: [], isExpanded: true });
|
||||
|
||||
interface MeasurementDataProps extends MeasurementTableContext {
|
||||
title: string;
|
||||
@@ -91,6 +92,8 @@ interface MeasurementItem {
|
||||
isLocked: boolean;
|
||||
toolName: string;
|
||||
isExpanded: boolean;
|
||||
isUnmapped?: boolean;
|
||||
statusTooltip?: string;
|
||||
}
|
||||
|
||||
interface RowProps {
|
||||
@@ -117,11 +120,15 @@ const Row = ({ item, index }: RowProps) => {
|
||||
onRename={e => onAction(e, 'renameMeasurement', uid)}
|
||||
onToggleVisibility={e => onAction(e, 'toggleVisibilityMeasurement', uid)}
|
||||
onToggleLocked={e => onAction(e, 'toggleLockMeasurement', uid)}
|
||||
onColor={e => onAction(e, 'changeMeasurementColor', uid)}
|
||||
disableEditing={disableEditing}
|
||||
isExpanded={isExpanded}
|
||||
isVisible={item.isVisible}
|
||||
isLocked={item.isLocked}
|
||||
/>
|
||||
>
|
||||
{item.isUnmapped && (
|
||||
<DataRow.Status.Warning tooltip={item.statusTooltip} />
|
||||
)}
|
||||
</DataRow>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user