feat: improve dicom tag browser with nested rows (#4451)

This commit is contained in:
Pedro H. Köhler 2025-02-18 12:29:53 -03:00 committed by GitHub
parent 8b969eb1e9
commit 0b5836ca1a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 233 additions and 126 deletions

View File

@ -1,30 +1,44 @@
import dcmjs from 'dcmjs';
import moment from 'moment';
import React, { useState, useMemo, useEffect } from 'react';
import { classes } from '@ohif/core';
import React, { useState, useMemo, useCallback } from 'react';
import { classes, Types } from '@ohif/core';
import { InputFilterText } from '@ohif/ui';
import debounce from 'lodash.debounce';
import { Select, SelectTrigger, SelectContent, SelectItem, Slider } from '@ohif/ui-next';
import DicomTagTable from './DicomTagTable';
import './DicomTagBrowser.css';
export type Row = {
uid: string;
tag: string;
valueRepresentation: string;
keyword: string;
value: string;
isVisible: boolean;
depth: number;
parents?: string[];
children?: string[];
areChildrenVisible?: true;
};
let rowCounter = 0;
const generateRowId = () => `row_${++rowCounter}`;
const { ImageSet } = classes;
const { DicomMetaDictionary } = dcmjs.data;
const { nameMap } = DicomMetaDictionary;
const DicomTagBrowser = ({ displaySets, displaySetInstanceUID }) => {
// The column indices that are to be excluded during a filter of the table.
// At present the column indices are:
// 0: DICOM tag
// 1: VR
// 2: Keyword
// 3: Value
const excludedColumnIndicesForFilter: Set<number> = new Set([1]);
const DicomTagBrowser = ({
displaySets,
displaySetInstanceUID,
}: {
displaySets: Types.DisplaySet[];
displaySetInstanceUID: string;
}) => {
const [selectedDisplaySetInstanceUID, setSelectedDisplaySetInstanceUID] =
useState(displaySetInstanceUID);
const [instanceNumber, setInstanceNumber] = useState(1);
const [shouldShowInstanceList, setShouldShowInstanceList] = useState(false);
const [filterValue, setFilterValue] = useState('');
const onSelectChange = value => {
@ -36,9 +50,6 @@ const DicomTagBrowser = ({ displaySets, displaySetInstanceUID }) => {
ds => ds.displaySetInstanceUID === selectedDisplaySetInstanceUID
);
const isImageStack = _isImageStack(activeDisplaySet);
const showInstanceList = isImageStack && activeDisplaySet.images.length > 1;
const displaySetList = useMemo(() => {
displaySets.sort((a, b) => a.SeriesNumber - b.SeriesNumber);
return displaySets.map(displaySet => {
@ -64,48 +75,53 @@ const DicomTagBrowser = ({ displaySets, displaySetInstanceUID }) => {
});
}, [displaySets]);
const getMetadata = useCallback(
isImageStack => {
if (isImageStack) {
return activeDisplaySet.images[instanceNumber - 1];
}
return activeDisplaySet.instance || activeDisplaySet;
},
[activeDisplaySet, instanceNumber]
);
const rows = useMemo(() => {
let metadata;
if (isImageStack) {
metadata = activeDisplaySet.images[instanceNumber - 1];
} else {
metadata = activeDisplaySet.instance || activeDisplaySet;
}
const isImageStack = activeDisplaySet instanceof ImageSet;
const metadata = getMetadata(isImageStack);
setShouldShowInstanceList(isImageStack && activeDisplaySet.images.length > 1);
const tags = getSortedTags(metadata);
return getFormattedRowsFromTags(tags, metadata);
}, [instanceNumber, selectedDisplaySetInstanceUID]);
const rows = getFormattedRowsFromTags({ tags, metadata, depth: 0 });
return rows;
}, [getMetadata, activeDisplaySet]);
const filteredRows = useMemo(() => {
if (!filterValue) {
return rows;
}
const filterValueLowerCase = filterValue.toLowerCase();
return rows.filter(row => {
return row.reduce((keepRow, col, colIndex) => {
if (keepRow) {
// We are already keeping the row, why do more work so return now.
return keepRow;
}
const matchedRowIds = new Set();
if (excludedColumnIndicesForFilter.has(colIndex)) {
return keepRow;
}
const propertiesToCheck = ['tag', 'valueRepresentation', 'keyword', 'value'];
return keepRow || col.toLowerCase().includes(filterValueLowerCase);
}, false);
});
}, [rows, filterValue]);
const setIsMatched = row => {
const isDirectMatch = propertiesToCheck.some(propertyName =>
row[propertyName]?.toLowerCase().includes(filterValueLowerCase)
);
const debouncedSetFilterValue = useMemo(() => {
return debounce(setFilterValue, 200);
}, []);
if (!isDirectMatch) {
return;
}
useEffect(() => {
return () => {
debouncedSetFilterValue?.cancel();
matchedRowIds.add(row.uid);
[...(row.parents ?? []), ...(row.children ?? [])].forEach(uid => matchedRowIds.add(uid));
};
}, []);
const filterValueLowerCase = filterValue.toLowerCase();
rows.forEach(setIsMatched);
return rows.filter(row => matchedRowIds.has(row.uid));
}, [rows, filterValue]);
return (
<div className="dicom-tag-browser-content bg-muted">
@ -136,10 +152,10 @@ const DicomTagBrowser = ({ displaySets, displaySetInstanceUID }) => {
</SelectContent>
</Select>
</div>
{showInstanceList && (
{shouldShowInstanceList && (
<div className="mx-auto flex w-1/5 flex-col">
<span className="text-muted-foreground flex h-6 items-center text-xs">
Instance Number ({instanceNumber} of {activeDisplaySet.images.length})
Instance Number ({instanceNumber} of {activeDisplaySet?.images?.length})
</span>
<Slider
value={[instanceNumber]}
@ -147,7 +163,7 @@ const DicomTagBrowser = ({ displaySets, displaySetInstanceUID }) => {
setInstanceNumber(value);
}}
min={1}
max={activeDisplaySet.images.length}
max={activeDisplaySet?.images?.length}
step={1}
className="pt-4"
/>
@ -169,22 +185,33 @@ const DicomTagBrowser = ({ displaySets, displaySetInstanceUID }) => {
);
};
function getFormattedRowsFromTags(tags, metadata) {
const rows = [];
function getFormattedRowsFromTags({ tags, metadata, depth, parents }) {
const rows: Row[] = [];
tags.forEach(tagInfo => {
const uid = generateRowId();
if (tagInfo.vr === 'SQ') {
rows.push([`${tagInfo.tagIndent}${tagInfo.tag}`, tagInfo.vr, tagInfo.keyword, '']);
const { values } = tagInfo;
values.forEach((item, index) => {
const formatedRowsFromTags = getFormattedRowsFromTags(item, metadata);
rows.push([`${item[0].tagIndent}(FFFE,E000)`, '', `Item #${index}`, '']);
rows.push(...formatedRowsFromTags);
});
const children = tagInfo.values.flatMap(value =>
getFormattedRowsFromTags({
tags: value,
metadata,
depth: depth + 1,
parents: parents ? [...parents, uid] : [uid],
})
);
const row: Row = {
uid,
tag: tagInfo.tag,
valueRepresentation: tagInfo.vr,
keyword: tagInfo.keyword,
value: '',
depth,
isVisible: true,
areChildrenVisible: true,
children: children.map(child => child.uid),
parents,
};
rows.push(row, ...children);
} else {
if (tagInfo.vr === 'xs') {
try {
@ -192,10 +219,20 @@ function getFormattedRowsFromTags(tags, metadata) {
const originalTagInfo = metadata[tag];
tagInfo.vr = originalTagInfo.vr;
} catch (error) {
console.error(`Failed to parse value representation for tag '${tagInfo.keyword}'`);
console.warn(`Failed to parse value representation for tag '${tagInfo.keyword}'`);
}
}
rows.push([`${tagInfo.tagIndent}${tagInfo.tag}`, tagInfo.vr, tagInfo.keyword, tagInfo.value]);
const row: Row = {
uid,
tag: tagInfo.tag,
valueRepresentation: tagInfo.vr,
keyword: tagInfo.keyword,
value: tagInfo.value,
depth,
isVisible: true,
parents,
};
rows.push(row);
}
});
@ -216,16 +253,6 @@ function getRows(metadata, depth = 0) {
const keywords = Object.keys(metadata);
let tagIndent = '';
for (let i = 0; i < depth; i++) {
tagIndent += '>';
}
if (depth > 0) {
tagIndent += ' '; // If indented, add a space after the indents.
}
const rows = [];
for (let i = 0; i < keywords.length; i++) {
let keyword = keywords[i];
@ -245,7 +272,6 @@ function getRows(metadata, depth = 0) {
const sequence = {
tag: tagInfo.tag,
tagIndent,
vr: tagInfo.vr,
keyword,
values: [],
@ -311,7 +337,6 @@ function getRows(metadata, depth = 0) {
if (tagInfo) {
rows.push({
tag: tagInfo.tag,
tagIndent,
vr: tagInfo.vr,
keyword,
value,
@ -323,7 +348,6 @@ function getRows(metadata, depth = 0) {
const tag = `(${keyword.substring(0, 4)},${keyword.substring(4, 8)})`;
rows.push({
tag,
tagIndent,
vr: '',
keyword: 'Private Tag',
value,
@ -335,10 +359,6 @@ function getRows(metadata, depth = 0) {
return rows;
}
function _isImageStack(displaySet) {
return displaySet instanceof ImageSet;
}
function toArray(objectOrArray) {
return Array.isArray(objectOrArray) ? objectOrArray : [objectOrArray];
}

View File

@ -1,7 +1,9 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { VariableSizeList as List } from 'react-window';
import classNames from 'classnames';
import debounce from 'lodash.debounce';
import { Row } from './DicomTagBrowser';
import { Icons } from '@ohif/ui-next';
const lineHeightPx = 20;
const lineHeightClassName = `leading-[${lineHeightPx}px]`;
@ -12,6 +14,52 @@ const rowStyle = {
borderBottomWidth: `${rowBottomBorderPx}px`,
...rowVerticalPaddingStyle,
};
const indentationPadding = 8;
const RowComponent = ({
row,
style,
keyPrefix,
onToggle,
}: {
row: Row;
style: any;
keyPrefix: string;
onToggle?: (areChildrenVisible: boolean) => void;
}) => {
const handleToggle = useCallback(() => {
onToggle(!row.areChildrenVisible);
}, [row.areChildrenVisible, onToggle]);
const hasChildren = row.children && row.children.length > 0;
const isChildOrParent = hasChildren || row.depth > 0;
const padding = indentationPadding * (1 + 2 * row.depth);
return (
<div
style={{ ...style, ...rowStyle }}
className={classNames(
'hover:bg-secondary-main border-secondary-light flex w-full flex-row items-center break-all bg-black text-base transition duration-300',
lineHeightClassName
)}
key={keyPrefix}
>
{isChildOrParent && (
<div style={{ paddingLeft: `${padding}px`, opacity: onToggle ? 1 : 0 }}>
{row.areChildrenVisible ? (
<Icons.ChevronDown onClick={handleToggle} />
) : (
<Icons.ChevronRight onClick={handleToggle} />
)}
</div>
)}
<div className="w-4/24 px-3">{row.tag}</div>
<div className="w-2/24 px-3">{row.valueRepresentation}</div>
<div className="w-6/24 px-3">{row.keyword}</div>
<div className="w-5/24 grow px-3">{row.value}</div>
</div>
);
};
function ColumnHeaders({ tagRef, vrRef, keywordRef, valueRef }) {
return (
@ -56,8 +104,7 @@ function ColumnHeaders({ tagRef, vrRef, keywordRef, valueRef }) {
</div>
);
}
function DicomTagTable({ rows }) {
function DicomTagTable({ rows }: { rows: Row[] }) {
const listRef = useRef();
const canvasRef = useRef();
@ -65,6 +112,7 @@ function DicomTagTable({ rows }) {
const [vrHeaderElem, setVrHeaderElem] = useState(null);
const [keywordHeaderElem, setKeywordHeaderElem] = useState(null);
const [valueHeaderElem, setValueHeaderElem] = useState(null);
const [internalRows, setInternalRows] = useState(rows);
// Here the refs are inturn stored in state to trigger a render of the table.
// This virtualized table does NOT render until the header is rendered because the header column widths are used to determine the row heights in the table.
@ -90,6 +138,14 @@ function DicomTagTable({ rows }) {
}
};
useEffect(() => {
setInternalRows(rows);
}, [rows]);
const visibleRows = useMemo(() => {
return internalRows.filter(row => row.isVisible);
}, [internalRows]);
/**
* When new rows are set, scroll to the top and reset the virtualization.
*/
@ -116,42 +172,8 @@ function DicomTagTable({ rows }) {
};
}, []);
const Row = useCallback(
({ index, style }) => {
const row = rows[index];
return (
<div
style={{ ...style, ...rowStyle }}
className={classNames(
'hover:bg-secondary-main border-secondary-light flex w-full flex-row items-center break-all bg-black text-base transition duration-300',
lineHeightClassName
)}
key={`DICOMTagRow-${index}`}
>
<div className="w-4/24 px-3">{row[0]}</div>
<div className="w-2/24 px-3">{row[1]}</div>
<div className="w-6/24 px-3">{row[2]}</div>
<div className="w-5/24 grow px-3">{row[3]}</div>
</div>
);
},
[rows]
);
/**
* Whenever any one of the column headers is set, then the header is rendered.
* Here we chose the tag header.
*/
const isHeaderRendered = useCallback(() => tagHeaderElem !== null, [tagHeaderElem]);
/**
* Get the item/row size. We use the header column widths to calculate the various row heights.
* @param index the row index
* @returns the row height
*/
const getItemSize = useCallback(
index => {
const getOneRowHeight = useCallback(
row => {
const headerWidths = [
tagHeaderElem.offsetWidth,
vrHeaderElem.offsetWidth,
@ -162,17 +184,79 @@ function DicomTagTable({ rows }) {
const context = canvasRef.current.getContext('2d');
context.font = getComputedStyle(canvasRef.current).font;
return rows[index]
.map((colText, index) => {
const propertiesToCheck = ['tag', 'valueRepresentation', 'keyword', 'value'];
return Object.entries(row)
.filter(([key]) => propertiesToCheck.includes(key))
.map(([, colText], index) => {
const colOneLineWidth = context.measureText(colText).width;
const numLines = Math.ceil(colOneLineWidth / headerWidths[index]);
return numLines * lineHeightPx + 2 * rowVerticalPaddingPx + rowBottomBorderPx;
})
.reduce((maxHeight, colHeight) => Math.max(maxHeight, colHeight));
.reduce((maxHeight, colHeight) => Math.max(maxHeight, colHeight), 0);
},
[rows, keywordHeaderElem, tagHeaderElem, valueHeaderElem, vrHeaderElem]
[keywordHeaderElem, tagHeaderElem, valueHeaderElem, vrHeaderElem]
);
/**
* Get the item/row size. We use the header column widths to calculate the various row heights.
* @param index the row index
* @returns the row height
*/
const getItemSize = useCallback(
rows => index => {
const row = rows[index];
const height = getOneRowHeight(row);
return height;
},
[getOneRowHeight]
);
const onToggle = useCallback(
sourceRow => {
if (!sourceRow.children) {
return undefined;
}
return areChildrenVisible => {
const newInternalRows = internalRows.map(internalRow => {
if (sourceRow.uid === internalRow.uid) {
return { ...internalRow, areChildrenVisible };
}
if (sourceRow.children.includes(internalRow.uid)) {
return { ...internalRow, isVisible: areChildrenVisible, areChildrenVisible };
}
return internalRow;
});
setInternalRows(newInternalRows);
};
},
[internalRows]
);
const getRowComponent = useCallback(
({ rows }: { rows: Row[] }) =>
function RowList({ index, style }) {
const row = useMemo(() => rows[index], [index]);
return (
<RowComponent
style={style}
row={row}
keyPrefix={`DICOMTagRow-${index}`}
onToggle={onToggle(row)}
/>
);
},
[onToggle]
);
/**
* Whenever any one of the column headers is set, then the header is rendered.
* Here we chose the tag header.
*/
const isHeaderRendered = useCallback(() => tagHeaderElem !== null, [tagHeaderElem]);
return (
<div>
<canvas
@ -194,12 +278,12 @@ function DicomTagTable({ rows }) {
<List
ref={listRef}
height={500}
itemCount={rows.length}
itemSize={getItemSize}
itemCount={visibleRows.length}
itemSize={getItemSize(visibleRows)}
width={'100%'}
className="ohif-scrollbar"
>
{Row}
{getRowComponent({ rows: visibleRows })}
</List>
)}
</div>
@ -207,4 +291,4 @@ function DicomTagTable({ rows }) {
);
}
export default DicomTagTable;
export default React.memo(DicomTagTable);

View File

@ -12,6 +12,9 @@ export type DisplaySet = {
Modality?: string;
imageIds?: string[];
images?: unknown[];
SeriesDate?: string;
SeriesTime?: string;
instance?: InstanceMetadata;
};
export type DisplaySetSeriesMetadataInvalidatedEvent = {