Compare commits

...
Author SHA1 Message Date
ohif-bot 9a62c28b3f chore(release): publish [skip ci]
- @ohif/extension-lesion-tracker@0.2.0
 - @ohif/extension-vtk@1.2.17
 - @ohif/ui@1.2.0
 - @ohif/viewer@3.6.1
2020-02-10 20:36:28 +00:00
Igor Octaviano 98a649b455 feat: Lesion tracker right panel (#1428)
* WIP

* Finish mock

* Update name, remove vendor prefix

* Remove lesion tracker from defaults

* Refactor measurement table changes

* Remove unused code

* Update naming of collections
2020-02-10 15:33:06 -05:00
ohif-bot 3529474a16 chore(release): publish [skip ci]
- @ohif/extension-cornerstone@2.3.0
 - @ohif/extension-vtk@1.2.16
 - @ohif/core@2.4.0
 - @ohif/viewer@3.6.0
2020-02-10 19:17:19 +00:00
Igor OctavianoandDanny Brown 0c37a406d9 feat: 🎸 MeasurementService (#1314)
* feat: 🎸 MeasurementService

Work in progress...

* Remove cornerstone tools import

* Second iteration

* CR Update: Add update / added events example

* Add new props to measurements

* Update event log

* Add new measurementid to annotation

* Add context support

* Add value types

* Add area

* Add todo

* Wip measurement to annotation map

* Change points representation

* Add props to annotation mapping

* Add tests

* Extract formatter from init and add tests

* Sketch matchers

* Fix events and valuetypes imports

* Remove context support

* Rename formatter to mappings

* Sketching source and source definitions

* Adjust matching criteria in addOrUpdate

* CR Updates: Extract private functions and rename variables

* Fix broken tests

* Add more measurement service tests

* Update broken mapping tests

* Update test description

* Update getAnnotation to get mapping based on def and source

Co-authored-by: Danny Brown <danny.ri.brown@gmail.com>
2020-02-10 14:15:05 -05:00
ohif-bot e8d56c11f0 chore(release): publish [skip ci]
- @ohif/extension-vtk@1.2.15
 - @ohif/core@2.3.9
 - @ohif/viewer@3.5.1
2020-02-07 16:11:02 +00:00
Erik Ziegler a6b92a4505 Fix issues with isDisplaySetReconstructable when we have empty DICOM tags (closes #1280) (#1426) 2020-02-07 17:09:37 +01:00
ohif-bot a4c9a86772 chore(release): publish [skip ci]
- @ohif/extension-lesion-tracker@0.1.0
 - @ohif/viewer@3.5.0
2020-02-06 19:07:34 +00:00
Danny Brown 73e440968c feat: lesion-tracker extension (#1420)
* chore: init lesion-tracker extension

* chore: dev build for lesion-tracker extension

* fix: a more appropriate library name

* chore: fix script naming
2020-02-06 14:04:18 -05:00
ohif-bot 3f7eb8a5d3 chore(release): publish [skip ci]
- @ohif/extension-vtk@1.2.14
 - @ohif/core@2.3.8
 - @ohif/viewer@3.4.14
2020-02-06 02:56:29 +00:00
Gustavo André LelisandDanny Brown 7b2d44f2c1 fix: Remove trash data from redux storage after updates (#1358)
* Remove tash data from redux storage after updates

* Imprelemnted more unit tests

* Remove missing comment

Co-authored-by: Danny Brown <danny.ri.brown@gmail.com>
2020-02-05 21:54:57 -05:00
ohif-bot 225da81135 chore(release): publish [skip ci]
- @ohif/extension-vtk@1.2.13
 - @ohif/core@2.3.7
 - @ohif/viewer@3.4.13
2020-01-30 18:33:22 +00:00
Rodrigo AntinarelliandDanny Brown 686d12da5c fix: Set VTK viewport as active by interaction (#1139)
* fix: Set VTK viewport as active by interaction

* feat: listen for vtkscrollevent in wrapper component

* fix: definitions can skip empty storeContexts key

* hoc to set/pass in commandsManager

* Bump minimum react-vtkjs-viewport version to leverage new event

* Simplify to use onScroll event instead of passing down commandsManager to base component

* fix: make sure we include @JamesAPetts bug fix

Co-authored-by: Danny Brown <danny.ri.brown@gmail.com>
2020-01-30 13:26:06 -05:00
ohif-bot c22756051b chore(release): publish [skip ci]
- @ohif/viewer@3.4.12
2020-01-30 03:46:42 +00:00
8fdfcc0c7d chore: Remove unused dependency on favicons-webpack-plugin. (#1351)
This dependency depends on the `favicons` library which depends on
`sharp` which depends on a native library.

This complicates the build process as the library is built from source
because there's no binary available for my macOS Catalina
installation. As this is a common developer setup I expect others to
run into this issue as well, which is why I opted to fix this issue.

Co-authored-by: Danny Brown <danny.ri.brown@gmail.com>
Co-authored-by: Erik Ziegler <erik.sweed@gmail.com>
2020-01-29 22:43:15 -05:00
ohif-bot 44ab65c981 chore(release): publish [skip ci]
- @ohif/extension-vtk@1.2.12
 - @ohif/i18n@0.52.5
 - @ohif/ui@1.1.9
 - @ohif/viewer@3.4.11
2020-01-30 03:42:31 +00:00
b9574b6efc fix: download tool fixes & improvements (#1235)
* fix: download tool fixes & improvements

* fix filename error

* Create variable to track erros

* Fix small console error

* Fix all conflicts and merge changes from latest master with this PR's improvements

* Small improvement on select and textinput labels

* Add new icon for unlink

* Refactor on download image modal

* Adding loading screen

* Fix translation issue

* Fixing aspect Ratio and E2E tests

* Allow empty value without setting to 0

* Remove eslint comments

* Fixing typos

Co-authored-by: Gustavo André Lelis <galelis@gmail.com>
Co-authored-by: Danny Brown <danny.ri.brown@gmail.com>
2020-01-29 22:39:44 -05:00
ohif-bot 96a8e26786 chore(release): publish [skip ci]
- @ohif/viewer@3.4.10
2020-01-28 21:32:03 +00:00
Mirna SilvaandDanny Brown 80f5b23622 test: 1316 - E2E tests for /local (#1379)
* 1316 - E2E tests for /local

* fix test title

* update yarn.lock

* Increased timeout for thumbnails

* Removing PDF file test from script-tag server

Co-authored-by: Danny Brown <danny.ri.brown@gmail.com>
2020-01-28 16:29:17 -05:00
ohif-bot 2b4950e954 chore(release): publish [skip ci]
- @ohif/viewer@3.4.9
2020-01-28 19:18:20 +00:00
dependabot[bot] e16f14089d chore(deps-dev): bump lodash from 4.17.11 to 4.17.15 in /platform/viewer (#1403)
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.11 to 4.17.15.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.11...4.17.15)

Signed-off-by: dependabot[bot] <support@github.com>
2020-01-28 14:15:59 -05:00
Danny Brown c3807a89ed chore: exclude extension discussion 2020-01-28 12:46:05 -05:00
Danny Brown 049ef4a666 chore: scare away stale bot 2020-01-28 12:43:07 -05:00
ohif-bot d5fa880a29 chore(release): publish [skip ci]
- @ohif/extension-vtk@1.2.11
 - @ohif/core@2.3.6
 - @ohif/viewer@3.4.8
2020-01-28 14:03:08 +00:00
Erik Ziegler 1b9a6db73f Handle Palette Color images where LUT has been returned as InlineBinary (#1350) (#1401) 2020-01-28 15:01:13 +01:00
ohif-bot ed2692908e chore(release): publish [skip ci]
- @ohif/viewer@3.4.7
2020-01-28 12:54:21 +00:00
Joël Spaltenstein 0c6c5a5d51 OIDC Initiating Login from a Third Party
https://openid.net/specs/openid-connect-core-1_0.html#ThirdPartyInitiatedLogin
2020-01-28 13:50:54 +01:00
ohif-bot b449f8eb0e chore(release): publish [skip ci]
- @ohif/extension-cornerstone@2.2.2
 - @ohif/extension-vtk@1.2.10
 - @ohif/core@2.3.5
 - @ohif/viewer@3.4.6
2020-01-28 12:41:49 +00:00
Erik Ziegler cf5160fb13 Add DICOM Group 6xxx Overlay Plane rendering (#1402)
* Add DICOM Group 6xxx Overlay Plane rendering

* Bump to cornerstone-tools 4.12.0 to support DICOM 6xxx Overlays
2020-01-28 13:40:03 +01:00
ohif-bot 4c1c91c006 chore(release): publish [skip ci]
- @ohif/viewer@3.4.5
2020-01-27 16:10:06 +00:00
Mirna Silva 9ed39c149b chore: Removing dependency ffmpeg-installer (#1397)
Fixes FOSSA warning/failure
2020-01-27 11:06:51 -05:00
ohif-bot b3f0fe029d chore(release): publish [skip ci]
- @ohif/extension-vtk@1.2.9
 - @ohif/core@2.3.4
 - @ohif/viewer@3.4.4
2020-01-27 09:14:56 +00:00
Erik Ziegler a1b78afdb9 Add palette color cache object back to core (fix #1350) (#1395) 2020-01-27 10:12:07 +01:00
Mirna Silva 3d3cfb1b41 Merge pull request #1393 from mirnasilva/fix_NewPatientSaveMeasurement
Fix on CI - Choosing another Patient Study
2020-01-24 16:17:10 -03:00
Mirna Silva b812fe03d2 Fix on CI - Changing to another Patient Study 2020-01-24 15:56:25 -03:00
ohif-bot 84f087bca4 chore(release): publish [skip ci]
- @ohif/extension-vtk@1.2.8
 - @ohif/core@2.3.3
 - @ohif/viewer@3.4.3
2020-01-24 10:33:21 +00:00
Igor Octaviano a7e612eb88 refactor: 💡 Clean up JSON launch with DICOMWeb server (#1388)
* refactor: 💡 Clean up JSON launch with DICOMWeb server

Clean up JSON launch with DICOMWeb server

✅ Closes: #1387

* Remove test file

* parse seriesinstanceuids in query

* Add default studies

* Remove test file

* Cleanup retrievedata changes

* Set active server
2020-01-24 10:31:22 +00:00
Mirna Silva 9e3afda2f1 Merge pull request #1384 from mirnasilva/percy-ci-fix
Visual tests: Fixed ci config for visual tests (percy)
2020-01-20 11:13:24 -03:00
Mirna Silva 4d7a91a584 Merge branch 'master' into percy-ci-fix 2020-01-17 20:48:51 -03:00
Mirna Silva 1dc320edcd Fixed ci config for visual tests 2020-01-17 20:44:45 -03:00
ohif-bot 1d02f5485f chore(release): publish [skip ci]
- @ohif/viewer@3.4.2
2020-01-17 22:45:01 +00:00
Mirna Silva 4c9a977920 Merge pull request #1382 from OHIF/mirna/test/visual-tests
Percy fix for CI failure
2020-01-17 19:43:19 -03:00
Mirna Silva b5db09c25e Merge pull request #1383 from mirnasilva/percy-ci-fix
Percy ci fix
2020-01-17 19:14:16 -03:00
Mirna Silva 8dc4a5b82a Merge branch 'percy-ci-fix' of https://github.com/mirnasilva/Viewers into percy-ci-fix 2020-01-17 16:36:08 -03:00
Mirna Silva 44add9ce2f Fix for save measurements failure 2020-01-17 16:35:26 -03:00
Mirna Silva c19eceeede Merge pull request #1381 from mirnasilva/percy-ci-fix
Percy tests - fix for CI failure
2020-01-17 14:46:43 -03:00
Mirna Silva 72bc5a5ae5 Merge branch 'master' into percy-ci-fix 2020-01-17 14:45:13 -03:00
Mirna Silva dfdf46dbdd fix for CI failure 2020-01-17 14:31:53 -03:00
ohif-bot 571dff2c09 chore(release): publish [skip ci]
- @ohif/viewer@3.4.1
2020-01-15 14:16:43 +00:00
Gustavo André Lelis b9a66d4424 fix: 🐛 Metadata is being mistakenly purged (#1360)
Closes: #1326
2020-01-15 09:15:10 -05:00
ohif-bot 7ab2e3e60d chore(release): publish [skip ci]
- @ohif/viewer@3.4.0
2020-01-14 15:26:35 +00:00
mukhlin a5d6bc6a51 feat: Custom Healthcare API endpoint (#1367)
* Add ability to configure Google Cloud Healthcare API endpoint.

* Fixed bug in script.

* Fixed PR remark.

* Set Healthcare API endpoint in proper place.
2020-01-14 10:22:53 -05:00
ohif-bot df612ca4a0 chore(release): publish [skip ci]
- @ohif/viewer@3.3.8
2020-01-10 18:00:27 +00:00
Mirna Silva a91ce57c43 Merge pull request #1363 from OHIF/task/improveVisualTest
Task/improve visual tests. Related to PR #1327
2020-01-10 14:57:33 -03:00
Mirna Silva 342884f83e Merge pull request #1327 from mirnasilva/1320_ImproveVisualRegression
E2E test: Percy visual regression improvements
2020-01-10 14:30:01 -03:00
Mirna Silva 0fb39bad84 Changes on config file 2020-01-10 14:18:20 -03:00
Mirna Silva c74c80bb6f Fix for VTK test 2020-01-09 16:46:29 -03:00
Mirna Silva 910d5c65d5 Changing RELEASE config to run only percy tests 2020-01-09 16:17:22 -03:00
Mirna Silva d0fb4843ab Changing screenshot paths back to original 2020-01-09 15:29:39 -03:00
Mirna Silva f488b857fe Merge branch '1320_ImproveVisualRegression' of https://github.com/mirnasilva/Viewers into 1320_ImproveVisualRegression 2020-01-09 14:50:40 -03:00
Mirna Silva 256e8b1116 Merge branch 'master' into 1320_ImproveVisualRegression 2020-01-09 14:49:42 -03:00
Mirna Silva 168dd87747 Merge branch 'master' of https://github.com/mirnasilva/Viewers into 1320_ImproveVisualRegression 2020-01-08 15:21:43 -03:00
ohif-bot 13c41e6d86 chore(release): publish [skip ci]
- @ohif/extension-vtk@1.2.7
 - @ohif/ui@1.1.8
 - @ohif/viewer@3.3.7
2020-01-08 13:48:10 +00:00
barhantasandDanny Brown 7ab0bbb325 fix: measurements panel css and delete button visibility (#1352)
Co-authored-by: Danny Brown <danny.ri.brown@gmail.com>
2020-01-08 08:43:30 -05:00
ohif-bot 2213187b14 chore(release): publish [skip ci]
- @ohif/extension-vtk@1.2.6
 - @ohif/viewer@3.3.6
2020-01-07 22:44:05 +00:00
Maher Jendoubi 4c4d8a61b6 chore: Fix a typo (#1353) 2020-01-07 17:41:16 -05:00
Mirna Silva 67f58f5f09 Added dependency ffmpeg-installer 2020-01-07 19:09:04 -03:00
Mirna Silva dfc35885bb Changed record to false 2020-01-07 18:28:16 -03:00
Mirna Silva e9c449d1db Changed path for cypress/videos 2020-01-07 18:07:20 -03:00
Mirna Silva 6dac50c7c6 Improvements on VTK visual tests 2 2020-01-07 17:23:35 -03:00
Mirna Silva 9be54f6164 Improvements on VTK visual tests 2020-01-07 16:52:28 -03:00
Mirna Silva 341cc5a4eb Merge branch 'master' into 1320_ImproveVisualRegression 2020-01-06 19:07:20 -03:00
Mirna Silva 7b80171203 Removing unnecessary screenshots 2020-01-06 18:54:09 -03:00
ohif-bot 2e5a385e53 chore(release): publish [skip ci]
- @ohif/extension-vtk@1.2.5
 - @ohif/core@2.3.2
 - @ohif/viewer@3.3.5
2020-01-06 21:29:37 +00:00
Danny Brown a3caeb381e chore: remove unused file; duplicated by studyInstanceHelpers (#1349)
* chore: remove unused file; duplicated by studyInstanceHelpers

* Change expected study list results num
2020-01-06 16:26:57 -05:00
Mirna Silva ab0af86379 Merge branch '1320_ImproveVisualRegression' of https://github.com/mirnasilva/Viewers into 1320_ImproveVisualRegression 2020-01-06 17:09:24 -03:00
Mirna Silva 86426cb024 Changed command CI line for diff test paths 2020-01-06 17:08:00 -03:00
Mirna Silva 348840ef26 Merge branch 'master' of https://github.com/mirnasilva/Viewers into 1320_ImproveVisualRegression 2020-01-06 16:20:11 -03:00
Mirna Silva 52ef934961 Merge branch 'master' into 1320_ImproveVisualRegression 2020-01-06 14:48:30 -03:00
ohif-bot be995f93b6 chore(release): publish [skip ci]
- @ohif/extension-vtk@1.2.4
 - @ohif/core@2.3.1
 - @ohif/viewer@3.3.4
2019-12-30 20:01:54 +00:00
Danny Brown 5036615e00 chore: throw our error if we encounter one (no swallow) (#1337) 2019-12-30 14:53:39 -05:00
Mirna Silva 4ee2b135a7 Added more tests and updated CI config.yml 2019-12-24 01:17:02 -03:00
Mirna Silva 388fe04e09 E2E test: Percy visua regression improvements 2019-12-23 23:10:55 -03:00
Gustavo André Lelis 2e215aed4c test: 💍 Updating tests after bug fix (#1324)
Closes: #1167
2019-12-20 19:14:07 -05:00
ohif-bot ffd27d6db4 chore(release): publish [skip ci]
- @ohif/extension-cornerstone@2.2.1
 - @ohif/extension-vtk@1.2.3
 - @ohif/viewer@3.3.3
2019-12-20 21:01:26 +00:00
Gustavo André LelisandDanny Brown 6540e36818 fix: 🐛 1241: Make Plugin switch part of ToolbarModule (#1322)
* fix: 🐛 Make Plugin switch part of ToolbarModule

Closes: #1241

* Move setCornerstoneViewport command into cornerstone extension

* Exposing extensionManager into extension's get modules functions

* Remove Plugin switch

* Updating set cornerstonelayout to work on VIEWER context

* Creating a customComponent for 2D MPR button

* Use extensionManager to show or not buttons
Adding isHidden option into toolbarModules

* Make buttons hidden depending on extension registration

* Fix unit test after changes on extensionManager

* Remove workaround for extensionManager

* Use studyMeatadataManager and remove TODO's

* Remove storeContext not needed

* Moving Exit 2D MPR into cornerstone extension with VTK context

* Remove PluginSwitch files

* Fix E2E tests

* Adding cypress screenshots into gitignore

* Remove extensionManager from getModuleFn functions and moving CustomComponent for VTK button to VTK extension

Co-authored-by: Danny Brown <danny.ri.brown@gmail.com>
2019-12-20 15:59:36 -05:00
ohif-bot a436436def chore(release): publish [skip ci]
- @ohif/extension-vtk@1.2.2
 - @ohif/ui@1.1.7
 - @ohif/viewer@3.3.2
2019-12-20 19:10:48 +00:00
Mirna Silva d6d92afbda test: Refactoring toolbar data-cy selectors (#1323) 2019-12-20 14:07:00 -05:00
ohif-bot e1142910e4 chore(release): publish [skip ci]
- @ohif/extension-vtk@1.2.1
 - @ohif/ui@1.1.6
 - @ohif/viewer@3.3.1
2019-12-20 18:57:38 +00:00
Mirna Silva 067e6c769b test: Save Measurements Functionality (#1300)
* E2E Tests: Save Measurements Functionality

* Moving test file to PWA only. Script-tag doesn't have the required Study

* Removing data-cy selector from cornerstone toolbar
2019-12-20 13:54:59 -05:00
ohif-bot d375d02a4b chore(release): publish [skip ci]
- @ohif/extension-cornerstone@2.2.0
 - @ohif/extension-vtk@1.2.0
 - @ohif/core@2.3.0
 - @ohif/viewer@3.3.0
2019-12-20 17:18:56 +00:00
Igor OctavianoandDanny Brown 63594d36b0 feat: 🎸 Configuration so viewer tools can nix handles (#1304)
* feat: 🎸 Configuration so viewer tools can nix handles

Adds a key to cornerstone configuration that allows to toggle on/off
handle rendering

Closes: #1223

* Update extension docs

* Update doc

* docs: include hideHandles configuration in our configuration docs

* chore: default to hiding handles for annotations

* chore: switch to drawHandlesOnHover to mimic legacy viewer functionality

* Flip logic for drawHandlesOnHover; reverse of hideHandles

* Don't hide the handles of ellipse or rectangle roi

* invert check

Co-authored-by: Danny Brown <danny.ri.brown@gmail.com>
2019-12-20 12:15:40 -05:00
ohif-bot 6c160f517f chore(release): publish [skip ci]
- @ohif/extension-vtk@1.1.7
 - @ohif/ui@1.1.5
 - @ohif/viewer@3.2.2
2019-12-19 15:15:49 +00:00
Gustavo André Lelis 23305cec9c fix: 🐛 Fix drag-n-drop of local files into OHIF (#1319)
* fix: 🐛 Fix drag-n-drop of local files into OHIF

Closes: #1307

* CR Update: Centralize dialogs
2019-12-19 10:14:05 -05:00
ohif-bot 36a741f0bc chore(release): publish [skip ci]
- @ohif/extension-vtk@1.1.6
 - @ohif/core@2.2.1
 - @ohif/viewer@3.2.1
2019-12-18 18:29:28 +00:00
Igor Octaviano 695ad732e1 refactor: 💡 Move DICOMSR code to @ohif/core project (#1310)
* Move DICOMSR to @ohif/core

* refactor: 💡 Move DICOMSR code to @ohif/core project

We've just merged support for DICOM Structured Reports. Today, support
for this feature lives in the @ohif/viewer project in it's lib folder.
Ideally, it should be moved to @ohif/core and given a clear public API
and unit tests.

Closes: #1282

* Update imports

* CR Update: Update imports
2019-12-18 13:25:53 -05:00
ohif-bot 73a3f07e2b chore(release): publish [skip ci]
- @ohif/extension-vtk@1.1.5
 - @ohif/core@2.2.0
 - @ohif/viewer@3.2.0
2019-12-16 19:11:59 +00:00
Igor Octaviano 4ea239a953 feat: 🎸 Expose extension config to modules (#1279)
* feat: 🎸 Expose extension config to modules

Currently, only the preRegistration hook receives the extension's
configuration as a parameter. Providing getModuleFn's with the
extension's configuration, and all lifecycle/modules with the
application's configuration as rootConfig should open the doors to more
configurable extensions.

Closes: #1268

* CR Update: Pass extension and service config through extension manager preinit/getmodule

* CR Update: Remove appConfig from servicesManager

* CR Update: Remove appconfig variable
2019-12-16 14:07:53 -05:00
ohif-bot 625d5d70a7 chore(release): publish [skip ci]
- @ohif/viewer@3.1.12
2019-12-16 18:30:12 +00:00
Igor Octaviano 5c3d8b37b6 fix: 🐛 Dismiss all dialogs if leaving viewer route #1242 (#1301)
* fix: 🐛 #1242

Dismiss all dialogs if route change

Closes: #1242

* CR Update: Dismiss dialogs in videwer
2019-12-16 13:26:31 -05:00
ohif-bot 8bbbf3a889 chore(release): publish [skip ci]
- @ohif/extension-cornerstone@2.1.1
 - @ohif/viewer@3.1.11
2019-12-16 16:49:50 +00:00
arthur-miller d5bf72851a fix: 🐛 add WwwcRegionTool to cornerstone tools initialization (#1302) 2019-12-16 11:47:02 -05:00
ohif-bot 2914c83032 chore(release): publish [skip ci]
- @ohif/extension-dicom-pdf@1.0.1
 - @ohif/viewer@3.1.10
2019-12-16 16:13:00 +00:00
Igor Octaviano 31e542dfd9 fix: 🐛 Update ohif pdf extension to optionally use pdfjs (#1162)
* fix: 🐛 Update ohif pdf extension to optionally use pdfjs

Update ohif pdf extension of optionally use pdfjs

Closes: #1049

* Update branch and fix scrolling

* Dynamically import component

* Fix wrong file change

* Fix import

* Add multi opened pdf configuration

* Refactor connected component

* Specify worker explicitly

* Specify worker explicitly

* CR Update: Remove dead code

* CR Update: Refactor state

* CR Update: use refs

* CR Update: use refs
2019-12-16 11:10:10 -05:00
ohif-bot 25599a2e2a chore(release): publish [skip ci]
- @ohif/i18n@0.52.4
 - @ohif/viewer@3.1.9
2019-12-16 15:13:14 +00:00
Danny Brown 1cf7015511 docs: stub environment variables docs 2019-12-16 09:47:12 -05:00
Igor Octaviano 756f70cc54 refactor: 💡 Use env variables to set locize (#1299)
Instead of relying in dynamic configuration, use dedicated env variables
to set locize api key / configuration

Closes: #1271
2019-12-16 09:41:41 -05:00
155 changed files with 5378 additions and 2190 deletions

No files matched your search

+2 -1
View File
@@ -328,6 +328,7 @@ workflows:
working_directory: platform/viewer
build: npx cross-env QUICK_BUILD=true yarn run build
start: yarn run test:e2e:serve
spec: 'cypress/integration/common/**/*,cypress/integration/pwa/**/*'
wait-on: 'http://localhost:3000'
cache-key: 'yarn-packages-{{ checksum "yarn.lock" }}'
no-workspace: true # Don't persist workspace
@@ -352,7 +353,7 @@ workflows:
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/**/*'
spec: 'cypress/integration/common/**/*,cypress/integration/script-tag/**/*'
wait-on: 'http://localhost:3000'
cache-key: 'yarn-packages-{{ checksum "yarn.lock" }}'
no-workspace: true # Don't persist workspace
+23 -9
View File
@@ -1,17 +1,31 @@
#!/bin/bash
# If CLIENT_ID is specified, use the google.js configuration with the modified ID
if [ ! -z "$CLIENT_ID" ]
if [ -n "$CLIENT_ID" ] || [ -n "$HEALTHCARE_API_ENDPOINT" ]
then
echo "Google Cloud Healthcare $CLIENT_ID has been provided: "
echo $CLIENT_ID
echo "Updating config..."
# If CLIENT_ID is specified, use the google.js configuration with the modified ID
if [ -n "$CLIENT_ID" ]
then
echo "Google Cloud Healthcare \$CLIENT_ID has been provided: "
echo "$CLIENT_ID"
echo "Updating config..."
# - Use SED to replace the CLIENT_ID that is currently in google.js
sed -i -e "s/YOURCLIENTID.apps.googleusercontent.com/$CLIENT_ID/g" /usr/share/nginx/html/google.js
# - Use SED to replace the CLIENT_ID that is currently in google.js
sed -i -e "s/YOURCLIENTID.apps.googleusercontent.com/$CLIENT_ID/g" /usr/share/nginx/html/google.js
fi
# - Copy google.js to overwrite app-config.js
cp /usr/share/nginx/html/google.js /usr/share/nginx/html/app-config.js
# If HEALTHCARE_API_ENDPOINT is specified, use the google.js configuration with the modified endpoint
if [ -n "$HEALTHCARE_API_ENDPOINT" ]
then
echo "Google Cloud Healthcare \$HEALTHCARE_API_ENDPOINT has been provided: "
echo "$HEALTHCARE_API_ENDPOINT"
echo "Updating config..."
# - Use SED to replace the HEALTHCARE_API_ENDPOINT that is currently in google.js
sed -i -e "s+https://healthcare.googleapis.com/v1beta1+$HEALTHCARE_API_ENDPOINT+g" /usr/share/nginx/html/google.js
fi
# - Copy google.js to overwrite app-config.js
cp /usr/share/nginx/html/google.js /usr/share/nginx/html/app-config.js
fi
echo "Starting Nginx to serve the OHIF Viewer..."
+2 -1
View File
@@ -12,10 +12,11 @@ exemptLabels:
- 'Task: CI/Tooling :robot:'
- 'Task: Docs 📖'
- 'Task: Docs :book:'
- 'Task: Refactor 🛠'
- 'Task: Refactor :hammer_and_wrench:'
- 'Task: Tests :microscope:'
- 'PR: Awaiting Review 👀'
- 'Triage :white_flag:'
- 'Extension: Discussion'
- 'Announcement 🎉'
# Label to use when marking an issue as stale
staleLabel: 'Stale :baguette_bread:'
+1
View File
@@ -30,6 +30,7 @@ docker/dcm4che/dcm4che-arc
# Cypress test results
videos/
screenshots/
# Locize settings
.locize
+5
View File
@@ -72,12 +72,17 @@ module.exports = (env, argv, { SRC_DIR, DIST_DIR }) => {
},
plugins: [
new webpack.DefinePlugin({
/* Application */
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
'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),
/* i18n */
'process.env.USE_LOCIZE': JSON.stringify(process.env.USE_LOCIZE || ''),
'process.env.LOCIZE_PROJECTID': JSON.stringify(process.env.LOCIZE_PROJECTID || ''),
'process.env.LOCIZE_API_KEY': JSON.stringify(process.env.LOCIZE_API_KEY || ''),
}),
],
// Fix: https://github.com/webpack-contrib/css-loader/issues/447#issuecomment-285598881
+5 -3
View File
@@ -193,7 +193,8 @@ today, create a GitHub issue!
The `ExtensionManager` is a class made available to us via the `@ohif/core`
project (platform/core). Our application instantiates a single instance of it,
and provides a `ServicesManager` and `CommandsManager`.
and provides a `ServicesManager` and `CommandsManager` along with the application's
configuration through the appConfig key (optional).
```js
const commandsManager = new CommandsManager();
@@ -201,6 +202,7 @@ const servicesManager = new ServicesManager();
const extensionManager = new ExtensionManager({
commandsManager,
servicesManager,
appConfig
});
```
@@ -210,8 +212,8 @@ The `ExtensionManager` only has a few public members:
- `registerExtensions` - Registers an array of extensions
- `modules` - An object containing registered extensions by `MODULE_TYPE`
During registration, lifecycle hooks and modules have access to
`ExtensionManager`'s `ServicesManager` and `CommandsManager`.
During registration, lifecycle hooks and modules have access to the extension's config,
the application's config and `ExtensionManager`'s `ServicesManager` and `CommandsManager` instances.
Our `@ohif/viewer` uses the `modules` member to access registered extensions at
appropriate places in our application.
+14 -3
View File
@@ -1,6 +1,9 @@
# Viewer: Configuration
We maintain a number of common viewer application configurations at [`<root>/platform/viewer/public/configs`][config-dir]. How these values are passed to the viewer depend on how it's deployed, but the two most common paths are:
We maintain a number of common viewer application configurations at
[`<root>/platform/viewer/public/configs`][config-dir]. How these values are
passed to the viewer depend on how it's deployed, but the two most common paths
are:
- `index.html` looks for `https://your-website.com/app-config.js` OR
- `index.html` passes the values to `OHIF.installViewer()`
@@ -42,8 +45,16 @@ window.config = {
keys: ['v'],
},
],
// Config to pass to the bundled cornerstone extension
cornerstoneExtensionConfig: {},
/* Configuration passed to the bundled cornerstone extension
*
* The cornerstone extension is currently tightly coupled to the platform.
* Until we're able to decouple it, this key will serve as a workaround to
* pass it configuration.
*/
cornerstoneExtensionConfig: {
/* Whether to show/hide annotation "handles" */
hideHandles: true,
},
};
```
@@ -0,0 +1,23 @@
# Environment Variables
There are a number of environment variables we use at build time to influence the output application's behavior.
```bash
# Application
NODE_ENV=< production | development >
DEBUG=< true | false >
APP_CONFIG=< relative path to application configuration file >
PUBLIC_URL=<>
VERSION_NUMBER=<Set by CircleCI>
BUILD_NUM=<Set by CircleCI>
# i18n
USE_LOCIZE=<false>
LOCIZE_PROJECTID=<ProjectID to pull translations for>
LOCIZE_API_KEY=<To enable Locize live editing of translations>
```
## Setting Environment Variables
- `npx cross-env`
- `.env` files
- env variables on build machine, or for terminal session
+1
View File
@@ -14,6 +14,7 @@ export default {
preRegistration({
servicesManager = {},
commandsManager = {},
appConfig = {},
configuration = {},
}) {},
+52
View File
@@ -3,6 +3,58 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [2.3.0](https://github.com/OHIF/Viewers/compare/@ohif/extension-cornerstone@2.2.2...@ohif/extension-cornerstone@2.3.0) (2020-02-10)
### Features
* 🎸 MeasurementService ([#1314](https://github.com/OHIF/Viewers/issues/1314)) ([0c37a40](https://github.com/OHIF/Viewers/commit/0c37a406d963569af8c3be24c697dafd42712dfc))
## [2.2.2](https://github.com/OHIF/Viewers/compare/@ohif/extension-cornerstone@2.2.1...@ohif/extension-cornerstone@2.2.2) (2020-01-28)
**Note:** Version bump only for package @ohif/extension-cornerstone
## [2.2.1](https://github.com/OHIF/Viewers/compare/@ohif/extension-cornerstone@2.2.0...@ohif/extension-cornerstone@2.2.1) (2019-12-20)
### Bug Fixes
* 🐛 1241: Make Plugin switch part of ToolbarModule ([#1322](https://github.com/OHIF/Viewers/issues/1322)) ([6540e36](https://github.com/OHIF/Viewers/commit/6540e36818944ac2eccc696186366ae495b33a04)), closes [#1241](https://github.com/OHIF/Viewers/issues/1241)
# [2.2.0](https://github.com/OHIF/Viewers/compare/@ohif/extension-cornerstone@2.1.1...@ohif/extension-cornerstone@2.2.0) (2019-12-20)
### Features
* 🎸 Configuration so viewer tools can nix handles ([#1304](https://github.com/OHIF/Viewers/issues/1304)) ([63594d3](https://github.com/OHIF/Viewers/commit/63594d36b0bdba59f0901095aed70b75fb05172d)), closes [#1223](https://github.com/OHIF/Viewers/issues/1223)
## [2.1.1](https://github.com/OHIF/Viewers/compare/@ohif/extension-cornerstone@2.1.0...@ohif/extension-cornerstone@2.1.1) (2019-12-16)
### Bug Fixes
* 🐛 add WwwcRegionTool to cornerstone tools initialization ([#1302](https://github.com/OHIF/Viewers/issues/1302)) ([d5bf728](https://github.com/OHIF/Viewers/commit/d5bf72851a32dff9fd3fc09332ea5250bc7e6114))
# [2.1.0](https://github.com/OHIF/Viewers/compare/@ohif/extension-cornerstone@2.0.2...@ohif/extension-cornerstone@2.1.0) (2019-12-11)
+14
View File
@@ -85,6 +85,20 @@ Tools can be configured through extension configuration using the tools key:
...
```
## Annotate Tools Configuration
*We currently support one property for annotation tools.*
### Hide handles
This extension configuration allows you to toggle on/off handle rendering for all annotate tools:
```js
...
cornerstoneExtensionConfig: {
hideHandles: true,
},
...
## Resources
### Repositories
+1
View File
@@ -0,0 +1 @@
module.exports = require("../../babel.config.js");
+13
View File
@@ -0,0 +1,13 @@
const base = require('../../jest.config.base.js');
const pkg = require('./package');
module.exports = {
...base,
name: pkg.name,
displayName: pkg.name,
// rootDir: "../.."
// testMatch: [
// //`<rootDir>/platform/${pack.name}/**/*.spec.js`
// "<rootDir>/platform/viewer/**/*.test.js"
// ]
};
+5 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@ohif/extension-cornerstone",
"version": "2.1.0",
"version": "2.3.0",
"description": "OHIF extension for Cornerstone",
"author": "OHIF",
"license": "MIT",
@@ -25,14 +25,16 @@
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
"build:package": "yarn run build",
"prepublishOnly": "yarn run build",
"start": "yarn run dev"
"start": "yarn run dev",
"test:unit": "jest --watchAll",
"test:unit:ci": "jest --ci --runInBand --collectCoverage"
},
"peerDependencies": {
"@ohif/core": "^0.50.0",
"@ohif/ui": "^0.50.0",
"cornerstone-core": "^2.2.8",
"cornerstone-math": "^0.1.8",
"cornerstone-tools": "^4.8.0",
"cornerstone-tools": "^4.12.0",
"cornerstone-wado-image-loader": "^3.0.0",
"dcmjs": "^0.8.2",
"dicom-parser": "^1.8.3",
@@ -2,6 +2,7 @@ import cornerstone from 'cornerstone-core';
import cornerstoneTools from 'cornerstone-tools';
import OHIF from '@ohif/core';
import setCornerstoneLayout from './utils/setCornerstoneLayout.js';
import { getEnabledElement } from './state';
import CornerstoneViewportDownloadForm from './CornerstoneViewportDownloadForm';
const scroll = cornerstoneTools.import('util/scroll');
@@ -242,6 +243,9 @@ const commandsModule = ({ servicesManager }) => {
cornerstoneTools.removeToolState(element, toolType, tool);
cornerstone.updateImage(element);
},
setCornerstoneLayout: () => {
setCornerstoneLayout();
}
};
const definitions = {
@@ -348,6 +352,12 @@ const commandsModule = ({ servicesManager }) => {
storeContexts: [],
options: {},
},
setCornerstoneLayout: {
commandFn: actions.setCornerstoneLayout,
storeContexts: [],
options: {},
context: 'VIEWER',
},
};
return {
+186 -32
View File
@@ -1,10 +1,11 @@
import OHIF from '@ohif/core';
import { SimpleDialog } from '@ohif/ui';
import cornerstone from 'cornerstone-core';
import csTools from 'cornerstone-tools';
import initCornerstoneTools from './initCornerstoneTools.js';
import queryString from 'query-string';
import { SimpleDialog } from '@ohif/ui';
import merge from 'lodash.merge';
import queryString from 'query-string';
import initCornerstoneTools from './initCornerstoneTools.js';
import measurementServiceMappingsFactory from './utils/measurementServiceMappings/measurementServiceMappingsFactory';
function fallbackMetaDataProvider(type, imageId) {
if (!imageId.includes('wado?requestType=WADO')) {
@@ -17,7 +18,7 @@ function fallbackMetaDataProvider(type, imageId) {
const wadoRoot = window.store.getState().servers.servers[0].wadoRoot;
const wadoRsImageId = `wadors:${wadoRoot}/studies/${qs.studyUID}/series/${
qs.seriesUID
}/instances/${qs.objectUID}/frames/${qs.frame || 1}`;
}/instances/${qs.objectUID}/frames/${qs.frame || 1}`;
return cornerstone.metaData.get(type, wadoRsImageId);
}
@@ -32,9 +33,9 @@ cornerstone.metaData.addProvider(fallbackMetaDataProvider, -1);
* @param {Object|Array} configuration.csToolsConfig
*/
export default function init({ servicesManager, configuration }) {
const callInputDialog = (data, event, callback) => {
const { UIDialogService } = servicesManager.services;
const { UIDialogService, MeasurementService } = servicesManager.services;
const callInputDialog = (data, event, callback) => {
if (UIDialogService) {
let dialogId = UIDialogService.create({
centralize: true,
@@ -76,30 +77,39 @@ export default function init({ servicesManager, configuration }) {
initCornerstoneTools(defaultCsToolsConfig);
// ~~ Toooools 🙌
const tools = [
csTools.PanTool,
csTools.ZoomTool,
csTools.WwwcTool,
csTools.MagnifyTool,
csTools.StackScrollTool,
csTools.StackScrollMouseWheelTool,
// Touch
csTools.PanMultiTouchTool,
csTools.ZoomTouchPinchTool,
// Annotations
csTools.ArrowAnnotateTool,
csTools.EraserTool,
csTools.BidirectionalTool,
csTools.LengthTool,
csTools.AngleTool,
csTools.FreehandRoiTool,
csTools.EllipticalRoiTool,
csTools.DragProbeTool,
csTools.RectangleRoiTool,
// Segmentation
csTools.BrushTool,
];
const toolsGroupedByType = {
touch: [csTools.PanMultiTouchTool, csTools.ZoomTouchPinchTool],
annotations: [
csTools.ArrowAnnotateTool,
csTools.EraserTool,
csTools.BidirectionalTool,
csTools.LengthTool,
csTools.AngleTool,
csTools.FreehandRoiTool,
csTools.EllipticalRoiTool,
csTools.DragProbeTool,
csTools.RectangleRoiTool,
],
segmentation: [csTools.BrushTool],
other: [
csTools.PanTool,
csTools.ZoomTool,
csTools.WwwcTool,
csTools.WwwcRegionTool,
csTools.MagnifyTool,
csTools.StackScrollTool,
csTools.StackScrollMouseWheelTool,
csTools.OverlayTool,
],
};
let tools = [];
Object.keys(toolsGroupedByType).forEach(toolsGroup =>
tools.push(...toolsGroupedByType[toolsGroup])
);
/* Measurement Service */
_connectToolsToMeasurementService(MeasurementService);
/* Add extension tools configuration here. */
const internalToolsConfig = {
@@ -113,13 +123,48 @@ export default function init({ servicesManager, configuration }) {
},
};
/* Abstract tools configuration using extension configuration. */
const parseToolProps = (props, tool) => {
const { annotations } = toolsGroupedByType;
// An alternative approach would be to remove the `drawHandlesOnHover` config
// from the supported configuration properties in `cornerstone-tools`
const toolsWithHideableHandles = annotations.filter(
tool => !['RectangleRoiTool', 'EllipticalRoiTool'].includes(tool.name)
);
let parsedProps = { ...props };
/**
* drawHandles - Never/Always show handles
* drawHandlesOnHover - Only show handles on handle hover (pointNearHandle)
*
* Does not apply to tools where handles aren't placed in predictable
* locations.
*/
if (
configuration.hideHandles !== false &&
toolsWithHideableHandles.includes(tool)
) {
if (props.configuration) {
parsedProps.configuration.drawHandlesOnHover = true;
} else {
parsedProps.configuration = { drawHandlesOnHover: true };
}
}
return parsedProps;
};
/* Add tools with its custom props through extension configuration. */
tools.forEach(tool => {
const toolName = tool.name.replace('Tool', '');
const externalToolsConfig = configuration.tools || {};
const externalToolProps = externalToolsConfig[toolName] || {};
const internalToolProps = internalToolsConfig[toolName] || {};
const props = merge(internalToolProps, externalToolProps);
const props = merge(
internalToolProps,
parseToolProps(externalToolProps, tool)
);
csTools.addTool(tool, props);
});
@@ -132,11 +177,120 @@ export default function init({ servicesManager, configuration }) {
csTools.setToolPassive(toolName); // there may be a better place to determine name; may not be on uninstantiated class
}
});
csTools.setToolActive('Pan', { mouseButtonMask: 4 });
csTools.setToolActive('Zoom', { mouseButtonMask: 2 });
csTools.setToolActive('Wwwc', { mouseButtonMask: 1 });
csTools.setToolActive('StackScrollMouseWheel', {}); // TODO: Empty options should not be required
csTools.setToolActive('PanMultiTouch', { pointers: 2 }); // TODO: Better error if no options
csTools.setToolActive('ZoomTouchPinch', {});
csTools.setToolEnabled('Overlay', {});
}
const _initMeasurementService = measurementService => {
/* Initialization */
const { toAnnotation, toMeasurement } = measurementServiceMappingsFactory(measurementService);
const csToolsVer4MeasurementSource = measurementService.createSource(
'CornerstoneTools',
'4'
);
/* Matching Criterias */
const matchingCriteria = {
valueType: measurementService.VALUE_TYPES.POLYLINE,
points: 2,
};
/* Mappings */
measurementService.addMapping(
csToolsVer4MeasurementSource,
'Length',
matchingCriteria,
toAnnotation,
toMeasurement
);
return csToolsVer4MeasurementSource;
};
const _connectToolsToMeasurementService = measurementService => {
const csToolsVer4MeasurementSource = _initMeasurementService(measurementService);
const {
id: sourceId,
addOrUpdate,
getAnnotation,
} = csToolsVer4MeasurementSource;
/* Measurement Service Events */
cornerstone.events.addEventListener(
cornerstone.EVENTS.ELEMENT_ENABLED,
event => {
const {
MEASUREMENT_ADDED,
MEASUREMENT_UPDATED,
} = measurementService.EVENTS;
measurementService.subscribe(
MEASUREMENT_ADDED,
({ source, measurement }) => {
if (![sourceId].includes(source.id)) {
const annotation = getAnnotation('Length', measurement.id);
console.log(
'Measurement Service [Cornerstone]: Measurement added',
measurement
);
console.log('Mapped annotation:', annotation);
}
});
measurementService.subscribe(
MEASUREMENT_UPDATED,
({ source, measurement }) => {
if (![sourceId].includes(source.id)) {
const annotation = getAnnotation('Length', measurement.id);
console.log(
'Measurement Service [Cornerstone]: Measurement updated',
measurement
);
console.log('Mapped annotation:', annotation);
}
}
);
const addOrUpdateMeasurement = csToolsAnnotation => {
try {
const { toolName, toolType, measurementData } = csToolsAnnotation;
const csTool = toolName || measurementData.toolType || toolType;
csToolsAnnotation.id = measurementData._measurementServiceId;
const measurementServiceId = addOrUpdate(csTool, csToolsAnnotation);
if (!measurementData._measurementServiceId) {
addMeasurementServiceId(measurementServiceId, csToolsAnnotation);
}
} catch (error) {
console.warn('Failed to add or update measurement:', error);
}
};
const addMeasurementServiceId = (id, csToolsAnnotation) => {
const { measurementData } = csToolsAnnotation;
Object.assign(measurementData, { _measurementServiceId: id });
};
[
csTools.EVENTS.MEASUREMENT_ADDED,
csTools.EVENTS.MEASUREMENT_MODIFIED,
].forEach(csToolsEvtName => {
event.detail.element.addEventListener(
csToolsEvtName,
({ detail: csToolsAnnotation }) => {
console.log(`Cornerstone Element Event: ${csToolsEvtName}`);
addOrUpdateMeasurement(csToolsAnnotation);
}
);
});
}
);
};
@@ -237,6 +237,15 @@ const definitions = [
},
],
},
{
id: 'Exit2DMPR',
label: 'Exit 2D MPR',
icon: 'times',
//
type: TOOLBAR_BUTTON_TYPES.COMMAND,
commandName: 'setCornerstoneLayout',
context: 'ACTIVE_VIEWPORT::VTK',
}
];
export default {
@@ -0,0 +1,133 @@
import cornerstone from 'cornerstone-core';
const SUPPORTED_TOOLS = ['Length', 'EllipticalRoi', 'RectangleRoi', 'ArrowAnnotate'];
const measurementServiceMappingsFactory = measurementService => {
/**
* Maps measurement service format object to cornerstone annotation object.
*
* @param {Measurement} measurement The measurement instance
* @param {string} definition The source definition
* @return {Object} Cornerstone annotation data
*/
const toAnnotation = (measurement, definition) => {
const {
id,
label,
description,
points,
unit,
sopInstanceUID,
frameOfReferenceUID,
referenceSeriesUID,
} = measurement;
return {
toolName: definition,
measurementData: {
sopInstanceUid: sopInstanceUID,
frameOfReferenceUid: frameOfReferenceUID,
seriesInstanceUid: referenceSeriesUID,
unit,
text: label,
description,
handles: _getHandlesFromPoints(points),
_measurementServiceId: id,
},
};
};
/**
* Maps cornerstone annotation event data to measurement service format.
*
* @param {Object} cornerstone Cornerstone event data
* @return {Measurement} Measurement instance
*/
const toMeasurement = csToolsAnnotation => {
const { element, measurementData } = csToolsAnnotation;
const tool =
csToolsAnnotation.toolType ||
csToolsAnnotation.toolName ||
measurementData.toolType;
const validToolType = toolName => SUPPORTED_TOOLS.includes(toolName);
if (!validToolType(tool)) {
throw new Error('Tool not supported');
}
const {
sopInstanceUid,
frameOfReferenceUid,
seriesInstanceUid,
} = _getAttributes(element);
const points = [];
points.push(measurementData.handles);
return {
id: measurementData._measurementServiceId,
sopInstanceUID: sopInstanceUid,
frameOfReferenceUID: frameOfReferenceUid,
referenceSeriesUID: seriesInstanceUid,
label: measurementData.text,
description: measurementData.description,
unit: measurementData.unit,
area: measurementData.cachedStats && measurementData.cachedStats.area, /* TODO: Add concept names instead (descriptor) */
type: _getValueTypeFromToolType(tool),
points: _getPointsFromHandles(measurementData.handles),
};
};
const _getAttributes = element => {
const enabledElement = cornerstone.getEnabledElement(element);
const imageId = enabledElement.image.imageId;
const sopInstance = cornerstone.metaData.get('instance', imageId);
const sopInstanceUid = sopInstance.sopInstanceUid;
const frameOfReferenceUid = sopInstance.frameOfReferenceUID;
const series = cornerstone.metaData.get('series', imageId);
const seriesInstanceUid = series.seriesInstanceUid;
return { sopInstanceUid, frameOfReferenceUid, seriesInstanceUid };
};
const _getValueTypeFromToolType = toolType => {
const { POLYLINE, ELLIPSE, POINT } = measurementService.VALUE_TYPES;
/* TODO: Relocate static value types */
const TOOL_TYPE_TO_VALUE_TYPE = {
Length: POLYLINE,
EllipticalRoi: ELLIPSE,
RectangleRoi: POLYLINE,
ArrowAnnotate: POINT,
};
return TOOL_TYPE_TO_VALUE_TYPE[toolType];
};
const _getPointsFromHandles = handles => {
let points = [];
Object.keys(handles).map(handle => {
if (['start', 'end'].includes(handle)) {
let point = {};
if (handles[handle].x) point.x = handles[handle].x;
if (handles[handle].y) point.y = handles[handle].y;
points.push(point);
}
});
return points;
};
const _getHandlesFromPoints = points => {
return points
.map((p, i) => (i % 10 === 0 ? { start: p } : { end: p }))
.reduce((obj, item) => Object.assign(obj, { ...item }), {});
};
return {
toAnnotation,
toMeasurement,
};
};
export default measurementServiceMappingsFactory;
@@ -0,0 +1,80 @@
import measurementServiceMappingsFactory from './measurementServiceMappingsFactory';
jest.mock('cornerstone-core', () => ({
...jest.requireActual('cornerstone-core'),
getEnabledElement: () => ({
image: { imageId: 123 },
}),
metaData: {
...jest.requireActual('cornerstone-core').metaData,
get: () => ({
sopInstanceUid: "123",
frameOfReferenceUID: "123",
seriesInstanceUid: "123",
}),
},
}));
describe('measurementServiceMappings.js', () => {
let mappings;
let handles;
let points;
let csToolsAnnotation;
let measurement;
let measurementServiceMock;
let definition = 'Length';
beforeEach(() => {
measurementServiceMock = {
VALUE_TYPES: {
POLYLINE: 'value_type::polyline',
POINT: 'value_type::point',
ELLIPSE: 'value_type::ellipse',
MULTIPOINT: 'value_type::multipoint',
CIRCLE: 'value_type::circle',
},
};
mappings = measurementServiceMappingsFactory(measurementServiceMock);
handles = { start: { x: 1, y: 2 }, end: { x: 1, y: 2 } };
points = [{ x: 1, y: 2 }, { x: 1, y: 2 }];
csToolsAnnotation = {
toolName: definition,
measurementData: {
_measurementServiceId: 1,
sopInstanceUid: '123',
frameOfReferenceUid: '123',
seriesInstanceUid: '123',
handles,
text: 'Test',
description: 'Test',
unit: 'mm',
},
};
measurement = {
id: 1,
sopInstanceUID: '123',
frameOfReferenceUID: '123',
referenceSeriesUID: '123',
label: 'Test',
description: 'Test',
unit: 'mm',
type: measurementServiceMock.VALUE_TYPES.POLYLINE,
points: points,
};
jest.clearAllMocks();
});
describe('toAnnotation()', () => {
it('map measurement service format to annotation', async () => {
const mappedMeasurement = await mappings.toAnnotation(measurement, definition);
expect(mappedMeasurement).toEqual(csToolsAnnotation);
});
});
describe('toMeasurement()', () => {
it('map annotation to measurement service format', async () => {
const mappedAnnotation = await mappings.toMeasurement(csToolsAnnotation);
expect(mappedAnnotation).toEqual(measurement);
});
});
});
@@ -0,0 +1,22 @@
import { redux } from '@ohif/core';
const { setLayout } = redux.actions;
/**
* Update the current layout with a simple Cornerstone one
*
* @return void
*/
const setCornerstoneLayout = () => {
const layout = {
numRows: 1,
numColumns: 1,
viewports: [{ plugin: 'cornerstone' }],
};
const action = setLayout(layout);
window.store.dispatch(action);
}
export default setCornerstoneLayout;
+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.
## [1.0.1](https://github.com/OHIF/Viewers/compare/@ohif/extension-dicom-pdf@1.0.0...@ohif/extension-dicom-pdf@1.0.1) (2019-12-16)
### Bug Fixes
* 🐛 Update ohif pdf extension to optionally use pdfjs ([#1162](https://github.com/OHIF/Viewers/issues/1162)) ([31e542d](https://github.com/OHIF/Viewers/commit/31e542dfd973c3e982ede6d02d79bfe65175e792)), closes [#1049](https://github.com/OHIF/Viewers/issues/1049)
# [1.0.0](https://github.com/OHIF/Viewers/compare/@ohif/extension-dicom-pdf@0.50.4...@ohif/extension-dicom-pdf@1.0.0) (2019-09-27)
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@ohif/extension-dicom-pdf",
"version": "1.0.0",
"version": "1.0.1",
"description": "OHIF extension for Dicom PDF",
"author": "OHIF",
"license": "MIT",
@@ -37,7 +37,8 @@
"dependencies": {
"@babel/runtime": "^7.5.5",
"classnames": "^2.2.6",
"lodash.isequal": "^4.5.0"
"lodash.isequal": "^4.5.0",
"pdfjs-dist": "^2.2.228"
},
"gitHead": "a5baa9228c0eda0df880136bde4420d78e6f8706"
}
@@ -0,0 +1,27 @@
import OHIF from '@ohif/core';
import { connect } from 'react-redux';
import OHIFDicomPDFViewport from './OHIFDicomPDFViewport';
const { setViewportActive } = OHIF.redux.actions;
const mapStateToProps = (state, ownProps) => {
const { activeViewportIndex } = state.viewports;
return { activeViewportIndex };
};
const mapDispatchToProps = (dispatch, ownProps) => {
const { viewportIndex } = ownProps;
return {
setViewportActive: () => {
dispatch(setViewportActive(viewportIndex));
},
};
};
const ConnectedOHIFDicomPDFViewer = connect(
mapStateToProps,
mapDispatchToProps
)(OHIFDicomPDFViewport);
export default ConnectedOHIFDicomPDFViewer;
@@ -0,0 +1,62 @@
.DicomPDFViewport {
--header-height: 50px;
}
.DicomPDFViewport #toolbar {
display: flex;
align-items: center;
color: #fff;
padding: 0.5em;
border-bottom: var(--ui-border-thickness) solid var(--ui-border-color);
height: var(--header-height);
}
.DicomPDFViewport #canvas {
height: calc(100% - var(--header-height));
overflow-y: scroll;
}
.DicomPDFViewport #text-layer {
height: 100%;
margin-top: var(--header-height);
}
.DicomPDFViewport #pdf-canvas-container {
position: relative;
}
.DicomPDFViewport canvas, #text-layer {
display: block;
margin: 0 auto;
overflow: hidden;
}
.DicomPDFViewport #toolbar button {
color: currentColor;
background-color: transparent;
font: inherit;
border: var(--ui-border-thickness) solid var(--ui-border-color);
border-radius: 3px;
padding: 0.25em 0.5em;
margin-right: 0.5em;
cursor: pointer;
}
.DicomPDFViewport #text-layer {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
overflow: hidden;
opacity: 0.2;
line-height: 1;
}
.DicomPDFViewport #text-layer>span {
color: transparent;
position: absolute;
white-space: pre;
cursor: text;
transform-origin: 0% 0%;
}
+197 -23
View File
@@ -1,6 +1,14 @@
import React, { Component } from 'react';
import React, { Component, createRef } from 'react';
import dicomParser from 'dicom-parser';
import PDFJS from 'pdfjs-dist';
import PropTypes from 'prop-types';
import TypedArrayProp from './TypedArrayProp';
import './DicomPDFViewport.css';
import pdfjsBuild from 'pdfjs-dist/build/pdf';
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.entry';
pdfjsBuild.GlobalWorkerOptions.workerSrc = pdfjsWorker;
// TODO: Should probably use dcmjs for this
const SOP_CLASS_UIDS = {
@@ -8,16 +16,88 @@ const SOP_CLASS_UIDS = {
};
class DicomPDFViewport extends Component {
state = {
fileURL: null,
error: null,
};
constructor(props) {
super(props);
this.state = {
fileURL: null,
error: null,
currentPageIndex: 1,
pdf: null,
scale: 1,
};
this.canvas = createRef();
this.textLayer = createRef();
}
static propTypes = {
byteArray: TypedArrayProp.uint8,
useNative: PropTypes.bool,
viewportData: PropTypes.object,
activeViewportIndex: PropTypes.number,
setViewportActive: PropTypes.func,
viewportIndex: PropTypes.number,
};
renderPDF = (dataSet, byteArray) => {
static defaultProps = {
useNative: false,
};
async componentDidMount() {
const dataSet = this.parseByteArray(this.props.byteArray);
const fileURL = this.getPDFFileUrl(dataSet, this.props.byteArray);
this.setState(state => ({ ...state, fileURL }));
if (!this.props.useNative) {
const pdf = await PDFJS.getDocument(fileURL).promise;
this.setState(state => ({ ...state, pdf }), () => this.updatePDFCanvas());
}
}
updatePDFCanvas = async () => {
const { pdf, scale, currentPageIndex } = this.state;
const context = this.canvas.getContext('2d');
const page = await pdf.getPage(currentPageIndex);
let viewport = page.getViewport({ scale });
this.canvas.height = viewport.height;
this.canvas.width = viewport.width;
const renderContext = {
canvasContext: context,
viewport: viewport,
};
await page.render(renderContext);
const textContent = await page.getTextContent();
this.textLayer.innerHTML = '';
this.textLayer.style.height = viewport.height + 'px';
this.textLayer.style.width = viewport.width + 'px';
PDFJS.renderTextLayer({
textContent,
container: this.textLayer,
viewport,
textDivs: [],
});
};
componentDidUpdate(prevProps, prevState) {
const { currentPageIndex, scale } = this.state;
const newValidScale = prevState.scale !== scale && scale > 0;
const newValidPageNumber =
prevState.currentPageIndex !== currentPageIndex && currentPageIndex > 0;
if (newValidScale || newValidPageNumber) {
this.updatePDFCanvas();
}
}
getPDFFileUrl = (dataSet, byteArray) => {
let pdfByteArray = byteArray;
if (dataSet) {
@@ -36,50 +116,144 @@ class DicomPDFViewport extends Component {
const PDF = new Blob([pdfByteArray], { type: 'application/pdf' });
const fileURL = URL.createObjectURL(PDF);
this.setState({
fileURL,
});
return fileURL;
};
onPageChange = async event => {
const { currentPageIndex, pdf } = this.state;
let newPageIndex = currentPageIndex;
const action = event.target.getAttribute('data-pager');
if (action === 'prev') {
if (currentPageIndex === 1) {
return;
}
newPageIndex -= 1;
if (currentPageIndex < 0) {
newPageIndex = 0;
}
}
if (action === 'next') {
if (currentPageIndex === pdf.numPages - 1) {
return;
}
newPageIndex += 1;
if (currentPageIndex > pdf.numPages - 1) {
newPageIndex = pdf.numPages - 1;
}
}
this.setState(state => ({ ...state, currentPageIndex: newPageIndex }));
};
onZoomChange = () => {
let newZoomValue = this.state.scale;
const action = event.target.getAttribute('data-pager');
if (action === '+') {
newZoomValue += 0.25;
}
if (action === '-') {
newZoomValue -= 0.25;
}
this.setState(state => ({ ...state, scale: newZoomValue }));
};
parseByteArray = byteArray => {
const options = {
untilTag: '',
};
const options = { untilTag: '' };
let dataSet;
try {
dataSet = dicomParser.parseDicom(byteArray, options);
} catch (error) {
this.setState({
error,
});
this.setState(state => ({ ...state, error }));
}
return dataSet;
};
componentDidMount() {
const dataSet = this.parseByteArray(this.props.byteArray);
setViewportActiveHandler = () => {
const {
setViewportActive,
viewportIndex,
activeViewportIndex,
} = this.props;
this.renderPDF(dataSet, this.props.byteArray);
}
if (viewportIndex !== activeViewportIndex) {
setViewportActive(viewportIndex);
}
};
downloadPDFCanvas = () => {
const { fileURL } = this.state;
const a = document.createElement('a');
a.href = fileURL;
a.download = fileURL.substr(fileURL.lastIndexOf('/') + 1);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
render() {
const { fileURL, pdf, error } = this.state;
return (
<div
className={'DicomPDFViewport'}
onClick={this.setViewportActiveHandler}
onScroll={this.setViewportActiveHandler}
style={{ width: '100%', height: '100%' }}
>
{this.state.fileURL && (
{!this.props.useNative ? (
<>
<div id="toolbar">
<div id="pager">
{pdf && pdf.numPages > 1 && (
<>
<button data-pager="prev" onClick={this.onPageChange}>
{`<`}
</button>
<button data-pager="next" onClick={this.onPageChange}>
{`>`}
</button>
</>
)}
<button data-pager="-" onClick={this.onZoomChange}>
{`-`}
</button>
<button data-pager="+" onClick={this.onZoomChange}>
{`+`}
</button>
<button onClick={this.downloadPDFCanvas}>Download</button>
</div>
</div>
<div id="canvas">
<div id="pdf-canvas-container">
<canvas
id="pdf-canvas"
ref={canvas => (this.canvas = canvas)}
/>
<div
id="text-layer"
ref={textLayer => (this.textLayer = textLayer)}
></div>
</div>
</div>
</>
) : (
<object
data={this.state.fileURL}
aria-label="PDF Viewer"
data={fileURL}
type="application/pdf"
width="100%"
height="100%"
/>
)}
{this.state.error && <h2>{JSON.stringify(this.state.error)}</h2>}
{error && <h2>{JSON.stringify(error)}</h2>}
</div>
);
}
@@ -11,6 +11,9 @@ class OHIFDicomPDFViewport extends Component {
studies: PropTypes.object,
displaySet: PropTypes.object,
viewportIndex: PropTypes.number,
viewportData: PropTypes.object,
activeViewportIndex: PropTypes.number,
setViewportActive: PropTypes.func,
};
state = {
@@ -30,42 +33,36 @@ class OHIFDicomPDFViewport extends Component {
componentDidMount() {
const { displaySet, studies } = this.props.viewportData;
const {
studyInstanceUid,
seriesInstanceUid,
sopInstanceUid,
wadoRoot,
wadoUri,
authorizationHeaders,
} = displaySet;
DicomLoaderService.findDicomDataPromise(displaySet, studies).then(
data => {
const byteArray = new Uint8Array(data);
this.setState({
byteArray: byteArray,
});
},
data => this.setState({ byteArray: new Uint8Array(data) }),
error => {
this.setState({
error,
});
this.setState({ error });
throw new Error(error);
}
);
}
render() {
const {
setViewportActive,
viewportIndex,
activeViewportIndex,
} = this.props;
const { byteArray, error } = this.state;
const { id, init, destroy } = OHIFDicomPDFViewport;
const pluginProps = { id, init, destroy };
return (
<OHIFComponentPlugin {...pluginProps}>
{this.state.byteArray && (
<DicomPDFViewport byteArray={this.state.byteArray} />
{byteArray && (
<DicomPDFViewport
byteArray={byteArray}
setViewportActive={setViewportActive}
viewportIndex={viewportIndex}
activeViewportIndex={activeViewportIndex}
/>
)}
{this.state.error && <h2>{JSON.stringify(this.state.error)}</h2>}
{error && <h2>{JSON.stringify(error)}</h2>}
</OHIFComponentPlugin>
);
}
@@ -0,0 +1,37 @@
/**
* We use this component to leverage "Code Splitting"
*
* Link: https://serverless-stack.com/chapters/code-splitting-in-create-react-app.html
*/
import React, { Component } from 'react';
export default function asyncComponent(importComponent) {
class AsyncComponent extends Component {
constructor(props) {
super(props);
this.state = {
component: null,
};
}
async componentDidMount() {
// Add dynamically loaded component to state
const { default: component } = await importComponent();
this.setState({
component: component,
});
}
render() {
const C = this.state.component;
// Render the loaded component, or null
return C ? <C {...this.props} /> : null;
}
}
return AsyncComponent;
}
+9 -4
View File
@@ -1,16 +1,21 @@
import asyncComponent from './asyncComponent.js';
import OHIFDicomPDFSopClassHandler from './OHIFDicomPDFSopClassHandler.js';
import OHIFDicomPDFViewport from './OHIFDicomPDFViewport.js';
const ConnectedOHIFDicomPDFViewer = asyncComponent(() =>
import(
/* webpackChunkName: "ConnectedOHIFDicomPDFViewer" */ './ConnectedOHIFDicomPDFViewer'
)
);
export default {
/**
* Only required property. Should be a unique value across all extensions.
*/
id: 'pdf',
getViewportModule() {
return OHIFDicomPDFViewport;
return ConnectedOHIFDicomPDFViewer;
},
getSopClassHandlerModule() {
return OHIFDicomPDFSopClassHandler;
}
},
};
@@ -0,0 +1,8 @@
const path = require('path');
const webpackCommon = require('./../../../.webpack/webpack.commonjs.js');
const SRC_DIR = path.join(__dirname, '../src');
const DIST_DIR = path.join(__dirname, '../dist');
module.exports = (env, argv) => {
return webpackCommon(env, argv, { SRC_DIR, DIST_DIR });
};
@@ -0,0 +1,38 @@
const merge = require('webpack-merge');
const path = require('path');
const webpackCommon = require('./../../../.webpack/webpack.commonjs.js');
const pkg = require('./../package.json');
const ROOT_DIR = path.join(__dirname, './..');
const SRC_DIR = path.join(__dirname, '../src');
const DIST_DIR = path.join(__dirname, '../dist');
module.exports = (env, argv) => {
const commonConfig = webpackCommon(env, argv, { SRC_DIR, DIST_DIR });
return merge(commonConfig, {
devtool: 'source-map',
stats: {
colors: true,
hash: true,
timings: true,
assets: true,
chunks: false,
chunkModules: false,
modules: false,
children: false,
warnings: true,
},
optimization: {
minimize: true,
sideEffects: true,
},
output: {
path: ROOT_DIR,
library: 'OHIFExtLesionTracker',
libraryTarget: 'umd',
libraryExport: 'default',
filename: pkg.main,
},
});
};
+22
View File
@@ -0,0 +1,22 @@
# Change Log
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [0.2.0](https://github.com/OHIF/Viewers/compare/@ohif/extension-lesion-tracker@0.1.0...@ohif/extension-lesion-tracker@0.2.0) (2020-02-10)
### Features
* Lesion tracker right panel ([#1428](https://github.com/OHIF/Viewers/issues/1428)) ([98a649b](https://github.com/OHIF/Viewers/commit/98a649b455ffc712938fc5035cdef40695e58440))
# 0.1.0 (2020-02-06)
### Features
* lesion-tracker extension ([#1420](https://github.com/OHIF/Viewers/issues/1420)) ([73e4409](https://github.com/OHIF/Viewers/commit/73e440968ce4699d081a9c9f2d21dd68095b3056))
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 Open Health Imaging Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+38
View File
@@ -0,0 +1,38 @@
# @ohif/extension-lesion-tracker
This project is an OHIF extension that can be used with the Core OHIF Platform,
either at runtime, or as an ES6 dependency, to create a medical image viewing
application similar to that of the [legacy Lesion
Tracker][legacy-lesion-tracker] viewer.
## About
...
## Scope
...
### Configuration
...
### Extensions
...
## Build & Deploy
...
## Funding/Support
...
<!--
LINKS
-->
<!-- prettier-ignore-start -->
[legacy-lesion-tracker]: http://lesiontracker.ohif.org/studylist
<!-- prettier-ignore-end -->
+40
View File
@@ -0,0 +1,40 @@
{
"name": "@ohif/extension-lesion-tracker",
"version": "0.2.0",
"description": "OHIF extension for Lesion Tracker",
"author": "OHIF",
"license": "MIT",
"repository": "OHIF/Viewers",
"main": "dist/index.umd.js",
"module": "src/index.js",
"publishConfig": {
"access": "public"
},
"engines": {
"node": ">=10",
"npm": ">=6",
"yarn": ">=1.16.0"
},
"files": [
"dist",
"README.md"
],
"scripts": {
"dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --debug --output-pathinfo",
"dev:lesion-tracker": "yarn run dev",
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
"build:package": "yarn run build",
"prepublishOnly": "yarn run build",
"start": "yarn run dev"
},
"peerDependencies": {
"@ohif/core": "^0.50.0",
"prop-types": "^15.6.2",
"react": "^16.8.6",
"react-dom": "^16.8.6"
},
"dependencies": {
"@babel/runtime": "^7.5.5",
"classnames": "^2.2.6"
}
}
@@ -0,0 +1,60 @@
.MeasurementComparisonTable {
height: 100%;
display: flex;
flex-direction: column;
}
.MeasurementComparisonTable .displayTexts {
display: flex;
}
.MeasurementComparisonTable .displayTexts .measurementDisplayText {
flex: 1;
}
.MeasurementComparisonTable .measurementTableHeader {
display: flex;
position: relative;
padding-left: 38px;
}
.MeasurementComparisonTable .measurementTableHeader .warning-status {
left: 0;
top: 3px;
cursor: pointer;
width: 40px;
height: 100%;
display: flex;
align-items: center;
}
.MeasurementComparisonTable .measurementTableHeader .warning-status .warning-border {
padding: 2px 3px;
width: 30px;
height: 30px;
}
.MeasurementComparisonTable .measurementTableHeader .warning-status svg {
width: 20px;
height: 18px;
}
.MeasurementComparisonTable .measurementTableHeader .measurementTableHeaderItem {
position: relative;
flex: 1;
}
.MeasurementComparisonTable .generate-report {
background-color: #151a1f;
margin-top: 2px;
padding: 10px 0;
display: flex;
justify-content: center;
align-items: center;
}
.MeasurementComparisonTable .generate-report button {
font-size: 12px;
color: black;
background-color: var(--active-color);
}
@@ -0,0 +1,151 @@
import React from 'react';
import { MeasurementTable } from '@ohif/ui';
import './MeasurementComparisonTable.css';
const overallWarnings = {
warningList: [
'All measurements should have a location',
'Nodal lesions must be >= 15mm short axis AND >= double the acquisition slice thickness by CT and MR',
],
};
const measurements = [
{
measurementId: '125',
measurementNumber: '125',
itemNumber: 1,
label: '(No description)',
data: [{ displayText: '12.5 x 4.6' }],
},
{
measurementId: '124',
measurementNumber: '124',
itemNumber: 2,
label: '(No description)',
data: [{ displayText: '32.5 x 1.6' }],
},
{
measurementId: '123',
measurementNumber: '123',
itemNumber: 3,
hasWarnings: true,
warningList: [
'All measurements should have a location',
'Nodal lesions must be >= 15mm short axis AND >= double the acquisition slice thickness by CT and MR',
],
label: '(No description)',
data: [{ displayText: '5.5 x 9.2' }],
},
];
const additionalFindings = [
{
measurementId: '122',
measurementNumber: '122',
itemNumber: 1,
hasWarnings: true,
warningList: [
'All measurements should have a location',
'Nodal lesions must be >= 15mm short axis AND >= double the acquisition slice thickness by CT and MR',
],
label: '(No description)',
data: [{ displayText: '23.5 x 9.2' }],
},
{
measurementId: '121',
measurementNumber: '121',
itemNumber: 2,
hasWarnings: true,
warningList: [
'All measurements should have a location',
'Nodal lesions must be >= 15mm short axis AND >= double the acquisition slice thickness by CT and MR',
],
label: '(No description)',
data: [{ displayText: '11.2 x 9.2' }],
},
{
measurementId: '120',
measurementNumber: '120',
itemNumber: 3,
label: '(No description)',
data: [{ displayText: '2.9 x 9.2' }],
},
];
const currentCollections = [
{
selectorAction: () => { },
maxMeasurements: 3,
groupName: 'Measurements',
measurements: measurements,
},
{
selectorAction: () => { },
groupName: 'Additional Findings',
measurements: additionalFindings,
},
];
const comparisonColletions = [
{
selectorAction: () => { },
maxMeasurements: 3,
groupName: 'Measurements',
measurements: measurements,
},
{
selectorAction: () => { },
groupName: 'Additional Findings',
measurements: additionalFindings,
},
];
const comparisonCollections = currentCollections.map((group, index) => {
return {
...group,
measurements: group.measurements.map((measurement, measurementIndex) => {
const comparisonCollection = comparisonColletions[index].measurements;
if (measurementIndex < comparisonCollection.length) {
return {
...measurement,
data: [
...measurement.data,
...comparisonCollection[measurementIndex].data,
],
};
}
}),
};
});
const comparisonTimepoints = [
{
key: 'Current',
date: '10-Apr-18',
},
{
key: 'Comparison',
date: '15-Jun-18',
}
];
const MeasurementComparisonTable = () => {
return (
<div className="MeasurementComparisonTable">
<MeasurementTable
timepoints={comparisonTimepoints}
overallWarnings={overallWarnings}
measurementCollection={comparisonCollections}
onRelabelClick={() => { }}
onEditDescriptionClick={() => { }}
/>
<div className="generate-report">
<button className="btn btn-primary">Generate Report</button>
</div>
</div>
);
};
export default MeasurementComparisonTable;
+52
View File
@@ -0,0 +1,52 @@
import MeasurementComparisonTable from './components/MeasurementComparisonTable';
export default {
/**
* Only required property. Should be a unique value across all extensions.
*/
id: 'lesion-tracker',
/**
* @param {object} params
* @param {ServicesManager} params.servicesManager
* @param {CommandsManager} params.commandsManager
*/
getPanelModule({ servicesManager, commandsManager }) {
return {
menuOptions: [
{
icon: 'th-list',
label: 'Measurements',
target: 'lesion-tracker-panel',
},
],
components: [
{
id: 'lesion-tracker-panel',
component: MeasurementComparisonTable,
},
],
defaultContext: ['VIEWER']
};
},
/**
* @param {object} params
* @param {ServicesManager} params.servicesManager
* @param {CommandsManager} params.commandsManager
* @returns Object
*/
getToolbarModule() {
return null;
},
/**
* @param {object} params
* @param {ServicesManager} params.servicesManager
* @param {CommandsManager} params.commandsManager
* @returns Object
*/
getCommandsModule() {
return null;
},
};
+177
View File
@@ -3,6 +3,183 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.2.17](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.16...@ohif/extension-vtk@1.2.17) (2020-02-10)
**Note:** Version bump only for package @ohif/extension-vtk
## [1.2.16](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.15...@ohif/extension-vtk@1.2.16) (2020-02-10)
**Note:** Version bump only for package @ohif/extension-vtk
## [1.2.15](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.14...@ohif/extension-vtk@1.2.15) (2020-02-07)
**Note:** Version bump only for package @ohif/extension-vtk
## [1.2.14](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.13...@ohif/extension-vtk@1.2.14) (2020-02-06)
**Note:** Version bump only for package @ohif/extension-vtk
## [1.2.13](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.12...@ohif/extension-vtk@1.2.13) (2020-01-30)
### Bug Fixes
* Set VTK viewport as active by interaction ([#1139](https://github.com/OHIF/Viewers/issues/1139)) ([686d12d](https://github.com/OHIF/Viewers/commit/686d12da5c9d3d435b1e326c2a5caee36e2ed27c))
## [1.2.12](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.11...@ohif/extension-vtk@1.2.12) (2020-01-30)
**Note:** Version bump only for package @ohif/extension-vtk
## [1.2.11](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.10...@ohif/extension-vtk@1.2.11) (2020-01-28)
**Note:** Version bump only for package @ohif/extension-vtk
## [1.2.10](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.9...@ohif/extension-vtk@1.2.10) (2020-01-28)
**Note:** Version bump only for package @ohif/extension-vtk
## [1.2.9](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.8...@ohif/extension-vtk@1.2.9) (2020-01-27)
**Note:** Version bump only for package @ohif/extension-vtk
## [1.2.8](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.7...@ohif/extension-vtk@1.2.8) (2020-01-24)
**Note:** Version bump only for package @ohif/extension-vtk
## [1.2.7](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.6...@ohif/extension-vtk@1.2.7) (2020-01-08)
**Note:** Version bump only for package @ohif/extension-vtk
## [1.2.6](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.5...@ohif/extension-vtk@1.2.6) (2020-01-07)
**Note:** Version bump only for package @ohif/extension-vtk
## [1.2.5](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.4...@ohif/extension-vtk@1.2.5) (2020-01-06)
**Note:** Version bump only for package @ohif/extension-vtk
## [1.2.4](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.3...@ohif/extension-vtk@1.2.4) (2019-12-30)
**Note:** Version bump only for package @ohif/extension-vtk
## [1.2.3](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.2...@ohif/extension-vtk@1.2.3) (2019-12-20)
### Bug Fixes
* 🐛 1241: Make Plugin switch part of ToolbarModule ([#1322](https://github.com/OHIF/Viewers/issues/1322)) ([6540e36](https://github.com/OHIF/Viewers/commit/6540e36818944ac2eccc696186366ae495b33a04)), closes [#1241](https://github.com/OHIF/Viewers/issues/1241)
## [1.2.2](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.1...@ohif/extension-vtk@1.2.2) (2019-12-20)
**Note:** Version bump only for package @ohif/extension-vtk
## [1.2.1](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.2.0...@ohif/extension-vtk@1.2.1) (2019-12-20)
**Note:** Version bump only for package @ohif/extension-vtk
# [1.2.0](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.1.7...@ohif/extension-vtk@1.2.0) (2019-12-20)
### Features
* 🎸 Configuration so viewer tools can nix handles ([#1304](https://github.com/OHIF/Viewers/issues/1304)) ([63594d3](https://github.com/OHIF/Viewers/commit/63594d36b0bdba59f0901095aed70b75fb05172d)), closes [#1223](https://github.com/OHIF/Viewers/issues/1223)
## [1.1.7](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.1.6...@ohif/extension-vtk@1.1.7) (2019-12-19)
**Note:** Version bump only for package @ohif/extension-vtk
## [1.1.6](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.1.5...@ohif/extension-vtk@1.1.6) (2019-12-18)
**Note:** Version bump only for package @ohif/extension-vtk
## [1.1.5](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.1.4...@ohif/extension-vtk@1.1.5) (2019-12-16)
**Note:** Version bump only for package @ohif/extension-vtk
## [1.1.4](https://github.com/OHIF/Viewers/compare/@ohif/extension-vtk@1.1.3...@ohif/extension-vtk@1.1.4) (2019-12-16)
**Note:** Version bump only for package @ohif/extension-vtk
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@ohif/extension-vtk",
"version": "1.1.4",
"version": "1.2.17",
"description": "OHIF extension for VTK.js",
"author": "OHIF",
"license": "MIT",
@@ -49,12 +49,12 @@
"dependencies": {
"@babel/runtime": "^7.5.5",
"lodash.throttle": "^4.1.1",
"react-vtkjs-viewport": "^0.3.9"
"react-vtkjs-viewport": "^0.8.3"
},
"devDependencies": {
"@ohif/core": "^2.1.1",
"@ohif/ui": "^1.1.4",
"cornerstone-tools": "^4.8.0",
"@ohif/core": "^2.4.0",
"@ohif/ui": "^1.2.0",
"cornerstone-tools": "^4.12.0",
"cornerstone-wado-image-loader": "^3.0.0",
"dicom-parser": "^1.8.3",
"gh-pages": "^2.0.1",
+3 -2
View File
@@ -1,6 +1,6 @@
import OHIF from '@ohif/core';
import { View2D } from 'react-vtkjs-viewport';
import { connect } from 'react-redux';
import VTKViewport from './VTKViewport';
const { setViewportActive, setViewportSpecificData } = OHIF.redux.actions;
@@ -18,6 +18,7 @@ const mapStateToProps = (state, ownProps) => {
const pluginDetails = viewportLayout.vtk || {};
return {
activeViewportIndex: state.viewports.activeViewportIndex,
layout: state.viewports.layout,
isActive,
...pluginDetails,
@@ -75,6 +76,6 @@ const ConnectedVTKViewport = connect(
mapStateToProps,
mapDispatchToProps,
mergeProps
)(View2D);
)(VTKViewport);
export default ConnectedVTKViewport;
+6
View File
@@ -77,6 +77,11 @@ class OHIFVTKViewport extends Component {
}),
viewportIndex: PropTypes.number,
children: PropTypes.node,
onScroll: PropTypes.func,
};
static defaultProps = {
onScroll: () => {},
};
static id = 'OHIFVTKViewport';
@@ -386,6 +391,7 @@ class OHIFVTKViewport extends Component {
this.state.paintFilterBackgroundImageData
}
viewportIndex={this.props.viewportIndex}
onScroll={this.props.onScroll}
/>
)}
</div>
+3
View File
@@ -0,0 +1,3 @@
.vtk-viewport-handler svg {
pointer-events: none;
}
+58
View File
@@ -0,0 +1,58 @@
import React, { useEffect, useCallback } from 'react';
import { View2D } from 'react-vtkjs-viewport';
import PropTypes from 'prop-types';
import './VTKViewport.css';
const VTKViewport = props => {
const style = { width: '100%', height: '100%', position: 'relative' };
const setViewportActiveHandler = useCallback(() => {
const { setViewportActive, viewportIndex, activeViewportIndex } = props;
if (viewportIndex !== activeViewportIndex) {
// set in Connected
setViewportActive();
}
});
useEffect(() => {
const handleScrollEvent = evt => {
const vtkViewportApiReference = props.onScroll(props.viewportIndex) || {};
const viewportUid = vtkViewportApiReference.uid;
const viewportWasScrolled = viewportUid === evt.detail.uid;
if (viewportWasScrolled) {
setViewportActiveHandler();
}
};
window.addEventListener('vtkscrollevent', handleScrollEvent);
return () =>
window.removeEventListener('vtkscrollevent', handleScrollEvent);
}, [props, props.onScroll, props.viewportIndex, setViewportActiveHandler]);
return (
<div
className="vtk-viewport-handler"
style={style}
onClick={setViewportActiveHandler}
>
<View2D {...props} />
</div>
);
};
VTKViewport.propTypes = {
setViewportActive: PropTypes.func.isRequired,
viewportIndex: PropTypes.number.isRequired,
activeViewportIndex: PropTypes.number.isRequired,
/* Receives viewportIndex */
onScroll: PropTypes.func,
};
VTKViewport.defaultProps = {
onScroll: () => {},
};
export default VTKViewport;
+10 -12
View File
@@ -103,6 +103,9 @@ const commandsModule = ({ commandsManager }) => {
}
const actions = {
getVtkApis: ({ index }) => {
return apis[index];
},
axial: async ({ viewports }) => {
const api = await _getActiveViewportVTKApi(viewports);
@@ -234,7 +237,7 @@ const commandsModule = ({ commandsManager }) => {
},
},
{
// Sagital
// Sagittal
orientation: {
sliceNormal: [1, 0, 0],
viewUp: [0, 0, 1],
@@ -266,11 +269,12 @@ const commandsModule = ({ commandsManager }) => {
'crosshairsWidget'
);
const uid = api.uid;
const istyle = vtkInteractorStyleMPRCrosshairs.newInstance();
api.setInteractorStyle({
istyle,
configuration: { apis, apiIndex },
configuration: { apis, apiIndex, uid },
});
});
},
@@ -296,55 +300,45 @@ const commandsModule = ({ commandsManager }) => {
},
enableRotateTool: {
commandFn: actions.enableRotateTool,
storeContexts: [],
options: {},
},
enableCrosshairsTool: {
commandFn: actions.enableCrosshairsTool,
storeContexts: [],
options: {},
},
enableLevelTool: {
commandFn: actions.enableLevelTool,
storeContexts: [],
options: {},
},
setBlendModeToComposite: {
commandFn: actions.setBlendModeToComposite,
storeContexts: [],
options: { blendMode: BlendMode.COMPOSITE_BLEND },
},
setBlendModeToMaximumIntensity: {
commandFn: actions.setBlendModeToMaximumIntensity,
storeContexts: [],
options: { blendMode: BlendMode.MAXIMUM_INTENSITY_BLEND },
},
setBlendModeToMinimumIntensity: {
commandFn: actions.setBlendMode,
storeContexts: [],
options: { blendMode: BlendMode.MINIMUM_INTENSITY_BLEND },
},
setBlendModeToAverageIntensity: {
commandFn: actions.setBlendMode,
storeContexts: [],
options: { blendMode: BlendMode.AVERAGE_INTENSITY_BLEND },
},
setSlabThickness: {
// TODO: How do we pass in a function argument?
commandFn: actions.setSlabThickness,
storeContexts: [],
options: {},
},
increaseSlabThickness: {
commandFn: actions.changeSlabThickness,
storeContexts: [],
options: {
change: 3,
},
},
decreaseSlabThickness: {
commandFn: actions.changeSlabThickness,
storeContexts: [],
options: {
change: -3,
},
@@ -355,6 +349,10 @@ const commandsModule = ({ commandsManager }) => {
options: {},
context: 'VIEWER',
},
getVtkApiForViewportIndex: {
commandFn: actions.getVtkApis,
context: 'VIEWER',
},
};
return {
+3 -2
View File
@@ -1,6 +1,7 @@
import asyncComponent from './asyncComponent.js';
import commandsModule from './commandsModule.js';
import toolbarModule from './toolbarModule.js';
import withCommandsManager from './withCommandsManager.js';
// This feels weird
// import loadLocales from './loadLocales';
@@ -14,8 +15,8 @@ const vtkExtension = {
*/
id: 'vtk',
getViewportModule() {
return OHIFVTKViewport;
getViewportModule({ commandsManager }) {
return withCommandsManager(OHIFVTKViewport, commandsManager);
},
getToolbarModule() {
return toolbarModule;
@@ -0,0 +1,87 @@
import React from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { ToolbarButton } from '@ohif/ui';
import { utils } from '@ohif/core';
const { studyMetadataManager } = utils;
let isVisible = true;
const _isDisplaySetReconstructable = (viewportSpecificData = {}, activeViewportIndex) => {
if (!viewportSpecificData[activeViewportIndex]) {
return false;
};
const { displaySetInstanceUid, studyInstanceUid } = viewportSpecificData[
activeViewportIndex
];
const studies = studyMetadataManager.all();
const study = studies.find(
study => study.studyInstanceUID === studyInstanceUid
);
if (!study) {
return false;
}
const displaySet = study._displaySets.find(set => set.displaySetInstanceUid === displaySetInstanceUid);
if (!displaySet) {
return false;
};
return displaySet.isReconstructable;
};
function VTKMPRToolbarButton({
parentContext,
toolbarClickCallback,
button,
activeButtons,
isActive,
className,
}) {
const { id, label, icon } = button;
const { viewportSpecificData, activeViewportIndex } = useSelector(state => {
const { viewports = {} } = state;
const { viewportSpecificData, activeViewportIndex } = viewports;
return {
viewportSpecificData,
activeViewportIndex,
}
});
isVisible = _isDisplaySetReconstructable(
viewportSpecificData,
activeViewportIndex,
);
return (
<React.Fragment>
{isVisible && (
<ToolbarButton
key={id}
label={label}
icon={icon}
onClick={evt => toolbarClickCallback(button, evt)}
isActive={isActive}
/>
)}
</React.Fragment>
);
}
VTKMPRToolbarButton.propTypes = {
parentContext: PropTypes.object.isRequired,
toolbarClickCallback: PropTypes.func.isRequired,
button: PropTypes.object.isRequired,
activeButtons: PropTypes.array.isRequired,
isActive: PropTypes.bool,
className: PropTypes.string,
};
export default VTKMPRToolbarButton;
+12 -1
View File
@@ -1,4 +1,5 @@
import SlabThicknessToolbarComponent from './toolbarComponents/SlabThicknessToolbarComponent';
import VTKMPRToolbarButton from './toolbarComponents/VTKMPRToolbarButton';
const TOOLBAR_BUTTON_TYPES = {
COMMAND: 'command',
@@ -115,9 +116,19 @@ const definitions = [
},
],
},
{
id: '2DMPR',
label: '2D MPR',
icon: 'cube',
//
CustomComponent: VTKMPRToolbarButton,
type: TOOLBAR_BUTTON_TYPES.COMMAND,
commandName: 'mpr2d',
context: 'ACTIVE_VIEWPORT::CORNERSTONE',
},
];
export default {
definitions,
defaultContext: 'ACTIVE_VIEWPORT::VTK',
};
}
+18
View File
@@ -0,0 +1,18 @@
import React from 'react';
export default function withCommandsManager(Component, commandsManager = {}) {
return class WithCommandsManager extends React.Component {
render() {
return (
<Component
{...this.props}
onScroll={viewportIndex =>
commandsManager.runCommand('getVtkApiForViewportIndex', {
index: viewportIndex,
})
}
/>
);
}
};
}
+2 -1
View File
@@ -10,7 +10,8 @@ module.exports = {
// https://jestjs.io/docs/en/configuration#projects-array-string-projectconfig
projects: [
// "<rootDir>/extensions/*/jest.config.js",
"<rootDir>/platform/*/jest.config.js"
"<rootDir>/platform/*/jest.config.js",
"<rootDir>/extensions/*/jest.config.js"
],
coverageDirectory: "<rootDir>/coverage/"
};
+3 -1
View File
@@ -13,11 +13,13 @@
"scripts": {
"cm": "npx git-cz",
"build": "lerna run build:viewer --stream",
"build:lt": "lerna run build:viewer:lesion-tracker --stream",
"build:ci": "lerna run build:viewer:ci --stream",
"build:demo": "lerna run build:viewer:demo --stream",
"build:package": "lerna run build:viewer:package --stream",
"build:package-all": "lerna run build:package --parallel --stream",
"dev": "lerna run dev:viewer --stream",
"dev:lt": "lerna run dev:viewer:lesion-tracker --stream",
"dev:project": ".scripts/dev.sh",
"dev:orthanc": "lerna run dev:orthanc --stream",
"orthanc:up": "docker-compose -f .docker/Nginx-Orthanc/docker-compose.yml up",
@@ -59,6 +61,7 @@
"copy-webpack-plugin": "^5.0.3",
"cross-env": "^5.2.0",
"css-loader": "^3.2.0",
"cypress-file-upload": "^3.5.3",
"dotenv": "^8.1.0",
"eslint": "5.16.0",
"eslint-config-prettier": "^6.4.0",
@@ -72,7 +75,6 @@
"eslint-plugin-react": "7.x",
"eslint-plugin-react-hooks": "1.5.0",
"extract-css-chunks-webpack-plugin": "^4.5.4",
"favicons-webpack-plugin": "^1.0.2",
"html-webpack-plugin": "^3.2.0",
"husky": "^3.0.0",
"jest": "^24.8.0",
+122
View File
@@ -3,6 +3,128 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [2.4.0](https://github.com/OHIF/Viewers/compare/@ohif/core@2.3.9...@ohif/core@2.4.0) (2020-02-10)
### Features
* 🎸 MeasurementService ([#1314](https://github.com/OHIF/Viewers/issues/1314)) ([0c37a40](https://github.com/OHIF/Viewers/commit/0c37a406d963569af8c3be24c697dafd42712dfc))
## [2.3.9](https://github.com/OHIF/Viewers/compare/@ohif/core@2.3.8...@ohif/core@2.3.9) (2020-02-07)
**Note:** Version bump only for package @ohif/core
## [2.3.8](https://github.com/OHIF/Viewers/compare/@ohif/core@2.3.7...@ohif/core@2.3.8) (2020-02-06)
### Bug Fixes
* Remove trash data from redux storage after updates ([#1358](https://github.com/OHIF/Viewers/issues/1358)) ([7b2d44f](https://github.com/OHIF/Viewers/commit/7b2d44f2c18241ea521b8d3652aee32e36eaddb8))
## [2.3.7](https://github.com/OHIF/Viewers/compare/@ohif/core@2.3.6...@ohif/core@2.3.7) (2020-01-30)
### Bug Fixes
* Set VTK viewport as active by interaction ([#1139](https://github.com/OHIF/Viewers/issues/1139)) ([686d12d](https://github.com/OHIF/Viewers/commit/686d12da5c9d3d435b1e326c2a5caee36e2ed27c))
## [2.3.6](https://github.com/OHIF/Viewers/compare/@ohif/core@2.3.5...@ohif/core@2.3.6) (2020-01-28)
**Note:** Version bump only for package @ohif/core
## [2.3.5](https://github.com/OHIF/Viewers/compare/@ohif/core@2.3.4...@ohif/core@2.3.5) (2020-01-28)
**Note:** Version bump only for package @ohif/core
## [2.3.4](https://github.com/OHIF/Viewers/compare/@ohif/core@2.3.3...@ohif/core@2.3.4) (2020-01-27)
**Note:** Version bump only for package @ohif/core
## [2.3.3](https://github.com/OHIF/Viewers/compare/@ohif/core@2.3.2...@ohif/core@2.3.3) (2020-01-24)
**Note:** Version bump only for package @ohif/core
## [2.3.2](https://github.com/OHIF/Viewers/compare/@ohif/core@2.3.1...@ohif/core@2.3.2) (2020-01-06)
**Note:** Version bump only for package @ohif/core
## [2.3.1](https://github.com/OHIF/Viewers/compare/@ohif/core@2.3.0...@ohif/core@2.3.1) (2019-12-30)
### Bug Fixes
* 🐛 1241: Make Plugin switch part of ToolbarModule ([#1322](https://github.com/OHIF/Viewers/issues/1322)) ([6540e36](https://github.com/OHIF/Viewers/commit/6540e36818944ac2eccc696186366ae495b33a04)), closes [#1241](https://github.com/OHIF/Viewers/issues/1241)
# [2.3.0](https://github.com/OHIF/Viewers/compare/@ohif/core@2.2.1...@ohif/core@2.3.0) (2019-12-20)
### Features
* 🎸 Configuration so viewer tools can nix handles ([#1304](https://github.com/OHIF/Viewers/issues/1304)) ([63594d3](https://github.com/OHIF/Viewers/commit/63594d36b0bdba59f0901095aed70b75fb05172d)), closes [#1223](https://github.com/OHIF/Viewers/issues/1223)
## [2.2.1](https://github.com/OHIF/Viewers/compare/@ohif/core@2.2.0...@ohif/core@2.2.1) (2019-12-18)
**Note:** Version bump only for package @ohif/core
# [2.2.0](https://github.com/OHIF/Viewers/compare/@ohif/core@2.1.1...@ohif/core@2.2.0) (2019-12-16)
### Features
* 🎸 Expose extension config to modules ([#1279](https://github.com/OHIF/Viewers/issues/1279)) ([4ea239a](https://github.com/OHIF/Viewers/commit/4ea239a9535ef297e23387c186e537ab273744ea)), closes [#1268](https://github.com/OHIF/Viewers/issues/1268)
## [2.1.1](https://github.com/OHIF/Viewers/compare/@ohif/core@2.1.0...@ohif/core@2.1.1) (2019-12-16)
**Note:** Version bump only for package @ohif/core
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@ohif/core",
"version": "2.1.1",
"version": "2.4.0",
"description": "Generic business logic for web-based medical imaging applications",
"author": "OHIF Core Team",
"license": "MIT",
@@ -32,13 +32,14 @@
},
"peerDependencies": {
"cornerstone-core": "^2.2.8",
"cornerstone-tools": "^4.8.0",
"cornerstone-tools": "^4.12.0",
"cornerstone-wado-image-loader": "^3.0.0",
"dicom-parser": "^1.8.3"
},
"dependencies": {
"@babel/runtime": "^7.5.5",
"ajv": "^6.10.0",
"dcmjs": "^0.8.3",
"dicomweb-client": "^0.5.2",
"isomorphic-base64": "^1.0.2",
"lodash.clonedeep": "^4.5.0",
@@ -1,5 +1,10 @@
import { log, studies, utils } from '@ohif/core';
import { retrieveMeasurementFromSR, stowSRFromMeasurements } from './handleStructuredReport';
import log from '../log';
import studies from '../studies';
import utils from '../utils';
import {
retrieveMeasurementFromSR,
stowSRFromMeasurements,
} from './handleStructuredReport';
import findMostRecentStructuredReport from './utils/findMostRecentStructuredReport';
/**
@@ -65,7 +70,9 @@ const storeMeasurements = async (measurementData, filter, server) => {
message: 'Measurements were saved with success',
};
} catch (error) {
log.error(`[DICOMSR] Error while saving the measurements: ${error.message}`);
log.error(
`[DICOMSR] Error while saving the measurements: ${error.message}`
);
throw new Error('Error while saving the measurements.');
}
};
@@ -1,11 +1,11 @@
import * as dcmjs from 'dcmjs';
import { DICOMWeb } from '@ohif/core';
import { api } from 'dicomweb-client';
import DICOMWeb from '../DICOMWeb';
import parseDicomStructuredReport from './parseDicomStructuredReport';
import parseMeasurementsData from './parseMeasurementsData';
import getAllDisplaySets from './utils/getAllDisplaySets';
import { api } from 'dicomweb-client';
const VERSION_NAME = 'dcmjs-0.0';
const TRANSFER_SYNTAX_UID = '1.2.840.10008.1.2.1';
@@ -34,7 +34,10 @@ const retrieveMeasurementFromSR = async (series, studies, serverUrl) => {
const part10SRArrayBuffer = await dicomWeb.retrieveInstance(options);
const displaySets = getAllDisplaySets(studies);
const measurementsData = parseDicomStructuredReport(part10SRArrayBuffer, displaySets);
const measurementsData = parseDicomStructuredReport(
part10SRArrayBuffer,
displaySets
);
return measurementsData;
};
@@ -47,13 +50,10 @@ const retrieveMeasurementFromSR = async (series, studies, serverUrl) => {
* @returns {Promise}
*/
const stowSRFromMeasurements = async (measurements, serverUrl) => {
const { dataset } = parseMeasurementsData(
measurements
);
const { dataset } = parseMeasurementsData(measurements);
const { DicomMetaDictionary, DicomDict } = dcmjs.data;
const meta = {
FileMetaInformationVersion:
dataset._meta.FileMetaInformationVersion.Value,
FileMetaInformationVersion: dataset._meta.FileMetaInformationVersion.Value,
MediaStorageSOPClassUID: dataset.SOPClassUID,
MediaStorageSOPInstanceUID: dataset.SOPInstanceUID,
TransferSyntaxUID: TRANSFER_SYNTAX_UID,
File renamed without changes.
@@ -1,4 +1,5 @@
import * as dcmjs from 'dcmjs';
import findInstanceMetadataBySopInstanceUid from './utils/findInstanceMetadataBySopInstanceUid';
/**
@@ -37,7 +38,12 @@ const parseDicomStructuredReport = (part10SRArrayBuffer, displaySets) => {
const { studyInstanceUid, patientId } = study;
const { seriesInstanceUid } = series;
const { sopInstanceUid, frameIndex } = measurement;
const imagePath = getImagePath(studyInstanceUid, seriesInstanceUid, sopInstanceUid, frameIndex);
const imagePath = getImagePath(
studyInstanceUid,
seriesInstanceUid,
sopInstanceUid,
frameIndex
);
const imageId = instanceMetadata.getImageId();
if (!imageId) {
@@ -75,8 +81,15 @@ const parseDicomStructuredReport = (part10SRArrayBuffer, displaySets) => {
* @param {string} frameIndex
* @returns
*/
const getImagePath = (studyInstanceUid, seriesInstanceUid, sopInstanceUid, frameIndex) => {
return [studyInstanceUid, seriesInstanceUid, sopInstanceUid, frameIndex].join('_');
}
const getImagePath = (
studyInstanceUid,
seriesInstanceUid,
sopInstanceUid,
frameIndex
) => {
return [studyInstanceUid, seriesInstanceUid, sopInstanceUid, frameIndex].join(
'_'
);
};
export default parseDicomStructuredReport;
@@ -1,6 +1,8 @@
import * as dcmjs from 'dcmjs';
import { log, measurements } from '@ohif/core';
import cornerstone from 'cornerstone-core';
import log from '../log';
import measurements from '../measurements';
import isToolSupported from './utils/isToolSupported';
/**
@@ -11,7 +11,9 @@ const findInstanceMetadataBySopInstanceUid = (displaySets, sopInstanceUid) => {
displaySets.find(displaySet => {
if (!displaySet.images) return false;
instanceFound = displaySet.images.find(instanceMetadata => instanceMetadata._sopInstanceUID === sopInstanceUid);
instanceFound = displaySet.images.find(
instanceMetadata => instanceMetadata._sopInstanceUID === sopInstanceUid
);
return !!instanceFound;
});
@@ -4,15 +4,17 @@
* @param {Array} studies
* @returns {Object} Series
*/
const findMostRecentStructuredReport = (studies) => {
const findMostRecentStructuredReport = studies => {
let mostRecentStructuredReport;
studies.forEach(study => {
const allSeries = study.getSeries ? study.getSeries() : [];
allSeries.forEach(series => {
if (isStructuredReportSeries(series)) {
if (!mostRecentStructuredReport || compareSeriesDate(series, mostRecentStructuredReport)) {
if (
!mostRecentStructuredReport ||
compareSeriesDate(series, mostRecentStructuredReport)
) {
mostRecentStructuredReport = series;
}
}
@@ -28,7 +30,7 @@ const findMostRecentStructuredReport = (studies) => {
* @param {Object} series - Series metadata
* @returns {boolean}
*/
const isStructuredReportSeries = (series) => {
const isStructuredReportSeries = series => {
const supportedSopClassUIDs = [
'1.2.840.10008.5.1.4.1.1.88.22',
'1.2.840.10008.5.1.4.1.1.11.1',
@@ -38,7 +40,7 @@ const isStructuredReportSeries = (series) => {
const sopClassUid = firstInstance._instance.sopClassUid;
return supportedSopClassUIDs.includes(sopClassUid);
}
};
/**
* Checkes if series1 is newer than series2
@@ -48,10 +50,11 @@ const isStructuredReportSeries = (series) => {
* @returns {boolean} true/false if series1 is newer than series2
*/
const compareSeriesDate = (series1, series2) => {
return series1._data.seriesDate > series2._data.seriesDate ||
return (
series1._data.seriesDate > series2._data.seriesDate ||
(series1._data.seriesDate === series2._data.seriesDate &&
series1._data.seriesTime > series2._data.seriesTime);
}
series1._data.seriesTime > series2._data.seriesTime)
);
};
export default findMostRecentStructuredReport;
@@ -4,7 +4,7 @@
* @param {Object} studies - List of studies loaded into the viewer
* @returns {Object} List of DisplaySets
*/
const getAllDisplaySets = (studies) => {
const getAllDisplaySets = studies => {
let allDisplaySets = [];
studies.forEach(study => {
+1 -1
View File
@@ -161,7 +161,7 @@ export class CommandsManager {
return;
}
const { commandFn, storeContexts } = definition;
const { commandFn, storeContexts = [] } = definition;
const definitionOptions = definition.options;
let commandParams = {};
@@ -2,13 +2,14 @@ import MODULE_TYPES from './MODULE_TYPES.js';
import log from './../log.js';
export default class ExtensionManager {
constructor({ commandsManager, servicesManager }) {
constructor({ commandsManager, servicesManager, appConfig = {} }) {
this.modules = {};
this.registeredExtensionIds = [];
this.moduleTypeNames = Object.values(MODULE_TYPES);
//
this._commandsManager = commandsManager;
this._servicesManager = servicesManager;
this._appConfig = appConfig;
this.moduleTypeNames.forEach(moduleType => {
this.modules[moduleType] = [];
@@ -70,6 +71,7 @@ export default class ExtensionManager {
extension.preRegistration({
servicesManager: this._servicesManager,
commandsManager: this._commandsManager,
appConfig: this._appConfig,
configuration,
});
}
@@ -79,7 +81,8 @@ export default class ExtensionManager {
const extensionModule = this._getExtensionModule(
moduleType,
extension,
extensionId
extensionId,
configuration
);
if (extensionModule) {
@@ -102,7 +105,7 @@ export default class ExtensionManager {
* @param {Object} extension
* @param {string} extensionId - Used for logging warnings
*/
_getExtensionModule(moduleType, extension, extensionId) {
_getExtensionModule(moduleType, extension, extensionId, configuration) {
const getModuleFnName = 'get' + _capitalizeFirstCharacter(moduleType);
const getModuleFn = extension[getModuleFnName];
@@ -114,6 +117,8 @@ export default class ExtensionManager {
const extensionModule = getModuleFn({
servicesManager: this._servicesManager,
commandsManager: this._commandsManager,
appConfig: this._appConfig,
configuration,
});
if (!extensionModule) {
@@ -6,7 +6,7 @@ import log from './../log.js';
jest.mock('./../log.js');
describe('ExtensionManager.js', () => {
let extensionManager, commandsManager;
let extensionManager, commandsManager, servicesManager, appConfig;
beforeEach(() => {
commandsManager = {
@@ -14,7 +14,17 @@ describe('ExtensionManager.js', () => {
getContext: jest.fn(),
registerCommand: jest.fn(),
};
extensionManager = new ExtensionManager({ commandsManager });
servicesManager = {
registerService: jest.fn(),
};
appConfig = {
testing: true,
};
extensionManager = new ExtensionManager({
servicesManager,
commandsManager,
appConfig,
});
log.warn.mockClear();
jest.clearAllMocks();
});
@@ -51,7 +61,7 @@ describe('ExtensionManager.js', () => {
extensionManager.registerExtensions(fakeExtensions);
// Assert
expect(extensionManager.registerExtension.mock.calls[1]).toContain(
expect(extensionManager.registerExtension.mock.calls[1][1]).toEqual(
fakeConfiguration
);
});
@@ -67,19 +77,19 @@ describe('ExtensionManager.js', () => {
expect(fakeExtension.preRegistration.mock.calls.length).toBe(1);
});
it('calls preRegistration() passing configuration along with servicesManager and commandsManager instances for extension', () => {
const configuration = { config: 'Some configuration' };
extensionManager._servicesManager = { services: { TestService: {} } };
it('calls preRegistration() passing dependencies and extension configuration to extension', () => {
const extensionConfiguration = { config: 'Some configuration' };
// SUT
const fakeExtension = { one: '1', preRegistration: jest.fn() };
extensionManager.registerExtension(fakeExtension, configuration);
const extension = { one: '1', preRegistration: jest.fn() };
extensionManager.registerExtension(extension, extensionConfiguration);
// Assert
expect(fakeExtension.preRegistration.mock.calls[0][0]).toEqual({
servicesManager: extensionManager._servicesManager,
commandsManager: extensionManager._commandsManager,
configuration,
expect(extension.preRegistration.mock.calls[0][0]).toEqual({
servicesManager,
commandsManager,
appConfig,
configuration: extensionConfiguration,
});
});
@@ -153,8 +163,8 @@ describe('ExtensionManager.js', () => {
);
});
it('successfully passes a servicesManager and commandsManager instances to each module', () => {
extensionManager._servicesManager = { services: { TestService: {} } };
it('successfully passes dependencies to each module along with extension configuration', () => {
const extensionConfiguration = { testing: true };
const extension = {
id: 'hello-world',
@@ -165,11 +175,17 @@ describe('ExtensionManager.js', () => {
getCommandsModule: jest.fn(),
};
extensionManager.registerExtension(extension);
extensionManager.registerExtension(extension, extensionConfiguration);
expect(extension.getViewportModule.mock.calls[0][0]).toEqual({
servicesManager: extensionManager._servicesManager,
commandsManager: extensionManager._commandsManager,
Object.keys(extension).forEach(module => {
if (typeof extension[module] === 'function') {
expect(extension[module].mock.calls[0][0]).toEqual({
servicesManager,
commandsManager,
appConfig,
configuration: extensionConfiguration,
});
}
});
});
@@ -210,7 +226,7 @@ describe('ExtensionManager.js', () => {
return {
definitions: {
exampleDefinition: {
commandFn: () => {},
commandFn: () => { },
storeContexts: [],
options: {},
},
+6
View File
@@ -5,6 +5,7 @@ import { ServicesManager } from './services';
import classes, { CommandsManager, HotkeysManager } from './classes/';
import DICOMWeb from './DICOMWeb';
import DICOMSR from './DICOMSR';
import cornerstone from './cornerstone.js';
import hangingProtocols from './hanging-protocols';
import header from './header.js';
@@ -23,6 +24,7 @@ import {
UINotificationService,
UIModalService,
UIDialogService,
MeasurementService,
} from './services';
const OHIF = {
@@ -46,6 +48,7 @@ const OHIF = {
object,
log,
DICOMWeb,
DICOMSR,
viewer: {},
measurements,
hangingProtocols,
@@ -53,6 +56,7 @@ const OHIF = {
UINotificationService,
UIModalService,
UIDialogService,
MeasurementService,
};
export {
@@ -76,12 +80,14 @@ export {
object,
log,
DICOMWeb,
DICOMSR,
measurements,
hangingProtocols,
//
UINotificationService,
UIModalService,
UIDialogService,
MeasurementService,
};
export { OHIF };
+2
View File
@@ -13,6 +13,7 @@ describe('Top level exports', () => {
'UINotificationService',
'UIModalService',
'UIDialogService',
'MeasurementService',
//
'utils',
'studies',
@@ -28,6 +29,7 @@ describe('Top level exports', () => {
'object',
'log',
'DICOMWeb',
'DICOMSR',
'OHIF', //
'measurements',
'hangingProtocols',
@@ -11,6 +11,16 @@ const servers = (state = defaultState, action) => {
servers.forEach(s => (s.active = true));
return { ...state, servers };
case 'ACTIVATE_SERVER': {
const newServer = { ...action.server, active: true };
const newServers = state.servers;
newServers.forEach(s => (s.active = false));
return {
...state,
servers: uniqBy([...newServers, newServer], 'wadoRoot'),
};
}
case 'SET_SERVERS':
return { ...state, servers: action.servers };
+83 -8
View File
@@ -11,20 +11,63 @@ import {
SET_VIEWPORT_LAYOUT_AND_DATA,
} from './../constants/ActionTypes.js';
const DEFAULT_STATE = {
export const DEFAULT_STATE = {
numRows: 1,
numColumns: 1,
activeViewportIndex: 0,
layout: {
viewports: [
{
// plugin: 'cornerstone',
},
],
viewports: [{}],
},
viewportSpecificData: {},
};
/**
* Take the new number of rows and columns, delete all not used viewport data and also set
* active viewport as default in case current one is not available anymore.
*
* @param {Number} numRows
* @param {Number} numColumns
* @param {Object} currentViewportSpecificData
* @returns
*/
const findActiveViewportSpecificData = (
numRows,
numColumns,
currentViewportSpecificData = {}
) => {
const numberOfViewports = numRows * numColumns;
const viewportSpecificData = cloneDeep(currentViewportSpecificData);
if (numberOfViewports < Object.keys(viewportSpecificData).length) {
Object.keys(viewportSpecificData).forEach(key => {
if (key > numberOfViewports - 1) {
delete viewportSpecificData[key];
}
});
}
return viewportSpecificData;
};
/**
* Take new number of rows and columns and make sure the current active viewport index is still available, if not, return the default
*
* @param {Number} numRows
* @param {Number} numColumns
* @param {Number} currentActiveViewportIndex
* @returns
*/
const getActiveViewportIndex = (
numRows,
numColumns,
currentActiveViewportIndex
) => {
const numberOfViewports = numRows * numColumns;
return currentActiveViewportIndex > numberOfViewports - 1
? DEFAULT_STATE.activeViewportIndex
: currentActiveViewportIndex;
};
/**
* The definition of a viewport action.
*
@@ -50,7 +93,12 @@ const viewports = (state = DEFAULT_STATE, action) => {
* @return {Object} New state.
*/
case SET_VIEWPORT_ACTIVE: {
return { ...state, activeViewportIndex: action.viewportIndex };
const activeViewportIndex = getActiveViewportIndex(
state.numRows,
state.numColumns,
action.viewportIndex
);
return { ...state, activeViewportIndex };
}
/**
@@ -59,11 +107,25 @@ const viewports = (state = DEFAULT_STATE, action) => {
* @return {Object} New state.
*/
case SET_VIEWPORT_LAYOUT: {
const { numRows, numColumns } = action;
const viewportSpecificData = findActiveViewportSpecificData(
numRows,
numColumns,
state.viewportSpecificData
);
const activeViewportIndex = getActiveViewportIndex(
numRows,
numColumns,
state.activeViewportIndex
);
return {
...state,
numRows: action.numRows,
numColumns: action.numColumns,
layout: { viewports: [...action.viewports] },
viewportSpecificData,
activeViewportIndex,
};
}
@@ -73,12 +135,25 @@ const viewports = (state = DEFAULT_STATE, action) => {
* @return {Object} New state.
*/
case SET_VIEWPORT_LAYOUT_AND_DATA: {
const { numRows, numColumns } = action;
const viewportSpecificData = findActiveViewportSpecificData(
numRows,
numColumns,
action.viewportSpecificData
);
const activeViewportIndex = getActiveViewportIndex(
numRows,
numColumns,
state.activeViewportIndex
);
return {
...state,
numRows: action.numRows,
numColumns: action.numColumns,
layout: { viewports: [...action.viewports] },
viewportSpecificData: cloneDeep(action.viewportSpecificData),
viewportSpecificData,
activeViewportIndex,
};
}
@@ -1,35 +1,61 @@
// https://medium.com/@netxm/testing-redux-reducers-with-jest-6653abbfe3e1
import reducer from './viewports.js';
import { Reducer } from 'redux-testkit';
import reducer, { DEFAULT_STATE } from './viewports.js';
import * as types from './../constants/ActionTypes.js';
describe('viewports reducer', () => {
it('should return the initial state', () => {
expect(reducer(undefined, {})).toEqual({
activeViewportIndex: 0,
numRows: 1,
numColumns: 1,
layout: {
viewports: [{}],
},
viewportSpecificData: {},
});
expect(reducer(undefined, {})).toEqual(DEFAULT_STATE);
});
it('should handle SET_VIEWPORT_ACTIVE', () => {
const setViewportActiveAction = {
it('should handle SET_VIEWPORT_ACTIVE with inexistent viewport index', () => {
const initialState = {
numRows: 4,
numColumns: 4,
activeViewportIndex: 0,
};
const action = {
type: types.SET_VIEWPORT_ACTIVE,
viewportIndex: 100,
};
const updatedState = reducer({}, setViewportActiveAction);
const expectedToChangeInState = {
activeViewportIndex: 0,
};
expect(updatedState.activeViewportIndex).toEqual(
setViewportActiveAction.viewportIndex
);
Reducer(reducer)
.withState(initialState)
.expect(action)
.toChangeInState(expectedToChangeInState);
});
it('should handle SET_VIEWPORT_ACTIVE with existent viewport index', () => {
const initialState = {
numRows: 4,
numColumns: 4,
activeViewportIndex: 0,
};
const action = {
type: types.SET_VIEWPORT_ACTIVE,
viewportIndex: 5,
};
const expectedToChangeInState = {
activeViewportIndex: 5,
};
Reducer(reducer)
.withState(initialState)
.expect(action)
.toChangeInState(expectedToChangeInState);
});
it('should handle SET_VIEWPORT_LAYOUT', () => {
const setViewportLayoutAction = {
const initialState = DEFAULT_STATE;
const action = {
type: types.SET_VIEWPORT_LAYOUT,
numRows: 1,
numColumns: 2,
@@ -43,18 +69,193 @@ describe('viewports reducer', () => {
],
};
const updatedState = reducer({}, setViewportLayoutAction);
const expectedToChangeInState = {
numRows: 1,
numColumns: 2,
layout: {
viewports: [
{
plugin: 'cornerstone',
},
{
plugin: 'vtk',
},
],
},
};
expect(updatedState.numRows).toEqual(setViewportLayoutAction.numRows);
expect(updatedState.numColumns).toEqual(setViewportLayoutAction.numColumns);
expect(updatedState.layout.viewports).toEqual(
setViewportLayoutAction.viewports
);
Reducer(reducer)
.withState(initialState)
.expect(action)
.toChangeInState(expectedToChangeInState);
});
it('should handle SET_VIEWPORT_LAYOUT when we reduce the number of viewports', () => {
const initialState = {
numRows: 1,
numColumns: 2,
viewportSpecificData: {
0: { viewportData0: 'viewportData0' },
1: { viewportData1: 'viewportData1' },
},
layout: {
viewports: [],
},
activeViewportIndex: 0,
};
const action = {
type: types.SET_VIEWPORT_LAYOUT,
numRows: 1,
numColumns: 1,
viewports: [],
};
const expectedState = {
numRows: 1,
numColumns: 1,
viewportSpecificData: {
0: { viewportData0: 'viewportData0' },
},
layout: {
viewports: [],
},
activeViewportIndex: 0,
};
Reducer(reducer)
.withState(initialState)
.expect(action)
.toReturnState(expectedState);
});
it('should handle SET_VIEWPORT_LAYOUT_AND_DATA', () => {
const initialState = {
numRows: 1,
numColumns: 1,
viewportSpecificData: {
0: { viewportData0: 'data0' },
},
layout: {
viewports: [{ plugin: 'cornerstone' }],
},
activeViewportIndex: 0,
};
const action = {
type: types.SET_VIEWPORT_LAYOUT_AND_DATA,
numRows: 1,
numColumns: 2,
viewports: [{ plugin: 'cornerstone' }, { plugin: 'cornerstone' }],
viewportSpecificData: {
0: { viewportData0: 'NEWdata0' },
1: { viewportData1: 'NEWdata1' },
},
};
const expectedState = {
numRows: 1,
numColumns: 2,
viewportSpecificData: {
0: { viewportData0: 'NEWdata0' },
1: { viewportData1: 'NEWdata1' },
},
layout: {
viewports: [{ plugin: 'cornerstone' }, { plugin: 'cornerstone' }],
},
activeViewportIndex: 0,
};
Reducer(reducer)
.withState(initialState)
.expect(action)
.toReturnState(expectedState);
});
it('should handle SET_VIEWPORT_LAYOUT_AND_DATA when we reduce the number of viewports', () => {
const initialState = {
numRows: 1,
numColumns: 3,
viewportSpecificData: {
0: { viewportData0: 'vtkData0' },
1: { viewportData1: 'vtkData1' },
2: { viewportData2: 'vtkData2' },
},
layout: {
viewports: [{ plugin: 'vtk' }, { plugin: 'vtk' }, { plugin: 'vtk' }],
},
activeViewportIndex: 0,
};
const action = {
type: types.SET_VIEWPORT_LAYOUT_AND_DATA,
numRows: 1,
numColumns: 1,
viewports: [{ plugin: 'cornerstone' }],
viewportSpecificData: {
0: { viewportData0: 'cornerstoneData0' },
},
};
const expectedState = {
numRows: 1,
numColumns: 1,
viewportSpecificData: {
0: { viewportData0: 'cornerstoneData0' },
},
layout: {
viewports: [{ plugin: 'cornerstone' }],
},
activeViewportIndex: 0,
};
Reducer(reducer)
.withState(initialState)
.expect(action)
.toReturnState(expectedState);
});
it('should handle SET_VIEWPORT when we only set one viewport specific data', () => {
const initialState = {
numRows: 1,
numColumns: 2,
viewportSpecificData: {
0: { viewportData0: 'data0' },
1: { viewportData1: 'data1' },
},
layout: {
viewports: [{ plugin: 'cornerstone' }, { plugin: 'cornerstone' }],
},
activeViewportIndex: 0,
};
const action = {
type: types.SET_VIEWPORT,
viewportIndex: 1,
viewportSpecificData: {
viewportData1: 'NEWdata1',
},
};
const expectedState = {
numRows: 1,
numColumns: 2,
viewportSpecificData: {
0: { viewportData0: 'data0' },
1: { viewportData1: 'NEWdata1' },
},
layout: {
viewports: [{ plugin: 'cornerstone' }, { plugin: 'cornerstone' }],
},
activeViewportIndex: 0,
};
Reducer(reducer)
.withState(initialState)
.expect(action)
.toReturnState(expectedState);
});
// If there were previous keys, this would have
// "merge" behavior, not a clear & set
// May be worth another test?
it('should handle SET_VIEWPORT', () => {
const viewportToSet = 0;
const setViewportAction = {
@@ -0,0 +1,481 @@
import log from '../../log';
import guid from '../../utils/guid';
/**
* Measurement source schema
*
* @typedef {Object} MeasurementSource
* @property {number} id -
* @property {string} name -
* @property {string} version -
*/
/**
* Measurement schema
*
* @typedef {Object} Measurement
* @property {number} id -
* @property {string} sopInstanceUID -
* @property {string} frameOfReferenceUID -
* @property {string} referenceSeriesUID -
* @property {string} label -
* @property {string} description -
* @property {string} type -
* @property {string} unit -
* @property {number} area -
* @property {Array} points -
* @property {MeasurementSource} source -
*/
/* Measurement schema keys for object validation. */
const MEASUREMENT_SCHEMA_KEYS = [
'id',
'sopInstanceUID',
'frameOfReferenceUID',
'referenceSeriesUID',
'label',
'description',
'type',
'unit',
'area', // TODO: Add concept names instead (descriptor)
'points',
'source',
];
const EVENTS = {
MEASUREMENT_UPDATED: 'event::measurement_updated',
MEASUREMENT_ADDED: 'event::measurement_added',
};
const VALUE_TYPES = {
POLYLINE: 'value_type::polyline',
POINT: 'value_type::point',
ELLIPSE: 'value_type::ellipse',
MULTIPOINT: 'value_type::multipoint',
CIRCLE: 'value_type::circle',
};
class MeasurementService {
constructor() {
this.sources = {};
this.mappings = {};
this.measurements = {};
this.listeners = {};
Object.defineProperty(this, 'EVENTS', {
value: EVENTS,
writable: false,
enumerable: true,
configurable: false,
});
Object.defineProperty(this, 'VALUE_TYPES', {
value: VALUE_TYPES,
writable: false,
enumerable: true,
configurable: false,
});
}
/**
* Get all measurements.
*
* @return {Measurement[]} Array of measurements
*/
getMeasurements() {
const measurements = this._arrayOfObjects(this.measurements);
return (
measurements &&
measurements.map(m => this.measurements[Object.keys(m)[0]])
);
}
/**
* Get specific measurement by its id.
*
* @param {string} id If of the measurement
* @return {Measurement} Measurement instance
*/
getMeasurement(id) {
let measurement = null;
const measurements = this.measurements[id];
if (measurements && Object.keys(measurements).length > 0) {
measurement = this.measurements[id];
}
return measurement;
}
/**
* Create a new source.
*
* @param {string} name Name of the source
* @param {string} version Source name
* @return {MeasurementSource} Measurement source instance
*/
createSource(name, version) {
if (!name) {
log.warn('Source name not provided. Exiting early.');
return;
}
if (!version) {
log.warn('Source version not provided. Exiting early.');
return;
}
const id = guid();
const source = {
id,
name,
version,
};
source.addOrUpdate = (definition, measurement) => {
return this.addOrUpdate(source, definition, measurement);
};
source.getAnnotation = (definition, measurementId) => {
return this.getAnnotation(source, definition, measurementId);
};
log.info(`New '${name}@${version}' source added.`);
this.sources[id] = source;
return source;
}
/**
* Add a new measurement matching criteria along with mapping functions.
*
* @param {MeasurementSource} source Measurement source instance
* @param {string} definition Definition of the measurement (Annotation Type)
* @param {MatchingCriteria} matchingCriteria The matching criteria
* @param {Function} toSourceSchema Mapping function to source schema
* @param {Function} toMeasurementSchema Mapping function to measurement schema
* @return void
*/
addMapping(
source,
definition,
matchingCriteria,
toSourceSchema,
toMeasurementSchema
) {
if (!this._isValidSource(source)) {
log.warn('Invalid source. Exiting early.');
return;
}
if (!matchingCriteria) {
log.warn('Matching criteria not provided. Exiting early.');
return;
}
if (!definition) {
log.warn('Definition not provided. Exiting early.');
return;
}
if (!toSourceSchema) {
log.warn('Source mapping function not provided. Exiting early.');
return;
}
if (!toMeasurementSchema) {
log.warn('Measurement mapping function not provided. Exiting early.');
return;
}
const mapping = {
matchingCriteria,
definition,
toSourceSchema,
toMeasurementSchema,
};
if (Array.isArray(this.mappings[source.id])) {
this.mappings[source.id].push(mapping);
} else {
this.mappings[source.id] = [mapping];
}
log.info(`New measurement mapping added to source '${this._getSourceInfo(source)}'.`);
}
/**
* Get annotation for specific source.
*
* @param {MeasurementSource} source Measurement source instance
* @param {string} definition The source definition
* @param {string} measurementId The measurement service measurement id
* @return {Object} Source measurement schema
*/
getAnnotation(source, definition, measurementId) {
if (!this._isValidSource(source)) {
log.warn('Invalid source. Exiting early.');
return;
}
if (!definition) {
log.warn('No source definition provided. Exiting early.');
return;
}
const mapping = this._getMappingByMeasurementSource(measurementId, definition);
if (mapping) return mapping.toSourceSchema(measurement, definition);
const measurement = this.getMeasurement(measurementId);
const matchingMapping = this._getMatchingMapping(source, definition, measurement);
if (matchingMapping) {
log.info('Matching mapping found:', matchingMapping);
const { toSourceSchema, definition } = matchingMapping;
return toSourceSchema(measurement, definition);
}
}
/**
* Adds or update persisted measurements.
*
* @param {MeasurementSource} source The measurement source instance
* @param {string} definition The source definition
* @param {Measurement} measurement The source measurement
* @return {string} A measurement id
*/
addOrUpdate(source, definition, sourceMeasurement) {
if (!this._isValidSource(source)) {
log.warn('Invalid source. Exiting early.');
return;
}
const sourceInfo = this._getSourceInfo(source);
if (!definition) {
log.warn('No source definition provided. Exiting early.');
return;
}
if (!this._sourceHasMappings(source)) {
log.warn(`No measurement mappings found for '${sourceInfo}' source. Exiting early.`);
return;
}
let measurement = {};
try {
const sourceMappings = this.mappings[source.id];
const { toMeasurementSchema } = sourceMappings.find(
mapping => mapping.definition === definition
);
/* Convert measurement */
measurement = toMeasurementSchema(sourceMeasurement);
/* Assign measurement source instance */
measurement.source = source;
} catch (error) {
log.error(`Failed to map '${sourceInfo}' measurement for definition ${definition}:`, error.message);
return;
}
if (!this._isValidMeasurement(measurement)) {
log.warn(
`Attempting to add or update a invalid measurement provided by '${sourceInfo}'. Exiting early.`
);
return;
}
let internalId = sourceMeasurement.id;
if (!internalId) {
internalId = guid();
log.warn(`Measurement ID not found. Generating UID: ${internalId}`);
}
const newMeasurement = {
...measurement,
modifiedTimestamp: Math.floor(Date.now() / 1000),
id: internalId,
};
if (this.measurements[internalId]) {
log.info(`Measurement already defined. Updating measurement.`, newMeasurement);
this.measurements[internalId] = newMeasurement;
this._broadcastChange(this.EVENTS.MEASUREMENT_UPDATED, source, newMeasurement);
} else {
log.info(`Measurement added.`, newMeasurement);
this.measurements[internalId] = newMeasurement;
this._broadcastChange(this.EVENTS.MEASUREMENT_ADDED, source, newMeasurement);
}
return newMeasurement.id;
}
/**
* Subscribe to measurement updates.
*
* @param {string} eventName The name of the event
* @param {Function} callback Events callback
* @return {Object} Observable object with actions
*/
subscribe(eventName, callback) {
if (this._isValidEvent(eventName)) {
const listenerId = guid();
const subscription = { id: listenerId, callback };
console.info(`Subscribing to '${eventName}'.`);
if (Array.isArray(this.listeners[eventName])) {
this.listeners[eventName].push(subscription);
} else {
this.listeners[eventName] = [subscription];
}
return {
unsubscribe: () => this._unsubscribe(eventName, listenerId),
};
} else {
throw new Error(`Event ${eventName} not supported.`);
}
}
_getMappingByMeasurementSource(measurementId, definition) {
const measurement = this.getMeasurement(measurementId);
if (this._isValidSource(measurement.source)) {
return this.mappings[measurement.source.id].find(
m => m.definition === definition
);
}
}
/**
* Get measurement mapping function if matching criteria.
*
* @param {MeasurementSource} source Measurement source instance
* @param {string} definition The source definition
* @param {string} measurement The measurement serice measurement
* @return {Object} The mapping based on matched criteria
*/
_getMatchingMapping(source, definition, measurement) {
const sourceMappings = this.mappings[source.id];
const sourceMappingsByDefinition = sourceMappings.filter(
mapping => mapping.definition === definition
);
/* Criteria Matching */
return sourceMappingsByDefinition.find(({ matchingCriteria }) => {
return (
measurement.points &&
measurement.points.length === matchingCriteria.points
);
});
}
/**
* Returns formatted string with source info.
*
* @param {MeasurementSource} source Measurement source
* @return {string} Source information
*/
_getSourceInfo(source) {
return `${source.name}@${source.version}`;
}
/**
* Checks if given source is valid.
*
* @param {MeasurementSource} source Measurement source
* @return {boolean} Measurement source validation
*/
_isValidSource(source) {
return source && this.sources[source.id];
}
/**
* Checks if a given source has mappings.
*
* @param {MeasurementSource} source The measurement source
* @return {boolean} Validation if source has mappings
*/
_sourceHasMappings(source) {
return (
Array.isArray(this.mappings[source.id]) && this.mappings[source.id].length
);
}
/**
* Broadcasts measurement changes.
*
* @param {string} measurementId The measurement id
* @param {MeasurementSource} source The measurement source
* @param {string} eventName The event name
* @return void
*/
_broadcastChange(eventName, source, measurement) {
const hasListeners = Object.keys(this.listeners).length > 0;
const hasCallbacks = Array.isArray(this.listeners[eventName]);
if (hasListeners && hasCallbacks) {
this.listeners[eventName].forEach(listener => {
listener.callback({ source, measurement });
});
}
}
/**
* Unsubscribe to measurement updates.
*
* @param {string} eventName The name of the event
* @param {string} listenerId The listeners id
* @return void
*/
_unsubscribe(eventName, listenerId) {
if (!this.listeners[eventName]) {
return;
}
const listeners = this.listeners[eventName];
if (Array.isArray(listeners)) {
this.listeners[eventName] = listeners.filter(
({ id }) => id !== listenerId
);
} else {
this.listeners[eventName] = undefined;
}
}
/**
* Check if a given measurement data is valid.
*
* @param {Measurement} measurementData Measurement data
* @return {boolean} Measurement validation
*/
_isValidMeasurement(measurementData) {
Object.keys(measurementData).forEach(key => {
if (!MEASUREMENT_SCHEMA_KEYS.includes(key)) {
log.warn(`Invalid measurement key: ${key}`);
return false;
}
});
return true;
}
/**
* Check if a given measurement service event is valid.
*
* @param {string} eventName The name of the event
* @return {boolean} Event name validation
*/
_isValidEvent(eventName) {
return Object.values(this.EVENTS).includes(eventName);
}
/**
* Converts object of objects to array.
*
* @return {Array} Array of objects
*/
_arrayOfObjects = obj => {
return Object.entries(obj).map(e => ({ [e[0]]: e[1] }));
};
}
export default MeasurementService;
export { EVENTS, VALUE_TYPES };
@@ -0,0 +1,405 @@
import MeasurementService from './MeasurementService.js';
import log from '../../log';
jest.mock('../../log.js', () => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}));
describe('MeasurementService.js', () => {
let measurementService;
let measurement;
let source;
let definition;
let matchingCriteria;
let toAnnotation;
let toMeasurement;
let annotation;
beforeEach(() => {
measurementService = new MeasurementService();
source = measurementService.createSource('Test', '1');
definition = 'Length';
annotation = {
toolName: definition,
measurementData: {},
};
measurement = {
sopInstanceUID: '123',
frameOfReferenceUID: '1234',
referenceSeriesUID: '12345',
label: 'Label',
description: 'Description',
unit: 'mm',
area: 123,
type: measurementService.VALUE_TYPES.POLYLINE,
points: [{ x: 1, y: 2 }, { x: 1, y: 2 }],
source: source,
};
toAnnotation = () => annotation;
toMeasurement = () => measurement;
matchingCriteria = {
valueType: measurementService.VALUE_TYPES.POLYLINE,
points: 2,
};
log.warn.mockClear();
jest.clearAllMocks();
});
describe('createSource()', () => {
it('creates new source with name and version', () => {
measurementService.createSource('Testing', '1');
});
it('logs warning and return early if no name provided', () => {
measurementService.createSource(null, '1');
expect(log.warn.mock.calls.length).toBe(1);
});
it('logs warning and return early if no version provided', () => {
measurementService.createSource('Testing', null);
expect(log.warn.mock.calls.length).toBe(1);
});
});
describe('addMapping()', () => {
it('adds new mapping', () => {
measurementService.addMapping(
source,
definition,
matchingCriteria,
toAnnotation,
toMeasurement
);
});
it('logs warning and return early if no matching criteria provided', () => {
measurementService.addMapping(
source,
definition,
null,
toAnnotation,
toMeasurement
);
expect(log.warn.mock.calls.length).toBe(1);
});
it('logs warning and return early if invalid source provided', () => {
const invalidSoure = {};
measurementService.addMapping(
invalidSoure,
definition,
matchingCriteria,
toAnnotation,
toMeasurement
);
expect(log.warn.mock.calls.length).toBe(1);
});
it('logs warning and return early if no source provided', () => {
measurementService.addMapping(
null /* source */,
definition,
matchingCriteria,
toAnnotation,
toMeasurement
);
expect(log.warn.mock.calls.length).toBe(1);
});
it('logs warning and return early if no definition provided', () => {
measurementService.addMapping(
source,
null /* definition */,
matchingCriteria,
toAnnotation,
toMeasurement
);
expect(log.warn.mock.calls.length).toBe(1);
});
it('logs warning and return early if no measurement mapping function provided', () => {
measurementService.addMapping(
source,
definition,
matchingCriteria,
null /* toAnnotation */,
toMeasurement
);
expect(log.warn.mock.calls.length).toBe(1);
});
it('logs warning and return early if no annotation mapping function provided', () => {
measurementService.addMapping(
source,
definition,
matchingCriteria,
toAnnotation,
null /* toMeasurement */
);
expect(log.warn.mock.calls.length).toBe(1);
});
});
describe('getAnnotation()', () => {
it('get annotation based on matched criteria', () => {
measurementService.addMapping(
source,
definition,
matchingCriteria,
toAnnotation,
toMeasurement
);
const measurementId = source.addOrUpdate(definition, annotation);
const mappedAnnotation = source.getAnnotation(definition, measurementId);
expect(annotation).toBe(mappedAnnotation);
});
it('get annotation based on source and definition', () => {
measurementService.addMapping(
source,
definition,
{},
toAnnotation,
toMeasurement
);
const measurementId = source.addOrUpdate(definition, annotation);
const mappedAnnotation = source.getAnnotation(definition, measurementId);
expect(annotation).toBe(mappedAnnotation);
});
});
describe('getMeasurements()', () => {
it('return all measurement service measurements', () => {
const anotherMeasurement = {
...measurement,
label: 'Label2',
unit: 'HU',
};
measurementService.addMapping(
source,
definition,
matchingCriteria,
toAnnotation,
toMeasurement
);
source.addOrUpdate(definition, measurement);
source.addOrUpdate(definition, anotherMeasurement);
const measurements = measurementService.getMeasurements();
expect(measurements.length).toEqual(2);
expect(measurements.length).toEqual(2);
});
});
describe('getMeasurement()', () => {
it('return measurement service measurement with given id', () => {
measurementService.addMapping(
source,
definition,
matchingCriteria,
toAnnotation,
toMeasurement
);
const id = source.addOrUpdate(definition, measurement);
const returnedMeasurement = measurementService.getMeasurement(id);
/* Clear dynamic data */
delete returnedMeasurement.modifiedTimestamp;
expect({ id, ...measurement }).toEqual(returnedMeasurement);
});
});
describe('addOrUpdate()', () => {
it('adds new measurements', () => {
measurementService.addMapping(
source,
definition,
matchingCriteria,
toAnnotation,
toMeasurement
);
source.addOrUpdate(definition, measurement);
source.addOrUpdate(definition, measurement);
const measurements = measurementService.getMeasurements();
expect(measurements.length).toBe(2);
});
it('fails to add new measurements when no mapping', () => {
source.addOrUpdate(definition, measurement);
expect(log.warn.mock.calls.length).toBe(1);
});
it('fails to add new measurements when invalid mapping function', () => {
measurementService.addMapping(
source,
definition,
matchingCriteria,
toAnnotation,
1 /* Invalid */
);
source.addOrUpdate(definition, measurement);
expect(log.error.mock.calls.length).toBe(1);
});
it('adds new measurement with custom id', () => {
const newMeasurement = { id: 1, ...measurement };
measurementService.addMapping(
source,
definition,
matchingCriteria,
toAnnotation,
toMeasurement
);
/* Add new measurement */
source.addOrUpdate(definition, newMeasurement);
const savedMeasurement = measurementService.getMeasurement(newMeasurement.id);
/* Clear dynamic data */
delete newMeasurement.modifiedTimestamp;
delete savedMeasurement.modifiedTimestamp;
expect(newMeasurement).toEqual(savedMeasurement);
});
it('logs warning and return if adding invalid measurement', () => {
measurement.invalidProperty = {};
measurementService.addMapping(
source,
definition,
matchingCriteria,
toAnnotation,
toMeasurement
);
source.addOrUpdate(definition, measurement);
expect(log.warn.mock.calls.length).toBe(2);
});
it('updates existent measurement', () => {
measurementService.addMapping(
source,
definition,
matchingCriteria,
toAnnotation,
toMeasurement
);
const id = source.addOrUpdate(definition, measurement);
measurement.unit = 'HU';
source.addOrUpdate(definition, { id, ...measurement });
const updatedMeasurement = measurementService.getMeasurement(id);
expect(updatedMeasurement.unit).toBe('HU');
});
});
describe('subscribe()', () => {
it('subscribers receive broadcasted add event', () => {
measurementService.addMapping(
source,
definition,
matchingCriteria,
toAnnotation,
toMeasurement
);
const { MEASUREMENT_ADDED } = measurementService.EVENTS;
let addCallbackWasCalled = false;
/* Subscribe to add event */
measurementService.subscribe(
MEASUREMENT_ADDED,
() => (addCallbackWasCalled = true)
);
/* Add new measurement */
source.addOrUpdate(definition, measurement);
expect(addCallbackWasCalled).toBe(true);
});
it('subscribers receive broadcasted update event', () => {
measurementService.addMapping(
source,
definition,
matchingCriteria,
toAnnotation,
toMeasurement
);
const { MEASUREMENT_UPDATED } = measurementService.EVENTS;
let updateCallbackWasCalled = false;
/* Subscribe to update event */
measurementService.subscribe(
MEASUREMENT_UPDATED,
() => (updateCallbackWasCalled = true)
);
/* Create measurement */
const id = source.addOrUpdate(definition, measurement);
/* Update measurement */
source.addOrUpdate(definition, { id, ...measurement });
expect(updateCallbackWasCalled).toBe(true);
});
it('unsubscribes a listener', () => {
measurementService.addMapping(
source,
definition,
matchingCriteria,
toAnnotation,
toMeasurement
);
let updateCallbackWasCalled = false;
const { MEASUREMENT_ADDED } = measurementService.EVENTS;
/* Subscribe to Add event */
const { unsubscribe } = measurementService.subscribe(
MEASUREMENT_ADDED,
() => (updateCallbackWasCalled = true)
);
/* Unsubscribe */
unsubscribe();
/* Create measurement */
source.addOrUpdate(definition, measurement);
expect(updateCallbackWasCalled).toBe(false);
});
});
});
@@ -0,0 +1,8 @@
import MeasurementService from './MeasurementService';
export default {
name: 'MeasurementService',
create: ({ configuration = {} }) => {
return new MeasurementService();
},
};
@@ -33,7 +33,9 @@ export default class ServicesManager {
}
if (service.create) {
this.services[service.name] = service.create({ configuration });
this.services[service.name] = service.create({
configuration,
});
} else {
log.warn(`Service create factory function not defined. Exiting early.`);
return;
@@ -33,7 +33,7 @@ describe('ServicesManager.js', () => {
[{ name: 'UIModalTestService', create: jest.fn() }, fakeConfiguration],
]);
expect(servicesManager.registerService.mock.calls[1]).toContain(
expect(servicesManager.registerService.mock.calls[1][1]).toEqual(
fakeConfiguration
);
});
@@ -85,7 +85,7 @@ describe('ServicesManager.js', () => {
expect(log.warn.mock.calls.length).toBe(1);
});
it('pass configuration to service create factory function', () => {
it('pass dependencies and configuration to service create factory function', () => {
const configuration = { config: 'Some configuration' };
servicesManager.registerService(fakeService, configuration);
+2
View File
@@ -2,10 +2,12 @@ import ServicesManager from './ServicesManager.js';
import UINotificationService from './UINotificationService';
import UIModalService from './UIModalService';
import UIDialogService from './UIDialogService';
import MeasurementService from './MeasurementService';
export {
UINotificationService,
UIModalService,
UIDialogService,
ServicesManager,
MeasurementService,
};
@@ -40,19 +40,21 @@ export default class RetrieveMetadataLoader {
if (result && result.length) {
break; // closes iterator in case data is retrieved successfully
}
} catch (e) { }
} catch (e) {
throw e;
}
}
if (loaders.next().done && !result) {
throw 'cant find data';
throw new Error('RetrieveMetadataLoader failed');
}
return result;
}
// Methods to be overwrite
async configLoad() { }
async preLoad() { }
async load(preLoadData) { }
async posLoad(loadData) { }
async configLoad() {}
async preLoad() {}
async load(preLoadData) {}
async posLoad(loadData) {}
}
@@ -1,3 +1,4 @@
import { api } from 'dicomweb-client';
import DICOMWeb from '../../../DICOMWeb';
const WADOProxy = {
@@ -21,6 +22,43 @@ function parseFloatArray(obj) {
return result;
}
/**
* Simple cache schema for retrieved color palettes.
*/
const paletteColorCache = {
count: 0,
maxAge: 24 * 60 * 60 * 1000, // 24h cache?
entries: {},
isValidUID: function(paletteUID) {
return typeof paletteUID === 'string' && paletteUID.length > 0;
},
get: function(paletteUID) {
let entry = null;
if (this.entries.hasOwnProperty(paletteUID)) {
entry = this.entries[paletteUID];
// check how the entry is...
if (Date.now() - entry.time > this.maxAge) {
// entry is too old... remove entry.
delete this.entries[paletteUID];
this.count--;
entry = null;
}
}
return entry;
},
add: function(entry) {
if (this.isValidUID(entry.uid)) {
let paletteUID = entry.uid;
if (this.entries.hasOwnProperty(paletteUID) !== true) {
this.count++; // increment cache entry count...
}
entry.time = Date.now();
this.entries[paletteUID] = entry;
// @TODO: Add logic to get rid of old entries and reduce memory usage...
}
},
};
/**
* Create a plain JS object that describes a study (a study descriptor object)
* @param {Object} server Object with server configuration parameters
@@ -299,40 +337,41 @@ async function makeSOPInstance(server, study, instance) {
return sopInstance;
}
/**
* Convert String to ArrayBuffer
*
* @param {String} str Input String
* @return {ArrayBuffer} Output converted ArrayBuffer
*/
function str2ab(str) {
const strLen = str.length;
const bytes = new Uint8Array(strLen);
for (let i = 0; i < strLen; i++) {
bytes[i] = str.charCodeAt(i);
}
return bytes.buffer;
}
function getPaletteColor(server, instance, tag, lutDescriptor) {
const numLutEntries = lutDescriptor[0];
const bits = lutDescriptor[2];
let uri = WADOProxy.convertURL(instance[tag].BulkDataURI, server);
// TODO: Workaround for dcm4chee behind SSL-terminating proxy returning
// incorrect bulk data URIs
if (server.wadoRoot.indexOf('https') === 0 && !uri.includes('https')) {
uri = uri.replace('http', 'https');
}
const config = {
url: server.wadoRoot, //BulkDataURI is absolute, so this isn't used
headers: DICOMWeb.getAuthorizationHeader(server),
};
const dicomWeb = new api.DICOMwebClient(config);
const options = {
BulkDataURI: uri,
};
const readUInt16 = (byteArray, position) => {
return byteArray[position] + byteArray[position + 1] * 256;
};
const arrayBufferToPaletteColorLUT = result => {
const arraybuffer = result[0];
const arrayBufferToPaletteColorLUT = arraybuffer => {
const byteArray = new Uint8Array(arraybuffer);
const lut = [];
for (let i = 0; i < numLutEntries; i++) {
if (bits === 16) {
if (bits === 16) {
for (let i = 0; i < numLutEntries; i++) {
lut[i] = readUInt16(byteArray, i * 2);
} else {
}
} else {
for (let i = 0; i < numLutEntries; i++) {
lut[i] = byteArray[i];
}
}
@@ -340,7 +379,38 @@ function getPaletteColor(server, instance, tag, lutDescriptor) {
return lut;
};
return dicomWeb.retrieveBulkData(options).then(arrayBufferToPaletteColorLUT);
if (instance[tag].BulkDataURI) {
let uri = WADOProxy.convertURL(instance[tag].BulkDataURI, server);
// TODO: Workaround for dcm4chee behind SSL-terminating proxy returning
// incorrect bulk data URIs
if (server.wadoRoot.indexOf('https') === 0 && !uri.includes('https')) {
uri = uri.replace('http', 'https');
}
const config = {
url: server.wadoRoot, //BulkDataURI is absolute, so this isn't used
headers: DICOMWeb.getAuthorizationHeader(server),
};
const dicomWeb = new api.DICOMwebClient(config);
const options = {
BulkDataURI: uri,
};
return dicomWeb
.retrieveBulkData(options)
.then(result => result[0])
.then(arrayBufferToPaletteColorLUT);
} else if (instance[tag].InlineBinary) {
const inlineBinaryData = atob(instance[tag].InlineBinary);
const arraybuf = str2ab(inlineBinaryData);
return arrayBufferToPaletteColorLUT(arraybuf);
}
throw new Error(
'Palette Color LUT was not provided as InlineBinary or BulkDataURI'
);
}
/**
-413
View File
@@ -1,413 +0,0 @@
import DICOMWeb from '../DICOMWeb/';
const WADOProxy = {
convertURL: (url, server) => {
// TODO: Remove all WADOProxy stuff from this file
return url;
},
};
function parseFloatArray(obj) {
const result = [];
if (!obj) {
return result;
}
const objs = obj.split('\\');
for (let i = 0; i < objs.length; i++) {
result.push(parseFloat(objs[i]));
}
return result;
}
/**
* Create a plain JS object that describes a study (a study descriptor object)
* @param {Object} server Object with server configuration parameters
* @param {Object} aSopInstance a SOP Instance from which study information will be added
*/
function createStudy(server, aSopInstance) {
// TODO: Pass a reference ID to the server instead of including the URLs here
return {
seriesList: [],
seriesMap: Object.create(null),
seriesLoader: null,
wadoUriRoot: server.wadoUriRoot,
wadoRoot: server.wadoRoot,
qidoRoot: server.qidoRoot,
patientName: DICOMWeb.getName(aSopInstance['00100010']),
patientId: DICOMWeb.getString(aSopInstance['00100020']),
patientAge: DICOMWeb.getNumber(aSopInstance['00101010']),
patientSize: DICOMWeb.getNumber(aSopInstance['00101020']),
patientWeight: DICOMWeb.getNumber(aSopInstance['00101030']),
accessionNumber: DICOMWeb.getString(aSopInstance['00080050']),
studyDate: DICOMWeb.getString(aSopInstance['00080020']),
modalities: DICOMWeb.getString(aSopInstance['00080061']),
studyDescription: DICOMWeb.getString(aSopInstance['00081030']),
imageCount: DICOMWeb.getString(aSopInstance['00201208']),
studyInstanceUid: DICOMWeb.getString(aSopInstance['0020000D']),
institutionName: DICOMWeb.getString(aSopInstance['00080080']),
};
}
/** Returns a WADO url for an instance
*
* @param studyInstanceUid
* @param seriesInstanceUid
* @param sopInstanceUid
* @returns {string}
*/
function buildInstanceWadoUrl(
server,
studyInstanceUid,
seriesInstanceUid,
sopInstanceUid
) {
// TODO: This can be removed, since DICOMWebClient has the same function. Not urgent, though
const params = [];
params.push('requestType=WADO');
params.push(`studyUID=${studyInstanceUid}`);
params.push(`seriesUID=${seriesInstanceUid}`);
params.push(`objectUID=${sopInstanceUid}`);
params.push('contentType=application/dicom');
params.push('transferSyntax=*');
const paramString = params.join('&');
return `${server.wadoUriRoot}?${paramString}`;
}
function buildInstanceWadoRsUri(
server,
studyInstanceUid,
seriesInstanceUid,
sopInstanceUid
) {
return `${server.wadoRoot}/studies/${studyInstanceUid}/series/${seriesInstanceUid}/instances/${sopInstanceUid}`;
}
function buildInstanceFrameWadoRsUri(
server,
studyInstanceUid,
seriesInstanceUid,
sopInstanceUid,
frame
) {
const baseWadoRsUri = buildInstanceWadoRsUri(
server,
studyInstanceUid,
seriesInstanceUid,
sopInstanceUid
);
frame = frame != null || 1;
return `${baseWadoRsUri}/frames/${frame}`;
}
function getFrameIncrementPointer(element) {
const frameIncrementPointerNames = {
'00181065': 'frameTimeVector',
'00181063': 'frameTime',
};
if (!element || !element.Value || !element.Value.length) {
return;
}
const value = element.Value[0];
return frameIncrementPointerNames[value];
}
function getRadiopharmaceuticalInfo(instance) {
const modality = DICOMWeb.getString(instance['00080060']);
if (modality !== 'PT') {
return;
}
const radiopharmaceuticalInfo = instance['00540016'];
if (
radiopharmaceuticalInfo === undefined ||
!radiopharmaceuticalInfo.Value ||
!radiopharmaceuticalInfo.Value.length
) {
return;
}
const firstPetRadiopharmaceuticalInfo = radiopharmaceuticalInfo.Value[0];
return {
radiopharmaceuticalStartTime: DICOMWeb.getString(
firstPetRadiopharmaceuticalInfo['00181072']
),
radionuclideTotalDose: DICOMWeb.getNumber(
firstPetRadiopharmaceuticalInfo['00181074']
),
radionuclideHalfLife: DICOMWeb.getNumber(
firstPetRadiopharmaceuticalInfo['00181075']
),
};
}
/**
* Parses the SourceImageSequence, if it exists, in order
* to return a ReferenceSOPInstanceUID. The ReferenceSOPInstanceUID
* is used to refer to this image in any accompanying DICOM-SR documents.
*
* @param instance
* @returns {String} The ReferenceSOPInstanceUID
*/
function getSourceImageInstanceUid(instance) {
// TODO= Parse the whole Source Image Sequence
// This is a really poor workaround for now.
// Later we should probably parse the whole sequence.
var SourceImageSequence = instance['00082112'];
if (
SourceImageSequence &&
SourceImageSequence.Value &&
SourceImageSequence.Value.length &&
SourceImageSequence.Value[0]['00081155'].Value
) {
return SourceImageSequence.Value[0]['00081155'].Value[0];
}
}
async function makeSOPInstance(server, study, instance) {
const { studyInstanceUid } = study;
const seriesInstanceUid = DICOMWeb.getString(instance['0020000E']);
let series = study.seriesMap[seriesInstanceUid];
if (!series) {
series = {
seriesInstanceUid,
seriesDescription: DICOMWeb.getString(instance['0008103E']),
modality: DICOMWeb.getString(instance['00080060']),
seriesNumber: DICOMWeb.getNumber(instance['00200011']),
seriesDate: DICOMWeb.getString(instance['00080021']),
seriesTime: DICOMWeb.getString(instance['00080031']),
instances: [],
};
study.seriesMap[seriesInstanceUid] = series;
study.seriesList.push(series);
}
const sopInstanceUid = DICOMWeb.getString(instance['00080018']);
const wadouri = buildInstanceWadoUrl(
server,
studyInstanceUid,
seriesInstanceUid,
sopInstanceUid
);
const baseWadoRsUri = buildInstanceWadoRsUri(
server,
studyInstanceUid,
seriesInstanceUid,
sopInstanceUid
);
const wadorsuri = buildInstanceFrameWadoRsUri(
server,
studyInstanceUid,
seriesInstanceUid,
sopInstanceUid
);
const sopInstance = {
imageType: DICOMWeb.getString(instance['00080008']),
sopClassUid: DICOMWeb.getString(instance['00080016']),
modality: DICOMWeb.getString(instance['00080060']),
sopInstanceUid,
instanceNumber: DICOMWeb.getNumber(instance['00200013']),
imagePositionPatient: DICOMWeb.getString(instance['00200032']),
imageOrientationPatient: DICOMWeb.getString(instance['00200037']),
frameOfReferenceUID: DICOMWeb.getString(instance['00200052']),
sliceLocation: DICOMWeb.getNumber(instance['00201041']),
samplesPerPixel: DICOMWeb.getNumber(instance['00280002']),
photometricInterpretation: DICOMWeb.getString(instance['00280004']),
planarConfiguration: DICOMWeb.getNumber(instance['00280006']),
rows: DICOMWeb.getNumber(instance['00280010']),
columns: DICOMWeb.getNumber(instance['00280011']),
pixelSpacing: DICOMWeb.getString(instance['00280030']),
pixelAspectRatio: DICOMWeb.getString(instance['00280034']),
bitsAllocated: DICOMWeb.getNumber(instance['00280100']),
bitsStored: DICOMWeb.getNumber(instance['00280101']),
highBit: DICOMWeb.getNumber(instance['00280102']),
pixelRepresentation: DICOMWeb.getNumber(instance['00280103']),
smallestPixelValue: DICOMWeb.getNumber(instance['00280106']),
largestPixelValue: DICOMWeb.getNumber(instance['00280107']),
windowCenter: DICOMWeb.getString(instance['00281050']),
windowWidth: DICOMWeb.getString(instance['00281051']),
rescaleIntercept: DICOMWeb.getNumber(instance['00281052']),
rescaleSlope: DICOMWeb.getNumber(instance['00281053']),
rescaleType: DICOMWeb.getNumber(instance['00281054']),
sourceImageInstanceUid: getSourceImageInstanceUid(instance),
laterality: DICOMWeb.getString(instance['00200062']),
viewPosition: DICOMWeb.getString(instance['00185101']),
acquisitionDateTime: DICOMWeb.getString(instance['0008002A']),
numberOfFrames: DICOMWeb.getNumber(instance['00280008']),
frameIncrementPointer: getFrameIncrementPointer(instance['00280009']),
frameTime: DICOMWeb.getNumber(instance['00181063']),
frameTimeVector: parseFloatArray(DICOMWeb.getString(instance['00181065'])),
sliceThickness: DICOMWeb.getNumber(instance['00180050']),
spacingBetweenSlices: DICOMWeb.getString(instance['00180088']),
lossyImageCompression: DICOMWeb.getString(instance['00282110']),
derivationDescription: DICOMWeb.getString(instance['00282111']),
lossyImageCompressionRatio: DICOMWeb.getString(instance['00282112']),
lossyImageCompressionMethod: DICOMWeb.getString(instance['00282114']),
echoNumber: DICOMWeb.getString(instance['00180086']),
contrastBolusAgent: DICOMWeb.getString(instance['00180010']),
radiopharmaceuticalInfo: getRadiopharmaceuticalInfo(instance),
baseWadoRsUri: baseWadoRsUri,
wadouri: WADOProxy.convertURL(wadouri, server),
wadorsuri: WADOProxy.convertURL(wadorsuri, server),
wadoRoot: server.wadoRoot,
imageRendering: server.imageRendering,
thumbnailRendering: server.thumbnailRendering,
};
// Get additional information if the instance uses "PALETTE COLOR" photometric interpretation
if (sopInstance.photometricInterpretation === 'PALETTE COLOR') {
const redPaletteColorLookupTableDescriptor = parseFloatArray(
DICOMWeb.getString(instance['00281101'])
);
const greenPaletteColorLookupTableDescriptor = parseFloatArray(
DICOMWeb.getString(instance['00281102'])
);
const bluePaletteColorLookupTableDescriptor = parseFloatArray(
DICOMWeb.getString(instance['00281103'])
);
const palettes = await getPaletteColors(
server,
instance,
redPaletteColorLookupTableDescriptor
);
if (palettes) {
if (palettes.uid) {
sopInstance.paletteColorLookupTableUID = palettes.uid;
}
sopInstance.redPaletteColorLookupTableData = palettes.red;
sopInstance.greenPaletteColorLookupTableData = palettes.green;
sopInstance.bluePaletteColorLookupTableData = palettes.blue;
sopInstance.redPaletteColorLookupTableDescriptor = redPaletteColorLookupTableDescriptor;
sopInstance.greenPaletteColorLookupTableDescriptor = greenPaletteColorLookupTableDescriptor;
sopInstance.bluePaletteColorLookupTableDescriptor = bluePaletteColorLookupTableDescriptor;
}
}
series.instances.push(sopInstance);
return sopInstance;
}
function getPaletteColor(server, instance, tag, lutDescriptor) {
const numLutEntries = lutDescriptor[0];
const bits = lutDescriptor[2];
let uri = WADOProxy.convertURL(instance[tag].BulkDataURI, server);
// TODO: Workaround for dcm4chee behind SSL-terminating proxy returning
// incorrect bulk data URIs
if (server.wadoRoot.indexOf('https') === 0 && !uri.includes('https')) {
uri = uri.replace('http', 'https');
}
const config = {
url: server.wadoRoot, //BulkDataURI is absolute, so this isn't used
headers: DICOMWeb.getAuthorizationHeader(server),
};
const dicomWeb = new api.DICOMwebClient(config);
const options = {
BulkDataURI: uri,
};
const readUInt16 = (byteArray, position) => {
return byteArray[position] + byteArray[position + 1] * 256;
};
const arrayBufferToPaletteColorLUT = result => {
const arraybuffer = result[0];
const byteArray = new Uint8Array(arraybuffer);
const lut = [];
for (let i = 0; i < numLutEntries; i++) {
if (bits === 16) {
lut[i] = readUInt16(byteArray, i * 2);
} else {
lut[i] = byteArray[i];
}
}
return lut;
};
return dicomWeb.retrieveBulkData(options).then(arrayBufferToPaletteColorLUT);
}
/**
* Fetch palette colors for instances with "PALETTE COLOR" photometricInterpretation.
*
* @param server {Object} Current server;
* @param instance {Object} The retrieved instance metadata;
* @returns {String} The ReferenceSOPInstanceUID
*/
async function getPaletteColors(server, instance, lutDescriptor) {
let paletteUID = DICOMWeb.getString(instance['00281199']);
return new Promise((resolve, reject) => {
let entry;
if (paletteColorCache.isValidUID(paletteUID)) {
entry = paletteColorCache.get(paletteUID);
if (entry) {
return resolve(entry);
}
}
// no entry in cache... Fetch remote data.
const r = getPaletteColor(server, instance, '00281201', lutDescriptor);
const g = getPaletteColor(server, instance, '00281202', lutDescriptor);
const b = getPaletteColor(server, instance, '00281203', lutDescriptor);
const promises = [r, g, b];
Promise.all(promises).then(args => {
entry = {
red: args[0],
green: args[1],
blue: args[2],
};
// when paletteUID is present, the entry can be cached...
entry.uid = paletteUID;
paletteColorCache.add(entry);
resolve(entry);
});
});
}
/**
* Add a list of SOP Instances to a given study object descriptor
* @param {Object} server Object with server configuration parameters
* @param {Object} study The study descriptor to which the given SOP instances will be added
* @param {Array} sopInstanceList A list of SOP instance objects
*/
async function addInstancesToStudy(server, study, sopInstanceList) {
return Promise.all(
sopInstanceList.map(function(sopInstance) {
return makeSOPInstance(server, study, sopInstance);
})
);
}
const createStudyFromSOPInstanceList = async (server, sopInstanceList) => {
if (Array.isArray(sopInstanceList) && sopInstanceList.length > 0) {
const firstSopInstance = sopInstanceList[0];
const study = createStudy(server, firstSopInstance);
await addInstancesToStudy(server, study, sopInstanceList);
return study;
}
throw new Error('Failed to create study out of provided SOP instance list');
};
export { createStudyFromSOPInstanceList, addInstancesToStudy };
@@ -67,6 +67,12 @@ function processSingleframe(instances) {
if (instances.length > 2) {
const firstIpp = _getImagePositionPatient(firstImage);
const lastIpp = _getImagePositionPatient(instances[instances.length - 1]);
// We can't reconstruct if we are missing imagePositionPatient values
if (!firstIpp || !lastIpp) {
return { value: false };
}
const averageSpacingBetweenFrames =
_getPerpendicularDistance(firstIpp, lastIpp) / (instances.length - 1);
@@ -136,8 +142,13 @@ function _getSpacingIssue(spacing, averageSpacing) {
}
function _getImagePositionPatient(instance) {
return instance
.getTagValue('x00200032')
const tagValue = instance
.getTagValue('x00200032');
if (!tagValue) {
return;
}
return tagValue
.split('\\')
.map(element => Number(element));
}
+19
View File
@@ -3,6 +3,25 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [0.52.5](https://github.com/OHIF/Viewers/compare/@ohif/i18n@0.52.4...@ohif/i18n@0.52.5) (2020-01-30)
### Bug Fixes
* download tool fixes & improvements ([#1235](https://github.com/OHIF/Viewers/issues/1235)) ([b9574b6](https://github.com/OHIF/Viewers/commit/b9574b6efcfeb85cde35b5cae63282f8e1b35be6))
## [0.52.4](https://github.com/OHIF/Viewers/compare/@ohif/i18n@0.52.3...@ohif/i18n@0.52.4) (2019-12-16)
**Note:** Version bump only for package @ohif/i18n
## [0.52.3](https://github.com/OHIF/Viewers/compare/@ohif/i18n@0.52.2...@ohif/i18n@0.52.3) (2019-12-12)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ohif/i18n",
"version": "0.52.3",
"version": "0.52.5",
"description": "Internationalization library for The OHIF Viewer",
"author": "OHIF",
"license": "MIT",
+9 -8
View File
@@ -29,19 +29,20 @@ function addLocales(newLocales) {
customDebug(resourceBundle, 'info');
}
// Note: Developers can add the API key to use the in-context editor.
// DO NOT commit the API key
const config = (window.config && window.config.i18n) || {};
/*
* Note: Developers can add the API key to use the
* in-context editor using environment variables.
* (DO NOT commit the API key)
*/
const locizeOptions = {
projectId: config.LOCIZE_PROJECTID,
apiKey: config.LOCIZE_API_KEY,
projectId: process.env.LOCIZE_PROJECTID,
apiKey: process.env.LOCIZE_API_KEY,
referenceLng: 'en-US',
fallbacklng: 'en-US',
};
const envUseLocize = !!config.USE_LOCIZE;
const envApiKeyAvailable = !!config.LOCIZE_API_KEY;
const envUseLocize = !!process.env.USE_LOCIZE;
const envApiKeyAvailable = !!process.env.LOCIZE_API_KEY;
const DEFAULT_LANGUAGE = 'en-US';
function initI18n(
@@ -0,0 +1,14 @@
{
"emptyFilenameError": "The file name cannot be empty.",
"fileType": "File Type",
"filename": "File Name",
"formTitle": "Please specify the dimensions, filename, and desired type for the output image.",
"imageHeight": "Image height (px)",
"imagePreview": "Image Preview",
"imageWidth": "Image width (px)",
"keepAspectRatio": "Keep aspect ratio",
"loadingPreview": "Loading Image Preview...",
"minHeightError": "The minimum valid height is 100px.",
"minWidthError": "The minimum valid width is 100px.",
"showAnnotations": "Show Annotations"
}
+2
View File
@@ -7,6 +7,7 @@ import Header from './Header.json';
import MeasurementTable from './MeasurementTable.json';
import StudyList from './StudyList.json';
import UserPreferencesModal from './UserPreferencesModal.json';
import ViewportDownloadForm from './ViewportDownloadForm.json';
export default {
'en-US': {
@@ -19,5 +20,6 @@ export default {
MeasurementTable,
StudyList,
UserPreferencesModal,
ViewportDownloadForm,
},
};
+60
View File
@@ -3,6 +3,66 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.2.0](https://github.com/OHIF/Viewers/compare/@ohif/ui@1.1.9...@ohif/ui@1.2.0) (2020-02-10)
### Features
* Lesion tracker right panel ([#1428](https://github.com/OHIF/Viewers/issues/1428)) ([98a649b](https://github.com/OHIF/Viewers/commit/98a649b455ffc712938fc5035cdef40695e58440))
## [1.1.9](https://github.com/OHIF/Viewers/compare/@ohif/ui@1.1.8...@ohif/ui@1.1.9) (2020-01-30)
### Bug Fixes
* download tool fixes & improvements ([#1235](https://github.com/OHIF/Viewers/issues/1235)) ([b9574b6](https://github.com/OHIF/Viewers/commit/b9574b6efcfeb85cde35b5cae63282f8e1b35be6))
## [1.1.8](https://github.com/OHIF/Viewers/compare/@ohif/ui@1.1.7...@ohif/ui@1.1.8) (2020-01-08)
### Bug Fixes
* measurements panel css and delete button visibility ([#1352](https://github.com/OHIF/Viewers/issues/1352)) ([7ab0bbb](https://github.com/OHIF/Viewers/commit/7ab0bbb32581dcba16ee16b49b92406e2856ac76))
## [1.1.7](https://github.com/OHIF/Viewers/compare/@ohif/ui@1.1.6...@ohif/ui@1.1.7) (2019-12-20)
**Note:** Version bump only for package @ohif/ui
## [1.1.6](https://github.com/OHIF/Viewers/compare/@ohif/ui@1.1.5...@ohif/ui@1.1.6) (2019-12-20)
**Note:** Version bump only for package @ohif/ui
## [1.1.5](https://github.com/OHIF/Viewers/compare/@ohif/ui@1.1.4...@ohif/ui@1.1.5) (2019-12-19)
### Bug Fixes
* 🐛 Fix drag-n-drop of local files into OHIF ([#1319](https://github.com/OHIF/Viewers/issues/1319)) ([23305ce](https://github.com/OHIF/Viewers/commit/23305cec9c0f514e73a8dd17f984ffc87ad8d131)), closes [#1307](https://github.com/OHIF/Viewers/issues/1307)
## [1.1.4](https://github.com/OHIF/Viewers/compare/@ohif/ui@1.1.3...@ohif/ui@1.1.4) (2019-12-16)
**Note:** Version bump only for package @ohif/ui
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@ohif/ui",
"version": "1.1.4",
"version": "1.2.0",
"description": "A set of React components for Medical Imaging Viewers",
"author": "OHIF Contributors",
"license": "MIT",
@@ -1,9 +1,16 @@
import React, { useEffect, useState, createRef } from 'react';
import React, {
useRef,
useCallback,
useEffect,
useState,
createRef,
} from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import './ViewportDownloadForm.styl';
import { TextInput, Select } from '@ohif/ui';
import { TextInput, Select, Icon } from '@ohif/ui';
import classnames from 'classnames';
const FILE_TYPE_OPTIONS = [
{
@@ -17,6 +24,7 @@ const FILE_TYPE_OPTIONS = [
];
const DEFAULT_FILENAME = 'image';
const REFRESH_VIEWPORT_TIMEOUT = 1000;
const ViewportDownloadForm = ({
activeViewport,
@@ -32,7 +40,7 @@ const ViewportDownloadForm = ({
maximumSize,
canvasClass,
}) => {
const [t] = useTranslation('Buttons');
const [t] = useTranslation('ViewportDownloadForm');
const [filename, setFilename] = useState(DEFAULT_FILENAME);
const [fileType, setFileType] = useState('jpg');
@@ -44,6 +52,12 @@ const ViewportDownloadForm = ({
const [showAnnotations, setShowAnnotations] = useState(true);
const [keepAspect, setKeepAspect] = useState(true);
const [aspectMultiplier, setAspectMultiplier] = useState({
width: 1,
height: 1,
});
const [viewportElement, setViewportElement] = useState();
const [viewportElementDimensions, setViewportElementDimensions] = useState({
width: defaultSize,
@@ -62,41 +76,129 @@ const ViewportDownloadForm = ({
height: defaultSize,
});
// Cornerstone's `enable/disable`
useEffect(() => {
enableViewport(viewportElement);
const [error, setError] = useState({
width: false,
height: false,
filename: false,
});
return () => {
disableViewport(viewportElement);
};
}, [disableViewport, enableViewport, viewportElement]);
const hasError = Object.values(error).includes(true);
useEffect(() => {
const { width, height } = viewportElementDimensions;
const validSize = value => (value >= minimumSize ? value : minimumSize);
const loadAndUpdateViewports = async () => {
await loadImage(activeViewport, viewportElement, width, height);
toggleAnnotations(showAnnotations, viewportElement);
const refreshViewport = useRef(null);
const {
dataUrl,
width: viewportElementWidth,
height: viewportElementHeight,
} = await updateViewportPreview(
viewportElement,
downloadCanvas.ref.current,
fileType
const downloadImage = () => {
downloadBlob(
filename || DEFAULT_FILENAME,
fileType,
viewportElement,
downloadCanvas.ref.current
);
};
/**
* @param {object} event - Input change event
* @param {string} dimension - "height" | "width"
*/
const onDimensionsChange = (event, dimension) => {
const oppositeDimension = dimension === 'height' ? 'width' : 'height';
const sanitizedTargetValue = event.target.value.replace(/\D/, '');
const isEmpty = sanitizedTargetValue === '';
const newDimensions = { ...dimensions };
const updatedDimension = isEmpty
? ''
: Math.min(sanitizedTargetValue, maximumSize);
if (updatedDimension === dimensions[dimension]) {
return;
}
newDimensions[dimension] = updatedDimension;
if (keepAspect && newDimensions[oppositeDimension] !== '') {
newDimensions[oppositeDimension] = Math.round(
newDimensions[dimension] * aspectMultiplier[oppositeDimension]
);
}
setViewportPreview(state => ({
// In current code, keepAspect is always `true`
// And we always start w/ a square width/height
setDimensions(newDimensions);
// Only update if value is non-empty
if (!isEmpty) {
setViewportElementDimensions(newDimensions);
setDownloadCanvas(state => ({
...state,
src: dataUrl,
width: validSize(viewportElementWidth),
height: validSize(viewportElementHeight),
...newDimensions,
}));
}
};
const error_messages = {
width: t('minWidthError'),
height: t('minHeightError'),
filename: t('emptyFilenameError'),
};
const renderErrorHandler = errorType => {
if (!error[errorType]) {
return null;
}
return <div className="input-error">{error_messages[errorType]}</div>;
};
const onKeepAspectToggle = () => {
const { width, height } = dimensions;
const aspectMultiplier = { ...aspectMultiplier };
if (!keepAspect) {
const base = Math.min(width, height);
aspectMultiplier.width = width / base;
aspectMultiplier.height = height / base;
setAspectMultiplier(aspectMultiplier);
}
setKeepAspect(!keepAspect);
};
const validSize = value => (value >= minimumSize ? value : minimumSize);
const loadAndUpdateViewports = useCallback(async () => {
const { width: scaledWidth, height: scaledHeight } = await loadImage(
activeViewport,
viewportElement,
dimensions.width,
dimensions.height
);
toggleAnnotations(showAnnotations, viewportElement);
const scaledDimensions = {
height: validSize(scaledHeight),
width: validSize(scaledWidth),
};
loadAndUpdateViewports();
setViewportElementDimensions(scaledDimensions);
setDownloadCanvas(state => ({
...state,
...scaledDimensions,
}));
const {
dataUrl,
width: viewportElementWidth,
height: viewportElementHeight,
} = await updateViewportPreview(
viewportElement,
downloadCanvas.ref.current,
fileType
);
setViewportPreview(state => ({
...state,
src: dataUrl,
width: validSize(viewportElementWidth),
height: validSize(viewportElementHeight),
}));
}, [
activeViewport,
viewportElement,
@@ -111,76 +213,96 @@ const ViewportDownloadForm = ({
viewportElementDimensions,
]);
/**
* @param {object} event - Input change event
* @param {string} dimension - "height" | "width"
*/
const onDimensionsChange = (event, dimension) => {
const sanitizedTargetValue = event.target.value.replace(/\D/, '');
const isEmpty = sanitizedTargetValue === '';
const updatedDimension = isEmpty
? ''
: Math.min(sanitizedTargetValue, maximumSize);
useEffect(() => {
enableViewport(viewportElement);
if (updatedDimension === dimensions.width) {
return;
return () => {
disableViewport(viewportElement);
};
}, [disableViewport, enableViewport, viewportElement]);
useEffect(() => {
if (refreshViewport.current !== null) {
clearTimeout(refreshViewport.current);
}
// In current code, keepAspect is always `true`
// And we always start w/ a square width/height
setDimensions({
width: updatedDimension,
height: updatedDimension,
});
refreshViewport.current = setTimeout(() => {
refreshViewport.current = null;
loadAndUpdateViewports();
}, REFRESH_VIEWPORT_TIMEOUT);
}, [
activeViewport,
viewportElement,
showAnnotations,
dimensions,
loadImage,
toggleAnnotations,
updateViewportPreview,
fileType,
downloadCanvas.ref,
minimumSize,
maximumSize,
]);
// Only update if value is non-empty
if (!isEmpty) {
setViewportElementDimensions({
height: updatedDimension,
width: updatedDimension,
});
setDownloadCanvas(state => ({
...state,
height: updatedDimension,
width: updatedDimension,
}));
}
};
useEffect(() => {
const { width, height } = dimensions;
const hasError = {
width: width < minimumSize,
height: height < minimumSize,
filename: !filename,
};
const downloadImage = () => {
downloadBlob(
filename || DEFAULT_FILENAME,
fileType,
viewportElement,
downloadCanvas.ref.current
);
};
setError({ ...hasError });
}, [dimensions, filename, minimumSize]);
return (
<div className="ViewportDownloadForm">
<div className="title">
{t(
'Please specify the dimensions, filename, and desired type for the output image.'
)}
</div>
<div className="title">{t('formTitle')}</div>
<div className="file-info-container" data-cy="file-info-container">
<div className="col">
<div className="width">
<TextInput
data-cy="image-width"
value={dimensions.width}
label={t('Image width (px)')}
onChange={evt => onDimensionsChange(evt, 'height')}
/>
<div className="dimension-wrapper">
<div className="dimensions">
<div className="width">
<TextInput
type="number"
min={minimumSize}
max={maximumSize}
value={dimensions.width}
label={t('imageWidth')}
onChange={evt => onDimensionsChange(evt, 'width')}
data-cy="image-width"
/>
{renderErrorHandler('width')}
</div>
<div className="height">
<TextInput
type="number"
min={minimumSize}
max={maximumSize}
value={dimensions.height}
label={t('imageHeight')}
onChange={evt => onDimensionsChange(evt, 'height')}
data-cy="image-height"
/>
{renderErrorHandler('height')}
</div>
</div>
<div className="height">
<TextInput
data-cy="image-height"
value={dimensions.height}
label={t('Image height (px)')}
onChange={evt => onDimensionsChange(evt, 'width')}
/>
<div className="keep-aspect-wrapper">
<button
id="keep-aspect"
className={classnames(
'form-button btn',
keepAspect ? 'active' : ''
)}
data-cy="keep-aspect"
alt={t('keepAspectRatio')}
onClick={onKeepAspectToggle}
>
<Icon
name={keepAspect ? 'link' : 'unlink'}
alt={keepAspect ? 'Dismiss Aspect' : 'Keep Aspect'}
/>
</button>
</div>
</div>
@@ -191,9 +313,10 @@ const ViewportDownloadForm = ({
data-cy="file-name"
value={filename}
onChange={event => setFilename(event.target.value)}
label={t('File name')}
label={t('filename')}
id="file-name"
/>
{renderErrorHandler('filename')}
</div>
<div className="file-type">
<Select
@@ -201,7 +324,7 @@ const ViewportDownloadForm = ({
data-cy="file-type"
onChange={event => setFileType(event.target.value)}
options={FILE_TYPE_OPTIONS}
label={t('File type')}
label={t('fileType')}
/>
</div>
</div>
@@ -217,7 +340,7 @@ const ViewportDownloadForm = ({
checked={showAnnotations}
onChange={event => setShowAnnotations(event.target.checked)}
/>
{t('Show Annotations')}
{t('showAnnotations')}
</label>
</div>
</div>
@@ -245,15 +368,23 @@ const ViewportDownloadForm = ({
></canvas>
</div>
<div className="preview" data-cy="image-preview">
<h4> {t('Image Preview')}</h4>
<img
className="viewport-preview"
src={viewportPreview.src}
alt="Viewport Preview"
data-cy="viewport-preview-img"
/>
</div>
{viewportPreview.src ? (
<div className="preview" data-cy="image-preview">
<div className="preview-header"> {t('imagePreview')}</div>
<img
className="viewport-preview"
src={viewportPreview.src}
alt={t('imagePreview')}
data-cy="image-preview"
data-cy="viewport-preview-img"
/>
</div>
) : (
<div className="loading-image">
<Icon name="circle-notch" className="icon-spin" />
{t('loadingPreview')}
</div>
)}
<div className="actions">
<div className="action-cancel">
@@ -263,16 +394,17 @@ const ViewportDownloadForm = ({
className="btn btn-danger"
onClick={onClose}
>
{t('Cancel')}
{t('Buttons:Cancel')}
</button>
</div>
<div className="action-save">
<button
disabled={hasError}
onClick={downloadImage}
className="btn btn-primary"
data-cy="download-btn"
>
{t('Download')}
{t('Buttons:Download')}
</button>
</div>
</div>
@@ -10,6 +10,9 @@
input, select
max-height: 30px;
#keep-aspect svg
margin-top: 3px;
.title
margin: 0;
font-weight: bold;
@@ -17,85 +20,117 @@
.file-info-container
display: flex;
flex-direction: row;
justify-content: space-around;
margin: 20px 0;
border-radius: 5px;
padding: 20px 10px 0;
background-color: #16202b;
.form-control.input-ohif
padding: 6px 12px;
padding: 20px 10px;
background-color: var(--ui-gray-dark);
@media screen and (max-width: 1023px)
flex-direction: column;
align-items: flex-start;
.col
flex-grow: 1;
.width,
.height,
.file-name,
.file-type
height: 56px;
.input-ohif
margin-left: 15px;
.input-ohif
margin: 0 5px;
.file-name,
.file-type
.select-ohif, .input-ohif
width: 170px;
.input-ohif-label, .select-ohif-label
width: 90px;
display: inline-block;
@media screen and (max-width: 1023px)
margin-left: 0;
margin-top: 5px;
width: 100%;
width: 120px;
.file-type
.select-ohif
margin-left: 17px;
.dimension-wrapper
display: flex;
flex-direction: row;
.dimensions
display: flex;
flex-direction: column;
.input-ohif-label
width: 120px;
display: inline-block;
.input-ohif
@media screen and (max-width: 1023px)
margin-left: 0;
width: 100%;
width: 170px;
.show-annotations
font-weight: bold;
line-height: 30px;
input
margin-right: 7px;
vertical-align: middle;
label
display: flex;
justify-content: center;
align-items: center;
.keep-aspect-wrapper
display: flex;
justify-content: center;
align-items: center;
padding: 0 10px;
height: 86px;
.show-annotations
font-weight: bold;
line-height: 30px;
input
margin-right: 7px;
vertical-align: middle;
label
display: flex;
justify-content: center;
align-items: center;
.loading-image
height: 580px;
display: flex;
justify-content: center;
align-items: center;
color: var(--active-color);
font-size: 20px;
.icon-spin
margin-right: 15px;
.preview
display: flex;
flex-direction column;
height: fit-content;
background-color: #16202b;
width: fit-content;
flex-direction: column;
background-color: var(--ui-gray-dark);
padding: 10px;
border-radius: 5px;
align-self: center;
margin-bottom: 20px;
@media screen and (max-width: 1023px)
width: 100%;
justify-content: center;
align-items: center;
justify-content: flex-start;
align-items: center;
height: 580px;
.viewport-preview
max-height: 512px;
max-width: 512px;
h4
.preview-header
width: 100%;
text-align center;
font-size: 1.3em;
margin: 0 0 10px;
.preview-container
width: auto;
height: 100%;
max-height: 400px;
object-fit contain;
.actions
display: flex;
flex-wrap: nowrap;
justify-content: flex-end;
align-items: center;
.action-cancel
margin: 0 20px;
.actions-save
margin: 0 0 0 10px;
margin-top: 20px;
.btn
margin: 0 10px;
.input-error
font-size: 12px;
color: red;
text-align: center;
margin: 3px 0;
.modal-dialog
height: 100%;
@@ -79,7 +79,11 @@ class MeasurementTable extends Component {
</ScrollableArea>
<div className="measurementTableFooter">
{saveFunction && (
<button onClick={this.saveFunction} className="saveBtn">
<button
onClick={this.saveFunction}
className="saveBtn"
data-cy="save-measurements-btn"
>
<Icon name="save" width="14px" height="14px" />
Save measurements
</button>
@@ -180,10 +184,12 @@ class MeasurementTable extends Component {
};
getTimepointsHeader = () => {
return this.props.timepoints.map((timepoint, index) => {
const { timepoints, t } = this.props;
return timepoints.map((timepoint, index) => {
return (
<div key={index} className="measurementTableHeaderItem">
<div className="timepointLabel">{this.props.t(timepoint.key)}</div>
<div className="timepointLabel">{t(timepoint.key)}</div>
<div className="timepointDate">{timepoint.date}</div>
</div>
);
@@ -84,7 +84,6 @@
position: relative
top: 2px
.tooltip-warning
&.in
opacity 1
@@ -44,8 +44,8 @@ class MeasurementTableItem extends Component {
<div>{this.getTableListItem()}</div>
</OverlayTrigger>
) : (
<React.Fragment>{this.getTableListItem()}</React.Fragment>
)}
<React.Fragment>{this.getTableListItem()}</React.Fragment>
)}
</React.Fragment>
);
}
@@ -101,7 +101,7 @@ class MeasurementTableItem extends Component {
nsSeparator: '|',
})}
</div>
<div>{this.getDataDisplayText()}</div>
<div className="displayTexts">{this.getDataDisplayText()}</div>
<div className="rowActions">{actionButtons}</div>
</div>
</TableListItem>
@@ -1,5 +1,6 @@
.measurementItem
.rowActions
margin-left: -1px;
background-color: var(--ui-gray-darker)
height: 0
overflow: hidden
@@ -28,7 +29,6 @@
visibility: visible
.measurementLocation
margin-left: 9px;
margin-top: 9px;
overflow: hidden;
max-width: calc(100% - 50px);
@@ -39,7 +39,6 @@
.measurementDisplayText
display: inline-block;
margin-top: 9px;
margin-left: 9px;
padding-left: 9px;
width: 90px;
border-left: 1px solid var(--text-secondary-color);
@@ -25,6 +25,20 @@ $headerRowHeight = 63px
text-align: left
flex: 1;
.tableListHeaderSelector
cursor: pointer
max-width: 30px
padding-left: 2px
align-items: stretch
flex: 1
justify-content: space-around
text-align: center
svg
fill: #c1d8e3
height: 63px
max-width: 11px
.numberOfItems
color: var(--ui-sky-blue)
float: right
@@ -10,7 +10,10 @@ export class TableListItem extends Component {
children: PropTypes.node,
itemClass: PropTypes.string,
itemIndex: PropTypes.number,
itemKey: PropTypes.oneOfType(['number', 'string']),
itemKey: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]),
onItemClick: PropTypes.func.isRequired,
};
@@ -28,7 +28,7 @@
transition(all 0.3s ease)
font-size: 14px
font-weight: 400
margin-right: 5px
margin-right: 6px
padding: 10px
text-align: center
-webkit-box-sizing: initial;
@@ -38,9 +38,8 @@
.itemContent
flex: 1
color: var(--text-secondary-color)
width: calc(100% - 30px);
width: calc(100% - 51px);
font-weight: 400
margin-left: 9px;
margin-top: 9px;
.warning-icon
@@ -43,6 +43,7 @@ const renderTabsHeader = (tabs, activeTabIndex, onHeaderChanged) => {
? tabs.map((tab, index) => {
const { name, hidden = false } = tab;
const cypressSelectorId = name.toLowerCase();
const tabClass =
index === activeTabIndex ? 'nav-link active' : 'nav-link';
return !hidden ? (
@@ -52,6 +53,7 @@ const renderTabsHeader = (tabs, activeTabIndex, onHeaderChanged) => {
onHeaderChanged(index);
}}
className={tabClass}
data-cy={cypressSelectorId}
>
<button>{name}</button>
</li>
@@ -152,6 +152,7 @@ const DialogProvider = ({ children, service }) => {
onStart,
onStop,
onDrag,
showOverlay,
} = dialog;
let position =
@@ -160,7 +161,7 @@ const DialogProvider = ({ children, service }) => {
position = centerPositions.find(position => position.id === id);
}
return (
const dragableItem = () => (
<Draggable
key={id}
disabled={!isDraggable}
@@ -217,6 +218,16 @@ const DialogProvider = ({ children, service }) => {
</div>
</Draggable>
);
return (
showOverlay ? (
<div className="Overlay" key={id}>
{dragableItem()}
</div>
) : (
dragableItem()
)
);
});
/**
@@ -238,13 +249,11 @@ const DialogProvider = ({ children, service }) => {
return (
<DialogContext.Provider value={{ create, dismiss, dismissAll, isEmpty }}>
<div className="DraggableArea">
{dialogs.some(dialog => dialog.showOverlay) ? (
<div className="Overlay active">{renderDialogs()}</div>
) : (
renderDialogs()
)}
</div>
{!isEmpty() &&
<div className="DraggableArea">
{renderDialogs()}
</div>
}
{children}
</DialogContext.Provider>
);
@@ -6,17 +6,16 @@
div
cursor: grabbing !important
.DraggableArea, .Overlay
.DraggableArea
width: 100%
height: 100%
position: absolute
.Overlay.active
position: fixed
z-index: 999
left: 0
top: 0
width: 100%
height: 100%
overflow: auto
background: rgba(0,0,0,.1)
.Overlay
position: fixed
z-index: 999
left: 0
top: 0
width: 100%
height: 100%
overflow: auto
background: rgba(0,0,0,.1)
+4
View File
@@ -1,6 +1,10 @@
.icon-pulse
fa-spin 1s infinite steps(8)
.icon-spin {
animation: spin 2s linear infinite;
}
@keyframes fa-spin{
0%{ transform:rotate(0deg) }
to{ transform:rotate(1turn) }
+2
View File
@@ -80,6 +80,7 @@ import thLarge from './icons/th-large.svg';
import thList from './icons/th-list.svg';
import times from './icons/times.svg';
import trash from './icons/trash.svg';
import unlink from './icons/unlink.svg';
import user from './icons/user.svg';
import youtube from './icons/youtube.svg';
@@ -158,6 +159,7 @@ const ICONS = {
rotate,
'rotate-right': rotateRight,
trash,
unlink,
'exclamation-circle': exclamationCircle,
link,
'exclamation-triangle': exclamationTriangle,
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff. Show more