From 9f3235ff096636aafa88d8a42859e8dc85d9036d Mon Sep 17 00:00:00 2001 From: Ibrahim <93064150+IbrahimCSAE@users.noreply.github.com> Date: Tue, 2 Apr 2024 23:33:04 -0400 Subject: [PATCH] feat(segmentation): Enhanced segmentation panel design for TMTV (#3988) Co-authored-by: Alireza --- .../src/commandsModule.ts | 6 +- .../src/getPanelModule.tsx | 11 + .../src/getToolbarModule.ts | 4 +- .../src/panels/PanelSegmentation.tsx | 123 +++--- .../src/types/segmentation.tsx | 4 + .../cornerstone/src/getToolbarModule.tsx | 12 +- .../SegmentationService.ts | 1 + .../SegmentationServiceTypes.ts | 2 + extensions/default/src/Toolbar/Toolbar.tsx | 13 +- extensions/tmtv/.webpack/webpack.prod.js | 7 + extensions/tmtv/src/Panels/PanelPetSUV.tsx | 174 ++++---- .../LegacyPanelROIThresholdSegmentation.tsx | 287 +++++++++++++ .../PanelROIThresholdSegmentation.tsx | 396 ++++++++++-------- .../ROIThresholdConfiguration.tsx | 10 +- .../callInputDialog.tsx | 62 +++ .../colorPickerDialog.css | 3 + .../colorPickerDialog.tsx | 58 +++ .../tmtv/src/Panels/RectangleROIOptions.tsx | 214 ++++++++++ extensions/tmtv/src/commandsModule.js | 21 +- extensions/tmtv/src/getPanelModule.tsx | 31 +- extensions/tmtv/src/getToolbarModule.tsx | 10 + extensions/tmtv/src/index.tsx | 2 + .../RectangleROIStartEndThreshold.js | 4 - modes/longitudinal/src/toolbarButtons.ts | 10 +- modes/segmentation/src/segmentationButtons.ts | 3 +- modes/segmentation/src/toolbarButtons.ts | 10 +- modes/tmtv/src/index.js | 2 +- modes/tmtv/src/toolbarButtons.js | 40 +- platform/app/public/html-templates/index.html | 5 +- .../services/ToolBarService/ToolbarService.ts | 19 +- .../platform/extensions/modules/toolbar.md | 99 +++++ platform/ui/src/assets/styles/styles.css | 6 +- .../AdvancedToolbox/ToolSettings.tsx | 4 + .../components/ButtonGroup/ButtonGroup.tsx | 13 +- .../ui/src/components/Dropdown/Dropdown.tsx | 49 ++- platform/ui/src/components/Input/Input.tsx | 3 + .../components/PanelSection/PanelSection.tsx | 1 - .../SegmentationGroupTable/AddSegmentRow.tsx | 33 +- .../SegmentationDropDownRow.tsx | 22 +- .../SegmentationGroupSegment.tsx | 188 +++++---- .../SegmentationGroupTable.tsx | 4 +- .../SegmentationGroupTableExpanded.tsx | 211 ++++++++++ .../SegmentationItem.tsx | 211 ++++++++++ .../SegmentationGroupTable/index.js | 3 +- .../ToolbarButton/ToolbarButton.tsx | 3 +- .../ui/src/components/Toolbox/Toolbox.tsx | 25 +- .../ui/src/components/Toolbox/ToolboxUI.tsx | 26 +- .../ui/src/components/Tooltip/Tooltip.tsx | 122 ++++-- platform/ui/src/components/index.js | 3 +- platform/ui/src/index.js | 1 + yarn.lock | 2 +- 51 files changed, 2001 insertions(+), 572 deletions(-) create mode 100644 extensions/cornerstone-dicom-seg/src/types/segmentation.tsx create mode 100644 extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/LegacyPanelROIThresholdSegmentation.tsx create mode 100644 extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/callInputDialog.tsx create mode 100644 extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/colorPickerDialog.css create mode 100644 extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/colorPickerDialog.tsx create mode 100644 extensions/tmtv/src/Panels/RectangleROIOptions.tsx create mode 100644 extensions/tmtv/src/getToolbarModule.tsx create mode 100644 platform/ui/src/components/SegmentationGroupTable/SegmentationGroupTableExpanded.tsx create mode 100644 platform/ui/src/components/SegmentationGroupTable/SegmentationItem.tsx diff --git a/extensions/cornerstone-dicom-seg/src/commandsModule.ts b/extensions/cornerstone-dicom-seg/src/commandsModule.ts index c311e82aad8..5bff8aeef6e 100644 --- a/extensions/cornerstone-dicom-seg/src/commandsModule.ts +++ b/extensions/cornerstone-dicom-seg/src/commandsModule.ts @@ -206,6 +206,9 @@ const commandsModule = ({ loadSegmentationDisplaySetsForViewport: async ({ viewportId, displaySets }) => { // Todo: handle adding more than one segmentation const displaySet = displaySets[0]; + const referencedDisplaySet = displaySetService.getDisplaySetByUID( + displaySet.referencedDisplaySetInstanceUID + ); updateViewportsForSegmentationRendering({ viewportId, @@ -221,7 +224,8 @@ const commandsModule = ({ const boundFn = segmentationService[serviceFunction].bind(segmentationService); const segmentationId = await boundFn(segDisplaySet, null, suppressEvents); - + const segmentation = segmentationService.getSegmentation(segmentationId); + segmentation.description = `S${referencedDisplaySet.SeriesNumber}: ${referencedDisplaySet.SeriesDescription}`; return segmentationId; }, }); diff --git a/extensions/cornerstone-dicom-seg/src/getPanelModule.tsx b/extensions/cornerstone-dicom-seg/src/getPanelModule.tsx index 9227502afc0..12331caee9a 100644 --- a/extensions/cornerstone-dicom-seg/src/getPanelModule.tsx +++ b/extensions/cornerstone-dicom-seg/src/getPanelModule.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { useAppConfig } from '@state'; import { Toolbox } from '@ohif/ui'; import PanelSegmentation from './panels/PanelSegmentation'; +import { SegmentationPanelMode } from './types/segmentation'; const getPanelModule = ({ commandsManager, @@ -17,6 +18,9 @@ const getPanelModule = ({ const [appConfig] = useAppConfig(); const disableEditingForMode = customizationService.get('segmentation.disableEditing'); + const segmentationPanelMode = + customizationService.get('segmentation.segmentationPanelMode')?.value || + SegmentationPanelMode.Dropdown; return ( ); }; const wrappedPanelSegmentationWithTools = configuration => { + const [appConfig] = useAppConfig(); + const segmentationPanelMode = + customizationService.get('segmentation.segmentationPanelMode')?.value || + SegmentationPanelMode.Dropdown; + return ( <> diff --git a/extensions/cornerstone-dicom-seg/src/getToolbarModule.ts b/extensions/cornerstone-dicom-seg/src/getToolbarModule.ts index c334e98a57c..19390d2ac89 100644 --- a/extensions/cornerstone-dicom-seg/src/getToolbarModule.ts +++ b/extensions/cornerstone-dicom-seg/src/getToolbarModule.ts @@ -3,7 +3,7 @@ export function getToolbarModule({ commandsManager, servicesManager }) { return [ { name: 'evaluate.cornerstone.segmentation', - evaluate: ({ viewportId, button, toolNames }) => { + evaluate: ({ viewportId, button, toolNames, disabledText }) => { // Todo: we need to pass in the button section Id since we are kind of // forcing the button to have black background since initially // it is designed for the toolbox not the toolbar on top @@ -13,6 +13,7 @@ export function getToolbarModule({ commandsManager, servicesManager }) { return { disabled: true, className: '!text-common-bright !bg-black opacity-50', + disabledText: disabledText ?? 'No segmentations available', }; } @@ -28,6 +29,7 @@ export function getToolbarModule({ commandsManager, servicesManager }) { return { disabled: true, className: '!text-common-bright ohif-disabled', + disabledText: disabledText ?? 'Not available on the current viewport', }; } diff --git a/extensions/cornerstone-dicom-seg/src/panels/PanelSegmentation.tsx b/extensions/cornerstone-dicom-seg/src/panels/PanelSegmentation.tsx index ea1ab1de6ea..1a63ecd3a07 100644 --- a/extensions/cornerstone-dicom-seg/src/panels/PanelSegmentation.tsx +++ b/extensions/cornerstone-dicom-seg/src/panels/PanelSegmentation.tsx @@ -1,12 +1,17 @@ import { createReportAsync } from '@ohif/extension-default'; import React, { useEffect, useState, useCallback } from 'react'; import PropTypes from 'prop-types'; -import { SegmentationGroupTable } from '@ohif/ui'; - +import { SegmentationGroupTable, SegmentationGroupTableExpanded } from '@ohif/ui'; +import { SegmentationPanelMode } from '../types/segmentation'; import callInputDialog from './callInputDialog'; import callColorPickerDialog from './colorPickerDialog'; import { useTranslation } from 'react-i18next'; +const components = { + [SegmentationPanelMode.Expanded]: SegmentationGroupTableExpanded, + [SegmentationPanelMode.Dropdown]: SegmentationGroupTable, +}; + export default function PanelSegmentation({ servicesManager, commandsManager, @@ -170,6 +175,22 @@ export default function PanelSegmentation({ const onToggleSegmentationVisibility = segmentationId => { segmentationService.toggleSegmentationVisibility(segmentationId); + const segmentation = segmentationService.getSegmentation(segmentationId); + const isVisible = segmentation.isVisible; + const segments = segmentation.segments; + + const toolGroupIds = getToolGroupIds(segmentationId); + + toolGroupIds.forEach(toolGroupId => { + segments.forEach((segment, segmentIndex) => { + segmentationService.setSegmentVisibility( + segmentationId, + segmentIndex, + isVisible, + toolGroupId + ); + }); + }); }; const _setSegmentationConfiguration = useCallback( @@ -221,59 +242,53 @@ export default function PanelSegmentation({ }); }; + const SegmentationGroupTableComponent = components[configuration?.segmentationPanelMode]; + return ( - <> -
- - _setSegmentationConfiguration(selectedSegmentationId, 'renderOutline', value) - } - setOutlineOpacityActive={value => - _setSegmentationConfiguration(selectedSegmentationId, 'outlineOpacity', value) - } - setRenderFill={value => - _setSegmentationConfiguration(selectedSegmentationId, 'renderFill', value) - } - setRenderInactiveSegmentations={value => - _setSegmentationConfiguration( - selectedSegmentationId, - 'renderInactiveSegmentations', - value - ) - } - setOutlineWidthActive={value => - _setSegmentationConfiguration(selectedSegmentationId, 'outlineWidthActive', value) - } - setFillAlpha={value => - _setSegmentationConfiguration(selectedSegmentationId, 'fillAlpha', value) - } - setFillAlphaInactive={value => - _setSegmentationConfiguration(selectedSegmentationId, 'fillAlphaInactive', value) - } - /> -
- + + _setSegmentationConfiguration(selectedSegmentationId, 'renderOutline', value) + } + setOutlineOpacityActive={value => + _setSegmentationConfiguration(selectedSegmentationId, 'outlineOpacity', value) + } + setRenderFill={value => + _setSegmentationConfiguration(selectedSegmentationId, 'renderFill', value) + } + setRenderInactiveSegmentations={value => + _setSegmentationConfiguration(selectedSegmentationId, 'renderInactiveSegmentations', value) + } + setOutlineWidthActive={value => + _setSegmentationConfiguration(selectedSegmentationId, 'outlineWidthActive', value) + } + setFillAlpha={value => + _setSegmentationConfiguration(selectedSegmentationId, 'fillAlpha', value) + } + setFillAlphaInactive={value => + _setSegmentationConfiguration(selectedSegmentationId, 'fillAlphaInactive', value) + } + /> ); } diff --git a/extensions/cornerstone-dicom-seg/src/types/segmentation.tsx b/extensions/cornerstone-dicom-seg/src/types/segmentation.tsx new file mode 100644 index 00000000000..170c09ca47a --- /dev/null +++ b/extensions/cornerstone-dicom-seg/src/types/segmentation.tsx @@ -0,0 +1,4 @@ +export enum SegmentationPanelMode { + Expanded = 'expanded', + Dropdown = 'dropdown', +} diff --git a/extensions/cornerstone/src/getToolbarModule.tsx b/extensions/cornerstone/src/getToolbarModule.tsx index e6cbbe41b54..807eeb4f3fe 100644 --- a/extensions/cornerstone/src/getToolbarModule.tsx +++ b/extensions/cornerstone/src/getToolbarModule.tsx @@ -21,7 +21,7 @@ export default function getToolbarModule({ commandsManager, servicesManager }) { // enabled or not { name: 'evaluate.cornerstoneTool', - evaluate: ({ viewportId, button }) => { + evaluate: ({ viewportId, button, disabledText }) => { const toolGroup = toolGroupService.getToolGroupForViewport(viewportId); if (!toolGroup) { @@ -34,6 +34,7 @@ export default function getToolbarModule({ commandsManager, servicesManager }) { return { disabled: true, className: '!text-common-bright ohif-disabled', + disabledText: disabledText ?? 'Not available on the current viewport', }; } @@ -109,7 +110,7 @@ export default function getToolbarModule({ commandsManager, servicesManager }) { }, { name: 'evaluate.cornerstoneTool.toggle', - evaluate: ({ viewportId, button }) => { + evaluate: ({ viewportId, button, disabledText }) => { const toolGroup = toolGroupService.getToolGroupForViewport(viewportId); if (!toolGroup) { @@ -121,6 +122,7 @@ export default function getToolbarModule({ commandsManager, servicesManager }) { return { disabled: true, className: '!text-common-bright ohif-disabled', + disabledText: disabledText ?? 'Not available on the current viewport', }; } @@ -168,13 +170,14 @@ export default function getToolbarModule({ commandsManager, servicesManager }) { }, { name: 'evaluate.not3D', - evaluate: ({ viewportId, button }) => { + evaluate: ({ viewportId, disabledText }) => { const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); if (viewport?.type === 'volume3d') { return { disabled: true, className: '!text-common-bright ohif-disabled', + disabledText: disabledText ?? 'Not available on the current viewport', }; } }, @@ -211,7 +214,7 @@ export default function getToolbarModule({ commandsManager, servicesManager }) { }, { name: 'evaluate.mpr', - evaluate: ({ viewportId, button }) => { + evaluate: ({ viewportId, disabledText = 'Selected viewport is not reconstructable' }) => { const { protocol } = hangingProtocolService.getActiveProtocol(); const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId); @@ -230,6 +233,7 @@ export default function getToolbarModule({ commandsManager, servicesManager }) { return { disabled: true, className: '!text-common-bright ohif-disabled', + disabledText: disabledText ?? 'Not available on the current viewport', }; } diff --git a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts index c073f3f130c..97428eb35c3 100644 --- a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts +++ b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts @@ -988,6 +988,7 @@ class SegmentationService extends PubSubService { referencedVolumeId: volumeId, // Todo: this is so ugly }, }, + description: `S${displaySet.SeriesNumber}: ${displaySet.SeriesDescription}`, }; this.addOrUpdateSegmentation(segmentation); diff --git a/extensions/cornerstone/src/services/SegmentationService/SegmentationServiceTypes.ts b/extensions/cornerstone/src/services/SegmentationService/SegmentationServiceTypes.ts index 7262b0e6d6f..cb65154d26d 100644 --- a/extensions/cornerstone/src/services/SegmentationService/SegmentationServiceTypes.ts +++ b/extensions/cornerstone/src/services/SegmentationService/SegmentationServiceTypes.ts @@ -20,6 +20,8 @@ type Segment = { isVisible: boolean; // whether the segment is locked isLocked: boolean; + // display texts + displayText?: string[]; }; type Segmentation = { diff --git a/extensions/default/src/Toolbar/Toolbar.tsx b/extensions/default/src/Toolbar/Toolbar.tsx index 4852026e91c..000a2b1dac0 100644 --- a/extensions/default/src/Toolbar/Toolbar.tsx +++ b/extensions/default/src/Toolbar/Toolbar.tsx @@ -21,8 +21,6 @@ export function Toolbar({ servicesManager }) { } const { id, Component, componentProps } = toolDef; - const { disabled } = componentProps; - const tool = ( ); - return disabled ? ( - -
{tool}
-
- ) : ( + return (
{ const commonConfig = webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY }); @@ -42,6 +45,10 @@ module.exports = (env, argv) => { new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1, }), + new MiniCssExtractPlugin({ + filename: `./dist/${outputName}.css`, + chunkFilename: `./dist/${outputName}.css`, + }), ], }); }; diff --git a/extensions/tmtv/src/Panels/PanelPetSUV.tsx b/extensions/tmtv/src/Panels/PanelPetSUV.tsx index 618ff051c79..3252a08ac16 100644 --- a/extensions/tmtv/src/Panels/PanelPetSUV.tsx +++ b/extensions/tmtv/src/Panels/PanelPetSUV.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; -import { Input, Button } from '@ohif/ui'; +import { PanelSection, Input, Button } from '@ohif/ui'; import { DicomMetadataStore, ServicesManager } from '@ohif/core'; import { useTranslation } from 'react-i18next'; @@ -126,84 +126,102 @@ export default function PanelPetSUV({ servicesManager, commandsManager }) { }, 0); } return ( -
- { -
-
- { - handleMetadataChange({ - PatientSex: e.target.value, - }); - }} - /> - { - handleMetadataChange({ - PatientWeight: e.target.value, - }); - }} - /> - { - handleMetadataChange({ - RadiopharmaceuticalInformationSequence: { - RadionuclideTotalDose: e.target.value, - }, - }); - }} - /> - { - handleMetadataChange({ - RadiopharmaceuticalInformationSequence: { - RadionuclideHalfLife: e.target.value, - }, - }); - }} - /> - { - handleMetadataChange({ - RadiopharmaceuticalInformationSequence: { - RadiopharmaceuticalStartTime: e.target.value, - }, - }); - }} - /> - {}} - /> - +
+
+ +
+
+ { + handleMetadataChange({ + PatientSex: e.target.value, + }); + }} + /> + kg} + labelClassName="text-[13px] font-inter text-white" + className="!m-0 !h-[26px] !w-[117px]" + value={metadata.PatientWeight || ''} + onChange={e => { + handleMetadataChange({ + PatientWeight: e.target.value, + }); + }} + /> + bq} + labelClassName="text-[13px] font-inter text-white" + className="!m-0 !h-[26px] !w-[117px]" + value={metadata.RadiopharmaceuticalInformationSequence.RadionuclideTotalDose || ''} + onChange={e => { + handleMetadataChange({ + RadiopharmaceuticalInformationSequence: { + RadionuclideTotalDose: e.target.value, + }, + }); + }} + /> + s} + labelClassName="text-[13px] font-inter text-white" + className="!m-0 !h-[26px] !w-[117px]" + value={metadata.RadiopharmaceuticalInformationSequence.RadionuclideHalfLife || ''} + onChange={e => { + handleMetadataChange({ + RadiopharmaceuticalInformationSequence: { + RadionuclideHalfLife: e.target.value, + }, + }); + }} + /> + s} + labelClassName="text-[13px] font-inter text-white" + className="!m-0 !h-[26px] !w-[117px]" + value={ + metadata.RadiopharmaceuticalInformationSequence.RadiopharmaceuticalStartTime || '' + } + onChange={e => { + handleMetadataChange({ + RadiopharmaceuticalInformationSequence: { + RadiopharmaceuticalStartTime: e.target.value, + }, + }); + }} + /> + s} + labelClassName="text-[13px] font-inter text-white" + className="!m-0 !h-[26px] !w-[117px]" + value={metadata.SeriesTime || ''} + onChange={() => {}} + /> + +
-
- } + +
); } diff --git a/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/LegacyPanelROIThresholdSegmentation.tsx b/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/LegacyPanelROIThresholdSegmentation.tsx new file mode 100644 index 00000000000..ed20412cae3 --- /dev/null +++ b/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/LegacyPanelROIThresholdSegmentation.tsx @@ -0,0 +1,287 @@ +import React, { useEffect, useState, useCallback, useReducer } from 'react'; +import PropTypes from 'prop-types'; +import { SegmentationTable, Button, Icon } from '@ohif/ui'; + +import { useTranslation } from 'react-i18next'; +import segmentationEditHandler from './segmentationEditHandler'; +import ExportReports from './ExportReports'; +import ROIThresholdConfiguration, { ROI_STAT } from './ROIThresholdConfiguration'; + +const LOWER_CT_THRESHOLD_DEFAULT = -1024; +const UPPER_CT_THRESHOLD_DEFAULT = 1024; +const LOWER_PT_THRESHOLD_DEFAULT = 2.5; +const UPPER_PT_THRESHOLD_DEFAULT = 100; +const WEIGHT_DEFAULT = 0.41; // a default weight for suv max often used in the literature +const DEFAULT_STRATEGY = ROI_STAT; + +function reducer(state, action) { + const { payload } = action; + const { strategy, ctLower, ctUpper, ptLower, ptUpper, weight } = payload; + + switch (action.type) { + case 'setStrategy': + return { + ...state, + strategy, + }; + case 'setThreshold': + return { + ...state, + ctLower: ctLower ? ctLower : state.ctLower, + ctUpper: ctUpper ? ctUpper : state.ctUpper, + ptLower: ptLower ? ptLower : state.ptLower, + ptUpper: ptUpper ? ptUpper : state.ptUpper, + }; + case 'setWeight': + return { + ...state, + weight, + }; + default: + return state; + } +} + +export default function LegacyPanelRoiThresholdSegmentation({ servicesManager, commandsManager }) { + const { segmentationService } = servicesManager.services; + + const { t } = useTranslation('PanelSUV'); + const [showConfig, setShowConfig] = useState(false); + const [labelmapLoading, setLabelmapLoading] = useState(false); + const [selectedSegmentationId, setSelectedSegmentationId] = useState(null); + const [segmentations, setSegmentations] = useState(() => segmentationService.getSegmentations()); + + const [config, dispatch] = useReducer(reducer, { + strategy: DEFAULT_STRATEGY, + ctLower: LOWER_CT_THRESHOLD_DEFAULT, + ctUpper: UPPER_CT_THRESHOLD_DEFAULT, + ptLower: LOWER_PT_THRESHOLD_DEFAULT, + ptUpper: UPPER_PT_THRESHOLD_DEFAULT, + weight: WEIGHT_DEFAULT, + }); + + const [tmtvValue, setTmtvValue] = useState(null); + + const runCommand = useCallback( + (commandName, commandOptions = {}) => { + return commandsManager.runCommand(commandName, commandOptions); + }, + [commandsManager] + ); + + const handleTMTVCalculation = useCallback(() => { + const tmtv = runCommand('calculateTMTV', { segmentations }); + + if (tmtv !== undefined) { + setTmtvValue(tmtv.toFixed(2)); + } + }, [segmentations, runCommand]); + + const handleROIThresholding = useCallback(() => { + const labelmap = runCommand('thresholdSegmentationByRectangleROITool', { + segmentationId: selectedSegmentationId, + config, + }); + + const lesionStats = runCommand('getLesionStats', { labelmap }); + const suvPeak = runCommand('calculateSuvPeak', { labelmap }); + const lesionGlyoclysisStats = lesionStats.volume * lesionStats.meanValue; + + // update segDetails with the suv peak for the active segmentation + const segmentation = segmentationService.getSegmentation(selectedSegmentationId); + + const cachedStats = { + lesionStats, + suvPeak, + lesionGlyoclysisStats, + }; + + const notYetUpdatedAtSource = true; + segmentationService.addOrUpdateSegmentation( + { + ...segmentation, + ...Object.assign(segmentation.cachedStats, cachedStats), + displayText: [`SUV Peak: ${suvPeak.suvPeak.toFixed(2)}`], + }, + notYetUpdatedAtSource + ); + + handleTMTVCalculation(); + }, [selectedSegmentationId, config]); + + /** + * Update UI based on segmentation changes (added, removed, updated) + */ + useEffect(() => { + // ~~ Subscription + const added = segmentationService.EVENTS.SEGMENTATION_ADDED; + const updated = segmentationService.EVENTS.SEGMENTATION_UPDATED; + const subscriptions = []; + + [added, updated].forEach(evt => { + const { unsubscribe } = segmentationService.subscribe(evt, () => { + const segmentations = segmentationService.getSegmentations(); + setSegmentations(segmentations); + }); + subscriptions.push(unsubscribe); + }); + + return () => { + subscriptions.forEach(unsub => { + unsub(); + }); + }; + }, []); + + useEffect(() => { + const { unsubscribe } = segmentationService.subscribe( + segmentationService.EVENTS.SEGMENTATION_REMOVED, + () => { + const segmentations = segmentationService.getSegmentations(); + setSegmentations(segmentations); + + if (segmentations.length > 0) { + setSelectedSegmentationId(segmentations[0].id); + handleTMTVCalculation(); + } else { + setSelectedSegmentationId(null); + setTmtvValue(null); + } + } + ); + + return () => { + unsubscribe(); + }; + }, []); + + /** + * Whenever the segmentations change, update the TMTV calculations + */ + useEffect(() => { + if (!selectedSegmentationId && segmentations.length > 0) { + setSelectedSegmentationId(segmentations[0].id); + } + + handleTMTVCalculation(); + }, [segmentations, selectedSegmentationId]); + + return ( + <> +
+
+
+ + +
+
{ + setShowConfig(!showConfig); + }} + > +
{t('ROI Threshold Configuration')}
+
+ {showConfig && ( + + )} + {/* show segmentation table */} +
+ {segmentations?.length ? ( + { + runCommand('setSegmentationActiveForToolGroups', { + segmentationId: id, + }); + setSelectedSegmentationId(id); + }} + onToggleVisibility={id => { + segmentationService.toggleSegmentationVisibility(id); + }} + onToggleVisibilityAll={ids => { + ids.map(id => { + segmentationService.toggleSegmentationVisibility(id); + }); + }} + onDelete={id => { + segmentationService.remove(id); + }} + onEdit={id => { + segmentationEditHandler({ + id, + servicesManager, + }); + }} + /> + ) : null} +
+ {tmtvValue !== null ? ( +
+ + {'TMTV:'} + +
{`${tmtvValue} mL`}
+
+ ) : null} + +
+
+
{ + // navigate to a url in a new tab + window.open('https://github.com/OHIF/Viewers/blob/master/modes/tmtv/README.md', '_blank'); + }} + > + + {'User Guide'} +
+ + ); +} + +LegacyPanelRoiThresholdSegmentation.propTypes = { + commandsManager: PropTypes.shape({ + runCommand: PropTypes.func.isRequired, + }), + servicesManager: PropTypes.shape({ + services: PropTypes.shape({ + segmentationService: PropTypes.shape({ + getSegmentation: PropTypes.func.isRequired, + getSegmentations: PropTypes.func.isRequired, + toggleSegmentationVisibility: PropTypes.func.isRequired, + subscribe: PropTypes.func.isRequired, + EVENTS: PropTypes.object.isRequired, + }).isRequired, + }).isRequired, + }).isRequired, +}; diff --git a/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/PanelROIThresholdSegmentation.tsx b/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/PanelROIThresholdSegmentation.tsx index 306c39c9461..dd5c0cdbbaf 100644 --- a/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/PanelROIThresholdSegmentation.tsx +++ b/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/PanelROIThresholdSegmentation.tsx @@ -1,67 +1,23 @@ -import React, { useEffect, useState, useCallback, useReducer } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import PropTypes from 'prop-types'; -import { SegmentationTable, Button, Icon } from '@ohif/ui'; +import { SegmentationGroupTableExpanded, Icon } from '@ohif/ui'; +import { createReportAsync } from '@ohif/extension-default'; -import { useTranslation } from 'react-i18next'; import segmentationEditHandler from './segmentationEditHandler'; import ExportReports from './ExportReports'; -import ROIThresholdConfiguration, { ROI_STAT } from './ROIThresholdConfiguration'; - -const LOWER_CT_THRESHOLD_DEFAULT = -1024; -const UPPER_CT_THRESHOLD_DEFAULT = 1024; -const LOWER_PT_THRESHOLD_DEFAULT = 2.5; -const UPPER_PT_THRESHOLD_DEFAULT = 100; -const WEIGHT_DEFAULT = 0.41; // a default weight for suv max often used in the literature -const DEFAULT_STRATEGY = ROI_STAT; - -function reducer(state, action) { - const { payload } = action; - const { strategy, ctLower, ctUpper, ptLower, ptUpper, weight } = payload; - - switch (action.type) { - case 'setStrategy': - return { - ...state, - strategy, - }; - case 'setThreshold': - return { - ...state, - ctLower: ctLower ? ctLower : state.ctLower, - ctUpper: ctUpper ? ctUpper : state.ctUpper, - ptLower: ptLower ? ptLower : state.ptLower, - ptUpper: ptUpper ? ptUpper : state.ptUpper, - }; - case 'setWeight': - return { - ...state, - weight, - }; - default: - return state; - } -} +import callInputDialog from './callInputDialog'; +import callColorPickerDialog from './colorPickerDialog'; -export default function PanelRoiThresholdSegmentation({ servicesManager, commandsManager }) { - const { segmentationService } = servicesManager.services; +export default function PanelRoiThresholdSegmentation({ + servicesManager, + commandsManager, + extensionManager, +}) { + const { segmentationService, viewportGridService, uiDialogService } = servicesManager.services; - const { t } = useTranslation('PanelSUV'); - const [showConfig, setShowConfig] = useState(false); - const [labelmapLoading, setLabelmapLoading] = useState(false); const [selectedSegmentationId, setSelectedSegmentationId] = useState(null); const [segmentations, setSegmentations] = useState(() => segmentationService.getSegmentations()); - const [config, dispatch] = useReducer(reducer, { - strategy: DEFAULT_STRATEGY, - ctLower: LOWER_CT_THRESHOLD_DEFAULT, - ctUpper: UPPER_CT_THRESHOLD_DEFAULT, - ptLower: LOWER_PT_THRESHOLD_DEFAULT, - ptUpper: UPPER_PT_THRESHOLD_DEFAULT, - weight: WEIGHT_DEFAULT, - }); - - const [tmtvValue, setTmtvValue] = useState(null); - const runCommand = useCallback( (commandName, commandOptions = {}) => { return commandsManager.runCommand(commandName, commandOptions); @@ -69,46 +25,6 @@ export default function PanelRoiThresholdSegmentation({ servicesManager, command [commandsManager] ); - const handleTMTVCalculation = useCallback(() => { - const tmtv = runCommand('calculateTMTV', { segmentations }); - - if (tmtv !== undefined) { - setTmtvValue(tmtv.toFixed(2)); - } - }, [segmentations, runCommand]); - - const handleROIThresholding = useCallback(() => { - const labelmap = runCommand('thresholdSegmentationByRectangleROITool', { - segmentationId: selectedSegmentationId, - config, - }); - - const lesionStats = runCommand('getLesionStats', { labelmap }); - const suvPeak = runCommand('calculateSuvPeak', { labelmap }); - const lesionGlyoclysisStats = lesionStats.volume * lesionStats.meanValue; - - // update segDetails with the suv peak for the active segmentation - const segmentation = segmentationService.getSegmentation(selectedSegmentationId); - - const cachedStats = { - lesionStats, - suvPeak, - lesionGlyoclysisStats, - }; - - const notYetUpdatedAtSource = true; - segmentationService.addOrUpdateSegmentation( - { - ...segmentation, - ...Object.assign(segmentation.cachedStats, cachedStats), - displayText: [`SUV Peak: ${suvPeak.suvPeak.toFixed(2)}`], - }, - notYetUpdatedAtSource - ); - - handleTMTVCalculation(); - }, [selectedSegmentationId, config]); - /** * Update UI based on segmentation changes (added, removed, updated) */ @@ -116,9 +32,10 @@ export default function PanelRoiThresholdSegmentation({ servicesManager, command // ~~ Subscription const added = segmentationService.EVENTS.SEGMENTATION_ADDED; const updated = segmentationService.EVENTS.SEGMENTATION_UPDATED; + const removed = segmentationService.EVENTS.SEGMENTATION_REMOVED; const subscriptions = []; - [added, updated].forEach(evt => { + [added, updated, removed].forEach(evt => { const { unsubscribe } = segmentationService.subscribe(evt, () => { const segmentations = segmentationService.getSegmentations(); setSegmentations(segmentations); @@ -133,109 +50,222 @@ export default function PanelRoiThresholdSegmentation({ servicesManager, command }; }, []); - useEffect(() => { - const { unsubscribe } = segmentationService.subscribe( - segmentationService.EVENTS.SEGMENTATION_REMOVED, - () => { - const segmentations = segmentationService.getSegmentations(); - setSegmentations(segmentations); + const onSegmentationClick = (segmentationId: string) => { + segmentationService.setActiveSegmentationForToolGroup(segmentationId); + setSelectedSegmentationId(segmentationId); + }; + + const onSegmentationAdd = async () => { + runCommand('createNewLabelmapFromPT').then(segmentationId => { + setSelectedSegmentationId(segmentationId); + }); + }; + + const onSegmentAdd = segmentationId => { + segmentationService.addSegment(segmentationId); + }; + + const getToolGroupIds = segmentationId => { + const toolGroupIds = segmentationService.getToolGroupIdsWithSegmentation(segmentationId); + + return toolGroupIds; + }; + + const onSegmentClick = (segmentationId, segmentIndex) => { + segmentationService.setActiveSegment(segmentationId, segmentIndex); + + const toolGroupIds = getToolGroupIds(segmentationId); + + toolGroupIds.forEach(toolGroupId => { + // const toolGroupId = + segmentationService.setActiveSegmentationForToolGroup(segmentationId, toolGroupId); + segmentationService.jumpToSegmentCenter(segmentationId, segmentIndex, toolGroupId); + }); + }; + + const _setSegmentationConfiguration = useCallback( + (segmentationId, key, value) => { + segmentationService.setConfiguration({ + segmentationId, + [key]: value, + }); + }, + [segmentationService] + ); + + const onToggleSegmentVisibility = (segmentationId, segmentIndex) => { + const segmentation = segmentationService.getSegmentation(segmentationId); + const segmentInfo = segmentation.segments[segmentIndex]; + const isVisible = !segmentInfo.isVisible; + const toolGroupIds = getToolGroupIds(segmentationId); + toolGroupIds.forEach(toolGroupId => { + segmentationService.setSegmentVisibility( + segmentationId, + segmentIndex, + isVisible, + toolGroupId + ); + }); + }; - if (segmentations.length > 0) { - setSelectedSegmentationId(segmentations[0].id); - handleTMTVCalculation(); - } else { - setSelectedSegmentationId(null); - setTmtvValue(null); - } + const onSegmentDelete = (segmentationId, segmentIndex) => { + segmentationService.removeSegment(segmentationId, segmentIndex); + }; + + const onSegmentEdit = (segmentationId, segmentIndex) => { + const segmentation = segmentationService.getSegmentation(segmentationId); + + const segment = segmentation.segments[segmentIndex]; + const { label } = segment; + + callInputDialog(uiDialogService, label, (label, actionId) => { + if (label === '') { + return; } - ); - return () => { - unsubscribe(); + segmentationService.setSegmentLabel(segmentationId, segmentIndex, label); + }); + }; + + const onToggleSegmentLock = (segmentationId, segmentIndex) => { + segmentationService.toggleSegmentLocked(segmentationId, segmentIndex); + }; + + const onSegmentColorClick = (segmentationId, segmentIndex) => { + const segmentation = segmentationService.getSegmentation(segmentationId); + + const segment = segmentation.segments[segmentIndex]; + const { color, opacity } = segment; + + const rgbaColor = { + r: color[0], + g: color[1], + b: color[2], + a: opacity / 255.0, }; - }, []); - /** - * Whenever the segmentations change, update the TMTV calculations - */ - useEffect(() => { - if (!selectedSegmentationId && segmentations.length > 0) { - setSelectedSegmentationId(segmentations[0].id); + callColorPickerDialog(uiDialogService, rgbaColor, (newRgbaColor, actionId) => { + if (actionId === 'cancel') { + return; + } + + segmentationService.setSegmentRGBAColor(segmentationId, segmentIndex, [ + newRgbaColor.r, + newRgbaColor.g, + newRgbaColor.b, + newRgbaColor.a * 255.0, + ]); + }); + }; + + const storeSegmentation = async segmentationId => { + const datasources = extensionManager.getActiveDataSource(); + + const displaySetInstanceUIDs = await createReportAsync({ + servicesManager, + getReport: () => + commandsManager.runCommand('storeSegmentation', { + segmentationId, + dataSource: datasources[0], + }), + reportType: 'Segmentation', + }); + + // Show the exported report in the active viewport as read only (similar to SR) + if (displaySetInstanceUIDs) { + // clear the segmentation that we exported, similar to the storeMeasurement + // where we remove the measurements and prompt again the user if they would like + // to re-read the measurements in a SR read only viewport + segmentationService.remove(segmentationId); + + viewportGridService.setDisplaySetsForViewport({ + viewportId: viewportGridService.getActiveViewportId(), + displaySetInstanceUIDs, + }); } + }; + + const onSegmentationDownloadRTSS = segmentationId => { + commandsManager.runCommand('downloadRTSS', { + segmentationId, + }); + }; - handleTMTVCalculation(); - }, [segmentations, selectedSegmentationId]); + const onSegmentationDownload = segmentationId => { + commandsManager.runCommand('downloadSegmentation', { + segmentationId, + }); + }; + + const tmtvValue = segmentations?.[0]?.cachedStats?.tmtv?.value || null; + const config = segmentations?.[0]?.cachedStats?.tmtv?.config || {}; return ( <>
-
- - -
-
{ - setShowConfig(!showConfig); - }} - > -
{t('ROI Threshold Configuration')}
-
- {showConfig && ( - + _setSegmentationConfiguration(selectedSegmentationId, 'renderOutline', value) + } + setOutlineOpacityActive={value => + _setSegmentationConfiguration(selectedSegmentationId, 'outlineOpacity', value) + } + setRenderFill={value => + _setSegmentationConfiguration(selectedSegmentationId, 'renderFill', value) + } + setRenderInactiveSegmentations={value => + _setSegmentationConfiguration( + selectedSegmentationId, + 'renderInactiveSegmentations', + value + ) + } + setOutlineWidthActive={value => + _setSegmentationConfiguration(selectedSegmentationId, 'outlineWidthActive', value) + } + setFillAlpha={value => + _setSegmentationConfiguration(selectedSegmentationId, 'fillAlpha', value) + } + setFillAlphaInactive={value => + _setSegmentationConfiguration(selectedSegmentationId, 'fillAlphaInactive', value) + } /> - )} - {/* show segmentation table */} -
- {segmentations?.length ? ( - { - runCommand('setSegmentationActiveForToolGroups', { - segmentationId: id, - }); - setSelectedSegmentationId(id); - }} - onToggleVisibility={id => { - segmentationService.toggleSegmentationVisibility(id); - }} - onToggleVisibilityAll={ids => { - ids.map(id => { - segmentationService.toggleSegmentationVisibility(id); - }); - }} - onDelete={id => { - segmentationService.remove(id); - }} - onEdit={id => { - segmentationEditHandler({ - id, - servicesManager, - }); - }} - /> - ) : null}
{tmtvValue !== null ? ( -
+
{'TMTV:'} @@ -251,7 +281,7 @@ export default function PanelRoiThresholdSegmentation({ servicesManager, command
{ // navigate to a url in a new tab window.open('https://github.com/OHIF/Viewers/blob/master/modes/tmtv/README.md', '_blank'); diff --git a/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/ROIThresholdConfiguration.tsx b/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/ROIThresholdConfiguration.tsx index 274ae720da9..1f650b1c2c4 100644 --- a/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/ROIThresholdConfiguration.tsx +++ b/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/ROIThresholdConfiguration.tsx @@ -14,7 +14,7 @@ function ROIThresholdConfiguration({ config, dispatch, runCommand }) { const { t } = useTranslation('ROIThresholdConfiguration'); return ( -
+
{ + event.persist(); + setValue(value => ({ ...value, label: event.target.value })); + }} + onKeyPress={event => { + if (event.key === 'Enter') { + onSubmitHandler({ value, action: { id: 'save' } }); + } + }} + /> + ); + }, + }, + }); + } +} + +export default callInputDialog; diff --git a/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/colorPickerDialog.css b/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/colorPickerDialog.css new file mode 100644 index 00000000000..1c6bb206701 --- /dev/null +++ b/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/colorPickerDialog.css @@ -0,0 +1,3 @@ +.chrome-picker { + background: #090c29 !important; +} diff --git a/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/colorPickerDialog.tsx b/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/colorPickerDialog.tsx new file mode 100644 index 00000000000..38e85efb29c --- /dev/null +++ b/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/colorPickerDialog.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { Dialog } from '@ohif/ui'; +import { ChromePicker } from 'react-color'; + +import './colorPickerDialog.css'; + +function callColorPickerDialog(uiDialogService, rgbaColor, callback) { + const dialogId = 'pick-color'; + + const onSubmitHandler = ({ action, value }) => { + switch (action.id) { + case 'save': + callback(value.rgbaColor, action.id); + break; + case 'cancel': + callback('', action.id); + break; + } + uiDialogService.dismiss({ id: dialogId }); + }; + + if (uiDialogService) { + uiDialogService.create({ + id: dialogId, + centralize: true, + isDraggable: false, + showOverlay: true, + content: Dialog, + contentProps: { + title: 'Segment Color', + value: { rgbaColor }, + noCloseButton: true, + onClose: () => uiDialogService.dismiss({ id: dialogId }), + actions: [ + { id: 'cancel', text: 'Cancel', type: 'primary' }, + { id: 'save', text: 'Save', type: 'secondary' }, + ], + onSubmit: onSubmitHandler, + body: ({ value, setValue }) => { + const handleChange = color => { + setValue({ rgbaColor: color.rgb }); + }; + + return ( + + ); + }, + }, + }); + } +} + +export default callColorPickerDialog; diff --git a/extensions/tmtv/src/Panels/RectangleROIOptions.tsx b/extensions/tmtv/src/Panels/RectangleROIOptions.tsx new file mode 100644 index 00000000000..602bb5531df --- /dev/null +++ b/extensions/tmtv/src/Panels/RectangleROIOptions.tsx @@ -0,0 +1,214 @@ +import React, { useState, useCallback, useReducer, useEffect } from 'react'; +import { Button } from '@ohif/ui'; +import ROIThresholdConfiguration, { + ROI_STAT, +} from './PanelROIThresholdSegmentation/ROIThresholdConfiguration'; +import * as cs3dTools from '@cornerstonejs/tools'; + +const LOWER_CT_THRESHOLD_DEFAULT = -1024; +const UPPER_CT_THRESHOLD_DEFAULT = 1024; +const LOWER_PT_THRESHOLD_DEFAULT = 2.5; +const UPPER_PT_THRESHOLD_DEFAULT = 100; +const WEIGHT_DEFAULT = 0.41; // a default weight for suv max often used in the literature +const DEFAULT_STRATEGY = ROI_STAT; + +function reducer(state, action) { + const { payload } = action; + const { strategy, ctLower, ctUpper, ptLower, ptUpper, weight } = payload; + + switch (action.type) { + case 'setStrategy': + return { + ...state, + strategy, + }; + case 'setThreshold': + return { + ...state, + ctLower: ctLower ? ctLower : state.ctLower, + ctUpper: ctUpper ? ctUpper : state.ctUpper, + ptLower: ptLower ? ptLower : state.ptLower, + ptUpper: ptUpper ? ptUpper : state.ptUpper, + }; + case 'setWeight': + return { + ...state, + weight, + }; + default: + return state; + } +} + +function RectangleROIOptions({ servicesManager, commandsManager }) { + const { segmentationService } = servicesManager.services; + const [selectedSegmentationId, setSelectedSegmentationId] = useState(null); + + const runCommand = useCallback( + (commandName, commandOptions = {}) => { + return commandsManager.runCommand(commandName, commandOptions); + }, + [commandsManager] + ); + + const [config, dispatch] = useReducer(reducer, { + strategy: DEFAULT_STRATEGY, + ctLower: LOWER_CT_THRESHOLD_DEFAULT, + ctUpper: UPPER_CT_THRESHOLD_DEFAULT, + ptLower: LOWER_PT_THRESHOLD_DEFAULT, + ptUpper: UPPER_PT_THRESHOLD_DEFAULT, + weight: WEIGHT_DEFAULT, + }); + + const handleROIThresholding = useCallback(() => { + const segmentationId = selectedSegmentationId; + + const segmentation = segmentationService.getSegmentation(segmentationId); + const activeSegmentIndex = + cs3dTools.segmentation.segmentIndex.getActiveSegmentIndex(segmentationId); + + // run the threshold based on the active segment index + // Todo: later find a way to associate each rectangle with a segment (e.g., maybe with color?) + const labelmap = runCommand('thresholdSegmentationByRectangleROITool', { + segmentationId, + config, + segmentIndex: activeSegmentIndex, + }); + + // re-calculating the cached stats for the active segmentation + const updatedPerSegmentCachedStats = {}; + segmentation.segments = segmentation.segments.map(segment => { + if (!segment || !segment.segmentIndex) { + return segment; + } + + const segmentIndex = segment.segmentIndex; + + const lesionStats = runCommand('getLesionStats', { labelmap, segmentIndex }); + const suvPeak = runCommand('calculateSuvPeak', { labelmap, segmentIndex }); + const lesionGlyoclysisStats = lesionStats.volume * lesionStats.meanValue; + + // update segDetails with the suv peak for the active segmentation + const cachedStats = { + lesionStats, + suvPeak, + lesionGlyoclysisStats, + }; + + segment.cachedStats = cachedStats; + segment.displayText = [ + `SUV Peak: ${suvPeak.suvPeak.toFixed(2)}`, + `Volume: ${lesionStats.volume.toFixed(2)} mm3`, + ]; + updatedPerSegmentCachedStats[segmentIndex] = cachedStats; + + return segment; + }); + + const notYetUpdatedAtSource = true; + + const segmentations = segmentationService.getSegmentations(); + const tmtv = runCommand('calculateTMTV', { segmentations }); + + segmentation.cachedStats = Object.assign( + segmentation.cachedStats, + updatedPerSegmentCachedStats, + { + tmtv: { + value: tmtv.toFixed(3), + config: { ...config }, + }, + } + ); + + segmentationService.addOrUpdateSegmentation( + { + ...segmentation, + }, + false, // don't suppress events + notYetUpdatedAtSource + ); + }, [selectedSegmentationId, config]); + + useEffect(() => { + const segmentations = segmentationService.getSegmentations(); + + if (!segmentations.length) { + return; + } + + const isActive = segmentations.find(seg => seg.isActive); + setSelectedSegmentationId(isActive.id); + }, []); + + /** + * Update UI based on segmentation changes (added, removed, updated) + */ + useEffect(() => { + // ~~ Subscription + const added = segmentationService.EVENTS.SEGMENTATION_ADDED; + const updated = segmentationService.EVENTS.SEGMENTATION_UPDATED; + const subscriptions = []; + + [added, updated].forEach(evt => { + const { unsubscribe } = segmentationService.subscribe(evt, () => { + const segmentations = segmentationService.getSegmentations(); + + if (!segmentations.length) { + return; + } + + const isActive = segmentations.find(seg => seg.isActive); + setSelectedSegmentationId(isActive.id); + }); + subscriptions.push(unsubscribe); + }); + + return () => { + subscriptions.forEach(unsub => { + unsub(); + }); + }; + }, []); + + useEffect(() => { + const { unsubscribe } = segmentationService.subscribe( + segmentationService.EVENTS.SEGMENTATION_REMOVED, + () => { + const segmentations = segmentationService.getSegmentations(); + + if (segmentations.length > 0) { + setSelectedSegmentationId(segmentations[0].id); + handleROIThresholding(); + } else { + setSelectedSegmentationId(null); + handleROIThresholding(); + } + } + ); + + return () => { + unsubscribe(); + }; + }, []); + + return ( +
+ + {selectedSegmentationId !== null && ( + + )} +
+ ); +} + +export default RectangleROIOptions; diff --git a/extensions/tmtv/src/commandsModule.js b/extensions/tmtv/src/commandsModule.js index d00b33c89ba..765ab7f5b39 100644 --- a/extensions/tmtv/src/commandsModule.js +++ b/extensions/tmtv/src/commandsModule.js @@ -112,6 +112,7 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }) // Create a segmentation of the same resolution as the source data // using volumeLoader.createAndCacheDerivedVolume. const { viewportMatchDetails } = hangingProtocolService.getMatchDetails(); + const ptDisplaySet = actions.getMatchingPTDisplaySet({ viewportMatchDetails, }); @@ -121,8 +122,11 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }) return; } + const currentSegmentations = segmentationService.getSegmentations(); + const segmentationId = await segmentationService.createSegmentationForDisplaySet( - ptDisplaySet.displaySetInstanceUID + ptDisplaySet.displaySetInstanceUID, + { label: `Segmentation ${currentSegmentations.length + 1}` } ); // Add Segmentation to all toolGroupIds in the viewer @@ -141,6 +145,12 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }) segmentationService.setActiveSegmentationForToolGroup(segmentationId, toolGroupId); } + segmentationService.addSegment(segmentationId, { + segmentIndex: 1, + properties: { + label: 'Segment 1', + }, + }); return segmentationId; }, setSegmentationActiveForToolGroups: ({ segmentationId }) => { @@ -150,7 +160,7 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }) segmentationService.setActiveSegmentationForToolGroup(segmentationId, toolGroupId); }); }, - thresholdSegmentationByRectangleROITool: ({ segmentationId, config }) => { + thresholdSegmentationByRectangleROITool: ({ segmentationId, config, segmentIndex }) => { const segmentation = csTools.segmentation.state.getSegmentation(segmentationId); const { representationData } = segmentation; @@ -201,12 +211,11 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }) { volume: referencedVolume, lower: ptLower, upper: ptUpper }, { volume: ctReferencedVolume, lower: ctLower, upper: ctUpper }, ], - { overwrite: true } + { overwrite: true, segmentIndex } ); }, - calculateSuvPeak: ({ labelmap }) => { + calculateSuvPeak: ({ labelmap, segmentIndex }) => { const { referencedVolumeId } = labelmap; - const referencedVolume = cs.cache.getVolume(referencedVolumeId); const annotationUIDs = csTools.annotation.selection.getAnnotationsSelectedByToolName( @@ -217,7 +226,7 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }) csTools.annotation.state.getAnnotation(annotationUID) ); - const suvPeak = calculateSuvPeak(labelmap, referencedVolume, annotations); + const suvPeak = calculateSuvPeak(labelmap, referencedVolume, annotations, segmentIndex); return { suvPeak: suvPeak.mean, suvMax: suvPeak.max, diff --git a/extensions/tmtv/src/getPanelModule.tsx b/extensions/tmtv/src/getPanelModule.tsx index 0449ebe48bf..39a955df3f6 100644 --- a/extensions/tmtv/src/getPanelModule.tsx +++ b/extensions/tmtv/src/getPanelModule.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { PanelPetSUV, PanelROIThresholdSegmentation } from './Panels'; +import { Toolbox } from '@ohif/ui'; // TODO: // - No loading UI exists yet @@ -12,18 +13,26 @@ function getPanelModule({ commandsManager, extensionManager, servicesManager }) ); }; const wrappedROIThresholdSeg = () => { return ( - + <> + + + ); }; @@ -31,15 +40,15 @@ function getPanelModule({ commandsManager, extensionManager, servicesManager }) { name: 'petSUV', iconName: 'tab-patient-info', - iconLabel: 'PET SUV', - label: 'PET SUV', + iconLabel: 'Patient Info', + label: 'Patient Info', component: wrappedPanelPetSuv, }, { name: 'ROIThresholdSeg', - iconName: 'tab-roi-threshold', - iconLabel: 'ROI Threshold', - label: 'ROI Threshold', + iconName: 'tab-segmentation', + iconLabel: 'Segmentation', + label: 'Segmentation', component: wrappedROIThresholdSeg, }, ]; diff --git a/extensions/tmtv/src/getToolbarModule.tsx b/extensions/tmtv/src/getToolbarModule.tsx new file mode 100644 index 00000000000..33c178bd9ed --- /dev/null +++ b/extensions/tmtv/src/getToolbarModule.tsx @@ -0,0 +1,10 @@ +import RectangleROIOptions from './Panels/RectangleROIOptions'; + +export default function getToolbarModule({ commandsManager, servicesManager }) { + return [ + { + name: 'tmtv.RectangleROIThresholdOptions', + defaultComponent: () => RectangleROIOptions({ commandsManager, servicesManager }), + }, + ]; +} diff --git a/extensions/tmtv/src/index.tsx b/extensions/tmtv/src/index.tsx index 3922955bbfa..9d853c996d5 100644 --- a/extensions/tmtv/src/index.tsx +++ b/extensions/tmtv/src/index.tsx @@ -3,6 +3,7 @@ import getHangingProtocolModule from './getHangingProtocolModule'; import getPanelModule from './getPanelModule'; import init from './init'; import commandsModule from './commandsModule'; +import getToolbarModule from './getToolbarModule'; /** * @@ -15,6 +16,7 @@ const tmtvExtension = { preRegistration({ servicesManager, commandsManager, extensionManager, configuration = {} }) { init({ servicesManager, commandsManager, extensionManager, configuration }); }, + getToolbarModule, getPanelModule, getHangingProtocolModule, getCommandsModule({ servicesManager, commandsManager, extensionManager }) { diff --git a/extensions/tmtv/src/utils/measurementServiceMappings/RectangleROIStartEndThreshold.js b/extensions/tmtv/src/utils/measurementServiceMappings/RectangleROIStartEndThreshold.js index a6131e2ac5d..539f45f853b 100644 --- a/extensions/tmtv/src/utils/measurementServiceMappings/RectangleROIStartEndThreshold.js +++ b/extensions/tmtv/src/utils/measurementServiceMappings/RectangleROIStartEndThreshold.js @@ -43,8 +43,6 @@ const RectangleROIStartEndThreshold = { displaySet = displaySetService.getDisplaySetsForSeries(SeriesInstanceUID); } - const { cachedStats } = data; - return { uid: annotationUID, SOPInstanceUID, @@ -56,10 +54,8 @@ const RectangleROIStartEndThreshold = { toolName: metadata.toolName, displaySetInstanceUID: displaySet.displaySetInstanceUID, label: metadata.label, - // displayText: displayText, data: data.cachedStats, type: 'RectangleROIStartEndThreshold', - // getReport, }; }, }; diff --git a/modes/longitudinal/src/toolbarButtons.ts b/modes/longitudinal/src/toolbarButtons.ts index bce66af8383..ebb6b765437 100644 --- a/modes/longitudinal/src/toolbarButtons.ts +++ b/modes/longitudinal/src/toolbarButtons.ts @@ -119,7 +119,10 @@ const toolbarButtons: Button[] = [ icon: 'tool-3d-rotate', label: '3D Rotate', commands: setToolActiveToolbar, - evaluate: 'evaluate.cornerstoneTool', + evaluate: { + name: 'evaluate.cornerstoneTool', + disabledText: 'Select a 3D viewport to enable this tool', + }, }, }, { @@ -154,7 +157,10 @@ const toolbarButtons: Button[] = [ toolGroupIds: ['mpr'], }, }, - evaluate: 'evaluate.cornerstoneTool', + evaluate: { + name: 'evaluate.cornerstoneTool', + disabledText: 'Select an MPR viewport to enable this tool', + }, }, }, ]; diff --git a/modes/segmentation/src/segmentationButtons.ts b/modes/segmentation/src/segmentationButtons.ts index 012988ea254..51de3487dc5 100644 --- a/modes/segmentation/src/segmentationButtons.ts +++ b/modes/segmentation/src/segmentationButtons.ts @@ -25,6 +25,7 @@ const toolbarButtons: Button[] = [ evaluate: { name: 'evaluate.cornerstone.segmentation', options: { toolNames: ['CircularBrush', 'SphereBrush'] }, + disabledText: 'Create new segmentation to enable this tool.', }, commands: _createSetToolActiveCommands('CircularBrush'), options: [ @@ -95,7 +96,7 @@ const toolbarButtons: Button[] = [ { id: 'Threshold', icon: 'icon-tool-threshold', - label: 'Eraser', + label: 'Threshold Tool', evaluate: { name: 'evaluate.cornerstone.segmentation', options: { toolNames: ['ThresholdCircularBrush', 'ThresholdSphereBrush'] }, diff --git a/modes/segmentation/src/toolbarButtons.ts b/modes/segmentation/src/toolbarButtons.ts index 3255570d1ed..b732aff8ebc 100644 --- a/modes/segmentation/src/toolbarButtons.ts +++ b/modes/segmentation/src/toolbarButtons.ts @@ -75,7 +75,10 @@ const toolbarButtons: Button[] = [ icon: 'tool-3d-rotate', label: '3D Rotate', commands: setToolActiveToolbar, - evaluate: 'evaluate.cornerstoneTool', + evaluate: { + name: 'evaluate.cornerstoneTool', + disabledText: 'Select a 3D viewport to enable this tool', + }, }, }, { @@ -110,7 +113,10 @@ const toolbarButtons: Button[] = [ toolGroupIds: ['mpr'], }, }, - evaluate: 'evaluate.cornerstoneTool', + evaluate: { + name: 'evaluate.cornerstoneTool', + disabledText: 'Select an MPR viewport to enable this tool', + }, }, }, { diff --git a/modes/tmtv/src/index.js b/modes/tmtv/src/index.js index 9643fa971c8..e3cbb1967d0 100644 --- a/modes/tmtv/src/index.js +++ b/modes/tmtv/src/index.js @@ -89,8 +89,8 @@ function modeFactory({ modeConfiguration }) { 'Crosshairs', 'Pan', 'SyncToggle', - 'RectangleROIStartEndThreshold', ]); + toolbarService.createButtonSection('tmtvToolbox', ['RectangleROIStartEndThreshold']); // For the hanging protocol we need to decide on the window level // based on whether the SUV is corrected or not, hence we can't hard diff --git a/modes/tmtv/src/toolbarButtons.js b/modes/tmtv/src/toolbarButtons.js index c605b4966f0..042a54b5963 100644 --- a/modes/tmtv/src/toolbarButtons.js +++ b/modes/tmtv/src/toolbarButtons.js @@ -1,42 +1,8 @@ import { defaults, ToolbarService } from '@ohif/core'; -import { WindowLevelMenuItem } from '@ohif/ui'; import { toolGroupIds } from './initToolGroups'; const { windowLevelPresets } = defaults; -function _createColormap(label, colormap) { - return { - id: label, - label, - type: 'action', - commands: [ - { - commandName: 'setFusionPTColormap', - commandOptions: { - toolGroupId: toolGroupIds.Fusion, - colormap, - }, - }, - ], - }; -} -function _createWwwcPreset(preset, title, subtitle) { - return { - id: preset.toString(), - title, - subtitle, - commands: [ - { - commandName: 'setWindowLevel', - commandOptions: { - ...windowLevelPresets[preset], - }, - context: 'CORNERSTONE', - }, - ], - }; -} - const setToolActiveToolbar = { commandName: 'setToolActiveToolbar', commandOptions: { @@ -141,7 +107,11 @@ const toolbarButtons = [ icon: 'tool-create-threshold', label: 'Rectangle ROI Threshold', commands: setToolActiveToolbar, - evaluate: 'evaluate.cornerstoneTool', + evaluate: { + name: 'evaluate.cornerstoneTool', + disabledText: 'Select the PT Axial to enable this tool', + }, + options: 'tmtv.RectangleROIThresholdOptions', }, }, ]; diff --git a/platform/app/public/html-templates/index.html b/platform/app/public/html-templates/index.html index eb53fc851c8..dd61a8af9b2 100644 --- a/platform/app/public/html-templates/index.html +++ b/platform/app/public/html-templates/index.html @@ -233,7 +233,8 @@ - -
+
+
+
diff --git a/platform/core/src/services/ToolBarService/ToolbarService.ts b/platform/core/src/services/ToolBarService/ToolbarService.ts index 6fce58d2b92..0f84b0b6ed3 100644 --- a/platform/core/src/services/ToolBarService/ToolbarService.ts +++ b/platform/core/src/services/ToolBarService/ToolbarService.ts @@ -204,6 +204,7 @@ export default class ToolbarService extends PubSubService { const evaluated = props.evaluate?.({ ...refreshProps, button }); const updatedProps = { ...props, + ...evaluated, disabled: evaluated?.disabled || false, className: evaluated?.className || '', isActive: evaluated?.isActive, // isActive will be undefined for buttons without this prop @@ -386,7 +387,9 @@ export default class ToolbarService extends PubSubService { const { id, uiType, component } = btn; const { groupId } = btn.props; - const buttonType = this._getButtonUITypes()[uiType]; + const buttonTypes = this._getButtonUITypes(); + + const buttonType = buttonTypes[uiType]; if (!buttonType) { return; @@ -414,7 +417,16 @@ export default class ToolbarService extends PubSubService { }; handleEvaluate = props => { - const { evaluate } = props; + const { evaluate, options } = props; + + if (typeof options === 'string') { + // get the custom option component from the extension manager and set it as the optionComponent + const buttonTypes = this._getButtonUITypes(); + const optionComponent = buttonTypes[options]?.defaultComponent; + props.options = { + optionComponent, + }; + } if (typeof evaluate === 'function') { return; @@ -462,9 +474,8 @@ export default class ToolbarService extends PubSubService { } if (typeof evaluate === 'object') { - const { name, options } = evaluate; + const { name, ...options } = evaluate; const evaluateFunction = this._evaluateFunction[name]; - if (evaluateFunction) { props.evaluate = args => evaluateFunction({ ...args, ...options }); return; diff --git a/platform/docs/docs/platform/extensions/modules/toolbar.md b/platform/docs/docs/platform/extensions/modules/toolbar.md index a735b2dafaf..cf8ba27ea50 100644 --- a/platform/docs/docs/platform/extensions/modules/toolbar.md +++ b/platform/docs/docs/platform/extensions/modules/toolbar.md @@ -97,6 +97,7 @@ Let's look at one of the evaluators (for `evaluate.cornerstoneTool`) return { disabled: true, className: '!text-common-bright ohif-disabled', + disabledText: 'Tool not available', }; } @@ -371,6 +372,104 @@ state will get synchronized with the toolbar service automatically. ![alt text](../../../assets/img/toolbox-modal.png) +## Toolbox With Options + +Your toolbox toolbar buttons can have options, this is really useful +for advanced tools that require to change some parameters. For example, the brush tool that requires the brush size to change or the mode (2D or 3D). + +currently we support three types of options + +### Radio option + +We use this in segmentation shapes to let the user choose between +three different modes + +```js +{ + id: 'Shapes', + uiType: 'ohif.radioGroup', + props: { + label: 'Shapes', + evaluate: { + name: 'evaluate.cornerstone.segmentation', + options: { toolNames: ['CircleScissor', 'SphereScissor', 'RectangleScissor'] }, + }, + icon: 'icon-tool-shape', + commands: _createSetToolActiveCommands('CircleScissor'), + options: [ + { + name: 'Shape', + type: 'radio', + value: 'CircleScissor', + id: 'shape-mode', + values: [ + { value: 'CircleScissor', label: 'Circle' }, + { value: 'SphereScissor', label: 'Sphere' }, + { value: 'RectangleScissor', label: 'Rectangle' }, + ], + commands: 'setToolActiveToolbar', + }, + ], + }, +}, +``` + +### Range option + +We use this for brush radius change + +```js +{ + id: 'Brush', + icon: 'icon-tool-brush', + label: 'Brush', + evaluate: { + name: 'evaluate.cornerstone.segmentation', + options: { toolNames: ['CircularBrush', 'SphereBrush'] }, + disabledText: 'Create new segmentation to enable this tool.', + }, + commands: _createSetToolActiveCommands('CircularBrush'), + options: [ + { + name: 'Radius (mm)', + id: 'brush-radius', + type: 'range', + min: 0.5, + max: 99.5, + step: 0.5, + value: 25, + commands: { + commandName: 'setBrushSize', + commandOptions: { toolNames: ['CircularBrush', 'SphereBrush'] }, + }, + }, + ], +}, +``` + +### Custom option + +We use this pattern inside `tmtv` mode for `RectangleROIThreshold` + +```js +{ + id: 'RectangleROIStartEndThreshold', + uiType: 'ohif.radioGroup', + props: { + icon: 'tool-create-threshold', + label: 'Rectangle ROI Threshold', + commands: setToolActiveToolbar, + evaluate: { + name: 'evaluate.cornerstoneTool', + disabledText: 'Select the PT Axial to enable this tool', + }, + options: 'tmtv.RectangleROIThresholdOptions', + }, +}, +``` + +Note that it is your job to provide the `tmvt.RectangleROIThresholdOptions` in the getToolbarModule of your extension + ## Change Toolbar with hanging protocols diff --git a/platform/ui/src/assets/styles/styles.css b/platform/ui/src/assets/styles/styles.css index 05abcb151fa..0dc42514fc6 100644 --- a/platform/ui/src/assets/styles/styles.css +++ b/platform/ui/src/assets/styles/styles.css @@ -1,9 +1,7 @@ /* CUSTOM OHIF SCROLLBAR */ .ohif-scrollbar { - scrollbar-color: #041c4a transparent; - scrollbar-gutter: stable; - scrollbar-width: thin; - + scrollbar-color: #173239 transparent; + overflow-y: auto; } .study-min-height { diff --git a/platform/ui/src/components/AdvancedToolbox/ToolSettings.tsx b/platform/ui/src/components/AdvancedToolbox/ToolSettings.tsx index fc587157ef3..26b20b89dec 100644 --- a/platform/ui/src/components/AdvancedToolbox/ToolSettings.tsx +++ b/platform/ui/src/components/AdvancedToolbox/ToolSettings.tsx @@ -13,6 +13,10 @@ function ToolSettings({ options }) { return null; } + if (typeof options === 'function') { + return options(); + } + return (
{options?.map(option => { diff --git a/platform/ui/src/components/ButtonGroup/ButtonGroup.tsx b/platform/ui/src/components/ButtonGroup/ButtonGroup.tsx index ca080a138b5..916b72ad0b9 100644 --- a/platform/ui/src/components/ButtonGroup/ButtonGroup.tsx +++ b/platform/ui/src/components/ButtonGroup/ButtonGroup.tsx @@ -27,15 +27,14 @@ const ButtonGroup = ({ vertical: 'flex-col', }; - const wrapperClasses = classnames('inline-flex', orientationClasses[orientation], className); + const wrapperClasses = classnames( + 'items-stretch inline-flex', + orientationClasses[orientation], + className + ); return ( -
+
{Children.map(children, (child, index) => { if (React.isValidElement(child)) { return cloneElement(child, { diff --git a/platform/ui/src/components/Dropdown/Dropdown.tsx b/platform/ui/src/components/Dropdown/Dropdown.tsx index fc7d918039b..2e9a441445d 100644 --- a/platform/ui/src/components/Dropdown/Dropdown.tsx +++ b/platform/ui/src/components/Dropdown/Dropdown.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useCallback, useState, useRef } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; +import ReactDOM from 'react-dom'; import Icon from '../Icon'; import Typography from '../Typography'; @@ -21,7 +22,9 @@ const Dropdown = ({ maxCharactersPerLine, }) => { const [open, setOpen] = useState(false); - const element = useRef(null); + const elementRef = useRef(null); + const dropdownRef = useRef(null); + const [coords, setCoords] = useState({ x: 0, y: 0 }); // choose the max characters per line based on the longest title const longestTitle = list.reduce((acc, item) => { @@ -107,23 +110,54 @@ const Dropdown = ({ }; const handleClick = e => { - if (element.current && !element.current.contains(e.target)) { + if (elementRef.current && !elementRef.current.contains(e.target)) { setOpen(false); } }; + useEffect(() => { + if (elementRef.current && dropdownRef.current) { + const triggerRect = elementRef.current.getBoundingClientRect(); + const dropdownRect = dropdownRef.current.getBoundingClientRect(); + let x, y; + + switch (alignment) { + case 'right': + x = triggerRect.right + window.scrollX - dropdownRect.width; + y = triggerRect.bottom + window.scrollY; + break; + case 'left': + x = triggerRect.left + window.scrollX; + y = triggerRect.bottom + window.scrollY; + break; + default: + x = triggerRect.left + window.scrollX; + y = triggerRect.bottom + window.scrollY; + break; + } + setCoords({ x, y }); + } + }, [open, alignment, elementRef.current, dropdownRef.current]); + const renderList = () => { - return ( + const portalElement = document.getElementById('react-portal'); + + const listElement = (
{list.map((item, idx) => ( @@ -137,6 +171,7 @@ const Dropdown = ({ ))}
); + return ReactDOM.createPortal(listElement, portalElement); }; useEffect(() => { @@ -150,7 +185,7 @@ const Dropdown = ({ return (
{ return ( @@ -40,6 +41,7 @@ const Input = ({ {
{areChildrenVisible && ( <> -
{children}
)} diff --git a/platform/ui/src/components/SegmentationGroupTable/AddSegmentRow.tsx b/platform/ui/src/components/SegmentationGroupTable/AddSegmentRow.tsx index c5071495581..817a9fba29d 100644 --- a/platform/ui/src/components/SegmentationGroupTable/AddSegmentRow.tsx +++ b/platform/ui/src/components/SegmentationGroupTable/AddSegmentRow.tsx @@ -2,15 +2,14 @@ import React from 'react'; import Icon from '../Icon'; import { useTranslation } from 'react-i18next'; -function AddSegmentRow({ onClick }) { +function AddSegmentRow({ onClick, onToggleSegmentationVisibility = null, segmentation = null }) { const { t } = useTranslation('SegmentationTable'); return ( -
-
-
+
+
@@ -18,6 +17,26 @@ function AddSegmentRow({ onClick }) { {t('Add segment')}
+ {segmentation && ( +
+
onToggleSegmentationVisibility(segmentation.id)} + > + {segmentation.isVisible ? ( + + ) : ( + + )} +
+
+ )}
); } diff --git a/platform/ui/src/components/SegmentationGroupTable/SegmentationDropDownRow.tsx b/platform/ui/src/components/SegmentationGroupTable/SegmentationDropDownRow.tsx index f83d2962552..8db52a5e16a 100644 --- a/platform/ui/src/components/SegmentationGroupTable/SegmentationDropDownRow.tsx +++ b/platform/ui/src/components/SegmentationGroupTable/SegmentationDropDownRow.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Select, Icon, Dropdown } from '../../components'; +import { Select, Icon, Dropdown, Tooltip } from '../../components'; import PropTypes from 'prop-types'; import { useTranslation } from 'react-i18next'; @@ -31,7 +31,7 @@ function SegmentationDropDownRow({ } return ( -
+
{ e.stopPropagation(); @@ -97,7 +97,7 @@ function SegmentationDropDownRow({ ], ]} > -
+
@@ -122,8 +122,22 @@ function SegmentationDropDownRow({ /> )}
+ +
Series:
+
{activeSegmentation.description}
+
+ } + > + +
onToggleSegmentationVisibility(activeSegmentation.id)} > {activeSegmentation.isVisible ? ( diff --git a/platform/ui/src/components/SegmentationGroupTable/SegmentationGroupSegment.tsx b/platform/ui/src/components/SegmentationGroupTable/SegmentationGroupSegment.tsx index 1256efb34d1..cda9624213d 100644 --- a/platform/ui/src/components/SegmentationGroupTable/SegmentationGroupSegment.tsx +++ b/platform/ui/src/components/SegmentationGroupTable/SegmentationGroupSegment.tsx @@ -20,6 +20,7 @@ const SegmentItem = ({ onColor, onToggleVisibility, onToggleLocked, + displayText, }) => { const [isNumberBoxHovering, setIsNumberBoxHovering] = useState(false); @@ -27,7 +28,9 @@ const SegmentItem = ({ return (
{ e.stopPropagation(); onClick(segmentationId, segmentIndex); @@ -35,114 +38,140 @@ const SegmentItem = ({ tabIndex={0} data-cy={'segment-item'} > -
setIsNumberBoxHovering(true)} - onMouseLeave={() => setIsNumberBoxHovering(false)} - > - {isNumberBoxHovering && showDelete ? ( - { - if (disableEditing) { - return; - } - e.stopPropagation(); - onDelete(segmentationId, segmentIndex); - }} - /> - ) : ( -
{segmentIndex}
- )} -
-
-
-
-
+
setIsNumberBoxHovering(true)} + onMouseLeave={() => setIsNumberBoxHovering(false)} + > + {isNumberBoxHovering && showDelete ? ( + { if (disableEditing) { return; } e.stopPropagation(); - onColor(segmentationId, segmentIndex); + onDelete(segmentationId, segmentIndex); }} /> -
-
{label}
+ ) : ( +
{segmentIndex}
+ )}
+
-
- {!isVisible && ( - +
+
{ + if (disableEditing) { + return; + } e.stopPropagation(); - onToggleVisibility(segmentationId, segmentIndex); + onColor(segmentationId, segmentIndex); }} /> - )} +
+
{label}
- - {/* Icon for 'row-lock' that shows when NOT hovering and 'isLocked' is true */} -
- {isLocked && ( -
+
+
+ {!isVisible && ( { e.stopPropagation(); - onToggleLocked(segmentationId, segmentIndex); + onToggleVisibility(segmentationId, segmentIndex); }} /> + )} +
- {/* This icon is visible when 'isVisible' is true */} - {isVisible && ( + {/* Icon for 'row-lock' that shows when NOT hovering and 'isLocked' is true */} +
+ {isLocked && ( +
{ + e.stopPropagation(); + onToggleLocked(segmentationId, segmentIndex); + }} /> - )} -
- )} -
- {/* Icons that show only when hovering */} -
- + {/* This icon is visible when 'isVisible' is true */} + {isVisible && ( + + )} +
+ )} +
+ + {/* Icons that show only when hovering */} +
+ +
+ {Array.isArray(displayText) ? ( +
+ {displayText.map(text => ( +
+ {text} +
+ ))} +
+ ) : ( + displayText && ( +
+ {displayText} +
+ ) + )}
); }; @@ -205,6 +234,7 @@ SegmentItem.propTypes = { onDelete: PropTypes.func.isRequired, onToggleVisibility: PropTypes.func.isRequired, onToggleLocked: PropTypes.func, + displayText: PropTypes.string, }; SegmentItem.defaultProps = { diff --git a/platform/ui/src/components/SegmentationGroupTable/SegmentationGroupTable.tsx b/platform/ui/src/components/SegmentationGroupTable/SegmentationGroupTable.tsx index 2ea0bcb0014..7bbe8b37de9 100644 --- a/platform/ui/src/components/SegmentationGroupTable/SegmentationGroupTable.tsx +++ b/platform/ui/src/components/SegmentationGroupTable/SegmentationGroupTable.tsx @@ -100,7 +100,7 @@ const SegmentationGroupTable = ({ )}
{segmentations?.length === 0 ? ( -
+
{showAddSegmentation && !disableEditing && ( )} @@ -127,7 +127,7 @@ const SegmentationGroupTable = ({ )}
{activeSegmentation && ( -
+
{activeSegmentation?.segments?.map(segment => { if (!segment) { return null; diff --git a/platform/ui/src/components/SegmentationGroupTable/SegmentationGroupTableExpanded.tsx b/platform/ui/src/components/SegmentationGroupTable/SegmentationGroupTableExpanded.tsx new file mode 100644 index 00000000000..1cbeb8e17bb --- /dev/null +++ b/platform/ui/src/components/SegmentationGroupTable/SegmentationGroupTableExpanded.tsx @@ -0,0 +1,211 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { PanelSection } from '../../components'; +import SegmentationConfig from './SegmentationConfig'; +import NoSegmentationRow from './NoSegmentationRow'; +import { useTranslation } from 'react-i18next'; +import SegmentationItem from './SegmentationItem'; + +const SegmentationGroupTableExpanded = ({ + segmentations, + // segmentation initial config + segmentationConfig, + // UI show/hide + disableEditing, + showAddSegmentation, + showAddSegment, + showDeleteSegment, + // segmentation/segment handlers + onSegmentationAdd, + onSegmentationEdit, + onSegmentationClick, + onSegmentationDelete, + onSegmentationDownload, + onSegmentationDownloadRTSS, + storeSegmentation, + // segment handlers + onSegmentClick, + onSegmentAdd, + onSegmentDelete, + onSegmentEdit, + onToggleSegmentationVisibility, + onToggleSegmentVisibility, + onToggleSegmentLock, + onSegmentColorClick, + // segmentation config handlers + setFillAlpha, + setFillAlphaInactive, + setOutlineWidthActive, + setOutlineOpacityActive, + setRenderFill, + setRenderInactiveSegmentations, + setRenderOutline, +}) => { + const [isConfigOpen, setIsConfigOpen] = useState(false); + const [activeSegmentationId, setActiveSegmentationId] = useState(null); + + useEffect(() => { + // find the first active segmentation to set + let activeSegmentationIdToSet = segmentations?.find(segmentation => segmentation.isActive)?.id; + + // If there is no active segmentation, set the first one to be active + if (!activeSegmentationIdToSet && segmentations?.length > 0) { + activeSegmentationIdToSet = segmentations[0].id; + } + + // If there is no segmentation, set the active segmentation to null + if (segmentations?.length === 0) { + activeSegmentationIdToSet = null; + } + + setActiveSegmentationId(activeSegmentationIdToSet); + }, [segmentations]); + + const activeSegmentation = segmentations?.find( + segmentation => segmentation.id === activeSegmentationId + ); + const { t } = useTranslation('SegmentationTable'); + + return ( +
+ setIsConfigOpen(isOpen => !isOpen), + }, + ] + } + > + {isConfigOpen && ( + + )} +
+
+ {showAddSegmentation && !disableEditing && ( + + )} +
+ {segmentations?.length > 0 && ( +
+ {segmentations?.map(segmentation => { + return ( +
+ +
+ ); + })} +
+ )} +
+
+
+ ); +}; + +SegmentationGroupTableExpanded.propTypes = { + segmentations: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.string.isRequired, + isActive: PropTypes.bool.isRequired, + segments: PropTypes.arrayOf( + PropTypes.shape({ + segmentIndex: PropTypes.number.isRequired, + color: PropTypes.array.isRequired, + label: PropTypes.string.isRequired, + isVisible: PropTypes.bool.isRequired, + isLocked: PropTypes.bool.isRequired, + }) + ), + }) + ), + segmentationConfig: PropTypes.object.isRequired, + disableEditing: PropTypes.bool, + showAddSegmentation: PropTypes.bool, + showAddSegment: PropTypes.bool, + showDeleteSegment: PropTypes.bool, + onSegmentationAdd: PropTypes.func.isRequired, + onSegmentationEdit: PropTypes.func.isRequired, + onSegmentationClick: PropTypes.func.isRequired, + onSegmentationDelete: PropTypes.func.isRequired, + onSegmentationDownload: PropTypes.func.isRequired, + onSegmentationDownloadRTSS: PropTypes.func, + storeSegmentation: PropTypes.func.isRequired, + onSegmentClick: PropTypes.func.isRequired, + onSegmentAdd: PropTypes.func.isRequired, + onSegmentDelete: PropTypes.func.isRequired, + onSegmentEdit: PropTypes.func.isRequired, + onToggleSegmentationVisibility: PropTypes.func.isRequired, + onToggleSegmentVisibility: PropTypes.func.isRequired, + onToggleSegmentLock: PropTypes.func.isRequired, + onSegmentColorClick: PropTypes.func.isRequired, + setFillAlpha: PropTypes.func.isRequired, + setFillAlphaInactive: PropTypes.func.isRequired, + setOutlineWidthActive: PropTypes.func.isRequired, + setOutlineOpacityActive: PropTypes.func.isRequired, + setRenderFill: PropTypes.func.isRequired, + setRenderInactiveSegmentations: PropTypes.func.isRequired, + setRenderOutline: PropTypes.func.isRequired, +}; + +SegmentationGroupTableExpanded.defaultProps = { + segmentations: [], + disableEditing: false, + showAddSegmentation: true, + showAddSegment: true, + showDeleteSegment: true, + onSegmentationAdd: () => {}, + onSegmentationEdit: () => {}, + onSegmentationClick: () => {}, + onSegmentationDelete: () => {}, + onSegmentationDownload: () => {}, + onSemgnetationDownloadRTSS: () => {}, + storeSegmentation: () => {}, + onSegmentClick: () => {}, + onSegmentAdd: () => {}, + onSegmentDelete: () => {}, + onSegmentEdit: () => {}, + onToggleSegmentationVisibility: () => {}, + onToggleSegmentVisibility: () => {}, + onToggleSegmentLock: () => {}, + onSegmentColorClick: () => {}, + setFillAlpha: () => {}, + setFillAlphaInactive: () => {}, + setOutlineWidthActive: () => {}, + setOutlineOpacityActive: () => {}, + setRenderFill: () => {}, + setRenderInactiveSegmentations: () => {}, + setRenderOutline: () => {}, +}; +export default SegmentationGroupTableExpanded; diff --git a/platform/ui/src/components/SegmentationGroupTable/SegmentationItem.tsx b/platform/ui/src/components/SegmentationGroupTable/SegmentationItem.tsx new file mode 100644 index 00000000000..8741c338f51 --- /dev/null +++ b/platform/ui/src/components/SegmentationGroupTable/SegmentationItem.tsx @@ -0,0 +1,211 @@ +import React, { useState } from 'react'; +import { Icon, Dropdown } from '../../components'; +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import { useTranslation } from 'react-i18next'; +import AddSegmentRow from './AddSegmentRow'; +import SegmentationGroupSegment from './SegmentationGroupSegment'; +import { Tooltip } from '../../components'; + +function SegmentationItem({ + segmentation, + disableEditing, + onSegmentationEdit, + onSegmentationDownload, + onSegmentationDownloadRTSS, + storeSegmentation, + onSegmentationDelete, + showAddSegment, + onToggleSegmentationVisibility, + onSegmentAdd, + onSegmentClick, + onSegmentDelete, + onSegmentEdit, + showDeleteSegment, + onSegmentColorClick, + onToggleSegmentVisibility, + onToggleSegmentLock, + activeSegmentationId, +}) { + const { t } = useTranslation('SegmentationTable'); + + const [areChildrenVisible, setChildrenVisible] = useState(true); + + const handleHeaderClick = () => { + setChildrenVisible(!areChildrenVisible); + }; + + return ( + <> +
+
{ + e.stopPropagation(); + }} + className="flex" + > + { + onSegmentationEdit(segmentation.id); + }, + }, + ] + : []), + { + title: t('Delete'), + onClick: () => { + onSegmentationDelete(segmentation.id); + }, + }, + ...(!disableEditing + ? [ + { + title: t('Export DICOM SEG'), + onClick: () => { + storeSegmentation(segmentation.id); + }, + }, + ] + : []), + ...[ + { + title: t('Download DICOM SEG'), + onClick: () => { + onSegmentationDownload(segmentation.id); + }, + }, + { + title: t('Download DICOM RTSTRUCT'), + onClick: () => { + onSegmentationDownloadRTSS(segmentation.id); + }, + }, + ], + ]} + > +
+ +
+
+
+
+
+
{segmentation.label}
+
+ +
Series:
+
{segmentation.description}
+
+ } + > + + +
+ +
+
+
+
+ {areChildrenVisible && ( + <> + {!disableEditing && showAddSegment && ( + onSegmentAdd(segmentation.id)} + onToggleSegmentationVisibility={onToggleSegmentationVisibility} + segmentation={segmentation} + /> + )} +
+ {segmentation?.segments?.map(segment => { + if (!segment) { + return null; + } + + const { segmentIndex, color, label, isVisible, isLocked, displayText } = segment; + return ( +
+ +
+ ); + })} +
+ + )} + + ); +} + +SegmentationItem.propTypes = { + segmentation: PropTypes.object, + disableEditing: PropTypes.bool, + onToggleSegmentationVisibility: PropTypes.func, + onSegmentationEdit: PropTypes.func, + onSegmentationDownload: PropTypes.func, + onSegmentationDownloadRTSS: PropTypes.func, + storeSegmentation: PropTypes.func, + onSegmentationDelete: PropTypes.func, + showAddSegment: PropTypes.bool, + onSegmentAdd: PropTypes.func, + onSegmentClick: PropTypes.func, + onSegmentDelete: PropTypes.func, + onSegmentEdit: PropTypes.func, + showDeleteSegment: PropTypes.bool, + onSegmentColorClick: PropTypes.func, + onToggleSegmentVisibility: PropTypes.func, + onToggleSegmentLock: PropTypes.func, + activeSegmentationId: PropTypes.string, +}; + +SegmentationItem.defaultProps = { + segmentation: null, + disableEditing: false, +}; + +export default SegmentationItem; diff --git a/platform/ui/src/components/SegmentationGroupTable/index.js b/platform/ui/src/components/SegmentationGroupTable/index.js index c798973b1f8..6b48859839e 100644 --- a/platform/ui/src/components/SegmentationGroupTable/index.js +++ b/platform/ui/src/components/SegmentationGroupTable/index.js @@ -1,3 +1,4 @@ import SegmentationGroupTable from './SegmentationGroupTable'; +import SegmentationGroupTableExpanded from './SegmentationGroupTableExpanded'; -export default SegmentationGroupTable; +export { SegmentationGroupTable, SegmentationGroupTableExpanded }; diff --git a/platform/ui/src/components/ToolbarButton/ToolbarButton.tsx b/platform/ui/src/components/ToolbarButton/ToolbarButton.tsx index a9b44cb9b9c..226b1aeae34 100644 --- a/platform/ui/src/components/ToolbarButton/ToolbarButton.tsx +++ b/platform/ui/src/components/ToolbarButton/ToolbarButton.tsx @@ -16,6 +16,7 @@ const ToolbarButton = ({ // className, disabled, + disabledText, size, toolTipClassName, disableToolTip = false, @@ -38,7 +39,7 @@ const ToolbarButton = ({ { const { id: buttonId, componentProps } = toolbarButton; - const createEnhancedOptions = (options, parentId) => - options.map(option => { + const createEnhancedOptions = (options, parentId) => { + const optionsToUse = Array.isArray(options) ? options : [options]; + + return optionsToUse.map(option => { + if (typeof option.optionComponent === 'function') { + return option; + } + return { ...option, value: @@ -72,13 +78,18 @@ function Toolbox({ servicesManager, buttonSectionId, commandsManager, title, ... }, }; }); + }; - if (componentProps.items?.length) { - componentProps.items.forEach(item => { - accumulator[item.id] = createEnhancedOptions(item.options, item.id); + const { items, options } = componentProps; + + if (items?.length) { + items.forEach(({ options, id }) => { + accumulator[id] = createEnhancedOptions(options, id); }); - } else if (componentProps.options?.length) { - accumulator[buttonId] = createEnhancedOptions(componentProps.options, buttonId); + } else if (options?.length) { + accumulator[buttonId] = createEnhancedOptions(options, buttonId); + } else if (options?.optionComponent) { + accumulator[buttonId] = options.optionComponent; } return accumulator; diff --git a/platform/ui/src/components/Toolbox/ToolboxUI.tsx b/platform/ui/src/components/Toolbox/ToolboxUI.tsx index 653dc7946df..4ee2e3fe0b4 100644 --- a/platform/ui/src/components/Toolbox/ToolboxUI.tsx +++ b/platform/ui/src/components/Toolbox/ToolboxUI.tsx @@ -53,8 +53,21 @@ function ToolboxUI(props) { +
+ +
+
+ ) : ( +
- - ) : ( - +
)}
); diff --git a/platform/ui/src/components/Tooltip/Tooltip.tsx b/platform/ui/src/components/Tooltip/Tooltip.tsx index 7c588c0e869..509114cb273 100644 --- a/platform/ui/src/components/Tooltip/Tooltip.tsx +++ b/platform/ui/src/components/Tooltip/Tooltip.tsx @@ -1,7 +1,8 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; import { useTranslation } from 'react-i18next'; +import ReactDOM from 'react-dom'; import './tooltip.css'; @@ -42,6 +43,10 @@ const Tooltip = ({ }) => { const [isActive, setIsActive] = useState(false); const { t } = useTranslation('Buttons'); + const tooltipContainer = document.getElementById('react-portal'); + const [coords, setCoords] = useState({ x: 999999, y: 999999 }); + const parentRef = useRef(null); + const tooltipRef = useRef(null); const handleMouseOver = () => { if (!isActive) { @@ -57,8 +62,90 @@ const Tooltip = ({ const isOpen = (isSticky || isActive) && !isDisabled; + useEffect(() => { + if (parentRef.current && tooltipRef.current) { + const parentRect = parentRef.current.getBoundingClientRect(); + const tooltipRect = tooltipRef.current.getBoundingClientRect(); + const parentWidth = parentRect.width; + const parentHeight = parentRect.height; + const tooltipWidth = tooltipRect.width; + + let newX = 0; + let newY = 0; + + switch (position) { + case 'bottom': + newX = parentRect.left + parentWidth / 2; + newY = parentRect.top + parentHeight; + break; + case 'top': + newX = parentRect.left + parentWidth / 2; + newY = parentRect.top - parentHeight * 2; + break; + case 'right': + newX = parentRect.left + parentWidth; + newY = parentRect.top + parentHeight / 2; + break; + case 'left': + newX = parentRect.left - tooltipWidth - 10; + newY = parentRect.top + parentHeight / 2; + break; + case 'bottom-left': + newX = parentRect.left; + newY = parentRect.top + parentHeight; + break; + case 'bottom-right': + newX = parentRect.left - tooltipWidth + parentWidth; + newY = parentRect.top + parentHeight; + break; + default: + break; + } + + setCoords({ x: newX, y: newY }); + } + }, [isOpen, position, parentRef.current, tooltipRef.current]); + + const tooltipContent = ( +
+
+
{typeof content === 'string' ? t(content) : content}
+
+ {typeof secondaryContent === 'string' ? t(secondaryContent) : secondaryContent} +
+ + + +
+
+ ); + return (
{children} -
-
-
{typeof content === 'string' ? t(content) : content}
-
- {typeof secondaryContent === 'string' ? t(secondaryContent) : secondaryContent} -
- - - -
-
+ {tooltipContainer && ReactDOM.createPortal(tooltipContent, tooltipContainer)}
); }; @@ -110,7 +167,6 @@ Tooltip.defaultProps = { }; Tooltip.propTypes = { - /** prevents tooltip from rendering despite hover/active/sticky */ isDisabled: PropTypes.bool, content: PropTypes.oneOfType([PropTypes.node, PropTypes.func]), position: PropTypes.oneOf(['bottom', 'bottom-left', 'bottom-right', 'left', 'right', 'top']), diff --git a/platform/ui/src/components/index.js b/platform/ui/src/components/index.js index b8c8c0f9db1..feda4c36a17 100644 --- a/platform/ui/src/components/index.js +++ b/platform/ui/src/components/index.js @@ -29,7 +29,7 @@ import NavBar from './NavBar'; import Notification from './Notification'; import Select from './Select'; import SegmentationTable from './SegmentationTable'; -import SegmentationGroupTable from './SegmentationGroupTable'; +import { SegmentationGroupTable, SegmentationGroupTableExpanded } from './SegmentationGroupTable'; import SidePanel from './SidePanel'; import SplitButton from './SplitButton'; import StudyBrowser from './StudyBrowser'; @@ -144,6 +144,7 @@ export { Select, SegmentationTable, SegmentationGroupTable, + SegmentationGroupTableExpanded, SidePanel, SplitButton, StudyBrowser, diff --git a/platform/ui/src/index.js b/platform/ui/src/index.js index dff5e32785d..b275d67a64e 100644 --- a/platform/ui/src/index.js +++ b/platform/ui/src/index.js @@ -81,6 +81,7 @@ export { Select, SegmentationTable, SegmentationGroupTable, + SegmentationGroupTableExpanded, SidePanel, SplitButton, LegacySplitButton, diff --git a/yarn.lock b/yarn.lock index f409007a57e..414b9c706cd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2420,7 +2420,7 @@ resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.8.1.tgz#182b5a4704ef8ad91bde93f7a860a88fd92c79a3" integrity sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ== -"@emotion/use-insertion-effect-with-fallbacks@^1.0.1": +"@emotion/use-insertion-effect-with-fallbacks@^1.0.0", "@emotion/use-insertion-effect-with-fallbacks@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz#08de79f54eb3406f9daaf77c76e35313da963963" integrity sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==