diff --git a/extensions/default/src/DicomTagBrowser/DicomTagBrowser.tsx b/extensions/default/src/DicomTagBrowser/DicomTagBrowser.tsx index ad292a268..8ea351d00 100644 --- a/extensions/default/src/DicomTagBrowser/DicomTagBrowser.tsx +++ b/extensions/default/src/DicomTagBrowser/DicomTagBrowser.tsx @@ -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 = 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 (
@@ -136,10 +152,10 @@ const DicomTagBrowser = ({ displaySets, displaySetInstanceUID }) => {
- {showInstanceList && ( + {shouldShowInstanceList && (
- Instance Number ({instanceNumber} of {activeDisplaySet.images.length}) + Instance Number ({instanceNumber} of {activeDisplaySet?.images?.length}) { 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]; } diff --git a/extensions/default/src/DicomTagBrowser/DicomTagTable.tsx b/extensions/default/src/DicomTagBrowser/DicomTagTable.tsx index a661da9f7..4677fa02f 100644 --- a/extensions/default/src/DicomTagBrowser/DicomTagTable.tsx +++ b/extensions/default/src/DicomTagBrowser/DicomTagTable.tsx @@ -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 ( +
+ {isChildOrParent && ( +
+ {row.areChildrenVisible ? ( + + ) : ( + + )} +
+ )} +
{row.tag}
+
{row.valueRepresentation}
+
{row.keyword}
+
{row.value}
+
+ ); +}; function ColumnHeaders({ tagRef, vrRef, keywordRef, valueRef }) { return ( @@ -56,8 +104,7 @@ function ColumnHeaders({ tagRef, vrRef, keywordRef, valueRef }) {
); } - -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 ( -
-
{row[0]}
-
{row[1]}
-
{row[2]}
-
{row[3]}
-
- ); - }, - [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 ( + + ); + }, + [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 (
- {Row} + {getRowComponent({ rows: visibleRows })} )}
@@ -207,4 +291,4 @@ function DicomTagTable({ rows }) { ); } -export default DicomTagTable; +export default React.memo(DicomTagTable); diff --git a/platform/core/src/types/DisplaySet.ts b/platform/core/src/types/DisplaySet.ts index e11cc0bef..f7968af4f 100644 --- a/platform/core/src/types/DisplaySet.ts +++ b/platform/core/src/types/DisplaySet.ts @@ -12,6 +12,9 @@ export type DisplaySet = { Modality?: string; imageIds?: string[]; images?: unknown[]; + SeriesDate?: string; + SeriesTime?: string; + instance?: InstanceMetadata; }; export type DisplaySetSeriesMetadataInvalidatedEvent = {