diff --git a/.gitignore b/.gitignore index e33946ef..56eafb3d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ # testing /coverage +.env # production /build diff --git a/src/App.tsx b/src/App.tsx index efc5de9e..ac3c2981 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -452,6 +452,7 @@ class App extends React.Component { showWorklistButton={false} onServerSelection={this.handleServerSelection} showServerSelectionButton={false} + clients={this.state.clients} /> diff --git a/src/components/AnnotationGroupItem.tsx b/src/components/AnnotationGroupItem.tsx index b795a305..1c4b2d72 100644 --- a/src/components/AnnotationGroupItem.tsx +++ b/src/components/AnnotationGroupItem.tsx @@ -360,7 +360,7 @@ class AnnotationGroupItem extends React.Component - {} + <> ) diff --git a/src/components/CaseViewer.tsx b/src/components/CaseViewer.tsx index 72a195e1..db27bd3e 100644 --- a/src/components/CaseViewer.tsx +++ b/src/components/CaseViewer.tsx @@ -1,9 +1,6 @@ -import React from 'react' import { Routes, Route, useLocation, useParams } from 'react-router-dom' import { Layout, Menu } from 'antd' -import * as dmv from 'dicom-microscopy-viewer' - import { AnnotationSettings } from '../AppConfig' import ClinicalTrial from './ClinicalTrial' import DicomWebManager from '../DicomWebManager' @@ -13,13 +10,9 @@ import SlideList from './SlideList' import SlideViewer from './SlideViewer' import { User } from '../auth' -import { Slide, createSlides } from '../data/slides' -import { StorageClasses } from '../data/uids' +import { Slide } from '../data/slides' import { RouteComponentProps, withRouter } from '../utils/router' -import { CustomError, errorTypes } from '../utils/CustomError' -import NotificationMiddleware, { - NotificationMiddlewareContext -} from '../services/NotificationMiddleware' +import { useSlides } from '../hooks/useSlides' function ParametrizedSlideViewer ({ clients, @@ -97,109 +90,20 @@ interface ViewerProps extends RouteComponentProps { } } -interface ViewerState { - slides: Slide[] - isLoading: boolean -} - -class Viewer extends React.Component { - state = { - slides: [], - isLoading: true - } - - constructor (props: ViewerProps) { - super(props) - this.handleSeriesSelection = this.handleSeriesSelection.bind(this) - } +function Viewer (props: ViewerProps): JSX.Element | null { + const { clients, studyInstanceUID, location, navigate } = props + const { slides, isLoading } = useSlides({ clients, studyInstanceUID }) - componentDidMount (): void { - this.fetchImageMetadata().then( - (metadata: dmv.metadata.VLWholeSlideMicroscopyImage[][]) => { - this.setState({ - slides: createSlides(metadata), - isLoading: false - }) - } - ).catch((error) => { - console.error(error) - // eslint-disable-next-line @typescript-eslint/no-floating-promises - NotificationMiddleware.onError( - NotificationMiddlewareContext.SLIM, - new CustomError( - errorTypes.ENCODINGANDDECODING, - 'Image metadata could not be retrieved or decoded.') - ) - this.setState({ isLoading: false }) - }) - } - - /** - * Fetch metadata for VL Whole Slide Microscopy Image instances of the study. - * - * @returns Metadata of image instances of the study grouped per series - */ - async fetchImageMetadata (): Promise { - const images: dmv.metadata.VLWholeSlideMicroscopyImage[][] = [] - const studyInstanceUID = this.props.studyInstanceUID - console.info(`search for series of study "${studyInstanceUID}"...`) - const client = this.props.clients[ - StorageClasses.VL_WHOLE_SLIDE_MICROSCOPY_IMAGE - ] - const matchedSeries = await client.searchForSeries({ - queryParams: { - Modality: 'SM', - StudyInstanceUID: studyInstanceUID - } - }) - - await Promise.all(matchedSeries.map(async (s) => { - const { dataset } = dmv.metadata.formatMetadata(s) - const loadingSeries = dataset as dmv.metadata.Series - console.info( - `retrieve metadata of series "${loadingSeries.SeriesInstanceUID}"` - ) - const retrievedMetadata = await client.retrieveSeriesMetadata({ - studyInstanceUID: this.props.studyInstanceUID, - seriesInstanceUID: loadingSeries.SeriesInstanceUID - }) - - const seriesImages: dmv.metadata.VLWholeSlideMicroscopyImage[] = [] - retrievedMetadata.forEach((item, index) => { - if (item['00080016'] != null) { - const values = item['00080016'].Value - if (values != null) { - const sopClassUID = values[0] - if (sopClassUID === StorageClasses.VL_WHOLE_SLIDE_MICROSCOPY_IMAGE) { - const image = new dmv.metadata.VLWholeSlideMicroscopyImage({ - metadata: item - }) - seriesImages.push(image) - } - } - } - }) - - if (seriesImages.length > 0) { - images.push(seriesImages) - } - })) - - return images - } - - handleSeriesSelection ( - { seriesInstanceUID }: { seriesInstanceUID: string } - ): void { + const handleSeriesSelection = ({ seriesInstanceUID }: { seriesInstanceUID: string }): void => { console.info(`switch to series "${seriesInstanceUID}"`) let urlPath = ( - `/studies/${this.props.studyInstanceUID}` + + `/studies/${studyInstanceUID}` + `/series/${seriesInstanceUID}` ) - if (this.props.location.pathname.includes('/projects/')) { - urlPath = this.props.location.pathname - if (!this.props.location.pathname.includes('/series/')) { + if (location.pathname.includes('/projects/')) { + urlPath = location.pathname + if (!location.pathname.includes('/series/')) { urlPath += `/series/${seriesInstanceUID}` } else { urlPath = urlPath.replace(/\/series\/[^/]+/, `/series/${seriesInstanceUID}`) @@ -207,106 +111,105 @@ class Viewer extends React.Component { } if ( - this.props.location.pathname.includes('/series/') && - this.props.location.search != null + location.pathname.includes('/series/') && + location.search != null ) { - urlPath += this.props.location.search + urlPath += location.search } - this.props.navigate(urlPath, { replace: true }) + navigate(urlPath, { replace: true }) } - render (): React.ReactNode { - if (this.state.isLoading) { - return null - } - - if (this.state.slides.length === 0) { - return null - } - const firstSlide = this.state.slides[0] as Slide - const volumeInstances = firstSlide.volumeImages - if (volumeInstances.length === 0) { - return null - } - const refImage = volumeInstances[0] + if (isLoading) { + return null + } - /* If a series is encoded in the path, route the viewer to this series. - * Otherwise select the first series correspondent to - * the first slide contained in the study. - */ - let selectedSeriesInstanceUID: string - if (this.props.location.pathname.includes('series/')) { - const seriesFragment = this.props.location.pathname.split('series/')[1] - selectedSeriesInstanceUID = seriesFragment.includes('/') ? seriesFragment.split('/')[0] : seriesFragment - } else { - selectedSeriesInstanceUID = volumeInstances[0].SeriesInstanceUID - } + if (slides.length === 0) { + return null + } - let clinicalTrialMenu - if (refImage.ClinicalTrialSponsorName != null) { - clinicalTrialMenu = ( - - - - ) - } + const firstSlide = slides[0] + const volumeInstances = firstSlide.volumeImages + if (volumeInstances.length === 0) { + return null + } + const refImage = volumeInstances[0] - return ( - - - - - - - - - - {clinicalTrialMenu} - - - - - + /* If a series is encoded in the path, route the viewer to this series. + * Otherwise select the first series correspondent to + * the first slide contained in the study. + */ + let selectedSeriesInstanceUID: string + if (location.pathname.includes('series/')) { + const seriesFragment = location.pathname.split('series/')[1] + selectedSeriesInstanceUID = seriesFragment.includes('/') ? seriesFragment.split('/')[0] : seriesFragment + } else { + selectedSeriesInstanceUID = volumeInstances[0].SeriesInstanceUID + } - - - } - /> - - + let clinicalTrialMenu + if (refImage.ClinicalTrialSponsorName != null) { + clinicalTrialMenu = ( + + + ) } + + return ( + + + + + + + + + + {clinicalTrialMenu} + + + + + + + + + } + /> + + + ) } export default withRouter(Viewer) diff --git a/src/components/DicomTagBrowser/DicomTagBrowser.css b/src/components/DicomTagBrowser/DicomTagBrowser.css new file mode 100644 index 00000000..f6e4676c --- /dev/null +++ b/src/components/DicomTagBrowser/DicomTagBrowser.css @@ -0,0 +1,8 @@ +.dicom-tag-browser .ant-table-wrapper { + border: 1px solid #f0f0f0; + border-radius: 2px; +} + +.dicom-tag-browser .ant-table-cell { + word-break: break-word; +} diff --git a/src/components/DicomTagBrowser/DicomTagBrowser.tsx b/src/components/DicomTagBrowser/DicomTagBrowser.tsx new file mode 100644 index 00000000..a9f73890 --- /dev/null +++ b/src/components/DicomTagBrowser/DicomTagBrowser.tsx @@ -0,0 +1,309 @@ +import { useState, useMemo, useEffect } from 'react' +import { Select, Input, Slider, Typography, Table } from 'antd' +import { SearchOutlined } from '@ant-design/icons' + +import DicomWebManager from '../../DicomWebManager' +import './DicomTagBrowser.css' +import { useSlides } from '../../hooks/useSlides' +import { getSortedTags } from './dicomTagUtils' +import { formatDicomDate } from '../../utils/formatDicomDate' + +const { Option } = Select + +interface DisplaySet { + displaySetInstanceUID: number + SeriesDate: string + SeriesTime: string + SeriesNumber: number + SeriesDescription: string + Modality: string + images: any[] +} + +interface TableDataItem { + key: string + tag: string + vr: string + keyword: string + value: string + children?: TableDataItem[] +} + +interface DicomTagBrowserProps { + clients: { [key: string]: DicomWebManager } + studyInstanceUID: string +} + +const DicomTagBrowser = ({ clients, studyInstanceUID }: DicomTagBrowserProps): JSX.Element => { + const { slides, isLoading } = useSlides({ clients, studyInstanceUID }) + + const [displaySets, setDisplaySets] = useState([]) + const [selectedDisplaySetInstanceUID, setSelectedDisplaySetInstanceUID] = useState(0) + const [instanceNumber, setInstanceNumber] = useState(1) + const [filterValue, setFilterValue] = useState('') + const [expandedKeys, setExpandedKeys] = useState([]) + const [searchExpandedKeys, setSearchExpandedKeys] = useState([]) + + useEffect(() => { + if (slides.length === 0) return + + const updatedDisplaySets = slides + .map((slide, index) => { + const { volumeImages } = slide + if (volumeImages?.[0] === undefined) return null + + const { + SeriesDate, + SeriesTime, + SeriesNumber, + SeriesDescription, + Modality + } = volumeImages[0] + + return { + displaySetInstanceUID: index, + SeriesDate, + SeriesTime, + SeriesNumber, + SeriesDescription, + Modality, + images: volumeImages + } + }) + .filter((set): set is DisplaySet => set !== null) + + setDisplaySets(updatedDisplaySets) + }, [slides]) + + const displaySetList = useMemo(() => { + displaySets.sort((a, b) => a.SeriesNumber - b.SeriesNumber) + return displaySets.map((displaySet) => { + const { + displaySetInstanceUID, + SeriesDate, + SeriesTime, + SeriesNumber, + SeriesDescription, + Modality + } = displaySet + + const dateStr = `${SeriesDate}:${SeriesTime}`.split('.')[0] + const displayDate = formatDicomDate(dateStr) + + return { + value: displaySetInstanceUID, + label: `${SeriesNumber} (${Modality}): ${SeriesDescription}`, + description: displayDate + } + }) + }, [displaySets]) + + const showInstanceList = + displaySets[selectedDisplaySetInstanceUID]?.images.length > 1 + + const instanceSliderMarks = useMemo(() => { + if (displaySets[selectedDisplaySetInstanceUID] === undefined) return {} + const totalInstances = displaySets[selectedDisplaySetInstanceUID].images.length + + // Create marks for first, middle, and last instances + const marks: Record = { + 1: '1', // First + [Math.ceil(totalInstances / 2)]: String(Math.ceil(totalInstances / 2)), // Middle + [totalInstances]: String(totalInstances) // Last + } + + return marks + }, [selectedDisplaySetInstanceUID, displaySets]) + + const columns = [ + { + title: 'Tag', + dataIndex: 'tag', + key: 'tag', + width: '30%' + }, + { + title: 'VR', + dataIndex: 'vr', + key: 'vr', + width: '5%' + }, + { + title: 'Keyword', + dataIndex: 'keyword', + key: 'keyword', + width: '30%' + }, + { + title: 'Value', + dataIndex: 'value', + key: 'value', + width: '40%' + } + ] + + const tableData = useMemo(() => { + const transformTagsToTableData = (tags: any[], parentKey = ''): TableDataItem[] => { + return tags.map((tag, index) => { + // Create a unique key that includes the parent path + const currentKey = parentKey !== undefined ? `${parentKey}-${index}` : `${index}` + + const item: TableDataItem = { + key: currentKey, + tag: tag.tag, + vr: tag.vr, + keyword: tag.keyword, + value: tag.value + } + + if (tag.children !== undefined && tag.children.length > 0) { + // Pass the current key as parent for nested items + item.children = transformTagsToTableData(tag.children, currentKey) + } + + return item + }) + } + + if (displaySets[selectedDisplaySetInstanceUID] === undefined) return [] + const metadata = displaySets[selectedDisplaySetInstanceUID]?.images[instanceNumber - 1] + const tags = getSortedTags(metadata) + return transformTagsToTableData(tags) + }, [instanceNumber, selectedDisplaySetInstanceUID, displaySets]) + + const filteredData = useMemo(() => { + if (filterValue === undefined || filterValue === '') return tableData + + const searchLower = filterValue.toLowerCase() + const newSearchExpandedKeys: string[] = [] + + const filterNodes = (nodes: TableDataItem[], parentKey = ''): TableDataItem[] => { + return nodes.map(node => { + const newNode = { ...node } + + const matchesSearch = + (node.tag?.toLowerCase() ?? '').includes(searchLower) || + (node.vr?.toLowerCase() ?? '').includes(searchLower) || + (node.keyword?.toLowerCase() ?? '').includes(searchLower) || + (node.value?.toString().toLowerCase() ?? '').includes(searchLower) + + if (node.children != null) { + const filteredChildren = filterNodes(node.children, node.key) + newNode.children = filteredChildren + + if (matchesSearch || filteredChildren.length > 0) { + // Add all parent keys to maintain the expansion chain + if (parentKey !== undefined) { + newSearchExpandedKeys.push(parentKey) + } + newSearchExpandedKeys.push(node.key) + return newNode + } + } + + return matchesSearch ? newNode : null + }).filter((node): node is TableDataItem => node !== null) + } + + const filtered = filterNodes(tableData) + setSearchExpandedKeys(newSearchExpandedKeys) + return filtered + }, [tableData, filterValue]) + + // Reset search expanded keys when search is cleared + useEffect(() => { + if (filterValue === undefined || filterValue === '') { + setSearchExpandedKeys([]) + } + }, [filterValue]) + + // Combine manual expansion with search expansion + const allExpandedKeys = useMemo(() => { + return [...new Set([...expandedKeys, ...searchExpandedKeys])] + }, [expandedKeys, searchExpandedKeys]) + + if (isLoading) { + return
Loading...
+ } + + return ( +
+
+
+
+ Slides + +
+ + {showInstanceList && ( +
+ + Instance Number: {instanceNumber} + + setInstanceNumber(value)} + marks={instanceSliderMarks} + tooltip={{ + formatter: (value: number | undefined) => value !== undefined ? `Instance ${value}` : '' + }} + /> +
+ )} +
+ + } + onChange={(e) => setFilterValue(e.target.value)} + value={filterValue} + /> + + setExpandedKeys(keys as string[]) + }} + size='small' + scroll={{ y: 500 }} + /> + + + ) +} + +export default DicomTagBrowser diff --git a/src/components/DicomTagBrowser/dicomTagUtils.ts b/src/components/DicomTagBrowser/dicomTagUtils.ts new file mode 100644 index 00000000..8b9290fd --- /dev/null +++ b/src/components/DicomTagBrowser/dicomTagUtils.ts @@ -0,0 +1,122 @@ +import dcmjs from 'dcmjs' + +const { DicomMetaDictionary } = dcmjs.data +// @ts-expect-error +const { nameMap } = DicomMetaDictionary + +interface TagInfo { + tag: string + vr: string + keyword: string + value: string + children?: TagInfo[] + level: number +} + +export interface DicomTag { + name: string + vr: string + Value?: any[] + [key: string]: any +} + +const formatValue = (val: any): string => { + if (typeof val === 'object' && val !== null) { + return JSON.stringify(val) + } + return String(val) +} + +export const formatTagValue = (tag: DicomTag): string => { + if (tag.Value == null) return '' + + if (Array.isArray(tag.Value)) { + return tag.Value.map(formatValue).join(', ') + } + + return formatValue(tag.Value) +} + +/** + * Processes DICOM metadata and returns a flattened array of tag information + * @param metadata - The DICOM metadata object to process + * @param depth - The current depth level for nested sequences (default: 0) + * @returns Array of processed tag information + */ +export function getRows (metadata: Record, depth = 0): TagInfo[] { + const keywords = Object.keys(metadata).filter(key => key !== '_vrMap') + + return keywords.flatMap(keyword => { + const tagInfo = nameMap[keyword] as TagInfo | undefined + let value = metadata[keyword] + + // Handle private or unknown tags + if (tagInfo === undefined) { + const regex = /[0-9A-Fa-f]{6}/g + if (keyword.match(regex) == null) return [] + + return [{ + tag: `(${keyword.substring(0, 4)},${keyword.substring(4, 8)})`, + vr: '', + keyword: 'Private Tag', + value: value?.toString() ?? '', + level: depth + }] + } + + // Handle sequence values (SQ VR) + if (tagInfo.vr === 'SQ' && value !== undefined) { + const sequenceItems = Array.isArray(value) ? value : [value] + + // Create a parent sequence node + const sequenceNode: TagInfo = { + tag: tagInfo.tag, + vr: tagInfo.vr, + keyword, + value: `Sequence with ${sequenceItems.length} item(s)`, + level: depth, + children: [] + } + + // Create individual nodes for each sequence item + sequenceNode.children = sequenceItems.map((item, index) => { + const itemNode: TagInfo = { + tag: `${tagInfo.tag}.${index + 1}`, + vr: 'Item', + keyword: `Item ${index + 1}`, + value: `Sequence Item ${index + 1}`, + level: depth + 1, + children: getRows(item, depth + 2) + } + return itemNode + }) + + return [sequenceNode] + } + + // Handle array values + if (Array.isArray(value)) { + value = value.map(formatValue).join('\\') + } else if (typeof value === 'object' && value !== null) { + value = formatValue(value) + } + + return [{ + tag: tagInfo.tag, + vr: tagInfo.vr, + keyword: keyword.replace('RETIRED_', ''), + value: value?.toString() ?? '', + level: depth + }] + }) +} + +/** + * Sorts DICOM tags alphabetically by tag value + * @param metadata - The DICOM metadata object to process + * @returns Sorted array of tag information + */ +export function getSortedTags (metadata: Record): TagInfo[] { + const tagList = getRows(metadata) + return tagList.sort((a, b) => a.tag.localeCompare(b.tag)) +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index d793b3e3..6d4fe2b3 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -6,7 +6,6 @@ import { Dropdown, Input, Layout, - Menu, Modal, Row, Space, @@ -18,6 +17,7 @@ import { CheckOutlined, InfoOutlined, StopOutlined, + FileSearchOutlined, UnorderedListOutlined, UserOutlined, SettingOutlined @@ -29,6 +29,8 @@ import { RouteComponentProps, withRouter } from '../utils/router' import NotificationMiddleware, { NotificationMiddlewareEvents } from '../services/NotificationMiddleware' import { CustomError } from '../utils/CustomError' import { v4 as uuidv4 } from 'uuid' +import DicomTagBrowser from './DicomTagBrowser/DicomTagBrowser' +import DicomWebManager from '../DicomWebManager' interface HeaderProps extends RouteComponentProps { app: { @@ -42,6 +44,7 @@ interface HeaderProps extends RouteComponentProps { name: string email: string } + clients: { [key: string]: DicomWebManager } showWorklistButton: boolean onServerSelection: ({ url }: { url: string }) => void onUserLogout?: () => void @@ -175,6 +178,19 @@ class Header extends React.Component { }) } + handleDicomTagBrowserButtonClick = (): void => { + const width = window.innerWidth - 200 + Modal.info({ + title: 'DICOM Tag Browser', + width, + content: , + onOk (): void {} + }) + } + handleDebugButtonClick = (): void => { const errorMsgs: { Authentication: string[] @@ -295,9 +311,9 @@ class Header extends React.Component { } ) } - const userMenu = + const userMenu = { items: userMenuItems } user = ( - +