Initial commit from prod-batam

This commit is contained in:
mario
2025-05-27 10:51:12 +07:00
commit 025b96229b
3361 changed files with 304068 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
.viewport-wrapper {
width: 100%;
height: 100%; /* MUST have `height` to prevent resize infinite loop */
position: relative;
}
.cornerstone-viewport-element {
width: 100%;
height: 100%;
position: relative;
background-color: black;
/* Prevent the blue outline in Chrome when a viewport is selected */
outline: 0 !important;
/* Prevents the entire page from getting larger
when the magnify tool is near the sides/corners of the page */
overflow: hidden;
}

View File

@@ -0,0 +1,664 @@
import React, { useEffect, useRef, useCallback, useState } from 'react';
import { useResizeDetector } from 'react-resize-detector';
import * as cs3DTools from '@cornerstonejs/tools';
import { Enums, eventTarget, getEnabledElement } from '@cornerstonejs/core';
import { MeasurementService } from '@ohif/core';
import { AllInOneMenu, Notification, useViewportDialog } from '@ohif/ui';
import type { Types as csTypes } from '@cornerstonejs/core';
import { setEnabledElement } from '../state';
import './OHIFCornerstoneViewport.css';
import CornerstoneOverlays from './Overlays/CornerstoneOverlays';
import CinePlayer from '../components/CinePlayer';
import type { Types } from '@ohif/core';
import OHIFViewportActionCorners from '../components/OHIFViewportActionCorners';
import { getWindowLevelActionMenu } from '../components/WindowLevelActionMenu/getWindowLevelActionMenu';
import { useAppConfig } from '@state';
import { getViewportDataOverlaySettingsMenu } from '../components/ViewportDataOverlaySettingMenu';
import { getViewportPresentations } from '../utils/presentations/getViewportPresentations';
import { useSynchronizersStore } from '../stores/useSynchronizersStore';
import ActiveViewportBehavior from '../utils/ActiveViewportBehavior';
const STACK = 'stack';
/**
* Caches the jump to measurement operation, so that if display set is shown,
* it can jump to the measurement.
*/
let cacheJumpToMeasurementEvent;
// Todo: This should be done with expose of internal API similar to react-vtkjs-viewport
// Then we don't need to worry about the re-renders if the props change.
const OHIFCornerstoneViewport = React.memo(
(
props: withAppTypes<{
viewportId: string;
displaySets: AppTypes.DisplaySet[];
viewportOptions: AppTypes.ViewportGrid.GridViewportOptions;
initialImageIndex: number;
}>
) => {
const {
displaySets,
dataSource,
viewportOptions,
displaySetOptions,
servicesManager,
commandsManager,
onElementEnabled,
// eslint-disable-next-line react/prop-types
onElementDisabled,
isJumpToMeasurementDisabled = false,
// Note: you SHOULD NOT use the initialImageIdOrIndex for manipulation
// of the imageData in the OHIFCornerstoneViewport. This prop is used
// to set the initial state of the viewport's first image to render
// eslint-disable-next-line react/prop-types
initialImageIndex,
// if the viewport is part of a hanging protocol layout
// we should not really rely on the old synchronizers and
// you see below we only rehydrate the synchronizers if the viewport
// is not part of the hanging protocol layout. HPs should
// define their own synchronizers. Since the synchronizers are
// viewportId dependent and
// eslint-disable-next-line react/prop-types
isHangingProtocolLayout,
} = props;
const viewportId = viewportOptions.viewportId;
if (!viewportId) {
throw new Error('Viewport ID is required');
}
// Make sure displaySetOptions has one object per displaySet
while (displaySetOptions.length < displaySets.length) {
displaySetOptions.push({});
}
// Since we only have support for dynamic data in volume viewports, we should
// handle this case here and set the viewportType to volume if any of the
// displaySets are dynamic volumes
viewportOptions.viewportType = displaySets.some(
ds => ds.isDynamicVolume && ds.isReconstructable
)
? 'volume'
: viewportOptions.viewportType;
const [scrollbarHeight, setScrollbarHeight] = useState('100px');
const [enabledVPElement, setEnabledVPElement] = useState(null);
const elementRef = useRef() as React.MutableRefObject<HTMLDivElement>;
const [appConfig] = useAppConfig();
const {
displaySetService,
toolbarService,
toolGroupService,
syncGroupService,
cornerstoneViewportService,
segmentationService,
cornerstoneCacheService,
viewportActionCornersService,
} = servicesManager.services;
const [viewportDialogState] = useViewportDialog();
// useCallback for scroll bar height calculation
const setImageScrollBarHeight = useCallback(() => {
const scrollbarHeight = `${elementRef.current.clientHeight - 40}px`;
setScrollbarHeight(scrollbarHeight);
}, [elementRef]);
// useCallback for onResize
const onResize = useCallback(() => {
if (elementRef.current) {
cornerstoneViewportService.resize();
setImageScrollBarHeight();
}
}, [elementRef]);
const cleanUpServices = useCallback(
viewportInfo => {
const renderingEngineId = viewportInfo.getRenderingEngineId();
const syncGroups = viewportInfo.getSyncGroups();
toolGroupService.removeViewportFromToolGroup(viewportId, renderingEngineId);
syncGroupService.removeViewportFromSyncGroup(viewportId, renderingEngineId, syncGroups);
segmentationService.clearSegmentationRepresentations(viewportId);
viewportActionCornersService.clear(viewportId);
},
[
viewportId,
segmentationService,
syncGroupService,
toolGroupService,
viewportActionCornersService,
]
);
const elementEnabledHandler = useCallback(
evt => {
// check this is this element reference and return early if doesn't match
if (evt.detail.element !== elementRef.current) {
return;
}
const { viewportId, element } = evt.detail;
const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId);
setEnabledElement(viewportId, element);
setEnabledVPElement(element);
const renderingEngineId = viewportInfo.getRenderingEngineId();
const toolGroupId = viewportInfo.getToolGroupId();
const syncGroups = viewportInfo.getSyncGroups();
toolGroupService.addViewportToToolGroup(viewportId, renderingEngineId, toolGroupId);
syncGroupService.addViewportToSyncGroup(viewportId, renderingEngineId, syncGroups);
// we don't need reactivity here so just use state
const { synchronizersStore } = useSynchronizersStore.getState();
if (synchronizersStore?.[viewportId]?.length && !isHangingProtocolLayout) {
// If the viewport used to have a synchronizer, re apply it again
_rehydrateSynchronizers(viewportId, syncGroupService);
}
if (onElementEnabled && typeof onElementEnabled === 'function') {
onElementEnabled(evt);
}
},
[viewportId, onElementEnabled, toolGroupService]
);
// disable the element upon unmounting
useEffect(() => {
cornerstoneViewportService.enableViewport(viewportId, elementRef.current);
eventTarget.addEventListener(Enums.Events.ELEMENT_ENABLED, elementEnabledHandler);
setImageScrollBarHeight();
return () => {
const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId);
if (!viewportInfo) {
return;
}
cornerstoneViewportService.storePresentation({ viewportId });
// This should be done after the store presentation since synchronizers
// will get cleaned up and they need the viewportInfo to be present
cleanUpServices(viewportInfo);
if (onElementDisabled && typeof onElementDisabled === 'function') {
onElementDisabled(viewportInfo);
}
cornerstoneViewportService.disableElement(viewportId);
eventTarget.removeEventListener(Enums.Events.ELEMENT_ENABLED, elementEnabledHandler);
};
}, []);
// subscribe to displaySet metadata invalidation (updates)
// Currently, if the metadata changes we need to re-render the display set
// for it to take effect in the viewport. As we deal with scaling in the loading,
// we need to remove the old volume from the cache, and let the
// viewport to re-add it which will use the new metadata. Otherwise, the
// viewport will use the cached volume and the new metadata will not be used.
// Note: this approach does not actually end of sending network requests
// and it uses the network cache
useEffect(() => {
const { unsubscribe } = displaySetService.subscribe(
displaySetService.EVENTS.DISPLAY_SET_SERIES_METADATA_INVALIDATED,
async ({
displaySetInstanceUID: invalidatedDisplaySetInstanceUID,
invalidateData,
}: Types.DisplaySetSeriesMetadataInvalidatedEvent) => {
if (!invalidateData) {
return;
}
const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId);
if (viewportInfo.hasDisplaySet(invalidatedDisplaySetInstanceUID)) {
const viewportData = viewportInfo.getViewportData();
const newViewportData = await cornerstoneCacheService.invalidateViewportData(
viewportData,
invalidatedDisplaySetInstanceUID,
dataSource,
displaySetService
);
const keepCamera = true;
cornerstoneViewportService.updateViewport(viewportId, newViewportData, keepCamera);
}
}
);
return () => {
unsubscribe();
};
}, [viewportId]);
useEffect(() => {
// handle the default viewportType to be stack
if (!viewportOptions.viewportType) {
viewportOptions.viewportType = STACK;
}
const loadViewportData = async () => {
const viewportData = await cornerstoneCacheService.createViewportData(
displaySets,
viewportOptions,
dataSource,
initialImageIndex
);
const presentations = getViewportPresentations(viewportId, viewportOptions);
let measurement;
if (cacheJumpToMeasurementEvent?.viewportId === viewportId) {
measurement = cacheJumpToMeasurementEvent.measurement;
// Delete the position presentation so that viewport navigates direct
presentations.positionPresentation = null;
cacheJumpToMeasurementEvent = null;
}
// Note: This is a hack to get the grid to re-render the OHIFCornerstoneViewport component
// Used for segmentation hydration right now, since the logic to decide whether
// a viewport needs to render a segmentation lives inside the CornerstoneViewportService
// so we need to re-render (force update via change of the needsRerendering) so that React
// does the diffing and decides we should render this again (although the id and element has not changed)
// so that the CornerstoneViewportService can decide whether to render the segmentation or not. Not that we reached here we can turn it off.
if (viewportOptions.needsRerendering) {
viewportOptions.needsRerendering = false;
}
cornerstoneViewportService.setViewportData(
viewportId,
viewportData,
viewportOptions,
displaySetOptions,
presentations
);
if (measurement) {
cs3DTools.annotation.selection.setAnnotationSelected(measurement.uid);
}
};
loadViewportData();
}, [viewportOptions, displaySets, dataSource]);
/**
* There are two scenarios for jump to click
* 1. Current viewports contain the displaySet that the annotation was drawn on
* 2. Current viewports don't contain the displaySet that the annotation was drawn on
* and we need to change the viewports displaySet for jumping.
* Since measurement_jump happens via events and listeners, the former case is handled
* by the measurement_jump direct callback, but the latter case is handled first by
* the viewportGrid to set the correct displaySet on the viewport, AND THEN we check
* the cache for jumping to see if there is any jump queued, then we jump to the correct slice.
*/
useEffect(() => {
if (isJumpToMeasurementDisabled) {
return;
}
const unsubscribeFromJumpToMeasurementEvents = _subscribeToJumpToMeasurementEvents(
elementRef,
viewportId,
servicesManager
);
_checkForCachedJumpToMeasurementEvents(elementRef, viewportId, displaySets, servicesManager);
return () => {
unsubscribeFromJumpToMeasurementEvents();
};
}, [displaySets, elementRef, viewportId, isJumpToMeasurementDisabled, servicesManager]);
// Set up the window level action menu in the viewport action corners.
useEffect(() => {
// Doing an === check here because the default config value when not set is true
if (appConfig.addWindowLevelActionMenu === false) {
return;
}
const location = viewportActionCornersService.LOCATIONS.topRight;
// TODO: In the future we should consider using the customization service
// to determine if and in which corner various action components should go.
viewportActionCornersService.addComponent({
viewportId,
id: 'windowLevelActionMenu',
component: getWindowLevelActionMenu({
viewportId,
element: elementRef.current,
displaySets,
servicesManager,
commandsManager,
location,
verticalDirection: AllInOneMenu.VerticalDirection.TopToBottom,
horizontalDirection: AllInOneMenu.HorizontalDirection.RightToLeft,
}),
location,
});
viewportActionCornersService.addComponent({
viewportId,
id: 'segmentation',
component: getViewportDataOverlaySettingsMenu({
viewportId,
element: elementRef.current,
displaySets,
servicesManager,
commandsManager,
location,
}),
location,
});
}, [
displaySets,
viewportId,
viewportActionCornersService,
servicesManager,
commandsManager,
appConfig,
]);
const { ref: resizeRef } = useResizeDetector({
onResize,
});
return (
<React.Fragment>
<div className="viewport-wrapper">
<div
className="cornerstone-viewport-element"
style={{ height: '100%', width: '100%' }}
onContextMenu={e => e.preventDefault()}
onMouseDown={e => e.preventDefault()}
ref={el => {
resizeRef.current = el;
elementRef.current = el;
}}
></div>
<CornerstoneOverlays
viewportId={viewportId}
toolBarService={toolbarService}
element={elementRef.current}
scrollbarHeight={scrollbarHeight}
servicesManager={servicesManager}
/>
<CinePlayer
enabledVPElement={enabledVPElement}
viewportId={viewportId}
servicesManager={servicesManager}
/>
<ActiveViewportBehavior
viewportId={viewportId}
servicesManager={servicesManager}
/>
</div>
{/* top offset of 24px to account for ViewportActionCorners. */}
<div className="absolute top-[24px] w-full">
{viewportDialogState.viewportId === viewportId && (
<Notification
id="viewport-notification"
message={viewportDialogState.message}
type={viewportDialogState.type}
actions={viewportDialogState.actions}
onSubmit={viewportDialogState.onSubmit}
onOutsideClick={viewportDialogState.onOutsideClick}
onKeyPress={viewportDialogState.onKeyPress}
/>
)}
</div>
{/* The OHIFViewportActionCorners follows the viewport in the DOM so that it is naturally at a higher z-index.*/}
<OHIFViewportActionCorners viewportId={viewportId} />
</React.Fragment>
);
},
areEqual
);
function _subscribeToJumpToMeasurementEvents(elementRef, viewportId, servicesManager) {
const { measurementService, cornerstoneViewportService } = servicesManager.services;
const { unsubscribe } = measurementService.subscribe(
MeasurementService.EVENTS.JUMP_TO_MEASUREMENT_VIEWPORT,
props => {
cacheJumpToMeasurementEvent = props;
const { viewportId: jumpId, measurement, isConsumed } = props;
if (!measurement || isConsumed) {
return;
}
if (cacheJumpToMeasurementEvent.cornerstoneViewport === undefined) {
// Decide on which viewport should handle this
cacheJumpToMeasurementEvent.cornerstoneViewport =
cornerstoneViewportService.getViewportIdToJump(jumpId, {
displaySetInstanceUID: measurement.displaySetInstanceUID,
...measurement.metadata,
referencedImageId:
measurement.referencedImageId || measurement.metadata?.referencedImageId,
});
}
if (cacheJumpToMeasurementEvent.cornerstoneViewport !== viewportId) {
return;
}
_jumpToMeasurement(measurement, elementRef, viewportId, servicesManager);
}
);
return unsubscribe;
}
// Check if there is a queued jumpToMeasurement event
function _checkForCachedJumpToMeasurementEvents(
elementRef,
viewportId,
displaySets,
servicesManager
) {
if (!cacheJumpToMeasurementEvent) {
return;
}
if (cacheJumpToMeasurementEvent.isConsumed) {
cacheJumpToMeasurementEvent = null;
return;
}
const displaysUIDs = displaySets.map(displaySet => displaySet.displaySetInstanceUID);
if (!displaysUIDs?.length) {
return;
}
// Jump to measurement if the measurement exists
const { measurement } = cacheJumpToMeasurementEvent;
if (measurement && elementRef) {
if (displaysUIDs.includes(measurement?.displaySetInstanceUID)) {
_jumpToMeasurement(measurement, elementRef, viewportId, servicesManager);
}
}
}
function _jumpToMeasurement(measurement, targetElementRef, viewportId, servicesManager) {
const { viewportGridService } = servicesManager.services;
const targetElement = targetElementRef.current;
// Todo: setCornerstoneMeasurementActive should be handled by the toolGroupManager
// to set it properly
// setCornerstoneMeasurementActive(measurement);
viewportGridService.setActiveViewportId(viewportId);
const enabledElement = getEnabledElement(targetElement);
if (enabledElement) {
// See how the jumpToSlice() of Cornerstone3D deals with imageIdx param.
const viewport = enabledElement.viewport as csTypes.IStackViewport | csTypes.IVolumeViewport;
const { metadata } = measurement;
if (!viewport.isReferenceViewable(metadata, { withNavigation: true, withOrientation: true })) {
console.log("Reference isn't viewable, postponing until updated");
return;
}
viewport.setViewReference(metadata);
cs3DTools.annotation.selection.setAnnotationSelected(measurement.uid);
// Jump to measurement consumed, remove.
cacheJumpToMeasurementEvent?.consume?.();
cacheJumpToMeasurementEvent = null;
}
}
function _rehydrateSynchronizers(viewportId: string, syncGroupService: any) {
const { synchronizersStore } = useSynchronizersStore.getState();
const synchronizers = synchronizersStore[viewportId];
if (!synchronizers) {
return;
}
synchronizers.forEach(synchronizerObj => {
if (!synchronizerObj.id) {
return;
}
const { id, sourceViewports, targetViewports } = synchronizerObj;
const synchronizer = syncGroupService.getSynchronizer(id);
if (!synchronizer) {
return;
}
const sourceViewportInfo = sourceViewports.find(
sourceViewport => sourceViewport.viewportId === viewportId
);
const targetViewportInfo = targetViewports.find(
targetViewport => targetViewport.viewportId === viewportId
);
const isSourceViewportInSynchronizer = synchronizer
.getSourceViewports()
.find(sourceViewport => sourceViewport.viewportId === viewportId);
const isTargetViewportInSynchronizer = synchronizer
.getTargetViewports()
.find(targetViewport => targetViewport.viewportId === viewportId);
// if the viewport was previously a source viewport, add it again
if (sourceViewportInfo && !isSourceViewportInSynchronizer) {
synchronizer.addSource({
viewportId: sourceViewportInfo.viewportId,
renderingEngineId: sourceViewportInfo.renderingEngineId,
});
}
// if the viewport was previously a target viewport, add it again
if (targetViewportInfo && !isTargetViewportInSynchronizer) {
synchronizer.addTarget({
viewportId: targetViewportInfo.viewportId,
renderingEngineId: targetViewportInfo.renderingEngineId,
});
}
});
}
// Component displayName
OHIFCornerstoneViewport.displayName = 'OHIFCornerstoneViewport';
function areEqual(prevProps, nextProps) {
if (nextProps.needsRerendering) {
console.debug('OHIFCornerstoneViewport: Rerender caused by: needsRerendering');
return false;
}
if (prevProps.displaySets.length !== nextProps.displaySets.length) {
console.debug('OHIFCornerstoneViewport: Rerender caused by: displaySets length change');
return false;
}
if (prevProps.viewportOptions.orientation !== nextProps.viewportOptions.orientation) {
console.debug('OHIFCornerstoneViewport: Rerender caused by: orientation change');
return false;
}
if (prevProps.viewportOptions.toolGroupId !== nextProps.viewportOptions.toolGroupId) {
console.debug('OHIFCornerstoneViewport: Rerender caused by: toolGroupId change');
return false;
}
if (
nextProps.viewportOptions.viewportType &&
prevProps.viewportOptions.viewportType !== nextProps.viewportOptions.viewportType
) {
console.debug('OHIFCornerstoneViewport: Rerender caused by: viewportType change');
return false;
}
if (nextProps.viewportOptions.needsRerendering) {
console.debug('OHIFCornerstoneViewport: Rerender caused by: viewportOptions.needsRerendering');
return false;
}
const prevDisplaySets = prevProps.displaySets;
const nextDisplaySets = nextProps.displaySets;
if (prevDisplaySets.length !== nextDisplaySets.length) {
console.debug('OHIFCornerstoneViewport: Rerender caused by: displaySets length mismatch');
return false;
}
for (let i = 0; i < prevDisplaySets.length; i++) {
const prevDisplaySet = prevDisplaySets[i];
const foundDisplaySet = nextDisplaySets.find(
nextDisplaySet =>
nextDisplaySet.displaySetInstanceUID === prevDisplaySet.displaySetInstanceUID
);
if (!foundDisplaySet) {
console.debug('OHIFCornerstoneViewport: Rerender caused by: displaySet not found');
return false;
}
// check they contain the same image
if (foundDisplaySet.images?.length !== prevDisplaySet.images?.length) {
console.debug('OHIFCornerstoneViewport: Rerender caused by: images length mismatch');
return false;
}
// check if their imageIds are the same
if (foundDisplaySet.images?.length) {
for (let j = 0; j < foundDisplaySet.images.length; j++) {
if (foundDisplaySet.images[j].imageId !== prevDisplaySet.images[j].imageId) {
console.debug('OHIFCornerstoneViewport: Rerender caused by: imageId mismatch');
return false;
}
}
}
}
return true;
}
// Helper function to check if display sets have changed
function haveDisplaySetsChanged(prevDisplaySets, currentDisplaySets) {
if (prevDisplaySets.length !== currentDisplaySets.length) {
return true;
}
return currentDisplaySets.some((currentDS, index) => {
const prevDS = prevDisplaySets[index];
return currentDS.displaySetInstanceUID !== prevDS.displaySetInstanceUID;
});
}
export default OHIFCornerstoneViewport;

View File

@@ -0,0 +1,82 @@
import React, { useEffect, useState } from 'react';
import ViewportImageScrollbar from './ViewportImageScrollbar';
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 [imageSliceData, setImageSliceData] = useState({
imageIndex: 0,
numberOfSlices: 0,
});
const [viewportData, setViewportData] = useState(null);
useEffect(() => {
const { unsubscribe } = cornerstoneViewportService.subscribe(
cornerstoneViewportService.EVENTS.VIEWPORT_DATA_CHANGED,
props => {
if (props.viewportId !== viewportId) {
return;
}
setViewportData(props.viewportData);
}
);
return () => {
unsubscribe();
};
}, [viewportId]);
if (!element) {
return null;
}
if (viewportData) {
const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId);
if (viewportInfo?.viewportOptions?.customViewportProps?.hideOverlays) {
return null;
}
}
return (
<div className="noselect">
<ViewportImageScrollbar
viewportId={viewportId}
viewportData={viewportData}
element={element}
imageSliceData={imageSliceData}
setImageSliceData={setImageSliceData}
scrollbarHeight={scrollbarHeight}
servicesManager={servicesManager}
/>
<CustomizableViewportOverlay
imageSliceData={imageSliceData}
viewportData={viewportData}
viewportId={viewportId}
servicesManager={servicesManager}
element={element}
/>
<ViewportImageSliceLoadingIndicator
viewportData={viewportData}
element={element}
/>
<ViewportOrientationMarkers
imageSliceData={imageSliceData}
element={element}
viewportData={viewportData}
servicesManager={servicesManager}
viewportId={viewportId}
/>
</div>
);
}
export default CornerstoneOverlays;

View File

@@ -0,0 +1,34 @@
/*
custom overlay panels: top-left, top-right, bottom-left and bottom-right
If any text to be displayed on the overlay is too long to hold on a single
line, it will be truncated with ellipsis in the end.
*/
.viewport-overlay {
max-width: 40%;
}
.viewport-overlay span {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.viewport-overlay.left-viewport {
text-align: left;
}
.viewport-overlay.right-viewport-scrollbar {
text-align: right;
}
.viewport-overlay.right-viewport-scrollbar .flex.flex-row {
justify-content: flex-end;
}
.expertise-overlay {
max-width: 300px; /* Atur sesuai kebutuhan */
word-wrap: break-word;
white-space: pre-line; /* Untuk mempertahankan format newline dari API */
background: rgba(0, 0, 0, 0.7); /* Background semi-transparan */
font-size: 12px;
line-height: 1.2;
}

View File

@@ -0,0 +1,588 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { vec3 } from 'gl-matrix';
import PropTypes from 'prop-types';
import { metaData, Enums, utilities } from '@cornerstonejs/core';
import type { ImageSliceData } from '@cornerstonejs/core/types';
import { ViewportOverlay } from '@ohif/ui';
import type { InstanceMetadata } from '@ohif/core/src/types';
import { formatPN, formatDICOMDate, formatDICOMTime, formatNumberPrecision } from './utils';
import { StackViewportData, VolumeViewportData } from '../../types/CornerstoneCacheService';
import './CustomizableViewportOverlay.css';
import { studyDataForOverlayItem } from './studyDataForOverlayItem';
const EPSILON = 1e-4;
type ViewportData = StackViewportData | VolumeViewportData;
interface OverlayItemProps {
element: HTMLElement;
viewportData: ViewportData;
imageSliceData: ImageSliceData;
servicesManager: AppTypes.ServicesManager;
viewportId: string;
instance: InstanceMetadata;
customization: any;
formatters: {
formatPN: (val) => string;
formatDate: (val) => string;
formatTime: (val) => string;
formatNumberPrecision: (val, number) => string;
};
// calculated values
voi: {
windowWidth: number;
windowCenter: number;
};
instanceNumber?: number;
scale?: number;
displaySetProps?: {
referenceInstance?: {
StudyInstanceUID?: string;
};
};
}
const OverlayItemComponents = {
'ohif.overlayItem': OverlayItem,
'ohif.overlayItem.windowLevel': VOIOverlayItem,
'ohif.overlayItem.zoomLevel': ZoomOverlayItem,
'ohif.overlayItem.instanceNumber': InstanceNumberOverlayItem,
};
const studyDateItem = {
id: 'StudyDate',
customizationType: 'ohif.overlayItem',
label: 'Study Dttm: ',
title: 'Study date and time',
condition: ({ referenceInstance, studyApiData }) =>
referenceInstance?.StudyDate || studyApiData?.studyTime,
contentF: ({ referenceInstance, formatters: { formatDate }, studyApiData }) => {
const date = referenceInstance?.StudyDate ? formatDate(referenceInstance.StudyDate) : '';
const time = studyApiData?.studyTime ? studyApiData.studyTime : '';
return `${date} ${time}`;
},
};
const seriesDescriptionItem = {
id: 'SeriesDescription',
customizationType: 'ohif.overlayItem',
label: 'Series Desc: ',
title: 'Series description',
condition: ({ referenceInstance }) => {
return referenceInstance && referenceInstance.SeriesDescription;
},
contentF: ({ referenceInstance }) => referenceInstance.SeriesDescription,
};
const patientNameItem = {
id: 'PatientName',
customizationType: 'ohif.overlayItem',
label: '',
title: 'Patient Name',
condition: ({ instance }) => instance?.PatientName,
contentF: ({ instance, formatters: { formatPN } }) => formatPN(instance.PatientName.Alphabetic),
};
const mrnItem = {
id: 'MRN',
customizationType: 'ohif.overlayItem',
label: '',
title: 'Medical Record Number',
condition: ({ referenceInstance }) => referenceInstance?.PatientID,
contentF: ({ referenceInstance }) => referenceInstance.PatientID,
};
const sexAndAgeItem = {
id: 'SexAndAge',
customizationType: 'ohif.overlayItem',
label: '',
title: 'Patient Sex and Age',
condition: ({ studyApiData }) => studyApiData?.patientSex || studyApiData?.formattedAge,
contentF: ({ studyApiData }) => {
const sex = studyApiData?.patientSex || '';
const age = studyApiData?.formattedAge || '';
return sex && age ? `${sex}, ${age}` : sex || age;
},
};
const studyDescriptionItem = {
id: 'StudyDescription',
customizationType: 'ohif.overlayItem',
label: 'Study Desc: ',
title: 'Study Description',
condition: ({ studyApiData }) => studyApiData?.studyDescription,
contentF: ({ studyApiData }) => studyApiData.studyDescription,
};
const accessionNumberItem = {
id: 'AccessionNumber',
customizationType: 'ohif.overlayItem',
label: '',
title: 'Accession Number',
condition: ({ studyApiData }) => studyApiData?.accessionNumber,
contentF: ({ studyApiData }) => studyApiData.accessionNumber,
};
const topLeftItems = {
id: 'cornerstoneOverlayTopLeft',
items: [patientNameItem, mrnItem, sexAndAgeItem],
};
const topRightItems = { id: 'cornerstoneOverlayTopRight', items: [] };
const bottomLeftItems = {
id: 'cornerstoneOverlayBottomLeft',
items: [accessionNumberItem, studyDescriptionItem, studyDateItem, seriesDescriptionItem],
};
const bottomRightItems = {
id: 'cornerstoneOverlayBottomRight',
items: [
{
id: 'InstanceNumber',
customizationType: 'ohif.overlayItem.instanceNumber',
},
seriesDescriptionItem,
],
};
/**
* The @ohif/cornerstoneOverlay is a default value for a customization
* for the cornerstone overlays. The intent is to allow it to be extended
* without needing to re-write the individual overlays by using the append
* mechanism. Individual attributes can be modified individually without
* affecting the other items by using the append as well, with position
* based replacement.
* This is used as a default in the getCustomizationModule so that it
* is available early for additional customization extensions.
*/
const CornerstoneOverlay = {
id: '@ohif/cornerstoneOverlay',
topLeftItems,
topRightItems,
bottomLeftItems,
bottomRightItems,
};
/**
* Customizable Viewport Overlay
*/
function CustomizableViewportOverlay({
element,
viewportData,
imageSliceData,
viewportId,
servicesManager,
}: {
element: HTMLElement;
viewportData: ViewportData;
imageSliceData: ImageSliceData;
viewportId: string;
servicesManager: AppTypes.ServicesManager;
}) {
const { cornerstoneViewportService, customizationService, toolGroupService, displaySetService } =
servicesManager.services;
const [voi, setVOI] = useState({ windowCenter: null, windowWidth: null });
const [scale, setScale] = useState(1);
const { imageIndex } = imageSliceData;
// The new customization is 'cornerstoneOverlay', with an append or replace
// on the individual items rather than defining individual items.
const cornerstoneOverlay = customizationService.getCustomization('@ohif/cornerstoneOverlay');
// Historical usage defined the overlays as separate items due to lack of
// append functionality. This code enables the historical usage, but
// the recommended functionality is to append to the default values in
// cornerstoneOverlay rather than defining individual items.
const topLeftCustomization =
customizationService.getCustomization('cornerstoneOverlayTopLeft') ||
cornerstoneOverlay?.topLeftItems;
const topRightCustomization =
customizationService.getCustomization('cornerstoneOverlayTopRight') ||
cornerstoneOverlay?.topRightItems;
const bottomLeftCustomization =
customizationService.getCustomization('cornerstoneOverlayBottomLeft') ||
cornerstoneOverlay?.bottomLeftItems;
const bottomRightCustomization =
customizationService.getCustomization('cornerstoneOverlayBottomRight') ||
cornerstoneOverlay?.bottomRightItems;
const instanceNumber = useMemo(
() =>
viewportData
? getInstanceNumber(viewportData, viewportId, imageIndex, cornerstoneViewportService)
: null,
[viewportData, viewportId, imageIndex, cornerstoneViewportService]
);
const displaySetProps = useMemo(() => {
const displaySets = getDisplaySets(viewportData, displaySetService);
if (!displaySets) {
return null;
}
const [displaySet] = displaySets;
const { instances, instance: referenceInstance } = displaySet;
return {
displaySets,
displaySet,
instance: instances?.[imageIndex],
instances,
referenceInstance,
};
}, [viewportData, viewportId, instanceNumber, cornerstoneViewportService]);
// FEAT: Edit overlays item [2025-09-04]
const studyInstanceUID = displaySetProps?.referenceInstance?.StudyInstanceUID;
const { studyData: studyApiData, loading, error } = studyDataForOverlayItem(studyInstanceUID);
/**
* Updating the VOI when the viewport changes its voi
*/
useEffect(() => {
const updateVOI = eventDetail => {
const { range } = eventDetail.detail;
if (!range) {
return;
}
const { lower, upper } = range;
const { windowWidth, windowCenter } = utilities.windowLevel.toWindowLevel(lower, upper);
setVOI({ windowCenter, windowWidth });
};
element.addEventListener(Enums.Events.VOI_MODIFIED, updateVOI);
return () => {
element.removeEventListener(Enums.Events.VOI_MODIFIED, updateVOI);
};
}, [viewportId, viewportData, voi, element]);
/**
* Updating the scale when the viewport changes its zoom
*/
useEffect(() => {
const updateScale = eventDetail => {
const { previousCamera, camera } = eventDetail.detail;
if (
previousCamera.parallelScale !== camera.parallelScale ||
previousCamera.scale !== camera.scale
) {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport) {
return;
}
const scale = viewport.getZoom();
setScale(scale);
}
};
element.addEventListener(Enums.Events.CAMERA_MODIFIED, updateScale);
return () => {
element.removeEventListener(Enums.Events.CAMERA_MODIFIED, updateScale);
};
}, [viewportId, viewportData, cornerstoneViewportService, element]);
const _renderOverlayItem = useCallback(
(item, props) => {
const overlayItemProps = {
...props,
element,
viewportData,
imageSliceData,
viewportId,
servicesManager,
customization: item,
formatters: {
formatPN,
formatDate: formatDICOMDate,
formatTime: formatDICOMTime,
formatNumberPrecision,
},
displaySetProps,
};
if (!item) {
return null;
}
const { customizationType } = item;
const OverlayItemComponent = OverlayItemComponents[customizationType];
if (OverlayItemComponent) {
return <OverlayItemComponent {...overlayItemProps} />;
} else {
const renderItem = customizationService.transform(item);
if (typeof renderItem.contentF === 'function') {
return renderItem.contentF(overlayItemProps);
}
}
},
[
element,
viewportData,
imageSliceData,
viewportId,
servicesManager,
customizationService,
displaySetProps,
voi,
scale,
instanceNumber,
]
);
const getContent = useCallback(
(customization, keyPrefix) => {
if (!customization?.items) {
return null;
}
const { items } = customization;
const props = {
...displaySetProps,
formatters: { formatDate: formatDICOMDate },
voi,
scale,
instanceNumber,
viewportId,
toolGroupService,
studyApiData, // Pass the API data to the overlay items
};
return (
<>
{items.map((item, index) => (
<div key={`${keyPrefix}_${index}`}>
{((!item?.condition || item.condition(props)) && _renderOverlayItem(item, props)) ||
null}
</div>
))}
</>
);
},
[_renderOverlayItem, displaySetProps, studyApiData]
);
return (
<ViewportOverlay
topLeft={getContent(topLeftCustomization, 'topLeftOverlayItem')}
topRight={getContent(topRightCustomization, 'topRightOverlayItem')}
bottomLeft={getContent(bottomLeftCustomization, 'bottomLeftOverlayItem')}
bottomRight={getContent(bottomRightCustomization, 'bottomRightOverlayItem')}
/>
);
}
/**
* Gets an array of display sets for the given viewport, based on the viewport data.
* Returns null if none found.
*/
function getDisplaySets(viewportData, displaySetService) {
if (!viewportData?.data?.length) {
return null;
}
const displaySets = viewportData.data
.map(datum => displaySetService.getDisplaySetByUID(datum.displaySetInstanceUID))
.filter(it => !!it);
if (!displaySets.length) {
return null;
}
return displaySets;
}
const getInstanceNumber = (viewportData, viewportId, imageIndex, cornerstoneViewportService) => {
let instanceNumber;
switch (viewportData.viewportType) {
case Enums.ViewportType.STACK:
instanceNumber = _getInstanceNumberFromStack(viewportData, imageIndex);
break;
case Enums.ViewportType.ORTHOGRAPHIC:
instanceNumber = _getInstanceNumberFromVolume(
viewportData,
viewportId,
cornerstoneViewportService,
imageIndex
);
break;
}
return instanceNumber ?? null;
};
function _getInstanceNumberFromStack(viewportData, imageIndex) {
const imageIds = viewportData.data[0].imageIds;
const imageId = imageIds[imageIndex];
if (!imageId) {
return;
}
const generalImageModule = metaData.get('generalImageModule', imageId) || {};
const { instanceNumber } = generalImageModule;
const stackSize = imageIds.length;
if (stackSize <= 1) {
return;
}
return parseInt(instanceNumber);
}
// Since volume viewports can be in any view direction, they can render
// a reconstructed image which don't have imageIds; therefore, no instance and instanceNumber
// Here we check if viewport is in the acquisition direction and if so, we get the instanceNumber
function _getInstanceNumberFromVolume(
viewportData,
viewportId,
cornerstoneViewportService,
imageIndex
) {
const volumes = viewportData.data;
if (!volumes) {
return;
}
// Todo: support fusion of acquisition plane which has instanceNumber
const { volume } = volumes[0];
if (!volume) {
return;
}
const { direction, imageIds } = volume;
const cornerstoneViewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!cornerstoneViewport) {
return;
}
const camera = cornerstoneViewport.getCamera();
const { viewPlaneNormal } = camera;
// checking if camera is looking at the acquisition plane (defined by the direction on the volume)
const scanAxisNormal = direction.slice(6, 9);
// check if viewPlaneNormal is parallel to scanAxisNormal
const cross = vec3.cross(vec3.create(), viewPlaneNormal, scanAxisNormal);
const isAcquisitionPlane = vec3.length(cross) < EPSILON;
if (isAcquisitionPlane) {
const imageId = imageIds[imageIndex];
if (!imageId) {
return {};
}
const { instanceNumber } = metaData.get('generalImageModule', imageId) || {};
return parseInt(instanceNumber);
}
}
function OverlayItem(props) {
const { instance, customization = {} } = props;
const { color, attribute, title, label, background } = customization;
const value = customization.contentF?.(props, customization) ?? instance?.[attribute];
if (value === undefined || value === null) {
return null;
}
return (
<div
className="overlay-item flex flex-row"
style={{ color, background }}
title={title}
>
{label ? <span className="mr-1 shrink-0">{label}</span> : null}
<span className="ml-1 mr-2 shrink-0">{value}</span>
</div>
);
}
/**
* Window Level / Center Overlay item
*/
function VOIOverlayItem({ voi, customization }: OverlayItemProps) {
const { windowWidth, windowCenter } = voi;
if (typeof windowCenter !== 'number' || typeof windowWidth !== 'number') {
return null;
}
return (
<div
className="overlay-item flex flex-row"
style={{ color: customization?.color }}
>
<span className="mr-1 shrink-0">W:</span>
<span className="ml-1 mr-2 shrink-0">{windowWidth.toFixed(0)}</span>
<span className="mr-1 shrink-0">L:</span>
<span className="ml-1 shrink-0">{windowCenter.toFixed(0)}</span>
</div>
);
}
/**
* Zoom Level Overlay item
*/
function ZoomOverlayItem({ scale, customization }: OverlayItemProps) {
return (
<div
className="overlay-item flex flex-row"
style={{ color: (customization && customization.color) || undefined }}
>
<span className="mr-1 shrink-0">Zoom:</span>
<span>{scale.toFixed(2)}x</span>
</div>
);
}
/**
* Instance Number Overlay Item
*/
function InstanceNumberOverlayItem({
instanceNumber,
imageSliceData,
customization,
}: OverlayItemProps) {
const { imageIndex, numberOfSlices } = imageSliceData;
return (
<div
className="overlay-item flex flex-row"
style={{ color: (customization && customization.color) || undefined }}
>
<span>
{instanceNumber !== undefined && instanceNumber !== null ? (
<>
<span className="mr-1 shrink-0">I:</span>
<span>{`${instanceNumber} (${imageIndex + 1}/${numberOfSlices})`}</span>
</>
) : (
`${imageIndex + 1}/${numberOfSlices}`
)}
</span>
</div>
);
}
CustomizableViewportOverlay.propTypes = {
viewportData: PropTypes.object,
imageIndex: PropTypes.number,
viewportId: PropTypes.string,
};
export default CustomizableViewportOverlay;
export { CustomizableViewportOverlay, CornerstoneOverlay };

View File

@@ -0,0 +1,107 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { Enums, VolumeViewport3D, utilities as csUtils } from '@cornerstonejs/core';
import { ImageScrollbar } from '@ohif/ui';
function CornerstoneImageScrollbar({
viewportData,
viewportId,
element,
imageSliceData,
setImageSliceData,
scrollbarHeight,
servicesManager,
}: withAppTypes<{
element: HTMLElement;
}>) {
const { cineService, cornerstoneViewportService } = servicesManager.services;
const onImageScrollbarChange = (imageIndex, viewportId) => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
const { isCineEnabled } = cineService.getState();
if (isCineEnabled) {
// on image scrollbar change, stop the CINE if it is playing
cineService.stopClip(element, { viewportId });
cineService.setCine({ id: viewportId, isPlaying: false });
}
csUtils.jumpToSlice(viewport.element, {
imageIndex,
debounceLoading: true,
});
};
useEffect(() => {
if (!viewportData) {
return;
}
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport || viewport instanceof VolumeViewport3D) {
return;
}
const imageIndex = viewport.getCurrentImageIdIndex();
const numberOfSlices = viewport.getNumberOfSlices();
setImageSliceData({
imageIndex: imageIndex,
numberOfSlices,
});
}, [viewportId, viewportData]);
useEffect(() => {
if (!viewportData) {
return;
}
const { viewportType } = viewportData;
const eventId =
(viewportType === Enums.ViewportType.STACK && Enums.Events.STACK_VIEWPORT_SCROLL) ||
(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 { imageIndex, newImageIdIndex = imageIndex } = event.detail;
const numberOfSlices = viewport.getNumberOfSlices();
// find the index of imageId in the imageIds
setImageSliceData({
imageIndex: newImageIdIndex,
numberOfSlices,
});
};
element.addEventListener(eventId, updateIndex);
return () => {
element.removeEventListener(eventId, updateIndex);
};
}, [viewportData, element]);
return (
<ImageScrollbar
onChange={evt => onImageScrollbarChange(evt, viewportId)}
max={imageSliceData.numberOfSlices ? imageSliceData.numberOfSlices - 1 : 0}
height={scrollbarHeight}
value={imageSliceData.imageIndex || 0}
/>
);
}
CornerstoneImageScrollbar.propTypes = {
viewportData: PropTypes.object,
viewportId: PropTypes.string.isRequired,
element: PropTypes.instanceOf(Element),
scrollbarHeight: PropTypes.string,
imageSliceData: PropTypes.object.isRequired,
setImageSliceData: PropTypes.func.isRequired,
servicesManager: PropTypes.object.isRequired,
};
export default CornerstoneImageScrollbar;

View File

@@ -0,0 +1,85 @@
import React, { useEffect, useState, useRef } from 'react';
import PropTypes from 'prop-types';
import { Enums } from '@cornerstonejs/core';
function ViewportImageSliceLoadingIndicator({ viewportData, element }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const loadIndicatorRef = useRef(null);
const imageIdToBeLoaded = useRef(null);
const setLoadingState = evt => {
clearTimeout(loadIndicatorRef.current);
loadIndicatorRef.current = setTimeout(() => {
setLoading(true);
}, 50);
};
const setFinishLoadingState = evt => {
clearTimeout(loadIndicatorRef.current);
setLoading(false);
};
const setErrorState = evt => {
clearTimeout(loadIndicatorRef.current);
if (imageIdToBeLoaded.current === evt.detail.imageId) {
setError(evt.detail.error);
imageIdToBeLoaded.current = null;
}
};
useEffect(() => {
element.addEventListener(Enums.Events.STACK_VIEWPORT_SCROLL, setLoadingState);
element.addEventListener(Enums.Events.IMAGE_LOAD_ERROR, setErrorState);
element.addEventListener(Enums.Events.STACK_NEW_IMAGE, setFinishLoadingState);
return () => {
element.removeEventListener(Enums.Events.STACK_VIEWPORT_SCROLL, setLoadingState);
element.removeEventListener(Enums.Events.STACK_NEW_IMAGE, setFinishLoadingState);
element.removeEventListener(Enums.Events.IMAGE_LOAD_ERROR, setErrorState);
};
}, [element, viewportData]);
if (error) {
return (
<>
<div className="absolute top-0 left-0 h-full w-full bg-black opacity-50">
<div className="transparent flex h-full w-full items-center justify-center">
<p className="text-primary-light text-xl font-light">
<h4>Error Loading Image</h4>
<p>An error has occurred.</p>
<p>{error}</p>
</p>
</div>
</div>
</>
);
}
if (loading) {
return (
// IMPORTANT: we need to use the pointer-events-none class to prevent the loading indicator from
// interacting with the mouse, since scrolling should propagate to the viewport underneath
<div className="pointer-events-none absolute top-0 left-0 h-full w-full bg-black opacity-50">
<div className="transparent flex h-full w-full items-center justify-center">
<p className="text-primary-light text-xl font-light">Loading...</p>
</div>
</div>
);
}
return null;
}
ViewportImageSliceLoadingIndicator.propTypes = {
error: PropTypes.object,
element: PropTypes.object,
};
export default ViewportImageSliceLoadingIndicator;

View File

@@ -0,0 +1,38 @@
.ViewportOrientationMarkers {
--marker-width: 100px;
--marker-height: 100px;
--scrollbar-width: 20px;
pointer-events: none;
font-size: 15px;
line-height: 18px;
}
.ViewportOrientationMarkers .orientation-marker {
position: absolute;
}
.ViewportOrientationMarkers .top-mid {
top: 0.6rem;
left: 50%;
}
.ViewportOrientationMarkers .left-mid {
top: 47%;
left: 5px;
}
.ViewportOrientationMarkers .right-mid {
top: 47%;
left: calc(100% - var(--marker-width) - var(--scrollbar-width));
}
.ViewportOrientationMarkers .bottom-mid {
top: calc(100% - var(--marker-height) - 0.6rem);
left: 47%;
}
.ViewportOrientationMarkers .right-mid .orientation-marker-value {
display: flex;
justify-content: flex-end;
min-width: var(--marker-width);
}
.ViewportOrientationMarkers .bottom-mid .orientation-marker-value {
display: flex;
justify-content: flex-start;
min-height: var(--marker-height);
flex-direction: column-reverse;
}

View File

@@ -0,0 +1,207 @@
import React, { useEffect, useState, useMemo } from 'react';
import classNames from 'classnames';
import { metaData, Enums, Types, getEnabledElement } from '@cornerstonejs/core';
import { utilities } from '@cornerstonejs/tools';
import { vec3 } from 'gl-matrix';
import './ViewportOrientationMarkers.css';
const { getOrientationStringLPS, invertOrientationStringLPS } = utilities.orientation;
function ViewportOrientationMarkers({
element,
viewportData,
imageSliceData,
viewportId,
servicesManager,
orientationMarkers = ['top', 'left'],
}: withAppTypes) {
// Rotation is in degrees
const [rotation, setRotation] = useState(0);
const [flipHorizontal, setFlipHorizontal] = useState(false);
const [flipVertical, setFlipVertical] = useState(false);
const { cornerstoneViewportService } = servicesManager.services;
useEffect(() => {
const cameraModifiedListener = (evt: Types.EventTypes.CameraModifiedEvent) => {
const { previousCamera, camera } = evt.detail;
const { rotation } = camera;
if (rotation !== undefined) {
setRotation(rotation);
}
if (
camera.flipHorizontal !== undefined &&
previousCamera.flipHorizontal !== camera.flipHorizontal
) {
setFlipHorizontal(camera.flipHorizontal);
}
if (
camera.flipVertical !== undefined &&
previousCamera.flipVertical !== camera.flipVertical
) {
setFlipVertical(camera.flipVertical);
}
};
element.addEventListener(Enums.Events.CAMERA_MODIFIED, cameraModifiedListener);
return () => {
element.removeEventListener(Enums.Events.CAMERA_MODIFIED, cameraModifiedListener);
};
}, []);
const markers = useMemo(() => {
if (!viewportData) {
return '';
}
let rowCosines, columnCosines, isDefaultValueSetForRowCosine, isDefaultValueSetForColumnCosine;
if (viewportData.viewportType === 'stack') {
const imageIndex = imageSliceData.imageIndex;
const imageId = viewportData.data[0].imageIds?.[imageIndex];
// Workaround for below TODO stub
if (!imageId) {
return false;
}
({
rowCosines,
columnCosines,
isDefaultValueSetForColumnCosine,
isDefaultValueSetForColumnCosine,
} = metaData.get('imagePlaneModule', imageId) || {});
} else {
if (!element || !getEnabledElement(element)) {
return '';
}
const { viewport } = getEnabledElement(element);
const { viewUp, viewPlaneNormal } = viewport.getCamera();
const viewRight = vec3.create();
vec3.cross(viewRight, viewUp, viewPlaneNormal);
columnCosines = [-viewUp[0], -viewUp[1], -viewUp[2]];
rowCosines = viewRight;
}
if (
!rowCosines ||
!columnCosines ||
rotation === undefined ||
isDefaultValueSetForRowCosine ||
isDefaultValueSetForColumnCosine
) {
return '';
}
const markers = _getOrientationMarkers(
rowCosines,
columnCosines,
rotation,
flipVertical,
flipHorizontal
);
const ohifViewport = cornerstoneViewportService.getViewportInfo(viewportId);
if (!ohifViewport) {
console.log('ViewportOrientationMarkers::No viewport');
return null;
}
return orientationMarkers.map((m, index) => (
<div
className={classNames(
'overlay-text',
`${m}-mid orientation-marker`,
'text-aqua-pale',
'text-[13px]',
'leading-5'
)}
key={`${m}-mid orientation-marker`}
>
<div className="orientation-marker-value">{markers[m]}</div>
</div>
));
}, [
viewportData,
imageSliceData,
rotation,
flipVertical,
flipHorizontal,
orientationMarkers,
element,
]);
return <div className="ViewportOrientationMarkers select-none">{markers}</div>;
}
/**
*
* Computes the orientation labels on a Cornerstone-enabled Viewport element
* when the viewport settings change (e.g. when a horizontal flip or a rotation occurs)
*
* @param {*} rowCosines
* @param {*} columnCosines
* @param {*} rotation in degrees
* @returns
*/
function _getOrientationMarkers(rowCosines, columnCosines, rotation, flipVertical, flipHorizontal) {
const rowString = getOrientationStringLPS(rowCosines);
const columnString = getOrientationStringLPS(columnCosines);
const oppositeRowString = invertOrientationStringLPS(rowString);
const oppositeColumnString = invertOrientationStringLPS(columnString);
const markers = {
top: oppositeColumnString,
left: oppositeRowString,
right: rowString,
bottom: columnString,
};
// If any vertical or horizontal flips are applied, change the orientation strings ahead of
// the rotation applications
if (flipVertical) {
markers.top = invertOrientationStringLPS(markers.top);
markers.bottom = invertOrientationStringLPS(markers.bottom);
}
if (flipHorizontal) {
markers.left = invertOrientationStringLPS(markers.left);
markers.right = invertOrientationStringLPS(markers.right);
}
// Swap the labels accordingly if the viewport has been rotated
// This could be done in a more complex way for intermediate rotation values (e.g. 45 degrees)
if (rotation === 90 || rotation === -270) {
return {
top: markers.left,
left: invertOrientationStringLPS(markers.top),
right: invertOrientationStringLPS(markers.bottom),
bottom: markers.right, // left
};
} else if (rotation === -90 || rotation === 270) {
return {
top: invertOrientationStringLPS(markers.left),
left: markers.top,
bottom: markers.left,
right: markers.bottom,
};
} else if (rotation === 180 || rotation === -180) {
return {
top: invertOrientationStringLPS(markers.top),
left: invertOrientationStringLPS(markers.left),
bottom: invertOrientationStringLPS(markers.bottom),
right: invertOrientationStringLPS(markers.right),
};
}
return markers;
}
export default ViewportOrientationMarkers;

View File

@@ -0,0 +1,145 @@
/**
* Hook kustom untuk mengambil dan mengelola data studi berdasarkan StudyInstanceUID.
*
* Hook ini mengambil informasi terkait studi dari endpoint layanan QIDO
* dan memprosesnya untuk digunakan dalam overlay atau komponen UI lainnya.
* Ini menangani status pemuatan, kesalahan, dan ekstraksi data dari field metadata DICOM.
*
* @param studyInstanceUID - Identifier unik untuk studi yang datanya akan diambil.
* @returns Sebuah objek yang berisi:
* - `studyData`: Data studi yang telah diekstrak (atau `null` jika belum dimuat).
* - `loading`: Boolean yang menunjukkan apakah data sedang diambil.
* - `error`: Objek error jika proses pengambilan data gagal, atau `null` jika berhasil.
*
* @catatan
* Hook ini bergantung pada fungsi `getQidoRootUrl` untuk menentukan endpoint layanan QIDO.
* Pastikan fungsi ini dikonfigurasi dengan benar di aplikasi Anda.
*
* Field DICOM berikut akan diekstrak dan diproses:
* - Study Description (0008,1030)
* - Accession Number (0008,0050)
* - Study Time (0008,0030)
* - Patient Age (0010,1010)
* - Patient Sex (0010,0040)
* - Patient Birth Date (0010,0030)
*
* Jika tanggal lahir pasien tersedia, umur akan dihitung dan diformat.
*/
import { useState, useEffect } from 'react';
interface StudyData {
studyDescription?: string;
accessionNumber?: string;
studyTime?: string;
patientAge?: string;
patientSex?: string;
patientBirthDate?: string;
formattedAge?: string;
}
const calculateAge = (birthDateString: string): string => {
if (!birthDateString) return '';
// DICOM date format is YYYYMMDD
const year = parseInt(birthDateString.substring(0, 4));
const month = parseInt(birthDateString.substring(4, 6)) - 1; // JS months are 0-indexed
const day = parseInt(birthDateString.substring(6, 8));
const birthDate = new Date(year, month, day);
const today = new Date();
let ageYears = today.getFullYear() - birthDate.getFullYear();
let ageMonths = today.getMonth() - birthDate.getMonth();
let ageDays = today.getDate() - birthDate.getDate();
// Adjust for negative months or days
if (ageDays < 0) {
ageMonths--;
// Get days in the previous month
const prevMonthDate = new Date(today.getFullYear(), today.getMonth(), 0);
ageDays += prevMonthDate.getDate();
}
if (ageMonths < 0) {
ageYears--;
ageMonths += 12;
}
return `${ageYears}Y ${ageMonths}M ${ageDays}D`;
};
const getQidoRootUrl = (): string => {
const { config } = window as any;
if (!config || !config.dataSources || !config.defaultDataSourceName) {
console.warn('Configuration not found, using fallback URL');
return;
}
const dataSource = config.dataSources.find(
(ds: any) => ds.sourceName === config.defaultDataSourceName
);
if (!dataSource || !dataSource.configuration || !dataSource.configuration.qidoRoot) {
console.warn('Data source configuration not found, return nothing');
return;
}
return dataSource.configuration.qidoRoot;
};
export const studyDataForOverlayItem = (studyInstanceUID: string) => {
const [studyData, setStudyData] = useState<StudyData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!studyInstanceUID) return;
const fetchStudyData = async () => {
setLoading(true);
try {
const qidoRootUrl = getQidoRootUrl();
const response = await fetch(
`${qidoRootUrl}/studies?limit=101&offset=0&fuzzymatching=false&includefield=00080050,00081030,00101010,0010004&StudyInstanceUID=${studyInstanceUID}`
);
if (!response.ok) {
throw new Error('Failed to fetch study data');
}
const data = await response.json();
if (data && data.length > 0) {
const studyInfo = data[0];
const birthDate = studyInfo['00100030']?.Value?.[0] || '';
const formattedAge = calculateAge(birthDate);
const extractedData: StudyData = {
studyDescription: studyInfo['00081030']?.Value?.[0] || '',
accessionNumber: studyInfo['00080050']?.Value?.[0] || '',
studyTime: studyInfo['00080030']?.Value?.[0] || '',
patientAge: studyInfo['00101010']?.Value?.[0] || '',
patientSex: studyInfo['00100040']?.Value?.[0] || '',
patientBirthDate: birthDate,
formattedAge: formattedAge,
};
console.log('Extracted Study Data:', extractedData);
setStudyData(extractedData);
}
} catch (err) {
console.error('Error fetching study data:', err);
setError(err);
} finally {
setLoading(false);
}
};
fetchStudyData();
}, [studyInstanceUID]);
return { studyData, loading, error };
};

View File

@@ -0,0 +1,98 @@
import moment from 'moment';
import { metaData } from '@cornerstonejs/core';
/**
* Checks if value is valid.
*
* @param {number} value
* @returns {boolean} is valid.
*/
export function isValidNumber(value) {
return typeof value === 'number' && !isNaN(value);
}
/**
* Formats number precision.
*
* @param {number} number
* @param {number} precision
* @returns {number} formatted number.
*/
export function formatNumberPrecision(number, precision = 0) {
if (number !== null) {
return parseFloat(number).toFixed(precision);
}
}
/**
* Formats DICOM date.
*
* @param {string} date
* @param {string} strFormat
* @returns {string} formatted date.
*/
export function formatDICOMDate(date, strFormat = 'MMM D, YYYY') {
return moment(date, 'YYYYMMDD').format(strFormat);
}
/**
* DICOM Time is stored as HHmmss.SSS, where:
* HH 24 hour time:
* m mm 0..59 Minutes
* s ss 0..59 Seconds
* S SS SSS 0..999 Fractional seconds
*
* Goal: '24:12:12'
*
* @param {*} time
* @param {string} strFormat
* @returns {string} formatted name.
*/
export function formatDICOMTime(time, strFormat = 'HH:mm:ss') {
return moment(time, 'HH:mm:ss').format(strFormat);
}
/**
* Formats a patient name for display purposes
*
* @param {string} name
* @returns {string} formatted name.
*/
export function formatPN(name) {
if (!name) {
return '';
}
if (typeof name === 'object') {
name = name.Alphabetic;
if (!name) {
return '';
}
}
const cleaned = name
.split('^')
.filter(s => !!s)
.join(', ')
.trim();
return cleaned === ',' || cleaned === '' ? '' : cleaned;
}
/**
* Gets compression type
*
* @param {number} imageId
* @returns {string} compression type.
*/
export function getCompression(imageId) {
const generalImageModule = metaData.get('generalImageModule', imageId) || {};
const { lossyImageCompression, lossyImageCompressionRatio, lossyImageCompressionMethod } =
generalImageModule;
if (lossyImageCompression === '01' && lossyImageCompressionRatio !== '') {
const compressionMethod = lossyImageCompressionMethod || 'Lossy: ';
const compressionRatio = formatNumberPrecision(lossyImageCompressionRatio, 2);
return compressionMethod + compressionRatio + ' : 1';
}
return 'Lossless / Uncompressed';
}