feat(4D): Add 4D dynamic volume rendering and new pre-clinical 4d pt/ct mode (#3664)
Co-authored-by: Alireza <ar.sedghi@gmail.com> Co-authored-by: Neil <neil.a.macphee@gmail.com>
This commit is contained in:
200 files changed
+9589
-2178
No files matched your search
+56
-34
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<g fill-rule="evenodd">
|
||||
<path fill="currentcolor" fill-rule="nonzero" d="M17.207 10.793c.36.36.388.928.083 1.32l-.083.094-5 5c-.39.39-1.024.39-1.414 0-.36-.36-.388-.928-.083-1.32l.083-.094 4.292-4.293-4.292-4.293c-.36-.36-.388-.928-.083-1.32l.083-.094c.36-.36.928-.388 1.32-.083l.094.083 5 5z"/>
|
||||
<path fill="currentcolor" fill-rule="nonzero" d="M17.5 11.5c0 .513-.386.936-.883.993l-.117.007H6c-.552 0-1-.448-1-1 0-.513.386-.936.883-.993L6 10.5h10.5c.552 0 1 .448 1 1z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 568 B |
@@ -0,0 +1,22 @@
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 489.785 489.785" xml:space="preserve">
|
||||
<g id="tool-brush" stroke="currentColor" fill="currentColor">
|
||||
<path id="Path" stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M479.702,10.08L479.702,10.08c-25.866-25.864-93.419-0.238-150.886,57.231l0.001,0.001l-0.002-0.001
|
||||
c-9.503,9.502-18.126,19.282-25.793,29.104c-9.596,54.899-33.63,93.829-61.77,93.831v0.001c-9.089,0-17.749-4.064-25.633-11.412
|
||||
l-0.043,0.014l-20.831-20.832c-11.778-11.777-30.87-11.777-42.648,0l-80.851,80.849l-4.268,4.268L8.835,301.279
|
||||
c-11.776,11.778-11.776,30.872,0.001,42.648L145.86,480.953c11.777,11.777,30.869,11.776,42.647,0l58.144-58.144l4.27-4.267
|
||||
l80.849-80.85c11.774-11.778,11.774-30.871-0.001-42.648l-20.831-20.831l0.015-0.043c-7.348-7.884-11.411-16.543-11.412-25.631
|
||||
v-0.001c0-0.001,0-0.001,0-0.001c0.004-28.141,38.93-52.173,93.822-61.769c9.824-7.668,19.606-16.292,29.111-25.796l-0.028-0.031
|
||||
l0.03,0.03C479.941,103.499,505.565,35.949,479.702,10.08z M248.322,390.438l0.015,0.015l-56.174,56.175l-0.003-0.003l-14.69,14.692
|
||||
c-6.001,5.998-15.723,5.998-21.721,0L28.468,334.035c-5.996-5.996-6.002-15.722-0.003-21.721l14.691-14.69v-0.001L98.5,242.281
|
||||
l0.001,0.001c13.478-13.475,31.025-17.777,39.201-9.603c8.174,8.174,4.039,25.89-9.434,39.369l-0.001,0.001
|
||||
c-13.476,13.475-17.609,31.192-9.437,39.364c8.177,8.176,25.793,3.944,40.102-10.364l0.014,0.014
|
||||
c13.355-12.825,30.352-16.79,38.355-8.786c8.007,8.006,4.203,25.162-8.614,38.526l0.047,0.047
|
||||
c-14.308,14.308-18.441,32.025-10.269,40.198c8.177,8.175,25.795,3.942,39.269-9.533l0.835-0.831l0.013,0.012
|
||||
c13.356-12.824,30.351-16.788,38.357-8.782C264.94,359.917,261.136,377.073,248.322,390.438z M303.916,313.153
|
||||
c5.998,5.999,5.998,15.721-0.001,21.718l-17.245,17.246l-149-149.002l17.246-17.245c5.998-5.998,15.722-5.996,21.719,0.001
|
||||
L303.916,313.153z M409.705,80.082c-9.404-9.404-9.405-24.651,0.001-34.058c9.405-9.405,24.651-9.403,34.057,0
|
||||
c9.404,9.406,9.404,24.653,0.001,34.058C434.357,89.487,419.111,89.488,409.705,80.082z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 480.001 480.001" xml:space="preserve">
|
||||
<g id="tool-eraser" stroke="currentColor" fill="currentColor">
|
||||
<path d="M333.142,350.846c0.115-0.115,0.215-0.239,0.323-0.357l129.681-129.706c10.878-10.878,16.864-25.368,16.855-40.8
|
||||
c-0.01-15.409-5.999-29.865-16.854-40.694l-97.844-97.874c-10.853-10.845-25.326-16.817-40.75-16.817
|
||||
c-15.426,0-29.895,5.974-40.741,16.82L16.855,308.329C5.974,319.21-0.012,333.713,0,349.168
|
||||
c0.013,15.425,6.002,29.884,16.854,40.7l62.592,62.606c0.061,0.061,0.127,0.112,0.188,0.171c0.174,0.165,0.349,0.331,0.534,0.483
|
||||
c0.082,0.067,0.171,0.126,0.255,0.19c0.175,0.135,0.349,0.271,0.532,0.395c0.07,0.047,0.145,0.085,0.215,0.13
|
||||
c0.205,0.131,0.412,0.26,0.627,0.376c0.051,0.026,0.103,0.048,0.154,0.074c0.239,0.123,0.482,0.241,0.732,0.346
|
||||
c0.033,0.014,0.067,0.024,0.101,0.037c0.269,0.108,0.54,0.208,0.819,0.293c0.034,0.011,0.07,0.017,0.104,0.027
|
||||
c0.276,0.081,0.556,0.154,0.841,0.211c0.082,0.017,0.165,0.023,0.247,0.038c0.239,0.041,0.479,0.084,0.724,0.107
|
||||
c0.33,0.033,0.663,0.051,0.998,0.051h137.91h159.308c5.522,0,10-4.478,10-10c0-5.522-4.478-10-10-10H248.566l84.22-84.236
|
||||
C332.904,351.06,333.027,350.96,333.142,350.846z M220.285,435.404H90.66l-59.675-59.689
|
||||
c-7.076-7.054-10.977-16.487-10.985-26.563c-0.008-10.106,3.897-19.582,10.996-26.681l129.825-129.803l151.091,151.091
|
||||
L220.285,435.404z M174.965,178.527L297.953,55.56c7.069-7.069,16.516-10.963,26.6-10.963c10.085,0,19.536,3.895,26.609,10.962
|
||||
l97.85,97.88c7.08,7.063,10.982,16.493,10.989,26.557c0.006,10.085-3.899,19.547-10.998,26.645l-122.95,122.974L174.965,178.527z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 512 512" xml:space="preserve">
|
||||
<g id="tool-brush" stroke="currentColor" fill="currentColor">
|
||||
<path d="M160,0l-29.1,29.8l36.9,36.9l-143,143c-24.5,24.5-24.5,64.8,0,89.3l1.3,1.3l131.4,130.8c24.5,24.5,64.8,24.5,89.3,0
|
||||
L404,273.8l14.9-14.2L217.6,58.3l-16.2-16.8l-4.5-3.9L160,0z M197.6,96.4l163.1,163.1L217.6,402c-8.7,8.7-22.4,8.7-31.1,0
|
||||
l-132-132.7c-8.7-8.7-8.7-21.8,0-30.4L197.6,96.4z M443.5,330.8l-16.8,24.6c0,0-11.2,15.6-22,34.3c-5.4,9.4-10.5,19.2-14.9,29.1
|
||||
s-8.4,19.1-8.4,31.1c0,34.1,28.1,62.1,62.1,62.1s62.1-28.1,62.1-62.1c0-12-4-21.1-8.4-31.1c-4.4-10-9.5-19.7-14.9-29.1
|
||||
c-10.8-18.7-22-34.3-22-34.3L443.5,330.8z M443.5,405.8c1.4,2.3,1.2,2.1,2.6,4.5c4.9,8.5,10.2,17.6,13.6,25.2
|
||||
c3.4,7.7,4.5,14.8,4.5,14.2c0,11.5-9.2,20.7-20.7,20.7s-20.7-9.2-20.7-20.7c0,0.6,1.1-6.6,4.5-14.2c3.4-7.7,8.7-16.7,13.6-25.2
|
||||
C442.3,407.9,442.1,408.1,443.5,405.8z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1003 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg version="1.1" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||
<g id="tool-scissor-circle" stroke="currentColor" fill="currentColor">
|
||||
<path
|
||||
d="m 95.186411,99.470453 a 9.7463486,9.7463486 0 0 1 6.903659,11.957487 68.30566,68.30566 0 0 0 0.006,35.2595 9.8275678,9.8275678 0 0 1 -4.504348,11.04118 8.6092746,8.6092746 0 0 1 -2.383531,0.90721 9.665129,9.665129 0 0 1 -11.907784,-6.81755 87.554699,87.554699 0 0 1 -0.07138,-45.44416 9.9087877,9.9087877 0 0 1 11.9575,-6.903667 z m 24.574799,77.814127 a 9.762664,9.762664 0 0 0 -13.76005,13.85276 88.366894,88.366894 0 0 0 39.27951,22.71357 9.9087877,9.9087877 0 0 0 7.40009,-0.99 9.7463486,9.7463486 0 0 0 -2.34626,-17.87117 67.980782,67.980782 0 0 1 -30.57329,-17.70516 z m 96.47052,0.10436 a 67.412245,67.412245 0 0 1 -30.56739,17.55431 9.7463486,9.7463486 0 0 0 -6.88789,11.94839 9.665129,9.665129 0 0 0 11.87803,6.9285 87.22982,87.22982 0 0 0 21.26761,-8.80886 85.524208,85.524208 0 0 0 18.12181,-13.93263 9.7463486,9.7463486 0 0 0 -13.81217,-13.68971 z m 24.54655,-77.851495 a 9.665129,9.665129 0 0 0 -6.81753,11.907775 68.711758,68.711758 0 0 1 -0.0644,35.3001 9.7463486,9.7463486 0 0 0 14.30377,10.96752 9.9087877,9.9087877 0 0 0 4.5574,-5.91368 87.717137,87.717137 0 0 0 -0.0308,-45.37382 9.7463486,9.7463486 0 0 0 -11.94839,-6.887895 z M 216.22515,80.888706 a 9.9087877,9.9087877 0 0 0 11.77351,1.549376 12.589034,12.589034 0 0 0 2.01805,-1.540259 9.8275678,9.8275678 0 0 0 0.009,-13.791554 88.204454,88.204454 0 0 0 -39.32011,-22.783905 9.7632558,9.7632558 0 0 0 -5.05383,18.861161 67.980782,67.980782 0 0 1 30.57329,17.705181 z m -96.4705,-0.104375 a 67.412245,67.412245 0 0 1 30.56739,-17.554309 14.213424,14.213424 0 0 0 2.41326,-1.018158 9.7463486,9.7463486 0 0 0 -7.40342,-17.858725 88.123236,88.123236 0 0 0 -39.38942,22.741485 9.7463486,9.7463486 0 1 0 13.81219,13.68971 z" />
|
||||
<path
|
||||
d="m 37.698988,37.475257 c -0.710483,-0.0944 -1.417711,0.15341 -1.907644,0.67794 l -3.029525,3.23827 c -1.186174,1.25331 -2.301137,2.75282 -3.507657,4.7097 -2.482623,4.02648 -5.107669,10.19785 -3.519051,16.70654 0.428487,1.75952 1.180885,3.27693 1.90439,4.74144 0.116786,0.23561 0.234793,0.47122 0.349951,0.7052 l 30.212235,66.147003 -12.657265,20.15681 c -0.950161,-0.35239 -1.929212,-0.64945 -2.926168,-0.88953 -1.78272,-0.43378 -3.619967,-0.69217 -5.457622,-0.76704 -10.82165,-0.44762 -22.117364,5.53615 -29.281611,15.3759 -1.323713,1.79574 -3.0873082,4.62262 -5.0474462,9.36284 -1.279765,3.39535 -2.076923,6.92091 -2.351187,10.44566 -0.477318,6.12537 0.618926,12.05747 3.167471,17.14805 2.7609572,5.52354 7.3066692,9.95613 12.7984722,12.48799 3.185783,1.4641 6.591709,2.28038 10.126223,2.42565 0,0 0,0 0.0033,0 10.545352,0.43541 21.564767,-5.11173 28.76116,-14.47742 0.01424,-0.0151 0.02604,-0.0309 0.03784,-0.0509 l 1.092573,-1.50276 c 2.69219,-3.86331 4.71011,-8.26008 5.85397,-12.74964 0.009,-0.0183 0.011,-0.0383 0.0163,-0.0574 0.35687,-1.58781 0.45902,-2.1046 0.49115,-2.30725 2.8163,-14.67559 6.73576,-24.04006 10.05177,-29.81061 l 3.50847,-5.37786 V 110.37512 L 39.351494,38.641497 c -0.329606,-0.63276 -0.94365,-1.06857 -1.652506,-1.16624 z m 0.906213,160.268183 c -3.339599,3.03929 -7.643193,4.833 -11.229385,4.68772 -1.656168,-0.0708 -3.183342,-0.53631 -4.533098,-1.3819 -0.0036,-0.002 -0.0061,-0.003 -0.0073,-0.005 l -0.0016,-0.002 c -3.15038,-1.97275 -4.933507,-5.9622 -4.768704,-10.66661 0.177417,-5.08936 2.466754,-10.26417 6.277159,-14.19055 2.733288,-2.81345 7.339224,-5.75345 12.072526,-5.55691 1.118625,0.046 2.198593,0.27019 3.211825,0.66573 4.684066,1.83968 6.263326,7.115 6.194149,11.07963 -0.09929,5.60412 -2.797174,11.34943 -7.21552,15.37021 z" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,39 @@
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="453.408px" height="453.408px" viewBox="0 0 453.408 453.408" xml:space="preserve">
|
||||
<g stroke="currentColor" fill="currentColor">
|
||||
<path d="M437.671,383.799c-8.689,0-15.735,7.047-15.735,15.738v15.736h-15.737c-8.576,0-15.736,7.162-15.736,15.736
|
||||
c0,8.576,7.16,15.738,15.736,15.738h31.474c8.691,0,15.736-7.047,15.736-15.738v-31.473
|
||||
C453.407,390.846,446.362,383.799,437.671,383.799z"/>
|
||||
<path d="M345.353,415.273h-41.96c-8.576,0-15.737,7.16-15.737,15.736s7.161,15.736,15.737,15.736h41.96
|
||||
c8.576,0,15.736-7.16,15.736-15.736S353.93,415.273,345.353,415.273z"/>
|
||||
<path d="M242.548,415.273h-15.737v-15.738c0-8.576-7.16-15.736-15.736-15.736s-15.736,7.16-15.736,15.736v31.475
|
||||
c0,8.691,7.045,15.736,15.736,15.736h31.474c8.576,0,15.736-7.16,15.736-15.736S251.124,415.273,242.548,415.273z"/>
|
||||
<path d="M211.074,207.507c8.691,0,15.736-7.045,15.736-15.736v-69.255c0-8.576-7.16-15.737-15.736-15.737
|
||||
c-8.576,0-15.736,7.161-15.736,15.737v69.255C195.338,200.461,202.383,207.507,211.074,207.507z"/>
|
||||
<path d="M242.548,6.049h-31.474c-8.691,0-15.736,7.046-15.736,15.737v31.474c0,8.576,7.16,15.737,15.736,15.737
|
||||
c8.576,0,15.736-7.161,15.736-15.737V37.523h15.737c8.576,0,15.736-7.161,15.736-15.737
|
||||
C258.284,13.21,251.124,6.049,242.548,6.049z"/>
|
||||
<path d="M345.353,6.049h-41.96c-8.576,0-15.737,7.161-15.737,15.737s7.161,15.737,15.737,15.737h41.96
|
||||
c8.576,0,15.736-7.161,15.736-15.737C361.09,13.21,353.93,6.049,345.353,6.049z"/>
|
||||
<path d="M437.671,6.049h-31.474c-8.576,0-15.736,7.161-15.736,15.737s7.16,15.737,15.736,15.737h15.737v15.737
|
||||
c0,8.576,7.159,15.737,15.735,15.737s15.736-7.161,15.736-15.737V21.787C453.407,13.096,446.362,6.049,437.671,6.049z"/>
|
||||
<path d="M437.671,245.289c-8.689,0-15.735,7.045-15.735,15.736v69.256c0,8.575,7.159,15.736,15.735,15.736
|
||||
s15.736-7.161,15.736-15.736v-69.256C453.407,252.334,446.362,245.289,437.671,245.289z"/>
|
||||
<path d="M437.671,106.779c-8.689,0-15.735,7.046-15.735,15.737v69.255c0,8.576,7.159,15.736,15.735,15.736
|
||||
s15.736-7.161,15.736-15.736v-69.255C453.407,113.825,446.362,106.779,437.671,106.779z"/>
|
||||
<path d="M211.074,346.018c8.576,0,15.736-7.161,15.736-15.736v-69.256c0-8.576-7.16-15.736-15.736-15.736
|
||||
c-8.576,0-15.736,7.16-15.736,15.736v69.256C195.338,338.856,202.498,346.018,211.074,346.018z"/>
|
||||
<path d="M91.732,2.242c-1.746-0.232-3.484,0.377-4.688,1.666l-7.445,7.958c-2.915,3.08-5.655,6.765-8.62,11.574
|
||||
c-6.101,9.895-12.552,25.061-8.648,41.056c1.053,4.324,2.902,8.053,4.68,11.652c0.287,0.579,0.577,1.158,0.86,1.733
|
||||
l74.246,162.555l-31.105,49.535c-2.335-0.866-4.741-1.596-7.191-2.186c-4.381-1.066-8.896-1.701-13.412-1.885
|
||||
c-26.594-1.1-54.353,13.605-71.959,37.786c-3.253,4.413-7.587,11.36-12.404,23.009c-3.145,8.344-5.104,17.008-5.778,25.67
|
||||
c-1.173,15.053,1.521,29.631,7.784,42.141c6.785,13.574,17.956,24.467,31.452,30.689c7.829,3.598,16.199,5.604,24.885,5.961
|
||||
c0,0,0,0,0.008,0c25.915,1.07,52.995-12.562,70.68-35.578c0.035-0.037,0.064-0.076,0.093-0.125l2.685-3.693
|
||||
c6.616-9.494,11.575-20.299,14.386-31.332c0.021-0.045,0.027-0.094,0.04-0.141c0.877-3.902,1.128-5.172,1.207-5.67
|
||||
c6.921-36.065,16.553-59.078,24.702-73.259l8.622-13.216V181.392L95.793,5.108C94.983,3.553,93.474,2.482,91.732,2.242z
|
||||
M93.959,396.098c-8.207,7.469-18.783,11.877-27.596,11.52c-4.07-0.174-7.823-1.318-11.14-3.396
|
||||
c-0.009-0.004-0.015-0.008-0.018-0.014l-0.004-0.004c-7.742-4.848-12.124-14.652-11.719-26.213
|
||||
c0.436-12.507,6.062-25.224,15.426-34.873c6.717-6.914,18.036-14.139,29.668-13.656c2.749,0.113,5.403,0.664,7.893,1.636
|
||||
c11.511,4.521,15.392,17.485,15.222,27.228C111.447,372.098,104.817,386.217,93.959,396.098z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Button, ButtonEnums } from '../../components';
|
||||
|
||||
function ActionButtons({ actions, disabled, t }) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
{actions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
onClick={action.onClick}
|
||||
disabled={disabled || action.disabled}
|
||||
type={ButtonEnums.type.secondary}
|
||||
size={ButtonEnums.size.small}
|
||||
className={index > 0 ? 'ml-2' : ''}
|
||||
>
|
||||
{t ? t(action.label) : action.label}
|
||||
</Button>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -0,0 +1,3 @@
|
||||
import ActionButtons from './ActionButtons';
|
||||
|
||||
export default ActionButtons;
|
||||
@@ -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 (
|
||||
<div className={classnames(wrapperClasses, 'text-[13px]')}>
|
||||
{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;
|
||||
<div
|
||||
className={classnames(wrapperClasses, ' text-[13px]', {
|
||||
'border-secondary-light rounded-[5px] border bg-black': !separated,
|
||||
})}
|
||||
>
|
||||
{!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 && (
|
||||
<div className="flex space-x-2">
|
||||
{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;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -66,6 +96,7 @@ ButtonGroup.propTypes = {
|
||||
onActiveIndexChange: PropTypes.func,
|
||||
className: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
separated: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default ButtonGroup;
|
||||
@@ -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
|
||||
|
||||
@@ -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<CinePlayerProps> = ({
|
||||
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<CinePlayerProps> = ({
|
||||
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 (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
'border-secondary-light/60 bg-primary-dark flex select-none items-center gap-2 rounded border px-2 py-2'
|
||||
<div className={className}>
|
||||
{isDynamic && dynamicInfo && (
|
||||
<InputRange
|
||||
value={dynamicInfo.timePointIndex}
|
||||
onChange={handleTimePointChange}
|
||||
minValue={0}
|
||||
maxValue={dynamicInfo.numTimePoints - 1}
|
||||
step={1}
|
||||
containerClassName="mb-3 w-full"
|
||||
labelClassName="text-xs text-white"
|
||||
leftColor="#3a3f99"
|
||||
rightColor="#3a3f99"
|
||||
trackHeight="4px"
|
||||
thumbColor="#348cfd"
|
||||
thumbColorOuter="#000000"
|
||||
showLabel={false}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={getPlayPauseIconName()}
|
||||
className="active:text-primary-light hover:bg-customblue-300 cursor-pointer text-white hover:rounded"
|
||||
onClick={() => onPlayPauseChange(!isPlaying)}
|
||||
/>
|
||||
<Tooltip
|
||||
position="top"
|
||||
className="group/fps cine-fps-range-tooltip"
|
||||
tight={true}
|
||||
content={
|
||||
<InputRange
|
||||
containerClassName="h-9 px-2"
|
||||
inputClassName="w-40"
|
||||
value={frameRate}
|
||||
minValue={minFrameRate}
|
||||
maxValue={maxFrameRate}
|
||||
step={stepFrameRate}
|
||||
onChange={handleSetFrameRate}
|
||||
showLabel={false}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
'border-secondary-light/60 bg-primary-dark inline-flex select-none items-center gap-2 rounded border px-2 py-2'
|
||||
}
|
||||
>
|
||||
<div className="border-secondary-light flex h-6 items-stretch gap-1 rounded border">
|
||||
<Icon
|
||||
name={getPlayPauseIconName()}
|
||||
className="active:text-primary-light hover:bg-customblue-300 cursor-pointer text-white hover:rounded"
|
||||
onClick={() => onPlayPauseChange(!isPlaying)}
|
||||
/>
|
||||
{isDynamic && dynamicInfo && (
|
||||
<div className="min-w-16 max-w-44 flex flex-col text-white">
|
||||
{/* Add Tailwind classes for monospace font and center alignment */}
|
||||
<div className="text-[11px]">
|
||||
<span className="w-2 text-white">{dynamicInfo.timePointIndex}</span>{' '}
|
||||
<span className="text-aqua-pale">{`/${dynamicInfo.numTimePoints}`}</span>
|
||||
</div>
|
||||
<div className="text-aqua-pale text-xs">{dynamicInfo.label}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-secondary-light ml-4 flex h-6 items-stretch gap-1 rounded border">
|
||||
<div
|
||||
className={`${fpsButtonClassNames} rounded-l`}
|
||||
onClick={() => handleSetFrameRate(frameRate - 1)}
|
||||
>
|
||||
<Icon name="arrow-left-small" />
|
||||
</div>
|
||||
<div className="group-hover/fps:text-primary-light w-11 text-center text-sm leading-[22px] text-white">
|
||||
{`${frameRate} FPS`}
|
||||
</div>
|
||||
<Tooltip
|
||||
position="top"
|
||||
className="group/fps cine-fps-range-tooltip"
|
||||
tight={true}
|
||||
content={
|
||||
<InputRange
|
||||
containerClassName="h-9 px-2"
|
||||
inputClassName="w-40"
|
||||
value={frameRate}
|
||||
minValue={minFrameRate}
|
||||
maxValue={maxFrameRate}
|
||||
step={stepFrameRate}
|
||||
onChange={handleSetFrameRate}
|
||||
showLabel={false}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<div className="flex-shrink-0 text-center text-sm leading-[22px] text-white">
|
||||
<span className="inline-block text-right">{`${frameRate} `}</span>
|
||||
<span className="text-aqua-pale whitespace-nowrap text-xs">{' FPS'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<div
|
||||
className={`${fpsButtonClassNames} rounded-r`}
|
||||
onClick={() => handleSetFrameRate(frameRate + 1)}
|
||||
@@ -98,12 +153,12 @@ const CinePlayer: React.FC<CinePlayerProps> = ({
|
||||
<Icon name="arrow-right-small" />
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Icon
|
||||
name="icon-close"
|
||||
className="text-primary-active active:text-primary-light hover:bg-customblue-300 cursor-pointer hover:rounded"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<Icon
|
||||
name="icon-close"
|
||||
className="text-primary-active active:text-primary-light hover:bg-customblue-300 cursor-pointer hover:rounded"
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Components/CinePlayer"
|
||||
component={CinePlayer}
|
||||
/>
|
||||
|
||||
export const CinePlayerTemplate = args => (
|
||||
<div className="relative h-96 w-96 bg-black">
|
||||
<div className="absolute left-1/2 bottom-3 -translate-x-1/2">
|
||||
<CinePlayer {...args} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
<Heading
|
||||
title="CinePlayer"
|
||||
componentRelativePath="CinePlayer/CinePlayer.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
CinePlayer is a component that allows you to use as a boolean value
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Overview"
|
||||
args={{
|
||||
isDynamic: true,
|
||||
dynamicInfo: {
|
||||
timePointIndex: 5,
|
||||
numTimePoints: 20,
|
||||
label: 'TemporalPositionIdentifier',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{CinePlayerTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={CinePlayer} />
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="CinePlayer/__stories__/CinePlayer.stories.mdx" />
|
||||
@@ -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({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* <div className="absolute top-1/2 left-[211px] h-8 w-20 -translate-y-1/2">{future left component}</div> */}
|
||||
<div className="absolute top-1/2 left-[250px] h-8 -translate-y-1/2">{Secondary}</div>
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform">
|
||||
<div className="flex items-center justify-center space-x-2">{children}</div>
|
||||
</div>
|
||||
<div className="absolute right-0 top-1/2 flex -translate-y-1/2 select-none items-center">
|
||||
{(showPatientInfo === PatientInfoVisibility.VISIBLE ||
|
||||
showPatientInfo === PatientInfoVisibility.VISIBLE_COLLAPSED) && (
|
||||
<HeaderPatientInfo servicesManager={servicesManager} />
|
||||
<HeaderPatientInfo
|
||||
servicesManager={servicesManager}
|
||||
appConfig={appConfig}
|
||||
/>
|
||||
)}
|
||||
<div className="border-primary-dark mx-1.5 h-[25px] border-r"></div>
|
||||
<div className="flex-shrink-0">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<InputDoubleRangeProps> = ({
|
||||
@@ -36,6 +37,7 @@ const InputDoubleRange: React.FC<InputDoubleRangeProps> = ({
|
||||
labelPosition,
|
||||
trackColor,
|
||||
allowNumberEdit,
|
||||
allowOutOfRange = false,
|
||||
showAdjustmentArrows,
|
||||
}) => {
|
||||
// Set initial thumb positions as percentages
|
||||
@@ -55,7 +57,15 @@ const InputDoubleRange: React.FC<InputDoubleRangeProps> = ({
|
||||
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<InputDoubleRangeProps> = ({
|
||||
|
||||
const LabelOrEditableNumber = (val, index) => {
|
||||
return allowNumberEdit ? (
|
||||
<InputNumber
|
||||
minValue={minValue}
|
||||
maxValue={maxValue}
|
||||
value={val}
|
||||
onChange={newValue => {
|
||||
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
|
||||
<div className={index === 1 && 'pl-[2px]'}>
|
||||
<InputNumber
|
||||
minValue={minValue}
|
||||
maxValue={maxValue}
|
||||
value={val}
|
||||
onChange={newValue => {
|
||||
updateRangeValues(newValue, index);
|
||||
}}
|
||||
step={step}
|
||||
labelClassName={classNames(labelClassName ?? 'text-white')}
|
||||
showAdjustmentArrows={showAdjustmentArrows}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className={classNames(labelClassName ?? 'text-white')}>
|
||||
{val}
|
||||
@@ -138,31 +153,52 @@ const InputDoubleRange: React.FC<InputDoubleRangeProps> = ({
|
||||
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
|
||||
|
||||
@@ -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
|
||||
className={labelClassName}
|
||||
text={label}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
{label && (
|
||||
<Label
|
||||
className={labelClassName}
|
||||
text={label}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`flex overflow-hidden ${className} ${labelPosition === 'top' || labelPosition === 'bottom' ? 'flex-col' : 'flex-row'}`}
|
||||
>
|
||||
{label && labelPosition === 'left' && labelElement}
|
||||
{label && labelPosition === 'top' && labelElement}
|
||||
<div
|
||||
className={`border-secondary-light flex items-center justify-center overflow-hidden rounded-md border-2 bg-black px-1 ${
|
||||
sizesClasses[size]
|
||||
} ${className || ''}`}
|
||||
style={{ width: containerWidth }}
|
||||
className={`flex flex-grow items-center overflow-hidden ${sizeToUse} ${inputContainerClassName}`}
|
||||
>
|
||||
<div className="flex">
|
||||
<div className="flex w-full">
|
||||
{showAdjustmentArrows && arrowsDirection === 'horizontal' && (
|
||||
<div
|
||||
className={arrowHorizontalClassName}
|
||||
onClick={() => decrement()}
|
||||
>
|
||||
<Icon name="arrow-left-small" />
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="number"
|
||||
value={isFocused ? numberValue : parseFloat(numberValue).toFixed(decimalPlaces)}
|
||||
@@ -117,11 +141,19 @@ const InputNumber: React.FC<{
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
className={'input-number w-full bg-black text-center text-[12px] text-white'}
|
||||
className={`input-number ${inputClassName} w-full flex-grow text-center`}
|
||||
style={{ width: inputWidth }}
|
||||
/>
|
||||
{showAdjustmentArrows && (
|
||||
<div className="up-arrowsize flex flex-col items-center justify-around">
|
||||
{showAdjustmentArrows && arrowsDirection === 'horizontal' && (
|
||||
<div
|
||||
className={arrowHorizontalClassName}
|
||||
onClick={() => increment()}
|
||||
>
|
||||
<Icon name="arrow-right-small" />
|
||||
</div>
|
||||
)}
|
||||
{showAdjustmentArrows && arrowsDirection === 'vertical' && (
|
||||
<div className="up-arrowsize ml-auto flex flex-shrink-0 flex-col items-center justify-around pr-1">
|
||||
<ArrowButton
|
||||
onClick={increment}
|
||||
rotate
|
||||
@@ -131,6 +163,8 @@ const InputNumber: React.FC<{
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{label && labelPosition === 'right' && labelElement}
|
||||
{label && labelPosition === 'bottom' && labelElement}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<InputRangeProps> = ({
|
||||
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<InputRangeProps> = ({
|
||||
/>
|
||||
) : (
|
||||
<span className={classNames(labelClassName ?? 'text-white')}>
|
||||
{rangeValue}
|
||||
{unit}
|
||||
{rangeValue.toFixed(maxDigits)} {unit}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -101,19 +105,21 @@ const InputRange: React.FC<InputRangeProps> = ({
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div className="relative flex w-full items-center space-x-2">
|
||||
<div className={'relative flex w-full items-center ' + (showLabel ? 'space-x-2' : '')}>
|
||||
{showLabel && labelPosition === 'left' && (
|
||||
<div style={{ width: labelWidth }}>{LabelOrEditableNumber}</div>
|
||||
)}
|
||||
<div className="range-track"></div>
|
||||
<input
|
||||
type="range"
|
||||
min={minValue}
|
||||
max={maxValue}
|
||||
value={rangeValue}
|
||||
className={`h-[3px] appearance-none rounded-md ${inputClassName ?? ''}`}
|
||||
className={`w-full appearance-none rounded-md ${inputClassName ?? ''}`}
|
||||
style={{
|
||||
background: `linear-gradient(to right, #5acce6 0%, #5acce6 ${rangeValuePercentage}%, #3a3f99 ${rangeValuePercentage}%, #3a3f99 100%)`,
|
||||
background: `linear-gradient(to right, ${leftColor} 0%, ${leftColor} ${rangeValuePercentage}%, ${rightColor} ${rangeValuePercentage}%, ${rightColor} 100%)`,
|
||||
'--thumb-inner-color': thumbColor,
|
||||
'--thumb-outer-color': thumbColorOuter,
|
||||
height: trackHeight,
|
||||
}}
|
||||
onChange={handleChange}
|
||||
id="myRange"
|
||||
|
||||
@@ -34,7 +34,7 @@ InputRange is a component that allows you to use as a boolean value
|
||||
<Story
|
||||
name="Overview"
|
||||
args={{
|
||||
value: 10,
|
||||
value: 20,
|
||||
minValue: 0,
|
||||
maxValue: 100,
|
||||
step: 10,
|
||||
|
||||
@@ -67,7 +67,7 @@ const classes = {
|
||||
Content: ({ isExpanded }) => classNames(baseClasses.Content, isExpanded ? 'block' : 'hidden'),
|
||||
};
|
||||
|
||||
const SplitButton = ({
|
||||
const LegacySplitButton = ({
|
||||
groupId,
|
||||
primary,
|
||||
secondary,
|
||||
@@ -103,7 +103,7 @@ const SplitButton = ({
|
||||
disabled={!state.isExpanded}
|
||||
>
|
||||
<div
|
||||
id="SplitButton"
|
||||
id="LegacySplitButton"
|
||||
className="relative"
|
||||
>
|
||||
<div
|
||||
@@ -148,7 +148,7 @@ const SplitButton = ({
|
||||
);
|
||||
};
|
||||
|
||||
SplitButton.propTypes = {
|
||||
LegacySplitButton.propTypes = {
|
||||
isToggle: PropTypes.bool,
|
||||
groupId: PropTypes.string.isRequired,
|
||||
primary: PropTypes.object.isRequired,
|
||||
@@ -160,11 +160,11 @@ SplitButton.propTypes = {
|
||||
Component: PropTypes.elementType,
|
||||
};
|
||||
|
||||
SplitButton.defaultProps = {
|
||||
LegacySplitButton.defaultProps = {
|
||||
isToggle: false,
|
||||
renderer: null,
|
||||
isActive: false,
|
||||
Component: null,
|
||||
};
|
||||
|
||||
export default SplitButton;
|
||||
export default LegacySplitButton;
|
||||
@@ -0,0 +1,40 @@
|
||||
.LineChart svg {
|
||||
color: #ddd;
|
||||
border: solid 1px #383f42;
|
||||
}
|
||||
|
||||
.LineChart svg .background {
|
||||
stroke: none;
|
||||
fill: #151c20;
|
||||
}
|
||||
|
||||
.LineChart svg .axis.label {
|
||||
fill: #ddd;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.LineChart svg .axis .tick line {
|
||||
stroke: #ddd;
|
||||
}
|
||||
|
||||
.LineChart svg .axis .tick text {
|
||||
fill: #ddd;
|
||||
}
|
||||
|
||||
.LineChart svg .axis.grid .domain {
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.LineChart svg .axis.grid .tick line {
|
||||
stroke: #383f42;
|
||||
}
|
||||
|
||||
.LineChart svg .line {
|
||||
stroke-width: 1px;
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.LineChart svg .legend .legend-background {
|
||||
fill: none;
|
||||
stroke: #383f42;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import React, { useEffect, useLayoutEffect, useState, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import * as d3Selection from 'd3-selection';
|
||||
import { lineChart } from './d3LineChart';
|
||||
import './LineChart.css';
|
||||
|
||||
const LineChart = ({
|
||||
width: widthProp,
|
||||
height: heightProp,
|
||||
axis,
|
||||
series,
|
||||
showAxisLabels,
|
||||
showAxisGrid,
|
||||
showLegend,
|
||||
legendWidth,
|
||||
transparentChartBackground,
|
||||
containerClassName,
|
||||
chartContainerClassName,
|
||||
}: {
|
||||
title: string;
|
||||
width: number;
|
||||
height: number;
|
||||
showAxisGrid: boolean;
|
||||
showAxisLabels: boolean;
|
||||
showLegend: boolean;
|
||||
legendWidth: number;
|
||||
transparentChartBackground: boolean;
|
||||
containerClassName: string;
|
||||
chartContainerClassName: string;
|
||||
}): JSX.Element => {
|
||||
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 (
|
||||
<div
|
||||
className={classnames(
|
||||
'LineChart text-white',
|
||||
{
|
||||
[`w-[${widthProp}px]`]: !!widthProp,
|
||||
[`h-[${heightProp}px]`]: !!heightProp,
|
||||
},
|
||||
{
|
||||
'w-full': !widthProp,
|
||||
'h-full': !heightProp,
|
||||
},
|
||||
containerClassName
|
||||
)}
|
||||
>
|
||||
<div
|
||||
id="chartContainer"
|
||||
ref={chartContainerRef}
|
||||
className={classnames('h-full w-full', chartContainerClassName)}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Components/LineChart"
|
||||
component={LineChart}
|
||||
/>
|
||||
|
||||
export const LineChartTemplate = args => (
|
||||
<div className="w-96">
|
||||
<LineChart {...args} />
|
||||
</div>
|
||||
);
|
||||
|
||||
<Heading
|
||||
title="LineChart"
|
||||
componentRelativePath="LineChart/LineChart.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
LineChart is a component that...
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Overview"
|
||||
args={{
|
||||
width: 800,
|
||||
height: 400,
|
||||
showLegend: true,
|
||||
legendWidth: 150,
|
||||
axis: {
|
||||
x: {
|
||||
label: 'Time (s)',
|
||||
indexRef: 0,
|
||||
type: 'x',
|
||||
},
|
||||
y: {
|
||||
label: 'Signal Intensity',
|
||||
indexRef: 1,
|
||||
type: 'y',
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
label: 'First series',
|
||||
points: [
|
||||
[0, 607.54],
|
||||
[1, 648.28],
|
||||
[2, 696.7],
|
||||
[3, 693.87],
|
||||
[4, 740.02],
|
||||
[5, 707.04],
|
||||
[6, 748.73],
|
||||
[7, 792.34],
|
||||
[8, 842.03],
|
||||
[9, 808.63],
|
||||
[10, 798.81],
|
||||
[11, 836.71],
|
||||
[12, 800.22],
|
||||
[13, 761.66],
|
||||
[14, 745.5],
|
||||
[15, 774.53],
|
||||
[16, 824.04],
|
||||
[17, 837.02],
|
||||
[18, 857.5],
|
||||
[19, 818.29],
|
||||
[20, 786.71],
|
||||
[21, 797.34],
|
||||
[22, 795.29],
|
||||
[23, 771.26],
|
||||
[24, 751.51],
|
||||
[25, 800.11],
|
||||
[26, 824.1],
|
||||
[27, 851.25],
|
||||
[28, 826.65],
|
||||
[29, 845.48],
|
||||
[30, 891.25],
|
||||
[31, 899.52],
|
||||
[32, 944.36],
|
||||
[33, 947.73],
|
||||
[34, 923.11],
|
||||
[35, 889.22],
|
||||
[36, 871.86],
|
||||
[37, 871.42],
|
||||
[38, 826.14],
|
||||
[39, 866.41],
|
||||
[40, 897.81],
|
||||
[41, 858.01],
|
||||
[42, 848.76],
|
||||
[43, 818.07],
|
||||
[44, 778.81],
|
||||
[45, 737.75],
|
||||
[46, 719.47],
|
||||
[47, 730.18],
|
||||
[48, 705.91],
|
||||
[49, 689.15],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Second series',
|
||||
points: [
|
||||
[0, 307.54],
|
||||
[1, 348.28],
|
||||
[2, 396.7],
|
||||
[3, 393.87],
|
||||
[4, 440.02],
|
||||
[5, 407.04],
|
||||
[6, 448.73],
|
||||
[7, 492.34],
|
||||
[8, 542.03],
|
||||
[9, 508.63],
|
||||
[10, 498.81],
|
||||
[11, 536.71],
|
||||
[12, 500.22],
|
||||
[13, 461.66],
|
||||
[14, 445.5],
|
||||
[15, 474.53],
|
||||
[16, 524.04],
|
||||
[17, 537.02],
|
||||
[18, 557.5],
|
||||
[19, 518.29],
|
||||
[20, 486.71],
|
||||
[21, 497.34],
|
||||
[22, 495.29],
|
||||
[23, 471.26],
|
||||
[24, 451.51],
|
||||
[25, 500.11],
|
||||
[26, 524.1],
|
||||
[27, 551.25],
|
||||
[28, 526.65],
|
||||
[29, 545.48],
|
||||
[30, 591.25],
|
||||
[31, 599.52],
|
||||
[32, 644.36],
|
||||
[33, 647.73],
|
||||
[34, 623.11],
|
||||
[35, 589.22],
|
||||
[36, 571.86],
|
||||
[37, 571.42],
|
||||
[38, 526.14],
|
||||
[39, 566.41],
|
||||
[40, 597.81],
|
||||
[41, 558.01],
|
||||
[42, 548.76],
|
||||
[43, 518.07],
|
||||
[44, 478.81],
|
||||
[45, 437.75],
|
||||
[46, 419.47],
|
||||
[47, 430.18],
|
||||
[48, 405.91],
|
||||
[49, 389.15],
|
||||
],
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
{LineChartTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={LineChart} />
|
||||
|
||||
## Usage
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="LineChart/__stories__/lineChart.stories.mdx" />
|
||||
@@ -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;
|
||||
@@ -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<string, function>} zoomEvents <zoom d3 event name, callback method>
|
||||
*/
|
||||
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;
|
||||
@@ -0,0 +1,3 @@
|
||||
import * as lineChart from './lineChart';
|
||||
|
||||
export { lineChart };
|
||||
@@ -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<string, TimecoursePointDef>} 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 };
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './LineChart';
|
||||
@@ -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 = [] }) => {
|
||||
</div>
|
||||
{areChildrenVisible && (
|
||||
<>
|
||||
<div className="bg-primary-dark rounded-b-[4px]">{children}</div>
|
||||
<div className="h-[2px] bg-black"></div>
|
||||
<div className={`bg-primary-dark rounded-b-[4px] ${childrenClassName}`}>{children}</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex">
|
||||
{options.map((option, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={classnames('mr-1 h-1 grow first:rounded-l-sm last:mr-0 last:rounded-r-sm', {
|
||||
'bg-black': !option.activated && !option.completed,
|
||||
'bg-primary-main': option.activated && !option.completed,
|
||||
'bg-primary-light': option.completed,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProgressDiscreteBar.propTypes = {
|
||||
options: PropTypes.arrayOf(ProgressDropdownOptionPropType).isRequired,
|
||||
};
|
||||
|
||||
export default ProgressDiscreteBar;
|
||||
@@ -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 (
|
||||
<div
|
||||
ref={element}
|
||||
className="relative grow select-none text-[0px] text-white"
|
||||
>
|
||||
<div>
|
||||
<div className="mb-1.5 flex h-[26px]">
|
||||
<div
|
||||
className="bg-secondary-dark border-primary-main flex grow cursor-pointer rounded border"
|
||||
style={{ width: `${dropDownWidth}px` }}
|
||||
onClick={toggleOpen}
|
||||
>
|
||||
<div className="flex grow">
|
||||
{selectedOption && <ProgressItemDetail option={selectedOption} />}
|
||||
|
||||
{!selectedOption && <div className="ml-1 grow text-base leading-6">{children}</div>}
|
||||
</div>
|
||||
<Icon
|
||||
name="chevron-down"
|
||||
className="text-primary-active mt-0.5 ml-1"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className={classnames('ml-1.5 w-[26px] rounded text-base', {
|
||||
'bg-primary-main': canMoveNext,
|
||||
'bg-primary-dark pointer-events-none': !canMoveNext,
|
||||
})}
|
||||
>
|
||||
<Icon
|
||||
name="arrow-right"
|
||||
className={classnames('relative left-0.5 h-6 w-6 text-white', {
|
||||
'text-white': canMoveNext,
|
||||
'text-secondary-light': !canMoveNext,
|
||||
})}
|
||||
onClick={handleNextButtonClick}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={classnames(
|
||||
'absolute top-7 left-0 right-8 z-10 mt-0.5 origin-top',
|
||||
'bg-primary-dark overflow-hidden transition-[max-height] duration-300 ',
|
||||
'border-secondary-main rounded border shadow',
|
||||
{
|
||||
hidden: !open,
|
||||
'max-h-[500px]': open,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{options.map((option, index) => (
|
||||
<ProgressItem
|
||||
key={index}
|
||||
option={option}
|
||||
onSelect={() => handleOptionSelected(option)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<ProgressDiscreteBar options={options} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProgressDropdown.propTypes = {
|
||||
options: PropTypes.arrayOf(ProgressDropdownOptionPropType).isRequired,
|
||||
value: PropTypes.string,
|
||||
onChange: PropTypes.func,
|
||||
children: PropTypes.node,
|
||||
dropDownWidth: PropTypes.string,
|
||||
};
|
||||
|
||||
export default ProgressDropdown;
|
||||
@@ -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 (
|
||||
<div
|
||||
key={value}
|
||||
className={'hover:bg-secondary-main flex cursor-pointer py-1 transition duration-1000'}
|
||||
onClick={() => onSelect(option)}
|
||||
>
|
||||
<ProgressItemDetail option={option} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ProgressItem.propTypes = {
|
||||
option: ProgressDropdownOptionPropType.isRequired,
|
||||
onSelect: PropTypes.func,
|
||||
};
|
||||
|
||||
export default ProgressItem;
|
||||
@@ -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)}
|
||||
<button
|
||||
className="text-primary-active font-bold"
|
||||
onClick={handleReadMoreClick}
|
||||
>
|
||||
Read more...
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}, [info, truncate]);
|
||||
|
||||
const iconContent = (
|
||||
<>
|
||||
{icon && (
|
||||
<div>
|
||||
<Icon
|
||||
name={icon}
|
||||
className={iconClassNames}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!icon && <div className={iconClassNames} />}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{info && (
|
||||
<Tooltip
|
||||
content={tooltipText}
|
||||
position="bottom-left"
|
||||
tooltipBoxClassName={'max-w-xs'}
|
||||
onHide={handleOnHideTooltip}
|
||||
>
|
||||
{iconContent}
|
||||
</Tooltip>
|
||||
)}
|
||||
{!info && iconContent}
|
||||
|
||||
<div className="grow text-base leading-6">{label}</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ProgressItemDetail.propTypes = {
|
||||
option: ProgressDropdownOptionPropType.isRequired,
|
||||
};
|
||||
|
||||
export default ProgressItemDetail;
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Components/ProgressDropdown"
|
||||
component={ProgressDropdown}
|
||||
/>
|
||||
|
||||
export const ProgressDropdownTemplate = args => (
|
||||
<div className="mb-2.5 flex h-60 max-w-xs">
|
||||
<ProgressDropdown {...args} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const loremIpsumText = new Array(7)
|
||||
.join(` Lorem ipsum, dolor sit amet consectetur adipisicing elit`)
|
||||
.substring(1);
|
||||
|
||||
<Heading
|
||||
title="ProgressDropdown"
|
||||
componentRelativePath="ProgressDropdown/ProgressDropdown.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Usage](#usage)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
Dropdown is a modal Dropdown component which enabled to show a modal Dropdown.
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="ProgressDropdownOverview"
|
||||
args={{
|
||||
options: [],
|
||||
children: '[empty dropdown]',
|
||||
}}
|
||||
>
|
||||
{ProgressDropdownTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={ProgressDropdown} />
|
||||
|
||||
## 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.
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="ProgressDropdownList"
|
||||
onChange={selectedItem => (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({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="ProgressDropdown/__stories__/progressDropdown.stories.mdx" />
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ProgressDropdown';
|
||||
@@ -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,
|
||||
});
|
||||
@@ -53,7 +53,7 @@ const ActiveSegmentationConfig = ({
|
||||
unit="%"
|
||||
/>
|
||||
<InputRange
|
||||
minValue={4}
|
||||
minValue={0}
|
||||
maxValue={100}
|
||||
value={getRoundedValue(config.fillAlpha * 100)}
|
||||
onChange={setFillAlpha}
|
||||
@@ -148,7 +148,9 @@ const SegmentationConfig = ({
|
||||
'rotate-90 transform': !isMinimized,
|
||||
})}
|
||||
/>
|
||||
<span className="text-[12px] font-[300] text-[#d8d8d8]">{t('Inactive segmentations')}</span>
|
||||
<span className="text-[12px] font-[300] text-[#d8d8d8]">
|
||||
{t('Inactive segmentations')}
|
||||
</span>
|
||||
</div>
|
||||
{!isMinimized && (
|
||||
<InactiveSegmentationConfig
|
||||
|
||||
@@ -165,6 +165,7 @@ const SidePanel = ({
|
||||
tabs,
|
||||
onOpen,
|
||||
expandedWidth = 248,
|
||||
onActiveTabIndexChange,
|
||||
}) => {
|
||||
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)}
|
||||
>
|
||||
<span>{tabs[0].label}</span>
|
||||
</div>
|
||||
@@ -380,7 +386,14 @@ const SidePanel = ({
|
||||
{panelOpen ? (
|
||||
<>
|
||||
{getOpenStateComponent()}
|
||||
<ActiveComponent />
|
||||
{tabs.map((tab, tabIndex) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={tabIndex === activeTabIndex ? '' : 'hidden'}
|
||||
>
|
||||
<tab.content />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<React.Fragment>{getCloseStateComponent()}</React.Fragment>
|
||||
@@ -410,6 +423,7 @@ SidePanel.propTypes = {
|
||||
),
|
||||
]),
|
||||
onOpen: PropTypes.func,
|
||||
onActiveTabIndexChange: PropTypes.func,
|
||||
expandedWidth: PropTypes.number,
|
||||
};
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ function ToolboxUI(props) {
|
||||
content={componentProps.label}
|
||||
secondaryContent={componentProps.disabledText}
|
||||
>
|
||||
<div className="border-secondary-light rounded border bg-black">
|
||||
<div className="bg-black">
|
||||
<Component
|
||||
{...componentProps}
|
||||
{...props}
|
||||
@@ -67,7 +67,7 @@ function ToolboxUI(props) {
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div className="border-secondary-light rounded border bg-black">
|
||||
<div className="bg-black">
|
||||
<Component
|
||||
{...componentProps}
|
||||
{...props}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import debounce from 'lodash.debounce';
|
||||
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import './tooltip.css';
|
||||
@@ -40,27 +42,47 @@ const Tooltip = ({
|
||||
tight,
|
||||
children,
|
||||
isDisabled,
|
||||
tooltipBoxClassName,
|
||||
// time to show/hide the tooltip on mouse over and mouse out events (default: 300ms)
|
||||
showHideDelay,
|
||||
onHide,
|
||||
}) => {
|
||||
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
|
||||
)}
|
||||
>
|
||||
<div>{typeof content === 'string' ? t(content) : content}</div>
|
||||
@@ -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;
|
||||
@@ -1,5 +1,5 @@
|
||||
.tooltip {
|
||||
@apply absolute z-10;
|
||||
@apply absolute z-20;
|
||||
}
|
||||
|
||||
/* TOOLTIP WORKAROUND FOR ARROW UP */
|
||||
|
||||
@@ -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 (
|
||||
<div className={classnames('maxValue-w-sm p-0.5 text-[0px] text-white', containerClassName)}>
|
||||
<div className="px-2 pt-0 pb-[0.5]">
|
||||
{/* <legend className="pb-1 text-base text-white"> {title}</legend> */}
|
||||
<div className="flex h-4 text-xs">
|
||||
<div className="relative h-fit grow">
|
||||
<span className="absolute left-0 bottom-px leading-3">{range.min}</span>
|
||||
</div>
|
||||
<div className="relative h-fit grow text-right">
|
||||
<span className="absolute right-0 bottom-px leading-3">{range.max}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-1">
|
||||
<div className="pb-2.5">
|
||||
<div className="h-11 bg-white">
|
||||
<WindowLevelHistogram
|
||||
range={range}
|
||||
voiRange={voiRange}
|
||||
histogram={histogram}
|
||||
colormap={colormap}
|
||||
style={style}
|
||||
fillColor={fillColor}
|
||||
lineColor={lineColor}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<InputDoubleRange
|
||||
values={[voiRange.min, voiRange.max]}
|
||||
onChange={handleVOIRangeChange}
|
||||
minValue={range.min}
|
||||
maxValue={range.max}
|
||||
allowNumberEdit={true}
|
||||
showLabel={true}
|
||||
step={step}
|
||||
allowOutOfRange={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{showOpacitySlider && (
|
||||
<div className="flex items-center justify-between text-base">
|
||||
<div className="text-xs text-white">Opacity</div>
|
||||
<InputRange
|
||||
inputClassName="grow"
|
||||
maxValue={1}
|
||||
minValue={0}
|
||||
step={0.1}
|
||||
value={opacity}
|
||||
showLabel={true}
|
||||
allowNumberEdit={true}
|
||||
onChange={handleOpacityChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full"
|
||||
style={{ background }}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width="1"
|
||||
height="1"
|
||||
></canvas>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
<Meta
|
||||
title="Components/WindowLevel"
|
||||
component={WindowLevel}
|
||||
/>
|
||||
|
||||
export const WindowLevelTemplate = args => (
|
||||
<div className="w-96">
|
||||
<WindowLevel {...args} />
|
||||
</div>
|
||||
);
|
||||
|
||||
<Heading
|
||||
title="WindowLevel"
|
||||
componentRelativePath="WindowLevel/WindowLevel.tsx"
|
||||
/>
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Props](#props)
|
||||
- [Contribute](#contribute)
|
||||
|
||||
## Overview
|
||||
|
||||
WindowLevel is a component that...
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="Overview"
|
||||
args={{
|
||||
title: 'Target',
|
||||
voi: {
|
||||
windowWidth: 800,
|
||||
windowCenter: 0,
|
||||
},
|
||||
showOpacitySlider: true,
|
||||
histogram: {
|
||||
numBins: 256,
|
||||
range: {
|
||||
min: -1000,
|
||||
max: 1000,
|
||||
},
|
||||
bins: [
|
||||
469817, 485077, 499920, 513954, 526106, 472824, 551087, 560798, 570524, 576182, 509119,
|
||||
585371, 585123, 586201, 581405, 504649, 569829, 560213, 550658, 535250, 455914, 504388,
|
||||
487305, 467924, 446629, 427088, 355151, 384904, 362374, 342472, 321159, 262968, 282026,
|
||||
261558, 243751, 225659, 184247, 195394, 180728, 166477, 153932, 124837, 132557, 123424,
|
||||
114390, 106514, 98492, 80936, 87261, 81444, 76932, 72578, 60694, 66376, 62465, 59951,
|
||||
57424, 48593, 53942, 52361, 50857, 49578, 42447, 47633, 46587, 45540, 45061, 38899, 44420,
|
||||
43817, 43381, 43384, 42993, 37937, 43329, 43692, 43943, 44504, 39516, 46166, 46882, 48458,
|
||||
49524, 44720, 53620, 55901, 58585, 61007, 56985, 68541, 72795, 78542, 84522, 91109, 86305,
|
||||
106424, 115156, 125756, 136606, 130742, 160773, 176254, 192943, 209036, 197566, 242622,
|
||||
261520, 282054, 301671, 280379, 340427, 361651, 381541, 400844, 366045, 431948, 448730,
|
||||
461965, 474742, 484742, 429398, 495580, 498192, 500972, 495907, 430082, 487111, 478174,
|
||||
466274, 454838, 387260, 429238, 412109, 394391, 377166, 314069, 342320, 323342, 303672,
|
||||
287065, 269839, 222018, 237587, 220910, 207113, 192675, 157585, 168534, 158188, 147129,
|
||||
137956, 112961, 122187, 114196, 108509, 103007, 85884, 94253, 90044, 87221, 84668, 71879,
|
||||
80385, 78623, 77783, 77091, 76156, 66260, 76005, 75463, 74981, 75396, 65342, 75501, 76266,
|
||||
76450, 76053, 67137, 76115, 76326, 76434, 77201, 67050, 76313, 76293, 75723, 75894, 75980,
|
||||
65911, 74524, 74224, 73147, 72685, 62974, 70932, 70591, 69998, 68042, 58874, 66580, 65624,
|
||||
64716, 63501, 54665, 61603, 60323, 59414, 57771, 50209, 56266, 55508, 54012, 53424, 52116,
|
||||
44747, 50440, 49397, 47822, 47640, 40761, 45368, 44812, 44429, 42998, 37046, 41961, 41013,
|
||||
39896, 39609, 34086, 38540, 37511, 36643, 35892, 35723, 30200, 34272, 33283, 32656, 32171,
|
||||
27407, 30907, 30045, 29634, 28707, 24846, 27648, 27349, 26831, 25707, 22627, 25339, 24986,
|
||||
24310, 23723, 23324,
|
||||
],
|
||||
maxBin: 13,
|
||||
maxBinValue: 586201,
|
||||
},
|
||||
colormap: {
|
||||
Name: '2hot',
|
||||
RGBPoints: [
|
||||
-1, 0.0416667, 0, 0, -0.873016, 0.208333, 0, 0, -0.746032, 0.375, 0, 0, -0.619048,
|
||||
0.541667, 0, 0, -0.492063, 0.708333, 0, 0, -0.365079, 0.854137, 0, 0, -0.238095, 0.937488,
|
||||
0.039062, 0, -0.111111, 1, 0.208333, 0, 0.015873, 1, 0.375, 0, 0.142857, 1, 0.541667, 0,
|
||||
0.269841, 1, 0.708333, 0, 0.396825, 1, 0.858805, 0.03125, 0.52381, 1, 0.947392, 0.15625,
|
||||
0.650794, 1, 1, 0.3125, 0.777778, 1, 1, 0.5625, 0.904762, 1, 1, 0.8125, 1, 1, 1, 1,
|
||||
],
|
||||
},
|
||||
}}
|
||||
>
|
||||
{WindowLevelTemplate.bind({})}
|
||||
</Story>
|
||||
</Canvas>
|
||||
|
||||
## Props
|
||||
|
||||
<ArgsTable of={WindowLevel} />
|
||||
|
||||
## Usage
|
||||
|
||||
## Contribute
|
||||
|
||||
<Footer componentRelativePath="WindowLevel/__stories__/windowLevel.stories.mdx" />
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './WindowLevel';
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -20,9 +20,7 @@ const Header = () => {
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{/* <Toolbar tools={tools} activeTool={activeTool} moreTools={tools} /> */}
|
||||
</div>
|
||||
<div className="flex items-center"></div>
|
||||
<div className="flex items-center">
|
||||
<span className="text-common-light mr-3 text-lg">FOR INVESTIGATIONAL USE ONLY</span>
|
||||
<IconButton
|
||||
|
||||
Reference in new issue
Block a user