Compare commits

...
Author SHA1 Message Date
ohif-bot 86c479ac04 chore(release): publish [skip ci]
- @ohif/extension-vtk@0.52.18
 - @ohif/core@1.5.1
 - @ohif/viewer@1.12.1
2019-10-25 17:51:36 +00:00
James Petts 460e375f0a fix: 🐛 Orthographic MPR fix (#1092)
* fix: 🐛 Orthographic MPR fix
2019-10-25 19:43:34 +02:00
ohif-bot 78344c2c66 chore(release): publish [skip ci]
- @ohif/extension-vtk@0.52.17
 - @ohif/core@1.5.0
 - @ohif/viewer@1.12.0
2019-10-25 13:17:26 +00:00
James Petts b451ce4407 fix: 🐛 Fix crosshairs location when using MIP (#1080) 2019-10-25 15:07:35 +02:00
ladeirarodolfo 21b586b08f feat: 🎸 Allow routes to load Google Cloud DICOM Stores in the Study List (#1069) 2019-10-25 14:49:48 +02:00
ohif-bot 3bbeb1c521 chore(release): publish [skip ci]
- @ohif/viewer@1.11.5
2019-10-24 20:23:16 +00:00
Danny Brown 7668b69f20 test: percy snapshot workaround (#1085)
* Try workaround for percy snapshot

* Return our modified document
2019-10-24 16:09:59 -04:00
Danny Brown 3a12138134 test: Try workaround for percy snapshot (#1084) 2019-10-24 15:54:06 -04:00
Danny Brown 97786b808c ci: revert flags 2019-10-23 12:55:39 -04:00
Danny Brown cd1baa1b36 ci: force flag 2019-10-23 12:41:44 -04:00
Danny Brown 3ae3bee452 ci: test recursive removal 2019-10-23 12:24:56 -04:00
ohif-bot fd017c4512 chore(release): publish [skip ci]
- @ohif/viewer@1.11.4
2019-10-23 15:37:56 +00:00
Danny Brown c680720ce5 fix: Revert "Revert "fix: MPR initialization"" (#1065)
* Revert "Revert "fix: MPR initialization (#1062)" (#1064)"

This reverts commit a6c1e6c717.

* fix: MPR Initialization

* fix e2e openStudy

* add isRequired for numRows and numColumns
2019-10-23 11:27:22 -04:00
ohif-bot dfc4e89a91 chore(release): publish [skip ci]
- @ohif/extension-vtk@0.52.16
 - @ohif/viewer@1.11.3
2019-10-23 10:44:45 +00:00
James Petts 13d337aaab fix: 🐛 Switch to orhtographic view for 2D MPR (#1074) 2019-10-23 12:36:41 +02:00
ohif-bot 4f26940005 chore(release): publish [skip ci]
- @ohif/extension-vtk@0.52.15
 - @ohif/viewer@1.11.2
2019-10-23 07:56:28 +00:00
James Petts 9d08e81aa5 perf: ⚡️ Throttle UI refresh of synced WL value in vtk port (#1070) 2019-10-23 09:45:26 +02:00
ohif-bot 1a539d04c2 chore(release): publish [skip ci]
- @ohif/viewer@1.11.1
2019-10-23 01:12:25 +00:00
Rodrigo Antinarelli 5cc78c867f test: E2E Intermittent Failures (#1071) 2019-10-22 21:04:07 -04:00
ohif-bot cefa480979 chore(release): publish [skip ci]
- @ohif/extension-vtk@0.52.14
 - @ohif/ui@0.56.0
 - @ohif/viewer@1.11.0
2019-10-22 20:24:10 +00:00
Danny Brown 66537bf1b9 ci: specify our custom executor
fixes broken build
2019-10-22 16:15:09 -04:00
Danny Brown 862d426c71 ci: first attempt at adding a gated docker PR Publish (#1038) (#1041)
* ci: first attempt at adding a gated docker PR Publish (#1038)

* ci: first attempt at adding a gated docker PR Publish

* See if we can use contexts to inject variables after approval

* fix build command

* try alternative context

* docs: pull request template that spells out PR checklist

* change context back to Docker Hub

* Switch optional approval to another workflow

* Make docker builds faster

* Update .github/PULL_REQUEST_TEMPLATE/pull_request_template.md
2019-10-22 15:53:44 -04:00
ladeirarodolfo e62f403fe9 feat: 🎸 Load spinner when selecting gcloud store. Add key on td (#1034)
* feat: 🎸 Load spinner when selecting gcloud store. Add key on td

* feat: 🎸 Code review. Translate loading study status

* feat: 🎸 Code review. Minor code refactoring

* fix: add missing study and index declerations

* test: Improviment on VTK beforeEach hook (#1057)

* feat: 🎸 Some improvements on table cell and react lifecycle

* feat: 🎸 Prevent component update on a not searchData changing

* feat: 🎸 Some cypress improv to pass ci

* feat: 🎸 Code reviewe and also fixing list for gcloud

* feat: 🎸 Code review. Add spinner on first load of store

* feat: 🎸 Code review. Centering loading list spinner icon
2019-10-22 14:40:23 -04:00
Mirna Silva dea0ceab57 test: Adding Percy Snapshot on VTK, PDF and Microscopy tests (#1059)
* Adding Percy Snapshot on VTK, PDF and Microscopy tests

* Small improvement to always click on first search result

* Wait for search result

* Added timouts all over the code to avoid intermitted failures in CI

* Increasing Default Timeout and other configs

* Cleaning up the code

* try force enabling gpu

* Try custom executor with newer version of chrome

* Fix based on review comments

* Increased minimum thumbnails expected on VTK test

* Fix config validation

* Make sure we're using the chrome browser
2019-10-21 14:30:50 -04:00
Danny Brown a6c1e6c717 Revert "fix: MPR initialization (#1062)" (#1064)
This reverts commit b03739428f.
2019-10-19 17:00:51 -04:00
Rodrigo Antinarelli b03739428f fix: MPR initialization (#1062)
* fix: MPR initialization

* small refactoring
2019-10-18 14:29:00 -04:00
ohif-bot 2a52d9f05d chore(release): publish [skip ci]
- @ohif/extension-vtk@0.52.13
 - @ohif/viewer@1.10.3
2019-10-18 14:02:07 +00:00
James Petts d5e21bb5bb * chore: 🤖 Update react-vtkjs-viewport and refactor vtk extension
* chore: 🤖 Update react-vtkjs-viewport and refactor vtk extension
2019-10-18 15:53:43 +02:00
ohif-bot 04ffe3ec76 chore(release): publish [skip ci]
- @ohif/extension-vtk@0.52.12
 - @ohif/viewer@1.10.2
2019-10-18 10:16:06 +00:00
James Petts 9cdf8b690b * feat: 🎸 Progressive volume loading for vtk viewport (#1055)
* feat: 🎸 Progressive volume loading for vtk viewport

Adds progressive volume loading for vtkjs viewport whilst the frames are
streamed from PACs and rebuilt in the volume.

Closes: closes #1051

* Update to react-vtkjs-viewport 0.3.0
2019-10-18 11:43:23 +02:00
Mirna Silva 8d49b31d03 test: Improviment on VTK beforeEach hook (#1057) 2019-10-16 15:59:50 -04:00
Danny Brown e4cfae7ad9 docs: add percy badge to readme 2019-10-16 11:51:45 -04:00
ohif-bot e53041f630 chore(release): publish [skip ci]
- @ohif/viewer@1.10.1
2019-10-16 15:46:46 +00:00
Mirna Silva 35c54eac56 test: Test cases for extensions: VTK, HTML and PDF (#1036)
* Test cases for extensions: VTK, HTML and PDF

* Test Cases: Delete measurement, Relabel and Description through Contextmenu

* Fix: PDF, HTML and VTK extensions

* CI: running into different folders for PWA and Script-tag tests

* CI: running into different folders for PWA and Script-tag tests

* CI: running into different folders for PWA and Script-tag tests

* Fix for VTK tests and Cornerstone Eraser tool

* Removed VTK alias from Cornerstone aliases initialization

* Temporary fix for Eraser tool

* Test workaround

* Use juno for vtk

* Shift VTK tests to PWA folder
2019-10-16 11:39:41 -04:00
ohif-bot e7be573328 chore(release): publish [skip ci]
- @ohif/extension-vtk@0.52.11
 - @ohif/i18n@0.51.0
 - @ohif/ui@0.55.0
 - @ohif/viewer@1.10.0
2019-10-15 18:59:20 +00:00
Danny Brown 8a7706b286 test: percy visual testing (#1053)
* test: percy visual testing init

* Try PR checks with custom command

* Only run percy visual tests for PWA on merge for now
2019-10-15 14:52:23 -04:00
Rodrigo Antinarelli c217b8bbde feat: Add browser info and app version (#1046)
* feat: Add browser info and app version

* quick fix

* lock file

* add build_num

* add screenshot e2e test

* quick test build_num

* revert header

* include i18n texts
2019-10-15 13:09:14 -04:00
ohif-bot 74f3cbc4b2 chore(release): publish [skip ci]
- @ohif/viewer@1.9.1
2019-10-15 16:10:17 +00:00
James Petts d091cd604c fix: 🐛 Remove debugger statement left in from last PR (#1052) 2019-10-15 18:03:29 +02:00
ohif-bot 3a4280163d chore(release): publish [skip ci]
- @ohif/extension-vtk@0.52.10
 - @ohif/core@1.4.0
 - @ohif/viewer@1.9.0
2019-10-15 15:18:15 +00:00
James Petts 2d75e01ea0 feat: 🎸 Only allow reconstruction of datasets that make sense (#1010)
* feat: 🎸 Only allow reconstruction of datasets that make sense

Only allow reconstruction of datasets which are imaging data, that have
frames in the same orientation, with the same size and make sense to be
reconstructed in 3D.

Closes: #561
2019-10-15 17:08:53 +02:00
Danny Brown 5c5a49486d docs: more specific location for build output 2019-10-15 08:56:54 -04:00
ohif-bot a8a57132a7 chore(release): publish [skip ci]
- @ohif/extension-vtk@0.52.9
 - @ohif/ui@0.54.0
 - @ohif/viewer@1.8.0
2019-10-14 15:46:13 +00:00
Rodrigo Antinarelli 92c8996ed4 feat: Notification Service (#1011)
* WIP - Notification Service

* add snackbar provider into the main App component

* feat: Notification Service

* revert unmodified file

* Update DicomHtmlViewport.js

* update css classnames

* revert toolbarRow

* fix revert toolbarRow

* styles adjustments and hide method

* remove unecessary default options

* minor fixes

* fix long truncated messages

* move SnackbarContext to ui project, rename, export
2019-10-14 11:39:31 -04:00
ohif-bot f07dca5fa4 chore(release): publish [skip ci]
- @ohif/viewer@1.7.0
2019-10-14 12:50:19 +00:00
Karthik Rao c99e0d8a23 feat: Implement a 'Exit 2D MPR' button in the toolbar
* feat: Implement a 'Exit 2D MPR' button in the toolbar to act as a toggle when '2D MPR' button is clicked

* Move logic to exit MPR and toggling into ConnectedPluginSwitch and PluginSwitch. Also, discard the ConnectedExitPluginSwitch and ExitPluginSwitch components.

* Update ToolbarRow.js
2019-10-14 08:41:29 -04:00
74 changed files with 3692 additions and 1190 deletions

No files matched your search

+66 -4
View File
@@ -13,7 +13,7 @@ version: 2.1
##
orbs:
codecov: codecov/codecov@1.0.5
cypress: cypress-io/cypress@1.10.0
cypress: cypress-io/cypress@1.11.0
defaults: &defaults
docker:
@@ -198,7 +198,7 @@ jobs:
- run: chmod +x ~/repo/.circleci/build-and-publish-docs.sh
- run: ~/repo/.circleci/build-and-publish-docs.sh
DOCKER_PUBLISH:
DOCKER_MASTER_PUBLISH:
<<: *defaults
steps:
- attach_workspace:
@@ -227,6 +227,43 @@ jobs:
docker push ohif/$IMAGE_NAME:latest
fi
DOCKER_PR_PUBLISH:
<<: *defaults
steps:
# 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
- setup_remote_docker:
docker_layer_caching: false
- run:
name: Build and push Docker image
command: |
# Remove npm config
rm -f ./.npmrc
# Set our version number using vars
echo $CIRCLE_BUILD_NUM
# Build our image, auth, and push
docker build --tag ohif/viewer:PR_BUILD-$CIRCLE_BUILD_NUM .
echo $DOCKER_PWD | docker login -u $DOCKER_LOGIN --password-stdin
docker push ohif/viewer:PR_BUILD-$CIRCLE_BUILD_NUM
VIEWER_DEMO_BUILD:
<<: *defaults
steps:
@@ -288,10 +325,13 @@ workflows:
# E2E: PWA
- 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: false
working_directory: platform/viewer
build: npx cross-env QUICK_BUILD=true yarn run build
@@ -307,14 +347,18 @@ workflows:
# 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
@@ -324,6 +368,17 @@ workflows:
requires:
- UNIT_TESTS
PR_OPTIONAL_DOCKER_PUBLISH:
jobs:
# https://circleci.com/docs/2.0/workflows/#holding-a-workflow-for-a-manual-approval
- AWAIT_APPROVAL:
type: approval
# Update hub.docker.org
- DOCKER_PR_PUBLISH:
context: Docker Hub
requires:
- AWAIT_APPROVAL
# MERGE TO MASTER
RELEASE:
jobs:
@@ -334,16 +389,19 @@ workflows:
# 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: 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:
@@ -356,14 +414,18 @@ workflows:
# 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
@@ -393,6 +455,6 @@ workflows:
requires:
- VIEWER_DEMO_BUILD
# Update hub.docker.org
- DOCKER_PUBLISH:
- DOCKER_MASTER_PUBLISH:
requires:
- NPM_PUBLISH
+1
View File
@@ -0,0 +1 @@
PERCY_TOKEN=<your token here>
@@ -0,0 +1,29 @@
### Request 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._
<!--
Links
-->
<!-- prettier-ignore-start -->
[blog]: https://circleci.com/blog/triggering-trusted-ci-jobs-on-untrusted-forks/
[script]: https://github.com/jklukas/git-push-fork-to-upstream-branch
<!-- prettier-ignore-end -->
+1
View File
@@ -21,6 +21,7 @@ npm-debug.log
package-lock.json
yarn-error.log
.DS_Store
.env
# Common Example Data Directories
sampledata/
+10
View File
@@ -1,5 +1,9 @@
// ~~ ENV
const dotenv = require('dotenv');
//
const path = require('path');
const webpack = require('webpack');
const PACKAGE = require('../platform/viewer/package.json');
// ~~ RULES
const loadShadersRule = require('./rules/loadShaders.js');
const loadWebWorkersRule = require('./rules/loadWebWorkers.js');
@@ -9,6 +13,10 @@ const TerserJSPlugin = require('terser-webpack-plugin');
// ~~ ENV VARS
const NODE_ENV = process.env.NODE_ENV;
const QUICK_BUILD = process.env.QUICK_BUILD;
const BUILD_NUM = process.env.CIRCLE_BUILD_NUM || '0';
//
dotenv.config();
module.exports = (env, argv, { SRC_DIR, DIST_DIR }) => {
if (!process.env.NODE_ENV) {
@@ -68,6 +76,8 @@ module.exports = (env, argv, { SRC_DIR, DIST_DIR }) => {
'process.env.DEBUG': JSON.stringify(process.env.DEBUG),
'process.env.APP_CONFIG': JSON.stringify(process.env.APP_CONFIG || ''),
'process.env.PUBLIC_URL': JSON.stringify(process.env.PUBLIC_URL || '/'),
'process.env.VERSION_NUMBER': JSON.stringify(PACKAGE.version || ''),
'process.env.BUILD_NUM': JSON.stringify(BUILD_NUM),
}),
],
// Fix: https://github.com/webpack-contrib/css-loader/issues/447#issuecomment-285598881
+1
View File
@@ -44,6 +44,7 @@ RUN yarn config set workspaces-experimental true
RUN yarn install
ENV PATH /usr/src/app/node_modules/.bin:$PATH
ENV QUICK_BUILD true
# ENV GENERATE_SOURCEMAP=false
# ENV REACT_APP_CONFIG=config/default.js
+3
View File
@@ -26,6 +26,7 @@
[![Netlify Status][netlify-image]][netlify-url]
[![CircleCI][circleci-image]][circleci-url]
[![codecov][codecov-image]][codecov-url]
[![This project is using Percy.io for visual regression testing.][percy-image]](percy-url)
[![All Contributors](https://img.shields.io/badge/all_contributors-10-orange.svg?style=flat-square)](#contributors)
<!-- prettier-ignore-end -->
@@ -288,6 +289,8 @@ MIT © [OHIF](https://github.com/OHIF)
[docker-image-url]: https://hub.docker.com/r/ohif/viewer
[license-image]: https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square
[license-url]: LICENSE
[percy-image]: https://percy.io/static/images/percy-badge.svg
[percy-url]: https://percy.io/Open-Health-Imaging-Foundation/OHIF-Viewer
<!-- Links -->
[monorepo]: https://en.wikipedia.org/wiki/Monorepo
[how-to-fork]: https://help.github.com/en/articles/fork-a-repo
@@ -48,7 +48,7 @@ If everything worked as expected, you should have a new `dist/` directory in the
project's folder. It should roughly resemble the following:
```bash
dist/
<root>platform/viewer/dist/
├── app-config.js
├── app.bundle.js
├── app.css
+92
View File
@@ -3,6 +3,98 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [0.52.18](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.52.17...@ohif/extension-vtk@0.52.18) (2019-10-25)
### Bug Fixes
* 🐛 Orthographic MPR fix ([#1092](https://github.com/OHIF/Viewers/issues/1092)) ([460e375](https://github.com/OHIF/Viewers/commit/460e375f0aa75d35f7a46b4d48e6cc706019956d))
## [0.52.17](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.52.16...@ohif/extension-vtk@0.52.17) (2019-10-25)
### Bug Fixes
* 🐛 Fix crosshairs location when using MIP ([#1080](https://github.com/OHIF/Viewers/issues/1080)) ([b451ce4](https://github.com/OHIF/Viewers/commit/b451ce440796f13b0891739d2130e8ee71993d3d))
## [0.52.16](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.52.15...@ohif/extension-vtk@0.52.16) (2019-10-23)
### Bug Fixes
* 🐛 Switch to orhtographic view for 2D MPR ([#1074](https://github.com/OHIF/Viewers/issues/1074)) ([13d337a](https://github.com/OHIF/Viewers/commit/13d337aaabb8dadf6366c6262c5e47e7781edd08))
## [0.52.15](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.52.14...@ohif/extension-vtk@0.52.15) (2019-10-23)
### Performance Improvements
* ⚡️ Throttle UI refresh of synced WL value in vtk port ([#1070](https://github.com/OHIF/Viewers/issues/1070)) ([9d08e81](https://github.com/OHIF/Viewers/commit/9d08e81aa5def63a5e34f464aae099f7c29f0ac5))
## [0.52.14](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.52.13...@ohif/extension-vtk@0.52.14) (2019-10-22)
**Note:** Version bump only for package @ohif/extension-vtk
## [0.52.13](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.52.12...@ohif/extension-vtk@0.52.13) (2019-10-18)
**Note:** Version bump only for package @ohif/extension-vtk
## [0.52.12](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.52.11...@ohif/extension-vtk@0.52.12) (2019-10-18)
**Note:** Version bump only for package @ohif/extension-vtk
## [0.52.11](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.52.10...@ohif/extension-vtk@0.52.11) (2019-10-15)
**Note:** Version bump only for package @ohif/extension-vtk
## [0.52.10](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.52.9...@ohif/extension-vtk@0.52.10) (2019-10-15)
**Note:** Version bump only for package @ohif/extension-vtk
## [0.52.9](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.52.8...@ohif/extension-vtk@0.52.9) (2019-10-14)
**Note:** Version bump only for package @ohif/extension-vtk
## [0.52.8](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@0.52.7...@ohif/extension-vtk@0.52.8) (2019-10-14)
**Note:** Version bump only for package @ohif/extension-vtk
+5 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@ohif/extension-vtk",
"version": "0.52.8",
"version": "0.52.18",
"description": "OHIF extension for VTK.js",
"author": "OHIF",
"license": "MIT",
@@ -48,11 +48,12 @@
},
"dependencies": {
"@babel/runtime": "^7.5.5",
"react-vtkjs-viewport": "^0.1.6"
"lodash.throttle": "^4.1.1",
"react-vtkjs-viewport": "^0.3.7"
},
"devDependencies": {
"@ohif/core": "^1.3.2",
"@ohif/ui": "^0.53.4",
"@ohif/core": "^1.5.1",
"@ohif/ui": "^0.56.0",
"cornerstone-tools": "^4.0.9",
"cornerstone-wado-image-loader": "^3.0.0",
"dcmjs": "^0.6.1",
+7 -7
View File
@@ -1,6 +1,6 @@
import OHIF from "@ohif/core";
import { View2D } from "react-vtkjs-viewport";
import { connect } from "react-redux";
import OHIF from '@ohif/core';
import { View2D } from 'react-vtkjs-viewport';
import { connect } from 'react-redux';
const { setViewportActive, setViewportSpecificData } = OHIF.redux.actions;
@@ -24,7 +24,7 @@ const mapStateToProps = (state, ownProps) => {
// Hopefully this doesn't break anything under the hood for this one
// activeTool: activeButton && activeButton.command,
...dataFromStore,
enableStackPrefetch: isActive
enableStackPrefetch: isActive,
};
};
@@ -38,7 +38,7 @@ const mapDispatchToProps = (dispatch, ownProps) => {
setViewportSpecificData: data => {
dispatch(setViewportSpecificData(viewportIndex, data));
}
},
};
};
@@ -63,10 +63,10 @@ const mergeProps = (propsFromState, propsFromDispatch, ownProps) => {
// Store the API details for later
//setViewportSpecificData({ vtkApi: api });
if (afterCreation && typeof afterCreation === "function") {
if (afterCreation && typeof afterCreation === 'function') {
afterCreation(api);
}
}
},
};
return props;
};
+4 -4
View File
@@ -3,15 +3,15 @@
}
.loadingIndicator {
background-color: rgba(0, 0, 0, 0.75);
font-size: 18px;
height: 100%;
background-color: rgba(0, 0, 0, 0);
font-size: 8px;
height: 20%;
overflow: hidden;
position: absolute;
top: 0;
width: 100%;
z-index: 1;
/* Necessary for click-through to cornerstone element below */
/* Necessary for click-through to vtkjs element below */
pointer-events: none;
}
+4 -3
View File
@@ -1,7 +1,7 @@
import './LoadingIndicator.css';
import React, { PureComponent } from 'react';
import { withTranslation } from 'react-i18next';
import PropTypes from 'prop-types';
class LoadingIndicator extends PureComponent {
@@ -35,7 +35,8 @@ class LoadingIndicator extends PureComponent {
<div className="imageViewerLoadingIndicator loadingIndicator">
<div className="indicatorContents">
<p>
Loading... <i className="fa fa-spin fa-circle-o-notch fa-fw" />{' '}
{this.props.t('Reformatting')}...
<i className="fa fa-spin fa-circle-o-notch fa-fw" />
{percComplete}
</p>
</div>
@@ -46,4 +47,4 @@ class LoadingIndicator extends PureComponent {
}
}
export default LoadingIndicator;
export default withTranslation('Common')(LoadingIndicator);
+107 -52
View File
@@ -11,7 +11,6 @@ import vtkDataArray from 'vtk.js/Sources/Common/Core/DataArray';
import vtkImageData from 'vtk.js/Sources/Common/DataModel/ImageData';
import vtkVolume from 'vtk.js/Sources/Rendering/Core/Volume';
import vtkVolumeMapper from 'vtk.js/Sources/Rendering/Core/VolumeMapper';
import vtkViewportSubscriptionManager from './utils/vtkViewportSubscriptionManager.js';
const { StackManager } = OHIF.utils;
@@ -172,56 +171,58 @@ class OHIFVTKViewport extends Component {
default:
imageDataObject = getImageData(stack.imageIds, displaySetInstanceUid);
const loadImageDataPromise = loadImageData(imageDataObject);
return loadImageDataPromise.then(() => {
return {
data: imageDataObject.vtkImageData,
};
});
return imageDataObject;
}
};
getOrCreateVolume(data, displaySetInstanceUid) {
getOrCreateVolume(imageDataObject, displaySetInstanceUid) {
if (volumeCache[displaySetInstanceUid]) {
return volumeCache[displaySetInstanceUid];
}
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 volumeActor = vtkVolume.newInstance();
const volumeMapper = vtkVolumeMapper.newInstance();
volumeActor.setMapper(volumeMapper);
volumeMapper.setInputData(data);
volumeMapper.setInputData(vtkImageData);
const range = data
.getPointData()
.getScalars()
.getRange();
// TODO: For PET we might want to just set this to 0-5 SUV
volumeActor
.getProperty()
.getRGBTransferFunction(0)
.setRange(range[0], range[1]);
.setRange(lower, upper);
// TODO: Should look into implementing autoAdjustSampleDistance in vtk
const sampleDistance =
1.2 *
Math.sqrt(
data
.getSpacing()
.map(v => v * v)
.reduce((a, b) => a + b, 0)
);
const spacing = vtkImageData.getSpacing();
// Set the sample distance to half the mean length of one side. This is where the divide by 6 comes from.
// https://github.com/Kitware/VTK/blob/6b559c65bb90614fb02eb6d1b9e3f0fca3fe4b0b/Rendering/VolumeOpenGL2/vtkSmartVolumeMapper.cxx#L344
const sampleDistance = (spacing[0] + spacing[1] + spacing[2]) / 6;
volumeMapper.setSampleDistance(sampleDistance);
// Be generous to surpress warnings, as the logging really hurts performance.
// TODO: maybe we should auto adjust samples to 1000.
volumeMapper.setMaximumSamplesPerRay(4000);
volumeCache[displaySetInstanceUid] = volumeActor;
return volumeActor;
}
async setStateFromProps() {
setStateFromProps() {
const { studies, displaySet } = this.props.viewportData;
const {
studyInstanceUid,
@@ -238,8 +239,7 @@ class OHIFVTKViewport extends Component {
}
const sopClassUid = sopClassUids[0];
let { data, labelmap } = await this.getViewportData(
const imageDataObject = this.getViewportData(
studies,
studyInstanceUid,
displaySetInstanceUid,
@@ -248,29 +248,51 @@ class OHIFVTKViewport extends Component {
frameIndex
);
this.imageDataObject = imageDataObject;
// TODO: Temporarily disabling this since it is not yet
// being used and hurts performance significantly.
/*if (!labelmap) {
labelmap = createLabelMapImageData(data);
}*/
const volumeActor = this.getOrCreateVolume(data, displaySetInstanceUid);
const volumeActor = this.getOrCreateVolume(
imageDataObject,
displaySetInstanceUid
);
this.setState({
volumes: [volumeActor],
paintFilterBackgroundImageData: data,
paintFilterLabelMapImageData: labelmap,
paintFilterBackgroundImageData: imageDataObject.vtkImageData,
paintFilterLabelMapImageData: null, // TODO
percentComplete: 0,
});
this.setState(
{
paintFilterBackgroundImageData: imageDataObject.vtkImageData,
paintFilterLabelMapImageData: null, // TODO
percentComplete: 0,
},
() => {
this.loadProgressively(imageDataObject);
// TODO: There must be a better way to do this.
// We do this so that if all the data is available the react-vtkjs-viewport
// Will render _something_ before the volumes are set and the volume
// Construction that happens in react-vtkjs-viewport locks up the CPU.
setTimeout(() => {
this.setState({
volumes: [volumeActor],
});
}, 200);
}
);
}
componentDidMount() {
this.setStateFromProps();
}
componentWillUnmount() {
vtkViewportSubscriptionManager.unsubscribe(this.props.viewportIndex);
}
componentDidUpdate(prevProps) {
const { studies, displaySet } = this.props.viewportData;
const prevDisplaySet = prevProps.viewportData.displaySet;
@@ -285,6 +307,37 @@ class OHIFVTKViewport extends Component {
}
}
loadProgressively(imageDataObject) {
loadImageData(imageDataObject);
const { isLoading, insertPixelDataPromises } = imageDataObject;
const numberOfFrames = insertPixelDataPromises.length;
if (!isLoading) {
this.setState({ isLoaded: true });
return;
}
insertPixelDataPromises.forEach(promise => {
promise.then(numberProcessed => {
const percentComplete = Math.floor(
(numberProcessed * 100) / numberOfFrames
);
if (percentComplete !== this.state.percentComplete) {
this.setState({
percentComplete,
});
}
});
});
Promise.all(insertPixelDataPromises).then(() => {
this.setState({ isLoaded: true });
});
}
render() {
let childrenWithProps = null;
@@ -302,21 +355,23 @@ class OHIFVTKViewport extends Component {
return (
<>
{this.state.volumes ? (
<ConnectedVTKViewport
volumes={this.state.volumes}
paintFilterLabelMapImageData={
this.state.paintFilterLabelMapImageData
}
paintFilterBackgroundImageData={
this.state.paintFilterBackgroundImageData
}
viewportIndex={this.props.viewportIndex}
/>
) : (
<div style={style}>
<LoadingIndicator />
</div>
<div style={style}>
{!this.state.isLoaded && (
<LoadingIndicator percentComplete={this.state.percentComplete} />
)}
{this.state.volumes && (
<ConnectedVTKViewport
volumes={this.state.volumes}
paintFilterLabelMapImageData={
this.state.paintFilterLabelMapImageData
}
paintFilterBackgroundImageData={
this.state.paintFilterBackgroundImageData
}
viewportIndex={this.props.viewportIndex}
/>
)}
</div>
)}
{childrenWithProps}
</>
+114 -175
View File
@@ -4,66 +4,17 @@ import {
vtkInteractorStyleMPRSlice,
vtkInteractorStyleMPRRotate,
vtkSVGCrosshairsWidget,
vtkSVGWidgetManager,
} from 'react-vtkjs-viewport';
import setMPRLayout from './utils/setMPRLayout.js';
import setViewportToVTK from './utils/setViewportToVTK.js';
import vtkViewportSubscriptionManager from './utils/vtkViewportSubscriptionManager.js';
import vtkCoordinate from 'vtk.js/Sources/Rendering/Core/Coordinate';
import vtkMath from 'vtk.js/Sources/Common/Core/Math';
import vtkMatrixBuilder from 'vtk.js/Sources/Common/Core/MatrixBuilder';
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 = {};
let currentSlabThickness = 0.1;
let defaultSlabThickness = 0.1;
function getCrosshairCallbackForIndex(index) {
return ({ worldPos }) => {
// Set camera focal point to world coordinate for linked views
apis.forEach((api, viewportIndex) => {
if (viewportIndex !== index) {
// We are basically doing the same as getSlice but with the world coordinate
// that we want to jump to instead of the camera focal point.
// I would rather do the camera adjustment directly but I keep
// doing it wrong and so this is good enough for now.
const renderWindow = api.genericRenderWindow.getRenderWindow();
const istyle = renderWindow.getInteractor().getInteractorStyle();
const sliceNormal = istyle.getSliceNormal();
const transform = vtkMatrixBuilder
.buildFromDegree()
.identity()
.rotateFromDirections(sliceNormal, [1, 0, 0]);
const mutatedWorldPos = worldPos.slice();
transform.apply(mutatedWorldPos);
const slice = mutatedWorldPos[0];
istyle.setSlice(slice);
renderWindow.render();
}
const renderer = api.genericRenderWindow.getRenderer();
const wPos = vtkCoordinate.newInstance();
wPos.setCoordinateSystemToWorld();
wPos.setValue(worldPos);
const displayPosition = wPos.getComputedDisplayValue(renderer);
const { svgWidgetManager } = api;
api.svgWidgets.crosshairsWidget.setPoint(
displayPosition[0],
displayPosition[1]
);
svgWidgetManager.render();
});
};
}
async function _getActiveViewportVTKApi(viewports) {
const {
@@ -105,8 +56,6 @@ async function _getActiveViewportVTKApi(viewports) {
function _setView(api, sliceNormal, viewUp) {
const renderWindow = api.genericRenderWindow.getRenderWindow();
const renderer = api.genericRenderWindow.getRenderer();
const camera = renderer.getActiveCamera();
const istyle = renderWindow.getInteractor().getInteractorStyle();
istyle.setSliceNormal(...sliceNormal);
istyle.setViewUp(...viewUp);
@@ -114,41 +63,6 @@ function _setView(api, sliceNormal, viewUp) {
renderWindow.render();
}
function switchMPRInteractors(api, istyle) {
const renderWindow = api.genericRenderWindow.getRenderWindow();
const renderer = api.genericRenderWindow.getRenderer();
const camera = renderer.getActiveCamera();
const currentIStyle = renderWindow.getInteractor().getInteractorStyle();
let currentViewport;
if (currentIStyle.getViewport && istyle.getViewport) {
currentViewport = currentIStyle.getViewport();
}
let currentSlabThickness;
if (currentIStyle.getSlabThickness && istyle.getSlabThickness) {
currentSlabThickness = currentIStyle.getSlabThickness();
defaultSlabThickness = currentSlabThickness;
}
renderWindow.getInteractor().setInteractorStyle(istyle);
// TODO: Not sure why this is required the second time this function is called
istyle.setInteractor(renderWindow.getInteractor());
if (currentViewport) {
istyle.setViewport(currentViewport);
}
if (istyle.getVolumeMapper() !== api.volumes[0]) {
if (currentSlabThickness) {
istyle.setSlabThickness(currentSlabThickness);
}
istyle.setVolumeMapper(api.volumes[0]);
}
}
const actions = {
axial: async ({ viewports }) => {
const api = await _getActiveViewportVTKApi(viewports);
@@ -175,52 +89,56 @@ const actions = {
apis.forEach(api => {
const istyle = vtkInteractorStyleMPRRotate.newInstance();
switchMPRInteractors(api, istyle);
api.setInteractorStyle({ istyle });
});
},
enableCrosshairsTool: () => {
apis.forEach((api, index) => {
apis.forEach((api, apiIndex) => {
const istyle = vtkInteractorStyleMPRCrosshairs.newInstance();
switchMPRInteractors(api, istyle);
istyle.setCallback(getCrosshairCallbackForIndex(index));
api.setInteractorStyle({
istyle,
configuration: { apis, apiIndex },
});
});
},
enableLevelTool: () => {
function updateVOI(apis, windowWidth, windowCenter) {
apis.forEach(api => {
api.updateVOI(windowWidth, windowCenter);
});
}
const throttledUpdateVOIs = throttle(updateVOI, 16, { trailing: true }); // ~ 60 fps
const callbacks = {
setOnLevelsChanged: ({ windowCenter, windowWidth }) => {
apis.forEach(api => {
const renderWindow = api.genericRenderWindow.getRenderWindow();
renderWindow.render();
});
throttledUpdateVOIs(apis, windowWidth, windowCenter);
},
};
apis.forEach(api => {
const istyle = vtkInteractorStyleMPRWindowLevel.newInstance();
switchMPRInteractors(api, istyle);
api.setInteractorStyle({ istyle, callbacks });
});
},
setSlabThickness: ({ slabThickness }) => {
currentSlabThickness = slabThickness;
apis.forEach(api => {
const renderWindow = api.genericRenderWindow.getRenderWindow();
const istyle = renderWindow.getInteractor().getInteractorStyle();
if (istyle.setSlabThickness) {
istyle.setSlabThickness(currentSlabThickness);
}
renderWindow.render();
api.setSlabThickness(slabThickness);
});
},
changeSlabThickness: ({ change }) => {
currentSlabThickness += change;
currentSlabThickness = Math.max(currentSlabThickness, 0.1);
apis.forEach(api => {
const renderWindow = api.genericRenderWindow.getRenderWindow();
const istyle = renderWindow.getInteractor().getInteractorStyle();
const slabThickness = Math.max(api.getSlabThickness() + change, 0.1);
if (istyle.setSlabThickness) {
istyle.setSlabThickness(currentSlabThickness);
}
renderWindow.render();
api.setSlabThickness(slabThickness);
});
},
setBlendModeToComposite: () => {
@@ -228,13 +146,15 @@ const actions = {
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(defaultSlabThickness);
istyle.setSlabThickness(slabThickness);
}
renderWindow.render();
});
@@ -252,7 +172,6 @@ const actions = {
setBlendMode: ({ blendMode }) => {
apis.forEach(api => {
const renderWindow = api.genericRenderWindow.getRenderWindow();
const istyle = renderWindow.getInteractor().getInteractorStyle();
api.volumes[0].getMapper().setBlendMode(blendMode);
@@ -260,78 +179,60 @@ const actions = {
});
},
mpr2d: async ({ viewports }) => {
// TODO push a lot of this backdoor logic lower down to the library level.
const displaySet =
viewports.viewportSpecificData[viewports.activeViewportIndex];
let apiByViewport;
// 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 {
apiByViewport = await setMPRLayout(displaySet);
apis = await setMPRLayout(displaySet, viewportProps, 1, 3);
} catch (error) {
throw new Error(error);
}
apis = apiByViewport;
if (cornerstoneVOI) {
setVOI(cornerstoneVOI);
}
const rgbTransferFunction = apiByViewport[0].volumes[0]
.getProperty()
.getRGBTransferFunction(0);
const onModifiedSubscription = rgbTransferFunction.onModified(() => {
apiByViewport.forEach(a => {
const renderWindow = a.genericRenderWindow.getRenderWindow();
renderWindow.render();
});
});
vtkViewportSubscriptionManager.pushSubscription(0, onModifiedSubscription);
apiByViewport.forEach((api, index) => {
const renderWindow = api.genericRenderWindow.getRenderWindow();
const renderer = api.genericRenderWindow.getRenderer();
const camera = renderer.getActiveCamera();
// Add widgets and set default interactorStyle of each viewport.
apis.forEach((api, apiIndex) => {
api.addSVGWidget(
vtkSVGCrosshairsWidget.newInstance(),
'crosshairsWidget'
);
const istyle = vtkInteractorStyleMPRCrosshairs.newInstance();
renderWindow.getInteractor().setInteractorStyle(istyle);
istyle.setVolumeMapper(api.volumes[0]);
istyle.setCallback(getCrosshairCallbackForIndex(index));
const svgWidgetManager = vtkSVGWidgetManager.newInstance();
svgWidgetManager.setRenderer(renderer);
svgWidgetManager.setScale(1);
const crosshairsWidget = vtkSVGCrosshairsWidget.newInstance();
svgWidgetManager.addWidget(crosshairsWidget);
svgWidgetManager.render();
api.svgWidgetManager = svgWidgetManager;
api.svgWidgets = {
crosshairsWidget,
};
switch (index) {
default:
case 0:
//Axial
istyle.setSliceNormal(0, 0, 1);
istyle.setViewUp(0, -1, 0);
break;
case 1:
// sagittal
istyle.setSliceNormal(1, 0, 0);
istyle.setViewUp(0, 0, 1);
break;
case 2:
// Coronal
istyle.setSliceNormal(0, 1, 0);
istyle.setViewUp(0, 0, 1);
break;
}
renderWindow.render();
api.setInteractorStyle({
istyle,
configuration: { apis, apiIndex },
});
});
},
};
@@ -421,3 +322,41 @@ export default {
definitions,
defaultContext: 'ACTIVE_VIEWPORT::VTK',
};
function getVOIFromCornerstoneViewport(displaySet) {
const cornerstoneElement = cornerstone.getEnabledElement(displaySet.dom);
if (cornerstoneElement) {
const imageId = cornerstoneElement.image.imageId;
const { modality } = cornerstone.metaData.get(
'generalSeriesModule',
imageId
);
if (modality !== 'PT') {
const { windowWidth, windowCenter } = cornerstoneElement.viewport.voi;
return {
windowWidth,
windowCenter,
};
}
}
}
function setVOI(voi) {
const { windowWidth, windowCenter } = voi;
const lower = windowCenter - windowWidth / 2.0;
const upper = windowCenter + windowWidth / 2.0;
const rgbTransferFunction = apis[0].volumes[0]
.getProperty()
.getRGBTransferFunction(0);
rgbTransferFunction.setRange(lower, upper);
apis.forEach(api => {
api.updateVOI(windowWidth, windowCenter);
});
}
+17 -4
View File
@@ -1,11 +1,23 @@
import setLayoutAndViewportData from './setLayoutAndViewportData.js';
export default function setMPRLayout(displaySet) {
export default function setMPRLayout(
displaySet,
viewportPropsArray,
numRows = 1,
numColumns = 1
) {
return new Promise((resolve, reject) => {
const viewports = [];
const numRows = 1;
const numColumns = 3;
const numViewports = numRows * numColumns;
if (viewportPropsArray && viewportPropsArray.length !== numViewports) {
reject(
new Error(
'viewportProps is supplied but its length is not equal to numViewports'
)
);
}
const viewportSpecificData = {};
for (let i = 0; i < numViewports; i++) {
@@ -17,8 +29,8 @@ export default function setMPRLayout(displaySet) {
const apis = [];
viewports.forEach((viewport, index) => {
apis[index] = null;
const viewportProps = viewportPropsArray[index];
viewports[index] = Object.assign({}, viewports[index], {
// plugin: 'vtk',
vtk: {
mode: 'mpr', // TODO: not used
afterCreation: api => {
@@ -28,6 +40,7 @@ export default function setMPRLayout(displaySet) {
resolve(apis);
}
},
...viewportProps,
},
});
});
@@ -1,28 +0,0 @@
const subscriptions = [];
// This is pretty hacky right now, but it makes sure we don't keep adding subscriptions.
// TODO -> Nuke this and move it up a layer once we have more vigorous layout support.
const vtkViewportSubscriptionManager = {
subscriptions,
pushSubscription(viewportIndex, subscription) {
if (!Array.isArray(subscriptions[viewportIndex])) {
subscriptions[viewportIndex] = [];
}
subscriptions[viewportIndex].push(subscription);
},
unsubscribe(viewportIndex) {
if (!subscriptions[viewportIndex]) {
return;
}
while (subscriptions[viewportIndex].length) {
subscriptions[viewportIndex].pop().unsubscribe();
}
subscriptions[viewportIndex] = null;
},
};
export default vtkViewportSubscriptionManager;
+5
View File
@@ -26,6 +26,7 @@
"test:unit": "jest --collectCoverage",
"test:unit:ci": "lerna run test:unit:ci --parallel --stream",
"test:e2e": "lerna run test:e2e --stream",
"test:e2e:ci": "lerna run test:e2e:ci --stream",
"test:e2e:dist": "lerna run test:e2e:dist --stream",
"test:e2e:serve": "lerna run test:e2e:serve --stream",
"see-changed": "lerna changed",
@@ -49,6 +50,7 @@
"@babel/plugin-transform-runtime": "^7.5.0",
"@babel/preset-env": "^7.5.0",
"@babel/preset-react": "^7.0.0",
"@percy/cypress": "^2.2.0",
"babel-eslint": "9.x",
"babel-loader": "^8.0.6",
"babel-plugin-inline-react-svg": "1.1.0",
@@ -57,12 +59,15 @@
"copy-webpack-plugin": "^5.0.3",
"cross-env": "^5.2.0",
"css-loader": "^3.2.0",
"dotenv": "^8.1.0",
"eslint": "5.16.0",
"eslint-config-prettier": "^6.4.0",
"eslint-config-react-app": "^4.0.1",
"eslint-plugin-flowtype": "2.x",
"eslint-plugin-import": "2.x",
"eslint-plugin-jsx-a11y": "6.x",
"eslint-plugin-node": "^9.1.0",
"eslint-plugin-prettier": "^3.1.1",
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-react": "7.x",
"eslint-plugin-react-hooks": "1.5.0",
+33
View File
@@ -3,6 +3,39 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.5.1](https://github.com/OHIF/Viewers/compare/@ohif/core@1.5.0...@ohif/core@1.5.1) (2019-10-25)
### Bug Fixes
* 🐛 Orthographic MPR fix ([#1092](https://github.com/OHIF/Viewers/issues/1092)) ([460e375](https://github.com/OHIF/Viewers/commit/460e375f0aa75d35f7a46b4d48e6cc706019956d))
# [1.5.0](https://github.com/OHIF/Viewers/compare/@ohif/core@1.4.0...@ohif/core@1.5.0) (2019-10-25)
### Features
* 🎸 Allow routes to load Google Cloud DICOM Stores in the Study List ([#1069](https://github.com/OHIF/Viewers/issues/1069)) ([21b586b](https://github.com/OHIF/Viewers/commit/21b586b08f3dde6613859712a9e0577dece564db))
# [1.4.0](https://github.com/OHIF/Viewers/compare/@ohif/core@1.3.2...@ohif/core@1.4.0) (2019-10-15)
### Features
* 🎸 Only allow reconstruction of datasets that make sense ([#1010](https://github.com/OHIF/Viewers/issues/1010)) ([2d75e01](https://github.com/OHIF/Viewers/commit/2d75e01)), closes [#561](https://github.com/OHIF/Viewers/issues/561)
## [1.3.2](https://github.com/OHIF/Viewers/compare/@ohif/core@1.3.1...@ohif/core@1.3.2) (2019-10-14)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ohif/core",
"version": "1.3.2",
"version": "1.5.1",
"description": "Generic business logic for web-based medical imaging applications",
"author": "OHIF Core Team",
"license": "MIT",
@@ -9,6 +9,7 @@ import { SeriesMetadata } from './SeriesMetadata';
import { api } from 'dicomweb-client';
// - createStacks
import { isImage } from '../../utils/isImage';
import isDisplaySetReconstructable from '../../utils/isDisplaySetReconstructable';
import isLowPriorityModality from '../../utils/isLowPriorityModality';
export class StudyMetadata extends Metadata {
@@ -146,6 +147,7 @@ export class StudyMetadata extends Metadata {
if (isMultiFrame(instance)) {
displaySet = makeDisplaySet(series, [instance]);
displaySet.setAttributes({
sopClassUids,
isClip: true,
@@ -620,6 +622,16 @@ const makeDisplaySet = (series, instances) => {
imageSet.getImage(0).getRawValue('x00200013')
);
const isReconstructable = isDisplaySetReconstructable(series, instances);
imageSet.isReconstructable = isReconstructable.value;
if (isReconstructable.missingFrames) {
// TODO -> This is currently unused, but may be used for reconstructing
// Volumes with gaps later on.
imageSet.missingFrames = isReconstructable.missingFrames;
}
return imageSet;
};
@@ -0,0 +1,157 @@
/**
* Checks if a series is reconstructable to a 3D volume.
*
* @param {Object} series The `OHIFSeriesMetadata` object.
* @param {Object[]} instances The `OHIFInstanceMetadata` object
*/
export default function isDisplaySetReconstructable(series, instances) {
// Can't reconstruct if we only have one image.
const modality = series._data.modality; // TODO -> Is there a better way to get this?
const isMultiframe = instances[0].getRawValue('x00280008') > 1;
if (!constructableModalities.includes(modality)) {
return { value: false };
}
if (!isMultiframe && instances.length === 1) {
return { values: false };
}
if (isMultiframe) {
return processMultiframe(instances[0]);
} else {
return processSingleframe(instances);
}
}
function processMultiframe(instance) {
//TODO: deal with multriframe checks! return true for now.
return { value: true };
}
function processSingleframe(instances) {
const firstImage = instances[0];
const firstImageRows = firstImage.getTagValue('x00280010');
const firstImageColumns = firstImage.getTagValue('x00280011');
const firstImageSamplesPerPixel = firstImage.getTagValue('x00280002');
// Note: No need to unpack iop, can compare string form.
const firstImageOrientationPatient = firstImage.getTagValue('x00200037');
// Can't reconstruct if we:
// -- Have a different dimensions within a displaySet.
// -- Have a different number of components within a displaySet.
// -- Have different orientations within a displaySet.
for (let i = 1; i < instances.length; i++) {
const instance = instances[i];
const rows = instance.getTagValue('x00280010');
const columns = instance.getTagValue('x00280011');
const samplesPerPixel = instance.getTagValue('x00280002');
const imageOrientationPatient = instance.getTagValue('x00200037');
if (
rows !== firstImageRows ||
columns !== firstImageColumns ||
samplesPerPixel !== firstImageSamplesPerPixel ||
imageOrientationPatient !== firstImageOrientationPatient
) {
return { value: false };
}
}
let missingFrames = 0;
// Check if frame spacing is approximately equal within a tolerance.
// If spacing is on a uniform grid but we are missing frames,
// Allow reconstruction, but pass back the number of missing frames.
if (instances.length > 2) {
const firstIpp = _getImagePositionPatient(firstImage);
const lastIpp = _getImagePositionPatient(instances[instances.length - 1]);
const averageSpacingBetweenFrames =
_getPerpendicularDistance(firstIpp, lastIpp) / (instances.length - 1);
let previousIpp = firstIpp;
for (let i = 1; i < instances.length; i++) {
const instance = instances[i];
const ipp = _getImagePositionPatient(instance);
const spacingBetweenFrames = _getPerpendicularDistance(ipp, previousIpp);
const spacingIssue = _getSpacingIssue(
spacingBetweenFrames,
averageSpacingBetweenFrames
);
if (spacingIssue) {
const issue = spacingIssue.issue;
if (issue === reconstructionIssues.MISSING_FRAMES) {
missingFrames += spacingIssue.missingFrames;
} else if (issue === reconstructionIssues.IRREGULAR_SPACING) {
return { value: false };
}
}
previousIpp = ipp;
}
}
return { value: true, missingFrames };
}
// TODO: Is 10% a reasonable tolerance for spacing?
const tolerance = 0.1;
/**
* Checks for spacing issues.
*
* @param {number} spacing The spacing between two frames.
* @param {number} averageSpacing The average spacing between all frames.
*
* @returns {Object} An object containing the issue and extra information if necessary.
*/
function _getSpacingIssue(spacing, averageSpacing) {
const equalWithinTolerance =
Math.abs(spacing - averageSpacing) < averageSpacing * tolerance;
if (equalWithinTolerance) {
return;
}
const multipleOfAverageSpacing = spacing / averageSpacing;
const numberOfSpacings = Math.round(multipleOfAverageSpacing);
const errorForEachSpacing =
Math.abs(spacing - numberOfSpacings * averageSpacing) / numberOfSpacings;
if (errorForEachSpacing < tolerance * averageSpacing) {
return {
issue: reconstructionIssues.MISSING_FRAMES,
missingFrames: numberOfSpacings - 1,
};
}
return { issue: reconstructionIssues.IRREGULAR_SPACING };
}
function _getImagePositionPatient(instance) {
return instance
.getTagValue('x00200032')
.split('\\')
.map(element => Number(element));
}
function _getPerpendicularDistance(a, b) {
return Math.sqrt(
Math.pow(a[0] - b[0], 2) +
Math.pow(a[1] - b[1], 2) +
Math.pow(a[2] - b[2], 2)
);
}
const constructableModalities = ['MR', 'CT', 'PT', 'NM'];
const reconstructionIssues = {
MISSING_FRAMES: 'missingframes',
IRREGULAR_SPACING: 'irregularspacing',
};
+10 -4
View File
@@ -6,7 +6,7 @@ const PARAM_PATTERN_IDENTIFIER = ':';
function toLowerCaseFirstLetter(word) {
return word[0].toLowerCase() + word.slice(1);
}
const getFilters = (location = {}) => {
const getQueryFilters = (location = {}) => {
const { search } = location;
if (!search) {
@@ -55,13 +55,19 @@ const replaceParam = (path = '', paramKey, paramValue) => {
return path;
};
const isValidPath = path => {
const paramPatternPiece = `/${PARAM_PATTERN_IDENTIFIER}`;
return path.indexOf(paramPatternPiece) < 0;
};
const queryString = {
getQueryFilters: getFilters,
getQueryFilters,
};
const paramString = {
parseParam: parseParam,
replaceParam: replaceParam,
isValidPath,
parseParam,
replaceParam,
};
export { parse, queryString, paramString };
+11
View File
@@ -3,6 +3,17 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.51.0](https://github.com/OHIF/Viewers/compare/@ohif/i18n@0.50.5...@ohif/i18n@0.51.0) (2019-10-15)
### Features
* Add browser info and app version ([#1046](https://github.com/OHIF/Viewers/issues/1046)) ([c217b8b](https://github.com/OHIF/Viewers/commit/c217b8b))
## [0.50.5](https://github.com/OHIF/Viewers/compare/@ohif/i18n@0.50.4...@ohif/i18n@0.50.5) (2019-10-04)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ohif/i18n",
"version": "0.50.5",
"version": "0.51.0",
"description": "Internationalization library for The OHIF Viewer",
"author": "OHIF",
"license": "MIT",
@@ -0,0 +1,14 @@
{
"Browser": "Browser",
"Build Number": "Build Number",
"Latest Master Commits": "Latest Master Commits",
"More details": "More details",
"Name": "Name",
"OHIF Viewer - About": "OHIF Viewer - About",
"OS": "OS",
"Report an issue": "Report an issue",
"Repository URL": "Repository URL",
"Value": "Value",
"Version Information": "Version Information",
"Visit the forum": "Visit the forum"
}
+2
View File
@@ -1,3 +1,4 @@
import AboutModal from "./AboutModal.json";
import Buttons from "./Buttons.json";
import CineDialog from "./CineDialog.json";
import Common from "./Common.json";
@@ -8,6 +9,7 @@ import UserPreferencesModal from "./UserPreferencesModal.json";
export default {
'en-US': {
AboutModal,
Buttons,
CineDialog,
Common,
@@ -0,0 +1,14 @@
{
"Browser": "Navegador",
"Build Number": "Número da compilação",
"Latest Master Commits": "Últimos Commits na Master",
"More details": "Mais detalhes",
"Name": "Nome",
"OHIF Viewer - About": "OHIF Viewer - Sobre",
"OS": "SO",
"Report an issue": "Informar um problema",
"Repository URL": "URL do Repositório",
"Value": "Valor",
"Version Information": "Informação da Versão",
"Visit the forum": "Visite o fórum"
}
+2
View File
@@ -1,3 +1,4 @@
import AboutModal from "./AboutModal.json";
import Buttons from "./Buttons.json";
import CineDialog from "./CineDialog.json";
import Common from "./Common.json";
@@ -6,6 +7,7 @@ import UserPreferencesModal from "./UserPreferencesModal.json";
export default {
'pt-BR': {
AboutModal,
Buttons,
CineDialog,
Common,
+33
View File
@@ -3,6 +3,39 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.56.0](https://github.com/OHIF/Viewers/compare/@ohif/ui@0.55.0...@ohif/ui@0.56.0) (2019-10-22)
### Features
* 🎸 Load spinner when selecting gcloud store. Add key on td ([#1034](https://github.com/OHIF/Viewers/issues/1034)) ([e62f403](https://github.com/OHIF/Viewers/commit/e62f403fe9e3df56713128e3d59045824b086d8d)), closes [#1057](https://github.com/OHIF/Viewers/issues/1057)
# [0.55.0](https://github.com/OHIF/Viewers/compare/@ohif/ui@0.54.0...@ohif/ui@0.55.0) (2019-10-15)
### Features
* Add browser info and app version ([#1046](https://github.com/OHIF/Viewers/issues/1046)) ([c217b8b](https://github.com/OHIF/Viewers/commit/c217b8b))
# [0.54.0](https://github.com/OHIF/Viewers/compare/@ohif/ui@0.53.4...@ohif/ui@0.54.0) (2019-10-14)
### Features
* Notification Service ([#1011](https://github.com/OHIF/Viewers/issues/1011)) ([92c8996](https://github.com/OHIF/Viewers/commit/92c8996))
## [0.53.4](https://github.com/OHIF/Viewers/compare/@ohif/ui@0.53.3...@ohif/ui@0.53.4) (2019-10-11)
**Note:** Version bump only for package @ohif/ui
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ohif/ui",
"version": "0.53.4",
"version": "0.56.0",
"description": "A set of React components for Medical Imaging Viewers",
"author": "OHIF Contributors",
"license": "MIT",
@@ -39,6 +39,7 @@
"dependencies": {
"@babel/runtime": "^7.5.5",
"@ohif/i18n": "^0.2.3",
"browser-detect": "^0.2.28",
"classnames": "2.2.6",
"i18next": "^17.0.3",
"i18next-browser-languagedetector": "^3.0.1",
@@ -0,0 +1,196 @@
.sb-container {
position: fixed;
width: var(--snackbar-size);
padding: 20px;
z-index: var(--snackbar-zIndex);
box-sizing: border-box;
height: auto;
}
.sb-topLeft {
top: 0;
bottom: auto;
left: 0;
right: auto;
}
.sb-topCenter {
left: 50%;
top: 0;
bottom: auto;
transform: translateX(-50%);
}
.sb-topRight {
top: 0;
bottom: auto;
left: auto;
right: 0;
}
.sb-bottomLeft {
top: auto;
bottom: 0px;
left: 0px;
right: auto;
}
.sb-bottomCenter {
left: 50%;
bottom: 0;
top: auto;
transform: translateX(-50%);
}
.sb-bottomRight {
top: auto;
bottom: 0px;
left: auto;
right: 0px;
margin: 10px 0 0;
}
.sb-topLeft .sb-item,
.sb-topCenter .sb-item,
.sb-topRight .sb-item {
margin: 10px 0 0;
}
.sb-bottomLeft .sb-item,
.sb-bottomCenter .sb-item,
.sb-bottomRight .sb-item {
margin: 0 0 10px;
}
.sb-closeBtn {
height: 20px;
opacity: 1;
overflow: hidden;
padding: 2px;
text-align: center;
text-shadow: none;
width: 20px;
cursor: pointer;
position: absolute;
right: 5px;
top: 5px;
transition: all 0.3s ease;
background: rgba(255, 255, 255, 0.6);
border-radius: 100%;
}
.sb-closeBtn:hover {
background: #fff;
}
.sb-closeIcon {
display: block;
font-size: 0;
height: 100%;
line-height: 0;
overflow: hidden;
position: relative;
width: 100%;
}
.sb-closeIcon:after,
.sb-closeIcon:before {
content: ' ';
display: block;
height: 2px;
transition: all 0.3s ease;
width: 12px;
background-color: #222;
opacity: 1;
position: absolute;
}
.sb-closeIcon:before {
left: 4px;
top: 3px;
transform: rotate(45deg);
transform-origin: 0px 50%;
}
.sb-closeIcon:after {
right: 3px;
top: 5px;
transform: rotate(-45deg);
transform-origin: calc(100% - 3px) 50%;
}
.sb-title {
font-size: 16px;
font-weight: bold;
}
.sb-message {
font-size: 14px;
word-break: break-all;
}
.sb-item {
position: relative;
transition: height 300ms ease;
animation: fadein 1s;
padding: 20px;
color: white;
overflow: hidden;
border-radius: 4px;
box-shadow: 0 6px 10px 0 rgba(0, 0, 0, 0.2), 0 1px 18px 0 rgba(0, 0, 0, 0.12),
0 3px 5px -1px rgba(0, 0, 0, 0.14);
}
.sb-item a {
color: white;
text-decoration: underline;
}
.sb-container .sb-hidden {
padding-top: 0;
padding-bottom: 0;
margin-bottom: 0;
height: 0;
opacity: 0;
transition: all 300ms ease;
}
.sb-success {
background-color: var(--snackbar-success);
}
.sb-error {
background-color: var(--snackbar-error);
}
.sb-warning {
background-color: var(--snackbar-warning);
}
.sb-info {
background-color: var(--snackbar-info);
}
@keyframes fadein {
from {
opacity: 0;
top: 30px;
}
to {
opacity: 1;
top: 0;
}
}
/* Internet Explorer */
@-ms-keyframes fadein {
from {
opacity: 0;
top: 30px;
}
to {
opacity: 1;
top: 0;
}
}
@@ -0,0 +1,53 @@
import React from 'react';
import SnackbarItem from './SnackbarItem';
import './Snackbar.css';
import { useSnackbarContext } from '../../utils/SnackbarProvider';
const SnackbarContainer = () => {
const { snackbarItems, hide } = useSnackbarContext();
const renderItem = item => {
return <SnackbarItem key={item.itemId} options={item} onClose={hide} />;
};
if (!snackbarItems) {
return null;
}
const renderItems = () => {
const items = {
topLeft: [],
topCenter: [],
topRight: [],
bottomLeft: [],
bottomCenter: [],
bottomRight: [],
};
snackbarItems.map(item => {
items[item.position].push(item);
});
return (
<div>
{Object.keys(items).map(pos => {
if (!items[pos].length) {
return null;
}
return (
<div key={pos} className={`sb-container sb-${pos}`}>
{items[pos].map(item => (
<div key={item.id}>{renderItem(item)}</div>
))}
</div>
);
})}
</div>
);
};
return <>{renderItems()}</>;
};
export default SnackbarContainer;
@@ -0,0 +1,31 @@
import React, { useState, useEffect } from 'react';
const SnackbarItem = ({ options, onClose }) => {
const handleClose = () => {
onClose(options.id);
};
useEffect(() => {
if (options.autoClose) {
setTimeout(() => {
handleClose();
}, options.duration);
}
}, []);
return (
<div
className={`${options.visible ? '' : 'sb-hidden'} sb-${
options.type
} sb-item`}
>
<span className="sb-closeBtn" onClick={handleClose}>
<span className="sb-closeIcon">x</span>
</span>
{options.title && <div className="sb-title">{options.title}</div>}
{options.message && <div className="sb-message">{options.message}</div>}
</div>
);
};
export default SnackbarItem;
@@ -0,0 +1,6 @@
export default {
INFO: 'info',
WARNING: 'warning',
SUCCESS: 'success',
ERROR: 'error',
};
+165 -109
View File
@@ -11,11 +11,79 @@ import { StudylistToolbar } from './StudyListToolbar.js';
import { isInclusivelyBeforeDay } from 'react-dates';
import moment from 'moment';
import debounce from 'lodash.debounce';
import isEqual from 'lodash.isequal';
import { withTranslation } from '../../utils/LanguageProvider';
const today = moment();
const lastWeek = moment().subtract(7, 'day');
const lastMonth = moment().subtract(1, 'month');
function getPaginationFragment(
props,
searchData,
nextPageCb,
prevPageCb,
changeRowsPerPageCb
) {
return (
<PaginationArea
pageOptions={props.pageOptions}
currentPage={searchData.currentPage}
nextPageFunc={nextPageCb}
prevPageFunc={prevPageCb}
onRowsPerPageChange={changeRowsPerPageCb}
rowsPerPage={searchData.rowsPerPage}
recordCount={props.studies.length}
/>
);
}
function getTableMeta(translate) {
return {
patientName: {
displayText: translate('PatientName'),
sort: 0,
},
patientId: {
displayText: translate('MRN'),
sort: 0,
},
accessionNumber: {
displayText: translate('AccessionNumber'),
sort: 0,
},
studyDate: {
displayText: translate('StudyDate'),
inputType: 'date-range',
sort: 0,
},
modalities: {
displayText: translate('Modality'),
sort: 0,
},
studyDescription: {
displayText: translate('StudyDescription'),
sort: 0,
},
};
}
function getNoListFragment(translate, studies, error, loading) {
if (loading) {
return (
<div className="loading">
<StudyListLoadingText />
</div>
);
} else if (error) {
return (
<div className="notFound">
{translate('There was an error fetching studies')}
</div>
);
} else if (!studies.length) {
return <div className="notFound">{translate('No matching results')}</div>;
}
}
class StudyList extends Component {
static propTypes = {
@@ -111,24 +179,27 @@ class StudyList extends Component {
getBlurHandler(key) {
return event => {
this.delayedSearch.cancel();
this.setSearchData(key, event.target.value, this.search);
this.setSearchData(key, event.target.value);
};
}
setSearchData(key, value, callback) {
const searchData = this.state.searchData;
setSearchData(key, value) {
const searchData = { ...this.state.searchData };
searchData[key] = value;
this.setState({ searchData }, callback);
if (!isEqual(searchData[key], this.state.searchData[key])) {
this.setState({ ...this.state, searchData });
}
}
setSearchDataBatch(keyValues, callback) {
const searchData = this.state.searchData;
setSearchDataBatch(keyValues) {
const searchData = { ...this.state.searchData };
Object.keys(keyValues).forEach(key => {
searchData[key] = keyValues[key];
});
this.setState({ searchData }, callback);
this.setState({ searchData });
}
async onInputKeydown(event) {
@@ -138,7 +209,7 @@ class StudyList extends Component {
this.delayedSearch.cancel();
// reset the page because user is doing a new search
this.setSearchData('currentPage', 0, this.search);
this.setSearchData('currentPage', 0);
}
}
@@ -154,45 +225,21 @@ class StudyList extends Component {
}
}
renderNoMachingResults() {
if (!this.props.studies.length && !this.state.error) {
return <div className="notFound">No matching results</div>;
}
}
renderHasError() {
if (this.state.error) {
return (
<div className="notFound">There was an error fetching studies</div>
);
}
}
renderIsLoading() {
if (this.state.loading) {
return (
<div className="loading">
<StudyListLoadingText />
</div>
);
}
}
nextPage(currentPage) {
currentPage = currentPage + 1;
this.delayedSearch.cancel();
this.setSearchData('currentPage', currentPage, this.search);
this.setSearchData('currentPage', currentPage);
}
prevPage(currentPage) {
currentPage = currentPage - 1;
this.delayedSearch.cancel();
this.setSearchData('currentPage', currentPage, this.search);
this.setSearchData('currentPage', currentPage);
}
onRowsPerPageChange(rowsPerPage) {
this.delayedSearch.cancel();
this.setSearchDataBatch({ rowsPerPage, currentPage: 0 }, this.search);
this.setSearchDataBatch({ rowsPerPage, currentPage: 0 });
}
onSortClick(field) {
@@ -213,7 +260,7 @@ class StudyList extends Component {
}
this.delayedSearch.cancel();
this.setSearchData('sortData', { field, order }, this.search);
this.setSearchData('sortData', { field, order });
};
}
@@ -221,10 +268,39 @@ class StudyList extends Component {
this.setState({ highlightedItem: studyItemUid });
}
renderTableRow(study) {
getTableRow(study, index) {
const trKey = `trStudy${index}${study.studyInstanceUid}`;
if (!study) {
return;
}
const getTableCell = (
study,
studyKey,
emptyValue = '',
emptyClass = ''
) => {
const componentKey = `td${studyKey}`;
const isValidValue = study && typeof study[studyKey] === 'string';
let className = emptyClass;
let value = emptyValue;
if (isValidValue) {
className = studyKey;
value = study[studyKey];
}
return (
<td key={componentKey} className={className}>
{value}
</td>
);
};
return (
<tr
key={study.studyInstanceUid}
key={trKey}
className={
this.state.highlightedItem === study.studyInstanceUid
? 'studylistStudy noselect active'
@@ -241,47 +317,35 @@ class StudyList extends Component {
this.props.onSelectItem(study.studyInstanceUid);
}}
>
<td className={study.patientName ? 'patientName' : 'emptyCell'}>
{study.patientName || `(${this.props.t('Empty')})`}
</td>
<td className="patientId">{study.patientId}</td>
<td className="accessionNumber">{study.accessionNumber}</td>
<td className="studyDate">{study.studyDate}</td>
<td className="modalities">{study.modalities}</td>
<td className="studyDescription">{study.studyDescription}</td>
{getTableCell(
study,
'patientName',
`(${this.props.t('Empty')})`,
'emptyCell'
)}
{getTableCell(study, 'patientId')}
{getTableCell(study, 'accessionNumber')}
{getTableCell(study, 'studyDate')}
{getTableCell(study, 'modalities')}
{getTableCell(study, 'studyDescription')}
</tr>
);
}
componentDidUpdate(previousProps, previousState) {
if (!isEqual(previousState.searchData, this.state.searchData)) {
this.search();
}
}
renderTableBody(noListFragment) {
return !noListFragment && this.props.studies
? this.props.studies.map(this.getTableRow.bind(this))
: null;
}
render() {
const tableMeta = {
patientName: {
displayText: this.props.t('PatientName'),
sort: 0,
},
patientId: {
displayText: this.props.t('MRN'),
sort: 0,
},
accessionNumber: {
displayText: this.props.t('AccessionNumber'),
sort: 0,
},
studyDate: {
displayText: this.props.t('StudyDate'),
inputType: 'date-range',
sort: 0,
},
modalities: {
displayText: this.props.t('Modality'),
sort: 0,
},
studyDescription: {
displayText: this.props.t('StudyDescription'),
sort: 0,
},
};
const tableMeta = getTableMeta(this.props.t);
// Apply sort
const sortedFieldName = this.state.searchData.sortData.field;
@@ -294,14 +358,20 @@ class StudyList extends Component {
// Sort Icons
const sortIcons = ['sort', 'sort-up', 'sort-down'];
const noListFragment = getNoListFragment(
this.props.t,
this.props.studies,
this.state.error,
this.props.loading || this.state.loading
);
const tableBody = this.renderTableBody(noListFragment);
const studiesNum = (this.props.studies && this.props.studies.length) || 0;
return (
<div className="StudyList">
<div className="studyListToolbar clearfix">
<div className="header pull-left">{this.props.t('StudyList')}</div>
<div className="studyCount pull-right">
{this.props.studies.length}
</div>
<div className="studyCount pull-right">{studiesNum}</div>
<div className="pull-right">
{this.props.studyListFunctionsEnabled ? (
<StudylistToolbar onImport={this.props.onImport} />
@@ -367,22 +437,16 @@ class StudyList extends Component {
(this.state.focusedInput === 'endDate' ||
preset)
) {
this.setSearchDataBatch(
{
studyDateFrom: startDate.toDate(),
studyDateTo: endDate.toDate(),
},
this.search
);
this.setSearchDataBatch({
studyDateFrom: startDate.toDate(),
studyDateTo: endDate.toDate(),
});
this.setState({ focusedInput: false });
} else if (!startDate && !endDate) {
this.setSearchDataBatch(
{
studyDateFrom: null,
studyDateTo: null,
},
this.search
);
this.setSearchDataBatch({
studyDateFrom: null,
studyDateTo: null,
});
}
}}
focusedInput={this.state.focusedInput}
@@ -398,26 +462,18 @@ class StudyList extends Component {
})}
</tr>
</thead>
<tbody id="studyListData">
{this.props.studies.map(study => {
return this.renderTableRow(study);
})}
</tbody>
<tbody id="studyListData">{tableBody}</tbody>
</table>
{this.renderIsLoading()}
{this.renderHasError()}
{this.renderNoMachingResults()}
<PaginationArea
pageOptions={this.props.pageOptions}
currentPage={this.state.searchData.currentPage}
nextPageFunc={this.nextPage}
prevPageFunc={this.prevPage}
onRowsPerPageChange={this.onRowsPerPageChange}
rowsPerPage={this.state.searchData.rowsPerPage}
recordCount={this.props.studies.length}
/>
{noListFragment
? noListFragment
: getPaginationFragment(
this.props,
this.state.searchData,
this.nextPage,
this.prevPage,
this.onRowsPerPageChange
)}
</div>
</div>
);
@@ -82,9 +82,14 @@ placeholder-color(c)
position: absolute
z-index: 2
.loading-text
color: var(--table-text-secondary-color)
font-size: 30px
.loading
display: flex;
justify-content: center;
.loading-text
color: var(--table-text-secondary-color)
font-size: 30px
width: fit-content;
.notFound
color: var(--table-text-secondary-color)
@@ -1,12 +1,17 @@
import { Icon } from './../../elements/Icon';
import React from 'react';
import { withTranslation } from '../../utils/LanguageProvider';
function StudyListLoadingText() {
function StudyListLoadingText({ t: translate }) {
return (
<div className="loading-text">
Loading... <Icon name="circle-notch" animation="pulse" />
{translate('Loading')}... <Icon name="circle-notch" animation="pulse" />
</div>
);
}
export { StudyListLoadingText };
const connectedComponent = withTranslation('StudyListLoadingText')(
StudyListLoadingText
);
export { connectedComponent as StudyListLoadingText };
@@ -1,14 +1,16 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import Modal from 'react-bootstrap-modal';
import detect from 'browser-detect';
import './AboutModal.styl';
import { withTranslation } from '../../utils/LanguageProvider';
import 'react-bootstrap-modal/lib/css/rbm-patch.css';
// TODO: Is this the only component importing these?
import './../../design/styles/common/modal.styl';
export class AboutModal extends Component {
class AboutModal extends Component {
constructor(props) {
super(props);
}
@@ -18,16 +20,41 @@ export class AboutModal extends Component {
onCancel: PropTypes.func,
};
static itemsPreset = [
{
name: 'Repository URL',
value: 'https://github.com/OHIF/Viewers/',
},
{
name: 'Latest Master Commits',
value: 'https://github.com/OHIF/Viewers/commits/master',
},
];
itemsPreset() {
const { t } = this.props;
const browser = detect();
const capitalize = s =>
s.substr(0, 1).toUpperCase() + s.substr(1).toLowerCase();
return [
{
name: t('Repository URL'),
value: 'https://github.com/OHIF/Viewers/',
link: 'https://github.com/OHIF/Viewers/',
},
{
name: t('Latest Master Commits'),
value: 'https://github.com/OHIF/Viewers/commits/master',
link: 'https://github.com/OHIF/Viewers/commits/master',
},
{
name: 'Version Number',
value: process.env.VERSION_NUMBER,
},
{
name: t('Build Number'),
value: process.env.BUILD_NUM,
},
{
name: t('Browser'),
value: `${capitalize(browser.name)} ${browser.version}`,
},
{
name: t('OS'),
value: browser.os,
},
];
}
static defaultProps = {
isOpen: false,
@@ -38,15 +65,20 @@ export class AboutModal extends Component {
<tr key={item.name}>
<td>{item.name}</td>
<td>
<a target="_blank" href={item.value}>
{item.value}
</a>
{item.link ? (
<a target="_blank" href={item.link}>
{item.value}
</a>
) : (
item.value
)}
</td>
</tr>
);
}
render() {
const { t } = this.props;
return (
<Modal
show={this.props.isOpen}
@@ -58,7 +90,7 @@ export class AboutModal extends Component {
keyboard={false}
>
<Modal.Header closeButton>
<Modal.Title>About</Modal.Title>
<Modal.Title>{t('OHIF Viewer - About')}</Modal.Title>
</Modal.Header>
<Modal.Body>
<div className="btn-group">
@@ -67,34 +99,36 @@ export class AboutModal extends Component {
target="_blank"
href="https://groups.google.com/forum/#!forum/cornerstone-platform"
>
Visit the forum
{t('Visit the forum')}
</a>
{` `}
<a
className="btn btn-default"
target="_blank"
href="https://github.com/OHIF/Viewers/issues"
href="https://github.com/OHIF/Viewers/issues/new/choose"
>
Report an issue
{t('Report an issue')}
</a>
{` `}
<a
className="btn btn-default"
target="_blank"
href="http://ohif.org"
>
More details
{t('More details')}
</a>
</div>
<div>
<h3>Version Information</h3>
<h3>{t('Version Information')}</h3>
<table className="table table-responsive">
<thead>
<tr>
<th>Name</th>
<th>Value</th>
<th>{t('Name')}</th>
<th>{t('Value')}</th>
</tr>
</thead>
<tbody>
{AboutModal.itemsPreset.map(item => this.renderTableRow(item))}
{this.itemsPreset().map(item => this.renderTableRow(item))}
</tbody>
</table>
</div>
@@ -103,3 +137,7 @@ export class AboutModal extends Component {
);
}
}
const connectedComponent = withTranslation('AboutModal')(AboutModal);
export { connectedComponent as AboutModal };
export default connectedComponent;
@@ -12,6 +12,7 @@
z-index: 1050;
-webkit-overflow-scrolling: touch;
outline: 0;
background: rgba(0,0,0,0.5);
.modal-dialog
+7
View File
@@ -32,6 +32,10 @@ import { ScrollableArea } from './ScrollableArea/ScrollableArea.js';
import Toolbar from './viewer/Toolbar.js';
import ToolbarButton from './viewer/ToolbarButton.js';
import ViewerbaseDragDropContext from './utils/viewerbaseDragDropContext.js';
import SnackbarProvider, {
useSnackbarContext,
withSnackbar,
} from './utils/SnackbarProvider';
export {
ICONS,
@@ -68,4 +72,7 @@ export {
UserPreferences,
UserPreferencesModal,
ViewerbaseDragDropContext,
SnackbarProvider,
useSnackbarContext,
withSnackbar,
};
+102
View File
@@ -0,0 +1,102 @@
import React, { useState, createContext, useContext } from 'react';
import SnackbarContainer from '../components/snackbar/SnackbarContainer';
import SnackbarTypes from '../components/snackbar/SnackbarTypes';
const SnackbarContext = createContext(null);
export const useSnackbarContext = () => useContext(SnackbarContext);
const SnackbarProvider = ({ children }) => {
const DEFAULT_OPTIONS = {
title: '',
message: '',
duration: 5000,
autoClose: true,
position: 'bottomRight',
type: SnackbarTypes.INFO,
};
const [count, setCount] = useState(1);
const [snackbarItems, setSnackbarItems] = useState([]);
const show = options => {
if (!options || (!options.title && !options.message)) {
console.warn(
'Snackbar cannot be rendered without required parameters: title | message'
);
return null;
}
const newItem = {
...DEFAULT_OPTIONS,
...options,
id: count,
visible: true,
};
setSnackbarItems(state => [...state, newItem]);
setCount(count + 1);
};
const hide = id => {
const hideItem = items => {
const newItems = items.map(item => {
if (item.id === id) {
item.visible = false;
}
return item;
});
return newItems;
};
setSnackbarItems(state => hideItem(state));
setTimeout(() => {
setSnackbarItems(state => [...state.filter(item => item.id !== id)]);
}, 1000);
};
const hideAll = () => {
// reset count
setCount(1);
// remove all items from array
setSnackbarItems(() => []);
};
/**
* expose snackbar methods to window for debug purposes
* TODO: Check if it's really necessary
*/
window.snackbar = {
show,
hide,
hideAll,
};
return (
<SnackbarContext.Provider value={{ show, hide, hideAll, snackbarItems }}>
{!!snackbarItems && <SnackbarContainer />}
{children}
</SnackbarContext.Provider>
);
};
/**
*
* High Order Component to use the snackbar methods through a Class Component
*
*/
export const withSnackbar = Component => {
return function WrappedComponent(props) {
const snackbarContext = {
...useSnackbarContext(),
};
return <Component {...props} snackbarContext={snackbarContext} />;
};
};
export default SnackbarProvider;
+155
View File
@@ -3,6 +3,161 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.12.1](https://github.com/OHIF/Viewers/compare/@ohif/viewer@1.12.0...@ohif/viewer@1.12.1) (2019-10-25)
### Bug Fixes
* 🐛 Orthographic MPR fix ([#1092](https://github.com/OHIF/Viewers/issues/1092)) ([460e375](https://github.com/OHIF/Viewers/commit/460e375f0aa75d35f7a46b4d48e6cc706019956d))
# [1.12.0](https://github.com/OHIF/Viewers/compare/@ohif/viewer@1.11.5...@ohif/viewer@1.12.0) (2019-10-25)
### Features
* 🎸 Allow routes to load Google Cloud DICOM Stores in the Study List ([#1069](https://github.com/OHIF/Viewers/issues/1069)) ([21b586b](https://github.com/OHIF/Viewers/commit/21b586b08f3dde6613859712a9e0577dece564db))
## [1.11.5](https://github.com/OHIF/Viewers/compare/@ohif/viewer@1.11.4...@ohif/viewer@1.11.5) (2019-10-24)
**Note:** Version bump only for package @ohif/viewer
## [1.11.4](https://github.com/OHIF/Viewers/compare/@ohif/viewer@1.11.3...@ohif/viewer@1.11.4) (2019-10-23)
### Bug Fixes
* Revert "Revert "fix: MPR initialization"" ([#1065](https://github.com/OHIF/Viewers/issues/1065)) ([c680720](https://github.com/OHIF/Viewers/commit/c680720ce5ead58fdb399e3a356edac18093f5c0)), closes [#1062](https://github.com/OHIF/Viewers/issues/1062) [#1064](https://github.com/OHIF/Viewers/issues/1064)
## [1.11.3](https://github.com/OHIF/Viewers/compare/@ohif/viewer@1.11.2...@ohif/viewer@1.11.3) (2019-10-23)
### Bug Fixes
* 🐛 Switch to orhtographic view for 2D MPR ([#1074](https://github.com/OHIF/Viewers/issues/1074)) ([13d337a](https://github.com/OHIF/Viewers/commit/13d337aaabb8dadf6366c6262c5e47e7781edd08))
## [1.11.2](https://github.com/OHIF/Viewers/compare/@ohif/viewer@1.11.1...@ohif/viewer@1.11.2) (2019-10-23)
**Note:** Version bump only for package @ohif/viewer
## [1.11.1](https://github.com/OHIF/Viewers/compare/@ohif/viewer@1.11.0...@ohif/viewer@1.11.1) (2019-10-23)
**Note:** Version bump only for package @ohif/viewer
# [1.11.0](https://github.com/OHIF/Viewers/compare/@ohif/viewer@1.10.3...@ohif/viewer@1.11.0) (2019-10-22)
### Bug Fixes
* MPR initialization ([#1062](https://github.com/OHIF/Viewers/issues/1062)) ([b037394](https://github.com/OHIF/Viewers/commit/b03739428f72bb50bdabdd6f83b7af885057da69))
### Features
* 🎸 Load spinner when selecting gcloud store. Add key on td ([#1034](https://github.com/OHIF/Viewers/issues/1034)) ([e62f403](https://github.com/OHIF/Viewers/commit/e62f403fe9e3df56713128e3d59045824b086d8d)), closes [#1057](https://github.com/OHIF/Viewers/issues/1057)
## [1.10.3](https://github.com/OHIF/Viewers/compare/@ohif/viewer@1.10.2...@ohif/viewer@1.10.3) (2019-10-18)
**Note:** Version bump only for package @ohif/viewer
## [1.10.2](https://github.com/OHIF/Viewers/compare/@ohif/viewer@1.10.1...@ohif/viewer@1.10.2) (2019-10-18)
**Note:** Version bump only for package @ohif/viewer
## [1.10.1](https://github.com/OHIF/Viewers/compare/@ohif/viewer@1.10.0...@ohif/viewer@1.10.1) (2019-10-16)
**Note:** Version bump only for package @ohif/viewer
# [1.10.0](https://github.com/OHIF/Viewers/compare/@ohif/viewer@1.9.1...@ohif/viewer@1.10.0) (2019-10-15)
### Features
* Add browser info and app version ([#1046](https://github.com/OHIF/Viewers/issues/1046)) ([c217b8b](https://github.com/OHIF/Viewers/commit/c217b8b))
## [1.9.1](https://github.com/OHIF/Viewers/compare/@ohif/viewer@1.9.0...@ohif/viewer@1.9.1) (2019-10-15)
### Bug Fixes
* 🐛 Remove debugger statement left in from last PR ([#1052](https://github.com/OHIF/Viewers/issues/1052)) ([d091cd6](https://github.com/OHIF/Viewers/commit/d091cd6))
# [1.9.0](https://github.com/OHIF/Viewers/compare/@ohif/viewer@1.8.0...@ohif/viewer@1.9.0) (2019-10-15)
### Features
* 🎸 Only allow reconstruction of datasets that make sense ([#1010](https://github.com/OHIF/Viewers/issues/1010)) ([2d75e01](https://github.com/OHIF/Viewers/commit/2d75e01)), closes [#561](https://github.com/OHIF/Viewers/issues/561)
# [1.8.0](https://github.com/OHIF/Viewers/compare/@ohif/viewer@1.7.0...@ohif/viewer@1.8.0) (2019-10-14)
### Features
* Notification Service ([#1011](https://github.com/OHIF/Viewers/issues/1011)) ([92c8996](https://github.com/OHIF/Viewers/commit/92c8996))
# [1.7.0](https://github.com/OHIF/Viewers/compare/@ohif/viewer@1.6.3...@ohif/viewer@1.7.0) (2019-10-14)
### Features
* Implement a 'Exit 2D MPR' button in the toolbar ([c99e0d8](https://github.com/OHIF/Viewers/commit/c99e0d8))
## [1.6.3](https://github.com/OHIF/Viewers/compare/@ohif/viewer@1.6.2...@ohif/viewer@1.6.3) (2019-10-14)
**Note:** Version bump only for package @ohif/viewer
+4
View File
@@ -2,5 +2,9 @@
"baseUrl": "http://localhost:3000",
"video": false,
"chromeWebSecurity": false,
"waitForAnimations": true,
"defaultCommandTimeout": 10000,
"requestTimeout": 10000,
"responseTimeout": 10000,
"projectId": "4oe38f"
}
@@ -2,12 +2,12 @@ describe('OHIF Cornerstone Toolbar', () => {
before(() => {
cy.openStudy('MISTER^MR');
cy.waitDicomImage();
cy.expectMinimumThumbnails(3);
});
beforeEach(() => {
cy.initCornerstoneToolsAliases();
cy.initCommonElementsAliases();
//Following best practices, reset should be done before each test
cy.resetViewport();
});
@@ -42,9 +42,6 @@ describe('OHIF Cornerstone Toolbar', () => {
cy.get('@moreBtn')
.should('be.visible')
.contains('More');
cy.get('@twodmprBtn')
.should('be.visible')
.contains('2D MPR');
cy.get('@layoutBtn')
.should('be.visible')
.contains('Layout');
@@ -60,12 +57,11 @@ describe('OHIF Cornerstone Toolbar', () => {
//drags the mouse inside the viewport to be able to interact with series
cy.get('@viewport')
.trigger('mousedown', 'top', { which: 1 })
.trigger('mousemove', 'center', { which: 1 })
.trigger('mousedown', 'center', { which: 1 })
.trigger('mousemove', 'top', { which: 1 })
.trigger('mouseup');
const expectedText =
'Ser: 1Img: 14 14/26256 x 256Loc: 0.00 mm Thick: 5.00 mm';
'Ser: 1Img: 1 1/26256 x 256Loc: -30.00 mm Thick: 5.00 mm';
cy.get('@viewportInfoBottomLeft').should('have.text', expectedText);
});
@@ -79,11 +75,11 @@ describe('OHIF Cornerstone Toolbar', () => {
//drags the mouse inside the viewport to be able to interact with series
cy.get('@viewport')
.trigger('mousedown', 'top', { which: 1 })
.trigger('mousemove', 'center', { which: 1 })
.trigger('mousedown', 'center', { which: 1 })
.trigger('mousemove', 'top', { which: 1 })
.trigger('mouseup');
const expectedText = 'Zoom: 884%W: 820 L: 410Lossless / Uncompressed';
const expectedText = 'Zoom: 50%W: 958 L: 479Lossless / Uncompressed';
cy.get('@viewportInfoBottomRight').should('have.text', expectedText);
});
@@ -97,14 +93,14 @@ describe('OHIF Cornerstone Toolbar', () => {
//drags the mouse inside the viewport to be able to interact with series
cy.get('@viewport')
.trigger('mousedown', 'top', { which: 1 })
.trigger('mousemove', 'center', { which: 1 })
.trigger('mousedown', 'center', { which: 1 })
.trigger('mousemove', 'top', { which: 1 })
.trigger('mouseup')
.trigger('mousedown', 'center', { which: 1 })
.trigger('mousemove', 'left', { which: 1 })
.trigger('mouseup');
const expectedText = 'Zoom: 211%W: 544 L: 626Lossless / Uncompressed';
const expectedText = 'Zoom: 211%W: 635 L: 226Lossless / Uncompressed';
cy.get('@viewportInfoBottomRight').should('have.text', expectedText);
});
@@ -148,7 +144,7 @@ describe('OHIF Cornerstone Toolbar', () => {
//Add annotation on the viewport
const firstClick = [150, 100];
const secondClick = [130, 170];
cy.addLine('.cornerstone-canvas', firstClick, secondClick);
cy.addLine('@viewport', firstClick, secondClick);
//Verify if measurement annotation was added into the measurements panel
cy.get('@measurementsBtn')
@@ -176,7 +172,7 @@ describe('OHIF Cornerstone Toolbar', () => {
const initPos = [180, 390];
const midPos = [300, 410];
const finalPos = [180, 450];
cy.addAngle('.cornerstone-canvas', initPos, midPos, finalPos);
cy.addAngle('@viewport', initPos, midPos, finalPos);
//Verify if measurement annotation was added into the measurements panel
cy.get('@measurementsBtn')
@@ -200,11 +196,14 @@ describe('OHIF Cornerstone Toolbar', () => {
//Click on reset button
cy.get('@resetBtn').click();
const expectedText = 'Zoom: 211%W: 820 L: 410Lossless / Uncompressed';
const expectedText = 'Zoom: 211%W: 958 L: 479Lossless / Uncompressed';
cy.get('@viewportInfoBottomRight').should('have.text', expectedText);
});
it('checks if CINE tool will prompt a modal with working controls', () => {
cy.server();
cy.route('GET', '/**/studies/**/').as('studies');
//Click on button
cy.get('@cineBtn').click();
//Vefiry if cine control overlay is being displayed
@@ -219,26 +218,49 @@ describe('OHIF Cornerstone Toolbar', () => {
.click();
let expectedText = 'Img: 1 1/26';
cy.get('@viewportInfoBottomLeft').should('not.have.text', expectedText);
cy.get('@viewportInfoBottomLeft', { timeout: 15000 }).should(
'not.have.text',
expectedText
);
//Test SKIP TO FIRST IMAGE button
cy.get('[title="Skip to first Image"]').click();
cy.get('@viewportInfoBottomLeft').should('contain.text', expectedText);
cy.get('[title="Skip to first Image"]')
.click()
.wait(1000);
cy.get('@viewportInfoBottomLeft', { timeout: 15000 }).should(
'contain.text',
expectedText
);
//Test NEXT IMAGE button
cy.get('[title="Next Image"]').click();
cy.get('[title="Next Image"]')
.click()
.wait(1000);
expectedText = 'Img: 2 2/26';
cy.get('@viewportInfoBottomLeft').should('contain.text', expectedText);
cy.get('@viewportInfoBottomLeft', { timeout: 15000 }).should(
'contain.text',
expectedText
);
//Test SKIP TO LAST IMAGE button
cy.get('[title="Skip to last Image"]').click();
cy.get('[title="Skip to last Image"]')
.click()
.wait(2000);
expectedText = 'Img: 27 26/26';
cy.get('@viewportInfoBottomLeft').should('contain.text', expectedText);
cy.get('@viewportInfoBottomLeft', { timeout: 15000 }).should(
'contain.text',
expectedText
);
//Test PREVIOUS IMAGE button
cy.get('[title="Previous Image"]').click();
cy.get('[title="Previous Image"]')
.click()
.wait(1000);
expectedText = 'Img: 26 25/26';
cy.get('@viewportInfoBottomLeft').should('contain.text', expectedText);
cy.get('@viewportInfoBottomLeft', { timeout: 15000 }).should(
'contain.text',
expectedText
);
//Click on Cine button
cy.get('@cineBtn').click();
@@ -361,33 +383,20 @@ describe('OHIF Cornerstone Toolbar', () => {
.should('be.eq', 9);
});
//Commented this to avoid throwing an wrong exception at the end of "Layout button" test
//this commented section should be uncommented once issue #999 is fixed. (https://github.com/OHIF/Viewers/issues/999)
//verify if layout has changed to 1 viewport
// cy.get('@layoutBtn').click();
// cy.get('tbody > :nth-child(1) > :nth-child(1)').click();
// cy.get('.viewport-container').then(($viewport) =>{
// cy.wrap($viewport)
// .its('length')
// .should('be.eq', 1);
// })
cy.reload();
cy.waitDicomImage();
cy.get('@layoutBtn').click();
cy.get('tbody > :nth-child(1) > :nth-child(1)').click();
cy.get('.viewport-container').then($viewport => {
cy.wrap($viewport)
.its('length')
.should('be.eq', 1);
});
});
it('checks if Clear tool will delete all measurements added in the viewport', () => {
//Add measurements in the viewport
cy.get('@lengthBtn').click();
const firstClick = [150, 100];
const secondClick = [130, 170];
cy.addLine('.cornerstone-canvas', firstClick, secondClick);
cy.get('@angleBtn').click();
const initPos = [180, 390];
const midPos = [300, 410];
const finalPos = [180, 450];
cy.addAngle('.cornerstone-canvas', initPos, midPos, finalPos);
cy.addLengthMeasurement();
cy.addAngleMeasurement();
//Verify if measurement annotation was added into the measurements panel
cy.get('@measurementsBtn').click();
@@ -419,23 +428,18 @@ describe('OHIF Cornerstone Toolbar', () => {
it('checks if Eraser tool will remove the measurements added in the viewport', () => {
//Add measurements in the viewport
cy.get('@lengthBtn').click();
const firstClick = [150, 100];
const secondClick = [130, 170];
cy.addLine('.cornerstone-canvas', firstClick, secondClick);
cy.get('@angleBtn').click();
const initPos = [180, 390];
const midPos = [300, 410];
const finalPos = [180, 450];
cy.addAngle('.cornerstone-canvas', initPos, midPos, finalPos);
cy.addLengthMeasurement();
cy.addAngleMeasurement();
//Verify if measurement annotation was added into the measurements panel
cy.get('@measurementsBtn').click();
cy.get('.measurementItem')
.its('length')
.should('be.eq', 2);
cy.get('@measurementsBtn').click();
cy.get('@measurementsBtn')
.click()
.wait(2000);
//cy.isNotInViewport('@measurementsPanel'); //TO DO: check this intermittent behaviour
//Click More button
cy.get('@moreBtn').click();
@@ -443,14 +447,14 @@ describe('OHIF Cornerstone Toolbar', () => {
cy.get('.tooltip-inner > :nth-child(12)').click();
//Erase measurement #1 and Verify if it was removed from the measurements panel
const [x1, y1] = firstClick;
const [x1, y1] = [150, 100];
cy.get('@viewport').click(x1, y1, { force: true });
cy.get('.measurementItem')
.its('length')
.should('be.eq', 1);
//Erase measurement #2 and Verify if it was removed from the measurements panel
const [x2, y2] = initPos;
const [x2, y2] = [180, 390];
cy.get('@viewport').click(x2, y2, { force: true });
cy.get('.measurementItem').should('not.exist');
});
@@ -1,16 +1,23 @@
describe('OHIFStandaloneViewer', () => {
beforeEach(() => {
cy.visit('/');
cy.openStudyList();
});
it('loads route with at least 2 rows', () => {
cy.screenshot();
cy.percyCanvasSnapshot('Study List');
cy.get('#studyListData tr')
.its('length')
.should('be.gt', 2);
});
it('first 2 rows has values', () => {
cy.get('#studyListData > :nth-child(1) > .patientId').should('be.visible');
cy.get('#studyListData > :nth-child(2) > .patientId').should('be.visible');
cy.get('#studyListData > :nth-child(1) > .patientId', {
timeout: 15000,
}).should('be.visible');
cy.get('#studyListData > :nth-child(2) > .patientId', {
timeout: 15000,
}).should('be.visible');
});
});
@@ -2,22 +2,25 @@ describe('OHIF Study Viewer Page', () => {
before(() => {
cy.openStudy('MISTER^MR');
cy.waitDicomImage();
cy.expectMinimumThumbnails(6);
});
beforeEach(() => {
cy.initCommonElementsAliases();
//Following best practices, reset should be done before each test
cy.resetViewport();
});
it('checks if series thumbnails are being displayed', () => {
cy.get('.ThumbnailEntryContainer')
cy.screenshot();
cy.percyCanvasSnapshot('Series Thumbnails');
cy.get('[data-cy="thumbnail-list"]')
.its('length')
.should('be.gt', 1);
});
it('drags and drop a series thumbnail into viewport', () => {
cy.get('.ThumbnailEntryContainer:nth-child(2)') //element to be dragged
cy.get('[data-cy="thumbnail-list"]:nth-child(2)') //element to be dragged
.drag('.cornerstone-canvas'); //dropzone element
const expectedText =
@@ -62,8 +65,11 @@ describe('OHIF Study Viewer Page', () => {
// Confirm Selection
cy.get('.checkIconWrapper').click();
//Verify if 'Bone' label was added
// Verify if 'Bone' label was added
cy.get('.measurementLocation').should('contain.text', 'Bone');
// Close panel
cy.get('@measurementsBtn').click();
cy.get('@measurementsPanel').should('not.be.enabled');
});
//TO-DO: Test case will fail due to issue #1013: https://github.com/OHIF/Viewers/issues/1013
@@ -90,4 +96,114 @@ describe('OHIF Study Viewer Page', () => {
// cy.get('.measurementLocation')
// .should('contain.text', descriptionText);
// });
it('checks if measurement item can be deleted through the context menu on the viewport', () => {
cy.addLengthMeasurement([100, 100], [200, 100]); //Adding measurement in the viewport
//Right click on measurement annotation
const [x1, y1] = [150, 100];
cy.get('@viewport')
.trigger('mousedown', x1, y1, {
which: 3,
})
.trigger('mouseup', x1, y1, {
which: 3,
});
//Contextmenu is visible
cy.get('.ToolContextMenu').should('be.visible');
//Click "Delete measurement"
cy.get('.form-action')
.contains('Delete measurement')
.click();
//Open measurements menu
cy.get('@measurementsBtn').click();
//Verify measurements was removed from panel
cy.get('.measurementItem')
.should('not.exist')
.log('Annotation removed with success');
//Close panel
cy.get('@measurementsBtn').click();
cy.get('@measurementsPanel').should('not.be.enabled');
});
it('adds relabel and description to measurement item through the context menu on the viewport', () => {
cy.addLengthMeasurement([100, 100], [200, 100]); //Adding measurement in the viewport
// Relabel
// Right click on measurement annotation
const [x1, y1] = [150, 100];
cy.get('@viewport')
.trigger('mousedown', x1, y1, {
which: 3,
})
.trigger('mouseup', x1, y1, {
which: 3,
});
// Contextmenu is visible
cy.get('.ToolContextMenu').should('be.visible');
// Click "Relabel"
cy.get('.form-action')
.contains('Relabel')
.click();
// Search for "Brain"
cy.get('.searchInput').type('Brain');
// Select "Brain" Result
cy.get('.treeInputs > .wrapperLabel')
.contains('Brain')
.click();
// Confirm Selection
cy.get('.checkIconWrapper').click();
// Description
// Right click on measurement annotation
cy.get('@viewport')
.trigger('mousedown', x1, y1, {
which: 3,
})
.trigger('mouseup', x1, y1, {
which: 3,
});
// Contextmenu is visible
cy.get('.ToolContextMenu').should('be.visible');
// Click "Description"
cy.get('.form-action')
.contains('Add Description')
.click();
// Enter description text
const descriptionText = 'Adding text for description test';
cy.get('#description').type(descriptionText);
// Confirm
cy.get('.btn-confirm').click();
//Open measurements menu
cy.get('@measurementsBtn').click();
// Verify if label was added
cy.get('.measurementLocation')
.should('contain.text', 'Brain')
.log('Relabel added with success');
//Verify if descriptionText was added
cy.get('.measurementLocation')
.should('contain.text', descriptionText)
.log('Description added with success');
// Close panel
cy.get('@measurementsBtn').click();
cy.get('@measurementsPanel').should('not.be.enabled');
});
});
@@ -0,0 +1,25 @@
describe('OHIF HTML Extension', () => {
before(() => {
cy.openStudy('Dummy');
cy.expectMinimumThumbnails(5);
});
it('checks if series thumbnails are being displayed', () => {
cy.get('[data-cy="thumbnail-list"]')
.contains('SR')
.its('length')
.should('to.be.at.least', 1);
});
it('drags and drop a SR thumbnail into viewport', () => {
cy.get('[data-cy="thumbnail-list"]')
.contains('SR')
.first()
.drag('.viewport-drop-target');
cy.get(':nth-child(2) > h1').should(
'contain.text',
'Imaging Measurement Report'
);
});
});
@@ -0,0 +1,27 @@
describe('OHIF Microscopy Extension', () => {
before(() => {
cy.openStudyModality('SM');
cy.expectMinimumThumbnails(6);
});
it('checks if series thumbnails are being displayed', () => {
cy.get('[data-cy="thumbnail-list"]')
.contains('SM')
.its('length')
.should('to.be.at.least', 1);
});
it('drags and drop a SM thumbnail into viewport', () => {
cy.get('[data-cy="thumbnail-list"]')
.contains('SM')
.drag('.viewport-drop-target');
cy.get('.DicomMicroscopyViewer')
.its('length')
.should('be.eq', 1);
cy.wait(3000); //Waiting for image to render before taking the snapshot
cy.screenshot();
cy.percyCanvasSnapshot('Microscopy Extension');
});
});
@@ -0,0 +1,26 @@
describe('OHIF PDF Extension', () => {
before(() => {
cy.openStudy('Dummy');
cy.expectMinimumThumbnails(6);
});
it('checks if series thumbnails are being displayed', () => {
cy.get('[data-cy="thumbnail-list"]')
.contains('DOC')
.its('length')
.should('to.be.at.least', 1);
});
it('drags and drop a PDF thumbnail into viewport', () => {
cy.get('[data-cy="thumbnail-list"]')
.contains('DOC')
.drag('.viewport-drop-target');
cy.get('.DicomPDFViewport')
.its('length')
.should('be.eq', 1);
cy.screenshot();
cy.percyCanvasSnapshot('PDF Extension');
});
});
@@ -0,0 +1,54 @@
describe('OHIF VTK Extension', () => {
before(() => {
cy.openStudy('Juno');
cy.waitDicomImage();
cy.expectMinimumThumbnails(7);
});
beforeEach(() => {
// TODO: We shouldn't have to drag the thumbnail
// This is a known bug; 2D MPR button does not show until viewport
// has data from a drag-n-drop
// Drag and drop first thumbnail into first viewport
cy.get('[data-cy="thumbnail-list"]:nth-child(3)').drag(
'.viewport-drop-target'
);
cy.get('.PluginSwitch > .toolbar-button')
.as('twodmprBtn')
.should('be.visible')
.then(btn => {
if (!btn.text().includes('Exit')) {
btn.click();
}
});
//wait VTK toolbar and images to be loaded
cy.wait(3000);
cy.initVTKToolsAliases();
});
it('checks if VTK buttons are displayed on the toolbar', () => {
cy.screenshot();
cy.percyCanvasSnapshot('VTK Extension');
cy.get('@crosshairsBtn')
.should('be.visible')
.contains('Crosshairs');
cy.get('@wwwcBtn')
.should('be.visible')
.contains('WWWC');
cy.get('@rotateBtn')
.should('be.visible')
.contains('Rotate');
cy.get('@slabSlider')
.should('be.visible')
.contains('Slab Thickness');
cy.get('@modeDropdown')
.should('be.visible')
.contains('MIP');
cy.get('@modeCheckbox').should('be.visible');
cy.get('@layoutBtn')
.should('be.visible')
.contains('Layout');
});
});
+9 -1
View File
@@ -7,10 +7,10 @@
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
let percyHealthCheck = require('@percy/cypress/task');
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
@@ -23,5 +23,13 @@ module.exports = (on, config) => {
// whatever you return here becomes the new args
return args;
}
if (browser.name === 'chromium') {
const newArgs = args.filter(arg => arg !== '--disable-gpu');
newArgs.push('--ignore-gpu-blacklist');
return newArgs;
}
});
on('task', percyHealthCheck);
};
+19 -1
View File
@@ -10,7 +10,6 @@ export function initCornerstoneToolsAliases() {
cy.get('.ToolbarRow > :nth-child(9)').as('resetBtn');
cy.get('.ToolbarRow > :nth-child(10)').as('cineBtn');
cy.get('.expandableToolMenu').as('moreBtn');
cy.get('.PluginSwitch > .toolbar-button').as('twodmprBtn');
cy.get('.btn-group > .toolbar-button').as('layoutBtn');
}
@@ -32,3 +31,22 @@ export function initCommonElementsAliases() {
'viewportInfoBottomRight'
);
}
//Creating aliases for Routes
export function initRouteAliases() {
cy.server();
cy.route('GET', '**/series**').as('getStudySeries');
cy.route('GET', '**/studies**').as('getStudies');
}
//Creating aliases for VTK tools buttons
export function initVTKToolsAliases() {
cy.get('.PluginSwitch > .toolbar-button').as('twodmprBtn');
cy.get('.ToolbarRow > :nth-child(2)').as('crosshairsBtn');
cy.get('.ToolbarRow > :nth-child(3)').as('wwwcBtn');
cy.get('.ToolbarRow > :nth-child(4)').as('rotateBtn');
cy.get('.slab-thickness').as('slabSlider');
cy.get('.select-ohif').as('modeDropdown');
cy.get('.ohif-check-label').as('modeCheckbox');
cy.get('.btn-group > .toolbar-button').as('layoutBtn');
}
+179 -42
View File
@@ -1,7 +1,10 @@
import '@percy/cypress';
import { DragSimulator } from '../helpers/DragSimulator.js';
import {
initCornerstoneToolsAliases,
initCommonElementsAliases,
initRouteAliases,
initVTKToolsAliases,
} from './aliases.js';
// ***********************************************
@@ -36,14 +39,46 @@ import {
* @param {string} PatientName - Patient name that we would like to search for
*/
Cypress.Commands.add('openStudy', patientName => {
cy.visit('/');
cy.openStudyList();
cy.get('#patientName').type(patientName);
cy.wait('@getStudies');
cy.get('#studyListData .studylistStudy', { timeout: 5000 })
.contains(patientName)
.first()
.click({ force: true });
});
cy.get('.studylistStudy > .patientName')
.as('patientResult')
.then({ timeout: 5000 }, $patientResult => {
cy.contains(patientName).click();
});
/**
* Command to search for a modality and open the study.
*
* @param {string} modality - Modality type that we would like to search for
*/
Cypress.Commands.add('openStudyModality', modality => {
cy.initRouteAliases();
cy.visit('/');
cy.get('#modalities')
.type(modality)
.wait(2000);
cy.get('#studyListData')
.contains(modality)
.first()
.click();
});
/**
* Command to wait and check if a new page was loaded
*
* @param {string} url - part of the expected url. Default value is /viewer/
*/
Cypress.Commands.add('isPageLoaded', (url = '/viewer/') => {
return cy.location('pathname', { timeout: 60000 }).should('include', url);
});
Cypress.Commands.add('openStudyList', patientName => {
cy.initRouteAliases();
cy.visit('/');
cy.wait('@getStudies');
});
/**
@@ -103,33 +138,43 @@ Cypress.Commands.add(
}
);
Cypress.Commands.add('expectMinimumThumbnails', (seriesToWait = 1) => {
cy.get('[data-cy=thumbnail-list]', { timeout: 10000 }).should($itemList => {
expect($itemList.length >= seriesToWait).to.be.true;
});
});
//Command to wait DICOM image to load into the viewport
Cypress.Commands.add('waitDicomImage', (timeout = 20000) => {
cy.window()
.its('cornerstone')
.then({ timeout }, $cornerstone => {
return new Cypress.Promise(resolve => {
const onEvent = renderedEvt => {
const element = renderedEvt.detail.element;
const loaded = cy.isPageLoaded();
element.removeEventListener('cornerstoneimagerendered', onEvent);
$cornerstone.events.removeEventListener(
'cornerstoneimagerendered',
onEvent
if (loaded) {
cy.window()
.its('cornerstone')
.then({ timeout }, $cornerstone => {
return new Cypress.Promise(resolve => {
const onEvent = renderedEvt => {
const element = renderedEvt.detail.element;
element.removeEventListener('cornerstoneimagerendered', onEvent);
$cornerstone.events.removeEventListener(
'cornerstoneimagerendered',
onEvent
);
resolve();
};
const onEnabled = enabledEvt => {
const element = enabledEvt.detail.element;
element.addEventListener('cornerstoneimagerendered', onEvent);
};
$cornerstone.events.addEventListener(
'cornerstoneelementenabled',
onEnabled
);
resolve();
};
const onEnabled = enabledEvt => {
const element = enabledEvt.detail.element;
element.addEventListener('cornerstoneimagerendered', onEvent);
};
$cornerstone.events.addEventListener(
'cornerstoneelementenabled',
onEnabled
);
});
});
});
}
});
//Command to reset and clear all the changes made to the viewport
@@ -182,21 +227,113 @@ Cypress.Commands.add('initCommonElementsAliases', () => {
initCommonElementsAliases();
});
//Add measurements in the viewport
Cypress.Commands.add('addLengthMeasurement', () => {
cy.initCornerstoneToolsAliases();
cy.get('@lengthBtn').click();
const firstClick = [150, 100];
const secondClick = [130, 170];
cy.addLine('.cornerstone-canvas', firstClick, secondClick);
//Initialize aliases for Routes
Cypress.Commands.add('initRouteAliases', () => {
initRouteAliases();
});
//Initialize aliases for VTK tools
Cypress.Commands.add('initVTKToolsAliases', () => {
initVTKToolsAliases();
});
//Add measurements in the viewport
Cypress.Commands.add('addAngleMeasurement', () => {
cy.initCornerstoneToolsAliases();
cy.get('@angleBtn').click();
const initPos = [180, 390];
const midPos = [300, 410];
const finalPos = [180, 450];
cy.addAngle('.cornerstone-canvas', initPos, midPos, finalPos);
Cypress.Commands.add(
'addLengthMeasurement',
(firstClick = [150, 100], secondClick = [130, 170]) => {
cy.initCornerstoneToolsAliases();
cy.get('@lengthBtn').click();
cy.addLine('@viewport', firstClick, secondClick);
}
);
//Add measurements in the viewport
Cypress.Commands.add(
'addAngleMeasurement',
(initPos = [180, 390], midPos = [300, 410], finalPos = [180, 450]) => {
cy.initCornerstoneToolsAliases();
cy.get('@angleBtn').click();
cy.addAngle('@viewport', initPos, midPos, finalPos);
}
);
/**
* Tests if element is NOT in viewport, or does not exist in DOM
*
* @param {string} element - element selector string or alias
* @returns
*/
Cypress.Commands.add('isNotInViewport', element => {
cy.get(element, { timeout: 3000 }).should($el => {
const bottom = Cypress.$(cy.state('window')).height() - 50;
const right = Cypress.$(cy.state('window')).width() - 50;
// If it's not visible, it's not in the viewport
if ($el) {
const rect = $el[0].getBoundingClientRect();
// TODO: support leftOf, above
const isBeneath = rect.top >= bottom && rect.bottom >= bottom;
const isRightOf = rect.left >= right && rect.right >= right;
const isNotInViewport = isBeneath && isRightOf;
expect(isNotInViewport).to.be.true;
}
});
});
/**
* Tests if element is in viewport, or it does exist in DOM
*
* @param {string} element - element selector string or alias
* @returns
*/
Cypress.Commands.add('isInViewport', element => {
cy.get(element, { timeout: 3000 }).should($el => {
const bottom = Cypress.$(cy.state('window')).height();
const right = Cypress.$(cy.state('window')).width();
// If it's not visible, it's not in the viewport
if ($el) {
const rect = $el[0].getBoundingClientRect();
// TODO: support leftOf, above
const isBeneath = rect.top < bottom && rect.bottom < bottom;
const isRightOf = rect.left < right && rect.right < right;
const isInViewport = isBeneath && isRightOf;
expect(isInViewport).to.be.true;
}
});
});
/**
* Percy.io Canvas screenshot workaround
*
*/
Cypress.Commands.add('percyCanvasSnapshot', (name, options = {}) => {
function convertCanvas(documentClone) {
documentClone
.querySelectorAll('canvas')
.forEach(selector => canvasToImage(selector));
return documentClone;
}
function canvasToImage(selectorOrEl) {
let canvas =
typeof selectorOrEl === 'object'
? selectorOrEl
: document.querySelector(selectorOrEl);
let image = document.createElement('img');
let canvasImageBase64 = canvas.toDataURL();
image.src = canvasImageBase64;
image.style = 'max-width: 100%';
canvas.setAttribute('data-percy-modified', true);
canvas.parentElement.appendChild(image);
canvas.style = 'display: none';
}
cy.percySnapshot(name, { ...options, domTransformation: convertCanvas });
});
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@ohif/viewer",
"version": "1.6.3",
"version": "1.12.1",
"description": "OHIF Viewer",
"author": "OHIF Contributors",
"license": "MIT",
@@ -27,7 +27,7 @@
"dev:viewer": "yarn run dev",
"start": "yarn run dev",
"test:e2e": "cypress open",
"test:e2e:ci": "cypress run",
"test:e2e:ci": "percy exec -- cypress run --browser chrome",
"test:e2e:dist": "start-server-and-test test:e2e:serve http://localhost:3000 test:e2e:ci",
"test:e2e:serve": "serve -n -l 3000 -s dist",
"test:unit": "jest --watchAll",
@@ -45,14 +45,14 @@
},
"dependencies": {
"@babel/runtime": "^7.5.5",
"@ohif/core": "^1.3.2",
"@ohif/core": "^1.5.1",
"@ohif/extension-cornerstone": "^2.0.0",
"@ohif/extension-dicom-html": "^1.0.0",
"@ohif/extension-dicom-microscopy": "^0.50.6",
"@ohif/extension-dicom-pdf": "^1.0.0",
"@ohif/extension-vtk": "^0.52.8",
"@ohif/i18n": "^0.50.5",
"@ohif/ui": "^0.53.4",
"@ohif/extension-vtk": "^0.52.18",
"@ohif/i18n": "^0.51.0",
"@ohif/ui": "^0.56.0",
"@tanem/react-nprogress": "^1.1.25",
"classnames": "^2.2.6",
"core-js": "^3.2.1",
@@ -82,7 +82,7 @@
"redux-oidc": "3.1.x",
"redux-thunk": "^2.3.0",
"reselect": "^4.0.0",
"vtk.js": "^11.0.1"
"vtk.js": "^11.7.2"
},
"devDependencies": {
"cypress": "^3.3.1",
+7 -2
View File
@@ -27,6 +27,7 @@ import { BrowserRouter as Router } from 'react-router-dom';
import { getActiveContexts } from './store/layout/selectors.js';
import i18n from '@ohif/i18n';
import store from './store';
import { SnackbarProvider } from '@ohif/ui';
// Contexts
import WhiteLabellingContext from './context/WhiteLabellingContext';
@@ -99,7 +100,9 @@ class App extends Component {
<WhiteLabellingContext.Provider
value={this.props.whiteLabelling}
>
<OHIFStandaloneViewer userManager={userManager} />
<SnackbarProvider>
<OHIFStandaloneViewer userManager={userManager} />
</SnackbarProvider>
</WhiteLabellingContext.Provider>
</Router>
</UserManagerContext.Provider>
@@ -116,7 +119,9 @@ class App extends Component {
<I18nextProvider i18n={i18n}>
<Router basename={this.props.routerBasename}>
<WhiteLabellingContext.Provider value={this.props.whiteLabelling}>
<OHIFStandaloneViewer />
<SnackbarProvider>
<OHIFStandaloneViewer />
</SnackbarProvider>
</WhiteLabellingContext.Provider>
</Router>
</I18nextProvider>
@@ -96,3 +96,27 @@
color: #91b9cd;
margin-right: 1rem;
}
.header-versionInfo {
display: inline-block;
color: black;
background: #9ccef9;
padding: 0px 8px;
border-radius: 16px;
font-size: 12px;
margin-left: 10px;
font-weight: bold;
}
.header-versionInfoHome {
display: block;
color: black;
position: absolute;
bottom: 16px;
right: 0;
background: #9ccef9;
padding: 0px 8px;
border-radius: 16px;
font-size: 12px;
font-weight: bold;
}
@@ -101,6 +101,14 @@ class Header extends Component {
</Link>
)}
<span
className={`${
this.props.home ? 'header-versionInfoHome' : 'header-versionInfo'
}`}
>
v{process.env.VERSION_NUMBER}
</span>
{this.props.children}
{showStudyList && !this.props.home && (
@@ -32,6 +32,11 @@ const ViewportGrid = function(props) {
const ViewportPanes = layout.viewports.map((layout, viewportIndex) => {
const displaySet = viewportData[viewportIndex];
if (!displaySet) {
return null;
}
const data = {
displaySet,
studies,
@@ -108,6 +113,9 @@ ViewportGrid.propTypes = {
setViewportData: PropTypes.func.isRequired,
studies: PropTypes.array,
children: PropTypes.node,
defaultPlugin: PropTypes.string,
numRows: PropTypes.number.isRequired,
numColumns: PropTypes.number.isRequired,
};
ViewportGrid.defaultProps = {
@@ -1,9 +1,14 @@
// import OHIF from '@ohif/core';
import OHIF from '@ohif/core';
import React from 'react';
import PluginSwitch from './PluginSwitch.js';
import { commandsManager } from './../App.js';
import { connect } from 'react-redux';
// const { setLayout } = OHIF.redux.actions;
const { setLayout } = OHIF.redux.actions;
const ConnectedPluginSwitch = props => {
return <PluginSwitch {...props} />;
};
const mapStateToProps = state => {
const { activeViewportIndex, layout, viewportSpecificData } = state.viewports;
@@ -15,13 +20,13 @@ const mapStateToProps = state => {
};
};
// const mapDispatchToProps = dispatch => {
// return {
// setLayout: data => {
// dispatch(setLayout(data));
// }
// };
// };
const mapDispatchToProps = dispatch => {
return {
setLayout: data => {
dispatch(setLayout(data));
},
};
};
/*function setSingleLayoutData(originalArray, viewportIndex, data) {
const viewports = originalArray.slice();
@@ -33,23 +38,35 @@ const mapStateToProps = state => {
}*/
const mergeProps = (propsFromState, propsFromDispatch, ownProps) => {
//const { activeViewportIndex, layout } = propsFromState;
//const { setLayout } = propsFromDispatch;
const { activeViewportIndex, viewportSpecificData } = propsFromState;
const { studies } = ownProps;
const { setLayout } = propsFromDispatch;
// TODO: Do not display certain options if the current display set
// cannot be displayed using these view types
const mpr = () => {
commandsManager.runCommand('mpr2d');
};
const exitMpr = () => {
const layout = {
numRows: 1,
numColumns: 1,
viewports: [{ plugin: 'cornerstone' }],
};
setLayout(layout);
};
return {
mpr,
exitMpr,
activeViewportIndex,
viewportSpecificData,
studies,
};
};
const ConnectedPluginSwitch = connect(
export default connect(
mapStateToProps,
null, // mapDispatchToProps
mapDispatchToProps,
mergeProps
)(PluginSwitch);
export default ConnectedPluginSwitch;
)(ConnectedPluginSwitch);
@@ -6,17 +6,101 @@ import './PluginSwitch.css';
class PluginSwitch extends Component {
static propTypes = {
mpr: PropTypes.func,
activeViewportIndex: PropTypes.number,
viewportSpecificData: PropTypes.object,
studies: PropTypes.array,
exitMpr: PropTypes.func,
};
static defaultProps = {};
constructor(props) {
super(props);
this.state = {
isPlugSwitchOn: false,
label: '2D MPR',
icon: 'cube',
};
}
handleClick = () => {
if (this.state.isPlugSwitchOn) {
this.setState({
isPlugSwitchOn: false,
label: '2D MPR',
icon: 'cube',
});
this.props.exitMpr();
} else {
this.setState({
isPlugSwitchOn: true,
label: 'Exit 2D MPR',
icon: 'times',
});
this.props.mpr();
}
};
render() {
const { label, icon } = this.state;
// Render exit mpr if switched on, otherwise check if mpr button should be displayed.
const shouldRender =
this.state.isPlugSwitchOn || _shouldRenderMpr2DButton.call(this);
return (
<div className="PluginSwitch">
<ToolbarButton label="2D MPR" icon="cube" onClick={this.props.mpr} />
</div>
<>
{shouldRender && (
<div className="PluginSwitch">
<ToolbarButton
label={label}
icon={icon}
onClick={this.handleClick}
/>
</div>
)}
</>
);
}
}
function _shouldRenderMpr2DButton() {
const { viewportSpecificData, studies, activeViewportIndex } = this.props;
if (!viewportSpecificData[activeViewportIndex]) {
return;
}
const { displaySetInstanceUid, studyInstanceUid } = viewportSpecificData[
activeViewportIndex
];
const displaySet = _getDisplaySet(
studies,
studyInstanceUid,
displaySetInstanceUid
);
if (!displaySet) {
return;
}
return displaySet.isReconstructable;
}
function _getDisplaySet(studies, studyInstanceUid, displaySetInstanceUid) {
const study = studies.find(
study => study.studyInstanceUid === studyInstanceUid
);
if (!study) {
return;
}
const displaySet = study.displaySets.find(set => {
return set.displaySetInstanceUid === displaySetInstanceUid;
});
return displaySet;
}
export default PluginSwitch;
@@ -25,6 +25,7 @@ class ToolbarRow extends Component {
selectedRightSidePanel: PropTypes.string.isRequired,
handleSidePanelChange: PropTypes.func,
activeContexts: PropTypes.arrayOf(PropTypes.string).isRequired,
studies: PropTypes.array,
};
constructor(props) {
@@ -128,7 +129,7 @@ class ToolbarRow extends Component {
</div>
{buttonComponents}
<ConnectedLayoutButton />
<ConnectedPluginSwitch />
<ConnectedPluginSwitch studies={this.props.studies} />
<div
className="pull-right m-t-1 rm-x-1"
style={{ marginLeft: 'auto' }}
@@ -229,7 +230,6 @@ function _getButtonComponents(toolbarButtons, activeButtons) {
});
}
/**
* A handy way for us to handle different button types. IE. firing commands for
* buttons, or initiation built in behavior.
@@ -279,6 +279,7 @@ class Viewer extends Component {
this.setState(updatedState);
}}
studies={this.props.studies}
/>
{/*<ConnectedStudyLoadingMonitor studies={this.props.studies} />*/}
@@ -22,8 +22,6 @@ class ViewerMain extends Component {
this.state = {
displaySets: [],
};
this.cachedViewportData = {};
}
getDisplaySets(studies) {
@@ -65,63 +63,75 @@ class ViewerMain extends Component {
this.setState({
displaySets,
});
this.fillEmptyViewportPanes();
}
}
componentDidUpdate(prevProps) {
if (this.props.studies !== prevProps.studies) {
const prevViewportAmount = prevProps.layout.viewports.length;
const viewportAmount = this.props.layout.viewports.length;
const isVtk = this.props.layout.viewports.some(vp => !!vp.vtk);
if (
this.props.studies !== prevProps.studies ||
(viewportAmount !== prevViewportAmount && !isVtk)
) {
const displaySets = this.getDisplaySets(this.props.studies);
this.setState({
displaySets,
});
this.fillEmptyViewportPanes();
}
}
getViewportData = () => {
const viewportData = [];
fillEmptyViewportPanes = () => {
const dirtyViewportPanes = [];
const { layout, viewportSpecificData } = this.props;
const { displaySets } = this.state;
for (
let viewportIndex = 0;
viewportIndex < layout.viewports.length;
viewportIndex++
) {
let displaySet = viewportSpecificData[viewportIndex];
// Use the cached display set in viewport if the new one is empty
if (displaySet && !displaySet.displaySetInstanceUid) {
displaySet = this.cachedViewportData[viewportIndex];
}
if (
displaySet &&
displaySet.studyInstanceUid &&
displaySet.displaySetInstanceUid
) {
// Get missing fields from original display set
const originalDisplaySet = this.findDisplaySet(
this.props.studies,
displaySet.studyInstanceUid,
displaySet.displaySetInstanceUid
);
viewportData.push(Object.assign({}, originalDisplaySet, displaySet));
} else {
// If the viewport is empty, get one available in study
const { displaySets } = this.state;
displaySet = displaySets.find(
ds =>
!viewportData.some(
v => v.displaySetInstanceUid === ds.displaySetInstanceUid
)
);
viewportData.push(Object.assign({}, displaySet));
}
if (!displaySets || !displaySets.length) {
return;
}
this.cachedViewportData = viewportData;
for (let i = 0; i < layout.viewports.length; i++) {
const viewportPane = viewportSpecificData[i];
const isNonEmptyViewport =
viewportPane &&
viewportPane.studyInstanceUid &&
viewportPane.displaySetInstanceUid;
return viewportData;
if (isNonEmptyViewport) {
dirtyViewportPanes.push({
studyInstanceUid: viewportPane.studyInstanceUid,
displaySetInstanceUid: viewportPane.displaySetInstanceUid,
});
continue;
}
const foundDisplaySet =
displaySets.find(
ds =>
!dirtyViewportPanes.some(
v => v.displaySetInstanceUid === ds.displaySetInstanceUid
)
) || displaySets[displaySets.length - 1];
dirtyViewportPanes.push(foundDisplaySet);
}
dirtyViewportPanes.forEach((vp, i) => {
if (vp && vp.studyInstanceUid) {
this.setViewportData({
viewportIndex: i,
studyInstanceUid: vp.studyInstanceUid,
displaySetInstanceUid: vp.displaySetInstanceUid,
});
}
});
};
setViewportData = ({
@@ -139,12 +149,17 @@ class ViewerMain extends Component {
};
render() {
const { viewportSpecificData } = this.props;
const viewportData = viewportSpecificData
? Object.values(viewportSpecificData)
: [];
return (
<div className="ViewerMain">
{this.state.displaySets.length && (
<ConnectedViewportGrid
studies={this.props.studies}
viewportData={this.getViewportData()}
viewportData={viewportData}
setViewportData={this.setViewportData}
>
{/* Children to add to each viewport that support children */}
@@ -0,0 +1,9 @@
import React, { useEffect, useRef } from 'react';
export default function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
+72 -15
View File
@@ -1,8 +1,10 @@
import React, { useContext } from 'react';
import GoogleCloudApi from '../googleCloud/api/GoogleCloudApi';
import usePrevious from './usePrevious';
import * as GoogleCloudUtilServers from '../googleCloud/utils/getServers';
import { useSelector, useDispatch } from 'react-redux';
import isEqual from 'lodash.isequal';
// Contexts
import AppContext from '../context/AppContext';
@@ -32,28 +34,68 @@ const getServers = (appConfig, project, location, dataset, dicomStore) => {
wadoRoot: pathUrl,
};
servers = GoogleCloudUtilServers.getServers(data, dicomStore);
if (!isValidServer(servers[0], appConfig)) {
return;
}
}
return servers;
};
const updateServer = (
const isValidServer = (server, appConfig) => {
if (appConfig.enableGoogleCloudAdapter) {
return GoogleCloudUtilServers.isValidServer(server);
}
return !!server;
};
const setServers = (dispatch, servers) => {
const action = {
type: 'SET_SERVERS',
servers,
};
dispatch(action);
};
const useServerFromUrl = (
servers = [],
previousServers,
activeServer,
urlBasedServers,
appConfig,
dispatch,
project,
location,
dataset,
dicomStore
) => {
const servers = getServers(appConfig, project, location, dataset, dicomStore);
if (servers && servers.length) {
const action = {
type: 'SET_SERVERS',
servers,
};
dispatch(action);
// update state from url available only when gcloud on
if (!appConfig.enableGoogleCloudAdapter) {
return false;
}
const serverHasChanged = previousServers !== servers && previousServers;
// do not update from url. use state instead.
if (serverHasChanged) {
return false;
}
// if no valid urlbased servers
if (!urlBasedServers || !urlBasedServers.length) {
return false;
} else if (!servers.length || !activeServer) {
// no current valid server
return true;
}
const newServer = urlBasedServers[0];
let exists = servers.some(
GoogleCloudUtilServers.isEqualServer.bind(undefined, newServer)
);
return !exists;
};
export default function useServer({
@@ -64,14 +106,29 @@ export default function useServer({
} = {}) {
// Hooks
const servers = useSelector(state => state && state.servers);
const previousServers = usePrevious(servers);
const dispatch = useDispatch();
const { appConfig = {} } = useContext(AppContext);
const server = getActiveServer(servers);
const activeServer = getActiveServer(servers);
const urlBasedServers =
getServers(appConfig, project, location, dataset, dicomStore) || [];
const shouldUpdateServer = useServerFromUrl(
servers.servers,
previousServers,
activeServer,
urlBasedServers,
appConfig,
project,
location,
dataset,
dicomStore
);
if (!server) {
updateServer(appConfig, dispatch, project, location, dataset, dicomStore);
} else {
return server;
if (shouldUpdateServer) {
setServers(dispatch, urlBasedServers);
} else if (isValidServer(activeServer, appConfig)) {
return activeServer;
}
}
@@ -22,6 +22,8 @@ class DicomStorePickerModal extends Component {
handleEvent = data => {
const servers = GoogleCloudUtilServers.getServers(data, data.dicomstore);
this.props.setServers(servers);
// Force auto close
this.props.onClose();
};
render() {
@@ -29,6 +29,10 @@ class GoogleCloudApi {
);
}
getUrlPath(project, location, dataset, dicomStore) {
`/projects/${project}/locations/${location}/datasets/${dataset}/dicomStores/${dicomStore}`;
}
async doRequest(urlStr, config = {}, params = {}) {
const url = new URL(urlStr);
let data = null;
@@ -29,4 +29,31 @@ const getServers = (data, name) => {
];
};
export { getServers };
const isValidServer = server => {
return (
server &&
!!server.dataset &&
!!server.dicomStore &&
!!server.location &&
!!server.project
);
};
const isEqualServer = (server = {}, toCompare = {}) => {
const serverLength = Object.keys(server).length;
const toCompareLength = Object.keys(toCompare).length;
if (!serverLength || !toCompareLength) {
return false;
}
return (
server.dataset === toCompare.dataset &&
server.dataset === toCompare.dataset &&
server.dicomStore === toCompare.dicomStore &&
server.location === toCompare.location &&
server.project === toCompare.project
);
};
export { getServers, isValidServer, isEqualServer };
+35 -12
View File
@@ -65,6 +65,19 @@ const ROUTES_DEF = {
return !!appConfig.enableGoogleCloudAdapter;
},
},
list: {
path:
'/projects/:project/locations/:location/datasets/:dataset/dicomStores/:dicomStore',
component: StudyListRouting,
condition: appConfig => {
const showList =
appConfig.showStudyList !== undefined
? appConfig.showStudyList
: true;
return showList && !!appConfig.enableGoogleCloudAdapter;
},
},
},
};
@@ -92,23 +105,33 @@ const getRoutes = appConfig => {
return routes;
};
const parsePath = (path, server, params) => {
let _path = path;
const _paramsCopy = Object.assign({}, server, params);
for (let key in _paramsCopy) {
_path = UrlUtil.paramString.replaceParam(_path, key, _paramsCopy[key]);
}
return _path;
};
const parseViewerPath = (appConfig = {}, server = {}, params) => {
let viewerPath = ROUTES_DEF.default.viewer.path;
if (appConfig.enableGoogleCloudAdapter) {
viewerPath = ROUTES_DEF.gcloud.viewer.path;
}
const _paramsCopy = Object.assign({}, server, params);
for (let key in _paramsCopy) {
viewerPath = UrlUtil.paramString.replaceParam(
viewerPath,
key,
_paramsCopy[key]
);
}
return viewerPath;
return parsePath(viewerPath, server, params);
};
export { getRoutes, parseViewerPath, reload };
const parseStudyListPath = (appConfig = {}, server = {}, params) => {
let studyListPath = ROUTES_DEF.default.list.path;
if (appConfig.enableGoogleCloudAdapter) {
studyListPath = ROUTES_DEF.gcloud.list.path || studyListPath;
}
return parsePath(studyListPath, server, params);
};
export { getRoutes, parseViewerPath, parseStudyListPath, reload };
@@ -2,17 +2,26 @@ import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { withRouter } from 'react-router-dom';
import ConnectedStudyList from './ConnectedStudyList';
import useServer from '../customHooks/useServer';
import OHIF from '@ohif/core';
const { urlUtil: UrlUtil } = OHIF.utils;
// Contexts
import AppContext from '../context/AppContext';
function StudyListRouting({ location }) {
function StudyListRouting({ match: routeMatch, location: routeLocation }) {
const {
project,
location,
dataset,
dicomStore,
studyInstanceUids,
seriesInstanceUids,
} = routeMatch.params;
const server = useServer({ project, location, dataset, dicomStore });
const { appConfig = {} } = useContext(AppContext);
const filters = UrlUtil.queryString.getQueryFilters(location);
const filters = UrlUtil.queryString.getQueryFilters(routeLocation);
let studyListFunctionsEnabled = false;
if (appConfig.studyListFunctionsEnabled) {
@@ -8,10 +8,12 @@ import { StudyList } from '@ohif/ui';
import ConnectedHeader from '../connectedComponents/ConnectedHeader.js';
import * as RoutesUtil from '../routes/routesUtil';
import moment from 'moment';
import isEqual from 'lodash.isequal';
import ConnectedDicomFilesUploader from '../googleCloud/ConnectedDicomFilesUploader';
import ConnectedDicomStorePicker from '../googleCloud/ConnectedDicomStorePicker';
import filesToStudies from '../lib/filesToStudies.js';
const { urlUtil: UrlUtil } = OHIF.utils;
// Contexts
import UserManagerContext from '../context/UserManagerContext';
import WhiteLabellingContext from '../context/WhiteLabellingContext';
@@ -20,8 +22,9 @@ import AppContext from '../context/AppContext';
class StudyListWithData extends Component {
static contextType = AppContext;
state = {
searchData: {},
searchOutdated: true,
studies: [],
searchingStudies: false,
error: null,
modalComponentId: null,
};
@@ -62,6 +65,7 @@ class StudyListWithData extends Component {
if (!this.props.server && appConfig.enableGoogleCloudAdapter) {
this.setState({
modalComponentId: 'DicomStorePicker',
searchOutdated: false,
});
} else {
this.searchForStudies({
@@ -71,21 +75,33 @@ class StudyListWithData extends Component {
}
}
componentDidUpdate(prevProps) {
if (!this.state.searchData && !this.state.studies) {
this.searchForStudies();
}
if (this.props.server !== prevProps.server) {
this.setState({
modalComponentId: null,
searchData: null,
studies: null,
});
componentDidUpdate(prevProps, prevState) {
const hasNewServer = !isEqual(this.props.server, prevProps.server);
const { searchOutdated, searchingStudies } = this.state;
if (!searchingStudies) {
if (hasNewServer) {
const { appConfig = {} } = this.context;
const newState = {
searchOutdated: true,
studies: null,
};
if (appConfig.enableGoogleCloudAdapter) {
newState.modalComponentId = null;
}
this.setState(newState);
}
if (searchOutdated) {
this.searchForStudies();
}
}
}
searchForStudies = (searchData = StudyListWithData.defaultSearchData) => {
const { server } = this.props;
const { server = {} } = this.props;
const filter = {
patientId: searchData.patientId,
patientName: searchData.patientName,
@@ -105,7 +121,9 @@ class StudyListWithData extends Component {
// TODO: add sorting
const promise = OHIF.studies.searchStudies(server, filter);
// Render the viewer when the data is ready
this.setState({
searchingStudies: true,
});
promise
.then(studies => {
if (!studies) {
@@ -150,11 +168,15 @@ class StudyListWithData extends Component {
this.setState({
studies: sortedStudies,
searchingStudies: false,
searchOutdated: false,
});
})
.catch(error => {
this.setState({
error: true,
searchingStudies: false,
searchOutdated: false,
});
throw new Error(error);
@@ -181,9 +203,27 @@ class StudyListWithData extends Component {
const viewerPath = RoutesUtil.parseViewerPath(appConfig, server, {
studyInstanceUids: studyInstanceUID,
});
this.props.history.push(viewerPath);
if (UrlUtil.paramString.isValidPath(viewerPath)) {
this.props.history.push(viewerPath);
}
};
updateURL(modalOpened) {
if (!modalOpened) {
const { appConfig = {} } = this.context;
const { server } = this.props;
const listPath = RoutesUtil.parseStudyListPath(appConfig, server);
if (UrlUtil.paramString.isValidPath(listPath)) {
const { location = {} } = this.props.history;
if (location.pathname !== listPath) {
this.props.history.replace(listPath);
}
}
}
}
onSearch = searchData => {
this.searchForStudies(searchData);
};
@@ -208,17 +248,18 @@ class StudyListWithData extends Component {
if (this.state.error) {
return <div>Error: {JSON.stringify(this.state.error)}</div>;
} else if (this.state.studies === null && !this.state.modalComponentId) {
return <div>Loading...</div>;
}
let healthCareApiButtons = null;
let healthCareApiWindows = null;
if (appConfig.enableGoogleCloudAdapter) {
const modalOpened = this.state.modalComponentId === 'DicomStorePicker';
this.updateURL(modalOpened);
healthCareApiWindows = (
<ConnectedDicomStorePicker
isOpen={this.state.modalComponentId === 'DicomStorePicker'}
isOpen={modalOpened}
onClose={this.closeModals}
/>
);
@@ -240,8 +281,9 @@ class StudyListWithData extends Component {
const studyList = (
<div className="paginationArea">
{this.state.studies ? (
{this.state.studies || this.state.searchingStudies ? (
<StudyList
loading={this.state.searchingStudies}
studies={this.state.studies}
studyListFunctionsEnabled={this.props.studyListFunctionsEnabled}
onImport={this.onImport}
+15 -8
View File
@@ -1,6 +1,6 @@
/* Sizes */
:root {
/* Sizes */
--top-bar-height: 40px;
--top-bar-expanded-height: 160px;
--toolbar-height: 78px;
@@ -9,19 +9,26 @@
--right-sidepanel-menu-width: 323px;
--study-list-padding: 8%;
--study-list-padding-medium-screen: 10px;
}
--snackbar-size: 344px;
/* Transitions */
/* Transitions */
:root {
--transition-duration: 0.3s;
--transition-effect: ease;
--sidepanel-transition: all 0.3s ease;
}
/* Thicknesses */
/* Thicknesses */
:root {
--viewport-border-thickness: 1px;
--ui-border-thickness: 1px;
/** Colors **/
--snackbar-success: rgba(94, 164, 0, 0.9);
--snackbar-error: rgba(236, 61, 61, 0.9);
--snackbar-warning: rgba(235, 173, 23, 0.9);
--snackbar-info: rgba(54, 156, 199, 0.9);
/** z-Index **/
--snackbar-zIndex: 10;
}
+1128 -519
View File
File diff suppressed because it is too large. Load diff