diff --git a/.jshintrc b/.jshintrc
deleted file mode 100644
index 587615625..000000000
--- a/.jshintrc
+++ /dev/null
@@ -1,93 +0,0 @@
-{
- // JSHint Default Configuration File (as on JSHint website)
- // See http://jshint.com/docs/ for more details
-
- "maxerr" : 50, // {int} Maximum error before stopping
-
- // Enforcing
- "bitwise" : true, // true: Prohibit bitwise operators (&, |, ^, etc.)
- "camelcase" : false, // true: Identifiers must be in camelCase
- "curly" : false, // true: Require {} for every new block or scope
- "eqeqeq" : true, // true: Require triple equals (===) for comparison
- "forin" : true, // true: Require filtering for..in loops with obj.hasOwnProperty()
- "freeze" : true, // true: prohibits overwriting prototypes of native objects such as Array, Date etc.
- "immed" : false, // true: Require immediate invocations to be wrapped in parens e.g. `(function () { } ());`
- "latedef" : false, // true: Require variables/functions to be defined before being used
- "newcap" : false, // true: Require capitalization of all constructor functions e.g. `new F()`
- "noarg" : true, // true: Prohibit use of `arguments.caller` and `arguments.callee`
- "noempty" : true, // true: Prohibit use of empty blocks
- "nonbsp" : true, // true: Prohibit "non-breaking whitespace" characters.
- "nonew" : false, // true: Prohibit use of constructors for side-effects (without assignment)
- "plusplus" : false, // true: Prohibit use of `++` and `--`
- "quotmark" : false, // Quotation mark consistency:
- // false : do nothing (default)
- // true : ensure whatever is used is consistent
- // "single" : require single quotes
- // "double" : require double quotes
- "undef" : true, // true: Require all non-global variables to be declared (prevents global leaks)
- "unused" : "vars", // Unused variables:
- // true : all variables, last function parameter
- // "vars" : all variables only
- // "strict" : all variables, all function parameters
- "strict" : false, // true: Requires all functions run in ES5 Strict Mode
- "maxparams" : false, // {int} Max number of formal params allowed per function
- "maxdepth" : false, // {int} Max depth of nested blocks (within functions)
- "maxstatements" : false, // {int} Max number statements per function
- "maxcomplexity" : false, // {int} Max cyclomatic complexity per function
- "maxlen" : false, // {int} Max number of characters per line
- "varstmt" : false, // true: Disallow any var statements. Only `let` and `const` are allowed.
-
- // Relaxing
- "asi" : false, // true: Tolerate Automatic Semicolon Insertion (no semicolons)
- "boss" : false, // true: Tolerate assignments where comparisons would be expected
- "debug" : false, // true: Allow debugger statements e.g. browser breakpoints.
- "eqnull" : false, // true: Tolerate use of `== null`
- "es5" : true, // true: Allow ES5 syntax (ex: getters and setters)
- "esnext" : true, // true: Allow ES.next (ES6) syntax (ex: `const`)
- "moz" : false, // true: Allow Mozilla specific syntax (extends and overrides esnext features)
- // (ex: `for each`, multiple try/catch, function expression…)
- "evil" : false, // true: Tolerate use of `eval` and `new Function()`
- "expr" : false, // true: Tolerate `ExpressionStatement` as Programs
- "funcscope" : false, // true: Tolerate defining variables inside control statements
- "globalstrict" : false, // true: Allow global "use strict" (also enables 'strict')
- "iterator" : false, // true: Tolerate using the `__iterator__` property
- "lastsemic" : false, // true: Tolerate omitting a semicolon for the last statement of a 1-line block
- "laxbreak" : false, // true: Tolerate possibly unsafe line breakings
- "laxcomma" : false, // true: Tolerate comma-first style coding
- "loopfunc" : false, // true: Tolerate functions being defined in loops
- "multistr" : false, // true: Tolerate multi-line strings
- "noyield" : false, // true: Tolerate generator functions with no yield statement in them.
- "notypeof" : false, // true: Tolerate invalid typeof operator values
- "proto" : false, // true: Tolerate using the `__proto__` property
- "scripturl" : false, // true: Tolerate script-targeted URLs
- "shadow" : false, // true: Allows re-define variables later in code e.g. `var x=1; x=2;`
- "sub" : false, // true: Tolerate using `[]` notation when it can still be expressed in dot notation
- "supernew" : false, // true: Tolerate `new function () { ... };` and `new Object;`
- "validthis" : false, // true: Tolerate using this in a non-constructor function
-
- // Environments
- "browser" : true, // Web Browser (window, document, etc)
- "browserify" : false, // Browserify (node.js code in the browser)
- "couch" : false, // CouchDB
- "devel" : false, // Development/debugging (alert, confirm, etc)
- "dojo" : false, // Dojo Toolkit
- "jasmine" : false, // Jasmine
- "jquery" : true, // jQuery
- "mocha" : false, // Mocha
- "mootools" : false, // MooTools
- "node" : false, // Node.js
- "nonstandard" : false, // Widely adopted globals (escape, unescape, etc)
- "phantom" : false, // PhantomJS
- "prototypejs" : false, // Prototype and Scriptaculous
- "qunit" : false, // QUnit
- "rhino" : false, // Rhino
- "shelljs" : false, // ShellJS
- "typed" : false, // Globals for typed array constructions
- "worker" : false, // Web Workers
- "wsh" : false, // Windows Scripting Host
- "yui" : false, // Yahoo User Interface
- "globals" : {
- "require": true,
- "Package": true // Meteor Package definition
- }
-}
diff --git a/README.md b/README.md
index a48fa4fa1..575d327b2 100644
--- a/README.md
+++ b/README.md
@@ -1,295 +1,6 @@
-
-
-
-
ohif-viewer
-
ohif-viewer is a zero-footprint medical image viewer provided by the Open Health Imaging Foundation (OHIF). It is a configurable and extensible progressive web application with out-of-the-box support for image archives which support DICOMweb.
-
-
-
-
-
-
-
-
-
-[![CircleCI][circleci-image]][circleci-url]
-[![codecov][codecov-image]][codecov-url]
-[](#contributors)
-[![code style: prettier][prettier-image]][prettier-url]
-[![semantic-release][semantic-image]][semantic-url]
-
-[![NPM version][npm-version-image]][npm-url]
-[![NPM downloads][npm-downloads-image]][npm-url]
-[![Pulls][docker-pulls-img]][docker-image-url]
-[![MIT License][license-image]][license-url]
-
-
-
-> ATTENTION: If you are looking for Version 1 (the Meteor Version) of this
-> repository, it lives on
-> [the `v1.x` branch](https://github.com/OHIF/Viewers/tree/v1.x)
-
-## Why?
-
-Building a web based medical imaging viewer from scratch is time intensive, hard
-to get right, and expensive. Instead of re-inventing the wheel, you can use the
-OHIF Viewer as a rock solid platform to build on top of. The Viewer is a
-[React][react-url] [Progressive Web Application][pwa-url] that can be embedded
-in existing applications via it's [packaged source
-(ohif-viewer)][ohif-viewer-url] or hosted stand-alone. The Viewer exposes
-[configuration][configuration-url] and [extensions][extensions-url] to support
-workflow customization and advanced functionality at common integration points.
-
-If you're interested in using the OHIF Viewer, but you're not sure it supports
-your use case [check out our docs](https://docs.ohif.org/). Still not sure, or
-you would like to propose new features? Don't hesitate to
-[create an issue](https://github.com/OHIF/Viewers/issues) or open a pull
-request.
-
-## Getting Started
-
-This readme is specific to testing and developing locally. If you're more
-interested in production deployment strategies,
-[you can check out our documentation on publishing](https://docs.ohif.org/).
-
-Want to play around before you dig in?
-[Check out our LIVE Demo](https://viewer.ohif.org/)
-
-### Setup
-
-_Requirements:_
-
-- [NodeJS & NPM](https://nodejs.org/en/download/)
-- [Yarn](https://yarnpkg.com/lang/en/docs/install/)
-
-_Steps:_
-
-1. Fork this repository
-2. Clone your forked repository (your `origin`)
-
-- `git clone git@github.com:YOUR_GITHUB_USERNAME/Viewers.git`
-
-3. Add `OHIF/Viewers` as a `remote` repository (the `upstream`)
-
-- `git remote add upstream git@github.com:OHIF/Viewers.git`
-
-### Developing Locally
-
-In your cloned repository's root folder, run:
+# OHIF Medical Imaging Platform
```js
-// Restore dependencies
-yarn install
-
-// Stands up local server to host Viewer.
-// Viewer connects to our public cloud PACS by default
-yarn start
+// Add shared dev dependency for workspace
+yarn add --dev -W package-name
```
-
-For more advanced local development scenarios, like using your own locally
-hosted PACS and test data,
-[check out our Essential: Getting Started](https://docs.ohif.org/essentials/getting-started.html)
-guide.
-
-### E2E Tests
-
-Using [Cypress](https://www.cypress.io/) to create End-to-End tests and check whether the application flow is performing correctly, ensuring that the integrated components are working as expected.
-
-#### Why Cypress?
-
-Cypress is a next generation front end testing tool built for the modern web.
-With Cypress is easy to set up, write, run and debug tests
-
-It allow us to write different types of tests:
-
-- End-to-End tests
-- Integration tests
-- Unit tets
-
-All tests must be in `./cypress/integration` folder.
-
-Commands to run the tests:
-
-```js
-// Open Cypress Dashboard that provides insight into what happened when your tests ran
-yarn run cy
-
-// Run all tests using Electron browser headless
-yarn run cy:run
-
-// Run all tests in CI mode
-yarn run cy:run:ci
-```
-
-### Contributing
-
-> Large portions of the Viewer's functionality are maintained in other
-> repositories. To get a better understanding of the Viewer's architecture and
-> "where things live", read
-> [our docs on the Viewer's architecture](https://docs.ohif.org/advanced/architecture.html#diagram)
-
-It is notoriously difficult to setup multiple dependent repositories for
-end-to-end testing and development. That's why we recommend writing and running
-unit tests when adding and modifying features. This allows us to program in
-isolation without a complex setup, and has the added benefit of producing
-well-tested business logic.
-
-1. Clone this repository
-2. Navigate to the project directory, and `yarn install`
-3. To begin making changes, `yarn run dev`
-4. To commit changes, run `yarn run cm`
-
-When creating tests, place the test file "next to" the file you're testing.
-[For example](https://github.com/OHIF/Viewers/blob/master/src/utils/index.test.js):
-
-```js
-// File
-index.js
-
-// Test for file
-index.test.js
-```
-
-As you add and modify code, `jest` will watch for uncommitted changes and run
-your tests, reporting the results to your terminal. Make a pull request with
-your changes to `master`, and a core team member will review your work. If you
-have any questions, please don't hesitate to reach out via a GitHub issue.
-
-## Issues
-
-_Looking to contribute? Look for the [Good First Issue][good-first-issue]
-label._
-
-### 🐛 Bugs
-
-Please file an issue for bugs, missing documentation, or unexpected behavior.
-
-[**See Bugs**][bugs]
-
-### 💡 Feature Requests
-
-Please file an issue to suggest new features. Vote on feature requests by adding
-a 👍. This helps maintainers prioritize what to work on.
-
-[**See Feature Requests**][requests-feature]
-
-### ❓ Questions
-
-For questions related to using the library, please visit our support community,
-or file an issue on GitHub.
-
-[Google Group][google-group]
-
-## Roadmap
-
-If you want to know what's planned for the very near future,
-[check out our roadmap](https://ohif.canny.io/). The best way to influence when
-and what is worked on is to contribute to the conversation by creating GitHub
-issues, and contributing code through pull requests. OHIF's high level
-priorities for the near future are:
-
-- Feature parity with version 1
-- Extension and configuration improvements with key integration partners
-- Continued Developer Experience Improvements
-- Segmentation Tools, and improved VTK.js support
-
-More granular information will make it's way to the backlog as these items
-become scoped for development by core maintainers.
-
-> Don't hesitate to ask questions, propose features, or create pull requests.
-> We're here, we're listening, and we're ready to build the best open source
-> medical imaging viewer on the web.
-
-#### Roadmap Generously Powered by Canny.io
-
-
-
-
-
-## Contributors
-
-Thanks goes to these wonderful people
-([emoji key](https://allcontributors.org/docs/en/emoji-key)):
-
-
-
-
-
-
-
-This project follows the
-[all-contributors](https://github.com/all-contributors/all-contributors)
-specification. Contributions of any kind welcome!
-
-## License
-
-MIT © [OHIF](https://github.com/OHIF)
-
-## Acknowledgments
-
-To acknowledge the OHIF Viewer in an academic publication, please cite
-
-> _LesionTracker: Extensible Open-Source Zero-Footprint Web Viewer for Cancer
-> Imaging Research and Clinical Trials_
->
-> Trinity Urban, Erik Ziegler, Rob Lewis, Chris Hafey, Cheryl Sadow, Annick D.
-> Van den Abbeele and Gordon J. Harris
->
-> _Cancer Research_, November 1 2017 (77) (21) e119-e122 DOI:
-> [10.1158/0008-5472.CAN-17-0334](https://www.doi.org/10.1158/0008-5472.CAN-17-0334)
-
-**Note:** If you use or find this repository helpful, please take the time to
-star this repository on Github. This is an easy way for us to assess adoption
-and it can help us obtain future funding for the project.
-
-This work is supported primarily by the National Institutes of Health, National
-Cancer Institute, Informatics Technology for Cancer Research (ITCR) program,
-under a
-[grant to Dr. Gordon Harris at Massachusetts General Hospital (U24 CA199460)](https://projectreporter.nih.gov/project_info_description.cfm?aid=8971104).
-
-
-
-
-
-[all-contributors-image]: https://img.shields.io/badge/all_contributors-0-orange.svg?style=flat-square
-[contributing-url]: https://github.com/OHIF/Viewers/blob/react/CONTRIBUTING.md
-[circleci-image]: https://circleci.com/gh/OHIF/Viewers.svg?style=svg
-[circleci-url]: https://circleci.com/gh/OHIF/Viewers
-[codecov-image]: https://codecov.io/gh/OHIF/Viewers/branch/react/graph/badge.svg
-[codecov-url]: https://codecov.io/gh/OHIF/Viewers/branch/react
-[prettier-image]: https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square
-[prettier-url]: https://github.com/prettier/prettier
-[semantic-image]: https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg
-[semantic-url]: https://github.com/semantic-release/semantic-release
-
-[npm-url]: https://npmjs.org/package/ohif-viewer
-[npm-downloads-image]: https://img.shields.io/npm/dm/ohif-viewer.svg?style=flat-square
-[npm-version-image]: https://img.shields.io/npm/v/ohif-viewer.svg?style=flat-square
-[docker-pulls-img]: https://img.shields.io/docker/pulls/ohif/viewer.svg?style=flat-square
-[docker-image-url]: https://hub.docker.com/r/ohif/viewer
-[license-image]: https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square
-[license-url]: LICENSE
-
-[react-url]: https://reactjs.org/
-[pwa-url]: https://developers.google.com/web/progressive-web-apps/
-[ohif-viewer-url]: https://www.npmjs.com/package/ohif-viewer
-[configuration-url]: https://docs.ohif.org/essentials/configuration.html
-[extensions-url]: https://docs.ohif.org/advanced/extensions.html
-
-[react-viewer]: https://github.com/OHIF/Viewers/tree/react
-
-[bugs]: https://github.com/OHIF/Viewers/labels/bug
-[requests-feature]: https://github.com/OHIF/Viewers/labels/enhancement
-[good-first-issue]: https://github.com/OHIF/Viewers/labels/good%20first%20issue
-[google-group]: https://groups.google.com/forum/#!forum/cornerstone-platform
-
-
diff --git a/babel.config.js b/babel.config.js
new file mode 100644
index 000000000..18717d199
--- /dev/null
+++ b/babel.config.js
@@ -0,0 +1,20 @@
+module.exports = {
+ presets: [
+ [
+ "@babel/preset-env",
+ {
+ targets: {
+ ie: "11"
+ }
+ }
+ ],
+ "@babel/preset-react"
+ ],
+ plugins: [
+ "@babel/plugin-proposal-class-properties",
+ "@babel/plugin-proposal-object-rest-spread",
+ "@babel/plugin-syntax-dynamic-import",
+ "@babel/plugin-transform-regenerator",
+ "@babel/plugin-transform-runtime"
+ ]
+};
diff --git a/docker/OpenResty-Orthanc-Keycloak/volumes/keycloak-themes/ohif/welcome/.githold b/docker/OpenResty-Orthanc-Keycloak/volumes/keycloak-themes/ohif/welcome/.githold
deleted file mode 100644
index e69de29bb..000000000
diff --git a/lerna.json b/lerna.json
new file mode 100644
index 000000000..bcdca0415
--- /dev/null
+++ b/lerna.json
@@ -0,0 +1,6 @@
+{
+ "packages": ["packages/*"],
+ "npmClient": "yarn",
+ "useWorkspaces": true,
+ "version": "independent"
+}
diff --git a/package.json b/package.json
index 45e9f3353..e603df5d6 100644
--- a/package.json
+++ b/package.json
@@ -1,162 +1,13 @@
{
- "name": "ohif-viewer",
- "version": "0.0.21",
- "description": "OHIF Viewer",
- "author": "OHIF Contributors",
- "license": "MIT",
- "repository": "OHIF/Viewers",
- "main": "dist/index.umd.js",
- "browser": "dist/index.umd.js",
- "module": "dist/index.es.js",
- "jsnext:main": "dist/index.es.js",
- "engines": {
- "node": ">=8",
- "npm": ">=5"
- },
- "proxy": "http://localhost:8042",
- "scripts": {
- "cm": "npx git-cz",
- "dev": "yarn run preBuild && cross-env PORT=5000 react-scripts start",
- "dev:debug": "cross-env PORT=5000 react-scripts start",
- "build:package": "yarn run preBuild && node --max-old-space-size=4096 node_modules/rollup/bin/rollup -c",
- "build:package:ci": "yarn run preBuild && node --max-old-space-size=4096 node_modules/rollup/bin/rollup -c",
- "build:web": "yarn run preBuild && react-scripts --max_old_space_size=4096 build",
- "build:web:ci": "yarn run preBuild && cross-env PUBLIC_URL=/demo REACT_APP_CONFIG=config/netlify.js react-scripts --max_old_space_size=4096 build && cpx 'build/**/*' docs/latest/_book/demo --verbose",
- "build:demo:ci": "yarn run preBuild && cross-env PUBLIC_URL=/ REACT_APP_CONFIG=config/demo.js react-scripts --max_old_space_size=4096 build",
- "lint": "eslint -c .eslintrc --fix src && prettier --single-quote --write src/**/*.{js,jsx,json,css}",
- "test:unit": "jest",
- "test:unit:ci": "jest --ci --runInBand --collectCoverage --reporters=default --reporters=jest-junit && codecov",
- "test:e2e": "",
- "test:e2e:ci": "start-server-and-test start http://localhost:5000 cy:run:ci",
- "staticDeploy": "./generateStaticSite.sh",
- "docs:publish": "./publishStaticSite.sh",
- "start": "yarn run dev",
- "predeploy": "cd example && npm install && yarn run build:package",
- "preBuild": "yarn run version && yarn run copy:webworkers",
- "orthanc:up": "docker-compose -f docker/Nginx-Orthanc/docker-compose.yml up",
- "dev:orthanc": "yarn run preBuild && cross-env PORT=5000 REACT_APP_CONFIG=config/docker_nginx-orthanc.js react-scripts start",
- "version": "node -p -e \"'export default \\'' + require('./package.json').version + '\\';'\" > src/version.js",
- "copy:webworkers": "cpx \"node_modules/cornerstone-wado-image-loader/dist/*.min.js*\" \"public\" -v",
- "cy": "cypress open",
- "cy:run": "cypress run",
- "cy:run:ci": "yarn run cy:run -- --record"
- },
- "husky": {
- "hooks": {
- "pre-commit": "lint-staged"
- }
- },
- "lint-staged": {
- "src/**/*.{js,jsx,json,css}": [
- "prettier --single-quote --write",
- "git add"
- ]
- },
- "browserslist": [
- ">0.2%",
- "not dead",
- "not ie <= 11",
- "not op_mini all"
+ "name": "root",
+ "private": true,
+ "workspaces": [
+ "packages/*"
],
- "resolutions": {
- "browserslist": "4.6.2",
- "caniuse-lite": "1.0.30000974"
- },
- "files": [
- "dist"
- ],
- "config": {
- "commitizen": {
- "path": "./node_modules/cz-conventional-changelog"
- }
- },
- "peerDependencies": {
- "react": "^16.8.6",
- "react-dom": "^16.8.6"
- },
- "dependencies": {
- "@babel/runtime": "^7.4.5",
- "@ohif/extension-cornerstone": "0.0.38",
- "@ohif/extension-dicom-html": "0.0.3",
- "@ohif/extension-dicom-microscopy": "0.0.8",
- "@ohif/extension-dicom-pdf": "0.0.7",
- "@ohif/extension-vtk": "0.1.3",
- "@ohif/i18n": "0.2.2",
- "@tanem/react-nprogress": "^1.1.25",
- "classnames": "^2.2.6",
- "cornerstone-core": "^2.2.8",
- "cornerstone-math": "^0.1.8",
- "cornerstone-tools": "^3.15.1",
- "cornerstone-wado-image-loader": "^2.2.3",
- "dcmjs": "^0.4.7",
- "dicom-parser": "^1.8.3",
- "dicomweb-client": "^0.4.4",
- "hammerjs": "^2.0.8",
- "i18next": "^17.0.3",
- "i18next-browser-languagedetector": "^3.0.1",
- "lodash.isequal": "4.5.0",
- "moment": "^2.24.0",
- "ohif-core": "0.9.1",
- "oidc-client": "1.7.x",
- "prop-types": "^15.7.2",
- "react-i18next": "^10.11.0",
- "react-redux": "^7.1.0",
- "react-resize-detector": "^4.2.0",
- "react-router": "^5.0.1",
- "react-router-dom": "^5.0.1",
- "react-transition-group": "^4.1.1",
- "react-viewerbase": "0.15.3",
- "redux": "^4.0.1",
- "redux-logger": "^3.0.6",
- "redux-oidc": "3.1.x",
- "redux-thunk": "^2.3.0",
- "reselect": "^4.0.0"
- },
"devDependencies": {
- "@babel/core": "^7.4.5",
- "@babel/plugin-proposal-class-properties": "^7.4.4",
- "@babel/plugin-syntax-dynamic-import": "^7.2.0",
- "@babel/plugin-transform-runtime": "^7.4.4",
- "@babel/preset-env": "^7.4.5",
+ "@babel/core": "^7.5.0",
+ "@babel/preset-env": "^7.5.0",
"@babel/preset-react": "^7.0.0",
- "@semantic-release/exec": "3.3.3",
- "@svgr/rollup": "^4.3.0",
- "babel-eslint": "10.0.1",
- "codecov": "3.5.0",
- "commitizen": "3.1.x",
- "core-js": "^3.1.4",
- "cpx": "1.5.0",
- "cross-env": "^5.2.0",
- "cypress": "^3.3.1",
- "cz-conventional-changelog": "2.1.0",
- "eslint": "5.16.0",
- "eslint-plugin-import": "^2.17.3",
- "eslint-plugin-node": "^9.1.0",
- "eslint-plugin-promise": "^4.1.1",
- "eslint-plugin-react": "^7.13.0",
- "gh-pages": "2.0.1",
- "husky": "2.4.x",
- "identity-obj-proxy": "3.0.x",
- "jest-canvas-mock": "2.1.0",
- "jest-junit": "6.4.x",
- "lint-staged": "^8.2.1",
- "lodash": "4.17.11",
- "lodash.clonedeep": "4.5.0",
- "prettier": "1.18.x",
- "react": "^16.7.0",
- "react-dom": "^16.7.0",
- "react-scripts": "^3.0.1",
- "rollup": "^1.15.5",
- "rollup-plugin-babel": "^4.3.2",
- "rollup-plugin-commonjs": "^10.0.0",
- "rollup-plugin-json": "^4.0.0",
- "rollup-plugin-node-builtins": "^2.1.2",
- "rollup-plugin-node-resolve": "^5.0.2",
- "rollup-plugin-peer-deps-external": "^2.2.0",
- "rollup-plugin-postcss": "^2.0.3",
- "rollup-plugin-url": "^2.2.2",
- "semantic-release": "15.13.x",
- "start-server-and-test": "^1.9.1",
- "stylelint": "^10.1.0"
+ "lerna": "^3.15.0"
}
}
diff --git a/packages/_core/.all-contributorsrc b/packages/_core/.all-contributorsrc
new file mode 100644
index 000000000..143630ab6
--- /dev/null
+++ b/packages/_core/.all-contributorsrc
@@ -0,0 +1,69 @@
+{
+ "files": [
+ "README.md"
+ ],
+ "imageSize": 100,
+ "commit": false,
+ "contributors": [
+ {
+ "login": "swederik",
+ "name": "Erik Ziegler",
+ "avatar_url": "https://avatars3.githubusercontent.com/u/607793?v=4",
+ "profile": "https://github.com/swederik",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "evren217",
+ "name": "Evren Ozkan",
+ "avatar_url": "https://avatars1.githubusercontent.com/u/4920551?v=4",
+ "profile": "https://github.com/evren217",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "galelis",
+ "name": "Gustavo André Lelis",
+ "avatar_url": "https://avatars3.githubusercontent.com/u/2378326?v=4",
+ "profile": "https://github.com/galelis",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "dannyrb",
+ "name": "Danny Brown",
+ "avatar_url": "https://avatars1.githubusercontent.com/u/5797588?v=4",
+ "profile": "http://dannyrb.com/",
+ "contributions": [
+ "code"
+ ]
+ },
+ {
+ "login": "allcontributors",
+ "name": "allcontributors[bot]",
+ "avatar_url": "https://avatars3.githubusercontent.com/u/46843839?v=4",
+ "profile": "https://github.com/all-contributors/all-contributors-bot",
+ "contributions": [
+ "doc"
+ ]
+ },
+ {
+ "login": "ivan-aksamentov",
+ "name": "Ivan Aksamentov",
+ "avatar_url": "https://avatars0.githubusercontent.com/u/9403403?v=4",
+ "profile": "https://github.com/ivan-aksamentov",
+ "contributions": [
+ "code",
+ "test"
+ ]
+ }
+ ],
+ "contributorsPerLine": 7,
+ "projectName": "ohif-core",
+ "projectOwner": "OHIF",
+ "repoType": "github",
+ "repoHost": "https://github.com"
+}
diff --git a/packages/_core/.eslintrc b/packages/_core/.eslintrc
new file mode 100644
index 000000000..f1460852f
--- /dev/null
+++ b/packages/_core/.eslintrc
@@ -0,0 +1,7 @@
+{
+ "extends": ["eslint:recommended", "plugin:prettier/recommended"],
+ "parser": "babel-eslint",
+ "env": {
+ "jest": true
+ }
+}
diff --git a/packages/_core/.gitattributes b/packages/_core/.gitattributes
new file mode 100644
index 000000000..fcadb2cf9
--- /dev/null
+++ b/packages/_core/.gitattributes
@@ -0,0 +1 @@
+* text eol=lf
diff --git a/packages/_core/.gitignore b/packages/_core/.gitignore
new file mode 100644
index 000000000..007760ae4
--- /dev/null
+++ b/packages/_core/.gitignore
@@ -0,0 +1,27 @@
+# See https://help.github.com/ignore-files/ for more about ignoring files.
+
+# dependencies
+node_modules
+
+# builds
+build
+dist
+.rpt2_cache
+coverage/
+junit.xml
+
+# misc
+.DS_Store
+.env
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+package-lock.json
+
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+.idea
+.yalc
+yalc.lock
diff --git a/.prettierrc b/packages/_core/.prettierrc
similarity index 100%
rename from .prettierrc
rename to packages/_core/.prettierrc
diff --git a/packages/_core/.vscode/extensions.json b/packages/_core/.vscode/extensions.json
new file mode 100644
index 000000000..d7df89c9c
--- /dev/null
+++ b/packages/_core/.vscode/extensions.json
@@ -0,0 +1,3 @@
+{
+ "recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
+}
diff --git a/packages/_core/.vscode/settings.json b/packages/_core/.vscode/settings.json
new file mode 100644
index 000000000..4b67a8bd3
--- /dev/null
+++ b/packages/_core/.vscode/settings.json
@@ -0,0 +1,29 @@
+{
+ "editor.rulers": [80, 120],
+
+ // ===
+ // Spacing
+ // ===
+
+ "editor.insertSpaces": true,
+ "editor.tabSize": 2,
+ "editor.trimAutoWhitespace": true,
+ "files.trimTrailingWhitespace": true,
+ "files.eol": "\n",
+ "files.insertFinalNewline": true,
+ "files.trimFinalNewlines": true,
+
+ // ===
+ // Event Triggers
+ // ===
+
+ "editor.formatOnSave": true,
+ "eslint.autoFixOnSave": true,
+ "eslint.run": "onSave",
+ "eslint.validate": [
+ { "language": "javascript", "autoFix": true },
+ { "language": "javascriptreact", "autoFix": true }
+ ],
+ "prettier.disableLanguages": [],
+ "prettier.endOfLine": "lf"
+}
diff --git a/extensions/ohif-cornerstone-extension/LICENSE b/packages/_core/LICENSE
similarity index 100%
rename from extensions/ohif-cornerstone-extension/LICENSE
rename to packages/_core/LICENSE
diff --git a/packages/_core/README.md b/packages/_core/README.md
new file mode 100644
index 000000000..c60361196
--- /dev/null
+++ b/packages/_core/README.md
@@ -0,0 +1,181 @@
+
+
+
+
ohif-core
+
ohif-core is a collection of useful functions and classes for building web-based medical imaging applications. This library helps power OHIF's zero-footprint DICOM viewer.
+
+
+
+
+[![CircleCI][circleci-image]][circleci-url]
+[![codecov][codecov-image]][codecov-url]
+[](#contributors)
+[![code style: prettier][prettier-image]][prettier-url]
+[![semantic-release][semantic-image]][semantic-url]
+
+[![NPM version][npm-version-image]][npm-url]
+[![NPM downloads][npm-downloads-image]][npm-url]
+[![MIT License][license-image]][license-url]
+
+
+
+## Why?
+
+This library offers pre-packaged solutions for features common to Web-based
+medical imaging viewers. For example:
+
+- Hotkeys
+- DICOM Web
+- Hanging Protocols
+- Managing a study's measurements
+- Managing a study's DICOM metadata
+- A flexible pattern for extensions
+- And many others
+
+It does this while remaining decoupled from any particular view library or
+rendering logic. While we use it to power our [React Viewer][react-viewer], it
+can be used with Vue, React, Vanilla JS, or any number of other frameworks.
+
+## Getting Started
+
+The documentation for this library is sparse. The best way to get started is to
+look at its
+[top level exports](https://github.com/OHIF/ohif-core/blob/master/src/index.js),
+and explore the source code of features that interest you. If you want to see
+how we use this library, you can check out [our viewer
+implementation][react-viewer].
+
+### Install
+
+> This library is pre- v1.0. All realeases until a v1.0 have the possibility of
+> introducing breaking changes. Please depend on an "exact" version in your
+> projects to prevent issues caused by loose versioning.
+
+```
+// with npm
+npm i ohif-core --save-exact
+
+// with yarn
+yarn add ohif-core --exact
+```
+
+### Usage
+
+Usage is dependent on the feature(s) you want to leverage. The bulk of
+`ohif-core`'s features are "pure" and can be imported and used in place.
+
+_Example: retrieving study metadata from a server_
+
+```js
+import { studies } from 'ohif-core'
+
+const studiesMetadata = await studies.retrieveStudiesMetadata(
+ server, // Object
+ studyInstanceUids, // Array
+ seriesInstanceUids // Array (optional)
+)
+```
+
+### Contributing
+
+It is notoriously difficult to setup multiple dependent repositories for
+end-to-end testing and development. That's why we recommend writing and running
+unit tests when adding and modifying features for this library. This allows us
+to program in isolation without a complex setup, and has the added benefit of
+producing well-tested business logic.
+
+1. Clone this repository
+2. Navigate to the project directory, and `yarn install`
+3. To begin making changes, `yarn run dev`
+4. To commit changes, run `yarn run cm`
+
+When creating tests, place the test file "next to" the file you're testing.
+[For example](https://github.com/OHIF/ohif-core/blob/master/src/index.test.js):
+
+```js
+// File
+index.js
+
+// Test for file
+index.test.js
+```
+
+As you add and modify code, `jest` will watch for uncommitted changes and run
+your tests, reporting the results to your terminal. Make a pull request with
+your changes to `master`, and a core team member will review your work. If you
+have any questions, please don't hesitate to reach out via a GitHub issue.
+
+## Issues
+
+_Looking to contribute? Look for the [Good First Issue][good-first-issue]
+label._
+
+### 🐛 Bugs
+
+Please file an issue for bugs, missing documentation, or unexpected behavior.
+
+[**See Bugs**][bugs]
+
+### 💡 Feature Requests
+
+Please file an issue to suggest new features. Vote on feature requests by adding
+a 👍. This helps maintainers prioritize what to work on.
+
+[**See Feature Requests**][requests-feature]
+
+### ❓ Questions
+
+For questions related to using the library, please visit our support community,
+or file an issue on GitHub.
+
+[Google Group][google-group]
+
+## Contributors
+
+Thanks goes to these wonderful people
+([emoji key](https://allcontributors.org/docs/en/emoji-key)):
+
+
+
+
+
+
+
+This project follows the
+[all-contributors](https://github.com/all-contributors/all-contributors)
+specification. Contributions of any kind welcome!
+
+## License
+
+MIT © [OHIF](https://github.com/OHIF)
+
+
+
+
+
+[all-contributors-image]: https://img.shields.io/badge/all_contributors-0-orange.svg?style=flat-square
+[contributing-url]: https://github.com/OHIF/ohif-core/blob/master/CONTRIBUTING.md
+[circleci-image]: https://circleci.com/gh/OHIF/ohif-core.svg?style=svg
+[circleci-url]: https://circleci.com/gh/OHIF/ohif-core
+[codecov-image]: https://codecov.io/gh/OHIF/ohif-core/branch/master/graph/badge.svg
+[codecov-url]: https://codecov.io/gh/OHIF/ohif-core
+[prettier-image]: https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square
+[prettier-url]: https://github.com/prettier/prettier
+[semantic-image]: https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg
+[semantic-url]: https://github.com/semantic-release/semantic-release
+
+[npm-url]: https://npmjs.org/package/ohif-core
+[npm-downloads-image]: https://img.shields.io/npm/dm/ohif-core.svg?style=flat-square
+[npm-version-image]: https://img.shields.io/npm/v/ohif-core.svg?style=flat-square
+[license-image]: https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square
+[license-url]: LICENSE
+
+[react-viewer]: https://github.com/OHIF/Viewers/tree/react
+
+[bugs]: https://github.com/OHIF/ohif-core/labels/bug
+[requests-feature]: https://github.com/OHIF/ohif-core/labels/enhancement
+[good-first-issue]: https://github.com/OHIF/ohif-core/labels/good%20first%20issue
+[google-group]: https://groups.google.com/forum/#!forum/cornerstone-platform
+
diff --git a/packages/_core/jest.config.js b/packages/_core/jest.config.js
new file mode 100644
index 000000000..59879cbd5
--- /dev/null
+++ b/packages/_core/jest.config.js
@@ -0,0 +1,13 @@
+module.exports = {
+ verbose: true,
+ testPathIgnorePatterns: ["/node_modules/"],
+ testMatch: ["/src/**/*.test.js"],
+ //
+ collectCoverage: false,
+ collectCoverageFrom: [
+ "**/*.{js,jsx}",
+ "!**/node_modules/**",
+ "!/dist/**"
+ ],
+ reporters: ["default", "jest-junit"]
+};
diff --git a/packages/_core/package.json b/packages/_core/package.json
new file mode 100644
index 000000000..4714aa52c
--- /dev/null
+++ b/packages/_core/package.json
@@ -0,0 +1,106 @@
+{
+ "name": "ohif-core",
+ "version": "0.4.0",
+ "description": "Useful functions and classes for building web-based medical imaging applications",
+ "author": "OHIF",
+ "license": "MIT",
+ "repository": "OHIF/ohif-core",
+ "main": "dist/index.umd.js",
+ "module": "dist/index.es.js",
+ "jsnext:main": "dist/index.es.js",
+ "engines": {
+ "node": ">=8",
+ "npm": ">=5"
+ },
+ "scripts": {
+ "cm": "npx git-cz",
+ "build": "rollup -c",
+ "dev": "jest --watch",
+ "start": "rollup -c -w",
+ "test": "jest",
+ "test:ci": "jest --ci --runInBand --collectCoverage --reporters=default --reporters=jest-junit && codecov",
+ "lint": "eslint -c .eslintrc --fix src && prettier --single-quote --write src/**/*.{js,jsx,json,css}",
+ "prepublishOnly": "npm run build"
+ },
+ "peerDependencies": {
+ "cornerstone-core": "^2.2.8",
+ "cornerstone-tools": "^3.9.0",
+ "cornerstone-wado-image-loader": "^2.2.3",
+ "dicom-parser": "^1.8.3"
+ },
+ "dependencies": {
+ "@babel/runtime": "^7.2.0",
+ "ajv": "^6.10.0",
+ "dicomweb-client": "^0.4.2",
+ "isomorphic-base64": "^1.0.2",
+ "lodash.clonedeep": "^4.5.0",
+ "lodash.merge": "^4.6.1",
+ "mousetrap": "^1.6.3",
+ "validate.js": "^0.12.0"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.2.0",
+ "@babel/plugin-external-helpers": "^7.2.0",
+ "@babel/plugin-proposal-class-properties": "^7.2.1",
+ "@babel/plugin-proposal-object-rest-spread": "^7.4.4",
+ "@babel/plugin-transform-runtime": "^7.2.0",
+ "@babel/preset-env": "^7.2.0",
+ "@babel/preset-react": "^7.0.0",
+ "@svgr/rollup": "^4.1.0",
+ "babel-eslint": "10.0.1",
+ "babel-jest": "^24.7.1",
+ "codecov": "^3.3.0",
+ "commitizen": "3.1.x",
+ "cross-env": "^5.2.0",
+ "cz-conventional-changelog": "2.1.x",
+ "eslint": "5.13.0",
+ "eslint-config-prettier": "^4.3.0",
+ "eslint-plugin-import": "^2.14.0",
+ "eslint-plugin-node": "^8.0.0",
+ "eslint-plugin-prettier": "^3.1.0",
+ "eslint-plugin-promise": "^4.0.1",
+ "husky": "^1.2.1",
+ "jest": "^24.7.1",
+ "jest-junit": "^6.3.0",
+ "lint-staged": "^8.1.0",
+ "prettier": "^1.15.3",
+ "redux-testkit": "^1.0.6",
+ "rollup": "^1.1.2",
+ "rollup-plugin-babel": "^4.1.0",
+ "rollup-plugin-commonjs": "^9.2.0",
+ "rollup-plugin-json": "^4.0.0",
+ "rollup-plugin-node-resolve": "^4.0.0",
+ "rollup-plugin-peer-deps-external": "^2.2.0",
+ "rollup-plugin-postcss": "^2.0.3",
+ "rollup-plugin-url": "^2.1.0",
+ "semantic-release": "15.13.x",
+ "stylelint": "^9.9.0",
+ "stylelint-config-recommended": "^2.1.0",
+ "stylus": "^0.54.5"
+ },
+ "husky": {
+ "hooks": {
+ "pre-commit": "lint-staged"
+ }
+ },
+ "lint-staged": {
+ "src/**/*.{js,jsx,json,css}": [
+ "prettier --single-quote --write",
+ "git add"
+ ]
+ },
+ "browserslist": [
+ ">0.2%",
+ "not dead",
+ "not ie <= 11",
+ "not op_mini all"
+ ],
+ "files": [
+ "dist"
+ ],
+ "config": {
+ "commitizen": {
+ "path": "./node_modules/cz-conventional-changelog"
+ }
+ }
+}
diff --git a/packages/_core/rollup.config.js b/packages/_core/rollup.config.js
new file mode 100644
index 000000000..6deb3bb36
--- /dev/null
+++ b/packages/_core/rollup.config.js
@@ -0,0 +1,64 @@
+import babel from 'rollup-plugin-babel'
+import commonjs from 'rollup-plugin-commonjs'
+import external from 'rollup-plugin-peer-deps-external'
+import postcss from 'rollup-plugin-postcss'
+import resolve from 'rollup-plugin-node-resolve'
+import url from 'rollup-plugin-url'
+import json from 'rollup-plugin-json'
+import svgr from '@svgr/rollup'
+import pkg from './package.json'
+
+const globals = {
+ 'react': 'React',
+ 'react-dom': 'ReactDOM',
+ 'cornerstone-core': 'cornerstone',
+ 'cornerstone-math': 'cornerstoneMath',
+ 'cornerstone-tools': 'cornerstoneTools',
+ 'cornerstone-wado-image-loader': 'cornerstoneWADOImageLoader',
+ 'dicom-parser': 'dicomParser'
+};
+
+export default {
+ input: 'src/index.js',
+ output: [
+ {
+ file: pkg.main,
+ format: 'umd',
+ name: 'OHIF',
+ sourcemap: true,
+ exports: 'named',
+ globals
+ },
+ {
+ file: pkg.module,
+ format: 'es',
+ sourcemap: true,
+ exports: 'named',
+ globals
+ },
+ ],
+ plugins: [
+ external(),
+ postcss({
+ modules: false
+ }),
+ url(),
+ json(),
+ svgr(),
+ babel({
+ exclude: 'node_modules/**',
+ externalHelpers: false,
+ runtimeHelpers: true
+ }),
+ resolve(),
+ commonjs({
+ include: 'node_modules/**',
+ namedExports: {
+ 'node_modules/dicomweb-client/build/dicomweb-client.js': [
+ 'api'
+ ]
+ }
+ }),
+ ],
+ external: Object.keys(pkg.peerDependencies || {}),
+}
diff --git a/packages/_core/src/DICOMWeb/getAttribute.js b/packages/_core/src/DICOMWeb/getAttribute.js
new file mode 100644
index 000000000..49cc9feb0
--- /dev/null
+++ b/packages/_core/src/DICOMWeb/getAttribute.js
@@ -0,0 +1,44 @@
+/**
+ * Returns the specified element as a dicom attribute group/element.
+ *
+ * @param element - The group/element of the element (e.g. '00280009')
+ * @param [defaultValue] - The value to return if the element is not present
+ * @returns {*}
+ */
+export default function getAttribute(element, defaultValue) {
+ if (!element) {
+ return defaultValue;
+ }
+ // Value is not present if the attribute has a zero length value
+ if (!element.Value) {
+ return defaultValue;
+ }
+ // Sanity check to make sure we have at least one entry in the array.
+ if (!element.Value.length) {
+ return defaultValue;
+ }
+
+ return convertToInt(element.Value);
+}
+
+function convertToInt(input) {
+ function padFour(input) {
+ var l = input.length;
+
+ if (l == 0) return '0000';
+ if (l == 1) return '000' + input;
+ if (l == 2) return '00' + input;
+ if (l == 3) return '0' + input;
+
+ return input;
+ }
+
+ var output = '';
+ for (var i = 0; i < input.length; i++) {
+ for (var j = 0; j < input[i].length; j++) {
+ output += padFour(input[i].charCodeAt(j).toString(16));
+ }
+ }
+
+ return parseInt(output, 16);
+}
diff --git a/packages/_core/src/DICOMWeb/getAuthorizationHeader.js b/packages/_core/src/DICOMWeb/getAuthorizationHeader.js
new file mode 100644
index 000000000..c7d391571
--- /dev/null
+++ b/packages/_core/src/DICOMWeb/getAuthorizationHeader.js
@@ -0,0 +1,25 @@
+// Commenting this out for now since it looks like Rollup is pulling in the
+// Node.js version instead of the Browser version of this package
+//import { btoa } from 'isomorphic-base64';
+import user from '../user';
+
+/**
+ * Returns the Authorization header as part of an Object.
+ *
+ * @returns {Object}
+ */
+export default function getAuthorizationHeader(server) {
+ const headers = {};
+
+ // Check for OHIF.user since this can also be run on the server
+ const accessToken = user && user.getAccessToken && user.getAccessToken();
+
+ if (server && server.requestOptions && server.requestOptions.auth) {
+ // HTTP Basic Auth (user:password)
+ headers.Authorization = `Basic ${btoa(server.requestOptions.auth)}`;
+ } else if (accessToken) {
+ headers.Authorization = `Bearer ${accessToken}`;
+ }
+
+ return headers;
+}
diff --git a/packages/_core/src/DICOMWeb/getModalities.js b/packages/_core/src/DICOMWeb/getModalities.js
new file mode 100644
index 000000000..5da21aa02
--- /dev/null
+++ b/packages/_core/src/DICOMWeb/getModalities.js
@@ -0,0 +1,21 @@
+export default function getModalities(modality, modalitiesInStudy) {
+ var modalities = {};
+ if (modality) {
+ modalities = modality;
+ }
+
+ if (modalitiesInStudy) {
+ // Find vr in modalities
+ if (modalities.vr && modalities.vr === modalitiesInStudy.vr) {
+ for (var i = 0; i < modalitiesInStudy.Value.length; i++) {
+ var value = modalitiesInStudy.Value[i];
+ if (modalities.Value.indexOf(value) === -1) {
+ modalities.Value.push(value);
+ }
+ }
+ } else {
+ modalities = modalitiesInStudy;
+ }
+ }
+ return modalities;
+}
diff --git a/packages/_core/src/DICOMWeb/getName.js b/packages/_core/src/DICOMWeb/getName.js
new file mode 100644
index 000000000..096a1b4ea
--- /dev/null
+++ b/packages/_core/src/DICOMWeb/getName.js
@@ -0,0 +1,26 @@
+/**
+ * Returns the Alphabetic version of a PN
+ *
+ * @param element - The group/element of the element (e.g. '00200013')
+ * @param [defaultValue] - The default value to return if the element is not found
+ * @returns {*}
+ */
+export default function getName(element, defaultValue) {
+ if (!element) {
+ return defaultValue;
+ }
+ // Value is not present if the attribute has a zero length value
+ if (!element.Value) {
+ return defaultValue;
+ }
+ // Sanity check to make sure we have at least one entry in the array.
+ if (!element.Value.length) {
+ return defaultValue;
+ }
+ // Return the Alphabetic component group
+ if (element.Value[0].Alphabetic) {
+ return element.Value[0].Alphabetic;
+ }
+ // Orthanc does not return PN properly so this is a temporary workaround
+ return element.Value[0];
+}
diff --git a/packages/_core/src/DICOMWeb/getNumber.js b/packages/_core/src/DICOMWeb/getNumber.js
new file mode 100644
index 000000000..f7a21264b
--- /dev/null
+++ b/packages/_core/src/DICOMWeb/getNumber.js
@@ -0,0 +1,21 @@
+/**
+ * Returns the first string value as a Javascript Number
+ * @param element - The group/element of the element (e.g. '00200013')
+ * @param [defaultValue] - The default value to return if the element does not exist
+ * @returns {*}
+ */
+export default function getNumber(element, defaultValue) {
+ if (!element) {
+ return defaultValue;
+ }
+ // Value is not present if the attribute has a zero length value
+ if (!element.Value) {
+ return defaultValue;
+ }
+ // Sanity check to make sure we have at least one entry in the array.
+ if (!element.Value.length) {
+ return defaultValue;
+ }
+
+ return parseFloat(element.Value[0]);
+}
diff --git a/packages/_core/src/DICOMWeb/getString.js b/packages/_core/src/DICOMWeb/getString.js
new file mode 100644
index 000000000..b6ff678aa
--- /dev/null
+++ b/packages/_core/src/DICOMWeb/getString.js
@@ -0,0 +1,23 @@
+/**
+ * Returns the specified element as a string. Multi-valued elements will be separated by a backslash
+ *
+ * @param element - The group/element of the element (e.g. '00200013')
+ * @param [defaultValue] - The value to return if the element is not present
+ * @returns {*}
+ */
+export default function getString(element, defaultValue) {
+ if (!element) {
+ return defaultValue;
+ }
+ // Value is not present if the attribute has a zero length value
+ if (!element.Value) {
+ return defaultValue;
+ }
+ // Sanity check to make sure we have at least one entry in the array.
+ if (!element.Value.length) {
+ return defaultValue;
+ }
+ // Join the array together separated by backslash
+ // NOTE: Orthanc does not correctly split values into an array so the join is a no-op
+ return element.Value.join('\\');
+}
diff --git a/packages/_core/src/DICOMWeb/index.js b/packages/_core/src/DICOMWeb/index.js
new file mode 100644
index 000000000..f60c9b362
--- /dev/null
+++ b/packages/_core/src/DICOMWeb/index.js
@@ -0,0 +1,17 @@
+import getAttribute from './getAttribute.js';
+import getAuthorizationHeader from './getAuthorizationHeader.js';
+import getModalities from './getModalities.js';
+import getName from './getName.js';
+import getNumber from './getNumber.js';
+import getString from './getString.js';
+
+const DICOMWeb = {
+ getAttribute,
+ getAuthorizationHeader,
+ getModalities,
+ getName,
+ getNumber,
+ getString,
+};
+
+export default DICOMWeb;
diff --git a/packages/_core/src/__mocks__/cornerstone-core.js b/packages/_core/src/__mocks__/cornerstone-core.js
new file mode 100644
index 000000000..ff8b4c563
--- /dev/null
+++ b/packages/_core/src/__mocks__/cornerstone-core.js
@@ -0,0 +1 @@
+export default {};
diff --git a/packages/_core/src/__mocks__/cornerstone-tools.js b/packages/_core/src/__mocks__/cornerstone-tools.js
new file mode 100644
index 000000000..ff8b4c563
--- /dev/null
+++ b/packages/_core/src/__mocks__/cornerstone-tools.js
@@ -0,0 +1 @@
+export default {};
diff --git a/packages/_core/src/__mocks__/cornerstone-wado-image-loader.js b/packages/_core/src/__mocks__/cornerstone-wado-image-loader.js
new file mode 100644
index 000000000..ff8b4c563
--- /dev/null
+++ b/packages/_core/src/__mocks__/cornerstone-wado-image-loader.js
@@ -0,0 +1 @@
+export default {};
diff --git a/packages/_core/src/__mocks__/dicom-parser.js b/packages/_core/src/__mocks__/dicom-parser.js
new file mode 100644
index 000000000..ff8b4c563
--- /dev/null
+++ b/packages/_core/src/__mocks__/dicom-parser.js
@@ -0,0 +1 @@
+export default {};
diff --git a/packages/_core/src/__mocks__/dicomweb-client.js b/packages/_core/src/__mocks__/dicomweb-client.js
new file mode 100644
index 000000000..e91680ed0
--- /dev/null
+++ b/packages/_core/src/__mocks__/dicomweb-client.js
@@ -0,0 +1,15 @@
+// import { api } from 'dicomweb-client'
+
+const api = {
+ DICOMwebClient: jest.fn().mockImplementation(() => {
+ return {
+ retrieveStudyMetadata: jest.fn().mockResolvedValue([]),
+ };
+ }),
+};
+
+export default {
+ api,
+};
+
+export { api };
diff --git a/packages/_core/src/__mocks__/log.js b/packages/_core/src/__mocks__/log.js
new file mode 100644
index 000000000..29194b790
--- /dev/null
+++ b/packages/_core/src/__mocks__/log.js
@@ -0,0 +1,4 @@
+export default {
+ warn: jest.fn(),
+ error: jest.fn(),
+};
diff --git a/packages/_core/src/classes/CommandsManager.js b/packages/_core/src/classes/CommandsManager.js
new file mode 100644
index 000000000..e6efdaa67
--- /dev/null
+++ b/packages/_core/src/classes/CommandsManager.js
@@ -0,0 +1,189 @@
+import log from '../log.js';
+
+/**
+ * The definition of a command
+ *
+ * @typedef {Object} CommandDefinition
+ * @property {Function} commandFn - Command to call
+ * @property {Array} storeContexts - Array of string of modules required from store
+ * @property {Object} options - Object of params to pass action
+ */
+
+/**
+ * The Commands Manager tracks named commands (or functions) that are scoped to
+ * a context. When we attempt to run a command with a given name, we look for it
+ * in our active contexts. If found, we run the command, passing in any application
+ * or call specific data specified in the command's definition.
+ *
+ * NOTE: A more robust version of the CommandsManager lives in v1. If you're looking
+ * to extend this class, please check it's source before adding new methods.
+ */
+export class CommandsManager {
+ constructor({ getAppState, getActiveContexts } = {}) {
+ this.contexts = {};
+
+ if (!getAppState || !getActiveContexts) {
+ log.warn(
+ 'CommandsManager was instantiated without getAppState() or getActiveContexts()'
+ );
+ }
+
+ this._getAppState = getAppState;
+ this._getActiveContexts = getActiveContexts;
+ }
+
+ /**
+ * Allows us to create commands "per context". An example would be the "Cornerstone"
+ * context having a `SaveImage` command, and the "VTK" context having a `SaveImage`
+ * command. The distinction of a context allows us to call the command in either
+ * context, and have faith that the correct command will be run.
+ *
+ * @method
+ * @param {string} contextName - Namespace for commands
+ * @returns {undefined}
+ */
+ createContext(contextName) {
+ if (!contextName) {
+ return;
+ }
+
+ if (this.contexts[contextName]) {
+ return this.clearContext(contextName);
+ }
+
+ this.contexts[contextName] = {};
+ }
+
+ /**
+ * Returns all command definitions for a given context
+ *
+ * @method
+ * @param {string} contextName - Namespace for commands
+ * @returs {Object} - the matched context
+ */
+ getContext(contextName) {
+ const context = this.contexts[contextName];
+
+ if (!context) {
+ return;
+ }
+
+ return context;
+ }
+
+ /**
+ * Clears all registered commands for a given context.
+ *
+ * @param {string} contextName - Namespace for commands
+ * @returns {undefined}
+ */
+ clearContext(contextName) {
+ if (!contextName) {
+ return;
+ }
+
+ this.contexts[contextName] = {};
+ }
+
+ /**
+ * Register a new command with the command manager. Scoped to a context, and
+ * with a definition to assist command callers w/ providing the necessary params
+ *
+ * @method
+ * @param {string} contextName - Namespace for command; often scoped to the extension that added it
+ * @param {string} commandName - Unique name identifying the command
+ * @param {CommandDefinition} definition - {@link CommandDefinition}
+ */
+ registerCommand(contextName, commandName, definition) {
+ if (typeof definition !== 'object') {
+ return;
+ }
+
+ const context = this.getContext(contextName);
+ if (!context) {
+ return;
+ }
+
+ context[commandName] = definition;
+ }
+
+ /**
+ * Finds a command with the provided name if it exists in the specified context,
+ * or a currently active context.
+ *
+ * @method
+ * @param {String} commandName - Command to find
+ * @param {String} [contextName] - Specific command to look in. Defaults to current activeContexts
+ */
+ getCommand(commandName, contextName) {
+ let contexts = [];
+
+ if (contextName) {
+ const context = this.getContext(contextName);
+ if (context) {
+ contexts.push(context);
+ }
+ } else {
+ const activeContexts = this._getActiveContexts();
+ activeContexts.forEach(activeContext => {
+ const context = this.getContext(activeContext);
+ if (context) {
+ contexts.push(context);
+ }
+ });
+ }
+
+ if (contexts.length === 0) {
+ return;
+ }
+
+ let foundCommand;
+ contexts.forEach(context => {
+ if (context[commandName]) {
+ foundCommand = context[commandName];
+ }
+ });
+
+ return foundCommand;
+ }
+
+ /**
+ *
+ * @method
+ * @param {String} commandName
+ * @param {Object} [options={}] - Extra options to pass the command. Like a mousedown event
+ * @param {String} [contextName]
+ */
+ runCommand(commandName, options = {}, contextName) {
+ const definition = this.getCommand(commandName, contextName);
+ if (!definition) {
+ log.warn(`Command "${commandName}" not found in current context`);
+ return;
+ }
+
+ const { commandFn, storeContexts } = definition;
+ const definitionOptions = definition.options;
+
+ let commandParams = {};
+ const appState = this._getAppState();
+ storeContexts.forEach(context => {
+ commandParams[context] = appState[context];
+ });
+
+ commandParams = Object.assign(
+ {},
+ commandParams, // Required store contexts
+ definitionOptions, // "Command configuration"
+ options // "Time of call" info
+ );
+
+ if (typeof commandFn !== 'function') {
+ log.warn(`No commandFn was defined for command "${commandName}"`);
+ return;
+ } else {
+ return commandFn(commandParams);
+ }
+ }
+}
+
+export default CommandsManager;
diff --git a/packages/_core/src/classes/CommandsManager.test.js b/packages/_core/src/classes/CommandsManager.test.js
new file mode 100644
index 000000000..411fc7cb8
--- /dev/null
+++ b/packages/_core/src/classes/CommandsManager.test.js
@@ -0,0 +1,225 @@
+import CommandsManager from './CommandsManager.js';
+import log from './../log.js';
+
+jest.mock('./../log.js');
+
+describe('CommandsManager', () => {
+ let commandsManager,
+ contextName = 'VTK',
+ command = {
+ commandFn: jest.fn().mockReturnValue(true),
+ storeContexts: ['viewers'],
+ options: { passMeToCommandFn: ':wave:' },
+ },
+ commandsManagerConfig = {
+ getAppState: () => {
+ return {
+ viewers: 'Test',
+ };
+ },
+ getActiveContexts: () => ['VIEWER', 'ACTIVE_VIEWER::CORNERSTONE'],
+ };
+
+ beforeEach(() => {
+ commandsManager = new CommandsManager(commandsManagerConfig);
+ commandsManager.createContext('VIEWER');
+ commandsManager.createContext('ACTIVE_VIEWER::CORNERSTONE');
+ jest.clearAllMocks();
+ });
+
+ it('has a contexts property', () => {
+ const localCommandsManager = new CommandsManager(commandsManagerConfig);
+
+ expect(localCommandsManager).toHaveProperty('contexts');
+ expect(localCommandsManager.contexts).toEqual({});
+ });
+
+ it('logs a warning if instantiated without getAppState or getActiveContexts', () => {
+ new CommandsManager();
+
+ expect(log.warn.mock.calls.length).toBe(1);
+ });
+
+ describe('createContext()', () => {
+ it('creates a context', () => {
+ commandsManager.createContext(contextName);
+
+ expect(commandsManager.contexts).toHaveProperty(contextName);
+ });
+
+ it('clears the context if it already exists', () => {
+ commandsManager.createContext(contextName);
+ commandsManager.registerCommand(contextName, 'TestCommand', command);
+ commandsManager.registerCommand(contextName, 'TestCommand2', command);
+ commandsManager.createContext(contextName);
+
+ const registeredCommands = commandsManager.getContext(contextName);
+
+ expect(registeredCommands).toEqual({});
+ });
+ });
+
+ describe('getContext()', () => {
+ it('returns all registered commands for a context', () => {
+ commandsManager.createContext(contextName);
+ commandsManager.registerCommand(contextName, 'TestCommand', command);
+ const registeredCommands = commandsManager.getContext(contextName);
+
+ expect(registeredCommands).toHaveProperty('TestCommand');
+ expect(registeredCommands['TestCommand']).toEqual(command);
+ });
+ it('returns undefined if the context does not exist', () => {
+ const registeredCommands = commandsManager.getContext(contextName);
+
+ expect(registeredCommands).toBe(undefined);
+ });
+ });
+
+ describe('clearContext()', () => {
+ it('clears all registered commands for a context', () => {
+ commandsManager.createContext(contextName);
+ commandsManager.registerCommand(contextName, 'TestCommand', command);
+ commandsManager.registerCommand(contextName, 'TestCommand2', command);
+ commandsManager.clearContext(contextName);
+
+ const registeredCommands = commandsManager.getContext(contextName);
+
+ expect(registeredCommands).toEqual({});
+ });
+ });
+
+ describe('registerCommand()', () => {
+ it('registers commands to a context', () => {
+ commandsManager.createContext(contextName);
+ commandsManager.registerCommand(contextName, 'TestCommand', command);
+ const registeredCommands = commandsManager.getContext(contextName);
+
+ expect(registeredCommands).toHaveProperty('TestCommand');
+ expect(registeredCommands['TestCommand']).toEqual(command);
+ });
+ });
+
+ describe('getCommand()', () => {
+ it('returns undefined if context does not exist', () => {
+ const result = commandsManager.getCommand(
+ 'TestCommand',
+ 'NonExistentContext'
+ );
+
+ expect(result).toBe(undefined);
+ });
+ it('returns undefined if command does not exist in context', () => {
+ commandsManager.createContext(contextName);
+ const result = commandsManager.getCommand('TestCommand', contextName);
+
+ expect(result).toBe(undefined);
+ });
+ it('uses contextName param to get command', () => {
+ commandsManager.createContext('GLOBAL');
+ commandsManager.registerCommand('GLOBAL', 'TestCommand', command);
+ const foundCommand = commandsManager.getCommand('TestCommand', 'GLOBAL');
+
+ expect(foundCommand).toBe(command);
+ });
+ it('uses activeContexts, if contextName is not provided, to get command', () => {
+ commandsManager.registerCommand('VIEWER', 'TestCommand', command);
+ const foundCommand = commandsManager.getCommand('TestCommand');
+
+ expect(foundCommand).toBe(command);
+ });
+ it('returns the expected command', () => {
+ commandsManager.createContext(contextName);
+ commandsManager.registerCommand(contextName, 'TestCommand', command);
+ const result = commandsManager.getCommand('TestCommand', contextName);
+
+ expect(result).toEqual(command);
+ });
+ });
+
+ describe('runCommand()', () => {
+ it('Logs a warning if commandName not found in context', () => {
+ const result = commandsManager.runCommand(
+ 'CommandThatDoesNotExistInAnyContext'
+ );
+
+ expect(result).toBe(undefined);
+ expect(log.warn.mock.calls[0][0]).toEqual(
+ 'Command "CommandThatDoesNotExistInAnyContext" not found in current context'
+ );
+ });
+
+ it('Logs a warning if command definition does not have a commandFn', () => {
+ const commandWithNoCommmandFn = {
+ commandFn: undefined,
+ storeContexts: [],
+ options: {},
+ };
+
+ commandsManager.createContext(contextName);
+ commandsManager.registerCommand(
+ contextName,
+ 'TestCommand',
+ commandWithNoCommmandFn
+ );
+ const result = commandsManager.runCommand(
+ 'TestCommand',
+ null,
+ contextName
+ );
+
+ expect(result).toBe(undefined);
+ expect(log.warn.mock.calls[0][0]).toEqual(
+ 'No commandFn was defined for command "TestCommand"'
+ );
+ });
+
+ it('Calls commandFn', () => {
+ commandsManager.registerCommand('VIEWER', 'TestCommand', command);
+ commandsManager.runCommand('TestCommand', {}, 'VIEWER');
+
+ expect(command.commandFn.mock.calls.length).toBe(1);
+ });
+
+ it('Calls commandFn w/ properties from appState', () => {
+ commandsManager.registerCommand('VIEWER', 'TestCommand', command);
+ commandsManager.runCommand('TestCommand', {}, 'VIEWER');
+
+ expect(command.commandFn.mock.calls.length).toBe(1);
+ expect(command.commandFn.mock.calls[0][0].viewers).toEqual(
+ commandsManagerConfig.getAppState().viewers
+ );
+ });
+
+ it('Calls commandFn w/ command definition options', () => {
+ commandsManager.registerCommand('VIEWER', 'TestCommand', command);
+ commandsManager.runCommand('TestCommand', {}, 'VIEWER');
+
+ expect(command.commandFn.mock.calls.length).toBe(1);
+ expect(command.commandFn.mock.calls[0][0].passMeToCommandFn).toEqual(
+ command.options.passMeToCommandFn
+ );
+ });
+
+ it('Calls commandFn w/ runCommand "options" parameter', () => {
+ const runCommandOptions = {
+ test: ':+1:',
+ };
+
+ commandsManager.registerCommand('VIEWER', 'TestCommand', command);
+ commandsManager.runCommand('TestCommand', runCommandOptions, 'VIEWER');
+
+ expect(command.commandFn.mock.calls.length).toBe(1);
+ expect(command.commandFn.mock.calls[0][0].test).toEqual(
+ runCommandOptions.test
+ );
+ });
+
+ it('Returns the result of commandFn', () => {
+ commandsManager.registerCommand('VIEWER', 'TestCommand', command);
+ const result = commandsManager.runCommand('TestCommand', {}, 'VIEWER');
+
+ expect(command.commandFn.mock.calls.length).toBe(1);
+ expect(result).toBe(true);
+ });
+ });
+});
diff --git a/packages/_core/src/classes/HotkeysManager.js b/packages/_core/src/classes/HotkeysManager.js
new file mode 100644
index 000000000..b5a0ed20d
--- /dev/null
+++ b/packages/_core/src/classes/HotkeysManager.js
@@ -0,0 +1,162 @@
+import hotkeys from './hotkeys';
+import log from './../log.js';
+
+/**
+ *
+ *
+ * @typedef {Object} HotkeyDefinition
+ * @property {String} commandName - Command to call
+ * @property {String} label - Display name for hotkey
+ * @property {String[]} keys - Keys to bind; Follows Mousetrap.js binding syntax
+ */
+
+export class HotkeysManager {
+ constructor(commandsManager) {
+ this.hotkeyDefinitions = {};
+ this.hotkeyDefaults = [];
+ this.isEnabled = true;
+
+ if (!commandsManager) {
+ log.warn(
+ 'HotkeysManager instantiated without a commandsManager. Hotkeys will be unable to find and run commands.'
+ );
+ }
+
+ this._commandsManager = commandsManager;
+ }
+
+ /**
+ * Disables all hotkeys. Hotkeys added while disabled will not listen for
+ * input.
+ */
+ disable() {
+ this.isEnabled = false;
+ hotkeys.pause();
+ }
+
+ /**
+ * Enables all hotkeys.
+ */
+ enable() {
+ this.isEnabled = true;
+ hotkeys.unpause();
+ }
+
+ /**
+ * Registers a list of hotkeydefinitions. Optionally, sets the
+ * default hotkey bindings for all provided definitions. These
+ * values are used in `this.restoreDefaultBindings`.
+ *
+ * @param {HotkeyDefinition[]} hotkeyDefinitions
+ * @param {Boolean} [isDefaultDefinitions]
+ */
+ setHotkeys(hotkeyDefinitions, isDefaultDefinitions = false) {
+ hotkeyDefinitions.forEach(definition => this.registerHotkeys(definition));
+
+ if (isDefaultDefinitions) {
+ this.hotkeyDefaults = hotkeyDefinitions;
+ }
+ }
+
+ /**
+ * (unbinds and) binds the specified command to one or more key combinations.
+ * When a hotkey combination is triggered, the command name and active contexts
+ * are used to locate the correct command to call.
+ *
+ * @param {HotkeyDefinition} commandName
+ * @param {String} extension
+ * @returns {undefined}
+ */
+ registerHotkeys({ commandName, keys, label } = {}, extension) {
+ if (!commandName) {
+ log.warn(`No command was defined for hotkey "${keys}"`);
+ return;
+ }
+
+ const previouslyRegisteredDefinition = this.hotkeyDefinitions[commandName];
+
+ if (previouslyRegisteredDefinition) {
+ const previouslyRegisteredKeys = previouslyRegisteredDefinition.keys;
+ this._unbindHotkeys(commandName, previouslyRegisteredKeys);
+ }
+
+ // Set definition & bind
+ this.hotkeyDefinitions[commandName] = { keys, label };
+ this._bindHotkeys(commandName, keys);
+ }
+
+ /**
+ * Uses most recent
+ *
+ * @returns {undefined}
+ */
+ restoreDefaultBindings() {
+ this.setHotkeys(this.hotkeyDefaults);
+ }
+
+ /**
+ *
+ */
+ destroy() {
+ this.hotkeyDefaults = [];
+ this.hotkeyDefinitions = {};
+ hotkeys.reset();
+ }
+
+ /**
+ * Binds one or more set of hotkey combinations for a given command
+ *
+ * @private
+ * @param {string} commandName - The name of the command to trigger when hotkeys are used
+ * @param {string[]} keys - One or more key combinations that should trigger command
+ * @returns {undefined}
+ */
+ _bindHotkeys(commandName, keys) {
+ const isKeyDefined = keys === '' || keys === undefined;
+ if (isKeyDefined) {
+ return;
+ }
+
+ const isKeyArray = keys instanceof Array;
+ if (isKeyArray) {
+ keys.forEach(key => this._bindHotkeys(commandName, key));
+ return;
+ }
+
+ hotkeys.bind(keys, evt => {
+ this._commandsManager.runCommand(commandName, { evt });
+ });
+ }
+
+ /**
+ * unbinds one or more set of hotkey combinations for a given command
+ *
+ * @private
+ * @param {string} commandName - The name of the previously bound command
+ * @param {string[]} keys - One or more sets of previously bound keys
+ * @returns {undefined}
+ */
+ _unbindHotkeys(commandName, keys) {
+ const isKeyDefined = keys !== '' && keys !== undefined;
+ if (!isKeyDefined) {
+ return;
+ }
+
+ const isKeyArray = keys instanceof Array;
+ if (isKeyArray) {
+ keys.forEach(key => this._unbindHotkeys(commandName, key));
+ return;
+ }
+
+ hotkeys.unbind(keys);
+ }
+}
+
+export default HotkeysManager;
+
+// Commands Contexts:
+
+// --> Name and Priority
+// GLOBAL: 0
+// VIEWER::CORNERSTONE: 1
+// VIEWER::VTK: 1
diff --git a/packages/_core/src/classes/HotkeysManager.test.js b/packages/_core/src/classes/HotkeysManager.test.js
new file mode 100644
index 000000000..e02c1f9a0
--- /dev/null
+++ b/packages/_core/src/classes/HotkeysManager.test.js
@@ -0,0 +1,201 @@
+import CommandsManager from './CommandsManager.js';
+import HotkeysManager from './HotkeysManager.js';
+import hotkeys from './hotkeys';
+import log from './../log.js';
+
+jest.mock('./CommandsManager.js');
+jest.mock('./hotkeys');
+jest.mock('./../log.js');
+
+describe('HotkeysManager', () => {
+ let hotkeysManager, commandsManager;
+
+ beforeEach(() => {
+ commandsManager = new CommandsManager();
+ hotkeysManager = new HotkeysManager(commandsManager);
+ CommandsManager.mockClear();
+ hotkeys.mockClear();
+ log.warn.mockClear();
+ jest.clearAllMocks();
+ });
+
+ it('has expected properties', () => {
+ const allProperties = Object.keys(hotkeysManager);
+ const expectedProprties = [
+ 'hotkeyDefinitions',
+ 'hotkeyDefaults',
+ 'isEnabled',
+ ];
+
+ const containsAllExpectedProperties = expectedProprties.every(expected =>
+ allProperties.includes(expected)
+ );
+
+ expect(containsAllExpectedProperties).toBe(true);
+ });
+
+ it('logs a warning if instantiated without a commandsManager', () => {
+ new HotkeysManager();
+
+ expect(log.warn.mock.calls.length).toBe(1);
+ expect(log.warn.mock.calls[0][0]).toEqual(
+ 'HotkeysManager instantiated without a commandsManager. Hotkeys will be unable to find and run commands.'
+ );
+ });
+
+ describe('disable()', () => {
+ beforeEach(() => hotkeys.pause.mockClear());
+
+ it('sets isEnabled property to false', () => {
+ hotkeysManager.disable();
+
+ expect(hotkeysManager.isEnabled).toBe(false);
+ });
+
+ it('calls hotkeys.pause()', () => {
+ hotkeysManager.disable();
+
+ expect(hotkeys.pause.mock.calls.length).toBe(1);
+ });
+ });
+
+ describe('enable()', () => {
+ beforeEach(() => hotkeys.unpause.mockClear());
+
+ it('sets isEnabled property to true', () => {
+ hotkeysManager.disable();
+ hotkeysManager.enable();
+
+ expect(hotkeysManager.isEnabled).toBe(true);
+ });
+
+ it('calls hotkeys.unpause()', () => {
+ hotkeysManager.enable();
+
+ expect(hotkeys.unpause.mock.calls.length).toBe(1);
+ });
+ });
+
+ describe('setHotkeys()', () => {
+ it('calls registerHotkeys for each hotkeyDefinition', () => {
+ const hotkeyDefinitions = [
+ { commandName: 'dance', label: 'dance dance', keys: '+' },
+ { commandName: 'celebrate', label: 'celebrate everything', keys: 'q' },
+ ];
+
+ hotkeysManager.registerHotkeys = jest.fn();
+ hotkeysManager.setHotkeys(hotkeyDefinitions);
+
+ const numberOfCalls = hotkeysManager.registerHotkeys.mock.calls.length;
+ const firstCallArgs = hotkeysManager.registerHotkeys.mock.calls[0][0];
+ const secondCallArgs = hotkeysManager.registerHotkeys.mock.calls[1][0];
+
+ expect(numberOfCalls).toBe(2);
+ expect(firstCallArgs).toEqual(hotkeyDefinitions[0]);
+ expect(secondCallArgs).toEqual(hotkeyDefinitions[1]);
+ });
+ it('does not set this.hotkeyDefaults by default', () => {
+ const hotkeyDefinitions = [{ commandName: 'dance', keys: '+' }];
+
+ hotkeysManager.setHotkeys(hotkeyDefinitions);
+
+ expect(hotkeysManager.hotkeyDefaults).toEqual([]);
+ });
+ it('sets this.hotkeyDefaults when isDefaultDefinitions is true', () => {
+ const hotkeyDefinitions = [{ commandName: 'dance', keys: '+' }];
+ const isDefaultDefinitions = true;
+
+ hotkeysManager.setHotkeys(hotkeyDefinitions, isDefaultDefinitions);
+
+ expect(hotkeysManager.hotkeyDefaults).toEqual(hotkeyDefinitions);
+ });
+ });
+
+ describe('registerHotkeys()', () => {
+ it('logs a warning and returns undefined if a commandName is not provided', () => {
+ const definition = { commandName: undefined, keys: '+' };
+
+ const result = hotkeysManager.registerHotkeys(definition);
+
+ expect(result).toBe(undefined);
+ expect(log.warn.mock.calls.length).toBe(1);
+ });
+ it('updates hotkeyDefinitions property with registered keys', () => {
+ const definition = { commandName: 'dance', label: 'hello', keys: '+' };
+ const expectedHotkeyDefinition = { label: 'hello', keys: '+' };
+
+ hotkeysManager.registerHotkeys(definition);
+
+ const numOfHotkeyDefinitions = Object.keys(
+ hotkeysManager.hotkeyDefinitions
+ ).length;
+ const hotkeyDefinitionForRegisteredCommand =
+ hotkeysManager.hotkeyDefinitions[definition.commandName];
+
+ expect(numOfHotkeyDefinitions).toBe(1);
+ expect(hotkeyDefinitionForRegisteredCommand).toEqual(
+ expectedHotkeyDefinition
+ );
+ });
+ it('calls hotkeys.bind for all keys in array', () => {
+ const definition = { commandName: 'dance', keys: ['h', 'e', 'l', 'o'] };
+
+ hotkeysManager.registerHotkeys(definition);
+
+ expect(hotkeys.bind.mock.calls.length).toBe(definition.keys.length);
+ definition.keys.forEach((key, i) =>
+ expect(hotkeys.bind.mock.calls[i][0]).toBe(key)
+ );
+ });
+ it('calls hotkeys.unbind if commandName was previously registered, for each previously registered set of keys', () => {
+ const firstDefinition = {
+ commandName: 'dance',
+ keys: ['h', 'e', 'l', 'o'],
+ };
+ const secondDefinition = { commandName: 'dance', keys: 'a' };
+
+ // First call
+ hotkeysManager.registerHotkeys(firstDefinition);
+ // Second call
+ hotkeysManager.registerHotkeys(secondDefinition);
+
+ expect(hotkeys.unbind.mock.calls.length).toBe(
+ firstDefinition.keys.length
+ );
+ firstDefinition.keys.forEach((key, i) =>
+ expect(hotkeys.unbind.mock.calls[i][0]).toBe(key)
+ );
+ });
+ });
+
+ describe('restoreDefaults()', () => {
+ it('calls setHotkeys with hotkey defaults', () => {
+ hotkeysManager.setHotkeys = jest.fn();
+
+ hotkeysManager.restoreDefaultBindings();
+
+ expect(hotkeysManager.setHotkeys.mock.calls[0][0]).toEqual(
+ hotkeysManager.hotkeyDefaults
+ );
+ });
+ });
+
+ describe('destroy()', () => {
+ it('clears default and definition properties', () => {
+ hotkeysManager.hotkeyDefaults = ['hotdog', 'jeremy', 'qasar'];
+ hotkeysManager.hotkeyDefinitions = {
+ hello: 'world',
+ };
+
+ hotkeysManager.destroy();
+
+ expect(hotkeysManager.hotkeyDefaults).toEqual([]);
+ expect(hotkeysManager.hotkeyDefinitions).toEqual({});
+ });
+ it('resets all hotkey bindings', () => {
+ hotkeysManager.destroy();
+
+ expect(hotkeys.reset.mock.calls.length).toEqual(1);
+ });
+ });
+});
diff --git a/packages/_core/src/classes/ImageSet.js b/packages/_core/src/classes/ImageSet.js
new file mode 100644
index 000000000..d97c7cb56
--- /dev/null
+++ b/packages/_core/src/classes/ImageSet.js
@@ -0,0 +1,68 @@
+import guid from '../utils/guid.js';
+import OHIFError from './OHIFError';
+
+const OBJECT = 'object';
+
+/**
+ * This class defines an ImageSet object which will be used across the viewer. This object represents
+ * a list of images that are associated by any arbitrary criteria being thus content agnostic. Besides the
+ * main attributes (images and uid) it allows additional attributes to be appended to it (currently
+ * indiscriminately, but this should be changed).
+ */
+class ImageSet {
+ constructor(images) {
+ if (Array.isArray(images) !== true) {
+ throw new OHIFError('ImageSet expects an array of images');
+ }
+
+ // @property "images"
+ Object.defineProperty(this, 'images', {
+ enumerable: false,
+ configurable: false,
+ writable: false,
+ value: images,
+ });
+
+ // @property "uid"
+ Object.defineProperty(this, 'uid', {
+ enumerable: false,
+ configurable: false,
+ writable: false,
+ value: guid(), // Unique ID of the instance
+ });
+ }
+
+ getUID() {
+ return this.uid;
+ }
+
+ setAttribute(attribute, value) {
+ this[attribute] = value;
+ }
+
+ getAttribute(attribute) {
+ return this[attribute];
+ }
+
+ setAttributes(attributes) {
+ if (typeof attributes === OBJECT && attributes !== null) {
+ const imageSet = this,
+ hasOwn = Object.prototype.hasOwnProperty;
+ for (let attribute in attributes) {
+ if (hasOwn.call(attributes, attribute)) {
+ imageSet[attribute] = attributes[attribute];
+ }
+ }
+ }
+ }
+
+ getImage(index) {
+ return this.images[index];
+ }
+
+ sortBy(sortingCallback) {
+ return this.images.sort(sortingCallback);
+ }
+}
+
+export default ImageSet;
diff --git a/packages/_core/src/classes/MetadataProvider.js b/packages/_core/src/classes/MetadataProvider.js
new file mode 100644
index 000000000..f8ba427fe
--- /dev/null
+++ b/packages/_core/src/classes/MetadataProvider.js
@@ -0,0 +1,412 @@
+import { parsingUtils } from '../lib/parsingUtils';
+
+const FUNCTION = 'function';
+
+class MetadataProvider {
+ constructor() {
+ // Define the main "metadataLookup" private property as an immutable property.
+ Object.defineProperty(this, 'metadataLookup', {
+ configurable: false,
+ enumerable: false,
+ writable: false,
+ value: new Map(),
+ });
+
+ // Local reference to provider function bound to current instance.
+ Object.defineProperty(this, '_provider', {
+ configurable: false,
+ enumerable: false,
+ writable: true,
+ value: null,
+ });
+ }
+
+ /**
+ * Cornerstone Metadata provider to store image meta data
+ * Data from instances, series, and studies are associated with
+ * imageIds to facilitate usage of this information by Cornerstone's Tools
+ *
+ * e.g. the imagePlane metadata object contains instance information about
+ * row/column pixel spacing, patient position, and patient orientation. It
+ * is used in CornerstoneTools to position reference lines and orientation markers.
+ *
+ * @param {String} imageId The Cornerstone ImageId
+ * @param {Object} data An object containing instance, series, and study metadata
+ */
+ addMetadata(imageId, data) {
+ const instanceMetadata = data.instance;
+ const seriesMetadata = data.series;
+ const studyMetadata = data.study;
+ const numImages = data.numImages;
+ const metadata = {};
+
+ metadata.frameNumber = data.frameNumber;
+
+ metadata.study = {
+ accessionNumber: studyMetadata.accessionNumber,
+ patientId: studyMetadata.patientId,
+ studyInstanceUid: studyMetadata.studyInstanceUid,
+ studyDate: studyMetadata.studyDate,
+ studyTime: studyMetadata.studyTime,
+ studyDescription: studyMetadata.studyDescription,
+ institutionName: studyMetadata.institutionName,
+ patientHistory: studyMetadata.patientHistory,
+ };
+
+ metadata.series = {
+ seriesDescription: seriesMetadata.seriesDescription,
+ seriesNumber: seriesMetadata.seriesNumber,
+ seriesDate: seriesMetadata.seriesDate,
+ seriesTime: seriesMetadata.seriesTime,
+ modality: seriesMetadata.modality,
+ seriesInstanceUid: seriesMetadata.seriesInstanceUid,
+ numImages: numImages,
+ };
+
+ metadata.instance = instanceMetadata;
+
+ metadata.patient = {
+ name: studyMetadata.patientName,
+ id: studyMetadata.patientId,
+ birthDate: studyMetadata.patientBirthDate,
+ sex: studyMetadata.patientSex,
+ age: studyMetadata.patientAge,
+ };
+
+ // If there is sufficient information, populate
+ // the imagePlane object for easier use in the Viewer
+ metadata.imagePlane = this.getImagePlane(instanceMetadata);
+
+ // Add the metadata to the imageId lookup object
+ this.metadataLookup.set(imageId, metadata);
+ }
+
+ /**
+ * Return the metadata for the given imageId
+ * @param {String} imageId The Cornerstone ImageId
+ * @returns image metadata
+ */
+ getMetadata(imageId) {
+ return this.metadataLookup.get(imageId);
+ }
+
+ /**
+ * Adds a set of metadata to the Cornerstone metadata provider given a specific
+ * imageId, type, and dataset
+ *
+ * @param imageId
+ * @param type (e.g. series, instance, tagDisplay)
+ * @param data
+ */
+ addSpecificMetadata(imageId, type, data) {
+ const metadata = {};
+ metadata[type] = data;
+
+ const oldMetadata = this.metadataLookup.get(imageId);
+ this.metadataLookup.set(imageId, Object.assign(oldMetadata, metadata));
+ }
+
+ getFromImage(image, type, tag, attrName, defaultValue) {
+ let value;
+
+ if (image.data) {
+ value = this.getFromDataSet(image.data, type, tag);
+ } else {
+ value = image.instance[attrName];
+ }
+
+ return value === null ? defaultValue : value;
+ }
+
+ getFromDataSet(dataSet, type, tag) {
+ if (!dataSet) {
+ return;
+ }
+
+ const fn = dataSet[type];
+ if (!fn) {
+ return;
+ }
+
+ return fn.call(dataSet, tag);
+ }
+
+ getFrameIncrementPointer(image) {
+ const dataSet = image.data;
+ let frameInstancePointer = '';
+
+ if (parsingUtils.isValidDataSet(dataSet)) {
+ const frameInstancePointerNames = {
+ x00181063: 'frameTime',
+ x00181065: 'frameTimeVector',
+ };
+
+ // (0028,0009) = Frame Increment Pointer
+ const frameInstancePointerTag = parsingUtils.attributeTag(
+ dataSet,
+ 'x00280009'
+ );
+ frameInstancePointer = frameInstancePointerNames[frameInstancePointerTag];
+ } else {
+ frameInstancePointer = image.instance.frameIncrementPointer;
+ }
+
+ return frameInstancePointer || '';
+ }
+
+ getFrameTimeVector(image) {
+ const dataSet = image.data;
+
+ if (parsingUtils.isValidDataSet(dataSet)) {
+ // Frame Increment Pointer points to Frame Time Vector (0018,1065) field
+ return parsingUtils.floatArray(dataSet, 'x00181065');
+ }
+
+ return image.instance.frameTimeVector;
+ }
+
+ getFrameTime(image) {
+ const dataSet = image.data;
+
+ if (parsingUtils.isValidDataSet(dataSet)) {
+ // Frame Increment Pointer points to Frame Time (0018,1063) field or is not defined (for addtional flexibility).
+ // Yet another value is possible for this field (5200,9230 for Multi-frame Functional Groups)
+ // but that case is currently not supported.
+ return dataSet.floatString('x00181063', -1);
+ }
+
+ return image.instance.frameTime;
+ }
+
+ /**
+ * Updates the related metadata for missing fields given a specified image
+ *
+ * @param image
+ */
+ updateMetadata(image) {
+ const imageMetadata = this.metadataLookup.get(image.imageId);
+ if (!imageMetadata) {
+ return;
+ }
+
+ imageMetadata.patient.age =
+ imageMetadata.patient.age ||
+ this.getFromDataSet(image.data, 'string', 'x00101010');
+
+ imageMetadata.instance.rows = imageMetadata.instance.rows || image.rows;
+ imageMetadata.instance.columns =
+ imageMetadata.instance.columns || image.columns;
+
+ imageMetadata.instance.sopClassUid =
+ imageMetadata.instance.sopClassUid ||
+ this.getFromDataSet(image.data, 'string', 'x00080016');
+ imageMetadata.instance.sopInstanceUid =
+ imageMetadata.instance.sopInstanceUid ||
+ this.getFromDataSet(image.data, 'string', 'x00080018');
+
+ imageMetadata.instance.pixelSpacing =
+ imageMetadata.instance.pixelSpacing ||
+ this.getFromDataSet(image.data, 'string', 'x00280030');
+ imageMetadata.instance.frameOfReferenceUID =
+ imageMetadata.instance.frameOfReferenceUID ||
+ this.getFromDataSet(image.data, 'string', 'x00200052');
+ imageMetadata.instance.imageOrientationPatient =
+ imageMetadata.instance.imageOrientationPatient ||
+ this.getFromDataSet(image.data, 'string', 'x00200037');
+ imageMetadata.instance.imagePositionPatient =
+ imageMetadata.instance.imagePositionPatient ||
+ this.getFromDataSet(image.data, 'string', 'x00200032');
+
+ imageMetadata.instance.sliceThickness =
+ imageMetadata.instance.sliceThickness ||
+ this.getFromDataSet(image.data, 'string', 'x00180050');
+ imageMetadata.instance.sliceLocation =
+ imageMetadata.instance.sliceLocation ||
+ this.getFromDataSet(image.data, 'string', 'x00201041');
+ imageMetadata.instance.tablePosition =
+ imageMetadata.instance.tablePosition ||
+ this.getFromDataSet(image.data, 'string', 'x00189327');
+ imageMetadata.instance.spacingBetweenSlices =
+ imageMetadata.instance.spacingBetweenSlices ||
+ this.getFromDataSet(image.data, 'string', 'x00180088');
+
+ imageMetadata.instance.lossyImageCompression =
+ imageMetadata.instance.lossyImageCompression ||
+ this.getFromDataSet(image.data, 'string', 'x00282110');
+ imageMetadata.instance.lossyImageCompressionRatio =
+ imageMetadata.instance.lossyImageCompressionRatio ||
+ this.getFromDataSet(image.data, 'string', 'x00282112');
+
+ imageMetadata.instance.frameIncrementPointer =
+ imageMetadata.instance.frameIncrementPointer ||
+ this.getFromDataSet(image.data, 'string', 'x00280009');
+ imageMetadata.instance.frameTime =
+ imageMetadata.instance.frameTime ||
+ this.getFromDataSet(image.data, 'string', 'x00181063');
+ imageMetadata.instance.frameTimeVector =
+ imageMetadata.instance.frameTimeVector ||
+ this.getFromDataSet(image.data, 'string', 'x00181065');
+
+ if (
+ (image.data || image.instance) &&
+ !imageMetadata.instance.multiframeMetadata
+ ) {
+ imageMetadata.instance.multiframeMetadata = this.getMultiframeModuleMetadata(
+ image
+ );
+ }
+
+ imageMetadata.imagePlane =
+ imageMetadata.imagePlane || this.getImagePlane(imageMetadata.instance);
+ }
+
+ /**
+ * Constructs and returns the imagePlane given the metadata instance
+ *
+ * @param metadataInstance The metadata instance (InstanceMetadata class) containing information to construct imagePlane
+ * @returns imagePlane The constructed imagePlane to be used in viewer easily
+ */
+ getImagePlane(instance) {
+ if (
+ !instance.rows ||
+ !instance.columns ||
+ !instance.pixelSpacing ||
+ !instance.frameOfReferenceUID ||
+ !instance.imageOrientationPatient ||
+ !instance.imagePositionPatient
+ ) {
+ return;
+ }
+
+ const imageOrientation = instance.imageOrientationPatient.split('\\');
+ const imagePosition = instance.imagePositionPatient.split('\\');
+
+ let columnPixelSpacing = 1.0;
+ let rowPixelSpacing = 1.0;
+ if (instance.pixelSpacing) {
+ const split = instance.pixelSpacing.split('\\');
+ rowPixelSpacing = parseFloat(split[0]);
+ columnPixelSpacing = parseFloat(split[1]);
+ }
+
+ return {
+ frameOfReferenceUID: instance.frameOfReferenceUID,
+ rows: instance.rows,
+ columns: instance.columns,
+ rowCosines: [
+ parseFloat(imageOrientation[0]),
+ parseFloat(imageOrientation[1]),
+ parseFloat(imageOrientation[2]),
+ ],
+ columnCosines: [
+ parseFloat(imageOrientation[3]),
+ parseFloat(imageOrientation[4]),
+ parseFloat(imageOrientation[5]),
+ ],
+ imagePositionPatient: [
+ parseFloat(imagePosition[0]),
+ parseFloat(imagePosition[1]),
+ parseFloat(imagePosition[2]),
+ ],
+ rowPixelSpacing,
+ columnPixelSpacing,
+ };
+ }
+
+ /**
+ * This function extracts miltiframe information from a dicomParser.DataSet object.
+ *
+ * @param dataSet {Object} An instance of dicomParser.DataSet object where multiframe information can be found.
+ * @return {Object} An object containing multiframe image metadata (frameIncrementPointer, frameTime, frameTimeVector, etc).
+ */
+ getMultiframeModuleMetadata(image) {
+ const imageInfo = {
+ isMultiframeImage: false,
+ frameIncrementPointer: null,
+ numberOfFrames: 0,
+ frameTime: 0,
+ frameTimeVector: null,
+ averageFrameRate: 0, // backwards compatibility only... it might be useless in the future
+ };
+
+ let frameTime;
+
+ const numberOfFrames = this.getFromImage(
+ image,
+ 'intString',
+ 'x00280008',
+ 'numberOfFrames',
+ -1
+ );
+
+ if (numberOfFrames > 0) {
+ // set multi-frame image indicator
+ imageInfo.isMultiframeImage = true;
+ imageInfo.numberOfFrames = numberOfFrames;
+
+ // (0028,0009) = Frame Increment Pointer
+ const frameIncrementPointer = this.getFrameIncrementPointer(image);
+
+ if (frameIncrementPointer === 'frameTimeVector') {
+ // Frame Increment Pointer points to Frame Time Vector (0018,1065) field
+ const frameTimeVector = this.getFrameTimeVector(image);
+
+ if (frameTimeVector instanceof Array && frameTimeVector.length > 0) {
+ imageInfo.frameIncrementPointer = frameIncrementPointer;
+ imageInfo.frameTimeVector = frameTimeVector;
+ frameTime =
+ frameTimeVector.reduce((a, b) => a + b) / frameTimeVector.length;
+ imageInfo.averageFrameRate = 1000 / frameTime;
+ }
+ } else if (
+ frameIncrementPointer === 'frameTime' ||
+ frameIncrementPointer === ''
+ ) {
+ frameTime = this.getFrameTime(image);
+
+ if (frameTime > 0) {
+ imageInfo.frameIncrementPointer = frameIncrementPointer;
+ imageInfo.frameTime = frameTime;
+ imageInfo.averageFrameRate = 1000 / frameTime;
+ }
+ }
+ }
+
+ return imageInfo;
+ }
+
+ /**
+ * Get a bound reference to the provider function.
+ */
+ getProvider() {
+ let provider = this._provider;
+ if (typeof this._provider !== FUNCTION) {
+ provider = this.provider.bind(this);
+ this._provider = provider;
+ }
+
+ return provider;
+ }
+
+ /**
+ * Looks up metadata for Cornerstone Tools given a specified type and imageId
+ * A type may be, e.g. 'study', or 'patient', or 'imagePlaneModule'. These types
+ * are keys in the stored metadata objects.
+ *
+ * @param type
+ * @param imageId
+ * @returns {Object} Relevant metadata of the specified type
+ */
+ provider(type, imageId) {
+ const imageMetadata = this.metadataLookup.get(imageId);
+ if (!imageMetadata) {
+ return;
+ }
+
+ if (imageMetadata.hasOwnProperty(type)) {
+ return imageMetadata[type];
+ }
+ }
+}
+
+export default MetadataProvider;
diff --git a/packages/_core/src/classes/OHIFError.js b/packages/_core/src/classes/OHIFError.js
new file mode 100644
index 000000000..ff6068a0f
--- /dev/null
+++ b/packages/_core/src/classes/OHIFError.js
@@ -0,0 +1,14 @@
+// @TODO: improve this object
+/**
+ * Objects to be used to throw errors
+ */
+class OHIFError extends Error {
+ constructor(message) {
+ super();
+ this.message = message;
+ this.stack = new Error().stack;
+ this.name = this.constructor.name;
+ }
+}
+
+export default OHIFError;
diff --git a/packages/_core/src/classes/OHIFStudyMetadataSource.js b/packages/_core/src/classes/OHIFStudyMetadataSource.js
new file mode 100644
index 000000000..f78a5e6c1
--- /dev/null
+++ b/packages/_core/src/classes/OHIFStudyMetadataSource.js
@@ -0,0 +1,81 @@
+import { studyMetadataManager, updateMetaDataManager } from '../utils';
+
+import OHIFError from './OHIFError';
+import { StudyMetadata } from './metadata/StudyMetadata';
+import { StudyMetadataSource } from './StudyMetadataSource.js';
+import { StudySummary } from './metadata/StudySummary';
+import { retrieveStudyMetadata } from '../studies/retrieveStudyMetadata.js';
+
+export class OHIFStudyMetadataSource extends StudyMetadataSource {
+ /**
+ * Get study metadata for a study with given study InstanceUID
+ * @param server
+ * @param {String} studyInstanceUID Study InstanceUID
+ * @return {Promise} A Promise object
+ */
+ getByInstanceUID(server, studyInstanceUID) {
+ return retrieveStudyMetadata(server, studyInstanceUID);
+ }
+
+ /**
+ * Load study info (OHIF.viewer.Studies) and study metadata (OHIF.viewer.StudyMetadataList) for a given study.
+ * @param {StudySummary|StudyMetadata} study of StudySummary or StudyMetadata object.
+ */
+ loadStudy(study) {
+ if (!(study instanceof StudyMetadata) && !(study instanceof StudySummary)) {
+ throw new OHIFError(
+ 'OHIFStudyMetadataSource::loadStudy study is not an instance of StudySummary or StudyMetadata'
+ );
+ }
+
+ return new Promise((resolve, reject) => {
+ const studyInstanceUID = study.getStudyInstanceUID();
+
+ if (study instanceof StudyMetadata) {
+ const alreadyLoaded = OHIF.viewer.Studies.findBy({
+ studyInstanceUid: studyInstanceUID,
+ });
+
+ if (!alreadyLoaded) {
+ OHIFStudyMetadataSource._updateStudyCollections(study);
+ }
+
+ resolve(study);
+ return;
+ }
+
+ this.getByInstanceUID(studyInstanceUID)
+ .then(studyInfo => {
+ // Create study metadata object
+ const studyMetadata = new StudyMetadata(
+ studyInfo,
+ studyInfo.studyInstanceUid
+ );
+
+ // Get Study display sets
+ const displaySets = studyMetadata.createDisplaySets();
+
+ // Set studyMetadata display sets
+ studyMetadata.setDisplaySets(displaySets);
+
+ OHIFStudyMetadataSource._updateStudyCollections(studyMetadata);
+ resolve(studyMetadata);
+ })
+ .catch(reject);
+ });
+ }
+
+ // Static methods
+ static _updateStudyCollections(studyMetadata) {
+ const studyInfo = studyMetadata.getData();
+
+ // Set some studyInfo properties
+ studyInfo.selected = true;
+ studyInfo.displaySets = studyMetadata.getDisplaySets();
+
+ // Updates WADO-RS metaDataManager
+ updateMetaDataManager(studyInfo);
+
+ studyMetadataManager.add(studyMetadata);
+ }
+}
diff --git a/packages/_core/src/classes/OHIFStudySummary.js b/packages/_core/src/classes/OHIFStudySummary.js
new file mode 100644
index 000000000..e40072a77
--- /dev/null
+++ b/packages/_core/src/classes/OHIFStudySummary.js
@@ -0,0 +1,55 @@
+import StudySummary from './StudySummary';
+
+/**
+ * Constants
+ */
+
+const STRING = 'string';
+const propertyReplacementMap = {
+ modalities: 'ModalitiesInStudy',
+ patientBirthdate: 'PatientBirthDate',
+};
+
+/**
+ * OHIF Viewers specialized version of StudySummary class
+ */
+export class OHIFStudySummary extends StudySummary {
+ // @Override
+ addTags(tagMap) {
+ const _hasOwn = Object.prototype.hasOwnProperty;
+ const _tagMap = Object.create(null);
+ for (let property in tagMap) {
+ if (_hasOwn.call(tagMap, property)) {
+ let standardProperty = OHIFStudySummary.getStandardPropertyName(
+ property
+ );
+ if (standardProperty) {
+ _tagMap[standardProperty] = tagMap[property];
+ }
+ }
+ }
+ super.addTags(_tagMap);
+ }
+
+ /**
+ * Turns a non-standard, OHIF specific, DICOM property name into a standard one.
+ * @param {string} property A string representing a non-conforming keyword.
+ * @returns {string|undefined} Returns a standard-conforming property name.
+ */
+ static getStandardPropertyName(property) {
+ let standardProperty;
+ if (typeof property === STRING && property.charAt(0) !== '_') {
+ if (property in propertyReplacementMap) {
+ standardProperty = propertyReplacementMap[propertyReplacementMap];
+ } else {
+ standardProperty = property
+ .replace(/^sop/, 'SOP')
+ .replace(/Uid$/, 'UID')
+ .replace(/Id$/, 'ID');
+ standardProperty =
+ standardProperty.charAt(0).toUpperCase() + standardProperty.substr(1);
+ }
+ }
+ return standardProperty;
+ }
+}
diff --git a/packages/_core/src/classes/README.md b/packages/_core/src/classes/README.md
new file mode 100644
index 000000000..a93a1ea60
--- /dev/null
+++ b/packages/_core/src/classes/README.md
@@ -0,0 +1,167 @@
+# Table of contents
+In this document, some important objects are described. In the files there are comments that can help better undestand their methods and properties.
+ - [ResizeViewportManager object](#the-resize-viewport-manager-object)
+ - [ImageSet object](#the-image-set-object)
+ - [Layout Manager](#the-layout-manager-object)
+ - [Type Safe Collections](#the-type-safe-collections)
+
+# The Resize Viewport Manager object
+This object has multiple functions to manage window resize event. It relocates Dialogs, resizes viewport elements and scrollbars and some other UI components such as Study and Series Quick Switch, when available.
+
+## Usage
+It's only necessary to bind **handleResize** function to the window resize event as follows. The **ohif:viewerbase** package needs to be imported by the referring code as well.
+```javascript
+import { Viewerbase } from 'meteor/ohif:viewerbase';
+
+const ResizeViewportManager = new Viewerbase.ResizeViewportManager();
+window.addEventListener('resize', ResizeViewportManager.getResizeHandler());
+```
+An example os its usage can be found in **ohif-viewerbase/client/components/viewer/viewerMain/viewerMain.js**.
+
+# The Image Set object
+An object that represents a list of images that are associated by any arbitrary criteria being thus content agnostic. Besides the main attributes (**images** and **uid**) it allows additional attributes to be appended to it (currently indiscriminately, but this should be changed).
+
+## Usage
+ImageSet constructor requires an array of SOP instances like in the example below. It's necessary to import **ohif:viewerbase**.
+
+```javascript
+import { Viewerbase } from 'meteor/ohif:viewerbase';
+
+const imageSet = new Viewerbase.ImageSet(sopInstances);
+
+imageSet.setAttributes({
+ displaySetInstanceUid: imageSet.uid,
+ seriesInstanceUid: seriesData.seriesInstanceUid,
+ seriesNumber: seriesData.seriesNumber,
+ seriesDescription: seriesData.seriesDescription,
+ numImageFrames: instances.length,
+ frameRate: instance.getRawValue('x00181063'),
+ modality: seriesData.modality,
+ isMultiFrame: isMultiFrame(instance)
+});
+
+// Sort instances by InstanceNumber (0020,0013)
+imageSet.sortBy((a, b) => {
+ return (parseInt(a.getRawValue('x00200013', 0)) || 0) - (parseInt(b.getRawValue('x00200013', 0)) || 0);
+});
+```
+Each SOP instance in this example is an instance of **OHIFInstanceMetadata** object, which is a specialization of **InstanceMetadata**. To read more about the **Metadata API** click [here](metadata/).
+
+# The Layout Manager object
+Objects of this class are responsible for creating, organizing and maintaining (manage) viewport rendering. It creates a grid, positioning viewports accordingly to it's configuration keeping all viewports data (in **viewportData** property) for easy access from other components. It support many layout configurations and some of them were fully tested: 1x1, 1x2, 1x3, 2x1, 2x2, 2x3, 3x1, 3x2, 3x3. Other configurations may work as well.
+Finally it provides some useful functions to move through viewports and zoom it.
+
+## Usage
+In order to use _LayoutManager_ the **ohif:viewerbase** package needs to be imported by the referring code and instantiated as follows. An example os its usage is in **ohif-viewerbase/client/components/viewer/viewerMain/viewerMain.js**.
+
+```javascript
+import { Viewerbase } from 'meteor/ohif:viewerbase';
+
+// Get an array of studies object. This function needs to be implemented, it does not exist.
+const studies = getArrayOfStudiesObjects();
+const parentElement = document.getElementById('layoutManagerTarget');
+const LayoutManager = new Viewerbase.LayoutManager(parentElement, studies);
+```
+
+The default configuration is 1x1, and to change it just set **layoutProps** and call **updateViewports** to update the layout as follows.
+
+```javascript
+import { Viewerbase } from 'meteor/ohif:viewerbase';
+
+// Get an array of studies object. This function needs to be implemented, it does not exist.
+const studies = getArrayOfStudiesObjects();
+const parentElement = document.getElementById('layoutManagerTarget');
+const LayoutManager = new LayoutManager(parentElement, studies);
+
+// Set the layout proprerties to 2x2 layout
+LayoutManager.layoutProps = {
+ rows: 2,
+ columns: 2
+};
+
+// It will render four viewports: two in each row.
+LayoutManager.updateViewports();
+```
+
+The layoutManagerTarget element will have a new class **layout-2-2** (to allow further styling) and it's inner content will a new div#imageViewerViewports that has four inner elements like the following (some elements and attributes were removed for example purpose):
+```html
+
+```
+
+Each of this _div.viewportContainer_ will have some classes to help CSS specific styling accordingly to the element's position in the grid: **top**, **middle** and **bottom**. This classes are added by **viewer/components/gridLayout/** component in ohif-viewerbase package.
+
+# The Type Safe Collections
+
+With the introduction of the new _Study Metadata API_ in which study metadata is represented by class hierarchies (using prototype-based inheritance), the usage of standard _Minimongo_ collections as a central client-side storage for this data became no longer an option. Standard _Mongo_ and _Minimongo_ collections internally _flatten_ data (in other words, data gets serialized) before storage hence no functions or prototype chains are preserved. In that scenario, when an object is restored (fetched), what is returned is actually a flattened copy of the original object with no functions or prototype (it's no longer an instance of it's original class). As an attempt to overcome this limitation a new type of collection was intruduced: the *TypeSafeCollection*.
+
+The `TypeSafeCollection` is a simple list-like collection which tries to implement an API _similar_ but not compatible with _Mongo_'s API. It supports basic features like search by attribute map and ID, retrieval by index, sorting of result sets, insertion, removal and reactive operations but, unlike _Mongo_'s API, it (still) lacks support to advanced functionality like complex search criterea or flexible sorting options.
+
+## Implementation
+
+The `TypeSafeCollection` is implemented on top of the _JavaScript_ `Array` object. Each element inserted in the collection is appended to the end of its internal array as a _key-value pair (KVP)_ object where the _key_ is a unique randomly generated ID string and the _value_ is the element itself. Once the object has been successfully stored, the generated ID (its ID) is returned to the client code and can later be used to access that specific element. At this point, an important difference to the _Minimongo_ API can be highlighted: a _TypeSafeCollection_ instance will never make any changes to the stored element (e.g., no "\_id" property will ever be assigned to the original object). Another relevant feature that is supported by this design decision is that _not only objects_ can be stored in this collections, but literally _anything_.
+
+Inside the codebase, the _value_ attribute of each _KVP_ entry in the collection is refered to as _the **payload** of the entry_ since it's what really matters to the user. Hence, this term will also be used here to refer to the _value that has been stored in the collection_. That being said, we can approach another important feature of these collections: A single _payload_ cannot be stored more than once in a given collection. When an attempt of inserting a _payload_ which is already present in the collection is detected, the insert operation will fail and `null` will be returned. In that regard, the collection behaves like `Set` object not permitting a payload to be stored more than once. Strict equality is used when comparing payloads, thus cloned objects are not considered the same. This feature adds an additional garantee that a given study/series/instance will not be listed more than once (it was designed as a replacement for central study collections which were always checked for duplicates).
+
+Please refer to the codebase for the full `TypeSafeCollection` API.
+
+## Usage
+
+In order to use the `TypeSafeCollection` class, the **ohif:viewerbase** package needs to be imported by the referring code and instantiated as follows:
+
+```javascript
+import { Viewerbase } from 'meteor/ohif:viewerbase'; // i.e., Viewerbase.TypeSafeCollection
+OR
+
+import 'meteor/ohif:viewerbase'; // i.e., OHIF.viewerbase.TypeSafeCollection
+// The later is preferred when the client code already makes use of the "OHIF" namespace making the second
+// "import" a garantee that the ".viewerbase" namespace has been properly loaded.
+```
+
+A few usage examples:
+
+```javascript
+
+const Users = new OHIF.viewerbase.TypeSafeCollection();
+
+[[ ... ]]
+
+// Insert a User object...
+let userId = Users.insert({
+ data: {
+ firstName: 'John',
+ lastName: 'Doe',
+ age: 45
+ },
+ getFullName() {
+ return `${this.data.firstName} ${this.data.lastName}`;
+ },
+ getAge() {
+ return this.data.age;
+ }
+});
+
+[[ ... ]]
+
+let theUserWeJustStored = Users.findById(userId); // ;-)
+
+[[ ... ]]
+
+// Retrieve a single user with "Doe" as `lastName`...
+let myUser = Users.findBy({ 'data.lastName': 'Doe' });
+// Or all users with "Doe" as `lastName`, sorted by `firstName` in ascending
+// order and using the `age` attribute to break ties in descending order...
+let myUsers = Users.findAllBy({ 'data.lastName': 'Doe' }, {
+ sort: [ [ 'data.firstName', 'asc' ], [ 'data.age', 'desc' ] ]
+});
+
+```
diff --git a/packages/_core/src/classes/StudyLoadingListener.js b/packages/_core/src/classes/StudyLoadingListener.js
new file mode 100644
index 000000000..9859bb056
--- /dev/null
+++ b/packages/_core/src/classes/StudyLoadingListener.js
@@ -0,0 +1,471 @@
+import cornerstone from 'cornerstone-core';
+import cornerstoneWADOImageLoader from 'cornerstone-wado-image-loader';
+import {
+ clearStudyLoadingProgress,
+ setStudyLoadingProgress,
+} from '../redux/actions';
+import StackManager from '../utils/StackManager';
+
+class BaseLoadingListener {
+ constructor(stack, options = {}) {
+ this.id = BaseLoadingListener.getNewId();
+ this.stack = stack;
+ this.startListening();
+ this.statsItemsLimit = options.statsItemsLimit || 2;
+ this.stats = {
+ items: [],
+ total: 0,
+ elapsedTime: 0,
+ speed: 0,
+ };
+
+ this._setProgressData = options._setProgressData;
+ this._clearProgressById = options._clearProgressById;
+
+ // Register the start point to make it possible to calculate
+ // bytes/s or frames/s when the first byte or frame is received
+ this._addStatsData(0);
+
+ // Update the progress before starting the download
+ // to make it possible to update the UI
+ this._updateProgress();
+ }
+
+ _addStatsData(value) {
+ const date = new Date();
+ const stats = this.stats;
+ const items = stats.items;
+ const newItem = {
+ value,
+ date,
+ };
+
+ items.push(newItem);
+ stats.total += newItem.value;
+
+ // Remove items until it gets below the limit
+ while (items.length > this.statsItemsLimit) {
+ const item = items.shift();
+ stats.total -= item.value;
+ }
+
+ // Update the elapsedTime (seconds) based on first and last
+ // elements and recalculate the speed (bytes/s or frames/s)
+ if (items.length > 1) {
+ const oldestItem = items[0];
+ stats.elapsedTime =
+ (newItem.date.getTime() - oldestItem.date.getTime()) / 1000;
+ stats.speed = (stats.total - oldestItem.value) / stats.elapsedTime;
+ }
+ }
+
+ _getProgressId() {
+ const displaySetInstanceUid = this.stack.displaySetInstanceUid;
+ return 'StackProgress:' + displaySetInstanceUid;
+ }
+
+ _clearProgress() {
+ const progressId = this._getProgressId();
+ this._clearProgressById(progressId);
+ }
+
+ startListening() {
+ throw new Error('`startListening` must be implemented by child classes');
+ }
+
+ stopListening() {
+ throw new Error('`stopListening` must be implemented by child classes');
+ }
+
+ destroy() {
+ this.stopListening();
+ this._clearProgress();
+ }
+
+ static getNewId() {
+ const timeSlice = new Date()
+ .getTime()
+ .toString()
+ .slice(-8);
+ const randomNumber = parseInt(Math.random() * 1000000000);
+
+ return timeSlice.toString() + randomNumber.toString();
+ }
+}
+
+class DICOMFileLoadingListener extends BaseLoadingListener {
+ constructor(stack, options) {
+ super(stack, options);
+ this._dataSetUrl = this._getDataSetUrl(stack);
+ this._lastLoaded = 0;
+
+ // Check how many instances has already been download (cached)
+ this._checkCachedData();
+ }
+
+ _checkCachedData() {
+ const dataSet = cornerstoneWADOImageLoader.wadouri.dataSetCacheManager.get(
+ this._dataSetUrl
+ );
+
+ if (dataSet) {
+ const dataSetLength = dataSet.byteArray.length;
+
+ this._updateProgress({
+ percentComplete: 100,
+ loaded: dataSetLength,
+ total: dataSetLength,
+ });
+ }
+ }
+
+ _getImageLoadProgressEventName() {
+ // TODO: Add this event as a constant in Cornerstone
+ return 'cornerstoneimageloadprogress.' + this.id;
+ }
+
+ startListening() {
+ const imageLoadProgressEventName = this._getImageLoadProgressEventName();
+
+ this.imageLoadProgressEventHandler = this._imageLoadProgressEventHandle.bind(
+ this
+ );
+
+ this.stopListening();
+
+ cornerstone.events.addEventListener(
+ imageLoadProgressEventName,
+ this.imageLoadProgressEventHandle
+ );
+ }
+
+ stopListening() {
+ const imageLoadProgressEventName = this._getImageLoadProgressEventName();
+ cornerstone.events.removeEventListener(
+ imageLoadProgressEventName,
+ this.imageLoadProgressEventHandle
+ );
+ }
+
+ _imageLoadProgressEventHandler = e => {
+ const eventData = e.detail;
+ const dataSetUrl = this._convertImageIdToDataSetUrl(eventData.imageId);
+ const bytesDiff = eventData.loaded - this._lastLoaded;
+
+ if (!this._dataSetUrl === dataSetUrl) {
+ return;
+ }
+
+ // Add the bytes downloaded to the stats
+ this._addStatsData(bytesDiff);
+
+ // Update the download progress
+ this._updateProgress(eventData);
+
+ // Cache the last eventData.loaded value
+ this._lastLoaded = eventData.loaded;
+ };
+
+ _updateProgress(eventData) {
+ const progressId = this._getProgressId();
+ eventData = eventData || {};
+
+ const progressData = {
+ multiFrame: false,
+ percentComplete: eventData.percentComplete,
+ bytesLoaded: eventData.loaded,
+ bytesTotal: eventData.total,
+ bytesPerSecond: this.stats.speed,
+ };
+
+ this._setProgressData(progressId, progressData);
+ }
+
+ _convertImageIdToDataSetUrl(imageId) {
+ // Remove the prefix ("dicomweb:" or "wadouri:"")
+ imageId = imageId.replace(/^(dicomweb:|wadouri:)/i, '');
+
+ // Remove "frame=999&" from the imageId
+ imageId = imageId.replace(/frame=\d+&?/i, '');
+
+ // Remove the last "&" like in "http://...?foo=1&bar=2&"
+ imageId = imageId.replace(/&$/, '');
+
+ return imageId;
+ }
+
+ _getDataSetUrl(stack) {
+ const imageId = stack.imageIds[0];
+ return this._convertImageIdToDataSetUrl(imageId);
+ }
+}
+
+class StackLoadingListener extends BaseLoadingListener {
+ constructor(stack, options = {}) {
+ options.statsItemsLimit = 20;
+ super(stack, options);
+
+ this.imageDataMap = this._convertImageIdsArrayToMap(stack.imageIds);
+ this.framesStatus = this._createArray(stack.imageIds.length, false);
+ this.loadedCount = 0;
+
+ // Check how many instances has already been download (cached)
+ this._checkCachedData();
+ }
+
+ _convertImageIdsArrayToMap(imageIds) {
+ const imageIdsMap = new Map();
+
+ for (let i = 0; i < imageIds.length; i++) {
+ imageIdsMap.set(imageIds[i], {
+ index: i,
+ loaded: false,
+ });
+ }
+
+ return imageIdsMap;
+ }
+
+ _createArray(length, defaultValue) {
+ // `new Array(length)` is an anti-pattern in javascript because its
+ // funny API. Otherwise I would go for `new Array(length).fill(false)`
+ const array = [];
+
+ for (let i = 0; i < length; i++) {
+ array[i] = defaultValue;
+ }
+
+ return array;
+ }
+
+ _checkCachedData() {
+ // const imageIds = this.stack.imageIds;
+ // TODO: No way to check status of Promise.
+ /*for(let i = 0; i < imageIds.length; i++) {
+ const imageId = imageIds[i];
+
+ const imagePromise = cornerstone.imageCache.getImageLoadObject(imageId).promise;
+
+ if (imagePromise && (imagePromise.state() === 'resolved')) {
+ this._updateFrameStatus(imageId, true);
+ }
+ }*/
+ }
+
+ _getImageLoadedEventName() {
+ return `${cornerstone.EVENTS.IMAGE_LOADED}.${this.id}`;
+ }
+
+ _getImageCachePromiseRemoveEventName() {
+ return `${cornerstone.EVENTS.IMAGE_CACHE_PROMISE_REMOVED}.${this.id}`;
+ }
+
+ _imageLoadedEventHandler(e) {
+ this._updateFrameStatus(e.detail.image.imageId, true);
+ }
+
+ _imageCachePromiseRemovedEventHandler(e) {
+ this._updateFrameStatus(e.detail.imageId, false);
+ }
+
+ startListening() {
+ const imageLoadedEventName = this._getImageLoadedEventName();
+ const imageCachePromiseRemovedEventName = this._getImageCachePromiseRemoveEventName();
+
+ this.imageLoadedEventHandler = this._imageLoadedEventHandler.bind(this);
+ this.imageCachePromiseRemovedEventHandler = this._imageCachePromiseRemovedEventHandler.bind(
+ this
+ );
+
+ this.stopListening();
+
+ cornerstone.events.addEventListener(
+ imageLoadedEventName,
+ this.imageLoadedEventHandler
+ );
+ cornerstone.events.addEventListener(
+ imageCachePromiseRemovedEventName,
+ this.imageCachePromiseRemovedEventHandler
+ );
+ }
+
+ stopListening() {
+ const imageLoadedEventName = this._getImageLoadedEventName();
+ const imageCachePromiseRemovedEventName = this._getImageCachePromiseRemoveEventName();
+
+ cornerstone.events.removeEventListener(
+ imageLoadedEventName,
+ this.imageLoadedEventHandler
+ );
+ cornerstone.events.removeEventListener(
+ imageCachePromiseRemovedEventName,
+ this.imageCachePromiseRemovedEventHandler
+ );
+ }
+
+ _updateFrameStatus(imageId, loaded) {
+ const imageData = this.imageDataMap.get(imageId);
+
+ if (!imageData || imageData.loaded === loaded) {
+ return;
+ }
+
+ // Add one more frame to the stats
+ if (loaded) {
+ this._addStatsData(1);
+ }
+
+ imageData.loaded = loaded;
+ this.framesStatus[imageData.index] = loaded;
+ this.loadedCount += loaded ? 1 : -1;
+ this._updateProgress();
+ }
+
+ _setProgressData(progressId, progressData) {
+ // TODO: This method (and _clearProgressById) need to access
+ // the Redux store and should therefore be provided from the
+ // application. I've added a workaround to pass this in through
+ // the 'options' variable on instantiation, but this is really ugly.
+ // We could consider making the StudyLoadingListener a higher-order
+ // component which would set this stuff itself.
+ throw new Error(
+ "The _setProgressData function must be provided in StudyLoadingListener's options"
+ );
+ }
+
+ _clearProgressById(progressId) {
+ throw new Error(
+ "The _clearProgressById function must be provided in StudyLoadingListener's options"
+ );
+ }
+
+ _updateProgress() {
+ const totalFramesCount = this.stack.imageIds.length;
+ const loadedFramesCount = this.loadedCount;
+ const loadingFramesCount = totalFramesCount - loadedFramesCount;
+ const percentComplete = Math.round(
+ (loadedFramesCount / totalFramesCount) * 100
+ );
+ const progressId = this._getProgressId();
+ const progressData = {
+ multiFrame: true,
+ totalFramesCount,
+ loadedFramesCount,
+ loadingFramesCount,
+ percentComplete,
+ framesPerSecond: this.stats.speed,
+ framesStatus: this.framesStatus,
+ };
+
+ this._setProgressData(progressId, progressData);
+ }
+
+ _logProgress() {
+ const totalFramesCount = this.stack.imageIds.length;
+ const displaySetInstanceUid = this.stack.displaySetInstanceUid;
+ let progressBar = '[';
+
+ for (let i = 0; i < totalFramesCount; i++) {
+ const ch = this.framesStatus[i] ? '|' : '.';
+ progressBar += `${ch}`;
+ }
+
+ progressBar += ']';
+ log.info(`${displaySetInstanceUid}: ${progressBar}`);
+ }
+}
+
+class StudyLoadingListener {
+ constructor(options) {
+ this.listeners = {};
+ this.options = options;
+ }
+
+ addStack(stack, stackMetaData) {
+ // TODO: Make this work for plugins
+ if (!stack) {
+ //console.log('Skipping adding stack to StudyLoadingListener');
+ return;
+ }
+
+ const displaySetInstanceUid = stack.displaySetInstanceUid;
+
+ if (!this.listeners[displaySetInstanceUid]) {
+ const listener = this._createListener(stack, stackMetaData);
+ if (listener) {
+ this.listeners[displaySetInstanceUid] = listener;
+ }
+ }
+ }
+
+ addStudy(study) {
+ study.displaySets.forEach(displaySet => {
+ const stack = StackManager.findOrCreateStack(study, displaySet);
+
+ // TODO: Make this work for plugins
+ if (!stack) {
+ console.warn('Skipping adding displaySet to StudyLoadingListener');
+ console.warn(displaySet);
+ return;
+ }
+
+ this.addStack(stack, {
+ isMultiFrame: displaySet.isMultiFrame,
+ });
+ });
+ }
+
+ addStudies(studies) {
+ if (!studies || !studies.length) {
+ return;
+ }
+
+ studies.forEach(study => this.addStudy(study));
+ }
+
+ clear() {
+ const displaySetInstanceUids = Object.keys(this.listeners);
+ const length = displaySetInstanceUids.length;
+
+ for (let i = 0; i < length; i++) {
+ const displaySetInstanceUid = displaySetInstanceUids[i];
+ const displaySet = this.listeners[displaySetInstanceUid];
+
+ displaySet.destroy();
+ }
+
+ this.listeners = {};
+ }
+
+ _createListener(stack, stackMetaData) {
+ const schema = this._getSchema(stack);
+
+ // A StackLoadingListener can be created if it's wadors or not a multiframe
+ // wadouri instance (single file) that means "N" files will have to be
+ // downloaded where "N" is the number of frames. DICOMFileLoadingListener
+ // is created only if it's a single DICOM file and there's no way to know
+ // how many frames has already been loaded (bytes/s instead of frames/s).
+ if (schema === 'wadors' || !stackMetaData.isMultiFrame) {
+ return new StackLoadingListener(stack, this.options);
+ } else {
+ return new DICOMFileLoadingListener(stack, this.options);
+ }
+ }
+
+ _getSchema(stack) {
+ const imageId = stack.imageIds[0];
+ const colonIndex = imageId.indexOf(':');
+ return imageId.substring(0, colonIndex);
+ }
+
+ // Singleton
+ static getInstance(options) {
+ if (!StudyLoadingListener._instance) {
+ StudyLoadingListener._instance = new StudyLoadingListener(options);
+ }
+
+ return StudyLoadingListener._instance;
+ }
+}
+
+export { StudyLoadingListener, StackLoadingListener, DICOMFileLoadingListener };
diff --git a/packages/_core/src/classes/StudyMetadataSource.js b/packages/_core/src/classes/StudyMetadataSource.js
new file mode 100644
index 000000000..8f4fb8dc3
--- /dev/null
+++ b/packages/_core/src/classes/StudyMetadataSource.js
@@ -0,0 +1,32 @@
+import OHIFError from './OHIFError';
+
+/**
+ * Abstract class to fetch study metadata.
+ */
+export class StudyMetadataSource {
+ /**
+ * Get study metadata for a study with given study InstanceUID.
+ * @param {String} studyInstanceUID Study InstanceUID.
+ */
+ getByInstanceUID(studyInstanceUID) {
+ /**
+ * Please override this method on a specialized class.
+ */
+ throw new OHIFError(
+ 'StudyMetadataSource::getByInstanceUID is not overriden. Please, override it in a specialized class. See OHIFStudyMetadataSource for example'
+ );
+ }
+
+ /**
+ * Load study info and study metadata for a given study into the viewer.
+ * @param {StudySummary|StudyMetadata} study of StudySummary or StudyMetadata object.
+ */
+ loadStudy(study) {
+ /**
+ * Please override this method on a specialized class.
+ */
+ throw new OHIFError(
+ 'StudyMetadataSource::loadStudy is not overriden. Please, override it in a specialized class. See OHIFStudyMetadataSource for example'
+ );
+ }
+}
diff --git a/packages/_core/src/classes/StudyPrefetcher.js b/packages/_core/src/classes/StudyPrefetcher.js
new file mode 100644
index 000000000..fc2c4afc8
--- /dev/null
+++ b/packages/_core/src/classes/StudyPrefetcher.js
@@ -0,0 +1,255 @@
+import log from '../log.js';
+import OHIFError from './OHIFError';
+import cornerstone from 'cornerstone-core';
+import cornerstoneTools from 'cornerstone-tools';
+import getImageId from '../utils/getImageId.js';
+
+export class StudyPrefetcher {
+ constructor(studies) {
+ this.studies = studies || [];
+ this.prefetchDisplaySetsTimeout = 300;
+ this.lastActiveViewportElement = null;
+
+ cornerstone.events.addEventListener(
+ 'cornerstoneimagecachefull.StudyPrefetcher',
+ this.cacheFullHandler
+ );
+ }
+
+ destroy() {
+ this.stopPrefetching();
+ cornerstone.events.removeEventListener(
+ 'cornerstoneimagecachefull.StudyPrefetcher',
+ this.cacheFullHandler
+ );
+ }
+
+ static getInstance() {
+ if (!StudyPrefetcher.instance) {
+ StudyPrefetcher.instance = new StudyPrefetcher([]);
+ }
+
+ return StudyPrefetcher.instance;
+ }
+
+ setStudies(studies) {
+ this.stopPrefetching();
+ this.studies = studies;
+ }
+
+ prefetch() {
+ if (!this.studies || !this.studies.length) {
+ return;
+ }
+
+ this.stopPrefetching();
+ this.prefetchDisplaySets();
+ }
+
+ stopPrefetching() {
+ cornerstoneTools.requestPoolManager.clearRequestStack('prefetch');
+ }
+
+ prefetchDisplaySetsAsync(timeout) {
+ timeout = timeout || this.prefetchDisplaySetsTimeout;
+
+ clearTimeout(this.prefetchDisplaySetsHandler);
+ this.prefetchDisplaySetsHandler = setTimeout(() => {
+ this.prefetchDisplaySets();
+ }, timeout);
+ }
+
+ prefetchDisplaySets() {
+ // TODO: Allow passing in config
+ let config = {
+ order: 'closest',
+ displaySetCount: 1,
+ };
+
+ const displaySetsToPrefetch = this.getDisplaySetsToPrefetch(config);
+ const imageIds = this.getImageIdsFromDisplaySets(displaySetsToPrefetch);
+
+ this.prefetchImageIds(imageIds);
+ }
+
+ prefetchImageIds(imageIds) {
+ const nonCachedImageIds = this.filterCachedImageIds(imageIds);
+ const requestPoolManager = cornerstoneTools.requestPoolManager;
+ const requestType = 'prefetch';
+ const preventCache = false;
+ const noop = () => {};
+
+ nonCachedImageIds.forEach(imageId => {
+ requestPoolManager.addRequest(
+ {},
+ imageId,
+ requestType,
+ preventCache,
+ noop,
+ noop
+ );
+ });
+
+ requestPoolManager.startGrabbing();
+ }
+
+ getStudy(image) {
+ const studyMetadata = cornerstone.metaData.get('study', image.imageId);
+ return OHIF.viewer.Studies.find(
+ study => study.studyInstanceUid === studyMetadata.studyInstanceUid
+ );
+ }
+
+ getSeries(study, image) {
+ const seriesMetadata = cornerstone.metaData.get('series', image.imageId);
+ const studyMetadata = OHIF.viewerbase.getStudyMetadata(study);
+
+ return studyMetadata.getSeriesByUID(seriesMetadata.seriesInstanceUid);
+ }
+
+ getInstance(series, image) {
+ const instanceMetadata = cornerstone.metaData.get(
+ 'instance',
+ image.imageId
+ );
+ return series.getInstanceByUID(instanceMetadata.sopInstanceUid);
+ }
+
+ getActiveDisplaySet(displaySets, instance) {
+ return displaySets.find(displaySet => {
+ return displaySet.images.some(displaySetImage => {
+ return displaySetImage.sopInstanceUid === instance.sopInstanceUid;
+ });
+ });
+ }
+
+ getDisplaySetsToPrefetch(config) {
+ const image = this.getActiveViewportImage();
+
+ if (!image || !config || !config.displaySetCount) {
+ return [];
+ }
+
+ /*const study = this.getStudy(image);
+ const series = this.getSeries(study, image);
+ const instance = this.getInstance(series, image);*/
+ const displaySets = study.displaySets;
+ const activeDisplaySet = null; //this.getActiveDisplaySet(displaySets, instance);
+ const prefetchMethodMap = {
+ topdown: 'getFirstDisplaySets',
+ downward: 'getNextDisplaySets',
+ closest: 'getClosestDisplaySets',
+ };
+
+ const prefetchOrder = config.order;
+ const methodName = prefetchMethodMap[prefetchOrder];
+ const getDisplaySets = this[methodName];
+
+ if (!getDisplaySets) {
+ if (prefetchOrder) {
+ log.warn(`Invalid prefetch order configuration (${prefetchOrder})`);
+ }
+
+ return [];
+ }
+
+ return getDisplaySets.call(
+ this,
+ displaySets,
+ activeDisplaySet,
+ config.displaySetCount
+ );
+ }
+
+ getFirstDisplaySets(displaySets, activeDisplaySet, displaySetCount) {
+ const length = displaySets.length;
+ const selectedDisplaySets = [];
+
+ for (let i = 0; i < length && displaySetCount; i++) {
+ const displaySet = displaySets[i];
+
+ if (displaySet !== activeDisplaySet) {
+ selectedDisplaySets.push(displaySet);
+ displaySetCount--;
+ }
+ }
+
+ return selectedDisplaySets;
+ }
+
+ getNextDisplaySets(displaySets, activeDisplaySet, displaySetCount) {
+ const activeDisplaySetIndex = displaySets.indexOf(activeDisplaySet);
+ const begin = activeDisplaySetIndex + 1;
+ const end = Math.min(begin + displaySetCount, displaySets.length);
+
+ return displaySets.slice(begin, end);
+ }
+
+ getClosestDisplaySets(displaySets, activeDisplaySet, displaySetCount) {
+ const activeDisplaySetIndex = displaySets.indexOf(activeDisplaySet);
+ const length = displaySets.length;
+ const selectedDisplaySets = [];
+ let left = activeDisplaySetIndex - 1;
+ let right = activeDisplaySetIndex + 1;
+
+ while ((left >= 0 || right < length) && displaySetCount) {
+ if (left >= 0) {
+ selectedDisplaySets.push(displaySets[left]);
+ displaySetCount--;
+ left--;
+ }
+
+ if (right < length && displaySetCount) {
+ selectedDisplaySets.push(displaySets[right]);
+ displaySetCount--;
+ right++;
+ }
+ }
+
+ return selectedDisplaySets;
+ }
+
+ getImageIdsFromDisplaySets(displaySets) {
+ let imageIds = [];
+
+ displaySets.forEach(displaySet => {
+ imageIds = imageIds.concat(this.getImageIdsFromDisplaySet(displaySet));
+ });
+
+ return imageIds;
+ }
+
+ getImageIdsFromDisplaySet(displaySet) {
+ const imageIds = [];
+
+ // TODO: This duplicates work done by the stack manager
+ displaySet.images.forEach(image => {
+ const numFrames = image.numFrames;
+ if (numFrames > 1) {
+ for (let i = 0; i < numFrames; i++) {
+ let imageId = getImageId(image, i);
+ imageIds.push(imageId);
+ }
+ } else {
+ let imageId = getImageId(image);
+ imageIds.push(imageId);
+ }
+ });
+
+ return imageIds;
+ }
+
+ filterCachedImageIds(imageIds) {
+ return imageIds.filter(imageId => !this.isImageCached(imageId));
+ }
+
+ isImageCached(imageId) {
+ const image = cornerstone.imageCache.imageCache[imageId];
+ return image && image.sizeInBytes;
+ }
+
+ cacheFullHandler = () => {
+ log.warn('Cache full');
+ this.stopPrefetching();
+ };
+}
diff --git a/packages/_core/src/classes/TypeSafeCollection.js b/packages/_core/src/classes/TypeSafeCollection.js
new file mode 100644
index 000000000..29eea0fad
--- /dev/null
+++ b/packages/_core/src/classes/TypeSafeCollection.js
@@ -0,0 +1,508 @@
+import guid from '../utils/guid';
+
+/**
+ * Constants
+ */
+const PROPERTY_SEPARATOR = '.';
+const ORDER_ASC = 'asc';
+const ORDER_DESC = 'desc';
+const MIN_COUNT = 0x00000000;
+const MAX_COUNT = 0x7fffffff;
+
+/**
+ * Class Definition
+ */
+export class TypeSafeCollection {
+ constructor() {
+ this._operationCount = MIN_COUNT;
+ this._elementList = [];
+ this._handlers = Object.create(null);
+ }
+
+ /**
+ * Private Methods
+ */
+
+ _invalidate() {
+ let count = this._operationCount;
+ this._operationCount = count < MAX_COUNT ? count + 1 : MIN_COUNT;
+ }
+
+ _elements(silent) {
+ silent === true || this._operationCount;
+ return this._elementList;
+ }
+
+ _elementWithPayload(payload, silent) {
+ return this._elements(silent).find(item => item.payload === payload);
+ }
+
+ _elementWithId(id, silent) {
+ return this._elements(silent).find(item => item.id === id);
+ }
+
+ _trigger(event, data) {
+ let handlers = this._handlers;
+ if (event in handlers) {
+ handlers = handlers[event];
+ if (!(handlers instanceof Array)) {
+ return;
+ }
+ for (let i = 0, limit = handlers.length; i < limit; ++i) {
+ let handler = handlers[i];
+ if (_isFunction(handler)) {
+ handler.call(null, data);
+ }
+ }
+ }
+ }
+
+ /**
+ * Public Methods
+ */
+
+ onInsert(callback) {
+ if (_isFunction(callback)) {
+ let handlers = this._handlers.insert;
+ if (!(handlers instanceof Array)) {
+ handlers = [];
+ this._handlers.insert = handlers;
+ }
+ handlers.push(callback);
+ }
+ }
+
+ /**
+ * Update the payload associated with the given ID to be the new supplied payload.
+ * @param {string} id The ID of the entry that will be updated.
+ * @param {any} payload The element that will replace the previous payload.
+ * @returns {boolean} Returns true if the given ID is present in the collection, false otherwise.
+ */
+ updateById(id, payload) {
+ let result = false,
+ found = this._elementWithPayload(payload, true);
+ if (found) {
+ // nothing to do since the element is already in the collection...
+ if (found.id === id) {
+ // set result to true since the ids match...
+ result = true;
+ this._invalidate();
+ }
+ } else {
+ found = this._elementWithId(id, true);
+ if (found) {
+ found.payload = payload;
+ result = true;
+ this._invalidate();
+ }
+ }
+ return result;
+ }
+
+ /**
+ * Signal that the given element has been changed by notifying reactive data-source observers.
+ * This method is basically a means to invalidate the inernal reactive data-source.
+ * @param {any} payload The element that has been altered.
+ * @returns {boolean} Returns true if the element is present in the collection, false otherwise.
+ */
+ update(payload) {
+ let result = false,
+ found = this._elementWithPayload(payload, true);
+ if (found) {
+ // nothing to do since the element is already in the collection...
+ result = true;
+ this._invalidate();
+ }
+ return result;
+ }
+
+ /**
+ * Insert an element in the collection. On success, the element ID (a unique string) is returned. On failure, returns null.
+ * A failure scenario only happens when the given payload is already present in the collection. Note that NO exceptions are thrown!
+ * @param {any} payload The element to be stored.
+ * @returns {string} The ID of the inserted element or null if the element already exists...
+ */
+ insert(payload) {
+ let id = null,
+ found = this._elementWithPayload(payload, true);
+ if (!found) {
+ id = guid();
+ this._elements(true).push({ id, payload });
+ this._invalidate();
+ this._trigger('insert', { id, data: payload });
+ }
+ return id;
+ }
+
+ /**
+ * Remove all elements from the collection.
+ * @returns {void} No meaningful value is returned.
+ */
+ removeAll() {
+ let all = this._elements(true),
+ length = all.length;
+ for (let i = length - 1; i >= 0; i--) {
+ let item = all[i];
+ delete item.id;
+ delete item.payload;
+ all[i] = null;
+ }
+ all.splice(0, length);
+ this._invalidate();
+ }
+
+ /**
+ * Remove elements from the collection that match the criteria given in the property map.
+ * @param {Object} propertyMap A property map that will be macthed against all collection elements.
+ * @returns {Array} A list with all removed elements.
+ */
+ remove(propertyMap) {
+ let found = this.findAllEntriesBy(propertyMap),
+ foundCount = found.length,
+ removed = [];
+ if (foundCount > 0) {
+ const all = this._elements(true);
+ for (let i = foundCount - 1; i >= 0; i--) {
+ let item = found[i];
+ all.splice(item[2], 1);
+ removed.push(item[0]);
+ }
+ this._invalidate();
+ }
+ return removed;
+ }
+
+ /**
+ * Provides the ID of the given element inside the collection.
+ * @param {any} payload The element being searched for.
+ * @returns {string} The ID of the given element or undefined if the element is not present.
+ */
+ getElementId(payload) {
+ let found = this._elementWithPayload(payload);
+ return found && found.id;
+ }
+
+ /**
+ * Provides the position of the given element in the internal list returning -1 if the element is not present.
+ * @param {any} payload The element being searched for.
+ * @returns {number} The position of the given element in the internal list. If the element is not present -1 is returned.
+ */
+ findById(id) {
+ let found = this._elementWithId(id);
+ return found && found.payload;
+ }
+
+ /**
+ * Provides the position of the given element in the internal list returning -1 if the element is not present.
+ * @param {any} payload The element being searched for.
+ * @returns {number} The position of the given element in the internal list. If the element is not present -1 is returned.
+ */
+ indexOfElement(payload) {
+ return this._elements().indexOf(this._elementWithPayload(payload, true));
+ }
+
+ /**
+ * Provides the position of the element associated with the given ID in the internal list returning -1 if the element is not present.
+ * @param {string} id The index of the element.
+ * @returns {number} The position of the element associated with the given ID in the internal list. If the element is not present -1 is returned.
+ */
+ indexOfId(id) {
+ return this._elements().indexOf(this._elementWithId(id, true));
+ }
+
+ /**
+ * Provides a list-like approach to the collection returning an element by index.
+ * @param {number} index The index of the element.
+ * @returns {any} If out of bounds, undefined is returned. Otherwise the element in the given position is returned.
+ */
+ getElementByIndex(index) {
+ let found = this._elements()[index >= 0 ? index : -1];
+ return found && found.payload;
+ }
+
+ /**
+ * Find an element by a criteria defined by the given callback function.
+ * Attention!!! The reactive source will not be notified if no valid callback is supplied...
+ * @param {function} callback A callback function which will define the search criteria. The callback
+ * function will be passed the collection element, its ID and its index in this very order. The callback
+ * shall return true when its criterea has been fulfilled.
+ * @returns {any} The matched element or undefined if not match was found.
+ */
+ find(callback) {
+ let found;
+ if (_isFunction(callback)) {
+ found = this._elements().find((item, index) => {
+ return callback.call(this, item.payload, item.id, index);
+ });
+ }
+ return found && found.payload;
+ }
+
+ /**
+ * Find the first element that strictly matches the specified property map.
+ * @param {Object} propertyMap A property map that will be macthed against all collection elements.
+ * @param {Object} options A set of options. Currently only "options.sort" option is supported.
+ * @param {Object.SortingSpecifier} options.sort An optional sorting specifier. If a sorting specifier is supplied
+ * but is not valid, an exception will be thrown.
+ * @returns {Any} The matched element or undefined if not match was found.
+ */
+ findBy(propertyMap, options) {
+ let found;
+ if (_isObject(options)) {
+ // if the "options" argument is provided and is a valid object,
+ // it must be applied to the dataset before search...
+ const all = this.all(options);
+ if (all.length > 0) {
+ if (_isObject(propertyMap)) {
+ found = all.find(item =>
+ _compareToPropertyMapStrict(propertyMap, item)
+ );
+ } else {
+ found = all[0]; // simply extract the first element...
+ }
+ }
+ } else if (_isObject(propertyMap)) {
+ found = this._elements().find(item =>
+ _compareToPropertyMapStrict(propertyMap, item.payload)
+ );
+ if (found) {
+ found = found.payload;
+ }
+ }
+ return found;
+ }
+
+ /**
+ * Find all elements that strictly match the specified property map.
+ * Attention!!! The reactive source will not be notified if no valid property map is supplied...
+ * @param {Object} propertyMap A property map that will be macthed against all collection elements.
+ * @returns {Array} An array of entries of all elements that match the given criteria. Each set in
+ * in the array has the following format: [ elementData, elementId, elementIndex ].
+ */
+ findAllEntriesBy(propertyMap) {
+ const found = [];
+ if (_isObject(propertyMap)) {
+ this._elements().forEach((item, index) => {
+ if (_compareToPropertyMapStrict(propertyMap, item.payload)) {
+ // Match! Add it to the found list...
+ found.push([item.payload, item.id, index]);
+ }
+ });
+ }
+ return found;
+ }
+
+ /**
+ * Find all elements that match a specified property map.
+ * Attention!!! The reactive source will not be notified if no valid property map is supplied...
+ * @param {Object} propertyMap A property map that will be macthed against all collection elements.
+ * @param {Object} options A set of options. Currently only "options.sort" option is supported.
+ * @param {Object.SortingSpecifier} options.sort An optional sorting specifier. If a sorting specifier is supplied
+ * but is not valid, an exception will be thrown.
+ * @returns {Array} An array with all elements that match the given criteria and sorted in the specified sorting order.
+ */
+ findAllBy(propertyMap, options) {
+ const found = this.findAllEntriesBy(propertyMap).map(item => item[0]); // Only payload is relevant...
+ if (_isObject(options)) {
+ if ('sort' in options) {
+ _sortListBy(found, options.sort);
+ }
+ }
+ return found;
+ }
+
+ /**
+ * Executes the supplied callback function for each element of the collection.
+ * Attention!!! The reactive source will not be notified if no valid property map is supplied...
+ * @param {function} callback The callback function to be executed. The callback is passed the element,
+ * its ID and its index in this very order.
+ * @returns {void} Nothing is returned.
+ */
+ forEach(callback) {
+ if (_isFunction(callback)) {
+ this._elements().forEach((item, index) => {
+ callback.call(this, item.payload, item.id, index);
+ });
+ }
+ }
+
+ /**
+ * Count the number of elements currently in the collection.
+ * @returns {number} The current number of elements in the collection.
+ */
+ count() {
+ return this._elements().length;
+ }
+
+ /**
+ * Returns a list with all elements of the collection optionally sorted by a sorting specifier criteria.
+ * @param {Object} options A set of options. Currently only "options.sort" option is supported.
+ * @param {Object.SortingSpecifier} options.sort An optional sorting specifier. If a sorting specifier is supplied
+ * but is not valid, an exception will be thrown.
+ * @returns {Array} An array with all elements stored in the collection.
+ */
+ all(options) {
+ let list = this._elements().map(item => item.payload);
+ if (_isObject(options)) {
+ if ('sort' in options) {
+ _sortListBy(list, options.sort);
+ }
+ }
+ return list;
+ }
+}
+
+/**
+ * Utility Functions
+ */
+
+/**
+ * Test if supplied argument is a valid object for current class purposes.
+ * Atention! The underscore version of this function should not be used for performance reasons.
+ */
+function _isObject(subject) {
+ return (
+ subject instanceof Object ||
+ (typeof subject === 'object' && subject !== null)
+ );
+}
+
+/**
+ * Test if supplied argument is a valid string for current class purposes.
+ * Atention! The underscore version of this function should not be used for performance reasons.
+ */
+function _isString(subject) {
+ return typeof subject === 'string';
+}
+
+/**
+ * Test if supplied argument is a valid function for current class purposes.
+ * Atention! The underscore version of this function should not be used for performance reasons.
+ */
+function _isFunction(subject) {
+ return typeof subject === 'function';
+}
+
+/**
+ * Shortcut for Object's prototype "hasOwnProperty" method.
+ */
+const _hasOwnProperty = Object.prototype.hasOwnProperty;
+
+/**
+ * Retrieve an object's property value by name. Composite property names (e.g., 'address.country.name') are accepted.
+ * @param {Object} targetObject The object we want read the property from...
+ * @param {String} propertyName The property to be read (e.g., 'address.street.name' or 'address.street.number'
+ * to read object.address.street.name or object.address.street.number, respectively);
+ * @returns {Any} Returns whatever the property holds or undefined if the property cannot be read or reached.
+ */
+function _getPropertyValue(targetObject, propertyName) {
+ let propertyValue; // undefined (the default return value)
+ if (_isObject(targetObject) && _isString(propertyName)) {
+ const fragments = propertyName.split(PROPERTY_SEPARATOR);
+ const fragmentCount = fragments.length;
+ if (fragmentCount > 0) {
+ const firstFragment = fragments[0];
+ const remainingFragments =
+ fragmentCount > 1 ? fragments.slice(1).join(PROPERTY_SEPARATOR) : null;
+ propertyValue = targetObject[firstFragment];
+ if (remainingFragments !== null) {
+ propertyValue = _getPropertyValue(propertyValue, remainingFragments);
+ }
+ }
+ }
+ return propertyValue;
+}
+
+/**
+ * Compare a property map with a target object using strict comparison.
+ * @param {Object} propertyMap The property map whose properties will be used for comparison. Composite
+ * property names (e.g., 'address.country.name') will be tested against the "resolved" properties from the target object.
+ * @param {Object} targetObject The target object whose properties will be tested.
+ * @returns {boolean} Returns true if the properties match, false otherwise.
+ */
+function _compareToPropertyMapStrict(propertyMap, targetObject) {
+ let result = false;
+ // "for in" loops do not thown exceptions for invalid data types...
+ for (let propertyName in propertyMap) {
+ if (_hasOwnProperty.call(propertyMap, propertyName)) {
+ if (
+ propertyMap[propertyName] !==
+ _getPropertyValue(targetObject, propertyName)
+ ) {
+ result = false;
+ break;
+ } else if (result !== true) {
+ result = true;
+ }
+ }
+ }
+ return result;
+}
+
+/**
+ * Checks if a sorting specifier is valid.
+ * A valid sorting specifier consists of an array of arrays being each subarray a pair
+ * in the format ["property name", "sorting order"].
+ * The following exemple can be used to sort studies by "date"" and use "time" to break ties in descending order.
+ * [ [ 'study.date', 'desc' ], [ 'study.time', 'desc' ] ]
+ * @param {Array} specifiers The sorting specifier to be tested.
+ * @returns {boolean} Returns true if the specifiers are valid, false otherwise.
+ */
+function _isValidSortingSpecifier(specifiers) {
+ let result = true;
+ if (specifiers instanceof Array && specifiers.length > 0) {
+ for (let i = specifiers.length - 1; i >= 0; i--) {
+ const item = specifiers[i];
+ if (item instanceof Array) {
+ const property = item[0];
+ const order = item[1];
+ if (
+ _isString(property) &&
+ (order === ORDER_ASC || order === ORDER_DESC)
+ ) {
+ continue;
+ }
+ }
+ result = false;
+ break;
+ }
+ }
+ return result;
+}
+
+/**
+ * Sorts an array based on sorting specifier options.
+ * @param {Array} list The that needs to be sorted.
+ * @param {Array} specifiers An array of specifiers. Please read isValidSortingSpecifier method definition for further details.
+ * @returns {void} No value is returned. The array is sorted in place.
+ */
+function _sortListBy(list, specifiers) {
+ if (list instanceof Array && _isValidSortingSpecifier(specifiers)) {
+ const specifierCount = specifiers.length;
+ list.sort(function _sortListByCallback(a, b) {
+ // callback name for stack traces...
+ let index = 0;
+ while (index < specifierCount) {
+ const specifier = specifiers[index];
+ const property = specifier[0];
+ const order = specifier[1] === ORDER_DESC ? -1 : 1;
+ const aValue = _getPropertyValue(a, property);
+ const bValue = _getPropertyValue(b, property);
+ // @TODO: should we check for the types being compared, like:
+ // ~~ if (typeof aValue !== typeof bValue) continue;
+ // Not sure because dates, for example, can be correctly compared to numbers...
+ if (aValue < bValue) {
+ return order * -1;
+ }
+ if (aValue > bValue) {
+ return order * 1;
+ }
+ if (++index >= specifierCount) {
+ return 0;
+ }
+ }
+ });
+ } else {
+ throw new Error('Invalid Arguments');
+ }
+}
diff --git a/packages/_core/src/classes/hotkeys/index.js b/packages/_core/src/classes/hotkeys/index.js
new file mode 100644
index 000000000..4db77861a
--- /dev/null
+++ b/packages/_core/src/classes/hotkeys/index.js
@@ -0,0 +1,17 @@
+// Only imported in environment w/ `window`
+// So we need to mock these for tests
+import Mousetrap from 'mousetrap';
+import pausePlugin from 'mousetrap/plugins/pause/mousetrap-pause.js';
+import recordPlugin from 'mousetrap/plugins/record/mousetrap-record.js';
+
+// import pausePlugin from './pausePlugin.js';
+// import recordPlugin from './recordPlugin.js';
+
+// // // TODO: May need to bind these so Mousetrap = this in plugins;
+// pausePlugin(Mousetrap);
+// recordPlugin(Mousetrap);
+
+// console.log(Mousetrap);
+// console.log(Object.keys(Mousetrap));
+
+export default Mousetrap;
diff --git a/packages/_core/src/classes/hotkeys/pausePlugin.js b/packages/_core/src/classes/hotkeys/pausePlugin.js
new file mode 100644
index 000000000..80c5513f0
--- /dev/null
+++ b/packages/_core/src/classes/hotkeys/pausePlugin.js
@@ -0,0 +1,32 @@
+/**
+ * adds a pause and unpause method to Mousetrap
+ * this allows you to enable or disable keyboard shortcuts
+ * without having to reset Mousetrap and rebind everything
+ *
+ * https://github.com/ccampbell/mousetrap/blob/master/plugins/pause/mousetrap-pause.js
+ */
+export default function(Mousetrap) {
+ var _originalStopCallback = Mousetrap.prototype.stopCallback;
+
+ Mousetrap.prototype.stopCallback = function(e, element, combo) {
+ var self = this;
+
+ if (self.paused) {
+ return true;
+ }
+
+ return _originalStopCallback.call(self, e, element, combo);
+ };
+
+ Mousetrap.prototype.pause = function() {
+ var self = this;
+ self.paused = true;
+ };
+
+ Mousetrap.prototype.unpause = function() {
+ var self = this;
+ self.paused = false;
+ };
+
+ Mousetrap.init();
+}
diff --git a/packages/_core/src/classes/hotkeys/recordPlugin.js b/packages/_core/src/classes/hotkeys/recordPlugin.js
new file mode 100644
index 000000000..e091039bd
--- /dev/null
+++ b/packages/_core/src/classes/hotkeys/recordPlugin.js
@@ -0,0 +1,200 @@
+/**
+ * This extension allows you to record a sequence using Mousetrap.
+ * {@link https://craig.is/killing/mice}
+ *
+ * @author Dan Tao
+ */
+export default function(Mousetrap) {
+ /**
+ * the sequence currently being recorded
+ *
+ * @type {Array}
+ */
+ var _recordedSequence = [],
+ /**
+ * a callback to invoke after recording a sequence
+ *
+ * @type {Function|null}
+ */
+ _recordedSequenceCallback = null,
+ /**
+ * a list of all of the keys currently held down
+ *
+ * @type {Array}
+ */
+ _currentRecordedKeys = [],
+ /**
+ * temporary state where we remember if we've already captured a
+ * character key in the current combo
+ *
+ * @type {boolean}
+ */
+ _recordedCharacterKey = false,
+ /**
+ * a handle for the timer of the current recording
+ *
+ * @type {null|number}
+ */
+ _recordTimer = null,
+ /**
+ * the original handleKey method to override when Mousetrap.record() is
+ * called
+ *
+ * @type {Function}
+ */
+ _origHandleKey = Mousetrap.prototype.handleKey;
+
+ /**
+ * handles a character key event
+ *
+ * @param {string} character
+ * @param {Array} modifiers
+ * @param {Event} e
+ * @returns void
+ */
+ function _handleKey(character, modifiers, e) {
+ var self = this;
+
+ if (!self.recording) {
+ _origHandleKey.apply(self, arguments);
+ return;
+ }
+
+ // remember this character if we're currently recording a sequence
+ if (e.type == 'keydown') {
+ if (character.length === 1 && _recordedCharacterKey) {
+ _recordCurrentCombo();
+ }
+
+ for (i = 0; i < modifiers.length; ++i) {
+ _recordKey(modifiers[i]);
+ }
+ _recordKey(character);
+
+ // once a key is released, all keys that were held down at the time
+ // count as a keypress
+ } else if (e.type == 'keyup' && _currentRecordedKeys.length > 0) {
+ _recordCurrentCombo();
+ }
+ }
+
+ /**
+ * marks a character key as held down while recording a sequence
+ *
+ * @param {string} key
+ * @returns void
+ */
+ function _recordKey(key) {
+ var i;
+
+ // one-off implementation of Array.indexOf, since IE6-9 don't support it
+ for (i = 0; i < _currentRecordedKeys.length; ++i) {
+ if (_currentRecordedKeys[i] === key) {
+ return;
+ }
+ }
+
+ _currentRecordedKeys.push(key);
+
+ if (key.length === 1) {
+ _recordedCharacterKey = true;
+ }
+ }
+
+ /**
+ * marks whatever key combination that's been recorded so far as finished
+ * and gets ready for the next combo
+ *
+ * @returns void
+ */
+ function _recordCurrentCombo() {
+ _recordedSequence.push(_currentRecordedKeys);
+ _currentRecordedKeys = [];
+ _recordedCharacterKey = false;
+ _restartRecordTimer();
+ }
+
+ /**
+ * ensures each combo in a sequence is in a predictable order and formats
+ * key combos to be '+'-delimited
+ *
+ * modifies the sequence in-place
+ *
+ * @param {Array} sequence
+ * @returns void
+ */
+ function _normalizeSequence(sequence) {
+ var i;
+
+ for (i = 0; i < sequence.length; ++i) {
+ sequence[i].sort(function(x, y) {
+ // modifier keys always come first, in alphabetical order
+ if (x.length > 1 && y.length === 1) {
+ return -1;
+ } else if (x.length === 1 && y.length > 1) {
+ return 1;
+ }
+
+ // character keys come next (list should contain no duplicates,
+ // so no need for equality check)
+ return x > y ? 1 : -1;
+ });
+
+ sequence[i] = sequence[i].join('+');
+ }
+ }
+
+ /**
+ * finishes the current recording, passes the recorded sequence to the stored
+ * callback, and sets Mousetrap.handleKey back to its original function
+ *
+ * @returns void
+ */
+ function _finishRecording() {
+ if (_recordedSequenceCallback) {
+ _normalizeSequence(_recordedSequence);
+ _recordedSequenceCallback(_recordedSequence);
+ }
+
+ // reset all recorded state
+ _recordedSequence = [];
+ _recordedSequenceCallback = null;
+ _currentRecordedKeys = [];
+ }
+
+ /**
+ * called to set a 1 second timeout on the current recording
+ *
+ * this is so after each key press in the sequence the recording will wait for
+ * 1 more second before executing the callback
+ *
+ * @returns void
+ */
+ function _restartRecordTimer() {
+ clearTimeout(_recordTimer);
+ _recordTimer = setTimeout(_finishRecording, 1000);
+ }
+
+ /**
+ * records the next sequence and passes it to a callback once it's
+ * completed
+ *
+ * @param {Function} callback
+ * @returns void
+ */
+ Mousetrap.prototype.record = function(callback) {
+ var self = this;
+ self.recording = true;
+ _recordedSequenceCallback = function() {
+ self.recording = false;
+ callback.apply(self, arguments);
+ };
+ };
+
+ Mousetrap.prototype.handleKey = function() {
+ var self = this;
+ _handleKey.apply(self, arguments);
+ };
+
+ Mousetrap.init();
+}
diff --git a/packages/_core/src/classes/index.js b/packages/_core/src/classes/index.js
new file mode 100644
index 000000000..87977ac11
--- /dev/null
+++ b/packages/_core/src/classes/index.js
@@ -0,0 +1,61 @@
+import {
+ InstanceMetadata,
+ SeriesMetadata,
+ StudyMetadata,
+ StudySummary,
+} from './metadata';
+
+import CommandsManager from './CommandsManager.js';
+import { DICOMFileLoadingListener } from './StudyLoadingListener';
+import HotkeysManager from './HotkeysManager.js';
+import ImageSet from './ImageSet';
+import MetadataProvider from './MetadataProvider.js';
+import OHIFError from './OHIFError.js';
+import { OHIFStudyMetadataSource } from './OHIFStudyMetadataSource';
+import { StackLoadingListener } from './StudyLoadingListener';
+import { StudyLoadingListener } from './StudyLoadingListener';
+import { StudyMetadataSource } from './StudyMetadataSource';
+import { StudyPrefetcher } from './StudyPrefetcher';
+import { TypeSafeCollection } from './TypeSafeCollection';
+
+//import { StudySummary } from './metadata/StudySummary';
+
+export {
+ OHIFStudyMetadataSource,
+ MetadataProvider,
+ CommandsManager,
+ HotkeysManager,
+ ImageSet,
+ StudyPrefetcher,
+ //StudyLoadingListener,
+ StackLoadingListener,
+ DICOMFileLoadingListener,
+ StudyMetadata,
+ SeriesMetadata,
+ InstanceMetadata,
+ StudySummary,
+ TypeSafeCollection,
+ OHIFError,
+ StudyMetadataSource,
+};
+
+const classes = {
+ OHIFStudyMetadataSource,
+ MetadataProvider,
+ CommandsManager,
+ HotkeysManager,
+ ImageSet,
+ StudyPrefetcher,
+ StudyLoadingListener,
+ StackLoadingListener,
+ DICOMFileLoadingListener,
+ StudyMetadata,
+ SeriesMetadata,
+ InstanceMetadata,
+ StudySummary,
+ TypeSafeCollection,
+ OHIFError,
+ StudyMetadataSource,
+};
+
+export default classes;
diff --git a/packages/_core/src/classes/metadata/InstanceMetadata.js b/packages/_core/src/classes/metadata/InstanceMetadata.js
new file mode 100644
index 000000000..4b59852ae
--- /dev/null
+++ b/packages/_core/src/classes/metadata/InstanceMetadata.js
@@ -0,0 +1,225 @@
+import { Metadata } from './Metadata';
+import OHIFError from '../OHIFError.js';
+
+/**
+ * ATTENTION! This class should never depend on StudyMetadata or SeriesMetadata classes as this could
+ * possibly cause circular dependency issues.
+ */
+
+const UNDEFINED = 'undefined';
+const STRING = 'string';
+const STUDY_INSTANCE_UID = 'x0020000d';
+const SERIES_INSTANCE_UID = 'x0020000e';
+
+export class InstanceMetadata extends Metadata {
+ constructor(data, uid) {
+ super(data, uid);
+ // Initialize Private Properties
+ Object.defineProperties(this, {
+ _sopInstanceUID: {
+ configurable: true, // configurable so that it can be redefined in sub-classes...
+ enumerable: false,
+ writable: true,
+ value: null,
+ },
+ _imageId: {
+ configurable: true, // configurable so that it can be redefined in sub-classes...
+ enumerable: false,
+ writable: true,
+ value: null,
+ },
+ });
+ // Initialize Public Properties
+ this._definePublicProperties();
+ }
+
+ /**
+ * Private Methods
+ */
+
+ /**
+ * Define Public Properties
+ * This method should only be called during initialization (inside the class constructor)
+ */
+ _definePublicProperties() {
+ /**
+ * Property: this.sopInstanceUID
+ * Same as this.getSOPInstanceUID()
+ * It's specially useful in contexts where a method call is not suitable like in search criteria. For example:
+ * sopInstanceCollection.findBy({
+ * sopInstanceUID: '1.2.3.4.5.6.77777.8888888.99999999999.0'
+ * });
+ */
+ Object.defineProperty(this, 'sopInstanceUID', {
+ configurable: false,
+ enumerable: false,
+ get: function() {
+ return this.getSOPInstanceUID();
+ },
+ });
+ }
+
+ /**
+ * Public Methods
+ */
+
+ /**
+ * Returns the StudyInstanceUID of the current instance. This method is basically a shorthand the full "getTagValue" method call.
+ */
+ getStudyInstanceUID() {
+ return this.getTagValue(STUDY_INSTANCE_UID, null);
+ }
+
+ /**
+ * Returns the SeriesInstanceUID of the current instance. This method is basically a shorthand the full "getTagValue" method call.
+ */
+ getSeriesInstanceUID() {
+ return this.getTagValue(SERIES_INSTANCE_UID, null);
+ }
+
+ /**
+ * Returns the SOPInstanceUID of the current instance.
+ */
+ getSOPInstanceUID() {
+ return this._sopInstanceUID;
+ }
+
+ // @TODO: Improve this... (E.g.: blob data)
+ getStringValue(tagOrProperty, index, defaultValue) {
+ let value = this.getTagValue(tagOrProperty, defaultValue);
+
+ if (typeof value !== STRING && typeof value !== UNDEFINED) {
+ value = value.toString();
+ }
+
+ return InstanceMetadata.getIndexedValue(value, index, defaultValue);
+ }
+
+ // @TODO: Improve this... (E.g.: blob data)
+ getFloatValue(tagOrProperty, index, defaultValue) {
+ let value = this.getTagValue(tagOrProperty, defaultValue);
+ value = InstanceMetadata.getIndexedValue(value, index, defaultValue);
+
+ if (value instanceof Array) {
+ value.forEach((val, idx) => {
+ value[idx] = parseFloat(val);
+ });
+
+ return value;
+ }
+
+ return typeof value === STRING ? parseFloat(value) : value;
+ }
+
+ // @TODO: Improve this... (E.g.: blob data)
+ getIntValue(tagOrProperty, index, defaultValue) {
+ let value = this.getTagValue(tagOrProperty, defaultValue);
+ value = InstanceMetadata.getIndexedValue(value, index, defaultValue);
+
+ if (value instanceof Array) {
+ value.forEach((val, idx) => {
+ value[idx] = parseFloat(val);
+ });
+
+ return value;
+ }
+
+ return typeof value === STRING ? parseInt(value) : value;
+ }
+
+ /**
+ * @deprecated Please use getTagValue instead.
+ */
+ getRawValue(tagOrProperty, defaultValue) {
+ return this.getTagValue(tagOrProperty, defaultValue);
+ }
+
+ /**
+ * This function should be overriden by specialized classes in order to allow client libraries or viewers to take advantage of the Study Metadata API.
+ */
+ getTagValue(tagOrProperty, defaultValue) {
+ /**
+ * Please override this method on a specialized class.
+ */
+ throw new OHIFError(
+ 'InstanceMetadata::getTagValue is not overriden. Please, override it in a specialized class. See OHIFInstanceMetadata for example'
+ );
+ }
+
+ /**
+ * Compares the current instance with another one.
+ * @param {InstanceMetadata} instance An instance of the InstanceMetadata class.
+ * @returns {boolean} Returns true if both instances refer to the same instance.
+ */
+ equals(instance) {
+ const self = this;
+ return (
+ instance === self ||
+ (instance instanceof InstanceMetadata &&
+ instance.getSOPInstanceUID() === self.getSOPInstanceUID())
+ );
+ }
+
+ /**
+ * Check if the tagOrProperty exists
+ * @param {String} tagOrProperty tag or property be checked
+ * @return {Boolean} True if the tag or property exists or false if doesn't
+ */
+ tagExists(tagOrProperty) {
+ /**
+ * Please override this method
+ */
+ throw new OHIFError(
+ 'InstanceMetadata::tagExists is not overriden. Please, override it in a specialized class. See OHIFInstanceMetadata for example'
+ );
+ }
+
+ /**
+ * Get custom image id of a sop instance
+ * @return {Any} sop instance image id
+ */
+ getImageId(frame) {
+ /**
+ * Please override this method
+ */
+ throw new OHIFError(
+ 'InstanceMetadata::getImageId is not overriden. Please, override it in a specialized class. See OHIFInstanceMetadata for example'
+ );
+ }
+
+ /**
+ * Static Methods
+ */
+
+ /**
+ * Get an value based that can be index based. This function is called by all getters. See above functions.
+ * - If value is a String and has indexes:
+ * - If undefined index: returns an array of the split values.
+ * - If defined index:
+ * - If invalid: returns defaultValue
+ * - If valid: returns the indexed value
+ * - If value is not a String, returns default value.
+ */
+ static getIndexedValue(value, index, defaultValue) {
+ let result = defaultValue;
+
+ if (typeof value === STRING) {
+ const hasIndexValues = value.indexOf('\\') !== -1;
+
+ result = value;
+
+ if (hasIndexValues) {
+ const splitValues = value.split('\\');
+ if (Metadata.isValidIndex(index)) {
+ const indexedValue = splitValues[index];
+
+ result = typeof indexedValue !== STRING ? defaultValue : indexedValue;
+ } else {
+ result = splitValues;
+ }
+ }
+ }
+
+ return result;
+ }
+}
diff --git a/packages/_core/src/classes/metadata/Metadata.js b/packages/_core/src/classes/metadata/Metadata.js
new file mode 100644
index 000000000..3bf8dc8df
--- /dev/null
+++ b/packages/_core/src/classes/metadata/Metadata.js
@@ -0,0 +1,127 @@
+/**
+ * Constants
+ */
+
+const STRING = 'string';
+const NUMBER = 'number';
+const FUNCTION = 'function';
+const OBJECT = 'object';
+
+/**
+ * Class Definition
+ */
+
+export class Metadata {
+ /**
+ * Constructor and Instance Methods
+ */
+
+ constructor(data, uid) {
+ // Define the main "_data" private property as an immutable property.
+ // IMPORTANT: This property can only be set during instance construction.
+ Object.defineProperty(this, '_data', {
+ configurable: false,
+ enumerable: false,
+ writable: false,
+ value: data,
+ });
+
+ // Define the main "_uid" private property as an immutable property.
+ // IMPORTANT: This property can only be set during instance construction.
+ Object.defineProperty(this, '_uid', {
+ configurable: false,
+ enumerable: false,
+ writable: false,
+ value: uid,
+ });
+
+ // Define "_custom" properties as an immutable property.
+ // IMPORTANT: This property can only be set during instance construction.
+ Object.defineProperty(this, '_custom', {
+ configurable: false,
+ enumerable: false,
+ writable: false,
+ value: Object.create(null),
+ });
+ }
+
+ getData() {
+ return this._data;
+ }
+
+ getDataProperty(propertyName) {
+ let propertyValue;
+ const _data = this._data;
+ if (
+ _data instanceof Object ||
+ (typeof _data === OBJECT && _data !== null)
+ ) {
+ propertyValue = _data[propertyName];
+ }
+ return propertyValue;
+ }
+
+ /**
+ * Get unique object ID
+ */
+ getObjectID() {
+ return this._uid;
+ }
+
+ /**
+ * Set custom attribute value
+ * @param {String} attribute Custom attribute name
+ * @param {Any} value Custom attribute value
+ */
+ setCustomAttribute(attribute, value) {
+ this._custom[attribute] = value;
+ }
+
+ /**
+ * Get custom attribute value
+ * @param {String} attribute Custom attribute name
+ * @return {Any} Custom attribute value
+ */
+ getCustomAttribute(attribute) {
+ return this._custom[attribute];
+ }
+
+ /**
+ * Check if a custom attribute exists
+ * @param {String} attribute Custom attribute name
+ * @return {Boolean} True if custom attribute exists or false if not
+ */
+ customAttributeExists(attribute) {
+ return attribute in this._custom;
+ }
+
+ /**
+ * Set custom attributes in batch mode.
+ * @param {Object} attributeMap An object whose own properties will be used as custom attributes.
+ */
+ setCustomAttributes(attributeMap) {
+ const _hasOwn = Object.prototype.hasOwnProperty;
+ const _custom = this._custom;
+ for (let attribute in attributeMap) {
+ if (_hasOwn.call(attributeMap, attribute)) {
+ _custom[attribute] = attributeMap[attribute];
+ }
+ }
+ }
+
+ /**
+ * Static Methods
+ */
+
+ static isValidUID(uid) {
+ return typeof uid === STRING && uid.length > 0;
+ }
+
+ static isValidIndex(index) {
+ return typeof index === NUMBER && index >= 0 && (index | 0) === index;
+ }
+
+ static isValidCallback(callback) {
+ return typeof callback === FUNCTION;
+ }
+}
diff --git a/packages/_core/src/classes/metadata/OHIFInstanceMetadata.js b/packages/_core/src/classes/metadata/OHIFInstanceMetadata.js
new file mode 100644
index 000000000..8fc5f42a5
--- /dev/null
+++ b/packages/_core/src/classes/metadata/OHIFInstanceMetadata.js
@@ -0,0 +1,124 @@
+import { InstanceMetadata } from './InstanceMetadata';
+import { DICOMTagDescriptions } from '../../utils/DICOMTagDescriptions.js';
+import getImageId from '../../utils/getImageId.js';
+
+export class OHIFInstanceMetadata extends InstanceMetadata {
+ /**
+ * @param {Object} Instance object.
+ */
+ constructor(data, series, study, uid) {
+ super(data, uid);
+ this.init(series, study);
+ }
+
+ init(series, study) {
+ const instance = this.getData();
+
+ // Initialize Private Properties
+ Object.defineProperties(this, {
+ _sopInstanceUID: {
+ configurable: false,
+ enumerable: false,
+ writable: false,
+ value: instance.sopInstanceUid,
+ },
+ _study: {
+ configurable: false,
+ enumerable: false,
+ writable: false,
+ value: study,
+ },
+ _series: {
+ configurable: false,
+ enumerable: false,
+ writable: false,
+ value: series,
+ },
+ _instance: {
+ configurable: false,
+ enumerable: false,
+ writable: false,
+ value: instance,
+ },
+ _cache: {
+ configurable: false,
+ enumerable: false,
+ writable: false,
+ value: Object.create(null),
+ },
+ });
+ }
+
+ // Override
+ getTagValue(tagOrProperty, defaultValue, bypassCache) {
+ // check if this property has been cached...
+ if (tagOrProperty in this._cache && bypassCache !== true) {
+ return this._cache[tagOrProperty];
+ }
+
+ const propertyName = OHIFInstanceMetadata.getPropertyName(tagOrProperty);
+
+ // Search property value in the whole study metadata chain...
+ let rawValue;
+ if (propertyName in this._instance) {
+ rawValue = this._instance[propertyName];
+ } else if (propertyName in this._series) {
+ rawValue = this._series[propertyName];
+ } else if (propertyName in this._study) {
+ rawValue = this._study[propertyName];
+ }
+
+ if (rawValue !== void 0) {
+ // if rawValue value is not undefined, cache result...
+ this._cache[tagOrProperty] = rawValue;
+ return rawValue;
+ }
+
+ return defaultValue;
+ }
+
+ // Override
+ tagExists(tagOrProperty) {
+ const propertyName = OHIFInstanceMetadata.getPropertyName(tagOrProperty);
+
+ return (
+ propertyName in this._instance ||
+ propertyName in this._series ||
+ propertyName in this._study
+ );
+ }
+
+ // Override
+ getImageId(frame, thumbnail) {
+ // If _imageID is not cached, create it
+ if (this._imageId === null) {
+ this._imageId = getImageId(this.getData(), frame, thumbnail);
+ }
+
+ return this._imageId;
+ }
+
+ /**
+ * Static methods
+ */
+
+ // @TODO: The current mapping of standard DICOM property names to local property names is not optimal.
+ // The inconsistency in property naming makes this function increasingly complex.
+ // A possible solution to improve this would be adapt retriveMetadata names to use DICOM standard names as in dicomTagDescriptions.js
+ static getPropertyName(tagOrProperty) {
+ let propertyName;
+ const tagInfo = DICOMTagDescriptions.find(tagOrProperty);
+
+ if (tagInfo !== void 0) {
+ // This function tries to translate standard DICOM property names into local naming convention.
+ propertyName = tagInfo.keyword
+ .replace(/^SOP/, 'sop')
+ .replace(/UID$/, 'Uid')
+ .replace(/ID$/, 'Id');
+ propertyName =
+ propertyName.charAt(0).toLowerCase() + propertyName.substr(1);
+ }
+
+ return propertyName;
+ }
+}
diff --git a/packages/_core/src/classes/metadata/OHIFSeriesMetadata.js b/packages/_core/src/classes/metadata/OHIFSeriesMetadata.js
new file mode 100644
index 000000000..668a686e1
--- /dev/null
+++ b/packages/_core/src/classes/metadata/OHIFSeriesMetadata.js
@@ -0,0 +1,29 @@
+import { SeriesMetadata } from './SeriesMetadata';
+import { OHIFInstanceMetadata } from './OHIFInstanceMetadata';
+
+export class OHIFSeriesMetadata extends SeriesMetadata {
+ /**
+ * @param {Object} Series object.
+ */
+ constructor(data, study, uid) {
+ super(data, uid);
+ this.init(study);
+ }
+
+ init(study) {
+ const series = this.getData();
+
+ // define "_seriesInstanceUID" protected property...
+ Object.defineProperty(this, '_seriesInstanceUID', {
+ configurable: false,
+ enumerable: false,
+ writable: false,
+ value: series.seriesInstanceUid,
+ });
+
+ // populate internal list of instances...
+ series.instances.forEach(instance => {
+ this.addInstance(new OHIFInstanceMetadata(instance, series, study));
+ });
+ }
+}
diff --git a/packages/_core/src/classes/metadata/OHIFStudyMetadata.js b/packages/_core/src/classes/metadata/OHIFStudyMetadata.js
new file mode 100644
index 000000000..e5396dd48
--- /dev/null
+++ b/packages/_core/src/classes/metadata/OHIFStudyMetadata.js
@@ -0,0 +1,29 @@
+import { StudyMetadata } from './StudyMetadata';
+import { OHIFSeriesMetadata } from './OHIFSeriesMetadata';
+
+export class OHIFStudyMetadata extends StudyMetadata {
+ /**
+ * @param {Object} Study object.
+ */
+ constructor(data, uid) {
+ super(data, uid);
+ this.init();
+ }
+
+ init() {
+ const study = this.getData();
+
+ // define "_studyInstanceUID" protected property
+ Object.defineProperty(this, '_studyInstanceUID', {
+ configurable: false,
+ enumerable: false,
+ writable: false,
+ value: study.studyInstanceUid,
+ });
+
+ // populate internal list of series
+ study.seriesList.forEach(series => {
+ this.addSeries(new OHIFSeriesMetadata(series, study));
+ });
+ }
+}
diff --git a/packages/_core/src/classes/metadata/README.md b/packages/_core/src/classes/metadata/README.md
new file mode 100644
index 000000000..264b754df
--- /dev/null
+++ b/packages/_core/src/classes/metadata/README.md
@@ -0,0 +1,145 @@
+# Study Metadata Module
+
+This module defines the API/Data-Model by which OHIF Viewerbase package and
+possibly distinct viewer implementations can access studies metadata. This
+module does not attempt to define any means of _loading_ study metadata from any
+data end-point but only how the data that has been previously loaded into the
+application context will be accessed by any of the routines or algorithm
+implementations that need the data.
+
+## Intro
+
+For various reasons like sorting, grouping or simply rendering study
+information, OHIF Viewerbase package and applications depending on it usually
+have the need to access study metadata. Before the current initiative there was
+no uniform way of achieving that since each implementation provides study
+metadata on its own specific ways. The application and the package itself needed
+to have a deep knowledge of the data structures provided by the data endpoint to
+perform any of the operations mentioned above, meaning that any data access code
+needed to be adapted or rewritten.
+
+The intent of the current module is to provide a fairly consistent and flexible
+API/Data-Model by which OHIF Viewerbase package (and different viewer
+implementations that depend on it) can manipulate DICOM matadata retrieved from
+distinct data end points (e.g., a proprietary back end servers) in uniform ways
+with minor to no modifications needed.
+
+## Implementation
+
+The current API implementation defines three classes of objects:
+`StudyMetadata`, `SeriesMetadata` and `InstanceMetadata`. Inside OHIF Viewerbase
+package, every access to Study, Series or SOP Instance metadata is achieved by
+the interface exposed by these three classes. By inheriting from them and
+overriding or extending their methods, different applications with different
+data models can adapt even the most peculiar data structures to the uniform
+interface defined by those classes. Together these classes define a flexible and
+extensible data manipulation layer leaving routines and algorithms that depend
+on that data untouched.
+
+## Design Decisions & "_Protected_" Members
+
+In order to provide for good programming practices, attributes and methods meant
+to be used exclusively by the classes themselves (for internal purposes only)
+were written with an initial '\_' character, being thus treated as "_protected_"
+members. The idea behind this practice was never to hide them from the
+programmers (what makes debugging tasks painful) but only advise for something
+that's not part of the official public API and thus should not be relied on.
+Usage of "protected" members makes the code less readable and prone to
+compatibility issues.
+
+As an example, the initial implementation of the `StudyMetadata` class defined
+the attribute `_studyInstanceUID` and the method `getStudyInstanceUID`. This
+implies that whenever the _StudyInstanceUID_ of a given study needs to be
+retrieved the `getStudyInstanceUID` method should be called instead of directly
+accessing the attribute `_studyInstanceUID` (which might not even be populated
+since `getStudyInstanceUID` can be possiblity overriden by a subclass to satisfy
+specific implementation needs, leaving the attribute `_studyInstanceUID`
+unused).
+
+Ex:
+
+```javascript
+let studyUID = myStudy.getStudyInstanceUID(); // GOOD! :-)
+[ ... ]
+let otherStudyUID = anotherStudy._studyInstanceUID; // BAD... :-(
+```
+
+Another important topic is the preference of _methods_ over _attributes_ on the
+public API. This design decision was made to ensure extensibility and
+flexibility (methods are extensible while standalone attributes are not, and can
+be adapted – through overrides, for example – to support even the most peculiar
+data models) even though the overhead a few additional function calls may incur.
+
+## Abstract Classes
+
+Some classes defined in this module are "_abstract_" classes (even though
+JavaScript does not _officially_ support such programming facility). They are
+_abstract_ in the sense that a few methods (very important ones, by the way)
+were left "_blank_" (unimplemented, or more precisely implemented as empty NOP
+functions) in order to be implemented by specialized subclasses. Methods
+believed to be more generic were implemented in an attempt to satify most
+implementation needs but nothing prevents a subclass from overriding them as
+well (again, flexibility and extensibility are design goals). Most implemented
+methods rely on the implementation of an unimplemented method. For example, the
+method `getStringValue` from `InstanceMetadata` class, which has indeed been
+implemented and is meant to retrieve a metadata value as a string, internally
+calls the `getRawValue` method which _was NOT implemented_ and is meant to query
+the internal data structures for the requested metadata value and return it _as
+is_. Used in that way, an application would not benefit much from the already
+implemented methods. On the other hand, by simply overriding the `getRawValue`
+method on a specialized class to deal with the intrinsics of its internal data
+structures, this very application would now benefit from all already implemented
+methods.
+
+The following code snippet tries to illustrate the idea:
+
+```javascript
+
+// -- InstanceMetadata.js
+
+class InstanceMetadata {
+ [ ... ]
+ getRawValue(tagOrProperty, defaultValue) {
+ // Please implement this method in a specialized subclass...
+ }
+ [ ... ]
+ getStringValue(tagOrProperty, index, defaultValue) {
+ let rawValue = this.getRawValue(tagOrProperty, '');
+ // parse the returned value into a string...
+ [ ... ]
+ return stringValue;
+ }
+ [ ... ]
+}
+
+// -- MyFancyAppInstanceMetadata.js
+
+class MyFancyAppInstanceMetadata extends InstanceMetadata {
+ // Overriding this method will make all methods implemented in the super class
+ // that rely on it to be immediately available...
+ getRawValue(tagOrProperty, defaultValue) {
+ let rawValue;
+ // retrieve raw value from internal data structures...
+ [ ... ]
+ return rawValue;
+ }
+}
+
+// -- main.js
+
+[ ... ]
+let sopInstaceMetadata = new MyFancyAppInstanceMetadata(myInternalData);
+if (sopInstaceMetadata instanceof MyFancyAppInstanceMetadata) { // true
+ // this code will be executed...
+}
+if (sopInstaceMetadata instanceof InstanceMetadata) { // also true
+ // this code will also be executed...
+}
+// The following will also work since the internal "getRawValue" call inside
+// "getStringValue" method will now be satisfied... (thanks to the override)
+let patientName = sopInstaceMetadata.getStringValue('PatientName', '');
+[ ... ]
+
+```
+
+_Copyright © 2016 nucleushealth™. All rights reserved_
diff --git a/packages/_core/src/classes/metadata/SeriesMetadata.js b/packages/_core/src/classes/metadata/SeriesMetadata.js
new file mode 100644
index 000000000..84ae4a037
--- /dev/null
+++ b/packages/_core/src/classes/metadata/SeriesMetadata.js
@@ -0,0 +1,192 @@
+import { Metadata } from './Metadata';
+import { InstanceMetadata } from './InstanceMetadata';
+
+export class SeriesMetadata extends Metadata {
+ constructor(data, uid) {
+ super(data, uid);
+ // Initialize Private Properties
+ Object.defineProperties(this, {
+ _seriesInstanceUID: {
+ configurable: true, // configurable so that it can be redefined in sub-classes...
+ enumerable: false,
+ writable: true,
+ value: null,
+ },
+ _instances: {
+ configurable: false,
+ enumerable: false,
+ writable: false,
+ value: [],
+ },
+ _firstInstance: {
+ configurable: false,
+ enumerable: false,
+ writable: true,
+ value: null,
+ },
+ });
+ // Initialize Public Properties
+ this._definePublicProperties();
+ }
+
+ /**
+ * Private Methods
+ */
+
+ /**
+ * Define Public Properties
+ * This method should only be called during initialization (inside the class constructor)
+ */
+ _definePublicProperties() {
+ /**
+ * Property: this.seriesInstanceUID
+ * Same as this.getSeriesInstanceUID()
+ * It's specially useful in contexts where a method call is not suitable like in search criteria. For example:
+ * seriesCollection.findBy({
+ * seriesInstanceUID: '1.2.3.4.5.6.77777.8888888.99999999999.0'
+ * });
+ */
+ Object.defineProperty(this, 'seriesInstanceUID', {
+ configurable: false,
+ enumerable: false,
+ get: function() {
+ return this.getSeriesInstanceUID();
+ },
+ });
+ }
+
+ /**
+ * Public Methods
+ */
+
+ /**
+ * Returns the SeriesInstanceUID of the current series.
+ */
+ getSeriesInstanceUID() {
+ return this._seriesInstanceUID;
+ }
+
+ /**
+ * Append an instance to the current series.
+ * @param {InstanceMetadata} instance The instance to be added to the current series.
+ * @returns {boolean} Returns true on success, false otherwise.
+ */
+ addInstance(instance) {
+ let result = false;
+ if (
+ instance instanceof InstanceMetadata &&
+ this.getInstanceByUID(instance.getSOPInstanceUID()) === void 0
+ ) {
+ this._instances.push(instance);
+ result = true;
+ }
+ return result;
+ }
+
+ /**
+ * Get the first instance of the current series retaining a consistent result across multiple calls.
+ * @return {InstanceMetadata} An instance of the InstanceMetadata class or null if it does not exist.
+ */
+ getFirstInstance() {
+ let instance = this._firstInstance;
+ if (!(instance instanceof InstanceMetadata)) {
+ instance = null;
+ const found = this.getInstanceByIndex(0);
+ if (found instanceof InstanceMetadata) {
+ this._firstInstance = found;
+ instance = found;
+ }
+ }
+ return instance;
+ }
+
+ /**
+ * Find an instance by index.
+ * @param {number} index An integer representing a list index.
+ * @returns {InstanceMetadata} Returns a InstanceMetadata instance when found or undefined otherwise.
+ */
+ getInstanceByIndex(index) {
+ let found; // undefined by default...
+ if (Metadata.isValidIndex(index)) {
+ found = this._instances[index];
+ }
+ return found;
+ }
+
+ /**
+ * Find an instance by SOPInstanceUID.
+ * @param {string} uid An UID string.
+ * @returns {InstanceMetadata} Returns a InstanceMetadata instance when found or undefined otherwise.
+ */
+ getInstanceByUID(uid) {
+ let found; // undefined by default...
+ if (Metadata.isValidUID(uid)) {
+ found = this._instances.find(instance => {
+ return instance.getSOPInstanceUID() === uid;
+ });
+ }
+ return found;
+ }
+
+ /**
+ * Retrieve the number of instances within the current series.
+ * @returns {number} The number of instances in the current series.
+ */
+ getInstanceCount() {
+ return this._instances.length;
+ }
+
+ /**
+ * Invokes the supplied callback for each instance in the current series passing
+ * two arguments: instance (an InstanceMetadata instance) and index (the integer
+ * index of the instance within the current series)
+ * @param {function} callback The callback function which will be invoked for each instance in the series.
+ * @returns {undefined} Nothing is returned.
+ */
+ forEachInstance(callback) {
+ if (Metadata.isValidCallback(callback)) {
+ this._instances.forEach((instance, index) => {
+ callback.call(null, instance, index);
+ });
+ }
+ }
+
+ /**
+ * Find the index of an instance inside the series.
+ * @param {InstanceMetadata} instance An instance of the SeriesMetadata class.
+ * @returns {number} The index of the instance inside the series or -1 if not found.
+ */
+ indexOfInstance(instance) {
+ return this._instances.indexOf(instance);
+ }
+
+ /**
+ * Search the associated instances using the supplied callback as criteria. The callback is passed
+ * two arguments: instance (a InstanceMetadata instance) and index (the integer
+ * index of the instance within its series)
+ * @param {function} callback The callback function which will be invoked for each instance.
+ * @returns {InstanceMetadata|undefined} If an instance is found based on callback criteria it
+ * returns a InstanceMetadata. "undefined" is returned otherwise
+ */
+ findInstance(callback) {
+ if (Metadata.isValidCallback(callback)) {
+ return this._instances.find((instance, index) => {
+ return callback.call(null, instance, index);
+ });
+ }
+ }
+
+ /**
+ * Compares the current series with another one.
+ * @param {SeriesMetadata} series An instance of the SeriesMetadata class.
+ * @returns {boolean} Returns true if both instances refer to the same series.
+ */
+ equals(series) {
+ const self = this;
+ return (
+ series === self ||
+ (series instanceof SeriesMetadata &&
+ series.getSeriesInstanceUID() === self.getSeriesInstanceUID())
+ );
+ }
+}
diff --git a/packages/_core/src/classes/metadata/StudyMetadata.js b/packages/_core/src/classes/metadata/StudyMetadata.js
new file mode 100644
index 000000000..ae88a5a82
--- /dev/null
+++ b/packages/_core/src/classes/metadata/StudyMetadata.js
@@ -0,0 +1,651 @@
+// - createStacks
+import DICOMWeb from './../../DICOMWeb';
+import ImageSet from './../ImageSet';
+import { InstanceMetadata } from './InstanceMetadata';
+import { Metadata } from './Metadata';
+import OHIFError from '../OHIFError';
+import { SeriesMetadata } from './SeriesMetadata';
+// - createStacks
+import { api } from 'dicomweb-client';
+// - createStacks
+import { isImage } from './../../utils/isImage';
+
+export class StudyMetadata extends Metadata {
+ constructor(data, uid) {
+ super(data, uid);
+ // Initialize Private Properties
+ Object.defineProperties(this, {
+ _studyInstanceUID: {
+ configurable: true, // configurable so that it can be redefined in sub-classes...
+ enumerable: false,
+ writable: true,
+ value: null,
+ },
+ _series: {
+ configurable: false,
+ enumerable: false,
+ writable: false,
+ value: [],
+ },
+ _displaySets: {
+ configurable: false,
+ enumerable: false,
+ writable: false,
+ value: [],
+ },
+ _firstSeries: {
+ configurable: false,
+ enumerable: false,
+ writable: true,
+ value: null,
+ },
+ _firstInstance: {
+ configurable: false,
+ enumerable: false,
+ writable: true,
+ value: null,
+ },
+ });
+ // Initialize Public Properties
+ this._definePublicProperties();
+ }
+
+ /**
+ * Private Methods
+ */
+
+ /**
+ * Define Public Properties
+ * This method should only be called during initialization (inside the class constructor)
+ */
+ _definePublicProperties() {
+ /**
+ * Property: this.studyInstanceUID
+ * Same as this.getStudyInstanceUID()
+ * It's specially useful in contexts where a method call is not suitable like in search criteria. For example:
+ * studyCollection.findBy({
+ * studyInstanceUID: '1.2.3.4.5.6.77777.8888888.99999999999.0'
+ * });
+ */
+ Object.defineProperty(this, 'studyInstanceUID', {
+ configurable: false,
+ enumerable: false,
+ get: function() {
+ return this.getStudyInstanceUID();
+ },
+ });
+ }
+
+ /**
+ * Public Methods
+ */
+
+ /**
+ * Getter for displaySets
+ * @return {Array} Array of display set object
+ */
+ getDisplaySets() {
+ return this._displaySets.slice();
+ }
+
+ /**
+ * Creates a set of series to be placed in the Study Metadata
+ * The series that appear in the Study Metadata must represent
+ * imaging modalities.
+ *
+ * Furthermore, for drag/drop functionality,
+ * it is easiest if the stack objects also contain information about
+ * which study they are linked to.
+ *
+ * @param {StudyMetadata} study The study instance metadata to be used
+ * @returns {Array} An array of series to be placed in the Study Metadata
+ */
+ createDisplaySets(sopClassHandlerModules) {
+ const study = this;
+ const displaySets = [];
+ const anyDisplaySets = study.getSeriesCount();
+ const anySopClassHandlerModules =
+ sopClassHandlerModules && sopClassHandlerModules.length > 0;
+
+ if (!anyDisplaySets) {
+ return displaySets;
+ }
+
+ // Loop through the series (SeriesMetadata)
+ this.forEachSeries(series => {
+ const anyInstances = series.getInstanceCount() > 0;
+ if (!anyInstances) {
+ return;
+ }
+
+ const sopClassUids = getSopClassUids(series);
+
+ if (anySopClassHandlerModules) {
+ const displaySet = _getDisplaySetFromSopClassModule(
+ sopClassHandlerModules,
+ series,
+ study,
+ sopClassUids
+ );
+
+ if (displaySet) {
+ displaySets.push(displaySet);
+
+ return;
+ }
+ }
+
+ // WE NEED A BETTER WAY TO NOTE THAT THIS IS THE DEFAULT BEHAVIOR FOR LOADING
+ // A DISPLAY SET IF THERE IS NO MATCHING SOP CLASS PLUGIN
+
+ // Search through the instances (InstanceMetadata object) of this series
+ // Split Multi-frame instances and Single-image modalities
+ // into their own specific display sets. Place the rest of each
+ // series into another display set.
+ const stackableInstances = [];
+ series.forEachInstance(instance => {
+ // All imaging modalities must have a valid value for sopClassUid (x00080016) or rows (x00280010)
+ if (
+ !isImage(instance.getRawValue('x00080016')) &&
+ !instance.getRawValue('x00280010')
+ ) {
+ return;
+ }
+
+ let displaySet;
+
+ if (isMultiFrame(instance)) {
+ displaySet = makeDisplaySet(series, [instance]);
+ displaySet.setAttributes({
+ sopClassUids,
+ isClip: true,
+ seriesInstanceUid: series.getSeriesInstanceUID(),
+ studyInstanceUid: study.getStudyInstanceUID(), // Include the study instance Uid for drag/drop purposes
+ numImageFrames: instance.getRawValue('x00280008'), // Override the default value of instances.length
+ instanceNumber: instance.getRawValue('x00200013'), // Include the instance number
+ acquisitionDatetime: instance.getRawValue('x0008002a'), // Include the acquisition datetime
+ });
+ displaySets.push(displaySet);
+ } else if (isSingleImageModality(instance.modality)) {
+ displaySet = makeDisplaySet(series, [instance]);
+ displaySet.setAttributes({
+ sopClassUids,
+ studyInstanceUid: study.getStudyInstanceUID(), // Include the study instance Uid
+ seriesInstanceUid: series.getSeriesInstanceUID(),
+ instanceNumber: instance.getRawValue('x00200013'), // Include the instance number
+ acquisitionDatetime: instance.getRawValue('x0008002a'), // Include the acquisition datetime
+ });
+ displaySets.push(displaySet);
+ } else {
+ stackableInstances.push(instance);
+ }
+ });
+
+ if (stackableInstances.length) {
+ const displaySet = makeDisplaySet(series, stackableInstances);
+ displaySet.setAttribute(
+ 'studyInstanceUid',
+ study.getStudyInstanceUID()
+ );
+ displaySet.setAttributes({
+ sopClassUids,
+ });
+ displaySets.push(displaySet);
+ }
+ });
+
+ // TODO
+ displaySets.sort(_sortBySeriesNumber);
+
+ return displaySets;
+ }
+
+ /**
+ * Set display sets
+ * @param {Array} displaySets Array of display sets (ImageSet[])
+ */
+ setDisplaySets(displaySets) {
+ displaySets.forEach(displaySet => this.addDisplaySet(displaySet));
+ }
+
+ /**
+ * Add a single display set to the list
+ * @param {Object} displaySet Display set object
+ * @returns {boolean} True on success, false on failure.
+ */
+ addDisplaySet(displaySet) {
+ if (displaySet instanceof ImageSet) {
+ this._displaySets.push(displaySet);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Invokes the supplied callback for each display set in the current study passing
+ * two arguments: display set (a ImageSet instance) and index (the integer
+ * index of the display set within the current study)
+ * @param {function} callback The callback function which will be invoked for each display set instance.
+ * @returns {undefined} Nothing is returned.
+ */
+ forEachDisplaySet(callback) {
+ if (Metadata.isValidCallback(callback)) {
+ this._displaySets.forEach((displaySet, index) => {
+ callback.call(null, displaySet, index);
+ });
+ }
+ }
+
+ /**
+ * Search the associated display sets using the supplied callback as criteria. The callback is passed
+ * two arguments: display set (an ImageSet instance) and index (the integer
+ * index of the display set within the current study)
+ * @param {function} callback The callback function which will be invoked for each display set instance.
+ * @returns {undefined} Nothing is returned.
+ */
+ findDisplaySet(callback) {
+ if (Metadata.isValidCallback(callback)) {
+ return this._displaySets.find((displaySet, index) => {
+ return callback.call(null, displaySet, index);
+ });
+ }
+ }
+
+ /**
+ * Retrieve the number of display sets within the current study.
+ * @returns {number} The number of display sets in the current study.
+ */
+ getDisplaySetCount() {
+ return this._displaySets.length;
+ }
+
+ /**
+ * Returns the StudyInstanceUID of the current study.
+ */
+ getStudyInstanceUID() {
+ return this._studyInstanceUID;
+ }
+
+ /**
+ * Getter for series
+ * @return {Array} Array of SeriesMetadata object
+ */
+ getSeries() {
+ return this._series.slice();
+ }
+
+ /**
+ * Append a series to the current study.
+ * @param {SeriesMetadata} series The series to be added to the current study.
+ * @returns {boolean} Returns true on success, false otherwise.
+ */
+ addSeries(series) {
+ let result = false;
+ if (
+ series instanceof SeriesMetadata &&
+ this.getSeriesByUID(series.getSeriesInstanceUID()) === void 0
+ ) {
+ this._series.push(series);
+ result = true;
+ }
+ return result;
+ }
+
+ /**
+ * Find a series by index.
+ * @param {number} index An integer representing a list index.
+ * @returns {SeriesMetadata} Returns a SeriesMetadata instance when found or undefined otherwise.
+ */
+ getSeriesByIndex(index) {
+ let found; // undefined by default...
+ if (Metadata.isValidIndex(index)) {
+ found = this._series[index];
+ }
+ return found;
+ }
+
+ /**
+ * Find a series by SeriesInstanceUID.
+ * @param {string} uid An UID string.
+ * @returns {SeriesMetadata} Returns a SeriesMetadata instance when found or undefined otherwise.
+ */
+ getSeriesByUID(uid) {
+ let found; // undefined by default...
+ if (Metadata.isValidUID(uid)) {
+ found = this._series.find(series => {
+ return series.getSeriesInstanceUID() === uid;
+ });
+ }
+ return found;
+ }
+
+ /**
+ * Retrieve the number of series within the current study.
+ * @returns {number} The number of series in the current study.
+ */
+ getSeriesCount() {
+ return this._series.length;
+ }
+
+ /**
+ * Retrieve the number of instances within the current study.
+ * @returns {number} The number of instances in the current study.
+ */
+ getInstanceCount() {
+ return this._series.reduce((sum, series) => {
+ return sum + series.getInstanceCount();
+ }, 0);
+ }
+
+ /**
+ * Invokes the supplied callback for each series in the current study passing
+ * two arguments: series (a SeriesMetadata instance) and index (the integer
+ * index of the series within the current study)
+ * @param {function} callback The callback function which will be invoked for each series instance.
+ * @returns {undefined} Nothing is returned.
+ */
+ forEachSeries(callback) {
+ if (Metadata.isValidCallback(callback)) {
+ this._series.forEach((series, index) => {
+ callback.call(null, series, index);
+ });
+ }
+ }
+
+ /**
+ * Find the index of a series inside the study.
+ * @param {SeriesMetadata} series An instance of the SeriesMetadata class.
+ * @returns {number} The index of the series inside the study or -1 if not found.
+ */
+ indexOfSeries(series) {
+ return this._series.indexOf(series);
+ }
+
+ /**
+ * It sorts the series based on display sets order. Each series must be an instance
+ * of SeriesMetadata and each display sets must be an instance of ImageSet.
+ * Useful example of usage:
+ * Study data provided by backend does not sort series at all and client-side
+ * needs series sorted by the same criteria used for sorting display sets.
+ */
+ sortSeriesByDisplaySets() {
+ // Object for mapping display sets' index by seriesInstanceUid
+ const displaySetsMapping = {};
+
+ // Loop through each display set to create the mapping
+ this.forEachDisplaySet((displaySet, index) => {
+ if (!(displaySet instanceof ImageSet)) {
+ throw new OHIFError(
+ `StudyMetadata::sortSeriesByDisplaySets display set at index ${index} is not an instance of ImageSet`
+ );
+ }
+
+ // In case of multiframe studies, just get the first index occurence
+ if (displaySetsMapping[displaySet.seriesInstanceUid] === void 0) {
+ displaySetsMapping[displaySet.seriesInstanceUid] = index;
+ }
+ });
+
+ // Clone of actual series
+ const actualSeries = this.getSeries();
+
+ actualSeries.forEach((series, index) => {
+ if (!(series instanceof SeriesMetadata)) {
+ throw new OHIFError(
+ `StudyMetadata::sortSeriesByDisplaySets series at index ${index} is not an instance of SeriesMetadata`
+ );
+ }
+
+ // Get the new series index
+ const seriesIndex = displaySetsMapping[series.getSeriesInstanceUID()];
+
+ // Update the series object with the new series position
+ this._series[seriesIndex] = series;
+ });
+ }
+
+ /**
+ * Compares the current study instance with another one.
+ * @param {StudyMetadata} study An instance of the StudyMetadata class.
+ * @returns {boolean} Returns true if both instances refer to the same study.
+ */
+ equals(study) {
+ const self = this;
+ return (
+ study === self ||
+ (study instanceof StudyMetadata &&
+ study.getStudyInstanceUID() === self.getStudyInstanceUID())
+ );
+ }
+
+ /**
+ * Get the first series of the current study retaining a consistent result across multiple calls.
+ * @return {SeriesMetadata} An instance of the SeriesMetadata class or null if it does not exist.
+ */
+ getFirstSeries() {
+ let series = this._firstSeries;
+ if (!(series instanceof SeriesMetadata)) {
+ series = null;
+ const found = this.getSeriesByIndex(0);
+ if (found instanceof SeriesMetadata) {
+ this._firstSeries = found;
+ series = found;
+ }
+ }
+ return series;
+ }
+
+ /**
+ * Get the first instance of the current study retaining a consistent result across multiple calls.
+ * @return {InstanceMetadata} An instance of the InstanceMetadata class or null if it does not exist.
+ */
+ getFirstInstance() {
+ let instance = this._firstInstance;
+ if (!(instance instanceof InstanceMetadata)) {
+ instance = null;
+ const firstSeries = this.getFirstSeries();
+ if (firstSeries instanceof SeriesMetadata) {
+ const found = firstSeries.getFirstInstance();
+ if (found instanceof InstanceMetadata) {
+ this._firstInstance = found;
+ instance = found;
+ }
+ }
+ }
+ return instance;
+ }
+
+ /**
+ * Search the associated series to find an specific instance using the supplied callback as criteria.
+ * The callback is passed two arguments: instance (a InstanceMetadata instance) and index (the integer
+ * index of the instance within the current series)
+ * @param {function} callback The callback function which will be invoked for each instance instance.
+ * @returns {Object} Result object containing series (SeriesMetadata) and instance (InstanceMetadata)
+ * objects or an empty object if not found.
+ */
+ findSeriesAndInstanceByInstance(callback) {
+ let result;
+
+ if (Metadata.isValidCallback(callback)) {
+ let instance;
+
+ const series = this._series.find(series => {
+ instance = series.findInstance(callback);
+ return instance instanceof InstanceMetadata;
+ });
+
+ // No series found
+ if (series instanceof SeriesMetadata) {
+ result = {
+ series,
+ instance,
+ };
+ }
+ }
+
+ return result || {};
+ }
+
+ /**
+ * Find series by instance using the supplied callback as criteria. The callback is passed
+ * two arguments: instance (a InstanceMetadata instance) and index (the integer index of
+ * the instance within its series)
+ * @param {function} callback The callback function which will be invoked for each instance.
+ * @returns {SeriesMetadata|undefined} If a series is found based on callback criteria it
+ * returns a SeriesMetadata. "undefined" is returned otherwise
+ */
+ findSeriesByInstance(callback) {
+ const result = this.findSeriesAndInstanceByInstance(callback);
+
+ return result.series;
+ }
+
+ /**
+ * Find an instance using the supplied callback as criteria. The callback is passed
+ * two arguments: instance (a InstanceMetadata instance) and index (the integer index of
+ * the instance within its series)
+ * @param {function} callback The callback function which will be invoked for each instance.
+ * @returns {InstanceMetadata|undefined} If an instance is found based on callback criteria it
+ * returns a InstanceMetadata. "undefined" is returned otherwise
+ */
+ findInstance(callback) {
+ const result = this.findSeriesAndInstanceByInstance(callback);
+
+ return result.instance;
+ }
+}
+
+/**
+ *
+ * @typedef StudyMetadata
+ * @property {function} getSeriesCount - returns the number of series in the study
+ * @property {function} forEachSeries - function that invokes callback with each series and index
+ * @property {function} getStudyInstanceUID - returns the study's instance UID
+ *
+ */
+
+/**
+ * @typedef SeriesMetadata
+ * @property {function} getSeriesInstanceUID - returns the series's instance UID
+ * @property {function} getData - ???
+ * @property {function} forEachInstance - ???
+ */
+
+const dwc = api.DICOMwebClient;
+
+const isMultiFrame = instance => {
+ // NumberOfFrames (0028,0008)
+ return instance.getRawValue('x00280008') > 1;
+};
+
+const makeDisplaySet = (series, instances) => {
+ const instance = instances[0];
+ const imageSet = new ImageSet(instances);
+ const seriesData = series.getData();
+
+ // set appropriate attributes to image set...
+ imageSet.setAttributes({
+ displaySetInstanceUid: imageSet.uid, // create a local alias for the imageSet UID
+ seriesDate: seriesData.seriesDate,
+ seriesTime: seriesData.seriesTime,
+ seriesInstanceUid: series.getSeriesInstanceUID(),
+ seriesNumber: instance.getRawValue('x00200011'),
+ seriesDescription: instance.getRawValue('x0008103e'),
+ numImageFrames: instances.length,
+ frameRate: instance.getRawValue('x00181063'),
+ modality: instance.getRawValue('x00080060'),
+ isMultiFrame: isMultiFrame(instance),
+ });
+
+ // Sort the images in this series if needed
+ const shallSort = true; //!OHIF.utils.ObjectPath.get(Meteor, 'settings.public.ui.sortSeriesByIncomingOrder');
+ if (shallSort) {
+ imageSet.sortBy((a, b) => {
+ // Sort by InstanceNumber (0020,0013)
+ return (
+ (parseInt(a.getRawValue('x00200013', 0)) || 0) -
+ (parseInt(b.getRawValue('x00200013', 0)) || 0)
+ );
+ });
+ }
+
+ // Include the first image instance number (after sorted)
+ imageSet.setAttribute(
+ 'instanceNumber',
+ imageSet.getImage(0).getRawValue('x00200013')
+ );
+
+ return imageSet;
+};
+
+const isSingleImageModality = modality => {
+ return modality === 'CR' || modality === 'MG' || modality === 'DX';
+};
+
+function getSopClassUids(series) {
+ const uniqueSopClassUidsInSeries = new Set();
+ series.forEachInstance(instance => {
+ const instanceSopClassUid = instance.getRawValue('x00080016');
+
+ uniqueSopClassUidsInSeries.add(instanceSopClassUid);
+ });
+ const sopClassUids = Array.from(uniqueSopClassUidsInSeries);
+
+ return sopClassUids;
+}
+
+/**
+ * @private
+ * @param {SeriesMetadata} series
+ * @param {StudyMetadata} study
+ * @param {string[]} sopClassUids
+ */
+function _getDisplaySetFromSopClassModule(
+ sopClassHandlerExtensions, // TODO: Update Usage
+ series,
+ study,
+ sopClassUids
+) {
+ // TODO: For now only use the plugins if all instances have the same sopClassUid
+ if (sopClassUids.length !== 1) {
+ console.warn(
+ 'getDisplaySetFromSopClassPlugin: More than one SOPClassUid in the same series is not yet supported.'
+ );
+ return;
+ }
+
+ const sopClassUid = sopClassUids[0];
+ const sopClassHandlerModules = sopClassHandlerExtensions.map(extension => {
+ return extension.module;
+ });
+
+ const handlersForSopClassUid = sopClassHandlerModules.filter(module => {
+ return module.sopClassUids.includes(sopClassUid);
+ });
+
+ // TODO: Sort by something, so we can determine which plugin to use
+ if (!handlersForSopClassUid || !handlersForSopClassUid.length) {
+ return;
+ }
+
+ const plugin = handlersForSopClassUid[0];
+ const headers = DICOMWeb.getAuthorizationHeader();
+ const dicomWebClient = new dwc({
+ url: study.getData().wadoRoot,
+ headers,
+ });
+
+ return plugin.getDisplaySetFromSeries(series, study, dicomWebClient, headers);
+}
+
+/**
+ *
+ * @param {*} a - DisplaySet
+ * @param {*} b - DisplaySet
+ */
+function _sortBySeriesNumber(a, b) {
+ const seriesNumberAIsGreaterOrUndefined =
+ a.seriesNumber > b.seriesNumber || (!a.seriesNumber && b.seriesNumber);
+
+ return seriesNumberAIsGreaterOrUndefined ? 1 : -1;
+}
diff --git a/packages/_core/src/classes/metadata/StudySummary.js b/packages/_core/src/classes/metadata/StudySummary.js
new file mode 100644
index 000000000..a9b4550d3
--- /dev/null
+++ b/packages/_core/src/classes/metadata/StudySummary.js
@@ -0,0 +1,73 @@
+import { Metadata } from './Metadata';
+import { DICOMTagDescriptions } from '../../utils/DICOMTagDescriptions';
+
+/**
+ * Constants
+ */
+
+const STUDY_INSTANCE_UID = 'x0020000d';
+
+/**
+ * Class Definition
+ */
+
+export class StudySummary extends Metadata {
+ constructor(tagMap, attributeMap, uid) {
+ // Call the superclass constructor passing an plain object with no prototype to be used as the main "_data" attribute.
+ const _data = Object.create(null);
+ super(_data, uid);
+
+ // Initialize internal tag map if first argument is given.
+ if (tagMap !== void 0) {
+ this.addTags(tagMap);
+ }
+
+ // Initialize internal property map if second argument is given.
+ if (attributeMap !== void 0) {
+ this.setCustomAttributes(attributeMap);
+ }
+ }
+
+ getStudyInstanceUID() {
+ // This method should return null if StudyInstanceUID is not available to keep compatibility StudyMetadata API
+ return this.getTagValue(STUDY_INSTANCE_UID) || null;
+ }
+
+ /**
+ * Append tags to internal tag map.
+ * @param {Object} tagMap An object whose own properties will be used as tag values and appended to internal tag map.
+ */
+ addTags(tagMap) {
+ const _hasOwn = Object.prototype.hasOwnProperty;
+ const _data = this._data;
+ for (let tag in tagMap) {
+ if (_hasOwn.call(tagMap, tag)) {
+ const description = DICOMTagDescriptions.find(tag);
+ // When a description is available, use its tag as internal key...
+ if (description) {
+ _data[description.tag] = tagMap[tag];
+ } else {
+ _data[tag] = tagMap[tag];
+ }
+ }
+ }
+ }
+
+ tagExists(tagName) {
+ const _data = this._data;
+ const description = DICOMTagDescriptions.find(tagName);
+ if (description) {
+ return description.tag in _data;
+ }
+ return tagName in _data;
+ }
+
+ getTagValue(tagName) {
+ const _data = this._data;
+ const description = DICOMTagDescriptions.find(tagName);
+ if (description) {
+ return _data[description.tag];
+ }
+ return _data[tagName];
+ }
+}
diff --git a/packages/_core/src/classes/metadata/WadoRsMetaDataBuilder.js b/packages/_core/src/classes/metadata/WadoRsMetaDataBuilder.js
new file mode 100644
index 000000000..4cd0e8a35
--- /dev/null
+++ b/packages/_core/src/classes/metadata/WadoRsMetaDataBuilder.js
@@ -0,0 +1,48 @@
+export class WadoRsMetaDataBuilder {
+ constructor() {
+ this.tags = {};
+ }
+
+ addTag(tag, value, multi) {
+ this.tags[tag] = {
+ tag,
+ value,
+ multi,
+ };
+
+ return this;
+ }
+
+ toJSON() {
+ const json = {};
+ const keys = Object.keys(this.tags);
+
+ keys.forEach(key => {
+ if (!this.tags.hasOwnProperty(key)) {
+ return;
+ }
+
+ const tag = this.tags[key];
+ const multi = !!tag.multi;
+ let value = tag.value;
+
+ if (value == null || (value.length === 1 && value[0] == null)) {
+ return;
+ }
+
+ if (typeof value === 'string' && multi) {
+ value = value.split('\\');
+ }
+
+ if (!Array.isArray(value)) {
+ value = [value];
+ }
+
+ json[key] = {
+ Value: value,
+ };
+ });
+
+ return json;
+ }
+}
diff --git a/packages/_core/src/classes/metadata/index.js b/packages/_core/src/classes/metadata/index.js
new file mode 100644
index 000000000..a3e6da50d
--- /dev/null
+++ b/packages/_core/src/classes/metadata/index.js
@@ -0,0 +1,35 @@
+import { InstanceMetadata } from './InstanceMetadata';
+import { Metadata } from './Metadata';
+import { OHIFInstanceMetadata } from './OHIFInstanceMetadata';
+import { OHIFSeriesMetadata } from './OHIFSeriesMetadata';
+import { OHIFStudyMetadata } from './OHIFStudyMetadata';
+import { SeriesMetadata } from './SeriesMetadata';
+import { StudyMetadata } from './StudyMetadata';
+import { StudySummary } from './StudySummary';
+import { WadoRsMetaDataBuilder } from './WadoRsMetaDataBuilder';
+
+const metadata = {
+ Metadata,
+ StudySummary,
+ WadoRsMetaDataBuilder,
+ StudyMetadata,
+ SeriesMetadata,
+ InstanceMetadata,
+ OHIFStudyMetadata,
+ OHIFSeriesMetadata,
+ OHIFInstanceMetadata,
+};
+
+export {
+ Metadata,
+ StudySummary,
+ WadoRsMetaDataBuilder,
+ StudyMetadata,
+ SeriesMetadata,
+ InstanceMetadata,
+ OHIFStudyMetadata,
+ OHIFSeriesMetadata,
+ OHIFInstanceMetadata,
+};
+
+export default metadata;
diff --git a/packages/_core/src/compatibility/dialogPolyfill.js b/packages/_core/src/compatibility/dialogPolyfill.js
new file mode 100644
index 000000000..6523dea08
--- /dev/null
+++ b/packages/_core/src/compatibility/dialogPolyfill.js
@@ -0,0 +1,458 @@
+var dialogPolyfill = (function() {
+ var supportCustomEvent = window.CustomEvent;
+ if (!supportCustomEvent || typeof supportCustomEvent == 'object') {
+ supportCustomEvent = function CustomEvent(event, x) {
+ x = x || {};
+ var ev = document.createEvent('CustomEvent');
+ ev.initCustomEvent(event, !!x.bubbles, !!x.cancelable, x.detail || null);
+ return ev;
+ };
+ supportCustomEvent.prototype = window.Event.prototype;
+ }
+
+ /**
+ * Finds the nearest