From d57e8bc1571c6da4effaa492ee2d162c552365a2 Mon Sep 17 00:00:00 2001 From: Leonardo Campos Date: Wed, 10 Apr 2024 00:57:34 -0300 Subject: [PATCH] feat(4D): Add 4D dynamic volume rendering and new pre-clinical 4d pt/ct mode (#3664) Co-authored-by: Alireza Co-authored-by: Neil --- .gitignore | 1 + commit.txt | 2 +- extensions/cornerstone-dicom-seg/package.json | 6 +- .../src/getPanelModule.tsx | 16 +- .../src/getToolbarModule.ts | 20 +- .../cornerstone-dicom-seg/src/index.tsx | 2 - extensions/cornerstone-dicom-seg/src/init.ts | 5 - .../src/panels/PanelSegmentation.tsx | 11 +- extensions/cornerstone-dicom-sr/package.json | 6 +- .../viewports/OHIFCornerstoneSRViewport.tsx | 11 +- .../.webpack/webpack.dev.js | 8 + .../.webpack/webpack.prod.js | 54 + extensions/cornerstone-dynamic-volume/LICENSE | 20 + .../cornerstone-dynamic-volume/README.md | 8 + .../babel.config.js | 1 + .../cornerstone-dynamic-volume/package.json | 50 + .../src/actions/index.ts | 3 + .../updateSegmentationsChartDisplaySet.ts | 281 ++++ .../src/commandsModule.ts | 407 ++++++ .../src/getHangingProtocolModule.ts | 654 +++++++++ .../src/getPanelModule.tsx | 68 + .../cornerstone-dynamic-volume/src/id.js | 6 + .../cornerstone-dynamic-volume/src/index.ts | 57 + .../src/panels/DynamicDataPanel.tsx | 18 + .../src/panels/DynamicExport.tsx | 76 + .../src/panels/DynamicVolumeControls.tsx | 220 +++ .../src/panels/GenerateVolume.tsx | 71 + .../src/panels/PanelGenerateImage.tsx | 275 ++++ .../src/panels/WorkflowPanel.tsx | 27 + .../src/panels/index.js | 5 + extensions/cornerstone/package.json | 13 +- .../src/Viewport/OHIFCornerstoneViewport.tsx | 8 + .../Overlays/CustomizableViewportOverlay.tsx | 2 +- extensions/cornerstone/src/commandsModule.ts | 61 +- .../ActiveViewportWindowLevel.tsx | 31 + .../ActiveViewportWindowLevel/index.js | 1 + .../src/components/CinePlayer/CinePlayer.tsx | 230 ++- .../ViewportWindowLevel.tsx | 387 +++++ .../getViewportVolumeHistogram.ts | 70 + .../ViewportWindowLevel/histogramWorker.js | 97 ++ .../components/ViewportWindowLevel/index.js | 1 + .../cornerstone/src/getToolbarModule.tsx | 2 +- extensions/cornerstone/src/index.tsx | 23 +- extensions/cornerstone/src/init.tsx | 17 +- extensions/cornerstone/src/initCineService.ts | 62 +- .../cornerstone/src/initCornerstoneTools.js | 21 +- .../cornerstone/src/initMeasurementService.js | 14 + .../cornerstone/src/initWADOImageLoader.js | 10 +- .../CornerstoneCacheService.ts | 3 +- .../SegmentationService.ts | 10 +- .../ToolGroupService/ToolGroupService.ts | 42 +- .../CornerstoneViewportService.ts | 5 + .../src/utils/interleaveTopToBottom.ts | 34 +- .../LineChartViewport/LineChartViewport.tsx | 32 + .../src/Components/LineChartViewport/index.ts | 1 + .../src/Components/SidePanelWithServices.tsx | 74 +- .../default/src/Panels/ActionButtons.tsx | 44 - .../src/Panels/PanelMeasurementTable.tsx | 26 +- .../SOPClassHandlers/chartSOPClassHandler.ts | 97 ++ extensions/default/src/Toolbar/Toolbar.tsx | 4 +- .../default/src/ViewerLayout/ViewerHeader.tsx | 11 +- extensions/default/src/ViewerLayout/index.tsx | 78 +- extensions/default/src/commandsModule.ts | 10 - .../ProgressDropdownWithService.tsx | 109 ++ .../ProgressDropdownWithService/index.js | 1 + .../default/src/getCustomizationModule.tsx | 6 + .../default/src/getDisplaySetMessages.ts | 8 +- .../default/src/getSopClassHandlerModule.js | 74 +- extensions/default/src/getToolbarModule.tsx | 5 + extensions/default/src/getViewportModule.tsx | 21 + extensions/default/src/index.ts | 2 + extensions/default/src/init.ts | 2 +- .../default/src/utils/reuseCachedLayouts.ts | 5 + extensions/measurement-tracking/package.json | 4 +- .../ActionButtons.tsx | 45 - .../PanelMeasurementTableTracking/index.tsx | 45 +- .../PanelStudyBrowserTracking.tsx | 29 +- .../viewports/TrackedCornerstoneViewport.tsx | 9 +- extensions/test-extension/src/hp/index.ts | 17 + .../ExportReports.tsx | 51 - .../LegacyPanelROIThresholdSegmentation.tsx | 287 ---- .../PanelROIThresholdExport.tsx | 114 ++ .../PanelROIThresholdSegmentation.tsx | 317 ---- .../callInputDialog.tsx | 62 - .../colorPickerDialog.css | 3 - .../colorPickerDialog.tsx | 58 - .../PanelROIThresholdSegmentation/index.ts | 4 +- extensions/tmtv/src/Panels/index.tsx | 4 +- extensions/tmtv/src/commandsModule.js | 124 +- extensions/tmtv/src/getPanelModule.tsx | 29 +- extensions/tmtv/src/utils/calculateSUVPeak.ts | 12 +- .../src/utils/createAndDownloadTMTVReport.js | 4 +- modes/basic-test-mode/src/index.ts | 2 +- modes/basic-test-mode/src/moreTools.ts | 25 +- modes/longitudinal/src/index.js | 6 +- modes/longitudinal/src/initToolGroups.js | 7 +- modes/longitudinal/src/moreTools.ts | 36 +- modes/longitudinal/src/toolbarButtons.ts | 2 - modes/microscopy/src/index.tsx | 4 +- modes/preclinical-4d/.webpack/webpack.dev.js | 12 + modes/preclinical-4d/.webpack/webpack.prod.js | 53 + modes/preclinical-4d/LICENSE | 9 + modes/preclinical-4d/README.md | 7 + modes/preclinical-4d/babel.config.js | 44 + modes/preclinical-4d/package.json | 47 + .../preclinical-4d/src/getWorkflowSettings.ts | 104 ++ modes/preclinical-4d/src/id.js | 5 + modes/preclinical-4d/src/index.tsx | 176 +++ modes/preclinical-4d/src/initToolGroups.tsx | 130 ++ modes/preclinical-4d/src/initWorkflowSteps.ts | 9 + .../preclinical-4d/src/segmentationButtons.ts | 180 +++ modes/preclinical-4d/src/toolbarButtons.tsx | 160 ++ modes/segmentation/src/toolbarButtons.ts | 53 +- modes/tmtv/src/index.js | 25 +- package.json | 3 +- .../OHIFCornerstoneToolbar.spec.js | 3 +- .../study-list/OHIFStudyList.spec.js | 2 + platform/app/package.json | 5 +- platform/app/pluginConfig.json | 8 + platform/app/public/config/local_orthanc.js | 1 + platform/app/src/appInit.js | 2 + platform/app/src/routes/Mode/Mode.tsx | 26 +- .../app/src/routes/Mode/defaultRouteInit.ts | 6 +- platform/core/package.json | 2 +- platform/core/src/classes/MetadataProvider.ts | 43 + .../core/src/extensions/ExtensionManager.ts | 1 + platform/core/src/index.test.js | 3 +- platform/core/src/index.ts | 3 + .../src/services/CineService/CineService.ts | 23 +- .../HangingProtocolService.ts | 23 +- .../src/services/PanelService/PanelService.ts | 61 - .../services/PanelService/PanelService.tsx | 195 +++ platform/core/src/services/ServicesManager.ts | 2 + .../services/ToolBarService/ToolbarService.ts | 7 +- .../WorkflowStepsService.ts | 238 +++ .../services/WorkflowStepsService/index.ts | 3 + platform/core/src/services/index.ts | 2 + platform/core/src/types/Services.ts | 2 + .../docs/docs/assets/img/progressDropdown.png | Bin 0 -> 103408 bytes .../docs/configuration/configurationFiles.md | 15 +- .../docs/platform/extensions/modules/panel.md | 14 +- platform/docs/docs/platform/modes/index.md | 15 +- platform/docs/docs/platform/modes/routes.md | 13 + .../services/data/WorkflowStepService.md | 174 +++ platform/ui/CHANGELOG.md | 90 +- platform/ui/package.json | 25 +- platform/ui/src/assets/icons/arrow-right.svg | 6 + platform/ui/src/assets/icons/tool-brush.svg | 22 + platform/ui/src/assets/icons/tool-eraser.svg | 21 + .../ui/src/assets/icons/tool-paint-fill.svg | 12 + .../src/assets/icons/tool-scissor-circle.svg | 9 + .../ui/src/assets/icons/tool-scissor-rect.svg | 39 + .../ActionButtons/ActionButtons.tsx | 40 + .../ui/src/components/ActionButtons/index.ts | 3 + .../components/ButtonGroup/ButtonGroup.tsx | 73 +- .../ui/src/components/CheckBox/CheckBox.tsx | 5 +- .../src/components/CinePlayer/CinePlayer.tsx | 139 +- .../__stories__/cinePlayer.stories.mdx | 57 + platform/ui/src/components/Header/Header.tsx | 9 +- .../HeaderPatientInfo/HeaderPatientInfo.tsx | 4 +- platform/ui/src/components/Icon/getIcon.js | 7 + .../InputDoubleRange/InputDoubleRange.tsx | 104 +- .../components/InputNumber/InputNumber.tsx | 72 +- .../src/components/InputRange/InputRange.css | 16 +- .../src/components/InputRange/InputRange.tsx | 28 +- .../__stories__/inputRange.stories.mdx | 2 +- .../LegacySplitButton/LegacySplitButton.tsx | 10 +- .../ui/src/components/LineChart/LineChart.css | 40 + .../ui/src/components/LineChart/LineChart.tsx | 129 ++ .../__stories__/lineChart.stories.mdx | 181 +++ .../components/LineChart/d3LineChart/chart.ts | 365 +++++ .../LineChart/d3LineChart/events.ts | 149 ++ .../components/LineChart/d3LineChart/index.ts | 3 + .../LineChart/d3LineChart/lineChart.ts | 272 ++++ platform/ui/src/components/LineChart/index.js | 1 + .../components/PanelSection/PanelSection.tsx | 6 +- .../ProgressDropdown/ProgressDiscreteBar.tsx | 27 + .../ProgressDropdown/ProgressDropdown.tsx | 168 +++ .../ProgressDropdown/ProgressItem.tsx | 31 + .../ProgressDropdown/ProgressItemDetail.tsx | 81 + .../__stories__/progressDropdown.stories.mdx | 106 ++ .../src/components/ProgressDropdown/index.js | 1 + .../src/components/ProgressDropdown/types.ts | 19 + .../SegmentationConfig.tsx | 6 +- .../ui/src/components/SidePanel/SidePanel.tsx | 40 +- .../ui/src/components/Toolbox/ToolboxUI.tsx | 4 +- .../ui/src/components/Tooltip/Tooltip.tsx | 46 +- .../ui/src/components/Tooltip/tooltip.css | 2 +- .../components/WindowLevel/WindowLevel.tsx | 189 +++ .../WindowLevel/WindowLevelHistogram.tsx | 236 +++ .../__stories__/windowLevel.stories.mdx | 103 ++ .../ui/src/components/WindowLevel/index.js | 1 + .../ui/src/components/WindowLevel/types.ts | 64 + platform/ui/src/components/index.js | 10 +- .../ui/src/contextProviders/CineProvider.tsx | 29 +- platform/ui/src/index.js | 4 + .../ui/src/views/Viewer/components/Header.js | 4 +- version.json | 2 +- version.txt | 2 +- yarn.lock | 1301 +++++++++-------- 200 files changed, 9589 insertions(+), 2178 deletions(-) delete mode 100644 extensions/cornerstone-dicom-seg/src/init.ts create mode 100644 extensions/cornerstone-dynamic-volume/.webpack/webpack.dev.js create mode 100644 extensions/cornerstone-dynamic-volume/.webpack/webpack.prod.js create mode 100644 extensions/cornerstone-dynamic-volume/LICENSE create mode 100644 extensions/cornerstone-dynamic-volume/README.md create mode 100644 extensions/cornerstone-dynamic-volume/babel.config.js create mode 100644 extensions/cornerstone-dynamic-volume/package.json create mode 100644 extensions/cornerstone-dynamic-volume/src/actions/index.ts create mode 100644 extensions/cornerstone-dynamic-volume/src/actions/updateSegmentationsChartDisplaySet.ts create mode 100644 extensions/cornerstone-dynamic-volume/src/commandsModule.ts create mode 100644 extensions/cornerstone-dynamic-volume/src/getHangingProtocolModule.ts create mode 100644 extensions/cornerstone-dynamic-volume/src/getPanelModule.tsx create mode 100644 extensions/cornerstone-dynamic-volume/src/id.js create mode 100644 extensions/cornerstone-dynamic-volume/src/index.ts create mode 100644 extensions/cornerstone-dynamic-volume/src/panels/DynamicDataPanel.tsx create mode 100644 extensions/cornerstone-dynamic-volume/src/panels/DynamicExport.tsx create mode 100644 extensions/cornerstone-dynamic-volume/src/panels/DynamicVolumeControls.tsx create mode 100644 extensions/cornerstone-dynamic-volume/src/panels/GenerateVolume.tsx create mode 100644 extensions/cornerstone-dynamic-volume/src/panels/PanelGenerateImage.tsx create mode 100644 extensions/cornerstone-dynamic-volume/src/panels/WorkflowPanel.tsx create mode 100644 extensions/cornerstone-dynamic-volume/src/panels/index.js create mode 100644 extensions/cornerstone/src/components/ActiveViewportWindowLevel/ActiveViewportWindowLevel.tsx create mode 100644 extensions/cornerstone/src/components/ActiveViewportWindowLevel/index.js create mode 100644 extensions/cornerstone/src/components/ViewportWindowLevel/ViewportWindowLevel.tsx create mode 100644 extensions/cornerstone/src/components/ViewportWindowLevel/getViewportVolumeHistogram.ts create mode 100644 extensions/cornerstone/src/components/ViewportWindowLevel/histogramWorker.js create mode 100644 extensions/cornerstone/src/components/ViewportWindowLevel/index.js create mode 100644 extensions/default/src/Components/LineChartViewport/LineChartViewport.tsx create mode 100644 extensions/default/src/Components/LineChartViewport/index.ts delete mode 100644 extensions/default/src/Panels/ActionButtons.tsx create mode 100644 extensions/default/src/SOPClassHandlers/chartSOPClassHandler.ts create mode 100644 extensions/default/src/components/ProgressDropdownWithService/ProgressDropdownWithService.tsx create mode 100644 extensions/default/src/components/ProgressDropdownWithService/index.js create mode 100644 extensions/default/src/getViewportModule.tsx delete mode 100644 extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/ActionButtons.tsx create mode 100644 extensions/test-extension/src/hp/index.ts delete mode 100644 extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/ExportReports.tsx delete mode 100644 extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/LegacyPanelROIThresholdSegmentation.tsx create mode 100644 extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/PanelROIThresholdExport.tsx delete mode 100644 extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/PanelROIThresholdSegmentation.tsx delete mode 100644 extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/callInputDialog.tsx delete mode 100644 extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/colorPickerDialog.css delete mode 100644 extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/colorPickerDialog.tsx create mode 100644 modes/preclinical-4d/.webpack/webpack.dev.js create mode 100644 modes/preclinical-4d/.webpack/webpack.prod.js create mode 100644 modes/preclinical-4d/LICENSE create mode 100644 modes/preclinical-4d/README.md create mode 100644 modes/preclinical-4d/babel.config.js create mode 100644 modes/preclinical-4d/package.json create mode 100644 modes/preclinical-4d/src/getWorkflowSettings.ts create mode 100644 modes/preclinical-4d/src/id.js create mode 100644 modes/preclinical-4d/src/index.tsx create mode 100644 modes/preclinical-4d/src/initToolGroups.tsx create mode 100644 modes/preclinical-4d/src/initWorkflowSteps.ts create mode 100644 modes/preclinical-4d/src/segmentationButtons.ts create mode 100644 modes/preclinical-4d/src/toolbarButtons.tsx delete mode 100644 platform/core/src/services/PanelService/PanelService.ts create mode 100644 platform/core/src/services/PanelService/PanelService.tsx create mode 100644 platform/core/src/services/WorkflowStepsService/WorkflowStepsService.ts create mode 100644 platform/core/src/services/WorkflowStepsService/index.ts create mode 100644 platform/docs/docs/assets/img/progressDropdown.png create mode 100644 platform/docs/docs/platform/services/data/WorkflowStepService.md create mode 100644 platform/ui/src/assets/icons/arrow-right.svg create mode 100644 platform/ui/src/assets/icons/tool-brush.svg create mode 100644 platform/ui/src/assets/icons/tool-eraser.svg create mode 100644 platform/ui/src/assets/icons/tool-paint-fill.svg create mode 100644 platform/ui/src/assets/icons/tool-scissor-circle.svg create mode 100644 platform/ui/src/assets/icons/tool-scissor-rect.svg create mode 100644 platform/ui/src/components/ActionButtons/ActionButtons.tsx create mode 100644 platform/ui/src/components/ActionButtons/index.ts create mode 100644 platform/ui/src/components/CinePlayer/__stories__/cinePlayer.stories.mdx create mode 100644 platform/ui/src/components/LineChart/LineChart.css create mode 100644 platform/ui/src/components/LineChart/LineChart.tsx create mode 100644 platform/ui/src/components/LineChart/__stories__/lineChart.stories.mdx create mode 100644 platform/ui/src/components/LineChart/d3LineChart/chart.ts create mode 100644 platform/ui/src/components/LineChart/d3LineChart/events.ts create mode 100644 platform/ui/src/components/LineChart/d3LineChart/index.ts create mode 100644 platform/ui/src/components/LineChart/d3LineChart/lineChart.ts create mode 100644 platform/ui/src/components/LineChart/index.js create mode 100644 platform/ui/src/components/ProgressDropdown/ProgressDiscreteBar.tsx create mode 100644 platform/ui/src/components/ProgressDropdown/ProgressDropdown.tsx create mode 100644 platform/ui/src/components/ProgressDropdown/ProgressItem.tsx create mode 100644 platform/ui/src/components/ProgressDropdown/ProgressItemDetail.tsx create mode 100644 platform/ui/src/components/ProgressDropdown/__stories__/progressDropdown.stories.mdx create mode 100644 platform/ui/src/components/ProgressDropdown/index.js create mode 100644 platform/ui/src/components/ProgressDropdown/types.ts create mode 100644 platform/ui/src/components/WindowLevel/WindowLevel.tsx create mode 100644 platform/ui/src/components/WindowLevel/WindowLevelHistogram.tsx create mode 100644 platform/ui/src/components/WindowLevel/__stories__/windowLevel.stories.mdx create mode 100644 platform/ui/src/components/WindowLevel/index.js create mode 100644 platform/ui/src/components/WindowLevel/types.ts diff --git a/.gitignore b/.gitignore index ed3400749..245556be5 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ yarn-error.log .DS_Store .env *.code-workspace +.directory # Common Example Data Directories sampledata/ diff --git a/commit.txt b/commit.txt index 1590b5fff..90fdf8bec 100644 --- a/commit.txt +++ b/commit.txt @@ -1 +1 @@ -dc37802ec1f739a6ed602363bdf231d6fe58827e \ No newline at end of file +dc37802ec1f739a6ed602363bdf231d6fe58827e diff --git a/extensions/cornerstone-dicom-seg/package.json b/extensions/cornerstone-dicom-seg/package.json index cb50724e5..f5e84c27a 100644 --- a/extensions/cornerstone-dicom-seg/package.json +++ b/extensions/cornerstone-dicom-seg/package.json @@ -46,9 +46,9 @@ }, "dependencies": { "@babel/runtime": "^7.20.13", - "@cornerstonejs/adapters": "^1.68.1", - "@cornerstonejs/core": "^1.68.1", - "@kitware/vtk.js": "29.7.0", + "@cornerstonejs/adapters": "^1.70.0", + "@cornerstonejs/core": "^1.70.0", + "@kitware/vtk.js": "30.3.1", "react-color": "^2.19.3" } } diff --git a/extensions/cornerstone-dicom-seg/src/getPanelModule.tsx b/extensions/cornerstone-dicom-seg/src/getPanelModule.tsx index 12331caee..a86144d4f 100644 --- a/extensions/cornerstone-dicom-seg/src/getPanelModule.tsx +++ b/extensions/cornerstone-dicom-seg/src/getPanelModule.tsx @@ -3,7 +3,6 @@ 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,11 +16,6 @@ const getPanelModule = ({ const wrappedPanelSegmentation = configuration => { const [appConfig] = useAppConfig(); - const disableEditingForMode = customizationService.get('segmentation.disableEditing'); - const segmentationPanelMode = - customizationService.get('segmentation.segmentationPanelMode')?.value || - SegmentationPanelMode.Dropdown; - return ( ); @@ -38,9 +32,6 @@ const getPanelModule = ({ const wrappedPanelSegmentationWithTools = configuration => { const [appConfig] = useAppConfig(); - const segmentationPanelMode = - customizationService.get('segmentation.segmentationPanelMode')?.value || - SegmentationPanelMode.Dropdown; return ( <> @@ -60,7 +51,8 @@ const getPanelModule = ({ extensionManager={extensionManager} configuration={{ ...configuration, - segmentationPanelMode: segmentationPanelMode, + disableEditing: appConfig.disableEditing, + ...customizationService.get('segmentation.panel'), }} /> diff --git a/extensions/cornerstone-dicom-seg/src/getToolbarModule.ts b/extensions/cornerstone-dicom-seg/src/getToolbarModule.ts index 19390d2ac..0a06cf7eb 100644 --- a/extensions/cornerstone-dicom-seg/src/getToolbarModule.ts +++ b/extensions/cornerstone-dicom-seg/src/getToolbarModule.ts @@ -52,16 +52,22 @@ export function getToolbarModule({ commandsManager, servicesManager }) { ]; } +// Todo: this is duplicate, we should move it to a shared location function getToolNameForButton(button) { const { props } = button; const commands = props?.commands || button.commands; - - if (commands && commands.length) { - const command = commands[0]; - const { commandOptions } = command; - const { toolName } = commandOptions || { toolName: props?.id ?? button.id }; - return toolName; + const commandsArray = Array.isArray(commands) ? commands : [commands]; + const firstCommand = commandsArray[0]; + if (typeof firstCommand === 'string') { + // likely not a cornerstone tool + return null; } - return null; + + if ('commandOptions' in firstCommand) { + return firstCommand.commandOptions.toolName ?? props?.id ?? button.id; + } + + // use id as a fallback for toolName + return props?.id ?? button.id; } diff --git a/extensions/cornerstone-dicom-seg/src/index.tsx b/extensions/cornerstone-dicom-seg/src/index.tsx index 5fe0efef5..02db57545 100644 --- a/extensions/cornerstone-dicom-seg/src/index.tsx +++ b/extensions/cornerstone-dicom-seg/src/index.tsx @@ -6,7 +6,6 @@ import getHangingProtocolModule from './getHangingProtocolModule'; import getPanelModule from './getPanelModule'; import getCommandsModule from './commandsModule'; import { getToolbarModule } from './getToolbarModule'; -import preRegistration from './init'; const Component = React.lazy(() => { return import(/* webpackPrefetch: true */ './viewports/OHIFCornerstoneSEGViewport'); @@ -29,7 +28,6 @@ const extension = { * You ID can be anything you want, but it should be unique. */ id, - preRegistration, /** * PanelModule should provide a list of panels that will be available in OHIF * for Modes to consume and render. Each panel is defined by a {name, diff --git a/extensions/cornerstone-dicom-seg/src/init.ts b/extensions/cornerstone-dicom-seg/src/init.ts deleted file mode 100644 index c57e6a289..000000000 --- a/extensions/cornerstone-dicom-seg/src/init.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { addTool, BrushTool } from '@cornerstonejs/tools'; - -export default function init({ servicesManager }): void { - addTool(BrushTool); -} diff --git a/extensions/cornerstone-dicom-seg/src/panels/PanelSegmentation.tsx b/extensions/cornerstone-dicom-seg/src/panels/PanelSegmentation.tsx index 1a63ecd3a..c7f0232c9 100644 --- a/extensions/cornerstone-dicom-seg/src/panels/PanelSegmentation.tsx +++ b/extensions/cornerstone-dicom-seg/src/panels/PanelSegmentation.tsx @@ -242,7 +242,13 @@ export default function PanelSegmentation({ }); }; - const SegmentationGroupTableComponent = components[configuration?.segmentationPanelMode]; + const SegmentationGroupTableComponent = + components[configuration?.segmentationPanelMode] || SegmentationGroupTable; + const allowAddSegment = configuration?.addSegment; + const onSegmentationAddWrapper = + configuration?.onSegmentationAdd && typeof configuration?.onSegmentationAdd === 'function' + ? configuration?.onSegmentationAdd + : onSegmentationAdd; return ( { + return webpackCommon(env, argv, { SRC_DIR, DIST_DIR }); +}; diff --git a/extensions/cornerstone-dynamic-volume/.webpack/webpack.prod.js b/extensions/cornerstone-dynamic-volume/.webpack/webpack.prod.js new file mode 100644 index 000000000..66a3e9fc1 --- /dev/null +++ b/extensions/cornerstone-dynamic-volume/.webpack/webpack.prod.js @@ -0,0 +1,54 @@ +const webpack = require('webpack'); +const { merge } = require('webpack-merge'); +const path = require('path'); +const webpackCommon = require('./../../../.webpack/webpack.base.js'); +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); + +const pkg = require('./../package.json'); + +const ROOT_DIR = path.join(__dirname, '../'); +const SRC_DIR = path.join(__dirname, '../src'); +const DIST_DIR = path.join(__dirname, '../dist'); +const ENTRY = { + app: `${SRC_DIR}/index.ts`, +}; + +const outputName = `ohif-${pkg.name.split('/').pop()}`; + +module.exports = (env, argv) => { + const commonConfig = webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY }); + + return merge(commonConfig, { + stats: { + colors: true, + hash: true, + timings: true, + assets: true, + chunks: false, + chunkModules: false, + modules: false, + children: false, + warnings: true, + }, + optimization: { + minimize: true, + sideEffects: true, + }, + output: { + path: ROOT_DIR, + library: 'ohif-extension-cornerstone', + libraryTarget: 'umd', + filename: pkg.main, + }, + externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@ohif/, /^@cornerstonejs/], + plugins: [ + new webpack.optimize.LimitChunkCountPlugin({ + maxChunks: 1, + }), + new MiniCssExtractPlugin({ + filename: `./dist/${outputName}.css`, + chunkFilename: `./dist/${outputName}.css`, + }), + ], + }); +}; diff --git a/extensions/cornerstone-dynamic-volume/LICENSE b/extensions/cornerstone-dynamic-volume/LICENSE new file mode 100644 index 000000000..24728a704 --- /dev/null +++ b/extensions/cornerstone-dynamic-volume/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2023 cornerstone-dynamic-volume () + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/extensions/cornerstone-dynamic-volume/README.md b/extensions/cornerstone-dynamic-volume/README.md new file mode 100644 index 000000000..70949801a --- /dev/null +++ b/extensions/cornerstone-dynamic-volume/README.md @@ -0,0 +1,8 @@ +# cornerstone-dynamic-volume +## Description + +## Author +OHIF + +## License +MIT diff --git a/extensions/cornerstone-dynamic-volume/babel.config.js b/extensions/cornerstone-dynamic-volume/babel.config.js new file mode 100644 index 000000000..325ca2a8e --- /dev/null +++ b/extensions/cornerstone-dynamic-volume/babel.config.js @@ -0,0 +1 @@ +module.exports = require('../../babel.config.js'); diff --git a/extensions/cornerstone-dynamic-volume/package.json b/extensions/cornerstone-dynamic-volume/package.json new file mode 100644 index 000000000..39b8882fe --- /dev/null +++ b/extensions/cornerstone-dynamic-volume/package.json @@ -0,0 +1,50 @@ +{ + "name": "@ohif/extension-cornerstone-dynamic-volume", + "version": "3.8.0-beta.72", + "description": "OHIF extension for 4D volumes data", + "author": "OHIF", + "license": "MIT", + "repository": "OHIF/Viewers", + "main": "dist/ohif-extension-cornerstone-dynamic-volume.umd.js", + "module": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./types": "./src/types/index.ts" + }, + "files": [ + "dist", + "README.md" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --output-pathinfo", + "build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js", + "build:package": "yarn run build", + "clean": "shx rm -rf dist", + "clean:deep": "yarn run clean && shx rm -rf node_modules", + "start": "yarn run dev", + "test:unit": "jest --watchAll", + "test:unit:ci": "jest --ci --runInBand --collectCoverage --passWithNoTests" + }, + "peerDependencies": { + "@ohif/core": "3.7.0-beta.76", + "@ohif/ui": "3.7.0-beta.76", + "@ohif/extension-default": "3.7.0-beta.76", + "@ohif/extension-cornerstone": "3.7.0-beta.76", + "@ohif/i18n": "3.7.0-beta.76", + "dcmjs": "^0.29.5", + "dicom-parser": "^1.8.21", + "hammerjs": "^2.0.8", + "prop-types": "^15.6.2", + "react": "^17.0.2" + }, + "dependencies": { + "@babel/runtime": "^7.20.13", + "@cornerstonejs/tools": "^1.70.0", + "@cornerstonejs/core": "^1.70.0", + "@cornerstonejs/streaming-image-volume-loader": "^1.70.0", + "classnames": "^2.3.2" + } +} diff --git a/extensions/cornerstone-dynamic-volume/src/actions/index.ts b/extensions/cornerstone-dynamic-volume/src/actions/index.ts new file mode 100644 index 000000000..45d2adfc2 --- /dev/null +++ b/extensions/cornerstone-dynamic-volume/src/actions/index.ts @@ -0,0 +1,3 @@ +import updateSegmentationsChartDisplaySet from './updateSegmentationsChartDisplaySet'; + +export { updateSegmentationsChartDisplaySet }; diff --git a/extensions/cornerstone-dynamic-volume/src/actions/updateSegmentationsChartDisplaySet.ts b/extensions/cornerstone-dynamic-volume/src/actions/updateSegmentationsChartDisplaySet.ts new file mode 100644 index 000000000..810d98488 --- /dev/null +++ b/extensions/cornerstone-dynamic-volume/src/actions/updateSegmentationsChartDisplaySet.ts @@ -0,0 +1,281 @@ +import { DicomMetadataStore, utils } from '@ohif/core'; + +import * as cs from '@cornerstonejs/core'; +import * as csTools from '@cornerstonejs/tools'; + +const CHART_MODALITY = 'CHT'; +const SEG_CHART_INSTANCE_UID = utils.guid(); + +// Private SOPClassUid for chart data +const ChartDataSOPClassUid = '1.9.451.13215.7.3.2.7.6.1'; + +const { utilities: csToolsUtils } = csTools; + +function _getDateTimeStr() { + const now = new Date(); + const date = + now.getFullYear() + ('0' + now.getUTCMonth()).slice(-2) + ('0' + now.getUTCDate()).slice(-2); + const time = + ('0' + now.getUTCHours()).slice(-2) + + ('0' + now.getUTCMinutes()).slice(-2) + + ('0' + now.getUTCSeconds()).slice(-2); + + return { date, time }; +} + +function _getTimePointsDataByTagName(volume, timePointsTag) { + const uniqueTimePoints = volume.imageIds.reduce((timePoints, imageId) => { + const instance = DicomMetadataStore.getInstanceByImageId(imageId); + const timePointValue = instance[timePointsTag]; + + if (timePointValue !== undefined) { + timePoints.add(timePointValue); + } + + return timePoints; + }, new Set()); + + return Array.from(uniqueTimePoints).sort((a: number, b: number) => a - b); +} + +function _convertTimePointsUnit(timePoints, timePointsUnit) { + const validUnits = ['ms', 's', 'm', 'h']; + const divisors = [1000, 60, 60]; + const currentUnitIndex = validUnits.indexOf(timePointsUnit); + let divisor = 1; + + if (currentUnitIndex !== -1) { + for (let i = currentUnitIndex; i < validUnits.length - 1; i++) { + const newDivisor = divisor * divisors[i]; + const greaterThanDivisorCount = timePoints.filter(timePoint => timePoint > newDivisor).length; + + // Change the scale only if more than 50% of the time points are + // greater than the new divisor. + if (greaterThanDivisorCount <= timePoints.length / 2) { + break; + } + + divisor = newDivisor; + timePointsUnit = validUnits[i + 1]; + } + + if (divisor > 1) { + timePoints = timePoints.map(timePoint => timePoint / divisor); + } + } + + return { timePoints, timePointsUnit }; +} + +// It currently supports only one tag but a few other will be added soon +// Supported 4D Tags +// (0018,1060) Trigger Time [NOK] +// (0018,0081) Echo Time [NOK] +// (0018,0086) Echo Number [NOK] +// (0020,0100) Temporal Position Identifier [NOK] +// (0054,1300) FrameReferenceTime [OK] +function _getTimePointsData(volume) { + const timePointsTags = { + FrameReferenceTime: { + unit: 'ms', + }, + }; + + const timePointsTagNames = Object.keys(timePointsTags); + let timePoints; + let timePointsUnit; + + for (let i = 0; i < timePointsTagNames.length; i++) { + const tagName = timePointsTagNames[i]; + const curTimePoints = _getTimePointsDataByTagName(volume, tagName); + + if (curTimePoints.length) { + timePoints = curTimePoints; + timePointsUnit = timePointsTags[tagName].unit; + break; + } + } + + if (!timePoints.length) { + const concatTagNames = timePointsTagNames.join(', '); + + throw new Error(`Could not extract time points data for the following tags: ${concatTagNames}`); + } + + const convertedTimePoints = _convertTimePointsUnit(timePoints, timePointsUnit); + + timePoints = convertedTimePoints.timePoints; + timePointsUnit = convertedTimePoints.timePointsUnit; + + return { timePoints, timePointsUnit }; +} + +function _getSegmentationData(segmentation, volumesTimePointsCache, displaySetService) { + const displaySets = displaySetService.getActiveDisplaySets(); + + const dynamic4DDisplaySet = displaySets.find(displaySet => { + const anInstance = displaySet.instances?.[0]; + + if (anInstance) { + return ( + anInstance.FrameReferenceTime !== undefined || anInstance.NumberOfTimeSlices !== undefined + ); + } + + return false; + }); + + // const referencedDynamicVolume = cs.cache.getVolume(dynamic4DDisplaySet.displaySetInstanceUID); + let volumeCacheKey: string | undefined; + const volumeId = dynamic4DDisplaySet.displaySetInstanceUID; + + for (const [key] of cs.cache._volumeCache) { + if (key.includes(volumeId)) { + volumeCacheKey = key; + break; + } + } + + let referencedDynamicVolume; + if (volumeCacheKey) { + referencedDynamicVolume = cs.cache.getVolume(volumeCacheKey); + } + + const { StudyInstanceUID, StudyDescription } = DicomMetadataStore.getInstanceByImageId( + referencedDynamicVolume.imageIds[0] + ); + + const [timeData, _] = csToolsUtils.dynamicVolume.getDataInTime(referencedDynamicVolume, { + maskVolumeId: segmentation.id, + }) as number[][]; + + const pixelCount = timeData.length; + + if (pixelCount === 0) { + return []; + } + + // since we only use one segmentation representation per segmentationId + // it is fine to pick the first one + const segmentationRepresentations = csTools.segmentation.state.getSegmentationIdRepresentations( + segmentation.id + ); + + const segmentationRepresentationUID = + segmentationRepresentations[0].segmentationRepresentationUID; + + const toolGroupId = csTools.segmentation.state.getToolGroupIdFromSegmentationRepresentationUID( + segmentationRepresentationUID + ); + + // Todo: this is useless we should be able to grab color with just segRepUID and segmentIndex + const color = csTools.segmentation.config.color.getColorForSegmentIndex( + toolGroupId, + segmentationRepresentationUID, + 1 // segmentIndex + ); + + const hexColor = cs.utilities.color.rgbToHex(...color); + let timePointsData = volumesTimePointsCache.get(referencedDynamicVolume); + + if (!timePointsData) { + timePointsData = _getTimePointsData(referencedDynamicVolume); + volumesTimePointsCache.set(referencedDynamicVolume, timePointsData); + } + + const { timePoints, timePointsUnit } = timePointsData; + + if (timePoints.length !== timeData[0].length) { + throw new Error('Invalid number of time points returned'); + } + + const timepointsCount = timePoints.length; + const chartSeriesData = new Array(timepointsCount); + + for (let i = 0; i < timepointsCount; i++) { + const average = timeData.reduce((acc, cur) => acc + cur[i] / pixelCount, 0); + + chartSeriesData[i] = [timePoints[i], average]; + } + + return { + StudyInstanceUID, + StudyDescription, + chartData: { + series: { + label: segmentation.label, + points: chartSeriesData, + color: hexColor, + }, + axis: { + x: { + label: `Time (${timePointsUnit})`, + }, + y: { + label: `Vl (Bq/ml)`, + }, + }, + }, + }; +} + +function _getInstanceFromSegmentations(segmentations, displaySetService) { + if (!segmentations.length) { + return; + } + + const volumesTimePointsCache = new WeakMap(); + const segmentationsData = segmentations.map(segmentation => + _getSegmentationData(segmentation, volumesTimePointsCache, displaySetService) + ); + + const { date: seriesDate, time: seriesTime } = _getDateTimeStr(); + const series = segmentationsData.reduce((allSeries, curSegData) => { + return [...allSeries, curSegData.chartData.series]; + }, []); + + const instance = { + SOPClassUID: ChartDataSOPClassUid, + Modality: CHART_MODALITY, + SOPInstanceUID: utils.guid(), + SeriesDate: seriesDate, + SeriesTime: seriesTime, + SeriesInstanceUID: SEG_CHART_INSTANCE_UID, + StudyInstanceUID: segmentationsData[0].StudyInstanceUID, + StudyDescription: segmentationsData[0].StudyDescription, + SeriesNumber: 100, + SeriesDescription: 'Segmentation chart series data', + chartData: { + series, + axis: { ...segmentationsData[0].chartData.axis }, + }, + }; + + const seriesMetadata = { + StudyInstanceUID: instance.StudyInstanceUID, + StudyDescription: instance.StudyDescription, + SeriesInstanceUID: instance.SeriesInstanceUID, + SeriesDescription: instance.SeriesDescription, + SeriesNumber: instance.SeriesNumber, + SeriesTime: instance.SeriesTime, + SOPClassUID: instance.SOPClassUID, + Modality: instance.Modality, + }; + + return { seriesMetadata, instance }; +} + +function updateSegmentationsChartDisplaySet({ servicesManager }): void { + const { segmentationService, displaySetService } = servicesManager.services; + const segmentations = segmentationService.getSegmentations(); + const { seriesMetadata, instance } = + _getInstanceFromSegmentations(segmentations, displaySetService) ?? {}; + + if (seriesMetadata && instance) { + // An event is triggered after adding the instance and the displaySet is created + DicomMetadataStore.addSeriesMetadata([seriesMetadata], true); + DicomMetadataStore.addInstances([instance], true); + } +} + +export { updateSegmentationsChartDisplaySet as default }; diff --git a/extensions/cornerstone-dynamic-volume/src/commandsModule.ts b/extensions/cornerstone-dynamic-volume/src/commandsModule.ts new file mode 100644 index 000000000..a6cdf6cda --- /dev/null +++ b/extensions/cornerstone-dynamic-volume/src/commandsModule.ts @@ -0,0 +1,407 @@ +import * as importedActions from './actions'; +import { utilities, Enums } from '@cornerstonejs/tools'; +import { cache } from '@cornerstonejs/core'; + +const LABELMAP = Enums.SegmentationRepresentations.Labelmap; + +const commandsModule = ({ commandsManager, servicesManager }) => { + const services = servicesManager.services; + const { displaySetService, viewportGridService, segmentationService } = services; + + const actions = { + ...importedActions, + getDynamic4DDisplaySet: () => { + const displaySets = displaySetService.getActiveDisplaySets(); + + const dynamic4DDisplaySet = displaySets.find(displaySet => { + const anInstance = displaySet.instances?.[0]; + + if (anInstance) { + return ( + anInstance.FrameReferenceTime !== undefined || + anInstance.NumberOfTimeSlices !== undefined || + anInstance.TemporalPositionIdentifier !== undefined + ); + } + + return false; + }); + + return dynamic4DDisplaySet; + }, + getComputedDisplaySets: () => { + const displaySetCache = displaySetService.getDisplaySetCache(); + const cachedDisplaySets = [...displaySetCache.values()]; + const computedDisplaySets = cachedDisplaySets.filter(displaySet => { + return displaySet.isDerived; + }); + return computedDisplaySets; + }, + exportTimeReportCSV: ({ segmentations, config, options, summaryStats }) => { + const dynamic4DDisplaySet = actions.getDynamic4DDisplaySet(); + + const volumeId = dynamic4DDisplaySet?.displaySetInstanceUID; + + // cache._volumeCache is a map that has a key that includes the volumeId + // it is not exactly the volumeId, but it is the key that includes the volumeId + // so we can't do cache._volumeCache.get(volumeId) we should iterate + // over the keys and find the one that includes the volumeId + let volumeCacheKey: string | undefined; + + for (const [key] of cache._volumeCache) { + if (key.includes(volumeId)) { + volumeCacheKey = key; + break; + } + } + + let dynamicVolume; + if (volumeCacheKey) { + dynamicVolume = cache.getVolume(volumeCacheKey); + } + + const instance = dynamic4DDisplaySet.instances[0]; + + const csv = []; + + // CSV header information with placeholder empty values for the metadata lines + csv.push(`Patient ID,${instance.PatientID},`); + csv.push(`Study Date,${instance.StudyDate},`); + csv.push(`StudyInstanceUID,${instance.StudyInstanceUID},`); + csv.push(`StudyDescription,${instance.StudyDescription},`); + csv.push(`SeriesInstanceUID,${instance.SeriesInstanceUID},`); + + // empty line + csv.push(''); + csv.push(''); + + // Helper function to calculate standard deviation + function calculateStandardDeviation(data) { + const n = data.length; + const mean = data.reduce((acc, value) => acc + value, 0) / n; + const squaredDifferences = data.map(value => (value - mean) ** 2); + const variance = squaredDifferences.reduce((acc, value) => acc + value, 0) / n; + const stdDeviation = Math.sqrt(variance); + return stdDeviation; + } + + // Iterate through each segmentation to get the timeData and ijkCoords + segmentations.forEach((segmentation, segmentationIndex) => { + const [timeData, ijkCoords] = utilities.dynamicVolume.getDataInTime(dynamicVolume, { + maskVolumeId: segmentation.id, + }) as number[][]; + + if (summaryStats) { + // Adding column headers for pixel identifier and segmentation label ids + let headers = 'Operation,Segmentation Label ID'; + const maxLength = dynamicVolume.numTimePoints; + for (let t = 0; t < maxLength; t++) { + headers += `,Time Point ${t}`; + } + csv.push(headers); + // // perform summary statistics on the timeData including for each time point, mean, median, min, max, and standard deviation for + // // all the voxels in the ROI + const mean = []; + const min = []; + const minIJK = []; + const max = []; + const maxIJK = []; + const std = []; + + const numVoxels = timeData.length; + // Helper function to calculate standard deviation + for (let timeIndex = 0; timeIndex < maxLength; timeIndex++) { + // for each voxel in the ROI, get the value at the current time point + const voxelValues = []; + for (let voxelIndex = 0; voxelIndex < numVoxels; voxelIndex++) { + voxelValues.push(timeData[voxelIndex][timeIndex]); + } + + mean.push(voxelValues.reduce((acc, value) => acc + value, 0) / numVoxels); + const minimum = Math.min(...voxelValues); + min.push(minimum); + minIJK.push(ijkCoords[voxelValues.indexOf(minimum)]); + const maximum = Math.max(...voxelValues); + max.push(maximum); + maxIJK.push(ijkCoords[voxelValues.indexOf(maximum)]); + std.push(calculateStandardDeviation(voxelValues)); + } + + let row = `Mean,${segmentation.label}`; + // Generate separate rows for each statistic + for (let t = 0; t < maxLength; t++) { + row += `,${mean[t]}`; + } + + csv.push(row); + + row = `Standard Deviation,${segmentation.label}`; + for (let t = 0; t < maxLength; t++) { + row += `,${std[t]}`; + } + + csv.push(row); + + row = `Min,${segmentation.label}`; + for (let t = 0; t < maxLength; t++) { + row += `,${min[t]}`; + } + + csv.push(row); + + row = `Max,${segmentation.label}`; + for (let t = 0; t < maxLength; t++) { + row += `,${max[t]}`; + } + + csv.push(row); + } else { + // Adding column headers for pixel identifier and segmentation label ids + let headers = 'Pixel Identifier (IJK),Segmentation Label ID'; + const maxLength = dynamicVolume.numTimePoints; + for (let t = 0; t < maxLength; t++) { + headers += `,Time Point ${t}`; + } + csv.push(headers); + // Assuming timeData and ijkCoords are of the same length + for (let i = 0; i < timeData.length; i++) { + // Generate the pixel identifier + const pixelIdentifier = `${ijkCoords[i][0]}_${ijkCoords[i][1]}_${ijkCoords[i][2]}`; + + // Start a new row for the current pixel + let row = `${pixelIdentifier},${segmentation.label}`; + + // Add time data points for this pixel + for (let t = 0; t < timeData[i].length; t++) { + row += `,${timeData[i][t]}`; + } + + // Append the row to the CSV array + csv.push(row); + } + } + }); + + // Convert to CSV string + const csvContent = csv.join('\n'); + + // Generate filename and trigger download + const filename = `${instance.PatientID}.csv`; + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + link.setAttribute('href', url); + link.setAttribute('download', filename); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }, + swapDynamicWithComputedDisplaySet: ({ displaySet }) => { + const computedDisplaySet = displaySet; + + const displaySetCache = displaySetService.getDisplaySetCache(); + const cachedDisplaySetKeys = [displaySetCache.keys()]; + const { displaySetInstanceUID } = computedDisplaySet; + // Check to see if computed display set is already in cache + if (!cachedDisplaySetKeys.includes(displaySetInstanceUID)) { + displaySetCache.set(displaySetInstanceUID, computedDisplaySet); + } + + // Get all viewports and their corresponding indices + const { viewports } = viewportGridService.getState(); + + // get the viewports in the grid + // iterate over them and find the ones that are showing a dynamic + // volume (displaySet), and replace that exact displaySet with the + // computed displaySet + + const dynamic4DDisplaySet = actions.getDynamic4DDisplaySet(); + + const viewportsToUpdate = []; + + for (const [key, value] of viewports) { + const viewport = value; + const viewportOptions = viewport.viewportOptions; + const { displaySetInstanceUIDs } = viewport; + const displaySetInstanceUIDIndex = displaySetInstanceUIDs.indexOf( + dynamic4DDisplaySet.displaySetInstanceUID + ); + if (displaySetInstanceUIDIndex !== -1) { + const newViewport = { + viewportId: viewport.viewportId, + // merge the other displaySetInstanceUIDs with the new one + displaySetInstanceUIDs: [ + ...displaySetInstanceUIDs.slice(0, displaySetInstanceUIDIndex), + displaySetInstanceUID, + ...displaySetInstanceUIDs.slice(displaySetInstanceUIDIndex + 1), + ], + viewportOptions: { + initialImageOptions: viewportOptions.initialImageOptions, + viewportType: 'volume', + orientation: viewportOptions.orientation, + background: viewportOptions.background, + }, + }; + viewportsToUpdate.push(newViewport); + } + } + + viewportGridService.setDisplaySetsForViewports(viewportsToUpdate); + }, + swapComputedWithDynamicDisplaySet: () => { + // Todo: this assumes there is only one dynamic display set in the viewer + const dynamicDisplaySet = actions.getDynamic4DDisplaySet(); + + const displaySetCache = displaySetService.getDisplaySetCache(); + const cachedDisplaySetKeys = [...displaySetCache.keys()]; // Fix: Spread to get the array + const { displaySetInstanceUID } = dynamicDisplaySet; + + // Check to see if dynamic display set is already in cache + if (!cachedDisplaySetKeys.includes(displaySetInstanceUID)) { + displaySetCache.set(displaySetInstanceUID, dynamicDisplaySet); + } + + // Get all viewports and their corresponding indices + const { viewports } = viewportGridService.getState(); + + // Get the computed 4D display set + const computed4DDisplaySet = actions.getComputedDisplaySets()[0]; + + const viewportsToUpdate = []; + + for (const [key, value] of viewports) { + const viewport = value; + const viewportOptions = viewport.viewportOptions; + const { displaySetInstanceUIDs } = viewport; + const displaySetInstanceUIDIndex = displaySetInstanceUIDs.indexOf( + computed4DDisplaySet.displaySetInstanceUID + ); + if (displaySetInstanceUIDIndex !== -1) { + const newViewport = { + viewportId: viewport.viewportId, + // merge the other displaySetInstanceUIDs with the new one + displaySetInstanceUIDs: [ + ...displaySetInstanceUIDs.slice(0, displaySetInstanceUIDIndex), + displaySetInstanceUID, + ...displaySetInstanceUIDs.slice(displaySetInstanceUIDIndex + 1), + ], + viewportOptions: { + initialImageOptions: viewportOptions.initialImageOptions, + viewportType: 'volume', + orientation: viewportOptions.orientation, + background: viewportOptions.background, + }, + }; + viewportsToUpdate.push(newViewport); + } + } + + viewportGridService.setDisplaySetsForViewports(viewportsToUpdate); + }, + createNewLabelMapForDynamicVolume: async ({ label }) => { + const { viewports, activeViewportId } = viewportGridService.getState(); + + // get the dynamic 4D display set + const dynamic4DDisplaySet = actions.getDynamic4DDisplaySet(); + const dynamic4DDisplaySetInstanceUID = dynamic4DDisplaySet.displaySetInstanceUID; + + // check if the dynamic 4D display set is in the display, if not we might have + // the computed volumes and we should choose them for the segmentation + // creation + + let referenceDisplaySet; + + const activeViewport = viewports.get(activeViewportId); + const activeDisplaySetInstanceUIDs = activeViewport.displaySetInstanceUIDs; + const dynamicIsInActiveViewport = activeDisplaySetInstanceUIDs.includes( + dynamic4DDisplaySetInstanceUID + ); + + if (dynamicIsInActiveViewport) { + referenceDisplaySet = dynamic4DDisplaySet; + } + + if (!referenceDisplaySet) { + // try to see if there is any derived displaySet in the active viewport + // which is referencing the dynamic 4D display set + + // Todo: this is wrong but I don't have time to fix it now + const cachedDisplaySets = displaySetService.getDisplaySetCache(); + for (const [key, displaySet] of cachedDisplaySets) { + if (displaySet.referenceDisplaySetUID === dynamic4DDisplaySetInstanceUID) { + referenceDisplaySet = displaySet; + break; + } + } + } + + if (!referenceDisplaySet) { + throw new Error('No reference display set found based on the dynamic data'); + } + + const segmentationId = await segmentationService.createSegmentationForDisplaySet( + referenceDisplaySet.displaySetInstanceUID, + { label } + ); + + // Add Segmentation to all toolGroupIds in the viewer + const toolGroupIds = Array.from( + viewports.values(), + viewport => viewport.viewportOptions.toolGroupId + ); + + const representationType = LABELMAP; + + for (const toolGroupId of toolGroupIds) { + const hydrateSegmentation = true; + await segmentationService.addSegmentationRepresentationToToolGroup( + toolGroupId, + segmentationId, + hydrateSegmentation, + representationType + ); + + segmentationService.setActiveSegmentationForToolGroup(segmentationId, toolGroupId); + } + + return segmentationId; + }, + }; + + const definitions = { + updateSegmentationsChartDisplaySet: { + commandFn: actions.updateSegmentationsChartDisplaySet, + storeContexts: [], + options: {}, + }, + exportTimeReportCSV: { + commandFn: actions.exportTimeReportCSV, + storeContexts: [], + options: {}, + }, + swapDynamicWithComputedDisplaySet: { + commandFn: actions.swapDynamicWithComputedDisplaySet, + storeContexts: [], + options: {}, + }, + createNewLabelMapForDynamicVolume: { + commandFn: actions.createNewLabelMapForDynamicVolume, + storeContexts: [], + options: {}, + }, + swapComputedWithDynamicDisplaySet: { + commandFn: actions.swapComputedWithDynamicDisplaySet, + storeContexts: [], + options: {}, + }, + }; + + return { + actions, + definitions, + defaultContext: 'DYNAMIC-VOLUME:CORNERSTONE', + }; +}; + +export default commandsModule; diff --git a/extensions/cornerstone-dynamic-volume/src/getHangingProtocolModule.ts b/extensions/cornerstone-dynamic-volume/src/getHangingProtocolModule.ts new file mode 100644 index 000000000..0a8b6d520 --- /dev/null +++ b/extensions/cornerstone-dynamic-volume/src/getHangingProtocolModule.ts @@ -0,0 +1,654 @@ +const DEFAULT_COLORMAP = '2hot'; +const toolGroupIds = { + pt: 'dynamic4D-pt', + fusion: 'dynamic4D-fusion', + ct: 'dynamic4D-ct', +}; + +function getPTOptions({ + colormap, + voiInverted, +}: { + colormap?: { + name: string; + opacity: + | number + | { + value: number; + opacity: number; + }[]; + }; + voiInverted?: boolean; +} = {}) { + return { + blendMode: 'MIP', + colormap, + voi: { + windowWidth: 5, + windowCenter: 2.5, + }, + voiInverted, + }; +} + +function getPTViewports() { + const ptOptionsParams = { + colormap: { + name: DEFAULT_COLORMAP, + opacity: [ + { value: 0, opacity: 0 }, + { value: 0.1, opacity: 1 }, + { value: 1, opacity: 1 }, + ], + }, + voiInverted: false, + }; + + return [ + { + viewportOptions: { + viewportId: 'ptAxial', + viewportType: 'volume', + orientation: 'axial', + toolGroupId: toolGroupIds.pt, + initialImageOptions: { + preset: 'middle', // 'first', 'last', 'middle' + }, + syncGroups: [ + { + type: 'cameraPosition', + id: 'axialSync', + source: true, + target: true, + }, + { + type: 'voi', + id: 'ptWLSync', + source: true, + target: true, + }, + ], + }, + displaySets: [ + { + id: 'ptDisplaySet', + options: { ...getPTOptions(ptOptionsParams) }, + }, + ], + }, + { + viewportOptions: { + viewportId: 'ptSagittal', + viewportType: 'volume', + orientation: 'sagittal', + toolGroupId: toolGroupIds.pt, + initialImageOptions: { + preset: 'middle', // 'first', 'last', 'middle' + }, + syncGroups: [ + { + type: 'cameraPosition', + id: 'sagittalSync', + source: true, + target: true, + }, + { + type: 'voi', + id: 'ptWLSync', + source: true, + target: true, + }, + ], + }, + displaySets: [ + { + id: 'ptDisplaySet', + options: { ...getPTOptions(ptOptionsParams) }, + }, + ], + }, + { + viewportOptions: { + viewportId: 'ptCoronal', + viewportType: 'volume', + orientation: 'coronal', + toolGroupId: toolGroupIds.pt, + initialImageOptions: { + preset: 'middle', // 'first', 'last', 'middle' + }, + syncGroups: [ + { + type: 'cameraPosition', + id: 'coronalSync', + source: true, + target: true, + }, + { + type: 'voi', + id: 'ptWLSync', + source: true, + target: true, + }, + ], + }, + displaySets: [ + { + id: 'ptDisplaySet', + options: { ...getPTOptions(ptOptionsParams) }, + }, + ], + }, + ]; +} + +function getFusionViewports() { + const ptOptionsParams = { + colormap: { + name: DEFAULT_COLORMAP, + opacity: [ + { value: 0, opacity: 0 }, + { value: 0.1, opacity: 0.3 }, + { value: 1, opacity: 0.3 }, + ], + }, + }; + + return [ + { + viewportOptions: { + viewportId: 'fusionAxial', + viewportType: 'volume', + orientation: 'axial', + toolGroupId: toolGroupIds.fusion, + initialImageOptions: { + preset: 'middle', // '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, + options: { + syncInvertState: false, + }, + }, + ], + }, + displaySets: [ + { + id: 'ctDisplaySet', + }, + { + options: { ...getPTOptions(ptOptionsParams) }, + id: 'ptDisplaySet', + }, + ], + }, + { + viewportOptions: { + viewportId: 'fusionSagittal', + viewportType: 'volume', + orientation: 'sagittal', + toolGroupId: toolGroupIds.fusion, + initialImageOptions: { + 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, + options: { + syncInvertState: false, + }, + }, + ], + }, + displaySets: [ + { + id: 'ctDisplaySet', + }, + { + options: { ...getPTOptions(ptOptionsParams) }, + id: 'ptDisplaySet', + }, + ], + }, + { + viewportOptions: { + viewportId: 'fusionCoronal', + viewportType: 'volume', + orientation: 'coronal', + toolGroupId: toolGroupIds.fusion, + initialImageOptions: { + 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, + options: { + syncInvertState: false, + }, + }, + ], + }, + displaySets: [ + { + id: 'ctDisplaySet', + }, + { + options: { ...getPTOptions(ptOptionsParams) }, + id: 'ptDisplaySet', + }, + ], + }, + ]; +} + +function getSeriesChartViewport() { + return { + viewportOptions: { + viewportId: 'seriesChart', + }, + displaySets: [ + { + id: 'chartDisplaySet', + options: { + // This dataset does not require the download of any instance since it is pre-computed locally, + // but interleaveTopToBottom.ts was not loading any series because it consider that all viewports + // are a Cornerstone viewport which is not true in this case and it waits for all viewports to + // have called interleaveTopToBottom(...). + skipLoading: true, + }, + }, + ], + }; +} + +function getCTViewports() { + return [ + { + viewportOptions: { + viewportId: 'ctAxial', + viewportType: 'volume', + orientation: 'axial', + toolGroupId: toolGroupIds.ct, + initialImageOptions: { + preset: 'middle', // '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: toolGroupIds.ct, + initialImageOptions: { + preset: 'middle', + }, + 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: toolGroupIds.ct, + initialImageOptions: { + preset: 'middle', + }, + syncGroups: [ + { + type: 'cameraPosition', + id: 'coronalSync', + source: true, + target: true, + }, + { + type: 'voi', + id: 'ctWLSync', + source: true, + target: true, + }, + ], + }, + displaySets: [ + { + id: 'ctDisplaySet', + }, + ], + }, + ]; +} + +const defaultProtocol = { + id: 'default4D', + 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: '2023-01-01T00:00:00.000Z', + modifiedDate: '2023-01-01T00:00:00.000Z', + availableTo: {}, + editableBy: {}, + imageLoadStrategy: 'default', // "default" , "interleaveTopToBottom", "interleaveCenter" + protocolMatchingRules: [ + { + attribute: 'ModalitiesInStudy', + constraint: { + contains: ['CT', 'PT'], + }, + }, + ], + // -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: -1, + displaySetSelectors: { + defaultDisplaySetId: { + // Unused currently + imageMatchingRules: [], + // Matches displaysets, NOT series + seriesMatchingRules: [ + // Try to match series with images by default, to prevent weird display + // on SEG/SR containing studies + { + attribute: 'numImageFrames', + constraint: { + greaterThan: { value: 0 }, + }, + }, + ], + // Can be used to select matching studies + // studyMatchingRules: [], + }, + ctDisplaySet: { + // Unused currently + imageMatchingRules: [], + // Matches displaysets, NOT series + seriesMatchingRules: [ + { + attribute: 'Modality', + constraint: { + equals: { + value: 'CT', + }, + }, + required: true, + }, + { + attribute: 'isReconstructable', + constraint: { + equals: { + value: true, + }, + }, + required: true, + }, + ], + // Can be used to select matching studies + // studyMatchingRules: [], + }, + ptDisplaySet: { + // Unused currently + imageMatchingRules: [], + // Matches displaysets, NOT series + seriesMatchingRules: [ + { + attribute: 'Modality', + constraint: { + equals: 'PT', + }, + required: true, + }, + { + attribute: 'isReconstructable', + constraint: { + equals: { + value: true, + }, + }, + required: true, + }, + { + attribute: 'SeriesDescription', + constraint: { + contains: 'Corrected', + }, + }, + { + weight: 2, + attribute: 'SeriesDescription', + constraint: { + doesNotContain: { + value: 'Uncorrected', + }, + }, + }, + + // Should we check if CorrectedImage contains ATTN? + // (0028,0051) (CorrectedImage): NORM\DTIM\ATTN\SCAT\RADL\DECY + ], + // Can be used to select matching studies + // studyMatchingRules: [], + }, + chartDisplaySet: { + // Unused currently + imageMatchingRules: [], + // Matches displaysets, NOT series + seriesMatchingRules: [ + { + attribute: 'Modality', + constraint: { + equals: { + value: 'CHT', + }, + }, + required: true, + }, + ], + }, + }, + stages: [ + { + id: 'dataPreparation', + name: 'Data Preparation', + viewportStructure: { + layoutType: 'grid', + properties: { + rows: 1, + columns: 3, + }, + }, + viewports: [...getPTViewports()], + createdDate: '2023-01-01T00:00:00.000Z', + }, + + { + id: 'registration', + name: 'Registration', + viewportStructure: { + layoutType: 'grid', + properties: { + rows: 3, + columns: 3, + }, + }, + viewports: [...getFusionViewports(), ...getCTViewports(), ...getPTViewports()], + createdDate: '2023-01-01T00:00:00.000Z', + }, + + { + id: 'roiQuantification', + name: 'ROI Quantification', + viewportStructure: { + layoutType: 'grid', + properties: { + rows: 1, + columns: 3, + }, + }, + viewports: [...getFusionViewports()], + createdDate: '2023-01-01T00:00:00.000Z', + }, + + { + id: 'kineticAnalysis', + name: 'Kinetic Analysis', + viewportStructure: { + layoutType: 'grid', + properties: { + rows: 2, + columns: 3, + layoutOptions: [ + { + x: 0, + y: 0, + width: 1 / 3, + height: 1 / 2, + }, + { + x: 1 / 3, + y: 0, + width: 1 / 3, + height: 1 / 2, + }, + { + x: 2 / 3, + y: 0, + width: 1 / 3, + height: 1 / 2, + }, + { + x: 0, + y: 1 / 2, + width: 1, + height: 1 / 2, + }, + ], + }, + }, + viewports: [...getFusionViewports(), getSeriesChartViewport()], + createdDate: '2023-01-01T00:00:00.000Z', + }, + ], +}; + +/** + * HangingProtocolModule should provide a list of hanging protocols that will be + * available in OHIF for Modes to use to decide on the structure of the viewports + * and also the series that hung in the viewports. Each hanging protocol is defined by + * { name, protocols}. Examples include the default hanging protocol provided by + * the default extension that shows 2x2 viewports. + */ + +function getHangingProtocolModule() { + return [ + { + name: defaultProtocol.id, + protocol: defaultProtocol, + }, + ]; +} + +export default getHangingProtocolModule; diff --git a/extensions/cornerstone-dynamic-volume/src/getPanelModule.tsx b/extensions/cornerstone-dynamic-volume/src/getPanelModule.tsx new file mode 100644 index 000000000..a32b5243d --- /dev/null +++ b/extensions/cornerstone-dynamic-volume/src/getPanelModule.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { DynamicDataPanel } from './panels'; +import { Toolbox } from '@ohif/ui'; +import DynamicExport from './panels/DynamicExport'; + +function getPanelModule({ commandsManager, extensionManager, servicesManager }) { + const wrappedDynamicDataPanel = () => { + return ( + + ); + }; + + const wrappedDynamicToolbox = () => { + return ( + <> + + + ); + }; + + const wrappedDynamicExport = () => { + return ( + <> + + + ); + }; + + return [ + { + name: 'dynamic-volume', + iconName: 'group-layers', + iconLabel: '4D Workflow', + label: '4D Workflow', + component: wrappedDynamicDataPanel, + }, + { + name: 'dynamic-toolbox', + iconName: 'group-layers', + iconLabel: '4D Workflow', + label: 'Dynamic Toolbox', + component: wrappedDynamicToolbox, + }, + { + name: 'dynamic-export', + iconName: 'group-layers', + iconLabel: '4D Workflow', + label: '4D Workflow', + component: wrappedDynamicExport, + }, + ]; +} + +export default getPanelModule; diff --git a/extensions/cornerstone-dynamic-volume/src/id.js b/extensions/cornerstone-dynamic-volume/src/id.js new file mode 100644 index 000000000..b2dfe1809 --- /dev/null +++ b/extensions/cornerstone-dynamic-volume/src/id.js @@ -0,0 +1,6 @@ +import packageJson from '../package.json'; + +const id = packageJson.name; +const SOPClassHandlerName = 'dynamic-volume'; + +export { id, SOPClassHandlerName }; diff --git a/extensions/cornerstone-dynamic-volume/src/index.ts b/extensions/cornerstone-dynamic-volume/src/index.ts new file mode 100644 index 000000000..625899efe --- /dev/null +++ b/extensions/cornerstone-dynamic-volume/src/index.ts @@ -0,0 +1,57 @@ +import { id } from './id'; +import commandsModule from './commandsModule'; +import getPanelModule from './getPanelModule'; +import getHangingProtocolModule from './getHangingProtocolModule'; +import { cache } from '@cornerstonejs/core'; + +/** + * You can remove any of the following modules if you don't need them. + */ +const dynamicVolumeExtension = { + /** + * Only required property. Should be a unique value across all extensions. + * You ID can be anything you want, but it should be unique. + */ + id, + + /** + * Perform any pre-registration tasks here. This is called before the extension + * is registered. Usually we run tasks such as: configuring the libraries + * (e.g. cornerstone, cornerstoneTools, ...) or registering any services that + * this extension is providing. + */ + preRegistration: ({ servicesManager, commandsManager, configuration = {} }) => { + // TODO: look for the right fix + cache.setMaxCacheSize(5 * 1024 * 1024 * 1024); + }, + /** + * PanelModule should provide a list of panels that will be available in OHIF + * for Modes to consume and render. Each panel is defined by a {name, + * iconName, iconLabel, label, component} object. Example of a panel module + * is the StudyBrowserPanel that is provided by the default extension in OHIF. + */ + getPanelModule, + /** + * ViewportModule should provide a list of viewports that will be available in OHIF + * for Modes to consume and use in the viewports. Each viewport is defined by + * {name, component} object. Example of a viewport module is the CornerstoneViewport + * that is provided by the Cornerstone extension in OHIF. + */ + getHangingProtocolModule, + /** + * CommandsModule should provide a list of commands that will be available in OHIF + * for Modes to consume and use in the viewports. Each command is defined by + * an object of { actions, definitions, defaultContext } where actions is an + * object of functions, definitions is an object of available commands, their + * options, and defaultContext is the default context for the command to run against. + */ + getCommandsModule: ({ servicesManager, commandsManager, extensionManager }) => { + return commandsModule({ + servicesManager, + commandsManager, + extensionManager, + }); + }, +}; + +export { dynamicVolumeExtension as default }; diff --git a/extensions/cornerstone-dynamic-volume/src/panels/DynamicDataPanel.tsx b/extensions/cornerstone-dynamic-volume/src/panels/DynamicDataPanel.tsx new file mode 100644 index 000000000..6be93c1d3 --- /dev/null +++ b/extensions/cornerstone-dynamic-volume/src/panels/DynamicDataPanel.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import PanelGenerateImage from './PanelGenerateImage'; + +function DynamicDataPanel({ servicesManager, commandsManager }) { + return ( +
+ +
+ ); +} + +export default DynamicDataPanel; diff --git a/extensions/cornerstone-dynamic-volume/src/panels/DynamicExport.tsx b/extensions/cornerstone-dynamic-volume/src/panels/DynamicExport.tsx new file mode 100644 index 000000000..020278c4b --- /dev/null +++ b/extensions/cornerstone-dynamic-volume/src/panels/DynamicExport.tsx @@ -0,0 +1,76 @@ +import React, { useState, useEffect } from 'react'; +import { ActionButtons } from '@ohif/ui'; +import { useTranslation } from 'react-i18next'; + +function DynamicExport({ commandsManager, servicesManager, extensionManager }) { + const { segmentationService } = servicesManager.services; + const { t } = useTranslation('dynamicExport'); + + const [segmentations, setSegmentations] = useState(() => segmentationService.getSegmentations()); + + const actions = [ + { + label: 'Export Time Data', + onClick: () => { + commandsManager.runCommand('exportTimeReportCSV', { + segmentations, + options: { + filename: 'TimeData.csv', + }, + }); + }, + disabled: !segmentations?.length, + }, + { + label: 'Export ROI Stats', + onClick: () => { + commandsManager.runCommand('exportTimeReportCSV', { + segmentations, + summaryStats: true, + options: { + filename: 'ROIStats.csv', + }, + }); + }, + disabled: !segmentations?.length, + }, + ]; + + /** + * Update UI based on segmentation changes (added, removed, updated) + */ + useEffect(() => { + // ~~ Subscription + const added = segmentationService.EVENTS.SEGMENTATION_ADDED; + const updated = segmentationService.EVENTS.SEGMENTATION_UPDATED; + const removed = segmentationService.EVENTS.SEGMENTATION_REMOVED; + const subscriptions = []; + + [added, updated, removed].forEach(evt => { + const { unsubscribe } = segmentationService.subscribe(evt, () => { + const segmentations = segmentationService.getSegmentations(); + setSegmentations(segmentations); + }); + subscriptions.push(unsubscribe); + }); + + return () => { + subscriptions.forEach(unsub => { + unsub(); + }); + }; + }, []); + + return ( +
+
+ +
+
+ ); +} + +export default DynamicExport; diff --git a/extensions/cornerstone-dynamic-volume/src/panels/DynamicVolumeControls.tsx b/extensions/cornerstone-dynamic-volume/src/panels/DynamicVolumeControls.tsx new file mode 100644 index 000000000..337e9e0a3 --- /dev/null +++ b/extensions/cornerstone-dynamic-volume/src/panels/DynamicVolumeControls.tsx @@ -0,0 +1,220 @@ +import React, { useEffect, useState } from 'react'; +import { + InputDoubleRange, + Button, + PanelSection, + ButtonGroup, + IconButton, + InputNumber, + Icon, + Tooltip, +} from '@ohif/ui'; + +import { Enums } from '@cornerstonejs/core'; + +const controlClassNames = { + sizeClassName: 'w-[58px] h-[28px]', + arrowsDirection: 'horizontal', + labelPosition: 'bottom', +}; + +const Header = ({ title, tooltip }) => ( +
+ {tooltip}
} + position="bottom" + tight={true} + tooltipBoxClassName="max-w-xs" + > + + + {title} + +); + +const DynamicVolumeControls = ({ + isPlaying, + onPlayPauseChange, + // fps + fps, + onFpsChange, + minFps, + maxFps, + // Frames + currentFrameIndex, + onFrameChange, + framesLength, + onGenerate, + onDoubleRangeChange, + onDynamicClick, +}) => { + const [computedView, setComputedView] = useState(false); + + const [computeViewMode, setComputeViewMode] = useState(Enums.DynamicOperatorType.SUM); + + const [sliderRangeValues, setSliderRangeValues] = useState([framesLength / 4, framesLength / 2]); + + useEffect(() => { + setSliderRangeValues([framesLength / 4, framesLength / 2]); + }, [framesLength]); + + const handleSliderChange = newValues => { + onDoubleRangeChange(newValues); + + if (newValues[0] === sliderRangeValues[0] && newValues[1] === sliderRangeValues[1]) { + return; + } + setSliderRangeValues(newValues); + }; + + return ( +
+ +
+
+ + + + +
+
+ +
+
+
+ + + + + +
+ +
+ +
+
+
+ ); +}; + +export default DynamicVolumeControls; + +function FrameControls({ + isPlaying, + onPlayPauseChange, + fps, + minFps, + maxFps, + onFpsChange, + framesLength, + onFrameChange, + currentFrameIndex, + computedView, +}) { + const getPlayPauseIconName = () => (isPlaying ? 'icon-pause' : 'icon-play'); + + return ( +
+
+
+ onPlayPauseChange(!isPlaying)} + > + + + + +
+
+ ); +} diff --git a/extensions/cornerstone-dynamic-volume/src/panels/GenerateVolume.tsx b/extensions/cornerstone-dynamic-volume/src/panels/GenerateVolume.tsx new file mode 100644 index 000000000..516502a67 --- /dev/null +++ b/extensions/cornerstone-dynamic-volume/src/panels/GenerateVolume.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { InputDoubleRange } from '@ohif/ui'; +import { Select } from '@ohif/ui'; +import { Button } from '@ohif/ui'; +import PropTypes from 'prop-types'; + +const GenerateVolume = ({ + rangeValues, + handleSliderChange, + operationsUI, + options, + handleGenerateOptionsChange, + onGenerateImage, + returnTo4D, + displayingComputedVolume, +}) => { + return ( + <> +
+
Computed Image
+ { - 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 deleted file mode 100644 index 1c6bb2067..000000000 --- a/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/colorPickerDialog.css +++ /dev/null @@ -1,3 +0,0 @@ -.chrome-picker { - background: #090c29 !important; -} diff --git a/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/colorPickerDialog.tsx b/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/colorPickerDialog.tsx deleted file mode 100644 index 38e85efb2..000000000 --- a/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/colorPickerDialog.tsx +++ /dev/null @@ -1,58 +0,0 @@ -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/PanelROIThresholdSegmentation/index.ts b/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/index.ts index 22404ee98..01ce5d64e 100644 --- a/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/index.ts +++ b/extensions/tmtv/src/Panels/PanelROIThresholdSegmentation/index.ts @@ -1,3 +1,3 @@ -import PanelROIThresholdSegmentation from './PanelROIThresholdSegmentation'; +import PanelROIThresholdExport from './PanelROIThresholdExport'; -export default PanelROIThresholdSegmentation; +export default PanelROIThresholdExport; diff --git a/extensions/tmtv/src/Panels/index.tsx b/extensions/tmtv/src/Panels/index.tsx index 0fa6418fd..255c923f4 100644 --- a/extensions/tmtv/src/Panels/index.tsx +++ b/extensions/tmtv/src/Panels/index.tsx @@ -1,4 +1,4 @@ import PanelPetSUV from './PanelPetSUV'; -import PanelROIThresholdSegmentation from './PanelROIThresholdSegmentation'; +import PanelROIThresholdExport from './PanelROIThresholdSegmentation'; -export { PanelPetSUV, PanelROIThresholdSegmentation }; +export { PanelPetSUV, PanelROIThresholdExport }; diff --git a/extensions/tmtv/src/commandsModule.js b/extensions/tmtv/src/commandsModule.js index 765ab7f5b..0898a01a8 100644 --- a/extensions/tmtv/src/commandsModule.js +++ b/extensions/tmtv/src/commandsModule.js @@ -11,7 +11,10 @@ import createAndDownloadTMTVReport from './utils/createAndDownloadTMTVReport'; import dicomRTAnnotationExport from './utils/dicomRTAnnotationExport/RTStructureSet'; const metadataProvider = classes.MetadataProvider; -const RECTANGLE_ROI_THRESHOLD_MANUAL = 'RectangleROIStartEndThreshold'; +const RECTANGLE_ROI_THRESHOLD_MANUAL_TOOL_IDS = [ + 'RectangleROIStartEndThreshold', + 'RectangleROIThreshold', +]; const LABELMAP = csTools.Enums.SegmentationRepresentations.Labelmap; const commandsModule = ({ servicesManager, commandsManager, extensionManager }) => { @@ -52,6 +55,15 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }) return toolGroupIds; } + function _getAnnotationsSelectedByToolNames(toolNames) { + return toolNames.reduce((allAnnotationUIDs, toolName) => { + const annotationUIDs = + csTools.annotation.selection.getAnnotationsSelectedByToolName(toolName); + + return allAnnotationUIDs.concat(annotationUIDs); + }, []); + } + const actions = { getMatchingPTDisplaySet: ({ viewportMatchDetails }) => { // Todo: this is assuming that the hanging protocol has successfully matched @@ -108,7 +120,7 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }) return metadata; }, - createNewLabelmapFromPT: async () => { + createNewLabelmapFromPT: async ({ label }) => { // Create a segmentation of the same resolution as the source data // using volumeLoader.createAndCacheDerivedVolume. const { viewportMatchDetails } = hangingProtocolService.getMatchDetails(); @@ -173,20 +185,8 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }) const { volumeId: segVolumeId } = representationData[LABELMAP]; const { referencedVolumeId } = cs.cache.getVolume(segVolumeId); - const labelmapVolume = cs.cache.getVolume(segmentationId); - const referencedVolume = cs.cache.getVolume(referencedVolumeId); - const ctReferencedVolume = cs.cache.getVolume(ctVolumeId); - - if (!referencedVolume) { - throw new Error('No Reference volume found'); - } - - if (!labelmapVolume) { - throw new Error('No Reference labelmap found'); - } - - const annotationUIDs = csTools.annotation.selection.getAnnotationsSelectedByToolName( - RECTANGLE_ROI_THRESHOLD_MANUAL + const annotationUIDs = _getAnnotationsSelectedByToolNames( + RECTANGLE_ROI_THRESHOLD_MANUAL_TOOL_IDS ); if (annotationUIDs.length === 0) { @@ -198,6 +198,57 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }) return; } + const labelmapVolume = cs.cache.getVolume(segmentationId); + let referencedVolume = cs.cache.getVolume(referencedVolumeId); + const ctReferencedVolume = cs.cache.getVolume(ctVolumeId); + + // check if viewport is + + if (!referencedVolume) { + throw new Error('No Reference volume found'); + } + + if (!labelmapVolume) { + throw new Error('No Reference labelmap found'); + } + + const annotation = csTools.annotation.state.getAnnotation(annotationUIDs[0]); + + const { + metadata: { + enabledElement: { viewport }, + }, + } = annotation; + + const showingReferenceVolume = viewport.hasVolumeId(referencedVolumeId); + + if (!showingReferenceVolume) { + // if the reference volume is not being displayed, we can't + // rely on it for thresholding, we have couple of options here + // 1. We choose whatever volume is being displayed + // 2. We check if it is a fusion viewport, we pick the volume + // that matches the size and dimensions of the labelmap. This might + // happen if the 4D PT is converted to a computed volume and displayed + // and wants to threshold the labelmap + // 3. We throw an error + const displaySetInstanceUIDs = viewportGridService.getDisplaySetsUIDsForViewport( + viewport.id + ); + + displaySetInstanceUIDs.forEach(displaySetInstanceUID => { + const volume = cs.cache + .getVolumes() + .find(volume => volume.volumeId.includes(displaySetInstanceUID)); + + if ( + cs.utilities.isEqual(volume.dimensions, labelmapVolume.dimensions) && + cs.utilities.isEqual(volume.spacing, labelmapVolume.spacing) + ) { + referencedVolume = volume; + } + }); + } + const { ptLower, ptUpper, ctLower, ctUpper } = getThresholdValues( annotationUIDs, [referencedVolume, ctReferencedVolume], @@ -218,8 +269,8 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }) const { referencedVolumeId } = labelmap; const referencedVolume = cs.cache.getVolume(referencedVolumeId); - const annotationUIDs = csTools.annotation.selection.getAnnotationsSelectedByToolName( - RECTANGLE_ROI_THRESHOLD_MANUAL + const annotationUIDs = _getAnnotationsSelectedByToolNames( + RECTANGLE_ROI_THRESHOLD_MANUAL_TOOL_IDS ); const annotations = annotationUIDs.map(annotationUID => @@ -236,8 +287,7 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }) }, getLesionStats: ({ labelmap, segmentIndex = 1 }) => { const { scalarData, spacing } = labelmap; - - const { scalarData: referencedScalarData } = cs.cache.getVolume(labelmap.referencedVolumeId); + const referencedScalarData = cs.cache.getVolume(labelmap.referencedVolumeId).getScalarData(); let segmentationMax = -Infinity; let segmentationMin = Infinity; @@ -287,19 +337,25 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }) return calculateTMTV(labelmaps); }, - exportTMTVReportCSV: ({ segmentations, tmtv, config }) => { + exportTMTVReportCSV: ({ segmentations, tmtv, config, options }) => { const segReport = commandsManager.runCommand('getSegmentationCSVReport', { segmentations, }); const tlg = actions.getTotalLesionGlycolysis({ segmentations }); const additionalReportRows = [ - { key: 'Total Metabolic Tumor Volume', value: { tmtv } }, { key: 'Total Lesion Glycolysis', value: { tlg: tlg.toFixed(4) } }, { key: 'Threshold Configuration', value: { ...config } }, ]; - createAndDownloadTMTVReport(segReport, additionalReportRows); + if (tmtv !== undefined) { + additionalReportRows.unshift({ + key: 'Total Metabolic Tumor Volume', + value: { tmtv }, + }); + } + + createAndDownloadTMTVReport(segReport, additionalReportRows, options); }, getTotalLesionGlycolysis: ({ segmentations }) => { const labelmapVolumes = segmentations.map(s => segmentationService.getLabelmapVolume(s.id)); @@ -323,9 +379,9 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }) } const ptVolume = cs.cache.getVolume(referencedVolumeId); - const mergedLabelData = mergedLabelmap.scalarData; + const mergedLabelData = mergedLabelmap.getScalarData(); - if (mergedLabelData.length !== ptVolume.scalarData.length) { + if (mergedLabelData.length !== ptVolume.getScalarData().length) { console.error( 'commandsModule::getTotalLesionGlycolysis:Labelmap and ptVolume are not the same size' ); @@ -336,7 +392,7 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }) for (let i = 0; i < mergedLabelData.length; i++) { // if not background if (mergedLabelData[i] !== 0) { - suv += ptVolume.scalarData[i]; + suv += ptVolume.getScalarData()[i]; totalLesionVoxelCount += 1; } } @@ -351,8 +407,8 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }) const { viewport } = _getActiveViewportsEnabledElement(); const { focalPoint, viewPlaneNormal } = viewport.getCamera(); - const selectedAnnotationUIDs = csTools.annotation.selection.getAnnotationsSelectedByToolName( - RECTANGLE_ROI_THRESHOLD_MANUAL + const selectedAnnotationUIDs = _getAnnotationsSelectedByToolNames( + RECTANGLE_ROI_THRESHOLD_MANUAL_TOOL_IDS ); const annotationUID = selectedAnnotationUIDs[0]; @@ -389,8 +445,8 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }) setEndSliceForROIThresholdTool: () => { const { viewport } = _getActiveViewportsEnabledElement(); - const selectedAnnotationUIDs = csTools.annotation.selection.getAnnotationsSelectedByToolName( - RECTANGLE_ROI_THRESHOLD_MANUAL + const selectedAnnotationUIDs = _getAnnotationsSelectedByToolNames( + RECTANGLE_ROI_THRESHOLD_MANUAL_TOOL_IDS ); const annotationUID = selectedAnnotationUIDs[0]; @@ -415,7 +471,11 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }) Object.keys(stateManager.annotations).forEach(frameOfReferenceUID => { const forAnnotations = stateManager.annotations[frameOfReferenceUID]; - const ROIAnnotations = forAnnotations[RECTANGLE_ROI_THRESHOLD_MANUAL]; + const ROIAnnotations = RECTANGLE_ROI_THRESHOLD_MANUAL_TOOL_IDS.reduce( + (annotations, toolName) => [...annotations, ...(forAnnotations[toolName] ?? [])], + [] + ); + annotations.push(...ROIAnnotations); }); @@ -483,7 +543,7 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }) report[id] = { ...segReport, - PatientID: instance.PatientID, + PatientID: instance.PatientID ?? '000000', PatientName: instance.PatientName.Alphabetic, StudyInstanceUID: instance.StudyInstanceUID, SeriesInstanceUID: instance.SeriesInstanceUID, diff --git a/extensions/tmtv/src/getPanelModule.tsx b/extensions/tmtv/src/getPanelModule.tsx index 39a955df3..239233978 100644 --- a/extensions/tmtv/src/getPanelModule.tsx +++ b/extensions/tmtv/src/getPanelModule.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { PanelPetSUV, PanelROIThresholdSegmentation } from './Panels'; +import { PanelPetSUV, PanelROIThresholdExport } from './Panels'; import { Toolbox } from '@ohif/ui'; // TODO: @@ -17,20 +17,26 @@ function getPanelModule({ commandsManager, extensionManager, servicesManager }) ); }; - const wrappedROIThresholdSeg = () => { + const wrappedROIThresholdToolbox = () => { return ( <> - + ); + }; + + const wrappedROIThresholdExport = () => { + return ( + <> + ); @@ -45,11 +51,18 @@ function getPanelModule({ commandsManager, extensionManager, servicesManager }) component: wrappedPanelPetSuv, }, { - name: 'ROIThresholdSeg', + name: 'tmtvBox', iconName: 'tab-segmentation', iconLabel: 'Segmentation', - label: 'Segmentation', - component: wrappedROIThresholdSeg, + label: 'Segmentation Toolbox', + component: wrappedROIThresholdToolbox, + }, + { + name: 'tmtvExport', + iconName: 'tab-segmentation', + iconLabel: 'Segmentation', + label: 'Segmentation Export', + component: wrappedROIThresholdExport, }, ]; } diff --git a/extensions/tmtv/src/utils/calculateSUVPeak.ts b/extensions/tmtv/src/utils/calculateSUVPeak.ts index 302300f5b..6627d2e2b 100644 --- a/extensions/tmtv/src/utils/calculateSUVPeak.ts +++ b/extensions/tmtv/src/utils/calculateSUVPeak.ts @@ -40,17 +40,19 @@ function calculateSuvPeak( return; } - if (labelmap.scalarData.length !== referenceVolume.scalarData.length) { + const labelmapData = labelmap.getScalarData(); + const referenceVolumeData = referenceVolume.getScalarData(); + + if (labelmapData.length !== referenceVolumeData.length) { throw new Error('labelmap and referenceVolume must have the same number of pixels'); } - const { scalarData: labelmapData, dimensions, imageData: labelmapImageData } = labelmap; - - const { scalarData: referenceVolumeData, imageData: referenceVolumeImageData } = referenceVolume; + const { dimensions, imageData: labelmapImageData } = labelmap; + const { imageData: referenceVolumeImageData } = referenceVolume; let boundsIJK; // Todo: using the first annotation for now - if (annotations && annotations[0].data?.cachedStats) { + if (annotations?.length && annotations[0].data?.cachedStats) { const { projectionPoints } = annotations[0].data.cachedStats; const pointsToUse = [].concat(...projectionPoints); // cannot use flat() because of typescript compiler right now diff --git a/extensions/tmtv/src/utils/createAndDownloadTMTVReport.js b/extensions/tmtv/src/utils/createAndDownloadTMTVReport.js index b36787293..615d384fe 100644 --- a/extensions/tmtv/src/utils/createAndDownloadTMTVReport.js +++ b/extensions/tmtv/src/utils/createAndDownloadTMTVReport.js @@ -1,4 +1,4 @@ -export default function createAndDownloadTMTVReport(segReport, additionalReportRows) { +export default function createAndDownloadTMTVReport(segReport, additionalReportRows, options = {}) { const firstReport = segReport[Object.keys(segReport)[0]]; const columns = Object.keys(firstReport); const csv = [columns.join(',')]; @@ -40,6 +40,6 @@ export default function createAndDownloadTMTVReport(segReport, additionalReportR const a = document.createElement('a'); a.href = url; - a.download = `${firstReport.PatientID}_tmtv.csv`; + a.download = options.filename ?? `${firstReport.PatientID}_tmtv.csv`; a.click(); } diff --git a/modes/basic-test-mode/src/index.ts b/modes/basic-test-mode/src/index.ts index 155fc7013..52dc5ebc1 100644 --- a/modes/basic-test-mode/src/index.ts +++ b/modes/basic-test-mode/src/index.ts @@ -136,7 +136,7 @@ function modeFactory() { props: { leftPanels: [tracked.thumbnailList], rightPanels: [dicomSeg.panel, tracked.measurements], - // rightPanelDefaultClosed: true, // optional prop to start with collapse panels + // rightPanelClosed: true, // optional prop to start with collapse panels viewports: [ { namespace: tracked.viewport, diff --git a/modes/basic-test-mode/src/moreTools.ts b/modes/basic-test-mode/src/moreTools.ts index 1369bc629..5f49e08ad 100644 --- a/modes/basic-test-mode/src/moreTools.ts +++ b/modes/basic-test-mode/src/moreTools.ts @@ -11,6 +11,13 @@ const ReferenceLinesListeners: RunCommand = [ }, ]; +export const toggleEnabledDisabledToolbar = { + commandName: 'toggleEnabledDisabledToolbar', + commandOptions: { + toolGroupIds: ['default', 'mpr', 'SRToolGroup', 'volume3d'], + }, +}; + const moreTools = [ { id: 'MoreTools', @@ -80,13 +87,7 @@ const moreTools = [ icon: 'tool-referenceLines', label: 'Reference Lines', tooltip: 'Show Reference Lines', - commands: { - commandName: 'setToolEnabled', - commandOptions: { - toolName: 'ReferenceLines', - toggle: true, - }, - }, + commands: toggleEnabledDisabledToolbar, listeners: { [ViewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED]: ReferenceLinesListeners, [ViewportGridService.EVENTS.VIEWPORTS_READY]: ReferenceLinesListeners, @@ -94,17 +95,11 @@ const moreTools = [ evaluate: 'evaluate.cornerstoneTool.toggle', }), createButton({ - id: 'ImageOverlay', + id: 'ImageOverlayViewer', icon: 'toggle-dicom-overlay', label: 'Image Overlay', tooltip: 'Toggle Image Overlay', - commands: { - commandName: 'setToolEnabled', - commandOptions: { - toolName: 'ImageOverlayViewer', - toggle: true, - }, - }, + commands: toggleEnabledDisabledToolbar, evaluate: 'evaluate.cornerstoneTool.toggle', }), createButton({ diff --git a/modes/longitudinal/src/index.js b/modes/longitudinal/src/index.js index 6925384fb..bfa6792ca 100644 --- a/modes/longitudinal/src/index.js +++ b/modes/longitudinal/src/index.js @@ -108,8 +108,8 @@ function modeFactory({ modeConfiguration }) { customizationService.addModeCustomizations([ { - id: 'segmentation.disableEditing', - value: true, + id: 'segmentation.panel', + disableEditing: true, }, ]); @@ -183,7 +183,7 @@ function modeFactory({ modeConfiguration }) { props: { leftPanels: [tracked.thumbnailList], rightPanels: [dicomSeg.panel, tracked.measurements], - rightPanelDefaultClosed: true, + rightPanelClosed: true, viewports: [ { namespace: tracked.viewport, diff --git a/modes/longitudinal/src/initToolGroups.js b/modes/longitudinal/src/initToolGroups.js index d1dfe3324..f5bae8d86 100644 --- a/modes/longitudinal/src/initToolGroups.js +++ b/modes/longitudinal/src/initToolGroups.js @@ -64,7 +64,12 @@ function initDefaultToolGroup( { toolName: toolNames.Magnify }, { toolName: toolNames.SegmentationDisplay }, { toolName: toolNames.CalibrationLine }, - { toolName: toolNames.AdvancedMagnify }, + { + toolName: toolNames.AdvancedMagnify, + configuration: { + disableOnPassive: true, + }, + }, { toolName: toolNames.UltrasoundDirectional }, { toolName: toolNames.PlanarFreehandROI }, { toolName: toolNames.SplineROI }, diff --git a/modes/longitudinal/src/moreTools.ts b/modes/longitudinal/src/moreTools.ts index f9b1189c6..267114f73 100644 --- a/modes/longitudinal/src/moreTools.ts +++ b/modes/longitudinal/src/moreTools.ts @@ -11,6 +11,20 @@ const ReferenceLinesListeners: RunCommand = [ }, ]; +export const toggleEnabledDisabledToolbar = { + commandName: 'toggleEnabledDisabledToolbar', + commandOptions: { + toolGroupIds: ['default', 'mpr', 'SRToolGroup', 'volume3d'], + }, +}; + +export const toggleActiveDisabledToolbar = { + commandName: 'toggleActiveDisabledToolbar', + commandOptions: { + toolGroupIds: ['default', 'mpr', 'SRToolGroup'], + }, +}; + const moreTools = [ { id: 'MoreTools', @@ -80,13 +94,7 @@ const moreTools = [ icon: 'tool-referenceLines', label: 'Reference Lines', tooltip: 'Show Reference Lines', - commands: { - commandName: 'setToolEnabled', - commandOptions: { - toolName: 'ReferenceLines', - toggle: true, // Toggle the tool on/off upon click - }, - }, + commands: toggleEnabledDisabledToolbar, listeners: { [ViewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED]: ReferenceLinesListeners, [ViewportGridService.EVENTS.VIEWPORTS_READY]: ReferenceLinesListeners, @@ -94,17 +102,11 @@ const moreTools = [ evaluate: 'evaluate.cornerstoneTool.toggle', }), createButton({ - id: 'ImageOverlay', + id: 'ImageOverlayViewer', icon: 'toggle-dicom-overlay', label: 'Image Overlay', tooltip: 'Toggle Image Overlay', - commands: { - commandName: 'setToolEnabled', - commandOptions: { - toolName: 'ImageOverlayViewer', - toggle: true, // Toggle the tool on/off upon click - }, - }, + commands: toggleEnabledDisabledToolbar, evaluate: 'evaluate.cornerstoneTool.toggle', }), createButton({ @@ -175,8 +177,8 @@ const moreTools = [ icon: 'icon-tool-loupe', label: 'Loupe', tooltip: 'Loupe', - commands: setToolActiveToolbar, - evaluate: 'evaluate.cornerstoneTool', + commands: toggleActiveDisabledToolbar, + evaluate: 'evaluate.cornerstoneTool.toggle', }), createButton({ id: 'UltrasoundDirectionalTool', diff --git a/modes/longitudinal/src/toolbarButtons.ts b/modes/longitudinal/src/toolbarButtons.ts index 7abe57f2c..dc5700fe6 100644 --- a/modes/longitudinal/src/toolbarButtons.ts +++ b/modes/longitudinal/src/toolbarButtons.ts @@ -1,10 +1,8 @@ // TODO: torn, can either bake this here; or have to create a whole new button type // Only ways that you can pass in a custom React component for render :l -import { WindowLevelMenuItem } from '@ohif/ui'; import { defaults, ToolbarService } from '@ohif/core'; import type { Button } from '@ohif/core/types'; -const { windowLevelPresets } = defaults; const { createButton } = ToolbarService; export const setToolActiveToolbar = { diff --git a/modes/microscopy/src/index.tsx b/modes/microscopy/src/index.tsx index e7d30d396..5c0bdba80 100644 --- a/modes/microscopy/src/index.tsx +++ b/modes/microscopy/src/index.tsx @@ -87,8 +87,8 @@ function modeFactory({ modeConfiguration }) { id: ohif.layout, props: { leftPanels: [ohif.leftPanel], - leftPanelDefaultClosed: true, // we have problem with rendering thumbnails for microscopy images - rightPanelDefaultClosed: true, // we do not have the save microscopy measurements yet + leftPanelClosed: true, // we have problem with rendering thumbnails for microscopy images + rightPanelClosed: true, // we do not have the save microscopy measurements yet rightPanels: ['@ohif/extension-dicom-microscopy.panelModule.measure'], viewports: [ { diff --git a/modes/preclinical-4d/.webpack/webpack.dev.js b/modes/preclinical-4d/.webpack/webpack.dev.js new file mode 100644 index 000000000..6aea859ca --- /dev/null +++ b/modes/preclinical-4d/.webpack/webpack.dev.js @@ -0,0 +1,12 @@ +const path = require('path'); +const webpackCommon = require('./../../../.webpack/webpack.base.js'); +const SRC_DIR = path.join(__dirname, '../src'); +const DIST_DIR = path.join(__dirname, '../dist'); + +const ENTRY = { + app: `${SRC_DIR}/index.tsx`, +}; + +module.exports = (env, argv) => { + return webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY }); +}; diff --git a/modes/preclinical-4d/.webpack/webpack.prod.js b/modes/preclinical-4d/.webpack/webpack.prod.js new file mode 100644 index 000000000..f8b0a79f8 --- /dev/null +++ b/modes/preclinical-4d/.webpack/webpack.prod.js @@ -0,0 +1,53 @@ +const webpack = require('webpack'); +const { merge } = require('webpack-merge'); +const path = require('path'); +const MiniCssExtractPlugin = require('mini-css-extract-plugin'); + +const pkg = require('./../package.json'); +const webpackCommon = require('./../../../.webpack/webpack.base.js'); + +const ROOT_DIR = path.join(__dirname, './../'); +const SRC_DIR = path.join(__dirname, '../src'); +const DIST_DIR = path.join(__dirname, '../dist'); +const ENTRY = { + app: `${SRC_DIR}/index.tsx`, +}; + +module.exports = (env, argv) => { + const commonConfig = webpackCommon(env, argv, { SRC_DIR, DIST_DIR, ENTRY }); + + return merge(commonConfig, { + stats: { + colors: true, + hash: true, + timings: true, + assets: true, + chunks: false, + chunkModules: false, + modules: false, + children: false, + warnings: true, + }, + optimization: { + minimize: true, + sideEffects: false, + }, + output: { + path: ROOT_DIR, + library: 'ohif-mode-preclinical-4d', + libraryTarget: 'umd', + libraryExport: 'default', + filename: pkg.main, + }, + externals: [/\b(vtk.js)/, /\b(dcmjs)/, /\b(gl-matrix)/, /^@ohif/, /^@cornerstonejs/], + plugins: [ + new webpack.optimize.LimitChunkCountPlugin({ + maxChunks: 1, + }), + // new MiniCssExtractPlugin({ + // filename: './dist/[name].css', + // chunkFilename: './dist/[id].css', + // }), + ], + }); +}; diff --git a/modes/preclinical-4d/LICENSE b/modes/preclinical-4d/LICENSE new file mode 100644 index 000000000..5f35ab7d8 --- /dev/null +++ b/modes/preclinical-4d/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2023 4d () + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/modes/preclinical-4d/README.md b/modes/preclinical-4d/README.md new file mode 100644 index 000000000..0f6870d2f --- /dev/null +++ b/modes/preclinical-4d/README.md @@ -0,0 +1,7 @@ +# 4d +## Description + +## Author +OHIF +## License +MIT \ No newline at end of file diff --git a/modes/preclinical-4d/babel.config.js b/modes/preclinical-4d/babel.config.js new file mode 100644 index 000000000..a38ddda21 --- /dev/null +++ b/modes/preclinical-4d/babel.config.js @@ -0,0 +1,44 @@ +module.exports = { + plugins: ['inline-react-svg', '@babel/plugin-proposal-class-properties'], + env: { + test: { + presets: [ + [ + // TODO: https://babeljs.io/blog/2019/03/19/7.4.0#migration-from-core-js-2 + '@babel/preset-env', + { + modules: 'commonjs', + debug: false, + }, + '@babel/preset-typescript', + ], + '@babel/preset-react', + ], + plugins: [ + '@babel/plugin-proposal-object-rest-spread', + '@babel/plugin-syntax-dynamic-import', + '@babel/plugin-transform-regenerator', + '@babel/plugin-transform-runtime', + ], + }, + production: { + presets: [ + // WebPack handles ES6 --> Target Syntax + ['@babel/preset-env', { modules: false }], + '@babel/preset-react', + '@babel/preset-typescript', + ], + ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'], + }, + development: { + presets: [ + // WebPack handles ES6 --> Target Syntax + ['@babel/preset-env', { modules: false }], + '@babel/preset-react', + '@babel/preset-typescript', + ], + plugins: ['react-hot-loader/babel'], + ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'], + }, + }, +}; diff --git a/modes/preclinical-4d/package.json b/modes/preclinical-4d/package.json new file mode 100644 index 000000000..37f66ca9e --- /dev/null +++ b/modes/preclinical-4d/package.json @@ -0,0 +1,47 @@ +{ + "name": "@ohif/mode-preclinical-4d", + "version": "3.7.0-beta.76", + "description": "4D Workflow", + "author": "OHIF", + "license": "MIT", + "repository": "OHIF/Viewers", + "main": "dist/index.umd.js", + "module": "src/index.tsx", + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1.16.0" + }, + "files": [ + "dist/**", + "public/**", + "README.md" + ], + "keywords": [ + "ohif-mode" + ], + "scripts": { + "dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --debug --output-pathinfo", + "dev:cornerstone": "yarn run dev", + "build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js", + "build:package": "yarn run build", + "start": "yarn run dev", + "test:unit": "jest --watchAll", + "test:unit:ci": "jest --ci --runInBand --collectCoverage --passWithNoTests" + }, + "peerDependencies": { + "@ohif/core": "3.7.0-beta.76", + "@ohif/extension-default": "3.7.0-beta.76", + "@ohif/extension-cornerstone": "3.7.0-beta.76", + "@ohif/extension-cornerstone-dynamic-volume": "3.7.0-beta.76", + "@ohif/extension-cornerstone-dicom-seg": "3.7.0-beta.76", + "@ohif/extension-tmtv": "3.7.0-beta.76" + }, + "dependencies": { + "@babel/runtime": "^7.20.13" + }, + "devDependencies": { + "webpack": "^5.50.0", + "webpack-merge": "^5.7.3" + } +} diff --git a/modes/preclinical-4d/src/getWorkflowSettings.ts b/modes/preclinical-4d/src/getWorkflowSettings.ts new file mode 100644 index 000000000..efaab9b1f --- /dev/null +++ b/modes/preclinical-4d/src/getWorkflowSettings.ts @@ -0,0 +1,104 @@ +const dynamicVolume = { + sopClassHandler: + '@ohif/extension-cornerstone-dynamic-volume.sopClassHandlerModule.dynamic-volume', + leftPanel: '@ohif/extension-cornerstone-dynamic-volume.panelModule.dynamic-volume', + toolBox: '@ohif/extension-cornerstone-dynamic-volume.panelModule.dynamic-toolbox', + export: '@ohif/extension-cornerstone-dynamic-volume.panelModule.dynamic-export', +}; + +const cornerstone = { + segmentation: '@ohif/extension-cornerstone-dicom-seg.panelModule.panelSegmentation', + activeViewportWindowLevel: '@ohif/extension-cornerstone.panelModule.activeViewportWindowLevel', +}; + +const defaultButtons = { + buttonSection: 'primary', + buttons: ['MeasurementTools', 'Zoom', 'WindowLevel', 'Crosshairs', 'Pan'], +}; + +const defaultLeftPanel = [[dynamicVolume.leftPanel, cornerstone.activeViewportWindowLevel]]; + +const defaultLayout = { + panels: { + left: defaultLeftPanel, + right: [], + }, +}; + +function getWorkflowSettings({ servicesManager }) { + return { + steps: [ + { + id: 'dataPreparation', + name: 'Data Preparation', + layout: { + panels: { + left: defaultLeftPanel, + }, + }, + toolbarButtons: defaultButtons, + hangingProtocol: { + protocolId: 'default4D', + stageId: 'dataPreparation', + }, + info: 'In the Data Preparation step, you can visualize the dynamic PT volume data in three orthogonal views: axial, sagittal, and coronal. Use the left panel controls to adjust the visualization settings, such as playback speed, or navigate between different frames. This step allows you to assess the quality of the PT data and prepare for further analysis or registration with other modalities.', + }, + { + id: 'registration', + name: 'Registration', + layout: defaultLayout, + toolbarButtons: defaultButtons, + hangingProtocol: { + protocolId: 'default4D', + stageId: 'registration', + }, + info: 'The Registration step provides a comprehensive view of the CT, PT, and fused CT-PT volume data in multiple orientations. The fusion viewports display the CT and PT volumes overlaid, allowing you to visually assess the alignment and registration between the two modalities. The individual CT and PT viewports are also available for side-by-side comparison. This step is crucial for ensuring proper registration before proceeding with further analysis or quantification.', + }, + { + id: 'roiQuantification', + name: 'ROI Quantification', + layout: { + panels: { + left: defaultLeftPanel, + right: [[dynamicVolume.toolBox, cornerstone.segmentation, dynamicVolume.export]], + }, + options: { + leftPanelClosed: false, + rightPanelClosed: false, + }, + }, + toolbarButtons: [ + defaultButtons, + { + buttonSection: 'dynamic-toolbox', + buttons: ['BrushTools', 'RectangleROIStartEndThreshold'], + }, + ], + hangingProtocol: { + protocolId: 'default4D', + stageId: 'roiQuantification', + }, + info: 'The ROI quantification step allows you to define regions of interest (ROIs) with labelmap segmentations, on the fused CT-PT volume data using the labelmap tools. The left panel provides controls for adjusting the dynamic volume visualization, while the right panel offers tools for segmentation, editing, and exporting the ROI data. This step enables you to quantify the uptake or other measures within the defined ROIs for further analysis.', + }, + { + id: 'kineticAnalysis', + name: 'Kinetic Analysis', + layout: defaultLayout, + toolbarButtons: defaultButtons, + hangingProtocol: { + protocolId: 'default4D', + stageId: 'kineticAnalysis', + }, + onEnter: [ + { + commandName: 'updateSegmentationsChartDisplaySet', + options: { servicesManager }, + }, + ], + info: 'The Kinetic Analysis step provides a comprehensive view for visualizing and analyzing the dynamic data derived from the ROI segmentations. The fusion viewports display the combined CT-PT volume data, while a dedicated viewport shows a series chart representing the data over time. This step allows you to explore the temporal dynamics of the uptake or other kinetic measures within the defined regions of interest, enabling further quantitative analysis and modeling.', + }, + ], + }; +} + +export { getWorkflowSettings as default }; diff --git a/modes/preclinical-4d/src/id.js b/modes/preclinical-4d/src/id.js new file mode 100644 index 000000000..ebe5acd98 --- /dev/null +++ b/modes/preclinical-4d/src/id.js @@ -0,0 +1,5 @@ +import packageJson from '../package.json'; + +const id = packageJson.name; + +export { id }; diff --git a/modes/preclinical-4d/src/index.tsx b/modes/preclinical-4d/src/index.tsx new file mode 100644 index 000000000..c74fb985c --- /dev/null +++ b/modes/preclinical-4d/src/index.tsx @@ -0,0 +1,176 @@ +import { id } from './id'; +import { hotkeys } from '@ohif/core'; +import initWorkflowSteps from './initWorkflowSteps'; +import initToolGroups from './initToolGroups'; +import toolbarButtons from './toolbarButtons'; +import segmentationButtons from './segmentationButtons'; + +const extensionDependencies = { + '@ohif/extension-default': '3.7.0-beta.76', + '@ohif/extension-cornerstone': '3.7.0-beta.76', + '@ohif/extension-cornerstone-dynamic-volume': '3.7.0-beta.76', + '@ohif/extension-cornerstone-dicom-seg': '3.7.0-beta.76', + '@ohif/extension-tmtv': '3.7.0-beta.76', +}; + +const ohif = { + layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout', + defaultSopClassHandler: '@ohif/extension-default.sopClassHandlerModule.stack', + chartSopClassHandler: '@ohif/extension-default.sopClassHandlerModule.chart', + hangingProtocol: '@ohif/extension-default.hangingProtocolModule.default', + leftPanel: '@ohif/extension-default.panelModule.seriesList', + rightPanel: '@ohif/extension-default.panelModule.measure', + chartViewport: '@ohif/extension-default.viewportModule.chartViewport', +}; + +const dynamicVolume = { + leftPanel: '@ohif/extension-cornerstone-dynamic-volume.panelModule.dynamic-volume', +}; + +const cornerstone = { + viewport: '@ohif/extension-cornerstone.viewportModule.cornerstone', + activeViewportWindowLevel: '@ohif/extension-cornerstone.panelModule.activeViewportWindowLevel', +}; + +function modeFactory({ modeConfiguration }) { + return { + id, + routeName: 'dynamic-volume', + displayName: '4D PT/CT', + onModeEnter: function ({ servicesManager, extensionManager, commandsManager }) { + const { + measurementService, + toolbarService, + cineService, + cornerstoneViewportService, + toolGroupService, + customizationService, + viewportGridService, + } = servicesManager.services; + + const utilityModule = extensionManager.getModuleEntry( + '@ohif/extension-cornerstone.utilityModule.tools' + ); + + const { toolNames, Enums } = utilityModule.exports; + + measurementService.clearMeasurements(); + initToolGroups({ toolNames, Enums, toolGroupService, commandsManager }); + + toolbarService.addButtons([...toolbarButtons, ...segmentationButtons]); + toolbarService.createButtonSection('secondary', ['ProgressDropdown']); + + // the primary button section is created in the workflow steps + // specific to the step + customizationService.addModeCustomizations([ + { + id: 'segmentation.panel', + segmentationPanelMode: 'expanded', + addSegment: false, + onSegmentationAdd: () => { + commandsManager.run('createNewLabelmapFromPT'); + }, + }, + ]); + + // Auto play the clip initially when the volumes are loaded + const { unsubscribe } = cornerstoneViewportService.subscribe( + cornerstoneViewportService.EVENTS.VIEWPORT_VOLUMES_CHANGED, + () => { + const viewportId = viewportGridService.getActiveViewportId(); + const csViewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + cineService.playClip(csViewport.element); + // cineService.setIsCineEnabled(true); + + unsubscribe(); + } + ); + }, + onSetupRouteComplete: ({ servicesManager }) => { + // This needs to run after hanging protocol matching process because + // it may change the protocol/stage based on workflow stage settings + initWorkflowSteps({ servicesManager }); + }, + onModeExit: ({ servicesManager }) => { + const { + toolGroupService, + syncGroupService, + segmentationService, + cornerstoneViewportService, + } = servicesManager.services; + + toolGroupService.destroy(); + syncGroupService.destroy(); + segmentationService.destroy(); + cornerstoneViewportService.destroy(); + }, + get validationTags() { + return { + study: [], + series: [], + }; + }, + isValidMode: ({ modalities, study }) => { + // Todo: we need to find a better way to validate the mode + return { + valid: study.mrn === 'M1', + description: 'This mode is only available for 4D PET/CT studies.', + }; + }, + + /** + * Mode Routes are used to define the mode's behavior. A list of Mode Route + * that includes the mode's path and the layout to be used. The layout will + * include the components that are used in the layout. For instance, if the + * default layoutTemplate is used (id: '@ohif/extension-default.layoutTemplateModule.viewerLayout') + * it will include the leftPanels, rightPanels, and viewports. However, if + * you define another layoutTemplate that includes a Footer for instance, + * you should provide the Footer component here too. Note: We use Strings + * to reference the component's ID as they are registered in the internal + * ExtensionManager. The template for the string is: + * `${extensionId}.{moduleType}.${componentId}`. + */ + routes: [ + { + path: 'preclinical-4d', + layoutTemplate: ({ location, servicesManager }) => { + return { + id: ohif.layout, + props: { + leftPanels: [[dynamicVolume.leftPanel, cornerstone.activeViewportWindowLevel]], + rightPanels: [], + rightPanelClosed: true, + viewports: [ + { + namespace: cornerstone.viewport, + displaySetsToDisplay: [ohif.defaultSopClassHandler], + }, + { + namespace: ohif.chartViewport, + displaySetsToDisplay: [ohif.chartSopClassHandler], + }, + ], + }, + }; + }, + }, + ], + extensions: extensionDependencies, + // Default protocol gets self-registered by default in the init + hangingProtocol: 'default4D', + // Order is important in sop class handlers when two handlers both use + // the same sop class under different situations. In that case, the more + // general handler needs to come last. For this case, the dicomvideo must + // come first to remove video transfer syntax before ohif uses images + sopClassHandlers: [ohif.chartSopClassHandler, ohif.defaultSopClassHandler], + hotkeys: [...hotkeys.defaults.hotkeyBindings], + }; +} + +const mode = { + id, + modeFactory, + extensionDependencies, +}; + +export default mode; diff --git a/modes/preclinical-4d/src/initToolGroups.tsx b/modes/preclinical-4d/src/initToolGroups.tsx new file mode 100644 index 000000000..9a6457618 --- /dev/null +++ b/modes/preclinical-4d/src/initToolGroups.tsx @@ -0,0 +1,130 @@ +const toolGroupIds = { + default: 'dynamic4D-default', + PT: 'dynamic4D-pt', + Fusion: 'dynamic4D-fusion', + CT: 'dynamic4D-ct', +}; + +function _initToolGroups(toolNames, Enums, toolGroupService, commandsManager) { + const tools = { + active: [ + { + toolName: toolNames.WindowLevel, + bindings: [{ mouseButton: Enums.MouseBindings.Primary }], + }, + { + toolName: toolNames.Pan, + bindings: [{ mouseButton: Enums.MouseBindings.Auxiliary }], + }, + { + toolName: toolNames.Zoom, + bindings: [{ mouseButton: Enums.MouseBindings.Secondary }], + }, + { toolName: toolNames.StackScrollMouseWheel, bindings: [] }, + ], + passive: [ + { toolName: toolNames.Length }, + { toolName: toolNames.ArrowAnnotate }, + { toolName: toolNames.Bidirectional }, + { toolName: toolNames.Probe }, + { toolName: toolNames.EllipticalROI }, + { toolName: toolNames.RectangleROI }, + { toolName: toolNames.RectangleROIThreshold }, + { toolName: toolNames.RectangleScissors }, + { toolName: toolNames.PaintFill }, + { toolName: toolNames.StackScroll }, + { toolName: toolNames.Magnify }, + { + toolName: 'CircularBrush', + parentTool: 'Brush', + configuration: { + activeStrategy: 'FILL_INSIDE_CIRCLE', + brushSize: 7, + }, + }, + { + toolName: 'CircularEraser', + parentTool: 'Brush', + configuration: { + activeStrategy: 'ERASE_INSIDE_CIRCLE', + brushSize: 7, + }, + }, + { + toolName: 'SphereBrush', + parentTool: 'Brush', + configuration: { + activeStrategy: 'FILL_INSIDE_SPHERE', + brushSize: 7, + }, + }, + { + toolName: 'SphereEraser', + parentTool: 'Brush', + configuration: { + activeStrategy: 'ERASE_INSIDE_SPHERE', + brushSize: 7, + }, + }, + { + toolName: 'ThresholdCircularBrush', + parentTool: 'Brush', + configuration: { + activeStrategy: 'THRESHOLD_INSIDE_CIRCLE', + brushSize: 7, + }, + }, + { + toolName: 'ThresholdSphereBrush', + parentTool: 'Brush', + configuration: { + activeStrategy: 'THRESHOLD_INSIDE_SPHERE', + brushSize: 7, + }, + }, + { toolName: toolNames.CircleScissors }, + { toolName: toolNames.RectangleScissors }, + { toolName: toolNames.SphereScissors }, + { toolName: toolNames.StackScroll }, + { toolName: toolNames.Magnify }, + { toolName: toolNames.SegmentationDisplay }, + ], + enabled: [{ toolName: toolNames.SegmentationDisplay }], + disabled: [ + { + toolName: toolNames.Crosshairs, + configuration: { + viewportIndicators: false, + disableOnPassive: true, + autoPan: { + enabled: false, + panSize: 10, + }, + }, + }, + ], + }; + + toolGroupService.createToolGroupAndAddTools(toolGroupIds.PT, { + ...tools, + passive: [...tools.passive, { toolName: 'RectangleROIStartEndThreshold' }], + }); + + toolGroupService.createToolGroupAndAddTools(toolGroupIds.CT, { + ...tools, + passive: [...tools.passive, { toolName: 'RectangleROIStartEndThreshold' }], + }); + + toolGroupService.createToolGroupAndAddTools(toolGroupIds.Fusion, { + ...tools, + passive: [...tools.passive, { toolName: 'RectangleROIStartEndThreshold' }], + }); + + toolGroupService.createToolGroupAndAddTools(toolGroupIds.default, tools); +} + +function initToolGroups({ toolNames, Enums, toolGroupService, commandsManager }) { + _initToolGroups(toolNames, Enums, toolGroupService, commandsManager); +} + +export { initToolGroups as default, toolGroupIds }; diff --git a/modes/preclinical-4d/src/initWorkflowSteps.ts b/modes/preclinical-4d/src/initWorkflowSteps.ts new file mode 100644 index 000000000..8b36fe48d --- /dev/null +++ b/modes/preclinical-4d/src/initWorkflowSteps.ts @@ -0,0 +1,9 @@ +import getWorkflowSettings from './getWorkflowSettings'; + +export default function initWorkflowSteps({ servicesManager }): void { + const { workflowStepsService } = servicesManager.services; + const workflowSettings = getWorkflowSettings({ servicesManager }); + + workflowStepsService.addWorkflowSteps(workflowSettings.steps); + workflowStepsService.setActiveWorkflowStep(workflowSettings.steps[0].id); +} diff --git a/modes/preclinical-4d/src/segmentationButtons.ts b/modes/preclinical-4d/src/segmentationButtons.ts new file mode 100644 index 000000000..b4efbe04d --- /dev/null +++ b/modes/preclinical-4d/src/segmentationButtons.ts @@ -0,0 +1,180 @@ +import type { Button } from '@ohif/core/types'; + +function _createSetToolActiveCommands(toolName) { + return [ + { + commandName: 'setToolActive', + commandOptions: { + toolName, + }, + }, + ]; +} + +const toolbarButtons: Button[] = [ + { + id: 'BrushTools', + uiType: 'ohif.buttonGroup', + props: { + groupId: 'BrushTools', + items: [ + { + id: 'Brush', + icon: 'icon-tool-brush', + label: 'Brush', + evaluate: { + name: 'evaluate.cornerstone.segmentation', + options: { toolNames: ['CircularBrush', 'SphereBrush'] }, + }, + commands: _createSetToolActiveCommands('CircularBrush'), + options: [ + { + name: 'Radius (mm)', + id: 'brush-radius', + type: 'range', + min: 0.5, + max: 99.5, + step: 0.5, + value: 7, + commands: { + commandName: 'setBrushSize', + commandOptions: { toolNames: ['CircularBrush', 'SphereBrush'] }, + }, + }, + { + name: 'Shape', + type: 'radio', + id: 'brush-mode', + value: 'CircularBrush', + values: [ + { value: 'CircularBrush', label: 'Circle' }, + { value: 'SphereBrush', label: 'Sphere' }, + ], + commands: 'setToolActiveToolbar', + }, + ], + }, + { + id: 'Eraser', + icon: 'icon-tool-eraser', + label: 'Eraser', + evaluate: { + name: 'evaluate.cornerstone.segmentation', + options: { + toolNames: ['CircularEraser', 'SphereEraser'], + }, + }, + commands: _createSetToolActiveCommands('CircularEraser'), + options: [ + { + name: 'Radius (mm)', + id: 'eraser-radius', + type: 'range', + min: 0.5, + max: 99.5, + step: 0.5, + value: 7, + commands: { + commandName: 'setBrushSize', + commandOptions: { toolNames: ['CircularEraser', 'SphereEraser'] }, + }, + }, + { + name: 'Shape', + type: 'radio', + id: 'eraser-mode', + value: 'CircularEraser', + values: [ + { value: 'CircularEraser', label: 'Circle' }, + { value: 'SphereEraser', label: 'Sphere' }, + ], + commands: 'setToolActiveToolbar', + }, + ], + }, + { + id: 'Threshold', + icon: 'icon-tool-threshold', + label: 'Eraser', + evaluate: { + name: 'evaluate.cornerstone.segmentation', + options: { toolNames: ['ThresholdCircularBrush', 'ThresholdSphereBrush'] }, + }, + commands: _createSetToolActiveCommands('ThresholdCircularBrush'), + options: [ + { + name: 'Radius (mm)', + id: 'threshold-radius', + type: 'range', + min: 0.5, + max: 99.5, + step: 0.5, + value: 7, + commands: { + commandName: 'setBrushSize', + commandOptions: { + toolNames: ['ThresholdCircularBrush', 'ThresholdSphereBrush'], + }, + }, + }, + { + name: 'Shape', + type: 'radio', + id: 'eraser-mode', + value: 'ThresholdCircularBrush', + values: [ + { value: 'ThresholdCircularBrush', label: 'Circle' }, + { value: 'ThresholdSphereBrush', label: 'Sphere' }, + ], + commands: 'setToolActiveToolbar', + }, + { + name: 'ThresholdRange', + type: 'double-range', + id: 'threshold-range', + min: 0, + max: 10, + step: 1, + values: [2, 5], + commands: { + commandName: 'setThresholdRange', + commandOptions: { + toolNames: ['ThresholdCircularBrush', 'ThresholdSphereBrush'], + }, + }, + }, + ], + }, + ], + }, + }, + { + 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', + }, + ], + }, + }, +]; + +export default toolbarButtons; diff --git a/modes/preclinical-4d/src/toolbarButtons.tsx b/modes/preclinical-4d/src/toolbarButtons.tsx new file mode 100644 index 000000000..3c6fc47b5 --- /dev/null +++ b/modes/preclinical-4d/src/toolbarButtons.tsx @@ -0,0 +1,160 @@ +import { defaults, ToolbarService } from '@ohif/core'; +import { toolGroupIds } from './initToolGroups'; + +const { createButton } = ToolbarService; + +const setToolActiveToolbar = { + commandName: 'setToolActiveToolbar', + commandOptions: { + toolGroupIds: [toolGroupIds.PT, toolGroupIds.CT, toolGroupIds.Fusion, toolGroupIds.default], + }, +}; + +const toolbarButtons = [ + { + id: 'MeasurementTools', + uiType: 'ohif.splitButton', + props: { + groupId: 'MeasurementTools', + evaluate: 'evaluate.group.promoteToPrimaryIfCornerstoneToolNotActiveInTheList', + primary: createButton({ + id: 'Length', + icon: 'tool-length', + label: 'Length', + tooltip: 'Length Tool', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }), + secondary: { + icon: 'chevron-down', + tooltip: 'More Measure Tools', + }, + items: [ + { + id: 'Length', + icon: 'tool-length', + label: 'Length', + tooltip: 'Length Tool', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }, + { + id: 'Bidirectional', + icon: 'tool-bidirectional', + label: 'Bidirectional', + tooltip: 'Bidirectional Tool', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }, + { + id: 'ArrowAnnotate', + icon: 'tool-annotate', + label: 'Annotation', + tooltip: 'Arrow Annotate', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }, + { + id: 'EllipticalROI', + icon: 'tool-ellipse', + label: 'Ellipse', + tooltip: 'Ellipse ROI', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }, + ], + }, + }, + { + id: 'Zoom', + uiType: 'ohif.radioGroup', + props: { + icon: 'tool-zoom', + label: 'Zoom', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }, + }, + { + id: 'WindowLevel', + uiType: 'ohif.radioGroup', + props: { + icon: 'tool-window-level', + label: 'Window Level', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }, + }, + { + id: 'Pan', + uiType: 'ohif.radioGroup', + props: { + type: 'tool', + icon: 'tool-move', + label: 'Pan', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }, + }, + { + id: 'TrackballRotate', + uiType: 'ohif.radioGroup', + props: { + type: 'tool', + icon: 'tool-3d-rotate', + label: '3D Rotate', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }, + }, + { + id: 'Capture', + uiType: 'ohif.radioGroup', + props: { + icon: 'tool-capture', + label: 'Capture', + commands: 'showDownloadViewportModal', + evaluate: 'evaluate.action', + }, + }, + { + id: 'Layout', + uiType: 'ohif.layoutSelector', + props: { + rows: 3, + columns: 4, + evaluate: 'evaluate.action', + }, + }, + { + id: 'Crosshairs', + uiType: 'ohif.radioGroup', + props: { + type: 'tool', + icon: 'tool-crosshair', + label: 'Crosshairs', + commands: setToolActiveToolbar, + evaluate: 'evaluate.cornerstoneTool', + }, + }, + { + id: 'ProgressDropdown', + uiType: 'ohif.progressDropdown', + }, + { + id: 'RectangleROIStartEndThreshold', + uiType: 'ohif.radioGroup', + props: { + icon: 'tool-create-threshold', + label: 'Rectangle ROI Threshold', + commands: setToolActiveToolbar, + evaluate: { + name: 'evaluate.cornerstone.segmentation', + toolNames: ['RectangleROIStartEndThreshold'], + }, + options: 'tmtv.RectangleROIThresholdOptions', + }, + }, +]; + +export default toolbarButtons; diff --git a/modes/segmentation/src/toolbarButtons.ts b/modes/segmentation/src/toolbarButtons.ts index 5232312cc..58e12eca8 100644 --- a/modes/segmentation/src/toolbarButtons.ts +++ b/modes/segmentation/src/toolbarButtons.ts @@ -1,8 +1,6 @@ import type { Button } from '@ohif/core/types'; import { defaults, ToolbarService, ViewportGridService } from '@ohif/core'; -import { WindowLevelMenuItem } from '@ohif/ui'; -const { windowLevelPresets } = defaults; const { createButton } = ToolbarService; const ReferenceLinesListeners: RunCommand = [ @@ -12,23 +10,6 @@ const ReferenceLinesListeners: RunCommand = [ }, ]; -function _createWwwcPreset(preset, title, subtitle) { - return { - id: preset.toString(), - title, - subtitle, - commands: [ - { - commandName: 'setWindowLevel', - commandOptions: { - ...windowLevelPresets[preset], - }, - context: 'CORNERSTONE', - }, - ], - }; -} - export const setToolActiveToolbar = { commandName: 'setToolActiveToolbar', commandOptions: { @@ -36,6 +17,20 @@ export const setToolActiveToolbar = { }, }; +export const toggleEnabledDisabledToolbar = { + commandName: 'toggleEnabledDisabledToolbar', + commandOptions: { + toolGroupIds: ['default', 'mpr', 'SRToolGroup'], + }, +}; + +export const toggleActiveDisabledToolbar = { + commandName: 'toggleActiveDisabledToolbar', + commandOptions: { + toolGroupIds: ['default', 'mpr', 'SRToolGroup'], + }, +}; + const toolbarButtons: Button[] = [ { id: 'Zoom', @@ -168,13 +163,7 @@ const toolbarButtons: Button[] = [ icon: 'tool-referenceLines', label: 'Reference Lines', tooltip: 'Show Reference Lines', - commands: { - commandName: 'setToolEnabled', - commandOptions: { - toolName: 'ReferenceLines', - toggle: true, // Toggle the tool on/off upon click - }, - }, + commands: toggleEnabledDisabledToolbar, listeners: { [ViewportGridService.EVENTS.ACTIVE_VIEWPORT_ID_CHANGED]: ReferenceLinesListeners, [ViewportGridService.EVENTS.VIEWPORTS_READY]: ReferenceLinesListeners, @@ -182,17 +171,11 @@ const toolbarButtons: Button[] = [ evaluate: 'evaluate.cornerstoneTool.toggle', }), createButton({ - id: 'ImageOverlay', + id: 'ImageOverlayViewer', icon: 'toggle-dicom-overlay', label: 'Image Overlay', tooltip: 'Toggle Image Overlay', - commands: { - commandName: 'setToolEnabled', - commandOptions: { - toolName: 'ImageOverlayViewer', - toggle: true, // Toggle the tool on/off upon click - }, - }, + commands: toggleEnabledDisabledToolbar, evaluate: 'evaluate.cornerstoneTool.toggle', }), createButton({ @@ -271,7 +254,7 @@ const toolbarButtons: Button[] = [ icon: 'icon-tool-loupe', label: 'Loupe', tooltip: 'Loupe', - commands: setToolActiveToolbar, + commands: toggleActiveDisabledToolbar, evaluate: 'evaluate.cornerstoneTool', }), createButton({ diff --git a/modes/tmtv/src/index.js b/modes/tmtv/src/index.js index 6f4fb65f5..491294220 100644 --- a/modes/tmtv/src/index.js +++ b/modes/tmtv/src/index.js @@ -17,18 +17,21 @@ const ohif = { const cs3d = { viewport: '@ohif/extension-cornerstone.viewportModule.cornerstone', + segPanel: '@ohif/extension-cornerstone-dicom-seg.panelModule.panelSegmentation', }; const tmtv = { hangingProtocol: '@ohif/extension-tmtv.hangingProtocolModule.ptCT', petSUV: '@ohif/extension-tmtv.panelModule.petSUV', - ROIThresholdPanel: '@ohif/extension-tmtv.panelModule.ROIThresholdSeg', + toolbox: '@ohif/extension-tmtv.panelModule.tmtvBox', + export: '@ohif/extension-tmtv.panelModule.tmtvExport', }; const extensionDependencies = { // Can derive the versions at least process.env.from npm_package_version '@ohif/extension-default': '^3.0.0', '@ohif/extension-cornerstone': '^3.0.0', + '@ohif/extension-cornerstone-dicom-seg': '^3.0.0', '@ohif/extension-tmtv': '^3.0.0', }; @@ -47,6 +50,7 @@ function modeFactory({ modeConfiguration }) { const { toolbarService, toolGroupService, + customizationService, hangingProtocolService, displaySetService, } = servicesManager.services; @@ -94,7 +98,18 @@ function modeFactory({ modeConfiguration }) { 'Pan', 'SyncToggle', ]); - toolbarService.createButtonSection('tmtvToolbox', ['RectangleROIStartEndThreshold']); + toolbarService.createButtonSection('ROIThresholdToolbox', ['RectangleROIStartEndThreshold']); + + customizationService.addModeCustomizations([ + { + id: 'segmentation.panel', + segmentationPanelMode: 'expanded', + addSegment: false, + onSegmentationAdd: () => { + commandsManager.run('createNewLabelmapFromPT'); + }, + }, + ]); // 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 @@ -176,13 +191,13 @@ function modeFactory({ modeConfiguration }) { /*init: ({ servicesManager, extensionManager }) => { //defaultViewerRouteInit },*/ - layoutTemplate: ({ location, servicesManager }) => { + layoutTemplate: () => { return { id: ohif.layout, props: { leftPanels: [ohif.thumbnailList], - leftPanelDefaultClosed: true, - rightPanels: [tmtv.ROIThresholdPanel, tmtv.petSUV], + leftPanelClosed: true, + rightPanels: [[tmtv.toolbox, cs3d.segPanel, tmtv.export], tmtv.petSUV], viewports: [ { namespace: cs3d.viewport, diff --git a/package.json b/package.json index 81b792ec6..1c82d2442 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "dev:no:cache": "lerna run dev:no:cache --stream", "dev:project": ".scripts/dev.sh", "dev:orthanc": "lerna run dev:orthanc --stream", + "dev:orthanc:no:cache": "lerna run dev:orthanc:no:cache --stream", "dev:dcm4chee": "lerna run dev:dcm4chee --stream", "dev:static": "lerna run dev:static --stream", "orthanc:up": "docker-compose -f platform/app/.recipes/OpenResty-Orthanc/docker-compose.yml up", @@ -59,7 +60,7 @@ }, "dependencies": { "@babel/runtime": "^7.20.13", - "@kitware/vtk.js": "29.7.0", + "@kitware/vtk.js": "30.3.1", "core-js": "^3.2.1" }, "peerDependencies": { diff --git a/platform/app/cypress/integration/measurement-tracking/OHIFCornerstoneToolbar.spec.js b/platform/app/cypress/integration/measurement-tracking/OHIFCornerstoneToolbar.spec.js index 595cfd3dd..444ed5a09 100644 --- a/platform/app/cypress/integration/measurement-tracking/OHIFCornerstoneToolbar.spec.js +++ b/platform/app/cypress/integration/measurement-tracking/OHIFCornerstoneToolbar.spec.js @@ -110,7 +110,8 @@ describe('OHIF Cornerstone Toolbar', () => { //Click on button and verify if icon is active on toolbar cy.addLengthMeasurement(); cy.get('[data-cy="viewport-notification"]').as('notif').should('exist'); - cy.get('[data-cy="viewport-notification"]').as('notif').should('be.visible'); + // cy.get('[data-cy="viewport-notification"]').as('notif').should('be.visible'); + cy.get('[data-cy="prompt-begin-tracking-yes-btn"]').as('yesBtn').click(); //Verify the measurement exists in the table diff --git a/platform/app/cypress/integration/study-list/OHIFStudyList.spec.js b/platform/app/cypress/integration/study-list/OHIFStudyList.spec.js index d1c32e088..e59df25cf 100644 --- a/platform/app/cypress/integration/study-list/OHIFStudyList.spec.js +++ b/platform/app/cypress/integration/study-list/OHIFStudyList.spec.js @@ -122,6 +122,8 @@ describe('OHIF Study List', function () { '[data-cy="mode-basic-test-1.3.6.1.4.1.25403.345050719074.3824.20170125113417.1"]' ).click(); cy.get('[data-cy="return-to-work-list"]').click(); + + cy.wait(1000); cy.get('@searchResult2').should($list => { expect($list.length).to.be.eq(1); expect($list).to.contain('PETCT'); diff --git a/platform/app/package.json b/platform/app/package.json index 499ba4b8c..fbf5e5503 100644 --- a/platform/app/package.json +++ b/platform/app/package.json @@ -30,6 +30,7 @@ "dev": "cross-env NODE_ENV=development webpack serve --config .webpack/webpack.pwa.js", "dev:no:cache": "cross-env NODE_ENV=development webpack serve --no-cache --config .webpack/webpack.pwa.js", "dev:orthanc": "cross-env NODE_ENV=development PROXY_TARGET=/dicom-web PROXY_DOMAIN=http://localhost:8042 APP_CONFIG=config/docker_nginx-orthanc.js webpack serve --config .webpack/webpack.pwa.js", + "dev:orthanc:no:cache": "cross-env NODE_ENV=development PROXY_TARGET=/dicom-web PROXY_DOMAIN=http://localhost:8042 APP_CONFIG=config/docker_nginx-orthanc.js webpack serve --no-cache --config .webpack/webpack.pwa.js", "dev:dcm4chee": "cross-env NODE_ENV=development APP_CONFIG=config/local_dcm4chee.js webpack serve --config .webpack/webpack.pwa.js", "dev:static": "cross-env NODE_ENV=development APP_CONFIG=config/local_static.js webpack serve --config .webpack/webpack.pwa.js", "dev:viewer": "yarn run dev", @@ -53,7 +54,7 @@ "@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2", "@cornerstonejs/codec-openjpeg": "^1.2.2", "@cornerstonejs/codec-openjph": "^2.4.5", - "@cornerstonejs/dicom-image-loader": "^1.68.1", + "@cornerstonejs/dicom-image-loader": "^1.70.0", "@emotion/serialize": "^1.1.3", "@ohif/core": "3.8.0-beta.73", "@ohif/extension-cornerstone": "3.8.0-beta.73", @@ -98,7 +99,7 @@ "devDependencies": { "@babel/plugin-proposal-private-methods": "^7.18.6", "@percy/cypress": "^3.1.1", - "cypress": "13.6.2", + "cypress": "13.7.2", "cypress-file-upload": "^3.5.3", "glob": "^8.0.3", "identity-obj-proxy": "3.0.x", diff --git a/platform/app/pluginConfig.json b/platform/app/pluginConfig.json index 08a42deb0..a910bee9f 100644 --- a/platform/app/pluginConfig.json +++ b/platform/app/pluginConfig.json @@ -22,6 +22,11 @@ "default": false, "version": "3.0.0" }, + { + "packageName": "@ohif/extension-cornerstone-dynamic-volume", + "default": false, + "version": "3.0.0" + }, { "packageName": "@ohif/extension-dicom-microscopy", "default": false, @@ -66,6 +71,9 @@ { "packageName": "@ohif/mode-microscopy" }, + { + "packageName": "@ohif/mode-preclinical-4d" + }, { "packageName": "@ohif/mode-test", "default": false, diff --git a/platform/app/public/config/local_orthanc.js b/platform/app/public/config/local_orthanc.js index f8ae80c86..9fd18992c 100644 --- a/platform/app/public/config/local_orthanc.js +++ b/platform/app/public/config/local_orthanc.js @@ -29,6 +29,7 @@ window.config = { imageRendering: 'wadors', thumbnailRendering: 'wadors', enableStudyLazyLoad: true, + useBulkDataURI: false, supportsFuzzyMatching: true, supportsWildcard: true, dicomUploadEnabled: true, diff --git a/platform/app/src/appInit.js b/platform/app/src/appInit.js index 867534bd2..4e26eee21 100644 --- a/platform/app/src/appInit.js +++ b/platform/app/src/appInit.js @@ -19,6 +19,7 @@ import { errorHandler, CustomizationService, PanelService, + WorkflowStepsService, // utils, } from '@ohif/core'; @@ -68,6 +69,7 @@ async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) { CineService.REGISTRATION, UserAuthenticationService.REGISTRATION, PanelService.REGISTRATION, + WorkflowStepsService.REGISTRATION, StateSyncService.REGISTRATION, ]); diff --git a/platform/app/src/routes/Mode/Mode.tsx b/platform/app/src/routes/Mode/Mode.tsx index 6c7fccf50..53b881e51 100644 --- a/platform/app/src/routes/Mode/Mode.tsx +++ b/platform/app/src/routes/Mode/Mode.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useRef, useContext, createContext } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { useParams, useLocation, useNavigate } from 'react-router'; import PropTypes from 'prop-types'; import { ServicesManager, utils } from '@ohif/core'; @@ -60,7 +60,7 @@ export default function ModeRoute({ locationRef.current = location; } - const { displaySetService, hangingProtocolService, userAuthenticationService } = ( + const { displaySetService, panelService, hangingProtocolService, userAuthenticationService } = ( servicesManager as ServicesManager ).services; @@ -141,7 +141,17 @@ export default function ModeRoute({ servicesManager, studyInstanceUIDs, }); + if (isMounted.current) { + const { leftPanels = [], rightPanels = [], ...layoutProps } = layoutData.props; + + panelService.reset(); + panelService.addPanels(panelService.PanelPosition.Left, leftPanels); + panelService.addPanels(panelService.PanelPosition.Right, rightPanels); + + // layoutProps contains all props but leftPanels and rightPanels + layoutData.props = layoutProps; + layoutTemplateData.current = layoutData; setRefresh(!refresh); } @@ -244,8 +254,10 @@ export default function ModeRoute({ } }, {}) ?? {}; + let unsubs; + if (route.init) { - return await route.init( + unsubs = await route.init( { servicesManager, extensionManager, @@ -273,6 +285,14 @@ export default function ModeRoute({ let unsubscriptions; setupRouteInit().then(unsubs => { unsubscriptions = unsubs; + + // Some code may need to run after hanging protocol initialization + // (eg: workflowStepsService initialization on 4D mode) + mode?.onSetupRouteComplete?.({ + servicesManager, + extensionManager, + commandsManager, + }); }); return () => { diff --git a/platform/app/src/routes/Mode/defaultRouteInit.ts b/platform/app/src/routes/Mode/defaultRouteInit.ts index 3f8aafaa7..975ac4d8b 100644 --- a/platform/app/src/routes/Mode/defaultRouteInit.ts +++ b/platform/app/src/routes/Mode/defaultRouteInit.ts @@ -15,7 +15,7 @@ const { sortingCriteria } = utils; * @param props.filters filters from query params to read the data from * @returns array of subscriptions to cancel */ -export function defaultRouteInit( +export async function defaultRouteInit( { servicesManager, studyInstanceUIDs, dataSource, filters, appConfig }, hangingProtocolId ) { @@ -95,7 +95,7 @@ export function defaultRouteInit( }); }); - Promise.allSettled(allRetrieves).then(promises => { + await Promise.allSettled(allRetrieves).then(async promises => { log.timeEnd(Enums.TimingEnum.STUDY_TO_DISPLAY_SETS); log.time(Enums.TimingEnum.DISPLAY_SETS_TO_FIRST_IMAGE); log.time(Enums.TimingEnum.DISPLAY_SETS_TO_ALL_IMAGES); @@ -120,7 +120,7 @@ export function defaultRouteInit( } }); - Promise.allSettled(allPromises).then(applyHangingProtocol); + await Promise.allSettled(allPromises).then(applyHangingProtocol); startRemainingPromises(remainingPromises); applyHangingProtocol(); }); diff --git a/platform/core/package.json b/platform/core/package.json index a4005a9e6..1b22d884d 100644 --- a/platform/core/package.json +++ b/platform/core/package.json @@ -37,7 +37,7 @@ "@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2", "@cornerstonejs/codec-openjpeg": "^1.2.2", "@cornerstonejs/codec-openjph": "^2.4.2", - "@cornerstonejs/dicom-image-loader": "^1.68.1", + "@cornerstonejs/dicom-image-loader": "^1.70.0", "@ohif/ui": "3.8.0-beta.73", "cornerstone-math": "0.1.9", "dicom-parser": "^1.8.21" diff --git a/platform/core/src/classes/MetadataProvider.ts b/platform/core/src/classes/MetadataProvider.ts index 650466a0a..73238568c 100644 --- a/platform/core/src/classes/MetadataProvider.ts +++ b/platform/core/src/classes/MetadataProvider.ts @@ -241,6 +241,12 @@ class MetadataProvider { sopInstanceUID: instance.SOPInstanceUID, }; break; + case WADO_IMAGE_LOADER_TAGS.PET_IMAGE_MODULE: + metadata = { + frameReferenceTime: instance.FrameReferenceTime, + actualFrameDuration: instance.ActualFrameDuration, + }; + break; case WADO_IMAGE_LOADER_TAGS.PET_ISOTOPE_MODULE: const { RadiopharmaceuticalInformationSequence } = instance; @@ -393,6 +399,41 @@ class MetadataProvider { decayCorrection: instance.DecayCorrection, }; break; + case WADO_IMAGE_LOADER_TAGS.CALIBRATION_MODULE: + // map the DICOM tags to the cornerstone tags since cornerstone tags + // are camelCase and instance tags are all caps + metadata = { + sequenceOfUltrasoundRegions: instance.SequenceOfUltrasoundRegions?.map(region => { + return { + regionSpatialFormat: region.RegionSpatialFormat, + regionDataType: region.RegionDataType, + regionFlags: region.RegionFlags, + regionLocationMinX0: region.RegionLocationMinX0, + regionLocationMinY0: region.RegionLocationMinY0, + regionLocationMaxX1: region.RegionLocationMaxX1, + regionLocationMaxY1: region.RegionLocationMaxY1, + referencePixelX0: region.ReferencePixelX0, + referencePixelY0: region.ReferencePixelY0, + referencePixelPhysicalValueX: region.ReferencePixelPhysicalValueX, + referencePixelPhysicalValueY: region.ReferencePixelPhysicalValueY, + physicalUnitsXDirection: region.PhysicalUnitsXDirection, + physicalUnitsYDirection: region.PhysicalUnitsYDirection, + physicalDeltaX: region.PhysicalDeltaX, + physicalDeltaY: region.PhysicalDeltaY, + }; + }), + }; + break; + + /** + * Below are the tags and not the modules since they are not really + * consistent with the modules above + */ + case 'temporalPositionIdentifier': + metadata = { + temporalPositionIdentifier: instance.TemporalPositionIdentifier, + }; + break; default: return; @@ -539,6 +580,7 @@ const WADO_IMAGE_LOADER_TAGS = { VOI_LUT_MODULE: 'voiLutModule', MODALITY_LUT_MODULE: 'modalityLutModule', SOP_COMMON_MODULE: 'sopCommonModule', + PET_IMAGE_MODULE: 'petImageModule', PET_ISOTOPE_MODULE: 'petIsotopeModule', PER_SERIES_MODULE: 'petSeriesModule', OVERLAY_PLANE_MODULE: 'overlayPlaneModule', @@ -549,6 +591,7 @@ const WADO_IMAGE_LOADER_TAGS = { GENERAL_IMAGE_MODULE: 'generalImageModule', GENERAL_STUDY_MODULE: 'generalStudyModule', CINE_MODULE: 'cineModule', + CALIBRATION_MODULE: 'calibrationModule', }; const INSTANCE = 'instance'; diff --git a/platform/core/src/extensions/ExtensionManager.ts b/platform/core/src/extensions/ExtensionManager.ts index ed4a9c4c5..d7997c9fc 100644 --- a/platform/core/src/extensions/ExtensionManager.ts +++ b/platform/core/src/extensions/ExtensionManager.ts @@ -48,6 +48,7 @@ export interface Extension { getCustomizationModule?: (p: ExtensionParams) => unknown; getSopClassHandlerModule?: (p: ExtensionParams) => unknown; getToolbarModule?: (p: ExtensionParams) => unknown; + getPanelModule?: (p: ExtensionParams) => unknown; onModeEnter?: () => void; onModeExit?: () => void; } diff --git a/platform/core/src/index.test.js b/platform/core/src/index.test.js index 6b2f6efae..7649a8702 100644 --- a/platform/core/src/index.test.js +++ b/platform/core/src/index.test.js @@ -1,7 +1,7 @@ import * as OHIF from './index'; describe('Top level exports', () => { - test('have not changed', () => { + test.only('have not changed', () => { const expectedExports = [ 'MODULE_TYPES', // @@ -46,6 +46,7 @@ describe('Top level exports', () => { 'pubSubServiceInterface', 'PubSubService', 'PanelService', + 'WorkflowStepsService', 'useToolbar', ].sort(); diff --git a/platform/core/src/index.ts b/platform/core/src/index.ts index a04b6569f..d548e6e7b 100644 --- a/platform/core/src/index.ts +++ b/platform/core/src/index.ts @@ -33,6 +33,7 @@ import { CustomizationService, StateSyncService, PanelService, + WorkflowStepsService, } from './services'; import { DisplaySetMessage, DisplaySetMessageList } from './services/DisplaySetService'; @@ -84,6 +85,7 @@ const OHIF = { PubSubService, PanelService, useToolbar, + WorkflowStepsService, }; export { @@ -128,6 +130,7 @@ export { Enums, Types, PanelService, + WorkflowStepsService, useToolbar, }; diff --git a/platform/core/src/services/CineService/CineService.ts b/platform/core/src/services/CineService/CineService.ts index 9c027afb2..d7f054b82 100644 --- a/platform/core/src/services/CineService/CineService.ts +++ b/platform/core/src/services/CineService/CineService.ts @@ -34,29 +34,46 @@ class CineService extends PubSubService { // reducer state does not get updated right away and if we publish the // event and we use the cineService.getState() it will return the old state setTimeout(() => { - this._broadcastEvent(this.EVENTS.CINE_STATE_CHANGED, isCineEnabled); + this._broadcastEvent(this.EVENTS.CINE_STATE_CHANGED, { isCineEnabled }); }, 0); } public playClip(element, playClipOptions) { - return this.serviceImplementation._playClip(element, playClipOptions); + const res = this.serviceImplementation._playClip(element, playClipOptions); + + this._broadcastEvent(this.EVENTS.CINE_STATE_CHANGED, { isPlaying: true }); + + return res; } public stopClip(element) { - return this.serviceImplementation._stopClip(element); + const res = this.serviceImplementation._stopClip(element); + + this._broadcastEvent(this.EVENTS.CINE_STATE_CHANGED, { isPlaying: false }); + + return res; } public _onModeExit() { this.setIsCineEnabled(false); } + public getSyncedViewports(viewportId) { + return this.serviceImplementation._getSyncedViewports(viewportId); + } + public setServiceImplementation({ getState: getStateImplementation, setCine: setCineImplementation, setIsCineEnabled: setIsCineEnabledImplementation, playClip: playClipImplementation, stopClip: stopClipImplementation, + getSyncedViewports: getSyncedViewportsImplementation, }) { + if (getSyncedViewportsImplementation) { + this.serviceImplementation._getSyncedViewports = getSyncedViewportsImplementation; + } + if (getStateImplementation) { this.serviceImplementation._getState = getStateImplementation; } diff --git a/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts b/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts index 7c20251a3..0246a28e9 100644 --- a/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts +++ b/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts @@ -1314,18 +1314,19 @@ export default class HangingProtocolService extends PubSubService { ); // Use the display set provided instead - if (reuseDisplaySetUID) { - if (viewportOptions.allowUnmatchedView !== true) { - this.validateDisplaySetSelectMatch(viewportDisplaySet, id, reuseDisplaySetUID); - } - const displaySetInfo: HangingProtocol.DisplaySetInfo = { - displaySetInstanceUID: reuseDisplaySetUID, - displaySetOptions, - }; + // Todo: find out what is wrong here + // if (reuseDisplaySetUID) { + // if (viewportOptions.allowUnmatchedView !== true) { + // this.validateDisplaySetSelectMatch(viewportDisplaySet, id, reuseDisplaySetUID); + // } + // const displaySetInfo: HangingProtocol.DisplaySetInfo = { + // displaySetInstanceUID: reuseDisplaySetUID, + // displaySetOptions, + // }; - displaySetsInfo.push(displaySetInfo); - return; - } + // displaySetsInfo.push(displaySetInfo); + // return; + // } // Use the display set index to allow getting the "next" match, eg // matching all display sets, and get the matchedDisplaySetsIndex'th item diff --git a/platform/core/src/services/PanelService/PanelService.ts b/platform/core/src/services/PanelService/PanelService.ts deleted file mode 100644 index c22194b11..000000000 --- a/platform/core/src/services/PanelService/PanelService.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { ActivatePanelTriggers } from '../../types'; -import { Subscription } from '../../types/IPubSub'; -import { PubSubService } from '../_shared/pubSubServiceInterface'; - -export const EVENTS = { - ACTIVATE_PANEL: 'event::panelService:activatePanel', -}; - -export default class PanelService extends PubSubService { - public static REGISTRATION = { - name: 'panelService', - create: (): PanelService => { - return new PanelService(); - }, - }; - - constructor() { - super(EVENTS); - } - - /** - * Activates the panel with the given id. If the forceActive flag is false - * then it is up to the component containing the panel whether to activate - * it immediately or not. For instance, the panel might not be activated when - * the forceActive flag is false in the case where the user might have - * activated/displayed and then closed the panel already. - * Note that this method simply fires a broadcast event: ActivatePanelEvent. - * @param panelId the panel's id - * @param forceActive optional flag indicating if the panel should be forced to be activated or not - */ - activatePanel(panelId: string, forceActive = false): void { - this._broadcastEvent(EVENTS.ACTIVATE_PANEL, { panelId, forceActive }); - } - - /** - * Adds a mapping of events (activatePanelTriggers.sourceEvents) broadcast by - * activatePanelTrigger.sourcePubSubService that - * when fired/broadcasted must in turn activate the panel with the given id. - * The subscriptions created are returned such that they can be managed and unsubscribed - * as appropriate. - * @param panelId the id of the panel to activate - * @param activatePanelTriggers an array of triggers - * @param forceActive optional flag indicating if the panel should be forced to be activated or not - * @returns an array of the subscriptions subscribed to - */ - addActivatePanelTriggers( - panelId: string, - activatePanelTriggers: ActivatePanelTriggers[], - forceActive = false - ): Subscription[] { - return activatePanelTriggers - .map(trigger => - trigger.sourceEvents.map(eventName => - trigger.sourcePubSubService.subscribe(eventName, () => - this.activatePanel(panelId, forceActive) - ) - ) - ) - .flat(); - } -} diff --git a/platform/core/src/services/PanelService/PanelService.tsx b/platform/core/src/services/PanelService/PanelService.tsx new file mode 100644 index 000000000..b7e0b13f4 --- /dev/null +++ b/platform/core/src/services/PanelService/PanelService.tsx @@ -0,0 +1,195 @@ +import React from 'react'; +import { ActivatePanelTriggers } from '../../types'; +import { Subscription } from '../../types/IPubSub'; +import { PubSubService } from '../_shared/pubSubServiceInterface'; +import { ExtensionManager } from '../../extensions'; + +export const EVENTS = { + PANELS_CHANGED: 'event::panelService:panelsChanged', + ACTIVATE_PANEL: 'event::panelService:activatePanel', +}; + +type PanelData = { + id: string; + iconName: string; + iconLabel: string; + label: string; + name: string; + content: unknown; +}; + +export enum PanelPosition { + Left = 'left', + Right = 'right', + Bottom = 'bottom', +} + +export default class PanelService extends PubSubService { + private _extensionManager: ExtensionManager; + + public static REGISTRATION = { + name: 'panelService', + create: ({ extensionManager }): PanelService => { + return new PanelService(extensionManager); + }, + }; + + private _panelsGroups: Map = new Map(); + + constructor(extensionManager: ExtensionManager) { + super(EVENTS); + this._extensionManager = extensionManager; + } + + public get PanelPosition(): typeof PanelPosition { + return PanelPosition; + } + + private _getPanelComponent(panelId: string) { + const entry = this._extensionManager.getModuleEntry(panelId); + + if (!entry) { + throw new Error( + `${panelId} is not a valid entry for an extension module, please check your configuration or make sure the extension is registered.` + ); + } + + if (!entry?.component) { + throw new Error( + `No component found from extension ${panelId}. Check the reference string to the extension in your Mode configuration` + ); + } + + const content = entry.component; + + return { entry, content }; + } + + public getPanelData(panelId): PanelData { + let content, entry; + if (Array.isArray(panelId)) { + const panelsData = panelId.map(id => this._getPanelComponent(id)); + + // use the first panel's entry for the combined panel + entry = panelsData[0].entry; + + // stack the content of the panels in one react component + content = () => ( + <> + {panelsData.map(({ content: PanelContent }) => ( + + ))} + + ); + } else { + ({ content, entry } = this._getPanelComponent(panelId)); + } + + return { + id: entry.id, + iconName: entry.iconName, + iconLabel: entry.iconLabel, + label: entry.label, + name: entry.name, + content, + }; + } + + public addPanel(position: PanelPosition, panelId: string, options): void { + let panels = this._panelsGroups.get(position); + + if (!panels) { + panels = []; + this._panelsGroups.set(position, panels); + } + + const panelComponent = this.getPanelData(panelId); + + panels.push(panelComponent); + this._broadcastEvent(EVENTS.PANELS_CHANGED, { position, options }); + } + + public addPanels(position: PanelPosition, panelsIds: string[], options): void { + if (!Array.isArray(panelsIds)) { + throw new Error('Invalid "panelsIds" array'); + } + + panelsIds.forEach(panelId => this.addPanel(position, panelId, options)); + } + + public setPanels( + panels: { [key in PanelPosition]: string[] }, + options: { + rightPanelClosed?: boolean; + leftPanelClosed?: boolean; + } + ): void { + this.reset(); + + Object.keys(panels).forEach((position: PanelPosition) => { + this.addPanels(position, panels[position], options); + }); + } + + public getPanels(position: PanelPosition): PanelData[] { + const panels = this._panelsGroups.get(position) ?? []; + + // Return a new array to preserve the internal state + return [...panels]; + } + + public reset(): void { + const affectedPositions = Array.from(this._panelsGroups.keys()); + + this._panelsGroups.clear(); + + affectedPositions.forEach(position => + this._broadcastEvent(EVENTS.PANELS_CHANGED, { position }) + ); + } + + public onModeExit(): void { + this.reset(); + } + + /**5 + * Activates the panel with the given id. If the forceActive flag is false + * then it is up to the component containing the panel whether to activate + * it immediately or not. For instance, the panel might not be activated when + * the forceActive flag is false in the case where the user might have + * activated/displayed and then closed the panel already. + * Note that this method simply fires a broadcast event: ActivatePanelEvent. + * @param panelId the panel's id + * @param forceActive optional flag indicating if the panel should be forced to be activated or not + */ + public activatePanel(panelId: string, forceActive = false): void { + this._broadcastEvent(EVENTS.ACTIVATE_PANEL, { panelId, forceActive }); + } + + /** + * Adds a mapping of events (activatePanelTriggers.sourceEvents) broadcast by + * activatePanelTrigger.sourcePubSubService that + * when fired/broadcasted must in turn activate the panel with the given id. + * The subscriptions created are returned such that they can be managed and unsubscribed + * as appropriate. + * @param panelId the id of the panel to activate + * @param activatePanelTriggers an array of triggers + * @param forceActive optional flag indicating if the panel should be forced to be activated or not + * @returns an array of the subscriptions subscribed to + */ + public addActivatePanelTriggers( + panelId: string, + activatePanelTriggers: ActivatePanelTriggers[], + forceActive = false + ): Subscription[] { + return activatePanelTriggers + .map(trigger => + trigger.sourceEvents.map(eventName => + trigger.sourcePubSubService.subscribe(eventName, () => + this.activatePanel(panelId, forceActive) + ) + ) + ) + .flat(); + } +} diff --git a/platform/core/src/services/ServicesManager.ts b/platform/core/src/services/ServicesManager.ts index b7419639a..fb96db0f9 100644 --- a/platform/core/src/services/ServicesManager.ts +++ b/platform/core/src/services/ServicesManager.ts @@ -11,6 +11,7 @@ export default class ServicesManager { constructor(commandsManager: CommandsManager) { this._commandsManager = commandsManager; + this._extensionManager = null; this.services = {}; this.registeredServiceNames = []; } @@ -46,6 +47,7 @@ export default class ServicesManager { if (service.create) { this.services[service.name] = service.create({ configuration, + extensionManager: this._extensionManager, commandsManager: this._commandsManager, servicesManager: this, extensionManager: this._extensionManager, diff --git a/platform/core/src/services/ToolBarService/ToolbarService.ts b/platform/core/src/services/ToolBarService/ToolbarService.ts index 0f84b0b6e..b9dfea404 100644 --- a/platform/core/src/services/ToolBarService/ToolbarService.ts +++ b/platform/core/src/services/ToolBarService/ToolbarService.ts @@ -68,7 +68,7 @@ export default class ToolbarService extends PubSubService { } public reset(): void { - this.unsubscriptions.forEach(unsub => unsub()); + // this.unsubscriptions.forEach(unsub => unsub()); this.state = { buttons: {}, buttonSections: {}, @@ -120,6 +120,10 @@ export default class ToolbarService extends PubSubService { public addButtons(buttons: Button[]): void { buttons.forEach(button => { if (!this.state.buttons[button.id]) { + if (!button.props) { + button.props = {}; + } + this.state.buttons[button.id] = button; } }); @@ -350,6 +354,7 @@ export default class ToolbarService extends PubSubService { * @param {Array} buttons - The buttons to be added to the section. */ createButtonSection(key, buttons) { + // make sure all buttons have at least an empty props this.state.buttonSections[key] = buttons; this._broadcastEvent(this.EVENTS.TOOL_BAR_MODIFIED, { ...this.state }); } diff --git a/platform/core/src/services/WorkflowStepsService/WorkflowStepsService.ts b/platform/core/src/services/WorkflowStepsService/WorkflowStepsService.ts new file mode 100644 index 000000000..8a5d33782 --- /dev/null +++ b/platform/core/src/services/WorkflowStepsService/WorkflowStepsService.ts @@ -0,0 +1,238 @@ +import { CommandsManager } from '../../classes'; +import { ExtensionManager } from '../../extensions'; +import { ServicesManager } from '../../services'; +import { PubSubService } from '../_shared/pubSubServiceInterface'; + +export const EVENTS = { + ACTIVE_STEP_CHANGED: 'event::workflowStepsService:activateStepChanged', + STEPS_CHANGED: 'event::workflowStepsService:stepsChanged', +}; + +/* + A mode may define a workflow and each workflow may have one or more steps. + Each step may define a different set of tools, hanging protocol and panels + layout that will be applied to the viewer once it gets activated making the + viewer work in a more dynamic way. + + Example: + All keys inside brackets are optionals. + + workflow: { + [initialStepId]: 'step1', + steps: [ + { + id: 'firstStep', + name: 'First Step', + [toolbar]: { + buttons: firstStepToolbarButtons, + sections: [ + { + key: 'primary', + buttons: [ 'MeasurementTools', 'Zoom', ... ], + }, + ], + }, + [layout]: { + [panels]: { + left: ['firstLeftPanelId', 'secondLeftPanelId'], + right: ['firstRightPanelId'], + }, + }, + [hangingProtocol]: { + protocolId: 'default', + [stepId]: 'firstStep', + }, + }, + { + id: 'secondStep', + name: 'Second Step', + ... + }, + ] + } + + If workflow steps are defined but `initialStepId` is not set then the first + step is set as active during mode initialization. +*/ + +type CommandCallback = { + commandName: string; + options: Record; +}; + +export type WorkflowStep = { + id: string; + name: string; + toolbarButtons?: { + buttonSection: string; + buttons: string[]; + }[]; + hangingProtocol?: { + protocolId: string; + stageId?: string; + }; + layout?: { + panels: { + left?: string[]; + right?: string[]; + }; + }; + onEnter: () => void | CommandCallback[]; +}; + +class WorkflowStepsService extends PubSubService { + private _extensionManager: ExtensionManager; + private _servicesManager: ServicesManager; + private _commandsManager: CommandsManager; + private _workflowSteps: WorkflowStep[]; + private _activeWorkflowStep: WorkflowStep; + + constructor( + extensionManager: ExtensionManager, + commandsManager: CommandsManager, + servicesManager: ServicesManager + ) { + super(EVENTS); + this._workflowSteps = []; + this._activeWorkflowStep = null; + this._extensionManager = extensionManager; + this._commandsManager = commandsManager; + this._servicesManager = servicesManager; + } + + public get workflowSteps(): WorkflowStep[] { + return [...this._workflowSteps]; + } + + public get activeWorkflowStep(): WorkflowStep { + return this._activeWorkflowStep; + } + + public addWorkflowSteps(workflowSteps: WorkflowStep[]): void { + let workflowStepAdded = false; + + workflowSteps.forEach(newWorkflowStep => { + const workflowStepExists = this._workflowSteps.some( + workflowStep => workflowStep.id === newWorkflowStep.id + ); + + if (workflowStepExists) { + throw new Error(`Duplicated workflow step id (${newWorkflowStep.id})`); + } + + this._workflowSteps.push(newWorkflowStep); + workflowStepAdded = true; + }); + + if (workflowStepAdded) { + this._broadcastEvent(EVENTS.STEPS_CHANGED, {}); + } + } + + private _updateToolBar(workflowStep: WorkflowStep) { + const { toolbarService } = this._servicesManager.services; + const { toolbarButtons } = workflowStep; + + const toUse = Array.isArray(toolbarButtons) ? toolbarButtons : [toolbarButtons]; + + toUse.forEach(({ buttonSection, buttons }) => { + toolbarService.createButtonSection(buttonSection, buttons); + }); + } + + private _updatePanels(workflowStep: WorkflowStep) { + const { panelService } = this._servicesManager.services; + const panels = workflowStep?.layout?.panels; + + if (!panels) { + return; + } + + panelService.setPanels(panels, workflowStep?.layout?.options); + } + + private _updateHangingProtocol(workflowStep: WorkflowStep) { + const { hangingProtocol } = workflowStep; + + if (!hangingProtocol) { + return; + } + + this._commandsManager.runCommand('setHangingProtocol', { + protocolId: hangingProtocol.protocolId, + stageId: hangingProtocol.stageId, + stageIndex: hangingProtocol.stageIndex, + }); + } + + private _invokeCallbacks(callbacks) { + if (!callbacks) { + return; + } + + const commandsManager = this._commandsManager; + + if (!Array.isArray) { + callbacks = [callbacks]; + } + + // Invoke all callbacks which may be a function or an object like + // { commandName: string, options?: object } + callbacks.forEach(callback => { + let fn = callback; + + if (callback?.commandName) { + const { commandName, options } = callback; + fn = () => commandsManager.runCommand(commandName, options); + } + + fn(); + }); + } + + public setActiveWorkflowStep(workflowStepId: string): void { + const previousWorkflowStep = this._activeWorkflowStep; + + if (workflowStepId === previousWorkflowStep?.id) { + return; + } + + const newWorkflowStep = this._workflowSteps.find(step => step.id === workflowStepId); + + if (!newWorkflowStep) { + throw new Error(`Invalid workflowStepId (${workflowStepId})`); + } + + // onEnter needs to be called before updating the Hanging Protocol because + // some displaySets need to be created before moving to the next HP stage + // (eg: convert segmentations into a chart displaySet). If needed we can + // change it to onBeforeEnter and onAfterEnter in the future. + this._invokeCallbacks(newWorkflowStep.onEnter); + + this._activeWorkflowStep = newWorkflowStep; + this._updateToolBar(newWorkflowStep); + this._updatePanels(newWorkflowStep); + this._updateHangingProtocol(newWorkflowStep); + this._broadcastEvent(EVENTS.ACTIVE_STEP_CHANGED, { + activeWorkflowStep: newWorkflowStep, + }); + } + + public reset(): void { + this._activeWorkflowStep = null; + this._workflowSteps = []; + } + + public onModeEnter(): void { + this.reset(); + } + + public static REGISTRATION = { + name: 'workflowStepsService', + create: ({ extensionManager, commandsManager, servicesManager }): WorkflowStepsService => { + return new WorkflowStepsService(extensionManager, commandsManager, servicesManager); + }, + }; +} + +export { WorkflowStepsService as default, WorkflowStepsService }; diff --git a/platform/core/src/services/WorkflowStepsService/index.ts b/platform/core/src/services/WorkflowStepsService/index.ts new file mode 100644 index 000000000..39db00b4a --- /dev/null +++ b/platform/core/src/services/WorkflowStepsService/index.ts @@ -0,0 +1,3 @@ +import WorkflowStepsService from './WorkflowStepsService'; + +export default WorkflowStepsService; diff --git a/platform/core/src/services/index.ts b/platform/core/src/services/index.ts index bee9afd54..f8d972057 100644 --- a/platform/core/src/services/index.ts +++ b/platform/core/src/services/index.ts @@ -16,6 +16,7 @@ import UserAuthenticationService from './UserAuthenticationService'; import CustomizationService from './CustomizationService'; import StateSyncService from './StateSyncService'; import PanelService from './PanelService'; +import WorkflowStepsService from './WorkflowStepsService'; import type Services from '../types/Services'; @@ -40,4 +41,5 @@ export { PubSubService, UserAuthenticationService, PanelService, + WorkflowStepsService, }; diff --git a/platform/core/src/types/Services.ts b/platform/core/src/types/Services.ts index bf5d78591..9cedd0ad3 100644 --- a/platform/core/src/types/Services.ts +++ b/platform/core/src/types/Services.ts @@ -8,6 +8,7 @@ import { StateSyncService, UINotificationService, UIModalService, + WorkflowStepsService, } from '../services'; /** @@ -23,6 +24,7 @@ export default interface Services { uiModalService?: UIModalService; uiNotificationService?: UINotificationService; stateSyncService?: StateSyncService; + workflowStepsService: WorkflowStepsService; cineService?: unknown; userAuthenticationService?: unknown; cornerstoneViewportService?: unknown; diff --git a/platform/docs/docs/assets/img/progressDropdown.png b/platform/docs/docs/assets/img/progressDropdown.png new file mode 100644 index 0000000000000000000000000000000000000000..263e99ae4ea95c0ee50342e1fb3c69ca4c8da618 GIT binary patch literal 103408 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 literal 0 HcmV?d00001 diff --git a/platform/docs/docs/configuration/configurationFiles.md b/platform/docs/docs/configuration/configurationFiles.md index 8182f0c27..be62938f3 100644 --- a/platform/docs/docs/configuration/configurationFiles.md +++ b/platform/docs/docs/configuration/configurationFiles.md @@ -145,7 +145,7 @@ if auth headers are used, a preflight request is required. props: { leftPanels: [tracked.thumbnailList], rightPanels: [dicomSeg.panel, tracked.measurements], - rightPanelDefaultClosed: true, + rightPanelClosed: true, viewports: [ { namespace: tracked.viewport, @@ -242,6 +242,19 @@ Example usage:
This configuration would allow the user to build a dicomweb configuration from a GCP healthcare api path e.g. http://localhost:3000/projects/your-gcp-project/locations/us-central1/datasets/your-dataset/dicomStores/your-dicom-store/study/1.3.6.1.4.1.1234.5.2.1.1234.1234.123123123123123123123123123123 +:::note +You can stack multiple panel components on top of each other by providing an array of panel components in the `rightPanels` or `leftPanels` properties. + +For instance we can use + +``` +rightPanels: [[dicomSeg.panel, tracked.measurements], [dicomSeg.panel, tracked.measurements]] +``` + +This will result in two panels, one with `dicomSeg.panel` and `tracked.measurements` and the other with `dicomSeg.panel` and `tracked.measurements` stacked on top of each other. + +::: + ### More on Accept Header Configuration In the previous section we showed that you can modify the `acceptHeader` configuration to request specific dicom transfer syntax. By default diff --git a/platform/docs/docs/platform/extensions/modules/panel.md b/platform/docs/docs/platform/extensions/modules/panel.md index 8704206c1..9a9235cee 100644 --- a/platform/docs/docs/platform/extensions/modules/panel.md +++ b/platform/docs/docs/platform/extensions/modules/panel.md @@ -107,7 +107,7 @@ function modeFactory({ modeConfiguration }) { rightPanels: [ '@ohif/extension-measurement-tracking.panelModule.trackedMeasurements', ], - rightPanelDefaultClosed: true, + rightPanelClosed: true, viewports, }, }; @@ -147,5 +147,17 @@ const mode = { }; export default mode; +``` + +:::note +You can stack multiple panel components on top of each other by providing an array of panel components in the `rightPanels` or `leftPanels` properties. + +For instance we can use ``` +rightPanels: [[dicomSeg.panel, tracked.measurements], [dicomSeg.panel, tracked.measurements]] +``` + +This will result in two panels, one with `dicomSeg.panel` and `tracked.measurements` and the other with `dicomSeg.panel` and `tracked.measurements` stacked on top of each other. + +::: diff --git a/platform/docs/docs/platform/modes/index.md b/platform/docs/docs/platform/modes/index.md index 35678edc7..aded22bd0 100644 --- a/platform/docs/docs/platform/modes/index.md +++ b/platform/docs/docs/platform/modes/index.md @@ -362,7 +362,6 @@ function modeFactory() { // exports ``` -### Toolbar @@ -398,3 +397,17 @@ Use the provided `cli` to add/remove/install/uninstall modes. Read more [here](. ::: The final registration and import of the modes happen inside a non-tracked file `pluginImport.js` (this file is also for internal use only). + + +:::note +You can stack multiple panel components on top of each other by providing an array of panel components in the `rightPanels` or `leftPanels` properties. + +For instance we can use + +``` +rightPanels: [[dicomSeg.panel, tracked.measurements], [dicomSeg.panel, tracked.measurements]] +``` + +This will result in two panels, one with `dicomSeg.panel` and `tracked.measurements` and the other with `dicomSeg.panel` and `tracked.measurements` stacked on top of each other. + +::: diff --git a/platform/docs/docs/platform/modes/routes.md b/platform/docs/docs/platform/modes/routes.md index cc5c722bc..4f4130164 100644 --- a/platform/docs/docs/platform/modes/routes.md +++ b/platform/docs/docs/platform/modes/routes.md @@ -274,6 +274,19 @@ layoutTemplate: ({ location, servicesManager }) => { */ ``` +:::note +You can stack multiple panel components on top of each other by providing an array of panel components in the `rightPanels` or `leftPanels` properties. + +For instance we can use + +``` +rightPanels: [[dicomSeg.panel, tracked.measurements], [dicomSeg.panel, tracked.measurements]] +``` + +This will result in two panels, one with `dicomSeg.panel` and `tracked.measurements` and the other with `dicomSeg.panel` and `tracked.measurements` stacked on top of each other. + +::: + ## FAQ > What is the difference between `onModeEnter` and `route.init` diff --git a/platform/docs/docs/platform/services/data/WorkflowStepService.md b/platform/docs/docs/platform/services/data/WorkflowStepService.md new file mode 100644 index 000000000..7070cade4 --- /dev/null +++ b/platform/docs/docs/platform/services/data/WorkflowStepService.md @@ -0,0 +1,174 @@ +--- +sidebar_position: 9 +sidebar_label: WorkflowStep Service +--- + +# Workflow Step Service + +This service allows you to manage your workflow in smaller steps. It provides a structured way to define and navigate through different stages +or phases of a larger process or workflow. Each step can have its own configuration, layout, toolbar buttons, and other settings tailored to the specific requirements of that stage. + +## Anatomy of a Workflow Step + +The anatomy of a workflow step refers to the different components or properties that define and configure each individual step within the workflow. Each step can be customized with various settings to tailor the user interface, available tools, and behavior of the application for that specific stage of the workflow. Here are the key components that make up a workflow step: + +- `id`: A unique identifier for the step +- `name`: A human-readable name or title for the step, which can be displayed in the user interface to help users understand the current stage of the workflow. +- `hangingProtocol`: The hanging protocol configuration specifies the protocol and stage ID to be used for displaying the images. This ensures that the appropriate data viewports and presentation are used for the current workflow step. +- `layout`: The layout configuration defines the arrangement and visibility of various panels or viewports within the application's user interface for the specific step. This can include specifying which panels should be visible on the left or right side of the screen, as well as any options for panel visibility or behavior. +- `toolbarButtons`: Each step can define a set of toolbar buttons that should be available and displayed in the application's toolbar during that step. Remember the button definitions should already be registered to toolbarService beforehand, here we are just referencing the buttons id in each section. +- `info` : An optional description or additional information about the current workflow step can be provided. which +will be displayed as tooltip in the UI. + +- Step Callbacks or Commands: Some workflow steps may require specific actions or commands to be executed when the step is entered or exited. These callbacks or commands can be defined within the step configuration and can be used to update the application's state, perform data processing, or trigger other relevant actions. For instance you have access to `onEnter` hook to run a command right after the step is entered. + +For instance, a simplified example of our pre-clinical 4D workflow steps configuration might look like this: + +```js +const dynamicVolume = { + sopClassHandler: + "@ohif/extension-cornerstone-dynamic-volume.sopClassHandlerModule.dynamic-volume", + leftPanel: + "@ohif/extension-cornerstone-dynamic-volume.panelModule.dynamic-volume", + toolBox: + "@ohif/extension-cornerstone-dynamic-volume.panelModule.dynamic-toolbox", + export: + "@ohif/extension-cornerstone-dynamic-volume.panelModule.dynamic-export", +} + +const cs3d = { + segmentation: + "@ohif/extension-cornerstone-dicom-seg.panelModule.panelSegmentation", +} + + + +const steps = [ + { + id: "dataPreparation", + name: "Data Preparation", + layout: { + panels: { + left: [dynamicVolume.leftPanel], + }, + }, + toolbarButtons: { + buttonSection: "primary", + buttons: ["MeasurementTools", "Zoom", "WindowLevel", "Crosshairs", "Pan"], + }, + hangingProtocol: { + protocolId: "default4D", + stageId: "dataPreparation", + }, + info: "In the Data Preparation step...", + }, + { + id: "roiQuantification", + name: "ROI Quantification", + layout: { + panels: { + left: [dynamicVolume.leftPanel], + right: [ + [dynamicVolume.toolBox, cs3d.segmentation, dynamicVolume.export], + ], + }, + options: { + leftPanelClosed: false, + rightPanelClosed: false, + }, + }, + toolbarButtons: [ + { + buttonSection: "primary", + buttons: [ + "MeasurementTools", + "Zoom", + "WindowLevel", + "Crosshairs", + "Pan", + ], + }, + { + buttonSection: "dynamic-toolbox", + buttons: ["BrushTools", "RectangleROIStartEndThreshold"], + }, + ], + hangingProtocol: { + protocolId: "default4D", + stageId: "roiQuantification", + }, + info: "The ROI quantification step ...", + }, + { + id: "kineticAnalysis", + name: "Kinetic Analysis", + layout: { + panels: { + left: [dynamicVolume.leftPanel], + right: [], + }, + }, + toolbarButtons: { + buttonSection: "primary", + buttons: ["MeasurementTools", "Zoom", "WindowLevel", "Crosshairs", "Pan"], + }, + hangingProtocol: { + protocolId: "default4D", + stageId: "kineticAnalysis", + }, + onEnter: [ + { + commandName: "updateSegmentationsChartDisplaySet", + options: { servicesManager }, + }, + ], + info: "The Kinetic Analysis step ...", + }, +] + +``` + +## Integration + +After you have defined your workflow steps, you can integrate them into your application by using the `workflowStepsService`. + +These steps should be called on `onSetupRouteComplete` in your mode factory. + + +Note: onModeEnter is too soon to call these steps as the mode is not yet fully initialized. + + +```js +onSetupRouteComplete: ({ servicesManager }) => { + workflowStepsService.addWorkflowSteps(workflowSettings.steps); + workflowStepsService.setActiveWorkflowStep(workflowSettings.steps[0].id); +}, +``` + +check out the `modes/preclinical-4d/src/index.tsx` for a complete example. + + +## User Interface + +We have developed a simple dropdown UI element that you can use to navigate between the different steps of your workflow. This dropdown can be added to the toolbar like below: + +```js +toolbarService.addButtons([ + { + id: 'ProgressDropdown', + uiType: 'ohif.progressDropdown', + }, +]) +toolbarService.createButtonSection('secondary', ['ProgressDropdown']); +``` + +It will appear in the `secondary` location in the toolbar. + +![alt text](../../../assets/img/progressDropdown.png) + +:::note +if you like to place the progressbar in a different location, you can use the Toolbox component +to create a button section and place the progress bar there. + +Read more in the [Toolbar module](../../extensions//modules/toolbar.md) +::: diff --git a/platform/ui/CHANGELOG.md b/platform/ui/CHANGELOG.md index 6e9843ebf..ce16ce187 100644 --- a/platform/ui/CHANGELOG.md +++ b/platform/ui/CHANGELOG.md @@ -996,7 +996,9 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline **Note:** Version bump only for package @ohif/ui +# [3.7.0-beta.69](https://github.com/OHIF/Viewers/compare/v3.7.0-beta.68...v3.7.0-beta.69) (2023-09-11) +**Note:** Version bump only for package @ohif/ui @@ -1037,6 +1039,7 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline **Note:** Version bump only for package @ohif/ui +**Note:** Version bump only for package @ohif/ui @@ -1044,9 +1047,11 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline **Note:** Version bump only for package @ohif/ui +# [3.7.0-beta.63](https://github.com/OHIF/Viewers/compare/v3.7.0-beta.62...v3.7.0-beta.63) (2023-09-01) +* **grid:** remove viewportIndex and only rely on viewportId ([#3591](https://github.com/OHIF/Viewers/issues/3591)) ([4c6ff87](https://github.com/OHIF/Viewers/commit/4c6ff873e887cc30ffc09223f5cb99e5f94c9cdd)) # [3.7.0-beta.66](https://github.com/OHIF/Viewers/compare/v3.7.0-beta.65...v3.7.0-beta.66) (2023-09-06) @@ -1054,49 +1059,64 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline - - -# [3.7.0-beta.65](https://github.com/OHIF/Viewers/compare/v3.7.0-beta.64...v3.7.0-beta.65) (2023-09-06) - - -### Features - -* **ImageOverlayViewerTool:** add ImageOverlayViewer tool that can render image overlay (pixel overlay) of the DICOM images ([#3163](https://github.com/OHIF/Viewers/issues/3163)) ([69115da](https://github.com/OHIF/Viewers/commit/69115da06d2d437b57e66608b435bb0bc919a90f)) - - - - - -# [3.7.0-beta.64](https://github.com/OHIF/Viewers/compare/v3.7.0-beta.63...v3.7.0-beta.64) (2023-09-05) - -**Note:** Version bump only for package @ohif/ui - - - - - -# [3.7.0-beta.63](https://github.com/OHIF/Viewers/compare/v3.7.0-beta.62...v3.7.0-beta.63) (2023-09-01) - - -### Features - -* **grid:** remove viewportIndex and only rely on viewportId ([#3591](https://github.com/OHIF/Viewers/issues/3591)) ([4c6ff87](https://github.com/OHIF/Viewers/commit/4c6ff873e887cc30ffc09223f5cb99e5f94c9cdd)) - - - - - # Change Log All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. -# [3.7.0-beta.62](https://github.com/OHIF/Viewers/compare/v3.7.0-beta.61...v3.7.0-beta.62) (2023-08-30) +# [3.7.0-beta.65](https://github.com/OHIF/Viewers/compare/v3.7.0-beta.64...v3.7.0-beta.65) (2023-09-06) **Note:** Version bump only for package @ohif/ui # [3.7.0-beta.61](https://github.com/OHIF/Viewers/compare/v3.7.0-beta.60...v3.7.0-beta.61) (2023-08-29) +* **ImageOverlayViewerTool:** add ImageOverlayViewer tool that can render image overlay (pixel overlay) of the DICOM images ([#3163](https://github.com/OHIF/Viewers/issues/3163)) ([69115da](https://github.com/OHIF/Viewers/commit/69115da06d2d437b57e66608b435bb0bc919a90f)) + +# [3.7.0-beta.60](https://github.com/OHIF/Viewers/compare/v3.7.0-beta.59...v3.7.0-beta.60) (2023-08-29) + +**Note:** Version bump only for package @ohif/ui + +# [3.7.0-beta.59](https://github.com/OHIF/Viewers/compare/v3.7.0-beta.58...v3.7.0-beta.59) (2023-08-29) + +**Note:** Version bump only for package @ohif/ui + +# [3.7.0-beta.64](https://github.com/OHIF/Viewers/compare/v3.7.0-beta.63...v3.7.0-beta.64) (2023-09-05) + +**Note:** Version bump only for package @ohif/ui + + + +**Note:** Version bump only for package @ohif/ui + +## [1.4.4](https://github.com/OHIF/Viewers/compare/@ohif/ui@1.4.3...@ohif/ui@1.4.4) (2020-05-04) + +# [3.7.0-beta.63](https://github.com/OHIF/Viewers/compare/v3.7.0-beta.62...v3.7.0-beta.63) (2023-09-01) + +- 🐛 Proper error handling for derived display sets + ([#1708](https://github.com/OHIF/Viewers/issues/1708)) + ([5b20d8f](https://github.com/OHIF/Viewers/commit/5b20d8f323e4b3ef9988f2f2ab672d697b6da409)) + +### Features + +* **grid:** remove viewportIndex and only rely on viewportId ([#3591](https://github.com/OHIF/Viewers/issues/3591)) ([4c6ff87](https://github.com/OHIF/Viewers/commit/4c6ff873e887cc30ffc09223f5cb99e5f94c9cdd)) + + + +## [1.4.2](https://github.com/OHIF/Viewers/compare/@ohif/ui@1.4.1...@ohif/ui@1.4.2) (2020-04-06) + +**Note:** Version bump only for package @ohif/ui + +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [3.7.0-beta.62](https://github.com/OHIF/Viewers/compare/v3.7.0-beta.61...v3.7.0-beta.62) (2023-08-30) + +# [1.4.0](https://github.com/OHIF/Viewers/compare/@ohif/ui@1.3.3...@ohif/ui@1.4.0) (2020-03-13) + +# [3.7.0-beta.61](https://github.com/OHIF/Viewers/compare/v3.7.0-beta.60...v3.7.0-beta.61) (2023-08-29) + **Note:** Version bump only for package @ohif/ui # [3.7.0-beta.60](https://github.com/OHIF/Viewers/compare/v3.7.0-beta.59...v3.7.0-beta.60) (2023-08-29) @@ -1139,7 +1159,9 @@ All notable changes to this project will be documented in this file. See ## [1.4.2](https://github.com/OHIF/Viewers/compare/@ohif/ui@1.4.1...@ohif/ui@1.4.2) (2020-04-06) -**Note:** Version bump only for package @ohif/ui +- Combined Hotkeys for special characters + ([#1233](https://github.com/OHIF/Viewers/issues/1233)) + ([2f30e7a](https://github.com/OHIF/Viewers/commit/2f30e7a821a238144c49c56f37d8e5565540b4bd)) ## [1.4.1](https://github.com/OHIF/Viewers/compare/@ohif/ui@1.4.0...@ohif/ui@1.4.1) (2020-03-17) diff --git a/platform/ui/package.json b/platform/ui/package.json index efe72cdcf..d6845eafd 100644 --- a/platform/ui/package.json +++ b/platform/ui/package.json @@ -36,6 +36,13 @@ "@testing-library/react-hooks": "^3.2.1", "browser-detect": "^0.2.28", "classnames": "^2.3.2", + "d3-array": "3", + "d3-axis": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-zoom": "3", "lodash.debounce": "4.0.8", "moment": "2.29.4", "mousetrap": "^1.6.5", @@ -57,14 +64,14 @@ }, "devDependencies": { "@babel/core": "^7.23.2", - "@storybook/addon-actions": "^7.2.2", - "@storybook/addon-docs": "^7.2.2", - "@storybook/addon-essentials": "^7.2.2", - "@storybook/addon-links": "^7.2.2", - "@storybook/cli": "^7.2.2", - "@storybook/react": "^7.2.2", - "@storybook/react-webpack5": "^7.2.2", - "@storybook/source-loader": "^7.2.2", + "@storybook/addon-actions": "^7.6.10", + "@storybook/addon-docs": "^7.6.10", + "@storybook/addon-essentials": "^7.6.10", + "@storybook/addon-links": "^7.6.10", + "@storybook/cli": "^7.6.10", + "@storybook/react": "^7.6.10", + "@storybook/react-webpack5": "^7.6.10", + "@storybook/source-loader": "^7.6.10", "autoprefixer": "^10.4.14", "babel-loader": "^9.1.2", "dotenv-webpack": "^8.0.1", @@ -72,7 +79,7 @@ "postcss-loader": "^7.2.4", "prop-types": "^15.8.1", "remark-gfm": "^3.0.1", - "storybook": "^7.2.2", + "storybook": "^7.6.10", "tailwindcss": "3.2.4" } } diff --git a/platform/ui/src/assets/icons/arrow-right.svg b/platform/ui/src/assets/icons/arrow-right.svg new file mode 100644 index 000000000..307f8ffdf --- /dev/null +++ b/platform/ui/src/assets/icons/arrow-right.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/platform/ui/src/assets/icons/tool-brush.svg b/platform/ui/src/assets/icons/tool-brush.svg new file mode 100644 index 000000000..b00be0fe5 --- /dev/null +++ b/platform/ui/src/assets/icons/tool-brush.svg @@ -0,0 +1,22 @@ + + + + + diff --git a/platform/ui/src/assets/icons/tool-eraser.svg b/platform/ui/src/assets/icons/tool-eraser.svg new file mode 100644 index 000000000..4c81a34a5 --- /dev/null +++ b/platform/ui/src/assets/icons/tool-eraser.svg @@ -0,0 +1,21 @@ + + + + + + + diff --git a/platform/ui/src/assets/icons/tool-paint-fill.svg b/platform/ui/src/assets/icons/tool-paint-fill.svg new file mode 100644 index 000000000..4e671c2b9 --- /dev/null +++ b/platform/ui/src/assets/icons/tool-paint-fill.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/platform/ui/src/assets/icons/tool-scissor-circle.svg b/platform/ui/src/assets/icons/tool-scissor-circle.svg new file mode 100644 index 000000000..58b996572 --- /dev/null +++ b/platform/ui/src/assets/icons/tool-scissor-circle.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/platform/ui/src/assets/icons/tool-scissor-rect.svg b/platform/ui/src/assets/icons/tool-scissor-rect.svg new file mode 100644 index 000000000..b8b449878 --- /dev/null +++ b/platform/ui/src/assets/icons/tool-scissor-rect.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + diff --git a/platform/ui/src/components/ActionButtons/ActionButtons.tsx b/platform/ui/src/components/ActionButtons/ActionButtons.tsx new file mode 100644 index 000000000..ae58b985b --- /dev/null +++ b/platform/ui/src/components/ActionButtons/ActionButtons.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { Button, ButtonEnums } from '../../components'; + +function ActionButtons({ actions, disabled, t }) { + return ( + + {actions.map((action, index) => ( + + ))} + + ); +} + +ActionButtons.propTypes = { + actions: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string.isRequired, + onClick: PropTypes.func.isRequired, + disabled: PropTypes.bool, + }) + ).isRequired, + disabled: PropTypes.bool, +}; + +ActionButtons.defaultProps = { + disabled: false, +}; + +export default ActionButtons; diff --git a/platform/ui/src/components/ActionButtons/index.ts b/platform/ui/src/components/ActionButtons/index.ts new file mode 100644 index 000000000..d3cc681b9 --- /dev/null +++ b/platform/ui/src/components/ActionButtons/index.ts @@ -0,0 +1,3 @@ +import ActionButtons from './ActionButtons'; + +export default ActionButtons; diff --git a/platform/ui/src/components/ButtonGroup/ButtonGroup.tsx b/platform/ui/src/components/ButtonGroup/ButtonGroup.tsx index 916b72ad0..a8f89d249 100644 --- a/platform/ui/src/components/ButtonGroup/ButtonGroup.tsx +++ b/platform/ui/src/components/ButtonGroup/ButtonGroup.tsx @@ -9,6 +9,7 @@ const ButtonGroup = ({ orientation = ButtonEnums.orientation.horizontal, activeIndex: defaultActiveIndex = 0, onActiveIndexChange, + separated = false, disabled = false, }) => { const [activeIndex, setActiveIndex] = useState(defaultActiveIndex); @@ -28,33 +29,62 @@ const ButtonGroup = ({ }; const wrapperClasses = classnames( - 'items-stretch inline-flex', + `${separated ? '' : 'inline-flex'}`, orientationClasses[orientation], className ); return ( -
- {Children.map(children, (child, index) => { - if (React.isValidElement(child)) { - return cloneElement(child, { - key: index, - className: classnames( - 'rounded-[4px] px-2 py-1 text-center', - index === activeIndex - ? 'bg-customblue-40 text-white' - : 'text-primary-active bg-black', - child.props.className, - disabled ? 'ohif-disabled' : '' - ), - onClick: e => { - child.props.onClick && child.props.onClick(e); - handleButtonClick(index); - }, - }); - } - return child; +
+ {!separated && + Children.map(children, (child, index) => { + if (React.isValidElement(child)) { + return cloneElement(child, { + key: index, + className: classnames( + 'rounded-[4px] px-2 py-1', + index === activeIndex + ? 'bg-customblue-40 text-white' + : 'text-primary-active bg-black', + child.props.className, + disabled ? 'ohif-disabled' : '' + ), + onClick: e => { + child.props.onClick && child.props.onClick(e); + handleButtonClick(index); + }, + }); + } + return child; + })} + {separated && ( +
+ {Children.map(children, (child, index) => { + if (React.isValidElement(child)) { + return cloneElement(child, { + key: index, + className: classnames( + 'rounded-[4px] px-2 py-1', + index === activeIndex + ? 'bg-customblue-40 text-white' + : 'text-primary-active bg-black border-secondary-light rounded-[5px] border', + child.props.className, + disabled ? 'ohif-disabled' : '' + ), + onClick: e => { + child.props.onClick && child.props.onClick(e); + handleButtonClick(index); + }, + }); + } + return child; + })} +
+ )}
); }; @@ -66,6 +96,7 @@ ButtonGroup.propTypes = { onActiveIndexChange: PropTypes.func, className: PropTypes.string, disabled: PropTypes.bool, + separated: PropTypes.bool, }; export default ButtonGroup; diff --git a/platform/ui/src/components/CheckBox/CheckBox.tsx b/platform/ui/src/components/CheckBox/CheckBox.tsx index cb787fb34..1767abdec 100644 --- a/platform/ui/src/components/CheckBox/CheckBox.tsx +++ b/platform/ui/src/components/CheckBox/CheckBox.tsx @@ -1,7 +1,6 @@ import React, { useState, useCallback } from 'react'; -import PropTypes, { string } from 'prop-types'; -import Icon from '../Icon'; -import Typography from '../Typography'; +import PropTypes from 'prop-types'; +import { Icon, Typography } from '../../'; /** * REACT CheckBox component diff --git a/platform/ui/src/components/CinePlayer/CinePlayer.tsx b/platform/ui/src/components/CinePlayer/CinePlayer.tsx index 34afa7807..a628754cc 100644 --- a/platform/ui/src/components/CinePlayer/CinePlayer.tsx +++ b/platform/ui/src/components/CinePlayer/CinePlayer.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import PropTypes from 'prop-types'; import debounce from 'lodash.debounce'; @@ -7,7 +7,6 @@ import Tooltip from '../Tooltip'; import InputRange from '../InputRange'; import './CinePlayer.css'; -import classNames from 'classnames'; export type CinePlayerProps = { className: string; @@ -19,6 +18,12 @@ export type CinePlayerProps = { onFrameRateChange: (value: number) => void; onPlayPauseChange: (value: boolean) => void; onClose: () => void; + updateDynamicInfo?: () => void; + dynamicInfo?: { + timePointIndex: number; + numTimePoints: number; + label?: string; + }; }; const fpsButtonClassNames = @@ -31,12 +36,15 @@ const CinePlayer: React.FC = ({ maxFrameRate, stepFrameRate, frameRate: defaultFrameRate, + dynamicInfo = {}, onFrameRateChange, onPlayPauseChange, onClose, + updateDynamicInfo, }) => { + const isDynamic = !!dynamicInfo?.numTimePoints; const [frameRate, setFrameRate] = useState(defaultFrameRate); - const debouncedSetFrameRate = debounce(onFrameRateChange, 300); + const debouncedSetFrameRate = useCallback(debounce(onFrameRateChange, 100), [onFrameRateChange]); const getPlayPauseIconName = () => (isPlaying ? 'icon-pause' : 'icon-play'); @@ -52,45 +60,92 @@ const CinePlayer: React.FC = ({ setFrameRate(defaultFrameRate); }, [defaultFrameRate]); + const handleTimePointChange = useCallback( + (newIndex: number) => { + if (isDynamic && dynamicInfo) { + // Here, you would update the component's state or context that controls the current time point index + // For demonstration, assuming a hypothetical function that updates the time point index + updateDynamicInfo({ + ...dynamicInfo, + timePointIndex: newIndex, + }); + } + }, + [isDynamic, dynamicInfo] + ); + return ( -
+ {isDynamic && dynamicInfo && ( + )} - > - onPlayPauseChange(!isPlaying)} - /> - +
-
+ onPlayPauseChange(!isPlaying)} + /> + {isDynamic && dynamicInfo && ( +
+ {/* Add Tailwind classes for monospace font and center alignment */} +
+ {dynamicInfo.timePointIndex}{' '} + {`/${dynamicInfo.numTimePoints}`} +
+
{dynamicInfo.label}
+
+ )} + +
handleSetFrameRate(frameRate - 1)} >
-
- {`${frameRate} FPS`} -
+ + } + > +
+
+ {`${frameRate} `} + {' FPS'} +
+
+
+
handleSetFrameRate(frameRate + 1)} @@ -98,12 +153,12 @@ const CinePlayer: React.FC = ({
- - + +
); }; @@ -119,6 +174,8 @@ CinePlayer.defaultProps = { onPlayPauseChange: noop, onFrameRateChange: noop, onClose: noop, + isDynamic: false, + dynamicInfo: {}, }; CinePlayer.propTypes = { @@ -134,6 +191,12 @@ CinePlayer.propTypes = { onPlayPauseChange: PropTypes.func, onFrameRateChange: PropTypes.func, onClose: PropTypes.func, + isDynamic: PropTypes.bool, + dynamicInfo: PropTypes.shape({ + timePointIndex: PropTypes.number, + numTimePoints: PropTypes.number, + label: PropTypes.string, + }), }; export default CinePlayer; diff --git a/platform/ui/src/components/CinePlayer/__stories__/cinePlayer.stories.mdx b/platform/ui/src/components/CinePlayer/__stories__/cinePlayer.stories.mdx new file mode 100644 index 000000000..9f2ea521c --- /dev/null +++ b/platform/ui/src/components/CinePlayer/__stories__/cinePlayer.stories.mdx @@ -0,0 +1,57 @@ +import { CinePlayer } from '../../../components'; +import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs'; + +export const argTypes = { + component: CinePlayer, + title: 'Components/CinePlayer', +}; + + + +export const CinePlayerTemplate = args => ( +
+
+ +
+
+); + + + +- [Overview](#overview) +- [Props](#props) +- [Contribute](#contribute) + +## Overview + +CinePlayer is a component that allows you to use as a boolean value + + + + {CinePlayerTemplate.bind({})} + + + +## Props + + + +## Contribute + +
diff --git a/platform/ui/src/components/Header/Header.tsx b/platform/ui/src/components/Header/Header.tsx index 5d4cd63e9..2ba958b08 100644 --- a/platform/ui/src/components/Header/Header.tsx +++ b/platform/ui/src/components/Header/Header.tsx @@ -20,6 +20,8 @@ function Header({ WhiteLabeling, showPatientInfo = PatientInfoVisibility.VISIBLE_COLLAPSED, servicesManager, + Secondary, + appConfig, ...props }): ReactNode { const { t } = useTranslation('Header'); @@ -58,14 +60,17 @@ function Header({
- {/*
{future left component}
*/} +
{Secondary}
{children}
{(showPatientInfo === PatientInfoVisibility.VISIBLE || showPatientInfo === PatientInfoVisibility.VISIBLE_COLLAPSED) && ( - + )}
diff --git a/platform/ui/src/components/HeaderPatientInfo/HeaderPatientInfo.tsx b/platform/ui/src/components/HeaderPatientInfo/HeaderPatientInfo.tsx index ec665a7a9..21a43f984 100644 --- a/platform/ui/src/components/HeaderPatientInfo/HeaderPatientInfo.tsx +++ b/platform/ui/src/components/HeaderPatientInfo/HeaderPatientInfo.tsx @@ -2,7 +2,6 @@ import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; import { Icon } from '@ohif/ui'; import { utils } from '@ohif/core'; -import { useAppConfig } from '@state'; const { formatDate, formatPN } = utils; @@ -70,8 +69,7 @@ function usePatientInfo(servicesManager) { return { patientInfo, isMixedPatients }; } -function HeaderPatientInfo({ servicesManager }) { - const [appConfig] = useAppConfig(); +function HeaderPatientInfo({ servicesManager, appConfig }) { const initialExpandedState = appConfig.showPatientInfo === 'visible'; const [expanded, setExpanded] = useState(initialExpandedState); const { patientInfo, isMixedPatients } = usePatientInfo(servicesManager); diff --git a/platform/ui/src/components/Icon/getIcon.js b/platform/ui/src/components/Icon/getIcon.js index 7bd5c5042..c2edee38d 100644 --- a/platform/ui/src/components/Icon/getIcon.js +++ b/platform/ui/src/components/Icon/getIcon.js @@ -3,6 +3,7 @@ import React from 'react'; import arrowDown from './../../assets/icons/arrow-down.svg'; import arrowLeft from './../../assets/icons/arrow-left.svg'; +import arrowRight from './../../assets/icons/arrow-right.svg'; import arrowLeftSmall from './../../assets/icons/arrow-left-small.svg'; import arrowRightSmall from './../../assets/icons/arrow-right-small.svg'; import calendar from './../../assets/icons/calendar.svg'; @@ -119,6 +120,11 @@ import toolCalibration from './../../assets/icons/tool-calibrate.svg'; import toolFreehand from './../../assets/icons/tool-freehand.svg'; import toolFreehandPolygon from './../../assets/icons/tool-freehand-polygon.svg'; import toolPolygon from './../../assets/icons/tool-polygon.svg'; +import toolBrush from './../../assets/icons/tool-brush.svg'; +import toolEraser from './../../assets/icons/tool-eraser.svg'; +import toolScissorRect from './../../assets/icons/tool-scissor-rect.svg'; +import toolScissorCircle from './../../assets/icons/tool-scissor-circle.svg'; +import toolPaintFill from './../../assets/icons/tool-paint-fill.svg'; import editPatient from './../../assets/icons/edit-patient.svg'; import panelGroupMore from './../../assets/icons/panel-group-more.svg'; import panelGroupOpenClose from './../../assets/icons/panel-group-open-close.svg'; @@ -216,6 +222,7 @@ import investigationalUse from './../../assets/icons/illustration-investigationa const ICONS = { 'arrow-down': arrowDown, 'arrow-left': arrowLeft, + 'arrow-right': arrowRight, 'arrow-left-small': arrowLeftSmall, 'arrow-right-small': arrowRightSmall, calendar: calendar, diff --git a/platform/ui/src/components/InputDoubleRange/InputDoubleRange.tsx b/platform/ui/src/components/InputDoubleRange/InputDoubleRange.tsx index b8aadf26d..d5ea68165 100644 --- a/platform/ui/src/components/InputDoubleRange/InputDoubleRange.tsx +++ b/platform/ui/src/components/InputDoubleRange/InputDoubleRange.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import classNames from 'classnames'; import { InputNumber } from '../../components'; // Import InputNumber component import './InputDoubleRange.css'; @@ -19,6 +19,7 @@ type InputDoubleRangeProps = { trackColor?: string; allowNumberEdit?: boolean; showAdjustmentArrows?: boolean; + allowOutOfRange?: boolean; }; const InputDoubleRange: React.FC = ({ @@ -36,6 +37,7 @@ const InputDoubleRange: React.FC = ({ labelPosition, trackColor, allowNumberEdit, + allowOutOfRange = false, showAdjustmentArrows, }) => { // Set initial thumb positions as percentages @@ -55,7 +57,15 @@ const InputDoubleRange: React.FC = ({ updatedRangeValue[index] = newValues; } - const calculatePercentage = value => ((value - minValue) / (maxValue - minValue)) * 100; + const calculatePercentage = value => { + if (value < minValue) { + return 0; + } + if (value > maxValue) { + return 100; + } + return ((value - minValue) / (maxValue - minValue)) * 100; + }; const newPercentageStart = calculatePercentage(updatedRangeValue[0]); const newPercentageEnd = calculatePercentage(updatedRangeValue[1]); @@ -73,17 +83,22 @@ const InputDoubleRange: React.FC = ({ const LabelOrEditableNumber = (val, index) => { return allowNumberEdit ? ( - { - updateRangeValues(newValue, index); - }} - step={step} - labelClassName="text-white" - showAdjustmentArrows={showAdjustmentArrows} - /> + // the pl-[2px] class is used to align the thumb so that it doesn't + // go over the label when the value is full, not sure what is wrong + // with the implementation, we need to fix it properly +
+ { + updateRangeValues(newValue, index); + }} + step={step} + labelClassName={classNames(labelClassName ?? 'text-white')} + showAdjustmentArrows={showAdjustmentArrows} + /> +
) : ( {val} @@ -138,31 +153,52 @@ const InputDoubleRange: React.FC = ({ const newValue = Math.round(((x / rect.width) * (maxValue - minValue) + minValue) / step) * step; - // Make sure newValue is within [minValue, maxValue] - const clampedValue = Math.min(Math.max(newValue, minValue), maxValue); + if (!allowOutOfRange) { + const clampedValue = Math.min(Math.max(newValue, minValue), maxValue); - // Ensure that left and right thumbs don't switch positions - if (selectedThumbValue === 0 && clampedValue >= rangeValue[1]) { - return; - } - if (selectedThumbValue === 1 && clampedValue <= rangeValue[0]) { - return; + const updatedRangeValue = [...rangeValue]; + updatedRangeValue[selectedThumbValue] = clampedValue; + setRangeValue(updatedRangeValue); + + onChange(updatedRangeValue); + + const percentage = Math.round(((clampedValue - minValue) / (maxValue - minValue)) * 100); + if (selectedThumbValue === 0) { + setPercentageStart(percentage); + } else { + setPercentageEnd(percentage); + } + } else { + const updatedRangeValue = [...rangeValue]; + updatedRangeValue[selectedThumbValue] = newValue; + setRangeValue(updatedRangeValue); + + onChange(updatedRangeValue); + + // Update the thumb position + const percentage = Math.round(((newValue - minValue) / (maxValue - minValue)) * 100); + if (percentage < 0) { + if (selectedThumbValue === 0) { + setPercentageStart(0); + } else { + setPercentageEnd(0); + } + } else if (percentage > 100) { + if (selectedThumbValue === 0) { + setPercentageStart(100); + } else { + setPercentageEnd(100); + } + } else { + if (selectedThumbValue === 0) { + setPercentageStart(percentage); + } else { + setPercentageEnd(percentage); + } + } } // Update the correct values in the rangeValue array - const updatedRangeValue = [...rangeValue]; - updatedRangeValue[selectedThumbValue] = clampedValue; - setRangeValue(updatedRangeValue); - - onChange(updatedRangeValue); - - // Update the thumb position - const percentage = Math.round(((clampedValue - minValue) / (maxValue - minValue)) * 100); - if (selectedThumbValue === 0) { - setPercentageStart(percentage); - } else { - setPercentageEnd(percentage); - } }; // Calculate the range values percentages for gradient background diff --git a/platform/ui/src/components/InputNumber/InputNumber.tsx b/platform/ui/src/components/InputNumber/InputNumber.tsx index b3d9bbec5..bb29b4ca9 100644 --- a/platform/ui/src/components/InputNumber/InputNumber.tsx +++ b/platform/ui/src/components/InputNumber/InputNumber.tsx @@ -5,6 +5,9 @@ import './InputNumber.css'; import Label from '../Label'; import getMaxDigits from '../../utils/getMaxDigits'; +const arrowHorizontalClassName = + 'cursor-pointer text-primary-active active:text-primary-light hover:opacity-70 w-4 flex items-center justify-center'; + /** * React Number Input component' * it has two props, value and onChange @@ -16,6 +19,7 @@ import getMaxDigits from '../../utils/getMaxDigits'; const sizesClasses = { sm: 'w-[45px] h-[28px]', + md: 'w-[58px] h-[28px]', lg: 'w-[206px] h-[35px]', }; @@ -25,11 +29,16 @@ const InputNumber: React.FC<{ minValue?: number; maxValue?: number; step?: number; - size?: 'sm' | 'lg'; + size?: 'sm' | 'lg' | 'md'; className?: string; labelClassName?: string; label?: string; showAdjustmentArrows?: boolean; + arrowsDirection: 'vertical' | 'horizontal'; + labelPosition?: 'left' | 'bottom' | 'right' | 'top'; + inputClassName?: string; + sizeClassName?: string; + inputContainerClassName?: string; }> = ({ value, onChange, @@ -38,19 +47,24 @@ const InputNumber: React.FC<{ size = 'sm', minValue = 0, maxValue = 100, - labelClassName, + labelClassName = 'text-aqua-pale text-[11px] mx-auto', label, showAdjustmentArrows = true, + arrowsDirection = 'vertical', + labelPosition = 'left', + inputClassName = 'text-white bg-primary-dark text-[14px]', + sizeClassName, + inputContainerClassName = 'bg-primary-dark border-secondary-light border rounded-[4px]', }) => { const [numberValue, setNumberValue] = useState(value); const [isFocused, setIsFocused] = useState(false); const maxDigits = getMaxDigits(maxValue, step); const inputWidth = Math.max(maxDigits * 10, showAdjustmentArrows ? 20 : 28); - const arrowWidth = showAdjustmentArrows ? 20 : 0; - const containerWidth = `${inputWidth + arrowWidth}px`; const decimalPlaces = Number.isInteger(step) ? 0 : step.toString().split('.')[1].length; + const sizeToUse = sizeClassName ? sizeClassName : sizesClasses[size]; + useEffect(() => { setNumberValue(value); }, [value]); @@ -95,21 +109,31 @@ const InputNumber: React.FC<{ const increment = () => updateValue(parseFloat(numberValue) + step); const decrement = () => updateValue(parseFloat(numberValue) - step); + const labelElement = label && ( +
- {label && ( -
+ {label && labelPosition === 'left' && labelElement} + {label && labelPosition === 'top' && labelElement}
-
+
+ {showAdjustmentArrows && arrowsDirection === 'horizontal' && ( +
decrement()} + > + +
+ )} - {showAdjustmentArrows && ( -
+ {showAdjustmentArrows && arrowsDirection === 'horizontal' && ( +
increment()} + > + +
+ )} + {showAdjustmentArrows && arrowsDirection === 'vertical' && ( +
+ {label && labelPosition === 'right' && labelElement} + {label && labelPosition === 'bottom' && labelElement}
); }; diff --git a/platform/ui/src/components/InputRange/InputRange.css b/platform/ui/src/components/InputRange/InputRange.css index 8e1df7a58..7c7e6c51a 100644 --- a/platform/ui/src/components/InputRange/InputRange.css +++ b/platform/ui/src/components/InputRange/InputRange.css @@ -4,17 +4,21 @@ input[type='range'] { input[type='range']::-webkit-slider-thumb { -webkit-appearance: none; - @apply border-primary-dark bg-primary-light border-[2px] border-solid; height: 13px; width: 13px; border-radius: 50%; + background-color: var(--thumb-inner-color); + border: 2px solid var(--thumb-outer-color); + cursor: pointer; } input[type='range']::-moz-range-thumb { - @apply border-primary-dark bg-primary-light border-[2px] border-solid; height: 13px; width: 13px; border-radius: 50%; + background-color: var(--thumb-inner-color); + border: 2px solid var(--thumb-outer-color); + cursor: pointer; } input[type='range']::-webkit-slider-runnable-track { @@ -31,3 +35,11 @@ input[type='range']::-moz-range-track { left: 0; right: 0; } + +input[type='range']::-webkit-slider-thumb { + pointer-events: all; +} + +input[type='range']::-moz-range-thumb { + pointer-events: all; +} diff --git a/platform/ui/src/components/InputRange/InputRange.tsx b/platform/ui/src/components/InputRange/InputRange.tsx index 4ae168712..bba2ba77f 100644 --- a/platform/ui/src/components/InputRange/InputRange.tsx +++ b/platform/ui/src/components/InputRange/InputRange.tsx @@ -10,7 +10,6 @@ import getMaxDigits from '../../utils/getMaxDigits'; * value is a number value * onChange is a function that will be called when the range input is changed * - * */ type Label = { @@ -31,9 +30,13 @@ type InputRangeProps = { labelVariant?: string; showLabel?: boolean; labelPosition?: string; - trackColor?: string; + leftColor?: string; + rightColor?: string; + thumbColor?: string; + thumbColorOuter?: string; allowNumberEdit?: boolean; showAdjustmentArrows?: boolean; + trackHeight?: string; labels?: Label[]; }; @@ -47,16 +50,18 @@ const InputRange: React.FC = ({ containerClassName, inputClassName, labelClassName, - labelVariant, showLabel = true, labelPosition = 'right', - trackColor, + leftColor = '#5acce6', + rightColor = '#3a3f99', + thumbColor = '#5acce6', + thumbColorOuter = '#090c29', + trackHeight = '3px', allowNumberEdit = false, showAdjustmentArrows = true, labels = [], }) => { const [rangeValue, setRangeValue] = useState(value); - const maxDigits = getMaxDigits(maxValue, step); const labelWidth = `${maxDigits * 10}px`; @@ -88,8 +93,7 @@ const InputRange: React.FC = ({ /> ) : ( - {rangeValue} - {unit} + {rangeValue.toFixed(maxDigits)} {unit} ); @@ -101,19 +105,21 @@ const InputRange: React.FC = ({ e.preventDefault(); }} > -
+
{showLabel && labelPosition === 'left' && (
{LabelOrEditableNumber}
)} -
classNames(baseClasses.Content, isExpanded ? 'block' : 'hidden'), }; -const SplitButton = ({ +const LegacySplitButton = ({ groupId, primary, secondary, @@ -103,7 +103,7 @@ const SplitButton = ({ disabled={!state.isExpanded} >
{ + const chartContainerRef = useRef(null); + const [d3SVGContainer, setD3SVGRef] = useState(null); + const [width, setWidth] = useState(0); + const [height, setHeight] = useState(0); + + useLayoutEffect(() => { + const chartContainer = chartContainerRef.current; + const containerWidth = chartContainer.offsetWidth; + const containerHeight = chartContainer.offsetHeight; + const d3Container = d3Selection + .create('svg') + .attr('viewBox', [0, 0, containerWidth, containerHeight]) + .style('max-width', `${containerWidth}px`) + .style('overflow', 'visible'); + + chartContainer.append(d3Container.node()); + + setD3SVGRef(d3Container); + setWidth(containerWidth); + setHeight(containerHeight); + }, [chartContainerRef]); + + useEffect(() => { + if (!d3SVGContainer) { + return; + } + + lineChart.addLineChartNode({ + d3SVGRef: d3SVGContainer, + axis, + series, + width, + height, + showAxisLabels, + showAxisGrid, + showLegend, + legendWidth, + transparentChartBackground, + }); + }, [ + d3SVGContainer, + axis, + series, + width, + height, + showAxisLabels, + showAxisGrid, + transparentChartBackground, + showLegend, + legendWidth, + ]); + + return ( +
+
+
+ ); +}; + +LineChart.defaultProps = { + showAxisLabels: true, + showAxisGrid: true, + showLegend: false, + legendWidth: 120, + transparentChartBackground: false, +}; + +LineChart.propTypes = { + title: PropTypes.string, + width: PropTypes.number, + height: PropTypes.number, + showAxisLabels: PropTypes.bool, + showAxisGrid: PropTypes.bool, + showLegend: PropTypes.bool, + legendWidth: PropTypes.number, + transparentChartBackground: PropTypes.bool, + containerClassName: PropTypes.string, + chartContainerClassName: PropTypes.string, +}; + +export default LineChart; diff --git a/platform/ui/src/components/LineChart/__stories__/lineChart.stories.mdx b/platform/ui/src/components/LineChart/__stories__/lineChart.stories.mdx new file mode 100644 index 000000000..7397d48f8 --- /dev/null +++ b/platform/ui/src/components/LineChart/__stories__/lineChart.stories.mdx @@ -0,0 +1,181 @@ +import LineChart from '../LineChart'; + +import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs'; +import { createComponentTemplate } from '../../../storybook/functions/create-component-story'; + +export const argTypes = { + component: LineChart, + title: 'Components/LineChart', +}; + + + +export const LineChartTemplate = args => ( +
+ +
+); + + + +- [Overview](#overview) +- [Props](#props) +- [Contribute](#contribute) + +## Overview + +LineChart is a component that... + + + + {LineChartTemplate.bind({})} + + + +## Props + + + +## Usage + +## Contribute + +
diff --git a/platform/ui/src/components/LineChart/d3LineChart/chart.ts b/platform/ui/src/components/LineChart/d3LineChart/chart.ts new file mode 100644 index 000000000..a8757ffb6 --- /dev/null +++ b/platform/ui/src/components/LineChart/d3LineChart/chart.ts @@ -0,0 +1,365 @@ +/** + * It adds attributes to root element from passed attributes list + * + * @param {object} root svg element to be changed + * @param {object} attributes list of attributes to be added on root element + * @modifies {root} + * + */ +const _addAttributes = (root, attributes = []) => { + for (const attr of attributes) { + const { key, value } = attr; + + if (key && value) { + if (key === 'class') { + root.classed(value, true); + } else { + root.attr(key, value); + } + } + } +}; + +/** + * It formats axis labels. It adds units if existing + * + * @param {Object} def + * @param {string} def.label string representing axis label + * @param {string} [def.unit] string representing axis unit + * @return {string} formatted label + */ +const _formatAxisLabel = ({ label, unit }) => { + let formattedLabel = label; + + if (unit) { + formattedLabel += `(${unit})`; + } + + return formattedLabel; +}; + +/** + * It appends chart svg element container to root element + * + * @param {object} root svg element to be changed + * @param {number} width width of container + * @param {number} height height of container + * @param {number} translateX value to translate on axis x + * @param {number} translateY value to translate on axis y + * @modifies {root} + * + * @return {object} appended chart container (svg element) + */ +const _addChartContainer = (root, width, height, translateX, translateY) => { + root.append('rect').attr('class', 'background').attr('width', width).attr('height', height); + + return root + .append('g') + .attr('width', width) + .attr('height', height) + .append('g') + .attr('transform', 'translate(' + translateX + ',' + translateY + ')'); +}; + +const _addLegend = (root, width, height, posX, posY) => { + const legendContainer = root + .append('g') + .attr('class', 'legend') + .attr('transform', 'translate(' + posX + ',' + posY + ')'); + + legendContainer + .append('rect') + .attr('class', 'legend-background') + .attr('width', width) + .attr('height', height); + + return legendContainer; +}; + +const _setLegendLabels = (root, labels, colors, itemHeight, itemWidth, textEllipses) => { + const yOffset = itemHeight / 2; + const textLeft = 20; + + // Add one dot in the legend for each + root + .selectAll('labelDots') + .data(labels) + .enter() + .append('circle') + .attr('cx', 10) + .attr('cy', (_d, i) => yOffset + i * itemHeight) + .attr('r', 5) + .style('fill', (_d, i) => colors[i]); + + root + .selectAll('labelText') + .data(labels) + .enter() + .append('text') + .attr('x', textLeft) + .attr('y', (_d, i) => yOffset + i * itemHeight) + .style('fill', (_d, i) => colors[i]) + .append('tspan') + .style('alignment-baseline', 'middle') + .text(d => d) + .each(textEllipses(itemWidth - textLeft, 1)); +}; + +/** + * It appends axis svg element to root element + * + * @param {object} root svg element to be changed + * @param {TimecoursePointDef} axisDef definition of given axis + * @param {number} posX position X to place content + * @param {number} posY position Y to place content + * @param {number} axisExtraAttrs list of extra attributes to be added on content + * @param {function} axisBuilder callback function to create axis graphics + * @param {boolean} showAxisLabels flag to display labels or not + * @param {number} labelPosX position X to place label content + * @param {number} labelPosY position Y to place label content + * @param {object} labelExtraAttrs list of extra attributes to be added on label content + * @modifies {root} + * + * @return {object} axis svg element + */ +const _addAxis = ( + root, + axisDef, + posX = 0, + posY = 0, + axisExtraAttrs = [], + axisBuilder, + showAxisLabels, + labelPosX, + labelPosY, + labelExtraAttrs = [], + showAxisGrid +) => { + const { type } = axisDef; + const showAxisGridClass = showAxisGrid ? 'grid' : ''; + const translateValue = `${posX}, ${posY}`; + const axisContent = root + .append('g') + .attr('class', `${type} ${showAxisGridClass} axis`) + .attr('transform', `translate(${translateValue})`); + + _addAttributes(axisContent, axisExtraAttrs); + axisContent.call(axisBuilder()); + + if (showAxisLabels) { + const label = _formatAxisLabel(axisDef); + // text label for the x axis + if (label) { + const labelContent = root + .append('text') + .attr('class', 'label axis') + .attr('x', labelPosX) + .attr('y', labelPosY) + .style('text-anchor', 'middle') + .text(label); + + _addAttributes(labelContent, labelExtraAttrs); + } + } + + return axisContent; +}; + +/** + * It scales axis graphics + * + * @param {object} sourceGraphicsXAxis original d3 graphics to be proccessed (x Axis) + * @param {object} sourceGraphicsYAxis original d3 graphics to be proccessed (y Axis) + * @param {object} xAxisGenerator d3 X axis generator + * @param {object} yAxisGenerator d3 Y axis generator + * @param {object} newXAxisScale d3 continuos scale for X Axis + * @param {object} newYAxisScale d3 continuos scale for Y Axis + * @modifies {sourceGraphicsXAxis|sourceGraphicsYAxis} + */ +const _scaleAxisGraphics = ( + sourceGraphicsXAxis, + sourceGraphicsYAxis, + xAxisGenerator, + yAxisGenerator, + newXAxisScale, + newYAxisScale +) => { + const newXAxis = xAxisGenerator.scale(newXAxisScale); + const newYAxis = yAxisGenerator.scale(newYAxisScale); + + sourceGraphicsXAxis.call(newXAxis); + sourceGraphicsYAxis.call(newYAxis); +}; + +/** + * It appends background svg element to root element and also a clip path for chart boundaries + * + * @param {object} root svg element to be changed + * @param {number} width content`s width + * @param {number} height content`s height + * @param {boolean} transparent background to be transparent or not + * @param {number} offset content`s clip offset + * @modifies {root} + * + */ +const _addChartClipPath = (root, width, height, transparent = false, offset = 6) => { + const translateOffset = -offset / 2; + root + .append('clipPath') + .attr('id', 'clip') + .append('rect') + .attr('transform', `translate(${translateOffset}, ${translateOffset})`) + .attr('width', width + offset) + .attr('height', height + offset); + + root + .append('rect') + .attr('class', 'background') + .attr('width', width) + .attr('height', height) + .classed('transparent', transparent); +}; + +const _addSeries = (root, seriesIndex, style) => { + return root + .append('g') + .attr('id', `series_${seriesIndex}`) + .attr('class', 'series') + .attr('stroke', style.color || '#ffffff'); +}; + +/** + * It appends line chart svg element to root element + * + * @param {object} root svg element to be changed + * @param {object} dataset dataset to tie to content + * @param {string} dAttrValue path d attribute + * @modifies {root} + * + * @return {object} svg line element line + */ +const _addLine = (root, dataset, dAttrValue) => { + return root + .append('path') + .attr('clip-path', 'url(#clip)') + .datum(dataset) + .attr('class', 'line') + .attr('d', dAttrValue); +}; + +/** + * It updates line chart svg element with new dataset and dAttrValue value + * + * @param {object} root svg element to be changed + * @param {object} dataset dataset to tie to content + * @param {string} dAttrValue path d attribute + * @modifies {root} + * + */ +const _updateLine = (root, dataset = [], dAttrValue) => { + const line = root.select('.line'); + + if (dataset && dataset.length > 0) { + line.datum(dataset); + } + + line.attr('d', dAttrValue); +}; + +/** + * It appends points svg element to root element + * + * @param {object} root svg element to be changed + * @param {object} dataset dataset to tie to content + * @param {number} cxAttrValue cx attribute + * @param {number} cyAttrValue cy attribute + * @modifies {root} + * + * @return {object} points svg element + * + */ +const _addPoints = (root, dataset, cxAttrValue, cyAttrValue) => { + return root + .selectAll('.dot') + .data(dataset) + .enter() + .append('circle') + .attr('id', (point, index) => { + return `point-${index}`; + }) + .attr('class', 'dot') + .attr('clip-path', 'url(#clip)') + .attr('cx', cxAttrValue) + .attr('cy', cyAttrValue) + .attr('r', 2); +}; + +/** + * It return points from root parent + * + * @param {object} root svg element to be changed + * @return {object} points svg element + * + */ +const _getPoints = root => { + return root.selectAll('.dot'); +}; + +/** + * It updates points svg element with new dataset, cxAttrValue and cyAttrValue + * + * @param {object} root svg element to be changed + * @param {object} dataset dataset to tie to content + * @param {number} cxAttrValue cx attribute + * @param {number} cyAttrValue cy attribute + * @modifies {root} + * + */ +const _updatePoints = (root, dataset = [], cxAttrValue, cyAttrValue) => { + const dot = root.selectAll('.dot'); + + dot.data(dataset).attr('cx', cxAttrValue).attr('cy', cyAttrValue); +}; + +/** + * It removes children nodes from root param. + * + * @param {object} root svg element to remove children content + * @modifies {root} + * + */ +const _removeChartContents = root => { + root.selectAll('*').remove(); +}; + +const chart = { + axis: { + addNode: _addAxis, + scaleGraphics: _scaleAxisGraphics, + }, + series: { + addNode: _addSeries, + }, + lines: { + addNode: _addLine, + updateNode: _updateLine, + }, + points: { + addNode: _addPoints, + updateNode: _updatePoints, + getPoints: _getPoints, + }, + background: { + addNode: _addChartClipPath, + }, + container: { + addNode: _addChartContainer, + }, + legend: { + addNode: _addLegend, + setLabels: _setLegendLabels, + }, + removeContents: _removeChartContents, +}; + +export default chart; diff --git a/platform/ui/src/components/LineChart/d3LineChart/events.ts b/platform/ui/src/components/LineChart/d3LineChart/events.ts new file mode 100644 index 000000000..b4a01b032 --- /dev/null +++ b/platform/ui/src/components/LineChart/d3LineChart/events.ts @@ -0,0 +1,149 @@ +import * as d3Shape from 'd3-shape'; +import * as d3Zoom from 'd3-zoom'; + +import chart from './chart'; + +const d3ZoomNativeEvents = [ + 'wheel.zoom', + 'mousedown.zoom', + 'dblclick.zoom', + 'touchstart.zoom', + 'touchmove.zoom', + 'touchend.zoom touchcancel.zoom', +]; +/** + * Object to override native zoom events. + * It defines a specific callback for a event name. + * + * @type {Object} zoomEvents + */ +const zoomEvents = { + 'dblclick.zoom': (root, zoom) => { + _resetZoom(root, zoom); + }, +}; + +/** + * It resets given root + * + * @param {object} root svg element to be reset + * @param {object} zoom d3 zoom object + * + * @modifies {root} + */ +const _resetZoom = (root, zoom) => { + if (root) { + root.transition().duration(750).call(zoom.transform, d3Zoom.zoomIdentity); + } +}; + +const DEFAULT_MAX_ZOOM_SCALE = 20; + +const _zoom = d3Zoom.zoom().scaleExtent([1, DEFAULT_MAX_ZOOM_SCALE]); + +const _bindZoom = ( + root, + gX, + gY, + xAxisScale, + yAxisScale, + xAxisGenerator, + yAxisGenerator, + parseXPoint, + parseYPoint, + datasets +) => { + _zoom.on('zoom', _zoomListener); // .filter(zoomEventsFilter); + + _removeListeners(); + _setListeners(); + + function _removeListeners() { + for (const eventName of d3ZoomNativeEvents) { + root.on(eventName, null); + } + } + + function _setListeners() { + const keys = Object.keys(zoomEvents); + + for (const key of keys) { + root.call(_zoom).on(key, zoomEvents[key].bind(undefined, root, _zoom)); + } + } + + function _zoomListener(event) { + const transformedXAxisScale = event.transform.rescaleX(xAxisScale); + const transformedYAxisScale = event.transform.rescaleY(yAxisScale); + + chart.axis.scaleGraphics( + gX, + gY, + xAxisGenerator, + yAxisGenerator, + transformedXAxisScale, + transformedYAxisScale + ); + + datasets.forEach((dataset, seriesIndex) => { + const seriesContainer = root.select(`#series_${seriesIndex}`); + // create line + const line = d3Shape + .line() + .x(parseXPoint(transformedXAxisScale)) + .y(parseYPoint(transformedYAxisScale)); + + chart.lines.updateNode(seriesContainer, dataset, line); + + chart.points.updateNode( + seriesContainer, + dataset, + parseXPoint(transformedXAxisScale), + parseYPoint(transformedYAxisScale) + ); + }); + } + + return _zoom; +}; + +const bindMouseEvents = ( + root, + gX, + gY, + xAxisScale, + yAxisScale, + xAxisGenerator, + yAxisGenerator, + parseXPoint, + parseYPoint, + datasets +) => { + if (!root) { + return; + } + + _bindZoom( + root, + gX, + gY, + xAxisScale, + yAxisScale, + xAxisGenerator, + yAxisGenerator, + parseXPoint, + parseYPoint, + datasets + ); +}; + +const events = { + bindMouseEvents, + external: { + resetZoom: root => { + _resetZoom(root, _zoom); + }, + }, +}; + +export default events; diff --git a/platform/ui/src/components/LineChart/d3LineChart/index.ts b/platform/ui/src/components/LineChart/d3LineChart/index.ts new file mode 100644 index 000000000..8cbc2f80f --- /dev/null +++ b/platform/ui/src/components/LineChart/d3LineChart/index.ts @@ -0,0 +1,3 @@ +import * as lineChart from './lineChart'; + +export { lineChart }; diff --git a/platform/ui/src/components/LineChart/d3LineChart/lineChart.ts b/platform/ui/src/components/LineChart/d3LineChart/lineChart.ts new file mode 100644 index 000000000..fc2ecd8aa --- /dev/null +++ b/platform/ui/src/components/LineChart/d3LineChart/lineChart.ts @@ -0,0 +1,272 @@ +import * as d3Array from 'd3-array'; +import * as d3Axis from 'd3-axis'; +import * as d3Scale from 'd3-scale'; +import * as d3ScaleChromatic from 'd3-scale-chromatic'; +import * as d3Selection from 'd3-selection'; +import * as d3Shape from 'd3-shape'; + +import chart from './chart'; +import events from './events'; + +const { + external: { resetZoom }, +} = events; + +/** + * @typedef TimecoursePoint It defines a tuple of timecourse value (time X intensity) + * @type {array} + * @property {number} 0 indicates x|y value on the pair (x,y) + * @property {number} 1 indicates x|y value on the pair (x,y) + */ + +/** + * @typedef TimecoursePointDef It defines the shape of a given TimecoursePoint Axis + * @type {object} + * @property {string} label label for given TimecoursePoint Axis + * @property {string} [unit] unit for given TimecoursePoint Axis + * @property {string} type defines the type of given TimecoursePoint Axis + * @property {number} indexRef refers the index into given TimecoursePoint for the current Axis Definition + */ + +/** + * It gets max value of an array, considering value of param index + * + * @param {TimecoursePoint[]} array array of items to be evaluated + * @param {number} index index of each array`s item to be evaluated + * @return {any} max value + */ +function _getMaxValue(array, index) { + return d3Array.max(array, arrayItem => { + return arrayItem[index]; + }); +} + +function _getMinValue(array, index) { + return d3Array.min(array, arrayItem => { + return arrayItem[index]; + }); +} + +const LEGEND = { width: 100, margin: 10 }; +const MARGIN = { top: 20, right: 20, bottom: 50, left: 50 }; + +function _createAxisScale(domainBottom, domainUpper, rangeBottom, rangeUpper) { + return d3Scale + .scaleLinear() + .domain([domainBottom, domainUpper * 1.05]) + .range([rangeBottom, rangeUpper]); +} + +const _getSeriesColor = series => { + const seriesLabels = series.reduce((labels, series) => [...labels, series.label], []); + + return d3Scale.scaleOrdinal().domain(seriesLabels).range(d3ScaleChromatic.schemeSet2); +}; + +const _updateSeriesColors = series => { + const seriesColor = _getSeriesColor(series); + + return series.map(series => ({ + ...series, + color: series.color ?? seriesColor(series.label), + })); +}; + +const _textEllipses = (width, padding = 0) => { + return function (...args) { + const self = d3Selection.select(this); + let textLength = self.node().getComputedTextLength(); + let text = self.text(); + + while (textLength > width - 2 * padding && text.length > 0) { + text = text.slice(0, -1); + self.text(text + '...'); + textLength = self.node().getComputedTextLength(); + } + }; +}; + +const _addLegend = (root, series, chartWidth, chartHeight, legendWidth) => { + const legendItemHeight = 25; + const legendHeight = legendItemHeight * series.length; + + const legendContainer = chart.legend.addNode( + root, + legendWidth, + legendHeight, + chartWidth + LEGEND.margin, + chartHeight / 2 - legendHeight / 2 + ); + + const seriesLabels = series.reduce((labels, series) => [...labels, series.label], []); + + const seriesColors = series.reduce((colors, series) => [...colors, series.color], []); + + chart.legend.setLabels( + legendContainer, + seriesLabels, + seriesColors, + legendItemHeight, + legendWidth, + _textEllipses + ); +}; + +/** + * It creates a svg chart containing lines, dots, axis, labels + * + * @param {object} d3SVGRef svg content reference to append chart + * @param {Object} axis definition of axis + * @param {object} points list of points to be created + * @param {number} width width for whole content including lines, dots, axis, labels + * @param {number} height height for whole content including lines, dots, axis, labels + * @param {boolean} showAxisLabels flag to display labels or not + * + * @modifies {d3SVGRef} + */ +const addLineChartNode = ({ + d3SVGRef, + axis, + series, + width, + height, + legendWidth = LEGEND.width, + showAxisLabels = true, + showAxisGrid = false, + showLegend = false, + transparentChartBackground = false, +}) => { + const marginRight = showLegend ? legendWidth + 2 * LEGEND.margin : MARGIN.right; + const _width = width - MARGIN.left - marginRight; + const _height = height - MARGIN.top - MARGIN.bottom; + const { x: XAxis, y: YAxis } = axis; + + series = _updateSeriesColors(series); + + let maxX = -Infinity; + let minX = Infinity; + let maxY = -Infinity; + let minY = Infinity; + + series.forEach(currentSeries => { + minX = Math.min(minX, _getMinValue(currentSeries.points, XAxis.indexRef)); + maxX = Math.max(maxX, _getMaxValue(currentSeries.points, XAxis.indexRef)); + minY = Math.min(minY, _getMinValue(currentSeries.points, YAxis.indexRef)); + maxY = Math.max(maxY, _getMaxValue(currentSeries.points, YAxis.indexRef)); + }); + + minX = axis?.x?.range?.min ?? minX; + maxX = axis?.x?.range?.max ?? maxX; + minY = axis?.y?.range?.min ?? minY; + maxY = axis?.y?.range?.max ?? maxY; + + const xAxisScale = _createAxisScale(minX, maxX, 0, _width); + const yAxisScale = _createAxisScale(minY, maxY, _height, 0); + + const parseXPoint = axisScale => point => { + return (axisScale || xAxisScale)(point.x); + }; + + const parseYPoint = axisScale => point => { + return (axisScale || yAxisScale)(point.y); + }; + + // Remove old D3 elements + chart.removeContents(d3SVGRef); + + const chartWrapper = chart.container.addNode(d3SVGRef, width, height, MARGIN.left, MARGIN.top); + + // add background + chart.background.addNode(chartWrapper, _width, _height, transparentChartBackground); + + // call the x axis in a group tag + const xAxisGenerator = d3Axis.axisBottom(xAxisScale); + + if (showAxisGrid) { + xAxisGenerator.tickSize(-_height).tickPadding(10); + } + const gXAxis = chart.axis.addNode( + chartWrapper, + XAxis, + undefined, + _height, + undefined, + () => xAxisGenerator, + showAxisLabels, + _width / 2, + _height + MARGIN.bottom / 2 + 10, + undefined, + showAxisGrid + ); + const yAxisGenerator = d3Axis.axisLeft(yAxisScale); + + if (showAxisGrid) { + yAxisGenerator.tickSize(-_width).tickPadding(10); + } + // add y axis + const gYAxis = chart.axis.addNode( + chartWrapper, + YAxis, + undefined, + undefined, + undefined, + () => yAxisGenerator, + showAxisLabels, + 0 - _height / 2, + 0 - MARGIN.left, + [ + { key: 'transform', value: 'rotate(-90)' }, + { key: 'dy', value: '1em' }, + ], + showAxisGrid + ); + + const datasets = []; + + series.forEach((currentSeries, seriesIndex) => { + const { points } = currentSeries; + const line = d3Shape.line().x(parseXPoint(xAxisScale)).y(parseYPoint(yAxisScale)); + + const dataset = points.map((point, pointIndex) => { + return { x: point[0], y: point[1], seriesIndex, pointIndex }; + }); + + const seriesContainer = chart.series.addNode(chartWrapper, seriesIndex, { + color: currentSeries.color || '#00ff00', + }); + + chart.lines.addNode(seriesContainer, dataset, line); + + // add chart points + chart.points.addNode( + seriesContainer, + dataset, + parseXPoint(xAxisScale), + parseYPoint(yAxisScale) + ); + + datasets.push(dataset); + }); + + if (showLegend) { + _addLegend(chartWrapper, series, _width, _height, legendWidth); + } + + // bind events + events.bindMouseEvents( + chartWrapper, + gXAxis, + gYAxis, + xAxisScale, + yAxisScale, + xAxisGenerator, + yAxisGenerator, + parseXPoint, + parseYPoint, + datasets + ); + + return chartWrapper; +}; + +export { addLineChartNode, resetZoom }; diff --git a/platform/ui/src/components/LineChart/index.js b/platform/ui/src/components/LineChart/index.js new file mode 100644 index 000000000..f87705103 --- /dev/null +++ b/platform/ui/src/components/LineChart/index.js @@ -0,0 +1 @@ +export { default } from './LineChart'; diff --git a/platform/ui/src/components/PanelSection/PanelSection.tsx b/platform/ui/src/components/PanelSection/PanelSection.tsx index bdd651c66..caefd2c45 100644 --- a/platform/ui/src/components/PanelSection/PanelSection.tsx +++ b/platform/ui/src/components/PanelSection/PanelSection.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { Icon } from '@ohif/ui'; import PropTypes from 'prop-types'; -const PanelSection = ({ title, children, actionIcons = [] }) => { +const PanelSection = ({ title, children, actionIcons = [], childrenClassName }) => { const [areChildrenVisible, setChildrenVisible] = useState(true); const handleHeaderClick = () => { @@ -37,7 +37,8 @@ const PanelSection = ({ title, children, actionIcons = [] }) => {
{areChildrenVisible && ( <> -
{children}
+
+
{children}
)} @@ -49,6 +50,7 @@ PanelSection.defaultProps = {}; PanelSection.propTypes = { title: PropTypes.string, children: PropTypes.node, + childrenClassName: PropTypes.string, actionIcons: PropTypes.arrayOf( PropTypes.shape({ name: PropTypes.string, diff --git a/platform/ui/src/components/ProgressDropdown/ProgressDiscreteBar.tsx b/platform/ui/src/components/ProgressDropdown/ProgressDiscreteBar.tsx new file mode 100644 index 000000000..66ea3f15d --- /dev/null +++ b/platform/ui/src/components/ProgressDropdown/ProgressDiscreteBar.tsx @@ -0,0 +1,27 @@ +import React, { ReactElement } from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { ProgressDropdownOption, ProgressDropdownOptionPropType } from './types'; + +const ProgressDiscreteBar = ({ options }: { options: ProgressDropdownOption[] }): ReactElement => { + return ( +
+ {options.map((option, i) => ( +
+ ))} +
+ ); +}; + +ProgressDiscreteBar.propTypes = { + options: PropTypes.arrayOf(ProgressDropdownOptionPropType).isRequired, +}; + +export default ProgressDiscreteBar; diff --git a/platform/ui/src/components/ProgressDropdown/ProgressDropdown.tsx b/platform/ui/src/components/ProgressDropdown/ProgressDropdown.tsx new file mode 100644 index 000000000..356025ba6 --- /dev/null +++ b/platform/ui/src/components/ProgressDropdown/ProgressDropdown.tsx @@ -0,0 +1,168 @@ +import React, { ReactNode, useEffect, useCallback, useState, useMemo, useRef } from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import ProgressDiscreteBar from './ProgressDiscreteBar'; +import ProgressItemDetail from './ProgressItemDetail'; +import ProgressItem from './ProgressItem'; +import { Icon } from '../../components'; +import { ProgressDropdownOption, ProgressDropdownOptionPropType } from './types'; + +const ProgressDropdown = ({ + options: optionsProps, + value, + children, + dropDownWidth = '170', + onChange, +}: { + options: ProgressDropdownOption[]; + value?: string; + children?: ReactNode; + onChange?: ({ selectedOption }) => void; +}): JSX.Element => { + const element = useRef(null); + const [open, setOpen] = useState(false); + const toggleOpen = () => setOpen(s => !s); + const [options, setOptions] = useState(optionsProps); + const [selectedOption, setSelectedOption] = useState(undefined); + + const selectedOptionIndex = useMemo( + () => options.findIndex(option => option.value === selectedOption?.value), + [options, selectedOption] + ); + + const canMoveNext = useMemo( + () => selectedOptionIndex < options.length - 1, + [selectedOptionIndex, options] + ); + + const handleOptionSelected = useCallback( + (newSelectedOption?: ProgressDropdownOption): void => { + if (newSelectedOption?.value === selectedOption?.value) { + return; + } + + setOpen(false); + setSelectedOption(newSelectedOption); + + if (newSelectedOption) { + newSelectedOption.activated = true; + newSelectedOption.onSelect?.(); + } + + if (onChange) { + onChange({ selectedOption: newSelectedOption }); + } + }, + [selectedOption, onChange] + ); + + const handleNextButtonClick = useCallback(() => { + if (canMoveNext) { + handleOptionSelected(options[selectedOptionIndex + 1]); + } + }, [options, selectedOptionIndex, canMoveNext, handleOptionSelected]); + + // Update the options in case the options from props has changed + useEffect(() => setOptions(optionsProps), [optionsProps]); + + // Updates the selected item based on the value from props + useEffect(() => { + if (!value) { + return; + } + + const newOption = value ? options.find(option => option.value === value) : undefined; + + handleOptionSelected(newOption); + }, [value, options, handleOptionSelected]); + + // Listen to any click event outside of the dropdown context to hide the options + useEffect(() => { + const handleDocumentClick = e => { + if (element.current && !element.current.contains(e.target)) { + setOpen(false); + } + }; + + document.addEventListener('click', handleDocumentClick); + + if (!open) { + document.removeEventListener('click', handleDocumentClick); + } + }, [open]); + + return ( +
+
+
+
+
+ {selectedOption && } + + {!selectedOption &&
{children}
} +
+ +
+ +
+
+ {options.map((option, index) => ( + handleOptionSelected(option)} + /> + ))} +
+ +
+ +
+
+
+ ); +}; + +ProgressDropdown.propTypes = { + options: PropTypes.arrayOf(ProgressDropdownOptionPropType).isRequired, + value: PropTypes.string, + onChange: PropTypes.func, + children: PropTypes.node, + dropDownWidth: PropTypes.string, +}; + +export default ProgressDropdown; diff --git a/platform/ui/src/components/ProgressDropdown/ProgressItem.tsx b/platform/ui/src/components/ProgressDropdown/ProgressItem.tsx new file mode 100644 index 000000000..e92dbff24 --- /dev/null +++ b/platform/ui/src/components/ProgressDropdown/ProgressItem.tsx @@ -0,0 +1,31 @@ +import React, { ReactElement } from 'react'; +import PropTypes from 'prop-types'; +import ProgressItemDetail from './ProgressItemDetail'; +import { ProgressDropdownOption, ProgressDropdownOptionPropType } from './types'; + +const ProgressItem = ({ + option, + onSelect, +}: { + option: ProgressDropdownOption; + onSelect: (option: ProgressDropdownOption) => void; +}): ReactElement => { + const { value } = option; + + return ( +
onSelect(option)} + > + +
+ ); +}; + +ProgressItem.propTypes = { + option: ProgressDropdownOptionPropType.isRequired, + onSelect: PropTypes.func, +}; + +export default ProgressItem; diff --git a/platform/ui/src/components/ProgressDropdown/ProgressItemDetail.tsx b/platform/ui/src/components/ProgressDropdown/ProgressItemDetail.tsx new file mode 100644 index 000000000..b0213690e --- /dev/null +++ b/platform/ui/src/components/ProgressDropdown/ProgressItemDetail.tsx @@ -0,0 +1,81 @@ +import React, { useState, useMemo, ReactElement } from 'react'; +import { Icon, Tooltip } from '../../components'; +import { ProgressDropdownOption, ProgressDropdownOptionPropType } from './types'; + +const MAX_TOOLTIP_LENGTH = 150; +const iconClassNames = 'grow-0 text-primary-light h-4 w-4 mt-1 mr-2 mb-0 ml-1'; + +const ProgressItemDetail = ({ option }: { option: ProgressDropdownOption }): ReactElement => { + const { label, info, completed } = option; + const [truncate, setTruncate] = useState(true); + const handleOnHideTooltip = () => setTruncate(true); + let icon; + + if (completed) { + icon = 'status-tracked'; + } else if (info) { + icon = 'launch-info'; + } + + const tooltipText = useMemo(() => { + if (!truncate || !info || info.length <= MAX_TOOLTIP_LENGTH) { + return info; + } + + const handleReadMoreClick = e => { + setTruncate(false); + e.stopPropagation(); + e.preventDefault(); + }; + + return ( + <> + {info.slice(0, MAX_TOOLTIP_LENGTH)} + + + ); + }, [info, truncate]); + + const iconContent = ( + <> + {icon && ( +
+ +
+ )} + {!icon &&
} + + ); + + return ( + <> + {info && ( + + {iconContent} + + )} + {!info && iconContent} + +
{label}
+ + ); +}; + +ProgressItemDetail.propTypes = { + option: ProgressDropdownOptionPropType.isRequired, +}; + +export default ProgressItemDetail; diff --git a/platform/ui/src/components/ProgressDropdown/__stories__/progressDropdown.stories.mdx b/platform/ui/src/components/ProgressDropdown/__stories__/progressDropdown.stories.mdx new file mode 100644 index 000000000..db9ec694f --- /dev/null +++ b/platform/ui/src/components/ProgressDropdown/__stories__/progressDropdown.stories.mdx @@ -0,0 +1,106 @@ +import { ProgressDropdown } from '../../../components'; +import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs'; +import { createComponentTemplate } from '../../../storybook/functions/create-component-story'; + +export const argTypes = { + component: ProgressDropdown, + title: 'Components/ProgressDropdown', +}; + + + +export const ProgressDropdownTemplate = args => ( +
+ +
+); + +export const loremIpsumText = new Array(7) + .join(` Lorem ipsum, dolor sit amet consectetur adipisicing elit`) + .substring(1); + + + +- [Overview](#overview) +- [Props](#props) +- [Usage](#usage) +- [Contribute](#contribute) + +## Overview + +Dropdown is a modal Dropdown component which enabled to show a modal Dropdown. + + + + {ProgressDropdownTemplate.bind({})} + + + +## Props + + + +## Usage + +You can list the items in the dropdown. The status of each item changes from black to dark blue in +the status bar beneath the dropdown when the item is selected. + + + (selectedItem.completed = true)} + args={{ + options: [ + { + label: 'Item 1', + value: 'item1', + info: `Item 1 - ${loremIpsumText}`, + onClick: () => { + alert('Item 1 clicked'); + }, + }, + { + label: 'Item 2', + value: 'item2', + info: `Item 2 - ${loremIpsumText}`, + onClick: () => { + alert('Item 2 clicked'); + }, + }, + { + label: 'Item 3', + value: 'item3', + info: `Item 3 - ${loremIpsumText}`, + onClick: () => { + alert('Item 3 clicked'); + }, + }, + { + label: 'Item 4', + info: `Item 4 - ${loremIpsumText}`, + onClick: () => { + alert('Item 4 clicked'); + }, + }, + ], + }} + > + {ProgressDropdownTemplate.bind({})} + + + +## Contribute + +
diff --git a/platform/ui/src/components/ProgressDropdown/index.js b/platform/ui/src/components/ProgressDropdown/index.js new file mode 100644 index 000000000..abd294a17 --- /dev/null +++ b/platform/ui/src/components/ProgressDropdown/index.js @@ -0,0 +1 @@ +export { default } from './ProgressDropdown'; diff --git a/platform/ui/src/components/ProgressDropdown/types.ts b/platform/ui/src/components/ProgressDropdown/types.ts new file mode 100644 index 000000000..fb99e1bd2 --- /dev/null +++ b/platform/ui/src/components/ProgressDropdown/types.ts @@ -0,0 +1,19 @@ +import PropTypes from 'prop-types'; + +export type ProgressDropdownOption = { + label: string; + value: string; + info?: string; + activated?: boolean; + completed?: boolean; + onSelect?: () => void; +}; + +export const ProgressDropdownOptionPropType = PropTypes.shape({ + label: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + info: PropTypes.string, + activated: PropTypes.bool, + completed: PropTypes.bool, + onSelect: PropTypes.func, +}); diff --git a/platform/ui/src/components/SegmentationGroupTable/SegmentationConfig.tsx b/platform/ui/src/components/SegmentationGroupTable/SegmentationConfig.tsx index 622b92196..406a9003f 100644 --- a/platform/ui/src/components/SegmentationGroupTable/SegmentationConfig.tsx +++ b/platform/ui/src/components/SegmentationGroupTable/SegmentationConfig.tsx @@ -53,7 +53,7 @@ const ActiveSegmentationConfig = ({ unit="%" /> - {t('Inactive segmentations')} + + {t('Inactive segmentations')} +
{!isMinimized && ( { const { t } = useTranslation('SidePanel'); @@ -178,14 +179,15 @@ const SidePanel = ({ const openStatus = panelOpen ? 'open' : 'closed'; const style = Object.assign({}, styleMap[openStatus][side], baseStyle); - const ActiveComponent = tabs[activeTabIndex]?.content; - - const updatePanelOpen = useCallback((panelOpen: boolean) => { - setPanelOpen(panelOpen); - if (panelOpen) { - onOpen?.(); - } - }, []); + const updatePanelOpen = useCallback( + (panelOpen: boolean) => { + setPanelOpen(panelOpen); + if (panelOpen && onOpen) { + onOpen(); + } + }, + [onOpen] + ); const updateActiveTabIndex = useCallback( (activeTabIndex: number) => { @@ -196,8 +198,12 @@ const SidePanel = ({ setActiveTabIndex(activeTabIndex); updatePanelOpen(true); + + if (onActiveTabIndexChange) { + onActiveTabIndexChange({ activeTabIndex }); + } }, - [updatePanelOpen] + [onActiveTabIndexChange, updatePanelOpen] ); useEffect(() => { @@ -214,7 +220,7 @@ const SidePanel = ({ side === 'left' ? 'justify-end pr-2' : 'justify-start pl-2' )} onClick={() => { - updatePanelOpen(prev => !prev); + updatePanelOpen(!panelOpen); }} data-cy={`side-panel-header-${side}`} > @@ -270,7 +276,7 @@ const SidePanel = ({ )} style={{ width: `${closeIconWidth}px` }} onClick={() => { - updatePanelOpen(prev => !prev); + updatePanelOpen(!panelOpen); }} data-cy={`side-panel-header-${side}`} > @@ -356,7 +362,7 @@ const SidePanel = ({ : { marginRight: `${closeIconWidth}px` }), }} data-cy={`${tabs[0].name}-btn`} - onClick={() => updatePanelOpen(prev => !prev)} + onClick={() => updatePanelOpen(!panelOpen)} > {tabs[0].label}
@@ -380,7 +386,14 @@ const SidePanel = ({ {panelOpen ? ( <> {getOpenStateComponent()} - + {tabs.map((tab, tabIndex) => ( +
+ +
+ ))} ) : ( {getCloseStateComponent()} @@ -410,6 +423,7 @@ SidePanel.propTypes = { ), ]), onOpen: PropTypes.func, + onActiveTabIndexChange: PropTypes.func, expandedWidth: PropTypes.number, }; diff --git a/platform/ui/src/components/Toolbox/ToolboxUI.tsx b/platform/ui/src/components/Toolbox/ToolboxUI.tsx index 4ee2e3fe0..f54c6201e 100644 --- a/platform/ui/src/components/Toolbox/ToolboxUI.tsx +++ b/platform/ui/src/components/Toolbox/ToolboxUI.tsx @@ -55,7 +55,7 @@ function ToolboxUI(props) { content={componentProps.label} secondaryContent={componentProps.disabledText} > -
+
) : ( -
+
{ const [isActive, setIsActive] = useState(false); + const isOpen = useMemo( + () => (isSticky || isActive) && !isDisabled, + [isSticky, isActive, isDisabled] + ); 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 handleMouseOverDebounced = useMemo( + () => debounce(() => setIsActive(true), showHideDelay), + [showHideDelay] + ); + + const handleMouseOutDebounced = useMemo( + () => debounce(() => setIsActive(false), showHideDelay), + [showHideDelay] + ); + const handleMouseOver = () => { - if (!isActive) { - setIsActive(true); - } + handleMouseOutDebounced.cancel(); + handleMouseOverDebounced(); }; const handleMouseOut = () => { - if (isActive) { - setIsActive(false); - } + handleMouseOverDebounced.cancel(); + handleMouseOutDebounced(); }; - const isOpen = (isSticky || isActive) && !isDisabled; + useEffect(() => { + if (!isOpen && onHide) { + onHide(); + } + }, [isOpen, onHide]); useEffect(() => { if (parentRef.current && tooltipRef.current) { @@ -121,7 +143,8 @@ const Tooltip = ({ 'tooltip-box bg-primary-dark border-secondary-light w-max-content relative inset-x-auto top-full rounded border text-base text-white', { 'py-[6px] px-[8px]': !tight, - } + }, + tooltipBoxClassName )} >
{typeof content === 'string' ? t(content) : content}
@@ -164,16 +187,21 @@ Tooltip.defaultProps = { isSticky: false, position: 'bottom', isDisabled: false, + showHideDelay: 300, }; Tooltip.propTypes = { isDisabled: PropTypes.bool, content: PropTypes.oneOfType([PropTypes.node, PropTypes.func]), + secondaryContent: PropTypes.oneOfType([PropTypes.node, PropTypes.func]), position: PropTypes.oneOf(['bottom', 'bottom-left', 'bottom-right', 'left', 'right', 'top']), isSticky: PropTypes.bool, tight: PropTypes.bool, children: PropTypes.node.isRequired, className: PropTypes.string, + tooltipBoxClassName: PropTypes.string, + showHideDelay: PropTypes.number, + onHide: PropTypes.func, }; export default Tooltip; diff --git a/platform/ui/src/components/Tooltip/tooltip.css b/platform/ui/src/components/Tooltip/tooltip.css index 283bce32e..7661ca0d9 100644 --- a/platform/ui/src/components/Tooltip/tooltip.css +++ b/platform/ui/src/components/Tooltip/tooltip.css @@ -1,5 +1,5 @@ .tooltip { - @apply absolute z-10; + @apply absolute z-20; } /* TOOLTIP WORKAROUND FOR ARROW UP */ diff --git a/platform/ui/src/components/WindowLevel/WindowLevel.tsx b/platform/ui/src/components/WindowLevel/WindowLevel.tsx new file mode 100644 index 000000000..3f3ca14db --- /dev/null +++ b/platform/ui/src/components/WindowLevel/WindowLevel.tsx @@ -0,0 +1,189 @@ +import React, { useEffect, useCallback, useState, useMemo, ReactElement } from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import WindowLevelHistogram from './WindowLevelHistogram'; +import InputRange from '../InputRange'; +import InputDoubleRange from '../InputDoubleRange'; +import { + VOI, + Colormap, + Histogram, + voiPropType, + histogramPropType, + colormapPropType, +} from './types'; +import PanelSection from '../PanelSection'; + +const convertVOItoVOIRange = voi => { + return { + min: voi.windowCenter - voi.windowWidth / 2, + max: voi.windowCenter + voi.windowWidth / 2, + }; +}; + +const WindowLevel = ({ + title, + step, + histogram, + voi: voiProp, + opacity: opacityProp, + showOpacitySlider, + colormap, + style, + fillColor, + lineColor, + containerClassName, + onVOIChange, + onOpacityChange, +}: { + title: string; + step: number; + histogram: Histogram; + voi: VOI; + opacity: number; + showOpacitySlider: boolean; + colormap: Colormap; + style: string; + fillColor: string; + lineColor: string; + containerClassName: string; + onVOIChange: (voi: VOI) => void; + onOpacityChange: (opacity: number) => void; +}): ReactElement => { + const [opacity, setOpacity] = useState(opacityProp); + + const range = useMemo( + () => ({ + min: histogram.range.min, + max: histogram.range.max, + }), + [histogram] + ); + + const [voiRange, setVOIRange] = useState( + voiProp + ? convertVOItoVOIRange(voiProp) + : { + min: range.min, + max: range.max, + } + ); + + const handleVOIRangeChange = useCallback( + newRange => { + if (newRange[0] === voiRange.min && newRange[1] === voiRange.max) { + return; + } + + const windowWidth = newRange[1] - newRange[0]; + const windowCenter = newRange[0] + windowWidth / 2; + + setVOIRange({ + min: newRange[0], + max: newRange[1], + }); + + if (onVOIChange) { + onVOIChange({ windowWidth, windowCenter }); + } + }, + [onVOIChange, voiRange] + ); + + const handleOpacityChange = useCallback( + value => { + if (onOpacityChange) { + onOpacityChange(value); + } + }, + [onOpacityChange] + ); + + useEffect(() => setVOIRange(convertVOItoVOIRange(voiProp)), [voiProp]); + + useEffect(() => setOpacity(opacityProp), [opacityProp]); + return ( +
+
+ {/* {title} */} +
+
+ {range.min} +
+
+ {range.max} +
+
+
+
+
+ +
+
+ +
+
+ {showOpacitySlider && ( +
+
Opacity
+ +
+ )} +
+
+
+ ); +}; + +WindowLevel.defaultProps = { + step: 1, + opacity: 1, + showOpacitySlider: false, + style: 'polygon', + fillColor: '#3f3f3f', + lineColor: '#707070', +}; + +WindowLevel.propTypes = { + title: PropTypes.string, + step: PropTypes.number, + histogram: histogramPropType.isRequired, + voi: voiPropType, + opacity: PropTypes.number, + showOpacitySlider: PropTypes.bool, + colormap: colormapPropType, + style: PropTypes.oneOf(['polygon', 'bars']), + fillColor: PropTypes.string, + lineColor: PropTypes.string, + containerClassName: PropTypes.string, + onVOIChange: PropTypes.func, + onOpacityChange: PropTypes.func, +}; + +export default WindowLevel; diff --git a/platform/ui/src/components/WindowLevel/WindowLevelHistogram.tsx b/platform/ui/src/components/WindowLevel/WindowLevelHistogram.tsx new file mode 100644 index 000000000..987263a96 --- /dev/null +++ b/platform/ui/src/components/WindowLevel/WindowLevelHistogram.tsx @@ -0,0 +1,236 @@ +import React, { useLayoutEffect, useEffect, useRef, ReactElement } from 'react'; +import PropTypes from 'prop-types'; +import { + Range, + VOIRange, + Histogram, + Colormap, + voiRangePropType, + rangePropType, + histogramPropType, + colormapPropType, +} from './types'; + +const DEFAULT_COLORMAP = { + Name: 'Grayscale', + RGBPoints: [0, 0, 0, 0, 1, 1, 1, 1], +}; + +const clamp = (value, min, max) => Math.min(Math.max(min, value), max); + +const interpolateVec3 = (a, b, t) => { + return [a[0] * (1 - t) + b[0] * t, a[1] * (1 - t) + b[1] * t, a[2] * (1 - t) + b[2] * t]; +}; + +const drawBackground = (canvas, range, voiRange, colormap) => { + const context = canvas.getContext('2d'); + const { width, height } = canvas; + const windowWidth = voiRange.max - voiRange.min; + const rgbPoints = colormap.RGBPoints; + const colorsCount = rgbPoints.length / 4; + + const getColorPoint = index => { + const offset = 4 * index; + + return index < colorsCount + ? { + index, + position: rgbPoints[offset], + color: [rgbPoints[offset + 1], rgbPoints[offset + 2], rgbPoints[offset + 3]], + } + : undefined; + }; + + let leftColorPoint = undefined; + let rightColorPoint = getColorPoint(0); + + const incRawPixelValue = (range.max - range.min) / (width - 1); + let rawPixelValue = range.min; + + for (let x = 0; x < width; x++) { + const tVoiRange = (rawPixelValue - voiRange.min) / windowWidth; + + // Find the color in a linear way (O(n) complexity) + if (rightColorPoint) { + for (let i = rightColorPoint.index; i < colorsCount; i++) { + if (tVoiRange <= rightColorPoint.position) { + break; + } + + leftColorPoint = rightColorPoint; + rightColorPoint = getColorPoint(i + 1); + } + } + + let normColor; + + if (!leftColorPoint) { + normColor = [...rightColorPoint.color]; + } else if (!rightColorPoint) { + normColor = [...leftColorPoint.color]; + } else { + const tColorRange = + (tVoiRange - leftColorPoint.position) / + (rightColorPoint.position - leftColorPoint.position); + + normColor = interpolateVec3(leftColorPoint.color, rightColorPoint.color, tColorRange); + } + + const color = normColor.map(color => clamp(Math.round(color * 255), 0, 255)); + + context.fillStyle = `rgb(${color[0]}, ${color[1]}, ${color[2]})`; + context.fillRect(x, 0, 1, height); + + rawPixelValue += incRawPixelValue; + } +}; + +/* plots the histogram bins as a polygon that traces the centers of each bin */ +const drawPolygonHistogram = (canvas, context, histogram, options) => { + const xScale = canvas.width / histogram.numBins; + const { scale } = options; + const maxVal = scale(histogram.maxBinValue); + + context.beginPath(); + context.moveTo(0, canvas.height); + context.lineTo(0, canvas.height - (canvas.height * scale(histogram.bins[0])) / maxVal); + + let x = xScale / 2; + for (let bin = 0; bin < histogram.numBins; bin++) { + context.lineTo(x, canvas.height - (canvas.height * scale(histogram.bins[bin])) / maxVal); + x += xScale; + } + context.lineTo( + canvas.width, + canvas.height - (canvas.height * scale(histogram.bins[histogram.numBins - 1])) / maxVal + ); + context.lineTo(canvas.width, canvas.height); + context.lineTo(0, canvas.height); + + context.closePath(); + context.fill(); + context.stroke(); +}; + +/* plots the histogram bins as a series of n vertical bars (n = number of bins) */ +const drawBarHistogram = (canvas, context, histogram, options) => { + const xScale = canvas.width / histogram.numBins; + const { scale } = options; + const maxVal = scale(histogram.maxBinValue); + + context.beginPath(); + + for (let bin = 0; bin < histogram.numBins; bin++) { + context.moveTo(xScale * bin, canvas.height); + context.lineTo( + xScale * bin, + canvas.height - (canvas.height * scale(histogram.bins[bin])) / maxVal + ); + } + + context.closePath(); + context.strokeStyle = options.fillColor; + context.lineWidth = xScale; + context.stroke(); +}; + +const drawHistogram = (canvas, histogram, options) => { + const context = canvas.getContext('2d'); + + // context.clearRect(0, 0, canvas.width, canvas.height); + context.fillStyle = options.fillColor; + context.strokeStyle = options.lineColor; + + if (options.style === 'polygon') { + drawPolygonHistogram(canvas, context, histogram, options); + } else if (options.style === 'bars') { + drawBarHistogram(canvas, context, histogram, options); + } else { + throw new Error(`Invalid style (${options.style})`); + } +}; + +const WindowLevelHistogram = ({ + range, + voiRange, + histogram, + colormap, + style, + fillColor, + lineColor, +}: { + range: Range; + voiRange: VOIRange; + histogram: Histogram; + colormap: Colormap; + style: string; + fillColor: string; + lineColor: string; +}): ReactElement => { + const containerRef = useRef(null); + const canvasRef = useRef(null); + const minVOIPercent = ((voiRange.min - range.min) / (range.max - range.min)) * 100; + const maxVoiPercent = (1 - (range.max - voiRange.max) / (range.max - range.min)) * 100; + const background = + colormap.Name !== 'Grayscale' + ? undefined + : `linear-gradient(to right, #000 0%, #000 ${minVOIPercent}%, #fff ${maxVoiPercent}%, #fff 100%)`; + + useLayoutEffect(() => { + const container = containerRef.current; + const canvas = canvasRef.current; + + canvas.width = container.offsetWidth; + canvas.height = container.offsetHeight; + }, [containerRef, canvasRef]); + + useEffect(() => { + const canvas = canvasRef.current; + const context = canvas.getContext('2d'); + const fnIdentity = x => x; + const options = { + style, + fillColor, + lineColor, + scale: fnIdentity, + }; + + context.clearRect(0, 0, canvas.width, canvas.height); + + drawBackground(canvas, range, voiRange, colormap); + drawHistogram(canvas, histogram, options); + }, [range, voiRange, histogram, colormap, style, fillColor, lineColor, canvasRef]); + + return ( +
+ +
+ ); +}; + +WindowLevelHistogram.defaultProps = { + colormap: DEFAULT_COLORMAP, + style: 'polygon', + fillColor: '#3f3f3f', + lineColor: '#707070', +}; + +WindowLevelHistogram.propTypes = { + range: rangePropType.isRequired, + voiRange: voiRangePropType.isRequired, + histogram: histogramPropType.isRequired, + colormap: colormapPropType, + style: PropTypes.oneOf(['polygon', 'bars']), + fillColor: PropTypes.string, + lineColor: PropTypes.string, +}; + +export default WindowLevelHistogram; diff --git a/platform/ui/src/components/WindowLevel/__stories__/windowLevel.stories.mdx b/platform/ui/src/components/WindowLevel/__stories__/windowLevel.stories.mdx new file mode 100644 index 000000000..ab9c9f998 --- /dev/null +++ b/platform/ui/src/components/WindowLevel/__stories__/windowLevel.stories.mdx @@ -0,0 +1,103 @@ +import WindowLevel from '../WindowLevel'; + +import { ArgsTable, Story, Canvas, Meta } from '@storybook/addon-docs'; +import { createComponentTemplate } from '../../../storybook/functions/create-component-story'; +import '../../InputRange/InputRange.css'; + +export const argTypes = { + component: WindowLevel, + title: 'Components/WindowLevel', +}; + + + +export const WindowLevelTemplate = args => ( +
+ +
+); + + + +- [Overview](#overview) +- [Props](#props) +- [Contribute](#contribute) + +## Overview + +WindowLevel is a component that... + + + + {WindowLevelTemplate.bind({})} + + + +## Props + + + +## Usage + +## Contribute + +
diff --git a/platform/ui/src/components/WindowLevel/index.js b/platform/ui/src/components/WindowLevel/index.js new file mode 100644 index 000000000..b853aaba0 --- /dev/null +++ b/platform/ui/src/components/WindowLevel/index.js @@ -0,0 +1 @@ +export { default } from './WindowLevel'; diff --git a/platform/ui/src/components/WindowLevel/types.ts b/platform/ui/src/components/WindowLevel/types.ts new file mode 100644 index 000000000..a0bb615c7 --- /dev/null +++ b/platform/ui/src/components/WindowLevel/types.ts @@ -0,0 +1,64 @@ +import PropTypes from 'prop-types'; + +export type Range = { + min: number; + max: number; +}; + +export type VOI = { + windowWidth: number; + windowCenter: number; +}; + +export type VOIRange = { + min: number; + max: number; +}; + +export type Histogram = { + bins: number[]; + numBins: number; + maxBin: number; + maxBinValue: number; + range: { + min: number; + max: number; + }; +}; + +export type Colormap = { + Name: string; + RGBPoints: number[]; +}; + +export const rangePropType = PropTypes.shape({ + min: PropTypes.number.isRequired, + max: PropTypes.number.isRequired, +}); + +export const voiRangePropType = PropTypes.shape({ + min: PropTypes.number.isRequired, + max: PropTypes.number.isRequired, +}); + +export const voiPropType = PropTypes.shape({ + windowCenter: PropTypes.number.isRequired, + windowWidth: PropTypes.number.isRequired, +}); + +export const histogramPropType = PropTypes.shape({ + bins: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.number), PropTypes.instanceOf(Int32Array)]) + .isRequired, + numBins: PropTypes.number.isRequired, + maxBin: PropTypes.number.isRequired, + maxBinValue: PropTypes.number.isRequired, + range: PropTypes.shape({ + min: PropTypes.number.isRequired, + max: PropTypes.number.isRequired, + }), +}); + +export const colormapPropType = PropTypes.shape({ + Name: PropTypes.string, + RGBPoints: PropTypes.arrayOf(PropTypes.number), +}); diff --git a/platform/ui/src/components/index.js b/platform/ui/src/components/index.js index 0dc9438da..f3b023d2a 100644 --- a/platform/ui/src/components/index.js +++ b/platform/ui/src/components/index.js @@ -27,6 +27,7 @@ import MeasurementTable from './MeasurementTable'; import Modal from './Modal'; import NavBar from './NavBar'; import Notification from './Notification'; +import ProgressDropdown from './ProgressDropdown'; import Select from './Select'; import SegmentationTable from './SegmentationTable'; import { SegmentationGroupTable, SegmentationGroupTableExpanded } from './SegmentationGroupTable'; @@ -52,6 +53,7 @@ import ThumbnailTracked from './ThumbnailTracked'; import ThumbnailList from './ThumbnailList'; import ToolbarButton from './ToolbarButton'; import ExpandableToolbarButton from './ExpandableToolbarButton'; +import LineChart from './LineChart'; import ListMenu from './ListMenu'; import Tooltip from './Tooltip'; import TooltipClipboard from './TooltipClipboard'; @@ -60,6 +62,7 @@ import Viewport from './Viewport'; import ViewportDownloadForm from './ViewportDownloadForm'; import ViewportGrid from './ViewportGrid'; import ViewportPane from './ViewportPane'; +import WindowLevel from './WindowLevel'; import WindowLevelMenuItem from './WindowLevelMenuItem'; import UserPreferences from './UserPreferences'; import HotkeysPreferences from './HotkeysPreferences'; @@ -91,8 +94,10 @@ import { Toolbox } from './Toolbox'; import InvestigationalUseDialog from './InvestigationalUseDialog'; import MeasurementItem from './MeasurementTable/MeasurementItem'; import LayoutPreset from './LayoutPreset'; +import ActionButtons from './ActionButtons'; export { + ActionButtons, AboutModal, AdvancedToolbox, AllInOneMenu, @@ -112,6 +117,7 @@ export { EmptyStudies, ErrorBoundary, ExpandableToolbarButton, + LineChart, ListMenu, Icon, IconButton, @@ -120,7 +126,6 @@ export { InputFilterText, InputGroup, InputRange, - InputDoubleRange, InputNumber, InputLabelWrapper, InputMultiSelect, @@ -139,6 +144,7 @@ export { Modal, NavBar, Notification, + ProgressDropdown, ProgressLoadingBar, PanelSection, Select, @@ -181,7 +187,9 @@ export { ViewportGrid, ViewportPane, ViewportOverlay, + WindowLevel, WindowLevelMenuItem, + InputDoubleRange, LabellingFlow, HeaderPatientInfo, LayoutPreset, diff --git a/platform/ui/src/contextProviders/CineProvider.tsx b/platform/ui/src/contextProviders/CineProvider.tsx index 180989ca4..5946698f4 100644 --- a/platform/ui/src/contextProviders/CineProvider.tsx +++ b/platform/ui/src/contextProviders/CineProvider.tsx @@ -21,13 +21,30 @@ export default function CineProvider({ children, service }) { const { id, frameRate, isPlaying = undefined } = action.payload; const cines = state.cines; - if (!cines[id]) { - cines[id] = { id, ...DEFAULT_CINE }; - } - cines[id].frameRate = frameRate || cines[id].frameRate; - cines[id].isPlaying = isPlaying !== undefined ? isPlaying : cines[id].isPlaying; + const syncedCineIds = service.getSyncedViewports(id).map(({ viewportId }) => viewportId); + const cineIdsToUpdate = [id, ...syncedCineIds].filter(curId => { + const currentCine = cines[curId] ?? {}; + const shouldUpdateFrameRate = + currentCine.frameRate !== (frameRate ?? currentCine.frameRate); + const shouldUpdateIsPlaying = + currentCine.isPlaying !== (isPlaying ?? currentCine.isPlaying); - return { ...state, ...{ cines } }; + return shouldUpdateFrameRate || shouldUpdateIsPlaying; + }); + + cineIdsToUpdate.forEach(currId => { + let cine = cines[currId]; + + if (!cine) { + cine = { id, ...DEFAULT_CINE }; + cines[currId] = cine; + } + + cine.frameRate = frameRate ?? cine.frameRate; + cine.isPlaying = isPlaying ?? cine.isPlaying; + }); + + return { ...state, ...cines }; } case 'SET_IS_CINE_ENABLED': { return { ...state, ...{ isCineEnabled: action.payload } }; diff --git a/platform/ui/src/index.js b/platform/ui/src/index.js index 704f27302..02697c22b 100644 --- a/platform/ui/src/index.js +++ b/platform/ui/src/index.js @@ -31,6 +31,7 @@ export { /** COMPONENTS */ export { + ActionButtons, AboutModal, AdvancedToolbox, AllInOneMenu, @@ -50,6 +51,7 @@ export { EmptyStudies, ErrorBoundary, ExpandableToolbarButton, + LineChart, ListMenu, Icon, IconButton, @@ -84,6 +86,7 @@ export { SegmentationGroupTableExpanded, SidePanel, SplitButton, + ProgressDropdown, LegacySplitButton, StudyBrowser, StudyItem, @@ -118,6 +121,7 @@ export { ViewportDownloadForm, ViewportGrid, ViewportPane, + WindowLevel, WindowLevelMenuItem, ImageScrollbar, ViewportOverlay, diff --git a/platform/ui/src/views/Viewer/components/Header.js b/platform/ui/src/views/Viewer/components/Header.js index 48ced6625..268ffa384 100644 --- a/platform/ui/src/views/Viewer/components/Header.js +++ b/platform/ui/src/views/Viewer/components/Header.js @@ -20,9 +20,7 @@ const Header = () => {
-
- {/* */} -
+
FOR INVESTIGATIONAL USE ONLY