fix: jump to measurement forever (#4867)
This commit is contained in:
1 parent
eafa2efda5
commit
d7bd36544a
39 files changed
+731
-448
No files matched your search
@@ -54,7 +54,7 @@
|
||||
"@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2",
|
||||
"@cornerstonejs/codec-openjpeg": "^1.2.4",
|
||||
"@cornerstonejs/codec-openjph": "^2.4.5",
|
||||
"@cornerstonejs/dicom-image-loader": "^3.7.16",
|
||||
"@cornerstonejs/dicom-image-loader": "^3.7.17",
|
||||
"@emotion/serialize": "^1.1.3",
|
||||
"@ohif/core": "3.10.0-beta.140",
|
||||
"@ohif/extension-cornerstone": "3.10.0-beta.140",
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import React, { useEffect, useCallback, useRef } from 'react';
|
||||
import React, { useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { useResizeDetector } from 'react-resize-detector';
|
||||
import { Types, MeasurementService } from '@ohif/core';
|
||||
import { ViewportGrid, ViewportPane } from '@ohif/ui-next';
|
||||
import { useViewportGrid } from '@ohif/ui-next';
|
||||
import EmptyViewport from './EmptyViewport';
|
||||
import classNames from 'classnames';
|
||||
import { useAppConfig } from '@state';
|
||||
|
||||
function ViewerViewportGrid(props: withAppTypes) {
|
||||
const { servicesManager, viewportComponents = [], dataSource } = props;
|
||||
const { servicesManager, viewportComponents = [], dataSource, commandsManager } = props;
|
||||
const [viewportGrid, viewportGridService] = useViewportGrid();
|
||||
const [appConfig] = useAppConfig();
|
||||
|
||||
@@ -167,17 +166,51 @@ function ViewerViewportGrid(props: withAppTypes) {
|
||||
useEffect(() => {
|
||||
const { unsubscribe } = measurementService.subscribe(
|
||||
MeasurementService.EVENTS.JUMP_TO_MEASUREMENT_LAYOUT,
|
||||
({ viewportId, measurement, isConsumed }) => {
|
||||
event => {
|
||||
const { viewportId, measurement, isConsumed } = event;
|
||||
if (isConsumed) {
|
||||
return;
|
||||
}
|
||||
// This occurs when no viewport has elected to consume the event
|
||||
// so we need to change layouts into a layout which can consume
|
||||
// the event.
|
||||
const { displaySetInstanceUID: referencedDisplaySetInstanceUID } = measurement;
|
||||
|
||||
const { displaySetInstanceUID: referencedDisplaySetInstanceUID } = measurement;
|
||||
const { viewports } = viewportGridService.getState();
|
||||
|
||||
// Check if any viewport can display this measurement
|
||||
let canAnyViewportDisplayMeasurement = false;
|
||||
|
||||
viewports.forEach((viewport, id) => {
|
||||
const displaySetInstanceUIDs = viewport.displaySetInstanceUIDs || [];
|
||||
const viewportHasDisplaySet = displaySetInstanceUIDs.includes(
|
||||
referencedDisplaySetInstanceUID
|
||||
);
|
||||
|
||||
// Extract metadata and prepare reference
|
||||
const { FrameOfReferenceUID, ...metadataRest } = measurement.metadata;
|
||||
const reference = {
|
||||
...(viewportHasDisplaySet ? measurement.metadata : metadataRest),
|
||||
displaySetInstanceUID: referencedDisplaySetInstanceUID,
|
||||
};
|
||||
|
||||
// Check if viewport can display the reference
|
||||
if (
|
||||
viewport.isReferenceViewable?.({
|
||||
viewportId: id,
|
||||
reference,
|
||||
})
|
||||
) {
|
||||
canAnyViewportDisplayMeasurement = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (canAnyViewportDisplayMeasurement) {
|
||||
// Let the viewports handle the jump
|
||||
return;
|
||||
}
|
||||
|
||||
// Need to change layouts since no viewport consumed the event
|
||||
const updatedViewports = _getUpdatedViewports(viewportId, referencedDisplaySetInstanceUID);
|
||||
if (!updatedViewports[0]) {
|
||||
|
||||
if (!updatedViewports?.[0]) {
|
||||
console.warn(
|
||||
'ViewportGrid::Unable to navigate to viewport containing',
|
||||
referencedDisplaySetInstanceUID
|
||||
@@ -185,24 +218,35 @@ function ViewerViewportGrid(props: withAppTypes) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Arbitrarily assign the viewport to element 0
|
||||
// TODO - this should perform a search to find the most suitable viewport.
|
||||
updatedViewports[0] = { ...updatedViewports[0] };
|
||||
const [viewport] = updatedViewports;
|
||||
// Find the viewport that can display the measurement
|
||||
const viewport = updatedViewports.find(viewport => {
|
||||
const gridViewport = viewportGridService.getViewportState(viewport.viewportId);
|
||||
return gridViewport.isReferenceViewable?.({
|
||||
viewportId: viewport.viewportId,
|
||||
reference: {
|
||||
...measurement.metadata,
|
||||
displaySetInstanceUID: referencedDisplaySetInstanceUID,
|
||||
},
|
||||
viewportOptions: gridViewport.viewportOptions || {},
|
||||
});
|
||||
});
|
||||
|
||||
// Copy the viewport options to prevent modifying the internal data
|
||||
viewport.viewportOptions = {
|
||||
...viewport.viewportOptions,
|
||||
orientation: 'acquisition',
|
||||
// The preferred way to jump to the measurement view is to set the
|
||||
// view reference, as this can hold information such as the orientation
|
||||
// or zoom level required to display an annotation. The metadata attribute
|
||||
// of the measurement is a viewReference, so use it to show the measurement.
|
||||
// Longer term this should clear the view reference data
|
||||
viewReference: measurement.metadata,
|
||||
viewportType: measurement.metadata.volumeId ? 'volume' : null,
|
||||
};
|
||||
if (!viewport) {
|
||||
console.warn('No suitable viewport found for displaying measurement');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update stored position presentation
|
||||
commandsManager.run('updateStoredPositionPresentation', {
|
||||
viewportId: viewport.viewportId,
|
||||
displaySetInstanceUID: referencedDisplaySetInstanceUID,
|
||||
referencedImageId: measurement.referencedImageId,
|
||||
options: {
|
||||
...measurement.metadata,
|
||||
},
|
||||
});
|
||||
|
||||
event.consume();
|
||||
viewportGridService.setDisplaySetsForViewports(updatedViewports);
|
||||
}
|
||||
);
|
||||
@@ -210,7 +254,7 @@ function ViewerViewportGrid(props: withAppTypes) {
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [viewports]);
|
||||
}, [viewports, _getUpdatedViewports]);
|
||||
|
||||
const onDropHandler = (viewportId, { displaySetInstanceUID }) => {
|
||||
const customOnDropHandler = customizationService.getCustomization('customOnDropHandler');
|
||||
@@ -229,6 +273,9 @@ function ViewerViewportGrid(props: withAppTypes) {
|
||||
});
|
||||
};
|
||||
|
||||
// Store previous isReferenceViewable values to avoid infinite loops
|
||||
const prevReferenceViewableMap = useRef(new Map());
|
||||
|
||||
const getViewportPanes = useCallback(() => {
|
||||
const viewportPanes = [];
|
||||
|
||||
@@ -260,12 +307,27 @@ function ViewerViewportGrid(props: withAppTypes) {
|
||||
return !displaySet?.unsupported;
|
||||
});
|
||||
|
||||
const ViewportComponent = _getViewportComponent(
|
||||
const { component: ViewportComponent, isReferenceViewable } = _getViewportComponent(
|
||||
displaySets,
|
||||
viewportComponents,
|
||||
uiNotificationService
|
||||
);
|
||||
|
||||
// Only update isReferenceViewable if it's changed to avoid render loops
|
||||
// We need to handle both function and non-function values
|
||||
if (viewportId) {
|
||||
const prevValue = prevReferenceViewableMap.current.get(viewportId);
|
||||
const isFunction = typeof isReferenceViewable === 'function';
|
||||
const isSameFunction = isFunction && typeof prevValue === 'function';
|
||||
|
||||
// For non-functions, compare directly. For functions, we treat them as always different
|
||||
// (this is conservative but safe)
|
||||
if (!isSameFunction && prevValue !== isReferenceViewable) {
|
||||
viewportGridService.setIsReferenceViewable(viewportId, isReferenceViewable);
|
||||
prevReferenceViewableMap.current.set(viewportId, isReferenceViewable);
|
||||
}
|
||||
}
|
||||
|
||||
// look inside displaySets to see if they need reRendering
|
||||
const displaySetsNeedsRerendering = displaySets.some(displaySet => {
|
||||
return displaySet.needsRerendering;
|
||||
@@ -381,7 +443,7 @@ function ViewerViewportGrid(props: withAppTypes) {
|
||||
|
||||
function _getViewportComponent(displaySets, viewportComponents, uiNotificationService) {
|
||||
if (!displaySets || !displaySets.length) {
|
||||
return EmptyViewport;
|
||||
return { component: EmptyViewport, isReferenceViewable: () => false };
|
||||
}
|
||||
|
||||
// Todo: Do we have a viewport that has two different SOPClassHandlerIds?
|
||||
@@ -395,8 +457,8 @@ function _getViewportComponent(displaySets, viewportComponents, uiNotificationSe
|
||||
throw new Error('displaySetsToDisplay is null');
|
||||
}
|
||||
if (viewportComponents[i].displaySetsToDisplay.includes(SOPClassHandlerId)) {
|
||||
const { component } = viewportComponents[i];
|
||||
return component;
|
||||
const { component, isReferenceViewable } = viewportComponents[i];
|
||||
return { component, isReferenceViewable };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,7 +469,7 @@ function _getViewportComponent(displaySets, viewportComponents, uiNotificationSe
|
||||
type: 'error',
|
||||
});
|
||||
|
||||
return EmptyViewport;
|
||||
return { component: EmptyViewport, isReferenceViewable: () => false };
|
||||
}
|
||||
|
||||
export default ViewerViewportGrid;
|
||||
@@ -37,7 +37,7 @@
|
||||
"@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2",
|
||||
"@cornerstonejs/codec-openjpeg": "^1.2.4",
|
||||
"@cornerstonejs/codec-openjph": "^2.4.5",
|
||||
"@cornerstonejs/dicom-image-loader": "^3.7.16",
|
||||
"@cornerstonejs/dicom-image-loader": "^3.7.17",
|
||||
"@ohif/ui": "3.10.0-beta.140",
|
||||
"cornerstone-math": "0.1.9",
|
||||
"dicom-parser": "^1.8.21"
|
||||
|
||||
@@ -670,8 +670,11 @@ class MeasurementService extends PubSubService {
|
||||
measurement,
|
||||
});
|
||||
|
||||
this._broadcastEvent(EVENTS.JUMP_TO_MEASUREMENT_VIEWPORT, consumableEvent);
|
||||
// Important: we should broadcast the layout event first, since
|
||||
// in the layout there might be a viewport that we can match and choose
|
||||
// and jump in it before we decide on changing the orientation of different viewports
|
||||
this._broadcastEvent(EVENTS.JUMP_TO_MEASUREMENT_LAYOUT, consumableEvent);
|
||||
this._broadcastEvent(EVENTS.JUMP_TO_MEASUREMENT_VIEWPORT, consumableEvent);
|
||||
}
|
||||
|
||||
_getSourceUID(name, version) {
|
||||
|
||||
@@ -38,6 +38,10 @@ class ViewportGridService extends PubSubService {
|
||||
this.presentationIdProviders.set(id, provider);
|
||||
}
|
||||
|
||||
public setIsReferenceViewable(viewportId: string, isReferenceViewable: boolean): void {
|
||||
this.serviceImplementation._setIsReferenceViewable(viewportId, isReferenceViewable);
|
||||
}
|
||||
|
||||
public getPresentationId(id: string, viewportId: string): string | null {
|
||||
const state = this.getState();
|
||||
const viewport = state.viewports.get(viewportId);
|
||||
@@ -93,7 +97,12 @@ class ViewportGridService extends PubSubService {
|
||||
set: setImplementation,
|
||||
getNumViewportPanes: getNumViewportPanesImplementation,
|
||||
setViewportIsReady: setViewportIsReadyImplementation,
|
||||
setIsReferenceViewable: setIsReferenceViewableImplementation,
|
||||
getViewportState: getViewportStateImplementation,
|
||||
}): void {
|
||||
if (getViewportStateImplementation) {
|
||||
this.serviceImplementation._getViewportState = getViewportStateImplementation;
|
||||
}
|
||||
if (getStateImplementation) {
|
||||
this.serviceImplementation._getState = getStateImplementation;
|
||||
}
|
||||
@@ -123,6 +132,9 @@ class ViewportGridService extends PubSubService {
|
||||
if (setViewportIsReadyImplementation) {
|
||||
this.serviceImplementation._setViewportIsReady = setViewportIsReadyImplementation;
|
||||
}
|
||||
if (setIsReferenceViewableImplementation) {
|
||||
this.serviceImplementation._setIsReferenceViewable = setIsReferenceViewableImplementation;
|
||||
}
|
||||
}
|
||||
|
||||
public publishViewportsReady() {
|
||||
@@ -148,8 +160,7 @@ class ViewportGridService extends PubSubService {
|
||||
}
|
||||
|
||||
public getViewportState(viewportId: string) {
|
||||
const state = this.getState();
|
||||
return state.viewports.get(viewportId);
|
||||
return this.serviceImplementation._getViewportState(viewportId);
|
||||
}
|
||||
|
||||
public setViewportIsReady(viewportId, callback) {
|
||||
|
||||
@@ -45,6 +45,10 @@ export interface ViewportGridState {
|
||||
activeViewportId: string | null;
|
||||
layout: Layout;
|
||||
isHangingProtocolLayout: boolean;
|
||||
isReferenceViewable: (props: {
|
||||
viewportId: string;
|
||||
reference: Record<string, unknown>;
|
||||
}) => boolean;
|
||||
viewports: GridViewports;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { vec3 } from 'gl-matrix';
|
||||
|
||||
/**
|
||||
* Get the plane (orientation) to which the ImageOrientationPatient is most closely aligned
|
||||
*
|
||||
* @param displaySetService
|
||||
* @param displaySetInstanceUID
|
||||
* @returns orientation
|
||||
*/
|
||||
export default function getClosestOrientationFromIOP(displaySetService, displaySetInstanceUID) {
|
||||
const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
|
||||
const imageOrientationPatient = displaySet.instances[0].ImageOrientationPatient as Array<number>;
|
||||
// ImageOrientationPatient must be an array of length 6.
|
||||
if (imageOrientationPatient?.length !== 6) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Take cross product to get vector coming "out" of image plane
|
||||
const rowCosineVec = vec3.fromValues(
|
||||
imageOrientationPatient[0],
|
||||
imageOrientationPatient[1],
|
||||
imageOrientationPatient[2]
|
||||
);
|
||||
const colCosineVec = vec3.fromValues(
|
||||
imageOrientationPatient[3],
|
||||
imageOrientationPatient[4],
|
||||
imageOrientationPatient[5]
|
||||
);
|
||||
const scanAxisNormal = vec3.cross(vec3.create(), rowCosineVec, colCosineVec);
|
||||
|
||||
// Define the reference vectors for axial, coronal, and sagittal planes
|
||||
const unitVectors = {
|
||||
axial: vec3.fromValues(0, 0, 1),
|
||||
coronal: vec3.fromValues(0, 1, 0),
|
||||
sagittal: vec3.fromValues(1, 0, 0),
|
||||
};
|
||||
|
||||
// Compute dot products for each reference plane
|
||||
// Because all vectors are normalized, dot product is bounded between -1 and 1
|
||||
let maxDot = 0;
|
||||
let maxOrientation: string = '';
|
||||
for (const [k, v] of Object.entries(unitVectors)) {
|
||||
// Absolute value of dot product because we only care about alignment with the axis
|
||||
// For example, dot product of -1 for a given axis means perfect alignment
|
||||
// but the image is pointing in the "opposite" direction
|
||||
const res = Math.abs(vec3.dot(scanAxisNormal, v));
|
||||
if (res > maxDot) {
|
||||
maxDot = res;
|
||||
maxOrientation = k;
|
||||
}
|
||||
}
|
||||
|
||||
return maxOrientation;
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import * as utils from './index';
|
||||
|
||||
describe('Top level exports', () => {
|
||||
test('should export the modules ', () => {
|
||||
const expectedExports = [
|
||||
'guid',
|
||||
'ObjectPath',
|
||||
'absoluteUrl',
|
||||
'seriesSortCriteria',
|
||||
'sortBy',
|
||||
'sortStudy',
|
||||
'sortBySeriesDate',
|
||||
'sortStudyInstances',
|
||||
'sortStudySeries',
|
||||
'sortingCriteria',
|
||||
'splitComma',
|
||||
'getSplitParam',
|
||||
'isLowPriorityModality',
|
||||
'writeScript',
|
||||
'debounce',
|
||||
'downloadCSVReport',
|
||||
'imageIdToURI',
|
||||
'roundNumber',
|
||||
'b64toBlob',
|
||||
'sopClassDictionary',
|
||||
'createStudyBrowserTabs',
|
||||
'formatDate',
|
||||
'formatTime',
|
||||
'formatPN',
|
||||
'generateAcceptHeader',
|
||||
'isEqualWithin',
|
||||
//'loadAndCacheDerivedDisplaySets',
|
||||
'isDisplaySetReconstructable',
|
||||
'isImage',
|
||||
'urlUtil',
|
||||
'makeDeferred',
|
||||
'makeCancelable',
|
||||
'hotkeys',
|
||||
'Queue',
|
||||
'isDicomUid',
|
||||
'resolveObjectPath',
|
||||
'hierarchicalListUtils',
|
||||
'progressTrackingUtils',
|
||||
'uuidv4',
|
||||
'addAccessors',
|
||||
'MeasurementFilters',
|
||||
].sort();
|
||||
|
||||
const exports = Object.keys(utils.default).sort();
|
||||
|
||||
expect(exports).toEqual(expectedExports);
|
||||
});
|
||||
});
|
||||
@@ -40,7 +40,7 @@ import { splitComma, getSplitParam } from './splitComma';
|
||||
import { createStudyBrowserTabs } from './createStudyBrowserTabs';
|
||||
import { sopClassDictionary } from './sopClassDictionary';
|
||||
import * as MeasurementFilters from './measurementFilters';
|
||||
|
||||
import getClosestOrientationFromIOP from './getClosestOrientationFromIOP';
|
||||
// Commented out unused functionality.
|
||||
// Need to implement new mechanism for derived displaySets using the displaySetManager.
|
||||
|
||||
@@ -86,6 +86,7 @@ const utils = {
|
||||
generateAcceptHeader,
|
||||
createStudyBrowserTabs,
|
||||
MeasurementFilters,
|
||||
getClosestOrientationFromIOP,
|
||||
};
|
||||
|
||||
export {
|
||||
@@ -120,6 +121,7 @@ export {
|
||||
generateAcceptHeader,
|
||||
createStudyBrowserTabs,
|
||||
MeasurementFilters,
|
||||
getClosestOrientationFromIOP,
|
||||
};
|
||||
|
||||
export default utils;
|
||||
@@ -141,6 +141,19 @@ interface ViewportGridProviderProps {
|
||||
export function ViewportGridProvider({ children, service }: ViewportGridProviderProps) {
|
||||
const viewportGridReducer = (state: AppTypes.ViewportGrid.State, action) => {
|
||||
switch (action.type) {
|
||||
case 'SET_IS_REFERENCE_VIEWABLE': {
|
||||
const { viewportId, isReferenceViewable } = action.payload;
|
||||
const viewports = new Map(state.viewports);
|
||||
const viewport = viewports.get(viewportId);
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
viewports.set(viewportId, {
|
||||
...viewport,
|
||||
isReferenceViewable,
|
||||
});
|
||||
return { ...state, viewports };
|
||||
}
|
||||
case 'SET_ACTIVE_VIEWPORT_ID': {
|
||||
return { ...state, ...{ activeViewportId: action.payload } };
|
||||
}
|
||||
@@ -378,6 +391,13 @@ export function ViewportGridProvider({ children, service }: ViewportGridProvider
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const setIsReferenceViewable = useCallback(
|
||||
(viewportId, isReferenceViewable) => {
|
||||
dispatch({ type: 'SET_IS_REFERENCE_VIEWABLE', payload: { viewportId, isReferenceViewable } });
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const setDisplaySetsForViewports = useCallback(
|
||||
viewports =>
|
||||
dispatch({
|
||||
@@ -449,6 +469,14 @@ export function ViewportGridProvider({ children, service }: ViewportGridProvider
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const getViewportState = useCallback(
|
||||
viewportId => {
|
||||
const { viewports } = viewportGridState;
|
||||
return viewports.get(viewportId);
|
||||
},
|
||||
[viewportGridState]
|
||||
);
|
||||
|
||||
const getNumViewportPanes = useCallback(() => {
|
||||
const { layout, viewports } = viewportGridState;
|
||||
const { numRows, numCols } = layout;
|
||||
@@ -466,12 +494,14 @@ export function ViewportGridProvider({ children, service }: ViewportGridProvider
|
||||
getState,
|
||||
setActiveViewportId,
|
||||
setDisplaySetsForViewports,
|
||||
setIsReferenceViewable,
|
||||
setLayout,
|
||||
reset,
|
||||
onModeExit: reset,
|
||||
set,
|
||||
getNumViewportPanes,
|
||||
setViewportIsReady,
|
||||
getViewportState,
|
||||
getGridViewportsReady,
|
||||
});
|
||||
}
|
||||
@@ -480,12 +510,14 @@ export function ViewportGridProvider({ children, service }: ViewportGridProvider
|
||||
service,
|
||||
setActiveViewportId,
|
||||
setDisplaySetsForViewports,
|
||||
setIsReferenceViewable,
|
||||
setLayout,
|
||||
reset,
|
||||
set,
|
||||
getNumViewportPanes,
|
||||
setViewportIsReady,
|
||||
getGridViewportsReady,
|
||||
getViewportState,
|
||||
]);
|
||||
|
||||
// run many of the calls through the service itself since we want to publish events
|
||||
@@ -494,7 +526,10 @@ export function ViewportGridProvider({ children, service }: ViewportGridProvider
|
||||
setActiveViewportId: index => service.setActiveViewportId(index),
|
||||
setDisplaySetsForViewport: props => service.setDisplaySetsForViewports([props]),
|
||||
setDisplaySetsForViewports: props => service.setDisplaySetsForViewports(props),
|
||||
setIsReferenceViewable: (viewportId, isReferenceViewable) =>
|
||||
service.setIsReferenceViewable(viewportId, isReferenceViewable),
|
||||
setLayout: layout => service.setLayout(layout),
|
||||
getViewportState: viewportId => service.getViewportState(viewportId),
|
||||
reset: () => service.reset(),
|
||||
set: gridLayoutState => service.setState(gridLayoutState), // run it through the service itself since we want to publish events
|
||||
getNumViewportPanes,
|
||||
|
||||
Reference in new issue
Block a user