Compare commits

..
2986 changed files with 70275 additions and 197310 deletions

No files matched your search

+296 -240
View File
@@ -1,52 +1,72 @@
version: 2.1
### ABOUT
#
# This configuration powers our Circleci.io integration
#
# Note:
# Netlify works independently from this configuration to
# create pull request previews and to update `https://docs.ohif.org`
###
## https://github.com/cypress-io/circleci-orb
##
orbs:
codecov: codecov/codecov@1.0.5
cypress: cypress-io/cypress@3.4.2
cypress: cypress-io/cypress@3.3.1
executors:
cypress-custom:
description: |
Single Docker container used to run Cypress Tests
docker:
- image: cimg/node:<< parameters.node-version >>-browsers
parameters:
node-version:
default: '18.16.1'
description: |
The version of Node to run your tests with.
type: string
defaults: &defaults
docker:
- image: cimg/node:20.18.1
- image: cimg/node:18.18
environment:
TERM: xterm
TERM: xterm # Enable colors in term
QUICK_BUILD: true
working_directory: ~/repo
commands:
install_bun:
steps:
- restore_cache:
keys:
- bun-cache-v2-{{ arch }}-latest
- run:
name: Install Bun
command: |
if [ ! -d "$HOME/.bun" ]; then
curl -fsSL https://bun.sh/install | bash
fi
echo 'export BUN_INSTALL="$HOME/.bun"' >> $BASH_ENV
echo 'export PATH="$BUN_INSTALL/bin:$PATH"' >> $BASH_ENV
source $BASH_ENV
- save_cache:
key: bun-cache-v2-{{ arch }}-latest
paths:
- ~/.bun
jobs:
###
# Workflow: PR_CHECKS
###
UNIT_TESTS:
<<: *defaults
resource_class: large
steps:
- install_bun
# Update yarn
- run: yarn -v
- run: node --version
# Checkout code and ALL Git Tags
- checkout
- restore_cache:
name: Restore Yarn and Cypress Package Cache
keys:
# when lock file changes, use increasingly general patterns to restore cache
- yarn-packages-{{ checksum "yarn.lock" }}
- yarn-packages-
- run:
name: Install Dependencies
command: bun install --no-save
command: yarn install --frozen-lockfile
- save_cache:
name: Save Yarn Package Cache
paths:
- ~/.cache ## Cache yarn and Cypress
key: yarn-packages-{{ checksum "yarn.lock" }}
# RUN TESTS
- run:
name: 'JavaScript Test Suite'
command: bun run test:unit:ci
command: yarn run test:unit:ci
# platform/app
- run:
name: 'VIEWER: Combine report output'
@@ -74,23 +94,36 @@ jobs:
file: '/home/circleci/repo/platform/core/coverage/reports'
flags: 'core'
###
# Workflow: DEPLOY
###
BUILD:
<<: *defaults
resource_class: large
steps:
# Checkout code and ALL Git Tags
- checkout
- install_bun
- restore_cache:
name: Restore Yarn and Cypress Package Cache
keys:
# when lock file changes, use increasingly general patterns to restore cache
- yarn-packages-{{ checksum "yarn.lock" }}
- yarn-packages-
- run:
name: Install Dependencies
command: bun install --no-save
command: yarn install --frozen-lockfile
- save_cache:
name: Save Yarn Package Cache
paths:
- ~/.cache ## Cache yarn and Cypress
key: yarn-packages-{{ checksum "yarn.lock" }}
# Build & Test
- run:
name: 'Perform the versioning before build'
command: bun ./version.mjs
command: node ./version.mjs
- run:
name: 'Build the OHIF Viewer'
command: bun run build
command: yarn run build
no_output_timeout: 45m
- run:
name: 'Upload SourceMaps, Send Deploy Notification'
@@ -112,18 +145,30 @@ jobs:
- commit.txt
- version.json
# just to make sure later on we can publish them
BUILD_PACKAGES_QUICK:
<<: *defaults
resource_class: large
steps:
- install_bun
- run: yarn -v
# Checkout code and ALL Git Tags
- checkout
- attach_workspace:
at: ~/repo
# Use increasingly general patterns to restore cache
- restore_cache:
name: Restore Yarn and Cypress Package Cache
keys:
- yarn-packages-{{ checksum "yarn.lock" }}
- yarn-packages-
- run:
name: Install Dependencies
command: bun install --frozen-lockfile
command: yarn install --frozen-lockfile
- save_cache:
name: Save Yarn Package Cache
paths:
- ~/.cache/yarn
key: yarn-packages-{{ checksum "yarn.lock" }}
- run:
name: Avoid hosts unknown for github
command: |
@@ -138,24 +183,38 @@ jobs:
- run:
name: build half of the packages (to avoid out of memory in circleci)
command: |
bun run build:package-all
yarn run build:package-all
- run:
name: build the other half of the packages
command: |
bun run build:package-all-1
yarn run build:package-all-1
###
# Workflow: RELEASE
###
NPM_PUBLISH:
<<: *defaults
resource_class: large
steps:
- install_bun
- run: yarn -v
# Checkout code and ALL Git Tags
- checkout
- attach_workspace:
at: ~/repo
# Use increasingly general patterns to restore cache
- restore_cache:
name: Restore Yarn and Cypress Package Cache
keys:
- yarn-packages-{{ checksum "yarn.lock" }}
- yarn-packages-
- run:
name: Install Dependencies
command: bun install --no-save
command: yarn install --frozen-lockfile
- save_cache:
name: Save Yarn Package Cache
paths:
- ~/.cache/yarn
key: yarn-packages-{{ checksum "yarn.lock" }}
- run:
name: Avoid hosts unknown for github
command: |
@@ -170,11 +229,11 @@ jobs:
- run:
name: build half of the packages (to avoid out of memory in circleci)
command: |
bun run build:package-all
yarn run build:package-all
- run:
name: build the other half of the packages
command: |
bun run build:package-all-1
yarn run build:package-all-1
- run:
name: increase min time out
command: |
@@ -186,14 +245,14 @@ jobs:
- run:
name: publish package versions
command: |
bun ./publish-version.mjs
node ./publish-version.mjs
- run:
name: Again set the NPM registry (was deleted in the version script)
command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/repo/.npmrc
- run:
name: publish package dist
command: |
bun ./publish-package.mjs
node ./publish-package.mjs
- persist_to_workspace:
root: ~/repo
paths:
@@ -208,7 +267,7 @@ jobs:
- setup_remote_docker:
docker_layer_caching: false
- run:
name: Build Docker image for amd64
name: Build and push Docker image from the release branch
command: |
# This file will exist if a new version was published by
# our command in the previous job.
@@ -222,50 +281,12 @@ jobs:
export IMAGE_VERSION_FULL=v$IMAGE_VERSION
echo $IMAGE_VERSION
echo $IMAGE_VERSION_FULL
# Build our amd64 image, auth, and push
docker build --platform linux/amd64 --tag ohif/app:$IMAGE_VERSION_FULL-amd64 --tag ohif/app:latest-amd64 .
# Build our image, auth, and push
docker build --tag ohif/app:$IMAGE_VERSION_FULL --tag ohif/app:latest .
echo $DOCKER_PWD | docker login -u $DOCKER_LOGIN --password-stdin
docker push ohif/app:$IMAGE_VERSION_FULL-amd64
docker push ohif/app:latest-amd64
docker push ohif/app:$IMAGE_VERSION_FULL
docker push ohif/app:latest
fi
- persist_to_workspace:
root: ~/repo
paths:
- .
DOCKER_RELEASE_PUBLISH_ARM:
<<: *defaults
resource_class: arm.large
steps:
- attach_workspace:
at: ~/repo
- setup_remote_docker:
docker_layer_caching: false
- run:
name: Build Docker image for arm64 (Release)
command: |
# This file will exist if a new version was published by
# our command in the previous job.
if [[ ! -e version.txt ]]; then
exit 0
else
# Remove npm config
rm -f ./.npmrc
# Set our version number using vars
export IMAGE_VERSION=$(cat version.txt)
export IMAGE_VERSION_FULL=v$IMAGE_VERSION
echo $IMAGE_VERSION
echo $IMAGE_VERSION_FULL
# Build our arm64 image, auth, and push
docker build --platform linux/arm64 --tag ohif/app:$IMAGE_VERSION_FULL-arm64 --tag ohif/app:latest-arm64 .
echo $DOCKER_PWD | docker login -u $DOCKER_LOGIN --password-stdin
docker push ohif/app:$IMAGE_VERSION_FULL-arm64
docker push ohif/app:latest-arm64
fi
- persist_to_workspace:
root: ~/repo
paths:
- .
DOCKER_BETA_PUBLISH:
<<: *defaults
@@ -276,7 +297,7 @@ jobs:
- setup_remote_docker:
docker_layer_caching: false
- run:
name: Build Docker image for amd64 (Beta)
name: Build and push Docker image from the master branch (beta releases)
command: |
echo $(ls -l)
@@ -288,153 +309,108 @@ jobs:
else
echo "Building and pushing Docker image from the master branch (beta releases)"
rm -f ./.npmrc
# Set our version number using vars
export IMAGE_VERSION=$(cat version.txt)
export IMAGE_VERSION_FULL=v$IMAGE_VERSION
echo $IMAGE_VERSION
echo $IMAGE_VERSION_FULL
# Build our amd64 image, auth, and push
docker build --platform linux/amd64 --tag ohif/app:$IMAGE_VERSION_FULL-amd64 --tag ohif/app:latest-beta-amd64 .
# Build our image, auth, and push
echo "starting docker build"
docker build --tag ohif/app:$IMAGE_VERSION_FULL .
echo $DOCKER_PWD | docker login -u $DOCKER_LOGIN --password-stdin
docker push ohif/app:$IMAGE_VERSION_FULL-amd64
docker push ohif/app:latest-beta-amd64
echo "starting docker push"
docker push ohif/app:$IMAGE_VERSION_FULL
fi
DOCKER_BETA_PUBLISH_ARM:
<<: *defaults
resource_class: arm.large
steps:
- attach_workspace:
at: ~/repo
- setup_remote_docker:
docker_layer_caching: false
- run:
name: Build Docker image for arm64 (Beta)
command: |
echo $(ls -l)
# This file will exist if a new version was published by
# our command in the previous job.
if [[ ! -e version.txt ]]; then
echo "don't have version txt"
exit 0
else
echo "Building and pushing ARM64 Docker image from the master branch (beta releases)"
rm -f ./.npmrc
# Set our version number using vars
export IMAGE_VERSION=$(cat version.txt)
export IMAGE_VERSION_FULL=v$IMAGE_VERSION
echo $IMAGE_VERSION
echo $IMAGE_VERSION_FULL
# Build our arm64 image, auth, and push
docker build --platform linux/arm64 --tag ohif/app:$IMAGE_VERSION_FULL-arm64 --tag ohif/app:latest-beta-arm64 .
echo $DOCKER_PWD | docker login -u $DOCKER_LOGIN --password-stdin
docker push ohif/app:$IMAGE_VERSION_FULL-arm64
docker push ohif/app:latest-beta-arm64
fi
CYPRESS:
<<: *defaults
# This is copied from the Cypress orb since the default for cypress/run is node 16 and
# we migrated to 18
CYPRESS_CUSTOM_RUN:
description: |
A single, complete job to run Cypress end-to-end tests in your application.
executor: cypress-custom
parallelism: << parameters.parallelism >>
parameters:
cypress-cache-key:
default: cypress-cache-{{ arch }}-{{ checksum "package.json" }}
description: Cache key used to cache the Cypress binary.
type: string
cypress-cache-path:
default: ~/.cache/Cypress
description: |
By default, this will cache the '~/.cache/Cypress' directory so that the Cypress binary is cached. You can override this by providing your own cache path.
type: string
cypress-command:
default: npx cypress run
description: Command used to run your Cypress tests
type: string
include-branch-in-node-cache-key:
default: false
description: |
If true, this cache will only apply to runs within the same branch. (Adds -{{ .Branch }}- to the node cache key)
type: boolean
install-browsers:
default: false
description: |
Cypress runs by default in the Electron browser. Use this flag to install additional browsers to run your tests in.
This is only needed if you are passing the `--browser` flag in your `cypress-command`.
This parameter leverages the `circleci/browser-tools` orb and includes Chrome and FireFox.
If you need additional browser support you can set this to false and use an executor with a docker image
that includes the browsers of your choosing. See https://hub.docker.com/r/cypress/browsers/tags
type: boolean
install-command:
default: ''
description: Overrides the default NPM command (npm ci)
type: string
node-cache-version:
default: v1
description:
Change the default node cache version if you need to clear the cache for any reason.
type: string
package-manager:
default: npm
description: Select the default node package manager to use. NPM v5+ Required.
enum:
- npm
- yarn
- yarn-berry
type: enum
parallelism:
default: 4
description: |
Number of Circle machines to use for load balancing, min 1
(requires `parallel` and `record` flags in your `cypress-command`)
type: integer
post-install:
default: ''
description: |
Additional commands to run after running install but before verifying Cypress and saving cache.
type: string
start-command:
default: ''
description: Command used to start your local dev server for Cypress to tests against
type: string
working-directory:
default: ''
description: Directory containing package.json
type: string
resource_class: large
parallelism: 8
steps:
- run:
name: Install System Dependencies
command: |
sudo apt-get update
sudo apt-get install -y xvfb libgtk2.0-0 libgtk-3-0 libgbm-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 libxtst6
- run:
name: Start Xvfb
command: Xvfb :99 -screen 0 1920x1080x24 &
background: true
- run:
name: Export Display Variable
command: export DISPLAY=:99
- cypress/install:
install-command: yarn install --no-save
cypress-cache-key: << parameters.cypress-cache-key >>
cypress-cache-path: << parameters.cypress-cache-path >>
include-branch-in-node-cache-key: << parameters.include-branch-in-node-cache-key >>
install-browsers: << parameters.install-browsers >>
install-command: << parameters.install-command >>
node-cache-version: << parameters.node-cache-version >>
package-manager: << parameters.package-manager >>
post-install: << parameters.post-install >>
working-directory: << parameters.working-directory >>
- cypress/run-tests:
cypress-command: |
npx wait-on@latest http://localhost:3000 && cd platform/app && npx cypress run --record --parallel
start-command: yarn run test:data && yarn run test:e2e:serve
DOCKER_MULTIARCH_MANIFEST:
<<: *defaults
resource_class: large
steps:
- attach_workspace:
at: ~/repo
- setup_remote_docker:
docker_layer_caching: false
- run:
name: Create and push multi-architecture manifest (Release)
command: |
# This file will exist if a new version was published by
# our command in the previous job.
if [[ ! -e version.txt ]]; then
exit 0
else
echo "Building and pushing multi-architecture manifest from the master branch (release releases)"
rm -f ./.npmrc
# Set our version number using vars
export IMAGE_VERSION=$(cat version.txt)
export IMAGE_VERSION_FULL=v$IMAGE_VERSION
echo $IMAGE_VERSION
echo $IMAGE_VERSION_FULL
echo $DOCKER_PWD | docker login -u $DOCKER_LOGIN --password-stdin
# Create and push manifest for specific version
docker manifest create ohif/app:$IMAGE_VERSION_FULL \
--amend ohif/app:$IMAGE_VERSION_FULL-amd64 \
--amend ohif/app:$IMAGE_VERSION_FULL-arm64
docker manifest push ohif/app:$IMAGE_VERSION_FULL
# Create and push manifest for "latest" tag
docker manifest create ohif/app:latest \
--amend ohif/app:latest-amd64 \
--amend ohif/app:latest-arm64
docker manifest push ohif/app:latest
fi
DOCKER_BETA_MULTIARCH_MANIFEST:
<<: *defaults
resource_class: large
steps:
- attach_workspace:
at: ~/repo
- setup_remote_docker:
docker_layer_caching: false
- run:
name: Create and push multi-architecture manifest (Beta)
command: |
echo $(ls -l)
# This file will exist if a new version was published by
# our command in the previous job.
if [[ ! -e version.txt ]]; then
exit 0
else
echo "Building and pushing multi-architecture manifest from the master branch (beta releases)"
rm -f ./.npmrc
# Set our version number using vars
export IMAGE_VERSION=$(cat version.txt)
export IMAGE_VERSION_FULL=v$IMAGE_VERSION
echo $IMAGE_VERSION
echo $IMAGE_VERSION_FULL
echo $DOCKER_PWD | docker login -u $DOCKER_LOGIN --password-stdin
# Create and push manifest for specific beta version
docker manifest create ohif/app:$IMAGE_VERSION_FULL \
--amend ohif/app:$IMAGE_VERSION_FULL-amd64 \
--amend ohif/app:$IMAGE_VERSION_FULL-arm64
docker manifest push ohif/app:$IMAGE_VERSION_FULL
# Create and push manifest for "latest-beta" tag
docker manifest create ohif/app:latest-beta \
--amend ohif/app:latest-beta-amd64 \
--amend ohif/app:latest-beta-arm64
docker manifest push ohif/app:latest-beta
fi
cypress-command: << parameters.cypress-command >>
start-command: << parameters.start-command >>
working-directory: << parameters.working-directory >>
workflows:
PR_CHECKS:
@@ -443,37 +419,76 @@ workflows:
filters:
branches:
ignore: master
- UNIT_TESTS
- CYPRESS:
- UNIT_TESTS:
requires:
- BUILD_PACKAGES_QUICK
- CYPRESS_CUSTOM_RUN:
name: 'Cypress Tests'
context: cypress
matrix:
parameters:
start-command:
- yarn run test:data && yarn run test:e2e:serve
install-browsers:
- true
cypress-command:
- 'npx wait-on@latest http://localhost:3000 && cd platform/app && npx cypress run
--record --browser chrome --parallel'
package-manager:
- 'yarn'
cypress-cache-key:
- 'yarn-packages-{{ checksum "yarn.lock" }}'
cypress-cache-path:
- '~/.cache/Cypress'
requires:
- BUILD_PACKAGES_QUICK
# viewer-dev.ohif.org
# PR_OPTIONAL_VISUAL_TESTS:
# jobs:
# - AWAIT_APPROVAL:
# type: approval
# # Update hub.docker.org
# - cypress/run:
# name: 'Generate Percy Snapshots'
# executor: cypress/browsers-chrome76
# browser: chrome
# pre-steps:
# - run: 'rm -rf ~/.yarn && yarn -v && yarn global add wait-on'
# yarn: true
# store_artifacts: false
# working_directory: platform/app
# build:
# yarn test:data && npx cross-env QUICK_BUILD=true APP_CONFIG=config/dicomweb-server.js
# yarn run build
# # start server --> verify running --> percy + chrome + cypress
# command: yarn run test:e2e:dist
# cache-key: 'yarn-packages-{{ checksum "yarn.lock" }}'
# no-workspace: true # Don't persist workspace
# post-steps:
# - store_artifacts:
# path: platform/app/cypress/screenshots
# - store_artifacts:
# path: platform/app/cypress/videos
# requires:
# - AWAIT_APPROVAL
# Our master branch deploys to viewer-dev.ohif.org, the viewer.ohif.org is
# deployed from the release branch which is more stable and less frequently updated.
DEPLOY_MASTER:
jobs:
- BUILD:
filters:
branches:
only: master
# - HOLD_FOR_APPROVAL:
# type: approval
# requires:
# - BUILD
- NPM_PUBLISH:
requires:
# - HOLD_FOR_APPROVAL
- BUILD
- DOCKER_BETA_PUBLISH:
requires:
- NPM_PUBLISH
- DOCKER_BETA_PUBLISH_ARM:
requires:
- DOCKER_BETA_PUBLISH
- DOCKER_BETA_MULTIARCH_MANIFEST:
requires:
- DOCKER_BETA_PUBLISH_ARM
# viewer.ohif.org
# Our release branch deploys to viewer.ohif.org and is more stable and less
# frequently updated after being tested in the staging environment.
DEPLOY_RELEASE:
jobs:
- BUILD:
@@ -490,9 +505,50 @@ workflows:
- DOCKER_RELEASE_PUBLISH:
requires:
- NPM_PUBLISH
- DOCKER_RELEASE_PUBLISH_ARM:
requires:
- DOCKER_RELEASE_PUBLISH
- DOCKER_MULTIARCH_MANIFEST:
requires:
- DOCKER_RELEASE_PUBLISH_ARM
###
# Unit and E2E tests have already run for PR_CHECKS
# Re-running should not gain us any confidence here
###
# RELEASE:
# jobs:
# - NPM_PUBLISH:
# filters:
# branches:
# only: master
# - DOCS_PUBLISH:
# filters:
# branches:
# only: master
# # Update base branch snapshots
# # and record a Cypress dashboard test run
# - cypress/run:
# name: 'Generate Percy Snapshots'
# executor: cypress/browsers-chrome76
# browser: chrome
# pre-steps:
# - run: 'rm -rf ~/.yarn && npm i -g yarn && yarn -v && yarn global
# add wait-on' # Use yarn latest
# yarn: true
# store_artifacts: false
# working_directory: platform/app
# build:
# npx cross-env QUICK_BUILD=true APP_CONFIG=config/e2e.js yarn run
# build
# # start server --> verify running --> percy + chrome + cypress
# command: yarn run test:e2e:dist
# cache-key: 'yarn-packages-{{ checksum "yarn.lock" }}'
# no-workspace: true # Don't persist workspace
# post-steps:
# - store_artifacts:
# path: platform/app/cypress/screenshots
# - store_artifacts:
# path: platform/app/cypress/videos
# - store_test_results:
# path: platform/app/cypress/results
# filters:
# branches:
# only: master
# - DOCKER_MASTER_PUBLISH:
# requires:
# - NPM_PUBLISH
+1 -4
View File
@@ -1,13 +1,10 @@
server {
gzip_static always;
gzip_proxied expired no-cache no-store private auth;
gunzip on;
listen ${PORT} default_server;
listen [::]:${PORT} default_server;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ ${PUBLIC_URL}index.html;
try_files $uri $uri/ /index.html;
add_header Cross-Origin-Resource-Policy same-origin;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
+6 -24
View File
@@ -2,34 +2,16 @@
if [ -n "$SSL_PORT" ]
then
envsubst '${SSL_PORT}:${PORT}' < /usr/src/default.ssl.conf.template | envsubst '${PUBLIC_URL}' > /etc/nginx/conf.d/default.conf
envsubst '${SSL_PORT}:${PORT}' < /usr/src/default.ssl.conf.template > /etc/nginx/conf.d/default.conf
else
envsubst '${PORT}:${PUBLIC_URL}' < /usr/src/default.conf.template > /etc/nginx/conf.d/default.conf
envsubst '${PORT}' < /usr/src/default.conf.template > /etc/nginx/conf.d/default.conf
fi
if [ -n "$APP_CONFIG" ]; then
echo "$APP_CONFIG" > /usr/share/nginx/html${PUBLIC_URL}app-config.js
echo "Using custom APP_CONFIG environment variable"
else
echo "Not using custom APP_CONFIG"
if [ -n "$APP_CONFIG" ]
then
echo "$APP_CONFIG" > /usr/share/nginx/html/app-config.js
fi
if [ -f /usr/share/nginx/html${PUBLIC_URL}app-config.js ]; then
if [ -s /usr/share/nginx/html${PUBLIC_URL}app-config.js ]; then
echo "Detected non-empty app-config.js. Ensuring .gz file is updated..."
rm -f /usr/share/nginx/html${PUBLIC_URL}app-config.js.gz
gzip /usr/share/nginx/html${PUBLIC_URL}app-config.js
touch /usr/share/nginx/html${PUBLIC_URL}app-config.js
echo "Compressed app-config.js to app-config.js.gz"
else
echo "app-config.js is empty. Skipping compression."
fi
else
echo "No app-config.js file found. Skipping compression."
fi
if [ -n "$CLIENT_ID" ] || [ -n "$HEALTHCARE_API_ENDPOINT" ]
then
# If CLIENT_ID is specified, use the google.js configuration with the modified ID
@@ -58,6 +40,6 @@ if [ -n "$CLIENT_ID" ] || [ -n "$HEALTHCARE_API_ENDPOINT" ]
cp /usr/share/nginx/html/google.js /usr/share/nginx/html/app-config.js
fi
echo "Starting Nginx to serve the OHIF Viewer on ${PUBLIC_URL}"
echo "Starting Nginx to serve the OHIF Viewer..."
exec "$@"
-4
View File
@@ -1,4 +0,0 @@
find platform/app/dist -name "*.js" -exec gzip -9 "{}" \; -exec touch "{}" \;
find platform/app/dist -name "*.map" -exec gzip -9 "{}" \; -exec touch "{}" \;
find platform/app/dist -name "*.css" -exec gzip -9 "{}" \; -exec touch "{}" \;
find platform/app/dist -name "*.svg" -exec gzip -9 "{}" \; -exec touch "{}" \;
+4 -4
View File
@@ -8,11 +8,11 @@
# have this file as their .dockerignore.
# Output
**/dist/
**/build/
dist/
build/
# Dependencies
**/node_modules/
node_modules/
# Root
README.md
@@ -33,5 +33,5 @@ dockerfile
.scripts/
.vscode/
coverage/
platform/docs/
docs/
testdata/
+1 -2
View File
@@ -18,8 +18,7 @@
},
"rules": {
// Enforce consistent brace style for all control statements for readability
"curly": "error",
"import/no-anonymous-default-export": "off"
"curly": "error"
},
"globals": {
"cy": true,
-19
View File
@@ -1,19 +0,0 @@
version: 2
enable-beta-ecosystems: true
updates:
- package-ecosystem: 'bun'
directory: '/'
schedule:
interval: 'daily'
labels: ['dependencies']
commit-message:
prefix: 'chore'
include: 'scope'
- package-ecosystem: 'npm'
directory: '/'
schedule:
interval: 'daily'
labels: ['dependencies']
commit-message:
prefix: 'chore'
include: 'scope'
+21 -22
View File
@@ -51,29 +51,28 @@ body:
validations:
required: true
- type: textarea
id: system_info
- type: input
id: os
attributes:
label: 'System Information'
description: 'Please run the following command in your terminal and paste the output:'
placeholder: |
Run: npx envinfo --system --binaries --browsers
Then paste the output here. It should look something like:
System:
OS: Windows 10 10.0.19042
CPU: (8) x64 Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
Memory: 15.89 GB / 31.74 GB
Shell: 1.0.0 - C:\WINDOWS\System32\WindowsPowerShell\v1.0\powershell.exe
Binaries:
Node: 20.18.1 - C:\Program Files\nodejs\node.EXE
Yarn: 1.22.22 - C:\Users\user\AppData\Roaming\npm\yarn.CMD
npm: 10.8.2 - C:\Program Files\nodejs\npm.CMD
Browsers:
Chrome: 83.0.4103.116
Edge: Spartan (44.19041.1266.0), Chromium (83.0.478.58)
Firefox: 77.0.1
label: 'OS'
description: 'Your operating system.'
placeholder: 'e.g., Windows 10, macOS 10.15.4'
validations:
required: true
- type: input
id: node-version
attributes:
label: 'Node version'
description: 'Your Node.js version.'
placeholder: 'e.g., 18.16.1'
validations:
required: true
- type: input
id: browser
attributes:
label: 'Browser'
description: 'Your browser.'
placeholder: 'e.g., Chrome 83.0.4103.116, Firefox 77.0.1, Safari 13.1.1'
validations:
required: true
-108
View File
@@ -1,108 +0,0 @@
name: Build and Deploy Docs
on:
push:
branches: [master]
env:
ACTIONS_STEP_DEBUG: true
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-deploy-docs:
timeout-minutes: 60
runs-on: ubuntu-latest
# Need permissions to read actions and pull requests
permissions:
actions: read
contents: read
pull-requests: read
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- uses: actions/setup-node@v4
with:
node-version: 20 # Or your desired Node version
- name: Install root dependencies
run: bun install --frozen-lockfile
# Removed Playwright tests and coverage generation steps
- name: Find PR and associated workflow run
id: find_pr_run
env:
GH_TOKEN: ${{ github.token }}
MERGE_COMMIT_SHA: ${{ github.sha }}
run: |
# Find the PR associated with the merge commit SHA
# Note: This relies on the merge commit being directly pushed to main
PR_DATA=$(gh pr list --state merged --search "$MERGE_COMMIT_SHA" --json number,headRefOid --jq '.[0]')
if [ -z "$PR_DATA" ]; then
echo "Could not find merged PR for commit $MERGE_COMMIT_SHA."
# Decide how to handle - fail, or maybe generate coverage now?
# For now, let's fail.
exit 1
fi
PR_HEAD_SHA=$(echo "$PR_DATA" | jq -r '.headRefOid')
PR_NUMBER=$(echo "$PR_DATA" | jq -r '.number')
echo "Found PR Number: $PR_NUMBER"
echo "Found PR Head SHA: $PR_HEAD_SHA"
# Find the latest workflow run ID for the playwright workflow on the PR head commit
# Uses the workflow file name 'playwright.yml'
# Remove the --status success flag to find any run
RUN_ID=$(gh run list --workflow playwright.yml --commit "$PR_HEAD_SHA" --event pull_request --json databaseId --jq '.[0].databaseId')
if [ -z "$RUN_ID" ]; then
echo "Could not find any 'playwright.yml' run for PR $PR_NUMBER (Head SHA: $PR_HEAD_SHA)."
# Decide how to handle - maybe try finding the artifact from the merge commit run if that exists?
# For now, let's fail.
exit 1
fi
echo "Found Run ID: $RUN_ID"
echo "run_id=$RUN_ID" >> $GITHUB_OUTPUT
- name: Download coverage artifact from PR run
env:
GH_TOKEN: ${{ github.token }}
run: |
mkdir -p ./coverage-artifact
gh run download ${{ steps.find_pr_run.outputs.run_id }} -n coverage-report-pr --dir ./coverage-artifact
# Check if download was successful (e.g., check if files exist)
if [ ! -f ./coverage-artifact/base.css ]; then
echo "Failed to download or find expected files in artifact 'coverage-report-pr' from run ${{ steps.find_pr_run.outputs.run_id }}."
exit 1
fi
echo "Artifact downloaded successfully."
- name: Install docs dependencies
run: cd platform/docs && bun install
- name: Copy coverage to docs static directory
run: |
# Copy files from the downloaded artifact directory
mkdir -p platform/docs/static/coverage
cp -r ./coverage-artifact/* platform/docs/static/coverage/
# Copy specific asset files from the downloaded artifact root to static root
cp ./coverage-artifact/base.css platform/docs/static/
cp ./coverage-artifact/block-navigation.js platform/docs/static/
cp ./coverage-artifact/prettify.css platform/docs/static/
cp ./coverage-artifact/prettify.js platform/docs/static/
cp ./coverage-artifact/favicon.png platform/docs/static/
cp ./coverage-artifact/sort-arrow-sprite.png platform/docs/static/
cp ./coverage-artifact/sorter.js platform/docs/static/
- name: Build docs
run: cd platform/docs && bun run build
- name: Deploy to Netlify
run: |
cd platform/docs
npx netlify-cli deploy --dir=./build --prod
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
-98
View File
@@ -1,98 +0,0 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL Advanced"
on:
pull_request:
branches: [ "main" ]
schedule:
- cron: '15 1 * * 5'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
# required for all workflows
security-events: write
# required to fetch internal or private CodeQL packs
packages: read
# only required for workflows in private repositories
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: javascript-typescript
build-mode: none
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
# or others). This is typically only required for manual builds.
# - name: Setup runtime (example)
# uses: actions/setup-example@v1
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# ℹ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- if: matrix.build-mode == 'manual'
shell: bash
run: |
echo 'If you are using a "manual" build mode for one or more of the' \
'languages you are analyzing, replace this with the commands to build' \
'your code, for example:'
echo ' make bootstrap'
echo ' make release'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"
+47 -34
View File
@@ -1,54 +1,67 @@
name: Playwright Tests
on:
push:
branches: [main, master]
pull_request:
branches: [master]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
branches: [main, master]
jobs:
playwright-tests:
timeout-minutes: 60
runs-on: self-hosted
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.49.0-noble
strategy:
fail-fast: false
matrix:
node-version: [20]
shardIndex: [1, 2, 3, 4, 5]
shardTotal: [5]
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
node-version: 18
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Install Playwright browsers
run: npx playwright install
run: yarn install --frozen-lockfile
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: |
export NODE_OPTIONS="--max_old_space_size=10192"
bun run test:e2e:coverage
- name: Create directory of test results
if: ${{ !cancelled() }}
run: |
mkdir -p packaged-test-results
cp -r ./tests/test-results packaged-test-results/ || true
cp ./tests/playwright-report.json packaged-test-results/ || true
- name: Upload directory of test results artifact
run:
export NODE_OPTIONS="--max_old_space_size=8192" && npx playwright test --shard=${{
matrix.shardIndex }}/${{ matrix.shardTotal }}
- name: Upload blob report to GitHub Actions Artifacts
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-results
path: packaged-test-results/
retention-days: 5
- name: create the coverage report
run: |
bun nyc report --reporter=lcov --reporter=text
- name: Upload the coverage report to GitHub Actions Artifacts
if: ${{ !cancelled() }}
name: blob-report-${{ matrix.shardIndex }}
path: blob-report
retention-days: 1
merge-reports:
if: ${{ !cancelled() }}
needs: [playwright-tests]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Download blob reports from GitHub Actions Artifacts
uses: actions/download-artifact@v4
with:
path: all-blob-reports
pattern: blob-report-*
merge-multiple: true
- name: Merge into HTML Report
run: npx playwright merge-reports --reporter html ./all-blob-reports
- name: Upload HTML report
uses: actions/upload-artifact@v4
with:
name: coverage-report-pr
path: coverage
retention-days: 3
name: html-report--attempt-${{ github.run_attempt }}
path: playwright-report
retention-days: 14
+1 -8
View File
@@ -1,7 +1,6 @@
# Packages
node_modules
.cursor/
.nyc_output/
# Output
build
dist
@@ -13,7 +12,6 @@ coverage/
.yarn/
.nx/
addOns/yarn.lock
playwright-report/
# YALC (for Erik)
.yalc
@@ -58,8 +56,3 @@ tests/test-results/
tests/playwright-report/
/blob-report/
/playwright/.cache/
**/.claude/settings.local.json
# Backup files
*~
+1 -1
View File
@@ -1 +1 @@
20.9.0
18.16.1
-24
View File
@@ -1,24 +0,0 @@
{
"extends": "@istanbuljs/nyc-config-typescript",
"instrument": true,
"sourceMap": true,
"cache": false,
"all": true,
"include": [
"platform/*/src/**/*.ts",
"platform/*/src/**/*.js",
"extensions/*/src/**/*.ts",
"extensions/*/src/**/*.js",
"modes/*/src/**/*.ts",
"modes/*/src/**/*.js"
],
"exclude": [
"**/*.spec.ts",
"**/*.test.ts",
"**/test/**",
"**/tests/**",
"**/examples/**",
"**/stories/**",
"platform/docs/**"
]
}
+1 -4
View File
@@ -82,7 +82,6 @@
"Reslices",
"roadmap",
"ROADMAPS",
"rtstruct",
"Segmentations",
"semibold",
"sitk",
@@ -110,8 +109,6 @@
"wado",
"wadors",
"wadouri",
"workerpool",
"Colorbar",
"Colorbars"
"workerpool"
]
}
+10 -30
View File
@@ -26,7 +26,6 @@ const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'
const NODE_ENV = process.env.NODE_ENV;
const QUICK_BUILD = process.env.QUICK_BUILD;
const BUILD_NUM = process.env.CIRCLE_BUILD_NUM || '0';
const IS_COVERAGE = process.env.COVERAGE === 'true';
// read from ../version.txt
const VERSION_NUMBER = fs.readFileSync(path.join(__dirname, '../version.txt'), 'utf8') || '';
@@ -50,7 +49,6 @@ const defineValues = {
'process.env.LOCIZE_PROJECTID': JSON.stringify(process.env.LOCIZE_PROJECTID || ''),
'process.env.LOCIZE_API_KEY': JSON.stringify(process.env.LOCIZE_API_KEY || ''),
'process.env.REACT_APP_I18N_DEBUG': JSON.stringify(process.env.REACT_APP_I18N_DEBUG || ''),
'process.env.TEST_ENV': JSON.stringify(process.env.TEST_ENV || ''),
};
// Only redefine updated values. This avoids warning messages in the logs
@@ -101,30 +99,14 @@ module.exports = (env, argv, { SRC_DIR, ENTRY }) => {
...(isProdBuild
? []
: [
...(IS_COVERAGE
? [
{
test: /\.[jt]sx?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-typescript', '@babel/preset-react'],
plugins: ['istanbul'],
},
},
},
]
: [
{
test: /\.[jt]sx?$/,
exclude: /node_modules/,
loader: 'babel-loader',
options: {
plugins: isProdBuild ? [] : ['react-refresh/babel'],
},
},
]),
{
test: /\.[jt]sx?$/,
exclude: /node_modules/,
loader: 'babel-loader',
options: {
plugins: isProdBuild ? [] : ['react-refresh/babel'],
},
},
]),
{
test: /\.svg?$/,
@@ -186,10 +168,6 @@ module.exports = (env, argv, { SRC_DIR, ENTRY }) => {
},
],
},
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
], //.concat(vtkRules),
},
resolve: {
@@ -201,6 +179,8 @@ module.exports = (env, argv, { SRC_DIR, ENTRY }) => {
'@hooks': path.resolve(__dirname, '../platform/app/src/hooks'),
'@routes': path.resolve(__dirname, '../platform/app/src/routes'),
'@state': path.resolve(__dirname, '../platform/app/src/state'),
'dicom-microscopy-viewer':
'dicom-microscopy-viewer/dist/dynamic-import/dicomMicroscopyViewer.min.js',
},
// Which directories to search when resolving modules
modules: [
-92
View File
@@ -1,92 +0,0 @@
# Bug Fix: Duplicate Segmentation Entries When Switching Layouts
## Issue Description
**Linear Issue:** OHI-2076
**Title:** [Bug] Duplicate entries for same segmentation when switching between Common and advanced layout
When switching from a Common layout to an advanced layout (e.g., 3D Four-Up) in the OHIF Viewer, duplicate entries for the same segmentation appear in the Segmentation Panel.
## Root Cause
The issue was in the `useViewportSegmentations` hook located at:
```
extensions/cornerstone/src/hooks/useViewportSegmentations.ts
```
When a viewport contains multiple representations of the same segmentation (e.g., both a LABELMAP and SURFACE representation), the hook was creating separate entries for each representation, even though they belong to the same segmentation. This resulted in duplicate entries in the Segmentation Panel.
The problem occurred because:
1. When switching layouts, new viewports are created
2. The synchronizer may add the same segmentation with different representation types to these viewports
3. The `getSegmentationRepresentations(viewportId)` call returns ALL representations for that viewport
4. Each representation was mapped to a separate entry, creating duplicates
## Solution
Added deduplication logic in the `useViewportSegmentations` hook to ensure only one entry per unique `segmentationId` is shown in the panel, regardless of how many representation types exist.
### Code Changes
**File:** `extensions/cornerstone/src/hooks/useViewportSegmentations.ts`
**Before:**
```typescript
const representations = segmentationService.getSegmentationRepresentations(viewportId);
const newSegmentationsWithRepresentations = representations.map(representation => {
const segmentation = segmentationService.getSegmentation(representation.segmentationId);
const mappedSegmentation = mapSegmentationToDisplay(segmentation, customizationService);
return {
representation,
segmentation: mappedSegmentation,
};
});
```
**After:**
```typescript
const representations = segmentationService.getSegmentationRepresentations(viewportId);
// Deduplicate representations by segmentationId to prevent showing
// the same segmentation multiple times in the panel when it has
// multiple representation types (e.g., labelmap and surface)
const uniqueSegmentationMap = new Map();
representations.forEach(representation => {
if (!uniqueSegmentationMap.has(representation.segmentationId)) {
uniqueSegmentationMap.set(representation.segmentationId, representation);
}
});
const newSegmentationsWithRepresentations = Array.from(uniqueSegmentationMap.values()).map(
representation => {
const segmentation = segmentationService.getSegmentation(representation.segmentationId);
const mappedSegmentation = mapSegmentationToDisplay(segmentation, customizationService);
return {
representation,
segmentation: mappedSegmentation,
};
}
);
```
## How It Works
1. After retrieving all representations for a viewport, we create a `Map` keyed by `segmentationId`
2. We iterate through all representations and only add the first occurrence of each unique `segmentationId`
3. This ensures that even if a segmentation has multiple representation types (labelmap, surface, etc.), only one entry appears in the panel
4. The fix is applied at the data source level, ensuring consistency across all panel views
## Testing
To verify the fix:
1. Launch OHIF Viewer with segmentation data (e.g., https://viewer-dev.ohif.org/segmentation?StudyInstanceUIDs=1.3.6.1.4.1.14519.5.2.1.1706.8374.643249677828306008300337414785)
2. Drag and drop a segmentation into the Common layout viewport
3. Switch to an advanced layout (e.g., 3D Four-Up)
4. Verify that only one entry per segmentation appears in the Segmentation Panel (no duplicates)
## Impact
- **Scope:** Minimal - only affects the display logic in the Segmentation Panel
- **Breaking Changes:** None
- **Performance:** Negligible - adds a simple Map-based deduplication step
- **User Experience:** Significantly improved - eliminates confusion from duplicate entries
## Related Files
- `/workspace/extensions/cornerstone/src/hooks/useViewportSegmentations.ts` (modified)
- `/workspace/extensions/cornerstone/src/hooks/useActiveViewportSegmentationRepresentations.ts` (uses the fixed hook)
- `/workspace/extensions/cornerstone/src/panels/PanelSegmentation.tsx` (consumes the data)
+8 -3321
View File
File diff suppressed because it is too large. Load diff
+32 -57
View File
@@ -1,4 +1,3 @@
# syntax=docker/dockerfile:1.7-labs
# This dockerfile is used to publish the `ohif/app` image on dockerhub.
#
# It's a good example of how to build our static application and package it
@@ -20,85 +19,61 @@
#
# syntax=docker/dockerfile:1.7-labs
# This dockerfile is used to publish the `ohif/app` image on dockerhub.
#
# It's a good example of how to build our static application and package it
# with a web server capable of hosting it as static content.
#
# docker build
# --------------
# If you would like to use this dockerfile to build and tag an image, make sure
# you set the context to the project's root directory:
# https://docs.docker.com/engine/reference/commandline/build/
#
#
# SUMMARY
# --------------
# This dockerfile is used as an input for a second stage to make things run faster.
#
# Stage 1: Build the application
# docker build -t ohif/viewer:latest .
# Copy Files
FROM node:20.18.1-slim as builder
RUN apt-get update && apt-get install -y build-essential python3
FROM node:18.16.1-slim as json-copier
RUN mkdir /usr/src/app
WORKDIR /usr/src/app
RUN npm install -g bun
RUN npm install -g lerna@7.4.2
ENV PATH=/usr/src/app/node_modules/.bin:$PATH
# Do an initial install and then a final install
COPY package.json yarn.lock preinstall.js lerna.json ./
COPY --parents ./addOns/package.json ./addOns/*/*/package.json ./extensions/*/package.json ./modes/*/package.json ./platform/*/package.json ./
COPY ["package.json", "yarn.lock", "preinstall.js", "./"]
COPY extensions /usr/src/app/extensions
COPY modes /usr/src/app/modes
COPY platform /usr/src/app/platform
# Find and remove non-package.json files
#RUN find extensions \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs rm -rf
#RUN find modes \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs rm -rf
#RUN find platform \! -name "package.json" -mindepth 2 -maxdepth 2 -print | xargs rm -rf
# Copy Files
FROM node:18.16.1-slim as builder
RUN apt-get update && apt-get install -y build-essential python3
RUN mkdir /usr/src/app
WORKDIR /usr/src/app
COPY --from=json-copier /usr/src/app .
# Run the install before copying the rest of the files
RUN yarn config set workspaces-experimental true
RUN yarn install --frozen-lockfile --verbose
RUN bun pm cache rm
RUN bun install
# Copy the local directory
COPY --link --exclude=yarn.lock --exclude=package.json --exclude=Dockerfile . .
COPY . .
# Build here
# After install it should hopefully be stable until the local directory changes
# To restore workspaces symlinks
RUN yarn install --frozen-lockfile --verbose
ENV PATH /usr/src/app/node_modules/.bin:$PATH
ENV QUICK_BUILD true
# ENV GENERATE_SOURCEMAP=false
ARG APP_CONFIG=config/default.js
ARG PUBLIC_URL=/
ENV PUBLIC_URL=${PUBLIC_URL}
# ENV REACT_APP_CONFIG=config/default.js
RUN bun run show:config
RUN bun run build
# Precompress files
RUN chmod u+x .docker/compressDist.sh
RUN ./.docker/compressDist.sh
RUN yarn run build
# Stage 3: Bundle the built application into a Docker container
# which runs Nginx using Alpine Linux
FROM nginxinc/nginx-unprivileged:1.27-alpine as final
FROM nginxinc/nginx-unprivileged:1.25-alpine as final
#RUN apk add --no-cache bash
ARG PUBLIC_URL=/
ENV PUBLIC_URL=${PUBLIC_URL}
ARG PORT=80
ENV PORT=${PORT}
ENV PORT=80
RUN rm /etc/nginx/conf.d/default.conf
USER nginx
COPY --chown=nginx:nginx .docker/Viewer-v3.x /usr/src
RUN chmod 777 /usr/src/entrypoint.sh
COPY --from=builder /usr/src/app/platform/app/dist /usr/share/nginx/html${PUBLIC_URL}
# Copy paths that are renamed/redirected generally
# Microscopy libraries depend on root level include, so must be copied
COPY --from=builder /usr/src/app/platform/app/dist/dicom-microscopy-viewer /usr/share/nginx/html/dicom-microscopy-viewer
COPY --from=builder /usr/src/app/platform/app/dist /usr/share/nginx/html
# In entrypoint.sh, app-config.js might be overwritten, so chmod it to be writeable.
# The nginx user cannot chmod it, so change to root.
USER root
RUN chown -R nginx:nginx /usr/share/nginx/html && chmod -R 666 /usr/share/nginx/html
RUN chmod 666 /usr/share/nginx/html/app-config.js
USER nginx
ENTRYPOINT ["/usr/src/entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]
+11 -13
View File
@@ -1,4 +1,5 @@
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<div align="center">
<h1>OHIF Medical Imaging Viewer</h1>
<p><strong>The OHIF Viewer</strong> is a zero-footprint medical image viewer
@@ -76,7 +77,7 @@ contributions of individuals, research groups, and commercial organizations.
After more than 8-years of integrating with many companies and organizations,
The OHIF Viewer has been rebuilt from the ground up to better address the
varying workflow and configuration needs of its many users. All of the Viewer's
core features are built using its own extension system. The same extensibility
core features are built using it's own extension system. The same extensibility
that allows us to offer:
- 2D and 3D medical image viewing
@@ -134,20 +135,18 @@ Here is a schematic representation of our development workflow:
### Requirements
- [Yarn 1.20.0+](https://yarnpkg.com/en/docs/install)
- [Yarn 1.17.3+](https://yarnpkg.com/en/docs/install)
- [Node 18+](https://nodejs.org/en/)
- Yarn Workspaces should be enabled on your machine:
- `yarn config set workspaces-experimental true`
### Getting Started
1. [Fork this repository][how-to-fork]
2. [Clone your forked repository][how-to-clone]
- `git clone https://github.com/YOUR-USERNAME/Viewers.git`
3. Navigate to the cloned project's directory
4. Add this repo as a `remote` named `upstream`
- `git remote add upstream https://github.com/OHIF/Viewers.git`
5. `yarn install` to restore dependencies and link projects
1. Clone the repository
- `git clone https://github.com/OHIF/Viewers.git`
2. Navigate to the cloned project's directory
3. `yarn install` to restore dependencies and link projects
4. `yarn dev` to start the development server
#### To Develop
@@ -167,16 +166,15 @@ These commands are available from the root directory. Each project directory
also supports a number of commands that can be found in their respective
`README.md` and `package.json` files.
| Yarn Commands | Description |
| Commands | Description |
| ---------------------------- | ------------------------------------------------------------- |
| **Develop** | |
| `dev` | Default development experience for Viewer |
| `dev:fast` | Our experimental fast dev mode that uses rsbuild instead of webpack |
| `dev` or `start` | Default development experience for Viewer |
| `test:unit` | Jest multi-project test runner; overall coverage |
| **Deploy** | |
| `build`\* | Builds production output for our PWA Viewer | |
\* - For more information on different builds, check out our [Deploy
\* - For more information on our different builds, check out our [Deploy
Docs][deployment-docs]
## Project
File diff suppressed because it is too large. Load diff
+17 -18
View File
@@ -1,7 +1,7 @@
{
"name": "@externals/devDependencies",
"description": "External dev dependencies - put dev build dependencies here",
"version": "3.12.0-beta.43",
"version": "3.9.2",
"license": "MIT",
"private": true,
"engines": {
@@ -10,27 +10,23 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@kitware/vtk.js": "32.12.0",
"@kitware/vtk.js": "32.1.1",
"clsx": "^2.1.1",
"core-js": "3.45.1",
"moment": "^2.30.1"
"core-js": "^3.2.1",
"moment": "^2.9.4"
},
"peerDependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@babel/eslint-parser": "7.28.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
"@rsbuild/core": "^1.5.1",
"@rsbuild/plugin-node-polyfill": "^1.4.2",
"@rsbuild/plugin-react": "^1.4.0",
"@svgr/webpack": "^8.1.0",
"@swc/helpers": "^0.5.15",
"@types/jest": "^27.5.0",
"@typescript-eslint/eslint-plugin": "^6.3.0",
"@typescript-eslint/parser": "^6.3.0",
"autoprefixer": "^10.4.4",
"babel-eslint": "9.x",
"babel-loader": "^8.2.4",
"babel-plugin-module-resolver": "^5.0.0",
"clean-webpack-plugin": "^3.0.0",
@@ -39,14 +35,14 @@
"css-loader": "^6.8.1",
"dotenv": "^8.1.0",
"eslint": "^8.39.0",
"eslint-config-prettier": "7.2.0",
"eslint-config-prettier": "^7.2.0",
"eslint-config-react-app": "^6.0.0",
"eslint-plugin-cypress": "^2.12.1",
"eslint-plugin-flowtype": "^7.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "5.5.1",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-promise": "^5.2.0",
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^4.4.0",
@@ -62,28 +58,31 @@
"jest-junit": "^6.4.0",
"lerna": "^7.2.0",
"lint-staged": "^9.0.2",
"mini-css-extract-plugin": "2.9.2",
"mini-css-extract-plugin": "^2.1.0",
"optimize-css-assets-webpack-plugin": "^6.0.1",
"postcss": "^8.3.5",
"postcss-import": "^14.0.2",
"postcss-loader": "^6.1.1",
"postcss-preset-env": "^7.4.3",
"prettier": "3.6.2",
"prettier-plugin-tailwindcss": "0.6.9",
"prettier": "^3.0.3",
"prettier-plugin-tailwindcss": "^0.5.4",
"react-refresh": "^0.14.2",
"semver": "^7.5.1",
"serve": "^14.2.4",
"serve": "^14.2.0",
"shader-loader": "^1.3.1",
"shx": "^0.3.3",
"source-map-loader": "^4.0.1",
"start-server-and-test": "^1.10.0",
"style-loader": "^1.0.0",
"stylus": "^0.59.0",
"stylus-loader": "^7.1.3",
"terser-webpack-plugin": "^5.1.4",
"typescript": "5.5.4",
"unused-webpack-plugin": "2.4.0",
"webpack": "5.95.0",
"webpack": "5.94.0",
"webpack-bundle-analyzer": "^4.8.0",
"webpack-cli": "^5.0.1",
"webpack-dev-server": "5.2.2",
"webpack-cli": "^4.7.2",
"webpack-dev-server": "4.7.3",
"webpack-hot-middleware": "^2.25.0",
"webpack-merge": "^5.7.3",
"workbox-webpack-plugin": "^6.1.5",
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -1,9 +1,9 @@
{
"name": "@externals/dicom-microscopy-viewer",
"description": "External reference to dicom-microscopy-viewer",
"version": "3.12.0-beta.43",
"version": "3.9.2",
"license": "MIT",
"dependencies": {
"dicom-microscopy-viewer": "^0.48.6"
"dicom-microscopy-viewer": "^0.46.1"
}
}
+13 -12
View File
@@ -21,30 +21,31 @@
"preinstall": "cd .. && node preinstall.js"
},
"devDependencies": {
"@babel/core": "7.28.0",
"@babel/plugin-transform-class-properties": "^7.27.1",
"@babel/plugin-transform-object-rest-spread": "^7.28.0",
"@babel/plugin-transform-private-methods": "^7.27.1",
"@babel/plugin-transform-private-property-in-object": "^7.27.1",
"@babel/core": "7.24.7",
"@babel/plugin-proposal-class-properties": "^7.16.7",
"@babel/plugin-proposal-object-rest-spread": "^7.17.3",
"@babel/plugin-proposal-private-methods": "^7.18.6",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-arrow-functions": "^7.16.7",
"@babel/plugin-transform-regenerator": "^7.16.7",
"@babel/plugin-transform-runtime": "7.28.0",
"@babel/plugin-transform-typescript": "^7.28.0",
"@babel/preset-env": "7.28.0",
"@babel/preset-react": "^7.27.1",
"@babel/preset-typescript": "^7.27.1"
"@babel/plugin-transform-runtime": "7.24.7",
"@babel/plugin-transform-typescript": "^7.13.0",
"@babel/preset-env": "7.24.7",
"@babel/preset-react": "^7.16.7",
"@babel/preset-typescript": "^7.13.0"
},
"resolutions": {
"**/@babel/runtime": "^7.20.13",
"commander": "8.3.0",
"dcmjs": "0.43.1",
"dcmjs": ">=0.33.0",
"dicomweb-client": ">=0.10.4",
"nth-check": "^2.1.1",
"trim-newlines": "^5.0.0",
"glob-parent": "^6.0.2",
"trim": "^1.0.0",
"package-json": "^8.1.0",
"typescript": "5.5.4"
"typescript": "5.5.4",
"sharp": "^0.32.6"
}
}
+7 -9
View File
@@ -1,12 +1,14 @@
// https://babeljs.io/docs/en/options#babelrcroots
const { extendDefaultPlugins } = require('svgo');
module.exports = {
babelrcRoots: ['./platform/*', './extensions/*', './modes/*'],
presets: ['@babel/preset-env', '@babel/preset-react', '@babel/preset-typescript'],
plugins: [
['@babel/plugin-transform-class-properties', { loose: true }],
['@babel/plugin-proposal-class-properties', { loose: true }],
'@babel/plugin-transform-typescript',
['@babel/plugin-transform-private-property-in-object', { loose: true }],
['@babel/plugin-transform-private-methods', { loose: true }],
['@babel/plugin-proposal-private-property-in-object', { loose: true }],
['@babel/plugin-proposal-private-methods', { loose: true }],
'@babel/plugin-transform-class-static-block',
],
env: {
@@ -18,24 +20,19 @@ module.exports = {
{
modules: 'commonjs',
debug: false,
targets: { node: 'current' },
bugfixes: true,
},
],
'@babel/preset-react',
'@babel/preset-typescript',
],
plugins: [
'babel-plugin-istanbul',
'@babel/plugin-transform-object-rest-spread',
'@babel/plugin-proposal-object-rest-spread',
'@babel/plugin-syntax-dynamic-import',
'@babel/plugin-transform-regenerator',
'@babel/transform-destructuring',
'@babel/plugin-transform-runtime',
'@babel/plugin-transform-typescript',
'@babel/plugin-transform-class-static-block',
'@babel/plugin-transform-for-of',
['babel-plugin-transform-import-meta', { module: 'ES6' }],
],
},
production: {
@@ -54,6 +51,7 @@ module.exports = {
'@babel/preset-react',
'@babel/preset-typescript',
],
plugins: ['react-refresh/babel'],
ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'],
},
},
-7335
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1 +1 @@
851e74d7b867a806befb5d85fd71ff9a75e9f2d2
e5286d9fc2449da50b738e7e6208d489cce1633e
-118
View File
@@ -1,118 +0,0 @@
diff --git a/extensions/cornerstone/src/components/ViewportOrientationMenu/ViewportOrientationMenu.tsx b/extensions/cornerstone/src/components/ViewportOrientationMenu/ViewportOrientationMenu.tsx
index 6c1e4f8f6..56dac15b5 100644
--- a/extensions/cornerstone/src/components/ViewportOrientationMenu/ViewportOrientationMenu.tsx
+++ b/extensions/cornerstone/src/components/ViewportOrientationMenu/ViewportOrientationMenu.tsx
@@ -156,65 +156,65 @@ function ViewportOrientationMenu({
>
<Button
variant="ghost"
- className="flex h-[28px] flex-shrink-0 items-center justify-start self-stretch"
+ className="flex h-7 w-full flex-shrink-0 items-center justify-start self-stretch px-1 py-0"
onClick={() => handleOrientationChange('axial')}
>
- <span className="mr-0 flex w-4 items-center justify-center">
+ <div className="mr-1 flex w-6 items-center justify-start">
{currentOrientation === 'axial' ? (
- <Icons.Checked className="text-primary h-5 w-5" />
+ <Icons.Checked className="text-primary h-6 w-6" />
) : null}
- </span>
- <span className="flex-1 text-left">Axial</span>
+ </div>
+ <div className="flex-1 text-left">Axial</div>
</Button>
<Button
variant="ghost"
- className="flex h-[28px] flex-shrink-0 items-center justify-start self-stretch"
+ className="flex h-7 w-full flex-shrink-0 items-center justify-start self-stretch px-1 py-0"
onClick={() => handleOrientationChange('sagittal')}
>
- <span className="mr-0 flex w-4 items-center justify-center">
+ <div className="mr-1 flex w-6 items-center justify-start">
{currentOrientation === 'sagittal' ? (
- <Icons.Checked className="text-primary h-5 w-5" />
+ <Icons.Checked className="text-primary h-6 w-6" />
) : null}
- </span>
- <span className="flex-1 text-left">Sagittal</span>
+ </div>
+ <div className="flex-1 text-left">Sagittal</div>
</Button>
<Button
variant="ghost"
- className="flex h-[28px] flex-shrink-0 items-center justify-start self-stretch"
+ className="flex h-7 w-full flex-shrink-0 items-center justify-start self-stretch px-1 py-0"
onClick={() => handleOrientationChange('coronal')}
>
- <span className="mr-0 flex w-4 items-center justify-center">
+ <div className="mr-1 flex w-6 items-center justify-start">
{currentOrientation === 'coronal' ? (
- <Icons.Checked className="text-primary h-5 w-5" />
+ <Icons.Checked className="text-primary h-6 w-6" />
) : null}
- </span>
- <span className="flex-1 text-left">Coronal</span>
+ </div>
+ <div className="flex-1 text-left">Coronal</div>
</Button>
<Button
variant="ghost"
- className="flex h-[28px] flex-shrink-0 items-center justify-start self-stretch"
+ className="flex h-7 w-full flex-shrink-0 items-center justify-start self-stretch px-1 py-0"
onClick={() => handleOrientationChange('acquisition')}
>
- <span className="mr-0 flex w-4 items-center justify-center">
+ <div className="mr-1 flex w-6 items-center justify-start">
{currentOrientation === 'acquisition' ? (
- <Icons.Checked className="text-primary h-5 w-5" />
+ <Icons.Checked className="text-primary h-6 w-6" />
) : null}
- </span>
- <span className="flex-1 text-left">Acquisition</span>
+ </div>
+ <div className="flex-1 text-left">Acquisition</div>
</Button>
{/* Divider */}
- <div className="my-2 border-t border-white/20" />
+ <div className="mx-1 my-2 border-t border-white/20" />
<Button
variant="ghost"
- className="flex h-[28px] flex-shrink-0 items-center justify-start self-stretch"
+ className="flex h-7 w-full flex-shrink-0 items-center justify-start self-stretch px-1 py-0"
onClick={() => handleOrientationChange('reformat')}
>
- <span className="mr-0 flex w-4 items-center justify-center">
+ <div className="mr-1 flex w-6 items-center justify-start">
{currentOrientation === 'reformat' ? (
- <Icons.Checked className="text-primary h-5 w-5" />
+ <Icons.Checked className="text-primary h-6 w-6" />
) : null}
- </span>
- <span className="flex-1 text-left">Reformat</span>
+ </div>
+ <div className="flex-1 text-left">Reformat</div>
</Button>
</PopoverContent>
</Popover>
diff --git a/platform/ui-next/src/components/Icons/Sources/Checked.tsx b/platform/ui-next/src/components/Icons/Sources/Checked.tsx
index 83c8fc57e..c88b75e0d 100644
--- a/platform/ui-next/src/components/Icons/Sources/Checked.tsx
+++ b/platform/ui-next/src/components/Icons/Sources/Checked.tsx
@@ -4,14 +4,14 @@ import type { IconProps } from '../types';
export const Checked = (props: IconProps) => (
<svg
{...props}
- width="24"
+ width="20"
height="24"
- viewBox="0 0 24 24"
+ viewBox="0 0 20 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
- d="M16 9L11.3333 15L8 12.4737"
+ d="M14 9L9.3333 15L6 12.4737"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,5 @@
module.exports = {
plugins: ['@babel/plugin-transform-class-properties'],
plugins: ['@babel/plugin-proposal-class-properties'],
env: {
test: {
presets: [
@@ -15,7 +15,7 @@ module.exports = {
'@babel/preset-react',
],
plugins: [
'@babel/plugin-transform-object-rest-spread',
'@babel/plugin-proposal-object-rest-spread',
'@babel/plugin-syntax-dynamic-import',
'@babel/plugin-transform-regenerator',
'@babel/plugin-transform-runtime',
@@ -1,6 +1,6 @@
{
"name": "@ohif/extension-cornerstone-dicom-pmap",
"version": "3.12.0-beta.43",
"version": "3.9.2",
"description": "DICOM Parametric Map read workflow",
"author": "OHIF",
"license": "MIT",
@@ -33,10 +33,10 @@
"start": "yarn run dev"
},
"peerDependencies": {
"@ohif/core": "3.12.0-beta.43",
"@ohif/extension-cornerstone": "3.12.0-beta.43",
"@ohif/extension-default": "3.12.0-beta.43",
"@ohif/i18n": "3.12.0-beta.43",
"@ohif/core": "3.9.2",
"@ohif/extension-cornerstone": "3.9.2",
"@ohif/extension-default": "3.9.2",
"@ohif/i18n": "3.9.2",
"prop-types": "^15.6.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -46,9 +46,9 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "4.3.14",
"@cornerstonejs/core": "4.3.14",
"@kitware/vtk.js": "32.12.0",
"@cornerstonejs/adapters": "^2.2.20",
"@cornerstonejs/core": "^2.2.20",
"@kitware/vtk.js": "32.1.1",
"react-color": "^2.19.3"
}
}
@@ -1,9 +1,10 @@
import { utils, Types as OhifTypes } from '@ohif/core';
import i18n from '@ohif/i18n';
import { utils } from '@ohif/core';
import { metaData, cache, utilities as csUtils, volumeLoader } from '@cornerstonejs/core';
import { adaptersPMAP } from '@cornerstonejs/adapters';
import { SOPClassHandlerId } from './id';
import { dicomLoaderService, VOLUME_LOADER_SCHEME } from '@ohif/extension-cornerstone';
import { dicomLoaderService } from '@ohif/extension-cornerstone';
const VOLUME_LOADER_SCHEME = 'cornerstoneStreamingImageVolume';
const sopClassUids = ['1.2.840.10008.5.1.4.1.1.30'];
function _getDisplaySetsFromSeries(
@@ -56,8 +57,7 @@ function _getDisplaySetsFromSeries(
wadoRoot,
wadoUriRoot,
wadoUri,
supportsWindowLevel: true,
label: SeriesDescription || `${i18n.t('Series')} ${SeriesNumber} - ${i18n.t('PMAP')}`,
isOverlayDisplaySet: true,
};
const referencedSeriesSequence = instance.ReferencedSeriesSequence;
@@ -227,8 +227,7 @@ async function _loadParametricMap({ displaySet, headers }: withAppTypes) {
return derivedVolume;
}
function getSopClassHandlerModule(params: OhifTypes.Extensions.ExtensionParams) {
const { servicesManager, extensionManager } = params;
function getSopClassHandlerModule({ servicesManager, extensionManager }) {
const getDisplaySetsFromSeries = instances => {
return _getDisplaySetsFromSeries(instances, servicesManager, extensionManager);
};
@@ -1,12 +1,18 @@
import PropTypes from 'prop-types';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useViewportGrid } from '@ohif/ui-next';
import { OHIFCornerstoneViewport } from '@ohif/extension-cornerstone';
import { useViewportGrid, LoadingIndicatorTotalPercent } from '@ohif/ui';
function OHIFCornerstonePMAPViewport(props: withAppTypes) {
const { displaySets, children, viewportOptions, displaySetOptions, servicesManager } = props;
const {
displaySets,
children,
viewportOptions,
displaySetOptions,
servicesManager,
extensionManager,
} = props;
const viewportId = viewportOptions.viewportId;
const { displaySetService, segmentationService, uiNotificationService, customizationService } =
const { displaySetService, segmentationService, uiNotificationService } =
servicesManager.services;
// PMAP viewport will always have a single display set
@@ -14,10 +20,6 @@ function OHIFCornerstonePMAPViewport(props: withAppTypes) {
throw new Error('PMAP viewport must have a single display set');
}
const LoadingIndicatorTotalPercent = customizationService.getCustomization(
'ui.loadingIndicatorTotalPercent'
);
const pmapDisplaySet = displaySets[0];
const [viewportGrid, viewportGridService] = useViewportGrid();
const referencedDisplaySetRef = useRef(null);
@@ -53,6 +55,9 @@ function OHIFCornerstonePMAPViewport(props: withAppTypes) {
const getCornerstoneViewport = useCallback(() => {
const { displaySet: referencedDisplaySet } = referencedDisplaySetRef.current;
const { component: Component } = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.viewportModule.cornerstone'
);
displaySetOptions.unshift({});
const [pmapDisplaySetOptions] = displaySetOptions;
@@ -84,7 +89,7 @@ function OHIFCornerstonePMAPViewport(props: withAppTypes) {
});
return (
<OHIFCornerstoneViewport
<Component
{...props}
// Referenced + PMAP displaySets must be passed as parameter in this order
displaySets={[referencedDisplaySet, pmapDisplaySet]}
@@ -92,19 +97,17 @@ function OHIFCornerstonePMAPViewport(props: withAppTypes) {
viewportType: 'volume',
orientation: viewportOptions.orientation,
viewportId: viewportOptions.viewportId,
presentationIds: viewportOptions.presentationIds,
}}
displaySetOptions={[{}, pmapDisplaySetOptions]}
/>
></Component>
);
}, [
extensionManager,
displaySetOptions,
props,
pmapDisplaySet,
viewportOptions.orientation,
viewportOptions.viewportId,
viewportOptions.presentationIds,
uiNotificationService,
]);
// Cleanup the PMAP viewport when the viewport is destroyed
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,5 @@
module.exports = {
plugins: ['@babel/plugin-transform-class-properties'],
plugins: ['@babel/plugin-proposal-class-properties'],
env: {
test: {
presets: [
@@ -15,7 +15,7 @@ module.exports = {
'@babel/preset-react',
],
plugins: [
'@babel/plugin-transform-object-rest-spread',
'@babel/plugin-proposal-object-rest-spread',
'@babel/plugin-syntax-dynamic-import',
'@babel/plugin-transform-regenerator',
'@babel/plugin-transform-runtime',
@@ -37,6 +37,7 @@ module.exports = {
'@babel/preset-react',
'@babel/preset-typescript',
],
plugins: ['react-refresh/babel'],
ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'],
},
},
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@ohif/extension-cornerstone-dicom-rt",
"version": "3.12.0-beta.43",
"version": "3.9.2",
"description": "DICOM RT read workflow",
"author": "OHIF",
"license": "MIT",
@@ -33,10 +33,10 @@
"start": "yarn run dev"
},
"peerDependencies": {
"@ohif/core": "3.12.0-beta.43",
"@ohif/extension-cornerstone": "3.12.0-beta.43",
"@ohif/extension-default": "3.12.0-beta.43",
"@ohif/i18n": "3.12.0-beta.43",
"@ohif/core": "3.9.2",
"@ohif/extension-cornerstone": "3.9.2",
"@ohif/extension-default": "3.9.2",
"@ohif/i18n": "3.9.2",
"prop-types": "^15.6.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -27,7 +27,7 @@ const commandsModule = ({ commandsManager, servicesManager }: withAppTypes) => {
// it will be in the correct position zoom and pan
commandsManager.runCommand('updateStoredPositionPresentation', {
viewportId,
displaySetInstanceUIDs: [referencedDisplaySet.displaySetInstanceUID],
displaySetInstanceUID: referencedDisplaySet.displaySetInstanceUID,
});
viewportGridService.setDisplaySetsForViewport({
@@ -1,14 +1,9 @@
import { utils, Types as OhifTypes } from '@ohif/core';
import i18n from '@ohif/i18n';
import { utils } from '@ohif/core';
import { SOPClassHandlerId } from './id';
import loadRTStruct from './loadRTStruct';
const { sopClassDictionary } = utils;
const sopClassUids = [sopClassDictionary.RTStructureSetStorage];
const cachedRTStructsSEG = new Set<string>();
const sopClassUids = ['1.2.840.10008.5.1.4.1.1.481.3'];
const loadPromises = {};
@@ -35,7 +30,7 @@ function _getDisplaySetsFromSeries(
const displaySet = {
Modality: 'RTSTRUCT',
loading: false,
isReconstructable: false,
isReconstructable: false, // by default for now since it is a volumetric SEG currently
displaySetInstanceUID: utils.guid(),
SeriesDescription,
SeriesNumber,
@@ -58,7 +53,6 @@ function _getDisplaySetsFromSeries(
wadoUriRoot,
wadoUri,
isOverlayDisplaySet: true,
label: SeriesDescription || `${i18n.t('Series')} ${SeriesNumber} - ${i18n.t('RTSTRUCT')}`,
};
let referencedSeriesSequence = instance.ReferencedSeriesSequence;
@@ -91,7 +85,6 @@ function _getDisplaySetsFromSeries(
const addedDisplaySet = displaySetsAdded[0];
if (addedDisplaySet.SeriesInstanceUID === displaySet.referencedSeriesInstanceUID) {
displaySet.referencedDisplaySetInstanceUID = addedDisplaySet.displaySetInstanceUID;
displaySet.isReconstructable = addedDisplaySet.isReconstructable;
unsubscribe();
}
}
@@ -99,64 +92,45 @@ function _getDisplaySetsFromSeries(
} else {
const referencedDisplaySet = referencedDisplaySets[0];
displaySet.referencedDisplaySetInstanceUID = referencedDisplaySet.displaySetInstanceUID;
displaySet.isReconstructable = referencedDisplaySet.isReconstructable;
}
displaySet.load = ({ headers, createSegmentation = true }) =>
_load(displaySet, servicesManager, extensionManager, headers, createSegmentation);
displaySet.load = ({ headers }) => _load(displaySet, servicesManager, extensionManager, headers);
return [displaySet];
}
function _load(
rtDisplaySet,
servicesManager: AppTypes.ServicesManager,
extensionManager,
headers,
createSegmentation = true
) {
function _load(rtDisplaySet, servicesManager: AppTypes.ServicesManager, extensionManager, headers) {
const { SOPInstanceUID } = rtDisplaySet;
const { segmentationService } = servicesManager.services;
if (
(rtDisplaySet.loading || rtDisplaySet.isLoaded) &&
loadPromises[SOPInstanceUID] &&
cachedRTStructsSEG.has(rtDisplaySet.displaySetInstanceUID)
_segmentationExistsInCache(rtDisplaySet, segmentationService)
) {
return loadPromises[SOPInstanceUID];
}
rtDisplaySet.loading = true;
const { unsubscribe } = segmentationService.subscribe(
segmentationService.EVENTS.SEGMENTATION_LOADING_COMPLETE,
(evt: { rtDisplaySet: { displaySetInstanceUID: string } }) => {
if (evt.rtDisplaySet?.displaySetInstanceUID === rtDisplaySet.displaySetInstanceUID) {
cachedRTStructsSEG.add(rtDisplaySet.displaySetInstanceUID);
unsubscribe();
}
}
);
// We don't want to fire multiple loads, so we'll wait for the first to finish
// and also return the same promise to any other callers.
loadPromises[SOPInstanceUID] = new Promise<void>(async (resolve, reject) => {
try {
if (!rtDisplaySet.structureSet) {
const structureSet = await loadRTStruct(extensionManager, rtDisplaySet, headers);
rtDisplaySet.structureSet = structureSet;
}
loadPromises[SOPInstanceUID] = new Promise(async (resolve, reject) => {
if (!rtDisplaySet.structureSet) {
const structureSet = await loadRTStruct(extensionManager, rtDisplaySet, headers);
if (createSegmentation) {
await segmentationService.createSegmentationForRTDisplaySet(rtDisplaySet);
}
resolve();
} catch (error) {
reject(error);
} finally {
rtDisplaySet.loading = false;
rtDisplaySet.structureSet = structureSet;
}
segmentationService
.createSegmentationForRTDisplaySet(rtDisplaySet)
.then(() => {
rtDisplaySet.loading = false;
resolve();
})
.catch(error => {
rtDisplaySet.loading = false;
reject(error);
});
});
return loadPromises[SOPInstanceUID];
@@ -197,9 +171,20 @@ function _deriveReferencedSeriesSequenceFromFrameOfReferenceSequence(
return ReferencedSeriesSequence;
}
function getSopClassHandlerModule(params: OhifTypes.Extensions.ExtensionParams) {
const { servicesManager, extensionManager } = params;
function _segmentationExistsInCache(
rtDisplaySet,
segmentationService: AppTypes.SegmentationService
) {
// Todo: fix this
return false;
// This should be abstracted with the CornerstoneCacheService
const rtContourId = rtDisplaySet.displaySetInstanceUID;
const contour = segmentationService.getContour(rtContourId);
return contour !== undefined;
}
function getSopClassHandlerModule({ servicesManager, extensionManager }) {
return [
{
name: 'dicom-rt',
@@ -2,34 +2,9 @@ import dcmjs from 'dcmjs';
const { DicomMessage, DicomMetaDictionary } = dcmjs.data;
const dicomlab2RGB = dcmjs.data.Colors.dicomlab2RGB;
/**
* Checks and loads contour data for RT Structure Set, handling both inline and bulk data URIs.
* Processes ROIContourSequence to extract contour data and resolve any bulk data references.
*
* @async
* @function checkAndLoadContourData
* @param {Object} params - Parameters object
* @param {Object} params.instance - Initial RT Structure instance
* @param {Object} params.dataSource - Data source for retrieving bulk data
* @param {Object} params.extensionManager - OHIF extension manager
* @param {Object} params.rtStructDisplaySet - RT Structure display set
* @param {Object} params.headers - HTTP headers for requests
* @returns {Promise<Object>} Promise that resolves to the processed RT Structure instance with loaded contour data
* @throws {Promise<string>} Rejects with error message if instance is invalid or data retrieval fails
*/
async function checkAndLoadContourData({
instance: initialInstance,
dataSource,
extensionManager,
rtStructDisplaySet,
headers,
}) {
let instance = initialInstance;
async function checkAndLoadContourData(instance, datasource) {
if (!instance || !instance.ROIContourSequence) {
instance = await getRTStructInstance({ extensionManager, rtStructDisplaySet, headers });
if (!instance || !instance.ROIContourSequence) {
return Promise.reject('Invalid instance object or ROIContourSequence');
}
return Promise.reject('Invalid instance object or ROIContourSequence');
}
const promisesMap = new Map();
@@ -55,11 +30,11 @@ async function checkAndLoadContourData({
} else if (contourData && contourData.BulkDataURI) {
const bulkDataURI = contourData.BulkDataURI;
if (!dataSource || !dataSource.retrieve || !dataSource.retrieve.bulkDataURI) {
return Promise.reject('Invalid dataSource object or retrieve function');
if (!datasource || !datasource.retrieve || !datasource.retrieve.bulkDataURI) {
return Promise.reject('Invalid datasource object or retrieve function');
}
const bulkDataPromise = dataSource.retrieve.bulkDataURI({
const bulkDataPromise = datasource.retrieve.bulkDataURI({
BulkDataURI: bulkDataURI,
StudyInstanceUID: instance.StudyInstanceUID,
SeriesInstanceUID: instance.SeriesInstanceUID,
@@ -112,74 +87,35 @@ async function checkAndLoadContourData({
console.error(error);
}
});
return instance;
}
/**
* Retrieves and parses RT Structure Set instance from DICOM data.
* Uses the cornerstone utility module to load DICOM data and converts it to a naturalized dataset.
*
* @async
* @function getRTStructInstance
* @param {Object} params - Parameters object
* @param {Object} params.extensionManager - OHIF extension manager
* @param {Object} params.rtStructDisplaySet - RT Structure display set
* @param {Object} params.headers - HTTP headers for requests
* @returns {Promise<Object>} Promise that resolves to the parsed RT Structure dataset
*/
const getRTStructInstance = async ({ extensionManager, rtStructDisplaySet, headers }) => {
export default async function loadRTStruct(extensionManager, rtStructDisplaySet, headers) {
const utilityModule = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.utilityModule.common'
);
const { dicomLoaderService } = utilityModule.exports;
const segArrayBuffer = await dicomLoaderService.findDicomDataPromise(
rtStructDisplaySet,
null,
headers
);
const dicomData = DicomMessage.readFile(segArrayBuffer);
const rtStructDataset = DicomMetaDictionary.naturalizeDataset(dicomData.dict);
rtStructDataset._meta = DicomMetaDictionary.namifyDataset(dicomData.meta);
return rtStructDataset;
};
/**
* Main function to load and process RT Structure Set data.
* Creates a structure set object with ROI contours, metadata, and visualization properties.
* Handles both bulk data URI and inline contour data scenarios.
*
* @async
* @function loadRTStruct
* @param {Object} extensionManager - OHIF extension manager
* @param {Object} rtStructDisplaySet - RT Structure display set to process
* @param {Object} headers - HTTP headers for data requests
* @returns {Promise<Object>} Promise that resolves to a structure set object containing:
* - StructureSetLabel: Label of the structure set
* - SeriesInstanceUID: Series instance UID
* - ROIContours: Array of ROI contour data with points and metadata
* - visible: Visibility state
* - ReferencedSOPInstanceUIDsSet: Set of referenced SOP instance UIDs
*/
export default async function loadRTStruct(extensionManager, rtStructDisplaySet, headers) {
const dataSource = extensionManager.getActiveDataSource()[0];
const { bulkDataURI } = dataSource.getConfig?.() || {};
const { dicomLoaderService } = utilityModule.exports;
// Set here is loading is asynchronous.
// If this function throws its set back to false.
rtStructDisplaySet.isLoaded = true;
let instance = rtStructDisplaySet.instance;
if (!bulkDataURI || !bulkDataURI.enabled) {
instance = await getRTStructInstance({ extensionManager, rtStructDisplaySet, headers });
} else {
instance = await checkAndLoadContourData({
instance,
dataSource,
extensionManager,
const segArrayBuffer = await dicomLoaderService.findDicomDataPromise(
rtStructDisplaySet,
headers,
});
null,
headers
);
const dicomData = DicomMessage.readFile(segArrayBuffer);
const rtStructDataset = DicomMetaDictionary.naturalizeDataset(dicomData.dict);
rtStructDataset._meta = DicomMetaDictionary.namifyDataset(dicomData.meta);
instance = rtStructDataset;
} else {
await checkAndLoadContourData(instance, dataSource);
}
const { StructureSetROISequence, ROIContourSequence, RTROIObservationsSequence } = instance;
@@ -190,22 +126,25 @@ export default async function loadRTStruct(extensionManager, rtStructDisplaySet,
SeriesInstanceUID: instance.SeriesInstanceUID,
ROIContours: [],
visible: true,
ReferencedSOPInstanceUIDsSet: new Set(),
};
for (let i = 0; i < ROIContourSequence.length; i++) {
const ROIContour = ROIContourSequence[i];
const { ContourSequence } = ROIContour;
if (!ContourSequence) {
continue;
}
const isSupported = false;
const ContourSequenceArray = _toArray(ContourSequence);
const contourPoints = [];
for (const ContourSequenceItem of ContourSequenceArray) {
const { ContourData, NumberOfContourPoints, ContourGeometricType, ContourImageSequence } =
ContourSequenceItem;
for (let c = 0; c < ContourSequenceArray.length; c++) {
const { ContourData, NumberOfContourPoints, ContourGeometricType } = ContourSequenceArray[c];
let isSupported = false;
const points = [];
for (let p = 0; p < NumberOfContourPoints * 3; p += 3) {
@@ -216,25 +155,23 @@ export default async function loadRTStruct(extensionManager, rtStructDisplaySet,
});
}
const supportedContourTypesMap = new Map([
['CLOSED_PLANAR', false],
['OPEN_NONPLANAR', false],
['OPEN_PLANAR', false],
['POINT', true],
]);
switch (ContourGeometricType) {
case 'CLOSED_PLANAR':
case 'OPEN_PLANAR':
case 'POINT':
isSupported = true;
break;
default:
continue;
}
contourPoints.push({
numberOfPoints: NumberOfContourPoints,
points,
type: ContourGeometricType,
isSupported: supportedContourTypesMap.get(ContourGeometricType) ?? false,
isSupported,
});
if (ContourImageSequence?.ReferencedSOPInstanceUID) {
structureSet.ReferencedSOPInstanceUIDsSet.add(
ContourImageSequence?.ReferencedSOPInstanceUID
);
}
}
_setROIContourMetadata(
@@ -242,30 +179,20 @@ export default async function loadRTStruct(extensionManager, rtStructDisplaySet,
StructureSetROISequence,
RTROIObservationsSequence,
ROIContour,
contourPoints
contourPoints,
isSupported
);
}
return structureSet;
}
/**
* Sets metadata for ROI contour data and adds it to the structure set.
* Extracts ROI information from StructureSetROISequence and RTROIObservationsSequence,
* then creates a complete ROI contour data object with visualization properties.
*
* @function _setROIContourMetadata
* @param {Object} structureSet - The structure set object to add ROI contour to
* @param {Array} StructureSetROISequence - Array of structure set ROI definitions
* @param {Array} RTROIObservationsSequence - Array of RT ROI observations
* @param {Object} ROIContour - ROI contour object containing contour data
* @param {Array} contourPoints - Array of processed contour points
*/
function _setROIContourMetadata(
structureSet,
StructureSetROISequence,
RTROIObservationsSequence,
ROIContour,
contourPoints
contourPoints,
isSupported
) {
const StructureSetROI = StructureSetROISequence.find(
structureSetROI => structureSetROI.ROINumber === ROIContour.ReferencedROINumber
@@ -276,9 +203,9 @@ function _setROIContourMetadata(
ROIName: StructureSetROI.ROIName,
ROIGenerationAlgorithm: StructureSetROI.ROIGenerationAlgorithm,
ROIDescription: StructureSetROI.ROIDescription,
isSupported,
contourPoints,
visible: true,
colorArray: [],
};
_setROIContourDataColor(ROIContour, ROIContourData);
@@ -295,14 +222,6 @@ function _setROIContourMetadata(
structureSet.ROIContours.push(ROIContourData);
}
/**
* Sets the display color for ROI contour data.
* Uses ROIDisplayColor if available, otherwise converts RecommendedDisplayCIELabValue to RGB.
*
* @function _setROIContourDataColor
* @param {Object} ROIContour - ROI contour object containing color information
* @param {Object} ROIContourData - ROI contour data object to set color on
*/
function _setROIContourDataColor(ROIContour, ROIContourData) {
let { ROIDisplayColor, RecommendedDisplayCIELabValue } = ROIContour;
@@ -316,15 +235,6 @@ function _setROIContourDataColor(ROIContour, ROIContourData) {
}
}
/**
* Sets RT ROI observations metadata for ROI contour data.
* Finds matching RTROIObservations by ROINumber and adds observation details to contour data.
*
* @function _setROIContourRTROIObservations
* @param {Object} ROIContourData - ROI contour data object to add observations to
* @param {Array} RTROIObservationsSequence - Array of RT ROI observations
* @param {number} ROINumber - ROI number to match observations
*/
function _setROIContourRTROIObservations(ROIContourData, RTROIObservationsSequence, ROINumber) {
const RTROIObservations = RTROIObservationsSequence.find(
RTROIObservations => RTROIObservations.ReferencedROINumber === ROINumber
@@ -344,14 +254,6 @@ function _setROIContourRTROIObservations(ROIContourData, RTROIObservationsSequen
}
}
/**
* Converts a single object or array to an array.
* Utility function to ensure consistent array handling for DICOM sequences.
*
* @function _toArray
* @param {*} objOrArray - Object or array to convert
* @returns {Array} Array containing the input (if already array) or wrapped in array
*/
function _toArray(objOrArray) {
return Array.isArray(objOrArray) ? objOrArray : [objOrArray];
}
@@ -1,5 +1,5 @@
function createRTToolGroupAndAddTools(ToolGroupService, customizationService, toolGroupId) {
const tools = customizationService.getCustomization('cornerstone.overlayViewportTools');
const { tools } = customizationService.get('cornerstone.overlayViewportTools') ?? {};
return ToolGroupService.createToolGroupAndAddTools(toolGroupId, tools);
}
@@ -1,4 +1,10 @@
import { utils, Types } from '@ohif/extension-cornerstone';
import { ButtonEnums } from '@ohif/ui';
const RESPONSE = {
NO_NEVER: -1,
CANCEL: 0,
HYDRATE_SEG: 5,
};
function promptHydrateRT({
servicesManager,
@@ -6,20 +12,70 @@ function promptHydrateRT({
viewportId,
preHydrateCallbacks,
hydrateRTDisplaySet,
}: {
servicesManager: AppTypes.ServicesManager;
rtDisplaySet: AppTypes.DisplaySet;
viewportId: string;
preHydrateCallbacks?: Types.HydrationCallback[];
hydrateRTDisplaySet: Types.HydrationCallback;
}) {
return utils.promptHydrationDialog({
servicesManager,
viewportId,
displaySet: rtDisplaySet,
preHydrateCallbacks,
hydrateCallback: hydrateRTDisplaySet,
type: 'RTSTRUCT',
}: withAppTypes) {
const { uiViewportDialogService } = servicesManager.services;
const extensionManager = servicesManager._extensionManager;
const appConfig = extensionManager._appConfig;
return new Promise(async function (resolve, reject) {
const promptResult = appConfig?.disableConfirmationPrompts
? RESPONSE.HYDRATE_SEG
: await _askHydrate(uiViewportDialogService, viewportId);
if (promptResult === RESPONSE.HYDRATE_SEG) {
preHydrateCallbacks?.forEach(callback => {
callback();
});
const isHydrated = await hydrateRTDisplaySet({
rtDisplaySet,
viewportId,
servicesManager,
});
resolve(isHydrated);
}
});
}
function _askHydrate(uiViewportDialogService: AppTypes.UIViewportDialogService, viewportId) {
return new Promise(function (resolve, reject) {
const message = 'Do you want to open this Segmentation?';
const actions = [
{
id: 'no-hydrate',
type: ButtonEnums.type.secondary,
text: 'No',
value: RESPONSE.CANCEL,
},
{
id: 'yes-hydrate',
type: ButtonEnums.type.primary,
text: 'Yes',
value: RESPONSE.HYDRATE_SEG,
},
];
const onSubmit = result => {
uiViewportDialogService.hide();
resolve(result);
};
uiViewportDialogService.show({
id: 'promptHydrateRT',
viewportId,
type: 'info',
message,
actions,
onSubmit,
onOutsideClick: () => {
uiViewportDialogService.hide();
resolve(RESPONSE.CANCEL);
},
onKeyPress: event => {
if (event.key === 'Enter') {
onSubmit(RESPONSE.HYDRATE_SEG);
}
},
});
});
}
@@ -1,27 +1,33 @@
import React, { Component, useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { useViewportGrid } from '@ohif/ui-next';
import {
utils,
usePositionPresentationStore,
OHIFCornerstoneViewport,
} from '@ohif/extension-cornerstone';
import { useViewportGrid, LoadingIndicatorTotalPercent, ViewportActionArrows } from '@ohif/ui';
import promptHydrateRT from '../utils/promptHydrateRT';
import _getStatusComponent from './_getStatusComponent';
import createRTToolGroupAndAddTools from '../utils/initRTToolGroup';
import { useSystem } from '@ohif/core/src';
import { SegmentationRepresentations } from '@cornerstonejs/tools/enums';
const RT_TOOLGROUP_BASE_NAME = 'RTToolGroup';
function OHIFCornerstoneRTViewport(props: withAppTypes) {
const { servicesManager, commandsManager } = useSystem();
const { children, displaySets, viewportOptions } = props as {
children: React.ReactNode;
displaySets: AppTypes.DisplaySet[];
viewportOptions: AppTypes.ViewportOptions;
};
const {
children,
displaySets,
viewportOptions,
servicesManager,
extensionManager,
commandsManager,
} = props;
const { displaySetService, toolGroupService, segmentationService, customizationService } =
servicesManager.services;
const {
displaySetService,
toolGroupService,
segmentationService,
uiNotificationService,
customizationService,
viewportActionCornersService,
} = servicesManager.services;
const viewportId = viewportOptions.viewportId;
@@ -32,40 +38,33 @@ function OHIFCornerstoneRTViewport(props: withAppTypes) {
throw new Error('RT viewport should only have a single display set');
}
const LoadingIndicatorTotalPercent = customizationService.getCustomization(
'ui.loadingIndicatorTotalPercent'
);
const rtDisplaySet = displaySets[0];
const [{ viewports, activeViewportId }, viewportGridService] = useViewportGrid();
const [viewportGrid, viewportGridService] = useViewportGrid();
// States
const { setPositionPresentation } = usePositionPresentationStore();
const [isToolGroupCreated, setToolGroupCreated] = useState(false);
const [selectedSegment, setSelectedSegment] = useState(1);
// Hydration means that the RT is opened and segments are loaded into the
// segmentation panel, and RT is also rendered on any viewport that is in the
// same frameOfReferenceUID as the referencedSeriesUID of the RT. However,
// loading basically means RT loading over network and bit unpacking of the
// RT data.
const [isHydrated, setIsHydrated] = useState(rtDisplaySet.isHydrated);
const [rtIsLoading, setRtIsLoading] = useState(!rtDisplaySet.isLoaded);
const [element, setElement] = useState(null);
const [processingProgress, setProcessingProgress] = useState({
percentComplete: null,
totalSegments: null,
});
// refs
const referencedDisplaySetRef = useRef(null);
const { viewports, activeViewportId } = viewportGrid;
const referencedDisplaySetInstanceUID = rtDisplaySet.referencedDisplaySetInstanceUID;
// If the referencedDisplaySetInstanceUID is not found, it means the RTStruct series is being
// launched without its corresponding referenced display set (e.g., the RTStruct series is launched using
// series launch /mode?StudyInstanceUIDs=&SeriesInstanceUID).
// In such cases, we attempt to handle this scenario gracefully by
// invoking a custom handler. Ideally, if a user tries to launch a series that isn't viewable,
// (eg.: we can prompt them with an explanation and provide a link to the full study).
if (!referencedDisplaySetInstanceUID) {
const missingReferenceDisplaySetHandler = customizationService.getCustomization(
'missingReferenceDisplaySetHandler'
);
const { handled } = missingReferenceDisplaySetHandler();
if (handled) {
return;
}
}
const referencedDisplaySet = displaySetService.getDisplaySetByUID(
referencedDisplaySetInstanceUID
);
@@ -75,31 +74,110 @@ function OHIFCornerstoneRTViewport(props: withAppTypes) {
displaySet: referencedDisplaySet,
metadata: referencedDisplaySetMetadata,
};
/**
* OnElementEnabled callback which is called after the cornerstoneExtension
* has enabled the element. Note: we delegate all the image rendering to
* cornerstoneExtension, so we don't need to do anything here regarding
* the image rendering, element enabling etc.
*/
const onElementEnabled = evt => {
setElement(evt.detail.element);
};
const onElementDisabled = () => {
setElement(null);
};
const storePresentationState = useCallback(() => {
viewportGrid?.viewports.forEach(({ viewportId }) => {
commandsManager.runCommand('storePresentation', {
viewportId,
});
});
}, [viewportGrid]);
const hydrateRTDisplaySet = useCallback(
({ rtDisplaySet, viewportId }) => {
commandsManager.runCommand('hydrateRTSDisplaySet', {
displaySet: rtDisplaySet,
viewportId,
});
},
[commandsManager]
);
const getCornerstoneViewport = useCallback(() => {
const { component: Component } = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.viewportModule.cornerstone'
);
const { displaySet: referencedDisplaySet } = referencedDisplaySetRef.current;
// Todo: jump to the center of the first segment
return (
<Component
{...props}
displaySets={[referencedDisplaySet, rtDisplaySet]}
viewportOptions={{
viewportType: 'stack',
toolGroupId: toolGroupId,
orientation: viewportOptions.orientation,
viewportId: viewportOptions.viewportId,
}}
onElementEnabled={evt => {
props.onElementEnabled?.(evt);
onElementEnabled(evt);
}}
onElementDisabled={onElementDisabled}
></Component>
);
}, [viewportId, rtDisplaySet, toolGroupId]);
const onSegmentChange = useCallback(
direction => {
const segmentationId = rtDisplaySet.displaySetInstanceUID;
const segmentation = segmentationService.getSegmentation(segmentationId);
const { segments } = segmentation;
const numberOfSegments = Object.keys(segments).length;
let newSelectedSegmentIndex = selectedSegment + direction;
// Segment 0 is always background
if (newSelectedSegmentIndex >= numberOfSegments - 1) {
newSelectedSegmentIndex = 1;
} else if (newSelectedSegmentIndex === 0) {
newSelectedSegmentIndex = numberOfSegments - 1;
}
segmentationService.jumpToSegmentCenter(segmentationId, newSelectedSegmentIndex, viewportId);
setSelectedSegment(newSelectedSegmentIndex);
},
[selectedSegment, segmentationService]
);
useEffect(() => {
if (rtIsLoading) {
return;
}
// if not active viewport, return
if (viewportId !== activeViewportId) {
return;
}
promptHydrateRT({
servicesManager,
viewportId,
rtDisplaySet,
hydrateRTDisplaySet: async () => {
return commandsManager.runCommand('hydrateSecondaryDisplaySet', {
displaySet: rtDisplaySet,
viewportId,
});
},
preHydrateCallbacks: [storePresentationState],
hydrateRTDisplaySet,
}).then(isHydrated => {
if (isHydrated) {
setIsHydrated(true);
}
});
}, [servicesManager, viewportId, rtDisplaySet, rtIsLoading, commandsManager, activeViewportId]);
}, [servicesManager, viewportId, rtDisplaySet, rtIsLoading]);
useEffect(() => {
// I'm not sure what is this, since in RT we support Overlapping segments
// via contours
const { unsubscribe } = segmentationService.subscribe(
segmentationService.EVENTS.SEGMENTATION_LOADING_COMPLETE,
evt => {
@@ -107,16 +185,11 @@ function OHIFCornerstoneRTViewport(props: withAppTypes) {
setRtIsLoading(false);
}
if (rtDisplaySet?.firstSegmentedSliceImageId && viewportOptions?.presentationIds) {
const { firstSegmentedSliceImageId } = rtDisplaySet;
const { presentationIds } = viewportOptions;
setPositionPresentation(presentationIds.positionPresentationId, {
viewportType: 'stack',
viewReference: {
referencedImageId: firstSegmentedSliceImageId,
},
viewPresentation: {},
if (evt.overlappingSegments) {
uiNotificationService.show({
title: 'Overlapping Segments',
message: 'Overlapping segments detected which is not currently supported',
type: 'warning',
});
}
}
@@ -128,7 +201,7 @@ function OHIFCornerstoneRTViewport(props: withAppTypes) {
}, [rtDisplaySet]);
useEffect(() => {
const segmentLoadingSubscription = segmentationService.subscribe(
const { unsubscribe } = segmentationService.subscribe(
segmentationService.EVENTS.SEGMENT_LOADING_COMPLETE,
({ percentComplete, numSegments }) => {
setProcessingProgress({
@@ -138,7 +211,16 @@ function OHIFCornerstoneRTViewport(props: withAppTypes) {
}
);
const displaySetsRemovedSubscription = displaySetService.subscribe(
return () => {
unsubscribe();
};
}, [rtDisplaySet]);
/**
Cleanup the SEG viewport when the viewport is destroyed
*/
useEffect(() => {
const onDisplaySetsRemovedSubscription = displaySetService.subscribe(
displaySetService.EVENTS.DISPLAY_SETS_REMOVED,
({ displaySetInstanceUIDs }) => {
const activeViewport = viewports.get(activeViewportId);
@@ -152,10 +234,9 @@ function OHIFCornerstoneRTViewport(props: withAppTypes) {
);
return () => {
segmentLoadingSubscription.unsubscribe();
displaySetsRemovedSubscription.unsubscribe();
onDisplaySetsRemovedSubscription.unsubscribe();
};
}, [rtDisplaySet, displaySetService, viewports, activeViewportId, viewportGridService]);
}, []);
useEffect(() => {
let toolGroup = toolGroupService.getToolGroup(toolGroupId);
@@ -166,36 +247,43 @@ function OHIFCornerstoneRTViewport(props: withAppTypes) {
toolGroup = createRTToolGroupAndAddTools(toolGroupService, customizationService, toolGroupId);
setToolGroupCreated(true);
return () => {
// remove the segmentation representations if seg displayset changed
segmentationService.removeSegmentationRepresentations(viewportId);
referencedDisplaySetRef.current = null;
toolGroupService.destroyToolGroup(toolGroupId);
};
}, []);
const getCornerstoneViewport = useCallback(() => {
const { displaySet: referencedDisplaySet } = referencedDisplaySetRef.current;
useEffect(() => {
setIsHydrated(rtDisplaySet.isHydrated);
// Todo: jump to the center of the first segment
return (
<OHIFCornerstoneViewport
{...props}
displaySets={[referencedDisplaySet, rtDisplaySet]}
viewportOptions={{
viewportType: viewportOptions.viewportType,
toolGroupId: toolGroupId,
orientation: viewportOptions.orientation,
viewportId: viewportOptions.viewportId,
presentationIds: viewportOptions.presentationIds,
}}
onElementEnabled={evt => {
props.onElementEnabled?.(evt);
}}
/>
);
}, [viewportId, rtDisplaySet, toolGroupId]);
return () => {
// remove the segmentation representations if seg displayset changed
segmentationService.removeSegmentationRepresentations(viewportId);
referencedDisplaySetRef.current = null;
};
}, [rtDisplaySet]);
const onStatusClick = useCallback(async () => {
// Before hydrating a RT and make it added to all viewports in the grid
// that share the same frameOfReferenceUID, we need to store the viewport grid
// presentation state, so that we can restore it after hydrating the RT. This is
// required if the user has changed the viewport (other viewport than RT viewport)
// presentation state (w/l and invert) and then opens the RT. If we don't store
// the presentation state, the viewport will be reset to the default presentation
storePresentationState();
const isHydrated = await hydrateRTDisplaySet({
rtDisplaySet,
viewportId,
});
setIsHydrated(isHydrated);
}, [hydrateRTDisplaySet, rtDisplaySet, storePresentationState, viewportId]);
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
let childrenWithProps = null;
if (
@@ -218,6 +306,43 @@ function OHIFCornerstoneRTViewport(props: withAppTypes) {
});
}
useEffect(() => {
viewportActionCornersService.addComponents([
{
viewportId,
id: 'viewportStatusComponent',
component: _getStatusComponent({
isHydrated,
onStatusClick,
}),
indexPriority: -100,
location: viewportActionCornersService.LOCATIONS.topLeft,
},
{
viewportId,
id: 'viewportActionArrowsComponent',
component: (
<ViewportActionArrows
key="actionArrows"
onArrowsClick={onSegmentChange}
className={
viewportId === activeViewportId ? 'visible' : 'invisible group-hover/pane:visible'
}
></ViewportActionArrows>
),
indexPriority: 0,
location: viewportActionCornersService.LOCATIONS.topRight,
},
]);
}, [
activeViewportId,
isHydrated,
onSegmentChange,
onStatusClick,
viewportActionCornersService,
viewportId,
]);
return (
<>
<div className="relative flex h-full w-full flex-row overflow-hidden">
@@ -0,0 +1,62 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Icon, Tooltip } from '@ohif/ui';
export default function _getStatusComponent({ isHydrated, onStatusClick }) {
let ToolTipMessage = null;
let StatusIcon = null;
switch (isHydrated) {
case true:
StatusIcon = () => <Icon name="status-alert" />;
ToolTipMessage = () => <div>This Segmentation is loaded in the segmentation panel</div>;
break;
case false:
StatusIcon = () => (
<Icon
className="text-aqua-pale"
name="status-untracked"
/>
);
ToolTipMessage = () => <div>Click LOAD to load RTSTRUCT.</div>;
}
const StatusArea = () => {
const { t } = useTranslation('Common');
const loadStr = t('LOAD');
return (
<div className="flex h-6 cursor-default text-sm leading-6 text-white">
<div className="bg-customgray-100 flex min-w-[45px] items-center rounded-l-xl rounded-r p-1">
<StatusIcon />
<span className="ml-1">RTSTRUCT</span>
</div>
{!isHydrated && (
<div
className="bg-primary-main hover:bg-primary-light ml-1 cursor-pointer rounded px-1.5 hover:text-black"
// Using onMouseUp here because onClick is not working when the viewport is not active and is styled with pointer-events:none
onMouseUp={onStatusClick}
>
{loadStr}
</div>
)}
</div>
);
};
return (
<>
{ToolTipMessage && (
<Tooltip
content={<ToolTipMessage />}
position="bottom-left"
>
<StatusArea />
</Tooltip>
)}
{!ToolTipMessage && <StatusArea />}
</>
);
}
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,5 @@
module.exports = {
plugins: ['@babel/plugin-transform-class-properties'],
plugins: ['@babel/plugin-proposal-class-properties'],
env: {
test: {
presets: [
@@ -15,7 +15,7 @@ module.exports = {
'@babel/preset-react',
],
plugins: [
'@babel/plugin-transform-object-rest-spread',
'@babel/plugin-proposal-object-rest-spread',
'@babel/plugin-syntax-dynamic-import',
'@babel/plugin-transform-regenerator',
'@babel/plugin-transform-runtime',
@@ -37,6 +37,7 @@ module.exports = {
'@babel/preset-react',
'@babel/preset-typescript',
],
plugins: ['react-refresh/babel'],
ignore: ['**/*.test.jsx', '**/*.test.js', '__snapshots__', '__tests__'],
},
},
@@ -1,6 +1,6 @@
{
"name": "@ohif/extension-cornerstone-dicom-seg",
"version": "3.12.0-beta.43",
"version": "3.9.2",
"description": "DICOM SEG read workflow",
"author": "OHIF",
"license": "MIT",
@@ -33,10 +33,10 @@
"start": "yarn run dev"
},
"peerDependencies": {
"@ohif/core": "3.12.0-beta.43",
"@ohif/extension-cornerstone": "3.12.0-beta.43",
"@ohif/extension-default": "3.12.0-beta.43",
"@ohif/i18n": "3.12.0-beta.43",
"@ohif/core": "3.9.2",
"@ohif/extension-cornerstone": "3.9.2",
"@ohif/extension-default": "3.9.2",
"@ohif/i18n": "3.9.2",
"prop-types": "^15.6.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -46,9 +46,9 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "4.3.14",
"@cornerstonejs/core": "4.3.14",
"@kitware/vtk.js": "32.12.0",
"@cornerstonejs/adapters": "^2.2.20",
"@cornerstonejs/core": "^2.2.20",
"@kitware/vtk.js": "32.1.1",
"react-color": "^2.19.3"
}
}
@@ -1,12 +1,20 @@
import dcmjs from 'dcmjs';
import { classes, Types } from '@ohif/core';
import { cache, metaData } from '@cornerstonejs/core';
import { segmentation as cornerstoneToolsSegmentation } from '@cornerstonejs/tools';
import { adaptersRT, helpers, adaptersSEG } from '@cornerstonejs/adapters';
import { createReportDialogPrompt } from '@ohif/extension-default';
import { DicomMetadataStore } from '@ohif/core';
import { Types } from '@ohif/core';
import { cache, metaData } from '@cornerstonejs/core';
import {
segmentation as cornerstoneToolsSegmentation,
Enums as cornerstoneToolsEnums,
utilities,
} from '@cornerstonejs/tools';
import { adaptersRT, helpers, adaptersSEG } from '@cornerstonejs/adapters';
import { classes, DicomMetadataStore } from '@ohif/core';
import PROMPT_RESPONSES from '../../default/src/utils/_shared/PROMPT_RESPONSES';
import vtkImageMarchingSquares from '@kitware/vtk.js/Filters/General/ImageMarchingSquares';
import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
const { segmentation: segmentationUtils } = utilities;
const { datasetToBlob } = dcmjs.data;
@@ -37,8 +45,13 @@ const commandsModule = ({
servicesManager,
extensionManager,
}: Types.Extensions.ExtensionParams): Types.Extensions.CommandsModule => {
const { segmentationService, displaySetService, viewportGridService, toolGroupService } =
servicesManager.services as AppTypes.Services;
const {
segmentationService,
uiDialogService,
displaySetService,
viewportGridService,
toolGroupService,
} = servicesManager.services as AppTypes.Services;
const actions = {
/**
@@ -213,6 +226,14 @@ const commandsModule = ({
* otherwise throws an error.
*/
storeSegmentation: async ({ segmentationId, dataSource }) => {
const promptResult = await createReportDialogPrompt(uiDialogService, {
extensionManager,
});
if (promptResult.action !== 1 && !promptResult.value) {
return;
}
const segmentation = segmentationService.getSegmentation(segmentationId);
if (!segmentation) {
@@ -220,55 +241,33 @@ const commandsModule = ({
}
const { label } = segmentation;
const defaultDataSource = dataSource ?? extensionManager.getActiveDataSource()[0];
const SeriesDescription = promptResult.value || label || 'Research Derived Series';
const {
value: reportName,
dataSourceName: selectedDataSource,
action,
} = await createReportDialogPrompt({
servicesManager,
extensionManager,
title: 'Store Segmentation',
const generatedData = actions.generateSegmentation({
segmentationId,
options: {
SeriesDescription,
},
});
if (action === PROMPT_RESPONSES.CREATE_REPORT) {
try {
const selectedDataSourceConfig = selectedDataSource
? extensionManager.getDataSources(selectedDataSource)[0]
: defaultDataSource;
const generatedData = actions.generateSegmentation({
segmentationId,
options: {
SeriesDescription: reportName || label || 'Research Derived Series',
},
});
if (!generatedData || !generatedData.dataset) {
throw new Error('Error during segmentation generation');
}
const { dataset: naturalizedReport } = generatedData;
// DCMJS assigns a dummy study id during creation, and this can cause problems, so clearing it out
if (naturalizedReport.StudyID === 'No Study ID') {
naturalizedReport.StudyID = '';
}
await selectedDataSourceConfig.store.dicom(naturalizedReport);
// add the information for where we stored it to the instance as well
naturalizedReport.wadoRoot = selectedDataSourceConfig.getConfig().wadoRoot;
DicomMetadataStore.addInstances([naturalizedReport], true);
return naturalizedReport;
} catch (error) {
console.debug('Error storing segmentation:', error);
throw error;
}
if (!generatedData || !generatedData.dataset) {
throw new Error('Error during segmentation generation');
}
const { dataset: naturalizedReport } = generatedData;
await dataSource.store.dicom(naturalizedReport);
// The "Mode" route listens for DicomMetadataStore changes
// When a new instance is added, it listens and
// automatically calls makeDisplaySets
// add the information for where we stored it to the instance as well
naturalizedReport.wadoRoot = dataSource.getConfig().wadoRoot;
DicomMetadataStore.addInstances([naturalizedReport], true);
return naturalizedReport;
},
/**
* Converts segmentations into RTSS for download.
@@ -277,24 +276,21 @@ const commandsModule = ({
* converts dataset to downloadable blob.
*
*/
downloadRTSS: async ({ segmentationId }) => {
downloadRTSS: ({ segmentationId }) => {
const segmentations = segmentationService.getSegmentation(segmentationId);
const vtkUtils = {
vtkImageMarchingSquares,
vtkDataArray,
vtkImageData,
};
// inject colors to the segmentIndex
const firstRepresentation =
segmentationService.getRepresentationsForSegmentation(segmentationId)[0];
Object.entries(segmentations.segments).forEach(([segmentIndex, segment]) => {
segment.color = segmentationService.getSegmentColor(
firstRepresentation.viewportId,
segmentationId,
segmentIndex
);
});
const RTSS = await generateRTSSFromSegmentations(
const RTSS = generateRTSSFromSegmentations(
segmentations,
classes.MetadataProvider,
DicomMetadataStore
DicomMetadataStore,
cache,
cornerstoneToolsEnums,
vtkUtils
);
try {
@@ -307,9 +303,48 @@ const commandsModule = ({
console.warn(e);
}
},
setBrushSize: ({ value, toolNames }) => {
const brushSize = Number(value);
toolGroupService.getToolGroupIds()?.forEach(toolGroupId => {
if (toolNames?.length === 0) {
segmentationUtils.setBrushSizeForToolGroup(toolGroupId, brushSize);
} else {
toolNames?.forEach(toolName => {
segmentationUtils.setBrushSizeForToolGroup(toolGroupId, brushSize, toolName);
});
}
});
},
setThresholdRange: ({
value,
toolNames = ['ThresholdCircularBrush', 'ThresholdSphereBrush'],
}) => {
toolGroupService.getToolGroupIds()?.forEach(toolGroupId => {
const toolGroup = toolGroupService.getToolGroup(toolGroupId);
toolNames?.forEach(toolName => {
toolGroup.setToolConfiguration(toolName, {
strategySpecificConfiguration: {
THRESHOLD: {
threshold: value,
},
},
});
});
});
},
};
const definitions = {
/**
* Obsolete?
*/
loadSegmentationDisplaySetsForViewport: {
commandFn: actions.loadSegmentationDisplaySetsForViewport,
},
/**
* Obsolete?
*/
loadSegmentationsForViewport: {
commandFn: actions.loadSegmentationsForViewport,
},
@@ -326,6 +361,12 @@ const commandsModule = ({
downloadRTSS: {
commandFn: actions.downloadRTSS,
},
setBrushSize: {
commandFn: actions.setBrushSize,
},
setThresholdRange: {
commandFn: actions.setThresholdRange,
},
};
return {
@@ -1,13 +1,12 @@
import { utils, Types as OhifTypes } from '@ohif/core';
import i18n from '@ohif/i18n';
import { metaData, eventTarget } from '@cornerstonejs/core';
import { utils } from '@ohif/core';
import { metaData, triggerEvent, eventTarget } from '@cornerstonejs/core';
import { CONSTANTS, segmentation as cstSegmentation } from '@cornerstonejs/tools';
import { adaptersSEG, Enums } from '@cornerstonejs/adapters';
import { SOPClassHandlerId } from './id';
import { dicomlabToRGB } from './utils/dicomlabToRGB';
const sopClassUids = ['1.2.840.10008.5.1.4.1.1.66.4', '1.2.840.10008.5.1.4.1.1.66.7'];
const sopClassUids = ['1.2.840.10008.5.1.4.1.1.66.4'];
const loadPromises = {};
@@ -34,7 +33,7 @@ function _getDisplaySetsFromSeries(
const displaySet = {
Modality: 'SEG',
loading: false,
isReconstructable: false,
isReconstructable: true, // by default for now since it is a volumetric SEG currently
displaySetInstanceUID: utils.guid(),
SeriesDescription,
SeriesNumber,
@@ -58,7 +57,6 @@ function _getDisplaySetsFromSeries(
wadoUriRoot,
wadoUri,
isOverlayDisplaySet: true,
label: SeriesDescription || `${i18n.t('Series')} ${SeriesNumber} - ${i18n.t('SEG')}`,
};
const referencedSeriesSequence = instance.ReferencedSeriesSequence;
@@ -91,14 +89,12 @@ function _getDisplaySetsFromSeries(
const addedDisplaySet = displaySetsAdded[0];
if (addedDisplaySet.SeriesInstanceUID === displaySet.referencedSeriesInstanceUID) {
displaySet.referencedDisplaySetInstanceUID = addedDisplaySet.displaySetInstanceUID;
displaySet.isReconstructable = addedDisplaySet.isReconstructable;
unsubscribe();
}
}
);
} else {
displaySet.referencedDisplaySetInstanceUID = referencedDisplaySet.displaySetInstanceUID;
displaySet.isReconstructable = referencedDisplaySet.isReconstructable;
}
displaySet.load = async ({ headers }) =>
@@ -181,16 +177,12 @@ async function _loadSegments({
throw new Error('referencedDisplaySet is missing for SEG');
}
let { imageIds } = referencedDisplaySet;
if (!imageIds) {
// try images
const { images } = referencedDisplaySet;
imageIds = images.map(image => image.imageId);
}
const { instances: images } = referencedDisplaySet;
const imageIds = images.map(({ imageId }) => imageId);
// Todo: what should be defaults here
const tolerance = 0.001;
const skipOverlapping = true;
eventTarget.addEventListener(Enums.Events.SEGMENTATION_LOAD_PROGRESS, evt => {
const { percentComplete } = evt.detail;
segmentationService._broadcastEvent(segmentationService.EVENTS.SEGMENT_LOADING_COMPLETE, {
@@ -198,10 +190,11 @@ async function _loadSegments({
});
});
const results = await adaptersSEG.Cornerstone3D.Segmentation.createFromDICOMSegBuffer(
const results = await adaptersSEG.Cornerstone3D.Segmentation.generateToolState(
imageIds,
arrayBuffer,
{ metadataProvider: metaData, tolerance }
metaData,
{ skipOverlapping, tolerance, eventTarget, triggerEvent }
);
let usedRecommendedDisplayCIELabValue = true;
@@ -218,6 +211,15 @@ async function _loadSegments({
}
});
if (results.overlappingSegments) {
uiNotificationService.show({
title: 'Overlapping Segments',
message:
'Unsupported overlapping segments detected, segmentation rendering results may be incorrect.',
type: 'warning',
});
}
if (!usedRecommendedDisplayCIELabValue) {
// Display a notification about the non-utilization of RecommendedDisplayCIELabValue
uiNotificationService.show({
@@ -236,8 +238,7 @@ function _segmentationExists(segDisplaySet) {
return cstSegmentation.state.getSegmentation(segDisplaySet.displaySetInstanceUID);
}
function getSopClassHandlerModule(params: OhifTypes.Extensions.ExtensionParams) {
const { servicesManager, extensionManager } = params;
function getSopClassHandlerModule({ servicesManager, extensionManager }) {
const getDisplaySetsFromSeries = instances => {
return _getDisplaySetsFromSeries(instances, servicesManager, extensionManager);
};
@@ -1,15 +1,6 @@
export function getToolbarModule({ servicesManager }: withAppTypes) {
const { segmentationService, toolbarService, toolGroupService } = servicesManager.services;
return [
{
name: 'evaluate.cornerstone.hasSegmentation',
evaluate: ({ viewportId }) => {
const segmentations = segmentationService.getSegmentationRepresentations(viewportId);
return {
disabled: !segmentations?.length,
};
},
},
{
name: 'evaluate.cornerstone.segmentation',
evaluate: ({ viewportId, button, toolNames, disabledText }) => {
@@ -21,39 +12,27 @@ export function getToolbarModule({ servicesManager }: withAppTypes) {
if (!segmentations?.length) {
return {
disabled: true,
className: '!text-common-bright !bg-black opacity-50',
disabledText: disabledText ?? 'No segmentations available',
};
}
const activeSegmentation = segmentationService.getActiveSegmentation(viewportId);
if (!Object.keys(activeSegmentation.segments).length) {
return {
disabled: true,
disabledText: 'Add segment to enable this tool',
};
}
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
if (!toolGroup) {
return {
disabled: true,
className: '!text-common-bright ohif-disabled',
disabledText: disabledText ?? 'Not available on the current viewport',
};
}
if (!toolNames) {
return {
disabled: false,
// isActive: false,
};
}
const toolName = toolbarService.getToolNameForButton(button);
if (!toolGroup.hasTool(toolName) && !toolNames) {
return {
disabled: true,
className: '!text-common-bright ohif-disabled',
disabledText: disabledText ?? 'Not available on the current viewport',
};
}
@@ -64,6 +43,12 @@ export function getToolbarModule({ servicesManager }: withAppTypes) {
return {
disabled: false,
className: isPrimaryActive
? '!text-black !bg-primary-light hover:bg-primary-light hover-text-black hover:cursor-pointer'
: '!text-common-bright !bg-black hover:bg-primary-light hover:cursor-pointer hover:text-black',
// Todo: isActive right now is used for nested buttons where the primary
// button needs to be fully rounded (vs partial rounded) when active
// otherwise it does not have any other use
isActive: isPrimaryActive,
};
},
@@ -1,14 +1,7 @@
function createSEGToolGroupAndAddTools({
commandsManager,
toolGroupService,
customizationService,
toolGroupId,
}) {
const tools = customizationService.getCustomization('cornerstone.overlayViewportTools');
function createSEGToolGroupAndAddTools(ToolGroupService, customizationService, toolGroupId) {
const { tools } = customizationService.get('cornerstone.overlayViewportTools') ?? {};
const updatedTools = commandsManager.run('initializeSegmentLabelTool', { tools });
return toolGroupService.createToolGroupAndAddTools(toolGroupId, updatedTools);
return ToolGroupService.createToolGroupAndAddTools(toolGroupId, tools);
}
export default createSEGToolGroupAndAddTools;
@@ -1,4 +1,10 @@
import { utils, Types } from '@ohif/extension-cornerstone';
import { ButtonEnums } from '@ohif/ui';
const RESPONSE = {
NO_NEVER: -1,
CANCEL: 0,
HYDRATE_SEG: 5,
};
function promptHydrateSEG({
servicesManager,
@@ -6,20 +12,71 @@ function promptHydrateSEG({
viewportId,
preHydrateCallbacks,
hydrateCallback,
}: {
servicesManager: AppTypes.ServicesManager;
segDisplaySet: AppTypes.DisplaySet;
viewportId: string;
preHydrateCallbacks?: Types.HydrationCallback[];
hydrateCallback: Types.HydrationCallback;
}) {
return utils.promptHydrationDialog({
servicesManager,
viewportId,
displaySet: segDisplaySet as AppTypes.DisplaySet,
preHydrateCallbacks,
hydrateCallback,
type: 'SEG',
}: withAppTypes) {
const { uiViewportDialogService } = servicesManager.services;
const extensionManager = servicesManager._extensionManager;
const appConfig = extensionManager._appConfig;
return new Promise(async function (resolve, reject) {
const promptResult = appConfig?.disableConfirmationPrompts
? RESPONSE.HYDRATE_SEG
: await _askHydrate(uiViewportDialogService, viewportId);
if (promptResult === RESPONSE.HYDRATE_SEG) {
preHydrateCallbacks?.forEach(callback => {
callback();
});
window.setTimeout(async () => {
const isHydrated = await hydrateCallback({
segDisplaySet,
viewportId,
});
resolve(isHydrated);
}, 0);
}
});
}
function _askHydrate(uiViewportDialogService, viewportId) {
return new Promise(function (resolve, reject) {
const message = 'Do you want to open this Segmentation?';
const actions = [
{
id: 'no-hydrate',
type: ButtonEnums.type.secondary,
text: 'No',
value: RESPONSE.CANCEL,
},
{
id: 'yes-hydrate',
type: ButtonEnums.type.primary,
text: 'Yes',
value: RESPONSE.HYDRATE_SEG,
},
];
const onSubmit = result => {
uiViewportDialogService.hide();
resolve(result);
};
uiViewportDialogService.show({
viewportId,
type: 'info',
message,
actions,
onSubmit,
onOutsideClick: () => {
uiViewportDialogService.hide();
resolve(RESPONSE.CANCEL);
},
onKeyPress: event => {
if (event.key === 'Enter') {
onSubmit(RESPONSE.HYDRATE_SEG);
}
},
});
});
}
@@ -1,28 +1,33 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useViewportGrid } from '@ohif/ui-next';
import { useTranslation } from 'react-i18next';
import { LoadingIndicatorTotalPercent, useViewportGrid, ViewportActionArrows } from '@ohif/ui';
import createSEGToolGroupAndAddTools from '../utils/initSEGToolGroup';
import promptHydrateSEG from '../utils/promptHydrateSEG';
import { usePositionPresentationStore, OHIFCornerstoneViewport } from '@ohif/extension-cornerstone';
import _getStatusComponent from './_getStatusComponent';
import { SegmentationRepresentations } from '@cornerstonejs/tools/enums';
import { useSystem } from '@ohif/core/src/contextProviders/SystemProvider';
const SEG_TOOLGROUP_BASE_NAME = 'SEGToolGroup';
function OHIFCornerstoneSEGViewport(props: withAppTypes) {
const { servicesManager, commandsManager } = useSystem();
const { children, displaySets, viewportOptions } = props as {
children: React.ReactNode;
displaySets: AppTypes.DisplaySet[];
viewportOptions: AppTypes.ViewportOptions;
};
const {
children,
displaySets,
viewportOptions,
servicesManager,
extensionManager,
commandsManager,
} = props;
const { t } = useTranslation('SEGViewport');
const viewportId = viewportOptions.viewportId;
const { displaySetService, toolGroupService, segmentationService, customizationService } =
servicesManager.services;
const LoadingIndicatorTotalPercent = customizationService.getCustomization(
'ui.loadingIndicatorTotalPercent'
);
const {
displaySetService,
toolGroupService,
segmentationService,
customizationService,
viewportActionCornersService,
} = servicesManager.services;
const toolGroupId = `${SEG_TOOLGROUP_BASE_NAME}-${viewportId}`;
@@ -35,14 +40,16 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) {
const [viewportGrid, viewportGridService] = useViewportGrid();
// States
const { setPositionPresentation } = usePositionPresentationStore();
const [selectedSegment, setSelectedSegment] = useState(1);
// Hydration means that the SEG is opened and segments are loaded into the
// segmentation panel, and SEG is also rendered on any viewport that is in the
// same frameOfReferenceUID as the referencedSeriesUID of the SEG. However,
// loading basically means SEG loading over network and bit unpacking of the
// SEG data.
const [isHydrated, setIsHydrated] = useState(segDisplaySet.isHydrated);
const [segIsLoading, setSegIsLoading] = useState(!segDisplaySet.isLoaded);
const [element, setElement] = useState(null);
const [processingProgress, setProcessingProgress] = useState({
percentComplete: null,
totalSegments: null,
@@ -54,21 +61,6 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) {
const { viewports, activeViewportId } = viewportGrid;
const referencedDisplaySetInstanceUID = segDisplaySet.referencedDisplaySetInstanceUID;
// If the referencedDisplaySetInstanceUID is not found, it means the SEG series is being
// launched without its corresponding referenced display set (e.g., the SEG series is launched using
// series launch /mode?StudyInstanceUIDs=&SeriesInstanceUID).
// In such cases, we attempt to handle this scenario gracefully by
// invoking a custom handler. Ideally, if a user tries to launch a series that isn't viewable,
// (eg.: we can prompt them with an explanation and provide a link to the full study).
if (!referencedDisplaySetInstanceUID) {
const missingReferenceDisplaySetHandler = customizationService.getCustomization(
'missingReferenceDisplaySetHandler'
);
const { handled } = missingReferenceDisplaySetHandler();
if (handled) {
return;
}
}
const referencedDisplaySet = displaySetService.getDisplaySetByUID(
referencedDisplaySetInstanceUID
);
@@ -82,10 +74,36 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) {
displaySet: referencedDisplaySet,
metadata: referencedDisplaySetMetadata,
};
/**
* OnElementEnabled callback which is called after the cornerstoneExtension
* has enabled the element. Note: we delegate all the image rendering to
* cornerstoneExtension, so we don't need to do anything here regarding
* the image rendering, element enabling etc.
*/
const onElementEnabled = evt => {
setElement(evt.detail.element);
};
const onElementDisabled = () => {
setElement(null);
};
const storePresentationState = useCallback(() => {
viewportGrid?.viewports.forEach(({ viewportId }) => {
commandsManager.runCommand('storePresentation', {
viewportId,
});
});
}, [viewportGrid]);
const getCornerstoneViewport = useCallback(() => {
const { component: Component } = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.viewportModule.cornerstone'
);
// Todo: jump to the center of the first segment
return (
<OHIFCornerstoneViewport
<Component
{...props}
displaySets={[segDisplaySet]}
viewportOptions={{
@@ -97,35 +115,78 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) {
}}
onElementEnabled={evt => {
props.onElementEnabled?.(evt);
onElementEnabled(evt);
}}
/>
onElementDisabled={onElementDisabled}
></Component>
);
}, [viewportId, segDisplaySet, toolGroupId, props, viewportOptions]);
}, [viewportId, segDisplaySet, toolGroupId]);
const onSegmentChange = useCallback(
direction => {
const segmentationId = segDisplaySet.displaySetInstanceUID;
const segmentation = segmentationService.getSegmentation(segmentationId);
const { segments } = segmentation;
const numberOfSegments = Object.keys(segments).length;
let newSelectedSegmentIndex = selectedSegment + direction;
// Segment 0 is always background
if (newSelectedSegmentIndex > numberOfSegments - 1) {
newSelectedSegmentIndex = 1;
} else if (newSelectedSegmentIndex === 0) {
newSelectedSegmentIndex = numberOfSegments - 1;
}
segmentationService.jumpToSegmentCenter(segmentationId, newSelectedSegmentIndex, viewportId);
setSelectedSegment(newSelectedSegmentIndex);
},
[selectedSegment]
);
const hydrateSEG = useCallback(() => {
// update the previously stored segmentationPresentation with the new viewportId
// presentation so that when we put the referencedDisplaySet back in the viewport
// it will have the correct segmentation representation hydrated
commandsManager.runCommand('updateStoredSegmentationPresentation', {
displaySet: segDisplaySet,
type: SegmentationRepresentations.Labelmap,
});
// update the previously stored positionPresentation with the new viewportId
// presentation so that when we put the referencedDisplaySet back in the viewport
// it will be in the correct position zoom and pan
commandsManager.runCommand('updateStoredPositionPresentation', {
viewportId,
displaySetInstanceUID: referencedDisplaySet.displaySetInstanceUID,
});
viewportGridService.setDisplaySetsForViewport({
viewportId,
displaySetInstanceUIDs: [referencedDisplaySet.displaySetInstanceUID],
});
}, [commandsManager, viewportId, referencedDisplaySet, segDisplaySet]);
useEffect(() => {
if (segIsLoading) {
return;
}
// if not active viewport, return
if (viewportId !== activeViewportId) {
return;
}
promptHydrateSEG({
servicesManager,
viewportId,
segDisplaySet,
hydrateCallback: async () => {
await commandsManager.runCommand('hydrateSecondaryDisplaySet', {
displaySet: segDisplaySet,
viewportId,
});
return true;
},
preHydrateCallbacks: [storePresentationState],
hydrateCallback: hydrateSEG,
}).then(isHydrated => {
if (isHydrated) {
setIsHydrated(true);
}
});
}, [servicesManager, viewportId, segDisplaySet, segIsLoading, commandsManager, activeViewportId]);
}, [servicesManager, viewportId, segDisplaySet, segIsLoading, hydrateSEG]);
useEffect(() => {
// on new seg display set, remove all segmentations from all viewports
@@ -137,17 +198,6 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) {
if (evt.segDisplaySet.displaySetInstanceUID === segDisplaySet.displaySetInstanceUID) {
setSegIsLoading(false);
}
if (segDisplaySet?.firstSegmentedSliceImageId && viewportOptions?.presentationIds) {
const { firstSegmentedSliceImageId } = segDisplaySet;
const { presentationIds } = viewportOptions;
setPositionPresentation(presentationIds.positionPresentationId, {
viewReference: {
referencedImageId: firstSegmentedSliceImageId,
},
});
}
}
);
@@ -214,12 +264,7 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) {
// This creates a custom tool group which has the lifetime of this view
// only, and does NOT interfere with currently displayed segmentations.
toolGroup = createSEGToolGroupAndAddTools({
commandsManager,
toolGroupService,
customizationService,
toolGroupId,
});
toolGroup = createSEGToolGroupAndAddTools(toolGroupService, customizationService, toolGroupId);
return () => {
// remove the segmentation representations if seg displayset changed
@@ -231,6 +276,54 @@ function OHIFCornerstoneSEGViewport(props: withAppTypes) {
};
}, []);
const onStatusClick = useCallback(async () => {
// Before hydrating a SEG and make it added to all viewports in the grid
// that share the same frameOfReferenceUID, we need to store the viewport grid
// presentation state, so that we can restore it after hydrating the SEG. This is
// required if the user has changed the viewport (other viewport than SEG viewport)
// presentation state (w/l and invert) and then opens the SEG. If we don't store
// the presentation state, the viewport will be reset to the default presentation
storePresentationState();
hydrateSEG();
}, [storePresentationState, hydrateSEG]);
useEffect(() => {
viewportActionCornersService.addComponents([
{
viewportId,
id: 'viewportStatusComponent',
component: _getStatusComponent({
isHydrated,
onStatusClick,
}),
indexPriority: -100,
location: viewportActionCornersService.LOCATIONS.topLeft,
},
{
viewportId,
id: 'viewportActionArrowsComponent',
component: (
<ViewportActionArrows
key="actionArrows"
onArrowsClick={onSegmentChange}
className={
viewportId === activeViewportId ? 'visible' : 'invisible group-hover/pane:visible'
}
></ViewportActionArrows>
),
indexPriority: 0,
location: viewportActionCornersService.LOCATIONS.topRight,
},
]);
}, [
activeViewportId,
isHydrated,
onSegmentChange,
onStatusClick,
viewportActionCornersService,
viewportId,
]);
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
let childrenWithProps = null;
@@ -0,0 +1,62 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Icon, Tooltip } from '@ohif/ui';
export default function _getStatusComponent({ isHydrated, onStatusClick }) {
let ToolTipMessage = null;
let StatusIcon = null;
switch (isHydrated) {
case true:
StatusIcon = () => <Icon name="status-alert" />;
ToolTipMessage = () => <div>This Segmentation is loaded in the segmentation panel</div>;
break;
case false:
StatusIcon = () => (
<Icon
className="text-aqua-pale"
name="status-untracked"
/>
);
ToolTipMessage = () => <div>Click LOAD to load segmentation.</div>;
}
const StatusArea = () => {
const { t } = useTranslation('Common');
const loadStr = t('LOAD');
return (
<div className="flex h-6 cursor-default text-sm leading-6 text-white">
<div className="bg-customgray-100 flex min-w-[45px] items-center rounded-l-xl rounded-r p-1">
<StatusIcon />
<span className="ml-1">SEG</span>
</div>
{!isHydrated && (
<div
className="bg-primary-main hover:bg-primary-light ml-1 cursor-pointer rounded px-1.5 hover:text-black"
// Using onMouseUp here because onClick is not working when the viewport is not active and is styled with pointer-events:none
onMouseUp={onStatusClick}
>
{loadStr}
</div>
)}
</div>
);
};
return (
<>
{ToolTipMessage && (
<Tooltip
content={<ToolTipMessage />}
position="bottom-left"
>
<StatusArea />
</Tooltip>
)}
{!ToolTipMessage && <StatusArea />}
</>
);
}
File diff suppressed because it is too large. Load diff
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@ohif/extension-cornerstone-dicom-sr",
"version": "3.12.0-beta.43",
"version": "3.9.2",
"description": "OHIF extension for an SR Cornerstone Viewport",
"author": "OHIF",
"license": "MIT",
@@ -34,11 +34,11 @@
"test:unit:ci": "jest --ci --runInBand --collectCoverage --passWithNoTests"
},
"peerDependencies": {
"@ohif/core": "3.12.0-beta.43",
"@ohif/extension-cornerstone": "3.12.0-beta.43",
"@ohif/extension-measurement-tracking": "3.12.0-beta.43",
"@ohif/ui": "3.12.0-beta.43",
"dcmjs": "0.43.1",
"@ohif/core": "3.9.2",
"@ohif/extension-cornerstone": "3.9.2",
"@ohif/extension-measurement-tracking": "3.9.2",
"@ohif/ui": "3.9.2",
"dcmjs": "*",
"dicom-parser": "^1.8.9",
"hammerjs": "^2.0.8",
"prop-types": "^15.6.2",
@@ -46,9 +46,9 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "4.3.14",
"@cornerstonejs/core": "4.3.14",
"@cornerstonejs/tools": "4.3.14",
"@cornerstonejs/adapters": "^2.2.20",
"@cornerstonejs/core": "^2.2.20",
"@cornerstonejs/tools": "^2.2.20",
"classnames": "^2.3.2"
}
}
@@ -5,34 +5,30 @@ import dcmjs from 'dcmjs';
import { adaptersSR } from '@cornerstonejs/adapters';
import getFilteredCornerstoneToolState from './utils/getFilteredCornerstoneToolState';
import hydrateStructuredReport from './utils/hydrateStructuredReport';
const { MeasurementReport } = adaptersSR.Cornerstone3D;
const { log } = OHIF;
interface Options {
SeriesDescription?: string;
SeriesInstanceUID?: string;
SeriesNumber?: number;
InstanceNumber?: number;
SeriesDate?: string;
SeriesTime?: string;
}
/**
*
* @param measurementData An array of measurements from the measurements service
* that you wish to serialize.
* @param additionalFindingTypes toolTypes that should be stored with labels as Findings
* @param options Naturalized DICOM JSON headers to merge into the displaySet.
*
*/
const _generateReport = (measurementData, additionalFindingTypes, options: Options = {}) => {
const _generateReport = (measurementData, additionalFindingTypes, options = {}) => {
const filteredToolState = getFilteredCornerstoneToolState(
measurementData,
additionalFindingTypes
);
const report = MeasurementReport.generateReport(filteredToolState, metaData, options);
const report = MeasurementReport.generateReport(
filteredToolState,
metaData,
utilities.worldToImageCoords,
options
);
const { dataset } = report;
@@ -41,37 +37,13 @@ const _generateReport = (measurementData, additionalFindingTypes, options: Optio
if (typeof dataset.SpecificCharacterSet === 'undefined') {
dataset.SpecificCharacterSet = 'ISO_IR 192';
}
dataset.InstanceNumber = options.InstanceNumber ?? 1;
return dataset;
};
const commandsModule = (props: withAppTypes) => {
const { servicesManager, extensionManager, commandsManager } = props;
const { servicesManager } = props;
const { customizationService } = servicesManager.services;
const actions = {
changeColorMeasurement: ({ uid }) => {
// When this gets supported, it probably belongs in cornerstone, not sr
throw new Error('Unsupported operation: changeColorMeasurement');
// const { color } = measurementService.getMeasurement(uid);
// const rgbaColor = {
// r: color[0],
// g: color[1],
// b: color[2],
// a: color[3] / 255.0,
// };
// colorPickerDialog(uiDialogService, rgbaColor, (newRgbaColor, actionId) => {
// if (actionId === 'cancel') {
// return;
// }
// const color = [newRgbaColor.r, newRgbaColor.g, newRgbaColor.b, newRgbaColor.a * 255.0];
// segmentationService.setSegmentColor(viewportId, segmentationId, segmentIndex, color);
// });
},
/**
*
* @param measurementData An array of measurements from the measurements service
@@ -81,7 +53,7 @@ const commandsModule = (props: withAppTypes) => {
* that you wish to serialize.
*/
downloadReport: ({ measurementData, additionalFindingTypes, options = {} }) => {
const srDataset = _generateReport(measurementData, additionalFindingTypes, options);
const srDataset = actions.generateReport(measurementData, additionalFindingTypes, options);
const reportBlob = dcmjs.data.datasetToBlob(srDataset);
//Create a URL for the binary.
@@ -124,15 +96,13 @@ const commandsModule = (props: withAppTypes) => {
console.log('naturalizedReport missing imaging content', naturalizedReport);
throw new Error('Invalid report, no content');
}
if (!naturalizedReport.SOPClassUID) {
throw new Error('No sop class uid');
}
const onBeforeDicomStore = customizationService.getCustomization('onBeforeDicomStore');
const onBeforeDicomStore =
customizationService.getModeCustomization('onBeforeDicomStore')?.value;
let dicomDict;
if (typeof onBeforeDicomStore === 'function') {
dicomDict = onBeforeDicomStore({ dicomDict, measurementData, naturalizedReport });
dicomDict = onBeforeDicomStore({ measurementData, naturalizedReport });
}
await dataSource.store.dicom(naturalizedReport, null, dicomDict);
@@ -153,23 +123,15 @@ const commandsModule = (props: withAppTypes) => {
throw new Error(error.message || 'Error while saving the measurements.');
}
},
/**
* Loads measurements by hydrating and loading the SR for the given display set instance UID
* and displays it in the active viewport.
*/
hydrateStructuredReport: ({ displaySetInstanceUID }) => {
return hydrateStructuredReport(
{ servicesManager, extensionManager, commandsManager },
displaySetInstanceUID
);
},
};
const definitions = {
downloadReport: actions.downloadReport,
storeMeasurements: actions.storeMeasurements,
hydrateStructuredReport: actions.hydrateStructuredReport,
downloadReport: {
commandFn: actions.downloadReport,
},
storeMeasurements: {
commandFn: actions.storeMeasurements,
},
};
return {
@@ -1,24 +1,38 @@
import PropTypes from 'prop-types';
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useContext, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ExtensionManager } from '@ohif/core';
import { setTrackingUniqueIdentifiersForElement } from '../tools/modules/dicomSRModule';
import { Icon, Tooltip, useViewportGrid, ViewportActionArrows } from '@ohif/ui';
import hydrateStructuredReport from '../utils/hydrateStructuredReport';
import { useAppConfig } from '@state';
import createReferencedImageDisplaySet from '../utils/createReferencedImageDisplaySet';
import { usePositionPresentationStore, OHIFCornerstoneViewport } from '@ohif/extension-cornerstone';
import { useViewportGrid } from '@ohif/ui-next';
import { useSystem } from '@ohif/core/src/contextProviders/SystemProvider';
const MEASUREMENT_TRACKING_EXTENSION_ID = '@ohif/extension-measurement-tracking';
const SR_TOOLGROUP_BASE_NAME = 'SRToolGroup';
function OHIFCornerstoneSRMeasurementViewport(props) {
const { servicesManager } = useSystem();
const { children, dataSource, displaySets, viewportOptions } = props as {
children: React.ReactNode;
dataSource: unknown;
displaySets: AppTypes.DisplaySet[];
viewportOptions: AppTypes.ViewportOptions;
};
function OHIFCornerstoneSRMeasurementViewport(props: withAppTypes) {
const {
commandsManager,
children,
dataSource,
displaySets,
viewportOptions,
servicesManager,
extensionManager,
} = props;
const { displaySetService } = servicesManager.services;
const [appConfig] = useAppConfig();
const {
displaySetService,
cornerstoneViewportService,
measurementService,
viewportActionCornersService,
} = servicesManager.services;
const viewportId = viewportOptions.viewportId;
@@ -29,15 +43,66 @@ function OHIFCornerstoneSRMeasurementViewport(props) {
const srDisplaySet = displaySets[0];
const { setPositionPresentation } = usePositionPresentationStore();
const [viewportGrid, viewportGridService] = useViewportGrid();
const [measurementSelected, setMeasurementSelected] = useState(0);
const [measurementCount, setMeasurementCount] = useState(1);
const [activeImageDisplaySetData, setActiveImageDisplaySetData] = useState(null);
const [referencedDisplaySetMetadata, setReferencedDisplaySetMetadata] = useState(null);
const [element, setElement] = useState(null);
const { viewports, activeViewportId } = viewportGrid;
const { t } = useTranslation('Common');
// Optional hook into tracking extension, if present.
let trackedMeasurements;
let sendTrackedMeasurementsEvent;
const hasMeasurementTrackingExtension = extensionManager.registeredExtensionIds.includes(
MEASUREMENT_TRACKING_EXTENSION_ID
);
if (hasMeasurementTrackingExtension) {
const contextModule = extensionManager.getModuleEntry(
'@ohif/extension-measurement-tracking.contextModule.TrackedMeasurementsContext'
);
const tracked = useContext(contextModule.context);
trackedMeasurements = tracked?.[0];
sendTrackedMeasurementsEvent = tracked?.[1];
}
if (!sendTrackedMeasurementsEvent) {
// if no panels from measurement-tracking extension is used, this code will run
trackedMeasurements = null;
sendTrackedMeasurementsEvent = (eventName, { displaySetInstanceUID }) => {
measurementService.clearMeasurements();
const { SeriesInstanceUIDs } = hydrateStructuredReport(
{ servicesManager, extensionManager, appConfig },
displaySetInstanceUID
);
const displaySets = displaySetService.getDisplaySetsForSeries(SeriesInstanceUIDs[0]);
if (displaySets.length) {
viewportGridService.setDisplaySetsForViewports([
{
viewportId: activeViewportId,
displaySetInstanceUIDs: [displaySets[0].displaySetInstanceUID],
},
]);
}
};
}
/**
* Todo: what is this, not sure what it does regarding the react aspect,
* it is updating a local variable? which is not state.
*/
const [isLocked, setIsLocked] = useState(trackedMeasurements?.context?.trackedSeries?.length > 0);
/**
* Store the tracking identifiers per viewport in order to be able to
* show the SR measurements on the referenced image on the correct viewport,
* when multiple viewports are used.
*/
const setTrackingIdentifiers = useCallback(
measurementSelected => {
const { measurements } = srDisplaySet;
@@ -63,12 +128,22 @@ function OHIFCornerstoneSRMeasurementViewport(props) {
const updateViewport = useCallback(
newMeasurementSelected => {
const { StudyInstanceUID, displaySetInstanceUID } = srDisplaySet;
const { StudyInstanceUID, displaySetInstanceUID, sopClassUids } = srDisplaySet;
if (!StudyInstanceUID || !displaySetInstanceUID) {
return;
}
if (sopClassUids && sopClassUids.length > 1) {
// Todo: what happens if there are multiple SOP Classes? Why we are
// not throwing an error?
console.warn('More than one SOPClassUID in the same series is not yet supported.');
}
// if (!srDisplaySet.measurements || !srDisplaySet.measurements.length) {
// return;
// }
_getViewportReferencedDisplaySetData(
srDisplaySet,
newMeasurementSelected,
@@ -79,17 +154,33 @@ function OHIFCornerstoneSRMeasurementViewport(props) {
}
setMeasurementSelected(newMeasurementSelected);
setActiveImageDisplaySetData(referencedDisplaySet);
setReferencedDisplaySetMetadata(referencedDisplaySetMetadata);
const { presentationIds } = viewportOptions;
const measurement = srDisplaySet.measurements[newMeasurementSelected];
setPositionPresentation(presentationIds.positionPresentationId, {
viewReference: measurement.viewReference || {
referencedImageId: measurement.imageId,
},
});
if (
referencedDisplaySet.displaySetInstanceUID ===
activeImageDisplaySetData?.displaySetInstanceUID
) {
const { measurements } = srDisplaySet;
// it means that we have a new referenced display set, and the
// imageIdIndex will handle it by updating the viewport, but if they
// are the same we just need to use measurementService to jump to the
// new measurement
const csViewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!csViewport) {
return;
}
const imageIds = csViewport.getImageIds();
const imageIdIndex = imageIds.indexOf(measurements[newMeasurementSelected].imageId);
if (imageIdIndex !== -1) {
csViewport.setImageIdIndex(imageIdIndex);
}
}
});
},
[dataSource, srDisplaySet, activeImageDisplaySetData, viewportId]
@@ -100,6 +191,10 @@ function OHIFCornerstoneSRMeasurementViewport(props) {
return null;
}
const { component: Component } = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.viewportModule.cornerstone'
);
const { measurements } = srDisplaySet;
const measurement = measurements[measurementSelected];
@@ -107,8 +202,12 @@ function OHIFCornerstoneSRMeasurementViewport(props) {
return null;
}
const initialImageIndex = activeImageDisplaySetData.images.findIndex(
image => image.imageId === measurement.imageId
);
return (
<OHIFCornerstoneViewport
<Component
{...props}
// should be passed second since we don't want SR displaySet to
// override the activeImageDisplaySetData
@@ -131,11 +230,29 @@ function OHIFCornerstoneSRMeasurementViewport(props) {
props.onElementEnabled?.(evt);
onElementEnabled(evt);
}}
initialImageIndex={initialImageIndex}
isJumpToMeasurementDisabled={true}
/>
></Component>
);
}, [activeImageDisplaySetData, viewportId, measurementSelected]);
const onMeasurementChange = useCallback(
direction => {
let newMeasurementSelected = measurementSelected;
newMeasurementSelected += direction;
if (newMeasurementSelected >= measurementCount) {
newMeasurementSelected = 0;
} else if (newMeasurementSelected < 0) {
newMeasurementSelected = measurementCount - 1;
}
setTrackingIdentifiers(newMeasurementSelected);
updateViewport(newMeasurementSelected);
},
[measurementSelected, measurementCount, updateViewport, setTrackingIdentifiers]
);
/**
Cleanup the SR viewport when the viewport is destroyed
*/
@@ -170,6 +287,8 @@ function OHIFCornerstoneSRMeasurementViewport(props) {
if (!srDisplaySet.isLoaded) {
await srDisplaySet.load();
}
const numMeasurements = srDisplaySet.measurements.length;
setMeasurementCount(numMeasurements);
updateViewport(measurementSelected);
};
loadSR();
@@ -192,6 +311,50 @@ function OHIFCornerstoneSRMeasurementViewport(props) {
updateSR();
}, [measurementSelected, element, setTrackingIdentifiers, srDisplaySet]);
useEffect(() => {
setIsLocked(trackedMeasurements?.context?.trackedSeries?.length > 0);
}, [trackedMeasurements]);
useEffect(() => {
viewportActionCornersService.addComponents([
{
viewportId,
id: 'viewportStatusComponent',
component: _getStatusComponent({
srDisplaySet,
viewportId,
isRehydratable: srDisplaySet.isRehydratable,
isLocked,
sendTrackedMeasurementsEvent,
t,
}),
indexPriority: -100,
location: viewportActionCornersService.LOCATIONS.topLeft,
},
{
viewportId,
id: 'viewportActionArrowsComponent',
index: 0,
component: (
<ViewportActionArrows
key="actionArrows"
onArrowsClick={onMeasurementChange}
></ViewportActionArrows>
),
indexPriority: 0,
location: viewportActionCornersService.LOCATIONS.topRight,
},
]);
}, [
isLocked,
onMeasurementChange,
sendTrackedMeasurementsEvent,
srDisplaySet,
t,
viewportActionCornersService,
viewportId,
]);
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
let childrenWithProps = null;
@@ -228,6 +391,8 @@ OHIFCornerstoneSRMeasurementViewport.propTypes = {
children: PropTypes.node,
viewportLabel: PropTypes.string,
viewportOptions: PropTypes.object,
servicesManager: PropTypes.object.isRequired,
extensionManager: PropTypes.instanceOf(ExtensionManager).isRequired,
};
async function _getViewportReferencedDisplaySetData(
@@ -252,9 +417,6 @@ async function _getViewportReferencedDisplaySetData(
}
const referencedDisplaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
if (!referencedDisplaySet?.images) {
return { referencedDisplaySetMetadata: null, referencedDisplaySet: null };
}
const image0 = referencedDisplaySet.images[0];
const referencedDisplaySetMetadata = {
@@ -274,4 +436,97 @@ async function _getViewportReferencedDisplaySetData(
return { referencedDisplaySetMetadata, referencedDisplaySet };
}
function _getStatusComponent({
srDisplaySet,
viewportId,
isRehydratable,
isLocked,
sendTrackedMeasurementsEvent,
t,
}) {
const handleMouseUp = () => {
sendTrackedMeasurementsEvent('HYDRATE_SR', {
displaySetInstanceUID: srDisplaySet.displaySetInstanceUID,
viewportId,
});
};
const loadStr = t('LOAD');
// 1 - Incompatible
// 2 - Locked
// 3 - Rehydratable / Open
const state = isRehydratable && !isLocked ? 3 : isRehydratable && isLocked ? 2 : 1;
let ToolTipMessage = null;
let StatusIcon = null;
switch (state) {
case 1:
StatusIcon = () => <Icon name="status-alert" />;
ToolTipMessage = () => (
<div>
This structured report is not compatible
<br />
with this application.
</div>
);
break;
case 2:
StatusIcon = () => <Icon name="status-locked" />;
ToolTipMessage = () => (
<div>
This structured report is currently read-only
<br />
because you are tracking measurements in
<br />
another viewport.
</div>
);
break;
case 3:
StatusIcon = () => (
<Icon
className="text-aqua-pale"
name="status-untracked"
/>
);
ToolTipMessage = () => <div>{`Click ${loadStr} to restore measurements.`}</div>;
}
const StatusArea = () => (
<div className="flex h-6 cursor-default text-sm leading-6 text-white">
<div className="bg-customgray-100 flex min-w-[45px] items-center rounded-l-xl rounded-r p-1">
<StatusIcon />
<span className="ml-1">SR</span>
</div>
{state === 3 && (
<div
className="bg-primary-main hover:bg-primary-light ml-1 cursor-pointer rounded px-1.5 hover:text-black"
// Using onMouseUp here because onClick is not working when the viewport is not active and is styled with pointer-events:none
onMouseUp={handleMouseUp}
>
{loadStr}
</div>
)}
</div>
);
return (
<>
{ToolTipMessage && (
<Tooltip
content={<ToolTipMessage />}
position="bottom-left"
>
<StatusArea />
</Tooltip>
)}
{!ToolTipMessage && <StatusArea />}
</>
);
}
export default OHIFCornerstoneSRMeasurementViewport;
@@ -1,5 +1,4 @@
import { utils, classes, DisplaySetService, Types as OhifTypes } from '@ohif/core';
import i18n from '@ohif/i18n';
import { utils, classes, DisplaySetService, Types } from '@ohif/core';
import { Enums as CSExtensionEnums } from '@ohif/extension-cornerstone';
import { adaptersSR } from '@cornerstonejs/adapters';
@@ -15,14 +14,10 @@ import { CodeNameCodeSequenceValues, CodingSchemeDesignators } from './enums';
const { sopClassDictionary } = utils;
const { CORNERSTONE_3D_TOOLS_SOURCE_NAME, CORNERSTONE_3D_TOOLS_SOURCE_VERSION } = CSExtensionEnums;
const { MetadataProvider: metadataProvider } = classes;
const {
TEXT_ANNOTATION_POSITION,
COMMENT_CODE,
CodeScheme: Cornerstone3DCodeScheme,
} = adaptersSR.Cornerstone3D;
const { ImageSet, MetadataProvider: metadataProvider } = classes;
const { CodeScheme: Cornerstone3DCodeScheme } = adaptersSR.Cornerstone3D;
type InstanceMetadata = OhifTypes.InstanceMetadata;
type InstanceMetadata = Types.InstanceMetadata;
/**
* TODO
@@ -35,7 +30,6 @@ const sopClassUids = [
sopClassDictionary.BasicTextSR,
sopClassDictionary.EnhancedSR,
sopClassDictionary.ComprehensiveSR,
sopClassDictionary.Comprehensive3DSR,
];
const validateSameStudyUID = (uid: string, instances): void => {
@@ -54,7 +48,7 @@ const validateSameStudyUID = (uid: string, instances): void => {
* @param instances is a list of instances from THIS series that are not
* in this DICOM SR Display Set already.
*/
function addInstances(instances: InstanceMetadata[], _displaySetService: DisplaySetService) {
function addInstances(instances: InstanceMetadata[], displaySetService: DisplaySetService) {
this.instances.push(...instances);
utils.sortStudyInstances(this.instances);
// The last instance is the newest one, so is the one most interesting.
@@ -69,10 +63,9 @@ function addInstances(instances: InstanceMetadata[], _displaySetService: Display
* DICOM SR SOP Class Handler
* For all referenced images in the TID 1500/300 sections, add an image to the
* display.
* @param {InstanceMetadata[]} instances - A set of instances all from the same series
* @param {AppTypes.ServicesManager} servicesManager - The services that can be used for creating
* @param {AppTypes.ExtensionManager} extensionManager - The extension manager
* @returns {Types.DisplaySet[]} The list of display sets created for the given instances object
* @param instances is a set of instances all from the same series
* @param servicesManager is the services that can be used for creating
* @returns The list of display sets created for the given instances object
*/
function _getDisplaySetsFromSeries(
instances,
@@ -97,7 +90,6 @@ function _getDisplaySetsFromSeries(
SeriesDescription,
SeriesNumber,
SeriesDate,
SeriesTime,
ConceptNameCodeSequence,
SOPClassUID,
} = instance;
@@ -114,7 +106,6 @@ function _getDisplaySetsFromSeries(
SeriesDescription,
SeriesNumber,
SeriesDate,
SeriesTime,
SOPInstanceUID,
SeriesInstanceUID,
StudyInstanceUID,
@@ -129,7 +120,6 @@ function _getDisplaySetsFromSeries(
sopClassUids,
instance,
addInstances,
label: SeriesDescription || `${i18n.t('Series')} ${SeriesNumber} - ${i18n.t('SR')}`,
};
displaySet.load = () => _load(displaySet, servicesManager, extensionManager);
@@ -144,7 +134,7 @@ function _getDisplaySetsFromSeries(
* @param extensionManager - The extension manager containing data sources.
*/
async function _load(
srDisplaySet: OhifTypes.DisplaySet,
srDisplaySet: Types.DisplaySet,
servicesManager: AppTypes.ServicesManager,
extensionManager: AppTypes.ExtensionManager
) {
@@ -222,15 +212,17 @@ async function _load(
});
}
function _measurementBelongsToDisplaySet({ measurement, displaySet }) {
return (
measurement.coords[0].ReferencedFrameOfReferenceSequence === displaySet.FrameOfReferenceUID
);
}
/**
* Checks if measurements can be added to a display set.
*
* @param srDisplaySet - The source display set containing measurements.
* @param newDisplaySet - The new display set to check if measurements can be added.
* @param dataSource - The data source used to retrieve image IDs.
* @param servicesManager - The services manager.
*/
function _checkIfCanAddMeasurementsToDisplaySet(
srDisplaySet: OhifTypes.DisplaySet,
newDisplaySet: OhifTypes.DisplaySet,
srDisplaySet,
newDisplaySet,
dataSource,
servicesManager: AppTypes.ServicesManager
) {
@@ -240,10 +232,18 @@ function _checkIfCanAddMeasurementsToDisplaySet(
measurement => measurement.loaded === false
);
if (!unloadedMeasurements.length || newDisplaySet.unsupported) {
if (
unloadedMeasurements.length === 0 ||
!(newDisplaySet instanceof ImageSet) ||
newDisplaySet.unsupported
) {
return;
}
// const { sopClassUids } = newDisplaySet;
// Create a Set for faster lookups
// const sopClassUidSet = new Set(sopClassUids);
// Create a Map to efficiently look up ImageIds by SOPInstanceUID and frame number
const imageIdMap = new Map<string, string>();
const imageIds = dataSource.getImageIdsForDisplaySet(newDisplaySet);
@@ -262,11 +262,10 @@ function _checkIfCanAddMeasurementsToDisplaySet(
for (let j = unloadedMeasurements.length - 1; j >= 0; j--) {
let measurement = unloadedMeasurements[j];
const is3DMeasurement = measurement.coords?.[0]?.ValueType === 'SCOORD3D';
const onBeforeSRAddMeasurement = customizationService.getCustomization(
const onBeforeSRAddMeasurement = customizationService.getModeCustomization(
'onBeforeSRAddMeasurement'
);
)?.value;
if (typeof onBeforeSRAddMeasurement === 'function') {
measurement = onBeforeSRAddMeasurement({
@@ -277,15 +276,9 @@ function _checkIfCanAddMeasurementsToDisplaySet(
}
// if it is 3d SR we can just add the SR annotation
if (
is3DSR &&
is3DMeasurement &&
_measurementBelongsToDisplaySet({ measurement, displaySet: newDisplaySet })
) {
addSRAnnotation({ measurement, displaySet: newDisplaySet });
if (is3DSR) {
addSRAnnotation(measurement, null, null);
measurement.loaded = true;
measurement.displaySetInstanceUID = newDisplaySet.displaySetInstanceUID;
unloadedMeasurements.splice(j, 1);
continue;
}
@@ -303,12 +296,15 @@ function _checkIfCanAddMeasurementsToDisplaySet(
imageId &&
_measurementReferencesSOPInstanceUID(measurement, ReferencedSOPInstanceUID, frame)
) {
addSRAnnotation({ measurement, imageId, frameNumber: frame, displaySet: newDisplaySet });
addSRAnnotation(measurement, imageId, frame);
// Update measurement properties
measurement.loaded = true;
measurement.imageId = imageId;
measurement.displaySetInstanceUID = newDisplaySet.displaySetInstanceUID;
measurement.ReferencedSOPInstanceUID = ReferencedSOPInstanceUID;
measurement.frameNumber = frame;
unloadedMeasurements.splice(j, 1);
}
}
@@ -316,10 +312,10 @@ function _checkIfCanAddMeasurementsToDisplaySet(
/**
* Checks if a measurement references a specific SOP Instance UID.
* @param {any} measurement - The measurement object.
* @param {string} sopInstanceUID - The SOP Instance UID to check against.
* @param {number} frameNumber - The frame number to check against (optional).
* @returns {boolean} True if the measurement references the specified SOP Instance UID, false otherwise.
* @param measurement - The measurement object.
* @param SOPInstanceUID - The SOP Instance UID to check against.
* @param frameNumber - The frame number to check against (optional).
* @returns True if the measurement references the specified SOP Instance UID, false otherwise.
*/
function _measurementReferencesSOPInstanceUID(measurement, SOPInstanceUID, frameNumber) {
const { coords } = measurement;
@@ -351,11 +347,12 @@ function _measurementReferencesSOPInstanceUID(measurement, SOPInstanceUID, frame
/**
* Retrieves the SOP class handler module.
*
* @param {OhifTypes.Extensions.ExtensionParams} params - The extension parameters.
* @returns {Array} An array containing the SOP class handler modules.
* @param {Object} options - The options for retrieving the SOP class handler module.
* @param {Object} options.servicesManager - The services manager.
* @param {Object} options.extensionManager - The extension manager.
* @returns {Array} An array containing the SOP class handler module.
*/
function getSopClassHandlerModule(params: OhifTypes.Extensions.ExtensionParams) {
const { servicesManager, extensionManager } = params;
function getSopClassHandlerModule({ servicesManager, extensionManager }) {
const getDisplaySetsFromSeries = instances => {
return _getDisplaySetsFromSeries(instances, servicesManager, extensionManager);
};
@@ -376,8 +373,8 @@ function getSopClassHandlerModule(params: OhifTypes.Extensions.ExtensionParams)
/**
* Retrieves the measurements from the ImagingMeasurementReportContentSequence.
*
* @param {any[]} imagingMeasurementReportContentSequence - The ImagingMeasurementReportContentSequence array.
* @returns {any[]} The array of measurements.
* @param {Array} ImagingMeasurementReportContentSequence - The ImagingMeasurementReportContentSequence array.
* @returns {Array} - The array of measurements.
*/
function _getMeasurements(ImagingMeasurementReportContentSequence) {
const ImagingMeasurements = ImagingMeasurementReportContentSequence.find(
@@ -415,8 +412,8 @@ function _getMeasurements(ImagingMeasurementReportContentSequence) {
/**
* Retrieves merged content sequences by tracking unique identifiers.
*
* @param {any[]} measurementGroups - The measurement groups.
* @returns {Object} The merged content sequences by tracking unique identifiers.
* @param {Array} MeasurementGroups - The measurement groups.
* @returns {Object} - The merged content sequences by tracking unique identifiers.
*/
function _getMergedContentSequencesByTrackingUniqueIdentifiers(MeasurementGroups) {
const mergedContentSequencesByTrackingUniqueIdentifiers = {};
@@ -463,11 +460,15 @@ function _getMergedContentSequencesByTrackingUniqueIdentifiers(MeasurementGroups
* it calls the _processTID1410Measurement function.
* Otherwise, it calls the _processNonGeometricallyDefinedMeasurement function.
*
* @param {any[]} mergedContentSequence - The merged content sequence to process.
* @returns {any} The processed measurement result.
* @param {Array<Object>} mergedContentSequence - The merged content sequence to process.
* @returns {any} - The processed measurement result.
*/
function _processMeasurement(mergedContentSequence) {
if (mergedContentSequence.some(group => isScoordOr3d(group) && !isTextPosition(group))) {
if (
mergedContentSequence.some(
group => group.ValueType === 'SCOORD' || group.ValueType === 'SCOORD3D'
)
) {
return _processTID1410Measurement(mergedContentSequence);
}
@@ -479,8 +480,8 @@ function _processMeasurement(mergedContentSequence) {
* TID 1410 style measurements have a SCOORD or SCOORD3D at the top level,
* and non-geometric representations where each NUM has "INFERRED FROM" SCOORD/SCOORD3D.
*
* @param {any[]} mergedContentSequence - The merged content sequence containing the measurements.
* @returns {any} The measurement object containing the loaded status, labels, coordinates, tracking unique identifier, and tracking identifier.
* @param mergedContentSequence - The merged content sequence containing the measurements.
* @returns The measurement object containing the loaded status, labels, coordinates, tracking unique identifier, and tracking identifier.
*/
function _processTID1410Measurement(mergedContentSequence) {
// Need to deal with TID 1410 style measurements, which will have a SCOORD or SCOORD3D at the top level,
@@ -505,25 +506,12 @@ function _processTID1410Measurement(mergedContentSequence) {
const NUMContentItems = mergedContentSequence.filter(group => group.ValueType === 'NUM');
const { ConceptNameCodeSequence: conceptNameItem } = graphicItem;
const { CodeValue: graphicValue, CodingSchemeDesignator: graphicDesignator } = conceptNameItem;
const graphicCode = `${graphicDesignator}:${graphicValue}`;
const pointDataItem = _getCoordsFromSCOORDOrSCOORD3D(graphicItem);
const is3DMeasurement = pointDataItem.ValueType === 'SCOORD3D';
const pointLength = is3DMeasurement ? 3 : 2;
const pointsLength = pointDataItem.GraphicData.length / pointLength;
const measurement = {
loaded: false,
labels: [],
coords: [pointDataItem],
coords: [_getCoordsFromSCOORDOrSCOORD3D(graphicItem)],
TrackingUniqueIdentifier: UIDREFContentItem.UID,
TrackingIdentifier: TrackingIdentifierContentItem.TextValue,
graphicCode,
is3DMeasurement,
pointsLength,
graphicType: pointDataItem.GraphicType,
};
NUMContentItems.forEach(item => {
@@ -553,8 +541,8 @@ function _processTID1410Measurement(mergedContentSequence) {
/**
* Processes the non-geometrically defined measurement from the merged content sequence.
*
* @param {any[]} mergedContentSequence The merged content sequence containing the measurement data.
* @returns {any} The processed measurement object.
* @param mergedContentSequence The merged content sequence containing the measurement data.
* @returns The processed measurement object.
*/
function _processNonGeometricallyDefinedMeasurement(mergedContentSequence) {
const NUMContentItems = mergedContentSequence.filter(group => group.ValueType === 'NUM');
@@ -574,12 +562,6 @@ function _processNonGeometricallyDefinedMeasurement(mergedContentSequence) {
item.ConceptNameCodeSequence.CodeValue === CodeNameCodeSequenceValues.FindingSite
);
const commentSites = mergedContentSequence.filter(
item =>
item.ConceptNameCodeSequence.CodingSchemeDesignator === COMMENT_CODE.schemeDesignator &&
item.ConceptNameCodeSequence.CodeValue === COMMENT_CODE.value
);
const measurement = {
loaded: false,
labels: [],
@@ -588,14 +570,6 @@ function _processNonGeometricallyDefinedMeasurement(mergedContentSequence) {
TrackingIdentifier: TrackingIdentifierContentItem.TextValue,
};
if (commentSites) {
for (const group of commentSites) {
if (group.TextValue) {
measurement.labels.push({ label: group.TextValue, value: '' });
}
}
}
if (
finding &&
CodingSchemeDesignators.CornerstoneCodeSchemes.includes(
@@ -654,8 +628,8 @@ function _processNonGeometricallyDefinedMeasurement(mergedContentSequence) {
/**
* Extracts coordinates from a graphic item of type SCOORD or SCOORD3D.
* @param {any} graphicItem - The graphic item containing the coordinates.
* @returns {any} The extracted coordinates.
* @param {object} graphicItem - The graphic item containing the coordinates.
* @returns {object} - The extracted coordinates.
*/
const _getCoordsFromSCOORDOrSCOORD3D = graphicItem => {
const { ValueType, GraphicType, GraphicData } = graphicItem;
@@ -669,9 +643,9 @@ const _getCoordsFromSCOORDOrSCOORD3D = graphicItem => {
/**
* Retrieves the label and value from the provided ConceptNameCodeSequence and MeasuredValueSequence.
* @param {any} conceptNameCodeSequence - The ConceptNameCodeSequence object.
* @param {any} measuredValueSequence - The MeasuredValueSequence object.
* @returns {Object} An object containing the label and value.
* @param {Object} ConceptNameCodeSequence - The ConceptNameCodeSequence object.
* @param {Object} MeasuredValueSequence - The MeasuredValueSequence object.
* @returns {Object} - An object containing the label and value.
* The label represents the CodeMeaning from the ConceptNameCodeSequence.
* The value represents the formatted NumericValue and CodeValue from the MeasuredValueSequence.
* Example: { label: 'Long Axis', value: '31.00 mm' }
@@ -690,8 +664,8 @@ function _getLabelFromMeasuredValueSequence(ConceptNameCodeSequence, MeasuredVal
/**
* Retrieves a list of referenced images from the Imaging Measurement Report Content Sequence.
*
* @param {any[]} imagingMeasurementReportContentSequence - The Imaging Measurement Report Content Sequence.
* @returns {any[]} The list of referenced images.
* @param {Array} ImagingMeasurementReportContentSequence - The Imaging Measurement Report Content Sequence.
* @returns {Array} - The list of referenced images.
*/
function _getReferencedImagesList(ImagingMeasurementReportContentSequence) {
const ImageLibrary = ImagingMeasurementReportContentSequence.find(
@@ -738,7 +712,7 @@ function _getReferencedImagesList(ImagingMeasurementReportContentSequence) {
* Otherwise, the sequence is wrapped in an array and returned.
*
* @param {any} sequence - The DICOM sequence to convert.
* @returns {any[]} The converted array.
* @returns {any[]} - The converted array.
*/
function _getSequenceAsArray(sequence) {
if (!sequence) {
@@ -747,17 +721,4 @@ function _getSequenceAsArray(sequence) {
return Array.isArray(sequence) ? sequence : [sequence];
}
function isScoordOr3d(group) {
return group.ValueType === 'SCOORD' || group.ValueType === 'SCOORD3D';
}
function isTextPosition(group) {
const concept = group.ConceptNameCodeSequence[0];
return (
concept &&
concept.CodeValue === TEXT_ANNOTATION_POSITION.value &&
concept.CodingSchemeDesignator === TEXT_ANNOTATION_POSITION.schemeDesignator
);
}
export default getSopClassHandlerModule;
@@ -30,7 +30,6 @@ const dicomSRExtension = {
* Only required property. Should be a unique value across all extensions.
*/
id,
onModeEnter,
preRegistration,
+29 -4
View File
@@ -9,10 +9,11 @@ import {
LengthTool,
PlanarFreehandROITool,
RectangleROITool,
utilities as csToolsUtils,
} from '@cornerstonejs/tools';
import { Types } from '@ohif/core';
import { Types, MeasurementService } from '@ohif/core';
import { StackViewport, utilities as csUtils } from '@cornerstonejs/core';
import { Enums as CSExtensionEnums } from '@ohif/extension-cornerstone';
import DICOMSRDisplayTool from './tools/DICOMSRDisplayTool';
import SCOORD3DPointTool from './tools/SCOORD3DPointTool';
import SRSCOOR3DProbeMapper from './utils/SRSCOOR3DProbeMapper';
@@ -28,7 +29,7 @@ export default function init({
configuration = {},
servicesManager,
}: Types.Extensions.ExtensionParams): void {
const { measurementService } = servicesManager.services;
const { measurementService, cornerstoneViewportService } = servicesManager.services;
addToolInstance(toolNames.DICOMSRDisplay, DICOMSRDisplayTool);
addToolInstance(toolNames.SRLength, LengthTool);
@@ -56,7 +57,7 @@ export default function init({
'SRSCOORD3DPoint',
POINT,
SRSCOOR3DProbeMapper.toAnnotation,
SRSCOOR3DProbeMapper.toMeasurement.bind(null, { servicesManager })
SRSCOOR3DProbeMapper.toMeasurement
);
// Modify annotation tools to use dashed lines on SR
@@ -76,4 +77,28 @@ export default function init({
SRRectangleROI: dashedLine,
global: {},
});
measurementService.subscribe(
MeasurementService.EVENTS.JUMP_TO_MEASUREMENT_LAYOUT,
({ viewportId, measurement, isConsumed }) => {
if (isConsumed) {
return;
}
try {
const currentViewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
const { viewPlaneNormal } = currentViewport.getCamera();
const referencedImageId = csToolsUtils.getClosestImageIdForStackViewport(
currentViewport as StackViewport,
measurement.points[0],
viewPlaneNormal
);
const imageIndex = (currentViewport as StackViewport)
.getImageIds()
.indexOf(referencedImageId);
csUtils.jumpToSlice(currentViewport.element, { imageIndex });
} catch (error) {
console.warn('Unable to jump to image based on measurement coordinate', error);
}
}
);
}
@@ -1,4 +1,4 @@
import { type Types } from '@cornerstonejs/core';
import { Types, metaData, utilities as csUtils } from '@cornerstonejs/core';
import {
annotation,
drawing,
@@ -7,8 +7,13 @@ const SRSCOOR3DProbe = {
* @param {Object} cornerstone Cornerstone event data
* @return {Measurement} Measurement instance
*/
toMeasurement: ({ servicesManager, getValueTypeFromToolType }, csToolsEventDetail) => {
const { displaySetService } = servicesManager.services;
toMeasurement: (
csToolsEventDetail,
displaySetService,
CornerstoneViewportService,
getValueTypeFromToolType,
customizationService
) => {
const { annotation } = csToolsEventDetail;
const { metadata, data, annotationUID } = annotation;
@@ -17,25 +22,14 @@ const SRSCOOR3DProbe = {
return null;
}
const { toolName, FrameOfReferenceUID } = metadata;
const { toolName } = metadata;
const { points } = data.handles;
const displaySets = displaySetService
.getActiveDisplaySets()
.filter(ds => ds.FrameOfReferenceUID === FrameOfReferenceUID);
const displaySet = displaySets.filter(ds => ds.isReconstructable)[0] || displaySets[0];
const { StudyInstanceUID: referenceStudyUID, SeriesInstanceUID: referenceSeriesUID } =
displaySets[0] || {};
const displayText = getDisplayText(annotation);
return {
uid: annotationUID,
points,
metadata,
referenceStudyUID,
referenceSeriesUID,
displaySetInstanceUID: displaySet?.displaySetInstanceUID,
toolName: metadata.toolName,
label: data.label,
displayText: displayText,
@@ -62,10 +56,7 @@ function getDisplayText(annotation) {
}
}
return {
primary: displayText,
secondary: [],
};
return displayText;
}
export default SRSCOOR3DProbe;
@@ -1,59 +1,11 @@
import { Types, annotation } from '@cornerstonejs/tools';
import { metaData } from '@cornerstonejs/core';
import { adaptersSR } from '@cornerstonejs/adapters';
import getRenderableData from './getRenderableData';
import toolNames from '../tools/toolNames';
const { MeasurementReport } = adaptersSR.Cornerstone3D;
/**
* Adds a DICOM SR (Structured Report) annotation to the annotation manager.
* This function processes measurement data from DICOM SR and converts it into
* a format suitable for display in the Cornerstone3D viewer.
*
* @param {Object} params - The parameters object
* @param {Object} params.measurement - The DICOM SR measurement data containing coordinates, labels, and metadata
* @param {Array} params.measurement.coords - Array of coordinate objects with GraphicType, ValueType, and other properties
* @param {string} params.measurement.TrackingUniqueIdentifier - Unique identifier for the measurement
* @param {string} params.measurement.TrackingIdentifier - Tracking identifier for adapter lookup
* @param {Array} [params.measurement.labels] - Optional array of label objects
* @param {string} [params.measurement.displayText] - Optional display text for the annotation
* @param {Object} [params.measurement.textBox] - Optional text box configuration
* @param {string|null} [params.imageId] - Optional image ID for the referenced image (defaults to null)
* @param {number|null} [params.frameNumber] - Optional frame number for multi-frame images (defaults to null)
* @param {Object} params.displaySet - The display set containing the image
* @param {string} params.displaySet.displaySetInstanceUID - Unique identifier for the display set
* @returns {void}
*
* @example
* ```typescript
* addSRAnnotation({
* measurement: {
* TrackingUniqueIdentifier: '1.2.3.4.5',
* TrackingIdentifier: 'POINT',
* coords: [{
* GraphicType: 'POINT',
* ValueType: 'SCOORD',
* // ... other coordinate properties
* }],
* labels: [{ value: 'Measurement Point' }],
* displayText: 'Point measurement'
* },
* imageId: 'wadouri:file://path/to/image.dcm', // Optional
* frameNumber: 0, // Optional
* displaySet: { displaySetInstanceUID: '1.2.3.4' }
* });
* ```
*/
export default function addSRAnnotation({ measurement, imageId = null, frameNumber = null, displaySet }) {
/** @type {string} The tool name to use for the annotation, defaults to DICOMSRDisplay */
export default function addSRAnnotation(measurement, imageId, frameNumber) {
let toolName = toolNames.DICOMSRDisplay;
/**
* @type {Object} Renderable data organized by graphic type
* Groups coordinate data by GraphicType for efficient rendering
*/
const renderableData = measurement.coords.reduce((acc, coordProps) => {
acc[coordProps.GraphicType] = acc[coordProps.GraphicType] || [];
acc[coordProps.GraphicType].push(getRenderableData({ ...coordProps, imageId }));
@@ -66,7 +18,6 @@ export default function addSRAnnotation({ measurement, imageId = null, frameNumb
/** TODO: Read the tool name from the DICOM SR identification type in the future. */
let frameOfReferenceUID = null;
let planeRestriction = null;
if (imageId) {
const imagePlaneModule = metaData.get('imagePlaneModule', imageId);
@@ -74,47 +25,23 @@ export default function addSRAnnotation({ measurement, imageId = null, frameNumb
}
if (valueType === 'SCOORD3D') {
const adapter = MeasurementReport.getAdapterForTrackingIdentifier(
measurement.TrackingIdentifier
);
if (!adapter) {
toolName = toolNames.SRSCOORD3DPoint;
}
toolName = toolNames.SRSCOORD3DPoint;
// get the ReferencedFrameOfReferenceUID from the measurement
frameOfReferenceUID = measurement.coords[0].ReferencedFrameOfReferenceSequence;
planeRestriction = {
FrameOfReferenceUID: frameOfReferenceUID,
point: graphicTypePoints[0][0],
};
}
// Store the view reference for use in initial navigation
measurement.viewReference = {
planeRestriction,
FrameOfReferenceUID: frameOfReferenceUID,
referencedImageId: imageId,
};
/**
* @type {Types.Annotation} The annotation object to be added to the annotation manager
* Contains all necessary metadata and data for rendering the DICOM SR measurement
*/
const SRAnnotation: Types.Annotation = {
annotationUID: TrackingUniqueIdentifier,
highlighted: false,
isLocked: false,
isPreview: toolName === toolNames.DICOMSRDisplay,
invalidated: false,
metadata: {
toolName,
planeRestriction,
valueType,
graphicType,
FrameOfReferenceUID: frameOfReferenceUID,
referencedImageId: imageId,
displaySetInstanceUID: displaySet.displaySetInstanceUID,
},
data: {
label: measurement.labels?.[0]?.value || undefined,
@@ -132,11 +59,9 @@ export default function addSRAnnotation({ measurement, imageId = null, frameNumb
};
/**
* Add the annotation to the annotation state manager.
* Note: Using annotation.state.addAnnotation() instead of annotationManager.addAnnotation()
* because the latter was not triggering annotation_added events properly.
*
* @param {Types.Annotation} SRAnnotation - The annotation to add
* const annotationManager = annotation.annotationState.getAnnotationManager();
* was not triggering annotation_added events.
*/
annotation.state.addAnnotation(SRAnnotation);
console.debug('Adding SR annotation:', SRAnnotation);
}
@@ -25,7 +25,7 @@ const contentItemFormatters = {
return `${NumericValue} ${CodeValue}`;
},
PNAME: contentItem => {
const personName = contentItem.PersonName?.[0];
const personName = contentItem.PersonName?.[0]?.Alphabetic;
return personName ? utils.formatPN(personName) : undefined;
},
DATE: contentItem => {
@@ -1,11 +1,17 @@
import OHIF from '@ohif/core';
import { annotation } from '@cornerstonejs/tools';
import { NO_IMAGE_ID } from '@cornerstonejs/adapters';
const { log } = OHIF;
function getFilteredCornerstoneToolState(measurementData, additionalFindingTypes) {
const filteredToolState = {};
function addToFilteredToolState(annotation, toolType) {
const imageId = annotation.metadata?.referencedImageId ?? NO_IMAGE_ID;
if (!annotation.metadata?.referencedImageId) {
log.warn(`[DICOMSR] No referencedImageId found for ${toolType} ${annotation.id}`);
return;
}
const imageId = annotation.metadata.referencedImageId;
if (!filteredToolState[imageId]) {
filteredToolState[imageId] = {};
@@ -11,11 +11,7 @@ const { CodeScheme: Cornerstone3DCodeScheme } = adaptersSR.Cornerstone3D;
* @returns {string} The extracted label.
*/
export default function getLabelFromDCMJSImportedToolData(toolData) {
const { findingSites = [], finding, annotation } = toolData;
if (annotation.data.label) {
return annotation.data.label;
}
const { findingSites = [], finding } = toolData;
let freeTextLabel = findingSites.find(
fs => fs.CodeValue === Cornerstone3DCodeScheme.codeValues.CORNERSTONEFREETEXT
@@ -36,14 +36,6 @@ function getRenderableData({ GraphicType, GraphicData, ValueType, imageId }) {
ValueType,
imageId,
});
if (!imageId) {
// without the image id it's not possible to perform the calculations below
// these calculations also do not seem to be needed, since everything works
// just fine when we skip them. At least for SCOORD3D annotations.
return pointsWorld;
}
// We do not have an explicit draw circle svg helper in Cornerstone3D at
// this time, but we can use the ellipse svg helper to draw a circle, so
// here we reshape the data for that purpose.
@@ -98,13 +90,6 @@ function getRenderableData({ GraphicType, GraphicData, ValueType, imageId }) {
imageId,
});
if (!imageId) {
// without the image id it's not possible to perform the calculations below
// these calculations also do not seem to be needed, since everything works
// just fine when we skip them. At least for SCOORD3D annotations.
return pointsWorld;
}
const majorAxisStart = vec3.fromValues(...pointsWorld[0]);
const majorAxisEnd = vec3.fromValues(...pointsWorld[1]);
const minorAxisStart = vec3.fromValues(...pointsWorld[2]);
@@ -1,16 +1,15 @@
import { utilities, metaData, type Types } from '@cornerstonejs/core';
import { utilities, metaData } from '@cornerstonejs/core';
import OHIF, { DicomMetadataStore } from '@ohif/core';
import { vec3 } from 'gl-matrix';
import getLabelFromDCMJSImportedToolData from './getLabelFromDCMJSImportedToolData';
import { adaptersSR } from '@cornerstonejs/adapters';
import { annotation as CsAnnotation, type Types as ToolTypes } from '@cornerstonejs/tools';
import { annotation as CsAnnotation } from '@cornerstonejs/tools';
import { Enums as CSExtensionEnums } from '@ohif/extension-cornerstone';
const { locking } = CsAnnotation;
const { guid } = OHIF.utils;
const { MeasurementReport } = adaptersSR.Cornerstone3D;
const { MeasurementReport, CORNERSTONE_3D_TAG } = adaptersSR.Cornerstone3D;
const { CORNERSTONE_3D_TOOLS_SOURCE_NAME, CORNERSTONE_3D_TOOLS_SOURCE_VERSION } = CSExtensionEnums;
const supportedLegacyCornerstoneTags = ['cornerstoneTools@^4.0.0'];
const convertCode = (codingValues, code) => {
if (!code || code.CodingSchemeDesignator === 'CORNERSTONEJS') {
@@ -38,20 +37,26 @@ const convertSites = (codingValues, sites) => {
};
/**
* Hydrates a structured report
* Handles 2d and 3d hydration from SCOORD and SCOORD3D points
* For 3D hydration, chooses a volume display set to display with
* FOr 2D hydration, chooses the (first) display set containing the referenced image.
* Hydrates a structured report, for default viewports.
*
*/
export default function hydrateStructuredReport(
{ servicesManager, extensionManager, commandsManager }: withAppTypes,
{ servicesManager, extensionManager, appConfig }: withAppTypes,
displaySetInstanceUID
) {
const annotationManager = CsAnnotation.state.getAnnotationManager();
const dataSource = extensionManager.getActiveDataSource()[0];
const { measurementService, displaySetService, customizationService } = servicesManager.services;
const codingValues = customizationService.getCustomization('codingValues');
const disableEditing = customizationService.getCustomization('panelMeasurement.disableEditing');
const codingValues = customizationService.getCustomization('codingValues', {});
const { disableEditing } = customizationService.getCustomization(
'PanelMeasurement.disableEditing',
{
id: 'default.disableEditing',
disableEditing: false,
}
);
const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
@@ -88,10 +93,9 @@ export default function hydrateStructuredReport(
}
});
// Mapping of legacy datasets is now directly handled by adapters module
const datasetToUse = instance;
const datasetToUse = _mapLegacyDataSet(instance);
// Use CS3D adapters to generate toolState.
// Use dcmjs to generate toolState.
let storedMeasurementByAnnotationType = MeasurementReport.generateToolState(
datasetToUse,
// NOTE: we need to pass in the imageIds to dcmjs since the we use them
@@ -99,10 +103,12 @@ export default function hydrateStructuredReport(
// that measurements were added to the display set are the same order as
// the measurementGroups in the instance.
sopInstanceUIDToImageId,
utilities.imageToWorldCoords,
metaData
);
const onBeforeSRHydration = customizationService.getCustomization('onBeforeSRHydration')?.value;
const onBeforeSRHydration =
customizationService.getModeCustomization('onBeforeSRHydration')?.value;
if (typeof onBeforeSRHydration === 'function') {
storedMeasurementByAnnotationType = onBeforeSRHydration({
@@ -149,9 +155,6 @@ export default function hydrateStructuredReport(
for (let i = 0; i < imageIds.length; i++) {
const imageId = imageIds[i];
if (!imageId) {
continue;
}
const { SeriesInstanceUID, StudyInstanceUID } = metaData.get('instance', imageId);
if (!SeriesInstanceUIDs.includes(SeriesInstanceUID)) {
@@ -165,55 +168,38 @@ export default function hydrateStructuredReport(
}
}
/**
* Gets reference data for what frame of reference and the referenced
* image id, or for 3d measurements, the volumeId to apply this annotation to.
*/
function getReferenceData(toolData): ToolTypes.AnnotationMetadata {
// Add the measurement to toolState
// dcmjs and Cornerstone3D has structural defect in supporting multi-frame
// files, and looking up the imageId from sopInstanceUIDToImageId results
// in the wrong value.
const frameNumber = (toolData.annotation.data && toolData.annotation.data.frameNumber) || 1;
const imageId =
imageIdsForToolState[toolData.sopInstanceUid][frameNumber] ||
sopInstanceUIDToImageId[toolData.sopInstanceUid];
if (!imageId) {
return getReferenceData3D(toolData, servicesManager);
}
const instance = metaData.get('instance', imageId);
const {
FrameOfReferenceUID,
// SOPInstanceUID,
// SeriesInstanceUID,
// StudyInstanceUID,
} = instance;
return {
referencedImageId: imageId,
FrameOfReferenceUID,
};
}
Object.keys(hydratableMeasurementsInSR).forEach(annotationType => {
const toolDataForAnnotationType = hydratableMeasurementsInSR[annotationType];
toolDataForAnnotationType.forEach(toolData => {
// Add the measurement to toolState
// dcmjs and Cornerstone3D has structural defect in supporting multi-frame
// files, and looking up the imageId from sopInstanceUIDToImageId results
// in the wrong value.
const frameNumber = (toolData.annotation.data && toolData.annotation.data.frameNumber) || 1;
const imageId =
imageIdsForToolState[toolData.sopInstanceUid][frameNumber] ||
sopInstanceUIDToImageId[toolData.sopInstanceUid];
toolData.uid = guid();
const referenceData = getReferenceData(toolData);
const { imageId } = referenceData;
const instance = metaData.get('instance', imageId);
const {
FrameOfReferenceUID,
// SOPInstanceUID,
// SeriesInstanceUID,
// StudyInstanceUID,
} = instance;
const annotation = {
annotationUID: toolData.annotation.annotationUID,
data: toolData.annotation.data,
metadata: {
...referenceData,
toolName: annotationType,
referencedImageId: imageId,
FrameOfReferenceUID,
},
};
utilities.updatePlaneRestriction(annotation.data.handles.points, annotation.metadata);
const source = measurementService.getSource(
CORNERSTONE_3D_TOOLS_SOURCE_NAME,
@@ -222,11 +208,7 @@ export default function hydrateStructuredReport(
annotation.data.label = getLabelFromDCMJSImportedToolData(toolData);
annotation.data.finding = convertCode(codingValues, toolData.finding?.[0]);
annotation.data.findingSites = convertSites(codingValues, toolData.findingSites);
annotation.data.findingSites?.forEach(site => {
if (site.type) {
annotation.data[site.type] = site;
}
});
annotation.data.site = annotation.data.findingSites?.[0];
const matchingMapping = mappings.find(m => m.annotationType === annotationType);
@@ -238,16 +220,12 @@ export default function hydrateStructuredReport(
dataSource
);
commandsManager.runCommand('updateMeasurement', {
uid: newAnnotationUID,
code: annotation.data.finding,
});
if (disableEditing) {
locking.setAnnotationLocked(newAnnotationUID, true);
const addedAnnotation = annotationManager.getAnnotation(newAnnotationUID);
locking.setAnnotationLocked(addedAnnotation, true);
}
if (imageId && !imageIds.includes(imageId)) {
if (!imageIds.includes(imageId)) {
imageIds.push(imageId);
}
});
@@ -261,91 +239,61 @@ export default function hydrateStructuredReport(
};
}
/**
* For 3d annotations, there are often several display sets which could
* be used to display the annotation. Choose the first annotation with the
* same frame of reference that is reconstructable, or the first display set
* otherwise.
*/
function chooseDisplaySet(displaySets, annotation) {
if (!displaySets?.length) {
console.warn('No display set found for', annotation);
return;
}
if (displaySets.length === 1) {
return displaySets[0];
}
const volumeDs = displaySets.find(ds => ds.isReconstructable);
if (volumeDs) {
return volumeDs;
}
return displaySets[0];
}
function _mapLegacyDataSet(dataset) {
const REPORT = 'Imaging Measurements';
const GROUP = 'Measurement Group';
const TRACKING_IDENTIFIER = 'Tracking Identifier';
/**
* Gets the additional reference data appropriate for a 3d reference.
* This will choose a volume id, frame of reference and a plane restriction.
*/
function getReferenceData3D(toolData, servicesManager: Types.ServicesManager) {
const { FrameOfReferenceUID } = toolData.annotation.metadata;
const { points } = toolData.annotation.data.handles;
const { displaySetService } = servicesManager.services;
const displaySetsFOR = displaySetService.getDisplaySetsBy(
ds => ds.FrameOfReferenceUID === FrameOfReferenceUID
// Identify the Imaging Measurements
const imagingMeasurementContent = toArray(dataset.ContentSequence).find(
codeMeaningEquals(REPORT)
);
if (!displaySetsFOR.length || !points?.length) {
return {
FrameOfReferenceUID,
};
}
const ds = chooseDisplaySet(displaySetsFOR, toolData.annotation);
const cameraView = chooseCameraView(ds, points);
const viewReference = {
...cameraView,
volumeId: ds.displaySetInstanceUID,
FrameOfReferenceUID,
// Retrieve the Measurements themselves
const measurementGroups = toArray(imagingMeasurementContent.ContentSequence).filter(
codeMeaningEquals(GROUP)
);
// For each of the supported measurement types, compute the measurement data
const measurementData = {};
const cornerstoneToolClasses = MeasurementReport.CORNERSTONE_TOOL_CLASSES_BY_UTILITY_TYPE;
const registeredToolClasses = [];
Object.keys(cornerstoneToolClasses).forEach(key => {
registeredToolClasses.push(cornerstoneToolClasses[key]);
measurementData[key] = [];
});
measurementGroups.forEach((measurementGroup, index) => {
const measurementGroupContentSequence = toArray(measurementGroup.ContentSequence);
const TrackingIdentifierGroup = measurementGroupContentSequence.find(
contentItem => contentItem.ConceptNameCodeSequence.CodeMeaning === TRACKING_IDENTIFIER
);
const TrackingIdentifier = TrackingIdentifierGroup.TextValue;
let [cornerstoneTag, toolName] = TrackingIdentifier.split(':');
if (supportedLegacyCornerstoneTags.includes(cornerstoneTag)) {
cornerstoneTag = CORNERSTONE_3D_TAG;
}
const mappedTrackingIdentifier = `${cornerstoneTag}:${toolName}`;
TrackingIdentifierGroup.TextValue = mappedTrackingIdentifier;
});
return dataset;
}
const toArray = function (x) {
return Array.isArray(x) ? x : [x];
};
const codeMeaningEquals = codeMeaningName => {
return contentItem => {
return contentItem.ConceptNameCodeSequence.CodeMeaning === codeMeaningName;
};
utilities.updatePlaneRestriction(points, viewReference);
return viewReference;
}
/**
* Chooses a possible camera view - right now this is fairly basic,
* just setting the unknowns to null.
*/
function chooseCameraView(_ds, points) {
const selectedPoints = choosePoints(points);
const cameraFocalPoint = <Point3>centerOf(selectedPoints);
// These are sufficient to be null for now and can be set on first view
let viewPlaneNormal: Types.Point3 = null;
let viewUp: Types.Point3 = null;
return {
cameraFocalPoint,
viewPlaneNormal,
viewUp,
};
}
function centerOf(points) {
const scale = 1 / points.length;
const center = vec3.create();
for (const point of points) {
vec3.scaleAndAdd(center, center, point, scale);
}
return center;
}
function choosePoints(points) {
if (points.length === 1 || points.length === 2) {
return points;
}
const firstIndex = 0;
const secondIndex = Math.ceil(points.length / 4);
const thirdIndex = Math.ceil(points.length / 2);
// TODO - check if colinear, if so try to find another 3 points.
const newPoints = [points[firstIndex], points[secondIndex], points[thirdIndex]];
return newPoints;
}
};
@@ -0,0 +1,60 @@
import { adaptersSR } from '@cornerstonejs/adapters';
const cornerstoneAdapters =
adaptersSR.Cornerstone3D.MeasurementReport.CORNERSTONE_TOOL_CLASSES_BY_UTILITY_TYPE;
const supportedLegacyCornerstoneTags = ['cornerstoneTools@^4.0.0'];
const CORNERSTONE_3D_TAG = adaptersSR.Cornerstone3D.CORNERSTONE_3D_TAG;
/**
* Checks if the given `displaySet`can be rehydrated into the `measurementService`.
*
* @param {object} displaySet The SR `displaySet` to check.
* @param {object[]} mappings The CornerstoneTools 4 mappings to the `measurementService`.
* @returns {boolean} True if the SR can be rehydrated into the `measurementService`.
*/
export default function isRehydratable(displaySet, mappings) {
if (!mappings || !mappings.length) {
return false;
}
const mappingDefinitions = mappings.map(m => m.annotationType);
const { measurements } = displaySet;
const adapterKeys = Object.keys(cornerstoneAdapters).filter(
adapterKey =>
typeof cornerstoneAdapters[adapterKey].isValidCornerstoneTrackingIdentifier === 'function'
);
const adapters = [];
adapterKeys.forEach(key => {
if (mappingDefinitions.includes(key)) {
// Must have both a dcmjs adapter and a measurementService
// Definition in order to be a candidate for import.
adapters.push(cornerstoneAdapters[key]);
}
});
for (let i = 0; i < measurements.length; i++) {
const { TrackingIdentifier } = measurements[i] || {};
const hydratable = adapters.some(adapter => {
let [cornerstoneTag, toolName] = TrackingIdentifier.split(':');
if (supportedLegacyCornerstoneTags.includes(cornerstoneTag)) {
cornerstoneTag = CORNERSTONE_3D_TAG;
}
const mappedTrackingIdentifier = `${cornerstoneTag}:${toolName}`;
return adapter.isValidCornerstoneTrackingIdentifier(mappedTrackingIdentifier);
});
if (hydratable) {
return true;
}
console.log('Measurement is not rehydratable', TrackingIdentifier, measurements[i]);
}
console.log('No measurements found which were rehydratable');
return false;
}
@@ -1,48 +0,0 @@
import { adaptersSR } from '@cornerstonejs/adapters';
const { MeasurementReport } = adaptersSR.Cornerstone3D;
/**
* Checks if the given `displaySet`can be rehydrated into the `measurementService`.
*
* @param {object} displaySet The SR `displaySet` to check.
* @param {object[]} mappings The CornerstoneTools 4 mappings to the `measurementService`.
* @returns {boolean} True if the SR can be rehydrated into the `measurementService`.
*/
export default function isRehydratable(displaySet, mappings) {
if (!mappings || !mappings.length) {
return false;
}
const mappingDefinitions = new Set<string>();
for (const m of mappings) {
mappingDefinitions.add(m.annotationType);
}
const { measurements } = displaySet;
for (let i = 0; i < measurements.length; i++) {
const measurement = measurements[i];
if (!measurement) {
continue;
}
const { TrackingIdentifier = '', graphicType, graphicCode, pointsLength } = measurement;
if (!TrackingIdentifier && !graphicType) {
console.warn('No tracking identifier or graphicType for measurement ', measurement);
continue;
}
const adapter = MeasurementReport.getAdapterForTrackingIdentifier(TrackingIdentifier);
const adapters = MeasurementReport.getAdaptersForTypes(graphicCode, graphicType, pointsLength);
const hydratable =
(adapter && mappingDefinitions.has(adapter.toolType)) ||
(adapters && adapters.some(adapter => mappingDefinitions.has(adapter.toolType)));
if (hydratable) {
return true;
}
console.log('Measurement is not rehydratable', TrackingIdentifier, measurements[i]);
}
console.log('No measurements found which were rehydratable');
return false;
}
File diff suppressed because it is too large. Load diff
@@ -1,6 +1,6 @@
{
"name": "@ohif/extension-cornerstone-dynamic-volume",
"version": "3.12.0-beta.43",
"version": "3.9.2",
"description": "OHIF extension for 4D volumes data",
"author": "OHIF",
"license": "MIT",
@@ -29,12 +29,12 @@
"test:unit:ci": "jest --ci --runInBand --collectCoverage --passWithNoTests"
},
"peerDependencies": {
"@ohif/core": "3.12.0-beta.43",
"@ohif/extension-cornerstone": "3.12.0-beta.43",
"@ohif/extension-default": "3.12.0-beta.43",
"@ohif/i18n": "3.12.0-beta.43",
"@ohif/ui": "3.12.0-beta.43",
"dcmjs": "0.43.1",
"@ohif/core": "3.9.2",
"@ohif/extension-cornerstone": "3.9.2",
"@ohif/extension-default": "3.9.2",
"@ohif/i18n": "3.9.2",
"@ohif/ui": "3.9.2",
"dcmjs": "*",
"dicom-parser": "^1.8.21",
"hammerjs": "^2.0.8",
"prop-types": "^15.6.2",
@@ -42,8 +42,8 @@
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/core": "4.3.14",
"@cornerstonejs/tools": "4.3.14",
"@cornerstonejs/core": "^2.2.20",
"@cornerstonejs/tools": "^2.2.20",
"classnames": "^2.3.2"
}
}
@@ -262,6 +262,7 @@ function _getInstanceFromSegmentations(segmentations, { servicesManager }) {
}
function updateSegmentationsChartDisplaySet({ servicesManager }: withAppTypes): void {
debugger;
const { segmentationService } = servicesManager.services;
const segmentations = segmentationService.getSegmentations();
const { seriesMetadata, instance } =
@@ -263,7 +263,7 @@ const commandsModule = ({ commandsManager, servicesManager }: withAppTypes) => {
}
}
commandsManager.run('setDisplaySetsForViewports', { viewportsToUpdate });
viewportGridService.setDisplaySetsForViewports(viewportsToUpdate);
},
swapComputedWithDynamicDisplaySet: () => {
// Todo: this assumes there is only one dynamic display set in the viewer
@@ -313,7 +313,7 @@ const commandsModule = ({ commandsManager, servicesManager }: withAppTypes) => {
}
}
commandsManager.run('setDisplaySetsForViewports', { viewportsToUpdate });
viewportGridService.setDisplaySetsForViewports(viewportsToUpdate);
},
createNewLabelMapForDynamicVolume: async ({ label }) => {
const { viewports, activeViewportId } = viewportGridService.getState();
@@ -1,12 +1,10 @@
import React from 'react';
import { DynamicDataPanel } from './panels';
import { Toolbox } from '@ohif/extension-default';
import { Toolbox } from '@ohif/ui-next';
import { PanelSegmentation } from '@ohif/extension-cornerstone';
import DynamicExport from './panels/DynamicExport';
function getPanelModule({ commandsManager, extensionManager, servicesManager, configuration }) {
const { toolbarService } = servicesManager.services;
const wrappedDynamicDataPanel = () => {
return (
<DynamicDataPanel
@@ -21,7 +19,10 @@ function getPanelModule({ commandsManager, extensionManager, servicesManager, co
return (
<>
<Toolbox
buttonSectionId={toolbarService.sections.dynamicToolbox}
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
buttonSectionId="dynamic-toolbox"
title="Threshold Tools"
/>
<PanelSegmentation
@@ -1,45 +1,38 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import {
Tabs,
TabsList,
TabsTrigger,
Button,
PanelSection,
Icons,
ButtonGroup,
IconButton,
InputNumber,
Icon,
Tooltip,
TooltipTrigger,
TooltipContent,
Numeric,
} from '@ohif/ui-next';
} from '@ohif/ui';
import { DoubleSlider } from '@ohif/ui-next';
import { Enums } from '@cornerstonejs/core';
// Helper function to safely convert any value to uppercase string
const toUpperCaseString = value => {
if (value === null || value === undefined) {
return '';
}
return String(value).toUpperCase();
const controlClassNames = {
sizeClassName: 'w-[58px] h-[28px]',
arrowsDirection: 'horizontal',
labelPosition: 'bottom',
};
const Header = ({ title, tooltip }) => (
<div className="flex items-center space-x-1">
<Tooltip>
<TooltipTrigger asChild>
<span>
<Icons.ByName
name="info-link"
className="text-primary h-3 w-3"
/>
</span>
</TooltipTrigger>
<TooltipContent
sideOffset={4}
className="max-w-xs"
>
<div>{tooltip}</div>
</TooltipContent>
<Tooltip
content={<div className="text-white">{tooltip}</div>}
position="bottom-left"
tight={true}
tooltipBoxClassName="max-w-xs p-2"
>
<Icon
name="info-link"
className="text-primary-active h-[14px] w-[14px]"
/>
</Tooltip>
<span className="text-muted-foreground text-xs uppercase tracking-wide">{title}</span>
<span className="text-aqua-pale text-[11px] uppercase">{title}</span>
</div>
);
@@ -52,149 +45,132 @@ const DynamicVolumeControls = ({
minFps,
maxFps,
// Frames
currentDimensionGroupNumber,
onDimensionGroupChange,
numDimensionGroups,
currentFrameIndex,
onFrameChange,
framesLength,
onGenerate,
onDoubleRangeChange,
rangeValues,
onDynamicClick,
}) => {
const [computedView, setComputedView] = useState(false);
const [computeViewMode, setComputeViewMode] = useState(Enums.DynamicOperatorType.SUM);
// Wrapper for onGenerate to handle potential errors
const handleGenerate = () => {
try {
if (typeof onGenerate === 'function') {
onGenerate(computeViewMode);
} else {
console.error('onGenerate is not a function', onGenerate);
}
} catch (error) {
console.error('Error in onGenerate:', error);
}
const [sliderRangeValues, setSliderRangeValues] = useState([0, framesLength - 1]);
const handleSliderChange = newValues => {
onDoubleRangeChange(newValues);
setSliderRangeValues(newValues);
};
const formatLabel = value => Math.round(value);
return (
<div className="flex select-none flex-col">
<PanelSection defaultOpen={true}>
<PanelSection.Header>Controls</PanelSection.Header>
<PanelSection.Content className="bg-muted space-y-4 px-5 pt-2 pb-4">
<div className="mb-4">
<Header
title="View"
tooltip={
'Select the view mode, 4D to view the dynamic volume or Computed to view the computed volume'
}
/>
<Tabs
value={computedView ? 'computed' : '4d'}
onValueChange={value => {
const isComputed = value === 'computed';
setComputedView(isComputed);
if (!isComputed && typeof onDynamicClick === 'function') {
onDynamicClick();
}
<PanelSection
title="Controls"
childrenClassName="space-y-4 pb-5 px-5"
>
<div className="mt-2">
<Header
title="View"
tooltip={
'Select the view mode, 4D to view the dynamic volume or Computed to view the computed volume'
}
/>
<ButtonGroup className="mt-2 w-full">
<button
className="w-1/2"
onClick={() => {
setComputedView(false);
onDynamicClick?.();
}}
className="my-2 w-full"
>
<TabsList className="w-full">
<TabsTrigger
value="4d"
className="w-1/2"
>
4D
</TabsTrigger>
<TabsTrigger
value="computed"
className="w-1/2"
>
Computed
</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div>
<DimensionGroupControls
onPlayPauseChange={onPlayPauseChange}
isPlaying={isPlaying}
computedView={computedView}
// fps
fps={fps}
onFpsChange={onFpsChange}
minFps={minFps}
maxFps={maxFps}
//
numDimensionGroups={numDimensionGroups}
onDimensionGroupChange={onDimensionGroupChange}
currentDimensionGroupNumber={currentDimensionGroupNumber}
/>
</div>
<div className={`mt-3 flex flex-col ${computedView ? '' : 'ohif-disabled'}`}>
<Header
title="Computed Operation"
tooltip={
<div>
Operation Buttons (SUM, AVERAGE, SUBTRACT): Select the mathematical operation to
be applied to the data set.
<br /> Range Slider: Choose the numeric range of dimension groups within which the
operation will be performed.
<br />
Generate Button: Execute the chosen operation on the specified range of data.
</div>
}
/>
<Tabs
value={String(computeViewMode)}
onValueChange={value => {
setComputeViewMode(value);
4D
</button>
<button
className="w-1/2"
onClick={() => {
setComputedView(true);
}}
className="mt-2 w-full"
>
<TabsList className="w-full gap-1">
{' '}
<TabsTrigger
value={String(Enums.DynamicOperatorType.SUM)}
className="w-1/3"
>
{toUpperCaseString(Enums.DynamicOperatorType.SUM)}
</TabsTrigger>
<TabsTrigger
value={String(Enums.DynamicOperatorType.AVERAGE)}
className="w-1/3"
>
{toUpperCaseString(Enums.DynamicOperatorType.AVERAGE)}
</TabsTrigger>
<TabsTrigger
value={String(Enums.DynamicOperatorType.SUBTRACT)}
className="w-1/3"
>
{toUpperCaseString(Enums.DynamicOperatorType.SUBTRACT)}
</TabsTrigger>
</TabsList>
</Tabs>
<div className="mt-2 w-full">
<Numeric.Container
mode="doubleRange"
min={1}
max={numDimensionGroups || 1}
values={rangeValues || [1, numDimensionGroups || 1]}
onChange={onDoubleRangeChange || (() => {})}
>
<Numeric.DoubleRange showNumberInputs />
</Numeric.Container>
</div>
<Button
variant="default"
size="sm"
className="mt-2 h-[26px] w-[115px] self-start p-0"
onClick={handleGenerate}
Computed
</button>
</ButtonGroup>
</div>
<div>
<FrameControls
onPlayPauseChange={onPlayPauseChange}
isPlaying={isPlaying}
computedView={computedView}
// fps
fps={fps}
onFpsChange={onFpsChange}
minFps={minFps}
maxFps={maxFps}
//
framesLength={framesLength}
onFrameChange={onFrameChange}
currentFrameIndex={currentFrameIndex}
/>
</div>
<div className={`mt-6 flex flex-col ${computedView ? '' : 'ohif-disabled'}`}>
<Header
title="Computed Operation"
tooltip={
<div>
Operation Buttons (SUM, AVERAGE, SUBTRACT): Select the mathematical operation to be
applied to the data set.
<br></br> Range Slider: Choose the numeric range within which the operation will be
performed.
<br></br>Generate Button: Execute the chosen operation on the specified range of
data.{' '}
</div>
}
/>
<ButtonGroup
className={`mt-2 w-full`}
separated={true}
>
<button
className="w-1/2"
onClick={() => setComputeViewMode(Enums.DynamicOperatorType.SUM)}
>
Generate
</Button>
{Enums.DynamicOperatorType.SUM.toString().toUpperCase()}
</button>
<button
className="w-1/2"
onClick={() => setComputeViewMode(Enums.DynamicOperatorType.AVERAGE)}
>
{Enums.DynamicOperatorType.AVERAGE.toString().toUpperCase()}
</button>
<button
className="w-1/2"
onClick={() => setComputeViewMode(Enums.DynamicOperatorType.SUBTRACT)}
>
{Enums.DynamicOperatorType.SUBTRACT.toString().toUpperCase()}
</button>
</ButtonGroup>
<div className="mt-2 w-full">
<DoubleSlider
min={0}
max={framesLength - 1}
step={1}
defaultValue={sliderRangeValues}
onValueChange={handleSliderChange}
formatLabel={formatLabel}
className="w-full"
/>
</div>
</PanelSection.Content>
<Button
className="mt-2 !h-[26px] !w-[115px] self-start !p-0"
onClick={() => {
onGenerate(computeViewMode);
}}
>
Generate
</Button>
</div>
</PanelSection>
</div>
);
@@ -202,84 +178,58 @@ const DynamicVolumeControls = ({
export default DynamicVolumeControls;
function DimensionGroupControls({
function FrameControls({
isPlaying,
onPlayPauseChange,
fps,
minFps,
maxFps,
onFpsChange,
numDimensionGroups,
onDimensionGroupChange,
currentDimensionGroupNumber,
framesLength,
onFrameChange,
currentFrameIndex,
computedView,
}) {
const getPlayPauseIconName = () => (isPlaying ? 'icon-pause' : 'icon-play');
return (
<div className={computedView ? 'ohif-disabled' : ''}>
<div className={computedView && 'ohif-disabled'}>
<Header
title="4D Controls"
tooltip={
<div>
Play/Pause Button: Begin or pause the animation of the 4D visualization. <br />
Dimension Group Selector: Navigate through individual dimension groups of the 4D data.{' '}
<br />
FPS (Frames Per Second) Selector: Adjust the playback speed of the animation.
Play/Pause Button: Begin or pause the animation of the 4D visualization. <br></br> Frame
Selector: Navigate through individual frames of the 4D data. <br></br> FPS (Frames Per
Second) Selector: Adjust the playback speed of the animation.
</div>
}
/>
<div className="mt-3 flex justify-between">
<Button
id="play-pause-button"
variant="secondary"
size="default"
className="w-[58px]"
onClick={() => {
if (typeof onPlayPauseChange === 'function') {
onPlayPauseChange(!isPlaying);
}
}}
<IconButton
className="bg-customblue-30 h-[26px] w-[58px] rounded-[4px]"
onClick={() => onPlayPauseChange(!isPlaying)}
>
<Icons.ByName
<Icon
name={getPlayPauseIconName()}
className="text-foreground h-[24px] w-[24px]"
className="active:text-primary-light hover:bg-customblue-300 h-[24px] w-[24px] cursor-pointer text-white"
/>
</Button>
<Numeric.Container
mode="stepper"
value={currentDimensionGroupNumber || 1}
onChange={onDimensionGroupChange || (() => {})}
min={1}
max={numDimensionGroups || 1}
step={1}
>
<div className="flex flex-col items-center">
<Numeric.NumberStepper
className="h-[28px] w-[58px]"
direction="horizontal"
/>
<Numeric.Label className="text-muted-foreground mt-1 text-sm">Frame</Numeric.Label>
</div>
</Numeric.Container>
<Numeric.Container
mode="stepper"
value={fps || 1}
onChange={onFpsChange || (() => {})}
min={minFps || 1}
max={maxFps || 30}
step={1}
>
<div className="flex flex-col items-center">
<Numeric.NumberStepper
className="h-[28px] w-[58px]"
direction="horizontal"
/>
<Numeric.Label className="text-muted-foreground mt-1 text-sm">FPS</Numeric.Label>
</div>
</Numeric.Container>
</IconButton>
<InputNumber
value={currentFrameIndex}
onChange={onFrameChange}
minValue={0}
maxValue={framesLength - 1}
label="Frame"
{...controlClassNames}
/>
<InputNumber
value={fps}
onChange={onFpsChange}
minValue={minFps}
maxValue={maxFps}
{...controlClassNames}
label="FPS"
/>
</div>
</div>
);
@@ -0,0 +1,71 @@
import React from 'react';
import { InputDoubleRange } from '@ohif/ui';
import { Select } from '@ohif/ui';
import { Button } from '@ohif/ui';
import PropTypes from 'prop-types';
const GenerateVolume = ({
rangeValues,
handleSliderChange,
operationsUI,
options,
handleGenerateOptionsChange,
onGenerateImage,
returnTo4D,
displayingComputedVolume,
}) => {
return (
<>
<div>
<div className="mb-2 text-white">Computed Image</div>
<Select
closeMenuOnSelect={true}
className="border-primary-main mr-2 bg-black text-white "
options={operationsUI}
placeholder={operationsUI.find(option => option.value === options.Operation).placeHolder}
value={options.Operation}
onChange={({ value }) => {
handleGenerateOptionsChange({
Operation: value,
});
}}
/>
<InputDoubleRange
values={rangeValues}
onChange={handleSliderChange}
minValue={rangeValues[0] || 1}
maxValue={rangeValues[1] || 2}
showLabel={true}
step={1}
/>
<div className="flex space-x-2">
<Button
onClick={onGenerateImage}
className="w-1/2"
>
Generate
</Button>
<Button
onClick={returnTo4D}
disabled={!displayingComputedVolume}
className="w-1/2"
>
Return To 4D
</Button>
</div>
</div>
</>
);
};
GenerateVolume.propTypes = {
rangeValues: PropTypes.array.isRequired,
handleSliderChange: PropTypes.func.isRequired,
operationsUI: PropTypes.array.isRequired,
options: PropTypes.object.isRequired,
handleGenerateOptionsChange: PropTypes.func.isRequired,
onGenerateImage: PropTypes.func.isRequired,
returnTo4D: PropTypes.func.isRequired,
};
export default GenerateVolume;
@@ -1,6 +1,5 @@
import React, { useEffect, useState, useRef } from 'react';
import { useCine } from '@ohif/ui-next';
import { useViewportGrid } from '@ohif/ui-next';
import { useCine, useViewportGrid } from '@ohif/ui';
import { utilities as csUtils, volumeLoader, eventTarget, Enums, cache } from '@cornerstonejs/core';
import { utilities as cstUtils } from '@cornerstonejs/tools';
import DynamicVolumeControls from './DynamicVolumeControls';
@@ -15,12 +14,13 @@ export default function PanelGenerateImage({ servicesManager, commandsManager }:
const [{ activeViewportId }] = useViewportGrid();
//
const [dimensionGroupRange, setDimensionGroupRange] = useState([1, 1]);
const [timePointsRange, setTimePointsRange] = useState([0, 0]);
const [timePointsRangeToUseForGenerate, setTimePointsRangeToUseForGenerate] = useState([0, 0]);
const [computedDisplaySet, setComputedDisplaySet] = useState(null);
const [dynamicVolume, setDynamicVolume] = useState(null);
const [frameRate, setFrameRate] = useState(20);
const [isPlaying, setIsPlaying] = useState(isCineEnabled);
const [dimensionGroupNumberRendered, setDimensionGroupNumberRendered] = useState(null);
const [timePointRendered, setTimePointRendered] = useState(null);
const [displayingComputed, setDisplayingComputed] = useState(false);
//
@@ -37,8 +37,9 @@ export default function PanelGenerateImage({ servicesManager, commandsManager }:
if (volumeData.volume?.isDynamicVolume()) {
setDynamicVolume(volumeData.volume);
uuidDynamicVolume.current = volumeData.displaySetInstanceUID;
const newRange = [1, volumeData.volume.numDimensionGroups];
setDimensionGroupRange(newRange);
const newRange = [1, volumeData.volume.numTimePoints];
setTimePointsRange(newRange);
setTimePointsRangeToUseForGenerate(newRange);
}
});
};
@@ -63,10 +64,10 @@ export default function PanelGenerateImage({ servicesManager, commandsManager }:
}, [cornerstoneViewportService, cineService, servicesManager.services.cineService]);
useEffect(() => {
const evt = Enums.Events.DYNAMIC_VOLUME_DIMENSION_GROUP_CHANGED;
const evt = Enums.Events.DYNAMIC_VOLUME_TIME_POINT_INDEX_CHANGED;
const callback = evt => {
setDimensionGroupNumberRendered(evt.detail.dimensionGroupNumber);
setTimePointRendered(evt.detail.timePointIndex);
};
eventTarget.addEventListener(evt, callback);
@@ -74,7 +75,7 @@ export default function PanelGenerateImage({ servicesManager, commandsManager }:
return () => {
eventTarget.removeEventListener(evt, callback);
};
}, []);
}, [cornerstoneViewportService]);
useEffect(() => {
const displaySetUIDs = viewportGridService.getDisplaySetsUIDsForViewport(activeViewportId);
@@ -100,7 +101,9 @@ export default function PanelGenerateImage({ servicesManager, commandsManager }:
setDynamicVolume(dynamicVolume);
uuidDynamicVolume.current = dynamicVolumeDisplaySet.displaySetInstanceUID;
setDimensionGroupRange([1, dynamicVolume.numDimensionGroups]);
const newRange = [1, dynamicVolume.numTimePoints];
setTimePointsRange(newRange);
setTimePointsRangeToUseForGenerate(newRange);
}, [
activeViewportId,
viewportGridService,
@@ -137,12 +140,11 @@ export default function PanelGenerateImage({ servicesManager, commandsManager }:
volumeId: computedVolumeId,
});
}
const [start, end] = dimensionGroupRange;
// from start to end, with steps of 1
const frameNumbers = Array.from({ length: end - start + 1 }, (_, i) => start + i);
const [start, end] = timePointsRangeToUseForGenerate;
const frameNumbers = Array.from({ length: end - start + 1 }, (_, i) => i + start - 1);
const options = {
dimensionGroupNumbers: operationName === 'SUBTRACT' ? [start, end] : frameNumbers,
frameNumbers: operationName === 'SUBTRACT' ? [start, end - 1] : frameNumbers,
targetVolume: computedVolume,
};
@@ -204,6 +206,21 @@ export default function PanelGenerateImage({ servicesManager, commandsManager }:
handlePlay();
};
function handleSliderChange(newValues) {
if (
newValues[0] === timePointsRangeToUseForGenerate[0] &&
newValues[1] === timePointsRangeToUseForGenerate[1]
) {
return;
}
setTimePointsRangeToUseForGenerate(newValues);
}
if (!dynamicVolume || timePointsRange.length === 0) {
return null;
}
return (
<DynamicVolumeControls
fps={frameRate}
@@ -211,16 +228,16 @@ export default function PanelGenerateImage({ servicesManager, commandsManager }:
onPlayPauseChange={onPlayPauseChange}
minFps={1}
maxFps={50}
currentFrameIndex={timePointRendered}
onFpsChange={handleSetFrameRate}
currentDimensionGroupNumber={dimensionGroupNumberRendered}
numDimensionGroups={dynamicVolume?.numDimensionGroups || 1}
onDimensionGroupChange={dimensionGroupNumber => {
dynamicVolume.dimensionGroupNumber = dimensionGroupNumber;
framesLength={timePointsRange[1]}
onFrameChange={timePointIndex => {
dynamicVolume.timePointIndex = timePointIndex;
}}
onGenerate={onGenerateImage}
onDynamicClick={displayingComputed ? () => renderDynamicImage(computedDisplaySet) : null}
onDoubleRangeChange={setDimensionGroupRange}
rangeValues={dimensionGroupRange}
onDoubleRangeChange={handleSliderChange}
initialRangeValues={timePointsRangeToUseForGenerate}
/>
);
}
@@ -5,7 +5,7 @@ function WorkflowPanel({ servicesManager }: { servicesManager: ServicesManager }
const ProgressDropdownWithService =
servicesManager.services.customizationService.getCustomization(
'progressDropdownWithServiceComponent'
);
).component;
return (
<div
File diff suppressed because it is too large. Load diff
+2 -6
View File
@@ -1,13 +1,9 @@
const base = require('../../jest.config.base.js');
const pkg = require('./package');
module.exports = {
...base,
moduleNameMapper: {
...base.moduleNameMapper,
'@ohif/(.*)': '<rootDir>/../../platform/$1/src',
'^@cornerstonejs/([^/]+)/(.*)$': '<rootDir>/../../node_modules/@cornerstonejs/$1/dist/esm/$2',
'^@cornerstonejs/([^/]+)$': '<rootDir>/../../node_modules/@cornerstonejs/$1/dist/esm',
},
displayName: pkg.name,
// rootDir: "../.."
// testMatch: [
// //`<rootDir>/platform/${pack.name}/**/*.spec.js`
+15 -19
View File
@@ -1,6 +1,6 @@
{
"name": "@ohif/extension-cornerstone",
"version": "3.12.0-beta.43",
"version": "3.9.2",
"description": "OHIF extension for Cornerstone",
"author": "OHIF",
"license": "MIT",
@@ -31,19 +31,18 @@
"dev:cornerstone": "yarn run dev",
"build": "cross-env NODE_ENV=production webpack --progress --config .webpack/webpack.prod.js",
"build:package-1": "yarn run build",
"start": "yarn run dev",
"test:unit": "jest --watchAll",
"test:unit:ci": "jest --ci --runInBand --collectCoverage --passWithNoTests"
"start": "yarn run dev"
},
"peerDependencies": {
"@cornerstonejs/codec-charls": "^1.2.3",
"@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2",
"@cornerstonejs/codec-openjpeg": "^1.2.4",
"@cornerstonejs/codec-openjph": "^2.4.5",
"@cornerstonejs/dicom-image-loader": "4.3.14",
"@ohif/core": "3.12.0-beta.43",
"@ohif/ui": "3.12.0-beta.43",
"dcmjs": "0.43.1",
"@cornerstonejs/dicom-image-loader": "^2.2.20",
"@icr/polyseg-wasm": "^0.4.0",
"@ohif/core": "3.9.2",
"@ohif/ui": "3.9.2",
"dcmjs": "*",
"dicom-parser": "^1.8.21",
"hammerjs": "^2.0.8",
"prop-types": "^15.6.2",
@@ -51,22 +50,19 @@
"react-dom": "^18.3.1",
"react-resize-detector": "^10.0.1"
},
"devDependencies": {
"lodash": "^4.17.21"
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@cornerstonejs/adapters": "4.3.14",
"@cornerstonejs/ai": "4.3.14",
"@cornerstonejs/core": "4.3.14",
"@cornerstonejs/labelmap-interpolation": "4.3.14",
"@cornerstonejs/polymorphic-segmentation": "4.3.14",
"@cornerstonejs/tools": "4.3.14",
"@itk-wasm/morphological-contour-interpolation": "1.1.0",
"@kitware/vtk.js": "32.12.0",
"@cornerstonejs/adapters": "^2.2.20",
"@cornerstonejs/core": "^2.2.20",
"@cornerstonejs/tools": "^2.2.20",
"@icr/polyseg-wasm": "^0.4.0",
"@kitware/vtk.js": "32.1.1",
"html2canvas": "^1.4.1",
"lodash.compact": "^3.0.1",
"lodash.debounce": "^4.0.8",
"lodash.flatten": "^4.4.0",
"lodash.merge": "^4.6.2",
"lodash.zip": "^4.2.0",
"shader-loader": "^1.3.1",
"worker-loader": "^3.0.8"
}
@@ -2,11 +2,6 @@
width: 100%;
height: 100%; /* MUST have `height` to prevent resize infinite loop */
position: relative;
/* Prevent text selection on the entire viewport wrapper */
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.cornerstone-viewport-element {
@@ -21,26 +16,4 @@
/* Prevents the entire page from getting larger
when the magnify tool is near the sides/corners of the page */
overflow: hidden;
/* Prevent text selection on the viewport element */
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
/* Prevent text selection on overlay elements */
.noselect {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
/* Ensure all elements within viewport wrapper prevent text selection */
.viewport-wrapper * {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
@@ -1,8 +1,9 @@
import React, { useEffect, useRef, useCallback, useState } from 'react';
import { useResizeDetector } from 'react-resize-detector';
import * as cs3DTools from '@cornerstonejs/tools';
import { Enums, eventTarget, getEnabledElement } from '@cornerstonejs/core';
import { MeasurementService, useViewportRef } from '@ohif/core';
import { useViewportDialog } from '@ohif/ui-next';
import { MeasurementService } from '@ohif/core';
import { AllInOneMenu, Notification, useViewportDialog } from '@ohif/ui';
import type { Types as csTypes } from '@cornerstonejs/core';
import { setEnabledElement } from '../state';
@@ -13,15 +14,20 @@ import CinePlayer from '../components/CinePlayer';
import type { Types } from '@ohif/core';
import OHIFViewportActionCorners from '../components/OHIFViewportActionCorners';
import { getWindowLevelActionMenu } from '../components/WindowLevelActionMenu/getWindowLevelActionMenu';
import { useAppConfig } from '@state';
import { getViewportDataOverlaySettingsMenu } from '../components/ViewportDataOverlaySettingMenu';
import { getViewportPresentations } from '../utils/presentations/getViewportPresentations';
import { useSynchronizersStore } from '../stores/useSynchronizersStore';
import ActiveViewportBehavior from '../utils/ActiveViewportBehavior';
import { WITH_NAVIGATION } from '../services/ViewportService/CornerstoneViewportService';
const STACK = 'stack';
// Cache for viewport dimensions, persists across component remounts
const viewportDimensions = new Map<string, { width: number; height: number }>();
/**
* Caches the jump to measurement operation, so that if display set is shown,
* it can jump to the measurement.
*/
let cacheJumpToMeasurementEvent;
// Todo: This should be done with expose of internal API similar to react-vtkjs-viewport
// Then we don't need to worry about the re-renders if the props change.
@@ -40,6 +46,7 @@ const OHIFCornerstoneViewport = React.memo(
viewportOptions,
displaySetOptions,
servicesManager,
commandsManager,
onElementEnabled,
// eslint-disable-next-line react/prop-types
onElementDisabled,
@@ -81,7 +88,7 @@ const OHIFCornerstoneViewport = React.memo(
const [scrollbarHeight, setScrollbarHeight] = useState('100px');
const [enabledVPElement, setEnabledVPElement] = useState(null);
const elementRef = useRef() as React.MutableRefObject<HTMLDivElement>;
const viewportRef = useViewportRef(viewportId);
const [appConfig] = useAppConfig();
const {
displaySetService,
@@ -91,56 +98,23 @@ const OHIFCornerstoneViewport = React.memo(
cornerstoneViewportService,
segmentationService,
cornerstoneCacheService,
customizationService,
measurementService,
viewportActionCornersService,
} = servicesManager.services;
const [viewportDialogState] = useViewportDialog();
// useCallback for scroll bar height calculation
const setImageScrollBarHeight = useCallback(() => {
const scrollbarHeight = `${elementRef.current.clientHeight - 10}px`;
const scrollbarHeight = `${elementRef.current.clientHeight - 40}px`;
setScrollbarHeight(scrollbarHeight);
}, [elementRef]);
// useCallback for onResize
const onResize = useCallback(
(entries: ResizeObserverEntry[]) => {
if (elementRef.current && entries?.length) {
const entry = entries[0];
const { width, height } = entry.contentRect;
const prevDimensions = viewportDimensions.get(viewportId) || { width: 0, height: 0 };
// Check if dimensions actually changed and then only resize if they have changed
const hasDimensionsChanged =
prevDimensions.width !== width || prevDimensions.height !== height;
if (width > 0 && height > 0 && hasDimensionsChanged) {
viewportDimensions.set(viewportId, { width, height });
// Perform resize operations
cornerstoneViewportService.resize();
setImageScrollBarHeight();
}
}
},
[viewportId, elementRef, cornerstoneViewportService, setImageScrollBarHeight]
);
useEffect(() => {
const element = elementRef.current;
if (!element) {
return;
const onResize = useCallback(() => {
if (elementRef.current) {
cornerstoneViewportService.resize();
setImageScrollBarHeight();
}
const resizeObserver = new ResizeObserver(onResize);
resizeObserver.observe(element);
// Cleanup function
return () => {
resizeObserver.unobserve(element);
resizeObserver.disconnect();
};
}, [onResize]);
}, [elementRef]);
const cleanUpServices = useCallback(
viewportInfo => {
@@ -151,8 +125,16 @@ const OHIFCornerstoneViewport = React.memo(
syncGroupService.removeViewportFromSyncGroup(viewportId, renderingEngineId, syncGroups);
segmentationService.clearSegmentationRepresentations(viewportId);
viewportActionCornersService.clear(viewportId);
},
[viewportId, segmentationService, syncGroupService, toolGroupService]
[
viewportId,
segmentationService,
syncGroupService,
toolGroupService,
viewportActionCornersService,
]
);
const elementEnabledHandler = useCallback(
@@ -164,11 +146,6 @@ const OHIFCornerstoneViewport = React.memo(
const { viewportId, element } = evt.detail;
const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportId);
if (!viewportInfo) {
return;
}
setEnabledElement(viewportId, element);
setEnabledVPElement(element);
@@ -220,7 +197,6 @@ const OHIFCornerstoneViewport = React.memo(
}
cornerstoneViewportService.disableElement(viewportId);
viewportRef.unregister();
eventTarget.removeEventListener(Enums.Events.ELEMENT_ENABLED, elementEnabledHandler);
};
@@ -282,6 +258,14 @@ const OHIFCornerstoneViewport = React.memo(
const presentations = getViewportPresentations(viewportId, viewportOptions);
let measurement;
if (cacheJumpToMeasurementEvent?.viewportId === viewportId) {
measurement = cacheJumpToMeasurementEvent.measurement;
// Delete the position presentation so that viewport navigates direct
presentations.positionPresentation = null;
cacheJumpToMeasurementEvent = null;
}
// Note: This is a hack to get the grid to re-render the OHIFCornerstoneViewport component
// Used for segmentation hydration right now, since the logic to decide whether
// a viewport needs to render a segmentation lives inside the CornerstoneViewportService
@@ -299,12 +283,95 @@ const OHIFCornerstoneViewport = React.memo(
displaySetOptions,
presentations
);
if (measurement) {
cs3DTools.annotation.selection.setAnnotationSelected(measurement.uid);
}
};
loadViewportData();
}, [viewportOptions, displaySets, dataSource]);
const Notification = customizationService.getCustomization('ui.notificationComponent');
/**
* There are two scenarios for jump to click
* 1. Current viewports contain the displaySet that the annotation was drawn on
* 2. Current viewports don't contain the displaySet that the annotation was drawn on
* and we need to change the viewports displaySet for jumping.
* Since measurement_jump happens via events and listeners, the former case is handled
* by the measurement_jump direct callback, but the latter case is handled first by
* the viewportGrid to set the correct displaySet on the viewport, AND THEN we check
* the cache for jumping to see if there is any jump queued, then we jump to the correct slice.
*/
useEffect(() => {
if (isJumpToMeasurementDisabled) {
return;
}
const unsubscribeFromJumpToMeasurementEvents = _subscribeToJumpToMeasurementEvents(
elementRef,
viewportId,
servicesManager
);
_checkForCachedJumpToMeasurementEvents(elementRef, viewportId, displaySets, servicesManager);
return () => {
unsubscribeFromJumpToMeasurementEvents();
};
}, [displaySets, elementRef, viewportId, isJumpToMeasurementDisabled, servicesManager]);
// Set up the window level action menu in the viewport action corners.
useEffect(() => {
// Doing an === check here because the default config value when not set is true
if (appConfig.addWindowLevelActionMenu === false) {
return;
}
const location = viewportActionCornersService.LOCATIONS.topRight;
// TODO: In the future we should consider using the customization service
// to determine if and in which corner various action components should go.
viewportActionCornersService.addComponent({
viewportId,
id: 'windowLevelActionMenu',
component: getWindowLevelActionMenu({
viewportId,
element: elementRef.current,
displaySets,
servicesManager,
commandsManager,
location,
verticalDirection: AllInOneMenu.VerticalDirection.TopToBottom,
horizontalDirection: AllInOneMenu.HorizontalDirection.RightToLeft,
}),
location,
});
viewportActionCornersService.addComponent({
viewportId,
id: 'segmentation',
component: getViewportDataOverlaySettingsMenu({
viewportId,
element: elementRef.current,
displaySets,
servicesManager,
commandsManager,
location,
}),
location,
});
}, [
displaySets,
viewportId,
viewportActionCornersService,
servicesManager,
commandsManager,
appConfig,
]);
const { ref: resizeRef } = useResizeDetector({
onResize,
});
return (
<React.Fragment>
@@ -314,12 +381,9 @@ const OHIFCornerstoneViewport = React.memo(
style={{ height: '100%', width: '100%' }}
onContextMenu={e => e.preventDefault()}
onMouseDown={e => e.preventDefault()}
data-viewportid={viewportId}
ref={el => {
resizeRef.current = el;
elementRef.current = el;
if (el) {
viewportRef.register(el);
}
}}
></div>
<CornerstoneOverlays
@@ -361,6 +425,97 @@ const OHIFCornerstoneViewport = React.memo(
areEqual
);
function _subscribeToJumpToMeasurementEvents(elementRef, viewportId, servicesManager) {
const { measurementService, cornerstoneViewportService } = servicesManager.services;
const { unsubscribe } = measurementService.subscribe(
MeasurementService.EVENTS.JUMP_TO_MEASUREMENT_VIEWPORT,
props => {
cacheJumpToMeasurementEvent = props;
const { viewportId: jumpId, measurement, isConsumed } = props;
if (!measurement || isConsumed) {
return;
}
if (cacheJumpToMeasurementEvent.cornerstoneViewport === undefined) {
// Decide on which viewport should handle this
cacheJumpToMeasurementEvent.cornerstoneViewport =
cornerstoneViewportService.getViewportIdToJump(jumpId, {
displaySetInstanceUID: measurement.displaySetInstanceUID,
...measurement.metadata,
referencedImageId:
measurement.referencedImageId || measurement.metadata?.referencedImageId,
});
}
if (cacheJumpToMeasurementEvent.cornerstoneViewport !== viewportId) {
return;
}
_jumpToMeasurement(measurement, elementRef, viewportId, servicesManager);
}
);
return unsubscribe;
}
// Check if there is a queued jumpToMeasurement event
function _checkForCachedJumpToMeasurementEvents(
elementRef,
viewportId,
displaySets,
servicesManager
) {
if (!cacheJumpToMeasurementEvent) {
return;
}
if (cacheJumpToMeasurementEvent.isConsumed) {
cacheJumpToMeasurementEvent = null;
return;
}
const displaysUIDs = displaySets.map(displaySet => displaySet.displaySetInstanceUID);
if (!displaysUIDs?.length) {
return;
}
// Jump to measurement if the measurement exists
const { measurement } = cacheJumpToMeasurementEvent;
if (measurement && elementRef) {
if (displaysUIDs.includes(measurement?.displaySetInstanceUID)) {
_jumpToMeasurement(measurement, elementRef, viewportId, servicesManager);
}
}
}
function _jumpToMeasurement(measurement, targetElementRef, viewportId, servicesManager) {
const { viewportGridService } = servicesManager.services;
const targetElement = targetElementRef.current;
// Todo: setCornerstoneMeasurementActive should be handled by the toolGroupManager
// to set it properly
// setCornerstoneMeasurementActive(measurement);
viewportGridService.setActiveViewportId(viewportId);
const enabledElement = getEnabledElement(targetElement);
if (enabledElement) {
// See how the jumpToSlice() of Cornerstone3D deals with imageIdx param.
const viewport = enabledElement.viewport as csTypes.IStackViewport | csTypes.IVolumeViewport;
const { metadata } = measurement;
if (!viewport.isReferenceViewable(metadata, { withNavigation: true, withOrientation: true })) {
console.log("Reference isn't viewable, postponing until updated");
return;
}
viewport.setViewReference(metadata);
cs3DTools.annotation.selection.setAnnotationSelected(measurement.uid);
// Jump to measurement consumed, remove.
cacheJumpToMeasurementEvent?.consume?.();
cacheJumpToMeasurementEvent = null;
}
}
function _rehydrateSynchronizers(viewportId: string, syncGroupService: any) {
const { synchronizersStore } = useSynchronizersStore.getState();
const synchronizers = synchronizersStore[viewportId];
@@ -421,18 +576,22 @@ OHIFCornerstoneViewport.displayName = 'OHIFCornerstoneViewport';
function areEqual(prevProps, nextProps) {
if (nextProps.needsRerendering) {
console.debug('OHIFCornerstoneViewport: Rerender caused by: needsRerendering');
return false;
}
if (prevProps.displaySets.length !== nextProps.displaySets.length) {
console.debug('OHIFCornerstoneViewport: Rerender caused by: displaySets length change');
return false;
}
if (prevProps.viewportOptions.orientation !== nextProps.viewportOptions.orientation) {
console.debug('OHIFCornerstoneViewport: Rerender caused by: orientation change');
return false;
}
if (prevProps.viewportOptions.toolGroupId !== nextProps.viewportOptions.toolGroupId) {
console.debug('OHIFCornerstoneViewport: Rerender caused by: toolGroupId change');
return false;
}
@@ -440,10 +599,12 @@ function areEqual(prevProps, nextProps) {
nextProps.viewportOptions.viewportType &&
prevProps.viewportOptions.viewportType !== nextProps.viewportOptions.viewportType
) {
console.debug('OHIFCornerstoneViewport: Rerender caused by: viewportType change');
return false;
}
if (nextProps.viewportOptions.needsRerendering) {
console.debug('OHIFCornerstoneViewport: Rerender caused by: viewportOptions.needsRerendering');
return false;
}
@@ -451,6 +612,7 @@ function areEqual(prevProps, nextProps) {
const nextDisplaySets = nextProps.displaySets;
if (prevDisplaySets.length !== nextDisplaySets.length) {
console.debug('OHIFCornerstoneViewport: Rerender caused by: displaySets length mismatch');
return false;
}
@@ -463,11 +625,13 @@ function areEqual(prevProps, nextProps) {
);
if (!foundDisplaySet) {
console.debug('OHIFCornerstoneViewport: Rerender caused by: displaySet not found');
return false;
}
// check they contain the same image
if (foundDisplaySet.images?.length !== prevDisplaySet.images?.length) {
console.debug('OHIFCornerstoneViewport: Rerender caused by: images length mismatch');
return false;
}
@@ -475,6 +639,7 @@ function areEqual(prevProps, nextProps) {
if (foundDisplaySet.images?.length) {
for (let j = 0; j < foundDisplaySet.images.length; j++) {
if (foundDisplaySet.images[j].imageId !== prevDisplaySet.images[j].imageId) {
console.debug('OHIFCornerstoneViewport: Rerender caused by: imageId mismatch');
return false;
}
}
@@ -484,4 +649,16 @@ function areEqual(prevProps, nextProps) {
return true;
}
// Helper function to check if display sets have changed
function haveDisplaySetsChanged(prevDisplaySets, currentDisplaySets) {
if (prevDisplaySets.length !== currentDisplaySets.length) {
return true;
}
return currentDisplaySets.some((currentDS, index) => {
const prevDS = prevDisplaySets[index];
return currentDS.displaySetInstanceUID !== prevDS.displaySetInstanceUID;
});
}
export default OHIFCornerstoneViewport;
@@ -1,20 +1,16 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { vec3 } from 'gl-matrix';
import PropTypes from 'prop-types';
import { metaData, Enums, utilities, eventTarget } from '@cornerstonejs/core';
import { Enums as csToolsEnums, UltrasoundPleuraBLineTool } from '@cornerstonejs/tools';
import { metaData, Enums, utilities } from '@cornerstonejs/core';
import type { ImageSliceData } from '@cornerstonejs/core/types';
import { ViewportOverlay } from '@ohif/ui-next';
import { ViewportOverlay } from '@ohif/ui';
import type { InstanceMetadata } from '@ohif/core/src/types';
import { formatDICOMDate, formatDICOMTime, formatNumberPrecision } from './utils';
import { utils } from '@ohif/core';
import { formatPN, formatDICOMDate, formatDICOMTime, formatNumberPrecision } from './utils';
import { StackViewportData, VolumeViewportData } from '../../types/CornerstoneCacheService';
import './CustomizableViewportOverlay.css';
import { useViewportRendering } from '../../hooks';
const EPSILON = 1e-4;
const { formatPN } = utils;
type ViewportData = StackViewportData | VolumeViewportData;
@@ -49,6 +45,80 @@ const OverlayItemComponents = {
'ohif.overlayItem.instanceNumber': InstanceNumberOverlayItem,
};
const studyDateItem = {
id: 'StudyDate',
customizationType: 'ohif.overlayItem',
label: '',
title: 'Study date',
condition: ({ referenceInstance }) => referenceInstance?.StudyDate,
contentF: ({ referenceInstance, formatters: { formatDate } }) =>
formatDate(referenceInstance.StudyDate),
};
const seriesDescriptionItem = {
id: 'SeriesDescription',
customizationType: 'ohif.overlayItem',
label: '',
title: 'Series description',
condition: ({ referenceInstance }) => {
return referenceInstance && referenceInstance.SeriesDescription;
},
contentF: ({ referenceInstance }) => referenceInstance.SeriesDescription,
};
const topLeftItems = {
id: 'cornerstoneOverlayTopLeft',
items: [studyDateItem, seriesDescriptionItem],
};
const topRightItems = { id: 'cornerstoneOverlayTopRight', items: [] };
const bottomLeftItems = {
id: 'cornerstoneOverlayBottomLeft',
items: [
{
id: 'WindowLevel',
customizationType: 'ohif.overlayItem.windowLevel',
},
{
id: 'ZoomLevel',
customizationType: 'ohif.overlayItem.zoomLevel',
condition: props => {
const activeToolName = props.toolGroupService.getActiveToolForViewport(props.viewportId);
return activeToolName === 'Zoom';
},
},
],
};
const bottomRightItems = {
id: 'cornerstoneOverlayBottomRight',
items: [
{
id: 'InstanceNumber',
customizationType: 'ohif.overlayItem.instanceNumber',
},
],
};
/**
* The @ohif/cornerstoneOverlay is a default value for a customization
* for the cornerstone overlays. The intent is to allow it to be extended
* without needing to re-write the individual overlays by using the append
* mechanism. Individual attributes can be modified individually without
* affecting the other items by using the append as well, with position
* based replacement.
* This is used as a default in the getCustomizationModule so that it
* is available early for additional customization extensions.
*/
const CornerstoneOverlay = {
id: '@ohif/cornerstoneOverlay',
topLeftItems,
topRightItems,
bottomLeftItems,
bottomRightItems,
};
/**
* Customizable Viewport Overlay
*/
@@ -69,22 +139,28 @@ function CustomizableViewportOverlay({
servicesManager.services;
const [voi, setVOI] = useState({ windowCenter: null, windowWidth: null });
const [scale, setScale] = useState(1);
const [annotationState, setAnnotationState] = useState(0);
const { isViewportBackgroundLight: isLight } = useViewportRendering(viewportId);
const { imageIndex } = imageSliceData;
// The new customization is 'cornerstoneOverlay', with an append or replace
// on the individual items rather than defining individual items.
const cornerstoneOverlay = customizationService.getCustomization('@ohif/cornerstoneOverlay');
// Historical usage defined the overlays as separate items due to lack of
// append functionality. This code enables the historical usage, but
// the recommended functionality is to append to the default values in
// cornerstoneOverlay rather than defining individual items.
const topLeftCustomization = customizationService.getCustomization('viewportOverlay.topLeft');
const topRightCustomization = customizationService.getCustomization('viewportOverlay.topRight');
const bottomLeftCustomization = customizationService.getCustomization(
'viewportOverlay.bottomLeft'
);
const bottomRightCustomization = customizationService.getCustomization(
'viewportOverlay.bottomRight'
);
const topLeftCustomization =
customizationService.getCustomization('cornerstoneOverlayTopLeft') ||
cornerstoneOverlay?.topLeftItems;
const topRightCustomization =
customizationService.getCustomization('cornerstoneOverlayTopRight') ||
cornerstoneOverlay?.topRightItems;
const bottomLeftCustomization =
customizationService.getCustomization('cornerstoneOverlayBottomLeft') ||
cornerstoneOverlay?.bottomLeftItems;
const bottomRightCustomization =
customizationService.getCustomization('cornerstoneOverlayBottomRight') ||
cornerstoneOverlay?.bottomRightItems;
const instanceNumber = useMemo(
() =>
@@ -134,20 +210,6 @@ function CustomizableViewportOverlay({
};
}, [viewportId, viewportData, voi, element]);
const annotationModified = useCallback(evt => {
if (evt.detail.annotation.metadata.toolName === UltrasoundPleuraBLineTool.toolName) {
// Update the annotation state to trigger a re-render
setAnnotationState(prevState => prevState + 1);
}
}, []);
useEffect(() => {
eventTarget.addEventListener(csToolsEnums.Events.ANNOTATION_MODIFIED, annotationModified);
return () => {
eventTarget.removeEventListener(csToolsEnums.Events.ANNOTATION_MODIFIED, annotationModified);
};
}, [annotationModified]);
/**
* Updating the scale when the viewport changes its zoom
*/
@@ -188,7 +250,6 @@ function CustomizableViewportOverlay({
viewportId,
servicesManager,
customization: item,
isLight,
formatters: {
formatPN,
formatDate: formatDICOMDate,
@@ -201,8 +262,8 @@ function CustomizableViewportOverlay({
return null;
}
const { inheritsFrom } = item;
const OverlayItemComponent = OverlayItemComponents[inheritsFrom];
const { customizationType } = item;
const OverlayItemComponent = OverlayItemComponents[customizationType];
if (OverlayItemComponent) {
return <OverlayItemComponent {...overlayItemProps} />;
@@ -225,12 +286,15 @@ function CustomizableViewportOverlay({
voi,
scale,
instanceNumber,
annotationState,
]
);
const getContent = useCallback(
(customization, keyPrefix) => {
if (!customization?.items) {
return null;
}
const { items } = customization;
const props = {
...displaySetProps,
formatters: { formatDate: formatDICOMDate },
@@ -239,12 +303,11 @@ function CustomizableViewportOverlay({
instanceNumber,
viewportId,
toolGroupService,
isLight,
};
return (
<>
{customization.map((item, index) => (
{items.map((item, index) => (
<div key={`${keyPrefix}_${index}`}>
{((!item?.condition || item.condition(props)) && _renderOverlayItem(item, props)) ||
null}
@@ -262,8 +325,6 @@ function CustomizableViewportOverlay({
topRight={getContent(topRightCustomization, 'topRightOverlayItem')}
bottomLeft={getContent(bottomLeftCustomization, 'bottomLeftOverlayItem')}
bottomRight={getContent(bottomRightCustomization, 'bottomRightOverlayItem')}
color={isLight ? 'text-neutral-dark' : 'text-neutral-light'}
shadowClass={isLight ? 'shadow-light' : 'shadow-dark'}
/>
);
}
@@ -391,14 +452,13 @@ function OverlayItem(props) {
title={title}
>
{label ? <span className="mr-1 shrink-0">{label}</span> : null}
<span className="ml-0 mr-2 shrink-0">{value}</span>
<span className="ml-1 mr-2 shrink-0">{value}</span>
</div>
);
}
/**
* Window Level / Center Overlay item
* //
*/
function VOIOverlayItem({ voi, customization }: OverlayItemProps) {
const { windowWidth, windowCenter } = voi;
@@ -411,10 +471,10 @@ function VOIOverlayItem({ voi, customization }: OverlayItemProps) {
className="overlay-item flex flex-row"
style={{ color: customization?.color }}
>
<span className="mr-0.5 shrink-0 opacity-[0.70]">W:</span>
<span className="mr-2.5 shrink-0">{windowWidth.toFixed(0)}</span>
<span className="mr-0.5 shrink-0 opacity-[0.70]">L:</span>
<span className="shrink-0">{windowCenter.toFixed(0)}</span>
<span className="mr-1 shrink-0">W:</span>
<span className="ml-1 mr-2 shrink-0">{windowWidth.toFixed(0)}</span>
<span className="mr-1 shrink-0">L:</span>
<span className="ml-1 shrink-0">{windowCenter.toFixed(0)}</span>
</div>
);
}
@@ -428,7 +488,7 @@ function ZoomOverlayItem({ scale, customization }: OverlayItemProps) {
className="overlay-item flex flex-row"
style={{ color: (customization && customization.color) || undefined }}
>
<span className="mr-0.5 shrink-0 opacity-[0.70]">Zoom:</span>
<span className="mr-1 shrink-0">Zoom:</span>
<span>{scale.toFixed(2)}x</span>
</div>
);
@@ -452,7 +512,7 @@ function InstanceNumberOverlayItem({
<span>
{instanceNumber !== undefined && instanceNumber !== null ? (
<>
<span className="mr-0.5 shrink-0 opacity-[0.70]">I:</span>
<span className="mr-1 shrink-0">I:</span>
<span>{`${instanceNumber} (${imageIndex + 1}/${numberOfSlices})`}</span>
</>
) : (
@@ -471,4 +531,4 @@ CustomizableViewportOverlay.propTypes = {
export default CustomizableViewportOverlay;
export { CustomizableViewportOverlay };
export { CustomizableViewportOverlay, CornerstoneOverlay };
@@ -1,7 +1,7 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { Enums, VolumeViewport3D, utilities as csUtils } from '@cornerstonejs/core';
import { ImageScrollbar } from '@ohif/ui-next';
import { ImageScrollbar } from '@ohif/ui';
function CornerstoneImageScrollbar({
viewportData,
@@ -44,17 +44,13 @@ function CornerstoneImageScrollbar({
return;
}
try {
const imageIndex = viewport.getCurrentImageIdIndex();
const numberOfSlices = viewport.getNumberOfSlices();
const imageIndex = viewport.getCurrentImageIdIndex();
const numberOfSlices = viewport.getNumberOfSlices();
setImageSliceData({
imageIndex: imageIndex,
numberOfSlices,
});
} catch (error) {
console.warn(error);
}
setImageSliceData({
imageIndex: imageIndex,
numberOfSlices,
});
}, [viewportId, viewportData]);
useEffect(() => {
@@ -63,7 +59,7 @@ function CornerstoneImageScrollbar({
}
const { viewportType } = viewportData;
const eventId =
(viewportType === Enums.ViewportType.STACK && Enums.Events.STACK_NEW_IMAGE) ||
(viewportType === Enums.ViewportType.STACK && Enums.Events.STACK_VIEWPORT_SCROLL) ||
(viewportType === Enums.ViewportType.ORTHOGRAPHIC && Enums.Events.VOLUME_NEW_IMAGE) ||
Enums.Events.IMAGE_RENDERED;
@@ -72,11 +68,11 @@ function CornerstoneImageScrollbar({
if (!viewport || viewport instanceof VolumeViewport3D) {
return;
}
const { imageIndex, newImageIdIndex = imageIndex, imageIdIndex } = event.detail;
const { imageIndex, newImageIdIndex = imageIndex } = event.detail;
const numberOfSlices = viewport.getNumberOfSlices();
// find the index of imageId in the imageIds
setImageSliceData({
imageIndex: newImageIdIndex ?? imageIdIndex,
imageIndex: newImageIdIndex,
numberOfSlices,
});
};
@@ -3,30 +3,27 @@
--marker-height: 100px;
--scrollbar-width: 20px;
pointer-events: none;
font-size: 15px;
line-height: 18px;
}
.ViewportOrientationMarkers .orientation-marker {
position: absolute;
}
.ViewportOrientationMarkers .top-mid {
top: 0.38rem;
top: 0.6rem;
left: 50%;
transform: translateX(-50%);
}
.ViewportOrientationMarkers .left-mid {
top: 50%;
left: 0.38rem;
transform: translateY(-50%);
top: 47%;
left: 5px;
}
.ViewportOrientationMarkers .right-mid {
top: 50%;
top: 47%;
left: calc(100% - var(--marker-width) - var(--scrollbar-width));
transform: translateY(-50%);
}
.ViewportOrientationMarkers .bottom-mid {
top: calc(100% - var(--marker-height) - 0.6rem);
left: 50%;
transform: translateX(-50%);
left: 47%;
}
.ViewportOrientationMarkers .right-mid .orientation-marker-value {
display: flex;
@@ -1,11 +1,11 @@
import React, { useEffect, useState, useMemo } from 'react';
import classNames from 'classnames';
import { metaData, Enums, getEnabledElement } from '@cornerstonejs/core';
import { metaData, Enums, Types, getEnabledElement } from '@cornerstonejs/core';
import { utilities } from '@cornerstonejs/tools';
import { vec3 } from 'gl-matrix';
import './ViewportOrientationMarkers.css';
import { useViewportRendering } from '../../hooks';
const { getOrientationStringLPS, invertOrientationStringLPS } = utilities.orientation;
function ViewportOrientationMarkers({
@@ -16,36 +16,49 @@ function ViewportOrientationMarkers({
servicesManager,
orientationMarkers = ['top', 'left'],
}: withAppTypes) {
const [cameraModifiedTime, setCameraModifiedTime] = useState(0);
const { isViewportBackgroundLight: isLight } = useViewportRendering(viewportId);
// Rotation is in degrees
const [rotation, setRotation] = useState(0);
const [flipHorizontal, setFlipHorizontal] = useState(false);
const [flipVertical, setFlipVertical] = useState(false);
const { cornerstoneViewportService } = servicesManager.services;
useEffect(() => {
const cameraModifiedListener = () => setCameraModifiedTime(Date.now());
const cameraModifiedListener = (evt: Types.EventTypes.CameraModifiedEvent) => {
const { previousCamera, camera } = evt.detail;
const { rotation } = camera;
if (rotation !== undefined) {
setRotation(rotation);
}
if (
camera.flipHorizontal !== undefined &&
previousCamera.flipHorizontal !== camera.flipHorizontal
) {
setFlipHorizontal(camera.flipHorizontal);
}
if (
camera.flipVertical !== undefined &&
previousCamera.flipVertical !== camera.flipVertical
) {
setFlipVertical(camera.flipVertical);
}
};
element.addEventListener(Enums.Events.CAMERA_MODIFIED, cameraModifiedListener);
return () => {
element.removeEventListener(Enums.Events.CAMERA_MODIFIED, cameraModifiedListener);
};
}, [element]);
}, []);
const markers = useMemo(() => {
if (!viewportData || cameraModifiedTime === 0) {
return '';
}
if (!element || !getEnabledElement(element)) {
console.log(`ViewportOrientationMarkers :: Viewport element not enabled (${viewportId})`);
return '';
}
const ohifViewport = cornerstoneViewportService.getViewportInfo(viewportId);
if (!ohifViewport) {
console.log(`ViewportOrientationMarkers :: No viewport (${viewportId})`);
if (!viewportData) {
return '';
}
let rowCosines, columnCosines, isDefaultValueSetForRowCosine, isDefaultValueSetForColumnCosine;
if (viewportData.viewportType === 'stack') {
const imageIndex = imageSliceData.imageIndex;
const imageId = viewportData.data[0].imageIds?.[imageIndex];
@@ -55,34 +68,59 @@ function ViewportOrientationMarkers({
return false;
}
const { isDefaultValueSetForRowCosine, isDefaultValueSetForColumnCosine } =
metaData.get('imagePlaneModule', imageId) || {};
if (isDefaultValueSetForColumnCosine || isDefaultValueSetForRowCosine) {
({
rowCosines,
columnCosines,
isDefaultValueSetForColumnCosine,
isDefaultValueSetForColumnCosine,
} = metaData.get('imagePlaneModule', imageId) || {});
} else {
if (!element || !getEnabledElement(element)) {
return '';
}
const { viewport } = getEnabledElement(element);
const { viewUp, viewPlaneNormal } = viewport.getCamera();
const viewRight = vec3.create();
vec3.cross(viewRight, viewUp, viewPlaneNormal);
columnCosines = [-viewUp[0], -viewUp[1], -viewUp[2]];
rowCosines = viewRight;
}
const { viewport } = getEnabledElement(element);
const p00 = viewport.canvasToWorld([0, 0]);
const p10 = viewport.canvasToWorld([1, 0]);
const p01 = viewport.canvasToWorld([0, 1]);
const rowCosines = vec3.sub(vec3.create(), p10, p00);
const columnCosines = vec3.sub(vec3.create(), p01, p00);
if (
!rowCosines ||
!columnCosines ||
rotation === undefined ||
isDefaultValueSetForRowCosine ||
isDefaultValueSetForColumnCosine
) {
return '';
}
vec3.normalize(rowCosines, rowCosines);
vec3.normalize(columnCosines, columnCosines);
const markers = _getOrientationMarkers(
rowCosines,
columnCosines,
rotation,
flipVertical,
flipHorizontal
);
const markers = _getOrientationMarkers(rowCosines, columnCosines);
const ohifViewport = cornerstoneViewportService.getViewportInfo(viewportId);
if (!ohifViewport) {
console.log('ViewportOrientationMarkers::No viewport');
return null;
}
return orientationMarkers.map((m, index) => (
<div
className={classNames(
'overlay-text',
`${m}-mid orientation-marker`,
isLight ? 'text-neutral-dark/70' : 'text-neutral-light/70',
isLight ? 'shadow-light' : 'shadow-dark',
'text-base',
'text-aqua-pale',
'text-[13px]',
'leading-5'
)}
key={`${m}-mid orientation-marker`}
@@ -90,7 +128,15 @@ function ViewportOrientationMarkers({
<div className="orientation-marker-value">{markers[m]}</div>
</div>
));
}, [viewportData, imageSliceData, cameraModifiedTime, orientationMarkers, element, isLight]);
}, [
viewportData,
imageSliceData,
rotation,
flipVertical,
flipHorizontal,
orientationMarkers,
element,
]);
return <div className="ViewportOrientationMarkers select-none">{markers}</div>;
}
@@ -102,8 +148,10 @@ function ViewportOrientationMarkers({
*
* @param {*} rowCosines
* @param {*} columnCosines
* @param {*} rotation in degrees
* @returns
*/
function _getOrientationMarkers(rowCosines, columnCosines) {
function _getOrientationMarkers(rowCosines, columnCosines, rotation, flipVertical, flipHorizontal) {
const rowString = getOrientationStringLPS(rowCosines);
const columnString = getOrientationStringLPS(columnCosines);
const oppositeRowString = invertOrientationStringLPS(rowString);
@@ -116,6 +164,43 @@ function _getOrientationMarkers(rowCosines, columnCosines) {
bottom: columnString,
};
// If any vertical or horizontal flips are applied, change the orientation strings ahead of
// the rotation applications
if (flipVertical) {
markers.top = invertOrientationStringLPS(markers.top);
markers.bottom = invertOrientationStringLPS(markers.bottom);
}
if (flipHorizontal) {
markers.left = invertOrientationStringLPS(markers.left);
markers.right = invertOrientationStringLPS(markers.right);
}
// Swap the labels accordingly if the viewport has been rotated
// This could be done in a more complex way for intermediate rotation values (e.g. 45 degrees)
if (rotation === 90 || rotation === -270) {
return {
top: markers.left,
left: invertOrientationStringLPS(markers.top),
right: invertOrientationStringLPS(markers.bottom),
bottom: markers.right, // left
};
} else if (rotation === -90 || rotation === 270) {
return {
top: invertOrientationStringLPS(markers.left),
left: markers.top,
bottom: markers.left,
right: markers.bottom,
};
} else if (rotation === 180 || rotation === -180) {
return {
top: invertOrientationStringLPS(markers.top),
left: invertOrientationStringLPS(markers.left),
bottom: invertOrientationStringLPS(markers.bottom),
right: invertOrientationStringLPS(markers.right),
};
}
return markers;
}
@@ -52,6 +52,31 @@ export function formatDICOMTime(time, strFormat = 'HH:mm:ss') {
return moment(time, 'HH:mm:ss').format(strFormat);
}
/**
* Formats a patient name for display purposes
*
* @param {string} name
* @returns {string} formatted name.
*/
export function formatPN(name) {
if (!name) {
return '';
}
if (typeof name === 'object') {
name = name.Alphabetic;
if (!name) {
return '';
}
}
const cleaned = name
.split('^')
.filter(s => !!s)
.join(', ')
.trim();
return cleaned === ',' || cleaned === '' ? '' : cleaned;
}
/**
* Gets compression type
*
File diff suppressed because it is too large. Load diff
@@ -1,195 +0,0 @@
import React from 'react';
import { useSystem } from '@ohif/core';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@ohif/ui-next';
import { ChevronDownIcon } from '@radix-ui/react-icons';
export type AccordionGrouping = {
[name: string]: unknown;
};
export type AccordionGroupProps = {
grouping: AccordionGrouping;
};
/**
* Searches for the required type from the provided allChildren list and
* renders them.
*/
export const CloneChildren = props => {
const { group, allChildren, children, childType, defaultTypes } = props;
const subType = group?.subType;
for (const child of allChildren) {
if (subType && child.props?.subType !== subType) {
continue;
}
if (childType && child.type === childType) {
return React.cloneElement(child, { ...props, children: child.props.children });
}
if (defaultTypes?.indexOf(child.type) === -1) {
return childType({ ...props, children: child });
}
}
if (!children) {
throw new Error(`No children defined for ${props.name} CloneChildren in group ${group?.name}`);
}
return React.cloneElement(children, props);
};
/** Used to exclude defaults */
const DEFAULT_TYPES = [GroupAccordion, Content, Trigger];
/**
* An AccordionGroup is a component that splits a set of items into different
* groups according to a set of grouping rules. It then puts the groups
* into a set of accordion folds selected from the body of the accordion group,
* looking for matching trigger/content sections according to the type definition
* in the group with first one found being used.
*
* This design allows for easy customization of the component by declaring grouping
* functions with default grouping setups and then only overriding the specific
* children needing to be changed. See the PanelMeasurement for some example
* possibilities of how to modify the default grouping, or the test-extension
* measurements panel for a practical, working example.
*/
export function AccordionGroup(props) {
const { grouping, items, children, sourceChildren } = props;
const childProps = useSystem();
let defaultValue = props.defaultValue;
const groups = grouping.groupingFunction(items, grouping, childProps);
if (!defaultValue) {
const defaultGroup = groups.values().find(group => group.isSelected);
defaultValue = defaultGroup?.key || defaultGroup?.title;
}
const valueArr =
(Array.isArray(defaultValue) && defaultValue) || (defaultValue && [defaultValue]) || [];
const sourceChildrenArr = sourceChildren ? React.Children.toArray(sourceChildren) : [];
const childrenArr = children ? React.Children.toArray(children) : [];
const allChildren = sourceChildrenArr.concat(childrenArr);
return (
<CloneChildren
allChildren={allChildren}
groups={groups}
childType={GroupAccordion}
grouping={grouping}
defaultValue={valueArr}
name={'grouping ' + grouping.name}
>
<DefaultAccordion name="DefaultAccordion" />
</CloneChildren>
);
}
function DefaultAccordion(props) {
const { groups, defaultValue, grouping, allChildren, asChild } = props;
if (!allChildren || !groups) {
return null;
}
if (Boolean(asChild)) {
return React.cloneElement(props.children, props);
}
return (
<Accordion
type={grouping.type || 'multiple'}
className="text-white"
defaultValue={defaultValue}
>
{[...groups.entries()].map(([key, group]) => {
return (
<AccordionItem
key={group.key + '-i'}
value={group.key}
>
<CloneChildren
allChildren={allChildren}
group={group}
childType={Trigger}
name="AccordionGroup.Trigger"
/>
<CloneChildren
allChildren={allChildren}
group={group}
childType={Content}
defaultTypes={DEFAULT_TYPES}
name="AccordionGroup.Content"
/>
</AccordionItem>
);
})}
</Accordion>
);
}
function GroupAccordion(props) {
const { groups, children } = props;
if (!groups) {
return null;
}
return [...groups.values()].map(group =>
React.cloneElement(children, {
...props,
children: children.props.children,
group,
...group,
key: group.title,
})
);
}
function Content(props) {
const { children, asChild, ...childProps } = props;
const { group } = props;
Object.assign(childProps, group);
if (!group) {
return null;
}
if (asChild) {
return React.cloneElement(children, { ...group, ...props, children: children.props.children });
}
return (
<AccordionContent>
{React.cloneElement(children, { ...group, ...props, children: children.props.children })}
</AccordionContent>
);
}
function Trigger(props) {
const { children, asChild, ...childProps } = props;
const { group } = props;
Object.assign(childProps, group);
if (!group) {
return null;
}
if (asChild) {
return React.cloneElement(children, childProps);
}
return (
<AccordionTrigger
value={group.value}
asChild={true}
>
<div>
{React.cloneElement(children, childProps)}
<ChevronDownIcon
key="chevronDown"
className="text-primary h-4 w-4 shrink-0 transition-transform duration-200"
/>
</div>
</AccordionTrigger>
);
}
AccordionGroup.Content = Content;
AccordionGroup.Trigger = Trigger;
AccordionGroup.Accordion = GroupAccordion;
export default AccordionGroup;
@@ -1,4 +0,0 @@
export * from './AccordionGroup';
import AccordionGroup from './AccordionGroup';
export default AccordionGroup;
@@ -1,6 +1,6 @@
import React, { ReactElement } from 'react';
import PropTypes from 'prop-types';
import { useViewportGrid } from '@ohif/ui-next';
import { useViewportGrid } from '@ohif/ui';
import ViewportWindowLevel from '../ViewportWindowLevel/ViewportWindowLevel';
const ActiveViewportWindowLevel = ({ servicesManager }: withAppTypes): ReactElement => {
@@ -1,149 +0,0 @@
import { useToolbar, useViewportMousePosition } from '@ohif/core/src/hooks';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useViewportRendering } from '../../hooks';
import { ButtonLocation } from '@ohif/core/src/services/ToolBarService/ToolbarService';
import classNames from 'classnames';
const mouseNearControlsRanges = {
[ButtonLocation.TopMiddle]: { minX: 0, minY: 0, maxX: 1, maxY: 0.1 },
[ButtonLocation.BottomMiddle]: { minX: 0, minY: 0.9, maxX: 1, maxY: 1 },
[ButtonLocation.LeftMiddle]: { minX: 0, minY: 0, maxX: 0.1, maxY: 1 },
[ButtonLocation.RightMiddle]: { minX: 0.9, minY: 0, maxX: 1, maxY: 1 },
};
const getFlexDirectionClassName = (location: ButtonLocation) =>
location === ButtonLocation.LeftMiddle || location === ButtonLocation.RightMiddle
? 'flex-col'
: 'flex-row';
function AdvancedRenderingControls({
viewportId,
location,
buttonSection,
}: {
viewportId: string;
location: number;
buttonSection: string;
}) {
const {
onInteraction,
toolbarButtons,
isItemOpen,
isItemLocked,
openItem,
closeItem,
toggleLock,
} = useToolbar({
buttonSection,
});
const mousePosition = useViewportMousePosition(viewportId);
const [isMouseNearControls, setIsMouseNearControls] = useState(false);
const [showAllIcons, setShowAllIcons] = useState(true);
const firstMountRef = useRef(true);
const { hasColorbar } = useViewportRendering(viewportId);
const [isAnItemOpen, setIsAnItemOpen] = useState(false);
useEffect(() => {
if (firstMountRef.current) {
firstMountRef.current = false;
const timer = setTimeout(() => {
setShowAllIcons(false);
}, 3000);
return () => clearTimeout(timer);
}
}, []);
useEffect(() => {
if (!showAllIcons && mousePosition.isInViewport) {
const mouseHoverLocation = mouseNearControlsRanges[location];
if (mousePosition.isWithinNormalizedBox(mouseHoverLocation)) {
setIsMouseNearControls(true);
} else {
setIsMouseNearControls(false);
}
}
}, [location, mousePosition, showAllIcons]);
const handleOnItemOpen = useCallback(
(id, viewportId) => {
openItem(id, viewportId);
setIsAnItemOpen(true);
},
[openItem, setIsAnItemOpen]
);
const handleOnItemClose = useCallback(
(id, viewportId) => {
closeItem(id, viewportId);
setIsAnItemOpen(false);
},
[closeItem, setIsAnItemOpen]
);
if (!toolbarButtons?.length) {
return null;
}
if (!hasColorbar) {
return null;
}
return (
<div className={classNames('flex gap-2', getFlexDirectionClassName(location))}>
{toolbarButtons.map(toolDef => {
if (!toolDef) {
return null;
}
const { id, Component, componentProps } = toolDef;
// Enhanced props with state and actions - respecting viewport specificity
const enhancedProps = {
...componentProps,
isOpen: isItemOpen(id, viewportId),
isLocked: isItemLocked(id, viewportId),
onOpen: () => handleOnItemOpen(id, viewportId),
onClose: () => handleOnItemClose(id, viewportId),
onToggleLock: () => toggleLock(id, viewportId),
viewportId,
};
const tool = (
<Component
key={id}
id={id}
location={location}
onInteraction={args => {
onInteraction({
...args,
itemId: id,
viewportId,
});
}}
{...enhancedProps}
/>
);
// Always show all icons on first mount for 3 seconds
// After that, always show Colorbar, show others only when mouse is at bottom
const shouldBeVisible =
isAnItemOpen || showAllIcons || id === 'Colorbar' || isMouseNearControls;
return (
<div
key={id}
className={shouldBeVisible ? 'opacity-100' : 'pointer-events-none opacity-0'}
style={{ transition: 'opacity 0.2s ease-in-out' }}
>
{tool}
</div>
);
})}
</div>
);
}
export default AdvancedRenderingControls;
@@ -1 +0,0 @@
export { default } from './AdvancedRenderingControls';
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useState, useRef } from 'react';
import { useCine } from '@ohif/ui-next';
import { CinePlayer, useCine } from '@ohif/ui';
import { Enums, eventTarget, cache } from '@cornerstonejs/core';
import { useAppConfig } from '@state';
@@ -53,13 +53,13 @@ function WrappedCinePlayer({
// check if the displaySet is dynamic and set the dynamic info
if (displaySet.isDynamicVolume) {
const { dynamicVolumeInfo } = displaySet;
const numDimensionGroups = dynamicVolumeInfo.timePoints.length;
const numTimePoints = dynamicVolumeInfo.timePoints.length;
const label = dynamicVolumeInfo.splittingTag;
const dimensionGroupNumber = dynamicVolumeInfo.dimensionGroupNumber || 1;
const timePointIndex = dynamicVolumeInfo.timePointIndex || 0;
setDynamicInfo({
volumeId: displaySet.displaySetInstanceUID,
dimensionGroupNumber,
numDimensionGroups,
timePointIndex,
numTimePoints,
label,
});
} else {
@@ -160,7 +160,8 @@ function RenderCinePlayer({
dynamicInfo: dynamicInfoProp,
customizationService,
}) {
const CinePlayerComponent = customizationService.getCustomization('cinePlayer');
const { component: CinePlayerComponent = CinePlayer } =
customizationService.get('cinePlayer') ?? {};
const [dynamicInfo, setDynamicInfo] = useState(dynamicInfoProp);
@@ -176,20 +177,20 @@ function RenderCinePlayer({
return;
}
const handleDimensionGroupChange = evt => {
const { volumeId, dimensionGroupNumber, numDimensionGroups, splittingTag } = evt.detail;
setDynamicInfo({ volumeId, dimensionGroupNumber, numDimensionGroups, label: splittingTag });
const handleTimePointIndexChange = evt => {
const { volumeId, timePointIndex, numTimePoints, splittingTag } = evt.detail;
setDynamicInfo({ volumeId, timePointIndex, numTimePoints, label: splittingTag });
};
eventTarget.addEventListener(
Enums.Events.DYNAMIC_VOLUME_DIMENSION_GROUP_CHANGED,
handleDimensionGroupChange
Enums.Events.DYNAMIC_VOLUME_TIME_POINT_INDEX_CHANGED,
handleTimePointIndexChange
);
return () => {
eventTarget.removeEventListener(
Enums.Events.DYNAMIC_VOLUME_DIMENSION_GROUP_CHANGED,
handleDimensionGroupChange
Enums.Events.DYNAMIC_VOLUME_TIME_POINT_INDEX_CHANGED,
handleTimePointIndexChange
);
};
}, [dynamicInfo]);
@@ -199,17 +200,17 @@ function RenderCinePlayer({
return;
}
const { volumeId, dimensionGroupNumber, numDimensionGroups, splittingTag } = dynamicInfo || {};
const { volumeId, timePointIndex, numTimePoints, splittingTag } = dynamicInfo || {};
const volume = cache.getVolume(volumeId, true);
volume.dimensionGroupNumber = dimensionGroupNumber;
volume.timePointIndex = timePointIndex;
setDynamicInfo({ volumeId, dimensionGroupNumber, numDimensionGroups, label: splittingTag });
setDynamicInfo({ volumeId, timePointIndex, numTimePoints, label: splittingTag });
}, []);
const updateDynamicInfo = useCallback(props => {
const { volumeId, dimensionGroupNumber } = props;
const { volumeId, timePointIndex } = props;
const volume = cache.getVolume(volumeId, true);
volume.dimensionGroupNumber = dimensionGroupNumber;
volume.timePointIndex = timePointIndex;
}, []);
return (
@@ -244,4 +245,4 @@ function RenderCinePlayer({
);
}
export default WrappedCinePlayer;
export default WrappedCinePlayer;
@@ -1,8 +1,6 @@
import React, { useCallback, useEffect, useRef, useState, ReactElement } from 'react';
import PropTypes from 'prop-types';
import { useSystem } from '@ohif/core';
import { Button } from '@ohif/ui';
import { Icons } from '@ohif/ui-next';
import { Button, Icon, ProgressLoadingBar } from '@ohif/ui';
import DicomFileUploader, {
EVENTS,
UploadStatus,
@@ -41,11 +39,6 @@ function DicomUploadProgress({
dicomFileUploaderArr,
onComplete,
}: DicomUploadProgressProps): ReactElement {
const { servicesManager } = useSystem();
const ProgressLoadingBar =
servicesManager.services.customizationService.getCustomization('ui.progressLoadingBar');
const [totalUploadSize] = useState(
dicomFileUploaderArr.reduce((acc, fileUploader) => acc + fileUploader.getFileSize(), 0)
);
@@ -259,24 +252,22 @@ function DicomUploadProgress({
}, [getPercentCompleteRounded, percentComplete]);
/**
* Gets the CSS style for the 'n of m' (files completed) text.
* The width changes according to numFilesCompleted and can vary,
* e.g. "1 of 200", "10 of 200", "100 of 200" all have differents width.
* Gets the css style for the 'n of m' (files completed) text. The only css attribute
* of the style is width such that the 'n of m' is always a fixed width and thus
* as each file completes uploading the text on screen does not constantly shift
* left and right.
*/
const getNofMFilesStyle = useCallback(() => {
// the number of digits accounts for the digits being on each side of the ' of '
const numDigits =
numFilesCompleted.toString().length + dicomFileUploaderArr.length.toString().length;
// The number of digits + 3 additional characters (accounts for ' of ').
// Even though intuitively 4 should be better, this is the most accurate width.
// The font may play a part in this discrepancy.
const numChars = numDigits + 3;
const numDigits = 2 * dicomFileUploaderArr.length.toString().length;
// the number of digits + 2 spaces and 2 characters for ' of '
const numChars = numDigits + 4;
return { width: `${numChars}ch` };
}, [numFilesCompleted]);
}, []);
const getNumCompletedAndTimeRemainingComponent = (): ReactElement => {
return (
<div className="bg-primary-dark flex h-14 items-center px-1 pb-4 text-lg text-white">
<div className="bg-primary-dark flex h-14 items-center px-1 pb-4 text-lg">
{numFilesCompleted === dicomFileUploaderArr.length ? (
<>
<span className={NO_WRAP_ELLIPSIS_CLASS_NAMES}>{`${dicomFileUploaderArr.length} ${
@@ -292,24 +283,21 @@ function DicomUploadProgress({
</>
) : (
<>
<div className="flex flex-wrap">
<span
style={getNofMFilesStyle()}
className={classNames(NO_WRAP_ELLIPSIS_CLASS_NAMES, 'text-right')}
>
{`${numFilesCompleted} of ${dicomFileUploaderArr.length}`}&nbsp;
</span>
<span className={NO_WRAP_ELLIPSIS_CLASS_NAMES}>{' files completed.'}</span>
<br />
<span>
{timeRemaining ? `Less than ${getFormattedTimeRemaining()} remaining. ` : ''}
</span>
</div>
<span
className={
'text-primary hover:text-primary-lightactive:text-aqua-pale ml-auto cursor-pointer whitespace-nowrap'
}
style={getNofMFilesStyle()}
className={classNames(NO_WRAP_ELLIPSIS_CLASS_NAMES, 'text-end')}
>
{`${numFilesCompleted} of ${dicomFileUploaderArr.length}`}&nbsp;
</span>
<span className={NO_WRAP_ELLIPSIS_CLASS_NAMES}>{' files completed.'}&nbsp;</span>
<span className={NO_WRAP_ELLIPSIS_CLASS_NAMES}>
{timeRemaining ? `Less than ${getFormattedTimeRemaining()} remaining. ` : ''}
</span>
<span
className={classNames(
NO_WRAP_ELLIPSIS_CLASS_NAMES,
'text-primary-active hover:text-primary-light active:text-aqua-pale ml-auto cursor-pointer'
)}
onClick={cancelAllUploads}
>
Cancel All Uploads
@@ -325,10 +313,10 @@ function DicomUploadProgress({
<div className="ml-auto flex w-6 justify-center">
{numFails > 0 && (
<div onClick={() => setShowFailedOnly(currentShowFailedOnly => !currentShowFailedOnly)}>
<Icons.ByName
<Icon
className="cursor-pointer"
name="icon-status-alert"
></Icons.ByName>
></Icon>
</div>
)}
</div>
@@ -359,7 +347,7 @@ function DicomUploadProgress({
></ProgressLoadingBar>
</div>
<div className="ml-1 flex w-24 items-center">
<div className="w-10 text-right text-foreground">{`${getPercentCompleteRounded()}%`}</div>
<div className="w-10 text-right">{`${getPercentCompleteRounded()}%`}</div>
{getShowFailedOnlyIconComponent()}
</div>
</>
@@ -6,7 +6,7 @@ import DicomFileUploader, {
UploadRejection,
UploadStatus,
} from '../../utils/DicomFileUploader';
import { Icons } from '@ohif/ui-next';
import { Icon } from '@ohif/ui';
type DicomUploadProgressItemProps = {
dicomFileUploader: DicomFileUploader;
@@ -54,17 +54,17 @@ const DicomUploadProgressItem = memo(
switch (dicomFileUploader.getStatus()) {
case UploadStatus.Success:
return (
<Icons.ByName
<Icon
name="status-tracked"
className="text-primary-light"
/>
></Icon>
);
case UploadStatus.InProgress:
return <Icons.ByName name="icon-transferring" />;
return <Icon name="icon-transferring"></Icon>;
case UploadStatus.Failed:
return <Icons.ByName name="icon-alert-small" />;
return <Icon name="icon-alert-small"></Icon>;
case UploadStatus.Cancelled:
return <Icons.ByName name="icon-alert-outline" />;
return <Icon name="icon-alert-outline"></Icon>;
default:
return <></>;
}
@@ -75,7 +75,7 @@ const DicomUploadProgressItem = memo(
<div className="self-top flex w-0 shrink grow flex-col gap-1">
<div className="flex gap-4">
<div className="flex w-6 shrink-0 items-center justify-center">{getStatusIcon()}</div>
<div className="overflow-hidden text-ellipsis whitespace-nowrap text-white">
<div className="overflow-hidden text-ellipsis whitespace-nowrap">
{dicomFileUploader.getFileName()}
</div>
</div>
@@ -88,8 +88,9 @@ const DicomUploadProgressItem = memo(
<div className="w-10 text-right">{percentComplete}%</div>
)}
<div className="ml-auto flex cursor-pointer">
<Icons.Close
className="text-primary self-center"
<Icon
className="text-primary-active self-center"
name="close"
onClick={cancelUpload}
/>
</div>
Loaded 100 of 2986 files, more files were not shown because too many files have changed in this diff. Show more