feat(slice scrollbar): Integrate ViewportSliceProgressScrollbar with customizations and docs (#5960)
Co-authored-by: Dan Rukas <dan.rukas@gmail.com>
This commit is contained in:
1 parent
8a2fee4d8a
commit
8fc0dc16e9
46 files changed
+1730
-279
No files matched your search
@@ -1,13 +1,14 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import ViewportImageScrollbar from './ViewportImageScrollbar';
|
||||
import ViewportSliceProgressScrollbar from './ViewportSliceProgressScrollbar/ViewportSliceProgressScrollbar';
|
||||
import CustomizableViewportOverlay from './CustomizableViewportOverlay';
|
||||
import ViewportOrientationMarkers from './ViewportOrientationMarkers';
|
||||
import ViewportImageSliceLoadingIndicator from './ViewportImageSliceLoadingIndicator';
|
||||
|
||||
function CornerstoneOverlays(props: withAppTypes) {
|
||||
const { viewportId, element, scrollbarHeight, servicesManager } = props;
|
||||
const { cornerstoneViewportService } = servicesManager.services;
|
||||
const { cornerstoneViewportService, customizationService } = servicesManager.services;
|
||||
const [imageSliceData, setImageSliceData] = useState({
|
||||
imageIndex: 0,
|
||||
numberOfSlices: 0,
|
||||
@@ -43,17 +44,31 @@ function CornerstoneOverlays(props: withAppTypes) {
|
||||
}
|
||||
}
|
||||
|
||||
const viewportScrollbarVariant = customizationService.getCustomization('viewportScrollbar.variant');
|
||||
const useProgressScrollbar = viewportScrollbarVariant !== 'legacy';
|
||||
|
||||
return (
|
||||
<div className="noselect">
|
||||
<ViewportImageScrollbar
|
||||
viewportId={viewportId}
|
||||
viewportData={viewportData}
|
||||
element={element}
|
||||
imageSliceData={imageSliceData}
|
||||
setImageSliceData={setImageSliceData}
|
||||
scrollbarHeight={scrollbarHeight}
|
||||
servicesManager={servicesManager}
|
||||
/>
|
||||
{useProgressScrollbar ? (
|
||||
<ViewportSliceProgressScrollbar
|
||||
viewportId={viewportId}
|
||||
viewportData={viewportData}
|
||||
element={element}
|
||||
imageSliceData={imageSliceData}
|
||||
setImageSliceData={setImageSliceData}
|
||||
servicesManager={servicesManager}
|
||||
/>
|
||||
) : (
|
||||
<ViewportImageScrollbar
|
||||
viewportId={viewportId}
|
||||
viewportData={viewportData}
|
||||
element={element}
|
||||
imageSliceData={imageSliceData}
|
||||
setImageSliceData={setImageSliceData}
|
||||
scrollbarHeight={scrollbarHeight}
|
||||
servicesManager={servicesManager}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CustomizableViewportOverlay
|
||||
imageSliceData={imageSliceData}
|
||||
|
||||
@@ -367,7 +367,7 @@ function OverlayItem(props) {
|
||||
title={title}
|
||||
>
|
||||
{label ? <span className="mr-1 shrink-0">{label}</span> : null}
|
||||
<span className="ml-0 mr-2 shrink-0">{value}</span>
|
||||
<span className="ml-0 shrink-0">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { VolumeViewport3D, utilities as csUtils } from '@cornerstonejs/core';
|
||||
import {
|
||||
SmartScrollbar,
|
||||
SmartScrollbarTrack,
|
||||
SmartScrollbarFill,
|
||||
SmartScrollbarIndicator,
|
||||
SmartScrollbarEndpoints,
|
||||
} from '@ohif/ui-next';
|
||||
import { getViewportImageIds } from './helpers';
|
||||
import {
|
||||
useLoadedSliceBytes,
|
||||
useProgressScrollbarMode,
|
||||
useViewedSliceBytes,
|
||||
useViewportSliceSync,
|
||||
} from './hooks';
|
||||
import { ViewportSliceProgressScrollbarProps } from './types';
|
||||
|
||||
function ViewportSliceProgressScrollbar({
|
||||
viewportData,
|
||||
viewportId,
|
||||
element,
|
||||
imageSliceData,
|
||||
setImageSliceData,
|
||||
servicesManager,
|
||||
}: ViewportSliceProgressScrollbarProps) {
|
||||
const { cineService, cornerstoneViewportService, customizationService, viewedDataService } =
|
||||
servicesManager.services;
|
||||
|
||||
const showLoadedEndpoints =
|
||||
customizationService.getCustomization('viewportScrollbar.showLoadedEndpoints') !== false;
|
||||
const showLoadedFill =
|
||||
customizationService.getCustomization('viewportScrollbar.showLoadedFill') !== false;
|
||||
const showViewedFill =
|
||||
customizationService.getCustomization('viewportScrollbar.showViewedFill') !== false;
|
||||
const showLoadingPattern =
|
||||
customizationService.getCustomization('viewportScrollbar.showLoadingPattern') !== false;
|
||||
const viewedDwellMsRaw = customizationService.getCustomization('viewportScrollbar.viewedDwellMs');
|
||||
const loadedBatchIntervalMsRaw = customizationService.getCustomization(
|
||||
'viewportScrollbar.loadedBatchIntervalMs'
|
||||
);
|
||||
const viewedDwellMs =
|
||||
typeof viewedDwellMsRaw === 'number' && viewedDwellMsRaw >= 0 ? viewedDwellMsRaw : 0;
|
||||
const loadedBatchIntervalMs =
|
||||
typeof loadedBatchIntervalMsRaw === 'number' && loadedBatchIntervalMsRaw >= 0
|
||||
? loadedBatchIntervalMsRaw
|
||||
: 200;
|
||||
|
||||
const { numberOfSlices, imageIndex } = imageSliceData;
|
||||
|
||||
const imageIds = useMemo(() => getViewportImageIds(viewportData), [viewportData]);
|
||||
const imageIdToIndex = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (let i = 0; i < imageIds.length; i++) {
|
||||
const imageId = imageIds[i];
|
||||
if (imageId) {
|
||||
map.set(imageId, i);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [imageIds]);
|
||||
|
||||
const isFullMode = useProgressScrollbarMode({
|
||||
viewportData,
|
||||
viewportId,
|
||||
element,
|
||||
cornerstoneViewportService,
|
||||
});
|
||||
|
||||
useViewportSliceSync({
|
||||
viewportData,
|
||||
viewportId,
|
||||
element,
|
||||
cornerstoneViewportService,
|
||||
setImageSliceData,
|
||||
});
|
||||
|
||||
const {
|
||||
bytes: loadedBytes,
|
||||
version: loadedVersion,
|
||||
isFull: isFullyLoaded,
|
||||
} = useLoadedSliceBytes({
|
||||
isFullMode,
|
||||
numberOfSlices,
|
||||
viewportData,
|
||||
imageIds,
|
||||
imageIdToIndex,
|
||||
loadedBatchIntervalMs,
|
||||
});
|
||||
|
||||
const { bytes: viewedBytes, version: viewedVersion } = useViewedSliceBytes({
|
||||
isFullMode,
|
||||
numberOfSlices,
|
||||
imageIndex,
|
||||
imageIds,
|
||||
imageIdToIndex,
|
||||
viewedDwellMs,
|
||||
viewedDataService,
|
||||
});
|
||||
|
||||
const onScrollbarValueChange = targetImageIndex => {
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
|
||||
if (!viewport || viewport instanceof VolumeViewport3D) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { isCineEnabled } = cineService.getState();
|
||||
|
||||
if (isCineEnabled) {
|
||||
cineService.stopClip(element, { viewportId });
|
||||
cineService.setCine({ id: viewportId, frameRate: undefined, isPlaying: false });
|
||||
}
|
||||
|
||||
csUtils.jumpToSlice(viewport.element, {
|
||||
imageIndex: targetImageIndex,
|
||||
debounceLoading: true,
|
||||
});
|
||||
};
|
||||
|
||||
const isLoading = isFullMode && showLoadingPattern ? !isFullyLoaded : false;
|
||||
|
||||
if (!numberOfSlices || numberOfSlices <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: '100%',
|
||||
padding: '8px 5px',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
width: '11px',
|
||||
}}
|
||||
>
|
||||
<SmartScrollbar
|
||||
className="absolute inset-0"
|
||||
value={imageIndex || 0}
|
||||
total={numberOfSlices}
|
||||
onValueChange={onScrollbarValueChange}
|
||||
isLoading={isLoading}
|
||||
enableKeyboardNavigation={false}
|
||||
aria-label="Image navigation scrollbar"
|
||||
indicator={
|
||||
customizationService.getCustomization('viewportScrollbar.indicator') as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
}
|
||||
>
|
||||
<SmartScrollbarTrack>
|
||||
{isFullMode && showLoadedFill && (
|
||||
<SmartScrollbarFill
|
||||
marked={loadedBytes}
|
||||
version={loadedVersion}
|
||||
className="bg-neutral/25"
|
||||
loadingClassName="bg-neutral/50"
|
||||
/>
|
||||
)}
|
||||
{isFullMode && showViewedFill && (
|
||||
<SmartScrollbarFill
|
||||
marked={viewedBytes}
|
||||
version={viewedVersion}
|
||||
className="bg-primary/35"
|
||||
loadingClassName="bg-primary/35"
|
||||
/>
|
||||
)}
|
||||
</SmartScrollbarTrack>
|
||||
<SmartScrollbarIndicator />
|
||||
{isFullMode && showLoadedEndpoints && (
|
||||
<SmartScrollbarEndpoints
|
||||
marked={loadedBytes}
|
||||
version={loadedVersion}
|
||||
/>
|
||||
)}
|
||||
</SmartScrollbar>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ViewportSliceProgressScrollbar.propTypes = {
|
||||
viewportData: PropTypes.object,
|
||||
viewportId: PropTypes.string.isRequired,
|
||||
element: PropTypes.instanceOf(Element),
|
||||
imageSliceData: PropTypes.object.isRequired,
|
||||
setImageSliceData: PropTypes.func.isRequired,
|
||||
servicesManager: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default ViewportSliceProgressScrollbar;
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { Enums, VolumeViewport3D } from '@cornerstonejs/core';
|
||||
import { ViewportData } from './types';
|
||||
|
||||
export function getImageIndexFromEvent(event): number | undefined {
|
||||
const { imageIndex, newImageIdIndex = imageIndex, imageIdIndex } = event.detail;
|
||||
return newImageIdIndex ?? imageIdIndex;
|
||||
}
|
||||
|
||||
export function getViewportImageIds(viewportData: ViewportData): string[] {
|
||||
if (!viewportData?.data?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const firstData = viewportData.data[0];
|
||||
const volumeImageIds = (firstData as any).volume?.imageIds as string[] | undefined;
|
||||
const datumImageIds = (firstData as any).imageIds as string[] | undefined;
|
||||
|
||||
return volumeImageIds || datumImageIds || [];
|
||||
}
|
||||
|
||||
export function isProgressFullMode(viewportData: ViewportData, viewport): boolean {
|
||||
if (!viewportData || !viewport || viewport instanceof VolumeViewport3D) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (viewportData.viewportType === Enums.ViewportType.STACK) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (viewportData.viewportType === Enums.ViewportType.ORTHOGRAPHIC) {
|
||||
return !!viewport.isInAcquisitionPlane?.();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getImageIdFromCacheEvent(event): string | undefined {
|
||||
const detail = event?.detail;
|
||||
return detail?.imageId || detail?.image?.imageId || detail?.cachedImage?.imageId;
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
cache as cornerstoneCache,
|
||||
Enums,
|
||||
eventTarget,
|
||||
utilities,
|
||||
VolumeViewport3D,
|
||||
} from '@cornerstonejs/core';
|
||||
import { useByteArray } from '@ohif/ui-next';
|
||||
import { getImageIdFromCacheEvent, getImageIndexFromEvent, isProgressFullMode } from './helpers';
|
||||
import { ImageSliceData, ViewportData } from './types';
|
||||
|
||||
export function useProgressScrollbarMode({
|
||||
viewportData,
|
||||
viewportId,
|
||||
element,
|
||||
cornerstoneViewportService,
|
||||
}: {
|
||||
viewportData: ViewportData;
|
||||
viewportId: string;
|
||||
element: HTMLElement;
|
||||
cornerstoneViewportService: AppTypes.CornerstoneViewportService;
|
||||
}) {
|
||||
const [isFullMode, setIsFullMode] = useState(false);
|
||||
const lastViewPlaneNormalRef = useRef<number[] | null>(null);
|
||||
|
||||
/**
|
||||
* Tracks whether this viewport should render full progress UI (stack or acquisition-plane
|
||||
* orthographic volume) versus minimal UI. We compute once on setup and recompute on each
|
||||
* CAMERA_MODIFIED event so stack->MPR transitions and acquisition-plane changes are reflected
|
||||
* immediately.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!viewportData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateMode = () => {
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
const viewportImageData = viewport?.getImageData?.();
|
||||
const nextViewPlaneNormal = viewport?.getCamera?.()?.viewPlaneNormal as number[] | undefined;
|
||||
// Do not update the lastViewPlaneNormalRef until we have a valid viewportImageData.
|
||||
// Without viewportImageData, the viewport is not fully initialized and the isAcquisitionPlane
|
||||
// check will not be accurate.
|
||||
if (viewportImageData && nextViewPlaneNormal) {
|
||||
lastViewPlaneNormalRef.current = [...nextViewPlaneNormal];
|
||||
}
|
||||
const nextMode = isProgressFullMode(viewportData, viewport);
|
||||
setIsFullMode(prevMode => (prevMode === nextMode ? prevMode : nextMode));
|
||||
};
|
||||
|
||||
updateMode();
|
||||
|
||||
const onCameraModified = () => {
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
const nextViewPlaneNormal = viewport?.getCamera?.()?.viewPlaneNormal as number[] | undefined;
|
||||
const previousViewPlaneNormal = lastViewPlaneNormalRef.current;
|
||||
|
||||
// Ignore camera updates that keep the same orientation (pan/zoom/scroll).
|
||||
if (nextViewPlaneNormal && previousViewPlaneNormal) {
|
||||
if (utilities.isEqual(nextViewPlaneNormal, previousViewPlaneNormal)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
updateMode();
|
||||
};
|
||||
element.addEventListener(Enums.Events.CAMERA_MODIFIED, onCameraModified);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener(Enums.Events.CAMERA_MODIFIED, onCameraModified);
|
||||
};
|
||||
}, [viewportData, viewportId, cornerstoneViewportService, element]);
|
||||
|
||||
return isFullMode;
|
||||
}
|
||||
|
||||
export function useViewportSliceSync({
|
||||
viewportData,
|
||||
viewportId,
|
||||
element,
|
||||
cornerstoneViewportService,
|
||||
setImageSliceData,
|
||||
}: {
|
||||
viewportData: ViewportData;
|
||||
viewportId: string;
|
||||
element: HTMLElement;
|
||||
cornerstoneViewportService: AppTypes.CornerstoneViewportService;
|
||||
setImageSliceData: (data: ImageSliceData) => void;
|
||||
}) {
|
||||
/**
|
||||
* Keeps shared slice state in sync: first initialize from the live viewport snapshot, then
|
||||
* subscribe to navigation/render events for incremental updates while users scroll.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!viewportData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
if (viewport && !(viewport instanceof VolumeViewport3D)) {
|
||||
try {
|
||||
const currentImageIndex = viewport.getCurrentImageIdIndex();
|
||||
const currentNumberOfSlices = viewport.getNumberOfSlices();
|
||||
|
||||
setImageSliceData({
|
||||
imageIndex: currentImageIndex,
|
||||
numberOfSlices: currentNumberOfSlices,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
}
|
||||
}
|
||||
|
||||
const { viewportType } = viewportData;
|
||||
const eventId =
|
||||
(viewportType === Enums.ViewportType.STACK && Enums.Events.STACK_NEW_IMAGE) ||
|
||||
(viewportType === Enums.ViewportType.ORTHOGRAPHIC && Enums.Events.VOLUME_NEW_IMAGE) ||
|
||||
Enums.Events.IMAGE_RENDERED;
|
||||
|
||||
const updateIndex = event => {
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
if (!viewport || viewport instanceof VolumeViewport3D) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextImageIndex = getImageIndexFromEvent(event);
|
||||
if (nextImageIndex == null) {
|
||||
return;
|
||||
}
|
||||
const nextNumberOfSlices = viewport.getNumberOfSlices();
|
||||
|
||||
setImageSliceData({
|
||||
imageIndex: nextImageIndex,
|
||||
numberOfSlices: nextNumberOfSlices,
|
||||
});
|
||||
};
|
||||
|
||||
element.addEventListener(eventId, updateIndex);
|
||||
|
||||
return () => {
|
||||
element.removeEventListener(eventId, updateIndex);
|
||||
};
|
||||
}, [viewportData, element, viewportId, cornerstoneViewportService, setImageSliceData]);
|
||||
}
|
||||
|
||||
export function useLoadedSliceBytes({
|
||||
isFullMode,
|
||||
numberOfSlices,
|
||||
viewportData,
|
||||
imageIds,
|
||||
imageIdToIndex,
|
||||
loadedBatchIntervalMs,
|
||||
}: {
|
||||
isFullMode: boolean;
|
||||
numberOfSlices: number;
|
||||
viewportData: ViewportData;
|
||||
imageIds: string[];
|
||||
imageIdToIndex: Map<string, number>;
|
||||
loadedBatchIntervalMs: number;
|
||||
}) {
|
||||
const loadedState = useByteArray(numberOfSlices || 0, loadedBatchIntervalMs);
|
||||
const {
|
||||
resetWith: resetLoaded,
|
||||
setByte: setLoadedByte,
|
||||
clearByte: clearLoadedByte,
|
||||
} = loadedState;
|
||||
|
||||
/**
|
||||
* Keeps the loaded byte array in sync with Cornerstone cache: seed from cache whenever stack /
|
||||
* mode / slice count changes, then subscribe so cache add/remove updates stay incremental.
|
||||
* Seeding runs immediately before registering listeners in the same effect.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isFullMode && numberOfSlices) {
|
||||
resetLoaded(bytes => {
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
const imageId = imageIds[i];
|
||||
if (imageId && cornerstoneCache.isLoaded(imageId)) {
|
||||
bytes[i] = 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!isFullMode || !viewportData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const markLoaded = event => {
|
||||
const imageId = getImageIdFromCacheEvent(event);
|
||||
if (!imageId) {
|
||||
return;
|
||||
}
|
||||
const index = imageIdToIndex.get(imageId);
|
||||
if (index !== undefined) {
|
||||
setLoadedByte(index);
|
||||
}
|
||||
};
|
||||
|
||||
const markRemoved = event => {
|
||||
const imageId = getImageIdFromCacheEvent(event);
|
||||
if (!imageId) {
|
||||
return;
|
||||
}
|
||||
const index = imageIdToIndex.get(imageId);
|
||||
if (index !== undefined) {
|
||||
clearLoadedByte(index);
|
||||
}
|
||||
};
|
||||
|
||||
eventTarget.addEventListener(Enums.Events.IMAGE_CACHE_IMAGE_ADDED, markLoaded);
|
||||
eventTarget.addEventListener(Enums.Events.IMAGE_CACHE_IMAGE_REMOVED, markRemoved);
|
||||
|
||||
return () => {
|
||||
eventTarget.removeEventListener(Enums.Events.IMAGE_CACHE_IMAGE_ADDED, markLoaded);
|
||||
eventTarget.removeEventListener(Enums.Events.IMAGE_CACHE_IMAGE_REMOVED, markRemoved);
|
||||
};
|
||||
}, [
|
||||
imageIds,
|
||||
isFullMode,
|
||||
numberOfSlices,
|
||||
viewportData,
|
||||
imageIdToIndex,
|
||||
resetLoaded,
|
||||
setLoadedByte,
|
||||
clearLoadedByte,
|
||||
]);
|
||||
|
||||
return loadedState;
|
||||
}
|
||||
|
||||
export function useViewedSliceBytes({
|
||||
isFullMode,
|
||||
numberOfSlices,
|
||||
imageIndex,
|
||||
imageIds,
|
||||
imageIdToIndex,
|
||||
viewedDwellMs,
|
||||
viewedDataService,
|
||||
}: {
|
||||
isFullMode: boolean;
|
||||
numberOfSlices: number;
|
||||
imageIndex: number;
|
||||
imageIds: string[];
|
||||
imageIdToIndex: Map<string, number>;
|
||||
viewedDwellMs: number;
|
||||
viewedDataService: AppTypes.ViewedDataService;
|
||||
}) {
|
||||
const viewedState = useByteArray(numberOfSlices || 0);
|
||||
const { resetWith: resetViewed, setByte: setViewedByte } = viewedState;
|
||||
|
||||
/**
|
||||
* Keeps the viewed byte array in sync with the global viewed-data store: seed from the store
|
||||
* whenever stack / mode / slice count changes, then subscribe so `markDataViewed` updates stay
|
||||
* incremental. Seeding runs immediately before registering the listener in the same effect.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isFullMode && numberOfSlices) {
|
||||
resetViewed(bytes => {
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
const imageId = imageIds[i];
|
||||
if (imageId && viewedDataService?.isDataViewed(imageId)) {
|
||||
bytes[i] = 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!viewedDataService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = viewedDataService.subscribeViewedDataChanges(
|
||||
({
|
||||
viewedDataId,
|
||||
viewedDataCleared,
|
||||
}: {
|
||||
viewedDataId?: string;
|
||||
viewedDataCleared?: boolean;
|
||||
}) => {
|
||||
if (!isFullMode || !numberOfSlices) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (viewedDataCleared) {
|
||||
resetViewed(bytes => {
|
||||
bytes.fill(0);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const index = imageIdToIndex.get(viewedDataId);
|
||||
if (index !== undefined) {
|
||||
setViewedByte(index);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [
|
||||
imageIds,
|
||||
isFullMode,
|
||||
numberOfSlices,
|
||||
imageIdToIndex,
|
||||
resetViewed,
|
||||
setViewedByte,
|
||||
viewedDataService,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Marks slices as viewed in full mode. With `viewedDwellMs === 0`, marking is immediate on
|
||||
* index change; otherwise a dwell timer is used and cleaned up on subsequent changes/unmount.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isFullMode || !numberOfSlices) {
|
||||
return;
|
||||
}
|
||||
|
||||
const markViewed = (targetIndex: number) => {
|
||||
setViewedByte(targetIndex);
|
||||
const imageId = imageIds[targetIndex];
|
||||
if (imageId) {
|
||||
viewedDataService?.markDataViewed(imageId);
|
||||
}
|
||||
};
|
||||
|
||||
if (viewedDwellMs === 0) {
|
||||
markViewed(imageIndex || 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const timerId = window.setTimeout(() => {
|
||||
markViewed(imageIndex || 0);
|
||||
}, viewedDwellMs);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timerId);
|
||||
};
|
||||
}, [
|
||||
isFullMode,
|
||||
numberOfSlices,
|
||||
imageIndex,
|
||||
imageIds,
|
||||
setViewedByte,
|
||||
viewedDwellMs,
|
||||
viewedDataService,
|
||||
]);
|
||||
|
||||
return viewedState;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ViewportSliceProgressScrollbar';
|
||||
@@ -0,0 +1,17 @@
|
||||
import { StackViewportData, VolumeViewportData } from '../../../types/CornerstoneCacheService';
|
||||
|
||||
export type ViewportData = StackViewportData | VolumeViewportData;
|
||||
|
||||
export type ImageSliceData = {
|
||||
imageIndex: number;
|
||||
numberOfSlices: number;
|
||||
};
|
||||
|
||||
export type ViewportSliceProgressScrollbarProps = {
|
||||
viewportData: ViewportData | null;
|
||||
viewportId: string;
|
||||
element: HTMLElement;
|
||||
imageSliceData: ImageSliceData;
|
||||
setImageSliceData: (data: ImageSliceData) => void;
|
||||
servicesManager: AppTypes.ServicesManager;
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Default customization values for viewport scrollbar behavior.
|
||||
* The `progress` scrollbar variant is in full mode for stack viewports and
|
||||
* acquisition-plane orthographic viewports.
|
||||
* Otherwise, viewports using the `progress` scrollbar show only the indicator.
|
||||
*
|
||||
* - `viewportScrollbar.variant`: `'progress' | 'legacy'` (default: `'progress'`)
|
||||
* - `viewportScrollbar.showLoadedEndpoints`: show loaded-range endpoint caps in full mode
|
||||
* - `viewportScrollbar.showLoadedFill`: show loaded/cached fill in full mode
|
||||
* - `viewportScrollbar.showViewedFill`: show viewed fill in full mode
|
||||
* - `viewportScrollbar.showLoadingPattern`: show loading pattern in full mode while not fully loaded
|
||||
* - `viewportScrollbar.viewedDwellMs`: delay before marking current image viewed in full mode (ms)
|
||||
* - `viewportScrollbar.loadedBatchIntervalMs`: loaded-state version batch interval in full mode (ms)
|
||||
* - `viewportScrollbar.indicator`: optional custom indicator config
|
||||
*/
|
||||
export default function getViewportScrollbarCustomization() {
|
||||
return {
|
||||
'viewportScrollbar.variant': 'progress',
|
||||
'viewportScrollbar.showLoadedEndpoints': true,
|
||||
'viewportScrollbar.showLoadedFill': true,
|
||||
'viewportScrollbar.showViewedFill': true,
|
||||
'viewportScrollbar.showLoadingPattern': true,
|
||||
'viewportScrollbar.viewedDwellMs': 0,
|
||||
'viewportScrollbar.loadedBatchIntervalMs': 200,
|
||||
'viewportScrollbar.indicator': {},
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import windowLevelPresetsCustomization from './customizations/windowLevelPresets
|
||||
import miscCustomization from './customizations/miscCustomization';
|
||||
import captureViewportModalCustomization from './customizations/captureViewportModalCustomization';
|
||||
import viewportDownloadWarningCustomization from './customizations/viewportDownloadWarningCustomization';
|
||||
import getViewportScrollbarCustomization from './customizations/viewportScrollbarCustomization';
|
||||
|
||||
function getCustomizationModule({ commandsManager, servicesManager, extensionManager }) {
|
||||
return [
|
||||
@@ -34,6 +35,7 @@ function getCustomizationModule({ commandsManager, servicesManager, extensionMan
|
||||
...miscCustomization,
|
||||
...captureViewportModalCustomization,
|
||||
...viewportDownloadWarningCustomization,
|
||||
...getViewportScrollbarCustomization(),
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -21,6 +21,7 @@ import SegmentationService from './services/SegmentationService';
|
||||
import CornerstoneCacheService from './services/CornerstoneCacheService';
|
||||
import CornerstoneViewportService from './services/ViewportService/CornerstoneViewportService';
|
||||
import ColorbarService from './services/ColorbarService';
|
||||
import ViewedDataService from './services/ViewedDataService';
|
||||
import * as CornerstoneExtensionTypes from './types';
|
||||
|
||||
import { toolNames } from './initCornerstoneTools';
|
||||
@@ -180,6 +181,7 @@ const cornerstoneExtension: Types.Extensions.Extension = {
|
||||
useSelectedSegmentationsForViewportStore
|
||||
.getState()
|
||||
.clearSelectedSegmentationsForViewportStore();
|
||||
servicesManager.services.viewedDataService?.clearViewedData();
|
||||
segmentationService.removeAllSegmentations();
|
||||
},
|
||||
|
||||
@@ -196,6 +198,7 @@ const cornerstoneExtension: Types.Extensions.Extension = {
|
||||
servicesManager.registerService(SegmentationService.REGISTRATION);
|
||||
servicesManager.registerService(CornerstoneCacheService.REGISTRATION);
|
||||
servicesManager.registerService(ColorbarService.REGISTRATION);
|
||||
servicesManager.registerService(ViewedDataService.REGISTRATION);
|
||||
|
||||
const { syncGroupService } = servicesManager.services;
|
||||
syncGroupService.registerCustomSynchronizer('frameview', createFrameViewSynchronizer);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { PubSubService } from '@ohif/core';
|
||||
|
||||
export type ViewedDataPayload = {
|
||||
viewedDataId?: string;
|
||||
viewedDataCleared?: boolean;
|
||||
};
|
||||
|
||||
class ViewedDataService extends PubSubService {
|
||||
public static readonly EVENTS = {
|
||||
VIEWED_DATA_CHANGED: 'event::viewedDataChanged',
|
||||
};
|
||||
|
||||
public static REGISTRATION = {
|
||||
name: 'viewedDataService',
|
||||
altName: 'ViewedDataService',
|
||||
create: () => {
|
||||
return new ViewedDataService();
|
||||
},
|
||||
};
|
||||
|
||||
private viewedDataIds = new Set<string>();
|
||||
|
||||
constructor() {
|
||||
super(ViewedDataService.EVENTS);
|
||||
}
|
||||
|
||||
public markDataViewed(dataId: string): void {
|
||||
if (!dataId || this.viewedDataIds.has(dataId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.viewedDataIds.add(dataId);
|
||||
this._broadcastEvent(this.EVENTS.VIEWED_DATA_CHANGED, {
|
||||
viewedDataId: dataId,
|
||||
});
|
||||
}
|
||||
|
||||
public isDataViewed(dataId: string): boolean {
|
||||
if (!dataId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.viewedDataIds.has(dataId);
|
||||
}
|
||||
|
||||
public clearViewedData(): void {
|
||||
this.viewedDataIds.clear();
|
||||
this._broadcastEvent(this.EVENTS.VIEWED_DATA_CHANGED, {
|
||||
viewedDataCleared: true,
|
||||
});
|
||||
}
|
||||
|
||||
public subscribeViewedDataChanges(listener: (payload: ViewedDataPayload) => void) {
|
||||
return this.subscribe(this.EVENTS.VIEWED_DATA_CHANGED, listener);
|
||||
}
|
||||
}
|
||||
|
||||
export default ViewedDataService;
|
||||
@@ -0,0 +1,3 @@
|
||||
import ViewedDataService from './ViewedDataService';
|
||||
|
||||
export default ViewedDataService;
|
||||
@@ -5,6 +5,7 @@ import SegmentationServiceType from '../services/SegmentationService';
|
||||
import SyncGroupServiceType from '../services/SyncGroupService';
|
||||
import ToolGroupServiceType from '../services/ToolGroupService';
|
||||
import ColorbarServiceType from '../services/ColorbarService';
|
||||
import ViewedDataServiceType from '../services/ViewedDataService';
|
||||
import * as cornerstone from '@cornerstonejs/core';
|
||||
import * as cornerstoneTools from '@cornerstonejs/tools';
|
||||
|
||||
@@ -23,6 +24,7 @@ declare global {
|
||||
export type SyncGroupService = SyncGroupServiceType;
|
||||
export type ToolGroupService = ToolGroupServiceType;
|
||||
export type ColorbarService = ColorbarServiceType;
|
||||
export type ViewedDataService = ViewedDataServiceType;
|
||||
|
||||
export interface Services {
|
||||
cornerstoneViewportService?: CornerstoneViewportServiceType;
|
||||
@@ -31,6 +33,7 @@ declare global {
|
||||
segmentationService?: SegmentationServiceType;
|
||||
cornerstoneCacheService?: CornerstoneCacheServiceType;
|
||||
colorbarService?: ColorbarServiceType;
|
||||
viewedDataService?: ViewedDataServiceType;
|
||||
}
|
||||
|
||||
export namespace Segmentation {
|
||||
|
||||
Reference in new issue
Block a user