Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1a4c09b74 | ||
|
|
02572b4928 | ||
|
|
fd1d29820a | ||
|
|
6c63a917d3 | ||
|
|
caa6062c41 | ||
|
|
7f9743a020 | ||
|
|
a2c4161477 | ||
|
|
42cfce60f5 | ||
|
|
244949bab6 | ||
|
|
e87ec7b7f5 | ||
|
|
d98889f1eb | ||
|
|
817d43ee62 | ||
|
|
f396b30166 | ||
|
|
0bb3eeb068 | ||
|
|
1df21a9e07 | ||
|
|
fd1a9e2abe | ||
|
|
e34a057263 | ||
|
|
27e02825a4 | ||
|
|
8fd3af1e13 | ||
|
|
1334ba8eb0 | ||
|
|
a870b3cc60 | ||
|
|
52687a7113 | ||
|
|
421de23ba3 | ||
|
|
adc8d9ea0f | ||
|
|
f65639c2b0 | ||
|
|
cdd75bda2a | ||
|
|
d92fe44d3c | ||
|
|
d90c2ac024 | ||
|
|
7ce5e1d2d3 | ||
|
|
a3bf47f9bf | ||
|
|
81f79a26ad | ||
|
|
60b4b5d7cf | ||
|
|
69c54d4f49 | ||
|
|
0f8868a232 | ||
|
|
1bbfee4087 | ||
|
|
33c7b59611 | ||
|
|
b17f753e62 | ||
|
|
c77a2ef5c9 | ||
|
|
348edd4717 | ||
|
|
407060658a | ||
|
|
fc00f9d505 | ||
|
|
bc3d080672 | ||
|
|
09353354c3 | ||
|
|
0a12320083 | ||
|
|
813ee5ed4d | ||
|
|
6085b5f733 | ||
|
|
ffe12789dd | ||
|
|
0b68429dfb | ||
|
|
b747ea5144 | ||
|
|
caadf089df | ||
|
|
e0fc69c181 | ||
|
|
2b9765304f | ||
|
|
57730483e9 | ||
|
|
731b64e321 | ||
|
|
e97a09b0f4 | ||
|
|
f63d8a73d8 | ||
|
|
f10adf82f4 | ||
|
|
6174109d5a | ||
|
|
244e822afc | ||
|
|
cbdeeae597 | ||
|
|
7f6de72e8e | ||
|
|
1f514e5f1b | ||
|
|
f3fed511b9 | ||
|
|
31796d272c | ||
|
|
b41e6f93f3 | ||
|
|
882a45d398 | ||
|
|
fb0cd9355f | ||
|
|
2b12193020 | ||
|
|
9a626a2064 | ||
|
|
903777044a | ||
|
|
5e4725860d | ||
|
|
670470119f | ||
|
|
0d4f90f3cd | ||
|
|
43dd87c2c1 | ||
|
|
98f8583fba | ||
|
|
97a9a505ca | ||
|
|
bb74f3a761 | ||
|
|
aa33397fe6 | ||
|
|
8ae731fe4c | ||
|
|
add3416e11 | ||
|
|
136969bf18 | ||
|
|
e3e039ad47 | ||
|
|
0797eff0b7 | ||
|
|
55a5f04923 | ||
|
|
ed9e4d6751 | ||
|
|
b6119f8e29 | ||
|
|
2e38f4b2e4 | ||
|
|
e03abc45f5 | ||
|
|
f03d64ce39 | ||
|
|
e895bfb1aa | ||
|
|
17358c3f4b | ||
|
|
ec7337e801 | ||
|
|
538ea2516d | ||
|
|
fe687cd2f9 | ||
|
|
bdb9af5c4b | ||
|
|
db0f751a68 | ||
|
|
08fdb10f6d | ||
|
|
95c18b4986 | ||
|
|
e684558783 | ||
|
|
9104e7be50 | ||
|
|
81ad25b39f | ||
|
|
54d2cde6da | ||
|
|
9edc366ff7 | ||
|
|
79c10bb424 | ||
|
|
d8055e458f | ||
|
|
5c04e34c8f | ||
|
|
c59c5b3f14 | ||
|
|
c4adcfd39e | ||
|
|
0fbaf95971 | ||
|
|
a2a7e05e7d | ||
|
|
9da8647c84 | ||
|
|
e62f5f8dd2 | ||
|
|
dc6e9d6bb5 | ||
|
|
84a097212b | ||
|
|
2381810ba9 | ||
|
|
31b1adfa59 | ||
|
|
cd28877ed8 | ||
|
|
e81a39f5be | ||
|
|
9a2cf70504 | ||
|
|
d70eae3eb0 | ||
|
|
8481e94d4d | ||
|
|
19b22c39ae | ||
|
|
05802a3cf1 | ||
|
|
75d88bc454 | ||
|
|
9827d1bf84 | ||
|
|
e208f2e6a9 | ||
|
|
9c1a3c3183 | ||
|
|
ecaf578f92 | ||
|
|
d26adf92bd | ||
|
|
5ee832b195 | ||
|
|
8baba22a2e | ||
|
|
1d86b1a102 | ||
|
|
25e6817248 | ||
|
|
681384b742 | ||
|
|
54e07c281f | ||
|
|
9dedccc7f9 | ||
|
|
621e63b3e1 | ||
|
|
64cf3b324d | ||
|
|
b7f315ca47 | ||
|
|
caaa032c4b |
No files matched your search
@@ -62,7 +62,7 @@ echo "Done generating documentation output"
|
||||
echo 'STARTING PUBLISH'
|
||||
|
||||
# WILL ALWAYS FAIL IF INITIATED FROM PR BRANCH
|
||||
./node_modules/.bin/gh-pages \
|
||||
npx gh-pages \
|
||||
--silent \
|
||||
--repo https://$GITHUB_TOKEN@github.com/OHIF/Viewers.git \
|
||||
--message 'Autogenerated Message: [ci skip]' \
|
||||
|
||||
@@ -14,52 +14,53 @@ version: 2.1
|
||||
orbs:
|
||||
codecov: codecov/codecov@1.0.5
|
||||
cypress: cypress-io/cypress@1.11.0
|
||||
executors:
|
||||
# Custom executor to override Cypress config
|
||||
deploy-to-prod-executor:
|
||||
docker:
|
||||
- image: 'cypress/browsers:node10.16.0-chrome77'
|
||||
environment:
|
||||
CYPRESS_BASE_URL: https://ohif-staging.netlify.com/
|
||||
|
||||
defaults: &defaults
|
||||
docker:
|
||||
- image: circleci/node:12.9.1
|
||||
environment:
|
||||
TERM: xterm # Enable colors in term
|
||||
QUICK_BUILD: true
|
||||
working_directory: ~/repo
|
||||
|
||||
jobs:
|
||||
###
|
||||
# Workflow: PR_CHECKS
|
||||
###
|
||||
UNIT_TESTS:
|
||||
<<: *defaults
|
||||
steps:
|
||||
# Update yarn
|
||||
- run: yarn -v
|
||||
#- run: rm -rf ~/.yarn && npm i -g yarn && yarn -v
|
||||
|
||||
# Enable yarn workspaces
|
||||
- run: yarn config set workspaces-experimental true
|
||||
|
||||
# Checkout code and ALL Git Tags
|
||||
- checkout:
|
||||
post:
|
||||
- git fetch --all
|
||||
|
||||
- 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: 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: yarn run test:unit:ci
|
||||
|
||||
# PLATFORM/VIEWER
|
||||
- run:
|
||||
name: 'VIEWER: Combine report output'
|
||||
@@ -73,7 +74,6 @@ jobs:
|
||||
- codecov/upload:
|
||||
file: '/home/circleci/repo/platform/viewer/coverage/reports'
|
||||
flags: 'viewer'
|
||||
|
||||
# PLATFORM/CORE
|
||||
- run:
|
||||
name: 'CORE: Combine report output'
|
||||
@@ -88,145 +88,9 @@ jobs:
|
||||
file: '/home/circleci/repo/platform/core/coverage/reports'
|
||||
flags: 'core'
|
||||
|
||||
UNIT_TESTS_PERSIST_WORKSPACE:
|
||||
<<: *defaults
|
||||
steps:
|
||||
# Update yarn
|
||||
#- run: rm -rf ~/.yarn && npm i -g yarn && yarn -v
|
||||
|
||||
# Enable yarn workspaces
|
||||
- run: yarn config set workspaces-experimental true
|
||||
|
||||
# Checkout code and ALL Git Tags
|
||||
- checkout:
|
||||
post:
|
||||
- git fetch --all
|
||||
|
||||
- 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: 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: yarn run test:unit:ci
|
||||
|
||||
# PLATFORM/VIEWER
|
||||
- run:
|
||||
name: 'VIEWER: Combine report output'
|
||||
command: |
|
||||
viewerCov="/home/circleci/repo/platform/viewer/coverage"
|
||||
touch "${viewerCov}/reports"
|
||||
cat "${viewerCov}/clover.xml" >> "${viewerCov}/reports"
|
||||
echo "\<<\<<\<< EOF" >> "${viewerCov}/reports"
|
||||
cat "${viewerCov}/lcov.info" >>"${viewerCov}/reports"
|
||||
echo "\<<\<<\<< EOF" >> "${viewerCov}/reports"
|
||||
- codecov/upload:
|
||||
file: '/home/circleci/repo/platform/viewer/coverage/reports'
|
||||
flags: 'viewer'
|
||||
|
||||
# PLATFORM/CORE
|
||||
- run:
|
||||
name: 'CORE: Combine report output'
|
||||
command: |
|
||||
coreCov="/home/circleci/repo/platform/core/coverage"
|
||||
touch "${coreCov}/reports"
|
||||
cat "${coreCov}/clover.xml" >> "${coreCov}/reports"
|
||||
echo "\<<\<<\<< EOF" >> "${coreCov}/reports"
|
||||
cat "${coreCov}/lcov.info" >> "${coreCov}/reports"
|
||||
echo "\<<\<<\<< EOF" >> "${coreCov}/reports"
|
||||
- codecov/upload:
|
||||
file: '/home/circleci/repo/platform/core/coverage/reports'
|
||||
flags: 'core'
|
||||
|
||||
# Persist :+1:
|
||||
- persist_to_workspace: &persist_to_workspace
|
||||
root: ~/repo
|
||||
paths: .
|
||||
|
||||
NPM_PUBLISH:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: ~/repo
|
||||
- run:
|
||||
name: Avoid hosts unknown for github
|
||||
command:
|
||||
mkdir ~/.ssh/ && echo -e "Host github.com\n\tStrictHostKeyChecking
|
||||
no\n" > ~/.ssh/config
|
||||
- run: git config --global user.email "danny.ri.brown+ohif-bot@gmail.com"
|
||||
- run: git config --global user.name "ohif-bot"
|
||||
- run:
|
||||
name: Authenticate with registry
|
||||
command:
|
||||
echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/repo/.npmrc
|
||||
- run: npx lerna version
|
||||
- run: npx lerna publish from-package
|
||||
|
||||
# Persist :+1:
|
||||
- persist_to_workspace:
|
||||
root: ~/repo
|
||||
paths: .
|
||||
|
||||
DOCS_PUBLISH:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: ~/repo
|
||||
- run:
|
||||
name: Avoid hosts unknown for github
|
||||
command:
|
||||
mkdir ~/.ssh/ && echo -e "Host github.com\n\tStrictHostKeyChecking
|
||||
no\n" > ~/.ssh/config
|
||||
- run: git config --global user.email "danny.ri.brown+ohif-bot@gmail.com"
|
||||
- run: git config --global user.name "ohif-bot"
|
||||
- run: yarn global add gitbook-cli
|
||||
- run: yarn global add gh-pages
|
||||
- run: chmod +x ~/repo/.circleci/build-and-publish-docs.sh
|
||||
- run: ~/repo/.circleci/build-and-publish-docs.sh
|
||||
|
||||
DOCKER_MASTER_PUBLISH:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: ~/repo
|
||||
- setup_remote_docker:
|
||||
docker_layer_caching: false
|
||||
- run:
|
||||
name: Build and push Docker image
|
||||
command: |
|
||||
# This file will exist if a new version was published by
|
||||
# our `semantic-release` command in the previous job
|
||||
if [[ ! -e platform/viewer/success_version.txt ]]; then
|
||||
exit 0
|
||||
else
|
||||
# Remove npm config
|
||||
rm -f ./.npmrc
|
||||
# Set our version number using vars
|
||||
export IMAGE_VERSION=$(cat platform/viewer/success_version.txt)
|
||||
export IMAGE_VERSION_FULL=v$IMAGE_VERSION.${CIRCLE_BUILD_NUM}
|
||||
echo $IMAGE_VERSION
|
||||
echo $IMAGE_VERSION_FULL
|
||||
# Build our image, auth, and push
|
||||
docker build --tag ohif/$IMAGE_NAME:$IMAGE_VERSION_FULL --tag ohif/$IMAGE_NAME:latest .
|
||||
echo $DOCKER_PWD | docker login -u $DOCKER_LOGIN --password-stdin
|
||||
docker push ohif/$IMAGE_NAME:$IMAGE_VERSION_FULL
|
||||
docker push ohif/$IMAGE_NAME:latest
|
||||
fi
|
||||
|
||||
###
|
||||
# Workflow: PR_OPTIONAL_DOCKER_PUBLISH
|
||||
###
|
||||
DOCKER_PR_PUBLISH:
|
||||
<<: *defaults
|
||||
steps:
|
||||
@@ -264,55 +128,190 @@ jobs:
|
||||
echo $DOCKER_PWD | docker login -u $DOCKER_LOGIN --password-stdin
|
||||
docker push ohif/viewer:PR_BUILD-$CIRCLE_BUILD_NUM
|
||||
|
||||
VIEWER_DEMO_BUILD:
|
||||
###
|
||||
# Workflow: DEPLOY
|
||||
###
|
||||
BUILD:
|
||||
<<: *defaults
|
||||
steps:
|
||||
# Checkout code and ALL Git Tags
|
||||
- checkout:
|
||||
post:
|
||||
- git fetch --all
|
||||
- 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: 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: 'Build the OHIF Viewer'
|
||||
command: yarn run build
|
||||
no_output_timeout: 45m
|
||||
# - run:
|
||||
# name: 'Upload SourceMaps, Send Deploy Notification'
|
||||
# command: |
|
||||
# # export FILE_1=$(find ./build/static/js -type f -name "2.*.js" -exec basename {} \;)
|
||||
# # export FILE_MAIN=$(find ./build/static/js -type f -name "main.*.js" -exec basename {} \;)
|
||||
# # export FILE_RUNTIME_MAIN=$(find ./build/static/js -type f -name "runtime~main.*.js" -exec basename {} \;)
|
||||
# # curl https://api.rollbar.com/api/1/sourcemap -F source_map=@build/static/js/$FILE_1.map -F access_token=$ROLLBAR_TOKEN -F version=$CIRCLE_SHA1 -F minified_url=https://$GOOGLE_STORAGE_BUCKET/static/js/$FILE_1
|
||||
# # curl https://api.rollbar.com/api/1/sourcemap -F source_map=@build/static/js/$FILE_MAIN.map -F access_token=$ROLLBAR_TOKEN -F version=$CIRCLE_SHA1 -F minified_url=https://$GOOGLE_STORAGE_BUCKET/static/js/$FILE_MAIN
|
||||
# # curl https://api.rollbar.com/api/1/sourcemap -F source_map=@build/static/js/$FILE_RUNTIME_MAIN.map -F access_token=$ROLLBAR_TOKEN -F version=$CIRCLE_SHA1 -F minified_url=https://$GOOGLE_STORAGE_BUCKET/static/js/$FILE_RUNTIME_MAIN
|
||||
# curl --request POST https://api.rollbar.com/api/1/deploy/ -F access_token=$ROLLBAR_TOKEN -F environment=$GOOGLE_STORAGE_BUCKET -F revision=$CIRCLE_SHA1 -F local_username=CircleCI
|
||||
# Persist :+1:
|
||||
- persist_to_workspace:
|
||||
root: ~/repo
|
||||
paths:
|
||||
- platform/viewer/dist
|
||||
- netlify.toml
|
||||
- .netlify
|
||||
|
||||
DEPLOY_TO_DEV:
|
||||
docker:
|
||||
- image: circleci/node:12.9.1
|
||||
environment:
|
||||
TERM: xterm
|
||||
NETLIFY_SITE_ID: 32708787-c9b0-4634-b50f-7ca41952da77
|
||||
working_directory: ~/repo
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: ~/repo
|
||||
# Build & Test
|
||||
- run: cd .netlify && npm install
|
||||
- run:
|
||||
name: 'Build Demo Site'
|
||||
command: yarn run build:demo
|
||||
no_output_timeout: 45m
|
||||
cp .netlify/deploy-workflow/_redirects platform/viewer/dist/_redirects
|
||||
- run: cd .netlify && npm run deploy
|
||||
|
||||
DEPLOY_TO_STAGING:
|
||||
docker:
|
||||
- image: circleci/node:12.9.1
|
||||
environment:
|
||||
TERM: xterm
|
||||
NETLIFY_SITE_ID: c7502ae3-b150-493c-8422-05701e44a969
|
||||
working_directory: ~/repo
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: ~/repo
|
||||
- run: cd .netlify && npm install
|
||||
- run:
|
||||
name: 'Upload SourceMaps, Send Deploy Notification'
|
||||
cp .netlify/deploy-workflow/_redirects platform/viewer/dist/_redirects
|
||||
- run: cd .netlify && npm run deploy
|
||||
|
||||
DEPLOY_TO_PRODUCTION:
|
||||
docker:
|
||||
- image: circleci/node:12.9.1
|
||||
environment:
|
||||
TERM: xterm
|
||||
NETLIFY_SITE_ID: 79c4a5da-5c95-4dc9-84f7-45fd9dfe21b0
|
||||
working_directory: ~/repo
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: ~/repo
|
||||
- run: cd .netlify && npm install
|
||||
- run:
|
||||
cp .netlify/deploy-workflow/_redirects platform/viewer/dist/_redirects
|
||||
- run: cd .netlify && npm run deploy
|
||||
|
||||
###
|
||||
# Workflow: RELEASE
|
||||
###
|
||||
NPM_PUBLISH:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- run: yarn -v
|
||||
# Checkout code and ALL Git Tags
|
||||
- checkout:
|
||||
post:
|
||||
- git fetch --all
|
||||
# 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: 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: |
|
||||
# export FILE_1=$(find ./build/static/js -type f -name "2.*.js" -exec basename {} \;)
|
||||
# export FILE_MAIN=$(find ./build/static/js -type f -name "main.*.js" -exec basename {} \;)
|
||||
# export FILE_RUNTIME_MAIN=$(find ./build/static/js -type f -name "runtime~main.*.js" -exec basename {} \;)
|
||||
# curl https://api.rollbar.com/api/1/sourcemap -F source_map=@build/static/js/$FILE_1.map -F access_token=$ROLLBAR_TOKEN -F version=$CIRCLE_SHA1 -F minified_url=https://$GOOGLE_STORAGE_BUCKET/static/js/$FILE_1
|
||||
# curl https://api.rollbar.com/api/1/sourcemap -F source_map=@build/static/js/$FILE_MAIN.map -F access_token=$ROLLBAR_TOKEN -F version=$CIRCLE_SHA1 -F minified_url=https://$GOOGLE_STORAGE_BUCKET/static/js/$FILE_MAIN
|
||||
# curl https://api.rollbar.com/api/1/sourcemap -F source_map=@build/static/js/$FILE_RUNTIME_MAIN.map -F access_token=$ROLLBAR_TOKEN -F version=$CIRCLE_SHA1 -F minified_url=https://$GOOGLE_STORAGE_BUCKET/static/js/$FILE_RUNTIME_MAIN
|
||||
curl --request POST https://api.rollbar.com/api/1/deploy/ -F access_token=$ROLLBAR_TOKEN -F environment=$GOOGLE_STORAGE_BUCKET -F revision=$CIRCLE_SHA1 -F local_username=CircleCI
|
||||
# Persist :+1:
|
||||
rm -rf ~/.ssh
|
||||
mkdir ~/.ssh/
|
||||
echo -e "Host github.com\n\tStrictHostKeyChecking no\n" > ~/.ssh/config
|
||||
git config --global user.email "danny.ri.brown+ohif-bot@gmail.com"
|
||||
git config --global user.name "ohif-bot"
|
||||
- run:
|
||||
name: Authenticate with NPM registry
|
||||
command:
|
||||
echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/repo/.npmrc
|
||||
- run: npx lerna version
|
||||
- run: npx lerna publish from-package
|
||||
- persist_to_workspace:
|
||||
root: ~/repo
|
||||
paths: .
|
||||
|
||||
VIEWER_DEMO_PUBLISH:
|
||||
working_directory: ~/repo
|
||||
docker:
|
||||
- image: google/cloud-sdk
|
||||
DOCS_PUBLISH:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- run:
|
||||
name: Avoid hosts unknown for github
|
||||
command: |
|
||||
rm -rf ~/.ssh
|
||||
mkdir ~/.ssh/
|
||||
echo -e "Host github.com\n\tStrictHostKeyChecking no\n" > ~/.ssh/config
|
||||
git config --global user.email "danny.ri.brown+ohif-bot@gmail.com"
|
||||
git config --global user.name "ohif-bot"
|
||||
- run: yarn global add gitbook-cli gh-pages
|
||||
- run: chmod +x ~/repo/.circleci/build-and-publish-docs.sh
|
||||
- run: ~/repo/.circleci/build-and-publish-docs.sh
|
||||
|
||||
DOCKER_MASTER_PUBLISH:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- attach_workspace:
|
||||
at: ~/repo
|
||||
- setup_remote_docker:
|
||||
docker_layer_caching: false
|
||||
- run:
|
||||
name: Deploy latest version to viewer.ohif.org
|
||||
name: Build and push Docker image
|
||||
command: |
|
||||
# https://circleci.com/docs/2.0/google-auth/#authorization
|
||||
echo $GCLOUD_SERVICE_KEY | gcloud auth activate-service-account --key-file=-
|
||||
gcloud --quiet config set project ${GOOGLE_PROJECT_ID}
|
||||
gcloud --quiet config set compute/zone ${GOOGLE_COMPUTE_ZONE}
|
||||
# Remove all files in Storage Bucket; Push latest
|
||||
gsutil -m rm gs://$GOOGLE_STORAGE_BUCKET/**
|
||||
gsutil -m rsync -R platform/viewer/dist gs://$GOOGLE_STORAGE_BUCKET
|
||||
# This file will exist if a new version was published by
|
||||
# our command in the previous job. Created in npm postpublish hook
|
||||
# in the `platform/viewer` project.
|
||||
if [[ ! -e platform/viewer/success_version.txt ]]; then
|
||||
exit 0
|
||||
else
|
||||
# Remove npm config
|
||||
rm -f ./.npmrc
|
||||
# Set our version number using vars
|
||||
export IMAGE_VERSION=$(cat platform/viewer/success_version.txt)
|
||||
export IMAGE_VERSION_FULL=v$IMAGE_VERSION.${CIRCLE_BUILD_NUM}
|
||||
echo $IMAGE_VERSION
|
||||
echo $IMAGE_VERSION_FULL
|
||||
# Build our image, auth, and push
|
||||
docker build --tag ohif/$IMAGE_NAME:$IMAGE_VERSION_FULL --tag ohif/$IMAGE_NAME:latest .
|
||||
echo $DOCKER_PWD | docker login -u $DOCKER_LOGIN --password-stdin
|
||||
docker push ohif/$IMAGE_NAME:$IMAGE_VERSION_FULL
|
||||
docker push ohif/$IMAGE_NAME:latest
|
||||
fi
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
|
||||
# PULL REQUESTS
|
||||
PR_CHECKS:
|
||||
jobs:
|
||||
- UNIT_TESTS:
|
||||
@@ -379,82 +378,66 @@ workflows:
|
||||
requires:
|
||||
- AWAIT_APPROVAL
|
||||
|
||||
# MERGE TO MASTER
|
||||
RELEASE:
|
||||
###
|
||||
# Our workflow for building, deploying, and promoting builds across our
|
||||
# development, staging, and production environments.
|
||||
###
|
||||
DEPLOY:
|
||||
jobs:
|
||||
- UNIT_TESTS_PERSIST_WORKSPACE:
|
||||
- BUILD:
|
||||
filters:
|
||||
branches:
|
||||
only: master
|
||||
# E2E: PWA + Persist
|
||||
- cypress/run:
|
||||
name: 'E2E: PWA'
|
||||
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
|
||||
record: false
|
||||
store_artifacts: true
|
||||
working_directory: platform/viewer
|
||||
build: npx cross-env QUICK_BUILD=true yarn run build
|
||||
start: yarn run test:e2e:serve
|
||||
wait-on: 'http://localhost:3000'
|
||||
command: 'yarn run test:e2e:ci'
|
||||
cache-key: 'yarn-packages-{{ checksum "yarn.lock" }}'
|
||||
no-workspace: true # Don't persist workspace
|
||||
post-steps:
|
||||
- store_artifacts:
|
||||
path: platform/viewer/cypress/screenshots
|
||||
- store_test_results:
|
||||
path: cypress/results
|
||||
- DEPLOY_TO_DEV:
|
||||
requires:
|
||||
- UNIT_TESTS_PERSIST_WORKSPACE
|
||||
# E2E: script-tag
|
||||
- cypress/run:
|
||||
name: 'E2E: Script Tag'
|
||||
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
|
||||
record: false
|
||||
store_artifacts: false
|
||||
working_directory: platform/viewer
|
||||
build: npx cross-env QUICK_BUILD=true yarn run build:package
|
||||
start: yarn run test:e2e:serve
|
||||
spec: 'cypress/integration/common/**/*'
|
||||
wait-on: 'http://localhost:3000'
|
||||
cache-key: 'yarn-packages-{{ checksum "yarn.lock" }}'
|
||||
no-workspace: true # Don't persist workspace
|
||||
post-steps:
|
||||
- store_artifacts:
|
||||
path: platform/viewer/cypress/screenshots
|
||||
- store_test_results:
|
||||
path: cypress/results
|
||||
- BUILD
|
||||
- PROMOTE_TO_STAGING:
|
||||
type: approval
|
||||
requires:
|
||||
- UNIT_TESTS_PERSIST_WORKSPACE
|
||||
# Update NPM
|
||||
- DEPLOY_TO_DEV
|
||||
- DEPLOY_TO_STAGING:
|
||||
requires:
|
||||
- PROMOTE_TO_STAGING
|
||||
- PROMOTE_TO_PRODUCTION:
|
||||
type: approval
|
||||
requires:
|
||||
- DEPLOY_TO_STAGING
|
||||
- DEPLOY_TO_PRODUCTION:
|
||||
requires:
|
||||
- PROMOTE_TO_PRODUCTION
|
||||
###
|
||||
# Unit and E2E tests have already run for PR_CHECKS
|
||||
# Re-running should not gain us any confidence here
|
||||
###
|
||||
RELEASE:
|
||||
jobs:
|
||||
- NPM_PUBLISH:
|
||||
requires:
|
||||
- 'E2E: Script Tag'
|
||||
- 'E2E: PWA'
|
||||
# Update docs.ohif.org
|
||||
filters:
|
||||
branches:
|
||||
only: master
|
||||
- DOCS_PUBLISH:
|
||||
requires:
|
||||
- 'E2E: Script Tag'
|
||||
- 'E2E: PWA'
|
||||
- VIEWER_DEMO_BUILD:
|
||||
requires:
|
||||
- 'E2E: Script Tag'
|
||||
- 'E2E: PWA'
|
||||
# Update viewer.ohif.org
|
||||
- VIEWER_DEMO_PUBLISH:
|
||||
requires:
|
||||
- VIEWER_DEMO_BUILD
|
||||
# Update hub.docker.org
|
||||
filters:
|
||||
branches:
|
||||
only: master
|
||||
- DOCKER_MASTER_PUBLISH:
|
||||
requires:
|
||||
- NPM_PUBLISH
|
||||
#
|
||||
#
|
||||
# Potentially use to E2E test STAGING environment
|
||||
# before publishing to production
|
||||
# - cypress/run:
|
||||
# name: 'E2E: PWA'
|
||||
# executor: deploy-to-prod-executor
|
||||
# browser: chrome
|
||||
# working_directory: platform/viewer
|
||||
# cache-key: 'yarn-packages-{{ checksum "yarn.lock" }}'
|
||||
# yarn: true
|
||||
# record: false
|
||||
# no-workspace: true
|
||||
# store_artifacts: true
|
||||
# post-steps:
|
||||
# - store_artifacts:
|
||||
# path: platform/viewer/cypress/screenshots
|
||||
# - store_test_results:
|
||||
# path: cypress/results
|
||||
@@ -2,7 +2,7 @@
|
||||
name: "\U0001F41B Bug report"
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: 'Community: Report :bug:'
|
||||
labels: 'Community: Report :bug:, Awaiting Reproduction, Triage :white_flag:'
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: "\U0001F680 Feature request"
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: 'Community: Request :hand:'
|
||||
labels: 'Community: Request :hand:, Triage :white_flag:'
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: "\U0001F917 Support Question"
|
||||
about: "I have a question \U0001F4AC"
|
||||
title: ''
|
||||
labels: 'Community: Question :question:'
|
||||
labels: 'Community: Question :question:, Triage :white_flag:'
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
|
||||
@@ -1,23 +1,10 @@
|
||||
### Request Checklist
|
||||
### PR Checklist
|
||||
|
||||
- [] Brief description of changes
|
||||
- [] Links to any relevant issues
|
||||
- [] Required status checks are passing
|
||||
- [] `@mention` a maintainer to request a review
|
||||
|
||||
#### Special
|
||||
|
||||
> If this PR is not on a branch for this repo, it is "untrusted" and does not
|
||||
> have access to env vars. Reviewers can kick off a trusted docker deploy by:
|
||||
>
|
||||
> 1. Verifying there are no malicious changes to CI build
|
||||
> 2. Using the [git-push-fork-to-upstream-branch][script] script
|
||||
> 3. Executing the manual approval step in the CI workflow
|
||||
|
||||
- [] Request docker publish of PR
|
||||
|
||||
_Want to improve this process? Consider PR'ing the ability for reviewers to add
|
||||
a label to trigger this process._
|
||||
- [ ] Brief description of changes
|
||||
- [ ] Links to any relevant issues
|
||||
- [ ] Required status checks are passing
|
||||
- [ ] User cases if changes impact the user's experience
|
||||
- [ ] `@mention` a maintainer to request a review
|
||||
|
||||
<!--
|
||||
Links
|
||||
|
||||
@@ -15,7 +15,7 @@ exemptLabels:
|
||||
- 'Task: Refactor 🛠'
|
||||
- 'Task: Tests :microscope:'
|
||||
- 'PR: Awaiting Review 👀'
|
||||
- 'Triage 🏳️'
|
||||
- 'Triage :white_flag:'
|
||||
- 'Announcement 🎉'
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: 'Stale :baguette_bread:'
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Specific to our non-deploy-preview deploys
|
||||
# Confgure redirects using netlify.toml
|
||||
|
||||
# PWA Redirect
|
||||
/* /index.html 200
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=10",
|
||||
"npm": ">=6",
|
||||
"yarn": ">=1.16.0"
|
||||
},
|
||||
"scripts": {
|
||||
"deploy": "netlify deploy --prod --dir ./../platform/viewer/dist"
|
||||
},
|
||||
"devDependencies": {
|
||||
"netlify-cli": "^2.21.0"
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
[![NPM downloads][npm-downloads-image]][npm-url]
|
||||
[![Pulls][docker-pulls-img]][docker-image-url]
|
||||
[![MIT License][license-image]][license-url]
|
||||
[](https://app.fossa.io/projects/git%2Bgithub.com%2FOHIF%2FViewers?ref=badge_shield)
|
||||
|
||||
[![Netlify Status][netlify-image]][netlify-url]
|
||||
[![CircleCI][circleci-image]][circleci-url]
|
||||
@@ -324,3 +325,6 @@ MIT © [OHIF](https://github.com/OHIF)
|
||||
[extension-vtk]: extensions/vtk/README.md
|
||||
[vtk-npm]: https://www.npmjs.com/package/@ohif/extension-vtk
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
|
||||
[](https://app.fossa.io/projects/git%2Bgithub.com%2FOHIF%2FViewers?ref=badge_large)
|
||||
@@ -1,5 +1,6 @@
|
||||
# OHIF Viewers
|
||||
|
||||
- [Our Process](our-process.md)
|
||||
- Essentials
|
||||
- [Getting Started](essentials/getting-started.md)
|
||||
- [Installation](essentials/installation.md)
|
||||
@@ -20,11 +21,24 @@
|
||||
- [Extensions](advanced/architecture.md#misc-extensions)
|
||||
- [Diagram](advanced/architecture.md#diagram)
|
||||
- [Common Questions](advanced/architecture.md#common-questions)
|
||||
- [Extensions](advanced/extensions.md)
|
||||
- [Overview](advanced/extensions.md#overview)
|
||||
- [Modules](advanced/extensions.md#modules)
|
||||
- [Registering](advanced/extensions.md#registering-extensions)
|
||||
- [OHIF Maintained](advanced/extensions.md#ohif-maintained-extensions)
|
||||
- [Extensions](extensions/index.md)
|
||||
- [Registering](extensions/index.md#registering-an-extension)
|
||||
- [Lifecycle Hooks](extensions/index.md#lifecycle-hooks)
|
||||
- [preRegistration](extensions/lifecycle/pre-registration.md)
|
||||
- [Modules](extensions/index.md#modules)
|
||||
- [Commands](extensions/modules/commands.md)
|
||||
- [Panel](extensions/modules/panel.md)
|
||||
- [SOP Class Handler](extensions/modules/sop-class-handler.md)
|
||||
- [Toolbar](extensions/modules/toolbar.md)
|
||||
- [Viewport](extensions/modules/viewport.md)
|
||||
- [Contexts](extension/index.md#contexts)
|
||||
- [OHIF Maintained](extension/index.md#maintained-extensions)
|
||||
- [Services](services/index.md)
|
||||
- [Default](services/default/index.md)
|
||||
- [UI](services/ui/index.md)
|
||||
- [Dialog Service](services/ui/ui-dialog-service.md)
|
||||
- [Modal Service](services/ui/ui-modal-service.md)
|
||||
- [Notification Service](services/ui/ui-notification-service.md)
|
||||
- [Custom Tools](advanced/custom-tools.md)
|
||||
|
||||
---
|
||||
@@ -47,5 +61,6 @@
|
||||
|
||||
- [FAQ](frequently-asked-questions.md)
|
||||
- [Contributing](contributing/index.md)
|
||||
- [Continuous Integration](continous-integration.md)
|
||||
- [Browser Support](browser-support.md)
|
||||
- [Help](help.md)
|
||||
@@ -94,16 +94,16 @@ the viewer. Some common use cases include:
|
||||
- Adding surgical templating tools and reports
|
||||
- and many others
|
||||
|
||||
We expose common integration points via [extensions](./extensions.md) to make
|
||||
this possible. The viewer and many of our own extensions also offer
|
||||
We expose common integration points via [extensions](../extensions/index.md) to
|
||||
make this possible. The viewer and many of our own extensions also offer
|
||||
[configuration][configuration]. For a list of extensions maintained by OHIF,
|
||||
[check out this helpful table](./extensions.html#ohif-maintained-extensions).
|
||||
[check out this helpful table](../extensions/index.mdl#maintained-extensions).
|
||||
|
||||
If you find yourself thinking "I wish the Viewer could do X", and you can't
|
||||
accomplish it with an extension today, create a GitHub issue! We're actively
|
||||
looking for ways to improve our extensibility ^\_^
|
||||
|
||||
[Click here to read more about extensions!](./extensions.md)
|
||||
[Click here to read more about extensions!](../extensions/index.md)
|
||||
|
||||
### Diagram
|
||||
|
||||
@@ -145,7 +145,7 @@ you would need to recreate.
|
||||
[viewer-npm]: https://www.npmjs.com/package/@ohif/viewer
|
||||
[pwa]: https://developers.google.com/web/progressive-web-apps/
|
||||
[configuration]: ../essentials/configuration.md
|
||||
[extensions]: ./extensions.md
|
||||
[extensions]: ../extensions/index.md
|
||||
[core-github]: https://github.com/OHIF/viewers/platform/core
|
||||
[ui-github]: https://github.com/OHIF/Viewers/platform/ui
|
||||
<!-- prettier-ignore-end -->
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
This is not yet exposed in an easy/convenient way. Most tools are currently
|
||||
added by creating new Viewport, Toolbar, and SOPInstanceHandler extension
|
||||
modules. You can read more about that approach in [extensions](./extensions.md).
|
||||
modules. You can read more about that approach in
|
||||
[extensions](../extensions/index.md).
|
||||
|
||||
In the near future, we intend to improve the extensibility of tools for existing
|
||||
Viewports (like our Cornerstone.js and VTK.js viewports).
|
||||
@@ -1,263 +0,0 @@
|
||||
# Extensions
|
||||
|
||||
Extensions add new functionality to the viewer by registering one or more
|
||||
modules. They go one step further than configuration in that they allow us to
|
||||
inject custom React components, so long as they adhere to the module's
|
||||
interface. This can be something as simple as adding a new button to the
|
||||
toolbar, or as complex as a new viewport capable of rendering volumes in 3D.
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Modules](#modules)
|
||||
- [Commands](#commands)
|
||||
- [Hotkeys](#hotkeys)
|
||||
- [Toolbar](#toolbar)
|
||||
- [Panel](#panel)
|
||||
- [Viewport](#viewport)
|
||||
- [SOP Class Handler](#sopclasshandler)
|
||||
|
||||
## Overview
|
||||
|
||||
At a glance, an extension is a javascript object that has an `id` property, and
|
||||
one or more "module" methods. You can find an abbreviated extension below, or
|
||||
[view the source][example-ext-src] of our example extension.
|
||||
|
||||
```js
|
||||
export default {
|
||||
/**
|
||||
* Only required property. Should be a unique value across all extensions.
|
||||
*/
|
||||
id: 'example-extension',
|
||||
|
||||
/**
|
||||
* Registers one or more named commands scoped to a context. Commands are
|
||||
* the primary means for...
|
||||
*/
|
||||
getCommandsModule() {
|
||||
return {
|
||||
defaultContext: 'VIEWER'
|
||||
actions: { ... },
|
||||
definitions: { ... }
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Allows you to provide toolbar definitions that will be merged with any
|
||||
* existing application toolbar configuration. Used to determine which
|
||||
* buttons should be visible when, their order, what happens when they're
|
||||
* clicked, etc.
|
||||
*/
|
||||
getToolbarModule() {
|
||||
return {
|
||||
definitions: [ ... ],
|
||||
defaultContext: 'ACTIVE_VIEWPORT::CORNERSTONE'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Not yet implemented
|
||||
*/
|
||||
getPanelModule: () => null,
|
||||
|
||||
/**
|
||||
* Registers a ReactComponent that should be used to render data in a
|
||||
* Viewport. The first registered viewport is our "default viewport". If
|
||||
* more than one viewport is registered, we use `SopClassHandlers` to
|
||||
* determine which viewport should be used.
|
||||
*/
|
||||
getViewportModule: () => reactViewportComponent,
|
||||
|
||||
/** Provides a whitelist of SOPClassUIDs the viewport is capable of rendering.
|
||||
* Can modify default behavior for methods like `getDisplaySetFromSeries` */
|
||||
getSopClassHandler: () => {
|
||||
id: 'some-other-unique-id',
|
||||
sopClassUids: [ ... ],
|
||||
getDisplaySetFromSeries: (series, study, dicomWebClient, authorizationHeaders) => { ... }
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Modules
|
||||
|
||||
There are a few different module types. Each module type allows us to extend the
|
||||
viewer in a different way, and provides a consistent API for us to do so. You
|
||||
can find a full list of the different types of modules
|
||||
[`in ohif-core`][module-types]. Information on each type of module, it's API,
|
||||
and how we determine when/where it should be used is included below.
|
||||
|
||||
> NOTE: Modifying the extensions/modules registered to the OHIF Viewer currently
|
||||
> requires us to import and pass extensions to the ExtensionManager in
|
||||
> `src/App.js`, then rebuild the application. Long-term, we intend to make it
|
||||
> possible to accomplish this without a build step.
|
||||
|
||||
#### Commands
|
||||
|
||||
The Commands Module allows us to register one or more commands scoped to
|
||||
specific contexts. Commands can be run by [hotkeys][#], [toolbar buttons][#],
|
||||
and any registered custom react component (like a [viewport][#] or [panel][#]).
|
||||
Here is a simple example commands module:
|
||||
|
||||
```js
|
||||
{
|
||||
getCommandsModule() {
|
||||
return {
|
||||
actions: {
|
||||
speak: ({ viewports, words }) => {
|
||||
console.log(viewports, words);
|
||||
},
|
||||
},
|
||||
definitions: {
|
||||
rotateViewportCW: {
|
||||
commandFn: actions.rotateViewport,
|
||||
storeContexts: ['viewports'],
|
||||
options: { rotation: 90 }
|
||||
},
|
||||
rotateViewportCCW: {
|
||||
commandFn: actions.rotateViewport,
|
||||
storeContexts: ['viewports'],
|
||||
options: { rotation: -90 },
|
||||
context: 'ACTIVE_VIEWER::CORNERSTONE'
|
||||
},
|
||||
},
|
||||
defaultContext: 'VIEWER'
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Viewport
|
||||
|
||||
An extension can register a Viewport Module by providing a `getViewportModule()`
|
||||
method that returns a React Component. The React component will receive the
|
||||
following props:
|
||||
|
||||
```js
|
||||
children: PropTypes.arrayOf(PropTypes.element)
|
||||
studies: PropTypes.object,
|
||||
displaySet: PropTypes.object,
|
||||
viewportData: PropTypes.object, // { studies, displaySet }
|
||||
viewportIndex: PropTypes.number,
|
||||
children: PropTypes.node,
|
||||
customProps: PropTypes.object
|
||||
```
|
||||
|
||||
Viewport components are managed by the `ViewportGrid` Component. Which Viewport
|
||||
component is used depends on:
|
||||
|
||||
- The Layout Configuration
|
||||
- Registered SopClassHandlers
|
||||
- The SopClassUID for visible/selected datasets
|
||||
|
||||

|
||||
|
||||
<center><i>An example of three Viewports</i></center>
|
||||
|
||||
For a complete example implementation,
|
||||
[check out the OHIFCornerstoneViewport](https://github.com/OHIF/Viewers/blob/master/extensions/cornerstone/src/OHIFCornerstoneViewport.js).
|
||||
|
||||
#### Toolbar
|
||||
|
||||
An extension can register a Toolbar Module by providing a `getToolbarModule()`
|
||||
method that returns a React Component. The component does not receive any props.
|
||||
If you want to modify or react to state, you will need to connect to the redux
|
||||
store. The given toolbar must determine its set of elements and the context of
|
||||
them. The set of elements will be listed on toolbar `definitions`.
|
||||
|
||||

|
||||
|
||||
<center><i>A toolbar extension example</i></center>
|
||||
|
||||
Toolbar components are rendered in the `ToolbarRow` component.
|
||||
|
||||
For a complete example implementation,
|
||||
[check out the OHIFCornerstoneViewport's Toolbar Module](https://github.com/OHIF/Viewers/blob/master/extensions/cornerstone/src/toolbarModule.js).
|
||||
|
||||
##### Toolbar Custom Component
|
||||
|
||||
Toolbar elements can define its own custom react component to be consumed when
|
||||
rendering it. So far, it accepts `Functional` and `Class` Components. For that,
|
||||
you just need to expose your `CustomToolbarComponent` as the value of key
|
||||
`CustomComponent`. In case the property `CustomComponent` is not present, a
|
||||
default toolbar component will be used to render it. See bellow
|
||||
|
||||
```js
|
||||
definitions: [
|
||||
...
|
||||
{
|
||||
id: 'Custom',
|
||||
label: 'Custom',
|
||||
icon: 'custom-icon',
|
||||
CustomComponent: CustomToolbarComponent,
|
||||
}
|
||||
...
|
||||
]
|
||||
|
||||
```
|
||||
|
||||
`CustomComponent` components will receive the following props:
|
||||
|
||||
- parentContext: parent context. (In most of the cases it will be a ToolbarRow
|
||||
instance)
|
||||
- toolbarClickCallback: callback method when clicking on toolbar
|
||||
- button: its own definition object
|
||||
- key: react key prop
|
||||
- activeButtons: list of active elements
|
||||
- isActive: if current
|
||||
|
||||
#### SopClassHandler
|
||||
|
||||
...
|
||||
|
||||
#### Panel
|
||||
|
||||
> The panel module is not yet in use.
|
||||
|
||||
#### Hotkeys
|
||||
|
||||
...
|
||||
|
||||
### Registering Extensions
|
||||
|
||||
Extensions are registered for the application at startup. The
|
||||
`ExtensionManager`, exposed by `ohif-core`, registers a list of extensions with
|
||||
our application's store. Each module provided by the extension becomes available
|
||||
via `state.plugins.availablePlugins`, and consists of three parts: id, type
|
||||
([PLUGIN_TYPE](https://github.com/OHIF/ohif-core/blob/43c08a29eff3fb646a0e83a03a236ddd84f4a6e8/src/plugins.js#L1-L6)),
|
||||
and the return value of the module method.
|
||||
|
||||
In a future version, we will likely expose a way to provide the extensions you
|
||||
would like included at startup.
|
||||
|
||||
_app.js_
|
||||
|
||||
```js
|
||||
import { createStore, combineReducers } from 'redux';
|
||||
import OHIF from '@ohif/core';
|
||||
import OHIFCornerstoneExtension from 'ohif-cornerstone-extension';
|
||||
|
||||
const combined = combineReducers(OHIF.redux.reducers);
|
||||
const store = createStore(combined);
|
||||
const extensions = [new OHIFCornerstoneExtension()];
|
||||
|
||||
// Dispatches the `addPlugin` action to the store
|
||||
// Adding extension modules to `state.plugins.availablePlugins`
|
||||
ExtensionManager.registerExtensions(store, extensions);
|
||||
```
|
||||
|
||||
## OHIF Maintained Extensions
|
||||
|
||||
A small number of powerful extensions for popular use cases are maintained by
|
||||
OHIF. They're co-located in the
|
||||
[`OHIF/Viewers`](https://github.com/OHIF/Viewers) repository, in the top level
|
||||
[`extensions/`](https://github.com/OHIF/Viewers/tree/master/extensions)
|
||||
directory.
|
||||
|
||||
{% include "./_maintained-extensions-table.md" %}
|
||||
|
||||
<!--
|
||||
Links
|
||||
-->
|
||||
|
||||
<!-- prettier-ignore-start -->
|
||||
[example-ext-src]: https://github.com/OHIF/Viewers/tree/master/extensions/_example/src
|
||||
[module-types]: https://github.com/OHIF/Viewers/blob/master/platform/core/src/extensions/MODULE_TYPES.js
|
||||
<!-- prettier-ignore-end -->
|
||||
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 260.4 82.9" enable-background="new 0 0 260.4 82.9" xml:space="preserve">
|
||||
<g>
|
||||
<path fill="#525DF9" d="M31.7,8.9c9.6,0,16.5,4.9,19,13.4l0.1,0.4h9.6l-0.1-0.6C57.6,8.5,46.7,0,31.8,0C13.7,0,0,14.1,0,32.9 s13.7,32.9,31.8,32.9c14.9,0,25.8-8.5,28.6-22.1l0.1-0.6h-9.6l-0.1,0.4c-2.5,8.5-9.5,13.4-19,13.4c-13.1,0-21.9-9.6-21.9-24 C9.8,18.6,18.6,8.9,31.7,8.9z"/>
|
||||
<path fill="#525DF9" d="M110.1,58c-2.3,0-3.5-1.4-3.5-4.2V32.7c0-8.8-7.2-14.7-17.9-14.7c-7.5,0-16.7,3.9-18.3,14.9l-0.1,0.6h8.4 l0.1-0.4c1.4-5.7,5.8-6.9,9.3-6.9c6,0,9.5,2.7,9.5,7.4v2.6l-13.6,1.4c-7.6,0.8-15.7,5-15.7,14.6c0,8.1,6,13.5,14.8,13.5 c6.5,0,11.8-3.4,15-6.8c1.3,4.2,4.5,6.5,9.1,6.5c1.7,0,3.2-0.3,5.2-0.9l0.3-0.1v-7l-0.6,0.2C111.5,57.9,110.9,58,110.1,58z M97.7,43.5v7.3c-4.6,4.6-8.9,6.7-13.5,6.7c-2,0-6.8-0.6-6.8-5.7c0-3.8,2.8-6.3,7.4-6.9L97.7,43.5z"/>
|
||||
<path fill="#525DF9" d="M146.5,18c-6.5,0-12,3.1-15.2,6.1v-5.2h-8.9v46h8.9V34.3c2.2-3.2,6.7-8.3,13-8.3c5.6,0,8.7,3.1,8.7,8.8 v30.1h8.9V33.3C161.9,23.7,156.1,18,146.5,18z"/>
|
||||
<path fill="#525DF9" d="M195.8,18c-6.5,0-12,3.1-15.2,6.1v-5.2h-8.9v46h8.9V34.3c2.2-3.2,6.7-8.3,13-8.3c5.6,0,8.7,3.1,8.7,8.8 v30.1h8.9V33.3C211.2,23.7,205.4,18,195.8,18z"/>
|
||||
<polygon fill="#525DF9" points="251.3,18.9 238.6,51.8 237.8,47.9 225.6,18.9 216.2,18.9 234.3,62.4 226,82.9 235,82.9 260.4,18.9 "/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1,19 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 445.9 638" enable-background="new 0 0 445.9 638" xml:space="preserve">
|
||||
<g>
|
||||
<g>
|
||||
<path fill="#5F5DF9" d="M224.5,638C101.1,638,0,537.8,0,414.7V223.3C0,100.2,101.1,0,224.5,0c109.6,0,202.8,78.3,220.9,186.1
|
||||
c2.9,17.4-8.6,33.8-26,36.8c-17.4,2.9-33.7-8.8-36.7-26.2c-13-77-78.6-132.9-157-132.9c-88.2,0-159.3,71.6-159.3,159.5v191.4
|
||||
c0,88,71.1,159.5,159.3,159.5c78.3,0,144.2-55.9,157.1-132.9c2.9-17.4,19.3-29.1,36.6-26.2c17.4,2.9,28.6,19.4,25.7,36.7
|
||||
C427.2,559.7,334,638,224.5,638z"/>
|
||||
</g>
|
||||
<g opacity="0.5">
|
||||
<path fill="#5F5DF9" d="M153.6,347.7c-17.6,0-30.7-14.3-30.7-31.9v-92.3c0-56.8,45.8-103,102.8-103c38.9,0,73.9,21.6,91.7,56.4
|
||||
c8,15.7,1.7,34.9-14,42.9c-15.7,8-35,1.8-43-13.9c-6.8-13.4-21-21.7-35.8-21.7c-21.8,0-40.2,17.6-40.2,39.2v92.3
|
||||
C184.3,333.4,171.2,347.7,153.6,347.7z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,109 @@
|
||||
# Continous Integration (CI)
|
||||
|
||||
This repository uses `CircleCI` and `Netlify` for continous integration.
|
||||
|
||||
## Deploy Previews
|
||||
|
||||
[Netlify Deploy previews][deploy-previews] are generated for every pull request.
|
||||
They allow pull request authors and reviewers to "Preview" the OHIF Viewer as if
|
||||
the changes had been merged.
|
||||
|
||||
Deploy previews can be configured by modifying the `netlify.toml` file in the
|
||||
root of the repository. Some additional scripts/assets for netlify are included
|
||||
in the root `.netlify` directory.
|
||||
|
||||
## Workflows
|
||||
|
||||
[CircleCI Workflows][circleci-workflows] are a set of rules for defining a
|
||||
collection of jobs and their run order. They are self-documenting and their
|
||||
configuration can be found in our CircleCI configuration file:
|
||||
`.circleci/config.yml`.
|
||||
|
||||
### Workflow: PR_CHECKS
|
||||
|
||||
The PR_CHECKS workflow (Pull Request Checks) runs our automated unit and
|
||||
end-to-end tests for every code check-in. These tests must all pass before code
|
||||
can be merged to our `master` branch.
|
||||
|
||||
<div style="text-align: center;">
|
||||
<a href="/assets/img/WORKFLOW_PR_CHECKS.png">
|
||||
<img src="/assets/img/WORKFLOW_PR_CHECKS.png" alt="workflow diagram" style="margin: 0 auto; max-width: 500px;" />
|
||||
</a>
|
||||
<div><i>Workflow diagram for PR_CHECKS</i></div>
|
||||
</div>
|
||||
|
||||
### Workflow: PR_OPTIONAL_DOCKER_PUBLISH
|
||||
|
||||
The PR_OPTIONAL_DOCKER_PUBLISH workflow allows for "manual approval" to publish
|
||||
the pull request as a tagged docker image. This is helpful when changes need to
|
||||
be tested with the Google Adapter before merging to `master`.
|
||||
|
||||
<div style="text-align: center;">
|
||||
<a href="/assets/img/WORKFLOW_PR_OPTIONAL_DOCKER_PUBLISH.png">
|
||||
<img src="/assets/img/WORKFLOW_PR_OPTIONAL_DOCKER_PUBLISH.png" alt="workflow diagram" style="margin: 0 auto; max-width: 500px;" />
|
||||
</a>
|
||||
<div><i>Workflow diagram for PR_WORKFLOW_PR_OPTIONAL_DOCKER_PUBLISH</i></div>
|
||||
</div>
|
||||
|
||||
> NOTE: This workflow will fail unless it's for a branch on our `upstream`
|
||||
> repository. If you need this functionality, but the branch is from a fork,
|
||||
> merge the changes to a short-lived `feature/` branch on `upstream`
|
||||
|
||||
### Workflow: DEPLOY
|
||||
|
||||
The DEPLOY workflow deploys the OHIF Viewer when changes are merged to master.
|
||||
It uses the Netlify CLI to deploy assets created as part of the repository's PWA
|
||||
Build process (`yarn run build`). The workflow allows for "Manual Approval" to
|
||||
promote the build to `STAGING` and `PRODUCTION` environments.
|
||||
|
||||
<div style="text-align: center;">
|
||||
<a href="/assets/img/WORKFLOW_DEPLOY.png">
|
||||
<img src="/assets/img/WORKFLOW_DEPLOY.png" alt="workflow diagram" style="margin: 0 auto; max-width: 500px;" />
|
||||
</a>
|
||||
<div><i>Workflow diagram for WORKFLOW_DEPLOY</i></div>
|
||||
</div>
|
||||
|
||||
| Environment | Description | URL |
|
||||
| ----------- | ---------------------------------------------------------------------------------- | --------------------------------------------- |
|
||||
| Development | Always reflects latest changes on `master` branch. | [Netlify][netlify-dev] / [OHIF][ohif-dev] |
|
||||
| Staging | For manual testing before promotion to prod. Keeps development workflow unblocked. | [Netlify][netlify-stage] / [OHIF][ohif-stage] |
|
||||
| Production | Stable, tested, updated less frequently. | [Netlify][netlify-prod] / [OHIF][ohif-prod] |
|
||||
|
||||
### Workflow: RELEASE
|
||||
|
||||
The RELEASE workflow publishes our `npm` packages, updated documentation, and
|
||||
`docker` image when changes are merged to master. `Lerna` and "Semantic Commit
|
||||
Syntax" are used to independently version and publish the many packages in our
|
||||
monorepository. If a new version is cut/released, a Docker image is created.
|
||||
Documentation is generated with `gitbook` and pushed to our `gh-pages` branch.
|
||||
GitHub hosts the `gh-pages` branch with GitHub Pages.
|
||||
|
||||
- Platform Packages: https://github.com/ohif/viewers/#platform
|
||||
- Extension Packages: https://github.com/ohif/viewers/#extensions
|
||||
- Documentation: https://docs.ohif.org/
|
||||
|
||||
<div style="text-align: center;">
|
||||
<a href="/assets/img/WORKFLOW_RELEASE.png">
|
||||
<img src="/assets/img/WORKFLOW_RELEASE.png" alt="workflow diagram" style="margin: 0 auto; max-width: 500px;" />
|
||||
</a>
|
||||
<div><i>Workflow diagram for WORKFLOW_RELEASE</i></div>
|
||||
</div>
|
||||
|
||||
### HOTFIX
|
||||
|
||||
_Not yet implemented_
|
||||
|
||||
<!--
|
||||
LINKS
|
||||
-->
|
||||
|
||||
<!-- prettier-ignore-start -->
|
||||
[deploy-previews]: https://www.netlify.com/blog/2016/07/20/introducing-deploy-previews-in-netlify/
|
||||
[circleci-workflows]: https://circleci.com/docs/2.0/workflows/
|
||||
[netlify-dev]: https://ohif-dev.netlify.com
|
||||
[netlify-stage]: https://ohif-stage.netlify.com
|
||||
[netlify-prod]: https://ohif-prod.netlify.com
|
||||
[ohif-dev]: https://viewer-dev.ohif.org
|
||||
[ohif-stage]: https://viewer-stage.ohif.org
|
||||
[ohif-prod]: https://viewer-prod.ohif.org
|
||||
<!-- prettier-ignore-end -->
|
||||
@@ -7,8 +7,8 @@ like to discuss the changes you intend to make to clarify where or how they
|
||||
should be implemented, please don't hesitate to create a new issue. At a
|
||||
minimum, you may want to read the following documentation:
|
||||
|
||||
- [Essentials: Getting Started](./essentials/getting-started.md)
|
||||
- [Advanced: Architecture](./advanced/architecture.md)
|
||||
- [Essentials: Getting Started](/essentials/getting-started.md)
|
||||
- [Advanced: Architecture](/advanced/architecture.md)
|
||||
|
||||
Pull requests that are:
|
||||
|
||||
|
||||
@@ -35,8 +35,7 @@ benefits, but comes at the cost of time and complexity. Some benefits include:
|
||||
|
||||
_Today:_
|
||||
|
||||
- Leverage [extensions](/advanced/extensions.md) to drop-in powerful new
|
||||
features
|
||||
- Leverage [extensions](/extensions/index.md) to drop-in powerful new features
|
||||
- Add routes and customize the viewer's workflow
|
||||
- Finer control over styling and whitelabeling
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ extension enabled here][whole-slide-ext-demo] ([source code][ext-code-sandbox])
|
||||
and [without it here][whole-slide-base-demo] ([source code][code-sandbox]).
|
||||
|
||||
You can read more about extensions and how to create your own in our
|
||||
[extensions guide](/advanced/extensions.md).
|
||||
[extensions guide](/extensions/index.md).
|
||||
|
||||
#### FAQ
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Extension</th>
|
||||
<th>Description</th>
|
||||
<th>Modules</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- CORNERSTONE.js -->
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://www.npmjs.com/package/@ohif/extension-cornerstone">
|
||||
Cornerstone
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
A viewport powered by <code>cornerstone.js</code>. Adds support for 2D DICOM rendering and manipulation, as well as support for the tools features in <a href="https://tools.cornerstonejs.org/examples/"><code>cornerstone-tools</code></a>. Also adds "CINE Dialog" to the Toolbar.
|
||||
</td>
|
||||
<td>Viewport, Toolbar</td>
|
||||
</tr>
|
||||
<!-- VTK.js -->
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://www.npmjs.com/package/@ohif/extension-vtk">
|
||||
VTK.js
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
A viewport powered by <code>vtk.js</code>. Adds support for volume renderings and advanced features like MPR. Also adds "3D Rotate" to the Toolbar.
|
||||
</td>
|
||||
<td>Viewport, Toolbar</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://www.npmjs.com/package/@ohif/extension-dicom-html">DICOM HTML</a>
|
||||
</td>
|
||||
<td>
|
||||
Renders text and HTML content for <a href="https://github.com/OHIF/Viewers/blob/master/extensions/dicom-html/src/OHIFDicomHtmlSopClassHandler.js#L4-L12">specific SopClassUIDs</a>.
|
||||
</td>
|
||||
<td>Viewport, SopClassHandler</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://www.npmjs.com/package/@ohif/extension-dicom-pdf">DICOM PDF</a>
|
||||
</td>
|
||||
<td>
|
||||
Renders PDFs for a <a href="https://github.com/OHIF/Viewers/blob/master/extensions/dicom-pdf/src/OHIFDicomPDFSopClassHandler.js#L4-L6">specific SopClassUID</a>.
|
||||
</td>
|
||||
<td>Viewport, SopClassHandler</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://www.npmjs.com/package/@ohif/extension-dicom-microscopy">DICOM Microscopy</a>
|
||||
</td>
|
||||
<td>
|
||||
Renders Microscopy images for a <a href="https://github.com/OHIF/Viewers/blob/master/extensions/dicom-microscopy/src/DicomMicroscopySopClassHandler.js#L5-L7">specific SopClassUID</a>.
|
||||
</td>
|
||||
<td>Viewport, SopClassHandler</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -0,0 +1,151 @@
|
||||
# Extensions
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Concepts](#concepts)
|
||||
- [Extension Skeleton](#extension-skeleton)
|
||||
- [Registering an Extension](#registering-an-extension)
|
||||
- [Lifecylce Hooks](#lifecycle-hooks)
|
||||
- [Modules](#modules)
|
||||
- [Contexts](#contexts)
|
||||
- [Consuming Extensions](#consuming-extensions)
|
||||
- [Maintained Extensions](#maintained-extensions)
|
||||
|
||||
## Overview
|
||||
|
||||
We use extensions to help us isolate and package groups of related features.
|
||||
Extensions provide functionality, ui components, and new behaviors.
|
||||
|
||||
<div style="text-align: center;">
|
||||
<a href="/assets/img/extensions-diagram.png">
|
||||
<img src="/assets/img/extensions-diagram.png" alt="Extensions Diagram" style="margin: 0 auto; max-width: 500px;" />
|
||||
</a>
|
||||
<div><i>Diagram showing how extensions are configured and accessed.</i></div>
|
||||
</div>
|
||||
|
||||
The `@ohif/viewer`'s application level configuration gives us the ability to add
|
||||
and configure extensions. When the application starts, extensions are registered
|
||||
with the `ExtensionManager`. Different portions of the `@ohif/viewer` project
|
||||
will use registered extensions to influence application behavior.
|
||||
|
||||
Extensions allow us to:
|
||||
|
||||
- Wrap and integrate functionality of 3rd party dependencies in a reusable way
|
||||
- Change how application data is mapped and transformed
|
||||
- Display a consistent/cohesive UI
|
||||
- Inject custom components to override built-in components
|
||||
|
||||
Practical examples of extensions include:
|
||||
|
||||
- A set of segmentation tools that build on top of the `cornerstone` viewport
|
||||
- Showing ML/AI report summaries for the selected study/series/image
|
||||
- Support for parsing DICOM structured reports and displaying them in a user
|
||||
friendly way
|
||||
- [See our maintained extensions for more examples of what's possible](#maintained-extensions)
|
||||
|
||||
## Concepts
|
||||
|
||||
### Extension Skeleton
|
||||
|
||||
An extension is a plain JavaScript object has an `id` property, and one or more
|
||||
"getModuleFunctions" and/or lifecycle hooks. You can read more about
|
||||
[lifecycle hooks](#lifecycle-hooks) and [modules](#modules) further down.
|
||||
|
||||
```js
|
||||
// prettier-ignore
|
||||
export default {
|
||||
/**
|
||||
* Only required property. Should be a unique value across all extensions.
|
||||
*/
|
||||
id: 'example-extension',
|
||||
|
||||
// Lifecyle
|
||||
preRegistration() { /* */ },
|
||||
// Modules
|
||||
getCommandsModule() { /* */ },
|
||||
getToolbarModule() { /* */ },
|
||||
getPanelModule() { /* */ },
|
||||
getSopClassHandler() { /* */ },
|
||||
getViewportModule() { /* */ },
|
||||
}
|
||||
```
|
||||
|
||||
### Registering an Extension
|
||||
|
||||
There are two different ways to register and configure extensions. You can
|
||||
leverage one or both strategies. Which one(s) you choose depend on your
|
||||
application's requirements. Each [module](#modules) defined by the extension
|
||||
becomes available to the core application via the `ExtensionManager`.
|
||||
|
||||
```js
|
||||
// prettier-ignore
|
||||
const config = {
|
||||
extensions: [
|
||||
MyFirstExtension,
|
||||
[
|
||||
MySecondExtension,
|
||||
{ /* MySecondExtensions Configuration */ },
|
||||
],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
#### Runtime Extensions
|
||||
|
||||
The `@ohif/viewer` uses a [configuration file](#) at startup. The schema for
|
||||
that file includes an `Extensions` key that supports an array of extensions to
|
||||
register.
|
||||
|
||||
#### Bundled Extensions
|
||||
|
||||
The `@ohif/viewer` works best when built as a "Progressive Web Application"
|
||||
(PWA). If you know the extensions your application will need, you can specify
|
||||
them at "build time" to leverage some advantaged afforded to us by modern
|
||||
tooling:
|
||||
|
||||
- Code Splitting
|
||||
- Tree Shaking
|
||||
- Dependency deduplication
|
||||
|
||||
You can update the list of bundled extensions by:
|
||||
|
||||
1. Having your `@ohif/viewer` project depend on the extension
|
||||
2. Importing and adding it to the list of extensions in the
|
||||
`<repo-root>/platform/src/index.js` entrypoint.
|
||||
|
||||
### Lifecycle Hooks
|
||||
|
||||
...
|
||||
|
||||
### Modules
|
||||
|
||||
There are a few different module types. Each module type allows us to extend the
|
||||
viewer in a different way, and provides a consistent API for us to do so. You
|
||||
can find a full list of the different types of modules
|
||||
[`in ohif-core`][module-types]. Information on each type of module, it's API,
|
||||
and how we determine when/where it should be used is included below.
|
||||
|
||||
### Contexts
|
||||
|
||||
...
|
||||
|
||||
## Consuming Extensions
|
||||
|
||||
...
|
||||
|
||||
## Maintained Extensions
|
||||
|
||||
A small number of powerful extensions for popular use cases are maintained by
|
||||
OHIF. They're co-located in the [`OHIF/Viewers`][viewers-repo] repository, in
|
||||
the top level [`extensions/`][ext-source] directory.
|
||||
|
||||
{% include "./_maintained-extensions-table.md" %}
|
||||
|
||||
<!--
|
||||
LINKS
|
||||
-->
|
||||
|
||||
<!-- prettier-ignore-start -->
|
||||
[viewers-repo]: https://github.com/OHIF/Viewers
|
||||
[ext-source]: https://github.com/OHIF/Viewers/tree/master/extensions
|
||||
[module-types]: https://github.com/OHIF/Viewers/blob/master/platform/core/src/extensions/MODULE_TYPES.js
|
||||
<!-- prettier-ignore-end -->
|
||||
@@ -0,0 +1,21 @@
|
||||
# Lifecylce Hook: preRegistration
|
||||
|
||||
If an extension defines the `preRegistration` lifecycle hook, it is called
|
||||
before any modules are registered to the `ExtensionManager`.
|
||||
|
||||
```js
|
||||
export default {
|
||||
id: 'MyExampleExtension',
|
||||
|
||||
preRegistration({ servicesManager, commandsManager, configuration }) {
|
||||
console.log('Wiring up important stuff.');
|
||||
|
||||
window.importantStuff = () => {
|
||||
console.log(configuration);
|
||||
};
|
||||
|
||||
console.log('Important stuff has been wired.');
|
||||
window.importantStuff();
|
||||
},
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,34 @@
|
||||
# Module: Commands
|
||||
|
||||
The Commands Module allows us to register one or more commands scoped to
|
||||
specific contexts. Commands can be run by [hotkeys][#], [toolbar buttons][#],
|
||||
and any registered custom react component (like a [viewport][#] or [panel][#]).
|
||||
Here is a simple example commands module:
|
||||
|
||||
```js
|
||||
{
|
||||
getCommandsModule() {
|
||||
return {
|
||||
actions: {
|
||||
speak: ({ viewports, words }) => {
|
||||
console.log(viewports, words);
|
||||
},
|
||||
},
|
||||
definitions: {
|
||||
rotateViewportCW: {
|
||||
commandFn: actions.rotateViewport,
|
||||
storeContexts: ['viewports'],
|
||||
options: { rotation: 90 }
|
||||
},
|
||||
rotateViewportCCW: {
|
||||
commandFn: actions.rotateViewport,
|
||||
storeContexts: ['viewports'],
|
||||
options: { rotation: -90 },
|
||||
context: 'ACTIVE_VIEWER::CORNERSTONE'
|
||||
},
|
||||
},
|
||||
defaultContext: 'VIEWER'
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
# Module: Panel
|
||||
|
||||
...
|
||||
@@ -0,0 +1,3 @@
|
||||
# Module: SOP Class Handler
|
||||
|
||||
...
|
||||
@@ -0,0 +1,48 @@
|
||||
# Module: Toolbar
|
||||
|
||||
An extension can register a Toolbar Module by providing a `getToolbarModule()`
|
||||
method that returns a React Component. The component does not receive any props.
|
||||
If you want to modify or react to state, you will need to connect to the redux
|
||||
store. The given toolbar must determine its set of elements and the context of
|
||||
them. The set of elements will be listed on toolbar `definitions`.
|
||||
|
||||

|
||||
|
||||
<center><i>A toolbar extension example</i></center>
|
||||
|
||||
Toolbar components are rendered in the `ToolbarRow` component.
|
||||
|
||||
For a complete example implementation,
|
||||
[check out the OHIFCornerstoneViewport's Toolbar Module](https://github.com/OHIF/Viewers/blob/master/extensions/cornerstone/src/toolbarModule.js).
|
||||
|
||||
## Toolbar Custom Component
|
||||
|
||||
Toolbar elements can define its own custom react component to be consumed when
|
||||
rendering it. So far, it accepts `Functional` and `Class` Components. For that,
|
||||
you just need to expose your `CustomToolbarComponent` as the value of key
|
||||
`CustomComponent`. In case the property `CustomComponent` is not present, a
|
||||
default toolbar component will be used to render it. See bellow
|
||||
|
||||
```js
|
||||
definitions: [
|
||||
...
|
||||
{
|
||||
id: 'Custom',
|
||||
label: 'Custom',
|
||||
icon: 'custom-icon',
|
||||
CustomComponent: CustomToolbarComponent,
|
||||
}
|
||||
...
|
||||
]
|
||||
|
||||
```
|
||||
|
||||
`CustomComponent` components will receive the following props:
|
||||
|
||||
- parentContext: parent context. (In most of the cases it will be a ToolbarRow
|
||||
instance)
|
||||
- toolbarClickCallback: callback method when clicking on toolbar
|
||||
- button: its own definition object
|
||||
- key: react key prop
|
||||
- activeButtons: list of active elements
|
||||
- isActive: if current
|
||||
@@ -0,0 +1,29 @@
|
||||
# Module: Viewport
|
||||
|
||||
An extension can register a Viewport Module by providing a `getViewportModule()`
|
||||
method that returns a React Component. The React component will receive the
|
||||
following props:
|
||||
|
||||
```js
|
||||
children: PropTypes.arrayOf(PropTypes.element)
|
||||
studies: PropTypes.object,
|
||||
displaySet: PropTypes.object,
|
||||
viewportData: PropTypes.object, // { studies, displaySet }
|
||||
viewportIndex: PropTypes.number,
|
||||
children: PropTypes.node,
|
||||
customProps: PropTypes.object
|
||||
```
|
||||
|
||||
Viewport components are managed by the `ViewportGrid` Component. Which Viewport
|
||||
component is used depends on:
|
||||
|
||||
- The Layout Configuration
|
||||
- Registered SopClassHandlers
|
||||
- The SopClassUID for visible/selected datasets
|
||||
|
||||

|
||||
|
||||
<center><i>An example of three Viewports</i></center>
|
||||
|
||||
For a complete example implementation,
|
||||
[check out the OHIFCornerstoneViewport](https://github.com/OHIF/Viewers/blob/master/extensions/cornerstone/src/OHIFCornerstoneViewport.js).
|
||||
@@ -0,0 +1,3 @@
|
||||
# Hotkeys
|
||||
|
||||
...
|
||||
@@ -0,0 +1,160 @@
|
||||
# Our Process
|
||||
|
||||
Our process is a living, breathing thing. We strive to have regular
|
||||
[retrospectives][retrospective] that help us shape and adapt our process to our
|
||||
team's current needs. This document attempts to capture the broad strokes of
|
||||
that process in an effort to:
|
||||
|
||||
- Strengthen community member involvement and understanding
|
||||
- Welcome feedback and helpful suggestions
|
||||
|
||||
## Overview
|
||||
|
||||
- [Issue Triage](#issue-triage)
|
||||
- [Issue Curation ("backlog grooming")](#issue-curation-backlog-grooming)
|
||||
- [Contributions (Pull Requests)](#contributions-pull-requests)
|
||||
- [Releases](#releases)
|
||||
|
||||
_Include issue lifecycle diagram_
|
||||
|
||||
## Issue Triage
|
||||
|
||||
[GitHub issues][gh-issues] are the best way to provide feedback, ask questions,
|
||||
and suggest changes to the OHIF Viewer's core team. Community issues generally
|
||||
fall into one of three categories, and are marked with a `triage` label when
|
||||
created.
|
||||
|
||||
| Issue Template Name | Description |
|
||||
| ---------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| Community: Report 🐛 | Describe a new issue; Provide steps to reproduce; Expected versus actual result? |
|
||||
| Community: Request ✋ | Describe a proposed new feature. Why should it be implemented? What is the impact/value? |
|
||||
| Community: Question ❓ | Seek clarification or assistance relevant to the repository. |
|
||||
|
||||
_table 1. issue template names and descriptions_
|
||||
|
||||
Issues that require `triage` are akin to support tickets. As this is often our
|
||||
first contact with would-be adopters and contributors, it's important that we
|
||||
strive for timely responses and satisfactory resolutions. We attempt to
|
||||
accomplish this by:
|
||||
|
||||
1. Responding to issues requiring `triage` at least once a week
|
||||
2. Create new "official issues" from "community issues"
|
||||
3. Provide clear guidance and next steps (when applicable)
|
||||
4. Regularly clean up old (stale) issues
|
||||
|
||||
> :pencil: Less obviously, patterns in the issues being reported can highlight
|
||||
> areas that need improvement. For example, users often have difficulty
|
||||
> navigating CORS issues when deploying the OHIF Viewer -- how do we best reduce
|
||||
> our ticket volume for this issue?
|
||||
|
||||
### Backlogged Issues
|
||||
|
||||
Community issues serve as vehicles of discussion that lead us to "backlogged
|
||||
issues". Backlogged issues are the distilled and actionable information
|
||||
extracted from community issues. They contain the scope and requirements
|
||||
necessary for hand-off to a core-team (or community) contributor ^\_^
|
||||
|
||||
| Category | Description | Labels |
|
||||
| -------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Bugs | An issue with steps that produce a bug (an unexpected result). | [Bug: Verified 🐛][label-bug] |
|
||||
| Stories | A feature/enhancement with a clear benefit, boundaries, and requirements. | [Story 🙌][label-story] |
|
||||
| Tasks | Changes that improve [UX], [DX], or test coverage; but don't impact application behavior | [Task: CI/Tooling 🤖][label-tooling], [Task: Docs 📖][label-docs], [Task: Refactor 🛠][label-refactor], [Task: Tests 🔬][label-tests] |
|
||||
|
||||
_table 2. backlogged issue types ([full list of labels][gh-labels])_
|
||||
|
||||
## Issue Curation (["backlog grooming"][groom-backlog])
|
||||
|
||||
If a [GitHub issue][gh-issues] has a `bug`, `story`, or `task` label; it's on
|
||||
our backlog. If an issue is on our backlog, it means we are, at the very least,
|
||||
committed to reviewing any community drafted Pull Requests to complete the
|
||||
issue. If you're interested in seeing an issue completed but don't know where to
|
||||
start, please don't hesitate to leave a comment!
|
||||
|
||||
While we don't yet have a long-term or quarterly road map, we do regularly add
|
||||
items to our ["Active Development" GitHub Project Board][gh-board]. Items on
|
||||
this project board are either in active development by Core Team members, or
|
||||
queued up for development as in-progress items are completed.
|
||||
|
||||
> :pencil: Want to contribute but not sure where to start? Check out [Up for
|
||||
> grabs][label-grabs] issues and our [Contributing
|
||||
> documentation][contributing-docs]
|
||||
|
||||
## Contributions (Pull Requests)
|
||||
|
||||
Incoming Pull Requests (PRs) are triaged using the following labels. Code review
|
||||
is performed on all PRs where the bug fix or added functionality is deemed
|
||||
appropriate:
|
||||
|
||||
| Labels | Description |
|
||||
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| **Classification** | |
|
||||
| [PR: Bug Fix][label-bug] | Filed to address a Bug. |
|
||||
| [PR: Draft][draft] | Filed to gather early feedback from the core team, but which is not intended for merging in the short term. |
|
||||
| **Review Workflow** | |
|
||||
| [PR: Awaiting Response 💬][awaiting-response] | The core team is waiting for additional information from the author. |
|
||||
| [PR: Awaiting Review 👀][awaiting-review] | The core team has not yet performed a code review. |
|
||||
| [PR: Awaiting Revisions 🖊][awaiting-revisions] | Following code review, this label is applied until the author has made sufficient changes. |
|
||||
| **QA** | |
|
||||
| [PR: Awaiting User Cases 💃][awaiting-stories] | The PR code changes need common language descriptions of impact to end users before the review can start |
|
||||
| [PR: No UX Impact 🙃][no-ux-impact] | The PR code changes do not impact the user's experience |
|
||||
|
||||
We rely on GitHub Checks and integrations with third party services to evaluate
|
||||
changes in code quality and test coverage. Tests must pass and User cases must
|
||||
be present (when applicable) before a PR can be merged to master, and code
|
||||
quality and test coverage must not changed by a significant margin. For some
|
||||
repositories, visual screenshot-based tests are also included, and video
|
||||
recordings of end-to-end tests are stored for later review.
|
||||
|
||||
[You can read more about our continous integration efforts here](/continous-integration.md)
|
||||
|
||||
## Releases
|
||||
|
||||
Releases are made automatically based on the type of commits which have been
|
||||
merged (major.minor.patch). Releases are automatically pushed to NPM. Release
|
||||
notes are automatically generated. Users can subscribe to GitHub and NPM
|
||||
releases.
|
||||
|
||||
We host development, staging, and production environments for the Progressive
|
||||
Web Application version of the OHIF Viewer. [Development][ohif-dev] always
|
||||
reflects the latest changes on our master branch. [Staging][ohif-stage] is used
|
||||
to regression test a release before a bi-weekly deploy to our [Production
|
||||
environment][ohif-prod].
|
||||
|
||||
Important announcements are made on GitHub, tagged as Announcement, and pinned
|
||||
so that they remain at the top of the Issue page.
|
||||
|
||||
The Core team occasionally performs full manual testing to begin the process of
|
||||
releasing a Stable version. Once testing is complete, the known issues are
|
||||
addressed and a Stable version is released.
|
||||
|
||||
<!--
|
||||
LINKS
|
||||
-->
|
||||
|
||||
<!-- prettier-ignore-start -->
|
||||
[groom-backlog]: https://www.agilealliance.org/glossary/backlog-grooming
|
||||
[retrospective]: https://www.atlassian.com/team-playbook/plays/retrospective
|
||||
[gh-issues]: https://github.com/OHIF/Viewers/issues/new/choose
|
||||
[gh-labels]: https://github.com/OHIF/Viewers/labels
|
||||
<!-- Issue Labels -->
|
||||
[label-story]: https://github.com/OHIF/Viewers/labels/Story%20%3Araised_hands%3A
|
||||
[label-tooling]: https://github.com/OHIF/Viewers/labels/Task%3A%20CI%2FTooling%20%3Arobot%3A
|
||||
[label-docs]: https://github.com/OHIF/Viewers/labels/Task%3A%20Docs%20%3Abook%3A
|
||||
[label-refactor]: https://github.com/OHIF/Viewers/labels/Task%3A%20Refactor%20%3Ahammer_and_wrench%3A
|
||||
[label-tests]: https://github.com/OHIF/Viewers/labels/Task%3A%20Tests%20%3Amicroscope%3A
|
||||
[label-bug]: https://github.com/OHIF/Viewers/labels/Bug%3A%20Verified%20%3Abug%3A
|
||||
<!-- PR Labels -->
|
||||
[draft]: https://github.com/OHIF/Viewers/labels/PR%3A%20Draft
|
||||
[awaiting-response]: https://github.com/OHIF/Viewers/labels/PR%3A%20Awaiting%20Response%20%3Aspeech_balloon%3A
|
||||
[awaiting-review]: https://github.com/OHIF/Viewers/labels/PR%3A%20Awaiting%20Review%20%3Aeyes%3A
|
||||
[awaiting-stories]: https://github.com/OHIF/Viewers/labels/PR%3A%20Awaiting%20UX%20Stories%20%3Adancer%3A
|
||||
[awaiting-revisions]: https://github.com/OHIF/Viewers/labels/PR%3A%20Awaiting%20Revisions%20%3Apen%3A
|
||||
[no-ux-impact]: https://github.com/OHIF/Viewers/labels/PR%3A%20No%20UX%20Impact%20%3Aupside_down_face%3A
|
||||
<!-- -->
|
||||
[ohif-dev]: https://viewer-dev.ohif.org
|
||||
[ohif-stage]: https://viewer-stage.ohif.org
|
||||
[ohif-prod]: https://viewer.ohif.org
|
||||
[gh-board]: https://github.com/OHIF/Viewers/projects/4
|
||||
[label-grabs]: https://github.com/OHIF/Viewers/issues?q=is%3Aissue+is%3Aopen+label%3A%22Up+For+Grabs+%3Araising_hand_woman%3A%22
|
||||
[contributing-docs]: https://docs.ohif.org/contributing/
|
||||
<!-- prettier-ignore-end -->
|
||||
@@ -0,0 +1,65 @@
|
||||
# Services (default)
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Example](#example)
|
||||
|
||||
## Overview
|
||||
|
||||
Services are a work in progress. As we are still in the progress of creating a
|
||||
non-ui maintained service, this usage may change.
|
||||
|
||||
<div style="text-align: center;">
|
||||
<a href="/assets/img/services.png">
|
||||
<img src="/assets/img/services.png" alt="UI Services Diagram" style="margin: 0 auto; max-width: 500px;" />
|
||||
</a>
|
||||
<div><i>Diagram showing relationship between React Context and UI Service</i></div>
|
||||
</div>
|
||||
|
||||
## Example
|
||||
|
||||
The simplest service return a new object that has a `name` property, and
|
||||
methods/properties that give the service its functionality. The "Factory
|
||||
Function" that creates the service is provided with the implementation (this is
|
||||
slightly different for UI Services).
|
||||
|
||||
```js
|
||||
const _speak = () => {
|
||||
console.warn('Speak is not implemented');
|
||||
};
|
||||
|
||||
/**
|
||||
* Factory function to create `HelloWorldService`
|
||||
*
|
||||
* @param {object} implementation
|
||||
* @param {function} implementation.speak - Speak's implementation
|
||||
* @returns HelloWorldService
|
||||
*/
|
||||
export default function createHelloWorldService({ speak }) {
|
||||
return {
|
||||
name: 'HelloWorldService',
|
||||
speak: speak || _speak,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
A service, once created, can be registered with the `ServicesManager` to make it
|
||||
accessible to extensions. Similarly, the application code can access named
|
||||
services from the `ServicesManager`.
|
||||
|
||||
```js
|
||||
// In the application
|
||||
const speak = () => {
|
||||
window.alert('HELLO WORLD');
|
||||
};
|
||||
const HelloWorldService = createHelloWorldService({ speak });
|
||||
const servicesManager = new ServicesManager();
|
||||
|
||||
servicesManager.registerService(HelloWorldService);
|
||||
|
||||
// In an extension
|
||||
const { HelloWorldService } = servicesManager.services;
|
||||
|
||||
if (HelloWorldService) {
|
||||
HelloWorldService.speak();
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,69 @@
|
||||
# Services Overview
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Kinds of Services](#kinds-of-services)
|
||||
- [Services (default)](#services-default)
|
||||
- [UI Services](#ui-services)
|
||||
- [Related Patterns](#related-patterns)
|
||||
|
||||
## Overview
|
||||
|
||||
Services are a beefier version of [commands][commands]. They provide a set of
|
||||
operations, often tied to some shared state, and are made available to
|
||||
extensions via the `ServicesManager`. Services are particularly well suited to
|
||||
address [cross-cutting concerns][cross-cutting-concerns].
|
||||
|
||||
Each service should be:
|
||||
|
||||
- self-contained
|
||||
- able to fail and/or be removed without breaking the application
|
||||
- completely interchangeable with another module implementing the same interface
|
||||
|
||||
## Kinds of Services
|
||||
|
||||
Depending on the kind of service, we follow slightly different conventions. For
|
||||
example, a UI service often receives its implementation from a React Context
|
||||
Provider. You can read more about the different kinds of services and what makes
|
||||
them different below:
|
||||
|
||||
### Services (default)
|
||||
|
||||
Services are a work in progress. As we are still in the progress of creating a
|
||||
non-ui maintained service, this usage may change.
|
||||
|
||||
[You can read more about default services: here](./default/index.md)
|
||||
|
||||
### UI Services
|
||||
|
||||
A typical web application will have components and state for common UI like
|
||||
modals, notifications, dialogs, etc. A UI service makes it possible to leverage
|
||||
these components from an extension.
|
||||
|
||||
We maintain the following UI Services:
|
||||
|
||||
- [UIDialogService](./ui/ui-dialog-service.md)
|
||||
- [UIModalService](./ui/ui-modal-service.md)
|
||||
- [UINotificationService](./ui/ui-notification-service.md)
|
||||
|
||||
You can read more about a specific service by selecting it in the above list,
|
||||
and more about [UI services in general: here](./ui/index.md)
|
||||
|
||||
## Related Patterns
|
||||
|
||||
Services are "concern-specific" code modules that can be consumed across layers.
|
||||
We try to minimize the coupling they introduce by authoring services that are
|
||||
able to fail or be removed. Related patterns that may reduce coupling include:
|
||||
|
||||
- Pub/Sub
|
||||
- Commands
|
||||
|
||||
<!--
|
||||
LINKS
|
||||
-->
|
||||
|
||||
<!-- prettier-ignore-start -->
|
||||
[commands]: #/
|
||||
[core-services]: https://github.com/OHIF/Viewers/tree/master/platform/core/src/services
|
||||
[services-manager]: https://github.com/OHIF/Viewers/blob/master/platform/core/src/services/ServicesManager.js
|
||||
[cross-cutting-concerns]: https://en.wikipedia.org/wiki/Cross-cutting_concern
|
||||
<!-- prettier-ignore-end -->
|
||||
@@ -0,0 +1,106 @@
|
||||
# UI Services
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Example](#example)
|
||||
- [Tips & Tricks](#/)
|
||||
- [Maintained Services](#/)
|
||||
|
||||
## Overview
|
||||
|
||||
A typical web application will have components and state for common UI like
|
||||
modals, notifications, dialogs, etc. A UI service makes it possible to leverage
|
||||
these components from an extension.
|
||||
|
||||
<div style="text-align: center;">
|
||||
<a href="/assets/img/ui-services.png">
|
||||
<img src="/assets/img/ui-services.png" alt="UI Services Diagram" style="margin: 0 auto; max-width: 500px;" />
|
||||
</a>
|
||||
<div><i>Diagram showing relationship between React Context and UI Service</i></div>
|
||||
</div>
|
||||
|
||||
In `@ohif/core`, we have a collection of service factories. We select one we
|
||||
would like our application to support, create an instance of it, and pass that
|
||||
instance to our `ServicesManager` AND to a React component (in this example,
|
||||
`ModalContext`'s provider).
|
||||
|
||||
The `ModalContext`'s provider:
|
||||
|
||||
- Exposes context values
|
||||
- Exposes methods that leverage `useCallback` hooks
|
||||
- Sets the service's implementation in a `useEffect` hook
|
||||
|
||||
The `ServicesManager` is:
|
||||
|
||||
- Passed to the `ExtensionManager`
|
||||
- The `ExtensionManager` makes the `ServicesManager` available to:
|
||||
- All of it's lifecycle hooks (`preRegistration`)
|
||||
- Each "getModuleFunction" (`getToolbarModule`, `getPanelModule`, etc.)
|
||||
|
||||
## Example
|
||||
|
||||
That's all fine and good, but it's still a little too abstract. What does this
|
||||
translate to in practice?
|
||||
|
||||
```js
|
||||
// In the application
|
||||
const UINotificationService = createUINotificationService();
|
||||
const servicesManager = new ServicesManager();
|
||||
|
||||
servicesManager.registerService(UINotificationService);
|
||||
|
||||
// UI Service Provider
|
||||
useEffect(() => {
|
||||
if (service) {
|
||||
service.setServiceImplementation({ hide, show });
|
||||
}
|
||||
}, [service, hide, show]);
|
||||
|
||||
// In an extension
|
||||
const { UINotificationService } = servicesManager.services;
|
||||
|
||||
if (UINotificationService) {
|
||||
UINotificationService.show('Hello from the other side 👋');
|
||||
}
|
||||
```
|
||||
|
||||
<div style="text-align: center;">
|
||||
<a href="/assets/img/notification-example.gif">
|
||||
<img src="/assets/img/notification-example.gif" alt="UI Notification Service Example" style="margin: 0 auto; max-width: 500px;" />
|
||||
</a>
|
||||
<div><i>GIF showing successful call of UINotificationService from an extension.</i></div>
|
||||
</div>
|
||||
|
||||
## Tips & Tricks
|
||||
|
||||
It's important to remember that all we're doing is making it possible to control
|
||||
bits of the application's UI from an extension. Here are a few non-obvious
|
||||
takeaways worth mentioning:
|
||||
|
||||
- Your application code should continue to use React context
|
||||
(consumers/providers) as it normally would
|
||||
- You can substitute our "out of the box" UI implementations with your own
|
||||
- You can create and register your own UI services
|
||||
- You can choose not to register a service or provide a service implementation
|
||||
- In extensions, you can provide fallback/alternative behavior if an expected
|
||||
service is not registered
|
||||
- No `UIModalService`? Use the `UINotificationService` to notify users.
|
||||
- While we don't have an examples of this, you can technically register a
|
||||
service in an extension and expose it to the core application
|
||||
|
||||
> Note: These are recommended patterns, not hard and fast rules. Following them
|
||||
> will help reduce confusion and interoperability with the larger OHIF
|
||||
> community, but they're not silver bullets. Please speak up, create an issue,
|
||||
> if you would like to discuss new services or improvements to this pattern.
|
||||
|
||||
## Maintained Services
|
||||
|
||||
Our `@ohif/viewer` project is an example of how to glue together the different
|
||||
parts and pieces of the OHIF Platform to create a polished and powerful product.
|
||||
To accomplish that, we maintain several UI Services that you can use in your own
|
||||
project, or provide alternative implementations for:
|
||||
|
||||
| Name | Docs |
|
||||
| --------------------- | ------------------------------------ |
|
||||
| UIDialogService | [Here](./ui-dialog-service.md) |
|
||||
| UIModalService | [Here](./ui-modal-service.md) |
|
||||
| UINotificationService | [Here](./ui-notification-service.md) |
|
||||
@@ -0,0 +1,49 @@
|
||||
# UI Dialog Service
|
||||
|
||||
Dialogs have similar characteristics to that of Modals, but often with a
|
||||
streamlined focus. They can be helpful when:
|
||||
|
||||
- We need to grab the user's attention
|
||||
- We need user input
|
||||
- We need to show additional information
|
||||
|
||||
If you're curious about the DOs and DON'Ts of dialogs and modals, check out this
|
||||
article: ["Best Practices for Modals / Overlays / Dialog Windows"][ux-article]
|
||||
|
||||
<div style="text-align: center;">
|
||||
<a href="/assets/img/dialog-example.gif">
|
||||
<img src="/assets/img/dialog-example.gif" alt="UI Dialog Service Example" style="margin: 0 auto; max-width: 500px;" />
|
||||
</a>
|
||||
<div><i>GIF showing successful call of UIDialogService from an extension.</i></div>
|
||||
</div>
|
||||
|
||||
## Interface
|
||||
|
||||
For a more detailed look on the options and return values each of these methods
|
||||
is expected to support, [check out it's interface in `@ohif/core`][interface]
|
||||
|
||||
| API Member | Description |
|
||||
| -------------- | ------------------------------------------------------ |
|
||||
| `create()` | Creates a new Dialog that is displayed until dismissed |
|
||||
| `dismiss()` | Dismisses the specified dialog |
|
||||
| `dismissAll()` | Dismisses all dialogs |
|
||||
|
||||
## Implementations
|
||||
|
||||
| Implementation | Consumer |
|
||||
| ------------------------------------ | -------------------------- |
|
||||
| [Dialog Provider][dialog-provider]\* | Baked into Dialog Provider |
|
||||
|
||||
`*` - Denotes maintained by OHIF
|
||||
|
||||
> 3rd Party implementers may be added to this table via pull requests.
|
||||
|
||||
<!--
|
||||
LINKS
|
||||
-->
|
||||
|
||||
<!-- prettier-ignore-start -->
|
||||
[interface]: https://github.com/OHIF/Viewers/blob/master/platform/core/src/services/UIDialogService/index.js
|
||||
[dialog-provider]: https://github.com/OHIF/Viewers/blob/master/platform/ui/src/contextProviders/DialogProvider.js
|
||||
[ux-article]: https://uxplanet.org/best-practices-for-modals-overlays-dialog-windows-c00c66cddd8c
|
||||
<!-- prettier-ignore-end -->
|
||||
@@ -0,0 +1,50 @@
|
||||
# UI Modal Service
|
||||
|
||||
Modals have similar characteristics to that of Dialogs, but are often larger,
|
||||
and only allow for a single instance to be viewable at once. They also tend to
|
||||
be centered, and not draggable. They're commonly used when:
|
||||
|
||||
- We need to grab the user's attention
|
||||
- We need user input
|
||||
- We need to show additional information
|
||||
|
||||
If you're curious about the DOs and DON'Ts of dialogs and modals, check out this
|
||||
article: ["Best Practices for Modals / Overlays / Dialog Windows"][ux-article]
|
||||
|
||||
<div style="text-align: center;">
|
||||
<a href="/assets/img/modal-example.gif">
|
||||
<img src="/assets/img/modal-example.gif" alt="UI Modal Service Example" style="margin: 0 auto; max-width: 500px;" />
|
||||
</a>
|
||||
<div><i>GIF showing successful call of UIModalService from an extension.</i></div>
|
||||
</div>
|
||||
|
||||
## Interface
|
||||
|
||||
For a more detailed look on the options and return values each of these methods
|
||||
is expected to support, [check out it's interface in `@ohif/core`][interface]
|
||||
|
||||
| API Member | Description |
|
||||
| ---------- | ------------------------------------- |
|
||||
| `hide()` | Hides the open modal |
|
||||
| `show()` | Shows the provided content in a modal |
|
||||
|
||||
## Implementations
|
||||
|
||||
| Implementation | Consumer |
|
||||
| ---------------------------------- | ----------------------------- |
|
||||
| [Modal Provider][modal-provider]\* | [OHIFModal][modal-consumer]\* |
|
||||
|
||||
`*` - Denotes maintained by OHIF
|
||||
|
||||
> 3rd Party implementers may be added to this table via pull requests.
|
||||
|
||||
<!--
|
||||
LINKS
|
||||
-->
|
||||
|
||||
<!-- prettier-ignore-start -->
|
||||
[interface]: https://github.com/OHIF/Viewers/blob/master/platform/core/src/services/UIModalService/index.js
|
||||
[modal-provider]: https://github.com/OHIF/Viewers/blob/master/platform/ui/src/contextProviders/ModalProvider.js
|
||||
[modal-consumer]: https://github.com/OHIF/Viewers/tree/master/platform/ui/src/components/ohifModal
|
||||
[ux-article]: https://uxplanet.org/best-practices-for-modals-overlays-dialog-windows-c00c66cddd8c
|
||||
<!-- prettier-ignore-end -->
|
||||
@@ -0,0 +1,51 @@
|
||||
# UI Notification Service
|
||||
|
||||
Notifications can be annoying and disruptive. They can also deliver timely
|
||||
helpful information, or expedite the user's workflow. Here is some high level
|
||||
guidance on when and how to use them:
|
||||
|
||||
- Notifications should be non-interfering (timely, relevant, important)
|
||||
- We should only show small/brief notifications
|
||||
- Notifications should be contextual to current behavior/actions
|
||||
- Notifications can serve warnings (acting as a confirmation)
|
||||
|
||||
If you're curious about the DOs and DON'Ts of notifications, check out this
|
||||
article: ["How To Design Notifications For Better UX"][ux-article]
|
||||
|
||||
<div style="text-align: center;">
|
||||
<a href="/assets/img/notification-example.gif">
|
||||
<img src="/assets/img/notification-example.gif" alt="UI Notification Service Example" style="margin: 0 auto; max-width: 500px;" />
|
||||
</a>
|
||||
<div><i>GIF showing successful call of UINotificationService from an extension.</i></div>
|
||||
</div>
|
||||
|
||||
## Interface
|
||||
|
||||
For a more detailed look on the options and return values each of these methods
|
||||
is expected to support, [check out it's interface in `@ohif/core`][interface]
|
||||
|
||||
| API Member | Description |
|
||||
| ---------- | --------------------------------------- |
|
||||
| `hide()` | Hides the specified notification |
|
||||
| `show()` | Creates and displays a new notification |
|
||||
|
||||
## Implementations
|
||||
|
||||
| Implementation | Consumer |
|
||||
| ---------------------------------------- | ----------------------------------------- |
|
||||
| [Snackbar Provider][snackbar-provider]\* | [SnackbarContainer][snackbar-container]\* |
|
||||
|
||||
`*` - Denotes maintained by OHIF
|
||||
|
||||
> 3rd Party implementers may be added to this table via pull requests.
|
||||
|
||||
<!--
|
||||
LINKS
|
||||
-->
|
||||
|
||||
<!-- prettier-ignore-start -->
|
||||
[interface]: https://github.com/OHIF/Viewers/blob/master/platform/core/src/services/UINotificationService/index.js
|
||||
[snackbar-provider]: https://github.com/OHIF/Viewers/blob/master/platform/ui/src/contextProviders/SnackbarProvider.js
|
||||
[snackbar-container]: https://github.com/OHIF/Viewers/blob/master/platform/ui/src/components/snackbar/SnackbarContainer.js
|
||||
[ux-article]: https://uxplanet.org/how-to-design-notifications-for-better-ux-6fb0711be54d
|
||||
<!-- prettier-ignore-end -->
|
||||
@@ -11,7 +11,10 @@ export default {
|
||||
* LIFECYCLE HOOKS
|
||||
*/
|
||||
|
||||
preRegistration(extensionConfiguration) {},
|
||||
preRegistration({
|
||||
servicesManager,
|
||||
configuration: extensionConfiguration,
|
||||
}) {},
|
||||
|
||||
/**
|
||||
* MODULE GETTERS
|
||||
|
||||
@@ -3,6 +3,74 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.7.2](https://github.com/OHIF/Viewers/compare/@ohif/extension-cornerstone@1.7.1...@ohif/extension-cornerstone@1.7.2) (2019-12-02)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-cornerstone
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.7.1](https://github.com/OHIF/Viewers/compare/@ohif/extension-cornerstone@1.7.0...@ohif/extension-cornerstone@1.7.1) (2019-12-02)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-cornerstone
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [1.7.0](https://github.com/OHIF/Viewers/compare/@ohif/extension-cornerstone@1.6.0...@ohif/extension-cornerstone@1.7.0) (2019-11-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add new annotate tool using new dialog service ([#1211](https://github.com/OHIF/Viewers/issues/1211)) ([8fd3af1](https://github.com/OHIF/Viewers/commit/8fd3af1e137e793f1b482760a22591c64a072047))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [1.6.0](https://github.com/OHIF/Viewers/compare/@ohif/extension-cornerstone@1.5.1...@ohif/extension-cornerstone@1.6.0) (2019-11-19)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* New dialog service ([#1202](https://github.com/OHIF/Viewers/issues/1202)) ([f65639c](https://github.com/OHIF/Viewers/commit/f65639c2b0dab01decd20cab2cef4263cb4fab37))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.5.1](https://github.com/OHIF/Viewers/compare/@ohif/extension-cornerstone@1.5.0...@ohif/extension-cornerstone@1.5.1) (2019-11-15)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-cornerstone
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [1.5.0](https://github.com/OHIF/Viewers/compare/@ohif/extension-cornerstone@1.4.1...@ohif/extension-cornerstone@1.5.0) (2019-11-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* expose UiNotifications service ([#1172](https://github.com/OHIF/Viewers/issues/1172)) ([5c04e34](https://github.com/OHIF/Viewers/commit/5c04e34c8fb2394ab7acd9eb4f2ab12afeb2f255))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.4.1](https://github.com/OHIF/Viewers/compare/@ohif/extension-cornerstone@1.4.0...@ohif/extension-cornerstone@1.4.1) (2019-11-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add a fallback metadata provider which pulls metadata from WADO-… ([#1158](https://github.com/OHIF/Viewers/issues/1158)) ([31b1adf](https://github.com/OHIF/Viewers/commit/31b1adfa5993d6c8e3e9c8b03fa9856f2621b037))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [1.4.0](https://github.com/OHIF/Viewers/compare/@ohif/extension-cornerstone@1.3.1...@ohif/extension-cornerstone@1.4.0) (2019-10-26)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/extension-cornerstone",
|
||||
"version": "1.4.0",
|
||||
"version": "1.7.2",
|
||||
"description": "OHIF extension for Cornerstone",
|
||||
"author": "OHIF",
|
||||
"license": "MIT",
|
||||
@@ -32,7 +32,7 @@
|
||||
"@ohif/ui": "^0.50.0",
|
||||
"cornerstone-core": "^2.2.8",
|
||||
"cornerstone-math": "^0.1.8",
|
||||
"cornerstone-tools": "^4.0.9",
|
||||
"cornerstone-tools": "^4.8.0",
|
||||
"cornerstone-wado-image-loader": "^3.0.0",
|
||||
"dcmjs": "^0.6.1",
|
||||
"dicom-parser": "^1.8.3",
|
||||
@@ -48,6 +48,7 @@
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"classnames": "^2.2.6",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"query-string": "^6.8.3",
|
||||
"react-cornerstone-viewport": "2.x.x"
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import CornerstoneViewport from 'react-cornerstone-viewport';
|
||||
import OHIF from '@ohif/core';
|
||||
import { connect } from 'react-redux';
|
||||
import throttle from 'lodash.throttle';
|
||||
import { setEnabledElement } from './state';
|
||||
|
||||
const { setViewportActive, setViewportSpecificData } = OHIF.redux.actions;
|
||||
const {
|
||||
@@ -80,11 +81,11 @@ const mapDispatchToProps = (dispatch, ownProps) => {
|
||||
*/
|
||||
onElementEnabled: event => {
|
||||
const enabledElement = event.detail.element;
|
||||
setEnabledElement(viewportIndex, enabledElement);
|
||||
dispatch(
|
||||
setViewportSpecificData(viewportIndex, {
|
||||
// TODO: Hack to make sure our plugin info is available from the outset
|
||||
plugin: 'cornerstone',
|
||||
dom: enabledElement,
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import React from 'react';
|
||||
import cornerstone from 'cornerstone-core';
|
||||
import cornerstoneTools from 'cornerstone-tools';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { ViewportDownloadForm } from '@ohif/ui';
|
||||
import { utils } from '@ohif/core';
|
||||
|
||||
import { getEnabledElement } from './state';
|
||||
|
||||
const MINIMUM_SIZE = 100;
|
||||
const DEFAULT_SIZE = 512;
|
||||
const MAX_TEXTURE_SIZE = 10000;
|
||||
|
||||
const CornerstoneViewportDownloadForm = ({ onClose, activeViewportIndex }) => {
|
||||
const activeEnabledElement = getEnabledElement(activeViewportIndex);
|
||||
|
||||
const enableViewport = viewportElement => {
|
||||
if (viewportElement) {
|
||||
cornerstone.enable(viewportElement);
|
||||
}
|
||||
};
|
||||
|
||||
const disableViewport = viewportElement => {
|
||||
if (viewportElement) {
|
||||
cornerstone.disable(viewportElement);
|
||||
}
|
||||
};
|
||||
|
||||
const updateViewportPreview = (viewportElement, downloadCanvas, fileType) =>
|
||||
new Promise(resolve => {
|
||||
cornerstone.fitToWindow(viewportElement);
|
||||
|
||||
viewportElement.addEventListener(
|
||||
'cornerstoneimagerendered',
|
||||
function updateViewport(event) {
|
||||
const enabledElement = cornerstone.getEnabledElement(event.target)
|
||||
.element;
|
||||
const type = 'image/' + fileType;
|
||||
const dataUrl = downloadCanvas.toDataURL(type, 1);
|
||||
|
||||
let newWidth = enabledElement.offsetHeight;
|
||||
let newHeight = enabledElement.offsetWidth;
|
||||
|
||||
if (newWidth > DEFAULT_SIZE || newHeight > DEFAULT_SIZE) {
|
||||
const multiplier = DEFAULT_SIZE / Math.max(newWidth, newHeight);
|
||||
newHeight *= multiplier;
|
||||
newWidth *= multiplier;
|
||||
}
|
||||
|
||||
resolve({ dataUrl, width: newWidth, height: newHeight });
|
||||
|
||||
viewportElement.removeEventListener(
|
||||
'cornerstoneimagerendered',
|
||||
updateViewport
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const loadImage = (activeViewport, viewportElement, width, height) =>
|
||||
new Promise(resolve => {
|
||||
if (activeViewport && viewportElement) {
|
||||
const enabledElement = cornerstone.getEnabledElement(activeViewport);
|
||||
const viewport = Object.assign({}, enabledElement.viewport);
|
||||
delete viewport.scale;
|
||||
viewport.translation = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
};
|
||||
|
||||
cornerstone.loadImage(enabledElement.image.imageId).then(image => {
|
||||
cornerstone.displayImage(viewportElement, image);
|
||||
cornerstone.setViewport(viewportElement, viewport);
|
||||
cornerstone.resize(viewportElement, true);
|
||||
|
||||
const newWidth = Math.min(width || image.width, MAX_TEXTURE_SIZE);
|
||||
const newHeight = Math.min(height || image.height, MAX_TEXTURE_SIZE);
|
||||
|
||||
resolve({ image, width: newWidth, height: newHeight });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const toggleAnnotations = (toggle, viewportElement) => {
|
||||
cornerstoneTools.store.state.tools.forEach(({ name }) => {
|
||||
if (toggle) {
|
||||
cornerstoneTools.setToolEnabledForElement(viewportElement, name);
|
||||
} else {
|
||||
cornerstoneTools.setToolDisabledForElement(viewportElement, name);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const downloadBlob = (
|
||||
filename,
|
||||
fileType,
|
||||
viewportElement,
|
||||
downloadCanvas
|
||||
) => {
|
||||
const file = `${filename}.${fileType}`;
|
||||
const mimetype = `image/${fileType}`;
|
||||
|
||||
/* Handles JPEG images for IE11 */
|
||||
if (downloadCanvas.msToBlob && fileType === 'jpeg') {
|
||||
const image = downloadCanvas.toDataURL(mimetype, 1);
|
||||
const blob = utils.b64toBlob(
|
||||
image.replace('data:image/jpeg;base64,', ''),
|
||||
mimetype
|
||||
);
|
||||
return window.navigator.msSaveBlob(blob, file);
|
||||
}
|
||||
|
||||
viewportElement.querySelector('canvas').toBlob(blob => {
|
||||
const URLObj = window.URL || window.webkitURL;
|
||||
const a = document.createElement('a');
|
||||
a.href = URLObj.createObjectURL(blob);
|
||||
a.download = file;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ViewportDownloadForm
|
||||
onClose={onClose}
|
||||
minimumSize={MINIMUM_SIZE}
|
||||
maximumSize={MAX_TEXTURE_SIZE}
|
||||
defaultSize={DEFAULT_SIZE}
|
||||
canvasClass={'cornerstone-canvas'}
|
||||
activeViewport={activeEnabledElement}
|
||||
enableViewport={enableViewport}
|
||||
disableViewport={disableViewport}
|
||||
updateViewportPreview={updateViewportPreview}
|
||||
loadImage={loadImage}
|
||||
toggleAnnotations={toggleAnnotations}
|
||||
downloadBlob={downloadBlob}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
CornerstoneViewportDownloadForm.propTypes = {
|
||||
onClose: PropTypes.func,
|
||||
activeViewportIndex: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
export default CornerstoneViewportDownloadForm;
|
||||
@@ -1,261 +1,260 @@
|
||||
import cornerstone from 'cornerstone-core';
|
||||
import cornerstoneTools from 'cornerstone-tools';
|
||||
import OHIF from '@ohif/core';
|
||||
|
||||
import { getEnabledElement } from './state';
|
||||
import CornerstoneViewportDownloadForm from './CornerstoneViewportDownloadForm';
|
||||
const scroll = cornerstoneTools.import('util/scroll');
|
||||
|
||||
const actions = {
|
||||
rotateViewport: ({ viewports, rotation }) => {
|
||||
const enabledElement = _getActiveViewportEnabledElement(
|
||||
viewports.viewportSpecificData,
|
||||
viewports.activeViewportIndex
|
||||
);
|
||||
const commandsModule = ({ servicesManager }) => {
|
||||
const actions = {
|
||||
rotateViewport: ({ viewports, rotation }) => {
|
||||
const enabledElement = getEnabledElement(viewports.activeViewportIndex);
|
||||
|
||||
if (enabledElement) {
|
||||
let viewport = cornerstone.getViewport(enabledElement);
|
||||
viewport.rotation += rotation;
|
||||
cornerstone.setViewport(enabledElement, viewport);
|
||||
}
|
||||
},
|
||||
flipViewportHorizontal: ({ viewports }) => {
|
||||
const enabledElement = _getActiveViewportEnabledElement(
|
||||
viewports.viewportSpecificData,
|
||||
viewports.activeViewportIndex
|
||||
);
|
||||
|
||||
if (enabledElement) {
|
||||
let viewport = cornerstone.getViewport(enabledElement);
|
||||
viewport.hflip = !viewport.hflip;
|
||||
cornerstone.setViewport(enabledElement, viewport);
|
||||
}
|
||||
},
|
||||
flipViewportVertical: ({ viewports }) => {
|
||||
const enabledElement = _getActiveViewportEnabledElement(
|
||||
viewports.viewportSpecificData,
|
||||
viewports.activeViewportIndex
|
||||
);
|
||||
|
||||
if (enabledElement) {
|
||||
let viewport = cornerstone.getViewport(enabledElement);
|
||||
viewport.vflip = !viewport.vflip;
|
||||
cornerstone.setViewport(enabledElement, viewport);
|
||||
}
|
||||
},
|
||||
scaleViewport: ({ viewports, direction }) => {
|
||||
const enabledElement = _getActiveViewportEnabledElement(
|
||||
viewports.viewportSpecificData,
|
||||
viewports.activeViewportIndex
|
||||
);
|
||||
const step = direction * 0.15;
|
||||
|
||||
if (enabledElement) {
|
||||
if (step) {
|
||||
if (enabledElement) {
|
||||
let viewport = cornerstone.getViewport(enabledElement);
|
||||
viewport.scale += step;
|
||||
viewport.rotation += rotation;
|
||||
cornerstone.setViewport(enabledElement, viewport);
|
||||
} else {
|
||||
cornerstone.fitToWindow(enabledElement);
|
||||
}
|
||||
}
|
||||
},
|
||||
resetViewport: ({ viewports }) => {
|
||||
const enabledElement = _getActiveViewportEnabledElement(
|
||||
viewports.viewportSpecificData,
|
||||
viewports.activeViewportIndex
|
||||
);
|
||||
},
|
||||
flipViewportHorizontal: ({ viewports }) => {
|
||||
const enabledElement = getEnabledElement(viewports.activeViewportIndex);
|
||||
|
||||
if (enabledElement) {
|
||||
cornerstone.reset(enabledElement);
|
||||
}
|
||||
},
|
||||
invertViewport: ({ viewports }) => {
|
||||
const enabledElement = _getActiveViewportEnabledElement(
|
||||
viewports.viewportSpecificData,
|
||||
viewports.activeViewportIndex
|
||||
);
|
||||
if (enabledElement) {
|
||||
let viewport = cornerstone.getViewport(enabledElement);
|
||||
viewport.hflip = !viewport.hflip;
|
||||
cornerstone.setViewport(enabledElement, viewport);
|
||||
}
|
||||
},
|
||||
flipViewportVertical: ({ viewports }) => {
|
||||
const enabledElement = getEnabledElement(viewports.activeViewportIndex);
|
||||
|
||||
if (enabledElement) {
|
||||
let viewport = cornerstone.getViewport(enabledElement);
|
||||
viewport.invert = !viewport.invert;
|
||||
cornerstone.setViewport(enabledElement, viewport);
|
||||
}
|
||||
},
|
||||
// TODO: this is receiving `evt` from `ToolbarRow`. We could use it to have
|
||||
// better mouseButtonMask sets.
|
||||
setToolActive: ({ toolName }) => {
|
||||
if (!toolName) {
|
||||
console.warn('No toolname provided to setToolActive command');
|
||||
}
|
||||
cornerstoneTools.setToolActive(toolName, { mouseButtonMask: 1 });
|
||||
},
|
||||
updateViewportDisplaySet: ({ direction }) => {
|
||||
// TODO
|
||||
console.warn('updateDisplaySet: ', direction);
|
||||
},
|
||||
clearAnnotations: ({ viewports }) => {
|
||||
const element = _getActiveViewportEnabledElement(
|
||||
viewports.viewportSpecificData,
|
||||
viewports.activeViewportIndex
|
||||
);
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
if (enabledElement) {
|
||||
let viewport = cornerstone.getViewport(enabledElement);
|
||||
viewport.vflip = !viewport.vflip;
|
||||
cornerstone.setViewport(enabledElement, viewport);
|
||||
}
|
||||
},
|
||||
scaleViewport: ({ direction, viewports }) => {
|
||||
const enabledElement = getEnabledElement(viewports.activeViewportIndex);
|
||||
const step = direction * 0.15;
|
||||
|
||||
const enabledElement = cornerstone.getEnabledElement(element);
|
||||
if (!enabledElement || !enabledElement.image) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
toolState,
|
||||
} = cornerstoneTools.globalImageIdSpecificToolStateManager;
|
||||
if (
|
||||
!toolState ||
|
||||
toolState.hasOwnProperty(enabledElement.image.imageId) === false
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageIdToolState = toolState[enabledElement.image.imageId];
|
||||
|
||||
const measurementsToRemove = [];
|
||||
|
||||
Object.keys(imageIdToolState).forEach(toolType => {
|
||||
const { data } = imageIdToolState[toolType];
|
||||
|
||||
data.forEach(measurementData => {
|
||||
const { _id, lesionNamingNumber, measurementNumber } = measurementData;
|
||||
if (!_id) {
|
||||
return;
|
||||
if (enabledElement) {
|
||||
if (step) {
|
||||
let viewport = cornerstone.getViewport(enabledElement);
|
||||
viewport.scale += step;
|
||||
cornerstone.setViewport(enabledElement, viewport);
|
||||
} else {
|
||||
cornerstone.fitToWindow(enabledElement);
|
||||
}
|
||||
}
|
||||
},
|
||||
resetViewport: ({ viewports }) => {
|
||||
const enabledElement = getEnabledElement(viewports.activeViewportIndex);
|
||||
|
||||
measurementsToRemove.push({
|
||||
toolType,
|
||||
_id,
|
||||
lesionNamingNumber,
|
||||
measurementNumber,
|
||||
if (enabledElement) {
|
||||
cornerstone.reset(enabledElement);
|
||||
}
|
||||
},
|
||||
invertViewport: ({ viewports }) => {
|
||||
const enabledElement = getEnabledElement(viewports.activeViewportIndex);
|
||||
|
||||
if (enabledElement) {
|
||||
let viewport = cornerstone.getViewport(enabledElement);
|
||||
viewport.invert = !viewport.invert;
|
||||
cornerstone.setViewport(enabledElement, viewport);
|
||||
}
|
||||
},
|
||||
// TODO: this is receiving `evt` from `ToolbarRow`. We could use it to have
|
||||
// better mouseButtonMask sets.
|
||||
setToolActive: ({ toolName }) => {
|
||||
if (!toolName) {
|
||||
console.warn('No toolname provided to setToolActive command');
|
||||
}
|
||||
cornerstoneTools.setToolActive(toolName, { mouseButtonMask: 1 });
|
||||
},
|
||||
updateViewportDisplaySet: ({ direction }) => {
|
||||
// TODO
|
||||
console.warn('updateDisplaySet: ', direction);
|
||||
},
|
||||
clearAnnotations: ({ viewports }) => {
|
||||
const element = getEnabledElement(viewports.activeViewportIndex);
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const enabledElement = cornerstone.getEnabledElement(element);
|
||||
if (!enabledElement || !enabledElement.image) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
toolState,
|
||||
} = cornerstoneTools.globalImageIdSpecificToolStateManager;
|
||||
if (
|
||||
!toolState ||
|
||||
toolState.hasOwnProperty(enabledElement.image.imageId) === false
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageIdToolState = toolState[enabledElement.image.imageId];
|
||||
|
||||
const measurementsToRemove = [];
|
||||
|
||||
Object.keys(imageIdToolState).forEach(toolType => {
|
||||
const { data } = imageIdToolState[toolType];
|
||||
|
||||
data.forEach(measurementData => {
|
||||
const {
|
||||
_id,
|
||||
lesionNamingNumber,
|
||||
measurementNumber,
|
||||
} = measurementData;
|
||||
if (!_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
measurementsToRemove.push({
|
||||
toolType,
|
||||
_id,
|
||||
lesionNamingNumber,
|
||||
measurementNumber,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
measurementsToRemove.forEach(measurementData => {
|
||||
OHIF.measurements.MeasurementHandlers.onRemoved({
|
||||
detail: {
|
||||
toolType: measurementData.toolType,
|
||||
measurementData,
|
||||
measurementsToRemove.forEach(measurementData => {
|
||||
OHIF.measurements.MeasurementHandlers.onRemoved({
|
||||
detail: {
|
||||
toolType: measurementData.toolType,
|
||||
measurementData,
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
nextImage: ({ viewports }) => {
|
||||
const enabledElement = getEnabledElement(viewports.activeViewportIndex);
|
||||
scroll(enabledElement, 1);
|
||||
},
|
||||
previousImage: ({ viewports }) => {
|
||||
const enabledElement = getEnabledElement(viewports.activeViewportIndex);
|
||||
scroll(enabledElement, -1);
|
||||
},
|
||||
getActiveViewportEnabledElement: ({ viewports }) => {
|
||||
const enabledElement = getEnabledElement(viewports.activeViewportIndex);
|
||||
return enabledElement;
|
||||
},
|
||||
showDownloadViewportModal: ({ title, viewports }) => {
|
||||
const activeViewportIndex = viewports.activeViewportIndex;
|
||||
const { UIModalService } = servicesManager.services;
|
||||
UIModalService.show({
|
||||
content: CornerstoneViewportDownloadForm,
|
||||
title,
|
||||
contentProps: {
|
||||
activeViewportIndex,
|
||||
onClose: UIModalService.hide,
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
nextImage: ({ viewports }) => {
|
||||
const enabledElement = _getActiveViewportEnabledElement(
|
||||
viewports.viewportSpecificData,
|
||||
viewports.activeViewportIndex
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
scroll(enabledElement, 1);
|
||||
},
|
||||
previousImage: ({ viewports }) => {
|
||||
const enabledElement = _getActiveViewportEnabledElement(
|
||||
viewports.viewportSpecificData,
|
||||
viewports.activeViewportIndex
|
||||
);
|
||||
const definitions = {
|
||||
showDownloadViewportModal: {
|
||||
commandFn: actions.showDownloadViewportModal,
|
||||
storeContexts: ['viewports'],
|
||||
options: {},
|
||||
},
|
||||
getActiveViewportEnabledElement: {
|
||||
commandFn: actions.getActiveViewportEnabledElement,
|
||||
storeContexts: ['viewports'],
|
||||
options: {},
|
||||
},
|
||||
rotateViewportCW: {
|
||||
commandFn: actions.rotateViewport,
|
||||
storeContexts: ['viewports'],
|
||||
options: { rotation: 90 },
|
||||
},
|
||||
rotateViewportCCW: {
|
||||
commandFn: actions.rotateViewport,
|
||||
storeContexts: ['viewports'],
|
||||
options: { rotation: -90 },
|
||||
},
|
||||
invertViewport: {
|
||||
commandFn: actions.invertViewport,
|
||||
storeContexts: ['viewports'],
|
||||
options: {},
|
||||
},
|
||||
flipViewportVertical: {
|
||||
commandFn: actions.flipViewportVertical,
|
||||
storeContexts: ['viewports'],
|
||||
options: {},
|
||||
},
|
||||
flipViewportHorizontal: {
|
||||
commandFn: actions.flipViewportHorizontal,
|
||||
storeContexts: ['viewports'],
|
||||
options: {},
|
||||
},
|
||||
scaleUpViewport: {
|
||||
commandFn: actions.scaleViewport,
|
||||
storeContexts: ['viewports'],
|
||||
options: { direction: 1 },
|
||||
},
|
||||
scaleDownViewport: {
|
||||
commandFn: actions.scaleViewport,
|
||||
storeContexts: ['viewports'],
|
||||
options: { direction: -1 },
|
||||
},
|
||||
fitViewportToWindow: {
|
||||
commandFn: actions.scaleViewport,
|
||||
storeContexts: ['viewports'],
|
||||
options: { direction: 0 },
|
||||
},
|
||||
resetViewport: {
|
||||
commandFn: actions.resetViewport,
|
||||
storeContexts: ['viewports'],
|
||||
options: {},
|
||||
},
|
||||
clearAnnotations: {
|
||||
commandFn: actions.clearAnnotations,
|
||||
storeContexts: ['viewports'],
|
||||
options: {},
|
||||
},
|
||||
nextImage: {
|
||||
commandFn: actions.nextImage,
|
||||
storeContexts: ['viewports'],
|
||||
options: {},
|
||||
},
|
||||
previousImage: {
|
||||
commandFn: actions.previousImage,
|
||||
storeContexts: ['viewports'],
|
||||
options: {},
|
||||
},
|
||||
// TODO: First/Last image
|
||||
// Next/Previous series/DisplaySet
|
||||
nextViewportDisplaySet: {
|
||||
commandFn: actions.updateViewportDisplaySet,
|
||||
storeContexts: [],
|
||||
options: { direction: 1 },
|
||||
},
|
||||
previousViewportDisplaySet: {
|
||||
commandFn: actions.updateViewportDisplaySet,
|
||||
storeContexts: [],
|
||||
options: { direction: -1 },
|
||||
},
|
||||
// TOOLS
|
||||
setToolActive: {
|
||||
commandFn: actions.setToolActive,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
};
|
||||
|
||||
scroll(enabledElement, -1);
|
||||
},
|
||||
return {
|
||||
actions,
|
||||
definitions,
|
||||
defaultContext: 'ACTIVE_VIEWPORT::CORNERSTONE',
|
||||
};
|
||||
};
|
||||
|
||||
const definitions = {
|
||||
rotateViewportCW: {
|
||||
commandFn: actions.rotateViewport,
|
||||
storeContexts: ['viewports'],
|
||||
options: { rotation: 90 },
|
||||
},
|
||||
rotateViewportCCW: {
|
||||
commandFn: actions.rotateViewport,
|
||||
storeContexts: ['viewports'],
|
||||
options: { rotation: -90 },
|
||||
},
|
||||
invertViewport: {
|
||||
commandFn: actions.invertViewport,
|
||||
storeContexts: ['viewports'],
|
||||
options: {},
|
||||
},
|
||||
flipViewportVertical: {
|
||||
commandFn: actions.flipViewportVertical,
|
||||
storeContexts: ['viewports'],
|
||||
options: {},
|
||||
},
|
||||
flipViewportHorizontal: {
|
||||
commandFn: actions.flipViewportHorizontal,
|
||||
storeContexts: ['viewports'],
|
||||
options: {},
|
||||
},
|
||||
scaleUpViewport: {
|
||||
commandFn: actions.scaleViewport,
|
||||
storeContexts: ['viewports'],
|
||||
options: { direction: 1 },
|
||||
},
|
||||
scaleDownViewport: {
|
||||
commandFn: actions.scaleViewport,
|
||||
storeContexts: ['viewports'],
|
||||
options: { direction: -1 },
|
||||
},
|
||||
fitViewportToWindow: {
|
||||
commandFn: actions.scaleViewport,
|
||||
storeContexts: ['viewports'],
|
||||
options: { direction: 0 },
|
||||
},
|
||||
resetViewport: {
|
||||
commandFn: actions.resetViewport,
|
||||
storeContexts: ['viewports'],
|
||||
options: {},
|
||||
},
|
||||
clearAnnotations: {
|
||||
commandFn: actions.clearAnnotations,
|
||||
storeContexts: ['viewports'],
|
||||
options: {},
|
||||
},
|
||||
nextImage: {
|
||||
commandFn: actions.nextImage,
|
||||
storeContexts: ['viewports'],
|
||||
options: {},
|
||||
},
|
||||
previousImage: {
|
||||
commandFn: actions.previousImage,
|
||||
storeContexts: ['viewports'],
|
||||
options: {},
|
||||
},
|
||||
// TODO: First/Last image
|
||||
// Next/Previous series/DisplaySet
|
||||
nextViewportDisplaySet: {
|
||||
commandFn: actions.updateViewportDisplaySet,
|
||||
storeContexts: [],
|
||||
options: { direction: 1 },
|
||||
},
|
||||
previousViewportDisplaySet: {
|
||||
commandFn: actions.updateViewportDisplaySet,
|
||||
storeContexts: [],
|
||||
options: { direction: -1 },
|
||||
},
|
||||
// TOOLS
|
||||
setToolActive: {
|
||||
commandFn: actions.setToolActive,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Grabs `dom` reference for the enabledElement of
|
||||
* the active viewport
|
||||
*/
|
||||
function _getActiveViewportEnabledElement(viewports, activeIndex) {
|
||||
const activeViewport = viewports[activeIndex] || {};
|
||||
return activeViewport.dom;
|
||||
}
|
||||
|
||||
export default {
|
||||
actions,
|
||||
definitions,
|
||||
defaultContext: 'ACTIVE_VIEWPORT::CORNERSTONE',
|
||||
};
|
||||
export default commandsModule;
|
||||
@@ -2,6 +2,7 @@ import init from './init.js';
|
||||
import asyncComponent from './asyncComponent.js';
|
||||
import commandsModule from './commandsModule.js';
|
||||
import toolbarModule from './toolbarModule.js';
|
||||
import CornerstoneViewportDownloadForm from './CornerstoneViewportDownloadForm';
|
||||
|
||||
const OHIFCornerstoneViewport = asyncComponent(() =>
|
||||
import(
|
||||
@@ -24,8 +25,8 @@ export default {
|
||||
* @param {object} [configuration={}]
|
||||
* @param {object|array} [configuration.csToolsConfig] - Passed directly to `initCornerstoneTools`
|
||||
*/
|
||||
preRegistration(configuration = {}) {
|
||||
init(configuration);
|
||||
preRegistration({ servicesManager, configuration = {} }) {
|
||||
init({ servicesManager, configuration });
|
||||
},
|
||||
getViewportModule() {
|
||||
return OHIFCornerstoneViewport;
|
||||
@@ -33,7 +34,9 @@ export default {
|
||||
getToolbarModule() {
|
||||
return toolbarModule;
|
||||
},
|
||||
getCommandsModule() {
|
||||
return commandsModule;
|
||||
getCommandsModule({ servicesManager }) {
|
||||
return commandsModule({ servicesManager });
|
||||
},
|
||||
};
|
||||
|
||||
export { CornerstoneViewportDownloadForm };
|
||||
@@ -2,13 +2,56 @@ import OHIF from '@ohif/core';
|
||||
import cornerstone from 'cornerstone-core';
|
||||
import csTools from 'cornerstone-tools';
|
||||
import initCornerstoneTools from './initCornerstoneTools.js';
|
||||
import queryString from 'query-string';
|
||||
import { SimpleDialog } from '@ohif/ui';
|
||||
|
||||
function fallbackMetaDataProvider(type, imageId) {
|
||||
if (!imageId.includes('wado?requestType=WADO')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If you call for an WADO-URI imageId and get no
|
||||
// metadata, try reformatting to WADO-RS imageId
|
||||
const qs = queryString.parse(imageId);
|
||||
const wadoRoot = window.store.getState().servers.servers[0].wadoRoot;
|
||||
const wadoRsImageId = `wadors:${wadoRoot}/studies/${qs.studyUID}/series/${
|
||||
qs.seriesUID
|
||||
}/instances/${qs.objectUID}/frames/${qs.frame || 1}`;
|
||||
|
||||
return cornerstone.metaData.get(type, wadoRsImageId);
|
||||
}
|
||||
|
||||
// Add this fallback provider with a low priority so it is handled last
|
||||
cornerstone.metaData.addProvider(fallbackMetaDataProvider, -1);
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {object} configuration
|
||||
* @param {Object|Array} configuration.csToolsConfig
|
||||
*/
|
||||
export default function init(configuration = {}) {
|
||||
export default function init({ servicesManager, configuration = {} }) {
|
||||
const { UIDialogService } = servicesManager.services;
|
||||
const callInputDialog = (data, event, callback) => {
|
||||
let dialogId = UIDialogService.create({
|
||||
content: SimpleDialog.InputDialog,
|
||||
defaultPosition: {
|
||||
x: (event && event.currentPoints.canvas.x) || 0,
|
||||
y: (event && event.currentPoints.canvas.y) || 0,
|
||||
},
|
||||
showOverlay: true,
|
||||
contentProps: {
|
||||
title: 'Enter your annotation',
|
||||
label: 'New label',
|
||||
measurementData: data ? { description: data.text } : {},
|
||||
onClose: () => UIDialogService.dismiss({ id: dialogId }),
|
||||
onSubmit: value => {
|
||||
callback(value);
|
||||
UIDialogService.dismiss({ id: dialogId });
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const { csToolsConfig } = configuration;
|
||||
const { StackManager } = OHIF.utils;
|
||||
const metadataProvider = new OHIF.cornerstone.MetadataProvider();
|
||||
@@ -42,7 +85,6 @@ export default function init(configuration = {}) {
|
||||
ZoomTouchPinchTool,
|
||||
// Annotations
|
||||
EraserTool,
|
||||
ArrowAnnotateTool,
|
||||
BidirectionalTool,
|
||||
LengthTool,
|
||||
AngleTool,
|
||||
@@ -65,7 +107,6 @@ export default function init(configuration = {}) {
|
||||
ZoomTouchPinchTool,
|
||||
// Annotations
|
||||
EraserTool,
|
||||
ArrowAnnotateTool,
|
||||
BidirectionalTool,
|
||||
LengthTool,
|
||||
AngleTool,
|
||||
@@ -78,6 +119,16 @@ export default function init(configuration = {}) {
|
||||
];
|
||||
|
||||
tools.forEach(tool => csTools.addTool(tool));
|
||||
|
||||
csTools.addTool(csTools.ArrowAnnotateTool, {
|
||||
configuration: {
|
||||
getTextCallback: (callback, eventDetails) =>
|
||||
callInputDialog(null, eventDetails, callback),
|
||||
changeTextCallback: (data, eventDetails, callback) =>
|
||||
callInputDialog(data, eventDetails, callback),
|
||||
},
|
||||
});
|
||||
|
||||
csTools.setToolActive('Pan', { mouseButtonMask: 4 });
|
||||
csTools.setToolActive('Zoom', { mouseButtonMask: 2 });
|
||||
csTools.setToolActive('Wwwc', { mouseButtonMask: 1 });
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
const state = {
|
||||
enabledElements: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the enabled element `dom` reference for an active viewport.
|
||||
* @param {HTMLElement} dom Active viewport element.
|
||||
* @return void
|
||||
*/
|
||||
const setEnabledElement = (viewportIndex, element) =>
|
||||
(state.enabledElements[viewportIndex] = element);
|
||||
|
||||
/**
|
||||
* Grabs the enabled element `dom` reference of an active viewport.
|
||||
*
|
||||
* @return {HTMLElement} Active viewport element.
|
||||
*/
|
||||
const getEnabledElement = viewportIndex => state.enabledElements[viewportIndex];
|
||||
|
||||
export { setEnabledElement, getEnabledElement };
|
||||
@@ -23,6 +23,17 @@ const TOOLBAR_BUTTON_TYPES = {
|
||||
BUILT_IN: 'builtIn',
|
||||
};
|
||||
|
||||
const TOOLBAR_BUTTON_BEHAVIORS = {
|
||||
CINE: 'CINE',
|
||||
DOWNLOAD_SCREEN_SHOT: 'DOWNLOAD_SCREEN_SHOT',
|
||||
};
|
||||
|
||||
/* TODO: Export enums through a extension manager. */
|
||||
const enums = {
|
||||
TOOLBAR_BUTTON_TYPES,
|
||||
TOOLBAR_BUTTON_BEHAVIORS,
|
||||
};
|
||||
|
||||
const definitions = [
|
||||
{
|
||||
id: 'StackScroll',
|
||||
@@ -102,7 +113,7 @@ const definitions = [
|
||||
//
|
||||
type: TOOLBAR_BUTTON_TYPES.BUILT_IN,
|
||||
options: {
|
||||
behavior: 'CINE',
|
||||
behavior: TOOLBAR_BUTTON_BEHAVIORS.CINE,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -220,7 +231,8 @@ const definitions = [
|
||||
//
|
||||
type: TOOLBAR_BUTTON_TYPES.BUILT_IN,
|
||||
options: {
|
||||
behavior: 'DOWNLOAD_SCREEN_SHOT',
|
||||
behavior: TOOLBAR_BUTTON_BEHAVIORS.DOWNLOAD_SCREEN_SHOT,
|
||||
togglable: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.0.2](https://github.com/OHIF/Viewers/compare/@ohif/extension-dicom-html@1.0.1...@ohif/extension-dicom-html@1.0.2) (2019-12-02)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-dicom-html
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.0.1](https://github.com/OHIF/Viewers/compare/@ohif/extension-dicom-html@1.0.0...@ohif/extension-dicom-html@1.0.1) (2019-10-29)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/extension-dicom-html",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"description": "OHIF extension for rendering structured reports to HTML",
|
||||
"author": "OHIF",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -6,7 +6,7 @@ const { setViewportActive } = OHIF.redux.actions;
|
||||
|
||||
const mapStateToProps = (state, ownProps) => {
|
||||
const { viewportIndex, byteArray } = ownProps;
|
||||
const activeViewportIndex = state.viewports;
|
||||
const { activeViewportIndex } = state.viewports;
|
||||
|
||||
return {
|
||||
viewportIndex,
|
||||
|
||||
@@ -238,7 +238,7 @@ class DicomHtmlViewport extends Component {
|
||||
} = this.props;
|
||||
|
||||
if (viewportIndex !== activeViewportIndex) {
|
||||
setViewportActive();
|
||||
setViewportActive(viewportIndex);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,216 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [0.54.3](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.54.2...@ohif/extension-vtk@0.54.3) (2019-12-06)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.54.2](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.54.1...@ohif/extension-vtk@0.54.2) (2019-12-02)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.54.1](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.54.0...@ohif/extension-vtk@0.54.1) (2019-11-28)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.54.0](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.53.13...@ohif/extension-vtk@0.54.0) (2019-11-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add new annotate tool using new dialog service ([#1211](https://github.com/OHIF/Viewers/issues/1211)) ([8fd3af1](https://github.com/OHIF/Viewers/commit/8fd3af1e137e793f1b482760a22591c64a072047))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.53.13](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.53.12...@ohif/extension-vtk@0.53.13) (2019-11-25)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.53.12](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.53.11...@ohif/extension-vtk@0.53.12) (2019-11-20)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.53.11](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.53.10...@ohif/extension-vtk@0.53.11) (2019-11-19)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.53.10](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.53.9...@ohif/extension-vtk@0.53.10) (2019-11-19)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.53.9](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.53.8...@ohif/extension-vtk@0.53.9) (2019-11-18)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.53.8](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.53.7...@ohif/extension-vtk@0.53.8) (2019-11-15)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.53.7](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.53.6...@ohif/extension-vtk@0.53.7) (2019-11-15)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.53.6](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.53.5...@ohif/extension-vtk@0.53.6) (2019-11-14)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.53.5](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.53.4...@ohif/extension-vtk@0.53.5) (2019-11-13)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.53.4](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.53.3...@ohif/extension-vtk@0.53.4) (2019-11-12)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.53.3](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.53.2...@ohif/extension-vtk@0.53.3) (2019-11-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* mpr2d vtkjs viewport does not render if range is set to `NaN` values ([#1157](https://github.com/OHIF/Viewers/issues/1157)) ([84a0972](https://github.com/OHIF/Viewers/commit/84a097212babc0f98198b9ced1def9743973a5a8))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.53.2](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.53.1...@ohif/extension-vtk@0.53.2) (2019-11-08)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.53.1](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.53.0...@ohif/extension-vtk@0.53.1) (2019-11-08)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [0.53.0](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.52.32...@ohif/extension-vtk@0.53.0) (2019-11-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* modal provider ([#1151](https://github.com/OHIF/Viewers/issues/1151)) ([75d88bc](https://github.com/OHIF/Viewers/commit/75d88bc454710d2dcdbc7d68c4d9df041159c840)), closes [#1086](https://github.com/OHIF/Viewers/issues/1086) [#1116](https://github.com/OHIF/Viewers/issues/1116) [#1116](https://github.com/OHIF/Viewers/issues/1116) [#1146](https://github.com/OHIF/Viewers/issues/1146) [#1142](https://github.com/OHIF/Viewers/issues/1142) [#1143](https://github.com/OHIF/Viewers/issues/1143) [#1110](https://github.com/OHIF/Viewers/issues/1110) [#1086](https://github.com/OHIF/Viewers/issues/1086) [#1116](https://github.com/OHIF/Viewers/issues/1116) [#1119](https://github.com/OHIF/Viewers/issues/1119)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.52.32](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.52.31...@ohif/extension-vtk@0.52.32) (2019-11-05)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.52.31](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.52.30...@ohif/extension-vtk@0.52.31) (2019-11-05)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
## [0.52.30](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.52.29...@ohif/extension-vtk@0.52.30) (2019-11-04)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.52.29](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.52.28...@ohif/extension-vtk@0.52.29) (2019-11-04)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* 🐛 Upgrade react-vtkjs-viewport to fix volume orientation ([#1143](https://github.com/OHIF/Viewers/issues/1143)) ([1d86b1a](https://github.com/OHIF/Viewers/commit/1d86b1a102fc422053cff103de8c12ca6f437354))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.52.28](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.52.27...@ohif/extension-vtk@0.52.28) (2019-11-04)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.52.27](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.52.26...@ohif/extension-vtk@0.52.27) (2019-10-31)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.52.26](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.52.25...@ohif/extension-vtk@0.52.26) (2019-10-30)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [0.52.25](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.52.24...@ohif/extension-vtk@0.52.25) (2019-10-29)
|
||||
|
||||
**Note:** Version bump only for package @ohif/extension-vtk
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/extension-vtk",
|
||||
"version": "0.52.25",
|
||||
"version": "0.54.3",
|
||||
"description": "OHIF extension for VTK.js",
|
||||
"author": "OHIF",
|
||||
"license": "MIT",
|
||||
@@ -49,12 +49,12 @@
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"react-vtkjs-viewport": "^0.3.7"
|
||||
"react-vtkjs-viewport": "^0.3.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ohif/core": "^1.6.0",
|
||||
"@ohif/ui": "^0.58.2",
|
||||
"cornerstone-tools": "^4.0.9",
|
||||
"@ohif/core": "^1.13.3",
|
||||
"@ohif/ui": "^0.65.1",
|
||||
"cornerstone-tools": "^4.8.0",
|
||||
"cornerstone-wado-image-loader": "^3.0.0",
|
||||
"dcmjs": "^0.6.1",
|
||||
"dicom-parser": "^1.8.3",
|
||||
|
||||
@@ -65,8 +65,16 @@ class OHIFVTKViewport extends Component {
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
studies: PropTypes.object,
|
||||
displaySet: PropTypes.object,
|
||||
viewportData: PropTypes.shape({
|
||||
studies: PropTypes.array,
|
||||
displaySet: PropTypes.shape({
|
||||
studyInstanceUid: PropTypes.string,
|
||||
displaySetInstanceUid: PropTypes.string,
|
||||
sopClassUids: PropTypes.arrayOf(PropTypes.string),
|
||||
sopInstanceUid: PropTypes.string,
|
||||
frameIndex: PropTypes.number,
|
||||
}),
|
||||
}),
|
||||
viewportIndex: PropTypes.number,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
@@ -175,6 +183,19 @@ class OHIFVTKViewport extends Component {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @param {object} imageDataObject
|
||||
* @param {object} imageDataObject.vtkImageData
|
||||
* @param {object} imageDataObject.imageMetaData0
|
||||
* @param {number} [imageDataObject.imageMetaData0.windowWidth] - The volume's initial windowWidth
|
||||
* @param {number} [imageDataObject.imageMetaData0.windowCenter] - The volume's initial windowCenter
|
||||
* @param {string} imageDataObject.imageMetaData0.modality - CT, MR, PT, etc
|
||||
* @param {string} displaySetInstanceUid
|
||||
* @returns vtkVolumeActor
|
||||
* @memberof OHIFVTKViewport
|
||||
*/
|
||||
getOrCreateVolume(imageDataObject, displaySetInstanceUid) {
|
||||
if (volumeCache[displaySetInstanceUid]) {
|
||||
return volumeCache[displaySetInstanceUid];
|
||||
@@ -183,18 +204,11 @@ class OHIFVTKViewport extends Component {
|
||||
const { vtkImageData, imageMetaData0 } = imageDataObject;
|
||||
const { windowWidth, windowCenter, modality } = imageMetaData0;
|
||||
|
||||
let lower;
|
||||
let upper;
|
||||
|
||||
if (modality === 'PT') {
|
||||
// For PET just set the range to 0-5 SUV
|
||||
lower = 0;
|
||||
upper = 5;
|
||||
} else {
|
||||
lower = windowCenter - windowWidth / 2.0;
|
||||
upper = windowCenter + windowWidth / 2.0;
|
||||
}
|
||||
|
||||
const { lower, upper } = _getRangeFromWindowLevels(
|
||||
windowWidth,
|
||||
windowCenter,
|
||||
modality
|
||||
);
|
||||
const volumeActor = vtkVolume.newInstance();
|
||||
const volumeMapper = vtkVolumeMapper.newInstance();
|
||||
|
||||
@@ -379,4 +393,32 @@ class OHIFVTKViewport extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes window levels and converts them to a range (lower/upper)
|
||||
* for use with VTK RGBTransferFunction
|
||||
*
|
||||
* @private
|
||||
* @param {number} [width] - the width of our window
|
||||
* @param {number} [center] - the center of our window
|
||||
* @param {string} [modality] - 'PT', 'CT', etc.
|
||||
* @returns { lower, upper } - range
|
||||
*/
|
||||
function _getRangeFromWindowLevels(width, center, modality = undefined) {
|
||||
const levelsAreNotNumbers = isNaN(center) || isNaN(width);
|
||||
|
||||
if (levelsAreNotNumbers) {
|
||||
return { lower: 0, upper: 512 };
|
||||
}
|
||||
|
||||
// For PET just set the range to 0-5 SUV
|
||||
if (modality === 'PT') {
|
||||
return { lower: 0, upper: 5 };
|
||||
}
|
||||
|
||||
return {
|
||||
lower: center - width / 2.0,
|
||||
upper: center + width / 2.0,
|
||||
};
|
||||
}
|
||||
|
||||
export default OHIFVTKViewport;
|
||||
@@ -1,7 +1,7 @@
|
||||
import throttle from 'lodash.throttle';
|
||||
import {
|
||||
vtkInteractorStyleMPRCrosshairs,
|
||||
vtkInteractorStyleMPRWindowLevel,
|
||||
vtkInteractorStyleMPRSlice,
|
||||
vtkInteractorStyleMPRRotate,
|
||||
vtkSVGCrosshairsWidget,
|
||||
} from 'react-vtkjs-viewport';
|
||||
@@ -9,354 +9,358 @@ import {
|
||||
import setMPRLayout from './utils/setMPRLayout.js';
|
||||
import setViewportToVTK from './utils/setViewportToVTK.js';
|
||||
import Constants from 'vtk.js/Sources/Rendering/Core/VolumeMapper/Constants.js';
|
||||
import throttle from 'lodash.throttle';
|
||||
|
||||
const { BlendMode } = Constants;
|
||||
|
||||
// TODO: Put this somewhere else
|
||||
let apis = {};
|
||||
const commandsModule = ({ commandsManager }) => {
|
||||
// TODO: Put this somewhere else
|
||||
let apis = {};
|
||||
|
||||
async function _getActiveViewportVTKApi(viewports) {
|
||||
const {
|
||||
numRows,
|
||||
numColumns,
|
||||
layout,
|
||||
viewportSpecificData,
|
||||
activeViewportIndex,
|
||||
} = viewports;
|
||||
async function _getActiveViewportVTKApi(viewports) {
|
||||
const {
|
||||
numRows,
|
||||
numColumns,
|
||||
layout,
|
||||
viewportSpecificData,
|
||||
activeViewportIndex,
|
||||
} = viewports;
|
||||
|
||||
const currentData = layout.viewports[activeViewportIndex];
|
||||
if (currentData && currentData.plugin === 'vtk') {
|
||||
// TODO: I was storing/pulling this from Redux but ran into weird issues
|
||||
if (apis[activeViewportIndex]) {
|
||||
return apis[activeViewportIndex];
|
||||
const currentData = layout.viewports[activeViewportIndex];
|
||||
if (currentData && currentData.plugin === 'vtk') {
|
||||
// TODO: I was storing/pulling this from Redux but ran into weird issues
|
||||
if (apis[activeViewportIndex]) {
|
||||
return apis[activeViewportIndex];
|
||||
}
|
||||
}
|
||||
|
||||
const displaySet = viewportSpecificData[activeViewportIndex];
|
||||
|
||||
let api;
|
||||
if (!api) {
|
||||
try {
|
||||
api = await setViewportToVTK(
|
||||
displaySet,
|
||||
activeViewportIndex,
|
||||
numRows,
|
||||
numColumns,
|
||||
layout,
|
||||
viewportSpecificData
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(error);
|
||||
}
|
||||
}
|
||||
|
||||
return api;
|
||||
}
|
||||
|
||||
const displaySet = viewportSpecificData[activeViewportIndex];
|
||||
function _setView(api, sliceNormal, viewUp) {
|
||||
const renderWindow = api.genericRenderWindow.getRenderWindow();
|
||||
const istyle = renderWindow.getInteractor().getInteractorStyle();
|
||||
istyle.setSliceNormal(...sliceNormal);
|
||||
istyle.setViewUp(...viewUp);
|
||||
|
||||
let api;
|
||||
if (!api) {
|
||||
try {
|
||||
api = await setViewportToVTK(
|
||||
displaySet,
|
||||
activeViewportIndex,
|
||||
numRows,
|
||||
numColumns,
|
||||
layout,
|
||||
viewportSpecificData
|
||||
renderWindow.render();
|
||||
}
|
||||
|
||||
function getVOIFromCornerstoneViewport() {
|
||||
const dom = commandsManager.runCommand('getActiveViewportEnabledElement');
|
||||
const cornerstoneElement = cornerstone.getEnabledElement(dom);
|
||||
|
||||
if (cornerstoneElement) {
|
||||
const imageId = cornerstoneElement.image.imageId;
|
||||
|
||||
const { modality } = cornerstone.metaData.get(
|
||||
'generalSeriesModule',
|
||||
imageId
|
||||
);
|
||||
} catch (error) {
|
||||
throw new Error(error);
|
||||
|
||||
if (modality !== 'PT') {
|
||||
const { windowWidth, windowCenter } = cornerstoneElement.viewport.voi;
|
||||
|
||||
return {
|
||||
windowWidth,
|
||||
windowCenter,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return api;
|
||||
}
|
||||
function setVOI(voi) {
|
||||
const { windowWidth, windowCenter } = voi;
|
||||
const lower = windowCenter - windowWidth / 2.0;
|
||||
const upper = windowCenter + windowWidth / 2.0;
|
||||
|
||||
function _setView(api, sliceNormal, viewUp) {
|
||||
const renderWindow = api.genericRenderWindow.getRenderWindow();
|
||||
const istyle = renderWindow.getInteractor().getInteractorStyle();
|
||||
istyle.setSliceNormal(...sliceNormal);
|
||||
istyle.setViewUp(...viewUp);
|
||||
const rgbTransferFunction = apis[0].volumes[0]
|
||||
.getProperty()
|
||||
.getRGBTransferFunction(0);
|
||||
|
||||
renderWindow.render();
|
||||
}
|
||||
rgbTransferFunction.setRange(lower, upper);
|
||||
|
||||
const actions = {
|
||||
axial: async ({ viewports }) => {
|
||||
const api = await _getActiveViewportVTKApi(viewports);
|
||||
|
||||
apis[viewports.activeViewportIndex] = api;
|
||||
|
||||
_setView(api, [0, 0, 1], [0, -1, 0]);
|
||||
},
|
||||
sagittal: async ({ viewports }) => {
|
||||
const api = await _getActiveViewportVTKApi(viewports);
|
||||
|
||||
apis[viewports.activeViewportIndex] = api;
|
||||
|
||||
_setView(api, [1, 0, 0], [0, 0, 1]);
|
||||
},
|
||||
coronal: async ({ viewports }) => {
|
||||
const api = await _getActiveViewportVTKApi(viewports);
|
||||
|
||||
apis[viewports.activeViewportIndex] = api;
|
||||
|
||||
_setView(api, [0, 1, 0], [0, 0, 1]);
|
||||
},
|
||||
enableRotateTool: () => {
|
||||
apis.forEach(api => {
|
||||
const istyle = vtkInteractorStyleMPRRotate.newInstance();
|
||||
|
||||
api.setInteractorStyle({ istyle });
|
||||
api.updateVOI(windowWidth, windowCenter);
|
||||
});
|
||||
},
|
||||
enableCrosshairsTool: () => {
|
||||
apis.forEach((api, apiIndex) => {
|
||||
const istyle = vtkInteractorStyleMPRCrosshairs.newInstance();
|
||||
}
|
||||
|
||||
api.setInteractorStyle({
|
||||
istyle,
|
||||
configuration: { apis, apiIndex },
|
||||
});
|
||||
});
|
||||
},
|
||||
enableLevelTool: () => {
|
||||
function updateVOI(apis, windowWidth, windowCenter) {
|
||||
const actions = {
|
||||
axial: async ({ viewports }) => {
|
||||
const api = await _getActiveViewportVTKApi(viewports);
|
||||
|
||||
apis[viewports.activeViewportIndex] = api;
|
||||
|
||||
_setView(api, [0, 0, 1], [0, -1, 0]);
|
||||
},
|
||||
sagittal: async ({ viewports }) => {
|
||||
const api = await _getActiveViewportVTKApi(viewports);
|
||||
|
||||
apis[viewports.activeViewportIndex] = api;
|
||||
|
||||
_setView(api, [1, 0, 0], [0, 0, 1]);
|
||||
},
|
||||
coronal: async ({ viewports }) => {
|
||||
const api = await _getActiveViewportVTKApi(viewports);
|
||||
|
||||
apis[viewports.activeViewportIndex] = api;
|
||||
|
||||
_setView(api, [0, 1, 0], [0, 0, 1]);
|
||||
},
|
||||
enableRotateTool: () => {
|
||||
apis.forEach(api => {
|
||||
api.updateVOI(windowWidth, windowCenter);
|
||||
const istyle = vtkInteractorStyleMPRRotate.newInstance();
|
||||
|
||||
api.setInteractorStyle({ istyle });
|
||||
});
|
||||
}
|
||||
},
|
||||
enableCrosshairsTool: () => {
|
||||
apis.forEach((api, apiIndex) => {
|
||||
const istyle = vtkInteractorStyleMPRCrosshairs.newInstance();
|
||||
|
||||
const throttledUpdateVOIs = throttle(updateVOI, 16, { trailing: true }); // ~ 60 fps
|
||||
|
||||
const callbacks = {
|
||||
setOnLevelsChanged: ({ windowCenter, windowWidth }) => {
|
||||
apis.forEach(api => {
|
||||
const renderWindow = api.genericRenderWindow.getRenderWindow();
|
||||
|
||||
renderWindow.render();
|
||||
api.setInteractorStyle({
|
||||
istyle,
|
||||
configuration: { apis, apiIndex },
|
||||
});
|
||||
|
||||
throttledUpdateVOIs(apis, windowWidth, windowCenter);
|
||||
},
|
||||
};
|
||||
|
||||
apis.forEach(api => {
|
||||
const istyle = vtkInteractorStyleMPRWindowLevel.newInstance();
|
||||
|
||||
api.setInteractorStyle({ istyle, callbacks });
|
||||
});
|
||||
},
|
||||
setSlabThickness: ({ slabThickness }) => {
|
||||
apis.forEach(api => {
|
||||
api.setSlabThickness(slabThickness);
|
||||
});
|
||||
},
|
||||
changeSlabThickness: ({ change }) => {
|
||||
apis.forEach(api => {
|
||||
const slabThickness = Math.max(api.getSlabThickness() + change, 0.1);
|
||||
|
||||
api.setSlabThickness(slabThickness);
|
||||
});
|
||||
},
|
||||
setBlendModeToComposite: () => {
|
||||
apis.forEach(api => {
|
||||
const renderWindow = api.genericRenderWindow.getRenderWindow();
|
||||
const istyle = renderWindow.getInteractor().getInteractorStyle();
|
||||
|
||||
const slabThickness = api.getSlabThickness();
|
||||
|
||||
const mapper = api.volumes[0].getMapper();
|
||||
if (mapper.setBlendModeToComposite) {
|
||||
mapper.setBlendModeToComposite();
|
||||
}
|
||||
|
||||
if (istyle.setSlabThickness) {
|
||||
istyle.setSlabThickness(slabThickness);
|
||||
}
|
||||
renderWindow.render();
|
||||
});
|
||||
},
|
||||
setBlendModeToMaximumIntensity: () => {
|
||||
apis.forEach(api => {
|
||||
const renderWindow = api.genericRenderWindow.getRenderWindow();
|
||||
const mapper = api.volumes[0].getMapper();
|
||||
if (mapper.setBlendModeToMaximumIntensity) {
|
||||
mapper.setBlendModeToMaximumIntensity();
|
||||
}
|
||||
renderWindow.render();
|
||||
});
|
||||
},
|
||||
setBlendMode: ({ blendMode }) => {
|
||||
apis.forEach(api => {
|
||||
const renderWindow = api.genericRenderWindow.getRenderWindow();
|
||||
|
||||
api.volumes[0].getMapper().setBlendMode(blendMode);
|
||||
|
||||
renderWindow.render();
|
||||
});
|
||||
},
|
||||
mpr2d: async ({ viewports }) => {
|
||||
// TODO push a lot of this backdoor logic lower down to the library level.
|
||||
const displaySet =
|
||||
viewports.viewportSpecificData[viewports.activeViewportIndex];
|
||||
|
||||
// Get current VOI if cornerstone viewport.
|
||||
const cornerstoneVOI = getVOIFromCornerstoneViewport(displaySet);
|
||||
|
||||
const viewportProps = [
|
||||
{
|
||||
//Axial
|
||||
orientation: {
|
||||
sliceNormal: [0, 0, 1],
|
||||
viewUp: [0, -1, 0],
|
||||
},
|
||||
},
|
||||
{
|
||||
// Sagital
|
||||
orientation: {
|
||||
sliceNormal: [1, 0, 0],
|
||||
viewUp: [0, 0, 1],
|
||||
},
|
||||
},
|
||||
{
|
||||
// Coronal
|
||||
orientation: {
|
||||
sliceNormal: [0, 1, 0],
|
||||
viewUp: [0, 0, 1],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
apis = await setMPRLayout(displaySet, viewportProps, 1, 3);
|
||||
} catch (error) {
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
if (cornerstoneVOI) {
|
||||
setVOI(cornerstoneVOI);
|
||||
}
|
||||
|
||||
// Add widgets and set default interactorStyle of each viewport.
|
||||
apis.forEach((api, apiIndex) => {
|
||||
api.addSVGWidget(
|
||||
vtkSVGCrosshairsWidget.newInstance(),
|
||||
'crosshairsWidget'
|
||||
);
|
||||
|
||||
const istyle = vtkInteractorStyleMPRCrosshairs.newInstance();
|
||||
|
||||
api.setInteractorStyle({
|
||||
istyle,
|
||||
configuration: { apis, apiIndex },
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
window.vtkActions = actions;
|
||||
|
||||
const definitions = {
|
||||
axial: {
|
||||
commandFn: actions.axial,
|
||||
storeContexts: ['viewports'],
|
||||
options: {},
|
||||
},
|
||||
coronal: {
|
||||
commandFn: actions.coronal,
|
||||
storeContexts: ['viewports'],
|
||||
options: {},
|
||||
},
|
||||
sagittal: {
|
||||
commandFn: actions.sagittal,
|
||||
storeContexts: ['viewports'],
|
||||
options: {},
|
||||
},
|
||||
enableRotateTool: {
|
||||
commandFn: actions.enableRotateTool,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
enableCrosshairsTool: {
|
||||
commandFn: actions.enableCrosshairsTool,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
enableLevelTool: {
|
||||
commandFn: actions.enableLevelTool,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
setBlendModeToComposite: {
|
||||
commandFn: actions.setBlendModeToComposite,
|
||||
storeContexts: [],
|
||||
options: { blendMode: BlendMode.COMPOSITE_BLEND },
|
||||
},
|
||||
setBlendModeToMaximumIntensity: {
|
||||
commandFn: actions.setBlendModeToMaximumIntensity,
|
||||
storeContexts: [],
|
||||
options: { blendMode: BlendMode.MAXIMUM_INTENSITY_BLEND },
|
||||
},
|
||||
setBlendModeToMinimumIntensity: {
|
||||
commandFn: actions.setBlendMode,
|
||||
storeContexts: [],
|
||||
options: { blendMode: BlendMode.MINIMUM_INTENSITY_BLEND },
|
||||
},
|
||||
setBlendModeToAverageIntensity: {
|
||||
commandFn: actions.setBlendMode,
|
||||
storeContexts: [],
|
||||
options: { blendMode: BlendMode.AVERAGE_INTENSITY_BLEND },
|
||||
},
|
||||
setSlabThickness: {
|
||||
// TODO: How do we pass in a function argument?
|
||||
commandFn: actions.setSlabThickness,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
increaseSlabThickness: {
|
||||
commandFn: actions.changeSlabThickness,
|
||||
storeContexts: [],
|
||||
options: {
|
||||
change: 3,
|
||||
},
|
||||
},
|
||||
decreaseSlabThickness: {
|
||||
commandFn: actions.changeSlabThickness,
|
||||
storeContexts: [],
|
||||
options: {
|
||||
change: -3,
|
||||
},
|
||||
},
|
||||
mpr2d: {
|
||||
commandFn: actions.mpr2d,
|
||||
storeContexts: ['viewports'],
|
||||
options: {},
|
||||
context: 'VIEWER',
|
||||
},
|
||||
};
|
||||
enableLevelTool: () => {
|
||||
function updateVOI(apis, windowWidth, windowCenter) {
|
||||
apis.forEach(api => {
|
||||
api.updateVOI(windowWidth, windowCenter);
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
definitions,
|
||||
defaultContext: 'ACTIVE_VIEWPORT::VTK',
|
||||
};
|
||||
const throttledUpdateVOIs = throttle(updateVOI, 16, { trailing: true }); // ~ 60 fps
|
||||
|
||||
function getVOIFromCornerstoneViewport(displaySet) {
|
||||
const cornerstoneElement = cornerstone.getEnabledElement(displaySet.dom);
|
||||
const callbacks = {
|
||||
setOnLevelsChanged: ({ windowCenter, windowWidth }) => {
|
||||
apis.forEach(api => {
|
||||
const renderWindow = api.genericRenderWindow.getRenderWindow();
|
||||
|
||||
if (cornerstoneElement) {
|
||||
const imageId = cornerstoneElement.image.imageId;
|
||||
renderWindow.render();
|
||||
});
|
||||
|
||||
const { modality } = cornerstone.metaData.get(
|
||||
'generalSeriesModule',
|
||||
imageId
|
||||
);
|
||||
|
||||
if (modality !== 'PT') {
|
||||
const { windowWidth, windowCenter } = cornerstoneElement.viewport.voi;
|
||||
|
||||
return {
|
||||
windowWidth,
|
||||
windowCenter,
|
||||
throttledUpdateVOIs(apis, windowWidth, windowCenter);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setVOI(voi) {
|
||||
const { windowWidth, windowCenter } = voi;
|
||||
const lower = windowCenter - windowWidth / 2.0;
|
||||
const upper = windowCenter + windowWidth / 2.0;
|
||||
apis.forEach(api => {
|
||||
const istyle = vtkInteractorStyleMPRWindowLevel.newInstance();
|
||||
|
||||
const rgbTransferFunction = apis[0].volumes[0]
|
||||
.getProperty()
|
||||
.getRGBTransferFunction(0);
|
||||
api.setInteractorStyle({ istyle, callbacks });
|
||||
});
|
||||
},
|
||||
setSlabThickness: ({ slabThickness }) => {
|
||||
apis.forEach(api => {
|
||||
api.setSlabThickness(slabThickness);
|
||||
});
|
||||
},
|
||||
changeSlabThickness: ({ change }) => {
|
||||
apis.forEach(api => {
|
||||
const slabThickness = Math.max(api.getSlabThickness() + change, 0.1);
|
||||
|
||||
rgbTransferFunction.setRange(lower, upper);
|
||||
api.setSlabThickness(slabThickness);
|
||||
});
|
||||
},
|
||||
setBlendModeToComposite: () => {
|
||||
apis.forEach(api => {
|
||||
const renderWindow = api.genericRenderWindow.getRenderWindow();
|
||||
const istyle = renderWindow.getInteractor().getInteractorStyle();
|
||||
|
||||
apis.forEach(api => {
|
||||
api.updateVOI(windowWidth, windowCenter);
|
||||
});
|
||||
}
|
||||
const slabThickness = api.getSlabThickness();
|
||||
|
||||
const mapper = api.volumes[0].getMapper();
|
||||
if (mapper.setBlendModeToComposite) {
|
||||
mapper.setBlendModeToComposite();
|
||||
}
|
||||
|
||||
if (istyle.setSlabThickness) {
|
||||
istyle.setSlabThickness(slabThickness);
|
||||
}
|
||||
renderWindow.render();
|
||||
});
|
||||
},
|
||||
setBlendModeToMaximumIntensity: () => {
|
||||
apis.forEach(api => {
|
||||
const renderWindow = api.genericRenderWindow.getRenderWindow();
|
||||
const mapper = api.volumes[0].getMapper();
|
||||
if (mapper.setBlendModeToMaximumIntensity) {
|
||||
mapper.setBlendModeToMaximumIntensity();
|
||||
}
|
||||
renderWindow.render();
|
||||
});
|
||||
},
|
||||
setBlendMode: ({ blendMode }) => {
|
||||
apis.forEach(api => {
|
||||
const renderWindow = api.genericRenderWindow.getRenderWindow();
|
||||
|
||||
api.volumes[0].getMapper().setBlendMode(blendMode);
|
||||
|
||||
renderWindow.render();
|
||||
});
|
||||
},
|
||||
mpr2d: async ({ viewports }) => {
|
||||
// TODO push a lot of this backdoor logic lower down to the library level.
|
||||
const displaySet =
|
||||
viewports.viewportSpecificData[viewports.activeViewportIndex];
|
||||
|
||||
// Get current VOI if cornerstone viewport.
|
||||
const cornerstoneVOI = getVOIFromCornerstoneViewport();
|
||||
|
||||
const viewportProps = [
|
||||
{
|
||||
//Axial
|
||||
orientation: {
|
||||
sliceNormal: [0, 0, 1],
|
||||
viewUp: [0, -1, 0],
|
||||
},
|
||||
},
|
||||
{
|
||||
// Sagital
|
||||
orientation: {
|
||||
sliceNormal: [1, 0, 0],
|
||||
viewUp: [0, 0, 1],
|
||||
},
|
||||
},
|
||||
{
|
||||
// Coronal
|
||||
orientation: {
|
||||
sliceNormal: [0, 1, 0],
|
||||
viewUp: [0, 0, 1],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
apis = await setMPRLayout(displaySet, viewportProps, 1, 3);
|
||||
} catch (error) {
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
if (cornerstoneVOI) {
|
||||
setVOI(cornerstoneVOI);
|
||||
}
|
||||
|
||||
// Add widgets and set default interactorStyle of each viewport.
|
||||
apis.forEach((api, apiIndex) => {
|
||||
api.addSVGWidget(
|
||||
vtkSVGCrosshairsWidget.newInstance(),
|
||||
'crosshairsWidget'
|
||||
);
|
||||
|
||||
const istyle = vtkInteractorStyleMPRCrosshairs.newInstance();
|
||||
|
||||
api.setInteractorStyle({
|
||||
istyle,
|
||||
configuration: { apis, apiIndex },
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
window.vtkActions = actions;
|
||||
|
||||
const definitions = {
|
||||
axial: {
|
||||
commandFn: actions.axial,
|
||||
storeContexts: ['viewports'],
|
||||
options: {},
|
||||
},
|
||||
coronal: {
|
||||
commandFn: actions.coronal,
|
||||
storeContexts: ['viewports'],
|
||||
options: {},
|
||||
},
|
||||
sagittal: {
|
||||
commandFn: actions.sagittal,
|
||||
storeContexts: ['viewports'],
|
||||
options: {},
|
||||
},
|
||||
enableRotateTool: {
|
||||
commandFn: actions.enableRotateTool,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
enableCrosshairsTool: {
|
||||
commandFn: actions.enableCrosshairsTool,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
enableLevelTool: {
|
||||
commandFn: actions.enableLevelTool,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
setBlendModeToComposite: {
|
||||
commandFn: actions.setBlendModeToComposite,
|
||||
storeContexts: [],
|
||||
options: { blendMode: BlendMode.COMPOSITE_BLEND },
|
||||
},
|
||||
setBlendModeToMaximumIntensity: {
|
||||
commandFn: actions.setBlendModeToMaximumIntensity,
|
||||
storeContexts: [],
|
||||
options: { blendMode: BlendMode.MAXIMUM_INTENSITY_BLEND },
|
||||
},
|
||||
setBlendModeToMinimumIntensity: {
|
||||
commandFn: actions.setBlendMode,
|
||||
storeContexts: [],
|
||||
options: { blendMode: BlendMode.MINIMUM_INTENSITY_BLEND },
|
||||
},
|
||||
setBlendModeToAverageIntensity: {
|
||||
commandFn: actions.setBlendMode,
|
||||
storeContexts: [],
|
||||
options: { blendMode: BlendMode.AVERAGE_INTENSITY_BLEND },
|
||||
},
|
||||
setSlabThickness: {
|
||||
// TODO: How do we pass in a function argument?
|
||||
commandFn: actions.setSlabThickness,
|
||||
storeContexts: [],
|
||||
options: {},
|
||||
},
|
||||
increaseSlabThickness: {
|
||||
commandFn: actions.changeSlabThickness,
|
||||
storeContexts: [],
|
||||
options: {
|
||||
change: 3,
|
||||
},
|
||||
},
|
||||
decreaseSlabThickness: {
|
||||
commandFn: actions.changeSlabThickness,
|
||||
storeContexts: [],
|
||||
options: {
|
||||
change: -3,
|
||||
},
|
||||
},
|
||||
mpr2d: {
|
||||
commandFn: actions.mpr2d,
|
||||
storeContexts: ['viewports'],
|
||||
options: {},
|
||||
context: 'VIEWER',
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
definitions,
|
||||
defaultContext: 'ACTIVE_VIEWPORT::VTK',
|
||||
};
|
||||
};
|
||||
|
||||
export default commandsModule;
|
||||
@@ -20,8 +20,8 @@ const vtkExtension = {
|
||||
getToolbarModule() {
|
||||
return toolbarModule;
|
||||
},
|
||||
getCommandsModule() {
|
||||
return commandsModule;
|
||||
getCommandsModule({ commandsManager }) {
|
||||
return commandsModule({ commandsManager });
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -8,15 +8,6 @@
|
||||
# managed by CircleCI and deployed to our Google Hosting
|
||||
#
|
||||
|
||||
# Settings in the [build] context are global and are applied to all contexts
|
||||
# unless otherwise overridden by more specific contexts.
|
||||
[build]
|
||||
# Directory to change to before starting a build.
|
||||
# This is where we will look for package.json/.nvmrc/etc.
|
||||
base = ""
|
||||
publish = "project/build-output/"
|
||||
|
||||
|
||||
# NODE_VERSION in root `.nvmrc` takes priority
|
||||
# YARN_FLAGS: https://www.netlify.com/docs/build-gotchas/#yarn
|
||||
[build.environment]
|
||||
@@ -28,10 +19,8 @@
|
||||
|
||||
# Production context: all deploys from the Production branch set in your site's
|
||||
# deploy contexts will inherit these settings.
|
||||
# [context.production]
|
||||
# publish = "project/output/"
|
||||
# command = "make publish"
|
||||
# environment = { ACCESS_TOKEN = "super secret", NODE_VERSION = "8.0.1" }
|
||||
[context.production]
|
||||
ignore = "exit 0" # Never build production; We'll let our CI do that
|
||||
|
||||
# Deploy Preview context: all deploys generated from a pull/merge request will
|
||||
# inherit these settings.
|
||||
@@ -39,7 +28,6 @@
|
||||
base = ""
|
||||
publish = ".netlify/www/"
|
||||
command = "chmod +x .netlify/build-deploy-preview.sh && .netlify/build-deploy-preview.sh"
|
||||
# command = "yarn config set workspaces-experimental true && chmod +x .netlify/build-deploy-preview.sh && .netlify/build-deploy-preview.sh"
|
||||
|
||||
[[headers]]
|
||||
# Define which paths this specific [[headers]] block will cover.
|
||||
|
||||
@@ -3,6 +3,151 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.13.3](https://github.com/OHIF/Viewers/compare/@ohif/core@1.13.2...@ohif/core@1.13.3) (2019-12-06)
|
||||
|
||||
**Note:** Version bump only for package @ohif/core
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.13.2](https://github.com/OHIF/Viewers/compare/@ohif/core@1.13.1...@ohif/core@1.13.2) (2019-12-02)
|
||||
|
||||
**Note:** Version bump only for package @ohif/core
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.13.1](https://github.com/OHIF/Viewers/compare/@ohif/core@1.13.0...@ohif/core@1.13.1) (2019-11-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* User Preferences Issues ([#1207](https://github.com/OHIF/Viewers/issues/1207)) ([1df21a9](https://github.com/OHIF/Viewers/commit/1df21a9e075b5e6dfc10a429ae825826f46c71b8)), closes [#1161](https://github.com/OHIF/Viewers/issues/1161) [#1164](https://github.com/OHIF/Viewers/issues/1164) [#1177](https://github.com/OHIF/Viewers/issues/1177) [#1179](https://github.com/OHIF/Viewers/issues/1179) [#1180](https://github.com/OHIF/Viewers/issues/1180) [#1181](https://github.com/OHIF/Viewers/issues/1181) [#1182](https://github.com/OHIF/Viewers/issues/1182) [#1183](https://github.com/OHIF/Viewers/issues/1183) [#1184](https://github.com/OHIF/Viewers/issues/1184) [#1185](https://github.com/OHIF/Viewers/issues/1185)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [1.13.0](https://github.com/OHIF/Viewers/compare/@ohif/core@1.12.0...@ohif/core@1.13.0) (2019-11-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add new annotate tool using new dialog service ([#1211](https://github.com/OHIF/Viewers/issues/1211)) ([8fd3af1](https://github.com/OHIF/Viewers/commit/8fd3af1e137e793f1b482760a22591c64a072047))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [1.12.0](https://github.com/OHIF/Viewers/compare/@ohif/core@1.11.0...@ohif/core@1.12.0) (2019-11-19)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* New dialog service ([#1202](https://github.com/OHIF/Viewers/issues/1202)) ([f65639c](https://github.com/OHIF/Viewers/commit/f65639c2b0dab01decd20cab2cef4263cb4fab37))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [1.11.0](https://github.com/OHIF/Viewers/compare/@ohif/core@1.10.0...@ohif/core@1.11.0) (2019-11-19)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Issue 879 viewer route query param not filtering but promoting ([#1141](https://github.com/OHIF/Viewers/issues/1141)) ([b17f753](https://github.com/OHIF/Viewers/commit/b17f753e6222045252ef885e40233681541a32e1)), closes [#1118](https://github.com/OHIF/Viewers/issues/1118)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [1.10.0](https://github.com/OHIF/Viewers/compare/@ohif/core@1.9.1...@ohif/core@1.10.0) (2019-11-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Inject into Extension Modules / improve tests ([f63d8a7](https://github.com/OHIF/Viewers/commit/f63d8a73d867ad9dfd8ee0cad74edce180eb34f0))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.9.1](https://github.com/OHIF/Viewers/compare/@ohif/core@1.9.0...@ohif/core@1.9.1) (2019-11-15)
|
||||
|
||||
**Note:** Version bump only for package @ohif/core
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [1.9.0](https://github.com/OHIF/Viewers/compare/@ohif/core@1.8.0...@ohif/core@1.9.0) (2019-11-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* expose UiNotifications service ([#1172](https://github.com/OHIF/Viewers/issues/1172)) ([5c04e34](https://github.com/OHIF/Viewers/commit/5c04e34c8fb2394ab7acd9eb4f2ab12afeb2f255))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [1.8.0](https://github.com/OHIF/Viewers/compare/@ohif/core@1.7.1...@ohif/core@1.8.0) (2019-11-12)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* 🎸 Update hotkeys and user preferences modal ([#1135](https://github.com/OHIF/Viewers/issues/1135)) ([e62f5f8](https://github.com/OHIF/Viewers/commit/e62f5f8dd28ab363f23671cd21cee115abb870ff)), closes [#923](https://github.com/OHIF/Viewers/issues/923)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.7.1](https://github.com/OHIF/Viewers/compare/@ohif/core@1.7.0...@ohif/core@1.7.1) (2019-11-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add a fallback metadata provider which pulls metadata from WADO-… ([#1158](https://github.com/OHIF/Viewers/issues/1158)) ([31b1adf](https://github.com/OHIF/Viewers/commit/31b1adfa5993d6c8e3e9c8b03fa9856f2621b037))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [1.7.0](https://github.com/OHIF/Viewers/compare/@ohif/core@1.6.2...@ohif/core@1.7.0) (2019-11-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* 🎸 Filter by url query param for seriesInstnaceUID ([#1117](https://github.com/OHIF/Viewers/issues/1117)) ([e208f2e](https://github.com/OHIF/Viewers/commit/e208f2e6a9c49b16dadead0a917f657cf023929a)), closes [#1118](https://github.com/OHIF/Viewers/issues/1118)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.6.2](https://github.com/OHIF/Viewers/compare/@ohif/core@1.6.1...@ohif/core@1.6.2) (2019-11-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#1075](https://github.com/OHIF/Viewers/issues/1075) Returning to the Study List before all series have finishe… ([#1090](https://github.com/OHIF/Viewers/issues/1090)) ([ecaf578](https://github.com/OHIF/Viewers/commit/ecaf578f92dc40294cec7ff9b272fb432dec4125))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [1.6.1](https://github.com/OHIF/Viewers/compare/@ohif/core@1.6.0...@ohif/core@1.6.1) (2019-10-31)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* application crash if patientName is an object ([#1138](https://github.com/OHIF/Viewers/issues/1138)) ([64cf3b3](https://github.com/OHIF/Viewers/commit/64cf3b324da2383a927af1df2d46db2fca5318aa))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# [1.6.0](https://github.com/OHIF/Viewers/compare/@ohif/core@1.5.2...@ohif/core@1.6.0) (2019-10-26)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/core",
|
||||
"version": "1.6.0",
|
||||
"version": "1.13.3",
|
||||
"description": "Generic business logic for web-based medical imaging applications",
|
||||
"author": "OHIF Core Team",
|
||||
"license": "MIT",
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"cornerstone-core": "^2.2.8",
|
||||
"cornerstone-tools": "^4.0.9",
|
||||
"cornerstone-tools": "^4.8.0",
|
||||
"cornerstone-wado-image-loader": "^3.0.0",
|
||||
"dicom-parser": "^1.8.3"
|
||||
},
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
export default function getModalities(modality, modalitiesInStudy) {
|
||||
let modalities = {};
|
||||
if (modality) {
|
||||
modalities = modality;
|
||||
if (!modality && !modalitiesInStudy) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const modalities = modality || {
|
||||
vr: 'CS',
|
||||
Value: [],
|
||||
};
|
||||
|
||||
if (modalitiesInStudy) {
|
||||
// Find vr in modalities
|
||||
if (modalities.vr && modalities.vr === modalitiesInStudy.vr) {
|
||||
for (let i = 0; i < modalitiesInStudy.Value.length; i++) {
|
||||
const value = modalitiesInStudy.Value[i];
|
||||
@@ -14,8 +17,9 @@ export default function getModalities(modality, modalitiesInStudy) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
modalities = modalitiesInStudy;
|
||||
return modalitiesInStudy;
|
||||
}
|
||||
}
|
||||
|
||||
return modalities;
|
||||
}
|
||||
@@ -43,19 +43,68 @@ export class HotkeysManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a list of hotkeydefinitions. Optionally, sets the
|
||||
* default hotkey bindings for all provided definitions. These
|
||||
* Registers a list of hotkeydefinitions.
|
||||
*
|
||||
* @param {HotkeyDefinition[] | Object} hotkeyDefinitions Contains hotkeys definitions
|
||||
*/
|
||||
setHotkeys(hotkeyDefinitions) {
|
||||
const definitions = Array.isArray(hotkeyDefinitions)
|
||||
? [...hotkeyDefinitions]
|
||||
: this._parseToArrayLike(hotkeyDefinitions);
|
||||
|
||||
definitions.forEach(definition => this.registerHotkeys(definition));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set default hotkey bindings. These
|
||||
* values are used in `this.restoreDefaultBindings`.
|
||||
*
|
||||
* @param {HotkeyDefinition[]} hotkeyDefinitions
|
||||
* @param {Boolean} [isDefaultDefinitions]
|
||||
* @param {HotkeyDefinition[] | Object} hotkeyDefinitions Contains hotkeys definitions
|
||||
*/
|
||||
setHotkeys(hotkeyDefinitions, isDefaultDefinitions = false) {
|
||||
hotkeyDefinitions.forEach(definition => this.registerHotkeys(definition));
|
||||
setDefaultHotKeys(hotkeyDefinitions) {
|
||||
const definitions = Array.isArray(hotkeyDefinitions)
|
||||
? [...hotkeyDefinitions]
|
||||
: this._parseToArrayLike(hotkeyDefinitions);
|
||||
|
||||
if (isDefaultDefinitions) {
|
||||
this.hotkeyDefaults = hotkeyDefinitions;
|
||||
}
|
||||
this.hotkeyDefaults = definitions;
|
||||
}
|
||||
|
||||
/**
|
||||
* It parses given object containing hotkeyDefinition to array like.
|
||||
* Each property of given object will be mapped to an object of an array. And its property name will be the value of a property named as commandName
|
||||
*
|
||||
* @param {HotkeyDefinition[] | Object} hotkeyDefinitions Contains hotkeys definitions
|
||||
* @returns {HotkeyDefinition[]}
|
||||
*/
|
||||
_parseToArrayLike(hotkeyDefinitionsObj) {
|
||||
const copy = { ...hotkeyDefinitionsObj };
|
||||
return Object.entries(copy).map(entryValue =>
|
||||
this._parseToHotKeyObj(entryValue[0], entryValue[1])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return HotkeyDefinition object like based on given property name and property value
|
||||
* @param {string} propertyName property name of hotkey definition object
|
||||
* @param {object} propertyValue property value of hotkey definition object
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* const hotKeyObj = {hotKeyDefA: {keys:[],....}}
|
||||
*
|
||||
* const parsed = _parseToHotKeyObj(Object.keys(hotKeyDefA)[0], hotKeyObj[hotKeyDefA]);
|
||||
* {
|
||||
* commandName: hotKeyDefA,
|
||||
* keys: [],
|
||||
* ....
|
||||
* }
|
||||
*
|
||||
*/
|
||||
_parseToHotKeyObj(propertyName, propertyValue) {
|
||||
return {
|
||||
commandName: propertyName,
|
||||
...propertyValue,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -94,18 +94,20 @@ describe('HotkeysManager', () => {
|
||||
expect(firstCallArgs).toEqual(hotkeyDefinitions[0]);
|
||||
expect(secondCallArgs).toEqual(hotkeyDefinitions[1]);
|
||||
});
|
||||
it('does not set this.hotkeyDefaults by default', () => {
|
||||
it('does not set this.hotkeyDefaults when calling setHotKeys', () => {
|
||||
const hotkeyDefinitions = [{ commandName: 'dance', keys: '+' }];
|
||||
|
||||
hotkeysManager.setHotkeys(hotkeyDefinitions);
|
||||
|
||||
expect(hotkeysManager.hotkeyDefaults).toEqual([]);
|
||||
});
|
||||
it('sets this.hotkeyDefaults when isDefaultDefinitions is true', () => {
|
||||
const hotkeyDefinitions = [{ commandName: 'dance', keys: '+' }];
|
||||
const isDefaultDefinitions = true;
|
||||
});
|
||||
|
||||
hotkeysManager.setHotkeys(hotkeyDefinitions, isDefaultDefinitions);
|
||||
describe('setDefaultHotKeys()', () => {
|
||||
it('it sets default hotkeys', () => {
|
||||
const hotkeyDefinitions = [{ commandName: 'dance', keys: '+' }];
|
||||
|
||||
hotkeysManager.setDefaultHotKeys(hotkeyDefinitions);
|
||||
|
||||
expect(hotkeysManager.hotkeyDefaults).toEqual(hotkeyDefinitions);
|
||||
});
|
||||
|
||||
@@ -2,12 +2,13 @@ import MODULE_TYPES from './MODULE_TYPES.js';
|
||||
import log from './../log.js';
|
||||
|
||||
export default class ExtensionManager {
|
||||
constructor({ commandsManager }) {
|
||||
constructor({ commandsManager, servicesManager }) {
|
||||
this.modules = {};
|
||||
this.registeredExtensionIds = [];
|
||||
this.moduleTypeNames = Object.values(MODULE_TYPES);
|
||||
//
|
||||
this._commandsManager = commandsManager;
|
||||
this._servicesManager = servicesManager;
|
||||
|
||||
this.moduleTypeNames.forEach(moduleType => {
|
||||
this.modules[moduleType] = [];
|
||||
@@ -66,7 +67,11 @@ export default class ExtensionManager {
|
||||
|
||||
// preRegistrationHook
|
||||
if (extension.preRegistration) {
|
||||
extension.preRegistration(configuration);
|
||||
extension.preRegistration({
|
||||
servicesManager: this._servicesManager,
|
||||
commandsManager: this._commandsManager,
|
||||
configuration,
|
||||
});
|
||||
}
|
||||
|
||||
// Register Modules
|
||||
@@ -106,7 +111,10 @@ export default class ExtensionManager {
|
||||
}
|
||||
|
||||
try {
|
||||
const extensionModule = getModuleFn();
|
||||
const extensionModule = getModuleFn({
|
||||
servicesManager: this._servicesManager,
|
||||
commandsManager: this._commandsManager,
|
||||
});
|
||||
|
||||
if (!extensionModule) {
|
||||
log.warn(
|
||||
|
||||
@@ -40,6 +40,31 @@ describe('ExtensionManager.js', () => {
|
||||
});
|
||||
|
||||
describe('registerExtension()', () => {
|
||||
it('calls preRegistration() for extension', () => {
|
||||
// SUT
|
||||
const fakeExtension = { one: '1', preRegistration: jest.fn() };
|
||||
extensionManager.registerExtension(fakeExtension);
|
||||
|
||||
// Assert
|
||||
expect(fakeExtension.preRegistration.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('calls preRegistration() passing configuration along with servicesManager and commandsManager instances for extension', () => {
|
||||
const configuration = { config: 'Some configuration' };
|
||||
extensionManager._servicesManager = { services: { TestService: {} } };
|
||||
|
||||
// SUT
|
||||
const fakeExtension = { one: '1', preRegistration: jest.fn() };
|
||||
extensionManager.registerExtension(fakeExtension, configuration);
|
||||
|
||||
// Assert
|
||||
expect(fakeExtension.preRegistration.mock.calls[0][0]).toEqual({
|
||||
servicesManager: extensionManager._servicesManager,
|
||||
commandsManager: extensionManager._commandsManager,
|
||||
configuration,
|
||||
});
|
||||
});
|
||||
|
||||
it('logs a warning if the extension is null or undefined', () => {
|
||||
const undefinedExtension = undefined;
|
||||
const nullExtension = null;
|
||||
@@ -110,6 +135,26 @@ describe('ExtensionManager.js', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('successfully passes a servicesManager and commandsManager instances to each module', () => {
|
||||
extensionManager._servicesManager = { services: { TestService: {} } };
|
||||
|
||||
const extension = {
|
||||
id: 'hello-world',
|
||||
getViewportModule: jest.fn(),
|
||||
getSopClassHandlerModule: jest.fn(),
|
||||
getPanelModule: jest.fn(),
|
||||
getToolbarModule: jest.fn(),
|
||||
getCommandsModule: jest.fn(),
|
||||
};
|
||||
|
||||
extensionManager.registerExtension(extension);
|
||||
|
||||
expect(extension.getViewportModule.mock.calls[0][0]).toEqual({
|
||||
servicesManager: extensionManager._servicesManager,
|
||||
commandsManager: extensionManager._commandsManager,
|
||||
});
|
||||
});
|
||||
|
||||
it('successfully registers a module for each module type', () => {
|
||||
const extension = {
|
||||
id: 'hello-world',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import './lib';
|
||||
|
||||
import { ExtensionManager, MODULE_TYPES } from './extensions';
|
||||
import { ServicesManager } from './services';
|
||||
import classes, { CommandsManager, HotkeysManager } from './classes/';
|
||||
|
||||
import DICOMWeb from './DICOMWeb';
|
||||
@@ -18,12 +19,19 @@ import ui from './ui';
|
||||
import user from './user.js';
|
||||
import utils from './utils/';
|
||||
|
||||
import {
|
||||
createUINotificationService,
|
||||
createUIModalService,
|
||||
createUIDialogService,
|
||||
} from './services';
|
||||
|
||||
const OHIF = {
|
||||
MODULE_TYPES,
|
||||
//
|
||||
CommandsManager,
|
||||
ExtensionManager,
|
||||
HotkeysManager,
|
||||
ServicesManager,
|
||||
//
|
||||
utils,
|
||||
studies,
|
||||
@@ -41,6 +49,10 @@ const OHIF = {
|
||||
viewer: {},
|
||||
measurements,
|
||||
hangingProtocols,
|
||||
//
|
||||
createUINotificationService,
|
||||
createUIModalService,
|
||||
createUIDialogService,
|
||||
};
|
||||
|
||||
export {
|
||||
@@ -49,6 +61,7 @@ export {
|
||||
CommandsManager,
|
||||
ExtensionManager,
|
||||
HotkeysManager,
|
||||
ServicesManager,
|
||||
//
|
||||
utils,
|
||||
studies,
|
||||
@@ -65,6 +78,10 @@ export {
|
||||
DICOMWeb,
|
||||
measurements,
|
||||
hangingProtocols,
|
||||
//
|
||||
createUINotificationService,
|
||||
createUIModalService,
|
||||
createUIDialogService,
|
||||
};
|
||||
|
||||
export { OHIF };
|
||||
|
||||
@@ -8,6 +8,11 @@ describe('Top level exports', () => {
|
||||
'CommandsManager',
|
||||
'ExtensionManager',
|
||||
'HotkeysManager',
|
||||
'ServicesManager',
|
||||
//
|
||||
'createUINotificationService',
|
||||
'createUIModalService',
|
||||
'createUIDialogService',
|
||||
//
|
||||
'utils',
|
||||
'studies',
|
||||
|
||||
@@ -12,13 +12,25 @@ import {
|
||||
SET_VIEWPORT_LAYOUT_AND_DATA,
|
||||
} from './constants/ActionTypes.js';
|
||||
|
||||
/**
|
||||
* The definition of a viewport layout.
|
||||
*
|
||||
* @typedef {Object} ViewportLayout
|
||||
* @property {number} numRows -
|
||||
* @property {number} numColumns -
|
||||
* @property {array} viewports -
|
||||
*/
|
||||
|
||||
/**
|
||||
* VIEWPORT
|
||||
*/
|
||||
export const setViewportSpecificData = (viewportIndex, data) => ({
|
||||
export const setViewportSpecificData = (
|
||||
viewportIndex,
|
||||
viewportSpecificData
|
||||
) => ({
|
||||
type: SET_VIEWPORT,
|
||||
viewportIndex,
|
||||
data,
|
||||
viewportSpecificData,
|
||||
});
|
||||
|
||||
export const setViewportActive = viewportIndex => ({
|
||||
@@ -27,10 +39,7 @@ export const setViewportActive = viewportIndex => ({
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {object} layout
|
||||
* @param {number} layout.numRows
|
||||
* @param {number} layout.numColumns
|
||||
* @param {array} layout.viewports
|
||||
* @param {ViewportLayout} layout
|
||||
*/
|
||||
export const setLayout = ({ numRows, numColumns, viewports }) => ({
|
||||
type: SET_VIEWPORT_LAYOUT,
|
||||
@@ -40,10 +49,9 @@ export const setLayout = ({ numRows, numColumns, viewports }) => ({
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {object} layout
|
||||
* @param {number} layout.numRows
|
||||
* @param {number} layout.numColumns
|
||||
* @param {array} layout.viewports
|
||||
* @param {array} viewports
|
||||
*/
|
||||
export const setViewportLayoutAndData = (
|
||||
{ numRows, numColumns, viewports },
|
||||
@@ -61,9 +69,9 @@ export const clearViewportSpecificData = viewportIndex => ({
|
||||
viewportIndex,
|
||||
});
|
||||
|
||||
export const setActiveViewportSpecificData = data => ({
|
||||
export const setActiveViewportSpecificData = viewportSpecificData => ({
|
||||
type: SET_ACTIVE_SPECIFIC_DATA,
|
||||
data,
|
||||
viewportSpecificData,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -113,13 +121,18 @@ export const setServers = servers => ({
|
||||
});
|
||||
|
||||
const actions = {
|
||||
// VIEWPORT
|
||||
/**
|
||||
* VIEWPORT
|
||||
*/
|
||||
setViewportActive,
|
||||
setViewportSpecificData,
|
||||
setViewportLayoutAndData,
|
||||
setLayout,
|
||||
clearViewportSpecificData,
|
||||
setActiveViewportSpecificData,
|
||||
/**
|
||||
* NOT-VIEWPORT
|
||||
*/
|
||||
setStudyLoadingProgress,
|
||||
clearStudyLoadingProgress,
|
||||
setUserPreferences,
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('actions', () => {
|
||||
|
||||
describe('viewport action creators', () => {
|
||||
it('should create an action to set the viewport specific data', () => {
|
||||
const data = {
|
||||
const viewportSpecificData = {
|
||||
displaySetInstanceUid: 'ef859a23-4631-93ab-d26b-7940a822c699',
|
||||
seriesDate: '20151026',
|
||||
seriesTime: '082611.370000',
|
||||
@@ -65,12 +65,12 @@ describe('actions', () => {
|
||||
|
||||
const expectedAction = {
|
||||
type: types.SET_ACTIVE_SPECIFIC_DATA,
|
||||
data,
|
||||
viewportSpecificData,
|
||||
};
|
||||
|
||||
expect(actions.setActiveViewportSpecificData(data)).toEqual(
|
||||
expectedAction
|
||||
);
|
||||
expect(
|
||||
actions.setActiveViewportSpecificData(viewportSpecificData)
|
||||
).toEqual(expectedAction);
|
||||
});
|
||||
|
||||
it('should create an action to clear clearViewportSpecificData', () => {
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import cloneDeep from 'lodash.clonedeep';
|
||||
|
||||
const defaultState = {
|
||||
// Top level key
|
||||
viewer: {
|
||||
// First tab
|
||||
hotKeysData: {
|
||||
// hotkeyName, label, keys, column
|
||||
// zoom: { label: 'Zoom', command: 'Z', column: 0 },
|
||||
},
|
||||
// Second tab
|
||||
windowLevelData: {
|
||||
// order, description, window (int), level (int)
|
||||
// 0: { description: 'Soft tissue', window: '', level: '' },
|
||||
},
|
||||
// First tab
|
||||
hotkeyDefinitions: [
|
||||
// commandName, label, keys
|
||||
// [{ zoom: { label: 'Zoom', keys: ['z'] }}]
|
||||
],
|
||||
// Second tab
|
||||
windowLevelData: {
|
||||
// order, description, window (int), level (int)
|
||||
// 0: { description: 'Soft tissue', window: '', level: '' },
|
||||
},
|
||||
generalPreferences: {
|
||||
// language: 'en-US'
|
||||
},
|
||||
};
|
||||
|
||||
const preferences = (state, action) => {
|
||||
switch (action.type) {
|
||||
case 'SET_USER_PREFERENCES': {
|
||||
const newState = action.state ? action.state : cloneDeep(defaultState);
|
||||
const newState = action.state || cloneDeep(defaultState);
|
||||
|
||||
return Object.assign({}, state, newState);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import cloneDeep from 'lodash.clonedeep';
|
||||
import merge from 'lodash.merge';
|
||||
|
||||
import {
|
||||
CLEAR_VIEWPORT,
|
||||
SET_ACTIVE_SPECIFIC_DATA,
|
||||
@@ -8,10 +11,7 @@ import {
|
||||
SET_VIEWPORT_LAYOUT_AND_DATA,
|
||||
} from './../constants/ActionTypes.js';
|
||||
|
||||
import cloneDeep from 'lodash.clonedeep';
|
||||
import merge from 'lodash.merge';
|
||||
|
||||
const defaultState = {
|
||||
const DEFAULT_STATE = {
|
||||
numRows: 1,
|
||||
numColumns: 1,
|
||||
activeViewportIndex: 0,
|
||||
@@ -26,94 +26,137 @@ const defaultState = {
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {Object} [state=defaultState]
|
||||
* @param {Object} action
|
||||
* @param {string} [action.type]
|
||||
* @param {number} [action.viewportIndex]
|
||||
* @param {Object} [action.layout]
|
||||
* @param {Object} [action.viewportSpecificData]
|
||||
* The definition of a viewport action.
|
||||
*
|
||||
* @typedef {Object} ViewportAction
|
||||
* @property {string} type -
|
||||
* @property {Object} data -
|
||||
* @property {Object} layout -
|
||||
* @property {number} viewportIndex -
|
||||
* @property {Object} viewportSpecificData -
|
||||
*/
|
||||
const viewports = (state = defaultState, action) => {
|
||||
let viewportSpecificData;
|
||||
|
||||
/**
|
||||
* @param {Object} [state=DEFAULT_STATE] The current viewport state.
|
||||
* @param {ViewportAction} action A viewport action.
|
||||
*/
|
||||
const viewports = (state = DEFAULT_STATE, action) => {
|
||||
let useActiveViewport = false;
|
||||
|
||||
switch (action.type) {
|
||||
case SET_VIEWPORT_ACTIVE:
|
||||
return Object.assign({}, state, {
|
||||
activeViewportIndex: action.viewportIndex,
|
||||
});
|
||||
/**
|
||||
* Sets the active viewport index.
|
||||
*
|
||||
* @return {Object} New state.
|
||||
*/
|
||||
case SET_VIEWPORT_ACTIVE: {
|
||||
return { ...state, activeViewportIndex: action.viewportIndex };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets viewport layout.
|
||||
*
|
||||
* @return {Object} New state.
|
||||
*/
|
||||
case SET_VIEWPORT_LAYOUT: {
|
||||
const { numRows, numColumns, viewports } = action;
|
||||
const layout = {
|
||||
viewports: [...viewports],
|
||||
return {
|
||||
...state,
|
||||
numRows: action.numRows,
|
||||
numColumns: action.numColumns,
|
||||
layout: { viewports: [...action.viewports] },
|
||||
};
|
||||
|
||||
return Object.assign({}, state, { numRows, numColumns, layout });
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets viewport layout and data.
|
||||
*
|
||||
* @return {Object} New state.
|
||||
*/
|
||||
case SET_VIEWPORT_LAYOUT_AND_DATA: {
|
||||
const { numRows, numColumns, viewports, viewportSpecificData } = action;
|
||||
const layout = {
|
||||
viewports: [...viewports],
|
||||
return {
|
||||
...state,
|
||||
numRows: action.numRows,
|
||||
numColumns: action.numColumns,
|
||||
layout: { viewports: [...action.viewports] },
|
||||
viewportSpecificData: cloneDeep(action.viewportSpecificData),
|
||||
};
|
||||
|
||||
return Object.assign({}, state, {
|
||||
numRows,
|
||||
numColumns,
|
||||
layout,
|
||||
viewportSpecificData: cloneDeep(viewportSpecificData),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets viewport specific data of active viewport.
|
||||
*
|
||||
* @return {Object} New state.
|
||||
*/
|
||||
case SET_VIEWPORT: {
|
||||
const layout = cloneDeep(state.layout);
|
||||
const hasPlugin = action.data && action.data.plugin;
|
||||
|
||||
viewportSpecificData = cloneDeep(state.viewportSpecificData);
|
||||
let viewportSpecificData = cloneDeep(state.viewportSpecificData);
|
||||
viewportSpecificData[action.viewportIndex] = merge(
|
||||
{},
|
||||
viewportSpecificData[action.viewportIndex],
|
||||
action.data
|
||||
action.viewportSpecificData
|
||||
);
|
||||
|
||||
if (hasPlugin) {
|
||||
layout.viewports[action.viewportIndex].plugin = action.data.plugin;
|
||||
if (action.viewportSpecificData && action.viewportSpecificData.plugin) {
|
||||
layout.viewports[action.viewportIndex].plugin =
|
||||
action.viewportSpecificData.plugin;
|
||||
}
|
||||
|
||||
return Object.assign({}, state, { layout, viewportSpecificData });
|
||||
return { ...state, layout, viewportSpecificData };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets viewport specific data of active/any viewport.
|
||||
*
|
||||
* @return {Object} New state.
|
||||
*/
|
||||
case SET_ACTIVE_SPECIFIC_DATA:
|
||||
useActiveViewport = true;
|
||||
// Allow fall-through
|
||||
// eslint-disable-next-line
|
||||
case SET_SPECIFIC_DATA: {
|
||||
const layout = cloneDeep(state.layout);
|
||||
const hasPlugin = action.data && action.data.plugin;
|
||||
const viewportIndex = useActiveViewport
|
||||
? state.activeViewportIndex
|
||||
: action.viewportIndex;
|
||||
const { dom } = state.viewportSpecificData[viewportIndex];
|
||||
|
||||
viewportSpecificData = cloneDeep(state.viewportSpecificData);
|
||||
let viewportSpecificData = cloneDeep(state.viewportSpecificData);
|
||||
viewportSpecificData[viewportIndex] = {
|
||||
dom,
|
||||
...action.data,
|
||||
...action.viewportSpecificData,
|
||||
};
|
||||
|
||||
if (hasPlugin) {
|
||||
layout.viewports[viewportIndex].plugin = action.data.plugin;
|
||||
if (action.viewportSpecificData && action.viewportSpecificData.plugin) {
|
||||
layout.viewports[viewportIndex].plugin =
|
||||
action.viewportSpecificData.plugin;
|
||||
}
|
||||
|
||||
return Object.assign({}, state, { layout, viewportSpecificData });
|
||||
return { ...state, layout, viewportSpecificData };
|
||||
}
|
||||
case CLEAR_VIEWPORT:
|
||||
viewportSpecificData = cloneDeep(state.viewportSpecificData);
|
||||
|
||||
/**
|
||||
* Clears viewport specific data of any viewport.
|
||||
*
|
||||
* @return {Object} New state.
|
||||
*/
|
||||
case CLEAR_VIEWPORT: {
|
||||
let viewportSpecificData = cloneDeep(state.viewportSpecificData);
|
||||
|
||||
if (action.viewportIndex) {
|
||||
viewportSpecificData[action.viewportIndex] = {};
|
||||
return Object.assign({}, state, { viewportSpecificData });
|
||||
return { ...state, viewportSpecificData };
|
||||
} else {
|
||||
return defaultState;
|
||||
return DEFAULT_STATE;
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
/**
|
||||
* Returns the current application state.
|
||||
*
|
||||
* @return {Object} The current state.
|
||||
*/
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('viewports reducer', () => {
|
||||
const setViewportAction = {
|
||||
type: types.SET_VIEWPORT,
|
||||
viewportIndex: viewportToSet,
|
||||
data: {
|
||||
viewportSpecificData: {
|
||||
hello: 'this is that data for the viewport',
|
||||
world: 'that will be set for the viewportIndex',
|
||||
},
|
||||
@@ -69,7 +69,7 @@ describe('viewports reducer', () => {
|
||||
const updatedState = reducer(undefined, setViewportAction);
|
||||
const updatedViewport = updatedState.viewportSpecificData[viewportToSet];
|
||||
|
||||
expect(updatedViewport).toEqual(setViewportAction.data);
|
||||
expect(updatedViewport).toEqual(setViewportAction.viewportSpecificData);
|
||||
});
|
||||
|
||||
it('should handle CLEAR_VIEWPORT', () => {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import log from './../log.js';
|
||||
|
||||
export default class ServicesManager {
|
||||
constructor() {
|
||||
this.services = {};
|
||||
this.registeredServiceNames = [];
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Object} service
|
||||
*/
|
||||
registerService(service) {
|
||||
if (!service) {
|
||||
log.warn(
|
||||
'Attempting to register a null/undefined service. Exiting early.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let serviceName = service.name;
|
||||
|
||||
if (!serviceName) {
|
||||
log.warn(`Service name not set. Exiting early.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.registeredServiceNames.includes(serviceName)) {
|
||||
log.warn(
|
||||
`Extension name ${serviceName} has already been registered. Exiting before duplicating services.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.services[service.name] = service;
|
||||
|
||||
// Track service registration
|
||||
this.registeredServiceNames.push(serviceName);
|
||||
}
|
||||
|
||||
/**
|
||||
* An array of services.
|
||||
*
|
||||
* @param {Object[]} services - Array of services
|
||||
*/
|
||||
registerServices(services) {
|
||||
services.forEach(service => this.registerService(service));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import ServicesManager from './ServicesManager.js';
|
||||
import log from '../log.js';
|
||||
|
||||
jest.mock('./../log.js');
|
||||
|
||||
describe('ServicesManager.js', () => {
|
||||
let servicesManager;
|
||||
|
||||
beforeEach(() => {
|
||||
servicesManager = new ServicesManager();
|
||||
log.warn.mockClear();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('registerServices()', () => {
|
||||
it('calls registerService() for each service', () => {
|
||||
servicesManager.registerService = jest.fn();
|
||||
|
||||
const fakeServices = [
|
||||
{ name: 'UINotificationTestService', hide: jest.fn() },
|
||||
{ name: 'UIModalTestService', hide: jest.fn() },
|
||||
];
|
||||
|
||||
servicesManager.registerServices(fakeServices);
|
||||
|
||||
expect(servicesManager.registerService.mock.calls.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerService()', () => {
|
||||
it('logs a warning if the service is null or undefined', () => {
|
||||
const undefinedService = undefined;
|
||||
const nullService = null;
|
||||
|
||||
servicesManager.registerService(undefinedService);
|
||||
servicesManager.registerService(nullService);
|
||||
|
||||
expect(log.warn.mock.calls.length).toBe(2);
|
||||
});
|
||||
|
||||
it('logs a warning if the service does not have a name', () => {
|
||||
const serviceWithEmptyName = { name: '', hide: jest.fn() };
|
||||
const serviceWithoutName = { hide: jest.fn() };
|
||||
|
||||
servicesManager.registerService(serviceWithEmptyName);
|
||||
servicesManager.registerService(serviceWithoutName);
|
||||
|
||||
expect(log.warn.mock.calls.length).toBe(2);
|
||||
});
|
||||
|
||||
it('tracks which services have been registered', () => {
|
||||
const service = {
|
||||
name: 'UINotificationService',
|
||||
};
|
||||
|
||||
servicesManager.registerService(service);
|
||||
|
||||
expect(servicesManager.registeredServiceNames).toContain(service.name);
|
||||
});
|
||||
|
||||
it('logs a warning if the service has an name that has already been registered', () => {
|
||||
const service = { name: 'UINotificationService' };
|
||||
|
||||
servicesManager.registerService(service);
|
||||
servicesManager.registerService(service);
|
||||
|
||||
expect(log.warn.mock.calls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* A UI Element Position
|
||||
*
|
||||
* @typedef {Object} ElementPosition
|
||||
* @property {number} top -
|
||||
* @property {number} left -
|
||||
* @property {number} right -
|
||||
* @property {number} bottom -
|
||||
*/
|
||||
|
||||
/**
|
||||
* UI Dialog
|
||||
*
|
||||
* @typedef {Object} DialogProps
|
||||
* @property {string} id The dialog id.
|
||||
* @property {ReactElement|HTMLElement} content The dialog content.
|
||||
* @property {Object} contentProps The dialog content props.
|
||||
* @property {boolean} [isDraggable=true] Controls if dialog content is draggable or not.
|
||||
* @property {boolean} [showOverlay=false] Controls dialog overlay.
|
||||
* @property {ElementPosition} defaultPosition Specifies the `x` and `y` that the dragged item should start at.
|
||||
* @property {ElementPosition} position If this property is present, the item becomes 'controlled' and is not responsive to user input.
|
||||
* @property {Function} onStart Called when dragging starts. If `false` is returned any handler, the action will cancel.
|
||||
* @property {Function} onStop Called when dragging stops.
|
||||
* @property {Function} onDrag Called while dragging.
|
||||
*/
|
||||
|
||||
const uiDialogServicePublicAPI = {
|
||||
name: 'UIDialogService',
|
||||
dismiss,
|
||||
dismissAll,
|
||||
create,
|
||||
setServiceImplementation,
|
||||
};
|
||||
|
||||
const uiDialogServiceImplementation = {
|
||||
_dismiss: () => console.warn('dismiss() NOT IMPLEMENTED'),
|
||||
_dismissAll: () => console.warn('dismissAll() NOT IMPLEMENTED'),
|
||||
_create: () => console.warn('create() NOT IMPLEMENTED'),
|
||||
};
|
||||
|
||||
function createUIDialogService() {
|
||||
return uiDialogServicePublicAPI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a new UI dialog;
|
||||
*
|
||||
* @param {DialogProps} props { id, content, contentProps, onStart, onDrag, onStop, isDraggable, showOverlay, defaultPosition, position }
|
||||
*/
|
||||
function create({
|
||||
id,
|
||||
content,
|
||||
contentProps,
|
||||
onStart,
|
||||
onDrag,
|
||||
onStop,
|
||||
isDraggable = true,
|
||||
showOverlay = false,
|
||||
defaultPosition,
|
||||
position,
|
||||
}) {
|
||||
return uiDialogServiceImplementation._create({
|
||||
id,
|
||||
content,
|
||||
contentProps,
|
||||
onStart,
|
||||
onDrag,
|
||||
onStop,
|
||||
isDraggable,
|
||||
showOverlay,
|
||||
defaultPosition,
|
||||
position,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys all dialogs, if any
|
||||
*
|
||||
* @returns void
|
||||
*/
|
||||
function dismissAll() {
|
||||
return uiDialogServiceImplementation._dismissAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the dialog, if currently created
|
||||
*
|
||||
* @returns void
|
||||
*/
|
||||
function dismiss({ id }) {
|
||||
return uiDialogServiceImplementation._dismiss({ id });
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @param {*} {
|
||||
* dismiss: dismissImplementation,
|
||||
* dismissAll: dismissAllImplementation,
|
||||
* create: createImplementation,
|
||||
* }
|
||||
*/
|
||||
function setServiceImplementation({
|
||||
dismiss: dismissImplementation,
|
||||
dismissAll: dismissAllImplementation,
|
||||
create: createImplementation,
|
||||
}) {
|
||||
if (dismissImplementation) {
|
||||
uiDialogServiceImplementation._dismiss = dismissImplementation;
|
||||
}
|
||||
if (dismissAllImplementation) {
|
||||
uiDialogServiceImplementation._dismissAll = dismissAllImplementation;
|
||||
}
|
||||
if (createImplementation) {
|
||||
uiDialogServiceImplementation._create = createImplementation;
|
||||
}
|
||||
}
|
||||
|
||||
export default createUIDialogService;
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* UI Modal
|
||||
*
|
||||
* @typedef {Object} ModalProps
|
||||
* @property {ReactElement|HTMLElement} [content=null] Modal content.
|
||||
* @property {Object} [contentProps=null] Modal content props.
|
||||
* @property {boolean} [shouldCloseOnEsc=false] Modal is dismissible via the esc key.
|
||||
* @property {boolean} [isOpen=true] Make the Modal visible or hidden.
|
||||
* @property {boolean} [closeButton=true] Should the modal body render the close button.
|
||||
* @property {string} [title=null] Should the modal render the title independently of the body content.
|
||||
* @property {string} [customClassName=null] The custom class to style the modal.
|
||||
*/
|
||||
|
||||
const uiModalServicePublicAPI = {
|
||||
name: 'UIModalService',
|
||||
hide,
|
||||
show,
|
||||
setServiceImplementation,
|
||||
};
|
||||
|
||||
const uiModalServiceImplementation = {
|
||||
_hide: () => console.warn('hide() NOT IMPLEMENTED'),
|
||||
_show: () => console.warn('show() NOT IMPLEMENTED'),
|
||||
};
|
||||
|
||||
function createUIModalService() {
|
||||
return uiModalServicePublicAPI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a new UI modal;
|
||||
*
|
||||
* @param {ModalProps} props { content, contentProps, shouldCloseOnEsc, isOpen, closeButton, title, customClassName }
|
||||
*/
|
||||
function show({
|
||||
content = null,
|
||||
contentProps = null,
|
||||
shouldCloseOnEsc = false,
|
||||
isOpen = true,
|
||||
closeButton = true,
|
||||
title = null,
|
||||
customClassName = null,
|
||||
}) {
|
||||
return uiModalServiceImplementation._show({
|
||||
content,
|
||||
contentProps,
|
||||
shouldCloseOnEsc,
|
||||
isOpen,
|
||||
closeButton,
|
||||
title,
|
||||
customClassName,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides/dismisses the modal, if currently shown
|
||||
*
|
||||
* @returns void
|
||||
*/
|
||||
function hide() {
|
||||
return uiModalServiceImplementation._hide();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @param {*} {
|
||||
* hide: hideImplementation,
|
||||
* show: showImplementation,
|
||||
* }
|
||||
*/
|
||||
function setServiceImplementation({
|
||||
hide: hideImplementation,
|
||||
show: showImplementation,
|
||||
}) {
|
||||
if (hideImplementation) {
|
||||
uiModalServiceImplementation._hide = hideImplementation;
|
||||
}
|
||||
if (showImplementation) {
|
||||
uiModalServiceImplementation._show = showImplementation;
|
||||
}
|
||||
}
|
||||
|
||||
export default createUIModalService;
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* A UI Notification
|
||||
*
|
||||
* @typedef {Object} Notification
|
||||
* @property {string} title -
|
||||
* @property {string} message -
|
||||
* @property {number} [duration=5000] - in ms
|
||||
* @property {string} [position="bottomRight"] -"topLeft" | "topCenter | "topRight" | "bottomLeft" | "bottomCenter" | "bottomRight"
|
||||
* @property {string} [type="info"] - "info" | "error" | "warning" | "success"
|
||||
* @property {boolean} [autoClose=true]
|
||||
*/
|
||||
|
||||
const uiNotificationServicePublicAPI = {
|
||||
name: 'UINotificationService',
|
||||
hide,
|
||||
show,
|
||||
setServiceImplementation,
|
||||
};
|
||||
|
||||
const uiNotificationServiceImplementation = {
|
||||
_hide: () => console.warn('hide() NOT IMPLEMENTED'),
|
||||
_show: () => console.warn('show() NOT IMPLEMENTED'),
|
||||
};
|
||||
|
||||
function createUINotificationService() {
|
||||
return uiNotificationServicePublicAPI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and show a new UI notification; returns the
|
||||
* ID of the created notification.
|
||||
*
|
||||
* @param {Notification} notification { title, message, duration, position, type, autoClose}
|
||||
* @returns {number} id
|
||||
*/
|
||||
function show({
|
||||
title,
|
||||
message,
|
||||
duration = 5000,
|
||||
position = 'bottomRight',
|
||||
type = 'info',
|
||||
autoClose = true,
|
||||
}) {
|
||||
return uiNotificationServiceImplementation._show({
|
||||
title,
|
||||
message,
|
||||
duration,
|
||||
position,
|
||||
type,
|
||||
autoClose,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hides/dismisses the notification, if currently shown
|
||||
*
|
||||
* @param {number} id - id of the notification to hide/dismiss
|
||||
* @returns undefined
|
||||
*/
|
||||
function hide(id) {
|
||||
return uiNotificationServiceImplementation._hide({ id });
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*
|
||||
* @param {*} {
|
||||
* hide: hideImplementation,
|
||||
* show: showImplementation,
|
||||
* }
|
||||
*/
|
||||
function setServiceImplementation({
|
||||
hide: hideImplementation,
|
||||
show: showImplementation,
|
||||
}) {
|
||||
if (hideImplementation) {
|
||||
uiNotificationServiceImplementation._hide = hideImplementation;
|
||||
}
|
||||
if (showImplementation) {
|
||||
uiNotificationServiceImplementation._show = showImplementation;
|
||||
}
|
||||
}
|
||||
|
||||
export default createUINotificationService;
|
||||
@@ -0,0 +1,11 @@
|
||||
import ServicesManager from './ServicesManager.js';
|
||||
import createUINotificationService from './UINotificationService';
|
||||
import createUIModalService from './UIModalService';
|
||||
import createUIDialogService from './UIDialogService';
|
||||
|
||||
export {
|
||||
createUINotificationService,
|
||||
createUIModalService,
|
||||
createUIDialogService,
|
||||
ServicesManager,
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import DICOMWeb from '../DICOMWeb/';
|
||||
import isLowPriorityModality from '../utils/isLowPriorityModality';
|
||||
|
||||
const INFO = Symbol('INFO');
|
||||
|
||||
/**
|
||||
* Creates an object with processed series information and saves its reference
|
||||
* inside the series object itself.
|
||||
* @param {Object} series The raw series object
|
||||
* @returns {Object} object containing some useful info from given series
|
||||
*/
|
||||
export default function getSeriesInfo(series) {
|
||||
let info = series[INFO];
|
||||
if (!info) {
|
||||
const modality = DICOMWeb.getString(series['00080060'], '').toUpperCase();
|
||||
info = Object.freeze({
|
||||
modality,
|
||||
isLowPriority: isLowPriorityModality(modality),
|
||||
seriesInstanceUid: DICOMWeb.getString(series['0020000E']),
|
||||
seriesNumber: DICOMWeb.getNumber(series['00200011'], 0) || 0,
|
||||
});
|
||||
series[INFO] = info;
|
||||
}
|
||||
return info;
|
||||
}
|
||||
@@ -7,13 +7,16 @@ import { retrieveStudyMetadata } from './retrieveStudyMetadata';
|
||||
* This function calls retrieveStudyMetadata several times, asynchronously,
|
||||
* and waits for all of the results to be returned.
|
||||
*
|
||||
* @param studyInstanceUids The UIDs of the Studies to be retrieved
|
||||
* @return Promise
|
||||
* @param {Object} server Object with server configuration parameters
|
||||
* @param {Array} studyInstanceUids The UIDs of the Studies to be retrieved
|
||||
* @param {Object} [filters] - Object containing filters to be applied on retrieve metadata process
|
||||
* @param {string} [filter.seriesInstanceUID] - series instance uid to filter results against
|
||||
* @returns {Promise} that will be resolved with the metadata or rejected with the error
|
||||
*/
|
||||
export default function retrieveStudiesMetadata(
|
||||
server,
|
||||
studyInstanceUids,
|
||||
seriesInstanceUids
|
||||
filters
|
||||
) {
|
||||
// Create an empty array to store the Promises for each metaData retrieval call
|
||||
const promises = [];
|
||||
@@ -21,11 +24,7 @@ export default function retrieveStudiesMetadata(
|
||||
// Loop through the array of studyInstanceUids
|
||||
studyInstanceUids.forEach(function(studyInstanceUid) {
|
||||
// Send the call and resolve or reject the related promise based on its outcome
|
||||
const promise = retrieveStudyMetadata(
|
||||
server,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUids
|
||||
);
|
||||
const promise = retrieveStudyMetadata(server, studyInstanceUid, filters);
|
||||
|
||||
// Add the current promise to the array of promises
|
||||
promises.push(promise);
|
||||
|
||||
@@ -7,11 +7,13 @@ const StudyMetaDataPromises = new Map();
|
||||
/**
|
||||
* Retrieves study metadata
|
||||
*
|
||||
* @param {Object} server
|
||||
* @param {Object} server Object with server configuration parameters
|
||||
* @param {string} studyInstanceUid The UID of the Study to be retrieved
|
||||
* @param {Object} [filters] - Object containing filters to be applied on retrieve metadata process
|
||||
* @param {string} [filter.seriesInstanceUID] - series instance uid to filter results against
|
||||
* @returns {Promise} that will be resolved with the metadata or rejected with the error
|
||||
*/
|
||||
export function retrieveStudyMetadata(server, studyInstanceUid) {
|
||||
export function retrieveStudyMetadata(server, studyInstanceUid, filters) {
|
||||
// @TODO: Whenever a study metadata request has failed, its related promise will be rejected once and for all
|
||||
// and further requests for that metadata will always fail. On failure, we probably need to remove the
|
||||
// corresponding promise from the "StudyMetaDataPromises" map...
|
||||
@@ -32,7 +34,7 @@ export function retrieveStudyMetadata(server, studyInstanceUid) {
|
||||
|
||||
// Create a promise to handle the data retrieval
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
RetrieveMetadata(server, studyInstanceUid).then(function(data) {
|
||||
RetrieveMetadata(server, studyInstanceUid, filters).then(function(data) {
|
||||
resolve(data);
|
||||
}, reject);
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ function dateToString(date) {
|
||||
function getQIDOQueryParams(filter, serverSupportsQIDOIncludeField) {
|
||||
const commaSeparatedFields = [
|
||||
'00081030', // Study Description
|
||||
'00080060', //Modality
|
||||
'00080060', // Modality
|
||||
// Add more fields here if you want them in the result
|
||||
].join(',');
|
||||
|
||||
|
||||
@@ -1,609 +1,30 @@
|
||||
import { api } from 'dicomweb-client';
|
||||
import DICOMWeb from '../../../DICOMWeb/';
|
||||
import isLowPriorityModality from '../../../utils/isLowPriorityModality';
|
||||
|
||||
const INFO = Symbol('INFO');
|
||||
|
||||
const WADOProxy = {
|
||||
convertURL: (url, server) => {
|
||||
// TODO: Remove all WADOProxy stuff from this file
|
||||
return url;
|
||||
},
|
||||
};
|
||||
|
||||
function parseFloatArray(obj) {
|
||||
const result = [];
|
||||
|
||||
if (!obj) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const objs = obj.split('\\');
|
||||
for (let i = 0; i < objs.length; i++) {
|
||||
result.push(parseFloat(objs[i]));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple cache schema for retrieved color palettes.
|
||||
*/
|
||||
const paletteColorCache = {
|
||||
count: 0,
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24h cache?
|
||||
entries: {},
|
||||
isValidUID: function(paletteUID) {
|
||||
return typeof paletteUID === 'string' && paletteUID.length > 0;
|
||||
},
|
||||
get: function(paletteUID) {
|
||||
let entry = null;
|
||||
if (this.entries.hasOwnProperty(paletteUID)) {
|
||||
entry = this.entries[paletteUID];
|
||||
// check how the entry is...
|
||||
if (Date.now() - entry.time > this.maxAge) {
|
||||
// entry is too old... remove entry.
|
||||
delete this.entries[paletteUID];
|
||||
this.count--;
|
||||
entry = null;
|
||||
}
|
||||
}
|
||||
return entry;
|
||||
},
|
||||
add: function(entry) {
|
||||
if (this.isValidUID(entry.uid)) {
|
||||
let paletteUID = entry.uid;
|
||||
if (this.entries.hasOwnProperty(paletteUID) !== true) {
|
||||
this.count++; // increment cache entry count...
|
||||
}
|
||||
entry.time = Date.now();
|
||||
this.entries[paletteUID] = entry;
|
||||
// @TODO: Add logic to get rid of old entries and reduce memory usage...
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/** Returns a WADO url for an instance
|
||||
*
|
||||
* @param studyInstanceUid
|
||||
* @param seriesInstanceUid
|
||||
* @param sopInstanceUid
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildInstanceWadoUrl(
|
||||
server,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUid,
|
||||
sopInstanceUid
|
||||
) {
|
||||
// TODO: This can be removed, since DICOMWebClient has the same function. Not urgent, though
|
||||
const params = [];
|
||||
|
||||
params.push('requestType=WADO');
|
||||
params.push(`studyUID=${studyInstanceUid}`);
|
||||
params.push(`seriesUID=${seriesInstanceUid}`);
|
||||
params.push(`objectUID=${sopInstanceUid}`);
|
||||
params.push('contentType=application/dicom');
|
||||
params.push('transferSyntax=*');
|
||||
|
||||
const paramString = params.join('&');
|
||||
|
||||
return `${server.wadoUriRoot}?${paramString}`;
|
||||
}
|
||||
|
||||
function buildInstanceWadoRsUri(
|
||||
server,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUid,
|
||||
sopInstanceUid
|
||||
) {
|
||||
return `${server.wadoRoot}/studies/${studyInstanceUid}/series/${seriesInstanceUid}/instances/${sopInstanceUid}`;
|
||||
}
|
||||
|
||||
function buildInstanceFrameWadoRsUri(
|
||||
server,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUid,
|
||||
sopInstanceUid,
|
||||
frame
|
||||
) {
|
||||
const baseWadoRsUri = buildInstanceWadoRsUri(
|
||||
server,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUid,
|
||||
sopInstanceUid
|
||||
);
|
||||
frame = frame != null || 1;
|
||||
|
||||
return `${baseWadoRsUri}/frames/${frame}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the SourceImageSequence, if it exists, in order
|
||||
* to return a ReferenceSOPInstanceUID. The ReferenceSOPInstanceUID
|
||||
* is used to refer to this image in any accompanying DICOM-SR documents.
|
||||
*
|
||||
* @param instance
|
||||
* @returns {String} The ReferenceSOPInstanceUID
|
||||
*/
|
||||
function getSourceImageInstanceUid(instance) {
|
||||
// TODO= Parse the whole Source Image Sequence
|
||||
// This is a really poor workaround for now.
|
||||
// Later we should probably parse the whole sequence.
|
||||
var SourceImageSequence = instance['00082112'];
|
||||
if (
|
||||
SourceImageSequence &&
|
||||
SourceImageSequence.Value &&
|
||||
SourceImageSequence.Value.length &&
|
||||
SourceImageSequence.Value[0]['00081155'].Value
|
||||
) {
|
||||
return SourceImageSequence.Value[0]['00081155'].Value[0];
|
||||
}
|
||||
}
|
||||
|
||||
function getPaletteColor(server, instance, tag, lutDescriptor) {
|
||||
const numLutEntries = lutDescriptor[0];
|
||||
const bits = lutDescriptor[2];
|
||||
|
||||
let uri = WADOProxy.convertURL(instance[tag].BulkDataURI, server);
|
||||
|
||||
// TODO: Workaround for dcm4chee behind SSL-terminating proxy returning
|
||||
// incorrect bulk data URIs
|
||||
if (server.wadoRoot.indexOf('https') === 0 && !uri.includes('https')) {
|
||||
uri = uri.replace('http', 'https');
|
||||
}
|
||||
|
||||
const config = {
|
||||
url: server.wadoRoot, //BulkDataURI is absolute, so this isn't used
|
||||
headers: DICOMWeb.getAuthorizationHeader(server),
|
||||
};
|
||||
const dicomWeb = new api.DICOMwebClient(config);
|
||||
const options = {
|
||||
BulkDataURI: uri,
|
||||
};
|
||||
|
||||
const readUInt16 = (byteArray, position) => {
|
||||
return byteArray[position] + byteArray[position + 1] * 256;
|
||||
};
|
||||
|
||||
const arrayBufferToPaletteColorLUT = result => {
|
||||
const arraybuffer = result[0];
|
||||
const byteArray = new Uint8Array(arraybuffer);
|
||||
const lut = [];
|
||||
|
||||
for (let i = 0; i < numLutEntries; i++) {
|
||||
if (bits === 16) {
|
||||
lut[i] = readUInt16(byteArray, i * 2);
|
||||
} else {
|
||||
lut[i] = byteArray[i];
|
||||
}
|
||||
}
|
||||
|
||||
return lut;
|
||||
};
|
||||
|
||||
return dicomWeb.retrieveBulkData(options).then(arrayBufferToPaletteColorLUT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch palette colors for instances with "PALETTE COLOR" photometricInterpretation.
|
||||
*
|
||||
* @param server {Object} Current server;
|
||||
* @param instance {Object} The retrieved instance metadata;
|
||||
* @returns {String} The ReferenceSOPInstanceUID
|
||||
*/
|
||||
async function getPaletteColors(server, instance, lutDescriptor) {
|
||||
let paletteUID = DICOMWeb.getString(instance['00281199']);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let entry;
|
||||
if (paletteColorCache.isValidUID(paletteUID)) {
|
||||
entry = paletteColorCache.get(paletteUID);
|
||||
|
||||
if (entry) {
|
||||
return resolve(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// no entry in cache... Fetch remote data.
|
||||
const r = getPaletteColor(server, instance, '00281201', lutDescriptor);
|
||||
const g = getPaletteColor(server, instance, '00281202', lutDescriptor);
|
||||
const b = getPaletteColor(server, instance, '00281203', lutDescriptor);
|
||||
|
||||
const promises = [r, g, b];
|
||||
|
||||
Promise.all(promises).then(args => {
|
||||
entry = {
|
||||
red: args[0],
|
||||
green: args[1],
|
||||
blue: args[2],
|
||||
};
|
||||
|
||||
// when paletteUID is present, the entry can be cached...
|
||||
entry.uid = paletteUID;
|
||||
paletteColorCache.add(entry);
|
||||
|
||||
resolve(entry);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getFrameIncrementPointer(element) {
|
||||
const frameIncrementPointerNames = {
|
||||
'00181065': 'frameTimeVector',
|
||||
'00181063': 'frameTime',
|
||||
};
|
||||
|
||||
if (!element || !element.Value || !element.Value.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = element.Value[0];
|
||||
return frameIncrementPointerNames[value];
|
||||
}
|
||||
|
||||
function getRadiopharmaceuticalInfo(instance) {
|
||||
const modality = DICOMWeb.getString(instance['00080060']);
|
||||
|
||||
if (modality !== 'PT') {
|
||||
return;
|
||||
}
|
||||
|
||||
const radiopharmaceuticalInfo = instance['00540016'];
|
||||
if (
|
||||
radiopharmaceuticalInfo === undefined ||
|
||||
!radiopharmaceuticalInfo.Value ||
|
||||
!radiopharmaceuticalInfo.Value.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstPetRadiopharmaceuticalInfo = radiopharmaceuticalInfo.Value[0];
|
||||
return {
|
||||
radiopharmaceuticalStartTime: DICOMWeb.getString(
|
||||
firstPetRadiopharmaceuticalInfo['00181072']
|
||||
),
|
||||
radionuclideTotalDose: DICOMWeb.getNumber(
|
||||
firstPetRadiopharmaceuticalInfo['00181074']
|
||||
),
|
||||
radionuclideHalfLife: DICOMWeb.getNumber(
|
||||
firstPetRadiopharmaceuticalInfo['00181075']
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function makeSOPInstance(server, study, instance) {
|
||||
const { studyInstanceUid } = study;
|
||||
const seriesInstanceUid = DICOMWeb.getString(instance['0020000E']);
|
||||
let series = study.seriesMap[seriesInstanceUid];
|
||||
|
||||
if (!series) {
|
||||
series = {
|
||||
seriesInstanceUid,
|
||||
seriesDescription: DICOMWeb.getString(instance['0008103E']),
|
||||
modality: DICOMWeb.getString(instance['00080060']),
|
||||
seriesNumber: DICOMWeb.getNumber(instance['00200011']),
|
||||
seriesDate: DICOMWeb.getString(instance['00080021']),
|
||||
seriesTime: DICOMWeb.getString(instance['00080031']),
|
||||
instances: [],
|
||||
};
|
||||
study.seriesMap[seriesInstanceUid] = series;
|
||||
study.seriesList.push(series);
|
||||
}
|
||||
|
||||
const sopInstanceUid = DICOMWeb.getString(instance['00080018']);
|
||||
const wadouri = buildInstanceWadoUrl(
|
||||
server,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUid,
|
||||
sopInstanceUid
|
||||
);
|
||||
const baseWadoRsUri = buildInstanceWadoRsUri(
|
||||
server,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUid,
|
||||
sopInstanceUid
|
||||
);
|
||||
const wadorsuri = buildInstanceFrameWadoRsUri(
|
||||
server,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUid,
|
||||
sopInstanceUid
|
||||
);
|
||||
|
||||
const sopInstance = {
|
||||
imageType: DICOMWeb.getString(instance['00080008']),
|
||||
sopClassUid: DICOMWeb.getString(instance['00080016']),
|
||||
modality: DICOMWeb.getString(instance['00080060']),
|
||||
sopInstanceUid,
|
||||
instanceNumber: DICOMWeb.getNumber(instance['00200013']),
|
||||
imagePositionPatient: DICOMWeb.getString(instance['00200032']),
|
||||
imageOrientationPatient: DICOMWeb.getString(instance['00200037']),
|
||||
frameOfReferenceUID: DICOMWeb.getString(instance['00200052']),
|
||||
sliceLocation: DICOMWeb.getNumber(instance['00201041']),
|
||||
samplesPerPixel: DICOMWeb.getNumber(instance['00280002']),
|
||||
photometricInterpretation: DICOMWeb.getString(instance['00280004']),
|
||||
planarConfiguration: DICOMWeb.getNumber(instance['00280006']),
|
||||
rows: DICOMWeb.getNumber(instance['00280010']),
|
||||
columns: DICOMWeb.getNumber(instance['00280011']),
|
||||
pixelSpacing: DICOMWeb.getString(instance['00280030']),
|
||||
pixelAspectRatio: DICOMWeb.getString(instance['00280034']),
|
||||
bitsAllocated: DICOMWeb.getNumber(instance['00280100']),
|
||||
bitsStored: DICOMWeb.getNumber(instance['00280101']),
|
||||
highBit: DICOMWeb.getNumber(instance['00280102']),
|
||||
pixelRepresentation: DICOMWeb.getNumber(instance['00280103']),
|
||||
smallestPixelValue: DICOMWeb.getNumber(instance['00280106']),
|
||||
largestPixelValue: DICOMWeb.getNumber(instance['00280107']),
|
||||
windowCenter: DICOMWeb.getString(instance['00281050']),
|
||||
windowWidth: DICOMWeb.getString(instance['00281051']),
|
||||
rescaleIntercept: DICOMWeb.getNumber(instance['00281052']),
|
||||
rescaleSlope: DICOMWeb.getNumber(instance['00281053']),
|
||||
rescaleType: DICOMWeb.getNumber(instance['00281054']),
|
||||
sourceImageInstanceUid: getSourceImageInstanceUid(instance),
|
||||
laterality: DICOMWeb.getString(instance['00200062']),
|
||||
viewPosition: DICOMWeb.getString(instance['00185101']),
|
||||
acquisitionDateTime: DICOMWeb.getString(instance['0008002A']),
|
||||
numberOfFrames: DICOMWeb.getNumber(instance['00280008']),
|
||||
frameIncrementPointer: getFrameIncrementPointer(instance['00280009']),
|
||||
frameTime: DICOMWeb.getNumber(instance['00181063']),
|
||||
frameTimeVector: parseFloatArray(DICOMWeb.getString(instance['00181065'])),
|
||||
sliceThickness: DICOMWeb.getNumber(instance['00180050']),
|
||||
spacingBetweenSlices: DICOMWeb.getString(instance['00180088']),
|
||||
lossyImageCompression: DICOMWeb.getString(instance['00282110']),
|
||||
derivationDescription: DICOMWeb.getString(instance['00282111']),
|
||||
lossyImageCompressionRatio: DICOMWeb.getString(instance['00282112']),
|
||||
lossyImageCompressionMethod: DICOMWeb.getString(instance['00282114']),
|
||||
echoNumber: DICOMWeb.getString(instance['00180086']),
|
||||
contrastBolusAgent: DICOMWeb.getString(instance['00180010']),
|
||||
radiopharmaceuticalInfo: getRadiopharmaceuticalInfo(instance),
|
||||
baseWadoRsUri: baseWadoRsUri,
|
||||
wadouri: WADOProxy.convertURL(wadouri, server),
|
||||
wadorsuri: WADOProxy.convertURL(wadorsuri, server),
|
||||
wadoRoot: server.wadoRoot,
|
||||
imageRendering: server.imageRendering,
|
||||
thumbnailRendering: server.thumbnailRendering,
|
||||
};
|
||||
|
||||
// Get additional information if the instance uses "PALETTE COLOR" photometric interpretation
|
||||
if (sopInstance.photometricInterpretation === 'PALETTE COLOR') {
|
||||
const redPaletteColorLookupTableDescriptor = parseFloatArray(
|
||||
DICOMWeb.getString(instance['00281101'])
|
||||
);
|
||||
const greenPaletteColorLookupTableDescriptor = parseFloatArray(
|
||||
DICOMWeb.getString(instance['00281102'])
|
||||
);
|
||||
const bluePaletteColorLookupTableDescriptor = parseFloatArray(
|
||||
DICOMWeb.getString(instance['00281103'])
|
||||
);
|
||||
const palettes = await getPaletteColors(
|
||||
server,
|
||||
instance,
|
||||
redPaletteColorLookupTableDescriptor
|
||||
);
|
||||
|
||||
if (palettes) {
|
||||
if (palettes.uid) {
|
||||
sopInstance.paletteColorLookupTableUID = palettes.uid;
|
||||
}
|
||||
|
||||
sopInstance.redPaletteColorLookupTableData = palettes.red;
|
||||
sopInstance.greenPaletteColorLookupTableData = palettes.green;
|
||||
sopInstance.bluePaletteColorLookupTableData = palettes.blue;
|
||||
sopInstance.redPaletteColorLookupTableDescriptor = redPaletteColorLookupTableDescriptor;
|
||||
sopInstance.greenPaletteColorLookupTableDescriptor = greenPaletteColorLookupTableDescriptor;
|
||||
sopInstance.bluePaletteColorLookupTableDescriptor = bluePaletteColorLookupTableDescriptor;
|
||||
}
|
||||
}
|
||||
|
||||
series.instances.push(sopInstance);
|
||||
return sopInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a plain JS object that describes a study (a study descriptor object)
|
||||
* @param {Object} server Object with server configuration paramenters
|
||||
* @param {Object} aSopInstance a SOP Instance from which study information will be added
|
||||
*/
|
||||
function createStudy(server, aSopInstance) {
|
||||
// TODO: Pass a reference ID to the server instead of including the URLs here
|
||||
return {
|
||||
seriesList: [],
|
||||
seriesMap: Object.create(null),
|
||||
seriesLoader: null,
|
||||
wadoUriRoot: server.wadoUriRoot,
|
||||
wadoRoot: server.wadoRoot,
|
||||
qidoRoot: server.qidoRoot,
|
||||
patientName: DICOMWeb.getName(aSopInstance['00100010']),
|
||||
patientId: DICOMWeb.getString(aSopInstance['00100020']),
|
||||
patientAge: DICOMWeb.getNumber(aSopInstance['00101010']),
|
||||
patientSize: DICOMWeb.getNumber(aSopInstance['00101020']),
|
||||
patientWeight: DICOMWeb.getNumber(aSopInstance['00101030']),
|
||||
accessionNumber: DICOMWeb.getString(aSopInstance['00080050']),
|
||||
studyDate: DICOMWeb.getString(aSopInstance['00080020']),
|
||||
modalities: DICOMWeb.getString(aSopInstance['00080061']),
|
||||
studyDescription: DICOMWeb.getString(aSopInstance['00081030']),
|
||||
imageCount: DICOMWeb.getString(aSopInstance['00201208']),
|
||||
studyInstanceUid: DICOMWeb.getString(aSopInstance['0020000D']),
|
||||
institutionName: DICOMWeb.getString(aSopInstance['00080080']),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a list of SOP Instances to a given study object descriptor
|
||||
* @param {Object} server Object with server configuration paramenters
|
||||
* @param {Object} study The study descriptor to which the given SOP instances will be added
|
||||
* @param {Array} sopInstanceList A list of SOP instance objects
|
||||
*/
|
||||
async function addInstancesToStudy(server, study, sopInstanceList) {
|
||||
return Promise.all(
|
||||
sopInstanceList.map(function(sopInstance) {
|
||||
return makeSOPInstance(server, study, sopInstance);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses result data from a WADO search into Study MetaData
|
||||
* Returns an object populated with study metadata, including the
|
||||
* series list.
|
||||
*
|
||||
* @param {Object} server Object with server configuration paramenters
|
||||
* @param {Array} sopInstanceList List of SOP Instances that build up to the study
|
||||
* @resolves {{seriesList: Array, patientName: *, patientId: *, accessionNumber: *, studyDate: *, modalities: *, studyDescription: *, imageCount: *, studyInstanceUid: *}}
|
||||
*/
|
||||
async function createStudyFromSOPInstanceList(server, sopInstanceList) {
|
||||
if (Array.isArray(sopInstanceList) && sopInstanceList.length > 0) {
|
||||
const firstSopInstance = sopInstanceList[0];
|
||||
const study = createStudy(server, firstSopInstance);
|
||||
await addInstancesToStudy(server, study, sopInstanceList);
|
||||
return study;
|
||||
}
|
||||
throw new Error('Failed to create study out of provided SOP instance list');
|
||||
}
|
||||
import RetrieveMetadataLoaderSync from './retrieveMetadataLoaderSync';
|
||||
import RetrieveMetadataLoaderAsync from './retrieveMetadataLoaderAsync';
|
||||
|
||||
/**
|
||||
* Retrieve Study metadata from a DICOM server. If the server is configured to use lazy load, only the first series
|
||||
* will be loaded and the property "studyLoader" will be set to let consumer load remaining series as needed
|
||||
* @param {Object} server Object with server configuration paramenters
|
||||
* will be loaded and the property "studyLoader" will be set to let consumer load remaining series as needed.
|
||||
*
|
||||
* @param {Object} server Object with server configuration parameters
|
||||
* @param {string} studyInstanceUid The Study Instance UID of the study which needs to be loaded
|
||||
* @param {Object} [filters] - Object containing filters to be applied on retrieve metadata process
|
||||
* @param {string} [filter.seriesInstanceUID] - series instance uid to filter results against
|
||||
* @returns {Object} A study descriptor object
|
||||
*/
|
||||
async function RetrieveMetadata(server, studyInstanceUid) {
|
||||
return (server.enableStudyLazyLoad !== false
|
||||
? lazyLoadStudyMetadata
|
||||
: loadStudyMetadata)(server, studyInstanceUid);
|
||||
}
|
||||
async function RetrieveMetadata(server, studyInstanceUid, filters = {}) {
|
||||
const RetrieveMetadataLoader =
|
||||
server.enableStudyLazyLoad !== false
|
||||
? RetrieveMetadataLoaderAsync
|
||||
: RetrieveMetadataLoaderSync;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} server
|
||||
* @param {*} studyInstanceUID
|
||||
*/
|
||||
async function loadStudyMetadata(server, studyInstanceUID) {
|
||||
const dicomWeb = new api.DICOMwebClient({
|
||||
url: server.wadoRoot,
|
||||
headers: DICOMWeb.getAuthorizationHeader(server),
|
||||
});
|
||||
return dicomWeb
|
||||
.retrieveStudyMetadata({ studyInstanceUID })
|
||||
.then(result => createStudyFromSOPInstanceList(server, result));
|
||||
}
|
||||
|
||||
async function lazyLoadStudyMetadata(server, studyInstanceUid) {
|
||||
const seriesInstanceUids = await searchStudySeries(server, studyInstanceUid);
|
||||
const dicomWeb = new api.DICOMwebClient({
|
||||
url: server.wadoRoot,
|
||||
headers: DICOMWeb.getAuthorizationHeader(server),
|
||||
});
|
||||
const seriesLoader = makeSeriesLoader(
|
||||
dicomWeb,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUids
|
||||
);
|
||||
const firstSeries = await seriesLoader.next();
|
||||
const study = await createStudyFromSOPInstanceList(
|
||||
const retrieveMetadataLoader = new RetrieveMetadataLoader(
|
||||
server,
|
||||
firstSeries.sopInstances
|
||||
studyInstanceUid,
|
||||
filters
|
||||
);
|
||||
if (seriesLoader.hasNext()) {
|
||||
attachSeriesLoader(server, study, seriesLoader);
|
||||
}
|
||||
return study;
|
||||
}
|
||||
const studyMetadata = retrieveMetadataLoader.execLoad();
|
||||
|
||||
function attachSeriesLoader(server, study, seriesLoader) {
|
||||
study.seriesLoader = Object.freeze({
|
||||
hasNext() {
|
||||
return seriesLoader.hasNext();
|
||||
},
|
||||
async next() {
|
||||
const series = await seriesLoader.next();
|
||||
await addInstancesToStudy(server, study, series.sopInstances);
|
||||
return study.seriesMap[series.seriesInstanceUID];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an immutable series loader object which loads each series sequentially using the iterator interface
|
||||
* @param {DICOMWebClient} dicomWebClient The DICOMWebClient instance to be used for series load
|
||||
* @param {string} studyInstanceUID The Study Instance UID from which series will be loaded
|
||||
* @param {Array} seriesInstanceUIDList A list of Series Instance UIDs
|
||||
* @returns {Object} Returns an object which supports loading of instances from each of given Series Instance UID
|
||||
*/
|
||||
function makeSeriesLoader(
|
||||
dicomWebClient,
|
||||
studyInstanceUID,
|
||||
seriesInstanceUIDList
|
||||
) {
|
||||
return Object.freeze({
|
||||
hasNext() {
|
||||
return seriesInstanceUIDList.length > 0;
|
||||
},
|
||||
async next() {
|
||||
const seriesInstanceUID = seriesInstanceUIDList.shift();
|
||||
const sopInstances = await dicomWebClient.retrieveSeriesMetadata({
|
||||
studyInstanceUID,
|
||||
seriesInstanceUID,
|
||||
});
|
||||
return { studyInstanceUID, seriesInstanceUID, sopInstances };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search series of a given study
|
||||
* @param {Object} server Object with server configuration paramenters
|
||||
* @param {string} studyInstanceUID The Study Instance UID to search series from;
|
||||
* @returns {Arrays} A list of Series Instance UIDs
|
||||
*/
|
||||
async function searchStudySeries(server, studyInstanceUID) {
|
||||
const dicomWeb = new api.DICOMwebClient({
|
||||
url: server.qidoRoot,
|
||||
headers: DICOMWeb.getAuthorizationHeader(server),
|
||||
});
|
||||
const seriesList = await dicomWeb.searchForSeries({ studyInstanceUID });
|
||||
return seriesList
|
||||
.sort(seriesSortingCriteria)
|
||||
.map(series => getSeriesInfo(series).seriesInstanceUid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Series sorting criteria: series considered low priority are moved to the end
|
||||
* of the list and series number is used to break ties
|
||||
* @param {Object} firstSeries
|
||||
* @param {Object} secondSeries
|
||||
*/
|
||||
function seriesSortingCriteria(firstSeries, secondSeries) {
|
||||
const a = getSeriesInfo(firstSeries);
|
||||
const b = getSeriesInfo(secondSeries);
|
||||
if (!a.isLowPriority && b.isLowPriority) {
|
||||
return -1;
|
||||
}
|
||||
if (a.isLowPriority && !b.isLowPriority) {
|
||||
return 1;
|
||||
}
|
||||
return a.seriesNumber - b.seriesNumber;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an object with processed series information and saves its reference
|
||||
* inside the series object itself to simplify sorting
|
||||
* @param {Object} series The raw series object
|
||||
*/
|
||||
function getSeriesInfo(series) {
|
||||
let info = series[INFO];
|
||||
if (!info) {
|
||||
const modality = DICOMWeb.getString(series['00080060'], '').toUpperCase();
|
||||
info = Object.freeze({
|
||||
modality,
|
||||
isLowPriority: isLowPriorityModality(modality),
|
||||
seriesInstanceUid: DICOMWeb.getString(series['0020000E']),
|
||||
seriesNumber: DICOMWeb.getNumber(series['00200011'], 0) || 0,
|
||||
});
|
||||
series[INFO] = info;
|
||||
}
|
||||
return info;
|
||||
return studyMetadata;
|
||||
}
|
||||
|
||||
export default RetrieveMetadata;
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Class to define inheritance of load retrieve strategy.
|
||||
* The process can be async load (lazy) or sync load
|
||||
*
|
||||
* There are methods that must be implemented at consumer level
|
||||
* To retrieve study call execLoad
|
||||
*/
|
||||
export default class RetrieveMetadataLoader {
|
||||
/**
|
||||
* @constructor
|
||||
* @param {Object} server Object with server configuration parameters
|
||||
* @param {Array} studyInstanceUID Study instance ui to be retrieved
|
||||
* @param {Object} [filters] - Object containing filters to be applied on retrieve metadata process
|
||||
* @param {string} [filter.seriesInstanceUID] - series instance uid to filter results against
|
||||
*/
|
||||
constructor(server, studyInstanceUID, filters = {}) {
|
||||
this.server = server;
|
||||
this.studyInstanceUID = studyInstanceUID;
|
||||
this.filters = filters;
|
||||
}
|
||||
|
||||
async execLoad() {
|
||||
await this.configLoad();
|
||||
const preLoadData = await this.preLoad();
|
||||
const loadData = await this.load(preLoadData);
|
||||
const postLoadData = await this.posLoad(loadData);
|
||||
|
||||
return postLoadData;
|
||||
}
|
||||
|
||||
/**
|
||||
* It iterates over given loaders running each one. Loaders parameters must be bind when getting it.
|
||||
* @param {Array} loaders - array of loader to retrieve data.
|
||||
*/
|
||||
async runLoaders(loaders) {
|
||||
let result;
|
||||
for (const loader of loaders) {
|
||||
try {
|
||||
result = await loader();
|
||||
if (result && result.length) {
|
||||
break; // closes iterator in case data is retrieved successfully
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
if (loaders.next().done && !result) {
|
||||
throw 'cant find data';
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Methods to be overwrite
|
||||
async configLoad() { }
|
||||
async preLoad() { }
|
||||
async load(preLoadData) { }
|
||||
async posLoad(loadData) { }
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { api } from 'dicomweb-client';
|
||||
import DICOMWeb from '../../../DICOMWeb/';
|
||||
import RetrieveMetadataLoader from './retrieveMetadataLoader';
|
||||
import { sortStudySeries, sortingCriteria } from '../../sortStudy';
|
||||
import getSeriesInfo from '../../getSeriesInfo';
|
||||
import {
|
||||
createStudyFromSOPInstanceList,
|
||||
addInstancesToStudy,
|
||||
} from './studyInstanceHelpers';
|
||||
|
||||
/**
|
||||
* Map seriesList to an array of seriesInstanceUid
|
||||
* @param {Arrays} seriesList list of Series Instance UIDs
|
||||
* @returns {Arrays} A list of Series Instance UIDs
|
||||
*/
|
||||
function mapStudySeries(seriesList) {
|
||||
return seriesList.map(series => getSeriesInfo(series).seriesInstanceUid);
|
||||
}
|
||||
|
||||
function attachSeriesLoader(server, study, seriesLoader) {
|
||||
study.seriesLoader = Object.freeze({
|
||||
hasNext() {
|
||||
return seriesLoader.hasNext();
|
||||
},
|
||||
async next() {
|
||||
const series = await seriesLoader.next();
|
||||
await addInstancesToStudy(server, study, series.sopInstances);
|
||||
return study.seriesMap[series.seriesInstanceUID];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an immutable series loader object which loads each series sequentially using the iterator interface
|
||||
* @param {DICOMWebClient} dicomWebClient The DICOMWebClient instance to be used for series load
|
||||
* @param {string} studyInstanceUID The Study Instance UID from which series will be loaded
|
||||
* @param {Array} seriesInstanceUIDList A list of Series Instance UIDs
|
||||
* @returns {Object} Returns an object which supports loading of instances from each of given Series Instance UID
|
||||
*/
|
||||
function makeSeriesAsyncLoader(
|
||||
dicomWebClient,
|
||||
studyInstanceUID,
|
||||
seriesInstanceUIDList
|
||||
) {
|
||||
return Object.freeze({
|
||||
hasNext() {
|
||||
return seriesInstanceUIDList.length > 0;
|
||||
},
|
||||
async next() {
|
||||
const seriesInstanceUID = seriesInstanceUIDList.shift();
|
||||
const sopInstances = await dicomWebClient.retrieveSeriesMetadata({
|
||||
studyInstanceUID,
|
||||
seriesInstanceUID,
|
||||
});
|
||||
return { studyInstanceUID, seriesInstanceUID, sopInstances };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Class for async load of study metadata.
|
||||
* It inherits from RetrieveMetadataLoader
|
||||
*
|
||||
* It loads the one series and then append to seriesLoader the others to be consumed/loaded
|
||||
*/
|
||||
export default class RetrieveMetadataLoaderAsync extends RetrieveMetadataLoader {
|
||||
configLoad() {
|
||||
const { server } = this;
|
||||
|
||||
const client = new api.DICOMwebClient({
|
||||
url: server.qidoRoot,
|
||||
headers: DICOMWeb.getAuthorizationHeader(server),
|
||||
});
|
||||
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Array} Array of preLoaders. To be consumed as queue
|
||||
*/
|
||||
*getPreLoaders() {
|
||||
const preLoaders = [];
|
||||
const {
|
||||
studyInstanceUID,
|
||||
filters: { seriesInstanceUID } = {},
|
||||
client,
|
||||
} = this;
|
||||
|
||||
if (seriesInstanceUID) {
|
||||
const options = {
|
||||
studyInstanceUID,
|
||||
queryParams: { SeriesInstanceUID: seriesInstanceUID },
|
||||
};
|
||||
preLoaders.push(client.searchForSeries.bind(client, options));
|
||||
}
|
||||
// Fallback preloader
|
||||
preLoaders.push(client.searchForSeries.bind(client, { studyInstanceUID }));
|
||||
|
||||
yield* preLoaders;
|
||||
}
|
||||
|
||||
async preLoad() {
|
||||
const preLoaders = this.getPreLoaders();
|
||||
const result = await this.runLoaders(preLoaders);
|
||||
|
||||
const seriesSorted = sortStudySeries(
|
||||
result,
|
||||
sortingCriteria.seriesSortCriteria.seriesInfoSortingCriteria
|
||||
);
|
||||
const seriesInstanceUidsMap = mapStudySeries(seriesSorted);
|
||||
|
||||
return seriesInstanceUidsMap;
|
||||
}
|
||||
|
||||
async load(preLoadData) {
|
||||
const { client, studyInstanceUID } = this;
|
||||
|
||||
const seriesAsyncLoader = makeSeriesAsyncLoader(
|
||||
client,
|
||||
studyInstanceUID,
|
||||
preLoadData
|
||||
);
|
||||
|
||||
const firstSeries = await seriesAsyncLoader.next();
|
||||
|
||||
return {
|
||||
sopInstances: firstSeries.sopInstances,
|
||||
asyncLoader: seriesAsyncLoader,
|
||||
};
|
||||
}
|
||||
|
||||
async posLoad(loadData) {
|
||||
const { server } = this;
|
||||
|
||||
const { sopInstances, asyncLoader } = loadData;
|
||||
|
||||
const study = await createStudyFromSOPInstanceList(server, sopInstances);
|
||||
|
||||
if (asyncLoader.hasNext()) {
|
||||
attachSeriesLoader(server, study, asyncLoader);
|
||||
}
|
||||
|
||||
return study;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { api } from 'dicomweb-client';
|
||||
import DICOMWeb from '../../../DICOMWeb/';
|
||||
import { createStudyFromSOPInstanceList } from './studyInstanceHelpers';
|
||||
import RetrieveMetadataLoader from './retrieveMetadataLoader';
|
||||
|
||||
/**
|
||||
* Class for sync load of study metadata.
|
||||
* It inherits from RetrieveMetadataLoader
|
||||
*
|
||||
* A list of loaders (getLoaders) can be created so, it will be applied a fallback load strategy.
|
||||
* I.e Retrieve metadata using all loaders possibilities.
|
||||
*/
|
||||
export default class RetrieveMetadataLoaderSync extends RetrieveMetadataLoader {
|
||||
getOptions() {
|
||||
const { studyInstanceUID, filters } = this;
|
||||
|
||||
const options = {
|
||||
studyInstanceUID,
|
||||
};
|
||||
|
||||
const { seriesInstanceUID } = filters;
|
||||
if (seriesInstanceUID) {
|
||||
options['seriesInstanceUID'] = seriesInstanceUID;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Array} Array of loaders. To be consumed as queue
|
||||
*/
|
||||
*getLoaders() {
|
||||
const loaders = [];
|
||||
const {
|
||||
studyInstanceUID,
|
||||
filters: { seriesInstanceUID } = {},
|
||||
client,
|
||||
} = this;
|
||||
|
||||
if (seriesInstanceUID) {
|
||||
loaders.push(
|
||||
client.retrieveSeriesMetadata.bind(client, {
|
||||
studyInstanceUID,
|
||||
seriesInstanceUID,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
loaders.push(
|
||||
client.retrieveStudyMetadata.bind(client, { studyInstanceUID })
|
||||
);
|
||||
|
||||
yield* loaders;
|
||||
}
|
||||
|
||||
configLoad() {
|
||||
const { server } = this;
|
||||
const client = new api.DICOMwebClient({
|
||||
url: server.wadoRoot,
|
||||
headers: DICOMWeb.getAuthorizationHeader(server),
|
||||
});
|
||||
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
async load(preLoadData) {
|
||||
const loaders = this.getLoaders();
|
||||
const result = this.runLoaders(loaders);
|
||||
return result;
|
||||
}
|
||||
|
||||
async posLoad(loadData) {
|
||||
const { server } = this;
|
||||
return createStudyFromSOPInstanceList(server, loadData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import DICOMWeb from '../../../DICOMWeb';
|
||||
|
||||
const WADOProxy = {
|
||||
convertURL: (url, server) => {
|
||||
// TODO: Remove all WADOProxy stuff from this file
|
||||
return url;
|
||||
},
|
||||
};
|
||||
function parseFloatArray(obj) {
|
||||
const result = [];
|
||||
|
||||
if (!obj) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const objs = obj.split('\\');
|
||||
for (let i = 0; i < objs.length; i++) {
|
||||
result.push(parseFloat(objs[i]));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a plain JS object that describes a study (a study descriptor object)
|
||||
* @param {Object} server Object with server configuration parameters
|
||||
* @param {Object} aSopInstance a SOP Instance from which study information will be added
|
||||
*/
|
||||
function createStudy(server, aSopInstance) {
|
||||
// TODO: Pass a reference ID to the server instead of including the URLs here
|
||||
return {
|
||||
seriesList: [],
|
||||
seriesMap: Object.create(null),
|
||||
seriesLoader: null,
|
||||
wadoUriRoot: server.wadoUriRoot,
|
||||
wadoRoot: server.wadoRoot,
|
||||
qidoRoot: server.qidoRoot,
|
||||
patientName: DICOMWeb.getName(aSopInstance['00100010']),
|
||||
patientId: DICOMWeb.getString(aSopInstance['00100020']),
|
||||
patientAge: DICOMWeb.getNumber(aSopInstance['00101010']),
|
||||
patientSize: DICOMWeb.getNumber(aSopInstance['00101020']),
|
||||
patientWeight: DICOMWeb.getNumber(aSopInstance['00101030']),
|
||||
accessionNumber: DICOMWeb.getString(aSopInstance['00080050']),
|
||||
studyDate: DICOMWeb.getString(aSopInstance['00080020']),
|
||||
modalities: DICOMWeb.getString(aSopInstance['00080061']),
|
||||
studyDescription: DICOMWeb.getString(aSopInstance['00081030']),
|
||||
imageCount: DICOMWeb.getString(aSopInstance['00201208']),
|
||||
studyInstanceUid: DICOMWeb.getString(aSopInstance['0020000D']),
|
||||
institutionName: DICOMWeb.getString(aSopInstance['00080080']),
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns a WADO url for an instance
|
||||
*
|
||||
* @param studyInstanceUid
|
||||
* @param seriesInstanceUid
|
||||
* @param sopInstanceUid
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildInstanceWadoUrl(
|
||||
server,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUid,
|
||||
sopInstanceUid
|
||||
) {
|
||||
// TODO: This can be removed, since DICOMWebClient has the same function. Not urgent, though
|
||||
const params = [];
|
||||
|
||||
params.push('requestType=WADO');
|
||||
params.push(`studyUID=${studyInstanceUid}`);
|
||||
params.push(`seriesUID=${seriesInstanceUid}`);
|
||||
params.push(`objectUID=${sopInstanceUid}`);
|
||||
params.push('contentType=application/dicom');
|
||||
params.push('transferSyntax=*');
|
||||
|
||||
const paramString = params.join('&');
|
||||
|
||||
return `${server.wadoUriRoot}?${paramString}`;
|
||||
}
|
||||
|
||||
function buildInstanceWadoRsUri(
|
||||
server,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUid,
|
||||
sopInstanceUid
|
||||
) {
|
||||
return `${server.wadoRoot}/studies/${studyInstanceUid}/series/${seriesInstanceUid}/instances/${sopInstanceUid}`;
|
||||
}
|
||||
|
||||
function buildInstanceFrameWadoRsUri(
|
||||
server,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUid,
|
||||
sopInstanceUid,
|
||||
frame
|
||||
) {
|
||||
const baseWadoRsUri = buildInstanceWadoRsUri(
|
||||
server,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUid,
|
||||
sopInstanceUid
|
||||
);
|
||||
frame = frame != null || 1;
|
||||
|
||||
return `${baseWadoRsUri}/frames/${frame}`;
|
||||
}
|
||||
|
||||
function getFrameIncrementPointer(element) {
|
||||
const frameIncrementPointerNames = {
|
||||
'00181065': 'frameTimeVector',
|
||||
'00181063': 'frameTime',
|
||||
};
|
||||
|
||||
if (!element || !element.Value || !element.Value.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = element.Value[0];
|
||||
return frameIncrementPointerNames[value];
|
||||
}
|
||||
|
||||
function getRadiopharmaceuticalInfo(instance) {
|
||||
const modality = DICOMWeb.getString(instance['00080060']);
|
||||
|
||||
if (modality !== 'PT') {
|
||||
return;
|
||||
}
|
||||
|
||||
const radiopharmaceuticalInfo = instance['00540016'];
|
||||
if (
|
||||
radiopharmaceuticalInfo === undefined ||
|
||||
!radiopharmaceuticalInfo.Value ||
|
||||
!radiopharmaceuticalInfo.Value.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstPetRadiopharmaceuticalInfo = radiopharmaceuticalInfo.Value[0];
|
||||
return {
|
||||
radiopharmaceuticalStartTime: DICOMWeb.getString(
|
||||
firstPetRadiopharmaceuticalInfo['00181072']
|
||||
),
|
||||
radionuclideTotalDose: DICOMWeb.getNumber(
|
||||
firstPetRadiopharmaceuticalInfo['00181074']
|
||||
),
|
||||
radionuclideHalfLife: DICOMWeb.getNumber(
|
||||
firstPetRadiopharmaceuticalInfo['00181075']
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the SourceImageSequence, if it exists, in order
|
||||
* to return a ReferenceSOPInstanceUID. The ReferenceSOPInstanceUID
|
||||
* is used to refer to this image in any accompanying DICOM-SR documents.
|
||||
*
|
||||
* @param instance
|
||||
* @returns {String} The ReferenceSOPInstanceUID
|
||||
*/
|
||||
function getSourceImageInstanceUid(instance) {
|
||||
// TODO= Parse the whole Source Image Sequence
|
||||
// This is a really poor workaround for now.
|
||||
// Later we should probably parse the whole sequence.
|
||||
var SourceImageSequence = instance['00082112'];
|
||||
if (
|
||||
SourceImageSequence &&
|
||||
SourceImageSequence.Value &&
|
||||
SourceImageSequence.Value.length &&
|
||||
SourceImageSequence.Value[0]['00081155'].Value
|
||||
) {
|
||||
return SourceImageSequence.Value[0]['00081155'].Value[0];
|
||||
}
|
||||
}
|
||||
|
||||
async function makeSOPInstance(server, study, instance) {
|
||||
const { studyInstanceUid } = study;
|
||||
const seriesInstanceUid = DICOMWeb.getString(instance['0020000E']);
|
||||
let series = study.seriesMap[seriesInstanceUid];
|
||||
|
||||
if (!series) {
|
||||
series = {
|
||||
seriesInstanceUid,
|
||||
seriesDescription: DICOMWeb.getString(instance['0008103E']),
|
||||
modality: DICOMWeb.getString(instance['00080060']),
|
||||
seriesNumber: DICOMWeb.getNumber(instance['00200011']),
|
||||
seriesDate: DICOMWeb.getString(instance['00080021']),
|
||||
seriesTime: DICOMWeb.getString(instance['00080031']),
|
||||
instances: [],
|
||||
};
|
||||
study.seriesMap[seriesInstanceUid] = series;
|
||||
study.seriesList.push(series);
|
||||
}
|
||||
|
||||
const sopInstanceUid = DICOMWeb.getString(instance['00080018']);
|
||||
const wadouri = buildInstanceWadoUrl(
|
||||
server,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUid,
|
||||
sopInstanceUid
|
||||
);
|
||||
const baseWadoRsUri = buildInstanceWadoRsUri(
|
||||
server,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUid,
|
||||
sopInstanceUid
|
||||
);
|
||||
const wadorsuri = buildInstanceFrameWadoRsUri(
|
||||
server,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUid,
|
||||
sopInstanceUid
|
||||
);
|
||||
|
||||
const sopInstance = {
|
||||
imageType: DICOMWeb.getString(instance['00080008']),
|
||||
sopClassUid: DICOMWeb.getString(instance['00080016']),
|
||||
modality: DICOMWeb.getString(instance['00080060']),
|
||||
sopInstanceUid,
|
||||
instanceNumber: DICOMWeb.getNumber(instance['00200013']),
|
||||
imagePositionPatient: DICOMWeb.getString(instance['00200032']),
|
||||
imageOrientationPatient: DICOMWeb.getString(instance['00200037']),
|
||||
frameOfReferenceUID: DICOMWeb.getString(instance['00200052']),
|
||||
sliceLocation: DICOMWeb.getNumber(instance['00201041']),
|
||||
samplesPerPixel: DICOMWeb.getNumber(instance['00280002']),
|
||||
photometricInterpretation: DICOMWeb.getString(instance['00280004']),
|
||||
planarConfiguration: DICOMWeb.getNumber(instance['00280006']),
|
||||
rows: DICOMWeb.getNumber(instance['00280010']),
|
||||
columns: DICOMWeb.getNumber(instance['00280011']),
|
||||
pixelSpacing: DICOMWeb.getString(instance['00280030']),
|
||||
pixelAspectRatio: DICOMWeb.getString(instance['00280034']),
|
||||
bitsAllocated: DICOMWeb.getNumber(instance['00280100']),
|
||||
bitsStored: DICOMWeb.getNumber(instance['00280101']),
|
||||
highBit: DICOMWeb.getNumber(instance['00280102']),
|
||||
pixelRepresentation: DICOMWeb.getNumber(instance['00280103']),
|
||||
smallestPixelValue: DICOMWeb.getNumber(instance['00280106']),
|
||||
largestPixelValue: DICOMWeb.getNumber(instance['00280107']),
|
||||
windowCenter: DICOMWeb.getString(instance['00281050']),
|
||||
windowWidth: DICOMWeb.getString(instance['00281051']),
|
||||
rescaleIntercept: DICOMWeb.getNumber(instance['00281052']),
|
||||
rescaleSlope: DICOMWeb.getNumber(instance['00281053']),
|
||||
rescaleType: DICOMWeb.getNumber(instance['00281054']),
|
||||
sourceImageInstanceUid: getSourceImageInstanceUid(instance),
|
||||
laterality: DICOMWeb.getString(instance['00200062']),
|
||||
viewPosition: DICOMWeb.getString(instance['00185101']),
|
||||
acquisitionDateTime: DICOMWeb.getString(instance['0008002A']),
|
||||
numberOfFrames: DICOMWeb.getNumber(instance['00280008']),
|
||||
frameIncrementPointer: getFrameIncrementPointer(instance['00280009']),
|
||||
frameTime: DICOMWeb.getNumber(instance['00181063']),
|
||||
frameTimeVector: parseFloatArray(DICOMWeb.getString(instance['00181065'])),
|
||||
sliceThickness: DICOMWeb.getNumber(instance['00180050']),
|
||||
spacingBetweenSlices: DICOMWeb.getString(instance['00180088']),
|
||||
lossyImageCompression: DICOMWeb.getString(instance['00282110']),
|
||||
derivationDescription: DICOMWeb.getString(instance['00282111']),
|
||||
lossyImageCompressionRatio: DICOMWeb.getString(instance['00282112']),
|
||||
lossyImageCompressionMethod: DICOMWeb.getString(instance['00282114']),
|
||||
echoNumber: DICOMWeb.getString(instance['00180086']),
|
||||
contrastBolusAgent: DICOMWeb.getString(instance['00180010']),
|
||||
radiopharmaceuticalInfo: getRadiopharmaceuticalInfo(instance),
|
||||
baseWadoRsUri: baseWadoRsUri,
|
||||
wadouri: WADOProxy.convertURL(wadouri, server),
|
||||
wadorsuri: WADOProxy.convertURL(wadorsuri, server),
|
||||
wadoRoot: server.wadoRoot,
|
||||
imageRendering: server.imageRendering,
|
||||
thumbnailRendering: server.thumbnailRendering,
|
||||
};
|
||||
|
||||
// Get additional information if the instance uses "PALETTE COLOR" photometric interpretation
|
||||
if (sopInstance.photometricInterpretation === 'PALETTE COLOR') {
|
||||
const redPaletteColorLookupTableDescriptor = parseFloatArray(
|
||||
DICOMWeb.getString(instance['00281101'])
|
||||
);
|
||||
const greenPaletteColorLookupTableDescriptor = parseFloatArray(
|
||||
DICOMWeb.getString(instance['00281102'])
|
||||
);
|
||||
const bluePaletteColorLookupTableDescriptor = parseFloatArray(
|
||||
DICOMWeb.getString(instance['00281103'])
|
||||
);
|
||||
const palettes = await getPaletteColors(
|
||||
server,
|
||||
instance,
|
||||
redPaletteColorLookupTableDescriptor
|
||||
);
|
||||
|
||||
if (palettes) {
|
||||
if (palettes.uid) {
|
||||
sopInstance.paletteColorLookupTableUID = palettes.uid;
|
||||
}
|
||||
|
||||
sopInstance.redPaletteColorLookupTableData = palettes.red;
|
||||
sopInstance.greenPaletteColorLookupTableData = palettes.green;
|
||||
sopInstance.bluePaletteColorLookupTableData = palettes.blue;
|
||||
sopInstance.redPaletteColorLookupTableDescriptor = redPaletteColorLookupTableDescriptor;
|
||||
sopInstance.greenPaletteColorLookupTableDescriptor = greenPaletteColorLookupTableDescriptor;
|
||||
sopInstance.bluePaletteColorLookupTableDescriptor = bluePaletteColorLookupTableDescriptor;
|
||||
}
|
||||
}
|
||||
|
||||
series.instances.push(sopInstance);
|
||||
return sopInstance;
|
||||
}
|
||||
|
||||
function getPaletteColor(server, instance, tag, lutDescriptor) {
|
||||
const numLutEntries = lutDescriptor[0];
|
||||
const bits = lutDescriptor[2];
|
||||
|
||||
let uri = WADOProxy.convertURL(instance[tag].BulkDataURI, server);
|
||||
|
||||
// TODO: Workaround for dcm4chee behind SSL-terminating proxy returning
|
||||
// incorrect bulk data URIs
|
||||
if (server.wadoRoot.indexOf('https') === 0 && !uri.includes('https')) {
|
||||
uri = uri.replace('http', 'https');
|
||||
}
|
||||
|
||||
const config = {
|
||||
url: server.wadoRoot, //BulkDataURI is absolute, so this isn't used
|
||||
headers: DICOMWeb.getAuthorizationHeader(server),
|
||||
};
|
||||
const dicomWeb = new api.DICOMwebClient(config);
|
||||
const options = {
|
||||
BulkDataURI: uri,
|
||||
};
|
||||
|
||||
const readUInt16 = (byteArray, position) => {
|
||||
return byteArray[position] + byteArray[position + 1] * 256;
|
||||
};
|
||||
|
||||
const arrayBufferToPaletteColorLUT = result => {
|
||||
const arraybuffer = result[0];
|
||||
const byteArray = new Uint8Array(arraybuffer);
|
||||
const lut = [];
|
||||
|
||||
for (let i = 0; i < numLutEntries; i++) {
|
||||
if (bits === 16) {
|
||||
lut[i] = readUInt16(byteArray, i * 2);
|
||||
} else {
|
||||
lut[i] = byteArray[i];
|
||||
}
|
||||
}
|
||||
|
||||
return lut;
|
||||
};
|
||||
|
||||
return dicomWeb.retrieveBulkData(options).then(arrayBufferToPaletteColorLUT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch palette colors for instances with "PALETTE COLOR" photometricInterpretation.
|
||||
*
|
||||
* @param server {Object} Current server;
|
||||
* @param instance {Object} The retrieved instance metadata;
|
||||
* @returns {String} The ReferenceSOPInstanceUID
|
||||
*/
|
||||
async function getPaletteColors(server, instance, lutDescriptor) {
|
||||
let paletteUID = DICOMWeb.getString(instance['00281199']);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let entry;
|
||||
if (paletteColorCache.isValidUID(paletteUID)) {
|
||||
entry = paletteColorCache.get(paletteUID);
|
||||
|
||||
if (entry) {
|
||||
return resolve(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// no entry in cache... Fetch remote data.
|
||||
const r = getPaletteColor(server, instance, '00281201', lutDescriptor);
|
||||
const g = getPaletteColor(server, instance, '00281202', lutDescriptor);
|
||||
const b = getPaletteColor(server, instance, '00281203', lutDescriptor);
|
||||
|
||||
const promises = [r, g, b];
|
||||
|
||||
Promise.all(promises).then(args => {
|
||||
entry = {
|
||||
red: args[0],
|
||||
green: args[1],
|
||||
blue: args[2],
|
||||
};
|
||||
|
||||
// when paletteUID is present, the entry can be cached...
|
||||
entry.uid = paletteUID;
|
||||
paletteColorCache.add(entry);
|
||||
|
||||
resolve(entry);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a list of SOP Instances to a given study object descriptor
|
||||
* @param {Object} server Object with server configuration parameters
|
||||
* @param {Object} study The study descriptor to which the given SOP instances will be added
|
||||
* @param {Array} sopInstanceList A list of SOP instance objects
|
||||
*/
|
||||
async function addInstancesToStudy(server, study, sopInstanceList) {
|
||||
return Promise.all(
|
||||
sopInstanceList.map(function(sopInstance) {
|
||||
return makeSOPInstance(server, study, sopInstance);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const createStudyFromSOPInstanceList = async (server, sopInstanceList) => {
|
||||
if (Array.isArray(sopInstanceList) && sopInstanceList.length > 0) {
|
||||
const firstSopInstance = sopInstanceList[0];
|
||||
const study = createStudy(server, firstSopInstance);
|
||||
await addInstancesToStudy(server, study, sopInstanceList);
|
||||
return study;
|
||||
}
|
||||
throw new Error('Failed to create study out of provided SOP instance list');
|
||||
};
|
||||
|
||||
export { createStudyFromSOPInstanceList, addInstancesToStudy };
|
||||
@@ -1,17 +1,96 @@
|
||||
import getSeriesInfo from './getSeriesInfo';
|
||||
|
||||
/**
|
||||
* Sorts the series and instances inside a study instance by their series
|
||||
* and instance numbers in ascending order.
|
||||
* Series sorting criteria: series considered low priority are moved to the end
|
||||
* of the list and series number is used to break ties
|
||||
* @param {Object} firstSeries
|
||||
* @param {Object} secondSeries
|
||||
*/
|
||||
function seriesInfoSortingCriteria(firstSeries, secondSeries) {
|
||||
const a = getSeriesInfo(firstSeries);
|
||||
const b = getSeriesInfo(secondSeries);
|
||||
if (!a.isLowPriority && b.isLowPriority) {
|
||||
return -1;
|
||||
}
|
||||
if (a.isLowPriority && !b.isLowPriority) {
|
||||
return 1;
|
||||
}
|
||||
return a.seriesNumber - b.seriesNumber;
|
||||
}
|
||||
|
||||
const seriesSortCriteria = {
|
||||
default: (a, b) => a.seriesNumber - b.seriesNumber,
|
||||
seriesInfoSortingCriteria,
|
||||
};
|
||||
|
||||
const instancesSortCriteria = {
|
||||
default: (a, b) => a.instanceNumber - b.instanceNumber,
|
||||
};
|
||||
|
||||
const sortingCriteria = {
|
||||
seriesSortCriteria,
|
||||
instancesSortCriteria,
|
||||
};
|
||||
|
||||
/**
|
||||
* Sorts given seriesList (given param is modified)
|
||||
* The default criteria is based on series number in ascending order.
|
||||
*
|
||||
* @param {Array} seriesList List of series
|
||||
* @param {function} seriesSortingCriteria method for sorting
|
||||
* @returns {Array} sorted seriesList object
|
||||
*/
|
||||
const sortStudySeries = (
|
||||
seriesList,
|
||||
seriesSortingCriteria = seriesSortCriteria.default
|
||||
) => {
|
||||
return seriesList.sort(seriesSortingCriteria);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sorts given instancesList (given param is modified)
|
||||
* The default criteria is based on instance number in ascending order.
|
||||
*
|
||||
* @param {Array} instancesList List of series
|
||||
* @param {function} instancesSortingCriteria method for sorting
|
||||
* @returns {Array} sorted instancesList object
|
||||
*/
|
||||
const sortStudyInstances = (
|
||||
instancesList,
|
||||
instancesSortingCriteria = instancesSortCriteria.default
|
||||
) => {
|
||||
return instancesList.sort(instancesSortingCriteria);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sorts the series and instances (by default) inside a study instance based on sortingCriteria (given param is modified)
|
||||
* The default criteria is based on series and instance numbers in ascending order.
|
||||
*
|
||||
* @param {Object} study The study instance
|
||||
* @param {boolean} [deepSort = true] to sort instance also
|
||||
* @param {function} [seriesSortingCriteria = seriesSortCriteria.default] method for sorting series
|
||||
* @param {function} [instancesSortingCriteria = instancesSortCriteria.default] method for sorting instances
|
||||
* @returns {Object} sorted study object
|
||||
*/
|
||||
export default function sortStudy(study) {
|
||||
export default function sortStudy(
|
||||
study,
|
||||
deepSort = true,
|
||||
seriesSortingCriteria = seriesSortCriteria.default,
|
||||
instancesSortingCriteria = instancesSortCriteria.default
|
||||
) {
|
||||
if (!study || !study.seriesList) {
|
||||
throw new Error('Insufficient study data was provided to sortStudy');
|
||||
}
|
||||
|
||||
study.seriesList.sort((a, b) => a.seriesNumber - b.seriesNumber);
|
||||
sortStudySeries(study.seriesList, seriesSortingCriteria);
|
||||
|
||||
study.seriesList.forEach(series => {
|
||||
series.instances.sort((a, b) => a.instanceNumber - b.instanceNumber);
|
||||
});
|
||||
if (deepSort) {
|
||||
study.seriesList.forEach(series => {
|
||||
sortStudyInstances(series.instances, instancesSortingCriteria);
|
||||
});
|
||||
}
|
||||
|
||||
return study;
|
||||
}
|
||||
|
||||
export { sortStudySeries, sortStudyInstances, sortingCriteria };
|
||||
@@ -0,0 +1,413 @@
|
||||
import DICOMWeb from '../DICOMWeb/';
|
||||
|
||||
const WADOProxy = {
|
||||
convertURL: (url, server) => {
|
||||
// TODO: Remove all WADOProxy stuff from this file
|
||||
return url;
|
||||
},
|
||||
};
|
||||
function parseFloatArray(obj) {
|
||||
const result = [];
|
||||
|
||||
if (!obj) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const objs = obj.split('\\');
|
||||
for (let i = 0; i < objs.length; i++) {
|
||||
result.push(parseFloat(objs[i]));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a plain JS object that describes a study (a study descriptor object)
|
||||
* @param {Object} server Object with server configuration parameters
|
||||
* @param {Object} aSopInstance a SOP Instance from which study information will be added
|
||||
*/
|
||||
function createStudy(server, aSopInstance) {
|
||||
// TODO: Pass a reference ID to the server instead of including the URLs here
|
||||
return {
|
||||
seriesList: [],
|
||||
seriesMap: Object.create(null),
|
||||
seriesLoader: null,
|
||||
wadoUriRoot: server.wadoUriRoot,
|
||||
wadoRoot: server.wadoRoot,
|
||||
qidoRoot: server.qidoRoot,
|
||||
patientName: DICOMWeb.getName(aSopInstance['00100010']),
|
||||
patientId: DICOMWeb.getString(aSopInstance['00100020']),
|
||||
patientAge: DICOMWeb.getNumber(aSopInstance['00101010']),
|
||||
patientSize: DICOMWeb.getNumber(aSopInstance['00101020']),
|
||||
patientWeight: DICOMWeb.getNumber(aSopInstance['00101030']),
|
||||
accessionNumber: DICOMWeb.getString(aSopInstance['00080050']),
|
||||
studyDate: DICOMWeb.getString(aSopInstance['00080020']),
|
||||
modalities: DICOMWeb.getString(aSopInstance['00080061']),
|
||||
studyDescription: DICOMWeb.getString(aSopInstance['00081030']),
|
||||
imageCount: DICOMWeb.getString(aSopInstance['00201208']),
|
||||
studyInstanceUid: DICOMWeb.getString(aSopInstance['0020000D']),
|
||||
institutionName: DICOMWeb.getString(aSopInstance['00080080']),
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns a WADO url for an instance
|
||||
*
|
||||
* @param studyInstanceUid
|
||||
* @param seriesInstanceUid
|
||||
* @param sopInstanceUid
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildInstanceWadoUrl(
|
||||
server,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUid,
|
||||
sopInstanceUid
|
||||
) {
|
||||
// TODO: This can be removed, since DICOMWebClient has the same function. Not urgent, though
|
||||
const params = [];
|
||||
|
||||
params.push('requestType=WADO');
|
||||
params.push(`studyUID=${studyInstanceUid}`);
|
||||
params.push(`seriesUID=${seriesInstanceUid}`);
|
||||
params.push(`objectUID=${sopInstanceUid}`);
|
||||
params.push('contentType=application/dicom');
|
||||
params.push('transferSyntax=*');
|
||||
|
||||
const paramString = params.join('&');
|
||||
|
||||
return `${server.wadoUriRoot}?${paramString}`;
|
||||
}
|
||||
|
||||
function buildInstanceWadoRsUri(
|
||||
server,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUid,
|
||||
sopInstanceUid
|
||||
) {
|
||||
return `${server.wadoRoot}/studies/${studyInstanceUid}/series/${seriesInstanceUid}/instances/${sopInstanceUid}`;
|
||||
}
|
||||
|
||||
function buildInstanceFrameWadoRsUri(
|
||||
server,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUid,
|
||||
sopInstanceUid,
|
||||
frame
|
||||
) {
|
||||
const baseWadoRsUri = buildInstanceWadoRsUri(
|
||||
server,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUid,
|
||||
sopInstanceUid
|
||||
);
|
||||
frame = frame != null || 1;
|
||||
|
||||
return `${baseWadoRsUri}/frames/${frame}`;
|
||||
}
|
||||
|
||||
function getFrameIncrementPointer(element) {
|
||||
const frameIncrementPointerNames = {
|
||||
'00181065': 'frameTimeVector',
|
||||
'00181063': 'frameTime',
|
||||
};
|
||||
|
||||
if (!element || !element.Value || !element.Value.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = element.Value[0];
|
||||
return frameIncrementPointerNames[value];
|
||||
}
|
||||
|
||||
function getRadiopharmaceuticalInfo(instance) {
|
||||
const modality = DICOMWeb.getString(instance['00080060']);
|
||||
|
||||
if (modality !== 'PT') {
|
||||
return;
|
||||
}
|
||||
|
||||
const radiopharmaceuticalInfo = instance['00540016'];
|
||||
if (
|
||||
radiopharmaceuticalInfo === undefined ||
|
||||
!radiopharmaceuticalInfo.Value ||
|
||||
!radiopharmaceuticalInfo.Value.length
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstPetRadiopharmaceuticalInfo = radiopharmaceuticalInfo.Value[0];
|
||||
return {
|
||||
radiopharmaceuticalStartTime: DICOMWeb.getString(
|
||||
firstPetRadiopharmaceuticalInfo['00181072']
|
||||
),
|
||||
radionuclideTotalDose: DICOMWeb.getNumber(
|
||||
firstPetRadiopharmaceuticalInfo['00181074']
|
||||
),
|
||||
radionuclideHalfLife: DICOMWeb.getNumber(
|
||||
firstPetRadiopharmaceuticalInfo['00181075']
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the SourceImageSequence, if it exists, in order
|
||||
* to return a ReferenceSOPInstanceUID. The ReferenceSOPInstanceUID
|
||||
* is used to refer to this image in any accompanying DICOM-SR documents.
|
||||
*
|
||||
* @param instance
|
||||
* @returns {String} The ReferenceSOPInstanceUID
|
||||
*/
|
||||
function getSourceImageInstanceUid(instance) {
|
||||
// TODO= Parse the whole Source Image Sequence
|
||||
// This is a really poor workaround for now.
|
||||
// Later we should probably parse the whole sequence.
|
||||
var SourceImageSequence = instance['00082112'];
|
||||
if (
|
||||
SourceImageSequence &&
|
||||
SourceImageSequence.Value &&
|
||||
SourceImageSequence.Value.length &&
|
||||
SourceImageSequence.Value[0]['00081155'].Value
|
||||
) {
|
||||
return SourceImageSequence.Value[0]['00081155'].Value[0];
|
||||
}
|
||||
}
|
||||
|
||||
async function makeSOPInstance(server, study, instance) {
|
||||
const { studyInstanceUid } = study;
|
||||
const seriesInstanceUid = DICOMWeb.getString(instance['0020000E']);
|
||||
let series = study.seriesMap[seriesInstanceUid];
|
||||
|
||||
if (!series) {
|
||||
series = {
|
||||
seriesInstanceUid,
|
||||
seriesDescription: DICOMWeb.getString(instance['0008103E']),
|
||||
modality: DICOMWeb.getString(instance['00080060']),
|
||||
seriesNumber: DICOMWeb.getNumber(instance['00200011']),
|
||||
seriesDate: DICOMWeb.getString(instance['00080021']),
|
||||
seriesTime: DICOMWeb.getString(instance['00080031']),
|
||||
instances: [],
|
||||
};
|
||||
study.seriesMap[seriesInstanceUid] = series;
|
||||
study.seriesList.push(series);
|
||||
}
|
||||
|
||||
const sopInstanceUid = DICOMWeb.getString(instance['00080018']);
|
||||
const wadouri = buildInstanceWadoUrl(
|
||||
server,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUid,
|
||||
sopInstanceUid
|
||||
);
|
||||
const baseWadoRsUri = buildInstanceWadoRsUri(
|
||||
server,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUid,
|
||||
sopInstanceUid
|
||||
);
|
||||
const wadorsuri = buildInstanceFrameWadoRsUri(
|
||||
server,
|
||||
studyInstanceUid,
|
||||
seriesInstanceUid,
|
||||
sopInstanceUid
|
||||
);
|
||||
|
||||
const sopInstance = {
|
||||
imageType: DICOMWeb.getString(instance['00080008']),
|
||||
sopClassUid: DICOMWeb.getString(instance['00080016']),
|
||||
modality: DICOMWeb.getString(instance['00080060']),
|
||||
sopInstanceUid,
|
||||
instanceNumber: DICOMWeb.getNumber(instance['00200013']),
|
||||
imagePositionPatient: DICOMWeb.getString(instance['00200032']),
|
||||
imageOrientationPatient: DICOMWeb.getString(instance['00200037']),
|
||||
frameOfReferenceUID: DICOMWeb.getString(instance['00200052']),
|
||||
sliceLocation: DICOMWeb.getNumber(instance['00201041']),
|
||||
samplesPerPixel: DICOMWeb.getNumber(instance['00280002']),
|
||||
photometricInterpretation: DICOMWeb.getString(instance['00280004']),
|
||||
planarConfiguration: DICOMWeb.getNumber(instance['00280006']),
|
||||
rows: DICOMWeb.getNumber(instance['00280010']),
|
||||
columns: DICOMWeb.getNumber(instance['00280011']),
|
||||
pixelSpacing: DICOMWeb.getString(instance['00280030']),
|
||||
pixelAspectRatio: DICOMWeb.getString(instance['00280034']),
|
||||
bitsAllocated: DICOMWeb.getNumber(instance['00280100']),
|
||||
bitsStored: DICOMWeb.getNumber(instance['00280101']),
|
||||
highBit: DICOMWeb.getNumber(instance['00280102']),
|
||||
pixelRepresentation: DICOMWeb.getNumber(instance['00280103']),
|
||||
smallestPixelValue: DICOMWeb.getNumber(instance['00280106']),
|
||||
largestPixelValue: DICOMWeb.getNumber(instance['00280107']),
|
||||
windowCenter: DICOMWeb.getString(instance['00281050']),
|
||||
windowWidth: DICOMWeb.getString(instance['00281051']),
|
||||
rescaleIntercept: DICOMWeb.getNumber(instance['00281052']),
|
||||
rescaleSlope: DICOMWeb.getNumber(instance['00281053']),
|
||||
rescaleType: DICOMWeb.getNumber(instance['00281054']),
|
||||
sourceImageInstanceUid: getSourceImageInstanceUid(instance),
|
||||
laterality: DICOMWeb.getString(instance['00200062']),
|
||||
viewPosition: DICOMWeb.getString(instance['00185101']),
|
||||
acquisitionDateTime: DICOMWeb.getString(instance['0008002A']),
|
||||
numberOfFrames: DICOMWeb.getNumber(instance['00280008']),
|
||||
frameIncrementPointer: getFrameIncrementPointer(instance['00280009']),
|
||||
frameTime: DICOMWeb.getNumber(instance['00181063']),
|
||||
frameTimeVector: parseFloatArray(DICOMWeb.getString(instance['00181065'])),
|
||||
sliceThickness: DICOMWeb.getNumber(instance['00180050']),
|
||||
spacingBetweenSlices: DICOMWeb.getString(instance['00180088']),
|
||||
lossyImageCompression: DICOMWeb.getString(instance['00282110']),
|
||||
derivationDescription: DICOMWeb.getString(instance['00282111']),
|
||||
lossyImageCompressionRatio: DICOMWeb.getString(instance['00282112']),
|
||||
lossyImageCompressionMethod: DICOMWeb.getString(instance['00282114']),
|
||||
echoNumber: DICOMWeb.getString(instance['00180086']),
|
||||
contrastBolusAgent: DICOMWeb.getString(instance['00180010']),
|
||||
radiopharmaceuticalInfo: getRadiopharmaceuticalInfo(instance),
|
||||
baseWadoRsUri: baseWadoRsUri,
|
||||
wadouri: WADOProxy.convertURL(wadouri, server),
|
||||
wadorsuri: WADOProxy.convertURL(wadorsuri, server),
|
||||
wadoRoot: server.wadoRoot,
|
||||
imageRendering: server.imageRendering,
|
||||
thumbnailRendering: server.thumbnailRendering,
|
||||
};
|
||||
|
||||
// Get additional information if the instance uses "PALETTE COLOR" photometric interpretation
|
||||
if (sopInstance.photometricInterpretation === 'PALETTE COLOR') {
|
||||
const redPaletteColorLookupTableDescriptor = parseFloatArray(
|
||||
DICOMWeb.getString(instance['00281101'])
|
||||
);
|
||||
const greenPaletteColorLookupTableDescriptor = parseFloatArray(
|
||||
DICOMWeb.getString(instance['00281102'])
|
||||
);
|
||||
const bluePaletteColorLookupTableDescriptor = parseFloatArray(
|
||||
DICOMWeb.getString(instance['00281103'])
|
||||
);
|
||||
const palettes = await getPaletteColors(
|
||||
server,
|
||||
instance,
|
||||
redPaletteColorLookupTableDescriptor
|
||||
);
|
||||
|
||||
if (palettes) {
|
||||
if (palettes.uid) {
|
||||
sopInstance.paletteColorLookupTableUID = palettes.uid;
|
||||
}
|
||||
|
||||
sopInstance.redPaletteColorLookupTableData = palettes.red;
|
||||
sopInstance.greenPaletteColorLookupTableData = palettes.green;
|
||||
sopInstance.bluePaletteColorLookupTableData = palettes.blue;
|
||||
sopInstance.redPaletteColorLookupTableDescriptor = redPaletteColorLookupTableDescriptor;
|
||||
sopInstance.greenPaletteColorLookupTableDescriptor = greenPaletteColorLookupTableDescriptor;
|
||||
sopInstance.bluePaletteColorLookupTableDescriptor = bluePaletteColorLookupTableDescriptor;
|
||||
}
|
||||
}
|
||||
|
||||
series.instances.push(sopInstance);
|
||||
return sopInstance;
|
||||
}
|
||||
|
||||
function getPaletteColor(server, instance, tag, lutDescriptor) {
|
||||
const numLutEntries = lutDescriptor[0];
|
||||
const bits = lutDescriptor[2];
|
||||
|
||||
let uri = WADOProxy.convertURL(instance[tag].BulkDataURI, server);
|
||||
|
||||
// TODO: Workaround for dcm4chee behind SSL-terminating proxy returning
|
||||
// incorrect bulk data URIs
|
||||
if (server.wadoRoot.indexOf('https') === 0 && !uri.includes('https')) {
|
||||
uri = uri.replace('http', 'https');
|
||||
}
|
||||
|
||||
const config = {
|
||||
url: server.wadoRoot, //BulkDataURI is absolute, so this isn't used
|
||||
headers: DICOMWeb.getAuthorizationHeader(server),
|
||||
};
|
||||
const dicomWeb = new api.DICOMwebClient(config);
|
||||
const options = {
|
||||
BulkDataURI: uri,
|
||||
};
|
||||
|
||||
const readUInt16 = (byteArray, position) => {
|
||||
return byteArray[position] + byteArray[position + 1] * 256;
|
||||
};
|
||||
|
||||
const arrayBufferToPaletteColorLUT = result => {
|
||||
const arraybuffer = result[0];
|
||||
const byteArray = new Uint8Array(arraybuffer);
|
||||
const lut = [];
|
||||
|
||||
for (let i = 0; i < numLutEntries; i++) {
|
||||
if (bits === 16) {
|
||||
lut[i] = readUInt16(byteArray, i * 2);
|
||||
} else {
|
||||
lut[i] = byteArray[i];
|
||||
}
|
||||
}
|
||||
|
||||
return lut;
|
||||
};
|
||||
|
||||
return dicomWeb.retrieveBulkData(options).then(arrayBufferToPaletteColorLUT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch palette colors for instances with "PALETTE COLOR" photometricInterpretation.
|
||||
*
|
||||
* @param server {Object} Current server;
|
||||
* @param instance {Object} The retrieved instance metadata;
|
||||
* @returns {String} The ReferenceSOPInstanceUID
|
||||
*/
|
||||
async function getPaletteColors(server, instance, lutDescriptor) {
|
||||
let paletteUID = DICOMWeb.getString(instance['00281199']);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let entry;
|
||||
if (paletteColorCache.isValidUID(paletteUID)) {
|
||||
entry = paletteColorCache.get(paletteUID);
|
||||
|
||||
if (entry) {
|
||||
return resolve(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// no entry in cache... Fetch remote data.
|
||||
const r = getPaletteColor(server, instance, '00281201', lutDescriptor);
|
||||
const g = getPaletteColor(server, instance, '00281202', lutDescriptor);
|
||||
const b = getPaletteColor(server, instance, '00281203', lutDescriptor);
|
||||
|
||||
const promises = [r, g, b];
|
||||
|
||||
Promise.all(promises).then(args => {
|
||||
entry = {
|
||||
red: args[0],
|
||||
green: args[1],
|
||||
blue: args[2],
|
||||
};
|
||||
|
||||
// when paletteUID is present, the entry can be cached...
|
||||
entry.uid = paletteUID;
|
||||
paletteColorCache.add(entry);
|
||||
|
||||
resolve(entry);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a list of SOP Instances to a given study object descriptor
|
||||
* @param {Object} server Object with server configuration parameters
|
||||
* @param {Object} study The study descriptor to which the given SOP instances will be added
|
||||
* @param {Array} sopInstanceList A list of SOP instance objects
|
||||
*/
|
||||
async function addInstancesToStudy(server, study, sopInstanceList) {
|
||||
return Promise.all(
|
||||
sopInstanceList.map(function(sopInstance) {
|
||||
return makeSOPInstance(server, study, sopInstance);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const createStudyFromSOPInstanceList = async (server, sopInstanceList) => {
|
||||
if (Array.isArray(sopInstanceList) && sopInstanceList.length > 0) {
|
||||
const firstSopInstance = sopInstanceList[0];
|
||||
const study = createStudy(server, firstSopInstance);
|
||||
await addInstancesToStudy(server, study, sopInstanceList);
|
||||
return study;
|
||||
}
|
||||
throw new Error('Failed to create study out of provided SOP instance list');
|
||||
};
|
||||
|
||||
export { createStudyFromSOPInstanceList, addInstancesToStudy };
|
||||
@@ -11,6 +11,7 @@ import writeScript from './writeScript.js';
|
||||
import DicomLoaderService from './dicomLoaderService.js';
|
||||
import b64toBlob from './b64toBlob.js';
|
||||
import * as urlUtil from './urlUtil';
|
||||
import makeCancelable from './makeCancelable';
|
||||
|
||||
const utils = {
|
||||
guid,
|
||||
@@ -27,6 +28,7 @@ const utils = {
|
||||
DICOMTagDescriptions,
|
||||
DicomLoaderService,
|
||||
urlUtil,
|
||||
makeCancelable,
|
||||
};
|
||||
|
||||
export {
|
||||
@@ -44,6 +46,7 @@ export {
|
||||
DICOMTagDescriptions,
|
||||
DicomLoaderService,
|
||||
urlUtil,
|
||||
makeCancelable,
|
||||
};
|
||||
|
||||
export default utils;
|
||||
@@ -17,6 +17,7 @@ describe('Top level exports', () => {
|
||||
'DICOMTagDescriptions',
|
||||
'DicomLoaderService',
|
||||
'urlUtil',
|
||||
'makeCancelable',
|
||||
].sort();
|
||||
|
||||
const exports = Object.keys(utils.default).sort();
|
||||
|
||||