feat: state sync service and hanging protocol updates to preserve state (#3131)

* feat: Add state sync and use it to remember viewport grid info

fix: Version updates

Fixes for toggling MPR mode

Fix the display when the interleaved load module fails

Fix the memory of the state to restore correctly

PR fixes for the state sync service

PR fixes

PR fixes

PR fixes

Added a hack warning to remove volumeDeactivate

Fixes for TMTV colormap setting

Fix the casing

Missed renames

fix: tests not running due to variance in ordering

Reverting some fixes to change case

PR changes - mostly comments and minor improvements

fix: All display sets were being updated on drag and drop

PR fixes - mostly renames

PR fixes

Test support for OHIF, for HP branch

test: Add at least a minimal set of automated tests for hanging protocols

Docs

PR fixes

Merge fixes

DOCS updates

Add an example of the mn hanging protocol

PR fixes

PR fixes

PR fixes

* Fix the drag and drop

PR fixes

* PR changes - update default keys for next/previous stage

* fix: Was storing the custom viewport grid too aggressively

Caused by a PR change misspelling a variable
This commit is contained in:
Bill Wallace authored and GitHub committed 2023-03-15 12:41:41 -04:00
1 parent b7fff77e17
commit 803f638401
87 files changed
+3413 -1919

No files matched your search

@@ -168,9 +168,11 @@ const SplitButton = ({
: 'text-common-bright hover:bg-primary-dark hover:text-primary-light'
)}
>
<span className="mr-4">
<Icon name={icon} className="w-5 h-5" />
</span>
{icon && (
<span className="mr-4">
<Icon name={icon} className="w-5 h-5" />
</span>
)}
<span className="mr-5">{t(label)}</span>
</div>
);
@@ -6,7 +6,7 @@ import { Icon } from '../';
import { StringNumber } from '../../types';
/**
*
* Display a thumbnail for a display set.
*/
const Thumbnail = ({
displaySetInstanceUID,
@@ -16,11 +16,12 @@ const Thumbnail = ({
description,
seriesNumber,
numInstances,
countIcon,
dragData,
isActive,
onClick,
onDoubleClick,
}) => {
}): React.ReactNode => {
// TODO: We should wrap our thumbnail to create a "DraggableThumbnail", as
// this will still allow for "drag", even if there is no drop target for the
// specified item.
@@ -73,7 +74,8 @@ const Thumbnail = ({
{seriesNumber}
</div>
<div className="flex flex-row items-center flex-1">
<Icon name="group-layers" className="w-3 mr-2" /> {numInstances}
<Icon name={countIcon || 'group-layers'} className="w-3 mr-2" />
{` ${numInstances}`}
</div>
</div>
<div className="text-base text-white break-all">{description}</div>
@@ -23,6 +23,7 @@ const ThumbnailList = ({
modality,
componentType,
seriesDate,
countIcon,
viewportIdentificator,
isTracked,
canReject,
@@ -33,7 +34,6 @@ const ThumbnailList = ({
const isActive = activeDisplaySetInstanceUIDs.includes(
displaySetInstanceUID
);
switch (componentType) {
case 'thumbnail':
return (
@@ -44,6 +44,7 @@ const ThumbnailList = ({
description={description}
seriesNumber={seriesNumber}
numInstances={numInstances}
countIcon={countIcon}
imageSrc={imageSrc}
imageAltText={imageAltText}
viewportIdentificator={viewportIdentificator}
@@ -63,6 +64,7 @@ const ThumbnailList = ({
description={description}
seriesNumber={seriesNumber}
numInstances={numInstances}
countIcon={countIcon}
imageSrc={imageSrc}
imageAltText={imageAltText}
viewportIdentificator={viewportIdentificator}
@@ -13,6 +13,7 @@ const ThumbnailTracked = ({
description,
seriesNumber,
numInstances,
countIcon,
dragData,
onClick,
onDoubleClick,
@@ -117,6 +118,7 @@ const ThumbnailTracked = ({
description={description}
seriesNumber={seriesNumber}
numInstances={numInstances}
countIcon={countIcon}
isActive={isActive}
onClick={onClick}
onDoubleClick={onDoubleClick}
@@ -39,6 +39,11 @@ const ToolbarButton = ({
const activeClass = isActive ? 'active' : '';
const shouldShowDropdown = !!isActive && !!dropdownContent;
const iconEl = icon ? (
<Icon name={icon} />
) : (
<div>{label || 'Missing icon and label'}</div>
);
return (
<div key={id}>
@@ -64,7 +69,7 @@ const ToolbarButton = ({
id={id}
{...rest}
>
<Icon name={icon} />
{iconEl}
</IconButton>
</Tooltip>
</div>
@@ -2,7 +2,16 @@ import React from 'react';
import PropTypes from 'prop-types';
import { LegacyViewportActionBar, Notification } from '../';
const Viewport = ({ viewportIndex, onArrowsClick, studyData, children }) => {
const Viewport = ({
viewportId,
viewportIndex,
onArrowsClick,
studyData,
children,
}) => {
if (!viewportId) {
viewportId = `viewport-${viewportIndex}`;
}
return (
<div className="relative flex flex-col h-full">
<div className="absolute top-0 left-0 w-full">
@@ -39,7 +48,7 @@ const Viewport = ({ viewportIndex, onArrowsClick, studyData, children }) => {
</div>
{/* STUDY IMAGE */}
<div className="w-full h-full" id={`viewport-${viewportIndex}`}>
<div className="w-full h-full" id={viewportId}>
{children}
</div>
</div>
@@ -6,17 +6,22 @@ import React, {
useReducer,
} from 'react';
import PropTypes from 'prop-types';
import isEqual from 'lodash.isequal';
import viewportLabels from '../utils/viewportLabels';
import getPresentationId from './getPresentationId';
const DEFAULT_STATE = {
numRows: null,
numCols: null,
layoutType: 'grid',
activeViewportIndex: 0,
layout: {
numRows: 0,
numCols: 0,
layoutType: 'grid',
},
viewports: [
{
displaySetInstanceUIDs: [],
viewportOptions: {},
displaySetSelectors: [],
displaySetOptions: [{}],
x: 0, // left
y: 0, // top
@@ -25,20 +30,58 @@ const DEFAULT_STATE = {
viewportLabel: null,
},
],
activeViewportIndex: 0,
cachedLayout: {},
};
export const ViewportGridContext = createContext(DEFAULT_STATE);
/**
* Given the flatten index, and rows and column, it returns the
* row and column index
* Find a viewport to re-use, and then set the viewportId
*
* @param idSet
* @param viewport
* @param stateViewports
* @returns
*/
const unravelIndex = (index, numRows, numCols) => {
const row = Math.floor(index / numCols);
const col = index % numCols;
return { row, col };
const reuseViewport = (idSet, viewport, stateViewports) => {
const oldIds = {};
for (const oldViewport of stateViewports) {
const { viewportId: oldId } = oldViewport;
oldIds[oldId] = true;
if (!oldId || idSet[oldId]) continue;
if (
!isEqual(
oldViewport.displaySetInstanceUIDs,
viewport.displaySetInstanceUIDs
)
) {
continue;
}
idSet[oldId] = true;
// TODO re-use viewports once the flickering/wrong size redraw is fixed
// return {
// ...oldViewport,
// ...viewport,
// viewportOptions: {
// ...oldViewport.viewportOptions,
// viewportId: oldViewport.viewportId,
// },
// };
}
// Find a viewport instance number different from earlier viewports having
// the same presentationId as this one would - will be less than 10k
// viewports hopefully :-)
for (let i = 0; i < 10000; i++) {
const viewportId = 'viewport-' + i;
if (idSet[viewportId] || oldIds[viewportId]) continue;
idSet[viewportId] = true;
return {
...viewport,
viewportId,
viewportOptions: { ...viewport.viewportOptions, viewportId },
};
}
throw new Error('No ID found');
};
export function ViewportGridProvider({ children, service }) {
@@ -59,26 +102,40 @@ export function ViewportGridProvider({ children, service }) {
// which might have been a PDF Viewport. The viewport itself
// will deal with inheritance if required. Here is just a simple
// provider.
const viewportOptions = payload.viewportOptions || {};
const displaySetOptions = payload.displaySetOptions || [{}];
const viewport = state.viewports[viewportIndex] || {};
const viewportOptions = { ...payload.viewportOptions };
const displaySetOptions = payload.displaySetOptions || [];
if (displaySetOptions.length === 0) {
// Only copy index 0, as that is all that is currently supported by this
// method call.
displaySetOptions.push({ ...viewport.displaySetOptions?.[0] });
}
const viewports = state.viewports.slice();
if (!viewportOptions.viewportId) {
viewportOptions.viewportId = `viewport-${viewportIndex}`;
}
// merge the displaySetOptions and viewportOptions and displaySetInstanceUIDs
// into the viewport object at the given index
viewports[viewportIndex] = {
...viewports[viewportIndex],
let newView = {
...viewport,
displaySetInstanceUIDs,
viewportOptions,
displaySetOptions,
viewportLabel: viewportLabels[viewportIndex],
};
viewportOptions.presentationId = getPresentationId(newView, viewports);
return { ...state, ...{ viewports } };
// Make sure we assign a viewport id
newView = reuseViewport({}, newView, state.viewports);
console.log(
'Creating new viewport',
viewportIndex,
newView.viewportOptions.viewportId,
displaySetInstanceUIDs,
displaySetOptions
);
viewports[viewportIndex] = newView;
return { ...state, viewports };
}
case 'SET_LAYOUT': {
const {
@@ -86,108 +143,96 @@ export function ViewportGridProvider({ children, service }) {
numRows,
layoutOptions,
layoutType = 'grid',
keepExtraViewports = false,
findOrCreateViewport,
} = action.payload;
// If empty viewportOptions, we use numRow and numCols to calculate number of viewports
const numPanes = layoutOptions.length || numRows * numCols;
const viewports = state.viewports.slice();
const activeViewportIndex =
state.activeViewportIndex >= numPanes ? 0 : state.activeViewportIndex;
const hasOptions = layoutOptions?.length;
const viewports = [];
while (viewports.length < numPanes) {
viewports.push({});
}
// Options is a temporary state store which can be used by the
// findOrCreate to store state about already found viewports. Typically,
// it will be used to store the display set UID's which are already
// in view so that the find or create can decide which display sets
// haven't been viewed yet, and add them in the appropriate order.
const options = {};
// Extra viewports are kept when the grid layout is changed in the UI
// because the user populated those viewports and if the viewports were to
// return on screen their contents should be maintained.
if (!keepExtraViewports) {
while (viewports.length > numPanes) {
viewports.pop();
let activeViewportIndex;
for (let row = 0; row < numRows; row++) {
for (let col = 0; col < numCols; col++) {
const pos = col + row * numCols;
const layoutOption = layoutOptions[pos];
const positionId = layoutOption?.positionId || `${col}-${row}`;
if (hasOptions && pos >= layoutOptions.length) {
continue;
}
if (
!activeViewportIndex ||
state.viewports[pos]?.positionId === positionId
) {
activeViewportIndex = pos;
}
const viewport = findOrCreateViewport(pos, positionId, options);
if (!viewport) continue;
viewport.positionId = positionId;
// Create a new viewport object as it is getting updated here
// and it is part of the read only state
viewports.push(viewport);
let xPos, yPos, w, h;
if (layoutOptions && layoutOptions[pos]) {
({ x: xPos, y: yPos, width: w, height: h } = layoutOptions[pos]);
} else {
w = 1 / numCols;
h = 1 / numRows;
xPos = col * w;
yPos = row * h;
}
viewport.width = w;
viewport.height = h;
viewport.x = xPos;
viewport.y = yPos;
}
}
for (let i = 0; i < numPanes; i++) {
let xPos, yPos, w, h;
if (layoutOptions && layoutOptions[i]) {
({ x: xPos, y: yPos, width: w, height: h } = layoutOptions[i]);
} else {
const { row, col } = unravelIndex(i, numRows, numCols);
w = 1 / numCols;
h = 1 / numRows;
xPos = col * w;
yPos = row * h;
const viewportIdSet = {};
for (
let viewportIndex = 0;
viewportIndex < viewports.length;
viewportIndex++
) {
const viewport = reuseViewport(
viewportIdSet,
viewports[viewportIndex],
state.viewports
);
if (!viewport.viewportOptions.presentationId) {
viewport.viewportOptions.presentationId = getPresentationId(
viewport,
viewports
);
}
viewports[i].width = w;
viewports[i].height = h;
viewports[i].x = xPos;
viewports[i].y = yPos;
viewport.viewportIndex = viewportIndex;
viewport.viewportLabel = viewportLabels[viewportIndex];
viewports[viewportIndex] = viewport;
}
return {
const ret = {
...state,
...{
activeViewportIndex,
activeViewportIndex,
layout: {
...state.layout,
numCols,
numRows,
layoutType,
viewports,
},
viewports,
};
return ret;
}
case 'RESET': {
return {
numCols: null,
numRows: null,
layoutType: 'grid',
activeViewportIndex: 0,
viewports: [
{
displaySetInstanceUIDs: [],
displaySetOptions: [],
viewportOptions: {},
x: 0, // left
y: 0, // top
width: 100,
height: 100,
},
],
cachedLayout: {},
};
}
// The SET_CACHE_LAYOUT action can be used for caching a layout
// for instance double clicking a viewport to maximize it.
// and then restoring the previous layout when the viewport is
// double clicked again.
case 'SET_CACHED_LAYOUT': {
const { cacheId, cachedLayout } = action.payload;
// deep copy the cachedLayout into the state
return {
...state,
cachedLayout: {
...state.cachedLayout,
[cacheId]: JSON.parse(JSON.stringify(cachedLayout)),
},
};
}
case 'RESTORE_CACHED_LAYOUT': {
const cacheId = action.payload;
if (!state.cachedLayout[cacheId]) {
console.warn(
`No cached layout found for cacheId: ${cacheId}. Ignoring...`
);
return state;
}
const cachedLayout = state.cachedLayout;
return { ...state.cachedLayout[cacheId], cachedLayout };
return DEFAULT_STATE;
}
case 'SET': {
@@ -221,6 +266,7 @@ export function ViewportGridProvider({ children, service }) {
viewportIndex,
displaySetInstanceUIDs,
viewportOptions,
displaySetSelectors,
displaySetOptions,
}) =>
dispatch({
@@ -229,6 +275,7 @@ export function ViewportGridProvider({ children, service }) {
viewportIndex,
displaySetInstanceUIDs,
viewportOptions,
displaySetSelectors,
displaySetOptions,
},
}),
@@ -250,7 +297,7 @@ export function ViewportGridProvider({ children, service }) {
numRows,
numCols,
layoutOptions = [],
keepExtraViewports = false,
findOrCreateViewport,
}) =>
dispatch({
type: 'SET_LAYOUT',
@@ -259,7 +306,7 @@ export function ViewportGridProvider({ children, service }) {
numRows,
numCols,
layoutOptions,
keepExtraViewports,
findOrCreateViewport,
},
}),
[dispatch]
@@ -274,25 +321,6 @@ export function ViewportGridProvider({ children, service }) {
[dispatch]
);
const setCachedLayout = useCallback(
payload =>
dispatch({
type: 'SET_CACHED_LAYOUT',
payload,
}),
[dispatch]
);
const restoreCachedLayout = useCallback(
cacheId => {
dispatch({
type: 'RESTORE_CACHED_LAYOUT',
payload: cacheId,
});
},
[dispatch]
);
const set = useCallback(
payload =>
dispatch({
@@ -303,7 +331,8 @@ export function ViewportGridProvider({ children, service }) {
);
const getNumViewportPanes = useCallback(() => {
const { numCols, numRows, viewports } = viewportGridState;
const { layout, viewports } = viewportGridState;
const { numRows, numCols } = layout;
return Math.min(viewports.length, numCols * numRows);
}, [viewportGridState]);
@@ -322,8 +351,6 @@ export function ViewportGridProvider({ children, service }) {
setLayout,
reset,
onModeExit: reset,
setCachedLayout,
restoreCachedLayout,
set,
getNumViewportPanes,
});
@@ -336,8 +363,6 @@ export function ViewportGridProvider({ children, service }) {
setDisplaySetsForViewports,
setLayout,
reset,
setCachedLayout,
restoreCachedLayout,
set,
getNumViewportPanes,
]);
@@ -348,8 +373,6 @@ export function ViewportGridProvider({ children, service }) {
setDisplaySetsForViewport,
setDisplaySetsForViewports,
setLayout,
setCachedLayout,
restoreCachedLayout,
reset,
set,
getNumViewportPanes,
@@ -0,0 +1,73 @@
/**
* Selects a presentation ID to use for this viewport.
* This is done to allow the same display set to be displayed more than once
* on screen, with different attributes such as window level and initial position.
* Then, when redisplaying that, the nearest/most common attribute is re-used.
*
* For example, for display set <displaySetUID>, in a viewport of type volume,
* the generated presentationID might be
* `volume:axial:<displaySetUID>`. This can then be used to store and retrieve
* presentation information in state sync service 'presentationSync' state.
*
* The generated value attempts to generate a unique value for every type
* of viewport which should have it's own presentation information. Thus, the
* following values are used for presentation ID:
*
* 1. viewportType - since the presentation information for a volume is different than for a stack
* 2. orientation - since the camera is different for different orientations
* 3. display set instance UID - since different display sets should get displayed differently
* 4. instance count - since displaying the same series twice should allow applying different window level etc
*
* @param viewport requiring a presentation Id
* @param viewports is the list of viewports being shown. Any presentation ID's
* among them must not be re-used in order to have each viewport have it's own presentation ID.
* @returns Presentation ID id, or undefined if nothing displayed
*/
const getPresentationId = (viewport, viewports): string => {
if (!viewport) return;
const { viewportOptions, displaySetInstanceUIDs } = viewport;
if (!viewportOptions || !displaySetInstanceUIDs?.length) {
console.log('No viewport type or display sets in', viewport);
return;
}
const viewportType = viewportOptions.viewportType || 'stack';
const idArr = [viewportType, 0, ...displaySetInstanceUIDs];
if (viewportOptions.orientation) {
idArr.splice(2, 0, viewportOptions.orientation);
}
// Allow setting a custom presentation prefix in the hanging protocol
// This allows defining new
// presentation groups to be set automatically when one knows that the
// same display set will be displayed in different ways.
// This is the recommended way to manage a hanging protocol which displays
// multiple views of a single display set, eg to display brain, bone, soft
// tissue views in different viewports.
if (viewportOptions.presentationPrefix) {
idArr.push(viewportOptions.presentationPrefix);
}
if (!viewports) {
console.log('viewports not defined', idArr.join(','));
return idArr.join('&');
}
// This code finds the first unique index to add to the presentation id so that
// two viewports containing the same display set in the same type of viewport
// can have different presentation information. This allows comparison of
// a single display set in two or more viewports, when the user has simply
// dragged and dropped the view in twice. For example, it allows displaying
// bone, brain and soft tissue views of a single display set, and to still
// remember the specific changes to each viewport.
for (let displayInstance = 0; displayInstance < 128; displayInstance++) {
idArr[1] = displayInstance;
const testId = idArr.join('&');
if (!viewports.find(it => it.viewportOptions?.presentationId === testId)) {
break;
}
}
const id = idArr.join('&');
return id;
};
export default getPresentationId;