diff --git a/extensions/cornerstone-dicom-rt/src/loadRTStruct.js b/extensions/cornerstone-dicom-rt/src/loadRTStruct.js index 52e1bd7c7..613c2fb65 100644 --- a/extensions/cornerstone-dicom-rt/src/loadRTStruct.js +++ b/extensions/cornerstone-dicom-rt/src/loadRTStruct.js @@ -7,12 +7,13 @@ async function checkAndLoadContourData(instance, datasource) { return Promise.reject('Invalid instance object or ROIContourSequence'); } - const promises = []; - let counter = 0; + const promisesMap = new Map(); for (const ROIContour of instance.ROIContourSequence) { + const referencedROINumber = ROIContour.ReferencedROINumber; if (!ROIContour || !ROIContour.ContourSequence) { - return Promise.reject('Invalid ROIContour or ContourSequence'); + promisesMap.set(referencedROINumber, [Promise.resolve([])]); + continue; } for (const Contour of ROIContour.ContourSequence) { @@ -21,9 +22,15 @@ async function checkAndLoadContourData(instance, datasource) { } const contourData = Contour.ContourData; - counter++; + if (Array.isArray(contourData)) { - promises.push(Promise.resolve(contourData)); + promisesMap.has(referencedROINumber) + ? promisesMap + .get(referencedROINumber) + .push(Promise.resolve(contourData)) + : promisesMap.set(referencedROINumber, [ + Promise.resolve(contourData), + ]); } else if (contourData && contourData.BulkDataURI) { const bulkDataURI = contourData.BulkDataURI; @@ -44,53 +51,59 @@ async function checkAndLoadContourData(instance, datasource) { SOPInstanceUID: instance.SOPInstanceUID, }); - promises.push(bulkDataPromise); + promisesMap.has(referencedROINumber) + ? promisesMap.get(referencedROINumber).push(bulkDataPromise) + : promisesMap.set(referencedROINumber, [bulkDataPromise]); } else { return Promise.reject(`Invalid ContourData: ${contourData}`); } } } - const flattenedPromises = promises.flat(); - const resolvedPromises = await Promise.allSettled(flattenedPromises); - // Modify contourData and replace it in its corresponding ROIContourSequence's Contour's contourData - let index = 0; - instance.ROIContourSequence.forEach((ROIContour, roiIndex) => { - ROIContour.ContourSequence.forEach((Contour, contourIndex) => { - const promise = resolvedPromises[index++]; + const resolvedPromisesMap = new Map(); + for (const [key, promiseArray] of promisesMap.entries()) { + resolvedPromisesMap.set(key, await Promise.allSettled(promiseArray)); + } - if (promise.status === 'fulfilled') { - const uint8Array = new Uint8Array(promise.value); - const textDecoder = new TextDecoder(); - const dataUint8Array = textDecoder.decode(uint8Array); - if ( - typeof dataUint8Array === 'string' && - dataUint8Array.includes('\\') - ) { - const numSlashes = (dataUint8Array.match(/\\/g) || []).length; - let startIndex = 0; - let endIndex = dataUint8Array.indexOf('\\', startIndex); - let numbersParsed = 0; - const ContourData = []; + instance.ROIContourSequence.forEach(ROIContour => { + try { + const referencedROINumber = ROIContour.ReferencedROINumber; + const resolvedPromises = resolvedPromisesMap.get(referencedROINumber); - while (numbersParsed !== numSlashes + 1) { - const str = dataUint8Array.substring(startIndex, endIndex); - let value = parseFloat(str); - - ContourData.push(value); - startIndex = endIndex + 1; - endIndex = dataUint8Array.indexOf('\\', startIndex); - endIndex === -1 ? (endIndex = dataUint8Array.length) : endIndex; - numbersParsed++; + if (ROIContour.ContourSequence) { + ROIContour.ContourSequence.forEach((Contour, index) => { + const promise = resolvedPromises[index]; + if (promise.status === 'fulfilled') { + if ( + Array.isArray(promise.value) && + promise.value.every(Number.isFinite) + ) { + // If promise.value is already an array of numbers, use it directly + Contour.ContourData = promise.value; + } else { + // If the resolved promise value is a byte array (Blob), it needs to be decoded + const uint8Array = new Uint8Array(promise.value); + const textDecoder = new TextDecoder(); + const dataUint8Array = textDecoder.decode(uint8Array); + if ( + typeof dataUint8Array === 'string' && + dataUint8Array.includes('\\') + ) { + Contour.ContourData = dataUint8Array + .split('\\') + .map(parseFloat); + } else { + Contour.ContourData = []; + } + } + } else { + console.error(promise.reason); } - Contour.ContourData = ContourData; - } else { - Contour.ContourData = []; - } - } else { - console.error(promise.reason); + }); } - }); + } catch (error) { + console.error(error); + } }); } @@ -104,7 +117,7 @@ export default async function loadRTStruct( '@ohif/extension-cornerstone.utilityModule.common' ); const dataSource = extensionManager.getActiveDataSource()[0]; - const { useBulkDataURI } = dataSource.getConfig?.() || {}; + const { bulkDataURI } = dataSource.getConfig?.() || {}; const { dicomLoaderService } = utilityModule.exports; const imageIdSopInstanceUidPairs = _getImageIdSopInstanceUidPairsForDisplaySet( @@ -116,7 +129,7 @@ export default async function loadRTStruct( rtStructDisplaySet.isLoaded = true; let instance = rtStructDisplaySet.instance; - if (!useBulkDataURI) { + if (!bulkDataURI || !bulkDataURI.enabled) { const segArrayBuffer = await dicomLoaderService.findDicomDataPromise( rtStructDisplaySet, null, diff --git a/extensions/cornerstone-dicom-sr/package.json b/extensions/cornerstone-dicom-sr/package.json index 5eec75694..37f7d6974 100644 --- a/extensions/cornerstone-dicom-sr/package.json +++ b/extensions/cornerstone-dicom-sr/package.json @@ -45,8 +45,8 @@ "dependencies": { "@babel/runtime": "^7.20.13", "classnames": "^2.3.2", - "@cornerstonejs/adapters": "^0.6.0", - "@cornerstonejs/core": "^0.47.1", - "@cornerstonejs/tools": "^0.67.4" + "@cornerstonejs/adapters": "^1.1.0", + "@cornerstonejs/core": "^1.1.0", + "@cornerstonejs/tools": "^1.1.0" } } diff --git a/extensions/cornerstone/package.json b/extensions/cornerstone/package.json index e4954986e..bd64914c0 100644 --- a/extensions/cornerstone/package.json +++ b/extensions/cornerstone/package.json @@ -34,7 +34,7 @@ "peerDependencies": { "@ohif/core": "^3.0.0", "@ohif/ui": "^2.0.0", - "@cornerstonejs/dicom-image-loader": "^0.6.6", + "@cornerstonejs/dicom-image-loader": "^0.6.8", "@cornerstonejs/codec-charls": "^1.2.3", "@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2", "@cornerstonejs/codec-openjpeg": "^1.2.2", @@ -52,10 +52,10 @@ }, "dependencies": { "@babel/runtime": "^7.20.13", - "@cornerstonejs/adapters": "^0.6.0", - "@cornerstonejs/core": "^0.47.1", - "@cornerstonejs/streaming-image-volume-loader": "^0.20.4", - "@cornerstonejs/tools": "^0.67.4", + "@cornerstonejs/adapters": "^1.1.0", + "@cornerstonejs/core": "^1.1.0", + "@cornerstonejs/streaming-image-volume-loader": "^1.1.0", + "@cornerstonejs/tools": "^1.1.0", "@kitware/vtk.js": "27.3.1", "html2canvas": "^1.4.1", "lodash.debounce": "4.0.8", diff --git a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx index 17a4f2a00..a94cc0fc6 100644 --- a/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx +++ b/extensions/cornerstone/src/Viewport/OHIFCornerstoneViewport.tsx @@ -335,13 +335,13 @@ const OHIFCornerstoneViewport = React.memo(props => { cleanUpServices(); + const viewportInfo = cornerstoneViewportService.getViewportInfoByIndex( + viewportIndex + ); + cornerstoneViewportService.disableElement(viewportIndex); if (onElementDisabled) { - const viewportInfo = cornerstoneViewportService.getViewportInfoByIndex( - viewportIndex - ); - onElementDisabled(viewportInfo); } diff --git a/extensions/cornerstone/src/initWADOImageLoader.js b/extensions/cornerstone/src/initWADOImageLoader.js index 2862aabb4..5727b16d3 100644 --- a/extensions/cornerstone/src/initWADOImageLoader.js +++ b/extensions/cornerstone/src/initWADOImageLoader.js @@ -61,8 +61,8 @@ export default function initWADOImageLoader( const xhrRequestHeaders = {} - if (headers && headers.Authorization) { - xhrRequestHeaders.Authorization = headers.Authorization; + if (headers) { + Object.assign(xhrRequestHeaders, headers); } return xhrRequestHeaders; diff --git a/extensions/default/package.json b/extensions/default/package.json index c2e884c98..42f80e23c 100644 --- a/extensions/default/package.json +++ b/extensions/default/package.json @@ -38,6 +38,7 @@ "react": "^17.0.2", "react-dom": "^17.0.2", "react-i18next": "^12.2.2", + "react-window": "^1.8.9", "webpack": "^5.50.0", "webpack-merge": "^5.7.3" }, diff --git a/extensions/default/src/CustomizeableContextMenu/ContextMenuController.tsx b/extensions/default/src/CustomizableContextMenu/ContextMenuController.tsx similarity index 100% rename from extensions/default/src/CustomizeableContextMenu/ContextMenuController.tsx rename to extensions/default/src/CustomizableContextMenu/ContextMenuController.tsx diff --git a/extensions/default/src/CustomizeableContextMenu/ContextMenuItemsBuilder.test.js b/extensions/default/src/CustomizableContextMenu/ContextMenuItemsBuilder.test.js similarity index 100% rename from extensions/default/src/CustomizeableContextMenu/ContextMenuItemsBuilder.test.js rename to extensions/default/src/CustomizableContextMenu/ContextMenuItemsBuilder.test.js diff --git a/extensions/default/src/CustomizeableContextMenu/ContextMenuItemsBuilder.ts b/extensions/default/src/CustomizableContextMenu/ContextMenuItemsBuilder.ts similarity index 100% rename from extensions/default/src/CustomizeableContextMenu/ContextMenuItemsBuilder.ts rename to extensions/default/src/CustomizableContextMenu/ContextMenuItemsBuilder.ts diff --git a/extensions/default/src/CustomizeableContextMenu/defaultContextMenu.ts b/extensions/default/src/CustomizableContextMenu/defaultContextMenu.ts similarity index 100% rename from extensions/default/src/CustomizeableContextMenu/defaultContextMenu.ts rename to extensions/default/src/CustomizableContextMenu/defaultContextMenu.ts diff --git a/extensions/default/src/CustomizeableContextMenu/index.ts b/extensions/default/src/CustomizableContextMenu/index.ts similarity index 75% rename from extensions/default/src/CustomizeableContextMenu/index.ts rename to extensions/default/src/CustomizableContextMenu/index.ts index 7dc08dd3d..d0abca069 100644 --- a/extensions/default/src/CustomizeableContextMenu/index.ts +++ b/extensions/default/src/CustomizableContextMenu/index.ts @@ -1,11 +1,11 @@ import ContextMenuController from './ContextMenuController'; import * as ContextMenuItemsBuilder from './ContextMenuItemsBuilder'; import defaultContextMenu from './defaultContextMenu'; -import * as CustomizeableContextMenuTypes from './types'; +import * as CustomizableContextMenuTypes from './types'; export { ContextMenuController, - CustomizeableContextMenuTypes, + CustomizableContextMenuTypes, ContextMenuItemsBuilder, defaultContextMenu, }; diff --git a/extensions/default/src/CustomizeableContextMenu/types.ts b/extensions/default/src/CustomizableContextMenu/types.ts similarity index 100% rename from extensions/default/src/CustomizeableContextMenu/types.ts rename to extensions/default/src/CustomizableContextMenu/types.ts diff --git a/extensions/default/src/DicomTagBrowser/DicomTagBrowser.tsx b/extensions/default/src/DicomTagBrowser/DicomTagBrowser.tsx index 281e545fb..57cf04fa8 100644 --- a/extensions/default/src/DicomTagBrowser/DicomTagBrowser.tsx +++ b/extensions/default/src/DicomTagBrowser/DicomTagBrowser.tsx @@ -1,27 +1,41 @@ import dcmjs from 'dcmjs'; import moment from 'moment'; -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useEffect, useRef } from 'react'; import { classes } from '@ohif/core'; +import { Icon, InputRange, Select, Typography } from '@ohif/ui'; +import debounce from 'lodash.debounce'; +import classNames from 'classnames'; + import DicomTagTable from './DicomTagTable'; import './DicomTagBrowser.css'; -import { InputRange, Select, Typography } from '@ohif/ui'; const { ImageSet } = classes; const { DicomMetaDictionary } = dcmjs.data; const { nameMap } = DicomMetaDictionary; const DicomTagBrowser = ({ displaySets, displaySetInstanceUID }) => { + // The column indices that are to be excluded during a filter of the table. + // At present the column indices are: + // 0: DICOM tag + // 1: VR + // 2: Keyword + // 3: Value + const excludedColumnIndicesForFilter: Set = new Set([1]); + const [ selectedDisplaySetInstanceUID, setSelectedDisplaySetInstanceUID, ] = useState(displaySetInstanceUID); const [instanceNumber, setInstanceNumber] = useState(1); + const [filterValue, setFilterValue] = useState(''); const onSelectChange = value => { setSelectedDisplaySetInstanceUID(value.value); setInstanceNumber(1); }; + const searchInputRef = useRef(null); + const activeDisplaySet = displaySets.find( ds => ds.displaySetInstanceUID === selectedDisplaySetInstanceUID ); @@ -54,64 +68,130 @@ const DicomTagBrowser = ({ displaySets, displaySetInstanceUID }) => { }); }, [displaySets]); + const rows = useMemo(() => { + let metadata; + if (isImageStack) { + metadata = activeDisplaySet.images[instanceNumber - 1]; + } else { + metadata = activeDisplaySet.instance || activeDisplaySet; + } + const tags = getSortedTags(metadata); + return getFormattedRowsFromTags(tags, metadata); + }, [instanceNumber, selectedDisplaySetInstanceUID]); + + const filteredRows = useMemo(() => { + if (!filterValue) { + return rows; + } + + const filterValueLowerCase = filterValue.toLowerCase(); + return rows.filter(row => { + return row.reduce((keepRow, col, colIndex) => { + if (keepRow) { + // We are already keeping the row, why do more work so return now. + return keepRow; + } + + if (excludedColumnIndicesForFilter.has(colIndex)) { + return keepRow; + } + + return keepRow || col.toLowerCase().includes(filterValueLowerCase); + }, false); + }); + }, [rows, filterValue]); + + const debouncedSetFilterValue = useMemo(() => { + return debounce(setFilterValue, 200); + }, []); + + useEffect(() => { + return () => { + debouncedSetFilterValue?.cancel(); + }; + }, []); + return (
-
- - Series - - {showInstanceList && ( - - Instance Number +
+
+ + Series - )} -
-
-
- ds.value === selectedDisplaySetInstanceUID + )} + className="text-white" />
- ) : null} +
+
+ {showInstanceList && ( + + Instance Number + + )} + {showInstanceList && ( +
+ { + setInstanceNumber(parseInt(value)); + }} + minValue={1} + maxValue={activeDisplaySet.images.length} + step={1} + inputClassName="w-full" + labelPosition="left" + trackColor={'#3a3f99'} + /> +
+ )} +
- +
+
+ {/* TODO - refactor the following into its own reusable component */} + +
+
); }; -function getFormattedRowsFromTags(displaySet, instanceNumber) { - const isImageStack = _isImageStack(displaySet); - - let metadata; - - if (isImageStack) { - metadata = displaySet.images[instanceNumber - 1]; - } else { - metadata = displaySet; - } - - const tags = getSortedTags(metadata); +function getFormattedRowsFromTags(tags, metadata) { const rows = []; tags.forEach(tagInfo => { @@ -126,7 +206,7 @@ function getFormattedRowsFromTags(displaySet, instanceNumber) { const { values } = tagInfo; values.forEach((item, index) => { - const formatedRowsFromTags = getFormattedRowsFromTags(item); + const formatedRowsFromTags = getFormattedRowsFromTags(item, metadata); rows.push([ `${item[0].tagIndent}(FFFE,E000)`, @@ -140,34 +220,21 @@ function getFormattedRowsFromTags(displaySet, instanceNumber) { } else { if (tagInfo.vr === 'xs') { try { - /* const dataset = metadataProvider.getStudyDataset( - meta.StudyInstanceUID - );*/ - // console.log(dataset); - // const tag = dcmjs.data.Tag.fromPString(tagInfo.tag).toCleanString(); - // const originalTagInfo = dataset[tag]; - // tagInfo.vr = originalTagInfo.vr; + const tag = dcmjs.data.Tag.fromPString(tagInfo.tag).toCleanString(); + const originalTagInfo = metadata[tag]; + tagInfo.vr = originalTagInfo.vr; } catch (error) { console.error( `Failed to parse value representation for tag '${tagInfo.keyword}'` ); } } - if (tagInfo.vr === 'PN') { - rows.push([ - `${tagInfo.tagIndent}${tagInfo.tag}`, - tagInfo.vr, - tagInfo.keyword, - tagInfo.value, - ]); - } else { - rows.push([ - `${tagInfo.tagIndent}${tagInfo.tag}`, - tagInfo.vr, - tagInfo.keyword, - tagInfo.value, - ]); - } + rows.push([ + `${tagInfo.tagIndent}${tagInfo.tag}`, + tagInfo.vr, + tagInfo.keyword, + tagInfo.value, + ]); } }); diff --git a/extensions/default/src/DicomTagBrowser/DicomTagTable.tsx b/extensions/default/src/DicomTagBrowser/DicomTagTable.tsx index 5a67bbae9..982af013b 100644 --- a/extensions/default/src/DicomTagBrowser/DicomTagTable.tsx +++ b/extensions/default/src/DicomTagBrowser/DicomTagTable.tsx @@ -1,97 +1,224 @@ -import React from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { VariableSizeList as List } from 'react-window'; +import classNames from 'classnames'; +import debounce from 'lodash.debounce'; -function ColumnHeaders() { +const lineHeightPx = 20; +const lineHeightClassName = `leading-[${lineHeightPx}px]`; +const rowVerticalPaddingPx = 10; +const rowBottomBorderPx = 1; +const rowVerticalPaddingStyle = { padding: `${rowVerticalPaddingPx}px 0` }; +const rowStyle = { + borderBottomWidth: `${rowBottomBorderPx}px`, + ...rowVerticalPaddingStyle, +}; + +function ColumnHeaders({ tagRef, vrRef, keywordRef, valueRef }) { return ( -
-
-
- -
-
- -
-
- -
-
- -
+
+
+ +
+
+ +
+
+ +
+
+
); } function DicomTagTable({ rows }) { + const listRef = useRef(); + const canvasRef = useRef(); + + const [tagHeaderElem, setTagHeaderElem] = useState(null); + const [vrHeaderElem, setVrHeaderElem] = useState(null); + const [keywordHeaderElem, setKeywordHeaderElem] = useState(null); + const [valueHeaderElem, setValueHeaderElem] = useState(null); + + // Here the refs are inturn stored in state to trigger a render of the table. + // This virtualized table does NOT render until the header is rendered because the header column widths are used to determine the row heights in the table. + // Therefore whenever the refs change (in particular the first time the refs are set), we want to trigger a render of the table. + const tagRef = elem => { + if (elem) { + setTagHeaderElem(elem); + } + }; + const vrRef = elem => { + if (elem) { + setVrHeaderElem(elem); + } + }; + const keywordRef = elem => { + if (elem) { + setKeywordHeaderElem(elem); + } + }; + const valueRef = elem => { + if (elem) { + setValueHeaderElem(elem); + } + }; + + /** + * When new rows are set, scroll to the top and reset the virtualization. + */ + useEffect(() => { + if (!listRef?.current) { + return; + } + + listRef.current.scrollTo(0); + listRef.current.resetAfterIndex(0); + }, [rows]); + + /** + * When the browser window resizes, update the row virtualization (i.e. row heights) + */ + useEffect(() => { + const debouncedResize = debounce( + () => listRef.current.resetAfterIndex(0), + 100 + ); + + window.addEventListener('resize', debouncedResize); + + return () => { + debouncedResize.cancel(); + window.removeEventListener('resize', debouncedResize); + }; + }, []); + + const Row = useCallback( + ({ index, style }) => { + const row = rows[index]; + + return ( +
+
{row[0]}
+
{row[1]}
+
{row[2]}
+
{row[3]}
+
+ ); + }, + [rows] + ); + + /** + * Whenever any one of the column headers is set, then the header is rendered. + * Here we chose the tag header. + */ + const isHeaderRendered = useCallback(() => tagHeaderElem !== null, [ + tagHeaderElem, + ]); + + /** + * Get the item/row size. We use the header column widths to calculate the various row heights. + * @param index the row index + * @returns the row height + */ + const getItemSize = useCallback( + index => { + const headerWidths = [ + tagHeaderElem.offsetWidth, + vrHeaderElem.offsetWidth, + keywordHeaderElem.offsetWidth, + valueHeaderElem.offsetWidth, + ]; + + const context = canvasRef.current.getContext('2d'); + context.font = getComputedStyle(canvasRef.current).font; + + return rows[index] + .map((colText, index) => { + const colOneLineWidth = context.measureText(colText).width; + const numLines = Math.ceil(colOneLineWidth / headerWidths[index]); + return ( + numLines * lineHeightPx + + 2 * rowVerticalPaddingPx + + rowBottomBorderPx + ); + }) + .reduce((maxHeight, colHeight) => Math.max(maxHeight, colHeight)); + }, + [rows, keywordHeaderElem, tagHeaderElem, valueHeaderElem, vrHeaderElem] + ); + return (
- {ColumnHeaders()} + +
- - - {rows.map((row, index) => { - const className = row.className ? row.className : null; - - return ( - - - - - - - ); - })} - -
-
-
{row[0]}
-
-
-
-
{row[1]}
-
-
-
-
{row[2]}
-
-
-
-
{row[3]}
-
-
+ {isHeaderRendered() && ( + + {Row} + + )}
); diff --git a/extensions/default/src/DicomWebDataSource/index.js b/extensions/default/src/DicomWebDataSource/index.js index e68e9bc50..ca3482e7d 100644 --- a/extensions/default/src/DicomWebDataSource/index.js +++ b/extensions/default/src/DicomWebDataSource/index.js @@ -24,6 +24,7 @@ import { } from './retrieveStudyMetadata.js'; import StaticWadoClient from './utils/StaticWadoClient'; import getDirectURL from '../utils/getDirectURL'; +import { fixBulkDataURI } from './utils/fixBulkDataURI'; const { DicomMetaDictionary, DicomDict } = dcmjs.data; @@ -432,13 +433,23 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) { */ const addRetrieveBulkData = instance => { const naturalized = naturalizeDataset(instance); + + // if we konw the server doesn't use bulkDataURI, then don't + if (!dicomWebConfig.bulkDataURI?.enabled) { + return naturalized; + } + Object.keys(naturalized).forEach(key => { const value = naturalized[key]; + // The value.Value will be set with the bulkdata read value // in which case it isn't necessary to re-read this. if (value && value.BulkDataURI && !value.Value) { // Provide a method to fetch bulkdata value.retrieveBulkData = () => { + // handle the scenarios where bulkDataURI is relative path + fixBulkDataURI(value, naturalized, dicomWebConfig); + const options = { // The bulkdata fetches work with either multipart or // singlepart, so set multipart to false to let the server diff --git a/extensions/default/src/DicomWebDataSource/utils/fixBulkDataURI.ts b/extensions/default/src/DicomWebDataSource/utils/fixBulkDataURI.ts new file mode 100644 index 000000000..1a408b597 --- /dev/null +++ b/extensions/default/src/DicomWebDataSource/utils/fixBulkDataURI.ts @@ -0,0 +1,56 @@ +/** + * Modifies a bulkDataURI to ensure it is absolute based on the DICOMWeb configuration and + * instance data. The modification is in-place. + * + * If the bulkDataURI is relative to the series or study (according to the DICOM standard), + * it is made absolute by prepending the relevant paths. + * + * In scenarios where the bulkDataURI is a server-relative path (starting with '/'), the function + * handles two cases: + * + * 1. If the wado root is absolute (starts with 'http'), it prepends the wado root to the bulkDataURI. + * 2. If the wado root is relative, no changes are needed as the bulkDataURI is already correctly relative to the server root. + * + * @param value - The object containing BulkDataURI to be fixed. + * @param instance - The object (DICOM instance data) containing StudyInstanceUID and SeriesInstanceUID. + * @param dicomWebConfig - The DICOMWeb configuration object, containing wadoRoot and potentially bulkDataURI.relativeResolution. + * @returns The function modifies `value` in-place, it does not return a value. + */ +function fixBulkDataURI(value, instance, dicomWebConfig) { + // in case of the relative path, make it absolute. The current DICOM standard says + // the bulkdataURI is relative to the series. However, there are situations where + // it can be relative to the study too + if ( + !value.BulkDataURI.startsWith('http') && + !value.BulkDataURI.startsWith('/') + ) { + if (dicomWebConfig.bulkDataURI?.relativeResolution === 'studies') { + value.BulkDataURI = `${dicomWebConfig.wadoRoot}/studies/${instance.StudyInstanceUID}/${value.BulkDataURI}`; + } else if ( + dicomWebConfig.bulkDataURI?.relativeResolution === 'series' || + !dicomWebConfig.bulkDataURI?.relativeResolution + ) { + value.BulkDataURI = `${dicomWebConfig.wadoRoot}/studies/${instance.StudyInstanceUID}/series/${instance.SeriesInstanceUID}/${value.BulkDataURI}`; + } + + return; + } + + // in case it is relative path but starts at the server (e.g., /bulk/1e, note the missing http + // in the beginning and the first character is /) There are two scenarios, whether the wado root + // is absolute or relative. In case of absolute, we need to prepend the wado root to the bulkdata + // uri (e.g., bulkData: /bulk/1e, wado root: http://myserver.com/dicomweb, output: http://myserver.com/bulk/1e) + // and in case of relative wado root, we need to prepend the bulkdata uri to the wado root (e.g,. bulkData: /bulk/1e + // wado root: /dicomweb, output: /bulk/1e) + if (value.BulkDataURI[0] === '/') { + if (dicomWebConfig.wadoRoot.startsWith('http')) { + // Absolute wado root + const url = new URL(dicomWebConfig.wadoRoot); + value.BulkDataURI = `${url.origin}${value.BulkDataURI}`; + } else { + // Relative wado root, we don't need to do anything, bulkdata uri is already correct + } + } +} + +export { fixBulkDataURI }; diff --git a/extensions/default/src/DicomWebDataSource/utils/index.ts b/extensions/default/src/DicomWebDataSource/utils/index.ts new file mode 100644 index 000000000..2132253fe --- /dev/null +++ b/extensions/default/src/DicomWebDataSource/utils/index.ts @@ -0,0 +1,3 @@ +import { fixBulkDataURI } from './fixBulkDataURI'; + +export { fixBulkDataURI }; diff --git a/extensions/default/src/commandsModule.ts b/extensions/default/src/commandsModule.ts index 238cd37ff..958f34b60 100644 --- a/extensions/default/src/commandsModule.ts +++ b/extensions/default/src/commandsModule.ts @@ -3,14 +3,14 @@ import { ServicesManager, utils, Types } from '@ohif/core'; import { ContextMenuController, defaultContextMenu, -} from './CustomizeableContextMenu'; +} from './CustomizableContextMenu'; import DicomTagBrowser from './DicomTagBrowser/DicomTagBrowser'; import reuseCachedLayouts from './utils/reuseCachedLayouts'; import findViewportsByPosition, { findOrCreateViewport as layoutFindOrCreate, } from './findViewportsByPosition'; -import { ContextMenuProps } from './CustomizeableContextMenu/types'; +import { ContextMenuProps } from './CustomizableContextMenu/types'; import { NavigateHistory } from './types/commandModuleTypes'; import { history } from '@ohif/viewer'; @@ -23,6 +23,11 @@ export type HangingProtocolParams = { stageId?: string; }; +export type UpdateViewportDisplaySetParams = { + direction: number; + excludeNonImageModalities?: boolean; +}; + /** * Determine if a command is a hanging protocol one. * For now, just use the two hanging protocol commands that are in this @@ -541,6 +546,130 @@ const commandsModule = ({ overlays.item(i).classList.toggle('hidden'); } }, + + scrollActiveThumbnailIntoView: () => { + const { activeViewportIndex, viewports } = viewportGridService.getState(); + + if ( + !viewports || + activeViewportIndex < 0 || + activeViewportIndex > viewports.length - 1 + ) { + return; + } + + const activeViewport = viewports[activeViewportIndex]; + const activeDisplaySetInstanceUID = + activeViewport.displaySetInstanceUIDs[0]; + + const thumbnailList = document.querySelector('#ohif-thumbnail-list'); + + if (!thumbnailList) { + return; + } + + const thumbnailListBounds = thumbnailList.getBoundingClientRect(); + + const thumbnail = document.querySelector( + `#thumbnail-${activeDisplaySetInstanceUID}` + ); + + if (!thumbnail) { + return; + } + + const thumbnailBounds = thumbnail.getBoundingClientRect(); + + // This only handles a vertical thumbnail list. + if ( + thumbnailBounds.top >= thumbnailListBounds.top && + thumbnailBounds.top <= thumbnailListBounds.bottom + ) { + return; + } + + thumbnail.scrollIntoView({ behavior: 'smooth' }); + }, + + updateViewportDisplaySet: ({ + direction, + excludeNonImageModalities, + }: UpdateViewportDisplaySetParams) => { + const nonImageModalities = [ + 'SR', + 'SEG', + 'SM', + 'RTSTRUCT', + 'RTPLAN', + 'RTDOSE', + ]; + + // Sort the display sets as per the hanging protocol service viewport/display set scoring system. + // The thumbnail list uses the same sorting. + const dsSortFn = hangingProtocolService.getDisplaySetSortFunction(); + const currentDisplaySets = [...displaySetService.activeDisplaySets]; + + currentDisplaySets.sort(dsSortFn); + + const { activeViewportIndex, viewports } = viewportGridService.getState(); + + const { displaySetInstanceUIDs } = viewports[activeViewportIndex]; + + const activeDisplaySetIndex = currentDisplaySets.findIndex(displaySet => + displaySetInstanceUIDs.includes(displaySet.displaySetInstanceUID) + ); + + let displaySetIndexToShow: number; + + for ( + displaySetIndexToShow = activeDisplaySetIndex + direction; + displaySetIndexToShow > -1 && + displaySetIndexToShow < currentDisplaySets.length; + displaySetIndexToShow += direction + ) { + if ( + !excludeNonImageModalities || + !nonImageModalities.includes( + currentDisplaySets[displaySetIndexToShow].Modality + ) + ) { + break; + } + } + + if ( + displaySetIndexToShow < 0 || + displaySetIndexToShow >= currentDisplaySets.length + ) { + return; + } + + const { displaySetInstanceUID } = currentDisplaySets[ + displaySetIndexToShow + ]; + + let updatedViewports = []; + + try { + updatedViewports = hangingProtocolService.getViewportsRequireUpdate( + activeViewportIndex, + displaySetInstanceUID + ); + } catch (error) { + console.warn(error); + uiNotificationService.show({ + title: 'Navigate Viewport Display Set', + message: + 'The requested display sets could not be added to the viewport due to a mismatch in the Hanging Protocol rules.', + type: 'info', + duration: 3000, + }); + } + + viewportGridService.setDisplaySetsForViewports(updatedViewports); + + setTimeout(() => actions.scrollActiveThumbnailIntoView(), 0); + }, }; const definitions = { @@ -598,6 +727,11 @@ const commandsModule = ({ openDICOMTagViewer: { commandFn: actions.openDICOMTagViewer, }, + updateViewportDisplaySet: { + commandFn: actions.updateViewportDisplaySet, + storeContexts: [], + options: {}, + }, }; return { diff --git a/extensions/default/src/index.ts b/extensions/default/src/index.ts index c56f39b7e..2b023d415 100644 --- a/extensions/default/src/index.ts +++ b/extensions/default/src/index.ts @@ -13,8 +13,9 @@ import { id } from './id.js'; import preRegistration from './init'; import { ContextMenuController, - CustomizeableContextMenuTypes, -} from './CustomizeableContextMenu'; + CustomizableContextMenuTypes, +} from './CustomizableContextMenu'; +import * as dicomWebUtils from './DicomWebDataSource/utils'; const defaultExtension: Types.Extensions.Extension = { /** @@ -47,6 +48,7 @@ export default defaultExtension; export { ContextMenuController, - CustomizeableContextMenuTypes, + CustomizableContextMenuTypes, getStudiesForPatientByMRN, + dicomWebUtils, }; diff --git a/extensions/default/src/utils/getDirectURL.js b/extensions/default/src/utils/getDirectURL.js index 4580b8d30..c3905e9f2 100644 --- a/extensions/default/src/utils/getDirectURL.js +++ b/extensions/default/src/utils/getDirectURL.js @@ -1,10 +1,4 @@ -import { - DicomMetadataStore, - IWebApiDataSource, - utils, - errorHandler, - classes, -} from '@ohif/core'; +import { utils } from '@ohif/core'; /** * Generates a URL that can be used for direct retrieve of the bulkdata @@ -57,31 +51,19 @@ const getDirectURL = (config, params) => { const BulkDataURI = (value && value.BulkDataURI) || `series/${SeriesInstanceUID}/instances/${SOPInstanceUID}${defaultPath}`; - const hasQuery = BulkDataURI.indexOf('?') != -1; - const hasAccept = BulkDataURI.indexOf('accept=') != -1; + const hasQuery = BulkDataURI.indexOf('?') !== -1; + const hasAccept = BulkDataURI.indexOf('accept=') !== -1; const acceptUri = BulkDataURI + (hasAccept ? '' : (hasQuery ? '&' : '?') + `accept=${defaultType}`); - if (BulkDataURI.indexOf('http') === 0) { - if (tag === 'PixelData' || tag === 'EncapsulatedDocument') { - return `${wadoRoot}/studies/${StudyInstanceUID}/series/${SeriesInstanceUID}/instances/${SOPInstanceUID}/rendered`; - } else { - return acceptUri; - } + + if (tag === 'PixelData' || tag === 'EncapsulatedDocument') { + return `${wadoRoot}/studies/${StudyInstanceUID}/series/${SeriesInstanceUID}/instances/${SOPInstanceUID}/rendered`; } - if (BulkDataURI.indexOf('/') === 0) { - return wadoRoot + acceptUri; - } - if (BulkDataURI.indexOf('series/') == 0) { - return `${wadoRoot}/studies/${StudyInstanceUID}/${acceptUri}`; - } - if (BulkDataURI.indexOf('instances/') === 0) { - return `${wadoRoot}/studies/${StudyInstanceUID}/series/${SeriesInstanceUID}/${acceptUri}`; - } - if (BulkDataURI.indexOf('bulkdata/') === 0) { - return `${wadoRoot}/studies/${StudyInstanceUID}/${acceptUri}`; - } - throw new Error('BulkDataURI in unknown format:' + BulkDataURI); + + // The DICOMweb standard states that the default is multipart related, and then + // separately states that the accept parameter is the URL parameter equivalent of the accept header. + return acceptUri; }; export default getDirectURL; diff --git a/extensions/dicom-microscopy/src/DicomMicroscopyViewport.css b/extensions/dicom-microscopy/src/DicomMicroscopyViewport.css index 7e10bb501..db3ee2455 100644 --- a/extensions/dicom-microscopy/src/DicomMicroscopyViewport.css +++ b/extensions/dicom-microscopy/src/DicomMicroscopyViewport.css @@ -1,3 +1,10 @@ +.DicomMicroscopyViewer { + --ol-partial-background-color: rgba(127, 127, 127, 0.7); + --ol-foreground-color: #000000; + --ol-subtle-foreground-color: #000; + --ol-subtle-background-color: rgba(78, 78, 78, 0.5); +} + .DicomMicroscopyViewer .ol-box { box-sizing: border-box; border-radius: 2px; @@ -332,7 +339,7 @@ } .DicomMicroscopyViewer .ol-overviewmap-box { - border: 1.5px dotted var(--ol-subtle-foreground-color); + border: 0.5px dotted var(--ol-subtle-foreground-color); } .DicomMicroscopyViewer .ol-overviewmap .ol-overviewmap-box:hover { diff --git a/extensions/dicom-microscopy/src/DicomMicroscopyViewport.tsx b/extensions/dicom-microscopy/src/DicomMicroscopyViewport.tsx index 329760604..dc794a896 100644 --- a/extensions/dicom-microscopy/src/DicomMicroscopyViewport.tsx +++ b/extensions/dicom-microscopy/src/DicomMicroscopyViewport.tsx @@ -2,6 +2,7 @@ import React, { Component } from 'react'; import ReactResizeDetector from 'react-resize-detector'; import PropTypes from 'prop-types'; import debounce from 'lodash.debounce'; +import { LoadingIndicatorProgress } from '@ohif/ui'; import './DicomMicroscopyViewport.css'; import ViewportOverlay from './components/ViewportOverlay'; @@ -10,9 +11,20 @@ import dcmjs from 'dcmjs'; import cleanDenaturalizedDataset from './utils/cleanDenaturalizedDataset'; import MicroscopyService from './services/MicroscopyService'; +function transformImageTypeUnnaturalized(entry) { + if (entry.vr === 'CS') { + return { + vr: 'US', + Value: entry.Value[0].split('\\'), + }; + } + return entry; +} + class DicomMicroscopyViewport extends Component { state = { error: null as any, + isLoaded: false, }; microscopyService: MicroscopyService; @@ -132,6 +144,10 @@ class DicomMicroscopyViewport extends Component { // ); // m['00200052'].Value[0] = volumeImages[0].FrameOfReferenceUID; // } + // NOTE: depending on different data source, image.ImageType sometimes + // is a string, not a string array. + // m['00080008'] = transformImageTypeUnnaturalized(m['00080008']); + // const image = new metadataUtils.VLWholeSlideMicroscopyImage({ // metadata: m, // }); @@ -143,8 +159,20 @@ class DicomMicroscopyViewport extends Component { // } metadata.forEach(m => { + // NOTE: depending on different data source, image.ImageType sometimes + // is a string, not a string array. + m.ImageType = + typeof m.ImageType === 'string' + ? m.ImageType.split('\\') + : m.ImageType; + const inst = cleanDenaturalizedDataset( - dcmjs.data.DicomMetaDictionary.denaturalizeDataset(m) + dcmjs.data.DicomMetaDictionary.denaturalizeDataset(m), + { + StudyInstanceUID: m.StudyInstanceUID, + SeriesInstanceUID: m.SeriesInstanceUID, + dataSourceConfig: this.props.dataSource.getConfig(), + } ); if (!inst['00480105']) { // Optical Path Sequence, no OpticalPathIdentifier? @@ -164,6 +192,7 @@ class DicomMicroscopyViewport extends Component { const image = new metadataUtils.VLWholeSlideMicroscopyImage({ metadata: inst, }); + const imageFlavor = image.ImageType[2]; if (imageFlavor === 'VOLUME' || imageFlavor === 'THUMBNAIL') { volumeImages.push(image); @@ -175,7 +204,7 @@ class DicomMicroscopyViewport extends Component { client, metadata: volumeImages, retrieveRendered: false, - controls: ['overview', 'position', 'zoom'], + controls: ['overview', 'position'], }; this.viewer = new microscopyViewer(options); @@ -230,7 +259,11 @@ class DicomMicroscopyViewport extends Component { componentDidMount() { const { displaySets, viewportIndex } = this.props; const displaySet = displaySets[viewportIndex]; - this.installOpenLayersRenderer(this.container.current, displaySet); + this.installOpenLayersRenderer(this.container.current, displaySet).then( + () => { + this.setState({ isLoaded: true }); + } + ); } componentDidUpdate( @@ -309,6 +342,9 @@ class DicomMicroscopyViewport extends Component { ) : (
)} + {this.state.isLoaded ? null : ( + + )}
); } diff --git a/extensions/dicom-microscopy/src/components/ViewportOverlay/index.tsx b/extensions/dicom-microscopy/src/components/ViewportOverlay/index.tsx index bbe863e71..e4bb67424 100644 --- a/extensions/dicom-microscopy/src/components/ViewportOverlay/index.tsx +++ b/extensions/dicom-microscopy/src/components/ViewportOverlay/index.tsx @@ -1,6 +1,5 @@ import React from 'react'; import classnames from 'classnames'; -import ConfigPoint from 'config-point'; import listComponentGenerator from './listComponentGenerator'; import './ViewportOverlay.css'; @@ -11,7 +10,7 @@ import { formatPN, } from './utils'; -interface OverylayItem { +interface OverlayItem { id: string; title: string; value?: (props: any) => string; @@ -28,17 +27,17 @@ interface OverylayItem { * @returns */ export const generateFromConfig = ({ - topLeft, - topRight, - bottomLeft, - bottomRight, - itemGenerator, + topLeft = [], + topRight = [], + bottomLeft = [], + bottomRight = [], + itemGenerator = () => {}, }: { - topLeft: OverylayItem[]; - topRight: OverylayItem[]; - bottomLeft: OverylayItem[]; - bottomRight: OverylayItem[]; - itemGenerator: (props: any) => any; + topLeft?: OverlayItem[]; + topRight?: OverlayItem[]; + bottomLeft?: OverlayItem[]; + bottomRight?: OverlayItem[]; + itemGenerator?: (props: any) => any; }) => { return (props: any) => { const topLeftClass = 'top-viewport left-viewport text-primary-light'; @@ -127,29 +126,4 @@ const itemGenerator = (props: any) => { ); }; -const { MicroscopyViewportOverlay } = ConfigPoint.register({ - MicroscopyViewportOverlay: { - configBase: { - topLeft: [ - // { - // id: 'sm-overlay-patient-name', - // title: 'PatientName', - // condition: ({ instance }) => - // instance && instance.PatientName && instance.PatientName.Alphabetic, - // value: ({ instance }) => - // instance.PatientName && instance.PatientName.Alphabetic, - // } as OverylayItem, - ], - topRight: [] as OverylayItem[], - bottomLeft: [] as OverylayItem[], - bottomRight: [] as OverylayItem[], - itemGenerator, - generateFromConfig, - }, - ...(window.config?.MicroscopyViewportOverlay || {}), - }, -}); - -export default MicroscopyViewportOverlay.generateFromConfig( - MicroscopyViewportOverlay -); +export default generateFromConfig({}); diff --git a/extensions/dicom-microscopy/src/utils/cleanDenaturalizedDataset.ts b/extensions/dicom-microscopy/src/utils/cleanDenaturalizedDataset.ts index 91d72e3e2..43abcaf22 100644 --- a/extensions/dicom-microscopy/src/utils/cleanDenaturalizedDataset.ts +++ b/extensions/dicom-microscopy/src/utils/cleanDenaturalizedDataset.ts @@ -1,3 +1,5 @@ +import { dicomWebUtils } from '@ohif/extension-default'; + function isPrimitive(v: any) { return !(typeof v == 'object' || Array.isArray(v)); } @@ -24,10 +26,17 @@ const vrNumerics = [ * @param obj * @returns */ -export default function cleanDenaturalizedDataset(obj: any): any { +export default function cleanDenaturalizedDataset( + obj: any, + options: { + StudyInstanceUID: string; + SeriesInstanceUID: string; + dataSourceConfig: unknown; + } +): any { if (Array.isArray(obj)) { const newAry = obj.map(o => - isPrimitive(o) ? o : cleanDenaturalizedDataset(o) + isPrimitive(o) ? o : cleanDenaturalizedDataset(o, options) ); return newAry; } else if (isPrimitive(obj)) { @@ -38,6 +47,12 @@ export default function cleanDenaturalizedDataset(obj: any): any { delete obj[key].Value; } else if (Array.isArray(obj[key].Value) && obj[key].vr) { if (obj[key].Value.length === 1 && obj[key].Value[0].BulkDataURI) { + dicomWebUtils.fixBulkDataURI( + obj[key].Value[0], + options, + options.dataSourceConfig + ); + obj[key].BulkDataURI = obj[key].Value[0].BulkDataURI; // prevent mixed-content blockage @@ -54,7 +69,9 @@ export default function cleanDenaturalizedDataset(obj: any): any { } else if (vrNumerics.includes(obj[key].vr)) { obj[key].Value = obj[key].Value.map(v => +v); } else { - obj[key].Value = obj[key].Value.map(cleanDenaturalizedDataset); + obj[key].Value = obj[key].Value.map(entry => + cleanDenaturalizedDataset(entry, options) + ); } } }); diff --git a/extensions/dicom-microscopy/src/utils/dicomWebClient.ts b/extensions/dicom-microscopy/src/utils/dicomWebClient.ts index 8174d6c26..bb8b1f2e7 100644 --- a/extensions/dicom-microscopy/src/utils/dicomWebClient.ts +++ b/extensions/dicom-microscopy/src/utils/dicomWebClient.ts @@ -1,6 +1,12 @@ import { api } from 'dicomweb-client'; import { errorHandler, DicomMetadataStore } from '@ohif/core'; +const { DICOMwebClient } = api; + +DICOMwebClient._buildMultipartAcceptHeaderFieldValue = () => { + return '*/*'; +}; + /** * create a DICOMwebClient object to be used by Dicom Microscopy Viewer * diff --git a/extensions/dicom-video/src/viewports/OHIFCornerstoneVideoViewport.tsx b/extensions/dicom-video/src/viewports/OHIFCornerstoneVideoViewport.tsx index c6aa80b2f..0b9a92d49 100644 --- a/extensions/dicom-video/src/viewports/OHIFCornerstoneVideoViewport.tsx +++ b/extensions/dicom-video/src/viewports/OHIFCornerstoneVideoViewport.tsx @@ -29,6 +29,7 @@ function OHIFCornerstoneVideoViewport({ displaySets }) { controlsList="nodownload" preload="auto" className="w-full h-full" + crossOrigin="anonymous" > diff --git a/extensions/measurement-tracking/package.json b/extensions/measurement-tracking/package.json index 8b71f466e..7445f8720 100644 --- a/extensions/measurement-tracking/package.json +++ b/extensions/measurement-tracking/package.json @@ -32,8 +32,8 @@ "peerDependencies": { "@ohif/core": "^3.0.0", "classnames": "^2.3.2", - "@cornerstonejs/core": "^0.47.1", - "@cornerstonejs/tools": "^0.67.4", + "@cornerstonejs/core": "^1.1.0", + "@cornerstonejs/tools": "^1.1.0", "@ohif/extension-cornerstone-dicom-sr": "^3.0.0", "dcmjs": "^0.29.5", "lodash.debounce": "^4.17.21", diff --git a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx index 9cc9ab2a7..171a27bff 100644 --- a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx +++ b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx @@ -292,7 +292,8 @@ function PanelStudyBrowserTracking({ const tabs = _createStudyBrowserTabs( StudyInstanceUIDs, studyDisplayList, - displaySets + displaySets, + hangingProtocolService ); // TODO: Should not fire this on "close" @@ -601,7 +602,8 @@ function _getComponentType(Modality) { function _createStudyBrowserTabs( primaryStudyInstanceUIDs, studyDisplayList, - displaySets + displaySets, + hangingProtocolService ) { const primaryStudies = []; const recentStudies = []; @@ -615,9 +617,8 @@ function _createStudyBrowserTabs( ); // Sort them - const sortedDisplaySetsForStudy = utils.sortBySeriesDate( - displaySetsForStudy - ); + const dsSortFn = hangingProtocolService.getDisplaySetSortFunction(); + displaySetsForStudy.sort(dsSortFn); /* Sort by series number, then by series date displaySetsForStudy.sort((a, b) => { diff --git a/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx b/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx index 418d4aa97..81e796b87 100644 --- a/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx +++ b/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import PropTypes from 'prop-types'; import OHIF, { utils } from '@ohif/core'; @@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next'; import { annotation } from '@cornerstonejs/tools'; import { useTrackedMeasurements } from './../getContextModule'; +import { BaseVolumeViewport, Enums } from '@cornerstonejs/core'; const { formatDate } = utils; @@ -34,6 +35,7 @@ function TrackedCornerstoneViewport(props) { const [trackedMeasurements] = useTrackedMeasurements(); const [isTracked, setIsTracked] = useState(false); const [trackedMeasurementUID, setTrackedMeasurementUID] = useState(null); + const [viewportElem, setViewportElem] = useState(null); const { trackedSeries } = trackedMeasurements.context; const viewportId = viewportOptions.viewportId; @@ -55,6 +57,69 @@ function TrackedCornerstoneViewport(props) { ManufacturerModelName, } = displaySet.images[0]; + const updateIsTracked = useCallback(() => { + const viewport = cornerstoneViewportService.getCornerstoneViewportByIndex( + viewportIndex + ); + + if (viewport instanceof BaseVolumeViewport) { + // A current image id will only exist for volume viewports that can have measurements tracked. + // Typically these are those volume viewports for the series of acquisition. + const currentImageId = viewport?.getCurrentImageId(); + + if (!currentImageId) { + if (isTracked) { + setIsTracked(false); + } + return; + } + } + + if (trackedSeries.includes(SeriesInstanceUID) !== isTracked) { + setIsTracked(!isTracked); + } + }, [isTracked, trackedMeasurements, viewportIndex, SeriesInstanceUID]); + + const onElementEnabled = useCallback( + evt => { + if (evt.detail.element !== viewportElem) { + // The VOLUME_VIEWPORT_NEW_VOLUME event allows updateIsTracked to reliably fetch the image id for a volume viewport. + evt.detail.element?.addEventListener( + Enums.Events.VOLUME_VIEWPORT_NEW_VOLUME, + updateIsTracked + ); + setViewportElem(evt.detail.element); + } + }, + [updateIsTracked, viewportElem] + ); + + const onElementDisabled = useCallback(() => { + viewportElem?.removeEventListener( + Enums.Events.VOLUME_VIEWPORT_NEW_VOLUME, + updateIsTracked + ); + }, [updateIsTracked, viewportElem]); + + useEffect(updateIsTracked, [updateIsTracked]); + + useEffect(() => { + const { unsubscribe } = cornerstoneViewportService.subscribe( + cornerstoneViewportService.EVENTS.VIEWPORT_DATA_CHANGED, + props => { + if (props.viewportIndex !== viewportIndex) { + return; + } + + updateIsTracked(); + } + ); + + return () => { + unsubscribe(); + }; + }, [updateIsTracked, viewportIndex]); + useEffect(() => { if (isTracked) { annotation.config.style.setViewportToolStyles(viewportId, { @@ -83,19 +148,6 @@ function TrackedCornerstoneViewport(props) { }; }, [isTracked]); - // A current image id will only exist for viewports that can have measurements tracked. - // Typically these are stack viewports and those volume viewports for the series of acquisition. - const currentImageId = cornerstoneViewportService - .getCornerstoneViewport(viewportId) - ?.getCurrentImageId(); - if (currentImageId) { - if (trackedSeries.includes(SeriesInstanceUID) !== isTracked) { - setIsTracked(!isTracked); - } - } else if (isTracked) { - setIsTracked(false); - } - function switchMeasurement(direction) { const newTrackedMeasurementUID = _getNextMeasurementUID( direction, @@ -121,7 +173,13 @@ function TrackedCornerstoneViewport(props) { '@ohif/extension-cornerstone.viewportModule.cornerstone' ); - return ; + return ( + + ); }; return ( diff --git a/modes/tmtv/src/index.js b/modes/tmtv/src/index.js index e4cfc5c25..aae993bbd 100644 --- a/modes/tmtv/src/index.js +++ b/modes/tmtv/src/index.js @@ -189,16 +189,26 @@ function modeFactory({ modeConfiguration }) { study: [], series: [], }, - isValidMode: ({ modalities }) => { + isValidMode: ({ modalities, study }) => { const modalities_list = modalities.split('\\'); const invalidModalities = ['SM']; - // there should be both CT and PT modalities and the modality should not be SM - return ( + const isValid = modalities_list.includes('CT') && modalities_list.includes('PT') && - !invalidModalities.some(modality => modalities_list.includes(modality)) - ); + !invalidModalities.some(modality => + modalities_list.includes(modality) + ) && + // This is study is a 4D study with PT and CT and not a 3D study for the tmtv + // mode, until we have a better way to identify 4D studies we will use the + // StudyInstanceUID to identify the study + // Todo: when we add the 4D mode which comes with a mechanism to identify + // 4D studies we can use that + study.studyInstanceUid !== + '1.3.6.1.4.1.12842.1.1.14.3.20220915.105557.468.2963630849'; + + // there should be both CT and PT modalities and the modality should not be SM + return isValid; }, routes: [ { diff --git a/package.json b/package.json index 775bea023..747fd461e 100644 --- a/package.json +++ b/package.json @@ -147,7 +147,7 @@ ] }, "resolutions": { - "@cornerstonejs/core": "^0.47.1", + "@cornerstonejs/core": "^1.1.0", "**/@babel/runtime": "^7.20.13", "nth-check": "^2.1.1", "trim-newlines": "^5.0.0", diff --git a/platform/core/package.json b/platform/core/package.json index 5b028125f..1737926f7 100644 --- a/platform/core/package.json +++ b/platform/core/package.json @@ -32,7 +32,7 @@ }, "peerDependencies": { "cornerstone-math": "0.1.9", - "@cornerstonejs/dicom-image-loader": "^0.6.6", + "@cornerstonejs/dicom-image-loader": "^0.6.8", "@cornerstonejs/codec-charls": "^1.2.3", "@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2", "@cornerstonejs/codec-openjpeg": "^1.2.2", diff --git a/platform/core/src/defaults/hotkeyBindings.js b/platform/core/src/defaults/hotkeyBindings.js index 75023247c..4c12e2179 100644 --- a/platform/core/src/defaults/hotkeyBindings.js +++ b/platform/core/src/defaults/hotkeyBindings.js @@ -76,18 +76,24 @@ const bindings = [ keys: ['left'], isEditable: true, }, - // { - // commandName: 'nextViewportDisplaySet', - // label: 'Next Series', - // keys: ['pageup'], - // isEditable: true, - // }, - // { - // commandName: 'previousViewportDisplaySet', - // label: 'Previous Series', - // keys: ['pagedown'], - // isEditable: true, - // }, + { + commandName: 'updateViewportDisplaySet', + commandOptions: { + direction: -1, + }, + label: 'Previous Series', + keys: ['pageup'], + isEditable: true, + }, + { + commandName: 'updateViewportDisplaySet', + commandOptions: { + direction: 1, + }, + label: 'Next Series', + keys: ['pagedown'], + isEditable: true, + }, { commandName: 'nextStage', context: 'DEFAULT', diff --git a/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts b/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts index 3b6c540c2..4d3f72df0 100644 --- a/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts +++ b/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts @@ -1033,6 +1033,23 @@ export default class HangingProtocolService extends PubSubService { return this._matchViewport(useViewport, options); } + /** + * Gets a sort function that is consistent with the display set sorting performed + * to match display sets to viewports. + * @returns a display set sort function + */ + public getDisplaySetSortFunction(): ( + displaySetA: IDisplaySet, + displaySetB: IDisplaySet + ) => number { + return (displaySetA, displaySetB) => { + const seriesA = this._getSeriesSortInfoForDisplaySetSort(displaySetA); + const seriesB = this._getSeriesSortInfoForDisplaySetSort(displaySetB); + + return sortBy(this._getSeriesFieldForDisplaySetSort())(seriesA, seriesB); + }; + } + /** * Updates the viewports with the selected protocol stage. */ @@ -1461,7 +1478,7 @@ export default class HangingProtocolService extends PubSubService { sortingInfo: { score: totalMatchScore, study: study.StudyInstanceUID, - series: parseInt(displaySet.SeriesNumber), + ...this._getSeriesSortInfoForDisplaySetSort(displaySet), }, }; @@ -1484,9 +1501,7 @@ export default class HangingProtocolService extends PubSubService { name: 'study', reverse: true, }, - { - name: 'series', - } + this._getSeriesFieldForDisplaySetSort() ); matchingScores.sort((a, b) => sortingFunction(a.sortingInfo, b.sortingInfo) @@ -1506,6 +1521,19 @@ export default class HangingProtocolService extends PubSubService { }; } + private _getSeriesSortInfoForDisplaySetSort(displaySet) { + return { + [this._getSeriesFieldForDisplaySetSort().name]: + displaySet.SeriesNumber != null + ? parseInt(displaySet.SeriesNumber) + : parseInt(displaySet.seriesNumber), + }; + } + + private _getSeriesFieldForDisplaySetSort() { + return { name: 'series' }; + } + /** * Check if the next stage is available * @return {Boolean} True if next stage is available or false otherwise diff --git a/platform/docs/docs/configuration/dataSources/dicom-web.md b/platform/docs/docs/configuration/dataSources/dicom-web.md index 2736ffc9d..24b2c16b3 100644 --- a/platform/docs/docs/configuration/dataSources/dicom-web.md +++ b/platform/docs/docs/configuration/dataSources/dicom-web.md @@ -179,6 +179,24 @@ See the [`singlepart`](#singlepart) data source configuration option. ### DICOM Video See the [`singlepart`](#singlepart) data source configuration option. +### BulkDataURI + +The `bulkDataURI` configuration option allows the datasource to use the +bulkdata end points for retrieving metadata if originally was not included in the +response from the server. This is useful for the metadata information that +are big and can/should be retrieved in a separate request. In case the bulkData URI +is relative (instead of absolute) the `relativeResolution` option can be used to +specify the resolution of the relative URI. The possible values are `studies`, `series` and `instances`. +Certainly the knowledge of how the server is configured is required to use this option. + +```js +bulkDataURI: { + enabled: true, + relativeResolution: 'series', +}, +``` + + ### Running DCM4CHEE dcm4che is a collection of open source applications for healthcare enterprise diff --git a/platform/docs/docs/migration-guide.md b/platform/docs/docs/migration-guide.md index cad1b8851..f836c8607 100644 --- a/platform/docs/docs/migration-guide.md +++ b/platform/docs/docs/migration-guide.md @@ -209,6 +209,13 @@ see [custom routes](./platform/services/ui/customization-service.md#customroutes +## DICOM Endpoints + +In OHIF v3 there is a new end point that your DICOM server should be able to respond to +`WADO-RS GET studies/{studyInstanceUid}/series` + +This is used in the viewer for fetching the series list for a study to use for the hanging protocol. + ## LifeCycle Hooks OHIF v2 had `preRegistration` hook for extensions for initialization. In OHIF v3 you have diff --git a/platform/docs/docs/platform/services/ui/customization-service.md b/platform/docs/docs/platform/services/ui/customization-service.md index b59bf10d9..516df2d24 100644 --- a/platform/docs/docs/platform/services/ui/customization-service.md +++ b/platform/docs/docs/platform/services/ui/customization-service.md @@ -490,7 +490,7 @@ are specific to the context used for where the menu is displayed. The default cornerstone context menu can be customized by setting the `cornerstoneContextMenu`. For a full example, see `findingsContextMenu`. -## Customizeable Cornerstone Viewport Click Behaviour +## Customizable Cornerstone Viewport Click Behaviour The behaviour on clicking on the cornerstone viewport can be customized by setting the `cornerstoneViewportClickCommands`. This is intended to diff --git a/platform/ui/package.json b/platform/ui/package.json index 2cd2c17ad..d913dd104 100644 --- a/platform/ui/package.json +++ b/platform/ui/package.json @@ -47,6 +47,7 @@ "react-outside-click-handler": "^1.3.0", "react-select": "3.0.8", "react-with-direction": "^1.3.1", + "react-window": "^1.8.9", "swiper": "^8.4.2", "webpack": "^5.81.0" }, diff --git a/platform/ui/src/assets/icons/icon-clear-field.svg b/platform/ui/src/assets/icons/icon-clear-field.svg new file mode 100644 index 000000000..faf019500 --- /dev/null +++ b/platform/ui/src/assets/icons/icon-clear-field.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/platform/ui/src/assets/icons/icon-search.svg b/platform/ui/src/assets/icons/icon-search.svg new file mode 100644 index 000000000..2a960ddd3 --- /dev/null +++ b/platform/ui/src/assets/icons/icon-search.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/platform/ui/src/components/Icon/getIcon.js b/platform/ui/src/components/Icon/getIcon.js index 103bcef9e..34a64595f 100644 --- a/platform/ui/src/components/Icon/getIcon.js +++ b/platform/ui/src/components/Icon/getIcon.js @@ -53,12 +53,14 @@ import checkboxUnchecked from './../../assets/icons/checkbox-unchecked.svg'; import iconAlertOutline from './../../assets/icons/icons-alert-outline.svg'; import iconAlertSmall from './../../assets/icons/icon-alert-small.svg'; import iconClose from './../../assets/icons/icon-close.svg'; +import iconClearField from './../../assets/icons/icon-clear-field.svg'; import iconNextInactive from './../../assets/icons/icon-next-inactive.svg'; import iconNext from './../../assets/icons/icon-next.svg'; import iconPlay from './../../assets/icons/icon-play.svg'; import iconPause from './../../assets/icons/icon-pause.svg'; import iconPrevInactive from './../../assets/icons/icon-prev-inactive.svg'; import iconPrev from './../../assets/icons/icon-prev.svg'; +import iconSearch from './../../assets/icons/icon-search.svg'; import iconStatusAlert from './../../assets/icons/icon-status-alert.svg'; import iconTransferring from './../../assets/icons/icon-transferring.svg'; import iconUpload from './../../assets/icons/icon-upload.svg'; @@ -151,9 +153,11 @@ const ICONS = { info: info, 'icon-alert-outline': iconAlertOutline, 'icon-alert-small': iconAlertSmall, + 'icon-clear-field': iconClearField, 'icon-close': iconClose, 'icon-play': iconPlay, 'icon-pause': iconPause, + 'icon-search': iconSearch, 'icon-status-alert': iconStatusAlert, 'icon-transferring': iconTransferring, 'info-action': infoAction, diff --git a/platform/ui/src/components/InputRange/InputRange.css b/platform/ui/src/components/InputRange/InputRange.css index 41f23ca56..f9b00259b 100644 --- a/platform/ui/src/components/InputRange/InputRange.css +++ b/platform/ui/src/components/InputRange/InputRange.css @@ -5,8 +5,8 @@ input[type='range'] { input[type='range']::-webkit-slider-thumb { -webkit-appearance: none; border: none; - height: 10px; - width: 10px; + height: 13px; + width: 13px; border-radius: 50%; background: #5acce6; } @@ -14,8 +14,8 @@ input[type='range']::-webkit-slider-thumb { input[type='range']::-moz-range-thumb { -webkit-appearance: none; border: none; - height: 10px; - width: 10px; + height: 13px; + width: 13px; border-radius: 50%; background: #5acce6; } diff --git a/platform/ui/src/components/InputRange/InputRange.tsx b/platform/ui/src/components/InputRange/InputRange.tsx index 9bcf40b14..a8002af96 100644 --- a/platform/ui/src/components/InputRange/InputRange.tsx +++ b/platform/ui/src/components/InputRange/InputRange.tsx @@ -23,7 +23,9 @@ const InputRange: React.FC<{ inputClassName?: string; labelClassName?: string; labelVariant?: string; - showLabel: boolean; + showLabel?: boolean; + labelPosition?: string; + trackColor?: string; }> = ({ value, onChange, @@ -36,6 +38,8 @@ const InputRange: React.FC<{ labelClassName, labelVariant, showLabel = true, + labelPosition = '', + trackColor, }) => { const [rangeValue, setRangeValue] = useState(value); @@ -63,6 +67,16 @@ const InputRange: React.FC<{ containerClassName ? containerClassName : '' }`} > + {showLabel && labelPosition === 'left' && ( + + {rangeValueForStr} + {unit} + + )} - {showLabel && ( + {showLabel && (!labelPosition || labelPosition === 'right') && (
- +
{textBlock}
diff --git a/platform/ui/src/components/Thumbnail/Thumbnail.tsx b/platform/ui/src/components/Thumbnail/Thumbnail.tsx index dad5b6ea1..d7487d5ba 100644 --- a/platform/ui/src/components/Thumbnail/Thumbnail.tsx +++ b/platform/ui/src/components/Thumbnail/Thumbnail.tsx @@ -63,6 +63,7 @@ const Thumbnail = ({ src={imageSrc} alt={imageAltText} className="object-none min-h-32" + crossOrigin="anonymous" /> ) : (
{imageAltText}
diff --git a/platform/ui/src/components/ThumbnailList/ThumbnailList.tsx b/platform/ui/src/components/ThumbnailList/ThumbnailList.tsx index df2047cad..b1cb06416 100644 --- a/platform/ui/src/components/ThumbnailList/ThumbnailList.tsx +++ b/platform/ui/src/components/ThumbnailList/ThumbnailList.tsx @@ -12,7 +12,10 @@ const ThumbnailList = ({ activeDisplaySetInstanceUIDs = [], }) => { return ( -
+
{thumbnails.map( ({ displaySetInstanceUID, diff --git a/platform/ui/tailwind.config.js b/platform/ui/tailwind.config.js index 1bce56ab0..4d11d86b8 100644 --- a/platform/ui/tailwind.config.js +++ b/platform/ui/tailwind.config.js @@ -39,7 +39,7 @@ module.exports = { main: '#3a3f99', disabled: '#2b166b', focus: '#5acce6', - placeholder: '#39383f' + placeholder: '#39383f', }, secondary: { diff --git a/platform/viewer/package.json b/platform/viewer/package.json index 6ce2ff1b8..4a487787a 100644 --- a/platform/viewer/package.json +++ b/platform/viewer/package.json @@ -65,10 +65,9 @@ "@ohif/ui": "^2.0.0", "@types/react": "^17.0.38", "classnames": "^2.3.2", - "config-point": "^0.4.8", "core-js": "^3.16.1", "cornerstone-math": "^0.1.9", - "@cornerstonejs/dicom-image-loader": "^0.6.6", + "@cornerstonejs/dicom-image-loader": "^0.6.8", "@cornerstonejs/codec-charls": "^1.2.3", "@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2", "@cornerstonejs/codec-openjpeg": "^1.2.2", diff --git a/platform/viewer/public/config/default.js b/platform/viewer/public/config/default.js index 44570e3f1..8ff91875b 100644 --- a/platform/viewer/public/config/default.js +++ b/platform/viewer/public/config/default.js @@ -48,9 +48,9 @@ window.config = { // wadoRoot: 'https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs', // new server - wadoUriRoot: 'https://domvja9iplmyu.cloudfront.net/dicomweb', - qidoRoot: 'https://domvja9iplmyu.cloudfront.net/dicomweb', - wadoRoot: 'https://domvja9iplmyu.cloudfront.net/dicomweb', + wadoUriRoot: 'https://d33do7qe4w26qo.cloudfront.net/dicomweb', + qidoRoot: 'https://d33do7qe4w26qo.cloudfront.net/dicomweb', + wadoRoot: 'https://d33do7qe4w26qo.cloudfront.net/dicomweb', qidoSupportsIncludeField: false, supportsReject: false, @@ -60,9 +60,14 @@ window.config = { supportsFuzzyMatching: false, supportsWildcard: true, staticWado: true, - singlepart: 'bulkdata,video,pdf', - useBulkDataURI: false, - //requestTransferSyntaxUID: '1.2.840.10008.1.2.4.80' + singlepart: 'bulkdata,video', + // whether the data source should use retrieveBulkData to grab metadata, + // and in case of relative path, what would it be relative to, options + // are in the series level or study level (some servers like series some study) + bulkDataURI: { + enabled: true, + relativeResolution: 'studies', + }, }, }, { diff --git a/platform/viewer/public/config/demo.js b/platform/viewer/public/config/demo.js index 59f01cc9c..b4d43b861 100644 --- a/platform/viewer/public/config/demo.js +++ b/platform/viewer/public/config/demo.js @@ -16,13 +16,15 @@ window.config = { sourceName: 'dicomweb', configuration: { name: 'DCM4CHEE', - wadoUriRoot: 'https://domvja9iplmyu.cloudfront.net/dicomweb', - qidoRoot: 'https://domvja9iplmyu.cloudfront.net/dicomweb', - wadoRoot: 'https://domvja9iplmyu.cloudfront.net/dicomweb', + wadoUriRoot: 'https://d33do7qe4w26qo.cloudfront.net/dicomweb', + qidoRoot: 'https://d33do7qe4w26qo.cloudfront.net/dicomweb', + wadoRoot: 'https://d33do7qe4w26qo.cloudfront.net/dicomweb', qidoSupportsIncludeField: true, imageRendering: 'wadors', enableStudyLazyLoad: true, - useBulkDataURI: false, + bulkDataURI: { + enabled: false, + }, }, }, ], diff --git a/platform/viewer/public/config/e2e.js b/platform/viewer/public/config/e2e.js index a2b9ff8b5..47903374c 100644 --- a/platform/viewer/public/config/e2e.js +++ b/platform/viewer/public/config/e2e.js @@ -64,9 +64,9 @@ window.config = { // qidoRoot: 'https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs', // wadoRoot: 'https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs', // new server - wadoUriRoot: 'https://domvja9iplmyu.cloudfront.net/dicomweb', - qidoRoot: 'https://domvja9iplmyu.cloudfront.net/dicomweb', - wadoRoot: 'https://domvja9iplmyu.cloudfront.net/dicomweb', + wadoUriRoot: 'https://d33do7qe4w26qo.cloudfront.net/dicomweb', + qidoRoot: 'https://d33do7qe4w26qo.cloudfront.net/dicomweb', + wadoRoot: 'https://d33do7qe4w26qo.cloudfront.net/dicomweb', qidoSupportsIncludeField: false, supportsReject: false, imageRendering: 'wadors', diff --git a/platform/viewer/public/config/local_dcm4chee.js b/platform/viewer/public/config/local_dcm4chee.js index 682501fff..763604ffd 100644 --- a/platform/viewer/public/config/local_dcm4chee.js +++ b/platform/viewer/public/config/local_dcm4chee.js @@ -33,6 +33,12 @@ window.config = { }, dicomUploadEnabled: true, singlepart: 'pdf,video', + // whether the data source should use retrieveBulkData to grab metadata, + // and in case of relative path, what would it be relative to, options + // are in the series level or study level (some servers like series some study) + bulkDataURI: { + enabled: true, + }, }, }, { diff --git a/platform/viewer/public/config/local_orthanc.js b/platform/viewer/public/config/local_orthanc.js index 140f4d516..80469a291 100644 --- a/platform/viewer/public/config/local_orthanc.js +++ b/platform/viewer/public/config/local_orthanc.js @@ -29,10 +29,12 @@ window.config = { imageRendering: 'wadors', thumbnailRendering: 'wadors', enableStudyLazyLoad: true, - useBulkDataURI: false, supportsFuzzyMatching: true, supportsWildcard: true, dicomUploadEnabled: true, + bulkDataURI: { + enabled: false, + }, }, }, { diff --git a/platform/viewer/public/config/multiple.js b/platform/viewer/public/config/multiple.js index d0e726caf..07a284264 100644 --- a/platform/viewer/public/config/multiple.js +++ b/platform/viewer/public/config/multiple.js @@ -55,9 +55,9 @@ window.config = { // qidoRoot: 'https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs', // wadoRoot: 'https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs', // new server - wadoUriRoot: 'https://domvja9iplmyu.cloudfront.net/dicomweb', - qidoRoot: 'https://domvja9iplmyu.cloudfront.net/dicomweb', - wadoRoot: 'https://domvja9iplmyu.cloudfront.net/dicomweb', + wadoUriRoot: 'https://d33do7qe4w26qo.cloudfront.net/dicomweb', + qidoRoot: 'https://d33do7qe4w26qo.cloudfront.net/dicomweb', + wadoRoot: 'https://d33do7qe4w26qo.cloudfront.net/dicomweb', qidoSupportsIncludeField: false, supportsReject: false, imageRendering: 'wadors', diff --git a/platform/viewer/public/config/netlify.js b/platform/viewer/public/config/netlify.js index c2187aca6..1bc23c049 100644 --- a/platform/viewer/public/config/netlify.js +++ b/platform/viewer/public/config/netlify.js @@ -18,9 +18,10 @@ window.config = { sourceName: 'dicomweb', configuration: { name: 'aws', - wadoUriRoot: 'https://domvja9iplmyu.cloudfront.net/dicomweb', - qidoRoot: 'https://domvja9iplmyu.cloudfront.net/dicomweb', - wadoRoot: 'https://domvja9iplmyu.cloudfront.net/dicomweb', + wadoUriRoot: 'https://d33do7qe4w26qo.cloudfront.net/dicomweb', + qidoRoot: 'https://d33do7qe4w26qo.cloudfront.net/dicomweb', + wadoRoot: 'https://d33do7qe4w26qo.cloudfront.net/dicomweb', + qidoSupportsIncludeField: false, supportsReject: false, imageRendering: 'wadors', @@ -29,7 +30,14 @@ window.config = { supportsFuzzyMatching: false, supportsWildcard: true, staticWado: true, - singlepart: 'bulkdata,video,pdf', + singlepart: 'bulkdata,video', + // whether the data source should use retrieveBulkData to grab metadata, + // and in case of relative path, what would it be relative to, options + // are in the series level or study level (some servers like series some study) + bulkDataURI: { + enabled: true, + relativeResolution: 'studies', + }, }, }, { diff --git a/platform/viewer/public/config/public_dicomweb.js b/platform/viewer/public/config/public_dicomweb.js index 7d310a330..322d89684 100644 --- a/platform/viewer/public/config/public_dicomweb.js +++ b/platform/viewer/public/config/public_dicomweb.js @@ -11,9 +11,9 @@ window.config = { dicomWeb: [ { name: 'aws', - wadoUriRoot: 'https://domvja9iplmyu.cloudfront.net/dicomweb', - qidoRoot: 'https://domvja9iplmyu.cloudfront.net/dicomweb', - wadoRoot: 'https://domvja9iplmyu.cloudfront.net/dicomweb', + wadoUriRoot: 'https://d33do7qe4w26qo.cloudfront.net/dicomweb', + qidoRoot: 'https://d33do7qe4w26qo.cloudfront.net/dicomweb', + wadoRoot: 'https://d33do7qe4w26qo.cloudfront.net/dicomweb', qidoSupportsIncludeField: true, imageRendering: 'wadors', thumbnailRendering: 'wadors', diff --git a/platform/viewer/src/routes/WorkList/WorkList.tsx b/platform/viewer/src/routes/WorkList/WorkList.tsx index df2c30376..f5c914111 100644 --- a/platform/viewer/src/routes/WorkList/WorkList.tsx +++ b/platform/viewer/src/routes/WorkList/WorkList.tsx @@ -343,6 +343,7 @@ function WorkList({ const isValidMode = mode.isValidMode({ modalities: modalitiesToCheck, + study, }); // TODO: Modes need a default/target route? We mostly support a single one for now. // We should also be using the route path, but currently are not diff --git a/platform/viewer/tailwind.config.js b/platform/viewer/tailwind.config.js index 272ff180c..e224be206 100644 --- a/platform/viewer/tailwind.config.js +++ b/platform/viewer/tailwind.config.js @@ -50,7 +50,7 @@ module.exports = { main: '#3a3f99', disabled: '#2b166b', focus: '#5acce6', - placeholder: '#39383f' + placeholder: '#39383f', }, secondary: { diff --git a/yarn.lock b/yarn.lock index 47d527d3c..e0b3eea49 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1302,7 +1302,7 @@ core-js-pure "^3.25.1" regenerator-runtime "^0.13.11" -"@babel/runtime@7.17.9", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.14.6", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.6", "@babel/runtime@^7.20.13", "@babel/runtime@^7.20.6", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.0", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.4", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": +"@babel/runtime@7.17.9", "@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.6", "@babel/runtime@^7.20.13", "@babel/runtime@^7.20.6", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.0", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.4", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": version "7.21.0" resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.0.tgz#5b55c9d394e5fcf304909a8b00c07dc217b56673" integrity sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw== @@ -1418,10 +1418,10 @@ resolved "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9" integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ== -"@cornerstonejs/adapters@^0.6.0": - version "0.6.0" - resolved "https://registry.npmjs.org/@cornerstonejs/adapters/-/adapters-0.6.0.tgz#9b2efdabb0d596d53ae4854556956b668cca7535" - integrity sha512-bzOwtOX0EfJ/PufPq1mONPU+HmVQf+pA/78+mbHuV8bvznM1IkSzc2h0WYsLXTdVEkZrGwgWCwrOiYGfsNW1tQ== +"@cornerstonejs/adapters@^1.1.0": + version "1.1.0" + resolved "https://registry.npmjs.org/@cornerstonejs/adapters/-/adapters-1.1.0.tgz#6476ff9075291a10204992cffbc178c67a785ed4" + integrity sha512-v2uj9uGd0mEuQfPvKBuohgNM+EDOAqOexnI0yHfAOIQkzA+FPdifr5vRoH6TKkSk403GZh8ASGeoyleavEifmQ== dependencies: "@babel/runtime-corejs2" "^7.17.8" dcmjs "^0.29.5" @@ -1469,43 +1469,43 @@ resolved "https://registry.npmjs.org/@cornerstonejs/codec-openjph/-/codec-openjph-2.4.2.tgz#e96721d56f6ec96f7f95c16321d88cc8467d8d81" integrity sha512-lgdvBvvNezleY+4pIe2ceUsJzlZe/0PipdeubQ3vZZOz3xxtHHMR1XFCl4fgd8gosR8COHuD7h6q+MwgrwBsng== -"@cornerstonejs/core@^0.47.1": - version "0.47.1" - resolved "https://registry.yarnpkg.com/@cornerstonejs/core/-/core-0.47.1.tgz#8bbb9d5d8a6cd8a4c0fbf0b20bc0f56808d4fa40" - integrity sha512-bA69qo2WkMd5lkFFegYQ1UWHbnOADF0TD6DHiOnabFxHMQYZLB9CkCy4lT/tPAYL2w9WKm6b52ScUPNLTirWeA== +"@cornerstonejs/core@^0.47.3", "@cornerstonejs/core@^1.1.0": + version "1.1.0" + resolved "https://registry.npmjs.org/@cornerstonejs/core/-/core-1.1.0.tgz#bedac4d7583be931f289e5e645a1531b605cbb83" + integrity sha512-YLbb47rCrxf04XZ1+qR1ATnbKlV6WXOsEc4pAmty5WfTqL9zlmSx72IoXx034m39Nz9JvZB+VUN8MKvVrYv3bw== dependencies: "@kitware/vtk.js" "27.3.1" detect-gpu "^5.0.22" gl-matrix "^3.4.3" lodash.clonedeep "4.5.0" -"@cornerstonejs/dicom-image-loader@^0.6.6": - version "0.6.6" - resolved "https://registry.yarnpkg.com/@cornerstonejs/dicom-image-loader/-/dicom-image-loader-0.6.6.tgz#086b14e93e67923ea999d7f80c42bf766db3918f" - integrity sha512-pdJ5f7/JEX4BAXR2keAAGf6RUfuyzB3vhEvk0T4yDv960TdpT4RZqRjs5acZMs9bkGqY8zAb+c9+Gdz5vypfvg== +"@cornerstonejs/dicom-image-loader@^0.6.8": + version "0.6.8" + resolved "https://registry.yarnpkg.com/@cornerstonejs/dicom-image-loader/-/dicom-image-loader-0.6.8.tgz#e9d5c24959d74981b3ccca3641617b2c34106355" + integrity sha512-wYKPXprDYgHrsz2tNf2o0UL0Mr4YcUF4xalQqdLxfrgyUExQKHoDigXmEAwNVokSlS7rNz6NplLXeTbNJ2QSwg== dependencies: "@cornerstonejs/codec-charls" "^1.2.3" "@cornerstonejs/codec-libjpeg-turbo-8bit" "^1.2.2" "@cornerstonejs/codec-openjpeg" "^1.2.2" "@cornerstonejs/codec-openjph" "^2.4.2" - "@cornerstonejs/core" "^0.47.1" + "@cornerstonejs/core" "^0.47.3" dicom-parser "^1.8.9" pako "^2.0.4" uuid "^9.0.0" -"@cornerstonejs/streaming-image-volume-loader@^0.20.4": - version "0.20.4" - resolved "https://registry.yarnpkg.com/@cornerstonejs/streaming-image-volume-loader/-/streaming-image-volume-loader-0.20.4.tgz#f8d727f2ae7dc0ae3c8975bad2a4f5365743127f" - integrity sha512-Zkf/HKatWGf3GPb+0yR7wNPqBHfKXQznpcKsqwFbbMu/SNp7dBcCuvqRVXO5BrJOfnyDeeDpB2+2XRocBnighg== +"@cornerstonejs/streaming-image-volume-loader@^1.1.0": + version "1.1.0" + resolved "https://registry.npmjs.org/@cornerstonejs/streaming-image-volume-loader/-/streaming-image-volume-loader-1.1.0.tgz#bd2fa9cddc8088c6e6501be6c3bba0f722478078" + integrity sha512-HGHm3WHUxwzsTEkcGwscwFOuxZxeVvRZ1MkcSpkGVgiL15NE8L47FKBLP9Ph2kdv+5DC3IOQuZV502mhi2unag== dependencies: - "@cornerstonejs/core" "^0.47.1" + "@cornerstonejs/core" "^1.1.0" -"@cornerstonejs/tools@^0.67.4": - version "0.67.4" - resolved "https://registry.yarnpkg.com/@cornerstonejs/tools/-/tools-0.67.4.tgz#5dfc2d3bacc2be600496cd4e230b1749de59b001" - integrity sha512-qeCCtK/xioEBv7l1zqicWQrHdzkFOX4fGNZ5D/TMkQMa+Ed5Zip8gDAcYTbmUvbYXdvkzhoOZeLcPVhoAkDykw== +"@cornerstonejs/tools@^1.1.0": + version "1.1.0" + resolved "https://registry.npmjs.org/@cornerstonejs/tools/-/tools-1.1.0.tgz#8c59f50899127505fd1e7e775ccf264ebddb8ae5" + integrity sha512-1OhTAycShG5BoAH5x1XUN9TrSdL9hwB5jTBgm0t/nJGVj/DJ29RMXvakgX8HAv1K2acrKRl8JcJCV2VS9OAHtQ== dependencies: - "@cornerstonejs/core" "^0.47.1" + "@cornerstonejs/core" "^1.1.0" lodash.clonedeep "4.5.0" lodash.get "^4.4.2" @@ -7461,14 +7461,6 @@ config-chain@^1.1.11: ini "^1.3.4" proto-list "~1.2.1" -config-point@^0.4.8: - version "0.4.9" - resolved "https://registry.npmjs.org/config-point/-/config-point-0.4.9.tgz#ec4594d04235438d5852e5ab33bde60aeffd18db" - integrity sha512-2QyqD2eb2iURURZNW6MAcNcT2e1Pr1VXwCaMgEQM0f/i+WaUCLZOdPjFtqF7oIT1rzPXNoMfl3KK2hbNcy5dXw== - dependencies: - "@babel/runtime" "^7.14.6" - json5 "^2.2.0" - configstore@^5.0.1: version "5.0.1" resolved "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz#d365021b5df4b98cdd187d6a3b0e3f6a7cc5ed96" @@ -13975,7 +13967,7 @@ memfs@^3.1.2, memfs@^3.4.1, memfs@^3.4.3: dependencies: fs-monkey "^1.0.3" -memoize-one@^5.0.0: +"memoize-one@>=3.1.1 <6", memoize-one@^5.0.0: version "5.2.1" resolved "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== @@ -17518,6 +17510,14 @@ react-waypoint@^10.3.0: prop-types "^15.0.0" react-is "^17.0.1 || ^18.0.0" +react-window@^1.8.9: + version "1.8.9" + resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.9.tgz#24bc346be73d0468cdf91998aac94e32bc7fa6a8" + integrity sha512-+Eqx/fj1Aa5WnhRfj9dJg4VYATGwIUP2ItwItiJ6zboKWA6EX3lYDAXfGF2hyNqplEprhbtjbipiADEcwQ823Q== + dependencies: + "@babel/runtime" "^7.0.0" + memoize-one ">=3.1.1 <6" + react-with-direction@^1.3.1: version "1.4.0" resolved "https://registry.npmjs.org/react-with-direction/-/react-with-direction-1.4.0.tgz#ebdf64d685d0650ce966e872e6431ad5a2485444"