diff --git a/.gitignore b/.gitignore index a69c085..10c5d5c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ # testing /coverage +.env # production /build diff --git a/README.md b/README.md index 68f0a1a..f4588a8 100644 --- a/README.md +++ b/README.md @@ -81,11 +81,87 @@ Users can authenticate and authorize the application to access data via [OpenID ## Configuration +### Server Configuration + The app can be configured via a `public/config/{name}.js` JavaScript configuration file (see for example the default `public/config/local.js`). Please refer to the [AppConfig.d.ts](src/AppConfig.d.ts) file for configuration options. The configuration can be changed at build-time using the `REACT_APP_CONFIG` environment variable. +### Handling Mixed Content and HTTPS + +When deploying SLIM with HTTPS, you may encounter mixed content scenarios where your PACS/VNA server returns HTTP URLs in its responses. This commonly occurs when: + +- The PACS server populates bulkdataURI fields with internal HTTP URLs +- Your viewer is running on HTTPS but needs to communicate with services that respond with HTTP URLs +- You're using a reverse proxy that terminates SSL + +To handle these scenarios, SLIM provides the `upgradeInsecureRequests` option in the server configuration: + +```js +window.config = { + servers: [{ + id: "local", + url: "https://your-server.com/dcm4chee-arc/aets/MYAET/rs", + upgradeInsecureRequests: true // Enable automatic HTTP -> HTTPS upgrade + }] +} +``` + +When `upgradeInsecureRequests` is set to `true` and at least one of your URLs (service URL, QIDO, WADO, or STOW prefixes) uses HTTPS, the viewer will automatically: + +1. Add the `Content-Security-Policy: upgrade-insecure-requests` header to requests +2. Attempt to upgrade any HTTP responses to HTTPS + +This feature was implemented in response to [issue #159](https://github.com/ImagingDataCommons/slim/issues/159) where PACS servers would return HTTP bulkdata URIs even when accessed via HTTPS. + +### Messages/Popups Configuration + +Configure message popup notifications that appear at the top of the screen. By default, all message popups are enabled. + +```javascript +window.config = { + // ... other config options ... + messages: { + disabled: ['warning', 'info'], // Disable specific message types + duration: 5, // Show messages for 5 seconds + top: 100 // Show 100px from top of screen + } +} +``` + +Options: +- `disabled`: Disable specific message types or all messages +- `duration`: How long messages are shown (in seconds) +- `top`: Distance from top of screen (in pixels) + +Available message types: +- `success` - Green popups +- `error` - Red popups +- `warning` - Yellow popups +- `info` - Blue popups + +Examples: +```javascript +// Disable specific types with custom duration and position +messages: { + disabled: ['warning', 'info'], + duration: 5, // Show for 5 seconds + top: 50 // Show 50px from top +} +``` + +```javascript +// Disable all popups +messages: { + disabled: true +} +``` + +Default values if not specified: +- `duration`: 5 seconds +- `top`: 100 pixels + ## Deployment Download the latest release from [github.com/imagingdatacommons/slim/releases](https://github.com/imagingdatacommons/slim/releases) and then run the following commands to install build dependencies and build the app: @@ -223,7 +299,6 @@ Create an [OIDC client ID for web application](https://developers.google.com/ide Note that Google's OIDC implementation does currently not yet support the authorization code grant type with PKCE challenge for private clients. For the time being, the legacy implicit grand type has to be used. - ## Development To install requirements and run the app for local development, run the following commands: diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 28eccc7..0b1ccbe 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,3 +1,31 @@ +# [0.35.0](https://github.com/ImagingDataCommons/slim/compare/v0.34.0...v0.35.0) (2024-11-21) + + +### Features + +* bump dmv version ([#254](https://github.com/ImagingDataCommons/slim/issues/254)) ([fe9bb49](https://github.com/ImagingDataCommons/slim/commit/fe9bb496aa503c54ee65ffba86a0cf14b7e7c20f)) + +# [0.34.0](https://github.com/ImagingDataCommons/slim/compare/v0.33.0...v0.34.0) (2024-11-20) + + +### Features + +* improve dicom tag browser ([#251](https://github.com/ImagingDataCommons/slim/issues/251)) ([7e57859](https://github.com/ImagingDataCommons/slim/commit/7e57859287ea3b758f9d2cf4e53729063c589569)) + +# [0.33.0](https://github.com/ImagingDataCommons/slim/compare/v0.32.0...v0.33.0) (2024-10-31) + + +### Features + +* dicom tag browser ([#248](https://github.com/ImagingDataCommons/slim/issues/248)) ([177ed8f](https://github.com/ImagingDataCommons/slim/commit/177ed8f6b6e82614f9563eb22584c2c6b8bc8de4)) + +# [0.32.0](https://github.com/ImagingDataCommons/slim/compare/v0.31.4...v0.32.0) (2024-10-29) + + +### Features + +* remove unused white space ([#247](https://github.com/ImagingDataCommons/slim/issues/247)) ([155e001](https://github.com/ImagingDataCommons/slim/commit/155e0018c2fd8b87eee70c207051de9df55f0ada)) + ## [0.31.4](https://github.com/ImagingDataCommons/slim/compare/v0.31.3...v0.31.4) (2024-07-29) diff --git a/package.json b/package.json index 53a1f8f..42e8069 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "slim", - "version": "0.31.4", + "version": "0.35.0", "homepage": "https://github.com/imagingdatacommons/slim", "private": true, "author": "ImagingDataCommons", @@ -56,9 +56,9 @@ "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", + "dicom-microscopy-viewer": "^0.47.2", "dicomweb-client": "^0.10.3", "gh-pages": "^5.0.0", "oidc-client": "^1.11.5", diff --git a/src/App.tsx b/src/App.tsx index 94f9fc6..5dda4bd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -453,6 +453,7 @@ class App extends React.Component { onServerSelection={this.handleServerSelection} showServerSelectionButton={false} appConfig={this.props.config} + clients={this.state.clients} /> diff --git a/src/AppConfig.d.ts b/src/AppConfig.d.ts index e224000..211eae7 100644 --- a/src/AppConfig.d.ts +++ b/src/AppConfig.d.ts @@ -64,6 +64,7 @@ export interface ServerSettings { retry?: RetryRequestSettings errorMessages?: ErrorMessageSettings[] storageClasses?: string[] + upgradeInsecureRequests?: boolean } export interface OidcSettings { @@ -103,4 +104,9 @@ export default interface AppConfig { mode?: string preload?: boolean downloadStudyDialog?: DownloadStudyDialogSettings + messages?: { + disabled?: boolean | string[] + top?: number + duration?: number + } } diff --git a/src/DicomWebManager.ts b/src/DicomWebManager.ts index 36ce938..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 @@ -58,9 +63,20 @@ export default class DicomWebManager implements dwc.api.DICOMwebClient { ) ) } + + const hasHttpsUrl = (url?: string): boolean => url?.startsWith('https') ?? false + const clientSettings: dwc.api.DICOMwebClientOptions = { url: serviceUrl } + + const shouldUpgradeInsecure = serverSettings.upgradeInsecureRequests === true && [ + serviceUrl, + serverSettings.qidoPathPrefix, + serverSettings.wadoPathPrefix, + serverSettings.stowPathPrefix + ].some(hasHttpsUrl) + if (serverSettings.qidoPathPrefix !== undefined) { clientSettings.qidoURLPrefix = serverSettings.qidoPathPrefix } @@ -70,6 +86,14 @@ export default class DicomWebManager implements dwc.api.DICOMwebClient { if (serverSettings.stowPathPrefix !== undefined) { clientSettings.stowURLPrefix = serverSettings.stowPathPrefix } + + if (shouldUpgradeInsecure) { + clientSettings.headers = { + ...clientSettings.headers, + 'Content-Security-Policy': 'upgrade-insecure-requests' + } + } + if (serverSettings.retry !== undefined) { clientSettings.requestHooks = [getXHRRetryHook(serverSettings.retry)] } @@ -144,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 ( @@ -162,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/AnnotationGroupItem.tsx b/src/components/AnnotationGroupItem.tsx index b795a30..1c4b2d7 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 72a195e..db27bd3 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 0000000..f6e4676 --- /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 0000000..ede592c --- /dev/null +++ b/src/components/DicomTagBrowser/DicomTagBrowser.tsx @@ -0,0 +1,372 @@ +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' +import DicomMetadataStore, { Series, Study } from '../../services/DICOMMetadataStore' +import { useDebounce } from '../../hooks/useDebounce' + +const { Option } = Select + +interface DisplaySet { + displaySetInstanceUID: number + SeriesDate?: string + SeriesTime?: string + SeriesNumber: string + 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 [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 [searchInput, setSearchInput] = useState('') + + const debouncedSearchValue = useDebounce(searchInput, 300) + + useEffect(() => { + 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([...displaySets, ...derivedDisplaySets]) + }, [slides, study]) + + const displaySetList = useMemo(() => { + displaySets.sort((a, b) => Number(a.SeriesNumber) - Number(b.SeriesNumber)) + return displaySets.map((displaySet, index) => { + const { + SeriesDate = '', + SeriesTime = '', + SeriesNumber = '', + SeriesDescription = '', + Modality = '' + } = displaySet + + const dateStr = `${SeriesDate}:${SeriesTime}`.split('.')[0] + const displayDate = formatDicomDate(dateStr) + + return { + value: index, + label: `${SeriesNumber} (${Modality}): ${SeriesDescription}`, + description: displayDate + } + }) + }, [displaySets]) + + 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 + + // 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 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, + tag: tag.tag, + vr: tag.vr, + keyword: tag.keyword, + value: tag.value + } + + if (tag.children !== undefined && tag.children.length > 0) { + 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]) + + // Reset expanded keys when search value changes + useEffect(() => { + setExpandedKeys([]) + }, [filterValue]) + + const filteredData = useMemo(() => { + if (filterValue === undefined || filterValue === '') return tableData + + const searchLower = filterValue.toLowerCase() + + 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 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 + } + + // 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 + } + + return findMatchingNodes(tableData) + }, [tableData, filterValue]) + + 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) => setSearchInput(e.target.value)} + value={searchInput} + /> + + 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 0000000..e4f0678 --- /dev/null +++ b/src/components/DicomTagBrowser/dicomTagUtils.ts @@ -0,0 +1,122 @@ +import dcmjs from 'dcmjs' + +const { DicomMetaDictionary } = dcmjs.data + +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[] { + if (metadata === undefined || metadata === null) return [] + const keywords = Object.keys(metadata).filter(key => key !== '_vrMap') + + return keywords.flatMap(keyword => { + // @ts-expect-error + const tagInfo = DicomMetaDictionary.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 014f7c6..cedb9b2 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, @@ -32,6 +32,8 @@ import { CustomError } from '../utils/CustomError' import { v4 as uuidv4 } from 'uuid' import DownloadStudySeriesDialog from './DownloadStudySeriesDialog' import AppConfig from '../AppConfig' +import DicomTagBrowser from './DicomTagBrowser/DicomTagBrowser' +import DicomWebManager from '../DicomWebManager' interface HeaderProps extends RouteComponentProps { app: { @@ -45,6 +47,7 @@ interface HeaderProps extends RouteComponentProps { name: string email: string } + clients: { [key: string]: DicomWebManager } showWorklistButton: boolean onServerSelection: ({ url }: { url: string }) => void onUserLogout?: () => void @@ -179,6 +182,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[] @@ -310,9 +326,9 @@ class Header extends React.Component { } ) } - const userMenu = + const userMenu = { items: userMenuItems } user = ( - + {this.state.isHoveredRoiTooltipVisible && - (this.state.hoveredRoiAttributes.length > 0) + this.state.hoveredRoiAttributes.length > 0 ? ( (value: T, delay: number): T => { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value) + }, delay) + + return () => { + clearTimeout(timer) + } + }, [value, delay]) + + return debouncedValue +} diff --git a/src/hooks/useSlides.ts b/src/hooks/useSlides.ts new file mode 100644 index 0000000..fa3217b --- /dev/null +++ b/src/hooks/useSlides.ts @@ -0,0 +1,90 @@ +import { useState, useEffect } from 'react' + +import DicomWebManager from '../DicomWebManager' +import { Slide } from '../data/slides' +import { fetchImageMetadata } from '../services/fetchImageMetadata' + +interface UseSlidesProps { + clients: { [key: string]: DicomWebManager } + studyInstanceUID: string +} + +interface UseSlidesReturn { + slides: Slide[] + isLoading: boolean + error: Error | null +} + +const slidesCache = new Map() +const pendingRequests = new Map>() + +/** + * Hook to fetch and manage whole slide microscopy images for a given study. + * Values are cached so they can be reused if props are not provided. + * + * @param props - Hook configuration props + * @param props.clients - Map of DICOM web clients keyed by storage class + */ +export const useSlides = ({ clients, studyInstanceUID }: UseSlidesProps): UseSlidesReturn => { + const [slides, setSlides] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (studyInstanceUID === undefined) { + setSlides([]) + setIsLoading(false) + return + } + + const cachedData = slidesCache.get(studyInstanceUID) + if (cachedData !== undefined) { + setSlides(cachedData) + setIsLoading(false) + return + } + + setIsLoading(true) + + const fetchSlides = async (): Promise => { + // Check if there's already a pending request for this study + let pendingRequest = pendingRequests.get(studyInstanceUID) + + if (pendingRequest === undefined) { + // Create a new promise for this request + pendingRequest = new Promise((resolve, reject): void => { + fetchImageMetadata({ + clients, + studyInstanceUID, + onSuccess: (newSlides) => { + slidesCache.set(studyInstanceUID, newSlides) + resolve(newSlides) + }, + onError: (err) => { + reject(err) + } + }).catch((err) => { + reject(err) + }) + }) + pendingRequests.set(studyInstanceUID, pendingRequest) + } + + try { + const newSlides = await pendingRequest + setSlides(newSlides) + setError(null) + } catch (err) { + setError(err as Error) + setSlides([]) + } finally { + pendingRequests.delete(studyInstanceUID) + setIsLoading(false) + } + } + + void fetchSlides() + }, [clients, studyInstanceUID]) + + return { slides, isLoading, error } +} diff --git a/src/index.tsx b/src/index.tsx index 66155af..585644f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,5 +1,6 @@ import React from 'react' import { createRoot } from 'react-dom/client' +import { message } from 'antd' import './index.css' import AppConfig from './AppConfig' @@ -25,6 +26,69 @@ if (config.mode === 'dark') { App = React.lazy(async () => await import('./AppLight')) } +const isMessageTypeDisabled = ({ type }: { type: string }): boolean => { + const { messages } = config + if (messages === undefined) return false + if (typeof messages.disabled === 'boolean') { + return messages.disabled + } + return Array.isArray(messages.disabled) && messages.disabled.includes(type) +} + +// Store original message methods +const originalMessage = { ...message } + +const createMessageConfig = (content: string | object): object => { + const duration = config.messages?.duration ?? 5 + + if (typeof content === 'object' && content !== null) { + return { + ...content, + duration + } + } + + return { + content, + duration + } +} + +/** Create a proxy to control antd message */ +const messageProxy = new Proxy(originalMessage, { + get (target, prop: PropertyKey) { + // Handle config method separately + if (prop === 'config') { + return message.config.bind(message) + } + + // Handle message methods (success, error, etc) + const method = target[prop as keyof typeof target] + if (typeof method === 'function') { + return (...args: any[]) => { + const isMessageEnabled = !isMessageTypeDisabled({ type: prop as string }) + if (isMessageEnabled) { + const messageConfig = createMessageConfig(args[0]) + return (method as Function).apply(message, [messageConfig]) + } + return { then: () => {} } + } + } + + // Pass through any other properties + return Reflect.get(target, prop) + } +}) + +// Apply the proxy +Object.assign(message, messageProxy) + +// Set global config after proxy is in place +message.config({ + top: config.messages?.top ?? 100, + duration: config.messages?.duration ?? 5 +}) + const container = document.getElementById('root') // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const root = createRoot(container!) diff --git a/src/services/DICOMMetadataStore.ts b/src/services/DICOMMetadataStore.ts new file mode 100644 index 0000000..fbcdba0 --- /dev/null +++ b/src/services/DICOMMetadataStore.ts @@ -0,0 +1,319 @@ +import dcmjs from 'dcmjs' + +import pubSubServiceInterface from '../utils/pubSubServiceInterface' +import createStudyMetadata from '../utils/createStudyMetadata' + +export const EVENTS = { + STUDY_ADDED: 'event::dicomMetadataStore:studyAdded', + INSTANCES_ADDED: 'event::dicomMetadataStore:instancesAdded', + SERIES_ADDED: 'event::dicomMetadataStore:seriesAdded', + SERIES_UPDATED: 'event::dicomMetadataStore:seriesUpdated' +} + +export interface Instance { + SOPInstanceUID: string + SOPClassUID: string + Rows: number + Columns: number + PatientSex: string + Modality: string + InstanceNumber: string + imageId?: string + [key: string]: any // For dynamic metadata properties +} + +export interface Series { + Modality: string + SeriesInstanceUID: string + SeriesNumber: number + SeriesDate: string + SeriesTime: string + SeriesDescription: string + instances: Instance[] + addInstance: (newInstance: Instance) => void + addInstances: (newInstances: Instance[]) => void + getInstance: (SOPInstanceUID: string) => Instance | undefined +} + +export interface Study { + StudyInstanceUID: string + StudyDescription: string + PatientID: string + PatientName: string + StudyDate: string + AccessionNumber: string + NumInstances: number + ModalitiesInStudy: any[] + NumberOfStudyRelatedSeries?: number + isLoaded: boolean + series: Series[] + addInstanceToSeries: (instance: Instance) => void + addInstancesToSeries: (instances: Instance[]) => void + setSeriesMetadata: (SeriesInstanceUID: string, metadata: any) => void +} + +interface Model { + studies: Study[] +} + +const _model: Model = { + studies: [] +} + +function _getStudyInstanceUIDs (): string[] { + return _model.studies.map((aStudy) => aStudy.StudyInstanceUID) +} + +function _getStudy (StudyInstanceUID: string): Study | undefined { + return _model.studies.find( + (aStudy) => aStudy.StudyInstanceUID === StudyInstanceUID + ) +} + +function _getSeries (StudyInstanceUID: string, SeriesInstanceUID: string): Series | undefined { + const study = _getStudy(StudyInstanceUID) + + if (study == null) { + return + } + + return study.series.find( + (aSeries) => aSeries.SeriesInstanceUID === SeriesInstanceUID + ) +} + +function _getInstance ( + StudyInstanceUID: string, + SeriesInstanceUID: string, + SOPInstanceUID: string +): Instance | undefined { + const series = _getSeries(StudyInstanceUID, SeriesInstanceUID) + + if (series == null) { + return + } + + return series.getInstance(SOPInstanceUID) +} + +function _getInstanceByImageId (imageId: string): Instance | undefined { + for (const study of _model.studies) { + for (const series of study.series) { + for (const instance of series.instances) { + if (instance.imageId === imageId) { + return instance + } + } + } + } +} + +/** + * Update the metadata of a specific series + * @param {*} StudyInstanceUID + * @param {*} SeriesInstanceUID + * @param {*} metadata metadata inform of key value pairs + * @returns + */ +function _updateMetadataForSeries ( + StudyInstanceUID: string, + SeriesInstanceUID: string, + metadata: Record +): void { + const study = _getStudy(StudyInstanceUID) + + if (study == null) { + return + } + + const series = study.series.find( + (aSeries) => aSeries.SeriesInstanceUID === SeriesInstanceUID + ) + + if (series == null) { + return + } + + const { instances } = series + instances.forEach((instance) => { + Object.keys(metadata).forEach((key) => { + if (typeof metadata[key] === 'object') { + instance[key] = { ...instance[key], ...metadata[key] } + } else { + instance[key] = metadata[key] + } + }) + }) +} + +interface BaseImplementationType { + EVENTS: typeof EVENTS + listeners: Record + addInstance: (dicomJSONDatasetOrP10ArrayBuffer: ArrayBuffer | Record) => void + addInstances: (instances: Instance[], madeInClient?: boolean) => void + updateSeriesMetadata: (seriesMetadata: Record) => void + addSeriesMetadata: (seriesSummaryMetadata: Array>, madeInClient?: boolean) => void + addStudy: (study: Record) => void + getStudyInstanceUIDs: typeof _getStudyInstanceUIDs + getStudy: typeof _getStudy + getSeries: typeof _getSeries + getInstance: typeof _getInstance + getInstanceByImageId: typeof _getInstanceByImageId + updateMetadataForSeries: typeof _updateMetadataForSeries + _broadcastEvent: (eventName: string, data: any) => void +} + +const BaseImplementation: BaseImplementationType = { + EVENTS, + listeners: {}, + addInstance (dicomJSONDatasetOrP10ArrayBuffer) { + let dicomJSONDataset + + // If Arraybuffer, parse to DICOMJSON before naturalizing. + if (dicomJSONDatasetOrP10ArrayBuffer instanceof ArrayBuffer) { + const dicomData = dcmjs.data.DicomMessage.readFile( + dicomJSONDatasetOrP10ArrayBuffer + ) + + dicomJSONDataset = dicomData.dict + } else { + dicomJSONDataset = dicomJSONDatasetOrP10ArrayBuffer + } + + let naturalizedDataset: Instance + + if (!('SeriesInstanceUID' in dicomJSONDataset)) { + naturalizedDataset = + dcmjs.data.DicomMetaDictionary.naturalizeDataset(dicomJSONDataset) as Instance + } else { + naturalizedDataset = dicomJSONDataset as unknown as Instance + } + + const { StudyInstanceUID } = naturalizedDataset + + let study = _model.studies.find( + (study) => study.StudyInstanceUID === StudyInstanceUID + ) + + if (study == null) { + _model.studies.push(createStudyMetadata(StudyInstanceUID)) + study = _model.studies[_model.studies.length - 1] + } + + study.addInstanceToSeries(naturalizedDataset) + }, + addInstances (instances, madeInClient = false) { + const { StudyInstanceUID, SeriesInstanceUID } = instances[0] + + let study = _model.studies.find( + (study) => study.StudyInstanceUID === StudyInstanceUID + ) + + if (study == null) { + _model.studies.push(createStudyMetadata(StudyInstanceUID)) + study = _model.studies[_model.studies.length - 1] + } + + study.addInstancesToSeries(instances) + + // Broadcast an event even if we used cached data. + // This is because the mode needs to listen to instances that are added to build up its active displaySets. + // It will see there are cached displaySets and end early if this Series has already been fired in this + // Mode session for some reason. + this._broadcastEvent(EVENTS.INSTANCES_ADDED, { + StudyInstanceUID, + SeriesInstanceUID, + madeInClient + }) + }, + updateSeriesMetadata (seriesMetadata) { + const { StudyInstanceUID, SeriesInstanceUID } = seriesMetadata + const series = _getSeries(StudyInstanceUID, SeriesInstanceUID) + if (series == null) { + return + } + + const study = _getStudy(StudyInstanceUID) + if (study != null) { + study.setSeriesMetadata(SeriesInstanceUID, seriesMetadata) + } + }, + addSeriesMetadata (seriesSummaryMetadata, madeInClient = false) { + if ( + seriesSummaryMetadata === undefined || + seriesSummaryMetadata.length === 0 || + seriesSummaryMetadata[0] === undefined + ) { + return + } + + const { StudyInstanceUID } = seriesSummaryMetadata[0] + let study = _getStudy(StudyInstanceUID) + if (study == null) { + study = createStudyMetadata(StudyInstanceUID) + // Will typically be undefined with a compliant DICOMweb server, reset later + study.StudyDescription = seriesSummaryMetadata[0].StudyDescription + seriesSummaryMetadata?.forEach((item) => { + if (study !== undefined && !study.ModalitiesInStudy?.includes(item.Modality)) { + study.ModalitiesInStudy?.push(item.Modality) + } + }) + study.NumberOfStudyRelatedSeries = seriesSummaryMetadata.length + _model.studies.push(study) + } + + seriesSummaryMetadata.forEach((series) => { + const { SeriesInstanceUID } = series + study?.setSeriesMetadata(SeriesInstanceUID, series) + }) + + this._broadcastEvent(EVENTS.SERIES_ADDED, { + StudyInstanceUID, + seriesSummaryMetadata, + madeInClient + }) + }, + addStudy (study) { + const { StudyInstanceUID } = study + + const existingStudy = _model.studies.find( + (study) => study.StudyInstanceUID === StudyInstanceUID + ) + + if (existingStudy == null) { + const newStudy = createStudyMetadata(StudyInstanceUID) + + newStudy.PatientID = study.PatientID + newStudy.PatientName = study.PatientName + newStudy.StudyDate = study.StudyDate + newStudy.ModalitiesInStudy = study.ModalitiesInStudy + newStudy.StudyDescription = study.StudyDescription + newStudy.AccessionNumber = study.AccessionNumber + newStudy.NumInstances = study.NumInstances // todo: Correct naming? + + _model.studies.push(newStudy) + } + }, + getStudyInstanceUIDs: _getStudyInstanceUIDs, + getStudy: _getStudy, + getSeries: _getSeries, + getInstance: _getInstance, + getInstanceByImageId: _getInstanceByImageId, + updateMetadataForSeries: _updateMetadataForSeries, + _broadcastEvent (eventName: string, data: any): void { + } +} + +interface DicomMetadataStoreType extends BaseImplementationType { + subscribe: (event: string, callback: (data: any) => void) => { unsubscribe: () => any } + unsubscribe: (event: string, callback: (data: any) => void) => void +} + +const DicomMetadataStore = Object.assign( + {}, + BaseImplementation, + pubSubServiceInterface +) as unknown as DicomMetadataStoreType + +export { DicomMetadataStore } +export default DicomMetadataStore diff --git a/src/services/fetchImageMetadata.ts b/src/services/fetchImageMetadata.ts new file mode 100644 index 0000000..e59af78 --- /dev/null +++ b/src/services/fetchImageMetadata.ts @@ -0,0 +1,80 @@ +import * as dmv from 'dicom-microscopy-viewer' + +import DicomWebManager from '../DicomWebManager' +import { StorageClasses } from '../data/uids' +import { CustomError, errorTypes } from '../utils/CustomError' +import NotificationMiddleware, { + NotificationMiddlewareContext +} from './NotificationMiddleware' +import { createSlides, Slide } from '../data/slides' + +interface FetchImageMetadataParams { + clients: { [key: string]: DicomWebManager } + studyInstanceUID: string + onSuccess: (slides: Slide[]) => void + onError: (error: Error) => void +} + +export const fetchImageMetadata = async ({ + clients, + studyInstanceUID, + onSuccess, + onError +}: FetchImageMetadataParams): Promise => { + try { + const images: dmv.metadata.VLWholeSlideMicroscopyImage[][] = [] + console.info(`search for series of study "${studyInstanceUID}"...`) + + const client = 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: studyInstanceUID, + seriesInstanceUID: loadingSeries.SeriesInstanceUID + }) + + const seriesImages: dmv.metadata.VLWholeSlideMicroscopyImage[] = [] + retrievedMetadata.forEach((item) => { + if ( + item['00080016']?.Value?.[0] === + StorageClasses.VL_WHOLE_SLIDE_MICROSCOPY_IMAGE + ) { + const image = new dmv.metadata.VLWholeSlideMicroscopyImage({ + metadata: item + }) + seriesImages.push(image) + } + }) + + if (seriesImages.length > 0) { + images.push(seriesImages) + } + }) + ) + const newSlides = createSlides(images) + onSuccess(newSlides) + } catch (err) { + console.error(err) + const customError = new CustomError( + errorTypes.ENCODINGANDDECODING, + 'Image metadata could not be retrieved or decoded.' + ) + onError(customError) + NotificationMiddleware.onError( + NotificationMiddlewareContext.SLIM, + customError + ) + } +} diff --git a/src/utils/__tests__/formatDicomDate.test.ts b/src/utils/__tests__/formatDicomDate.test.ts new file mode 100644 index 0000000..0049e92 --- /dev/null +++ b/src/utils/__tests__/formatDicomDate.test.ts @@ -0,0 +1,42 @@ +import { formatDicomDate } from '../formatDicomDate' + +describe('formatDicomDate', () => { + describe('valid dates', () => { + it('should format a basic date correctly', () => { + expect(formatDicomDate('20240101:120000')).toBe('Mon, Jan 1 2024') + }) + + it('should handle end of months correctly', () => { + expect(formatDicomDate('20240131:120000')).toBe('Wed, Jan 31 2024') + expect(formatDicomDate('20240229:120000')).toBe('Thu, Feb 29 2024') + expect(formatDicomDate('20240331:120000')).toBe('Sun, Mar 31 2024') + expect(formatDicomDate('20240430:120000')).toBe('Tue, Apr 30 2024') + }) + }) + + describe('invalid dates', () => { + it('should return original string for malformed input', () => { + expect(formatDicomDate('invalid')).toBe('invalid') + expect(formatDicomDate('')).toBe('') + expect(formatDicomDate('20240101')).toBe('20240101') + }) + + it('should return original string for invalid dates', () => { + expect(formatDicomDate('20240231:120000')).toBe('20240231:120000') // Feb 31 + expect(formatDicomDate('20240431:120000')).toBe('20240431:120000') // Apr 31 + }) + + it('should return original string for out of range values', () => { + expect(formatDicomDate('20241301:120000')).toBe('20241301:120000') // month 13 + expect(formatDicomDate('20240132:120000')).toBe('20240132:120000') // day 32 + }) + }) + + describe('time handling', () => { + it('should handle different times for the same date', () => { + expect(formatDicomDate('20240101:000000')).toBe('Mon, Jan 1 2024') + expect(formatDicomDate('20240101:235959')).toBe('Mon, Jan 1 2024') + expect(formatDicomDate('20240101:120000')).toBe('Mon, Jan 1 2024') + }) + }) +}) diff --git a/src/utils/createSeriesMetadata.ts b/src/utils/createSeriesMetadata.ts new file mode 100644 index 0000000..4a922ea --- /dev/null +++ b/src/utils/createSeriesMetadata.ts @@ -0,0 +1,35 @@ +import { Instance, Series } from '../services/DICOMMetadataStore' + +function createSeriesMetadata (SeriesInstanceUID: string, defaultInstances?: Instance[]): Series { + const instances: Instance[] = [] + const instancesMap = new Map() + + return { + SeriesInstanceUID, + Modality: '', + SeriesNumber: 0, + SeriesDescription: '', + SeriesDate: '', + SeriesTime: '', + ...defaultInstances?.[0], + instances, + addInstance: function (newInstance: Instance) { + this.addInstances([newInstance]) + }, + addInstances: function (newInstances: Instance[]) { + for (let i = 0, len = newInstances.length; i < len; i++) { + const instance = newInstances[i] + + if (!instancesMap.has(instance.SOPInstanceUID)) { + instancesMap.set(instance.SOPInstanceUID, instance) + instances.push(instance) + } + } + }, + getInstance: function (SOPInstanceUID: string) { + return instancesMap.get(SOPInstanceUID) + } + } +} + +export default createSeriesMetadata diff --git a/src/utils/createStudyMetadata.ts b/src/utils/createStudyMetadata.ts new file mode 100644 index 0000000..0bbd48c --- /dev/null +++ b/src/utils/createStudyMetadata.ts @@ -0,0 +1,65 @@ +import createSeriesMetadata from './createSeriesMetadata' + +import { Study, Series, Instance } from '../services/DICOMMetadataStore' + +function createStudyMetadata (StudyInstanceUID: string): Study { + return { + StudyInstanceUID, + StudyDescription: '', + PatientID: '', + PatientName: '', + StudyDate: '', + AccessionNumber: '', + NumInstances: 0, + ModalitiesInStudy: [], + isLoaded: false, + series: [] as Series[], + /** + * @param {object} instance + */ + addInstanceToSeries: function (instance: Instance) { + this.addInstancesToSeries([instance]) + }, + /** + * @param {object[]} instances + * @param {string} instances[].SeriesInstanceUID + * @param {string} instances[].StudyDescription + */ + addInstancesToSeries: function (instances: Instance[]) { + const { SeriesInstanceUID } = instances[0] + + if (this.StudyDescription !== '' && this.StudyDescription !== undefined) { + this.StudyDescription = instances[0].StudyDescription + } + + let series = this.series.find( + (s) => s.SeriesInstanceUID === SeriesInstanceUID + ) + + if (series == null) { + series = createSeriesMetadata(SeriesInstanceUID, instances) + this.series.push(series) + } + + series.addInstances(instances) + }, + + setSeriesMetadata: function ( + SeriesInstanceUID: string, + seriesMetadata: any + ) { + let existingSeries = this.series.find( + (s) => s.SeriesInstanceUID === SeriesInstanceUID + ) + + if (existingSeries != null) { + existingSeries = Object.assign(existingSeries, seriesMetadata) + } else { + const series = createSeriesMetadata(SeriesInstanceUID) + this.series.push(Object.assign(series, seriesMetadata)) + } + } + } +} + +export default createStudyMetadata diff --git a/src/utils/formatDicomDate.ts b/src/utils/formatDicomDate.ts new file mode 100644 index 0000000..cd476c3 --- /dev/null +++ b/src/utils/formatDicomDate.ts @@ -0,0 +1,49 @@ +/** + * Formats a DICOM datetime string (YYYYMMDD:HHmmss) into a human-readable format + * + * @param dateStr - DICOM datetime string in format "YYYYMMDD:HHmmss" + * @returns Formatted date string (e.g., "Mon, Jan 1 2024") + * @example + * formatDicomDate("20240101:120000") // Returns "Mon, Jan 1 2024" + * formatDicomDate("invalid") // Returns "invalid" + */ +export const formatDicomDate = (dateStr: string): string => { + // Parse YYYYMMDD:HHmmss format + const match = dateStr.match(/^(\d{4})(\d{2})(\d{2}):(\d{2})(\d{2})(\d{2})/) + if (match == null) return dateStr + + const [, year, month, day, hour, minute, second] = match + + // Validate month and day + const monthNum = parseInt(month) + const dayNum = parseInt(day) + if (monthNum < 1 || monthNum > 12 || dayNum < 1 || dayNum > 31) { + return dateStr + } + + const date = new Date( + parseInt(year), + monthNum - 1, // months are 0-based + dayNum, + parseInt(hour), + parseInt(minute), + parseInt(second) + ) + + // Check if the date is invalid or if the month/day combination is invalid + // This catches cases like February 31st where the date rolls over to March + if ( + date.getMonth() !== monthNum - 1 || // month rolled over + date.getDate() !== dayNum // day rolled over + ) { + return dateStr + } + + // Format parts separately to avoid the extra comma + const weekday = date.toLocaleDateString('en-US', { weekday: 'short' }) + const monthName = date.toLocaleDateString('en-US', { month: 'short' }) + const dayFormatted = date.getDate() + const yearNum = date.getFullYear() + + return `${weekday}, ${monthName} ${dayFormatted} ${yearNum}` +} diff --git a/src/utils/pubSubServiceInterface.ts b/src/utils/pubSubServiceInterface.ts new file mode 100644 index 0000000..e2a9c42 --- /dev/null +++ b/src/utils/pubSubServiceInterface.ts @@ -0,0 +1,135 @@ +import { v4 as generateUUID } from 'uuid' + +/** + * Consumer must implement: + * this.listeners = {} + * this.EVENTS = { "EVENT_KEY": "EVENT_VALUE" } + */ +const pubSubInterface = { + subscribe, + _broadcastEvent, + _unsubscribe, + _isValidEvent +} + +export default pubSubInterface + +/** + * Subscribe to updates. + * + * @param {string} eventName The name of the event + * @param {Function} callback Events callback + * @return {Object} Observable object with actions + */ +function subscribe (this: PubSubService, eventName: string, callback: Function): { unsubscribe: () => any } { + if (this._isValidEvent(eventName)) { + const listenerId = generateUUID() + const subscription = { id: listenerId, callback } + + // console.info(`Subscribing to '${eventName}'.`); + if (Array.isArray(this.listeners[eventName])) { + this.listeners[eventName].push(subscription) + } else { + this.listeners[eventName] = [subscription] + } + + return { + unsubscribe: () => this._unsubscribe(eventName, listenerId) + } + } else { + throw new Error(`Event ${eventName} not supported.`) + } +} + +/** + * Unsubscribe to measurement updates. + * + * @param {string} eventName The name of the event + * @param {string} listenerId The listeners id + * @return void + */ +function _unsubscribe (this: PubSubService, eventName: string, listenerId: string): void { + if (this.listeners[eventName] === undefined) { + return + } + + const listeners = this.listeners[eventName] + if (Array.isArray(listeners)) { + this.listeners[eventName] = listeners.filter(({ id }) => id !== listenerId) + } else { + this.listeners[eventName] = [] + } +} + +/** + * Check if a given event is valid. + * + * @param {string} eventName The name of the event + * @return {boolean} Event name validation + */ +function _isValidEvent (this: PubSubService, eventName: string): boolean { + return Object.values(this.EVENTS).includes(eventName) +} + +/** + * Broadcasts changes. + * + * @param {string} eventName - The event name + * @param {func} callbackProps - Properties to pass callback + * @return void + */ +function _broadcastEvent (this: PubSubService, eventName: string, callbackProps: any): void { + const hasListeners = Object.keys(this.listeners).length > 0 + const hasCallbacks = Array.isArray(this.listeners[eventName]) + + if (hasListeners && hasCallbacks) { + this.listeners[eventName].forEach((listener: { id: string, callback: Function }) => { + listener.callback(callbackProps) + }) + } +} + +/** Export a PubSubService class to be used instead of the individual items */ +export class PubSubService { + EVENTS: any + subscribe: ( + eventName: string, + callback: Function + ) => { unsubscribe: () => any } + + _broadcastEvent: (eventName: string, callbackProps: any) => void + _unsubscribe: (eventName: string, listenerId: string) => void + _isValidEvent: (eventName: string) => boolean + listeners: { [key: string]: Array<{ id: string, callback: Function }> } + unsubscriptions: any[] + constructor (EVENTS: Record) { + this.EVENTS = EVENTS + this.subscribe = subscribe + this._broadcastEvent = _broadcastEvent + this._unsubscribe = _unsubscribe + this._isValidEvent = _isValidEvent + this.listeners = {} + this.unsubscriptions = [] + } + + reset (): void { + this.unsubscriptions.forEach((unsub) => unsub()) + this.unsubscriptions = [] + } + + /** + * Creates an event that records whether or not someone + * has consumed it. Call eventData.consume() to consume the event. + * Check eventData.isConsumed to see if it is consumed or not. + * @param props - to include in the event + */ + protected createConsumableEvent (props: Record): Record { + return { + ...props, + isConsumed: false, + consume: function Consume () { + this.isConsumed = true + } + } + } +} diff --git a/types/dicom-microscopy-viewer/index.d.ts b/types/dicom-microscopy-viewer/index.d.ts index 98496aa..8fcac48 100644 --- a/types/dicom-microscopy-viewer/index.d.ts +++ b/types/dicom-microscopy-viewer/index.d.ts @@ -519,6 +519,9 @@ declare module 'dicom-microscopy-viewer' { // General Series module SeriesInstanceUID: string SeriesNumber: number | null | undefined + SeriesDate: string + SeriesTime: string + SeriesDescription: string Modality: string // SOP Common module SOPClassUID: string diff --git a/types/dicomweb-client/index.d.ts b/types/dicomweb-client/index.d.ts index 02dfac1..0729934 100644 --- a/types/dicomweb-client/index.d.ts +++ b/types/dicomweb-client/index.d.ts @@ -10,12 +10,13 @@ declare module 'dicomweb-client' { export type DICOMwebClientRequestHook = (request: XMLHttpRequest, metadata: DICOMwebClientRequestHookMetadata) => XMLHttpRequest export interface DICOMwebClientOptions { - url: string|undefined + url: string | undefined qidoURLPrefix?: string wadoURLPrefix?: string stowURLPrefix?: string headers?: { Authorization?: string + 'Content-Security-Policy'?: string } requestHooks?: DICOMwebClientRequestHook[] errorInterceptor?: (request: DICOMwebClientError) => void @@ -133,49 +134,49 @@ declare module 'dicomweb-client' { export type Dataset = ArrayBuffer export interface DICOMwebClient { - headers: {[key: string]: string} + headers: { [key: string]: string } baseURL: string // STOW-RS - storeInstances (options: StoreInstancesOptions): Promise + storeInstances(options: StoreInstancesOptions): Promise // QIDO-RS - searchForStudies ( + searchForStudies( options: SearchForStudiesOptions ): Promise - searchForSeries ( + searchForSeries( options: SearchForSeriesOptions ): Promise - searchForInstances ( + searchForInstances( options: SearchForInstancesOptions ): Promise // WADO-RS - retrieveStudyMetadata ( + retrieveStudyMetadata( options: RetrieveStudyMetadataOptions ): Promise - retrieveSeriesMetadata ( + retrieveSeriesMetadata( options: RetrieveSeriesMetadataOptions ): Promise - retrieveInstanceMetadata ( + retrieveInstanceMetadata( options: RetrieveInstanceMetadataOptions ): Promise - retrieveInstance ( + retrieveInstance( options: RetrieveInstanceOptions ): Promise - retrieveInstanceFrames ( + retrieveInstanceFrames( options: RetrieveInstanceFramesOptions ): Promise - retrieveInstanceRendered ( + retrieveInstanceRendered( options: RetrieveInstanceRenderedOptions ): Promise - retrieveInstanceFramesRendered ( + retrieveInstanceFramesRendered( options: RetrieveInstanceFramesRenderedOptions ): Promise - retrieveBulkData ( + retrieveBulkData( options: RetrieveBulkDataOptions ): Promise } export class DICOMwebClient implements DICOMwebClient { - constructor (options: DICOMwebClientOptions) + constructor(options: DICOMwebClientOptions) } export interface MetadataElement { diff --git a/yarn.lock b/yarn.lock index 1a8fd26..6cc23a0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4554,9 +4554,9 @@ create-require@^1.1.0: integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== dependencies: path-key "^3.1.0" shebang-command "^2.0.0" @@ -4804,9 +4804,9 @@ dayjs@1.x: integrity sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ== dcmjs@^0.29.8: - version "0.29.8" - resolved "https://registry.yarnpkg.com/dcmjs/-/dcmjs-0.29.8.tgz#3daa5224f8e75b2e5b069590bef26a272741fa44" - integrity sha512-Y0/KZAmT1siVo7eH3KK4ZflEbNi61soUpD0N7lsXMVVJQ6IZkHlaSzb9DtqnEpMs7RJDfvZGr1uXpv1vBBIypQ== + version "0.29.13" + resolved "https://registry.yarnpkg.com/dcmjs/-/dcmjs-0.29.13.tgz#a74df541bf3948366b25630dbe1e8376f108ea3b" + integrity sha512-Bf9tKzJNWqk4kbV210N5TLEHDqaZvO3S+MH9vezFAU8WKcG4cR6z4/II3TQVqhLI185eNUL+lhfPCVH1Uu2yTA== dependencies: "@babel/runtime-corejs3" "^7.22.5" adm-zip "^0.5.10" @@ -4816,6 +4816,19 @@ dcmjs@^0.29.8: ndarray "^1.0.19" pako "^2.0.4" +dcmjs@^0.35.0: + version "0.35.0" + resolved "https://registry.yarnpkg.com/dcmjs/-/dcmjs-0.35.0.tgz#5169fe27a8473b6e8459dbef922333a314fed2b9" + integrity sha512-kKOX4y0XoSQsDx7xYh9HJLfAWizrvYNJPU/d+DGOehdGz1frvimBZDEL0iaCK46Lhy7amAmtv4g5hkjcxVF+kg== + dependencies: + "@babel/runtime-corejs3" "^7.22.5" + adm-zip "^0.5.10" + gl-matrix "^3.1.0" + lodash.clonedeep "^4.5.0" + loglevel "^1.8.1" + ndarray "^1.0.19" + pako "^2.0.4" + debug@2.6.9, debug@^2.6.0, debug@^2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -4969,10 +4982,10 @@ detective@^5.2.1: defined "^1.0.0" minimist "^1.2.6" -dicom-microscopy-viewer@^0.47.0: - version "0.47.0" - resolved "https://registry.yarnpkg.com/dicom-microscopy-viewer/-/dicom-microscopy-viewer-0.47.0.tgz#ec87344f7df3457ead8edaa08f6bfc209307fdff" - integrity sha512-w4mimmVTHddGNAKvCv4VX+3x3Rc1KrSJcYfo5Tg8Ng3wyxiq/wY9eX6jM7ywpePVa3bIicc6Gv33XIg3UF4fEA== +dicom-microscopy-viewer@^0.47.2: + version "0.47.2" + resolved "https://registry.yarnpkg.com/dicom-microscopy-viewer/-/dicom-microscopy-viewer-0.47.2.tgz#cc7b2ee5321872f14b3097480355385755831250" + integrity sha512-h+YaZwokxMFGNTdF4/YmiIaLyXA/Vn2A8ikS6JdSQOBkysHbolqzHelCCzZgpXO/ZPAF/p/hyrEjDuaE+LGDmw== dependencies: "@cornerstonejs/codec-charls" "^1.2.3" "@cornerstonejs/codec-libjpeg-turbo-8bit" "^1.2.2" @@ -6723,9 +6736,9 @@ http-proxy-agent@^7.0.0: debug "^4.3.4" http-proxy-middleware@^2.0.3: - version "2.0.6" - resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz#e1a4dd6979572c7ab5a4e4b55095d1f32a74963f" - integrity sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw== + version "2.0.7" + resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz#915f236d92ae98ef48278a95dedf17e991936ec6" + integrity sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA== dependencies: "@types/http-proxy" "^1.17.8" http-proxy "^1.18.1" @@ -8406,6 +8419,11 @@ lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.7.0 resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== +loglevel@^1.8.1: + version "1.9.2" + resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.9.2.tgz#c2e028d6c757720107df4e64508530db6621ba08" + integrity sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg== + loglevelnext@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/loglevelnext/-/loglevelnext-3.0.1.tgz#e3e4659c4061c09264f6812c33586dc55a009a04" @@ -8721,9 +8739,9 @@ mkdirp@~0.5.1: minimist "^1.2.6" moment@^2.24.0, moment@^2.29.2: - version "2.29.4" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108" - integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w== + version "2.30.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" + integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== ms@2.0.0: version "2.0.0" @@ -11811,7 +11829,16 @@ string-natural-compare@^3.0.1: resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -11884,7 +11911,14 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -13109,7 +13143,16 @@ workbox-window@6.5.4: "@types/trusted-types" "^2.0.2" workbox-core "6.5.4" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@7.0.0, wrap-ansi@^7.0.0, wrap-ansi@^8.1.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@7.0.0, wrap-ansi@^7.0.0, wrap-ansi@^8.1.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==