From b17f753e6222045252ef885e40233681541a32e1 Mon Sep 17 00:00:00 2001 From: ladeirarodolfo <39910206+ladeirarodolfo@users.noreply.github.com> Date: Tue, 19 Nov 2019 05:33:48 -0300 Subject: [PATCH] feat: Issue 879 viewer route query param not filtering but promoting (#1141) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 🎸 Filter by url query param for seriesInstnaceUID * fix: Set SR viewport as active by interaction (#1118) * fix: Set SR viewport as active by interaction * quick fix * (eslint) add "before" as global variables * add data-cy * add data-cy * create E2E test * (E2E) create custom command to set layout size * remove .only e2e * remove throttle for onScroll * feat: 🎸 Code review in progress Code review. Move retrieveMEtadata load to separate folders. Some minor code clean up * feat: 🎸 Code review. Missing changes from previous commit * feat: 🎸 Code review missing changes from previous commit When sorting, Criteria for instance must use instanceNumber and not instancesNumber * feat: 🎸 Code review. Add more jsdoc info * feat: 🎸 Code review. Prettify changed code * feat: 🎸 Instead of filtering do promote. WIP * feat: 🎸 Fix minor issue. Allow promote or filter * feat: 🎸 Changing component to functional component * Merge from master Part1/2 Conflicts solved: both added: core/src/studies/services/wado/retrieveMetadataLoader.js both added: core/src/studies/services/wado/retrieveMetadataLoaderAsync.js both added: core/src/studies/services/wado/retrieveMetadataLoaderSync.js both modified: viewer/src/connectedComponents/ViewerRetrieveStudyData.js both modified: viewer/src/routes/ViewerRouting.js * Merge process from master Part 1/2 Missing files from previous commit * feat: 🎸 Add cancelable promises to cut async methods * feat: 🎸 Missing changes from previous merge process * feat: 🎸 Missing changes from previous merge process --- .../services/wado/retrieveMetadataLoader.js | 10 +- platform/core/src/studies/studyUtils.js | 413 ++++++++++++++++ .../components/studyBrowser/ImageThumbnail.js | 16 +- .../integration/common/OHIFStudyList.spec.js | 34 +- platform/viewer/public/config/default.js | 1 + .../ConnectedViewerRetrieveStudyData.js | 13 +- .../ViewerRetrieveStudyData.js | 457 ++++++++++++------ 7 files changed, 762 insertions(+), 182 deletions(-) create mode 100644 platform/core/src/studies/studyUtils.js diff --git a/platform/core/src/studies/services/wado/retrieveMetadataLoader.js b/platform/core/src/studies/services/wado/retrieveMetadataLoader.js index 087b6d1dc..6c695d3b9 100644 --- a/platform/core/src/studies/services/wado/retrieveMetadataLoader.js +++ b/platform/core/src/studies/services/wado/retrieveMetadataLoader.js @@ -40,7 +40,7 @@ export default class RetrieveMetadataLoader { if (result && result.length) { break; // closes iterator in case data is retrieved successfully } - } catch (e) {} + } catch (e) { } } if (loaders.next().done && !result) { @@ -51,8 +51,8 @@ export default class RetrieveMetadataLoader { } // Methods to be overwrite - async configLoad() {} - async preLoad() {} - async load(preLoadData) {} - async posLoad(loadData) {} + async configLoad() { } + async preLoad() { } + async load(preLoadData) { } + async posLoad(loadData) { } } diff --git a/platform/core/src/studies/studyUtils.js b/platform/core/src/studies/studyUtils.js new file mode 100644 index 000000000..a0dd54ffd --- /dev/null +++ b/platform/core/src/studies/studyUtils.js @@ -0,0 +1,413 @@ +import DICOMWeb from '../DICOMWeb/'; + +const WADOProxy = { + convertURL: (url, server) => { + // TODO: Remove all WADOProxy stuff from this file + return url; + }, +}; +function parseFloatArray(obj) { + const result = []; + + if (!obj) { + return result; + } + + const objs = obj.split('\\'); + for (let i = 0; i < objs.length; i++) { + result.push(parseFloat(objs[i])); + } + + return result; +} + +/** + * Create a plain JS object that describes a study (a study descriptor object) + * @param {Object} server Object with server configuration parameters + * @param {Object} aSopInstance a SOP Instance from which study information will be added + */ +function createStudy(server, aSopInstance) { + // TODO: Pass a reference ID to the server instead of including the URLs here + return { + seriesList: [], + seriesMap: Object.create(null), + seriesLoader: null, + wadoUriRoot: server.wadoUriRoot, + wadoRoot: server.wadoRoot, + qidoRoot: server.qidoRoot, + patientName: DICOMWeb.getName(aSopInstance['00100010']), + patientId: DICOMWeb.getString(aSopInstance['00100020']), + patientAge: DICOMWeb.getNumber(aSopInstance['00101010']), + patientSize: DICOMWeb.getNumber(aSopInstance['00101020']), + patientWeight: DICOMWeb.getNumber(aSopInstance['00101030']), + accessionNumber: DICOMWeb.getString(aSopInstance['00080050']), + studyDate: DICOMWeb.getString(aSopInstance['00080020']), + modalities: DICOMWeb.getString(aSopInstance['00080061']), + studyDescription: DICOMWeb.getString(aSopInstance['00081030']), + imageCount: DICOMWeb.getString(aSopInstance['00201208']), + studyInstanceUid: DICOMWeb.getString(aSopInstance['0020000D']), + institutionName: DICOMWeb.getString(aSopInstance['00080080']), + }; +} + +/** Returns a WADO url for an instance + * + * @param studyInstanceUid + * @param seriesInstanceUid + * @param sopInstanceUid + * @returns {string} + */ +function buildInstanceWadoUrl( + server, + studyInstanceUid, + seriesInstanceUid, + sopInstanceUid +) { + // TODO: This can be removed, since DICOMWebClient has the same function. Not urgent, though + const params = []; + + params.push('requestType=WADO'); + params.push(`studyUID=${studyInstanceUid}`); + params.push(`seriesUID=${seriesInstanceUid}`); + params.push(`objectUID=${sopInstanceUid}`); + params.push('contentType=application/dicom'); + params.push('transferSyntax=*'); + + const paramString = params.join('&'); + + return `${server.wadoUriRoot}?${paramString}`; +} + +function buildInstanceWadoRsUri( + server, + studyInstanceUid, + seriesInstanceUid, + sopInstanceUid +) { + return `${server.wadoRoot}/studies/${studyInstanceUid}/series/${seriesInstanceUid}/instances/${sopInstanceUid}`; +} + +function buildInstanceFrameWadoRsUri( + server, + studyInstanceUid, + seriesInstanceUid, + sopInstanceUid, + frame +) { + const baseWadoRsUri = buildInstanceWadoRsUri( + server, + studyInstanceUid, + seriesInstanceUid, + sopInstanceUid + ); + frame = frame != null || 1; + + return `${baseWadoRsUri}/frames/${frame}`; +} + +function getFrameIncrementPointer(element) { + const frameIncrementPointerNames = { + '00181065': 'frameTimeVector', + '00181063': 'frameTime', + }; + + if (!element || !element.Value || !element.Value.length) { + return; + } + + const value = element.Value[0]; + return frameIncrementPointerNames[value]; +} + +function getRadiopharmaceuticalInfo(instance) { + const modality = DICOMWeb.getString(instance['00080060']); + + if (modality !== 'PT') { + return; + } + + const radiopharmaceuticalInfo = instance['00540016']; + if ( + radiopharmaceuticalInfo === undefined || + !radiopharmaceuticalInfo.Value || + !radiopharmaceuticalInfo.Value.length + ) { + return; + } + + const firstPetRadiopharmaceuticalInfo = radiopharmaceuticalInfo.Value[0]; + return { + radiopharmaceuticalStartTime: DICOMWeb.getString( + firstPetRadiopharmaceuticalInfo['00181072'] + ), + radionuclideTotalDose: DICOMWeb.getNumber( + firstPetRadiopharmaceuticalInfo['00181074'] + ), + radionuclideHalfLife: DICOMWeb.getNumber( + firstPetRadiopharmaceuticalInfo['00181075'] + ), + }; +} + +/** + * Parses the SourceImageSequence, if it exists, in order + * to return a ReferenceSOPInstanceUID. The ReferenceSOPInstanceUID + * is used to refer to this image in any accompanying DICOM-SR documents. + * + * @param instance + * @returns {String} The ReferenceSOPInstanceUID + */ +function getSourceImageInstanceUid(instance) { + // TODO= Parse the whole Source Image Sequence + // This is a really poor workaround for now. + // Later we should probably parse the whole sequence. + var SourceImageSequence = instance['00082112']; + if ( + SourceImageSequence && + SourceImageSequence.Value && + SourceImageSequence.Value.length && + SourceImageSequence.Value[0]['00081155'].Value + ) { + return SourceImageSequence.Value[0]['00081155'].Value[0]; + } +} + +async function makeSOPInstance(server, study, instance) { + const { studyInstanceUid } = study; + const seriesInstanceUid = DICOMWeb.getString(instance['0020000E']); + let series = study.seriesMap[seriesInstanceUid]; + + if (!series) { + series = { + seriesInstanceUid, + seriesDescription: DICOMWeb.getString(instance['0008103E']), + modality: DICOMWeb.getString(instance['00080060']), + seriesNumber: DICOMWeb.getNumber(instance['00200011']), + seriesDate: DICOMWeb.getString(instance['00080021']), + seriesTime: DICOMWeb.getString(instance['00080031']), + instances: [], + }; + study.seriesMap[seriesInstanceUid] = series; + study.seriesList.push(series); + } + + const sopInstanceUid = DICOMWeb.getString(instance['00080018']); + const wadouri = buildInstanceWadoUrl( + server, + studyInstanceUid, + seriesInstanceUid, + sopInstanceUid + ); + const baseWadoRsUri = buildInstanceWadoRsUri( + server, + studyInstanceUid, + seriesInstanceUid, + sopInstanceUid + ); + const wadorsuri = buildInstanceFrameWadoRsUri( + server, + studyInstanceUid, + seriesInstanceUid, + sopInstanceUid + ); + + const sopInstance = { + imageType: DICOMWeb.getString(instance['00080008']), + sopClassUid: DICOMWeb.getString(instance['00080016']), + modality: DICOMWeb.getString(instance['00080060']), + sopInstanceUid, + instanceNumber: DICOMWeb.getNumber(instance['00200013']), + imagePositionPatient: DICOMWeb.getString(instance['00200032']), + imageOrientationPatient: DICOMWeb.getString(instance['00200037']), + frameOfReferenceUID: DICOMWeb.getString(instance['00200052']), + sliceLocation: DICOMWeb.getNumber(instance['00201041']), + samplesPerPixel: DICOMWeb.getNumber(instance['00280002']), + photometricInterpretation: DICOMWeb.getString(instance['00280004']), + planarConfiguration: DICOMWeb.getNumber(instance['00280006']), + rows: DICOMWeb.getNumber(instance['00280010']), + columns: DICOMWeb.getNumber(instance['00280011']), + pixelSpacing: DICOMWeb.getString(instance['00280030']), + pixelAspectRatio: DICOMWeb.getString(instance['00280034']), + bitsAllocated: DICOMWeb.getNumber(instance['00280100']), + bitsStored: DICOMWeb.getNumber(instance['00280101']), + highBit: DICOMWeb.getNumber(instance['00280102']), + pixelRepresentation: DICOMWeb.getNumber(instance['00280103']), + smallestPixelValue: DICOMWeb.getNumber(instance['00280106']), + largestPixelValue: DICOMWeb.getNumber(instance['00280107']), + windowCenter: DICOMWeb.getString(instance['00281050']), + windowWidth: DICOMWeb.getString(instance['00281051']), + rescaleIntercept: DICOMWeb.getNumber(instance['00281052']), + rescaleSlope: DICOMWeb.getNumber(instance['00281053']), + rescaleType: DICOMWeb.getNumber(instance['00281054']), + sourceImageInstanceUid: getSourceImageInstanceUid(instance), + laterality: DICOMWeb.getString(instance['00200062']), + viewPosition: DICOMWeb.getString(instance['00185101']), + acquisitionDateTime: DICOMWeb.getString(instance['0008002A']), + numberOfFrames: DICOMWeb.getNumber(instance['00280008']), + frameIncrementPointer: getFrameIncrementPointer(instance['00280009']), + frameTime: DICOMWeb.getNumber(instance['00181063']), + frameTimeVector: parseFloatArray(DICOMWeb.getString(instance['00181065'])), + sliceThickness: DICOMWeb.getNumber(instance['00180050']), + spacingBetweenSlices: DICOMWeb.getString(instance['00180088']), + lossyImageCompression: DICOMWeb.getString(instance['00282110']), + derivationDescription: DICOMWeb.getString(instance['00282111']), + lossyImageCompressionRatio: DICOMWeb.getString(instance['00282112']), + lossyImageCompressionMethod: DICOMWeb.getString(instance['00282114']), + echoNumber: DICOMWeb.getString(instance['00180086']), + contrastBolusAgent: DICOMWeb.getString(instance['00180010']), + radiopharmaceuticalInfo: getRadiopharmaceuticalInfo(instance), + baseWadoRsUri: baseWadoRsUri, + wadouri: WADOProxy.convertURL(wadouri, server), + wadorsuri: WADOProxy.convertURL(wadorsuri, server), + wadoRoot: server.wadoRoot, + imageRendering: server.imageRendering, + thumbnailRendering: server.thumbnailRendering, + }; + + // Get additional information if the instance uses "PALETTE COLOR" photometric interpretation + if (sopInstance.photometricInterpretation === 'PALETTE COLOR') { + const redPaletteColorLookupTableDescriptor = parseFloatArray( + DICOMWeb.getString(instance['00281101']) + ); + const greenPaletteColorLookupTableDescriptor = parseFloatArray( + DICOMWeb.getString(instance['00281102']) + ); + const bluePaletteColorLookupTableDescriptor = parseFloatArray( + DICOMWeb.getString(instance['00281103']) + ); + const palettes = await getPaletteColors( + server, + instance, + redPaletteColorLookupTableDescriptor + ); + + if (palettes) { + if (palettes.uid) { + sopInstance.paletteColorLookupTableUID = palettes.uid; + } + + sopInstance.redPaletteColorLookupTableData = palettes.red; + sopInstance.greenPaletteColorLookupTableData = palettes.green; + sopInstance.bluePaletteColorLookupTableData = palettes.blue; + sopInstance.redPaletteColorLookupTableDescriptor = redPaletteColorLookupTableDescriptor; + sopInstance.greenPaletteColorLookupTableDescriptor = greenPaletteColorLookupTableDescriptor; + sopInstance.bluePaletteColorLookupTableDescriptor = bluePaletteColorLookupTableDescriptor; + } + } + + series.instances.push(sopInstance); + return sopInstance; +} + +function getPaletteColor(server, instance, tag, lutDescriptor) { + const numLutEntries = lutDescriptor[0]; + const bits = lutDescriptor[2]; + + let uri = WADOProxy.convertURL(instance[tag].BulkDataURI, server); + + // TODO: Workaround for dcm4chee behind SSL-terminating proxy returning + // incorrect bulk data URIs + if (server.wadoRoot.indexOf('https') === 0 && !uri.includes('https')) { + uri = uri.replace('http', 'https'); + } + + const config = { + url: server.wadoRoot, //BulkDataURI is absolute, so this isn't used + headers: DICOMWeb.getAuthorizationHeader(server), + }; + const dicomWeb = new api.DICOMwebClient(config); + const options = { + BulkDataURI: uri, + }; + + const readUInt16 = (byteArray, position) => { + return byteArray[position] + byteArray[position + 1] * 256; + }; + + const arrayBufferToPaletteColorLUT = result => { + const arraybuffer = result[0]; + const byteArray = new Uint8Array(arraybuffer); + const lut = []; + + for (let i = 0; i < numLutEntries; i++) { + if (bits === 16) { + lut[i] = readUInt16(byteArray, i * 2); + } else { + lut[i] = byteArray[i]; + } + } + + return lut; + }; + + return dicomWeb.retrieveBulkData(options).then(arrayBufferToPaletteColorLUT); +} + +/** + * Fetch palette colors for instances with "PALETTE COLOR" photometricInterpretation. + * + * @param server {Object} Current server; + * @param instance {Object} The retrieved instance metadata; + * @returns {String} The ReferenceSOPInstanceUID + */ +async function getPaletteColors(server, instance, lutDescriptor) { + let paletteUID = DICOMWeb.getString(instance['00281199']); + + return new Promise((resolve, reject) => { + let entry; + if (paletteColorCache.isValidUID(paletteUID)) { + entry = paletteColorCache.get(paletteUID); + + if (entry) { + return resolve(entry); + } + } + + // no entry in cache... Fetch remote data. + const r = getPaletteColor(server, instance, '00281201', lutDescriptor); + const g = getPaletteColor(server, instance, '00281202', lutDescriptor); + const b = getPaletteColor(server, instance, '00281203', lutDescriptor); + + const promises = [r, g, b]; + + Promise.all(promises).then(args => { + entry = { + red: args[0], + green: args[1], + blue: args[2], + }; + + // when paletteUID is present, the entry can be cached... + entry.uid = paletteUID; + paletteColorCache.add(entry); + + resolve(entry); + }); + }); +} + +/** + * Add a list of SOP Instances to a given study object descriptor + * @param {Object} server Object with server configuration parameters + * @param {Object} study The study descriptor to which the given SOP instances will be added + * @param {Array} sopInstanceList A list of SOP instance objects + */ +async function addInstancesToStudy(server, study, sopInstanceList) { + return Promise.all( + sopInstanceList.map(function(sopInstance) { + return makeSOPInstance(server, study, sopInstance); + }) + ); +} + +const createStudyFromSOPInstanceList = async (server, sopInstanceList) => { + if (Array.isArray(sopInstanceList) && sopInstanceList.length > 0) { + const firstSopInstance = sopInstanceList[0]; + const study = createStudy(server, firstSopInstance); + await addInstancesToStudy(server, study, sopInstanceList); + return study; + } + throw new Error('Failed to create study out of provided SOP instance list'); +}; + +export { createStudyFromSOPInstanceList, addInstancesToStudy }; diff --git a/platform/ui/src/components/studyBrowser/ImageThumbnail.js b/platform/ui/src/components/studyBrowser/ImageThumbnail.js index 8f48b4545..90214ce3c 100644 --- a/platform/ui/src/components/studyBrowser/ImageThumbnail.js +++ b/platform/ui/src/components/studyBrowser/ImageThumbnail.js @@ -102,14 +102,14 @@ function ImageThumbnail(props) { {shouldRenderToCanvas() ? ( ) : ( - {''} - )} + {''} + )} {loadingOrError} {showStackLoadingProgressBar && ( diff --git a/platform/viewer/cypress/integration/common/OHIFStudyList.spec.js b/platform/viewer/cypress/integration/common/OHIFStudyList.spec.js index 804f4ec32..ac80c6e20 100644 --- a/platform/viewer/cypress/integration/common/OHIFStudyList.spec.js +++ b/platform/viewer/cypress/integration/common/OHIFStudyList.spec.js @@ -1,14 +1,14 @@ //We are keeping the hardcoded results values for the study list tests //this is intended to be running in a controled docker environment with test data. -describe('OHIF Study List', function() { - context('Desktop resolution', function() { - beforeEach(function() { +describe('OHIF Study List', function () { + context('Desktop resolution', function () { + beforeEach(function () { cy.viewport(1750, 720); cy.openStudyList(); cy.initStudyListAliasesOnDesktop(); }); - it('searches Patient Name with exact string', function() { + it('searches Patient Name with exact string', function () { cy.get('@patientName').type('Juno'); //Wait result list to be displayed cy.waitStudyList(); @@ -18,7 +18,7 @@ describe('OHIF Study List', function() { }); }); - it('searches MRN with exact string', function() { + it('searches MRN with exact string', function () { cy.get('@MRN').type('ProstateX-0000'); //Wait result list to be displayed cy.waitStudyList(); @@ -28,7 +28,7 @@ describe('OHIF Study List', function() { }); }); - it('searches Accession with exact string', function() { + it('searches Accession with exact string', function () { cy.get('@accessionNumber').type('fpcben98890'); //Wait result list to be displayed cy.waitStudyList(); @@ -38,7 +38,7 @@ describe('OHIF Study List', function() { }); }); - it('searches Modality with camel case', function() { + it('searches Modality with camel case', function () { cy.get('@modalities').type('Mr'); //Wait result list to be displayed cy.waitStudyList(); @@ -48,7 +48,7 @@ describe('OHIF Study List', function() { }); }); - it('searches Description with exact string', function() { + it('searches Description with exact string', function () { cy.get('@studyDescription').type('CHEST'); //Wait result list to be displayed cy.waitStudyList(); @@ -58,7 +58,7 @@ describe('OHIF Study List', function() { }); }); - it('changes rows per page and checks the study count', function() { + it('changes rows per page and checks the study count', function () { //Show rows per page options const pageRows = [25, 50, 100]; @@ -100,14 +100,14 @@ describe('OHIF Study List', function() { // }); }); - context('Tablet resolution', function() { - beforeEach(function() { + context('Tablet resolution', function () { + beforeEach(function () { cy.viewport(1000, 660); cy.openStudyList(); cy.initStudyListAliasesOnTablet(); }); - it('searches Patient Name with exact string', function() { + it('searches Patient Name with exact string', function () { cy.get('@patientNameOrMRN').type('Juno'); //Wait result list to be displayed cy.waitStudyList(); @@ -117,7 +117,7 @@ describe('OHIF Study List', function() { }); }); - it('searches MRN with with exact string', function() { + it('searches MRN with with exact string', function () { cy.get('@patientNameOrMRN').type('ProstateX-0000'); //Wait result list to be displayed cy.waitStudyList(); @@ -127,7 +127,7 @@ describe('OHIF Study List', function() { }); }); - it('searches Modality with exact string', function() { + it('searches Modality with exact string', function () { cy.get('@accessionModalityDescription').type('MR'); //Wait result list to be displayed cy.waitStudyList(); @@ -137,7 +137,7 @@ describe('OHIF Study List', function() { }); }); - it('searches Accession with exact string', function() { + it('searches Accession with exact string', function () { cy.get('@accessionModalityDescription').type('fpcben98890'); //Wait result list to be displayed cy.waitStudyList(); @@ -147,7 +147,7 @@ describe('OHIF Study List', function() { }); }); - it('searches Description with exact string', function() { + it('searches Description with exact string', function () { cy.get('@accessionModalityDescription').type('CHEST'); //Wait result list to be displayed cy.waitStudyList(); @@ -157,7 +157,7 @@ describe('OHIF Study List', function() { }); }); - it('changes rows per page and checks the study count', function() { + it('changes rows per page and checks the study count', function () { //Show rows per page options const pageRows = [25, 50, 100]; diff --git a/platform/viewer/public/config/default.js b/platform/viewer/public/config/default.js index f77f2942b..37d807738 100644 --- a/platform/viewer/public/config/default.js +++ b/platform/viewer/public/config/default.js @@ -3,6 +3,7 @@ window.config = { routerBasename: '/', extensions: [], showStudyList: true, + filterQueryParam: false, servers: { dicomWeb: [ { diff --git a/platform/viewer/src/connectedComponents/ConnectedViewerRetrieveStudyData.js b/platform/viewer/src/connectedComponents/ConnectedViewerRetrieveStudyData.js index 4ecb33438..f3a7e5ee2 100644 --- a/platform/viewer/src/connectedComponents/ConnectedViewerRetrieveStudyData.js +++ b/platform/viewer/src/connectedComponents/ConnectedViewerRetrieveStudyData.js @@ -1,6 +1,10 @@ import { connect } from 'react-redux'; import ViewerRetrieveStudyData from './ViewerRetrieveStudyData.js'; +import OHIF from "@ohif/core"; +const { + clearViewportSpecificData +} = OHIF.redux.actions; const isActive = a => a.active === true; const mapStateToProps = state => { @@ -10,10 +14,17 @@ const mapStateToProps = state => { server: activeServer, }; }; +const mapDispatchToProps = dispatch => { + return { + clearViewportSpecificData: () => { + dispatch(clearViewportSpecificData()); + } + }; +}; const ConnectedViewerRetrieveStudyData = connect( mapStateToProps, - null + mapDispatchToProps )(ViewerRetrieveStudyData); export default ConnectedViewerRetrieveStudyData; diff --git a/platform/viewer/src/connectedComponents/ViewerRetrieveStudyData.js b/platform/viewer/src/connectedComponents/ViewerRetrieveStudyData.js index d898c3968..1b32e018a 100644 --- a/platform/viewer/src/connectedComponents/ViewerRetrieveStudyData.js +++ b/platform/viewer/src/connectedComponents/ViewerRetrieveStudyData.js @@ -1,191 +1,346 @@ -import React, { Component } from 'react'; +import React, { useState, useEffect, useContext } from 'react'; import { metadata, studies, utils, log } from '@ohif/core'; import ConnectedViewer from './ConnectedViewer.js'; import PropTypes from 'prop-types'; import { extensionManager } from './../App.js'; -import { withSnackbar } from '@ohif/ui'; +import { useSnackbarContext } from '@ohif/ui'; const { OHIFStudyMetadata, OHIFSeriesMetadata } = metadata; const { retrieveStudiesMetadata, deleteStudyMetadataPromise } = studies; -const { studyMetadataManager, updateMetaDataManager } = utils; +const { studyMetadataManager, updateMetaDataManager, makeCancelable } = utils; -class ViewerRetrieveStudyData extends Component { - static propTypes = { - studyInstanceUids: PropTypes.array.isRequired, - seriesInstanceUids: PropTypes.array, - server: PropTypes.object, +// Contexts +import AppContext from '../context/AppContext'; + +const _promoteToFront = (list, value, searchMethod) => { + let response = [...list]; + let promoted = false; + const index = response.findIndex(searchMethod.bind(undefined, value)); + + if (index > 0) { + const first = response.splice(index, 1); + response = [...first, ...response]; + } + + if (index >= 0) { + promoted = true; + } + + return { + promoted, + data: response, }; +}; - constructor(props) { - super(props); - this.abortSeriesLoad = false; - this.seriesLoadStats = Object.create(null); - this.state = { - studies: null, - error: null, - }; +/** + * Promote series to front if find found equivalent on filters object + * @param {Object} study - study reference to promote series against + * @param {Object} [filters] - Object containing filters to be applied + * @param {string} [filter.seriesInstanceUID] - series instance uid to filter results against + * @param {boolean} isFilterStrategy - if filtering by query param strategy ON + */ +const _promoteList = (study, studyMetadata, filters, isFilterStrategy) => { + let promoted = false; + // Promote only if no filter should be applied + if (!isFilterStrategy) { + _sortStudyDisplaySet(study, studyMetadata); + promoted = _promoteStudyDisplaySet(study, studyMetadata, filters); } - async loadStudies() { - try { - const { server, studyInstanceUids, seriesInstanceUids } = this.props; - const filters = {}; + return promoted; +}; - // Use the first, discard others - const seriesInstanceUID = seriesInstanceUids && seriesInstanceUids[0]; +const _promoteStudyDisplaySet = (study, studyMetadata, filters) => { + let promoted = false; + const queryParamsLength = Object.keys(filters).length; + const shouldPromoteToFront = queryParamsLength > 0; - if (seriesInstanceUID) { - filters.seriesInstanceUID = seriesInstanceUID; - } - - const studies = await retrieveStudiesMetadata( - server, - studyInstanceUids, - filters - ); - this.validateFilters(studies, filters); - this.setStudies(studies); - } catch (e) { - this.setState({ error: true }); - log.error(e); - } - } - - /** - * Validate filters and promp user a message in case filter is unsuccessfully applied. - * In case of success, studies array contains, as the first element, the queried content (from filter) - * @param {Array} studies array of studies to be evaluated - * @param {Object} filters filters to test against - */ - validateFilters(studies = [], filters = {}) { + if (shouldPromoteToFront) { const { seriesInstanceUID } = filters; - const { snackbarContext } = this.props; - // skip in case no filter or no toast manager - if (!seriesInstanceUID || !snackbarContext) { - return; - } + const _seriesLookup = (valueToCompare, displaySet) => { + return displaySet.seriesInstanceUid === valueToCompare; + }; + const promotedResponse = _promoteToFront( + studyMetadata.getDisplaySets(), + seriesInstanceUID, + _seriesLookup + ); - const firstStudy = studies[0] || {}; - const { seriesList = [] } = firstStudy; - const firstSeries = seriesList[0]; - - if (!firstSeries || firstSeries.seriesInstanceUid !== seriesInstanceUID) { - snackbarContext.show({ - message: 'No series for given filter: ' + seriesInstanceUID, - }); - } + study.displaySets = promotedResponse.data; + promoted = promotedResponse.promoted; } - setStudies(givenStudies) { - if (Array.isArray(givenStudies) && givenStudies.length > 0) { - const sopClassHandlerModules = - extensionManager.modules['sopClassHandlerModule']; + return promoted; +}; + +/** + * Method to identify if query param (from url) was applied to given list + * @param {Object} study - study reference to promote series against + * @param {Object} [filters] - Object containing filters to be applied + * @param {string} [filter.seriesInstanceUID] - series instance uid to filter results against + * @param {boolean} isFilterStrategy - if filtering by query param strategy ON + */ +const _isQueryParamApplied = (study, filters = {}, isFilterStrategy) => { + const { seriesInstanceUID } = filters; + let applied = true; + // skip in case no filter or no toast manager + if (!seriesInstanceUID) { + return applied; + } + + const { seriesList = [], displaySets = [] } = study; + const firstSeries = isFilterStrategy ? seriesList[0] : displaySets[0]; + + if (!firstSeries || firstSeries.seriesInstanceUid !== seriesInstanceUID) { + applied = false; + } + + return applied; +}; +const _showUserMessage = (queryParamApplied, message, dialog = {}) => { + if (queryParamApplied) { + return; + } + + const { show: showUserMessage = () => { } } = dialog; + showUserMessage({ + message, + }); +}; + +const _addSeriesToStudy = (studyMetadata, series) => { + const sopClassHandlerModules = + extensionManager.modules['sopClassHandlerModule']; + const study = studyMetadata.getData(); + const seriesMetadata = new OHIFSeriesMetadata(series, study); + studyMetadata.addSeries(seriesMetadata); + studyMetadata.createAndAddDisplaySetsForSeries( + sopClassHandlerModules, + seriesMetadata, + false + ); + study.displaySets = studyMetadata.getDisplaySets(); + _updateMetaDataManager(study, series.seriesInstanceUid); +}; + +const _updateMetaDataManager = (study, studyMetadata, series) => { + updateMetaDataManager(study, series); + + const { studyInstanceUID } = study; + + if (!studyMetadataManager.get(studyInstanceUID)) { + studyMetadataManager.add(studyMetadata); + } +}; + +const _updateStudyDisplaySets = (study, studyMetadata) => { + const sopClassHandlerModules = + extensionManager.modules['sopClassHandlerModule']; + + if (!study.displaySets) { + study.displaySets = studyMetadata.createDisplaySets(sopClassHandlerModules); + } + + studyMetadata.setDisplaySets(study.displaySets); +}; + +const _sortStudyDisplaySet = (study, studyMetadata) => { + studyMetadata.sortDisplaySets(study.displaySets); +}; +const _loadRemainingSeries = studyMetadata => { + const { seriesLoader } = studyMetadata.getData(); + if (!seriesLoader) { + return Promise.resolve(); + } + const promisesLoaders = []; + while (seriesLoader.hasNext()) { + promisesLoaders.push( + seriesLoader + .next() + .then( + series => void _addSeriesToStudy(studyMetadata, series), + error => void log.error(error) + ) + ); + } + + return Promise.all(promisesLoaders); +}; + +function ViewerRetrieveStudyData({ + server, + studyInstanceUids, + seriesInstanceUids, + clearViewportSpecificData, +}) { + // hooks + const [error, setError] = useState(false); + const [studies, setStudies] = useState([]); + const snackbarContext = useSnackbarContext(); + const { appConfig = {} } = useContext(AppContext); + const { filterQueryParam: isFilterStrategy = false } = appConfig; + + let cancelableSeriesPromises; + let cancelableStudiesPromises; + /** + * Callback method when study is totally loaded + * @param {object} study study loaded + * @param {object} studyMetadata studyMetadata for given study + * @param {Object} [filters] - Object containing filters to be applied + * @param {string} [filter.seriesInstanceUID] - series instance uid to filter results against + */ + const studyDidLoad = (study, studyMetadata, filters) => { + // User message + const promoted = _promoteList( + study, + studyMetadata, + filters, + isFilterStrategy + ); + + // Clear viewport to allow new promoted one to be displayed + if (promoted) { + clearViewportSpecificData(0); + } + + const isQueryParamApplied = _isQueryParamApplied( + study, + filters, + isFilterStrategy + ); + // Show message in case not promoted neither filtered but should to + _showUserMessage( + isQueryParamApplied, + 'Query parameters were not applied. Using original series list for given study.', + snackbarContext + ); + + setStudies([...studies, study]); + }; + + /** + * Method to process studies. It will update displaySet, studyMetadata, load remaining series, ... + * @param {Array} studiesData Array of studies retrieved from server + * @param {Object} [filters] - Object containing filters to be applied + * @param {string} [filter.seriesInstanceUID] - series instance uid to filter results against + */ + const processStudies = (studiesData, filters) => { + if (Array.isArray(studiesData) && studiesData.length > 0) { // Map studies to new format, update metadata manager? - const studies = givenStudies.map(study => { + const studies = studiesData.map(study => { const studyMetadata = new OHIFStudyMetadata( study, study.studyInstanceUid ); - if (!study.displaySets) { - study.displaySets = studyMetadata.createDisplaySets( - sopClassHandlerModules - ); - } - studyMetadata.setDisplaySets(study.displaySets); - // Updates WADO-RS metaDataManager - updateMetaDataManager(study); - studyMetadataManager.add(studyMetadata); + + _updateStudyDisplaySets(study, studyMetadata); + _updateMetaDataManager(study, studyMetadata); + // Attempt to load remaning series if any - this._attemptToLoadRemainingSeries(studyMetadata); + cancelableSeriesPromises[study.studyInstanceUid] = makeCancelable( + _loadRemainingSeries(studyMetadata) + ) + .then(result => { + if (result && !result.isCanceled) { + studyDidLoad(study, studyMetadata, filters); + } + }) + .catch(error => { + if (error && !error.isCanceled) { + setError(true); + } + }); + return study; }); - this.setState({ studies }); - } - } - _addSeriesToStudy(studyMetadata, series) { - const sopClassHandlerModules = - extensionManager.modules['sopClassHandlerModule']; - const study = studyMetadata.getData(); - const seriesMetadata = new OHIFSeriesMetadata(series, study); - studyMetadata.addSeries(seriesMetadata); - studyMetadata.createAndAddDisplaySetsForSeries( - sopClassHandlerModules, - seriesMetadata - ); - study.displaySets = studyMetadata.getDisplaySets(); - updateMetaDataManager(study, series.seriesInstanceUid); - this.setState(function(state) { - return { studies: state.studies.slice() }; - }); - } - - _handleSeriesLoadResult(error, studyMetadata, series) { - if (this.abortSeriesLoad) return; - const stats = this.seriesLoadStats[studyMetadata.getStudyInstanceUID()]; - if (!stats) return; - stats.count--; - if (error || !series) { - stats.errors++; - log.error(error || 'Bad Series'); - return; + setStudies(studies); } - this._addSeriesToStudy(studyMetadata, series); - } + }; - _attemptToLoadRemainingSeries(studyMetadata) { - const { seriesLoader } = studyMetadata.getData(); - if (!seriesLoader) { - return; - } - const stats = (this.seriesLoadStats[studyMetadata.getStudyInstanceUID()] = { - errors: 0, - count: 0, - }); - while (seriesLoader.hasNext()) { - seriesLoader - .next() - .then( - series => - void this._handleSeriesLoadResult(null, studyMetadata, series), - error => void this._handleSeriesLoadResult({ error }, null, null) - ); - stats.count++; - } - } + const loadStudies = async () => { + try { + const filters = {}; + // Use the first, discard others + const seriesInstanceUID = seriesInstanceUids && seriesInstanceUids[0]; - componentWillUnmount() { - this.abortSeriesLoad = true; - for (const studyInstanceUid in this.seriesLoadStats) { - const stats = this.seriesLoadStats[studyInstanceUid]; - if (stats && (stats.count > 0 || stats.errors > 0)) { - deleteStudyMetadataPromise(studyInstanceUid); - studyMetadataManager.remove(studyInstanceUid); - log.info(`Purging incomplete study data: ${studyInstanceUid}`); + const retrieveParams = [server, studyInstanceUids]; + + if (seriesInstanceUID) { + filters.seriesInstanceUID = seriesInstanceUID; + // Query param filtering controlled by appConfig property + if (isFilterStrategy) { + retrieveParams.push(filters); + } + } + + cancelableStudiesPromises[studyInstanceUids] = makeCancelable( + retrieveStudiesMetadata(...retrieveParams) + ) + .then(result => { + if (result && !result.isCanceled) { + processStudies(result, filters); + } + }) + .catch(error => { + if (error && !error.isCanceled) { + setError(true); + } + }); + } catch (error) { + if (error) { + setError(true); } } - } + }; - componentDidMount() { - // TODO: CLEAR THIS SOMEWHERE ELSE - studyMetadataManager.purge(); - this.loadStudies(); - } - - render() { - if (this.state.error) { - return
Error: {JSON.stringify(this.state.error)}
; + const purgeCancellablePromises = () => { + for (let studyInstanceUids in cancelableStudiesPromises) { + if ('cancel' in cancelableStudiesPromises[studyInstanceUids]) { + cancelableStudiesPromises[studyInstanceUids].cancel(); + } } - return ( - - ); + for (let studyInstanceUids in cancelableSeriesPromises) { + if ('cancel' in cancelableSeriesPromises[studyInstanceUids]) { + cancelableSeriesPromises[studyInstanceUids].cancel(); + deleteStudyMetadataPromise(studyInstanceUids); + studyMetadataManager.remove(studyInstanceUids); + } + } + }; + + useEffect(() => { + studyMetadataManager.purge(); + purgeCancellablePromises(); + }, [studyInstanceUids]); + + useEffect(() => { + cancelableSeriesPromises = {}; + cancelableStudiesPromises = {}; + loadStudies(); + + return () => { + purgeCancellablePromises(); + }; + }, []); + + if (error) { + return
Error: {JSON.stringify(error)}
; } + + return ( + + ); } -export default withSnackbar(ViewerRetrieveStudyData); +ViewerRetrieveStudyData.propTypes = { + studyInstanceUids: PropTypes.array.isRequired, + seriesInstanceUids: PropTypes.array, + server: PropTypes.object, + clearViewportSpecificData: PropTypes.func.isRequired, +}; + +export default ViewerRetrieveStudyData;