From 97cdc2fcdbd9dc4a5b4ebbe9ebb0a59d85a9d5e7 Mon Sep 17 00:00:00 2001 From: Adrian Sebuliba Date: Thu, 8 Aug 2024 20:38:34 +0200 Subject: [PATCH 01/37] Implement vo9lume viewr in React --- package-lock.json | 1211 ++++++++++++++++++++++++++++++ package.json | 5 + src/App.js | 16 +- src/components/VolumeViewer.js | 799 ++++++++++++++++++++ src/components/appConfig.js | 180 +++++ src/components/useConstructor.js | 13 + 6 files changed, 2210 insertions(+), 14 deletions(-) create mode 100644 src/components/VolumeViewer.js create mode 100644 src/components/appConfig.js create mode 100644 src/components/useConstructor.js diff --git a/package-lock.json b/package-lock.json index 2f00437..07c1b0a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,12 +8,17 @@ "name": "3d-volume-app", "version": "0.1.0", "dependencies": { + "@aics/volume-viewer": "^3.9.0", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", + "antd": "^5.20.0", + "dat.gui": "^0.7.9", "react": "^18.3.1", + "react-color": "^2.19.3", "react-dom": "^18.3.1", "react-scripts": "5.0.1", + "three": "^0.167.1", "web-vitals": "^2.1.4" } }, @@ -22,6 +27,23 @@ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.0.tgz", "integrity": "sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ==" }, + "node_modules/@aics/volume-viewer": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/@aics/volume-viewer/-/volume-viewer-3.10.0.tgz", + "integrity": "sha512-8eaV1q5gOLpPgC9XkvWAeiYCzkad1Z9AH32C51NuiDKKPtL4SW2VolLMJaa0pgf2mhSpOiEYNih0C6lcXF7ciQ==", + "dependencies": { + "geotiff": "^2.0.5", + "serialize-error": "^11.0.3", + "three": "^0.144.0", + "tweakpane": "^3.1.9", + "zarrita": "^0.3.2" + } + }, + "node_modules/@aics/volume-viewer/node_modules/three": { + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.144.0.tgz", + "integrity": "sha512-R8AXPuqfjfRJKkYoTQcTK7A6i3AdO9++2n8ubya/GTU+fEHhYKu1ZooRSCPkx69jbnzT7dD/xEo6eROQTt2lJw==" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -45,6 +67,96 @@ "node": ">=6.0.0" } }, + "node_modules/@ant-design/colors": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.1.0.tgz", + "integrity": "sha512-MMoDGWn1y9LdQJQSHiCC20x3uZ3CwQnv9QMz6pCmJOrqdgM9YxsoVVY0wtrdXbmfSgnV0KNk6zi09NAhMR2jvg==", + "dependencies": { + "@ctrl/tinycolor": "^3.6.1" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-1.21.0.tgz", + "integrity": "sha512-gIilraPl+9EoKdYxnupxjHB/Q6IHNRjEXszKbDxZdsgv4sAZ9pjkCq8yanDWNvyfjp4leir2OVAJm0vxwKK8YA==", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "classnames": "^2.3.1", + "csstype": "^3.1.3", + "rc-util": "^5.35.0", + "stylis": "^4.0.13" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-1.0.3.tgz", + "integrity": "sha512-BrztZZKuoYcJK8uEH40ylBemf/Mu/QPiDos56g2bv6eUoniQkgQHOCOvA3+pncoFO1TaS8xcUCIqGzDA0I+ZVQ==", + "dependencies": { + "@ant-design/cssinjs": "^1.21.0", + "@babel/runtime": "^7.23.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.5.tgz", + "integrity": "sha512-kzUEdptM2vbHFn+fGkgKgbfsko5TR9GlGvAj+Xa7pKSXipbsvbqPtxcUGv7vdoPHFCr6JUBZa8Rfs+QJtFZEAw==", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.4.0.tgz", + "integrity": "sha512-QZbWC5xQYexCI5q4/fehSEkchJr5UGtvAJweT743qKUQQGs9IH2DehNLP49DJ3Ii9m9CijD2HN6fNy3WKhIFdA==", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", + "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==" + }, + "node_modules/@ant-design/react-slick": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-1.1.2.tgz", + "integrity": "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==", + "dependencies": { + "@babel/runtime": "^7.10.4", + "classnames": "^2.2.5", + "json2mq": "^0.2.0", + "resize-observer-polyfill": "^1.5.1", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": ">=16.9.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", @@ -2280,6 +2392,24 @@ "postcss-selector-parser": "^6.0.10" } }, + "node_modules/@ctrl/tinycolor": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -2405,6 +2535,14 @@ "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", "deprecated": "Use @eslint/object-schema instead" }, + "node_modules/@icons/material": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz", + "integrity": "sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==", + "peerDependencies": { + "react": "*" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -3245,6 +3383,11 @@ "node": ">= 8" } }, + "node_modules/@petamoriken/float16": { + "version": "3.8.7", + "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.8.7.tgz", + "integrity": "sha512-/Ri4xDDpe12NT6Ex/DRgHzLlobiQXEW/hmG08w1wj/YU7hLemk97c+zHQFp0iZQ9r7YqgLEXZR2sls4HxBf9NA==" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -3301,6 +3444,146 @@ } } }, + "node_modules/@rc-component/async-validator": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.0.4.tgz", + "integrity": "sha512-qgGdcVIF604M9EqjNF0hbUTz42bz/RDtxWdWuU5EQe3hi7M8ob54B6B35rOsvX5eSvIHIzT9iH1R3n+hk3CGfg==", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-2.0.0.tgz", + "integrity": "sha512-52z3XqUwUr0+Br3B8RjN2GfuR1Pk3MZPAVd34WptWFEOyTz7OQmmn8nqgXUBOYwZem8jXp6G3iv+6Dm1+1epJA==", + "dependencies": { + "@ant-design/fast-color": "^2.0.1", + "@babel/runtime": "^7.23.6", + "classnames": "^2.2.6", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-1.4.0.tgz", + "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.0.tgz", + "integrity": "sha512-jS4E7T9Li2GuYwI6PyiVXmxTiM6b07rlD9Ge8uGZSCz3WlzcG5ZK7g5bbuKNeZ9pgUuPK/5guV781ujdVpm4HQ==", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz", + "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/portal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-1.1.2.tgz", + "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.0.0.tgz", + "integrity": "sha512-L+rZ4HXP2sJ1gHMGHjsg9jlYBX/SLN2D6OxP9Zn3qgtpMWtO2vUfxVFwiogHpAIqs54FnALxraUy/BCO1yRIgg==", + "dependencies": { + "@babel/runtime": "^7.24.7", + "classnames": "^2.3.2", + "rc-util": "^5.38.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-1.15.0.tgz", + "integrity": "sha512-h6hyILDwL+In9GAgRobwRWihLqqsD7Uft3fZGrJ7L4EiyCoxbnNYwzPXDfz7vNDhWeVyvAWQJj9fJCzpI4+b4g==", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/portal": "^1.0.0-9", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/trigger": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-2.2.0.tgz", + "integrity": "sha512-QarBCji02YE9aRFhZgRZmOpXBj0IZutRippsVBv85sxvG4FGk/vRxwAlkn3MS9zK5mwbETd86mAVg2tKqTkdJA==", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@rc-component/portal": "^1.1.0", + "classnames": "^2.3.2", + "rc-motion": "^2.0.0", + "rc-resize-observer": "^1.3.1", + "rc-util": "^5.38.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, "node_modules/@rollup/plugin-babel": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", @@ -4867,6 +5150,40 @@ "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==" }, + "node_modules/@zarrita/core": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@zarrita/core/-/core-0.0.3.tgz", + "integrity": "sha512-fWv51b+xbYnws1pkNDPwJQoDa76aojxplHyMup82u11UAiet3gURMsrrkhM6YbeTgSY1A8oGxDOrvar3SiZpLA==", + "dependencies": { + "@zarrita/storage": "^0.0.2", + "@zarrita/typedarray": "^0.0.1", + "numcodecs": "^0.2.2" + } + }, + "node_modules/@zarrita/indexing": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@zarrita/indexing/-/indexing-0.0.3.tgz", + "integrity": "sha512-Q61d9MYX6dsK1DLltEpwx4mJWCZHj0TXiaEN4QpxNDtToa/EoyytP/pYHPypO4GXBscZooJ6eZkKT5FMx9PVfg==", + "dependencies": { + "@zarrita/core": "^0.0.3", + "@zarrita/storage": "^0.0.2", + "@zarrita/typedarray": "^0.0.1" + } + }, + "node_modules/@zarrita/storage": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@zarrita/storage/-/storage-0.0.2.tgz", + "integrity": "sha512-uFt4abAoiOYLroalNDAnVaQxA17zGKrQ0waYKmTVm+bNonz8ggKZP+0FqMhgUZITGChqoANHuYTazbuU5AFXWA==", + "dependencies": { + "reference-spec-reader": "^0.2.0", + "unzipit": "^1.3.6" + } + }, + "node_modules/@zarrita/typedarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@zarrita/typedarray/-/typedarray-0.0.1.tgz", + "integrity": "sha512-ZdvNjYP1bEuQXoSTVkemV99w42jHYrJ3nh9golCLd4MVBlrVbfZo4wWgBslU4JZUaDikhFSH+GWMDgAq/rI32g==" + }, "node_modules/abab": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", @@ -5085,6 +5402,70 @@ "node": ">=4" } }, + "node_modules/antd": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/antd/-/antd-5.20.0.tgz", + "integrity": "sha512-wWCFzbry3hov7k8gqhPR+FzD6EkWlhBbGD9mYOSIDoYRGMRqueTh2+2jfU1voHucmwcxDwzU7iwZDU2+PCXZdA==", + "dependencies": { + "@ant-design/colors": "^7.1.0", + "@ant-design/cssinjs": "^1.21.0", + "@ant-design/cssinjs-utils": "^1.0.3", + "@ant-design/icons": "^5.4.0", + "@ant-design/react-slick": "~1.1.2", + "@babel/runtime": "^7.24.8", + "@ctrl/tinycolor": "^3.6.1", + "@rc-component/color-picker": "~2.0.0", + "@rc-component/mutate-observer": "^1.1.0", + "@rc-component/qrcode": "~1.0.0", + "@rc-component/tour": "~1.15.0", + "@rc-component/trigger": "^2.2.0", + "classnames": "^2.5.1", + "copy-to-clipboard": "^3.3.3", + "dayjs": "^1.11.11", + "rc-cascader": "~3.27.0", + "rc-checkbox": "~3.3.0", + "rc-collapse": "~3.7.3", + "rc-dialog": "~9.5.2", + "rc-drawer": "~7.2.0", + "rc-dropdown": "~4.2.0", + "rc-field-form": "~2.2.1", + "rc-image": "~7.9.0", + "rc-input": "~1.6.2", + "rc-input-number": "~9.2.0", + "rc-mentions": "~2.15.0", + "rc-menu": "~9.14.1", + "rc-motion": "^2.9.2", + "rc-notification": "~5.6.0", + "rc-pagination": "~4.2.0", + "rc-picker": "~4.6.11", + "rc-progress": "~4.0.0", + "rc-rate": "~2.13.0", + "rc-resize-observer": "^1.4.0", + "rc-segmented": "~2.3.0", + "rc-select": "~14.15.1", + "rc-slider": "~11.1.3", + "rc-steps": "~6.0.1", + "rc-switch": "~4.1.0", + "rc-table": "~7.45.7", + "rc-tabs": "~15.1.1", + "rc-textarea": "~1.8.1", + "rc-tooltip": "~6.2.0", + "rc-tree": "~5.8.8", + "rc-tree-select": "~5.22.1", + "rc-upload": "~4.6.0", + "rc-util": "^5.43.0", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -5162,6 +5543,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/array-tree-filter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-tree-filter/-/array-tree-filter-2.1.0.tgz", + "integrity": "sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==" + }, "node_modules/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", @@ -6057,6 +6443,11 @@ "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.3.1.tgz", "integrity": "sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==" }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + }, "node_modules/clean-css": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", @@ -6214,6 +6605,11 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.0.tgz", + "integrity": "sha512-rj8l8pD4bJ1nx+dAkMhV1xB5RuZEyVysfxJqB1pRchh1KVvwOv9b7CGB8ZfjTImVv2oF+sYMUkMZq6Na5Ftmbg==" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -6269,6 +6665,14 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, "node_modules/core-js": { "version": "3.37.1", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.37.1.tgz", @@ -6702,6 +7106,11 @@ "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==" }, + "node_modules/dat.gui": { + "version": "0.7.9", + "resolved": "https://registry.npmjs.org/dat.gui/-/dat.gui-0.7.9.tgz", + "integrity": "sha512-sCNc1OHobc+Erc1HqiswYgHdVNpSJUlk/Hz8vzOCsER7rl+oF/4+v8GXFUyCgtXpoCX6+bnmg07DedLvBLwYKQ==" + }, "node_modules/data-urls": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz", @@ -6763,6 +7172,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.12", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.12.tgz", + "integrity": "sha512-Rt2g+nTbLlDWZTwwrIXjy9MeiZmSDI375FvZs72ngxx8PDC6YXOeR3q5LAuPzjZQxhiWdRKac7RKV+YyQYfYIg==" + }, "node_modules/debug": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", @@ -8805,6 +9219,24 @@ "node": ">=6.9.0" } }, + "node_modules/geotiff": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/geotiff/-/geotiff-2.1.3.tgz", + "integrity": "sha512-PT6uoF5a1+kbC3tHmZSUsLHBp2QJlHasxxxxPW47QIY1VBKpFB+FcDvX+MxER6UzgLQZ0xDzJ9s48B9JbOCTqA==", + "dependencies": { + "@petamoriken/float16": "^3.4.7", + "lerc": "^3.0.0", + "pako": "^2.0.4", + "parse-headers": "^2.0.2", + "quick-lru": "^6.1.1", + "web-worker": "^1.2.0", + "xml-utils": "^1.0.2", + "zstddec": "^0.1.0" + }, + "engines": { + "node": ">=10.19" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -12175,6 +12607,14 @@ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==" }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "dependencies": { + "string-convert": "^0.2.0" + } + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -12298,6 +12738,11 @@ "shell-quote": "^1.8.1" } }, + "node_modules/lerc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz", + "integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==" + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -12368,6 +12813,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -12466,6 +12916,11 @@ "tmpl": "1.0.5" } }, + "node_modules/material-colors": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz", + "integrity": "sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==" + }, "node_modules/mdn-data": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz", @@ -12779,6 +13234,14 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/numcodecs": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/numcodecs/-/numcodecs-0.2.2.tgz", + "integrity": "sha512-Y5K8mv80yb4MgVpcElBkUeMZqeE4TrovxRit/dTZvoRl6YkB6WEjY+fiUjGCblITnt3T3fmrDg8yRWu0gOLjhQ==", + "engines": { + "node": ">=12" + } + }, "node_modules/nwsapi": { "version": "2.2.12", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.12.tgz", @@ -13058,6 +13521,11 @@ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==" }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==" + }, "node_modules/param-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", @@ -13078,6 +13546,11 @@ "node": ">=6" } }, + "node_modules/parse-headers": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.5.tgz", + "integrity": "sha512-ft3iAoLOB/MlwbNXgzy43SWGP6sQki2jQvAyBg/zDFAgr9bfNWZIUj42Kw2eJIl8kEi4PbgE6U1Zau/HwI75HA==" + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -14666,6 +15139,17 @@ } ] }, + "node_modules/quick-lru": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz", + "integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/raf": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", @@ -14723,6 +15207,583 @@ "node": ">=0.10.0" } }, + "node_modules/rc-cascader": { + "version": "3.27.0", + "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.27.0.tgz", + "integrity": "sha512-z5uq8VvQadFUBiuZJ7YF5UAUGNkZtdEtcEYiIA94N/Kc2MIKr6lEbN5HyVddvYSgwWlKqnL6pH5bFXFuIK3MNg==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "array-tree-filter": "^2.1.0", + "classnames": "^2.3.1", + "rc-select": "~14.15.0", + "rc-tree": "~5.8.1", + "rc-util": "^5.37.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-checkbox": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/rc-checkbox/-/rc-checkbox-3.3.0.tgz", + "integrity": "sha512-Ih3ZaAcoAiFKJjifzwsGiT/f/quIkxJoklW4yKGho14Olulwn8gN7hOBve0/WGDg5o/l/5mL0w7ff7/YGvefVw==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.25.2" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-collapse": { + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/rc-collapse/-/rc-collapse-3.7.3.tgz", + "integrity": "sha512-60FJcdTRn0X5sELF18TANwtVi7FtModq649H11mYF1jh83DniMoM4MqY627sEKRCTm4+WXfGDcB7hY5oW6xhyw==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.3.4", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dialog": { + "version": "9.5.2", + "resolved": "https://registry.npmjs.org/rc-dialog/-/rc-dialog-9.5.2.tgz", + "integrity": "sha512-qVUjc8JukG+j/pNaHVSRa2GO2/KbV2thm7yO4hepQ902eGdYK913sGkwg/fh9yhKYV1ql3BKIN2xnud3rEXAPw==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/portal": "^1.0.0-8", + "classnames": "^2.2.6", + "rc-motion": "^2.3.0", + "rc-util": "^5.21.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-drawer": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/rc-drawer/-/rc-drawer-7.2.0.tgz", + "integrity": "sha512-9lOQ7kBekEJRdEpScHvtmEtXnAsy+NGDXiRWc2ZVC7QXAazNVbeT4EraQKYwCME8BJLa8Bxqxvs5swwyOepRwg==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@rc-component/portal": "^1.1.1", + "classnames": "^2.2.6", + "rc-motion": "^2.6.1", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dropdown": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/rc-dropdown/-/rc-dropdown-4.2.0.tgz", + "integrity": "sha512-odM8Ove+gSh0zU27DUj5cG1gNKg7mLWBYzB5E4nNLrLwBmYEgYP43vHKDGOVZcJSVElQBI0+jTQgjnq0NfLjng==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-util": "^5.17.0" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/rc-field-form": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/rc-field-form/-/rc-field-form-2.2.1.tgz", + "integrity": "sha512-uoNqDoR7A4tn4QTSqoWPAzrR7ZwOK5I+vuZ/qdcHtbKx+ZjEsTg7QXm2wk/jalDiSksAQmATxL0T5LJkRREdIA==", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/async-validator": "^5.0.3", + "rc-util": "^5.32.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-image": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.9.0.tgz", + "integrity": "sha512-l4zqO5E0quuLMCtdKfBgj4Suv8tIS011F5k1zBBlK25iMjjiNHxA0VeTzGFtUZERSA45gvpXDg8/P6qNLjR25g==", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/portal": "^1.0.2", + "classnames": "^2.2.6", + "rc-dialog": "~9.5.2", + "rc-motion": "^2.6.2", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-input": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/rc-input/-/rc-input-1.6.2.tgz", + "integrity": "sha512-nJqsiIv8K88w8pvbUR5savKqBokdSR0zVGPntLApeOKFp8dp6s92l1CzD60yVActpCZAJwlCfRX5rno+QVYV7g==", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.18.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-input-number": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.2.0.tgz", + "integrity": "sha512-5XZFhBCV5f9UQ62AZ2hFbEY8iZT/dm23Q1kAg0H8EvOgD3UDbYYJAayoVIkM3lQaCqYAW5gV0yV3vjw1XtzWHg==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/mini-decimal": "^1.0.1", + "classnames": "^2.2.5", + "rc-input": "~1.6.0", + "rc-util": "^5.40.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-mentions": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.15.0.tgz", + "integrity": "sha512-f5v5i7VdqvBDXbphoqcQWmXDif2Msd2arritVoWybrVDuHE6nQ7XCYsybHbV//WylooK52BFDouFvyaRDtXZEw==", + "dependencies": { + "@babel/runtime": "^7.22.5", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-input": "~1.6.0", + "rc-menu": "~9.14.0", + "rc-textarea": "~1.8.0", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-menu": { + "version": "9.14.1", + "resolved": "https://registry.npmjs.org/rc-menu/-/rc-menu-9.14.1.tgz", + "integrity": "sha512-5wlRb3M8S4yGlWhSoEYJ7ZVRElyScdcpUHxgiLxkeig1tEdyKrnED3B2fhpN0Rrpdp9jyhnmZR/Lwq2fH5VvDQ==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.0.0", + "classnames": "2.x", + "rc-motion": "^2.4.3", + "rc-overflow": "^1.3.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-motion": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.2.tgz", + "integrity": "sha512-fUAhHKLDdkAXIDLH0GYwof3raS58dtNUmzLF2MeiR8o6n4thNpSDQhOqQzWE4WfFZDCi9VEN8n7tiB7czREcyw==", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-notification": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.0.tgz", + "integrity": "sha512-TGQW5T7waOxLwgJG7fXcw8l7AQiFOjaZ7ISF5PrU526nunHRNcTMuzKihQHaF4E/h/KfOCDk3Mv8eqzbu2e28w==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.9.0", + "rc-util": "^5.20.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-overflow": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/rc-overflow/-/rc-overflow-1.3.2.tgz", + "integrity": "sha512-nsUm78jkYAoPygDAcGZeC2VwIg/IBGSodtOY3pMof4W3M9qRJgqaDYm03ZayHlde3I6ipliAxbN0RUcGf5KOzw==", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.37.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-pagination": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/rc-pagination/-/rc-pagination-4.2.0.tgz", + "integrity": "sha512-V6qeANJsT6tmOcZ4XiUmj8JXjRLbkusuufpuoBw2GiAn94fIixYjFLmbruD1Sbhn8fPLDnWawPp4CN37zQorvw==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-picker": { + "version": "4.6.11", + "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-4.6.11.tgz", + "integrity": "sha512-PEVH5MMTUrdvTTxCmPndsXiJL7TFLSu8q0cDdZrhdcjn8en3NbuhOFacWqKTvdnfG53RPPhiBssXCUHYyc3R/Q==", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.1", + "rc-overflow": "^1.3.2", + "rc-resize-observer": "^1.4.0", + "rc-util": "^5.43.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/rc-progress": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/rc-progress/-/rc-progress-4.0.0.tgz", + "integrity": "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.6", + "rc-util": "^5.16.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-rate": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/rc-rate/-/rc-rate-2.13.0.tgz", + "integrity": "sha512-oxvx1Q5k5wD30sjN5tqAyWTvJfLNNJn7Oq3IeS4HxWfAiC4BOXMITNAsw7u/fzdtO4MS8Ki8uRLOzcnEuoQiAw==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.0.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-resize-observer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.4.0.tgz", + "integrity": "sha512-PnMVyRid9JLxFavTjeDXEXo65HCRqbmLBw9xX9gfC4BZiSzbLXKzW3jPz+J0P71pLbD5tBMTT+mkstV5gD0c9Q==", + "dependencies": { + "@babel/runtime": "^7.20.7", + "classnames": "^2.2.1", + "rc-util": "^5.38.0", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-segmented": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/rc-segmented/-/rc-segmented-2.3.0.tgz", + "integrity": "sha512-I3FtM5Smua/ESXutFfb8gJ8ZPcvFR+qUgeeGFQHBOvRiRKyAk4aBE5nfqrxXx+h8/vn60DQjOt6i4RNtrbOobg==", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-motion": "^2.4.4", + "rc-util": "^5.17.0" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-select": { + "version": "14.15.1", + "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.15.1.tgz", + "integrity": "sha512-mGvuwW1RMm1NCSI8ZUoRoLRK51R2Nb+QJnmiAvbDRcjh2//ulCkxeV6ZRFTECPpE1t2DPfyqZMPw90SVJzQ7wQ==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.1.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-overflow": "^1.3.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-slider": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-11.1.5.tgz", + "integrity": "sha512-b77H5PbjMKsvkYXAYIkn50QuFX6ICQmCTibDinI9q+BHx65/TV4TeU25+oadhSRzykxs0/vBWeKBwRyySOeWlg==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-steps": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/rc-steps/-/rc-steps-6.0.1.tgz", + "integrity": "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==", + "dependencies": { + "@babel/runtime": "^7.16.7", + "classnames": "^2.2.3", + "rc-util": "^5.16.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-switch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-4.1.0.tgz", + "integrity": "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==", + "dependencies": { + "@babel/runtime": "^7.21.0", + "classnames": "^2.2.1", + "rc-util": "^5.30.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-table": { + "version": "7.45.7", + "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.45.7.tgz", + "integrity": "sha512-wi9LetBL1t1csxyGkMB2p3mCiMt+NDexMlPbXHvQFmBBAsMxrgNSAPwUci2zDLUq9m8QdWc1Nh8suvrpy9mXrg==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/context": "^1.4.0", + "classnames": "^2.2.5", + "rc-resize-observer": "^1.1.0", + "rc-util": "^5.37.0", + "rc-virtual-list": "^3.14.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tabs": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.1.1.tgz", + "integrity": "sha512-Tc7bJvpEdkWIVCUL7yQrMNBJY3j44NcyWS48jF/UKMXuUlzaXK+Z/pEL5LjGcTadtPvVmNqA40yv7hmr+tCOAw==", + "dependencies": { + "@babel/runtime": "^7.11.2", + "classnames": "2.x", + "rc-dropdown": "~4.2.0", + "rc-menu": "~9.14.0", + "rc-motion": "^2.6.2", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.34.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-textarea": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.8.1.tgz", + "integrity": "sha512-bm36N2ZqwZAP60ZQg2OY9mPdqWC+m6UTjHc+CqEZOxb3Ia29BGHazY/s5bI8M4113CkqTzhtFUDNA078ZiOx3Q==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.1", + "rc-input": "~1.6.0", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tooltip": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-6.2.0.tgz", + "integrity": "sha512-iS/3iOAvtDh9GIx1ulY7EFUXUtktFccNLsARo3NPgLf0QW9oT0w3dA9cYWlhqAKmD+uriEwdWz1kH0Qs4zk2Aw==", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tree": { + "version": "5.8.8", + "resolved": "https://registry.npmjs.org/rc-tree/-/rc-tree-5.8.8.tgz", + "integrity": "sha512-S+mCMWo91m5AJqjz3PdzKilGgbFm7fFJRFiTDOcoRbD7UfMOPnerXwMworiga0O2XIo383UoWuEfeHs1WOltag==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-tree-select": { + "version": "5.22.1", + "resolved": "https://registry.npmjs.org/rc-tree-select/-/rc-tree-select-5.22.1.tgz", + "integrity": "sha512-b8mAK52xEpRgS+b2PTapCt29GoIrO5cO8jB7AfHttFsIJfcnynY9FCtnYzURsKXJkGHbFY6UzSEB2I3TETtdWg==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-select": "~14.15.0", + "rc-tree": "~5.8.1", + "rc-util": "^5.16.1" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-upload": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.6.0.tgz", + "integrity": "sha512-Zr0DT1NHw/ApxrP7UAoxOtGaVYuzarrrCVr0ld7RiEFsKX07uFhE1EpCBxwL11ruFn89GMcshOKWp+s6FLyAlA==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "classnames": "^2.2.5", + "rc-util": "^5.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util": { + "version": "5.43.0", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.43.0.tgz", + "integrity": "sha512-AzC7KKOXFqAdIBqdGWepL9Xn7cm3vnAmjlHqUnoQaTMZYhM4VlXGLkkHHxj/BZ7Td0+SOPKB4RGPboBVKT9htw==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" + }, + "node_modules/rc-virtual-list": { + "version": "3.14.5", + "resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.14.5.tgz", + "integrity": "sha512-ZMOnkCLv2wUN8Jz7yI4XiSLa9THlYvf00LuMhb1JlsQCewuU7ydPuHw1rGVPhe9VZYl/5UqODtNd7QKJ2DMGfg==", + "dependencies": { + "@babel/runtime": "^7.20.0", + "classnames": "^2.2.6", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -14755,6 +15816,23 @@ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" }, + "node_modules/react-color": { + "version": "2.19.3", + "resolved": "https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz", + "integrity": "sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==", + "dependencies": { + "@icons/material": "^0.2.4", + "lodash": "^4.17.15", + "lodash-es": "^4.17.15", + "material-colors": "^1.2.1", + "prop-types": "^15.5.10", + "reactcss": "^1.2.0", + "tinycolor2": "^1.4.1" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-dev-utils": { "version": "12.0.1", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz", @@ -15031,6 +16109,14 @@ } } }, + "node_modules/reactcss": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz", + "integrity": "sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==", + "dependencies": { + "lodash": "^4.0.1" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -15086,6 +16172,11 @@ "node": ">=8" } }, + "node_modules/reference-spec-reader": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz", + "integrity": "sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ==" + }, "node_modules/reflect.getprototypeof": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz", @@ -15233,6 +16324,11 @@ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==" + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -15632,6 +16728,14 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, "node_modules/select-hose": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", @@ -15701,6 +16805,31 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/serialize-error": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-11.0.3.tgz", + "integrity": "sha512-2G2y++21dhj2R7iHAdd0FIzjGwuKZld+7Pl/bTU6YIkrC2ZMbVUjm+luj6A6V34Rv9XfKJDKpTWu9W4Gse1D9g==", + "dependencies": { + "type-fest": "^2.12.2" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serialize-error/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -16147,6 +17276,11 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==" + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -16402,6 +17536,11 @@ "postcss": "^8.2.15" } }, + "node_modules/stylis": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.2.tgz", + "integrity": "sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg==" + }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -16819,16 +17958,34 @@ "node": ">=0.8" } }, + "node_modules/three": { + "version": "0.167.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.167.1.tgz", + "integrity": "sha512-gYTLJA/UQip6J/tJvl91YYqlZF47+D/kxiWrbTon35ZHlXEN0VOo+Qke2walF1/x92v55H6enomymg4Dak52kw==" + }, "node_modules/throat": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/throat/-/throat-6.0.2.tgz", "integrity": "sha512-WKexMoJj3vEuK0yFEapj8y64V0A6xcuPuK9Gt1d0R+dzCSJc0lHqQytAbSB4cDAK0dWh4T0E2ETkoLE2WZ41OQ==" }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "engines": { + "node": ">=12.22" + } + }, "node_modules/thunky": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==" }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -16853,6 +18010,11 @@ "node": ">=8.0" } }, + "node_modules/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -16958,6 +18120,14 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, + "node_modules/tweakpane": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/tweakpane/-/tweakpane-3.1.10.tgz", + "integrity": "sha512-rqwnl/pUa7+inhI2E9ayGTqqP0EPOOn/wVvSWjZsRbZUItzNShny7pzwL3hVlaN4m9t/aZhsP0aFQ9U5VVR2VQ==", + "funding": { + "url": "https://github.com/sponsors/cocopon" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -17182,6 +18352,17 @@ "resolved": "https://registry.npmjs.org/unquote/-/unquote-1.1.1.tgz", "integrity": "sha512-vRCqFv6UhXpWxZPyGDh/F3ZpNv8/qo7w6iufLpQg9aKnQ71qM4B5KiI7Mia9COcjEhrO9LueHpMYjYzsWH3OIg==" }, + "node_modules/unzipit": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unzipit/-/unzipit-1.4.3.tgz", + "integrity": "sha512-gsq2PdJIWWGhx5kcdWStvNWit9FVdTewm4SEG7gFskWs+XCVaULt9+BwuoBtJiRE8eo3L1IPAOrbByNLtLtIlg==", + "dependencies": { + "uzip-module": "^1.0.2" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/upath": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", @@ -17277,6 +18458,11 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/uzip-module": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/uzip-module/-/uzip-module-1.0.3.tgz", + "integrity": "sha512-AMqwWZaknLM77G+VPYNZLEruMGWGzyigPK3/Whg99B3S6vGHuqsyl5ZrOv1UUF3paGK1U6PM0cnayioaryg/fA==" + }, "node_modules/v8-to-istanbul": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz", @@ -17356,6 +18542,11 @@ "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz", "integrity": "sha512-sVWcwhU5mX6crfI5Vd2dC4qchyTqxV8URinzt25XqVh+bHEPGH4C3NPrNionCP7Obx59wrYEbNlw4Z8sjALzZg==" }, + "node_modules/web-worker": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.3.0.tgz", + "integrity": "sha512-BSR9wyRsy/KOValMgd5kMyr3JzpdeoR9KVId8u5GVlTTAtNChlsE4yTxeY7zMdNSyOmoKBv8NH2qeRY9Tg+IaA==" + }, "node_modules/webidl-conversions": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", @@ -18180,6 +19371,11 @@ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==" }, + "node_modules/xml-utils": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.1.tgz", + "integrity": "sha512-Dn6vJ1Z9v1tepSjvnCpwk5QqwIPcEFKdgnjqfYOABv1ngSofuAhtlugcUC3ehS1OHdgDWSG6C5mvj+Qm15udTQ==" + }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", @@ -18241,6 +19437,21 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zarrita": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/zarrita/-/zarrita-0.3.2.tgz", + "integrity": "sha512-Zx9nS28C2tXZhF1BmQkgQGi0M/Z5JiM/KCMa+fEYtr/MnIzyizR4sKRA/sXjDP1iuylILWTJAWWBJD//0ONXCA==", + "dependencies": { + "@zarrita/core": "^0.0.3", + "@zarrita/indexing": "^0.0.3", + "@zarrita/storage": "^0.0.2" + } + }, + "node_modules/zstddec": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.1.0.tgz", + "integrity": "sha512-w2NTI8+3l3eeltKAdK8QpiLo/flRAr2p8AGeakfMZOXBxOg9HIu4LVDxBi81sYgVhFhdJjv1OrB5ssI8uFPoLg==" } } } diff --git a/package.json b/package.json index 249a261..6ad23ef 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,17 @@ "version": "0.1.0", "private": true, "dependencies": { + "@aics/volume-viewer": "^3.9.0", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", + "antd": "^5.20.0", + "dat.gui": "^0.7.9", "react": "^18.3.1", + "react-color": "^2.19.3", "react-dom": "^18.3.1", "react-scripts": "5.0.1", + "three": "^0.167.1", "web-vitals": "^2.1.4" }, "scripts": { diff --git a/src/App.js b/src/App.js index 3784575..d6fb43f 100644 --- a/src/App.js +++ b/src/App.js @@ -1,23 +1,11 @@ import logo from './logo.svg'; import './App.css'; +import VolumeViewer from './components/VolumeViewer'; function App() { return (
-
- logo -

- Edit src/App.js and save to reload. -

- - Learn React - -
+
); } diff --git a/src/components/VolumeViewer.js b/src/components/VolumeViewer.js new file mode 100644 index 0000000..2a1d068 --- /dev/null +++ b/src/components/VolumeViewer.js @@ -0,0 +1,799 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + LoadSpec, + View3d, + VolumeFileFormat, + RENDERMODE_PATHTRACE, + RENDERMODE_RAYMARCH, + VolumeMaker, + Light, + AREA_LIGHT, + SKY_LIGHT, + Lut +} from "@aics/volume-viewer"; +import * as THREE from 'three'; +import { TEST_DATA, loaderContext, PREFETCH_DISTANCE, MAX_PREFETCH_CHUNKS, myState } from "./appConfig"; +import { useConstructor } from './useConstructor'; +import { Slider, Switch, InputNumber, Row, Col, Collapse, Layout, Button, Select, Input, Tooltip } from 'antd'; + +const { Sider, Content } = Layout; +const { Panel } = Collapse; +const { Option } = Select; +const { Vector3 } = THREE; + +const concatenateArrays = (arrays) => { + const totalLength = arrays.reduce((acc, arr) => acc + arr.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + for (const arr of arrays) { + result.set(arr, offset); + offset += arr.length; + } + return result; +}; + +const createTestVolume = () => { + const sizeX = 64; + const sizeY = 64; + const sizeZ = 64; + const imgData = { + name: "AICS-10_5_5", + sizeX, + sizeY, + sizeZ, + sizeC: 3, + physicalPixelSize: [1, 1, 1], + spatialUnit: "", + channelNames: ["DRAQ5", "EGFP", "SEG_Memb"], + }; + + const channelVolumes = [ + VolumeMaker.createSphere(sizeX, sizeY, sizeZ, 24), + VolumeMaker.createTorus(sizeX, sizeY, sizeZ, 24, 8), + VolumeMaker.createCone(sizeX, sizeY, sizeZ, 24, 24), + ]; + const alldata = concatenateArrays(channelVolumes); + return { + metadata: imgData, + data: { + dtype: "uint8", + shape: [channelVolumes.length, sizeZ, sizeY, sizeX], + buffer: new DataView(alldata.buffer), + }, + }; +}; + +const createLoader = async (data, loadContext) => { + console.log("Creating loader context..."); + await loadContext.onOpen(); + console.log("Loader context opened."); + + const options = {}; + let path = data.url; + if (data.type === VolumeFileFormat.JSON) { + path = []; + const times = data.times || 0; + for (let t = 0; t <= times; t++) { + path.push(data.url.replace("%%", t.toString())); + } + } else if (data.type === VolumeFileFormat.DATA) { + const volumeInfo = createTestVolume(); + options.fileType = VolumeFileFormat.DATA; + options.rawArrayOptions = { data: volumeInfo.data, metadata: volumeInfo.metadata }; + } + const loader = await loadContext.createLoader(path, { + ...options, + fetchOptions: { maxPrefetchDistance: PREFETCH_DISTANCE, maxPrefetchChunks: MAX_PREFETCH_CHUNKS }, + }); + console.log("Loader created."); + return loader; +}; + +function App() { + const viewerRef = useRef(null); + const view3D = useConstructor(() => new View3d({ parentElement: viewerRef.current })); + const loadContext = useConstructor(() => loaderContext); + const [loader, setLoader] = useState(null); + const [density, setDensity] = useState(myState.density); + const [exposure, setExposure] = useState(myState.exposure); + const [lights, setLights] = useState([ + new Light(SKY_LIGHT), + new Light(AREA_LIGHT) + ]); + const [isPT, setIsPT] = useState(myState.isPT); + const [channels, setChannels] = useState([]); + const [currentVolume, setCurrentVolume] = useState(null); + const [cameraMode, setCameraMode] = useState('3D'); + const [isTurntable, setIsTurntable] = useState(false); + const [showAxis, setShowAxis] = useState(false); + const [showBoundingBox, setShowBoundingBox] = useState(false); + const [showScaleBar, setShowScaleBar] = useState(true); + const [backgroundColor, setBackgroundColor] = useState(myState.backgroundColor); + const [boundingBoxColor, setBoundingBoxColor] = useState(myState.boundingBoxColor); + const [flipX, setFlipX] = useState(1); + const [flipY, setFlipY] = useState(1); + const [flipZ, setFlipZ] = useState(1); + const [gamma, setGamma] = useState([0, 0.5, 1]); + const [clipRegion, setClipRegion] = useState({ + xmin: myState.xmin, + xmax: myState.xmax, + ymin: myState.ymin, + ymax: myState.ymax, + zmin: myState.zmin, + zmax: myState.zmax + }); + const [isPlaying, setIsPlaying] = useState(false); + const [currentFrame, setCurrentFrame] = useState(0); + const [totalFrames, setTotalFrames] = useState(0); + const [timerId, setTimerId] = useState(null); + + const densitySliderToView3D = (density) => density / 50.0; + + const onChannelDataArrived = (v, channelIndex) => { + view3D.onVolumeData(v, [channelIndex]); + if (channels[channelIndex]) { + view3D.setVolumeChannelEnabled(v, channelIndex, channels[channelIndex].enabled); + } + view3D.updateActiveChannels(v); + view3D.updateLuts(v); + if (v.isLoaded()) { + console.log("Volume " + v.name + " is loaded"); + } + view3D.redraw(); + }; + + const onVolumeCreated = (volume) => { + setCurrentVolume(volume); + view3D.removeAllVolumes(); + view3D.addVolume(volume); + setInitialRenderMode(); + showChannelUI(volume); + view3D.updateActiveChannels(volume); + view3D.updateLuts(volume); + view3D.updateLights(lights); + view3D.updateDensity(volume, densitySliderToView3D(density)); + view3D.updateExposure(exposure); + view3D.redraw(); + }; + + const loadVolume = async (loadSpec, loader) => { + const volume = await loader.createVolume(loadSpec, onChannelDataArrived); + onVolumeCreated(volume); + await loader.loadVolumeData(volume); + }; + + const loadTestData = async (testdata) => { + const loader = await createLoader(testdata, loadContext); + setLoader(loader); + const loadSpec = new LoadSpec(); + myState.totalFrames = testdata.times; + setTotalFrames(testdata.times); + await loadVolume(loadSpec, loader); + }; + + useEffect(() => { + if (viewerRef.current) { + (async () => { + try { + await loadTestData(TEST_DATA['timeSeries']); + + const container = viewerRef.current; + container.appendChild(view3D.getDOMElement()); + + const handleResize = () => { + view3D.resize(); + }; + + window.addEventListener("resize", handleResize); + + // Force a resize event after a slight delay + setTimeout(() => { + handleResize(); + }, 100); + + return () => { + window.removeEventListener("resize", handleResize); + if (view3D.getDOMElement().parentNode) { + view3D.getDOMElement().parentNode.removeChild(view3D.getDOMElement()); + } + view3D.removeAllVolumes(); + }; + } catch (error) { + console.error("Error during initialization:", error); + } + })(); + } + }, [viewerRef, view3D]); + + useEffect(() => { + if (currentVolume) { + view3D.updateDensity(currentVolume, densitySliderToView3D(density)); + view3D.redraw(); + } + }, [density]); + + useEffect(() => { + if (currentVolume) { + view3D.updateExposure(exposure); + view3D.redraw(); + } + }, [exposure]); + + useEffect(() => { + view3D.setVolumeRenderMode(isPT ? RENDERMODE_PATHTRACE : RENDERMODE_RAYMARCH); + view3D.redraw(); + }, [isPT]); + + useEffect(() => { + view3D.updateLights(lights); + view3D.redraw(); + }, [lights]); + + useEffect(() => { + if (currentVolume) { + view3D.updateActiveChannels(currentVolume); + view3D.updateLuts(currentVolume); + view3D.redraw(); + } + }, [channels]); + + useEffect(() => { + view3D.setCameraMode(cameraMode); + }, [cameraMode]); + + useEffect(() => { + view3D.setAutoRotate(isTurntable); + }, [isTurntable]); + + useEffect(() => { + view3D.setShowAxis(showAxis); + }, [showAxis]); + + useEffect(() => { + if (currentVolume) { + view3D.setShowBoundingBox(currentVolume, showBoundingBox); + } + }, [showBoundingBox]); + + useEffect(() => { + view3D.setShowScaleBar(showScaleBar); + }, [showScaleBar]); + + useEffect(() => { + view3D.setBackgroundColor(backgroundColor); + }, [backgroundColor]); + + useEffect(() => { + if (currentVolume) { + view3D.setBoundingBoxColor(currentVolume, boundingBoxColor); + } + }, [boundingBoxColor]); + + useEffect(() => { + if (currentVolume) { + view3D.setFlipVolume(currentVolume, flipX, flipY, flipZ); + } + }, [flipX, flipY, flipZ]); + + useEffect(() => { + if (currentVolume) { + const gammaValues = gammaSliderToImageValues(gamma); + view3D.setGamma(currentVolume, gammaValues[0], gammaValues[1], gammaValues[2]); + } + }, [gamma]); + + useEffect(() => { + if (currentVolume) { + view3D.updateClipRegion( + currentVolume, + clipRegion.xmin, + clipRegion.xmax, + clipRegion.ymin, + clipRegion.ymax, + clipRegion.zmin, + clipRegion.zmax + ); + } + }, [clipRegion]); + + const setInitialRenderMode = () => { + view3D.setVolumeRenderMode(isPT ? RENDERMODE_PATHTRACE : RENDERMODE_RAYMARCH); + view3D.setMaxProjectMode(currentVolume, false); + }; + + const showChannelUI = (volume) => { + const channelGui = volume.imageInfo.channelNames.map((name, index) => ({ + name, + enabled: index < 3, + colorD: volume.channelColorsDefault[index], + colorS: [0, 0, 0], + colorE: [0, 0, 0], + glossiness: 0, + window: 1, + level: 0.5, + isovalue: 128, + isosurface: false + })); + setChannels(channelGui); + }; + + const updateChannel = (index, key, value) => { + const updatedChannels = [...channels]; + updatedChannels[index][key] = value; + setChannels(updatedChannels); + + if (currentVolume) { + if (key === 'enabled') { + view3D.setVolumeChannelEnabled(currentVolume, index, value); + } else if (key === 'isosurface') { + view3D.setVolumeChannelOptions(currentVolume, index, { isosurfaceEnabled: value }); + } else if (['colorD', 'colorS', 'colorE', 'glossiness'].includes(key)) { + view3D.updateChannelMaterial( + currentVolume, + index, + updatedChannels[index].colorD, + updatedChannels[index].colorS, + updatedChannels[index].colorE, + updatedChannels[index].glossiness + ); + view3D.updateMaterial(currentVolume); + } else if (key === 'window' || key === 'level') { + const lut = new Lut().createFromWindowLevel( + updatedChannels[index].window, + updatedChannels[index].level + ); + currentVolume.setLut(index, lut); + view3D.updateLuts(currentVolume); + } + view3D.redraw(); + } + }; + + const setCameraModeHandler = (mode) => { + setCameraMode(mode); + }; + + const toggleTurntable = () => { + setIsTurntable(!isTurntable); + }; + + const toggleAxis = () => { + setShowAxis(!showAxis); + }; + + const toggleBoundingBox = () => { + setShowBoundingBox(!showBoundingBox); + }; + + const toggleScaleBar = () => { + setShowScaleBar(!showScaleBar); + }; + + const updateBackgroundColor = (color) => { + setBackgroundColor(color); + }; + + const updateBoundingBoxColor = (color) => { + setBoundingBoxColor(color); + }; + + const flipVolume = (axis) => { + if (axis === 'X') { + setFlipX(flipX * -1); + } else if (axis === 'Y') { + setFlipY(flipY * -1); + } else if (axis === 'Z') { + setFlipZ(flipZ * -1); + } + }; + + const gammaSliderToImageValues = (sliderValues) => { + let min = Number(sliderValues[0]); + let mid = Number(sliderValues[1]); + let max = Number(sliderValues[2]); + if (mid > max || mid < min) { + mid = 0.5 * (min + max); + } + const div = 255; + min /= div; + max /= div; + mid /= div; + const diff = max - min; + const x = (mid - min) / diff; + let scale = 4 * x * x; + if ((mid - 0.5) * (mid - 0.5) < 0.0005) { + scale = 1.0; + } + return [min, max, scale]; + }; + + const updateGamma = (newGamma) => { + setGamma(newGamma); + }; + + const captureScreenshot = () => { + view3D.capture((dataUrl) => { + const anchor = document.createElement("a"); + anchor.href = dataUrl; + anchor.download = "screenshot.png"; + anchor.click(); + }); + }; + + const updateClipRegion = (key, value) => { + const updatedClipRegion = { ...clipRegion, [key]: value }; + setClipRegion(updatedClipRegion); + }; + + const goToFrame = (frame) => { + if (frame >= 0 && frame < totalFrames) { + view3D.setTime(currentVolume, frame); + setCurrentFrame(frame); + } + }; + + const goToZSlice = (slice) => { + if (currentVolume && view3D.setZSlice(currentVolume, slice)) { + // Z slice updated successfully + } + }; + + const playTimeSeries = () => { + if (timerId) { + clearInterval(timerId); + } + setIsPlaying(true); + const newTimerId = setInterval(() => { + setCurrentFrame((prevFrame) => { + const nextFrame = (prevFrame + 1) % totalFrames; + view3D.setTime(currentVolume, nextFrame); + return nextFrame; + }); + }, 80); + setTimerId(newTimerId); + }; + + const pauseTimeSeries = () => { + if (timerId) { + clearInterval(timerId); + setTimerId(null); + } + setIsPlaying(false); + }; + + return ( + + + + + + Path Trace + + { + setIsPT(checked); + }} /> + + + + + { + setDensity(value); + }} + /> + + + { + setExposure(value); + }} + /> + + + {lights.map((light, index) => ( +
+ + Intensity + + { + const updatedLights = [...lights]; + updatedLights[index].mColor.setScalar(value / 255); + setLights(updatedLights); + }} + /> + + + + Theta + + { + const updatedLights = [...lights]; + updatedLights[index].mTheta = value * (Math.PI / 180); + setLights(updatedLights); + }} + /> + + + + Phi + + { + const updatedLights = [...lights]; + updatedLights[index].mPhi = value * (Math.PI / 180); + setLights(updatedLights); + }} + /> + + +
+ ))} +
+ + + + + + + + + + Background Color + + c.toString(16).padStart(2, '0')).join('')}`} onChange={(e) => updateBackgroundColor(e.target.value.match(/.{1,2}/g).map(c => parseInt(c, 16)))} /> + + + + Bounding Box Color + + c.toString(16).padStart(2, '0')).join('')}`} onChange={(e) => updateBoundingBoxColor(e.target.value.match(/.{1,2}/g).map(c => parseInt(c, 16)))} /> + + + + + + + + + Min + + updateGamma([value, gamma[1], gamma[2]])} + /> + + + + Mid + + updateGamma([gamma[0], value, gamma[2]])} + /> + + + + Max + + updateGamma([gamma[0], gamma[1], value])} + /> + + + + + {channels.map((channel, index) => ( +
+ + Enable + + updateChannel(index, 'enabled', checked)} /> + + + + Isosurface + + updateChannel(index, 'isosurface', checked)} /> + + + + Diffuse Color + + c.toString(16).padStart(2, '0')).join('')}`} onChange={(e) => updateChannel(index, 'colorD', e.target.value.match(/.{1,2}/g).map(c => parseInt(c, 16)))} /> + + + + Specular Color + + c.toString(16).padStart(2, '0')).join('')}`} onChange={(e) => updateChannel(index, 'colorS', e.target.value.match(/.{1,2}/g).map(c => parseInt(c, 16)))} /> + + + + Emissive Color + + c.toString(16).padStart(2, '0')).join('')}`} onChange={(e) => updateChannel(index, 'colorE', e.target.value.match(/.{1,2}/g).map(c => parseInt(c, 16)))} /> + + + + Glossiness + + updateChannel(index, 'glossiness', value)} + /> + + + + Window + + updateChannel(index, 'window', value)} + /> + + + + Level + + updateChannel(index, 'level', value)} + /> + + +
+ ))} +
+ + + X Min + + updateClipRegion('xmin', value)} + /> + + + + X Max + + updateClipRegion('xmax', value)} + /> + + + + Y Min + + updateClipRegion('ymin', value)} + /> + + + + Y Max + + updateClipRegion('ymax', value)} + /> + + + + Z Min + + updateClipRegion('zmin', value)} + /> + + + + Z Max + + updateClipRegion('zmax', value)} + /> + + + + + + + + + + + + Frame + + + + + + Z Slice + + + + + +
+
+ +
+
+
+ ); +} + +export default App; diff --git a/src/components/appConfig.js b/src/components/appConfig.js new file mode 100644 index 0000000..3e5b36c --- /dev/null +++ b/src/components/appConfig.js @@ -0,0 +1,180 @@ +import * as THREE from 'three'; +import { + Volume, + Light, + VolumeLoaderContext, + JsonImageInfoLoader, + VolumeFileFormat, + SKY_LIGHT, + AREA_LIGHT, +} from "@aics/volume-viewer"; + +const { Vector2, Vector3 } = THREE; + +export const getDefaultImageInfo = () => ({ + name: "", + originalSize: new Vector3(1, 1, 1), + atlasTileDims: new Vector2(1, 1), + volumeSize: new Vector3(1, 1, 1), + subregionSize: new Vector3(1, 1, 1), + subregionOffset: new Vector3(0, 0, 0), + physicalPixelSize: new Vector3(1, 1, 1), + spatialUnit: "", + numChannels: 0, + channelNames: [], + channelColors: [], + times: 1, + timeScale: 1, + timeUnit: "", + numMultiscaleLevels: 1, + multiscaleLevel: 0, + transform: { + translation: new Vector3(0, 0, 0), + rotation: new Vector3(0, 0, 0), + }, +}); + +export const CACHE_MAX_SIZE = 1_000_000_000; +export const CONCURRENCY_LIMIT = 8; +export const PREFETCH_CONCURRENCY_LIMIT = 3; +export const PREFETCH_DISTANCE = [5, 5, 5, 5]; +export const MAX_PREFETCH_CHUNKS = 25; +export const PLAYBACK_INTERVAL = 80; +export const DATARANGE_UINT8 = [0, 255]; + +export const TEST_DATA = { + timeSeries: { + type: VolumeFileFormat.JSON, + url: "https://animatedcell-test-data.s3.us-west-2.amazonaws.com/timelapse/test_parent_T49.ome_%%_atlas.json", + times: 46, + }, + omeTiff: { + type: VolumeFileFormat.TIFF, + url: "https://animatedcell-test-data.s3.us-west-2.amazonaws.com/AICS-12_881.ome.tif", + }, + zarrEMT: { + url: "https://dev-aics-dtp-001.int.allencell.org/dan-data/3500005818_20230811__20x_Timelapse-02(P27-E7).ome.zarr", + type: VolumeFileFormat.ZARR, + }, + zarrIDR1: { + type: VolumeFileFormat.ZARR, + url: "https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0076A/10501752.zarr", + }, + zarrIDR2: { + type: VolumeFileFormat.ZARR, + url: "https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0054A/5025553.zarr", + }, + zarrVariance: { + type: VolumeFileFormat.ZARR, + url: "https://animatedcell-test-data.s3.us-west-2.amazonaws.com/variance/1.zarr", + }, + zarrNucmorph0: { + type: VolumeFileFormat.ZARR, + url: "https://animatedcell-test-data.s3.us-west-2.amazonaws.com/20200323_F01_001/P13-C4.zarr/", + }, + zarrNucmorph1: { + type: VolumeFileFormat.ZARR, + url: "https://animatedcell-test-data.s3.us-west-2.amazonaws.com/20200323_F01_001/P15-C3.zarr/", + }, + zarrNucmorph2: { + type: VolumeFileFormat.ZARR, + url: "https://animatedcell-test-data.s3.us-west-2.amazonaws.com/20200323_F01_001/P7-B4.zarr/", + }, + zarrNucmorph3: { + type: VolumeFileFormat.ZARR, + url: "https://animatedcell-test-data.s3.us-west-2.amazonaws.com/20200323_F01_001/P8-B4.zarr/", + }, + zarrFlyBrain: { + type: VolumeFileFormat.ZARR, + url: "https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0048A/9846152.zarr/", + }, + zarrUK: { + type: VolumeFileFormat.ZARR, + url: "https://uk1s3.embassy.ebi.ac.uk/idr/zarr/v0.4/idr0062A/6001240.zarr", + }, + opencell: { type: "opencell", url: "" }, + cfeJson: { + type: VolumeFileFormat.JSON, + url: "AICS-12_881_atlas.json", + }, + abm: { + type: VolumeFileFormat.TIFF, + url: "https://animatedcell-test-data.s3.us-west-2.amazonaws.com/HAMILTONIAN_TERM_FOV_VSAHJUP_0000_000192.ome.tif", + }, + procedural: { type: VolumeFileFormat.DATA, url: "" }, +}; + +export const myState = { + file: "", + volume: new Volume(), + currentFrame: 0, + lastFrameTime: 0, + isPlaying: false, + timerId: 0, + loader: new JsonImageInfoLoader( + "https://animatedcell-test-data.s3.us-west-2.amazonaws.com/timelapse/test_parent_T49.ome_%%_atlas.json" + ), + density: 12.5, + maskAlpha: 1.0, + exposure: 0.75, + aperture: 0.0, + fov: 20, + focalDistance: 4.0, + lights: [new Light(SKY_LIGHT), new Light(AREA_LIGHT)], + skyTopIntensity: 0.3, + skyMidIntensity: 0.3, + skyBotIntensity: 0.3, + skyTopColor: [255, 255, 255], + skyMidColor: [255, 255, 255], + skyBotColor: [255, 255, 255], + lightColor: [255, 255, 255], + lightIntensity: 75.0, + lightTheta: 14, // deg + lightPhi: 54, // deg + xmin: 0.0, + ymin: 0.0, + zmin: 0.0, + xmax: 1.0, + ymax: 1.0, + zmax: 1.0, + samplingRate: 0.25, + primaryRay: 1.0, + secondaryRay: 1.0, + isPT: false, + isMP: false, + interpolationActive: true, + isTurntable: false, + isAxisShowing: false, + isAligned: true, + showScaleBar: true, + showBoundingBox: false, + boundingBoxColor: [255, 255, 0], + backgroundColor: [0, 0, 0], + flipX: 1, + flipY: 1, + flipZ: 1, + channelFolderNames: [], + infoObj: getDefaultImageInfo(), + channelGui: [], + currentImageStore: "", + currentImageName: "", + channelStates: [], +}; + +export const loaderContext = new VolumeLoaderContext( + CACHE_MAX_SIZE, + CONCURRENCY_LIMIT, + PREFETCH_CONCURRENCY_LIMIT +); + + +export const getDefaultChannelState = () => ({ + volumeEnabled: true, + isosurfaceEnabled: false, + colorizeEnabled: false, + colorizeAlpha: 1.0, + isovalue: 128, + opacity: 1.0, + color: [255, 255, 255], + controlPoints: [], +}); \ No newline at end of file diff --git a/src/components/useConstructor.js b/src/components/useConstructor.js new file mode 100644 index 0000000..5e06ac3 --- /dev/null +++ b/src/components/useConstructor.js @@ -0,0 +1,13 @@ +import { useRef } from 'react'; + +/** + * For objects which are persistent for the lifetime of the component, not + * a member of state, and require a constructor to create. Wraps `useRef`. + */ +export function useConstructor(constructor) { + const value = useRef(null); + if (value.current === null) { + value.current = constructor(); + } + return value.current; +} From 8ecfe713ceb3f1f904520acd5a2766b7ed2f313a Mon Sep 17 00:00:00 2001 From: Adrian Sebuliba Date: Thu, 8 Aug 2024 20:42:48 +0200 Subject: [PATCH 02/37] add readme --- README.md | 112 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 67 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 58beeac..1411fa7 100644 --- a/README.md +++ b/README.md @@ -1,70 +1,92 @@ -# Getting Started with Create React App +# 3D Volume Viewer Application -This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). +This application is a 3D volume viewer designed to visualize medical imaging data of mouse body parts. The frontend is built with React and communicates with a Node.js backend that serves the volume files. Users can select different body parts and view the corresponding 3D volume files. -## Available Scripts +## Table of Contents -In the project directory, you can run: +1. [Features](#features) +2. [Installation](#installation) +3. [Usage](#usage) +4. [File Structure](#file-structure) +5. [API Endpoints](#api-endpoints) +6. [Technologies Used](#technologies-used) +7. [Contributing](#contributing) +8. [License](#license) -### `npm start` +## Features -Runs the app in the development mode.\ -Open [http://localhost:3000](http://localhost:3000) to view it in your browser. +- Visualize 3D volume data in various formats (OME-TIFF, OME-ZARR, TIFF). +- Control rendering modes (Path Trace, Ray March). +- Adjust visualization parameters such as density, exposure, and gamma. +- Manipulate lighting settings and camera modes. +- Playback functionality for time-series data. +- Dynamic channel controls to adjust color and material properties. +- Set clipping regions and view bounding boxes. +- Flip volume along different axes. +- Screenshot capturing functionality. +- RESTful API for serving volume files. -The page will reload when you make changes.\ -You may also see any lint errors in the console. +## Installation -### `npm test` +### Prerequisites -Launches the test runner in the interactive watch mode.\ -See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. +- Node.js (v12 or later) +- npm (v6 or later) -### `npm run build` +### Backend Setup -Builds the app for production to the `build` folder.\ -It correctly bundles React in production mode and optimizes the build for the best performance. +1. Clone the repository: + ```bash + git clone https://github.com/yourusername/3d-volume-viewer.git + cd 3d-volume-viewer + ``` -The build is minified and the filenames include the hashes.\ -Your app is ready to be deployed! +2. Navigate to the backend directory: + ```bash + cd server + ``` -See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. +3. Install dependencies: + ```bash + npm install + ``` -### `npm run eject` +4. Start the server: + ```bash + npm start + ``` -**Note: this is a one-way operation. Once you `eject`, you can't go back!** +### Frontend Setup -If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. +1. Navigate to the frontend directory: + ```bash + cd ../client + ``` -Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own. +2. Install dependencies: + ```bash + npm install + ``` -You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it. +3. Start the development server: + ```bash + npm start + ``` -## Learn More +4. Open your browser and navigate to `http://localhost:3000`. -You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). +## Usage -To learn React, check out the [React documentation](https://reactjs.org/). +### Upload Files -### Code Splitting +1. Place your volume files in the appropriate directories under `server/uploads`. Each directory represents a different body part (e.g., `liver`, `brain`). -This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting) +### View and Manipulate Volumes -### Analyzing the Bundle Size +1. Use the file selector in the frontend to choose a body part and a specific volume file. +2. Adjust rendering and visualization settings using the provided controls. +3. Use playback controls to navigate through time-series data. +4. Capture screenshots using the screenshot button. -This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size) +## File Structure -### Making a Progressive Web App - -This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app) - -### Advanced Configuration - -This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration) - -### Deployment - -This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment) - -### `npm run build` fails to minify - -This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) From 89ced8e6b6121f7fad95e8ad61360a118726808f Mon Sep 17 00:00:00 2001 From: "adrian.sebuliba" Date: Tue, 8 Oct 2024 14:29:05 +0300 Subject: [PATCH 03/37] Fix issue with wrong file extension check --- package-lock.json | 29 ++++ package.json | 1 + src/components/VolumeViewer.js | 246 ++++++++++++++++++--------------- 3 files changed, 164 insertions(+), 112 deletions(-) diff --git a/package-lock.json b/package-lock.json index 07c1b0a..686752c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "antd": "^5.20.0", + "axios": "^1.7.7", "dat.gui": "^0.7.9", "react": "^18.3.1", "react-color": "^2.19.3", @@ -5770,6 +5771,29 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axios/node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/axobject-query": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz", @@ -15078,6 +15102,11 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", diff --git a/package.json b/package.json index 6ad23ef..8c3d917 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "antd": "^5.20.0", + "axios": "^1.7.7", "dat.gui": "^0.7.9", "react": "^18.3.1", "react-color": "^2.19.3", diff --git a/src/components/VolumeViewer.js b/src/components/VolumeViewer.js index 2a1d068..27f3aff 100644 --- a/src/components/VolumeViewer.js +++ b/src/components/VolumeViewer.js @@ -12,15 +12,13 @@ import { Lut } from "@aics/volume-viewer"; import * as THREE from 'three'; -import { TEST_DATA, loaderContext, PREFETCH_DISTANCE, MAX_PREFETCH_CHUNKS, myState } from "./appConfig"; +import { loaderContext, PREFETCH_DISTANCE, MAX_PREFETCH_CHUNKS, myState } from "./appConfig"; import { useConstructor } from './useConstructor'; -import { Slider, Switch, InputNumber, Row, Col, Collapse, Layout, Button, Select, Input, Tooltip } from 'antd'; - -const { Sider, Content } = Layout; -const { Panel } = Collapse; -const { Option } = Select; -const { Vector3 } = THREE; +import { Slider, Switch, InputNumber, Row, Col, Collapse, Layout, Button, Select, Input, Tooltip, Spin } from 'antd'; +import axios from 'axios'; +import { API_URL } from '../config'; // Importing API_URL from your config +// Utility function to concatenate arrays const concatenateArrays = (arrays) => { const totalLength = arrays.reduce((acc, arr) => acc + arr.length, 0); const result = new Uint8Array(totalLength); @@ -30,70 +28,23 @@ const concatenateArrays = (arrays) => { offset += arr.length; } return result; -}; - -const createTestVolume = () => { - const sizeX = 64; - const sizeY = 64; - const sizeZ = 64; - const imgData = { - name: "AICS-10_5_5", - sizeX, - sizeY, - sizeZ, - sizeC: 3, - physicalPixelSize: [1, 1, 1], - spatialUnit: "", - channelNames: ["DRAQ5", "EGFP", "SEG_Memb"], - }; +} - const channelVolumes = [ - VolumeMaker.createSphere(sizeX, sizeY, sizeZ, 24), - VolumeMaker.createTorus(sizeX, sizeY, sizeZ, 24, 8), - VolumeMaker.createCone(sizeX, sizeY, sizeZ, 24, 24), - ]; - const alldata = concatenateArrays(channelVolumes); - return { - metadata: imgData, - data: { - dtype: "uint8", - shape: [channelVolumes.length, sizeZ, sizeY, sizeX], - buffer: new DataView(alldata.buffer), - }, - }; -}; - -const createLoader = async (data, loadContext) => { - console.log("Creating loader context..."); - await loadContext.onOpen(); - console.log("Loader context opened."); - - const options = {}; - let path = data.url; - if (data.type === VolumeFileFormat.JSON) { - path = []; - const times = data.times || 0; - for (let t = 0; t <= times; t++) { - path.push(data.url.replace("%%", t.toString())); - } - } else if (data.type === VolumeFileFormat.DATA) { - const volumeInfo = createTestVolume(); - options.fileType = VolumeFileFormat.DATA; - options.rawArrayOptions = { data: volumeInfo.data, metadata: volumeInfo.metadata }; - } - const loader = await loadContext.createLoader(path, { - ...options, - fetchOptions: { maxPrefetchDistance: PREFETCH_DISTANCE, maxPrefetchChunks: MAX_PREFETCH_CHUNKS }, - }); - console.log("Loader created."); - return loader; -}; +const { Sider, Content } = Layout; +const { Panel } = Collapse; +const { Option } = Select; +const { Vector3 } = THREE; -function App() { +const VolumeViewer = () => { const viewerRef = useRef(null); const view3D = useConstructor(() => new View3d({ parentElement: viewerRef.current })); const loadContext = useConstructor(() => loaderContext); + const [loader, setLoader] = useState(null); + const [fileData, setFileData] = useState({}); + const [selectedBodyPart, setSelectedBodyPart] = useState(null); + const [selectedFile, setSelectedFile] = useState(null); + const [currentVolume, setCurrentVolume] = useState(null); const [density, setDensity] = useState(myState.density); const [exposure, setExposure] = useState(myState.exposure); const [lights, setLights] = useState([ @@ -102,7 +53,6 @@ function App() { ]); const [isPT, setIsPT] = useState(myState.isPT); const [channels, setChannels] = useState([]); - const [currentVolume, setCurrentVolume] = useState(null); const [cameraMode, setCameraMode] = useState('3D'); const [isTurntable, setIsTurntable] = useState(false); const [showAxis, setShowAxis] = useState(false); @@ -126,6 +76,7 @@ function App() { const [currentFrame, setCurrentFrame] = useState(0); const [totalFrames, setTotalFrames] = useState(0); const [timerId, setTimerId] = useState(null); + const [isLoading, setIsLoading] = useState(false); const densitySliderToView3D = (density) => density / 50.0; @@ -162,49 +113,101 @@ function App() { await loader.loadVolumeData(volume); }; - const loadTestData = async (testdata) => { - const loader = await createLoader(testdata, loadContext); - setLoader(loader); - const loadSpec = new LoadSpec(); - myState.totalFrames = testdata.times; - setTotalFrames(testdata.times); - await loadVolume(loadSpec, loader); + const loadVolumeFromServer = async (url) => { + setIsLoading(true); + try { + const loadSpec = new LoadSpec(); + const fileExtension = url.split('.').pop(); + console.log(fileExtension) + const volumeFileType = (fileExtension === 'tiff' || fileExtension === 'tif' || fileExtension === 'ome.tiff' || fileExtension === 'ome.tif') ? VolumeFileFormat.TIFF : VolumeFileFormat.ZARR; + console.log(volumeFileType) + const loader = await loadContext.createLoader(url, { + fileType: volumeFileType, + fetchOptions: { maxPrefetchDistance: PREFETCH_DISTANCE, maxPrefetchChunks: MAX_PREFETCH_CHUNKS }, + }); + + setLoader(loader); + await loadVolume(loadSpec, loader); + } catch (error) { + console.error('Error loading volume:', error); + } finally { + setIsLoading(false); + } + }; + + + const createTestVolume = () => { + const sizeX = 64; + const sizeY = 64; + const sizeZ = 64; + const imgData = { + name: "AICS-10_5_5", + sizeX, + sizeY, + sizeZ, + sizeC: 3, + physicalPixelSize: [1, 1, 1], + spatialUnit: "", + channelNames: ["DRAQ5", "EGFP", "SEG_Memb"], + }; + + const channelVolumes = [ + VolumeMaker.createSphere(sizeX, sizeY, sizeZ, 24), + VolumeMaker.createTorus(sizeX, sizeY, sizeZ, 24, 8), + VolumeMaker.createCone(sizeX, sizeY, sizeZ, 24, 24), + ]; + + const alldata = concatenateArrays(channelVolumes); + return { + metadata: imgData, + data: { + dtype: "uint8", + shape: [channelVolumes.length, sizeZ, sizeY, sizeX], + buffer: new DataView(alldata.buffer), + }, + }; + }; + + const fetchFiles = async () => { + try { + const response = await axios.get(`${API_URL}/files`); + setFileData(response.data); + } catch (error) { + console.error('Error fetching files:', error); + } }; + const handleFileSelect = async (bodyPart, file) => { + setSelectedBodyPart(bodyPart); + setSelectedFile(file); + await loadVolumeFromServer(`${API_URL}/${bodyPart}/${file}`); + }; + + useEffect(() => { + fetchFiles(); + }, []); + useEffect(() => { if (viewerRef.current) { - (async () => { - try { - await loadTestData(TEST_DATA['timeSeries']); - - const container = viewerRef.current; - container.appendChild(view3D.getDOMElement()); - - const handleResize = () => { - view3D.resize(); - }; - - window.addEventListener("resize", handleResize); - - // Force a resize event after a slight delay - setTimeout(() => { - handleResize(); - }, 100); - - return () => { - window.removeEventListener("resize", handleResize); - if (view3D.getDOMElement().parentNode) { - view3D.getDOMElement().parentNode.removeChild(view3D.getDOMElement()); - } - view3D.removeAllVolumes(); - }; - } catch (error) { - console.error("Error during initialization:", error); + const container = viewerRef.current; + container.appendChild(view3D.getDOMElement()); + + const handleResize = () => view3D.resize(); + window.addEventListener("resize", handleResize); + + view3D.resize(); + + return () => { + window.removeEventListener("resize", handleResize); + if (view3D.getDOMElement().parentNode) { + view3D.getDOMElement().parentNode.removeChild(view3D.getDOMElement()); } - })(); + view3D.removeAllVolumes(); + }; } }, [viewerRef, view3D]); + // Various useEffect hooks for updating the volume based on user controls useEffect(() => { if (currentVolume) { view3D.updateDensity(currentVolume, densitySliderToView3D(density)); @@ -217,17 +220,17 @@ function App() { view3D.updateExposure(exposure); view3D.redraw(); } - }, [exposure]); + }, [currentVolume, exposure, view3D]); useEffect(() => { view3D.setVolumeRenderMode(isPT ? RENDERMODE_PATHTRACE : RENDERMODE_RAYMARCH); view3D.redraw(); - }, [isPT]); + }, [isPT, view3D]); useEffect(() => { view3D.updateLights(lights); view3D.redraw(); - }, [lights]); + }, [lights, view3D]); useEffect(() => { if (currentVolume) { @@ -243,25 +246,25 @@ function App() { useEffect(() => { view3D.setAutoRotate(isTurntable); - }, [isTurntable]); + }, [isTurntable, view3D]); useEffect(() => { view3D.setShowAxis(showAxis); - }, [showAxis]); + }, [showAxis, view3D]); useEffect(() => { if (currentVolume) { view3D.setShowBoundingBox(currentVolume, showBoundingBox); } - }, [showBoundingBox]); + }, [currentVolume, showBoundingBox, view3D]); useEffect(() => { view3D.setShowScaleBar(showScaleBar); - }, [showScaleBar]); + }, [showScaleBar, view3D]); useEffect(() => { view3D.setBackgroundColor(backgroundColor); - }, [backgroundColor]); + }, [backgroundColor, view3D]); useEffect(() => { if (currentVolume) { @@ -788,12 +791,31 @@ function App() { +
+ + {Object.keys(fileData).map((bodyPart) => ( + + {fileData[bodyPart].map((file) => ( +
handleFileSelect(bodyPart, file)} + > + {file} +
+ ))} +
+ ))} +
+
- -
+ + +
+
); } -export default App; +export default VolumeViewer; \ No newline at end of file From a523606fad1d5ab859c0867eb20bf439c65fcaa6 Mon Sep 17 00:00:00 2001 From: "adrian.sebuliba" Date: Tue, 8 Oct 2024 14:36:19 +0300 Subject: [PATCH 04/37] Add configuration url --- src/config.js | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 src/config.js diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..4a0c2f3 --- /dev/null +++ b/src/config.js @@ -0,0 +1,2 @@ +// API URL +export const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api'; \ No newline at end of file From dd6444f3d9a3579b32e44ac3735b4d085fae3bec Mon Sep 17 00:00:00 2001 From: "adrian.sebuliba" Date: Tue, 15 Oct 2024 14:11:21 +0300 Subject: [PATCH 05/37] Add histogram controls for opacity, window, and level adjustments in volume rendering --- src/components/VolumeViewer.js | 91 +++++++++++++++++----------------- src/components/appConfig.js | 4 +- 2 files changed, 48 insertions(+), 47 deletions(-) diff --git a/src/components/VolumeViewer.js b/src/components/VolumeViewer.js index 27f3aff..6eac595 100644 --- a/src/components/VolumeViewer.js +++ b/src/components/VolumeViewer.js @@ -94,9 +94,18 @@ const VolumeViewer = () => { }; const onVolumeCreated = (volume) => { + + volume.channelColorsDefault = volume.imageInfo.channelNames.map(() => DEFAULT_CHANNEL_COLOR); + setCurrentVolume(volume); view3D.removeAllVolumes(); view3D.addVolume(volume); + + + // Log the channel colors to verify the change + console.log("Channel Default Colors:", volume.channelColors); + + setInitialRenderMode(); showChannelUI(volume); view3D.updateActiveChannels(volume); @@ -304,11 +313,13 @@ const VolumeViewer = () => { view3D.setMaxProjectMode(currentVolume, false); }; - const showChannelUI = (volume) => { + const DEFAULT_CHANNEL_COLOR = [128, 128, 128]; // Medium gray + + const showChannelUI = (volume) => { const channelGui = volume.imageInfo.channelNames.map((name, index) => ({ name, enabled: index < 3, - colorD: volume.channelColorsDefault[index], + colorD: volume.channelColorsDefault[index] || DEFAULT_CHANNEL_COLOR, colorS: [0, 0, 0], colorE: [0, 0, 0], glossiness: 0, @@ -318,13 +329,18 @@ const VolumeViewer = () => { isosurface: false })); setChannels(channelGui); + + // Log channel colors for verification + channelGui.forEach((channel, index) => { + console.log(`Channel ${index} (${channel.name}) color:`, channel.colorD); + }); }; const updateChannel = (index, key, value) => { const updatedChannels = [...channels]; updatedChannels[index][key] = value; setChannels(updatedChannels); - + if (currentVolume) { if (key === 'enabled') { view3D.setVolumeChannelEnabled(currentVolume, index, value); @@ -352,6 +368,28 @@ const VolumeViewer = () => { } }; + + // Histogram-based LUT adjustments + const updateChannelLut = (index, type) => { + if (currentVolume) { + let lut; + if (type === 'autoIJ') { + const [hmin, hmax] = currentVolume.getHistogram(index).findAutoIJBins(); + lut = new Lut().createFromMinMax(hmin, hmax); + } else if (type === 'auto0') { + const [b, e] = currentVolume.getHistogram(index).findAutoMinMax(); + lut = new Lut().createFromMinMax(b, e); + } else if (type === 'bestFit') { + const [hmin, hmax] = currentVolume.getHistogram(index).findBestFitBins(); + lut = new Lut().createFromMinMax(hmin, hmax); + } + + currentVolume.setLut(index, lut); + view3D.updateLuts(currentVolume); + view3D.redraw(); + } + }; + const setCameraModeHandler = (mode) => { setCameraMode(mode); }; @@ -637,50 +675,11 @@ const VolumeViewer = () => { - Specular Color + Histogram Adjustments - c.toString(16).padStart(2, '0')).join('')}`} onChange={(e) => updateChannel(index, 'colorS', e.target.value.match(/.{1,2}/g).map(c => parseInt(c, 16)))} /> - - - - Emissive Color - - c.toString(16).padStart(2, '0')).join('')}`} onChange={(e) => updateChannel(index, 'colorE', e.target.value.match(/.{1,2}/g).map(c => parseInt(c, 16)))} /> - - - - Glossiness - - updateChannel(index, 'glossiness', value)} - /> - - - - Window - - updateChannel(index, 'window', value)} - /> - - - - Level - - updateChannel(index, 'level', value)} - /> + + + diff --git a/src/components/appConfig.js b/src/components/appConfig.js index 3e5b36c..f668120 100644 --- a/src/components/appConfig.js +++ b/src/components/appConfig.js @@ -177,4 +177,6 @@ export const getDefaultChannelState = () => ({ opacity: 1.0, color: [255, 255, 255], controlPoints: [], -}); \ No newline at end of file +}); + +export const DEFAULT_CHANNEL_COLOR = [128, 128, 128]; // Medium gray From 6fef2c493797e61bbb6f750f328c0077fc86a7cb Mon Sep 17 00:00:00 2001 From: Adrian Sebuliba Date: Wed, 16 Oct 2024 15:01:06 +0200 Subject: [PATCH 06/37] Ensure opacity can be controlled --- src/components/VolumeViewer.js | 502 +++++++++++++++++++++++++-------- src/components/constants.js | 128 +++++++++ src/config.js | 2 +- 3 files changed, 520 insertions(+), 112 deletions(-) create mode 100644 src/components/constants.js diff --git a/src/components/VolumeViewer.js b/src/components/VolumeViewer.js index 6eac595..5a34f53 100644 --- a/src/components/VolumeViewer.js +++ b/src/components/VolumeViewer.js @@ -16,7 +16,7 @@ import { loaderContext, PREFETCH_DISTANCE, MAX_PREFETCH_CHUNKS, myState } from " import { useConstructor } from './useConstructor'; import { Slider, Switch, InputNumber, Row, Col, Collapse, Layout, Button, Select, Input, Tooltip, Spin } from 'antd'; import axios from 'axios'; -import { API_URL } from '../config'; // Importing API_URL from your config +import { API_URL } from '../config'; // Importing API_URL from your config // Utility function to concatenate arrays const concatenateArrays = (arrays) => { @@ -77,6 +77,27 @@ const VolumeViewer = () => { const [totalFrames, setTotalFrames] = useState(0); const [timerId, setTimerId] = useState(null); const [isLoading, setIsLoading] = useState(false); + + const [maskAlpha, setMaskAlpha] = useState(myState.maskAlpha); + const [primaryRay, setPrimaryRay] = useState(myState.primaryRay); + const [secondaryRay, setSecondaryRay] = useState(myState.secondaryRay); + const [fov, setFov] = useState(myState.fov); + const [focalDistance, setFocalDistance] = useState(myState.focal_distance); + const [aperture, setAperture] = useState(myState.aperture); + const [samplingRate, setSamplingRate] = useState(myState.samplingRate); + + + + const [skyTopIntensity, setSkyTopIntensity] = useState(myState.skyTopIntensity); + const [skyMidIntensity, setSkyMidIntensity] = useState(myState.skyMidIntensity); + const [skyBotIntensity, setSkyBotIntensity] = useState(myState.skyBotIntensity); + const [skyTopColor, setSkyTopColor] = useState(myState.skyTopColor); + const [skyMidColor, setSkyMidColor] = useState(myState.skyMidColor); + const [skyBotColor, setSkyBotColor] = useState(myState.skyBotColor); + const [lightColor, setLightColor] = useState(myState.lightColor); + const [lightIntensity, setLightIntensity] = useState(myState.lightIntensity); + const [lightTheta, setLightTheta] = useState(myState.lightTheta); + const [lightPhi, setLightPhi] = useState(myState.lightPhi); const densitySliderToView3D = (density) => density / 50.0; @@ -95,7 +116,7 @@ const VolumeViewer = () => { const onVolumeCreated = (volume) => { - volume.channelColorsDefault = volume.imageInfo.channelNames.map(() => DEFAULT_CHANNEL_COLOR); + // volume.channelColorsDefault = volume.imageInfo.channelNames.map(() => DEFAULT_CHANNEL_COLOR); setCurrentVolume(volume); view3D.removeAllVolumes(); @@ -112,13 +133,21 @@ const VolumeViewer = () => { view3D.updateLuts(volume); view3D.updateLights(lights); view3D.updateDensity(volume, densitySliderToView3D(density)); + view3D.updateMaskAlpha(volume, maskAlpha); + view3D.setRayStepSizes(volume, primaryRay, secondaryRay); view3D.updateExposure(exposure); + view3D.updateCamera(fov, focalDistance, aperture); + // view3D.updatePixelSamplingRate(samplingRate); view3D.redraw(); }; const loadVolume = async (loadSpec, loader) => { const volume = await loader.createVolume(loadSpec, onChannelDataArrived); onVolumeCreated(volume); + + console.log(volume.imageInfo, volume.imageInfo.times) + // Set total frames based on the volume's metadata (assuming 'times' represents the number of frames) + setTotalFrames(volume.imageInfo.times || 1); await loader.loadVolumeData(volume); }; @@ -127,9 +156,7 @@ const VolumeViewer = () => { try { const loadSpec = new LoadSpec(); const fileExtension = url.split('.').pop(); - console.log(fileExtension) const volumeFileType = (fileExtension === 'tiff' || fileExtension === 'tif' || fileExtension === 'ome.tiff' || fileExtension === 'ome.tif') ? VolumeFileFormat.TIFF : VolumeFileFormat.ZARR; - console.log(volumeFileType) const loader = await loadContext.createLoader(url, { fileType: volumeFileType, fetchOptions: { maxPrefetchDistance: PREFETCH_DISTANCE, maxPrefetchChunks: MAX_PREFETCH_CHUNKS }, @@ -144,7 +171,6 @@ const VolumeViewer = () => { } }; - const createTestVolume = () => { const sizeX = 64; const sizeY = 64; @@ -216,7 +242,6 @@ const VolumeViewer = () => { } }, [viewerRef, view3D]); - // Various useEffect hooks for updating the volume based on user controls useEffect(() => { if (currentVolume) { view3D.updateDensity(currentVolume, densitySliderToView3D(density)); @@ -308,6 +333,70 @@ const VolumeViewer = () => { } }, [clipRegion]); + useEffect(() => { + if (currentVolume) { + view3D.updateCamera(fov, focalDistance, aperture); + view3D.redraw(); + } + }, [fov, focalDistance, aperture]); + + + useEffect(() => { + if (currentVolume) { + view3D.setRayStepSizes(currentVolume, primaryRay, secondaryRay); + view3D.redraw(); + } + }, [primaryRay, secondaryRay]); + + useEffect(() => { + if (currentVolume) { + view3D.updateMaskAlpha(currentVolume, maskAlpha); + view3D.redraw(); + } + }, [maskAlpha]) + + useEffect(() => { + if (view3D && lights[0]) { + const skyLight = lights[0]; + skyLight.mColorTop = new Vector3( + (skyTopColor[0] / 255.0) * skyTopIntensity, + (skyTopColor[1] / 255.0) * skyTopIntensity, + (skyTopColor[2] / 255.0) * skyTopIntensity + ); + skyLight.mColorMiddle = new Vector3( + (skyMidColor[0] / 255.0) * skyMidIntensity, + (skyMidColor[1] / 255.0) * skyMidIntensity, + (skyMidColor[2] / 255.0) * skyMidIntensity + ); + skyLight.mColorBottom = new Vector3( + (skyBotColor[0] / 255.0) * skyBotIntensity, + (skyBotColor[1] / 255.0) * skyBotIntensity, + (skyBotColor[2] / 255.0) * skyBotIntensity + ); + view3D.updateLights(lights); + // view3D.redraw(); + console.log([skyTopColor, skyTopIntensity, skyMidColor, skyMidIntensity, skyBotColor, skyBotIntensity]); + } + + }, [skyTopColor, skyTopIntensity, skyMidColor, skyMidIntensity, skyBotColor, skyBotIntensity]); + + // useEffect for area light + useEffect(() => { + if (view3D && lights[1]) { + const areaLight = lights[1]; + areaLight.mColor = new Vector3( + (lightColor[0] / 255.0) * lightIntensity, + (lightColor[1] / 255.0) * lightIntensity, + (lightColor[2] / 255.0) * lightIntensity + ); + areaLight.mTheta = (lightTheta * Math.PI) / 180.0; + areaLight.mPhi = (lightPhi * Math.PI) / 180.0; + view3D.updateLights(lights); + // view3D.redraw(); + } + console.log([lightColor, lightIntensity, lightTheta, lightPhi]); + }, [lightColor, lightIntensity, lightTheta, lightPhi]); + const setInitialRenderMode = () => { view3D.setVolumeRenderMode(isPT ? RENDERMODE_PATHTRACE : RENDERMODE_RAYMARCH); view3D.setMaxProjectMode(currentVolume, false); @@ -346,6 +435,11 @@ const VolumeViewer = () => { view3D.setVolumeChannelEnabled(currentVolume, index, value); } else if (key === 'isosurface') { view3D.setVolumeChannelOptions(currentVolume, index, { isosurfaceEnabled: value }); + if (value) { + view3D.createIsosurface(currentVolume, index, updatedChannels[index].isovalue, 1.0); + } else { + view3D.clearIsosurface(currentVolume, index); + } } else if (['colorD', 'colorS', 'colorE', 'glossiness'].includes(key)) { view3D.updateChannelMaterial( currentVolume, @@ -368,6 +462,12 @@ const VolumeViewer = () => { } }; + const updateIsovalue = (index, isovalue) => { + if (currentVolume) { + view3D.updateIsosurface(currentVolume, index, isovalue); + view3D.redraw(); + } + }; // Histogram-based LUT adjustments const updateChannelLut = (index, type) => { @@ -382,6 +482,10 @@ const VolumeViewer = () => { } else if (type === 'bestFit') { const [hmin, hmax] = currentVolume.getHistogram(index).findBestFitBins(); lut = new Lut().createFromMinMax(hmin, hmax); + } else if (type === 'pct50_98') { + const hmin = currentVolume.getHistogram(index).findBinOfPercentile(0.5); + const hmax = currentVolume.getHistogram(index).findBinOfPercentile(0.983); + lut = new Lut().createFromMinMax(hmin, hmax); } currentVolume.setLut(index, lut); @@ -476,6 +580,17 @@ const VolumeViewer = () => { const goToZSlice = (slice) => { if (currentVolume && view3D.setZSlice(currentVolume, slice)) { // Z slice updated successfully + const zSlider = document.getElementById("zSlider"); + const zInput = document.getElementById("zValue"); + + if (zInput) { + zInput.value = slice; + } + if (zSlider) { + zSlider.value = slice; + } + } else { + console.log('Failed to update Z slice'); } }; @@ -502,52 +617,165 @@ const VolumeViewer = () => { setIsPlaying(false); }; + const rgbToHex = (r, g, b) => { + const toHex = (component) => { + const hex = Math.round(component).toString(16); + return hex.length === 1 ? '0' + hex : hex; // Ensures two digits + }; + + // Ensure r, g, b are valid numbers and fall back to 0 if undefined or invalid + r = isNaN(r) ? 0 : r; + g = isNaN(g) ? 0 : g; + b = isNaN(b) ? 0 : b; + + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; + }; + + const updatePixelSamplingRate = (rate) => { + setSamplingRate(rate); + view3D.updatePixelSamplingRate(rate); + view3D.redraw(); + }; + + + const updateSkyLight = (position, intensity, color) => { + if (position === 'top') { + setSkyTopIntensity(intensity); + setSkyTopColor(color); + } else if (position === 'mid') { + setSkyMidIntensity(intensity); + setSkyMidColor(color); + } else if (position === 'bot') { + setSkyBotIntensity(intensity); + setSkyBotColor(color); + } + updateLights(); + }; + + const updateAreaLight = (intensity, color, theta, phi) => { + setLightIntensity(intensity); + setLightColor(color); + setLightTheta(theta); + setLightPhi(phi); + updateLights(); + }; + + const updateLights = () => { + const updatedLights = [...lights]; + // Update sky light + updatedLights[0].mColorTop = new Vector3( + (skyTopColor[0] / 255.0) * skyTopIntensity, + (skyTopColor[1] / 255.0) * skyTopIntensity, + (skyTopColor[2] / 255.0) * skyTopIntensity + ); + updatedLights[0].mColorMiddle = new Vector3( + (skyMidColor[0] / 255.0) * skyMidIntensity, + (skyMidColor[1] / 255.0) * skyMidIntensity, + (skyMidColor[2] / 255.0) * skyMidIntensity + ); + updatedLights[0].mColorBottom = new Vector3( + (skyBotColor[0] / 255.0) * skyBotIntensity, + (skyBotColor[1] / 255.0) * skyBotIntensity, + (skyBotColor[2] / 255.0) * skyBotIntensity + ); + + // Update area light + updatedLights[1].mColor = new Vector3( + (lightColor[0] / 255.0) * lightIntensity, + (lightColor[1] / 255.0) * lightIntensity, + (lightColor[2] / 255.0) * lightIntensity + ); + updatedLights[1].mTheta = (lightTheta * Math.PI) / 180.0; + updatedLights[1].mPhi = (lightPhi * Math.PI) / 180.0; + + setLights(updatedLights); + view3D.updateLights(updatedLights); + view3D.redraw(); + }; + return ( + {/* Render Mode */} Path Trace - { - setIsPT(checked); - }} /> + setIsPT(checked)} /> + + {/* Density */} - { - setDensity(value); - }} - /> + + + + {/* Mask Alpha */} + + + + {/* Ray Step Sizes */} + + + + + + + + {/* Exposure */} - { - setExposure(value); - }} - /> + + + + {/* Camera Settings */} + + + FOV + + + + + + Focal Distance + + + + + + Aperture + + + + + + {/* Sampling Rate */} + + + Pixel Sampling Rate + + + + + + + {/* Lights */} {lights.map((light, index) => (
Intensity - { const updatedLights = [...lights]; updatedLights[index].mColor.setScalar(value / 255); @@ -559,10 +787,7 @@ const VolumeViewer = () => { Theta - { const updatedLights = [...lights]; updatedLights[index].mTheta = value * (Math.PI / 180); @@ -574,10 +799,7 @@ const VolumeViewer = () => { Phi - { const updatedLights = [...lights]; updatedLights[index].mPhi = value * (Math.PI / 180); @@ -589,6 +811,86 @@ const VolumeViewer = () => {
))}
+ + + + + + Top Intensity + + updateSkyLight('top', value, skyTopColor)} + /> + + + + Top Color + + updateSkyLight('top', skyTopIntensity, e.target.value.match(/[A-Za-z0-9]{2}/g).map(v => parseInt(v, 16)))} + /> + + + {/* Repeat for Mid and Bottom with appropriate state variables */} + + + + Intensity + + updateAreaLight(value, lightColor, lightTheta, lightPhi)} + /> + + + + Color + + updateAreaLight(lightIntensity, e.target.value.match(/[A-Za-z0-9]{2}/g).map(v => parseInt(v, 16)), lightTheta, lightPhi)} + /> + + + + Theta (deg) + + updateAreaLight(lightIntensity, lightColor, value, lightPhi)} + /> + + + + Phi (deg) + + updateAreaLight(lightIntensity, lightColor, lightTheta, value)} + /> + + + + + + + {/* Camera Mode */} + + {/* Controls */} @@ -605,54 +909,48 @@ const VolumeViewer = () => { Background Color - c.toString(16).padStart(2, '0')).join('')}`} onChange={(e) => updateBackgroundColor(e.target.value.match(/.{1,2}/g).map(c => parseInt(c, 16)))} /> + c.toString(16).padStart(2, '0')).join('')}`} + onChange={(e) => updateBackgroundColor(e.target.value.match(/.{1,2}/g).map(c => parseInt(c, 16)))} /> Bounding Box Color - c.toString(16).padStart(2, '0')).join('')}`} onChange={(e) => updateBoundingBoxColor(e.target.value.match(/.{1,2}/g).map(c => parseInt(c, 16)))} /> + c.toString(16).padStart(2, '0')).join('')}`} + onChange={(e) => updateBoundingBoxColor(e.target.value.match(/.{1,2}/g).map(c => parseInt(c, 16)))} /> + + {/* Gamma */} Min - updateGamma([value, gamma[1], gamma[2]])} - /> + updateGamma([value, gamma[1], gamma[2]])} /> Mid - updateGamma([gamma[0], value, gamma[2]])} - /> + updateGamma([gamma[0], value, gamma[2]])} /> Max - updateGamma([gamma[0], gamma[1], value])} - /> + updateGamma([gamma[0], gamma[1], value])} /> + + {/* Channels */} {channels.map((channel, index) => (
@@ -668,10 +966,18 @@ const VolumeViewer = () => { updateChannel(index, 'isosurface', checked)} /> + + Isovalue + + updateIsovalue(index, value)} /> + + Diffuse Color - c.toString(16).padStart(2, '0')).join('')}`} onChange={(e) => updateChannel(index, 'colorD', e.target.value.match(/.{1,2}/g).map(c => parseInt(c, 16)))} /> + updateChannel(index, 'colorD', e.target.value.match(/.{1,2}/g).map(c => parseInt(c, 16)))} /> @@ -680,85 +986,54 @@ const VolumeViewer = () => { +
))}
+ + {/* Clip Region */} X Min - updateClipRegion('xmin', value)} - /> + updateClipRegion('xmin', value)} /> X Max - updateClipRegion('xmax', value)} - /> + updateClipRegion('xmax', value)} /> Y Min - updateClipRegion('ymin', value)} - /> + updateClipRegion('ymin', value)} /> Y Max - updateClipRegion('ymax', value)} - /> + updateClipRegion('ymax', value)} /> Z Min - updateClipRegion('zmin', value)} - /> + updateClipRegion('zmin', value)} /> Z Max - updateClipRegion('zmax', value)} - /> + updateClipRegion('zmax', value)} /> + + {/* Playback */} @@ -769,18 +1044,14 @@ const VolumeViewer = () => { Frame - + Z Slice { /> + + +
+
{Object.keys(fileData).map((bodyPart) => ( {fileData[bodyPart].map((file) => ( -
handleFileSelect(bodyPart, file)} > {file} @@ -808,6 +1087,7 @@ const VolumeViewer = () => {
+
diff --git a/src/components/constants.js b/src/components/constants.js new file mode 100644 index 0000000..27e35d6 --- /dev/null +++ b/src/components/constants.js @@ -0,0 +1,128 @@ +// constants.js + +// URL search parameter keys +export const CELL_ID_QUERY = "cellId"; +export const FOV_ID_QUERY = "fovId"; +export const CELL_LINE_QUERY = "cellLine"; +export const IMAGE_NAME_QUERY = "name"; + +// View modes +export const YZ_MODE = "YZ"; +export const XZ_MODE = "XZ"; +export const XY_MODE = "XY"; +export const THREE_D_MODE = "3D"; + +// App state values +export const SEGMENTED_CELL = "segmented"; +export const FULL_FIELD_IMAGE = "full field"; + +// Channel setting keys +export const ISO_SURFACE_ENABLED = "isoSurfaceEnabled"; +export const VOLUME_ENABLED = "volumeEnabled"; + +// App State Keys +export const ALPHA_MASK_SLIDER_LEVEL = "alphaMaskSliderLevel"; +export const BRIGHTNESS_SLIDER_LEVEL = "brightnessSliderLevel"; +export const DENSITY_SLIDER_LEVEL = "densitySliderLevel"; +export const LEVELS_SLIDER = "levelsSlider"; +export const MODE = "mode"; +export const AUTO_ROTATE = "autorotate"; +export const MAX_PROJECT = "maxProject"; +export const VOLUMETRIC_RENDER = "volume"; +export const PATH_TRACE = "pathTrace"; +export const LUT_CONTROL_POINTS = "controlPoints"; +export const COLORIZE_ALPHA = "colorizeAlpha"; +export const COLORIZE_ENABLED = "colorizeEnabled"; + +// Volume viewer keys +export const ISO_VALUE = "isovalue"; +export const OPACITY = "opacity"; +export const COLOR = "color"; +export const SAVE_ISO_SURFACE = "saveIsoSurface"; + +// LUT percentiles for remapping intensity values +export const LUT_MIN_PERCENTILE = 0.5; +export const LUT_MAX_PERCENTILE = 0.983; + +// Opacity control for isosurfaces +export const ISOSURFACE_OPACITY_SLIDER_MAX = 255.0; + +// Default values for sliders +export const ALPHA_MASK_SLIDER_3D_DEFAULT = [50]; +export const ALPHA_MASK_SLIDER_2D_DEFAULT = [0]; +export const BRIGHTNESS_SLIDER_LEVEL_DEFAULT = [70]; +export const DENSITY_SLIDER_LEVEL_DEFAULT = [50]; +export const LEVELS_SLIDER_DEFAULT = [35.0, 140.0, 255.0]; + +// Channel group keys +export const OTHER_CHANNEL_KEY = "Other"; +export const SINGLE_GROUP_CHANNEL_KEY = "Channels"; + +// Special channel names +export const CELL_SEGMENTATION_CHANNEL_NAME = "SEG_Memb"; + +// Color presets for channels +export const PRESET_COLORS_1 = [ + [190, 68, 171, 255], + [189, 211, 75, 255], + [61, 155, 169, 255], + [128, 128, 128, 255], + [255, 255, 255, 255], + [239, 27, 45, 255], + [238, 77, 245, 255], + [96, 255, 255, 255] +]; + +export const PRESET_COLORS_2 = [ + [128, 0, 0, 255], + [0, 128, 0, 255], + [0, 0, 128, 255], + [32, 32, 32, 255], + [255, 255, 0, 255], + [255, 0, 255, 255], + [0, 255, 0, 255], + [0, 0, 255, 255] +]; + +export const PRESET_COLORS_3 = [ + [128, 0, 128, 255], + [128, 128, 128, 255], + [0, 128, 128, 255], + [128, 128, 0, 255], + [255, 255, 255, 255], + [255, 0, 0, 255], + [255, 0, 255, 255], + [0, 255, 255, 255] +]; + +// Map of preset color groups +export const PRESET_COLOR_MAP = Object.freeze([ + { + colors: PRESET_COLORS_1, + name: "Thumbnail colors", + key: 1, + }, + { + colors: PRESET_COLORS_2, + name: "RGB colors", + key: 2, + }, + { + colors: PRESET_COLORS_3, + name: "White structure", + key: 3, + } +]); + +// Application color scheme +export default { + primary1Color: '#0B9AAB', // bright blue + primary2Color: '#827AA3', // aics purple + primary3Color: '#d8e0e2', // light blue gray + accent1Color: '#B8D637', // aics lime green light + accent2Color: '#C1F448', // aics lime green bright + accent3Color: '#d8e0e2', // light blue gray + textColor: '#003057', // dark blue + disabledColor: '#D1D1D1', // dull gray + pickerHeaderColor: '#316773' // cool blue green +}; diff --git a/src/config.js b/src/config.js index 4a0c2f3..5dacc8e 100644 --- a/src/config.js +++ b/src/config.js @@ -1,2 +1,2 @@ // API URL -export const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api'; \ No newline at end of file +export const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000'; \ No newline at end of file From 8a6f1c320bc49df2a251162cbbd38d678870ef34 Mon Sep 17 00:00:00 2001 From: Adrian Sebuliba Date: Wed, 23 Oct 2024 12:06:47 +0200 Subject: [PATCH 07/37] Add deployment script --- .env.production | 4 + deploy.sh | 46 + package-lock.json | 47 +- package.json | 3 +- public/favicon.ico | Bin 3870 -> 4668 bytes public/index.html | 2 +- src/App.css | 1190 ++++++++++++++++- src/App.js | 39 +- src/components/DatasetCard.js | 44 + src/components/Footer.js | 13 + .../ISAS_Logo_Standard.34684188-1.svg | 237 ++++ src/components/NotFoundPage.js | 24 + src/components/VolumeViewer.js | 111 +- src/components/WelcomePage.js | 39 + src/components/volumeViewrKeep.js | 1070 +++++++++++++++ 15 files changed, 2827 insertions(+), 42 deletions(-) create mode 100644 .env.production create mode 100755 deploy.sh mode change 100644 => 100755 public/favicon.ico create mode 100755 src/components/DatasetCard.js create mode 100755 src/components/Footer.js create mode 100755 src/components/ISAS_Logo_Standard.34684188-1.svg create mode 100755 src/components/NotFoundPage.js create mode 100755 src/components/WelcomePage.js create mode 100644 src/components/volumeViewrKeep.js diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..f4667e2 --- /dev/null +++ b/.env.production @@ -0,0 +1,4 @@ +# .env.production + +REACT_APP_API_URL=https://cellmigration.isas.de/api +REACT_APP_UPLOAD_FOLDER=https://cellmigration.isas.de/uploads diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..8e0a37f --- /dev/null +++ b/deploy.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +# Exit the script if any command fails +set -e + +# Configuration variables +PROJECT_DIR="." # Frontend root remains in 3D-Cell-Viewer +FRONTEND_DIR="$PROJECT_DIR" +WEB_SERVER_ROOT="/var/www/vhosts/lsfm.isas.de" + +# Generate a new release branch name with timestamp +RELEASE_BRANCH="release-$(date +'%Y%m%d%H%M%S')" + +echo "Creating a new release branch: $RELEASE_BRANCH" + +# Checkout to the deployment branch and pull the latest changes +echo "Checking out to the deployment branch and pulling the latest changes..." +git checkout make--it-load-gracefully +git pull origin make--it-load-gracefully + +# Create a new release branch from the current branch +git checkout -b "$RELEASE_BRANCH" + +# Push the new release branch to the remote repository +# git push origin "$RELEASE_BRANCH" + +# Set environment variables for the API and file storage server +export REACT_APP_API_URL="https://cellmigration.isas.de/api" +export REACT_APP_UPLOAD_FOLDER="https://cellmigration.isas.de/uploads" + +# Proceed with the frontend deployment +echo "Starting frontend-only deployment..." + +# Building the React Application +echo "Building React app..." +cd "$FRONTEND_DIR" +npm run build + +echo "Copying the React build files to the web server root..." +sudo rm -rf "$WEB_SERVER_ROOT"/* +sudo cp -r "$FRONTEND_DIR/build/"* "$WEB_SERVER_ROOT" + +echo "Changing ownership of the web server root directory to www-data user..." +sudo chown -R www-data:www-data "$WEB_SERVER_ROOT" + +echo "Frontend deployment completed successfully." diff --git a/package-lock.json b/package-lock.json index 686752c..e89f453 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "3d-volume-app", "version": "0.1.0", "dependencies": { - "@aics/volume-viewer": "^3.9.0", + "@aics/volume-viewer": "^3.11.2", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", @@ -18,6 +18,7 @@ "react": "^18.3.1", "react-color": "^2.19.3", "react-dom": "^18.3.1", + "react-router-dom": "^6.27.0", "react-scripts": "5.0.1", "three": "^0.167.1", "web-vitals": "^2.1.4" @@ -29,9 +30,9 @@ "integrity": "sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ==" }, "node_modules/@aics/volume-viewer": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@aics/volume-viewer/-/volume-viewer-3.10.0.tgz", - "integrity": "sha512-8eaV1q5gOLpPgC9XkvWAeiYCzkad1Z9AH32C51NuiDKKPtL4SW2VolLMJaa0pgf2mhSpOiEYNih0C6lcXF7ciQ==", + "version": "3.11.2", + "resolved": "https://registry.npmjs.org/@aics/volume-viewer/-/volume-viewer-3.11.2.tgz", + "integrity": "sha512-lmYgBFirjpHVjN4Wb/ywmeUQeBqQQXpWP9D606ooXDeBsPJs+bLGfEiVgE4qj+O/3nLInLMthilcH+tcqK1JfQ==", "dependencies": { "geotiff": "^2.0.5", "serialize-error": "^11.0.3", @@ -3585,6 +3586,14 @@ "react-dom": ">=16.9.0" } }, + "node_modules/@remix-run/router": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.20.0.tgz", + "integrity": "sha512-mUnk8rPJBI9loFDZ+YzPGdeniYK+FTmRD1TMCz7ev2SNIozyKKpnGgsxO34u6Z4z/t0ITuu7voi/AshfsGsgFg==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@rollup/plugin-babel": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", @@ -16066,6 +16075,36 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.27.0.tgz", + "integrity": "sha512-YA+HGZXz4jaAkVoYBE98VQl+nVzI+cVI2Oj/06F5ZM+0u3TgedN9Y9kmMRo2mnkSK2nCpNQn0DVob4HCsY/WLw==", + "dependencies": { + "@remix-run/router": "1.20.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.27.0.tgz", + "integrity": "sha512-+bvtFWMC0DgAFrfKXKG9Fc+BcXWRUO1aJIihbB79xaeq0v5UzfvnM5houGUm1Y461WVRcgAQ+Clh5rdb1eCx4g==", + "dependencies": { + "@remix-run/router": "1.20.0", + "react-router": "6.27.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/react-scripts": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz", diff --git a/package.json b/package.json index 8c3d917..c1d5011 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { - "@aics/volume-viewer": "^3.9.0", + "@aics/volume-viewer": "^3.11.2", "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", @@ -13,6 +13,7 @@ "react": "^18.3.1", "react-color": "^2.19.3", "react-dom": "^18.3.1", + "react-router-dom": "^6.27.0", "react-scripts": "5.0.1", "three": "^0.167.1", "web-vitals": "^2.1.4" diff --git a/public/favicon.ico b/public/favicon.ico old mode 100644 new mode 100755 index a11777cc471a4344702741ab1c8a588998b1311a..85504531889c8e987ec7c0773d52b49cab532bbf GIT binary patch literal 4668 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 XRvrum-7Fis!Fcko{|%RdcL)9jXcTrO literal 3870 zcma);c{J4h9>;%nil|2-o+rCuEF-(I%-F}ijC~o(k~HKAkr0)!FCj~d>`RtpD?8b; zXOC1OD!V*IsqUwzbMF1)-gEDD=A573Z-&G7^LoAC9|WO7Xc0Cx1g^Zu0u_SjAPB3vGa^W|sj)80f#V0@M_CAZTIO(t--xg= z!sii`1giyH7EKL_+Wi0ab<)&E_0KD!3Rp2^HNB*K2@PHCs4PWSA32*-^7d{9nH2_E zmC{C*N*)(vEF1_aMamw2A{ZH5aIDqiabnFdJ|y0%aS|64E$`s2ccV~3lR!u<){eS` z#^Mx6o(iP1Ix%4dv`t@!&Za-K@mTm#vadc{0aWDV*_%EiGK7qMC_(`exc>-$Gb9~W!w_^{*pYRm~G zBN{nA;cm^w$VWg1O^^<6vY`1XCD|s_zv*g*5&V#wv&s#h$xlUilPe4U@I&UXZbL z0)%9Uj&@yd03n;!7do+bfixH^FeZ-Ema}s;DQX2gY+7g0s(9;`8GyvPY1*vxiF&|w z>!vA~GA<~JUqH}d;DfBSi^IT*#lrzXl$fNpq0_T1tA+`A$1?(gLb?e#0>UELvljtQ zK+*74m0jn&)5yk8mLBv;=@}c{t0ztT<v;Avck$S6D`Z)^c0(jiwKhQsn|LDRY&w(Fmi91I7H6S;b0XM{e zXp0~(T@k_r-!jkLwd1_Vre^v$G4|kh4}=Gi?$AaJ)3I+^m|Zyj#*?Kp@w(lQdJZf4 z#|IJW5z+S^e9@(6hW6N~{pj8|NO*>1)E=%?nNUAkmv~OY&ZV;m-%?pQ_11)hAr0oAwILrlsGawpxx4D43J&K=n+p3WLnlDsQ$b(9+4 z?mO^hmV^F8MV{4Lx>(Q=aHhQ1){0d*(e&s%G=i5rq3;t{JC zmgbn5Nkl)t@fPH$v;af26lyhH!k+#}_&aBK4baYPbZy$5aFx4}ka&qxl z$=Rh$W;U)>-=S-0=?7FH9dUAd2(q#4TCAHky!$^~;Dz^j|8_wuKc*YzfdAht@Q&ror?91Dm!N03=4=O!a)I*0q~p0g$Fm$pmr$ zb;wD;STDIi$@M%y1>p&_>%?UP($15gou_ue1u0!4(%81;qcIW8NyxFEvXpiJ|H4wz z*mFT(qVx1FKufG11hByuX%lPk4t#WZ{>8ka2efjY`~;AL6vWyQKpJun2nRiZYDij$ zP>4jQXPaP$UC$yIVgGa)jDV;F0l^n(V=HMRB5)20V7&r$jmk{UUIe zVjKroK}JAbD>B`2cwNQ&GDLx8{pg`7hbA~grk|W6LgiZ`8y`{Iq0i>t!3p2}MS6S+ zO_ruKyAElt)rdS>CtF7j{&6rP-#c=7evGMt7B6`7HG|-(WL`bDUAjyn+k$mx$CH;q2Dz4x;cPP$hW=`pFfLO)!jaCL@V2+F)So3}vg|%O*^T1j>C2lx zsURO-zIJC$^$g2byVbRIo^w>UxK}74^TqUiRR#7s_X$e)$6iYG1(PcW7un-va-S&u zHk9-6Zn&>T==A)lM^D~bk{&rFzCi35>UR!ZjQkdSiNX*-;l4z9j*7|q`TBl~Au`5& z+c)*8?#-tgUR$Zd%Q3bs96w6k7q@#tUn`5rj+r@_sAVVLqco|6O{ILX&U-&-cbVa3 zY?ngHR@%l{;`ri%H*0EhBWrGjv!LE4db?HEWb5mu*t@{kv|XwK8?npOshmzf=vZA@ zVSN9sL~!sn?r(AK)Q7Jk2(|M67Uy3I{eRy z_l&Y@A>;vjkWN5I2xvFFTLX0i+`{qz7C_@bo`ZUzDugfq4+>a3?1v%)O+YTd6@Ul7 zAfLfm=nhZ`)P~&v90$&UcF+yXm9sq!qCx3^9gzIcO|Y(js^Fj)Rvq>nQAHI92ap=P z10A4@prk+AGWCb`2)dQYFuR$|H6iDE8p}9a?#nV2}LBCoCf(Xi2@szia7#gY>b|l!-U`c}@ zLdhvQjc!BdLJvYvzzzngnw51yRYCqh4}$oRCy-z|v3Hc*d|?^Wj=l~18*E~*cR_kU z{XsxM1i{V*4GujHQ3DBpl2w4FgFR48Nma@HPgnyKoIEY-MqmMeY=I<%oG~l!f<+FN z1ZY^;10j4M4#HYXP zw5eJpA_y(>uLQ~OucgxDLuf}fVs272FaMxhn4xnDGIyLXnw>Xsd^J8XhcWIwIoQ9} z%FoSJTAGW(SRGwJwb=@pY7r$uQRK3Zd~XbxU)ts!4XsJrCycrWSI?e!IqwqIR8+Jh zlRjZ`UO1I!BtJR_2~7AbkbSm%XQqxEPkz6BTGWx8e}nQ=w7bZ|eVP4?*Tb!$(R)iC z9)&%bS*u(lXqzitAN)Oo=&Ytn>%Hzjc<5liuPi>zC_nw;Z0AE3Y$Jao_Q90R-gl~5 z_xAb2J%eArrC1CN4G$}-zVvCqF1;H;abAu6G*+PDHSYFx@Tdbfox*uEd3}BUyYY-l zTfEsOqsi#f9^FoLO;ChK<554qkri&Av~SIM*{fEYRE?vH7pTAOmu2pz3X?Wn*!ROX ztd54huAk&mFBemMooL33RV-*1f0Q3_(7hl$<#*|WF9P!;r;4_+X~k~uKEqdzZ$5Al zV63XN@)j$FN#cCD;ek1R#l zv%pGrhB~KWgoCj%GT?%{@@o(AJGt*PG#l3i>lhmb_twKH^EYvacVY-6bsCl5*^~L0 zonm@lk2UvvTKr2RS%}T>^~EYqdL1q4nD%0n&Xqr^cK^`J5W;lRRB^R-O8b&HENO||mo0xaD+S=I8RTlIfVgqN@SXDr2&-)we--K7w= zJVU8?Z+7k9dy;s;^gDkQa`0nz6N{T?(A&Iz)2!DEecLyRa&FI!id#5Z7B*O2=PsR0 zEvc|8{NS^)!d)MDX(97Xw}m&kEO@5jqRaDZ!+%`wYOI<23q|&js`&o4xvjP7D_xv@ z5hEwpsp{HezI9!~6O{~)lLR@oF7?J7i>1|5a~UuoN=q&6N}EJPV_GD`&M*v8Y`^2j zKII*d_@Fi$+i*YEW+Hbzn{iQk~yP z>7N{S4)r*!NwQ`(qcN#8SRQsNK6>{)X12nbF`*7#ecO7I)Q$uZsV+xS4E7aUn+U(K baj7?x%VD!5Cxk2YbYLNVeiXvvpMCWYo=by@ diff --git a/public/index.html b/public/index.html index aa069f2..c0bc686 100644 --- a/public/index.html +++ b/public/index.html @@ -24,7 +24,7 @@ work correctly both with client-side routing and a non-root public URL. Learn how to configure a non-root public URL by running `npm run build`. --> - React App + 3D visualization diff --git a/src/App.css b/src/App.css index 74b5e05..35dc250 100644 --- a/src/App.css +++ b/src/App.css @@ -1,38 +1,1194 @@ +:root { + --color-primary: #000000; /* Black for primary */ + --color-primary-dark: #333333; /* Dark grey as a substitute for dark primary */ + --color-secondary: #FFFFFF; /* White for secondary */ + --color-border: #e0e0e0; /* You might keep this for borders */ + --color-background: #f5f5f5; /* Light grey for backgrounds, if suitable */ + --color-tooltip: #000000; /* Black for tooltips */ + --color-tooltip-text: #FFFFFF; /* White for tooltip text */ +} .App { + display: flex; + flex-direction: column; + min-height: 100vh; /* Make sure the app fills the viewport height */ + padding-top: 60px; /* Adjusted for the fixed header */ + box-sizing: border-box; +} + +.content-wrap { + flex: 1; /* Allows this container to grow and fill available space, pushing the footer down */ + display: flex; + flex-direction: column; + margin-bottom: -60px; /* Adjust this value based on your footer's height */ +} + +/* Adjust the footer CSS */ +.app-footer { + background-color: var(--color-primary); /* Using your primary color variable */ + color: var(--color-secondary); + padding: 20px; /* Adjust the padding as needed */ text-align: center; + width: 100%; + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border-top: 1px solid var(--color-border); /* Optional border */ + margin-top: auto; } -.App-logo { - height: 40vmin; - pointer-events: none; +body { + font-family: 'Roboto', sans-serif; +} +body, html { + margin: auto; + padding: 0; + max-width: 100vw; + overflow-x: hidden; /* Prevent horizontal scroll */ + +} + +.container { + display: flex; + flex-direction: row; + align-items: flex-start; + width: 95%; /* Adjust the percentage as needed */ + margin: 40px auto 40px 20px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.main-content { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: flex-start; + position: relative; + min-width: 98%; + width: 100%; } -@media (prefers-reduced-motion: no-preference) { - .App-logo { - animation: App-logo-spin infinite 20s linear; + +.content-container { + flex: 1; /* Each container occupies 50% of the horizontal space */ + margin-right: 20px; /* Add some spacing between the containers */ + overflow: hidden; /* Prevent content from overflowing */ +} + +.rounds-container { + display: grid; + grid-template-columns: repeat(4, calc(25% - 10px)); + gap: 4px; + /* margin-bottom: 20px; */ + /* margin-left: -153px; */ + /* margin-top: -97px; */ + z-index: 50; + position: relative; + top: 3px; + flex-grow: 1; +} + + .grid-with-indicators { + display: grid; + padding-top: 21px; + position: relative; + width: auto; + gap: 5px; + } + + .grid-with-indicators .horizontal-indicators { + grid-column: 2; + grid-row: 1; + margin-left: -2px; + } + + .grid-with-indicators .vertical-indicators { + grid-column: 1; + grid-row: 2; + } + + .grid-with-indicators .grid-container { + grid-column: 2; + grid-row: 2; + } + + .grid-with-indicators:hover { + transform: scale(1.5); /* Adjust the scale value as needed */ + z-index: 100; /* Ensure the zoomed element is above others */ + transition: transform 0.3s ease; /* Smooth zoom effect */ + /* position: absolute; Use absolute positioning */ + } + + .grid-container { + display: grid; + grid-template-columns: repeat(8, 15px); /* Keeps the 15px width for each cell */ + grid-template-rows: repeat(8, 15px); /* Ensures rows are also defined for clarity */ + border: 1px solid #e0e0e0; /* Maintains the border */ + background-color: #f5f5f5; /* Keeps the background color */ + border-radius: 5px; /* Maintains the border-radius */ + width: 120px; /* Adjusted width: 8 cells * 15px each */ + height: 120px; /* Adjusted height: 8 cells * 15px each */ + box-shadow: 0 1px 3px rgba(0,0,0,0.2); /* Keeps the shadow */ + } + + .grid-cell { + background-color: #adacaa; + border: .25px inherit; + border-color: #9e9c9d; + box-sizing: border-box; + cursor: pointer; + height: 15px; + position: relative; + transition: box-shadow .3s ease,transform .3s ease; + width: 15px + } + + .tooltip { + visibility: hidden; + position: absolute; + background-color: var(--color-tooltip); + color: var(--color-tooltip-text); + padding: 8px 10px; + border-radius: 5px; + border: 1px solid #fff; + z-index: 1010 !important; + font-size: 0.7em; + width: 130px; + text-align: left; + box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.2); + transition: visibility 0.2s ease, opacity 0.2s ease; + opacity: 0; /* Start with an invisible tooltip */ + pointer-events: none; /* Prevents the tooltip from blocking mouse events */ +} + + + .grid-cell:hover .tooltip { + visibility: visible; + opacity: 1; /* Make the tooltip visible on hover */ + transition-delay: 0.5s; /* Add a delay to the transition */ + } + + .grid-cell:hover { + box-shadow: 0 2px 6px rgba(0,0,0,0.3); /* More pronounced shadow on hover */ + transform: scale(1.05); + z-index: 1; + } + +.grid-cell.selected { + position: relative; /* This ensures the pseudo-element is positioned relative to this cell */ + overflow: hidden; /* Prevents the pseudo-element from spilling outside the cell */ } -.App-header { - background-color: #282c34; - min-height: 100vh; +.grid-cell.selected::after { + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + background-image: repeating-linear-gradient( + -45deg, + rgba(0, 0, 0, 0.2), + rgba(0, 0, 0, 0.2) 10px, + transparent 10px, + transparent 20px /* The size of the stripes */ + ); + z-index: 1; +} + + .tiff-player img { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + /* object-fit: cover; This ensures the image covers the available space, similar to your video setup */ + } + + .player-controls { + position: absolute; + bottom: 0; + left: 0; + right: 0; + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px; + background-color: rgba(0, 0, 0, 0.5); /* Semi-transparent background for visibility */ + } + + .player-controls button { + cursor: pointer; + padding: 5px 10px; + background-color: var(--color-primary); /* Use primary color for button background */ + color: var(--color-secondary); /* Use secondary color for text */ + border: none; + border-radius: 5px; + outline: none; + transition: background-color 0.3s ease; + } + + .player-controls button:hover { + background-color: var(--color-primary-dark); /* Darker shade for hover state */ + } + + .progress-bar { + flex-grow: 1; + height: 20px; + background-color: #e9e9e9; + border-radius: 10px; + margin-left: 10px; + position: relative; + overflow: hidden; + background-color: var(--color-border); + box-shadow: inset 0 1px 3px rgba(0,0,0,0.2); + } + + .progress { + height: 100%; + border-radius: 10px; + background-color: var(--color-primary); /* Use primary color for progress bar */ + transition: width 0.3s ease; + } + + .scrubber { + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 12px; + height: 12px; + background-color: var(--color-secondary); /* Use secondary color for scrubber */ + border: 2px solid var(--color-primary); /* Border color from primary */ + border-radius: 50%; + cursor: pointer; + z-index: 2; + box-shadow: 0 2px 4px rgba(0,0,0,0.4); + } + + + .tiff-player { + position: relative; + width: 100%; + padding-top: 56.25%; /* Adjust this value to match the aspect ratio of your TIFF images */ + top: 76px; + flex-grow: 2; /* This ensures that the player takes up the space it needs, similar to your mp4 player setup */ + } + + .overlay { + position: absolute; /* Positions .overlay in relation to .tiff-player */ + top: 0; + left: 0; + right: 0; + bottom: 0; /* These four properties ensure .overlay matches the size of .tiff-player */ + display: flex; + justify-content: center; + align-items: center; + background: rgba(0, 0, 0, 0.1); + z-index: 1; /* Ensures .overlay stacks above the img */ + cursor: pointer; + pointer-events: none; + } + + .loading-bar { + width: 100%; + height: 4px; + background-color: #e0e0e0; + position: fixed; + top: 0; + left: 0; + z-index: 10; + } + + .loading-progress { + height: 100%; + width: 0; + background-color: #007bff; + transition: width 0.3s ease; + } + + .grid-cell.selected { + border: 2px solid #007bff; + } + + .round-header { + position: absolute; + top: 0; + left: 57%; + transform: translateX(-50%); + background-color: var(--color-secondary); /* light orange background */ + font-weight: bold; + z-index: 1; + padding: 2px 10px; + border-radius: 2px; /* rounding the corners */ + box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.2); /* optional shadow for a lifted effect */ +} + + button { + margin: 10px; + padding: 10px 20px; + display: inline-block; + text-align: center; + vertical-align: middle; + cursor: pointer; + background-color: #007bff; + color: #fff; + border: none; + border-radius: 5px; + transition: background-color 0.3s; + outline: none; + font-size: 0.9em; + line-height: 1; + width: 120px; + height: 40px; + background-color: var(--color-primary); + color: var(--color-tooltip-text); + box-shadow: 0 2px 4px rgba(0,0,0,0.2); /* Adds subtle shadow for depth */ + } + + button:hover { + background-color: var(--color-primary-dark); + } + + button:disabled { + background-color: #e0e0e0; + cursor: not-allowed; + } + + .grid-cell:not(.selected):hover { + box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); + transform: scale(1.05); + z-index: 1; + } + + .grid-container:hover { + border-color: #007bff; + } + + .horizontal-indicators, + .vertical-indicators { + display: flex; + justify-content: space-between; + align-items: center; + background-color: #f9f9f9; + padding: 5px; + font-size: 0.7em; /* Smaller font size */ + } + + .horizontal-indicators { + position: relative; + z-index: 1; + width: 100%; + height: auto; + flex-direction: row; + padding: 5px 5px 5px 0; + } + + .vertical-indicators { + height: 100%; + width: 0px; + flex-direction: column; + padding: 0px; + } + + .indicator-item { + text-align: center; + font-size: 0.8em; + font-weight: bold; + margin: 0 5px; + } + + + .rounds-header-container { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 20px; /* Adding some spacing between the round headers and the rest of the content */ + } + + .rounds-header-group { + display: flex; + justify-content: space-between; /* Spread the round headers evenly */ + width: 100%; /* Take the full width of the parent container */ + background-color: #FFA500; /* Orange background */ + padding: 5px; /* Add some padding around the group */ + margin: 5px 0; /* Add some margin between the groups */ + border-radius: 5px; /* Add rounded corners */ + box-shadow: 0px 2px 15px rgba(0, 0, 0, 0.1); /* Add a subtle shadow for depth */ + } + +.color-legend { + display: flex; + flex-direction: column; /* Align legend items vertically */ + padding-right: 10px; /* Space between legend and histogram */ + padding-top: 49px; +} + +.gradient-box { + height: 200px; /* Match the height of your histogram */ + width: 20px; /* Width of the color bar */ + margin-bottom: 10px; /* Space between the gradient and labels */ +} + +.legend-marks { + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.legend-mark { + position: absolute; + left: -1px; + transform: translate(0, -1468%); + font-size: 0.8em; +} + + .text-center{ + text-align: center; + } + + .side-panel { + background-color: #fff; /* White background */ + margin-top: -62px; + border-radius: 4px; /* Rounded corners */ + box-shadow: 0 4px 6px rgba(0,0,0,0.1); /* Shadow for depth */ + display: flex; + flex-direction: row; + overflow: hidden; + width: 90%; + } + + .side-panel .panel-header { + font-size: 1.25rem; + background-color: var(--color-primary); /* Black background */ + color: var(--color-secondary); /* White text */ + text-align: center; + font-weight: 500; + padding: 8px 0; /* Padding for the header */ + border-bottom: 1px solid var(--color-border); + width: 100%; + } + + .side-panel .panel-content { + padding: 16px; + flex-grow: 1; /* Allows this element to fill up the remaining space */ + margin-left: -32px; + overflow-y: auto; /* Allows scrolling if content is too long */ + } + + .side-panel .panel-item { + padding: 12px 16px; + border-bottom: 1px solid var(--color-border); + transition: background-color 0.2s; + cursor: pointer; + } + + .side-panel .panel-item:hover { + background-color: var(--color-background); + } + + .histogram { + margin-top: 20px; /* Space above the histogram */ + } + + .histogram-bar { + display: flex; + align-items: center; + margin-bottom: 5px; + } + + .histogram-bar-fill { + height: 20px; + background-color: var(--color-primary); /* Black bars for the histogram */ + margin-right: 10px; + transition: width 3s ease-in-out; + } + + .histogram-bar-label { + font-size: 0.8em; + color: var(--color-primary-dark); /* Dark grey text */ + } + + + /* For screens wider than 1920px */ +@media (min-width: 1920px) { + .container { + width: 90%; /* You can go up to 100% if you want to use all the horizontal space */ + max-width: none; /* This will allow the container to adjust to the percentage width */ + } + + .main-content { + flex: 0 0 100%; /* Adjust the flex-basis as needed */ + } + +} + +/* For screens wider than 3840px */ +@media (min-width: 3840px) { + .container { + width: 80%; /* Less percentage as the screen is very wide */ + } + +} + + +.navbar { + background-color: black; /* As per your color scheme */ + color: white; + padding: 10px 20px; + display: flex; + align-items: center; + justify-content: space-between; /* This spreads out the logo and title */ +} + +.navbar h1 { + margin-top: 0; /* Removes any top margin from the h1 for accurate centering */ + flex-grow: 1; /* Allows the h1 to take up the remaining space */ + text-align: center; /* Centers the title in the available space */ +} + +.logo { + height: 50px; + margin-right: 10px; /* Adds a little space between logo and title */ +} + + +.NotFound { + display: flex; + align-items: center; + justify-content: center; + position: relative; + height: 100vh; + color: white; + background-color: black; + text-align: center; + overflow: hidden; +} + +.solar-system { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 50%; + transform: translateX(-50%); + z-index: 1; +} + +.sun { + position: absolute; + top: 50%; + left: 50%; + width: 100px; + height: 100px; + background-color: yellow; + border-radius: 50%; + transform: translate(-50%, -50%); + box-shadow: 0 0 100px yellow; /* Glowing effect */ +} + +.orbit { + position: absolute; + top: 50%; + left: 50%; + width: 300px; + height: 300px; + border: 1px dashed white; + border-radius: 50%; + transform: translate(-50%, -50%); +} + +.planet { + position: absolute; + top: 0; + left: 0; + width: 20px; + height: 20px; + background-color: blue; + border-radius: 50%; + animation: orbitAnimation 10s linear infinite; +} + +@keyframes orbitAnimation { + from { transform: rotate(0deg) translateX(150px); } + to { transform: rotate(360deg) translateX(150px); } +} + +.NotFound-content { + position: relative; + z-index: 2; +} + +.NotFound-title { + font-size: 5rem; + margin: 0; +} + +.NotFound-message { + font-size: 1.5rem; +} + +.NotFound-link { + display: inline-block; + margin-top: 20px; + padding: 10px 20px; + background-color: #ff4500; + color: white; + border: none; + border-radius: 4px; + text-decoration: none; + transition: background-color 0.3s; +} + +.NotFound-link:hover { + background-color: #ff5733; +} + + +.histogram-tooltip { + position: absolute; + visibility: hidden; + background-color: var(--color-tooltip); + color: var(--color-tooltip-text); + text-align: center; + border-radius: 4px; + padding: 5px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + z-index: 2; + white-space: nowrap; + pointer-events: none; + transition: visibility 0.2s ease, opacity 0.2s ease; + opacity: 0; + top: -35px; /* Adjust the position above the hovered element */ + left: 50%; + transform: translateX(-50%); /* Center the tooltip */ +} + +.histogram-bar:hover .histogram-tooltip { + visibility: visible; + opacity: 1; +} + + +.histogram-bar { + position: relative; /* To position the tooltip */ +} + +.zoom-controls { + margin-top: 20px; + display: flex; + flex-direction: column; + align-items: center; +} + +.zoom-controls button { + margin-bottom: 10px; +} + + +.tooltip.top-left { + bottom: 100%; + left: 0; + transform: translateY(-10px); /* Adjust as needed */ +} + +.tooltip.top-right { + bottom: 100%; + right: 0; + transform: translateY(-10px); /* Adjust as needed */ +} + +.tiff-player-placeholder, .loading-overlay { + position: relative; /* Needed for absolute positioning of the overlay */ display: flex; flex-direction: column; align-items: center; justify-content: center; - font-size: calc(10px + 2vmin); + height: 684px; + width: 100%; + background-color: #f5f5f5; + border: 1px solid #d3d3d3; + border-radius: 5px; + margin-top: 50px; + /* margin-left: -104px; */ + color: #333; + text-align: center; + padding: 20px; +} + +.loading-overlay { + /* position: absolute; */ + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + justify-content: center; + align-items: center; + background-color: var(--color-background); /* Semi-transparent white background */ + z-index: 10; /* Ensures it's above other elements */ +} + +.loading-circle { + border: 5px solid #f3f3f3; /* Light grey border */ + border-top: 5px solid #3498db; /* Blue border */ + border-radius: 50%; + width: 50px; + height: 50px; + animation: spin 2s linear infinite; +} + +.tiff-player-placeholder p { + margin-top: 15px; + font-size: 1.1em; /* Slightly larger font size */ + color: #333; /* Darker text for better readability */ +} + +/* Define a specific size for the chart container */ +.histogram-chart-container { + width: 100%; /* Use the full width of the side panel */ + height: 300px; /* Set a fixed height for the chart */ +} + + +.histogram-and-legend-container { + display: flex; + align-items: stretch; /* Align items vertically in the center */ + flex-direction: column; +} + +.histogram-and-legend-container-contents { + display: flex; + align-items: stretch; /* Align items vertically in the center */ +} + +.loading-circle { + border: 5px solid #f3f3f3; /* Light grey border */ + border-top: 5px solid #3498db; /* Blue border */ + border-radius: 50%; + width: 60px; /* Same size as FaPlay icon */ + height: 60px; /* Same size as FaPlay icon */ + animation: spin 2s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + + + +.jumbotron { + background-color: #f9f9f9; /* Light grey background */ + padding: 20px; + margin-top: 43px; /* Small top margin to create spacing */ + border-radius: 5px; /* Rounded corners */ + box-shadow: 0 2px 4px rgba(0,0,0,0.2); /* Shadow for depth */ + text-align: center; /* Center the text */ + height: 150px; /* Fixed height to prevent resizing */ + overflow: hidden; /* Hide overflowing content */ +} + +.jumbotron h2 { + margin-top: 0; + color: var(--color-primary); /* Black color for the heading */ +} + +.jumbotron p { + color: var(--color-primary-dark); /* Dark grey for the paragraph */ +} + + + +button { + display: inline-flex; /* Use flex to align items horizontally */ + align-items: center; /* Center items vertically within the button */ + justify-content: center; /* Center items horizontally within the button */ + margin: 10px; + padding: 10px 15px; /* Adjust padding to give more space */ + text-align: center; + vertical-align: middle; + cursor: pointer; + background-color: #007bff; + color: #fff; + border: none; + border-radius: 5px; + transition: background-color 0.3s; + outline: none; + font-size: 0.9em; + line-height: 1; + min-width: 122px; /* Set a minimum width to accommodate text and icon */ + height: 46px; + background-color: var(--color-primary); + color: var(--color-tooltip-text); + box-shadow: 0 2px 4px rgba(0,0,0,0.2); +} + +button:hover { + background-color: var(--color-primary-dark); +} + +button:disabled { + background-color: #e0e0e0; + cursor: not-allowed; +} + +/* Adjust icon size and margin */ +button svg { + width: 20px; /* Adjust the icon size */ + height: 20px; /* Adjust the icon size */ + margin-right: 5px; /* Add some space between icon and text */ +} + +.selected-tiff-tags { + display: flex; + flex-wrap: wrap; + margin-top: 10px; +} + +.tiff-tag { + background-color: #007bff; color: white; + padding: 5px 10px; + margin-right: 5px; + margin-bottom: 5px; + border-radius: 15px; + font-size: 0.85em; + white-space: nowrap; +} + +.color-legend { + display: flex; + flex-direction: column; + align-items: center; +} + +.gradient-box { + width: 30px; /* Adjust as needed */ + height: 200px; /* Adjust as needed */ + margin: 10px 0; +} + +.legend-labels .legend-label { + position: absolute; + left: 35px; /* Adjust as needed */ + /* Other styling as needed */ +} + +.mp4-player { + position: relative; + width: 100%; + padding-top: 50.25%; + top: 75px; + flex-grow: 2; + min-width: 320px; /* Base min-width for small devices */ } -.App-link { - color: #61dafb; +.mp4-player video { + position: absolute; + top: -25px; + left: 0; + width: 100%; + max-height: 95%; + object-fit: cover; } -@keyframes App-logo-spin { - from { - transform: rotate(0deg); +/* Adjust for tablets */ +@media (max-width: 768px) { + .container { + width: 100%; /* Use more of the screen width on smaller devices */ + margin: 20px auto; /* Adjust margin to fit smaller screens */ } - to { - transform: rotate(360deg); +} + +/* Adjust for larger screens */ +@media (min-width: 1024px) { + .container { + width: 90%; /* Adjust width as needed */ + margin: 40px auto; /* Center with appropriate margin */ + } +} + +.polar-plot-display { + background-color: var(--color-secondary); /* White background */ + margin: 20px auto; /* Center the widget and add vertical space */ + padding: 15px; /* Padding inside the container */ + border-radius: 8px; /* Rounded corners */ + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); /* Subtle shadow for depth */ + max-width: 95%; /* Limit the maximum width */ + text-align: center; /* Center align the content */ +} + +.polar-plot-display img { + max-width: 100%; /* Ensure the image is responsive */ + height: auto; /* Maintain aspect ratio */ + border-radius: 5px; /* Slightly rounded corners for the image */ + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); /* Soft shadow around the image */ +} + +.polar-plot-display h2 { + margin-bottom: 15px; /* Space between title and image */ + color: var(--color-primary); /* Black color for the text */ + font-size: 1.5rem; /* Larger font size for the title */ +} +.polar-plot-container { + text-align: center; + padding: 10px; + margin-top: 20px; + transition: transform 0.3s ease-in-out; + display: flex; + flex-direction: column; /* This will stack children vertically */ + align-items: center; /* Center align items for good measure */ +} + +.polar-plot-image { + max-width: 95%; + height: auto; + border-radius: 10px; /* Rounded corners for the image */ + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); /* Shadow for depth */ + transition: all 0.3s ease-in-out; +} + +.polar-plot-container:hover .polar-plot-image { + transform: scale(1.05); /* Slightly enlarge the image on hover */ + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3); /* More pronounced shadow on hover */ +} + +/* Animation for the loading of the polar plot */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.polar-plot-image { + animation: fadeIn 1s; /* Apply the animation to the image */ +} + +.typewriter-effect { + white-space: pre-wrap; /* Allows for line breaks in your text */ + font-family: monospace; /* Gives the typewriter effect more authenticity */ +} + +.cursor { + display: inline-block; + width: 2px; + height: 1em; + background-color: currentColor; + margin-left: 2px; + animation: blink 1s infinite; +} + +@keyframes blink { + 0%, 50% { opacity: 1; } + 50.01%, 100% { opacity: 0; } +} + +.typewriter-link { + color: #007bff; + text-decoration: none; +} + +.typewriter-link:hover, +.typewriter-link:focus { + text-decoration: underline; +} + +.selected-tiff-tags { + display: flex; + flex-wrap: wrap; + align-items: center; /* Ensure tags are aligned in the middle */ + gap: 5px; /* Add some space between the tags */ + margin-top: 10px; +} + +.tiff-tag { + background-color: #007bff; + color: white; + padding: 2px 5px; /* Reduce padding for a more compact look */ + border-radius: 10px; /* Rounded corners for a chip-like appearance */ + font-size: 0.75em; /* Smaller font size for the tag */ + display: flex; + align-items: center; /* Center the content vertically */ + justify-content: center; /* Center the content horizontally */ + gap: 5px; /* Space between text and 'X' button */ + white-space: nowrap; /* Prevent wrapping inside the tag */ +} + +.tiff-tag .tiff-tag-remove { + display: inline-block; + width: 12px; /* Fixed width */ + height: 12px; /* Fixed height */ + line-height: 12px; /* Aligns the 'X' vertically */ + text-align: center; + background-color: transparent; + color: white; + border-radius: 50%; /* Circular button */ + padding: 0; + font-size: 12px; /* Adjust based on your design */ + cursor: pointer; +} + +.tiff-tag .tiff-tag-remove:hover { + background-color: rgba(255, 255, 255, 0.2); /* Slight highlight on hover */ +} + +@media (min-width: 768px) { + .mp4-player { + min-width: 596px; /* Adjust for tablets and small desktops */ + } +} + + +@media (min-width: 1024px) { + .mp4-player { + min-width: 470px; /* Adjust for larger desktops */ + } + + body, html { + + overflow-x: initial; + } + +} + +.histogram-description p { + font-size: 0.9em; + color: var(--color-primary-dark); /* Assuming this is a darker shade for text */ + padding: 10px; + background-color: var(--color-background); + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + margin-top: 20px; +} + + +.app-footer p { + margin: 10px 0; + font-size: 1rem; /* Adjust the font size as needed */ + line-height: 1.5; /* Improves readability */ + max-width: 80%; /* Limits the width of the text to improve readability */ + text-align: justify; /* Justifies the text for a neater appearance */ +} + +.app-footer a { + color: var(--color-highlight); /* Highlight color for links, assuming you define this */ + text-decoration: underline; +} + +.app-footer a:hover { + color: var(--color-secondary); /* Color change on hover for visual feedback */ +} + +/* Additional styles for responsiveness */ +@media (max-width: 768px) { + .app-footer p { + font-size: 0.9rem; /* Slightly smaller font size for smaller screens */ + max-width: 95%; /* Increase width percentage for smaller screens */ + } +} + +.download-instructions ol { + margin-left: 20px; +} + +.download-button:enabled { + background-color: #4CAF50; /* Green */ +} + +.download-button:disabled { + background-color: #ccc; + color: #666; + cursor: not-allowed; +} + +.selected-tiff-tags { + display: flex; + flex-wrap: wrap; + margin-top: 10px; +} + +.tiff-tag { + margin: 5px; + background-color: #007bff; + color: white; + padding: 5px; + border-radius: 5px; + cursor: pointer; +} + +.tiff-tag:hover { + opacity: 0.7; +} + +/* New class for horizontal side panel layout */ +.side-panel-horizontal { + display: flex; /* Use flexbox to layout items horizontally */ + flex-wrap: wrap; /* Allow items to wrap if needed */ + justify-content: space-around; /* Space out items evenly */ + padding: 10px; + margin-top: 20px; /* Add some space above the side panel */ + margin-left: -13px; +} + +/* Adjustments for panel items to display in a row */ +.side-panel-horizontal .panel-item { + flex: 1 1 25%; /* This makes each item take up a quarter of the horizontal space */ + max-width: calc(25% - 20px); /* Adjusts for margin */ + box-sizing: border-box; /* Includes padding and border in the element's total width and height */ + margin: 10px; /* Add some space around items */ + display: flex; /* Use flex to manage content inside each panel item */ + align-items: center; /* Centers items vertically inside the panel item */ +} + +/* Directly style .panel-content for horizontal layout */ +.side-panel-horizontal .panel-content { + display: flex; + flex-wrap: wrap; + justify-content: space-between; /* Adjust spacing to use available space */ + width: 100%; /* Ensure it takes full width of its parent */ +} + +.histogram-and-legend-container-contents { + flex-grow: 1; + width: 100%; +} + +.download-section { + flex-direction: column; + flex-wrap: wrap; +} + +.round-well-info { + width: 100%; +} + +.polar-plot-container { + width: 100%; +} +.round-well-info .well{ + padding: auto; + +} + +.deleter { + width: 100%; +} + +.polar-plot-description p { + font-size: 0.9em; + color: var(--color-primary-dark); /* Assuming this is a darker shade for text */ + padding: 10px; + background-color: var(--color-background); + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + margin-top: 20px; +} + + +/* Adjust for larger screens */ +@media (min-width: 1024px) { + .side-panel-horizontal { + width: 100% } } diff --git a/src/App.js b/src/App.js index d6fb43f..9100bea 100644 --- a/src/App.js +++ b/src/App.js @@ -1,11 +1,46 @@ -import logo from './logo.svg'; +import logo from './components/ISAS_Logo_Standard.34684188-1.svg'; import './App.css'; +import { BrowserRouter as Router, Route, Routes, Link } from 'react-router-dom'; +import WelcomePage from './components/WelcomePage'; +import NotFoundPage from './components/NotFoundPage'; +import Footer from './components/Footer'; + import VolumeViewer from './components/VolumeViewer'; function App() { + const headerStyle = { + backgroundColor: 'black', + color: 'white', + padding: '10px', + textAlign: 'center', + position: 'fixed', + top: 0, + left: 0, + right: 0, + zIndex: 1000 + }; + return (
- + +
+ +
+ {/* Wrapping the Routes and Footer within a flex container */} +
+ + } /> + } /> + } /> + +
+
+
); } diff --git a/src/components/DatasetCard.js b/src/components/DatasetCard.js new file mode 100755 index 0000000..d191bb1 --- /dev/null +++ b/src/components/DatasetCard.js @@ -0,0 +1,44 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; + +function DatasetCard({ title, description, datasetId }) { + const navigate = useNavigate(); + + const cardStyle = { + display: 'flex', + flexDirection: 'column', + margin: '10px', + padding: '20px', + border: '1px solid black', + borderRadius: '5px', + backgroundColor: 'white', + boxShadow: '0 4px 8px rgba(0, 0, 0, 0.1)', // Shadow for depth + transition: 'transform 0.2s' // Smooth transition for hover effect + }; + + const buttonStyle = { + marginTop: '10px', + backgroundColor: 'black', + color: 'white', + border: 'none', + padding: '10px 20px', + borderRadius: '4px', + cursor: 'pointer', + textAlign: 'center' + }; + + const handleLoadClick = () => { + navigate(`/dataset/${datasetId}`); + }; + + return ( +
e.currentTarget.style.transform = 'scale(1.05)'} onMouseOut={e => e.currentTarget.style.transform = 'none'}> +
{title}
+

+
{description}
+ +
+ ); +} + +export default DatasetCard; diff --git a/src/components/Footer.js b/src/components/Footer.js new file mode 100755 index 0000000..59d25d9 --- /dev/null +++ b/src/components/Footer.js @@ -0,0 +1,13 @@ +import React from 'react'; + +const Footer = () => { + return ( +
+

We thank all authors of the publication ComplexEye: a multi-lens array microscope for high-throughput embedded immune cell migration analysis for their contribution to this research. This work was supported by a grant from the Mercator foundation (Anschubförderung An-2014-0050) as well as intramural funds from the University Duisburg Essen (UDE) and the medical faculty of the UDE to M.G., A.G. and R.V.

+

Further support of the work came from the German Research Foundation (DFG) to M.G. (CRC TRR332 TP C6, KFO 337 “PhenoTime” TP7 (also to I.H.), FOR-2879 “Immunostroke” project 405358801 and GU769/10-1). Additional funding was provided by the Bundesministerium für Bildung und Forschung (BMBF) and by the Ministerium für Kultur und Wissenschaft des Landes Nordrhein-Westfalen (MKW).

+

We thank Jürgen Becker and Kathrin Blank for blood drawing and support during this study. We acknowledge support by the Open Access Publication Fund of the University of Duisburg-Essen. All members of the laboratories are acknowledged for intense discussions.

+
+ ); +}; + +export default Footer; diff --git a/src/components/ISAS_Logo_Standard.34684188-1.svg b/src/components/ISAS_Logo_Standard.34684188-1.svg new file mode 100755 index 0000000..d51e69a --- /dev/null +++ b/src/components/ISAS_Logo_Standard.34684188-1.svg @@ -0,0 +1,237 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/NotFoundPage.js b/src/components/NotFoundPage.js new file mode 100755 index 0000000..1e93b47 --- /dev/null +++ b/src/components/NotFoundPage.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import '../App.css'; // Assuming you have a dedicated CSS file for this component + +function NotFoundPage() { + return ( +
+
+
+
+
+
+ {/* Repeat for more planets/orbits */} +
+
+

404

+

Lost in the cosmic void...

+ Navigate Back Home +
+
+ ); +} + +export default NotFoundPage; \ No newline at end of file diff --git a/src/components/VolumeViewer.js b/src/components/VolumeViewer.js index 5a34f53..e07f032 100644 --- a/src/components/VolumeViewer.js +++ b/src/components/VolumeViewer.js @@ -115,6 +115,7 @@ const VolumeViewer = () => { }; const onVolumeCreated = (volume) => { + initializeChannelOptions(volume); // volume.channelColorsDefault = volume.imageInfo.channelNames.map(() => DEFAULT_CHANNEL_COLOR); @@ -137,6 +138,7 @@ const VolumeViewer = () => { view3D.setRayStepSizes(volume, primaryRay, secondaryRay); view3D.updateExposure(exposure); view3D.updateCamera(fov, focalDistance, aperture); + view3D.updateActiveChannels(volume) // view3D.updatePixelSamplingRate(samplingRate); view3D.redraw(); }; @@ -262,8 +264,11 @@ const VolumeViewer = () => { }, [isPT, view3D]); useEffect(() => { - view3D.updateLights(lights); - view3D.redraw(); + if (currentVolume) { + view3D.updateLights(lights); + view3D.updateActiveChannels(currentVolume); + view3D.redraw(); + } }, [lights, view3D]); useEffect(() => { @@ -336,6 +341,7 @@ const VolumeViewer = () => { useEffect(() => { if (currentVolume) { view3D.updateCamera(fov, focalDistance, aperture); + view3D.updateActiveChannels(currentVolume); view3D.redraw(); } }, [fov, focalDistance, aperture]); @@ -344,6 +350,7 @@ const VolumeViewer = () => { useEffect(() => { if (currentVolume) { view3D.setRayStepSizes(currentVolume, primaryRay, secondaryRay); + view3D.updateActiveChannels(currentVolume); view3D.redraw(); } }, [primaryRay, secondaryRay]); @@ -351,6 +358,7 @@ const VolumeViewer = () => { useEffect(() => { if (currentVolume) { view3D.updateMaskAlpha(currentVolume, maskAlpha); + view3D.updateActiveChannels(currentVolume); view3D.redraw(); } }, [maskAlpha]) @@ -374,7 +382,8 @@ const VolumeViewer = () => { (skyBotColor[2] / 255.0) * skyBotIntensity ); view3D.updateLights(lights); - // view3D.redraw(); + view3D.updateActiveChannels(currentVolume); + view3D.redraw(); console.log([skyTopColor, skyTopIntensity, skyMidColor, skyMidIntensity, skyBotColor, skyBotIntensity]); } @@ -392,7 +401,8 @@ const VolumeViewer = () => { areaLight.mTheta = (lightTheta * Math.PI) / 180.0; areaLight.mPhi = (lightPhi * Math.PI) / 180.0; view3D.updateLights(lights); - // view3D.redraw(); + view3D.updateActiveChannels(currentVolume); + view3D.redraw(); } console.log([lightColor, lightIntensity, lightTheta, lightPhi]); }, [lightColor, lightIntensity, lightTheta, lightPhi]); @@ -460,6 +470,59 @@ const VolumeViewer = () => { } view3D.redraw(); } + } + + + const updateChannelOptions = (index, options) => { + const updatedChannels = [...channels]; + updatedChannels[index] = { ...updatedChannels[index], ...options }; + setChannels(updatedChannels); + + if (view3D) { + view3D.setVolumeChannelOptions(index, options); + if (options.isosurfaceEnabled !== undefined) { + if (options.isosurfaceEnabled) { + const channel = updatedChannels[index]; + view3D.createIsosurface( + index, + channel.color, + channel.isovalue, + channel.isosurfaceOpacity, + channel.isosurfaceOpacity < 0.95 + ); + } else { + view3D.clearIsosurface(index); + } + } + if (options.isovalue !== undefined || options.isosurfaceOpacity !== undefined) { + const channel = updatedChannels[index]; + view3D.updateIsosurface(index, channel.isovalue); + view3D.updateChannelMaterial( + index, + channel.color, + channel.specularColor, + channel.emissiveColor, + channel.glossiness + ); + view3D.updateOpacity(index, channel.isosurfaceOpacity); + } + view3D.redraw(); + } + }; + + const initializeChannelOptions = (volume) => { + const channelOptions = volume.imageInfo.channelNames.map((name, index) => ({ + name, + enabled: index < 3, + color: volume.channelColorsDefault[index] || [128, 128, 128], + specularColor: [0, 0, 0], + emissiveColor: [0, 0, 0], + glossiness: 0, + isosurfaceEnabled: false, + isovalue: 127, + isosurfaceOpacity: 1.0 + })); + setChannels(channelOptions); }; const updateIsovalue = (index, isovalue) => { @@ -960,19 +1023,33 @@ const VolumeViewer = () => { updateChannel(index, 'enabled', checked)} /> - - Isosurface - - updateChannel(index, 'isosurface', checked)} /> - - - - Isovalue - - updateIsovalue(index, value)} /> - - + {channel.isosurfaceEnabled && ( + <> + + Isovalue + + updateChannelOptions(index, { isovalue: value })} + /> + + + + Isosurface Opacity + + updateChannelOptions(index, { isosurfaceOpacity: value })} + /> + + + + )} Diffuse Color diff --git a/src/components/WelcomePage.js b/src/components/WelcomePage.js new file mode 100755 index 0000000..e6a1f82 --- /dev/null +++ b/src/components/WelcomePage.js @@ -0,0 +1,39 @@ +import React from 'react'; +import DatasetCard from './DatasetCard'; + +function WelcomePage() { + + const gridContainerStyle = { + display: 'grid', + gridTemplateColumns: 'repeat(2, 1fr)', // 2x2 grid layout + gap: '20px', + marginTop: '60px' // Adjust according to navbar height + }; + + return ( +
+ +
+

Welcome to the ISAS Platform for 3D visualization

+

This platform provides a comprehensive exploration and download functionality for various data on migrating cells, developed in collaboration with the Leibniz-Institut für Analytische Wissenschaften.

+

With a repository of over 1000 videos comprising more than 400,000 individual images, our platform provides a reliable source for detailed analysis. Intuitive widgets will allow users to selectively engage with different datasets, each presenting specific biological questions.

+

So far, we have provided the dataset "ComplexEye". Dive deep into the data by clicking on the corresponding widget.

+

More datasets will follow.

+
+ +
+

Load a dataset to get started

+
+ {/* Example DatasetCard usage */} + + + + + {/* Additional DatasetCards can be added here as more datasets become available */} +
+
+
+ ); +} + +export default WelcomePage; diff --git a/src/components/volumeViewrKeep.js b/src/components/volumeViewrKeep.js new file mode 100644 index 0000000..1816402 --- /dev/null +++ b/src/components/volumeViewrKeep.js @@ -0,0 +1,1070 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + LoadSpec, + View3d, + VolumeFileFormat, + RENDERMODE_PATHTRACE, + RENDERMODE_RAYMARCH, + VolumeMaker, + Light, + AREA_LIGHT, + SKY_LIGHT, + Lut +} from "@aics/volume-viewer"; +import * as THREE from 'three'; +import { loaderContext, PREFETCH_DISTANCE, MAX_PREFETCH_CHUNKS, myState } from "./appConfig"; +import { useConstructor } from './useConstructor'; +import { Slider, Switch, InputNumber, Row, Col, Collapse, Layout, Button, Select, Input, Tooltip, Spin, Menu, Tabs, Card} from 'antd'; +import axios from 'axios'; +import { API_URL } from '../config'; // Importing API_URL from your config + +// Utility function to concatenate arrays +const concatenateArrays = (arrays) => { + const totalLength = arrays.reduce((acc, arr) => acc + arr.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + for (const arr of arrays) { + result.set(arr, offset); + offset += arr.length; + } + return result; +} + +const { Header, Sider, Content, Footer } = Layout; +const { Panel } = Collapse; +const { Option } = Select; +const { Vector3 } = THREE; +const { TabPane } = Tabs; + +const VolumeViewer = () => { + const viewerRef = useRef(null); + const view3D = useConstructor(() => new View3d({ parentElement: viewerRef.current })); + const loadContext = useConstructor(() => loaderContext); + + const [loader, setLoader] = useState(null); + const [fileData, setFileData] = useState({}); + const [selectedBodyPart, setSelectedBodyPart] = useState(null); + const [selectedFile, setSelectedFile] = useState(null); + const [currentVolume, setCurrentVolume] = useState(null); + const [density, setDensity] = useState(myState.density); + const [exposure, setExposure] = useState(myState.exposure); + const [lights, setLights] = useState([ + new Light(SKY_LIGHT), + new Light(AREA_LIGHT) + ]); + const [isPT, setIsPT] = useState(myState.isPT); + const [channels, setChannels] = useState([]); + const [cameraMode, setCameraMode] = useState('3D'); + const [isTurntable, setIsTurntable] = useState(false); + const [showAxis, setShowAxis] = useState(false); + const [showBoundingBox, setShowBoundingBox] = useState(false); + const [showScaleBar, setShowScaleBar] = useState(true); + const [backgroundColor, setBackgroundColor] = useState(myState.backgroundColor); + const [boundingBoxColor, setBoundingBoxColor] = useState(myState.boundingBoxColor); + const [flipX, setFlipX] = useState(1); + const [flipY, setFlipY] = useState(1); + const [flipZ, setFlipZ] = useState(1); + const [gamma, setGamma] = useState([0, 0.5, 1]); + const [clipRegion, setClipRegion] = useState({ + xmin: myState.xmin, + xmax: myState.xmax, + ymin: myState.ymin, + ymax: myState.ymax, + zmin: myState.zmin, + zmax: myState.zmax + }); + const [isPlaying, setIsPlaying] = useState(false); + const [currentFrame, setCurrentFrame] = useState(0); + const [totalFrames, setTotalFrames] = useState(0); + const [timerId, setTimerId] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const [maskAlpha, setMaskAlpha] = useState(myState.maskAlpha); + const [primaryRay, setPrimaryRay] = useState(myState.primaryRay); + const [secondaryRay, setSecondaryRay] = useState(myState.secondaryRay); + const [fov, setFov] = useState(myState.fov); + const [focalDistance, setFocalDistance] = useState(myState.focal_distance); + const [aperture, setAperture] = useState(myState.aperture); + const [samplingRate, setSamplingRate] = useState(myState.samplingRate); + + + + const [skyTopIntensity, setSkyTopIntensity] = useState(myState.skyTopIntensity); + const [skyMidIntensity, setSkyMidIntensity] = useState(myState.skyMidIntensity); + const [skyBotIntensity, setSkyBotIntensity] = useState(myState.skyBotIntensity); + const [skyTopColor, setSkyTopColor] = useState(myState.skyTopColor); + const [skyMidColor, setSkyMidColor] = useState(myState.skyMidColor); + const [skyBotColor, setSkyBotColor] = useState(myState.skyBotColor); + const [lightColor, setLightColor] = useState(myState.lightColor); + const [lightIntensity, setLightIntensity] = useState(myState.lightIntensity); + const [lightTheta, setLightTheta] = useState(myState.lightTheta); + const [lightPhi, setLightPhi] = useState(myState.lightPhi); + + const densitySliderToView3D = (density) => density / 50.0; + + const onChannelDataArrived = (v, channelIndex) => { + view3D.onVolumeData(v, [channelIndex]); + if (channels[channelIndex]) { + view3D.setVolumeChannelEnabled(v, channelIndex, channels[channelIndex].enabled); + } + view3D.updateActiveChannels(v); + view3D.updateLuts(v); + if (v.isLoaded()) { + console.log("Volume " + v.name + " is loaded"); + } + view3D.redraw(); + }; + + const onVolumeCreated = (volume) => { + initializeChannelOptions(volume); + + // volume.channelColorsDefault = volume.imageInfo.channelNames.map(() => DEFAULT_CHANNEL_COLOR); + + setCurrentVolume(volume); + view3D.removeAllVolumes(); + view3D.addVolume(volume); + + + // Log the channel colors to verify the change + console.log("Channel Default Colors:", volume.channelColors); + + + setInitialRenderMode(); + showChannelUI(volume); + view3D.updateActiveChannels(volume); + view3D.updateLuts(volume); + view3D.updateLights(lights); + view3D.updateDensity(volume, densitySliderToView3D(density)); + view3D.updateMaskAlpha(volume, maskAlpha); + view3D.setRayStepSizes(volume, primaryRay, secondaryRay); + view3D.updateExposure(exposure); + view3D.updateCamera(fov, focalDistance, aperture); + // view3D.updatePixelSamplingRate(samplingRate); + view3D.redraw(); + }; + + const loadVolume = async (loadSpec, loader) => { + const volume = await loader.createVolume(loadSpec, onChannelDataArrived); + onVolumeCreated(volume); + + console.log(volume.imageInfo, volume.imageInfo.times) + // Set total frames based on the volume's metadata (assuming 'times' represents the number of frames) + setTotalFrames(volume.imageInfo.times || 1); + await loader.loadVolumeData(volume); + }; + + const loadVolumeFromServer = async (url) => { + setIsLoading(true); + try { + const loadSpec = new LoadSpec(); + const fileExtension = url.split('.').pop(); + const volumeFileType = (fileExtension === 'tiff' || fileExtension === 'tif' || fileExtension === 'ome.tiff' || fileExtension === 'ome.tif') ? VolumeFileFormat.TIFF : VolumeFileFormat.ZARR; + const loader = await loadContext.createLoader(url, { + fileType: volumeFileType, + fetchOptions: { maxPrefetchDistance: PREFETCH_DISTANCE, maxPrefetchChunks: MAX_PREFETCH_CHUNKS }, + }); + + setLoader(loader); + await loadVolume(loadSpec, loader); + } catch (error) { + console.error('Error loading volume:', error); + } finally { + setIsLoading(false); + } + }; + + const createTestVolume = () => { + const sizeX = 64; + const sizeY = 64; + const sizeZ = 64; + const imgData = { + name: "AICS-10_5_5", + sizeX, + sizeY, + sizeZ, + sizeC: 3, + physicalPixelSize: [1, 1, 1], + spatialUnit: "", + channelNames: ["DRAQ5", "EGFP", "SEG_Memb"], + }; + + const channelVolumes = [ + VolumeMaker.createSphere(sizeX, sizeY, sizeZ, 24), + VolumeMaker.createTorus(sizeX, sizeY, sizeZ, 24, 8), + VolumeMaker.createCone(sizeX, sizeY, sizeZ, 24, 24), + ]; + + const alldata = concatenateArrays(channelVolumes); + return { + metadata: imgData, + data: { + dtype: "uint8", + shape: [channelVolumes.length, sizeZ, sizeY, sizeX], + buffer: new DataView(alldata.buffer), + }, + }; + }; + + const fetchFiles = async () => { + try { + const response = await axios.get(`${API_URL}/files`); + setFileData(response.data); + } catch (error) { + console.error('Error fetching files:', error); + } + }; + + const handleFileSelect = async (bodyPart, file) => { + setSelectedBodyPart(bodyPart); + setSelectedFile(file); + await loadVolumeFromServer(`${API_URL}/${bodyPart}/${file}`); + }; + + useEffect(() => { + fetchFiles(); + }, []); + + useEffect(() => { + if (viewerRef.current) { + const container = viewerRef.current; + container.appendChild(view3D.getDOMElement()); + + const handleResize = () => view3D.resize(); + window.addEventListener("resize", handleResize); + + view3D.resize(); + + return () => { + window.removeEventListener("resize", handleResize); + if (view3D.getDOMElement().parentNode) { + view3D.getDOMElement().parentNode.removeChild(view3D.getDOMElement()); + } + view3D.removeAllVolumes(); + }; + } + }, [viewerRef, view3D]); + + useEffect(() => { + if (currentVolume) { + view3D.updateDensity(currentVolume, densitySliderToView3D(density)); + view3D.redraw(); + } + }, [density]); + + useEffect(() => { + if (currentVolume) { + view3D.updateExposure(exposure); + view3D.redraw(); + } + }, [currentVolume, exposure, view3D]); + + useEffect(() => { + view3D.setVolumeRenderMode(isPT ? RENDERMODE_PATHTRACE : RENDERMODE_RAYMARCH); + view3D.redraw(); + }, [isPT, view3D]); + + useEffect(() => { + view3D.updateLights(lights); + view3D.redraw(); + }, [lights, view3D]); + + useEffect(() => { + if (currentVolume) { + view3D.updateActiveChannels(currentVolume); + view3D.updateLuts(currentVolume); + view3D.redraw(); + } + }, [channels]); + + useEffect(() => { + view3D.setCameraMode(cameraMode); + }, [cameraMode]); + + useEffect(() => { + view3D.setAutoRotate(isTurntable); + }, [isTurntable, view3D]); + + useEffect(() => { + view3D.setShowAxis(showAxis); + }, [showAxis, view3D]); + + useEffect(() => { + if (currentVolume) { + view3D.setShowBoundingBox(currentVolume, showBoundingBox); + } + }, [currentVolume, showBoundingBox, view3D]); + + useEffect(() => { + view3D.setShowScaleBar(showScaleBar); + }, [showScaleBar, view3D]); + + useEffect(() => { + view3D.setBackgroundColor(backgroundColor); + }, [backgroundColor, view3D]); + + useEffect(() => { + if (currentVolume) { + view3D.setBoundingBoxColor(currentVolume, boundingBoxColor); + } + }, [boundingBoxColor]); + + useEffect(() => { + if (currentVolume) { + view3D.setFlipVolume(currentVolume, flipX, flipY, flipZ); + } + }, [flipX, flipY, flipZ]); + + useEffect(() => { + if (currentVolume) { + const gammaValues = gammaSliderToImageValues(gamma); + view3D.setGamma(currentVolume, gammaValues[0], gammaValues[1], gammaValues[2]); + } + }, [gamma]); + + useEffect(() => { + if (currentVolume) { + view3D.updateClipRegion( + currentVolume, + clipRegion.xmin, + clipRegion.xmax, + clipRegion.ymin, + clipRegion.ymax, + clipRegion.zmin, + clipRegion.zmax + ); + } + }, [clipRegion]); + + useEffect(() => { + if (currentVolume) { + view3D.updateCamera(fov, focalDistance, aperture); + view3D.redraw(); + } + }, [fov, focalDistance, aperture]); + + + useEffect(() => { + if (currentVolume) { + view3D.setRayStepSizes(currentVolume, primaryRay, secondaryRay); + view3D.redraw(); + } + }, [primaryRay, secondaryRay]); + + useEffect(() => { + if (currentVolume) { + view3D.updateMaskAlpha(currentVolume, maskAlpha); + view3D.redraw(); + } + }, [maskAlpha]) + + useEffect(() => { + if (view3D && lights[0]) { + const skyLight = lights[0]; + skyLight.mColorTop = new Vector3( + (skyTopColor[0] / 255.0) * skyTopIntensity, + (skyTopColor[1] / 255.0) * skyTopIntensity, + (skyTopColor[2] / 255.0) * skyTopIntensity + ); + skyLight.mColorMiddle = new Vector3( + (skyMidColor[0] / 255.0) * skyMidIntensity, + (skyMidColor[1] / 255.0) * skyMidIntensity, + (skyMidColor[2] / 255.0) * skyMidIntensity + ); + skyLight.mColorBottom = new Vector3( + (skyBotColor[0] / 255.0) * skyBotIntensity, + (skyBotColor[1] / 255.0) * skyBotIntensity, + (skyBotColor[2] / 255.0) * skyBotIntensity + ); + view3D.updateLights(lights); + // view3D.redraw(); + console.log([skyTopColor, skyTopIntensity, skyMidColor, skyMidIntensity, skyBotColor, skyBotIntensity]); + } + + }, [skyTopColor, skyTopIntensity, skyMidColor, skyMidIntensity, skyBotColor, skyBotIntensity]); + + // useEffect for area light + useEffect(() => { + if (view3D && lights[1]) { + const areaLight = lights[1]; + areaLight.mColor = new Vector3( + (lightColor[0] / 255.0) * lightIntensity, + (lightColor[1] / 255.0) * lightIntensity, + (lightColor[2] / 255.0) * lightIntensity + ); + areaLight.mTheta = (lightTheta * Math.PI) / 180.0; + areaLight.mPhi = (lightPhi * Math.PI) / 180.0; + view3D.updateLights(lights); + // view3D.redraw(); + } + console.log([lightColor, lightIntensity, lightTheta, lightPhi]); + }, [lightColor, lightIntensity, lightTheta, lightPhi]); + + const setInitialRenderMode = () => { + view3D.setVolumeRenderMode(isPT ? RENDERMODE_PATHTRACE : RENDERMODE_RAYMARCH); + view3D.setMaxProjectMode(currentVolume, false); + }; + + const DEFAULT_CHANNEL_COLOR = [128, 128, 128]; // Medium gray + + const showChannelUI = (volume) => { + const channelGui = volume.imageInfo.channelNames.map((name, index) => ({ + name, + enabled: index < 3, + colorD: volume.channelColorsDefault[index] || DEFAULT_CHANNEL_COLOR, + colorS: [0, 0, 0], + colorE: [0, 0, 0], + glossiness: 0, + window: 1, + level: 0.5, + isovalue: 128, + isosurface: false + })); + setChannels(channelGui); + + // Log channel colors for verification + channelGui.forEach((channel, index) => { + console.log(`Channel ${index} (${channel.name}) color:`, channel.colorD); + }); + }; + + const updateChannel = (index, key, value) => { + const updatedChannels = [...channels]; + updatedChannels[index][key] = value; + setChannels(updatedChannels); + + if (currentVolume) { + if (key === 'enabled') { + view3D.setVolumeChannelEnabled(currentVolume, index, value); + } else if (key === 'isosurface') { + view3D.setVolumeChannelOptions(currentVolume, index, { isosurfaceEnabled: value }); + if (value) { + view3D.createIsosurface(currentVolume, index, updatedChannels[index].isovalue, 1.0); + } else { + view3D.clearIsosurface(currentVolume, index); + } + } else if (['colorD', 'colorS', 'colorE', 'glossiness'].includes(key)) { + view3D.updateChannelMaterial( + currentVolume, + index, + updatedChannels[index].colorD, + updatedChannels[index].colorS, + updatedChannels[index].colorE, + updatedChannels[index].glossiness + ); + view3D.updateMaterial(currentVolume); + } else if (key === 'window' || key === 'level') { + const lut = new Lut().createFromWindowLevel( + updatedChannels[index].window, + updatedChannels[index].level + ); + currentVolume.setLut(index, lut); + view3D.updateLuts(currentVolume); + } + view3D.redraw(); + } + } + + + const updateChannelOptions = (index, options) => { + const updatedChannels = [...channels]; + updatedChannels[index] = { ...updatedChannels[index], ...options }; + setChannels(updatedChannels); + + if (view3D) { + view3D.setVolumeChannelOptions(index, options); + if (options.isosurfaceEnabled !== undefined) { + if (options.isosurfaceEnabled) { + const channel = updatedChannels[index]; + view3D.createIsosurface( + index, + channel.color, + channel.isovalue, + channel.isosurfaceOpacity, + channel.isosurfaceOpacity < 0.95 + ); + } else { + view3D.clearIsosurface(index); + } + } + if (options.isovalue !== undefined || options.isosurfaceOpacity !== undefined) { + const channel = updatedChannels[index]; + view3D.updateIsosurface(index, channel.isovalue); + view3D.updateChannelMaterial( + index, + channel.color, + channel.specularColor, + channel.emissiveColor, + channel.glossiness + ); + view3D.updateOpacity(index, channel.isosurfaceOpacity); + } + view3D.redraw(); + } + }; + + const initializeChannelOptions = (volume) => { + const channelOptions = volume.imageInfo.channelNames.map((name, index) => ({ + name, + enabled: index < 3, + color: volume.channelColorsDefault[index] || [128, 128, 128], + specularColor: [0, 0, 0], + emissiveColor: [0, 0, 0], + glossiness: 0, + isosurfaceEnabled: false, + isovalue: 127, + isosurfaceOpacity: 1.0 + })); + setChannels(channelOptions); + }; + + const updateIsovalue = (index, isovalue) => { + if (currentVolume) { + view3D.updateIsosurface(currentVolume, index, isovalue); + view3D.redraw(); + } + }; + + // Histogram-based LUT adjustments + const updateChannelLut = (index, type) => { + if (currentVolume) { + let lut; + if (type === 'autoIJ') { + const [hmin, hmax] = currentVolume.getHistogram(index).findAutoIJBins(); + lut = new Lut().createFromMinMax(hmin, hmax); + } else if (type === 'auto0') { + const [b, e] = currentVolume.getHistogram(index).findAutoMinMax(); + lut = new Lut().createFromMinMax(b, e); + } else if (type === 'bestFit') { + const [hmin, hmax] = currentVolume.getHistogram(index).findBestFitBins(); + lut = new Lut().createFromMinMax(hmin, hmax); + } else if (type === 'pct50_98') { + const hmin = currentVolume.getHistogram(index).findBinOfPercentile(0.5); + const hmax = currentVolume.getHistogram(index).findBinOfPercentile(0.983); + lut = new Lut().createFromMinMax(hmin, hmax); + } + + currentVolume.setLut(index, lut); + view3D.updateLuts(currentVolume); + view3D.redraw(); + } + }; + + const setCameraModeHandler = (mode) => { + setCameraMode(mode); + }; + + const toggleTurntable = () => { + setIsTurntable(!isTurntable); + }; + + const toggleAxis = () => { + setShowAxis(!showAxis); + }; + + const toggleBoundingBox = () => { + setShowBoundingBox(!showBoundingBox); + }; + + const toggleScaleBar = () => { + setShowScaleBar(!showScaleBar); + }; + + const updateBackgroundColor = (color) => { + setBackgroundColor(color); + }; + + const updateBoundingBoxColor = (color) => { + setBoundingBoxColor(color); + }; + + const flipVolume = (axis) => { + if (axis === 'X') { + setFlipX(flipX * -1); + } else if (axis === 'Y') { + setFlipY(flipY * -1); + } else if (axis === 'Z') { + setFlipZ(flipZ * -1); + } + }; + + const gammaSliderToImageValues = (sliderValues) => { + let min = Number(sliderValues[0]); + let mid = Number(sliderValues[1]); + let max = Number(sliderValues[2]); + if (mid > max || mid < min) { + mid = 0.5 * (min + max); + } + const div = 255; + min /= div; + max /= div; + mid /= div; + const diff = max - min; + const x = (mid - min) / diff; + let scale = 4 * x * x; + if ((mid - 0.5) * (mid - 0.5) < 0.0005) { + scale = 1.0; + } + return [min, max, scale]; + }; + + const updateGamma = (newGamma) => { + setGamma(newGamma); + }; + + const captureScreenshot = () => { + view3D.capture((dataUrl) => { + const anchor = document.createElement("a"); + anchor.href = dataUrl; + anchor.download = "screenshot.png"; + anchor.click(); + }); + }; + + const updateClipRegion = (key, value) => { + const updatedClipRegion = { ...clipRegion, [key]: value }; + setClipRegion(updatedClipRegion); + }; + + const goToFrame = (frame) => { + if (frame >= 0 && frame < totalFrames) { + view3D.setTime(currentVolume, frame); + setCurrentFrame(frame); + } + }; + + const goToZSlice = (slice) => { + if (currentVolume && view3D.setZSlice(currentVolume, slice)) { + // Z slice updated successfully + const zSlider = document.getElementById("zSlider"); + const zInput = document.getElementById("zValue"); + + if (zInput) { + zInput.value = slice; + } + if (zSlider) { + zSlider.value = slice; + } + } else { + console.log('Failed to update Z slice'); + } + }; + + const playTimeSeries = () => { + if (timerId) { + clearInterval(timerId); + } + setIsPlaying(true); + const newTimerId = setInterval(() => { + setCurrentFrame((prevFrame) => { + const nextFrame = (prevFrame + 1) % totalFrames; + view3D.setTime(currentVolume, nextFrame); + return nextFrame; + }); + }, 80); + setTimerId(newTimerId); + }; + + const pauseTimeSeries = () => { + if (timerId) { + clearInterval(timerId); + setTimerId(null); + } + setIsPlaying(false); + }; + + const rgbToHex = (r, g, b) => { + const toHex = (component) => { + const hex = Math.round(component).toString(16); + return hex.length === 1 ? '0' + hex : hex; // Ensures two digits + }; + + // Ensure r, g, b are valid numbers and fall back to 0 if undefined or invalid + r = isNaN(r) ? 0 : r; + g = isNaN(g) ? 0 : g; + b = isNaN(b) ? 0 : b; + + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; + }; + + const updatePixelSamplingRate = (rate) => { + setSamplingRate(rate); + view3D.updatePixelSamplingRate(rate); + view3D.redraw(); + }; + + + const updateSkyLight = (position, intensity, color) => { + if (position === 'top') { + setSkyTopIntensity(intensity); + setSkyTopColor(color); + } else if (position === 'mid') { + setSkyMidIntensity(intensity); + setSkyMidColor(color); + } else if (position === 'bot') { + setSkyBotIntensity(intensity); + setSkyBotColor(color); + } + updateLights(); + }; + + const updateAreaLight = (intensity, color, theta, phi) => { + setLightIntensity(intensity); + setLightColor(color); + setLightTheta(theta); + setLightPhi(phi); + updateLights(); + }; + + const updateLights = () => { + const updatedLights = [...lights]; + // Update sky light + updatedLights[0].mColorTop = new Vector3( + (skyTopColor[0] / 255.0) * skyTopIntensity, + (skyTopColor[1] / 255.0) * skyTopIntensity, + (skyTopColor[2] / 255.0) * skyTopIntensity + ); + updatedLights[0].mColorMiddle = new Vector3( + (skyMidColor[0] / 255.0) * skyMidIntensity, + (skyMidColor[1] / 255.0) * skyMidIntensity, + (skyMidColor[2] / 255.0) * skyMidIntensity + ); + updatedLights[0].mColorBottom = new Vector3( + (skyBotColor[0] / 255.0) * skyBotIntensity, + (skyBotColor[1] / 255.0) * skyBotIntensity, + (skyBotColor[2] / 255.0) * skyBotIntensity + ); + + // Update area light + updatedLights[1].mColor = new Vector3( + (lightColor[0] / 255.0) * lightIntensity, + (lightColor[1] / 255.0) * lightIntensity, + (lightColor[2] / 255.0) * lightIntensity + ); + updatedLights[1].mTheta = (lightTheta * Math.PI) / 180.0; + updatedLights[1].mPhi = (lightPhi * Math.PI) / 180.0; + + setLights(updatedLights); + view3D.updateLights(updatedLights); + view3D.redraw(); + }; + + return ( + +
+ + Home + About + Help + +
+ + + + {Object.keys(fileData).map((bodyPart) => ( + + {fileData[bodyPart].map((file) => ( +
handleFileSelect(bodyPart, file)} + > + {file} +
+ ))} +
+ ))} +
+
+ + +
+
+ +
+
+
+ + + +
+
+ setIsPT(checked)} /> Path Trace +
+
+ Density: +
+
+ Mask Alpha: +
+
+ Primary Ray: +
+
+ Secondary Ray: +
+
+ Exposure: +
+
+
+ +
+
+ FOV: +
+
+ Focal Distance: +
+
+ Aperture: +
+
+ Pixel Sampling Rate: +
+
+ Camera Mode: + +
+
+
+ + + +
+
+ Top Intensity: + updateSkyLight('top', value, skyTopColor)} + /> +
+
+ Top Color: + updateSkyLight('top', skyTopIntensity, e.target.value.match(/[A-Za-z0-9]{2}/g).map(v => parseInt(v, 16)))} + /> +
+
+ Mid Intensity: + updateSkyLight('mid', value, skyMidColor)} + /> +
+
+ Mid Color: + updateSkyLight('mid', skyMidIntensity, e.target.value.match(/[A-Za-z0-9]{2}/g).map(v => parseInt(v, 16)))} + /> +
+
+
+ +
+
+ Intensity: + updateAreaLight(value, lightColor, lightTheta, lightPhi)} + /> +
+
+ Color: + updateAreaLight(lightIntensity, e.target.value.match(/[A-Za-z0-9]{2}/g).map(v => parseInt(v, 16)), lightTheta, lightPhi)} + /> +
+
+ Theta (deg): + updateAreaLight(lightIntensity, lightColor, value, lightPhi)} + /> +
+
+ Phi (deg): + updateAreaLight(lightIntensity, lightColor, lightTheta, value)} + /> +
+
+
+
+
+ +
+ + + + + + + +
+ Background Color: + c.toString(16).padStart(2, '0')).join('')}`} + onChange={(e) => updateBackgroundColor(e.target.value.match(/.{1,2}/g).map(c => parseInt(c, 16)))} /> +
+
+ Bounding Box Color: + c.toString(16).padStart(2, '0')).join('')}`} + onChange={(e) => updateBoundingBoxColor(e.target.value.match(/.{1,2}/g).map(c => parseInt(c, 16)))} /> +
+
+
+ +
+ {channels.map((channel, index) => ( +
+
+ updateChannel(index, 'enabled', checked)} /> Enable +
+ {channel.isosurfaceEnabled && ( + <> +
+ Isovalue: + updateChannelOptions(index, { isovalue: value })} + /> +
+
+ Isosurface Opacity: + updateChannelOptions(index, { isosurfaceOpacity: value })} + /> +
+ + )} +
+ Diffuse Color: + updateChannel(index, 'colorD', e.target.value.match(/.{1,2}/g).map(c => parseInt(c, 16)))} /> +
+
+ + + + +
+
+ ))} +
+
+ +
+
+ X Min: + updateClipRegion('xmin', value)} /> +
+
+ X Max: + updateClipRegion('xmax', value)} /> +
+
+ Y Min: + updateClipRegion('ymin', value)} /> +
+
+ Y Max: + updateClipRegion('ymax', value)} /> +
+
+ Z Min: + updateClipRegion('zmin', value)} /> +
+
+ Z Max: + updateClipRegion('zmax', value)} /> +
+
+
+ +
+
+ + + + +
+
+ Frame: +
+
+ Z Slice: +
+
+ +
+
+
+ +
+
+ Min: updateGamma([value, gamma[1], gamma[2]])} /> +
+
+ Mid: updateGamma([gamma[0], value, gamma[2]])} /> +
+
+ Max: updateGamma([gamma[0], gamma[1], value])} /> +
+
+
+
+
+
+
+
+
+
+ ); +} + +export default VolumeViewer; \ No newline at end of file From 171b7682757385c179dd907d5942131b34b52bed Mon Sep 17 00:00:00 2001 From: "adrian.sebuliba" Date: Thu, 24 Oct 2024 19:23:37 +0300 Subject: [PATCH 08/37] Change texts accordingly --- src/components/Footer.js | 6 +++--- src/components/WelcomePage.js | 22 +++++++++++++++------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/components/Footer.js b/src/components/Footer.js index 59d25d9..cf18ea6 100755 --- a/src/components/Footer.js +++ b/src/components/Footer.js @@ -3,9 +3,9 @@ import React from 'react'; const Footer = () => { return (
-

We thank all authors of the publication ComplexEye: a multi-lens array microscope for high-throughput embedded immune cell migration analysis for their contribution to this research. This work was supported by a grant from the Mercator foundation (Anschubförderung An-2014-0050) as well as intramural funds from the University Duisburg Essen (UDE) and the medical faculty of the UDE to M.G., A.G. and R.V.

-

Further support of the work came from the German Research Foundation (DFG) to M.G. (CRC TRR332 TP C6, KFO 337 “PhenoTime” TP7 (also to I.H.), FOR-2879 “Immunostroke” project 405358801 and GU769/10-1). Additional funding was provided by the Bundesministerium für Bildung und Forschung (BMBF) and by the Ministerium für Kultur und Wissenschaft des Landes Nordrhein-Westfalen (MKW).

-

We thank Jürgen Becker and Kathrin Blank for blood drawing and support during this study. We acknowledge support by the Open Access Publication Fund of the University of Duisburg-Essen. All members of the laboratories are acknowledged for intense discussions.

+

We extend our gratitude to all contributors involved in this research, particularly Prof. Axel Mosig from Ruhr University Bochum and Prof. Dirk M. Hermann from University Hospital Essen, for their collaboration on this work. The ISAS team received substantial support from the Ministry of Culture and Science of the State of North Rhine-Westphalia (MKW NRW). Further funding for the AMBIOM group was provided by the Federal Ministry of Education and Research (BMBF) in Germany under the funding reference 161L0272.

+

For data-related inquiries, please reach out to Dr. Jianxu Chen at jianxu.chen@isas.de. The dataset is available on Zenodo under the Creative Commons Attribution 4.0 International license.

+

If you use the data from this portal, please cite the following paper: Spangenberg, Philippa, et al. "Rapid and fully automated blood vasculature analysis in 3D light-sheet image volumes of different organs." Cell Reports Methods 3.3 (2023).

); }; diff --git a/src/components/WelcomePage.js b/src/components/WelcomePage.js index e6a1f82..77b69d0 100755 --- a/src/components/WelcomePage.js +++ b/src/components/WelcomePage.js @@ -12,20 +12,28 @@ function WelcomePage() { return (
-
-

Welcome to the ISAS Platform for 3D visualization

-

This platform provides a comprehensive exploration and download functionality for various data on migrating cells, developed in collaboration with the Leibniz-Institut für Analytische Wissenschaften.

-

With a repository of over 1000 videos comprising more than 400,000 individual images, our platform provides a reliable source for detailed analysis. Intuitive widgets will allow users to selectively engage with different datasets, each presenting specific biological questions.

-

So far, we have provided the dataset "ComplexEye". Dive deep into the data by clicking on the corresponding widget.

-

More datasets will follow.

+

Welcome to the ISAS LSFM Data Portal

+

+ Lightsheet fluorescence microscopy (LSFM) is a cutting-edge technique offering high optical resolutions and superior sectioning capabilities, + ideal for whole-tissue, whole-organ, and potentially even whole-body imaging at cellular resolution. +

+

+ This portal hosts LSFM datasets collected at **ISAS** and in collaboration with research partners such as + **Ruhr University Bochum** and **University Hospital Essen**, shared with permission for scientific research purposes. +

+

+ The portal offers an interactive platform for biomedical researchers to explore these large biomedical image datasets. It is + equally beneficial for computational scientists and AI researchers interested in developing or applying LSFM analysis methods. + We aim to make these datasets accessible to foster deeper exploration and innovative discoveries. +

Load a dataset to get started

{/* Example DatasetCard usage */} - + From 9984d2bf0daa1fdd50d491d237379dd478444674 Mon Sep 17 00:00:00 2001 From: Adrian Sebuliba Date: Wed, 23 Oct 2024 12:12:03 +0200 Subject: [PATCH 09/37] Add deployment script --- deploy.sh | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/deploy.sh b/deploy.sh index 8e0a37f..3f5e57f 100755 --- a/deploy.sh +++ b/deploy.sh @@ -21,7 +21,7 @@ git pull origin make--it-load-gracefully # Create a new release branch from the current branch git checkout -b "$RELEASE_BRANCH" -# Push the new release branch to the remote repository +# Optionally, push the new release branch to the remote repository (uncomment if needed) # git push origin "$RELEASE_BRANCH" # Set environment variables for the API and file storage server @@ -36,10 +36,21 @@ echo "Building React app..." cd "$FRONTEND_DIR" npm run build +# Check if the web server root directory exists, if not, create it +if [ ! -d "$WEB_SERVER_ROOT" ]; then + echo "Directory $WEB_SERVER_ROOT does not exist. Creating it now..." + sudo mkdir -p "$WEB_SERVER_ROOT" + sudo chown -R www-data:www-data "$WEB_SERVER_ROOT" + sudo chmod -R 755 "$WEB_SERVER_ROOT" + echo "Directory $WEB_SERVER_ROOT created successfully." +fi + +# Deploy the built React app echo "Copying the React build files to the web server root..." sudo rm -rf "$WEB_SERVER_ROOT"/* sudo cp -r "$FRONTEND_DIR/build/"* "$WEB_SERVER_ROOT" +# Set correct ownership and permissions echo "Changing ownership of the web server root directory to www-data user..." sudo chown -R www-data:www-data "$WEB_SERVER_ROOT" From 748d003a17a4feac935a3af346665a967bea95af Mon Sep 17 00:00:00 2001 From: Adrian Sebuliba Date: Fri, 25 Oct 2024 07:23:12 +0200 Subject: [PATCH 10/37] Change title and logo --- public/logo192.png | Bin 5347 -> 12877 bytes public/logo512.png | Bin 9664 -> 53306 bytes src/App.js | 2 +- 3 files changed, 1 insertion(+), 1 deletion(-) diff --git a/public/logo192.png b/public/logo192.png index fc44b0a3796c0e0a64c3d858ca038bd4570465d9..443b4b5215a8fa1bd2bd267d1c9817efa2f41991 100644 GIT binary patch literal 12877 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 B=(Yd= literal 5347 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 X@eDJUQo;Ye2mwlRs?|yqrDPIbKj6feL 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 RW+DPUYD#w$E9A`s{s+XkKH~rY literal 9664 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 b{|{}U^kRn2wo}j7|0ATu<;8xA7zX}7|B6mN diff --git a/src/App.js b/src/App.js index 9100bea..42d7453 100644 --- a/src/App.js +++ b/src/App.js @@ -28,7 +28,7 @@ function App() { ISAS Logo -

3D visualization

+

ISAS LSFM

{/* Wrapping the Routes and Footer within a flex container */} From 366a3ccf7e25d852cacb7cb80a89d1dd0db57ef7 Mon Sep 17 00:00:00 2001 From: Adrian Sebuliba Date: Fri, 25 Oct 2024 07:30:12 +0200 Subject: [PATCH 11/37] Refine deployment script --- deploy.sh | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/deploy.sh b/deploy.sh index 3f5e57f..5dff694 100755 --- a/deploy.sh +++ b/deploy.sh @@ -13,10 +13,20 @@ RELEASE_BRANCH="release-$(date +'%Y%m%d%H%M%S')" echo "Creating a new release branch: $RELEASE_BRANCH" -# Checkout to the deployment branch and pull the latest changes -echo "Checking out to the deployment branch and pulling the latest changes..." -git checkout make--it-load-gracefully -git pull origin make--it-load-gracefully +# Get the current branch name +CURRENT_BRANCH=$(git branch --show-current) + +# Checkout to the 'ft-create-deployment-script' branch if not already on it +if [ "$CURRENT_BRANCH" != "ft-create-deployment-script" ]; then + echo "Switching to 'ft-create-deployment-script' branch..." + git checkout ft-create-deployment-script +else + echo "Already on 'ft-create-deployment-script' branch." +fi + +# Pull the latest changes from the remote repository +echo "Pulling the latest changes from 'ft-create-deployment-script' branch..." +git pull origin ft-create-deployment-script # Create a new release branch from the current branch git checkout -b "$RELEASE_BRANCH" @@ -31,9 +41,13 @@ export REACT_APP_UPLOAD_FOLDER="https://cellmigration.isas.de/uploads" # Proceed with the frontend deployment echo "Starting frontend-only deployment..." +# Move to the frontend directory and install dependencies +echo "Installing npm dependencies..." +cd "$FRONTEND_DIR" +npm install + # Building the React Application echo "Building React app..." -cd "$FRONTEND_DIR" npm run build # Check if the web server root directory exists, if not, create it From 1926898a08770dd846d9b618f08a8ac380bd6e49 Mon Sep 17 00:00:00 2001 From: Adrian Sebuliba Date: Fri, 25 Oct 2024 07:36:22 +0200 Subject: [PATCH 12/37] Change title --- public/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/index.html b/public/index.html index c0bc686..0b9a6c5 100644 --- a/public/index.html +++ b/public/index.html @@ -24,7 +24,7 @@ work correctly both with client-side routing and a non-root public URL. Learn how to configure a non-root public URL by running `npm run build`. --> - 3D visualization + ISAS LSFM From 8596d2a955c0358bc0aa83bcaef0764199ffe99d Mon Sep 17 00:00:00 2001 From: Adrian Sebuliba Date: Thu, 31 Oct 2024 11:11:32 +0100 Subject: [PATCH 13/37] Refine ui --- src/App.css | 122 +++++++++++++++++++++++++++++++++ src/components/VolumeViewer.js | 28 ++++++-- 2 files changed, 144 insertions(+), 6 deletions(-) diff --git a/src/App.css b/src/App.css index 35dc250..8cecedb 100644 --- a/src/App.css +++ b/src/App.css @@ -1192,3 +1192,125 @@ button svg { width: 100% } } + + +/* New Independent Scrolling Styles */ +.sider-container { + width: 300px; + height: 100vh; + overflow-y: auto; + position: relative; + z-index: 1; + padding-bottom: 60px; +} + +.content-container { + flex: 1; + height: 100vh; + overflow-y: auto; + position: relative; +} + +/* Ensure proper collapse panel behavior */ +.ant-collapse-content { + overflow: visible !important; +} + + +/* Add these new styles for the file listing container */ +.file-listing { + padding: 8px 0; + cursor: pointer; + color: #1890ff; + word-break: break-word; + overflow-wrap: break-word; + font-size: 0.9em; + line-height: 1.4; + border-bottom: 1px solid #f0f0f0; + transition: background-color 0.2s ease; + display: flex; + align-items: center; +} + +.file-listing:hover { + background-color: #f5f5f5; +} + +/* Style for the file name text */ +.file-name { + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; + padding: 0 10px; + width: 100%; +} + +/* Update Ant Design's collapse panel styles */ +.ant-collapse-content-box { + padding: 0 !important; +} + +.ant-collapse-header { + font-weight: 500; + background-color: #fafafa; +} + +/* Add styles for the body part headers */ +.body-part-header { + font-weight: 500; + padding: 8px 12px; + background-color: #f5f5f5; + border-bottom: 1px solid #e8e8e8; + margin: 0; +} + +/* Add tooltip styles for long file names */ +.file-name-tooltip { + max-width: 300px; + word-wrap: break-word; + visibility: hidden; + background-color: rgba(0, 0, 0, 0.75); + color: #fff; + text-align: center; + padding: 5px 10px; + border-radius: 4px; + position: absolute; + z-index: 1500; + font-size: 0.85em; + left: 100%; + top: 50%; + transform: translateY(-50%); + opacity: 0; + transition: opacity 0.3s; +} + +.file-listing:hover .file-name-tooltip { + visibility: visible; + opacity: 1; +} + +/* Add styles for optional file icon */ +.file-icon { + min-width: 16px; + margin-right: 8px; + color: #1890ff; +} + +/* Ensure the sider content doesn't overflow */ +.ant-layout-sider-children { + overflow-x: hidden; +} + +/* Style scrollbar for better visibility */ +.ant-layout-sider::-webkit-scrollbar { + width: 6px; +} + +.ant-layout-sider::-webkit-scrollbar-thumb { + background-color: #d9d9d9; + border-radius: 3px; +} + +.ant-layout-sider::-webkit-scrollbar-track { + background-color: #f0f0f0; +} \ No newline at end of file diff --git a/src/components/VolumeViewer.js b/src/components/VolumeViewer.js index e07f032..cb4a1db 100644 --- a/src/components/VolumeViewer.js +++ b/src/components/VolumeViewer.js @@ -757,8 +757,9 @@ const VolumeViewer = () => { }; return ( - - + +
+ {/* Render Mode */} @@ -1151,12 +1152,26 @@ const VolumeViewer = () => {
{Object.keys(fileData).map((bodyPart) => ( - + {bodyPart}} + key={bodyPart} + > {fileData[bodyPart].map((file) => ( -
handleFileSelect(bodyPart, file)} > - {file} + + 📄 + + + {file} + + {/* Tooltip for long file names */} + + {file} +
))}
@@ -1164,8 +1179,9 @@ const VolumeViewer = () => {
+
- +
From 5cac57746a490c4fcc860ab460faca29ce1bd2d0 Mon Sep 17 00:00:00 2001 From: Adrian Sebuliba Date: Tue, 5 Nov 2024 20:06:33 +0100 Subject: [PATCH 14/37] Fix color picker, mask aplha and gamma settings --- src/components/VolumeViewer.js | 308 ++++++++++++++++++++++++++------- src/components/appConfig.js | 2 +- src/components/constants.js | 81 +++++++-- src/utils/colorUtils.js | 57 ++++++ 4 files changed, 365 insertions(+), 83 deletions(-) create mode 100644 src/utils/colorUtils.js diff --git a/src/components/VolumeViewer.js b/src/components/VolumeViewer.js index cb4a1db..a5ff7a0 100644 --- a/src/components/VolumeViewer.js +++ b/src/components/VolumeViewer.js @@ -17,6 +17,7 @@ import { useConstructor } from './useConstructor'; import { Slider, Switch, InputNumber, Row, Col, Collapse, Layout, Button, Select, Input, Tooltip, Spin } from 'antd'; import axios from 'axios'; import { API_URL } from '../config'; // Importing API_URL from your config +import { ALPHA_MASK_SLIDER_3D_DEFAULT, BRIGHTNESS_SLIDER_LEVEL_DEFAULT, DENSITY_SLIDER_LEVEL_DEFAULT, LEVELS_SLIDER_DEFAULT, LUT_MAX_PERCENTILE, LUT_MIN_PERCENTILE, PRESET_COLORS_0, PRESET_COLOR_MAP } from './constants'; // Utility function to concatenate arrays const concatenateArrays = (arrays) => { @@ -37,6 +38,7 @@ const { Vector3 } = THREE; const VolumeViewer = () => { const viewerRef = useRef(null); + const volumeRef = useRef(null); const view3D = useConstructor(() => new View3d({ parentElement: viewerRef.current })); const loadContext = useConstructor(() => loaderContext); @@ -98,48 +100,95 @@ const VolumeViewer = () => { const [lightIntensity, setLightIntensity] = useState(myState.lightIntensity); const [lightTheta, setLightTheta] = useState(myState.lightTheta); const [lightPhi, setLightPhi] = useState(myState.lightPhi); + const [currentPreset, setCurrentPreset] = useState(0); // Default preset + const [settings, setSettings] = useState({ + maskAlpha: ALPHA_MASK_SLIDER_3D_DEFAULT[0], + brightness: BRIGHTNESS_SLIDER_LEVEL_DEFAULT[0], + density: DENSITY_SLIDER_LEVEL_DEFAULT[0], + levels: LEVELS_SLIDER_DEFAULT, + }); + const densitySliderToView3D = (density) => density / 50.0; const onChannelDataArrived = (v, channelIndex) => { + const histogram = v.getHistogram(channelIndex); + const hmin = histogram.findBinOfPercentile(LUT_MIN_PERCENTILE); + const hmax = histogram.findBinOfPercentile(LUT_MAX_PERCENTILE); + + const channelColor = PRESET_COLORS_0[channelIndex % PRESET_COLORS_0.length]; + + // Create control points for smoother gradients + const controlPoints = [ + { x: 0, opacity: 0, color: channelColor }, + { x: hmin, opacity: 0.1, color: channelColor }, + { x: (hmin + hmax) / 2, opacity: 0.5, color: channelColor }, + { x: hmax, opacity: 1.0, color: channelColor }, + { x: 255, opacity: 1.0, color: channelColor } + ]; + + // Create and set LUT + const lutObject = new Lut().createFromControlPoints(controlPoints); + v.setLut(channelIndex, lutObject); + v.setColorPalette(channelIndex, channelColor); + view3D.onVolumeData(v, [channelIndex]); + if (channels[channelIndex]) { view3D.setVolumeChannelEnabled(v, channelIndex, channels[channelIndex].enabled); + view3D.setVolumeChannelOptions(v, channelIndex, { + color: channelColor, + opacity: 1.0, + brightness: 1.2, + contrast: 1.1 + }); } + view3D.updateActiveChannels(v); view3D.updateLuts(v); + if (v.isLoaded()) { console.log("Volume " + v.name + " is loaded"); } view3D.redraw(); }; + // Modify your onVolumeCreated function const onVolumeCreated = (volume) => { - initializeChannelOptions(volume); + volumeRef.current = volume; + // Set default channel colors + volume.channelColorsDefault = volume.imageInfo.channelNames.map((_, index) => + PRESET_COLORS_0[index % PRESET_COLORS_0.length] + ); - // volume.channelColorsDefault = volume.imageInfo.channelNames.map(() => DEFAULT_CHANNEL_COLOR); - setCurrentVolume(volume); view3D.removeAllVolumes(); view3D.addVolume(volume); - - // Log the channel colors to verify the change - console.log("Channel Default Colors:", volume.channelColors); - - setInitialRenderMode(); showChannelUI(volume); + view3D.updateActiveChannels(volume); view3D.updateLuts(volume); view3D.updateLights(lights); - view3D.updateDensity(volume, densitySliderToView3D(density)); - view3D.updateMaskAlpha(volume, maskAlpha); + // Apply initial settings + const alphaValue = 1 - (settings.maskAlpha / 100); + view3D.updateMaskAlpha(volumeRef.current, alphaValue); + + const brightnessValue = settings.brightness / 100; + view3D.updateExposure(brightnessValue); + + const densityValue = settings.density / 100; + view3D.updateDensity(volume, densityValue); + + const [min, mid, max] = settings.levels.map(v => v / 255); + const diff = max - min; + const x = (mid - min) / diff; + const scale = 4 * x * x; + view3D.setGamma(volume, min, scale, max); view3D.setRayStepSizes(volume, primaryRay, secondaryRay); - view3D.updateExposure(exposure); view3D.updateCamera(fov, focalDistance, aperture); - view3D.updateActiveChannels(volume) - // view3D.updatePixelSamplingRate(samplingRate); + view3D.updateActiveChannels(volume); view3D.redraw(); }; @@ -245,18 +294,18 @@ const VolumeViewer = () => { }, [viewerRef, view3D]); useEffect(() => { - if (currentVolume) { - view3D.updateDensity(currentVolume, densitySliderToView3D(density)); - view3D.redraw(); - } - }, [density]); + if (!currentVolume || !view3D) return; + const densityValue = settings.density / 100; + view3D.updateDensity(currentVolume, densityValue); + view3D.redraw(); + }, [settings.density]); useEffect(() => { - if (currentVolume) { - view3D.updateExposure(exposure); - view3D.redraw(); - } - }, [currentVolume, exposure, view3D]); + if (!view3D) return; + const brightnessValue = settings.brightness / 100; + view3D.updateExposure(brightnessValue); + view3D.redraw(); + }, [settings.brightness]) useEffect(() => { view3D.setVolumeRenderMode(isPT ? RENDERMODE_PATHTRACE : RENDERMODE_RAYMARCH); @@ -318,11 +367,14 @@ const VolumeViewer = () => { }, [flipX, flipY, flipZ]); useEffect(() => { - if (currentVolume) { - const gammaValues = gammaSliderToImageValues(gamma); - view3D.setGamma(currentVolume, gammaValues[0], gammaValues[1], gammaValues[2]); - } - }, [gamma]); + if (!currentVolume || !view3D) return; + const [min, mid, max] = settings.levels.map(v => v / 255); + const diff = max - min; + const x = (mid - min) / diff; + const scale = 4 * x * x; + view3D.setGamma(currentVolume, min, scale, max); + view3D.redraw(); + }, [settings.levels]); useEffect(() => { if (currentVolume) { @@ -356,12 +408,13 @@ const VolumeViewer = () => { }, [primaryRay, secondaryRay]); useEffect(() => { - if (currentVolume) { - view3D.updateMaskAlpha(currentVolume, maskAlpha); - view3D.updateActiveChannels(currentVolume); - view3D.redraw(); - } - }, [maskAlpha]) + if (!currentVolume || !view3D) return; + const alphaValue = 1 - (settings.maskAlpha / 100.0); + view3D.updateMaskAlpha(currentVolume, alphaValue); + view3D.updateActiveChannels(currentVolume); + // view3D.redraw(); + console.log("maskAlpha", settings.maskAlpha); + }, [settings.maskAlpha]); useEffect(() => { if (view3D && lights[0]) { @@ -414,26 +467,49 @@ const VolumeViewer = () => { const DEFAULT_CHANNEL_COLOR = [128, 128, 128]; // Medium gray - const showChannelUI = (volume) => { - const channelGui = volume.imageInfo.channelNames.map((name, index) => ({ - name, - enabled: index < 3, - colorD: volume.channelColorsDefault[index] || DEFAULT_CHANNEL_COLOR, - colorS: [0, 0, 0], - colorE: [0, 0, 0], - glossiness: 0, - window: 1, - level: 0.5, - isovalue: 128, - isosurface: false - })); + // Modify your showChannelUI function + const showChannelUI = (volume) => { + const currentPresetColors = PRESET_COLOR_MAP[currentPreset].colors; + + const channelGui = volume.imageInfo.channelNames.map((name, index) => { + const channelColor = currentPresetColors[index % currentPresetColors.length]; + + return { + name, + enabled: index < 3, + colorD: channelColor, + colorS: [0, 0, 0], + colorE: [0, 0, 0], + glossiness: 0, + window: 1, + level: 0.5, + isovalue: 128, + isosurface: false, + brightness: 1.2, + contrast: 1.1 + }; + }); + setChannels(channelGui); - // Log channel colors for verification + // Force update channel materials channelGui.forEach((channel, index) => { - console.log(`Channel ${index} (${channel.name}) color:`, channel.colorD); + if (channel.enabled) { + view3D.updateChannelMaterial( + volume, + index, + channel.colorD, + channel.colorS, + channel.colorE, + channel.glossiness + ); + } }); + + view3D.updateMaterial(volume); + view3D.redraw(); }; + const updateChannel = (index, key, value) => { const updatedChannels = [...channels]; @@ -683,17 +759,30 @@ const VolumeViewer = () => { const rgbToHex = (r, g, b) => { const toHex = (component) => { const hex = Math.round(component).toString(16); - return hex.length === 1 ? '0' + hex : hex; // Ensures two digits + return hex.length === 1 ? '0' + hex : hex; }; - // Ensure r, g, b are valid numbers and fall back to 0 if undefined or invalid - r = isNaN(r) ? 0 : r; - g = isNaN(g) ? 0 : g; - b = isNaN(b) ? 0 : b; + // Ensure values are between 0-255 + r = Math.min(255, Math.max(0, Math.round(r))); + g = Math.min(255, Math.max(0, Math.round(g))); + b = Math.min(255, Math.max(0, Math.round(b))); return `#${toHex(r)}${toHex(g)}${toHex(b)}`; }; + const hexToRgb = (hex) => { + // Remove the hash if present + hex = hex.replace(/^#/, ''); + + // Parse the hex values + const bigint = parseInt(hex, 16); + const r = (bigint >> 16) & 255; + const g = (bigint >> 8) & 255; + const b = bigint & 255; + + return [r, g, b]; + }; + const updatePixelSamplingRate = (rate) => { setSamplingRate(rate); view3D.updatePixelSamplingRate(rate); @@ -756,6 +845,46 @@ const VolumeViewer = () => { view3D.redraw(); }; + const applyColorPreset = (presetIndex) => { + if (!currentVolume) return; + + const preset = PRESET_COLOR_MAP[presetIndex].colors; + + const updatedChannels = channels.map((channel, index) => { + const newColor = preset[index % preset.length]; + return { + ...channel, + colorD: newColor + }; + }); + + // Update state + setChannels(updatedChannels); + setCurrentPreset(presetIndex); + + // Update each channel's material + updatedChannels.forEach((channel, index) => { + view3D.updateChannelMaterial( + currentVolume, + index, + channel.colorD, + channel.colorS, + channel.colorE, + channel.glossiness + ); + }); + + view3D.updateMaterial(currentVolume); + view3D.redraw(); + }; + + const updateSetting = (key, value) => { + setSettings(prev => ({ + ...prev, + [key]: value + })); + }; + return (
@@ -773,12 +902,22 @@ const VolumeViewer = () => { {/* Density */} - + updateSetting('density', val)} + /> {/* Mask Alpha */} - + updateSetting('maskAlpha', val)} + /> {/* Ray Step Sizes */} @@ -791,7 +930,12 @@ const VolumeViewer = () => { {/* Exposure */} - + updateSetting('brightness', val)} + /> {/* Camera Settings */} @@ -994,28 +1138,56 @@ const VolumeViewer = () => { Min - updateGamma([value, gamma[1], gamma[2]])} /> + updateSetting('levels', [value, settings.levels[1], settings.levels[2]])} + /> Mid - updateGamma([gamma[0], value, gamma[2]])} /> + updateSetting('levels', [settings.levels[0], value, settings.levels[2]])} + /> Max - updateGamma([gamma[0], gamma[1], value])} /> + updateSetting('levels', [settings.levels[0], settings.levels[1], value])} + /> {/* Channels */} + + Color Preset + + + + {channels.map((channel, index) => (
@@ -1054,8 +1226,14 @@ const VolumeViewer = () => { Diffuse Color - updateChannel(index, 'colorD', e.target.value.match(/.{1,2}/g).map(c => parseInt(c, 16)))} /> + { + const rgbColor = hexToRgb(e.target.value); + updateChannel(index, 'colorD', rgbColor); + }} + /> diff --git a/src/components/appConfig.js b/src/components/appConfig.js index f668120..9597c6b 100644 --- a/src/components/appConfig.js +++ b/src/components/appConfig.js @@ -115,7 +115,7 @@ export const myState = { "https://animatedcell-test-data.s3.us-west-2.amazonaws.com/timelapse/test_parent_T49.ome_%%_atlas.json" ), density: 12.5, - maskAlpha: 1.0, + maskAlpha: 0.0, exposure: 0.75, aperture: 0.0, fov: 20, diff --git a/src/components/constants.js b/src/components/constants.js index 27e35d6..d125e04 100644 --- a/src/components/constants.js +++ b/src/components/constants.js @@ -41,7 +41,7 @@ export const COLOR = "color"; export const SAVE_ISO_SURFACE = "saveIsoSurface"; // LUT percentiles for remapping intensity values -export const LUT_MIN_PERCENTILE = 0.5; +export const LUT_MIN_PERCENTILE = 0.1; export const LUT_MAX_PERCENTILE = 0.983; // Opacity control for isosurfaces @@ -61,6 +61,18 @@ export const SINGLE_GROUP_CHANNEL_KEY = "Channels"; // Special channel names export const CELL_SEGMENTATION_CHANNEL_NAME = "SEG_Memb"; +export const PRESET_COLORS_0 = [ + [226, 205, 179], // Membrane + [111, 186, 17], // Structure + [141, 163, 192], // DNA + [245, 241, 203], // Brightfield + [224, 227, 209], + [221, 155, 245], + [227, 244, 245], + [255, 98, 0], + [247, 219, 120] + ]; + // Color presets for channels export const PRESET_COLORS_1 = [ [190, 68, 171, 255], @@ -97,22 +109,11 @@ export const PRESET_COLORS_3 = [ // Map of preset color groups export const PRESET_COLOR_MAP = Object.freeze([ - { - colors: PRESET_COLORS_1, - name: "Thumbnail colors", - key: 1, - }, - { - colors: PRESET_COLORS_2, - name: "RGB colors", - key: 2, - }, - { - colors: PRESET_COLORS_3, - name: "White structure", - key: 3, - } -]); + { colors: PRESET_COLORS_0, name: "Default", key: 0 }, + { colors: PRESET_COLORS_1, name: "Thumbnail colors", key: 1 }, + { colors: PRESET_COLORS_2, name: "RGB colors", key: 2 }, + { colors: PRESET_COLORS_3, name: "White structure", key: 3 } + ]); // Application color scheme export default { @@ -126,3 +127,49 @@ export default { disabledColor: '#D1D1D1', // dull gray pickerHeaderColor: '#316773' // cool blue green }; + +// Default settings and constants +export const DEFAULT_SETTINGS = { + LUT_MIN_PERCENTILE: 0.5, + LUT_MAX_PERCENTILE: 0.983, + ISOSURFACE_OPACITY_SLIDER_MAX: 255.0, + ALPHA_MASK_SLIDER_3D_DEFAULT: [50], + ALPHA_MASK_SLIDER_2D_DEFAULT: [0], + BRIGHTNESS_SLIDER_LEVEL_DEFAULT: [70], + DENSITY_SLIDER_LEVEL_DEFAULT: [50], + LEVELS_SLIDER_DEFAULT: [35.0, 140.0, 255.0], + PLAY_RATE_MS_PER_STEP: 125 + }; + + + // Constants and settings from the reference implementation +export const VIEWER_3D_SETTING = { + groups: [ + { + name: "Observed channels", + channels: [ + { name: "Membrane", match: ["(CMDRP)"], color: "E2CDB3", enabled: true, lut: ["p50", "p98"] }, + { name: "Labeled structure", match: ["(EGFP)|(RFPT)"], color: "6FBA11", enabled: true, lut: ["p50", "p98"] }, + { name: "DNA", match: ["(H3342)"], color: "8DA3C0", enabled: true, lut: ["p50", "p98"] }, + { name: "Bright field", match: ["(100)|(Bright)"], color: "F5F1CB", enabled: false, lut: ["p50", "p98"] }, + ], + }, + { + name: "Segmentation channels", + channels: [ + { name: "Labeled structure", match: ["(SEG_STRUCT)"], color: "E0E3D1", enabled: false, lut: ["p50", "p98"] }, + { name: "Membrane", match: ["(SEG_Memb)"], color: "DD9BF5", enabled: false, lut: ["p50", "p98"] }, + { name: "DNA", match: ["(SEG_DNA)"], color: "E3F4F5", enabled: false, lut: ["p50", "p98"] }, + ], + }, + { + name: "Contour channels", + channels: [ + { name: "Membrane", match: ["(CON_Memb)"], color: "FF6200", enabled: false, lut: ["p50", "p98"] }, + { name: "DNA", match: ["(CON_DNA)"], color: "F7DB78", enabled: false, lut: ["p50", "p98"] }, + ], + }, + ], + maskChannelName: "SEG_Memb", + }; + diff --git a/src/utils/colorUtils.js b/src/utils/colorUtils.js new file mode 100644 index 0000000..faf14a8 --- /dev/null +++ b/src/utils/colorUtils.js @@ -0,0 +1,57 @@ +// src/utils/colorUtils.js + +import { Lut } from "@aics/volume-viewer"; + +export const controlPointsToLut = (controlPoints) => { + if (!controlPoints || controlPoints.length < 2) { + return new Lut().createFromMinMax(0, 255); + } + + const lut = new Lut(); + const sortedPoints = [...controlPoints].sort((a, b) => a.x - b.x); + + // Ensure the LUT spans the full range + if (sortedPoints[0].x > 0) { + sortedPoints.unshift({ ...sortedPoints[0], x: 0 }); + } + if (sortedPoints[sortedPoints.length - 1].x < 1) { + sortedPoints.push({ ...sortedPoints[sortedPoints.length - 1], x: 1 }); + } + + // Create LUT from control points + for (let i = 0; i < sortedPoints.length - 1; i++) { + const p1 = sortedPoints[i]; + const p2 = sortedPoints[i + 1]; + + const steps = Math.ceil((p2.x - p1.x) * 255); + for (let j = 0; j < steps; j++) { + const t = j / steps; + const x = p1.x + t * (p2.x - p1.x); + const y = p1.y + t * (p2.y - p1.y); + + // Interpolate colors + const r = p1.color[0] + t * (p2.color[0] - p1.color[0]); + const g = p1.color[1] + t * (p2.color[1] - p1.color[1]); + const b = p1.color[2] + t * (p2.color[2] - p1.color[2]); + + const index = Math.floor(x * 255); + lut.lut[index] = [r, g, b, 255]; + } + } + + return lut; +}; + +export const rgbaFromArray = (arr) => { + return { + r: arr[0] || 0, + g: arr[1] || 0, + b: arr[2] || 0, + a: arr[3] ? arr[3] / 255 : 1 + }; +}; + +export const rgbaToString = (rgba) => { + const { r, g, b, a } = rgba; + return `rgba(${r},${g},${b},${a})`; +}; \ No newline at end of file From af45268edf9effd69a75c986fbec8d1ea90fdeab Mon Sep 17 00:00:00 2001 From: Adrian Sebuliba Date: Thu, 7 Nov 2024 19:28:13 +0100 Subject: [PATCH 15/37] Add playback and other refinements --- src/App.css | 21 ++ src/components/VolumeViewer.js | 464 ++++++++++++++++++++++++--------- src/components/appConfig.js | 11 +- 3 files changed, 370 insertions(+), 126 deletions(-) diff --git a/src/App.css b/src/App.css index 8cecedb..98e7e1e 100644 --- a/src/App.css +++ b/src/App.css @@ -1313,4 +1313,25 @@ button svg { .ant-layout-sider::-webkit-scrollbar-track { background-color: #f0f0f0; +} + +.plane-player-controls { + background: #fff; + border-radius: 8px; + padding: 15px; + margin: 10px 0; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.plane-player-controls .ant-slider { + margin: 10px 0; +} + +.plane-player-controls .ant-row { + margin: 10px 0; +} + +.plane-player-controls h4 { + margin-bottom: 15px; + text-align: center; } \ No newline at end of file diff --git a/src/components/VolumeViewer.js b/src/components/VolumeViewer.js index a5ff7a0..fd04fcd 100644 --- a/src/components/VolumeViewer.js +++ b/src/components/VolumeViewer.js @@ -17,7 +17,8 @@ import { useConstructor } from './useConstructor'; import { Slider, Switch, InputNumber, Row, Col, Collapse, Layout, Button, Select, Input, Tooltip, Spin } from 'antd'; import axios from 'axios'; import { API_URL } from '../config'; // Importing API_URL from your config -import { ALPHA_MASK_SLIDER_3D_DEFAULT, BRIGHTNESS_SLIDER_LEVEL_DEFAULT, DENSITY_SLIDER_LEVEL_DEFAULT, LEVELS_SLIDER_DEFAULT, LUT_MAX_PERCENTILE, LUT_MIN_PERCENTILE, PRESET_COLORS_0, PRESET_COLOR_MAP } from './constants'; +import { ALPHA_MASK_SLIDER_3D_DEFAULT, BRIGHTNESS_SLIDER_LEVEL_DEFAULT, CELL_SEGMENTATION_CHANNEL_NAME, DENSITY_SLIDER_LEVEL_DEFAULT, ISOSURFACE_OPACITY_SLIDER_MAX, LEVELS_SLIDER_DEFAULT, LUT_MAX_PERCENTILE, LUT_MIN_PERCENTILE, PRESET_COLORS_0, PRESET_COLOR_MAP, VIEWER_3D_SETTING } from './constants'; +import PlanarSlicePlayer from './PlanarSlicePlayer'; // Utility function to concatenate arrays const concatenateArrays = (arrays) => { @@ -102,95 +103,173 @@ const VolumeViewer = () => { const [lightPhi, setLightPhi] = useState(myState.lightPhi); const [currentPreset, setCurrentPreset] = useState(0); // Default preset const [settings, setSettings] = useState({ - maskAlpha: ALPHA_MASK_SLIDER_3D_DEFAULT[0], - brightness: BRIGHTNESS_SLIDER_LEVEL_DEFAULT[0], - density: DENSITY_SLIDER_LEVEL_DEFAULT[0], - levels: LEVELS_SLIDER_DEFAULT, + maskAlpha: ALPHA_MASK_SLIDER_3D_DEFAULT[0], // 50 + brightness: BRIGHTNESS_SLIDER_LEVEL_DEFAULT[0], // 70 + density: DENSITY_SLIDER_LEVEL_DEFAULT[0], // 50 + levels: LEVELS_SLIDER_DEFAULT, // [35.0, 140.0, 255.0] + autoRotate: false, + pathTrace: false, + renderMode: RENDERMODE_RAYMARCH, + colorizeEnabled: false, + colorizeAlpha: 1.0, + selectedColorPalette: 0, + axisClip: { x: [0, 1], y: [0, 1], z: [0, 1] }, + }) + + const [isoSurfaceSettings, setIsoSurfaceSettings] = useState({ + isosurfaceOpacityMax: ISOSURFACE_OPACITY_SLIDER_MAX, + defaultIsovalue: 128, + defaultOpacity: 1.0 }); const densitySliderToView3D = (density) => density / 50.0; - const onChannelDataArrived = (v, channelIndex) => { - const histogram = v.getHistogram(channelIndex); + const onChannelDataArrived = (volume, channelIndex) => { + if (volume !== volumeRef.current) return; + + const histogram = volume.getHistogram(channelIndex); + if (!histogram) return; + + // Find percentile values const hmin = histogram.findBinOfPercentile(LUT_MIN_PERCENTILE); const hmax = histogram.findBinOfPercentile(LUT_MAX_PERCENTILE); - const channelColor = PRESET_COLORS_0[channelIndex % PRESET_COLORS_0.length]; + // Create LUT using the Lut class + const lut = new Lut(); + const lutData = lut.createFromMinMax(hmin, hmax); - // Create control points for smoother gradients - const controlPoints = [ - { x: 0, opacity: 0, color: channelColor }, - { x: hmin, opacity: 0.1, color: channelColor }, - { x: (hmin + hmax) / 2, opacity: 0.5, color: channelColor }, - { x: hmax, opacity: 1.0, color: channelColor }, - { x: 255, opacity: 1.0, color: channelColor } - ]; + // Set the LUT for the channel + volume.setLut(channelIndex, lutData); - // Create and set LUT - const lutObject = new Lut().createFromControlPoints(controlPoints); - v.setLut(channelIndex, lutObject); - v.setColorPalette(channelIndex, channelColor); - - view3D.onVolumeData(v, [channelIndex]); + view3D.onVolumeData(volume, [channelIndex]); if (channels[channelIndex]) { - view3D.setVolumeChannelEnabled(v, channelIndex, channels[channelIndex].enabled); - view3D.setVolumeChannelOptions(v, channelIndex, { - color: channelColor, + view3D.setVolumeChannelEnabled(volume, channelIndex, channels[channelIndex].enabled); + view3D.setVolumeChannelOptions(volume, channelIndex, { + color: channels[channelIndex].color, opacity: 1.0, brightness: 1.2, contrast: 1.1 }); } - view3D.updateActiveChannels(v); - view3D.updateLuts(v); + view3D.updateActiveChannels(volume); + view3D.updateLuts(volume); - if (v.isLoaded()) { - console.log("Volume " + v.name + " is loaded"); + if (volume.isLoaded()) { + console.log("Volume " + volume.name + " is loaded"); } view3D.redraw(); }; // Modify your onVolumeCreated function const onVolumeCreated = (volume) => { + if (!volume || !volume.imageInfo) { + console.error("Invalid volume data"); + return; + } + volumeRef.current = volume; - // Set default channel colors - volume.channelColorsDefault = volume.imageInfo.channelNames.map((_, index) => - PRESET_COLORS_0[index % PRESET_COLORS_0.length] - ); - setCurrentVolume(volume); + // 1. First, set up volume in viewer view3D.removeAllVolumes(); - view3D.addVolume(volume); - - setInitialRenderMode(); - showChannelUI(volume); - view3D.updateActiveChannels(volume); - view3D.updateLuts(volume); - view3D.updateLights(lights); - // Apply initial settings - const alphaValue = 1 - (settings.maskAlpha / 100); - view3D.updateMaskAlpha(volumeRef.current, alphaValue); + // 2. Initialize channels with default colors + const channelNames = volume.imageInfo.channelNames || []; + const newChannels = channelNames.map((name, index) => { + const defaultColor = PRESET_COLORS_0[index % PRESET_COLORS_0.length]; + return { + name, + enabled: index < 3, // First 3 channels enabled by default + color: defaultColor, + isosurfaceEnabled: false, + isovalue: 128, + opacity: 1.0, + lut: ["p50", "p98"] + }; + }); + // 3. Add volume with initial channel settings + view3D.addVolume(volume, { + channels: newChannels.map(ch => ({ + enabled: ch.enabled, + color: ch.color, + isosurfaceEnabled: ch.isosurfaceEnabled, + isovalue: ch.isovalue, + isosurfaceOpacity: ch.opacity + })) + }); + + // 4. Apply initial volume settings + // Mask alpha + const alphaValue = 1 - (settings.maskAlpha / 100); + view3D.updateMaskAlpha(volume, alphaValue); + + // Brightness const brightnessValue = settings.brightness / 100; view3D.updateExposure(brightnessValue); + // Density const densityValue = settings.density / 100; view3D.updateDensity(volume, densityValue); + // Gamma levels const [min, mid, max] = settings.levels.map(v => v / 255); const diff = max - min; const x = (mid - min) / diff; const scale = 4 * x * x; - view3D.setGamma(volume, min, scale, max); - view3D.setRayStepSizes(volume, primaryRay, secondaryRay); - view3D.updateCamera(fov, focalDistance, aperture); + view3D.setGamma(volume, min, scale, max); + + // 5. Initialize LUTs for each channel + channelNames.forEach((_, index) => { + if (volume.getHistogram) { + const histogram = volume.getHistogram(index); + if (histogram) { + // Find percentile values + const hmin = histogram.findBinOfPercentile(LUT_MIN_PERCENTILE); + const hmax = histogram.findBinOfPercentile(LUT_MAX_PERCENTILE); + + // Create LUT using the Lut class + const lut = new Lut(); + const lutData = lut.createFromMinMax(hmin, hmax); + + // Set the LUT for the channel + volume.setLut(index, lutData); + + // Save control points if needed + const controlPoints = [ + { x: 0, opacity: 0, color: newChannels[index].color }, + { x: hmin, opacity: 0.1, color: newChannels[index].color }, + { x: (hmin + hmax) / 2, opacity: 0.5, color: newChannels[index].color }, + { x: hmax, opacity: 1.0, color: newChannels[index].color }, + { x: 255, opacity: 1.0, color: newChannels[index].color } + ]; + + newChannels[index].controlPoints = controlPoints; + } + } + }); + + // 6. Check for and set up segmentation mask + const segIndex = channelNames.findIndex(name => + name === CELL_SEGMENTATION_CHANNEL_NAME + ); + if (segIndex !== -1) { + view3D.setVolumeChannelAsMask(volume, segIndex); + } + + // 7. Update viewer state + view3D.setVolumeRenderMode(settings.pathTrace ? RENDERMODE_PATHTRACE : RENDERMODE_RAYMARCH); view3D.updateActiveChannels(volume); + view3D.updateLuts(volume); + + // 8. Store state + setChannels(newChannels); + setCurrentVolume(volume); view3D.redraw(); }; + const loadVolume = async (loadSpec, loader) => { const volume = await loader.createVolume(loadSpec, onChannelDataArrived); @@ -460,6 +539,94 @@ const VolumeViewer = () => { console.log([lightColor, lightIntensity, lightTheta, lightPhi]); }, [lightColor, lightIntensity, lightTheta, lightPhi]); + + // Effect for handling isosurface enable/disable + useEffect(() => { + if (!currentVolume || !view3D) return; + + channels.forEach((channel, index) => { + view3D.setVolumeChannelOptions( + currentVolume, + index, + { + isosurfaceEnabled: channel.isosurfaceEnabled, + isovalue: channel.isovalue, + opacity: channel.opacity + } + ); + }); + + view3D.updateMaterial(currentVolume); + view3D.redraw(); + }, [channels.map(ch => ch.isosurfaceEnabled).join(',')]); // Dependency on isosurfaceEnabled values + + // Effect for handling isovalue changes + useEffect(() => { + if (!currentVolume || !view3D) return; + + channels.forEach((channel, index) => { + if (channel.isosurfaceEnabled) { + view3D.setVolumeChannelOptions( + currentVolume, + index, + { + isosurfaceEnabled: true, + isovalue: channel.isovalue, + opacity: channel.opacity + } + ); + } + }); + + view3D.updateMaterial(currentVolume); + view3D.redraw(); + }, [channels.map(ch => ch.isovalue).join(',')]); // Dependency on isovalue changes + + // Effect for handling opacity changes + useEffect(() => { + if (!currentVolume || !view3D) return; + + channels.forEach((channel, index) => { + if (channel.isosurfaceEnabled) { + view3D.setVolumeChannelOptions( + currentVolume, + index, + { + isosurfaceEnabled: true, + isovalue: channel.isovalue, + isosurfaceOpacity: channel.opacity + } + ); + } + }); + + view3D.updateMaterial(currentVolume); + view3D.redraw(); + }, [channels.map(ch => ch.opacity).join(',')]); // Dependency on opacity changes + + + + useEffect(() => { + if (!currentVolume || !view3D) return; + channels.forEach((channel, index) => { + if (channel.isosurfaceEnabled) { + view3D.setVolumeChannelOptions( + currentVolume, + index, + { + isosurfaceEnabled: true, + isovalue: channel.isovalue, + isosurfaceOpacity: channel.opacity, + opacity: channel.opacity // Include both for compatibility + } + ); + // Force material update + view3D.updateMaterial(currentVolume); + } + }); + view3D.redraw(); + }, [channels.map(ch => `${ch.isosurfaceEnabled}-${ch.opacity}`).join(',')]); + const setInitialRenderMode = () => { view3D.setVolumeRenderMode(isPT ? RENDERMODE_PATHTRACE : RENDERMODE_RAYMARCH); view3D.setMaxProjectMode(currentVolume, false); @@ -550,40 +717,11 @@ const VolumeViewer = () => { const updateChannelOptions = (index, options) => { + if (!currentVolume || !view3D) return; + const updatedChannels = [...channels]; updatedChannels[index] = { ...updatedChannels[index], ...options }; setChannels(updatedChannels); - - if (view3D) { - view3D.setVolumeChannelOptions(index, options); - if (options.isosurfaceEnabled !== undefined) { - if (options.isosurfaceEnabled) { - const channel = updatedChannels[index]; - view3D.createIsosurface( - index, - channel.color, - channel.isovalue, - channel.isosurfaceOpacity, - channel.isosurfaceOpacity < 0.95 - ); - } else { - view3D.clearIsosurface(index); - } - } - if (options.isovalue !== undefined || options.isosurfaceOpacity !== undefined) { - const channel = updatedChannels[index]; - view3D.updateIsosurface(index, channel.isovalue); - view3D.updateChannelMaterial( - index, - channel.color, - channel.specularColor, - channel.emissiveColor, - channel.glossiness - ); - view3D.updateOpacity(index, channel.isosurfaceOpacity); - } - view3D.redraw(); - } }; const initializeChannelOptions = (volume) => { @@ -602,10 +740,27 @@ const VolumeViewer = () => { }; const updateIsovalue = (index, isovalue) => { - if (currentVolume) { - view3D.updateIsosurface(currentVolume, index, isovalue); - view3D.redraw(); - } + if (!currentVolume || !view3D) return; + + const updatedChannels = [...channels]; + updatedChannels[index] = { + ...updatedChannels[index], + isovalue + }; + setChannels(updatedChannels); + + view3D.setVolumeChannelOptions( + currentVolume, + index, + { + isosurfaceEnabled: updatedChannels[index].isosurfaceEnabled, + isovalue: isovalue, + isosurfaceOpacity: updatedChannels[index].opacity + } + ); + + view3D.updateMaterial(currentVolume); + view3D.redraw(); }; // Histogram-based LUT adjustments @@ -885,6 +1040,28 @@ const VolumeViewer = () => { })); }; + const handleClipRegionUpdate = (newClipRegion) => { + setClipRegion(newClipRegion); + + if (currentVolume) { + view3D.updateClipRegion( + currentVolume, + newClipRegion.xmin, + newClipRegion.xmax, + newClipRegion.ymin, + newClipRegion.ymax, + newClipRegion.zmin, + newClipRegion.zmax + ); + } + }; + + // Optional: Add handler for slice changes if you need to do something when slices change + const handleSliceChange = (newSlice) => { + // Handle slice changes if needed + console.log('Slice changed:', newSlice); + }; + return (
@@ -1107,6 +1284,19 @@ const VolumeViewer = () => { + + {/* Planar Slice Player Panel */} + {cameraMode !== '3D' && ( + + + + )} {/* Controls */} @@ -1189,60 +1379,83 @@ const VolumeViewer = () => { {channels.map((channel, index) => ( -
+
+ {/* Add Channel Name Header */} +
+ {channel.name || `Channel ${index + 1}`} +
+ Enable - updateChannel(index, 'enabled', checked)} /> + updateChannelOptions(index, { enabled })} + /> - {channel.isosurfaceEnabled && ( - <> - - Isovalue - - updateChannelOptions(index, { isovalue: value })} - /> - - - - Isosurface Opacity - - updateChannelOptions(index, { isosurfaceOpacity: value })} - /> - - - - )} + + {/* Rest of the controls... */} - Diffuse Color + Isosurface - { - const rgbColor = hexToRgb(e.target.value); - updateChannel(index, 'colorD', rgbColor); - }} - /> + updateChannelOptions(index, { + isosurfaceEnabled: enabled + })} + /> + + {channel.isosurfaceEnabled && ( + <> + + Isovalue + + updateChannelOptions(index, { + isovalue: value + })} + /> + + + + Opacity + + updateChannelOptions(index, { + opacity: value / 100 + })} + /> + + + + )} + - Histogram Adjustments + Color - - - - + { + const color = hexToRgb(e.target.value); + updateChannelOptions(index, { color }); + }} + />
@@ -1288,6 +1501,7 @@ const VolumeViewer = () => { + {/* Playback */} diff --git a/src/components/appConfig.js b/src/components/appConfig.js index 9597c6b..c35d006 100644 --- a/src/components/appConfig.js +++ b/src/components/appConfig.js @@ -34,7 +34,7 @@ export const getDefaultImageInfo = () => ({ }, }); -export const CACHE_MAX_SIZE = 1_000_000_000; +export const CACHE_MAX_SIZE = 2_000_000_000; export const CONCURRENCY_LIMIT = 8; export const PREFETCH_CONCURRENCY_LIMIT = 3; export const PREFETCH_DISTANCE = [5, 5, 5, 5]; @@ -42,6 +42,10 @@ export const MAX_PREFETCH_CHUNKS = 25; export const PLAYBACK_INTERVAL = 80; export const DATARANGE_UINT8 = [0, 255]; +// Cache settings +export const QUEUE_MAX_SIZE = 10; +export const QUEUE_MAX_LOW_PRIORITY_SIZE = 4; + export const TEST_DATA = { timeSeries: { type: VolumeFileFormat.JSON, @@ -166,6 +170,11 @@ export const loaderContext = new VolumeLoaderContext( CONCURRENCY_LIMIT, PREFETCH_CONCURRENCY_LIMIT ); +// new VolumeLoaderContext( +// CACHE_MAX_SIZE, +// QUEUE_MAX_SIZE, +// QUEUE_MAX_LOW_PRIORITY_SIZE +// ); export const getDefaultChannelState = () => ({ From b4b1ef386b6d2032d5222718afd42c34fe36f814 Mon Sep 17 00:00:00 2001 From: Adrian Sebuliba Date: Thu, 7 Nov 2024 19:29:09 +0100 Subject: [PATCH 16/37] Add playback and other refinements --- src/components/PlanarSlicePlayer.js | 272 ++++++++++++++++++++++++++ src/components/viewSettingsManager.js | 120 ++++++++++++ 2 files changed, 392 insertions(+) create mode 100644 src/components/PlanarSlicePlayer.js create mode 100644 src/components/viewSettingsManager.js diff --git a/src/components/PlanarSlicePlayer.js b/src/components/PlanarSlicePlayer.js new file mode 100644 index 0000000..549fa2b --- /dev/null +++ b/src/components/PlanarSlicePlayer.js @@ -0,0 +1,272 @@ +// PlanarSlicePlayer.js +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { Button, Slider, InputNumber, Row, Col, Typography, Switch } from 'antd'; +import { + PlayCircleOutlined, + PauseCircleOutlined, + StopOutlined, + StepForwardOutlined, + StepBackwardOutlined +} from '@ant-design/icons'; + +const { Text } = Typography; + +const PlanarSlicePlayer = ({ + currentVolume, + cameraMode, + updateClipRegion, + clipRegion, + onSliceChange, +}) => { + const [isPlaying, setIsPlaying] = useState(false); + const [playbackSpeed, setPlaybackSpeed] = useState(1); + const [currentSlice, setCurrentSlice] = useState(0); + const [totalSlices, setTotalSlices] = useState(100); + const [isLooping, setIsLooping] = useState(true); + + // Use useRef for the interval to prevent issues with closure stale values + const playbackIntervalRef = useRef(null); + // Store current slice in ref to access latest value in interval + const currentSliceRef = useRef(currentSlice); + + // Update ref when slice changes + useEffect(() => { + currentSliceRef.current = currentSlice; + }, [currentSlice]); + + const getAxisInfo = useCallback(() => { + switch (cameraMode) { + case 'X': + return { + min: 'xmin', + max: 'xmax', + label: 'X', + size: currentVolume?.imageInfo?.volumeSize?.x + }; + case 'Y': + return { + min: 'ymin', + max: 'ymax', + label: 'Y', + size: currentVolume?.imageInfo?.volumeSize?.y + }; + case 'Z': + return { + min: 'zmin', + max: 'zmax', + label: 'Z', + size: currentVolume?.imageInfo?.volumeSize?.z + }; + default: + return null; + } + }, [cameraMode, currentVolume]); + + const updateSlice = useCallback((newSlice) => { + if (!currentVolume) return; + + const axisInfo = getAxisInfo(); + if (!axisInfo) return; + + const normalizedPos = newSlice / (totalSlices - 1); + const sliceThickness = 0.01; + + const newClipRegion = { + ...clipRegion, + [axisInfo.min]: Math.max(0, normalizedPos - sliceThickness/2), + [axisInfo.max]: Math.min(1, normalizedPos + sliceThickness/2) + }; + + updateClipRegion(newClipRegion); + setCurrentSlice(newSlice); + onSliceChange?.(newSlice); + }, [currentVolume, getAxisInfo, clipRegion, totalSlices, updateClipRegion, onSliceChange]); + + const stopPlayback = useCallback(() => { + if (playbackIntervalRef.current) { + clearInterval(playbackIntervalRef.current); + playbackIntervalRef.current = null; + } + setIsPlaying(false); + }, []); + + const play = useCallback(() => { + // Clear any existing interval first + stopPlayback(); + + setIsPlaying(true); + + // Create new interval with looping logic + playbackIntervalRef.current = setInterval(() => { + const nextSlice = currentSliceRef.current + 1; + + if (nextSlice >= totalSlices) { + if (isLooping) { + // If looping is enabled, go back to start + updateSlice(0); + } else { + // If not looping, stop at the end + stopPlayback(); + } + return; + } + + updateSlice(nextSlice); + }, 1000 / playbackSpeed); + }, [playbackSpeed, totalSlices, updateSlice, stopPlayback, isLooping]); + + const pause = useCallback(() => { + stopPlayback(); + }, [stopPlayback]); + + const stop = useCallback(() => { + stopPlayback(); + updateSlice(0); + }, [stopPlayback, updateSlice]); + + const forward = useCallback(() => { + const nextSlice = Math.min(currentSliceRef.current + 1, totalSlices - 1); + updateSlice(nextSlice); + }, [totalSlices, updateSlice]); + + const backward = useCallback(() => { + const prevSlice = Math.max(currentSliceRef.current - 1, 0); + updateSlice(prevSlice); + }, [updateSlice]); + + // Update total slices when volume changes + useEffect(() => { + const axisInfo = getAxisInfo(); + if (axisInfo?.size) { + setTotalSlices(axisInfo.size); + setCurrentSlice(prev => Math.min(prev, axisInfo.size - 1)); + } + }, [getAxisInfo]); + + // Cleanup effect + useEffect(() => { + return () => { + if (playbackIntervalRef.current) { + clearInterval(playbackIntervalRef.current); + } + }; + }, []); + + // Handle camera mode changes + useEffect(() => { + stopPlayback(); + setCurrentSlice(0); + }, [cameraMode, stopPlayback]); + + // Handle playback speed changes + useEffect(() => { + if (isPlaying) { + // Restart playback with new speed + play(); + } + }, [playbackSpeed, play, isPlaying]); + + const axisInfo = getAxisInfo(); + if (!axisInfo) return null; + + return ( +
+ + + {axisInfo.label} Plane Navigation + + + + + + + + + {!isPlaying ? ( + + ) : ( + + )} + + + + + + + Loop Playback: + + + + + + + + Speed (fps): + + + setPlaybackSpeed(value)} + /> + + + + + Current Slice: + + + + + +
+ ); +}; + +export default PlanarSlicePlayer; \ No newline at end of file diff --git a/src/components/viewSettingsManager.js b/src/components/viewSettingsManager.js new file mode 100644 index 0000000..cf166cf --- /dev/null +++ b/src/components/viewSettingsManager.js @@ -0,0 +1,120 @@ +// viewSettingsManager.js +import { XY_MODE, XZ_MODE, YZ_MODE, THREE_D_MODE } from './constants'; + +class ViewSettingsManager { + constructor() { + this.settings = { + viewMode: THREE_D_MODE, + channelSettings: [], + clipRegion: { + xmin: 0, + xmax: 1, + ymin: 0, + ymax: 1, + zmin: 0, + zmax: 1 + }, + density: 50, + exposure: 70, + maskAlpha: 50, + gamma: [35.0, 140.0, 255.0], + isPT: false, + lastKnown2DMode: XY_MODE, // Track the last used 2D mode + last3DSettings: null // Store 3D settings when switching to 2D + }; + } + + // Save current view settings + saveSettings(settings) { + // If switching modes, store or restore relevant settings + if (settings.viewMode) { + const isCurrently3D = this.settings.viewMode === THREE_D_MODE; + const switchingTo3D = settings.viewMode === THREE_D_MODE; + const switchingFrom3D = isCurrently3D && settings.viewMode !== THREE_D_MODE; + + if (switchingFrom3D) { + // Store 3D settings when switching to 2D + this.settings.last3DSettings = { + clipRegion: { ...this.settings.clipRegion }, + density: this.settings.density, + exposure: this.settings.exposure, + maskAlpha: this.settings.maskAlpha, + // Store any other relevant 3D settings + }; + this.settings.lastKnown2DMode = settings.viewMode; + } else if (switchingTo3D && this.settings.last3DSettings) { + // Restore 3D settings when switching back to 3D + Object.assign(settings, this.settings.last3DSettings); + } + } + + this.settings = { + ...this.settings, + ...settings + }; + } + + // Get current settings + getSettings() { + return { ...this.settings }; + } + + // Update view mode with proper state management + setViewMode(mode) { + if (mode !== THREE_D_MODE) { + this.settings.lastKnown2DMode = mode; + } + this.settings.viewMode = mode; + } + + // Get current view mode + getViewMode() { + return this.settings.viewMode; + } + + // Get last known 2D mode + getLastKnown2DMode() { + return this.settings.lastKnown2DMode; + } + + // Save channel settings + saveChannelSettings(channels) { + this.settings.channelSettings = channels.map(channel => ({ + enabled: channel.enabled, + color: channel.color, + isosurfaceEnabled: channel.isosurfaceEnabled, + isovalue: channel.isovalue, + opacity: channel.opacity + })); + } + + // Get saved channel settings + getChannelSettings() { + return [...this.settings.channelSettings]; + } + + // Convert camera mode to internal view mode + convertCameraModeToViewMode(cameraMode) { + switch (cameraMode) { + case 'X': return YZ_MODE; + case 'Y': return XZ_MODE; + case 'Z': return XY_MODE; + case '3D': return THREE_D_MODE; + default: return THREE_D_MODE; + } + } + + // Convert internal view mode to camera mode + convertViewModeToCameraMode(viewMode) { + switch (viewMode) { + case YZ_MODE: return 'X'; + case XZ_MODE: return 'Y'; + case XY_MODE: return 'Z'; + case THREE_D_MODE: return '3D'; + default: return '3D'; + } + } +} + +const viewSettingsManager = new ViewSettingsManager(); +export default viewSettingsManager; \ No newline at end of file From 9c2e8813abc97da09ec4ebd43dee51aaa17bdef8 Mon Sep 17 00:00:00 2001 From: Adrian Sebuliba Date: Thu, 7 Nov 2024 20:32:44 +0100 Subject: [PATCH 17/37] Ensure to persist mode --- src/components/VolumeViewer.js | 138 ++++++++++++++++++++------------- 1 file changed, 85 insertions(+), 53 deletions(-) diff --git a/src/components/VolumeViewer.js b/src/components/VolumeViewer.js index fd04fcd..bf35b28 100644 --- a/src/components/VolumeViewer.js +++ b/src/components/VolumeViewer.js @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState, useCallback } from 'react'; import { LoadSpec, View3d, @@ -115,6 +115,22 @@ const VolumeViewer = () => { selectedColorPalette: 0, axisClip: { x: [0, 1], y: [0, 1], z: [0, 1] }, }) + + // Add new state for persisting view settings + const [persistentSettings, setPersistentSettings] = useState({ + mode: '3D', // Default to 3D mode + channelSettings: {}, // Store channel-specific settings + density: 50, + brightness: 70, + maskAlpha: 50, + primaryRay: 1, + secondaryRay: 1, + clipRegion: { + xmin: 0, xmax: 1, + ymin: 0, ymax: 1, + zmin: 0, zmax: 1 + } + }); const [isoSurfaceSettings, setIsoSurfaceSettings] = useState({ isosurfaceOpacityMax: ISOSURFACE_OPACITY_SLIDER_MAX, @@ -171,26 +187,26 @@ const VolumeViewer = () => { } volumeRef.current = volume; - - // 1. First, set up volume in viewer view3D.removeAllVolumes(); - // 2. Initialize channels with default colors + // Initialize channels with persisted settings if available const channelNames = volume.imageInfo.channelNames || []; const newChannels = channelNames.map((name, index) => { + const persistedChannel = persistentSettings.channelSettings[index] || {}; const defaultColor = PRESET_COLORS_0[index % PRESET_COLORS_0.length]; + return { name, - enabled: index < 3, // First 3 channels enabled by default - color: defaultColor, - isosurfaceEnabled: false, - isovalue: 128, - opacity: 1.0, + enabled: persistedChannel.enabled ?? (index < 3), + color: persistedChannel.color || defaultColor, + isosurfaceEnabled: persistedChannel.isosurfaceEnabled ?? false, + isovalue: persistedChannel.isovalue ?? 128, + opacity: persistedChannel.opacity ?? 1.0, lut: ["p50", "p98"] }; }); - // 3. Add volume with initial channel settings + // Add volume with persisted settings view3D.addVolume(volume, { channels: newChannels.map(ch => ({ enabled: ch.enabled, @@ -201,6 +217,19 @@ const VolumeViewer = () => { })) }); + // Apply persisted view mode and settings + setCameraMode(persistentSettings.mode); + view3D.setCameraMode(persistentSettings.mode); + + // Apply other persisted settings + updateSetting('density', persistentSettings.density); + updateSetting('brightness', persistentSettings.brightness); + updateSetting('maskAlpha', persistentSettings.maskAlpha); + setPrimaryRay(persistentSettings.primaryRay); + setSecondaryRay(persistentSettings.secondaryRay); + setClipRegion(persistentSettings.clipRegion); + + // 4. Apply initial volume settings // Mask alpha const alphaValue = 1 - (settings.maskAlpha / 100); @@ -220,8 +249,7 @@ const VolumeViewer = () => { const x = (mid - min) / diff; const scale = 4 * x * x; view3D.setGamma(volume, min, scale, max); - - // 5. Initialize LUTs for each channel + channelNames.forEach((_, index) => { if (volume.getHistogram) { const histogram = volume.getHistogram(index); @@ -250,8 +278,8 @@ const VolumeViewer = () => { } } }); - - // 6. Check for and set up segmentation mask + + // Initialize masks and LUTs const segIndex = channelNames.findIndex(name => name === CELL_SEGMENTATION_CHANNEL_NAME ); @@ -259,12 +287,10 @@ const VolumeViewer = () => { view3D.setVolumeChannelAsMask(volume, segIndex); } - // 7. Update viewer state view3D.setVolumeRenderMode(settings.pathTrace ? RENDERMODE_PATHTRACE : RENDERMODE_RAYMARCH); view3D.updateActiveChannels(volume); view3D.updateLuts(volume); - // 8. Store state setChannels(newChannels); setCurrentVolume(volume); view3D.redraw(); @@ -790,6 +816,10 @@ const VolumeViewer = () => { const setCameraModeHandler = (mode) => { setCameraMode(mode); + setPersistentSettings(prev => ({ + ...prev, + mode: mode + })); }; const toggleTurntable = () => { @@ -1062,6 +1092,44 @@ const VolumeViewer = () => { console.log('Slice changed:', newSlice); }; + // Function to save current view settings + const saveCurrentSettings = useCallback(() => { + setPersistentSettings(prev => ({ + ...prev, + mode: cameraMode, + density: settings.density, + brightness: settings.brightness, + maskAlpha: settings.maskAlpha, + primaryRay: primaryRay, + secondaryRay: secondaryRay, + clipRegion: clipRegion, + channelSettings: channels.reduce((acc, channel, index) => { + acc[index] = { + enabled: channel.enabled, + color: channel.color, + isosurfaceEnabled: channel.isosurfaceEnabled, + isovalue: channel.isovalue, + opacity: channel.opacity + }; + return acc; + }, {}) + })); + }, [ + cameraMode, settings, primaryRay, secondaryRay, + clipRegion, channels + ]); + + // Add effect to save settings when they change + useEffect(() => { + if (currentVolume) { + saveCurrentSettings(); + } + }, [ + cameraMode, settings.density, settings.brightness, + settings.maskAlpha, primaryRay, secondaryRay, + clipRegion, channels, saveCurrentSettings + ]); + return (
@@ -1287,7 +1355,7 @@ const VolumeViewer = () => { {/* Planar Slice Player Panel */} {cameraMode !== '3D' && ( - + { - {/* Playback */} - - - - - - - - - Frame - - - - - - Z Slice - - - - - - - -
From 8775d8d30f4f5517a85574def628315c02db957c Mon Sep 17 00:00:00 2001 From: Adrian Sebuliba Date: Tue, 12 Nov 2024 11:09:01 +0100 Subject: [PATCH 18/37] Ensure to propelry tab the settings and data --- src/components/SidebarTabs.js | 604 ++++++++++++++++++ src/components/VolumeViewer.js | 1076 +++++++++++++++++++------------- 2 files changed, 1234 insertions(+), 446 deletions(-) create mode 100644 src/components/SidebarTabs.js diff --git a/src/components/SidebarTabs.js b/src/components/SidebarTabs.js new file mode 100644 index 0000000..03992c5 --- /dev/null +++ b/src/components/SidebarTabs.js @@ -0,0 +1,604 @@ +import React, { useState } from 'react'; +import { Layout, Tabs, Collapse, Switch, Slider, InputNumber, Row, Col, Select, Input, Button, Tooltip } from 'antd'; +import { + SettingOutlined, + FolderOutlined, + EyeOutlined, + CameraOutlined, + ControlOutlined, + BgColorsOutlined, + ApartmentOutlined, + BorderOutlined, + FileImageOutlined, + BulbOutlined // Changed from LightOutlined +} from '@ant-design/icons'; +import { PRESET_COLOR_MAP } from './constants'; + +const { Sider } = Layout; +const { TabPane } = Tabs; +const { Panel } = Collapse; +const { Option } = Select; + +export const SidebarTabs = ({ + settings, + updateSetting, + channels, + updateChannelOptions, + clipRegion, + updateClipRegion, + fileData, + handleFileSelect, + metadata, + lights, + updateLights, + currentPreset, + applyColorPreset +}) => { + const [activeKey, setActiveKey] = useState('settings'); + + // Helper function to format color for input + const rgbToHex = (r, g, b) => { + const toHex = x => ('0' + Math.round(x).toString(16)).slice(-2); + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; + }; + + // Helper function to parse hex color + const hexToRgb = (hex) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result ? [ + parseInt(result[1], 16), + parseInt(result[2], 16), + parseInt(result[3], 16) + ] : null; + }; + + return ( + + + {/* Settings Tab */} + + + + } + key="settings" + > + + {/* Render Settings */} + + Render Mode + + } + key="render" + > + + Path Trace + + updateSetting('pathTrace', val)} + /> + + + + Density + + updateSetting('density', val)} + /> + + + + Brightness + + updateSetting('brightness', val)} + /> + + + + Mask Alpha + + updateSetting('maskAlpha', val)} + /> + + + + + {/* Camera Settings */} + + Camera + + } + key="camera" + > + + Mode + + + + + + FOV + + updateSetting('fov', val)} + style={{ width: '100%' }} + /> + + + + Focal Distance + + updateSetting('focalDistance', val)} + style={{ width: '100%' }} + /> + + + + Aperture + + updateSetting('aperture', val)} + style={{ width: '100%' }} + /> + + + + + {/* Display Settings */} + + Display + + } + key="display" + > + + Show Axis + + updateSetting('showAxis', val)} + /> + + + + Show Scale Bar + + updateSetting('showScaleBar', val)} + /> + + + + Show Bounding Box + + updateSetting('showBoundingBox', val)} + /> + + + + Background Color + + { + const rgb = hexToRgb(e.target.value); + if (rgb) updateSetting('backgroundColor', rgb); + }} + /> + + + + + {/* Channel Settings */} + + Channels + + } + key="channels" + > + + Color Preset + + + + + {channels.map((channel, index) => ( +
+

{channel.name || `Channel ${index + 1}`}

+ + Enable + + updateChannelOptions(index, { enabled: val })} + /> + + + + Opacity + + updateChannelOptions(index, { opacity: val })} + /> + + + + Color + + { + const rgb = hexToRgb(e.target.value); + if (rgb) updateChannelOptions(index, { color: rgb }); + }} + /> + + + + Isosurface + + updateChannelOptions(index, { + isosurfaceEnabled: val + })} + /> + + + {channel.isosurfaceEnabled && ( + <> + + Isovalue + + updateChannelOptions(index, { + isovalue: val + })} + /> + + + + Surface Opacity + + updateChannelOptions(index, { + isosurfaceOpacity: val + })} + /> + + + + )} +
+ ))} +
+ + {/* Clipping Settings */} + + Clipping + + } + key="clipping" + > + {['X', 'Y', 'Z'].map(axis => ( +
+

{axis} Axis Clipping

+ { + updateClipRegion({ + ...clipRegion, + [`${axis.toLowerCase()}min`]: min, + [`${axis.toLowerCase()}max`]: max + }); + }} + /> +
+ ))} +
+ + {/* Lighting Settings */} + + Lighting + + } + key="lighting" + > + {/* Sky Light Controls */} +
+

Sky Light

+ {['Top', 'Middle', 'Bottom'].map((position, index) => ( +
+
{position}
+ + Intensity + + updateLights('sky', position.toLowerCase(), 'intensity', val)} + /> + + + + Color + + { + const rgb = hexToRgb(e.target.value); + if (rgb) updateLights('sky', position.toLowerCase(), 'color', rgb); + }} + /> + + +
+ ))} +
+ + {/* Area Light Controls */} +
+

Area Light

+ + Intensity + + updateLights('area', null, 'intensity', val)} + /> + + + + Color + + { + const rgb = hexToRgb(e.target.value); + if (rgb) updateLights('area', null, 'color', rgb); + }} + /> + + + + Direction (θ) + + updateLights('area', null, 'theta', val * (Math.PI / 180))} + /> + + + + Direction (φ) + + updateLights('area', null, 'phi', val * (Math.PI / 180))} + /> + + +
+
+ + {/* Metadata Panel */} + + Metadata + + } + key="metadata" + > + {metadata ? ( +
+ + Name: + {metadata.name} + + + Dimensions: + + {`${metadata.dimensions.x} × ${metadata.dimensions.y} × ${metadata.dimensions.z}`} + + + + Channels: + {metadata.dimensions.channels} + + + Pixel Size: + + {metadata.pixelSize.map(size => size.toFixed(2)).join(' × ')} {metadata.spatialUnit} + + +
+ ) : ( +
No volume loaded
+ )} +
+
+
+ + {/* Files Tab */} + + + + } + key="files" + > +
+ + {Object.entries(fileData).map(([category, files]) => ( + + {category} + + } + key={category} + > + {files.map(file => ( +
handleFileSelect(category, file)} + > + {file} +
+ ))} +
+ ))} +
+
+
+
+ + +
+ ); +}; + +export default SidebarTabs; \ No newline at end of file diff --git a/src/components/VolumeViewer.js b/src/components/VolumeViewer.js index bf35b28..74f57fd 100644 --- a/src/components/VolumeViewer.js +++ b/src/components/VolumeViewer.js @@ -14,12 +14,42 @@ import { import * as THREE from 'three'; import { loaderContext, PREFETCH_DISTANCE, MAX_PREFETCH_CHUNKS, myState } from "./appConfig"; import { useConstructor } from './useConstructor'; -import { Slider, Switch, InputNumber, Row, Col, Collapse, Layout, Button, Select, Input, Tooltip, Spin } from 'antd'; +import { + Layout, + Tabs, + Collapse, + Switch, + Slider, + InputNumber, + Row, + Col, + Button, + Select, + Input, + Spin + } from 'antd'; + import { + Settings, + Files, + Info, + Sun, + Camera, + Eye, + Sliders, + Box, + Move3d, + Palette, + Scissors, + Maximize2, + Image, + Lightbulb, + } from 'lucide-react'; import axios from 'axios'; import { API_URL } from '../config'; // Importing API_URL from your config import { ALPHA_MASK_SLIDER_3D_DEFAULT, BRIGHTNESS_SLIDER_LEVEL_DEFAULT, CELL_SEGMENTATION_CHANNEL_NAME, DENSITY_SLIDER_LEVEL_DEFAULT, ISOSURFACE_OPACITY_SLIDER_MAX, LEVELS_SLIDER_DEFAULT, LUT_MAX_PERCENTILE, LUT_MIN_PERCENTILE, PRESET_COLORS_0, PRESET_COLOR_MAP, VIEWER_3D_SETTING } from './constants'; import PlanarSlicePlayer from './PlanarSlicePlayer'; + // Utility function to concatenate arrays const concatenateArrays = (arrays) => { const totalLength = arrays.reduce((acc, arr) => acc + arr.length, 0); @@ -33,8 +63,7 @@ const concatenateArrays = (arrays) => { } const { Sider, Content } = Layout; -const { Panel } = Collapse; -const { Option } = Select; +const { TabPane } = Tabs; const { Vector3 } = THREE; const VolumeViewer = () => { @@ -185,9 +214,19 @@ const VolumeViewer = () => { console.error("Invalid volume data"); return; } + + console.log("Volume created with info:", volume.imageInfo); volumeRef.current = volume; view3D.removeAllVolumes(); + + // Log the dimensions specifically + console.log("Dimensions:", { + sizeX: volume.imageInfo.sizeX, + sizeY: volume.imageInfo.sizeY, + sizeZ: volume.imageInfo.sizeZ, + sizeC: volume.imageInfo.sizeC + }); // Initialize channels with persisted settings if available const channelNames = volume.imageInfo.channelNames || []; @@ -299,6 +338,7 @@ const VolumeViewer = () => { const loadVolume = async (loadSpec, loader) => { const volume = await loader.createVolume(loadSpec, onChannelDataArrived); + console.log("Loaded volume metadata:", volume.imageInfo); onVolumeCreated(volume); console.log(volume.imageInfo, volume.imageInfo.times) @@ -1131,487 +1171,631 @@ const VolumeViewer = () => { ]); return ( - -
+ +
- + + {/* Settings Tab */} + Settings} + key="settings" + > + {/* Render Mode */} - - - Path Trace - - setIsPT(checked)} /> - - - + Render Mode} key="renderMode"> + + Path Trace + + setIsPT(checked)} /> + + + - {/* Density */} - - updateSetting('density', val)} - /> - + {/* Density Settings */} + Density} key="density"> + updateSetting('density', val)} + /> + {/* Mask Alpha */} - - updateSetting('maskAlpha', val)} - /> - + Mask Alpha} key="maskAlpha"> + updateSetting('maskAlpha', val)} + /> + {/* Ray Step Sizes */} - + Ray Steps} key="raySteps"> +
+ - - +
+
+ - +
+
{/* Exposure */} - - updateSetting('brightness', val)} - /> - + Exposure} key="exposure"> + updateSetting('brightness', val)} + /> + {/* Camera Settings */} - - - FOV - - - - - - Focal Distance - - - - - - Aperture - - - - - + Camera Settings} key="camera"> + + FOV + + + + + + Focal Distance + + + + + + Aperture + + + + + {/* Sampling Rate */} - - - Pixel Sampling Rate - - - - - + Sampling Rate} key="sampling"> + + Pixel Sampling Rate + + + + + - {/* Lights */} - - {lights.map((light, index) => ( -
- - Intensity - - { - const updatedLights = [...lights]; - updatedLights[index].mColor.setScalar(value / 255); - setLights(updatedLights); - }} - /> - - - - Theta - - { - const updatedLights = [...lights]; - updatedLights[index].mTheta = value * (Math.PI / 180); - setLights(updatedLights); - }} - /> - - - - Phi - - { - const updatedLights = [...lights]; - updatedLights[index].mPhi = value * (Math.PI / 180); - setLights(updatedLights); - }} - /> - - -
- ))} -
- - - - - - Top Intensity - - updateSkyLight('top', value, skyTopColor)} - /> - - - - Top Color - - updateSkyLight('top', skyTopIntensity, e.target.value.match(/[A-Za-z0-9]{2}/g).map(v => parseInt(v, 16)))} - /> - - - {/* Repeat for Mid and Bottom with appropriate state variables */} - - - - Intensity - - updateAreaLight(value, lightColor, lightTheta, lightPhi)} - /> - - - - Color - - updateAreaLight(lightIntensity, e.target.value.match(/[A-Za-z0-9]{2}/g).map(v => parseInt(v, 16)), lightTheta, lightPhi)} - /> - - - - Theta (deg) - - updateAreaLight(lightIntensity, lightColor, value, lightPhi)} - /> - - - - Phi (deg) - - updateAreaLight(lightIntensity, lightColor, lightTheta, value)} - /> - - - - - - - {/* Camera Mode */} - - - - - {/* Planar Slice Player Panel */} - {cameraMode !== '3D' && ( - - - - )} - - {/* Controls */} - - - - - - - Background Color - - c.toString(16).padStart(2, '0')).join('')}`} - onChange={(e) => updateBackgroundColor(e.target.value.match(/.{1,2}/g).map(c => parseInt(c, 16)))} /> - - - - Bounding Box Color - - c.toString(16).padStart(2, '0')).join('')}`} - onChange={(e) => updateBoundingBoxColor(e.target.value.match(/.{1,2}/g).map(c => parseInt(c, 16)))} /> - - - - - - - - {/* Gamma */} - - - Min + {/* Lighting Settings */} + Lighting} key="lighting"> + + + + Top Intensity - updateSetting('levels', [value, settings.levels[1], settings.levels[2]])} - /> + updateSkyLight('top', value, skyTopColor)} + /> - - - Mid + + + Top Color - updateSetting('levels', [settings.levels[0], value, settings.levels[2]])} - /> + updateSkyLight('top', skyTopIntensity, hexToRgb(e.target.value))} + /> - - - Max + + {/* Mid Light Controls */} + + Mid Intensity - updateSetting('levels', [settings.levels[0], settings.levels[1], value])} - /> + updateSkyLight('mid', value, skyMidColor)} + /> - - - - {/* Channels */} - - - Color Preset - - + + + Mid Color + + updateSkyLight('mid', skyMidIntensity, hexToRgb(e.target.value))} + /> - - {channels.map((channel, index) => ( -
- {/* Add Channel Name Header */} -
- {channel.name || `Channel ${index + 1}`} -
- - - Enable - - updateChannelOptions(index, { enabled })} - /> - - - - {/* Rest of the controls... */} - - Isosurface - - updateChannelOptions(index, { - isosurfaceEnabled: enabled - })} - /> - - - - {channel.isosurfaceEnabled && ( - <> - - Isovalue - - updateChannelOptions(index, { - isovalue: value - })} - /> - - - - Opacity - - updateChannelOptions(index, { - opacity: value / 100 - })} - /> - - - - )} - - - Color - - { - const color = hexToRgb(e.target.value); - updateChannelOptions(index, { color }); - }} - /> - - -
- ))} -
- - {/* Clip Region */} - - - X Min + + {/* Bottom Light Controls */} + + Bottom Intensity - updateClipRegion('xmin', value)} /> + updateSkyLight('bot', value, skyBotColor)} + /> - - - X Max + + + Bottom Color - updateClipRegion('xmax', value)} /> + updateSkyLight('bot', skyBotIntensity, hexToRgb(e.target.value))} + /> - - - Y Min + + + + + Intensity - updateClipRegion('ymin', value)} /> + updateAreaLight(value, lightColor, lightTheta, lightPhi)} + /> - - - Y Max + + + Color - updateClipRegion('ymax', value)} /> + updateAreaLight(lightIntensity, hexToRgb(e.target.value), lightTheta, lightPhi)} + /> - - - Z Min + + + Theta (deg) - updateClipRegion('zmin', value)} /> + updateAreaLight(lightIntensity, lightColor, value, lightPhi)} + /> - - - Z Max + + + Phi (deg) - updateClipRegion('zmax', value)} /> + updateAreaLight(lightIntensity, lightColor, lightTheta, value)} + /> - - - + + +
+ -
+ {/* Camera Mode */} + Camera Mode} key="cameraMode"> + + -
- - {Object.keys(fileData).map((bodyPart) => ( - {bodyPart}} - key={bodyPart} - > - {fileData[bodyPart].map((file) => ( -
handleFileSelect(bodyPart, file)} - > - - 📄 - - - {file} - - {/* Tooltip for long file names */} - - {file} - -
- ))} -
- ))} -
-
-
-
+ {/* Controls */} + View Controls} key="controls"> + + + + + + Background Color + + updateBackgroundColor(hexToRgb(e.target.value))} + /> + + + + Bounding Box Color + + updateBoundingBoxColor(hexToRgb(e.target.value))} + /> + + + + + + - - -
-
-
-
- ); + {/* Gamma */} + + + Min + + updateSetting('levels', [value, settings.levels[1], settings.levels[2]])} + /> + + + + Mid + + updateSetting('levels', [settings.levels[0], value, settings.levels[2]])} + /> + + + + Max + + updateSetting('levels', [settings.levels[0], settings.levels[1], value])} + /> + + + + + {/* Channels */} + Channels} key="channels"> + + Color Preset + + + + + {channels.map((channel, index) => ( +
+
+ {channel.name || `Channel ${index + 1}`} +
+ + Enable + + updateChannelOptions(index, { enabled })} + /> + + + Isosurface + + updateChannelOptions(index, { + isosurfaceEnabled: enabled + })} + /> + + + {channel.isosurfaceEnabled && ( + <> + + Isovalue + + updateChannelOptions(index, { + isovalue: value + })} + /> + + + + Opacity + + updateChannelOptions(index, { + opacity: value / 100 + })} + /> + + + + )} + + Color + + { + const color = hexToRgb(e.target.value); + updateChannelOptions(index, { color }); + }} + /> + + +
+ ))} +
+ + {/* Clip Region */} + Clip Region} key="clipRegion"> + + X Min + + updateClipRegion('xmin', value)} /> + + + + X Max + + updateClipRegion('xmax', value)} /> + + + + Y Min + + updateClipRegion('ymin', value)} /> + + + + Y Max + + updateClipRegion('ymax', value)} /> + + + + Z Min + + updateClipRegion('zmin', value)} /> + + + + Z Max + + updateClipRegion('zmax', value)} /> + + + + + {/* Planar Slice Player */} + {cameraMode !== '3D' && ( + + + + )} + + + + {/* Files Tab */} + Files} key="files"> + + {Object.keys(fileData).map((bodyPart) => ( + {bodyPart}} + key={bodyPart} + > + {fileData[bodyPart].map((file) => ( +
handleFileSelect(bodyPart, file)} + > + 📄 + {file} + {file} +
+ ))} +
+ ))} +
+
+ + {/* Metadata Tab */} + Metadata} key="metadata"> + {currentVolume && ( +
+

Volume Information

+
+ + + {currentVolume.loader?.url?.split('/').pop() || currentVolume.name} + +
+ {currentVolume.imageInfo && ( + <> +
+ + + {currentVolume.imageInfo.volumeSize ? + `${Math.round(currentVolume.imageInfo.volumeSize.x)} × ${Math.round(currentVolume.imageInfo.volumeSize.y)} × ${Math.round(currentVolume.imageInfo.volumeSize.z)}` : + 'N/A'} + +
+
+ + + {currentVolume.physicalSize ? + `${Math.round(currentVolume.physicalSize.x)} × ${Math.round(currentVolume.physicalSize.y)} × ${Math.round(currentVolume.physicalSize.z)} ${currentVolume.physicalUnitSymbol || 'units'}` : + 'N/A'} + +
+
+ + {currentVolume.imageMetadata?.Channels || 'N/A'} +
+ {currentVolume.imageMetadata && ( + <> +
+ + + {currentVolume.imageMetadata.Dimensions ? + `${currentVolume.imageMetadata.Dimensions.x} × ${currentVolume.imageMetadata.Dimensions.y} × ${currentVolume.imageMetadata.Dimensions.z}` : + 'N/A'} + +
+
+ + + {currentVolume.imageMetadata['Physical size per pixel'] ? + `${currentVolume.imageMetadata['Physical size per pixel'].x} × ${currentVolume.imageMetadata['Physical size per pixel'].y} × ${currentVolume.imageMetadata['Physical size per pixel'].z}` : + 'N/A'} + +
+
+ + + {currentVolume.imageMetadata['Time series frames'] || 'N/A'} + +
+ + )} + + )} +
+ )} +
+ + +
+ + + +
+
+
+ + +
+ ); + } export default VolumeViewer; \ No newline at end of file From 906600aaed7a80acbcaf3cd4ebd9de06a020fc4d Mon Sep 17 00:00:00 2001 From: Adrian Sebuliba Date: Tue, 12 Nov 2024 11:14:24 +0100 Subject: [PATCH 19/37] Add mising ddependencies --- package-lock.json | 9 +++++++++ package.json | 1 + 2 files changed, 10 insertions(+) diff --git a/package-lock.json b/package-lock.json index e89f453..8c3be53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "antd": "^5.20.0", "axios": "^1.7.7", "dat.gui": "^0.7.9", + "lucide-react": "^0.456.0", "react": "^18.3.1", "react-color": "^2.19.3", "react-dom": "^18.3.1", @@ -12903,6 +12904,14 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.456.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.456.0.tgz", + "integrity": "sha512-DIIGJqTT5X05sbAsQ+OhA8OtJYyD4NsEMCA/HQW/Y6ToPQ7gwbtujIoeAaup4HpHzV35SQOarKAWH8LYglB6eA==", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", diff --git a/package.json b/package.json index c1d5011..7d7b5e3 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "antd": "^5.20.0", "axios": "^1.7.7", "dat.gui": "^0.7.9", + "lucide-react": "^0.456.0", "react": "^18.3.1", "react-color": "^2.19.3", "react-dom": "^18.3.1", From ca6ccf56be58d2f1528c7a8aafd534f1dfa10206 Mon Sep 17 00:00:00 2001 From: Adrian Sebuliba Date: Tue, 12 Nov 2024 19:07:52 +0100 Subject: [PATCH 20/37] Ensure that all the tabs fit properly --- src/components/VolumeViewer.js | 233 +++++++++++++++++++++------------ 1 file changed, 146 insertions(+), 87 deletions(-) diff --git a/src/components/VolumeViewer.js b/src/components/VolumeViewer.js index 74f57fd..10c0f49 100644 --- a/src/components/VolumeViewer.js +++ b/src/components/VolumeViewer.js @@ -1170,11 +1170,41 @@ const VolumeViewer = () => { clipRegion, channels, saveCurrentSettings ]); + // Utility function to round to 1 significant figure + const roundToSignificantFigure = (num, sigFigs = 1) => { + if (num === 0) return 0; + const scale = Math.pow(10, Math.floor(Math.log10(Math.abs(num))) + 1 - sigFigs); + return Math.round(num / scale) * scale; + }; + return (
- + + {/* Files Tab */} + Files} key="files"> + + {Object.keys(fileData).map((bodyPart) => ( + {bodyPart}} + key={bodyPart} + > + {fileData[bodyPart].map((file) => ( +
handleFileSelect(bodyPart, file)} + > + 📄 + {file} + {file} +
+ ))} +
+ ))} +
+
{/* Settings Tab */} Settings} @@ -1566,6 +1596,59 @@ const VolumeViewer = () => { /> + {/* Negative margins to counter parent padding */} + +
+ {/* First row */} +
+ + +
+ {/* Second row */} +
+ + +
+
+ +
))} @@ -1631,94 +1714,70 @@ const VolumeViewer = () => { - {/* Files Tab */} - Files} key="files"> - - {Object.keys(fileData).map((bodyPart) => ( - {bodyPart}} - key={bodyPart} - > - {fileData[bodyPart].map((file) => ( -
handleFileSelect(bodyPart, file)} - > - 📄 - {file} - {file} -
- ))} -
- ))} -
-
- {/* Metadata Tab */} - Metadata} key="metadata"> - {currentVolume && ( -
-

Volume Information

-
- - - {currentVolume.loader?.url?.split('/').pop() || currentVolume.name} - -
- {currentVolume.imageInfo && ( - <> -
- - - {currentVolume.imageInfo.volumeSize ? - `${Math.round(currentVolume.imageInfo.volumeSize.x)} × ${Math.round(currentVolume.imageInfo.volumeSize.y)} × ${Math.round(currentVolume.imageInfo.volumeSize.z)}` : - 'N/A'} - -
-
- - - {currentVolume.physicalSize ? - `${Math.round(currentVolume.physicalSize.x)} × ${Math.round(currentVolume.physicalSize.y)} × ${Math.round(currentVolume.physicalSize.z)} ${currentVolume.physicalUnitSymbol || 'units'}` : - 'N/A'} - -
-
- - {currentVolume.imageMetadata?.Channels || 'N/A'} -
- {currentVolume.imageMetadata && ( - <> -
- - - {currentVolume.imageMetadata.Dimensions ? - `${currentVolume.imageMetadata.Dimensions.x} × ${currentVolume.imageMetadata.Dimensions.y} × ${currentVolume.imageMetadata.Dimensions.z}` : - 'N/A'} - -
-
- - - {currentVolume.imageMetadata['Physical size per pixel'] ? - `${currentVolume.imageMetadata['Physical size per pixel'].x} × ${currentVolume.imageMetadata['Physical size per pixel'].y} × ${currentVolume.imageMetadata['Physical size per pixel'].z}` : - 'N/A'} - -
-
- - - {currentVolume.imageMetadata['Time series frames'] || 'N/A'} - -
- - )} - - )} + Info} key="metadata"> + {currentVolume && ( +
+

Volume Information

+
+ + + {currentVolume.loader?.url?.split('/').pop() || currentVolume.name} +
- )} - + {currentVolume.imageInfo && ( + <> +
+ + + {currentVolume.imageInfo.volumeSize ? + `${roundToSignificantFigure(currentVolume.imageInfo.volumeSize.x)} × ${roundToSignificantFigure(currentVolume.imageInfo.volumeSize.y)} × ${roundToSignificantFigure(currentVolume.imageInfo.volumeSize.z)}` : + 'N/A'} + +
+
+ + + {currentVolume.physicalSize ? + `${roundToSignificantFigure(currentVolume.physicalSize.x)} × ${roundToSignificantFigure(currentVolume.physicalSize.y)} × ${roundToSignificantFigure(currentVolume.physicalSize.z)} ${currentVolume.physicalUnitSymbol || 'units'}` : + 'N/A'} + +
+
+ + {currentVolume.imageMetadata?.Channels || 'N/A'} +
+ {currentVolume.imageMetadata && ( + <> +
+ + + {currentVolume.imageMetadata.Dimensions ? + `${roundToSignificantFigure(currentVolume.imageMetadata.Dimensions.x)} × ${roundToSignificantFigure(currentVolume.imageMetadata.Dimensions.y)} × ${roundToSignificantFigure(currentVolume.imageMetadata.Dimensions.z)}` : + 'N/A'} + +
+
+ + + {currentVolume.imageMetadata['Physical size per pixel'] ? + `${roundToSignificantFigure(currentVolume.imageMetadata['Physical size per pixel'].x)} × ${roundToSignificantFigure(currentVolume.imageMetadata['Physical size per pixel'].y)} × ${roundToSignificantFigure(currentVolume.imageMetadata['Physical size per pixel'].z)}` : + 'N/A'} + +
+
+ + + {currentVolume.imageMetadata['Time series frames'] || 'N/A'} + +
+ + )} + + )} +
+ )} +
From b44b7bbcbebad632ec2219ccdcb79b2bde520d02 Mon Sep 17 00:00:00 2001 From: Adrian Sebuliba Date: Tue, 12 Nov 2024 19:12:20 +0100 Subject: [PATCH 21/37] Ensure minification on build --- deploy.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deploy.sh b/deploy.sh index 5dff694..44bd118 100755 --- a/deploy.sh +++ b/deploy.sh @@ -47,8 +47,8 @@ cd "$FRONTEND_DIR" npm install # Building the React Application -echo "Building React app..." -npm run build +echo "Building and minifying the React app for production..." +NODE_ENV=production npm run build # Check if the web server root directory exists, if not, create it if [ ! -d "$WEB_SERVER_ROOT" ]; then From eda59e7575207c26d9203a4dbbaf2b04a5a73ebd Mon Sep 17 00:00:00 2001 From: Adrian Sebuliba Date: Mon, 18 Nov 2024 15:22:11 +0100 Subject: [PATCH 22/37] Ensure that the player is positioned on canvas --- package-lock.json | 477 +++- package.json | 9 +- src/App.css | 903 +++--- src/components/Footer.js | 44 +- src/components/PlanarSlicePlayer.js | 505 ++-- src/components/PlanarSliceWindow.js | 270 ++ src/components/ResizablePlanarPlayer.js | 311 ++ src/components/VolumeViewer.js | 3430 ++++++++++++----------- 8 files changed, 3640 insertions(+), 2309 deletions(-) create mode 100644 src/components/PlanarSliceWindow.js create mode 100644 src/components/ResizablePlanarPlayer.js diff --git a/package-lock.json b/package-lock.json index 8c3be53..f26995e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,10 +19,17 @@ "react": "^18.3.1", "react-color": "^2.19.3", "react-dom": "^18.3.1", + "react-rnd": "^10.4.13", "react-router-dom": "^6.27.0", "react-scripts": "5.0.1", "three": "^0.167.1", - "web-vitals": "^2.1.4" + "web-vitals": "^2.1.4", + "yarn": "^1.22.22" + }, + "devDependencies": { + "eslint": "^8.57.1", + "prettier": "^3.3.3", + "prettier-eslint": "^16.3.0" } }, "node_modules/@adobe/css-tools": { @@ -2499,20 +2506,20 @@ } }, "node_modules/@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", "deprecated": "Use @eslint/config-array instead", "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", + "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" }, @@ -6511,6 +6518,14 @@ "wrap-ansi": "^7.0.0" } }, + "node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "engines": { + "node": ">=6" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -7897,15 +7912,16 @@ } }, "node_modules/eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.0", - "@humanwhocodes/config-array": "^0.11.14", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", @@ -9495,6 +9511,27 @@ "resolved": "https://registry.npmjs.org/harmony-reflect/-/harmony-reflect-1.6.2.tgz", "integrity": "sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==" }, + "node_modules/has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==", + "dev": true, + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-ansi/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/has-bigints": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", @@ -12877,6 +12914,84 @@ "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==" }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "dev": true, + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, + "node_modules/loglevel-colored-level-prefix": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/loglevel-colored-level-prefix/-/loglevel-colored-level-prefix-1.0.0.tgz", + "integrity": "sha512-u45Wcxxc+SdAlh4yeF/uKlC1SPUPCy0gullSNKXod5I4bmifzk+Q4lSLExNEVn19tGaJipbZ4V4jbFn79/6mVA==", + "dev": true, + "dependencies": { + "chalk": "^1.1.3", + "loglevel": "^1.4.1" + } + }, + "node_modules/loglevel-colored-level-prefix/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/loglevel-colored-level-prefix/node_modules/ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/loglevel-colored-level-prefix/node_modules/chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", + "dev": true, + "dependencies": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/loglevel-colored-level-prefix/node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "dev": true, + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/loglevel-colored-level-prefix/node_modules/supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -15016,6 +15131,246 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-eslint": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/prettier-eslint/-/prettier-eslint-16.3.0.tgz", + "integrity": "sha512-Lh102TIFCr11PJKUMQ2kwNmxGhTsv/KzUg9QYF2Gkw259g/kPgndZDWavk7/ycbRvj2oz4BPZ1gCU8bhfZH/Xg==", + "dev": true, + "dependencies": { + "@typescript-eslint/parser": "^6.7.5", + "common-tags": "^1.4.0", + "dlv": "^1.1.0", + "eslint": "^8.7.0", + "indent-string": "^4.0.0", + "lodash.merge": "^4.6.0", + "loglevel-colored-level-prefix": "^1.0.0", + "prettier": "^3.0.1", + "pretty-format": "^29.7.0", + "require-relative": "^0.8.7", + "typescript": "^5.2.2", + "vue-eslint-parser": "^9.1.0" + }, + "engines": { + "node": ">=16.10.0" + }, + "peerDependencies": { + "prettier-plugin-svelte": "^3.0.0", + "svelte-eslint-parser": "*" + }, + "peerDependenciesMeta": { + "prettier-plugin-svelte": { + "optional": true + }, + "svelte-eslint-parser": { + "optional": true + } + } + }, + "node_modules/prettier-eslint/node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/prettier-eslint/node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true + }, + "node_modules/prettier-eslint/node_modules/@typescript-eslint/parser": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", + "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/prettier-eslint/node_modules/@typescript-eslint/scope-manager": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/prettier-eslint/node_modules/@typescript-eslint/types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "dev": true, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/prettier-eslint/node_modules/@typescript-eslint/typescript-estree": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/prettier-eslint/node_modules/@typescript-eslint/visitor-keys": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/prettier-eslint/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prettier-eslint/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/prettier-eslint/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/prettier-eslint/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/prettier-eslint/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true + }, + "node_modules/prettier-eslint/node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -15831,6 +16186,15 @@ "react-dom": ">=16.9.0" } }, + "node_modules/re-resizable": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.10.0.tgz", + "integrity": "sha512-hysSK0xmA5nz24HBVztlk4yCqCLCvS32E6ZpWxVKop9x3tqCa4yAj1++facrmkOf62JsJHjmjABdKxXofYioCw==", + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -16066,6 +16430,19 @@ "react": "^18.3.1" } }, + "node_modules/react-draggable": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.6.tgz", + "integrity": "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==", + "dependencies": { + "clsx": "^1.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, "node_modules/react-error-overlay": { "version": "6.0.11", "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", @@ -16084,6 +16461,25 @@ "node": ">=0.10.0" } }, + "node_modules/react-rnd": { + "version": "10.4.13", + "resolved": "https://registry.npmjs.org/react-rnd/-/react-rnd-10.4.13.tgz", + "integrity": "sha512-Vgbf0iihspcQ6nkaFhpOGWfmnuVbhkhoB0hBbYl8aRDA4horsQHESc4E1z7O/P27kFFjK2aqM0u5CGzfr9gEZA==", + "dependencies": { + "re-resizable": "6.10.0", + "react-draggable": "4.4.6", + "tslib": "2.6.2" + }, + "peerDependencies": { + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + } + }, + "node_modules/react-rnd/node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + }, "node_modules/react-router": { "version": "6.27.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.27.0.tgz", @@ -16396,6 +16792,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-relative": { + "version": "0.8.7", + "resolved": "https://registry.npmjs.org/require-relative/-/require-relative-0.8.7.tgz", + "integrity": "sha512-AKGr4qvHiryxRb19m3PsLRGuKVAbJLUD7E6eOaHkfKhwc+vSgVOCY5xNvm9EkolBKTOf0GrQAZKLimOCz81Khg==", + "dev": true + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -18138,6 +18540,18 @@ "resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz", "integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==" }, + "node_modules/ts-api-utils": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.0.tgz", + "integrity": "sha512-032cPxaEKwM+GT3vA5JXNzIaizx388rhsSW79vGRNGXfRRAdEAn2mvk36PvK5HnOchyWZ7afLEXqYCvPCrzuzQ==", + "dev": true, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -18566,6 +18980,30 @@ "node": ">= 0.8" } }, + "node_modules/vue-eslint-parser": { + "version": "9.4.3", + "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz", + "integrity": "sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==", + "dev": true, + "dependencies": { + "debug": "^4.3.4", + "eslint-scope": "^7.1.1", + "eslint-visitor-keys": "^3.3.0", + "espree": "^9.3.1", + "esquery": "^1.4.0", + "lodash": "^4.17.21", + "semver": "^7.3.6" + }, + "engines": { + "node": "^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, "node_modules/w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", @@ -19504,6 +19942,19 @@ "node": ">=10" } }, + "node_modules/yarn": { + "version": "1.22.22", + "resolved": "https://registry.npmjs.org/yarn/-/yarn-1.22.22.tgz", + "integrity": "sha512-prL3kGtyG7o9Z9Sv8IPfBNrWTDmXB4Qbes8A9rEzt6wkJV8mUvoirjU0Mp3GGAU06Y0XQyA3/2/RQFVuK7MTfg==", + "hasInstallScript": true, + "bin": { + "yarn": "bin/yarn.js", + "yarnpkg": "bin/yarn.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 7d7b5e3..658a370 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,12 @@ "react": "^18.3.1", "react-color": "^2.19.3", "react-dom": "^18.3.1", + "react-rnd": "^10.4.13", "react-router-dom": "^6.27.0", "react-scripts": "5.0.1", "three": "^0.167.1", - "web-vitals": "^2.1.4" + "web-vitals": "^2.1.4", + "yarn": "^1.22.22" }, "scripts": { "start": "react-scripts start", @@ -42,5 +44,10 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "devDependencies": { + "eslint": "^8.57.1", + "prettier": "^3.3.3", + "prettier-eslint": "^16.3.0" } } diff --git a/src/App.css b/src/App.css index 98e7e1e..7c6b14d 100644 --- a/src/App.css +++ b/src/App.css @@ -1,11 +1,11 @@ :root { --color-primary: #000000; /* Black for primary */ --color-primary-dark: #333333; /* Dark grey as a substitute for dark primary */ - --color-secondary: #FFFFFF; /* White for secondary */ + --color-secondary: #ffffff; /* White for secondary */ --color-border: #e0e0e0; /* You might keep this for borders */ --color-background: #f5f5f5; /* Light grey for backgrounds, if suitable */ --color-tooltip: #000000; /* Black for tooltips */ - --color-tooltip-text: #FFFFFF; /* White for tooltip text */ + --color-tooltip-text: #ffffff; /* White for tooltip text */ } .App { display: flex; @@ -24,7 +24,9 @@ /* Adjust the footer CSS */ .app-footer { - background-color: var(--color-primary); /* Using your primary color variable */ + background-color: var( + --color-primary + ); /* Using your primary color variable */ color: var(--color-secondary); padding: 20px; /* Adjust the padding as needed */ text-align: center; @@ -39,14 +41,14 @@ } body { - font-family: 'Roboto', sans-serif; + font-family: "Roboto", sans-serif; } -body, html { +body, +html { margin: auto; padding: 0; max-width: 100vw; overflow-x: hidden; /* Prevent horizontal scroll */ - } .container { @@ -55,7 +57,7 @@ body, html { align-items: flex-start; width: 95%; /* Adjust the percentage as needed */ margin: 40px auto 40px 20px; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .main-content { @@ -68,7 +70,6 @@ body, html { width: 100%; } - .content-container { flex: 1; /* Each container occupies 50% of the horizontal space */ margin-right: 20px; /* Add some spacing between the containers */ @@ -87,93 +88,101 @@ body, html { top: 3px; flex-grow: 1; } - - .grid-with-indicators { - display: grid; - padding-top: 21px; - position: relative; - width: auto; - gap: 5px; - } - - .grid-with-indicators .horizontal-indicators { - grid-column: 2; - grid-row: 1; - margin-left: -2px; - } - - .grid-with-indicators .vertical-indicators { - grid-column: 1; - grid-row: 2; - } - - .grid-with-indicators .grid-container { - grid-column: 2; - grid-row: 2; - } - .grid-with-indicators:hover { - transform: scale(1.5); /* Adjust the scale value as needed */ - z-index: 100; /* Ensure the zoomed element is above others */ - transition: transform 0.3s ease; /* Smooth zoom effect */ - /* position: absolute; Use absolute positioning */ +.grid-with-indicators { + display: grid; + padding-top: 21px; + position: relative; + width: auto; + gap: 5px; +} - } - - .grid-container { - display: grid; - grid-template-columns: repeat(8, 15px); /* Keeps the 15px width for each cell */ - grid-template-rows: repeat(8, 15px); /* Ensures rows are also defined for clarity */ - border: 1px solid #e0e0e0; /* Maintains the border */ - background-color: #f5f5f5; /* Keeps the background color */ - border-radius: 5px; /* Maintains the border-radius */ - width: 120px; /* Adjusted width: 8 cells * 15px each */ - height: 120px; /* Adjusted height: 8 cells * 15px each */ - box-shadow: 0 1px 3px rgba(0,0,0,0.2); /* Keeps the shadow */ - } - - .grid-cell { - background-color: #adacaa; - border: .25px inherit; - border-color: #9e9c9d; - box-sizing: border-box; - cursor: pointer; - height: 15px; - position: relative; - transition: box-shadow .3s ease,transform .3s ease; - width: 15px - } - - .tooltip { - visibility: hidden; - position: absolute; - background-color: var(--color-tooltip); - color: var(--color-tooltip-text); - padding: 8px 10px; - border-radius: 5px; - border: 1px solid #fff; - z-index: 1010 !important; - font-size: 0.7em; - width: 130px; - text-align: left; - box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.2); - transition: visibility 0.2s ease, opacity 0.2s ease; - opacity: 0; /* Start with an invisible tooltip */ - pointer-events: none; /* Prevents the tooltip from blocking mouse events */ -} - - - .grid-cell:hover .tooltip { - visibility: visible; - opacity: 1; /* Make the tooltip visible on hover */ - transition-delay: 0.5s; /* Add a delay to the transition */ - } - - .grid-cell:hover { - box-shadow: 0 2px 6px rgba(0,0,0,0.3); /* More pronounced shadow on hover */ - transform: scale(1.05); - z-index: 1; - } +.grid-with-indicators .horizontal-indicators { + grid-column: 2; + grid-row: 1; + margin-left: -2px; +} + +.grid-with-indicators .vertical-indicators { + grid-column: 1; + grid-row: 2; +} + +.grid-with-indicators .grid-container { + grid-column: 2; + grid-row: 2; +} + +.grid-with-indicators:hover { + transform: scale(1.5); /* Adjust the scale value as needed */ + z-index: 100; /* Ensure the zoomed element is above others */ + transition: transform 0.3s ease; /* Smooth zoom effect */ + /* position: absolute; Use absolute positioning */ +} + +.grid-container { + display: grid; + grid-template-columns: repeat( + 8, + 15px + ); /* Keeps the 15px width for each cell */ + grid-template-rows: repeat( + 8, + 15px + ); /* Ensures rows are also defined for clarity */ + border: 1px solid #e0e0e0; /* Maintains the border */ + background-color: #f5f5f5; /* Keeps the background color */ + border-radius: 5px; /* Maintains the border-radius */ + width: 120px; /* Adjusted width: 8 cells * 15px each */ + height: 120px; /* Adjusted height: 8 cells * 15px each */ + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); /* Keeps the shadow */ +} + +.grid-cell { + background-color: #adacaa; + border: 0.25px inherit; + border-color: #9e9c9d; + box-sizing: border-box; + cursor: pointer; + height: 15px; + position: relative; + transition: + box-shadow 0.3s ease, + transform 0.3s ease; + width: 15px; +} + +.tooltip { + visibility: hidden; + position: absolute; + background-color: var(--color-tooltip); + color: var(--color-tooltip-text); + padding: 8px 10px; + border-radius: 5px; + border: 1px solid #fff; + z-index: 1010 !important; + font-size: 0.7em; + width: 130px; + text-align: left; + box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.2); + transition: + visibility 0.2s ease, + opacity 0.2s ease; + opacity: 0; /* Start with an invisible tooltip */ + pointer-events: none; /* Prevents the tooltip from blocking mouse events */ +} + +.grid-cell:hover .tooltip { + visibility: visible; + opacity: 1; /* Make the tooltip visible on hover */ + transition-delay: 0.5s; /* Add a delay to the transition */ +} + +.grid-cell:hover { + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); /* More pronounced shadow on hover */ + transform: scale(1.05); + z-index: 1; +} .grid-cell.selected { position: relative; /* This ensures the pseudo-element is positioned relative to this cell */ @@ -181,7 +190,7 @@ body, html { } .grid-cell.selected::after { - content: ''; + content: ""; position: absolute; top: 0; right: 0; @@ -197,244 +206,255 @@ body, html { z-index: 1; } - .tiff-player img { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - /* object-fit: cover; This ensures the image covers the available space, similar to your video setup */ - } - - .player-controls { - position: absolute; - bottom: 0; - left: 0; - right: 0; - display: flex; - justify-content: space-between; - align-items: center; - padding: 10px; - background-color: rgba(0, 0, 0, 0.5); /* Semi-transparent background for visibility */ - } - - .player-controls button { - cursor: pointer; - padding: 5px 10px; - background-color: var(--color-primary); /* Use primary color for button background */ - color: var(--color-secondary); /* Use secondary color for text */ - border: none; - border-radius: 5px; - outline: none; - transition: background-color 0.3s ease; - } - - .player-controls button:hover { - background-color: var(--color-primary-dark); /* Darker shade for hover state */ - } - - .progress-bar { - flex-grow: 1; - height: 20px; - background-color: #e9e9e9; - border-radius: 10px; - margin-left: 10px; - position: relative; - overflow: hidden; - background-color: var(--color-border); - box-shadow: inset 0 1px 3px rgba(0,0,0,0.2); - } - - .progress { - height: 100%; - border-radius: 10px; - background-color: var(--color-primary); /* Use primary color for progress bar */ - transition: width 0.3s ease; - } - - .scrubber { - position: absolute; - left: 0; - top: 50%; - transform: translateY(-50%); - width: 12px; - height: 12px; - background-color: var(--color-secondary); /* Use secondary color for scrubber */ - border: 2px solid var(--color-primary); /* Border color from primary */ - border-radius: 50%; - cursor: pointer; - z-index: 2; - box-shadow: 0 2px 4px rgba(0,0,0,0.4); - } - - - .tiff-player { - position: relative; - width: 100%; - padding-top: 56.25%; /* Adjust this value to match the aspect ratio of your TIFF images */ - top: 76px; - flex-grow: 2; /* This ensures that the player takes up the space it needs, similar to your mp4 player setup */ - } - - .overlay { - position: absolute; /* Positions .overlay in relation to .tiff-player */ - top: 0; - left: 0; - right: 0; - bottom: 0; /* These four properties ensure .overlay matches the size of .tiff-player */ - display: flex; - justify-content: center; - align-items: center; - background: rgba(0, 0, 0, 0.1); - z-index: 1; /* Ensures .overlay stacks above the img */ - cursor: pointer; - pointer-events: none; - } - - .loading-bar { - width: 100%; - height: 4px; - background-color: #e0e0e0; - position: fixed; - top: 0; - left: 0; - z-index: 10; - } - - .loading-progress { - height: 100%; - width: 0; - background-color: #007bff; - transition: width 0.3s ease; - } - - .grid-cell.selected { - border: 2px solid #007bff; - } - - .round-header { - position: absolute; - top: 0; - left: 57%; - transform: translateX(-50%); - background-color: var(--color-secondary); /* light orange background */ - font-weight: bold; - z-index: 1; - padding: 2px 10px; - border-radius: 2px; /* rounding the corners */ - box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.2); /* optional shadow for a lifted effect */ -} - - button { - margin: 10px; - padding: 10px 20px; - display: inline-block; - text-align: center; - vertical-align: middle; - cursor: pointer; - background-color: #007bff; - color: #fff; - border: none; - border-radius: 5px; - transition: background-color 0.3s; - outline: none; - font-size: 0.9em; - line-height: 1; - width: 120px; - height: 40px; - background-color: var(--color-primary); - color: var(--color-tooltip-text); - box-shadow: 0 2px 4px rgba(0,0,0,0.2); /* Adds subtle shadow for depth */ - } - - button:hover { - background-color: var(--color-primary-dark); - } - - button:disabled { - background-color: #e0e0e0; - cursor: not-allowed; - } - - .grid-cell:not(.selected):hover { - box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); - transform: scale(1.05); - z-index: 1; - } - - .grid-container:hover { - border-color: #007bff; - } - - .horizontal-indicators, - .vertical-indicators { - display: flex; - justify-content: space-between; - align-items: center; - background-color: #f9f9f9; - padding: 5px; - font-size: 0.7em; /* Smaller font size */ - } - - .horizontal-indicators { - position: relative; - z-index: 1; - width: 100%; - height: auto; - flex-direction: row; - padding: 5px 5px 5px 0; - } - - .vertical-indicators { - height: 100%; - width: 0px; - flex-direction: column; - padding: 0px; - } - - .indicator-item { - text-align: center; - font-size: 0.8em; - font-weight: bold; - margin: 0 5px; - } - - - .rounds-header-container { - display: flex; - flex-direction: column; - align-items: center; - margin-bottom: 20px; /* Adding some spacing between the round headers and the rest of the content */ - } - - .rounds-header-group { - display: flex; - justify-content: space-between; /* Spread the round headers evenly */ - width: 100%; /* Take the full width of the parent container */ - background-color: #FFA500; /* Orange background */ - padding: 5px; /* Add some padding around the group */ - margin: 5px 0; /* Add some margin between the groups */ - border-radius: 5px; /* Add rounded corners */ - box-shadow: 0px 2px 15px rgba(0, 0, 0, 0.1); /* Add a subtle shadow for depth */ - } - +.tiff-player img { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + /* object-fit: cover; This ensures the image covers the available space, similar to your video setup */ +} + +.player-controls { + position: absolute; + bottom: 0; + left: 0; + right: 0; + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px; + background-color: rgba( + 0, + 0, + 0, + 0.5 + ); /* Semi-transparent background for visibility */ +} + +.player-controls button { + cursor: pointer; + padding: 5px 10px; + background-color: var( + --color-primary + ); /* Use primary color for button background */ + color: var(--color-secondary); /* Use secondary color for text */ + border: none; + border-radius: 5px; + outline: none; + transition: background-color 0.3s ease; +} + +.player-controls button:hover { + background-color: var( + --color-primary-dark + ); /* Darker shade for hover state */ +} + +.progress-bar { + flex-grow: 1; + height: 20px; + background-color: #e9e9e9; + border-radius: 10px; + margin-left: 10px; + position: relative; + overflow: hidden; + background-color: var(--color-border); + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2); +} + +.progress { + height: 100%; + border-radius: 10px; + background-color: var( + --color-primary + ); /* Use primary color for progress bar */ + transition: width 0.3s ease; +} + +.scrubber { + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 12px; + height: 12px; + background-color: var( + --color-secondary + ); /* Use secondary color for scrubber */ + border: 2px solid var(--color-primary); /* Border color from primary */ + border-radius: 50%; + cursor: pointer; + z-index: 2; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4); +} + +.tiff-player { + position: relative; + width: 100%; + padding-top: 56.25%; /* Adjust this value to match the aspect ratio of your TIFF images */ + top: 76px; + flex-grow: 2; /* This ensures that the player takes up the space it needs, similar to your mp4 player setup */ +} + +.overlay { + position: absolute; /* Positions .overlay in relation to .tiff-player */ + top: 0; + left: 0; + right: 0; + bottom: 0; /* These four properties ensure .overlay matches the size of .tiff-player */ + display: flex; + justify-content: center; + align-items: center; + background: rgba(0, 0, 0, 0.1); + z-index: 1; /* Ensures .overlay stacks above the img */ + cursor: pointer; + pointer-events: none; +} + +.loading-bar { + width: 100%; + height: 4px; + background-color: #e0e0e0; + position: fixed; + top: 0; + left: 0; + z-index: 10; +} + +.loading-progress { + height: 100%; + width: 0; + background-color: #007bff; + transition: width 0.3s ease; +} + +.grid-cell.selected { + border: 2px solid #007bff; +} + +.round-header { + position: absolute; + top: 0; + left: 57%; + transform: translateX(-50%); + background-color: var(--color-secondary); /* light orange background */ + font-weight: bold; + z-index: 1; + padding: 2px 10px; + border-radius: 2px; /* rounding the corners */ + box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.2); /* optional shadow for a lifted effect */ +} + +button { + margin: 10px; + padding: 10px 20px; + display: inline-block; + text-align: center; + vertical-align: middle; + cursor: pointer; + background-color: #007bff; + color: #fff; + border: none; + border-radius: 5px; + transition: background-color 0.3s; + outline: none; + font-size: 0.9em; + line-height: 1; + width: 120px; + height: 40px; + background-color: var(--color-primary); + color: var(--color-tooltip-text); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); /* Adds subtle shadow for depth */ +} + +button:hover { + background-color: var(--color-primary-dark); +} + +button:disabled { + background-color: #e0e0e0; + cursor: not-allowed; +} + +.grid-cell:not(.selected):hover { + box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); + transform: scale(1.05); + z-index: 1; +} + +.grid-container:hover { + border-color: #007bff; +} + +.horizontal-indicators, +.vertical-indicators { + display: flex; + justify-content: space-between; + align-items: center; + background-color: #f9f9f9; + padding: 5px; + font-size: 0.7em; /* Smaller font size */ +} + +.horizontal-indicators { + position: relative; + z-index: 1; + width: 100%; + height: auto; + flex-direction: row; + padding: 5px 5px 5px 0; +} + +.vertical-indicators { + height: 100%; + width: 0px; + flex-direction: column; + padding: 0px; +} + +.indicator-item { + text-align: center; + font-size: 0.8em; + font-weight: bold; + margin: 0 5px; +} + +.rounds-header-container { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: 20px; /* Adding some spacing between the round headers and the rest of the content */ +} + +.rounds-header-group { + display: flex; + justify-content: space-between; /* Spread the round headers evenly */ + width: 100%; /* Take the full width of the parent container */ + background-color: #ffa500; /* Orange background */ + padding: 5px; /* Add some padding around the group */ + margin: 5px 0; /* Add some margin between the groups */ + border-radius: 5px; /* Add rounded corners */ + box-shadow: 0px 2px 15px rgba(0, 0, 0, 0.1); /* Add a subtle shadow for depth */ +} + .color-legend { - display: flex; - flex-direction: column; /* Align legend items vertically */ - padding-right: 10px; /* Space between legend and histogram */ - padding-top: 49px; + display: flex; + flex-direction: column; /* Align legend items vertically */ + padding-right: 10px; /* Space between legend and histogram */ + padding-top: 49px; } .gradient-box { - height: 200px; /* Match the height of your histogram */ - width: 20px; /* Width of the color bar */ - margin-bottom: 10px; /* Space between the gradient and labels */ + height: 200px; /* Match the height of your histogram */ + width: 20px; /* Width of the color bar */ + margin-bottom: 10px; /* Space between the gradient and labels */ } .legend-marks { - display: flex; - flex-direction: column; - justify-content: space-between; + display: flex; + flex-direction: column; + justify-content: space-between; } .legend-mark { @@ -444,74 +464,73 @@ body, html { font-size: 0.8em; } - .text-center{ - text-align: center; - } +.text-center { + text-align: center; +} - .side-panel { - background-color: #fff; /* White background */ - margin-top: -62px; - border-radius: 4px; /* Rounded corners */ - box-shadow: 0 4px 6px rgba(0,0,0,0.1); /* Shadow for depth */ - display: flex; - flex-direction: row; - overflow: hidden; - width: 90%; - } - - .side-panel .panel-header { - font-size: 1.25rem; - background-color: var(--color-primary); /* Black background */ - color: var(--color-secondary); /* White text */ - text-align: center; - font-weight: 500; - padding: 8px 0; /* Padding for the header */ - border-bottom: 1px solid var(--color-border); - width: 100%; - } - - .side-panel .panel-content { - padding: 16px; - flex-grow: 1; /* Allows this element to fill up the remaining space */ - margin-left: -32px; - overflow-y: auto; /* Allows scrolling if content is too long */ - } - - .side-panel .panel-item { - padding: 12px 16px; - border-bottom: 1px solid var(--color-border); - transition: background-color 0.2s; - cursor: pointer; - } - - .side-panel .panel-item:hover { - background-color: var(--color-background); - } +.side-panel { + background-color: #fff; /* White background */ + margin-top: -62px; + border-radius: 4px; /* Rounded corners */ + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); /* Shadow for depth */ + display: flex; + flex-direction: row; + overflow: hidden; + width: 90%; +} - .histogram { - margin-top: 20px; /* Space above the histogram */ - } +.side-panel .panel-header { + font-size: 1.25rem; + background-color: var(--color-primary); /* Black background */ + color: var(--color-secondary); /* White text */ + text-align: center; + font-weight: 500; + padding: 8px 0; /* Padding for the header */ + border-bottom: 1px solid var(--color-border); + width: 100%; +} - .histogram-bar { - display: flex; - align-items: center; - margin-bottom: 5px; - } - - .histogram-bar-fill { - height: 20px; - background-color: var(--color-primary); /* Black bars for the histogram */ - margin-right: 10px; - transition: width 3s ease-in-out; - } - - .histogram-bar-label { - font-size: 0.8em; - color: var(--color-primary-dark); /* Dark grey text */ - } +.side-panel .panel-content { + padding: 16px; + flex-grow: 1; /* Allows this element to fill up the remaining space */ + margin-left: -32px; + overflow-y: auto; /* Allows scrolling if content is too long */ +} + +.side-panel .panel-item { + padding: 12px 16px; + border-bottom: 1px solid var(--color-border); + transition: background-color 0.2s; + cursor: pointer; +} +.side-panel .panel-item:hover { + background-color: var(--color-background); +} + +.histogram { + margin-top: 20px; /* Space above the histogram */ +} + +.histogram-bar { + display: flex; + align-items: center; + margin-bottom: 5px; +} - /* For screens wider than 1920px */ +.histogram-bar-fill { + height: 20px; + background-color: var(--color-primary); /* Black bars for the histogram */ + margin-right: 10px; + transition: width 3s ease-in-out; +} + +.histogram-bar-label { + font-size: 0.8em; + color: var(--color-primary-dark); /* Dark grey text */ +} + +/* For screens wider than 1920px */ @media (min-width: 1920px) { .container { width: 90%; /* You can go up to 100% if you want to use all the horizontal space */ @@ -521,7 +540,6 @@ body, html { .main-content { flex: 0 0 100%; /* Adjust the flex-basis as needed */ } - } /* For screens wider than 3840px */ @@ -529,10 +547,8 @@ body, html { .container { width: 80%; /* Less percentage as the screen is very wide */ } - } - .navbar { background-color: black; /* As per your color scheme */ color: white; @@ -553,7 +569,6 @@ body, html { margin-right: 10px; /* Adds a little space between logo and title */ } - .NotFound { display: flex; align-items: center; @@ -611,8 +626,12 @@ body, html { } @keyframes orbitAnimation { - from { transform: rotate(0deg) translateX(150px); } - to { transform: rotate(360deg) translateX(150px); } + from { + transform: rotate(0deg) translateX(150px); + } + to { + transform: rotate(360deg) translateX(150px); + } } .NotFound-content { @@ -645,7 +664,6 @@ body, html { background-color: #ff5733; } - .histogram-tooltip { position: absolute; visibility: hidden; @@ -658,7 +676,9 @@ body, html { z-index: 2; white-space: nowrap; pointer-events: none; - transition: visibility 0.2s ease, opacity 0.2s ease; + transition: + visibility 0.2s ease, + opacity 0.2s ease; opacity: 0; top: -35px; /* Adjust the position above the hovered element */ left: 50%; @@ -670,7 +690,6 @@ body, html { opacity: 1; } - .histogram-bar { position: relative; /* To position the tooltip */ } @@ -686,7 +705,6 @@ body, html { margin-bottom: 10px; } - .tooltip.top-left { bottom: 100%; left: 0; @@ -699,7 +717,8 @@ body, html { transform: translateY(-10px); /* Adjust as needed */ } -.tiff-player-placeholder, .loading-overlay { +.tiff-player-placeholder, +.loading-overlay { position: relative; /* Needed for absolute positioning of the overlay */ display: flex; flex-direction: column; @@ -726,7 +745,9 @@ body, html { display: flex; justify-content: center; align-items: center; - background-color: var(--color-background); /* Semi-transparent white background */ + background-color: var( + --color-background + ); /* Semi-transparent white background */ z-index: 10; /* Ensures it's above other elements */ } @@ -751,7 +772,6 @@ body, html { height: 300px; /* Set a fixed height for the chart */ } - .histogram-and-legend-container { display: flex; align-items: stretch; /* Align items vertically in the center */ @@ -773,18 +793,20 @@ body, html { } @keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } } - - .jumbotron { background-color: #f9f9f9; /* Light grey background */ padding: 20px; margin-top: 43px; /* Small top margin to create spacing */ border-radius: 5px; /* Rounded corners */ - box-shadow: 0 2px 4px rgba(0,0,0,0.2); /* Shadow for depth */ + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); /* Shadow for depth */ text-align: center; /* Center the text */ height: 150px; /* Fixed height to prevent resizing */ overflow: hidden; /* Hide overflowing content */ @@ -799,8 +821,6 @@ body, html { color: var(--color-primary-dark); /* Dark grey for the paragraph */ } - - button { display: inline-flex; /* Use flex to align items horizontally */ align-items: center; /* Center items vertically within the button */ @@ -822,7 +842,7 @@ button { height: 46px; background-color: var(--color-primary); color: var(--color-tooltip-text); - box-shadow: 0 2px 4px rgba(0,0,0,0.2); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); } button:hover { @@ -957,8 +977,12 @@ button svg { /* Animation for the loading of the polar plot */ @keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + to { + opacity: 1; + } } .polar-plot-image { @@ -980,8 +1004,14 @@ button svg { } @keyframes blink { - 0%, 50% { opacity: 1; } - 50.01%, 100% { opacity: 0; } + 0%, + 50% { + opacity: 1; + } + 50.01%, + 100% { + opacity: 0; + } } .typewriter-link { @@ -1039,30 +1069,29 @@ button svg { } } - @media (min-width: 1024px) { .mp4-player { min-width: 470px; /* Adjust for larger desktops */ } - body, html { - + body, + html { overflow-x: initial; } - } .histogram-description p { font-size: 0.9em; - color: var(--color-primary-dark); /* Assuming this is a darker shade for text */ + color: var( + --color-primary-dark + ); /* Assuming this is a darker shade for text */ padding: 10px; background-color: var(--color-background); border-radius: 4px; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); margin-top: 20px; } - .app-footer p { margin: 10px 0; font-size: 1rem; /* Adjust the font size as needed */ @@ -1072,7 +1101,9 @@ button svg { } .app-footer a { - color: var(--color-highlight); /* Highlight color for links, assuming you define this */ + color: var( + --color-highlight + ); /* Highlight color for links, assuming you define this */ text-decoration: underline; } @@ -1093,7 +1124,7 @@ button svg { } .download-button:enabled { - background-color: #4CAF50; /* Green */ + background-color: #4caf50; /* Green */ } .download-button:disabled { @@ -1166,9 +1197,8 @@ button svg { .polar-plot-container { width: 100%; } -.round-well-info .well{ +.round-well-info .well { padding: auto; - } .deleter { @@ -1177,23 +1207,23 @@ button svg { .polar-plot-description p { font-size: 0.9em; - color: var(--color-primary-dark); /* Assuming this is a darker shade for text */ + color: var( + --color-primary-dark + ); /* Assuming this is a darker shade for text */ padding: 10px; background-color: var(--color-background); border-radius: 4px; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); margin-top: 20px; } - /* Adjust for larger screens */ @media (min-width: 1024px) { .side-panel-horizontal { - width: 100% + width: 100%; } } - /* New Independent Scrolling Styles */ .sider-container { width: 300px; @@ -1216,7 +1246,6 @@ button svg { overflow: visible !important; } - /* Add these new styles for the file listing container */ .file-listing { padding: 8px 0; @@ -1320,7 +1349,7 @@ button svg { border-radius: 8px; padding: 15px; margin: 10px 0; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .plane-player-controls .ant-slider { @@ -1334,4 +1363,14 @@ button svg { .plane-player-controls h4 { margin-bottom: 15px; text-align: center; -} \ No newline at end of file +} + +.planar-slice-player { + background: #fff; + border-radius: 8px; + transition: height 0.3s ease; +} + +.player-header { + user-select: none; +} diff --git a/src/components/Footer.js b/src/components/Footer.js index cf18ea6..4fe4921 100755 --- a/src/components/Footer.js +++ b/src/components/Footer.js @@ -1,11 +1,47 @@ -import React from 'react'; +import React from "react"; const Footer = () => { return (
); }; diff --git a/src/components/PlanarSlicePlayer.js b/src/components/PlanarSlicePlayer.js index 549fa2b..d218b15 100644 --- a/src/components/PlanarSlicePlayer.js +++ b/src/components/PlanarSlicePlayer.js @@ -1,272 +1,287 @@ // PlanarSlicePlayer.js -import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { Button, Slider, InputNumber, Row, Col, Typography, Switch } from 'antd'; -import { - PlayCircleOutlined, - PauseCircleOutlined, - StopOutlined, - StepForwardOutlined, - StepBackwardOutlined -} from '@ant-design/icons'; +import React, { useState, useEffect, useCallback, useRef } from "react"; +import { + Button, + Slider, + InputNumber, + Row, + Col, + Typography, + Switch, +} from "antd"; +import { + PlayCircleOutlined, + PauseCircleOutlined, + StopOutlined, + StepForwardOutlined, + StepBackwardOutlined, +} from "@ant-design/icons"; const { Text } = Typography; const PlanarSlicePlayer = ({ - currentVolume, - cameraMode, - updateClipRegion, - clipRegion, - onSliceChange, + currentVolume, + cameraMode, + updateClipRegion, + clipRegion, + onSliceChange, }) => { - const [isPlaying, setIsPlaying] = useState(false); - const [playbackSpeed, setPlaybackSpeed] = useState(1); - const [currentSlice, setCurrentSlice] = useState(0); - const [totalSlices, setTotalSlices] = useState(100); - const [isLooping, setIsLooping] = useState(true); - - // Use useRef for the interval to prevent issues with closure stale values - const playbackIntervalRef = useRef(null); - // Store current slice in ref to access latest value in interval - const currentSliceRef = useRef(currentSlice); - - // Update ref when slice changes - useEffect(() => { - currentSliceRef.current = currentSlice; - }, [currentSlice]); + const [isPlaying, setIsPlaying] = useState(false); + const [playbackSpeed, setPlaybackSpeed] = useState(1); + const [currentSlice, setCurrentSlice] = useState(0); + const [totalSlices, setTotalSlices] = useState(100); + const [isLooping, setIsLooping] = useState(true); - const getAxisInfo = useCallback(() => { - switch (cameraMode) { - case 'X': - return { - min: 'xmin', - max: 'xmax', - label: 'X', - size: currentVolume?.imageInfo?.volumeSize?.x - }; - case 'Y': - return { - min: 'ymin', - max: 'ymax', - label: 'Y', - size: currentVolume?.imageInfo?.volumeSize?.y - }; - case 'Z': - return { - min: 'zmin', - max: 'zmax', - label: 'Z', - size: currentVolume?.imageInfo?.volumeSize?.z - }; - default: - return null; - } - }, [cameraMode, currentVolume]); + // Use useRef for the interval to prevent issues with closure stale values + const playbackIntervalRef = useRef(null); + // Store current slice in ref to access latest value in interval + const currentSliceRef = useRef(currentSlice); - const updateSlice = useCallback((newSlice) => { - if (!currentVolume) return; - - const axisInfo = getAxisInfo(); - if (!axisInfo) return; + // Update ref when slice changes + useEffect(() => { + currentSliceRef.current = currentSlice; + }, [currentSlice]); - const normalizedPos = newSlice / (totalSlices - 1); - const sliceThickness = 0.01; - - const newClipRegion = { - ...clipRegion, - [axisInfo.min]: Math.max(0, normalizedPos - sliceThickness/2), - [axisInfo.max]: Math.min(1, normalizedPos + sliceThickness/2) + const getAxisInfo = useCallback(() => { + switch (cameraMode) { + case "X": + return { + min: "xmin", + max: "xmax", + label: "X", + size: currentVolume?.imageInfo?.volumeSize?.x, + }; + case "Y": + return { + min: "ymin", + max: "ymax", + label: "Y", + size: currentVolume?.imageInfo?.volumeSize?.y, }; + case "Z": + return { + min: "zmin", + max: "zmax", + label: "Z", + size: currentVolume?.imageInfo?.volumeSize?.z, + }; + default: + return null; + } + }, [cameraMode, currentVolume]); - updateClipRegion(newClipRegion); - setCurrentSlice(newSlice); - onSliceChange?.(newSlice); - }, [currentVolume, getAxisInfo, clipRegion, totalSlices, updateClipRegion, onSliceChange]); + const updateSlice = useCallback( + (newSlice) => { + if (!currentVolume) return; - const stopPlayback = useCallback(() => { - if (playbackIntervalRef.current) { - clearInterval(playbackIntervalRef.current); - playbackIntervalRef.current = null; - } - setIsPlaying(false); - }, []); + const axisInfo = getAxisInfo(); + if (!axisInfo) return; - const play = useCallback(() => { - // Clear any existing interval first - stopPlayback(); - - setIsPlaying(true); - - // Create new interval with looping logic - playbackIntervalRef.current = setInterval(() => { - const nextSlice = currentSliceRef.current + 1; - - if (nextSlice >= totalSlices) { - if (isLooping) { - // If looping is enabled, go back to start - updateSlice(0); - } else { - // If not looping, stop at the end - stopPlayback(); - } - return; - } - - updateSlice(nextSlice); - }, 1000 / playbackSpeed); - }, [playbackSpeed, totalSlices, updateSlice, stopPlayback, isLooping]); + const normalizedPos = newSlice / (totalSlices - 1); + const sliceThickness = 0.01; - const pause = useCallback(() => { - stopPlayback(); - }, [stopPlayback]); + const newClipRegion = { + ...clipRegion, + [axisInfo.min]: Math.max(0, normalizedPos - sliceThickness / 2), + [axisInfo.max]: Math.min(1, normalizedPos + sliceThickness / 2), + }; - const stop = useCallback(() => { - stopPlayback(); - updateSlice(0); - }, [stopPlayback, updateSlice]); + updateClipRegion(newClipRegion); + setCurrentSlice(newSlice); + onSliceChange?.(newSlice); + }, + [ + currentVolume, + getAxisInfo, + clipRegion, + totalSlices, + updateClipRegion, + onSliceChange, + ], + ); - const forward = useCallback(() => { - const nextSlice = Math.min(currentSliceRef.current + 1, totalSlices - 1); - updateSlice(nextSlice); - }, [totalSlices, updateSlice]); + const stopPlayback = useCallback(() => { + if (playbackIntervalRef.current) { + clearInterval(playbackIntervalRef.current); + playbackIntervalRef.current = null; + } + setIsPlaying(false); + }, []); - const backward = useCallback(() => { - const prevSlice = Math.max(currentSliceRef.current - 1, 0); - updateSlice(prevSlice); - }, [updateSlice]); + const play = useCallback(() => { + // Clear any existing interval first + stopPlayback(); - // Update total slices when volume changes - useEffect(() => { - const axisInfo = getAxisInfo(); - if (axisInfo?.size) { - setTotalSlices(axisInfo.size); - setCurrentSlice(prev => Math.min(prev, axisInfo.size - 1)); + setIsPlaying(true); + + // Create new interval with looping logic + playbackIntervalRef.current = setInterval(() => { + const nextSlice = currentSliceRef.current + 1; + + if (nextSlice >= totalSlices) { + if (isLooping) { + // If looping is enabled, go back to start + updateSlice(0); + } else { + // If not looping, stop at the end + stopPlayback(); } - }, [getAxisInfo]); + return; + } - // Cleanup effect - useEffect(() => { - return () => { - if (playbackIntervalRef.current) { - clearInterval(playbackIntervalRef.current); - } - }; - }, []); + updateSlice(nextSlice); + }, 1000 / playbackSpeed); + }, [playbackSpeed, totalSlices, updateSlice, stopPlayback, isLooping]); - // Handle camera mode changes - useEffect(() => { - stopPlayback(); - setCurrentSlice(0); - }, [cameraMode, stopPlayback]); + const pause = useCallback(() => { + stopPlayback(); + }, [stopPlayback]); - // Handle playback speed changes - useEffect(() => { - if (isPlaying) { - // Restart playback with new speed - play(); - } - }, [playbackSpeed, play, isPlaying]); + const stop = useCallback(() => { + stopPlayback(); + updateSlice(0); + }, [stopPlayback, updateSlice]); + + const forward = useCallback(() => { + const nextSlice = Math.min(currentSliceRef.current + 1, totalSlices - 1); + updateSlice(nextSlice); + }, [totalSlices, updateSlice]); + const backward = useCallback(() => { + const prevSlice = Math.max(currentSliceRef.current - 1, 0); + updateSlice(prevSlice); + }, [updateSlice]); + + // Update total slices when volume changes + useEffect(() => { const axisInfo = getAxisInfo(); - if (!axisInfo) return null; + if (axisInfo?.size) { + setTotalSlices(axisInfo.size); + setCurrentSlice((prev) => Math.min(prev, axisInfo.size - 1)); + } + }, [getAxisInfo]); + + // Cleanup effect + useEffect(() => { + return () => { + if (playbackIntervalRef.current) { + clearInterval(playbackIntervalRef.current); + } + }; + }, []); + + // Handle camera mode changes + useEffect(() => { + stopPlayback(); + setCurrentSlice(0); + }, [cameraMode, stopPlayback]); + + // Handle playback speed changes + useEffect(() => { + if (isPlaying) { + // Restart playback with new speed + play(); + } + }, [playbackSpeed, play, isPlaying]); + + const axisInfo = getAxisInfo(); + if (!axisInfo) return null; - return ( -
- - - {axisInfo.label} Plane Navigation - - - - - - - - - {!isPlaying ? ( - - ) : ( - - )} - - - - - - - Loop Playback: - - - - - - - - Speed (fps): - - - setPlaybackSpeed(value)} - /> - - - - - Current Slice: - - - - - -
- ); + return ( +
+ + + {axisInfo.label} Plane Navigation + + + + + + + + + {!isPlaying ? ( + + ) : ( + + )} + + + + + + + Loop Playback: + + + + + + + + Speed (fps): + + + setPlaybackSpeed(value)} + /> + + + + + Current Slice: + + + + + +
+ ); }; -export default PlanarSlicePlayer; \ No newline at end of file +export default PlanarSlicePlayer; diff --git a/src/components/PlanarSliceWindow.js b/src/components/PlanarSliceWindow.js new file mode 100644 index 0000000..64c409f --- /dev/null +++ b/src/components/PlanarSliceWindow.js @@ -0,0 +1,270 @@ +import React, { useState, useRef, useEffect } from "react"; +import { Button, Card } from "antd"; +import { + FullscreenOutlined, + FullscreenExitOutlined, + MinusOutlined, + CloseOutlined, +} from "@ant-design/icons"; + +const PlanarSliceWindow = ({ children, onClose, mode }) => { + const [isMinimized, setIsMinimized] = useState(false); + const [isMaximized, setIsMaximized] = useState(false); + const [position, setPosition] = useState({ x: 20, y: 20 }); + const [size, setSize] = useState({ width: 600, height: 400 }); + const [isDragging, setIsDragging] = useState(false); + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); + const [isResizing, setIsResizing] = useState(false); + const [resizeEdge, setResizeEdge] = useState(null); + const [originalSize, setOriginalSize] = useState(null); + const [originalPosition, setOriginalPosition] = useState(null); + + const windowRef = useRef(null); + const dragStartPos = useRef({ x: 0, y: 0 }); + + useEffect(() => { + const parent = document.getElementById("volume-viewer"); + if (parent) { + const rect = parent.getBoundingClientRect(); + setPosition({ + x: (rect.width - size.width) / 2, + y: (rect.height - size.height) / 2, + }); + } + }, [size.width, size.height]); + + const handleMouseDown = (e) => { + if (e.target.closest(".resize-handle") || isMaximized) return; + + setIsDragging(true); + const rect = windowRef.current.getBoundingClientRect(); + setDragOffset({ + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + dragStartPos.current = { x: e.clientX, y: e.clientY }; + }; + + const handleMouseMove = (e) => { + if (!isDragging && !isResizing) return; + + if (isDragging) { + const parent = document.getElementById("volume-viewer"); + const parentRect = parent.getBoundingClientRect(); + + let newX = e.clientX - dragOffset.x; + let newY = e.clientY - dragOffset.y; + + newX = Math.max(0, Math.min(newX, parentRect.width - size.width)); + newY = Math.max(0, Math.min(newY, parentRect.height - size.height)); + + setPosition({ x: newX, y: newY }); + } + + if (isResizing) { + const parent = document.getElementById("volume-viewer"); + const parentRect = parent.getBoundingClientRect(); + const minWidth = 400; + const minHeight = 300; + + let newWidth = size.width; + let newHeight = size.height; + let newX = position.x; + let newY = position.y; + + const deltaX = e.clientX - dragStartPos.current.x; + const deltaY = e.clientY - dragStartPos.current.y; + + if (resizeEdge.includes("right")) { + newWidth = Math.max(minWidth, size.width + deltaX); + newWidth = Math.min(newWidth, parentRect.width - position.x); + } + if (resizeEdge.includes("bottom")) { + newHeight = Math.max(minHeight, size.height + deltaY); + newHeight = Math.min(newHeight, parentRect.height - position.y); + } + if (resizeEdge.includes("left")) { + const possibleWidth = Math.max(minWidth, size.width - deltaX); + if (possibleWidth !== size.width) { + newX = Math.max(0, position.x + deltaX); + newWidth = possibleWidth; + } + } + if (resizeEdge.includes("top")) { + const possibleHeight = Math.max(minHeight, size.height - deltaY); + if (possibleHeight !== size.height) { + newY = Math.max(0, position.y + deltaY); + newHeight = possibleHeight; + } + } + + setSize({ width: newWidth, height: newHeight }); + setPosition({ x: newX, y: newY }); + } + }; + + const handleMouseUp = () => { + setIsDragging(false); + setIsResizing(false); + }; + + const handleResizeStart = (e, edge) => { + e.stopPropagation(); + setIsResizing(true); + setResizeEdge(edge); + dragStartPos.current = { x: e.clientX, y: e.clientY }; + }; + + const toggleMinimize = () => { + setIsMinimized(!isMinimized); + }; + + const toggleMaximize = () => { + if (!isMaximized) { + setOriginalSize({ ...size }); + setOriginalPosition({ ...position }); + + const parent = document.getElementById("volume-viewer"); + const parentRect = parent.getBoundingClientRect(); + + setSize({ + width: parentRect.width, + height: parentRect.height, + }); + setPosition({ x: 0, y: 0 }); + } else { + setSize(originalSize); + setPosition(originalPosition); + } + setIsMaximized(!isMaximized); + }; + + useEffect(() => { + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [isDragging, isResizing]); + + const resizeHandles = [ + { position: "top", cursor: "ns-resize" }, + { position: "right", cursor: "ew-resize" }, + { position: "bottom", cursor: "ns-resize" }, + { position: "left", cursor: "ew-resize" }, + { position: "top-left", cursor: "nw-resize" }, + { position: "top-right", cursor: "ne-resize" }, + { position: "bottom-left", cursor: "sw-resize" }, + { position: "bottom-right", cursor: "se-resize" }, + ]; + + return ( + +
+ + Planar Slice Player - {mode} View + +
+
+
+ +
+ {children} +
+ + {!isMinimized && + !isMaximized && + resizeHandles.map((handle) => ( +
handleResizeStart(e, handle.position)} + /> + ))} + + ); +}; + +export default PlanarSliceWindow; diff --git a/src/components/ResizablePlanarPlayer.js b/src/components/ResizablePlanarPlayer.js new file mode 100644 index 0000000..b204053 --- /dev/null +++ b/src/components/ResizablePlanarPlayer.js @@ -0,0 +1,311 @@ +import React, { useState, useRef, useEffect } from "react"; +import { + GripHorizontal, + ChevronUp, + ChevronDown, + ArrowLeftCircle, + ArrowRightCircle, + Play, + Pause, + StopCircle, +} from "lucide-react"; +import { Button, Slider, InputNumber, Switch, Card } from "antd"; + +const ResizablePlanarPlayer = ({ + currentVolume, + cameraMode, + updateClipRegion, + clipRegion, + onSliceChange, +}) => { + const [isMinimized, setIsMinimized] = useState(false); + const [position, setPosition] = useState({ + x: 20, + y: window.innerHeight - 400, + }); + const [size, setSize] = useState({ width: 400, height: 300 }); + const [isDragging, setIsDragging] = useState(false); + const [isResizing, setIsResizing] = useState(false); + const [isPlaying, setIsPlaying] = useState(false); + const [currentSlice, setCurrentSlice] = useState(0); + const [playbackSpeed, setPlaybackSpeed] = useState(1); + const [isLooping, setIsLooping] = useState(true); + const [totalSlices, setTotalSlices] = useState(100); + + const playerRef = useRef(null); + const dragStartRef = useRef({ x: 0, y: 0 }); + const playbackIntervalRef = useRef(null); + + useEffect(() => { + if (currentVolume?.imageInfo) { + const dimension = + cameraMode === "X" ? "sizeX" : cameraMode === "Y" ? "sizeY" : "sizeZ"; + setTotalSlices(currentVolume.imageInfo[dimension] || 100); + } + }, [currentVolume, cameraMode]); + + useEffect(() => { + return () => { + if (playbackIntervalRef.current) { + clearInterval(playbackIntervalRef.current); + } + }; + }, []); + + const handleDragStart = (e) => { + if (e.target.closest(".resize-handle")) return; + setIsDragging(true); + dragStartRef.current = { + x: e.clientX - position.x, + y: e.clientY - position.y, + }; + }; + + const handleDrag = (e) => { + if (!isDragging) return; + + const newX = Math.max( + 0, + Math.min( + window.innerWidth - size.width, + e.clientX - dragStartRef.current.x, + ), + ); + const newY = Math.max( + 0, + Math.min( + window.innerHeight - size.height, + e.clientY - dragStartRef.current.y, + ), + ); + + setPosition({ x: newX, y: newY }); + }; + + const handleResizeStart = (e) => { + e.stopPropagation(); + setIsResizing(true); + dragStartRef.current = { + x: e.clientX, + y: e.clientY, + width: size.width, + height: size.height, + }; + }; + + const handleResize = (e) => { + if (!isResizing) return; + + const minWidth = 300; + const minHeight = 200; + const maxWidth = window.innerWidth - position.x; + const maxHeight = window.innerHeight - position.y; + + const newWidth = Math.max( + minWidth, + Math.min( + maxWidth, + dragStartRef.current.width + (e.clientX - dragStartRef.current.x), + ), + ); + const newHeight = Math.max( + minHeight, + Math.min( + maxHeight, + dragStartRef.current.height + (e.clientY - dragStartRef.current.y), + ), + ); + + setSize({ width: newWidth, height: newHeight }); + }; + + const updateSlice = (newSlice) => { + if (!currentVolume) return; + + const axisInfo = { + min: cameraMode === "X" ? "xmin" : cameraMode === "Y" ? "ymin" : "zmin", + max: cameraMode === "X" ? "xmax" : cameraMode === "Y" ? "ymax" : "zmax", + }; + + const normalizedPos = newSlice / (totalSlices - 1); + const sliceThickness = 0.01; + + const newClipRegion = { + ...clipRegion, + [axisInfo.min]: Math.max(0, normalizedPos - sliceThickness / 2), + [axisInfo.max]: Math.min(1, normalizedPos + sliceThickness / 2), + }; + + updateClipRegion(newClipRegion); + setCurrentSlice(newSlice); + onSliceChange?.(newSlice); + }; + + const togglePlayback = () => { + if (isPlaying) { + clearInterval(playbackIntervalRef.current); + setIsPlaying(false); + } else { + setIsPlaying(true); + playbackIntervalRef.current = setInterval(() => { + setCurrentSlice((prev) => { + const next = prev + 1; + if (next >= totalSlices) { + if (isLooping) { + updateSlice(0); + return 0; + } else { + clearInterval(playbackIntervalRef.current); + setIsPlaying(false); + return prev; + } + } + updateSlice(next); + return next; + }); + }, 1000 / playbackSpeed); + } + }; + + const stopPlayback = () => { + clearInterval(playbackIntervalRef.current); + setIsPlaying(false); + setCurrentSlice(0); + updateSlice(0); + }; + + return ( + { + setIsDragging(false); + setIsResizing(false); + }} + onMouseLeave={() => { + setIsDragging(false); + setIsResizing(false); + }} + > +
+
+ + + {cameraMode} Plane Control + +
+
+ + {!isMinimized && ( +
+
+
+ + +
+ +
+
+ +
+
+ Speed (fps): + +
+
+ Loop Playback: + +
+
+
+
+ )} +
+ ); +}; + +export default ResizablePlanarPlayer; diff --git a/src/components/VolumeViewer.js b/src/components/VolumeViewer.js index 10c0f49..76fc8dd 100644 --- a/src/components/VolumeViewer.js +++ b/src/components/VolumeViewer.js @@ -1,1555 +1,1638 @@ -import React, { useEffect, useRef, useState, useCallback } from 'react'; +import React, { useEffect, useRef, useState, useCallback } from "react"; import { - LoadSpec, - View3d, - VolumeFileFormat, - RENDERMODE_PATHTRACE, - RENDERMODE_RAYMARCH, - VolumeMaker, - Light, - AREA_LIGHT, - SKY_LIGHT, - Lut + LoadSpec, + View3d, + VolumeFileFormat, + RENDERMODE_PATHTRACE, + RENDERMODE_RAYMARCH, + VolumeMaker, + Light, + AREA_LIGHT, + SKY_LIGHT, + Lut, } from "@aics/volume-viewer"; -import * as THREE from 'three'; -import { loaderContext, PREFETCH_DISTANCE, MAX_PREFETCH_CHUNKS, myState } from "./appConfig"; -import { useConstructor } from './useConstructor'; -import { - Layout, - Tabs, - Collapse, - Switch, - Slider, - InputNumber, - Row, - Col, - Button, - Select, - Input, - Spin - } from 'antd'; - import { - Settings, - Files, - Info, - Sun, - Camera, - Eye, - Sliders, - Box, - Move3d, - Palette, - Scissors, - Maximize2, - Image, - Lightbulb, - } from 'lucide-react'; -import axios from 'axios'; -import { API_URL } from '../config'; // Importing API_URL from your config -import { ALPHA_MASK_SLIDER_3D_DEFAULT, BRIGHTNESS_SLIDER_LEVEL_DEFAULT, CELL_SEGMENTATION_CHANNEL_NAME, DENSITY_SLIDER_LEVEL_DEFAULT, ISOSURFACE_OPACITY_SLIDER_MAX, LEVELS_SLIDER_DEFAULT, LUT_MAX_PERCENTILE, LUT_MIN_PERCENTILE, PRESET_COLORS_0, PRESET_COLOR_MAP, VIEWER_3D_SETTING } from './constants'; -import PlanarSlicePlayer from './PlanarSlicePlayer'; - +import * as THREE from "three"; +import { + loaderContext, + PREFETCH_DISTANCE, + MAX_PREFETCH_CHUNKS, + myState, +} from "./appConfig"; +import { useConstructor } from "./useConstructor"; +import { + Layout, + Tabs, + Collapse, + Switch, + Slider, + InputNumber, + Row, + Col, + Button, + Select, + Input, + Spin, +} from "antd"; +import { + Settings, + Files, + Info, + Sun, + Camera, + Eye, + Sliders, + Box, + Move3d, + Palette, + Scissors, + Maximize2, + Image, + Lightbulb, +} from "lucide-react"; +import axios from "axios"; +import { API_URL } from "../config"; // Importing API_URL from your config +import { + ALPHA_MASK_SLIDER_3D_DEFAULT, + BRIGHTNESS_SLIDER_LEVEL_DEFAULT, + CELL_SEGMENTATION_CHANNEL_NAME, + DENSITY_SLIDER_LEVEL_DEFAULT, + ISOSURFACE_OPACITY_SLIDER_MAX, + LEVELS_SLIDER_DEFAULT, + LUT_MAX_PERCENTILE, + LUT_MIN_PERCENTILE, + PRESET_COLORS_0, + PRESET_COLOR_MAP, + VIEWER_3D_SETTING, +} from "./constants"; +import PlanarSlicePlayer from "./PlanarSlicePlayer"; // Utility function to concatenate arrays const concatenateArrays = (arrays) => { - const totalLength = arrays.reduce((acc, arr) => acc + arr.length, 0); - const result = new Uint8Array(totalLength); - let offset = 0; - for (const arr of arrays) { - result.set(arr, offset); - offset += arr.length; - } - return result; -} + const totalLength = arrays.reduce((acc, arr) => acc + arr.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + for (const arr of arrays) { + result.set(arr, offset); + offset += arr.length; + } + return result; +}; const { Sider, Content } = Layout; const { TabPane } = Tabs; const { Vector3 } = THREE; const VolumeViewer = () => { - const viewerRef = useRef(null); - const volumeRef = useRef(null); - const view3D = useConstructor(() => new View3d({ parentElement: viewerRef.current })); - const loadContext = useConstructor(() => loaderContext); - - const [loader, setLoader] = useState(null); - const [fileData, setFileData] = useState({}); - const [selectedBodyPart, setSelectedBodyPart] = useState(null); - const [selectedFile, setSelectedFile] = useState(null); - const [currentVolume, setCurrentVolume] = useState(null); - const [density, setDensity] = useState(myState.density); - const [exposure, setExposure] = useState(myState.exposure); - const [lights, setLights] = useState([ - new Light(SKY_LIGHT), - new Light(AREA_LIGHT) - ]); - const [isPT, setIsPT] = useState(myState.isPT); - const [channels, setChannels] = useState([]); - const [cameraMode, setCameraMode] = useState('3D'); - const [isTurntable, setIsTurntable] = useState(false); - const [showAxis, setShowAxis] = useState(false); - const [showBoundingBox, setShowBoundingBox] = useState(false); - const [showScaleBar, setShowScaleBar] = useState(true); - const [backgroundColor, setBackgroundColor] = useState(myState.backgroundColor); - const [boundingBoxColor, setBoundingBoxColor] = useState(myState.boundingBoxColor); - const [flipX, setFlipX] = useState(1); - const [flipY, setFlipY] = useState(1); - const [flipZ, setFlipZ] = useState(1); - const [gamma, setGamma] = useState([0, 0.5, 1]); - const [clipRegion, setClipRegion] = useState({ - xmin: myState.xmin, - xmax: myState.xmax, - ymin: myState.ymin, - ymax: myState.ymax, - zmin: myState.zmin, - zmax: myState.zmax - }); - const [isPlaying, setIsPlaying] = useState(false); - const [currentFrame, setCurrentFrame] = useState(0); - const [totalFrames, setTotalFrames] = useState(0); - const [timerId, setTimerId] = useState(null); - const [isLoading, setIsLoading] = useState(false); - - const [maskAlpha, setMaskAlpha] = useState(myState.maskAlpha); - const [primaryRay, setPrimaryRay] = useState(myState.primaryRay); - const [secondaryRay, setSecondaryRay] = useState(myState.secondaryRay); - const [fov, setFov] = useState(myState.fov); - const [focalDistance, setFocalDistance] = useState(myState.focal_distance); - const [aperture, setAperture] = useState(myState.aperture); - const [samplingRate, setSamplingRate] = useState(myState.samplingRate); - - - - const [skyTopIntensity, setSkyTopIntensity] = useState(myState.skyTopIntensity); - const [skyMidIntensity, setSkyMidIntensity] = useState(myState.skyMidIntensity); - const [skyBotIntensity, setSkyBotIntensity] = useState(myState.skyBotIntensity); - const [skyTopColor, setSkyTopColor] = useState(myState.skyTopColor); - const [skyMidColor, setSkyMidColor] = useState(myState.skyMidColor); - const [skyBotColor, setSkyBotColor] = useState(myState.skyBotColor); - const [lightColor, setLightColor] = useState(myState.lightColor); - const [lightIntensity, setLightIntensity] = useState(myState.lightIntensity); - const [lightTheta, setLightTheta] = useState(myState.lightTheta); - const [lightPhi, setLightPhi] = useState(myState.lightPhi); - const [currentPreset, setCurrentPreset] = useState(0); // Default preset - const [settings, setSettings] = useState({ - maskAlpha: ALPHA_MASK_SLIDER_3D_DEFAULT[0], // 50 - brightness: BRIGHTNESS_SLIDER_LEVEL_DEFAULT[0], // 70 - density: DENSITY_SLIDER_LEVEL_DEFAULT[0], // 50 - levels: LEVELS_SLIDER_DEFAULT, // [35.0, 140.0, 255.0] - autoRotate: false, - pathTrace: false, - renderMode: RENDERMODE_RAYMARCH, - colorizeEnabled: false, - colorizeAlpha: 1.0, - selectedColorPalette: 0, - axisClip: { x: [0, 1], y: [0, 1], z: [0, 1] }, - }) - - // Add new state for persisting view settings - const [persistentSettings, setPersistentSettings] = useState({ - mode: '3D', // Default to 3D mode - channelSettings: {}, // Store channel-specific settings - density: 50, - brightness: 70, - maskAlpha: 50, - primaryRay: 1, - secondaryRay: 1, - clipRegion: { - xmin: 0, xmax: 1, - ymin: 0, ymax: 1, - zmin: 0, zmax: 1 - } - }); + const viewerRef = useRef(null); + const volumeRef = useRef(null); + const view3D = useConstructor( + () => new View3d({ parentElement: viewerRef.current }), + ); + const loadContext = useConstructor(() => loaderContext); + + const [loader, setLoader] = useState(null); + const [fileData, setFileData] = useState({}); + const [selectedBodyPart, setSelectedBodyPart] = useState(null); + const [selectedFile, setSelectedFile] = useState(null); + const [currentVolume, setCurrentVolume] = useState(null); + const [density, setDensity] = useState(myState.density); + const [exposure, setExposure] = useState(myState.exposure); + const [lights, setLights] = useState([ + new Light(SKY_LIGHT), + new Light(AREA_LIGHT), + ]); + const [isPT, setIsPT] = useState(myState.isPT); + const [channels, setChannels] = useState([]); + const [cameraMode, setCameraMode] = useState("3D"); + const [isTurntable, setIsTurntable] = useState(false); + const [showAxis, setShowAxis] = useState(false); + const [showBoundingBox, setShowBoundingBox] = useState(false); + const [showScaleBar, setShowScaleBar] = useState(true); + const [backgroundColor, setBackgroundColor] = useState( + myState.backgroundColor, + ); + const [boundingBoxColor, setBoundingBoxColor] = useState( + myState.boundingBoxColor, + ); + const [flipX, setFlipX] = useState(1); + const [flipY, setFlipY] = useState(1); + const [flipZ, setFlipZ] = useState(1); + const [gamma, setGamma] = useState([0, 0.5, 1]); + const [clipRegion, setClipRegion] = useState({ + xmin: myState.xmin, + xmax: myState.xmax, + ymin: myState.ymin, + ymax: myState.ymax, + zmin: myState.zmin, + zmax: myState.zmax, + }); + const [isPlaying, setIsPlaying] = useState(false); + const [currentFrame, setCurrentFrame] = useState(0); + const [totalFrames, setTotalFrames] = useState(0); + const [timerId, setTimerId] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const [maskAlpha, setMaskAlpha] = useState(myState.maskAlpha); + const [primaryRay, setPrimaryRay] = useState(myState.primaryRay); + const [secondaryRay, setSecondaryRay] = useState(myState.secondaryRay); + const [fov, setFov] = useState(myState.fov); + const [focalDistance, setFocalDistance] = useState(myState.focal_distance); + const [aperture, setAperture] = useState(myState.aperture); + const [samplingRate, setSamplingRate] = useState(myState.samplingRate); + + const [skyTopIntensity, setSkyTopIntensity] = useState( + myState.skyTopIntensity, + ); + const [skyMidIntensity, setSkyMidIntensity] = useState( + myState.skyMidIntensity, + ); + const [skyBotIntensity, setSkyBotIntensity] = useState( + myState.skyBotIntensity, + ); + const [skyTopColor, setSkyTopColor] = useState(myState.skyTopColor); + const [skyMidColor, setSkyMidColor] = useState(myState.skyMidColor); + const [skyBotColor, setSkyBotColor] = useState(myState.skyBotColor); + const [lightColor, setLightColor] = useState(myState.lightColor); + const [lightIntensity, setLightIntensity] = useState(myState.lightIntensity); + const [lightTheta, setLightTheta] = useState(myState.lightTheta); + const [lightPhi, setLightPhi] = useState(myState.lightPhi); + const [currentPreset, setCurrentPreset] = useState(0); // Default preset + const [settings, setSettings] = useState({ + maskAlpha: ALPHA_MASK_SLIDER_3D_DEFAULT[0], // 50 + brightness: BRIGHTNESS_SLIDER_LEVEL_DEFAULT[0], // 70 + density: DENSITY_SLIDER_LEVEL_DEFAULT[0], // 50 + levels: LEVELS_SLIDER_DEFAULT, // [35.0, 140.0, 255.0] + autoRotate: false, + pathTrace: false, + renderMode: RENDERMODE_RAYMARCH, + colorizeEnabled: false, + colorizeAlpha: 1.0, + selectedColorPalette: 0, + axisClip: { x: [0, 1], y: [0, 1], z: [0, 1] }, + }); + + // Add new state for persisting view settings + const [persistentSettings, setPersistentSettings] = useState({ + mode: "3D", // Default to 3D mode + channelSettings: {}, // Store channel-specific settings + density: 50, + brightness: 70, + maskAlpha: 50, + primaryRay: 1, + secondaryRay: 1, + clipRegion: { + xmin: 0, + xmax: 1, + ymin: 0, + ymax: 1, + zmin: 0, + zmax: 1, + }, + }); + + const [isoSurfaceSettings, setIsoSurfaceSettings] = useState({ + isosurfaceOpacityMax: ISOSURFACE_OPACITY_SLIDER_MAX, + defaultIsovalue: 128, + defaultOpacity: 1.0, + }); + + const densitySliderToView3D = (density) => density / 50.0; + + const onChannelDataArrived = (volume, channelIndex) => { + if (volume !== volumeRef.current) return; + + const histogram = volume.getHistogram(channelIndex); + if (!histogram) return; + + // Find percentile values + const hmin = histogram.findBinOfPercentile(LUT_MIN_PERCENTILE); + const hmax = histogram.findBinOfPercentile(LUT_MAX_PERCENTILE); + + // Create LUT using the Lut class + const lut = new Lut(); + const lutData = lut.createFromMinMax(hmin, hmax); + + // Set the LUT for the channel + volume.setLut(channelIndex, lutData); + + view3D.onVolumeData(volume, [channelIndex]); + + if (channels[channelIndex]) { + view3D.setVolumeChannelEnabled( + volume, + channelIndex, + channels[channelIndex].enabled, + ); + view3D.setVolumeChannelOptions(volume, channelIndex, { + color: channels[channelIndex].color, + opacity: 1.0, + brightness: 1.2, + contrast: 1.1, + }); + } - const [isoSurfaceSettings, setIsoSurfaceSettings] = useState({ - isosurfaceOpacityMax: ISOSURFACE_OPACITY_SLIDER_MAX, - defaultIsovalue: 128, - defaultOpacity: 1.0 - }); + view3D.updateActiveChannels(volume); + view3D.updateLuts(volume); + if (volume.isLoaded()) { + console.log("Volume " + volume.name + " is loaded"); + } + view3D.redraw(); + }; + + // Modify your onVolumeCreated function + const onVolumeCreated = (volume) => { + if (!volume || !volume.imageInfo) { + console.error("Invalid volume data"); + return; + } - const densitySliderToView3D = (density) => density / 50.0; - - const onChannelDataArrived = (volume, channelIndex) => { - if (volume !== volumeRef.current) return; - - const histogram = volume.getHistogram(channelIndex); - if (!histogram) return; - - // Find percentile values - const hmin = histogram.findBinOfPercentile(LUT_MIN_PERCENTILE); - const hmax = histogram.findBinOfPercentile(LUT_MAX_PERCENTILE); - - // Create LUT using the Lut class - const lut = new Lut(); - const lutData = lut.createFromMinMax(hmin, hmax); - - // Set the LUT for the channel - volume.setLut(channelIndex, lutData); - - view3D.onVolumeData(volume, [channelIndex]); - - if (channels[channelIndex]) { - view3D.setVolumeChannelEnabled(volume, channelIndex, channels[channelIndex].enabled); - view3D.setVolumeChannelOptions(volume, channelIndex, { - color: channels[channelIndex].color, - opacity: 1.0, - brightness: 1.2, - contrast: 1.1 - }); - } - - view3D.updateActiveChannels(volume); - view3D.updateLuts(volume); - - if (volume.isLoaded()) { - console.log("Volume " + volume.name + " is loaded"); - } - view3D.redraw(); - }; + console.log("Volume created with info:", volume.imageInfo); - // Modify your onVolumeCreated function - const onVolumeCreated = (volume) => { - if (!volume || !volume.imageInfo) { - console.error("Invalid volume data"); - return; - } + volumeRef.current = volume; + view3D.removeAllVolumes(); - console.log("Volume created with info:", volume.imageInfo); - - volumeRef.current = volume; - view3D.removeAllVolumes(); + // Log the dimensions specifically + console.log("Dimensions:", { + sizeX: volume.imageInfo.sizeX, + sizeY: volume.imageInfo.sizeY, + sizeZ: volume.imageInfo.sizeZ, + sizeC: volume.imageInfo.sizeC, + }); - // Log the dimensions specifically - console.log("Dimensions:", { - sizeX: volume.imageInfo.sizeX, - sizeY: volume.imageInfo.sizeY, - sizeZ: volume.imageInfo.sizeZ, - sizeC: volume.imageInfo.sizeC - }); - - // Initialize channels with persisted settings if available - const channelNames = volume.imageInfo.channelNames || []; - const newChannels = channelNames.map((name, index) => { - const persistedChannel = persistentSettings.channelSettings[index] || {}; - const defaultColor = PRESET_COLORS_0[index % PRESET_COLORS_0.length]; - - return { - name, - enabled: persistedChannel.enabled ?? (index < 3), - color: persistedChannel.color || defaultColor, - isosurfaceEnabled: persistedChannel.isosurfaceEnabled ?? false, - isovalue: persistedChannel.isovalue ?? 128, - opacity: persistedChannel.opacity ?? 1.0, - lut: ["p50", "p98"] - }; - }); - - // Add volume with persisted settings - view3D.addVolume(volume, { - channels: newChannels.map(ch => ({ - enabled: ch.enabled, - color: ch.color, - isosurfaceEnabled: ch.isosurfaceEnabled, - isovalue: ch.isovalue, - isosurfaceOpacity: ch.opacity - })) - }); - - // Apply persisted view mode and settings - setCameraMode(persistentSettings.mode); - view3D.setCameraMode(persistentSettings.mode); - - // Apply other persisted settings - updateSetting('density', persistentSettings.density); - updateSetting('brightness', persistentSettings.brightness); - updateSetting('maskAlpha', persistentSettings.maskAlpha); - setPrimaryRay(persistentSettings.primaryRay); - setSecondaryRay(persistentSettings.secondaryRay); - setClipRegion(persistentSettings.clipRegion); - - - // 4. Apply initial volume settings - // Mask alpha - const alphaValue = 1 - (settings.maskAlpha / 100); - view3D.updateMaskAlpha(volume, alphaValue); - - // Brightness - const brightnessValue = settings.brightness / 100; - view3D.updateExposure(brightnessValue); - - // Density - const densityValue = settings.density / 100; - view3D.updateDensity(volume, densityValue); - - // Gamma levels - const [min, mid, max] = settings.levels.map(v => v / 255); - const diff = max - min; - const x = (mid - min) / diff; - const scale = 4 * x * x; - view3D.setGamma(volume, min, scale, max); - - channelNames.forEach((_, index) => { - if (volume.getHistogram) { - const histogram = volume.getHistogram(index); - if (histogram) { - // Find percentile values - const hmin = histogram.findBinOfPercentile(LUT_MIN_PERCENTILE); - const hmax = histogram.findBinOfPercentile(LUT_MAX_PERCENTILE); - - // Create LUT using the Lut class - const lut = new Lut(); - const lutData = lut.createFromMinMax(hmin, hmax); - - // Set the LUT for the channel - volume.setLut(index, lutData); - - // Save control points if needed - const controlPoints = [ - { x: 0, opacity: 0, color: newChannels[index].color }, - { x: hmin, opacity: 0.1, color: newChannels[index].color }, - { x: (hmin + hmax) / 2, opacity: 0.5, color: newChannels[index].color }, - { x: hmax, opacity: 1.0, color: newChannels[index].color }, - { x: 255, opacity: 1.0, color: newChannels[index].color } - ]; - - newChannels[index].controlPoints = controlPoints; - } - } - }); + // Initialize channels with persisted settings if available + const channelNames = volume.imageInfo.channelNames || []; + const newChannels = channelNames.map((name, index) => { + const persistedChannel = persistentSettings.channelSettings[index] || {}; + const defaultColor = PRESET_COLORS_0[index % PRESET_COLORS_0.length]; + + return { + name, + enabled: persistedChannel.enabled ?? index < 3, + color: persistedChannel.color || defaultColor, + isosurfaceEnabled: persistedChannel.isosurfaceEnabled ?? false, + isovalue: persistedChannel.isovalue ?? 128, + opacity: persistedChannel.opacity ?? 1.0, + lut: ["p50", "p98"], + }; + }); - // Initialize masks and LUTs - const segIndex = channelNames.findIndex(name => - name === CELL_SEGMENTATION_CHANNEL_NAME - ); - if (segIndex !== -1) { - view3D.setVolumeChannelAsMask(volume, segIndex); - } - - view3D.setVolumeRenderMode(settings.pathTrace ? RENDERMODE_PATHTRACE : RENDERMODE_RAYMARCH); - view3D.updateActiveChannels(volume); - view3D.updateLuts(volume); - - setChannels(newChannels); - setCurrentVolume(volume); - view3D.redraw(); - }; - - - const loadVolume = async (loadSpec, loader) => { - const volume = await loader.createVolume(loadSpec, onChannelDataArrived); - console.log("Loaded volume metadata:", volume.imageInfo); - onVolumeCreated(volume); - - console.log(volume.imageInfo, volume.imageInfo.times) - // Set total frames based on the volume's metadata (assuming 'times' represents the number of frames) - setTotalFrames(volume.imageInfo.times || 1); - await loader.loadVolumeData(volume); - }; + // Add volume with persisted settings + view3D.addVolume(volume, { + channels: newChannels.map((ch) => ({ + enabled: ch.enabled, + color: ch.color, + isosurfaceEnabled: ch.isosurfaceEnabled, + isovalue: ch.isovalue, + isosurfaceOpacity: ch.opacity, + })), + }); - const loadVolumeFromServer = async (url) => { - setIsLoading(true); - try { - const loadSpec = new LoadSpec(); - const fileExtension = url.split('.').pop(); - const volumeFileType = (fileExtension === 'tiff' || fileExtension === 'tif' || fileExtension === 'ome.tiff' || fileExtension === 'ome.tif') ? VolumeFileFormat.TIFF : VolumeFileFormat.ZARR; - const loader = await loadContext.createLoader(url, { - fileType: volumeFileType, - fetchOptions: { maxPrefetchDistance: PREFETCH_DISTANCE, maxPrefetchChunks: MAX_PREFETCH_CHUNKS }, - }); - - setLoader(loader); - await loadVolume(loadSpec, loader); - } catch (error) { - console.error('Error loading volume:', error); - } finally { - setIsLoading(false); + // Apply persisted view mode and settings + setCameraMode(persistentSettings.mode); + view3D.setCameraMode(persistentSettings.mode); + + // Apply other persisted settings + updateSetting("density", persistentSettings.density); + updateSetting("brightness", persistentSettings.brightness); + updateSetting("maskAlpha", persistentSettings.maskAlpha); + setPrimaryRay(persistentSettings.primaryRay); + setSecondaryRay(persistentSettings.secondaryRay); + setClipRegion(persistentSettings.clipRegion); + + // 4. Apply initial volume settings + // Mask alpha + const alphaValue = 1 - settings.maskAlpha / 100; + view3D.updateMaskAlpha(volume, alphaValue); + + // Brightness + const brightnessValue = settings.brightness / 100; + view3D.updateExposure(brightnessValue); + + // Density + const densityValue = settings.density / 100; + view3D.updateDensity(volume, densityValue); + + // Gamma levels + const [min, mid, max] = settings.levels.map((v) => v / 255); + const diff = max - min; + const x = (mid - min) / diff; + const scale = 4 * x * x; + view3D.setGamma(volume, min, scale, max); + + channelNames.forEach((_, index) => { + if (volume.getHistogram) { + const histogram = volume.getHistogram(index); + if (histogram) { + // Find percentile values + const hmin = histogram.findBinOfPercentile(LUT_MIN_PERCENTILE); + const hmax = histogram.findBinOfPercentile(LUT_MAX_PERCENTILE); + + // Create LUT using the Lut class + const lut = new Lut(); + const lutData = lut.createFromMinMax(hmin, hmax); + + // Set the LUT for the channel + volume.setLut(index, lutData); + + // Save control points if needed + const controlPoints = [ + { x: 0, opacity: 0, color: newChannels[index].color }, + { x: hmin, opacity: 0.1, color: newChannels[index].color }, + { + x: (hmin + hmax) / 2, + opacity: 0.5, + color: newChannels[index].color, + }, + { x: hmax, opacity: 1.0, color: newChannels[index].color }, + { x: 255, opacity: 1.0, color: newChannels[index].color }, + ]; + + newChannels[index].controlPoints = controlPoints; } - }; + } + }); - const createTestVolume = () => { - const sizeX = 64; - const sizeY = 64; - const sizeZ = 64; - const imgData = { - name: "AICS-10_5_5", - sizeX, - sizeY, - sizeZ, - sizeC: 3, - physicalPixelSize: [1, 1, 1], - spatialUnit: "", - channelNames: ["DRAQ5", "EGFP", "SEG_Memb"], - }; + // Initialize masks and LUTs + const segIndex = channelNames.findIndex( + (name) => name === CELL_SEGMENTATION_CHANNEL_NAME, + ); + if (segIndex !== -1) { + view3D.setVolumeChannelAsMask(volume, segIndex); + } - const channelVolumes = [ - VolumeMaker.createSphere(sizeX, sizeY, sizeZ, 24), - VolumeMaker.createTorus(sizeX, sizeY, sizeZ, 24, 8), - VolumeMaker.createCone(sizeX, sizeY, sizeZ, 24, 24), - ]; - - const alldata = concatenateArrays(channelVolumes); - return { - metadata: imgData, - data: { - dtype: "uint8", - shape: [channelVolumes.length, sizeZ, sizeY, sizeX], - buffer: new DataView(alldata.buffer), - }, - }; - }; + view3D.setVolumeRenderMode( + settings.pathTrace ? RENDERMODE_PATHTRACE : RENDERMODE_RAYMARCH, + ); + view3D.updateActiveChannels(volume); + view3D.updateLuts(volume); + + setChannels(newChannels); + setCurrentVolume(volume); + view3D.redraw(); + }; + + const loadVolume = async (loadSpec, loader) => { + const volume = await loader.createVolume(loadSpec, onChannelDataArrived); + console.log("Loaded volume metadata:", volume.imageInfo); + onVolumeCreated(volume); + + console.log(volume.imageInfo, volume.imageInfo.times); + // Set total frames based on the volume's metadata (assuming 'times' represents the number of frames) + setTotalFrames(volume.imageInfo.times || 1); + await loader.loadVolumeData(volume); + }; + + const loadVolumeFromServer = async (url) => { + setIsLoading(true); + try { + const loadSpec = new LoadSpec(); + const fileExtension = url.split(".").pop(); + const volumeFileType = + fileExtension === "tiff" || + fileExtension === "tif" || + fileExtension === "ome.tiff" || + fileExtension === "ome.tif" + ? VolumeFileFormat.TIFF + : VolumeFileFormat.ZARR; + const loader = await loadContext.createLoader(url, { + fileType: volumeFileType, + fetchOptions: { + maxPrefetchDistance: PREFETCH_DISTANCE, + maxPrefetchChunks: MAX_PREFETCH_CHUNKS, + }, + }); - const fetchFiles = async () => { - try { - const response = await axios.get(`${API_URL}/files`); - setFileData(response.data); - } catch (error) { - console.error('Error fetching files:', error); - } + setLoader(loader); + await loadVolume(loadSpec, loader); + } catch (error) { + console.error("Error loading volume:", error); + } finally { + setIsLoading(false); + } + }; + + const createTestVolume = () => { + const sizeX = 64; + const sizeY = 64; + const sizeZ = 64; + const imgData = { + name: "AICS-10_5_5", + sizeX, + sizeY, + sizeZ, + sizeC: 3, + physicalPixelSize: [1, 1, 1], + spatialUnit: "", + channelNames: ["DRAQ5", "EGFP", "SEG_Memb"], }; - const handleFileSelect = async (bodyPart, file) => { - setSelectedBodyPart(bodyPart); - setSelectedFile(file); - await loadVolumeFromServer(`${API_URL}/${bodyPart}/${file}`); + const channelVolumes = [ + VolumeMaker.createSphere(sizeX, sizeY, sizeZ, 24), + VolumeMaker.createTorus(sizeX, sizeY, sizeZ, 24, 8), + VolumeMaker.createCone(sizeX, sizeY, sizeZ, 24, 24), + ]; + + const alldata = concatenateArrays(channelVolumes); + return { + metadata: imgData, + data: { + dtype: "uint8", + shape: [channelVolumes.length, sizeZ, sizeY, sizeX], + buffer: new DataView(alldata.buffer), + }, }; + }; + + const fetchFiles = async () => { + try { + const response = await axios.get(`${API_URL}/files`); + setFileData(response.data); + } catch (error) { + console.error("Error fetching files:", error); + } + }; - useEffect(() => { - fetchFiles(); - }, []); + const handleFileSelect = async (bodyPart, file) => { + setSelectedBodyPart(bodyPart); + setSelectedFile(file); + await loadVolumeFromServer(`${API_URL}/${bodyPart}/${file}`); + }; - useEffect(() => { - if (viewerRef.current) { - const container = viewerRef.current; - container.appendChild(view3D.getDOMElement()); + useEffect(() => { + fetchFiles(); + }, []); - const handleResize = () => view3D.resize(); - window.addEventListener("resize", handleResize); + useEffect(() => { + if (viewerRef.current) { + const container = viewerRef.current; + container.appendChild(view3D.getDOMElement()); - view3D.resize(); + const handleResize = () => view3D.resize(); + window.addEventListener("resize", handleResize); - return () => { - window.removeEventListener("resize", handleResize); - if (view3D.getDOMElement().parentNode) { - view3D.getDOMElement().parentNode.removeChild(view3D.getDOMElement()); - } - view3D.removeAllVolumes(); - }; - } - }, [viewerRef, view3D]); - - useEffect(() => { - if (!currentVolume || !view3D) return; - const densityValue = settings.density / 100; - view3D.updateDensity(currentVolume, densityValue); - view3D.redraw(); - }, [settings.density]); - - useEffect(() => { - if (!view3D) return; - const brightnessValue = settings.brightness / 100; - view3D.updateExposure(brightnessValue); - view3D.redraw(); - }, [settings.brightness]) - - useEffect(() => { - view3D.setVolumeRenderMode(isPT ? RENDERMODE_PATHTRACE : RENDERMODE_RAYMARCH); - view3D.redraw(); - }, [isPT, view3D]); - - useEffect(() => { - if (currentVolume) { - view3D.updateLights(lights); - view3D.updateActiveChannels(currentVolume); - view3D.redraw(); - } - }, [lights, view3D]); + view3D.resize(); - useEffect(() => { - if (currentVolume) { - view3D.updateActiveChannels(currentVolume); - view3D.updateLuts(currentVolume); - view3D.redraw(); + return () => { + window.removeEventListener("resize", handleResize); + if (view3D.getDOMElement().parentNode) { + view3D.getDOMElement().parentNode.removeChild(view3D.getDOMElement()); } - }, [channels]); + view3D.removeAllVolumes(); + }; + } + }, [viewerRef, view3D]); + + useEffect(() => { + if (!currentVolume || !view3D) return; + const densityValue = settings.density / 100; + view3D.updateDensity(currentVolume, densityValue); + view3D.redraw(); + }, [settings.density]); + + useEffect(() => { + if (!view3D) return; + const brightnessValue = settings.brightness / 100; + view3D.updateExposure(brightnessValue); + view3D.redraw(); + }, [settings.brightness]); + + useEffect(() => { + view3D.setVolumeRenderMode( + isPT ? RENDERMODE_PATHTRACE : RENDERMODE_RAYMARCH, + ); + view3D.redraw(); + }, [isPT, view3D]); + + useEffect(() => { + if (currentVolume) { + view3D.updateLights(lights); + view3D.updateActiveChannels(currentVolume); + view3D.redraw(); + } + }, [lights, view3D]); - useEffect(() => { - view3D.setCameraMode(cameraMode); - }, [cameraMode]); + useEffect(() => { + if (currentVolume) { + view3D.updateActiveChannels(currentVolume); + view3D.updateLuts(currentVolume); + view3D.redraw(); + } + }, [channels]); - useEffect(() => { - view3D.setAutoRotate(isTurntable); - }, [isTurntable, view3D]); + useEffect(() => { + view3D.setCameraMode(cameraMode); + }, [cameraMode]); - useEffect(() => { - view3D.setShowAxis(showAxis); - }, [showAxis, view3D]); + useEffect(() => { + view3D.setAutoRotate(isTurntable); + }, [isTurntable, view3D]); - useEffect(() => { - if (currentVolume) { - view3D.setShowBoundingBox(currentVolume, showBoundingBox); - } - }, [currentVolume, showBoundingBox, view3D]); + useEffect(() => { + view3D.setShowAxis(showAxis); + }, [showAxis, view3D]); - useEffect(() => { - view3D.setShowScaleBar(showScaleBar); - }, [showScaleBar, view3D]); + useEffect(() => { + if (currentVolume) { + view3D.setShowBoundingBox(currentVolume, showBoundingBox); + } + }, [currentVolume, showBoundingBox, view3D]); - useEffect(() => { - view3D.setBackgroundColor(backgroundColor); - }, [backgroundColor, view3D]); + useEffect(() => { + view3D.setShowScaleBar(showScaleBar); + }, [showScaleBar, view3D]); - useEffect(() => { - if (currentVolume) { - view3D.setBoundingBoxColor(currentVolume, boundingBoxColor); - } - }, [boundingBoxColor]); + useEffect(() => { + view3D.setBackgroundColor(backgroundColor); + }, [backgroundColor, view3D]); - useEffect(() => { - if (currentVolume) { - view3D.setFlipVolume(currentVolume, flipX, flipY, flipZ); - } - }, [flipX, flipY, flipZ]); - - useEffect(() => { - if (!currentVolume || !view3D) return; - const [min, mid, max] = settings.levels.map(v => v / 255); - const diff = max - min; - const x = (mid - min) / diff; - const scale = 4 * x * x; - view3D.setGamma(currentVolume, min, scale, max); - view3D.redraw(); - }, [settings.levels]); - - useEffect(() => { - if (currentVolume) { - view3D.updateClipRegion( - currentVolume, - clipRegion.xmin, - clipRegion.xmax, - clipRegion.ymin, - clipRegion.ymax, - clipRegion.zmin, - clipRegion.zmax - ); - } - }, [clipRegion]); + useEffect(() => { + if (currentVolume) { + view3D.setBoundingBoxColor(currentVolume, boundingBoxColor); + } + }, [boundingBoxColor]); - useEffect(() => { - if (currentVolume) { - view3D.updateCamera(fov, focalDistance, aperture); - view3D.updateActiveChannels(currentVolume); - view3D.redraw(); - } - }, [fov, focalDistance, aperture]); + useEffect(() => { + if (currentVolume) { + view3D.setFlipVolume(currentVolume, flipX, flipY, flipZ); + } + }, [flipX, flipY, flipZ]); + + useEffect(() => { + if (!currentVolume || !view3D) return; + const [min, mid, max] = settings.levels.map((v) => v / 255); + const diff = max - min; + const x = (mid - min) / diff; + const scale = 4 * x * x; + view3D.setGamma(currentVolume, min, scale, max); + view3D.redraw(); + }, [settings.levels]); + + useEffect(() => { + if (currentVolume) { + view3D.updateClipRegion( + currentVolume, + clipRegion.xmin, + clipRegion.xmax, + clipRegion.ymin, + clipRegion.ymax, + clipRegion.zmin, + clipRegion.zmax, + ); + } + }, [clipRegion]); + useEffect(() => { + if (currentVolume) { + view3D.updateCamera(fov, focalDistance, aperture); + view3D.updateActiveChannels(currentVolume); + view3D.redraw(); + } + }, [fov, focalDistance, aperture]); - useEffect(() => { - if (currentVolume) { - view3D.setRayStepSizes(currentVolume, primaryRay, secondaryRay); - view3D.updateActiveChannels(currentVolume); - view3D.redraw(); - } - }, [primaryRay, secondaryRay]); - - useEffect(() => { - if (!currentVolume || !view3D) return; - const alphaValue = 1 - (settings.maskAlpha / 100.0); - view3D.updateMaskAlpha(currentVolume, alphaValue); - view3D.updateActiveChannels(currentVolume); - // view3D.redraw(); - console.log("maskAlpha", settings.maskAlpha); - }, [settings.maskAlpha]); - - useEffect(() => { - if (view3D && lights[0]) { - const skyLight = lights[0]; - skyLight.mColorTop = new Vector3( - (skyTopColor[0] / 255.0) * skyTopIntensity, - (skyTopColor[1] / 255.0) * skyTopIntensity, - (skyTopColor[2] / 255.0) * skyTopIntensity - ); - skyLight.mColorMiddle = new Vector3( - (skyMidColor[0] / 255.0) * skyMidIntensity, - (skyMidColor[1] / 255.0) * skyMidIntensity, - (skyMidColor[2] / 255.0) * skyMidIntensity - ); - skyLight.mColorBottom = new Vector3( - (skyBotColor[0] / 255.0) * skyBotIntensity, - (skyBotColor[1] / 255.0) * skyBotIntensity, - (skyBotColor[2] / 255.0) * skyBotIntensity - ); - view3D.updateLights(lights); - view3D.updateActiveChannels(currentVolume); - view3D.redraw(); - console.log([skyTopColor, skyTopIntensity, skyMidColor, skyMidIntensity, skyBotColor, skyBotIntensity]); - } - - }, [skyTopColor, skyTopIntensity, skyMidColor, skyMidIntensity, skyBotColor, skyBotIntensity]); - - // useEffect for area light - useEffect(() => { - if (view3D && lights[1]) { - const areaLight = lights[1]; - areaLight.mColor = new Vector3( - (lightColor[0] / 255.0) * lightIntensity, - (lightColor[1] / 255.0) * lightIntensity, - (lightColor[2] / 255.0) * lightIntensity - ); - areaLight.mTheta = (lightTheta * Math.PI) / 180.0; - areaLight.mPhi = (lightPhi * Math.PI) / 180.0; - view3D.updateLights(lights); - view3D.updateActiveChannels(currentVolume); - view3D.redraw(); - } - console.log([lightColor, lightIntensity, lightTheta, lightPhi]); - }, [lightColor, lightIntensity, lightTheta, lightPhi]); - - - // Effect for handling isosurface enable/disable - useEffect(() => { - if (!currentVolume || !view3D) return; - - channels.forEach((channel, index) => { - view3D.setVolumeChannelOptions( - currentVolume, - index, - { - isosurfaceEnabled: channel.isosurfaceEnabled, - isovalue: channel.isovalue, - opacity: channel.opacity - } - ); - }); + useEffect(() => { + if (currentVolume) { + view3D.setRayStepSizes(currentVolume, primaryRay, secondaryRay); + view3D.updateActiveChannels(currentVolume); + view3D.redraw(); + } + }, [primaryRay, secondaryRay]); + + useEffect(() => { + if (!currentVolume || !view3D) return; + const alphaValue = 1 - settings.maskAlpha / 100.0; + view3D.updateMaskAlpha(currentVolume, alphaValue); + view3D.updateActiveChannels(currentVolume); + // view3D.redraw(); + console.log("maskAlpha", settings.maskAlpha); + }, [settings.maskAlpha]); + + useEffect(() => { + if (view3D && lights[0]) { + const skyLight = lights[0]; + skyLight.mColorTop = new Vector3( + (skyTopColor[0] / 255.0) * skyTopIntensity, + (skyTopColor[1] / 255.0) * skyTopIntensity, + (skyTopColor[2] / 255.0) * skyTopIntensity, + ); + skyLight.mColorMiddle = new Vector3( + (skyMidColor[0] / 255.0) * skyMidIntensity, + (skyMidColor[1] / 255.0) * skyMidIntensity, + (skyMidColor[2] / 255.0) * skyMidIntensity, + ); + skyLight.mColorBottom = new Vector3( + (skyBotColor[0] / 255.0) * skyBotIntensity, + (skyBotColor[1] / 255.0) * skyBotIntensity, + (skyBotColor[2] / 255.0) * skyBotIntensity, + ); + view3D.updateLights(lights); + view3D.updateActiveChannels(currentVolume); + view3D.redraw(); + console.log([ + skyTopColor, + skyTopIntensity, + skyMidColor, + skyMidIntensity, + skyBotColor, + skyBotIntensity, + ]); + } + }, [ + skyTopColor, + skyTopIntensity, + skyMidColor, + skyMidIntensity, + skyBotColor, + skyBotIntensity, + ]); + + // useEffect for area light + useEffect(() => { + if (view3D && lights[1]) { + const areaLight = lights[1]; + areaLight.mColor = new Vector3( + (lightColor[0] / 255.0) * lightIntensity, + (lightColor[1] / 255.0) * lightIntensity, + (lightColor[2] / 255.0) * lightIntensity, + ); + areaLight.mTheta = (lightTheta * Math.PI) / 180.0; + areaLight.mPhi = (lightPhi * Math.PI) / 180.0; + view3D.updateLights(lights); + view3D.updateActiveChannels(currentVolume); + view3D.redraw(); + } + console.log([lightColor, lightIntensity, lightTheta, lightPhi]); + }, [lightColor, lightIntensity, lightTheta, lightPhi]); + + // Effect for handling isosurface enable/disable + useEffect(() => { + if (!currentVolume || !view3D) return; + + channels.forEach((channel, index) => { + view3D.setVolumeChannelOptions(currentVolume, index, { + isosurfaceEnabled: channel.isosurfaceEnabled, + isovalue: channel.isovalue, + opacity: channel.opacity, + }); + }); - view3D.updateMaterial(currentVolume); - view3D.redraw(); - }, [channels.map(ch => ch.isosurfaceEnabled).join(',')]); // Dependency on isosurfaceEnabled values - - // Effect for handling isovalue changes - useEffect(() => { - if (!currentVolume || !view3D) return; - - channels.forEach((channel, index) => { - if (channel.isosurfaceEnabled) { - view3D.setVolumeChannelOptions( - currentVolume, - index, - { - isosurfaceEnabled: true, - isovalue: channel.isovalue, - opacity: channel.opacity - } - ); - } - }); + view3D.updateMaterial(currentVolume); + view3D.redraw(); + }, [channels.map((ch) => ch.isosurfaceEnabled).join(",")]); // Dependency on isosurfaceEnabled values - view3D.updateMaterial(currentVolume); - view3D.redraw(); - }, [channels.map(ch => ch.isovalue).join(',')]); // Dependency on isovalue changes - - // Effect for handling opacity changes - useEffect(() => { - if (!currentVolume || !view3D) return; - - channels.forEach((channel, index) => { - if (channel.isosurfaceEnabled) { - view3D.setVolumeChannelOptions( - currentVolume, - index, - { - isosurfaceEnabled: true, - isovalue: channel.isovalue, - isosurfaceOpacity: channel.opacity - } - ); - } - }); + // Effect for handling isovalue changes + useEffect(() => { + if (!currentVolume || !view3D) return; - view3D.updateMaterial(currentVolume); - view3D.redraw(); - }, [channels.map(ch => ch.opacity).join(',')]); // Dependency on opacity changes - - - - useEffect(() => { - if (!currentVolume || !view3D) return; - channels.forEach((channel, index) => { - if (channel.isosurfaceEnabled) { - view3D.setVolumeChannelOptions( - currentVolume, - index, - { - isosurfaceEnabled: true, - isovalue: channel.isovalue, - isosurfaceOpacity: channel.opacity, - opacity: channel.opacity // Include both for compatibility - } - ); - // Force material update - view3D.updateMaterial(currentVolume); - } + channels.forEach((channel, index) => { + if (channel.isosurfaceEnabled) { + view3D.setVolumeChannelOptions(currentVolume, index, { + isosurfaceEnabled: true, + isovalue: channel.isovalue, + opacity: channel.opacity, }); - view3D.redraw(); - }, [channels.map(ch => `${ch.isosurfaceEnabled}-${ch.opacity}`).join(',')]); + } + }); - const setInitialRenderMode = () => { - view3D.setVolumeRenderMode(isPT ? RENDERMODE_PATHTRACE : RENDERMODE_RAYMARCH); - view3D.setMaxProjectMode(currentVolume, false); - }; + view3D.updateMaterial(currentVolume); + view3D.redraw(); + }, [channels.map((ch) => ch.isovalue).join(",")]); // Dependency on isovalue changes - const DEFAULT_CHANNEL_COLOR = [128, 128, 128]; // Medium gray - - // Modify your showChannelUI function - const showChannelUI = (volume) => { - const currentPresetColors = PRESET_COLOR_MAP[currentPreset].colors; - - const channelGui = volume.imageInfo.channelNames.map((name, index) => { - const channelColor = currentPresetColors[index % currentPresetColors.length]; - - return { - name, - enabled: index < 3, - colorD: channelColor, - colorS: [0, 0, 0], - colorE: [0, 0, 0], - glossiness: 0, - window: 1, - level: 0.5, - isovalue: 128, - isosurface: false, - brightness: 1.2, - contrast: 1.1 - }; - }); - - setChannels(channelGui); - - // Force update channel materials - channelGui.forEach((channel, index) => { - if (channel.enabled) { - view3D.updateChannelMaterial( - volume, - index, - channel.colorD, - channel.colorS, - channel.colorE, - channel.glossiness - ); - } - }); - - view3D.updateMaterial(volume); - view3D.redraw(); - }; - - - const updateChannel = (index, key, value) => { - const updatedChannels = [...channels]; - updatedChannels[index][key] = value; - setChannels(updatedChannels); - - if (currentVolume) { - if (key === 'enabled') { - view3D.setVolumeChannelEnabled(currentVolume, index, value); - } else if (key === 'isosurface') { - view3D.setVolumeChannelOptions(currentVolume, index, { isosurfaceEnabled: value }); - if (value) { - view3D.createIsosurface(currentVolume, index, updatedChannels[index].isovalue, 1.0); - } else { - view3D.clearIsosurface(currentVolume, index); - } - } else if (['colorD', 'colorS', 'colorE', 'glossiness'].includes(key)) { - view3D.updateChannelMaterial( - currentVolume, - index, - updatedChannels[index].colorD, - updatedChannels[index].colorS, - updatedChannels[index].colorE, - updatedChannels[index].glossiness - ); - view3D.updateMaterial(currentVolume); - } else if (key === 'window' || key === 'level') { - const lut = new Lut().createFromWindowLevel( - updatedChannels[index].window, - updatedChannels[index].level - ); - currentVolume.setLut(index, lut); - view3D.updateLuts(currentVolume); - } - view3D.redraw(); - } - } + // Effect for handling opacity changes + useEffect(() => { + if (!currentVolume || !view3D) return; + channels.forEach((channel, index) => { + if (channel.isosurfaceEnabled) { + view3D.setVolumeChannelOptions(currentVolume, index, { + isosurfaceEnabled: true, + isovalue: channel.isovalue, + isosurfaceOpacity: channel.opacity, + }); + } + }); - const updateChannelOptions = (index, options) => { - if (!currentVolume || !view3D) return; - - const updatedChannels = [...channels]; - updatedChannels[index] = { ...updatedChannels[index], ...options }; - setChannels(updatedChannels); - }; + view3D.updateMaterial(currentVolume); + view3D.redraw(); + }, [channels.map((ch) => ch.opacity).join(",")]); // Dependency on opacity changes + + useEffect(() => { + if (!currentVolume || !view3D) return; + channels.forEach((channel, index) => { + if (channel.isosurfaceEnabled) { + view3D.setVolumeChannelOptions(currentVolume, index, { + isosurfaceEnabled: true, + isovalue: channel.isovalue, + isosurfaceOpacity: channel.opacity, + opacity: channel.opacity, // Include both for compatibility + }); + // Force material update + view3D.updateMaterial(currentVolume); + } + }); + view3D.redraw(); + }, [channels.map((ch) => `${ch.isosurfaceEnabled}-${ch.opacity}`).join(",")]); + + const setInitialRenderMode = () => { + view3D.setVolumeRenderMode( + isPT ? RENDERMODE_PATHTRACE : RENDERMODE_RAYMARCH, + ); + view3D.setMaxProjectMode(currentVolume, false); + }; + + const DEFAULT_CHANNEL_COLOR = [128, 128, 128]; // Medium gray + + // Modify your showChannelUI function + const showChannelUI = (volume) => { + const currentPresetColors = PRESET_COLOR_MAP[currentPreset].colors; + + const channelGui = volume.imageInfo.channelNames.map((name, index) => { + const channelColor = + currentPresetColors[index % currentPresetColors.length]; + + return { + name, + enabled: index < 3, + colorD: channelColor, + colorS: [0, 0, 0], + colorE: [0, 0, 0], + glossiness: 0, + window: 1, + level: 0.5, + isovalue: 128, + isosurface: false, + brightness: 1.2, + contrast: 1.1, + }; + }); - const initializeChannelOptions = (volume) => { - const channelOptions = volume.imageInfo.channelNames.map((name, index) => ({ - name, - enabled: index < 3, - color: volume.channelColorsDefault[index] || [128, 128, 128], - specularColor: [0, 0, 0], - emissiveColor: [0, 0, 0], - glossiness: 0, - isosurfaceEnabled: false, - isovalue: 127, - isosurfaceOpacity: 1.0 - })); - setChannels(channelOptions); - }; + setChannels(channelGui); + + // Force update channel materials + channelGui.forEach((channel, index) => { + if (channel.enabled) { + view3D.updateChannelMaterial( + volume, + index, + channel.colorD, + channel.colorS, + channel.colorE, + channel.glossiness, + ); + } + }); - const updateIsovalue = (index, isovalue) => { - if (!currentVolume || !view3D) return; - - const updatedChannels = [...channels]; - updatedChannels[index] = { - ...updatedChannels[index], - isovalue - }; - setChannels(updatedChannels); - - view3D.setVolumeChannelOptions( + view3D.updateMaterial(volume); + view3D.redraw(); + }; + + const updateChannel = (index, key, value) => { + const updatedChannels = [...channels]; + updatedChannels[index][key] = value; + setChannels(updatedChannels); + + if (currentVolume) { + if (key === "enabled") { + view3D.setVolumeChannelEnabled(currentVolume, index, value); + } else if (key === "isosurface") { + view3D.setVolumeChannelOptions(currentVolume, index, { + isosurfaceEnabled: value, + }); + if (value) { + view3D.createIsosurface( currentVolume, index, - { - isosurfaceEnabled: updatedChannels[index].isosurfaceEnabled, - isovalue: isovalue, - isosurfaceOpacity: updatedChannels[index].opacity - } + updatedChannels[index].isovalue, + 1.0, + ); + } else { + view3D.clearIsosurface(currentVolume, index); + } + } else if (["colorD", "colorS", "colorE", "glossiness"].includes(key)) { + view3D.updateChannelMaterial( + currentVolume, + index, + updatedChannels[index].colorD, + updatedChannels[index].colorS, + updatedChannels[index].colorE, + updatedChannels[index].glossiness, ); - view3D.updateMaterial(currentVolume); - view3D.redraw(); - }; - - // Histogram-based LUT adjustments - const updateChannelLut = (index, type) => { - if (currentVolume) { - let lut; - if (type === 'autoIJ') { - const [hmin, hmax] = currentVolume.getHistogram(index).findAutoIJBins(); - lut = new Lut().createFromMinMax(hmin, hmax); - } else if (type === 'auto0') { - const [b, e] = currentVolume.getHistogram(index).findAutoMinMax(); - lut = new Lut().createFromMinMax(b, e); - } else if (type === 'bestFit') { - const [hmin, hmax] = currentVolume.getHistogram(index).findBestFitBins(); - lut = new Lut().createFromMinMax(hmin, hmax); - } else if (type === 'pct50_98') { - const hmin = currentVolume.getHistogram(index).findBinOfPercentile(0.5); - const hmax = currentVolume.getHistogram(index).findBinOfPercentile(0.983); - lut = new Lut().createFromMinMax(hmin, hmax); - } - - currentVolume.setLut(index, lut); - view3D.updateLuts(currentVolume); - view3D.redraw(); - } - }; - - const setCameraModeHandler = (mode) => { - setCameraMode(mode); - setPersistentSettings(prev => ({ - ...prev, - mode: mode - })); - }; - - const toggleTurntable = () => { - setIsTurntable(!isTurntable); - }; - - const toggleAxis = () => { - setShowAxis(!showAxis); - }; - - const toggleBoundingBox = () => { - setShowBoundingBox(!showBoundingBox); - }; - - const toggleScaleBar = () => { - setShowScaleBar(!showScaleBar); - }; - - const updateBackgroundColor = (color) => { - setBackgroundColor(color); - }; - - const updateBoundingBoxColor = (color) => { - setBoundingBoxColor(color); + } else if (key === "window" || key === "level") { + const lut = new Lut().createFromWindowLevel( + updatedChannels[index].window, + updatedChannels[index].level, + ); + currentVolume.setLut(index, lut); + view3D.updateLuts(currentVolume); + } + view3D.redraw(); + } + }; + + const updateChannelOptions = (index, options) => { + if (!currentVolume || !view3D) return; + + const updatedChannels = [...channels]; + updatedChannels[index] = { ...updatedChannels[index], ...options }; + setChannels(updatedChannels); + }; + + const initializeChannelOptions = (volume) => { + const channelOptions = volume.imageInfo.channelNames.map((name, index) => ({ + name, + enabled: index < 3, + color: volume.channelColorsDefault[index] || [128, 128, 128], + specularColor: [0, 0, 0], + emissiveColor: [0, 0, 0], + glossiness: 0, + isosurfaceEnabled: false, + isovalue: 127, + isosurfaceOpacity: 1.0, + })); + setChannels(channelOptions); + }; + + const updateIsovalue = (index, isovalue) => { + if (!currentVolume || !view3D) return; + + const updatedChannels = [...channels]; + updatedChannels[index] = { + ...updatedChannels[index], + isovalue, }; + setChannels(updatedChannels); - const flipVolume = (axis) => { - if (axis === 'X') { - setFlipX(flipX * -1); - } else if (axis === 'Y') { - setFlipY(flipY * -1); - } else if (axis === 'Z') { - setFlipZ(flipZ * -1); - } - }; + view3D.setVolumeChannelOptions(currentVolume, index, { + isosurfaceEnabled: updatedChannels[index].isosurfaceEnabled, + isovalue: isovalue, + isosurfaceOpacity: updatedChannels[index].opacity, + }); - const gammaSliderToImageValues = (sliderValues) => { - let min = Number(sliderValues[0]); - let mid = Number(sliderValues[1]); - let max = Number(sliderValues[2]); - if (mid > max || mid < min) { - mid = 0.5 * (min + max); - } - const div = 255; - min /= div; - max /= div; - mid /= div; - const diff = max - min; - const x = (mid - min) / diff; - let scale = 4 * x * x; - if ((mid - 0.5) * (mid - 0.5) < 0.0005) { - scale = 1.0; - } - return [min, max, scale]; - }; + view3D.updateMaterial(currentVolume); + view3D.redraw(); + }; + + // Histogram-based LUT adjustments + const updateChannelLut = (index, type) => { + if (currentVolume) { + let lut; + if (type === "autoIJ") { + const [hmin, hmax] = currentVolume.getHistogram(index).findAutoIJBins(); + lut = new Lut().createFromMinMax(hmin, hmax); + } else if (type === "auto0") { + const [b, e] = currentVolume.getHistogram(index).findAutoMinMax(); + lut = new Lut().createFromMinMax(b, e); + } else if (type === "bestFit") { + const [hmin, hmax] = currentVolume + .getHistogram(index) + .findBestFitBins(); + lut = new Lut().createFromMinMax(hmin, hmax); + } else if (type === "pct50_98") { + const hmin = currentVolume.getHistogram(index).findBinOfPercentile(0.5); + const hmax = currentVolume + .getHistogram(index) + .findBinOfPercentile(0.983); + lut = new Lut().createFromMinMax(hmin, hmax); + } + + currentVolume.setLut(index, lut); + view3D.updateLuts(currentVolume); + view3D.redraw(); + } + }; + + const setCameraModeHandler = (mode) => { + setCameraMode(mode); + + // Reset clip region when switching to 3D mode + if (mode === "3D" && currentVolume) { + const fullClipRegion = { + xmin: 0, + xmax: 1, + ymin: 0, + ymax: 1, + zmin: 0, + zmax: 1, + }; + setClipRegion(fullClipRegion); + view3D.updateClipRegion( + currentVolume, + fullClipRegion.xmin, + fullClipRegion.xmax, + fullClipRegion.ymin, + fullClipRegion.ymax, + fullClipRegion.zmin, + fullClipRegion.zmax, + ); + } - const updateGamma = (newGamma) => { - setGamma(newGamma); - }; + view3D.setCameraMode(mode); + setPersistentSettings((prev) => ({ + ...prev, + mode: mode, + })); + + // Force a redraw + view3D.redraw(); + }; + + const toggleTurntable = () => { + setIsTurntable(!isTurntable); + }; + + const toggleAxis = () => { + setShowAxis(!showAxis); + }; + + const toggleBoundingBox = () => { + setShowBoundingBox(!showBoundingBox); + }; + + const toggleScaleBar = () => { + setShowScaleBar(!showScaleBar); + }; + + const updateBackgroundColor = (color) => { + setBackgroundColor(color); + }; + + const updateBoundingBoxColor = (color) => { + setBoundingBoxColor(color); + }; + + const flipVolume = (axis) => { + if (axis === "X") { + setFlipX(flipX * -1); + } else if (axis === "Y") { + setFlipY(flipY * -1); + } else if (axis === "Z") { + setFlipZ(flipZ * -1); + } + }; + + const gammaSliderToImageValues = (sliderValues) => { + let min = Number(sliderValues[0]); + let mid = Number(sliderValues[1]); + let max = Number(sliderValues[2]); + if (mid > max || mid < min) { + mid = 0.5 * (min + max); + } + const div = 255; + min /= div; + max /= div; + mid /= div; + const diff = max - min; + const x = (mid - min) / diff; + let scale = 4 * x * x; + if ((mid - 0.5) * (mid - 0.5) < 0.0005) { + scale = 1.0; + } + return [min, max, scale]; + }; + + const updateGamma = (newGamma) => { + setGamma(newGamma); + }; + + const captureScreenshot = () => { + view3D.capture((dataUrl) => { + const anchor = document.createElement("a"); + anchor.href = dataUrl; + anchor.download = "screenshot.png"; + anchor.click(); + }); + }; - const captureScreenshot = () => { - view3D.capture((dataUrl) => { - const anchor = document.createElement("a"); - anchor.href = dataUrl; - anchor.download = "screenshot.png"; - anchor.click(); - }); - }; + const updateClipRegion = (key, value) => { + const updatedClipRegion = { ...clipRegion, [key]: value }; + setClipRegion(updatedClipRegion); + }; - const updateClipRegion = (key, value) => { - const updatedClipRegion = { ...clipRegion, [key]: value }; - setClipRegion(updatedClipRegion); - }; + const goToFrame = (frame) => { + if (frame >= 0 && frame < totalFrames) { + view3D.setTime(currentVolume, frame); + setCurrentFrame(frame); + } + }; + + const goToZSlice = (slice) => { + if (currentVolume && view3D.setZSlice(currentVolume, slice)) { + // Z slice updated successfully + const zSlider = document.getElementById("zSlider"); + const zInput = document.getElementById("zValue"); + + if (zInput) { + zInput.value = slice; + } + if (zSlider) { + zSlider.value = slice; + } + } else { + console.log("Failed to update Z slice"); + } + }; - const goToFrame = (frame) => { - if (frame >= 0 && frame < totalFrames) { - view3D.setTime(currentVolume, frame); - setCurrentFrame(frame); - } - }; + const playTimeSeries = () => { + if (timerId) { + clearInterval(timerId); + } + setIsPlaying(true); + const newTimerId = setInterval(() => { + setCurrentFrame((prevFrame) => { + const nextFrame = (prevFrame + 1) % totalFrames; + view3D.setTime(currentVolume, nextFrame); + return nextFrame; + }); + }, 80); + setTimerId(newTimerId); + }; + + const pauseTimeSeries = () => { + if (timerId) { + clearInterval(timerId); + setTimerId(null); + } + setIsPlaying(false); + }; - const goToZSlice = (slice) => { - if (currentVolume && view3D.setZSlice(currentVolume, slice)) { - // Z slice updated successfully - const zSlider = document.getElementById("zSlider"); - const zInput = document.getElementById("zValue"); - - if (zInput) { - zInput.value = slice; - } - if (zSlider) { - zSlider.value = slice; - } - } else { - console.log('Failed to update Z slice'); - } + const rgbToHex = (r, g, b) => { + const toHex = (component) => { + const hex = Math.round(component).toString(16); + return hex.length === 1 ? "0" + hex : hex; }; - const playTimeSeries = () => { - if (timerId) { - clearInterval(timerId); - } - setIsPlaying(true); - const newTimerId = setInterval(() => { - setCurrentFrame((prevFrame) => { - const nextFrame = (prevFrame + 1) % totalFrames; - view3D.setTime(currentVolume, nextFrame); - return nextFrame; - }); - }, 80); - setTimerId(newTimerId); - }; + // Ensure values are between 0-255 + r = Math.min(255, Math.max(0, Math.round(r))); + g = Math.min(255, Math.max(0, Math.round(g))); + b = Math.min(255, Math.max(0, Math.round(b))); + + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; + }; + + const hexToRgb = (hex) => { + // Remove the hash if present + hex = hex.replace(/^#/, ""); + + // Parse the hex values + const bigint = parseInt(hex, 16); + const r = (bigint >> 16) & 255; + const g = (bigint >> 8) & 255; + const b = bigint & 255; + + return [r, g, b]; + }; + + const updatePixelSamplingRate = (rate) => { + setSamplingRate(rate); + view3D.updatePixelSamplingRate(rate); + view3D.redraw(); + }; + + const updateSkyLight = (position, intensity, color) => { + if (position === "top") { + setSkyTopIntensity(intensity); + setSkyTopColor(color); + } else if (position === "mid") { + setSkyMidIntensity(intensity); + setSkyMidColor(color); + } else if (position === "bot") { + setSkyBotIntensity(intensity); + setSkyBotColor(color); + } + updateLights(); + }; + + const updateAreaLight = (intensity, color, theta, phi) => { + setLightIntensity(intensity); + setLightColor(color); + setLightTheta(theta); + setLightPhi(phi); + updateLights(); + }; + + const updateLights = () => { + const updatedLights = [...lights]; + // Update sky light + updatedLights[0].mColorTop = new Vector3( + (skyTopColor[0] / 255.0) * skyTopIntensity, + (skyTopColor[1] / 255.0) * skyTopIntensity, + (skyTopColor[2] / 255.0) * skyTopIntensity, + ); + updatedLights[0].mColorMiddle = new Vector3( + (skyMidColor[0] / 255.0) * skyMidIntensity, + (skyMidColor[1] / 255.0) * skyMidIntensity, + (skyMidColor[2] / 255.0) * skyMidIntensity, + ); + updatedLights[0].mColorBottom = new Vector3( + (skyBotColor[0] / 255.0) * skyBotIntensity, + (skyBotColor[1] / 255.0) * skyBotIntensity, + (skyBotColor[2] / 255.0) * skyBotIntensity, + ); + + // Update area light + updatedLights[1].mColor = new Vector3( + (lightColor[0] / 255.0) * lightIntensity, + (lightColor[1] / 255.0) * lightIntensity, + (lightColor[2] / 255.0) * lightIntensity, + ); + updatedLights[1].mTheta = (lightTheta * Math.PI) / 180.0; + updatedLights[1].mPhi = (lightPhi * Math.PI) / 180.0; + + setLights(updatedLights); + view3D.updateLights(updatedLights); + view3D.redraw(); + }; + + const applyColorPreset = (presetIndex) => { + if (!currentVolume) return; + + const preset = PRESET_COLOR_MAP[presetIndex].colors; + + const updatedChannels = channels.map((channel, index) => { + const newColor = preset[index % preset.length]; + return { + ...channel, + colorD: newColor, + }; + }); - const pauseTimeSeries = () => { - if (timerId) { - clearInterval(timerId); - setTimerId(null); - } - setIsPlaying(false); - }; + // Update state + setChannels(updatedChannels); + setCurrentPreset(presetIndex); + + // Update each channel's material + updatedChannels.forEach((channel, index) => { + view3D.updateChannelMaterial( + currentVolume, + index, + channel.colorD, + channel.colorS, + channel.colorE, + channel.glossiness, + ); + }); - const rgbToHex = (r, g, b) => { - const toHex = (component) => { - const hex = Math.round(component).toString(16); - return hex.length === 1 ? '0' + hex : hex; + view3D.updateMaterial(currentVolume); + view3D.redraw(); + }; + + const updateSetting = (key, value) => { + setSettings((prev) => ({ + ...prev, + [key]: value, + })); + }; + + const handleClipRegionUpdate = (newClipRegion) => { + setClipRegion(newClipRegion); + + if (currentVolume) { + view3D.updateClipRegion( + currentVolume, + newClipRegion.xmin, + newClipRegion.xmax, + newClipRegion.ymin, + newClipRegion.ymax, + newClipRegion.zmin, + newClipRegion.zmax, + ); + } + }; + + // Optional: Add handler for slice changes if you need to do something when slices change + const handleSliceChange = (newSlice) => { + // Handle slice changes if needed + console.log("Slice changed:", newSlice); + }; + + // Function to save current view settings + const saveCurrentSettings = useCallback(() => { + setPersistentSettings((prev) => ({ + ...prev, + mode: cameraMode, + density: settings.density, + brightness: settings.brightness, + maskAlpha: settings.maskAlpha, + primaryRay: primaryRay, + secondaryRay: secondaryRay, + clipRegion: clipRegion, + channelSettings: channels.reduce((acc, channel, index) => { + acc[index] = { + enabled: channel.enabled, + color: channel.color, + isosurfaceEnabled: channel.isosurfaceEnabled, + isovalue: channel.isovalue, + opacity: channel.opacity, }; - - // Ensure values are between 0-255 - r = Math.min(255, Math.max(0, Math.round(r))); - g = Math.min(255, Math.max(0, Math.round(g))); - b = Math.min(255, Math.max(0, Math.round(b))); - - return `#${toHex(r)}${toHex(g)}${toHex(b)}`; - }; - - const hexToRgb = (hex) => { - // Remove the hash if present - hex = hex.replace(/^#/, ''); - - // Parse the hex values - const bigint = parseInt(hex, 16); - const r = (bigint >> 16) & 255; - const g = (bigint >> 8) & 255; - const b = bigint & 255; - - return [r, g, b]; - }; - - const updatePixelSamplingRate = (rate) => { - setSamplingRate(rate); - view3D.updatePixelSamplingRate(rate); - view3D.redraw(); - }; - - - const updateSkyLight = (position, intensity, color) => { - if (position === 'top') { - setSkyTopIntensity(intensity); - setSkyTopColor(color); - } else if (position === 'mid') { - setSkyMidIntensity(intensity); - setSkyMidColor(color); - } else if (position === 'bot') { - setSkyBotIntensity(intensity); - setSkyBotColor(color); - } - updateLights(); - }; - - const updateAreaLight = (intensity, color, theta, phi) => { - setLightIntensity(intensity); - setLightColor(color); - setLightTheta(theta); - setLightPhi(phi); - updateLights(); - }; - - const updateLights = () => { - const updatedLights = [...lights]; - // Update sky light - updatedLights[0].mColorTop = new Vector3( - (skyTopColor[0] / 255.0) * skyTopIntensity, - (skyTopColor[1] / 255.0) * skyTopIntensity, - (skyTopColor[2] / 255.0) * skyTopIntensity - ); - updatedLights[0].mColorMiddle = new Vector3( - (skyMidColor[0] / 255.0) * skyMidIntensity, - (skyMidColor[1] / 255.0) * skyMidIntensity, - (skyMidColor[2] / 255.0) * skyMidIntensity - ); - updatedLights[0].mColorBottom = new Vector3( - (skyBotColor[0] / 255.0) * skyBotIntensity, - (skyBotColor[1] / 255.0) * skyBotIntensity, - (skyBotColor[2] / 255.0) * skyBotIntensity - ); - - // Update area light - updatedLights[1].mColor = new Vector3( - (lightColor[0] / 255.0) * lightIntensity, - (lightColor[1] / 255.0) * lightIntensity, - (lightColor[2] / 255.0) * lightIntensity - ); - updatedLights[1].mTheta = (lightTheta * Math.PI) / 180.0; - updatedLights[1].mPhi = (lightPhi * Math.PI) / 180.0; - - setLights(updatedLights); - view3D.updateLights(updatedLights); - view3D.redraw(); - }; + return acc; + }, {}), + })); + }, [cameraMode, settings, primaryRay, secondaryRay, clipRegion, channels]); + + // Add effect to save settings when they change + useEffect(() => { + if (currentVolume) { + saveCurrentSettings(); + } + }, [ + cameraMode, + settings.density, + settings.brightness, + settings.maskAlpha, + primaryRay, + secondaryRay, + clipRegion, + channels, + saveCurrentSettings, + ]); + + // Utility function to round to 1 significant figure + const roundToSignificantFigure = (num, sigFigs = 1) => { + if (num === 0) return 0; + const scale = Math.pow( + 10, + Math.floor(Math.log10(Math.abs(num))) + 1 - sigFigs, + ); + return Math.round(num / scale) * scale; + }; + + return ( + +
+ + + {/* Files Tab */} + + Files + + } + key="files" + > + + {Object.keys(fileData).map((bodyPart) => ( + {bodyPart} + } + key={bodyPart} + > + {fileData[bodyPart].map((file) => ( +
handleFileSelect(bodyPart, file)} + > + 📄 + {file} + {file} +
+ ))} +
+ ))} +
+
+ {/* Settings Tab */} + + Settings + + } + key="settings" + > + + {/* Render Mode */} + + Render Mode + + } + key="renderMode" + > + + Path Trace + + setIsPT(checked)} + /> + + + - const applyColorPreset = (presetIndex) => { - if (!currentVolume) return; - - const preset = PRESET_COLOR_MAP[presetIndex].colors; - - const updatedChannels = channels.map((channel, index) => { - const newColor = preset[index % preset.length]; - return { - ...channel, - colorD: newColor - }; - }); - - // Update state - setChannels(updatedChannels); - setCurrentPreset(presetIndex); - - // Update each channel's material - updatedChannels.forEach((channel, index) => { - view3D.updateChannelMaterial( - currentVolume, - index, - channel.colorD, - channel.colorS, - channel.colorE, - channel.glossiness - ); - }); - - view3D.updateMaterial(currentVolume); - view3D.redraw(); - }; + {/* Density Settings */} + + Density + + } + key="density" + > + updateSetting("density", val)} + /> + - const updateSetting = (key, value) => { - setSettings(prev => ({ - ...prev, - [key]: value - })); - }; + {/* Mask Alpha */} + + Mask Alpha + + } + key="maskAlpha" + > + updateSetting("maskAlpha", val)} + /> + - const handleClipRegionUpdate = (newClipRegion) => { - setClipRegion(newClipRegion); - - if (currentVolume) { - view3D.updateClipRegion( - currentVolume, - newClipRegion.xmin, - newClipRegion.xmax, - newClipRegion.ymin, - newClipRegion.ymax, - newClipRegion.zmin, - newClipRegion.zmax - ); - } - }; + {/* Exposure */} + + Exposure + + } + key="exposure" + > + updateSetting("brightness", val)} + /> + - // Optional: Add handler for slice changes if you need to do something when slices change - const handleSliceChange = (newSlice) => { - // Handle slice changes if needed - console.log('Slice changed:', newSlice); - }; + {/* Camera Settings */} + + Camera Settings + + } + key="camera" + > + + FOV + + + + + + Focal Distance + + + + + + Aperture + + + + + - // Function to save current view settings - const saveCurrentSettings = useCallback(() => { - setPersistentSettings(prev => ({ - ...prev, - mode: cameraMode, - density: settings.density, - brightness: settings.brightness, - maskAlpha: settings.maskAlpha, - primaryRay: primaryRay, - secondaryRay: secondaryRay, - clipRegion: clipRegion, - channelSettings: channels.reduce((acc, channel, index) => { - acc[index] = { - enabled: channel.enabled, - color: channel.color, - isosurfaceEnabled: channel.isosurfaceEnabled, - isovalue: channel.isovalue, - opacity: channel.opacity - }; - return acc; - }, {}) - })); - }, [ - cameraMode, settings, primaryRay, secondaryRay, - clipRegion, channels - ]); - - // Add effect to save settings when they change - useEffect(() => { - if (currentVolume) { - saveCurrentSettings(); - } - }, [ - cameraMode, settings.density, settings.brightness, - settings.maskAlpha, primaryRay, secondaryRay, - clipRegion, channels, saveCurrentSettings - ]); - - // Utility function to round to 1 significant figure - const roundToSignificantFigure = (num, sigFigs = 1) => { - if (num === 0) return 0; - const scale = Math.pow(10, Math.floor(Math.log10(Math.abs(num))) + 1 - sigFigs); - return Math.round(num / scale) * scale; - }; + {/* Camera Mode */} + + + Camera Mode + + } + key="cameraMode" + > + + - return ( - -
- - - {/* Files Tab */} - Files} key="files"> - - {Object.keys(fileData).map((bodyPart) => ( - {bodyPart}} - key={bodyPart} - > - {fileData[bodyPart].map((file) => ( -
handleFileSelect(bodyPart, file)} - > - 📄 - {file} - {file} -
- ))} -
- ))} -
-
- {/* Settings Tab */} - Settings} - key="settings" + {/* Controls */} + + View Controls + + } + key="controls" > - - {/* Render Mode */} - Render Mode} key="renderMode"> - - Path Trace - - setIsPT(checked)} /> - - - - - {/* Density Settings */} - Density} key="density"> - updateSetting('density', val)} + + + + + + Background Color + + { + const [r, g, b] = hexToRgb(e.target.value); + const normalizedColor = [r / 255, g / 255, b / 255]; + setBackgroundColor(normalizedColor); + if (view3D) { + view3D.setBackgroundColor(normalizedColor); + view3D.redraw(); + } + }} /> - - - {/* Mask Alpha */} - Mask Alpha} key="maskAlpha"> - updateSetting('maskAlpha', val)} + + + + Bounding Box Color + + { + const [r, g, b] = hexToRgb(e.target.value); + const normalizedColor = [r / 255, g / 255, b / 255]; + setBoundingBoxColor(normalizedColor); + if (currentVolume && view3D) { + view3D.setBoundingBoxColor( + currentVolume, + normalizedColor, + ); + view3D.redraw(); + } + }} /> - - - {/* Ray Step Sizes */} - Ray Steps} key="raySteps"> -
- - -
-
- - -
-
- - {/* Exposure */} - Exposure} key="exposure"> - updateSetting('brightness', val)} + + + + + + + + {/* Gamma */} + + + Min + + + updateSetting("levels", [ + value, + settings.levels[1], + settings.levels[2], + ]) + } /> - - - {/* Camera Settings */} - Camera Settings} key="camera"> - - FOV - - - - - - Focal Distance - - - - - - Aperture - - - - - - - {/* Sampling Rate */} - Sampling Rate} key="sampling"> - - Pixel Sampling Rate - - - - - - - {/* Lighting Settings */} - Lighting} key="lighting"> - - - - Top Intensity - - updateSkyLight('top', value, skyTopColor)} - /> - - - - Top Color - - updateSkyLight('top', skyTopIntensity, hexToRgb(e.target.value))} - /> - - - {/* Mid Light Controls */} - - Mid Intensity - - updateSkyLight('mid', value, skyMidColor)} - /> - - - - Mid Color - - updateSkyLight('mid', skyMidIntensity, hexToRgb(e.target.value))} - /> - - - {/* Bottom Light Controls */} - - Bottom Intensity - - updateSkyLight('bot', value, skyBotColor)} - /> - - - - Bottom Color - - updateSkyLight('bot', skyBotIntensity, hexToRgb(e.target.value))} - /> - - - - - - Intensity - - updateAreaLight(value, lightColor, lightTheta, lightPhi)} - /> - - - - Color - - updateAreaLight(lightIntensity, hexToRgb(e.target.value), lightTheta, lightPhi)} - /> - - - - Theta (deg) - - updateAreaLight(lightIntensity, lightColor, value, lightPhi)} - /> - - - - Phi (deg) - - updateAreaLight(lightIntensity, lightColor, lightTheta, value)} - /> - - - - - - - {/* Camera Mode */} - Camera Mode} key="cameraMode"> - + {PRESET_COLOR_MAP.map((preset) => ( + + {preset.name} + + ))} - - - {/* Controls */} - View Controls} key="controls"> - - - - - - Background Color - - updateBackgroundColor(hexToRgb(e.target.value))} - /> - - + + + {channels.map((channel, index) => ( +
+
+ {channel.name || `Channel ${index + 1}`} +
- Bounding Box Color + Enable Channel - updateBoundingBoxColor(hexToRgb(e.target.value))} - /> - - - - - - - - {/* Gamma */} - - - Min - - updateSetting('levels', [value, settings.levels[1], settings.levels[2]])} - /> - - - - Mid - - updateSetting('levels', [settings.levels[0], value, settings.levels[2]])} - /> - - - - Max - - updateSetting('levels', [settings.levels[0], settings.levels[1], value])} + + updateChannelOptions(index, { enabled }) + } /> - - - {/* Channels */} - Channels} key="channels"> - - Color Preset - - - - - {channels.map((channel, index) => ( -
-
- {channel.name || `Channel ${index + 1}`} -
- - Enable - - updateChannelOptions(index, { enabled })} - /> - - Isosurface + Enable Isosurface updateChannelOptions(index, { - isosurfaceEnabled: enabled - })} + onChange={(enabled) => + updateChannelOptions(index, { + isosurfaceEnabled: enabled, + }) + } /> @@ -1562,9 +1645,11 @@ const VolumeViewer = () => { min={0} max={255} value={channel.isovalue} - onChange={value => updateChannelOptions(index, { - isovalue: value - })} + onChange={(value) => + updateChannelOptions(index, { + isovalue: value, + }) + } /> @@ -1575,9 +1660,11 @@ const VolumeViewer = () => { min={0} max={100} value={channel.opacity * 100} - onChange={value => updateChannelOptions(index, { - opacity: value / 100 - })} + onChange={(value) => + updateChannelOptions(index, { + opacity: value / 100, + }) + } /> @@ -1588,203 +1675,306 @@ const VolumeViewer = () => { { - const color = hexToRgb(e.target.value); - updateChannelOptions(index, { color }); + value={rgbToHex( + channel.color[0], + channel.color[1], + channel.color[2], + )} + onChange={(e) => { + const newColor = hexToRgb(e.target.value); + + // Update channel options first + updateChannelOptions(index, { color: newColor }); + + // Then explicitly update the channel material + if (currentVolume && view3D) { + view3D.updateChannelMaterial( + currentVolume, + index, + newColor, // New color for diffuse + [0, 0, 0], // Specular color + [0, 0, 0], // Emissive color + 0, // Glossiness + ); + view3D.updateMaterial(currentVolume); + view3D.redraw(); + } }} /> - {/* Negative margins to counter parent padding */} - -
- {/* First row */} -
- - -
- {/* Second row */} -
- - + +
+ {/* Second row */} +
- 50-98% - + + +
-
- - + +
))}
{/* Clip Region */} - Clip Region} key="clipRegion"> + + Clip Region + + } + key="clipRegion" + > X Min - updateClipRegion('xmin', value)} /> + updateClipRegion("xmin", value)} + /> X Max - updateClipRegion('xmax', value)} /> + updateClipRegion("xmax", value)} + /> Y Min - updateClipRegion('ymin', value)} /> + updateClipRegion("ymin", value)} + /> Y Max - updateClipRegion('ymax', value)} /> + updateClipRegion("ymax", value)} + /> Z Min - updateClipRegion('zmin', value)} /> + updateClipRegion("zmin", value)} + /> Z Max - updateClipRegion('zmax', value)} /> + updateClipRegion("zmax", value)} + /> - - {/* Planar Slice Player */} - {cameraMode !== '3D' && ( - - - - )}
{/* Metadata Tab */} - Info} key="metadata"> - {currentVolume && ( -
-

Volume Information

-
- - - {currentVolume.loader?.url?.split('/').pop() || currentVolume.name} - + + Info + + } + key="metadata" + > + {currentVolume && ( +
+

Volume Information

+
+ + + {currentVolume.loader?.url?.split("/").pop() || + currentVolume.name} + +
+ {currentVolume.imageInfo && ( + <> +
+ + + {currentVolume.imageInfo.volumeSize + ? `${roundToSignificantFigure(currentVolume.imageInfo.volumeSize.x)} × ${roundToSignificantFigure(currentVolume.imageInfo.volumeSize.y)} × ${roundToSignificantFigure(currentVolume.imageInfo.volumeSize.z)}` + : "N/A"} + +
+
+ + + {currentVolume.physicalSize + ? `${roundToSignificantFigure(currentVolume.physicalSize.x)} × ${roundToSignificantFigure(currentVolume.physicalSize.y)} × ${roundToSignificantFigure(currentVolume.physicalSize.z)} ${currentVolume.physicalUnitSymbol || "units"}` + : "N/A"} + +
+
+ + + {currentVolume.imageMetadata?.Channels || "N/A"} + +
+ {currentVolume.imageMetadata && ( + <> +
+ + + {currentVolume.imageMetadata.Dimensions + ? `${roundToSignificantFigure(currentVolume.imageMetadata.Dimensions.x)} × ${roundToSignificantFigure(currentVolume.imageMetadata.Dimensions.y)} × ${roundToSignificantFigure(currentVolume.imageMetadata.Dimensions.z)}` + : "N/A"} + +
+
+ + + {currentVolume.imageMetadata[ + "Physical size per pixel" + ] + ? `${roundToSignificantFigure(currentVolume.imageMetadata["Physical size per pixel"].x)} × ${roundToSignificantFigure(currentVolume.imageMetadata["Physical size per pixel"].y)} × ${roundToSignificantFigure(currentVolume.imageMetadata["Physical size per pixel"].z)}` + : "N/A"} + +
+
+ + + {currentVolume.imageMetadata[ + "Time series frames" + ] || "N/A"} + +
+ + )} + + )}
- {currentVolume.imageInfo && ( - <> -
- - - {currentVolume.imageInfo.volumeSize ? - `${roundToSignificantFigure(currentVolume.imageInfo.volumeSize.x)} × ${roundToSignificantFigure(currentVolume.imageInfo.volumeSize.y)} × ${roundToSignificantFigure(currentVolume.imageInfo.volumeSize.z)}` : - 'N/A'} - -
-
- - - {currentVolume.physicalSize ? - `${roundToSignificantFigure(currentVolume.physicalSize.x)} × ${roundToSignificantFigure(currentVolume.physicalSize.y)} × ${roundToSignificantFigure(currentVolume.physicalSize.z)} ${currentVolume.physicalUnitSymbol || 'units'}` : - 'N/A'} - -
-
- - {currentVolume.imageMetadata?.Channels || 'N/A'} -
- {currentVolume.imageMetadata && ( - <> -
- - - {currentVolume.imageMetadata.Dimensions ? - `${roundToSignificantFigure(currentVolume.imageMetadata.Dimensions.x)} × ${roundToSignificantFigure(currentVolume.imageMetadata.Dimensions.y)} × ${roundToSignificantFigure(currentVolume.imageMetadata.Dimensions.z)}` : - 'N/A'} - -
-
- - - {currentVolume.imageMetadata['Physical size per pixel'] ? - `${roundToSignificantFigure(currentVolume.imageMetadata['Physical size per pixel'].x)} × ${roundToSignificantFigure(currentVolume.imageMetadata['Physical size per pixel'].y)} × ${roundToSignificantFigure(currentVolume.imageMetadata['Physical size per pixel'].z)}` : - 'N/A'} - -
-
- - - {currentVolume.imageMetadata['Time series frames'] || 'N/A'} - -
- - )} - - )} -
- )} - + )} +
- + -
+
+ {/* Planar slice player */} + {cameraMode !== "3D" && currentVolume && ( +
+ +
+ )} +
@@ -1851,10 +2041,22 @@ const VolumeViewer = () => { font-size: 12px; z-index: 1000; } + .planar-controls-container { + position: absolute; + bottom: 10%; + left: 50%; + transform: translateX(-50%); + width: min(99%, 808px); + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(4px); + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + padding: 16px; + z-index: 100; + } `} ); - -} +}; -export default VolumeViewer; \ No newline at end of file +export default VolumeViewer; From 81b100986d0087d8924787b4f532ee1722bddef0 Mon Sep 17 00:00:00 2001 From: Adrian Sebuliba Date: Mon, 18 Nov 2024 17:04:03 +0100 Subject: [PATCH 23/37] fix xccs --- src/App.css | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/src/App.css b/src/App.css index 7c6b14d..110c1b1 100644 --- a/src/App.css +++ b/src/App.css @@ -1374,3 +1374,76 @@ button svg { .player-header { user-select: none; } + +/* Base styles for planar controls container */ +.planar-controls-container { + position: absolute; + bottom: 10%; + left: 50%; + transform: translateX(-50%); + width: min(95%, 808px); + max-width: calc(100vw - 40px); + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(4px); + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + padding: 16px; + z-index: 100; + transition: all 0.3s ease; +} + +.planar-slice-player { + width: 100%; + max-width: 100%; + background: #fff; + border-radius: 8px; + transition: all 0.3s ease; +} + +/* Small screens */ +@media (max-width: 576px) { + .planar-controls-container { + width: 95%; + bottom: 5%; + padding: 12px; + } + + .planar-slice-player { + font-size: 0.9em; + } + + .planar-slice-player .ant-row { + margin: 8px 0; + } + + .planar-slice-player .ant-btn { + padding: 4px 8px; + font-size: 0.9em; + } +} + +/* Medium screens */ +@media (min-width: 577px) and (max-width: 992px) { + .planar-controls-container { + width: 90%; + bottom: 7%; + } +} + +/* Large screens */ +@media (min-width: 993px) { + .planar-controls-container { + width: min(85%, 808px); + } +} + +/* Handle touch devices */ +@media (hover: none) { + .planar-controls-container { + touch-action: none; + } + + .planar-slice-player input[type="number"] { + font-size: 16px; /* Prevents iOS zoom on focus */ + } +} From 29d2e6e4723f853f46cb32dc4481cf22c4a3026e Mon Sep 17 00:00:00 2001 From: Adrian Sebuliba Date: Thu, 21 Nov 2024 16:58:09 +0100 Subject: [PATCH 24/37] Ensure that OME-Zarr files are supported --- src/components/FilesList.js | 185 +++++++++++++++++++++++++++++++++ src/components/VolumeViewer.js | 60 +++++------ 2 files changed, 211 insertions(+), 34 deletions(-) create mode 100644 src/components/FilesList.js diff --git a/src/components/FilesList.js b/src/components/FilesList.js new file mode 100644 index 0000000..1eda4f6 --- /dev/null +++ b/src/components/FilesList.js @@ -0,0 +1,185 @@ +// src/components/FilesList.js +import React from "react"; +import { Collapse, Tooltip } from "antd"; + +export default function FilesList({ fileData, onFileSelect }) { + if (!fileData || Object.keys(fileData).length === 0) { + return
No files available
; + } + + const formatSize = (bytes) => { + const units = ["B", "KB", "MB", "GB"]; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(2)} ${units[unitIndex]}`; + }; + + const getTooltipContent = (file) => ( +
+
+ Name: {file.name} +
+
+ Type: {file.type.toUpperCase()} +
+ {file.size && ( +
+ Size: {formatSize(file.size)} +
+ )} +
+ Category: {file.category} +
+ {file.lastModified && ( +
+ Last Modified:{" "} + {new Date(file.lastModified).toLocaleString()} +
+ )} +
+ ); + + return ( + + {Object.entries(fileData).map(([category, files]) => ( + {category}} + key={category} + > + {Array.isArray(files) ? ( + files.map((file) => { + const fileObj = + typeof file === "string" + ? { + name: file, + type: file.endsWith(".zarr") ? "zarr" : "tiff", + size: 0, + category, + } + : { ...file, category }; + + return ( + +
onFileSelect(category, fileObj)} + > + + {fileObj.type === "zarr" ? "📁" : "📄"} + + {fileObj.name} + + + {fileObj.type.toUpperCase()} + + {fileObj.size > 0 && ( + + {formatSize(fileObj.size)} + + )} + +
+
+ ); + }) + ) : ( +
Invalid files data for category: {category}
+ )} +
+ ))} + + + + {/* Global styles for tooltip */} + +
+ ); +} diff --git a/src/components/VolumeViewer.js b/src/components/VolumeViewer.js index 76fc8dd..c7f5480 100644 --- a/src/components/VolumeViewer.js +++ b/src/components/VolumeViewer.js @@ -65,7 +65,7 @@ import { VIEWER_3D_SETTING, } from "./constants"; import PlanarSlicePlayer from "./PlanarSlicePlayer"; - +import FilesList from "./FilesList"; // Utility function to concatenate arrays const concatenateArrays = (arrays) => { const totalLength = arrays.reduce((acc, arr) => acc + arr.length, 0); @@ -387,14 +387,17 @@ const VolumeViewer = () => { setIsLoading(true); try { const loadSpec = new LoadSpec(); - const fileExtension = url.split(".").pop(); - const volumeFileType = - fileExtension === "tiff" || - fileExtension === "tif" || - fileExtension === "ome.tiff" || - fileExtension === "ome.tif" + const isZarr = url.endsWith(".zarr"); + const volumeFileType = isZarr + ? VolumeFileFormat.ZARR + : url.match(/\.(tiff?|ome\.tiff?)$/i) ? VolumeFileFormat.TIFF - : VolumeFileFormat.ZARR; + : null; + + if (!volumeFileType) { + throw new Error("Unsupported file format"); + } + const loader = await loadContext.createLoader(url, { fileType: volumeFileType, fetchOptions: { @@ -407,6 +410,7 @@ const VolumeViewer = () => { await loadVolume(loadSpec, loader); } catch (error) { console.error("Error loading volume:", error); + // You might want to show an error message to the user here } finally { setIsLoading(false); } @@ -453,10 +457,14 @@ const VolumeViewer = () => { } }; - const handleFileSelect = async (bodyPart, file) => { - setSelectedBodyPart(bodyPart); - setSelectedFile(file); - await loadVolumeFromServer(`${API_URL}/${bodyPart}/${file}`); + const handleFileSelect = async (category, fileName) => { + setSelectedBodyPart(category); + setSelectedFile(fileName); + + const fileUrl = `${API_URL}/${category}/${fileName}`; + console.log("Loading file:", fileUrl); + + await loadVolumeFromServer(fileUrl); }; useEffect(() => { @@ -1287,28 +1295,12 @@ const VolumeViewer = () => { } key="files" > - - {Object.keys(fileData).map((bodyPart) => ( - {bodyPart} - } - key={bodyPart} - > - {fileData[bodyPart].map((file) => ( -
handleFileSelect(bodyPart, file)} - > - 📄 - {file} - {file} -
- ))} -
- ))} -
+ + handleFileSelect(category, file.name) + } + />
{/* Settings Tab */} Date: Thu, 21 Nov 2024 17:57:04 +0100 Subject: [PATCH 25/37] Fix rounding issue --- src/components/VolumeViewer.js | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/components/VolumeViewer.js b/src/components/VolumeViewer.js index c7f5480..c31c4cb 100644 --- a/src/components/VolumeViewer.js +++ b/src/components/VolumeViewer.js @@ -1256,15 +1256,26 @@ const VolumeViewer = () => { ]); // Utility function to round to 1 significant figure - const roundToSignificantFigure = (num, sigFigs = 1) => { - if (num === 0) return 0; + const roundToSignificantFigure = (valueWithUnits, sigFigs = 1) => { + // Step 1: Remove units (assumes input format like "123µm") + const numericValue = valueWithUnits ? valueWithUnits : 0; + + // Step 2: Return 0 if the numeric value is 0 + if (numericValue === 0) return `0µm`; + + // Step 3: Calculate scale for rounding const scale = Math.pow( 10, - Math.floor(Math.log10(Math.abs(num))) + 1 - sigFigs, + Math.floor(Math.log10(Math.abs(numericValue))) + 1 - sigFigs, ); - return Math.round(num / scale) * scale; - }; + // Step 4: Perform rounding + const roundedValue = Math.round(numericValue / scale) * scale; + + // Step 5: Add the units back + return `${roundedValue}µm`; + }; + console.log(currentVolume?.imageMetadata); return (
{ {currentVolume.imageMetadata[ "Physical size per pixel" ] - ? `${roundToSignificantFigure(currentVolume.imageMetadata["Physical size per pixel"].x)} × ${roundToSignificantFigure(currentVolume.imageMetadata["Physical size per pixel"].y)} × ${roundToSignificantFigure(currentVolume.imageMetadata["Physical size per pixel"].z)}` + ? `${roundToSignificantFigure(currentVolume.imageMetadata["Physical size per pixel"]?.x)} × ${roundToSignificantFigure(currentVolume.imageMetadata["Physical size per pixel"]?.y)} × ${roundToSignificantFigure(currentVolume.imageMetadata["Physical size per pixel"]?.z)}` : "N/A"}
From 4ed19e4bd4fcf2fc36dc89594506db6f7b122056 Mon Sep 17 00:00:00 2001 From: Adrian Sebuliba Date: Fri, 22 Nov 2024 19:01:02 +0100 Subject: [PATCH 26/37] Address comments and issues --- src/App.css | 65 ++ src/components/PlanarSlicePlayer.js | 102 ++- src/components/PlanarSlicePlayer.module.css | 65 ++ src/components/VolumeViewer.js | 663 +++++++++++--------- 4 files changed, 539 insertions(+), 356 deletions(-) create mode 100644 src/components/PlanarSlicePlayer.module.css diff --git a/src/App.css b/src/App.css index 110c1b1..5afc385 100644 --- a/src/App.css +++ b/src/App.css @@ -1447,3 +1447,68 @@ button svg { font-size: 16px; /* Prevents iOS zoom on focus */ } } + +.playerContainer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: white; + border-top: 1px solid #e0e0e0; + box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1); + transition: transform 0.3s ease; + z-index: 1000; +} + +.collapsed { + transform: translateY(calc(100% - 48px)); +} + +.expanded { + transform: translateY(0); +} + +.toggleBar { + height: 48px; + display: flex; + align-items: center; + justify-content: center; + background-color: #f5f5f5; + cursor: pointer; + transition: background-color 0.2s; +} + +.toggleBar:hover { + background-color: #e8e8e8; +} + +.toggleText { + width: 100%; + text-align: center; + font-size: 14px; + font-weight: 500; + color: #666; +} + +.playerContent { + padding: 16px; +} + +.controlsRow { + display: flex; + justify-content: center; + gap: 8px; + margin-bottom: 16px; +} + +.settingsRow { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 16px; +} + +.label { + font-size: 14px; + color: #666; +} diff --git a/src/components/PlanarSlicePlayer.js b/src/components/PlanarSlicePlayer.js index d218b15..44d7469 100644 --- a/src/components/PlanarSlicePlayer.js +++ b/src/components/PlanarSlicePlayer.js @@ -16,6 +16,7 @@ import { StepForwardOutlined, StepBackwardOutlined, } from "@ant-design/icons"; +import styles from "./PlanarSlicePlayer.module.css"; const { Text } = Typography; @@ -31,13 +32,10 @@ const PlanarSlicePlayer = ({ const [currentSlice, setCurrentSlice] = useState(0); const [totalSlices, setTotalSlices] = useState(100); const [isLooping, setIsLooping] = useState(true); - - // Use useRef for the interval to prevent issues with closure stale values + const [isCollapsed, setIsCollapsed] = useState(false); const playbackIntervalRef = useRef(null); - // Store current slice in ref to access latest value in interval const currentSliceRef = useRef(currentSlice); - // Update ref when slice changes useEffect(() => { currentSliceRef.current = currentSlice; }, [currentSlice]); @@ -109,21 +107,16 @@ const PlanarSlicePlayer = ({ }, []); const play = useCallback(() => { - // Clear any existing interval first stopPlayback(); - setIsPlaying(true); - // Create new interval with looping logic playbackIntervalRef.current = setInterval(() => { const nextSlice = currentSliceRef.current + 1; if (nextSlice >= totalSlices) { if (isLooping) { - // If looping is enabled, go back to start updateSlice(0); } else { - // If not looping, stop at the end stopPlayback(); } return; @@ -152,7 +145,6 @@ const PlanarSlicePlayer = ({ updateSlice(prevSlice); }, [updateSlice]); - // Update total slices when volume changes useEffect(() => { const axisInfo = getAxisInfo(); if (axisInfo?.size) { @@ -161,7 +153,6 @@ const PlanarSlicePlayer = ({ } }, [getAxisInfo]); - // Cleanup effect useEffect(() => { return () => { if (playbackIntervalRef.current) { @@ -170,16 +161,13 @@ const PlanarSlicePlayer = ({ }; }, []); - // Handle camera mode changes useEffect(() => { stopPlayback(); setCurrentSlice(0); }, [cameraMode, stopPlayback]); - // Handle playback speed changes useEffect(() => { if (isPlaying) { - // Restart playback with new speed play(); } }, [playbackSpeed, play, isPlaying]); @@ -188,26 +176,35 @@ const PlanarSlicePlayer = ({ if (!axisInfo) return null; return ( -
- - - {axisInfo.label} Plane Navigation - - - - - - - +
+
setIsCollapsed(!isCollapsed)} + > +
+ {isCollapsed ? "▲ Expand Player" : "▼ Collapse Player"} +
+
+ +
+ + + {axisInfo.label} Plane Navigation + + + + + + +
- - - - - Loop Playback: - - +
+ +
+ Loop Playback: - - - - - Speed (fps): - - +
+ +
+ Speed (fps): setPlaybackSpeed(value)} /> - - - - - Current Slice: - - +
+ +
+ Current Slice: - - +
+
); }; diff --git a/src/components/PlanarSlicePlayer.module.css b/src/components/PlanarSlicePlayer.module.css new file mode 100644 index 0000000..ca950f3 --- /dev/null +++ b/src/components/PlanarSlicePlayer.module.css @@ -0,0 +1,65 @@ +/* PlanarSlicePlayer.module.css */ +.playerContainer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: white; + border-top: 1px solid #e0e0e0; + box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1); + transition: transform 0.3s ease; + z-index: 1000; +} + +.collapsed { + transform: translateY(calc(100% - 48px)); +} + +.expanded { + transform: translateY(0); +} + +.toggleBar { + height: 48px; + display: flex; + align-items: center; + justify-content: center; + background-color: #f5f5f5; + cursor: pointer; + transition: background-color 0.2s; +} + +.toggleBar:hover { + background-color: #e8e8e8; +} + +.toggleText { + width: 100%; + text-align: center; + font-size: 14px; + font-weight: 500; + color: #666; +} + +.playerContent { + padding: 16px; +} + +.controlsRow { + display: flex; + justify-content: center; + gap: 8px; + margin-bottom: 16px; +} + +.settingsRow { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 16px; +} + +.label { + font-size: 14px; + color: #666; +} diff --git a/src/components/VolumeViewer.js b/src/components/VolumeViewer.js index c31c4cb..9064cf5 100644 --- a/src/components/VolumeViewer.js +++ b/src/components/VolumeViewer.js @@ -48,6 +48,7 @@ import { Maximize2, Image, Lightbulb, + Wand2, } from "lucide-react"; import axios from "axios"; import { API_URL } from "../config"; // Importing API_URL from your config @@ -1255,25 +1256,29 @@ const VolumeViewer = () => { saveCurrentSettings, ]); - // Utility function to round to 1 significant figure - const roundToSignificantFigure = (valueWithUnits, sigFigs = 1) => { - // Step 1: Remove units (assumes input format like "123µm") - const numericValue = valueWithUnits ? valueWithUnits : 0; + // Update the roundToSignificantFigure function: + const roundToSignificantFigure = (value, sigFigs = 1) => { + if (value === undefined || value === null) return "N/A"; - // Step 2: Return 0 if the numeric value is 0 - if (numericValue === 0) return `0µm`; + // Convert value to number if it's a string + const numericValue = Number(value); - // Step 3: Calculate scale for rounding + // Return NaN if conversion failed + if (isNaN(numericValue)) return "N/A"; + + // Return 0 if the value is 0 + if (numericValue === 0) return "0µm"; + + // Calculate scale for rounding const scale = Math.pow( 10, Math.floor(Math.log10(Math.abs(numericValue))) + 1 - sigFigs, ); - // Step 4: Perform rounding + // Perform rounding const roundedValue = Math.round(numericValue / scale) * scale; - // Step 5: Add the units back - return `${roundedValue}µm`; + return `${roundedValue}`; }; console.log(currentVolume?.imageMetadata); return ( @@ -1322,269 +1327,7 @@ const VolumeViewer = () => { } key="settings" > - - {/* Render Mode */} - - Render Mode - - } - key="renderMode" - > - - Path Trace - - setIsPT(checked)} - /> - - - - - {/* Density Settings */} - - Density - - } - key="density" - > - updateSetting("density", val)} - /> - - - {/* Mask Alpha */} - - Mask Alpha - - } - key="maskAlpha" - > - updateSetting("maskAlpha", val)} - /> - - - {/* Exposure */} - - Exposure - - } - key="exposure" - > - updateSetting("brightness", val)} - /> - - - {/* Camera Settings */} - - Camera Settings - - } - key="camera" - > - - FOV - - - - - - Focal Distance - - - - - - Aperture - - - - - - - {/* Camera Mode */} - - - Camera Mode - - } - key="cameraMode" - > - - - - {/* Controls */} - - View Controls - - } - key="controls" - > - - - - - - Background Color - - { - const [r, g, b] = hexToRgb(e.target.value); - const normalizedColor = [r / 255, g / 255, b / 255]; - setBackgroundColor(normalizedColor); - if (view3D) { - view3D.setBackgroundColor(normalizedColor); - view3D.redraw(); - } - }} - /> - - - - Bounding Box Color - - { - const [r, g, b] = hexToRgb(e.target.value); - const normalizedColor = [r / 255, g / 255, b / 255]; - setBoundingBoxColor(normalizedColor); - if (currentVolume && view3D) { - view3D.setBoundingBoxColor( - currentVolume, - normalizedColor, - ); - view3D.redraw(); - } - }} - /> - - - - - - - - {/* Gamma */} - - - Min - - - updateSetting("levels", [ - value, - settings.levels[1], - settings.levels[2], - ]) - } - /> - - - - Mid - - - updateSetting("levels", [ - settings.levels[0], - value, - settings.levels[2], - ]) - } - /> - - - - Max - - - updateSetting("levels", [ - settings.levels[0], - settings.levels[1], - value, - ]) - } - /> - - - - + {/* Channels */} { )} onChange={(e) => { const newColor = hexToRgb(e.target.value); - - // Update channel options first updateChannelOptions(index, { color: newColor }); - - // Then explicitly update the channel material if (currentVolume && view3D) { view3D.updateChannelMaterial( currentVolume, index, - newColor, // New color for diffuse - [0, 0, 0], // Specular color - [0, 0, 0], // Emissive color - 0, // Glossiness + newColor, + [0, 0, 0], + [0, 0, 0], + 0, ); view3D.updateMaterial(currentVolume); view3D.redraw(); @@ -1713,28 +1452,25 @@ const VolumeViewer = () => { marginLeft: "-8px", }} > - {" "} - {/* Negative margins to counter parent padding */}
- {/* First row */}
- {/* Second row */}
{ ))} - {/* Clip Region */} + {/* Gamma */} - Clip Region + Gamma + + } + key="gamma" + > + + Min + + + updateSetting("levels", [ + value, + settings.levels[1], + settings.levels[2], + ]) + } + /> + + + + Mid + + + updateSetting("levels", [ + settings.levels[0], + value, + settings.levels[2], + ]) + } + /> + + + + Max + + + updateSetting("levels", [ + settings.levels[0], + settings.levels[1], + value, + ]) + } + /> + + + + + {/* Exposure */} + + Exposure + + } + key="exposure" + > + updateSetting("brightness", val)} + /> + + + {/* Camera Mode */} + + Camera Mode + + } + key="cameraMode" + > + + + + {/* View Controls */} + + View Controls + + } + key="controls" + > + + + + + + Background Color + + { + const [r, g, b] = hexToRgb(e.target.value); + const normalizedColor = [r / 255, g / 255, b / 255]; + setBackgroundColor(normalizedColor); + if (view3D) { + view3D.setBackgroundColor(normalizedColor); + view3D.redraw(); + } + }} + /> + + + + Bounding Box Color + + { + const [r, g, b] = hexToRgb(e.target.value); + const normalizedColor = [r / 255, g / 255, b / 255]; + setBoundingBoxColor(normalizedColor); + if (currentVolume && view3D) { + view3D.setBoundingBoxColor( + currentVolume, + normalizedColor, + ); + view3D.redraw(); + } + }} + /> + + + + + + + + {/* Clip Region */} + + Clip Region } key="clipRegion" @@ -1865,6 +1776,164 @@ const VolumeViewer = () => { + + {/* Camera Settings */} + + Camera Settings + + } + key="camera" + > + + FOV + + + + + + Focal Distance + + + + + + Aperture + + + + + + + {/* Render Mode */} + + Render Mode + + } + key="renderMode" + > + + Path Trace + + setIsPT(checked)} + /> + + + + {isPT && ( + <> + {/* Density */} + + Density + + } + key="density" + > + updateSetting("density", val)} + /> + + + {/* Ray Steps */} + + Ray Steps + + } + key="raySteps" + > +
+ + +
+
+ + +
+
+ + {/* Sampling Rate */} + + Sampling Rate + + } + key="sampling" + > + + Pixel Sampling Rate + + + + + + + )} + + {/* Mask Alpha */} + + Mask Alpha + + } + key="maskAlpha" + > + updateSetting("maskAlpha", val)} + /> + @@ -1882,10 +1951,7 @@ const VolumeViewer = () => {

Volume Information

- - {currentVolume.loader?.url?.split("/").pop() || - currentVolume.name} - + {currentVolume.name}
{currentVolume.imageInfo && ( <> @@ -1924,10 +1990,9 @@ const VolumeViewer = () => {
- {currentVolume.imageMetadata[ - "Physical size per pixel" - ] - ? `${roundToSignificantFigure(currentVolume.imageMetadata["Physical size per pixel"]?.x)} × ${roundToSignificantFigure(currentVolume.imageMetadata["Physical size per pixel"]?.y)} × ${roundToSignificantFigure(currentVolume.imageMetadata["Physical size per pixel"]?.z)}` + {currentVolume.imageInfo && + currentVolume.imageInfo.physicalPixelSize + ? `${currentVolume.imageMetadata["Physical size per pixel"]?.x} × ${currentVolume.imageMetadata["Physical size per pixel"]?.y} × ${currentVolume.imageMetadata["Physical size per pixel"]?.y}` : "N/A"}
From 43f2b4a12f0e08b5eae23deb67e7902ec429d610 Mon Sep 17 00:00:00 2001 From: Adrian Sebuliba Date: Fri, 22 Nov 2024 21:50:39 +0100 Subject: [PATCH 27/37] Use specified oixel size --- src/components/VolumeViewer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/VolumeViewer.js b/src/components/VolumeViewer.js index 9064cf5..2558ca3 100644 --- a/src/components/VolumeViewer.js +++ b/src/components/VolumeViewer.js @@ -1992,7 +1992,7 @@ const VolumeViewer = () => { {currentVolume.imageInfo && currentVolume.imageInfo.physicalPixelSize - ? `${currentVolume.imageMetadata["Physical size per pixel"]?.x} × ${currentVolume.imageMetadata["Physical size per pixel"]?.y} × ${currentVolume.imageMetadata["Physical size per pixel"]?.y}` + ? `${currentVolume.imageMetadata["Physical size per pixel"]?.x} × ${currentVolume.imageMetadata["Physical size per pixel"]?.y} × ${currentVolume.imageMetadata["Physical size per pixel"]?.z}` : "N/A"}
From 09027e0a9f8973a0e247aa11618ab00a97cb91b2 Mon Sep 17 00:00:00 2001 From: Adrian Sebuliba Date: Fri, 22 Nov 2024 21:56:30 +0100 Subject: [PATCH 28/37] Conditionally display maskAlpha --- src/components/VolumeViewer.js | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/components/VolumeViewer.js b/src/components/VolumeViewer.js index 2558ca3..f768cef 100644 --- a/src/components/VolumeViewer.js +++ b/src/components/VolumeViewer.js @@ -1915,25 +1915,25 @@ const VolumeViewer = () => { + + {/* Mask Alpha */} + + Mask Alpha + + } + key="maskAlpha" + > + updateSetting("maskAlpha", val)} + /> + )} - - {/* Mask Alpha */} - - Mask Alpha - - } - key="maskAlpha" - > - updateSetting("maskAlpha", val)} - /> - From 02666da4ecbfa63a97c24004a90f94dad820c917 Mon Sep 17 00:00:00 2001 From: Adrian Sebuliba Date: Mon, 25 Nov 2024 11:09:12 +0100 Subject: [PATCH 29/37] Ensure that player is postioned properly --- src/components/PlanarSlicePlayer.js | 256 +++++++++++++++++----------- src/components/VolumeViewer.js | 171 +++++++++++++++++-- 2 files changed, 317 insertions(+), 110 deletions(-) diff --git a/src/components/PlanarSlicePlayer.js b/src/components/PlanarSlicePlayer.js index 44d7469..91651ec 100644 --- a/src/components/PlanarSlicePlayer.js +++ b/src/components/PlanarSlicePlayer.js @@ -1,14 +1,6 @@ // PlanarSlicePlayer.js import React, { useState, useEffect, useCallback, useRef } from "react"; -import { - Button, - Slider, - InputNumber, - Row, - Col, - Typography, - Switch, -} from "antd"; +import { Button, Slider, InputNumber, Typography, Switch, Tooltip } from "antd"; import { PlayCircleOutlined, PauseCircleOutlined, @@ -16,7 +8,6 @@ import { StepForwardOutlined, StepBackwardOutlined, } from "@ant-design/icons"; -import styles from "./PlanarSlicePlayer.module.css"; const { Text } = Typography; @@ -32,7 +23,6 @@ const PlanarSlicePlayer = ({ const [currentSlice, setCurrentSlice] = useState(0); const [totalSlices, setTotalSlices] = useState(100); const [isLooping, setIsLooping] = useState(true); - const [isCollapsed, setIsCollapsed] = useState(false); const playbackIntervalRef = useRef(null); const currentSliceRef = useRef(currentSlice); @@ -106,7 +96,7 @@ const PlanarSlicePlayer = ({ setIsPlaying(false); }, []); - const play = useCallback(() => { + const startPlayback = useCallback(() => { stopPlayback(); setIsPlaying(true); @@ -126,21 +116,21 @@ const PlanarSlicePlayer = ({ }, 1000 / playbackSpeed); }, [playbackSpeed, totalSlices, updateSlice, stopPlayback, isLooping]); - const pause = useCallback(() => { + const pausePlayback = useCallback(() => { stopPlayback(); }, [stopPlayback]); - const stop = useCallback(() => { + const resetToStart = useCallback(() => { stopPlayback(); updateSlice(0); }, [stopPlayback, updateSlice]); - const forward = useCallback(() => { + const stepForward = useCallback(() => { const nextSlice = Math.min(currentSliceRef.current + 1, totalSlices - 1); updateSlice(nextSlice); }, [totalSlices, updateSlice]); - const backward = useCallback(() => { + const stepBackward = useCallback(() => { const prevSlice = Math.max(currentSliceRef.current - 1, 0); updateSlice(prevSlice); }, [updateSlice]); @@ -168,106 +158,180 @@ const PlanarSlicePlayer = ({ useEffect(() => { if (isPlaying) { - play(); + startPlayback(); } - }, [playbackSpeed, play, isPlaying]); + }, [playbackSpeed, startPlayback, isPlaying]); const axisInfo = getAxisInfo(); if (!axisInfo) return null; return ( -
-
setIsCollapsed(!isCollapsed)} - > -
- {isCollapsed ? "▲ Expand Player" : "▼ Collapse Player"} +
+
+ +
+ {`${axisInfo.label} Plane`} +
+
+ +
+ + + ) : ( + + )} + + + + + + + +
-
-
- - - {axisInfo.label} Plane Navigation - - +
+ `Slice ${value + 1} of ${totalSlices}`, + }} /> - - - -
- - {!isPlaying ? ( - - ) : ( - - )} - - +
-
- Loop Playback: - -
+
+ +
+ Slice: + + / {totalSlices - 1} +
+
-
- Speed (fps): - setPlaybackSpeed(value)} - /> -
+ +
+ Speed (fps): + +
+
-
- Current Slice: - + +
+ Loop: + +
+
+
); }; diff --git a/src/components/VolumeViewer.js b/src/components/VolumeViewer.js index f768cef..db4f53f 100644 --- a/src/components/VolumeViewer.js +++ b/src/components/VolumeViewer.js @@ -32,6 +32,7 @@ import { Select, Input, Spin, + Tooltip, } from "antd"; import { Settings, @@ -196,6 +197,8 @@ const VolumeViewer = () => { defaultIsovalue: 128, defaultOpacity: 1.0, }); + const [hasScrolledOnce, setHasScrolledOnce] = useState(false); + const [originalScrollPosition, setOriginalScrollPosition] = useState(0); const densitySliderToView3D = (density) => density / 50.0; @@ -912,10 +915,31 @@ const VolumeViewer = () => { }; const setCameraModeHandler = (mode) => { + const previousMode = cameraMode; setCameraMode(mode); - // Reset clip region when switching to 3D mode - if (mode === "3D" && currentVolume) { + if (!currentVolume || !view3D) return; + + if (mode === "3D") { + // Reset scroll flag when switching back to 3D + setHasScrolledOnce(false); + + // Restore original scroll position + setTimeout(() => { + const scrollContainer = document.querySelector(".content-container"); + if (scrollContainer) { + scrollContainer.scrollTo({ + top: originalScrollPosition, + behavior: "smooth", + }); + } else { + window.scrollTo({ + top: originalScrollPosition, + behavior: "smooth", + }); + } + }, 300); + const fullClipRegion = { xmin: 0, xmax: 1, @@ -924,6 +948,7 @@ const VolumeViewer = () => { zmin: 0, zmax: 1, }; + setClipRegion(fullClipRegion); view3D.updateClipRegion( currentVolume, @@ -934,15 +959,130 @@ const VolumeViewer = () => { fullClipRegion.zmin, fullClipRegion.zmax, ); + + view3D.setCameraMode(mode); + + const renderMode = isPT ? RENDERMODE_PATHTRACE : RENDERMODE_RAYMARCH; + view3D.setVolumeRenderMode(renderMode); + view3D.setMaxProjectMode(currentVolume, false); + + view3D.updateDensity(currentVolume, settings.density / 100); + view3D.updateExposure(settings.brightness / 100); + if (currentVolume) { + view3D.updateMaskAlpha(currentVolume, 1 - settings.maskAlpha / 100); + } + + view3D.setRayStepSizes(currentVolume, primaryRay, secondaryRay); + + channels.forEach((channel, index) => { + if (currentVolume) { + view3D.setVolumeChannelEnabled(currentVolume, index, channel.enabled); + if (channel.enabled) { + view3D.setVolumeChannelOptions(currentVolume, index, { + color: channel.color, + opacity: 1.0, + brightness: 1.2, + contrast: 1.1, + }); + } + } + }); + } else { + // 2D modes (X, Y, Z) + const defaultClipRegion = { + xmin: 0, + xmax: 1, + ymin: 0, + ymax: 1, + zmin: 0, + zmax: 1, + }; + + const sliceThickness = 0.01; + if (mode === "X") { + defaultClipRegion.xmin = 0; + defaultClipRegion.xmax = sliceThickness; + } else if (mode === "Y") { + defaultClipRegion.ymin = 0; + defaultClipRegion.ymax = sliceThickness; + } else if (mode === "Z") { + defaultClipRegion.zmin = 0; + defaultClipRegion.zmax = sliceThickness; + } + + setClipRegion(defaultClipRegion); + view3D.updateClipRegion( + currentVolume, + defaultClipRegion.xmin, + defaultClipRegion.xmax, + defaultClipRegion.ymin, + defaultClipRegion.ymax, + defaultClipRegion.zmin, + defaultClipRegion.zmax, + ); + + view3D.setCameraMode(mode); + view3D.setVolumeRenderMode(RENDERMODE_RAYMARCH); + view3D.setMaxProjectMode(currentVolume, false); + + view3D.updateDensity(currentVolume, 0.5); + view3D.updateExposure(0.7); + view3D.updateMaskAlpha(currentVolume, 0.5); + view3D.setRayStepSizes(currentVolume, 1, 1); + + channels.forEach((channel, index) => { + if (currentVolume) { + view3D.setVolumeChannelEnabled(currentVolume, index, channel.enabled); + if (channel.enabled) { + view3D.setVolumeChannelOptions(currentVolume, index, { + color: channel.color, + opacity: 1.0, + brightness: 1.2, + contrast: 1.1, + }); + } + } + }); + + // Only perform scroll if switching from 3D to 2D and haven't scrolled yet + if (previousMode === "3D" && !hasScrolledOnce) { + // Store the current scroll position before scrolling + const scrollContainer = document.querySelector(".content-container"); + const currentPosition = scrollContainer + ? scrollContainer.scrollTop + : window.pageYOffset; + setOriginalScrollPosition(currentPosition); + + setTimeout(() => { + const viewportHeight = window.innerHeight; + const scrollAmount = Math.min(50, viewportHeight * 0.03); + + if (scrollContainer) { + scrollContainer.style.overflow = "auto"; + scrollContainer.scrollBy({ + top: scrollAmount, + behavior: "smooth", + }); + } else { + window.scrollBy({ + top: scrollAmount, + behavior: "smooth", + }); + } + + // Set the flag to true after first scroll + setHasScrolledOnce(true); + }, 300); + } } - view3D.setCameraMode(mode); setPersistentSettings((prev) => ({ ...prev, mode: mode, })); - // Force a redraw + view3D.updateActiveChannels(currentVolume); + view3D.updateLuts(currentVolume); view3D.redraw(); }; @@ -2028,19 +2168,22 @@ const VolumeViewer = () => {
{/* Planar slice player */} {cameraMode !== "3D" && currentVolume && ( -
- -
+ )}
From e406492e21e5b1823036012bce73c23c695aab9c Mon Sep 17 00:00:00 2001 From: Adrian Sebuliba Date: Mon, 25 Nov 2024 17:38:32 +0100 Subject: [PATCH 30/37] Ensure planar slice player stays visible in 2D camera mode --- src/components/PlanarSlicePlayer.js | 13 +- src/components/VolumeViewer.js | 381 ++++++++++++++++++---------- 2 files changed, 252 insertions(+), 142 deletions(-) diff --git a/src/components/PlanarSlicePlayer.js b/src/components/PlanarSlicePlayer.js index 91651ec..e0c03fc 100644 --- a/src/components/PlanarSlicePlayer.js +++ b/src/components/PlanarSlicePlayer.js @@ -268,24 +268,25 @@ const PlanarSlicePlayer = ({
)} From 8cf7989a5b32abf858718c8126fa9770f4d9d3f2 Mon Sep 17 00:00:00 2001 From: Adrian Sebuliba Date: Mon, 25 Nov 2024 17:51:59 +0100 Subject: [PATCH 31/37] adjust gamma slider margins to prevent text cutoff --- src/components/ThreePointGammaSlider.js | 55 ++++++++++++++++++++++++ src/components/VolumeViewer.js | 56 +++---------------------- 2 files changed, 60 insertions(+), 51 deletions(-) create mode 100644 src/components/ThreePointGammaSlider.js diff --git a/src/components/ThreePointGammaSlider.js b/src/components/ThreePointGammaSlider.js new file mode 100644 index 0000000..e6addbe --- /dev/null +++ b/src/components/ThreePointGammaSlider.js @@ -0,0 +1,55 @@ +import React from "react"; +import { Slider } from "antd"; + +const ThreePointGammaSlider = ({ onChange, value = [0, 128, 255] }) => { + const marks = { + 0: "Min", + 128: "Mid", + 255: "Max", + }; + + return ( +
+ + +
+ ); +}; + +export default ThreePointGammaSlider; diff --git a/src/components/VolumeViewer.js b/src/components/VolumeViewer.js index 4e9412a..3bca1bb 100644 --- a/src/components/VolumeViewer.js +++ b/src/components/VolumeViewer.js @@ -68,6 +68,7 @@ import { } from "./constants"; import PlanarSlicePlayer from "./PlanarSlicePlayer"; import FilesList from "./FilesList"; +import ThreePointGammaSlider from "./ThreePointGammaSlider"; // Utility function to concatenate arrays const concatenateArrays = (arrays) => { const totalLength = arrays.reduce((acc, arr) => acc + arr.length, 0); @@ -1634,57 +1635,10 @@ const VolumeViewer = () => { } key="gamma" > - - Min - - - updateSetting("levels", [ - value, - settings.levels[1], - settings.levels[2], - ]) - } - /> - - - - Mid - - - updateSetting("levels", [ - settings.levels[0], - value, - settings.levels[2], - ]) - } - /> - - - - Max - - - updateSetting("levels", [ - settings.levels[0], - settings.levels[1], - value, - ]) - } - /> - - + updateSetting("levels", values)} + /> {/* Exposure */} From 2f44f8bc6ef53eb976d42e307ad92d4026b4add2 Mon Sep 17 00:00:00 2001 From: Adrian Sebuliba Date: Tue, 26 Nov 2024 14:11:54 +0100 Subject: [PATCH 32/37] Implement intuitive slice-based clipping control --- src/components/ClipRegionSlider.js | 123 +++++++++++++++++++++++++++ src/components/VolumeViewer.js | 129 ++++++++++++----------------- 2 files changed, 177 insertions(+), 75 deletions(-) create mode 100644 src/components/ClipRegionSlider.js diff --git a/src/components/ClipRegionSlider.js b/src/components/ClipRegionSlider.js new file mode 100644 index 0000000..c84ed15 --- /dev/null +++ b/src/components/ClipRegionSlider.js @@ -0,0 +1,123 @@ +import React, { useCallback, useMemo } from "react"; +import { Slider } from "antd"; + +const ClipRegionSlider = ({ axis, onChange, value = [0, 1], totalSlices }) => { + // Memoize conversion functions + const toSliceValue = useCallback( + (val) => + Math.max( + 1, + Math.min(totalSlices, Math.round(val * (totalSlices - 1) + 1)), + ), + [totalSlices], + ); + + const fromSliceValue = useCallback( + (slice) => (slice - 1) / (totalSlices - 1), + [totalSlices], + ); + + // Memoize slider values and marks + const sliceValues = useMemo( + () => [toSliceValue(value[0]), toSliceValue(value[1])], + [value, toSliceValue], + ); + + const marks = useMemo( + () => ({ + 1: "1", + [Math.floor((totalSlices + 1) / 2)]: axis, + [totalSlices]: totalSlices.toString(), + }), + [totalSlices, axis], + ); + + // Memoize tooltip formatter + const tooltipFormatter = useCallback((value) => `Slice ${value}`, []); + + // Debounced onChange handler + const handleChange = useCallback( + (newSliceValues) => { + // Skip if values haven't changed + if ( + newSliceValues[0] === sliceValues[0] && + newSliceValues[1] === sliceValues[1] + ) { + return; + } + + // Use RequestAnimationFrame to throttle updates + requestAnimationFrame(() => { + onChange([ + fromSliceValue(newSliceValues[0]), + fromSliceValue(newSliceValues[1]), + ]); + }); + }, + [fromSliceValue, sliceValues, onChange], + ); + + const sliderProps = useMemo( + () => ({ + range: true, + marks, + min: 1, + max: totalSlices, + step: 1, + value: sliceValues, + onChange: handleChange, + tooltip: { + formatter: tooltipFormatter, + open: undefined, // Let antd handle tooltip visibility + placement: "top", + }, + handleStyle: [ + { backgroundColor: "#1890ff", border: "2px solid #1890ff" }, + { backgroundColor: "#1890ff", border: "2px solid #1890ff" }, + ], + trackStyle: [{ backgroundColor: "#91d5ff" }], + }), + [marks, totalSlices, sliceValues, handleChange, tooltipFormatter], + ); + + return ( +
+ + +
+ ); +}; + +export default React.memo(ClipRegionSlider); diff --git a/src/components/VolumeViewer.js b/src/components/VolumeViewer.js index 3bca1bb..097aca1 100644 --- a/src/components/VolumeViewer.js +++ b/src/components/VolumeViewer.js @@ -69,6 +69,7 @@ import { import PlanarSlicePlayer from "./PlanarSlicePlayer"; import FilesList from "./FilesList"; import ThreePointGammaSlider from "./ThreePointGammaSlider"; +import ClipRegionSlider from "./ClipRegionSlider"; // Utility function to concatenate arrays const concatenateArrays = (arrays) => { const totalLength = arrays.reduce((acc, arr) => acc + arr.length, 0); @@ -1112,9 +1113,35 @@ const VolumeViewer = () => { }); }; - const updateClipRegion = (key, value) => { - const updatedClipRegion = { ...clipRegion, [key]: value }; - setClipRegion(updatedClipRegion); + const updateClipRegion = (axis, values) => { + const [min, max] = values; + const updates = {}; + + if (axis === "X") { + updates.xmin = min; + updates.xmax = max; + } else if (axis === "Y") { + updates.ymin = min; + updates.ymax = max; + } else if (axis === "Z") { + updates.zmin = min; + updates.zmax = max; + } + + const newClipRegion = { ...clipRegion, ...updates }; + setClipRegion(newClipRegion); + + if (currentVolume) { + view3D.updateClipRegion( + currentVolume, + newClipRegion.xmin, + newClipRegion.xmax, + newClipRegion.ymin, + newClipRegion.ymax, + newClipRegion.zmin, + newClipRegion.zmax, + ); + } }; const goToFrame = (frame) => { @@ -1764,78 +1791,30 @@ const VolumeViewer = () => { } key="clipRegion" > - - X Min - - updateClipRegion("xmin", value)} - /> - - - - X Max - - updateClipRegion("xmax", value)} - /> - - - - Y Min - - updateClipRegion("ymin", value)} - /> - - - - Y Max - - updateClipRegion("ymax", value)} - /> - - - - Z Min - - updateClipRegion("zmin", value)} - /> - - - - Z Max - - updateClipRegion("zmax", value)} - /> - - +
+ {currentVolume && ( + <> + updateClipRegion("X", values)} + totalSlices={currentVolume.imageMetadata.Dimensions.x} + /> + updateClipRegion("Y", values)} + totalSlices={currentVolume.imageMetadata.Dimensions.y} + /> + updateClipRegion("Z", values)} + totalSlices={currentVolume.imageMetadata.Dimensions.z} + /> + + )} +
{/* Camera Settings */} From d75df8f66b19e2ae34eb8a01ccc3cef35ac404e1 Mon Sep 17 00:00:00 2001 From: Adrian Sebuliba Date: Fri, 29 Nov 2024 18:40:47 +0100 Subject: [PATCH 33/37] Add histogram plot and custom intensity adjustment controls --- src/components/TransferFunctionEditor.js | 623 +++++++++++++++++++++++ src/components/VolumeViewer.js | 208 ++++++-- 2 files changed, 791 insertions(+), 40 deletions(-) create mode 100644 src/components/TransferFunctionEditor.js diff --git a/src/components/TransferFunctionEditor.js b/src/components/TransferFunctionEditor.js new file mode 100644 index 0000000..79cadb4 --- /dev/null +++ b/src/components/TransferFunctionEditor.js @@ -0,0 +1,623 @@ +import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react'; +import * as d3 from 'd3'; +import { Lut } from "@aics/volume-viewer"; + +const MARGIN = { + top: 15, + right: 10, + bottom: 35, + left: 45 +}; + +const TransferFunctionEditor = ({ + channelIndex, + histogram, + onLutUpdate, + width = 380, + height = 200, + initialControlPoints, + useAdvancedMode = false, + channelColor = [255, 255, 255], + rampRange: externalRampRange, + onRampRangeChange, + onControlPointsChange +}) => { + const svgRef = useRef(null); + const [controlPoints, setControlPoints] = useState(initialControlPoints || [ + { x: 0, opacity: 0, color: channelColor }, + { x: 255, opacity: 1, color: channelColor } + ]); + const [internalRampRange, setInternalRampRange] = useState(externalRampRange || [0, 255]); + const [selectedPoint, setSelectedPoint] = useState(null); + const [dragging, setDragging] = useState(false); + + const innerWidth = width - MARGIN.left - MARGIN.right; + const innerHeight = height - MARGIN.top - MARGIN.bottom; + + // Create gradient definition + const createGradientDef = useCallback((points) => { + const range = points[points.length - 1].x - points[0].x; + return points.map((cp, i) => { + const offset = `${((cp.x - points[0].x) / range) * 100}%`; + const opacity = Math.min(cp.opacity, 0.9); + return ; + }); + }, []); + + // Scale functions + const xScale = useMemo(() => + d3.scaleLinear() + .domain([0, 255]) + .range([0, innerWidth]) + .nice(), + [innerWidth] + ); + + const yScale = useMemo(() => + d3.scaleLinear() + .domain([-0.05, 1.05]) + .range([innerHeight, 0]), + [innerHeight] + ); + + // Histogram y-scale with log transform + const histogramYScale = useMemo(() => { + if (!histogram) return null; + + let maxValue = 0; + for (let i = 0; i < histogram.getNumBins(); i++) { + maxValue = Math.max(maxValue, histogram.getBin(i)); + } + + return d3.scaleLog() + .domain([1, maxValue]) + .range([innerHeight, 0]) + .nice(); + }, [histogram, innerHeight]); + + // Sync with external changes + useEffect(() => { + if (externalRampRange) { + setInternalRampRange(externalRampRange); + } + }, [externalRampRange]); + + useEffect(() => { + if (initialControlPoints) { + setControlPoints(initialControlPoints); + } + }, [initialControlPoints]); + + // Draw histogram + const drawHistogram = useCallback(() => { + if (!histogram || !svgRef.current || !histogramYScale) return; + + const binData = Array.from({length: histogram.getNumBins()}, (_, i) => ({ + bin: i, + value: histogram.getBin(i) + })); + + const svg = d3.select(svgRef.current); + const g = svg.select('.histogram-group'); + + const barWidth = Math.max(1, (innerWidth / histogram.getNumBins()) - 1); + + const bars = g.selectAll('.histogram-bar') + .data(binData); + + bars.enter() + .append('rect') + .attr('class', 'histogram-bar') + .merge(bars) + .attr('x', d => xScale(d.bin)) + .attr('y', d => histogramYScale(Math.max(1, d.value))) + .attr('width', barWidth) + .attr('height', d => innerHeight - histogramYScale(Math.max(1, d.value))) + .attr('fill', '#666') + .attr('opacity', 0.5); + + bars.exit().remove(); + }, [histogram, innerWidth, xScale, histogramYScale, innerHeight]); + + // Update transfer function visualization + const updateVisualization = useCallback(() => { + if (!svgRef.current) return; + + const svg = d3.select(svgRef.current); + const controlGroup = svg.select('.control-points-group'); + + // Clear existing elements + controlGroup.selectAll('*').remove(); + + // Add grid lines + controlGroup.append('g') + .attr('class', 'grid-lines') + .selectAll('line') + .data(yScale.ticks(5)) + .enter() + .append('line') + .attr('x1', 0) + .attr('x2', innerWidth) + .attr('y1', d => yScale(d)) + .attr('y2', d => yScale(d)) + .attr('stroke', '#ddd') + .attr('stroke-dasharray', '2,2') + .style('pointer-events', 'none'); + + // Create gradient definition + const gradientId = `tf-gradient-${channelIndex}`; + const defs = svg.selectAll('defs').data([null]).join('defs'); + + // Create area generator + const area = d3.area() + .x(d => xScale(d.x)) + .y0(innerHeight) + .y1(d => yScale(d.opacity)) + .curve(d3.curveLinear); + + if (useAdvancedMode) { + // Set up gradient for advanced mode + defs.html(` + + ${createGradientDef(controlPoints).map(stop => + `` + ).join('')} + + `); + + // Draw filled area with gradient + controlGroup.append('path') + .attr('class', 'gradient-area') + .attr('d', area(controlPoints)) + .attr('fill', `url(#${gradientId})`) + .attr('opacity', 0.85); + + // Draw control point line + const line = d3.line() + .x(d => xScale(d.x)) + .y(d => yScale(d.opacity)) + .curve(d3.curveLinear); + + controlGroup.append('path') + .datum(controlPoints) + .attr('class', 'control-line') + .attr('fill', 'none') + .attr('stroke', `rgb(${channelColor.join(',')})`) + .attr('stroke-width', 2.5) + .attr('d', line); + + // Draw control points + controlGroup.selectAll('.control-point') + .data(controlPoints) + .enter() + .append('circle') + .attr('class', 'control-point') + .attr('cx', d => xScale(d.x)) + .attr('cy', d => yScale(d.opacity)) + .attr('r', 6) + .attr('fill', '#fff') + .attr('stroke', `rgb(${channelColor.join(',')})`) + .attr('stroke-width', 2.5); + } else { + // Set up gradient for basic mode + const rampPoints = [ + { x: 0, opacity: 0, color: channelColor }, + { x: internalRampRange[0], opacity: 0, color: channelColor }, + { x: internalRampRange[1], opacity: 1, color: channelColor }, + { x: 255, opacity: 1, color: channelColor } + ]; + + defs.html(` + + ${createGradientDef(rampPoints).map(stop => + `` + ).join('')} + + `); + + // Draw vertical guidelines + controlGroup.selectAll('.ramp-guideline') + .data(internalRampRange) + .enter() + .append('line') + .attr('class', 'ramp-guideline') + .attr('x1', d => xScale(d)) + .attr('x2', d => xScale(d)) + .attr('y1', 0) + .attr('y2', innerHeight) + .attr('stroke', '#666') + .attr('stroke-width', 1) + .attr('stroke-dasharray', '4,4') + .style('pointer-events', 'none'); + + // Draw filled area with gradient + controlGroup.append('path') + .attr('class', 'gradient-area') + .attr('d', area(rampPoints)) + .attr('fill', `url(#${gradientId})`) + .attr('opacity', 0.85); + + // Draw ramp line + controlGroup.append('line') + .attr('class', 'ramp-line') + .attr('x1', xScale(internalRampRange[0])) + .attr('y1', yScale(0)) + .attr('x2', xScale(internalRampRange[1])) + .attr('y2', yScale(1)) + .attr('stroke', `rgb(${channelColor.join(',')})`) + .attr('stroke-width', 2.5); + + // Draw ramp handles + controlGroup.selectAll('.ramp-handle') + .data(internalRampRange) + .enter() + .append('rect') + .attr('class', 'ramp-handle') + .attr('x', d => xScale(d) - 6) + .attr('y', (_, i) => yScale(i)) + .attr('width', 12) + .attr('height', 12) + .attr('fill', `rgb(${channelColor.join(',')})`) + .attr('transform', (_, i) => `translate(0,${i === 0 ? -6 : -6})`); + } + }, [controlPoints, internalRampRange, useAdvancedMode, xScale, yScale, channelColor, channelIndex, innerHeight, createGradientDef]); + // Update LUT + const updateLut = useCallback(() => { + const lut = new Lut(); + if (useAdvancedMode) { + lut.createFromControlPoints(controlPoints); + } else { + lut.createFromMinMax(internalRampRange[0], internalRampRange[1]); + } + onLutUpdate(lut, channelIndex); + }, [useAdvancedMode, controlPoints, internalRampRange, channelIndex, onLutUpdate]); + + // Clamp point to valid ranges + const clampPoint = useCallback((x, y) => { + // Clamp x to valid range + const clampedX = Math.max(0, Math.min(255, x)); + + // Ensure y is non-negative and follows the 0-90 degree constraint + let clampedY = Math.max(0, Math.min(1, y)); + + // Calculate angle from horizontal (in degrees) + const angle = Math.atan2(clampedY, clampedX) * (180 / Math.PI); + + // If angle is greater than 90 degrees, adjust y to maintain 90 degree max + if (angle > 90) { + clampedY = x * Math.tan(90 * (Math.PI / 180)); + clampedY = Math.min(1, clampedY); // Ensure it doesn't exceed max opacity + } + + return { x: clampedX, y: clampedY }; + }, []); + + // Mouse event handlers + const handleMouseDown = (event) => { + event.preventDefault(); + const point = d3.pointer(event); + const x = xScale.invert(point[0] - MARGIN.left); + const y = yScale.invert(point[1] - MARGIN.top); + + if (useAdvancedMode) { + // Only allow points within valid region (first quadrant) + if (y < 0 || y > 1 || x < 0 || x > 255) return; + + // Check for existing point within click radius + const existingPointIndex = controlPoints.findIndex(p => + Math.abs(xScale(p.x) - (point[0] - MARGIN.left)) < 6 && + Math.abs(yScale(p.opacity) - (point[1] - MARGIN.top)) < 6 + ); + + if (existingPointIndex >= 0) { + setSelectedPoint(existingPointIndex); + } else { + const newPoint = { + x: Math.max(0, Math.min(255, x)), + opacity: Math.max(0, Math.min(1, y)), + color: channelColor + }; + const newPoints = [...controlPoints, newPoint].sort((a, b) => a.x - b.x); + setControlPoints(newPoints); + setSelectedPoint(newPoints.findIndex(p => p === newPoint)); + if (onControlPointsChange) { + onControlPointsChange(newPoints); + } + } + } else { + const distToMin = Math.abs(xScale(internalRampRange[0]) - (point[0] - MARGIN.left)); + const distToMax = Math.abs(xScale(internalRampRange[1]) - (point[0] - MARGIN.left)); + setSelectedPoint(distToMin < distToMax ? 0 : 1); + } + setDragging(true); + }; + + const handleMouseMove = (event) => { + if (!dragging) return; + event.preventDefault(); + + const point = d3.pointer(event); + const x = xScale.invert(point[0] - MARGIN.left); + const y = yScale.invert(point[1] - MARGIN.top); + const clamped = clampPoint(x, y); + + if (useAdvancedMode && selectedPoint !== null) { + const newPoints = [...controlPoints]; + if (selectedPoint < newPoints.length) { + newPoints[selectedPoint] = { + ...newPoints[selectedPoint], + x: clamped.x, + opacity: clamped.y + }; + const sortedPoints = newPoints.sort((a, b) => a.x - b.x); + setControlPoints(sortedPoints); + if (onControlPointsChange) { + onControlPointsChange(sortedPoints); + } + updateLut(); // Add immediate LUT update + } + } else if (selectedPoint !== null) { + const newRange = [...internalRampRange]; + newRange[selectedPoint] = clamped.x; + setInternalRampRange(newRange); + if (onRampRangeChange) { + onRampRangeChange(newRange); + } + updateLut(); // Add immediate LUT update + } + }; + + const handleMouseUp = () => { + if (dragging) { + updateLut(); + } + setDragging(false); + setSelectedPoint(null); + }; + + // Handle numeric input updates + const handleInputChange = (index, value) => { + const clampedValue = Math.min(Math.max(0, value), 255); + const newRange = [...internalRampRange]; + + if (index === 0) { + // Min value + newRange[0] = Math.min(clampedValue, newRange[1]); + } else { + // Max value + newRange[1] = Math.max(clampedValue, newRange[0]); + } + + setInternalRampRange(newRange); + if (onRampRangeChange) { + onRampRangeChange(newRange); + } + updateLut(); + }; + + // Initial setup and updates + useEffect(() => { + if (!svgRef.current) return; + + const svg = d3.select(svgRef.current); + svg.selectAll('*').remove(); + + // Create main groups + svg.append('g') + .attr('class', 'histogram-group') + .attr('transform', `translate(${MARGIN.left},${MARGIN.top})`); + + svg.append('g') + .attr('class', 'control-points-group') + .attr('transform', `translate(${MARGIN.left},${MARGIN.top})`); + + // Draw axes with explicit tick values + const xAxis = d3.axisBottom(xScale) + .tickValues([0, 50, 100, 150, 200, 255]) + .tickFormat(d3.format('d')) + .tickSize(-6) + .tickPadding(8); + + const yAxis = d3.axisLeft(yScale) + .ticks(5) + .tickFormat(d3.format('.2f')) + .tickSize(-6) + .tickPadding(8); + + svg.append('g') + .attr('transform', `translate(${MARGIN.left},${height - MARGIN.bottom})`) + .attr('class', 'x-axis') + .call(xAxis); + + svg.append('g') + .attr('transform', `translate(${MARGIN.left},${MARGIN.top})`) + .attr('class', 'y-axis') + .call(yAxis); + + // Add axis labels + svg.append('text') + .attr('class', 'x-axis-label') + .attr('text-anchor', 'middle') + .attr('x', MARGIN.left + innerWidth / 2) + .attr('y', height - 5) + .style('font-size', '12px') + .text('Intensity'); + + svg.append('text') + .attr('class', 'y-axis-label') + .attr('text-anchor', 'middle') + .attr('transform', `rotate(-90)`) + .attr('x', -(MARGIN.top + innerHeight / 2)) + .attr('y', MARGIN.left / 2) + .style('font-size', '12px') + .text('Opacity'); + + // Draw histogram and controls + drawHistogram(); + updateVisualization(); + }, [drawHistogram, updateVisualization, height, xScale, yScale, innerWidth, innerHeight]); + + return ( +
+ + + {/* Numeric inputs for ramp mode */} + {!useAdvancedMode && ( +
+
+ + handleInputChange(0, Number(e.target.value))} + style={{ + width: '60px', + padding: '4px', + border: '1px solid #ccc', + borderRadius: '4px', + textAlign: 'center' + }} + /> +
+
+ + handleInputChange(1, Number(e.target.value))} + style={{ + width: '60px', + padding: '4px', + border: '1px solid #ccc', + borderRadius: '4px', + textAlign: 'center' + }} + /> +
+
+ )} + + +
+ ); +}; + +export default TransferFunctionEditor; \ No newline at end of file diff --git a/src/components/VolumeViewer.js b/src/components/VolumeViewer.js index 097aca1..7bf085e 100644 --- a/src/components/VolumeViewer.js +++ b/src/components/VolumeViewer.js @@ -70,6 +70,7 @@ import PlanarSlicePlayer from "./PlanarSlicePlayer"; import FilesList from "./FilesList"; import ThreePointGammaSlider from "./ThreePointGammaSlider"; import ClipRegionSlider from "./ClipRegionSlider"; +import TransferFunctionEditor from "./TransferFunctionEditor"; // Utility function to concatenate arrays const concatenateArrays = (arrays) => { const totalLength = arrays.reduce((acc, arr) => acc + arr.length, 0); @@ -764,20 +765,26 @@ const VolumeViewer = () => { const channelColor = currentPresetColors[index % currentPresetColors.length]; - return { - name, - enabled: index < 3, - colorD: channelColor, - colorS: [0, 0, 0], - colorE: [0, 0, 0], - glossiness: 0, - window: 1, - level: 0.5, - isovalue: 128, - isosurface: false, - brightness: 1.2, - contrast: 1.1, - }; + return { + name, + enabled: index < 3, + colorD: channelColor, + colorS: [0, 0, 0], + colorE: [0, 0, 0], + glossiness: 0, + window: 1, + level: 0.5, + isovalue: 128, + isosurface: false, + brightness: 1.2, + contrast: 1.1, + // Add these new properties + useAdvancedMode: false, + controlPoints: [ + { x: 0, opacity: 0, color: channelColor }, + { x: 255, opacity: 1, color: channelColor } + ] + }; }); setChannels(channelGui); @@ -891,25 +898,74 @@ const VolumeViewer = () => { const updateChannelLut = (index, type) => { if (currentVolume) { let lut; - if (type === "autoIJ") { - const [hmin, hmax] = currentVolume.getHistogram(index).findAutoIJBins(); - lut = new Lut().createFromMinMax(hmin, hmax); - } else if (type === "auto0") { - const [b, e] = currentVolume.getHistogram(index).findAutoMinMax(); - lut = new Lut().createFromMinMax(b, e); - } else if (type === "bestFit") { - const [hmin, hmax] = currentVolume - .getHistogram(index) - .findBestFitBins(); - lut = new Lut().createFromMinMax(hmin, hmax); - } else if (type === "pct50_98") { - const hmin = currentVolume.getHistogram(index).findBinOfPercentile(0.5); - const hmax = currentVolume - .getHistogram(index) - .findBinOfPercentile(0.983); - lut = new Lut().createFromMinMax(hmin, hmax); + let newRange; + let newControlPoints; + const histogram = currentVolume.getHistogram(index); + const channelColor = channels[index].color; + + switch(type) { + case "autoIJ": { + const [hmin, hmax] = histogram.findAutoIJBins(); + lut = new Lut().createFromMinMax(hmin, hmax); + newRange = [hmin, hmax]; + // Create control points for advanced mode + newControlPoints = [ + { x: 0, opacity: 0, color: channelColor }, + { x: hmin, opacity: 0, color: channelColor }, + { x: hmax, opacity: 1, color: channelColor }, + { x: 255, opacity: 1, color: channelColor } + ]; + break; + } + case "auto0": { + const [hmin, hmax] = histogram.findAutoMinMax(); + lut = new Lut().createFromMinMax(hmin, hmax); + newRange = [hmin, hmax]; + newControlPoints = [ + { x: 0, opacity: 0, color: channelColor }, + { x: hmin, opacity: 0, color: channelColor }, + { x: hmax, opacity: 1, color: channelColor }, + { x: 255, opacity: 1, color: channelColor } + ]; + break; + } + case "bestFit": { + const [hmin, hmax] = histogram.findBestFitBins(); + lut = new Lut().createFromMinMax(hmin, hmax); + newRange = [hmin, hmax]; + newControlPoints = [ + { x: 0, opacity: 0, color: channelColor }, + { x: hmin, opacity: 0, color: channelColor }, + { x: hmax, opacity: 1, color: channelColor }, + { x: 255, opacity: 1, color: channelColor } + ]; + break; + } + case "pct50_98": { + const hmin = histogram.findBinOfPercentile(0.5); + const hmax = histogram.findBinOfPercentile(0.983); + lut = new Lut().createFromMinMax(hmin, hmax); + newRange = [hmin, hmax]; + newControlPoints = [ + { x: 0, opacity: 0, color: channelColor }, + { x: hmin, opacity: 0, color: channelColor }, + { x: hmax, opacity: 1, color: channelColor }, + { x: 255, opacity: 1, color: channelColor } + ]; + break; + } } - + + // Update channel settings + const updatedChannels = [...channels]; + updatedChannels[index] = { + ...updatedChannels[index], + controlPoints: newControlPoints, + rampRange: newRange + }; + setChannels(updatedChannels); + + // Apply to volume currentVolume.setLut(index, lut); view3D.updateLuts(currentVolume); view3D.redraw(); @@ -1493,6 +1549,47 @@ const VolumeViewer = () => {
{channel.name || `Channel ${index + 1}`}
+ {channel.enabled && ( +
+ { + if (currentVolume) { + currentVolume.setLut(channelIndex, lut); + view3D.updateLuts(currentVolume); + view3D.redraw(); + } + }} + useAdvancedMode={channel.useAdvancedMode} + initialControlPoints={channel.controlPoints} + rampRange={channel.rampRange} + channelColor={channel.color} + onRampRangeChange={(newRange) => { + const updatedChannels = [...channels]; + updatedChannels[index].rampRange = newRange; + setChannels(updatedChannels); + }} + onControlPointsChange={(newPoints) => { + const updatedChannels = [...channels]; + updatedChannels[index].controlPoints = newPoints; + setChannels(updatedChannels); + }} + /> + {/* Advanced Mode Toggle */} +
+ { + const updatedChannels = [...channels]; + updatedChannels[index].useAdvancedMode = checked; + setChannels(updatedChannels); + }} + size="small" + /> Advanced Mode +
+
+ )} Enable Channel @@ -1603,14 +1700,45 @@ const VolumeViewer = () => { width: "100%", }} > - +