diff --git a/docs/latest/viewer/configuration.md b/docs/latest/viewer/configuration.md index 838e5f5a4..cbdea6a41 100644 --- a/docs/latest/viewer/configuration.md +++ b/docs/latest/viewer/configuration.md @@ -67,6 +67,33 @@ window.config = { extensions: [], showStudyList: true, filterQueryParam: false, + /** + * Disable caching of servers configuration. + * + * There will be no effect if you update the servers property of this config + * while your application is running because this property is cached in local storage. + */ + disableServersCache: false, + /** + * OHIF's study prefetcher configuration. + * + * @param {boolean} enabled Whether to enable/disable OHIF's study prefetcher + * @param {('all'|'closest'|'downward'|'upward'|'topdown')} order Fetching order: all display sets, the closest ones, downward or top down fashion based on the currently selected display set + * @param {number} displaySetCount How much display sets should be prefetched at once (note: this attribute is ignored if order was set to 'all') + * @param {boolean} preventCache Prevent images to be cached in Cornerstone Tools's request pool manager + * @param {number} prefetchDisplaySetsTimeout Prefetch timeout + * @param {boolean} displayProgress Whether to display or not the progress bar in the display set + * @param {boolean} includeActiveDisplaySet Include or not the active display set while prefetching + */ + studyPrefetcher: { + enabled: true, + order: 'all', + displaySetCount: 1, + preventCache: false, + prefetchDisplaySetsTimeout: 300, + displayProgress: false, + includeActiveDisplaySet: true, + }, servers: { dicomWeb: [ { @@ -81,7 +108,14 @@ window.config = { }, ], }, - // Supported Keys: https://craig.is/killing/mice + /** + * Hotkey definitions. + * Supported Keys: https://craig.is/killing/mice + * + * @param {string} commandName + * @param {string} label + * @param {array} keys + */ hotkeys: [ { commandName: 'rotateViewportCW', label: 'Rotate Right', keys: ['r'] }, { commandName: 'rotateViewportCCW', label: 'Rotate Left', keys: ['l'] }, @@ -97,14 +131,16 @@ window.config = { keys: ['v'], }, ], - /* Configuration passed to the bundled cornerstone extension + /** + * Configuration passed to the bundled cornerstone extension * * The cornerstone extension is currently tightly coupled to the platform. * Until we're able to decouple it, this key will serve as a workaround to * pass it configuration. + * + * @param {boolean} hideHandles Whether to show/hide annotation "handles" */ cornerstoneExtensionConfig: { - /* Whether to show/hide annotation "handles" */ hideHandles: true, }, }; diff --git a/docs/latest/viewer/studyPrefetcher.md b/docs/latest/viewer/studyPrefetcher.md new file mode 100644 index 000000000..2470561a3 --- /dev/null +++ b/docs/latest/viewer/studyPrefetcher.md @@ -0,0 +1,37 @@ +# Viewer: Study Prefetcher & Stack Prefetch + +Prefetching the stack of images of the active viewport is enabled by default for +cornerstone viewports via cornerstone tools's stack prefetch functionality. + +In order to have a more customizable alternative for prefetching, OHIF provides +a built-in study prefetcher functionality which allows the user to set the order +in which the display sets are prefetched, the granularity, the timeout and +whether or not display a progress bar in the display set (close to the thumbnail +image) in the study browser. + +You can customize these options via the viewer's configuration: + +```js +window.config = { + /** + * OHIF's study prefetcher configuration. + * + * @param {boolean} enabled Whether to enable/disable OHIF's study prefetcher + * @param {('all'|'closest'|'downward'|'upward'|'topdown')} order Fetching order: all display sets, the closest ones, downward or top down fashion based on the currently selected display set + * @param {number} displaySetCount How much display sets should be prefetched at once (note: this attribute is ignored if order was set to 'all') + * @param {boolean} preventCache Prevent images to be cached in Cornerstone Tools's request pool manager + * @param {number} prefetchDisplaySetsTimeout Prefetch timeout + * @param {boolean} displayProgress Whether to display or not the progress bar in the display set + * @param {boolean} includeActiveDisplaySet Include or not the active display set while prefetching + */ + studyPrefetcher: { + enabled: true, + order: 'all', + displaySetCount: 1, + preventCache: false, + prefetchDisplaySetsTimeout: 300, + displayProgress: false, + includeActiveDisplaySet: true, + }, +}; +``` diff --git a/extensions/cornerstone/README.md b/extensions/cornerstone/README.md index 9c6e86015..514857020 100644 --- a/extensions/cornerstone/README.md +++ b/extensions/cornerstone/README.md @@ -85,12 +85,32 @@ Tools can be configured through extension configuration using the tools key: ... ``` +## Stack prefetch configuration + +Stack prefetch can be configured through extension configuration using the +stackPrefetch key: + +```js + ... + cornerstoneExtensionConfig: { + stackPrefetch: { + enabled: true, + maxImagesToPrefetch: Infinity, + preserveExistingPool: false, + maxSimultaneousRequests: 20, + }, + }, + ... +``` + ## Annotate Tools Configuration -*We currently support one property for annotation tools.* +_We currently support one property for annotation tools._ ### Hide handles -This extension configuration allows you to toggle on/off handle rendering for all annotate tools: + +This extension configuration allows you to toggle on/off handle rendering for +all annotate tools: ```js ... @@ -119,3 +139,4 @@ This extension configuration allows you to toggle on/off handle rendering for al [cornerstone-tools]: https://github.com/cornerstonejs/cornerstoneTools [cornerstone]: https://github.com/cornerstonejs/cornerstone +``` diff --git a/extensions/cornerstone/src/ConnectedCornerstoneViewport.js b/extensions/cornerstone/src/ConnectedCornerstoneViewport.js index 2ea40f70a..6a6e837c2 100644 --- a/extensions/cornerstone/src/ConnectedCornerstoneViewport.js +++ b/extensions/cornerstone/src/ConnectedCornerstoneViewport.js @@ -53,7 +53,11 @@ const mapStateToProps = (state, ownProps) => { // Currently justing using escape hatch + commands // activeTool: activeButton && activeButton.command, ...dataFromStore, - isStackPrefetchEnabled: isActive, + isStackPrefetchEnabled: ownProps.hasOwnProperty('isStackPrefetchEnabled') + ? ownProps.isStackPrefetchEnabled + : ownProps.stackPrefetch + ? ownProps.stackPrefetch.enabled + : isActive, isPlaying, frameRate, //stack: viewportSpecificData.stack, diff --git a/extensions/cornerstone/src/OHIFCornerstoneViewport.js b/extensions/cornerstone/src/OHIFCornerstoneViewport.js index d0b14c7e3..f7875c44c 100644 --- a/extensions/cornerstone/src/OHIFCornerstoneViewport.js +++ b/extensions/cornerstone/src/OHIFCornerstoneViewport.js @@ -1,6 +1,6 @@ import React, { Component } from 'react'; -import OHIFCornerstoneViewportOverlay from './components/OHIFCornerstoneViewportOverlay' +import OHIFCornerstoneViewportOverlay from './components/OHIFCornerstoneViewportOverlay'; import ConnectedCornerstoneViewport from './ConnectedCornerstoneViewport'; import OHIF from '@ohif/core'; import PropTypes from 'prop-types'; @@ -15,6 +15,7 @@ class OHIFCornerstoneViewport extends Component { static defaultProps = { customProps: {}, + isStackPrefetchEnabled: true, }; static propTypes = { @@ -23,6 +24,8 @@ class OHIFCornerstoneViewport extends Component { viewportIndex: PropTypes.number, children: PropTypes.node, customProps: PropTypes.object, + stackPrefetch: PropTypes.object, + isStackPrefetchEnabled: PropTypes.bool, }; static id = 'OHIFCornerstoneViewport'; @@ -180,7 +183,7 @@ class OHIFCornerstoneViewport extends Component { if ( displaySet.displaySetInstanceUID !== - prevDisplaySet.displaySetInstanceUID || + prevDisplaySet.displaySetInstanceUID || displaySet.SOPInstanceUID !== prevDisplaySet.SOPInstanceUID || displaySet.frameIndex !== prevDisplaySet.frameIndex ) { @@ -232,7 +235,12 @@ class OHIFCornerstoneViewport extends Component { }; const warningsOverlay = props => { - return + return ( + + ); }; return ( @@ -244,10 +252,11 @@ class OHIFCornerstoneViewport extends Component { onNewImageDebounced={newImageHandler} onNewImageDebounceTime={300} viewportOverlayComponent={warningsOverlay} + stackPrefetch={this.props.stackPrefetch} + isStackPrefetchEnabled={this.props.isStackPrefetchEnabled} // ~~ Connected (From REDUX) // frameRate={frameRate} // isPlaying={false} - // isStackPrefetchEnabled={true} // onElementEnabled={() => {}} // setViewportActive{() => {}} {...this.props.customProps} diff --git a/extensions/cornerstone/src/index.js b/extensions/cornerstone/src/index.js index e78f7bd6b..8ed69d2ad 100644 --- a/extensions/cornerstone/src/index.js +++ b/extensions/cornerstone/src/index.js @@ -36,7 +36,7 @@ export default { preRegistration({ servicesManager, configuration = {} }) { init({ servicesManager, configuration }); }, - getViewportModule({ commandsManager }) { + getViewportModule({ commandsManager, appConfig }) { const ExtendedOHIFCornerstoneViewport = props => { /** * TODO: This appears to be used to set the redux parameters for @@ -48,8 +48,17 @@ export default { jumpData.refreshViewports = false; commandsManager.runCommand('jumpToImage', jumpData); }; + + const { studyPrefetcher } = appConfig; + const isStackPrefetchEnabled = + studyPrefetcher && !studyPrefetcher.enabled; + return ( - + ); }; diff --git a/extensions/cornerstone/src/init.js b/extensions/cornerstone/src/init.js index 27e962b80..fb0f55436 100644 --- a/extensions/cornerstone/src/init.js +++ b/extensions/cornerstone/src/init.js @@ -37,7 +37,14 @@ export default function init({ servicesManager, configuration }) { } }; - const { csToolsConfig } = configuration; + const { + csToolsConfig, + stackPrefetch = { + maxImagesToPrefetch: Infinity, + preserveExistingPool: false, + maxSimultaneousRequests: 20, + }, + } = configuration; const metadataProvider = OHIF.cornerstone.metadataProvider; cornerstone.metaData.addProvider( @@ -52,7 +59,7 @@ export default function init({ servicesManager, configuration }) { autoResizeViewports: false, }; - initCornerstoneTools(defaultCsToolsConfig); + initCornerstoneTools({ ...defaultCsToolsConfig, ...stackPrefetch }); const toolsGroupedByType = { touch: [csTools.PanMultiTouchTool, csTools.ZoomTouchPinchTool], diff --git a/extensions/cornerstone/src/initCornerstoneTools.js b/extensions/cornerstone/src/initCornerstoneTools.js index 54b9fb6f6..8a513fa8f 100644 --- a/extensions/cornerstone/src/initCornerstoneTools.js +++ b/extensions/cornerstone/src/initCornerstoneTools.js @@ -37,8 +37,8 @@ export default function(configuration = {}) { // Configure stack prefetch cornerstoneTools.stackPrefetch.setConfiguration({ - maxImagesToPrefetch: Infinity, - preserveExistingPool: false, - maxSimultaneousRequests: 20, + maxImagesToPrefetch: configuration.maxImagesToPrefetch, + preserveExistingPool: configuration.preserveExistingPool, + maxSimultaneousRequests: configuration.maxSimultaneousRequests, }); } diff --git a/platform/core/src/classes/StudyLoadingListener.js b/platform/core/src/classes/StudyLoadingListener.js index c655250bc..42414ab38 100644 --- a/platform/core/src/classes/StudyLoadingListener.js +++ b/platform/core/src/classes/StudyLoadingListener.js @@ -1,16 +1,14 @@ import cornerstone from 'cornerstone-core'; import cornerstoneWADOImageLoader from 'cornerstone-wado-image-loader'; -import { - clearStudyLoadingProgress, - setStudyLoadingProgress, -} from '../redux/actions'; +import debounce from 'lodash.debounce'; + import StackManager from '../utils/StackManager'; +import { StudyPrefetcher } from './StudyPrefetcher'; class BaseLoadingListener { constructor(stack, options = {}) { this.id = BaseLoadingListener.getNewId(); this.stack = stack; - this.startListening(); this.statsItemsLimit = options.statsItemsLimit || 2; this.stats = { items: [], @@ -60,6 +58,9 @@ class BaseLoadingListener { } _getProgressId() { + /** + * TODO: The id key should be configurable. + */ const displaySetInstanceUID = this.stack.displaySetInstanceUID; return 'StackProgress:' + displaySetInstanceUID; } @@ -96,11 +97,18 @@ class BaseLoadingListener { class DICOMFileLoadingListener extends BaseLoadingListener { constructor(stack, options) { super(stack, options); + + this.imageLoadProgressEventHandler = this._imageLoadProgressEventHandle.bind( + this + ); + this._dataSetUrl = this._getDataSetUrl(stack); this._lastLoaded = 0; // Check how many instances has already been download (cached) this._checkCachedData(); + + this.startListening(); } _checkCachedData() { @@ -127,10 +135,6 @@ class DICOMFileLoadingListener extends BaseLoadingListener { startListening() { const imageLoadProgressEventName = this._getImageLoadProgressEventName(); - this.imageLoadProgressEventHandler = this._imageLoadProgressEventHandle.bind( - this - ); - this.stopListening(); cornerstone.events.addEventListener( @@ -200,17 +204,57 @@ class DICOMFileLoadingListener extends BaseLoadingListener { } } +const StudyLoadingListenerEvents = { + OnProgress: 'StudyLoadingListenerEvents.OnProgress', +}; + +function promiseState(promise, callback) { + // Symbols and RegExps are never content-equal + var uniqueValue = window['Symbol'] ? Symbol('unique') : /unique/; + + function notifyPendingOrResolved(value) { + if (value === uniqueValue) { + return callback('pending'); + } else { + return callback('fulfilled'); + } + } + + function notifyRejected(reason) { + return callback('rejected'); + } + + var race = [promise, Promise.resolve(uniqueValue)]; + Promise.race(race).then(notifyPendingOrResolved, notifyRejected); +} + class StackLoadingListener extends BaseLoadingListener { constructor(stack, options = {}) { options.statsItemsLimit = 20; + super(stack, options); + this.imageLoadedEventHandler = this._imageLoadedEventHandler.bind(this); + this.imageCachePromiseRemovedEventHandler = this._imageCachePromiseRemovedEventHandler.bind( + this + ); + this.imageDataMap = this._convertImageIdsArrayToMap(stack.imageIds); this.framesStatus = this._createArray(stack.imageIds.length, false); this.loadedCount = 0; // Check how many instances has already been download (cached) - this._checkCachedData(); + this._debouncedSetProgressData = debounce((...args) => { + this._setProgressData(...args); + + /** After checking cache, continue prefetch */ + const studyPrefetcher = StudyPrefetcher.getInstance(); + studyPrefetcher.prefetch(studyPrefetcher.getElement()); + }, 300); + const debounced = true; + this._checkCachedData(debounced); + + this.startListening(); } _convertImageIdsArrayToMap(imageIds) { @@ -238,18 +282,37 @@ class StackLoadingListener extends BaseLoadingListener { return array; } - _checkCachedData() { - // const imageIds = this.stack.imageIds; - // TODO: No way to check status of Promise. - /*for(let i = 0; i < imageIds.length; i++) { - const imageId = imageIds[i]; + /** + * Check if image id is cached in cornerstone. + * + * @param {string} imageId + * @returns + */ + isImageCached(imageId) { + const image = cornerstone.imageCache.imageCache[imageId]; + return image && image.sizeInBytes; + } - const imagePromise = cornerstone.imageCache.getImageLoadObject(imageId).promise; + _checkCachedData(debounced = false) { + const imageIds = this.stack.imageIds; - if (imagePromise && (imagePromise.state() === 'resolved')) { - this._updateFrameStatus(imageId, true); - } - }*/ + for (let i = 0; i < imageIds.length; i++) { + const imageId = imageIds[i]; + + const imageObject = cornerstone.imageCache.getImageLoadObject(imageId); + + if (this.isImageCached(imageId)) { + this._updateFrameStatus(imageId, true, debounced); + } + + if (imageObject && imageObject.promise) { + promiseState(imageObject.promise, state => { + if (state === 'fulfilled') { + this._updateFrameStatus(imageId, true, debounced); + } + }); + } + } } _getImageLoadedEventName() { @@ -272,11 +335,6 @@ class StackLoadingListener extends BaseLoadingListener { const imageLoadedEventName = this._getImageLoadedEventName(); const imageCachePromiseRemovedEventName = this._getImageCachePromiseRemoveEventName(); - this.imageLoadedEventHandler = this._imageLoadedEventHandler.bind(this); - this.imageCachePromiseRemovedEventHandler = this._imageCachePromiseRemovedEventHandler.bind( - this - ); - this.stopListening(); cornerstone.events.addEventListener( @@ -303,7 +361,7 @@ class StackLoadingListener extends BaseLoadingListener { ); } - _updateFrameStatus(imageId, loaded) { + _updateFrameStatus(imageId, loaded, debounced) { const imageData = this.imageDataMap.get(imageId); if (!imageData || imageData.loaded === loaded) { @@ -318,7 +376,7 @@ class StackLoadingListener extends BaseLoadingListener { imageData.loaded = loaded; this.framesStatus[imageData.index] = loaded; this.loadedCount += loaded ? 1 : -1; - this._updateProgress(); + this._updateProgress(debounced); } _setProgressData(progressId, progressData) { @@ -339,7 +397,7 @@ class StackLoadingListener extends BaseLoadingListener { ); } - _updateProgress() { + _updateProgress(debounced) { const totalFramesCount = this.stack.imageIds.length; const loadedFramesCount = this.loadedCount; const loadingFramesCount = totalFramesCount - loadedFramesCount; @@ -357,6 +415,11 @@ class StackLoadingListener extends BaseLoadingListener { framesStatus: this.framesStatus, }; + if (debounced) { + this._debouncedSetProgressData(progressId, progressData); + return; + } + this._setProgressData(progressId, progressData); } @@ -371,11 +434,13 @@ class StackLoadingListener extends BaseLoadingListener { } progressBar += ']'; - log.info(`${displaySetInstanceUID}: ${progressBar}`); + console.info(`${displaySetInstanceUID}: ${progressBar}`); } } class StudyLoadingListener { + static events = StudyLoadingListenerEvents; + constructor(options) { this.listeners = {}; this.options = options; @@ -454,14 +519,36 @@ class StudyLoadingListener { _getSchema(stack) { const imageId = stack.imageIds[0]; + if (!imageId) { + return; + } const colonIndex = imageId.indexOf(':'); return imageId.substring(0, colonIndex); } - // Singleton static getInstance(options) { + /** + * TODO: Use a different alternative without the use of events. + */ + const DEFAULT_OPTIONS = { + _setProgressData: (progressId, progressData) => { + const event = new CustomEvent(StudyLoadingListenerEvents.OnProgress, { + detail: { progressId, progressData }, + }); + document.dispatchEvent(event); + }, + _clearProgressById: progressId => { + const event = new CustomEvent(StudyLoadingListenerEvents.OnProgress, { + detail: { progressId, percentComplete: 0 }, + }); + document.dispatchEvent(event); + }, + }; + if (!StudyLoadingListener._instance) { - StudyLoadingListener._instance = new StudyLoadingListener(options); + StudyLoadingListener._instance = new StudyLoadingListener( + options || DEFAULT_OPTIONS + ); } return StudyLoadingListener._instance; diff --git a/platform/core/src/classes/StudyPrefetcher.js b/platform/core/src/classes/StudyPrefetcher.js index 5f384ad4c..72ccc96eb 100644 --- a/platform/core/src/classes/StudyPrefetcher.js +++ b/platform/core/src/classes/StudyPrefetcher.js @@ -1,14 +1,28 @@ -import log from '../log.js'; -import OHIFError from './OHIFError'; import cornerstone from 'cornerstone-core'; import cornerstoneTools from 'cornerstone-tools'; + import getImageId from '../utils/getImageId.js'; +const noop = () => {}; + export class StudyPrefetcher { - constructor(studies) { + options = { + order: 'closest', + displaySetCount: 1, + onImageCached: noop, + requestType: 'prefetch', + preventCache: false, + prefetchDisplaySetsTimeout: 300, + includeActiveDisplaySet: false, + }; + + constructor(studies, options) { this.studies = studies || []; - this.prefetchDisplaySetsTimeout = 300; - this.lastActiveViewportElement = null; + + if (options) { + this.options = { ...this.options, ...options }; + this.options.requestType = 'prefetch'; + } cornerstone.events.addEventListener( 'cornerstoneimagecachefull.StudyPrefetcher', @@ -16,6 +30,9 @@ export class StudyPrefetcher { ); } + /** + * Remove previously set event listeners and stop prefetching. + */ destroy() { this.stopPrefetching(); cornerstone.events.removeEventListener( @@ -24,68 +41,117 @@ export class StudyPrefetcher { ); } - static getInstance() { + /** + * Get StudyPrefetcher singleton instance. + * + * @param {array} studies + * @param {object} options + * @returns + */ + static getInstance(studies = [], options) { if (!StudyPrefetcher.instance) { - StudyPrefetcher.instance = new StudyPrefetcher([]); + StudyPrefetcher.instance = new StudyPrefetcher(studies, options); + } + + if (options) { + this.options = { ...this.options, ...options }; + this.options.requestType = 'prefetch'; } return StudyPrefetcher.instance; } + /** + * OHIF study metadata instances. + * + * @param {array} studies + */ setStudies(studies) { this.stopPrefetching(); this.studies = studies; } - prefetch() { + /** + * Get previously prefetched element. + * + * @returns {HTMLElement} Previously prefetched element. + */ + getElement() { + return this.element; + } + + /** + * Prefetch related display sets based on cornerstone viewport element + * with previously set options. + * + * @param {*} element + * @param {string} displaySetInstanceUID the display set instance uid + * @returns + */ + prefetch(element, displaySetInstanceUID) { if (!this.studies || !this.studies.length) { return; } + this.element = element; + this.enabledElement = cornerstone.getEnabledElement(element); + this.stopPrefetching(); - this.prefetchDisplaySets(); + this.prefetchDisplaySets(displaySetInstanceUID); } + /** + * Stop prefetching images. + */ stopPrefetching() { cornerstoneTools.requestPoolManager.clearRequestStack('prefetch'); } - prefetchDisplaySetsAsync(timeout) { - timeout = timeout || this.prefetchDisplaySetsTimeout; - + /** + * Prefetch display sets async. + * + * @param {HTMLElement} element cornerstone viewport element + * @param {number} timeout + */ + prefetchDisplaySetsAsync(element, timeout) { + this.enabledElement = cornerstone.getEnabledElement(element); + timeout = timeout || this.options.prefetchDisplaySetsTimeout; clearTimeout(this.prefetchDisplaySetsHandler); this.prefetchDisplaySetsHandler = setTimeout(() => { - this.prefetchDisplaySets(); + this.prefetchDisplaySets(element); }, timeout); } - prefetchDisplaySets() { - // TODO: Allow passing in config - let config = { - order: 'closest', - displaySetCount: 1, - }; - - const displaySetsToPrefetch = this.getDisplaySetsToPrefetch(config); + /** + * Extract all image ids from all display sets to be fetched and + * call method to add images to request pool manager. + * + * @param {string} displaySetInstanceUID the display set instance uid + */ + prefetchDisplaySets(displaySetInstanceUID) { + const displaySetsToPrefetch = this.getDisplaySetsToPrefetch( + displaySetInstanceUID + ); const imageIds = this.getImageIdsFromDisplaySets(displaySetsToPrefetch); - this.prefetchImageIds(imageIds); } + /** + * Add image ids to request pool manager. + * + * @param {array} imageIds + */ prefetchImageIds(imageIds) { const nonCachedImageIds = this.filterCachedImageIds(imageIds); const requestPoolManager = cornerstoneTools.requestPoolManager; - const requestType = 'prefetch'; - const preventCache = false; - const noop = () => {}; nonCachedImageIds.forEach(imageId => { requestPoolManager.addRequest( {}, imageId, - requestType, - preventCache, - noop, + this.options.requestType, + this.options.preventCache, + () => {}, noop ); }); @@ -93,26 +159,45 @@ export class StudyPrefetcher { requestPoolManager.startGrabbing(); } + /** + * Get study by cornerstone image instance. + * + * @param {object} image + * @returns + */ getStudy(image) { const StudyInstanceUID = cornerstone.metaData.get( 'StudyInstanceUID', image.imageId ); - return OHIF.viewer.Studies.find( - study => study.StudyInstanceUID === StudyInstanceUID + const studies = this.studies; + return studies.find( + study => study.getData().StudyInstanceUID === StudyInstanceUID ); } + /** + * Get study series by cornerstone image instance. + * + * @param {object} study OHIF study instance + * @param {object} image cornerstone image instance object + * @returns + */ getSeries(study, image) { const SeriesInstanceUID = cornerstone.metaData.get( 'SeriesInstanceUID', image.imageId ); - const studyMetadata = OHIF.viewerbase.getStudyMetadata(study); - - return studyMetadata.getSeriesByUID(SeriesInstanceUID); + return study.getSeriesByUID(SeriesInstanceUID); } + /** + * Get sop instance by cornerstone image instance. + * + * @param {array} series + * @param {object} image + * @returns + */ getInstance(series, image) { const instanceMetadata = cornerstone.metaData.get( 'instance', @@ -121,7 +206,33 @@ export class StudyPrefetcher { return series.getInstanceByUID(instanceMetadata.SOPInstanceUID); } - getActiveDisplaySet(displaySets, instance) { + /** + * Returns the display set with given uid. + * + * @param {string} displaySetInstanceUID the display set instance uid + * @returns {object} displaySet + */ + getDisplaySetByUID(displaySetInstanceUID) { + let displaySet; + this.studies.forEach(study => { + const ds = study.displaySets.find( + ds => ds.displaySetInstanceUID === displaySetInstanceUID + ); + if (ds) { + displaySet = ds; + } + }); + return displaySet; + } + + /** + * Get display set by SOPInstanceUID. + * + * @param {array} displaySets + * @param {object} instance + * @returns + */ + getDisplaySetBySOPInstanceUID(displaySets, instance) { return displaySets.find(displaySet => { return displaySet.images.some(displaySetImage => { return displaySetImage.SOPInstanceUID === instance.SOPInstanceUID; @@ -129,25 +240,48 @@ export class StudyPrefetcher { }); } - getDisplaySetsToPrefetch(config) { + /** + * Get active viewport image based on cornerstone viewport element. + * @returns + */ + getActiveViewportImage() { + if (!this.enabledElement) { + return; + } + + return this.enabledElement.image; + } + + /** + * Prefetch display sets based on cornerstone viewport element image. + * + * @param {string} displaySetInstanceUID the display set instance uid + * @returns {array} displaySets + */ + getDisplaySetsToPrefetch(displaySetInstanceUID) { const image = this.getActiveViewportImage(); - if (!image || !config || !config.displaySetCount) { + if (!image) { return []; } - /*const study = this.getStudy(image); + const study = this.getStudy(image); const series = this.getSeries(study, image); - const instance = this.getInstance(series, image);*/ + const instance = this.getInstance(series, image); const displaySets = study.displaySets; - const activeDisplaySet = null; //this.getActiveDisplaySet(displaySets, instance); + const activeDisplaySet = displaySetInstanceUID + ? this.getDisplaySetByUID(displaySetInstanceUID) + : this.getDisplaySetBySOPInstanceUID(displaySets, instance); + const prefetchMethodMap = { topdown: 'getFirstDisplaySets', downward: 'getNextDisplaySets', + upward: 'getPreviousDisplaySets', closest: 'getClosestDisplaySets', + all: 'getAllDisplaySets', }; - const prefetchOrder = config.order; + const prefetchOrder = this.options.order; const methodName = prefetchMethodMap[prefetchOrder]; const getDisplaySets = this[methodName]; @@ -163,18 +297,59 @@ export class StudyPrefetcher { this, displaySets, activeDisplaySet, - config.displaySetCount + this.options.displaySetCount, + this.options.includeActiveDisplaySet ); } - getFirstDisplaySets(displaySets, activeDisplaySet, displaySetCount) { + /** + * Get all display sets. + * + * @param {array} displaySets + * @param {object} activeDisplaySet + * @param {number} displaySetCount + * @param {boolean} includeActiveDisplaySet + * @returns + */ + getAllDisplaySets( + displaySets, + activeDisplaySet, + displaySetCount, + includeActiveDisplaySet + ) { + const length = displaySets.length; + const selectedDisplaySets = []; + + for (let i = 0; i < length; i++) { + const displaySet = displaySets[i]; + selectedDisplaySets.push(displaySet); + } + + return selectedDisplaySets; + } + + /** + * Get all display sets in order after the active display set. + * + * @param {array} displaySets + * @param {object} activeDisplaySet + * @param {number} displaySetCount + * @param {boolean} includeActiveDisplaySet + * @returns + */ + getFirstDisplaySets( + displaySets, + activeDisplaySet, + displaySetCount, + includeActiveDisplaySet + ) { const length = displaySets.length; const selectedDisplaySets = []; for (let i = 0; i < length && displaySetCount; i++) { const displaySet = displaySets[i]; - if (displaySet !== activeDisplaySet) { + if (includeActiveDisplaySet || displaySet !== activeDisplaySet) { selectedDisplaySets.push(displaySet); displaySetCount--; } @@ -183,21 +358,78 @@ export class StudyPrefetcher { return selectedDisplaySets; } - getNextDisplaySets(displaySets, activeDisplaySet, displaySetCount) { + /** + * Get all display sets before the active display set. + * + * @param {array} displaySets + * @param {object} activeDisplaySet + * @param {number} displaySetCount + * @param {boolean} includeActiveDisplaySet + * @returns + */ + getPreviousDisplaySets( + displaySets, + activeDisplaySet, + displaySetCount, + includeActiveDisplaySet + ) { const activeDisplaySetIndex = displaySets.indexOf(activeDisplaySet); - const begin = activeDisplaySetIndex + 1; - const end = Math.min(begin + displaySetCount, displaySets.length); + const end = includeActiveDisplaySet + ? activeDisplaySetIndex + 1 + : activeDisplaySetIndex; + const previousDisplaySets = displaySets.slice(0, end); + return previousDisplaySets.reverse().slice(0, displaySetCount); + } + /** + * Get all display sets after the active display set. + * + * @param {array} displaySets + * @param {object} activeDisplaySet + * @param {number} displaySetCount + * @param {boolean} includeActiveDisplaySet + * @returns + */ + getNextDisplaySets( + displaySets, + activeDisplaySet, + displaySetCount, + includeActiveDisplaySet + ) { + const activeDisplaySetIndex = displaySets.indexOf(activeDisplaySet); + const begin = includeActiveDisplaySet + ? activeDisplaySetIndex + : activeDisplaySetIndex + 1; + const end = Math.min(begin + displaySetCount, displaySets.length); return displaySets.slice(begin, end); } - getClosestDisplaySets(displaySets, activeDisplaySet, displaySetCount) { + /** + * Get all display set closest to the active display set. + * + * @param {array} displaySets + * @param {object} activeDisplaySet + * @param {number} displaySetCount + * @param {boolean} includeActiveDisplaySet + * @returns + */ + getClosestDisplaySets( + displaySets, + activeDisplaySet, + displaySetCount, + includeActiveDisplaySet + ) { const activeDisplaySetIndex = displaySets.indexOf(activeDisplaySet); const length = displaySets.length; const selectedDisplaySets = []; let left = activeDisplaySetIndex - 1; let right = activeDisplaySetIndex + 1; + if (includeActiveDisplaySet) { + selectedDisplaySets.push(displaySets[activeDisplaySetIndex]); + displaySetCount--; + } + while ((left >= 0 || right < length) && displaySetCount) { if (left >= 0) { selectedDisplaySets.push(displaySets[left]); @@ -215,6 +447,12 @@ export class StudyPrefetcher { return selectedDisplaySets; } + /** + * Get all image ids from display sets. + * + * @param {array} displaySets + * @returns {array} image ids + */ getImageIdsFromDisplaySets(displaySets) { let imageIds = []; @@ -225,9 +463,19 @@ export class StudyPrefetcher { return imageIds; } + /** + * Get all image ids from a given display set. + * + * @param {array} displaySet + * @returns + */ getImageIdsFromDisplaySet(displaySet) { const imageIds = []; + if (!displaySet.images || displaySet.images.length < 1) { + return []; + } + // TODO: This duplicates work done by the stack manager displaySet.images.forEach(image => { const numFrames = image.numFrames; @@ -245,15 +493,30 @@ export class StudyPrefetcher { return imageIds; } + /** + * Filter cached image ids from a set of image ids. + * + * @param {array} imageIds + * @returns {array} images not cached + */ filterCachedImageIds(imageIds) { return imageIds.filter(imageId => !this.isImageCached(imageId)); } + /** + * Check if image id is cached in cornerstone. + * + * @param {string} imageId + * @returns + */ isImageCached(imageId) { const image = cornerstone.imageCache.imageCache[imageId]; return image && image.sizeInBytes; } + /** + * Warns that cache is full and stops prefetching. + */ cacheFullHandler = () => { log.warn('Cache full'); this.stopPrefetching(); diff --git a/platform/core/src/classes/metadata/StudyMetadata.js b/platform/core/src/classes/metadata/StudyMetadata.js index e2e682cb2..78d0bf202 100644 --- a/platform/core/src/classes/metadata/StudyMetadata.js +++ b/platform/core/src/classes/metadata/StudyMetadata.js @@ -497,6 +497,12 @@ class StudyMetadata extends Metadata { } } + if (this._displaySets.some(ds => + ds.displaySetInstanceUID === displaySet.displaySetInstanceUID) + ) { + return; + } + this._displaySets.splice(insertIndex, 0, displaySet); this.displaySets = this._displaySets; } diff --git a/platform/core/src/redux/actions.js b/platform/core/src/redux/actions.js index 1e46a9c4d..5f1b8559e 100644 --- a/platform/core/src/redux/actions.js +++ b/platform/core/src/redux/actions.js @@ -78,17 +78,6 @@ export const setActiveViewportSpecificData = viewportSpecificData => ({ /** * NOT-VIEWPORT */ -export const setStudyLoadingProgress = (progressId, progressData) => ({ - type: 'SET_STUDY_LOADING_PROGRESS', - progressId, - progressData, -}); - -export const clearStudyLoadingProgress = progressId => ({ - type: 'CLEAR_STUDY_LOADING_PROGRESS', - progressId, -}); - export const setUserPreferences = state => ({ type: SET_USER_PREFERENCES, state, @@ -134,8 +123,6 @@ const actions = { /** * NOT-VIEWPORT */ - setStudyLoadingProgress, - clearStudyLoadingProgress, setUserPreferences, setExtensionData, setTimepoints, diff --git a/platform/core/src/redux/actions.test.js b/platform/core/src/redux/actions.test.js index e80297a66..cb07bf6da 100644 --- a/platform/core/src/redux/actions.test.js +++ b/platform/core/src/redux/actions.test.js @@ -11,8 +11,6 @@ describe('actions', () => { 'setLayout', 'clearViewportSpecificData', 'setActiveViewportSpecificData', - 'setStudyLoadingProgress', - 'clearStudyLoadingProgress', 'setUserPreferences', 'setExtensionData', 'setTimepoints', diff --git a/platform/core/src/redux/reducers/index.js b/platform/core/src/redux/reducers/index.js index 44bf26755..9be45870c 100644 --- a/platform/core/src/redux/reducers/index.js +++ b/platform/core/src/redux/reducers/index.js @@ -1,5 +1,4 @@ import extensions from './extensions'; -import loading from './loading'; import preferences from './preferences'; import servers from './servers'; import studies from './studies'; @@ -8,7 +7,6 @@ import viewports from './viewports'; const reducers = { extensions, - loading, preferences, servers, studies, diff --git a/platform/core/src/redux/reducers/loading.js b/platform/core/src/redux/reducers/loading.js deleted file mode 100644 index b82a57d27..000000000 --- a/platform/core/src/redux/reducers/loading.js +++ /dev/null @@ -1,33 +0,0 @@ -import cloneDeep from 'lodash.clonedeep'; - -const defaultState = { - progress: {}, - lastUpdated: null, -}; - -const loading = (state = defaultState, action) => { - let progress; - let lastUpdated; - switch (action.type) { - case 'SET_STUDY_LOADING_PROGRESS': - progress = cloneDeep(state).progress; - progress[action.progressId] = action.progressData; - - // This is a workaround so we can easily identify changes - // to the progress object without doing deep comparison. - lastUpdated = new Date().getTime(); - - return Object.assign({}, state, { progress, lastUpdated }); - case 'CLEAR_STUDY_LOADING_PROGRESS': - progress = cloneDeep(state).progress; - delete progress[action.progressId]; - - lastUpdated = new Date().getTime(); - - return Object.assign({}, state, { progress, lastUpdated }); - default: - return state; - } -}; - -export default loading; diff --git a/platform/ui/src/components/studyBrowser/ImageThumbnail.js b/platform/ui/src/components/studyBrowser/ImageThumbnail.js index 5f83d7348..2fb260ced 100644 --- a/platform/ui/src/components/studyBrowser/ImageThumbnail.js +++ b/platform/ui/src/components/studyBrowser/ImageThumbnail.js @@ -2,7 +2,7 @@ import './ImageThumbnail.styl'; import { utils } from '@ohif/core'; -import React, { useState, useEffect, createRef } from 'react'; +import React, { useState, useEffect, createRef, useCallback } from 'react'; import classNames from 'classnames'; import PropTypes from 'prop-types'; @@ -23,6 +23,7 @@ function ImageThumbnail(props) { imageId, stackPercentComplete, error: propsError, + showProgressBar, } = props; const [isLoading, setLoading] = useState(false); @@ -39,7 +40,8 @@ function ImageThumbnail(props) { loadingOrError = ; } - const showStackLoadingProgressBar = stackPercentComplete !== undefined; + const showStackLoadingProgressBar = + showProgressBar && stackPercentComplete !== undefined; const shouldRenderToCanvas = () => { return imageId && !imageSrc; @@ -71,11 +73,11 @@ function ImageThumbnail(props) { } }; - const purgeCancelablePromise = () => { + const purgeCancelablePromise = useCallback(() => { if (cancelablePromise) { cancelablePromise.cancel(); } - }; + }); useEffect(() => { return () => { @@ -96,7 +98,13 @@ function ImageThumbnail(props) { setImagePromise(); fetchImagePromise(); } - }, [fetchImagePromise, image.imageId, imageId, purgeCancelablePromise, setImagePromise]); + }, [ + fetchImagePromise, + image.imageId, + imageId, + purgeCancelablePromise, + setImagePromise, + ]); return (
@@ -104,14 +112,14 @@ function ImageThumbnail(props) { {shouldRenderToCanvas() ? ( ) : ( - {''} - )} + {''} + )}
{loadingOrError} {showStackLoadingProgressBar && ( @@ -135,6 +143,7 @@ ImageThumbnail.propTypes = { width: PropTypes.number, height: PropTypes.number, stackPercentComplete: PropTypes.number.isRequired, + showProgressBar: PropTypes.bool, }; ImageThumbnail.defaultProps = { @@ -143,6 +152,7 @@ ImageThumbnail.defaultProps = { stackPercentComplete: 0, width: 217, height: 123, + showProgressBar: true, }; export default ImageThumbnail; diff --git a/platform/ui/src/components/studyBrowser/StudyBrowser.js b/platform/ui/src/components/studyBrowser/StudyBrowser.js index 0bb75058a..a745c02fb 100644 --- a/platform/ui/src/components/studyBrowser/StudyBrowser.js +++ b/platform/ui/src/components/studyBrowser/StudyBrowser.js @@ -9,6 +9,7 @@ function StudyBrowser(props) { onThumbnailClick, onThumbnailDoubleClick, supportsDrag, + showThumbnailProgressBar, } = props; return ( @@ -28,7 +29,6 @@ function StudyBrowser(props) { numImageFrames, SeriesDescription, SeriesNumber, - stackPercentComplete, hasWarnings, } = thumb; @@ -54,13 +54,13 @@ function StudyBrowser(props) { SeriesDescription={SeriesDescription} SeriesNumber={SeriesNumber} hasWarnings={hasWarnings} - stackPercentComplete={stackPercentComplete} // Events onClick={onThumbnailClick.bind( undefined, displaySetInstanceUID )} onDoubleClick={onThumbnailDoubleClick} + showProgressBar={showThumbnailProgressBar} /> ); @@ -95,6 +95,7 @@ StudyBrowser.propTypes = { supportsDrag: PropTypes.bool, onThumbnailClick: PropTypes.func, onThumbnailDoubleClick: PropTypes.func, + showThumbnailProgressBar: PropTypes.bool, }; StudyBrowser.defaultProps = { @@ -102,6 +103,7 @@ StudyBrowser.defaultProps = { supportsDrag: true, onThumbnailClick: noop, onThumbnailDoubleClick: noop, + showThumbnailProgressBar: true, }; export { StudyBrowser }; diff --git a/platform/ui/src/components/studyBrowser/Thumbnail.js b/platform/ui/src/components/studyBrowser/Thumbnail.js index 94e76ec4d..36bb5f68c 100644 --- a/platform/ui/src/components/studyBrowser/Thumbnail.js +++ b/platform/ui/src/components/studyBrowser/Thumbnail.js @@ -1,34 +1,36 @@ import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; import { useDrag } from 'react-dnd'; +import { classes } from '@ohif/core'; import ImageThumbnail from './ImageThumbnail'; import classNames from 'classnames'; import { Icon } from './../../elements/Icon'; import { Tooltip } from './../tooltip'; import { OverlayTrigger } from './../overlayTrigger'; - import './Thumbnail.styl'; +const StudyLoadingListener = classes.StudyLoadingListener; + function ThumbnailFooter({ SeriesDescription, SeriesNumber, InstanceNumber, numImageFrames, - hasWarnings + hasWarnings, }) { const [inconsistencyWarnings, inconsistencyWarningsSet] = useState([]); useEffect(() => { - let unmounted = false + let unmounted = false; hasWarnings.then(response => { if (!unmounted) { - inconsistencyWarningsSet(response) + inconsistencyWarningsSet(response); } - }) + }); return () => { - unmounted = true - } - }, []) + unmounted = true; + }; + }, [hasWarnings]); const infoOnly = !SeriesDescription; @@ -41,7 +43,7 @@ function ThumbnailFooter({ ); }; - const getWarningContent = (inconsistencyWarnings) => { + const getWarningContent = inconsistencyWarnings => { if (Array.isArray(inconsistencyWarnings)) { const listedWarnings = inconsistencyWarnings.map((warn, index) => { return
  • {warn}
  • ; @@ -54,8 +56,8 @@ function ThumbnailFooter({ }; const getWarningInfo = (SeriesNumber, inconsistencyWarnings) => { - return( - + return ( + {inconsistencyWarnings && inconsistencyWarnings.length != 0 ? (
    Series Inconsistencies
    -
    {getWarningContent(inconsistencyWarnings)}
    +
    + {getWarningContent(inconsistencyWarnings)} +
    } > @@ -79,9 +83,9 @@ function ThumbnailFooter({
    ) : ( - )} + )}
    - ); + ); }; const getSeriesInformation = ( SeriesNumber, @@ -92,21 +96,27 @@ function ThumbnailFooter({ if (!SeriesNumber && !InstanceNumber && !numImageFrames) { return; } - const seriesInformation = + const seriesInformation = (
    {getInfo(SeriesNumber, 'S:')} {getInfo(InstanceNumber, 'I:')} {getInfo(numImageFrames, '', 'image-frames')} {getWarningInfo(SeriesNumber, inconsistencyWarnings)}
    + ); - return (seriesInformation); + return seriesInformation; }; return (
    {SeriesDescription}
    - {getSeriesInformation(SeriesNumber, InstanceNumber, numImageFrames, inconsistencyWarnings)} + {getSeriesInformation( + SeriesNumber, + InstanceNumber, + numImageFrames, + inconsistencyWarnings + )}
    ); } @@ -124,14 +134,37 @@ function Thumbnail(props) { SeriesDescription, SeriesNumber, hasWarnings, - stackPercentComplete, StudyInstanceUID, onClick, onDoubleClick, onMouseDown, supportsDrag, + showProgressBar, } = props; + const [stackPercentComplete, setStackPercentComplete] = useState(0); + useEffect(() => { + const onProgressChange = ({ detail }) => { + const { progressId, progressData } = detail; + if (`StackProgress:${displaySetInstanceUID}` === progressId) { + const percent = progressData ? progressData.percentComplete : 0; + setStackPercentComplete(percent); + } + }; + + document.addEventListener( + StudyLoadingListener.events.OnProgress, + onProgressChange + ); + + return () => { + document.removeEventListener( + StudyLoadingListener.events.OnProgress, + onProgressChange + ); + }; + }, [displaySetInstanceUID]); + const [collectedProps, drag, dragPreview] = useDrag({ // `droppedItem` in `dropTarget` // The only data it will have access to @@ -164,6 +197,7 @@ function Thumbnail(props) { imageId={imageId} error={error} stackPercentComplete={stackPercentComplete} + showProgressBar={showProgressBar} /> )} {/* SHOW TEXT ALTERNATIVE */} @@ -203,6 +237,7 @@ Thumbnail.propTypes = { onDoubleClick: PropTypes.func, onClick: PropTypes.func, onMouseDown: PropTypes.func, + showProgressBar: PropTypes.bool, }; Thumbnail.defaultProps = { diff --git a/platform/viewer/public/config/default.js b/platform/viewer/public/config/default.js index 7bc3bc78e..9ca1920db 100644 --- a/platform/viewer/public/config/default.js +++ b/platform/viewer/public/config/default.js @@ -5,6 +5,15 @@ window.config = { showStudyList: true, filterQueryParam: false, disableServersCache: false, + studyPrefetcher: { + enabled: true, + order: 'closest', + displaySetCount: 3, + preventCache: false, + prefetchDisplaySetsTimeout: 300, + displayProgress: true, + includeActiveDisplaySet: true, + }, servers: { dicomWeb: [ { diff --git a/platform/viewer/public/config/netlify.js b/platform/viewer/public/config/netlify.js index b85f5b0ac..497d1135b 100644 --- a/platform/viewer/public/config/netlify.js +++ b/platform/viewer/public/config/netlify.js @@ -1,6 +1,15 @@ window.config = { routerBasename: '/pwa', showStudyList: true, + studyPrefetcher: { + enabled: true, + order: 'closest', + displaySetCount: 3, + preventCache: false, + prefetchDisplaySetsTimeout: 300, + displayProgress: true, + includeActiveDisplaySet: true, + }, servers: { dicomWeb: [ { diff --git a/platform/viewer/src/components/StudyLoadingMonitor.js b/platform/viewer/src/components/StudyLoadingMonitor.js index 75698dc24..88a9b0a6b 100644 --- a/platform/viewer/src/components/StudyLoadingMonitor.js +++ b/platform/viewer/src/components/StudyLoadingMonitor.js @@ -1,42 +1,23 @@ -import { Component } from "react"; -import PropTypes from "prop-types"; -import OHIF from "@ohif/core"; +import { useEffect } from 'react'; +import OHIF from '@ohif/core'; -class StudyLoadingMonitor extends Component { - static propTypes = { - studies: PropTypes.array.isRequired, - setStudyLoadingProgress: PropTypes.func.isRequired, - clearStudyLoadingProgress: PropTypes.func.isRequired - }; +const { StudyLoadingListener } = OHIF.classes; - componentDidMount() { - // TODO: This is pretty ugly. The thing is that the StudyLoadingListener - // needs to update the Redux store, but shouldn't know that it exists. - // I am therefore passing in some functions to update the store instead, - // but this should definitely be cleaned up somehow. - const options = { - _setProgressData: (progressId, progressData) => { - this.props.setStudyLoadingProgress(progressId, progressData); - }, - _clearProgressById: progressId => { - this.props.clearStudyLoadingProgress(progressId); - } +const StudyLoadingMonitor = ({ studies }) => { + useEffect(() => { + const studyLoadingListener = StudyLoadingListener.getInstance(); + + if (studies && studies.length > 0) { + studyLoadingListener.clear(); + studyLoadingListener.addStudies(studies); + } + + return () => { + studyLoadingListener.clear(); }; + }, [studies]); - const { StudyLoadingListener } = OHIF.classes; - this.studyLoadingListener = StudyLoadingListener.getInstance(options); - this.studyLoadingListener.clear(); - this.studyLoadingListener.addStudies(this.props.studies); - } - - render() { - return null; - } - - componentWillUnmount() { - // Destroy stack loading listeners when we close the viewer - this.studyLoadingListener.clear(); - } -} + return null; +}; export default StudyLoadingMonitor; diff --git a/platform/viewer/src/components/StudyPrefetcher.css b/platform/viewer/src/components/StudyPrefetcher.css new file mode 100644 index 000000000..fe9acbfef --- /dev/null +++ b/platform/viewer/src/components/StudyPrefetcher.css @@ -0,0 +1,15 @@ +.StudyPrefetcher { + height: 5px; + width: 100%; + display: flex; + flex-direction: row; + background-color: var(--default-color); +} + +.StudyPrefetcher .item { + height: 5px; +} + +.StudyPrefetcher .item.cached { + background-color: black; +} diff --git a/platform/viewer/src/components/StudyPrefetcher.js b/platform/viewer/src/components/StudyPrefetcher.js index f6500776b..2a0bb4763 100644 --- a/platform/viewer/src/components/StudyPrefetcher.js +++ b/platform/viewer/src/components/StudyPrefetcher.js @@ -1,29 +1,93 @@ -import { Component } from "react"; -import PropTypes from "prop-types"; -import OHIF from "@ohif/core"; +import React, { useEffect } from 'react'; +import { classes, utils } from '@ohif/core'; +import PropTypes from 'prop-types'; +import cs from 'cornerstone-core'; -const { StudyPrefetcher } = OHIF.classes; +import './StudyPrefetcher.css'; -class StudyPrefetcherComponent extends Component { - static propTypes = { - studies: PropTypes.array - }; +const StudyPrefetcher = ({ studies, options }) => { + useEffect(() => { + const studyPrefetcher = classes.StudyPrefetcher.getInstance( + studies, + options + ); + const studiesMetadata = studies.map(s => + utils.studyMetadataManager.get(s.StudyInstanceUID) + ); + studyPrefetcher.setStudies(studiesMetadata); - componentDidMount() { - const { studies } = this.props; + const onNewImage = ({ detail }) => { + /** + * When images are cached the viewport will load instantly and + * the display sets will not be available at this point in time. + * + * This code add display sets and updates the study prefetcher metadata. + */ + const studiesMetadata = studies.map(s => { + const studyMetadata = utils.studyMetadataManager.get( + s.StudyInstanceUID + ); + const displaySets = studyMetadata.getDisplaySets(); + if (!displaySets || displaySets.length < 1) { + s.displaySets.forEach(ds => studyMetadata.addDisplaySet(ds)); + } + return studyMetadata; + }); + studyPrefetcher.setStudies(studiesMetadata); - this.studyPrefetcher = StudyPrefetcher.getInstance(); - this.studyPrefetcher.setStudies(studies); - } + const study = studyPrefetcher.getStudy(detail.image); + const series = studyPrefetcher.getSeries(study, detail.image); + const instance = studyPrefetcher.getInstance(series, detail.image); - render() { - return null; - } + if (study.displaySets && study.displaySets.length > 0) { + const { + displaySetInstanceUID, + } = studyPrefetcher.getDisplaySetBySOPInstanceUID( + study.displaySets, + instance + ); + studyPrefetcher.prefetch(detail.element, displaySetInstanceUID); + } + }; - componentWillUnmount() { - // Stop prefetching when we close the viewer - this.studyPrefetcher.destroy(); - } -} + const onElementEnabled = ({ detail }) => { + detail.element.addEventListener(cs.EVENTS.NEW_IMAGE, onNewImage); + }; -export default StudyPrefetcherComponent; + cs.events.addEventListener(cs.EVENTS.ELEMENT_ENABLED, onElementEnabled); + + return () => { + cs.events.removeEventListener( + cs.EVENTS.ELEMENT_ENABLED, + onElementEnabled + ); + studyPrefetcher.destroy(); + }; + }, [options, studies]); + + return null; +}; + +StudyPrefetcher.propTypes = { + studies: PropTypes.array.isRequired, + options: PropTypes.shape({ + enabled: PropTypes.bool, + order: PropTypes.string, + displaySetCount: PropTypes.number, + preventCache: PropTypes.bool, + prefetchDisplaySetsTimeout: PropTypes.number, + includeActiveDisplaySet: PropTypes.bool, + }), +}; + +StudyPrefetcher.defaultProps = { + options: { + order: 'closest', + displaySetCount: 1, + preventCache: false, + prefetchDisplaySetsTimeout: 300, + includeActiveDisplaySet: false, + }, +}; + +export default StudyPrefetcher; diff --git a/platform/viewer/src/connectedComponents/ConnectedStudyBrowser.js b/platform/viewer/src/connectedComponents/ConnectedStudyBrowser.js index 22efaeb12..0f6a129ce 100644 --- a/platform/viewer/src/connectedComponents/ConnectedStudyBrowser.js +++ b/platform/viewer/src/connectedComponents/ConnectedStudyBrowser.js @@ -1,44 +1,11 @@ import OHIF from '@ohif/core'; import { connect } from 'react-redux'; -import { StudyBrowser } from '@ohif/ui'; -import cloneDeep from 'lodash.clonedeep'; import findDisplaySetByUID from './findDisplaySetByUID'; import { servicesManager } from './../App.js'; - -const { studyMetadataManager } = OHIF.utils; +import { StudyBrowser } from '../../../ui/src/components/studyBrowser/StudyBrowser'; const { setActiveViewportSpecificData } = OHIF.redux.actions; -// TODO -// - Determine in which display set is active from Redux (activeViewportIndex and layout viewportData) -// - Pass in errors and stack loading progress from Redux -const mapStateToProps = (state, ownProps) => { - // If we know that the stack loading progress details have changed, - // we can try to update the component state so that the thumbnail - // progress bar is updated - const stackLoadingProgressMap = state.loading.progress; - const studiesWithLoadingData = cloneDeep(ownProps.studies); - - studiesWithLoadingData.forEach(study => { - study.thumbnails.forEach(data => { - const { displaySetInstanceUID } = data; - const stackId = `StackProgress:${displaySetInstanceUID}`; - const stackProgressData = stackLoadingProgressMap[stackId]; - - let stackPercentComplete = 0; - if (stackProgressData) { - stackPercentComplete = stackProgressData.percentComplete; - } - - data.stackPercentComplete = stackPercentComplete; - }); - }); - - return { - studies: studiesWithLoadingData, - }; -}; - const mapDispatchToProps = (dispatch, ownProps) => { return { onThumbnailClick: displaySetInstanceUID => { @@ -50,7 +17,10 @@ const mapDispatchToProps = (dispatch, ownProps) => { if (displaySet.isDerived) { const { Modality } = displaySet; if (Modality === 'SEG' && servicesManager) { - const {LoggerService, UINotificationService} = servicesManager.services; + const { + LoggerService, + UINotificationService, + } = servicesManager.services; const onDisplaySetLoadFailureHandler = error => { LoggerService.error({ error, message: error.message }); UINotificationService.show({ @@ -61,20 +31,25 @@ const mapDispatchToProps = (dispatch, ownProps) => { }); }; - const {referencedDisplaySet, activatedLabelmapPromise} = displaySet.getSourceDisplaySet( + const { + referencedDisplaySet, + activatedLabelmapPromise, + } = displaySet.getSourceDisplaySet( ownProps.studyMetadata, true, onDisplaySetLoadFailureHandler ); displaySet = referencedDisplaySet; - activatedLabelmapPromise.then((activatedLabelmapIndex) => { - const selectionFired = new CustomEvent("extensiondicomsegmentationsegselected", { - "detail": {"activatedLabelmapIndex":activatedLabelmapIndex} - }); + activatedLabelmapPromise.then(activatedLabelmapIndex => { + const selectionFired = new CustomEvent( + 'extensiondicomsegmentationsegselected', + { + detail: { activatedLabelmapIndex: activatedLabelmapIndex }, + } + ); document.dispatchEvent(selectionFired); }); - } else { displaySet = displaySet.getSourceDisplaySet(ownProps.studyMetadata); } @@ -96,7 +71,7 @@ const mapDispatchToProps = (dispatch, ownProps) => { }; const ConnectedStudyBrowser = connect( - mapStateToProps, + null, mapDispatchToProps )(StudyBrowser); diff --git a/platform/viewer/src/connectedComponents/ConnectedStudyLoadingMonitor.js b/platform/viewer/src/connectedComponents/ConnectedStudyLoadingMonitor.js deleted file mode 100644 index 8bfffe88a..000000000 --- a/platform/viewer/src/connectedComponents/ConnectedStudyLoadingMonitor.js +++ /dev/null @@ -1,26 +0,0 @@ -import { connect } from "react-redux"; -import StudyLoadingMonitor from "../components/StudyLoadingMonitor.js"; -import OHIF from "@ohif/core"; - -const { - setStudyLoadingProgress, - clearStudyLoadingProgress -} = OHIF.redux.actions; - -const mapDispatchToProps = dispatch => { - return { - setStudyLoadingProgress: (progressId, progressData) => { - dispatch(setStudyLoadingProgress(progressId, progressData)); - }, - clearStudyLoadingProgress: progressId => { - dispatch(clearStudyLoadingProgress(progressId)); - } - }; -}; - -const ConnectedStudyLoadingMonitor = connect( - null, - mapDispatchToProps -)(StudyLoadingMonitor); - -export default ConnectedStudyLoadingMonitor; diff --git a/platform/viewer/src/connectedComponents/Viewer.js b/platform/viewer/src/connectedComponents/Viewer.js index c4e2b25d2..e6152c872 100644 --- a/platform/viewer/src/connectedComponents/Viewer.js +++ b/platform/viewer/src/connectedComponents/Viewer.js @@ -1,11 +1,10 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import { useLogger } from '@ohif/ui'; - import OHIF, { MODULE_TYPES, DICOMSR } from '@ohif/core'; import { withDialog } from '@ohif/ui'; import moment from 'moment'; + import ConnectedHeader from './ConnectedHeader.js'; import ToolbarRow from './ToolbarRow.js'; import ConnectedStudyBrowser from './ConnectedStudyBrowser.js'; @@ -14,7 +13,6 @@ import SidePanel from './../components/SidePanel.js'; import ErrorBoundaryDialog from './../components/ErrorBoundaryDialog'; import { extensionManager } from './../App.js'; import { ReconstructionIssues } from './../../../core/src/enums.js'; -import dcmjs from 'dcmjs'; // Contexts import WhiteLabelingContext from '../context/WhiteLabelingContext.js'; @@ -22,8 +20,8 @@ import UserManagerContext from '../context/UserManagerContext'; import AppContext from '../context/AppContext'; import './Viewer.css'; -import { finished } from 'stream'; -import { cornerstoneWADOImageLoader } from 'cornerstone-wado-image-loader'; +import StudyPrefetcher from '../components/StudyPrefetcher.js'; +import StudyLoadingMonitor from '../components/StudyLoadingMonitor'; class Viewer extends Component { static propTypes = { @@ -197,11 +195,17 @@ class Viewer extends Component { ]); } - const activeViewport = this.props.viewports[this.props.activeViewportIndex]; - const activeDisplaySetInstanceUID = - activeViewport ? activeViewport.displaySetInstanceUID : undefined; + const activeViewport = this.props.viewports[ + this.props.activeViewportIndex + ]; + const activeDisplaySetInstanceUID = activeViewport + ? activeViewport.displaySetInstanceUID + : undefined; this.setState({ - thumbnails: _mapStudiesToThumbnails(studies, activeDisplaySetInstanceUID), + thumbnails: _mapStudiesToThumbnails( + studies, + activeDisplaySetInstanceUID + ), }); } } @@ -211,24 +215,31 @@ class Viewer extends Component { studies, isStudyLoaded, activeViewportIndex, - viewports + viewports, } = this.props; const activeViewport = viewports[activeViewportIndex]; - const activeDisplaySetInstanceUID = - activeViewport ? activeViewport.displaySetInstanceUID : undefined; + const activeDisplaySetInstanceUID = activeViewport + ? activeViewport.displaySetInstanceUID + : undefined; - const prevActiveViewport = prevProps.viewports[prevProps.activeViewportIndex]; - const prevActiveDisplaySetInstanceUID = - prevActiveViewport ? prevActiveViewport.displaySetInstanceUID : undefined; + const prevActiveViewport = + prevProps.viewports[prevProps.activeViewportIndex]; + const prevActiveDisplaySetInstanceUID = prevActiveViewport + ? prevActiveViewport.displaySetInstanceUID + : undefined; - if (studies !== prevProps.studies || + if ( + studies !== prevProps.studies || activeViewportIndex !== prevProps.activeViewportIndex || activeDisplaySetInstanceUID !== prevActiveDisplaySetInstanceUID - ) { - + ) { this.setState({ - thumbnails: _mapStudiesToThumbnails(studies, activeDisplaySetInstanceUID), + thumbnails: _mapStudiesToThumbnails( + studies, + activeDisplaySetInstanceUID + ), + activeDisplaySetInstanceUID, }); } if (isStudyLoaded && isStudyLoaded !== prevProps.isStudyLoaded) { @@ -288,14 +299,12 @@ class Viewer extends Component { )} - {/* TOOLBAR */} - - {/**/} - {/**/} - + + {appContext => } + {/* VIEWPORTS + SIDEPANELS */}
    {/* LEFT */} @@ -348,10 +356,24 @@ class Viewer extends Component { activeIndex={this.props.activeViewportIndex} /> ) : ( - + + {appContext => { + const { appConfig } = appContext; + const { studyPrefetcher } = appConfig; + const { thumbnails } = this.state; + return ( + + ); + }} + )} @@ -359,6 +381,22 @@ class Viewer extends Component { {/* MAIN */}
    + + {appContext => { + const { appConfig } = appContext; + const { studyPrefetcher } = appConfig; + const { studies } = this.props; + return ( + studyPrefetcher && + studyPrefetcher.enabled && ( + + ) + ); + }} + { switch (warning) { case ReconstructionIssues.DATASET_4D: inconsistencyWarnings.push('The dataset is 4D.'); break; case ReconstructionIssues.VARYING_IMAGESDIMENSIONS: - inconsistencyWarnings.push('The dataset frames have different dimensions (rows, columns).'); + inconsistencyWarnings.push( + 'The dataset frames have different dimensions (rows, columns).' + ); break; case ReconstructionIssues.VARYING_IMAGESCOMPONENTS: - inconsistencyWarnings.push('The dataset frames have different components (Sample per pixel).'); + inconsistencyWarnings.push( + 'The dataset frames have different components (Sample per pixel).' + ); break; case ReconstructionIssues.VARYING_IMAGESORIENTATION: - inconsistencyWarnings.push('The dataset frames have different orientation.'); + inconsistencyWarnings.push( + 'The dataset frames have different orientation.' + ); break; case ReconstructionIssues.IRREGULAR_SPACING: - inconsistencyWarnings.push('The dataset frames have different pixel spacing.'); + inconsistencyWarnings.push( + 'The dataset frames have different pixel spacing.' + ); break; case ReconstructionIssues.MULTIFFRAMES: inconsistencyWarnings.push('The dataset is a multiframes.'); @@ -443,13 +495,22 @@ const _checkForSeriesInconsistencesWarnings = async function (displaySet, studie break; } }); - inconsistencyWarnings.push('The datasets is not a reconstructable 3D volume. MPR mode is not available.'); + inconsistencyWarnings.push( + 'The datasets is not a reconstructable 3D volume. MPR mode is not available.' + ); } - if (displaySet.missingFrames && + if ( + displaySet.missingFrames && (!displaySet.reconstructionIssues || - (displaySet.reconstructionIssues && !displaySet.reconstructionIssues.find(warn => warn === ReconstructionIssues.DATASET_4D)))) { - inconsistencyWarnings.push('The datasets is missing frames: ' + displaySet.missingFrames + '.'); + (displaySet.reconstructionIssues && + !displaySet.reconstructionIssues.find( + warn => warn === ReconstructionIssues.DATASET_4D + ))) + ) { + inconsistencyWarnings.push( + 'The datasets is missing frames: ' + displaySet.missingFrames + '.' + ); } } else { const segMetadata = displaySet.metadata; @@ -458,24 +519,31 @@ const _checkForSeriesInconsistencesWarnings = async function (displaySet, studie return inconsistencyWarnings; } - const { referencedDisplaySet } = displaySet.getSourceDisplaySet(studies, false); + const { referencedDisplaySet } = displaySet.getSourceDisplaySet( + studies, + false + ); if (!referencedDisplaySet) { displaySet.inconsistencyWarnings = inconsistencyWarnings; return inconsistencyWarnings; } - const imageIds = referencedDisplaySet.images.map(image => image.getImageId()); + const imageIds = referencedDisplaySet.images.map(image => + image.getImageId() + ); if (!imageIds || imageIds.length === 0) { displaySet.inconsistencyWarnings = inconsistencyWarnings; return inconsistencyWarnings; } for ( - let i = 0, groupsLen = segMetadata.PerFrameFunctionalGroupsSequence.length; + let i = 0, + groupsLen = segMetadata.PerFrameFunctionalGroupsSequence.length; i < groupsLen; ++i ) { - const PerFrameFunctionalGroups = segMetadata.PerFrameFunctionalGroupsSequence[i]; + const PerFrameFunctionalGroups = + segMetadata.PerFrameFunctionalGroupsSequence[i]; if (!PerFrameFunctionalGroups) { continue; } @@ -485,29 +553,27 @@ const _checkForSeriesInconsistencesWarnings = async function (displaySet, studie SourceImageSequence = segMetadata.SourceImageSequence[i]; } else if (PerFrameFunctionalGroups.DerivationImageSequence) { SourceImageSequence = - PerFrameFunctionalGroups.DerivationImageSequence - .SourceImageSequence; + PerFrameFunctionalGroups.DerivationImageSequence.SourceImageSequence; } if (!SourceImageSequence) { if (inconsistencyWarnings.length === 0) { - const warningMessage = 'The segmentation ' + + const warningMessage = + 'The segmentation ' + 'has frames out of plane respect to the source images.'; - inconsistencyWarnings.push(warningMessage); + inconsistencyWarnings.push(warningMessage); } continue; } - const { - ReferencedSOPInstanceUID, - } = SourceImageSequence; + const { ReferencedSOPInstanceUID } = SourceImageSequence; const imageId = imageIds.find(imageId => { const sopCommonModule = cornerstone.metaData.get( - "sopCommonModule", - imageId + 'sopCommonModule', + imageId ); if (!sopCommonModule) { - return; + return; } return sopCommonModule.sopInstanceUID === ReferencedSOPInstanceUID; @@ -517,33 +583,32 @@ const _checkForSeriesInconsistencesWarnings = async function (displaySet, studie continue; } - const sourceImageMetadata = cornerstone.metaData.get( - "instance", - imageId - ); + const sourceImageMetadata = cornerstone.metaData.get('instance', imageId); if ( segMetadata.Rows !== sourceImageMetadata.Rows || segMetadata.Columns !== sourceImageMetadata.Columns ) { - const warningMessage = 'The segmentation ' + + const warningMessage = + 'The segmentation ' + 'has frames with different geometry ' + 'dimensions (Rows and Columns) respect to the source images.'; - inconsistencyWarnings.push(warningMessage); + inconsistencyWarnings.push(warningMessage); break; } } if (inconsistencyWarnings.length !== 0) { - const warningMessage = 'The segmentation format is not supported yet. ' + + const warningMessage = + 'The segmentation format is not supported yet. ' + 'The segmentation data (segments) could not be loaded.'; - inconsistencyWarnings.push(warningMessage); + inconsistencyWarnings.push(warningMessage); } } // cache the warnings displaySet.inconsistencyWarnings = inconsistencyWarnings; return inconsistencyWarnings; -} +}; /** * Checks if display set is active, i.e. if the series is currently shown @@ -562,30 +627,42 @@ const _checkForSeriesInconsistencesWarnings = async function (displaySet, studie * @param {string} activeDisplaySetInstanceUID * @returns {boolean} is active. */ - const _isDisplaySetActive = function(displaySet, studies, activeDisplaySetInstanceUID) { +const _isDisplaySetActive = function( + displaySet, + studies, + activeDisplaySetInstanceUID +) { let active = false; - const { - displaySetInstanceUID, - } = displaySet; + const { displaySetInstanceUID } = displaySet; // TO DO: in the future, we could possibly support new modalities // we should have a list of all modalities here, instead of having hard coded checks - if (displaySet.Modality !== 'SEG' && + if ( + displaySet.Modality !== 'SEG' && displaySet.Modality !== 'RTSTRUCT' && - displaySet.Modality !== 'RTDOSE') { + displaySet.Modality !== 'RTDOSE' + ) { active = activeDisplaySetInstanceUID === displaySetInstanceUID; - } else if (displaySet.getSourceDisplaySet){ + } else if (displaySet.getSourceDisplaySet) { if (displaySet.Modality === 'SEG') { - const { referencedDisplaySet } = displaySet.getSourceDisplaySet(studies, false); - active = referencedDisplaySet ? - activeDisplaySetInstanceUID === referencedDisplaySet.displaySetInstanceUID : - false; + const { referencedDisplaySet } = displaySet.getSourceDisplaySet( + studies, + false + ); + active = referencedDisplaySet + ? activeDisplaySetInstanceUID === + referencedDisplaySet.displaySetInstanceUID + : false; } else { - const referencedDisplaySet = displaySet.getSourceDisplaySet(studies, false); - active = referencedDisplaySet ? - activeDisplaySetInstanceUID === referencedDisplaySet.displaySetInstanceUID : - false; + const referencedDisplaySet = displaySet.getSourceDisplaySet( + studies, + false + ); + active = referencedDisplaySet + ? activeDisplaySetInstanceUID === + referencedDisplaySet.displaySetInstanceUID + : false; } } @@ -629,11 +706,17 @@ const _mapStudiesToThumbnails = function(studies, activeDisplaySetInstanceUID) { altImageText = displaySet.Modality ? displaySet.Modality : 'UN'; } - const hasWarnings = _checkForSeriesInconsistencesWarnings(displaySet, studies); - const active = _isDisplaySetActive(displaySet, studies, activeDisplaySetInstanceUID) + const hasWarnings = _checkForSeriesInconsistencesWarnings( + displaySet, + studies + ); return { - active, + active: _isDisplaySetActive( + displaySet, + studies, + activeDisplaySetInstanceUID + ), imageId, altImageText, displaySetInstanceUID,