Merge pull request #3 from OHIF/v3-stable

V3 stable
This commit is contained in:
Salim Kanoun 2023-04-05 14:02:23 +02:00 committed by GitHub
commit 5b7dc9e9c3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
178 changed files with 7030 additions and 3052 deletions

View File

@ -2,7 +2,8 @@
<!-- markdownlint-disable -->
<div align="center">
<h1>OHIF Medical Imaging Viewer</h1>
<p><strong>The OHIF Viewer</strong> is a zero-footprint medical image viewer provided by the <a href="https://ohif.org/">Open Health Imaging Foundation (OHIF)</a>. It is a configurable and extensible progressive web application with out-of-the-box support for image archives which support <a href="https://www.dicomstandard.org/dicomweb/">DICOMweb</a>.</p>
<p><strong>The OHIF Viewer</strong> is a zero-footprint medical image viewer
provided by the <a href="https://ohif.org/">Open Health Imaging Foundation (OHIF)</a>. It is a configurable and extensible progressive web application with out-of-the-box support for image archives which support <a href="https://www.dicomstandard.org/dicomweb/">DICOMweb</a>.</p>
</div>

View File

@ -31,8 +31,6 @@ async function _hydrateSEGDisplaySet({
displaySetInstanceUID
);
viewportGridService.setDisplaySetsForViewports(updatedViewports);
// Todo: fix this after we have a better way for stack viewport segmentations
// check every viewport in the viewports to see if the displaySetInstanceUID
@ -50,7 +48,7 @@ async function _hydrateSEGDisplaySet({
);
if (shouldDisplaySeg) {
viewportGridService.setDisplaySetsForViewport({
updatedViewports.push({
viewportIndex: index,
displaySetInstanceUIDs: viewport.displaySetInstanceUIDs,
viewportOptions: {
@ -62,6 +60,9 @@ async function _hydrateSEGDisplaySet({
}
});
// Do the entire update at once
viewportGridService.setDisplaySetsForViewports(updatedViewports);
return true;
}

View File

@ -45,8 +45,8 @@
"dependencies": {
"@babel/runtime": "^7.20.13",
"classnames": "^2.3.2",
"@cornerstonejs/adapters": "^0.4.1",
"@cornerstonejs/core": "^0.33.2",
"@cornerstonejs/tools": "^0.50.2"
"@cornerstonejs/adapters": "^0.6.0",
"@cornerstonejs/core": "^0.40.0",
"@cornerstonejs/tools": "^0.60.1"
}
}

View File

@ -35,7 +35,7 @@ function getFilteredCornerstoneToolState(
);
const toolData = imageIdSpecificToolState[toolType].data;
let finding;
let { finding } = measurementDataI;
const findingSites = [];
// NOTE -> We use the CORNERSTONEJS coding schemeDesignator which we have
@ -56,6 +56,10 @@ function getFilteredCornerstoneToolState(
}
}
if (measurementDataI.findingSites) {
findingSites.push(...measurementDataI.findingSites);
}
const measurement = Object.assign({}, annotation, {
finding,
findingSites,
@ -73,7 +77,7 @@ function getFilteredCornerstoneToolState(
for (let i = 0; i < framesOfReference.length; i++) {
const frameOfReference = framesOfReference[i];
const frameOfReferenceAnnotations = annotationManager.getFrameOfReferenceAnnotations(
const frameOfReferenceAnnotations = annotationManager.getAnnotations(
frameOfReference
);

View File

@ -11,6 +11,25 @@ const CORNERSTONE_3D_TOOLS_SOURCE_VERSION = '0.1';
const supportedLegacyCornerstoneTags = ['cornerstoneTools@^4.0.0'];
const convertCode = (codingValues, code) => {
if (!code || code.CodingSchemeDesignator === 'CORNERSTONEJS') return;
const ref = `${code.CodingSchemeDesignator}:${code.CodeValue}`;
const ret = { ...codingValues[ref], ref, ...code, text: code.CodeMeaning };
return ret;
};
const convertSites = (codingValues, sites) => {
if (!sites || !sites.length) return;
const ret = [];
// Do as a loop to convert away from Proxy instances
for (let i = 0; i < sites.length; i++) {
// Deal with irregular conversion from dcmjs
const site = convertCode(codingValues, sites[i][0] || sites[i]);
if (site) ret.push(site);
}
return (ret.length && ret) || undefined;
};
/**
* Hydrates a structured report, for default viewports.
*
@ -20,8 +39,16 @@ export default function hydrateStructuredReport(
displaySetInstanceUID
) {
const dataSource = extensionManager.getActiveDataSource()[0];
const { measurementService, displaySetService } = servicesManager.services;
const {
measurementService,
displaySetService,
customizationService,
} = servicesManager.services;
const codingValues = customizationService.getCustomization(
'codingValues',
{}
);
const displaySet = displaySetService.getDisplaySetByUID(
displaySetInstanceUID
);
@ -171,6 +198,15 @@ export default function hydrateStructuredReport(
CORNERSTONE_3D_TOOLS_SOURCE_VERSION
);
annotation.data.label = getLabelFromDCMJSImportedToolData(toolData);
annotation.data.finding = convertCode(
codingValues,
toolData.finding?.[0]
);
annotation.data.findingSites = convertSites(
codingValues,
toolData.findingSites
);
annotation.data.site = annotation.data.findingSites?.[0];
const matchingMapping = mappings.find(
m => m.annotationType === annotationType

View File

@ -1,7 +1,7 @@
import PropTypes from 'prop-types';
import React, { useCallback, useContext, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import OHIF, { utils } from '@ohif/core';
import OHIF, { utils, ServicesManager, ExtensionManager } from '@ohif/core';
import { setTrackingUniqueIdentifiersForElement } from '../tools/modules/dicomSRModule';
import {
@ -27,6 +27,7 @@ function OHIFCornerstoneSRViewport(props) {
dataSource,
displaySets,
viewportIndex,
viewportOptions,
viewportLabel,
servicesManager,
extensionManager,
@ -215,6 +216,7 @@ function OHIFCornerstoneSRViewport(props) {
// override the activeImageDisplaySetData
displaySets={[activeImageDisplaySetData]}
viewportOptions={{
...viewportOptions,
toolGroupId: `${SR_TOOLGROUP_BASE_NAME}`,
}}
onElementEnabled={onElementEnabled}
@ -419,6 +421,9 @@ OHIFCornerstoneSRViewport.propTypes = {
dataSource: PropTypes.object,
children: PropTypes.node,
customProps: PropTypes.object,
viewportOptions: PropTypes.object,
servicesManager: PropTypes.instanceOf(ServicesManager).isRequired,
extensionManager: PropTypes.instanceOf(ExtensionManager).isRequired,
};
OHIFCornerstoneSRViewport.defaultProps = {

View File

@ -29,7 +29,7 @@
"peerDependencies": {
"@ohif/core": "^3.0.0",
"@ohif/ui": "^2.0.0",
"cornerstone-wado-image-loader": "^4.2.1",
"cornerstone-wado-image-loader": "^4.13.0",
"dcmjs": "^0.29.4",
"dicom-parser": "^1.8.9",
"hammerjs": "^2.0.8",
@ -43,10 +43,10 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "^0.4.1",
"@cornerstonejs/core": "^0.33.2",
"@cornerstonejs/streaming-image-volume-loader": "^0.14.1",
"@cornerstonejs/tools": "^0.50.2",
"@cornerstonejs/adapters": "^0.6.0",
"@cornerstonejs/core": "^0.40.0",
"@cornerstonejs/streaming-image-volume-loader": "^0.16.0",
"@cornerstonejs/tools": "^0.60.1",
"@kitware/vtk.js": "26.5.6",
"html2canvas": "^1.4.1",
"lodash.debounce": "4.0.8",

View File

@ -128,6 +128,7 @@ const OHIFCornerstoneViewport = React.memo(props => {
cornerstoneViewportService,
cornerstoneCacheService,
viewportGridService,
stateSyncService,
} = servicesManager.services;
const cineHandler = () => {
@ -211,6 +212,33 @@ const OHIFCornerstoneViewport = React.memo(props => {
}
}, [elementRef]);
const storePresentation = () => {
const currentPresentation = cornerstoneViewportService.getPresentation(
viewportIndex
);
if (!currentPresentation || !currentPresentation.presentationIds) return;
const {
lutPresentationStore,
positionPresentationStore,
} = stateSyncService.getState();
const { presentationIds } = currentPresentation;
const { lutPresentationId, positionPresentationId } = presentationIds || {};
const storeState = {};
if (lutPresentationId) {
storeState.lutPresentationStore = {
...lutPresentationStore,
[lutPresentationId]: currentPresentation,
};
}
if (positionPresentationId) {
storeState.positionPresentationStore = {
...positionPresentationStore,
[positionPresentationId]: currentPresentation,
};
}
stateSyncService.store(storeState);
};
const cleanUpServices = useCallback(() => {
const viewportInfo = cornerstoneViewportService.getViewportInfoByIndex(
viewportIndex
@ -288,6 +316,8 @@ const OHIFCornerstoneViewport = React.memo(props => {
setImageScrollBarHeight();
return () => {
storePresentation();
cleanUpServices();
cornerstoneViewportService.disableElement(viewportIndex);
@ -360,11 +390,26 @@ const OHIFCornerstoneViewport = React.memo(props => {
initialImageIndex
);
storePresentation();
const {
lutPresentationStore,
positionPresentationStore,
} = stateSyncService.getState();
const { presentationIds } = viewportOptions;
const presentations = {
positionPresentation:
positionPresentationStore[presentationIds?.positionPresentationId],
lutPresentation:
lutPresentationStore[presentationIds?.lutPresentationId],
};
cornerstoneViewportService.setViewportData(
viewportIndex,
viewportData,
viewportOptions,
displaySetOptions
displaySetOptions,
presentations
);
};

View File

@ -117,8 +117,11 @@ function CustomizableViewportOverlay({
viewportIndex,
servicesManager,
}) {
const { toolbarService, cornerstoneViewportService, customizationService } =
servicesManager.services;
const {
toolbarService,
cornerstoneViewportService,
customizationService,
} = servicesManager.services;
const [voi, setVOI] = useState({ windowCenter: null, windowWidth: null });
const [scale, setScale] = useState(1);
const [activeTools, setActiveTools] = useState([]);
@ -202,10 +205,9 @@ function CustomizableViewportOverlay({
previousCamera.parallelScale !== camera.parallelScale ||
previousCamera.scale !== camera.scale
) {
const viewport =
cornerstoneViewportService.getCornerstoneViewportByIndex(
viewportIndex
);
const viewport = cornerstoneViewportService.getCornerstoneViewportByIndex(
viewportIndex
);
if (!viewport) {
return;
@ -283,7 +285,7 @@ function CustomizableViewportOverlay({
} else if (item.customizationType === 'ohif.overlayItem.instanceNumber') {
return <InstanceNumberOverlayItem {...overlayItemProps} />;
} else {
const renderItem = customizationService.applyType(item);
const renderItem = customizationService.transform(item);
if (typeof renderItem.content === 'function') {
return renderItem.content(overlayItemProps);
@ -450,8 +452,9 @@ function _getInstanceNumberFromVolume(
const volume = volumes[0];
const { direction, imageIds } = volume;
const cornerstoneViewport =
cornerstoneViewportService.getCornerstoneViewportByIndex(viewportIndex);
const cornerstoneViewport = cornerstoneViewportService.getCornerstoneViewportByIndex(
viewportIndex
);
if (!cornerstoneViewport) {
return;

View File

@ -118,6 +118,10 @@ function ViewportOrientationMarkers({
viewportIndex
);
if (!ohifViewport) {
console.log('ViewportOrientationMarkers::No viewport');
return null;
}
const backgroundColor = ohifViewport.getViewportOptions().background;
// Todo: probably this can be done in a better way in which we identify bright

View File

@ -15,11 +15,11 @@ import { ServicesManager } from '@ohif/core';
import CornerstoneViewportDownloadForm from './utils/CornerstoneViewportDownloadForm';
import callInputDialog from './utils/callInputDialog';
import { setColormap } from './utils/colormap/transferFunctionHelpers';
import toggleMPRHangingProtocol from './utils/mpr/toggleMPRHangingProtocol';
import toggleStackImageSync from './utils/stackSync/toggleStackImageSync';
import { getFirstAnnotationSelected } from './utils/measurementServiceMappings/utils/selection';
import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement';
const commandsModule = ({ servicesManager }) => {
function commandsModule({ servicesManager, commandsManager }) {
const {
viewportGridService,
toolGroupService,
@ -27,10 +27,14 @@ const commandsModule = ({ servicesManager }) => {
toolbarService,
uiDialogService,
cornerstoneViewportService,
hangingProtocolService,
uiNotificationService,
customizationService,
measurementService,
hangingProtocolService,
} = (servicesManager as ServicesManager).services;
const { measurementServiceSource } = this;
function _getActiveViewportEnabledElement() {
return getActiveViewportEnabledElement(viewportGridService);
}
@ -70,9 +74,165 @@ const commandsModule = ({ servicesManager }) => {
}
const actions = {
getActiveViewportEnabledElement: () => {
return _getActiveViewportEnabledElement();
/**
* Generates the selector props for the context menu, specific to
* the cornerstone viewport, and then runs the context menu.
*/
showCornerstoneContextMenu: options => {
const element = _getActiveViewportEnabledElement()?.viewport?.element;
const optionsToUse = { ...options, element };
const { useSelectedAnnotation, nearbyToolData, event } = optionsToUse;
// This code is used to invoke the context menu via keyboard shortcuts
if (useSelectedAnnotation && !nearbyToolData) {
const firstAnnotationSelected = getFirstAnnotationSelected(element);
// filter by allowed selected tools from config property (if there is any)
const isToolAllowed =
!optionsToUse.allowedSelectedTools ||
optionsToUse.allowedSelectedTools.includes(
firstAnnotationSelected?.metadata?.toolName
);
if (isToolAllowed) {
optionsToUse.nearbyToolData = firstAnnotationSelected;
} else {
return;
}
}
optionsToUse.defaultPointsPosition = [];
// if (optionsToUse.nearbyToolData) {
// optionsToUse.defaultPointsPosition = commandsManager.runCommand(
// 'getToolDataActiveCanvasPoints',
// { toolData: optionsToUse.nearbyToolData }
// );
// }
// TODO - make the selectorProps richer by including the study metadata and display set.
optionsToUse.selectorProps = {
toolName: optionsToUse.nearbyToolData?.metadata?.toolName,
value: optionsToUse.nearbyToolData,
uid: optionsToUse.nearbyToolData?.annotationUID,
nearbyToolData: optionsToUse.nearbyToolData,
event,
...optionsToUse.selectorProps,
};
commandsManager.run(options, optionsToUse);
},
getNearbyToolData({ nearbyToolData, element, canvasCoordinates }) {
return (
nearbyToolData ??
cstUtils.getAnnotationNearPoint(element, canvasCoordinates)
);
},
// Measurement tool commands:
/** Delete the given measurement */
deleteMeasurement: ({ uid }) => {
if (uid) {
measurementServiceSource.remove(uid);
}
},
/**
* Show the measurement labelling input dialog and update the label
* on the measurement with a response if not cancelled.
*/
setMeasurementLabel: ({ uid }) => {
const measurement = measurementService.getMeasurement(uid);
callInputDialog(
uiDialogService,
measurement,
(label, actionId) => {
if (actionId === 'cancel') {
return;
}
const updatedMeasurement = Object.assign({}, measurement, {
label,
});
measurementService.update(
updatedMeasurement.uid,
updatedMeasurement,
true
);
},
false
);
},
/**
*
* @param props - containing the updates to apply
* @param props.measurementKey - chooses the measurement key to apply the
* code to. This will typically be finding or site to apply a
* finind code or a findingSites code.
* @param props.code - A coding scheme value from DICOM, including:
* * CodeValue - the language independent code, for example '1234'
* * CodingSchemeDesignator - the issue of the code value
* * CodeMeaning - the text value shown to the user
* * ref - a string reference in the form `<designator>:<codeValue>`
* * Other fields
* Note it is a valid option to remove the finding or site values by
* supplying null for the code.
* @param props.uid - the measurement UID to find it with
* @param props.label - the text value for the code. Has NOTHING to do with
* the measurement label, which can be set with textLabel
* @param props.textLabel is the measurement label to apply. Set to null to
* delete.
*
* If the measurementKey is `site`, then the code will also be added/replace
* the 0 element of findingSites. This behaviour is expected to be enhanced
* in the future with ability to set other site information.
*/
updateMeasurement: props => {
const { code, uid, textLabel, label } = props;
const measurement = measurementService.getMeasurement(uid);
const updatedMeasurement = {
...measurement,
};
// Call it textLabel as the label value
// TODO - remove the label setting when direct rendering of findingSites is enabled
if (textLabel !== undefined) {
updatedMeasurement.label = textLabel;
}
if (code !== undefined) {
const measurementKey = code.type || 'finding';
if (code.ref && !code.CodeValue) {
const split = code.ref.indexOf(':');
code.CodeValue = code.ref.substring(split + 1);
code.CodeMeaning = code.text || label;
code.CodingSchemeDesignator = code.ref.substring(0, split);
}
updatedMeasurement[measurementKey] = code;
// TODO - remove this line once the measurements table customizations are in
if (measurementKey !== 'finding') {
if (updatedMeasurement.findingSites) {
updatedMeasurement.findingSites = updatedMeasurement.findingSites.filter(
it => it.type !== measurementKey
);
updatedMeasurement.findingSites.push(code);
} else {
updatedMeasurement.findingSites = [code];
}
}
}
measurementService.update(
updatedMeasurement.uid,
updatedMeasurement,
true
);
},
// Retrieve value commands
getActiveViewportEnabledElement: _getActiveViewportEnabledElement,
setViewportActive: ({ viewportId }) => {
const viewportInfo = cornerstoneViewportService.getViewportInfo(
viewportId
@ -128,6 +288,14 @@ const commandsModule = ({ servicesManager }) => {
});
viewport.render();
},
// Just call the toolbar service record interaction - allows
// executing a toolbar command as a full toolbar command with side affects
// coming from the ToolbarService itself.
toolbarServiceRecordInteraction: props => {
toolbarService.recordInteraction(props);
},
setToolActive: ({ toolName, toolGroupId = null }) => {
if (toolName === 'Crosshairs') {
const activeViewportToolGroup = _getToolGroup(null);
@ -150,7 +318,7 @@ const commandsModule = ({ servicesManager }) => {
};
const toolGroup = _getToolGroup(toolGroupId);
const toolGroupViewportIds = toolGroup.getViewportIds();
const toolGroupViewportIds = toolGroup?.getViewportIds?.();
// if toolGroup has been destroyed, or its viewports have been removed
if (!toolGroupViewportIds || !toolGroupViewportIds.length) {
@ -171,6 +339,17 @@ const commandsModule = ({ servicesManager }) => {
return;
}
if (!toolGroup.getToolInstance(toolName)) {
uiNotificationService.show({
title: `${toolName} tool`,
message: `The ${toolName} tool is not available in this viewport.`,
type: 'info',
duration: 3000,
});
throw new Error(`ToolGroup ${toolGroup.id} does not have this tool.`);
}
const activeToolName = toolGroup.getActivePrimaryMouseButtonTool();
if (activeToolName) {
@ -404,16 +583,6 @@ const commandsModule = ({ servicesManager }) => {
(activeViewportIndex - 1 + viewports.length) % viewports.length;
viewportGridService.setActiveViewportIndex(nextViewportIndex);
},
setHangingProtocol: ({ protocolId }) => {
hangingProtocolService.setProtocol(protocolId);
},
toggleMPR: ({ toggledState }) => {
toggleMPRHangingProtocol({
toggledState,
servicesManager,
getToolGroup: _getToolGroup,
});
},
toggleStackImageSync: ({ toggledState }) => {
toggleStackImageSync({
getEnabledElement,
@ -446,11 +615,53 @@ const commandsModule = ({ servicesManager }) => {
};
const definitions = {
// The command here is to show the viewer context menu, as being the
// context menu
showCornerstoneContextMenu: {
commandFn: actions.showCornerstoneContextMenu,
storeContexts: [],
options: {
menuCustomizationId: 'measurementsContextMenu',
commands: [
{
commandName: 'showContextMenu',
},
],
},
},
getNearbyToolData: {
commandFn: actions.getNearbyToolData,
storeContexts: [],
options: {},
},
deleteMeasurement: {
commandFn: actions.deleteMeasurement,
storeContexts: [],
options: {},
},
setMeasurementLabel: {
commandFn: actions.setMeasurementLabel,
storeContexts: [],
options: {},
},
updateMeasurement: {
commandFn: actions.updateMeasurement,
storeContexts: [],
options: {},
},
setWindowLevel: {
commandFn: actions.setWindowLevel,
storeContexts: [],
options: {},
},
toolbarServiceRecordInteraction: {
commandFn: actions.toolbarServiceRecordInteraction,
storeContexts: [],
options: {},
},
setToolActive: {
commandFn: actions.setToolActive,
storeContexts: [],
@ -554,16 +765,6 @@ const commandsModule = ({ servicesManager }) => {
storeContexts: [],
options: {},
},
setHangingProtocol: {
commandFn: actions.setHangingProtocol,
storeContexts: [],
options: {},
},
toggleMPR: {
commandFn: actions.toggleMPR,
storeContexts: [],
options: {},
},
toggleStackImageSync: {
commandFn: actions.toggleStackImageSync,
storeContexts: [],
@ -581,6 +782,6 @@ const commandsModule = ({ servicesManager }) => {
definitions,
defaultContext: 'CORNERSTONE',
};
};
}
export default commandsModule;

View File

@ -1,16 +1,46 @@
const mpr = {
id: 'mpr',
import { Types } from '@ohif/core';
const mpr: Types.HangingProtocol.Protocol = {
locked: true,
hasUpdatedPriorsInformation: false,
name: 'mpr',
createdDate: '2021-02-23T19:22:08.894Z',
modifiedDate: '2022-10-04T19:22:08.894Z',
modifiedDate: '2023-02-17',
availableTo: {},
editableBy: {},
// Unknown number of priors referenced - so just match any study
numberOfPriorsReferenced: 0,
protocolMatchingRules: [],
imageLoadStrategy: 'nth',
callbacks: {
// Switches out of MPR mode when the layout change button is used
onLayoutChange: [
{
commandName: 'toggleHangingProtocol',
commandOptions: { protocolId: 'mpr' },
context: 'DEFAULT',
},
],
// Turns off crosshairs when switching out of MPR mode
onProtocolExit: [
{
commandName: 'toolbarServiceRecordInteraction',
commandOptions: {
interactionType: 'tool',
commands: [
{
commandOptions: {
toolName: 'WindowLevel',
},
context: 'CORNERSTONE',
},
],
},
},
],
},
displaySetSelectors: {
mprDisplaySet: {
activeDisplaySet: {
seriesMatchingRules: [
{
weight: 1,
@ -27,8 +57,7 @@ const mpr = {
},
stages: [
{
id: 'mpr3Stage',
name: 'mpr',
name: 'MPR 1x3',
viewportStructure: {
layoutType: 'grid',
properties: {
@ -74,6 +103,169 @@ const mpr = {
},
],
},
displaySets: [
{
id: 'activeDisplaySet',
},
],
},
{
viewportOptions: {
toolGroupId: 'mpr',
viewportType: 'volume',
orientation: 'sagittal',
initialImageOptions: {
preset: 'middle',
},
syncGroups: [
{
type: 'voi',
id: 'mpr',
source: true,
target: true,
},
],
},
displaySets: [
{
id: 'activeDisplaySet',
},
],
},
{
viewportOptions: {
toolGroupId: 'mpr',
viewportType: 'volume',
orientation: 'coronal',
initialImageOptions: {
preset: 'middle',
},
syncGroups: [
{
type: 'voi',
id: 'mpr',
source: true,
target: true,
},
],
},
displaySets: [
{
id: 'activeDisplaySet',
},
],
},
],
},
],
};
const mprAnd3DVolumeViewport = {
id: 'mprAnd3DVolumeViewport',
locked: true,
hasUpdatedPriorsInformation: false,
name: 'mpr',
createdDate: '2023-03-15T10:29:44.894Z',
modifiedDate: '2023-03-15T10:29:44.894Z',
availableTo: {},
editableBy: {},
protocolMatchingRules: [],
imageLoadStrategy: 'interleaveCenter',
displaySetSelectors: {
mprDisplaySet: {
seriesMatchingRules: [
{
weight: 1,
attribute: 'isReconstructable',
constraint: {
equals: {
value: true,
},
},
required: true,
},
{
attribute: 'Modality',
constraint: {
equals: {
value: 'CT',
},
},
required: true,
},
],
},
},
stages: [
{
id: 'mpr3Stage',
name: 'mpr',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 2,
columns: 2,
},
},
viewports: [
{
viewportOptions: {
toolGroupId: 'mpr',
viewportType: 'volume',
orientation: 'axial',
initialImageOptions: {
preset: 'middle',
},
syncGroups: [
{
type: 'voi',
id: 'mpr',
source: true,
target: true,
},
],
},
displaySets: [
{
id: 'mprDisplaySet',
},
],
},
{
viewportOptions: {
toolGroupId: 'volume3d',
viewportType: 'volume3d',
orientation: 'coronal',
customViewportProps: {
hideOverlays: true,
},
},
displaySets: [
{
id: 'mprDisplaySet',
options: {
presetName: 'CT-Bone',
},
},
],
},
{
viewportOptions: {
toolGroupId: 'mpr',
viewportType: 'volume',
orientation: 'coronal',
initialImageOptions: {
preset: 'middle',
},
syncGroups: [
{
type: 'voi',
id: 'mpr',
source: true,
target: true,
},
],
},
displaySets: [
{
id: 'mprDisplaySet',
@ -103,29 +295,6 @@ const mpr = {
},
],
},
{
viewportOptions: {
toolGroupId: 'mpr',
viewportType: 'volume',
orientation: 'coronal',
initialImageOptions: {
preset: 'middle',
},
syncGroups: [
{
type: 'voi',
id: 'mpr',
source: true,
target: true,
},
],
},
displaySets: [
{
id: 'mprDisplaySet',
},
],
},
],
},
],
@ -134,9 +303,13 @@ const mpr = {
function getHangingProtocolModule() {
return [
{
id: 'mpr',
name: 'mpr',
protocol: mpr,
},
{
name: mprAnd3DVolumeViewport.id,
protocol: mprAnd3DVolumeViewport,
},
];
}

View File

@ -10,7 +10,7 @@ import { Enums as cs3DToolsEnums } from '@cornerstonejs/tools';
import { Types } from '@ohif/core';
import init from './init';
import commandsModule from './commandsModule';
import getCommandsModule from './commandsModule';
import getHangingProtocolModule from './getHangingProtocolModule';
import ToolGroupService from './services/ToolGroupService';
import SyncGroupService from './services/SyncGroupService';
@ -26,6 +26,7 @@ import { registerColormap } from './utils/colormap/transferFunctionHelpers';
import { id } from './id';
import * as csWADOImageLoader from './initWADOImageLoader.js';
import { measurementMappingUtils } from './utils/measurementServiceMappings';
import { PublicViewportOptions } from './services/ViewportService/Viewport';
const Component = React.lazy(() => {
return import(
@ -50,7 +51,7 @@ const cornerstoneExtension: Types.Extensions.Extension = {
*/
id,
onModeExit: () => {
onModeExit: (): void => {
// Empty out the image load and retrieval pools to prevent memory leaks
// on the mode exits
Object.values(cs3DEnums.RequestType).forEach(type => {
@ -67,12 +68,10 @@ const cornerstoneExtension: Types.Extensions.Extension = {
*
* @param configuration.csToolsConfig - Passed directly to `initCornerstoneTools`
*/
async preRegistration({
servicesManager,
commandsManager,
configuration = {},
appConfig,
}) {
preRegistration: function (
props: Types.Extensions.ExtensionParams
): Promise<void> {
const { servicesManager } = props;
// Todo: we should be consistent with how services get registered. Use REGISTRATION static method for all
servicesManager.registerService(
CornerstoneViewportService(servicesManager)
@ -86,20 +85,21 @@ const cornerstoneExtension: Types.Extensions.Extension = {
CornerstoneCacheService.REGISTRATION(servicesManager)
);
await init({ servicesManager, commandsManager, configuration, appConfig });
return init.call(this, props);
},
getHangingProtocolModule,
getViewportModule({ servicesManager, commandsManager }) {
const ExtendedOHIFCornerstoneViewport = props => {
// const onNewImageHandler = jumpData => {
// commandsManager.runCommand('jumpToImage', jumpData);
// };
const { ToolbarService } = servicesManager.services;
const { toolbarService } = (servicesManager as ServicesManager).services;
return (
<OHIFCornerstoneViewport
{...props}
ToolbarService={ToolbarService}
ToolbarService={toolbarService}
servicesManager={servicesManager}
commandsManager={commandsManager}
/>
@ -113,13 +113,7 @@ const cornerstoneExtension: Types.Extensions.Extension = {
},
];
},
getCommandsModule({ servicesManager, commandsManager, extensionManager }) {
return commandsModule({
servicesManager,
commandsManager,
extensionManager,
});
},
getCommandsModule,
getUtilityModule({ servicesManager }) {
return [
{
@ -150,5 +144,6 @@ const cornerstoneExtension: Types.Extensions.Extension = {
},
};
export default cornerstoneExtension;
export type { PublicViewportOptions };
export { measurementMappingUtils };
export default cornerstoneExtension;

View File

@ -1,6 +1,5 @@
import OHIF from '@ohif/core';
import React from 'react';
import { ContextMenuMeasurements } from '@ohif/ui';
import * as cornerstone from '@cornerstonejs/core';
import * as cornerstoneTools from '@cornerstonejs/tools';
@ -21,15 +20,12 @@ import initWADOImageLoader from './initWADOImageLoader';
import initCornerstoneTools from './initCornerstoneTools';
import { connectToolsToMeasurementService } from './initMeasurementService';
import callInputDialog from './utils/callInputDialog';
import initCineService from './initCineService';
import interleaveCenterLoader from './utils/interleaveCenterLoader';
import nthLoader from './utils/nthLoader';
import interleaveTopToBottom from './utils/interleaveTopToBottom';
const cs3DToolsEvents = Enums.Events;
let CONTEXT_MENU_OPEN = false;
import initContextMenu from './initContextMenu';
import initDoubleClick from './initDoubleClick';
// TODO: Cypress tests are currently grabbing this from the window?
window.cornerstone = cornerstone;
@ -42,11 +38,19 @@ export default async function init({
commandsManager,
configuration,
appConfig,
}) {
}: Types.Extensions.ExtensionParams): Promise<void> {
await cs3DInit();
// For debugging e2e tests that are failing on CI
cornerstone.setUseCPURendering(Boolean(appConfig.useCPURendering));
cornerstone.setConfiguration({
...cornerstone.getConfiguration(),
rendering: {
...cornerstone.getConfiguration().rendering,
strictZSpacingForVolumeViewport:
appConfig.strictZSpacingForVolumeViewport,
},
});
// For debugging large datasets
const MAX_CACHE_SIZE_1GB = 1073741824;
@ -65,6 +69,7 @@ export default async function init({
const {
userAuthenticationService,
measurementService,
customizationService,
displaySetService,
uiDialogService,
uiModalService,
@ -74,6 +79,7 @@ export default async function init({
hangingProtocolService,
toolGroupService,
viewportGridService,
stateSyncService,
} = servicesManager.services;
window.services = servicesManager.services;
@ -97,6 +103,22 @@ export default async function init({
_showCPURenderingModal(uiModalService, hangingProtocolService);
}
// Stores a map from `lutPresentationId` to a Presentation object so that
// an OHIFCornerstoneViewport can be redisplayed with the same LUT
stateSyncService.register('lutPresentationStore', { clearOnModeExit: true });
// Stores a map from `positionPresentationId` to a Presentation object so that
// an OHIFCornerstoneViewport can be redisplayed with the same position
stateSyncService.register('positionPresentationStore', {
clearOnModeExit: true,
});
// Stores the entire ViewportGridService getState when toggling to one up
// (e.g. via a double click) so that it can be restored when toggling back.
stateSyncService.register('toggleOneUpViewportGridStore', {
clearOnModeExit: true,
});
const labelmapRepresentation =
cornerstoneTools.Enums.SegmentationRepresentations.Labelmap;
@ -144,118 +166,12 @@ export default async function init({
initWADOImageLoader(userAuthenticationService, appConfig);
/* Measurement Service */
const measurementServiceSource = connectToolsToMeasurementService(
this.measurementServiceSource = connectToolsToMeasurementService(
servicesManager
);
initCineService(cineService);
const _getDefaultPosition = event => ({
x: (event && event.currentPoints.client[0]) || 0,
y: (event && event.currentPoints.client[1]) || 0,
});
const onRightClick = event => {
if (!uiDialogService) {
console.warn('Unable to show dialog; no UI Dialog Service available.');
return;
}
const onGetMenuItems = defaultMenuItems => {
const { element, currentPoints } = event.detail;
const nearbyToolData = utilities.getAnnotationNearPoint(
element,
currentPoints.canvas
);
const menuItems = [];
if (nearbyToolData && nearbyToolData.metadata.toolName !== 'Crosshairs') {
defaultMenuItems.forEach(item => {
item.value = nearbyToolData;
item.element = element;
menuItems.push(item);
});
}
return menuItems;
};
CONTEXT_MENU_OPEN = true;
uiDialogService.dismiss({ id: 'context-menu' });
uiDialogService.create({
id: 'context-menu',
isDraggable: false,
preservePosition: false,
defaultPosition: _getDefaultPosition(event.detail),
content: ContextMenuMeasurements,
onClickOutside: () => {
uiDialogService.dismiss({ id: 'context-menu' });
CONTEXT_MENU_OPEN = false;
},
contentProps: {
onGetMenuItems,
eventData: event.detail,
onDelete: item => {
const { annotationUID } = item.value;
const uid = annotationUID;
// Sync'd w/ Measurement Service
if (uid) {
measurementServiceSource.remove(uid, {
element: item.element,
});
}
CONTEXT_MENU_OPEN = false;
},
onClose: () => {
CONTEXT_MENU_OPEN = false;
uiDialogService.dismiss({ id: 'context-menu' });
},
onSetLabel: item => {
const { annotationUID } = item.value;
const measurement = measurementService.getMeasurement(annotationUID);
callInputDialog(
uiDialogService,
measurement,
(label, actionId) => {
if (actionId === 'cancel') {
return;
}
const updatedMeasurement = Object.assign({}, measurement, {
label,
});
measurementService.update(
updatedMeasurement.uid,
updatedMeasurement,
true
);
},
false
);
CONTEXT_MENU_OPEN = false;
},
},
});
};
const resetContextMenu = () => {
if (!uiDialogService) {
console.warn('Unable to show dialog; no UI Dialog Service available.');
return;
}
CONTEXT_MENU_OPEN = false;
uiDialogService.dismiss({ id: 'context-menu' });
};
// When a custom image load is performed, update the relevant viewports
hangingProtocolService.subscribe(
hangingProtocolService.EVENTS.CUSTOM_IMAGE_LOAD_PERFORMED,
@ -266,32 +182,41 @@ export default async function init({
viewportId
);
const ohifViewport = cornerstoneViewportService.getViewportInfo(
viewportId
);
const {
lutPresentationStore,
positionPresentationStore,
} = stateSyncService.getState();
const { presentationIds } = ohifViewport.getViewportOptions();
const presentations = {
positionPresentation:
positionPresentationStore[presentationIds?.positionPresentationId],
lutPresentation:
lutPresentationStore[presentationIds?.lutPresentationId],
};
cornerstoneViewportService.setVolumesForViewport(
viewport,
volumeInputArray
volumeInputArray,
presentations
);
}
}
);
/*
* Because click gives us the native "mouse up", buttons will always be `0`
* Need to fallback to event.which;
*
*/
const contextMenuHandleClick = evt => {
const mouseUpEvent = evt.detail.event;
const isRightClick = mouseUpEvent.which === 3;
initContextMenu({
cornerstoneViewportService,
customizationService,
commandsManager,
});
const clickMethodHandler = isRightClick ? onRightClick : resetContextMenu;
clickMethodHandler(evt);
};
// const cancelContextMenuIfOpen = evt => {
// if (CONTEXT_MENU_OPEN) {
// resetContextMenu();
// }
// };
initDoubleClick({
customizationService,
commandsManager,
});
const newStackCallback = evt => {
const { element } = evt.detail;
@ -326,12 +251,6 @@ export default async function init({
function elementEnabledHandler(evt) {
const { element } = evt.detail;
element.addEventListener(
cs3DToolsEvents.MOUSE_CLICK,
contextMenuHandleClick
);
element.addEventListener(EVENTS.CAMERA_RESET, resetCrosshairs);
eventTarget.addEventListener(
@ -343,11 +262,6 @@ export default async function init({
function elementDisabledHandler(evt) {
const { element } = evt.detail;
element.removeEventListener(
cs3DToolsEvents.MOUSE_CLICK,
contextMenuHandleClick
);
element.removeEventListener(EVENTS.CAMERA_RESET, resetCrosshairs);
// TODO - consider removing the callback when all elements are gone
@ -369,8 +283,8 @@ export default async function init({
viewportGridService.subscribe(
viewportGridService.EVENTS.ACTIVE_VIEWPORT_INDEX_CHANGED,
({ viewportIndex }) => {
const viewportId = `viewport-${viewportIndex}`;
({ viewportIndex, viewportId }) => {
viewportId = viewportId || `viewport-${viewportIndex}`;
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
if (!toolGroup || !toolGroup._toolInstances?.['ReferenceLines']) {
@ -427,9 +341,9 @@ function _showCPURenderingModal(uiModalService, hangingProtocolService) {
};
const { unsubscribe } = hangingProtocolService.subscribe(
hangingProtocolService.EVENTS.HANGING_PROTOCOL_APPLIED_FOR_VIEWPORT,
({ progress }) => {
const done = callback(progress);
hangingProtocolService.EVENTS.PROTOCOL_CHANGED,
() => {
const done = callback(100);
if (done) {
unsubscribe();

View File

@ -0,0 +1,107 @@
import { eventTarget, EVENTS } from '@cornerstonejs/core';
import { Enums } from '@cornerstonejs/tools';
import { setEnabledElement } from './state';
import { findNearbyToolData } from './utils/findNearbyToolData';
const cs3DToolsEvents = Enums.Events;
const DEFAULT_CONTEXT_MENU_CLICKS = {
button1: {
commands: [
{
commandName: 'closeContextMenu',
},
],
},
button3: {
commands: [
{
commandName: 'showCornerstoneContextMenu',
commandOptions: {
menuId: 'measurementsContextMenu',
},
},
],
},
};
/**
* Generates a name, consisting of:
* * alt when the alt key is down
* * ctrl when the cctrl key is down
* * shift when the shift key is down
* * 'button' followed by the button number (1 left, 3 right etc)
*/
function getEventName(evt) {
const button = evt.detail.event.which;
const nameArr = [];
if (evt.detail.event.altKey) nameArr.push('alt');
if (evt.detail.event.ctrlKey) nameArr.push('ctrl');
if (evt.detail.event.shiftKey) nameArr.push('shift');
nameArr.push('button');
nameArr.push(button);
return nameArr.join('');
}
function initContextMenu({
cornerstoneViewportService,
customizationService,
commandsManager,
}): void {
/*
* Run the commands associated with the given button press,
* defaults on button1 and button2
*/
const cornerstoneViewportHandleEvent = (name, evt) => {
const customizations =
customizationService.get('cornerstoneViewportClickCommands') ||
DEFAULT_CONTEXT_MENU_CLICKS;
const toRun = customizations[name];
console.log('initContextMenu::cornerstoneViewportHandleEvent', name, toRun);
const options = {
nearbyToolData: findNearbyToolData(commandsManager, evt),
event: evt,
};
commandsManager.run(toRun, options);
};
const cornerstoneViewportHandleClick = evt => {
const name = getEventName(evt);
cornerstoneViewportHandleEvent(name, evt);
};
function elementEnabledHandler(evt) {
const { viewportId, element } = evt.detail;
const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId);
if (!viewportInfo) return;
const viewportIndex = viewportInfo.getViewportIndex();
// TODO check update upstream
setEnabledElement(viewportIndex, element);
element.addEventListener(
cs3DToolsEvents.MOUSE_CLICK,
cornerstoneViewportHandleClick
);
}
function elementDisabledHandler(evt) {
const { element } = evt.detail;
element.removeEventListener(
cs3DToolsEvents.MOUSE_CLICK,
cornerstoneViewportHandleClick
);
}
eventTarget.addEventListener(
EVENTS.ELEMENT_ENABLED,
elementEnabledHandler.bind(null)
);
eventTarget.addEventListener(
EVENTS.ELEMENT_DISABLED,
elementDisabledHandler.bind(null)
);
}
export default initContextMenu;

View File

@ -23,6 +23,7 @@ import {
addTool,
annotation,
ReferenceLinesTool,
TrackballRotateTool,
} from '@cornerstonejs/tools';
import CalibrationLineTool from './tools/CalibrationLineTool';
@ -51,6 +52,7 @@ export default function initCornerstoneTools(configuration = {}) {
addTool(SegmentationDisplayTool);
addTool(ReferenceLinesTool);
addTool(CalibrationLineTool);
addTool(TrackballRotateTool);
// Modify annotation tools to use dashed lines on SR
const annotationStyle = {
@ -90,6 +92,7 @@ const toolNames = {
SegmentationDisplay: SegmentationDisplayTool.toolName,
ReferenceLines: ReferenceLinesTool.toolName,
CalibrationLine: CalibrationLineTool.toolName,
TrackballRotateTool: TrackballRotateTool.toolName,
};
export { toolNames };

View File

@ -0,0 +1,92 @@
import { eventTarget, EVENTS } from '@cornerstonejs/core';
import { Enums } from '@cornerstonejs/tools';
import { CommandsManager, CustomizationService, Types } from '@ohif/core';
import { findNearbyToolData } from './utils/findNearbyToolData';
const cs3DToolsEvents = Enums.Events;
const DEFAULT_DOUBLE_CLICK = {
doubleClick: {
commandName: 'toggleOneUp',
commandOptions: {},
},
};
/**
* Generates a double click event name, consisting of:
* * alt when the alt key is down
* * ctrl when the cctrl key is down
* * shift when the shift key is down
* * 'doubleClick'
*/
function getDoubleClickEventName(evt: CustomEvent) {
const nameArr = [];
if (evt.detail.event.altKey) nameArr.push('alt');
if (evt.detail.event.ctrlKey) nameArr.push('ctrl');
if (evt.detail.event.shiftKey) nameArr.push('shift');
nameArr.push('doubleClick');
return nameArr.join('');
}
export type initDoubleClickArgs = {
customizationService: CustomizationService;
commandsManager: CommandsManager;
};
function initDoubleClick({
customizationService,
commandsManager,
}: initDoubleClickArgs): void {
const cornerstoneViewportHandleDoubleClick = (evt: CustomEvent) => {
// Do not allow double click on a tool.
const nearbyToolData = findNearbyToolData(commandsManager, evt);
if (nearbyToolData) {
return;
}
const eventName = getDoubleClickEventName(evt);
// Allows for the customization of the double click on a viewport.
const customizations =
customizationService.get('cornerstoneViewportClickCommands') ||
DEFAULT_DOUBLE_CLICK;
const toRun = customizations[eventName];
if (!toRun) {
return;
}
commandsManager.run(toRun);
};
function elementEnabledHandler(evt: CustomEvent) {
const { element } = evt.detail;
element.addEventListener(
cs3DToolsEvents.MOUSE_DOUBLE_CLICK,
cornerstoneViewportHandleDoubleClick
);
}
function elementDisabledHandler(evt: CustomEvent) {
const { element } = evt.detail;
element.removeEventListener(
cs3DToolsEvents.MOUSE_DOUBLE_CLICK,
cornerstoneViewportHandleDoubleClick
);
}
eventTarget.addEventListener(
EVENTS.ELEMENT_ENABLED,
elementEnabledHandler.bind(null)
);
eventTarget.addEventListener(
EVENTS.ELEMENT_DISABLED,
elementDisabledHandler.bind(null)
);
}
export default initDoubleClick;

View File

@ -62,12 +62,20 @@ class CornerstoneCacheService {
viewportData = await this._getStackViewportData(
dataSource,
displaySets,
initialImageIndex
initialImageIndex,
cs3DViewportType
);
}
if (cs3DViewportType === Enums.ViewportType.ORTHOGRAPHIC) {
viewportData = await this._getVolumeViewportData(dataSource, displaySets);
if (
cs3DViewportType === Enums.ViewportType.ORTHOGRAPHIC ||
cs3DViewportType === Enums.ViewportType.VOLUME_3D
) {
viewportData = await this._getVolumeViewportData(
dataSource,
displaySets,
cs3DViewportType
);
}
viewportData.viewportType = cs3DViewportType;
@ -100,7 +108,8 @@ class CornerstoneCacheService {
const newViewportData = await this._getVolumeViewportData(
dataSource,
displaySets
displaySets,
viewportData.viewportType
);
return newViewportData;
@ -109,7 +118,8 @@ class CornerstoneCacheService {
private _getStackViewportData(
dataSource,
displaySets,
initialImageIndex
initialImageIndex,
viewportType: Enums.ViewportType
): StackViewportData {
// For Stack Viewport we don't have fusion currently
const displaySet = displaySets[0];
@ -126,7 +136,7 @@ class CornerstoneCacheService {
const { displaySetInstanceUID, StudyInstanceUID } = displaySet;
const StackViewportData: StackViewportData = {
viewportType: Enums.ViewportType.STACK,
viewportType,
data: {
StudyInstanceUID,
displaySetInstanceUID,
@ -143,7 +153,8 @@ class CornerstoneCacheService {
private async _getVolumeViewportData(
dataSource,
displaySets
displaySets,
viewportType: Enums.ViewportType
): Promise<VolumeViewportData> {
// Todo: Check the cache for multiple scenarios to see if we need to
// decache the volume data from other viewports or not
@ -207,7 +218,7 @@ class CornerstoneCacheService {
}
return {
viewportType: Enums.ViewportType.ORTHOGRAPHIC,
viewportType,
data: volumeData,
};
}

View File

@ -1,22 +1,22 @@
import cloneDeep from 'lodash.clonedeep';
import { pubSubServiceInterface } from '@ohif/core';
import {
utilities as cstUtils,
segmentation as cstSegmentation,
CONSTANTS as cstConstants,
Enums as csToolsEnums,
Types as cstTypes,
} from '@cornerstonejs/tools';
import {
eventTarget,
cache,
eventTarget,
getEnabledElementByIds,
metaData,
Types,
utilities as csUtils,
volumeLoader,
Types,
metaData,
getEnabledElementByIds,
} from '@cornerstonejs/core';
import {
CONSTANTS as cstConstants,
Enums as csToolsEnums,
segmentation as cstSegmentation,
Types as cstTypes,
utilities as cstUtils,
} from '@cornerstonejs/tools';
import { pubSubServiceInterface } from '@ohif/core';
import isEqual from 'lodash.isequal';
import { easeInOutBell } from '../../utils/transitions';
import {
@ -202,7 +202,7 @@ class SegmentationService {
this._setActiveSegment(segmentationId, segmentIndex, suppressEvents);
}
// Todo: this includes nonhydrated segmentations which might not be
// Todo: this includes non-hydrated segmentations which might not be
// persisted in the store
this._broadcastEvent(this.EVENTS.SEGMENTATION_UPDATED, {
segmentation,
@ -1591,13 +1591,20 @@ class SegmentationService {
segmentInfo.isVisible = isVisible;
cstSegmentation.config.visibility.setVisibilityForSegmentIndex(
cstSegmentation.config.visibility.setSegmentVisibility(
toolGroupId,
segmentationRepresentationUID,
segmentIndex,
isVisible
);
// make sure to update the isVisible flag on the segmentation
// if a segment becomes invisible then the segmentation should be invisible
// in the status as well, and show correct icon
segmentation.isVisible = segmentation.segments
.filter(Boolean)
.every(segment => segment.isVisible);
if (suppressEvents === false) {
this._broadcastEvent(this.EVENTS.SEGMENTATION_UPDATED, {
segmentation,
@ -1917,28 +1924,27 @@ class SegmentationService {
representation => representation.segmentationId === segmentationId
);
const visibility = cstSegmentation.config.visibility.getSegmentationVisibility(
toolGroupId,
representation.segmentationRepresentationUID
);
const { segmentsHidden } = representation;
const currentVisibility = segmentsHidden.size === 0 ? true : false;
const newVisibility = !currentVisibility;
cstSegmentation.config.visibility.setSegmentationVisibility(
toolGroupId,
representation.segmentationRepresentationUID,
!visibility
newVisibility
);
// set all segments to visible as well
const segments = this.getSegmentation(segmentationId).segments;
Object.keys(segments).forEach(segmentIndex => {
if (segmentIndex !== '0') {
this._setSegmentVisibility(
segmentationId,
Number(segmentIndex),
!visibility,
toolGroupId
);
}
// update segments visibility
const { segmentation } = this._getSegmentationInfo(
segmentationId,
toolGroupId
);
const segments = segmentation.segments.filter(Boolean);
segments.forEach(segment => {
segment.isVisible = newVisibility;
});
});
};

View File

@ -1,4 +1,4 @@
import { pubSubServiceInterface } from '@ohif/core';
import { PubSubService } from '@ohif/core';
import {
RenderingEngine,
StackViewport,
@ -6,7 +6,10 @@ import {
getRenderingEngine,
utilities as csUtils,
VolumeViewport,
VolumeViewport3D,
cache,
utilities,
CONSTANTS,
} from '@cornerstonejs/core';
import { utilities as csToolsUtils } from '@cornerstonejs/tools';
@ -21,6 +24,7 @@ import {
StackViewportData,
VolumeViewportData,
} from '../../types/CornerstoneCacheService';
import { Presentation, Presentations } from '../../types/Presentation';
import {
setColormap,
setLowerUpperColorTransferFunction,
@ -37,18 +41,14 @@ const EVENTS = {
* Handles cornerstone viewport logic including enabling, disabling, and
* updating the viewport.
*/
class CornerstoneViewportService implements IViewportService {
class CornerstoneViewportService extends PubSubService
implements IViewportService {
renderingEngine: Types.IRenderingEngine | null;
viewportsInfo: Map<number, ViewportInfo>;
viewportsInfo: Map<number, ViewportInfo> = new Map();
viewportsById: Map<string, ViewportInfo> = new Map();
viewportGridResizeObserver: ResizeObserver | null;
viewportsDisplaySets: Map<string, string[]> = new Map();
/**
* Service-specific
*/
EVENTS: { [key: string]: string };
listeners: { [key: string]: Array<(...args: any[]) => void> };
_broadcastEvent: unknown; // we should be able to extend the PubSub class to get this
// Some configs
enableResizeDetector: true;
resizeRefreshRateMs: 200;
@ -56,15 +56,10 @@ class CornerstoneViewportService implements IViewportService {
servicesManager = null;
constructor(servicesManager) {
super(EVENTS);
this.renderingEngine = null;
this.viewportGridResizeObserver = null;
this.viewportsInfo = new Map();
//
this.listeners = {};
this.EVENTS = EVENTS;
this.servicesManager = servicesManager;
Object.assign(this, pubSubServiceInterface);
//
}
/**
@ -77,12 +72,23 @@ class CornerstoneViewportService implements IViewportService {
viewportOptions: PublicViewportOptions,
elementRef: HTMLDivElement
) {
const viewportInfo = new ViewportInfo(
viewportIndex,
this.getViewportId(viewportIndex)
);
// Use the provided viewportId
// Not providing a viewportId is frowned upon because it does weird things
// on moving them around, but it does mostly work.
if (!viewportOptions.viewportId) {
console.warn('Should provide viewport id externally', viewportOptions);
viewportOptions.viewportId =
this.getViewportId(viewportIndex) || `viewport-${viewportIndex}`;
}
const { viewportId } = viewportOptions;
const viewportInfo = new ViewportInfo(viewportIndex, viewportId);
if (!viewportInfo.viewportId) {
throw new Error('Should have viewport ID afterwards');
}
viewportInfo.setElement(elementRef);
this.viewportsInfo.set(viewportIndex, viewportInfo);
this.viewportsById.set(viewportId, viewportInfo);
}
public getViewportIds(): string[] {
@ -96,7 +102,7 @@ class CornerstoneViewportService implements IViewportService {
}
public getViewportId(viewportIndex: number): string {
return `viewport-${viewportIndex}`;
return this.viewportsInfo[viewportIndex]?.viewportId;
}
/**
@ -149,9 +155,14 @@ class CornerstoneViewportService implements IViewportService {
/**
* Disables the viewport inside the renderingEngine, if no viewport is left
* it destroys the renderingEngine.
*
* This is called when the element goes away entirely - with new viewportId's
* created for every new viewport, this will be called whenever the set of
* viewports is changed, but NOT when the viewport position changes only.
*
* @param viewportIndex
*/
public disableElement(viewportIndex: number) {
public disableElement(viewportIndex: number): void {
const viewportInfo = this.viewportsInfo.get(viewportIndex);
if (!viewportInfo) {
return;
@ -163,6 +174,39 @@ class CornerstoneViewportService implements IViewportService {
this.viewportsInfo.get(viewportIndex).destroy();
this.viewportsInfo.delete(viewportIndex);
this.viewportsById.delete(viewportId);
}
public setPresentations(viewport, presentations?: Presentations): void {
const properties = presentations?.lutPresentation?.properties;
if (properties) viewport.setProperties(properties);
const camera = presentations?.positionPresentation?.camera;
if (camera) viewport.setCamera(camera);
}
public getPresentation(viewportIndex: number): Presentation {
const viewportInfo = this.viewportsInfo.get(viewportIndex);
if (!viewportInfo) return;
const { viewportType, presentationIds } = viewportInfo.getViewportOptions();
const csViewport = this.getCornerstoneViewportByIndex(viewportIndex);
if (!csViewport) return;
const properties = csViewport.getProperties();
if (properties.isComputedVOI) {
delete properties.voiRange;
delete properties.VOILUTFunction;
}
const initialImageIndex = csViewport.getCurrentImageIdIndex();
const camera = csViewport.getCamera();
return {
presentationIds,
viewportType:
!viewportType || viewportType === 'stack' ? 'stack' : 'volume',
properties,
initialImageIndex,
camera,
};
}
/**
@ -177,29 +221,27 @@ class CornerstoneViewportService implements IViewportService {
viewportIndex: number,
viewportData: StackViewportData | VolumeViewportData,
publicViewportOptions: PublicViewportOptions,
publicDisplaySetOptions: DisplaySetOptions[]
publicDisplaySetOptions: DisplaySetOptions[],
presentations?: Presentations
): void {
const renderingEngine = this.getRenderingEngine();
const viewportInfo = this.viewportsInfo.get(viewportIndex);
if (!publicViewportOptions.viewportId) {
publicViewportOptions.viewportId = this.getViewportId(viewportIndex);
const viewportId =
publicViewportOptions.viewportId || this.getViewportId(viewportIndex);
if (!viewportId) {
throw new Error('Must define viewportId externally');
}
let viewportId = viewportInfo.getViewportId();
const viewportInfo = this.viewportsById.get(viewportId);
// if currently there is a viewport with the viewportId, but it is not the same
// as the one we are trying to set, we need to disable the old one
// and enable the new one, we could ideally change the name of the viewportId
// but the viewportId is an integral part in renderers map, tools svg cache
// etc. which would require a lot of refactoring, for now we will just disable
// the old one and enable the new one at the end of this function
let newViewportId = null;
if (publicViewportOptions?.viewportId !== viewportId) {
newViewportId = publicViewportOptions.viewportId;
viewportInfo.setViewportId(newViewportId);
if (!viewportInfo) {
throw new Error('Viewport info not defined');
}
renderingEngine.disableElement(viewportId);
// If the viewport has moved index, then record the new index
if (viewportInfo.viewportIndex !== viewportIndex) {
this.viewportsInfo.delete(viewportInfo.viewportIndex);
this.viewportsInfo.set(viewportIndex, viewportInfo);
viewportInfo.viewportIndex = viewportIndex;
}
viewportInfo.setRenderingEngineId(renderingEngine.id);
@ -217,12 +259,6 @@ class CornerstoneViewportService implements IViewportService {
viewportInfo.setDisplaySetOptions(displaySetOptions);
viewportInfo.setViewportData(viewportData);
this._broadcastEvent(this.EVENTS.VIEWPORT_DATA_CHANGED, {
viewportData,
viewportIndex,
});
viewportId = viewportInfo.getViewportId();
const element = viewportInfo.getElement();
const type = viewportInfo.getViewportType();
const background = viewportInfo.getBackground();
@ -245,7 +281,16 @@ class CornerstoneViewportService implements IViewportService {
renderingEngine.enableElement(viewportInput);
const viewport = renderingEngine.getViewport(viewportId);
this._setDisplaySets(viewport, viewportData, viewportInfo);
this._setDisplaySets(viewport, viewportData, viewportInfo, presentations);
// The broadcast event here ensures that listeners have a valid, up to date
// viewport to access. Doing it too early can result in exceptions or
// invalid data.
this._broadcastEvent(this.EVENTS.VIEWPORT_DATA_CHANGED, {
viewportData,
viewportIndex,
viewportId,
});
}
public getCornerstoneViewport(
@ -308,8 +353,9 @@ class CornerstoneViewportService implements IViewportService {
_setStackViewport(
viewport: Types.IStackViewport,
viewportData: StackViewportData,
viewportInfo: ViewportInfo
) {
viewportInfo: ViewportInfo,
presentations: Presentations
): void {
const displaySetOptions = viewportInfo.getDisplaySetOptions();
const {
@ -320,29 +366,38 @@ class CornerstoneViewportService implements IViewportService {
this.viewportsDisplaySets.set(viewport.id, [displaySetInstanceUID]);
let initialImageIndexToUse = initialImageIndex;
let initialImageIndexToUse =
presentations?.positionPresentation?.initialImageIndex ??
initialImageIndex;
if (!initialImageIndexToUse) {
if (
initialImageIndexToUse === undefined ||
initialImageIndexToUse === null
) {
initialImageIndexToUse =
this._getInitialImageIndexForStackViewport(viewportInfo, imageIds) || 0;
}
const { voi, voiInverted } = displaySetOptions[0];
const properties = {};
if (voi && (voi.windowWidth || voi.windowCenter)) {
const { lower, upper } = csUtils.windowLevel.toLowHighRange(
voi.windowWidth,
voi.windowCenter
);
properties.voiRange = { lower, upper };
}
const properties = { ...presentations.lutPresentation?.properties };
if (!presentations.lutPresentation?.properties) {
const { voi, voiInverted } = displaySetOptions[0];
if (voi && (voi.windowWidth || voi.windowCenter)) {
const { lower, upper } = csUtils.windowLevel.toLowHighRange(
voi.windowWidth,
voi.windowCenter
);
properties.voiRange = { lower, upper };
}
if (voiInverted !== undefined) {
properties.invert = voiInverted;
if (voiInverted !== undefined) {
properties.invert = voiInverted;
}
}
viewport.setStack(imageIds, initialImageIndexToUse).then(() => {
viewport.setProperties(properties);
const camera = presentations.positionPresentation?.camera;
if (camera) viewport.setCamera(camera);
});
}
@ -397,7 +452,8 @@ class CornerstoneViewportService implements IViewportService {
async _setVolumeViewport(
viewport: Types.IVolumeViewport,
viewportData: VolumeViewportData,
viewportInfo: ViewportInfo
viewportInfo: ViewportInfo,
presentations: Presentations
): Promise<void> {
// TODO: We need to overhaul the way data sources work so requests can be made
// async. I think we should follow the image loader pattern which is async and
@ -423,6 +479,7 @@ class CornerstoneViewportService implements IViewportService {
displaySetInstanceUIDs.push(displaySetInstanceUID);
if (!volume) {
console.log('Volume display set not found');
continue;
}
@ -460,14 +517,24 @@ class CornerstoneViewportService implements IViewportService {
}
volumeToLoad.forEach(volume => {
volume.load();
if (!volume.loadStatus.loaded && !volume.loadStatus.loading) {
volume.load();
}
});
// This returns the async continuation only
return this.setVolumesForViewport(viewport, volumeInputArray);
return this.setVolumesForViewport(
viewport,
volumeInputArray,
presentations
);
}
public async setVolumesForViewport(viewport, volumeInputArray) {
public async setVolumesForViewport(
viewport,
volumeInputArray,
presentations
) {
const {
displaySetService,
segmentationService,
@ -475,6 +542,7 @@ class CornerstoneViewportService implements IViewportService {
} = this.servicesManager.services;
await viewport.setVolumes(volumeInputArray);
this.setPresentations(viewport, presentations);
// load any secondary displaySets
const displaySetInstanceUIDs = this.viewportsDisplaySets.get(viewport.id);
@ -557,6 +625,10 @@ class CornerstoneViewportService implements IViewportService {
const viewportInfo = this.getViewportInfo(viewport.id);
if (!viewportInfo) {
console.warn('Viewport info not defined for', viewport.id);
}
const toolGroup = toolGroupService.getToolGroupForViewport(viewport.id);
csToolsUtils.segmentation.triggerSegmentationRender(toolGroup.id);
@ -589,7 +661,11 @@ class CornerstoneViewportService implements IViewportService {
// Todo: keepCamera is an interim solution until we have a better solution for
// keeping the camera position when the viewport data is changed
public updateViewport(viewportIndex, viewportData, keepCamera = false) {
public updateViewport(
viewportIndex: number,
viewportData,
keepCamera = false
) {
const viewportInfo = this.getViewportInfoByIndex(viewportIndex);
const viewportId = viewportInfo.getViewportId();
@ -614,7 +690,12 @@ class CornerstoneViewportService implements IViewportService {
}
_getVOICallbacks(volumeId, displaySetOptions) {
const { voi, voiInverted: inverted, colormap } = displaySetOptions;
const {
voi,
voiInverted: inverted,
colormap,
presetName,
} = displaySetOptions;
const voiCallbackArray = [];
@ -639,25 +720,41 @@ class CornerstoneViewportService implements IViewportService {
);
}
if (presetName) {
voiCallbackArray.push(volumeActor => {
utilities.applyPreset(
volumeActor,
CONSTANTS.VIEWPORT_PRESETS.find(preset => {
return preset.name === presetName;
})
);
});
}
return voiCallbackArray;
}
_setDisplaySets(
viewport: StackViewport | VolumeViewport,
viewportData: StackViewportData | VolumeViewportData,
viewportInfo: ViewportInfo
viewportInfo: ViewportInfo,
presentations: Presentations = {}
): void {
if (viewport instanceof StackViewport) {
this._setStackViewport(
viewport,
viewportData as StackViewportData,
viewportInfo
viewportInfo,
presentations
);
} else if (viewport instanceof VolumeViewport) {
} else if (
viewport instanceof VolumeViewport ||
viewport instanceof VolumeViewport3D
) {
this._setVolumeViewport(
viewport,
viewportData as VolumeViewportData,
viewportInfo
viewportInfo,
presentations
);
} else {
throw new Error('Unknown viewport type');
@ -758,7 +855,7 @@ class CornerstoneViewportService implements IViewportService {
}
}
export default function ExtendedCornerstoneViewportService(serviceManager) {
export default function CornerstoneViewportServiceRegistration(serviceManager) {
return {
name: 'cornerstoneViewportService',
altName: 'CornerstoneViewportService',
@ -767,3 +864,5 @@ export default function ExtendedCornerstoneViewportService(serviceManager) {
},
};
}
export { CornerstoneViewportService, CornerstoneViewportServiceRegistration };

View File

@ -1,13 +1,14 @@
import { Types, Enums } from '@cornerstonejs/core';
import { Types as UITypes } from '@ohif/ui';
import {
StackViewportData,
VolumeViewportData,
} from '../../types/CornerstoneCacheService';
import getCornerstoneBlendMode from '../../utils/getCornerstoneBlendMode';
import getCornerstoneOrientation from '../../utils/getCornerstoneOrientation';
import getCornerstoneViewportType from '../../utils/getCornerstoneViewportType';
import JumpPresets from '../../utils/JumpPresets';
import { SyncGroup } from '../SyncGroupService/SyncGroupService';
import {
StackViewportData,
VolumeViewportData,
} from '../../types/CornerstoneCacheService';
export type InitialImageOptions = {
index?: number;
@ -15,10 +16,13 @@ export type InitialImageOptions = {
};
export type ViewportOptions = {
id?: string;
viewportType: Enums.ViewportType;
toolGroupId: string;
viewportId: string;
orientation?: Types.Orientation;
// Presentation ID to store/load presentation state from
presentationIds?: UITypes.PresentationIds;
orientation?: Enums.OrientationAxis;
background?: Types.Point3;
syncGroups?: SyncGroup[];
initialImageOptions?: InitialImageOptions;
@ -31,10 +35,12 @@ export type ViewportOptions = {
};
export type PublicViewportOptions = {
id?: string;
viewportType?: string;
toolGroupId?: string;
presentationIds?: UITypes.PresentationIds;
viewportId?: string;
orientation?: string;
orientation?: Enums.OrientationAxis;
background?: Types.Point3;
syncGroups?: SyncGroup[];
initialImageOptions?: InitialImageOptions;
@ -42,20 +48,32 @@ export type PublicViewportOptions = {
allowUnmatchedView?: boolean;
};
export type DisplaySetSelector = {
id?: string;
options?: PublicDisplaySetOptions;
};
export type PublicDisplaySetOptions = {
/** The display set options can have an id in order to distinguish
* it from other similar items.
*/
id?: string;
voi?: VOI;
voiInverted?: boolean;
blendMode?: string;
slabThickness?: number;
colormap?: string;
presetName?: string;
};
export type DisplaySetOptions = {
id?: string;
voi?: VOI;
voiInverted: boolean;
blendMode?: Enums.BlendModes;
slabThickness?: number;
colormap?: string;
presetName?: string;
};
type VOI = {
@ -68,7 +86,6 @@ export type DisplaySet = {
};
const STACK = 'stack';
const VOLUME = 'volume';
const DEFAULT_TOOLGROUP_ID = 'default';
class ViewportInfo {
@ -136,7 +153,7 @@ class ViewportInfo {
}
public setPublicDisplaySetOptions(
publicDisplaySetOptions: Array<PublicDisplaySetOptions>
publicDisplaySetOptions: PublicDisplaySetOptions[] | DisplaySetSelector[]
): void {
// map the displaySetOptions and check if they are undefined then set them to default values
const displaySetOptions = this.mapDisplaySetOptions(
@ -167,7 +184,10 @@ class ViewportInfo {
viewportOptionsEntry: PublicViewportOptions
): void {
let viewportType = viewportOptionsEntry.viewportType;
let toolGroupId = viewportOptionsEntry.toolGroupId;
const {
toolGroupId = DEFAULT_TOOLGROUP_ID,
presentationIds,
} = viewportOptionsEntry;
let orientation;
if (!viewportType) {
@ -179,14 +199,8 @@ class ViewportInfo {
}
// map SAGITTAL, AXIAL, CORONAL orientation to be used by cornerstone
if (viewportOptionsEntry.viewportType?.toLowerCase() === VOLUME) {
if (viewportOptionsEntry.viewportType?.toLowerCase() !== STACK) {
orientation = getCornerstoneOrientation(viewportOptionsEntry.orientation);
} else {
orientation = Enums.OrientationAxis.AXIAL;
}
if (!toolGroupId) {
toolGroupId = DEFAULT_TOOLGROUP_ID;
}
this.setViewportOptions({
@ -195,6 +209,7 @@ class ViewportInfo {
viewportType: viewportType as Enums.ViewportType,
orientation,
toolGroupId,
presentationIds,
});
}
@ -232,7 +247,7 @@ class ViewportInfo {
return this.viewportOptions.background || [0, 0, 0];
}
public getOrientation(): Types.Orientation {
public getOrientation(): Enums.OrientationAxis {
return this.viewportOptions.orientation;
}
@ -240,12 +255,15 @@ class ViewportInfo {
return this.viewportOptions.initialImageOptions;
}
// Handle incoming public display set options or a display set select
// with a contained options.
private mapDisplaySetOptions(
publicDisplaySetOptions: Array<PublicDisplaySetOptions>
options: PublicDisplaySetOptions[] | DisplaySetSelector[] = [{}]
): Array<DisplaySetOptions> {
const displaySetOptions: Array<DisplaySetOptions> = [];
publicDisplaySetOptions.forEach(option => {
options.forEach(item => {
let option = item?.options || item;
if (!option) {
option = {
blendMode: undefined,
@ -263,6 +281,7 @@ class ViewportInfo {
colormap: option.colormap,
slabThickness: option.slabThickness,
blendMode,
presetName: option.presetName,
});
});

View File

@ -1,9 +1,10 @@
import { metaData } from '@cornerstonejs/core';
import { LengthTool } from '@cornerstonejs/tools';
import { calibrateImageSpacing } from '@cornerstonejs/tools/dist/esm/utilities';
import { LengthTool, utilities } from '@cornerstonejs/tools';
import callInputDialog from '../utils/callInputDialog';
import getActiveViewportEnabledElement from '../utils/getActiveViewportEnabledElement';
const { calibrateImageSpacing } = utilities;
/**
* Calibration Line tool works almost the same as the
*/

View File

@ -0,0 +1,23 @@
/** Store presentation data for either stack viewports or volume viewports */
import { Types } from '@cornerstonejs/core';
import { Types as UITypes } from '@ohif/ui';
/**
* Has information on the presentation of the viewport.
*/
export interface Presentation extends Types.StackViewportProperties {
presentationIds: UITypes.PresentationIds;
viewportType: string;
initialImageIndex: number;
camera: Types.ICamera;
properties: Types.StackViewportProperties | Types.VolumeViewportProperties;
zoom?: number;
pan?: [number, number];
}
export type Presentations = {
positionPresentation?: Presentation;
lutPresentation?: Presentation;
};
export default Presentation;

View File

@ -0,0 +1,21 @@
/**
* Finds tool nearby event position triggered.
*
* @param {Object} commandsManager mannager of commands
* @param {Object} event that has being triggered
* @returns cs toolData or undefined if not found.
*/
export const findNearbyToolData = (commandsManager, evt) => {
if (!evt?.detail) {
return;
}
const { element, currentPoints } = evt.detail;
return commandsManager.runCommand(
'getNearbyToolData',
{
element,
canvasCoordinates: currentPoints?.canvas,
},
'CORNERSTONE'
);
};

View File

@ -1,5 +1,4 @@
import { Enums } from '@cornerstonejs/core';
import { log } from '@ohif/core';
const AXIAL = 'axial';
const SAGITTAL = 'sagittal';

View File

@ -2,18 +2,25 @@ import { Enums } from '@cornerstonejs/core';
const STACK = 'stack';
const VOLUME = 'volume';
const ORTHOGRAPHIC = 'orthographic';
const VOLUME_3D = 'volume3d';
export default function getCornerstoneViewportType(
viewportType: string
): Enums.ViewportType {
if (viewportType.toLowerCase() === STACK) {
const lowerViewportType = viewportType.toLowerCase();
if (lowerViewportType === STACK) {
return Enums.ViewportType.STACK;
}
if (viewportType.toLowerCase() === VOLUME) {
if (lowerViewportType === VOLUME || lowerViewportType === ORTHOGRAPHIC) {
return Enums.ViewportType.ORTHOGRAPHIC;
}
if (lowerViewportType === VOLUME_3D) {
return Enums.ViewportType.VOLUME_3D;
}
throw new Error(
`Invalid viewport type: ${viewportType}. Valid types are: stack, volume`
);

View File

@ -1,301 +0,0 @@
import { Enums } from '@cornerstonejs/tools';
import removeToolGroupSegmentationRepresentations from '../removeToolGroupSegmentationRepresentations';
const MPR_TOOLGROUP_ID = 'mpr';
const cachedState = {
protocol: null,
stage: null,
viewportMatchDetails: null,
viewportStructure: null,
toolOptions: null,
};
const setCachedState = (
protocol,
stage,
viewportMatchDetails,
viewportStructure,
toolOptions
) => {
cachedState.protocol = protocol;
cachedState.stage = stage;
cachedState.viewportMatchDetails = viewportMatchDetails;
cachedState.viewportStructure = viewportStructure;
cachedState.toolOptions = JSON.parse(JSON.stringify(toolOptions));
};
const resetCachedState = () => {
cachedState.protocol = null;
cachedState.stage = null;
cachedState.viewportMatchDetails = null;
cachedState.viewportStructure = null;
cachedState.toolOptions = null;
};
export default function toggleMPRHangingProtocol({
toggledState,
servicesManager,
getToolGroup,
}) {
const {
uiNotificationService,
hangingProtocolService,
viewportGridService,
toolbarService,
} = servicesManager.services;
// TODO Introduce a service to persist the state of the current hanging protocol/app.
// So all of the code to persist the state here will no longer be needed. Perhaps
// just the id of the current hanging protocol to toggle MPR off is needed.
const {
activeViewportIndex,
viewports,
numRows,
numCols,
} = viewportGridService.getState();
const viewportDisplaySetInstanceUIDs =
viewports[activeViewportIndex].displaySetInstanceUIDs;
// What is the current active protocol and stage number to restore later
const { protocol, stage } = hangingProtocolService.getActiveProtocol();
const restoreErrorCallback = error => {
console.error(error);
uiNotificationService.show({
title: 'Multiplanar reconstruction (MPR) ',
message:
'Something went wrong while trying to restore the previous layout.',
type: 'info',
duration: 3000,
});
};
if (toggledState) {
resetCachedState();
const {
viewportMatchDetails,
viewportStructure,
toolOptions,
} = _getViewportsInfo({
protocol,
stage,
viewports,
servicesManager,
});
setCachedState(
protocol,
stage,
viewportMatchDetails,
viewportStructure,
toolOptions
);
const matchDetails = {
displaySetInstanceUIDs: viewportDisplaySetInstanceUIDs,
};
_disableCrosshairs(
toolOptions.map(({ toolGroupId }) => toolGroupId),
getToolGroup
);
const errorCallback = error => {
// Unable to create MPR, so be sure to return to the cached/original protocol.
hangingProtocolService.setProtocol(
cachedState.protocol.id,
viewportMatchDetails,
restoreErrorCallback
);
uiNotificationService.show({
title: 'Multiplanar reconstruction (MPR) ',
message:
'Cannot create MPR for this DisplaySet since it is not reconstructable.',
type: 'info',
duration: 3000,
});
};
hangingProtocolService.setProtocol(
MPR_TOOLGROUP_ID,
matchDetails,
errorCallback
);
return;
}
_disableCrosshairs([MPR_TOOLGROUP_ID], getToolGroup);
const { layoutType, properties } = cachedState.viewportStructure;
const { viewportMatchDetails } = cachedState;
// The reason we split the flow here is that we don't allow viewport grid
// change in the non default hanging protocol, so we can just apply the
// cached protocol and stage. However, for the default protocol, we need
// to also apply the layout type and properties.
if (cachedState.protocol.id !== 'default') {
hangingProtocolService.setProtocol(
cachedState.protocol.id,
viewportMatchDetails,
restoreErrorCallback
);
return;
}
hangingProtocolService.setProtocol(
'default',
viewportMatchDetails,
restoreErrorCallback
);
if (numRows !== properties.rows || numCols !== properties.columns) {
viewportGridService.setLayout({
numRows: properties.rows,
numCols: properties.columns,
layoutType,
layoutOptions: properties.layoutOptions,
});
}
const numViewports =
properties.layoutOptions.length || properties.rows * properties.columns;
// loop inside viewportMatchDetails map
// and set the viewportOptions for each viewport
[...Array(numViewports).keys()].forEach(viewportIndex => {
const viewportMatchDetailsForViewport = viewportMatchDetails.get(
viewportIndex
);
if (viewportMatchDetailsForViewport) {
const {
viewportOptions,
displaySetsInfo,
} = viewportMatchDetailsForViewport;
viewportGridService.setDisplaySetsForViewport({
viewportIndex,
displaySetInstanceUIDs: displaySetsInfo.map(
displaySetInfo => displaySetInfo.displaySetInstanceUID
),
viewportOptions,
});
} else {
viewportGridService.setDisplaySetsForViewport({
viewportIndex,
displaySetInstanceUIDs: [],
viewportOptions: {},
});
}
});
toolbarService.recordInteraction({
groupId: 'WindowLevel',
itemId: 'WindowLevel',
interactionType: 'tool',
commands: [
{
commandName: 'setToolActive',
commandOptions: {
toolName: 'WindowLevel',
},
context: 'CORNERSTONE',
},
],
});
//clear segmentations if they exist
removeToolGroupSegmentationRepresentations(MPR_TOOLGROUP_ID);
}
function _disableCrosshairs(toolGroupIds, getToolGroup) {
toolGroupIds.forEach(toolGroupId => {
const toolGroup = getToolGroup(toolGroupId);
if (
toolGroup.getToolInstance('Crosshairs')?.mode === Enums.ToolModes.Active
) {
toolGroup.setToolDisabled('Crosshairs');
}
});
}
function _getViewportsInfo({ protocol, stage, viewports, servicesManager }) {
// here we need to use the viewports and try to map it into the
// viewportMatchDetails and displaySetMatch that hangingProtocolService
// expects
const {
viewportGridService,
hangingProtocolService,
toolGroupService,
} = servicesManager.services;
const { numRows, numCols } = viewportGridService.getState();
let viewportMatchDetails = new Map();
const viewportStructure = {
layoutType: 'grid',
properties: {
rows: numRows,
columns: numCols,
layoutOptions: [],
},
};
viewports.forEach((viewport, viewportIndex) => {
viewportStructure.properties.layoutOptions.push({
x: viewport.x,
y: viewport.y,
width: viewport.width,
height: viewport.height,
});
});
if (protocol.id === 'default') {
viewports.forEach((viewport, viewportIndex) => {
if (viewport.displaySetInstanceUIDs) {
viewportMatchDetails.set(viewportIndex, {
displaySetsInfo: viewport.displaySetInstanceUIDs.map(
displaySetInstanceUID => {
return { displaySetInstanceUID };
}
),
viewportOptions: viewport.viewportOptions,
});
}
});
} else {
({ viewportMatchDetails } = hangingProtocolService.getMatchDetails());
}
// get the toolGroup state for viewports
let toolOptions = [];
const viewportIds = viewports
.map(
viewport =>
viewport.displaySetInstanceUIDs &&
viewport.displaySetInstanceUIDs.length > 0 &&
viewport.viewportOptions?.viewportId
)
.filter(Boolean);
if (viewportIds.length) {
toolOptions = viewportIds
.map(viewportId => {
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
return toolGroup
? {
toolGroupId: toolGroup.id,
toolOptions: toolGroup.toolOptions,
}
: null;
})
.filter(Boolean);
}
return { viewportMatchDetails, viewportStructure, toolOptions };
}

View File

@ -18,7 +18,6 @@ const viewportIdVolumeInputArrayMap = new Map<string, unknown[]>();
export default function interleaveNthLoader({
data: { viewportId, volumeInputArray },
displaySetsMatchDetails,
viewportMatchDetails: matchDetails,
}) {
viewportIdVolumeInputArrayMap.set(viewportId, volumeInputArray);
@ -29,6 +28,7 @@ export default function interleaveNthLoader({
const volume = cache.getVolume(volumeId);
if (!volume) {
console.log("interleaveNthLoader::No volume, can't load it");
return;
}
@ -39,33 +39,6 @@ export default function interleaveNthLoader({
}
}
/**
* The following is checking if all the viewports that were matched in the HP has been
* successfully created their cornerstone viewport or not. Todo: This can be
* improved by not checking it, and as soon as the matched DisplaySets have their
* volume loaded, we start the loading, but that comes at the cost of viewports
* not being created yet (e.g., in a 10 viewport ptCT fusion, when one ct viewport and one
* pt viewport are created we have a guarantee that the volumes are created in the cache
* but the rest of the viewports (fusion, mip etc.) are not created yet. So
* we can't initiate setting the volumes for those viewports. One solution can be
* to add an event when a viewport is created (not enabled element event) and then
* listen to it and as the other viewports are created we can set the volumes for them
* since volumes are already started loading.
*/
if (matchDetails.size !== viewportIdVolumeInputArrayMap.size) {
return;
}
// Check if all the matched volumes are loaded
for (const [_, details] of displaySetsMatchDetails.entries()) {
const { SeriesInstanceUID } = details;
// HangingProtocol has matched, but don't have all the volumes created yet, so return
if (!Array.from(volumeIdMapsToLoad.values()).includes(SeriesInstanceUID)) {
return;
}
}
const volumeIds = Array.from(volumeIdMapsToLoad.keys()).slice();
// get volumes from cache
const volumes = volumeIds.map(volumeId => {

View File

@ -6,7 +6,7 @@
"license": "MIT",
"repository": "OHIF/Viewers",
"main": "dist/index.umd.js",
"module": "src/index.js",
"module": "src/index.ts",
"publishConfig": {
"access": "public"
},

View File

@ -0,0 +1,208 @@
import * as ContextMenuItemsBuilder from './ContextMenuItemsBuilder';
import ContextMenu from '../../../../platform/ui/src/components/ContextMenu/ContextMenu';
import { CommandsManager, ServicesManager, Types } from '@ohif/core';
import { Menu, MenuItem, Point, ContextMenuProps } from './types';
/**
* The context menu controller is a helper class that knows how
* to manage context menus based on the UI Customization Service.
* There are a few parts to this:
* 1. Basic controls to manage displaying and hiding context menus
* 2. Menu selection services, which use the UI customization service
* to choose which menu to display
* 3. Menu item adapter services to convert menu items into displayable and actionable items.
*
* The format for a menu is defined in the exported type MenuItem
*/
export default class ContextMenuController {
commandsManager: CommandsManager;
services: Types.Services;
menuItems: Menu[] | MenuItem[];
constructor(
servicesManager: ServicesManager,
commandsManager: CommandsManager
) {
this.services = servicesManager.services as Obj;
this.commandsManager = commandsManager;
}
closeContextMenu() {
this.services.uiDialogService.dismiss({ id: 'context-menu' });
}
/**
* Figures out which context menu is appropriate to display and shows it.
*
* @param contextMenuProps - the context menu properties, see ./types.ts
* @param viewportElement - the DOM element this context menu is related to
* @param defaultPointsPosition - a default position to show the context menu
*/
showContextMenu(
contextMenuProps: ContextMenuProps,
viewportElement,
defaultPointsPosition
): void {
if (!this.services.uiDialogService) {
console.warn('Unable to show dialog; no UI Dialog Service available.');
return;
}
const { event, subMenu, menuId, menus, selectorProps } = contextMenuProps;
console.log('Getting items from', menus);
const items = ContextMenuItemsBuilder.getMenuItems(
selectorProps || contextMenuProps,
event,
menus,
menuId
);
this.services.uiDialogService.dismiss({ id: 'context-menu' });
this.services.uiDialogService.create({
id: 'context-menu',
isDraggable: false,
preservePosition: false,
preventCutOf: true,
defaultPosition: ContextMenuController._getDefaultPosition(
defaultPointsPosition,
event?.detail,
viewportElement
),
event,
content: ContextMenu,
// This naming is part of hte uiDialogService convention
// Clicking outside simpy closes the dialog box.
onClickOutside: () =>
this.services.uiDialogService.dismiss({ id: 'context-menu' }),
contentProps: {
items,
selectorProps,
menus,
event,
subMenu,
eventData: event?.detail,
onClose: () => {
this.services.uiDialogService.dismiss({ id: 'context-menu' });
},
/**
* Displays a sub-menu, removing this menu
* @param {*} item
* @param {*} itemRef
* @param {*} subProps
*/
onShowSubMenu: (item, itemRef, subProps) => {
if (!itemRef.subMenu) {
console.warn('No submenu defined for', item, itemRef, subProps);
return;
}
this.showContextMenu(
{
...contextMenuProps,
menuId: itemRef.subMenu,
},
viewportElement,
defaultPointsPosition
);
},
// Default is to run the specified commands.
onDefault: (item, itemRef, subProps) => {
this.commandsManager.run(item, {
...selectorProps,
...itemRef,
subProps,
});
},
},
});
}
static getDefaultPosition = (): Point => {
return {
x: 0,
y: 0,
};
};
static _getEventDefaultPosition = eventDetail => ({
x: eventDetail && eventDetail.currentPoints.client[0],
y: eventDetail && eventDetail.currentPoints.client[1],
});
static _getElementDefaultPosition = element => {
if (element) {
const boundingClientRect = element.getBoundingClientRect();
return {
x: boundingClientRect.x,
y: boundingClientRect.y,
};
}
return {
x: undefined,
y: undefined,
};
};
static _getCanvasPointsPosition = (points = [], element) => {
const viewerPos = ContextMenuController._getElementDefaultPosition(element);
for (let pointIndex = 0; pointIndex < points.length; pointIndex++) {
const point = {
x: points[pointIndex][0] || points[pointIndex]['x'],
y: points[pointIndex][1] || points[pointIndex]['y'],
};
if (
ContextMenuController._isValidPosition(point) &&
ContextMenuController._isValidPosition(viewerPos)
) {
return {
x: point.x + viewerPos.x,
y: point.y + viewerPos.y,
};
}
}
};
static _isValidPosition = (source): boolean => {
return (
source && typeof source.x === 'number' && typeof source.y === 'number'
);
};
/**
* Returns the context menu default position. It look for the positions of: canvasPoints (got from selected), event that triggers it, current viewport element
*/
static _getDefaultPosition = (canvasPoints, eventDetail, viewerElement) => {
function* getPositionIterator() {
yield ContextMenuController._getCanvasPointsPosition(
canvasPoints,
viewerElement
);
yield ContextMenuController._getEventDefaultPosition(eventDetail);
yield ContextMenuController._getElementDefaultPosition(viewerElement);
yield ContextMenuController.getDefaultPosition();
}
const positionIterator = getPositionIterator();
let current = positionIterator.next();
let position = current.value;
while (!current.done) {
position = current.value;
if (ContextMenuController._isValidPosition(position)) {
positionIterator.return();
}
current = positionIterator.next();
}
return position;
};
}

View File

@ -0,0 +1,29 @@
import ContextMenuItemsBuilder from "./ContextMenuItemsBuilder";
const menus = [
{
id: 'one',
selector: ({ value }) => value === 'one',
items: [],
},
{
id: 'two',
selector: ({ value }) => value === 'two',
items: [],
},
{
id: 'default',
items: [],
},
];
const menuBuilder = new ContextMenuItemsBuilder();
describe('ContextMenuItemsBuilder', () => {
test('findMenuDefault', () => {
expect(menuBuilder.findMenuDefault(menus, {})).toBe(menus[2]);
expect(menuBuilder.findMenuDefault(menus, { value: 'two' })).toBe(menus[1]);
expect(menuBuilder.findMenuDefault([], {})).toBeUndefined();
expect(menuBuilder.findMenuDefault(undefined, undefined)).toBeNull();
});
});

View File

@ -0,0 +1,193 @@
import { Types } from '@ohif/ui';
import { Menu, SelectorProps, MenuItem, ContextMenuProps } from './types';
type ContextMenuItem = Types.ContextMenuItem;
/**
* Finds menu by menu id
*
* @returns Menu having the menuId
*/
export function findMenuById(menus: Menu[], menuId?: string): Menu {
if (!menuId) {
return;
}
return menus.find(menu => menu.id === menuId);
}
/**
* Default finding menu method. This method will go through
* the list of menus until it finds the first one which
* has no selector, OR has the selector, when applied to the
* check props, return true.
* The selectorProps are a set of provided properties which can be
* passed into the selector function to determine when to display a menu.
* For example, a selector function of:
* `({displayset}) => displaySet?.SeriesDescription?.indexOf?.('Left')!==-1
* would match series descriptions containing 'Left'.
*
* @param {Object[]} menus List of menus
* @param {*} subProps
* @returns
*/
export function findMenuDefault(
menus: Menu[],
subProps: Record<string, unknown>
): Menu {
if (!menus) {
return null;
}
return menus.find(
menu => !menu.selector || menu.selector(subProps.selectorProps)
);
}
/**
* Finds the menu to be used for different scenarios:
* This will first look for a subMenu with the specified subMenuId
* Next it will look for the first menu whose selector returns true.
*
* @param menus - List of menus
* @param props - root props
* @param menuIdFilter - menu id identifier (to be considered on selection)
* This is intended to support other types of filtering in the future.
*/
export function findMenu(
menus: Menu[],
props?: Types.IProps,
menuIdFilter?: string
) {
const { subMenu } = props;
function* findMenuIterator() {
yield findMenuById(menus, menuIdFilter || subMenu);
yield findMenuDefault(menus, props);
}
const findIt = findMenuIterator();
let current = findIt.next();
let menu = current.value;
while (!current.done) {
menu = current.value;
if (menu) {
findIt.return();
}
current = findIt.next();
}
console.log('Menu chosen', menu?.id || 'NONE');
return menu;
}
/**
* Returns the menu from a list of possible menus, based on the actual state of component props and tool data nearby.
* This uses the findMenu command above to first find the appropriate
* menu, and then it chooses the actual contents of that menu.
* A menu item can be optional by implementing the 'selector',
* which will be called with the selectorProps, and if it does not return true,
* then the item is excluded.
*
* Other menus can be delegated to by setting the delegating value to
* a string id for another menu. That menu's content will replace the
* current menu item (only if the item would be included).
*
* This allows single id menus to be chosen by id, but have varying contents
* based on the delegated menus.
*
* Finally, for each item, the adaptItem call is made. This allows
* items to modify themselves before being displayed, such as
* incorporating additional information from translation sources.
* See the `test-mode` examples for details.
*
* @param selectorProps
* @param {*} event event that originates the context menu
* @param {*} menus List of menus
* @param {*} menuIdFilter
* @returns
*/
export function getMenuItems(
selectorProps: SelectorProps,
event: Event,
menus: Menu[],
menuIdFilter?: string
): MenuItem[] | void {
// Include both the check props and the ...check props as one is used
// by the child menu and the other used by the selector function
const subProps = { selectorProps, event };
const menu = findMenu(menus, subProps, menuIdFilter);
if (!menu) {
return undefined;
}
if (!menu.items) {
console.warn('Must define items in menu', menu);
return [];
}
let menuItems = [];
menu.items.forEach(item => {
const { delegating, selector, subMenu } = item;
if (!selector || selector(selectorProps)) {
if (delegating) {
menuItems = [
...menuItems,
...getMenuItems(selectorProps, event, menus, subMenu),
];
} else {
const toAdd = adaptItem(item, subProps);
menuItems.push(toAdd);
}
}
});
return menuItems;
}
/**
* Returns item adapted to be consumed by ContextMenu component
* and then goes through the item to add action behaviour for clicking the item,
* making it compatible with the default ContextMenu display.
*
* @param {Object} item
* @param {Object} subProps
* @returns a MenuItem that is compatible with the base ContextMenu
* This requires having a label and set of actions to be called.
*/
export function adaptItem(
item: MenuItem,
subProps: ContextMenuProps
): ContextMenuItem {
const newItem: ContextMenuItem = {
...item,
value: subProps.selectorProps?.value,
};
if (item.actionType === 'ShowSubMenu' && !newItem.iconRight) {
newItem.iconRight = 'chevron-menu';
}
if (!item.action) {
newItem.action = (itemRef, componentProps) => {
const { event = {} } = componentProps;
const { detail = {} } = event;
newItem.element = detail.element;
componentProps.onClose();
const action = componentProps[`on${itemRef.actionType || 'Default'}`];
if (action) {
action.call(componentProps, newItem, itemRef, subProps);
} else {
console.warn('No action defined for', itemRef);
}
};
}
return newItem;
}

View File

@ -0,0 +1,31 @@
const defaultContextMenu = {
id: 'measurementsContextMenu',
customizationType: 'ohif.contextMenu',
menus: [
// Get the items from the UI Customization for the menu name (and have a custom name)
{
id: 'forExistingMeasurement',
selector: ({ nearbyToolData }) => !!nearbyToolData,
items: [
{
label: 'Delete measurement',
commands: [
{
commandName: 'deleteMeasurement',
},
],
},
{
label: 'Add Label',
commands: [
{
commandName: 'setMeasurementLabel',
},
],
},
],
},
],
};
export default defaultContextMenu;

View File

@ -0,0 +1,11 @@
import ContextMenuController from './ContextMenuController';
import ContextMenuItemsBuilder from './ContextMenuItemsBuilder';
import defaultContextMenu from './defaultContextMenu';
import * as CustomizeableContextMenuTypes from './types';
export {
ContextMenuController,
CustomizeableContextMenuTypes,
ContextMenuItemsBuilder,
defaultContextMenu,
};

View File

@ -0,0 +1,125 @@
import { Types } from '@ohif/core';
/**
* SelectorProps are properties used to decide whether to select a menu or
* menu item for display.
* An instance of SelectorProps is provided to the selector functions, which
* return true to include the item or false to exclude it.
* The point of this is to allow more specific conext menus which hide
* non-relevant menu options, optimizing the speed of selection of menus
*/
export interface SelectorProps {
// If the context menu is invoked in the context of a measurement, then it
// will contain the nearby tool data.
nearbyToolData?: Record<string, unknown>;
// The tool name for the nearby tool
toolName?: string;
// An annotation UID - this will be present if nearbyToolData is present.
uid?: string;
// If the context menu is invoked on an active viewport, then it will contain
// the first display set.
displaySet?: Record<string, unknown>;
// The triggering event - can be used to determine key modifiers
event?: Event;
// Any other properties
[propertyName: string]: unknown;
}
/**
* The type of item actually required for the ContextMenu UI display
*/
export type UIMenuItem = {
label: string;
// Called when the item is selected
action?: (itemRef, componentProps) => void;
};
/**
* A MenuItem is a single line item within a menu, and specifies a selectable
* value for the menu.
*/
export interface MenuItem {
id?: string;
/** The customization type is used to apply preset values to this item
* when registered with the customization service.
*/
customizationType?: string;
// The label is the value to show in the menu for this item
label?: string;
// Delegating items are used to include other sub-menus inline within
// this menu. That allows sharing part of the menu structure, but also,
// more importantly to use a single selector function to include/exclude
// and entire section of sub-menu.
// See the `siteSelectionSubMenu` within the example `findingsMenu`
// for an example
delegating?: boolean;
// A sub-menu is shown when this item is selected or is delegating.
// This item gives the name of the sub-menu.
subMenu?: string;
// The selector is used to determine if this menu entry will be shown
// or more importantly, if the delegating subMenu will be included.
selector?: (props: SelectorProps) => boolean;
/** Adapts the item by filling in additional properties as requried */
adaptItem?: (item: MenuItem, props: ContextMenuProps) => UIMenuItem;
/** List of commands to run when this item's action is taken. */
commands?: Types.Command[];
}
/**
* A menu is a list of menu items, plus a selector.
* The selector is used to determine whether the menu should be displayed
* in a given context. The parameters passed to the selector come from
* the 'selectorProps' value in the options, and are intended to be context
* specific values containing things like the selected object, the currently
* displayed study etc so that the context menu can dynamically choose which
* view to show.
*/
export interface Menu {
id: string;
/** The customization type is used to apply preset values to this item
* when registered with the customization service.
*/
customizationType?: string;
// Choose whether this menu applies.
selector?: Types.Predicate;
items: MenuItem[];
}
export type Point = {
x: number;
y: number;
};
/**
* ContextMenuProps is the top level argument used to invoke the context menu
* itself. It contains the menus available for display, as well as the event
* and selector props used to decide the menu.
*/
export type ContextMenuProps = {
event?: EventTarget;
menuCustomizationId?: string;
menuId: string;
element?: HTMLElement;
/** A set of menus to choose from for this context menu */
menus: Menu[];
/** The properties used to decide the menu type */
selectorProps: SelectorProps;
defaultPointsPosition?: [number, number] | [];
};

View File

@ -138,7 +138,9 @@ function createDicomLocalApi(dicomLocalConfig) {
study.series.forEach(aSeries => {
const { SeriesInstanceUID } = aSeries;
aSeries.instances.forEach(instance => {
const isMultiframe = aSeries.instances[0].NumberOfFrames > 1;
aSeries.instances.forEach((instance, index) => {
const {
url: imageId,
StudyInstanceUID,
@ -153,6 +155,7 @@ function createDicomLocalApi(dicomLocalConfig) {
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID,
frameIndex: isMultiframe ? index : 1,
});
});
@ -185,7 +188,7 @@ function createDicomLocalApi(dicomLocalConfig) {
displaySet.images.forEach(instance => {
const NumberOfFrames = instance.NumberOfFrames;
if (NumberOfFrames > 1) {
for (let i = 0; i < NumberOfFrames; i++) {
for (let i = 1; i <= NumberOfFrames; i++) {
const imageId = this.getImageIdsForInstance({
instance,
frame: i,

View File

@ -104,7 +104,7 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) {
query: {
studies: {
mapParams: mapParams.bind(),
search: async function (origParams) {
search: async function(origParams) {
const headers = userAuthenticationService.getAuthorizationHeader();
if (headers) {
qidoDicomWebClient.headers = headers;
@ -129,7 +129,7 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) {
},
series: {
// mapParams: mapParams.bind(),
search: async function (studyInstanceUid) {
search: async function(studyInstanceUid) {
const headers = userAuthenticationService.getAuthorizationHeader();
if (headers) {
qidoDicomWebClient.headers = headers;

View File

@ -218,6 +218,7 @@ export default function PanelMeasurementTable({
>
<MeasurementTable
title="Measurements"
servicesManager={servicesManager}
data={displayMeasurements}
onClick={jumpToImage}
onEdit={onMeasurementItemEditHandler}
@ -248,14 +249,46 @@ function _getMappedMeasurements(measurementService) {
return mappedMeasurements;
}
/**
* Map the measurements to the display text.
* Adds finding and site inforamtion to the displayText and/or label,
* and provides as 'displayText' and 'label', while providing the original
* values as baseDisplayText and baseLabel
*/
function _mapMeasurementToDisplay(measurement, index, types) {
const { displayText, uid, label, type, selected } = measurement;
const {
displayText: baseDisplayText,
uid,
label: baseLabel,
type,
selected,
findingSites,
finding,
} = measurement;
const firstSite = findingSites?.[0];
const label = baseLabel || finding?.text || firstSite?.text || '(empty)';
let displayText = baseDisplayText || [];
if (findingSites) {
const siteText = [];
findingSites.forEach(site => {
if (site?.text !== label) siteText.push(site.text);
});
displayText = [...siteText, ...displayText];
}
if (finding && finding?.text !== label) {
displayText = [finding.text, ...displayText];
}
return {
uid,
label: label || '(empty)',
label,
baseLabel,
measurementType: type,
displayText: displayText || [],
displayText,
baseDisplayText,
isActive: selected,
finding,
findingSites,
};
}

View File

@ -51,7 +51,7 @@ function PanelStudyBrowser({
uiNotificationService.show({
title: 'Thumbnail Double Click',
message:
'The selected display sets could not be added to the viewport due to a mismatch in the Hanging Protocol rules.',
'The selected display sets could not be added to the viewport.',
type: 'info',
duration: 3000,
});
@ -303,6 +303,7 @@ function _mapDisplaySets(displaySets, thumbnailImageSrcMap) {
seriesDate: ds.SeriesDate,
seriesTime: ds.SeriesTime,
numInstances: ds.numImageFrames,
countIcon: ds.countIcon,
StudyInstanceUID: ds.StudyInstanceUID,
componentType,
imageSrc,

View File

@ -1,10 +1,6 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import {
LayoutSelector as OHIFLayoutSelector,
ToolbarButton,
useViewportGrid,
} from '@ohif/ui';
import { LayoutSelector as OHIFLayoutSelector, ToolbarButton } from '@ohif/ui';
import { ServicesManager } from '@ohif/core';
@ -16,8 +12,6 @@ function LayoutSelector({
...rest
}) {
const [isOpen, setIsOpen] = useState(false);
const [disableSelector, setDisableSelector] = useState(false);
const [viewportGridState, viewportGridService] = useViewportGrid();
const {
hangingProtocolService,
@ -50,43 +44,19 @@ function LayoutSelector({
};
}, [isOpen]);
useEffect(() => {
/* Reset to default layout when component unmounts */
return () => {
viewportGridService.setLayout({ numCols: 1, numRows: 1 });
};
}, []);
const onInteractionHandler = () => setIsOpen(!isOpen);
const DropdownContent = isOpen ? OHIFLayoutSelector : null;
const onSelectionHandler = ({ numRows, numCols }) => {
// TODO Introduce a service to persist the state of the current hanging protocol/app.
// TODO Here the layout change will amount to a change of hanging protocol as specified by the extension for this layout selector tool
// followed by the change of the grid itself.
if (hangingProtocolService.getActiveProtocol().protocol.id === 'mpr') {
toolbarService.recordInteraction({
groupId: 'MPR',
itemId: 'MPR',
interactionType: 'toggle',
commands: [
{
commandName: 'toggleMPR',
commandOptions: {},
context: 'CORNERSTONE',
},
],
});
}
// When a new layout is selected, keep any extra/offscreen viewports
// so that if any of those viewports were populated via the UI then they
// will be maintained in case those viewports are redisplayed later.
viewportGridService.setLayout({
numRows,
numCols,
keepExtraViewports: true,
const onSelectionHandler = props => {
toolbarService.recordInteraction({
interactionType: 'action',
commands: [
{
commandName: 'setViewportGridLayout',
commandOptions: { ...props },
context: 'DEFAULT',
},
],
});
};
@ -107,7 +77,7 @@ function LayoutSelector({
/>
)
}
isActive={disableSelector ? false : isOpen}
isActive={isOpen}
type="toggle"
/>
);

View File

@ -14,7 +14,12 @@ import {
LoadingIndicatorProgress,
} from '@ohif/ui';
import i18n from '@ohif/i18n';
import { hotkeys } from '@ohif/core';
import {
ServicesManager,
HangingProtocolService,
hotkeys,
CommandsManager,
} from '@ohif/core';
import { useAppConfig } from '@state';
import Toolbar from '../Toolbar/Toolbar';
@ -33,7 +38,7 @@ function ViewerLayout({
rightPanels = [],
leftPanelDefaultClosed = false,
rightPanelDefaultClosed = false,
}) {
}): React.FunctionComponent {
const [appConfig] = useAppConfig();
const navigate = useNavigate();
const location = useLocation();
@ -169,15 +174,13 @@ function ViewerLayout({
useEffect(() => {
const { unsubscribe } = hangingProtocolService.subscribe(
hangingProtocolService.EVENTS.HANGING_PROTOCOL_APPLIED_FOR_VIEWPORT,
HangingProtocolService.EVENTS.PROTOCOL_CHANGED,
// Todo: right now to set the loading indicator to false, we need to wait for the
// hangingProtocolService to finish applying the viewport matching to each viewport,
// however, this might not be the only approach to set the loading indicator to false. we need to explore this further.
({ progress }) => {
if (progress === 100) {
setShowLoadingIndicator(false);
}
() => {
setShowLoadingIndicator(false);
}
);
@ -265,7 +268,8 @@ ViewerLayout.propTypes = {
extensionManager: PropTypes.shape({
getModuleEntry: PropTypes.func.isRequired,
}).isRequired,
commandsManager: PropTypes.object,
commandsManager: PropTypes.instanceOf(CommandsManager),
servicesManager: PropTypes.instanceOf(ServicesManager),
// From modes
leftPanels: PropTypes.array,
rightPanels: PropTypes.array,

View File

@ -1,97 +0,0 @@
import DicomTagBrowser from './DicomTagBrowser/DicomTagBrowser';
import React from 'react';
const commandsModule = ({ servicesManager, commandsManager }) => {
const {
measurementService,
hangingProtocolService,
uiNotificationService,
viewportGridService,
displaySetService,
} = servicesManager.services;
const actions = {
displayNotification: ({ text, title, type }) => {
uiNotificationService.show({
title: title,
message: text,
type: type,
});
},
clearMeasurements: () => {
measurementService.clear();
},
nextStage: () => {
// next stage in hanging protocols
hangingProtocolService.nextProtocolStage();
},
previousStage: () => {
hangingProtocolService.previousProtocolStage();
},
openDICOMTagViewer() {
const { activeViewportIndex, viewports } = viewportGridService.getState();
const activeViewportSpecificData = viewports[activeViewportIndex];
const { displaySetInstanceUIDs } = activeViewportSpecificData;
const displaySets = displaySetService.activeDisplaySets;
const { uiModalService } = servicesManager.services;
const displaySetInstanceUID = displaySetInstanceUIDs[0];
uiModalService.show({
content: DicomTagBrowser,
contentProps: {
displaySets,
displaySetInstanceUID,
onClose: uiModalService.hide,
},
title: 'DICOM Tag Browser',
});
},
/**
* Toggle viewport overlay (the information panel shown on the four corners
* of the viewport)
* @see ViewportOverlay and CustomizableViewportOverlay components
*/
toggleOverlays: () => {
const overlays = document.getElementsByClassName('viewport-overlay');
for (let i = 0; i < overlays.length; i++) {
overlays.item(i).classList.toggle('hidden');
}
},
};
const definitions = {
clearMeasurements: {
commandFn: actions.clearMeasurements,
storeContexts: [],
options: {},
},
displayNotification: {
commandFn: actions.displayNotification,
storeContexts: [],
options: {},
},
nextStage: {
commandFn: actions.nextStage,
storeContexts: [],
options: {},
},
previousStage: {
commandFn: actions.previousStage,
storeContexts: [],
options: {},
},
openDICOMTagViewer: {
commandFn: actions.openDICOMTagViewer,
},
};
return {
actions,
definitions,
defaultContext: 'DEFAULT',
};
};
export default commandsModule;

View File

@ -0,0 +1,575 @@
import { ServicesManager, utils } from '@ohif/core';
import {
ContextMenuController,
defaultContextMenu,
} from './CustomizeableContextMenu';
import DicomTagBrowser from './DicomTagBrowser/DicomTagBrowser';
import reuseCachedLayouts from './utils/reuseCachedLayouts';
import findViewportsByPosition, {
findOrCreateViewport as layoutFindOrCreate,
} from './findViewportsByPosition';
import { ContextMenuProps } from './CustomizeableContextMenu/types';
const { subscribeToNextViewportGridChange } = utils;
export type HangingProtocolParams = {
protocolId?: string;
stageIndex?: number;
activeStudyUID?: string;
stageId?: string;
};
/**
* Determine if a command is a hanging protocol one.
* For now, just use the two hanging protocol commands that are in this
* commands module, but if others get added elsewhere this may need enhancing.
*/
const isHangingProtocolCommand = command =>
command &&
(command.commandName === 'setHangingProtocol' ||
command.commandName === 'toggleHangingProtocol');
const commandsModule = ({
servicesManager,
commandsManager,
}: Types.Extensions.ExtensionParams): Types.Extensions.CommandsModule => {
const {
customizationService,
measurementService,
hangingProtocolService,
uiNotificationService,
viewportGridService,
displaySetService,
stateSyncService,
toolbarService,
} = (servicesManager as ServicesManager).services;
// Define a context menu controller for use with any context menus
const contextMenuController = new ContextMenuController(
servicesManager,
commandsManager
);
const actions = {
/**
* Show the context menu.
* @param options.menuId defines the menu name to lookup, from customizationService
* @param options.defaultMenu contains the default menu set to use
* @param options.element is the element to show the menu within
* @param options.event is the event that caused the context menu
* @param options.selectorProps is the set of selection properties to use
*/
showContextMenu: (options: ContextMenuProps) => {
const {
menuCustomizationId,
element,
event,
selectorProps,
defaultPointsPosition = [],
} = options;
const optionsToUse = { ...options };
if (menuCustomizationId) {
Object.assign(
optionsToUse,
customizationService.get(menuCustomizationId, defaultContextMenu)
);
}
// TODO - make the selectorProps richer by including the study metadata and display set.
const { protocol, stage } = hangingProtocolService.getActiveProtocol();
optionsToUse.selectorProps = {
event,
protocol,
stage,
...selectorProps,
};
contextMenuController.showContextMenu(
optionsToUse,
element,
defaultPointsPosition
);
},
/** Close a context menu currently displayed */
closeContextMenu: () => {
contextMenuController.closeContextMenu();
},
displayNotification: ({ text, title, type }) => {
uiNotificationService.show({
title: title,
message: text,
type: type,
});
},
clearMeasurements: () => {
measurementService.clear();
},
/**
* Toggles off all tools which contain a commandName of setHangingProtocol
* or toggleHangingProtocol, and which match/don't match the protocol id/stage
*/
toggleHpTools: () => {
const {
protocol,
stageIndex: toggleStageIndex,
stage,
} = hangingProtocolService.getActiveProtocol();
const enableListener = button => {
if (!button.id) return;
const { commands, items } = button.props || button;
if (items) {
items.forEach(enableListener);
}
const hpCommand = commands?.find?.(isHangingProtocolCommand);
if (!hpCommand) return;
const { protocolId, stageIndex, stageId } = hpCommand.commandOptions;
const isActive =
(!protocolId || protocolId === protocol.id) &&
(stageIndex === undefined || stageIndex === toggleStageIndex) &&
(!stageId || stageId === stage.id);
toolbarService.setActive(button.id, isActive);
};
Object.values(toolbarService.getButtons()).forEach(enableListener);
},
/**
* Sets the specified protocol
* 1. Records any existing state using the viewport grid service
* 2. Finds the destination state - this can be one of:
* a. The specified protocol stage
* b. An alternate (toggled or restored) protocol stage
* c. A restored custom layout
* 3. Finds the parameters for the specified state
* a. Gets the displaySetSelectorMap
* b. Gets the map by position
* c. Gets any toggle mapping to map position to/from current view
* 4. If restore, then sets layout
* a. Maps viewport position by currently displayed viewport map id
* b. Uses toggle information to map display set id
* 5. Else applies the hanging protocol
* a. HP Service is provided displaySetSelectorMap
* b. HP Service will throw an exception if it isn't applicable
* @param options - contains information on the HP to apply
* @param options.activeStudyUID - the updated study to apply the HP to
* @param options.protocolId - the protocol ID to change to
* @param options.stageId - the stageId to apply
* @param options.stageIndex - the index of the stage to go to.
* @param options.reset - flag to indicate if the HP should be reset to its original and not restored to a previous state
*/
setHangingProtocol: ({
activeStudyUID = '',
protocolId,
stageId,
stageIndex,
reset = false,
}: HangingProtocolParams): boolean => {
try {
// Stores in the state the reuseID to displaySetUID mapping
// Pass in viewportId for the active viewport. This item will get set as
// the activeViewportId
const state = viewportGridService.getState();
const hpInfo = hangingProtocolService.getState();
const {
protocol: oldProtocol,
} = hangingProtocolService.getActiveProtocol();
const stateSyncReduce = reuseCachedLayouts(
state,
hangingProtocolService,
stateSyncService
);
const {
hangingProtocolStageIndexMap,
viewportGridStore,
displaySetSelectorMap,
} = stateSyncReduce;
if (!protocolId) {
// Re-use the previous protocol id, and optionally stage
protocolId = hpInfo.protocolId;
if (stageId === undefined && stageIndex === undefined) {
stageIndex = hpInfo.stageIndex;
}
} else if (stageIndex === undefined && stageId === undefined) {
// Re-set the same stage as was previously used
const hangingId = `${activeStudyUID ||
hpInfo.activeStudyUID}:${protocolId}`;
stageIndex = hangingProtocolStageIndexMap[hangingId]?.stageIndex;
}
const useStageIdx =
stageIndex ??
hangingProtocolService.getStageIndex(protocolId, {
stageId,
stageIndex,
});
if (activeStudyUID) {
hangingProtocolService.setActiveStudyUID(activeStudyUID);
}
const storedHanging = `${
hangingProtocolService.getState().activeStudyUID
}:${protocolId}:${useStageIdx || 0}`;
const restoreProtocol = !reset && viewportGridStore[storedHanging];
if (
protocolId === hpInfo.protocolId &&
useStageIdx === hpInfo.stageIndex &&
!activeStudyUID
) {
// Clear the HP setting to reset them
hangingProtocolService.setProtocol(protocolId, {
stageId,
stageIndex: useStageIdx,
});
} else {
hangingProtocolService.setProtocol(protocolId, {
displaySetSelectorMap,
stageId,
stageIndex: useStageIdx,
restoreProtocol,
});
if (restoreProtocol) {
viewportGridService.set(viewportGridStore[storedHanging]);
}
}
// Do this after successfully applying the update
stateSyncService.store(stateSyncReduce);
// This is a default action applied
actions.toggleHpTools(hangingProtocolService.getActiveProtocol());
// Send the notification about updating the state
if (protocolId !== hpInfo.protocolId) {
const { protocol } = hangingProtocolService.getActiveProtocol();
// The old protocol callbacks are used for turning off things
// like crosshairs when moving to the new HP
commandsManager.run(oldProtocol.callbacks?.onProtocolExit);
// The new protocol callback is used for things like
// activating modes etc.
commandsManager.run(protocol.callbacks?.onProtocolEnter);
}
return true;
} catch (e) {
actions.toggleHpTools(hangingProtocolService.getActiveProtocol());
uiNotificationService.show({
title: 'Apply Hanging Protocol',
message: 'The hanging protocol could not be applied.',
type: 'error',
duration: 3000,
});
return false;
}
},
toggleHangingProtocol: ({
protocolId,
stageIndex,
}: HangingProtocolParams): boolean => {
const {
protocol,
stageIndex: desiredStageIndex,
activeStudy,
} = hangingProtocolService.getActiveProtocol();
const { toggleHangingProtocol } = stateSyncService.getState();
const storedHanging = `${
activeStudy.StudyInstanceUID
}:${protocolId}:${stageIndex | 0}`;
if (
protocol.id === protocolId &&
(stageIndex === undefined || stageIndex === desiredStageIndex)
) {
// Toggling off - restore to previous state
const previousState = toggleHangingProtocol[storedHanging] || {
protocolId: 'default',
};
return actions.setHangingProtocol(previousState);
} else {
stateSyncService.store({
toggleHangingProtocol: {
...toggleHangingProtocol,
[storedHanging]: {
protocolId: protocol.id,
stageIndex: desiredStageIndex,
},
},
});
return actions.setHangingProtocol({
protocolId,
stageIndex,
reset: true,
});
}
},
deltaStage: ({ direction }) => {
const {
protocolId,
stageIndex: oldStageIndex,
} = hangingProtocolService.getState();
const { protocol } = hangingProtocolService.getActiveProtocol();
for (
let stageIndex = oldStageIndex + direction;
stageIndex >= 0 && stageIndex < protocol.stages.length;
stageIndex += direction
) {
if (protocol.stages[stageIndex].status !== 'disabled') {
return actions.setHangingProtocol({
protocolId,
stageIndex,
});
}
}
uiNotificationService.show({
title: 'Change Stage',
message: 'The hanging protocol has no more applicable stages',
type: 'info',
duration: 3000,
});
},
/**
* Changes the viewport grid layout in terms of the MxN layout.
*/
setViewportGridLayout: ({ numRows, numCols }) => {
const { protocol } = hangingProtocolService.getActiveProtocol();
const onLayoutChange = protocol.callbacks?.onLayoutChange;
if (commandsManager.run(onLayoutChange, { numRows, numCols }) === false) {
console.log(
'setViewportGridLayout running',
onLayoutChange,
numRows,
numCols
);
// Don't apply the layout if the run command returns false
return;
}
const completeLayout = () => {
const state = viewportGridService.getState();
const stateReduce = findViewportsByPosition(
state,
{ numRows, numCols },
stateSyncService
);
const findOrCreateViewport = layoutFindOrCreate.bind(
null,
hangingProtocolService,
stateReduce.viewportsByPosition
);
viewportGridService.setLayout({
numRows,
numCols,
findOrCreateViewport,
});
stateSyncService.store(stateReduce);
};
// Need to finish any work in the callback
window.setTimeout(completeLayout, 0);
},
toggleOneUp() {
const viewportGridState = viewportGridService.getState();
const { activeViewportIndex, viewports, layout } = viewportGridState;
const {
displaySetInstanceUIDs,
displaySetOptions,
viewportOptions,
} = viewports[activeViewportIndex];
if (layout.numCols === 1 && layout.numRows === 1) {
// The viewer is in one-up. Check if there is a state to restore/toggle back to.
const { toggleOneUpViewportGridStore } = stateSyncService.getState();
if (!toggleOneUpViewportGridStore.layout) {
return;
}
// There is a state to toggle back to. The viewport that was
// originally toggled to one up was the former active viewport.
const viewportIndexToUpdate =
toggleOneUpViewportGridStore.activeViewportIndex;
// Determine which viewports need to be updated. This is particularly
// important when MPR is toggled to one up and a different reconstructable
// is swapped in. Note that currently HangingProtocolService.getViewportsRequireUpdate
// does not support viewport with multiple display sets.
const updatedViewports =
displaySetInstanceUIDs.length > 1
? []
: displaySetInstanceUIDs
.map(displaySetInstanceUID =>
hangingProtocolService.getViewportsRequireUpdate(
viewportIndexToUpdate,
displaySetInstanceUID
)
)
.flat();
// This findOrCreateViewport returns either one of the updatedViewports
// returned from the HP service OR if there is not one from the HP service then
// simply returns what was in the previous state.
const findOrCreateViewport = (viewportIndex: number) => {
const viewport = updatedViewports.find(
viewport => viewport.viewportIndex === viewportIndex
);
return viewport
? { viewportOptions, displaySetOptions, ...viewport }
: toggleOneUpViewportGridStore.viewports[viewportIndex];
};
const layoutOptions = viewportGridService.getLayoutOptionsFromState(
toggleOneUpViewportGridStore
);
// Restore the previous layout including the active viewport.
viewportGridService.setLayout({
numRows: toggleOneUpViewportGridStore.layout.numRows,
numCols: toggleOneUpViewportGridStore.layout.numCols,
activeViewportIndex: viewportIndexToUpdate,
layoutOptions,
findOrCreateViewport,
});
} else {
// We are not in one-up, so toggle to one up.
// Store the current viewport grid state so we can toggle it back later.
stateSyncService.store({
toggleOneUpViewportGridStore: viewportGridState,
});
// This findOrCreateViewport only return one viewport - the active
// one being toggled to one up.
const findOrCreateViewport = () => {
return {
displaySetInstanceUIDs,
displaySetOptions,
viewportOptions,
};
};
// Set the layout to be 1x1/one-up.
viewportGridService.setLayout({
numRows: 1,
numCols: 1,
findOrCreateViewport,
});
// Subscribe to ANY (i.e. manual and hanging protocol) layout changes so that
// any grid layout state to toggle to from one up is cleared. This is performed on
// a timeout to avoid clearing the state for the actual to one up change.
// Whenever the next layout change event is fired, the subscriptions are unsubscribed.
const clearToggleOneUpViewportGridStore = () => {
const toggleOneUpViewportGridStore = {};
stateSyncService.store({
toggleOneUpViewportGridStore,
});
};
subscribeToNextViewportGridChange(
viewportGridService,
clearToggleOneUpViewportGridStore
);
}
},
openDICOMTagViewer() {
const { activeViewportIndex, viewports } = viewportGridService.getState();
const activeViewportSpecificData = viewports[activeViewportIndex];
const { displaySetInstanceUIDs } = activeViewportSpecificData;
const displaySets = displaySetService.activeDisplaySets;
const { UIModalService } = servicesManager.services;
const displaySetInstanceUID = displaySetInstanceUIDs[0];
UIModalService.show({
content: DicomTagBrowser,
contentProps: {
displaySets,
displaySetInstanceUID,
onClose: UIModalService.hide,
},
title: 'DICOM Tag Browser',
});
},
/**
* Toggle viewport overlay (the information panel shown on the four corners
* of the viewport)
* @see ViewportOverlay and CustomizableViewportOverlay components
*/
toggleOverlays: () => {
const overlays = document.getElementsByClassName('viewport-overlay');
for (let i = 0; i < overlays.length; i++) {
overlays.item(i).classList.toggle('hidden');
}
},
};
const definitions = {
showContextMenu: {
commandFn: actions.showContextMenu,
},
closeContextMenu: {
commandFn: actions.closeContextMenu,
},
clearMeasurements: {
commandFn: actions.clearMeasurements,
storeContexts: [],
options: {},
},
displayNotification: {
commandFn: actions.displayNotification,
storeContexts: [],
options: {},
},
setHangingProtocol: {
commandFn: actions.setHangingProtocol,
storeContexts: [],
options: {},
},
toggleHangingProtocol: {
commandFn: actions.toggleHangingProtocol,
storeContexts: [],
options: {},
},
nextStage: {
commandFn: actions.deltaStage,
storeContexts: [],
options: { direction: 1 },
},
previousStage: {
commandFn: actions.deltaStage,
storeContexts: [],
options: { direction: -1 },
},
setViewportGridLayout: {
commandFn: actions.setViewportGridLayout,
storeContexts: [],
options: {},
},
toggleOneUp: {
commandFn: actions.toggleOneUp,
storeContexts: [],
options: {},
},
openDICOMTagViewer: {
commandFn: actions.openDICOMTagViewer,
},
};
return {
actions,
definitions,
defaultContext: 'DEFAULT',
};
};
export default commandsModule;

View File

@ -0,0 +1,106 @@
import { StateSyncService, Types } from '@ohif/core';
/**
* This find or create viewport is paired with the reduce results from
* below, and the action of this viewport is to look for previously filled
* viewports, and to re-use by position id. If there is no filled viewport,
* then one can be re-used from the display set if it isn't going to be displayed.
* @param hangingProtocolService - bound parameter supplied before using this
* @param viewportsByPosition - bound parameter supplied before using this
* @param viewportIndex - the index to retrieve
* @param positionId - the current position on screen to retrieve
* @param options - the set of options used, so that subsequent calls can
* store state that is reset by the setLayout.
* This class uses the options to store the already viewed
* display sets, filling it initially with the pre-existing viewports.
*/
export const findOrCreateViewport = (
hangingProtocolService,
viewportsByPosition,
viewportIndex: number,
positionId: string,
options: Record<string, unknown>
) => {
const byPositionViewport = viewportsByPosition?.[positionId];
if (byPositionViewport) return { ...byPositionViewport };
const { protocolId, stageIndex } = hangingProtocolService.getState();
// Setup the initial in display correctly for initial view/select
if (!options.inDisplay) {
options.inDisplay = [...viewportsByPosition.initialInDisplay];
}
// See if there is a default viewport for new views.
const missing = hangingProtocolService.getMissingViewport(
protocolId,
stageIndex,
options
);
if (missing) {
const displaySetInstanceUIDs = missing.displaySetsInfo.map(
it => it.displaySetInstanceUID
);
options.inDisplay.push(...displaySetInstanceUIDs);
return {
displaySetInstanceUIDs,
displaySetOptions: missing.displaySetsInfo.map(
it => it.displaySetOptions
),
viewportOptions: {
...missing.viewportOptions,
},
};
}
return {};
};
/**
* Records the information on what viewports are displayed in which position.
* Also records what instances from the existing positions are going to be in
* view initially.
* @param state is the viewport grid state
* @param syncService is the state sync service to use for getting existing state
* @returns Set of states that can be applied to the state sync to remember
* the current view state.
*/
const findViewportsByPosition = (
state,
{ numRows, numCols },
syncService: StateSyncService
): Record<string, Record<string, unknown>> => {
const { viewports } = state;
const syncState = syncService.getState();
const viewportsByPosition = { ...syncState.viewportsByPosition };
const initialInDisplay = [];
for (const viewport of viewports) {
if (viewport.positionId) {
const storedViewport = {
...viewport,
viewportOptions: { ...viewport.viewportOptions },
};
viewportsByPosition[viewport.positionId] = storedViewport;
// The cache doesn't store the viewport options - it is only useful
// for remembering the type of viewport and UIDs
delete storedViewport.viewportId;
delete storedViewport.viewportOptions.viewportId;
}
}
for (let row = 0; row < numRows; row++) {
for (let col = 0; col < numCols; col++) {
const pos = col + row * numCols;
const positionId = viewports?.[pos]?.positionId || `${col}-${row}`;
const viewport = viewportsByPosition[positionId];
if (viewport?.displaySetInstanceUIDs) {
initialInDisplay.push(...viewport.displaySetInstanceUIDs);
}
}
}
// Store the initially displayed elements
viewportsByPosition.initialInDisplay = initialInDisplay;
return { viewportsByPosition };
};
export default findViewportsByPosition;

View File

@ -1,3 +1,4 @@
import { CustomizationService } from '@ohif/core';
import React from 'react';
import DataSourceSelector from './Panels/DataSourceSelector';
@ -82,7 +83,6 @@ export default function getCustomizationModule() {
*/
{
id: 'ohif.overlayItem',
uiType: 'uiType',
content: function (props) {
if (this.condition && !this.condition(props)) return null;
@ -91,8 +91,8 @@ export default function getCustomizationModule() {
instance && this.attribute
? instance[this.attribute]
: this.contentF && typeof this.contentF === 'function'
? this.contentF(props)
: null;
? this.contentF(props)
: null;
if (!value) return null;
return (
@ -109,6 +109,29 @@ export default function getCustomizationModule() {
);
},
},
{
id: 'ohif.contextMenu',
/** Applies the customizationType to all the menu items.
* This function clones the object and child objects to prevent
* changes to the original customization object.
*/
transform: function (customizationService: CustomizationService) {
// Don't modify the children, as those are copied by reference
const clonedObject = { ...this };
clonedObject.menus = this.menus.map(menu => ({ ...menu }));
for (const menu of clonedObject.menus) {
const { items: originalItems } = menu;
menu.items = [];
for (const item of originalItems) {
menu.items.push(customizationService.transform(item));
}
}
return clonedObject;
},
},
],
},
];

View File

@ -1,6 +1,9 @@
const defaultProtocol = {
id: 'default',
locked: true,
// Don't store this hanging protocol as it applies to the currently active
// display set by default
// cacheId: null,
hasUpdatedPriorsInformation: false,
name: 'Default',
createdDate: '2021-02-23T19:22:08.894Z',
@ -9,6 +12,25 @@ const defaultProtocol = {
editableBy: {},
protocolMatchingRules: [],
toolGroupIds: ['default'],
// -1 would be used to indicate active only, whereas other values are
// the number of required priors referenced - so 0 means active with
// 0 or more priors.
numberOfPriorsReferenced: 0,
// Default viewport is used to define the viewport when
// additional viewports are added using the layout tool
defaultViewport: {
viewportOptions: {
viewportType: 'stack',
toolGroupId: 'default',
allowUnmatchedView: true,
},
displaySets: [
{
id: 'defaultDisplaySetId',
matchedDisplaySetsIndex: -1,
},
],
},
displaySetSelectors: {
defaultDisplaySetId: {
// Unused currently
@ -24,12 +46,12 @@ const defaultProtocol = {
},
},
],
studyMatchingRules: [],
// Can be used to select matching studies
// studyMatchingRules: [],
},
},
stages: [
{
id: 'hYbmMy3b7pz7GLiaT',
name: 'default',
viewportStructure: {
layoutType: 'grid',
@ -41,6 +63,7 @@ const defaultProtocol = {
viewports: [
{
viewportOptions: {
viewportType: 'stack',
toolGroupId: 'default',
// initialImageOptions: {
// index: 180,
@ -56,14 +79,218 @@ const defaultProtocol = {
],
createdDate: '2021-02-23T18:32:42.850Z',
},
// This is an example of a 2x2 layout that requires at least 2 viewports
// filled to be navigatable to
{
name: '2x2',
// Indicate that the number of viewports needed is 2 filled viewports,
// but that 4 viewports are preferred.
stageActivation: {
enabled: {
minViewportsMatched: 4,
},
passive: {
minViewportsMatched: 2,
},
},
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 2,
columns: 2,
},
},
viewports: [
{
viewportOptions: {
toolGroupId: 'default',
},
displaySets: [
{
id: 'defaultDisplaySetId',
matchedDisplaySetsIndex: 3,
},
],
},
{
viewportOptions: {
toolGroupId: 'default',
},
displaySets: [
{
id: 'defaultDisplaySetId',
matchedDisplaySetsIndex: 2,
},
],
},
{
viewportOptions: {
toolGroupId: 'default',
},
displaySets: [
{
id: 'defaultDisplaySetId',
matchedDisplaySetsIndex: 1,
},
],
},
{
viewportOptions: {},
displaySets: [
{
id: 'defaultDisplaySetId',
matchedDisplaySetsIndex: 0,
},
],
},
],
},
{
name: '3x1',
// Indicate that the number of viewports needed is 2 filled viewports,
// but that 4 viewports are preferred.
stageActivation: {
enabled: {
minViewportsMatched: 3,
},
},
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 1,
columns: 3,
},
},
viewports: [
{
viewportOptions: {
toolGroupId: 'default',
},
displaySets: [
{
id: 'defaultDisplaySetId',
matchedDisplaySetsIndex: 2,
},
],
},
{
viewportOptions: {
toolGroupId: 'default',
},
displaySets: [
{
id: 'defaultDisplaySetId',
matchedDisplaySetsIndex: 1,
},
],
},
{
viewportOptions: {},
displaySets: [
{
id: 'defaultDisplaySetId',
},
],
},
],
},
// This is an example of a layout with more than one element in it
// It can be navigated to using , and . (prev/next stage)
{
name: '2x1',
// Indicate that the number of viewports needed is 1 filled viewport,
// but that 2 viewports are preferred.
stageActivation: {
enabled: {
minViewportsMatched: 3,
},
},
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 1,
columns: 2,
},
},
viewports: [
{
viewportOptions: {
toolGroupId: 'default',
// initialImageOptions: {
// index: 180,
// preset: 'middle', // 'first', 'last', 'middle'
// },
},
displaySets: [
{
id: 'defaultDisplaySetId',
// Shows the second index of this image set
matchedDisplaySetsIndex: 1,
},
],
},
{
viewportOptions: {},
displaySets: [
{
id: 'defaultDisplaySetId',
},
],
},
],
},
{
name: '2x1',
// Indicate that the number of viewports needed is 1 filled viewport,
// but that 2 viewports are preferred.
stageActivation: {
enabled: {
minViewportsMatched: 3,
},
},
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 2,
columns: 1,
},
},
viewports: [
{
viewportOptions: {},
displaySets: [
{
id: 'defaultDisplaySetId',
// Shows the second index of this image set
matchedDisplaySetsIndex: 1,
},
],
},
{
viewportOptions: {},
displaySets: [
{
id: 'defaultDisplaySetId',
},
],
},
],
},
],
numberOfPriorsReferenced: -1,
};
function getHangingProtocolModule() {
return [
{
id: defaultProtocol.id,
name: defaultProtocol.id,
protocol: defaultProtocol,
},
];

View File

@ -29,6 +29,7 @@ const makeDisplaySet = instances => {
SeriesDescription: instance.SeriesDescription || '',
Modality: instance.Modality,
isMultiFrame: isMultiFrame(instance),
countIcon: displayReconstructableInfo.value ? 'icon-mpr' : undefined,
numImageFrames: instances.length,
SOPClassHandlerId: `${id}.sopClassHandlerModule.${sopClassHandlerName}`,
isReconstructable: displayReconstructableInfo.value,

View File

@ -1,32 +1,34 @@
import { Types } from '@ohif/core';
import getDataSourcesModule from './getDataSourcesModule.js';
import getLayoutTemplateModule from './getLayoutTemplateModule.js';
import getPanelModule from './getPanelModule';
import getSopClassHandlerModule from './getSopClassHandlerModule.js';
import getToolbarModule from './getToolbarModule';
import commandsModule from './commandsModule';
import getCommandsModule from './commandsModule';
import getHangingProtocolModule from './getHangingProtocolModule';
import getStudiesForPatientByStudyInstanceUID from './Panels/getStudiesForPatientByStudyInstanceUID';
import getCustomizationModule from './getCustomizationModule';
import { id } from './id.js';
import init from './init';
import preRegistration from './init';
import {
ContextMenuController,
CustomizeableContextMenuTypes,
} from './CustomizeableContextMenu';
const defaultExtension = {
const defaultExtension: Types.Extensions.Extension = {
/**
* Only required property. Should be a unique value across all extensions.
*/
id,
preRegistration: ({ servicesManager, configuration = {} }) => {
init({ servicesManager, configuration });
},
preRegistration,
getDataSourcesModule,
getLayoutTemplateModule,
getPanelModule,
getHangingProtocolModule,
getSopClassHandlerModule,
getToolbarModule,
getCommandsModule({ servicesManager, commandsManager }) {
return commandsModule({ servicesManager, commandsManager });
},
getCommandsModule,
getUtilityModule({ servicesManager }) {
return [
{
@ -42,3 +44,5 @@ const defaultExtension = {
};
export default defaultExtension;
export { ContextMenuController, CustomizeableContextMenuTypes };

View File

@ -10,7 +10,8 @@ const metadataProvider = classes.MetadataProvider;
* @param {Object} servicesManager
* @param {Object} configuration
*/
export default function init({ servicesManager, configuration }) {
export default function init({ servicesManager, configuration = {} }): void {
const { stateSyncService } = servicesManager.services;
// Add
DicomMetadataStore.subscribe(
DicomMetadataStore.EVENTS.INSTANCES_ADDED,
@ -23,6 +24,34 @@ export default function init({ servicesManager, configuration }) {
DicomMetadataStore.EVENTS.SERIES_UPDATED,
handlePETImageMetadata
);
// viewportGridStore is a sync state which stores the entire
// ViewportGridService getState, by the keys `<activeStudyUID>:<protocolId>:<stageIndex>`
// Used to recover manual changes to the layout of a stage.
stateSyncService.register('viewportGridStore', { clearOnModeExit: true });
// displaySetSelectorMap stores a map from
// `<activeStudyUID>:<displaySetSelectorId>:<matchOffset>` to
// a displaySetInstanceUID, used to display named display sets in
// specific spots within a hanging protocol and be able to remember what the
// user did with those named spots between stages and protocols.
stateSyncService.register('displaySetSelectorMap', { clearOnModeExit: true });
// Stores a map from `<activeStudyUID>:${protocolId}` to the getHPInfo results
// in order to recover the correct stage when returning to a Hanging Protocol.
stateSyncService.register('hangingProtocolStageIndexMap', {
clearOnModeExit: true,
});
// Stores a map from the to be applied hanging protocols `<activeStudyUID>:<protocolId>`
// to the previously applied hanging protolStageIndexMap key, in order to toggle
// off the applied protocol and remember the old state.
stateSyncService.register('toggleHangingProtocol', { clearOnModeExit: true });
// Stores the viewports by `rows-cols` position so that when the layout
// changes numRows and numCols, the viewports can be remembers and then replaced
// afterwards.
stateSyncService.register('viewportsByPosition', { clearOnModeExit: true });
}
const handlePETImageMetadata = ({ SeriesInstanceUID, StudyInstanceUID }) => {

View File

@ -0,0 +1,75 @@
import { HangingProtocolService, StateSyncService, Types } from '@ohif/core';
export type ReturnType = {
hangingProtocolStageIndexMap: Record<string, Types.HangingProtocol.HPInfo>;
viewportGridStore: Record<string, unknown>;
displaySetSelectorMap: Record<string, string>;
};
/**
* Calculates a set of state information for hanging protocols and viewport grid
* which defines the currently applied hanging protocol state.
* @param state is the viewport grid state
* @param syncService is the state sync service to use for getting existing state
* @returns Set of states that can be applied to the state sync to remember
* the current view state.
*/
const reuseCachedLayout = (
state,
hangingProtocolService: HangingProtocolService,
syncService: StateSyncService
): ReturnType => {
const { activeViewportIndex, viewports, layout } = state;
const hpInfo = hangingProtocolService.getState();
const { protocolId, stageIndex, activeStudyUID } = hpInfo;
const { protocol } = hangingProtocolService.getActiveProtocol();
const stage = protocol.stages[stageIndex];
const storeId = `${activeStudyUID}:${protocolId}:${stageIndex}`;
const syncState = syncService.getState();
const cacheId = `${activeStudyUID}:${protocolId}`;
const viewportGridStore = { ...syncState.viewportGridStore };
const hangingProtocolStageIndexMap = {
...syncState.hangingProtocolStageIndexMap,
};
const displaySetSelectorMap = { ...syncState.displaySetSelectorMap };
const { rows, columns } = stage.viewportStructure.properties;
const custom =
stage.viewports.length !== state.viewports.length ||
state.layout.numRows !== rows ||
state.layout.numCols !== columns;
hangingProtocolStageIndexMap[cacheId] = hpInfo;
if (storeId && custom) {
viewportGridStore[storeId] = { ...state };
}
for (let idx = 0; idx < state.viewports.length; idx++) {
const viewport = state.viewports[idx];
const { displaySetOptions, displaySetInstanceUIDs } = viewport;
if (!displaySetOptions) continue;
for (let i = 0; i < displaySetOptions.length; i++) {
const displaySetUID = displaySetInstanceUIDs[i];
if (!displaySetUID) continue;
if (idx === activeViewportIndex && i === 0) {
displaySetSelectorMap[
`${activeStudyUID}:activeDisplaySet:0`
] = displaySetUID;
}
if (displaySetOptions[i]?.id) {
displaySetSelectorMap[
`${activeStudyUID}:${displaySetOptions[i].id}:${displaySetOptions[i]
.matchedDisplaySetsIndex || 0}`
] = displaySetUID;
}
}
}
return {
hangingProtocolStageIndexMap,
viewportGridStore,
displaySetSelectorMap,
};
};
export default reuseCachedLayout;

View File

@ -32,10 +32,11 @@
"peerDependencies": {
"@ohif/core": "^3.0.0",
"classnames": "^2.3.2",
"@cornerstonejs/core": "^0.33.2",
"@cornerstonejs/tools": "^0.50.2",
"@cornerstonejs/core": "^0.40.0",
"@cornerstonejs/tools": "^0.60.1",
"@ohif/extension-cornerstone-dicom-sr": "^3.0.0",
"dcmjs": "^0.29.4",
"lodash.debounce": "^4.17.21",
"prop-types": "^15.6.2",
"react": "^17.0.2",
"react-dom": "^17.0.2",

View File

@ -7,12 +7,12 @@ const RESPONSE = {
};
function promptBeginTracking({ servicesManager, extensionManager }, ctx, evt) {
const { UIViewportDialogService } = servicesManager.services;
const { uiViewportDialogService } = servicesManager.services;
const { viewportIndex, StudyInstanceUID, SeriesInstanceUID } = evt;
return new Promise(async function(resolve, reject) {
let promptResult = await _askTrackMeasurements(
UIViewportDialogService,
uiViewportDialogService,
viewportIndex
);
@ -25,7 +25,7 @@ function promptBeginTracking({ servicesManager, extensionManager }, ctx, evt) {
});
}
function _askTrackMeasurements(UIViewportDialogService, viewportIndex) {
function _askTrackMeasurements(uiViewportDialogService, viewportIndex) {
return new Promise(function(resolve, reject) {
const message = 'Track measurements for this series?';
const actions = [
@ -49,11 +49,11 @@ function _askTrackMeasurements(UIViewportDialogService, viewportIndex) {
},
];
const onSubmit = result => {
UIViewportDialogService.hide();
uiViewportDialogService.hide();
resolve(result);
};
UIViewportDialogService.show({
uiViewportDialogService.show({
viewportIndex,
id: 'measurement-tracking-prompt-begin-tracking',
type: 'info',
@ -61,7 +61,7 @@ function _askTrackMeasurements(UIViewportDialogService, viewportIndex) {
actions,
onSubmit,
onOutsideClick: () => {
UIViewportDialogService.hide();
uiViewportDialogService.hide();
resolve(RESPONSE.CANCEL);
},
});

View File

@ -16,7 +16,7 @@ function promptHydrateStructuredReport(
evt
) {
const {
UIViewportDialogService,
uiViewportDialogService,
displaySetService,
} = servicesManager.services;
const { viewportIndex, displaySetInstanceUID } = evt;
@ -26,7 +26,7 @@ function promptHydrateStructuredReport(
return new Promise(async function(resolve, reject) {
const promptResult = await _askTrackMeasurements(
UIViewportDialogService,
uiViewportDialogService,
viewportIndex
);
@ -55,7 +55,7 @@ function promptHydrateStructuredReport(
});
}
function _askTrackMeasurements(UIViewportDialogService, viewportIndex) {
function _askTrackMeasurements(uiViewportDialogService, viewportIndex) {
return new Promise(function(resolve, reject) {
const message =
'Do you want to continue tracking measurements for this study?';
@ -72,18 +72,18 @@ function _askTrackMeasurements(UIViewportDialogService, viewportIndex) {
},
];
const onSubmit = result => {
UIViewportDialogService.hide();
uiViewportDialogService.hide();
resolve(result);
};
UIViewportDialogService.show({
uiViewportDialogService.show({
viewportIndex,
type: 'info',
message,
actions,
onSubmit,
onOutsideClick: () => {
UIViewportDialogService.hide();
uiViewportDialogService.hide();
resolve(RESPONSE.CANCEL);
},
});

View File

@ -33,7 +33,7 @@ function promptTrackNewSeries({ servicesManager, extensionManager }, ctx, evt) {
});
}
function _askShouldAddMeasurements(UIViewportDialogService, viewportIndex) {
function _askShouldAddMeasurements(uiViewportDialogService, viewportIndex) {
return new Promise(function(resolve, reject) {
const message =
'Do you want to add this measurement to the existing report?';
@ -51,18 +51,18 @@ function _askShouldAddMeasurements(UIViewportDialogService, viewportIndex) {
},
];
const onSubmit = result => {
UIViewportDialogService.hide();
uiViewportDialogService.hide();
resolve(result);
};
UIViewportDialogService.show({
uiViewportDialogService.show({
viewportIndex,
type: 'info',
message,
actions,
onSubmit,
onOutsideClick: () => {
UIViewportDialogService.hide();
uiViewportDialogService.hide();
resolve(RESPONSE.CANCEL);
},
});

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import PropTypes from 'prop-types';
import {
StudySummary,
@ -11,6 +11,7 @@ import { DicomMetadataStore, utils } from '@ohif/core';
import { useDebounce } from '@hooks';
import ActionButtons from './ActionButtons';
import { useTrackedMeasurements } from '../../getContextModule';
import debounce from 'lodash.debounce';
const { downloadCSVReport } = utils;
const { formatDate } = utils;
@ -45,6 +46,7 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) {
DISPLAY_STUDY_SUMMARY_INITIAL_VALUE
);
const [displayMeasurements, setDisplayMeasurements] = useState([]);
const measurementsPanelRef = useRef(null);
useEffect(() => {
const measurements = measurementService.getMeasurements();
@ -125,6 +127,12 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) {
subscriptions.push(
measurementService.subscribe(evt, () => {
setMeasurementsUpdated(Date.now().toString());
if (evt === added) {
debounce(() => {
measurementsPanelRef.current.scrollTop =
measurementsPanelRef.current.scrollHeight;
}, 300)();
}
}).unsubscribe
);
});
@ -241,6 +249,7 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) {
<>
<div
className="overflow-x-hidden overflow-y-auto invisible-scrollbar"
ref={measurementsPanelRef}
data-cy={'trackedMeasurements-panel'}
>
{displayStudySummary.key && (
@ -253,6 +262,7 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) {
<MeasurementTable
title="Measurements"
data={displayMeasurementsWithoutFindings}
servicesManager={servicesManager}
onClick={jumpToImage}
onEdit={onMeasurementItemEditHandler}
/>
@ -260,6 +270,7 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) {
<MeasurementTable
title="Additional Findings"
data={additionalFindings}
servicesManager={servicesManager}
onClick={jumpToImage}
onEdit={onMeasurementItemEditHandler}
/>
@ -318,13 +329,40 @@ function _mapMeasurementToDisplay(measurement, types, displaySetService) {
);
}
const { displayText } = measurement;
const {
displayText: baseDisplayText,
uid,
label: baseLabel,
type,
selected,
findingSites,
finding,
} = measurement;
const firstSite = findingSites?.[0];
const label = baseLabel || finding?.text || firstSite?.text || '(empty)';
let displayText = baseDisplayText || [];
if (findingSites) {
const siteText = [];
findingSites.forEach(site => {
if (site?.text !== label) siteText.push(site.text);
});
displayText = [...siteText, ...displayText];
}
if (finding && finding?.text !== label) {
displayText = [finding.text, ...displayText];
}
return {
uid: measurement.uid,
label: measurement.label || '(empty)',
measurementType: measurement.type,
displayText: displayText || [],
isActive: measurement.selected,
uid,
label,
baseLabel,
measurementType: type,
displayText,
baseDisplayText,
isActive: selected,
finding,
findingSites,
};
}

View File

@ -453,6 +453,7 @@ function _mapDisplaySets(
modality: ds.Modality,
seriesDate: formatDate(ds.SeriesDate),
numInstances: ds.numImageFrames,
countIcon: ds.countIcon,
StudyInstanceUID: ds.StudyInstanceUID,
componentType,
imageSrc,

View File

@ -80,7 +80,7 @@ function TrackedCornerstoneViewport(props) {
return;
}
annotation.config.style.setViewportToolStyles(`viewport-${viewportIndex}`, {
annotation.config.style.setViewportToolStyles(viewportId, {
global: {
lineDash: '4,4',
},

View File

@ -0,0 +1 @@
export default (study, extraData) => Math.max(...(extraData?.displaySets?.map?.(ds => (ds.numImageFrames ?? 0))) || [0]);

View File

@ -0,0 +1 @@
export default (study, extraData) => extraData?.displaySets?.length;

View File

@ -0,0 +1,5 @@
export default (study, extraData) => {
const ret = extraData?.displaySets?.filter(ds => ds.numImageFrames>0)?.length;
console.log("number of display sets with images", ret);
return ret;
};

View File

@ -0,0 +1,33 @@
/**
* This function extracts an attribute from the already matched display sets, and
* compares it to the attribute in the current display set, and indicates if they match.
* From 'this', it uses:
* `sameAttribute` as the attribute name to look for
* `sameDisplaySetId` as the display set id to look for
* From `options`, it looks for
*/
export default function (displaySet, options) {
const { sameAttribute, sameDisplaySetId } = this;
if( !sameAttribute ) {
console.log("sameAttribute not defined in", this);
return `sameAttribute not defined in ${this.id}`;
}
if( !sameDisplaySetId ) {
console.log("sameDisplaySetId not defined in", this);
return `sameDisplaySetId not defined in ${this.id}`;
}
const { displaySetMatchDetails, displaySets } = options;
const match = displaySetMatchDetails.get(sameDisplaySetId);
if( !match ) {
console.log("No match for display set", sameDisplaySetId);
return false;
}
const { displaySetInstanceUID } = match;
const altDisplaySet = displaySets.find(it => it.displaySetInstanceUID==displaySetInstanceUID);
if( !altDisplaySet ) {
console.log("No display set found with", displaySetInstanceUID, "in", displaySets);
return false;
}
const testValue = altDisplaySet[sameAttribute];
return testValue===displaySet[sameAttribute];
}

View File

@ -0,0 +1 @@
export default (study, extraData) => extraData?.displaySets?.map(ds => ds.SeriesDescription);

View File

@ -0,0 +1,80 @@
/**
* Coding values is a map of simple string coding values to a set of
* attributes associated with the coding value.
*
* The simple string is in the format `<codingSchemeDesignator>:<codingValue>`
* That allows extracting the DICOM attributes from the designator/value, and
* allows for passing around the simple string.
* The additional attributes contained in the object include:
* * text - this is the coding scheme text display value, and may be language specific
* * type - this defines a named type, typically 'site'. Different names can be used
* to allow setting different findingSites values in order to define a hierarchy.
* * color - used to apply annotation color
* It is also possible to define additional attributes here, used by custom
* extensions.
*
* See https://dicom.nema.org/medical/dicom/current/output/html/part16.html
* for definitions of SCT and other code values.
*/
const codingValues = {
id: 'codingValues',
// Sites
'SCT:69536005': {
text: 'Head',
type: 'site',
},
'SCT:45048000': {
text: 'Neck',
type: 'site',
},
'SCT:818981001': {
text: 'Abdomen',
type: 'site',
},
'SCT:816092008': {
text: 'Pelvis',
type: 'site',
},
// Findings
'SCT:371861004': {
text: 'Mild intimal coronary irregularities',
color: 'green',
},
'SCT:194983005': {
text: 'Aortic insufficiency',
color: 'darkred',
},
'SCT:399232001': {
text: '2-chamber',
},
'SCT:103340004': {
text: 'SAX',
},
'SCT:91134007': {
text: 'MV',
},
'SCT:122972007': {
text: 'PV',
},
// Orientations
'SCT:24422004': {
text: 'Axial',
color: '#000000',
type: 'orientation',
},
'SCT:81654009': {
text: 'Coronal',
color: '#000000',
type: 'orientation',
},
'SCT:30730003': {
text: 'Sagittal',
color: '#000000',
type: 'orientation',
},
};
export default codingValues;

View File

@ -0,0 +1,24 @@
const codeMenuItem = {
id: '@ohif/contextMenuAnnotationCode',
/** Applies the code value setup for this item */
transform: function (customizationService) {
const { code: codeRef } = this;
if (!codeRef) throw new Error(`item ${this} has no code ref`);
const codingValues = customizationService.get('codingValues');
const code = codingValues[codeRef];
return {
...this,
codeRef,
code: { ref: codeRef, ...code },
label: code.text,
commands: [
{
commandName: 'updateMeasurement',
},
],
};
},
};
export default codeMenuItem;

View File

@ -0,0 +1,100 @@
const findingsContextMenu = {
id: 'measurementsContextMenu',
customizationType: 'ohif.contextMenu',
menus: [
{
id: 'forExistingMeasurement',
// selector restricts context menu to when there is nearbyToolData
selector: ({ nearbyToolData }) => !!nearbyToolData,
items: [
{
customizationType: 'ohif.contextSubMenu',
label: 'Site',
actionType: 'ShowSubMenu',
subMenu: 'siteSelectionSubMenu',
},
{
customizationType: 'ohif.contextSubMenu',
label: 'Finding',
actionType: 'ShowSubMenu',
subMenu: 'findingSelectionSubMenu',
},
{
// customizationType is implicit here in the configuration setup
label: 'Delete Measurement',
commands: [
{
commandName: 'deleteMeasurement',
},
],
},
{
label: 'Add Label',
commands: [
{
commandName: 'setMeasurementLabel',
},
],
},
// The example below shows how to include a delegating sub-menu,
// Only available on the @ohif/hp-extension.mn hanging protocol
// To demonstrate, select the 3x1 layout from the protocol menu
// and right click on a measurement.
{
label: 'IncludeSubMenu',
selector: ({ protocol }) => protocol?.id === '@ohif/hp-extension.mn',
delegating: true,
subMenu: 'orientationSelectionSubMenu',
},
],
},
{
id: 'orientationSelectionSubMenu',
selector: ({ nearbyToolData }) => false,
items: [
{
customizationType: '@ohif/contextMenuAnnotationCode',
code: 'SCT:24422004',
},
{
customizationType: '@ohif/contextMenuAnnotationCode',
code: 'SCT:81654009',
},
],
},
{
id: 'findingSelectionSubMenu',
selector: ({ nearbyToolData }) => false,
items: [
{
customizationType: '@ohif/contextMenuAnnotationCode',
code: 'SCT:371861004',
},
{
customizationType: '@ohif/contextMenuAnnotationCode',
code: 'SCT:194983005',
},
],
},
{
id: 'siteSelectionSubMenu',
selector: ({ nearbyToolData }) => !!nearbyToolData,
items: [
{
customizationType: '@ohif/contextMenuAnnotationCode',
code: 'SCT:69536005',
},
{
customizationType: '@ohif/contextMenuAnnotationCode',
code: 'SCT:45048000',
},
],
},
],
};
export default findingsContextMenu;

View File

@ -0,0 +1,5 @@
import codingValues from './codingValues';
import contextMenuCodeItem from './contextMenuCodeItem';
import findingsContextMenu from './findingsContextMenu';
export { codingValues, contextMenuCodeItem, findingsContextMenu };

View File

@ -0,0 +1,14 @@
import {
codingValues,
contextMenuCodeItem,
findingsContextMenu,
} from './custom-context-menu';
export default function getCustomizationModule() {
return [
{
name: 'custom-context-menu',
value: [codingValues, contextMenuCodeItem, findingsContextMenu],
},
];
}

View File

@ -0,0 +1,257 @@
import { Types } from '@ohif/core';
/**
* This hanging protocol has multiple stages, which are enabled when
* there are enough display sets with images to fill the stage, and
* are passive when there is at least one display set.
* Enabled display sets are navigated to by default, while passive ones
* are navigated to manually using the ctrl+end keyboard shortcut.
*/
const hpMN: Types.HangingProtocol.Protocol = {
hasUpdatedPriorsInformation: false,
id: '@ohif/hp-extension.mn',
description: 'Has various hanging protocol layouts for use in testing',
name: '2x2',
protocolMatchingRules: [
{
id: 'OneOrMoreSeries',
weight: 1,
attribute: 'numberOfDisplaySetsWithImages',
constraint: {
greaterThan: 1,
},
},
],
toolGroupIds: ['default'],
displaySetSelectors: {
defaultDisplaySetId: {
seriesMatchingRules: [
{
attribute: 'numImageFrames',
constraint: {
greaterThan: { value: 0 },
},
},
],
},
},
defaultViewport: {
viewportOptions: {
viewportType: 'stack',
toolGroupId: 'default',
allowUnmatchedView: true,
},
displaySets: [
{
id: 'defaultDisplaySetId',
matchedDisplaySetsIndex: -1,
},
],
},
stages: [
{
id: '2x2',
stageActivation: {
enabled: {
minViewportsMatched: 4,
},
},
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 2,
columns: 2,
},
},
viewports: [
{
viewportOptions: {
toolGroupId: 'default',
allowUnmatchedView: true,
},
displaySets: [
{
id: 'defaultDisplaySetId',
},
],
},
{
viewportOptions: {
toolGroupId: 'default',
allowUnmatchedView: true,
},
displaySets: [
{
matchedDisplaySetsIndex: 1,
id: 'defaultDisplaySetId',
},
],
},
{
viewportOptions: {
toolGroupId: 'default',
allowUnmatchedView: true,
},
displaySets: [
{
matchedDisplaySetsIndex: 2,
id: 'defaultDisplaySetId',
},
],
},
{
viewportOptions: {
toolGroupId: 'default',
allowUnmatchedView: true,
},
displaySets: [
{
matchedDisplaySetsIndex: 3,
id: 'defaultDisplaySetId',
},
],
},
],
},
// 3x1 stage
{
id: '3x1',
// Obsolete settings:
requiredViewports: 1,
preferredViewports: 3,
// New equivalent:
stageActivation: {
enabled: {
minViewportsMatched: 3,
},
},
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 1,
columns: 3,
},
},
viewports: [
{
viewportOptions: {
toolGroupId: 'default',
allowUnmatchedView: true,
},
displaySets: [
{
id: 'defaultDisplaySetId',
reuseId: '0-0',
},
],
},
{
viewportOptions: {
toolGroupId: 'default',
allowUnmatchedView: true,
},
displaySets: [
{
matchedDisplaySetsIndex: 1,
id: 'defaultDisplaySetId',
reuseId: '1-0',
},
],
},
{
viewportOptions: {
toolGroupId: 'default',
allowUnmatchedView: true,
},
displaySets: [
{
matchedDisplaySetsIndex: 2,
id: 'defaultDisplaySetId',
reuseId: '0-1',
},
],
},
],
},
// A 2x1 stage
{
id: '2x1',
requiredViewports: 1,
preferredViewports: 2,
stageActivation: {
enabled: {
minViewportsMatched: 2,
},
},
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 1,
columns: 2,
},
},
viewports: [
{
viewportOptions: {
toolGroupId: 'default',
allowUnmatchedView: true,
},
displaySets: [
{
id: 'defaultDisplaySetId',
},
],
},
{
viewportOptions: {
toolGroupId: 'default',
allowUnmatchedView: true,
},
displaySets: [
{
matchedDisplaySetsIndex: 1,
id: 'defaultDisplaySetId',
},
],
},
],
},
// A 1x1 stage - should be automatically activated if there is only 1 viewable instance
{
id: '1x1',
requiredViewports: 1,
preferredViewports: 1,
stageActivation: {
enabled: {
minViewportsMatched: 1,
},
},
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 1,
columns: 1,
},
},
viewports: [
{
viewportOptions: {
toolGroupId: 'default',
allowUnmatchedView: true,
},
displaySets: [
{
id: 'defaultDisplaySetId',
},
],
},
],
},
],
numberOfPriorsReferenced: -1,
};
export default hpMN;

View File

@ -0,0 +1,17 @@
import hpMN from './hpMN';
const hangingProtocols = [
{
name: '@ohif/hp-extension.mn',
protocol: hpMN,
},
];
/**
* Registers a single study hanging protocol which can be referenced as
* `@ohif/hp-exgtension.mn`, that has initial layouts which show images
* only display sets, up to a 2x2 view.
*/
export default function getHangingProtocolModule() {
return hangingProtocols;
}

View File

@ -1,17 +1,68 @@
import { id } from './id';
import { Types } from '@ohif/core';
import { id } from './id';
import getHangingProtocolModule from './hp';
import getCustomizationModule from './getCustomizationModule';
// import {setViewportZoomPan, storeViewportZoomPan } from './custom-viewport/setViewportZoomPan';
import sameAs from './custom-attribute/sameAs';
import numberOfDisplaySets from './custom-attribute/numberOfDisplaySets';
import numberOfDisplaySetsWithImages from './custom-attribute/numberOfDisplaySetsWithImages';
import maxNumImageFrames from './custom-attribute/maxNumImageFrames';
import seriesDescriptionsFromDisplaySets from './custom-attribute/seriesDescriptionsFromDisplaySets';
/**
*
* The test extension provides additional behaviour for testing various
* customizations and settings for OHIF.
*/
const testExtension: Types.Extensions.Extension = {
/**
* Only required property. Should be a unique value across all extensions.
*/
id,
preRegistration() {
console.debug('hello from test-extension init.js');
/** Register additional behaviour:
* * HP custom attribute seriesDescriptions to retrieve an array of all series descriptions
* * HP custom attribute numberOfDisplaySets to retrieve the number of display sets
* * HP custom attribute numberOfDisplaySetsWithImages to retrieve the number of display sets containing images
* * HP custom attribute to return a boolean true, when the attribute sameAttribute has the same
* value as another series description in an already matched display set selector named with the value
* in `sameDisplaySetId`
*/
preRegistration: ({ servicesManager }: Types.Extensions.ExtensionParams) => {
const { hangingProtocolService } = servicesManager.services;
hangingProtocolService.addCustomAttribute(
'seriesDescriptions',
'Series Descriptions',
seriesDescriptionsFromDisplaySets
);
hangingProtocolService.addCustomAttribute(
'numberOfDisplaySets',
'Number of displays sets',
numberOfDisplaySets
);
hangingProtocolService.addCustomAttribute(
'numberOfDisplaySetsWithImages',
'Number of displays sets with images',
numberOfDisplaySetsWithImages
);
hangingProtocolService.addCustomAttribute(
'maxNumImageFrames',
'Maximum of number of image frames',
maxNumImageFrames
);
hangingProtocolService.addCustomAttribute(
'sameAs',
'Match an attribute in an existing display set',
sameAs
);
},
/** Registers some additional hanging protocols. See hp/index.tsx for more details */
getHangingProtocolModule,
/** Registers some customizations */
getCustomizationModule,
};
export default testExtension;

View File

@ -1,3 +1,219 @@
import {
ctAXIAL,
ctCORONAL,
ctSAGITTAL,
fusionAXIAL,
fusionCORONAL,
fusionSAGITTAL,
mipSAGITTAL,
ptAXIAL,
ptCORONAL,
ptSAGITTAL,
} from './utils/hpViewports';
/**
* represents a 3x4 viewport layout configuration. The layout displays CT axial, sagittal, and coronal
* images in the first row, PT axial, sagittal, and coronal images in the second row, and fusion axial,
* sagittal, and coronal images in the third row. The fourth column is fully spanned by a MIP sagittal
* image, covering all three rows. It has synchronizers for windowLevel for all CT and PT images, and
* also camera synchronizer for each orientation
*/
const stage1 = {
name: 'default',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 3,
columns: 4,
layoutOptions: [
{
x: 0,
y: 0,
width: 1 / 4,
height: 1 / 3,
},
{
x: 1 / 4,
y: 0,
width: 1 / 4,
height: 1 / 3,
},
{
x: 2 / 4,
y: 0,
width: 1 / 4,
height: 1 / 3,
},
{
x: 0,
y: 1 / 3,
width: 1 / 4,
height: 1 / 3,
},
{
x: 1 / 4,
y: 1 / 3,
width: 1 / 4,
height: 1 / 3,
},
{
x: 2 / 4,
y: 1 / 3,
width: 1 / 4,
height: 1 / 3,
},
{
x: 0,
y: 2 / 3,
width: 1 / 4,
height: 1 / 3,
},
{
x: 1 / 4,
y: 2 / 3,
width: 1 / 4,
height: 1 / 3,
},
{
x: 2 / 4,
y: 2 / 3,
width: 1 / 4,
height: 1 / 3,
},
{
x: 3 / 4,
y: 0,
width: 1 / 4,
height: 1,
},
],
},
},
viewports: [
ctAXIAL,
ctSAGITTAL,
ctCORONAL,
ptAXIAL,
ptSAGITTAL,
ptCORONAL,
fusionAXIAL,
fusionSAGITTAL,
fusionCORONAL,
mipSAGITTAL,
],
createdDate: '2021-02-23T18:32:42.850Z',
};
/**
* The layout displays CT axial image in the top-left viewport, fusion axial image
* in the top-right viewport, PT axial image in the bottom-left viewport, and MIP
* sagittal image in the bottom-right viewport. The layout follows a simple grid
* pattern with 2 rows and 2 columns. It includes synchronizers as well.
*/
const stage2 = {
name: 'Fusion 2x2',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 2,
columns: 2,
},
},
viewports: [ctAXIAL, fusionAXIAL, ptAXIAL, mipSAGITTAL],
};
/**
* The top row displays CT images in axial, sagittal, and coronal orientations from
* left to right, respectively. The bottom row displays PT images in axial, sagittal,
* and coronal orientations from left to right, respectively.
* The layout follows a simple grid pattern with 2 rows and 3 columns.
* It includes synchronizers as well.
*/
const stage3 = {
name: '2x3-layout',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 2,
columns: 3,
},
},
viewports: [ctAXIAL, ctSAGITTAL, ctCORONAL, ptAXIAL, ptSAGITTAL, ptCORONAL],
};
/**
* In this layout, the top row displays PT images in coronal, sagittal, and axial
* orientations from left to right, respectively, followed by a MIP sagittal image
* that spans both rows on the rightmost side. The bottom row displays fusion images
* in coronal, sagittal, and axial orientations from left to right, respectively.
* There is no viewport in the bottom row's rightmost position, as the MIP sagittal viewport
* from the top row spans the full height of both rows.
* It includes synchronizers as well.
*/
const stage4 = {
name: '2x4-layout',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 2,
columns: 4,
layoutOptions: [
{
x: 0,
y: 0,
width: 1 / 4,
height: 1 / 2,
},
{
x: 1 / 4,
y: 0,
width: 1 / 4,
height: 1 / 2,
},
{
x: 2 / 4,
y: 0,
width: 1 / 4,
height: 1 / 2,
},
{
x: 3 / 4,
y: 0,
width: 1 / 4,
height: 1,
},
{
x: 0,
y: 1 / 2,
width: 1 / 4,
height: 1 / 2,
},
{
x: 1 / 4,
y: 1 / 2,
width: 1 / 4,
height: 1 / 2,
},
{
x: 2 / 4,
y: 1 / 2,
width: 1 / 4,
height: 1 / 2,
},
],
},
},
viewports: [
ptCORONAL,
ptSAGITTAL,
ptAXIAL,
mipSAGITTAL,
fusionCORONAL,
fusionSAGITTAL,
fusionAXIAL,
],
};
const ptCT = {
id: '@ohif/extension-tmtv.hangingProtocolModule.ptCT',
locked: true,
@ -32,7 +248,6 @@ const ptCT = {
ctDisplaySet: {
seriesMatchingRules: [
{
weight: 1,
attribute: 'Modality',
constraint: {
equals: {
@ -42,7 +257,6 @@ const ptCT = {
required: true,
},
{
weight: 1,
attribute: 'isReconstructable',
constraint: {
equals: {
@ -75,7 +289,6 @@ const ptCT = {
required: true,
},
{
weight: 1,
attribute: 'isReconstructable',
constraint: {
equals: {
@ -103,508 +316,14 @@ const ptCT = {
},
},
stages: [
{
id: 'hYbmMy3b7pz7GLiaT',
name: 'default',
viewportStructure: {
layoutType: 'grid',
properties: {
rows: 3,
columns: 4,
layoutOptions: [
{
x: 0,
y: 0,
width: 1 / 4,
height: 1 / 3,
},
{
x: 1 / 4,
y: 0,
width: 1 / 4,
height: 1 / 3,
},
{
x: 2 / 4,
y: 0,
width: 1 / 4,
height: 1 / 3,
},
{
x: 0,
y: 1 / 3,
width: 1 / 4,
height: 1 / 3,
},
{
x: 1 / 4,
y: 1 / 3,
width: 1 / 4,
height: 1 / 3,
},
{
x: 2 / 4,
y: 1 / 3,
width: 1 / 4,
height: 1 / 3,
},
{
x: 0,
y: 2 / 3,
width: 1 / 4,
height: 1 / 3,
},
{
x: 1 / 4,
y: 2 / 3,
width: 1 / 4,
height: 1 / 3,
},
{
x: 2 / 4,
y: 2 / 3,
width: 1 / 4,
height: 1 / 3,
},
{
x: 3 / 4,
y: 0,
width: 1 / 4,
height: 1,
},
],
},
},
viewports: [
{
viewportOptions: {
viewportId: 'ctAXIAL',
viewportType: 'volume',
orientation: 'axial',
toolGroupId: 'ctToolGroup',
initialImageOptions: {
// index: 5,
preset: 'first', // 'first', 'last', 'middle'
},
syncGroups: [
{
type: 'cameraPosition',
id: 'axialSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ctWLSync',
source: true,
target: true,
},
],
},
displaySets: [
{
id: 'ctDisplaySet',
},
],
},
{
viewportOptions: {
viewportId: 'ctSAGITTAL',
viewportType: 'volume',
orientation: 'sagittal',
toolGroupId: 'ctToolGroup',
syncGroups: [
{
type: 'cameraPosition',
id: 'sagittalSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ctWLSync',
source: true,
target: true,
},
],
},
displaySets: [
{
id: 'ctDisplaySet',
},
],
},
{
viewportOptions: {
viewportId: 'ctCORONAL',
viewportType: 'volume',
orientation: 'coronal',
toolGroupId: 'ctToolGroup',
syncGroups: [
{
type: 'cameraPosition',
id: 'coronalSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ctWLSync',
source: true,
target: true,
},
],
},
displaySets: [
{
id: 'ctDisplaySet',
},
],
},
{
viewportOptions: {
viewportId: 'ptAXIAL',
viewportType: 'volume',
background: [1, 1, 1],
orientation: 'axial',
toolGroupId: 'ptToolGroup',
initialImageOptions: {
// index: 5,
preset: 'first', // 'first', 'last', 'middle'
},
syncGroups: [
{
type: 'cameraPosition',
id: 'axialSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptWLSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptFusionWLSync',
source: true,
target: false,
},
],
},
displaySets: [
{
options: {
voi: {
windowWidth: 5,
windowCenter: 2.5,
},
voiInverted: true,
},
id: 'ptDisplaySet',
},
],
},
{
viewportOptions: {
viewportId: 'ptSAGITTAL',
viewportType: 'volume',
orientation: 'sagittal',
background: [1, 1, 1],
toolGroupId: 'ptToolGroup',
syncGroups: [
{
type: 'cameraPosition',
id: 'sagittalSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptWLSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptFusionWLSync',
source: true,
target: false,
},
],
},
displaySets: [
{
options: {
voi: {
windowWidth: 5,
windowCenter: 2.5,
},
voiInverted: true,
},
id: 'ptDisplaySet',
},
],
},
{
viewportOptions: {
viewportId: 'ptCORONAL',
viewportType: 'volume',
orientation: 'coronal',
background: [1, 1, 1],
toolGroupId: 'ptToolGroup',
syncGroups: [
{
type: 'cameraPosition',
id: 'coronalSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptWLSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptFusionWLSync',
source: true,
target: false,
},
],
},
displaySets: [
{
options: {
voi: {
windowWidth: 5,
windowCenter: 2.5,
},
voiInverted: true,
},
id: 'ptDisplaySet',
},
],
},
{
viewportOptions: {
viewportId: 'fusionAXIAL',
viewportType: 'volume',
orientation: 'axial',
toolGroupId: 'fusionToolGroup',
initialImageOptions: {
// index: 5,
preset: 'first', // 'first', 'last', 'middle'
},
syncGroups: [
{
type: 'cameraPosition',
id: 'axialSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ctWLSync',
source: false,
target: true,
},
{
type: 'voi',
id: 'fusionWLSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptFusionWLSync',
source: false,
target: true,
},
],
},
displaySets: [
{
id: 'ctDisplaySet',
},
{
options: {
colormap: 'hsv',
voi: {
windowWidth: 5,
windowCenter: 2.5,
},
},
id: 'ptDisplaySet',
},
],
},
{
viewportOptions: {
viewportId: 'fusionSAGITTAL',
viewportType: 'volume',
orientation: 'sagittal',
toolGroupId: 'fusionToolGroup',
// initialImageOptions: {
// index: 180,
// preset: 'middle', // 'first', 'last', 'middle'
// },
syncGroups: [
{
type: 'cameraPosition',
id: 'sagittalSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ctWLSync',
source: false,
target: true,
},
{
type: 'voi',
id: 'fusionWLSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptFusionWLSync',
source: false,
target: true,
},
],
},
displaySets: [
{
id: 'ctDisplaySet',
},
{
options: {
colormap: 'hsv',
voi: {
windowWidth: 5,
windowCenter: 2.5,
},
},
id: 'ptDisplaySet',
},
],
},
{
viewportOptions: {
viewportId: 'fusionCoronal',
viewportType: 'volume',
orientation: 'coronal',
toolGroupId: 'fusionToolGroup',
// initialImageOptions: {
// index: 180,
// preset: 'middle', // 'first', 'last', 'middle'
// },
syncGroups: [
{
type: 'cameraPosition',
id: 'coronalSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ctWLSync',
source: false,
target: true,
},
{
type: 'voi',
id: 'fusionWLSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptFusionWLSync',
source: false,
target: true,
},
],
},
displaySets: [
{
id: 'ctDisplaySet',
},
{
options: {
colormap: 'hsv',
voi: {
windowWidth: 5,
windowCenter: 2.5,
},
},
id: 'ptDisplaySet',
},
],
},
{
viewportOptions: {
viewportId: 'mipSagittal',
viewportType: 'volume',
orientation: 'sagittal',
background: [1, 1, 1],
toolGroupId: 'mipToolGroup',
syncGroups: [
{
type: 'voi',
id: 'ptWLSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptFusionWLSync',
source: true,
target: false,
},
],
// Custom props can be used to set custom properties which extensions
// can react on.
customViewportProps: {
// We use viewportDisplay to filter the viewports which are displayed
// in mip and we set the scrollbar according to their rotation index
// in the cornerstone extension.
hideOverlays: true,
},
},
displaySets: [
{
options: {
blendMode: 'MIP',
slabThickness: 'fullVolume',
voi: {
windowWidth: 5,
windowCenter: 2.5,
},
voiInverted: true,
},
id: 'ptDisplaySet',
},
],
},
],
createdDate: '2021-02-23T18:32:42.850Z',
},
],
stages: [stage1, stage2, stage3, stage4],
numberOfPriorsReferenced: -1,
};
function getHangingProtocolModule() {
return [
{
id: ptCT.id,
name: ptCT.id,
protocol: ptCT,
},
];

View File

@ -0,0 +1,438 @@
const ctAXIAL = {
viewportOptions: {
viewportId: 'ctAXIAL',
viewportType: 'volume',
orientation: 'axial',
toolGroupId: 'ctToolGroup',
initialImageOptions: {
// index: 5,
preset: 'first', // 'first', 'last', 'middle'
},
syncGroups: [
{
type: 'cameraPosition',
id: 'axialSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ctWLSync',
source: true,
target: true,
},
],
},
displaySets: [
{
id: 'ctDisplaySet',
},
],
};
const ctSAGITTAL = {
viewportOptions: {
viewportId: 'ctSAGITTAL',
viewportType: 'volume',
orientation: 'sagittal',
toolGroupId: 'ctToolGroup',
syncGroups: [
{
type: 'cameraPosition',
id: 'sagittalSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ctWLSync',
source: true,
target: true,
},
],
},
displaySets: [
{
id: 'ctDisplaySet',
},
],
};
const ctCORONAL = {
viewportOptions: {
viewportId: 'ctCORONAL',
viewportType: 'volume',
orientation: 'coronal',
toolGroupId: 'ctToolGroup',
syncGroups: [
{
type: 'cameraPosition',
id: 'coronalSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ctWLSync',
source: true,
target: true,
},
],
},
displaySets: [
{
id: 'ctDisplaySet',
},
],
};
const ptAXIAL = {
viewportOptions: {
viewportId: 'ptAXIAL',
viewportType: 'volume',
background: [1, 1, 1],
orientation: 'axial',
toolGroupId: 'ptToolGroup',
initialImageOptions: {
// index: 5,
preset: 'first', // 'first', 'last', 'middle'
},
syncGroups: [
{
type: 'cameraPosition',
id: 'axialSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptWLSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptFusionWLSync',
source: true,
target: false,
},
],
},
displaySets: [
{
options: {
voi: {
windowWidth: 5,
windowCenter: 2.5,
},
voiInverted: true,
},
id: 'ptDisplaySet',
},
],
};
const ptSAGITTAL = {
viewportOptions: {
viewportId: 'ptSAGITTAL',
viewportType: 'volume',
orientation: 'sagittal',
background: [1, 1, 1],
toolGroupId: 'ptToolGroup',
syncGroups: [
{
type: 'cameraPosition',
id: 'sagittalSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptWLSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptFusionWLSync',
source: true,
target: false,
},
],
},
displaySets: [
{
options: {
voi: {
windowWidth: 5,
windowCenter: 2.5,
},
voiInverted: true,
},
id: 'ptDisplaySet',
},
],
};
const ptCORONAL = {
viewportOptions: {
viewportId: 'ptCORONAL',
viewportType: 'volume',
orientation: 'coronal',
background: [1, 1, 1],
toolGroupId: 'ptToolGroup',
syncGroups: [
{
type: 'cameraPosition',
id: 'coronalSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptWLSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptFusionWLSync',
source: true,
target: false,
},
],
},
displaySets: [
{
options: {
voi: {
windowWidth: 5,
windowCenter: 2.5,
},
voiInverted: true,
},
id: 'ptDisplaySet',
},
],
};
const fusionAXIAL = {
viewportOptions: {
viewportId: 'fusionAXIAL',
viewportType: 'volume',
orientation: 'axial',
toolGroupId: 'fusionToolGroup',
initialImageOptions: {
// index: 5,
preset: 'first', // 'first', 'last', 'middle'
},
syncGroups: [
{
type: 'cameraPosition',
id: 'axialSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ctWLSync',
source: false,
target: true,
},
{
type: 'voi',
id: 'fusionWLSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptFusionWLSync',
source: false,
target: true,
},
],
},
displaySets: [
{
id: 'ctDisplaySet',
},
{
options: {
colormap: 'hsv',
voi: {
windowWidth: 5,
windowCenter: 2.5,
},
},
id: 'ptDisplaySet',
},
],
};
const fusionSAGITTAL = {
viewportOptions: {
viewportId: 'fusionSAGITTAL',
viewportType: 'volume',
orientation: 'sagittal',
toolGroupId: 'fusionToolGroup',
// initialImageOptions: {
// index: 180,
// preset: 'middle', // 'first', 'last', 'middle'
// },
syncGroups: [
{
type: 'cameraPosition',
id: 'sagittalSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ctWLSync',
source: false,
target: true,
},
{
type: 'voi',
id: 'fusionWLSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptFusionWLSync',
source: false,
target: true,
},
],
},
displaySets: [
{
id: 'ctDisplaySet',
},
{
options: {
colormap: 'hsv',
voi: {
windowWidth: 5,
windowCenter: 2.5,
},
},
id: 'ptDisplaySet',
},
],
};
const fusionCORONAL = {
viewportOptions: {
viewportId: 'fusionCoronal',
viewportType: 'volume',
orientation: 'coronal',
toolGroupId: 'fusionToolGroup',
// initialImageOptions: {
// index: 180,
// preset: 'middle', // 'first', 'last', 'middle'
// },
syncGroups: [
{
type: 'cameraPosition',
id: 'coronalSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ctWLSync',
source: false,
target: true,
},
{
type: 'voi',
id: 'fusionWLSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptFusionWLSync',
source: false,
target: true,
},
],
},
displaySets: [
{
id: 'ctDisplaySet',
},
{
options: {
colormap: 'hsv',
voi: {
windowWidth: 5,
windowCenter: 2.5,
},
},
id: 'ptDisplaySet',
},
],
};
const mipSAGITTAL = {
viewportOptions: {
viewportId: 'mipSagittal',
viewportType: 'volume',
orientation: 'sagittal',
background: [1, 1, 1],
toolGroupId: 'mipToolGroup',
syncGroups: [
{
type: 'voi',
id: 'ptWLSync',
source: true,
target: true,
},
{
type: 'voi',
id: 'ptFusionWLSync',
source: true,
target: false,
},
],
// Custom props can be used to set custom properties which extensions
// can react on.
customViewportProps: {
// We use viewportDisplay to filter the viewports which are displayed
// in mip and we set the scrollbar according to their rotation index
// in the cornerstone extension.
hideOverlays: true,
},
},
displaySets: [
{
options: {
blendMode: 'MIP',
slabThickness: 'fullVolume',
voi: {
windowWidth: 5,
windowCenter: 2.5,
},
voiInverted: true,
},
id: 'ptDisplaySet',
},
],
};
export {
ctAXIAL,
ctSAGITTAL,
ctCORONAL,
ptAXIAL,
ptSAGITTAL,
ptCORONAL,
fusionAXIAL,
fusionSAGITTAL,
fusionCORONAL,
mipSAGITTAL,
};

View File

@ -140,7 +140,6 @@ function modeFactory({ modeConfiguration }) {
toolbarService,
} = servicesManager.services;
toolbarService.reset();
toolGroupService.destroy();
},
validationTags: {

View File

@ -72,6 +72,7 @@ function modeFactory() {
measurementService,
toolbarService,
toolGroupService,
customizationService,
} = servicesManager.services;
measurementService.clearMeasurements();
@ -79,6 +80,11 @@ function modeFactory() {
// Init Default and SR ToolGroups
initToolGroups(extensionManager, toolGroupService, commandsManager);
// init customizations
customizationService.addModeCustomizations([
'@ohif/extension-test.customizationModule.custom-context-menu',
]);
let unsubscribe;
const activateTool = () => {
@ -132,7 +138,6 @@ function modeFactory() {
cornerstoneViewportService,
} = servicesManager.services;
toolbarService.reset();
toolGroupService.destroy();
syncGroupService.destroy();
segmentationService.destroy();
@ -205,7 +210,12 @@ function modeFactory() {
dicompdf.sopClassHandler,
dicomsr.sopClassHandler,
],
hotkeys: [...hotkeys.defaults.hotkeyBindings],
hotkeys: {
// Don't store the hotkeys for basic-test-mode under the same key
// because they get customized by tests
name: 'basic-test-hotkeys',
hotkeys: [...hotkeys.defaults.hotkeyBindings],
},
};
}

View File

@ -297,10 +297,96 @@ const toolbarButtons = [
},
{
id: 'Layout',
type: 'ohif.layoutSelector',
type: 'ohif.splitButton',
props: {
rows: 3,
columns: 3,
groupId: 'LayoutTools',
isRadio: false,
primary: {
id: 'Layout',
type: 'action',
uiType: 'ohif.layoutSelector',
icon: 'tool-layout',
label: 'Grid Layout',
props: {
rows: 4,
columns: 4,
commands: [
{
commandName: 'setLayout',
commandOptions: {},
context: 'CORNERSTONE',
},
],
},
},
secondary: {
icon: 'chevron-down',
label: '',
isActive: true,
tooltip: 'Hanging Protocols',
},
items: [
{
id: '2x2',
type: 'action',
label: '2x2',
commands: [
{
commandName: 'setHangingProtocol',
commandOptions: {
protocolId: '@ohif/hp-extension.mn',
stageId: '2x2',
},
context: 'DEFAULT',
},
],
},
{
id: '3x1',
type: 'action',
label: '3x1',
commands: [
{
commandName: 'setHangingProtocol',
commandOptions: {
protocolId: '@ohif/hp-extension.mn',
stageId: '3x1',
},
context: 'DEFAULT',
},
],
},
{
id: '2x1',
type: 'action',
label: '2x1',
commands: [
{
commandName: 'setHangingProtocol',
commandOptions: {
protocolId: '@ohif/hp-extension.mn',
stageId: '2x1',
},
context: 'DEFAULT',
},
],
},
{
id: '1x1',
type: 'action',
label: '1x1',
commands: [
{
commandName: 'setHangingProtocol',
commandOptions: {
protocolId: '@ohif/hp-extension.mn',
stageId: '1x1',
},
context: 'DEFAULT',
},
],
},
],
},
},
{
@ -312,9 +398,11 @@ const toolbarButtons = [
label: 'MPR',
commands: [
{
commandName: 'toggleMPR',
commandOptions: {},
context: 'CORNERSTONE',
commandName: 'toggleHangingProtocol',
commandOptions: {
protocolId: 'mpr',
},
context: 'DEFAULT',
},
],
},

View File

@ -159,7 +159,6 @@ function modeFactory() {
_activatePanelTriggersSubscriptions.forEach(sub => sub.unsubscribe());
_activatePanelTriggersSubscriptions = [];
toolbarService.reset();
toolGroupService.destroy();
syncGroupService.destroy();
segmentationService.destroy();

View File

@ -215,6 +215,32 @@ function initMPRToolGroup(extensionManager, toolGroupService, commandsManager) {
toolGroupService.createToolGroupAndAddTools('mpr', tools, toolsConfig);
}
function initVolume3DToolGroup(extensionManager, toolGroupService) {
const utilityModule = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.utilityModule.tools'
);
const { toolNames, Enums } = utilityModule.exports;
const tools = {
active: [
{
toolName: toolNames.TrackballRotateTool,
bindings: [{ mouseButton: Enums.MouseBindings.Primary }],
},
{
toolName: toolNames.Zoom,
bindings: [{ mouseButton: Enums.MouseBindings.Secondary }],
},
{
toolName: toolNames.Pan,
bindings: [{ mouseButton: Enums.MouseBindings.Auxiliary }],
},
],
};
toolGroupService.createToolGroupAndAddTools('volume3d', tools);
}
function initToolGroups(extensionManager, toolGroupService, commandsManager) {
initDefaultToolGroup(
@ -225,6 +251,7 @@ function initToolGroups(extensionManager, toolGroupService, commandsManager) {
);
initSRToolGroup(extensionManager, toolGroupService, commandsManager);
initMPRToolGroup(extensionManager, toolGroupService, commandsManager);
initVolume3DToolGroup(extensionManager, toolGroupService);
}
export default initToolGroups;

View File

@ -27,18 +27,6 @@ function _createButton(type, id, icon, label, commands, tooltip, uiType) {
};
}
function _createCommands(commandName, toolName, toolGroupIds) {
return toolGroupIds.map(toolGroupId => ({
/* It's a command that is being run when the button is clicked. */
commandName,
commandOptions: {
toolName,
toolGroupId,
},
context: 'CORNERSTONE',
}));
}
const _createActionButton = _createButton.bind(null, 'action');
const _createToggleButton = _createButton.bind(null, 'toggle');
const _createToolButton = _createButton.bind(null, 'tool');
@ -67,6 +55,26 @@ function _createWwwcPreset(preset, title, subtitle) {
};
}
const toolGroupIds = ['default', 'mpr', 'SRToolGroup'];
/**
* Creates an array of 'setToolActive' commands for the given toolName - one for
* each toolGroupId specified in toolGroupIds.
* @param {string} toolName
* @returns {Array} an array of 'setToolActive' commands
*/
function _createSetToolActiveCommands(toolName) {
const temp = toolGroupIds.map(toolGroupId => ({
commandName: 'setToolActive',
commandOptions: {
toolGroupId,
toolName,
},
context: 'CORNERSTONE',
}));
return temp;
}
const toolbarButtons = [
// Measurement
{
@ -211,15 +219,7 @@ const toolbarButtons = [
type: 'tool',
icon: 'tool-zoom',
label: 'Zoom',
commands: [
{
commandName: 'setToolActive',
commandOptions: {
toolName: 'Zoom',
},
context: 'CORNERSTONE',
},
],
commands: _createSetToolActiveCommands('Zoom'),
},
},
// Window Level + Presets...
@ -268,15 +268,7 @@ const toolbarButtons = [
type: 'tool',
icon: 'tool-move',
label: 'Pan',
commands: [
{
commandName: 'setToolActive',
commandOptions: {
toolName: 'Pan',
},
context: 'CORNERSTONE',
},
],
commands: _createSetToolActiveCommands('Pan'),
},
},
{
@ -312,9 +304,11 @@ const toolbarButtons = [
label: 'MPR',
commands: [
{
commandName: 'toggleMPR',
commandOptions: {},
context: 'CORNERSTONE',
commandName: 'toggleHangingProtocol',
commandOptions: {
protocolId: 'mpr',
},
context: 'DEFAULT',
},
],
},
@ -330,8 +324,8 @@ const toolbarButtons = [
{
commandName: 'setToolActive',
commandOptions: {
toolGroupId: 'mpr',
toolName: 'Crosshairs',
toolGroupId: 'mpr',
},
context: 'CORNERSTONE',
},

View File

@ -136,13 +136,11 @@ function modeFactory({ modeConfiguration }) {
const {
toolGroupService,
syncGroupService,
toolbarService,
segmentationService,
cornerstoneViewportService,
} = servicesManager.services;
unsubscriptions.forEach(unsubscribe => unsubscribe());
toolbarService.reset();
toolGroupService.destroy();
syncGroupService.destroy();
segmentationService.destroy();

View File

@ -203,9 +203,11 @@ const toolbarButtons = [
label: 'MPR',
commands: [
{
commandName: 'toggleMPR',
commandOptions: {},
context: 'CORNERSTONE',
commandName: 'toggleHangingProtocol',
commandOptions: {
protocolId: 'mpr',
},
context: 'DEFAULT',
},
],
},

View File

@ -79,7 +79,7 @@
"@types/jest": "^27.5.0",
"@typescript-eslint/eslint-plugin": "^4.19.0",
"@typescript-eslint/parser": "^4.19.0",
"autoprefixer": "10.4.4",
"autoprefixer": "^10.4.4",
"babel-eslint": "9.x",
"babel-loader": "^8.2.4",
"babel-plugin-inline-react-svg": "1.1.0",

View File

@ -31,7 +31,7 @@
},
"peerDependencies": {
"cornerstone-math": "0.1.9",
"cornerstone-wado-image-loader": "^4.2.1",
"cornerstone-wado-image-loader": "^4.13.0",
"dicom-parser": "^1.8.9",
"@ohif/ui": "^2.0.0"
},

View File

@ -1,4 +1,5 @@
import log from '../log.js';
import { Command, Commands } from '../types/Command';
/**
* The definition of a command
@ -105,8 +106,8 @@ export class CommandsManager {
* @param {String} commandName - Command to find
* @param {String} [contextName] - Specific command to look in. Defaults to current activeContexts
*/
getCommand = (commandName, contextName) => {
let contexts = [];
getCommand = (commandName: string, contextName?: string) => {
const contexts = [];
if (contextName) {
const context = this.getContext(contextName);
@ -140,7 +141,7 @@ export class CommandsManager {
* @param {Object} [options={}] - Extra options to pass the command. Like a mousedown event
* @param {String} [contextName]
*/
runCommand(commandName, options = {}, contextName) {
public runCommand(commandName: string, options = {}, contextName?: string) {
const definition = this.getCommand(commandName, contextName);
if (!definition) {
log.warn(`Command "${commandName}" not found in current context`);
@ -161,6 +162,50 @@ export class CommandsManager {
return commandFn(commandParams);
}
}
/**
* Run one or more commands with specified extra options.
* Returns the result of the last command run.
*
* @param toRun - A specification of one or more commands
* @param options - to include in the commands run beyond
* the commandOptions specified in the base.
*/
public run(
toRun: Command | Commands | Command[] | undefined,
options?: Record<string, unknown>
): unknown {
if (!toRun) return;
const commands =
(Array.isArray(toRun) && toRun) ||
((toRun as Command).commandName && [toRun]) ||
(Array.isArray((toRun as Commands).commands) &&
(toRun as Commands).commands);
if (!commands) {
console.log("Command isn't runnable", toRun);
return;
}
let result;
(commands as Command[]).forEach(
({ commandName, commandOptions, context }) => {
if (commandName) {
result = this.runCommand(
commandName,
{
...commandOptions,
...options,
},
context
);
} else {
console.warn('No command name supplied in', toRun);
}
}
);
return result;
}
}
export default CommandsManager;

View File

@ -64,19 +64,17 @@ export class HotkeysManager {
*
* @param {HotkeyDefinition[] | Object} [hotkeyDefinitions=[]] Contains hotkeys definitions
*/
setHotkeys(hotkeyDefinitions = [], key = 'hotkey-definitions') {
setHotkeys(hotkeyDefinitions = [], name = 'hotkey-definitions') {
try {
const definitions = this.getValidDefinitions(hotkeyDefinitions);
if (isequal(definitions, this.hotkeyDefaults)) {
console.log('hotkeys REMOVING unused definition', key);
localStorage.removeItem(key);
localStorage.removeItem(name);
} else {
console.log('hotkeys setting local storage', key);
localStorage.setItem(key, JSON.stringify(definitions));
localStorage.setItem(name, JSON.stringify(definitions));
}
definitions.forEach(definition => this.registerHotkeys(definition));
} catch (error) {
const { uiNotificationService, } = this._servicesManager.services;
const { uiNotificationService } = this._servicesManager.services;
uiNotificationService.show({
title: 'Hotkeys Manager',
message: 'Error while setting hotkeys',

View File

@ -412,6 +412,34 @@ class MetadataProvider {
return metadata;
}
/**
* Retrieves the frameNumber information, depending on the url style
* wadors /frames/1
* wadouri &frame=1
* @param {*} imageId
* @returns
*/
getFrameInformationFromURL(imageId) {
function getInformationFromURL(informationString, separator) {
let result = '';
const splittedStr = imageId.split(informationString)[1];
if (splittedStr.includes(separator)) {
result = splittedStr.split(separator)[0];
} else {
result = splittedStr;
}
return result;
}
if (imageId.includes('/frames')) {
return getInformationFromURL('/frames', '/');
}
if (imageId.includes('&frame=')) {
return getInformationFromURL('&frame=', '&');
}
return;
}
getUIDsFromImageID(imageId) {
// TODO: adding csiv here is not really correct. Probably need to use
// metadataProvider.addImageIdToUIDs(imageId, {
@ -445,7 +473,7 @@ class MetadataProvider {
// check if the imageId starts with http:// or https:// using regex
// Todo: handle non http imageIds
let imageURI;
const urlRegex = /^(http|https):\/\//;
const urlRegex = /^(http|https|dicomfile):\/\//;
if (urlRegex.test(imageId)) {
imageURI = imageId;
} else {
@ -453,7 +481,7 @@ class MetadataProvider {
}
const uids = this.imageURIToUIDs.get(imageURI);
const frameNumber = imageId.split(/\/frames\//)[1];
let frameNumber = this.getFrameInformationFromURL(imageId) || '1';
if (uids && frameNumber !== undefined) {
return { ...uids, frameNumber };

View File

@ -88,6 +88,20 @@ const bindings = [
// keys: ['pagedown'],
// isEditable: true,
// },
{
commandName: 'nextStage',
context: 'DEFAULT',
label: 'Next Stage',
keys: ['.'],
isEditable: true,
},
{
commandName: 'previousStage',
context: 'DEFAULT',
label: 'Previous Stage',
keys: [','],
isEditable: true,
},
{
commandName: 'nextImage',
label: 'Next Image',

View File

@ -206,40 +206,40 @@ describe('ExtensionManager.ts', () => {
const extension = {
id: 'hello-world',
getViewportModule: () => {
return [{}];
return [{ name: 'test' }];
},
getSopClassHandlerModule: () => {
return [{}];
return [{ name: 'test' }];
},
getPanelModule: () => {
return [{}];
return [{ name: 'test' }];
},
getToolbarModule: () => {
return [{}];
return [{ name: 'test' }];
},
getCommandsModule: () => {
return [{}];
return [{ name: 'test' }];
},
getLayoutTemplateModule: () => {
return [{}];
return [{ name: 'test' }];
},
getDataSourcesModule: () => {
return [{}];
return [{ name: 'test' }];
},
getHangingProtocolModule: () => {
return [{}];
return [{ name: 'test' }];
},
getContextModule: () => {
return [{}];
return [{ name: 'test' }];
},
getUtilityModule: () => {
return [{}];
return [{ name: 'test' }];
},
getCustomizationModule: () => {
return [{}];
return [{ name: 'test' }];
},
getStateSyncModule: () => {
return [{}];
return [{ name: 'test' }];
},
};

View File

@ -36,7 +36,10 @@ export interface ExtensionParams extends ExtensionConstructor {
*/
export interface Extension {
id: string;
preRegistration?: (p: ExtensionParams) => void;
preRegistration?: (p: ExtensionParams) => Promise<void> | void;
onModeExit?: () => void;
getHangingProtocolModule?: (p: ExtensionParams) => unknown;
getCommandsModule?: (p: ExtensionParams) => CommandsModule;
}
export type ExtensionRegister = {
@ -44,6 +47,12 @@ export type ExtensionRegister = {
create: (p: ExtensionParams) => Extension;
};
export type CommandsModule = {
actions: Record<string, unknown>;
definitions: Record<string, unknown>;
defaultContext?: string;
};
export default class ExtensionManager {
private _commandsManager: CommandsManager;
private _servicesManager: ServicesManager;
@ -274,6 +283,11 @@ export default class ExtensionManager {
// Default for most extension points,
// Just adds each entry ready for consumption by mode.
extensionModule.forEach(element => {
if (!element.name) {
throw new Error(
`Extension ID ${extensionId} module ${moduleType} element has no name`
);
}
const id = `${extensionId}.${moduleType}.${element.name}`;
element.id = id;
this.modulesMap[id] = element;
@ -331,7 +345,7 @@ export default class ExtensionManager {
}
try {
const extensionModule = getModuleFn({
const extensionModule = extension[getModuleFnName]({
appConfig: this._appConfig,
commandsManager: this._commandsManager,
servicesManager: this._servicesManager,
@ -348,6 +362,7 @@ export default class ExtensionManager {
return extensionModule;
} catch (ex) {
console.log(ex);
throw new Error(
`Exception thrown while trying to call ${getModuleFnName} for the ${extensionId} extension`
);
@ -356,10 +371,10 @@ export default class ExtensionManager {
_initHangingProtocolsModule = (extensionModule, extensionId) => {
const { hangingProtocolService } = this._servicesManager.services;
extensionModule.forEach(({ id, protocol }) => {
extensionModule.forEach(({ name, protocol }) => {
if (protocol) {
// Only auto-register if protocol specified, otherwise let mode register
hangingProtocolService.addProtocol(id, protocol);
hangingProtocolService.addProtocol(name, protocol);
}
});
};

View File

@ -1,6 +1,7 @@
export default {
COMMANDS: 'commandsModule',
CUSTOMIZATION: 'customizationModule',
STATE_SYNC: 'stateSyncModule',
DATA_SOURCE: 'dataSourcesModule',
PANEL: 'panelModule',
SOP_CLASS_HANDLER: 'sopClassHandlerModule',

View File

@ -25,6 +25,7 @@ describe('Top level exports', () => {
//
'CineService',
'CustomizationService',
'StateSyncService',
'UIDialogService',
'UIModalService',
'UINotificationService',

View File

@ -29,6 +29,7 @@ import {
PubSubService,
UserAuthenticationService,
CustomizationService,
StateSyncService,
PanelService,
} from './services';
@ -61,6 +62,7 @@ const OHIF = {
//
CineService,
CustomizationService,
StateSyncService,
UIDialogService,
UIModalService,
UINotificationService,
@ -99,6 +101,7 @@ export {
//
CineService,
CustomizationService,
StateSyncService,
UIDialogService,
UIModalService,
UINotificationService,

View File

@ -63,7 +63,7 @@ export default class CustomizationService extends PubSubService {
modeCustomizations: Record<string, Customization> = {};
globalCustomizations: Record<string, Customization> = {};
configuration: UICustomizationConfiguration;
configuration: CustomizationConfiguration;
constructor({ configuration, commandsManager }) {
super(EVENTS);
@ -97,36 +97,6 @@ export default class CustomizationService extends PubSubService {
this.modeCustomizations = {};
}
/**
*
* @param {*} interaction - can be undefined to run nothing
* @param {*} extraOptions to include in the commands run
*/
recordInteraction(
interaction: Customization | void,
extraOptions?: Record<string, unknown>
): void {
if (!interaction) return;
const commandsManager = this.commandsManager;
const { commands = [] } = interaction;
commands.forEach(({ commandName, commandOptions, context }) => {
if (commandName) {
commandsManager.runCommand(
commandName,
{
interaction,
...commandOptions,
...extraOptions,
},
context
);
} else {
console.warn('No command name supplied in', interaction);
}
});
}
public getModeCustomizations(): Record<string, Customization> {
return this.modeCustomizations;
}
@ -145,6 +115,23 @@ export default class CustomizationService extends PubSubService {
});
}
/** This is the preferred getter for all customizations,
* getting mode customizations first and otherwise global customizations.
*
* @param customizationId - the customization id to look for
* @param defaultValue - is the default value to return. Note this value
* may have been extended with any customizationType extensions provided,
* so you cannot just use `|| defaultValue`
* @return A customization to use if one is found, or the default customization,
* both enhanced with any customizationType inheritance (see transform)
*/
public getCustomization(
customizationId: string,
defaultValue?: Customization
): Customization | void {
return this.getModeCustomization(customizationId, defaultValue);
}
/** Mode customizations are changes to the behaviour of the extensions
* when running in a given mode. Reset clears mode customizations.
* Note that global customizations over-ride mode customizations.
@ -158,7 +145,7 @@ export default class CustomizationService extends PubSubService {
this.globalCustomizations[customizationId] ??
this.modeCustomizations[customizationId] ??
defaultValue;
return this.applyType(customization);
return this.transform(customization);
}
public hasModeCustomization(customizationId: string) {
@ -167,16 +154,33 @@ export default class CustomizationService extends PubSubService {
this.modeCustomizations[customizationId]
);
}
/**
* get is an alias for getModeCustomization, as it is the generic getter
* which will return both mode and global customizations, and should be
* used generally.
* Note that the second parameter, defaultValue, will be expanded to include
* any customizationType values defined in it, so it is not the same as doing:
* `customizationService.get('key') || defaultValue`
* unless the defaultValue does not contain any customizationType definitions.
*/
public get = this.getModeCustomization;
/** Applies any inheritance due to UI Type customization */
public applyType(customization: Customization): Customization {
/**
* Applies any inheritance due to UI Type customization.
* This will look for customizationType in the customization object
* and if that is found, will assign all iterable values from that
* type into the new type, allowing default behaviour to be configured.
*/
public transform(customization: Customization): Customization {
if (!customization) return customization;
const { customizationType } = customization;
if (!customizationType) return customization;
const parent = this.getModeCustomization(customizationType);
return parent
const parent = this.getCustomization(customizationType);
const result = parent
? Object.assign(Object.create(parent), customization)
: customization;
// Execute an nested type information
return result.transform?.(this) || result;
}
public addModeCustomizations(modeCustomizations): void {
@ -203,7 +207,7 @@ export default class CustomizationService extends PubSubService {
id: string,
defaultValue?: Customization
): Customization | void {
return this.applyType(this.globalCustomizations[id] ?? defaultValue);
return this.transform(this.globalCustomizations[id] ?? defaultValue);
}
setGlobalCustomization(id: string, value: Customization): void {
@ -243,7 +247,7 @@ export default class CustomizationService extends PubSubService {
const extensionValue = this.findExtensionValue(value);
// The child of a reference is only a set of references when an array,
// so call the addReference direct. It could be a secondary reference perhaps
this.addReference(extensionValue);
this.addReference(extensionValue.value, isGlobal, extensionValue.name);
} else if (Array.isArray(value)) {
this.addReferences(value, isGlobal);
} else {

View File

@ -84,9 +84,9 @@ function _getInstance(StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID) {
}
function _getInstanceByImageId(imageId) {
for (let study of _model.studies) {
for (let series of study.series) {
for (let instance of series.instances) {
for (const study of _model.studies) {
for (const series of study.series) {
for (const instance of series.instances) {
if (instance.imageId === imageId) {
return instance;
}
@ -236,7 +236,7 @@ const BaseImplementation = {
addStudy(study) {
const { StudyInstanceUID } = study;
let existingStudy = _model.studies.find(
const existingStudy = _model.studies.find(
study => study.StudyInstanceUID === StudyInstanceUID
);

View File

@ -29,27 +29,31 @@ const match = (
let requiredFailed = false;
let score = 0;
// Allow for matching against current or prior specifically
const prior = options?.studies?.[1];
const current = options?.studies?.[0];
const instance = (metadataInstance.images || metadataInstance.others)?.[0];
const fromSrc = {
prior,
current,
instance,
...options,
options,
metadataInstance,
};
rules.forEach(rule => {
const { attribute } = rule;
const { attribute, from = 'metadataInstance' } = rule;
// Do not use the custom attribute from the metadataInstance since it is subject to change
if (customAttributeRetrievalCallbacks.hasOwnProperty(attribute)) {
readValues[attribute] = customAttributeRetrievalCallbacks[
attribute
].callback(metadataInstance, options);
].callback.call(rule, metadataInstance, options);
} else {
readValues[attribute] =
metadataInstance[attribute] ??
((metadataInstance.images || metadataInstance.others || [])[0] || {})[
attribute
];
fromSrc[from]?.[attribute] ?? instance?.[attribute];
}
console.log(
'Test',
attribute,
readValues[attribute],
JSON.stringify(rule.constraint)
);
// Format the constraint as required by Validate.js
const testConstraint = {
[attribute]: rule.constraint,
@ -70,6 +74,14 @@ const match = (
errorMessages = ['Something went wrong during validation.', e];
}
console.log(
'Test',
`${from}.${attribute}`,
readValues[attribute],
JSON.stringify(rule.constraint),
!errorMessages
);
if (!errorMessages) {
// If no errorMessages were returned, then validation passed.

View File

@ -117,8 +117,7 @@ const studyMatchDisplaySets = [displaySet3, displaySet2, displaySet1];
function checkHpsBestMatch(hps) {
hps.run({ studies: [studyMatch], displaySets: studyMatchDisplaySets });
const { hpAlreadyApplied, viewportMatchDetails } = hps.getMatchDetails();
expect(hpAlreadyApplied).toMatchObject(new Map([[0, false]]));
const { viewportMatchDetails } = hps.getMatchDetails();
expect(viewportMatchDetails.size).toBe(1);
expect(viewportMatchDetails.get(0)).toMatchObject({
viewportOptions: {
@ -131,9 +130,11 @@ function checkHpsBestMatch(hps) {
// ds2 fails to match required and ds3 fails to match an optional.
displaySetsInfo: [
{
SeriesInstanceUID: 'ds1',
displaySetInstanceUID: 'displaySet1',
displaySetOptions: {},
displaySetOptions: {
id: 'displaySetSelector',
options: {},
},
},
],
});
@ -191,14 +192,6 @@ describe('HangingProtocolService', () => {
it('matches best image match', () => {
checkHpsBestMatch(hangingProtocolService);
});
it('uses services manager', () => {
hangingProtocolService.run({
studies: [studyMatch],
displaySets: studyMatchDisplaySets,
});
expect(mockedFunction).toHaveBeenCalledTimes(1);
});
});
});
});

View File

@ -81,6 +81,16 @@ export default class ProtocolEngine {
});
}
/**
* finds the match results against the given display set or
* study instance by testing the given rules against this, and using
* the provided options for testing.
*
* @param {*} metaData to match against as primary value
* @param {*} rules to apply
* @param {*} options are additional values that can be used for matching
* @returns
*/
findMatch(metaData, rules, options) {
return HPMatcher.match(
metaData,
@ -109,7 +119,7 @@ export default class ProtocolEngine {
let rules = protocol.protocolMatchingRules.slice();
if (!rules || !rules.length) {
console.warn(
'ProtocolEngine::findMatchByStudy no matching rules - specify protocolMatchingRules',
'ProtocolEngine::findMatchByStudy no matching rules - specify protocolMatchingRules for',
protocol.id
);
return;

View File

@ -14,6 +14,41 @@ validate.validators.doesNotEqual = function(value, options, key) {
}
};
// Ignore case contains.
// options testValue MUST be in lower case already, otherwise it won't match
validate.validators.containsI = function (value, options, key) {
const testValue = options?.value ?? options;
if (Array.isArray(value)) {
if (
value.some(
item => !validate.validators.containsI(item.toLowerCase(), options, key)
)
) {
return undefined;
}
return `No item of ${value.join(',')} contains ${JSON.stringify(
testValue
)}`;
}
if (Array.isArray(testValue)) {
if (
testValue.some(
subTest => !validate.validators.containsI(value, subTest, key)
)
) {
return;
}
return `${key} must contain at least one of ${testValue.join(',')}`;
}
if (
testValue &&
value.indexOf &&
value.toLowerCase().indexOf(testValue) === -1
) {
return key + 'must contain any case of' + testValue;
}
};
validate.validators.contains = function(value, options, key) {
const testValue = options?.value ?? options;
if (Array.isArray(value)) {

View File

@ -3,9 +3,10 @@ import validate from './validator.js';
describe('validator', () => {
const attributeMap = {
str: 'string',
upper: 'UPPER',
num: 3,
nullValue: null,
list: ['abc', 'def'],
list: ['abc', 'def', 'GHI'],
};
const options = {
@ -35,6 +36,37 @@ describe('validator', () => {
});
});
describe('containsI', () => {
it('returns match any list contains case insensitive', () => {
expect(
validate(attributeMap, { upper: { containsI: ['bye', 'pre'] } }, [
options,
])
).not.toBeUndefined();
expect(
validate(attributeMap, { list: { containsI: 'hi' } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { list: { containsI: ['hi', 'bye'] } }, [
options,
])
).toBeUndefined();
expect(
validate(attributeMap, { list: { containsI: ['bye', 'hi'] } }, [
options,
])
).toBeUndefined();
expect(
validate(attributeMap, { list: { containsI: ['ig', 'hi'] } }, [options])
).toBeUndefined();
expect(
validate(attributeMap, { upper: { containsI: ['bye', 'per'] } }, [
options,
])
).toBeUndefined();
});
});
describe('equals', () => {
it('returned undefined on equals', () => {
expect(

View File

@ -459,7 +459,12 @@ class MeasurementService extends PubSubService {
log.warn(`Measurement ID not found. Generating UID: ${internalUID}`);
}
const annotationData = data.annotation.data;
const newMeasurement = {
finding: annotationData.finding,
findingSites: annotationData.findingSites,
site: annotationData.findingSites?.[0],
...measurement,
modifiedTimestamp: Math.floor(Date.now() / 1000),
uid: internalUID,
@ -472,7 +477,7 @@ class MeasurementService extends PubSubService {
measurement: newMeasurement,
});
} else {
log.info(`Measurement added.`, newMeasurement);
log.info('Measurement added', newMeasurement);
this.measurements[internalUID] = newMeasurement;
this._broadcastEvent(this.EVENTS.RAW_MEASUREMENT_ADDED, {
source,
@ -519,9 +524,14 @@ class MeasurementService extends PubSubService {
let measurement = {};
try {
const sourceMappings = this.mappings[source.uid];
const { toMeasurementSchema } = sourceMappings.find(
const sourceMapping = sourceMappings.find(
mapping => mapping.annotationType === annotationType
);
if (!sourceMapping) {
console.log('No source mapping', source);
return;
}
const { toMeasurementSchema } = sourceMapping;
/* Convert measurement */
measurement = toMeasurementSchema(sourceAnnotationDetail);
@ -548,26 +558,27 @@ class MeasurementService extends PubSubService {
);
}
const oldMeasurement = this.measurements[internalUID];
const newMeasurement = {
...oldMeasurement,
...measurement,
modifiedTimestamp: Math.floor(Date.now() / 1000),
uid: internalUID,
};
if (this.measurements[internalUID]) {
if (oldMeasurement) {
// TODO: Ultimately, each annotation should have a selected flag right from the soure.
// For now, it is just added in OHIF here and in setMeasurementSelected.
newMeasurement.selected = this.measurements[internalUID].selected;
this.measurements[internalUID] = newMeasurement;
if (isUpdate) {
this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, {
this._broadcastEvent(this.EVENTS.MEASUREMENT_UPDATED, {
source,
measurement: newMeasurement,
notYetUpdatedAtSource: false,
});
} else {
log.info('Measurement added.', newMeasurement);
this.measurements[internalUID] = newMeasurement;
this._broadcastEvent(this.EVENTS.MEASUREMENT_ADDED, {
source,
measurement: newMeasurement,

Some files were not shown because too many files have changed in this diff Show More