From b7419fb6a11d29bfd197777a0f0e3734d73d287a Mon Sep 17 00:00:00 2001 From: Dan Rukas Date: Tue, 1 Apr 2025 09:30:32 -0400 Subject: [PATCH] ui(components): Adds InputNumber component and 4d panel updates (#4874) Co-authored-by: sedghi --- bun.lock | 281 ++++++------ .../src/panels/DynamicVolumeControls.tsx | 326 ++++++++------ .../ViewportWindowLevel.tsx | 60 +-- platform/docs/package.json | 1 + .../pages/components/NumericMetaShowcase.tsx | 133 ++++++ platform/docs/yarn.lock | 71 +++ .../components/InputNumber/InputNumber.tsx | 405 ++++++++++++++++++ .../src/components/InputNumber/index.ts | 3 + .../src/components/Numeric/Numeric.tsx | 191 ++++++++- .../src/components/OHIFPanels/WindowLevel.tsx | 162 +++++++ .../OHIFPanels/WindowLevelHistogram.tsx | 237 ++++++++++ .../src/components/OHIFPanels/index.ts | 3 + .../src/components/OHIFPanels/types.ts | 41 ++ platform/ui-next/src/components/index.ts | 7 +- platform/ui-next/src/index.ts | 3 +- 15 files changed, 1620 insertions(+), 304 deletions(-) create mode 100644 platform/ui-next/src/components/InputNumber/InputNumber.tsx create mode 100644 platform/ui-next/src/components/InputNumber/index.ts create mode 100644 platform/ui-next/src/components/OHIFPanels/WindowLevel.tsx create mode 100644 platform/ui-next/src/components/OHIFPanels/WindowLevelHistogram.tsx create mode 100644 platform/ui-next/src/components/OHIFPanels/index.ts create mode 100644 platform/ui-next/src/components/OHIFPanels/types.ts diff --git a/bun.lock b/bun.lock index 307c2094d..82ce798af 100644 --- a/bun.lock +++ b/bun.lock @@ -33,7 +33,7 @@ }, "addOns/externals/devDependencies": { "name": "@externals/devDependencies", - "version": "3.10.0-beta.140", + "version": "3.10.0-beta.142", "dependencies": { "@babel/runtime": "^7.20.13", "@kitware/vtk.js": "32.12.0", @@ -121,14 +121,14 @@ }, "addOns/externals/dicom-microscopy-viewer": { "name": "@externals/dicom-microscopy-viewer", - "version": "3.10.0-beta.140", + "version": "3.10.0-beta.142", "dependencies": { "dicom-microscopy-viewer": "^0.46.1", }, }, "extensions/cornerstone": { "name": "@ohif/extension-cornerstone", - "version": "3.10.0-beta.140", + "version": "3.10.0-beta.142", "dependencies": { "@babel/runtime": "^7.20.13", "@cornerstonejs/adapters": "^3.7.17", @@ -153,8 +153,8 @@ "@cornerstonejs/codec-openjpeg": "^1.2.4", "@cornerstonejs/codec-openjph": "^2.4.5", "@cornerstonejs/dicom-image-loader": "^3.7.17", - "@ohif/core": "3.10.0-beta.140", - "@ohif/ui": "3.10.0-beta.140", + "@ohif/core": "3.10.0-beta.142", + "@ohif/ui": "3.10.0-beta.142", "dcmjs": "*", "dicom-parser": "^1.8.21", "hammerjs": "^2.0.8", @@ -166,7 +166,7 @@ }, "extensions/cornerstone-dicom-pmap": { "name": "@ohif/extension-cornerstone-dicom-pmap", - "version": "3.10.0-beta.140", + "version": "3.10.0-beta.142", "dependencies": { "@babel/runtime": "^7.20.13", "@cornerstonejs/adapters": "^3.7.17", @@ -175,10 +175,10 @@ "react-color": "^2.19.3", }, "peerDependencies": { - "@ohif/core": "3.10.0-beta.140", - "@ohif/extension-cornerstone": "3.10.0-beta.140", - "@ohif/extension-default": "3.10.0-beta.140", - "@ohif/i18n": "3.10.0-beta.140", + "@ohif/core": "3.10.0-beta.142", + "@ohif/extension-cornerstone": "3.10.0-beta.142", + "@ohif/extension-default": "3.10.0-beta.142", + "@ohif/i18n": "3.10.0-beta.142", "prop-types": "^15.6.2", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -189,16 +189,16 @@ }, "extensions/cornerstone-dicom-rt": { "name": "@ohif/extension-cornerstone-dicom-rt", - "version": "3.10.0-beta.140", + "version": "3.10.0-beta.142", "dependencies": { "@babel/runtime": "^7.20.13", "react-color": "^2.19.3", }, "peerDependencies": { - "@ohif/core": "3.10.0-beta.140", - "@ohif/extension-cornerstone": "3.10.0-beta.140", - "@ohif/extension-default": "3.10.0-beta.140", - "@ohif/i18n": "3.10.0-beta.140", + "@ohif/core": "3.10.0-beta.142", + "@ohif/extension-cornerstone": "3.10.0-beta.142", + "@ohif/extension-default": "3.10.0-beta.142", + "@ohif/i18n": "3.10.0-beta.142", "prop-types": "^15.6.2", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -209,7 +209,7 @@ }, "extensions/cornerstone-dicom-seg": { "name": "@ohif/extension-cornerstone-dicom-seg", - "version": "3.10.0-beta.140", + "version": "3.10.0-beta.142", "dependencies": { "@babel/runtime": "^7.20.13", "@cornerstonejs/adapters": "^3.7.17", @@ -218,10 +218,10 @@ "react-color": "^2.19.3", }, "peerDependencies": { - "@ohif/core": "3.10.0-beta.140", - "@ohif/extension-cornerstone": "3.10.0-beta.140", - "@ohif/extension-default": "3.10.0-beta.140", - "@ohif/i18n": "3.10.0-beta.140", + "@ohif/core": "3.10.0-beta.142", + "@ohif/extension-cornerstone": "3.10.0-beta.142", + "@ohif/extension-default": "3.10.0-beta.142", + "@ohif/i18n": "3.10.0-beta.142", "prop-types": "^15.6.2", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -232,7 +232,7 @@ }, "extensions/cornerstone-dicom-sr": { "name": "@ohif/extension-cornerstone-dicom-sr", - "version": "3.10.0-beta.140", + "version": "3.10.0-beta.142", "dependencies": { "@babel/runtime": "^7.20.13", "@cornerstonejs/adapters": "^3.7.17", @@ -241,10 +241,10 @@ "classnames": "^2.3.2", }, "peerDependencies": { - "@ohif/core": "3.10.0-beta.140", - "@ohif/extension-cornerstone": "3.10.0-beta.140", - "@ohif/extension-measurement-tracking": "3.10.0-beta.140", - "@ohif/ui": "3.10.0-beta.140", + "@ohif/core": "3.10.0-beta.142", + "@ohif/extension-cornerstone": "3.10.0-beta.142", + "@ohif/extension-measurement-tracking": "3.10.0-beta.142", + "@ohif/ui": "3.10.0-beta.142", "dcmjs": "*", "dicom-parser": "^1.8.9", "hammerjs": "^2.0.8", @@ -254,7 +254,7 @@ }, "extensions/cornerstone-dynamic-volume": { "name": "@ohif/extension-cornerstone-dynamic-volume", - "version": "3.10.0-beta.140", + "version": "3.10.0-beta.142", "dependencies": { "@babel/runtime": "^7.20.13", "@cornerstonejs/core": "^3.7.17", @@ -262,11 +262,11 @@ "classnames": "^2.3.2", }, "peerDependencies": { - "@ohif/core": "3.10.0-beta.140", - "@ohif/extension-cornerstone": "3.10.0-beta.140", - "@ohif/extension-default": "3.10.0-beta.140", - "@ohif/i18n": "3.10.0-beta.140", - "@ohif/ui": "3.10.0-beta.140", + "@ohif/core": "3.10.0-beta.142", + "@ohif/extension-cornerstone": "3.10.0-beta.142", + "@ohif/extension-default": "3.10.0-beta.142", + "@ohif/i18n": "3.10.0-beta.142", + "@ohif/ui": "3.10.0-beta.142", "dcmjs": "*", "dicom-parser": "^1.8.21", "hammerjs": "^2.0.8", @@ -276,7 +276,7 @@ }, "extensions/default": { "name": "@ohif/extension-default", - "version": "3.10.0-beta.140", + "version": "3.10.0-beta.142", "dependencies": { "@babel/runtime": "^7.20.13", "@cornerstonejs/calculate-suv": "^1.1.0", @@ -284,8 +284,8 @@ "lodash.uniqby": "^4.7.0", }, "peerDependencies": { - "@ohif/core": "3.10.0-beta.140", - "@ohif/i18n": "3.10.0-beta.140", + "@ohif/core": "3.10.0-beta.142", + "@ohif/i18n": "3.10.0-beta.142", "dcmjs": "*", "dicomweb-client": "^0.10.4", "prop-types": "^15.6.2", @@ -299,7 +299,7 @@ }, "extensions/dicom-microscopy": { "name": "@ohif/extension-dicom-microscopy", - "version": "3.10.0-beta.140", + "version": "3.10.0-beta.142", "dependencies": { "@babel/runtime": "^7.20.13", "@cornerstonejs/codec-charls": "^1.2.3", @@ -310,10 +310,10 @@ "mathjs": "^12.4.2", }, "peerDependencies": { - "@ohif/core": "3.10.0-beta.140", - "@ohif/extension-default": "3.10.0-beta.140", - "@ohif/i18n": "3.10.0-beta.140", - "@ohif/ui": "3.10.0-beta.140", + "@ohif/core": "3.10.0-beta.142", + "@ohif/extension-default": "3.10.0-beta.142", + "@ohif/i18n": "3.10.0-beta.142", + "@ohif/ui": "3.10.0-beta.142", "prop-types": "^15.6.2", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -324,14 +324,14 @@ }, "extensions/dicom-pdf": { "name": "@ohif/extension-dicom-pdf", - "version": "3.10.0-beta.140", + "version": "3.10.0-beta.142", "dependencies": { "@babel/runtime": "^7.20.13", "classnames": "^2.3.2", }, "peerDependencies": { - "@ohif/core": "3.10.0-beta.140", - "@ohif/ui": "3.10.0-beta.140", + "@ohif/core": "3.10.0-beta.142", + "@ohif/ui": "3.10.0-beta.142", "dcmjs": "*", "dicom-parser": "^1.8.9", "hammerjs": "^2.0.8", @@ -341,14 +341,14 @@ }, "extensions/dicom-video": { "name": "@ohif/extension-dicom-video", - "version": "3.10.0-beta.140", + "version": "3.10.0-beta.142", "dependencies": { "@babel/runtime": "^7.20.13", "classnames": "^2.3.2", }, "peerDependencies": { - "@ohif/core": "3.10.0-beta.140", - "@ohif/ui": "3.10.0-beta.140", + "@ohif/core": "3.10.0-beta.142", + "@ohif/ui": "3.10.0-beta.142", "dcmjs": "*", "dicom-parser": "^1.8.9", "hammerjs": "^2.0.8", @@ -358,20 +358,20 @@ }, "extensions/measurement-tracking": { "name": "@ohif/extension-measurement-tracking", - "version": "3.10.0-beta.140", + "version": "3.10.0-beta.142", "dependencies": { "@babel/runtime": "^7.20.13", - "@ohif/ui": "3.10.0-beta.140", + "@ohif/ui": "3.10.0-beta.142", "@xstate/react": "^3.2.2", "xstate": "^4.10.0", }, "peerDependencies": { "@cornerstonejs/core": "^3.7.17", "@cornerstonejs/tools": "^3.7.17", - "@ohif/core": "3.10.0-beta.140", - "@ohif/extension-cornerstone-dicom-sr": "3.10.0-beta.140", - "@ohif/extension-default": "3.10.0-beta.140", - "@ohif/ui": "3.10.0-beta.140", + "@ohif/core": "3.10.0-beta.142", + "@ohif/extension-cornerstone-dicom-sr": "3.10.0-beta.142", + "@ohif/extension-default": "3.10.0-beta.142", + "@ohif/ui": "3.10.0-beta.142", "classnames": "^2.3.2", "dcmjs": "*", "lodash.debounce": "^4.0.8", @@ -384,14 +384,14 @@ }, "extensions/test-extension": { "name": "@ohif/extension-test", - "version": "3.10.0-beta.140", + "version": "3.10.0-beta.142", "dependencies": { "@babel/runtime": "^7.20.13", "classnames": "^2.3.2", }, "peerDependencies": { - "@ohif/core": "3.10.0-beta.140", - "@ohif/ui": "3.10.0-beta.140", + "@ohif/core": "3.10.0-beta.142", + "@ohif/ui": "3.10.0-beta.142", "dcmjs": "0.38.0", "dicom-parser": "^1.8.9", "hammerjs": "^2.0.8", @@ -401,14 +401,14 @@ }, "extensions/tmtv": { "name": "@ohif/extension-tmtv", - "version": "3.10.0-beta.140", + "version": "3.10.0-beta.142", "dependencies": { "@babel/runtime": "^7.20.13", "classnames": "^2.3.2", }, "peerDependencies": { - "@ohif/core": "3.10.0-beta.140", - "@ohif/ui": "3.10.0-beta.140", + "@ohif/core": "3.10.0-beta.142", + "@ohif/ui": "3.10.0-beta.142", "dcmjs": "*", "dicom-parser": "^1.8.9", "hammerjs": "^2.0.8", @@ -418,7 +418,7 @@ }, "modes/basic-dev-mode": { "name": "@ohif/mode-basic-dev-mode", - "version": "3.10.0-beta.140", + "version": "3.10.0-beta.142", "dependencies": { "@babel/runtime": "^7.20.13", "i18next": "^17.0.3", @@ -428,17 +428,17 @@ "webpack-merge": "^5.7.3", }, "peerDependencies": { - "@ohif/core": "3.10.0-beta.140", - "@ohif/extension-cornerstone": "3.10.0-beta.140", - "@ohif/extension-cornerstone-dicom-sr": "3.10.0-beta.140", - "@ohif/extension-default": "3.10.0-beta.140", - "@ohif/extension-dicom-pdf": "3.10.0-beta.140", - "@ohif/extension-dicom-video": "3.10.0-beta.140", + "@ohif/core": "3.10.0-beta.142", + "@ohif/extension-cornerstone": "3.10.0-beta.142", + "@ohif/extension-cornerstone-dicom-sr": "3.10.0-beta.142", + "@ohif/extension-default": "3.10.0-beta.142", + "@ohif/extension-dicom-pdf": "3.10.0-beta.142", + "@ohif/extension-dicom-video": "3.10.0-beta.142", }, }, "modes/basic-test-mode": { "name": "@ohif/mode-test", - "version": "3.10.0-beta.140", + "version": "3.10.0-beta.142", "dependencies": { "@babel/runtime": "^7.20.13", "i18next": "^17.0.3", @@ -448,19 +448,19 @@ "webpack-merge": "^5.7.3", }, "peerDependencies": { - "@ohif/core": "3.10.0-beta.140", - "@ohif/extension-cornerstone": "3.10.0-beta.140", - "@ohif/extension-cornerstone-dicom-sr": "3.10.0-beta.140", - "@ohif/extension-default": "3.10.0-beta.140", - "@ohif/extension-dicom-pdf": "3.10.0-beta.140", - "@ohif/extension-dicom-video": "3.10.0-beta.140", - "@ohif/extension-measurement-tracking": "3.10.0-beta.140", - "@ohif/extension-test": "3.10.0-beta.140", + "@ohif/core": "3.10.0-beta.142", + "@ohif/extension-cornerstone": "3.10.0-beta.142", + "@ohif/extension-cornerstone-dicom-sr": "3.10.0-beta.142", + "@ohif/extension-default": "3.10.0-beta.142", + "@ohif/extension-dicom-pdf": "3.10.0-beta.142", + "@ohif/extension-dicom-video": "3.10.0-beta.142", + "@ohif/extension-measurement-tracking": "3.10.0-beta.142", + "@ohif/extension-test": "3.10.0-beta.142", }, }, "modes/longitudinal": { "name": "@ohif/mode-longitudinal", - "version": "3.10.0-beta.140", + "version": "3.10.0-beta.142", "dependencies": { "@babel/runtime": "^7.20.13", "i18next": "^17.0.3", @@ -470,32 +470,32 @@ "webpack-merge": "^5.7.3", }, "peerDependencies": { - "@ohif/core": "3.10.0-beta.140", - "@ohif/extension-cornerstone": "3.10.0-beta.140", - "@ohif/extension-cornerstone-dicom-rt": "3.10.0-beta.140", - "@ohif/extension-cornerstone-dicom-seg": "3.10.0-beta.140", - "@ohif/extension-cornerstone-dicom-sr": "3.10.0-beta.140", - "@ohif/extension-default": "3.10.0-beta.140", - "@ohif/extension-dicom-pdf": "3.10.0-beta.140", - "@ohif/extension-dicom-video": "3.10.0-beta.140", - "@ohif/extension-measurement-tracking": "3.10.0-beta.140", + "@ohif/core": "3.10.0-beta.142", + "@ohif/extension-cornerstone": "3.10.0-beta.142", + "@ohif/extension-cornerstone-dicom-rt": "3.10.0-beta.142", + "@ohif/extension-cornerstone-dicom-seg": "3.10.0-beta.142", + "@ohif/extension-cornerstone-dicom-sr": "3.10.0-beta.142", + "@ohif/extension-default": "3.10.0-beta.142", + "@ohif/extension-dicom-pdf": "3.10.0-beta.142", + "@ohif/extension-dicom-video": "3.10.0-beta.142", + "@ohif/extension-measurement-tracking": "3.10.0-beta.142", }, }, "modes/microscopy": { "name": "@ohif/mode-microscopy", - "version": "3.10.0-beta.140", + "version": "3.10.0-beta.142", "dependencies": { "@babel/runtime": "^7.20.13", "i18next": "^17.0.3", }, "peerDependencies": { - "@ohif/core": "3.10.0-beta.140", - "@ohif/extension-dicom-microscopy": "3.10.0-beta.140", + "@ohif/core": "3.10.0-beta.142", + "@ohif/extension-dicom-microscopy": "3.10.0-beta.142", }, }, "modes/preclinical-4d": { "name": "@ohif/mode-preclinical-4d", - "version": "3.10.0-beta.140", + "version": "3.10.0-beta.142", "dependencies": { "@babel/runtime": "^7.20.13", }, @@ -504,17 +504,17 @@ "webpack-merge": "^5.7.3", }, "peerDependencies": { - "@ohif/core": "3.10.0-beta.140", - "@ohif/extension-cornerstone": "3.10.0-beta.140", - "@ohif/extension-cornerstone-dicom-seg": "3.10.0-beta.140", - "@ohif/extension-cornerstone-dynamic-volume": "3.10.0-beta.140", - "@ohif/extension-default": "3.10.0-beta.140", - "@ohif/extension-tmtv": "3.10.0-beta.140", + "@ohif/core": "3.10.0-beta.142", + "@ohif/extension-cornerstone": "3.10.0-beta.142", + "@ohif/extension-cornerstone-dicom-seg": "3.10.0-beta.142", + "@ohif/extension-cornerstone-dynamic-volume": "3.10.0-beta.142", + "@ohif/extension-default": "3.10.0-beta.142", + "@ohif/extension-tmtv": "3.10.0-beta.142", }, }, "modes/segmentation": { "name": "@ohif/mode-segmentation", - "version": "3.10.0-beta.140", + "version": "3.10.0-beta.142", "dependencies": { "@babel/runtime": "^7.20.13", "i18next": "^17.0.3", @@ -546,19 +546,19 @@ "webpack-merge": "^5.7.3", }, "peerDependencies": { - "@ohif/core": "3.10.0-beta.140", - "@ohif/extension-cornerstone": "3.10.0-beta.140", - "@ohif/extension-cornerstone-dicom-rt": "3.10.0-beta.140", - "@ohif/extension-cornerstone-dicom-seg": "3.10.0-beta.140", - "@ohif/extension-cornerstone-dicom-sr": "3.10.0-beta.140", - "@ohif/extension-default": "3.10.0-beta.140", - "@ohif/extension-dicom-pdf": "3.10.0-beta.140", - "@ohif/extension-dicom-video": "3.10.0-beta.140", + "@ohif/core": "3.10.0-beta.142", + "@ohif/extension-cornerstone": "3.10.0-beta.142", + "@ohif/extension-cornerstone-dicom-rt": "3.10.0-beta.142", + "@ohif/extension-cornerstone-dicom-seg": "3.10.0-beta.142", + "@ohif/extension-cornerstone-dicom-sr": "3.10.0-beta.142", + "@ohif/extension-default": "3.10.0-beta.142", + "@ohif/extension-dicom-pdf": "3.10.0-beta.142", + "@ohif/extension-dicom-video": "3.10.0-beta.142", }, }, "modes/tmtv": { "name": "@ohif/mode-tmtv", - "version": "3.10.0-beta.140", + "version": "3.10.0-beta.142", "dependencies": { "@babel/runtime": "^7.20.13", "i18next": "^17.0.3", @@ -568,18 +568,18 @@ "webpack-merge": "^5.7.3", }, "peerDependencies": { - "@ohif/core": "3.10.0-beta.140", - "@ohif/extension-cornerstone": "3.10.0-beta.140", - "@ohif/extension-cornerstone-dicom-sr": "3.10.0-beta.140", - "@ohif/extension-default": "3.10.0-beta.140", - "@ohif/extension-dicom-pdf": "3.10.0-beta.140", - "@ohif/extension-dicom-video": "3.10.0-beta.140", - "@ohif/extension-measurement-tracking": "3.10.0-beta.140", + "@ohif/core": "3.10.0-beta.142", + "@ohif/extension-cornerstone": "3.10.0-beta.142", + "@ohif/extension-cornerstone-dicom-sr": "3.10.0-beta.142", + "@ohif/extension-default": "3.10.0-beta.142", + "@ohif/extension-dicom-pdf": "3.10.0-beta.142", + "@ohif/extension-dicom-video": "3.10.0-beta.142", + "@ohif/extension-measurement-tracking": "3.10.0-beta.142", }, }, "platform/app": { "name": "@ohif/app", - "version": "3.10.0-beta.140", + "version": "3.10.0-beta.142", "dependencies": { "@babel/runtime": "^7.20.13", "@cornerstonejs/codec-charls": "^1.2.3", @@ -588,23 +588,23 @@ "@cornerstonejs/codec-openjph": "^2.4.5", "@cornerstonejs/dicom-image-loader": "^3.7.17", "@emotion/serialize": "^1.1.3", - "@ohif/core": "3.10.0-beta.140", - "@ohif/extension-cornerstone": "3.10.0-beta.140", - "@ohif/extension-cornerstone-dicom-rt": "3.10.0-beta.140", - "@ohif/extension-cornerstone-dicom-seg": "3.10.0-beta.140", - "@ohif/extension-cornerstone-dicom-sr": "3.10.0-beta.140", - "@ohif/extension-default": "3.10.0-beta.140", - "@ohif/extension-dicom-microscopy": "3.10.0-beta.140", - "@ohif/extension-dicom-pdf": "3.10.0-beta.140", - "@ohif/extension-dicom-video": "3.10.0-beta.140", - "@ohif/extension-test": "3.10.0-beta.140", - "@ohif/i18n": "3.10.0-beta.140", - "@ohif/mode-basic-dev-mode": "3.10.0-beta.140", - "@ohif/mode-longitudinal": "3.10.0-beta.140", - "@ohif/mode-microscopy": "3.10.0-beta.140", - "@ohif/mode-test": "3.10.0-beta.140", - "@ohif/ui": "3.10.0-beta.140", - "@ohif/ui-next": "3.10.0-beta.140", + "@ohif/core": "3.10.0-beta.142", + "@ohif/extension-cornerstone": "3.10.0-beta.142", + "@ohif/extension-cornerstone-dicom-rt": "3.10.0-beta.142", + "@ohif/extension-cornerstone-dicom-seg": "3.10.0-beta.142", + "@ohif/extension-cornerstone-dicom-sr": "3.10.0-beta.142", + "@ohif/extension-default": "3.10.0-beta.142", + "@ohif/extension-dicom-microscopy": "3.10.0-beta.142", + "@ohif/extension-dicom-pdf": "3.10.0-beta.142", + "@ohif/extension-dicom-video": "3.10.0-beta.142", + "@ohif/extension-test": "3.10.0-beta.142", + "@ohif/i18n": "3.10.0-beta.142", + "@ohif/mode-basic-dev-mode": "3.10.0-beta.142", + "@ohif/mode-longitudinal": "3.10.0-beta.142", + "@ohif/mode-microscopy": "3.10.0-beta.142", + "@ohif/mode-test": "3.10.0-beta.142", + "@ohif/ui": "3.10.0-beta.142", + "@ohif/ui-next": "3.10.0-beta.142", "@svgr/webpack": "^8.1.0", "@types/react": "^18.3.3", "classnames": "^2.3.2", @@ -645,7 +645,7 @@ }, "platform/cli": { "name": "@ohif/cli", - "version": "3.10.0-beta.140", + "version": "3.10.0-beta.142", "bin": { "ohif-cli": "src/index.js", }, @@ -669,7 +669,7 @@ }, "platform/core": { "name": "@ohif/core", - "version": "3.10.0-beta.140", + "version": "3.10.0-beta.142", "dependencies": { "@babel/runtime": "^7.20.13", "dcmjs": "*", @@ -695,14 +695,14 @@ "@cornerstonejs/codec-openjpeg": "^1.2.4", "@cornerstonejs/codec-openjph": "^2.4.5", "@cornerstonejs/dicom-image-loader": "^3.7.17", - "@ohif/ui": "3.10.0-beta.140", + "@ohif/ui": "3.10.0-beta.142", "cornerstone-math": "0.1.9", "dicom-parser": "^1.8.21", }, }, "platform/i18n": { "name": "@ohif/i18n", - "version": "3.10.0-beta.140", + "version": "3.10.0-beta.142", "dependencies": { "@babel/runtime": "^7.20.13", "i18next-locize-backend": "^2.0.0", @@ -727,7 +727,7 @@ }, "platform/ui": { "name": "@ohif/ui", - "version": "3.10.0-beta.140", + "version": "3.10.0-beta.142", "dependencies": { "@testing-library/react": "^13.1.0", "browser-detect": "^0.2.28", @@ -787,7 +787,7 @@ }, "platform/ui-next": { "name": "@ohif/ui-next", - "version": "3.10.0-beta.140", + "version": "3.10.0-beta.142", "dependencies": { "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-checkbox": "^1.1.1", @@ -830,13 +830,16 @@ }, }, "overrides": { - "trim-newlines": "^5.0.0", - "path-to-regexp": "0.1.12", - "glob-parent": "^6.0.2", - "rollup": "2.79.2", "commander": "8.3.0", - "body-parser": "1.20.3", + "path-to-regexp": "0.1.12", "nth-check": "^2.1.1", + "trim-newlines": "^5.0.0", + "glob-parent": "^6.0.2", + "trim": "^1.0.0", + "package-json": "^8.1.0", + "sharp": "^0.32.6", + "rollup": "2.79.2", + "body-parser": "1.20.3", }, "packages": { "@adobe/css-tools": ["@adobe/css-tools@4.4.1", "", {}, "sha512-12WGKBQzjUAI4ayyF4IAtfw2QR/IDoqk6jTddXDhtYTJF9ASmoE1zst7cVtP0aL/F1jUJL5r+JxKXKEgHNbEUQ=="], diff --git a/extensions/cornerstone-dynamic-volume/src/panels/DynamicVolumeControls.tsx b/extensions/cornerstone-dynamic-volume/src/panels/DynamicVolumeControls.tsx index 53aab398d..7c11485f1 100644 --- a/extensions/cornerstone-dynamic-volume/src/panels/DynamicVolumeControls.tsx +++ b/extensions/cornerstone-dynamic-volume/src/panels/DynamicVolumeControls.tsx @@ -1,12 +1,24 @@ import React, { useState } from 'react'; -import { Button, PanelSection, ButtonGroup, IconButton, InputNumber } from '@ohif/ui'; -import { Icons, Tooltip, TooltipTrigger, TooltipContent, Numeric } from '@ohif/ui-next'; +import { + Tabs, + TabsList, + TabsTrigger, + Button, + PanelSection, + Icons, + Tooltip, + TooltipTrigger, + TooltipContent, + Numeric, +} from '@ohif/ui-next'; import { Enums } from '@cornerstonejs/core'; -const controlClassNames = { - sizeClassName: 'w-[58px] h-[28px]', - arrowsDirection: 'horizontal', - labelPosition: 'bottom', +// Helper function to safely convert any value to uppercase string +const toUpperCaseString = value => { + if (value === null || value === undefined) { + return ''; + } + return String(value).toUpperCase(); }; const Header = ({ title, tooltip }) => ( @@ -16,18 +28,18 @@ const Header = ({ title, tooltip }) => (
{tooltip}
- {title} + {title} ); @@ -51,112 +63,138 @@ const DynamicVolumeControls = ({ const [computedView, setComputedView] = useState(false); const [computeViewMode, setComputeViewMode] = useState(Enums.DynamicOperatorType.SUM); + // Wrapper for onGenerate to handle potential errors + const handleGenerate = () => { + try { + if (typeof onGenerate === 'function') { + onGenerate(computeViewMode); + } else { + console.error('onGenerate is not a function', onGenerate); + } + } catch (error) { + console.error('Error in onGenerate:', error); + } + }; + return (
- -
-
- - - - -
-
- -
-
-
- Operation Buttons (SUM, AVERAGE, SUBTRACT): Select the mathematical operation to be - applied to the data set. -
Range Slider: Choose the numeric range of dimension groups within which the - operation will be performed. -
- Generate Button: Execute the chosen operation on the specified range of data. -
- } - /> - - - - - -
- - - + + + 4D + + + Computed + + +
- -
+
+ +
+
+
+ Operation Buttons (SUM, AVERAGE, SUBTRACT): Select the mathematical operation to + be applied to the data set. +
Range Slider: Choose the numeric range of dimension groups within which the + operation will be performed. +
+ Generate Button: Execute the chosen operation on the specified range of data. +
+ } + /> + { + setComputeViewMode(value); + }} + className="mt-2 w-full" + > + + {' '} + + {toUpperCaseString(Enums.DynamicOperatorType.SUM)} + + + {toUpperCaseString(Enums.DynamicOperatorType.AVERAGE)} + + + {toUpperCaseString(Enums.DynamicOperatorType.SUBTRACT)} + + + +
+ {})} + > + + +
+ + + ); @@ -179,7 +217,7 @@ function DimensionGroupControls({ const getPlayPauseIconName = () => (isPlaying ? 'icon-pause' : 'icon-play'); return ( -
+
- onPlayPauseChange(!isPlaying)} + variant="secondary" + size="default" + className="w-[58px]" + onClick={() => { + if (typeof onPlayPauseChange === 'function') { + onPlayPauseChange(!isPlaying); + } + }} > - - - + + + {})} + min={1} + max={numDimensionGroups || 1} + step={1} + > +
+ + Frame +
+
+ + {})} + min={minFps || 1} + max={maxFps || 30} + step={1} + > +
+ + FPS +
+
); diff --git a/extensions/cornerstone/src/components/ViewportWindowLevel/ViewportWindowLevel.tsx b/extensions/cornerstone/src/components/ViewportWindowLevel/ViewportWindowLevel.tsx index 3810d5467..276dba05d 100644 --- a/extensions/cornerstone/src/components/ViewportWindowLevel/ViewportWindowLevel.tsx +++ b/extensions/cornerstone/src/components/ViewportWindowLevel/ViewportWindowLevel.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useCallback, useState, ReactElement, useMemo } from 'react'; import PropTypes from 'prop-types'; import debounce from 'lodash.debounce'; -import { PanelSection, WindowLevel } from '@ohif/ui'; +import { PanelSection, WindowLevel } from '@ohif/ui-next'; import { Enums, eventTarget } from '@cornerstonejs/core'; import { useActiveViewportDisplaySets } from '@ohif/core'; import { @@ -20,7 +20,7 @@ const ViewportWindowLevel = ({ viewportId: string; }>): ReactElement => { const { cornerstoneViewportService } = servicesManager.services; - const [windowLevels, setWindowLevels] = useState([]); + const [windowLevels, setWindowLevels] = useState([]); const [isLoading, setIsLoading] = useState(true); const displaySets = useActiveViewportDisplaySets({ servicesManager }); @@ -200,29 +200,41 @@ const ViewportWindowLevel = ({ }, [viewportId, cornerstoneViewportService, updateViewportHistograms, displaySetIds, isLoading]); return ( - - {windowLevels.map((windowLevel, i) => { - if (!windowLevel.histogram) { - return null; - } + + Window Level + + {windowLevels.map((windowLevel, i) => { + if (!windowLevel.histogram) { + return null; + } - return ( - handleVOIChange(windowLevel.volumeId, voi)} - opacity={windowLevel.opacity} - onOpacityChange={opacity => - handleOpacityChange(windowLevel.viewportId, i, windowLevel.volumeId, opacity) - } - /> - ); - })} + return ( + handleVOIChange(windowLevel.volumeId, voi)} + opacity={windowLevel.opacity} + onOpacityChange={opacity => + handleOpacityChange(windowLevel.viewportId, i, windowLevel.volumeId, opacity) + } + /> + ); + })} + {windowLevels.length === 0 && !isLoading && ( +
+ No window level data available +
+ )} + {isLoading && ( +
+ Loading window level data... +
+ )} +
); }; diff --git a/platform/docs/package.json b/platform/docs/package.json index 4aad06878..3cb7bf8aa 100644 --- a/platform/docs/package.json +++ b/platform/docs/package.json @@ -60,6 +60,7 @@ "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.2", + "@radix-ui/react-hover-card":"^1.1.6", "@svgr/webpack": "^5.5.0", "@types/react": "^18.2.29", "autoprefixer": "^10.4.20", diff --git a/platform/docs/src/pages/components/NumericMetaShowcase.tsx b/platform/docs/src/pages/components/NumericMetaShowcase.tsx index 010770864..b14b6dc88 100644 --- a/platform/docs/src/pages/components/NumericMetaShowcase.tsx +++ b/platform/docs/src/pages/components/NumericMetaShowcase.tsx @@ -9,6 +9,7 @@ import ShowcaseRow from './ShowcaseRow'; export default function NumericShowcase() { const [controlledValue, setControlledValue] = useState(0); const [controlledValues, setControlledValues] = useState([0, 100] as [number, number]); + const [dimensionGroupNumber, setDimensionGroupNumber] = useState(1); return (
@@ -87,6 +88,108 @@ export default function NumericShowcase() {
+ {/* Stepper Controls */} + setDimensionGroupNumber(val as number)} + min={1} + max={5} + step={1} +> +
+ + Frame +
+ + + +
+ Opacity + +
+
+ + +
+ Zoom: + +
+
`} + > +
+ setDimensionGroupNumber(val as number)} + min={1} + max={5} + step={1} + > +
+ + FPS + + Frame +
+
+ + +
+ Opacity + +
+
+ + +
+ + Zoom +
+
+
+
+ {/* Single Range Slider */} ([0, 1 + +
+ Offset + + +
+
+ ([0, 1 + +
+ Offset + +
+
+ { + if (Number.isInteger(step)) { + return 0; + } + const stepStr = step.toString(); + if (stepStr.includes('.')) { + return stepStr.split('.')[1].length; + } + return 0; +}; + +// Enhanced context type - includes shared constraints +type InputNumberContextType = { + value: number | string; + setValue: React.Dispatch>; + onChange: (value: number) => void; + // Shared constraints that can be used by subcomponents + constraints?: { + min?: number; + max?: number; + step?: number; + disabled?: boolean; + }; +}; + +// Create context +const InputNumberContext = React.createContext(undefined); + +// Hook to use context +const useInputNumber = () => { + const context = React.useContext(InputNumberContext); + if (!context) { + throw new Error('useInputNumber must be used within an component'); + } + return context; +}; + +// Simplified root component props +export interface InputNumberProps { + value: number; + onChange: (value: number) => void; + className?: string; + children?: React.ReactNode; +} + +// The single top-level InputNumber component - much simpler now +const InputNumber = React.forwardRef( + ({ value, onChange, className, children, ...props }, ref) => { + const [inputValue, setInputValue] = React.useState(value); + + // Update internal state when prop changes + React.useEffect(() => { + setInputValue(value); + }, [value]); + + // Context value - only core state + const contextValue = React.useMemo( + () => ({ + value: inputValue, + setValue: setInputValue, + onChange, + }), + [inputValue, onChange] + ); + + return ( + +
+ {children} +
+
+ ); + } +); + +InputNumber.displayName = 'InputNumber'; + +// Input component with its own constraints +export interface InputNumberInputProps + extends Omit, 'onChange'> { + min?: number; + max?: number; + step?: number; +} + +const InputNumberInput = React.forwardRef( + ({ className, min, max, step, ...props }, ref) => { + // Get context which may include constraints from parent + const { value, setValue, onChange, constraints = {} } = useInputNumber(); + + // Use provided props or fall back to parent constraints or defaults + const effectiveMin = min ?? constraints.min ?? 0; + const effectiveMax = max ?? constraints.max ?? 100; + const effectiveStep = step ?? constraints.step ?? 1; + const decimalPlaces = getDecimalPlaces(effectiveStep); + + // Format displayed value with proper decimal places + const displayValue = React.useMemo(() => { + if (typeof value === 'string') { + return value; + } + return decimalPlaces > 0 ? value.toFixed(decimalPlaces) : value.toString(); + }, [value, decimalPlaces]); + + // Handle input change with constraint awareness + const handleInputChange = React.useCallback( + (e: React.ChangeEvent) => { + const val = e.target.value; + + // Allow empty string, minus sign, or decimal point for flexibility + if (val === '' || val === '-' || val === '.') { + setValue(val); + return; + } + + const numValue = Number(val); + if (!isNaN(numValue)) { + setValue(numValue); + // Only call onChange if value is within boundaries + if (numValue >= effectiveMin && numValue <= effectiveMax) { + onChange(numValue); + } + } + }, + [effectiveMin, effectiveMax, onChange, setValue] + ); + + // Handle blur to format and validate + const handleBlur = React.useCallback(() => { + if (typeof value === 'string') { + // Handle empty or partial inputs + if (value === '' || value === '-' || value === '.') { + setValue(effectiveMin); + onChange(effectiveMin); + return; + } + + const numValue = parseFloat(value); + if (isNaN(numValue)) { + setValue(effectiveMin); + onChange(effectiveMin); + return; + } + + // Constrain value to min/max + const boundedValue = Math.max(effectiveMin, Math.min(effectiveMax, numValue)); + setValue(boundedValue); + onChange(boundedValue); + } + }, [value, effectiveMin, effectiveMax, onChange, setValue]); + + return ( + + ); + } +); + +InputNumberInput.displayName = 'InputNumber.Input'; + +// Label component with position prop +export interface InputNumberLabelProps extends React.HTMLAttributes { + position?: 'left' | 'right' | 'top' | 'bottom'; +} + +const InputNumberLabel = React.forwardRef( + ({ className, position = 'left', children, ...props }, ref) => { + const positionClasses = { + left: 'mr-2', + right: 'ml-2', + top: 'mb-1', + bottom: 'mt-1', + }; + + return ( + + ); + } +); + +InputNumberLabel.displayName = 'InputNumber.Label'; + +// Container for horizontal button layout with constraints that are shared via context +export interface InputNumberHorizontalControlsProps extends React.HTMLAttributes { + min?: number; + max?: number; + step?: number; + disabled?: boolean; +} + +const InputNumberHorizontalControls = React.forwardRef< + HTMLDivElement, + InputNumberHorizontalControlsProps +>(({ className, children, min = 0, max = 100, step = 1, disabled = false, ...props }, ref) => { + // Get existing context and enhance it with constraints + const context = useInputNumber(); + const { value, onChange } = context; + + // Set constraints in context for child components to use + React.useEffect(() => { + context.constraints = { min, max, step, disabled }; + }, [context, min, max, step, disabled]); + + // Increment function with local constraints + const increment = React.useCallback(() => { + if (disabled) { + return; + } + + const currentValue = typeof value === 'string' ? parseFloat(value) || min : value; + const newValue = Math.min(currentValue + step, max); + onChange(newValue); + }, [value, min, max, step, onChange, disabled]); + + // Decrement function with local constraints + const decrement = React.useCallback(() => { + if (disabled) { + return; + } + + const currentValue = typeof value === 'string' ? parseFloat(value) || min : value; + const newValue = Math.max(currentValue - step, min); + onChange(newValue); + }, [value, min, max, step, onChange, disabled]); + + return ( +
+ + + {children} + + +
+ ); +}); + +InputNumberHorizontalControls.displayName = 'InputNumber.HorizontalControls'; + +// Vertical controls with its own constraints +export interface InputNumberVerticalControlsProps extends React.HTMLAttributes { + min?: number; + max?: number; + step?: number; + disabled?: boolean; +} + +const InputNumberVerticalControls = React.forwardRef< + HTMLDivElement, + InputNumberVerticalControlsProps +>(({ className, children, min = 0, max = 100, step = 1, disabled = false, ...props }, ref) => { + // Get existing context and enhance it with constraints + const context = useInputNumber(); + const { value, onChange } = context; + + // Set constraints in context for child components to use + React.useEffect(() => { + context.constraints = { min, max, step, disabled }; + }, [context, min, max, step, disabled]); + + // Increment function with local constraints + const increment = React.useCallback(() => { + if (disabled) { + return; + } + + const currentValue = typeof value === 'string' ? parseFloat(value) || min : value; + const newValue = Math.min(currentValue + step, max); + onChange(newValue); + }, [value, min, max, step, onChange, disabled]); + + // Decrement function with local constraints + const decrement = React.useCallback(() => { + if (disabled) { + return; + } + + const currentValue = typeof value === 'string' ? parseFloat(value) || min : value; + const newValue = Math.max(currentValue - step, min); + onChange(newValue); + }, [value, min, max, step, onChange, disabled]); + + return ( +
+ {children} +
+ + +
+
+ ); +}); + +InputNumberVerticalControls.displayName = 'InputNumber.VerticalControls'; + +// Container with size prop +const sizesClasses = { + sm: 'w-[45px] h-[28px]', + md: 'w-[58px] h-[28px]', + lg: 'w-[206px] h-[35px]', +}; + +export interface InputNumberContainerProps extends React.HTMLAttributes { + size?: 'sm' | 'md' | 'lg'; + sizeClassName?: string; +} + +const InputNumberContainer = React.forwardRef( + ({ className, size = 'md', sizeClassName, children, ...props }, ref) => { + const sizeToUse = sizeClassName || sizesClasses[size]; + + return ( +
+ {children} +
+ ); + } +); + +InputNumberContainer.displayName = 'InputNumber.Container'; + +// Attach subcomponents +Object.assign(InputNumber, { + Input: InputNumberInput, + Label: InputNumberLabel, + Container: InputNumberContainer, + HorizontalControls: InputNumberHorizontalControls, + VerticalControls: InputNumberVerticalControls, +}); + +export { InputNumber }; diff --git a/platform/ui-next/src/components/InputNumber/index.ts b/platform/ui-next/src/components/InputNumber/index.ts new file mode 100644 index 000000000..d148bb55e --- /dev/null +++ b/platform/ui-next/src/components/InputNumber/index.ts @@ -0,0 +1,3 @@ +import { InputNumber } from './InputNumber'; +export { InputNumber }; +export type { InputNumberProps } from './InputNumber'; diff --git a/platform/ui-next/src/components/Numeric/Numeric.tsx b/platform/ui-next/src/components/Numeric/Numeric.tsx index b2cf66c68..dc1e6e081 100644 --- a/platform/ui-next/src/components/Numeric/Numeric.tsx +++ b/platform/ui-next/src/components/Numeric/Numeric.tsx @@ -5,9 +5,23 @@ import { cn } from '../../lib/utils'; import { Input } from '../Input/Input'; import { Slider } from '../Slider/Slider'; import { DoubleSlider } from '../DoubleSlider/DoubleSlider'; +import { Button } from '../Button/Button'; +import { ChevronUp, ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react'; + +// Calculate decimal places based on step +const getDecimalPlaces = (step: number): number => { + if (Number.isInteger(step)) { + return 0; + } + const stepStr = step.toString(); + if (stepStr.includes('.')) { + return stepStr.split('.')[1].length; + } + return 0; +}; interface NumericMetaContextValue { - mode: 'number' | 'singleRange' | 'doubleRange'; + mode: 'number' | 'singleRange' | 'doubleRange' | 'stepper'; singleValue: number; doubleValue: [number, number]; setSingleValue: (val: number) => void; @@ -23,7 +37,7 @@ const NumericMetaContext = createContext(null); 1) Container ---------------------------------------------------------------------------*/ interface NumericMetaContainerProps { - mode: 'number' | 'singleRange' | 'doubleRange'; + mode: 'number' | 'singleRange' | 'doubleRange' | 'stepper'; value?: number; // for controlled single-value usage from parent defaultValue?: number; // for uncontrolled single-value usage values?: [number, number]; // for controlled double-range usage from parent @@ -57,10 +71,10 @@ function NumericMetaContainer({ // Use useControllableState for both single and double values const [internalSingleValue, setInternalSingleValue] = useControllableState({ - prop: mode === 'number' || mode === 'singleRange' ? value : undefined, + prop: mode === 'number' || mode === 'singleRange' || mode === 'stepper' ? value : undefined, defaultProp: calculatedDefaultValue, onChange: newVal => { - if (mode === 'number' || mode === 'singleRange') { + if (mode === 'number' || mode === 'singleRange' || mode === 'stepper') { onChange?.(newVal); } }, @@ -127,7 +141,7 @@ function NumericMetaLabel({ children, showValue, className }: NumericMetaLabelPr let displayedValue = ''; let valueClasses = ''; - if (mode === 'number' || mode === 'singleRange') { + if (mode === 'number' || mode === 'singleRange' || mode === 'stepper') { displayedValue = singleValue.toString(); valueClasses = 'w-10'; } else if (mode === 'doubleRange') { @@ -291,12 +305,177 @@ function NumberInput({ className }: NumberInputProps) { ); } +/* ------------------------------------------------------------------------- + 6) NumberStepper sub-component +---------------------------------------------------------------------------*/ +interface NumberStepperProps { + className?: string; + children?: React.ReactNode; + direction?: 'horizontal' | 'vertical'; +} + +// Modified NumberStepper component to properly position left/right controls +function NumberStepper({ className, children, direction }: NumberStepperProps) { + const ctx = useContext(NumericMetaContext); + if (!ctx) { + throw new Error('NumberStepper must be used inside .'); + } + + const { mode, singleValue, setSingleValue, min, max, step } = ctx; + if (mode !== 'stepper') { + return null; + } + + // Calculate decimal places based on step + const decimalPlaces = getDecimalPlaces(step); + + // Format displayed value with proper decimal places + const displayValue = React.useMemo(() => { + return decimalPlaces > 0 ? singleValue.toFixed(decimalPlaces) : singleValue.toString(); + }, [singleValue, decimalPlaces]); + + const handleInputChange = (evt: React.ChangeEvent) => { + const val = evt.target.value; + + // Allow empty string, minus sign, or decimal point for flexibility + if (val === '' || val === '-' || val === '.') { + return; + } + + const numValue = Number(val); + if (!isNaN(numValue)) { + setSingleValue(Math.max(min, Math.min(numValue, max))); + } + }; + + const handleBlur = () => { + // Ensure value is within constraints when input loses focus + const boundedValue = Math.max(min, Math.min(singleValue, max)); + if (boundedValue !== singleValue) { + setSingleValue(boundedValue); + } + }; + + // Check if children is HorizontalControls component + const hasHorizontalControls = direction === 'horizontal'; + + if (hasHorizontalControls) { + // We'll handle the control positioning ourselves + return ( +
+ + + {children} + +
+ ); + } + + return ( +
+ +
+ + +
+
+ ); +} + +// New components for left and right controls +function LeftControl({ min, step, value, setValue }) { + const decrement = useCallback(() => { + const newValue = Math.max(value - step, min); + setValue(newValue); + }, [value, min, step, setValue]); + + return ( + + ); +} + +function RightControl({ max, step, value, setValue }) { + const increment = useCallback(() => { + const newValue = Math.min(value + step, max); + setValue(newValue); + }, [value, max, step, setValue]); + + return ( + + ); +} + export const Numeric = { Container: NumericMetaContainer, Label: NumericMetaLabel, SingleRange, DoubleRange, NumberInput, + NumberStepper, }; -export default Numeric; +export default Numeric; \ No newline at end of file diff --git a/platform/ui-next/src/components/OHIFPanels/WindowLevel.tsx b/platform/ui-next/src/components/OHIFPanels/WindowLevel.tsx new file mode 100644 index 000000000..3b1ed7ed9 --- /dev/null +++ b/platform/ui-next/src/components/OHIFPanels/WindowLevel.tsx @@ -0,0 +1,162 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { Numeric } from '../Numeric/Numeric'; +import WindowLevelHistogram from './WindowLevelHistogram'; +import { VOI, VOIRange, Histogram, Colormap } from './types'; + +interface WindowLevelProps { + title?: string; + step?: number; + histogram: Histogram; + voi: VOI; + opacity?: number; + showOpacitySlider?: boolean; + colormap?: Colormap; + style?: 'polygon' | 'bars'; + fillColor?: string; + lineColor?: string; + containerClassName?: string; + onVOIChange?: (voi: VOI) => void; + onOpacityChange?: (opacity: number) => void; +} + +const convertVOItoVOIRange = (voi: VOI): VOIRange => { + return { + min: voi.windowCenter - voi.windowWidth / 2, + max: voi.windowCenter + voi.windowWidth / 2, + }; +}; + +const WindowLevel = ({ + title, + step = 1, + histogram, + voi: voiProp, + opacity: opacityProp = 1, + showOpacitySlider = false, + colormap, + style = 'polygon', + fillColor = '#3f3f3f', + lineColor = '#707070', + containerClassName, + onVOIChange, + onOpacityChange, +}: WindowLevelProps) => { + // Define range from histogram + const range = useMemo( + () => ({ + min: histogram.range.min, + max: histogram.range.max, + }), + [histogram] + ); + + // Initialize VOI range from props + const [voiRange, setVOIRange] = useState( + voiProp + ? convertVOItoVOIRange(voiProp) + : { + min: range.min, + max: range.max, + } + ); + + // Update VOI range when props change + useEffect(() => { + if (voiProp) { + setVOIRange(convertVOItoVOIRange(voiProp)); + } + }, [voiProp]); + + // Handle VOI range changes (from slider) + const handleVOIRangeChange = (newValues: [number, number]) => { + const [min, max] = newValues; + if (min === voiRange.min && max === voiRange.max) { + return; + } + + const windowWidth = max - min; + const windowCenter = min + windowWidth / 2; + + setVOIRange({ + min, + max, + }); + + if (onVOIChange) { + onVOIChange({ windowWidth, windowCenter }); + } + }; + + // Handle opacity changes + const handleOpacityChange = (value: number) => { + if (onOpacityChange) { + onOpacityChange(value); + } + }; + + return ( +
+ {title &&
{title}
} + +
+ {/* Range Labels */} +
+
+ {range.min} +
+
+ {range.max} +
+
+ + {/* Histogram Visualization */} +
+ +
+ + {/* Window Level Range Slider */} + handleVOIRangeChange(values as [number, number])} + > + + + + {/* Opacity Slider (optional) */} + {showOpacitySlider && ( +
+
+
Opacity
+
+ handleOpacityChange(value as number)} + > + + +
+
+
+ )} +
+
+ ); +}; + +export default WindowLevel; diff --git a/platform/ui-next/src/components/OHIFPanels/WindowLevelHistogram.tsx b/platform/ui-next/src/components/OHIFPanels/WindowLevelHistogram.tsx new file mode 100644 index 000000000..c2f54f088 --- /dev/null +++ b/platform/ui-next/src/components/OHIFPanels/WindowLevelHistogram.tsx @@ -0,0 +1,237 @@ +import React, { useLayoutEffect, useEffect, useRef } from 'react'; +import { Range, VOIRange, Histogram, Colormap } from './types'; + +const DEFAULT_COLORMAP = { + Name: 'Grayscale', + RGBPoints: [0, 0, 0, 0, 1, 1, 1, 1], +}; + +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(min, value), max); + +const interpolateVec3 = (a: number[], b: number[], t: number) => { + 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: HTMLCanvasElement, range: Range, voiRange: VOIRange, colormap: Colormap) => { + const context = canvas.getContext('2d'); + if (!context) return; + + const { width, height } = canvas; + const windowWidth = voiRange.max - voiRange.min; + const rgbPoints = colormap.RGBPoints; + const colorsCount = rgbPoints.length / 4; + + const getColorPoint = (index: number) => { + 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: HTMLCanvasElement, + context: CanvasRenderingContext2D, + histogram: Histogram, + options: { scale: (val: number) => number, fillColor: string, lineColor: string } +) => { + 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: HTMLCanvasElement, + context: CanvasRenderingContext2D, + histogram: Histogram, + options: { scale: (val: number) => number, fillColor: string, lineColor: string } +) => { + 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: HTMLCanvasElement, + histogram: Histogram, + options: { + style: 'polygon' | 'bars', + fillColor: string, + lineColor: string, + scale: (val: number) => number + } +) => { + const context = canvas.getContext('2d'); + if (!context) return; + + 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})`); + } +}; + +interface WindowLevelHistogramProps { + range: Range; + voiRange: VOIRange; + histogram: Histogram; + colormap?: Colormap; + style?: 'polygon' | 'bars'; + fillColor?: string; + lineColor?: string; +} + +const WindowLevelHistogram = ({ + range, + voiRange, + histogram, + colormap = DEFAULT_COLORMAP, + style = 'polygon', + fillColor = '#3f3f3f', + lineColor = '#707070', +}: WindowLevelHistogramProps) => { + 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; + if (!container || !canvas) return; + + canvas.width = container.offsetWidth; + canvas.height = container.offsetHeight; + }, [containerRef, canvasRef]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const context = canvas.getContext('2d'); + if (!context) return; + + const fnIdentity = (x: number) => 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]); + + return ( +
+ +
+ ); +}; + +export default WindowLevelHistogram; \ No newline at end of file diff --git a/platform/ui-next/src/components/OHIFPanels/index.ts b/platform/ui-next/src/components/OHIFPanels/index.ts new file mode 100644 index 000000000..b3f850149 --- /dev/null +++ b/platform/ui-next/src/components/OHIFPanels/index.ts @@ -0,0 +1,3 @@ +export { default as WindowLevel } from './WindowLevel'; +export { default as WindowLevelHistogram } from './WindowLevelHistogram'; +export * from './types'; \ No newline at end of file diff --git a/platform/ui-next/src/components/OHIFPanels/types.ts b/platform/ui-next/src/components/OHIFPanels/types.ts new file mode 100644 index 000000000..ad5d85fed --- /dev/null +++ b/platform/ui-next/src/components/OHIFPanels/types.ts @@ -0,0 +1,41 @@ +// Window-level related types + +export interface Range { + min: number; + max: number; +} + +export interface VOIRange { + min: number; + max: number; +} + +export interface VOI { + windowWidth: number; + windowCenter: number; +} + +export interface Histogram { + bins: number[] | Int32Array; + numBins: number; + maxBin: number; + maxBinValue: number; + range: Range; +} + +export interface Colormap { + Name: string; + RGBPoints: number[]; +} + +export interface WindowLevelData { + volumeId: string; + viewportId: string; + modality: string; + voi: VOI; + step: number; + histogram: Histogram; + showOpacitySlider: boolean; + opacity?: number; + colormap?: Colormap; +} \ No newline at end of file diff --git a/platform/ui-next/src/components/index.ts b/platform/ui-next/src/components/index.ts index 83bbe812b..4accaf0bc 100644 --- a/platform/ui-next/src/components/index.ts +++ b/platform/ui-next/src/components/index.ts @@ -35,6 +35,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from './Tabs'; import { Toggle, toggleVariants } from './Toggle'; import { ToggleGroup, ToggleGroupItem } from './ToggleGroup'; import { Input } from './Input'; +import { InputNumber } from './InputNumber'; import { Label } from './Label'; import { Switch } from './Switch'; import { Checkbox } from './Checkbox'; @@ -59,6 +60,7 @@ import { AboutModal, ImageModal, UserPreferencesModal } from './OHIFModals'; import Modal from './Modal/Modal'; import { FooterAction } from './FooterAction'; import { InputFilter } from './InputFilter'; +import { WindowLevel, WindowLevelHistogram } from './OHIFPanels'; import { DropdownMenu, @@ -154,11 +156,12 @@ export { Calendar, DatePickerWithRange, Input, + InputNumber, Label, Tabs, + TabsContent, TabsList, TabsTrigger, - TabsContent, Separator, Switch, Checkbox, @@ -245,4 +248,6 @@ export { FooterAction, ToolSettings, InputFilter, + WindowLevel, + WindowLevelHistogram, }; diff --git a/platform/ui-next/src/index.ts b/platform/ui-next/src/index.ts index cf07d6bff..73c4652e9 100644 --- a/platform/ui-next/src/index.ts +++ b/platform/ui-next/src/index.ts @@ -1,6 +1,5 @@ +import * as utils from './utils'; export * from './components'; export * from './contextProviders'; -import * as utils from './utils'; - export { utils };