feat: improve dicom tag browser with nested rows (#4451)
This commit is contained in:
parent
8b969eb1e9
commit
0b5836ca1a
@ -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];
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -12,6 +12,9 @@ export type DisplaySet = {
|
||||
Modality?: string;
|
||||
imageIds?: string[];
|
||||
images?: unknown[];
|
||||
SeriesDate?: string;
|
||||
SeriesTime?: string;
|
||||
instance?: InstanceMetadata;
|
||||
};
|
||||
|
||||
export type DisplaySetSeriesMetadataInvalidatedEvent = {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user