From b684d8042660c1ab3cf8f33b37e35063de235c67 Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Fri, 26 May 2023 12:42:25 -0300 Subject: [PATCH] fix(DicomTagBrowser): Fix parsing problems that was limiting nested tags rendering (#3306) * fix(DicomTagBrowser): Fix parsing problems that was limiting nested tags rendering * Added a progress indicator when switching to a list of more than 1000 rows. * Switched to using react-window to virtualize the dicom tag browser window. * Added filtering to the DICOM tag browser with a new search UI widget. Lots of tweaks and updates to the look-and-feel. * Removed unused import. * PR feedback including some UI tweaks. * More PR feedback. --------- Co-authored-by: Igor Octaviano Co-authored-by: Joe Boccanfuso --- extensions/default/package.json | 1 + .../src/DicomTagBrowser/DicomTagBrowser.tsx | 213 ++++++++----- .../src/DicomTagBrowser/DicomTagTable.tsx | 289 +++++++++++++----- platform/ui/package.json | 1 + .../ui/src/assets/icons/icon-clear-field.svg | 8 + platform/ui/src/assets/icons/icon-search.svg | 9 + platform/ui/src/components/Icon/getIcon.js | 4 + .../src/components/InputRange/InputRange.css | 8 +- .../src/components/InputRange/InputRange.tsx | 24 +- platform/ui/tailwind.config.js | 2 +- platform/viewer/tailwind.config.js | 2 +- yarn.lock | 12 +- 12 files changed, 407 insertions(+), 166 deletions(-) create mode 100644 platform/ui/src/assets/icons/icon-clear-field.svg create mode 100644 platform/ui/src/assets/icons/icon-search.svg diff --git a/extensions/default/package.json b/extensions/default/package.json index c2e884c98..42f80e23c 100644 --- a/extensions/default/package.json +++ b/extensions/default/package.json @@ -38,6 +38,7 @@ "react": "^17.0.2", "react-dom": "^17.0.2", "react-i18next": "^12.2.2", + "react-window": "^1.8.9", "webpack": "^5.50.0", "webpack-merge": "^5.7.3" }, diff --git a/extensions/default/src/DicomTagBrowser/DicomTagBrowser.tsx b/extensions/default/src/DicomTagBrowser/DicomTagBrowser.tsx index 281e545fb..57cf04fa8 100644 --- a/extensions/default/src/DicomTagBrowser/DicomTagBrowser.tsx +++ b/extensions/default/src/DicomTagBrowser/DicomTagBrowser.tsx @@ -1,27 +1,41 @@ import dcmjs from 'dcmjs'; import moment from 'moment'; -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useEffect, useRef } from 'react'; import { classes } from '@ohif/core'; +import { Icon, InputRange, Select, Typography } from '@ohif/ui'; +import debounce from 'lodash.debounce'; +import classNames from 'classnames'; + import DicomTagTable from './DicomTagTable'; import './DicomTagBrowser.css'; -import { InputRange, Select, Typography } from '@ohif/ui'; 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 [ selectedDisplaySetInstanceUID, setSelectedDisplaySetInstanceUID, ] = useState(displaySetInstanceUID); const [instanceNumber, setInstanceNumber] = useState(1); + const [filterValue, setFilterValue] = useState(''); const onSelectChange = value => { setSelectedDisplaySetInstanceUID(value.value); setInstanceNumber(1); }; + const searchInputRef = useRef(null); + const activeDisplaySet = displaySets.find( ds => ds.displaySetInstanceUID === selectedDisplaySetInstanceUID ); @@ -54,64 +68,130 @@ const DicomTagBrowser = ({ displaySets, displaySetInstanceUID }) => { }); }, [displaySets]); + const rows = useMemo(() => { + let metadata; + if (isImageStack) { + metadata = activeDisplaySet.images[instanceNumber - 1]; + } else { + metadata = activeDisplaySet.instance || activeDisplaySet; + } + const tags = getSortedTags(metadata); + return getFormattedRowsFromTags(tags, metadata); + }, [instanceNumber, selectedDisplaySetInstanceUID]); + + 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; + } + + if (excludedColumnIndicesForFilter.has(colIndex)) { + return keepRow; + } + + return keepRow || col.toLowerCase().includes(filterValueLowerCase); + }, false); + }); + }, [rows, filterValue]); + + const debouncedSetFilterValue = useMemo(() => { + return debounce(setFilterValue, 200); + }, []); + + useEffect(() => { + return () => { + debouncedSetFilterValue?.cancel(); + }; + }, []); + return (
-
- - Series - - {showInstanceList && ( - - Instance Number +
+
+ + Series - )} -
-
-
- ds.value === selectedDisplaySetInstanceUID + )} + className="text-white" />
- ) : null} +
+
+ {showInstanceList && ( + + Instance Number + + )} + {showInstanceList && ( +
+ { + setInstanceNumber(parseInt(value)); + }} + minValue={1} + maxValue={activeDisplaySet.images.length} + step={1} + inputClassName="w-full" + labelPosition="left" + trackColor={'#3a3f99'} + /> +
+ )} +
- +
+
+ {/* TODO - refactor the following into its own reusable component */} + +
+
); }; -function getFormattedRowsFromTags(displaySet, instanceNumber) { - const isImageStack = _isImageStack(displaySet); - - let metadata; - - if (isImageStack) { - metadata = displaySet.images[instanceNumber - 1]; - } else { - metadata = displaySet; - } - - const tags = getSortedTags(metadata); +function getFormattedRowsFromTags(tags, metadata) { const rows = []; tags.forEach(tagInfo => { @@ -126,7 +206,7 @@ function getFormattedRowsFromTags(displaySet, instanceNumber) { const { values } = tagInfo; values.forEach((item, index) => { - const formatedRowsFromTags = getFormattedRowsFromTags(item); + const formatedRowsFromTags = getFormattedRowsFromTags(item, metadata); rows.push([ `${item[0].tagIndent}(FFFE,E000)`, @@ -140,34 +220,21 @@ function getFormattedRowsFromTags(displaySet, instanceNumber) { } else { if (tagInfo.vr === 'xs') { try { - /* const dataset = metadataProvider.getStudyDataset( - meta.StudyInstanceUID - );*/ - // console.log(dataset); - // const tag = dcmjs.data.Tag.fromPString(tagInfo.tag).toCleanString(); - // const originalTagInfo = dataset[tag]; - // tagInfo.vr = originalTagInfo.vr; + const tag = dcmjs.data.Tag.fromPString(tagInfo.tag).toCleanString(); + const originalTagInfo = metadata[tag]; + tagInfo.vr = originalTagInfo.vr; } catch (error) { console.error( `Failed to parse value representation for tag '${tagInfo.keyword}'` ); } } - if (tagInfo.vr === 'PN') { - rows.push([ - `${tagInfo.tagIndent}${tagInfo.tag}`, - tagInfo.vr, - tagInfo.keyword, - tagInfo.value, - ]); - } else { - rows.push([ - `${tagInfo.tagIndent}${tagInfo.tag}`, - tagInfo.vr, - tagInfo.keyword, - tagInfo.value, - ]); - } + rows.push([ + `${tagInfo.tagIndent}${tagInfo.tag}`, + tagInfo.vr, + tagInfo.keyword, + tagInfo.value, + ]); } }); diff --git a/extensions/default/src/DicomTagBrowser/DicomTagTable.tsx b/extensions/default/src/DicomTagBrowser/DicomTagTable.tsx index 5a67bbae9..982af013b 100644 --- a/extensions/default/src/DicomTagBrowser/DicomTagTable.tsx +++ b/extensions/default/src/DicomTagBrowser/DicomTagTable.tsx @@ -1,97 +1,224 @@ -import React from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { VariableSizeList as List } from 'react-window'; +import classNames from 'classnames'; +import debounce from 'lodash.debounce'; -function ColumnHeaders() { +const lineHeightPx = 20; +const lineHeightClassName = `leading-[${lineHeightPx}px]`; +const rowVerticalPaddingPx = 10; +const rowBottomBorderPx = 1; +const rowVerticalPaddingStyle = { padding: `${rowVerticalPaddingPx}px 0` }; +const rowStyle = { + borderBottomWidth: `${rowBottomBorderPx}px`, + ...rowVerticalPaddingStyle, +}; + +function ColumnHeaders({ tagRef, vrRef, keywordRef, valueRef }) { return ( -
-
-
- -
-
- -
-
- -
-
- -
+
+
+ +
+
+ +
+
+ +
+
+
); } function DicomTagTable({ rows }) { + const listRef = useRef(); + const canvasRef = useRef(); + + const [tagHeaderElem, setTagHeaderElem] = useState(null); + const [vrHeaderElem, setVrHeaderElem] = useState(null); + const [keywordHeaderElem, setKeywordHeaderElem] = useState(null); + const [valueHeaderElem, setValueHeaderElem] = useState(null); + + // 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. + // Therefore whenever the refs change (in particular the first time the refs are set), we want to trigger a render of the table. + const tagRef = elem => { + if (elem) { + setTagHeaderElem(elem); + } + }; + const vrRef = elem => { + if (elem) { + setVrHeaderElem(elem); + } + }; + const keywordRef = elem => { + if (elem) { + setKeywordHeaderElem(elem); + } + }; + const valueRef = elem => { + if (elem) { + setValueHeaderElem(elem); + } + }; + + /** + * When new rows are set, scroll to the top and reset the virtualization. + */ + useEffect(() => { + if (!listRef?.current) { + return; + } + + listRef.current.scrollTo(0); + listRef.current.resetAfterIndex(0); + }, [rows]); + + /** + * When the browser window resizes, update the row virtualization (i.e. row heights) + */ + useEffect(() => { + const debouncedResize = debounce( + () => listRef.current.resetAfterIndex(0), + 100 + ); + + window.addEventListener('resize', debouncedResize); + + return () => { + debouncedResize.cancel(); + window.removeEventListener('resize', debouncedResize); + }; + }, []); + + 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 headerWidths = [ + tagHeaderElem.offsetWidth, + vrHeaderElem.offsetWidth, + keywordHeaderElem.offsetWidth, + valueHeaderElem.offsetWidth, + ]; + + const context = canvasRef.current.getContext('2d'); + context.font = getComputedStyle(canvasRef.current).font; + + return rows[index] + .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)); + }, + [rows, keywordHeaderElem, tagHeaderElem, valueHeaderElem, vrHeaderElem] + ); + return (
- {ColumnHeaders()} + +
- - - {rows.map((row, index) => { - const className = row.className ? row.className : null; - - return ( - - - - - - - ); - })} - -
-
-
{row[0]}
-
-
-
-
{row[1]}
-
-
-
-
{row[2]}
-
-
-
-
{row[3]}
-
-
+ {isHeaderRendered() && ( + + {Row} + + )}
); diff --git a/platform/ui/package.json b/platform/ui/package.json index 2cd2c17ad..d913dd104 100644 --- a/platform/ui/package.json +++ b/platform/ui/package.json @@ -47,6 +47,7 @@ "react-outside-click-handler": "^1.3.0", "react-select": "3.0.8", "react-with-direction": "^1.3.1", + "react-window": "^1.8.9", "swiper": "^8.4.2", "webpack": "^5.81.0" }, diff --git a/platform/ui/src/assets/icons/icon-clear-field.svg b/platform/ui/src/assets/icons/icon-clear-field.svg new file mode 100644 index 000000000..faf019500 --- /dev/null +++ b/platform/ui/src/assets/icons/icon-clear-field.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/platform/ui/src/assets/icons/icon-search.svg b/platform/ui/src/assets/icons/icon-search.svg new file mode 100644 index 000000000..2a960ddd3 --- /dev/null +++ b/platform/ui/src/assets/icons/icon-search.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/platform/ui/src/components/Icon/getIcon.js b/platform/ui/src/components/Icon/getIcon.js index 103bcef9e..34a64595f 100644 --- a/platform/ui/src/components/Icon/getIcon.js +++ b/platform/ui/src/components/Icon/getIcon.js @@ -53,12 +53,14 @@ import checkboxUnchecked from './../../assets/icons/checkbox-unchecked.svg'; import iconAlertOutline from './../../assets/icons/icons-alert-outline.svg'; import iconAlertSmall from './../../assets/icons/icon-alert-small.svg'; import iconClose from './../../assets/icons/icon-close.svg'; +import iconClearField from './../../assets/icons/icon-clear-field.svg'; import iconNextInactive from './../../assets/icons/icon-next-inactive.svg'; import iconNext from './../../assets/icons/icon-next.svg'; import iconPlay from './../../assets/icons/icon-play.svg'; import iconPause from './../../assets/icons/icon-pause.svg'; import iconPrevInactive from './../../assets/icons/icon-prev-inactive.svg'; import iconPrev from './../../assets/icons/icon-prev.svg'; +import iconSearch from './../../assets/icons/icon-search.svg'; import iconStatusAlert from './../../assets/icons/icon-status-alert.svg'; import iconTransferring from './../../assets/icons/icon-transferring.svg'; import iconUpload from './../../assets/icons/icon-upload.svg'; @@ -151,9 +153,11 @@ const ICONS = { info: info, 'icon-alert-outline': iconAlertOutline, 'icon-alert-small': iconAlertSmall, + 'icon-clear-field': iconClearField, 'icon-close': iconClose, 'icon-play': iconPlay, 'icon-pause': iconPause, + 'icon-search': iconSearch, 'icon-status-alert': iconStatusAlert, 'icon-transferring': iconTransferring, 'info-action': infoAction, diff --git a/platform/ui/src/components/InputRange/InputRange.css b/platform/ui/src/components/InputRange/InputRange.css index 41f23ca56..f9b00259b 100644 --- a/platform/ui/src/components/InputRange/InputRange.css +++ b/platform/ui/src/components/InputRange/InputRange.css @@ -5,8 +5,8 @@ input[type='range'] { input[type='range']::-webkit-slider-thumb { -webkit-appearance: none; border: none; - height: 10px; - width: 10px; + height: 13px; + width: 13px; border-radius: 50%; background: #5acce6; } @@ -14,8 +14,8 @@ input[type='range']::-webkit-slider-thumb { input[type='range']::-moz-range-thumb { -webkit-appearance: none; border: none; - height: 10px; - width: 10px; + height: 13px; + width: 13px; border-radius: 50%; background: #5acce6; } diff --git a/platform/ui/src/components/InputRange/InputRange.tsx b/platform/ui/src/components/InputRange/InputRange.tsx index 9bcf40b14..a8002af96 100644 --- a/platform/ui/src/components/InputRange/InputRange.tsx +++ b/platform/ui/src/components/InputRange/InputRange.tsx @@ -23,7 +23,9 @@ const InputRange: React.FC<{ inputClassName?: string; labelClassName?: string; labelVariant?: string; - showLabel: boolean; + showLabel?: boolean; + labelPosition?: string; + trackColor?: string; }> = ({ value, onChange, @@ -36,6 +38,8 @@ const InputRange: React.FC<{ labelClassName, labelVariant, showLabel = true, + labelPosition = '', + trackColor, }) => { const [rangeValue, setRangeValue] = useState(value); @@ -63,6 +67,16 @@ const InputRange: React.FC<{ containerClassName ? containerClassName : '' }`} > + {showLabel && labelPosition === 'left' && ( + + {rangeValueForStr} + {unit} + + )} - {showLabel && ( + {showLabel && (!labelPosition || labelPosition === 'right') && ( =3.1.1 <6", memoize-one@^5.0.0: version "5.2.1" resolved "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== @@ -17518,6 +17518,14 @@ react-waypoint@^10.3.0: prop-types "^15.0.0" react-is "^17.0.1 || ^18.0.0" +react-window@^1.8.9: + version "1.8.9" + resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.9.tgz#24bc346be73d0468cdf91998aac94e32bc7fa6a8" + integrity sha512-+Eqx/fj1Aa5WnhRfj9dJg4VYATGwIUP2ItwItiJ6zboKWA6EX3lYDAXfGF2hyNqplEprhbtjbipiADEcwQ823Q== + dependencies: + "@babel/runtime" "^7.0.0" + memoize-one ">=3.1.1 <6" + react-with-direction@^1.3.1: version "1.4.0" resolved "https://registry.npmjs.org/react-with-direction/-/react-with-direction-1.4.0.tgz#ebdf64d685d0650ce966e872e6431ad5a2485444"