diff --git a/package.json b/package.json index 89bd04f..7a24722 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "classnames": "^2.2.6", "copy-webpack-plugin": "^10.2.4", "craco-less": "^2.0.0", - "dcmjs": "^0.29.8", + "dcmjs": "^0.35.0", "detect-browser": "^5.2.1", "dicom-microscopy-viewer": "^0.47.0", "dicomweb-client": "^0.10.3", diff --git a/src/DicomWebManager.ts b/src/DicomWebManager.ts index c7bdb01..98a60e7 100644 --- a/src/DicomWebManager.ts +++ b/src/DicomWebManager.ts @@ -1,4 +1,6 @@ import * as dwc from 'dicomweb-client' +import * as dcmjs from 'dcmjs' +import * as dmv from 'dicom-microscopy-viewer' import { ServerSettings, DicomWebManagerErrorHandler } from './AppConfig' import { joinUrl } from './utils/url' @@ -7,6 +9,9 @@ import { CustomError, errorTypes } from './utils/CustomError' import NotificationMiddleware, { NotificationMiddlewareContext } from './services/NotificationMiddleware' +import DicomMetadataStore, { Instance } from './services/DICOMMetadataStore' + +const { naturalizeDataset } = dcmjs.data.DicomMetaDictionary interface Store { id: string @@ -163,13 +168,21 @@ export default class DicomWebManager implements dwc.api.DICOMwebClient { retrieveStudyMetadata = async ( options: dwc.api.RetrieveStudyMetadataOptions ): Promise => { - return await this.stores[0].client.retrieveStudyMetadata(options) + const studySummaryMetadata = await this.stores[0].client.retrieveStudyMetadata(options) + const naturalized = naturalizeDataset(studySummaryMetadata) + DicomMetadataStore.addStudy(naturalized) + return studySummaryMetadata } retrieveSeriesMetadata = async ( options: dwc.api.RetrieveSeriesMetadataOptions ): Promise => { - return await this.stores[0].client.retrieveSeriesMetadata(options) + const seriesSummaryMetadata = await this.stores[0].client.retrieveSeriesMetadata(options) + console.debug('seriesSummaryMetadata:', seriesSummaryMetadata) + const naturalized = seriesSummaryMetadata.map(naturalizeDataset) + console.debug('naturalized:', naturalized) + DicomMetadataStore.addSeriesMetadata(naturalized, true) + return seriesSummaryMetadata } retrieveInstanceMetadata = async ( @@ -181,7 +194,11 @@ export default class DicomWebManager implements dwc.api.DICOMwebClient { retrieveInstance = async ( options: dwc.api.RetrieveInstanceOptions ): Promise => { - return await this.stores[0].client.retrieveInstance(options) + const instance = await this.stores[0].client.retrieveInstance(options) + const data = dcmjs.data.DicomMessage.readFile(instance) + const { dataset } = dmv.metadata.formatMetadata(data.dict) + DicomMetadataStore.addInstances([dataset as Instance]) + return instance } retrieveInstanceFrames = async ( diff --git a/src/components/DicomTagBrowser/DicomTagBrowser.tsx b/src/components/DicomTagBrowser/DicomTagBrowser.tsx index a9f7389..ede592c 100644 --- a/src/components/DicomTagBrowser/DicomTagBrowser.tsx +++ b/src/components/DicomTagBrowser/DicomTagBrowser.tsx @@ -7,15 +7,17 @@ import './DicomTagBrowser.css' import { useSlides } from '../../hooks/useSlides' import { getSortedTags } from './dicomTagUtils' import { formatDicomDate } from '../../utils/formatDicomDate' +import DicomMetadataStore, { Series, Study } from '../../services/DICOMMetadataStore' +import { useDebounce } from '../../hooks/useDebounce' const { Option } = Select interface DisplaySet { displaySetInstanceUID: number - SeriesDate: string - SeriesTime: string - SeriesNumber: number - SeriesDescription: string + SeriesDate?: string + SeriesTime?: string + SeriesNumber: string + SeriesDescription?: string Modality: string images: any[] } @@ -36,62 +38,116 @@ interface DicomTagBrowserProps { const DicomTagBrowser = ({ clients, studyInstanceUID }: DicomTagBrowserProps): JSX.Element => { const { slides, isLoading } = useSlides({ clients, studyInstanceUID }) + const [study, setStudy] = useState(undefined) 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([]) + const [searchInput, setSearchInput] = useState('') + + const debouncedSearchValue = useDebounce(searchInput, 300) 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) + setFilterValue(debouncedSearchValue) + }, [debouncedSearchValue]) + + useEffect(() => { + const handler = (event: any): void => { + const study: Study | undefined = Object.assign({}, DicomMetadataStore.getStudy(studyInstanceUID)) + setStudy(study) + } + const seriesAddedSubscription = DicomMetadataStore.subscribe(DicomMetadataStore.EVENTS.SERIES_ADDED, handler) + const instancesAddedSubscription = DicomMetadataStore.subscribe(DicomMetadataStore.EVENTS.INSTANCES_ADDED, handler) + + const study = Object.assign({}, DicomMetadataStore.getStudy(studyInstanceUID)) + setStudy(study) + + return () => { + seriesAddedSubscription.unsubscribe() + instancesAddedSubscription.unsubscribe() + } + }, [studyInstanceUID]) + + useEffect(() => { + let displaySets: DisplaySet[] = [] + let derivedDisplaySets: DisplaySet[] = [] + const processedSeries: string[] = [] + let index = 0 + + if (slides.length > 0) { + displaySets = slides + .map((slide): DisplaySet | null => { + const { volumeImages } = slide + if (volumeImages?.[0] === undefined) return null + + const { + SeriesDate, + SeriesTime, + SeriesNumber, + SeriesInstanceUID, + SeriesDescription, + Modality + } = volumeImages[0] + + processedSeries.push(SeriesInstanceUID) + + const ds: DisplaySet = { + displaySetInstanceUID: index, + SeriesDate, + SeriesTime, + SeriesInstanceUID, + // @ts-expect-error + SeriesNumber, + SeriesDescription, + Modality, + images: volumeImages + } + index++ + return ds + }) + .filter((set): set is DisplaySet => set !== null) + } + + if (study !== undefined && study.series?.length > 0) { + derivedDisplaySets = study.series.filter(s => !processedSeries.includes(s.SeriesInstanceUID)) + .map((series: Series): DisplaySet => { + const ds: DisplaySet = { + displaySetInstanceUID: index, + SeriesDate: series.SeriesDate, + SeriesTime: series.SeriesTime, + // @ts-expect-error + SeriesNumber: series.SeriesNumber, + SeriesDescription: series.SeriesDescription, + SeriesInstanceUID: series.SeriesInstanceUID, + Modality: series.Modality, + images: series?.instances?.length > 0 ? series.instances : [series] + } + index++ + return ds + }) + } - setDisplaySets(updatedDisplaySets) - }, [slides]) + setDisplaySets([...displaySets, ...derivedDisplaySets]) + }, [slides, study]) const displaySetList = useMemo(() => { - displaySets.sort((a, b) => a.SeriesNumber - b.SeriesNumber) - return displaySets.map((displaySet) => { + displaySets.sort((a, b) => Number(a.SeriesNumber) - Number(b.SeriesNumber)) + return displaySets.map((displaySet, index) => { const { - displaySetInstanceUID, - SeriesDate, - SeriesTime, - SeriesNumber, - SeriesDescription, - Modality + SeriesDate = '', + SeriesTime = '', + SeriesNumber = '', + SeriesDescription = '', + Modality = '' } = displaySet const dateStr = `${SeriesDate}:${SeriesTime}`.split('.')[0] const displayDate = formatDicomDate(dateStr) return { - value: displaySetInstanceUID, + value: index, label: `${SeriesNumber} (${Modality}): ${SeriesDescription}`, description: displayDate } @@ -101,6 +157,8 @@ const DicomTagBrowser = ({ clients, studyInstanceUID }: DicomTagBrowserProps): J const showInstanceList = displaySets[selectedDisplaySetInstanceUID]?.images.length > 1 + console.debug('displaySets:', displaySets) + const instanceSliderMarks = useMemo(() => { if (displaySets[selectedDisplaySetInstanceUID] === undefined) return {} const totalInstances = displaySets[selectedDisplaySetInstanceUID].images.length @@ -145,8 +203,9 @@ const DicomTagBrowser = ({ clients, studyInstanceUID }: DicomTagBrowserProps): J 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}` + // Create a unique key using tag value if available, otherwise use index + const keyBase: string = tag.tag !== '' ? tag.tag.replace(/[(),]/g, '') : index.toString() + const currentKey: string = parentKey !== '' ? `${parentKey}-${keyBase}` : keyBase const item: TableDataItem = { key: currentKey, @@ -157,7 +216,6 @@ const DicomTagBrowser = ({ clients, studyInstanceUID }: DicomTagBrowserProps): J } if (tag.children !== undefined && tag.children.length > 0) { - // Pass the current key as parent for nested items item.children = transformTagsToTableData(tag.children, currentKey) } @@ -171,56 +229,62 @@ const DicomTagBrowser = ({ clients, studyInstanceUID }: DicomTagBrowserProps): J return transformTagsToTableData(tags) }, [instanceNumber, selectedDisplaySetInstanceUID, displaySets]) + // Reset expanded keys when search value changes + useEffect(() => { + setExpandedKeys([]) + }, [filterValue]) + 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 nodeMatches = (node: TableDataItem): boolean => { + return ( + (node.tag?.toLowerCase() ?? '').includes(searchLower) || + (node.vr?.toLowerCase() ?? '').includes(searchLower) || + (node.keyword?.toLowerCase() ?? '').includes(searchLower) || + (node.value?.toString().toLowerCase() ?? '').includes(searchLower) + ) } - const filtered = filterNodes(tableData) - setSearchExpandedKeys(newSearchExpandedKeys) - return filtered - }, [tableData, filterValue]) + const findMatchingNodes = (nodes: TableDataItem[]): TableDataItem[] => { + const results: TableDataItem[] = [] + + const searchNode = (node: TableDataItem): void => { + if (nodeMatches(node)) { + // Create a new matching node with its original structure + const matchingNode: TableDataItem = { + key: node.key, + tag: node.tag, + vr: node.vr, + keyword: node.keyword, + value: node.value + } - // Reset search expanded keys when search is cleared - useEffect(() => { - if (filterValue === undefined || filterValue === '') { - setSearchExpandedKeys([]) + // If the node has children, preserve them for expansion + matchingNode.children = node?.children?.map((child): TableDataItem => ({ + key: child.key, + tag: child.tag, + vr: child.vr, + keyword: child.keyword, + value: child.value, + children: child.children + })) + + results.push(matchingNode) + } + + // Continue searching through children + node?.children?.forEach(searchNode) + } + + nodes.forEach(searchNode) + return results } - }, [filterValue]) - // Combine manual expansion with search expansion - const allExpandedKeys = useMemo(() => { - return [...new Set([...expandedKeys, ...searchExpandedKeys])] - }, [expandedKeys, searchExpandedKeys]) + return findMatchingNodes(tableData) + }, [tableData, filterValue]) if (isLoading) { return
Loading...
@@ -240,7 +304,6 @@ const DicomTagBrowser = ({ clients, studyInstanceUID }: DicomTagBrowserProps): J