diff --git a/extensions/cornerstone/src/init.tsx b/extensions/cornerstone/src/init.tsx index 890c5fbc9..cba90a855 100644 --- a/extensions/cornerstone/src/init.tsx +++ b/extensions/cornerstone/src/init.tsx @@ -25,6 +25,7 @@ import initCornerstoneTools from './initCornerstoneTools'; import { connectToolsToMeasurementService } from './initMeasurementService'; import initCineService from './initCineService'; +import initStudyPrefetcherService from './initStudyPrefetcherService'; import interleaveCenterLoader from './utils/interleaveCenterLoader'; import nthLoader from './utils/nthLoader'; import interleaveTopToBottom from './utils/interleaveTopToBottom'; @@ -98,6 +99,7 @@ export default async function init({ hangingProtocolService, viewportGridService, stateSyncService, + studyPrefetcherService } = servicesManager.services; window.services = servicesManager.services; @@ -190,6 +192,7 @@ export default async function init({ this.measurementServiceSource = connectToolsToMeasurementService(servicesManager); initCineService(servicesManager); + initStudyPrefetcherService(servicesManager); // When a custom image load is performed, update the relevant viewports hangingProtocolService.subscribe( diff --git a/extensions/cornerstone/src/initStudyPrefetcherService.ts b/extensions/cornerstone/src/initStudyPrefetcherService.ts new file mode 100644 index 000000000..78020f436 --- /dev/null +++ b/extensions/cornerstone/src/initStudyPrefetcherService.ts @@ -0,0 +1,33 @@ +import { cache, imageLoadPoolManager, imageLoader, Enums, eventTarget, EVENTS as csEvents } from '@cornerstonejs/core'; + +function initStudyPrefetcherService(servicesManager: AppTypes.ServicesManager) { + const { studyPrefetcherService } = servicesManager.services; + + studyPrefetcherService.requestType = Enums.RequestType.Prefetch; + studyPrefetcherService.imageLoadPoolManager = imageLoadPoolManager; + studyPrefetcherService.imageLoader = imageLoader; + + studyPrefetcherService.cache = { + isImageCached(imageId: string): boolean { + return !!cache.getImageLoadObject(imageId); + } + } + + studyPrefetcherService.imageLoadEventsManager = { + addEventListeners(onImageLoaded, onImageLoadFailed) { + eventTarget.addEventListener(csEvents.IMAGE_LOADED, onImageLoaded); + eventTarget.addEventListener(csEvents.IMAGE_LOAD_FAILED, onImageLoadFailed); + + return [ + { + unsubscribe: () => eventTarget.removeEventListener(csEvents.IMAGE_LOADED, onImageLoaded) + }, + { + unsubscribe: () => eventTarget.removeEventListener(csEvents.IMAGE_LOAD_FAILED, onImageLoadFailed) + }, + ] + } + } +} + +export default initStudyPrefetcherService; diff --git a/extensions/default/src/utils/createRenderedRetrieve.js b/extensions/default/src/utils/createRenderedRetrieve.js index 6e411e31b..ff490400c 100644 --- a/extensions/default/src/utils/createRenderedRetrieve.js +++ b/extensions/default/src/utils/createRenderedRetrieve.js @@ -17,12 +17,13 @@ const createRenderedRetrieve = (config, params) => { const { wadoRoot } = config; const { instance, tag = 'PixelData' } = params; const { StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID } = instance; - const value = instance[tag]; + const bulkDataURI = instance[tag]?.BulkDataURI ?? ''; - if (value?.BulkDataURI?.indexOf('?') !== -1) { + if (bulkDataURI?.indexOf('?') !== -1) { // The value instance has parameters, so it should not revert to the rendered return; } + if (tag === 'PixelData' || tag === 'EncapsulatedDocument') { return `${wadoRoot}/studies/${StudyInstanceUID}/series/${SeriesInstanceUID}/instances/${SOPInstanceUID}/rendered`; } diff --git a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx index c1909c8cd..076d492a6 100644 --- a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx +++ b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.tsx @@ -25,6 +25,7 @@ function PanelStudyBrowserTracking({ hangingProtocolService, uiNotificationService, measurementService, + studyPrefetcherService, } = servicesManager.services; const navigate = useNavigate(); @@ -43,6 +44,7 @@ function PanelStudyBrowserTracking({ ]); const [studyDisplayList, setStudyDisplayList] = useState([]); const [displaySets, setDisplaySets] = useState([]); + const [displaySetsLoadingState, setDisplaySetsLoadingState] = useState({}); const [thumbnailImageSrcMap, setThumbnailImageSrcMap] = useState({}); const [jumpToDisplaySet, setJumpToDisplaySet] = useState(null); @@ -162,6 +164,7 @@ function PanelStudyBrowserTracking({ const mappedDisplaySets = _mapDisplaySets( currentDisplaySets, + displaySetsLoadingState, thumbnailImageSrcMap, trackedSeries, viewports, @@ -176,12 +179,30 @@ function PanelStudyBrowserTracking({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [ displaySetService.activeDisplaySets, + displaySetsLoadingState, trackedSeries, viewports, dataSource, thumbnailImageSrcMap, ]); + // -- displaySetsLoadingState + useEffect(() => { + const { unsubscribe } = studyPrefetcherService.subscribe( + studyPrefetcherService.EVENTS.DISPLAYSET_LOAD_PROGRESS, + updatedDisplaySetLoadingState => { + const { displaySetInstanceUID, loadingProgress } = updatedDisplaySetLoadingState; + + setDisplaySetsLoadingState(prevState => ({ + ...prevState, + [displaySetInstanceUID]: loadingProgress, + })); + } + ); + + return () => unsubscribe(); + }, [studyPrefetcherService]); + // ~~ subscriptions --> displaySets useEffect(() => { // DISPLAY_SETS_ADDED returns an array of DisplaySets that were added @@ -233,6 +254,7 @@ function PanelStudyBrowserTracking({ changedDisplaySets => { const mappedDisplaySets = _mapDisplaySets( changedDisplaySets, + displaySetsLoadingState, thumbnailImageSrcMap, trackedSeries, viewports, @@ -252,6 +274,7 @@ function PanelStudyBrowserTracking({ () => { const mappedDisplaySets = _mapDisplaySets( displaySetService.getActiveDisplaySets(), + displaySetsLoadingState, thumbnailImageSrcMap, trackedSeries, viewports, @@ -270,7 +293,14 @@ function PanelStudyBrowserTracking({ SubscriptionDisplaySetsChanged.unsubscribe(); SubscriptionDisplaySetMetaDataInvalidated.unsubscribe(); }; - }, [thumbnailImageSrcMap, trackedSeries, viewports, dataSource, displaySetService]); + }, [ + displaySetsLoadingState, + thumbnailImageSrcMap, + trackedSeries, + viewports, + dataSource, + displaySetService, + ]); const tabs = createStudyBrowserTabs(StudyInstanceUIDs, studyDisplayList, displaySets); @@ -454,6 +484,7 @@ function _mapDataSourceStudies(studies) { function _mapDisplaySets( displaySets, + displaySetLoadingState, thumbnailImageSrcMap, trackedSeriesInstanceUIDs, viewports, // TODO: make array of `displaySetInstanceUIDs`? @@ -476,6 +507,7 @@ function _mapDisplaySets( componentType === 'thumbnailTracked' ? thumbnailDisplaySets : thumbnailNoImageDisplaySets; const { displaySetInstanceUID } = ds; + const loadingProgress = displaySetLoadingState?.[displaySetInstanceUID]; const thumbnailProps = { displaySetInstanceUID, @@ -484,6 +516,7 @@ function _mapDisplaySets( modality: ds.Modality, seriesDate: formatDate(ds.SeriesDate), numInstances: ds.numImageFrames, + loadingProgress, countIcon: ds.countIcon, messages: ds.messages, StudyInstanceUID: ds.StudyInstanceUID, diff --git a/platform/app/public/config/docker_nginx-orthanc.js b/platform/app/public/config/docker_nginx-orthanc.js index 44e3bf370..1d7c12a5d 100644 --- a/platform/app/public/config/docker_nginx-orthanc.js +++ b/platform/app/public/config/docker_nginx-orthanc.js @@ -12,7 +12,14 @@ window.config = { showWarningMessageForCrossOrigin: true, showCPUFallbackMessage: true, showLoadingIndicator: true, + experimentalStudyBrowserSort: false, strictZSpacingForVolumeViewport: true, + studyPrefetcher: { + enabled: true, + displaySetsCount: 2, + maxNumPrefetchRequests: 10, + order: 'closest', + }, defaultDataSourceName: 'dicomweb', dataSources: [ { @@ -47,4 +54,7 @@ window.config = { }, }, ], + httpErrorHandler: error => { + console.warn(`HTTP Error Handler (status: ${error.status})`, error); + }, }; diff --git a/platform/app/src/appInit.js b/platform/app/src/appInit.js index ff8b3818a..b72cd4042 100644 --- a/platform/app/src/appInit.js +++ b/platform/app/src/appInit.js @@ -20,6 +20,7 @@ import { CustomizationService, PanelService, WorkflowStepsService, + StudyPrefetcherService, // utils, } from '@ohif/core'; @@ -71,6 +72,7 @@ async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) { PanelService.REGISTRATION, WorkflowStepsService.REGISTRATION, StateSyncService.REGISTRATION, + [StudyPrefetcherService.REGISTRATION, appConfig.studyPrefetcher], ]); errorHandler.getHTTPErrorHandler = () => { diff --git a/platform/core/src/extensions/ExtensionManager.ts b/platform/core/src/extensions/ExtensionManager.ts index f834c1855..e87dd647e 100644 --- a/platform/core/src/extensions/ExtensionManager.ts +++ b/platform/core/src/extensions/ExtensionManager.ts @@ -134,6 +134,12 @@ export default class ExtensionManager extends PubSubService { return [...this.registeredExtensionIds]; } + private getUniqueServicesList(servicesManager: AppTypes.ServicesManager) { + // Make sure only one service instance is returned because almost all services are + // registered with different keys (eg: StudyPrefetcherService and studyPrefetcherService) + return Array.from(new Set(Object.values(servicesManager.services))); + } + /** * Calls all the services and extension on mode enters. * The service onModeEnter is called first @@ -148,11 +154,12 @@ export default class ExtensionManager extends PubSubService { _hotkeysManager, _extensionLifeCycleHooks, } = this; + const services = this.getUniqueServicesList(_servicesManager); // The onModeEnter of the service must occur BEFORE the extension // onModeEnter in order to reset the state to a standard state // before the extension restores and cached data. - for (const service of Object.values(_servicesManager.services)) { + for (const service of services) { service?.onModeEnter?.(); } @@ -172,6 +179,7 @@ export default class ExtensionManager extends PubSubService { public onModeExit(): void { const { registeredExtensionIds, _servicesManager, _commandsManager, _extensionLifeCycleHooks } = this; + const services = this.getUniqueServicesList(_servicesManager); registeredExtensionIds.forEach(extensionId => { const onModeExit = _extensionLifeCycleHooks.onModeExit[extensionId]; @@ -186,7 +194,7 @@ export default class ExtensionManager extends PubSubService { // The service onModeExit calls must occur after the extension ones // so that extension ones can store/restore data. - for (const service of Object.values(_servicesManager.services)) { + for (const service of services) { try { service?.onModeExit?.(); } catch (e) { diff --git a/platform/core/src/index.test.js b/platform/core/src/index.test.js index 7649a8702..66d4ac379 100644 --- a/platform/core/src/index.test.js +++ b/platform/core/src/index.test.js @@ -1,7 +1,7 @@ import * as OHIF from './index'; describe('Top level exports', () => { - test.only('have not changed', () => { + test('have not changed', () => { const expectedExports = [ 'MODULE_TYPES', // @@ -47,6 +47,7 @@ describe('Top level exports', () => { 'PubSubService', 'PanelService', 'WorkflowStepsService', + 'StudyPrefetcherService', 'useToolbar', ].sort(); diff --git a/platform/core/src/index.ts b/platform/core/src/index.ts index 465cfe2f9..219b32098 100644 --- a/platform/core/src/index.ts +++ b/platform/core/src/index.ts @@ -33,6 +33,7 @@ import { StateSyncService, PanelService, WorkflowStepsService, + StudyPrefetcherService, } from './services'; import { DisplaySetMessage, DisplaySetMessageList } from './services/DisplaySetService'; @@ -85,6 +86,7 @@ const OHIF = { PanelService, useToolbar, WorkflowStepsService, + StudyPrefetcherService, }; export { @@ -130,6 +132,7 @@ export { Types, PanelService, WorkflowStepsService, + StudyPrefetcherService, useToolbar, }; diff --git a/platform/core/src/services/StudyPrefetcherService/StudyPrefetcherService.ts b/platform/core/src/services/StudyPrefetcherService/StudyPrefetcherService.ts new file mode 100644 index 000000000..3edf20525 --- /dev/null +++ b/platform/core/src/services/StudyPrefetcherService/StudyPrefetcherService.ts @@ -0,0 +1,686 @@ +import { PubSubService } from '../_shared/pubSubServiceInterface'; +import { ExtensionManager } from '../../extensions'; +import ServicesManager from '../ServicesManager'; +import ViewportGridService from '../ViewportGridService'; +import { DisplaySet } from '../../types'; + +const IMAGE_REQUEST_TYPE = 'prefetch'; + +export const EVENTS = { + SERVICE_STARTED: 'event::studyPrefetcherService:started', + SERVICE_STOPPED: 'event::studyPrefetcherService:stopped', + DISPLAYSET_LOAD_PROGRESS: 'event::studyPrefetcherService:displaySetLoadProgress', + DISPLAYSET_LOAD_COMPLETE: 'event::studyPrefetcherService:displaySetLoadComplete', +}; + +/** + * Order used for prefetching display set + */ +enum StudyPrefetchOrder { + closest = 'closest', + downward = 'downward', + upward = 'upward', +} + +/** + * Study Prefetcher configuration + */ +type StudyPrefetcherConfig = { + /* Enable/disable study prefetching service */ + enabled: boolean; + /* Number of displaysets to be prefetched */ + displaySetsCount: number; + /** + * Max number of concurrent prefetch requests + * High numbers may impact on the time to load a new dropped series because + * the browser will be busy with all prefetching requests. As soon as the + * prefetch requests get fulfilled the new ones from the new dropped series + * are sent to the server. + * + * TODO: abort all prefetch requests when a new series is loaded on a viewport. + * (need to add support for `AbortController` on Cornerstone) + * */ + maxNumPrefetchRequests: number; + /* Display sets prefetching order (closest, downward and upward) */ + order: StudyPrefetchOrder; +}; + +type DisplaySetLoadingState = { + displaySetInstanceUID: string; + numInstances: number; + pendingImageIds: Set; + loadedImageIds: Set; + failedImageIds: Set; + loadingProgress: number; +}; + +type ImageRequest = { + displaySetInstanceUID: string; + imageId: string; + aborted: boolean; +}; + +type PubSubServiceSubscription = { unsubscribe: () => any }; + +interface ICache { + isImageCached(imageId: string): boolean; +} + +interface IImageLoadPoolManager { + addRequest ( + requestFn: () => Promise, + type: string, + additionalDetails: Record, + priority?: number + ); + clearRequestStack(type: string): void; +} + +interface IImageLoader { + loadAndCacheImage(imageId: string, options: any): Promise; +} + +type EventSubscription = { + unsubscribe: () => void +}; + +interface IImageLoadEventsManager { + addEventListeners( + onImageLoaded: (evt: any) => void, + onImageLoadFailed: (evt: any) => void + ): EventSubscription[]; +} + +class StudyPrefetcherService extends PubSubService { + private _extensionManager: ExtensionManager; + private _servicesManager: ServicesManager; + private _subscriptions: PubSubServiceSubscription[]; + private _activeDisplaySetsInstanceUIDs: string[] = []; + private _pendingRequests: ImageRequest[] = []; + private _inflightRequests = new Map(); + private _isRunning = false; + private _displaySetLoadingStates = new Map(); + private _imageIdsToDisplaySetsMap = new Map>(); + private config: StudyPrefetcherConfig = { + /* Enable/disable study prefetching service */ + enabled: false, + /* Number of displaysets to be prefetched */ + displaySetsCount: 1, + /** + * Max number of concurrent prefetch requests + * High numbers may impact on the time to load a new dropped series because + * the browser will be busy with all prefetching requests. As soon as the + * prefetch requests get fulfilled the new ones from the new dropped series + * are sent to the server. + * + * TODO: abort all prefetch requests when a new series is loaded on a viewport. + * (need to add support for `AbortController` on Cornerstone) + * */ + maxNumPrefetchRequests: 10, + /* Display sets prefetching order (closest, downward and upward) */ + order: StudyPrefetchOrder.downward, + }; + + // Properties set by Cornerstone extension (initStudyPrefetcherService) + public requestType: string = IMAGE_REQUEST_TYPE; + public cache: ICache; + public imageLoadPoolManager: IImageLoadPoolManager; + public imageLoader: IImageLoader; + public imageLoadEventsManager: IImageLoadEventsManager; + + public static REGISTRATION = { + name: 'studyPrefetcherService', + altName: 'StudyPrefetcherService', + create: ({ configuration, servicesManager, extensionManager }): StudyPrefetcherService => { + return new StudyPrefetcherService({ + servicesManager, + extensionManager, + configuration, + }); + }, + }; + + constructor({ + servicesManager, + extensionManager, + configuration, + }: { + servicesManager: ServicesManager; + extensionManager: ExtensionManager; + configuration: StudyPrefetcherConfig; + }) { + super(EVENTS); + + this._servicesManager = servicesManager; + this._extensionManager = extensionManager; + this._subscriptions = []; + + Object.assign(this.config, configuration); + } + + public onModeEnter(): void { + this._addEventListeners(); + } + + /** + * The onModeExit returns the service to the initial state. + */ + public onModeExit(): void { + this._removeEventListeners(); + this._stopPrefetching(); + } + + private _addImageLoadingEventsListeners() { + const fnOnImageLoadCompleted = (imageId: string) => { + // `sendNextRequests` must be called after image loaded/failed events + // to make sure prefetch requests shall be sent as soon as the active + // displaySets (active viewport) are loaded. + // + // PS: active display sets are not loaded by this service and that is why + // the requests shall not be in the inflight queue. + if (!this._inflightRequests.get(imageId)) { + this._sendNextRequests(); + } + } + + const fnImageLoadedEventListener = (evt) => { + const { image } = evt.detail; + const { imageId } = image; + + this._moveImageIdToLoadedSet(imageId); + fnOnImageLoadCompleted(imageId); + } + + const fnImageLoadFailedEventListener = (evt) => { + const { imageId } = evt.detail; + + this._moveImageIdToFailedSet(imageId); + fnOnImageLoadCompleted(imageId); + } + + return this.imageLoadEventsManager.addEventListeners( + fnImageLoadedEventListener, + fnImageLoadFailedEventListener + ); + } + + private _addServicesListeners() { + const { displaySetService, viewportGridService } = this._servicesManager.services; + + // Restart the prefetcher after any change to the displaySets + // (eg: sorting the displaySets on StudyBrowser) + const displaySetsChangedSubscription = displaySetService.subscribe( + displaySetService.EVENTS.DISPLAY_SETS_CHANGED, + () => this._syncWithActiveViewport({ forceRestart: true }) + ); + + // Loads new datasets when making a new viewport active + const viewportGridActiveViewportIdSubscription = viewportGridService.subscribe( + ViewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED, + ({ viewportId }) => this._syncWithActiveViewport({ activeViewportId: viewportId }) + ); + + // Continue loading datasets after changing the layout (eg: from 1x1 to 2x1) + const viewportGridLayoutChangedSubscription = viewportGridService.subscribe( + ViewportGridService.EVENTS.LAYOUT_CHANGED, + () => this._syncWithActiveViewport() + ); + + // Loads new datasets after loading a new display set on a viewport + const viewportGridStateChangedSubscription = viewportGridService.subscribe( + ViewportGridService.EVENTS.GRID_STATE_CHANGED, + () => this._syncWithActiveViewport() + ); + + // Loads the first datasets right after opening the viewer + const viewportGridViewportreadySubscription = viewportGridService.subscribe( + ViewportGridService.EVENTS.VIEWPORTS_READY, + () => { + this._syncWithActiveViewport(); + this._startPrefetching(); + } + ); + + return [ + displaySetsChangedSubscription, + viewportGridActiveViewportIdSubscription, + viewportGridLayoutChangedSubscription, + viewportGridStateChangedSubscription, + viewportGridViewportreadySubscription + ]; + } + + private _addEventListeners() { + const imageLoadingEventsSubscriptions = this._addImageLoadingEventsListeners(); + const servicesSubscriptions = this._addServicesListeners(); + + this._subscriptions.push(...imageLoadingEventsSubscriptions); + this._subscriptions.push(...servicesSubscriptions); + } + + private _removeEventListeners() { + this._subscriptions.forEach(subscription => subscription.unsubscribe()); + this._subscriptions = []; + } + + private _syncWithActiveViewport( + { + activeViewportId, + forceRestart + }: + { + activeViewportId?: string, + forceRestart?: boolean + } = {}) { + const { viewportGridService } = this._servicesManager.services; + const viewportGridServiceState = viewportGridService.getState(); + const { viewports } = viewportGridServiceState; + + activeViewportId = activeViewportId ?? viewportGridServiceState.activeViewportId; + + // If may be null when the viewer is loaded + if (!activeViewportId) { + return; + } + + const activeViewport = viewports.get(activeViewportId); + const displaySetUpdated = this._setActiveDisplaySetsUIDs(activeViewport.displaySetInstanceUIDs); + + if (forceRestart || displaySetUpdated) { + this._restartPrefetching(); + } + } + + private _setActiveDisplaySetsUIDs(newActiveDisplaySetInstanceUIDs: string[]): boolean { + const sameDisplaySets = + newActiveDisplaySetInstanceUIDs.length === this._activeDisplaySetsInstanceUIDs.length && + newActiveDisplaySetInstanceUIDs.every(uid => + this._activeDisplaySetsInstanceUIDs.includes(uid) + ); + + if (sameDisplaySets) { + return false; + } + + this._activeDisplaySetsInstanceUIDs = [...newActiveDisplaySetInstanceUIDs]; + this._restartPrefetching(); + + return true; + } + + private _areActiveDisplaySetsLoaded() { + const { _activeDisplaySetsInstanceUIDs: displaySetsInstanceUIDs } = this; + + return displaySetsInstanceUIDs.length && displaySetsInstanceUIDs.every(displaySetsInstanceUID => + this._displaySetLoadingStates.get(displaySetsInstanceUID).loadingProgress >= 1); + } + + private _getClosestDisplaySets(displaySets: DisplaySet[], activeDisplaySetIndex: number) { + const sortedDisplaySets = []; + let previousIndex = activeDisplaySetIndex - 1; + let nextIndex = activeDisplaySetIndex + 1; + + while (previousIndex >= 0 || nextIndex < displaySets.length) { + if (previousIndex >= 0) { + sortedDisplaySets.push(displaySets[previousIndex]); + previousIndex--; + } + + if (nextIndex < displaySets.length) { + sortedDisplaySets.push(displaySets[nextIndex]); + nextIndex++; + } + } + + return sortedDisplaySets; + } + + private _getDownwardDisplaySets(displaySets: DisplaySet[], activeDisplaySetIndex: number) { + const sortedDisplaySets = []; + + for (let i = activeDisplaySetIndex + 1; i < displaySets.length; i++) { + sortedDisplaySets.push(displaySets[i]); + } + + return sortedDisplaySets; + } + + private _getUpwardDisplaySets(displaySets: DisplaySet[], activeDisplaySetIndex: number) { + const sortedDisplaySets = []; + + for (let i = activeDisplaySetIndex - 1; i >= 0 && i !== activeDisplaySetIndex; i--) { + sortedDisplaySets.push(displaySets[i]); + } + + return sortedDisplaySets; + } + + private _getSortedDisplaySetsToPrefetch(displaySets: DisplaySet[]): DisplaySet[] { + if (!this._activeDisplaySetsInstanceUIDs?.length) { + return []; + } + + const { displaySetsCount } = this.config; + const activeDisplaySetsInstanceUIDs = this._activeDisplaySetsInstanceUIDs; + const [activeDisplaySetUID] = activeDisplaySetsInstanceUIDs; + const activeDisplaySetIndex = displaySets.findIndex(ds => ds.displaySetInstanceUID === activeDisplaySetUID); + const getDisplaySetsFunctionsMap = { + [StudyPrefetchOrder.closest]: this._getClosestDisplaySets, + [StudyPrefetchOrder.downward]: this._getDownwardDisplaySets, + [StudyPrefetchOrder.upward]: this._getUpwardDisplaySets, + }; + const { order } = this.config; + const fnGetDisplaySets = getDisplaySetsFunctionsMap[order]; + + if (!fnGetDisplaySets) { + throw new Error(`Invalid order (${order})`); + } + + // Creates a `Set` to look for UIDs in O(1) instead of O(n) + const uidsSet = new Set(activeDisplaySetsInstanceUIDs); + + // Remove any active displaySet that may still be in the activeDisplaySetsInstanceUIDs. + // That may happen when activeDisplaySetsInstanceUIDs has more than one element. + return fnGetDisplaySets.call(this, displaySets, activeDisplaySetIndex).filter( + ds => !uidsSet.has(ds.displaySetInstanceUID) + ).slice(0, displaySetsCount); + } + + private _getDisplaySets() { + const { displaySetService } = this._servicesManager.services; + const displaySets = [...displaySetService.getActiveDisplaySets()]; + let displaySetsToPrefetch = this._getSortedDisplaySetsToPrefetch(displaySets); + + return { displaySets, displaySetsToPrefetch }; + } + + private _updateImageIdsDisplaySetMap( + displaySetInstanceUID: string, + imageIds: string[] + ): void { + for (const imageId of imageIds) { + let displaySetsInstanceUIDsMap = this._imageIdsToDisplaySetsMap.get(imageId); + + if (!displaySetsInstanceUIDsMap) { + displaySetsInstanceUIDsMap = new Set(); + this._imageIdsToDisplaySetsMap.set(imageId, displaySetsInstanceUIDsMap); + } + + displaySetsInstanceUIDsMap.add(displaySetInstanceUID); + } + } + + private _getImageIdsForDisplaySet(displaySet: DisplaySet): string[] { + const dataSource = this._extensionManager.getActiveDataSource()[0]; + + return dataSource.getImageIdsForDisplaySet(displaySet); + } + + private _updateDisplaySetLoadingProgress(displaySetLoadingState: DisplaySetLoadingState) { + const { numInstances, loadedImageIds, failedImageIds } = displaySetLoadingState; + const loadingProgress = (loadedImageIds.size + failedImageIds.size) / numInstances; + + displaySetLoadingState.loadingProgress = loadingProgress; + } + + private _addDisplaySetLoadingState(displaySet: DisplaySet): void { + const { displaySetInstanceUID } = displaySet; + const imageIds = this._getImageIdsForDisplaySet(displaySet); + let displaySetLoadingState = this._displaySetLoadingStates.get(displaySetInstanceUID); + + if (displaySetLoadingState) { + return; + } + + const pendingImageIds = new Set(imageIds); + const loadedImageIds = new Set(); + + // Needs to check which image is already loaded to update the progress properly + // because some images may already be loaded (thumbnails and viewports). + for (const imageId of imageIds) { + if (this.cache.isImageCached(imageId)) { + loadedImageIds.add(imageId); + } else { + pendingImageIds.add(imageId); + } + } + + displaySetLoadingState = { + displaySetInstanceUID, + numInstances: imageIds.length, + pendingImageIds, + loadedImageIds, + failedImageIds: new Set(), + loadingProgress: 0 + }; + + this._updateDisplaySetLoadingProgress(displaySetLoadingState); + this._displaySetLoadingStates.set(displaySetInstanceUID, displaySetLoadingState); + this._updateImageIdsDisplaySetMap(displaySetInstanceUID, imageIds) + + // Notify the UI that something is already loaded (eg: update StudyBrowser) + if (loadedImageIds.size) { + this._triggerDisplaySetEvents(displaySetInstanceUID); + } + } + + private _loadDisplaySets() { + const { displaySets, displaySetsToPrefetch } = this._getDisplaySets(); + + displaySets.forEach(displaySet => this._addDisplaySetLoadingState(displaySet)); + displaySetsToPrefetch.forEach(displaySet => this._enqueueDisplaySetImagesRequests(displaySet)); + } + + private _moveImageIdToLoadedSet(imageId: string): boolean { + const displaySetsInstanceUIDs = this._imageIdsToDisplaySetsMap.get(imageId); + + if (!displaySetsInstanceUIDs) { + return; + } + + for (const displaySetInstanceUID of Array.from(displaySetsInstanceUIDs.values())) { + const displaySetLoadingState = this._displaySetLoadingStates.get(displaySetInstanceUID); + const { pendingImageIds, loadedImageIds } = displaySetLoadingState; + + pendingImageIds.delete(imageId); + loadedImageIds.add(imageId); + + this._updateDisplaySetLoadingProgress(displaySetLoadingState); + this._triggerDisplaySetEvents(displaySetInstanceUID); + } + + return true; + } + + private _moveImageIdToFailedSet(imageId: string): boolean { + const displaySetsInstanceUIDs = this._imageIdsToDisplaySetsMap.get(imageId); + + if (!displaySetsInstanceUIDs) { + return; + } + + for (const displaySetInstanceUID of Array.from(displaySetsInstanceUIDs.values())) { + const displaySetLoadingState = this._displaySetLoadingStates.get(displaySetInstanceUID); + const { pendingImageIds, failedImageIds } = displaySetLoadingState; + + pendingImageIds.delete(imageId); + failedImageIds.add(imageId); + + this._updateDisplaySetLoadingProgress(displaySetLoadingState); + this._triggerDisplaySetEvents(displaySetInstanceUID); + } + + return true; + } + + private _triggerDisplaySetEvents(displaySetInstanceUID: string) { + const displaySetLoadingState = this._displaySetLoadingStates.get(displaySetInstanceUID); + const { loadingProgress, numInstances } = displaySetLoadingState; + + this._broadcastEvent(this.EVENTS.DISPLAYSET_LOAD_PROGRESS, { + displaySetInstanceUID, + numInstances, + loadingProgress, + }); + + if (loadingProgress >= 1) { + this._broadcastEvent(this.EVENTS.DISPLAYSET_LOAD_COMPLETE, { + displaySetInstanceUID, + }); + } + } + + private _onImagePrefetchSuccess(imageRequest: ImageRequest) { + if (imageRequest.aborted) { + return; + } + + const { imageId } = imageRequest; + + this._inflightRequests.delete(imageId); + this._moveImageIdToLoadedSet(imageId); + + // `sendNextRequests` must be called after removing the request from the inflight + // queue otherwise it shall not be able to send the request (maxNumPrefetchRequests) + this._sendNextRequests(); + } + + private _onImagePrefetchFailed(imageRequest, error) { + if (imageRequest.aborted) { + return; + } + + console.warn(`An error ocurred when trying to load "${imageRequest.imageId}"`, error); + + const { imageId } = imageRequest; + + this._inflightRequests.delete(imageId); + this._moveImageIdToFailedSet(imageId); + + // `sendNextRequests` must be called after removing the request from the inflight + // queue otherwise it shall not be able to send the request (maxNumPrefetchRequests) + this._sendNextRequests(); + } + + private async _sendNextRequests() { + // If the service has stopped with async requests in progress this method may + // get called again when each of those requests are fulfilled. + if (!this._isRunning) { + return; + } + + // Does not send any prefetch request until the active display sets are loaded + if (!this._areActiveDisplaySetsLoaded()) { + return; + } + + const { _pendingRequests: pendingRequests, _inflightRequests: inflightRequests } = this; + const { maxNumPrefetchRequests } = this.config; + + if (!pendingRequests.length || inflightRequests.size >= maxNumPrefetchRequests) { + return; + } + + const numImageRequests = Math.min( + pendingRequests.length, + maxNumPrefetchRequests - inflightRequests.size + ); + const imageRequests = this._pendingRequests.splice(0, numImageRequests); + + imageRequests.forEach(imageRequest => { + const { imageId } = imageRequest; + const options = { + priority: -5, + requestType: this.requestType, + additionalDetails: { imageId }, + preScale: { + enabled: true, + }, + }; + + this.imageLoadPoolManager.addRequest( + async () => + this.imageLoader.loadAndCacheImage(imageId, options).then( + _image => this._onImagePrefetchSuccess(imageRequest), + error => this._onImagePrefetchFailed(imageRequest, error) + ), + this.requestType, + { imageId } + ); + + inflightRequests.set(imageId, imageRequest); + }); + } + + private _enqueueDisplaySetImagesRequests(displaySet: DisplaySet) { + const { displaySetInstanceUID } = displaySet; + const imageIds = this._getImageIdsForDisplaySet(displaySet); + + imageIds.forEach(imageId => { + if (this.cache.isImageCached(imageId)) { + this._moveImageIdToLoadedSet(imageId); + return; + } + + this._pendingRequests.push({ + displaySetInstanceUID, + imageId, + aborted: false, + }); + }); + } + + /** + * Start prefetching the display sets based on the active viewport and app configuration. + */ + private _startPrefetching(): void { + if (this._isRunning) { + return; + } + + if (!this.config.enabled) { + console.log('StudyPrefetcher is not enabled'); + return; + } + + this._isRunning = true; + + this._loadDisplaySets(); + this._sendNextRequests(); + this._broadcastEvent(this.EVENTS.SERVICE_STARTED, {}); + } + + /** + * Stop prefetching the display sets. + * All internal variables are cleared but activeDisplaySetsInstanceUIDs otherwise restart would not work. + */ + private _stopPrefetching(): void { + if (!this._isRunning) { + return; + } + this._isRunning = false; + + // Mark all inflight requests as aborted before clearing the map. + this._inflightRequests.forEach(inflightRequest => (inflightRequest.aborted = true)); + + this._pendingRequests = []; + this._displaySetLoadingStates.clear(); + this._imageIdsToDisplaySetsMap.clear(); + this._inflightRequests.clear(); + this.imageLoadPoolManager.clearRequestStack(IMAGE_REQUEST_TYPE); + + this._broadcastEvent(this.EVENTS.SERVICE_STOPPED, {}); + } + + /** + * Restart prefetching in case it is already running. + */ + private _restartPrefetching(): void { + if (this._isRunning) { + this._stopPrefetching(); + this._startPrefetching(); + } + } +} + +export { StudyPrefetcherService as default, StudyPrefetcherService }; diff --git a/platform/core/src/services/StudyPrefetcherService/index.ts b/platform/core/src/services/StudyPrefetcherService/index.ts new file mode 100644 index 000000000..51c22299c --- /dev/null +++ b/platform/core/src/services/StudyPrefetcherService/index.ts @@ -0,0 +1,3 @@ +import { StudyPrefetcherService } from './StudyPrefetcherService'; + +export { StudyPrefetcherService as default, StudyPrefetcherService }; diff --git a/platform/core/src/services/ViewportGridService/ViewportGridService.ts b/platform/core/src/services/ViewportGridService/ViewportGridService.ts index 60309eb09..4f31ba051 100644 --- a/platform/core/src/services/ViewportGridService/ViewportGridService.ts +++ b/platform/core/src/services/ViewportGridService/ViewportGridService.ts @@ -105,8 +105,8 @@ class ViewportGridService extends PubSubService { this.setDisplaySetsForViewports([props]); } - public setDisplaySetsForViewports(props) { - this.serviceImplementation._setDisplaySetsForViewports(props); + public async setDisplaySetsForViewports(props) { + await this.serviceImplementation._setDisplaySetsForViewports(props); const state = this.getState(); const viewports = []; diff --git a/platform/core/src/services/index.ts b/platform/core/src/services/index.ts index f8d972057..68666a18a 100644 --- a/platform/core/src/services/index.ts +++ b/platform/core/src/services/index.ts @@ -17,6 +17,7 @@ import CustomizationService from './CustomizationService'; import StateSyncService from './StateSyncService'; import PanelService from './PanelService'; import WorkflowStepsService from './WorkflowStepsService'; +import StudyPrefetcherService from './StudyPrefetcherService'; import type Services from '../types/Services'; @@ -42,4 +43,5 @@ export { UserAuthenticationService, PanelService, WorkflowStepsService, + StudyPrefetcherService, }; diff --git a/platform/core/src/types/AppTypes.ts b/platform/core/src/types/AppTypes.ts index 644f9335d..8d66e2a59 100644 --- a/platform/core/src/types/AppTypes.ts +++ b/platform/core/src/types/AppTypes.ts @@ -14,6 +14,7 @@ import UserAuthenticationServiceType from '../services/UserAuthenticationService import PanelServiceType from '../services/PanelService'; import UIDialogServiceType from '../services/UIDialogService'; import UIViewportDialogServiceType from '../services/UIViewportDialogService'; +import StudyPrefetcherServiceType from '../services/StudyPrefetcherService'; import ServicesManagerType from '../services/ServicesManager'; import CommandsManagerType from '../classes/CommandsManager'; @@ -41,6 +42,7 @@ declare global { export type UIDialogService = UIDialogServiceType; export type UIViewportDialogService = UIViewportDialogServiceType; export type PanelService = PanelServiceType; + export type StudyPrefetcherService = StudyPrefetcherServiceType; export interface Managers { servicesManager?: ServicesManager; @@ -64,6 +66,7 @@ declare global { uiDialogService?: UIDialogServiceType; uiViewportDialogService?: UIViewportDialogServiceType; panelService?: PanelServiceType; + studyPrefetcherService?: StudyPrefetcherServiceType; } export interface Config { routerBasename?: string; @@ -119,6 +122,12 @@ declare global { onConfiguration?: (dicomWebConfig: any, options: any) => any; dataSources?: any; oidc?: any; + studyPrefetcher: { + enabled: boolean; + displaySetsCount: number; + maxNumPrefetchRequests: number; + order: 'closest' | 'downward' | 'upward'; + } } export interface Test { diff --git a/platform/docs/docs/configuration/configurationFiles.md b/platform/docs/docs/configuration/configurationFiles.md index fefd36af0..c475c4c39 100644 --- a/platform/docs/docs/configuration/configurationFiles.md +++ b/platform/docs/docs/configuration/configurationFiles.md @@ -258,6 +258,35 @@ This will result in two panels, one with `dicomSeg.panel` and `tracked.measureme ::: +### Study Prefetcher + +You can enable the study prefetcher so that OHIF loads the next/previous series/display sets +based on the proximity to the current series/display set. This can be useful to improve the user experience + + +```js + studyPrefetcher: { + /* Enable/disable study prefetching service (default: false) */ + enabled: true, + /* Number of displaysets to be prefetched (default: 2)*/ + displaySetCount: 2, + /** + * Max number of concurrent prefetch requests (default: 10) + * High numbers may impact on the time to load a new dropped series because + * the browser will be busy with all prefetching requests. As soon as the + * prefetch requests get fulfilled the new ones from the new dropped series + * are sent to the server. + * + * TODO: abort all prefetch requests when a new series is loaded on a viewport. + * (need to add support for `AbortController` on Cornerstone) + * */ + maxNumPrefetchRequests: 10, + /* Display sets loading order (closest (deafult), downward or upward) */ + order: 'closest', + }, + +``` + ### More on Accept Header Configuration In the previous section we showed that you can modify the `acceptHeader` configuration to request specific dicom transfer syntax. By default diff --git a/platform/docs/docs/migration-guide/from-v2.md b/platform/docs/docs/migration-guide/from-v2.md index 81f70806f..e1a58e226 100644 --- a/platform/docs/docs/migration-guide/from-v2.md +++ b/platform/docs/docs/migration-guide/from-v2.md @@ -102,7 +102,7 @@ There are various configurations available to customize the viewer. Each configu OHIF v3 has a new configuration structure. The main difference is that the `servers` is renamed to `dataSources` and the configuration is now asynchronous. Datasources are more abstract and far more capable than servers. Read more about dataSources [here](../platform/extensions/modules/data-source.md). -- `StudyPrefetcher` is not currently supported in OHIF v3. +- `StudyPrefetcher` is only available in OHIF v3.9 beta and will be available in the next stable 3.9 release. - The `servers` object has been replaced with a `dataSources` array containing objects representing different data sources. - The cornerstoneExtensionConfig property has been removed, you should use `customizationService` instead (you can read more [here](../platform/services/ui/customization-service.md)) - The maxConcurrentMetadataRequests property has been removed in favor of `maxNumRequests` diff --git a/platform/ui/package.json b/platform/ui/package.json index 8ee5b582d..2498a58c1 100644 --- a/platform/ui/package.json +++ b/platform/ui/package.json @@ -33,7 +33,7 @@ "react-dom": "^18.3.1" }, "dependencies": { - "@testing-library/react-hooks": "^3.2.1", + "@testing-library/react": "^13.1.0", "browser-detect": "^0.2.28", "classnames": "^2.3.2", "d3-array": "3", @@ -56,7 +56,7 @@ "react-modal": "3.11.2", "react-outside-click-handler": "^1.3.0", "react-select": "5.7.4", - "react-test-renderer": "^16.12.0", + "react-test-renderer": "^18.3.1", "react-window": "^1.8.9", "react-with-direction": "^1.3.1", "swiper": "^8.4.2", diff --git a/platform/ui/src/assets/icons/database.svg b/platform/ui/src/assets/icons/database.svg new file mode 100644 index 000000000..79fd60ef6 --- /dev/null +++ b/platform/ui/src/assets/icons/database.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/platform/ui/src/components/Icon/getIcon.js b/platform/ui/src/components/Icon/getIcon.js index e899c1a42..1413d2528 100644 --- a/platform/ui/src/components/Icon/getIcon.js +++ b/platform/ui/src/components/Icon/getIcon.js @@ -9,6 +9,7 @@ import { ReactComponent as calendar } from './../../assets/icons/calendar.svg'; import { ReactComponent as cancel } from './../../assets/icons/cancel.svg'; import { ReactComponent as clipboard } from './../../assets/icons/clipboard.svg'; import { ReactComponent as close } from './../../assets/icons/closeIcon.svg'; +import { ReactComponent as database } from './../../assets/icons/database.svg'; import { ReactComponent as dottedCircle } from './../../assets/icons/dotted-circle.svg'; import { ReactComponent as circledCheckmark } from './../../assets/icons/circled-checkmark.svg'; import { ReactComponent as chevronDown } from './../../assets/icons/chevron-down.svg'; @@ -214,6 +215,7 @@ const ICONS = { cancel: cancel, clipboard: clipboard, close: close, + database: database, 'dotted-circle': dottedCircle, 'circled-checkmark': circledCheckmark, 'chevron-down': chevronDown, diff --git a/platform/ui/src/components/Thumbnail/Thumbnail.tsx b/platform/ui/src/components/Thumbnail/Thumbnail.tsx index 3707d1e99..7161282f5 100644 --- a/platform/ui/src/components/Thumbnail/Thumbnail.tsx +++ b/platform/ui/src/components/Thumbnail/Thumbnail.tsx @@ -17,6 +17,7 @@ const Thumbnail = ({ description, seriesNumber, numInstances, + loadingProgress, countIcon, messages, dragData = {}, @@ -94,6 +95,17 @@ const Thumbnail = ({ /> {` ${numInstances}`} +
+ {loadingProgress && loadingProgress < 1 && ( + <>{Math.round(loadingProgress * 100)}% + )} + {loadingProgress && loadingProgress === 1 && ( + + )} +