Files
ohif-viewer/extensions/default/src/Panels/StudyBrowser/PanelStudyBrowser.tsx
T
Luis Miguel SantosandBill Wallace 42aa2df626 fix 5323 unexpected series list filter change from all to recent (#5337)
* fix to unexpected change from All to recent browser study lists because of the search order for the presence of the study in the tab groups and subsequent reset of the active tab state.
Minor comment in the study browser header.

Signed-off-by: Luis M. Santos <luis.santos2@nih.gov>

* chore Added type annotations and cleaned up stylistically.

Signed-off-by: Luis M. Santos <luis.santos2@nih.gov>

* Tighten up a bit of the code to make it easier to follow

* Fix warnings

---------

Signed-off-by: Luis M. Santos <luis.santos2@nih.gov>
Co-authored-by: Bill Wallace <wayfarer3130@gmail.com>

Confirm that Alireza's requested change was included.
2025-10-16 21:55:31 -04:00

554 lines
18 KiB
TypeScript

import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useImageViewer } from '@ohif/ui-next';
import { useSystem, utils } from '@ohif/core';
import { useNavigate } from 'react-router-dom';
import { useViewportGrid, StudyBrowser, Separator } from '@ohif/ui-next';
import { PanelStudyBrowserHeader } from './PanelStudyBrowserHeader';
import { defaultActionIcons } from './constants';
import MoreDropdownMenu from '../../Components/MoreDropdownMenu';
import { CallbackCustomization } from 'platform/core/src/types';
import { type TabsProps } from '@ohif/core/src/utils/createStudyBrowserTabs';
const { sortStudyInstances, formatDate, createStudyBrowserTabs } = utils;
const thumbnailNoImageModalities = ['SR', 'SEG', 'RTSTRUCT', 'RTPLAN', 'RTDOSE', 'DOC', 'PMAP'];
/**
* Study Browser component that displays and manages studies and their display sets
*/
function PanelStudyBrowser({
getImageSrc,
getStudiesForPatientByMRN,
requestDisplaySetCreationForStudy,
dataSource,
customMapDisplaySets,
onClickUntrack,
onDoubleClickThumbnailHandlerCallBack,
}) {
const { servicesManager, commandsManager, extensionManager } = useSystem();
const { displaySetService, customizationService } = servicesManager.services;
const navigate = useNavigate();
const studyMode = (customizationService.getCustomization('studyBrowser.studyMode') as string) || 'all';
const internalImageViewer = useImageViewer();
const StudyInstanceUIDs = internalImageViewer.StudyInstanceUIDs;
const fetchedStudiesRef = useRef(new Set());
const [{ activeViewportId, viewports, isHangingProtocolLayout }] = useViewportGrid();
const [activeTabName, setActiveTabName] = useState(studyMode);
const [expandedStudyInstanceUIDs, setExpandedStudyInstanceUIDs] = useState(
studyMode === 'primary' && StudyInstanceUIDs.length > 0
? [StudyInstanceUIDs[0]]
: [...StudyInstanceUIDs]
);
const [hasLoadedViewports, setHasLoadedViewports] = useState(false);
const [studyDisplayList, setStudyDisplayList] = useState([]);
const [displaySets, setDisplaySets] = useState([]);
const [displaySetsLoadingState, setDisplaySetsLoadingState] = useState({});
const [thumbnailImageSrcMap, setThumbnailImageSrcMap] = useState({});
const [jumpToDisplaySet, setJumpToDisplaySet] = useState(null);
const [viewPresets, setViewPresets] = useState(
customizationService.getCustomization('studyBrowser.viewPresets')
);
const [actionIcons, setActionIcons] = useState(defaultActionIcons);
// multiple can be true or false
const updateActionIconValue = actionIcon => {
actionIcon.value = !actionIcon.value;
const newActionIcons = [...actionIcons];
setActionIcons(newActionIcons);
};
// only one is true at a time
const updateViewPresetValue = viewPreset => {
if (!viewPreset) {
return;
}
const newViewPresets = viewPresets.map(preset => {
preset.selected = preset.id === viewPreset.id;
return preset;
});
setViewPresets(newViewPresets);
};
const mapDisplaySetsWithState = customMapDisplaySets || _mapDisplaySets;
const onDoubleClickThumbnailHandler = useCallback(
async displaySetInstanceUID => {
const customHandler = customizationService.getCustomization(
'studyBrowser.thumbnailDoubleClickCallback'
) as CallbackCustomization;
const setupArgs = {
activeViewportId,
commandsManager,
servicesManager,
isHangingProtocolLayout,
appConfig: extensionManager._appConfig,
};
const handlers = customHandler?.callbacks.map(callback => callback(setupArgs));
for (const handler of handlers) {
await handler(displaySetInstanceUID);
}
onDoubleClickThumbnailHandlerCallBack?.(displaySetInstanceUID);
},
[
activeViewportId,
commandsManager,
servicesManager,
isHangingProtocolLayout,
customizationService,
]
);
// ~~ studyDisplayList
useEffect(() => {
// Fetch all studies for the patient in each primary study
async function fetchStudiesForPatient(StudyInstanceUID) {
// Skip fetching if we've already fetched this study
if (fetchedStudiesRef.current.has(StudyInstanceUID)) {
return;
}
fetchedStudiesRef.current.add(StudyInstanceUID);
// current study qido
const qidoForStudyUID = await dataSource.query.studies.search({
studyInstanceUid: StudyInstanceUID,
});
if (!qidoForStudyUID?.length) {
navigate('/notfoundstudy', '_self');
throw new Error('Invalid study URL');
}
let qidoStudiesForPatient = qidoForStudyUID;
// try to fetch the prior studies based on the patientID if the
// server can respond.
try {
qidoStudiesForPatient = await getStudiesForPatientByMRN(qidoForStudyUID);
} catch (error) {
console.warn(error);
}
const mappedStudies = _mapDataSourceStudies(qidoStudiesForPatient);
const actuallyMappedStudies = mappedStudies.map(qidoStudy => {
return {
studyInstanceUid: qidoStudy.StudyInstanceUID,
date: formatDate(qidoStudy.StudyDate) || '',
description: qidoStudy.StudyDescription,
modalities: qidoStudy.ModalitiesInStudy,
numInstances: Number(qidoStudy.NumInstances),
};
});
setStudyDisplayList(prevArray => {
const ret = [...prevArray];
for (const study of actuallyMappedStudies) {
if (!prevArray.find(it => it.studyInstanceUid === study.studyInstanceUid)) {
ret.push(study);
}
}
return ret;
});
}
StudyInstanceUIDs.forEach(sid => fetchStudiesForPatient(sid));
}, [StudyInstanceUIDs, dataSource, getStudiesForPatientByMRN, navigate]);
// ~~ Initial Thumbnails
useEffect(() => {
if (!hasLoadedViewports) {
if (activeViewportId) {
// Once there is an active viewport id, it means the layout is ready
// so wait a bit of time to allow the viewports preferential loading
// which improves user experience of responsiveness significantly on slower
// systems.
const delayMs = 250 + displaySetService.getActiveDisplaySets().length * 10;
window.setTimeout(() => setHasLoadedViewports(true), delayMs);
}
return;
}
let currentDisplaySets = displaySetService.activeDisplaySets;
// filter non based on the list of modalities that are supported by cornerstone
currentDisplaySets = currentDisplaySets.filter(
ds => !thumbnailNoImageModalities.includes(ds.Modality) || ds.thumbnailSrc === null
);
if (!currentDisplaySets.length) {
return;
}
currentDisplaySets.forEach(async dSet => {
const newImageSrcEntry = {};
const displaySet = displaySetService.getDisplaySetByUID(dSet.displaySetInstanceUID);
const imageIds = dataSource.getImageIdsForDisplaySet(dSet);
const imageId = getImageIdForThumbnail(displaySet, imageIds);
// TODO: Is it okay that imageIds are not returned here for SR displaySets?
if (displaySet?.unsupported) {
return;
}
// When the image arrives, render it and store the result in the thumbnailImgSrcMap
let { thumbnailSrc } = displaySet;
if (!thumbnailSrc && displaySet.getThumbnailSrc) {
thumbnailSrc = await displaySet.getThumbnailSrc({ getImageSrc });
}
if (!thumbnailSrc && imageId) {
const thumbnailSrc = await getImageSrc(imageId);
displaySet.thumbnailSrc = thumbnailSrc;
}
newImageSrcEntry[dSet.displaySetInstanceUID] = thumbnailSrc;
setThumbnailImageSrcMap(prevState => {
return { ...prevState, ...newImageSrcEntry };
});
});
}, [displaySetService, dataSource, getImageSrc, activeViewportId, hasLoadedViewports]);
// ~~ displaySets
useEffect(() => {
const currentDisplaySets = displaySetService.activeDisplaySets;
if (!currentDisplaySets.length) {
return;
}
const mappedDisplaySets = mapDisplaySetsWithState(
currentDisplaySets,
displaySetsLoadingState,
thumbnailImageSrcMap,
viewports
);
if (!customMapDisplaySets) {
sortStudyInstances(mappedDisplaySets);
}
setDisplaySets(mappedDisplaySets);
}, [
displaySetService.activeDisplaySets,
displaySetsLoadingState,
viewports,
thumbnailImageSrcMap,
customMapDisplaySets,
]);
// ~~ subscriptions --> displaySets
useEffect(() => {
// DISPLAY_SETS_ADDED returns an array of DisplaySets that were added
const SubscriptionDisplaySetsAdded = displaySetService.subscribe(
displaySetService.EVENTS.DISPLAY_SETS_ADDED,
data => {
if (!hasLoadedViewports) {
return;
}
const { displaySetsAdded, options } = data;
displaySetsAdded.forEach(async dSet => {
const displaySetInstanceUID = dSet.displaySetInstanceUID;
const newImageSrcEntry = {};
const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
if (displaySet?.unsupported) {
return;
}
if (options?.madeInClient) {
setJumpToDisplaySet(displaySetInstanceUID);
}
const imageIds = dataSource.getImageIdsForDisplaySet(displaySet);
const imageId = getImageIdForThumbnail(displaySet, imageIds);
// TODO: Is it okay that imageIds are not returned here for SR displaysets?
if (!imageId) {
return;
}
// When the image arrives, render it and store the result in the thumbnailImgSrcMap
let { thumbnailSrc } = displaySet;
if (!thumbnailSrc && displaySet.getThumbnailSrc) {
thumbnailSrc = await displaySet.getThumbnailSrc({ getImageSrc });
}
if (!thumbnailSrc) {
thumbnailSrc = await getImageSrc(imageId);
displaySet.thumbnailSrc = thumbnailSrc;
}
newImageSrcEntry[displaySetInstanceUID] = thumbnailSrc;
setThumbnailImageSrcMap(prevState => {
return { ...prevState, ...newImageSrcEntry };
});
});
}
);
return () => {
SubscriptionDisplaySetsAdded.unsubscribe();
};
}, [displaySetService, dataSource, getImageSrc, hasLoadedViewports]);
useEffect(() => {
// TODO: Will this always hold _all_ the displaySets we care about?
// DISPLAY_SETS_CHANGED returns `DisplaySerService.activeDisplaySets`
const SubscriptionDisplaySetsChanged = displaySetService.subscribe(
displaySetService.EVENTS.DISPLAY_SETS_CHANGED,
changedDisplaySets => {
const mappedDisplaySets = mapDisplaySetsWithState(
changedDisplaySets,
displaySetsLoadingState,
thumbnailImageSrcMap,
viewports
);
if (!customMapDisplaySets) {
sortStudyInstances(mappedDisplaySets);
}
setDisplaySets(mappedDisplaySets);
}
);
const SubscriptionDisplaySetMetaDataInvalidated = displaySetService.subscribe(
displaySetService.EVENTS.DISPLAY_SET_SERIES_METADATA_INVALIDATED,
() => {
const mappedDisplaySets = mapDisplaySetsWithState(
displaySetService.getActiveDisplaySets(),
displaySetsLoadingState,
thumbnailImageSrcMap,
viewports
);
if (!customMapDisplaySets) {
sortStudyInstances(mappedDisplaySets);
}
setDisplaySets(mappedDisplaySets);
}
);
return () => {
SubscriptionDisplaySetsChanged.unsubscribe();
SubscriptionDisplaySetMetaDataInvalidated.unsubscribe();
};
}, [
displaySetsLoadingState,
thumbnailImageSrcMap,
viewports,
displaySetService,
customMapDisplaySets,
]);
const tabs = createStudyBrowserTabs(StudyInstanceUIDs, studyDisplayList, displaySets);
// TODO: Should not fire this on "close"
function _handleStudyClick(StudyInstanceUID) {
const shouldCollapseStudy = expandedStudyInstanceUIDs.includes(StudyInstanceUID);
const updatedExpandedStudyInstanceUIDs = shouldCollapseStudy
? [...expandedStudyInstanceUIDs.filter(stdyUid => stdyUid !== StudyInstanceUID)]
: [...expandedStudyInstanceUIDs, StudyInstanceUID];
setExpandedStudyInstanceUIDs(updatedExpandedStudyInstanceUIDs);
if (!shouldCollapseStudy) {
const madeInClient = true;
requestDisplaySetCreationForStudy(displaySetService, StudyInstanceUID, madeInClient);
}
}
useEffect(() => {
if (jumpToDisplaySet) {
// Get element by displaySetInstanceUID
const displaySetInstanceUID = jumpToDisplaySet;
const element = document.getElementById(`thumbnail-${displaySetInstanceUID}`);
if (element && typeof element.scrollIntoView === 'function') {
// TODO: Any way to support IE here?
element.scrollIntoView({ behavior: 'smooth' });
setJumpToDisplaySet(null);
}
}
}, [jumpToDisplaySet, expandedStudyInstanceUIDs, activeTabName]);
useEffect(() => {
if (!jumpToDisplaySet) {
return;
}
const displaySetInstanceUID = jumpToDisplaySet;
// It is possible to navigate to a study not currently in view
const thumbnailLocation = _findTabAndStudyOfDisplaySet(displaySetInstanceUID, tabs, activeTabName);
if (!thumbnailLocation) {
return;
}
const { tabName, StudyInstanceUID } = thumbnailLocation;
setActiveTabName(tabName);
const studyExpanded = expandedStudyInstanceUIDs.includes(StudyInstanceUID);
if (!studyExpanded) {
const updatedExpandedStudyInstanceUIDs = [...expandedStudyInstanceUIDs, StudyInstanceUID];
setExpandedStudyInstanceUIDs(updatedExpandedStudyInstanceUIDs);
}
}, [expandedStudyInstanceUIDs, jumpToDisplaySet, tabs]);
const activeDisplaySetInstanceUIDs = viewports.get(activeViewportId)?.displaySetInstanceUIDs;
return (
<>
<>
<PanelStudyBrowserHeader
viewPresets={viewPresets}
updateViewPresetValue={updateViewPresetValue}
actionIcons={actionIcons}
updateActionIconValue={updateActionIconValue}
/>
<Separator
orientation="horizontal"
className="bg-black"
thickness="2px"
/>
</>
<StudyBrowser
tabs={tabs}
servicesManager={servicesManager}
activeTabName={activeTabName}
expandedStudyInstanceUIDs={expandedStudyInstanceUIDs}
onClickStudy={_handleStudyClick}
onClickTab={clickedTabName => {
setActiveTabName(clickedTabName);
}}
onClickUntrack={onClickUntrack}
onClickThumbnail={() => {}}
onDoubleClickThumbnail={onDoubleClickThumbnailHandler}
activeDisplaySetInstanceUIDs={activeDisplaySetInstanceUIDs}
showSettings={actionIcons.find(icon => icon.id === 'settings')?.value}
viewPresets={viewPresets}
ThumbnailMenuItems={MoreDropdownMenu({
commandsManager,
servicesManager,
menuItemsKey: 'studyBrowser.thumbnailMenuItems',
})}
StudyMenuItems={MoreDropdownMenu({
commandsManager,
servicesManager,
menuItemsKey: 'studyBrowser.studyMenuItems',
})}
/>
</>
);
}
export default PanelStudyBrowser;
/**
* Maps from the DataSource's format to a naturalized object
*
* @param {*} studies
*/
function _mapDataSourceStudies(studies) {
return studies.map(study => {
// TODO: Why does the data source return in this format?
return {
AccessionNumber: study.accession,
StudyDate: study.date,
StudyDescription: study.description,
NumInstances: study.instances,
ModalitiesInStudy: study.modalities,
PatientID: study.mrn,
PatientName: study.patientName,
StudyInstanceUID: study.studyInstanceUid,
StudyTime: study.time,
};
});
}
function _mapDisplaySets(displaySets, displaySetLoadingState, thumbnailImageSrcMap, viewports) {
const thumbnailDisplaySets = [];
const thumbnailNoImageDisplaySets = [];
displaySets
.filter(ds => !ds.excludeFromThumbnailBrowser)
.forEach(ds => {
const { thumbnailSrc, displaySetInstanceUID } = ds;
const componentType = _getComponentType(ds);
const array =
componentType === 'thumbnail' ? thumbnailDisplaySets : thumbnailNoImageDisplaySets;
const loadingProgress = displaySetLoadingState?.[displaySetInstanceUID];
array.push({
displaySetInstanceUID,
description: ds.SeriesDescription || '',
seriesNumber: ds.SeriesNumber,
modality: ds.Modality,
seriesDate: formatDate(ds.SeriesDate),
numInstances: ds.numImageFrames,
loadingProgress,
countIcon: ds.countIcon,
messages: ds.messages,
StudyInstanceUID: ds.StudyInstanceUID,
componentType,
imageSrc: thumbnailSrc || thumbnailImageSrcMap[displaySetInstanceUID],
dragData: {
type: 'displayset',
displaySetInstanceUID,
// .. Any other data to pass
},
isHydratedForDerivedDisplaySet: ds.isHydrated,
});
});
return [...thumbnailDisplaySets, ...thumbnailNoImageDisplaySets];
}
function _getComponentType(ds) {
if (
thumbnailNoImageModalities.includes(ds.Modality) ||
ds?.unsupported ||
ds.thumbnailSrc === null
) {
return 'thumbnailNoImage';
}
return 'thumbnail';
}
function getImageIdForThumbnail(displaySet, imageIds) {
let imageId;
if (displaySet.isDynamicVolume) {
const timePoints = displaySet.dynamicVolumeInfo.timePoints;
const middleIndex = Math.floor(timePoints.length / 2);
const middleTimePointImageIds = timePoints[middleIndex];
imageId = middleTimePointImageIds[Math.floor(middleTimePointImageIds.length / 2)];
} else {
imageId = imageIds[Math.floor(imageIds.length / 2)];
}
return imageId;
}
function _findTabAndStudyOfDisplaySet(
displaySetInstanceUID: string,
tabs: TabsProps,
currentTabName: string
) {
const current = tabs.find(tab => tab.name===currentTabName) || tabs[0];
const biasedTabs = [current, ...tabs];
for (let t = 0; t < biasedTabs.length; t++) {
const study = biasedTabs[t].studies.find(study => study.displaySets.find(ds => ds.displaySetInstanceUID ===displaySetInstanceUID));
if (study) {
return {
tabName: biasedTabs[t].name,
StudyInstanceUID: study.studyInstanceUid,
};
}
}
}